mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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.
249 lines
9.5 KiB
TypeScript
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);
|
|
});
|
|
});
|