Skip to content

feat(identity,tenancy): invite-only login, companies and forced RLS - #31

Merged
Fluory merged 13 commits into
mainfrom
claude/feat-identity-tenancy-4
Sep 23, 2026
Merged

Fluory merged 13 commits into
mainfrom
claude/feat-identity-tenancy-4

Conversation

@Fluory

@Fluory Fluory commented Sep 22, 2026 •

Copy link
Copy Markdown
Owner

Warum

Fixes #4 · Teil von Epic #2 · gestapelt auf #21 (Basis claude/chore-app-skeleton-3).

Arbeitsstand

  • Ziel: Login nur mit Einladung, jede:r Nutzer:in gehört zu genau einer Firma (Better-Auth-Organisation), jede mandantenbezogene Abfrage läuft in withTenant unter erzwungener RLS.
  • Nicht-Ziele: SSO/Entra, Magic Link, Rollen-Admin-UI (nur Einladungsformular + Seed-Skript).
  • Erledigt: alle Akzeptanzkriterien mit Tests; frischer Review + Security-Review eingearbeitet (Kommentar); pnpm verify lokal grün; CI check + compose-smoke grün.
  • Offen: menschliche Freigabe (Auth, Tenancy, Migration); Entscheidungen in feat(identity,tenancy): invite-only login, companies and forced RLS #4 (decision-needed).
  • Annahmen: siehe Impact Manifest.
  • Nächster kleinster Schritt: Review durch den Orchestrator nach dem Merge von chore(app): TS app skeleton, docker compose and verify commands #21.

Was ist passiert (Klartext)

Mitarbeitende melden sich jetzt mit E-Mail und Passwort an – aber nur, wenn sie eingeladen wurden. Eine Administratorin legt auf /invite eine Einladung an und gibt den Link weiter; ohne diesen Link entsteht kein Konto, auch wenn jemand die richtige Adresse kennt. Jede Person gehört zu genau einer Firma und hat dort die Rolle „Administration" oder „Sachbearbeitung".

Die Firmendaten sind doppelt getrennt: Der Code fragt nur im Rahmen der eigenen Firma ab (withTenant), und die Datenbank selbst verweigert alles andere per Row-Level Security – auch wenn im Code ein Filter fehlen würde. Tests beweisen das mit zwei Firmen, über die normalen Funktionen und über rohes SQL. Anmeldeversuche sind begrenzt (Rate Limit in der Datenbank).

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: identity (Better Auth, authorize(), Einladung, getActor), tenancy (withTenant, RLS), requests (erste Mandantentabelle), db (Schema auth + app.requests, Migrationen 0001/0002), app (Login, Registrierung per Link, Einladung, /api/auth/*), config (Auth-Secret, APP_ENV, IP-Quelle), src/seed.ts.
  • Schnittstellen / Datenänderungen: Schema auth (Better-Auth-Tabellen, UUID-IDs, Unique member.user_id) ohne RLS (Ausnahmenregister); app.requests mit ENABLE + FORCE RLS, Policy company_id = nullif(current_setting('app.company_id', true), '')::uuid; Endpunkte /api/auth/*; Seiten /login, /signup?invitation=…, /invite.
  • Akzeptanzkriterien: alle aus feat(identity,tenancy): invite-only login, companies and forced RLS #4 (Nachweis unten).
  • Testplan: Unit test-first für authorize(); Integration über auth.handler (HTTP) und rohes SQL als app_rw.
  • Verifizierte Fakten: better-auth 1.7.5 und @better-auth/drizzle-adapter 1.7.5 (MIT, Peers passen); Schema per Better-Auth-CLI (auth@1.7.5 generate) erzeugt; abgelehnte Registrierung liefert bewusst dieselbe generische Antwort wie eine erfolgreiche (Anti-Enumeration) – „abgelehnt" wird über die Wirkung geprüft; nach verweigertem set-active leert Better Auth activeOrganizationId.
  • Offene Annahmen: eine Firma pro Person (siehe decision-needed in feat(identity,tenancy): invite-only login, companies and forced RLS #4).
  • Nicht-Ziele: SSO, Magic Link, Rollen-Admin-UI (feat(identity): simple user and role management for admins #30), E-Mail-Versand der Einladung.
  • Risiken und Rollback: Auth-Konfiguration liegt bei uns → Security-Review durchgeführt; Rate Limit braucht einen Proxy mit vertrauenswürdigem IP-Header (operations.md); Migrationen additiv, Rollback per Revert + lokal docker compose down -v.

Akzeptanzkriterien → Nachweis

Kriterium Test
Ohne Einladung wird die Registrierung abgelehnt identity.test.ts: kein Nutzer, kein Token, kein Login; auch mit falscher/fremder Einladungs-ID
Eingeladene Person → Session trägt aktive Firma identity.test.ts („session carries their active company and role")
withTenant setzt app.company_id transaktionslokal; Repositories nur mit Tenant-Kontext tenancy.test.ts (Pool-Reuse mit max: 1, Repository ohne Tenant → MissingTenantError, Nicht-UUID abgelehnt); Typ-Brand TenantTx
Firma A sieht keine Zeile von B – Repository und rohes SQL als app_rw tenancy.test.ts (Repository, rohes SQL mit/ohne Kontext)
Insert mit fremder Firma wird von RLS abgelehnt tenancy.test.ts (Insert und Update auf fremde company_id → RLS-Fehler)
Rollen admin/clerk; authorize() verweigert Admin-Aktionen für Clerks authorize.test.ts (test-first) + HTTP-Test Plugin-Einladung durch Clerk → 403
Better Auth ≥ 1.7.5 mit Drizzle-Adapter; Rate Limit in DB; nur Organization + Admin package.json; identity.test.ts (429 + Zeile in auth.rate_limit); Konfiguration auth.ts

Geändert

  • src/features/identity/: auth.ts (Better Auth: Einladungs-Gate mit ID, Session-Hook, Rollen-Hooks, IP-Quelle), access.ts, authorize.ts (+Test), companies.ts (Firma anlegen, einladen), actor.ts.
  • src/features/tenancy/with-tenant.ts, src/features/requests/repository.ts.
  • src/db/schema/{auth,app,index}.ts, Migrationen 0001_identity_tenancy.sql (generiert, angepasst), 0002_auth_grants_force_rls.sql (Grants, FORCE RLS, Unique-Mitgliedschaft).
  • src/app/: /api/auth/[...all], /login, /signup, /invite, Startseite, _server/runtime.ts, _components/auth-form.tsx; src/seed.ts (pnpm seed:demo).
  • src/config/env.ts (+Tests): BETTER_AUTH_*, APP_ENV, AUTH_IP_HEADERS, AUTH_TRUSTED_PROXIES; .env.example, compose.yaml, vitest.config.ts.
  • Tests: tests/integration/{identity,tenancy}.test.ts, helpers/stack.ts.
  • Doku: docs/technical/data-model.md (neu, mit Klassifikation), Architekturkarte (Status, Ausnahme Admin-Plugin), operations.md (Rate Limit/Proxy, Einladung, Recovery), CHANGELOG.

Nachweis (SYSTEM.md §11)

  • verify:changed: grün
  • verify: grün – lokal: lint, typecheck, 23 Unit + 32 Integrationstests gegen echtes PostgreSQL 17, depcruise 0 Verstöße, build, audit 0 high; CI check + compose-smoke grün: https://github.com/Fluory/RequestFlow/actions/runs/35825480993
  • verify:full / E2E-Spec: nicht betroffen
  • Manueller Prüfnachweis: pnpm seed:demo → Standalone-Server: Admin-Login 200, Startseite zeigt Firma + „Mitarbeitende einladen", /invite als Admin 200, als Clerk 404, anonym → Redirect /login.
  • Frischer Review (P1 vor Ready-for-review; Architektur/API/DB immer): feat(identity,tenancy): invite-only login, companies and forced RLS #31 (comment) – Blocker und alle Important behoben; Security-Review Teil davon

Doku-Entscheidung (genau eine)

  • Keine langlebige Doku betroffen – Begründung: –
  • Doku betroffen und im selben PR aktualisiert:
    • Produktdoku (P0: README): –
    • Technische Doku: docs/technical/data-model.md (neu, mit Datenklassifikation), docs/technical/operations.md
    • Architekturkarte: docs/technical/architecture.md (Status identity/tenancy/requests, Ausnahmenregister)
    • 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:

Subagent-Einsätze

  • Frischer Review + Security-Review: unabhängiger Subagent (read-only) nach review-pr und .claude/rules/security.md – 1 Blocker, 3 Important, 4 Notes, alle bearbeitet.

Risiken / offene Punkte


🤖 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
- 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
…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

Fluory commented Sep 23, 2026

Copy link
Copy Markdown
Owner Author

Frischer Review + Security-Review (unabhängiger Subagent) – Befunde und Auflösung

Review nach .claude/skills/review-pr/SKILL.md plus Sicherheitsregel (.claude/rules/security.md), read-only gegen die Better-Auth-1.7.5-Quellen. Auflösung in a8a2808 und b7c1b2e (siehe Commits).

# Befund Auflösung
Blocker (security) Einladung nur an eine E-Mail gebunden → wer zuerst mit einer (erratbaren) eingeladenen Adresse registriert, übernimmt die Rolle (Firmenübernahme beim ersten Admin) behoben – Registrierung verlangt die Einladungs-ID (zufällige UUID, als Link übergeben) und die eingeladene Adresse; Tests: ohne ID, falsche ID, ID einer anderen Adresse → kein Konto
Important (security) Rate Limit vertraut client-gesetztem x-forwarded-for; hinter Proxy ein gemeinsamer Bucket Client-IP-Quelle konfigurierbar (AUTH_IP_HEADERS, AUTH_TRUSTED_PROXIES), Deployment-Regel in operations.md (Proxy davor); Test-Grenze im Test dokumentiert; Pro-Konto-Limit als Folgepunkt
Important Mehrere Mitgliedschaften möglich, limit(1) ohne Ordnung behoben – Unique-Index member(user_id), deterministische Abfrage, Test
Important PR-Beschreibung war noch der Plan-Stand behoben (diese Aktualisierung)
Note (security) Plugin akzeptierte Standardrollen owner/member und Rollenlisten behoben – organizationHooks lassen nur admin/clerk zu; Test
Note /invite: Clerk-Aufruf der Action → 500; schwache E-Mail-Prüfung behoben – 404 für Clerks, zod-Validierung; Seite zeigt den Einladungslink
Note Plugin-Garantien ohne Tests (set-active fremd, list-members fremd, letzter Admin) behoben – HTTP-Tests über auth.handler. Dabei gefunden: Better Auth leert nach verweigertem Wechsel activeOrganizationId; getActor löst die Firma daher aus der (eindeutigen) Mitgliedschaft auf und scheitert geschlossen bei Abweichung
Note (security) Öffentlich bekanntes Platzhalter-Secret als Fallback behoben – explizites APP_ENV (local/showcase/production); außerhalb local wird das Platzhalter-Secret abgelehnt; Test. (Erster Versuch über NODE_ENV brach den Compose-Stack, weil das Image NODE_ENV=production setzt – mit compose-smoke erkannt und korrigiert.)

Admin-Plugin: bleibt laut ADR D6 eingebunden, niemand hält die Plattform-Admin-Rolle (Endpunkte lehnen jeden ab, getestet) – als Ausnahme mit Ablauf „#30" im Register eingetragen.

Verdict des Reviewers: nach Behebung mergebar, menschliche Freigabe nötig (Auth + Tenancy + Migration).


Generated by Claude Code

@Fluory
Fluory marked this pull request as ready for review September 23, 2026 06:13
@Fluory Fluory mentioned this pull request Sep 23, 2026
7 tasks done
@Fluory
Fluory changed the base branch from claude/chore-app-skeleton-3 to main September 23, 2026 09:59
@Fluory
Fluory merged commit 92b3e07 into main Sep 23, 2026
6 of 8 checks passed
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(identity,tenancy): invite-only login, companies and forced RLS

2 participants