diff --git a/device-plane/packages/device-protocol-contract/src/index.mjs b/device-plane/packages/device-protocol-contract/src/index.mjs index 1383fdd..b243b47 100644 --- a/device-plane/packages/device-protocol-contract/src/index.mjs +++ b/device-plane/packages/device-protocol-contract/src/index.mjs @@ -25,6 +25,7 @@ export const DEVICE_BINDING_CAPABILITIES = Object.freeze([ const OPAQUE_REF_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/; const IMEI_RE = /^\d{15}$/; const DIGEST_RE = /^hmac-sha256:[a-f0-9]{64}$/; +const IDENTIFIER_KIND_RE = /^[a-z][a-z0-9._:-]{1,63}$/; const forbiddenKeyFragments = Object.freeze([ "password", "secret", @@ -88,7 +89,7 @@ export function toSafeDiscoveryView(signal, options = {}) { protocol: normalized.protocol, observedAt: normalized.observedAt, lifecycleState: normalized.lifecycleState, - identifier: Object.freeze({ + identifier: normalizeRestrictedIdentifierProjection({ kind: normalized.identifier.kind, masked: maskRestrictedIdentifier(normalized.identifier), }), @@ -116,6 +117,55 @@ export function assertIdentifierDigest(value) { return value; } +export function normalizeRestrictedIdentifierProjection(input) { + assertPlainObject(input, "restricted_identifier_projection"); + const allowedKeys = new Set(["kind", "masked"]); + for (const key of Object.keys(input)) { + if (!allowedKeys.has(key)) { + throw new TypeError( + `restricted_identifier_projection_field_unexpected:${key}`, + ); + } + } + if (typeof input.kind !== "string" || !IDENTIFIER_KIND_RE.test(input.kind)) { + throw new TypeError("restricted_identifier_projection_kind_invalid"); + } + if ( + typeof input.masked !== "string" + || input.masked.length < 5 + || input.masked.length > 128 + || !input.masked.includes("*") + || /\u0000|[\u0001-\u001f\u007f]/.test(input.masked) + || /\b\d{15}\b/.test(input.masked) + ) { + throw new TypeError("restricted_identifier_projection_mask_invalid"); + } + return Object.freeze({ + kind: input.kind, + masked: input.masked, + }); +} + +export function normalizeRestrictedIdentifierRecord(input) { + assertPlainObject(input, "restricted_identifier_record"); + const allowedKeys = new Set(["kind", "digest", "masked"]); + for (const key of Object.keys(input)) { + if (!allowedKeys.has(key)) { + throw new TypeError( + `restricted_identifier_record_field_unexpected:${key}`, + ); + } + } + const projection = normalizeRestrictedIdentifierProjection({ + kind: input.kind, + masked: input.masked, + }); + return Object.freeze({ + ...projection, + digest: assertIdentifierDigest(input.digest), + }); +} + export function normalizeDevicePlaneBinding(input) { assertPlainObject(input, "device_plane_binding"); rejectForbiddenKeys(input); diff --git a/device-plane/packages/device-protocol-contract/test/contract.test.mjs b/device-plane/packages/device-protocol-contract/test/contract.test.mjs index ca28097..00b5ef9 100644 --- a/device-plane/packages/device-protocol-contract/test/contract.test.mjs +++ b/device-plane/packages/device-protocol-contract/test/contract.test.mjs @@ -9,6 +9,8 @@ import { hashRestrictedIdentifier, normalizeDevicePlaneBinding, normalizeDiscoverySignal, + normalizeRestrictedIdentifierProjection, + normalizeRestrictedIdentifierRecord, toSafeDiscoveryView, } from "../src/index.mjs"; @@ -82,6 +84,32 @@ test("identifier hashing requires a strong process-only pepper", () => { ); }); +test("restricted identifier records keep digest internal and expose only a mask", () => { + const record = normalizeRestrictedIdentifierRecord({ + kind: "vendor.serial", + digest: `hmac-sha256:${"a".repeat(64)}`, + masked: "********ABCD", + }); + const projection = normalizeRestrictedIdentifierProjection({ + kind: record.kind, + masked: record.masked, + }); + + assert.deepEqual(projection, { + kind: "vendor.serial", + masked: "********ABCD", + }); + assert.equal("digest" in projection, false); + assertSafeProjection({ identifier: projection }); + assert.throws( + () => normalizeRestrictedIdentifierProjection({ + kind: "vendor.serial", + masked: "SERIAL-PLAINTEXT", + }), + /restricted_identifier_projection_mask_invalid/, + ); +}); + test("rejects unverified framing and command-shaped discovery input", () => { assert.throws( () => normalizeDiscoverySignal({ diff --git a/device-plane/services/device-control-core/migrations/008_device_sensitive_references.sql b/device-plane/services/device-control-core/migrations/008_device_sensitive_references.sql new file mode 100644 index 0000000..6fc5869 --- /dev/null +++ b/device-plane/services/device-control-core/migrations/008_device_sensitive_references.sql @@ -0,0 +1,233 @@ +begin; + +do $$ +begin + if not exists ( + select 1 from pg_constraint + where conname = 'device_instances_direct_legacy_credential_check' + and conrelid = 'device_instances'::regclass + ) then + alter table device_instances + add constraint device_instances_direct_legacy_credential_check + check (owner_scope_id is null or credential_ref is null) + not valid; + end if; +end +$$; + +create table if not exists device_restricted_identifiers ( + id uuid primary key, + device_id uuid not null references device_instances(id), + owner_scope_id uuid not null, + project_id uuid not null, + identifier_kind text not null + check (identifier_kind ~ '^[a-z][a-z0-9._:-]{1,63}$'), + identifier_digest text not null + check (identifier_digest ~ '^hmac-sha256:[a-f0-9]{64}$'), + identifier_masked text not null + check ( + length(identifier_masked) between 5 and 128 + and position('*' in identifier_masked) > 0 + and identifier_masked !~ '[[:cntrl:]]' + and identifier_masked !~ '(^|[^0-9])[0-9]{15}([^0-9]|$)' + ), + provenance_kind text not null + check (provenance_kind in ('claim', 'adapter_observation')), + is_primary boolean not null default false, + lifecycle_state text not null default 'active' + check (lifecycle_state in ('active', 'revoked')), + created_by_ref text not null + check (length(btrim(created_by_ref)) between 3 and 256), + 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(), + foreign key (project_id, owner_scope_id) + references device_projects(id, owner_scope_id), + check ( + (lifecycle_state = 'active' 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 unique index if not exists device_restricted_identifiers_active_identity_idx + on device_restricted_identifiers (identifier_kind, identifier_digest) + where lifecycle_state = 'active'; + +create unique index if not exists device_restricted_identifiers_primary_idx + on device_restricted_identifiers (device_id) + where lifecycle_state = 'active' and is_primary; + +create index if not exists device_restricted_identifiers_device_idx + on device_restricted_identifiers (device_id, lifecycle_state, created_at); + +create or replace function device_assert_identifier_current_owner() +returns trigger +language plpgsql +as $$ +begin + if new.lifecycle_state = 'active' 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_identifier_ownership_mismatch'; + end if; + return new; +end +$$; + +drop trigger if exists device_restricted_identifiers_owner_guard + on device_restricted_identifiers; + +create trigger device_restricted_identifiers_owner_guard +before insert or update of device_id, owner_scope_id, project_id, lifecycle_state +on device_restricted_identifiers +for each row +execute function device_assert_identifier_current_owner(); + +create or replace function device_assert_active_identifiers_follow_owner() +returns trigger +language plpgsql +as $$ +begin + if exists ( + select 1 from device_restricted_identifiers dri + where dri.device_id = new.id + and dri.lifecycle_state = 'active' + and ( + dri.owner_scope_id is distinct from new.owner_scope_id + or dri.project_id is distinct from new.project_id + ) + ) then + raise foreign_key_violation using + message = 'device_active_identifier_ownership_mismatch'; + end if; + return new; +end +$$; + +drop trigger if exists device_instances_identifier_owner_guard + on device_instances; + +create constraint trigger device_instances_identifier_owner_guard +after update +on device_instances +deferrable initially deferred +for each row +execute function device_assert_active_identifiers_follow_owner(); + +create table if not exists device_credential_bindings ( + id uuid primary key, + device_id uuid not null references device_instances(id), + owner_scope_id uuid not null, + project_id uuid not null, + purpose text not null + check (purpose ~ '^[a-z][a-z0-9._-]{1,63}$'), + credential_owner text not null + check (credential_owner = 'ndc_l2_credentials'), + credential_ref text not null + check (credential_ref ~ '^ndc-credref:[A-Za-z0-9][A-Za-z0-9._:-]{7,240}$'), + lifecycle_state text not null default 'active' + check (lifecycle_state in ('active', 'revoked')), + bound_by_ref text not null + check (length(btrim(bound_by_ref)) between 3 and 256), + 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(), + foreign key (project_id, owner_scope_id) + references device_projects(id, owner_scope_id), + check ( + (lifecycle_state = 'active' 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 unique index if not exists device_credential_bindings_active_purpose_idx + on device_credential_bindings (device_id, purpose) + where lifecycle_state = 'active'; + +create index if not exists device_credential_bindings_project_state_idx + on device_credential_bindings (project_id, lifecycle_state, updated_at desc); + +create or replace function device_assert_credential_binding_current_owner() +returns trigger +language plpgsql +as $$ +begin + if new.lifecycle_state = 'active' 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_credential_binding_ownership_mismatch'; + end if; + return new; +end +$$; + +drop trigger if exists device_credential_bindings_owner_guard + on device_credential_bindings; + +create trigger device_credential_bindings_owner_guard +before insert or update of device_id, owner_scope_id, project_id, lifecycle_state +on device_credential_bindings +for each row +execute function device_assert_credential_binding_current_owner(); + +create or replace function device_require_credential_revoke_before_transfer() +returns trigger +language plpgsql +as $$ +begin + if exists ( + select 1 from device_credential_bindings dcb + where dcb.device_id = old.id + and dcb.lifecycle_state = 'active' + ) then + raise check_violation using + message = 'device_transfer_active_credential_binding'; + end if; + return new; +end +$$; + +drop trigger if exists device_instances_credential_transfer_guard + on device_instances; + +create trigger device_instances_credential_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_credential_revoke_before_transfer(); + +commit; diff --git a/device-plane/services/device-control-core/migrations/009_device_sensitive_reference_commands.sql b/device-plane/services/device-control-core/migrations/009_device_sensitive_reference_commands.sql new file mode 100644 index 0000000..2902a15 --- /dev/null +++ b/device-plane/services/device-control-core/migrations/009_device_sensitive_reference_commands.sql @@ -0,0 +1,27 @@ +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' + )); + +commit; diff --git a/device-plane/services/device-control-core/src/app.mjs b/device-plane/services/device-control-core/src/app.mjs index e468e75..848d0ec 100644 --- a/device-plane/services/device-control-core/src/app.mjs +++ b/device-plane/services/device-control-core/src/app.mjs @@ -27,6 +27,14 @@ const managementRoutes = new Map([ ["/internal/v1/management/devices:transfer", "device.transfer"], ["/internal/v1/management/discoveries:reject", "discovery.reject"], ["/internal/v1/management/discoveries:expire", "discovery.expire"], + [ + "/internal/v1/management/device-credential-bindings:upsert", + "device_credential_binding.upsert", + ], + [ + "/internal/v1/management/device-credential-bindings:revoke", + "device_credential_binding.revoke", + ], ]); export function createControlCoreApp({ 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 11ee8f9..864264f 100644 --- a/device-plane/services/device-control-core/src/lifecycle-repository.mjs +++ b/device-plane/services/device-control-core/src/lifecycle-repository.mjs @@ -1,5 +1,8 @@ import { randomUUID } from "node:crypto"; +import { + normalizeRestrictedIdentifierProjection, +} from "../../../packages/device-protocol-contract/src/index.mjs"; import { isLifecycleManagementCommand } from "./lifecycle-management.mjs"; import { assertProjectCapability, @@ -123,6 +126,32 @@ async function claimDevice(client, actor, command) { const device = inserted.rows[0]; if (!device) throw domainError("device_claim_insert_failed", 409); + const identifierId = randomUUID(); + await client.query( + `insert into device_restricted_identifiers ( + id, + device_id, + owner_scope_id, + project_id, + identifier_kind, + identifier_digest, + identifier_masked, + provenance_kind, + is_primary, + created_by_ref + ) values ($1, $2, $3, $4, $5, $6, $7, 'claim', true, $8)`, + [ + identifierId, + device.id, + project.owner_scope_id, + project.id, + discovery.identifier_kind, + discovery.identifier_digest, + discovery.identifier_masked, + actor.userRef, + ], + ); + const claimedDiscovery = await client.query( `update device_discoveries set lifecycle_state = 'claimed', @@ -193,6 +222,7 @@ async function claimDevice(client, actor, command) { enrollmentIntentRef: `enrollment-intent:${enrollment.id}`, discoveryRef: `discovery:${discovery.id}`, ownershipTransitionRef: `ownership-transition:${transitionId}`, + identifierRef: `identifier:${identifierId}`, modelProfileRef: device.model_profile_ref, }, }); @@ -202,6 +232,7 @@ async function claimDevice(client, actor, command) { enrollmentIntentRef: `enrollment-intent:${enrollment.id}`, discoveryRef: `discovery:${discovery.id}`, ownershipTransitionRef: `ownership-transition:${transitionId}`, + identifierRef: `identifier:${identifierId}`, }; } @@ -329,6 +360,17 @@ async function transferDevice(client, actor, command) { throw domainError("device_transfer_active_session", 409); } + const activeCredentialBindings = await client.query( + `select exists ( + select 1 from device_credential_bindings + where device_id = $1 and lifecycle_state = 'active' + ) as active`, + [device.id], + ); + if (activeCredentialBindings.rows[0]?.active === true) { + throw domainError("device_transfer_active_credential_binding", 409); + } + const detached = await client.query( `delete from device_collection_members where device_id = $1 and project_id = $2`, @@ -354,6 +396,18 @@ async function transferDevice(client, actor, command) { const moved = updated.rows[0]; if (!moved) throw domainError("device_transfer_update_failed", 409); + const movedIdentifiers = await client.query( + `update device_restricted_identifiers + set owner_scope_id = $2, + project_id = $3, + updated_at = now() + where device_id = $1 and lifecycle_state = 'active'`, + [device.id, targetProject.owner_scope_id, targetProject.id], + ); + if (Number(movedIdentifiers.rowCount || 0) < 1) { + throw domainError("device_identifier_projection_missing", 409); + } + const transitionId = randomUUID(); await client.query( `insert into device_ownership_transitions ( @@ -382,6 +436,7 @@ async function transferDevice(client, actor, command) { sourceProjectRef: toProjectRef(sourceProject.id), targetProjectRef: toProjectRef(targetProject.id), detachedCollectionCount: Number(detached.rowCount || 0), + transferredIdentifierCount: Number(movedIdentifiers.rowCount || 0), }; await addAudit(client, { eventType: "device.transferred_out", @@ -403,10 +458,11 @@ async function transferDevice(client, actor, command) { sourceProjectRef: toProjectRef(sourceProject.id), ownershipTransitionRef: `ownership-transition:${transitionId}`, detachedCollectionCount: Number(detached.rowCount || 0), + transferredIdentifierCount: Number(movedIdentifiers.rowCount || 0), }; } -async function findProjectWithCapability(client, actor, projectId, capability) { +export async function findProjectWithCapability(client, actor, projectId, capability) { const result = await client.query( `select p.id, p.owner_scope_id, p.lifecycle_state, os.scope_kind, os.owner_ref, os.display_name as owner_display_name, @@ -554,10 +610,10 @@ function deviceView(row, project) { }, modelProfileRef: row.model_profile_ref, displayName: row.display_name, - identifier: { + identifier: normalizeRestrictedIdentifierProjection({ kind: row.identifier_kind, masked: row.identifier_masked, - }, + }), lifecycleState: row.lifecycle_state, createdAt: toIso(row.created_at), updatedAt: toIso(row.updated_at), 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 773ad7f..a28d2d7 100644 --- a/device-plane/services/device-control-core/src/management-command.mjs +++ b/device-plane/services/device-control-core/src/management-command.mjs @@ -12,14 +12,23 @@ import { DEVICE_MANAGEMENT_COMMAND_KINDS, normalizeManagementCommand, } from "./project-management.mjs"; +import { + DEVICE_SENSITIVE_REFERENCE_COMMAND_KINDS, + isSensitiveReferenceManagementCommand, + normalizeSensitiveReferenceManagementCommand, +} from "./sensitive-reference-management.mjs"; export const ALL_DEVICE_MANAGEMENT_COMMAND_KINDS = Object.freeze([ ...DEVICE_MANAGEMENT_COMMAND_KINDS, ...DEVICE_INFRASTRUCTURE_COMMAND_KINDS, ...DEVICE_LIFECYCLE_COMMAND_KINDS, + ...DEVICE_SENSITIVE_REFERENCE_COMMAND_KINDS, ]); export function normalizeDeviceManagementCommand(kind, input) { + if (isSensitiveReferenceManagementCommand(kind)) { + return normalizeSensitiveReferenceManagementCommand(kind, input); + } if (isLifecycleManagementCommand(kind)) { return normalizeLifecycleManagementCommand(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 50a3a24..c35f583 100644 --- a/device-plane/services/device-control-core/src/postgres-repository.mjs +++ b/device-plane/services/device-control-core/src/postgres-repository.mjs @@ -17,6 +17,13 @@ import { applyLifecycleManagementCommand, authorizeLifecycleManagementReplay, } from "./lifecycle-repository.mjs"; +import { + applySensitiveReferenceManagementCommand, + authorizeSensitiveReferenceManagementReplay, +} from "./sensitive-reference-repository.mjs"; +import { + isSensitiveReferenceManagementCommand, +} from "./sensitive-reference-management.mjs"; import { assertActorCanManageOwnerScope, assertGrantMutationAllowed, @@ -34,6 +41,8 @@ const migrationFiles = [ "005_device_registry_commands.sql", "006_device_lifecycle_ownership.sql", "007_device_lifecycle_commands.sql", + "008_device_sensitive_references.sql", + "009_device_sensitive_reference_commands.sql", ]; export class PostgresDeviceRepository { @@ -217,6 +226,13 @@ async function completeManagementReceipt(client, receiptId, result) { } async function applyManagementCommand(client, { commandKind, actor, command }) { + if (isSensitiveReferenceManagementCommand(commandKind)) { + return applySensitiveReferenceManagementCommand(client, { + commandKind, + actor, + command, + }); + } if (isLifecycleManagementCommand(commandKind)) { return applyLifecycleManagementCommand(client, { commandKind, @@ -247,6 +263,13 @@ async function applyManagementCommand(client, { commandKind, actor, command }) { } async function authorizeManagementReplay(client, { commandKind, actor, command }) { + if (isSensitiveReferenceManagementCommand(commandKind)) { + return authorizeSensitiveReferenceManagementReplay(client, { + commandKind, + actor, + command, + }); + } if (isLifecycleManagementCommand(commandKind)) { return authorizeLifecycleManagementReplay(client, { commandKind, diff --git a/device-plane/services/device-control-core/src/sensitive-reference-management.mjs b/device-plane/services/device-control-core/src/sensitive-reference-management.mjs new file mode 100644 index 0000000..5184c5a --- /dev/null +++ b/device-plane/services/device-control-core/src/sensitive-reference-management.mjs @@ -0,0 +1,97 @@ +import { + normalizeNdcCredentialReference, +} from "../../../../packages/external-provider-contract/src/credential-reference.mjs"; + +export const DEVICE_SENSITIVE_REFERENCE_COMMAND_KINDS = Object.freeze([ + "device_credential_binding.upsert", + "device_credential_binding.revoke", +]); + +const commandKindSet = new Set(DEVICE_SENSITIVE_REFERENCE_COMMAND_KINDS); +const purposePattern = /^[a-z][a-z0-9._-]{1,63}$/; +const resolutionPattern = /^[a-z][a-z0-9._-]{1,63}$/; + +export function isSensitiveReferenceManagementCommand(kind) { + return commandKindSet.has(kind); +} + +export function normalizeSensitiveReferenceManagementCommand(kind, input) { + if (!commandKindSet.has(kind)) { + throw new TypeError("device_sensitive_reference_command_kind_invalid"); + } + assertPlainObject(input); + + if (kind === "device_credential_binding.upsert") { + assertAllowedKeys(input, [ + "projectRef", + "deviceRef", + "purpose", + "credentialRef", + ]); + return Object.freeze({ + projectId: normalizeEntityRef(input.projectRef, "project"), + deviceId: normalizeEntityRef(input.deviceRef, "device"), + purpose: normalizePattern( + input.purpose, + purposePattern, + "device_credential_purpose_invalid", + ), + credentialRef: normalizeNdcCredentialReference(input.credentialRef), + }); + } + + assertAllowedKeys(input, [ + "projectRef", + "deviceRef", + "purpose", + "resolutionCode", + ]); + return Object.freeze({ + projectId: normalizeEntityRef(input.projectRef, "project"), + deviceId: normalizeEntityRef(input.deviceRef, "device"), + purpose: normalizePattern( + input.purpose, + purposePattern, + "device_credential_purpose_invalid", + ), + resolutionCode: normalizePattern( + input.resolutionCode, + resolutionPattern, + "device_credential_resolution_code_invalid", + ), + }); +} + +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 assertPlainObject(value) { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new TypeError("device_sensitive_reference_command_invalid"); + } +} + +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}`); + } + } +} diff --git a/device-plane/services/device-control-core/src/sensitive-reference-repository.mjs b/device-plane/services/device-control-core/src/sensitive-reference-repository.mjs new file mode 100644 index 0000000..038da0c --- /dev/null +++ b/device-plane/services/device-control-core/src/sensitive-reference-repository.mjs @@ -0,0 +1,269 @@ +import { randomUUID } from "node:crypto"; + +import { + findProjectWithCapability, +} from "./lifecycle-repository.mjs"; +import { + isSensitiveReferenceManagementCommand, +} from "./sensitive-reference-management.mjs"; +import { toProjectRef } from "./project-management.mjs"; + +export async function applySensitiveReferenceManagementCommand( + client, + { commandKind, actor, command }, +) { + if (!isSensitiveReferenceManagementCommand(commandKind)) { + throw new TypeError("device_sensitive_reference_command_kind_invalid"); + } + if (commandKind === "device_credential_binding.upsert") { + return upsertCredentialBinding(client, actor, command); + } + return revokeCredentialBinding(client, actor, command); +} + +export async function authorizeSensitiveReferenceManagementReplay( + client, + { commandKind, actor, command }, +) { + if (!isSensitiveReferenceManagementCommand(commandKind)) { + throw new TypeError("device_sensitive_reference_command_kind_invalid"); + } + await findProjectWithCapability( + client, + actor, + command.projectId, + "credential.manage", + ); + 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, + "credential.manage", + ); + } +} + +async function upsertCredentialBinding(client, actor, command) { + const project = await findProjectWithCapability( + client, + actor, + command.projectId, + "credential.manage", + ); + const device = await findDirectDeviceForUpdate(client, command); + const currentResult = await client.query( + `select id, device_id, owner_scope_id, project_id, purpose, + credential_owner, credential_ref, lifecycle_state, + created_at, updated_at + from device_credential_bindings + where device_id = $1 + and purpose = $2 + and lifecycle_state = 'active' + for update`, + [device.id, command.purpose], + ); + const current = currentResult.rows[0] ?? null; + if ( + current + && current.credential_owner === command.credentialRef.owner + && current.credential_ref === command.credentialRef.reference + ) { + return { + created: false, + rotated: false, + credentialBinding: credentialBindingView(current), + }; + } + + if (current) { + await client.query( + `update device_credential_bindings + set lifecycle_state = 'revoked', + revoked_at = now(), + revoked_by_ref = $2, + revocation_code = 'credential_rotation', + updated_at = now() + where id = $1 and lifecycle_state = 'active'`, + [current.id, actor.userRef], + ); + } + + const inserted = await client.query( + `insert into device_credential_bindings ( + id, + device_id, + owner_scope_id, + project_id, + purpose, + credential_owner, + credential_ref, + bound_by_ref + ) values ($1, $2, $3, $4, $5, $6, $7, $8) + returning id, device_id, owner_scope_id, project_id, purpose, + credential_owner, lifecycle_state, created_at, updated_at`, + [ + randomUUID(), + device.id, + project.owner_scope_id, + project.id, + command.purpose, + command.credentialRef.owner, + command.credentialRef.reference, + actor.userRef, + ], + ); + const binding = inserted.rows[0]; + if (!binding) throw domainError("device_credential_binding_insert_failed", 409); + + await addAudit(client, { + eventType: current + ? "device_credential_binding.rotated" + : "device_credential_binding.created", + actorRef: actor.userRef, + projectId: project.id, + deviceId: device.id, + payload: { + deviceRef: `device:${device.id}`, + projectRef: toProjectRef(project.id), + credentialBindingRef: `credential-binding:${binding.id}`, + ...(current + ? { rotatedCredentialBindingRef: `credential-binding:${current.id}` } + : {}), + purpose: binding.purpose, + credentialOwner: binding.credential_owner, + }, + }); + return { + created: true, + rotated: Boolean(current), + credentialBinding: credentialBindingView(binding), + }; +} + +async function revokeCredentialBinding(client, actor, command) { + const project = await findProjectWithCapability( + client, + actor, + command.projectId, + "credential.manage", + ); + const device = await findDirectDeviceForUpdate(client, command); + const revoked = await client.query( + `update device_credential_bindings + set lifecycle_state = 'revoked', + revoked_at = now(), + revoked_by_ref = $4, + revocation_code = $3, + updated_at = now() + where device_id = $1 + and purpose = $2 + and lifecycle_state = 'active' + returning id, device_id, owner_scope_id, project_id, purpose, + credential_owner, lifecycle_state, created_at, updated_at`, + [device.id, command.purpose, command.resolutionCode, actor.userRef], + ); + const binding = revoked.rows[0]; + if (!binding) throw domainError("device_credential_binding_not_found", 404); + + await addAudit(client, { + eventType: "device_credential_binding.revoked", + actorRef: actor.userRef, + projectId: project.id, + deviceId: device.id, + payload: { + deviceRef: `device:${device.id}`, + projectRef: toProjectRef(project.id), + credentialBindingRef: `credential-binding:${binding.id}`, + purpose: binding.purpose, + credentialOwner: binding.credential_owner, + resolutionCode: command.resolutionCode, + }, + }); + return { + revoked: true, + credentialBinding: credentialBindingView(binding), + resolutionCode: command.resolutionCode, + }; +} + +async function findDirectDeviceForUpdate(client, command) { + const result = await client.query( + `select id, owner_scope_id, project_id, 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.owner_scope_id + || !device.project_id + || device.project_id !== command.projectId + ) { + throw domainError("device_credential_binding_project_mismatch", 409); + } + if (device.lifecycle_state === "retired") { + throw domainError("device_credential_binding_lifecycle_blocked", 409); + } + return device; +} + +async function addAudit(client, { + eventType, + actorRef, + projectId, + deviceId, + 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 credentialBindingView(row) { + return { + credentialBindingRef: `credential-binding:${row.id}`, + deviceRef: `device:${row.device_id}`, + projectRef: toProjectRef(row.project_id), + purpose: row.purpose, + credentialOwner: row.credential_owner, + lifecycleState: row.lifecycle_state, + createdAt: toIso(row.created_at), + updatedAt: toIso(row.updated_at), + }; +} + +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/test/infrastructure-app.test.mjs b/device-plane/services/device-control-core/test/infrastructure-app.test.mjs index 179860b..b3d104f 100644 --- a/device-plane/services/device-control-core/test/infrastructure-app.test.mjs +++ b/device-plane/services/device-control-core/test/infrastructure-app.test.mjs @@ -116,6 +116,84 @@ test("management API forwards claim as evidence references without identity inpu } }); +test("management API accepts only a canonical credential reference", async () => { + let executed; + const runtime = await startServer({ + managementApiEnabled: true, + managementToken, + repository: { + health: async () => "ready", + executeManagementCommand: async (input) => { + executed = input; + return { + replayed: false, + result: { + credentialBinding: { + credentialBindingRef: + "credential-binding:44444444-4444-4444-8444-444444444444", + }, + }, + }; + }, + }, + }); + try { + const response = await fetch( + `${runtime.baseUrl}/internal/v1/management/device-credential-bindings:upsert`, + { + method: "POST", + headers: managementHeaders(), + body: JSON.stringify({ + projectRef: "project:11111111-1111-4111-8111-111111111111", + deviceRef: "device:22222222-2222-4222-8222-222222222222", + purpose: "tracker.command", + credentialRef: { + owner: "ndc_l2_credentials", + reference: "ndc-credref:pilot-command-0001", + }, + }), + }, + ); + + assert.equal(response.status, 200); + assert.equal( + executed.commandKind, + "device_credential_binding.upsert", + ); + assert.deepEqual(executed.command.credentialRef, { + owner: "ndc_l2_credentials", + reference: "ndc-credref:pilot-command-0001", + }); + + const rejected = await fetch( + `${runtime.baseUrl}/internal/v1/management/device-credential-bindings:upsert`, + { + method: "POST", + headers: { + ...managementHeaders(), + "Idempotency-Key": "phase24-credential-invalid-0001", + }, + body: JSON.stringify({ + projectRef: "project:11111111-1111-4111-8111-111111111111", + deviceRef: "device:22222222-2222-4222-8222-222222222222", + purpose: "tracker.command", + credentialRef: { + owner: "device_core", + reference: "ndc-credref:pilot-command-0001", + }, + }), + }, + ); + assert.equal(rejected.status, 400); + assert.equal( + (await rejected.json()).error, + "ndc_credential_reference_owner_invalid", + ); + } finally { + await runtime.close(); + } +}); + async function startServer(options) { const server = createControlCoreApp(options); await new Promise((resolve, reject) => { 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 af322b3..c795cb5 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 @@ -45,6 +45,7 @@ test("claims only matching observed enrollment evidence into direct ownership", display_name: command.displayName, })], }), + step("insert into device_restricted_identifiers"), step("update device_discoveries", { rows: [{ id: discoveryId }] }), step("update device_enrollment_intents", { rows: [{ id: enrollmentId }] }), step("insert into device_ownership_transitions"), @@ -109,6 +110,7 @@ test("authorized transfer preserves history and detaches source collections", as projectStep(targetProjectId, targetOwnerId), grantsStep(actor, "owner"), step("from device_sessions", { rows: [{ active: false }] }), + step("from device_credential_bindings", { rows: [{ active: false }] }), step("delete from device_collection_members", { rows: [], rowCount: 2 }), step("update device_instances", { rows: [deviceRow({ @@ -117,6 +119,7 @@ test("authorized transfer preserves history and detaches source collections", as device_key: command.targetDeviceKey, })], }), + step("update device_restricted_identifiers", { rows: [], rowCount: 1 }), step("insert into device_ownership_transitions"), step("insert into device_audit_events"), step("insert into device_audit_events"), @@ -135,6 +138,37 @@ test("authorized transfer preserves history and detaches source collections", as assert.equal(result.result.transferred, true); assert.equal(result.result.device.projectRef, `project:${targetProjectId}`); assert.equal(result.result.detachedCollectionCount, 2); + assert.equal(result.result.transferredIdentifierCount, 1); + assert.equal(client.remaining(), 0); + assert.equal(client.released, true); +}); + +test("transfer fails closed while a credential binding is active", async () => { + const actor = managementActor("owner"); + const command = transferCommand(); + const client = scriptedClient([ + step("begin"), + receiptStep("receipt-transfer-credential-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: true }] }), + step("rollback"), + ]); + const repository = repositoryWithClient(client); + + await assert.rejects( + repository.executeManagementCommand(commandInput({ + actor, + commandKind: "device.transfer", + command, + digestCharacter: "f", + })), + /device_transfer_active_credential_binding/, + ); assert.equal(client.remaining(), 0); assert.equal(client.released, true); }); diff --git a/device-plane/services/device-control-core/test/sensitive-reference-management.test.mjs b/device-plane/services/device-control-core/test/sensitive-reference-management.test.mjs new file mode 100644 index 0000000..9bd9cd2 --- /dev/null +++ b/device-plane/services/device-control-core/test/sensitive-reference-management.test.mjs @@ -0,0 +1,105 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + DEVICE_SENSITIVE_REFERENCE_COMMAND_KINDS, + normalizeSensitiveReferenceManagementCommand, +} from "../src/sensitive-reference-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"; + +test("credential binding commands share the strict management surface", () => { + for (const kind of DEVICE_SENSITIVE_REFERENCE_COMMAND_KINDS) { + assert.equal(ALL_DEVICE_MANAGEMENT_COMMAND_KINDS.includes(kind), true); + } + assert.equal( + normalizeDeviceManagementCommand( + "device_credential_binding.upsert", + upsertInput(), + ).projectId, + projectRef.slice("project:".length), + ); +}); + +test("credential binding accepts only the platform canonical opaque ref", () => { + const command = normalizeSensitiveReferenceManagementCommand( + "device_credential_binding.upsert", + upsertInput(), + ); + + assert.deepEqual(command.credentialRef, { + owner: "ndc_l2_credentials", + reference: "ndc-credref:pilot-command-0001", + }); + assert.equal(Object.isFrozen(command.credentialRef), true); + assert.throws( + () => normalizeSensitiveReferenceManagementCommand( + "device_credential_binding.upsert", + { + ...upsertInput(), + credentialRef: { + owner: "device_core", + reference: "ndc-credref:pilot-command-0001", + }, + }, + ), + /ndc_credential_reference_owner_invalid/, + ); + assert.throws( + () => normalizeSensitiveReferenceManagementCommand( + "device_credential_binding.upsert", + { + ...upsertInput(), + credentialRef: { + owner: "ndc_l2_credentials", + reference: "Bearer plaintext-is-forbidden", + }, + }, + ), + /ndc_credential_reference_value_invalid/, + ); +}); + +test("credential binding rejects raw secret-shaped fields", () => { + for (const field of ["password", "token", "secretValue", "endpoint"]) { + assert.throws( + () => normalizeSensitiveReferenceManagementCommand( + "device_credential_binding.upsert", + { ...upsertInput(), [field]: "forbidden" }, + ), + new RegExp(`device_management_command_field_unexpected:${field}`), + ); + } +}); + +test("credential revoke has no credential reference input", () => { + const command = normalizeSensitiveReferenceManagementCommand( + "device_credential_binding.revoke", + { + projectRef, + deviceRef, + purpose: "tracker.command", + resolutionCode: "operator.rotation", + }, + ); + + assert.equal(command.resolutionCode, "operator.rotation"); + assert.equal("credentialRef" in command, false); +}); + +function upsertInput() { + return { + projectRef, + deviceRef, + purpose: "tracker.command", + credentialRef: { + owner: "ndc_l2_credentials", + reference: "ndc-credref:pilot-command-0001", + }, + }; +} diff --git a/device-plane/services/device-control-core/test/sensitive-reference-migration.test.mjs b/device-plane/services/device-control-core/test/sensitive-reference-migration.test.mjs new file mode 100644 index 0000000..261131e --- /dev/null +++ b/device-plane/services/device-control-core/test/sensitive-reference-migration.test.mjs @@ -0,0 +1,49 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; + +const schemaUrl = new URL( + "../migrations/008_device_sensitive_references.sql", + import.meta.url, +); +const commandsUrl = new URL( + "../migrations/009_device_sensitive_reference_commands.sql", + import.meta.url, +); +const repositoryUrl = new URL("../src/postgres-repository.mjs", import.meta.url); + +test("sensitive reference schema stores only digest, mask and canonical refs", async () => { + const sql = await readFile(schemaUrl, "utf8"); + + assert.match(sql, /create table if not exists device_restricted_identifiers/); + assert.match(sql, /identifier_digest text not null/); + assert.match(sql, /identifier_masked text not null/); + assert.match(sql, /device_restricted_identifiers_active_identity_idx/); + assert.match(sql, /device_restricted_identifiers_primary_idx/); + assert.match(sql, /device_identifier_ownership_mismatch/); + assert.match(sql, /device_active_identifier_ownership_mismatch/); + assert.match(sql, /deferrable initially deferred/); + assert.match(sql, /create table if not exists device_credential_bindings/); + assert.match(sql, /credential_owner = 'ndc_l2_credentials'/); + assert.match(sql, /\^ndc-credref:/); + assert.match(sql, /device_credential_binding_ownership_mismatch/); + assert.match(sql, /device_transfer_active_credential_binding/); + assert.match(sql, /owner_scope_id is null or credential_ref is null/); + assert.doesNotMatch(sql, /imei\s+text|serial\s+text|password\s+text|token\s+text/i); + assert.doesNotMatch(sql, /insert\s+into/i); +}); + +test("credential commands extend durable receipts after their schema", async () => { + const commands = await readFile(commandsUrl, "utf8"); + const repository = await readFile(repositoryUrl, "utf8"); + + assert.match(commands, /'device_credential_binding\.upsert'/); + assert.match(commands, /'device_credential_binding\.revoke'/); + const schemaIndex = repository.indexOf("008_device_sensitive_references.sql"); + const commandsIndex = repository.indexOf( + "009_device_sensitive_reference_commands.sql", + ); + assert.notEqual(schemaIndex, -1); + assert.notEqual(commandsIndex, -1); + assert.ok(schemaIndex < commandsIndex); +}); diff --git a/device-plane/services/device-control-core/test/sensitive-reference-repository.test.mjs b/device-plane/services/device-control-core/test/sensitive-reference-repository.test.mjs new file mode 100644 index 0000000..386fee6 --- /dev/null +++ b/device-plane/services/device-control-core/test/sensitive-reference-repository.test.mjs @@ -0,0 +1,271 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +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 bindingId = "44444444-4444-4444-8444-444444444444"; +const canonicalRef = "ndc-credref:pilot-command-0001"; + +test("creates a canonical binding without returning or auditing its reference", async () => { + const actor = managementActor(); + const command = upsertCommand(); + const client = scriptedClient([ + step("begin"), + receiptStep("receipt-credential-upsert"), + projectStep(), + grantsStep(actor), + step("from device_instances", { rows: [deviceRow()] }), + step("from device_credential_bindings", { rows: [] }), + step("insert into device_credential_bindings", { + rows: [bindingRow()], + }), + 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_credential_binding.upsert", + command, + digestCharacter: "a", + })); + + assert.equal(result.result.created, true); + assert.equal(result.result.rotated, false); + assert.equal( + result.result.credentialBinding.credentialBindingRef, + `credential-binding:${bindingId}`, + ); + assert.equal(JSON.stringify(result.result).includes(canonicalRef), false); + const auditCall = client.calls.find((call) => + String(call.sql).includes("insert into device_audit_events") + ); + assert.ok(auditCall); + assert.equal(JSON.stringify(auditCall.params).includes(canonicalRef), false); + assert.equal(client.remaining(), 0); + assert.equal(client.released, true); +}); + +test("revokes by device and purpose without accepting a credential ref", async () => { + const actor = managementActor(); + const command = normalizeDeviceManagementCommand( + "device_credential_binding.revoke", + { + projectRef: `project:${projectId}`, + deviceRef: `device:${deviceId}`, + purpose: "tracker.command", + resolutionCode: "operator.rotation", + }, + ); + const client = scriptedClient([ + step("begin"), + receiptStep("receipt-credential-revoke"), + projectStep(), + grantsStep(actor), + step("from device_instances", { rows: [deviceRow()] }), + step("update device_credential_bindings", { + rows: [bindingRow({ lifecycle_state: "revoked" })], + }), + 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_credential_binding.revoke", + command, + digestCharacter: "b", + })); + + assert.equal(result.result.revoked, true); + assert.equal(result.result.credentialBinding.lifecycleState, "revoked"); + assert.equal("credentialRef" in command, false); + assert.equal(JSON.stringify(result.result).includes(canonicalRef), false); + assert.equal(client.remaining(), 0); + assert.equal(client.released, true); +}); + +test("rotates an active binding atomically and keeps both refs out of audit", async () => { + const actor = managementActor(); + const command = upsertCommand(); + const oldRef = "ndc-credref:pilot-command-old-0001"; + const client = scriptedClient([ + step("begin"), + receiptStep("receipt-credential-rotate"), + projectStep(), + grantsStep(actor), + step("from device_instances", { rows: [deviceRow()] }), + step("from device_credential_bindings", { + rows: [bindingRow({ credential_ref: oldRef })], + }), + step("update device_credential_bindings"), + step("insert into device_credential_bindings", { + rows: [bindingRow({ + id: "66666666-6666-4666-8666-666666666666", + })], + }), + 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_credential_binding.upsert", + command, + digestCharacter: "c", + })); + + assert.equal(result.result.created, true); + assert.equal(result.result.rotated, true); + const auditCall = client.calls.find((call) => + String(call.sql).includes("insert into device_audit_events") + ); + assert.ok(auditCall); + assert.equal(JSON.stringify(auditCall.params).includes(oldRef), false); + assert.equal(JSON.stringify(auditCall.params).includes(canonicalRef), false); + assert.equal(client.remaining(), 0); + assert.equal(client.released, true); +}); + +function upsertCommand() { + return normalizeDeviceManagementCommand( + "device_credential_binding.upsert", + { + projectRef: `project:${projectId}`, + deviceRef: `device:${deviceId}`, + purpose: "tracker.command", + credentialRef: { + owner: "ndc_l2_credentials", + reference: canonicalRef, + }, + }, + ); +} + +function managementActor() { + return normalizeManagementActor({ + userRef: "user:credential-operator", + hubRole: "admin", + 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: "55555555-5555-4555-8555-555555555555", + principal_kind: "user", + principal_ref: actor.userRef, + project_role: "admin", + capability_allow: [], + capability_deny: [], + lifecycle_state: "active", + }], + }); +} + +function deviceRow() { + return { + id: deviceId, + owner_scope_id: ownerId, + project_id: projectId, + lifecycle_state: "claimed", + }; +} + +function bindingRow(overrides = {}) { + return { + id: bindingId, + device_id: deviceId, + owner_scope_id: ownerId, + project_id: projectId, + purpose: "tracker.command", + credential_owner: "ndc_l2_credentials", + lifecycle_state: "active", + created_at: now, + updated_at: now, + ...overrides, + }; +} + +function receiptStep(id) { + return step("insert into device_management_command_receipts", { + rows: [{ id }], + }); +} + +function commandInput({ actor, commandKind, command, digestCharacter }) { + return { + idempotencyKey: `phase24-${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 { + calls: [], + released: false, + async query(sql, params = []) { + this.calls.push({ sql, params }); + 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/packages/external-provider-contract/src/credential-reference.mjs b/packages/external-provider-contract/src/credential-reference.mjs new file mode 100644 index 0000000..85861c3 --- /dev/null +++ b/packages/external-provider-contract/src/credential-reference.mjs @@ -0,0 +1,29 @@ +export const NDC_CREDENTIAL_REFERENCE_OWNER = "ndc_l2_credentials"; + +const CREDENTIAL_REFERENCE_PATTERN = + /^ndc-credref:[A-Za-z0-9][A-Za-z0-9._:-]{7,240}$/; + +export function normalizeNdcCredentialReference(input) { + if (!input || typeof input !== "object" || Array.isArray(input)) { + throw new TypeError("ndc_credential_reference_invalid"); + } + for (const key of Object.keys(input)) { + if (!new Set(["owner", "reference"]).has(key)) { + throw new TypeError(`ndc_credential_reference_field_unexpected:${key}`); + } + } + if (input.owner !== NDC_CREDENTIAL_REFERENCE_OWNER) { + throw new TypeError("ndc_credential_reference_owner_invalid"); + } + if (!isNdcCredentialReferenceValue(input.reference)) { + throw new TypeError("ndc_credential_reference_value_invalid"); + } + return Object.freeze({ + owner: NDC_CREDENTIAL_REFERENCE_OWNER, + reference: input.reference, + }); +} + +export function isNdcCredentialReferenceValue(value) { + return typeof value === "string" && CREDENTIAL_REFERENCE_PATTERN.test(value); +} diff --git a/packages/external-provider-contract/src/index.mjs b/packages/external-provider-contract/src/index.mjs index cf25d38..837b1b6 100644 --- a/packages/external-provider-contract/src/index.mjs +++ b/packages/external-provider-contract/src/index.mjs @@ -1,6 +1,12 @@ import { EXTERNAL_PROVIDER_CONTRACT_VERSION } from "./contract-version.mjs"; +import { isNdcCredentialReferenceValue } from "./credential-reference.mjs"; export { EXTERNAL_PROVIDER_CONTRACT_VERSION } from "./contract-version.mjs"; +export { + NDC_CREDENTIAL_REFERENCE_OWNER, + isNdcCredentialReferenceValue, + normalizeNdcCredentialReference, +} from "./credential-reference.mjs"; export { validateIntakeBatch } from "./intake-batch.mjs"; export { DATA_PRODUCT_GEOMETRY_TYPES, @@ -199,6 +205,12 @@ export function validateConnectionProfile(value) { if (value?.credentialRef?.owner && value.credentialRef.owner !== "ndc_l2_credentials") { errors.push("credentialRef.owner_must_be_ndc_l2_credentials"); } + if ( + typeof value?.credentialRef?.reference === "string" + && !isNdcCredentialReferenceValue(value.credentialRef.reference) + ) { + errors.push("credentialRef.reference_must_be_canonical_ndc_ref"); + } if (containsSecretLikeMaterial(value)) errors.push("profile_must_not_contain_secret_material"); if (value?.scope !== undefined) { if (!isPlainObject(value.scope)) { diff --git a/packages/external-provider-contract/src/l2-execution-plan.mjs b/packages/external-provider-contract/src/l2-execution-plan.mjs index 4d01eed..04b7908 100644 --- a/packages/external-provider-contract/src/l2-execution-plan.mjs +++ b/packages/external-provider-contract/src/l2-execution-plan.mjs @@ -1,4 +1,6 @@ import { createHash } from "node:crypto"; + +import { isNdcCredentialReferenceValue } from "./credential-reference.mjs"; import { validateProviderPackage } from "./provider-package.mjs"; import { compileTelemetryFieldProjection, @@ -20,7 +22,6 @@ export const L2_EXECUTION_PLAN_SUPPORTED_COMPILER_VERSIONS = Object.freeze([ const HASH = /^(?:sha256:)?[a-f0-9]{64}$/; const IDENTIFIER = /^[a-z][a-z0-9._:-]{2,127}$/; -const CREDENTIAL_REF = /^ndc-credref:[A-Za-z0-9._:-]{8,255}$/; const COMPILE_OPTION_KEYS = new Set(["telemetryFieldRegistry"]); const RECEIPT_OPTION_KEYS = new Set(["graphRevision", "graphDigest", "materializedStepIds"]); const STEP_RUNTIME_KINDS = Object.freeze({ @@ -478,7 +479,7 @@ function validateConnectionInstance(providerPackage, value) { if (typeof value.connectionId !== "string" || !IDENTIFIER.test(value.connectionId)) { throw new Error("l2_execution_plan_connection_id_invalid"); } - if (!CREDENTIAL_REF.test(String(value.credentialRefs?.provider?.reference || "")) + if (!isNdcCredentialReferenceValue(value.credentialRefs?.provider?.reference) || value.credentialRefs?.provider?.owner !== "ndc_l2_credentials") { throw new Error("l2_execution_plan_provider_credential_ref_invalid"); } diff --git a/packages/external-provider-contract/src/provider-package.mjs b/packages/external-provider-contract/src/provider-package.mjs index 24f9c6b..126396e 100644 --- a/packages/external-provider-contract/src/provider-package.mjs +++ b/packages/external-provider-contract/src/provider-package.mjs @@ -4,6 +4,7 @@ export const L2_CONNECTION_INSTANCE_SCHEMA_VERSION = "nodedc.l2-connection-insta export const SEMANTIC_MAPPING_SCHEMA_VERSION = "nodedc.semantic-mapping/v1"; import { SECRET_LIKE_VALUE as SECRET_VALUE } from "./sensitive-field-policy.mjs"; +import { isNdcCredentialReferenceValue } from "./credential-reference.mjs"; import { isBoundedTelemetryReadings } from "./telemetry-readings.mjs"; const IDENTIFIER = /^[a-z][a-z0-9._:-]{2,127}$/; @@ -1319,7 +1320,7 @@ function requiredString(value, path, errors) { } function requiredOpaqueReference(value, path, errors) { - if (typeof value !== "string" || !/^ndc-credref:[A-Za-z0-9][A-Za-z0-9._:-]{7,240}$/.test(value)) errors.push(`${path}_invalid`); + if (!isNdcCredentialReferenceValue(value)) errors.push(`${path}_invalid`); } function validateProviderBaseUrl(value, path, errors) { diff --git a/packages/external-provider-contract/test/contract.test.mjs b/packages/external-provider-contract/test/contract.test.mjs index a0b3b37..9dce1a6 100644 --- a/packages/external-provider-contract/test/contract.test.mjs +++ b/packages/external-provider-contract/test/contract.test.mjs @@ -2,7 +2,10 @@ import assert from "node:assert/strict"; import { EXTERNAL_PROVIDER_CONTRACT_VERSION, FOUNDRY_BINDING_UPSERT_SCHEMA_VERSION, + NDC_CREDENTIAL_REFERENCE_OWNER, assertValid, + isNdcCredentialReferenceValue, + normalizeNdcCredentialReference, validateCollectionProfile, validateConnectionProfile, validateDataProduct, @@ -19,6 +22,29 @@ assert.equal(validateConnectionProfile({ ...geliosPositionsCurrentExample.connection, credentialRef: { ...geliosPositionsCurrentExample.connection.credentialRef, owner: "engine" }, }).errors.includes("credentialRef.owner_must_be_ndc_l2_credentials"), true); +assert.deepEqual(normalizeNdcCredentialReference({ + owner: NDC_CREDENTIAL_REFERENCE_OWNER, + reference: "ndc-credref:provider-example-0001", +}), { + owner: "ndc_l2_credentials", + reference: "ndc-credref:provider-example-0001", +}); +assert.equal(isNdcCredentialReferenceValue("ndc-credref:provider-example-0001"), true); +assert.equal(isNdcCredentialReferenceValue("secret://provider-example"), false); +assert.equal(validateConnectionProfile({ + ...geliosPositionsCurrentExample.connection, + credentialRef: { + owner: "ndc_l2_credentials", + reference: "provider-example-0001", + }, +}).errors.includes("credentialRef.reference_must_be_canonical_ndc_ref"), true); +assert.throws( + () => normalizeNdcCredentialReference({ + owner: "device_core", + reference: "ndc-credref:provider-example-0001", + }), + /ndc_credential_reference_owner_invalid/, +); assert.equal(validateCollectionProfile(geliosPositionsCurrentExample.collectionProfile).ok, true); assert.equal(validateDataProduct(geliosPositionsCurrentExample.dataProduct).ok, true); assert.equal(validateFoundryBinding(geliosPositionsCurrentExample.foundryBinding).ok, true);