feat(device-core): add control resource ledger
This commit is contained in:
+610
@@ -0,0 +1,610 @@
|
|||||||
|
begin;
|
||||||
|
|
||||||
|
create table if not exists device_resource_bindings (
|
||||||
|
id uuid primary key,
|
||||||
|
owner_scope_id uuid not null,
|
||||||
|
project_id uuid not null,
|
||||||
|
binding_key text not null
|
||||||
|
check (binding_key ~ '^[a-z][a-z0-9-]{1,62}$'),
|
||||||
|
display_name text not null
|
||||||
|
check (length(btrim(display_name)) between 1 and 160),
|
||||||
|
source_kind text not null
|
||||||
|
check (source_kind in ('device', 'collection')),
|
||||||
|
device_id uuid references device_instances(id),
|
||||||
|
collection_id uuid references device_collections(id),
|
||||||
|
target_kind text not null
|
||||||
|
check (target_kind ~ '^[a-z][a-z0-9._:-]{1,63}$'),
|
||||||
|
target_ref text not null
|
||||||
|
check (length(btrim(target_ref)) between 3 and 256),
|
||||||
|
capabilities text[] not null default '{}'
|
||||||
|
check (
|
||||||
|
cardinality(capabilities) between 1 and 16
|
||||||
|
and array_position(capabilities, null) is null
|
||||||
|
),
|
||||||
|
lifecycle_state text not null default 'pending_external_approval'
|
||||||
|
check (lifecycle_state in ('pending_external_approval', 'active', 'revoked')),
|
||||||
|
source_approved_by_ref text not null
|
||||||
|
check (length(btrim(source_approved_by_ref)) between 3 and 256),
|
||||||
|
source_approved_at timestamptz not null default now(),
|
||||||
|
external_approval_ref text
|
||||||
|
check (
|
||||||
|
external_approval_ref is null
|
||||||
|
or length(btrim(external_approval_ref)) between 3 and 256
|
||||||
|
),
|
||||||
|
external_approval_digest text
|
||||||
|
check (
|
||||||
|
external_approval_digest is null
|
||||||
|
or external_approval_digest ~ '^sha256:[a-f0-9]{64}$'
|
||||||
|
),
|
||||||
|
external_approved_at timestamptz,
|
||||||
|
revoked_at timestamptz,
|
||||||
|
revoked_by_ref text
|
||||||
|
check (
|
||||||
|
revoked_by_ref is null
|
||||||
|
or length(btrim(revoked_by_ref)) between 3 and 256
|
||||||
|
),
|
||||||
|
revocation_code text
|
||||||
|
check (
|
||||||
|
revocation_code is null
|
||||||
|
or revocation_code ~ '^[a-z][a-z0-9._-]{1,63}$'
|
||||||
|
),
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
updated_at timestamptz not null default now(),
|
||||||
|
unique (project_id, binding_key),
|
||||||
|
foreign key (project_id, owner_scope_id)
|
||||||
|
references device_projects(id, owner_scope_id),
|
||||||
|
foreign key (collection_id, project_id)
|
||||||
|
references device_collections(id, project_id),
|
||||||
|
check (
|
||||||
|
(source_kind = 'device' and device_id is not null and collection_id is null)
|
||||||
|
or
|
||||||
|
(source_kind = 'collection' and device_id is null and collection_id is not null)
|
||||||
|
),
|
||||||
|
check (
|
||||||
|
(
|
||||||
|
lifecycle_state = 'pending_external_approval'
|
||||||
|
and external_approval_ref is null
|
||||||
|
and external_approval_digest is null
|
||||||
|
and external_approved_at is null
|
||||||
|
and revoked_at is null
|
||||||
|
and revoked_by_ref is null
|
||||||
|
and revocation_code is null
|
||||||
|
)
|
||||||
|
or
|
||||||
|
(
|
||||||
|
lifecycle_state = 'active'
|
||||||
|
and external_approval_ref is not null
|
||||||
|
and external_approval_digest is not null
|
||||||
|
and external_approved_at is not null
|
||||||
|
and revoked_at is null
|
||||||
|
and revoked_by_ref is null
|
||||||
|
and revocation_code is null
|
||||||
|
)
|
||||||
|
or
|
||||||
|
(
|
||||||
|
lifecycle_state = 'revoked'
|
||||||
|
and revoked_at is not null
|
||||||
|
and revoked_by_ref is not null
|
||||||
|
and revocation_code is not null
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
create index if not exists device_resource_bindings_project_state_idx
|
||||||
|
on device_resource_bindings (project_id, lifecycle_state, updated_at desc);
|
||||||
|
|
||||||
|
create index if not exists device_resource_bindings_device_state_idx
|
||||||
|
on device_resource_bindings (device_id, lifecycle_state, updated_at desc)
|
||||||
|
where device_id is not null;
|
||||||
|
|
||||||
|
create or replace function device_assert_binding_source_scope()
|
||||||
|
returns trigger
|
||||||
|
language plpgsql
|
||||||
|
as $$
|
||||||
|
begin
|
||||||
|
if new.source_kind = 'device' and not exists (
|
||||||
|
select 1 from device_instances di
|
||||||
|
where di.id = new.device_id
|
||||||
|
and di.owner_scope_id = new.owner_scope_id
|
||||||
|
and di.project_id = new.project_id
|
||||||
|
) then
|
||||||
|
raise foreign_key_violation using
|
||||||
|
message = 'device_binding_source_scope_mismatch';
|
||||||
|
end if;
|
||||||
|
return new;
|
||||||
|
end
|
||||||
|
$$;
|
||||||
|
|
||||||
|
drop trigger if exists device_resource_bindings_source_guard
|
||||||
|
on device_resource_bindings;
|
||||||
|
|
||||||
|
create trigger device_resource_bindings_source_guard
|
||||||
|
before insert or update of owner_scope_id, project_id, source_kind, device_id, collection_id
|
||||||
|
on device_resource_bindings
|
||||||
|
for each row
|
||||||
|
execute function device_assert_binding_source_scope();
|
||||||
|
|
||||||
|
create table if not exists device_configuration_revisions (
|
||||||
|
id uuid primary key,
|
||||||
|
owner_scope_id uuid not null,
|
||||||
|
project_id uuid not null,
|
||||||
|
device_id uuid not null references device_instances(id),
|
||||||
|
revision_number bigint not null check (revision_number > 0),
|
||||||
|
model_profile_ref text not null references device_model_profiles(profile_ref),
|
||||||
|
schema_artifact_ref text not null
|
||||||
|
check (length(btrim(schema_artifact_ref)) between 3 and 256),
|
||||||
|
configuration_digest text not null
|
||||||
|
check (configuration_digest ~ '^sha256:[a-f0-9]{64}$'),
|
||||||
|
configuration jsonb not null
|
||||||
|
check (
|
||||||
|
jsonb_typeof(configuration) = 'object'
|
||||||
|
and octet_length(configuration::text) <= 65536
|
||||||
|
),
|
||||||
|
change_summary text
|
||||||
|
check (change_summary is null or length(change_summary) <= 1000),
|
||||||
|
created_by_ref text not null
|
||||||
|
check (length(btrim(created_by_ref)) between 3 and 256),
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
unique (device_id, revision_number),
|
||||||
|
unique (id, device_id, project_id),
|
||||||
|
foreign key (project_id, owner_scope_id)
|
||||||
|
references device_projects(id, owner_scope_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
create index if not exists device_configuration_revisions_project_idx
|
||||||
|
on device_configuration_revisions (project_id, device_id, revision_number desc);
|
||||||
|
|
||||||
|
create or replace function device_assert_configuration_revision_scope()
|
||||||
|
returns trigger
|
||||||
|
language plpgsql
|
||||||
|
as $$
|
||||||
|
begin
|
||||||
|
if not exists (
|
||||||
|
select 1
|
||||||
|
from device_instances di
|
||||||
|
join device_model_profiles dmp
|
||||||
|
on dmp.profile_ref = di.model_profile_ref
|
||||||
|
where di.id = new.device_id
|
||||||
|
and di.owner_scope_id = new.owner_scope_id
|
||||||
|
and di.project_id = new.project_id
|
||||||
|
and di.model_profile_ref = new.model_profile_ref
|
||||||
|
and dmp.schema_artifact_ref = new.schema_artifact_ref
|
||||||
|
and dmp.lifecycle_state = 'active'
|
||||||
|
) then
|
||||||
|
raise foreign_key_violation using
|
||||||
|
message = 'device_configuration_revision_scope_mismatch';
|
||||||
|
end if;
|
||||||
|
return new;
|
||||||
|
end
|
||||||
|
$$;
|
||||||
|
|
||||||
|
drop trigger if exists device_configuration_revisions_scope_guard
|
||||||
|
on device_configuration_revisions;
|
||||||
|
|
||||||
|
create trigger device_configuration_revisions_scope_guard
|
||||||
|
before insert
|
||||||
|
on device_configuration_revisions
|
||||||
|
for each row
|
||||||
|
execute function device_assert_configuration_revision_scope();
|
||||||
|
|
||||||
|
create table if not exists device_configuration_state (
|
||||||
|
device_id uuid primary key,
|
||||||
|
owner_scope_id uuid not null,
|
||||||
|
project_id uuid not null,
|
||||||
|
desired_revision_id uuid,
|
||||||
|
applied_revision_id uuid,
|
||||||
|
applied_at timestamptz,
|
||||||
|
applied_by_ref text,
|
||||||
|
updated_at timestamptz not null default now(),
|
||||||
|
foreign key (device_id, project_id, owner_scope_id)
|
||||||
|
references device_instances(id, project_id, owner_scope_id),
|
||||||
|
foreign key (desired_revision_id, device_id, project_id)
|
||||||
|
references device_configuration_revisions(id, device_id, project_id),
|
||||||
|
foreign key (applied_revision_id, device_id, project_id)
|
||||||
|
references device_configuration_revisions(id, device_id, project_id),
|
||||||
|
check (desired_revision_id is not null or applied_revision_id is not null),
|
||||||
|
check (
|
||||||
|
(applied_revision_id is null and applied_at is null and applied_by_ref is null)
|
||||||
|
or
|
||||||
|
(applied_revision_id is not null and applied_at is not null and applied_by_ref is not null)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists device_commands (
|
||||||
|
id uuid primary key,
|
||||||
|
owner_scope_id uuid not null,
|
||||||
|
project_id uuid not null,
|
||||||
|
device_id uuid not null references device_instances(id),
|
||||||
|
command_key text not null
|
||||||
|
check (command_key ~ '^[a-z][a-z0-9-]{1,62}$'),
|
||||||
|
command_catalog_ref text not null
|
||||||
|
check (length(btrim(command_catalog_ref)) between 3 and 256),
|
||||||
|
command_type text not null
|
||||||
|
check (command_type ~ '^[a-z][a-z0-9._:-]{1,63}$'),
|
||||||
|
risk_class text not null
|
||||||
|
check (risk_class in ('low', 'moderate', 'high', 'critical')),
|
||||||
|
parameters_digest text not null
|
||||||
|
check (parameters_digest ~ '^sha256:[a-f0-9]{64}$'),
|
||||||
|
parameters_projection jsonb not null
|
||||||
|
check (
|
||||||
|
jsonb_typeof(parameters_projection) = 'object'
|
||||||
|
and octet_length(parameters_projection::text) <= 16384
|
||||||
|
),
|
||||||
|
lifecycle_state text not null default 'draft'
|
||||||
|
check (lifecycle_state in (
|
||||||
|
'draft',
|
||||||
|
'planned',
|
||||||
|
'awaiting_confirmation',
|
||||||
|
'queued',
|
||||||
|
'dispatched',
|
||||||
|
'acknowledged',
|
||||||
|
'verified',
|
||||||
|
'failed',
|
||||||
|
'expired',
|
||||||
|
'unknown'
|
||||||
|
)),
|
||||||
|
planned_by_ref text not null
|
||||||
|
check (length(btrim(planned_by_ref)) between 3 and 256),
|
||||||
|
planned_at timestamptz not null default now(),
|
||||||
|
expires_at timestamptz not null,
|
||||||
|
confirmed_by_ref text,
|
||||||
|
confirmed_at timestamptz,
|
||||||
|
dispatched_at timestamptz,
|
||||||
|
transport_message_ref text
|
||||||
|
check (
|
||||||
|
transport_message_ref is null
|
||||||
|
or length(btrim(transport_message_ref)) between 3 and 256
|
||||||
|
),
|
||||||
|
acknowledged_at timestamptz,
|
||||||
|
terminal_at timestamptz,
|
||||||
|
terminal_reason_code text
|
||||||
|
check (
|
||||||
|
terminal_reason_code is null
|
||||||
|
or terminal_reason_code ~ '^[a-z][a-z0-9._-]{1,63}$'
|
||||||
|
),
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
updated_at timestamptz not null default now(),
|
||||||
|
unique (project_id, command_key),
|
||||||
|
unique (id, device_id, project_id),
|
||||||
|
foreign key (project_id, owner_scope_id)
|
||||||
|
references device_projects(id, owner_scope_id),
|
||||||
|
check (expires_at > planned_at),
|
||||||
|
check (
|
||||||
|
(confirmed_at is null and confirmed_by_ref is null)
|
||||||
|
or
|
||||||
|
(confirmed_at is not null and confirmed_by_ref is not null)
|
||||||
|
),
|
||||||
|
check (
|
||||||
|
(
|
||||||
|
lifecycle_state in ('dispatched', 'acknowledged', 'verified', 'unknown')
|
||||||
|
and dispatched_at is not null
|
||||||
|
and transport_message_ref is not null
|
||||||
|
)
|
||||||
|
or lifecycle_state not in ('dispatched', 'acknowledged', 'verified', 'unknown')
|
||||||
|
),
|
||||||
|
check (
|
||||||
|
(lifecycle_state in ('acknowledged', 'verified') and acknowledged_at is not null)
|
||||||
|
or lifecycle_state not in ('acknowledged', 'verified')
|
||||||
|
),
|
||||||
|
check (
|
||||||
|
(
|
||||||
|
lifecycle_state in ('verified', 'failed', 'expired', 'unknown')
|
||||||
|
and terminal_at is not null
|
||||||
|
and terminal_reason_code is not null
|
||||||
|
)
|
||||||
|
or
|
||||||
|
(
|
||||||
|
lifecycle_state not in ('verified', 'failed', 'expired', 'unknown')
|
||||||
|
and terminal_at is null
|
||||||
|
and terminal_reason_code is null
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
create index if not exists device_commands_project_state_idx
|
||||||
|
on device_commands (project_id, lifecycle_state, updated_at desc);
|
||||||
|
|
||||||
|
create index if not exists device_commands_device_state_idx
|
||||||
|
on device_commands (device_id, lifecycle_state, updated_at desc);
|
||||||
|
|
||||||
|
create table if not exists device_command_events (
|
||||||
|
id uuid primary key,
|
||||||
|
command_id uuid not null,
|
||||||
|
device_id uuid not null,
|
||||||
|
project_id uuid not null,
|
||||||
|
sequence_number bigint not null check (sequence_number > 0),
|
||||||
|
from_state text,
|
||||||
|
to_state text not null
|
||||||
|
check (to_state in (
|
||||||
|
'draft',
|
||||||
|
'planned',
|
||||||
|
'awaiting_confirmation',
|
||||||
|
'queued',
|
||||||
|
'dispatched',
|
||||||
|
'acknowledged',
|
||||||
|
'verified',
|
||||||
|
'failed',
|
||||||
|
'expired',
|
||||||
|
'unknown'
|
||||||
|
)),
|
||||||
|
actor_ref text not null
|
||||||
|
check (length(btrim(actor_ref)) between 3 and 256),
|
||||||
|
reason_code text not null
|
||||||
|
check (reason_code ~ '^[a-z][a-z0-9._-]{1,63}$'),
|
||||||
|
evidence_ref text
|
||||||
|
check (
|
||||||
|
evidence_ref is null
|
||||||
|
or length(btrim(evidence_ref)) between 3 and 256
|
||||||
|
),
|
||||||
|
occurred_at timestamptz not null default now(),
|
||||||
|
unique (command_id, sequence_number),
|
||||||
|
foreign key (command_id, device_id, project_id)
|
||||||
|
references device_commands(id, device_id, project_id),
|
||||||
|
check (sequence_number = 1 or from_state is not null),
|
||||||
|
check (sequence_number <> 1 or from_state is null),
|
||||||
|
check (
|
||||||
|
from_state is null
|
||||||
|
or from_state in (
|
||||||
|
'draft',
|
||||||
|
'planned',
|
||||||
|
'awaiting_confirmation',
|
||||||
|
'queued',
|
||||||
|
'dispatched',
|
||||||
|
'acknowledged',
|
||||||
|
'verified',
|
||||||
|
'failed',
|
||||||
|
'expired',
|
||||||
|
'unknown'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
create index if not exists device_command_events_command_idx
|
||||||
|
on device_command_events (command_id, sequence_number);
|
||||||
|
|
||||||
|
create or replace function device_assert_command_scope()
|
||||||
|
returns trigger
|
||||||
|
language plpgsql
|
||||||
|
as $$
|
||||||
|
begin
|
||||||
|
if not exists (
|
||||||
|
select 1 from device_instances di
|
||||||
|
where di.id = new.device_id
|
||||||
|
and di.owner_scope_id = new.owner_scope_id
|
||||||
|
and di.project_id = new.project_id
|
||||||
|
) then
|
||||||
|
raise foreign_key_violation using message = 'device_command_scope_mismatch';
|
||||||
|
end if;
|
||||||
|
return new;
|
||||||
|
end
|
||||||
|
$$;
|
||||||
|
|
||||||
|
drop trigger if exists device_commands_scope_guard on device_commands;
|
||||||
|
|
||||||
|
create trigger device_commands_scope_guard
|
||||||
|
before insert
|
||||||
|
on device_commands
|
||||||
|
for each row
|
||||||
|
execute function device_assert_command_scope();
|
||||||
|
|
||||||
|
create or replace function device_assert_command_event_sequence()
|
||||||
|
returns trigger
|
||||||
|
language plpgsql
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
previous_state text;
|
||||||
|
begin
|
||||||
|
if new.sequence_number = 1 then
|
||||||
|
if new.from_state is not null or new.to_state <> 'draft' then
|
||||||
|
raise check_violation using message = 'device_command_initial_event_invalid';
|
||||||
|
end if;
|
||||||
|
return new;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
select dce.to_state into previous_state
|
||||||
|
from device_command_events dce
|
||||||
|
where dce.command_id = new.command_id
|
||||||
|
and dce.sequence_number = new.sequence_number - 1;
|
||||||
|
|
||||||
|
if previous_state is null or previous_state <> new.from_state then
|
||||||
|
raise check_violation using message = 'device_command_event_sequence_invalid';
|
||||||
|
end if;
|
||||||
|
if not (
|
||||||
|
(new.from_state = 'draft' and new.to_state in ('planned', 'expired'))
|
||||||
|
or (new.from_state = 'planned' and new.to_state in ('awaiting_confirmation', 'queued', 'expired'))
|
||||||
|
or (new.from_state = 'awaiting_confirmation' and new.to_state in ('queued', 'expired'))
|
||||||
|
or (new.from_state = 'queued' and new.to_state in ('dispatched', 'failed', 'expired'))
|
||||||
|
or (new.from_state = 'dispatched' and new.to_state in ('acknowledged', 'failed', 'unknown'))
|
||||||
|
or (new.from_state = 'acknowledged' and new.to_state in ('verified', 'failed', 'unknown'))
|
||||||
|
) then
|
||||||
|
raise check_violation using message = 'device_command_event_transition_invalid';
|
||||||
|
end if;
|
||||||
|
return new;
|
||||||
|
end
|
||||||
|
$$;
|
||||||
|
|
||||||
|
drop trigger if exists device_command_events_sequence_guard
|
||||||
|
on device_command_events;
|
||||||
|
|
||||||
|
create trigger device_command_events_sequence_guard
|
||||||
|
before insert
|
||||||
|
on device_command_events
|
||||||
|
for each row
|
||||||
|
execute function device_assert_command_event_sequence();
|
||||||
|
|
||||||
|
create or replace function device_assert_command_transition()
|
||||||
|
returns trigger
|
||||||
|
language plpgsql
|
||||||
|
as $$
|
||||||
|
begin
|
||||||
|
if old.lifecycle_state = new.lifecycle_state then
|
||||||
|
return new;
|
||||||
|
end if;
|
||||||
|
if not (
|
||||||
|
(old.lifecycle_state = 'draft' and new.lifecycle_state in ('planned', 'expired'))
|
||||||
|
or (old.lifecycle_state = 'planned' and new.lifecycle_state in ('awaiting_confirmation', 'queued', 'expired'))
|
||||||
|
or (old.lifecycle_state = 'awaiting_confirmation' and new.lifecycle_state in ('queued', 'expired'))
|
||||||
|
or (old.lifecycle_state = 'queued' and new.lifecycle_state in ('dispatched', 'failed', 'expired'))
|
||||||
|
or (old.lifecycle_state = 'dispatched' and new.lifecycle_state in ('acknowledged', 'failed', 'unknown'))
|
||||||
|
or (old.lifecycle_state = 'acknowledged' and new.lifecycle_state in ('verified', 'failed', 'unknown'))
|
||||||
|
) then
|
||||||
|
raise check_violation using message = 'device_command_transition_invalid';
|
||||||
|
end if;
|
||||||
|
return new;
|
||||||
|
end
|
||||||
|
$$;
|
||||||
|
|
||||||
|
drop trigger if exists device_commands_transition_guard on device_commands;
|
||||||
|
|
||||||
|
create trigger device_commands_transition_guard
|
||||||
|
before update of lifecycle_state
|
||||||
|
on device_commands
|
||||||
|
for each row
|
||||||
|
execute function device_assert_command_transition();
|
||||||
|
|
||||||
|
create or replace function device_assert_command_event_projection()
|
||||||
|
returns trigger
|
||||||
|
language plpgsql
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
current_state text;
|
||||||
|
latest_event_state text;
|
||||||
|
begin
|
||||||
|
select dc.lifecycle_state into current_state
|
||||||
|
from device_commands dc
|
||||||
|
where dc.id = new.id;
|
||||||
|
|
||||||
|
select dce.to_state into latest_event_state
|
||||||
|
from device_command_events dce
|
||||||
|
where dce.command_id = new.id
|
||||||
|
order by dce.sequence_number desc
|
||||||
|
limit 1;
|
||||||
|
|
||||||
|
if current_state is null or latest_event_state is distinct from current_state then
|
||||||
|
raise check_violation using message = 'device_command_event_projection_mismatch';
|
||||||
|
end if;
|
||||||
|
return new;
|
||||||
|
end
|
||||||
|
$$;
|
||||||
|
|
||||||
|
drop trigger if exists device_commands_event_projection_guard on device_commands;
|
||||||
|
|
||||||
|
create constraint trigger device_commands_event_projection_guard
|
||||||
|
after insert or update of lifecycle_state
|
||||||
|
on device_commands
|
||||||
|
deferrable initially deferred
|
||||||
|
for each row
|
||||||
|
execute function device_assert_command_event_projection();
|
||||||
|
|
||||||
|
create or replace function device_assert_command_current_projection()
|
||||||
|
returns trigger
|
||||||
|
language plpgsql
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
current_state text;
|
||||||
|
latest_event_state text;
|
||||||
|
begin
|
||||||
|
select dc.lifecycle_state into current_state
|
||||||
|
from device_commands dc
|
||||||
|
where dc.id = new.command_id;
|
||||||
|
|
||||||
|
select dce.to_state into latest_event_state
|
||||||
|
from device_command_events dce
|
||||||
|
where dce.command_id = new.command_id
|
||||||
|
order by dce.sequence_number desc
|
||||||
|
limit 1;
|
||||||
|
|
||||||
|
if current_state is null or latest_event_state is distinct from current_state then
|
||||||
|
raise check_violation using message = 'device_command_current_projection_mismatch';
|
||||||
|
end if;
|
||||||
|
return new;
|
||||||
|
end
|
||||||
|
$$;
|
||||||
|
|
||||||
|
drop trigger if exists device_command_events_current_projection_guard
|
||||||
|
on device_command_events;
|
||||||
|
|
||||||
|
create constraint trigger device_command_events_current_projection_guard
|
||||||
|
after insert
|
||||||
|
on device_command_events
|
||||||
|
deferrable initially deferred
|
||||||
|
for each row
|
||||||
|
execute function device_assert_command_current_projection();
|
||||||
|
|
||||||
|
create or replace function device_reject_immutable_mutation()
|
||||||
|
returns trigger
|
||||||
|
language plpgsql
|
||||||
|
as $$
|
||||||
|
begin
|
||||||
|
raise check_violation using message = 'device_immutable_record_mutation_forbidden';
|
||||||
|
end
|
||||||
|
$$;
|
||||||
|
|
||||||
|
drop trigger if exists device_configuration_revisions_immutable_guard
|
||||||
|
on device_configuration_revisions;
|
||||||
|
create trigger device_configuration_revisions_immutable_guard
|
||||||
|
before update or delete or truncate
|
||||||
|
on device_configuration_revisions
|
||||||
|
for each statement
|
||||||
|
execute function device_reject_immutable_mutation();
|
||||||
|
|
||||||
|
drop trigger if exists device_command_events_immutable_guard
|
||||||
|
on device_command_events;
|
||||||
|
create trigger device_command_events_immutable_guard
|
||||||
|
before update or delete or truncate
|
||||||
|
on device_command_events
|
||||||
|
for each statement
|
||||||
|
execute function device_reject_immutable_mutation();
|
||||||
|
|
||||||
|
drop trigger if exists device_audit_events_immutable_guard
|
||||||
|
on device_audit_events;
|
||||||
|
create trigger device_audit_events_immutable_guard
|
||||||
|
before update or delete or truncate
|
||||||
|
on device_audit_events
|
||||||
|
for each statement
|
||||||
|
execute function device_reject_immutable_mutation();
|
||||||
|
|
||||||
|
create or replace function device_require_control_resources_clear_before_transfer()
|
||||||
|
returns trigger
|
||||||
|
language plpgsql
|
||||||
|
as $$
|
||||||
|
begin
|
||||||
|
if exists (
|
||||||
|
select 1 from device_resource_bindings drb
|
||||||
|
where drb.device_id = old.id
|
||||||
|
and drb.lifecycle_state in ('pending_external_approval', 'active')
|
||||||
|
) then
|
||||||
|
raise check_violation using message = 'device_transfer_active_resource_binding';
|
||||||
|
end if;
|
||||||
|
if exists (
|
||||||
|
select 1 from device_configuration_state dcs
|
||||||
|
where dcs.device_id = old.id
|
||||||
|
and dcs.applied_revision_id is not null
|
||||||
|
) then
|
||||||
|
raise check_violation using message = 'device_transfer_applied_configuration';
|
||||||
|
end if;
|
||||||
|
if exists (
|
||||||
|
select 1 from device_commands dc
|
||||||
|
where dc.device_id = old.id
|
||||||
|
and dc.lifecycle_state not in ('verified', 'failed', 'expired', 'unknown')
|
||||||
|
) then
|
||||||
|
raise check_violation using message = 'device_transfer_nonterminal_command';
|
||||||
|
end if;
|
||||||
|
return new;
|
||||||
|
end
|
||||||
|
$$;
|
||||||
|
|
||||||
|
drop trigger if exists device_instances_control_resource_transfer_guard
|
||||||
|
on device_instances;
|
||||||
|
|
||||||
|
create trigger device_instances_control_resource_transfer_guard
|
||||||
|
before update of owner_scope_id, project_id
|
||||||
|
on device_instances
|
||||||
|
for each row
|
||||||
|
when (
|
||||||
|
old.owner_scope_id is distinct from new.owner_scope_id
|
||||||
|
or old.project_id is distinct from new.project_id
|
||||||
|
)
|
||||||
|
execute function device_require_control_resources_clear_before_transfer();
|
||||||
|
|
||||||
|
commit;
|
||||||
+31
@@ -0,0 +1,31 @@
|
|||||||
|
begin;
|
||||||
|
|
||||||
|
alter table device_management_command_receipts
|
||||||
|
drop constraint if exists device_management_command_receipts_command_kind_check;
|
||||||
|
|
||||||
|
alter table device_management_command_receipts
|
||||||
|
add constraint device_management_command_receipts_command_kind_check
|
||||||
|
check (command_kind in (
|
||||||
|
'owner_scope.ensure',
|
||||||
|
'project.ensure',
|
||||||
|
'collection.ensure',
|
||||||
|
'project_grant.upsert',
|
||||||
|
'adapter_package.ensure',
|
||||||
|
'adapter_version.register',
|
||||||
|
'model_profile.register',
|
||||||
|
'edge.ensure',
|
||||||
|
'route.ensure',
|
||||||
|
'enrollment_intent.ensure',
|
||||||
|
'device.claim',
|
||||||
|
'device.transfer',
|
||||||
|
'discovery.reject',
|
||||||
|
'discovery.expire',
|
||||||
|
'device_credential_binding.upsert',
|
||||||
|
'device_credential_binding.revoke',
|
||||||
|
'device_binding.ensure',
|
||||||
|
'device_binding.revoke',
|
||||||
|
'device_configuration_revision.create',
|
||||||
|
'device_configuration_desired.set'
|
||||||
|
));
|
||||||
|
|
||||||
|
commit;
|
||||||
@@ -35,6 +35,16 @@ const managementRoutes = new Map([
|
|||||||
"/internal/v1/management/device-credential-bindings:revoke",
|
"/internal/v1/management/device-credential-bindings:revoke",
|
||||||
"device_credential_binding.revoke",
|
"device_credential_binding.revoke",
|
||||||
],
|
],
|
||||||
|
["/internal/v1/management/device-bindings:ensure", "device_binding.ensure"],
|
||||||
|
["/internal/v1/management/device-bindings:revoke", "device_binding.revoke"],
|
||||||
|
[
|
||||||
|
"/internal/v1/management/device-configuration-revisions:create",
|
||||||
|
"device_configuration_revision.create",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"/internal/v1/management/device-configurations:set-desired",
|
||||||
|
"device_configuration_desired.set",
|
||||||
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export function createControlCoreApp({
|
export function createControlCoreApp({
|
||||||
|
|||||||
@@ -0,0 +1,284 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
|
||||||
|
import {
|
||||||
|
DEVICE_BINDING_CAPABILITIES,
|
||||||
|
assertSafeProjection,
|
||||||
|
} from "../../../packages/device-protocol-contract/src/index.mjs";
|
||||||
|
|
||||||
|
export const DEVICE_CONTROL_RESOURCE_COMMAND_KINDS = Object.freeze([
|
||||||
|
"device_binding.ensure",
|
||||||
|
"device_binding.revoke",
|
||||||
|
"device_configuration_revision.create",
|
||||||
|
"device_configuration_desired.set",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const commandKindSet = new Set(DEVICE_CONTROL_RESOURCE_COMMAND_KINDS);
|
||||||
|
const bindingCapabilitySet = new Set(DEVICE_BINDING_CAPABILITIES);
|
||||||
|
const keyPattern = /^[a-z][a-z0-9-]{1,62}$/;
|
||||||
|
const tokenPattern = /^[a-z][a-z0-9._:-]{1,63}$/;
|
||||||
|
const resolutionPattern = /^[a-z][a-z0-9._-]{1,63}$/;
|
||||||
|
const targetRefPattern = /^[A-Za-z0-9][A-Za-z0-9._:/+-]{2,255}$/;
|
||||||
|
const configurationKeyPattern = /^[a-z][a-z0-9._-]{0,63}$/;
|
||||||
|
const secretReferencePattern = /^(?:ndc-credref:|(?:bearer|basic)\s)|[?&](?:token|secret|password|api[_-]?key)=/i;
|
||||||
|
|
||||||
|
export function isControlResourceManagementCommand(kind) {
|
||||||
|
return commandKindSet.has(kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeControlResourceManagementCommand(kind, input) {
|
||||||
|
if (!commandKindSet.has(kind)) {
|
||||||
|
throw new TypeError("device_control_resource_command_kind_invalid");
|
||||||
|
}
|
||||||
|
assertPlainObject(input, "device_control_resource_command_invalid");
|
||||||
|
|
||||||
|
if (kind === "device_binding.ensure") {
|
||||||
|
assertAllowedKeys(input, [
|
||||||
|
"projectRef",
|
||||||
|
"bindingKey",
|
||||||
|
"displayName",
|
||||||
|
"source",
|
||||||
|
"targetKind",
|
||||||
|
"targetRef",
|
||||||
|
"capabilities",
|
||||||
|
]);
|
||||||
|
return Object.freeze({
|
||||||
|
projectId: normalizeEntityRef(input.projectRef, "project"),
|
||||||
|
bindingKey: normalizePattern(
|
||||||
|
input.bindingKey,
|
||||||
|
keyPattern,
|
||||||
|
"device_binding_key_invalid",
|
||||||
|
),
|
||||||
|
displayName: normalizeDisplayText(
|
||||||
|
input.displayName,
|
||||||
|
160,
|
||||||
|
"device_binding_name_invalid",
|
||||||
|
),
|
||||||
|
source: normalizeBindingSource(input.source),
|
||||||
|
targetKind: normalizePattern(
|
||||||
|
input.targetKind,
|
||||||
|
tokenPattern,
|
||||||
|
"device_binding_target_kind_invalid",
|
||||||
|
),
|
||||||
|
targetRef: normalizeTargetRef(input.targetRef),
|
||||||
|
capabilities: Object.freeze(normalizeBindingCapabilities(
|
||||||
|
input.capabilities,
|
||||||
|
)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kind === "device_binding.revoke") {
|
||||||
|
assertAllowedKeys(input, ["projectRef", "bindingRef", "resolutionCode"]);
|
||||||
|
return Object.freeze({
|
||||||
|
projectId: normalizeEntityRef(input.projectRef, "project"),
|
||||||
|
bindingId: normalizeEntityRef(input.bindingRef, "binding"),
|
||||||
|
resolutionCode: normalizePattern(
|
||||||
|
input.resolutionCode,
|
||||||
|
resolutionPattern,
|
||||||
|
"device_binding_resolution_code_invalid",
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kind === "device_configuration_revision.create") {
|
||||||
|
assertAllowedKeys(input, [
|
||||||
|
"projectRef",
|
||||||
|
"deviceRef",
|
||||||
|
"configuration",
|
||||||
|
"changeSummary",
|
||||||
|
]);
|
||||||
|
const configuration = normalizeDeviceConfiguration(input.configuration);
|
||||||
|
return Object.freeze({
|
||||||
|
projectId: normalizeEntityRef(input.projectRef, "project"),
|
||||||
|
deviceId: normalizeEntityRef(input.deviceRef, "device"),
|
||||||
|
configuration,
|
||||||
|
configurationDigest: `sha256:${createHash("sha256")
|
||||||
|
.update(JSON.stringify(configuration), "utf8")
|
||||||
|
.digest("hex")}`,
|
||||||
|
changeSummary: normalizeOptionalText(
|
||||||
|
input.changeSummary,
|
||||||
|
1000,
|
||||||
|
"device_configuration_change_summary_invalid",
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
assertAllowedKeys(input, [
|
||||||
|
"projectRef",
|
||||||
|
"deviceRef",
|
||||||
|
"configurationRevisionRef",
|
||||||
|
]);
|
||||||
|
return Object.freeze({
|
||||||
|
projectId: normalizeEntityRef(input.projectRef, "project"),
|
||||||
|
deviceId: normalizeEntityRef(input.deviceRef, "device"),
|
||||||
|
configurationRevisionId: normalizeEntityRef(
|
||||||
|
input.configurationRevisionRef,
|
||||||
|
"configuration-revision",
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeDeviceConfiguration(input) {
|
||||||
|
const normalized = normalizeConfigurationValue(input, 0, "$configuration");
|
||||||
|
if (!normalized || typeof normalized !== "object" || Array.isArray(normalized)) {
|
||||||
|
throw new TypeError("device_configuration_must_be_object");
|
||||||
|
}
|
||||||
|
if (Object.keys(normalized).length === 0) {
|
||||||
|
throw new TypeError("device_configuration_must_not_be_empty");
|
||||||
|
}
|
||||||
|
const serialized = JSON.stringify(normalized);
|
||||||
|
if (Buffer.byteLength(serialized, "utf8") > 32768) {
|
||||||
|
throw new TypeError("device_configuration_too_large");
|
||||||
|
}
|
||||||
|
assertSafeProjection({ configuration: normalized });
|
||||||
|
return deepFreeze(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeBindingSource(input) {
|
||||||
|
assertPlainObject(input, "device_binding_source_invalid");
|
||||||
|
assertAllowedKeys(input, ["kind", "ref"]);
|
||||||
|
if (input.kind === "device") {
|
||||||
|
return Object.freeze({
|
||||||
|
kind: "device",
|
||||||
|
id: normalizeEntityRef(input.ref, "device"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (input.kind === "collection") {
|
||||||
|
return Object.freeze({
|
||||||
|
kind: "collection",
|
||||||
|
id: normalizeEntityRef(input.ref, "collection"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw new TypeError("device_binding_source_kind_invalid");
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeBindingCapabilities(input) {
|
||||||
|
if (!Array.isArray(input) || input.length < 1 || input.length > 16) {
|
||||||
|
throw new TypeError("device_binding_capabilities_invalid");
|
||||||
|
}
|
||||||
|
const normalized = input.map((value) => {
|
||||||
|
if (typeof value !== "string" || !bindingCapabilitySet.has(value)) {
|
||||||
|
throw new TypeError("device_binding_capability_invalid");
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
});
|
||||||
|
if (new Set(normalized).size !== normalized.length) {
|
||||||
|
throw new TypeError("device_binding_capabilities_duplicate");
|
||||||
|
}
|
||||||
|
return normalized.sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTargetRef(value) {
|
||||||
|
if (
|
||||||
|
typeof value !== "string"
|
||||||
|
|| !targetRefPattern.test(value)
|
||||||
|
|| secretReferencePattern.test(value)
|
||||||
|
) {
|
||||||
|
throw new TypeError("device_binding_target_ref_invalid");
|
||||||
|
}
|
||||||
|
assertSafeProjection({ targetRef: value });
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeConfigurationValue(value, depth, path) {
|
||||||
|
if (depth > 5) throw new TypeError("device_configuration_depth_exceeded");
|
||||||
|
if (value === null || typeof value === "boolean") return value;
|
||||||
|
if (typeof value === "number") {
|
||||||
|
if (!Number.isFinite(value)) {
|
||||||
|
throw new TypeError(`device_configuration_number_invalid:${path}`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (typeof value === "string") {
|
||||||
|
if (
|
||||||
|
value.length > 1000
|
||||||
|
|| /\u0000|[\u0001-\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(value)
|
||||||
|
) {
|
||||||
|
throw new TypeError(`device_configuration_string_invalid:${path}`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
if (value.length > 64) {
|
||||||
|
throw new TypeError(`device_configuration_array_invalid:${path}`);
|
||||||
|
}
|
||||||
|
return value.map((item, index) =>
|
||||||
|
normalizeConfigurationValue(item, depth + 1, `${path}[${index}]`)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assertPlainObject(value, `device_configuration_object_invalid:${path}`);
|
||||||
|
const keys = Object.keys(value);
|
||||||
|
if (keys.length > 64) {
|
||||||
|
throw new TypeError(`device_configuration_object_invalid:${path}`);
|
||||||
|
}
|
||||||
|
const normalized = {};
|
||||||
|
for (const key of keys.sort()) {
|
||||||
|
if (!configurationKeyPattern.test(key)) {
|
||||||
|
throw new TypeError(`device_configuration_key_invalid:${path}.${key}`);
|
||||||
|
}
|
||||||
|
normalized[key] = normalizeConfigurationValue(
|
||||||
|
value[key],
|
||||||
|
depth + 1,
|
||||||
|
`${path}.${key}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeEntityRef(value, prefix) {
|
||||||
|
if (typeof value !== "string") {
|
||||||
|
throw new TypeError(`device_${prefix}_ref_invalid`);
|
||||||
|
}
|
||||||
|
const match = value.match(new RegExp(
|
||||||
|
`^${prefix}:([0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})$`,
|
||||||
|
"i",
|
||||||
|
));
|
||||||
|
if (!match) throw new TypeError(`device_${prefix}_ref_invalid`);
|
||||||
|
return match[1].toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePattern(value, pattern, code) {
|
||||||
|
if (typeof value !== "string" || !pattern.test(value)) {
|
||||||
|
throw new TypeError(code);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDisplayText(value, maxLength, code) {
|
||||||
|
if (typeof value !== "string") throw new TypeError(code);
|
||||||
|
const normalized = value.trim();
|
||||||
|
if (
|
||||||
|
normalized.length < 1
|
||||||
|
|| normalized.length > maxLength
|
||||||
|
|| /\u0000|[\u0001-\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(normalized)
|
||||||
|
) {
|
||||||
|
throw new TypeError(code);
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeOptionalText(value, maxLength, code) {
|
||||||
|
if (value == null || value === "") return null;
|
||||||
|
return normalizeDisplayText(value, maxLength, code);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertPlainObject(value, code) {
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||||
|
throw new TypeError(code);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertAllowedKeys(input, allowed) {
|
||||||
|
const allowedSet = new Set(allowed);
|
||||||
|
for (const key of Object.keys(input)) {
|
||||||
|
if (!allowedSet.has(key)) {
|
||||||
|
throw new TypeError(`device_management_command_field_unexpected:${key}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function deepFreeze(value) {
|
||||||
|
if (!value || typeof value !== "object" || Object.isFrozen(value)) return value;
|
||||||
|
Object.freeze(value);
|
||||||
|
for (const child of Object.values(value)) deepFreeze(child);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
@@ -0,0 +1,498 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
|
||||||
|
import {
|
||||||
|
isControlResourceManagementCommand,
|
||||||
|
} from "./control-resource-management.mjs";
|
||||||
|
import { findProjectWithCapability } from "./lifecycle-repository.mjs";
|
||||||
|
import { toProjectRef } from "./project-management.mjs";
|
||||||
|
|
||||||
|
export async function applyControlResourceManagementCommand(
|
||||||
|
client,
|
||||||
|
{ commandKind, actor, command },
|
||||||
|
) {
|
||||||
|
if (!isControlResourceManagementCommand(commandKind)) {
|
||||||
|
throw new TypeError("device_control_resource_command_kind_invalid");
|
||||||
|
}
|
||||||
|
if (commandKind === "device_binding.ensure") {
|
||||||
|
return ensureBinding(client, actor, command);
|
||||||
|
}
|
||||||
|
if (commandKind === "device_binding.revoke") {
|
||||||
|
return revokeBinding(client, actor, command);
|
||||||
|
}
|
||||||
|
if (commandKind === "device_configuration_revision.create") {
|
||||||
|
return createConfigurationRevision(client, actor, command);
|
||||||
|
}
|
||||||
|
return setDesiredConfiguration(client, actor, command);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function authorizeControlResourceManagementReplay(
|
||||||
|
client,
|
||||||
|
{ commandKind, actor, command },
|
||||||
|
) {
|
||||||
|
if (!isControlResourceManagementCommand(commandKind)) {
|
||||||
|
throw new TypeError("device_control_resource_command_kind_invalid");
|
||||||
|
}
|
||||||
|
const capability = commandKind.startsWith("device_binding.")
|
||||||
|
? "binding.manage"
|
||||||
|
: "configuration.manage";
|
||||||
|
await findProjectWithCapability(
|
||||||
|
client,
|
||||||
|
actor,
|
||||||
|
command.projectId,
|
||||||
|
capability,
|
||||||
|
);
|
||||||
|
if (command.deviceId) {
|
||||||
|
const current = await client.query(
|
||||||
|
`select project_id from device_instances where id = $1`,
|
||||||
|
[command.deviceId],
|
||||||
|
);
|
||||||
|
const currentProjectId = current.rows[0]?.project_id;
|
||||||
|
if (!currentProjectId) throw domainError("device_not_found", 404);
|
||||||
|
if (currentProjectId !== command.projectId) {
|
||||||
|
await findProjectWithCapability(
|
||||||
|
client,
|
||||||
|
actor,
|
||||||
|
currentProjectId,
|
||||||
|
capability,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureBinding(client, actor, command) {
|
||||||
|
const project = await findProjectWithCapability(
|
||||||
|
client,
|
||||||
|
actor,
|
||||||
|
command.projectId,
|
||||||
|
"binding.manage",
|
||||||
|
);
|
||||||
|
const source = await findBindingSource(client, command);
|
||||||
|
const bindingId = randomUUID();
|
||||||
|
const result = await client.query(
|
||||||
|
`insert into device_resource_bindings (
|
||||||
|
id,
|
||||||
|
owner_scope_id,
|
||||||
|
project_id,
|
||||||
|
binding_key,
|
||||||
|
display_name,
|
||||||
|
source_kind,
|
||||||
|
device_id,
|
||||||
|
collection_id,
|
||||||
|
target_kind,
|
||||||
|
target_ref,
|
||||||
|
capabilities,
|
||||||
|
source_approved_by_ref
|
||||||
|
) values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||||
|
on conflict (project_id, binding_key) do update set
|
||||||
|
display_name = excluded.display_name,
|
||||||
|
capabilities = excluded.capabilities,
|
||||||
|
updated_at = now()
|
||||||
|
where device_resource_bindings.lifecycle_state = 'pending_external_approval'
|
||||||
|
and device_resource_bindings.source_kind = excluded.source_kind
|
||||||
|
and device_resource_bindings.device_id is not distinct from excluded.device_id
|
||||||
|
and device_resource_bindings.collection_id is not distinct from excluded.collection_id
|
||||||
|
and device_resource_bindings.target_kind = excluded.target_kind
|
||||||
|
and device_resource_bindings.target_ref = excluded.target_ref
|
||||||
|
returning id, owner_scope_id, project_id, binding_key, display_name,
|
||||||
|
source_kind, device_id, collection_id, target_kind, target_ref,
|
||||||
|
capabilities, lifecycle_state, source_approved_at, created_at, updated_at,
|
||||||
|
(xmax = 0) as created`,
|
||||||
|
[
|
||||||
|
bindingId,
|
||||||
|
project.owner_scope_id,
|
||||||
|
project.id,
|
||||||
|
command.bindingKey,
|
||||||
|
command.displayName,
|
||||||
|
command.source.kind,
|
||||||
|
command.source.kind === "device" ? source.id : null,
|
||||||
|
command.source.kind === "collection" ? source.id : null,
|
||||||
|
command.targetKind,
|
||||||
|
command.targetRef,
|
||||||
|
command.capabilities,
|
||||||
|
actor.userRef,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
const binding = result.rows[0];
|
||||||
|
if (!binding) throw domainError("device_binding_identity_conflict", 409);
|
||||||
|
|
||||||
|
await addAudit(client, {
|
||||||
|
eventType: binding.created
|
||||||
|
? "device_binding.created"
|
||||||
|
: "device_binding.updated",
|
||||||
|
actorRef: actor.userRef,
|
||||||
|
projectId: project.id,
|
||||||
|
deviceId: binding.device_id,
|
||||||
|
payload: {
|
||||||
|
bindingRef: `binding:${binding.id}`,
|
||||||
|
projectRef: toProjectRef(project.id),
|
||||||
|
bindingKey: binding.binding_key,
|
||||||
|
sourceKind: binding.source_kind,
|
||||||
|
sourceRef: bindingSourceRef(binding),
|
||||||
|
targetKind: binding.target_kind,
|
||||||
|
targetRef: binding.target_ref,
|
||||||
|
lifecycleState: binding.lifecycle_state,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
created: binding.created === true,
|
||||||
|
binding: bindingView(binding),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function revokeBinding(client, actor, command) {
|
||||||
|
const project = await findProjectWithCapability(
|
||||||
|
client,
|
||||||
|
actor,
|
||||||
|
command.projectId,
|
||||||
|
"binding.manage",
|
||||||
|
);
|
||||||
|
const result = await client.query(
|
||||||
|
`update device_resource_bindings
|
||||||
|
set lifecycle_state = 'revoked',
|
||||||
|
revoked_at = now(),
|
||||||
|
revoked_by_ref = $3,
|
||||||
|
revocation_code = $4,
|
||||||
|
updated_at = now()
|
||||||
|
where id = $1
|
||||||
|
and project_id = $2
|
||||||
|
and lifecycle_state <> 'revoked'
|
||||||
|
returning id, owner_scope_id, project_id, binding_key, display_name,
|
||||||
|
source_kind, device_id, collection_id, target_kind, target_ref,
|
||||||
|
capabilities, lifecycle_state, source_approved_at, created_at, updated_at`,
|
||||||
|
[command.bindingId, project.id, actor.userRef, command.resolutionCode],
|
||||||
|
);
|
||||||
|
const binding = result.rows[0];
|
||||||
|
if (!binding) throw domainError("device_binding_not_found", 404);
|
||||||
|
|
||||||
|
await addAudit(client, {
|
||||||
|
eventType: "device_binding.revoked",
|
||||||
|
actorRef: actor.userRef,
|
||||||
|
projectId: project.id,
|
||||||
|
deviceId: binding.device_id,
|
||||||
|
payload: {
|
||||||
|
bindingRef: `binding:${binding.id}`,
|
||||||
|
projectRef: toProjectRef(project.id),
|
||||||
|
sourceKind: binding.source_kind,
|
||||||
|
sourceRef: bindingSourceRef(binding),
|
||||||
|
targetKind: binding.target_kind,
|
||||||
|
targetRef: binding.target_ref,
|
||||||
|
lifecycleState: binding.lifecycle_state,
|
||||||
|
resolutionCode: command.resolutionCode,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
revoked: true,
|
||||||
|
binding: bindingView(binding),
|
||||||
|
resolutionCode: command.resolutionCode,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createConfigurationRevision(client, actor, command) {
|
||||||
|
const project = await findProjectWithCapability(
|
||||||
|
client,
|
||||||
|
actor,
|
||||||
|
command.projectId,
|
||||||
|
"configuration.manage",
|
||||||
|
);
|
||||||
|
const device = await findDirectDeviceForUpdate(client, command);
|
||||||
|
const profileResult = await client.query(
|
||||||
|
`select profile_ref, schema_artifact_ref, lifecycle_state
|
||||||
|
from device_model_profiles
|
||||||
|
where profile_ref = $1
|
||||||
|
for share`,
|
||||||
|
[device.model_profile_ref],
|
||||||
|
);
|
||||||
|
const profile = profileResult.rows[0];
|
||||||
|
if (
|
||||||
|
!profile
|
||||||
|
|| profile.lifecycle_state !== "active"
|
||||||
|
|| !profile.schema_artifact_ref
|
||||||
|
) {
|
||||||
|
throw domainError("device_configuration_profile_unavailable", 409);
|
||||||
|
}
|
||||||
|
const nextResult = await client.query(
|
||||||
|
`select coalesce(max(revision_number), 0) + 1 as next_revision
|
||||||
|
from device_configuration_revisions
|
||||||
|
where device_id = $1`,
|
||||||
|
[device.id],
|
||||||
|
);
|
||||||
|
const revisionNumber = Number(nextResult.rows[0]?.next_revision);
|
||||||
|
if (!Number.isSafeInteger(revisionNumber) || revisionNumber < 1) {
|
||||||
|
throw domainError("device_configuration_revision_sequence_invalid", 409);
|
||||||
|
}
|
||||||
|
const revisionId = randomUUID();
|
||||||
|
const inserted = await client.query(
|
||||||
|
`insert into device_configuration_revisions (
|
||||||
|
id,
|
||||||
|
owner_scope_id,
|
||||||
|
project_id,
|
||||||
|
device_id,
|
||||||
|
revision_number,
|
||||||
|
model_profile_ref,
|
||||||
|
schema_artifact_ref,
|
||||||
|
configuration_digest,
|
||||||
|
configuration,
|
||||||
|
change_summary,
|
||||||
|
created_by_ref
|
||||||
|
) values ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, $10, $11)
|
||||||
|
returning id, owner_scope_id, project_id, device_id, revision_number,
|
||||||
|
model_profile_ref, schema_artifact_ref, configuration_digest,
|
||||||
|
configuration, change_summary, created_at`,
|
||||||
|
[
|
||||||
|
revisionId,
|
||||||
|
project.owner_scope_id,
|
||||||
|
project.id,
|
||||||
|
device.id,
|
||||||
|
revisionNumber,
|
||||||
|
profile.profile_ref,
|
||||||
|
profile.schema_artifact_ref,
|
||||||
|
command.configurationDigest,
|
||||||
|
JSON.stringify(command.configuration),
|
||||||
|
command.changeSummary,
|
||||||
|
actor.userRef,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
const revision = inserted.rows[0];
|
||||||
|
if (!revision) {
|
||||||
|
throw domainError("device_configuration_revision_insert_failed", 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
await addAudit(client, {
|
||||||
|
eventType: "device_configuration_revision.created",
|
||||||
|
actorRef: actor.userRef,
|
||||||
|
projectId: project.id,
|
||||||
|
deviceId: device.id,
|
||||||
|
payload: {
|
||||||
|
deviceRef: `device:${device.id}`,
|
||||||
|
projectRef: toProjectRef(project.id),
|
||||||
|
configurationRevisionRef: `configuration-revision:${revision.id}`,
|
||||||
|
revisionNumber: Number(revision.revision_number),
|
||||||
|
modelProfileRef: revision.model_profile_ref,
|
||||||
|
schemaArtifactRef: revision.schema_artifact_ref,
|
||||||
|
configurationDigest: revision.configuration_digest,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
created: true,
|
||||||
|
configurationRevision: configurationRevisionView(revision),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setDesiredConfiguration(client, actor, command) {
|
||||||
|
const project = await findProjectWithCapability(
|
||||||
|
client,
|
||||||
|
actor,
|
||||||
|
command.projectId,
|
||||||
|
"configuration.manage",
|
||||||
|
);
|
||||||
|
const device = await findDirectDeviceForUpdate(client, command);
|
||||||
|
const revisionResult = await client.query(
|
||||||
|
`select id, project_id, device_id, revision_number,
|
||||||
|
model_profile_ref, schema_artifact_ref, configuration_digest,
|
||||||
|
configuration, change_summary, created_at
|
||||||
|
from device_configuration_revisions
|
||||||
|
where id = $1 and device_id = $2 and project_id = $3
|
||||||
|
for share`,
|
||||||
|
[command.configurationRevisionId, device.id, project.id],
|
||||||
|
);
|
||||||
|
const revision = revisionResult.rows[0];
|
||||||
|
if (!revision) throw domainError("device_configuration_revision_not_found", 404);
|
||||||
|
|
||||||
|
const currentResult = await client.query(
|
||||||
|
`select desired_revision_id, applied_revision_id
|
||||||
|
from device_configuration_state
|
||||||
|
where device_id = $1
|
||||||
|
for update`,
|
||||||
|
[device.id],
|
||||||
|
);
|
||||||
|
const current = currentResult.rows[0] ?? null;
|
||||||
|
if (current?.desired_revision_id === revision.id) {
|
||||||
|
return {
|
||||||
|
changed: false,
|
||||||
|
configurationState: configurationStateView({
|
||||||
|
device_id: device.id,
|
||||||
|
project_id: project.id,
|
||||||
|
desired_revision_id: revision.id,
|
||||||
|
applied_revision_id: current.applied_revision_id,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const stateResult = await client.query(
|
||||||
|
`insert into device_configuration_state (
|
||||||
|
device_id,
|
||||||
|
owner_scope_id,
|
||||||
|
project_id,
|
||||||
|
desired_revision_id
|
||||||
|
) values ($1, $2, $3, $4)
|
||||||
|
on conflict (device_id) do update set
|
||||||
|
owner_scope_id = excluded.owner_scope_id,
|
||||||
|
project_id = excluded.project_id,
|
||||||
|
desired_revision_id = excluded.desired_revision_id,
|
||||||
|
updated_at = now()
|
||||||
|
returning device_id, project_id, desired_revision_id, applied_revision_id`,
|
||||||
|
[device.id, project.owner_scope_id, project.id, revision.id],
|
||||||
|
);
|
||||||
|
const state = stateResult.rows[0];
|
||||||
|
if (!state) throw domainError("device_configuration_state_update_failed", 409);
|
||||||
|
|
||||||
|
await addAudit(client, {
|
||||||
|
eventType: "device_configuration.desired_changed",
|
||||||
|
actorRef: actor.userRef,
|
||||||
|
projectId: project.id,
|
||||||
|
deviceId: device.id,
|
||||||
|
payload: {
|
||||||
|
deviceRef: `device:${device.id}`,
|
||||||
|
projectRef: toProjectRef(project.id),
|
||||||
|
configurationRevisionRef: `configuration-revision:${revision.id}`,
|
||||||
|
previousConfigurationRevisionRef: current?.desired_revision_id
|
||||||
|
? `configuration-revision:${current.desired_revision_id}`
|
||||||
|
: null,
|
||||||
|
configurationDigest: revision.configuration_digest,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
changed: true,
|
||||||
|
configurationState: configurationStateView(state),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findBindingSource(client, command) {
|
||||||
|
if (command.source.kind === "device") {
|
||||||
|
return findDirectDeviceForUpdate(client, {
|
||||||
|
projectId: command.projectId,
|
||||||
|
deviceId: command.source.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const result = await client.query(
|
||||||
|
`select id, project_id, lifecycle_state
|
||||||
|
from device_collections
|
||||||
|
where id = $1 and project_id = $2
|
||||||
|
for share`,
|
||||||
|
[command.source.id, command.projectId],
|
||||||
|
);
|
||||||
|
const collection = result.rows[0];
|
||||||
|
if (!collection) throw domainError("device_collection_not_found", 404);
|
||||||
|
if (collection.lifecycle_state !== "active") {
|
||||||
|
throw domainError("device_collection_inactive", 409);
|
||||||
|
}
|
||||||
|
return collection;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findDirectDeviceForUpdate(client, command) {
|
||||||
|
const result = await client.query(
|
||||||
|
`select id, contour_id, owner_scope_id, project_id,
|
||||||
|
model_profile_ref, lifecycle_state
|
||||||
|
from device_instances
|
||||||
|
where id = $1
|
||||||
|
for update`,
|
||||||
|
[command.deviceId],
|
||||||
|
);
|
||||||
|
const device = result.rows[0];
|
||||||
|
if (!device) throw domainError("device_not_found", 404);
|
||||||
|
if (
|
||||||
|
device.contour_id
|
||||||
|
|| !device.owner_scope_id
|
||||||
|
|| !device.project_id
|
||||||
|
|| device.project_id !== command.projectId
|
||||||
|
) {
|
||||||
|
throw domainError("device_control_resource_project_mismatch", 409);
|
||||||
|
}
|
||||||
|
if (device.lifecycle_state === "retired") {
|
||||||
|
throw domainError("device_control_resource_lifecycle_blocked", 409);
|
||||||
|
}
|
||||||
|
return device;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addAudit(client, {
|
||||||
|
eventType,
|
||||||
|
actorRef,
|
||||||
|
projectId,
|
||||||
|
deviceId = null,
|
||||||
|
payload,
|
||||||
|
}) {
|
||||||
|
await client.query(
|
||||||
|
`insert into device_audit_events (
|
||||||
|
id,
|
||||||
|
event_type,
|
||||||
|
actor_ref,
|
||||||
|
project_id,
|
||||||
|
device_id,
|
||||||
|
payload
|
||||||
|
) values ($1, $2, $3, $4, $5, $6::jsonb)`,
|
||||||
|
[
|
||||||
|
randomUUID(),
|
||||||
|
eventType,
|
||||||
|
actorRef,
|
||||||
|
projectId,
|
||||||
|
deviceId,
|
||||||
|
JSON.stringify(payload),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindingView(row) {
|
||||||
|
return {
|
||||||
|
bindingRef: `binding:${row.id}`,
|
||||||
|
projectRef: toProjectRef(row.project_id),
|
||||||
|
bindingKey: row.binding_key,
|
||||||
|
displayName: row.display_name,
|
||||||
|
source: {
|
||||||
|
kind: row.source_kind,
|
||||||
|
ref: bindingSourceRef(row),
|
||||||
|
},
|
||||||
|
target: {
|
||||||
|
kind: row.target_kind,
|
||||||
|
ref: row.target_ref,
|
||||||
|
},
|
||||||
|
capabilities: row.capabilities ?? [],
|
||||||
|
lifecycleState: row.lifecycle_state,
|
||||||
|
sourceApprovedAt: toIso(row.source_approved_at),
|
||||||
|
createdAt: toIso(row.created_at),
|
||||||
|
updatedAt: toIso(row.updated_at),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindingSourceRef(row) {
|
||||||
|
return row.source_kind === "device"
|
||||||
|
? `device:${row.device_id}`
|
||||||
|
: `collection:${row.collection_id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function configurationRevisionView(row) {
|
||||||
|
return {
|
||||||
|
configurationRevisionRef: `configuration-revision:${row.id}`,
|
||||||
|
deviceRef: `device:${row.device_id}`,
|
||||||
|
projectRef: toProjectRef(row.project_id),
|
||||||
|
revisionNumber: Number(row.revision_number),
|
||||||
|
modelProfileRef: row.model_profile_ref,
|
||||||
|
schemaArtifactRef: row.schema_artifact_ref,
|
||||||
|
configurationDigest: row.configuration_digest,
|
||||||
|
configuration: row.configuration,
|
||||||
|
changeSummary: row.change_summary ?? null,
|
||||||
|
createdAt: toIso(row.created_at),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function configurationStateView(row) {
|
||||||
|
return {
|
||||||
|
deviceRef: `device:${row.device_id}`,
|
||||||
|
projectRef: toProjectRef(row.project_id),
|
||||||
|
desiredConfigurationRevisionRef: row.desired_revision_id
|
||||||
|
? `configuration-revision:${row.desired_revision_id}`
|
||||||
|
: null,
|
||||||
|
appliedConfigurationRevisionRef: row.applied_revision_id
|
||||||
|
? `configuration-revision:${row.applied_revision_id}`
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toIso(value) {
|
||||||
|
return new Date(value).toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function domainError(code, statusCode) {
|
||||||
|
const error = new Error(code);
|
||||||
|
error.statusCode = statusCode;
|
||||||
|
return error;
|
||||||
|
}
|
||||||
@@ -371,6 +371,47 @@ async function transferDevice(client, actor, command) {
|
|||||||
throw domainError("device_transfer_active_credential_binding", 409);
|
throw domainError("device_transfer_active_credential_binding", 409);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const activeResourceBindings = await client.query(
|
||||||
|
`select exists (
|
||||||
|
select 1 from device_resource_bindings
|
||||||
|
where device_id = $1
|
||||||
|
and lifecycle_state in ('pending_external_approval', 'active')
|
||||||
|
) as active`,
|
||||||
|
[device.id],
|
||||||
|
);
|
||||||
|
if (activeResourceBindings.rows[0]?.active === true) {
|
||||||
|
throw domainError("device_transfer_active_resource_binding", 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
const configurationState = await client.query(
|
||||||
|
`select desired_revision_id, applied_revision_id
|
||||||
|
from device_configuration_state
|
||||||
|
where device_id = $1
|
||||||
|
for update`,
|
||||||
|
[device.id],
|
||||||
|
);
|
||||||
|
if (configurationState.rows[0]?.applied_revision_id) {
|
||||||
|
throw domainError("device_transfer_applied_configuration", 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nonterminalCommands = await client.query(
|
||||||
|
`select exists (
|
||||||
|
select 1 from device_commands
|
||||||
|
where device_id = $1
|
||||||
|
and lifecycle_state not in ('verified', 'failed', 'expired', 'unknown')
|
||||||
|
) as active`,
|
||||||
|
[device.id],
|
||||||
|
);
|
||||||
|
if (nonterminalCommands.rows[0]?.active === true) {
|
||||||
|
throw domainError("device_transfer_nonterminal_command", 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearedConfiguration = await client.query(
|
||||||
|
`delete from device_configuration_state
|
||||||
|
where device_id = $1 and applied_revision_id is null`,
|
||||||
|
[device.id],
|
||||||
|
);
|
||||||
|
|
||||||
const detached = await client.query(
|
const detached = await client.query(
|
||||||
`delete from device_collection_members
|
`delete from device_collection_members
|
||||||
where device_id = $1 and project_id = $2`,
|
where device_id = $1 and project_id = $2`,
|
||||||
@@ -437,6 +478,7 @@ async function transferDevice(client, actor, command) {
|
|||||||
targetProjectRef: toProjectRef(targetProject.id),
|
targetProjectRef: toProjectRef(targetProject.id),
|
||||||
detachedCollectionCount: Number(detached.rowCount || 0),
|
detachedCollectionCount: Number(detached.rowCount || 0),
|
||||||
transferredIdentifierCount: Number(movedIdentifiers.rowCount || 0),
|
transferredIdentifierCount: Number(movedIdentifiers.rowCount || 0),
|
||||||
|
clearedDesiredConfiguration: Number(clearedConfiguration.rowCount || 0) > 0,
|
||||||
};
|
};
|
||||||
await addAudit(client, {
|
await addAudit(client, {
|
||||||
eventType: "device.transferred_out",
|
eventType: "device.transferred_out",
|
||||||
@@ -459,6 +501,7 @@ async function transferDevice(client, actor, command) {
|
|||||||
ownershipTransitionRef: `ownership-transition:${transitionId}`,
|
ownershipTransitionRef: `ownership-transition:${transitionId}`,
|
||||||
detachedCollectionCount: Number(detached.rowCount || 0),
|
detachedCollectionCount: Number(detached.rowCount || 0),
|
||||||
transferredIdentifierCount: Number(movedIdentifiers.rowCount || 0),
|
transferredIdentifierCount: Number(movedIdentifiers.rowCount || 0),
|
||||||
|
clearedDesiredConfiguration: Number(clearedConfiguration.rowCount || 0) > 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,11 @@ import {
|
|||||||
isInfrastructureManagementCommand,
|
isInfrastructureManagementCommand,
|
||||||
normalizeInfrastructureManagementCommand,
|
normalizeInfrastructureManagementCommand,
|
||||||
} from "./infrastructure-management.mjs";
|
} from "./infrastructure-management.mjs";
|
||||||
|
import {
|
||||||
|
DEVICE_CONTROL_RESOURCE_COMMAND_KINDS,
|
||||||
|
isControlResourceManagementCommand,
|
||||||
|
normalizeControlResourceManagementCommand,
|
||||||
|
} from "./control-resource-management.mjs";
|
||||||
import {
|
import {
|
||||||
DEVICE_LIFECYCLE_COMMAND_KINDS,
|
DEVICE_LIFECYCLE_COMMAND_KINDS,
|
||||||
isLifecycleManagementCommand,
|
isLifecycleManagementCommand,
|
||||||
@@ -23,9 +28,13 @@ export const ALL_DEVICE_MANAGEMENT_COMMAND_KINDS = Object.freeze([
|
|||||||
...DEVICE_INFRASTRUCTURE_COMMAND_KINDS,
|
...DEVICE_INFRASTRUCTURE_COMMAND_KINDS,
|
||||||
...DEVICE_LIFECYCLE_COMMAND_KINDS,
|
...DEVICE_LIFECYCLE_COMMAND_KINDS,
|
||||||
...DEVICE_SENSITIVE_REFERENCE_COMMAND_KINDS,
|
...DEVICE_SENSITIVE_REFERENCE_COMMAND_KINDS,
|
||||||
|
...DEVICE_CONTROL_RESOURCE_COMMAND_KINDS,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export function normalizeDeviceManagementCommand(kind, input) {
|
export function normalizeDeviceManagementCommand(kind, input) {
|
||||||
|
if (isControlResourceManagementCommand(kind)) {
|
||||||
|
return normalizeControlResourceManagementCommand(kind, input);
|
||||||
|
}
|
||||||
if (isSensitiveReferenceManagementCommand(kind)) {
|
if (isSensitiveReferenceManagementCommand(kind)) {
|
||||||
return normalizeSensitiveReferenceManagementCommand(kind, input);
|
return normalizeSensitiveReferenceManagementCommand(kind, input);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,13 @@ import pg from "pg";
|
|||||||
|
|
||||||
import { ARUSNAVI_B2_MODEL_PROFILE } from "../../../packages/arusnavi-b2-adapter/src/index.mjs";
|
import { ARUSNAVI_B2_MODEL_PROFILE } from "../../../packages/arusnavi-b2-adapter/src/index.mjs";
|
||||||
import { observeQuarantineDiscovery } from "./discovery-repository.mjs";
|
import { observeQuarantineDiscovery } from "./discovery-repository.mjs";
|
||||||
|
import {
|
||||||
|
applyControlResourceManagementCommand,
|
||||||
|
authorizeControlResourceManagementReplay,
|
||||||
|
} from "./control-resource-repository.mjs";
|
||||||
|
import {
|
||||||
|
isControlResourceManagementCommand,
|
||||||
|
} from "./control-resource-management.mjs";
|
||||||
import {
|
import {
|
||||||
applyInfrastructureManagementCommand,
|
applyInfrastructureManagementCommand,
|
||||||
authorizeInfrastructureManagementReplay,
|
authorizeInfrastructureManagementReplay,
|
||||||
@@ -43,6 +50,8 @@ const migrationFiles = [
|
|||||||
"007_device_lifecycle_commands.sql",
|
"007_device_lifecycle_commands.sql",
|
||||||
"008_device_sensitive_references.sql",
|
"008_device_sensitive_references.sql",
|
||||||
"009_device_sensitive_reference_commands.sql",
|
"009_device_sensitive_reference_commands.sql",
|
||||||
|
"010_device_control_resources.sql",
|
||||||
|
"011_device_control_resource_commands.sql",
|
||||||
];
|
];
|
||||||
|
|
||||||
export class PostgresDeviceRepository {
|
export class PostgresDeviceRepository {
|
||||||
@@ -226,6 +235,13 @@ async function completeManagementReceipt(client, receiptId, result) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function applyManagementCommand(client, { commandKind, actor, command }) {
|
async function applyManagementCommand(client, { commandKind, actor, command }) {
|
||||||
|
if (isControlResourceManagementCommand(commandKind)) {
|
||||||
|
return applyControlResourceManagementCommand(client, {
|
||||||
|
commandKind,
|
||||||
|
actor,
|
||||||
|
command,
|
||||||
|
});
|
||||||
|
}
|
||||||
if (isSensitiveReferenceManagementCommand(commandKind)) {
|
if (isSensitiveReferenceManagementCommand(commandKind)) {
|
||||||
return applySensitiveReferenceManagementCommand(client, {
|
return applySensitiveReferenceManagementCommand(client, {
|
||||||
commandKind,
|
commandKind,
|
||||||
@@ -263,6 +279,13 @@ async function applyManagementCommand(client, { commandKind, actor, command }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function authorizeManagementReplay(client, { commandKind, actor, command }) {
|
async function authorizeManagementReplay(client, { commandKind, actor, command }) {
|
||||||
|
if (isControlResourceManagementCommand(commandKind)) {
|
||||||
|
return authorizeControlResourceManagementReplay(client, {
|
||||||
|
commandKind,
|
||||||
|
actor,
|
||||||
|
command,
|
||||||
|
});
|
||||||
|
}
|
||||||
if (isSensitiveReferenceManagementCommand(commandKind)) {
|
if (isSensitiveReferenceManagementCommand(commandKind)) {
|
||||||
return authorizeSensitiveReferenceManagementReplay(client, {
|
return authorizeSensitiveReferenceManagementReplay(client, {
|
||||||
commandKind,
|
commandKind,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export const DEVICE_PROJECT_CAPABILITIES = Object.freeze([
|
|||||||
"binding.manage",
|
"binding.manage",
|
||||||
"telemetry.observe",
|
"telemetry.observe",
|
||||||
"configuration.read",
|
"configuration.read",
|
||||||
|
"configuration.manage",
|
||||||
"command.plan",
|
"command.plan",
|
||||||
"command.confirm",
|
"command.confirm",
|
||||||
"command.dispatch",
|
"command.dispatch",
|
||||||
@@ -76,6 +77,7 @@ const roleCapabilities = Object.freeze({
|
|||||||
"binding.manage",
|
"binding.manage",
|
||||||
"telemetry.observe",
|
"telemetry.observe",
|
||||||
"configuration.read",
|
"configuration.read",
|
||||||
|
"configuration.manage",
|
||||||
"command.plan",
|
"command.plan",
|
||||||
"audit.read",
|
"audit.read",
|
||||||
]),
|
]),
|
||||||
@@ -91,6 +93,7 @@ const roleCapabilities = Object.freeze({
|
|||||||
"binding.manage",
|
"binding.manage",
|
||||||
"telemetry.observe",
|
"telemetry.observe",
|
||||||
"configuration.read",
|
"configuration.read",
|
||||||
|
"configuration.manage",
|
||||||
"command.plan",
|
"command.plan",
|
||||||
"command.confirm",
|
"command.confirm",
|
||||||
"command.dispatch",
|
"command.dispatch",
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import {
|
||||||
|
DEVICE_CONTROL_RESOURCE_COMMAND_KINDS,
|
||||||
|
normalizeControlResourceManagementCommand,
|
||||||
|
} from "../src/control-resource-management.mjs";
|
||||||
|
import {
|
||||||
|
ALL_DEVICE_MANAGEMENT_COMMAND_KINDS,
|
||||||
|
normalizeDeviceManagementCommand,
|
||||||
|
} from "../src/management-command.mjs";
|
||||||
|
|
||||||
|
const projectRef = "project:11111111-1111-4111-8111-111111111111";
|
||||||
|
const deviceRef = "device:22222222-2222-4222-8222-222222222222";
|
||||||
|
const collectionRef = "collection:33333333-3333-4333-8333-333333333333";
|
||||||
|
const bindingRef = "binding:44444444-4444-4444-8444-444444444444";
|
||||||
|
const revisionRef =
|
||||||
|
"configuration-revision:55555555-5555-4555-8555-555555555555";
|
||||||
|
|
||||||
|
test("control resource commands join the strict idempotent surface", () => {
|
||||||
|
for (const kind of DEVICE_CONTROL_RESOURCE_COMMAND_KINDS) {
|
||||||
|
assert.equal(ALL_DEVICE_MANAGEMENT_COMMAND_KINDS.includes(kind), true);
|
||||||
|
}
|
||||||
|
assert.equal(
|
||||||
|
normalizeDeviceManagementCommand(
|
||||||
|
"device_binding.ensure",
|
||||||
|
bindingInput(),
|
||||||
|
).projectId,
|
||||||
|
projectRef.slice("project:".length),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("binding input is source-scoped and cannot claim external approval", () => {
|
||||||
|
const command = normalizeControlResourceManagementCommand(
|
||||||
|
"device_binding.ensure",
|
||||||
|
bindingInput(),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(command.source, {
|
||||||
|
kind: "collection",
|
||||||
|
id: collectionRef.slice("collection:".length),
|
||||||
|
});
|
||||||
|
assert.deepEqual(command.capabilities, ["inspect", "observe"]);
|
||||||
|
assert.equal("lifecycleState" in command, false);
|
||||||
|
assert.equal("externalApprovalRef" in command, false);
|
||||||
|
assert.throws(
|
||||||
|
() => normalizeControlResourceManagementCommand(
|
||||||
|
"device_binding.ensure",
|
||||||
|
{ ...bindingInput(), externalApprovalRef: "approval:forged" },
|
||||||
|
),
|
||||||
|
/device_management_command_field_unexpected:externalApprovalRef/,
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => normalizeControlResourceManagementCommand(
|
||||||
|
"device_binding.ensure",
|
||||||
|
{ ...bindingInput(), targetRef: "ndc-credref:must-not-be-a-target" },
|
||||||
|
),
|
||||||
|
/device_binding_target_ref_invalid/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("binding revoke uses only project, binding and bounded reason refs", () => {
|
||||||
|
const command = normalizeControlResourceManagementCommand(
|
||||||
|
"device_binding.revoke",
|
||||||
|
{
|
||||||
|
projectRef,
|
||||||
|
bindingRef,
|
||||||
|
resolutionCode: "operator.unbound",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(command.bindingId, bindingRef.slice("binding:".length));
|
||||||
|
assert.equal(command.resolutionCode, "operator.unbound");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("configuration is canonical, bounded and secret-free before hashing", () => {
|
||||||
|
const first = normalizeControlResourceManagementCommand(
|
||||||
|
"device_configuration_revision.create",
|
||||||
|
{
|
||||||
|
projectRef,
|
||||||
|
deviceRef,
|
||||||
|
configuration: {
|
||||||
|
reporting_interval_seconds: 15,
|
||||||
|
motion: { enabled: true, threshold: 3.5 },
|
||||||
|
channels: ["gps", "voltage"],
|
||||||
|
},
|
||||||
|
changeSummary: "Pilot reporting profile",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const reordered = normalizeControlResourceManagementCommand(
|
||||||
|
"device_configuration_revision.create",
|
||||||
|
{
|
||||||
|
projectRef,
|
||||||
|
deviceRef,
|
||||||
|
configuration: {
|
||||||
|
channels: ["gps", "voltage"],
|
||||||
|
motion: { threshold: 3.5, enabled: true },
|
||||||
|
reporting_interval_seconds: 15,
|
||||||
|
},
|
||||||
|
changeSummary: "Pilot reporting profile",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(first.configurationDigest, reordered.configurationDigest);
|
||||||
|
assert.equal(Object.isFrozen(first.configuration.motion), true);
|
||||||
|
assert.throws(
|
||||||
|
() => normalizeControlResourceManagementCommand(
|
||||||
|
"device_configuration_revision.create",
|
||||||
|
{
|
||||||
|
projectRef,
|
||||||
|
deviceRef,
|
||||||
|
configuration: { api_token: "forbidden" },
|
||||||
|
},
|
||||||
|
),
|
||||||
|
/forbidden_device_field/,
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => normalizeControlResourceManagementCommand(
|
||||||
|
"device_configuration_revision.create",
|
||||||
|
{
|
||||||
|
projectRef,
|
||||||
|
deviceRef,
|
||||||
|
configuration: { tracker_imei: "000000000000001" },
|
||||||
|
},
|
||||||
|
),
|
||||||
|
/safe_projection_contains_unmasked_imei/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("desired configuration binds one exact immutable revision", () => {
|
||||||
|
const command = normalizeControlResourceManagementCommand(
|
||||||
|
"device_configuration_desired.set",
|
||||||
|
{
|
||||||
|
projectRef,
|
||||||
|
deviceRef,
|
||||||
|
configurationRevisionRef: revisionRef,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(command.deviceId, deviceRef.slice("device:".length));
|
||||||
|
assert.equal(
|
||||||
|
command.configurationRevisionId,
|
||||||
|
revisionRef.slice("configuration-revision:".length),
|
||||||
|
);
|
||||||
|
assert.equal("applied" in command, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
function bindingInput() {
|
||||||
|
return {
|
||||||
|
projectRef,
|
||||||
|
bindingKey: "robot2b-map",
|
||||||
|
displayName: "Robot2B map binding",
|
||||||
|
source: { kind: "collection", ref: collectionRef },
|
||||||
|
targetKind: "foundry.application",
|
||||||
|
targetRef: "foundry-application:robot2b-test",
|
||||||
|
capabilities: ["observe", "inspect"],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
const schemaUrl = new URL(
|
||||||
|
"../migrations/010_device_control_resources.sql",
|
||||||
|
import.meta.url,
|
||||||
|
);
|
||||||
|
const commandsUrl = new URL(
|
||||||
|
"../migrations/011_device_control_resource_commands.sql",
|
||||||
|
import.meta.url,
|
||||||
|
);
|
||||||
|
const repositoryUrl = new URL("../src/postgres-repository.mjs", import.meta.url);
|
||||||
|
const appUrl = new URL("../src/app.mjs", import.meta.url);
|
||||||
|
|
||||||
|
test("control resource schema separates bindings, revisions and current state", async () => {
|
||||||
|
const sql = await readFile(schemaUrl, "utf8");
|
||||||
|
|
||||||
|
assert.match(sql, /create table if not exists device_resource_bindings/);
|
||||||
|
assert.match(sql, /pending_external_approval/);
|
||||||
|
assert.match(sql, /external_approval_digest/);
|
||||||
|
assert.match(sql, /device_binding_source_scope_mismatch/);
|
||||||
|
assert.match(sql, /create table if not exists device_configuration_revisions/);
|
||||||
|
assert.match(sql, /create table if not exists device_configuration_state/);
|
||||||
|
assert.match(sql, /device_configuration_revision_scope_mismatch/);
|
||||||
|
assert.match(sql, /unique \(device_id, revision_number\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("command ledger has honest ordered states without a transport API", async () => {
|
||||||
|
const sql = await readFile(schemaUrl, "utf8");
|
||||||
|
const app = await readFile(appUrl, "utf8");
|
||||||
|
|
||||||
|
assert.match(sql, /create table if not exists device_commands/);
|
||||||
|
assert.match(sql, /create table if not exists device_command_events/);
|
||||||
|
for (const state of [
|
||||||
|
"draft",
|
||||||
|
"planned",
|
||||||
|
"awaiting_confirmation",
|
||||||
|
"queued",
|
||||||
|
"dispatched",
|
||||||
|
"acknowledged",
|
||||||
|
"verified",
|
||||||
|
"failed",
|
||||||
|
"expired",
|
||||||
|
"unknown",
|
||||||
|
]) {
|
||||||
|
assert.match(sql, new RegExp(`'${state}'`));
|
||||||
|
}
|
||||||
|
assert.match(sql, /device_command_initial_event_invalid/);
|
||||||
|
assert.match(sql, /device_command_event_sequence_invalid/);
|
||||||
|
assert.match(sql, /device_command_event_transition_invalid/);
|
||||||
|
assert.match(sql, /device_command_event_projection_mismatch/);
|
||||||
|
assert.match(sql, /device_command_events_current_projection_guard/);
|
||||||
|
assert.match(sql, /device_command_current_projection_mismatch/);
|
||||||
|
assert.doesNotMatch(app, /device-commands:(?:plan|confirm|dispatch)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("configuration, command history and audit are append-only", async () => {
|
||||||
|
const sql = await readFile(schemaUrl, "utf8");
|
||||||
|
|
||||||
|
for (const table of [
|
||||||
|
"device_configuration_revisions",
|
||||||
|
"device_command_events",
|
||||||
|
"device_audit_events",
|
||||||
|
]) {
|
||||||
|
assert.match(
|
||||||
|
sql,
|
||||||
|
new RegExp(`${table}_immutable_guard[\\s\\S]*before update or delete or truncate`),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert.match(sql, /device_immutable_record_mutation_forbidden/);
|
||||||
|
assert.match(sql, /device_transfer_active_resource_binding/);
|
||||||
|
assert.match(sql, /device_transfer_applied_configuration/);
|
||||||
|
assert.match(sql, /device_transfer_nonterminal_command/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("control resource schema contains no seeded device or raw secret material", async () => {
|
||||||
|
const sql = await readFile(schemaUrl, "utf8");
|
||||||
|
|
||||||
|
assert.doesNotMatch(sql, /insert\s+into/i);
|
||||||
|
assert.doesNotMatch(sql, /dcctouch|arusnavi|gelios|\bb2\b|imei/i);
|
||||||
|
assert.doesNotMatch(sql, /password\s+text|token\s+text|secret\s+text|raw_command|raw_packet/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("commands extend receipts only after their schema", async () => {
|
||||||
|
const commands = await readFile(commandsUrl, "utf8");
|
||||||
|
const repository = await readFile(repositoryUrl, "utf8");
|
||||||
|
|
||||||
|
for (const kind of [
|
||||||
|
"device_binding.ensure",
|
||||||
|
"device_binding.revoke",
|
||||||
|
"device_configuration_revision.create",
|
||||||
|
"device_configuration_desired.set",
|
||||||
|
]) {
|
||||||
|
assert.match(commands, new RegExp(`'${kind.replace(".", "\\.")}'`));
|
||||||
|
}
|
||||||
|
const schemaIndex = repository.indexOf("010_device_control_resources.sql");
|
||||||
|
const commandsIndex = repository.indexOf(
|
||||||
|
"011_device_control_resource_commands.sql",
|
||||||
|
);
|
||||||
|
assert.notEqual(schemaIndex, -1);
|
||||||
|
assert.notEqual(commandsIndex, -1);
|
||||||
|
assert.ok(schemaIndex < commandsIndex);
|
||||||
|
});
|
||||||
@@ -0,0 +1,313 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import { assertSafeProjection } from "../../../packages/device-protocol-contract/src/index.mjs";
|
||||||
|
import { normalizeDeviceManagementCommand } from "../src/management-command.mjs";
|
||||||
|
import { PostgresDeviceRepository } from "../src/postgres-repository.mjs";
|
||||||
|
import { normalizeManagementActor } from "../src/project-management.mjs";
|
||||||
|
|
||||||
|
const now = new Date("2026-08-10T00:00:00.000Z");
|
||||||
|
const projectId = "11111111-1111-4111-8111-111111111111";
|
||||||
|
const ownerId = "22222222-2222-4222-8222-222222222222";
|
||||||
|
const deviceId = "33333333-3333-4333-8333-333333333333";
|
||||||
|
const collectionId = "44444444-4444-4444-8444-444444444444";
|
||||||
|
const bindingId = "55555555-5555-4555-8555-555555555555";
|
||||||
|
const revisionId = "66666666-6666-4666-8666-666666666666";
|
||||||
|
|
||||||
|
test("creates only a pending collection binding owned by the source project", async () => {
|
||||||
|
const actor = managementActor();
|
||||||
|
const command = normalizeDeviceManagementCommand("device_binding.ensure", {
|
||||||
|
projectRef: `project:${projectId}`,
|
||||||
|
bindingKey: "robot2b-map",
|
||||||
|
displayName: "Robot2B map binding",
|
||||||
|
source: { kind: "collection", ref: `collection:${collectionId}` },
|
||||||
|
targetKind: "foundry.application",
|
||||||
|
targetRef: "foundry-application:robot2b-test",
|
||||||
|
capabilities: ["observe", "inspect"],
|
||||||
|
});
|
||||||
|
const client = scriptedClient([
|
||||||
|
step("begin"),
|
||||||
|
receiptStep("receipt-binding"),
|
||||||
|
projectStep(),
|
||||||
|
grantsStep(actor),
|
||||||
|
step("from device_collections", {
|
||||||
|
rows: [{ id: collectionId, project_id: projectId, lifecycle_state: "active" }],
|
||||||
|
}),
|
||||||
|
step("insert into device_resource_bindings", {
|
||||||
|
rows: [bindingRow({ source_kind: "collection", device_id: null })],
|
||||||
|
}),
|
||||||
|
step("insert into device_audit_events"),
|
||||||
|
step("update device_management_command_receipts"),
|
||||||
|
step("commit"),
|
||||||
|
]);
|
||||||
|
const repository = repositoryWithClient(client);
|
||||||
|
|
||||||
|
const result = await repository.executeManagementCommand(commandInput({
|
||||||
|
actor,
|
||||||
|
commandKind: "device_binding.ensure",
|
||||||
|
command,
|
||||||
|
digestCharacter: "a",
|
||||||
|
}));
|
||||||
|
|
||||||
|
assert.equal(result.result.binding.lifecycleState, "pending_external_approval");
|
||||||
|
assert.equal(result.result.binding.source.ref, `collection:${collectionId}`);
|
||||||
|
assert.equal("externalApprovalRef" in result.result.binding, false);
|
||||||
|
assertSafeProjection(result.result);
|
||||||
|
assert.equal(client.remaining(), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("creates an immutable configuration revision from the active profile schema", async () => {
|
||||||
|
const actor = managementActor();
|
||||||
|
const command = createConfigurationCommand();
|
||||||
|
const client = scriptedClient([
|
||||||
|
step("begin"),
|
||||||
|
receiptStep("receipt-configuration-revision"),
|
||||||
|
projectStep(),
|
||||||
|
grantsStep(actor),
|
||||||
|
step("from device_instances", { rows: [deviceRow()] }),
|
||||||
|
step("from device_model_profiles", {
|
||||||
|
rows: [{
|
||||||
|
profile_ref: "vendor.model.protocol.v1",
|
||||||
|
schema_artifact_ref: "schema:vendor.model.protocol.v1",
|
||||||
|
lifecycle_state: "active",
|
||||||
|
}],
|
||||||
|
}),
|
||||||
|
step("from device_configuration_revisions", {
|
||||||
|
rows: [{ next_revision: "1" }],
|
||||||
|
}),
|
||||||
|
step("insert into device_configuration_revisions", {
|
||||||
|
rows: [configurationRevisionRow({
|
||||||
|
configuration_digest: command.configurationDigest,
|
||||||
|
configuration: command.configuration,
|
||||||
|
})],
|
||||||
|
}),
|
||||||
|
step("insert into device_audit_events"),
|
||||||
|
step("update device_management_command_receipts"),
|
||||||
|
step("commit"),
|
||||||
|
]);
|
||||||
|
const repository = repositoryWithClient(client);
|
||||||
|
|
||||||
|
const result = await repository.executeManagementCommand(commandInput({
|
||||||
|
actor,
|
||||||
|
commandKind: "device_configuration_revision.create",
|
||||||
|
command,
|
||||||
|
digestCharacter: "b",
|
||||||
|
}));
|
||||||
|
|
||||||
|
assert.equal(result.result.configurationRevision.revisionNumber, 1);
|
||||||
|
assert.deepEqual(result.result.configurationRevision.configuration, {
|
||||||
|
reporting_interval_seconds: 15,
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
result.result.configurationRevision.schemaArtifactRef,
|
||||||
|
"schema:vendor.model.protocol.v1",
|
||||||
|
);
|
||||||
|
assertSafeProjection(result.result);
|
||||||
|
assert.equal(client.remaining(), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sets desired configuration without claiming runtime apply", async () => {
|
||||||
|
const actor = managementActor();
|
||||||
|
const command = normalizeDeviceManagementCommand(
|
||||||
|
"device_configuration_desired.set",
|
||||||
|
{
|
||||||
|
projectRef: `project:${projectId}`,
|
||||||
|
deviceRef: `device:${deviceId}`,
|
||||||
|
configurationRevisionRef: `configuration-revision:${revisionId}`,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const client = scriptedClient([
|
||||||
|
step("begin"),
|
||||||
|
receiptStep("receipt-configuration-desired"),
|
||||||
|
projectStep(),
|
||||||
|
grantsStep(actor),
|
||||||
|
step("from device_instances", { rows: [deviceRow()] }),
|
||||||
|
step("from device_configuration_revisions", {
|
||||||
|
rows: [configurationRevisionRow()],
|
||||||
|
}),
|
||||||
|
step("from device_configuration_state", { rows: [] }),
|
||||||
|
step("insert into device_configuration_state", {
|
||||||
|
rows: [{
|
||||||
|
device_id: deviceId,
|
||||||
|
project_id: projectId,
|
||||||
|
desired_revision_id: revisionId,
|
||||||
|
applied_revision_id: null,
|
||||||
|
}],
|
||||||
|
}),
|
||||||
|
step("insert into device_audit_events"),
|
||||||
|
step("update device_management_command_receipts"),
|
||||||
|
step("commit"),
|
||||||
|
]);
|
||||||
|
const repository = repositoryWithClient(client);
|
||||||
|
|
||||||
|
const result = await repository.executeManagementCommand(commandInput({
|
||||||
|
actor,
|
||||||
|
commandKind: "device_configuration_desired.set",
|
||||||
|
command,
|
||||||
|
digestCharacter: "c",
|
||||||
|
}));
|
||||||
|
|
||||||
|
assert.equal(result.result.changed, true);
|
||||||
|
assert.equal(
|
||||||
|
result.result.configurationState.desiredConfigurationRevisionRef,
|
||||||
|
`configuration-revision:${revisionId}`,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
result.result.configurationState.appliedConfigurationRevisionRef,
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
assert.equal("applied" in result.result, false);
|
||||||
|
assert.equal(client.remaining(), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
function createConfigurationCommand() {
|
||||||
|
return normalizeDeviceManagementCommand(
|
||||||
|
"device_configuration_revision.create",
|
||||||
|
{
|
||||||
|
projectRef: `project:${projectId}`,
|
||||||
|
deviceRef: `device:${deviceId}`,
|
||||||
|
configuration: { reporting_interval_seconds: 15 },
|
||||||
|
changeSummary: "Pilot reporting profile",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function managementActor() {
|
||||||
|
return normalizeManagementActor({
|
||||||
|
userRef: "user:device-engineer",
|
||||||
|
hubRole: "member",
|
||||||
|
groupRefs: [],
|
||||||
|
ownerScopes: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function projectStep() {
|
||||||
|
return step("from device_projects p", {
|
||||||
|
rows: [{
|
||||||
|
id: projectId,
|
||||||
|
owner_scope_id: ownerId,
|
||||||
|
lifecycle_state: "active",
|
||||||
|
scope_kind: "company",
|
||||||
|
owner_ref: "client:example-company",
|
||||||
|
owner_display_name: "Example Company",
|
||||||
|
owner_lifecycle_state: "active",
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function grantsStep(actor) {
|
||||||
|
return step("from device_project_grants", {
|
||||||
|
rows: [{
|
||||||
|
id: "77777777-7777-4777-8777-777777777777",
|
||||||
|
principal_kind: "user",
|
||||||
|
principal_ref: actor.userRef,
|
||||||
|
project_role: "engineer",
|
||||||
|
capability_allow: [],
|
||||||
|
capability_deny: [],
|
||||||
|
lifecycle_state: "active",
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function deviceRow() {
|
||||||
|
return {
|
||||||
|
id: deviceId,
|
||||||
|
contour_id: null,
|
||||||
|
owner_scope_id: ownerId,
|
||||||
|
project_id: projectId,
|
||||||
|
model_profile_ref: "vendor.model.protocol.v1",
|
||||||
|
lifecycle_state: "claimed",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindingRow(overrides = {}) {
|
||||||
|
return {
|
||||||
|
id: bindingId,
|
||||||
|
owner_scope_id: ownerId,
|
||||||
|
project_id: projectId,
|
||||||
|
binding_key: "robot2b-map",
|
||||||
|
display_name: "Robot2B map binding",
|
||||||
|
source_kind: "device",
|
||||||
|
device_id: deviceId,
|
||||||
|
collection_id: collectionId,
|
||||||
|
target_kind: "foundry.application",
|
||||||
|
target_ref: "foundry-application:robot2b-test",
|
||||||
|
capabilities: ["inspect", "observe"],
|
||||||
|
lifecycle_state: "pending_external_approval",
|
||||||
|
source_approved_at: now,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
created: true,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function configurationRevisionRow(overrides = {}) {
|
||||||
|
return {
|
||||||
|
id: revisionId,
|
||||||
|
owner_scope_id: ownerId,
|
||||||
|
project_id: projectId,
|
||||||
|
device_id: deviceId,
|
||||||
|
revision_number: "1",
|
||||||
|
model_profile_ref: "vendor.model.protocol.v1",
|
||||||
|
schema_artifact_ref: "schema:vendor.model.protocol.v1",
|
||||||
|
configuration_digest: `sha256:${"d".repeat(64)}`,
|
||||||
|
configuration: { reporting_interval_seconds: 15 },
|
||||||
|
change_summary: "Pilot reporting profile",
|
||||||
|
created_at: now,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function receiptStep(id) {
|
||||||
|
return step("insert into device_management_command_receipts", {
|
||||||
|
rows: [{ id }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function commandInput({ actor, commandKind, command, digestCharacter }) {
|
||||||
|
return {
|
||||||
|
idempotencyKey: `phase25-${commandKind.replaceAll(".", "-")}-0001`,
|
||||||
|
commandKind,
|
||||||
|
requestDigest: `sha256:${digestCharacter.repeat(64)}`,
|
||||||
|
actor,
|
||||||
|
command,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function repositoryWithClient(client) {
|
||||||
|
return new PostgresDeviceRepository({
|
||||||
|
pool: {
|
||||||
|
query: async () => ({ rows: [] }),
|
||||||
|
connect: async () => client,
|
||||||
|
end: async () => undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function step(includes, result = { rows: [] }) {
|
||||||
|
return { includes, result };
|
||||||
|
}
|
||||||
|
|
||||||
|
function scriptedClient(steps) {
|
||||||
|
const queue = [...steps];
|
||||||
|
return {
|
||||||
|
released: false,
|
||||||
|
async query(sql) {
|
||||||
|
const next = queue.shift();
|
||||||
|
assert.ok(next, `Unexpected query: ${sql}`);
|
||||||
|
assert.match(String(sql), new RegExp(escapeRegExp(next.includes), "i"));
|
||||||
|
return next.result;
|
||||||
|
},
|
||||||
|
release() {
|
||||||
|
this.released = true;
|
||||||
|
},
|
||||||
|
remaining() {
|
||||||
|
return queue.length;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeRegExp(value) {
|
||||||
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
}
|
||||||
@@ -111,6 +111,10 @@ test("authorized transfer preserves history and detaches source collections", as
|
|||||||
grantsStep(actor, "owner"),
|
grantsStep(actor, "owner"),
|
||||||
step("from device_sessions", { rows: [{ active: false }] }),
|
step("from device_sessions", { rows: [{ active: false }] }),
|
||||||
step("from device_credential_bindings", { rows: [{ active: false }] }),
|
step("from device_credential_bindings", { rows: [{ active: false }] }),
|
||||||
|
step("from device_resource_bindings", { rows: [{ active: false }] }),
|
||||||
|
step("from device_configuration_state", { rows: [] }),
|
||||||
|
step("from device_commands", { rows: [{ active: false }] }),
|
||||||
|
step("delete from device_configuration_state", { rows: [], rowCount: 0 }),
|
||||||
step("delete from device_collection_members", { rows: [], rowCount: 2 }),
|
step("delete from device_collection_members", { rows: [], rowCount: 2 }),
|
||||||
step("update device_instances", {
|
step("update device_instances", {
|
||||||
rows: [deviceRow({
|
rows: [deviceRow({
|
||||||
@@ -139,6 +143,7 @@ test("authorized transfer preserves history and detaches source collections", as
|
|||||||
assert.equal(result.result.device.projectRef, `project:${targetProjectId}`);
|
assert.equal(result.result.device.projectRef, `project:${targetProjectId}`);
|
||||||
assert.equal(result.result.detachedCollectionCount, 2);
|
assert.equal(result.result.detachedCollectionCount, 2);
|
||||||
assert.equal(result.result.transferredIdentifierCount, 1);
|
assert.equal(result.result.transferredIdentifierCount, 1);
|
||||||
|
assert.equal(result.result.clearedDesiredConfiguration, false);
|
||||||
assert.equal(client.remaining(), 0);
|
assert.equal(client.remaining(), 0);
|
||||||
assert.equal(client.released, true);
|
assert.equal(client.released, true);
|
||||||
});
|
});
|
||||||
@@ -173,6 +178,104 @@ test("transfer fails closed while a credential binding is active", async () => {
|
|||||||
assert.equal(client.released, true);
|
assert.equal(client.released, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("transfer fails closed while a resource binding is pending approval", async () => {
|
||||||
|
const actor = managementActor("owner");
|
||||||
|
const command = transferCommand();
|
||||||
|
const client = scriptedClient([
|
||||||
|
step("begin"),
|
||||||
|
receiptStep("receipt-transfer-resource-bound"),
|
||||||
|
step("from device_instances", { rows: [deviceRow()] }),
|
||||||
|
projectStep(sourceProjectId, sourceOwnerId),
|
||||||
|
grantsStep(actor, "owner"),
|
||||||
|
projectStep(targetProjectId, targetOwnerId),
|
||||||
|
grantsStep(actor, "owner"),
|
||||||
|
step("from device_sessions", { rows: [{ active: false }] }),
|
||||||
|
step("from device_credential_bindings", { rows: [{ active: false }] }),
|
||||||
|
step("from device_resource_bindings", { rows: [{ active: true }] }),
|
||||||
|
step("rollback"),
|
||||||
|
]);
|
||||||
|
const repository = repositoryWithClient(client);
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
repository.executeManagementCommand(commandInput({
|
||||||
|
actor,
|
||||||
|
commandKind: "device.transfer",
|
||||||
|
command,
|
||||||
|
digestCharacter: "1",
|
||||||
|
})),
|
||||||
|
/device_transfer_active_resource_binding/,
|
||||||
|
);
|
||||||
|
assert.equal(client.remaining(), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("transfer fails closed with applied configuration", async () => {
|
||||||
|
const actor = managementActor("owner");
|
||||||
|
const command = transferCommand();
|
||||||
|
const client = scriptedClient([
|
||||||
|
step("begin"),
|
||||||
|
receiptStep("receipt-transfer-applied-config"),
|
||||||
|
step("from device_instances", { rows: [deviceRow()] }),
|
||||||
|
projectStep(sourceProjectId, sourceOwnerId),
|
||||||
|
grantsStep(actor, "owner"),
|
||||||
|
projectStep(targetProjectId, targetOwnerId),
|
||||||
|
grantsStep(actor, "owner"),
|
||||||
|
step("from device_sessions", { rows: [{ active: false }] }),
|
||||||
|
step("from device_credential_bindings", { rows: [{ active: false }] }),
|
||||||
|
step("from device_resource_bindings", { rows: [{ active: false }] }),
|
||||||
|
step("from device_configuration_state", {
|
||||||
|
rows: [{
|
||||||
|
desired_revision_id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||||
|
applied_revision_id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
|
||||||
|
}],
|
||||||
|
}),
|
||||||
|
step("rollback"),
|
||||||
|
]);
|
||||||
|
const repository = repositoryWithClient(client);
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
repository.executeManagementCommand(commandInput({
|
||||||
|
actor,
|
||||||
|
commandKind: "device.transfer",
|
||||||
|
command,
|
||||||
|
digestCharacter: "2",
|
||||||
|
})),
|
||||||
|
/device_transfer_applied_configuration/,
|
||||||
|
);
|
||||||
|
assert.equal(client.remaining(), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("transfer fails closed with a nonterminal command", async () => {
|
||||||
|
const actor = managementActor("owner");
|
||||||
|
const command = transferCommand();
|
||||||
|
const client = scriptedClient([
|
||||||
|
step("begin"),
|
||||||
|
receiptStep("receipt-transfer-command-active"),
|
||||||
|
step("from device_instances", { rows: [deviceRow()] }),
|
||||||
|
projectStep(sourceProjectId, sourceOwnerId),
|
||||||
|
grantsStep(actor, "owner"),
|
||||||
|
projectStep(targetProjectId, targetOwnerId),
|
||||||
|
grantsStep(actor, "owner"),
|
||||||
|
step("from device_sessions", { rows: [{ active: false }] }),
|
||||||
|
step("from device_credential_bindings", { rows: [{ active: false }] }),
|
||||||
|
step("from device_resource_bindings", { rows: [{ active: false }] }),
|
||||||
|
step("from device_configuration_state", { rows: [] }),
|
||||||
|
step("from device_commands", { rows: [{ active: true }] }),
|
||||||
|
step("rollback"),
|
||||||
|
]);
|
||||||
|
const repository = repositoryWithClient(client);
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
repository.executeManagementCommand(commandInput({
|
||||||
|
actor,
|
||||||
|
commandKind: "device.transfer",
|
||||||
|
command,
|
||||||
|
digestCharacter: "3",
|
||||||
|
})),
|
||||||
|
/device_transfer_nonterminal_command/,
|
||||||
|
);
|
||||||
|
assert.equal(client.remaining(), 0);
|
||||||
|
});
|
||||||
|
|
||||||
test("reject resolves both quarantine and enrollment without exposing a digest", async () => {
|
test("reject resolves both quarantine and enrollment without exposing a digest", async () => {
|
||||||
const actor = managementActor("member");
|
const actor = managementActor("member");
|
||||||
const command = normalizeDeviceManagementCommand("discovery.reject", {
|
const command = normalizeDeviceManagementCommand("discovery.reject", {
|
||||||
|
|||||||
@@ -15,8 +15,11 @@ test("management surface is internal, POST-only and disabled by default", async
|
|||||||
assert.match(source, /\/internal\/v1\/management\/projects:ensure/);
|
assert.match(source, /\/internal\/v1\/management\/projects:ensure/);
|
||||||
assert.match(source, /\/internal\/v1\/management\/collections:ensure/);
|
assert.match(source, /\/internal\/v1\/management\/collections:ensure/);
|
||||||
assert.match(source, /\/internal\/v1\/management\/project-grants:upsert/);
|
assert.match(source, /\/internal\/v1\/management\/project-grants:upsert/);
|
||||||
|
assert.match(source, /\/internal\/v1\/management\/device-bindings:ensure/);
|
||||||
|
assert.match(source, /\/internal\/v1\/management\/device-configuration-revisions:create/);
|
||||||
assert.match(source, /request\.method === "POST" && managementCommandKind/);
|
assert.match(source, /request\.method === "POST" && managementCommandKind/);
|
||||||
assert.doesNotMatch(source, /\/api\/public\/.*management/);
|
assert.doesNotMatch(source, /\/api\/public\/.*management/);
|
||||||
|
assert.doesNotMatch(source, /device-commands:(?:plan|confirm|dispatch)/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("management token remains file-backed and is not enabled by current Compose", async () => {
|
test("management token remains file-backed and is not enabled by current Compose", async () => {
|
||||||
|
|||||||
@@ -138,6 +138,28 @@ test("matching group grants combine bounded operator and engineer capabilities",
|
|||||||
assert.equal(access.capabilities.includes("access.manage"), false);
|
assert.equal(access.capabilities.includes("access.manage"), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("configuration mutation belongs to engineer and admin, not operator", () => {
|
||||||
|
const engineer = resolveProjectAccess({
|
||||||
|
actor: actor({ groupRefs: ["group:engineers"] }),
|
||||||
|
grants: [grant({
|
||||||
|
principalKind: "group",
|
||||||
|
principalRef: "group:engineers",
|
||||||
|
projectRole: "engineer",
|
||||||
|
})],
|
||||||
|
});
|
||||||
|
const operator = resolveProjectAccess({
|
||||||
|
actor: actor({ groupRefs: ["group:operators"] }),
|
||||||
|
grants: [grant({
|
||||||
|
principalKind: "group",
|
||||||
|
principalRef: "group:operators",
|
||||||
|
projectRole: "operator",
|
||||||
|
})],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(engineer.capabilities.includes("configuration.manage"), true);
|
||||||
|
assert.equal(operator.capabilities.includes("configuration.manage"), false);
|
||||||
|
});
|
||||||
|
|
||||||
test("Hub ceiling and explicit deny prevent privilege escalation", () => {
|
test("Hub ceiling and explicit deny prevent privilege escalation", () => {
|
||||||
const ownerGrant = grant({
|
const ownerGrant = grant({
|
||||||
grantRef: "grant:owner",
|
grantRef: "grant:owner",
|
||||||
|
|||||||
Reference in New Issue
Block a user