feat(device-core): add restricted identity references
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user