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 d06f652..41f1473 100644 --- a/device-plane/services/device-control-core/src/lifecycle-repository.mjs +++ b/device-plane/services/device-control-core/src/lifecycle-repository.mjs @@ -505,7 +505,14 @@ async function transferDevice(client, actor, command) { }; } -export async function findProjectWithCapability(client, actor, projectId, capability) { +export async function findProjectWithCapability( + client, + actor, + projectId, + capability, + { lock = true } = {}, +) { + const projectLockClause = lock ? "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, @@ -514,7 +521,7 @@ export async function findProjectWithCapability(client, actor, projectId, capabi from device_projects p join device_owner_scopes os on os.id = p.owner_scope_id where p.id = $1 - for share of p, os`, + ${projectLockClause}`, [projectId], ); const project = result.rows[0]; @@ -531,7 +538,7 @@ export async function findProjectWithCapability(client, actor, projectId, capabi from device_project_grants where project_id = $1 order by created_at, id - for share`, + ${lock ? "for share" : ""}`, [projectId], ); assertProjectCapability( 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 f9f441b..bb68c4f 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 @@ -52,6 +52,7 @@ export async function getDeviceProjectWorkspace(client, actor, projectId) { actor, projectId, "project.read", + { lock: false }, ); const grantsResult = await client.query( `select id, principal_kind, principal_ref, project_role, 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 0ba9b49..7458eda 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 @@ -81,6 +81,19 @@ test("project workspace returns only masked identity projections", async () => { assert.equal(serialized.includes("raw-audit-payload"), false); }); +test("project workspace authorization remains compatible with read-only transactions", async () => { + const queries = []; + const client = workspaceClient({ queries }); + + await getDeviceProjectWorkspace(client, actor, projectId); + + assert.ok(queries.length > 0); + assert.equal( + queries.some((sql) => /\bfor\s+(?:no\s+key\s+)?(?:update|share)\b/i.test(sql)), + 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), @@ -93,10 +106,11 @@ test("project read source never selects identifier digests or credential refs", assert.doesNotMatch(source, /\b(?:dae\.payload|dcr\.configuration)\b/); }); -function workspaceClient() { +function workspaceClient({ queries = [] } = {}) { let grantReads = 0; return { async query(sql) { + queries.push(sql); if (/from device_projects p/.test(sql)) { return { rows: [projectGrantRow()] }; }