fix: show inherited admin tool authority (#937)
Co-authored-by: Codex Agent <codex@hwlab.local>
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user