feat(device-core): add control resource ledger
This commit is contained in:
@@ -35,6 +35,16 @@ const managementRoutes = new Map([
|
||||
"/internal/v1/management/device-credential-bindings:revoke",
|
||||
"device_credential_binding.revoke",
|
||||
],
|
||||
["/internal/v1/management/device-bindings:ensure", "device_binding.ensure"],
|
||||
["/internal/v1/management/device-bindings:revoke", "device_binding.revoke"],
|
||||
[
|
||||
"/internal/v1/management/device-configuration-revisions:create",
|
||||
"device_configuration_revision.create",
|
||||
],
|
||||
[
|
||||
"/internal/v1/management/device-configurations:set-desired",
|
||||
"device_configuration_desired.set",
|
||||
],
|
||||
]);
|
||||
|
||||
export function createControlCoreApp({
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import {
|
||||
DEVICE_BINDING_CAPABILITIES,
|
||||
assertSafeProjection,
|
||||
} from "../../../packages/device-protocol-contract/src/index.mjs";
|
||||
|
||||
export const DEVICE_CONTROL_RESOURCE_COMMAND_KINDS = Object.freeze([
|
||||
"device_binding.ensure",
|
||||
"device_binding.revoke",
|
||||
"device_configuration_revision.create",
|
||||
"device_configuration_desired.set",
|
||||
]);
|
||||
|
||||
const commandKindSet = new Set(DEVICE_CONTROL_RESOURCE_COMMAND_KINDS);
|
||||
const bindingCapabilitySet = new Set(DEVICE_BINDING_CAPABILITIES);
|
||||
const keyPattern = /^[a-z][a-z0-9-]{1,62}$/;
|
||||
const tokenPattern = /^[a-z][a-z0-9._:-]{1,63}$/;
|
||||
const resolutionPattern = /^[a-z][a-z0-9._-]{1,63}$/;
|
||||
const targetRefPattern = /^[A-Za-z0-9][A-Za-z0-9._:/+-]{2,255}$/;
|
||||
const configurationKeyPattern = /^[a-z][a-z0-9._-]{0,63}$/;
|
||||
const secretReferencePattern = /^(?:ndc-credref:|(?:bearer|basic)\s)|[?&](?:token|secret|password|api[_-]?key)=/i;
|
||||
|
||||
export function isControlResourceManagementCommand(kind) {
|
||||
return commandKindSet.has(kind);
|
||||
}
|
||||
|
||||
export function normalizeControlResourceManagementCommand(kind, input) {
|
||||
if (!commandKindSet.has(kind)) {
|
||||
throw new TypeError("device_control_resource_command_kind_invalid");
|
||||
}
|
||||
assertPlainObject(input, "device_control_resource_command_invalid");
|
||||
|
||||
if (kind === "device_binding.ensure") {
|
||||
assertAllowedKeys(input, [
|
||||
"projectRef",
|
||||
"bindingKey",
|
||||
"displayName",
|
||||
"source",
|
||||
"targetKind",
|
||||
"targetRef",
|
||||
"capabilities",
|
||||
]);
|
||||
return Object.freeze({
|
||||
projectId: normalizeEntityRef(input.projectRef, "project"),
|
||||
bindingKey: normalizePattern(
|
||||
input.bindingKey,
|
||||
keyPattern,
|
||||
"device_binding_key_invalid",
|
||||
),
|
||||
displayName: normalizeDisplayText(
|
||||
input.displayName,
|
||||
160,
|
||||
"device_binding_name_invalid",
|
||||
),
|
||||
source: normalizeBindingSource(input.source),
|
||||
targetKind: normalizePattern(
|
||||
input.targetKind,
|
||||
tokenPattern,
|
||||
"device_binding_target_kind_invalid",
|
||||
),
|
||||
targetRef: normalizeTargetRef(input.targetRef),
|
||||
capabilities: Object.freeze(normalizeBindingCapabilities(
|
||||
input.capabilities,
|
||||
)),
|
||||
});
|
||||
}
|
||||
|
||||
if (kind === "device_binding.revoke") {
|
||||
assertAllowedKeys(input, ["projectRef", "bindingRef", "resolutionCode"]);
|
||||
return Object.freeze({
|
||||
projectId: normalizeEntityRef(input.projectRef, "project"),
|
||||
bindingId: normalizeEntityRef(input.bindingRef, "binding"),
|
||||
resolutionCode: normalizePattern(
|
||||
input.resolutionCode,
|
||||
resolutionPattern,
|
||||
"device_binding_resolution_code_invalid",
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
if (kind === "device_configuration_revision.create") {
|
||||
assertAllowedKeys(input, [
|
||||
"projectRef",
|
||||
"deviceRef",
|
||||
"configuration",
|
||||
"changeSummary",
|
||||
]);
|
||||
const configuration = normalizeDeviceConfiguration(input.configuration);
|
||||
return Object.freeze({
|
||||
projectId: normalizeEntityRef(input.projectRef, "project"),
|
||||
deviceId: normalizeEntityRef(input.deviceRef, "device"),
|
||||
configuration,
|
||||
configurationDigest: `sha256:${createHash("sha256")
|
||||
.update(JSON.stringify(configuration), "utf8")
|
||||
.digest("hex")}`,
|
||||
changeSummary: normalizeOptionalText(
|
||||
input.changeSummary,
|
||||
1000,
|
||||
"device_configuration_change_summary_invalid",
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
assertAllowedKeys(input, [
|
||||
"projectRef",
|
||||
"deviceRef",
|
||||
"configurationRevisionRef",
|
||||
]);
|
||||
return Object.freeze({
|
||||
projectId: normalizeEntityRef(input.projectRef, "project"),
|
||||
deviceId: normalizeEntityRef(input.deviceRef, "device"),
|
||||
configurationRevisionId: normalizeEntityRef(
|
||||
input.configurationRevisionRef,
|
||||
"configuration-revision",
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeDeviceConfiguration(input) {
|
||||
const normalized = normalizeConfigurationValue(input, 0, "$configuration");
|
||||
if (!normalized || typeof normalized !== "object" || Array.isArray(normalized)) {
|
||||
throw new TypeError("device_configuration_must_be_object");
|
||||
}
|
||||
if (Object.keys(normalized).length === 0) {
|
||||
throw new TypeError("device_configuration_must_not_be_empty");
|
||||
}
|
||||
const serialized = JSON.stringify(normalized);
|
||||
if (Buffer.byteLength(serialized, "utf8") > 32768) {
|
||||
throw new TypeError("device_configuration_too_large");
|
||||
}
|
||||
assertSafeProjection({ configuration: normalized });
|
||||
return deepFreeze(normalized);
|
||||
}
|
||||
|
||||
function normalizeBindingSource(input) {
|
||||
assertPlainObject(input, "device_binding_source_invalid");
|
||||
assertAllowedKeys(input, ["kind", "ref"]);
|
||||
if (input.kind === "device") {
|
||||
return Object.freeze({
|
||||
kind: "device",
|
||||
id: normalizeEntityRef(input.ref, "device"),
|
||||
});
|
||||
}
|
||||
if (input.kind === "collection") {
|
||||
return Object.freeze({
|
||||
kind: "collection",
|
||||
id: normalizeEntityRef(input.ref, "collection"),
|
||||
});
|
||||
}
|
||||
throw new TypeError("device_binding_source_kind_invalid");
|
||||
}
|
||||
|
||||
function normalizeBindingCapabilities(input) {
|
||||
if (!Array.isArray(input) || input.length < 1 || input.length > 16) {
|
||||
throw new TypeError("device_binding_capabilities_invalid");
|
||||
}
|
||||
const normalized = input.map((value) => {
|
||||
if (typeof value !== "string" || !bindingCapabilitySet.has(value)) {
|
||||
throw new TypeError("device_binding_capability_invalid");
|
||||
}
|
||||
return value;
|
||||
});
|
||||
if (new Set(normalized).size !== normalized.length) {
|
||||
throw new TypeError("device_binding_capabilities_duplicate");
|
||||
}
|
||||
return normalized.sort();
|
||||
}
|
||||
|
||||
function normalizeTargetRef(value) {
|
||||
if (
|
||||
typeof value !== "string"
|
||||
|| !targetRefPattern.test(value)
|
||||
|| secretReferencePattern.test(value)
|
||||
) {
|
||||
throw new TypeError("device_binding_target_ref_invalid");
|
||||
}
|
||||
assertSafeProjection({ targetRef: value });
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeConfigurationValue(value, depth, path) {
|
||||
if (depth > 5) throw new TypeError("device_configuration_depth_exceeded");
|
||||
if (value === null || typeof value === "boolean") return value;
|
||||
if (typeof value === "number") {
|
||||
if (!Number.isFinite(value)) {
|
||||
throw new TypeError(`device_configuration_number_invalid:${path}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
if (
|
||||
value.length > 1000
|
||||
|| /\u0000|[\u0001-\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(value)
|
||||
) {
|
||||
throw new TypeError(`device_configuration_string_invalid:${path}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length > 64) {
|
||||
throw new TypeError(`device_configuration_array_invalid:${path}`);
|
||||
}
|
||||
return value.map((item, index) =>
|
||||
normalizeConfigurationValue(item, depth + 1, `${path}[${index}]`)
|
||||
);
|
||||
}
|
||||
assertPlainObject(value, `device_configuration_object_invalid:${path}`);
|
||||
const keys = Object.keys(value);
|
||||
if (keys.length > 64) {
|
||||
throw new TypeError(`device_configuration_object_invalid:${path}`);
|
||||
}
|
||||
const normalized = {};
|
||||
for (const key of keys.sort()) {
|
||||
if (!configurationKeyPattern.test(key)) {
|
||||
throw new TypeError(`device_configuration_key_invalid:${path}.${key}`);
|
||||
}
|
||||
normalized[key] = normalizeConfigurationValue(
|
||||
value[key],
|
||||
depth + 1,
|
||||
`${path}.${key}`,
|
||||
);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
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 normalizeDisplayText(value, maxLength, code) {
|
||||
if (typeof value !== "string") throw new TypeError(code);
|
||||
const normalized = value.trim();
|
||||
if (
|
||||
normalized.length < 1
|
||||
|| normalized.length > maxLength
|
||||
|| /\u0000|[\u0001-\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(normalized)
|
||||
) {
|
||||
throw new TypeError(code);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeOptionalText(value, maxLength, code) {
|
||||
if (value == null || value === "") return null;
|
||||
return normalizeDisplayText(value, maxLength, code);
|
||||
}
|
||||
|
||||
function assertPlainObject(value, code) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new TypeError(code);
|
||||
}
|
||||
}
|
||||
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function deepFreeze(value) {
|
||||
if (!value || typeof value !== "object" || Object.isFrozen(value)) return value;
|
||||
Object.freeze(value);
|
||||
for (const child of Object.values(value)) deepFreeze(child);
|
||||
return value;
|
||||
}
|
||||
@@ -0,0 +1,498 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
import {
|
||||
isControlResourceManagementCommand,
|
||||
} from "./control-resource-management.mjs";
|
||||
import { findProjectWithCapability } from "./lifecycle-repository.mjs";
|
||||
import { toProjectRef } from "./project-management.mjs";
|
||||
|
||||
export async function applyControlResourceManagementCommand(
|
||||
client,
|
||||
{ commandKind, actor, command },
|
||||
) {
|
||||
if (!isControlResourceManagementCommand(commandKind)) {
|
||||
throw new TypeError("device_control_resource_command_kind_invalid");
|
||||
}
|
||||
if (commandKind === "device_binding.ensure") {
|
||||
return ensureBinding(client, actor, command);
|
||||
}
|
||||
if (commandKind === "device_binding.revoke") {
|
||||
return revokeBinding(client, actor, command);
|
||||
}
|
||||
if (commandKind === "device_configuration_revision.create") {
|
||||
return createConfigurationRevision(client, actor, command);
|
||||
}
|
||||
return setDesiredConfiguration(client, actor, command);
|
||||
}
|
||||
|
||||
export async function authorizeControlResourceManagementReplay(
|
||||
client,
|
||||
{ commandKind, actor, command },
|
||||
) {
|
||||
if (!isControlResourceManagementCommand(commandKind)) {
|
||||
throw new TypeError("device_control_resource_command_kind_invalid");
|
||||
}
|
||||
const capability = commandKind.startsWith("device_binding.")
|
||||
? "binding.manage"
|
||||
: "configuration.manage";
|
||||
await findProjectWithCapability(
|
||||
client,
|
||||
actor,
|
||||
command.projectId,
|
||||
capability,
|
||||
);
|
||||
if (command.deviceId) {
|
||||
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,
|
||||
capability,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureBinding(client, actor, command) {
|
||||
const project = await findProjectWithCapability(
|
||||
client,
|
||||
actor,
|
||||
command.projectId,
|
||||
"binding.manage",
|
||||
);
|
||||
const source = await findBindingSource(client, command);
|
||||
const bindingId = randomUUID();
|
||||
const result = await client.query(
|
||||
`insert into device_resource_bindings (
|
||||
id,
|
||||
owner_scope_id,
|
||||
project_id,
|
||||
binding_key,
|
||||
display_name,
|
||||
source_kind,
|
||||
device_id,
|
||||
collection_id,
|
||||
target_kind,
|
||||
target_ref,
|
||||
capabilities,
|
||||
source_approved_by_ref
|
||||
) values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||
on conflict (project_id, binding_key) do update set
|
||||
display_name = excluded.display_name,
|
||||
capabilities = excluded.capabilities,
|
||||
updated_at = now()
|
||||
where device_resource_bindings.lifecycle_state = 'pending_external_approval'
|
||||
and device_resource_bindings.source_kind = excluded.source_kind
|
||||
and device_resource_bindings.device_id is not distinct from excluded.device_id
|
||||
and device_resource_bindings.collection_id is not distinct from excluded.collection_id
|
||||
and device_resource_bindings.target_kind = excluded.target_kind
|
||||
and device_resource_bindings.target_ref = excluded.target_ref
|
||||
returning id, owner_scope_id, project_id, binding_key, display_name,
|
||||
source_kind, device_id, collection_id, target_kind, target_ref,
|
||||
capabilities, lifecycle_state, source_approved_at, created_at, updated_at,
|
||||
(xmax = 0) as created`,
|
||||
[
|
||||
bindingId,
|
||||
project.owner_scope_id,
|
||||
project.id,
|
||||
command.bindingKey,
|
||||
command.displayName,
|
||||
command.source.kind,
|
||||
command.source.kind === "device" ? source.id : null,
|
||||
command.source.kind === "collection" ? source.id : null,
|
||||
command.targetKind,
|
||||
command.targetRef,
|
||||
command.capabilities,
|
||||
actor.userRef,
|
||||
],
|
||||
);
|
||||
const binding = result.rows[0];
|
||||
if (!binding) throw domainError("device_binding_identity_conflict", 409);
|
||||
|
||||
await addAudit(client, {
|
||||
eventType: binding.created
|
||||
? "device_binding.created"
|
||||
: "device_binding.updated",
|
||||
actorRef: actor.userRef,
|
||||
projectId: project.id,
|
||||
deviceId: binding.device_id,
|
||||
payload: {
|
||||
bindingRef: `binding:${binding.id}`,
|
||||
projectRef: toProjectRef(project.id),
|
||||
bindingKey: binding.binding_key,
|
||||
sourceKind: binding.source_kind,
|
||||
sourceRef: bindingSourceRef(binding),
|
||||
targetKind: binding.target_kind,
|
||||
targetRef: binding.target_ref,
|
||||
lifecycleState: binding.lifecycle_state,
|
||||
},
|
||||
});
|
||||
return {
|
||||
created: binding.created === true,
|
||||
binding: bindingView(binding),
|
||||
};
|
||||
}
|
||||
|
||||
async function revokeBinding(client, actor, command) {
|
||||
const project = await findProjectWithCapability(
|
||||
client,
|
||||
actor,
|
||||
command.projectId,
|
||||
"binding.manage",
|
||||
);
|
||||
const result = await client.query(
|
||||
`update device_resource_bindings
|
||||
set lifecycle_state = 'revoked',
|
||||
revoked_at = now(),
|
||||
revoked_by_ref = $3,
|
||||
revocation_code = $4,
|
||||
updated_at = now()
|
||||
where id = $1
|
||||
and project_id = $2
|
||||
and lifecycle_state <> 'revoked'
|
||||
returning id, owner_scope_id, project_id, binding_key, display_name,
|
||||
source_kind, device_id, collection_id, target_kind, target_ref,
|
||||
capabilities, lifecycle_state, source_approved_at, created_at, updated_at`,
|
||||
[command.bindingId, project.id, actor.userRef, command.resolutionCode],
|
||||
);
|
||||
const binding = result.rows[0];
|
||||
if (!binding) throw domainError("device_binding_not_found", 404);
|
||||
|
||||
await addAudit(client, {
|
||||
eventType: "device_binding.revoked",
|
||||
actorRef: actor.userRef,
|
||||
projectId: project.id,
|
||||
deviceId: binding.device_id,
|
||||
payload: {
|
||||
bindingRef: `binding:${binding.id}`,
|
||||
projectRef: toProjectRef(project.id),
|
||||
sourceKind: binding.source_kind,
|
||||
sourceRef: bindingSourceRef(binding),
|
||||
targetKind: binding.target_kind,
|
||||
targetRef: binding.target_ref,
|
||||
lifecycleState: binding.lifecycle_state,
|
||||
resolutionCode: command.resolutionCode,
|
||||
},
|
||||
});
|
||||
return {
|
||||
revoked: true,
|
||||
binding: bindingView(binding),
|
||||
resolutionCode: command.resolutionCode,
|
||||
};
|
||||
}
|
||||
|
||||
async function createConfigurationRevision(client, actor, command) {
|
||||
const project = await findProjectWithCapability(
|
||||
client,
|
||||
actor,
|
||||
command.projectId,
|
||||
"configuration.manage",
|
||||
);
|
||||
const device = await findDirectDeviceForUpdate(client, command);
|
||||
const profileResult = await client.query(
|
||||
`select profile_ref, schema_artifact_ref, lifecycle_state
|
||||
from device_model_profiles
|
||||
where profile_ref = $1
|
||||
for share`,
|
||||
[device.model_profile_ref],
|
||||
);
|
||||
const profile = profileResult.rows[0];
|
||||
if (
|
||||
!profile
|
||||
|| profile.lifecycle_state !== "active"
|
||||
|| !profile.schema_artifact_ref
|
||||
) {
|
||||
throw domainError("device_configuration_profile_unavailable", 409);
|
||||
}
|
||||
const nextResult = await client.query(
|
||||
`select coalesce(max(revision_number), 0) + 1 as next_revision
|
||||
from device_configuration_revisions
|
||||
where device_id = $1`,
|
||||
[device.id],
|
||||
);
|
||||
const revisionNumber = Number(nextResult.rows[0]?.next_revision);
|
||||
if (!Number.isSafeInteger(revisionNumber) || revisionNumber < 1) {
|
||||
throw domainError("device_configuration_revision_sequence_invalid", 409);
|
||||
}
|
||||
const revisionId = randomUUID();
|
||||
const inserted = await client.query(
|
||||
`insert into device_configuration_revisions (
|
||||
id,
|
||||
owner_scope_id,
|
||||
project_id,
|
||||
device_id,
|
||||
revision_number,
|
||||
model_profile_ref,
|
||||
schema_artifact_ref,
|
||||
configuration_digest,
|
||||
configuration,
|
||||
change_summary,
|
||||
created_by_ref
|
||||
) values ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, $10, $11)
|
||||
returning id, owner_scope_id, project_id, device_id, revision_number,
|
||||
model_profile_ref, schema_artifact_ref, configuration_digest,
|
||||
configuration, change_summary, created_at`,
|
||||
[
|
||||
revisionId,
|
||||
project.owner_scope_id,
|
||||
project.id,
|
||||
device.id,
|
||||
revisionNumber,
|
||||
profile.profile_ref,
|
||||
profile.schema_artifact_ref,
|
||||
command.configurationDigest,
|
||||
JSON.stringify(command.configuration),
|
||||
command.changeSummary,
|
||||
actor.userRef,
|
||||
],
|
||||
);
|
||||
const revision = inserted.rows[0];
|
||||
if (!revision) {
|
||||
throw domainError("device_configuration_revision_insert_failed", 409);
|
||||
}
|
||||
|
||||
await addAudit(client, {
|
||||
eventType: "device_configuration_revision.created",
|
||||
actorRef: actor.userRef,
|
||||
projectId: project.id,
|
||||
deviceId: device.id,
|
||||
payload: {
|
||||
deviceRef: `device:${device.id}`,
|
||||
projectRef: toProjectRef(project.id),
|
||||
configurationRevisionRef: `configuration-revision:${revision.id}`,
|
||||
revisionNumber: Number(revision.revision_number),
|
||||
modelProfileRef: revision.model_profile_ref,
|
||||
schemaArtifactRef: revision.schema_artifact_ref,
|
||||
configurationDigest: revision.configuration_digest,
|
||||
},
|
||||
});
|
||||
return {
|
||||
created: true,
|
||||
configurationRevision: configurationRevisionView(revision),
|
||||
};
|
||||
}
|
||||
|
||||
async function setDesiredConfiguration(client, actor, command) {
|
||||
const project = await findProjectWithCapability(
|
||||
client,
|
||||
actor,
|
||||
command.projectId,
|
||||
"configuration.manage",
|
||||
);
|
||||
const device = await findDirectDeviceForUpdate(client, command);
|
||||
const revisionResult = await client.query(
|
||||
`select id, project_id, device_id, revision_number,
|
||||
model_profile_ref, schema_artifact_ref, configuration_digest,
|
||||
configuration, change_summary, created_at
|
||||
from device_configuration_revisions
|
||||
where id = $1 and device_id = $2 and project_id = $3
|
||||
for share`,
|
||||
[command.configurationRevisionId, device.id, project.id],
|
||||
);
|
||||
const revision = revisionResult.rows[0];
|
||||
if (!revision) throw domainError("device_configuration_revision_not_found", 404);
|
||||
|
||||
const currentResult = await client.query(
|
||||
`select desired_revision_id, applied_revision_id
|
||||
from device_configuration_state
|
||||
where device_id = $1
|
||||
for update`,
|
||||
[device.id],
|
||||
);
|
||||
const current = currentResult.rows[0] ?? null;
|
||||
if (current?.desired_revision_id === revision.id) {
|
||||
return {
|
||||
changed: false,
|
||||
configurationState: configurationStateView({
|
||||
device_id: device.id,
|
||||
project_id: project.id,
|
||||
desired_revision_id: revision.id,
|
||||
applied_revision_id: current.applied_revision_id,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const stateResult = await client.query(
|
||||
`insert into device_configuration_state (
|
||||
device_id,
|
||||
owner_scope_id,
|
||||
project_id,
|
||||
desired_revision_id
|
||||
) values ($1, $2, $3, $4)
|
||||
on conflict (device_id) do update set
|
||||
owner_scope_id = excluded.owner_scope_id,
|
||||
project_id = excluded.project_id,
|
||||
desired_revision_id = excluded.desired_revision_id,
|
||||
updated_at = now()
|
||||
returning device_id, project_id, desired_revision_id, applied_revision_id`,
|
||||
[device.id, project.owner_scope_id, project.id, revision.id],
|
||||
);
|
||||
const state = stateResult.rows[0];
|
||||
if (!state) throw domainError("device_configuration_state_update_failed", 409);
|
||||
|
||||
await addAudit(client, {
|
||||
eventType: "device_configuration.desired_changed",
|
||||
actorRef: actor.userRef,
|
||||
projectId: project.id,
|
||||
deviceId: device.id,
|
||||
payload: {
|
||||
deviceRef: `device:${device.id}`,
|
||||
projectRef: toProjectRef(project.id),
|
||||
configurationRevisionRef: `configuration-revision:${revision.id}`,
|
||||
previousConfigurationRevisionRef: current?.desired_revision_id
|
||||
? `configuration-revision:${current.desired_revision_id}`
|
||||
: null,
|
||||
configurationDigest: revision.configuration_digest,
|
||||
},
|
||||
});
|
||||
return {
|
||||
changed: true,
|
||||
configurationState: configurationStateView(state),
|
||||
};
|
||||
}
|
||||
|
||||
async function findBindingSource(client, command) {
|
||||
if (command.source.kind === "device") {
|
||||
return findDirectDeviceForUpdate(client, {
|
||||
projectId: command.projectId,
|
||||
deviceId: command.source.id,
|
||||
});
|
||||
}
|
||||
const result = await client.query(
|
||||
`select id, project_id, lifecycle_state
|
||||
from device_collections
|
||||
where id = $1 and project_id = $2
|
||||
for share`,
|
||||
[command.source.id, command.projectId],
|
||||
);
|
||||
const collection = result.rows[0];
|
||||
if (!collection) throw domainError("device_collection_not_found", 404);
|
||||
if (collection.lifecycle_state !== "active") {
|
||||
throw domainError("device_collection_inactive", 409);
|
||||
}
|
||||
return collection;
|
||||
}
|
||||
|
||||
async function findDirectDeviceForUpdate(client, command) {
|
||||
const result = await client.query(
|
||||
`select id, contour_id, owner_scope_id, project_id,
|
||||
model_profile_ref, 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.contour_id
|
||||
|| !device.owner_scope_id
|
||||
|| !device.project_id
|
||||
|| device.project_id !== command.projectId
|
||||
) {
|
||||
throw domainError("device_control_resource_project_mismatch", 409);
|
||||
}
|
||||
if (device.lifecycle_state === "retired") {
|
||||
throw domainError("device_control_resource_lifecycle_blocked", 409);
|
||||
}
|
||||
return device;
|
||||
}
|
||||
|
||||
async function addAudit(client, {
|
||||
eventType,
|
||||
actorRef,
|
||||
projectId,
|
||||
deviceId = null,
|
||||
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 bindingView(row) {
|
||||
return {
|
||||
bindingRef: `binding:${row.id}`,
|
||||
projectRef: toProjectRef(row.project_id),
|
||||
bindingKey: row.binding_key,
|
||||
displayName: row.display_name,
|
||||
source: {
|
||||
kind: row.source_kind,
|
||||
ref: bindingSourceRef(row),
|
||||
},
|
||||
target: {
|
||||
kind: row.target_kind,
|
||||
ref: row.target_ref,
|
||||
},
|
||||
capabilities: row.capabilities ?? [],
|
||||
lifecycleState: row.lifecycle_state,
|
||||
sourceApprovedAt: toIso(row.source_approved_at),
|
||||
createdAt: toIso(row.created_at),
|
||||
updatedAt: toIso(row.updated_at),
|
||||
};
|
||||
}
|
||||
|
||||
function bindingSourceRef(row) {
|
||||
return row.source_kind === "device"
|
||||
? `device:${row.device_id}`
|
||||
: `collection:${row.collection_id}`;
|
||||
}
|
||||
|
||||
function configurationRevisionView(row) {
|
||||
return {
|
||||
configurationRevisionRef: `configuration-revision:${row.id}`,
|
||||
deviceRef: `device:${row.device_id}`,
|
||||
projectRef: toProjectRef(row.project_id),
|
||||
revisionNumber: Number(row.revision_number),
|
||||
modelProfileRef: row.model_profile_ref,
|
||||
schemaArtifactRef: row.schema_artifact_ref,
|
||||
configurationDigest: row.configuration_digest,
|
||||
configuration: row.configuration,
|
||||
changeSummary: row.change_summary ?? null,
|
||||
createdAt: toIso(row.created_at),
|
||||
};
|
||||
}
|
||||
|
||||
function configurationStateView(row) {
|
||||
return {
|
||||
deviceRef: `device:${row.device_id}`,
|
||||
projectRef: toProjectRef(row.project_id),
|
||||
desiredConfigurationRevisionRef: row.desired_revision_id
|
||||
? `configuration-revision:${row.desired_revision_id}`
|
||||
: null,
|
||||
appliedConfigurationRevisionRef: row.applied_revision_id
|
||||
? `configuration-revision:${row.applied_revision_id}`
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
function toIso(value) {
|
||||
return new Date(value).toISOString();
|
||||
}
|
||||
|
||||
function domainError(code, statusCode) {
|
||||
const error = new Error(code);
|
||||
error.statusCode = statusCode;
|
||||
return error;
|
||||
}
|
||||
@@ -371,6 +371,47 @@ async function transferDevice(client, actor, command) {
|
||||
throw domainError("device_transfer_active_credential_binding", 409);
|
||||
}
|
||||
|
||||
const activeResourceBindings = await client.query(
|
||||
`select exists (
|
||||
select 1 from device_resource_bindings
|
||||
where device_id = $1
|
||||
and lifecycle_state in ('pending_external_approval', 'active')
|
||||
) as active`,
|
||||
[device.id],
|
||||
);
|
||||
if (activeResourceBindings.rows[0]?.active === true) {
|
||||
throw domainError("device_transfer_active_resource_binding", 409);
|
||||
}
|
||||
|
||||
const configurationState = await client.query(
|
||||
`select desired_revision_id, applied_revision_id
|
||||
from device_configuration_state
|
||||
where device_id = $1
|
||||
for update`,
|
||||
[device.id],
|
||||
);
|
||||
if (configurationState.rows[0]?.applied_revision_id) {
|
||||
throw domainError("device_transfer_applied_configuration", 409);
|
||||
}
|
||||
|
||||
const nonterminalCommands = await client.query(
|
||||
`select exists (
|
||||
select 1 from device_commands
|
||||
where device_id = $1
|
||||
and lifecycle_state not in ('verified', 'failed', 'expired', 'unknown')
|
||||
) as active`,
|
||||
[device.id],
|
||||
);
|
||||
if (nonterminalCommands.rows[0]?.active === true) {
|
||||
throw domainError("device_transfer_nonterminal_command", 409);
|
||||
}
|
||||
|
||||
const clearedConfiguration = await client.query(
|
||||
`delete from device_configuration_state
|
||||
where device_id = $1 and applied_revision_id is null`,
|
||||
[device.id],
|
||||
);
|
||||
|
||||
const detached = await client.query(
|
||||
`delete from device_collection_members
|
||||
where device_id = $1 and project_id = $2`,
|
||||
@@ -437,6 +478,7 @@ async function transferDevice(client, actor, command) {
|
||||
targetProjectRef: toProjectRef(targetProject.id),
|
||||
detachedCollectionCount: Number(detached.rowCount || 0),
|
||||
transferredIdentifierCount: Number(movedIdentifiers.rowCount || 0),
|
||||
clearedDesiredConfiguration: Number(clearedConfiguration.rowCount || 0) > 0,
|
||||
};
|
||||
await addAudit(client, {
|
||||
eventType: "device.transferred_out",
|
||||
@@ -459,6 +501,7 @@ async function transferDevice(client, actor, command) {
|
||||
ownershipTransitionRef: `ownership-transition:${transitionId}`,
|
||||
detachedCollectionCount: Number(detached.rowCount || 0),
|
||||
transferredIdentifierCount: Number(movedIdentifiers.rowCount || 0),
|
||||
clearedDesiredConfiguration: Number(clearedConfiguration.rowCount || 0) > 0,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,11 @@ import {
|
||||
isInfrastructureManagementCommand,
|
||||
normalizeInfrastructureManagementCommand,
|
||||
} from "./infrastructure-management.mjs";
|
||||
import {
|
||||
DEVICE_CONTROL_RESOURCE_COMMAND_KINDS,
|
||||
isControlResourceManagementCommand,
|
||||
normalizeControlResourceManagementCommand,
|
||||
} from "./control-resource-management.mjs";
|
||||
import {
|
||||
DEVICE_LIFECYCLE_COMMAND_KINDS,
|
||||
isLifecycleManagementCommand,
|
||||
@@ -23,9 +28,13 @@ export const ALL_DEVICE_MANAGEMENT_COMMAND_KINDS = Object.freeze([
|
||||
...DEVICE_INFRASTRUCTURE_COMMAND_KINDS,
|
||||
...DEVICE_LIFECYCLE_COMMAND_KINDS,
|
||||
...DEVICE_SENSITIVE_REFERENCE_COMMAND_KINDS,
|
||||
...DEVICE_CONTROL_RESOURCE_COMMAND_KINDS,
|
||||
]);
|
||||
|
||||
export function normalizeDeviceManagementCommand(kind, input) {
|
||||
if (isControlResourceManagementCommand(kind)) {
|
||||
return normalizeControlResourceManagementCommand(kind, input);
|
||||
}
|
||||
if (isSensitiveReferenceManagementCommand(kind)) {
|
||||
return normalizeSensitiveReferenceManagementCommand(kind, input);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,13 @@ import pg from "pg";
|
||||
|
||||
import { ARUSNAVI_B2_MODEL_PROFILE } from "../../../packages/arusnavi-b2-adapter/src/index.mjs";
|
||||
import { observeQuarantineDiscovery } from "./discovery-repository.mjs";
|
||||
import {
|
||||
applyControlResourceManagementCommand,
|
||||
authorizeControlResourceManagementReplay,
|
||||
} from "./control-resource-repository.mjs";
|
||||
import {
|
||||
isControlResourceManagementCommand,
|
||||
} from "./control-resource-management.mjs";
|
||||
import {
|
||||
applyInfrastructureManagementCommand,
|
||||
authorizeInfrastructureManagementReplay,
|
||||
@@ -43,6 +50,8 @@ const migrationFiles = [
|
||||
"007_device_lifecycle_commands.sql",
|
||||
"008_device_sensitive_references.sql",
|
||||
"009_device_sensitive_reference_commands.sql",
|
||||
"010_device_control_resources.sql",
|
||||
"011_device_control_resource_commands.sql",
|
||||
];
|
||||
|
||||
export class PostgresDeviceRepository {
|
||||
@@ -226,6 +235,13 @@ async function completeManagementReceipt(client, receiptId, result) {
|
||||
}
|
||||
|
||||
async function applyManagementCommand(client, { commandKind, actor, command }) {
|
||||
if (isControlResourceManagementCommand(commandKind)) {
|
||||
return applyControlResourceManagementCommand(client, {
|
||||
commandKind,
|
||||
actor,
|
||||
command,
|
||||
});
|
||||
}
|
||||
if (isSensitiveReferenceManagementCommand(commandKind)) {
|
||||
return applySensitiveReferenceManagementCommand(client, {
|
||||
commandKind,
|
||||
@@ -263,6 +279,13 @@ async function applyManagementCommand(client, { commandKind, actor, command }) {
|
||||
}
|
||||
|
||||
async function authorizeManagementReplay(client, { commandKind, actor, command }) {
|
||||
if (isControlResourceManagementCommand(commandKind)) {
|
||||
return authorizeControlResourceManagementReplay(client, {
|
||||
commandKind,
|
||||
actor,
|
||||
command,
|
||||
});
|
||||
}
|
||||
if (isSensitiveReferenceManagementCommand(commandKind)) {
|
||||
return authorizeSensitiveReferenceManagementReplay(client, {
|
||||
commandKind,
|
||||
|
||||
@@ -11,6 +11,7 @@ export const DEVICE_PROJECT_CAPABILITIES = Object.freeze([
|
||||
"binding.manage",
|
||||
"telemetry.observe",
|
||||
"configuration.read",
|
||||
"configuration.manage",
|
||||
"command.plan",
|
||||
"command.confirm",
|
||||
"command.dispatch",
|
||||
@@ -76,6 +77,7 @@ const roleCapabilities = Object.freeze({
|
||||
"binding.manage",
|
||||
"telemetry.observe",
|
||||
"configuration.read",
|
||||
"configuration.manage",
|
||||
"command.plan",
|
||||
"audit.read",
|
||||
]),
|
||||
@@ -91,6 +93,7 @@ const roleCapabilities = Object.freeze({
|
||||
"binding.manage",
|
||||
"telemetry.observe",
|
||||
"configuration.read",
|
||||
"configuration.manage",
|
||||
"command.plan",
|
||||
"command.confirm",
|
||||
"command.dispatch",
|
||||
|
||||
Reference in New Issue
Block a user