Skip to content

feat(identity): simple user and role management for admins - #38

Merged
Fluory merged 102 commits into
mainfrom
claude/feat-user-management-30
Sep 23, 2026
Merged

Fluory merged 102 commits into
mainfrom
claude/feat-user-management-30

Conversation

@Fluory

@Fluory Fluory commented Sep 23, 2026 •

Copy link
Copy Markdown
Owner

Warum

Fixes #30 · Epic #18 · gestapelt auf #37 (Basis claude/feat-rls-guard-29).

Arbeitsstand

  • Ziel: Firmen-Admins verwalten ihre Nutzer in der App: Liste mit Rolle und Status, einladen, Rolle ändern, deaktivieren (und reaktivieren); jede Änderung auditiert; der letzte Admin bleibt; Sachbearbeitung hat serverseitig keinen Zugriff.
  • Nicht-Ziele: SSO, SCIM, eigene Rollen, firmenübergreifende Verwaltung, E-Mail-Versand von Einladungen.
  • Erledigt: Last-Admin-Regel (Unit), Dienst (Liste, Rolle, Deaktivieren/Reaktivieren, auditierte Einladung), Seite /users mit Server Actions, Better-Auth-Härtung (Creator-Rolle unerreichbar, /organization/* außer set-active abgeschaltet), Tests inkl. Server Actions, Doku inkl. ADR-Nachtrag, Security-Review inkl. Fixes.
  • Offen: Merge durch den Orchestrator nach feat(tenancy): guard test – every app table has forced RLS #37.
  • Annahmen: siehe Impact Manifest.
  • Nächster kleinster Schritt: Review durch den Orchestrator; danach feat(ai-service): full field set including line items #22 auf diesem Branch.

Was ist passiert (Klartext)

Admins haben jetzt eine Seite „Benutzer": Sie sehen alle Personen ihrer Firma mit Rolle und Status (aktiv/deaktiviert) sowie offene Einladungen, ändern Rollen (Administration/Sachbearbeitung), deaktivieren Zugänge (Anmeldung gesperrt, laufende Sitzungen sofort beendet) und aktivieren sie wieder. Jede Änderung – auch jede Einladung – wird protokolliert. Den letzten aktiven Admin kann niemand herabstufen, und sich selbst sperren geht nicht. Sachbearbeiter:innen bekommen die Seite und ihre Aktionen gar nicht zu sehen – das prüft der Server. Sicherheitsfund unterwegs: Die Login-Bibliothek (Better Auth) hat eigene Web-Schnittstellen für Mitglieder; darüber hätten Admins ohne Protokoll Rollen ändern und der letzte Admin die Firma verlassen können, und Sachbearbeiter:innen hätten die Mitgliederliste lesen können. Diese Schnittstellen sind jetzt abgeschaltet; alles läuft über den protokollierten Weg.

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 (Nutzerverwaltung, Last-Admin-Regel, inviteUser auditiert, getActor lehnt gesperrte Nutzer ab, Better-Auth-Konfiguration), audit (Ereignisse user.*, neuer Entity-Typ invitation), tenancy (Audit in withTenant), app (Seite /users, Server Actions, Link auf der Startseite).
  • Schnittstellen / Datenänderungen: keine Migration. Deaktivieren nutzt auth.user.banned/ban_reason (Admin-Plugin) und löscht die Sitzungen; Rollen auf auth.member.role. /api/auth/organization/* außer set-active → 404 (disabledPaths); creatorRole = owner (von niemandem erreichbar).
  • Akzeptanzkriterien: alle aus feat(identity): simple user and role management for admins #30 – siehe Nachweis.
  • Testplan: Unit – Last-Admin-Regel; Integration – Liste nur eigene Firma, Rollenwechsel + Audit, Einladung auditiert (ohne E-Mail), Deaktivieren sperrt Login/Sitzung + Reaktivieren, letzter Admin, keine Selbstsperre, parallele Änderungen, fremde Firma, Clerk über die Server Actions selbst (nur next/headers gefälscht), abgeschaltete Plugin-Endpunkte.
  • Verifizierte Fakten: Better Auth 1.7.5: hasPermission gibt der creatorRole alle Rechte; /organization/leave ohne Rechteprüfung; Admin-Plugin blockiert gesperrte Nutzer beim Session-Erstellen; keine Cookie-Cache-Konfiguration → gelöschte Sitzungen sofort ungültig; member.user_id eindeutig (eine Firma pro Nutzer).
  • Offene Annahmen: Deaktivieren sperrt den Nutzer global (wegen „eine Firma pro Nutzer" gleichbedeutend).
  • Nicht-Ziele: siehe oben.
  • Risiken und Rollback: Autorisierung in jeder Action und im Modul; keine personenbezogenen Daten in Logs; keine Migration; Rollback per Revert.

Geändert

  • src/features/identity/{users,last-admin,companies,actor,access,auth,index}.ts (+ last-admin.test.ts)
  • src/features/audit/audit.ts (Entity-Typ invitation)
  • src/app/users/{page.tsx,actions.ts}, src/app/page.tsx
  • Tests: tests/integration/{users,identity}.test.ts, tests/integration/helpers/stack.ts (Text-404 lesbar)
  • Doku: docs/decisions/ADR-0001-pilot-architecture.md (D6-Nachtrag), docs/technical/{operations,data-model,architecture}.md, CHANGELOG.md

Nachweis (SYSTEM.md §11)

  • verify:changed: typecheck + lint + Unit Last-Admin 4/4 + Integration users/identity 25/25 grün
  • verify: lokal grün (Exit 0, Node 24) gegen echtes PostgreSQL 17 + SeaweedFS – Unit 131/131, Integration 96/96, depcruise ohne Verstöße, Build, Audit (1 moderate, Schwelle high); CI check: siehe Checks dieses PRs
  • verify:full / E2E-Spec: Smoke lokal grün (Login/Session-Pfad nach Auth-Änderung unverändert)
  • Akzeptanzkriterien feat(identity): simple user and role management for admins #30: Admins sehen Nutzer der eigenen Firma mit Rolle/Status → „lists the users of the own company only…"; einladen/Rolle/deaktivieren, jede Änderung auditiert → „changes a role and audits it…", „deactivating ends the sessions…"; letzter Admin nicht entfernbar → Unit Last-Admin + „keeps the last active admin…" + „serialises concurrent changes…" + Plugin-leave/remove-member abgeschaltet; Clerks ohne Zugriff serverseitig → „server actions: a clerk gets 404…" + „refuses … clerks (module)" + list-members abgeschaltet
  • Manueller Prüfnachweis: –
  • Frischer Review (P1 vor Ready-for-review; Architektur/API/DB immer): unabhängiger Security-Review – 1 Blocker, 4 should-fix, 2 nits, alle behoben (PR-Kommentar); Mutationsprobe für den Blocker dokumentiert
  • Hinweis: Für den Review wurden Better-Auth-Quellen in node_modules gelesen (FLUORY_ALLOW_ARTIFACTS=1, Read-Guard) – nötig, um die Rechteprüfung der Plugin-Endpunkte zu belegen.

Doku-Entscheidung (genau eine)

  • Keine langlebige Doku betroffen – Begründung: –
  • Doku betroffen und im selben PR aktualisiert:
    • Produktdoku (P0: README): –
    • Technische Doku: docs/technical/operations.md, docs/technical/data-model.md
    • Architekturkarte: docs/technical/architecture.md (Identity-Status, Registereintrag Admin-Plugin entschieden)
    • ADR: docs/decisions/ADR-0001-pilot-architecture.md – Nachtrag D6 (Rollen-Admin-UI im Pilot, Plugin-Härtung)
    • CHANGELOG [Unreleased] (sichtbares Feature oder Verhalten – im selben PR, nie „später")

Entferntes oder Umbenanntes: nichts entfernt (Plugin-Endpunkte abgeschaltet, nicht 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: vorhandener Nutzer-/Einladungslogik; gefunden: identity/companies.ts (inviteUser), authorize() mit users.manage – erweitert, nicht dupliziert

Subagent-Einsätze

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

Risiken / offene Punkte

  • Better-Auth-Upgrades können neue /organization/*-Endpunkte bringen – die Abschaltliste ist explizit; bei Upgrades die Endpunktliste prüfen (Test deckt die bekannten ab).

🤖 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
…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
…ivate, audited invite); plugin member endpoints closed (creator role unheld)
…cl. actions, concurrency, deactivation; docs
@Fluory Fluory mentioned this pull request Sep 23, 2026
7 tasks done
…active (last admin could leave unaudited, clerks could list members)
…no self-deactivation, ADR-0001 D6 amendment, docs

Fluory commented Sep 23, 2026

Copy link
Copy Markdown
Owner Author

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

Ergebnis: 1 Blocker · 4 should-fix · 2 nits – alle behoben. Bestätigt: kein IDOR (Ziel nur unter den gesperrten Mitgliedern der eigenen Firma), Firma nur aus der Session, UUID/Rollen validiert, Clerk → 404 in Seite und allen Actions, Sperren blockiert Login (Admin-Plugin-Hook) und getActor prüft zusätzlich, kein Deadlock (gleiche Sperrreihenfolge), Rolle owner unerreichbar, Audit in derselben Transaktion.

# Schwere Befund Auflösung
B1 blocker /organization/leave hat keine Rechteprüfung; mit creatorRole: "owner" griff auch der Schutz „letzter Owner" nicht mehr → der letzte Admin konnte die Firma unauditiert verlassen Alle /organization/*-Endpunkte außer set-active per disabledPaths abgeschaltet (404). Test „…the plugin endpoints are disabled" prüft u. a. leave; Mutationsprobe ohne disabledPaths: leave = 200, list-members (Clerk) = 200 → Test rot
S1 should-fix remove-member-Tests nutzten User-ID statt Member-ID (scheiterten aus falschem Grund) Member-ID + exakter Status 404
S2 should-fix E-Mail im append-only Audit (user.invited) nicht löschbar nur { role }, entityType: "invitation" (Invitation-ID verweist auf die E-Mail)
S3 should-fix Clerks konnten per /organization/list-members / get-full-organization alle Kolleg:innen lesen durch B1 abgeschaltet, getestet
S4 should-fix PR-Body veraltet; ADR-0001 D6 sagte „kein Rollen-Admin-UI" Body aktualisiert; datierter Nachtrag in D6
N1 nit Admin konnte sich selbst deaktivieren abgelehnt (SelfDeactivation, Code self), Test; ein anderer Admin darf
N2 nit Admins konnten die Firma per Plugin umbenennen (ohne Audit) durch B1 abgeschaltet, getestet

Danach: pnpm verify lokal grün (Unit 131/131, Integration 96/96), E2E-Smoke grün.


Generated by Claude Code

@Fluory
Fluory marked this pull request as ready for review September 23, 2026 07:46
@Fluory
Fluory changed the base branch from claude/feat-rls-guard-29 to main September 23, 2026 10:00
@Fluory
Fluory merged commit c16ca7b into main Sep 23, 2026
7 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): simple user and role management for admins

2 participants