From 43dc9b1f45478a7400ef7784358e32d31feecc0e Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 10 Aug 2026 19:05:48 +0300 Subject: [PATCH] feat(device-core): add control resource ledger --- .../010_device_control_resources.sql | 610 ++++++++++++++++++ .../011_device_control_resource_commands.sql | 31 + .../services/device-control-core/src/app.mjs | 10 + .../src/control-resource-management.mjs | 284 ++++++++ .../src/control-resource-repository.mjs | 498 ++++++++++++++ .../src/lifecycle-repository.mjs | 43 ++ .../src/management-command.mjs | 9 + .../src/postgres-repository.mjs | 23 + .../src/project-management.mjs | 3 + .../test/control-resource-management.test.mjs | 158 +++++ .../test/control-resource-migration.test.mjs | 104 +++ .../test/control-resource-repository.test.mjs | 313 +++++++++ .../test/lifecycle-repository.test.mjs | 103 +++ .../test/management-runtime-contract.test.mjs | 3 + .../test/project-management.test.mjs | 22 + 15 files changed, 2214 insertions(+) create mode 100644 device-plane/services/device-control-core/migrations/010_device_control_resources.sql create mode 100644 device-plane/services/device-control-core/migrations/011_device_control_resource_commands.sql create mode 100644 device-plane/services/device-control-core/src/control-resource-management.mjs create mode 100644 device-plane/services/device-control-core/src/control-resource-repository.mjs create mode 100644 device-plane/services/device-control-core/test/control-resource-management.test.mjs create mode 100644 device-plane/services/device-control-core/test/control-resource-migration.test.mjs create mode 100644 device-plane/services/device-control-core/test/control-resource-repository.test.mjs diff --git a/device-plane/services/device-control-core/migrations/010_device_control_resources.sql b/device-plane/services/device-control-core/migrations/010_device_control_resources.sql new file mode 100644 index 0000000..134d7ca --- /dev/null +++ b/device-plane/services/device-control-core/migrations/010_device_control_resources.sql @@ -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; diff --git a/device-plane/services/device-control-core/migrations/011_device_control_resource_commands.sql b/device-plane/services/device-control-core/migrations/011_device_control_resource_commands.sql new file mode 100644 index 0000000..cc456e7 --- /dev/null +++ b/device-plane/services/device-control-core/migrations/011_device_control_resource_commands.sql @@ -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; diff --git a/device-plane/services/device-control-core/src/app.mjs b/device-plane/services/device-control-core/src/app.mjs index 848d0ec..64f4cc2 100644 --- a/device-plane/services/device-control-core/src/app.mjs +++ b/device-plane/services/device-control-core/src/app.mjs @@ -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({ diff --git a/device-plane/services/device-control-core/src/control-resource-management.mjs b/device-plane/services/device-control-core/src/control-resource-management.mjs new file mode 100644 index 0000000..a4248f0 --- /dev/null +++ b/device-plane/services/device-control-core/src/control-resource-management.mjs @@ -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; +} diff --git a/device-plane/services/device-control-core/src/control-resource-repository.mjs b/device-plane/services/device-control-core/src/control-resource-repository.mjs new file mode 100644 index 0000000..6dbb306 --- /dev/null +++ b/device-plane/services/device-control-core/src/control-resource-repository.mjs @@ -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; +} diff --git a/device-plane/services/device-control-core/src/lifecycle-repository.mjs b/device-plane/services/device-control-core/src/lifecycle-repository.mjs index 864264f..a422d3a 100644 --- a/device-plane/services/device-control-core/src/lifecycle-repository.mjs +++ b/device-plane/services/device-control-core/src/lifecycle-repository.mjs @@ -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, }; } diff --git a/device-plane/services/device-control-core/src/management-command.mjs b/device-plane/services/device-control-core/src/management-command.mjs index a28d2d7..9dc8072 100644 --- a/device-plane/services/device-control-core/src/management-command.mjs +++ b/device-plane/services/device-control-core/src/management-command.mjs @@ -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); } diff --git a/device-plane/services/device-control-core/src/postgres-repository.mjs b/device-plane/services/device-control-core/src/postgres-repository.mjs index c35f583..3789688 100644 --- a/device-plane/services/device-control-core/src/postgres-repository.mjs +++ b/device-plane/services/device-control-core/src/postgres-repository.mjs @@ -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, diff --git a/device-plane/services/device-control-core/src/project-management.mjs b/device-plane/services/device-control-core/src/project-management.mjs index 76c2115..b9862ca 100644 --- a/device-plane/services/device-control-core/src/project-management.mjs +++ b/device-plane/services/device-control-core/src/project-management.mjs @@ -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", diff --git a/device-plane/services/device-control-core/test/control-resource-management.test.mjs b/device-plane/services/device-control-core/test/control-resource-management.test.mjs new file mode 100644 index 0000000..e71445c --- /dev/null +++ b/device-plane/services/device-control-core/test/control-resource-management.test.mjs @@ -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"], + }; +} diff --git a/device-plane/services/device-control-core/test/control-resource-migration.test.mjs b/device-plane/services/device-control-core/test/control-resource-migration.test.mjs new file mode 100644 index 0000000..950672e --- /dev/null +++ b/device-plane/services/device-control-core/test/control-resource-migration.test.mjs @@ -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); +}); diff --git a/device-plane/services/device-control-core/test/control-resource-repository.test.mjs b/device-plane/services/device-control-core/test/control-resource-repository.test.mjs new file mode 100644 index 0000000..2835e1e --- /dev/null +++ b/device-plane/services/device-control-core/test/control-resource-repository.test.mjs @@ -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, "\\$&"); +} diff --git a/device-plane/services/device-control-core/test/lifecycle-repository.test.mjs b/device-plane/services/device-control-core/test/lifecycle-repository.test.mjs index c795cb5..2e584d1 100644 --- a/device-plane/services/device-control-core/test/lifecycle-repository.test.mjs +++ b/device-plane/services/device-control-core/test/lifecycle-repository.test.mjs @@ -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", { diff --git a/device-plane/services/device-control-core/test/management-runtime-contract.test.mjs b/device-plane/services/device-control-core/test/management-runtime-contract.test.mjs index bc1ab42..d1a06c8 100644 --- a/device-plane/services/device-control-core/test/management-runtime-contract.test.mjs +++ b/device-plane/services/device-control-core/test/management-runtime-contract.test.mjs @@ -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 () => { diff --git a/device-plane/services/device-control-core/test/project-management.test.mjs b/device-plane/services/device-control-core/test/project-management.test.mjs index bf03d7f..37c0602 100644 --- a/device-plane/services/device-control-core/test/project-management.test.mjs +++ b/device-plane/services/device-control-core/test/project-management.test.mjs @@ -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",