diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index eecbf30..07f05fa 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -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%';" | \ diff --git a/apps/api/prisma/seed.ts b/apps/api/prisma/seed.ts index f7b01b6..7feceb8 100644 --- a/apps/api/prisma/seed.ts +++ b/apps/api/prisma/seed.ts @@ -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', diff --git a/apps/web/e2e/README.md b/apps/web/e2e/README.md index d17418f..5d7bdb9 100644 --- a/apps/web/e2e/README.md +++ b/apps/web/e2e/README.md @@ -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 diff --git a/apps/web/e2e/permission-matrix.spec.ts b/apps/web/e2e/permission-matrix.spec.ts new file mode 100644 index 0000000..813923b --- /dev/null +++ b/apps/web/e2e/permission-matrix.spec.ts @@ -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 M2–M5 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(ctx: APIRequestContext, path: string, data: unknown): Promise { + 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 => + 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 => { + 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 => { + 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 => + ((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 subject’s 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 => { + 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); +});