feat(device-core): add authorized project read model
This commit is contained in:
@@ -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
|
opaque but requires a public IPv4 source and bounds its source table and bytes
|
||||||
per direction; it does not enable router/NAT exposure.
|
per direction; it does not enable router/NAT exposure.
|
||||||
|
|
||||||
The Foundry `Device Manager` is a canonical page template using a server-owned
|
The standalone Hub application `Device Core` / `Device Manager` is the human
|
||||||
`device-plane-control` binding. It is not a service in this directory.
|
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:
|
Run the foundation tests:
|
||||||
|
|
||||||
|
|||||||
@@ -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 (
|
if (
|
||||||
request.method === "POST"
|
request.method === "POST"
|
||||||
&& requestUrl.pathname === "/internal/v1/device-discoveries:observe"
|
&& requestUrl.pathname === "/internal/v1/device-discoveries:observe"
|
||||||
@@ -208,6 +263,13 @@ export function createControlCoreApp({
|
|||||||
return server;
|
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) {
|
function managementActorFromHeaders(headers) {
|
||||||
return normalizeManagementActor({
|
return normalizeManagementActor({
|
||||||
userRef: singleHeader(headers["x-nodedc-user-ref"]),
|
userRef: singleHeader(headers["x-nodedc-user-ref"]),
|
||||||
|
|||||||
@@ -507,7 +507,8 @@ async function transferDevice(client, actor, command) {
|
|||||||
|
|
||||||
export async function findProjectWithCapability(client, actor, projectId, capability) {
|
export async function findProjectWithCapability(client, actor, projectId, capability) {
|
||||||
const result = await client.query(
|
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.scope_kind, os.owner_ref, os.display_name as owner_display_name,
|
||||||
os.lifecycle_state as owner_lifecycle_state
|
os.lifecycle_state as owner_lifecycle_state
|
||||||
from device_projects p
|
from device_projects p
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ import {
|
|||||||
import {
|
import {
|
||||||
isControlResourceManagementCommand,
|
isControlResourceManagementCommand,
|
||||||
} from "./control-resource-management.mjs";
|
} from "./control-resource-management.mjs";
|
||||||
|
import {
|
||||||
|
getDeviceProjectWorkspace,
|
||||||
|
listAccessibleDeviceProjects,
|
||||||
|
} from "./project-query-repository.mjs";
|
||||||
import {
|
import {
|
||||||
applyInfrastructureManagementCommand,
|
applyInfrastructureManagementCommand,
|
||||||
authorizeInfrastructureManagementReplay,
|
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() {
|
async close() {
|
||||||
await this.pool.end();
|
await this.pool.end();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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 () => {
|
test("discovery ingest is closed by default", async () => {
|
||||||
const runtime = await startTestServer({
|
const runtime = await startTestServer({
|
||||||
repository: {
|
repository: {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user