fix: show inherited admin tool authority (#937)

Co-authored-by: Codex Agent <codex@hwlab.local>
This commit is contained in:
Lyon
2026-06-05 22:00:36 +08:00
committed by GitHub
parent a7e70462b6
commit 318df8597d
2 changed files with 71 additions and 2 deletions
+55
View File
@@ -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",
+16 -2
View File
@@ -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");