Files
SnapOtter/tests/e2e/rbac-permission-matrix.spec.ts
T

249 lines
9.5 KiB
TypeScript
Raw Normal View History

/**
* RBAC permission matrix, driven through scoped API keys.
*
* Every previous e2e RBAC spec asserted permissions through the three built-in
* roles, which bundles permissions together and leaves the individual guard
* untested: an admin-only endpoint returning 403 for the `user` role does not
* show WHICH permission decided it. A key scoped to exactly one permission
* isolates one guard at a time. It also closes a second hole: nothing in the
* e2e suite ever authenticated with an `si_` key, so scoping, expiry and
* revocation were only ever proven at the integration layer.
*
* Not every declared permission gates an endpoint. `files:own`, `files:all`,
* `pipelines:own`, `pipelines:all` and `apikeys:all` widen a result set instead
* of deciding a status code, so they cannot appear in a status matrix. Their
* ownership boundaries are covered by tests/integration/platform/
* ownership-enforcement.test.ts. GATED below is therefore the complete set of
* permissions that do decide a status code.
*/
import { randomBytes } from "node:crypto";
import { expect, test } from "@playwright/test";
const API = process.env.API_URL;
if (!API) throw new Error("API_URL was not initialized by playwright.config.ts");
// Only a uniquifier for the fixture accounts this spec creates, but this is the
// permission matrix, so a Math.random() sitting here reads as a security token
// to anyone scanning the file. Taking the real thing is cheaper than explaining
// it every time someone looks.
const UID = `${Date.now().toString(36)}${randomBytes(3).toString("hex")}`;
interface Probe {
permission: string;
method: "GET" | "PUT" | "POST";
path: string;
body?: unknown;
/** Status the route returns for a caller that holds the permission. */
allowed: number[];
/**
* Body the denial carries. Every guard that goes through requirePermission
* answers with code FORBIDDEN. requireToolAccess (apps/api/src/permissions.ts
* line 380) is the one exception: it sends 403 with a message and no code at
* all, so a client cannot tell a tool denial from any other 403
* programmatically. Asserted as it is rather than as it ought to be.
*/
deniedCode?: string;
deniedError?: RegExp;
/**
* Enterprise routes check the permission first and the licence second, so a
* permitted caller on an unlicensed instance still gets 403. The permission
* gate stays observable because only the permission denial carries FORBIDDEN.
*/
licenceGated?: boolean;
}
const GATED: Probe[] = [
{ permission: "settings:read", method: "GET", path: "/api/v1/settings", allowed: [200] },
{
permission: "settings:write",
method: "PUT",
path: "/api/v1/settings",
body: { defaultTheme: "system" },
allowed: [200],
},
{ permission: "users:manage", method: "GET", path: "/api/auth/users", allowed: [200] },
{ permission: "teams:manage", method: "GET", path: "/api/v1/teams", allowed: [200] },
{ permission: "audit:read", method: "GET", path: "/api/v1/audit-log", allowed: [200] },
{ permission: "system:health", method: "GET", path: "/api/v1/admin/health", allowed: [200] },
{
permission: "features:manage",
method: "GET",
path: "/api/v1/admin/features/disk-usage",
allowed: [200],
},
{ permission: "apikeys:own", method: "GET", path: "/api/v1/api-keys", allowed: [200] },
{
permission: "security:manage",
method: "POST",
path: "/api/v1/roles",
body: { name: `matrix-role-${UID}`, permissions: ["security:manage"], description: "" },
// 409 means the guard let the request through to the uniqueness check.
allowed: [200, 201, 409],
},
{
// The tool gate rejects before any upload is read, so an empty body is a
// valid probe: 400 proves the request reached body parsing.
permission: "tools:use",
method: "POST",
path: "/api/v1/tools/image/resize",
allowed: [400, 415, 422],
deniedError: /permission to use this tool/i,
},
{
permission: "webhooks:manage",
method: "GET",
path: "/api/v1/enterprise/webhooks",
allowed: [403],
licenceGated: true,
},
{
permission: "compliance:manage",
method: "GET",
path: "/api/v1/enterprise/legal-hold",
allowed: [403],
licenceGated: true,
},
];
/** A permission every probe can be scoped to without satisfying the guard. */
const NEUTRAL_PERMISSION = "files:own";
async function adminToken(): Promise<string> {
const res = await fetch(`${API}/api/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: "admin", password: "admin" }),
});
expect(res.ok, `admin login failed with ${res.status}`).toBe(true);
return ((await res.json()) as { token: string }).token;
}
/**
* Mint an `si_` key carrying exactly `permissions`. Scoped keys intersect with
* the owning user's role, and admin holds all 17, so any single permission can
* be isolated.
*/
async function scopedKey(token: string, permissions: string[]): Promise<string> {
const res = await fetch(`${API}/api/v1/api-keys`, {
method: "POST",
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
body: JSON.stringify({ name: `matrix-${UID}-${permissions.join("-") || "none"}`, permissions }),
});
expect(res.status, `key creation for [${permissions}] failed`).toBe(201);
return ((await res.json()) as { key: string }).key;
}
async function probe(
key: string,
entry: Probe,
): Promise<{ status: number; code?: string; error?: string }> {
const res = await fetch(`${API}${entry.path}`, {
method: entry.method,
headers: {
Authorization: `Bearer ${key}`,
...(entry.body === undefined ? {} : { "Content-Type": "application/json" }),
},
...(entry.body === undefined ? {} : { body: JSON.stringify(entry.body) }),
});
const parsed = await res.json().catch(() => ({}) as Record<string, unknown>);
const body = parsed as { code?: string; error?: string };
return { status: res.status, code: body.code, error: body.error };
}
test.describe("RBAC permission matrix", () => {
for (const entry of GATED) {
test(`${entry.permission} gates ${entry.method} ${entry.path}`, async () => {
const token = await adminToken();
const denied = await probe(await scopedKey(token, [NEUTRAL_PERMISSION]), entry);
expect(denied.status, `a key without ${entry.permission} must be refused`).toBe(403);
if (entry.deniedError) {
expect(denied.error, "the refusal must name the tool gate").toMatch(entry.deniedError);
} else {
expect(denied.code, "the refusal must come from the permission guard").toBe(
entry.deniedCode ?? "FORBIDDEN",
);
}
const granted = await probe(await scopedKey(token, [entry.permission]), entry);
expect(entry.allowed, `a key scoped to ${entry.permission} got ${granted.status}`).toContain(
granted.status,
);
if (entry.licenceGated) {
// Same status, different reason. Without this the granted case would be
// indistinguishable from the denied one.
expect(
granted.code,
"an unlicensed instance must refuse on the licence, not the permission",
).not.toBe("FORBIDDEN");
}
});
}
test("a key scoped to no permissions is refused everywhere", async () => {
const token = await adminToken();
const key = await scopedKey(token, []);
for (const entry of GATED) {
const result = await probe(key, entry);
expect(result.status, `${entry.method} ${entry.path} accepted an unscoped key`).toBe(403);
}
});
test("a revoked key stops authenticating immediately", async () => {
const token = await adminToken();
const created = await fetch(`${API}/api/v1/api-keys`, {
method: "POST",
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
body: JSON.stringify({ name: `matrix-revoke-${UID}`, permissions: ["settings:read"] }),
});
const { id, key } = (await created.json()) as { id: string; key: string };
const before = await fetch(`${API}/api/v1/settings`, {
headers: { Authorization: `Bearer ${key}` },
});
expect(before.status).toBe(200);
const deleted = await fetch(`${API}/api/v1/api-keys/${id}`, {
method: "DELETE",
headers: { Authorization: `Bearer ${token}` },
});
expect(deleted.ok, `key deletion failed with ${deleted.status}`).toBe(true);
const after = await fetch(`${API}/api/v1/settings`, {
headers: { Authorization: `Bearer ${key}` },
});
expect(after.status, "a deleted key must stop working at once").toBe(401);
});
test("a key cannot be scoped beyond what its owner holds", async () => {
const token = await adminToken();
const res = await fetch(`${API}/api/v1/api-keys`, {
method: "POST",
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
body: JSON.stringify({
name: `matrix-bogus-${UID}`,
permissions: ["settings:read", "not-a-real-permission"],
}),
});
expect(res.status).toBe(400);
expect(((await res.json()) as { code: string }).code).toBe("VALIDATION_ERROR");
});
test("an expiry in the past is refused at creation", async () => {
const token = await adminToken();
const res = await fetch(`${API}/api/v1/api-keys`, {
method: "POST",
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
body: JSON.stringify({
name: `matrix-expired-${UID}`,
permissions: ["settings:read"],
expiresAt: new Date(Date.now() - 60_000).toISOString(),
}),
});
expect(res.status).toBe(400);
});
});