feat(device-core): add restricted identity references
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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({
|
||||
|
||||
+233
@@ -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;
|
||||
+27
@@ -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;
|
||||
@@ -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({
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
+105
@@ -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",
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
+271
@@ -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, "\\$&");
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user