Skip to content

feat(tenancy): guard test – every app table has forced RLS - #37

Merged
Fluory merged 97 commits into
mainfrom
claude/feat-rls-guard-29
Sep 23, 2026
Merged

Fluory merged 97 commits into
mainfrom
claude/feat-rls-guard-29

Conversation

@Fluory

@Fluory Fluory commented Sep 23, 2026 •

Copy link
Copy Markdown
Owner

Warum

Fixes #29 · Epic #18 · Basis main (#36 ist gemergt; vorher gestapelt auf claude/feat-erp-export-9).

Arbeitsstand

  • Ziel: CI schlägt fehl, sobald eine Tabelle im Schema app ohne erzwungene RLS oder ohne Firmen-Policy existiert.
  • Nicht-Ziele: bestehende Policies ändern.
  • Erledigt: Regeln + leere Allow-List, Unit-Tests der Regeln, Integrations-Guard gegen echten Katalog inkl. Probe (ungeschützte Tabelle, halbe RLS, Materialized View, Definer-View), Schema-Liste, Rollenprüfung app_rw, Doku, frischer Review inkl. Fixes.
  • Offen: Merge (freigegeben durch den Orchestrator, Reihenfolge des Stapels).
  • Annahmen: siehe Impact Manifest.
  • Nächster kleinster Schritt: Merge nach grünem check gegen main; danach feat(identity): simple user and role management for admins #38.

Was ist passiert (Klartext)

Ein automatischer Wächter prüft bei jedem PR alle Tabellen mit Firmendaten: Jede muss die Firmen-Trennung erzwingen (Spalte company_id, erzwungene Zeilensicherheit, nur die Firmen-Regel). Auch Konstrukte, die die Trennung umgehen würden – Materialized Views, fremde Tabellen, Views mit Eigentümerrechten, neue Datenbank-Schemas – lassen den Build rot werden. Außerdem prüft er, dass die App wirklich mit der eingeschränkten Rolle app_rw läuft. Der Beweis, dass der Wächter anschlägt, steckt im Test selbst: Er legt absichtlich ungeschützte Tabellen an (und rollt sie zurück) und erwartet die Meldungen. Laufzeitverhalten ändert sich nicht.

Plan-Pflicht (SYSTEM.md §4)

  • Kein Auslöser – keine Modulgrenze, öffentliche API, Migration, Auth/Rechte, kein Zahlungs-/Daten-/Infrapfad, höchstens zwei Module, keine Architekturvarianten, umkehrbar
  • Auslöser zutreffend – Impact Manifest ausgefüllt (Plan vor Code)

Impact Manifest

  • Betroffene Module: tenancy (Guard-Regeln + Katalogabfrage + Allow-List, über die öffentliche API exportiert), Tests (tests/integration/rls-guard.test.ts, src/features/tenancy/rls-guard.test.ts), Kommentar in src/db/schema/app.ts.
  • Schnittstellen / Datenänderungen: keine Migration, keine Laufzeitänderung; nur Tests + reine Funktion.
  • Akzeptanzkriterien: alle aus feat(tenancy): guard test – every app table has forced RLS #29 – siehe Nachweis.
  • Testplan: Unit – Regeln; Integration – alle app-Relationen geschützt; Probe mit zurückgerollten ungeschützten Objekten; Allow-List dokumentiert und aktuell; bekannte Schemas; app_rw-Rolle.
  • Verifizierte Fakten: pg_policies.qual = (company_id = (NULLIF(current_setting('app.company_id'::text, true), ''::text))::uuid) für alle 8 Tabellen (PG17); Integrationsprojekt läuft seriell (fileParallelism: false).
  • Offene Annahmen: bewusst streng – auch restriktive/INSERT-only-Policies mit anderem Ausdruck werden gemeldet (fail-safe).
  • Nicht-Ziele: siehe oben.
  • Risiken und Rollback: nur Tests; Rollback per Revert.

Geändert

  • src/features/tenancy/{rls-guard.ts,rls-guard.test.ts,index.ts}, src/db/schema/app.ts (Kommentar)
  • tests/integration/rls-guard.test.ts
  • Doku: docs/technical/architecture.md (Tenancy-Status, Hinweis am Ausnahmenregister), CHANGELOG.md

Nachweis (SYSTEM.md §11)

  • verify:changed: typecheck + lint + Unit 6/6 + Integration Guard 5/5 grün
  • verify: lokal grün (Exit 0, Node 24) gegen echtes PostgreSQL 17 + SeaweedFS – Unit 127/127, Integration 86/86, depcruise ohne Verstöße, Build, Audit (1 moderate, Schwelle high); CI check: siehe Checks dieses PRs
  • verify:full / E2E-Spec: nicht betroffen (keine UI-/Laufzeitänderung)
  • Akzeptanzkriterien feat(tenancy): guard test – every app table has forced RLS #29: Katalog-Test über alle app-Tabellen (RLS an + erzwungen + Firmen-Policy) → „has company_id, forced row-level security and only company policies"; dokumentierte Allow-List (leer, Grund Pflicht) → GLOBAL_APP_TABLES + Test „keeps the allow-list … documented and current" + Hinweis im Ausnahmenregister; läuft in verify → Integrationsprojekt; Probe beweist das Anschlagen → „fails for a deliberately unprotected table"
  • Manueller Prüfnachweis: –
  • Frischer Review (P1 vor Ready-for-review; Architektur/API/DB immer): unabhängiger Subagent inkl. Security-Regel – 0 Blocker, 3 should-fix + 4 nits, alle behoben (PR-Kommentar)

Doku-Entscheidung (genau eine)

  • Keine langlebige Doku betroffen – Begründung: –
  • Doku betroffen und im selben PR aktualisiert:
    • Produktdoku (P0: README): –
    • Technische Doku: –
    • Architekturkarte: docs/technical/architecture.md
    • ADR: –
    • CHANGELOG [Unreleased] (sichtbares Feature oder Verhalten – im selben PR, nie „später")

Entferntes oder Umbenanntes: nichts entfernt

Dateigrößen und neue Bausteine (SYSTEM.md §7)

Dateien über 500 Zeilen im Diff (Ausnahmen: generierter Code, Lockfiles, Fixtures, Migrationen, Schemas, Ressourcen, Doku, Konfiguration):

  • keine
  • bewusst belassen – Begründung: –
  • im selben PR nach fachlicher Verantwortung geteilt
  • Folge-Issue –

Über 800 Zeilen mit neuer Fachlogik oder über 1000 Zeilen (P1/P2): nicht betroffen

Neue Shared-Komponente, Utility-Datei, Adapter oder fachlicher Service:

  • nein
  • ja – gesucht nach: –

Subagent-Einsätze

  • Frischer Review (read-only): Ergebnis und Auflösung als PR-Kommentar.

Risiken / offene Punkte

  • Der Wächter ist bewusst streng; ein bewusst globales Objekt braucht Allow-List-Eintrag + Registereintrag.

🤖 Generated with Claude Code

https://claude.ai/code/session_01DJ5vaKvTYiMvdngT4d3xo1

…ion access control

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DJ5vaKvTYiMvdngT4d3xo1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DJ5vaKvTYiMvdngT4d3xo1
…LS with integration proofs

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DJ5vaKvTYiMvdngT4d3xo1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DJ5vaKvTYiMvdngT4d3xo1
…boss queues

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DJ5vaKvTYiMvdngT4d3xo1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DJ5vaKvTYiMvdngT4d3xo1
…nd download routes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DJ5vaKvTYiMvdngT4d3xo1
…-only audit, composite FK

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DJ5vaKvTYiMvdngT4d3xo1
- sign-up requires the invitation id (link) plus the invited e-mail – no takeover by address alone
- one company per user (unique index), deterministic membership lookup, actor from membership
- organization plugin accepts only admin/clerk roles
- configurable client-IP source for the auth rate limit; local secret refused in production
- invite page: zod input, 404 for clerks, shows the invitation link; signup needs the link
- tests: wrong/missing invitation id, foreign set-active/list-members, last admin, roles
- docs: operations (rate limit/proxy, recovery), data model, exceptions register (admin plugin)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DJ5vaKvTYiMvdngT4d3xo1
…e rejection

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DJ5vaKvTYiMvdngT4d3xo1
… docs for #5

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DJ5vaKvTYiMvdngT4d3xo1
…secret

The image sets NODE_ENV=production, so the previous check blocked the local compose stack.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DJ5vaKvTYiMvdngT4d3xo1
…re script

Python 3.13 package requestflow_ai (src layout), docling/fastapi/google-genai
pinned, CPU-only torch via the PyTorch CPU index, dev tools ruff/pyright/pytest.
The synthetic PDF fixture is generated by scripts/make_fixtures.py (reportlab).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DJ5vaKvTYiMvdngT4d3xo1
Test-first per ADR-0001 D8: quote normalisation (whitespace, case, NFKC,
hyphenation), German number and date formats, and the verifier rules that
turn unsupported model claims into unverified. Also adds the segment and
model-output types the tests build on; the verifier does not exist yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DJ5vaKvTYiMvdngT4d3xo1
normalize_text folds NFKC, soft hyphens, line-break hyphenation, typographic
dashes/quotes, whitespace and case. values parses German/ISO numbers and
DD.MM.YYYY, D.M.YY and ISO dates. verify_field only keeps or downgrades the
model's status: a quote not in the cited segment, an unknown segment, a value
inconsistent with the quote, found without evidence or value, and missing
with a value all become unverified with a reason.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DJ5vaKvTYiMvdngT4d3xo1
…ction (red)

EML: body lines with 1-based line locators, From/Subject header segments,
RFC 2047 and quoted-printable decoding, HTML-only bodies, header newline
collapse, no attachment payloads. PDF: textline segments with page + top-left
bbox via docling-parse (model-free); the layout-pipeline test only runs with
AI_TEST_DOCLING_MODELS=1. Detection by magic bytes; .msg rejected for now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DJ5vaKvTYiMvdngT4d3xo1
EML via the standard library email package (policy=default): From/Subject
header segments and one segment per non-empty body line, HTML-only bodies
reduced to text lines. docling's EMAIL backend was checked but emits
paragraphs without provenance, so it cannot give line locators.

PDF via docling: the default textlines pipeline reads docling-parse text
lines (page + top-left bbox, no ML models, no network); the opt-in layout
pipeline uses DocumentConverter (OCR and tables off) and the heron layout
model. docling is imported lazily.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DJ5vaKvTYiMvdngT4d3xo1
…ex model client (red)

The model client is exercised through the real google-genai SDK with an
httpx MockTransport replaying recorded generateContent bodies: eu multi-region
URL, bearer auth, structured-output config, token usage, fail-closed init
(no project, no credentials, dev flag without key), no silent switch to API
key mode, and schema-invalid output. Adds the env-based Settings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DJ5vaKvTYiMvdngT4d3xo1
…lient

Prompt extract_header_v1.md (system instruction) plus render_document, which
puts segment-id-prefixed lines between <document> delimiters and neutralises
delimiter-like tags inside the document. GeminiModelClient calls Vertex via
google-genai with response_schema = ModelExtraction, JSON mime type,
temperature 0 and no tools; schema-invalid output raises ModelOutputError.
build_model_client needs VERTEX_PROJECT and credentials, loads ADC eagerly,
refuses API-key mode for Vertex, and allows the Gemini API only with
AI_ALLOW_GEMINI_API_DEV=true plus GEMINI_API_KEY.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DJ5vaKvTYiMvdngT4d3xo1
Pipeline: PDF and EML end to end with recorded model responses, a model date
contradicting its own quote, the prompt-injection mail (the recorded model
obeys and invents a quote -> unverified), and the no-text PDF that skips the
model. API: bearer auth (401 + WWW-Authenticate), response shape, request id
handling, 400/413/415/422/429/502 mapping without echoing input, JSON logs
with IDs only. Contract: the committed OpenAPI file equals the app's schema.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DJ5vaKvTYiMvdngT4d3xo1
…es; refuse overlong reasons; cross-tenant correction tests
…E smoke ends at Exportiert; compose + env wiring
…adable body retryable, timing-safe mock token, faults and timeout checked at start, skip reasons logged; docs
… only company policies; probe proves it bites
…iner views, pins known schemas, checks app_rw membership and runtime user

Fluory commented Sep 23, 2026

Copy link
Copy Markdown
Owner Author

Frischer Review (unabhängiger Subagent, read-only, review-pr + Security-Regel)

Ergebnis: 0 Blocker · 3 should-fix · 4 nits. Bestätigt: Ausdruck stimmt exakt mit PG17 pg_policies für alle 8 Tabellen; Partitionen abgedeckt; USING-only-ALL-Policy korrekt; zusätzliche permissive Policies werden gemeldet; Probe-DDL in begin/rollback, Integrationstests laufen seriell; app_rw ohne BYPASSRLS/Superuser/Eigentum.

# Schwere Befund Auflösung
1 should-fix Materialized Views/Foreign Tables (und Views) in app wurden nicht geprüft – können keine RLS tragen Abfrage umfasst r,p,m,f,v; m/f immer Verstoß, Views nur mit security_invoker; Probe legt zusätzlich eine MV und eine Definer-View an und erwartet beide Meldungen
2 should-fix Neue Schemas / public fielen nicht auf Test: Relationen nur in app, auth, drizzle, pgboss (Ausnahmen laut Register)
3 should-fix Keine Prüfung der Rollenmitgliedschaft / des tatsächlichen Laufzeit-Users pg_has_role('app_rw','app_owner','member') = false und current_user über DATABASE_URL = app_rw
4 nit in trifft geerbte Keys Object.hasOwn
5 nit Veraltete Allow-List-Einträge fielen nicht auf Test: jeder Eintrag existiert im Katalog
6 nit INSERT-only/restriktive Policies werden (fail-safe) gemeldet, ohne Begründung bewusst streng, Kommentar; Meldung enthält jetzt den erwarteten Ausdruck
7 nit Ausdruck doppelt gepflegt ohne Hinweis Querverweise in rls-guard.ts und src/db/schema/app.ts

pnpm verify danach lokal grün (Unit 127/127, Integration 86/86).


Generated by Claude Code

@Fluory
Fluory marked this pull request as ready for review September 23, 2026 07:38
@Fluory
Fluory changed the base branch from claude/feat-erp-export-9 to main September 23, 2026 10:00
@Fluory
Fluory merged commit aface92 into main Sep 23, 2026
8 of 10 checks passed
Fluory added a commit that referenced this pull request Sep 28, 2026
…ate, epic evidence

- README: forced RLS on every company-data table (schema app), not "every table"
- architecture map: audit and app are partial – the current-state line says so
- release date 2026-09-28 (tag, release and #86), CHANGELOG heading included
- roadmap: #37 belongs to epic #18; M1 names how the slice demo was proven
  (native services locally, compose-smoke + Playwright smoke in CI)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(tenancy): guard test – every app table has forced RLS

2 participants