diff --git a/device-plane/README.md b/device-plane/README.md index 26bf9cb..0027249 100644 --- a/device-plane/README.md +++ b/device-plane/README.md @@ -50,6 +50,10 @@ 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. +The authorized project workspace exposes only bounded metadata for catalog, +routes/sessions, bindings, configuration state, command state, audit and access; +raw audit/configuration payloads, command parameters/transport refs, external +approval proofs, credential refs and restricted identifier digests remain in Core. Foundry remains a downstream consumer for project-approved device data and is not the device registry or administration boundary. 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 index 003e709..f9f441b 100644 --- a/device-plane/services/device-control-core/src/project-query-repository.mjs +++ b/device-plane/services/device-control-core/src/project-query-repository.mjs @@ -127,12 +127,222 @@ export async function getDeviceProjectWorkspace(client, actor, projectId) { [projectId], ); + const capabilities = new Set(access.capabilities); + const mayManageRoutes = capabilities.has("route.manage"); + const catalogParameters = [projectId, mayManageRoutes]; + const adapterPackages = await client.query( + `select ap.id, ap.package_key, ap.display_name, ap.publisher_ref, + ap.lifecycle_state, ap.created_at, ap.updated_at + from device_adapter_packages ap + where $2::boolean + or exists ( + select 1 + from device_adapter_versions av + join device_model_profiles dmp on dmp.adapter_version_id = av.id + where av.adapter_package_id = ap.id + and ( + exists ( + select 1 from device_routes dr + where dr.project_id = $1 and dr.model_profile_ref = dmp.profile_ref + ) + or exists ( + select 1 from device_instances di + where di.project_id = $1 and di.model_profile_ref = dmp.profile_ref + ) + ) + ) + order by ap.display_name, ap.id`, + catalogParameters, + ); + const adapterVersions = await client.query( + `select av.id, av.adapter_package_id, av.version, + av.runtime_package_ref, av.content_digest, av.contract_version, + av.capabilities, av.lifecycle_state, av.created_at, av.updated_at + from device_adapter_versions av + where $2::boolean + or exists ( + select 1 from device_model_profiles dmp + where dmp.adapter_version_id = av.id + and ( + exists ( + select 1 from device_routes dr + where dr.project_id = $1 and dr.model_profile_ref = dmp.profile_ref + ) + or exists ( + select 1 from device_instances di + where di.project_id = $1 and di.model_profile_ref = dmp.profile_ref + ) + ) + ) + order by av.adapter_package_id, av.created_at, av.id`, + catalogParameters, + ); + const modelProfiles = await client.query( + `select dmp.profile_ref, dmp.adapter_version_id, dmp.schema_version, + dmp.vendor, dmp.model, dmp.device_type, dmp.protocol, + dmp.schema_artifact_ref, dmp.profile_digest, dmp.capabilities, + dmp.lifecycle_state, dmp.created_at, dmp.updated_at + from device_model_profiles dmp + where $2::boolean + or exists ( + select 1 from device_routes dr + where dr.project_id = $1 and dr.model_profile_ref = dmp.profile_ref + ) + or exists ( + select 1 from device_instances di + where di.project_id = $1 and di.model_profile_ref = dmp.profile_ref + ) + order by dmp.vendor, dmp.model, dmp.profile_ref`, + catalogParameters, + ); + const edges = await client.query( + `select de.id, de.edge_key, de.display_name, de.deployment_ref, + de.lifecycle_state, de.created_at, de.updated_at + from device_edges de + where $2::boolean + or exists ( + select 1 from device_routes dr + where dr.project_id = $1 and dr.edge_id = de.id + ) + order by de.display_name, de.id`, + catalogParameters, + ); + const routes = await client.query( + `select dr.id, dr.route_key, dr.display_name, dr.edge_id, + de.display_name as edge_name, dr.model_profile_ref, + dmp.vendor as profile_vendor, dmp.model as profile_model, + dr.listener_ref, dr.protocol, dr.direction, dr.lifecycle_state, + dr.created_at, dr.updated_at, + count(ds.id)::bigint as session_count, + (count(ds.id) filter ( + where ds.lifecycle_state in ('connecting', 'online') + ))::bigint as active_session_count + from device_routes dr + join device_edges de on de.id = dr.edge_id + join device_model_profiles dmp on dmp.profile_ref = dr.model_profile_ref + left join device_sessions ds on ds.route_id = dr.id + where dr.project_id = $1 + group by dr.id, de.display_name, dmp.vendor, dmp.model + order by dr.display_name, dr.id`, + [projectId], + ); + const sessions = capabilities.has("telemetry.observe") + ? await client.query( + `select ds.id, ds.route_id, dr.display_name as route_name, + ds.device_id, di.display_name as device_name, + ds.protocol, ds.lifecycle_state, ds.connected_at, + ds.last_seen_at, ds.disconnected_at, ds.close_reason_code, + ds.frame_count, ds.byte_count + from device_sessions ds + join device_routes dr on dr.id = ds.route_id + left join device_instances di on di.id = ds.device_id + where ds.project_id = $1 + order by ds.last_seen_at desc, ds.id + limit 200`, + [projectId], + ) + : { rows: [] }; + const bindings = capabilities.has("binding.manage") + ? await client.query( + `select drb.id, drb.binding_key, drb.display_name, + drb.source_kind, drb.device_id, drb.collection_id, + coalesce(di.display_name, dc.name) as source_name, + drb.target_kind, drb.target_ref, drb.capabilities, + drb.lifecycle_state, drb.source_approved_at, + drb.created_at, drb.updated_at + from device_resource_bindings drb + left join device_instances di on di.id = drb.device_id + left join device_collections dc on dc.id = drb.collection_id + where drb.project_id = $1 + order by drb.updated_at desc, drb.id`, + [projectId], + ) + : { rows: [] }; + const configurationRevisions = capabilities.has("configuration.read") + ? await client.query( + `select dcr.id, dcr.device_id, di.display_name as device_name, + dcr.revision_number, dcr.model_profile_ref, + dcr.schema_artifact_ref, dcr.configuration_digest, + dcr.change_summary, dcr.created_at + from device_configuration_revisions dcr + join device_instances di on di.id = dcr.device_id + where dcr.project_id = $1 + order by dcr.created_at desc, dcr.id + limit 200`, + [projectId], + ) + : { rows: [] }; + const configurationStates = capabilities.has("configuration.read") + ? await client.query( + `select dcs.device_id, di.display_name as device_name, + dcs.desired_revision_id, dcs.applied_revision_id, + dcs.applied_at, dcs.updated_at + from device_configuration_state dcs + join device_instances di on di.id = dcs.device_id + where dcs.project_id = $1 + order by di.display_name, dcs.device_id`, + [projectId], + ) + : { rows: [] }; + const mayReadCommands = ["command.plan", "command.confirm", "command.dispatch"] + .some((capability) => capabilities.has(capability)); + const commands = mayReadCommands + ? await client.query( + `select dc.id, dc.device_id, di.display_name as device_name, + dc.command_key, dc.command_catalog_ref, dc.command_type, + dc.risk_class, dc.lifecycle_state, dc.planned_at, dc.expires_at, + dc.confirmed_at, dc.dispatched_at, dc.acknowledged_at, + dc.terminal_at, dc.terminal_reason_code, + dc.created_at, dc.updated_at + from device_commands dc + join device_instances di on di.id = dc.device_id + where dc.project_id = $1 + order by dc.updated_at desc, dc.id + limit 200`, + [projectId], + ) + : { rows: [] }; + const auditEvents = capabilities.has("audit.read") + ? await client.query( + `select dae.id, dae.event_type, dae.actor_ref, + dae.device_id, dae.discovery_id, dae.occurred_at + from device_audit_events dae + where dae.project_id = $1 + order by dae.occurred_at desc, dae.id + limit 300`, + [projectId], + ) + : { rows: [] }; + const projectGrants = capabilities.has("access.manage") + ? grantsResult + : { rows: [] }; + 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), + adapterPackages: adapterPackages.rows.map(adapterPackageView), + adapterVersions: adapterVersions.rows.map(adapterVersionView), + modelProfiles: modelProfiles.rows.map(modelProfileView), + edges: edges.rows.map(edgeView), + routes: routes.rows.map(routeView), + sessions: sessions.rows.map(sessionView), + bindings: bindings.rows.map(bindingView), + configurationRevisions: configurationRevisions.rows.map( + configurationRevisionView, + ), + configurationStates: configurationStates.rows.map(configurationStateView), + commands: commands.rows.map(commandView), + auditEvents: auditEvents.rows.map(auditEventView), + grants: projectGrants.rows.map(storedGrantView), + policies: { + commandTransport: "disabled", + commandPlanningApi: "disabled", + identifierProjection: "masked-only", + auditPayloadProjection: "metadata-only", + }, }; } @@ -202,7 +412,7 @@ function discoveryView(row) { protocol: row.protocol, lifecycleState: row.lifecycle_state, enrollmentIntentRef: row.enrollment_intent_id - ? `enrollment:${row.enrollment_intent_id}` + ? `enrollment-intent:${row.enrollment_intent_id}` : null, claimedDeviceRef: row.claimed_device_id ? `device:${row.claimed_device_id}` : null, firstObservedAt: toIso(row.first_observed_at), @@ -212,7 +422,7 @@ function discoveryView(row) { function enrollmentView(row) { return { - enrollmentIntentRef: `enrollment:${row.id}`, + enrollmentIntentRef: `enrollment-intent:${row.id}`, enrollmentKey: row.enrollment_key, displayName: row.display_name, modelProfileRef: row.model_profile_ref, @@ -231,6 +441,186 @@ function enrollmentView(row) { }; } +function adapterPackageView(row) { + return { + adapterPackageRef: `adapter-package:${row.id}`, + packageKey: row.package_key, + displayName: row.display_name, + publisherRef: row.publisher_ref, + lifecycleState: row.lifecycle_state, + createdAt: toIso(row.created_at), + updatedAt: toIso(row.updated_at), + }; +} + +function adapterVersionView(row) { + return { + adapterVersionRef: `adapter-version:${row.id}`, + adapterPackageRef: `adapter-package:${row.adapter_package_id}`, + version: row.version, + runtimePackageRef: row.runtime_package_ref, + contentDigest: row.content_digest, + contractVersion: row.contract_version, + capabilities: [...(row.capabilities ?? [])].sort(), + lifecycleState: row.lifecycle_state, + createdAt: toIso(row.created_at), + updatedAt: toIso(row.updated_at), + }; +} + +function modelProfileView(row) { + return { + modelProfileRef: row.profile_ref, + adapterVersionRef: row.adapter_version_id + ? `adapter-version:${row.adapter_version_id}` + : null, + schemaVersion: row.schema_version, + vendor: row.vendor, + model: row.model, + deviceType: row.device_type, + protocol: row.protocol, + schemaArtifactRef: row.schema_artifact_ref ?? null, + profileDigest: row.profile_digest ?? null, + capabilities: [...(row.capabilities ?? [])].sort(), + lifecycleState: row.lifecycle_state, + createdAt: toIso(row.created_at), + updatedAt: toIso(row.updated_at), + }; +} + +function edgeView(row) { + return { + edgeRef: `edge:${row.id}`, + edgeKey: row.edge_key, + displayName: row.display_name, + deploymentRef: row.deployment_ref ?? null, + lifecycleState: row.lifecycle_state, + createdAt: toIso(row.created_at), + updatedAt: toIso(row.updated_at), + }; +} + +function routeView(row) { + return { + routeRef: `route:${row.id}`, + routeKey: row.route_key, + displayName: row.display_name, + edgeRef: `edge:${row.edge_id}`, + edgeName: row.edge_name, + modelProfileRef: row.model_profile_ref, + profileName: `${row.profile_vendor} ${row.profile_model}`.trim(), + listenerRef: row.listener_ref, + protocol: row.protocol, + direction: row.direction, + lifecycleState: row.lifecycle_state, + sessionCount: numericCount(row.session_count), + activeSessionCount: numericCount(row.active_session_count), + createdAt: toIso(row.created_at), + updatedAt: toIso(row.updated_at), + }; +} + +function sessionView(row) { + return { + sessionRef: `session:${row.id}`, + routeRef: `route:${row.route_id}`, + routeName: row.route_name, + deviceRef: row.device_id ? `device:${row.device_id}` : null, + deviceName: row.device_name ?? null, + protocol: row.protocol, + lifecycleState: row.lifecycle_state, + connectedAt: toIso(row.connected_at), + lastSeenAt: toIso(row.last_seen_at), + disconnectedAt: toIso(row.disconnected_at), + closeReasonCode: row.close_reason_code ?? null, + frameCount: numericCount(row.frame_count), + byteCount: numericCount(row.byte_count), + }; +} + +function bindingView(row) { + return { + bindingRef: `binding:${row.id}`, + bindingKey: row.binding_key, + displayName: row.display_name, + source: { + kind: row.source_kind, + ref: row.source_kind === "device" + ? `device:${row.device_id}` + : `collection:${row.collection_id}`, + displayName: row.source_name, + }, + target: { kind: row.target_kind, ref: row.target_ref }, + capabilities: [...(row.capabilities ?? [])].sort(), + lifecycleState: row.lifecycle_state, + sourceApprovedAt: toIso(row.source_approved_at), + createdAt: toIso(row.created_at), + updatedAt: toIso(row.updated_at), + }; +} + +function configurationRevisionView(row) { + return { + configurationRevisionRef: `configuration-revision:${row.id}`, + deviceRef: `device:${row.device_id}`, + deviceName: row.device_name, + revisionNumber: numericCount(row.revision_number), + modelProfileRef: row.model_profile_ref, + schemaArtifactRef: row.schema_artifact_ref, + configurationDigest: row.configuration_digest, + changeSummary: row.change_summary ?? null, + createdAt: toIso(row.created_at), + }; +} + +function configurationStateView(row) { + return { + deviceRef: `device:${row.device_id}`, + deviceName: row.device_name, + desiredConfigurationRevisionRef: row.desired_revision_id + ? `configuration-revision:${row.desired_revision_id}` + : null, + appliedConfigurationRevisionRef: row.applied_revision_id + ? `configuration-revision:${row.applied_revision_id}` + : null, + appliedAt: toIso(row.applied_at), + updatedAt: toIso(row.updated_at), + }; +} + +function commandView(row) { + return { + commandRef: `command:${row.id}`, + deviceRef: `device:${row.device_id}`, + deviceName: row.device_name, + commandKey: row.command_key, + commandCatalogRef: row.command_catalog_ref, + commandType: row.command_type, + riskClass: row.risk_class, + lifecycleState: row.lifecycle_state, + plannedAt: toIso(row.planned_at), + expiresAt: toIso(row.expires_at), + confirmedAt: toIso(row.confirmed_at), + dispatchedAt: toIso(row.dispatched_at), + acknowledgedAt: toIso(row.acknowledged_at), + terminalAt: toIso(row.terminal_at), + terminalReasonCode: row.terminal_reason_code ?? null, + createdAt: toIso(row.created_at), + updatedAt: toIso(row.updated_at), + }; +} + +function auditEventView(row) { + return { + auditEventRef: `audit-event:${row.id}`, + eventType: row.event_type, + actorRef: row.actor_ref, + deviceRef: row.device_id ? `device:${row.device_id}` : null, + discoveryRef: row.discovery_id ? `discovery:${row.discovery_id}` : null, + occurredAt: toIso(row.occurred_at), + }; +} + function grantView(row) { return storedGrantView({ id: row.grant_id, 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 index 86549ae..0ba9b49 100644 --- 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 @@ -59,9 +59,26 @@ test("project workspace returns only masked identity projections", async () => { assert.equal(workspace.devices[0].identifier.masked, "***********0001"); assert.equal(workspace.discoveries[0].identifier.masked, "***********0001"); assert.equal(workspace.enrollments[0].expectedIdentifier.masked, "***********0001"); + assert.equal( + workspace.enrollments[0].enrollmentIntentRef, + "enrollment-intent:77777777-7777-4777-8777-777777777777", + ); + assert.equal(workspace.adapterPackages[0].packageKey, "generic-tracker"); + assert.equal(workspace.modelProfiles[0].modelProfileRef, "vendor.model.v1"); + assert.equal(workspace.routes[0].activeSessionCount, 1); + assert.equal(workspace.sessions[0].frameCount, 12); + assert.equal(workspace.bindings[0].lifecycleState, "pending_external_approval"); + assert.equal(workspace.configurationRevisions[0].revisionNumber, 1); + assert.equal(workspace.commands[0].lifecycleState, "acknowledged"); + assert.equal(workspace.auditEvents[0].eventType, "device.observed"); + assert.equal(workspace.grants[0].principalRef, "user:device-admin"); + assert.equal(workspace.policies.commandTransport, "disabled"); const serialized = JSON.stringify(workspace); assert.equal(serialized.includes("hmac-sha256"), false); assert.equal(serialized.includes("ndc-credref"), false); + assert.equal(serialized.includes("transport-message-secret"), false); + assert.equal(serialized.includes("external-approval-proof"), false); + assert.equal(serialized.includes("raw-audit-payload"), false); }); test("project read source never selects identifier digests or credential refs", async () => { @@ -71,8 +88,9 @@ test("project read source never selects identifier digests or credential refs", ); assert.doesNotMatch( source, - /\b(?:identifier_digest|expected_identifier_digest|credential_ref)\b/, + /\b(?:identifier_digest|expected_identifier_digest|credential_ref|parameters_digest|parameters_projection|transport_message_ref|external_approval_ref|external_approval_digest)\b/, ); + assert.doesNotMatch(source, /\b(?:dae\.payload|dcr\.configuration)\b/); }); function workspaceClient() { @@ -86,7 +104,174 @@ function workspaceClient() { grantReads += 1; return { rows: [storedGrantRow()] }; } - if (/from device_instances di/.test(sql)) { + if (/from device_adapter_packages ap/.test(sql)) { + return { rows: [{ + id: "99999999-9999-4999-8999-999999999999", + package_key: "generic-tracker", + display_name: "Generic tracker", + publisher_ref: "publisher:nodedc", + lifecycle_state: "active", + created_at: timestamp, + updated_at: timestamp, + }] }; + } + if (/from device_adapter_versions av/.test(sql)) { + return { rows: [{ + id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + adapter_package_id: "99999999-9999-4999-8999-999999999999", + version: "1.0.0", + runtime_package_ref: "artifact:generic-tracker:1.0.0", + content_digest: `sha256:${"a".repeat(64)}`, + contract_version: "device-adapter.v1", + capabilities: ["telemetry"], + lifecycle_state: "active", + created_at: timestamp, + updated_at: timestamp, + }] }; + } + if (/from device_model_profiles dmp/.test(sql)) { + return { rows: [{ + profile_ref: "vendor.model.v1", + adapter_version_id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + schema_version: "1.0.0", + vendor: "Vendor", + model: "Model", + device_type: "tracker", + protocol: "INTERNAL", + schema_artifact_ref: "schema:vendor.model.v1", + profile_digest: `sha256:${"b".repeat(64)}`, + capabilities: ["telemetry"], + lifecycle_state: "active", + created_at: timestamp, + updated_at: timestamp, + }] }; + } + if (/from device_edges de/.test(sql)) { + return { rows: [{ + id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", + edge_key: "edge-one", + display_name: "Edge one", + deployment_ref: "deployment:edge-one", + lifecycle_state: "active", + created_at: timestamp, + updated_at: timestamp, + }] }; + } + if (/from device_routes dr/.test(sql)) { + return { rows: [{ + id: "cccccccc-cccc-4ccc-8ccc-cccccccccccc", + route_key: "route-one", + display_name: "Route one", + edge_id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", + edge_name: "Edge one", + model_profile_ref: "vendor.model.v1", + profile_vendor: "Vendor", + profile_model: "Model", + listener_ref: "listener:generic", + protocol: "INTERNAL", + direction: "bidirectional", + lifecycle_state: "active", + session_count: "1", + active_session_count: "1", + created_at: timestamp, + updated_at: timestamp, + }] }; + } + if (/select ds\.id, ds\.route_id, dr\.display_name/.test(sql)) { + return { rows: [{ + id: "dddddddd-dddd-4ddd-8ddd-dddddddddddd", + route_id: "cccccccc-cccc-4ccc-8ccc-cccccccccccc", + route_name: "Route one", + device_id: "44444444-4444-4444-8444-444444444444", + device_name: "Pilot device", + protocol: "INTERNAL", + lifecycle_state: "online", + connected_at: timestamp, + last_seen_at: timestamp, + disconnected_at: null, + close_reason_code: null, + frame_count: "12", + byte_count: "1024", + }] }; + } + if (/from device_resource_bindings drb/.test(sql)) { + return { rows: [{ + id: "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee", + binding_key: "foundry-map", + display_name: "Foundry map", + source_kind: "collection", + device_id: null, + collection_id: "55555555-5555-4555-8555-555555555555", + source_name: "Pilot fleet", + target_kind: "foundry.application", + target_ref: "application:pilot-map", + capabilities: ["observe"], + lifecycle_state: "pending_external_approval", + source_approved_at: timestamp, + external_approval_ref: "external-approval-proof", + created_at: timestamp, + updated_at: timestamp, + }] }; + } + if (/from device_configuration_revisions dcr/.test(sql)) { + return { rows: [{ + id: "ffffffff-ffff-4fff-8fff-ffffffffffff", + device_id: "44444444-4444-4444-8444-444444444444", + device_name: "Pilot device", + revision_number: "1", + model_profile_ref: "vendor.model.v1", + schema_artifact_ref: "schema:vendor.model.v1", + configuration_digest: `sha256:${"c".repeat(64)}`, + configuration: { raw: "must-not-leak" }, + change_summary: "Pilot configuration", + created_at: timestamp, + }] }; + } + if (/from device_configuration_state dcs/.test(sql)) { + return { rows: [{ + device_id: "44444444-4444-4444-8444-444444444444", + device_name: "Pilot device", + desired_revision_id: "ffffffff-ffff-4fff-8fff-ffffffffffff", + applied_revision_id: null, + applied_at: null, + updated_at: timestamp, + }] }; + } + if (/from device_commands dc/.test(sql)) { + return { rows: [{ + id: "12121212-1212-4121-8121-121212121212", + device_id: "44444444-4444-4444-8444-444444444444", + device_name: "Pilot device", + command_key: "safe-ping", + command_catalog_ref: "catalog:safe-ping:v1", + command_type: "device.ping", + risk_class: "low", + lifecycle_state: "acknowledged", + planned_at: timestamp, + expires_at: "2026-08-11T00:00:00.000Z", + confirmed_at: timestamp, + dispatched_at: timestamp, + acknowledged_at: timestamp, + terminal_at: null, + terminal_reason_code: null, + transport_message_ref: "transport-message-secret", + parameters_projection: { raw: "must-not-leak" }, + created_at: timestamp, + updated_at: timestamp, + }] }; + } + if (/from device_audit_events dae/.test(sql)) { + return { rows: [{ + id: "13131313-1313-4131-8131-131313131313", + event_type: "device.observed", + actor_ref: "user:device-admin", + device_id: "44444444-4444-4444-8444-444444444444", + discovery_id: "66666666-6666-4666-8666-666666666666", + occurred_at: timestamp, + payload: { raw: "raw-audit-payload" }, + }] }; + } + if (/from device_instances di\n left join lateral/.test(sql)) { return { rows: [{ id: "44444444-4444-4444-8444-444444444444", device_key: "pilot-device",