From 70bafdd028f90b5adb493668d6b3fe05df963f15 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 10 Aug 2026 17:27:07 +0300 Subject: [PATCH] feat(device-core): add idempotent project management --- .../003_device_management_commands.sql | 68 ++ .../services/device-control-core/src/app.mjs | 125 +++- .../src/postgres-repository.mjs | 659 +++++++++++++++++- .../src/project-management.mjs | 535 ++++++++++++++ .../device-control-core/src/server.mjs | 14 + .../device-control-core/test/app.test.mjs | 226 ++++++ .../management-command-migration.test.mjs | 39 ++ .../test/management-repository.test.mjs | 281 ++++++++ .../test/management-runtime-contract.test.mjs | 44 ++ .../test/project-management.test.mjs | 271 +++++++ 10 files changed, 2260 insertions(+), 2 deletions(-) create mode 100644 device-plane/services/device-control-core/migrations/003_device_management_commands.sql create mode 100644 device-plane/services/device-control-core/src/project-management.mjs create mode 100644 device-plane/services/device-control-core/test/management-command-migration.test.mjs create mode 100644 device-plane/services/device-control-core/test/management-repository.test.mjs create mode 100644 device-plane/services/device-control-core/test/management-runtime-contract.test.mjs create mode 100644 device-plane/services/device-control-core/test/project-management.test.mjs diff --git a/device-plane/services/device-control-core/migrations/003_device_management_commands.sql b/device-plane/services/device-control-core/migrations/003_device_management_commands.sql new file mode 100644 index 0000000..48ccb5b --- /dev/null +++ b/device-plane/services/device-control-core/migrations/003_device_management_commands.sql @@ -0,0 +1,68 @@ +begin; + +do $$ +begin + if not exists ( + select 1 + from pg_constraint + where conname = 'device_project_grants_owner_user_only' + and conrelid = 'device_project_grants'::regclass + ) then + alter table device_project_grants + add constraint device_project_grants_owner_user_only + check (project_role <> 'owner' or principal_kind = 'user'); + end if; +end +$$; + +alter table device_audit_events + add column if not exists project_id uuid references device_projects(id); + +create index if not exists device_audit_events_project_time_idx + on device_audit_events (project_id, occurred_at desc); + +create table if not exists device_management_command_receipts ( + id uuid primary key, + actor_ref text not null + check (length(btrim(actor_ref)) between 3 and 256), + command_kind text not null + check (command_kind in ( + 'owner_scope.ensure', + 'project.ensure', + 'collection.ensure', + 'project_grant.upsert' + )), + idempotency_key text not null + check (length(idempotency_key) between 8 and 256), + request_digest text not null + check (request_digest ~ '^sha256:[a-f0-9]{64}$'), + lifecycle_state text not null default 'pending' + check (lifecycle_state in ('pending', 'completed')), + response_status integer + check (response_status between 200 and 599), + response_body jsonb, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + completed_at timestamptz, + unique (actor_ref, command_kind, idempotency_key), + check ( + ( + lifecycle_state = 'pending' + and response_status is null + and response_body is null + and completed_at is null + ) + or + ( + lifecycle_state = 'completed' + and response_status is not null + and response_body is not null + and completed_at is not null + ) + ) +); + +create index if not exists device_management_receipts_created_idx + on device_management_command_receipts (created_at desc); + +commit; diff --git a/device-plane/services/device-control-core/src/app.mjs b/device-plane/services/device-control-core/src/app.mjs index da6b6f1..491d28a 100644 --- a/device-plane/services/device-control-core/src/app.mjs +++ b/device-plane/services/device-control-core/src/app.mjs @@ -1,4 +1,4 @@ -import { timingSafeEqual } from "node:crypto"; +import { createHash, timingSafeEqual } from "node:crypto"; import { createServer } from "node:http"; import { @@ -7,12 +7,25 @@ import { normalizeDiscoverySignal, toSafeDiscoveryView, } from "../../../packages/device-protocol-contract/src/index.mjs"; +import { + normalizeManagementActor, + normalizeManagementCommand, +} from "./project-management.mjs"; + +const managementRoutes = new Map([ + ["/internal/v1/management/owner-scopes:ensure", "owner_scope.ensure"], + ["/internal/v1/management/projects:ensure", "project.ensure"], + ["/internal/v1/management/collections:ensure", "collection.ensure"], + ["/internal/v1/management/project-grants:upsert", "project_grant.upsert"], +]); export function createControlCoreApp({ repository, gatewayToken = "", identifierPepper = "", discoveryIngestEnabled = false, + managementApiEnabled = false, + managementToken = "", } = {}) { if (!repository || typeof repository.health !== "function") { throw new TypeError("device_repository_required"); @@ -28,6 +41,14 @@ export function createControlCoreApp({ throw new TypeError("device_identifier_pepper_invalid"); } } + if (managementApiEnabled) { + if (typeof repository.executeManagementCommand !== "function") { + throw new TypeError("device_management_repository_required"); + } + if (typeof managementToken !== "string" || managementToken.length < 32) { + throw new TypeError("device_management_token_invalid"); + } + } const server = createServer(async (request, response) => { response.setHeader("Content-Type", "application/json; charset=utf-8"); @@ -47,10 +68,56 @@ export function createControlCoreApp({ service: "nodedc-device-control-core", database, discoveryIngest: discoveryIngestEnabled ? "enabled" : "disabled", + managementApi: managementApiEnabled ? "enabled" : "disabled", commandTransport: "disabled", }); } + const managementCommandKind = managementRoutes.get(requestUrl.pathname); + if (request.method === "POST" && managementCommandKind) { + if (!managementApiEnabled) { + return writeJson(response, 404, { + ok: false, + error: "device_management_api_disabled", + }); + } + if (!matchesBearer(request.headers.authorization, managementToken)) { + return writeJson(response, 401, { + ok: false, + error: "device_management_auth_required", + }); + } + + const idempotencyKey = normalizeIdempotencyKey( + request.headers["idempotency-key"], + ); + const actor = managementActorFromHeaders(request.headers); + const input = await readJsonBody(request, 64 * 1024); + const command = normalizeManagementCommand(managementCommandKind, input); + const requestDigest = managementRequestDigest({ + actor, + commandKind: managementCommandKind, + command, + }); + const execution = await repository.executeManagementCommand({ + idempotencyKey, + commandKind: managementCommandKind, + requestDigest, + actor, + command, + }); + response.setHeader("Idempotency-Key", idempotencyKey); + response.setHeader( + "Idempotency-Replayed", + execution.replayed ? "true" : "false", + ); + return writeJson(response, 200, { + ok: true, + replayed: execution.replayed, + result: execution.result, + }); + } + if ( request.method === "POST" && requestUrl.pathname === "/internal/v1/device-discoveries:observe" @@ -108,6 +175,62 @@ export function createControlCoreApp({ return server; } +function managementActorFromHeaders(headers) { + return normalizeManagementActor({ + userRef: singleHeader(headers["x-nodedc-user-ref"]), + hubRole: singleHeader(headers["x-nodedc-hub-role"]), + groupRefs: commaSeparatedHeader(headers["x-nodedc-group-refs"]), + ownerScopes: ownerScopeHeader(headers["x-nodedc-owner-scopes"]), + }); +} + +function ownerScopeHeader(value) { + return commaSeparatedHeader(value).map((claim) => { + const separatorIndex = claim.indexOf("="); + if (separatorIndex < 1 || separatorIndex === claim.length - 1) { + throw new TypeError("device_actor_owner_scopes_invalid"); + } + return { + scopeKind: claim.slice(0, separatorIndex), + ownerRef: claim.slice(separatorIndex + 1), + }; + }); +} + +function commaSeparatedHeader(value) { + const header = singleHeader(value, true); + if (!header) return []; + return header.split(",").map((item) => item.trim()).filter(Boolean); +} + +function singleHeader(value, optional = false) { + if (Array.isArray(value)) throw new TypeError("device_management_header_invalid"); + if (value == null || value === "") { + if (optional) return ""; + throw new TypeError("device_management_header_required"); + } + if (typeof value !== "string" || value.length > 4096) { + throw new TypeError("device_management_header_invalid"); + } + return value.trim(); +} + +function normalizeIdempotencyKey(value) { + const key = singleHeader(value); + if (!/^[\x21-\x7e]{8,256}$/.test(key)) { + const error = new Error("device_idempotency_key_invalid"); + error.statusCode = 400; + throw error; + } + return key; +} + +function managementRequestDigest(value) { + return `sha256:${createHash("sha256") + .update(JSON.stringify(value), "utf8") + .digest("hex")}`; +} + function matchesBearer(header, expected) { if (typeof header !== "string" || !header.startsWith("Bearer ")) return false; const actual = Buffer.from(header.slice("Bearer ".length), "utf8"); diff --git a/device-plane/services/device-control-core/src/postgres-repository.mjs b/device-plane/services/device-control-core/src/postgres-repository.mjs index 2a457e5..6c05238 100644 --- a/device-plane/services/device-control-core/src/postgres-repository.mjs +++ b/device-plane/services/device-control-core/src/postgres-repository.mjs @@ -6,16 +6,34 @@ import { fileURLToPath } from "node:url"; import pg from "pg"; import { ARUSNAVI_B2_MODEL_PROFILE } from "../../../packages/arusnavi-b2-adapter/src/index.mjs"; +import { + assertActorCanManageOwnerScope, + assertGrantMutationAllowed, + assertProjectCapability, + toProjectRef, +} from "./project-management.mjs"; const { Pool } = pg; const serviceRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const migrationFiles = [ "001_device_plane_foundation.sql", "002_device_project_access.sql", + "003_device_management_commands.sql", ]; export class PostgresDeviceRepository { - constructor({ databaseUrl, poolSize = 10 } = {}) { + constructor({ databaseUrl, poolSize = 10, pool = null } = {}) { + if (pool) { + if ( + typeof pool.query !== "function" || + typeof pool.connect !== "function" || + typeof pool.end !== "function" + ) { + throw new TypeError("device_database_pool_invalid"); + } + this.pool = pool; + return; + } if (typeof databaseUrl !== "string" || databaseUrl.trim() === "") { throw new TypeError("device_database_url_required"); } @@ -120,11 +138,650 @@ export class PostgresDeviceRepository { }; } + async executeManagementCommand({ + idempotencyKey, + commandKind, + requestDigest, + actor, + command, + }) { + const client = await this.pool.connect(); + try { + await client.query("begin"); + const receipt = await claimManagementReceipt(client, { + idempotencyKey, + commandKind, + requestDigest, + actorRef: actor.userRef, + }); + + if (receipt.replayed) { + await authorizeManagementReplay(client, { + commandKind, + actor, + command, + }); + await client.query("commit"); + return { + replayed: true, + result: receipt.responseBody, + }; + } + + const result = await applyManagementCommand(client, { + commandKind, + actor, + command, + }); + await completeManagementReceipt(client, receipt.id, result); + await client.query("commit"); + return { replayed: false, result }; + } catch (error) { + await client.query("rollback").catch(() => undefined); + throw mapPostgresError(error); + } finally { + client.release(); + } + } + async close() { await this.pool.end(); } } +async function claimManagementReceipt(client, { + idempotencyKey, + commandKind, + requestDigest, + actorRef, +}) { + const id = randomUUID(); + const inserted = await client.query( + `insert into device_management_command_receipts ( + id, + actor_ref, + command_kind, + idempotency_key, + request_digest + ) values ($1, $2, $3, $4, $5) + on conflict (actor_ref, command_kind, idempotency_key) do nothing + returning id`, + [id, actorRef, commandKind, idempotencyKey, requestDigest], + ); + + if (inserted.rows.length === 1) { + return { id, replayed: false, responseBody: null }; + } + + const existing = await client.query( + `select id, request_digest, lifecycle_state, response_body + from device_management_command_receipts + where actor_ref = $1 + and command_kind = $2 + and idempotency_key = $3 + for update`, + [actorRef, commandKind, idempotencyKey], + ); + const row = existing.rows[0]; + if (!row) throw domainError("device_idempotency_receipt_missing", 409); + if (row.request_digest !== requestDigest) { + throw domainError("device_idempotency_key_conflict", 409); + } + if (row.lifecycle_state !== "completed" || !row.response_body) { + throw domainError("device_idempotency_command_in_progress", 409); + } + return { + id: row.id, + replayed: true, + responseBody: row.response_body, + }; +} + +async function completeManagementReceipt(client, receiptId, result) { + await client.query( + `update device_management_command_receipts + set lifecycle_state = 'completed', + response_status = 200, + response_body = $2::jsonb, + completed_at = now(), + updated_at = now() + where id = $1`, + [receiptId, JSON.stringify(result)], + ); +} + +async function applyManagementCommand(client, { commandKind, actor, command }) { + if (commandKind === "owner_scope.ensure") { + return ensureOwnerScope(client, actor, command); + } + if (commandKind === "project.ensure") { + return ensureProject(client, actor, command); + } + if (commandKind === "collection.ensure") { + return ensureCollection(client, actor, command); + } + if (commandKind === "project_grant.upsert") { + return upsertProjectGrant(client, actor, command); + } + throw new TypeError("device_management_command_kind_invalid"); +} + +async function authorizeManagementReplay(client, { commandKind, actor, command }) { + if (commandKind === "owner_scope.ensure") { + assertActorCanManageOwnerScope(actor, command); + const ownerScope = await findOwnerScope( + client, + command.scopeKind, + command.ownerRef, + ); + assertOwnerScopeActive(ownerScope); + return; + } + if (commandKind === "project.ensure") { + const ownerScope = await findOwnerScope( + client, + command.scopeKind, + command.ownerRef, + ); + assertOwnerScopeActive(ownerScope); + const project = await findProjectByOwnerAndKey( + client, + ownerScope.id, + command.projectKey, + false, + ); + assertProjectActive(project); + const grants = await listProjectGrants(client, project.id); + assertProjectCapability(actor, grants, "project.manage"); + return; + } + + const lockForGrantMutation = commandKind === "project_grant.upsert"; + const { project } = await findProjectContext( + client, + command.projectId, + lockForGrantMutation, + ); + assertProjectActive(project); + const grants = await listProjectGrants(client, project.id); + if (commandKind === "collection.ensure") { + assertProjectCapability(actor, grants, "collection.manage"); + return; + } + if (commandKind === "project_grant.upsert") { + const existing = grants.find( + (grant) => + grant.principalKind === command.principalKind && + grant.principalRef === command.principalRef, + ) ?? null; + assertGrantMutationAllowed(actor, grants, command, existing); + return; + } + throw new TypeError("device_management_command_kind_invalid"); +} + +async function ensureOwnerScope(client, actor, command) { + assertActorCanManageOwnerScope(actor, command); + const result = await client.query( + `insert into device_owner_scopes ( + id, + scope_kind, + owner_ref, + display_name, + created_by_ref + ) values ($1, $2, $3, $4, $5) + on conflict (scope_kind, owner_ref) do update set + display_name = excluded.display_name, + updated_at = now() + returning id, scope_kind, owner_ref, display_name, lifecycle_state, + created_at, updated_at, (xmax = 0) as created`, + [randomUUID(), command.scopeKind, command.ownerRef, command.displayName, actor.userRef], + ); + const row = result.rows[0]; + assertOwnerScopeActive(row); + await addManagementAudit(client, { + eventType: row.created ? "owner_scope.created" : "owner_scope.updated", + actorRef: actor.userRef, + payload: { + scopeKind: row.scope_kind, + ownerRef: row.owner_ref, + lifecycleState: row.lifecycle_state, + }, + }); + return { + created: row.created === true, + ownerScope: projectOwnerScopeView(row), + }; +} + +async function ensureProject(client, actor, command) { + const ownerScope = await findOwnerScope(client, command.scopeKind, command.ownerRef); + assertOwnerScopeActive(ownerScope); + const inserted = await client.query( + `insert into device_projects ( + id, + owner_scope_id, + project_key, + name, + description, + created_by_ref + ) values ($1, $2, $3, $4, $5, $6) + on conflict (owner_scope_id, project_key) do nothing + returning id, owner_scope_id, project_key, name, description, + lifecycle_state, created_at, updated_at`, + [ + randomUUID(), + ownerScope.id, + command.projectKey, + command.name, + command.description, + actor.userRef, + ], + ); + + let row = inserted.rows[0]; + const created = Boolean(row); + if (created) { + assertActorCanManageOwnerScope(actor, command); + await client.query( + `insert into device_project_grants ( + id, + project_id, + principal_kind, + principal_ref, + project_role, + capability_allow, + capability_deny, + lifecycle_state, + created_by_ref + ) values ($1, $2, 'user', $3, 'owner', '{}', '{}', 'active', $3)`, + [randomUUID(), row.id, actor.userRef], + ); + } else { + row = await findProjectByOwnerAndKey( + client, + ownerScope.id, + command.projectKey, + true, + ); + assertProjectActive(row); + const grants = await listProjectGrants(client, row.id); + assertProjectCapability(actor, grants, "project.manage"); + const updated = await client.query( + `update device_projects + set name = $2, + description = $3, + updated_at = now() + where id = $1 + returning id, owner_scope_id, project_key, name, description, + lifecycle_state, created_at, updated_at`, + [row.id, command.name, command.description], + ); + row = updated.rows[0]; + } + + await addManagementAudit(client, { + eventType: created ? "project.created" : "project.updated", + actorRef: actor.userRef, + projectId: row.id, + payload: { + projectRef: toProjectRef(row.id), + projectKey: row.project_key, + ownerScope: { + scopeKind: ownerScope.scope_kind, + ownerRef: ownerScope.owner_ref, + }, + }, + }); + return { + created, + project: projectView(row, ownerScope), + ...(created + ? { + initialGrant: { + principalKind: "user", + principalRef: actor.userRef, + projectRole: "owner", + lifecycleState: "active", + }, + } + : {}), + }; +} + +async function ensureCollection(client, actor, command) { + const { project, ownerScope } = await findProjectContext(client, command.projectId); + assertProjectActive(project); + const grants = await listProjectGrants(client, project.id); + assertProjectCapability(actor, grants, "collection.manage"); + const result = await client.query( + `insert into device_collections ( + id, + project_id, + collection_key, + name, + description, + created_by_ref + ) values ($1, $2, $3, $4, $5, $6) + on conflict (project_id, collection_key) do update set + name = excluded.name, + description = excluded.description, + updated_at = now() + returning id, project_id, collection_key, name, description, + lifecycle_state, created_at, updated_at, (xmax = 0) as created`, + [ + randomUUID(), + project.id, + command.collectionKey, + command.name, + command.description, + actor.userRef, + ], + ); + const row = result.rows[0]; + assertCollectionActive(row); + await addManagementAudit(client, { + eventType: row.created ? "collection.created" : "collection.updated", + actorRef: actor.userRef, + projectId: project.id, + payload: { + projectRef: toProjectRef(project.id), + collectionRef: `collection:${row.id}`, + collectionKey: row.collection_key, + }, + }); + return { + created: row.created === true, + project: projectView(project, ownerScope), + collection: collectionView(row), + }; +} + +async function upsertProjectGrant(client, actor, command) { + const { project, ownerScope } = await findProjectContext( + client, + command.projectId, + true, + ); + assertProjectActive(project); + const grants = await listProjectGrants(client, project.id); + const existing = grants.find( + (grant) => + grant.principalKind === command.principalKind && + grant.principalRef === command.principalRef, + ) ?? null; + assertGrantMutationAllowed(actor, grants, command, existing); + + const removesActiveOwner = + existing?.projectRole === "owner" && + existing.lifecycleState === "active" && + (command.projectRole !== "owner" || command.lifecycleState !== "active"); + if (removesActiveOwner) { + const remaining = grants.filter( + (grant) => + grant.grantRef !== existing.grantRef && + grant.projectRole === "owner" && + grant.lifecycleState === "active" && + grant.principalKind === "user", + ); + if (remaining.length === 0) { + throw domainError("device_project_last_owner_required", 409); + } + } + + const result = await client.query( + `insert into device_project_grants ( + id, + project_id, + principal_kind, + principal_ref, + project_role, + capability_allow, + capability_deny, + lifecycle_state, + created_by_ref + ) values ($1, $2, $3, $4, $5, $6, $7, $8, $9) + on conflict (project_id, principal_kind, principal_ref) do update set + project_role = excluded.project_role, + capability_allow = excluded.capability_allow, + capability_deny = excluded.capability_deny, + lifecycle_state = excluded.lifecycle_state, + updated_at = now() + returning id, principal_kind, principal_ref, project_role, + capability_allow, capability_deny, lifecycle_state, created_at, + updated_at, (xmax = 0) as created`, + [ + randomUUID(), + project.id, + command.principalKind, + command.principalRef, + command.projectRole, + command.capabilityAllow, + command.capabilityDeny, + command.lifecycleState, + actor.userRef, + ], + ); + const row = result.rows[0]; + await addManagementAudit(client, { + eventType: row.created ? "project_grant.created" : "project_grant.updated", + actorRef: actor.userRef, + projectId: project.id, + payload: { + projectRef: toProjectRef(project.id), + grantRef: `grant:${row.id}`, + principalKind: row.principal_kind, + principalRef: row.principal_ref, + projectRole: row.project_role, + lifecycleState: row.lifecycle_state, + }, + }); + return { + created: row.created === true, + project: projectView(project, ownerScope), + grant: grantView(row), + }; +} + +async function findOwnerScope(client, scopeKind, ownerRef) { + const result = await client.query( + `select id, scope_kind, owner_ref, display_name, lifecycle_state, + created_at, updated_at + from device_owner_scopes + where scope_kind = $1 and owner_ref = $2 + for share`, + [scopeKind, ownerRef], + ); + if (!result.rows[0]) throw domainError("device_owner_scope_not_found", 404); + return result.rows[0]; +} + +async function findProjectByOwnerAndKey( + client, + ownerScopeId, + projectKey, + forUpdate = false, +) { + const lockClause = forUpdate ? "for update" : "for share"; + const result = await client.query( + `select id, owner_scope_id, project_key, name, description, + lifecycle_state, created_at, updated_at + from device_projects + where owner_scope_id = $1 and project_key = $2 + ${lockClause}`, + [ownerScopeId, projectKey], + ); + if (!result.rows[0]) throw domainError("device_project_not_found", 404); + return result.rows[0]; +} + +async function findProjectContext(client, projectId, forUpdate = false) { + const lockClause = forUpdate ? "for update of p" : "for share of p, os"; + const result = await client.query( + `select + p.id, + p.owner_scope_id, + p.project_key, + p.name, + p.description, + p.lifecycle_state, + p.created_at, + p.updated_at, + os.scope_kind, + os.owner_ref, + os.display_name as owner_display_name, + os.lifecycle_state as owner_lifecycle_state, + os.created_at as owner_created_at, + os.updated_at as owner_updated_at + from device_projects p + join device_owner_scopes os on os.id = p.owner_scope_id + where p.id = $1 + ${lockClause}`, + [projectId], + ); + const row = result.rows[0]; + if (!row) throw domainError("device_project_not_found", 404); + assertOwnerScopeActive({ lifecycle_state: row.owner_lifecycle_state }); + return { + project: row, + ownerScope: { + id: row.owner_scope_id, + scope_kind: row.scope_kind, + owner_ref: row.owner_ref, + display_name: row.owner_display_name, + lifecycle_state: row.owner_lifecycle_state, + created_at: row.owner_created_at, + updated_at: row.owner_updated_at, + }, + }; +} + +async function listProjectGrants(client, projectId) { + const result = await client.query( + `select id, principal_kind, principal_ref, project_role, + capability_allow, capability_deny, lifecycle_state, + created_at, updated_at + from device_project_grants + where project_id = $1 + order by created_at, id + for share`, + [projectId], + ); + return result.rows.map(grantView); +} + +async function addManagementAudit(client, { + eventType, + actorRef, + projectId = null, + payload, +}) { + await client.query( + `insert into device_audit_events ( + id, + event_type, + actor_ref, + project_id, + payload + ) values ($1, $2, $3, $4, $5::jsonb)`, + [randomUUID(), eventType, actorRef, projectId, JSON.stringify(payload)], + ); +} + +function projectOwnerScopeView(row) { + return { + ownerScopeRef: `owner-scope:${row.id}`, + scopeKind: row.scope_kind, + ownerRef: row.owner_ref, + displayName: row.display_name, + lifecycleState: row.lifecycle_state, + createdAt: toIso(row.created_at), + updatedAt: toIso(row.updated_at), + }; +} + +function projectView(row, ownerScope) { + return { + projectRef: toProjectRef(row.id), + ownerScope: projectOwnerScopeView(ownerScope), + projectKey: row.project_key, + name: row.name, + description: row.description ?? null, + lifecycleState: row.lifecycle_state, + createdAt: toIso(row.created_at), + updatedAt: toIso(row.updated_at), + }; +} + +function collectionView(row) { + return { + collectionRef: `collection:${row.id}`, + projectRef: toProjectRef(row.project_id), + collectionKey: row.collection_key, + name: row.name, + description: row.description ?? null, + lifecycleState: row.lifecycle_state, + createdAt: toIso(row.created_at), + updatedAt: toIso(row.updated_at), + }; +} + +function grantView(row) { + return { + grantRef: `grant:${row.id}`, + principalKind: row.principal_kind, + principalRef: row.principal_ref, + projectRole: row.project_role, + capabilityAllow: [...(row.capability_allow ?? [])].sort(), + capabilityDeny: [...(row.capability_deny ?? [])].sort(), + lifecycleState: row.lifecycle_state, + createdAt: toIso(row.created_at), + updatedAt: toIso(row.updated_at), + }; +} + +function assertOwnerScopeActive(ownerScope) { + if (ownerScope.lifecycle_state !== "active") { + throw domainError("device_owner_scope_inactive", 409); + } +} + +function assertProjectActive(project) { + if (project.lifecycle_state !== "active") { + throw domainError("device_project_inactive", 409); + } +} + +function assertCollectionActive(collection) { + if (collection.lifecycle_state !== "active") { + throw domainError("device_collection_inactive", 409); + } +} + +function toIso(value) { + return new Date(value).toISOString(); +} + +function mapPostgresError(error) { + if (error?.statusCode) return error; + if (error?.code === "23503") { + return domainError("device_management_reference_invalid", 409); + } + if (error?.code === "23505") { + return domainError("device_management_identity_conflict", 409); + } + if (error?.code === "23514") { + return domainError("device_management_constraint_failed", 400); + } + return error; +} + +function domainError(code, statusCode) { + const error = new Error(code); + error.statusCode = statusCode; + return error; +} + function normalizePoolSize(value) { const parsed = Number(value); if (!Number.isSafeInteger(parsed) || parsed < 1 || parsed > 50) { diff --git a/device-plane/services/device-control-core/src/project-management.mjs b/device-plane/services/device-control-core/src/project-management.mjs new file mode 100644 index 0000000..76c2115 --- /dev/null +++ b/device-plane/services/device-control-core/src/project-management.mjs @@ -0,0 +1,535 @@ +export const DEVICE_PROJECT_CAPABILITIES = Object.freeze([ + "project.read", + "project.manage", + "access.manage", + "inventory.read", + "device.enroll", + "device.claim", + "device.transfer", + "collection.manage", + "route.manage", + "binding.manage", + "telemetry.observe", + "configuration.read", + "command.plan", + "command.confirm", + "command.dispatch", + "credential.manage", + "audit.read", +]); + +export const DEVICE_PROJECT_ROLES = Object.freeze([ + "viewer", + "operator", + "engineer", + "admin", + "owner", +]); + +export const DEVICE_HUB_ROLES = Object.freeze([ + "viewer", + "member", + "admin", + "owner", +]); + +export const DEVICE_MANAGEMENT_COMMAND_KINDS = Object.freeze([ + "owner_scope.ensure", + "project.ensure", + "collection.ensure", + "project_grant.upsert", +]); + +const capabilitySet = new Set(DEVICE_PROJECT_CAPABILITIES); +const projectRoleSet = new Set(DEVICE_PROJECT_ROLES); +const hubRoleSet = new Set(DEVICE_HUB_ROLES); +const commandKindSet = new Set(DEVICE_MANAGEMENT_COMMAND_KINDS); +const opaqueRefPattern = /^[A-Za-z0-9][A-Za-z0-9._:-]{2,255}$/; +const keyPattern = /^[a-z][a-z0-9-]{1,62}$/; +const projectRefPattern = /^project:([0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})$/i; + +const roleCapabilities = Object.freeze({ + viewer: Object.freeze([ + "project.read", + "inventory.read", + "telemetry.observe", + "configuration.read", + "audit.read", + ]), + operator: Object.freeze([ + "project.read", + "inventory.read", + "telemetry.observe", + "configuration.read", + "command.plan", + "command.confirm", + "command.dispatch", + "audit.read", + ]), + engineer: Object.freeze([ + "project.read", + "inventory.read", + "device.enroll", + "device.claim", + "collection.manage", + "route.manage", + "binding.manage", + "telemetry.observe", + "configuration.read", + "command.plan", + "audit.read", + ]), + admin: Object.freeze([ + "project.read", + "project.manage", + "access.manage", + "inventory.read", + "device.enroll", + "device.claim", + "collection.manage", + "route.manage", + "binding.manage", + "telemetry.observe", + "configuration.read", + "command.plan", + "command.confirm", + "command.dispatch", + "credential.manage", + "audit.read", + ]), + owner: DEVICE_PROJECT_CAPABILITIES, +}); + +const hubRoleCeilings = Object.freeze({ + viewer: roleCapabilities.viewer, + member: Object.freeze([ + ...new Set([ + ...roleCapabilities.viewer, + ...roleCapabilities.operator, + ...roleCapabilities.engineer, + ]), + ]), + admin: roleCapabilities.admin, + owner: DEVICE_PROJECT_CAPABILITIES, +}); + +const projectRoleWeight = Object.freeze({ + viewer: 10, + operator: 20, + engineer: 30, + admin: 40, + owner: 50, +}); + +export function normalizeManagementActor(input) { + assertPlainObject(input, "device_management_actor_invalid"); + assertAllowedKeys( + input, + ["userRef", "hubRole", "groupRefs", "ownerScopes"], + "device_management_actor_field_unexpected", + ); + + const userRef = normalizeOpaqueRef(input.userRef, "device_actor_user_ref_invalid"); + const hubRole = normalizeEnum(input.hubRole, hubRoleSet, "device_actor_hub_role_invalid"); + const groupRefs = normalizeOpaqueRefArray( + input.groupRefs ?? [], + "device_actor_group_refs_invalid", + ); + const ownerScopes = normalizeOwnerScopeClaims(input.ownerScopes ?? []); + + return Object.freeze({ + userRef, + hubRole, + groupRefs: Object.freeze(groupRefs), + ownerScopes: Object.freeze(ownerScopes), + }); +} + +export function normalizeManagementCommand(kind, input) { + const normalizedKind = normalizeEnum( + kind, + commandKindSet, + "device_management_command_kind_invalid", + ); + assertPlainObject(input, "device_management_command_invalid"); + + if (normalizedKind === "owner_scope.ensure") { + assertAllowedKeys( + input, + ["scopeKind", "ownerRef", "displayName"], + "device_management_command_field_unexpected", + ); + return Object.freeze({ + scopeKind: normalizeScopeKind(input.scopeKind), + ownerRef: normalizeOpaqueRef(input.ownerRef, "device_owner_ref_invalid"), + displayName: normalizeDisplayText(input.displayName, 160, "device_owner_name_invalid"), + }); + } + + if (normalizedKind === "project.ensure") { + assertAllowedKeys( + input, + ["scopeKind", "ownerRef", "projectKey", "name", "description"], + "device_management_command_field_unexpected", + ); + return Object.freeze({ + scopeKind: normalizeScopeKind(input.scopeKind), + ownerRef: normalizeOpaqueRef(input.ownerRef, "device_owner_ref_invalid"), + projectKey: normalizeKey(input.projectKey, "device_project_key_invalid"), + name: normalizeDisplayText(input.name, 160, "device_project_name_invalid"), + description: normalizeOptionalText( + input.description, + 2000, + "device_project_description_invalid", + ), + }); + } + + if (normalizedKind === "collection.ensure") { + assertAllowedKeys( + input, + ["projectRef", "collectionKey", "name", "description"], + "device_management_command_field_unexpected", + ); + return Object.freeze({ + projectId: normalizeProjectRef(input.projectRef), + collectionKey: normalizeKey( + input.collectionKey, + "device_collection_key_invalid", + ), + name: normalizeDisplayText(input.name, 160, "device_collection_name_invalid"), + description: normalizeOptionalText( + input.description, + 2000, + "device_collection_description_invalid", + ), + }); + } + + assertAllowedKeys( + input, + [ + "projectRef", + "principalKind", + "principalRef", + "projectRole", + "capabilityAllow", + "capabilityDeny", + "lifecycleState", + ], + "device_management_command_field_unexpected", + ); + const principalKind = normalizeEnum( + input.principalKind, + new Set(["user", "group"]), + "device_project_principal_kind_invalid", + ); + const projectRole = normalizeEnum( + input.projectRole, + projectRoleSet, + "device_project_role_invalid", + ); + if (projectRole === "owner" && principalKind !== "user") { + throw domainError("device_project_owner_must_be_user", 400); + } + const capabilityAllow = normalizeCapabilities(input.capabilityAllow ?? []); + const capabilityDeny = normalizeCapabilities(input.capabilityDeny ?? []); + if (capabilityAllow.some((capability) => capabilityDeny.includes(capability))) { + throw domainError("device_project_capability_overlap", 400); + } + + return Object.freeze({ + projectId: normalizeProjectRef(input.projectRef), + principalKind, + principalRef: normalizeOpaqueRef( + input.principalRef, + "device_project_principal_ref_invalid", + ), + projectRole, + capabilityAllow: Object.freeze(capabilityAllow), + capabilityDeny: Object.freeze(capabilityDeny), + lifecycleState: normalizeEnum( + input.lifecycleState ?? "active", + new Set(["active", "revoked"]), + "device_project_grant_state_invalid", + ), + }); +} + +export function assertActorCanManageOwnerScope(actorInput, scopeInput) { + const actor = normalizeManagementActor(actorInput); + const scope = { + scopeKind: normalizeScopeKind(scopeInput?.scopeKind), + ownerRef: normalizeOpaqueRef(scopeInput?.ownerRef, "device_owner_ref_invalid"), + }; + + if (scope.scopeKind === "personal") { + if ( + actor.userRef !== scope.ownerRef || + !["admin", "owner"].includes(actor.hubRole) + ) { + throw domainError("device_owner_scope_access_denied", 403); + } + return actor; + } + + const hasClaim = actor.ownerScopes.some( + (claim) => claim.scopeKind === "company" && claim.ownerRef === scope.ownerRef, + ); + if (!hasClaim || !["admin", "owner"].includes(actor.hubRole)) { + throw domainError("device_owner_scope_access_denied", 403); + } + return actor; +} + +export function resolveProjectAccess({ actor: actorInput, grants = [] }) { + const actor = normalizeManagementActor(actorInput); + if (!Array.isArray(grants)) { + throw new TypeError("device_project_grants_invalid"); + } + + const active = grants + .map(normalizeStoredGrant) + .filter((grant) => grant.lifecycleState === "active"); + const direct = active.find( + (grant) => grant.principalKind === "user" && grant.principalRef === actor.userRef, + ); + const matching = direct + ? [direct] + : active + .filter( + (grant) => + grant.principalKind === "group" && + actor.groupRefs.includes(grant.principalRef), + ) + .sort(compareGrantPriority); + + if (matching.length === 0) { + return Object.freeze({ + allowed: false, + projectRole: null, + capabilities: Object.freeze([]), + sourceRefs: Object.freeze([]), + }); + } + + const primary = matching[0]; + const allowed = new Set(); + const denied = new Set(); + for (const grant of matching) { + for (const capability of roleCapabilities[grant.projectRole]) { + allowed.add(capability); + } + for (const capability of grant.capabilityAllow) allowed.add(capability); + for (const capability of grant.capabilityDeny) denied.add(capability); + } + for (const capability of denied) allowed.delete(capability); + + const hubCeiling = new Set(hubRoleCeilings[actor.hubRole]); + const capabilities = [...allowed] + .filter((capability) => hubCeiling.has(capability)) + .sort(); + + if (!capabilities.includes("project.read")) { + return Object.freeze({ + allowed: false, + projectRole: primary.projectRole, + capabilities: Object.freeze([]), + sourceRefs: Object.freeze(matching.map((grant) => grant.grantRef)), + }); + } + + return Object.freeze({ + allowed: true, + projectRole: primary.projectRole, + capabilities: Object.freeze(capabilities), + sourceRefs: Object.freeze(matching.map((grant) => grant.grantRef)), + }); +} + +export function assertProjectCapability(actor, grants, capability) { + if (!capabilitySet.has(capability)) { + throw new TypeError("device_project_capability_invalid"); + } + const access = resolveProjectAccess({ actor, grants }); + if (!access.capabilities.includes(capability)) { + throw domainError("device_project_capability_denied", 403); + } + return access; +} + +export function assertGrantMutationAllowed(actor, grants, command, existingGrant = null) { + const access = assertProjectCapability(actor, grants, "access.manage"); + if ( + command.projectRole === "owner" || + existingGrant?.projectRole === "owner" + ) { + if (!access.capabilities.includes("device.transfer")) { + throw domainError("device_project_owner_transfer_denied", 403); + } + } + return access; +} + +export function toProjectRef(projectId) { + if (typeof projectId !== "string" || !projectRefPattern.test(`project:${projectId}`)) { + throw new TypeError("device_project_id_invalid"); + } + return `project:${projectId.toLowerCase()}`; +} + +function normalizeStoredGrant(input) { + assertPlainObject(input, "device_project_grant_invalid"); + const grant = { + grantRef: normalizeOpaqueRef(input.grantRef, "device_project_grant_ref_invalid"), + principalKind: normalizeEnum( + input.principalKind, + new Set(["user", "group"]), + "device_project_principal_kind_invalid", + ), + principalRef: normalizeOpaqueRef( + input.principalRef, + "device_project_principal_ref_invalid", + ), + projectRole: normalizeEnum( + input.projectRole, + projectRoleSet, + "device_project_role_invalid", + ), + capabilityAllow: normalizeCapabilities(input.capabilityAllow ?? []), + capabilityDeny: normalizeCapabilities(input.capabilityDeny ?? []), + lifecycleState: normalizeEnum( + input.lifecycleState, + new Set(["active", "revoked"]), + "device_project_grant_state_invalid", + ), + }; + if (grant.projectRole === "owner" && grant.principalKind !== "user") { + throw new TypeError("device_project_owner_must_be_user"); + } + return grant; +} + +function compareGrantPriority(left, right) { + return ( + projectRoleWeight[right.projectRole] - projectRoleWeight[left.projectRole] || + left.principalRef.localeCompare(right.principalRef) + ); +} + +function normalizeOwnerScopeClaims(input) { + if (!Array.isArray(input) || input.length > 128) { + throw new TypeError("device_actor_owner_scopes_invalid"); + } + const claims = input.map((claim) => { + assertPlainObject(claim, "device_actor_owner_scope_invalid"); + assertAllowedKeys( + claim, + ["scopeKind", "ownerRef"], + "device_actor_owner_scope_field_unexpected", + ); + return { + scopeKind: normalizeScopeKind(claim.scopeKind), + ownerRef: normalizeOpaqueRef(claim.ownerRef, "device_owner_ref_invalid"), + }; + }); + const byKey = new Map( + claims.map((claim) => [`${claim.scopeKind}\0${claim.ownerRef}`, claim]), + ); + return [...byKey.values()].sort((left, right) => + `${left.scopeKind}:${left.ownerRef}`.localeCompare( + `${right.scopeKind}:${right.ownerRef}`, + ), + ); +} + +function normalizeCapabilities(input) { + if (!Array.isArray(input) || input.length > DEVICE_PROJECT_CAPABILITIES.length) { + throw new TypeError("device_project_capabilities_invalid"); + } + const normalized = input.map((capability) => + normalizeEnum( + capability, + capabilitySet, + "device_project_capability_invalid", + ), + ); + return [...new Set(normalized)].sort(); +} + +function normalizeOpaqueRefArray(input, code) { + if (!Array.isArray(input) || input.length > 128) throw new TypeError(code); + return [...new Set(input.map((value) => normalizeOpaqueRef(value, code)))].sort(); +} + +function normalizeProjectRef(value) { + if (typeof value !== "string") throw new TypeError("device_project_ref_invalid"); + const match = value.match(projectRefPattern); + if (!match) throw new TypeError("device_project_ref_invalid"); + return match[1].toLowerCase(); +} + +function normalizeScopeKind(value) { + return normalizeEnum( + value, + new Set(["company", "personal"]), + "device_owner_scope_kind_invalid", + ); +} + +function normalizeKey(value, code) { + if (typeof value !== "string" || !keyPattern.test(value)) { + throw new TypeError(code); + } + return value; +} + +function normalizeOpaqueRef(value, code) { + if (typeof value !== "string" || !opaqueRefPattern.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) { + throw new TypeError(code); + } + if (/\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 normalizeEnum(value, allowed, code) { + if (typeof value !== "string" || !allowed.has(value)) { + throw new TypeError(code); + } + return value; +} + +function assertAllowedKeys(input, allowed, code) { + const allowedSet = new Set(allowed); + for (const key of Object.keys(input)) { + if (!allowedSet.has(key)) throw new TypeError(`${code}:${key}`); + } +} + +function assertPlainObject(value, code) { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new TypeError(code); + } +} + +function domainError(code, statusCode) { + const error = new Error(code); + error.statusCode = statusCode; + return error; +} diff --git a/device-plane/services/device-control-core/src/server.mjs b/device-plane/services/device-control-core/src/server.mjs index ef6e92d..ae9e261 100644 --- a/device-plane/services/device-control-core/src/server.mjs +++ b/device-plane/services/device-control-core/src/server.mjs @@ -17,6 +17,8 @@ const server = createControlCoreApp({ gatewayToken: config.gatewayToken, identifierPepper: config.identifierPepper, discoveryIngestEnabled: config.discoveryIngestEnabled, + managementApiEnabled: config.managementApiEnabled, + managementToken: config.managementToken, }); server.listen(config.port, config.host, () => { @@ -25,6 +27,7 @@ server.listen(config.port, config.host, () => { host: config.host, port: config.port, discoveryIngest: config.discoveryIngestEnabled, + managementApi: config.managementApiEnabled, commandTransport: "disabled", })); }); @@ -44,6 +47,10 @@ async function readConfig() { process.env.DEVICE_DISCOVERY_INGEST_ENABLED, false, ); + const managementApiEnabled = parseBoolean( + process.env.DEVICE_MANAGEMENT_API_ENABLED, + false, + ); return { host: String(process.env.HOST || "127.0.0.1").trim(), port: parsePort(process.env.PORT, 18120), @@ -53,6 +60,7 @@ async function readConfig() { 10, ), discoveryIngestEnabled, + managementApiEnabled, gatewayToken: discoveryIngestEnabled ? await readRequiredSecretFile( process.env.DEVICE_GATEWAY_CORE_TOKEN_FILE, @@ -65,6 +73,12 @@ async function readConfig() { "device_identifier_pepper_file_required", ) : "", + managementToken: managementApiEnabled + ? await readRequiredSecretFile( + process.env.DEVICE_MANAGEMENT_CORE_TOKEN_FILE, + "device_management_core_token_file_required", + ) + : "", }; } diff --git a/device-plane/services/device-control-core/test/app.test.mjs b/device-plane/services/device-control-core/test/app.test.mjs index d8b0182..2435acd 100644 --- a/device-plane/services/device-control-core/test/app.test.mjs +++ b/device-plane/services/device-control-core/test/app.test.mjs @@ -8,6 +8,7 @@ import { createControlCoreApp } from "../src/app.mjs"; const gatewayToken = "test-only-gateway-token-with-32-bytes"; const identifierPepper = "test-only-identifier-pepper-with-32-bytes"; +const managementToken = "test-only-management-token-with-32-bytes"; const fakeImei = "000000000000001"; test("health reports database readiness and disabled command transport", async () => { @@ -24,6 +25,7 @@ test("health reports database readiness and disabled command transport", async ( service: "nodedc-device-control-core", database: "ready", discoveryIngest: "disabled", + managementApi: "disabled", commandTransport: "disabled", }); } finally { @@ -31,6 +33,210 @@ test("health reports database readiness and disabled command transport", async ( } }); +test("management API is closed by default", async () => { + const runtime = await startTestServer({ + repository: { + health: async () => "ready", + }, + }); + try { + const response = await fetch( + `${runtime.baseUrl}/internal/v1/management/owner-scopes:ensure`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{}", + }, + ); + assert.equal(response.status, 404); + assert.equal((await response.json()).error, "device_management_api_disabled"); + } finally { + await runtime.close(); + } +}); + +test("management API cannot start without its repository boundary and strong token", () => { + assert.throws( + () => createControlCoreApp({ + managementApiEnabled: true, + managementToken, + repository: { health: async () => "ready" }, + }), + /device_management_repository_required/, + ); + assert.throws( + () => createControlCoreApp({ + managementApiEnabled: true, + managementToken: "short", + repository: { + health: async () => "ready", + executeManagementCommand: async () => ({}), + }, + }), + /device_management_token_invalid/, + ); +}); + +test("management API requires service auth and an idempotency key", async () => { + const runtime = await startTestServer({ + managementApiEnabled: true, + managementToken, + repository: { + health: async () => "ready", + executeManagementCommand: async () => { + throw new Error("must_not_execute"); + }, + }, + }); + try { + const unauthorized = await fetch( + `${runtime.baseUrl}/internal/v1/management/owner-scopes:ensure`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{}", + }, + ); + assert.equal(unauthorized.status, 401); + + const missingIdempotency = await fetch( + `${runtime.baseUrl}/internal/v1/management/owner-scopes:ensure`, + { + method: "POST", + headers: managementHeaders({ includeIdempotency: false }), + body: JSON.stringify(ownerScopeCommand()), + }, + ); + assert.equal(missingIdempotency.status, 400); + assert.equal( + (await missingIdempotency.json()).error, + "device_management_header_required", + ); + } finally { + await runtime.close(); + } +}); + +test("management API forwards only normalized actor and command data", async () => { + let executed; + const runtime = await startTestServer({ + managementApiEnabled: true, + managementToken, + repository: { + health: async () => "ready", + executeManagementCommand: async (value) => { + executed = value; + return { + replayed: false, + result: { + created: true, + ownerScope: { + ownerScopeRef: "owner-scope:11111111-1111-4111-8111-111111111111", + scopeKind: "company", + ownerRef: "client:example", + displayName: "Example Company", + lifecycleState: "active", + }, + }, + }; + }, + }, + }); + try { + const response = await fetch( + `${runtime.baseUrl}/internal/v1/management/owner-scopes:ensure`, + { + method: "POST", + headers: managementHeaders(), + body: JSON.stringify(ownerScopeCommand()), + }, + ); + assert.equal(response.status, 200); + assert.equal(response.headers.get("idempotency-key"), "phase2-test-0001"); + assert.equal(response.headers.get("idempotency-replayed"), "false"); + assert.match(executed.requestDigest, /^sha256:[a-f0-9]{64}$/); + assert.equal(executed.commandKind, "owner_scope.ensure"); + assert.deepEqual(executed.command, ownerScopeCommand()); + assert.deepEqual(executed.actor, { + userRef: "user:engineer", + hubRole: "admin", + groupRefs: ["group:engineers", "group:operators"], + ownerScopes: [{ scopeKind: "company", ownerRef: "client:example" }], + }); + assert.equal((await response.json()).result.created, true); + } finally { + await runtime.close(); + } +}); + +test("management API rejects unexpected fields before repository execution", async () => { + let executions = 0; + const runtime = await startTestServer({ + managementApiEnabled: true, + managementToken, + repository: { + health: async () => "ready", + executeManagementCommand: async () => { + executions += 1; + return { replayed: false, result: {} }; + }, + }, + }); + try { + const response = await fetch( + `${runtime.baseUrl}/internal/v1/management/owner-scopes:ensure`, + { + method: "POST", + headers: managementHeaders(), + body: JSON.stringify({ + ...ownerScopeCommand(), + credential: "must-never-cross-boundary", + }), + }, + ); + assert.equal(response.status, 400); + assert.match( + (await response.json()).error, + /device_management_command_field_unexpected:credential/, + ); + assert.equal(executions, 0); + } finally { + await runtime.close(); + } +}); + +test("management API exposes a repository idempotency conflict without retrying", async () => { + let executions = 0; + const runtime = await startTestServer({ + managementApiEnabled: true, + managementToken, + repository: { + health: async () => "ready", + executeManagementCommand: async () => { + executions += 1; + const error = new Error("device_idempotency_key_conflict"); + error.statusCode = 409; + throw error; + }, + }, + }); + try { + const response = await fetch( + `${runtime.baseUrl}/internal/v1/management/owner-scopes:ensure`, + { + method: "POST", + headers: managementHeaders(), + body: JSON.stringify(ownerScopeCommand()), + }, + ); + assert.equal(response.status, 409); + assert.equal((await response.json()).error, "device_idempotency_key_conflict"); + assert.equal(executions, 1); + } finally { + await runtime.close(); + } +}); + test("discovery ingest is closed by default", async () => { const runtime = await startTestServer({ repository: { @@ -127,6 +333,26 @@ function fakeSignal() { }; } +function managementHeaders({ includeIdempotency = true } = {}) { + return { + Authorization: `Bearer ${managementToken}`, + "Content-Type": "application/json", + ...(includeIdempotency ? { "Idempotency-Key": "phase2-test-0001" } : {}), + "X-NODEDC-User-Ref": "user:engineer", + "X-NODEDC-Hub-Role": "admin", + "X-NODEDC-Group-Refs": "group:operators,group:engineers", + "X-NODEDC-Owner-Scopes": "company=client:example", + }; +} + +function ownerScopeCommand() { + return { + scopeKind: "company", + ownerRef: "client:example", + displayName: "Example Company", + }; +} + async function startTestServer(options) { const server = createControlCoreApp(options); await new Promise((resolve, reject) => { diff --git a/device-plane/services/device-control-core/test/management-command-migration.test.mjs b/device-plane/services/device-control-core/test/management-command-migration.test.mjs new file mode 100644 index 0000000..d868a32 --- /dev/null +++ b/device-plane/services/device-control-core/test/management-command-migration.test.mjs @@ -0,0 +1,39 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; + +const migrationUrl = new URL( + "../migrations/003_device_management_commands.sql", + import.meta.url, +); +const repositoryUrl = new URL("../src/postgres-repository.mjs", import.meta.url); + +test("management migration pins idempotency, audit and owner invariants", async () => { + const sql = await readFile(migrationUrl, "utf8"); + + assert.match(sql, /device_project_grants_owner_user_only/); + assert.match(sql, /project_role <> 'owner' or principal_kind = 'user'/); + assert.match(sql, /add column if not exists project_id uuid references device_projects\(id\)/); + assert.match(sql, /create table if not exists device_management_command_receipts/); + assert.match(sql, /unique \(actor_ref, command_kind, idempotency_key\)/); + assert.match(sql, /request_digest ~ '\^sha256:\[a-f0-9\]\{64\}\$'/); + assert.match(sql, /lifecycle_state in \('pending', 'completed'\)/); +}); + +test("management migration stores no tenant, device or credential seed", async () => { + const sql = await readFile(migrationUrl, "utf8"); + + assert.doesNotMatch(sql, /insert\s+into/i); + assert.doesNotMatch(sql, /dcctouch|arusnavi|b2|imei/i); + assert.doesNotMatch(sql, /password|secret|credential_ref/i); +}); + +test("repository applies management migration after project access", async () => { + const source = await readFile(repositoryUrl, "utf8"); + const projectAccessIndex = source.indexOf("002_device_project_access.sql"); + const managementIndex = source.indexOf("003_device_management_commands.sql"); + + assert.notEqual(projectAccessIndex, -1); + assert.notEqual(managementIndex, -1); + assert.ok(projectAccessIndex < managementIndex); +}); diff --git a/device-plane/services/device-control-core/test/management-repository.test.mjs b/device-plane/services/device-control-core/test/management-repository.test.mjs new file mode 100644 index 0000000..31e280f --- /dev/null +++ b/device-plane/services/device-control-core/test/management-repository.test.mjs @@ -0,0 +1,281 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { PostgresDeviceRepository } from "../src/postgres-repository.mjs"; +import { + normalizeManagementActor, + normalizeManagementCommand, +} from "../src/project-management.mjs"; + +const actor = normalizeManagementActor({ + userRef: "user:engineer", + hubRole: "admin", + groupRefs: [], + ownerScopes: [{ scopeKind: "company", ownerRef: "client:example" }], +}); +const command = normalizeManagementCommand("owner_scope.ensure", { + scopeKind: "company", + ownerRef: "client:example", + displayName: "Example Company", +}); +const baseInput = { + idempotencyKey: "phase2-repository-0001", + commandKind: "owner_scope.ensure", + requestDigest: `sha256:${"a".repeat(64)}`, + actor, + command, +}; + +test("replays a completed command without executing the domain mutation", async () => { + const client = scriptedClient([ + step("begin"), + step("insert into device_management_command_receipts", { rows: [] }), + step("from device_management_command_receipts", { + rows: [{ + id: "11111111-1111-4111-8111-111111111111", + request_digest: baseInput.requestDigest, + lifecycle_state: "completed", + response_body: { created: true, ownerScope: { ownerRef: "client:example" } }, + }], + }), + step("from device_owner_scopes", { + rows: [{ + id: "11111111-1111-4111-8111-111111111111", + scope_kind: "company", + owner_ref: "client:example", + display_name: "Example Company", + lifecycle_state: "active", + created_at: new Date("2026-08-10T00:00:00.000Z"), + updated_at: new Date("2026-08-10T00:00:00.000Z"), + }], + }), + step("commit"), + ]); + const repository = repositoryWithClient(client); + + const result = await repository.executeManagementCommand(baseInput); + + assert.equal(result.replayed, true); + assert.equal(result.result.ownerScope.ownerRef, "client:example"); + assert.equal(client.remaining(), 0); + assert.equal(client.released, true); +}); + +test("rejects idempotency-key reuse with a different normalized request", async () => { + const client = scriptedClient([ + step("begin"), + step("insert into device_management_command_receipts", { rows: [] }), + step("from device_management_command_receipts", { + rows: [{ + id: "11111111-1111-4111-8111-111111111111", + request_digest: `sha256:${"b".repeat(64)}`, + lifecycle_state: "completed", + response_body: { created: true }, + }], + }), + step("rollback"), + ]); + const repository = repositoryWithClient(client); + + await assert.rejects( + repository.executeManagementCommand(baseInput), + /device_idempotency_key_conflict/, + ); + assert.equal(client.remaining(), 0); + assert.equal(client.released, true); +}); + +test("does not replay a completed project command after project access is revoked", async () => { + const collectionCommand = normalizeManagementCommand("collection.ensure", { + projectRef: "project:11111111-1111-4111-8111-111111111111", + collectionKey: "field-devices", + name: "Field Devices", + }); + const input = { + idempotencyKey: "phase2-repository-collection-0001", + commandKind: "collection.ensure", + requestDigest: `sha256:${"c".repeat(64)}`, + actor, + command: collectionCommand, + }; + const now = new Date("2026-08-10T00:00:00.000Z"); + const client = scriptedClient([ + step("begin"), + step("insert into device_management_command_receipts", { rows: [] }), + step("from device_management_command_receipts", { + rows: [{ + id: "22222222-2222-4222-8222-222222222222", + request_digest: input.requestDigest, + lifecycle_state: "completed", + response_body: { created: true }, + }], + }), + step("from device_projects p", { + rows: [{ + id: "11111111-1111-4111-8111-111111111111", + owner_scope_id: "33333333-3333-4333-8333-333333333333", + project_key: "field-devices", + name: "Field Devices", + description: null, + lifecycle_state: "active", + created_at: now, + updated_at: now, + scope_kind: "company", + owner_ref: "client:example", + owner_display_name: "Example Company", + owner_lifecycle_state: "active", + owner_created_at: now, + owner_updated_at: now, + }], + }), + step("from device_project_grants", { rows: [] }), + step("rollback"), + ]); + const repository = repositoryWithClient(client); + + await assert.rejects( + repository.executeManagementCommand(input), + /device_project_capability_denied/, + ); + assert.equal(client.remaining(), 0); + assert.equal(client.released, true); +}); + +test("refuses to revoke the last active project owner", async () => { + const ownerActor = normalizeManagementActor({ + ...actor, + hubRole: "owner", + }); + const revokeOwner = normalizeManagementCommand("project_grant.upsert", { + projectRef: "project:11111111-1111-4111-8111-111111111111", + principalKind: "user", + principalRef: ownerActor.userRef, + projectRole: "owner", + lifecycleState: "revoked", + }); + const input = { + idempotencyKey: "phase2-repository-owner-0001", + commandKind: "project_grant.upsert", + requestDigest: `sha256:${"d".repeat(64)}`, + actor: ownerActor, + command: revokeOwner, + }; + const now = new Date("2026-08-10T00:00:00.000Z"); + const client = scriptedClient([ + step("begin"), + step("insert into device_management_command_receipts", { + rows: [{ id: "receipt-created" }], + }), + step("from device_projects p", { + rows: [{ + id: "11111111-1111-4111-8111-111111111111", + owner_scope_id: "33333333-3333-4333-8333-333333333333", + project_key: "field-devices", + name: "Field Devices", + description: null, + lifecycle_state: "active", + created_at: now, + updated_at: now, + scope_kind: "company", + owner_ref: "client:example", + owner_display_name: "Example Company", + owner_lifecycle_state: "active", + owner_created_at: now, + owner_updated_at: now, + }], + }), + step("from device_project_grants", { + rows: [{ + id: "44444444-4444-4444-8444-444444444444", + principal_kind: "user", + principal_ref: ownerActor.userRef, + project_role: "owner", + capability_allow: [], + capability_deny: [], + lifecycle_state: "active", + created_at: now, + updated_at: now, + }], + }), + step("rollback"), + ]); + const repository = repositoryWithClient(client); + + await assert.rejects( + repository.executeManagementCommand(input), + /device_project_last_owner_required/, + ); + assert.equal(client.remaining(), 0); + assert.equal(client.released, true); +}); + +test("commits an authorized generic owner-scope command and durable receipt", async () => { + const now = new Date("2026-08-10T00:00:00.000Z"); + const client = scriptedClient([ + step("begin"), + step("insert into device_management_command_receipts", { + rows: [{ id: "receipt-created" }], + }), + step("insert into device_owner_scopes", { + rows: [{ + id: "11111111-1111-4111-8111-111111111111", + scope_kind: "company", + owner_ref: "client:example", + display_name: "Example Company", + lifecycle_state: "active", + created_at: now, + updated_at: now, + created: true, + }], + }), + step("insert into device_audit_events"), + step("update device_management_command_receipts"), + step("commit"), + ]); + const repository = repositoryWithClient(client); + + const result = await repository.executeManagementCommand(baseInput); + + assert.equal(result.replayed, false); + assert.equal(result.result.created, true); + assert.equal(result.result.ownerScope.ownerRef, "client:example"); + assert.equal(client.remaining(), 0); + assert.equal(client.released, true); +}); + +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 { + released: false, + async query(sql) { + 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, "\\$&"); +} diff --git a/device-plane/services/device-control-core/test/management-runtime-contract.test.mjs b/device-plane/services/device-control-core/test/management-runtime-contract.test.mjs new file mode 100644 index 0000000..bc1ab42 --- /dev/null +++ b/device-plane/services/device-control-core/test/management-runtime-contract.test.mjs @@ -0,0 +1,44 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; + +const appUrl = new URL("../src/app.mjs", import.meta.url); +const serverUrl = new URL("../src/server.mjs", import.meta.url); +const repositoryUrl = new URL("../src/postgres-repository.mjs", import.meta.url); +const composeUrl = new URL("../../../docker-compose.device-plane.yml", import.meta.url); + +test("management surface is internal, POST-only and disabled by default", async () => { + const source = await readFile(appUrl, "utf8"); + + assert.match(source, /managementApiEnabled = false/); + assert.match(source, /\/internal\/v1\/management\/owner-scopes:ensure/); + assert.match(source, /\/internal\/v1\/management\/projects:ensure/); + assert.match(source, /\/internal\/v1\/management\/collections:ensure/); + assert.match(source, /\/internal\/v1\/management\/project-grants:upsert/); + assert.match(source, /request\.method === "POST" && managementCommandKind/); + assert.doesNotMatch(source, /\/api\/public\/.*management/); +}); + +test("management token remains file-backed and is not enabled by current Compose", async () => { + const server = await readFile(serverUrl, "utf8"); + const compose = await readFile(composeUrl, "utf8"); + + assert.match(server, /DEVICE_MANAGEMENT_API_ENABLED/); + assert.match(server, /DEVICE_MANAGEMENT_CORE_TOKEN_FILE/); + assert.doesNotMatch(compose, /DEVICE_MANAGEMENT_API_ENABLED/); + assert.doesNotMatch(compose, /DEVICE_MANAGEMENT_CORE_TOKEN_FILE/); +}); + +test("repository pins idempotency, audit and last-owner checks inside one transaction", async () => { + const source = await readFile(repositoryUrl, "utf8"); + + assert.match(source, /await client\.query\("begin"\)/); + assert.match(source, /await client\.query\("commit"\)/); + assert.match(source, /await client\.query\("rollback"\)/); + assert.match(source, /device_idempotency_key_conflict/); + assert.match(source, /device_project_last_owner_required/); + assert.match(source, /for update of p/); + assert.match(source, /authorizeManagementReplay/); + assert.match(source, /insert into device_audit_events/); + assert.match(source, /update device_management_command_receipts/); +}); diff --git a/device-plane/services/device-control-core/test/project-management.test.mjs b/device-plane/services/device-control-core/test/project-management.test.mjs new file mode 100644 index 0000000..bf03d7f --- /dev/null +++ b/device-plane/services/device-control-core/test/project-management.test.mjs @@ -0,0 +1,271 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + assertActorCanManageOwnerScope, + assertGrantMutationAllowed, + assertProjectCapability, + normalizeManagementActor, + normalizeManagementCommand, + resolveProjectAccess, +} from "../src/project-management.mjs"; + +const projectRef = "project:11111111-1111-4111-8111-111111111111"; + +test("normalizes strict generic management commands without seeded entities", () => { + const project = normalizeManagementCommand("project.ensure", { + scopeKind: "company", + ownerRef: "client:example", + projectKey: "field-devices", + name: "Field Devices", + description: "Generic project", + }); + assert.deepEqual(project, { + scopeKind: "company", + ownerRef: "client:example", + projectKey: "field-devices", + name: "Field Devices", + description: "Generic project", + }); + + assert.throws( + () => normalizeManagementCommand("project.ensure", { + scopeKind: "company", + ownerRef: "client:example", + projectKey: "field-devices", + name: "Field Devices", + rawPayload: "forbidden", + }), + /device_management_command_field_unexpected:rawPayload/, + ); +}); + +test("company scope requires an asserted scope and Hub admin ceiling", () => { + const scope = { scopeKind: "company", ownerRef: "client:example" }; + assert.doesNotThrow(() => assertActorCanManageOwnerScope(actor({ + hubRole: "admin", + ownerScopes: [scope], + }), scope)); + assert.throws( + () => assertActorCanManageOwnerScope(actor({ + hubRole: "viewer", + ownerScopes: [scope], + }), scope), + /device_owner_scope_access_denied/, + ); + assert.throws( + () => assertActorCanManageOwnerScope(actor({ hubRole: "owner" }), scope), + /device_owner_scope_access_denied/, + ); +}); + +test("personal scope is isolated to the matching Hub user", () => { + const scope = { scopeKind: "personal", ownerRef: "user:engineer" }; + assert.doesNotThrow(() => assertActorCanManageOwnerScope( + actor({ hubRole: "owner" }), + scope, + )); + assert.throws( + () => assertActorCanManageOwnerScope( + actor({ hubRole: "member" }), + scope, + ), + /device_owner_scope_access_denied/, + ); + assert.throws( + () => assertActorCanManageOwnerScope( + actor({ userRef: "user:other", hubRole: "owner" }), + scope, + ), + /device_owner_scope_access_denied/, + ); +}); + +test("Hub owner has no project access without an explicit project grant", () => { + const access = resolveProjectAccess({ + actor: actor({ hubRole: "owner" }), + grants: [], + }); + assert.equal(access.allowed, false); + assert.deepEqual(access.capabilities, []); +}); + +test("a direct user grant overrides broader group grants", () => { + const access = resolveProjectAccess({ + actor: actor({ hubRole: "owner", groupRefs: ["group:admins"] }), + grants: [ + grant({ + grantRef: "grant:group-admin", + principalKind: "group", + principalRef: "group:admins", + projectRole: "admin", + }), + grant({ + grantRef: "grant:direct-viewer", + principalKind: "user", + principalRef: "user:engineer", + projectRole: "viewer", + }), + ], + }); + assert.equal(access.projectRole, "viewer"); + assert.equal(access.capabilities.includes("access.manage"), false); +}); + +test("matching group grants combine bounded operator and engineer capabilities", () => { + const access = resolveProjectAccess({ + actor: actor({ + hubRole: "member", + groupRefs: ["group:operators", "group:engineers"], + }), + grants: [ + grant({ + grantRef: "grant:operator", + principalKind: "group", + principalRef: "group:operators", + projectRole: "operator", + }), + grant({ + grantRef: "grant:engineer", + principalKind: "group", + principalRef: "group:engineers", + projectRole: "engineer", + }), + ], + }); + assert.equal(access.capabilities.includes("device.enroll"), true); + assert.equal(access.capabilities.includes("command.dispatch"), true); + assert.equal(access.capabilities.includes("access.manage"), false); +}); + +test("Hub ceiling and explicit deny prevent privilege escalation", () => { + const ownerGrant = grant({ + grantRef: "grant:owner", + principalKind: "user", + principalRef: "user:engineer", + projectRole: "owner", + capabilityDeny: ["credential.manage"], + }); + const hubAdmin = resolveProjectAccess({ + actor: actor({ hubRole: "admin" }), + grants: [ownerGrant], + }); + const hubOwner = resolveProjectAccess({ + actor: actor({ hubRole: "owner" }), + grants: [ownerGrant], + }); + + assert.equal(hubAdmin.capabilities.includes("device.transfer"), false); + assert.equal(hubOwner.capabilities.includes("device.transfer"), true); + assert.equal(hubOwner.capabilities.includes("credential.manage"), false); +}); + +test("owner grant mutations require both Hub and project ownership authority", () => { + const grants = [grant({ + grantRef: "grant:owner", + principalKind: "user", + principalRef: "user:engineer", + projectRole: "owner", + })]; + const ownerCommand = normalizeManagementCommand("project_grant.upsert", { + projectRef, + principalKind: "user", + principalRef: "user:second-owner", + projectRole: "owner", + }); + + assert.throws( + () => assertGrantMutationAllowed( + actor({ hubRole: "admin" }), + grants, + ownerCommand, + ), + /device_project_owner_transfer_denied/, + ); + assert.doesNotThrow(() => assertGrantMutationAllowed( + actor({ hubRole: "owner" }), + grants, + ownerCommand, + )); +}); + +test("grant normalization rejects group owners and capability overlap", () => { + assert.throws( + () => normalizeManagementCommand("project_grant.upsert", { + projectRef, + principalKind: "group", + principalRef: "group:owners", + projectRole: "owner", + }), + /device_project_owner_must_be_user/, + ); + assert.throws( + () => normalizeManagementCommand("project_grant.upsert", { + projectRef, + principalKind: "user", + principalRef: "user:operator", + projectRole: "operator", + capabilityAllow: ["command.dispatch"], + capabilityDeny: ["command.dispatch"], + }), + /device_project_capability_overlap/, + ); +}); + +test("capability checks fail closed for inactive or unrelated grants", () => { + assert.throws( + () => assertProjectCapability( + actor({ hubRole: "owner" }), + [grant({ lifecycleState: "revoked" })], + "project.read", + ), + /device_project_capability_denied/, + ); +}); + +test("denying project.read collapses every derived capability", () => { + const access = resolveProjectAccess({ + actor: actor({ hubRole: "owner" }), + grants: [grant({ + projectRole: "owner", + capabilityDeny: ["project.read"], + })], + }); + + assert.equal(access.allowed, false); + assert.deepEqual(access.capabilities, []); + assert.throws( + () => assertProjectCapability( + actor({ hubRole: "owner" }), + [grant({ + projectRole: "owner", + capabilityDeny: ["project.read"], + })], + "access.manage", + ), + /device_project_capability_denied/, + ); +}); + +function actor(overrides = {}) { + return normalizeManagementActor({ + userRef: "user:engineer", + hubRole: "member", + groupRefs: [], + ownerScopes: [], + ...overrides, + }); +} + +function grant(overrides = {}) { + return { + grantRef: "grant:default", + principalKind: "user", + principalRef: "user:engineer", + projectRole: "viewer", + capabilityAllow: [], + capabilityDeny: [], + lifecycleState: "active", + ...overrides, + }; +}