diff --git a/internal/cloud/access-control-preset-users.test.ts b/internal/cloud/access-control-preset-users.test.ts new file mode 100644 index 00000000..bd84b677 --- /dev/null +++ b/internal/cloud/access-control-preset-users.test.ts @@ -0,0 +1,76 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { test } from "bun:test"; + +import { createCloudApiServer } from "./server.ts"; + +test("preset user login keeps YAML nav claims when OpenFGA tuple sync is unavailable", async () => { + const password = "preset-user-password"; + const server = createCloudApiServer({ + env: { + HWLAB_ACCESS_CONTROL_REQUIRED: "1", + HWLAB_ACCESS_CONTROL_USERS_JSON: JSON.stringify([ + { + id: "usr_v03_huiggao", + username: "huiggao@163.com", + displayName: "huiggao@163.com", + email: "huiggao@163.com", + role: "user", + status: "active", + navProfile: "code-mdtodo-only", + passwordHashEnv: "HWLAB_PRESET_USER_HUIGGAO_PASSWORD_HASH" + } + ]), + HWLAB_ACCESS_CONTROL_NAV_PROFILES_JSON: JSON.stringify([ + { id: "code-mdtodo-only", allowedIds: ["workbench.code", "project.mdtodo"] } + ]), + HWLAB_PRESET_USER_HUIGGAO_PASSWORD_HASH: stablePasswordHash(password) + }, + openFgaAuthorizer: throwingOpenFgaAuthorizer() + }); + await listen(server); + try { + const { port } = server.address() as { port: number }; + const login = await fetch(`http://127.0.0.1:${port}/auth/login`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ username: "huiggao@163.com", password }) + }); + assert.equal(login.status, 200); + const loginBody = await login.json(); + assert.deepEqual(loginBody.access.nav.allowedIds, ["workbench.code", "project.mdtodo"]); + + const cookie = login.headers.get("set-cookie")?.split(";")[0] ?? ""; + assert.match(cookie, /^hwlab_session=/u); + const session = await fetch(`http://127.0.0.1:${port}/auth/session`, { headers: { cookie } }); + assert.equal(session.status, 200); + const sessionBody = await session.json(); + assert.equal(sessionBody.actor.username, "huiggao@163.com"); + assert.deepEqual(sessionBody.access.nav.allowedIds, ["workbench.code", "project.mdtodo"]); + } finally { + await close(server); + } +}); + +function stablePasswordHash(password: string) { + const salt = "preset-user-test-salt"; + return `sha256:${salt}:${createHash("sha256").update(`${salt}:${password}`).digest("hex")}`; +} + +function throwingOpenFgaAuthorizer() { + const error = Object.assign(new Error("openfga unavailable"), { code: "openfga_unavailable" }); + return { + async describe() { return { ok: false, code: "openfga_unavailable" }; }, + async check() { throw error; }, + async writeTuple() { throw error; }, + async deleteTuple() { throw error; } + }; +} + +function listen(server: any) { + return new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); +} + +function close(server: any) { + return new Promise((resolve, reject) => server.close((error: Error | undefined) => (error ? reject(error) : resolve()))); +} diff --git a/internal/cloud/access-control.ts b/internal/cloud/access-control.ts index d42e3cb2..39465d7b 100644 --- a/internal/cloud/access-control.ts +++ b/internal/cloud/access-control.ts @@ -569,7 +569,7 @@ class AccessController { async syncExistingAdminTuples() { const users = await this.store.listUsers?.() ?? []; - for (const user of users.filter((item) => item.role === "admin")) await this.syncUserAdminTuple(user, user); + for (const user of users.filter((item) => item.role === "admin")) await this.syncUserAdminTupleBestEffort(user, user); } async syncPresetUsers() { @@ -589,13 +589,36 @@ class AccessController { email: spec.email || null, now: this.now() }); - await this.syncUserAdminTuple(user, user); + await this.syncUserAdminTupleBestEffort(user, user); if (user?.role === "admin") { - for (const toolId of HWLAB_TOOL_IDS) await this.grantAccessTuple({ userId: user.id, relation: "can_use", object: openFgaObject("tool", toolId), admin: user }); + for (const toolId of HWLAB_TOOL_IDS) { + await this.grantAccessTupleBestEffort( + { userId: user.id, relation: "can_use", object: openFgaObject("tool", toolId), admin: user }, + "preset_admin_tool_tuple_sync_failed" + ); + } } } } + async syncUserAdminTupleBestEffort(user, admin) { + try { + return await this.syncUserAdminTuple(user, admin); + } catch (error) { + warnAccessControlBootstrap("admin_tuple_sync_failed", error); + return { ok: false, degradedReason: "openfga_tuple_sync_failed" }; + } + } + + async grantAccessTupleBestEffort(input, code = "tuple_sync_failed") { + try { + return await this.grantAccessTuple(input); + } catch (error) { + warnAccessControlBootstrap(code, error); + return { ok: false, degradedReason: "openfga_tuple_sync_failed" }; + } + } + async grantAccessTuple({ userId, relation, object, admin }) { const result = await this.openFga.writeTuple({ userId, relation, object }); if (result.ok !== false) await this.store.upsertAccessTuple?.({ userId, relation, object, createdByAdminId: admin?.id ?? userId, now: this.now() }); @@ -1795,6 +1818,17 @@ function navProfileByIdFromEnv(env = process.env, profileId) { if (!found) return null; return { id, allowedIds: uniqueTextList(found.allowedIds) }; } +function warnAccessControlBootstrap(code, error) { + const message = error instanceof Error ? error.message : String(error ?? ""); + console.warn(JSON.stringify({ + event: "hwlab.access-control.bootstrap.warning", + code, + errorName: error?.name ?? null, + errorCode: error?.code ?? null, + message: message.slice(0, 300), + valuesRedacted: true + })); +} function uniqueTextList(value) { if (!Array.isArray(value)) return []; return [...new Set(value.map((item) => textOr(item, "")).filter(Boolean))];