From 318df8597d9aff82ff7033c14d47c4af5a8b08ec Mon Sep 17 00:00:00 2001 From: Lyon <88232613+pikasTech@users.noreply.github.com> Date: Fri, 5 Jun 2026 22:00:36 +0800 Subject: [PATCH] fix: show inherited admin tool authority (#937) Co-authored-by: Codex Agent --- internal/cloud/access-control.test.ts | 55 +++++++++++++++++++++++++++ internal/cloud/access-control.ts | 18 ++++++++- 2 files changed, 71 insertions(+), 2 deletions(-) diff --git a/internal/cloud/access-control.test.ts b/internal/cloud/access-control.test.ts index 64936115..369ecf4c 100644 --- a/internal/cloud/access-control.test.ts +++ b/internal/cloud/access-control.test.ts @@ -1952,6 +1952,61 @@ test("cloud api bootstrap password synchronizes existing admin", async () => { } }); +test("cloud api access matrix shows admin inherited tool authority", async () => { + const env = { + HWLAB_ACCESS_CONTROL_REQUIRED: "1", + HWLAB_BOOTSTRAP_ADMIN_ID: "usr_v02_admin", + HWLAB_BOOTSTRAP_ADMIN_USERNAME: "admin", + HWLAB_BOOTSTRAP_ADMIN_PASSWORD: "admin-pass", + HWLAB_OPENFGA_MODE: "enforce", + HWLAB_OPENFGA_API_URL: "http://openfga.test" + }; + const tuples = new Set(); + const accessController = createAccessController({ + env, + fetchImpl: async (url, init = {}) => { + const pathname = new URL(String(url)).pathname; + if (pathname === "/healthz") return new Response("ok", { status: 200 }); + if (pathname === "/stores") return new Response(JSON.stringify({ id: "store_admin_tools" }), { status: 200 }); + if (pathname.endsWith("/authorization-models")) return new Response(JSON.stringify({ authorization_model_id: "model_admin_tools" }), { status: 200 }); + if (pathname.endsWith("/write")) { + const body = JSON.parse(String(init.body ?? "{}")); + for (const tuple of body.writes?.tuple_keys ?? []) tuples.add(`${tuple.user}|${tuple.relation}|${tuple.object}`); + for (const tuple of body.deletes?.tuple_keys ?? []) tuples.delete(`${tuple.user}|${tuple.relation}|${tuple.object}`); + return new Response(JSON.stringify({ ok: true }), { status: 200 }); + } + if (pathname.endsWith("/check")) { + const body = JSON.parse(String(init.body ?? "{}")); + const tuple = body.tuple_key ?? {}; + return new Response(JSON.stringify({ allowed: tuples.has(`${tuple.user}|${tuple.relation}|${tuple.object}`) }), { status: 200 }); + } + return new Response(JSON.stringify({ error: "unexpected route" }), { status: 404 }); + }, + now: () => "2026-05-28T00:00:00.000Z" + }); + const server = createCloudApiServer({ env, accessController, now: () => "2026-05-28T00:00:00.000Z" }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + + try { + const { port } = server.address(); + const login = await postJson(port, "/auth/login", { username: "admin", password: "admin-pass" }); + assert.equal(login.status, 200); + + const users = await getJson(port, "/v1/admin/access/users", login.cookie); + assert.equal(users.status, 200); + const admin = users.body.users.find((item) => item.user.id === "usr_v02_admin"); + assert.equal(admin.user.role, "admin"); + assert.deepEqual(admin.tools, { hwpod: true, unidesk_ssh: true, trans_cmd: true, github_pr: true }); + + const matrix = await getJson(port, "/v1/admin/access/users/usr_v02_admin", login.cookie); + assert.equal(matrix.status, 200); + assert.deepEqual(matrix.body.tools, { hwpod: true, unidesk_ssh: true, trans_cmd: true, github_pr: true }); + assert.deepEqual(matrix.body.tuples.map((tuple) => `${tuple.relation}:${tuple.object}`), ["admin:system:hwlab"]); + } finally { + await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))); + } +}); + async function postJson(port, path, body, cookie = null, extraHeaders = {}) { const response = await fetch(`http://127.0.0.1:${port}${path}`, { method: "POST", diff --git a/internal/cloud/access-control.ts b/internal/cloud/access-control.ts index c8c6814b..92ff56d7 100644 --- a/internal/cloud/access-control.ts +++ b/internal/cloud/access-control.ts @@ -511,7 +511,7 @@ class AccessController { return { user: redactedUser(user), tupleCount: tuples.length, - tools: toolsFromTuples(tuples) + tools: await this.effectiveToolMatrix(user, tuples) }; } @@ -520,10 +520,24 @@ class AccessController { this.store.listAccessTuples?.({ userId: user.id }) ?? [], this.openFga.describe() ]); - const tools = Object.fromEntries(HWLAB_TOOL_IDS.map((toolId) => [toolId, tuples.some((tuple) => tuple.object === openFgaObject("tool", toolId) && tuple.relation === "can_use")])); + const tools = await this.effectiveToolMatrix(user, tuples); return { contractVersion: "admin-access-v1", actor: publicActor(actor), user: redactedUser(user), openfga, tools, tuples: tuples.map(publicAccessTuple) }; } + async effectiveToolMatrix(user, tuples = []) { + const explicitTools = toolsFromTuples(tuples); + const entries = {}; + for (const toolId of HWLAB_TOOL_IDS) { + if (explicitTools[toolId]) { + entries[toolId] = true; + continue; + } + const authorization = await this.openFga.check({ actor: user, relation: "can_use", object: openFgaObject("tool", toolId) }); + entries[toolId] = authorization.allowed === true; + } + return entries; + } + async syncUserAdminTuple(user, admin) { if (!user) return null; const object = openFgaObject("system", "hwlab");