mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user