feat(device-core): add idempotent project management
This commit is contained in:
+68
@@ -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;
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { timingSafeEqual } from "node:crypto";
|
import { createHash, timingSafeEqual } from "node:crypto";
|
||||||
import { createServer } from "node:http";
|
import { createServer } from "node:http";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -7,12 +7,25 @@ import {
|
|||||||
normalizeDiscoverySignal,
|
normalizeDiscoverySignal,
|
||||||
toSafeDiscoveryView,
|
toSafeDiscoveryView,
|
||||||
} from "../../../packages/device-protocol-contract/src/index.mjs";
|
} 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({
|
export function createControlCoreApp({
|
||||||
repository,
|
repository,
|
||||||
gatewayToken = "",
|
gatewayToken = "",
|
||||||
identifierPepper = "",
|
identifierPepper = "",
|
||||||
discoveryIngestEnabled = false,
|
discoveryIngestEnabled = false,
|
||||||
|
managementApiEnabled = false,
|
||||||
|
managementToken = "",
|
||||||
} = {}) {
|
} = {}) {
|
||||||
if (!repository || typeof repository.health !== "function") {
|
if (!repository || typeof repository.health !== "function") {
|
||||||
throw new TypeError("device_repository_required");
|
throw new TypeError("device_repository_required");
|
||||||
@@ -28,6 +41,14 @@ export function createControlCoreApp({
|
|||||||
throw new TypeError("device_identifier_pepper_invalid");
|
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) => {
|
const server = createServer(async (request, response) => {
|
||||||
response.setHeader("Content-Type", "application/json; charset=utf-8");
|
response.setHeader("Content-Type", "application/json; charset=utf-8");
|
||||||
@@ -47,10 +68,56 @@ export function createControlCoreApp({
|
|||||||
service: "nodedc-device-control-core",
|
service: "nodedc-device-control-core",
|
||||||
database,
|
database,
|
||||||
discoveryIngest: discoveryIngestEnabled ? "enabled" : "disabled",
|
discoveryIngest: discoveryIngestEnabled ? "enabled" : "disabled",
|
||||||
|
managementApi: managementApiEnabled ? "enabled" : "disabled",
|
||||||
commandTransport: "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 (
|
if (
|
||||||
request.method === "POST"
|
request.method === "POST"
|
||||||
&& requestUrl.pathname === "/internal/v1/device-discoveries:observe"
|
&& requestUrl.pathname === "/internal/v1/device-discoveries:observe"
|
||||||
@@ -108,6 +175,62 @@ export function createControlCoreApp({
|
|||||||
return server;
|
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) {
|
function matchesBearer(header, expected) {
|
||||||
if (typeof header !== "string" || !header.startsWith("Bearer ")) return false;
|
if (typeof header !== "string" || !header.startsWith("Bearer ")) return false;
|
||||||
const actual = Buffer.from(header.slice("Bearer ".length), "utf8");
|
const actual = Buffer.from(header.slice("Bearer ".length), "utf8");
|
||||||
|
|||||||
@@ -6,16 +6,34 @@ import { fileURLToPath } from "node:url";
|
|||||||
import pg from "pg";
|
import pg from "pg";
|
||||||
|
|
||||||
import { ARUSNAVI_B2_MODEL_PROFILE } from "../../../packages/arusnavi-b2-adapter/src/index.mjs";
|
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 { Pool } = pg;
|
||||||
const serviceRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
const serviceRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||||
const migrationFiles = [
|
const migrationFiles = [
|
||||||
"001_device_plane_foundation.sql",
|
"001_device_plane_foundation.sql",
|
||||||
"002_device_project_access.sql",
|
"002_device_project_access.sql",
|
||||||
|
"003_device_management_commands.sql",
|
||||||
];
|
];
|
||||||
|
|
||||||
export class PostgresDeviceRepository {
|
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() === "") {
|
if (typeof databaseUrl !== "string" || databaseUrl.trim() === "") {
|
||||||
throw new TypeError("device_database_url_required");
|
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() {
|
async close() {
|
||||||
await this.pool.end();
|
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) {
|
function normalizePoolSize(value) {
|
||||||
const parsed = Number(value);
|
const parsed = Number(value);
|
||||||
if (!Number.isSafeInteger(parsed) || parsed < 1 || parsed > 50) {
|
if (!Number.isSafeInteger(parsed) || parsed < 1 || parsed > 50) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -17,6 +17,8 @@ const server = createControlCoreApp({
|
|||||||
gatewayToken: config.gatewayToken,
|
gatewayToken: config.gatewayToken,
|
||||||
identifierPepper: config.identifierPepper,
|
identifierPepper: config.identifierPepper,
|
||||||
discoveryIngestEnabled: config.discoveryIngestEnabled,
|
discoveryIngestEnabled: config.discoveryIngestEnabled,
|
||||||
|
managementApiEnabled: config.managementApiEnabled,
|
||||||
|
managementToken: config.managementToken,
|
||||||
});
|
});
|
||||||
|
|
||||||
server.listen(config.port, config.host, () => {
|
server.listen(config.port, config.host, () => {
|
||||||
@@ -25,6 +27,7 @@ server.listen(config.port, config.host, () => {
|
|||||||
host: config.host,
|
host: config.host,
|
||||||
port: config.port,
|
port: config.port,
|
||||||
discoveryIngest: config.discoveryIngestEnabled,
|
discoveryIngest: config.discoveryIngestEnabled,
|
||||||
|
managementApi: config.managementApiEnabled,
|
||||||
commandTransport: "disabled",
|
commandTransport: "disabled",
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
@@ -44,6 +47,10 @@ async function readConfig() {
|
|||||||
process.env.DEVICE_DISCOVERY_INGEST_ENABLED,
|
process.env.DEVICE_DISCOVERY_INGEST_ENABLED,
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
|
const managementApiEnabled = parseBoolean(
|
||||||
|
process.env.DEVICE_MANAGEMENT_API_ENABLED,
|
||||||
|
false,
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
host: String(process.env.HOST || "127.0.0.1").trim(),
|
host: String(process.env.HOST || "127.0.0.1").trim(),
|
||||||
port: parsePort(process.env.PORT, 18120),
|
port: parsePort(process.env.PORT, 18120),
|
||||||
@@ -53,6 +60,7 @@ async function readConfig() {
|
|||||||
10,
|
10,
|
||||||
),
|
),
|
||||||
discoveryIngestEnabled,
|
discoveryIngestEnabled,
|
||||||
|
managementApiEnabled,
|
||||||
gatewayToken: discoveryIngestEnabled
|
gatewayToken: discoveryIngestEnabled
|
||||||
? await readRequiredSecretFile(
|
? await readRequiredSecretFile(
|
||||||
process.env.DEVICE_GATEWAY_CORE_TOKEN_FILE,
|
process.env.DEVICE_GATEWAY_CORE_TOKEN_FILE,
|
||||||
@@ -65,6 +73,12 @@ async function readConfig() {
|
|||||||
"device_identifier_pepper_file_required",
|
"device_identifier_pepper_file_required",
|
||||||
)
|
)
|
||||||
: "",
|
: "",
|
||||||
|
managementToken: managementApiEnabled
|
||||||
|
? await readRequiredSecretFile(
|
||||||
|
process.env.DEVICE_MANAGEMENT_CORE_TOKEN_FILE,
|
||||||
|
"device_management_core_token_file_required",
|
||||||
|
)
|
||||||
|
: "",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { createControlCoreApp } from "../src/app.mjs";
|
|||||||
|
|
||||||
const gatewayToken = "test-only-gateway-token-with-32-bytes";
|
const gatewayToken = "test-only-gateway-token-with-32-bytes";
|
||||||
const identifierPepper = "test-only-identifier-pepper-with-32-bytes";
|
const identifierPepper = "test-only-identifier-pepper-with-32-bytes";
|
||||||
|
const managementToken = "test-only-management-token-with-32-bytes";
|
||||||
const fakeImei = "000000000000001";
|
const fakeImei = "000000000000001";
|
||||||
|
|
||||||
test("health reports database readiness and disabled command transport", async () => {
|
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",
|
service: "nodedc-device-control-core",
|
||||||
database: "ready",
|
database: "ready",
|
||||||
discoveryIngest: "disabled",
|
discoveryIngest: "disabled",
|
||||||
|
managementApi: "disabled",
|
||||||
commandTransport: "disabled",
|
commandTransport: "disabled",
|
||||||
});
|
});
|
||||||
} finally {
|
} 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 () => {
|
test("discovery ingest is closed by default", async () => {
|
||||||
const runtime = await startTestServer({
|
const runtime = await startTestServer({
|
||||||
repository: {
|
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) {
|
async function startTestServer(options) {
|
||||||
const server = createControlCoreApp(options);
|
const server = createControlCoreApp(options);
|
||||||
await new Promise((resolve, reject) => {
|
await new Promise((resolve, reject) => {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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, "\\$&");
|
||||||
|
}
|
||||||
@@ -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/);
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user