mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 13:16:24 +00:00
* feat(authz): admin role-assignment API for configured custom roles (#4063 gap 2) The RBAC provider accepts arbitrary configured role names (guest, ...), but nothing could assign them to real users: User.system_role was pinned to Literal["admin", "user"] at the API layer while the DB column stayed a plain String(16) for exactly this future, and zhfeng's #3462 audit listed the missing assignment surface as gap 2 — custom roles were declarable in YAML but unreachable for authenticated web users. - User/UserResponse.system_role widens to a non-empty string; whether a role is *assignable* is the admin API's contract, not the model's. - RbacAuthorizationProvider exposes known_roles; the Gateway's new assignable_role_names() unions the built-ins with the provider's configured role names (custom providers: built-ins only). - New admin-only router /api/v1/admin/users: GET lists users with their role; PATCH assigns — role validated against assignable names (an unknown role would silently fall through to default_role on every decision), 404 for missing users, and the last remaining admin cannot be demoted (bootstrap lockout protection). Enforcement is unchanged: the assignment only changes which role the principal carries; the @require_permission route guards and runtime provider decisions remain the single enforcement points. - SQLite repository gains list_users (ordered oldest-first). Tests: router guard/422/404/409/no-op/happy-path, last-admin invariant (both directions), assignable names union, principal carries the configured role; the two old Literal-rejection contract tests updated to the new contract (custom role representable; empty string still rejected). * fix(auth): widen the frontend user schema for custom roles (review-lessons catch) The role-assignment API's review-lessons sweep found the one consumer class the implementation had not enumerated: the FRONTEND parses /me with a strict userSchema whose system_role was z.enum(["admin", "user"]) — a user assigned a provider-configured custom role (guest) would fail userSchema.parse in server.ts and break the auth flow before rendering anything. - types.ts: system_role widens to z.string().min(1), kept in lockstep with the backend model (min_length=1). All existing consumers compare === "admin", so any other value is simply non-admin — no behavior change for admin/user. - server.test.ts: contract tests — custom role parses verbatim; empty string still rejected. - config.example.yaml: the authorization comment now says custom roles become reachable via PATCH /api/v1/admin/users/{id} and that a declared-but-unassigned role grants nothing. * fix(auth): commit the admin_users router, drop the stale type-ignore, document the surface Review catch on #6382: the router module was never staged — app.py and the tests import it, so the pushed branch could not boot (ImportError in create_app). The local tree kept it as an untracked file, which is why every check passed before the push. - Actually commit app/gateway/routers/admin_users.py. - _row_to_user: drop the now-stale '# type: ignore[arg-type]' on system_role — the model field is a plain string now (keep the comma). - README: the authorization paragraph now mentions the admin role-assignment surface (GET/PATCH /api/v1/admin/users, admin-only, last-admin demotion protected, declared-but-unassigned grants nothing). - server.test.ts import-order lint fix from the frontend sweep. * fix(auth): field-scoped role writes close the demotion-undo race Review round 2 on #6382 — three findings, one design: - [P1] update_user copied system_role from the caller's account snapshot, so a password change that read the user as admin could restore a role concurrently revoked by the demotion route (stale-snapshot write-back). Account writes are now field-scoped against each other: update_user is the credential/profile writer (never touches the role, mirrors the row's current role back), update_system_role is the only role writer (single-column, never touches credentials or token_version). - The last-admin lockout check moved inside the role write's serialized transaction: count-then-write across two sessions let two concurrent demotions of the only two admins both pass. Same idiom as create_first_admin — BEGIN IMMEDIATE on SQLite, pg_advisory_xact_lock on a distinct key on Postgres, raise on unknown dialects. - UserNotFoundError from the racing-delete window maps to the route's 404 instead of a 500. Tests 11->12: real-repo regression covering the P1 interleaving (demotion lands -> stale credential snapshot writes -> role stays revoked, credentials applied), field isolation both directions, and the last-admin invariant raised from inside the write with the row intact. Mutation-verified: restoring the role write-back in update_user fails it. * style(auth): format the server auth tests * fix(tests): migrate the personal-MCP demote revocation to update_system_role The role-write field-scoping (66e2cec23) made update_user a credential/profile-only writer, so the demote branch of test_revocation_blocks_discovery... silently no-oped and CI failed on all four shards (assert 2 == 1 — the role never changed). - The demote now goes through update_system_role, the sole role writer. - The fixture gains a second admin: the revocation test demotes its sole synthetic admin, which the new last-admin lockout guard correctly blocks — a second admin keeps the scenario legal without weakening either invariant. --------- Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
209 lines
5.4 KiB
TypeScript
209 lines
5.4 KiB
TypeScript
import {
|
|
afterEach,
|
|
beforeEach,
|
|
describe,
|
|
expect,
|
|
test,
|
|
rs,
|
|
} from "@rstest/core";
|
|
|
|
import { AUTH_DISABLED_USER } from "@/core/auth/auth-disabled-user";
|
|
import { STATIC_WEBSITE_USER } from "@/core/auth/static-user";
|
|
import { userSchema } from "@/core/auth/types";
|
|
|
|
rs.mock("next/headers", () => ({
|
|
cookies: rs.fn(() => {
|
|
throw new Error("cookies should not be read in static website mode");
|
|
}),
|
|
}));
|
|
|
|
const ENV_KEYS = [
|
|
"DEER_FLOW_AUTH_DISABLED",
|
|
"DEER_FLOW_ENV",
|
|
"ENVIRONMENT",
|
|
"NEXT_PUBLIC_STATIC_WEBSITE_ONLY",
|
|
] as const;
|
|
|
|
type EnvSnapshot = Partial<
|
|
Record<(typeof ENV_KEYS)[number], string | undefined>
|
|
>;
|
|
|
|
function snapshotEnv(): EnvSnapshot {
|
|
const snapshot: EnvSnapshot = {};
|
|
for (const key of ENV_KEYS) {
|
|
snapshot[key] = process.env[key];
|
|
}
|
|
return snapshot;
|
|
}
|
|
|
|
function setEnv(key: (typeof ENV_KEYS)[number], value: string | undefined) {
|
|
const env = process.env as Record<string, string | undefined>;
|
|
if (value === undefined) {
|
|
delete env[key];
|
|
} else {
|
|
env[key] = value;
|
|
}
|
|
}
|
|
|
|
function restoreEnv(snapshot: EnvSnapshot) {
|
|
for (const key of ENV_KEYS) {
|
|
setEnv(key, snapshot[key]);
|
|
}
|
|
}
|
|
|
|
async function loadFreshServerAuth() {
|
|
rs.resetModules();
|
|
return await import("@/core/auth/server");
|
|
}
|
|
|
|
describe("getServerSideUser", () => {
|
|
let saved: EnvSnapshot;
|
|
|
|
beforeEach(() => {
|
|
saved = snapshotEnv();
|
|
setEnv("DEER_FLOW_AUTH_DISABLED", undefined);
|
|
setEnv("DEER_FLOW_ENV", undefined);
|
|
setEnv("ENVIRONMENT", undefined);
|
|
setEnv("NEXT_PUBLIC_STATIC_WEBSITE_ONLY", undefined);
|
|
});
|
|
|
|
afterEach(() => {
|
|
restoreEnv(saved);
|
|
rs.unstubAllGlobals();
|
|
});
|
|
|
|
test("bypasses gateway auth in static website mode", async () => {
|
|
setEnv("NEXT_PUBLIC_STATIC_WEBSITE_ONLY", "true");
|
|
const fetchSpy = rs.fn(() => {
|
|
throw new Error("fetch should not be called in static website mode");
|
|
});
|
|
rs.stubGlobal("fetch", fetchSpy);
|
|
|
|
const { getServerSideUser } = await loadFreshServerAuth();
|
|
|
|
await expect(getServerSideUser()).resolves.toEqual({
|
|
tag: "authenticated",
|
|
user: STATIC_WEBSITE_USER,
|
|
});
|
|
expect(fetchSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test("bypasses gateway auth in auth-disabled mode", async () => {
|
|
setEnv("DEER_FLOW_AUTH_DISABLED", "1");
|
|
const fetchSpy = rs.fn(() => {
|
|
throw new Error("fetch should not be called in auth-disabled mode");
|
|
});
|
|
rs.stubGlobal("fetch", fetchSpy);
|
|
|
|
const { getServerSideUser } = await loadFreshServerAuth();
|
|
|
|
await expect(getServerSideUser()).resolves.toEqual({
|
|
tag: "authenticated",
|
|
user: AUTH_DISABLED_USER,
|
|
});
|
|
expect(fetchSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test("does not enable auth-disabled mode in explicit production environments", async () => {
|
|
setEnv("DEER_FLOW_AUTH_DISABLED", "1");
|
|
setEnv("DEER_FLOW_ENV", "production");
|
|
|
|
const { isAuthDisabledMode } =
|
|
await import("@/core/auth/auth-disabled-user");
|
|
|
|
expect(isAuthDisabledMode()).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("getServerSideUser — gateway_unavailable contract (issue #3493)", () => {
|
|
let saved: EnvSnapshot;
|
|
|
|
beforeEach(() => {
|
|
saved = snapshotEnv();
|
|
setEnv("DEER_FLOW_AUTH_DISABLED", undefined);
|
|
setEnv("NEXT_PUBLIC_STATIC_WEBSITE_ONLY", undefined);
|
|
});
|
|
|
|
afterEach(() => {
|
|
restoreEnv(saved);
|
|
rs.unstubAllGlobals();
|
|
rs.doUnmock("next/headers");
|
|
});
|
|
|
|
test("returns gateway_unavailable when /auth/me fetch rejects (e.g. AbortError)", async () => {
|
|
rs.doMock("next/headers", () => ({
|
|
cookies: rs.fn(async () => ({
|
|
get: (name: string) =>
|
|
name === "access_token" ? { value: "stub-token" } : undefined,
|
|
})),
|
|
}));
|
|
const abortErr = new DOMException("Aborted", "AbortError");
|
|
rs.stubGlobal(
|
|
"fetch",
|
|
rs.fn(() => Promise.reject(abortErr)),
|
|
);
|
|
|
|
const { getServerSideUser } = await loadFreshServerAuth();
|
|
|
|
await expect(getServerSideUser()).resolves.toEqual({
|
|
tag: "gateway_unavailable",
|
|
});
|
|
});
|
|
|
|
test("returns gateway_unavailable when /auth/me responds with a 5xx", async () => {
|
|
rs.doMock("next/headers", () => ({
|
|
cookies: rs.fn(async () => ({
|
|
get: (name: string) =>
|
|
name === "access_token" ? { value: "stub-token" } : undefined,
|
|
})),
|
|
}));
|
|
rs.stubGlobal(
|
|
"fetch",
|
|
rs.fn(() =>
|
|
Promise.resolve(
|
|
new Response("upstream error", {
|
|
status: 503,
|
|
statusText: "Service Unavailable",
|
|
}),
|
|
),
|
|
),
|
|
);
|
|
|
|
const { getServerSideUser } = await loadFreshServerAuth();
|
|
|
|
await expect(getServerSideUser()).resolves.toEqual({
|
|
tag: "gateway_unavailable",
|
|
});
|
|
});
|
|
});
|
|
|
|
// ── system_role contract (RFC #4063 gap 2) ─────────────────────────
|
|
|
|
describe("userSchema system_role", () => {
|
|
test("accepts a provider-configured custom role verbatim", () => {
|
|
const parsed = userSchema.parse({
|
|
id: "1",
|
|
email: "g@example.com",
|
|
system_role: "guest",
|
|
needs_setup: false,
|
|
oauth_provider: null,
|
|
permissions: null,
|
|
});
|
|
|
|
expect(parsed.system_role).toBe("guest");
|
|
});
|
|
|
|
test("still rejects an empty role string", () => {
|
|
expect(() =>
|
|
userSchema.parse({
|
|
id: "1",
|
|
email: "g@example.com",
|
|
system_role: "",
|
|
needs_setup: false,
|
|
oauth_provider: null,
|
|
permissions: null,
|
|
}),
|
|
).toThrow();
|
|
});
|
|
});
|