diff --git a/device-plane/README.md b/device-plane/README.md index 06fde5c..26bf9cb 100644 --- a/device-plane/README.md +++ b/device-plane/README.md @@ -46,8 +46,12 @@ the remaining activation gates. The staged admission update keeps the relay opaque but requires a public IPv4 source and bounds its source table and bytes per direction; it does not enable router/NAT exposure. -The Foundry `Device Manager` is a canonical page template using a server-owned -`device-plane-control` binding. It is not a service in this directory. +The standalone Hub application `Device Core` / `Device Manager` is the human +control-plane shell. Its server-owned BFF calls the disabled-by-default +management and query API in `device-control-core`; browsers never receive the +Core bearer token and never author actor, role, group or owner-scope headers. +Foundry remains a downstream consumer for project-approved device data and is +not the device registry or administration boundary. Run the foundation tests: diff --git a/device-plane/services/device-control-core/src/app.mjs b/device-plane/services/device-control-core/src/app.mjs index 64f4cc2..eaec83b 100644 --- a/device-plane/services/device-control-core/src/app.mjs +++ b/device-plane/services/device-control-core/src/app.mjs @@ -149,6 +149,61 @@ export function createControlCoreApp({ }); } + if ( + request.method === "GET" + && requestUrl.pathname === "/internal/v1/query/projects" + ) { + 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", + }); + } + if (typeof repository.listAccessibleProjects !== "function") { + return writeJson(response, 503, { + ok: false, + error: "device_query_repository_unavailable", + }); + } + const actor = managementActorFromHeaders(request.headers); + const projects = await repository.listAccessibleProjects(actor); + return writeJson(response, 200, { ok: true, projects }); + } + + const workspaceProjectId = projectWorkspaceId(requestUrl.pathname); + if (request.method === "GET" && workspaceProjectId) { + 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", + }); + } + if (typeof repository.getProjectWorkspace !== "function") { + return writeJson(response, 503, { + ok: false, + error: "device_query_repository_unavailable", + }); + } + const actor = managementActorFromHeaders(request.headers); + const workspace = await repository.getProjectWorkspace( + actor, + workspaceProjectId, + ); + return writeJson(response, 200, { ok: true, workspace }); + } + if ( request.method === "POST" && requestUrl.pathname === "/internal/v1/device-discoveries:observe" @@ -208,6 +263,13 @@ export function createControlCoreApp({ return server; } +function projectWorkspaceId(pathname) { + const match = pathname.match( + /^\/internal\/v1\/query\/projects\/([0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})\/workspace$/i, + ); + return match?.[1]?.toLowerCase() ?? null; +} + function managementActorFromHeaders(headers) { return normalizeManagementActor({ userRef: singleHeader(headers["x-nodedc-user-ref"]), diff --git a/device-plane/services/device-control-core/src/lifecycle-repository.mjs b/device-plane/services/device-control-core/src/lifecycle-repository.mjs index a422d3a..d06f652 100644 --- a/device-plane/services/device-control-core/src/lifecycle-repository.mjs +++ b/device-plane/services/device-control-core/src/lifecycle-repository.mjs @@ -507,7 +507,8 @@ async function transferDevice(client, actor, command) { export async function findProjectWithCapability(client, actor, projectId, capability) { const result = await client.query( - `select p.id, p.owner_scope_id, p.lifecycle_state, + `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 from device_projects p diff --git a/device-plane/services/device-control-core/src/postgres-repository.mjs b/device-plane/services/device-control-core/src/postgres-repository.mjs index 3789688..aa5163d 100644 --- a/device-plane/services/device-control-core/src/postgres-repository.mjs +++ b/device-plane/services/device-control-core/src/postgres-repository.mjs @@ -14,6 +14,10 @@ import { import { isControlResourceManagementCommand, } from "./control-resource-management.mjs"; +import { + getDeviceProjectWorkspace, + listAccessibleDeviceProjects, +} from "./project-query-repository.mjs"; import { applyInfrastructureManagementCommand, authorizeInfrastructureManagementReplay, @@ -168,6 +172,33 @@ export class PostgresDeviceRepository { } } + async listAccessibleProjects(actor) { + return this.#executeRead((client) => + listAccessibleDeviceProjects(client, actor) + ); + } + + async getProjectWorkspace(actor, projectId) { + return this.#executeRead((client) => + getDeviceProjectWorkspace(client, actor, projectId) + ); + } + + async #executeRead(operation) { + const client = await this.pool.connect(); + try { + await client.query("begin transaction read only"); + const result = await operation(client); + await client.query("commit"); + return result; + } catch (error) { + await client.query("rollback").catch(() => undefined); + throw mapPostgresError(error); + } finally { + client.release(); + } + } + async close() { await this.pool.end(); } diff --git a/device-plane/services/device-control-core/src/project-query-repository.mjs b/device-plane/services/device-control-core/src/project-query-repository.mjs new file mode 100644 index 0000000..003e709 --- /dev/null +++ b/device-plane/services/device-control-core/src/project-query-repository.mjs @@ -0,0 +1,265 @@ +import { + resolveProjectAccess, + toProjectRef, +} from "./project-management.mjs"; +import { findProjectWithCapability } from "./lifecycle-repository.mjs"; + +export async function listAccessibleDeviceProjects(client, actor) { + const result = await client.query( + `select p.id, p.project_key, p.name, p.description, + p.lifecycle_state, p.created_at, p.updated_at, + os.id as owner_scope_id, os.scope_kind, os.owner_ref, + os.display_name as owner_display_name, + g.id as grant_id, g.principal_kind, g.principal_ref, + g.project_role, g.capability_allow, g.capability_deny, + g.lifecycle_state as grant_lifecycle_state, + (select count(*)::bigint from device_instances di + where di.project_id = p.id) as device_count, + (select count(*)::bigint from device_collections dc + where dc.project_id = p.id and dc.lifecycle_state = 'active') as collection_count, + (select count(*)::bigint from device_discoveries dd + where dd.project_id = p.id and dd.lifecycle_state = 'quarantine') as discovery_count + from device_projects p + join device_owner_scopes os on os.id = p.owner_scope_id + join device_project_grants g on g.project_id = p.id + where p.lifecycle_state <> 'archived' + and os.lifecycle_state = 'active' + and g.lifecycle_state = 'active' + and ( + (g.principal_kind = 'user' and g.principal_ref = $1) + or (g.principal_kind = 'group' and g.principal_ref = any($2::text[])) + ) + order by os.display_name, p.name, g.created_at, g.id`, + [actor.userRef, actor.groupRefs], + ); + + const projects = new Map(); + for (const row of result.rows) { + const entry = projects.get(row.id) ?? { row, grants: [] }; + entry.grants.push(grantView(row)); + projects.set(row.id, entry); + } + + return [...projects.values()].flatMap(({ row, grants }) => { + const access = resolveProjectAccess({ actor, grants }); + return access.allowed ? [projectSummaryView(row, access)] : []; + }); +} + +export async function getDeviceProjectWorkspace(client, actor, projectId) { + const project = await findProjectWithCapability( + client, + actor, + projectId, + "project.read", + ); + const grantsResult = await client.query( + `select id, principal_kind, principal_ref, project_role, + capability_allow, capability_deny, lifecycle_state + from device_project_grants + where project_id = $1 + order by created_at, id`, + [projectId], + ); + const access = resolveProjectAccess({ + actor, + grants: grantsResult.rows.map(storedGrantView), + }); + + const devices = await client.query( + `select di.id, di.device_key, di.display_name, di.model_profile_ref, + di.lifecycle_state, di.created_at, di.updated_at, + identifier.identifier_kind, identifier.identifier_masked, + session.lifecycle_state as session_state, + session.last_seen_at + from device_instances di + left join lateral ( + select dri.identifier_kind, dri.identifier_masked + from device_restricted_identifiers dri + where dri.device_id = di.id + and dri.lifecycle_state = 'active' + order by dri.is_primary desc, dri.created_at + limit 1 + ) identifier on true + left join lateral ( + select ds.lifecycle_state, ds.last_seen_at + from device_sessions ds + where ds.device_id = di.id + and ds.lifecycle_state in ('connecting', 'online') + order by ds.last_seen_at desc + limit 1 + ) session on true + where di.project_id = $1 + order by di.display_name, di.id`, + [projectId], + ); + const collections = await client.query( + `select dc.id, dc.collection_key, dc.name, dc.description, + dc.lifecycle_state, dc.created_at, dc.updated_at, + count(dcm.device_id)::bigint as member_count + from device_collections dc + left join device_collection_members dcm + on dcm.collection_id = dc.id and dcm.project_id = dc.project_id + where dc.project_id = $1 + group by dc.id + order by dc.name, dc.id`, + [projectId], + ); + const discoveries = await client.query( + `select dd.id, dd.identifier_kind, dd.identifier_masked, + dd.model_profile_ref, dd.protocol, dd.lifecycle_state, + dd.first_observed_at, dd.last_observed_at, + dd.enrollment_intent_id, dd.claimed_device_id + from device_discoveries dd + where dd.project_id = $1 + order by dd.last_observed_at desc, dd.id`, + [projectId], + ); + const enrollments = await client.query( + `select dei.id, dei.enrollment_key, dei.display_name, + dei.model_profile_ref, dei.expected_identifier_kind, + dei.expected_identifier_masked, dei.lifecycle_state, + dei.observed_discovery_id, dei.claimed_device_id, + dei.expires_at, dei.created_at, dei.updated_at + from device_enrollment_intents dei + where dei.project_id = $1 + order by dei.updated_at desc, dei.id`, + [projectId], + ); + + return { + project: projectSummaryView(project, access), + devices: devices.rows.map(deviceView), + collections: collections.rows.map(collectionView), + discoveries: discoveries.rows.map(discoveryView), + enrollments: enrollments.rows.map(enrollmentView), + }; +} + +function projectSummaryView(row, access) { + return { + projectRef: toProjectRef(row.id), + projectKey: row.project_key, + name: row.name, + description: row.description ?? null, + lifecycleState: row.lifecycle_state, + ownerScope: { + ownerScopeRef: `owner-scope:${row.owner_scope_id}`, + scopeKind: row.scope_kind, + ownerRef: row.owner_ref, + displayName: row.owner_display_name, + }, + access: { + projectRole: access.projectRole, + capabilities: access.capabilities, + }, + counts: { + devices: numericCount(row.device_count), + collections: numericCount(row.collection_count), + discoveries: numericCount(row.discovery_count), + }, + createdAt: toIso(row.created_at), + updatedAt: toIso(row.updated_at), + }; +} + +function deviceView(row) { + return { + deviceRef: `device:${row.id}`, + deviceKey: row.device_key, + displayName: row.display_name, + modelProfileRef: row.model_profile_ref, + lifecycleState: row.lifecycle_state, + identifier: row.identifier_masked + ? { kind: row.identifier_kind, masked: row.identifier_masked } + : null, + session: row.session_state + ? { state: row.session_state, lastSeenAt: toIso(row.last_seen_at) } + : null, + createdAt: toIso(row.created_at), + updatedAt: toIso(row.updated_at), + }; +} + +function collectionView(row) { + return { + collectionRef: `collection:${row.id}`, + collectionKey: row.collection_key, + name: row.name, + description: row.description ?? null, + lifecycleState: row.lifecycle_state, + memberCount: numericCount(row.member_count), + createdAt: toIso(row.created_at), + updatedAt: toIso(row.updated_at), + }; +} + +function discoveryView(row) { + return { + discoveryRef: `discovery:${row.id}`, + identifier: { kind: row.identifier_kind, masked: row.identifier_masked }, + modelProfileRef: row.model_profile_ref, + protocol: row.protocol, + lifecycleState: row.lifecycle_state, + enrollmentIntentRef: row.enrollment_intent_id + ? `enrollment:${row.enrollment_intent_id}` + : null, + claimedDeviceRef: row.claimed_device_id ? `device:${row.claimed_device_id}` : null, + firstObservedAt: toIso(row.first_observed_at), + lastObservedAt: toIso(row.last_observed_at), + }; +} + +function enrollmentView(row) { + return { + enrollmentIntentRef: `enrollment:${row.id}`, + enrollmentKey: row.enrollment_key, + displayName: row.display_name, + modelProfileRef: row.model_profile_ref, + expectedIdentifier: { + kind: row.expected_identifier_kind, + masked: row.expected_identifier_masked, + }, + lifecycleState: row.lifecycle_state, + observedDiscoveryRef: row.observed_discovery_id + ? `discovery:${row.observed_discovery_id}` + : null, + claimedDeviceRef: row.claimed_device_id ? `device:${row.claimed_device_id}` : null, + expiresAt: toIso(row.expires_at), + createdAt: toIso(row.created_at), + updatedAt: toIso(row.updated_at), + }; +} + +function grantView(row) { + return storedGrantView({ + id: row.grant_id, + principal_kind: row.principal_kind, + principal_ref: row.principal_ref, + project_role: row.project_role, + capability_allow: row.capability_allow, + capability_deny: row.capability_deny, + lifecycle_state: row.grant_lifecycle_state, + }); +} + +function storedGrantView(row) { + return { + grantRef: `grant:${row.id}`, + principalKind: row.principal_kind, + principalRef: row.principal_ref, + projectRole: row.project_role, + capabilityAllow: row.capability_allow ?? [], + capabilityDeny: row.capability_deny ?? [], + lifecycleState: row.lifecycle_state, + }; +} + +function numericCount(value) { + const count = Number(value ?? 0); + return Number.isSafeInteger(count) && count >= 0 ? count : 0; +} + +function toIso(value) { + return value == null ? null : new Date(value).toISOString(); +} diff --git a/device-plane/services/device-control-core/test/app.test.mjs b/device-plane/services/device-control-core/test/app.test.mjs index 434ca13..9e59e03 100644 --- a/device-plane/services/device-control-core/test/app.test.mjs +++ b/device-plane/services/device-control-core/test/app.test.mjs @@ -237,6 +237,77 @@ test("management API exposes a repository idempotency conflict without retrying" } }); +test("project query is service-authenticated and forwards only the trusted actor", async () => { + let queriedActor; + const runtime = await startTestServer({ + managementApiEnabled: true, + managementToken, + repository: { + health: async () => "ready", + executeManagementCommand: async () => ({ replayed: false, result: {} }), + listAccessibleProjects: async (actor) => { + queriedActor = actor; + return [{ projectRef: "project:11111111-1111-4111-8111-111111111111" }]; + }, + }, + }); + try { + const unauthorized = await fetch( + `${runtime.baseUrl}/internal/v1/query/projects`, + ); + assert.equal(unauthorized.status, 401); + + const response = await fetch( + `${runtime.baseUrl}/internal/v1/query/projects`, + { headers: managementHeaders() }, + ); + assert.equal(response.status, 200); + assert.equal((await response.json()).projects.length, 1); + assert.deepEqual(queriedActor, { + userRef: "user:engineer", + hubRole: "admin", + groupRefs: ["group:engineers", "group:operators"], + ownerScopes: [{ scopeKind: "company", ownerRef: "client:example" }], + }); + } finally { + await runtime.close(); + } +}); + +test("project workspace query accepts only a canonical project path", async () => { + const projectId = "11111111-1111-4111-8111-111111111111"; + let queried; + const runtime = await startTestServer({ + managementApiEnabled: true, + managementToken, + repository: { + health: async () => "ready", + executeManagementCommand: async () => ({ replayed: false, result: {} }), + getProjectWorkspace: async (actor, id) => { + queried = { actor, id }; + return { project: { projectRef: `project:${id}` }, devices: [] }; + }, + }, + }); + try { + const response = await fetch( + `${runtime.baseUrl}/internal/v1/query/projects/${projectId}/workspace`, + { headers: managementHeaders() }, + ); + assert.equal(response.status, 200); + assert.equal((await response.json()).workspace.devices.length, 0); + assert.equal(queried.id, projectId); + + const invalid = await fetch( + `${runtime.baseUrl}/internal/v1/query/projects/not-a-project/workspace`, + { headers: managementHeaders() }, + ); + assert.equal(invalid.status, 404); + } finally { + await runtime.close(); + } +}); + test("discovery ingest is closed by default", async () => { const runtime = await startTestServer({ repository: { diff --git a/device-plane/services/device-control-core/test/project-query-repository.test.mjs b/device-plane/services/device-control-core/test/project-query-repository.test.mjs new file mode 100644 index 0000000..86549ae --- /dev/null +++ b/device-plane/services/device-control-core/test/project-query-repository.test.mjs @@ -0,0 +1,192 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; + +import { + getDeviceProjectWorkspace, + listAccessibleDeviceProjects, +} from "../src/project-query-repository.mjs"; +import { normalizeManagementActor } from "../src/project-management.mjs"; + +const projectId = "11111111-1111-4111-8111-111111111111"; +const actor = normalizeManagementActor({ + userRef: "user:device-admin", + hubRole: "admin", + groupRefs: ["group:engineers"], + ownerScopes: [], +}); +const timestamp = "2026-08-10T00:00:00.000Z"; + +test("project list applies direct-grant precedence and returns bounded summaries", async () => { + const client = { + async query(sql) { + assert.match(sql, /from device_projects p/); + return { + rows: [ + projectGrantRow({ + grant_id: "22222222-2222-4222-8222-222222222222", + principal_kind: "group", + principal_ref: "group:engineers", + project_role: "engineer", + }), + projectGrantRow({ + grant_id: "33333333-3333-4333-8333-333333333333", + principal_kind: "user", + principal_ref: "user:device-admin", + project_role: "viewer", + }), + ], + }; + }, + }; + + const projects = await listAccessibleDeviceProjects(client, actor); + assert.equal(projects.length, 1); + assert.equal(projects[0].access.projectRole, "viewer"); + assert.equal(projects[0].access.capabilities.includes("collection.manage"), false); + assert.deepEqual(projects[0].counts, { + devices: 3, + collections: 2, + discoveries: 1, + }); +}); + +test("project workspace returns only masked identity projections", async () => { + const client = workspaceClient(); + const workspace = await getDeviceProjectWorkspace(client, actor, projectId); + + assert.equal(workspace.project.projectRef, `project:${projectId}`); + assert.equal(workspace.devices[0].identifier.masked, "***********0001"); + assert.equal(workspace.discoveries[0].identifier.masked, "***********0001"); + assert.equal(workspace.enrollments[0].expectedIdentifier.masked, "***********0001"); + const serialized = JSON.stringify(workspace); + assert.equal(serialized.includes("hmac-sha256"), false); + assert.equal(serialized.includes("ndc-credref"), false); +}); + +test("project read source never selects identifier digests or credential refs", async () => { + const source = await readFile( + new URL("../src/project-query-repository.mjs", import.meta.url), + "utf8", + ); + assert.doesNotMatch( + source, + /\b(?:identifier_digest|expected_identifier_digest|credential_ref)\b/, + ); +}); + +function workspaceClient() { + let grantReads = 0; + return { + async query(sql) { + if (/from device_projects p/.test(sql)) { + return { rows: [projectGrantRow()] }; + } + if (/from device_project_grants/.test(sql)) { + grantReads += 1; + return { rows: [storedGrantRow()] }; + } + if (/from device_instances di/.test(sql)) { + return { rows: [{ + id: "44444444-4444-4444-8444-444444444444", + device_key: "pilot-device", + display_name: "Pilot device", + model_profile_ref: "vendor.model.v1", + lifecycle_state: "online", + identifier_kind: "imei", + identifier_masked: "***********0001", + session_state: "online", + last_seen_at: timestamp, + created_at: timestamp, + updated_at: timestamp, + }] }; + } + if (/from device_collections dc/.test(sql)) { + return { rows: [{ + id: "55555555-5555-4555-8555-555555555555", + collection_key: "pilot-fleet", + name: "Pilot fleet", + description: null, + lifecycle_state: "active", + member_count: "1", + created_at: timestamp, + updated_at: timestamp, + }] }; + } + if (/from device_discoveries dd/.test(sql)) { + return { rows: [{ + id: "66666666-6666-4666-8666-666666666666", + identifier_kind: "imei", + identifier_masked: "***********0001", + model_profile_ref: "vendor.model.v1", + protocol: "INTERNAL", + lifecycle_state: "quarantine", + first_observed_at: timestamp, + last_observed_at: timestamp, + enrollment_intent_id: "77777777-7777-4777-8777-777777777777", + claimed_device_id: null, + }] }; + } + if (/from device_enrollment_intents dei/.test(sql)) { + return { rows: [{ + id: "77777777-7777-4777-8777-777777777777", + enrollment_key: "pilot-enrollment", + display_name: "Pilot device", + model_profile_ref: "vendor.model.v1", + expected_identifier_kind: "imei", + expected_identifier_masked: "***********0001", + lifecycle_state: "observed", + observed_discovery_id: "66666666-6666-4666-8666-666666666666", + claimed_device_id: null, + expires_at: null, + created_at: timestamp, + updated_at: timestamp, + }] }; + } + throw new Error(`unexpected_query:${sql}`); + }, + get grantReads() { + return grantReads; + }, + }; +} + +function projectGrantRow(overrides = {}) { + return { + id: projectId, + project_key: "pilot-project", + name: "Pilot project", + description: null, + lifecycle_state: "active", + owner_scope_id: "88888888-8888-4888-8888-888888888888", + scope_kind: "company", + owner_ref: "client:dctouch", + owner_display_name: "DCTOUCH", + owner_lifecycle_state: "active", + grant_id: "33333333-3333-4333-8333-333333333333", + principal_kind: "user", + principal_ref: "user:device-admin", + project_role: "viewer", + capability_allow: [], + capability_deny: [], + grant_lifecycle_state: "active", + device_count: "3", + collection_count: "2", + discovery_count: "1", + created_at: timestamp, + updated_at: timestamp, + ...overrides, + }; +} + +function storedGrantRow() { + return { + id: "33333333-3333-4333-8333-333333333333", + principal_kind: "user", + principal_ref: "user:device-admin", + project_role: "admin", + capability_allow: [], + capability_deny: [], + lifecycle_state: "active", + }; +}