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",
|
||||
"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({
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
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(
|
||||
`delete from device_collection_members
|
||||
where device_id = $1 and project_id = $2`,
|
||||
@@ -437,6 +478,7 @@ async function transferDevice(client, actor, command) {
|
||||
targetProjectRef: toProjectRef(targetProject.id),
|
||||
detachedCollectionCount: Number(detached.rowCount || 0),
|
||||
transferredIdentifierCount: Number(movedIdentifiers.rowCount || 0),
|
||||
clearedDesiredConfiguration: Number(clearedConfiguration.rowCount || 0) > 0,
|
||||
};
|
||||
await addAudit(client, {
|
||||
eventType: "device.transferred_out",
|
||||
@@ -459,6 +501,7 @@ async function transferDevice(client, actor, command) {
|
||||
ownershipTransitionRef: `ownership-transition:${transitionId}`,
|
||||
detachedCollectionCount: Number(detached.rowCount || 0),
|
||||
transferredIdentifierCount: Number(movedIdentifiers.rowCount || 0),
|
||||
clearedDesiredConfiguration: Number(clearedConfiguration.rowCount || 0) > 0,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,11 @@ import {
|
||||
isInfrastructureManagementCommand,
|
||||
normalizeInfrastructureManagementCommand,
|
||||
} from "./infrastructure-management.mjs";
|
||||
import {
|
||||
DEVICE_CONTROL_RESOURCE_COMMAND_KINDS,
|
||||
isControlResourceManagementCommand,
|
||||
normalizeControlResourceManagementCommand,
|
||||
} from "./control-resource-management.mjs";
|
||||
import {
|
||||
DEVICE_LIFECYCLE_COMMAND_KINDS,
|
||||
isLifecycleManagementCommand,
|
||||
@@ -23,9 +28,13 @@ export const ALL_DEVICE_MANAGEMENT_COMMAND_KINDS = Object.freeze([
|
||||
...DEVICE_INFRASTRUCTURE_COMMAND_KINDS,
|
||||
...DEVICE_LIFECYCLE_COMMAND_KINDS,
|
||||
...DEVICE_SENSITIVE_REFERENCE_COMMAND_KINDS,
|
||||
...DEVICE_CONTROL_RESOURCE_COMMAND_KINDS,
|
||||
]);
|
||||
|
||||
export function normalizeDeviceManagementCommand(kind, input) {
|
||||
if (isControlResourceManagementCommand(kind)) {
|
||||
return normalizeControlResourceManagementCommand(kind, input);
|
||||
}
|
||||
if (isSensitiveReferenceManagementCommand(kind)) {
|
||||
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 { observeQuarantineDiscovery } from "./discovery-repository.mjs";
|
||||
import {
|
||||
applyControlResourceManagementCommand,
|
||||
authorizeControlResourceManagementReplay,
|
||||
} from "./control-resource-repository.mjs";
|
||||
import {
|
||||
isControlResourceManagementCommand,
|
||||
} from "./control-resource-management.mjs";
|
||||
import {
|
||||
applyInfrastructureManagementCommand,
|
||||
authorizeInfrastructureManagementReplay,
|
||||
@@ -43,6 +50,8 @@ const migrationFiles = [
|
||||
"007_device_lifecycle_commands.sql",
|
||||
"008_device_sensitive_references.sql",
|
||||
"009_device_sensitive_reference_commands.sql",
|
||||
"010_device_control_resources.sql",
|
||||
"011_device_control_resource_commands.sql",
|
||||
];
|
||||
|
||||
export class PostgresDeviceRepository {
|
||||
@@ -226,6 +235,13 @@ async function completeManagementReceipt(client, receiptId, result) {
|
||||
}
|
||||
|
||||
async function applyManagementCommand(client, { commandKind, actor, command }) {
|
||||
if (isControlResourceManagementCommand(commandKind)) {
|
||||
return applyControlResourceManagementCommand(client, {
|
||||
commandKind,
|
||||
actor,
|
||||
command,
|
||||
});
|
||||
}
|
||||
if (isSensitiveReferenceManagementCommand(commandKind)) {
|
||||
return applySensitiveReferenceManagementCommand(client, {
|
||||
commandKind,
|
||||
@@ -263,6 +279,13 @@ async function applyManagementCommand(client, { commandKind, actor, command }) {
|
||||
}
|
||||
|
||||
async function authorizeManagementReplay(client, { commandKind, actor, command }) {
|
||||
if (isControlResourceManagementCommand(commandKind)) {
|
||||
return authorizeControlResourceManagementReplay(client, {
|
||||
commandKind,
|
||||
actor,
|
||||
command,
|
||||
});
|
||||
}
|
||||
if (isSensitiveReferenceManagementCommand(commandKind)) {
|
||||
return authorizeSensitiveReferenceManagementReplay(client, {
|
||||
commandKind,
|
||||
|
||||
@@ -11,6 +11,7 @@ export const DEVICE_PROJECT_CAPABILITIES = Object.freeze([
|
||||
"binding.manage",
|
||||
"telemetry.observe",
|
||||
"configuration.read",
|
||||
"configuration.manage",
|
||||
"command.plan",
|
||||
"command.confirm",
|
||||
"command.dispatch",
|
||||
@@ -76,6 +77,7 @@ const roleCapabilities = Object.freeze({
|
||||
"binding.manage",
|
||||
"telemetry.observe",
|
||||
"configuration.read",
|
||||
"configuration.manage",
|
||||
"command.plan",
|
||||
"audit.read",
|
||||
]),
|
||||
@@ -91,6 +93,7 @@ const roleCapabilities = Object.freeze({
|
||||
"binding.manage",
|
||||
"telemetry.observe",
|
||||
"configuration.read",
|
||||
"configuration.manage",
|
||||
"command.plan",
|
||||
"command.confirm",
|
||||
"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"),
|
||||
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: false }] }),
|
||||
step("delete from device_configuration_state", { rows: [], rowCount: 0 }),
|
||||
step("delete from device_collection_members", { rows: [], rowCount: 2 }),
|
||||
step("update device_instances", {
|
||||
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.detachedCollectionCount, 2);
|
||||
assert.equal(result.result.transferredIdentifierCount, 1);
|
||||
assert.equal(result.result.clearedDesiredConfiguration, false);
|
||||
assert.equal(client.remaining(), 0);
|
||||
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);
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const actor = managementActor("member");
|
||||
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\/collections:ensure/);
|
||||
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.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 () => {
|
||||
|
||||
@@ -138,6 +138,28 @@ test("matching group grants combine bounded operator and engineer capabilities",
|
||||
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", () => {
|
||||
const ownerGrant = grant({
|
||||
grantRef: "grant:owner",
|
||||
|
||||
Reference in New Issue
Block a user