Files
SnapOtter/tests/e2e/rbac-permission-matrix.spec.ts
T
SnapOtterandGitHub d10d0f544f fix: release QA hardening across processing, media, security, and CI gates (#649)
A release-readiness QA pass over the whole product. The commits split into
defects a user would hit and gates that were reporting green while measuring
nothing.

## Fixes that change behaviour

Rate limiting was bypassable on every install: TRUST_PROXY defaulted to true, so
request.ip came from a client-set header and a forged X-Forwarded-For got past
the login limiter. The default is now a private-network trust list.

A transient Postgres outage stranded in-flight jobs, leaving finished output on
disk with no row pointing at it. A reconciler now resolves those rows and adopts
the bytes rather than dropping the work.

A Redis connection that moved to a new address wedged every read-blocked
consumer, so completions stopped signalling while health still answered 200.
Socket timeouts plus subscriber pings recover it.

Installing more than one AI bundle left the shared venv multi-versioned and
silently broke three tools. The installer now reconciles distributions to one
version each.

Converting an image to JXL at quality 1 through 4 returned a 500, because
libjxl 0.7 rejects the distance those values compute. The quality is floored at
what the encoder honours. A missing ffmpeg was also reported to the user as a
corrupt upload; it now says the engine is unavailable.

RAW uploads reached an unpatched LibRaw on arm64, so it is built from source at
0.22.2, and the release scan was split so it can fail on an unfixed critical
instead of hiding it behind ignore-unfixed.

## Gates that could not fail

Two mutation lanes ran zero mutants because Stryker crawled the gitignored docs
build; coverage discarded its whole report on any failing test; the lint gate
skipped root tests, scripts, and two workspaces; and several generated matrices
counted a host missing ffmpeg as a passing tool. Each now measures what it
claims.

Full evidence and the outstanding release items are tracked locally and are not
part of this branch.
2026-07-27 15:37:30 +08:00

249 lines
9.5 KiB
TypeScript

/**
* 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);
});
});