fix: enforce role authority for user management (#616)

Centralize role-authority enforcement across user management, role management, configuration import, SCIM, GDPR, and MFA mutations. Add regression coverage for delegated custom roles and protect higher-privilege accounts from reset, deletion, or takeover.
This commit is contained in:
SnapOtter
2026-07-22 01:23:15 +08:00
committed by GitHub
parent 129e42b95c
commit 1f8a42e548
36 changed files with 4266 additions and 1584 deletions
@@ -3,6 +3,26 @@ import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { db, schema } from "../../../apps/api/src/db/index.js";
import { buildTestApp, loginAsAdmin, type TestApp } from "../test-server.js";
const ADMIN_PERMISSIONS = [
"tools:use",
"files:own",
"files:all",
"apikeys:own",
"apikeys:all",
"pipelines:own",
"pipelines:all",
"settings:read",
"settings:write",
"users:manage",
"teams:manage",
"features:manage",
"system:health",
"audit:read",
"compliance:manage",
"webhooks:manage",
"security:manage",
];
let testApp: TestApp;
let adminToken: string;
@@ -201,7 +221,188 @@ describe("config import with enterprise license", () => {
expect(res.statusCode).toBe(403);
});
it("dry-run mode returns changes without applying", async () => {
it("denies config import to a custom role even when it has every admin permission", async () => {
const suffix = Date.now().toString(36);
const roleName = `config-health-${suffix}`;
const username = `config-health-user-${suffix}`;
const settingKey = `configImportAuthzSentinel${suffix}`;
let roleId: string | undefined;
let userId: string | undefined;
try {
const roleRes = await licensedApp.app.inject({
method: "POST",
url: "/api/v1/roles",
headers: { authorization: `Bearer ${licensedToken}` },
payload: { name: roleName, permissions: ADMIN_PERMISSIONS },
});
if (roleRes.statusCode !== 201) {
throw new Error(`Failed to create config test role: ${roleRes.body}`);
}
roleId = JSON.parse(roleRes.body).id as string;
const registerRes = await licensedApp.app.inject({
method: "POST",
url: "/api/auth/register",
headers: { authorization: `Bearer ${licensedToken}` },
payload: { username, password: "TestPass1", role: roleName },
});
if (registerRes.statusCode !== 201) {
throw new Error(`Failed to create config test user: ${registerRes.body}`);
}
userId = JSON.parse(registerRes.body).id as string;
await db
.update(schema.users)
.set({ mustChangePassword: false })
.where(eq(schema.users.id, userId));
const loginRes = await licensedApp.app.inject({
method: "POST",
url: "/api/auth/login",
payload: { username, password: "TestPass1" },
});
const actorToken = JSON.parse(loginRes.body).token as string;
const res = await licensedApp.app.inject({
method: "POST",
url: "/api/v1/enterprise/config/import",
headers: { authorization: `Bearer ${actorToken}` },
payload: {
dryRun: false,
config: {
configSchemaVersion: 1,
settings: { [settingKey]: "must-not-be-imported" },
},
},
});
const body = JSON.parse(res.body);
const [importedSetting] = await db
.select()
.from(schema.settings)
.where(eq(schema.settings.key, settingKey));
expect.soft(res.statusCode).toBe(403);
expect.soft(body.code).toBe("ESCALATION_DENIED");
expect(importedSetting).toBeUndefined();
} finally {
await db.delete(schema.settings).where(eq(schema.settings.key, settingKey));
if (userId) await db.delete(schema.users).where(eq(schema.users.id, userId));
if (roleId) await db.delete(schema.roles).where(eq(schema.roles.id, roleId));
}
});
it("denies config import through a permission-scoped built-in admin API key", async () => {
const suffix = Date.now().toString(36);
const settingKey = `configImportScopedKeySentinel${suffix}`;
const keyRes = await licensedApp.app.inject({
method: "POST",
url: "/api/v1/api-keys",
headers: { authorization: `Bearer ${licensedToken}` },
payload: {
name: `config-import-scoped-${suffix}`,
permissions: ["system:health"],
},
});
expect(keyRes.statusCode, keyRes.body).toBe(201);
const scopedKey = JSON.parse(keyRes.body).key as string;
try {
const res = await licensedApp.app.inject({
method: "POST",
url: "/api/v1/enterprise/config/import",
headers: { authorization: `Bearer ${scopedKey}` },
payload: {
dryRun: false,
config: {
configSchemaVersion: 1,
settings: { [settingKey]: "must-not-be-imported" },
},
},
});
const [importedSetting] = await db
.select()
.from(schema.settings)
.where(eq(schema.settings.key, settingKey));
expect.soft(res.statusCode).toBe(403);
expect.soft(JSON.parse(res.body).code).toBe("ESCALATION_DENIED");
expect(importedSetting).toBeUndefined();
} finally {
await db.delete(schema.settings).where(eq(schema.settings.key, settingKey));
}
});
it.each([
{
caseName: "unknown permissions",
roleName: "config-invalid-permission",
role: {
name: "config-invalid-permission",
permissions: ["users:impersonate"],
},
},
{
caseName: "unknown tool permission modes",
roleName: "config-invalid-tool-mode",
role: {
name: "config-invalid-tool-mode",
permissions: ["tools:use"],
toolPermissions: { mode: "everything", allowed: ["compress-image"] },
},
},
{
caseName: "invalid role names",
roleName: "INVALID ROLE!",
role: {
name: "INVALID ROLE!",
permissions: ["settings:read"],
},
},
])("rejects $caseName before mutating any configuration", async ({ roleName, role }) => {
const suffix = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
const settingKey = `configInvalidRoleSentinel${suffix}`;
try {
const res = await licensedApp.app.inject({
method: "POST",
url: "/api/v1/enterprise/config/import",
headers: { authorization: `Bearer ${licensedToken}` },
payload: {
dryRun: false,
config: {
configSchemaVersion: 1,
settings: { [settingKey]: "must-not-be-imported" },
roles: [role],
},
},
});
const [importedSetting] = await db
.select()
.from(schema.settings)
.where(eq(schema.settings.key, settingKey));
const [importedRole] = await db
.select()
.from(schema.roles)
.where(eq(schema.roles.name, roleName));
expect.soft(res.statusCode, res.body).toBe(400);
expect.soft(JSON.parse(res.body).error).toBe("Invalid import payload");
expect.soft(importedSetting).toBeUndefined();
expect(importedRole).toBeUndefined();
} finally {
await db.delete(schema.settings).where(eq(schema.settings.key, settingKey));
await db.delete(schema.roles).where(eq(schema.roles.name, roleName));
}
});
it("dry-run reports setting, role, and team changes without mutating them", async () => {
const suffix = Date.now().toString(36);
const settingKey = `configDryRunSetting${suffix}`;
const roleName = `config-dry-run-role-${suffix}`;
const teamName = `config-dry-run-team-${suffix}`;
const res = await licensedApp.app.inject({
method: "POST",
url: "/api/v1/enterprise/config/import",
@@ -210,7 +411,9 @@ describe("config import with enterprise license", () => {
dryRun: true,
config: {
configSchemaVersion: 1,
settings: { testSetting: "hello" },
settings: { [settingKey]: "hello" },
roles: [{ name: roleName, permissions: ["settings:read"] }],
teams: [{ name: teamName }],
},
},
});
@@ -218,11 +421,23 @@ describe("config import with enterprise license", () => {
const body = JSON.parse(res.body);
expect(body.dryRun).toBe(true);
expect(body.changes).toBeDefined();
expect(body.changes.settings).toBeGreaterThanOrEqual(1);
expect(body.changes).toEqual({ settings: 1, roles: 1, teams: 1 });
expect(body.details).toBeDefined();
expect(body.details.settings).toEqual(
expect.arrayContaining([expect.objectContaining({ key: "testSetting" })]),
expect.arrayContaining([expect.objectContaining({ key: settingKey, action: "create" })]),
);
expect(body.details.roles).toContainEqual({ name: roleName, action: "create" });
expect(body.details.teams).toContainEqual({ name: teamName, action: "create" });
const [setting] = await db
.select()
.from(schema.settings)
.where(eq(schema.settings.key, settingKey));
const [role] = await db.select().from(schema.roles).where(eq(schema.roles.name, roleName));
const [team] = await db.select().from(schema.teams).where(eq(schema.teams.name, teamName));
expect.soft(setting).toBeUndefined();
expect.soft(role).toBeUndefined();
expect(team).toBeUndefined();
});
it("rejects future schema versions", async () => {
@@ -240,7 +455,7 @@ describe("config import with enterprise license", () => {
expect(body.error).toContain("Unsupported config schema version");
});
it("empty config import succeeds with no changes", async () => {
it("allows the full built-in admin to import an empty config", async () => {
const res = await licensedApp.app.inject({
method: "POST",
url: "/api/v1/enterprise/config/import",
@@ -5,6 +5,35 @@ import { buildTestApp, loginAsAdmin, type TestApp } from "../test-server.js";
let testApp: TestApp;
let adminToken: string;
let userSequence = 0;
const BASE_USER_PERMISSIONS = [
"tools:use",
"files:own",
"apikeys:own",
"pipelines:own",
"settings:read",
];
const ADMIN_PERMISSIONS = [
"tools:use",
"files:own",
"files:all",
"apikeys:own",
"apikeys:all",
"pipelines:own",
"pipelines:all",
"settings:read",
"settings:write",
"users:manage",
"teams:manage",
"features:manage",
"system:health",
"audit:read",
"compliance:manage",
"webhooks:manage",
"security:manage",
];
beforeAll(async () => {
testApp = await buildTestApp();
@@ -44,12 +73,17 @@ async function createUserAndLogin(
password: string,
role: string,
): Promise<string> {
await testApp.app.inject({
const registerRes = await testApp.app.inject({
method: "POST",
url: "/api/auth/register",
headers: { authorization: `Bearer ${adminToken}` },
payload: { username, password, role },
});
if (registerRes.statusCode !== 201) {
throw new Error(
`createUserAndLogin registration failed (${registerRes.statusCode}): ${registerRes.body}`,
);
}
await db
.update(schema.users)
.set({ mustChangePassword: false })
@@ -63,6 +97,47 @@ async function createUserAndLogin(
return JSON.parse(loginRes.body).token as string;
}
async function createTargetUser(
prefix: string,
role: string,
options: { mfaEnabled?: boolean } = {},
): Promise<{ id: string; password: string; username: string }> {
userSequence += 1;
const username = `${prefix}-${Date.now()}-${userSequence}`;
const password = "TargetPass1";
const registerRes = await testApp.app.inject({
method: "POST",
url: "/api/auth/register",
headers: { authorization: `Bearer ${adminToken}` },
payload: { username, password, role },
});
if (registerRes.statusCode !== 201) {
throw new Error(`createTargetUser failed (${registerRes.statusCode}): ${registerRes.body}`);
}
const id = JSON.parse(registerRes.body).id as string;
await db
.update(schema.users)
.set({
mustChangePassword: false,
...(options.mfaEnabled
? {
totpEnabled: true,
totpSecret: "target-authority-test-secret",
recoveryCodesHash: "target-authority-test-recovery-codes",
}
: {}),
})
.where(eq(schema.users.id, id));
return { id, password, username };
}
async function getUserById(id: string) {
const [user] = await db.select().from(schema.users).where(eq(schema.users.id, id));
return user;
}
// ---------------------------------------------------------------------------
// Name validation (5 tests)
// ---------------------------------------------------------------------------
@@ -278,3 +353,325 @@ describe("functional permissions", () => {
expect(auditRes.statusCode).toBe(403);
});
});
// ---------------------------------------------------------------------------
// Target role authority
// ---------------------------------------------------------------------------
describe("target role authority", () => {
let managerRole: string;
let subordinateRole: string;
let alternateSubordinateRole: string;
let managerToken: string;
let fullPermissionManagerToken: string;
beforeAll(async () => {
const suffix = Date.now();
managerRole = `mgr-${suffix}`;
subordinateRole = `sub-${suffix}`;
alternateSubordinateRole = `alt-${suffix}`;
await createRole(managerRole, [...BASE_USER_PERMISSIONS, "users:manage"]);
await createRole(subordinateRole, BASE_USER_PERMISSIONS);
await createRole(alternateSubordinateRole, BASE_USER_PERMISSIONS);
managerToken = await createUserAndLogin(`manager-${suffix}`, "ManagerPass1", managerRole);
const fullPermissionManagerRole = `full-mgr-${suffix}`;
await createRole(fullPermissionManagerRole, ADMIN_PERMISSIONS);
fullPermissionManagerToken = await createUserAndLogin(
`full-manager-${suffix}`,
"FullManagerPass1",
fullPermissionManagerRole,
);
});
it("denies a custom role with all 17 admin permissions from managing built-in admins", async () => {
expect(ADMIN_PERMISSIONS).toHaveLength(17);
const demotionTarget = await createTargetUser("full-deny-demote-admin", "admin");
const passwordTarget = await createTargetUser("full-deny-reset-admin", "admin");
const deleteTarget = await createTargetUser("full-deny-delete-admin", "admin");
const mfaTarget = await createTargetUser("full-deny-mfa-admin", "admin", {
mfaEnabled: true,
});
const passwordBefore = await getUserById(passwordTarget.id);
const demotionResponse = await testApp.app.inject({
method: "PUT",
url: `/api/auth/users/${demotionTarget.id}`,
headers: { authorization: `Bearer ${fullPermissionManagerToken}` },
payload: { role: subordinateRole },
});
const passwordResponse = await testApp.app.inject({
method: "POST",
url: `/api/auth/users/${passwordTarget.id}/reset-password`,
headers: { authorization: `Bearer ${fullPermissionManagerToken}` },
payload: { newPassword: "UnauthorizedReset1" },
});
const deleteResponse = await testApp.app.inject({
method: "DELETE",
url: `/api/auth/users/${deleteTarget.id}`,
headers: { authorization: `Bearer ${fullPermissionManagerToken}` },
});
const mfaResponse = await testApp.app.inject({
method: "POST",
url: `/api/auth/users/${mfaTarget.id}/mfa/reset`,
headers: { authorization: `Bearer ${fullPermissionManagerToken}` },
});
for (const response of [demotionResponse, passwordResponse, deleteResponse, mfaResponse]) {
expect.soft(response.statusCode).toBe(403);
expect.soft(JSON.parse(response.body).code).toBe("ESCALATION_DENIED");
}
const [demotionAfter, passwordAfter, deleteAfter, mfaAfter] = await Promise.all([
getUserById(demotionTarget.id),
getUserById(passwordTarget.id),
getUserById(deleteTarget.id),
getUserById(mfaTarget.id),
]);
expect.soft(demotionAfter?.role).toBe("admin");
expect.soft(passwordAfter?.passwordHash).toBe(passwordBefore?.passwordHash);
expect.soft(passwordAfter?.mustChangePassword).toBe(false);
expect.soft(deleteAfter?.role).toBe("admin");
expect(mfaAfter).toMatchObject({
role: "admin",
totpEnabled: true,
totpSecret: "target-authority-test-secret",
recoveryCodesHash: "target-authority-test-recovery-codes",
});
});
it("denies a custom manager demoting an admin and preserves the admin role", async () => {
const target = await createTargetUser("deny-demote-admin", "admin");
const response = await testApp.app.inject({
method: "PUT",
url: `/api/auth/users/${target.id}`,
headers: { authorization: `Bearer ${managerToken}` },
payload: { role: subordinateRole },
});
const targetAfter = await getUserById(target.id);
expect.soft(response.statusCode).toBe(403);
expect.soft(JSON.parse(response.body).code).toBe("ESCALATION_DENIED");
expect(targetAfter?.role).toBe("admin");
});
it("denies a custom manager resetting an admin password and preserves credentials", async () => {
const target = await createTargetUser("deny-reset-admin", "admin");
const targetBefore = await getUserById(target.id);
const response = await testApp.app.inject({
method: "POST",
url: `/api/auth/users/${target.id}/reset-password`,
headers: { authorization: `Bearer ${managerToken}` },
payload: { newPassword: "ReplacementPass1" },
});
const targetAfter = await getUserById(target.id);
expect.soft(response.statusCode).toBe(403);
expect.soft(JSON.parse(response.body).code).toBe("ESCALATION_DENIED");
expect(targetAfter?.passwordHash).toBe(targetBefore?.passwordHash);
expect(targetAfter?.mustChangePassword).toBe(false);
});
it("denies a custom manager deleting an admin and preserves the account", async () => {
const target = await createTargetUser("deny-delete-admin", "admin");
const response = await testApp.app.inject({
method: "DELETE",
url: `/api/auth/users/${target.id}`,
headers: { authorization: `Bearer ${managerToken}` },
});
const targetAfter = await getUserById(target.id);
expect.soft(response.statusCode).toBe(403);
expect.soft(JSON.parse(response.body).code).toBe("ESCALATION_DENIED");
expect(targetAfter).toBeDefined();
expect(targetAfter?.role).toBe("admin");
});
it("denies a custom manager resetting admin MFA and preserves MFA state", async () => {
const target = await createTargetUser("deny-mfa-admin", "admin", { mfaEnabled: true });
const response = await testApp.app.inject({
method: "POST",
url: `/api/auth/users/${target.id}/mfa/reset`,
headers: { authorization: `Bearer ${managerToken}` },
});
const targetAfter = await getUserById(target.id);
expect.soft(response.statusCode).toBe(403);
expect.soft(JSON.parse(response.body).code).toBe("ESCALATION_DENIED");
expect(targetAfter).toMatchObject({
role: "admin",
totpEnabled: true,
totpSecret: "target-authority-test-secret",
recoveryCodesHash: "target-authority-test-recovery-codes",
});
});
it("protects a disabled admin from a custom manager while allowing built-in admin recovery", async () => {
const target = await createTargetUser("disabled-admin-recovery", "admin");
await db
.update(schema.users)
.set({ role: "disabled:admin" })
.where(eq(schema.users.id, target.id));
const targetBefore = await getUserById(target.id);
const deniedResponse = await testApp.app.inject({
method: "POST",
url: `/api/auth/users/${target.id}/reset-password`,
headers: { authorization: `Bearer ${managerToken}` },
payload: { newPassword: "UnauthorizedReset1" },
});
const targetAfterDenial = await getUserById(target.id);
expect.soft(deniedResponse.statusCode).toBe(403);
expect.soft(JSON.parse(deniedResponse.body).code).toBe("ESCALATION_DENIED");
expect.soft(targetAfterDenial?.role).toBe("disabled:admin");
expect.soft(targetAfterDenial?.passwordHash).toBe(targetBefore?.passwordHash);
expect.soft(targetAfterDenial?.mustChangePassword).toBe(false);
const recoveryResponse = await testApp.app.inject({
method: "POST",
url: `/api/auth/users/${target.id}/reset-password`,
headers: { authorization: `Bearer ${adminToken}` },
payload: { newPassword: "AdminRecovery1" },
});
const targetAfterRecovery = await getUserById(target.id);
expect(recoveryResponse.statusCode).toBe(200);
expect(targetAfterRecovery?.role).toBe("disabled:admin");
expect(targetAfterRecovery?.passwordHash).not.toBe(targetBefore?.passwordHash);
expect(targetAfterRecovery?.mustChangePassword).toBe(true);
});
it("allows a custom manager to change a subordinate custom-role user", async () => {
const target = await createTargetUser("allow-demote-sub", subordinateRole);
const response = await testApp.app.inject({
method: "PUT",
url: `/api/auth/users/${target.id}`,
headers: { authorization: `Bearer ${managerToken}` },
payload: { role: alternateSubordinateRole },
});
const targetAfter = await getUserById(target.id);
expect(response.statusCode).toBe(200);
expect(targetAfter?.role).toBe(alternateSubordinateRole);
});
it("allows a custom manager to reset a subordinate custom-role user password", async () => {
const target = await createTargetUser("allow-reset-sub", subordinateRole);
const targetBefore = await getUserById(target.id);
const response = await testApp.app.inject({
method: "POST",
url: `/api/auth/users/${target.id}/reset-password`,
headers: { authorization: `Bearer ${managerToken}` },
payload: { newPassword: "ReplacementPass1" },
});
const targetAfter = await getUserById(target.id);
expect(response.statusCode).toBe(200);
expect(targetAfter?.passwordHash).not.toBe(targetBefore?.passwordHash);
expect(targetAfter?.mustChangePassword).toBe(true);
});
it("allows a custom manager to delete a subordinate custom-role user", async () => {
const target = await createTargetUser("allow-delete-sub", subordinateRole);
const response = await testApp.app.inject({
method: "DELETE",
url: `/api/auth/users/${target.id}`,
headers: { authorization: `Bearer ${managerToken}` },
});
const targetAfter = await getUserById(target.id);
expect(response.statusCode).toBe(200);
expect(targetAfter).toBeUndefined();
});
it("allows a custom manager to reset subordinate custom-role user MFA", async () => {
const target = await createTargetUser("allow-mfa-sub", subordinateRole, { mfaEnabled: true });
const response = await testApp.app.inject({
method: "POST",
url: `/api/auth/users/${target.id}/mfa/reset`,
headers: { authorization: `Bearer ${managerToken}` },
});
const targetAfter = await getUserById(target.id);
expect(response.statusCode).toBe(200);
expect(targetAfter).toMatchObject({
totpEnabled: false,
totpSecret: null,
recoveryCodesHash: null,
});
});
it("allows an admin to demote an equal admin", async () => {
const target = await createTargetUser("allow-demote-peer", "admin");
const response = await testApp.app.inject({
method: "PUT",
url: `/api/auth/users/${target.id}`,
headers: { authorization: `Bearer ${adminToken}` },
payload: { role: subordinateRole },
});
const targetAfter = await getUserById(target.id);
expect(response.statusCode).toBe(200);
expect(targetAfter?.role).toBe(subordinateRole);
});
it("allows an admin to reset an equal admin password", async () => {
const target = await createTargetUser("allow-reset-peer", "admin");
const targetBefore = await getUserById(target.id);
const response = await testApp.app.inject({
method: "POST",
url: `/api/auth/users/${target.id}/reset-password`,
headers: { authorization: `Bearer ${adminToken}` },
payload: { newPassword: "ReplacementPass1" },
});
const targetAfter = await getUserById(target.id);
expect(response.statusCode).toBe(200);
expect(targetAfter?.passwordHash).not.toBe(targetBefore?.passwordHash);
expect(targetAfter?.mustChangePassword).toBe(true);
});
it("allows an admin to delete an equal admin", async () => {
const target = await createTargetUser("allow-delete-peer", "admin");
const response = await testApp.app.inject({
method: "DELETE",
url: `/api/auth/users/${target.id}`,
headers: { authorization: `Bearer ${adminToken}` },
});
const targetAfter = await getUserById(target.id);
expect(response.statusCode).toBe(200);
expect(targetAfter).toBeUndefined();
});
it("allows an admin to reset equal admin MFA", async () => {
const target = await createTargetUser("allow-mfa-peer", "admin", { mfaEnabled: true });
const response = await testApp.app.inject({
method: "POST",
url: `/api/auth/users/${target.id}/mfa/reset`,
headers: { authorization: `Bearer ${adminToken}` },
});
const targetAfter = await getUserById(target.id);
expect(response.statusCode).toBe(200);
expect(targetAfter).toMatchObject({
totpEnabled: false,
totpSecret: null,
recoveryCodesHash: null,
});
});
});
@@ -1,5 +1,6 @@
import { randomUUID } from "node:crypto";
import { eq } from "drizzle-orm";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { db, schema } from "../../../apps/api/src/db/index.js";
import { buildTestApp, loginAsAdmin, type TestApp } from "../test-server.js";
@@ -262,3 +263,198 @@ describe("GDPR edge cases", () => {
expect(res.statusCode).toBe(404);
});
});
describe("GDPR purge role hierarchy", () => {
let licensedApp: TestApp;
let licensedAdminToken: string;
let complianceManagerToken: string;
let complianceRoleId: string;
let complianceManagerId: string;
let targetSequence = 0;
beforeAll(async () => {
vi.resetModules();
const { mockEnterpriseFeatures } = await import("../../helpers/enterprise-mock.js");
mockEnterpriseFeatures(["gdpr_lifecycle"]);
const { buildTestApp, loginAsAdmin } = await import("../test-server.js");
licensedApp = await buildTestApp();
licensedAdminToken = await loginAsAdmin(licensedApp.app);
const suffix = Date.now().toString(36);
const roleName = `compliance-${suffix}`;
const username = `compliance-manager-${suffix}`;
const roleRes = await licensedApp.app.inject({
method: "POST",
url: "/api/v1/roles",
headers: { authorization: `Bearer ${licensedAdminToken}` },
payload: { name: roleName, permissions: ["compliance:manage"] },
});
if (roleRes.statusCode !== 201) {
throw new Error(`Failed to create compliance manager role: ${roleRes.body}`);
}
complianceRoleId = JSON.parse(roleRes.body).id as string;
const registerRes = await licensedApp.app.inject({
method: "POST",
url: "/api/auth/register",
headers: { authorization: `Bearer ${licensedAdminToken}` },
payload: { username, password: "TestPass1", role: roleName },
});
if (registerRes.statusCode !== 201) {
throw new Error(`Failed to create compliance manager user: ${registerRes.body}`);
}
complianceManagerId = JSON.parse(registerRes.body).id as string;
await db
.update(schema.users)
.set({ mustChangePassword: false })
.where(eq(schema.users.id, complianceManagerId));
const loginRes = await licensedApp.app.inject({
method: "POST",
url: "/api/auth/login",
payload: { username, password: "TestPass1" },
});
complianceManagerToken = JSON.parse(loginRes.body).token as string;
}, 30_000);
afterAll(async () => {
if (complianceManagerId) {
await db.delete(schema.users).where(eq(schema.users.id, complianceManagerId));
}
if (complianceRoleId) {
await db.delete(schema.roles).where(eq(schema.roles.id, complianceRoleId));
}
await licensedApp.cleanup();
vi.restoreAllMocks();
}, 10_000);
async function createTarget(role: string, teamId?: string): Promise<string> {
targetSequence += 1;
const username = `gdpr-target-${role}-${Date.now().toString(36)}-${targetSequence}`;
const registerRes = await licensedApp.app.inject({
method: "POST",
url: "/api/auth/register",
headers: { authorization: `Bearer ${licensedAdminToken}` },
payload: { username, password: "TargetPass1", role },
});
if (registerRes.statusCode !== 201) {
throw new Error(`Failed to create GDPR target user: ${registerRes.body}`);
}
const id = JSON.parse(registerRes.body).id as string;
await db
.update(schema.users)
.set({
mustChangePassword: false,
...(teamId ? { team: teamId } : {}),
})
.where(eq(schema.users.id, id));
return id;
}
it("denies direct purge of a disabled administrator and preserves the account", async () => {
const targetId = await createTarget("admin");
await db
.update(schema.users)
.set({ role: "disabled:admin" })
.where(eq(schema.users.id, targetId));
try {
const res = await licensedApp.app.inject({
method: "DELETE",
url: `/api/v1/enterprise/users/${targetId}/purge`,
headers: { authorization: `Bearer ${complianceManagerToken}` },
payload: { confirm: true },
});
const body = JSON.parse(res.body);
const [remainingTarget] = await db
.select()
.from(schema.users)
.where(eq(schema.users.id, targetId));
expect.soft(res.statusCode).toBe(403);
expect.soft(body.code).toBe("ESCALATION_DENIED");
expect(remainingTarget?.role).toBe("disabled:admin");
} finally {
await db.delete(schema.users).where(eq(schema.users.id, targetId));
}
});
it("preflights a mixed team and preserves subordinate members when a disabled admin is denied", async () => {
const teamId = randomUUID();
await db.insert(schema.teams).values({
id: teamId,
name: `GDPR hierarchy ${Date.now().toString(36)}`,
});
const subordinateId = await createTarget("user", teamId);
const disabledAdminId = await createTarget("admin", teamId);
await db
.update(schema.users)
.set({ role: "disabled:admin" })
.where(eq(schema.users.id, disabledAdminId));
try {
const res = await licensedApp.app.inject({
method: "DELETE",
url: `/api/v1/enterprise/teams/${teamId}/purge`,
headers: { authorization: `Bearer ${complianceManagerToken}` },
payload: { confirm: true },
});
const body = JSON.parse(res.body);
const [remainingSubordinate] = await db
.select()
.from(schema.users)
.where(eq(schema.users.id, subordinateId));
const [remainingDisabledAdmin] = await db
.select()
.from(schema.users)
.where(eq(schema.users.id, disabledAdminId));
const [remainingTeam] = await db
.select()
.from(schema.teams)
.where(eq(schema.teams.id, teamId));
expect.soft(res.statusCode).toBe(403);
expect.soft(body.code).toBe("ESCALATION_DENIED");
expect.soft(remainingSubordinate?.role).toBe("user");
expect.soft(remainingDisabledAdmin?.role).toBe("disabled:admin");
expect(remainingTeam?.id).toBe(teamId);
} finally {
await db.delete(schema.users).where(eq(schema.users.id, subordinateId));
await db.delete(schema.users).where(eq(schema.users.id, disabledAdminId));
await db.delete(schema.teams).where(eq(schema.teams.id, teamId));
}
});
it("allows the full built-in admin to purge a subordinate team", async () => {
const teamId = randomUUID();
await db.insert(schema.teams).values({
id: teamId,
name: `GDPR subordinate ${Date.now().toString(36)}`,
});
const targetId = await createTarget("user", teamId);
try {
const res = await licensedApp.app.inject({
method: "DELETE",
url: `/api/v1/enterprise/teams/${teamId}/purge`,
headers: { authorization: `Bearer ${licensedAdminToken}` },
payload: { confirm: true },
});
const [remainingTarget] = await db
.select()
.from(schema.users)
.where(eq(schema.users.id, targetId));
const [remainingTeam] = await db
.select()
.from(schema.teams)
.where(eq(schema.teams.id, teamId));
expect(res.statusCode).toBe(200);
expect(remainingTarget).toBeUndefined();
expect(remainingTeam).toBeUndefined();
} finally {
await db.delete(schema.users).where(eq(schema.users.id, targetId));
await db.delete(schema.teams).where(eq(schema.teams.id, teamId));
}
});
});
@@ -0,0 +1,542 @@
import { eq, sql } from "drizzle-orm";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { db, schema } from "../../../apps/api/src/db/index.js";
import { canManageTargetRole, hasToolAccess } from "../../../apps/api/src/permissions.js";
import { buildTestApp, loginAsAdmin, type TestApp } from "../test-server.js";
let testApp: TestApp;
let adminToken: string;
let scopeManagerToken: string;
let scopeManagerRole: string;
let sequence = 0;
const testRun = Date.now().toString(36);
function uniqueName(prefix: string): string {
sequence += 1;
return `${prefix}-${testRun}-${sequence}`;
}
async function createRoleAsAdmin(
name: string,
permissions: string[],
toolPermissions?: { mode: "category" | "tool"; allowed: string[] } | null,
): Promise<string> {
const response = await testApp.app.inject({
method: "POST",
url: "/api/v1/roles",
headers: { authorization: `Bearer ${adminToken}` },
payload: { name, permissions, toolPermissions },
});
expect(response.statusCode, response.body).toBe(201);
return JSON.parse(response.body).id as string;
}
async function createUserAndLogin(username: string, role: string): Promise<string> {
const password = "RoleAuthority1!";
const registerResponse = await testApp.app.inject({
method: "POST",
url: "/api/auth/register",
headers: { authorization: `Bearer ${adminToken}` },
payload: { username, password, role },
});
expect(registerResponse.statusCode, registerResponse.body).toBe(201);
await db
.update(schema.users)
.set({ mustChangePassword: false })
.where(eq(schema.users.username, username));
const loginResponse = await testApp.app.inject({
method: "POST",
url: "/api/auth/login",
payload: { username, password },
});
expect(loginResponse.statusCode, loginResponse.body).toBe(200);
return JSON.parse(loginResponse.body).token as string;
}
beforeAll(async () => {
testApp = await buildTestApp();
adminToken = await loginAsAdmin(testApp.app);
scopeManagerRole = uniqueName("scope-manager");
await createRoleAsAdmin(scopeManagerRole, ["security:manage", "tools:use"], {
mode: "category",
allowed: ["image"],
});
scopeManagerToken = await createUserAndLogin(uniqueName("scope-manager-user"), scopeManagerRole);
}, 30_000);
afterAll(async () => {
await testApp.cleanup();
}, 10_000);
describe("role authority containment", () => {
it.each(["disabled:", "disabled:disabled", "disabled:disabled:", "disabled:disabled:admin"])(
"normalizes malformed disabled role %s to the conservative admin boundary",
async (role) => {
const admin = { id: "admin", username: "admin", role: "admin" };
const customManager = {
id: "scope-manager",
username: "scope-manager",
role: scopeManagerRole,
};
expect.soft(await canManageTargetRole(admin, role)).toBe(true);
expect(await canManageTargetRole(customManager, role)).toBe(false);
},
);
it("rejects updating a role whose existing permissions exceed the actor's authority", async () => {
const targetName = uniqueName("broader-update");
const targetId = await createRoleAsAdmin(targetName, ["settings:write"]);
const [before] = await db.select().from(schema.roles).where(eq(schema.roles.id, targetId));
const response = await testApp.app.inject({
method: "PUT",
url: `/api/v1/roles/${targetId}`,
headers: { authorization: `Bearer ${scopeManagerToken}` },
payload: { description: "unauthorized change" },
});
expect(response.statusCode).toBe(403);
expect(JSON.parse(response.body).code).toBe("ESCALATION_DENIED");
const [after] = await db.select().from(schema.roles).where(eq(schema.roles.id, targetId));
expect(after).toMatchObject({
description: before.description,
permissions: before.permissions,
toolPermissions: before.toolPermissions,
});
});
it("rejects adding an ordinary permission the actor does not hold", async () => {
const targetName = uniqueName("broader-permission");
const targetId = await createRoleAsAdmin(targetName, ["security:manage"]);
const [before] = await db.select().from(schema.roles).where(eq(schema.roles.id, targetId));
const response = await testApp.app.inject({
method: "PUT",
url: `/api/v1/roles/${targetId}`,
headers: { authorization: `Bearer ${scopeManagerToken}` },
payload: { permissions: ["security:manage", "settings:write"] },
});
const [after] = await db.select().from(schema.roles).where(eq(schema.roles.id, targetId));
expect.soft(response.statusCode).toBe(403);
expect.soft(JSON.parse(response.body).code).toBe("ESCALATION_DENIED");
expect(after.permissions).toEqual(before.permissions);
});
it("rejects deleting a role whose existing permissions exceed the actor's authority", async () => {
const targetName = uniqueName("broader-delete");
const targetId = await createRoleAsAdmin(targetName, ["settings:write"]);
const response = await testApp.app.inject({
method: "DELETE",
url: `/api/v1/roles/${targetId}`,
headers: { authorization: `Bearer ${scopeManagerToken}` },
});
expect(response.statusCode).toBe(403);
expect(JSON.parse(response.body).code).toBe("ESCALATION_DENIED");
const [persisted] = await db.select().from(schema.roles).where(eq(schema.roles.id, targetId));
expect(persisted).toMatchObject({ id: targetId, name: targetName });
});
it("rejects creating a role with tool access outside the actor's tool scope", async () => {
const targetName = uniqueName("broader-tool-create");
const response = await testApp.app.inject({
method: "POST",
url: "/api/v1/roles",
headers: { authorization: `Bearer ${scopeManagerToken}` },
payload: {
name: targetName,
permissions: ["tools:use"],
toolPermissions: { mode: "category", allowed: ["video"] },
},
});
expect(response.statusCode).toBe(403);
expect(JSON.parse(response.body).code).toBe("ESCALATION_DENIED");
const [persisted] = await db
.select()
.from(schema.roles)
.where(eq(schema.roles.name, targetName));
expect(persisted).toBeUndefined();
});
it("rejects widening an existing role beyond the actor's tool scope", async () => {
const targetName = uniqueName("broader-tool-update");
const targetId = await createRoleAsAdmin(targetName, ["tools:use"], {
mode: "category",
allowed: ["image"],
});
const [before] = await db.select().from(schema.roles).where(eq(schema.roles.id, targetId));
const response = await testApp.app.inject({
method: "PUT",
url: `/api/v1/roles/${targetId}`,
headers: { authorization: `Bearer ${scopeManagerToken}` },
payload: { toolPermissions: { mode: "category", allowed: ["image", "video"] } },
});
expect(response.statusCode).toBe(403);
expect(JSON.parse(response.body).code).toBe("ESCALATION_DENIED");
const [after] = await db.select().from(schema.roles).where(eq(schema.roles.id, targetId));
expect(after.toolPermissions).toEqual(before.toolPermissions);
});
it("fails closed for malformed persisted custom-role tool permissions", async () => {
const targetName = uniqueName("malformed-tools");
const targetId = await createRoleAsAdmin(targetName, ["tools:use"]);
await db
.update(schema.roles)
.set({
toolPermissions: sql`${JSON.stringify({ mode: "unexpected", allowed: [] })}::jsonb`,
})
.where(eq(schema.roles.id, targetId));
const admin = { id: "admin", username: "admin", role: "admin" };
expect.soft(await canManageTargetRole(admin, targetName)).toBe(false);
expect(await hasToolAccess(targetName, "resize")).toBe(false);
});
it("rejects null unrestricted tool scopes on create and update", async () => {
const createdName = uniqueName("null-tool-create");
const createResponse = await testApp.app.inject({
method: "POST",
url: "/api/v1/roles",
headers: { authorization: `Bearer ${scopeManagerToken}` },
payload: {
name: createdName,
permissions: ["tools:use"],
toolPermissions: null,
},
});
expect.soft(createResponse.statusCode).toBe(403);
expect.soft(JSON.parse(createResponse.body).code).toBe("ESCALATION_DENIED");
const [unexpectedCreate] = await db
.select()
.from(schema.roles)
.where(eq(schema.roles.name, createdName));
expect.soft(unexpectedCreate).toBeUndefined();
const updatedName = uniqueName("null-tool-update");
const updatedId = await createRoleAsAdmin(updatedName, ["tools:use"], {
mode: "category",
allowed: ["image"],
});
const [before] = await db.select().from(schema.roles).where(eq(schema.roles.id, updatedId));
const updateResponse = await testApp.app.inject({
method: "PUT",
url: `/api/v1/roles/${updatedId}`,
headers: { authorization: `Bearer ${scopeManagerToken}` },
payload: { toolPermissions: null },
});
const [after] = await db.select().from(schema.roles).where(eq(schema.roles.id, updatedId));
expect.soft(updateResponse.statusCode).toBe(403);
expect.soft(JSON.parse(updateResponse.body).code).toBe("ESCALATION_DENIED");
expect(after.toolPermissions).toEqual(before.toolPermissions);
});
it("matches tool-scope containment to graceful degradation without the enterprise feature", async () => {
const actorRole = uniqueName("deg-manager");
await createRoleAsAdmin(actorRole, ["security:manage", "tools:use"], {
mode: "tool",
allowed: ["resize"],
});
const actorToken = await createUserAndLogin(uniqueName("deg-user"), actorRole);
const targetName = uniqueName("deg-target");
const response = await testApp.app.inject({
method: "POST",
url: "/api/v1/roles",
headers: { authorization: `Bearer ${actorToken}` },
payload: {
name: targetName,
permissions: ["tools:use"],
toolPermissions: null,
},
});
expect(response.statusCode, response.body).toBe(201);
});
it("allows creating and updating a contained role and lets a full admin delete it", async () => {
const targetName = uniqueName("contained-role");
const createResponse = await testApp.app.inject({
method: "POST",
url: "/api/v1/roles",
headers: { authorization: `Bearer ${scopeManagerToken}` },
payload: {
name: targetName,
permissions: ["tools:use"],
toolPermissions: { mode: "category", allowed: ["image"] },
},
});
expect(createResponse.statusCode, createResponse.body).toBe(201);
const targetId = JSON.parse(createResponse.body).id as string;
const updateResponse = await testApp.app.inject({
method: "PUT",
url: `/api/v1/roles/${targetId}`,
headers: { authorization: `Bearer ${scopeManagerToken}` },
payload: { description: "contained update" },
});
expect(updateResponse.statusCode, updateResponse.body).toBe(200);
const deleteResponse = await testApp.app.inject({
method: "DELETE",
url: `/api/v1/roles/${targetId}`,
headers: { authorization: `Bearer ${adminToken}` },
});
expect(deleteResponse.statusCode, deleteResponse.body).toBe(200);
const [persisted] = await db.select().from(schema.roles).where(eq(schema.roles.id, targetId));
expect(persisted).toBeUndefined();
});
it("renames active and disabled custom-role members atomically", async () => {
const originalName = uniqueName("rename-role");
const renamedName = uniqueName("renamed-role");
const roleId = await createRoleAsAdmin(originalName, ["security:manage"]);
const activeUsername = uniqueName("rename-active");
const disabledUsername = uniqueName("rename-disabled");
const nestedUsername = uniqueName("rename-nested");
const lookalikeUsername = uniqueName("rename-lookalike");
await createUserAndLogin(activeUsername, originalName);
await createUserAndLogin(disabledUsername, originalName);
await createUserAndLogin(nestedUsername, originalName);
await createUserAndLogin(lookalikeUsername, originalName);
await db
.update(schema.users)
.set({ role: `disabled:${originalName}` })
.where(eq(schema.users.username, disabledUsername));
await db
.update(schema.users)
.set({ role: `disabled:disabled:disabled:${originalName}` })
.where(eq(schema.users.username, nestedUsername));
const lookalikeRole = `disabled:disabled:${originalName}-suffix`;
await db
.update(schema.users)
.set({ role: lookalikeRole })
.where(eq(schema.users.username, lookalikeUsername));
const response = await testApp.app.inject({
method: "PUT",
url: `/api/v1/roles/${roleId}`,
headers: { authorization: `Bearer ${adminToken}` },
payload: { name: renamedName },
});
const [renamedRole] = await db.select().from(schema.roles).where(eq(schema.roles.id, roleId));
const [activeMember] = await db
.select()
.from(schema.users)
.where(eq(schema.users.username, activeUsername));
const [disabledMember] = await db
.select()
.from(schema.users)
.where(eq(schema.users.username, disabledUsername));
const [nestedMember] = await db
.select()
.from(schema.users)
.where(eq(schema.users.username, nestedUsername));
const [lookalikeMember] = await db
.select()
.from(schema.users)
.where(eq(schema.users.username, lookalikeUsername));
expect.soft(response.statusCode, response.body).toBe(200);
expect.soft(renamedRole?.name).toBe(renamedName);
expect.soft(activeMember?.role).toBe(renamedName);
expect.soft(disabledMember?.role).toBe(`disabled:${renamedName}`);
expect.soft(nestedMember?.role).toBe(`disabled:${renamedName}`);
expect(lookalikeMember?.role).toBe(lookalikeRole);
});
it("preserves member activation state when a full admin deletes a custom role", async () => {
const targetName = uniqueName("delete-role");
const targetId = await createRoleAsAdmin(targetName, ["security:manage"]);
const activeUsername = uniqueName("delete-active");
const disabledUsername = uniqueName("delete-disabled");
const nestedUsername = uniqueName("delete-nested");
const lookalikeUsername = uniqueName("delete-lookalike");
await createUserAndLogin(activeUsername, targetName);
await createUserAndLogin(disabledUsername, targetName);
await createUserAndLogin(nestedUsername, targetName);
await createUserAndLogin(lookalikeUsername, targetName);
await db
.update(schema.users)
.set({ role: `disabled:${targetName}` })
.where(eq(schema.users.username, disabledUsername));
await db
.update(schema.users)
.set({ role: `disabled:disabled:disabled:${targetName}` })
.where(eq(schema.users.username, nestedUsername));
const lookalikeRole = `disabled:disabled:${targetName}-suffix`;
await db
.update(schema.users)
.set({ role: lookalikeRole })
.where(eq(schema.users.username, lookalikeUsername));
const [disabledBefore] = await db
.select()
.from(schema.users)
.where(eq(schema.users.username, disabledUsername));
const response = await testApp.app.inject({
method: "DELETE",
url: `/api/v1/roles/${targetId}`,
headers: { authorization: `Bearer ${adminToken}` },
});
const [deletedRole] = await db.select().from(schema.roles).where(eq(schema.roles.id, targetId));
const [activeMember] = await db
.select()
.from(schema.users)
.where(eq(schema.users.username, activeUsername));
const [disabledMember] = await db
.select()
.from(schema.users)
.where(eq(schema.users.username, disabledUsername));
const [nestedMember] = await db
.select()
.from(schema.users)
.where(eq(schema.users.username, nestedUsername));
const [lookalikeMember] = await db
.select()
.from(schema.users)
.where(eq(schema.users.username, lookalikeUsername));
expect.soft(response.statusCode, response.body).toBe(200);
expect.soft(deletedRole).toBeUndefined();
expect.soft(activeMember?.role).toBe("user");
expect.soft(disabledMember?.role).toBe("disabled:user");
expect.soft(nestedMember?.role).toBe("disabled:user");
expect.soft(lookalikeMember?.role).toBe(lookalikeRole);
const manageResponse = await testApp.app.inject({
method: "PUT",
url: `/api/auth/users/${disabledBefore.id}`,
headers: { authorization: `Bearer ${adminToken}` },
payload: { role: "user" },
});
const [reactivatedMember] = await db
.select()
.from(schema.users)
.where(eq(schema.users.id, disabledBefore.id));
expect.soft(manageResponse.statusCode, manageResponse.body).toBe(200);
expect(reactivatedMember?.role).toBe("user");
});
it.each(["active", "disabled"])(
"rejects deleting a contained role with an %s member when the user fallback exceeds actor authority",
async (memberState) => {
const deletionManagerRole = uniqueName("delete-manager");
await createRoleAsAdmin(deletionManagerRole, ["security:manage", "users:manage"]);
const deletionManagerToken = await createUserAndLogin(
uniqueName("delete-mgr-user"),
deletionManagerRole,
);
const targetName = uniqueName("occupied-role");
const targetId = await createRoleAsAdmin(targetName, ["security:manage"]);
const memberName = uniqueName("occupied-member");
await createUserAndLogin(memberName, targetName);
if (memberState === "disabled") {
await db
.update(schema.users)
.set({ role: `disabled:${targetName}` })
.where(eq(schema.users.username, memberName));
}
const [memberBefore] = await db
.select()
.from(schema.users)
.where(eq(schema.users.username, memberName));
const response = await testApp.app.inject({
method: "DELETE",
url: `/api/v1/roles/${targetId}`,
headers: { authorization: `Bearer ${deletionManagerToken}` },
});
const [persistedRole] = await db
.select()
.from(schema.roles)
.where(eq(schema.roles.id, targetId));
const [memberAfter] = await db
.select()
.from(schema.users)
.where(eq(schema.users.id, memberBefore.id));
expect.soft(response.statusCode).toBe(403);
expect.soft(JSON.parse(response.body).code).toBe("ESCALATION_DENIED");
expect.soft(persistedRole).toMatchObject({ id: targetId, name: targetName });
expect(memberAfter?.role).toBe(memberBefore.role);
},
);
it("rejects deleting an empty contained role when the fallback exceeds actor authority", async () => {
const targetName = uniqueName("empty-role");
const targetId = await createRoleAsAdmin(targetName, ["security:manage"]);
const response = await testApp.app.inject({
method: "DELETE",
url: `/api/v1/roles/${targetId}`,
headers: { authorization: `Bearer ${scopeManagerToken}` },
});
const [persistedRole] = await db
.select()
.from(schema.roles)
.where(eq(schema.roles.id, targetId));
expect.soft(response.statusCode).toBe(403);
expect.soft(JSON.parse(response.body).code).toBe("ESCALATION_DENIED");
expect(persistedRole).toMatchObject({ id: targetId, name: targetName });
});
});
describe("API-key-scoped role authority", () => {
it("does not let a users:manage-only admin API key reset a peer administrator", async () => {
const targetUsername = uniqueName("peer-admin");
const targetPassword = "PeerAdmin1!";
const registerResponse = await testApp.app.inject({
method: "POST",
url: "/api/auth/register",
headers: { authorization: `Bearer ${adminToken}` },
payload: { username: targetUsername, password: targetPassword, role: "admin" },
});
expect(registerResponse.statusCode, registerResponse.body).toBe(201);
const targetId = JSON.parse(registerResponse.body).id as string;
await db
.update(schema.users)
.set({ mustChangePassword: false })
.where(eq(schema.users.id, targetId));
const [before] = await db.select().from(schema.users).where(eq(schema.users.id, targetId));
const keyResponse = await testApp.app.inject({
method: "POST",
url: "/api/v1/api-keys",
headers: { authorization: `Bearer ${adminToken}` },
payload: { name: uniqueName("users-manage-key"), permissions: ["users:manage"] },
});
expect(keyResponse.statusCode, keyResponse.body).toBe(201);
const apiKey = JSON.parse(keyResponse.body).key as string;
const resetResponse = await testApp.app.inject({
method: "POST",
url: `/api/auth/users/${targetId}/reset-password`,
headers: { authorization: `Bearer ${apiKey}` },
payload: { newPassword: "UnauthorizedReset1!" },
});
expect(resetResponse.statusCode).toBe(403);
expect(JSON.parse(resetResponse.body).code).toBe("ESCALATION_DENIED");
const [after] = await db.select().from(schema.users).where(eq(schema.users.id, targetId));
expect(after.passwordHash).toBe(before.passwordHash);
expect(after.mustChangePassword).toBe(false);
});
});
+450 -3
View File
@@ -1,10 +1,30 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { eq } from "drizzle-orm";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { db, schema } from "../../../apps/api/src/db/index.js";
import { hashPassword } from "../../../apps/api/src/plugins/auth.js";
import { hashPassword, verifyPassword } from "../../../apps/api/src/plugins/auth.js";
import { buildTestApp, type TestApp } from "../test-server.js";
let testApp: TestApp;
const SCIM_TOKEN = "test-scim-token-abc123";
const SCIM_TOKEN = `so_scim_v2_${"a".repeat(64)}`;
const ADMIN_PERMISSIONS = [
"tools:use",
"files:own",
"files:all",
"apikeys:own",
"apikeys:all",
"pipelines:own",
"pipelines:all",
"settings:read",
"settings:write",
"users:manage",
"teams:manage",
"features:manage",
"system:health",
"audit:read",
"compliance:manage",
"webhooks:manage",
"security:manage",
];
beforeAll(async () => {
testApp = await buildTestApp();
@@ -175,6 +195,38 @@ describe("SCIM 2.0 provisioning", () => {
});
expect(res.statusCode).toBe(401);
});
it("rejects a correctly hashed legacy unversioned token", async () => {
const legacyToken = "b".repeat(64);
const legacyHash = await hashPassword(legacyToken);
await db
.insert(schema.settings)
.values({ key: "scim_token_hash", value: legacyHash })
.onConflictDoUpdate({
target: schema.settings.key,
set: { value: legacyHash },
});
try {
const res = await testApp.app.inject({
method: "GET",
url: "/api/v1/scim/v2/Users",
headers: { authorization: `Bearer ${legacyToken}` },
});
expect(res.statusCode).toBe(401);
expect(JSON.parse(res.body)).toMatchObject({
status: 401,
detail: "Invalid token",
});
} finally {
const currentHash = await hashPassword(SCIM_TOKEN);
await db
.update(schema.settings)
.set({ value: currentHash })
.where(eq(schema.settings.key, "scim_token_hash"));
}
});
});
// ── Enterprise gate ────────────────────────────────────────────
@@ -322,3 +374,398 @@ describe("SCIM 2.0 provisioning", () => {
});
});
});
describe("SCIM global token administration", () => {
let licensedApp: TestApp;
let licensedAdminToken: string;
let managerToken: string;
let managerRoleId: string;
let managerUserId: string;
let scopedAdminKey: string;
beforeAll(async () => {
vi.resetModules();
const { mockEnterpriseFeatures } = await import("../../helpers/enterprise-mock.js");
mockEnterpriseFeatures(["scim"]);
const { buildTestApp, loginAsAdmin } = await import("../test-server.js");
licensedApp = await buildTestApp();
licensedAdminToken = await loginAsAdmin(licensedApp.app);
const suffix = Date.now().toString(36);
const roleName = `scim-manager-${suffix}`;
const username = `scim-manager-user-${suffix}`;
const roleRes = await licensedApp.app.inject({
method: "POST",
url: "/api/v1/roles",
headers: { authorization: `Bearer ${licensedAdminToken}` },
payload: { name: roleName, permissions: ADMIN_PERMISSIONS },
});
if (roleRes.statusCode !== 201) {
throw new Error(`Failed to create SCIM manager role: ${roleRes.body}`);
}
managerRoleId = JSON.parse(roleRes.body).id as string;
const registerRes = await licensedApp.app.inject({
method: "POST",
url: "/api/auth/register",
headers: { authorization: `Bearer ${licensedAdminToken}` },
payload: { username, password: "TestPass1", role: roleName },
});
if (registerRes.statusCode !== 201) {
throw new Error(`Failed to create SCIM manager user: ${registerRes.body}`);
}
managerUserId = JSON.parse(registerRes.body).id as string;
await db
.update(schema.users)
.set({ mustChangePassword: false })
.where(eq(schema.users.id, managerUserId));
const loginRes = await licensedApp.app.inject({
method: "POST",
url: "/api/auth/login",
payload: { username, password: "TestPass1" },
});
managerToken = JSON.parse(loginRes.body).token as string;
const apiKeyRes = await licensedApp.app.inject({
method: "POST",
url: "/api/v1/api-keys",
headers: { authorization: `Bearer ${licensedAdminToken}` },
payload: {
name: `scim-scoped-admin-${suffix}`,
permissions: ["users:manage", "apikeys:own"],
},
});
if (apiKeyRes.statusCode !== 201) {
throw new Error(`Failed to create scoped admin API key: ${apiKeyRes.body}`);
}
scopedAdminKey = JSON.parse(apiKeyRes.body).key as string;
}, 30_000);
afterAll(async () => {
await db.delete(schema.settings).where(eq(schema.settings.key, "scim_token_hash"));
if (managerUserId) {
await db.delete(schema.users).where(eq(schema.users.id, managerUserId));
}
if (managerRoleId) {
await db.delete(schema.roles).where(eq(schema.roles.id, managerRoleId));
}
await licensedApp.cleanup();
vi.restoreAllMocks();
}, 10_000);
it("denies token issuance to a custom role even when it has every admin permission", async () => {
const originalHash = "scim-issuance-authorization-sentinel";
await db
.insert(schema.settings)
.values({ key: "scim_token_hash", value: originalHash })
.onConflictDoUpdate({
target: schema.settings.key,
set: { value: originalHash },
});
const res = await licensedApp.app.inject({
method: "POST",
url: "/api/v1/enterprise/scim/token",
headers: { authorization: `Bearer ${managerToken}` },
});
const body = JSON.parse(res.body);
const [storedToken] = await db
.select()
.from(schema.settings)
.where(eq(schema.settings.key, "scim_token_hash"));
expect.soft(res.statusCode).toBe(403);
expect.soft(body.code).toBe("ESCALATION_DENIED");
expect(storedToken?.value).toBe(originalHash);
});
it("denies token revocation to a custom role even when it has every admin permission", async () => {
const originalHash = "scim-revocation-authorization-sentinel";
await db
.insert(schema.settings)
.values({ key: "scim_token_hash", value: originalHash })
.onConflictDoUpdate({
target: schema.settings.key,
set: { value: originalHash },
});
const res = await licensedApp.app.inject({
method: "DELETE",
url: "/api/v1/enterprise/scim/token",
headers: { authorization: `Bearer ${managerToken}` },
});
const body = res.body ? JSON.parse(res.body) : {};
const [storedToken] = await db
.select()
.from(schema.settings)
.where(eq(schema.settings.key, "scim_token_hash"));
expect.soft(res.statusCode).toBe(403);
expect.soft(body.code).toBe("ESCALATION_DENIED");
expect(storedToken?.value).toBe(originalHash);
});
it.each([
{ method: "POST" as const, operation: "issuance" },
{ method: "DELETE" as const, operation: "revocation" },
])("denies token $operation through a scoped built-in admin API key", async ({ method }) => {
const originalHash = `scim-scoped-key-${method.toLowerCase()}-sentinel`;
await db
.insert(schema.settings)
.values({ key: "scim_token_hash", value: originalHash })
.onConflictDoUpdate({
target: schema.settings.key,
set: { value: originalHash },
});
const res = await licensedApp.app.inject({
method,
url: "/api/v1/enterprise/scim/token",
headers: { authorization: `Bearer ${scopedAdminKey}` },
});
const [storedToken] = await db
.select()
.from(schema.settings)
.where(eq(schema.settings.key, "scim_token_hash"));
expect.soft(res.statusCode).toBe(403);
expect.soft(JSON.parse(res.body).code).toBe("ESCALATION_DENIED");
expect(storedToken?.value).toBe(originalHash);
});
it("issues a versioned token that authenticates an end-to-end SCIM request", async () => {
await db.delete(schema.settings).where(eq(schema.settings.key, "scim_token_hash"));
const res = await licensedApp.app.inject({
method: "POST",
url: "/api/v1/enterprise/scim/token",
headers: { authorization: `Bearer ${licensedAdminToken}` },
});
const body = JSON.parse(res.body) as { token: string };
const [storedToken] = await db
.select()
.from(schema.settings)
.where(eq(schema.settings.key, "scim_token_hash"));
expect(res.statusCode).toBe(201);
expect(body.token).toMatch(/^so_scim_v2_[0-9a-f]{64}$/);
if (!storedToken) throw new Error("SCIM token hash was not persisted");
expect(await verifyPassword(body.token, storedToken.value)).toBe(true);
const listRes = await licensedApp.app.inject({
method: "GET",
url: "/api/v1/scim/v2/Users",
headers: { authorization: `Bearer ${body.token}` },
});
expect(listRes.statusCode, listRes.body).toBe(200);
expect(JSON.parse(listRes.body).Resources).toBeInstanceOf(Array);
});
it("allows the full built-in admin to revoke a token", async () => {
const tokenHash = await hashPassword(SCIM_TOKEN);
await db
.insert(schema.settings)
.values({ key: "scim_token_hash", value: tokenHash })
.onConflictDoUpdate({
target: schema.settings.key,
set: { value: tokenHash },
});
const res = await licensedApp.app.inject({
method: "DELETE",
url: "/api/v1/enterprise/scim/token",
headers: { authorization: `Bearer ${licensedAdminToken}` },
});
const [storedToken] = await db
.select()
.from(schema.settings)
.where(eq(schema.settings.key, "scim_token_hash"));
expect(res.statusCode).toBe(204);
expect(storedToken).toBeUndefined();
});
it("keeps repeated user deprovisioning idempotent and recoverable", async () => {
const tokenHash = await hashPassword(SCIM_TOKEN);
await db
.insert(schema.settings)
.values({ key: "scim_token_hash", value: tokenHash })
.onConflictDoUpdate({
target: schema.settings.key,
set: { value: tokenHash },
});
const username = `scim-repeat-delete-${Date.now().toString(36)}`;
const createResponse = await licensedApp.app.inject({
method: "POST",
url: "/api/v1/scim/v2/Users",
headers: { authorization: `Bearer ${SCIM_TOKEN}` },
payload: { userName: username, active: true },
});
expect(createResponse.statusCode, createResponse.body).toBe(201);
const userId = JSON.parse(createResponse.body).id as string;
const firstDelete = await licensedApp.app.inject({
method: "DELETE",
url: `/api/v1/scim/v2/Users/${userId}`,
headers: { authorization: `Bearer ${SCIM_TOKEN}` },
});
const [afterFirstDelete] = await db
.select()
.from(schema.users)
.where(eq(schema.users.id, userId));
const secondDelete = await licensedApp.app.inject({
method: "DELETE",
url: `/api/v1/scim/v2/Users/${userId}`,
headers: { authorization: `Bearer ${SCIM_TOKEN}` },
});
const [afterSecondDelete] = await db
.select()
.from(schema.users)
.where(eq(schema.users.id, userId));
expect.soft(firstDelete.statusCode).toBe(204);
expect.soft(secondDelete.statusCode).toBe(204);
expect.soft(afterFirstDelete?.role).toBe("disabled:user");
expect.soft(afterSecondDelete?.role).toBe("disabled:user");
const reactivateResponse = await licensedApp.app.inject({
method: "PUT",
url: `/api/v1/scim/v2/Users/${userId}`,
headers: { authorization: `Bearer ${SCIM_TOKEN}` },
payload: { userName: username, active: true },
});
const [reactivated] = await db.select().from(schema.users).where(eq(schema.users.id, userId));
expect.soft(reactivateResponse.statusCode, reactivateResponse.body).toBe(200);
expect.soft(JSON.parse(reactivateResponse.body).active).toBe(true);
expect(reactivated?.role).toBe("user");
});
it("canonicalizes persisted nested disabled markers during deactivation and activation", async () => {
const tokenHash = await hashPassword(SCIM_TOKEN);
await db
.insert(schema.settings)
.values({ key: "scim_token_hash", value: tokenHash })
.onConflictDoUpdate({
target: schema.settings.key,
set: { value: tokenHash },
});
const username = `scim-nested-disabled-${Date.now().toString(36)}`;
const createResponse = await licensedApp.app.inject({
method: "POST",
url: "/api/v1/scim/v2/Users",
headers: { authorization: `Bearer ${SCIM_TOKEN}` },
payload: { userName: username, active: true },
});
expect(createResponse.statusCode, createResponse.body).toBe(201);
const userId = JSON.parse(createResponse.body).id as string;
await db
.update(schema.users)
.set({ role: "disabled:disabled:disabled:user" })
.where(eq(schema.users.id, userId));
const deleteResponse = await licensedApp.app.inject({
method: "DELETE",
url: `/api/v1/scim/v2/Users/${userId}`,
headers: { authorization: `Bearer ${SCIM_TOKEN}` },
});
const [afterDelete] = await db.select().from(schema.users).where(eq(schema.users.id, userId));
expect.soft(deleteResponse.statusCode).toBe(204);
expect.soft(afterDelete?.role).toBe("disabled:user");
await db
.update(schema.users)
.set({ role: "disabled:disabled:disabled:user" })
.where(eq(schema.users.id, userId));
const reactivateResponse = await licensedApp.app.inject({
method: "PUT",
url: `/api/v1/scim/v2/Users/${userId}`,
headers: { authorization: `Bearer ${SCIM_TOKEN}` },
payload: { userName: username, active: true },
});
const [afterReactivation] = await db
.select()
.from(schema.users)
.where(eq(schema.users.id, userId));
expect.soft(reactivateResponse.statusCode, reactivateResponse.body).toBe(200);
expect.soft(JSON.parse(reactivateResponse.body).active).toBe(true);
expect(afterReactivation?.role).toBe("user");
});
it.each([
{
method: "PUT" as const,
payload: { userName: "admin", active: false },
},
{
method: "PATCH" as const,
payload: {
schemas: ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
Operations: [{ op: "Replace", path: "active", value: false }],
},
},
{
method: "PATCH" as const,
payload: {
schemas: ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
Operations: [{ op: "Replace", value: { active: false } }],
},
},
{
method: "DELETE" as const,
payload: undefined,
},
])("$method refuses to deactivate the last active administrator", async ({ method, payload }) => {
const [adminBefore] = await db
.select()
.from(schema.users)
.where(eq(schema.users.username, "admin"));
if (!adminBefore) throw new Error("Default administrator is missing");
const activeAdmins = (await db.select().from(schema.users)).filter(
(candidate) => candidate.role === "admin",
);
expect(activeAdmins).toHaveLength(1);
const tokenHash = await hashPassword(SCIM_TOKEN);
await db
.insert(schema.settings)
.values({ key: "scim_token_hash", value: tokenHash })
.onConflictDoUpdate({
target: schema.settings.key,
set: { value: tokenHash },
});
try {
const res = await licensedApp.app.inject({
method,
url: `/api/v1/scim/v2/Users/${adminBefore.id}`,
headers: { authorization: `Bearer ${SCIM_TOKEN}` },
...(payload === undefined ? {} : { payload }),
});
const [adminAfter] = await db
.select()
.from(schema.users)
.where(eq(schema.users.id, adminBefore.id));
expect.soft(res.statusCode).toBe(409);
expect.soft(JSON.parse(res.body)).toMatchObject({
status: 409,
detail: "Cannot deactivate the last active administrator",
});
expect.soft(adminAfter?.role).toBe("admin");
expect(adminAfter?.passwordHash).toBe(adminBefore.passwordHash);
} finally {
await db
.update(schema.users)
.set({ role: "admin", passwordHash: adminBefore.passwordHash })
.where(eq(schema.users.id, adminBefore.id));
}
});
});