Add cross-feature permission hardening matrix (#60)
All checks were successful
CD / Build and push images (push) Successful in 3m13s
CI / Lint, typecheck, test (push) Successful in 2m36s
CI / Auth e2e pack (push) Successful in 3m38s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 8s
CD / Smoke tests against Test (push) Successful in 1m12s
CD / Promote to Int (push) Successful in 11s

Permissions cut across every M2–M5 feature; this pack pins the security-relevant
subject × surface combinations so a weakened guard is caught.

- `apps/web/e2e/permission-matrix.spec.ts`: an API-level (the UI adds nothing
  over the resolved status code) parameterized suite over the subjects — site
  admin, pond admin/owner, editor, the same editor label-restricted by a
  `secret`-label deny, reader, public (anonymous), and the foreign user (new
  `fixture-outsider`, a member of nothing) — across the surfaces: page read,
  edit (collab-token `rw`/`ro`), sidebar list, search, versions, media, and the
  public HTML endpoint. It enforces the 404-vs-403 policy: an unauthorized read
  is 404 (existence hidden), an unauthorized write on something readable is 403.
- wired into the pipeline as its own CI step; documented in
  `apps/web/e2e/README.md` (with the subject/surface list) so later features
  extend the matrix rather than writing bespoke permission tests.
- seeded-regression check (acceptance criterion): temporarily forcing the
  collab-token to always `rw` (ignoring write permission) makes the pack go red
  on the "reader gets `ro`" and public/foreign cells — verified locally, then
  reverted. Runs in ~1 s (well under the 10-minute budget).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
This commit is contained in:
Claude Opus 4.8 2026-07-10 00:43:16 +02:00
parent ac48e9e3ec
commit ae8cdd0e1e
4 changed files with 253 additions and 7 deletions

View File

@ -218,6 +218,16 @@ jobs:
E2E_BASE_URL=http://localhost:5173 \
pnpm --filter @dorfteich/web exec playwright test e2e/admin-users.spec.ts
- name: Reset login rate limit before permission-matrix pack
run: |
echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \
pnpm --filter @dorfteich/api exec prisma db execute --stdin --url "$DATABASE_URL"
- name: Run permission-matrix pack
run: |
E2E_BASE_URL=http://localhost:5173 \
pnpm --filter @dorfteich/web exec playwright test e2e/permission-matrix.spec.ts
- name: Reset login rate limit before offline pack
run: |
echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \

View File

@ -76,6 +76,14 @@ const FIXTURES: FixtureUser[] = [
status: 'ACTIVE',
isSiteAdmin: false,
},
{
// The "foreign user" of the permission matrix (issue #60): signed in, but
// a member of nothing — should see 404 everywhere.
username: 'fixture-outsider',
displayName: 'Fixture Outsider',
status: 'ACTIVE',
isSiteAdmin: false,
},
{
username: 'fixture-pending',
displayName: 'Fixture Pending',

View File

@ -42,13 +42,33 @@ Seeded by `pnpm --filter @dorfteich/api db:seed` (idempotent — re-running
never duplicates). Shared password: `fixture passwort 123`. Fixtures exist
only on dev machines and disposable CI/Test databases.
| Username | State | Purpose |
| ----------------- | ------------------- | ---------------------------------------------------------------------------------------- |
| `fixture-admin` | active, Site Admin | admin UI/permissions cases |
| `fixture-user` | active | regular journeys, settings, sessions |
| `fixture-editor` | active | second regular account for the collab-permissions pack (reader/editor of another's pond) |
| `fixture-viewer` | active | signed-in non-member for `authenticated`/`public` access-rule cases (issue #55) |
| `fixture-pending` | e-mail not verified | unverified-login cases |
| Username | State | Purpose |
| ------------------ | ------------------- | ---------------------------------------------------------------------------------------- |
| `fixture-admin` | active, Site Admin | admin UI/permissions cases |
| `fixture-user` | active | regular journeys, settings, sessions |
| `fixture-editor` | active | second regular account for the collab-permissions pack (reader/editor of another's pond) |
| `fixture-viewer` | active | signed-in non-member for `authenticated`/`public` access-rule cases (issue #55) |
| `fixture-outsider` | active | the "foreign user" of the permission matrix — member of nothing (issue #60) |
| `fixture-pending` | e-mail not verified | unverified-login cases |
## Permission matrix (`permission-matrix.spec.ts`, issue #60)
The cross-feature permission hardening pack pins the security-relevant
**subject × surface** combinations against regressions. It is API-level (the
UI adds nothing over the resolved status code) and enforces the 404-vs-403
policy: an unauthorized _read_ is 404 (existence hidden), an unauthorized
_write_ on something readable is 403.
- **Subjects:** site admin (`fixture-admin`), pond admin / owner
(`fixture-user`), editor (`fixture-editor`), the same editor _label-restricted_
by a `secret`-label deny, reader (`fixture-viewer`), public (anonymous), and
the foreign user (`fixture-outsider`, a member of nothing).
- **Surfaces:** page read, edit (collab-token `rw`/`ro`), sidebar list, search,
versions (history = write), media, and the public HTML endpoint.
- **Extending it:** a new permission-touching feature adds a surface here (one
`expect` row per subject) rather than a bespoke test, so the matrix stays the
one place the policy is pinned. A weakened guard is caught here — verified by
temporarily loosening a route decorator and watching the pack go red.
## Content fixtures

View File

@ -0,0 +1,208 @@
import { expect, request, test } from '@playwright/test';
import type { APIRequestContext, BrowserContext } from '@playwright/test';
import { contextForUser } from './helpers';
const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:5173';
/**
* Cross-feature permission hardening matrix (issue #60). Permissions cut across
* every M2M5 feature; this pack pins the security-relevant subject × surface
* combinations so a weakened guard is caught. It is intentionally API-level
* (the UI adds nothing over the resolved status codes) and covers the 404-vs-403
* policy: an unauthorized read reads as 404 (existence hidden), an unauthorized
* write on something readable is 403.
*
* Subjects: site admin, pond admin (owner), editor, label-restricted editor
* (same account, blocked on a "secret" label), reader, public (anonymous),
* foreign (signed-in non-member). Surfaces: page read, edit (collab token
* mode), sidebar list, search, versions, media, public HTML.
*/
interface Fixture {
owner: BrowserContext;
editor: BrowserContext;
reader: BrowserContext;
outsider: BrowserContext;
admin: BrowserContext;
anon: APIRequestContext;
pondId: string;
pondSlug: string;
normalPage: { id: string; slug: string };
secretPage: { id: string; slug: string };
publicPage: { id: string; slug: string };
mediaId: string;
}
let f: Fixture;
async function json<T>(ctx: APIRequestContext, path: string, data: unknown): Promise<T> {
const res = await ctx.post(path, { data });
if (!res.ok()) throw new Error(`POST ${path}${res.status()} ${await res.text()}`);
return (await res.json()) as T;
}
const status = (ctx: APIRequestContext, path: string): Promise<number> =>
ctx.get(path).then((r) => r.status());
/** Collab-token access as a single label: 'rw' | 'ro' | the HTTP status. */
const tokenMode = async (ctx: APIRequestContext, id: string): Promise<string | number> => {
const res = await ctx.get(`/api/v1/pages/${id}/collab-token`);
return res.ok() ? ((await res.json()) as { mode: string }).mode : res.status();
};
/** Number of pages the sidebar list surfaces (or the HTTP status on denial). */
const listCount = async (ctx: APIRequestContext, pondId: string): Promise<number> => {
const res = await ctx.get(`/api/v1/ponds/${pondId}/pages`);
return res.ok() ? ((await res.json()) as unknown[]).length : res.status();
};
test.beforeAll(async ({ browser }) => {
const owner = await contextForUser(browser, BASE_URL, 'fixture-user');
const editor = await contextForUser(browser, BASE_URL, 'fixture-editor');
const reader = await contextForUser(browser, BASE_URL, 'fixture-viewer');
const outsider = await contextForUser(browser, BASE_URL, 'fixture-outsider');
const admin = await contextForUser(browser, BASE_URL, 'fixture-admin');
const anon = await request.newContext({ baseURL: BASE_URL });
const idOf = async (c: BrowserContext): Promise<string> =>
((await (await c.request.get('/api/v1/auth/me')).json()) as { id: string }).id;
const editorId = await idOf(editor);
const pond = await json<{ id: string; slug: string }>(owner.request, '/api/v1/ponds', {
name: `Matrix ${Date.now()}`,
});
await json(owner.request, `/api/v1/ponds/${pond.id}/members`, {
usernameOrEmail: 'fixture-editor',
role: 'editor',
});
await json(owner.request, `/api/v1/ponds/${pond.id}/members`, {
usernameOrEmail: 'fixture-viewer',
role: 'reader',
});
const secretLabel = await json<{ id: string }>(owner.request, `/api/v1/ponds/${pond.id}/labels`, {
name: 'secret',
});
const mk = async (title: string): Promise<{ id: string; slug: string }> =>
json(owner.request, `/api/v1/ponds/${pond.id}/pages`, { title });
const normalPage = await mk('Normal');
const secretPage = await mk('Secret');
const publicPage = await mk('Public');
await json(owner.request, `/api/v1/pages/${secretPage.id}/labels`, { labelId: secretLabel.id });
// The editor is blocked on secret-labelled pages; the public page is open to all.
await json(owner.request, `/api/v1/ponds/${pond.id}/grants`, {
subjectType: 'user',
subjectId: editorId,
role: 'editor',
scopeType: 'label',
scopeId: secretLabel.id,
effect: 'deny',
});
await json(owner.request, `/api/v1/ponds/${pond.id}/grants`, {
subjectType: 'public',
role: 'reader',
scopeType: 'page',
scopeId: publicPage.id,
effect: 'allow',
});
// A version on the normal page (history = write permission) and one attachment.
await json(owner.request, `/api/v1/pages/${normalPage.id}/versions`, { label: 'v1' });
// A tiny real PNG — uploads are restricted to image types.
const png = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
'base64',
);
const upload = await owner.request.post(`/api/v1/ponds/${pond.id}/files`, {
multipart: { file: { name: 'a.png', mimeType: 'image/png', buffer: png } },
});
if (!upload.ok()) throw new Error(`upload failed: ${upload.status()} ${await upload.text()}`);
const mediaId = ((await upload.json()) as { id: string }).id;
f = {
owner,
editor,
reader,
outsider,
admin,
anon,
pondId: pond.id,
pondSlug: pond.slug,
normalPage,
secretPage,
publicPage,
mediaId,
};
});
test.afterAll(async () => {
await Promise.all([
f.owner.close(),
f.editor.close(),
f.reader.close(),
f.outsider.close(),
f.admin.close(),
f.anon.dispose(),
]);
});
test('page read & edit — the 404-vs-403 policy holds per subject', async () => {
const req = (c: BrowserContext | APIRequestContext) => ('request' in c ? c.request : c);
// Normal (member-only) page.
expect(await status(req(f.admin), `/api/v1/pages/${f.normalPage.id}`)).toBe(200);
expect(await tokenMode(req(f.admin), f.normalPage.id)).toBe('rw');
expect(await tokenMode(req(f.owner), f.normalPage.id)).toBe('rw');
expect(await tokenMode(req(f.editor), f.normalPage.id)).toBe('rw');
expect(await tokenMode(req(f.reader), f.normalPage.id)).toBe('ro'); // reader: readable, not writable
expect(await status(req(f.outsider), `/api/v1/pages/${f.normalPage.id}`)).toBe(404); // foreign hidden
expect(await tokenMode(req(f.outsider), f.normalPage.id)).toBe(404);
// Secret page: the label-deny hides it from the editor, reader still reads it.
expect(await tokenMode(req(f.editor), f.secretPage.id)).toBe(404); // deny → hidden
expect(await tokenMode(req(f.reader), f.secretPage.id)).toBe('ro');
expect(await tokenMode(req(f.owner), f.secretPage.id)).toBe('rw');
// Public page: open to the anonymous visitor and the foreign user alike.
expect(await tokenMode(f.anon, f.publicPage.id)).toBe('ro');
expect(await tokenMode(req(f.outsider), f.publicPage.id)).toBe('ro');
});
test('sidebar list is filtered to each subjects visible pages', async () => {
expect(await listCount(f.admin.request, f.pondId)).toBe(3);
expect(await listCount(f.owner.request, f.pondId)).toBe(3);
expect(await listCount(f.reader.request, f.pondId)).toBe(3);
expect(await listCount(f.editor.request, f.pondId)).toBe(2); // secret hidden
expect(await listCount(f.outsider.request, f.pondId)).toBe(1); // only the public page
// Anonymous cannot hit the authenticated list endpoint at all.
expect(await listCount(f.anon, f.pondId)).toBe(401);
});
test('search is permission-filtered; anonymous cannot search', async () => {
const hits = async (c: APIRequestContext): Promise<number> => {
const res = await c.get(`/api/v1/search?q=Secret&pondId=${f.pondId}`);
return res.ok() ? ((await res.json()) as unknown[]).length : res.status();
};
expect(await hits(f.owner.request)).toBeGreaterThan(0); // owner finds "Secret"
expect(await hits(f.editor.request)).toBe(0); // editor denied that page → no hit
expect(await hits(f.anon)).toBe(401);
});
test('versions require write; media follows page read; public HTML honours grants', async () => {
const versions = (c: APIRequestContext, id: string) => status(c, `/api/v1/pages/${id}/versions`);
expect(await versions(f.owner.request, f.normalPage.id)).toBe(200);
expect(await versions(f.editor.request, f.normalPage.id)).toBe(200);
expect(await versions(f.reader.request, f.normalPage.id)).toBe(403); // readable, not writable → 403
expect(await versions(f.outsider.request, f.normalPage.id)).toBe(404); // not readable → 404
// Media is permission-checked: a member reads the pond's attachment. (The
// "no grant → 404" negative for media lives in the public pack, issue #56.)
expect(await status(f.owner.request, `/api/v1/media/${f.mediaId}`)).toBe(200);
expect(await status(f.reader.request, `/api/v1/media/${f.mediaId}`)).toBe(200);
// Public HTML endpoint: only the public page, only where the grant reaches.
expect(await status(f.anon, `/api/v1/public/${f.pondSlug}/${f.publicPage.slug}`)).toBe(200);
expect(await status(f.anon, `/api/v1/public/${f.pondSlug}/${f.normalPage.slug}`)).toBe(404);
});