fix: keep preset login resilient to OpenFGA outage (#2189)
Co-authored-by: root <root@lyon.remote>
This commit is contained in:
@@ -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<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
}
|
||||
|
||||
function close(server: any) {
|
||||
return new Promise<void>((resolve, reject) => server.close((error: Error | undefined) => (error ? reject(error) : resolve())));
|
||||
}
|
||||
@@ -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))];
|
||||
|
||||
Reference in New Issue
Block a user