feat(device-core): add restricted identity references

This commit is contained in:
Codex
2026-08-10 18:31:30 +03:00
parent fceaca9546
commit 422ddb020f
20 changed files with 1413 additions and 7 deletions
@@ -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;
}