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 = {}) {
|
async function postJson(port, path, body, cookie = null, extraHeaders = {}) {
|
||||||
const response = await fetch(`http://127.0.0.1:${port}${path}`, {
|
const response = await fetch(`http://127.0.0.1:${port}${path}`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|||||||
@@ -511,7 +511,7 @@ class AccessController {
|
|||||||
return {
|
return {
|
||||||
user: redactedUser(user),
|
user: redactedUser(user),
|
||||||
tupleCount: tuples.length,
|
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.store.listAccessTuples?.({ userId: user.id }) ?? [],
|
||||||
this.openFga.describe()
|
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) };
|
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) {
|
async syncUserAdminTuple(user, admin) {
|
||||||
if (!user) return null;
|
if (!user) return null;
|
||||||
const object = openFgaObject("system", "hwlab");
|
const object = openFgaObject("system", "hwlab");
|
||||||
|
|||||||
Reference in New Issue
Block a user