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 OPAQUE_REF_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||||
const IMEI_RE = /^\d{15}$/;
|
const IMEI_RE = /^\d{15}$/;
|
||||||
const DIGEST_RE = /^hmac-sha256:[a-f0-9]{64}$/;
|
const DIGEST_RE = /^hmac-sha256:[a-f0-9]{64}$/;
|
||||||
|
const IDENTIFIER_KIND_RE = /^[a-z][a-z0-9._:-]{1,63}$/;
|
||||||
const forbiddenKeyFragments = Object.freeze([
|
const forbiddenKeyFragments = Object.freeze([
|
||||||
"password",
|
"password",
|
||||||
"secret",
|
"secret",
|
||||||
@@ -88,7 +89,7 @@ export function toSafeDiscoveryView(signal, options = {}) {
|
|||||||
protocol: normalized.protocol,
|
protocol: normalized.protocol,
|
||||||
observedAt: normalized.observedAt,
|
observedAt: normalized.observedAt,
|
||||||
lifecycleState: normalized.lifecycleState,
|
lifecycleState: normalized.lifecycleState,
|
||||||
identifier: Object.freeze({
|
identifier: normalizeRestrictedIdentifierProjection({
|
||||||
kind: normalized.identifier.kind,
|
kind: normalized.identifier.kind,
|
||||||
masked: maskRestrictedIdentifier(normalized.identifier),
|
masked: maskRestrictedIdentifier(normalized.identifier),
|
||||||
}),
|
}),
|
||||||
@@ -116,6 +117,55 @@ export function assertIdentifierDigest(value) {
|
|||||||
return 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) {
|
export function normalizeDevicePlaneBinding(input) {
|
||||||
assertPlainObject(input, "device_plane_binding");
|
assertPlainObject(input, "device_plane_binding");
|
||||||
rejectForbiddenKeys(input);
|
rejectForbiddenKeys(input);
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import {
|
|||||||
hashRestrictedIdentifier,
|
hashRestrictedIdentifier,
|
||||||
normalizeDevicePlaneBinding,
|
normalizeDevicePlaneBinding,
|
||||||
normalizeDiscoverySignal,
|
normalizeDiscoverySignal,
|
||||||
|
normalizeRestrictedIdentifierProjection,
|
||||||
|
normalizeRestrictedIdentifierRecord,
|
||||||
toSafeDiscoveryView,
|
toSafeDiscoveryView,
|
||||||
} from "../src/index.mjs";
|
} 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", () => {
|
test("rejects unverified framing and command-shaped discovery input", () => {
|
||||||
assert.throws(
|
assert.throws(
|
||||||
() => normalizeDiscoverySignal({
|
() => 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/devices:transfer", "device.transfer"],
|
||||||
["/internal/v1/management/discoveries:reject", "discovery.reject"],
|
["/internal/v1/management/discoveries:reject", "discovery.reject"],
|
||||||
["/internal/v1/management/discoveries:expire", "discovery.expire"],
|
["/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({
|
export function createControlCoreApp({
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
|
|
||||||
|
import {
|
||||||
|
normalizeRestrictedIdentifierProjection,
|
||||||
|
} from "../../../packages/device-protocol-contract/src/index.mjs";
|
||||||
import { isLifecycleManagementCommand } from "./lifecycle-management.mjs";
|
import { isLifecycleManagementCommand } from "./lifecycle-management.mjs";
|
||||||
import {
|
import {
|
||||||
assertProjectCapability,
|
assertProjectCapability,
|
||||||
@@ -123,6 +126,32 @@ async function claimDevice(client, actor, command) {
|
|||||||
const device = inserted.rows[0];
|
const device = inserted.rows[0];
|
||||||
if (!device) throw domainError("device_claim_insert_failed", 409);
|
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(
|
const claimedDiscovery = await client.query(
|
||||||
`update device_discoveries
|
`update device_discoveries
|
||||||
set lifecycle_state = 'claimed',
|
set lifecycle_state = 'claimed',
|
||||||
@@ -193,6 +222,7 @@ async function claimDevice(client, actor, command) {
|
|||||||
enrollmentIntentRef: `enrollment-intent:${enrollment.id}`,
|
enrollmentIntentRef: `enrollment-intent:${enrollment.id}`,
|
||||||
discoveryRef: `discovery:${discovery.id}`,
|
discoveryRef: `discovery:${discovery.id}`,
|
||||||
ownershipTransitionRef: `ownership-transition:${transitionId}`,
|
ownershipTransitionRef: `ownership-transition:${transitionId}`,
|
||||||
|
identifierRef: `identifier:${identifierId}`,
|
||||||
modelProfileRef: device.model_profile_ref,
|
modelProfileRef: device.model_profile_ref,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -202,6 +232,7 @@ async function claimDevice(client, actor, command) {
|
|||||||
enrollmentIntentRef: `enrollment-intent:${enrollment.id}`,
|
enrollmentIntentRef: `enrollment-intent:${enrollment.id}`,
|
||||||
discoveryRef: `discovery:${discovery.id}`,
|
discoveryRef: `discovery:${discovery.id}`,
|
||||||
ownershipTransitionRef: `ownership-transition:${transitionId}`,
|
ownershipTransitionRef: `ownership-transition:${transitionId}`,
|
||||||
|
identifierRef: `identifier:${identifierId}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -329,6 +360,17 @@ async function transferDevice(client, actor, command) {
|
|||||||
throw domainError("device_transfer_active_session", 409);
|
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(
|
const detached = await client.query(
|
||||||
`delete from device_collection_members
|
`delete from device_collection_members
|
||||||
where device_id = $1 and project_id = $2`,
|
where device_id = $1 and project_id = $2`,
|
||||||
@@ -354,6 +396,18 @@ async function transferDevice(client, actor, command) {
|
|||||||
const moved = updated.rows[0];
|
const moved = updated.rows[0];
|
||||||
if (!moved) throw domainError("device_transfer_update_failed", 409);
|
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();
|
const transitionId = randomUUID();
|
||||||
await client.query(
|
await client.query(
|
||||||
`insert into device_ownership_transitions (
|
`insert into device_ownership_transitions (
|
||||||
@@ -382,6 +436,7 @@ async function transferDevice(client, actor, command) {
|
|||||||
sourceProjectRef: toProjectRef(sourceProject.id),
|
sourceProjectRef: toProjectRef(sourceProject.id),
|
||||||
targetProjectRef: toProjectRef(targetProject.id),
|
targetProjectRef: toProjectRef(targetProject.id),
|
||||||
detachedCollectionCount: Number(detached.rowCount || 0),
|
detachedCollectionCount: Number(detached.rowCount || 0),
|
||||||
|
transferredIdentifierCount: Number(movedIdentifiers.rowCount || 0),
|
||||||
};
|
};
|
||||||
await addAudit(client, {
|
await addAudit(client, {
|
||||||
eventType: "device.transferred_out",
|
eventType: "device.transferred_out",
|
||||||
@@ -403,10 +458,11 @@ async function transferDevice(client, actor, command) {
|
|||||||
sourceProjectRef: toProjectRef(sourceProject.id),
|
sourceProjectRef: toProjectRef(sourceProject.id),
|
||||||
ownershipTransitionRef: `ownership-transition:${transitionId}`,
|
ownershipTransitionRef: `ownership-transition:${transitionId}`,
|
||||||
detachedCollectionCount: Number(detached.rowCount || 0),
|
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(
|
const result = await client.query(
|
||||||
`select p.id, p.owner_scope_id, p.lifecycle_state,
|
`select p.id, p.owner_scope_id, p.lifecycle_state,
|
||||||
os.scope_kind, os.owner_ref, os.display_name as owner_display_name,
|
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,
|
modelProfileRef: row.model_profile_ref,
|
||||||
displayName: row.display_name,
|
displayName: row.display_name,
|
||||||
identifier: {
|
identifier: normalizeRestrictedIdentifierProjection({
|
||||||
kind: row.identifier_kind,
|
kind: row.identifier_kind,
|
||||||
masked: row.identifier_masked,
|
masked: row.identifier_masked,
|
||||||
},
|
}),
|
||||||
lifecycleState: row.lifecycle_state,
|
lifecycleState: row.lifecycle_state,
|
||||||
createdAt: toIso(row.created_at),
|
createdAt: toIso(row.created_at),
|
||||||
updatedAt: toIso(row.updated_at),
|
updatedAt: toIso(row.updated_at),
|
||||||
|
|||||||
@@ -12,14 +12,23 @@ import {
|
|||||||
DEVICE_MANAGEMENT_COMMAND_KINDS,
|
DEVICE_MANAGEMENT_COMMAND_KINDS,
|
||||||
normalizeManagementCommand,
|
normalizeManagementCommand,
|
||||||
} from "./project-management.mjs";
|
} 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([
|
export const ALL_DEVICE_MANAGEMENT_COMMAND_KINDS = Object.freeze([
|
||||||
...DEVICE_MANAGEMENT_COMMAND_KINDS,
|
...DEVICE_MANAGEMENT_COMMAND_KINDS,
|
||||||
...DEVICE_INFRASTRUCTURE_COMMAND_KINDS,
|
...DEVICE_INFRASTRUCTURE_COMMAND_KINDS,
|
||||||
...DEVICE_LIFECYCLE_COMMAND_KINDS,
|
...DEVICE_LIFECYCLE_COMMAND_KINDS,
|
||||||
|
...DEVICE_SENSITIVE_REFERENCE_COMMAND_KINDS,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export function normalizeDeviceManagementCommand(kind, input) {
|
export function normalizeDeviceManagementCommand(kind, input) {
|
||||||
|
if (isSensitiveReferenceManagementCommand(kind)) {
|
||||||
|
return normalizeSensitiveReferenceManagementCommand(kind, input);
|
||||||
|
}
|
||||||
if (isLifecycleManagementCommand(kind)) {
|
if (isLifecycleManagementCommand(kind)) {
|
||||||
return normalizeLifecycleManagementCommand(kind, input);
|
return normalizeLifecycleManagementCommand(kind, input);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,13 @@ import {
|
|||||||
applyLifecycleManagementCommand,
|
applyLifecycleManagementCommand,
|
||||||
authorizeLifecycleManagementReplay,
|
authorizeLifecycleManagementReplay,
|
||||||
} from "./lifecycle-repository.mjs";
|
} from "./lifecycle-repository.mjs";
|
||||||
|
import {
|
||||||
|
applySensitiveReferenceManagementCommand,
|
||||||
|
authorizeSensitiveReferenceManagementReplay,
|
||||||
|
} from "./sensitive-reference-repository.mjs";
|
||||||
|
import {
|
||||||
|
isSensitiveReferenceManagementCommand,
|
||||||
|
} from "./sensitive-reference-management.mjs";
|
||||||
import {
|
import {
|
||||||
assertActorCanManageOwnerScope,
|
assertActorCanManageOwnerScope,
|
||||||
assertGrantMutationAllowed,
|
assertGrantMutationAllowed,
|
||||||
@@ -34,6 +41,8 @@ const migrationFiles = [
|
|||||||
"005_device_registry_commands.sql",
|
"005_device_registry_commands.sql",
|
||||||
"006_device_lifecycle_ownership.sql",
|
"006_device_lifecycle_ownership.sql",
|
||||||
"007_device_lifecycle_commands.sql",
|
"007_device_lifecycle_commands.sql",
|
||||||
|
"008_device_sensitive_references.sql",
|
||||||
|
"009_device_sensitive_reference_commands.sql",
|
||||||
];
|
];
|
||||||
|
|
||||||
export class PostgresDeviceRepository {
|
export class PostgresDeviceRepository {
|
||||||
@@ -217,6 +226,13 @@ async function completeManagementReceipt(client, receiptId, result) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function applyManagementCommand(client, { commandKind, actor, command }) {
|
async function applyManagementCommand(client, { commandKind, actor, command }) {
|
||||||
|
if (isSensitiveReferenceManagementCommand(commandKind)) {
|
||||||
|
return applySensitiveReferenceManagementCommand(client, {
|
||||||
|
commandKind,
|
||||||
|
actor,
|
||||||
|
command,
|
||||||
|
});
|
||||||
|
}
|
||||||
if (isLifecycleManagementCommand(commandKind)) {
|
if (isLifecycleManagementCommand(commandKind)) {
|
||||||
return applyLifecycleManagementCommand(client, {
|
return applyLifecycleManagementCommand(client, {
|
||||||
commandKind,
|
commandKind,
|
||||||
@@ -247,6 +263,13 @@ async function applyManagementCommand(client, { commandKind, actor, command }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function authorizeManagementReplay(client, { commandKind, actor, command }) {
|
async function authorizeManagementReplay(client, { commandKind, actor, command }) {
|
||||||
|
if (isSensitiveReferenceManagementCommand(commandKind)) {
|
||||||
|
return authorizeSensitiveReferenceManagementReplay(client, {
|
||||||
|
commandKind,
|
||||||
|
actor,
|
||||||
|
command,
|
||||||
|
});
|
||||||
|
}
|
||||||
if (isLifecycleManagementCommand(commandKind)) {
|
if (isLifecycleManagementCommand(commandKind)) {
|
||||||
return authorizeLifecycleManagementReplay(client, {
|
return authorizeLifecycleManagementReplay(client, {
|
||||||
commandKind,
|
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) {
|
async function startServer(options) {
|
||||||
const server = createControlCoreApp(options);
|
const server = createControlCoreApp(options);
|
||||||
await new Promise((resolve, reject) => {
|
await new Promise((resolve, reject) => {
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ test("claims only matching observed enrollment evidence into direct ownership",
|
|||||||
display_name: command.displayName,
|
display_name: command.displayName,
|
||||||
})],
|
})],
|
||||||
}),
|
}),
|
||||||
|
step("insert into device_restricted_identifiers"),
|
||||||
step("update device_discoveries", { rows: [{ id: discoveryId }] }),
|
step("update device_discoveries", { rows: [{ id: discoveryId }] }),
|
||||||
step("update device_enrollment_intents", { rows: [{ id: enrollmentId }] }),
|
step("update device_enrollment_intents", { rows: [{ id: enrollmentId }] }),
|
||||||
step("insert into device_ownership_transitions"),
|
step("insert into device_ownership_transitions"),
|
||||||
@@ -109,6 +110,7 @@ test("authorized transfer preserves history and detaches source collections", as
|
|||||||
projectStep(targetProjectId, targetOwnerId),
|
projectStep(targetProjectId, targetOwnerId),
|
||||||
grantsStep(actor, "owner"),
|
grantsStep(actor, "owner"),
|
||||||
step("from device_sessions", { rows: [{ active: false }] }),
|
step("from device_sessions", { rows: [{ active: false }] }),
|
||||||
|
step("from device_credential_bindings", { rows: [{ active: false }] }),
|
||||||
step("delete from device_collection_members", { rows: [], rowCount: 2 }),
|
step("delete from device_collection_members", { rows: [], rowCount: 2 }),
|
||||||
step("update device_instances", {
|
step("update device_instances", {
|
||||||
rows: [deviceRow({
|
rows: [deviceRow({
|
||||||
@@ -117,6 +119,7 @@ test("authorized transfer preserves history and detaches source collections", as
|
|||||||
device_key: command.targetDeviceKey,
|
device_key: command.targetDeviceKey,
|
||||||
})],
|
})],
|
||||||
}),
|
}),
|
||||||
|
step("update device_restricted_identifiers", { rows: [], rowCount: 1 }),
|
||||||
step("insert into device_ownership_transitions"),
|
step("insert into device_ownership_transitions"),
|
||||||
step("insert into device_audit_events"),
|
step("insert into device_audit_events"),
|
||||||
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.transferred, true);
|
||||||
assert.equal(result.result.device.projectRef, `project:${targetProjectId}`);
|
assert.equal(result.result.device.projectRef, `project:${targetProjectId}`);
|
||||||
assert.equal(result.result.detachedCollectionCount, 2);
|
assert.equal(result.result.detachedCollectionCount, 2);
|
||||||
|
assert.equal(result.result.transferredIdentifierCount, 1);
|
||||||
|
assert.equal(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.remaining(), 0);
|
||||||
assert.equal(client.released, true);
|
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 { EXTERNAL_PROVIDER_CONTRACT_VERSION } from "./contract-version.mjs";
|
||||||
|
import { isNdcCredentialReferenceValue } from "./credential-reference.mjs";
|
||||||
|
|
||||||
export { EXTERNAL_PROVIDER_CONTRACT_VERSION } from "./contract-version.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 { validateIntakeBatch } from "./intake-batch.mjs";
|
||||||
export {
|
export {
|
||||||
DATA_PRODUCT_GEOMETRY_TYPES,
|
DATA_PRODUCT_GEOMETRY_TYPES,
|
||||||
@@ -199,6 +205,12 @@ export function validateConnectionProfile(value) {
|
|||||||
if (value?.credentialRef?.owner && value.credentialRef.owner !== "ndc_l2_credentials") {
|
if (value?.credentialRef?.owner && value.credentialRef.owner !== "ndc_l2_credentials") {
|
||||||
errors.push("credentialRef.owner_must_be_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 (containsSecretLikeMaterial(value)) errors.push("profile_must_not_contain_secret_material");
|
||||||
if (value?.scope !== undefined) {
|
if (value?.scope !== undefined) {
|
||||||
if (!isPlainObject(value.scope)) {
|
if (!isPlainObject(value.scope)) {
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
|
|
||||||
|
import { isNdcCredentialReferenceValue } from "./credential-reference.mjs";
|
||||||
import { validateProviderPackage } from "./provider-package.mjs";
|
import { validateProviderPackage } from "./provider-package.mjs";
|
||||||
import {
|
import {
|
||||||
compileTelemetryFieldProjection,
|
compileTelemetryFieldProjection,
|
||||||
@@ -20,7 +22,6 @@ export const L2_EXECUTION_PLAN_SUPPORTED_COMPILER_VERSIONS = Object.freeze([
|
|||||||
|
|
||||||
const HASH = /^(?:sha256:)?[a-f0-9]{64}$/;
|
const HASH = /^(?:sha256:)?[a-f0-9]{64}$/;
|
||||||
const IDENTIFIER = /^[a-z][a-z0-9._:-]{2,127}$/;
|
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 COMPILE_OPTION_KEYS = new Set(["telemetryFieldRegistry"]);
|
||||||
const RECEIPT_OPTION_KEYS = new Set(["graphRevision", "graphDigest", "materializedStepIds"]);
|
const RECEIPT_OPTION_KEYS = new Set(["graphRevision", "graphDigest", "materializedStepIds"]);
|
||||||
const STEP_RUNTIME_KINDS = Object.freeze({
|
const STEP_RUNTIME_KINDS = Object.freeze({
|
||||||
@@ -478,7 +479,7 @@ function validateConnectionInstance(providerPackage, value) {
|
|||||||
if (typeof value.connectionId !== "string" || !IDENTIFIER.test(value.connectionId)) {
|
if (typeof value.connectionId !== "string" || !IDENTIFIER.test(value.connectionId)) {
|
||||||
throw new Error("l2_execution_plan_connection_id_invalid");
|
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") {
|
|| value.credentialRefs?.provider?.owner !== "ndc_l2_credentials") {
|
||||||
throw new Error("l2_execution_plan_provider_credential_ref_invalid");
|
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";
|
export const SEMANTIC_MAPPING_SCHEMA_VERSION = "nodedc.semantic-mapping/v1";
|
||||||
|
|
||||||
import { SECRET_LIKE_VALUE as SECRET_VALUE } from "./sensitive-field-policy.mjs";
|
import { SECRET_LIKE_VALUE as SECRET_VALUE } from "./sensitive-field-policy.mjs";
|
||||||
|
import { isNdcCredentialReferenceValue } from "./credential-reference.mjs";
|
||||||
import { isBoundedTelemetryReadings } from "./telemetry-readings.mjs";
|
import { isBoundedTelemetryReadings } from "./telemetry-readings.mjs";
|
||||||
|
|
||||||
const IDENTIFIER = /^[a-z][a-z0-9._:-]{2,127}$/;
|
const IDENTIFIER = /^[a-z][a-z0-9._:-]{2,127}$/;
|
||||||
@@ -1319,7 +1320,7 @@ function requiredString(value, path, errors) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function requiredOpaqueReference(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) {
|
function validateProviderBaseUrl(value, path, errors) {
|
||||||
|
|||||||
@@ -2,7 +2,10 @@ import assert from "node:assert/strict";
|
|||||||
import {
|
import {
|
||||||
EXTERNAL_PROVIDER_CONTRACT_VERSION,
|
EXTERNAL_PROVIDER_CONTRACT_VERSION,
|
||||||
FOUNDRY_BINDING_UPSERT_SCHEMA_VERSION,
|
FOUNDRY_BINDING_UPSERT_SCHEMA_VERSION,
|
||||||
|
NDC_CREDENTIAL_REFERENCE_OWNER,
|
||||||
assertValid,
|
assertValid,
|
||||||
|
isNdcCredentialReferenceValue,
|
||||||
|
normalizeNdcCredentialReference,
|
||||||
validateCollectionProfile,
|
validateCollectionProfile,
|
||||||
validateConnectionProfile,
|
validateConnectionProfile,
|
||||||
validateDataProduct,
|
validateDataProduct,
|
||||||
@@ -19,6 +22,29 @@ assert.equal(validateConnectionProfile({
|
|||||||
...geliosPositionsCurrentExample.connection,
|
...geliosPositionsCurrentExample.connection,
|
||||||
credentialRef: { ...geliosPositionsCurrentExample.connection.credentialRef, owner: "engine" },
|
credentialRef: { ...geliosPositionsCurrentExample.connection.credentialRef, owner: "engine" },
|
||||||
}).errors.includes("credentialRef.owner_must_be_ndc_l2_credentials"), true);
|
}).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(validateCollectionProfile(geliosPositionsCurrentExample.collectionProfile).ok, true);
|
||||||
assert.equal(validateDataProduct(geliosPositionsCurrentExample.dataProduct).ok, true);
|
assert.equal(validateDataProduct(geliosPositionsCurrentExample.dataProduct).ok, true);
|
||||||
assert.equal(validateFoundryBinding(geliosPositionsCurrentExample.foundryBinding).ok, true);
|
assert.equal(validateFoundryBinding(geliosPositionsCurrentExample.foundryBinding).ok, true);
|
||||||
|
|||||||
Reference in New Issue
Block a user