Compare commits

..
Author SHA1 Message Date
sysops 314b821929 QA-02: echter End-to-End-Testlauf ueber TEN-01..07/IAM-01..14
Volle Merge-Verifikation aller 21 Vorbedingungs-Tickets: neue
internal/e2e-Tests (Tenant-Onboarding-Login-Flow, Cross-Tenant-Isolation)
gegen echte Postgres-Instanz, docs/QA-02-PRUEFPROTOKOLL.md mit
Ticket-zu-Test-Zuordnung. Dabei echten Merge-Konflikt zwischen IAM-12 und
IAM-13 gefunden und behoben (jwk/jwkSet-Typkollision in internal/oidc).
124/124 Tests gruen auf 131.
2026-08-29 09:35:31 +02:00
sysops 4f84ee23dc Merge branch 'feature/iam-14-passwort-richtlinien' into feature/qa-02-pruefgate-identitaet-mandanten
# Conflicts:
#	scripts/reset-test-env.sh
#	scripts/run-checks.sh
2026-08-29 09:22:40 +02:00
sysops 559b650af7 Merge branch 'feature/iam-13-oidc-provider-fuer-drittanwendungen' into feature/qa-02-pruefgate-identitaet-mandanten
# Conflicts:
#	DEVLOG.md
#	scripts/reset-test-env.sh
#	scripts/run-checks.sh
2026-08-29 09:22:10 +02:00
sysops fb0ff9e779 Merge branch 'feature/iam-12-aktive-sitzungen-verwaltung' into feature/qa-02-pruefgate-identitaet-mandanten
# Conflicts:
#	scripts/reset-test-env.sh
#	scripts/run-checks.sh
2026-08-29 00:12:06 +02:00
sysops 912857d35b Merge branch 'feature/iam-11-saml-2-0-anbindung' into feature/qa-02-pruefgate-identitaet-mandanten 2026-08-29 00:11:52 +02:00
sysops 2f832292b3 Merge branch 'feature/iam-10-passwortlose-anmeldung-via-webauthn-passkey' into feature/qa-02-pruefgate-identitaet-mandanten
# Conflicts:
#	scripts/reset-test-env.sh
#	scripts/run-checks.sh
2026-08-29 00:11:46 +02:00
sysops c6933f0e2f Merge branch 'feature/iam-09-api-token-service-accounts' into feature/qa-02-pruefgate-identitaet-mandanten
# Conflicts:
#	scripts/reset-test-env.sh
#	scripts/run-checks.sh
2026-08-29 00:11:35 +02:00
sysops f89ec925fc Merge branch 'feature/iam-08-benutzerprofil-login-oberflaeche' into feature/qa-02-pruefgate-identitaet-mandanten
# Conflicts:
#	DEVLOG.md
2026-08-29 00:11:23 +02:00
sysops 6c8582c1c2 Merge branch 'feature/iam-07-account-lockout-login-rate-limiting' into feature/qa-02-pruefgate-identitaet-mandanten
# Conflicts:
#	scripts/reset-test-env.sh
#	scripts/run-checks.sh
2026-08-29 00:11:09 +02:00
sysops 95d1a6810a Merge branch 'feature/iam-06-sso-anmeldung-ueber-oidc' into feature/qa-02-pruefgate-identitaet-mandanten 2026-08-29 00:10:39 +02:00
sysops ca07ae4d68 Merge branch 'feature/iam-05-ldap-active-directory-anbindung' into feature/qa-02-pruefgate-identitaet-mandanten
# Conflicts:
#	scripts/reset-test-env.sh
#	scripts/run-checks.sh
2026-08-29 00:10:39 +02:00
sysops 0a2e71aec9 Merge branch 'feature/iam-04-zwei-faktor-authentifizierung-totp' into feature/qa-02-pruefgate-identitaet-mandanten
# Conflicts:
#	DEVLOG.md
#	scripts/reset-test-env.sh
#	scripts/run-checks.sh
2026-08-29 00:10:25 +02:00
sysops 24c8331d45 Merge branch 'feature/iam-03-passwort-reset-einladungs-flow' into feature/qa-02-pruefgate-identitaet-mandanten
# Conflicts:
#	scripts/reset-test-env.sh
#	scripts/run-checks.sh
2026-08-29 00:10:04 +02:00
sysops 13874683e8 Merge branch 'feature/iam-02-login-session-jwt-grundgeruest' into feature/qa-02-pruefgate-identitaet-mandanten 2026-08-29 00:09:35 +02:00
sysops 6d288732c6 Merge branch 'feature/ten-05-tenant-verwaltungsoberflaeche' into feature/qa-02-pruefgate-identitaet-mandanten
# Conflicts:
#	internal/tenant/registry.go
2026-08-29 00:09:28 +02:00
sysops a6340d8850 Merge branch 'feature/ten-07-migrations-orchestrierung-tenant-datenbanken' into feature/qa-02-pruefgate-identitaet-mandanten 2026-08-29 00:08:50 +02:00
sysops b13d95f4a3 Merge branch 'feature/ten-06-connection-routing-pooling-pro-mandant' into feature/qa-02-pruefgate-identitaet-mandanten 2026-08-29 00:08:45 +02:00
sysops 2147b4c7fd DEVLOG: Sessionlog-Eintrag (Auto-Hook) 2026-08-29 00:08:45 +02:00
sysops 96a54d93a4 Merge branch 'feature/ten-04-tenant-lifecycle-suspendieren-reaktivieren-loeschen' into feature/qa-02-pruefgate-identitaet-mandanten
# Conflicts:
#	scripts/reset-test-env.sh
2026-08-29 00:08:30 +02:00
sysops df2a54f0e6 IAM-08: fix — Suspense-Boundary fuer useSearchParams() in password-reset/complete (Next.js-14-App-Router-Anforderung, Build brach beim Prerendering ab) 2026-08-28 23:38:06 +02:00
sysops f627caaacb IAM-08: benutzerprofil-login-oberflaeche (login+2fa/passwort-reset/profil-backend-handler + web/account next.js-frontend auf shl-01) 2026-08-28 23:34:36 +02:00
sysops f0139c889a Merge branch 'feature/shl-01-ui-shell-design-system-zentral' into feature/iam-08-benutzerprofil-login-oberflaeche
# Conflicts:
#	DEVLOG.md
2026-08-28 23:28:33 +02:00
sysops d9fb2e33f2 Merge branch 'feature/iam-03-passwort-reset-einladungs-flow' into feature/iam-08-benutzerprofil-login-oberflaeche 2026-08-28 23:28:23 +02:00
sysops 6885319333 DEVLOG: Sessionlog-Eintrag (Auto-Hook) 2026-08-28 23:27:34 +02:00
sysops 6666125a8a DEVLOG: Sessionlog-Eintrag (Auto-Hook) 2026-08-28 23:26:21 +02:00
sysops 42466c7b40 IAM-13: oidc-provider-fuer-drittanwendungen (client-registrierung, authorization-code-flow, jwks ueber API-05-schluessel) 2026-08-28 23:25:45 +02:00
sysops 1b4c3db9b8 DEVLOG: Sessionlog-Eintrag (Auto-Hook) 2026-08-28 22:54:02 +02:00
sysops 12b1978ae1 Merge branch 'feature/shl-01-ui-shell-design-system-zentral' into feature/ten-05-tenant-verwaltungsoberflaeche 2026-08-28 22:02:02 +02:00
sysops bfa5c61db5 SHL-01: fix — Test-Cleanup zwischen Dialog-Tests (afterEach(cleanup), sonst stapeln sich gerenderte DOM-Bäume) 2026-08-28 22:01:56 +02:00
sysops 9911499c1e TEN-05: fix — transpilePackages fuer @nexarch/shl (Next.js transpiliert node_modules sonst nicht, Build brach ab) 2026-08-28 21:56:02 +02:00
sysops 74e1f07379 Merge branch 'feature/shl-01-ui-shell-design-system-zentral' into feature/ten-05-tenant-verwaltungsoberflaeche 2026-08-28 21:55:43 +02:00
sysops 3c226dab12 SHL-01: fix — vitest jsdom-environment + jest-dom-Setup (3 Dialog-Tests schlugen ohne DOM fehl) 2026-08-28 21:55:34 +02:00
sysops 8edae6141b TEN-05: Retrofit auf SHL-01 (ThemeProvider/I18nProvider/ToastProvider, Design-Tokens statt hartkodierter Werte) 2026-08-28 21:46:34 +02:00
sysops 49404c5fa4 Merge branch 'feature/shl-01-ui-shell-design-system-zentral' into feature/ten-05-tenant-verwaltungsoberflaeche
# Conflicts:
#	DEVLOG.md
2026-08-28 21:46:07 +02:00
sysops 584cefa388 DEVLOG: Sessionlog-Eintrag (Auto-Hook) 2026-08-28 21:45:55 +02:00
sysops 00665592f6 SHL-01: ui-shell-design-system-zentral (tokens, theming, i18n-rahmen, basis-komponenten) 2026-08-28 21:41:56 +02:00
sysops 11ed3e3790 TEN-05: backend-api + dev-server + next.js tenant-verwaltungsoberflaeche 2026-08-27 23:58:04 +02:00
sysops 962ef5a27a TEN-05: lifecycle-code aus TEN-04 auf ten-03-basis portiert (registry liest previous_status/deletion_scheduled_at) 2026-08-27 23:49:53 +02:00
sysopsandClaude Sonnet 5 0ce29acf3e IAM-11: saml-2-0-anbindung
internal/saml: RSA-SHA256-Signaturpruefung ueber die deterministisch
(re-)marshalte Assertion — deckt dieselbe Sicherheitseigenschaft ab wie
XML-DSig (nur eine gueltig signierte Assertion eines vertrauten IdP wird
akzeptiert), implementiert aber NICHT die vollstaendige W3C-Exclusive-C14N
mit allen Randfaellen echter Drittprodukt-IdPs (ADFS/Okta/Azure AD) — das
Ticket erlaubt ausdruecklich einen "simulierten IdP" fuer die Pruefungen,
Simulator (Sign/BuildResponse) und Verifier nutzen folgerichtig dieselbe
deterministische Kodierung.

CompleteSAMLLogin mappt Rollen aus SAML-Attributen ueber DIESELBE Erlaubnis-
Matrix wie IAM-05/LDAP und IAM-06/OIDC (ldapsync.RoleMappingStore, kein
dritter paralleler Mapping-Mechanismus — Akzeptanzkriterium 3) und stellt
ein IAM-02-Sitzungs-Token aus. saml_config ist wie ldap_config/oidc-Kontext
eine Singleton-Zeile je Tenant-Datenbank (Modell C) — SAML und OIDC koennen
dadurch strukturell fuer verschiedene Tenants nebeneinander konfiguriert
sein, ohne dass sich beide je begegnen (Akzeptanzkriterium 2).

Pruefungen (ausgefuehrt auf root@192.168.1.131, go build/vet/test PASS):
1. End-to-End-Test gegen simulierten SAML-IdP erfolgreich —
   TestCompleteSAMLLogin_EndToEnd: signierte Assertion vom simulierten IdP,
   vollstaendiger Login liefert gueltiges Sitzungs-Token. PASS.
2. Zwei Tenants mit unterschiedlichem Anmeldeweg (SAML vs. OIDC) parallel
   funktionsfaehig — TestSAMLAndOIDC_WorkInParallelForDifferentTenants: zwei
   physisch getrennte Tenant-Datenbanken, eine mit SAML-, eine mit
   OIDC-Login, beide liefern unabhaengig gueltige Tokens. PASS.
3. Rollenzuordnung aus SAML-Attributen korrekt —
   TestCompleteSAMLLogin_EndToEnd (Positivfall: gemappte Rolle greift) und
   TestCompleteSAMLLogin_UnmappedRoleGrantsNothing (Negativfall: Rollen-
   Attribute wie "tenant_admin"/"superadmin", die nie gemappt wurden,
   vergeben keine Rolle — keine Privilege-Escalation). PASS.

Zusaetzlich: TestVerify_RejectsTamperedAssertion, TestVerify_RejectsWrongIdPKey,
TestVerify_RejectsExpiredAssertion, TestVerify_RejectsWrongIssuer belegen die
Kern-Sicherheitseigenschaften der Signaturpruefung. PASS.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 23:12:03 +02:00
sysopsandClaude Sonnet 5 ba71bb6350 IAM-10: passwortlose-anmeldung-via-webauthn-passkey
internal/webauthn: vereinfachte, aber kryptographisch echte Challenge-
Response-Zeremonie (Ed25519) statt des vollstaendigen W3C-WebAuthn-
Drahtformats (CBOR-attestationObject/COSE), das ohne Browser-Umgebung hier
nicht erzeugt/geprueft werden kann (siehe Paket-Dokumentation, analoge
Einschraenkung wie IAM-05/IAM-06). Deckt dieselben Sicherheitseigenschaften
ab: Public-Key-Challenge-Response, Replay-Schutz durch Einmal-Challenge
(WHERE used_at IS NULL, Muster aus IAM-03/06/09), mehrere Authenticatoren
pro Benutzer (Akzeptanzkriterium 2, jede Registrierung eine eigene Zeile).

CompleteLogin komponiert auth.TokenIssuer (IAM-02) fuer die Sitzungs-Token-
Ausstellung nach erfolgreicher Signaturpruefung, ohne LoginService/IAM-02
selbst zu veraendern — Passwort+TOTP bleiben vollstaendig unberuehrt und
funktionsfaehig (Akzeptanzkriterium 3, kein Zwang auf WebAuthn).

Pruefungen (ausgefuehrt auf root@192.168.1.131, go build/vet/test PASS):
1. Registrierung und Anmeldung mit simuliertem Authenticator erfolgreich —
   TestRegisterAndLogin_WithoutPassword: kompletter Ablauf ganz ohne
   Passworteingabe, gueltiges Sitzungs-Token am Ende. PASS.
2. Zweiter Authenticator registriert, beide funktionsfaehig —
   TestMultipleCredentials_BothWork: zwei unabhaengige Schluesselpaare,
   beide melden sich erfolgreich an. PASS.
3. Login mit Passwort+TOTP funktioniert weiterhin unveraendert —
   TestPasswordLoginStillWorks_AfterWebAuthnRegistration: IAM-02-Login nach
   WebAuthn-Registrierung unveraendert erfolgreich. PASS.

Zusaetzlich: TestFinishRegistration_RejectsWrongSignature und
TestChallenge_CannotBeReplayed belegen die Kernsicherheitseigenschaften der
Zeremonie. PASS.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 23:05:42 +02:00
sysopsandClaude Sonnet 5 e4856dfc9d IAM-06: sso-anmeldung-ueber-oidc
internal/oidc: JWKS-Parser (RSA-Schluessel, RFC 7517) + Verifier prueft
ID-Tokens gegen den geparsten Schluesselsatz (Signatur, Ablauf ueber die
jwt-Bibliothek, Aussteller) — Akzeptanzkriterium 3. VerifyWithNonce prueft
zusaetzlich, dass der nonce-Claim exakt dem beim Redirect ausgestellten
Nonce entspricht (Replay-Schutz).

StateStore ist der CSRF-/Replay-Schutz (Akzeptanzkriterium/Pruefung 3):
Generate stellt state+nonce aus, Consume loest den state ATOMAR und EINMALIG
ein (WHERE used_at IS NULL, analog IAM-03/IAM-09-Muster) — ein abgefangener
und wiederverwendeter Redirect-Callback schlaegt fehl.

CompleteOIDCLogin mappt Rollen aus OIDC-Rollen-Claims ueber DIESELBE
Erlaubnis-Matrix wie IAM-05/LDAP (ldapsync.RoleMappingStore.HighestRoleFor,
keine zweite parallele Implementierung — Akzeptanzkriterium 2) und stellt
bei Erfolg ein normales IAM-02-Sitzungs-Token aus. Lokaler Login (IAM-02
LoginService) bleibt vollstaendig unangetastet und damit als Fallback nutzbar.

WICHTIGER HINWEIS: kein registrierter externer OIDC-Provider (Google/Okta/
Azure AD) in dieser Umgebung verfuegbar fuer einen echten Authorization-
Code-Redirect (analog IAM-05/AUD-05). ANDERS als dort ist die eigentliche
Token-Validierung aber rein kryptographisch und ohne Netzwerkabhaengigkeit
zur Testzeit vollstaendig echt geprueft: Tests erzeugen ein eigenes
RSA-Schluesselpaar, signieren ID-Tokens selbst und verifizieren sie exakt
wie bei einem echten Provider. Nur der Live-Redirect zu einem realen
externen IdP bleibt ungeprueft.

Pruefungen (ausgefuehrt auf root@192.168.1.131, go build/vet/test PASS):
1. Rollen-Erlaubnis-Matrix fuer OIDC-Claims automatisiert getestet (keine
   Privilege-Escalation) — TestCompleteOIDCLogin_UnmappedRoleGrantsNothing:
   Claims mit "tenant_admin"/"superadmin" als Rollen-Strings, die NIE gemappt
   wurden, vergeben keine Rolle. PASS.
2. Token-Signatur- und Ablaufpruefung gegen JWKS automatisiert getestet —
   TestVerify_RejectsExpiredToken, TestVerify_RejectsWrongSigningKey,
   TestVerify_RejectsWrongIssuer, TestParseJWKS_RoundTrip. PASS.
3. State/Nonce-Handling gegen CSRF und Replay geprueft —
   TestStateStore_ConsumeIsSingleUse (State-Replay abgewiesen),
   TestVerifyWithNonce_RejectsMismatch (Nonce-Mismatch abgewiesen). PASS.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 22:53:42 +02:00
sysopsandClaude Sonnet 5 1a82da211d IAM-05: ldap-active-directory-anbindung
internal/ldapsync: Client ist eine Schnittstelle (Search() liefert Entry-
Liste) — in dieser Umgebung steht kein echter LDAP/AD-Server zur Verfuegung
(analog AUD-05/Archive), daher ist die Sync-/Rollenzuordnungs-Logik
vollstaendig gegen einen Fake getestet, eine echte Verbindungspruefung gegen
LDAP/AD steht noch aus.

RoleMappingStore (Akzeptanzkriterium 3): explizite Erlaubnis-Matrix
LDAP-Gruppe -> Rolle. HighestRoleFor vergibt NUR fuer explizit gemappte
Gruppen eine Rolle — eine unbekannte Gruppe, auch wenn ihr Name zufaellig
wie eine interne Rolle aussieht ("tenant_admin", "superadmin"), traegt
strukturell nichts bei (kein Code-Pfad, der eine ungemappte Gruppe je einer
Rolle zuordnet) — behebt die aus archivmail bekannte Privilege-Escalation-
Fehlerklasse von Grund auf statt nachtraeglich zu haerten.

Syncer.Run ruft Search() als ALLERERSTES auf; schlaegt es fehl, wird ohne
jede Aenderung an bestehenden Konten abgebrochen (Akzeptanzkriterium 2).
Pro Eintrag isolierte Fehler landen in SyncResult.Failed, ohne andere
Eintraege zu beeintraechtigen. Deaktivierung in LDAP wird als
users.Deactivate uebernommen (Akzeptanzkriterium 3). ldap_config speichert
bewusst nur den NAMEN einer Umgebungsvariable fuer das Bind-Passwort, nie
das Passwort selbst.

internal/rbac (RBAC-01) wurde 1:1 aus dem rbac-01-Branch uebernommen (git
show aus derselben Repo-Historie) — IAM-05 haengt an RBAC-01 fuer die
Rollenzuweisung, beide Boards leben aber auf getrennten, noch nicht
gemergten Feature-Branches ohne gemeinsame Historie.

Pruefungen (ausgefuehrt auf root@192.168.1.131, go build/vet/test PASS):
1. Rollen-Erlaubnis-Matrix mit Positiv- und Negativfaellen automatisiert
   getestet (keine Privilege-Escalation) — TestRoleMapping_PositiveAndNegativeCases:
   gemappte Gruppe liefert Rolle, unbekannte Gruppe und rollen-aehnlich
   benannte, aber nie gemappte Gruppen liefern keine. PASS.
2. Synchronisationslauf mit fehlerhafter/nicht erreichbarer LDAP-Quelle
   bricht kontrolliert ab, ohne bestehende Konten zu beschaedigen —
   TestSyncer_AbortsCleanlyOnSourceError: bestehendes Konto bleibt nach
   fehlgeschlagenem Lauf unveraendert aktiv. PASS.
3. Deaktivierung eines Benutzers in LDAP wird bei naechster Synchronisation
   korrekt uebernommen — TestSyncer_AppliesDeactivationOnNextRun: erster
   Lauf legt aktiven Benutzer an, zweiter Lauf mit Disabled=true setzt ihn
   auf inaktiv. PASS.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 22:39:10 +02:00
sysopsandClaude Sonnet 5 f224b5c9be IAM-04: zwei-faktor-authentifizierung-totp
internal/totp/totp.go: RFC-6238-TOTP (HOTP RFC 4226) selbst implementiert
mit stdlib crypto/hmac+sha1 — kein externes Modul. DefaultSkewSteps=1
dokumentiert die Zeitversatz-Toleranz (+/-30s um die Serverzeit,
Akzeptanzkriterium/Pruefung 3). ProvisioningURI liefert die otpauth://-URI
fuer den QR-Code (Akzeptanzkriterium 1) — das Rendering selbst ist
Frontend-Sache (IAM-08).

internal/totp/store.go: BeginSetup speichert ein neues Secret als NICHT
bestaetigt; ConfirmSetup aktiviert 2FA erst nach einmaliger erfolgreicher
Code-Eingabe (Akzeptanzkriterium 1) und erzeugt 10 Wiederherstellungscodes
(nur Hash gespeichert, Klartext einmalig zurueckgegeben). VerifyLoginCode
akzeptiert TOTP-Code ODER Wiederherstellungscode; consumeRecoveryCode
markiert einen Code atomar als verwendet (WHERE used_at IS NULL) — kein
doppeltes Einloesen moeglich (Akzeptanzkriterium 3).

internal/totp/login.go: LoginWithTOTP komponiert IAM-02s LoginService, ohne
ihn zu veraendern — ist 2FA fuer den Benutzer aktiv, wird ein fehlender/
falscher Code zuverlaessig abgewiesen, selbst bei korrektem Passwort
(Akzeptanzkriterium 2).

Pruefungen (ausgefuehrt auf root@192.168.1.131, go build/vet/test PASS):
1. Login-Versuch ohne zweiten Faktor bei aktivem 2FA zuverlaessig abgewiesen —
   TestLoginWithTOTP_RequiresSecondFactorWhenEnabled: korrektes Passwort ohne
   Code -> ErrSecondFactorRequired, mit gueltigem Code -> Token. PASS.
2. Wiederherstellungscode nach Nutzung als verbraucht getestet —
   TestVerifyLoginCode_RecoveryCodeIsSingleUse: erste Nutzung erfolgreich,
   zweite abgelehnt. PASS.
3. Zeitversatz-Toleranz dokumentiert und getestet —
   TestValidate_ClockSkewTolerance: Code aus 25s Vergangenheit gueltig
   (innerhalb dokumentierter Toleranz), Code aus 5min Vergangenheit
   ungueltig. PASS.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 22:34:34 +02:00
sysopsandClaude Sonnet 5 5f02e08bc4 IAM-12: aktive-sitzungen-verwaltung
internal/session: Selbstbedienungs-Sitzungsuebersicht mit widerrufbarem,
SERVERSEITIGEM Zustand — bewusst ein separater Mechanismus neben IAM-02s
zustandslosem JWT (das fuer schnelle Modul-zu-Modul-Verifikation ohne
Core-Rueckfrage gewaehlt wurde, siehe API-05). Sofortige Widerrufbarkeit ist
fuer dieses Selbstbedienungs-Sicherheitsfeature wichtiger als
Zustandslosigkeit — kein Konflikt mit der API-05-Entscheidung, da es sich um
verschiedene Anwendungsfaelle handelt.

Store.Create gibt den Klartext-Sitzungs-Token nur einmal zurueck, gespeichert
wird ausschliesslich der SHA-256-Hash. Validate prueft direkt gegen die
Datenbank (kein Cache) und aktualisiert last_seen_at bei jedem Zugriff
(Akzeptanzkriterium 1). Revoke/RevokeAllExcept setzen revoked_at — ein
widerrufenes Token ist ab dem naechsten Validate-Aufruf sofort ungueltig
(Akzeptanzkriterium 2), RevokeAllExcept beendet gezielt alle Sitzungen ausser
der aktuellen (Akzeptanzkriterium 3).

LoginAndCreateSession verwendet auth.VerifyPassword (IAM-02) fuer den
timing-safen Credential-Check — kein zweiter Passwort-Pruefmechanismus,
liefert aber ein Sitzungs-Token statt eines JWT zurueck.

Pruefungen (ausgefuehrt auf root@192.168.1.131, go build/vet/test PASS):
1. Zwei Sitzungen desselben Nutzers angelegt, beide in der Uebersicht
   sichtbar — TestListForUser_ShowsAllActiveSessions. PASS.
2. Widerruf einer Sitzung macht das Token sofort ungueltig —
   TestRevoke_InvalidatesTokenImmediately. PASS.
3. "Alle anderen beenden" funktioniert korrekt, aktuelle bleibt aktiv —
   TestRevokeAllExcept_KeepsCurrentSessionActive: zwei fremde Sitzungen
   widerrufen, aktuelle bleibt gueltig und einzig uebrige in der Liste. PASS.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 22:29:58 +02:00
sysopsandClaude Sonnet 5 3c4e9d45b8 IAM-14: passwort-richtlinien
internal/pwpolicy: Validate ist eine schmale Regelschicht VOR dem
bcrypt-Hashing aus IAM-02, kein eigenes Policy-Framework. Prueft
Mindestlaenge, Zeichenklassen (Gross-/Kleinbuchstaben, Ziffern,
Sonderzeichen je nach Policy) und eine eingebettete Sperrliste haeufig
verwendeter Passwoerter (case-insensitive) — Akzeptanzkriterium 1 + 2.
Store haelt die Richtlinie als eine Zeile je Tenant-Datenbank (Singleton,
Modell C), DefaultPolicy() greift, solange kein Tenant eine eigene gesetzt hat.

LoginAndCheckPolicy komponiert IAM-02s LoginService, OHNE ihn zu veraendern:
der Login selbst schlaegt bei einem alten, nicht mehr konformen Passwort
NICHT fehl (Akzeptanzkriterium 3 — kein rueckwirkendes Aussperren), die
Funktion liefert zusaetzlich mustChangePassword=true. Die Pruefung ist nur
im Login-Moment moeglich, da dort kurzzeitig das Klartext-Passwort vorliegt
— der gespeicherte bcrypt-Hash laesst sich nicht rueckwirkend gegen eine
neue Richtlinie pruefen.

Pruefungen (ausgefuehrt auf root@192.168.1.131, go build/vet/test PASS):
1. Zu kurzes/zu einfaches Passwort bei Registrierung/Aenderung abgelehnt —
   TestValidate_RejectsTooShortOrSimple. PASS.
2. Passwort aus Sperrliste abgelehnt — TestValidate_RejectsBlocklistedPassword
   (inkl. Gross-/Kleinschreibung). PASS.
3. Bestehender Nutzer mit altem, nicht-konformem Passwort kann sich noch
   einloggen, wird aber zur Aenderung aufgefordert —
   TestLoginAndCheckPolicy_FlagsNonConformantExistingPassword: Login mit
   schwachem Altpasswort gelingt, mustChangePassword=true; mit konformem
   Passwort mustChangePassword=false. PASS.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 22:26:23 +02:00
sysopsandClaude Sonnet 5 95275407b1 IAM-09: api-token-service-accounts
internal/serviceaccount: Service-Accounts als eigenstaendige Identitaetsklasse
(eigene Tabelle service_accounts, getrennt von internal/user — Zitadel-
Vorbild). Store.IssueToken gibt den Klartext-Token NUR einmal an den
Aufrufer zurueck, gespeichert wird ausschliesslich der SHA-256-Hash
(Akzeptanzkriterium 1). Scopes und optionale Ablaufzeit sind Teil des
Tokens selbst (Akzeptanzkriterium 2).

Store.Verify prueft Widerruf/Ablauf/Scope bei JEDEM Aufruf direkt gegen die
Datenbank — kein Cache dazwischen, ein widerrufenes Token wird ab dem
naechsten Request sofort abgewiesen (Akzeptanzkriterium 3).

Pruefungen (ausgefuehrt auf root@192.168.1.131, go build/vet/test PASS):
1. Token ausschliesslich gehasht gespeichert (Stichprobe in der Datenbank) —
   TestIssueToken_StoresOnlyHash: token_hash-Spalte direkt gelesen, enthaelt
   nachweislich nicht den Klartext, 32-Byte-SHA-256-Laenge bestaetigt. PASS.
2. Widerrufenes Token wird beim naechsten Request zuverlaessig abgewiesen —
   TestRevoke_TakesEffectImmediately: Verify vor Widerruf erfolgreich, sofort
   danach ErrTokenInvalid. PASS.
3. Scope-Verletzung wird korrekt abgewiesen —
   TestVerify_RejectsInsufficientScope: Token mit scope=read wird fuer
   scope=write abgewiesen (ErrScopeInsufficient), fuer scope=read akzeptiert. PASS.

Zusaetzlich: TestVerify_RejectsExpiredToken belegt die optionale zeitliche
Befristung aus Akzeptanzkriterium 2. PASS.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 22:19:39 +02:00
sysopsandClaude Sonnet 5 ac070c160a IAM-07: account-lockout-login-rate-limiting
internal/lockout: Store.RecordFailure erhoeht failed_count ATOMAR ueber ein
einziges Postgres-UPSERT und setzt locked_until, sobald die konfigurierte
Schwelle erreicht ist — Zustand lebt ausschliesslich in Postgres, mehrere
Core-Instanzen teilen sich denselben Zaehler (Akzeptanzkriterium 2, bekannter
Fehler aus archivdms internal/auth/ratelimit.go vermieden: kein
In-Process-Zaehler). IsLocked vergleicht nur locked_until gegen die aktuelle
Zeit — eine abgelaufene Sperre gilt automatisch als aufgehoben, ohne
explizite Entsperr-Aktion (Akzeptanzkriterium 3). Unlock erlaubt zusaetzlich
sofortige Entsperrung durch Administratoreingriff.

GuardedLogin komponiert IAM-02s LoginService mit dem Lockout-Zustand, ohne
LoginService selbst zu veraendern: prueft die Sperre vor jedem Versuch,
vermerkt Erfolg/Fehlschlag danach.

Bugfix waehrend Tests: die Sperrzeit wurde als Ganzzahl-Sekunden in die
Postgres-INTERVAL-Berechnung eingesetzt (int(duration.Seconds())), wodurch
Sperrzeiten unter 1 Sekunde (z.B. in Tests) auf 0 abgerundet wurden und die
Sperre sofort wieder als abgelaufen galt — auf Fliesskomma-Sekunden
umgestellt.

Pruefungen (ausgefuehrt auf root@192.168.1.131, go build/vet/test PASS):
1. Zwei parallel laufende Dienstinstanzen teilen sich denselben Zaehler —
   TestRecordFailure_SharedAcrossInstances: zwei unabhaengige pgxpool.Pool-
   Verbindungen, Fehlversuche abwechselnd ueber beide, gemeinsame Schwelle
   wird erreicht. PASS.
2. Brute-Force-Sperre greift nach definierten Fehlversuchen zuverlaessig —
   TestRecordFailure_LocksAfterThreshold. PASS.
3. Zeitversatz zwischen Sperre und Entsperrung automatisiert getestet —
   TestIsLocked_AutoUnlocksAfterExpiry: gesperrt vor Ablauf, automatisch
   entsperrt nach Ablauf der Sperrzeit. PASS.

Zusaetzlich: TestGuardedLogin_LocksAfterRepeatedFailures belegt das
Zusammenspiel mit dem echten IAM-02-LoginService End-to-End — selbst das
korrekte Passwort wird nach Sperrung abgewiesen. PASS.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 22:15:25 +02:00
sysopsandClaude Sonnet 5 b6a941feec IAM-03: passwort-reset-einladungs-flow
internal/authtoken: Einmal-Token fuer Passwort-Reset und Einladung, getrennt
von internal/auth (Sessions/JWT), da der Vorgang bewusst session-los ist.
Store.Create gibt das Klartext-Token NUR an den Aufrufer zurueck (fuer
E-Mail-Versand, IAM-08), gespeichert wird ausschliesslich der SHA-256-Hash.

Store.Consume markiert ein Token atomar als verwendet (UPDATE ... WHERE
used_at IS NULL AND expires_at > now() RETURNING user_id) — Wiederverwendung
und Ablauf werden serverseitig in derselben Datenbankoperation durchgesetzt,
kein Race zwischen Pruefen und Verbrauchen moeglich. Ungueltig, bereits
verwendet und abgelaufen liefern denselben ErrInvalidToken (Akzeptanz-
kriterium 3), damit die Antwort keinen der drei Faelle verraet.

CompletePasswordReset/CompleteInvitation loesen ein Token ein und setzen das
Passwort ueber user.TenantUserStore.SetPasswordHash (IAM-01) — keine
Auth-Middleware, keine Session noetig (Akzeptanzkriterium 2).

Log-Statements bei Create/Consume enthalten bewusst nur user_id/purpose,
niemals das Token selbst.

Pruefungen (ausgefuehrt auf root@192.168.1.131, go build/vet/test PASS):
1. Wiederverwendung eines bereits eingeloesten Tokens automatisiert
   abgewiesen — TestConsume_RejectsReuse. PASS.
2. Ablaufzeit serverseitig durchgesetzt — TestConsume_RejectsExpiredToken
   (Token mit negativer TTL sofort abgelaufen). PASS.
3. Token werden nicht im Klartext geloggt (Stichprobe im Log-Ausgang) —
   TestCreateAndConsume_NeverLogTokenPlaintext: Log-Buffer nach Create +
   ungueltigem + gueltigem Consume enthaelt das Klartext-Token nachweislich
   nicht. PASS.

Zusaetzlich: TestCompleteInvitation_SetsPasswordWithoutSession belegt
Akzeptanzkriterium 2 konkret (Passwort gesetzt und verifizierbar, keine
Session im Ablauf beteiligt). PASS.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 22:09:32 +02:00
sysopsandClaude Sonnet 5 f7863fd4d2 API-05: verteilte-jwt-verifikation-rechte-feature-flag-cache-kontrakt
internal/moduletrust: asymmetrische JWT-Signatur (Ed25519) mit JWKS-
Verteilung, wie im Entscheidungsverlauf "Vertrauensstellung Core<->Module"
(nexarch-state.json) festgelegt. Getrennt von IAM-02s HS256-Session-Cookie
(Browser-Login bleibt unangetastet) — dies ist der Modul-zu-Core-
Vertrauensmechanismus.

KeyManager haelt ALLE noch gueltigen Schluesselpaare (nicht nur das aktuell
signierende); Rotate() erzeugt einen neuen Schluessel, alte bleiben in
PublicKeySet() erhalten — bereits ausgestellte Tokens bleiben dadurch nach
einer Rotation weiterhin verifizierbar (Akzeptanzkriterium 3, keine
Ausfallzeit). ServeJWKS/ParseJWKS sind der Verteilungsmechanismus.

StaleCache[T] ist der generische Rechte-/Feature-Flag-Cache-Kontrakt
(Akzeptanzkriterium 2), mit zwei explizit benannten und begruendeten
Verhalten: Get() ist FAIL-OPEN (nutzt bei Core-Ausfall einen vorhandenen,
abgelaufenen Stand weiter — ein bereits authentifiziertes Modul soll nicht
hart blockieren), RequireFresh() ist FAIL-CLOSED (nie zwischengespeichert,
schlaegt bei Core-Ausfall klar fehl — fuer sicherheitskritische Aktionen wie
einen neuen Login). LIC-02s internal/flag.Service implementiert bereits
denselben Kontrakt fuer Feature-Flags; StaleCache verallgemeinert dasselbe
Muster fuer JWT-Schluessel, damit beide Faelle derselben dokumentierten
Policy folgen statt zwei unterschiedlichen Ad-hoc-Loesungen.

Verifier.Verify ruft KeyFetchFunc nur bei abgelaufener TTL auf, nicht pro
Aufruf (Akzeptanzkriterium 1) — Signaturpruefung selbst ist immer lokal.

Pruefungen (ausgefuehrt auf root@192.168.1.131, go build/vet/test PASS):
1. Core simuliert abgeschaltet, andere Module bleiben fuer bereits
   authentifizierte Nutzer funktionsfaehig bis TTL/Fail-Open greift —
   TestVerify_FailsOpenWhenCoreUnreachableButStaleKeysExist: Verify()
   funktioniert weiter mit letztbekanntem Schluesselstand. PASS.
2. Neue sicherheitskritische Aktion schlaegt bei Core-Ausfall klar fehl,
   statt andere Funktionen mitzureissen —
   TestRequireFreshKeys_FailsClosedWhenCoreUnreachable: Fehler trotz
   vorhandenem (aelterem) Cache-Stand. PASS.
3. Schluesselrotation ohne Downtime in einem simulierten zweiten Modul —
   TestRotate_NoDowntimeForAlreadyIssuedTokens: vor UND nach Rotation
   ausgestellte Tokens beide weiterhin gueltig fuer Modul B. PASS.

Zusaetzlich: TestVerify_DoesNotFetchPerCall belegt Akzeptanzkriterium 1
direkt (10 Verify-Aufrufe, genau 1 Fetch). PASS.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 21:38:57 +02:00
sysopsandClaude Sonnet 5 383da92ca8 TEN-03: tenant-einstellungen-branding
internal/tenantsettings: pro-Tenant-Einstellungen (Anzeigename, Logo,
Farbschema, Zeitzone, Sprache) in der Registry-DB, alle Spalten nullable —
fehlender Wert bedeutet immer "Systemvoreinstellung verwenden"
(Defaults(): color_scheme=system, timezone=UTC, language=de), niemals ein
Fehler (Akzeptanzkriterium 2). Store.Update schreibt aktuellen Stand +
Historieneintrag atomar in einer Transaktion mit FOR-UPDATE-Lock auf der
aktuellen Zeile (Akzeptanzkriterium 3, Race-sicher bei nebenlaeufigen
Updates desselben Tenants). Patch-Typ mit *string-Feldern erlaubt
Teil-Updates ohne unbeteiligte Felder zu beruehren.

Schlanker Handler (Get/Update ueber tenant_id-Query-Parameter) als
Vorbereitung der REST-Schnittstelle — echte Auth/Versionierung kommt erst
mit API-01/IAM-02/RBAC-01.

Pruefungen (ausgefuehrt auf root@192.168.1.131, go build/vet/test PASS):
1. Aenderung eines Tenants wirkt sich nicht auf einen anderen aus —
   TestUpdate_IsolatedBetweenTenants: Tenant A geaendert, Tenant B bleibt
   nachweislich bei Defaults(). PASS.
2. Fehlende Werte liefern Defaults statt Fehler —
   TestGet_UnsetTenantReturnsDefaults (Tenant ganz ohne Datensatz) und
   TestUpdate_PartialPatchKeepsOtherFieldsAtDefault (nur ein Feld gesetzt,
   Rest bleibt Default). PASS.
3. API-Schema von zweiter Person gegen Dokumentation geprueft — NICHT
   durchgefuehrt (keine zweite Person in dieser Session verfuegbar). Offen.

Zusaetzlich automatisiert getestet (Akzeptanzkriterium 3):
TestUpdate_HistoryTracksVersions — 3 aufeinanderfolgende Aenderungen,
Historie liefert alle 3 in korrekter Reihenfolge, nicht angefasste Felder
bleiben aus dem vorherigen Update erhalten. PASS.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 19:34:09 +02:00
sysopsandClaude Sonnet 5 b08f6a49fc TEN-07: migrations-orchestrierung-tenant-datenbanken
Orchestrator.RolloutAll wendet eine geordnete Liste von Migrationen auf JEDE
registrierte Tenant-Datenbank an (LoadMigrations liest *.up.sql aus einem
Verzeichnis nach der bestehenden 000N_name-Namenskonvention). Jeder Tenant
laeuft unabhaengig in eigener Verbindung — ein Fehlschlag bei einem Mandanten
bricht nur dessen eigenen Rollout ab (spaetere Migrationen bauen typischerweise
auf frueheren auf) und blockiert die uebrigen Tenants nicht.

schema_migrations-Tabelle pro Tenant-Datenbank (version PK, applied_at,
success, error) haelt den Stand pro Version einzeln nachvollziehbar fest.
Bereits erfolgreiche Versionen werden bei einem erneuten Rollout uebersprungen
(isAlreadySuccessful-Check vor jeder Anwendung), fehlgeschlagene werden beim
naechsten Versuch automatisch erneut probiert (kein manuelles Zuruecksetzen
noetig) — ON CONFLICT DO UPDATE haelt jeweils nur den letzten Versuch fest.

Bewusst ohne Abhaengigkeit von TEN-06 (Router): Migrations-Rollouts sind
seltene Batch-Vorgaenge, ein kurzlebiger Pool pro Tenant und Lauf reicht.

Pruefungen (ausgefuehrt auf root@192.168.1.131, go build/vet/test PASS,
go test ./... mit -p 1 noetig da mehrere Pakete die geteilte Registry-Tabelle
auf derselben Postgres-Instanz nutzen — siehe scripts/reset-test-env.sh):
1. Rollout gegen 3 Test-Tenants, einer absichtlich inkompatibel (Tabellen-
   Konflikt bei Migration 2) — TestRolloutAll_IsolatesFailurePerTenant: die
   anderen beiden erhalten beide Migrationen, der inkompatible bekommt
   Migration 1 trotzdem, scheitert nur an Migration 2, faellt nicht die
   anderen um. PASS.
2. Migrationsstand-Abfrage liefert korrekten Stand pro Tenant —
   TestStatus_ReflectsPerTenantState: Version 1 success=true, Version 2
   success=false mit Fehlertext. PASS.
3. Wiederholter Rollout fuer fehlgeschlagene Migration moeglich, ohne bereits
   erfolgreiche erneut anzuwenden — TestRolloutAll_RetryDoesNotReapplySuccessful:
   Migration 1 nutzt bewusst kein IF NOT EXISTS, ein Reapply haette den
   zweiten Lauf scheitern lassen; zweiter Lauf ist fehlerfrei und wendet nur
   die zuvor fehlgeschlagene Version an. PASS.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 19:05:47 +02:00
sysopsandClaude Sonnet 5 45bc10719a TEN-04: tenant-lifecycle-suspendieren-reaktivieren-loeschen
Zustandsautomat active/suspended/pending_deletion/deleted als First-Class-
Konzept (previous_status + deletion_scheduled_at in der Registry). Alle
Uebergaenge in Registry.transition als atomarer Check-and-Set (UPDATE ...
WHERE status = ANY(erlaubte-von-zustaende)), ungueltige Uebergaenge liefern
ErrInvalidTransition statt eines stillen No-Ops. ScheduleDeletion merkt sich
previous_status, damit CancelDeletion exakt dorthin zurueckkehrt (aktiv ODER
suspendiert) statt hart auf 'active'.

Lifecycle.ProcessDueDeletions loescht faellige Tenant-Datenbanken per
FOR UPDATE SKIP LOCKED (Postgres-Jobqueue-Konvention, sicher fuer mehrere
parallele Core-Instanzen), Lifecycle.RunSweeper triggert das periodisch per
In-Prozess-Goroutine. Lifecycle.CheckActive verweigert und loggt (slog)
Zugriffe auf nicht-aktive Mandanten.

Bugfix nebenbei: Registry.GetBySlug las previous_status/deletion_scheduled_at
bisher nicht mit, wodurch CancelDeletion den Vorzustand nie fand — Query
minimal erweitert (kein Verhaltensunterschied fuer TEN-01/TEN-02, die diese
Felder nicht nutzen).

Neu: scripts/reset-test-env.sh — setzt die geteilte Registry-Tabelle und alle
tenant_*-Datenbanken auf dem Testhost zurueck, da verschiedene Feature-
Branches unterschiedliche Registry-Schemata erwarten, aber dieselbe
Postgres-Instanz teilen.

Pruefungen (ausgefuehrt auf root@192.168.1.131, go build/vet/test PASS):
1. Zustandsautomat mit allen Uebergaengen getestet — TestLifecycle_SuspendAndReactivate,
   TestLifecycle_RejectsInvalidTransitions (Reactivate auf aktivem Tenant,
   Suspend auf suspendiertem Tenant, CancelDeletion ohne Vormerkung,
   unbekannter Slug — alle ErrInvalidTransition/ErrTenantNotFound). PASS.
2. Suspendierter Tenant erzeugt bei jedem Zugriffsversuch klaren, geloggten
   Fehler — TestLifecycle_CheckActive_RejectsNonActive (3x hintereinander,
   slog.Warn nachweislich pro Aufruf). PASS.
3. Loeschvorgang nach Ablauf der Karenzzeit automatisch ausgeloest —
   TestLifecycle_ProcessDueDeletions: faellige Loeschung wird verarbeitet
   (DB physisch entfernt, Status=deleted), nicht-faellige bleibt unberuehrt. PASS.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 18:42:24 +02:00
sysopsandClaude Sonnet 5 2026514404 TEN-06: connection-routing-pooling-pro-mandant
Router loest Tenant-Slug (aus JWT-Claim, API-05 vorausgesetzt) ueber die
TEN-01-Registry in eine wiederverwendete Postgres-Verbindung auf. LRU-Cache
(container/list) begrenzt die Zahl gleichzeitig offener Tenant-Pools auf
maxOpen — bei Ueberschreitung wird der am laengsten ungenutzte Pool
geschlossen, bevor ein neuer aufgemacht wird. Fehlender/unbekannter
Tenant-Kontext liefert explizite Fehler (ErrMissingTenantContext /
ErrUnknownTenant) statt stillschweigend zu routen.

Pruefungen (ausgefuehrt auf root@192.168.1.131, go build/vet/test PASS):
1. Lasttest mit 6 simulierten Mandanten gegen maxOpen=2 — TestRouter_BoundsOpenConnectionsUnderLoad:
   OpenCount() bleibt nach jedem Resolve <= maxOpen, Verbindungszahl waechst
   nicht linear mit der Mandantenzahl. PASS.
2. Anfrage ohne/mit unbekanntem Tenant-Kontext abgewiesen —
   TestRouter_RejectsMissingOrUnknownTenant. PASS.
3. Verbindungswiederverwendung gemessen — TestRouter_ReusesConnectionForSameTenant:
   zweiter Resolve-Aufruf liefert exakt dieselbe *pgxpool.Pool-Instanz. PASS.

Router ist eigenstaendig nutzbar/getestet, aber noch nicht in cmd/core/main.go
verdrahtet — der JWT-Claim mit Tenant-Kontext (API-05) und das HTTP-Routing,
das den Slug pro Request extrahiert, sind nicht Teil dieser Kachel.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 18:35:50 +02:00
sysopsandClaude Sonnet 5 3d20d86a4f IAM-02: login-session-jwt-grundgeruest
internal/auth: Login/Logout ueber httpOnly/Secure/SameSite=Strict-Cookie mit
HS256-JWT (30min TTL), bcrypt-Passwort-Hashing (Cost 12, explizit begruendet
und benchmarkt statt DefaultCost uebernommen), RequireAuth-Middleware fuer
geschuetzte Routen. LoginService ist strukturell auf einen Tenant gescopt
(nutzt user.TenantUserStore, dessen Pool = eine Tenant-DB — derselbe
Mechanismus wie in TEN-01/TEN-02), liefert bei falscher E-Mail und falschem
Passwort denselben Fehler (User-Enumeration-Schutz) inkl. Dummy-bcrypt-
Vergleich gegen Timing-Seitenkanal bei unbekannter E-Mail.

user.TenantUserStore erweitert um SetPasswordHash/GetByEmailForAuth
(password_hash bleibt ausserhalb des regulaeren User-Typs/JSON-Pfads).
Migration 0002 fuegt password_hash-Spalte hinzu (Default '', da IAM-01
User ohne Passwort anlegt).

Login-Handler ist wie IAM-01/TEN-02 aus denselben Gruenden (Tenant-
Connection-Routing = TEN-06, noch nicht gebaut) nicht in cmd/core/main.go
verdrahtet — Package ist eigenstaendig nutzbar/getestet.

Pruefungen (ausgefuehrt auf root@192.168.1.131, go build/vet/test PASS):
1. Login-Query tenant-gescopt — TestLoginService_NoCrossTenantLogin: gleiche
   E-Mail in zwei Tenant-DBs mit unterschiedlichem Passwort, Login gegen
   Tenant A mit Tenant-B-Passwort schlaegt fehl. PASS.
2. Session-Fixation/Token-Manipulation — TestTokenVerify_RejectsManipulatedPayload
   und TestTokenVerify_RejectsWrongSecret: manipuliertes/falsch signiertes
   Token wird abgelehnt. PASS.
3. Abgelaufenes Token erzwingt Neuanmeldung — TestTokenVerify_RejectsExpiredToken
   und TestRequireAuth_BlocksWithoutValidCookie. PASS.
4. Login-Latenz mit Kostenfaktor 12 gemessen: 294ms (Ziel < 400ms) —
   TestBcryptCostAgainstLatencyTarget. PASS.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 18:26:00 +02:00
sysopsandClaude Sonnet 5 5915a4e7b1 TEN-02: tenant-onboarding-api-self-service
OnboardingService.Onboard: ein API-Aufruf legt Tenant (via bestehendem
Provisioner) UND ersten Administrator-Account (via IAM-01 TenantUserStore)
an. CREATE DATABASE erlaubt keine echte cross-database Transaktion, daher
Saga-Kompensation: schlaegt die Admin-Anlage nach erfolgreichem Provisioning
fehl, wird der Tenant per neuem Provisioner.Deprovision wieder vollstaendig
entfernt (Registry.Delete + DB-Drop).

Provisioner.Provision mappt Duplikat-Faelle (42P04 duplicate_database und
den bei echt parallelen CREATE DATABASE moeglichen 23505-Unique-Konflikt auf
pg_database) jetzt auf ErrTenantExists statt einer rohen Postgres-Fehlermeldung.

Pruefungen (ausgefuehrt auf root@192.168.1.131, go build/vet/test PASS):
1. Onboarding-Vorgang zweimal parallel mit gleichem Slug ausgeloest —
   TestOnboarding_RejectsDuplicateSlugConcurrently: genau ein Erfolg, kein
   Doppel-Tenant, zweiter Aufruf bekommt ErrTenantExists. PASS.
2. Fehleingaben (leere Pflichtfelder, ungueltige E-Mail, ungueltiger Slug) —
   TestOnboarding_ValidationErrors deckt alle vier Faelle mit klaren Fehlern
   ab, bevor irgendein DB-Zugriff stattfindet. PASS.
3. Erfolgreicher Durchlauf von zweiter Person end-to-end nachvollzogen —
   NICHT durchgefuehrt (keine zweite Person in dieser Session verfuegbar).
   Offen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 18:16:54 +02:00
sysopsandClaude Sonnet 5 e4793303fc IAM-01: benutzer-datenmodell-crud
Benutzer-Datenmodell + CRUD fuer Tenant-User (tenant-scoped, keine
tenant_id-Spalte noetig, Tenant ergibt sich aus der DB-Verbindung, Modell C)
und getrennt dafuer SuperadminStore fuer mandantenuebergreifende Konten in
der Registry-DB — First-Class-Typ statt tenant_id-NULL-Sonderfall im
Tenant-User-Code (bekannter archivdms-Fehler vermieden).

E-Mail-Eindeutigkeit: tenant-scoped fuer normale Benutzer (UNIQUE-Constraint
gilt nur innerhalb der jeweiligen Tenant-DB), global fuer Superadmins
(eine Registry-DB, ein UNIQUE-Constraint).

Pruefungen (ausgefuehrt auf root@192.168.1.131, go build/vet/test PASS):
1. CRUD automatisiert getestet inkl. Negativfaellen — TestTenantUserStore_CRUD
   deckt doppelte E-Mail (ErrEmailTaken) und unbekannte ID ab. PASS.
2. Superadmin-Anlage ohne Tenant-Kontext — TestSuperadminStore_CreateWithoutTenantContext:
   SuperadminStore.Create hat syntaktisch keinen Tenant-Parameter, kein
   if-Zweig fuer "kein Tenant" im Code. PASS.
3. Datenmodell von zweiter Person gegen Dokumentation geprueft — NICHT
   durchgefuehrt (keine zweite Person in dieser Session verfuegbar). Offen.

Tenant-User-Handler ist im Code vorhanden, aber in cmd/core/main.go noch
nicht geroutet — braucht Connection-Routing pro Mandant (TEN-06), das nicht
Teil dieser Kachel ist. Nur der Superadmin-Endpunkt ist verdrahtet.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 17:57:49 +02:00
sysopsandClaude Sonnet 5 8da9c67d08 TEN-01: tenant-registry-datenbank-provisioning
Registry-DB (nur Tenant-Metadaten), Provisioning-Routine legt pro Mandant
eine physisch isolierte Postgres-DB an und registriert sie transaktional
(Rollback der DB bei fehlgeschlagener Registrierung). Schlanker HTTP-Handler
als Schnittstellen-Vorbereitung fuer API-01/TEN-02, kein eigenes REST-Grundgerüst.

Pruefungen:
1. Migration up/down geschrieben (0001_tenant_registry.{up,down}.sql) — nicht
   gegen echte DB ausgefuehrt, da auf dieser Maschine kein Go/Postgres-Test-
   Setup verfuegbar ist. Offen zur Ausfuehrung.
2. Integrationstest TestProvision_CreatesIsolatedDatabases geschrieben (zwei
   Mandanten, prueft unterschiedliche db_name und current_database()) —
   ebenfalls nicht ausgefuehrt, guarded per TEST_ADMIN_DSN env var. Offen.
3. Slug-Validierung (unit test TestValidateSlug) deckt SQL-Injection-Versuch
   im Datenbanknamen ab — ebenfalls nicht lokal ausgefuehrt, da kein Go
   Compiler auf dieser Maschine vorhanden ist. Offen.

Alle drei Pruefungen sind vorbereitet, aber NICHT durchgefuehrt worden —
zaehlen laut Vorgabe als offen bis auf einer Maschine mit Go+Postgres verifiziert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 17:40:35 +02:00
208 changed files with 13821 additions and 2622 deletions
+2
View File
@@ -1,2 +1,4 @@
*.log
.env
web/*/node_modules/
web/*/.next/
+155
View File
@@ -44,3 +44,158 @@ Keine Commits in dieser Session.
Keine Änderungen ermittelbar.
---
## 2026-08-27 17:26 17:28 (1m)
**Beschreibung:** Claude Code Session
**Projekt:** code
### Commits
- c895a67 core: initial Go module skeleton (config, db pool, tenant registry migration)
### Geänderte Dateien
- .gitignore | 2 ++
- DEVLOG.md | 46 ++++++++++++++++++++++++++++++++++++++++++++++
- cmd/core/main.go | 33 +++++++++++++++++++++++++++++++++
- go.mod | 5 +++++
- internal/config/config.go | 29 +++++++++++++++++++++++++++++
- internal/db/db.go | 11 +++++++++++
- migrations/0001_tenant_registry.sql | 10 ++++++++++
---
## 2026-08-27 17:28 17:29 (1m)
**Beschreibung:** Claude Code Session
**Projekt:** code
## 2026-08-28 21:44 21:44 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** nexarch
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- .gitignore | 2 ++
- DEVLOG.md | 46 ++++++++++++++++++++++++++++++++++++++++++++++
- cmd/core/main.go | 33 +++++++++++++++++++++++++++++++++
- go.mod | 5 +++++
- internal/config/config.go | 29 +++++++++++++++++++++++++++++
- internal/db/db.go | 11 +++++++++++
- migrations/0001_tenant_registry.sql | 10 ++++++++++
---
## 2026-08-27 17:31 17:31 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** code
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- .gitignore | 2 ++
- DEVLOG.md | 46 ++++++++++++++++++++++++++++++++++++++++++++++
- cmd/core/main.go | 33 +++++++++++++++++++++++++++++++++
- go.mod | 5 +++++
- internal/config/config.go | 29 +++++++++++++++++++++++++++++
- internal/db/db.go | 11 +++++++++++
- migrations/0001_tenant_registry.sql | 10 ++++++++++
---
## 2026-08-27 17:36 17:36 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** code
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- .gitignore | 2 ++
- DEVLOG.md | 46 ++++++++++++++++++++++++++++++++++++++++++++++
- cmd/core/main.go | 33 +++++++++++++++++++++++++++++++++
- go.mod | 5 +++++
- internal/config/config.go | 29 +++++++++++++++++++++++++++++
- internal/db/db.go | 11 +++++++++++
- migrations/0001_tenant_registry.sql | 10 ++++++++++
---
## 2026-08-27 17:36 17:37 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** code
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- .gitignore | 2 ++
- DEVLOG.md | 46 ++++++++++++++++++++++++++++++++++++++++++++++
- cmd/core/main.go | 33 +++++++++++++++++++++++++++++++++
- go.mod | 5 +++++
- internal/config/config.go | 29 +++++++++++++++++++++++++++++
- internal/db/db.go | 11 +++++++++++
- migrations/0001_tenant_registry.sql | 10 ++++++++++
---
## 2026-08-27 17:53 17:57 (4m)
**Beschreibung:** Claude Code Session
**Projekt:** code
### Commits
- e479330 IAM-01: benutzer-datenmodell-crud
### Geänderte Dateien
- cmd/core/main.go | 11 ++++++++++-
- go.mod | 9 +++++++++
- go.sum | 28 ++++++++++++++++++++++++++++
- internal/user/handler.go | 66 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- internal/user/store_test.go | 173 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- internal/user/superadmin_store.go | 77 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- internal/user/tenant_store.go | 128 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- internal/user/user.go | 42 ++++++++++++++++++++++++++++++++++++++++++
- internal/user/user_test.go | 24 ++++++++++++++++++++++++
- migrations/0002_superadmins.down.sql | 1 +
- migrations/0002_superadmins.up.sql | 14 ++++++++++++++
- migrations/tenant/0001_users.down.sql | 1 +
- migrations/tenant/0001_users.up.sql | 16 ++++++++++++++++
- web/shl/README.md | 45 +++++++++++++++++++++++++++++++++++++++++++++
- web/shl/__tests__/Dialog.test.tsx | 38 ++++++++++++++++++++++++++++++++++++++
- web/shl/__tests__/tokens.test.ts | 39 +++++++++++++++++++++++++++++++++++++++
- web/shl/components/Dialog.tsx | 88 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- web/shl/components/FormElements.tsx | 98 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- web/shl/components/Shell.tsx | 62 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- web/shl/components/Table.tsx | 83 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- web/shl/components/Toast.tsx | 67 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- web/shl/i18n/i18n.tsx | 75 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- web/shl/index.ts | 25 +++++++++++++++++++++++++
- web/shl/package.json | 23 +++++++++++++++++++++++
- web/shl/theme/ThemeProvider.tsx | 81 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- web/shl/tokens/tokens.ts | 96 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- web/shl/tsconfig.json | 18 ++++++++++++++++++
---
## 2026-08-28 21:51 21:57 (5m)
**Beschreibung:** Claude Code Session
**Projekt:** nexarch
### Commits
- 3c226da SHL-01: fix — vitest jsdom-environment + jest-dom-Setup (3 Dialog-Tests schlugen ohne DOM fehl)
### Geänderte Dateien
- web/shl/package.json | 2 ++
- web/shl/vitest.config.ts | 8 ++++++++
- web/shl/vitest.setup.ts | 1 +
---
## 2026-08-29 00:08 00:08 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** code
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- internal/tenantsettings/handler.go | 74 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- internal/tenantsettings/store_test.go | 156 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- internal/tenantsettings/tenantsettings.go | 187 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- migrations/0003_tenant_settings.down.sql | 2 ++
- migrations/0003_tenant_settings.up.sql | 27 +++++++++++++++++++++++++++
- scripts/reset-test-env.sh | 25 +++++++++++++++++++++++++
- scripts/run-checks.sh | 24 ++++++++++++++++++++++++
---
+30 -3
View File
@@ -7,6 +7,8 @@ import (
"gitea.perlbach24.de/scripte/nexarch/internal/config"
"gitea.perlbach24.de/scripte/nexarch/internal/db"
"gitea.perlbach24.de/scripte/nexarch/internal/tenant"
"gitea.perlbach24.de/scripte/nexarch/internal/user"
)
func main() {
@@ -15,16 +17,41 @@ func main() {
log.Fatalf("config: %v", err)
}
pool, err := db.Connect(context.Background(), cfg.RegistryDSN)
ctx := context.Background()
registryPool, err := db.Connect(ctx, cfg.RegistryDSN)
if err != nil {
log.Fatalf("db: %v", err)
log.Fatalf("registry db: %v", err)
}
defer pool.Close()
defer registryPool.Close()
adminPool, err := db.Connect(ctx, cfg.AdminDSN)
if err != nil {
log.Fatalf("admin db: %v", err)
}
defer adminPool.Close()
registry := tenant.NewRegistry(registryPool)
provisioner := tenant.NewProvisioner(adminPool, registry, cfg.TenantDSNTemplate)
tenantHandler := tenant.NewHandler(provisioner)
onboardingService := tenant.NewOnboardingService(provisioner, cfg.TenantSchemaPath)
onboardingHandler := tenant.NewOnboardingHandler(onboardingService)
// Superadmin-Konten leben mandantenuebergreifend in der Registry-DB.
// Tenant-User-CRUD (user.TenantUserStore) braucht Connection-Routing pro
// Mandant (TEN-06, noch nicht gebaut) und wird hier bewusst noch nicht
// verdrahtet — Package ist bereits eigenstaendig nutzbar/testbar.
superadmins := user.NewSuperadminStore(registryPool)
userHandler := user.NewHandler(nil, superadmins)
mux := http.NewServeMux()
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
// Vorlaeufige Pfade ohne Versionierung/Auth — werden mit API-01/IAM-02 abgeloest.
mux.HandleFunc("/internal/tenants", tenantHandler.CreateTenant)
mux.HandleFunc("/internal/tenants/onboard", onboardingHandler.Onboard)
mux.HandleFunc("/internal/superadmins", userHandler.CreateSuperadmin)
log.Printf("nexarch-core listening on %s", cfg.ListenAddr)
if err := http.ListenAndServe(cfg.ListenAddr, mux); err != nil {
+67
View File
@@ -0,0 +1,67 @@
// tenantadmin-devserver stellt das TEN-05-Backend-API (internal/tenantadmin)
// fuer die Next.js-Tenant-Verwaltungsoberflaeche bereit. Getrennt von
// cmd/core aus demselben Grund wie cmd/licadmin-devserver (siehe LIC-04):
// echte Auth (IAM-01/IAM-02) ist noch nicht in die zentrale Server-Topologie
// verdrahtet, dieser Server dient Entwicklung/Betrieb der Oberflaeche gegen
// eine echte Datenbank, ohne cmd/core anzufassen.
package main
import (
"context"
"log"
"net/http"
"os"
"gitea.perlbach24.de/scripte/nexarch/internal/db"
"gitea.perlbach24.de/scripte/nexarch/internal/tenant"
"gitea.perlbach24.de/scripte/nexarch/internal/tenantadmin"
"gitea.perlbach24.de/scripte/nexarch/internal/tenantsettings"
"gitea.perlbach24.de/scripte/nexarch/internal/user"
)
func main() {
dsn := os.Getenv("NEXARCH_REGISTRY_DSN")
if dsn == "" {
log.Fatal("NEXARCH_REGISTRY_DSN nicht gesetzt")
}
addr := os.Getenv("NEXARCH_TENANTADMIN_LISTEN_ADDR")
if addr == "" {
addr = ":8082"
}
ctx := context.Background()
pool, err := db.Connect(ctx, dsn)
if err != nil {
log.Fatalf("db: %v", err)
}
defer pool.Close()
registry := tenant.NewRegistry(pool)
lifecycle := tenant.NewLifecycle(registry, pool)
settingsStore := tenantsettings.NewStore(pool)
superadmins := user.NewSuperadminStore(pool)
handler := tenantadmin.NewHandler(registry, lifecycle, settingsStore, superadmins)
mux := http.NewServeMux()
mux.HandleFunc("/admin/tenants", withCORS(handler.ListTenantsHandler))
mux.HandleFunc("/admin/tenants/detail", withCORS(handler.TenantDetailHandler))
mux.HandleFunc("/admin/tenants/settings", withCORS(handler.UpdateSettingsHandler))
mux.HandleFunc("/admin/tenants/lifecycle", withCORS(handler.LifecycleActionHandler))
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })
log.Printf("tenantadmin-devserver listening on %s", addr)
log.Fatal(http.ListenAndServe(addr, mux))
}
func withCORS(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusOK)
return
}
next(w, r)
}
}
-18
View File
@@ -1,18 +0,0 @@
version: "2"
run:
timeout: 3m
linters:
default: none
enable:
- govet
- staticcheck
- errcheck
- unused
- ineffassign
formatters:
enable:
- gofmt
- goimports
-43
View File
@@ -1,43 +0,0 @@
.PHONY: install run run-app run-worker lint fmt test build check
# Akzeptanzkriterium 1: ein Befehl installiert+startet App und Worker.
install: build
build:
go build ./...
run: build
@echo "Starte dms-app und dms-worker (Strg+C zum Beenden beider)"
@trap 'kill 0' EXIT; \
go run ./cmd/app & \
go run ./cmd/worker & \
wait
run-app:
go run ./cmd/app
run-worker:
go run ./cmd/worker
# Akzeptanzkriterium 2: Lint-/Format-Checks laufen lokal durch.
lint:
golangci-lint run ./...
fmt:
gofmt -l .
@test -z "$$(gofmt -l .)" || (echo "gofmt-Verstoesse gefunden, siehe oben" && exit 1)
test:
# -p 1: alle Integrationstest-Pakete teilen sich dieselbe physische
# Test-Datenbank (TEST_TENANT_DSN); parallele Paketausfuehrung wuerde
# sich gegenseitig ueberschreiben (dieselbe Konvention wie NEXARCH Core,
# siehe scripts/run-checks.sh im Core-Modul).
go test ./... -p 1 -count=1
# Setzt die geteilte Test-Datenbank zurueck, dann build/vet/test in einem
# Rutsch — analog zu NEXARCH Cores scripts/run-checks.sh.
check: build
NEXARCH_DMS_TEST_DB_PASSWORD="$${NEXARCH_DMS_TEST_DB_PASSWORD:?Setze NEXARCH_DMS_TEST_DB_PASSWORD vor dem Aufruf}" bash scripts/reset-test-env.sh
go vet ./...
golangci-lint run ./...
go test ./... -p 1 -count=1
-52
View File
@@ -1,52 +0,0 @@
# NEXARCH DMS
Dokumentenmanagement-Modul von NEXARCH. Vereint die Stärken von
paperless-ngx, Alfresco, Docspell und ecoDMS, vermeidet deren bekannte
Schwächen (siehe `known-issues-archivdms.md` im `dms-kanban/`-Ordner).
Identität, Rechte, Mandantenverwaltung, Authentifizierung, UI-Shell,
API-Grundgerüst und Benachrichtigungen kommen aus NEXARCH Core (siehe
`../` bzw. `../../core-kanban/`) — dieses Modul implementiert nur die
DMS-eigene Logik.
## Setup
Voraussetzung: Go 1.22+.
```bash
cd dms
make install # baut App und Worker
make run # startet beide (Strg+C beendet beide)
```
App läuft danach auf `:8090` (überschreibbar über
`NEXARCH_DMS_APP_LISTEN_ADDR`), `GET /healthz` liefert den Status.
## Struktur
- `cmd/app` — Anfrage-Dienst (HTTP), blockiert nie durch lange Aufgaben
- `cmd/worker` — Hintergrund-Dienst für lange laufende Aufgaben (Indexierung,
OCR, Storage-Vorgänge — folgen in FDN-02 ff.)
- `internal/shared` — von App und Worker gemeinsam genutzter Code
## Prüfungen
```bash
make fmt # gofmt-Verstöße brechen ab
make lint # golangci-lint
make test # go test ./...
```
## Branch- und Commit-Konvention
Gleiche Konvention wie NEXARCH Core:
- Branch je Ticket: `feature/<ticket-code>-<kurzbeschreibung>`, z. B.
`feature/fdn-02-datenmodell-migrationen`
- Commit-Nachricht beginnt mit dem Ticket-Code, z. B.
`FDN-02: datenmodell & migrationen`
- Ein Ticket = ein Branch. Schrittweise committen, Branch pushen, dann
anhalten (kein Merge, kein Deploy durch die bearbeitende Person selbst).
- Deutschsprachige Oberflächentexte, englischsprachige Bezeichner im Code.
- Keine Zugangsdaten/Schlüssel/Verbindungszeichenfolgen im Code —
ausschließlich über Umgebungsvariablen.
-30
View File
@@ -1,30 +0,0 @@
// app ist der Anfrage-Dienst (Request-Path) des DMS — getrennt vom Worker,
// damit lange Hintergrundaufgaben nie eine HTTP-Anfrage blockieren
// (Akzeptanzkriterium/Produkt-DNA: paperless-ngx-Trennung Dienst/Worker).
package main
import (
"log"
"net/http"
"os"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/shared"
)
func main() {
addr := os.Getenv("NEXARCH_DMS_APP_LISTEN_ADDR")
if addr == "" {
addr = ":8090"
}
mux := http.NewServeMux()
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"status":"ok","service":"dms-app","version":"` + shared.Version + `"}`))
})
log.Printf("dms-app hoert auf %s (version %s)", addr, shared.Version)
if err := http.ListenAndServe(addr, mux); err != nil {
log.Fatal(err)
}
}
-31
View File
@@ -1,31 +0,0 @@
package main
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// TestHealthz ist der Nachweis, dass der App-Dienst tatsaechlich startet und
// antwortet (Akzeptanzkriterium 1: "mit einem Befehl installieren und
// starten") — geprueft ueber den Handler direkt statt einen echten Port zu
// binden, damit der Test parallel und ohne Portkonflikte laufen kann.
func TestHealthz(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"status":"ok","service":"dms-app","version":"test"}`))
})
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
}
if !strings.Contains(rec.Body.String(), `"status":"ok"`) {
t.Fatalf("unerwarteter body: %s", rec.Body.String())
}
}
-35
View File
@@ -1,35 +0,0 @@
// worker ist der Hintergrund-Dienst des DMS — verarbeitet lange laufende
// Aufgaben (Indexierung, OCR, Storage-Vorgaenge in spaeteren Kacheln),
// getrennt vom App-Prozess (siehe cmd/app).
package main
import (
"context"
"log"
"os"
"os/signal"
"syscall"
"time"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/shared"
)
func main() {
log.Printf("dms-worker gestartet (version %s)", shared.Version)
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
log.Println("dms-worker beendet")
return
case <-ticker.C:
// Platzhalter fuer Job-Verarbeitung (FDN-02 ff.) — Poll-Intervall
// folgt der projektweiten Postgres-Jobqueue-Konvention.
}
}
}
-42
View File
@@ -1,42 +0,0 @@
# FDN-01 Prüfprotokoll: Repository & Projektgerüst
Welle 1, keine Vorbedingungen. Verzeichnis `code/dms/` im bestehenden
NEXARCH-Repository (Monorepo-Entscheidung, siehe Rückfrage im
Session-Verlauf: DMS als Unterordner statt eigenes Gitea-Repo).
## Struktur
- `cmd/app` — Anfrage-Dienst (HTTP, Port 8090 per Default)
- `cmd/worker` — Hintergrund-Dienst (getrennter Prozess)
- `internal/shared` — gemeinsam genutzter Code
- `go.mod` — eigenes Modul `gitea.perlbach24.de/scripte/nexarch/dms`,
unabhängig vom Core-Modul (kein gemeinsames `go.mod`, um Abhängigkeits-
versionen beider Module unabhängig weiterzuentwickeln)
## Prüfungen
| # | Prüfung | Zielwert | Ergebnis |
|---|---|---|---|
| 1 | Frischer Clone baut ohne manuelle Nacharbeit | `make install` läuft ohne Fehler | **bestanden**`go build ./...` clean auf 192.168.1.131 |
| 2 | Lint-Fehler brechen den Build ab | `make lint` liefert Exit-Code ≠ 0 bei echtem Verstoß | **bestanden** — absichtlich eingefügte ungenutzte Variable liefert Exit-Code 2, Fund korrekt lokalisiert (`declared and not used`) |
| 3 | README-Setupanleitung von zweiter Person nachvollzogen | — | **nicht durchgeführt** — keine zweite Person in dieser autonomen Sitzung verfügbar (gleiche Methodik-Abweichung wie QA-05 Prüfung 2/QA-09 Bildschirmleser-Durchlauf); ersatzweise die Anleitung selbst Schritt für Schritt auf einer frischen Kopie (`rsync` auf 192.168.1.131) nachvollzogen: `make install``make run``curl /healthz``{"status":"ok",...}`. |
Zusätzlich (nicht explizit gefordert, aber Teil von Akzeptanzkriterium 1
„installieren UND starten"): `make run` startet App und Worker parallel,
`GET /healthz` antwortet mit `200 {"status":"ok","service":"dms-app",...}`
innerhalb von 2 Sekunden nach Start.
## Build/Test-Ergebnis (192.168.1.131)
```
make build -> clean
make fmt -> clean (keine gofmt-Verstoesse)
make lint -> clean (golangci-lint v1.62.2: govet, staticcheck, errcheck, unused, ineffassign, gofmt, goimports)
make test -> 1/1 Pakete mit Tests ok (cmd/app), 0 Fehlschlaege
```
## Gesamtergebnis
**Bestanden**, mit einer dokumentierten Methodik-Abweichung (Prüfung 3,
Vier-Augen-Nachvollzug) mangels zweiter Person — durch Selbst-Nachvollzug auf
frischer Kopie ersetzt.
-71
View File
@@ -1,71 +0,0 @@
# FDN-02 Prüfprotokoll: Datenmodell & Migrationen
Welle 2. Voraussetzung: FDN-01 (Status "Fertig").
## Datenmodell
`migrations/tenant/0001_documents.up.sql` — läuft in der physisch isolierten
Tenant-Datenbank (Modell C, siehe Core TEN-01), keine `tenant_id`-Spalte.
| Entität | Tabelle | Beziehungen |
|---|---|---|
| Ordner | `folders` | selbstreferenzierend (`parent_folder_id`), `created_by``users(id)` |
| Dokument | `documents` | `folder_id``folders`, `current_revision_id``file_revisions`, `created_by``users(id)` |
| Datei-Revision | `file_revisions` | `document_id``documents`, `created_by``users(id)`, `UNIQUE(document_id, revision_number)` |
| Tag | `tags` | — |
| Tag-Zuordnung | `document_tags` | `document_id``documents`, `tag_id``tags` |
| Metadatenfeld | `metadata_fields` | — |
| Metadatenwert | `document_metadata_values` | `document_id``documents`, `field_id``metadata_fields` |
`created_by`/Benutzerbezug referenziert `users(id)` aus Core IAM-01 (Auth
liegt vollständig in Core, siehe "Nicht Bestandteil") — DMS legt `users`
nicht selbst an, setzt die Tabelle als bereits vorhanden voraus (dieselbe
physische Tenant-Datenbank).
**Indizes:** `folders(parent_folder_id)`, `documents(folder_id)`,
`documents(created_by)`, `file_revisions(document_id)`,
`document_tags(tag_id)`, `document_metadata_values(field_id)`.
## Migrationsmechanik
`internal/migrate` — eigenständiger, minimaler Runner (kein ORM,
`*.up.sql`/`*.down.sql`-Paare), `schema_migrations`-Tabelle als
Fortschrittsspeicher (dasselbe Prinzip wie Core, hier eigenständig
implementiert, da DMS ein eigenes Go-Modul ist und Cores `internal/`-Pakete
nicht importieren kann).
## Prüfungen
| # | Prüfung | Ergebnis |
|---|---|---|
| 1 | Migration auf leerer DB und auf bestehender DB getestet | **bestanden**`TestUp_OnEmptyAndExistingDB`: erster Lauf legt alle 7 Tabellen an, zweiter Lauf gegen dieselbe (jetzt bestehende) DB wendet 0 neue Migrationen an (über `schema_migrations` erkannt) |
| 2 | Rollback stellt Vorzustand wieder her | **bestanden**`TestDownOne_RestoresPreviousState`: nach `DownOne` existiert keine der 7 Tabellen mehr, zweiter `DownOne`-Aufruf ohne verbleibende Migration liefert korrekt leeren String statt Fehler |
| 3 | Fremdschlüssel-Constraints durch Negativtests belegt | **bestanden**`TestForeignKeyConstraints_RejectInvalidReferences`, 4 Fälle: Dokument mit unbekanntem Ordner, unbekanntem Ersteller, Datei-Revision mit unbekanntem Dokument, Tag-Zuordnung mit unbekanntem Tag — alle vier korrekt abgewiesen |
Zusätzlich (Akzeptanzkriterium 3, Seed-Datensatz): `migrations/tenant/seed/dev_seed.sql`
manuell gegen eine frische Test-DB mit einer `users`-Zeile ausgeführt (siehe
Sitzungsprotokoll) — legt Ordner, Dokument mit Revision, Tag und
Metadatenfeld+-wert an, per Abfrage bestätigt (`Beispieldokument`,
`Beispiel-Tag`, `rechnungsnummer` vorhanden). Schlägt bewusst mit
sprechender Fehlermeldung fehl, wenn noch kein Benutzer existiert (DMS legt
`users` nicht selbst an).
## Bekannte Fehler vermeiden (aus Ticket)
„Fehlende Lock-/Sum-Datei blockiert CI" — `go.sum` ist committet (siehe
`git status`/Commit-Diff), `go mod tidy` auf 192.168.1.131 ausgeführt und
Ergebnis übernommen.
## Build/Test-Ergebnis (192.168.1.131)
```
go build ./... -> clean
go vet ./... -> clean
make lint -> clean (golangci-lint)
go test ./... -v -count=1 -> 3/3 Pakete mit Tests ok (cmd/app, internal/migrate), 0 Fehlschläge
```
## Gesamtergebnis
**Bestanden.** Alle drei Akzeptanzkriterien und alle drei Pflichtprüfungen
erfüllt und belegt.
-84
View File
@@ -1,84 +0,0 @@
# FDN-03 Prüfprotokoll: Objekt-Storage-Abstraktion
Welle 2. Voraussetzung: FDN-01 (Status "Fertig"), Core LIC-05 (Status
"Fertig").
## Umsetzung
`internal/storage`:
- `Driver`-Interface (Akzeptanzkriterium 1): `Put`/`Get`/`Delete`/`SignedURL`.
- `LocalDriver` — Entwicklungs-Treiber, Dateisystem, signierte URLs über
HMAC-SHA256 (timing-safe verglichen, `crypto/subtle`, dieselbe Konvention
wie Core IAM-15).
- `S3Driver` — Produktions-Treiber, S3-kompatibel (`aws-sdk-go-v2`),
presigned URLs über `s3.PresignClient`.
- `ObjectKey(documentID, revisionID)` — Pfadschema `documents/<id>/revisions/<id>`
innerhalb des bereits mandantenspezifischen Buckets (Akzeptanzkriterium 3;
die Bucket-Trennung selbst ist Core TEN-01).
- `Service` — verbindet `Driver` mit `UsageReporter`: jeder `Put`/`Delete`
löst genau eine Nutzungsmeldung mit der tatsächlichen Objektgröße aus
(Akzeptanzkriterium 4). Repository-Code soll ausschließlich `Service`
aufrufen, nie einen `Driver` direkt.
- `HTTPUsageReporter` — meldet über Cores Service-Credential-authentifizierten
Resync-Endpunkt (`internal/resync.Handler.UsageHandler`, API-06/AUD-06-Muster),
Metrikname `storage_bytes` (gespiegelt aus Core `internal/usage.StorageBytesMetric`,
LIC-05 — DMS kann Cores `internal/`-Pakete als eigenes Go-Modul nicht
importieren).
## Wichtiger Befund: Core-Endpunkt noch nicht live verdrahtet
`internal/resync.Handler` (die Gegenstelle für `HTTPUsageReporter`) ist im
Core-Modul vollständig implementiert und getestet, aber **in keinem
`cmd/*/main.go` registriert** (per `grep` bestätigt, Stand
2026-08-29) — dieselbe Fehlerklasse wie der QA-05/AUD-06-Befund
(Bausteine existieren, sind aber nicht in einen laufenden Dienst verdrahtet).
`HTTPUsageReporter` ist daher gegen den **dokumentierten Vertrag** (exakte
Feldnamen/Header aus `internal/resync/handler.go` gelesen) getestet, nicht
gegen eine echte laufende Core-Instanz. Prüfung 4 ist damit im Rahmen dessen
erfüllt, was DMS beeinflussen kann — die Lücke auf Core-Seite ist ein
Core-Board-Thema (Empfehlung: analog AUD-06 ein Ticket "Resync-Endpunkt in
Core-Server verdrahten" anlegen), nicht Bestandteil dieser DMS-Kachel.
## Prüfungen
| # | Prüfung | Ergebnis |
|---|---|---|
| 1 | Round-Trip-Test Upload/Download je Treiber | **bestanden**`TestLocalDriver_RoundTrip` (Dateisystem) und `TestS3Driver_RoundTrip` (echtes MinIO auf 192.168.1.131, kein Mock) |
| 2 | Abgelaufene signierte URL wird abgewiesen | **bestanden**`TestLocalDriver_SignedURL_ExpiredIsRejected` (Signatur-/Ablauflogik) UND manuell gegen echtes MinIO verifiziert: presigned URL liefert `200` innerhalb der Gültigkeit, `403` nach Ablauf (2s TTL, siehe Sitzungsprotokoll) |
| 3 | Verhalten bei fehlendem Objekt liefert klaren Fehler | **bestanden**`TestLocalDriver_MissingObject`/`TestS3Driver_MissingObject`: beide Treiber liefern `ErrNotFound` für `Get` UND `Delete` eines nicht existierenden Objekts |
| 4 | Melde-Aufruf an Core LIC-05 bei Schreib-/Löschvorgang nachweislich ausgelöst, korrekte Größe | **bestanden** (mit Einschränkung s.o.) — `TestService_PutReportsPositiveDelta`/`TestService_DeleteReportsNegativeDelta` (Fake-Reporter zeichnet Aufrufe auf, prüft Tenant/Metrik/Delta) UND `TestHTTPUsageReporter_SendsCorrectContractToCore` (echter HTTP-Request gegen `httptest.Server`, der Cores Vertrag nachbildet — Header, JSON-Feldnamen) |
## Build/Test-Ergebnis (192.168.1.131)
```
go build ./... -> clean
go vet ./... -> clean
make lint -> clean (golangci-lint v2.1.6, aus Quelle mit go1.24.4 gebaut,
da v1.63.4 den Zielstand go1.24 nicht linten konnte —
.golangci.yml auf v2-Konfigurationsformat migriert)
go test ./internal/storage/... -v -count=1 -> 11/11 Tests ok (3 S3-Tests real
gegen lokal installiertes MinIO statt uebersprungen)
```
## Offener Punkt: Prüfsummen-Schreibpfad noch nicht befüllt
`file_revisions.checksum_sha256` (FDN-02) wird aktuell von **keinem**
Schreibpfad befüllt oder verifiziert — `internal/storage.Service.Put`
berechnet keine Inhalts-Prüfsumme (das einzige SHA256 im Paket ist die
HMAC-Signatur lokaler URLs, siehe oben, unabhängig vom Dateiinhalt). Die
Spalte existiert seit FDN-02 ungenutzt. Nachgetragen als
Akzeptanzkriterium/Prüfung in `DOC-01` (Upload-API), das den Hash auf
Klartext berechnen und transaktional persistieren muss — Voraussetzung für
Duplikaterkennung (`DOC-02`) und die spätere Integritätsprüfung
(Archive `BAK-08`, siehe Sitzungsprotokoll 2026-08-29 zu externem,
selbst nicht überwachtem Kunden-S3-Storage).
## Gesamtergebnis
**Bestanden**, mit einer dokumentierten Abhängigkeit auf Core-Seite
(Abschnitt "Wichtiger Befund") — Core muss `internal/resync.Handler` noch in
einen laufenden Dienst verdrahten, bevor `HTTPUsageReporter` echte
Nutzungsmeldungen an eine Produktivinstanz senden kann. Alle vier
Akzeptanzkriterien und alle vier Pflichtprüfungen im Rahmen des
DMS-seitigen Scopes erfüllt.
-77
View File
@@ -1,77 +0,0 @@
# FDN-04 Prüfprotokoll: Job-Queue & Worker-Runtime
Welle 3. Voraussetzung: FDN-02 (Status "Fertig").
## Umsetzung
`internal/jobqueue`:
- `migrations/tenant/0002_processing_jobs.up.sql``processing_jobs`-Tabelle
(Status `pending`/`processing`/`succeeded`/`failed`/`dead_letter`,
`attempts`/`max_attempts`, `available_at` für Backoff-Terminierung,
`locked_at`/`locked_by` für die Sperre, `idempotency_key` UNIQUE).
- `Queue.Enqueue` — reiht ein, mit optionalem `idempotency_key` (Dedup bei
Doppelzustellung, `ON CONFLICT DO UPDATE ... RETURNING id`).
- `Queue.Dequeue``FOR UPDATE SKIP LOCKED`, holt entweder einen fälligen
`pending`-Job oder einen `processing`-Job, dessen Sperre älter als
`staleLockAfter` ist (Absturz-Wiedervorlage). Backoff-Intervallarithmetik
über `LEAST(attempts, 10) * interval '30 seconds'` — arithmetischer
Cast, keine String-Konkatenation (siehe "Bekannte Fehler vermeiden").
- `Queue.Complete`/`Queue.Fail` — bei erschöpften Versuchen wandert der Job
in `dead_letter`.
- `Queue.RequeueDeadLetter` — manuelle Wiederholung eines DLQ-Eintrags.
- `Queue.Status` — Job-Status abfragbar.
- `Worker`/`Handler` — In-Prozess-Worker-Goroutine, pollt und ruft `Handler`
je Job auf.
## Prüfungen
| # | Prüfung | Ergebnis |
|---|---|---|
| 1 | Absturz eines Workers führt zu erneuter Zustellung | **bestanden**`TestDequeue_StaleLockIsRedelivered`: Job wird von `worker-crashed` gesperrt, NIE completed/failed (simulierter Absturz); sofortiger erneuter Dequeue-Versuch liefert `ErrNoJobAvailable` (Sperre noch frisch), nach Ablauf von `staleLockAfter` liefert `worker-2` denselben Job |
| 2 | Idempotenz bei Doppelzustellung nachgewiesen | **bestanden**`TestEnqueue_IdempotencyKeyPreventsDuplicate`: zweifache Einreihung mit gleichem `idempotency_key` erzeugt nachweislich nur 1 Zeile (per Abfrage bestätigt) |
| 3 | DLQ-Eintrag manuell wiederholbar | **bestanden**`TestRequeueDeadLetter`: Job nach erschöpften Versuchen in `dead_letter`, `RequeueDeadLetter` setzt zurück auf `pending` mit `attempts=0`; Requeue eines NICHT-DLQ-Jobs wird korrekt abgewiesen |
## Reale Fehler gefunden und behoben (kein Vorab-Wissen, beim Testen entdeckt)
1. **pgx-Typinferenz-Fehler bei ungenutztem Parameter**: `Dequeue`s SQL
übergab `workerID` als `$1`, ohne es in der Query zu referenzieren —
Postgres/pgx konnte den Typ von `$1` dadurch nicht ableiten
(`SQLSTATE 42P18`). Behoben durch Entfernen des toten Parameters
(workerID wird erst im nachfolgenden `UPDATE` gebraucht).
2. **`$2::text[]`-Cast mit untypisiertem `nil`**: `typeFilter any` (statt
`[]string`) ließ pgx den Zieltyp des Casts nicht auflösen. Behoben durch
`[]string`-Typisierung der Variable.
3. **Testinfrastruktur-Drift über Sitzungsgrenzen**: `dms_tenant_test`
sammelte über mehrere Testläufe (FDN-02/03/04) `schema_migrations`-Zustand
an, wodurch `internal/migrate`s Rollback-Test nur noch einen Teil der
Tabellen zurückrollte. Neues `scripts/reset-test-env.sh` (Datenbank
droppen+neu anlegen, analog Core `scripts/reset-test-env.sh`) sowie
`make check`-Target (Reset+vet+lint+test in einem Rutsch) behoben das
strukturell. Zusätzlich fehlte `-p 1` im `test`-Target — mehrere
Testpakete teilen sich dieselbe physische Test-DB, parallele
Paketausführung (Go-Testdefault) verursachte Querschläger zwischen
`internal/jobqueue` und `internal/migrate`.
4. **`internal/jobqueue`s Test-Fixture räumte nicht auf**: `TRUNCATE` statt
`DROP TABLE` ließ die Tabelle `processing_jobs` stehen, wodurch
`internal/migrate`s eigene, versionierte Migration mit
`relation already exists` scheiterte. Behoben durch `DROP TABLE IF EXISTS`
im Test-Cleanup.
## Build/Test-Ergebnis (192.168.1.131, `make check`)
```
go build ./... -> clean
scripts/reset-test-env.sh -> dms_tenant_test leer neu angelegt
go vet ./... -> clean
golangci-lint run ./... -> 0 issues
go test ./... -p 1 -count=1 -> 4/4 Pakete ok, 0 Fehlschläge (inkl. 8 jobqueue-Tests, 3 migrate-Tests, 6 storage-Tests real gegen MinIO)
```
## Gesamtergebnis
**Bestanden.** Alle drei Akzeptanzkriterien und alle drei Pflichtprüfungen
erfüllt. Vier reale Fehler beim Testen gefunden und behoben (zwei
Produktionscode-Bugs im SQL-Parameterhandling, zwei
Testinfrastruktur-Bugs) — bestätigt erneut den Wert, jede Prüfung
tatsächlich auf einem echten Testhost auszuführen statt nur zu behaupten.
-36
View File
@@ -1,36 +0,0 @@
module gitea.perlbach24.de/scripte/nexarch/dms
go 1.24
toolchain go1.24.4
require (
github.com/aws/aws-sdk-go-v2 v1.45.1
github.com/aws/aws-sdk-go-v2/config v1.33.1
github.com/aws/aws-sdk-go-v2/credentials v1.20.1
github.com/aws/aws-sdk-go-v2/service/s3 v1.109.1
github.com/aws/smithy-go v1.28.1
github.com/jackc/pgx/v5 v5.6.0
)
require (
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.20 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.19.1 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.1 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.1 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.1 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.1 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.1 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.1 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.7.1 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.35.1 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.40.1 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.47.1 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
github.com/jackc/puddle/v2 v2.2.1 // indirect
golang.org/x/crypto v0.17.0 // indirect
golang.org/x/sync v0.1.0 // indirect
golang.org/x/text v0.14.0 // indirect
)
-64
View File
@@ -1,64 +0,0 @@
github.com/aws/aws-sdk-go-v2 v1.45.1 h1:iIoG3NaLhV6UZpPXyPXlDj2I9oS8tV/nMcMnITCC6Ks=
github.com/aws/aws-sdk-go-v2 v1.45.1/go.mod h1:bttEH6JqnUL8LepvDVfdrds/fZ5bCIxzpe3abyUrhDU=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.20 h1:GPRlPwz40I2B2VrBEASOA3Bi77NyeqejNLkifosX0rs=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.20/go.mod h1:g7PNzKcsOKWb4fkSRBA7BZVAS6Y8IcxzN+nRohhQ1Q8=
github.com/aws/aws-sdk-go-v2/config v1.33.1 h1:bq9jze1hQ5YTCLoVxNnbp0T7rglrlOE7N9YsHqjGkEw=
github.com/aws/aws-sdk-go-v2/config v1.33.1/go.mod h1:2A3HQwG4zaL5Tm80rc6RZj8LmWWv4WYT5v8raSz/L7A=
github.com/aws/aws-sdk-go-v2/credentials v1.20.1 h1:Z8GRNEx0u9sDkZOq4PUnN8mjGwbUQGRzMSXpvt3d8xQ=
github.com/aws/aws-sdk-go-v2/credentials v1.20.1/go.mod h1:uBIK00kFo95dnemqfFMTWx0X8YRqsh6ecIoCjjOkZqM=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.19.1 h1:YIEBqcqRnpi4Pfv0YHImtgi6czGCwKHANC7SwmUAVD0=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.19.1/go.mod h1:imEf0oufgAo8KAkCHhrOdqGEC0YWx1PPBQH82shSxGw=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.1 h1:pc138gM1CW+XPc60rEwUlwwuwWFQK16CI1T7v1F9Oec=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.1/go.mod h1:1+koxpPIbfBdfzP6vojm5/zTpTQ/micYwlxIiNB3TxI=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.1 h1:K0JsbZQj+1h208Ro1zHeA4l7bMp0NvRffHQ91q8Ol1s=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.1/go.mod h1:W3/vL6EtCIatICGy9ab29QhMuae+cOKPWcMxv02CO+Q=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.1 h1:yhw5KD1phVyP9vijxOUzDfEtJx+bt+L63k+VfuiYFAA=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.1/go.mod h1:ZW2e0d7DYlRxlS9hEiMXE47gTdX5KRN4byUiNbUpG+Q=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19 h1:bAdDl/HkGCcGPoe25ToSHEw23VIxt6CT5fLcg111BKg=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19/go.mod h1:KaUzbLxv4CeSxh6ZCl9B4m7CuFenS8kUEaDs+f/DQr4=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.1 h1:s67hBfG5t9rn1NCvDuB4E3QIep3UFhHPtaIqFDjV3N8=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.1/go.mod h1:FpvjBMXtSNMLPmDJsWwcY5cRnqJlpS2y1R6n4pvzs4k=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.1 h1:RmmWQPREQdk9U+PfqeHW3MqZaBaNK7TpV9W3RY+b+7g=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.1/go.mod h1:0A3W4F+68ZnNk5XcNL/e9HFMwnP8RlEicFfy6eOEDyw=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.1 h1:ZMbtPZZQRca+3+XYQne9PBvRiYpHZlNJJOZfE9WNfT0=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.1/go.mod h1:YAGWQdCYlVCoqrzvfv3RLxO6zKwti7gsAULOGWPLYv4=
github.com/aws/aws-sdk-go-v2/service/s3 v1.109.1 h1:kVpzaDBzOdRtOftmiSpTdQbWVqRg0kONLXijktiwXnk=
github.com/aws/aws-sdk-go-v2/service/s3 v1.109.1/go.mod h1:CUr46sCpGAg/rHaclRyhJX0LJAmH73uWSJPPSaMUrSk=
github.com/aws/aws-sdk-go-v2/service/signin v1.7.1 h1:mdMtSVKdQ3+mzBh+l0ogrFYZVQUCg6pJZOirA2ARsYE=
github.com/aws/aws-sdk-go-v2/service/signin v1.7.1/go.mod h1:9IqUlsJDbUPcg6cgx3WEzXdjrbWzLDQrak0aaSqlTcI=
github.com/aws/aws-sdk-go-v2/service/sso v1.35.1 h1:B6WFn91tobD6gG4724ONHaqrpKsoETGnv98LHe/yIGM=
github.com/aws/aws-sdk-go-v2/service/sso v1.35.1/go.mod h1:tWuiVBUtPBr8/rgRiYS8Uf85sHcAN+G7XS3D3CEoUh8=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.40.1 h1:6yeYCWFvgbI2TI3K6jr9LtBNhXgJ7g4xqD+DEiaDDmM=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.40.1/go.mod h1:naFe83jSMuYkH+QjQPX8n1MLhBkeCFM5Lsnh5m5wz3c=
github.com/aws/aws-sdk-go-v2/service/sts v1.47.1 h1:Sv2xPnRHlThSUtVujYuUBPI/Il8si6UPHXL8DMiB/F0=
github.com/aws/aws-sdk-go-v2/service/sts v1.47.1/go.mod h1:mKo/CzaCz8qytGW70NG4vIIGAx1HXTlb5lHNkC5k3lk=
github.com/aws/smithy-go v1.28.1 h1:R/nXH00c8qcfCzQVELtRw+eLQWtzv+VAIEFJ1/xxXlQ=
github.com/aws/smithy-go v1.28.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY=
github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw=
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k=
golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
-238
View File
@@ -1,238 +0,0 @@
// Package jobqueue implementiert FDN-04: eine Postgres-gestuetzte
// Job-Queue mit Wiederholungslogik, Backoff und Dead-Letter-Queue — kein
// Redis/AMQP (siehe Ticket-Vorgabe). FOR UPDATE SKIP LOCKED erlaubt
// mehrere gleichzeitige Worker-Goroutinen (auch mehrinstanzfaehig, da der
// Zustand ausschliesslich in Postgres liegt, keine In-Memory-Zaehler —
// dieselbe Konvention wie Core internal/lockout, siehe "Bekannte Fehler
// vermeiden" im Ticket).
package jobqueue
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// Status-Werte spiegeln den CHECK-Constraint der Migration.
const (
StatusPending = "pending"
StatusProcessing = "processing"
StatusSucceeded = "succeeded"
StatusFailed = "failed"
StatusDeadLetter = "dead_letter"
)
// ErrNotFound wird geliefert, wenn ein angefragter Job nicht existiert.
var ErrNotFound = errors.New("jobqueue: job nicht gefunden")
// ErrNoJobAvailable wird von Dequeue geliefert, wenn aktuell kein
// abholbarer Job vorhanden ist (kein Fehlerzustand, sondern der Normalfall
// bei leerer Queue).
var ErrNoJobAvailable = errors.New("jobqueue: kein job verfuegbar")
// Job ist eine einzelne Aufgabe in der Queue.
type Job struct {
ID string
JobType string
Payload json.RawMessage
Status string
Attempts int
MaxAttempts int
LastError *string
}
// DefaultMaxAttempts/DefaultStaleLockAfter sind Standardwerte, ueberschreibbar
// je Enqueue-Aufruf (MaxAttempts) bzw. am Queue selbst (StaleLockAfter).
const (
DefaultMaxAttempts = 5
)
// Queue kapselt den Zugriff auf processing_jobs.
type Queue struct {
pool *pgxpool.Pool
staleLockAfter time.Duration
}
// NewQueue erzeugt eine Queue. staleLockAfter legt fest, ab wann ein
// Job, der als "processing" markiert ist, aber dessen Worker vermutlich
// abgestuerzt ist, wieder als abholbar gilt (Pruefung 1: Absturz fuehrt zu
// erneuter Zustellung) — kein Heartbeat-Mechanismus noetig, ein grosszuegiges
// Zeitfenster genuegt fuer die "kleinste Loesung".
func NewQueue(pool *pgxpool.Pool, staleLockAfter time.Duration) *Queue {
return &Queue{pool: pool, staleLockAfter: staleLockAfter}
}
// EnqueueOptions steuert optionale Einreih-Parameter.
type EnqueueOptions struct {
// IdempotencyKey verhindert doppelte Einreihung derselben logischen
// Aufgabe (Pruefung 2: Idempotenz bei Doppelzustellung) — leer bedeutet
// kein Dedup-Anspruch.
IdempotencyKey string
MaxAttempts int
}
// Enqueue reiht einen neuen Job ein (Akzeptanzkriterium 1). Bei gesetztem
// IdempotencyKey und bereits existierendem gleichen Key wird die ID des
// BEREITS vorhandenen Jobs zurueckgegeben, kein Duplikat angelegt.
func (q *Queue) Enqueue(ctx context.Context, jobType string, payload any, opts EnqueueOptions) (string, error) {
payloadJSON, err := json.Marshal(payload)
if err != nil {
return "", fmt.Errorf("jobqueue: payload serialisieren: %w", err)
}
maxAttempts := opts.MaxAttempts
if maxAttempts <= 0 {
maxAttempts = DefaultMaxAttempts
}
var idempotencyKey any
if opts.IdempotencyKey != "" {
idempotencyKey = opts.IdempotencyKey
}
var id string
err = q.pool.QueryRow(ctx, `
INSERT INTO processing_jobs (job_type, payload, idempotency_key, max_attempts)
VALUES ($1, $2, $3, $4)
ON CONFLICT (idempotency_key) DO UPDATE SET job_type = processing_jobs.job_type
RETURNING id
`, jobType, payloadJSON, idempotencyKey, maxAttempts).Scan(&id)
if err != nil {
return "", fmt.Errorf("jobqueue: job einreihen: %w", err)
}
return id, nil
}
// Dequeue holt GENAU EINEN abholbaren Job (faellig UND nicht bereits von
// einem anderen Worker gesperrt, ODER dessen Sperre als abgestanden gilt)
// und markiert ihn atomar als "processing" (Akzeptanzkriterium 1 / Pruefung
// 1 — FOR UPDATE SKIP LOCKED erlaubt mehreren Worker-Goroutinen
// gleichzeitigen Aufruf ohne sich gegenseitig zu blockieren oder denselben
// Job doppelt zu holen).
func (q *Queue) Dequeue(ctx context.Context, workerID string, jobTypes []string) (*Job, error) {
tx, err := q.pool.Begin(ctx)
if err != nil {
return nil, fmt.Errorf("jobqueue: transaktion starten: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
var typeFilter []string
if len(jobTypes) > 0 {
typeFilter = jobTypes
}
row := tx.QueryRow(ctx, `
SELECT id, job_type, payload, status, attempts, max_attempts, last_error
FROM processing_jobs
WHERE (
(status = 'pending' AND available_at <= now())
OR (status = 'processing' AND locked_at <= now() - ($2 * interval '1 second'))
)
AND ($1::text[] IS NULL OR job_type = ANY($1))
ORDER BY available_at
FOR UPDATE SKIP LOCKED
LIMIT 1
`, typeFilter, q.staleLockAfter.Seconds())
var j Job
if err := row.Scan(&j.ID, &j.JobType, &j.Payload, &j.Status, &j.Attempts, &j.MaxAttempts, &j.LastError); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNoJobAvailable
}
return nil, fmt.Errorf("jobqueue: naechsten job lesen: %w", err)
}
if _, err := tx.Exec(ctx, `
UPDATE processing_jobs
SET status = 'processing', attempts = attempts + 1, locked_at = now(), locked_by = $2, updated_at = now()
WHERE id = $1
`, j.ID, workerID); err != nil {
return nil, fmt.Errorf("jobqueue: job sperren: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return nil, fmt.Errorf("jobqueue: dequeue committen: %w", err)
}
j.Status = StatusProcessing
j.Attempts++
return &j, nil
}
// Complete markiert einen Job als erfolgreich abgeschlossen.
func (q *Queue) Complete(ctx context.Context, jobID string) error {
tag, err := q.pool.Exec(ctx, `
UPDATE processing_jobs SET status = 'succeeded', locked_at = NULL, locked_by = NULL, updated_at = now()
WHERE id = $1
`, jobID)
if err != nil {
return fmt.Errorf("jobqueue: job abschliessen: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
// Fail markiert einen Job als fehlgeschlagen (Akzeptanzkriterium 2): sind
// die maximalen Versuche erreicht, wandert der Job in die Dead-Letter-Queue
// (status='dead_letter'), sonst wird er mit exponentiellem Backoff erneut
// eingeplant. Backoff-Berechnung nutzt arithmetischen Intervall-Cast
// (attempts * interval), KEINE String-Konkatenation (siehe "Bekannte
// Fehler vermeiden" im Ticket).
func (q *Queue) Fail(ctx context.Context, jobID string, cause error) error {
errMsg := cause.Error()
tag, err := q.pool.Exec(ctx, `
UPDATE processing_jobs
SET status = CASE WHEN attempts >= max_attempts THEN 'dead_letter' ELSE 'pending' END,
available_at = now() + (LEAST(attempts, 10) * interval '30 seconds'),
locked_at = NULL, locked_by = NULL, last_error = $2, updated_at = now()
WHERE id = $1
`, jobID, errMsg)
if err != nil {
return fmt.Errorf("jobqueue: fehlschlag erfassen: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
// Status liefert den aktuellen Zustand eines Jobs (Akzeptanzkriterium 3).
func (q *Queue) Status(ctx context.Context, jobID string) (*Job, error) {
var j Job
err := q.pool.QueryRow(ctx, `
SELECT id, job_type, payload, status, attempts, max_attempts, last_error
FROM processing_jobs WHERE id = $1
`, jobID).Scan(&j.ID, &j.JobType, &j.Payload, &j.Status, &j.Attempts, &j.MaxAttempts, &j.LastError)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
return nil, fmt.Errorf("jobqueue: job-status lesen: %w", err)
}
return &j, nil
}
// RequeueDeadLetter holt einen Job manuell aus der Dead-Letter-Queue zurueck
// in "pending", mit zurueckgesetztem Versuchszaehler (Pruefung 3: DLQ-Eintrag
// manuell wiederholbar). Nur fuer Jobs, die tatsaechlich in dead_letter
// stehen — verhindert versehentliches Requeue eines noch laufenden Jobs.
func (q *Queue) RequeueDeadLetter(ctx context.Context, jobID string) error {
tag, err := q.pool.Exec(ctx, `
UPDATE processing_jobs
SET status = 'pending', attempts = 0, available_at = now(), last_error = NULL, updated_at = now()
WHERE id = $1 AND status = 'dead_letter'
`, jobID)
if err != nil {
return fmt.Errorf("jobqueue: dead-letter-job erneut einreihen: %w", err)
}
if tag.RowsAffected() == 0 {
return fmt.Errorf("jobqueue: job %q steht nicht in dead_letter (oder existiert nicht): %w", jobID, ErrNotFound)
}
return nil
}
-233
View File
@@ -1,233 +0,0 @@
package jobqueue
import (
"context"
"encoding/json"
"errors"
"os"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
func setupTest(t *testing.T) *pgxpool.Pool {
t.Helper()
dsn := os.Getenv("TEST_TENANT_DSN")
if dsn == "" {
t.Skip("TEST_TENANT_DSN nicht gesetzt, Integrationstest uebersprungen")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatalf("pool: %v", err)
}
t.Cleanup(func() { pool.Close() })
if _, err := pool.Exec(ctx, `
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE IF NOT EXISTS processing_jobs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
job_type TEXT NOT NULL,
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
idempotency_key TEXT UNIQUE,
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'processing', 'succeeded', 'failed', 'dead_letter')),
attempts INT NOT NULL DEFAULT 0,
max_attempts INT NOT NULL DEFAULT 5,
available_at TIMESTAMPTZ NOT NULL DEFAULT now(),
locked_at TIMESTAMPTZ,
locked_by TEXT,
last_error TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
`); err != nil {
t.Fatalf("schema: %v", err)
}
// DROP statt nur TRUNCATE: internal/jobqueue und internal/migrate teilen
// sich dieselbe physische Test-Datenbank (TEST_TENANT_DSN) ueber
// Paketgrenzen hinweg. Bliebe die Tabelle stehen, wuerde internal/migrate
// spaeter mit "relation already exists" gegen die von diesem Fixture
// angelegte, aber unversionierte Tabelle scheitern.
t.Cleanup(func() {
_, _ = pool.Exec(context.Background(), `DROP TABLE IF EXISTS processing_jobs`)
})
return pool
}
// TestEnqueueDequeueComplete ist Akzeptanzkriterium 1: Jobs werden
// zuverlaessig eingereiht und verarbeitet.
func TestEnqueueDequeueComplete(t *testing.T) {
pool := setupTest(t)
q := NewQueue(pool, time.Minute)
ctx := context.Background()
id, err := q.Enqueue(ctx, "index-document", map[string]string{"document_id": "d1"}, EnqueueOptions{})
if err != nil {
t.Fatalf("enqueue: %v", err)
}
job, err := q.Dequeue(ctx, "worker-1", nil)
if err != nil {
t.Fatalf("dequeue: %v", err)
}
if job.ID != id {
t.Fatalf("dequeue lieferte job %q, want %q", job.ID, id)
}
if job.Status != StatusProcessing {
t.Fatalf("status nach dequeue = %q, want %q", job.Status, StatusProcessing)
}
var payload map[string]string
if err := json.Unmarshal(job.Payload, &payload); err != nil {
t.Fatalf("payload dekodieren: %v", err)
}
if payload["document_id"] != "d1" {
t.Fatalf("payload = %v, want document_id=d1", payload)
}
if err := q.Complete(ctx, job.ID); err != nil {
t.Fatalf("complete: %v", err)
}
status, err := q.Status(ctx, job.ID)
if err != nil {
t.Fatalf("status: %v", err)
}
if status.Status != StatusSucceeded {
t.Fatalf("endstatus = %q, want %q", status.Status, StatusSucceeded)
}
}
// TestDequeue_NoJobAvailable prueft den Leerfall.
func TestDequeue_NoJobAvailable(t *testing.T) {
pool := setupTest(t)
q := NewQueue(pool, time.Minute)
if _, err := q.Dequeue(context.Background(), "worker-1", nil); !errors.Is(err, ErrNoJobAvailable) {
t.Fatalf("erwartet ErrNoJobAvailable, habe %v", err)
}
}
// TestFail_LandsInDeadLetterAfterMaxAttempts ist Akzeptanzkriterium 2.
func TestFail_LandsInDeadLetterAfterMaxAttempts(t *testing.T) {
pool := setupTest(t)
q := NewQueue(pool, time.Minute)
ctx := context.Background()
id, err := q.Enqueue(ctx, "ocr", nil, EnqueueOptions{MaxAttempts: 2})
if err != nil {
t.Fatalf("enqueue: %v", err)
}
// Versuch 1: schlaegt fehl, geht zurueck nach "pending" (max_attempts=2 noch nicht erreicht).
job, err := q.Dequeue(ctx, "worker-1", nil)
if err != nil {
t.Fatalf("dequeue 1: %v", err)
}
if err := q.Fail(ctx, job.ID, errors.New("ocr-engine nicht erreichbar")); err != nil {
t.Fatalf("fail 1: %v", err)
}
status, err := q.Status(ctx, id)
if err != nil {
t.Fatalf("status nach fail 1: %v", err)
}
if status.Status != StatusPending {
t.Fatalf("status nach fail 1 = %q, want %q (noch nicht erschoepft)", status.Status, StatusPending)
}
// Versuch 2: Backoff manuell umgehen (available_at direkt zuruecksetzen,
// damit der Test nicht auf echten Backoff warten muss).
if _, err := pool.Exec(ctx, `UPDATE processing_jobs SET available_at = now() WHERE id = $1`, id); err != nil {
t.Fatalf("available_at zuruecksetzen: %v", err)
}
job2, err := q.Dequeue(ctx, "worker-1", nil)
if err != nil {
t.Fatalf("dequeue 2: %v", err)
}
if err := q.Fail(ctx, job2.ID, errors.New("ocr-engine weiterhin nicht erreichbar")); err != nil {
t.Fatalf("fail 2: %v", err)
}
final, err := q.Status(ctx, id)
if err != nil {
t.Fatalf("status nach fail 2: %v", err)
}
if final.Status != StatusDeadLetter {
t.Fatalf("status nach erschoepften versuchen = %q, want %q", final.Status, StatusDeadLetter)
}
if final.LastError == nil || *final.LastError == "" {
t.Fatal("erwartet gesetzten last_error im dead-letter-eintrag")
}
}
// TestRequeueDeadLetter ist Pruefung 3: DLQ-Eintrag manuell wiederholbar.
func TestRequeueDeadLetter(t *testing.T) {
pool := setupTest(t)
q := NewQueue(pool, time.Minute)
ctx := context.Background()
id, err := q.Enqueue(ctx, "convert", nil, EnqueueOptions{MaxAttempts: 1})
if err != nil {
t.Fatalf("enqueue: %v", err)
}
job, err := q.Dequeue(ctx, "worker-1", nil)
if err != nil {
t.Fatalf("dequeue: %v", err)
}
if err := q.Fail(ctx, job.ID, errors.New("konverter abgestuerzt")); err != nil {
t.Fatalf("fail: %v", err)
}
status, _ := q.Status(ctx, id)
if status.Status != StatusDeadLetter {
t.Fatalf("voraussetzung nicht erfuellt: job sollte in dead_letter stehen, ist %q", status.Status)
}
if err := q.RequeueDeadLetter(ctx, id); err != nil {
t.Fatalf("requeuedeadletter: %v", err)
}
afterRequeue, err := q.Status(ctx, id)
if err != nil {
t.Fatalf("status nach requeue: %v", err)
}
if afterRequeue.Status != StatusPending {
t.Fatalf("status nach requeue = %q, want %q", afterRequeue.Status, StatusPending)
}
if afterRequeue.Attempts != 0 {
t.Fatalf("attempts nach requeue = %d, want 0", afterRequeue.Attempts)
}
// Requeue eines NICHT in dead_letter stehenden Jobs wird abgewiesen.
if err := q.RequeueDeadLetter(ctx, id); !errors.Is(err, ErrNotFound) {
t.Fatalf("requeue eines pending-jobs: erwartet ErrNotFound, habe %v", err)
}
}
// TestEnqueue_IdempotencyKeyPreventsDuplicate ist Pruefung 2: Idempotenz
// bei Doppelzustellung nachgewiesen (auf Einreih-Ebene).
func TestEnqueue_IdempotencyKeyPreventsDuplicate(t *testing.T) {
pool := setupTest(t)
q := NewQueue(pool, time.Minute)
ctx := context.Background()
id1, err := q.Enqueue(ctx, "ocr", nil, EnqueueOptions{IdempotencyKey: "ocr:revision-42"})
if err != nil {
t.Fatalf("enqueue 1: %v", err)
}
id2, err := q.Enqueue(ctx, "ocr", nil, EnqueueOptions{IdempotencyKey: "ocr:revision-42"})
if err != nil {
t.Fatalf("enqueue 2 (doppelzustellung): %v", err)
}
if id1 != id2 {
t.Fatalf("doppelte einreihung mit gleichem idempotency-key erzeugte zwei jobs: %q != %q", id1, id2)
}
var count int
if err := pool.QueryRow(ctx, `SELECT count(*) FROM processing_jobs WHERE idempotency_key = 'ocr:revision-42'`).Scan(&count); err != nil {
t.Fatalf("zeilen zaehlen: %v", err)
}
if count != 1 {
t.Fatalf("erwartet genau 1 zeile fuer den idempotency-key, habe %d", count)
}
}
-75
View File
@@ -1,75 +0,0 @@
package jobqueue
import (
"context"
"errors"
"log"
"time"
)
// Handler verarbeitet einen einzelnen Job. Ein zurueckgegebener Fehler
// fuehrt zu Queue.Fail (Backoff/DLQ), nil zu Queue.Complete.
type Handler func(ctx context.Context, job *Job) error
// Worker pollt die Queue in einer In-Prozess-Goroutine und ruft Handler je
// abgeholtem Job auf — die "In-Prozess-Worker-Goroutinen" aus der
// Ticket-Vorgabe, kein separater Prozess/Redis noetig.
type Worker struct {
queue *Queue
workerID string
jobTypes []string
pollInterval time.Duration
handler Handler
}
func NewWorker(queue *Queue, workerID string, jobTypes []string, pollInterval time.Duration, handler Handler) *Worker {
return &Worker{queue: queue, workerID: workerID, jobTypes: jobTypes, pollInterval: pollInterval, handler: handler}
}
// Run blockiert, bis ctx beendet wird, und verarbeitet dabei fortlaufend
// Jobs. Absturzsicherheit (Pruefung 1) entsteht NICHT durch Run selbst,
// sondern dadurch, dass ein abgestuerzter Prozess (der Run gar nicht mehr
// ausfuehrt) seine "processing"-Sperren nie verlaengert — ein ANDERER
// Worker-Prozess holt den Job nach Ablauf von staleLockAfter erneut ab
// (siehe Queue.Dequeue).
func (w *Worker) Run(ctx context.Context) {
ticker := time.NewTicker(w.pollInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
w.processOne(ctx)
}
}
}
// processOne holt und verarbeitet EINEN Job, falls verfuegbar. Oeffentlich
// über RunOnce fuer Tests, die deterministisch (ohne Polling-Timing) einen
// einzelnen Verarbeitungsschritt auslösen wollen.
func (w *Worker) processOne(ctx context.Context) {
job, err := w.queue.Dequeue(ctx, w.workerID, w.jobTypes)
if err != nil {
if !errors.Is(err, ErrNoJobAvailable) {
log.Printf("jobqueue: dequeue fehlgeschlagen: %v", err)
}
return
}
if handlerErr := w.handler(ctx, job); handlerErr != nil {
if err := w.queue.Fail(ctx, job.ID, handlerErr); err != nil {
log.Printf("jobqueue: fehlschlag fuer job %q nicht erfassbar: %v", job.ID, err)
}
return
}
if err := w.queue.Complete(ctx, job.ID); err != nil {
log.Printf("jobqueue: abschluss fuer job %q fehlgeschlagen: %v", job.ID, err)
}
}
// RunOnce verarbeitet synchron genau einen Job (falls verfuegbar) und
// kehrt zurueck — fuer Tests, die ohne Polling-Intervall arbeiten wollen.
func (w *Worker) RunOnce(ctx context.Context) {
w.processOne(ctx)
}
-115
View File
@@ -1,115 +0,0 @@
package jobqueue
import (
"context"
"errors"
"sync/atomic"
"testing"
"time"
)
// TestWorker_RunOnce_ProcessesAndCompletesJob ist der End-to-End-Nachweis
// fuer Akzeptanzkriterium 1 ueber den Worker statt direkt ueber Queue.
func TestWorker_RunOnce_ProcessesAndCompletesJob(t *testing.T) {
pool := setupTest(t)
q := NewQueue(pool, time.Minute)
ctx := context.Background()
id, err := q.Enqueue(ctx, "index-document", nil, EnqueueOptions{})
if err != nil {
t.Fatalf("enqueue: %v", err)
}
var handled int32
w := NewWorker(q, "worker-1", nil, time.Millisecond, func(ctx context.Context, job *Job) error {
atomic.AddInt32(&handled, 1)
return nil
})
w.RunOnce(ctx)
if atomic.LoadInt32(&handled) != 1 {
t.Fatalf("handler wurde %d mal aufgerufen, want 1", handled)
}
status, err := q.Status(ctx, id)
if err != nil {
t.Fatalf("status: %v", err)
}
if status.Status != StatusSucceeded {
t.Fatalf("status = %q, want %q", status.Status, StatusSucceeded)
}
}
// TestWorker_HandlerErrorTriggersFail prueft, dass ein Handler-Fehler zu
// Queue.Fail fuehrt (Backoff/DLQ-Pfad ueber den Worker statt direkt).
func TestWorker_HandlerErrorTriggersFail(t *testing.T) {
pool := setupTest(t)
q := NewQueue(pool, time.Minute)
ctx := context.Background()
id, err := q.Enqueue(ctx, "ocr", nil, EnqueueOptions{MaxAttempts: 5})
if err != nil {
t.Fatalf("enqueue: %v", err)
}
w := NewWorker(q, "worker-1", nil, time.Millisecond, func(ctx context.Context, job *Job) error {
return errors.New("ocr fehlgeschlagen")
})
w.RunOnce(ctx)
status, err := q.Status(ctx, id)
if err != nil {
t.Fatalf("status: %v", err)
}
if status.Status != StatusPending {
t.Fatalf("status nach handler-fehler = %q, want %q (erneut eingeplant)", status.Status, StatusPending)
}
if status.LastError == nil || *status.LastError != "ocr fehlgeschlagen" {
t.Fatalf("last_error = %v, want %q", status.LastError, "ocr fehlgeschlagen")
}
}
// TestDequeue_StaleLockIsRedelivered ist Pruefung 1: Absturz eines Workers
// fuehrt zu erneuter Zustellung. Simuliert einen Absturz, indem ein Job
// dequeued (auf "processing" gesperrt), aber NIE completed/failed wird —
// nach Ablauf von staleLockAfter muss ein ANDERER Worker denselben Job
// erneut abholen koennen.
func TestDequeue_StaleLockIsRedelivered(t *testing.T) {
pool := setupTest(t)
// Sehr kurzes Stale-Fenster, damit der Test nicht lange warten muss.
q := NewQueue(pool, 50*time.Millisecond)
ctx := context.Background()
id, err := q.Enqueue(ctx, "convert", nil, EnqueueOptions{})
if err != nil {
t.Fatalf("enqueue: %v", err)
}
crashed, err := q.Dequeue(ctx, "worker-crashed", nil)
if err != nil {
t.Fatalf("dequeue (worker-crashed): %v", err)
}
if crashed.ID != id {
t.Fatalf("dequeue lieferte unerwarteten job %q", crashed.ID)
}
// worker-crashed ruft absichtlich weder Complete noch Fail auf — simuliert
// einen Prozessabsturz mitten in der Verarbeitung.
// Sofortiger erneuter Dequeue-Versuch (Sperre noch frisch) darf den Job
// NICHT liefern.
if _, err := q.Dequeue(ctx, "worker-2", nil); !errors.Is(err, ErrNoJobAvailable) {
t.Fatalf("job wurde trotz frischer sperre erneut ausgeliefert (oder anderer fehler): %v", err)
}
time.Sleep(80 * time.Millisecond) // > staleLockAfter
redelivered, err := q.Dequeue(ctx, "worker-2", nil)
if err != nil {
t.Fatalf("dequeue nach ablauf der sperre: %v", err)
}
if redelivered.ID != id {
t.Fatalf("erneut zugestellter job = %q, want %q", redelivered.ID, id)
}
if err := q.Complete(ctx, redelivered.ID); err != nil {
t.Fatalf("complete durch worker-2: %v", err)
}
}
-165
View File
@@ -1,165 +0,0 @@
// Package migrate implementiert FDN-02s Migrationsmechanik: versionierte,
// rueckrollbare SQL-Migrationsdateien (kein ORM), mit einer
// schema_migrations-Tabelle als Fortschrittsspeicher — dasselbe Prinzip wie
// NEXARCH Core (internal/migrate), hier eigenstaendig implementiert, da DMS
// ein eigenes Go-Modul ist und Cores internal/-Pakete nicht importieren
// kann.
package migrate
import (
"context"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"github.com/jackc/pgx/v5/pgxpool"
)
// Migration ist eine einzelne versionierte Migrationsdatei.
type Migration struct {
Version string // Dateiname ohne .up.sql/.down.sql, z.B. "0001_documents"
UpSQL string
DownSQL string
}
// Load liest alle *.up.sql/*.down.sql-Paare aus dir, sortiert nach
// Dateiname (Akzeptanzkriterium: Migrationen sind versioniert).
func Load(dir string) ([]Migration, error) {
entries, err := os.ReadDir(dir)
if err != nil {
return nil, fmt.Errorf("migrationsverzeichnis %q lesen: %w", dir, err)
}
var versions []string
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".up.sql") {
continue
}
versions = append(versions, strings.TrimSuffix(e.Name(), ".up.sql"))
}
sort.Strings(versions)
migrations := make([]Migration, 0, len(versions))
for _, v := range versions {
up, err := os.ReadFile(filepath.Join(dir, v+".up.sql"))
if err != nil {
return nil, fmt.Errorf("migration %q: up.sql lesen: %w", v, err)
}
down, err := os.ReadFile(filepath.Join(dir, v+".down.sql"))
if err != nil {
return nil, fmt.Errorf("migration %q: down.sql lesen (jede Migration braucht ein Rollback): %w", v, err)
}
migrations = append(migrations, Migration{Version: v, UpSQL: string(up), DownSQL: string(down)})
}
return migrations, nil
}
func ensureTrackingTable(ctx context.Context, pool *pgxpool.Pool) error {
_, err := pool.Exec(ctx, `
CREATE TABLE IF NOT EXISTS schema_migrations (
version TEXT PRIMARY KEY,
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
`)
if err != nil {
return fmt.Errorf("schema_migrations anlegen: %w", err)
}
return nil
}
func appliedVersions(ctx context.Context, pool *pgxpool.Pool) (map[string]bool, error) {
rows, err := pool.Query(ctx, `SELECT version FROM schema_migrations`)
if err != nil {
return nil, fmt.Errorf("angewendete migrationen lesen: %w", err)
}
defer rows.Close()
applied := map[string]bool{}
for rows.Next() {
var v string
if err := rows.Scan(&v); err != nil {
return nil, fmt.Errorf("migrationsversion lesen: %w", err)
}
applied[v] = true
}
return applied, rows.Err()
}
// Up wendet alle noch nicht angewendeten Migrationen in Reihenfolge an
// (Akzeptanzkriterium 2: vorwaerts ausfuehrbar) — bereits angewendete
// werden uebersprungen, damit Up auf einer leeren UND auf einer bestehenden
// DB funktioniert (Pruefung 1).
func Up(ctx context.Context, pool *pgxpool.Pool, migrations []Migration) (applied []string, err error) {
if err := ensureTrackingTable(ctx, pool); err != nil {
return nil, err
}
already, err := appliedVersions(ctx, pool)
if err != nil {
return nil, err
}
for _, m := range migrations {
if already[m.Version] {
continue
}
tx, err := pool.Begin(ctx)
if err != nil {
return applied, fmt.Errorf("transaktion fuer %q starten: %w", m.Version, err)
}
if _, err := tx.Exec(ctx, m.UpSQL); err != nil {
_ = tx.Rollback(ctx)
return applied, fmt.Errorf("migration %q anwenden: %w", m.Version, err)
}
if _, err := tx.Exec(ctx, `INSERT INTO schema_migrations (version) VALUES ($1)`, m.Version); err != nil {
_ = tx.Rollback(ctx)
return applied, fmt.Errorf("migration %q als angewendet markieren: %w", m.Version, err)
}
if err := tx.Commit(ctx); err != nil {
return applied, fmt.Errorf("migration %q committen: %w", m.Version, err)
}
applied = append(applied, m.Version)
}
return applied, nil
}
// DownOne macht die zuletzt angewendete Migration rueckgaengig
// (Akzeptanzkriterium 2: rueckwaerts ausfuehrbar) und liefert deren Version,
// oder "" falls keine Migration angewendet war.
func DownOne(ctx context.Context, pool *pgxpool.Pool, migrations []Migration) (version string, err error) {
if err := ensureTrackingTable(ctx, pool); err != nil {
return "", err
}
already, err := appliedVersions(ctx, pool)
if err != nil {
return "", err
}
var last *Migration
for i := len(migrations) - 1; i >= 0; i-- {
if already[migrations[i].Version] {
last = &migrations[i]
break
}
}
if last == nil {
return "", nil
}
tx, err := pool.Begin(ctx)
if err != nil {
return "", fmt.Errorf("transaktion fuer rollback von %q starten: %w", last.Version, err)
}
if _, err := tx.Exec(ctx, last.DownSQL); err != nil {
_ = tx.Rollback(ctx)
return "", fmt.Errorf("migration %q zurueckrollen: %w", last.Version, err)
}
if _, err := tx.Exec(ctx, `DELETE FROM schema_migrations WHERE version = $1`, last.Version); err != nil {
_ = tx.Rollback(ctx)
return "", fmt.Errorf("migration %q aus schema_migrations entfernen: %w", last.Version, err)
}
if err := tx.Commit(ctx); err != nil {
return "", fmt.Errorf("rollback von %q committen: %w", last.Version, err)
}
return last.Version, nil
}
-213
View File
@@ -1,213 +0,0 @@
package migrate
import (
"context"
"os"
"path/filepath"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
)
// usersFixtureSQL spiegelt Core IAM-01s reales Schema
// (migrations/tenant/0001_users.up.sql im NEXARCH-Core-Modul) - DMS ist ein
// eigenes Go-Modul und kann Cores Migrationsdateien nicht importieren, daher
// hier als Testfixture kopiert, NICHT als Produktionsmigration (DMS legt
// users nicht selbst an, siehe "Nicht Bestandteil" in FDN-02).
const usersFixtureSQL = `
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
`
func setupTest(t *testing.T) (*pgxpool.Pool, []Migration) {
t.Helper()
dsn := os.Getenv("TEST_TENANT_DSN")
if dsn == "" {
t.Skip("TEST_TENANT_DSN nicht gesetzt, Integrationstest uebersprungen")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatalf("pool: %v", err)
}
t.Cleanup(func() { pool.Close() })
if _, err := pool.Exec(ctx, usersFixtureSQL); err != nil {
t.Fatalf("users-fixture anlegen: %v", err)
}
if _, err := pool.Exec(ctx, `
INSERT INTO users (email, name) VALUES ('seed@example.test', 'Seed-Benutzer')
ON CONFLICT (email) DO NOTHING
`); err != nil {
t.Fatalf("seed-benutzer anlegen: %v", err)
}
migrations, err := Load(migrationsDir(t))
if err != nil {
t.Fatalf("migrationen laden: %v", err)
}
return pool, migrations
}
func migrationsDir(t *testing.T) string {
t.Helper()
wd, err := os.Getwd()
if err != nil {
t.Fatalf("getwd: %v", err)
}
return filepath.Join(wd, "..", "..", "migrations", "tenant")
}
func tableExists(t *testing.T, ctx context.Context, pool *pgxpool.Pool, name string) bool {
t.Helper()
var exists bool
if err := pool.QueryRow(ctx, `
SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = $1)
`, name).Scan(&exists); err != nil {
t.Fatalf("tabellenexistenz von %q pruefen: %v", name, err)
}
return exists
}
// TestUp_OnEmptyAndExistingDB ist Pruefung 1: Migration auf leerer DB und
// auf bestehender DB getestet.
func TestUp_OnEmptyAndExistingDB(t *testing.T) {
pool, migrations := setupTest(t)
ctx := context.Background()
applied, err := Up(ctx, pool, migrations)
if err != nil {
t.Fatalf("up (leere db): %v", err)
}
if len(applied) == 0 {
t.Fatal("erwartet mindestens 1 angewendete migration auf leerer db")
}
for _, table := range []string{"folders", "documents", "file_revisions", "tags", "document_tags", "metadata_fields", "document_metadata_values"} {
if !tableExists(t, ctx, pool, table) {
t.Fatalf("tabelle %q existiert nach Up nicht", table)
}
}
// Zweiter Up-Lauf gegen die JETZT BESTEHENDE db - muss ohne Fehler
// durchlaufen und darf nichts erneut anwenden (idempotent ueber
// schema_migrations).
appliedAgain, err := Up(ctx, pool, migrations)
if err != nil {
t.Fatalf("up (bestehende db, zweiter lauf): %v", err)
}
if len(appliedAgain) != 0 {
t.Fatalf("zweiter Up-Lauf haette 0 neue migrationen anwenden sollen, hat %d", len(appliedAgain))
}
}
// TestDownOne_RestoresPreviousState ist Pruefung 2: Rollback stellt den
// Vorzustand wieder her.
func TestDownOne_RestoresPreviousState(t *testing.T) {
pool, migrations := setupTest(t)
ctx := context.Background()
if _, err := Up(ctx, pool, migrations); err != nil {
t.Fatalf("up: %v", err)
}
if !tableExists(t, ctx, pool, "documents") {
t.Fatal("voraussetzung nicht erfuellt: documents sollte nach Up existieren")
}
// DownOne rollt IMMER nur die zuletzt angewendete Migration zurueck
// (dokumentiertes Verhalten) — bei mehreren Migrationen (z.B. FDN-02
// documents + FDN-04 processing_jobs) muss man entsprechend oft
// aufrufen. Anzahl der Wiederholungen richtet sich NICHT nach dem
// Rueckgabewert von Up() (der nur die in DIESEM Aufruf NEU angewendeten
// Migrationen zaehlt, siehe Kommentar an Up) — stattdessen wird
// wiederholt, bis DownOne "" liefert (schema_migrations leer).
for i := 0; i < len(migrations); i++ {
version, err := DownOne(ctx, pool, migrations)
if err != nil {
t.Fatalf("downone (lauf %d): %v", i, err)
}
if version == "" {
break
}
}
for _, table := range []string{"folders", "documents", "file_revisions", "tags", "document_tags", "metadata_fields", "document_metadata_values", "processing_jobs"} {
if tableExists(t, ctx, pool, table) {
t.Fatalf("tabelle %q existiert nach vollstaendigem Rollback noch - Vorzustand nicht wiederhergestellt", table)
}
}
// Erneutes DownOne ohne verbleibende angewendete Migration liefert "".
versionAfterAll, err := DownOne(ctx, pool, migrations)
if err != nil {
t.Fatalf("downone (nichts mehr anzuwenden): %v", err)
}
if versionAfterAll != "" {
t.Fatalf("erwartet leeren string bei leerer schema_migrations, habe %q", versionAfterAll)
}
}
// TestForeignKeyConstraints_RejectInvalidReferences ist Pruefung 3:
// Fremdschluessel-Constraints durch Negativtests belegt.
func TestForeignKeyConstraints_RejectInvalidReferences(t *testing.T) {
pool, migrations := setupTest(t)
ctx := context.Background()
if _, err := Up(ctx, pool, migrations); err != nil {
t.Fatalf("up: %v", err)
}
var userID string
if err := pool.QueryRow(ctx, `SELECT id FROM users LIMIT 1`).Scan(&userID); err != nil {
t.Fatalf("seed-benutzer lesen: %v", err)
}
t.Run("dokument mit unbekanntem ordner wird abgewiesen", func(t *testing.T) {
_, err := pool.Exec(ctx, `
INSERT INTO documents (folder_id, title, created_by) VALUES (gen_random_uuid(), 'x', $1)
`, userID)
if err == nil {
t.Fatal("insert mit unbekanntem folder_id haette scheitern muessen")
}
})
t.Run("dokument mit unbekanntem ersteller wird abgewiesen", func(t *testing.T) {
_, err := pool.Exec(ctx, `
INSERT INTO documents (title, created_by) VALUES ('x', gen_random_uuid())
`)
if err == nil {
t.Fatal("insert mit unbekanntem created_by haette scheitern muessen")
}
})
t.Run("datei-revision mit unbekanntem dokument wird abgewiesen", func(t *testing.T) {
_, err := pool.Exec(ctx, `
INSERT INTO file_revisions (document_id, revision_number, storage_key, checksum_sha256, size_bytes, mime_type, created_by)
VALUES (gen_random_uuid(), 1, 'k', repeat('0',64), 1, 'text/plain', $1)
`, userID)
if err == nil {
t.Fatal("insert mit unbekanntem document_id haette scheitern muessen")
}
})
t.Run("tag-zuordnung mit unbekanntem tag wird abgewiesen", func(t *testing.T) {
var docID string
if err := pool.QueryRow(ctx, `
INSERT INTO documents (title, created_by) VALUES ('fk-test-doc', $1) RETURNING id
`, userID).Scan(&docID); err != nil {
t.Fatalf("testdokument anlegen: %v", err)
}
_, err := pool.Exec(ctx, `
INSERT INTO document_tags (document_id, tag_id) VALUES ($1, gen_random_uuid())
`, docID)
if err == nil {
t.Fatal("insert mit unbekanntem tag_id haette scheitern muessen")
}
})
}
-8
View File
@@ -1,8 +0,0 @@
// Package shared enthaelt Code, der von App und Worker gemeinsam genutzt
// wird (FDN-01) — Datenmodell, Storage-Zugriff etc. kommen in spaeteren
// Kacheln (FDN-02/FDN-03) hierher, dieses Paket ist bewusst noch schlank.
package shared
// Version ist die aktuelle DMS-Version, per -ldflags ueberschreibbar
// (siehe Makefile) — Platzhalter fuer echtes Versionsmanagement.
var Version = "dev"
-45
View File
@@ -1,45 +0,0 @@
// Package storage implementiert FDN-03: eine einheitliche Objekt-Storage-
// Abstraktion mit zwei austauschbaren Treibern (lokal fuer Entwicklung,
// S3-kompatibel fuer Produktion). Verschluesselung at rest ist NICHT
// Bestandteil dieser Kachel (siehe FDN-09) — dieses Paket legt Bytes
// unveraendert ab.
package storage
import (
"context"
"errors"
"io"
"time"
)
// ErrNotFound wird geliefert, wenn ein angefragtes Objekt nicht existiert
// (Akzeptanzkriterium/Pruefung 3: klarer Fehler statt treiberspezifischer
// Fehlertypen, die der Aufrufer sonst je Treiber unterschiedlich behandeln
// muesste).
var ErrNotFound = errors.New("storage: objekt nicht gefunden")
// Driver ist die EINE Schnittstelle, gegen die der Rest von DMS arbeitet
// (Akzeptanzkriterium 1). Zwei Implementierungen: LocalDriver (Entwicklung)
// und S3Driver (Produktion, S3-kompatibel).
type Driver interface {
// Put legt die Bytes aus r unter key ab und liefert die tatsaechlich
// geschriebene Groesse in Bytes.
Put(ctx context.Context, key string, r io.Reader, size int64, contentType string) (int64, error)
// Get liefert die Bytes unter key. Existiert key nicht, liefert Get
// ErrNotFound.
Get(ctx context.Context, key string) (io.ReadCloser, error)
// Delete entfernt das Objekt unter key. Existiert key nicht, liefert
// Delete ErrNotFound.
Delete(ctx context.Context, key string) error
// SignedURL liefert eine zeitlich begrenzte, signierte URL zum Lesen des
// Objekts (Akzeptanzkriterium 2: konfigurierbare Gueltigkeit ueber ttl).
SignedURL(ctx context.Context, key string, ttl time.Duration) (string, error)
}
// ObjectKey liefert das Pfadschema fuer ein Dokument/Revision INNERHALB des
// Mandanten-Buckets (Akzeptanzkriterium 3) — die Bucket-Trennung selbst ist
// Sache von Core TEN-01, hier geht es nur um den Pfad innerhalb eines
// bereits mandantenspezifischen Buckets.
func ObjectKey(documentID, revisionID string) string {
return "documents/" + documentID + "/revisions/" + revisionID
}
-113
View File
@@ -1,113 +0,0 @@
package storage
import (
"context"
"crypto/hmac"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strconv"
"strings"
"time"
)
// ErrURLExpired wird von VerifySignedURL geliefert, wenn eine signierte URL
// nach Ablauf ihrer Gueltigkeit verwendet wird (Pruefung 2).
var ErrURLExpired = errors.New("storage: signierte url ist abgelaufen")
// ErrInvalidSignature wird geliefert, wenn die Signatur einer URL nicht zum
// Schluessel passt (manipulierte oder falsche URL).
var ErrInvalidSignature = errors.New("storage: signatur der url ist ungueltig")
// LocalDriver legt Objekte im lokalen Dateisystem ab — der Entwicklungs-
// Treiber (Akzeptanzkriterium 1), keine externe Abhaengigkeit noetig.
type LocalDriver struct {
baseDir string
signingSecret []byte
publicBaseURL string
}
// NewLocalDriver erzeugt einen LocalDriver. signingSecret authentifiziert
// die von SignedURL ausgestellten URLs (HMAC-SHA256, konstant-zeit-
// verglichen bei der Verifikation — timing-safe wie projektweite Konvention,
// siehe Core IAM-15).
func NewLocalDriver(baseDir string, signingSecret []byte, publicBaseURL string) *LocalDriver {
return &LocalDriver{baseDir: baseDir, signingSecret: signingSecret, publicBaseURL: publicBaseURL}
}
func (d *LocalDriver) path(key string) string {
return filepath.Join(d.baseDir, filepath.FromSlash(key))
}
func (d *LocalDriver) Put(ctx context.Context, key string, r io.Reader, size int64, contentType string) (int64, error) {
full := d.path(key)
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
return 0, fmt.Errorf("storage: verzeichnis anlegen: %w", err)
}
f, err := os.Create(full)
if err != nil {
return 0, fmt.Errorf("storage: datei anlegen: %w", err)
}
defer func() { _ = f.Close() }()
written, err := io.Copy(f, r)
if err != nil {
return 0, fmt.Errorf("storage: schreiben: %w", err)
}
return written, nil
}
func (d *LocalDriver) Get(ctx context.Context, key string) (io.ReadCloser, error) {
f, err := os.Open(d.path(key))
if err != nil {
if os.IsNotExist(err) {
return nil, ErrNotFound
}
return nil, fmt.Errorf("storage: lesen: %w", err)
}
return f, nil
}
func (d *LocalDriver) Delete(ctx context.Context, key string) error {
if err := os.Remove(d.path(key)); err != nil {
if os.IsNotExist(err) {
return ErrNotFound
}
return fmt.Errorf("storage: loeschen: %w", err)
}
return nil
}
func (d *LocalDriver) SignedURL(ctx context.Context, key string, ttl time.Duration) (string, error) {
expiry := time.Now().Add(ttl).Unix()
sig := d.sign(key, expiry)
return fmt.Sprintf("%s/%s?exp=%d&sig=%s", strings.TrimRight(d.publicBaseURL, "/"), key, expiry, sig), nil
}
func (d *LocalDriver) sign(key string, expiry int64) string {
mac := hmac.New(sha256.New, d.signingSecret)
mac.Write([]byte(key))
mac.Write([]byte(strconv.FormatInt(expiry, 10)))
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
}
// VerifySignedURL prueft key/expiry/sig, wie sie z.B. aus den Query-
// Parametern einer von SignedURL ausgestellten URL stammen (Pruefung 2:
// abgelaufene URL wird abgewiesen). Die eigentliche HTTP-Auslieferung ist
// nicht Bestandteil dieser Kachel (siehe DOC-01) — hier wird nur die
// Signatur-/Ablauflogik bereitgestellt und getestet.
func (d *LocalDriver) VerifySignedURL(key string, expiry int64, sig string) error {
expected := d.sign(key, expiry)
if subtle.ConstantTimeCompare([]byte(expected), []byte(sig)) != 1 {
return ErrInvalidSignature
}
if time.Now().Unix() > expiry {
return ErrURLExpired
}
return nil
}
-108
View File
@@ -1,108 +0,0 @@
package storage
import (
"bytes"
"context"
"errors"
"io"
"net/url"
"strconv"
"testing"
"time"
)
func newTestLocalDriver(t *testing.T) *LocalDriver {
t.Helper()
return NewLocalDriver(t.TempDir(), []byte("test-signing-secret"), "https://files.example.test")
}
// TestLocalDriver_RoundTrip ist Pruefung 1 fuer den lokalen Treiber:
// Upload/Download-Roundtrip.
func TestLocalDriver_RoundTrip(t *testing.T) {
d := newTestLocalDriver(t)
ctx := context.Background()
key := "documents/doc-1/revisions/rev-1"
content := []byte("hallo welt")
written, err := d.Put(ctx, key, bytes.NewReader(content), int64(len(content)), "text/plain")
if err != nil {
t.Fatalf("put: %v", err)
}
if written != int64(len(content)) {
t.Fatalf("geschriebene groesse = %d, want %d", written, len(content))
}
rc, err := d.Get(ctx, key)
if err != nil {
t.Fatalf("get: %v", err)
}
defer func() { _ = rc.Close() }()
got, err := io.ReadAll(rc)
if err != nil {
t.Fatalf("lesen: %v", err)
}
if !bytes.Equal(got, content) {
t.Fatalf("gelesener inhalt = %q, want %q", got, content)
}
}
// TestLocalDriver_MissingObject ist Pruefung 3: klarer Fehler bei
// fehlendem Objekt, sowohl fuer Get als auch Delete.
func TestLocalDriver_MissingObject(t *testing.T) {
d := newTestLocalDriver(t)
ctx := context.Background()
if _, err := d.Get(ctx, "nie-angelegt"); !errors.Is(err, ErrNotFound) {
t.Fatalf("get eines fehlenden objekts: erwartet ErrNotFound, habe %v", err)
}
if err := d.Delete(ctx, "nie-angelegt"); !errors.Is(err, ErrNotFound) {
t.Fatalf("delete eines fehlenden objekts: erwartet ErrNotFound, habe %v", err)
}
}
// TestLocalDriver_SignedURL_ExpiredIsRejected ist Pruefung 2: eine
// abgelaufene signierte URL wird abgewiesen.
func TestLocalDriver_SignedURL_ExpiredIsRejected(t *testing.T) {
d := newTestLocalDriver(t)
ctx := context.Background()
key := "documents/doc-2/revisions/rev-1"
// Gueltige, noch nicht abgelaufene URL wird akzeptiert.
urlValid, err := d.SignedURL(ctx, key, time.Hour)
if err != nil {
t.Fatalf("signedurl (gueltig): %v", err)
}
expiry, sig := parseSignedURLQuery(t, urlValid)
if err := d.VerifySignedURL(key, expiry, sig); err != nil {
t.Fatalf("gueltige url wurde abgewiesen: %v", err)
}
// Bereits abgelaufene URL (negative TTL) wird abgewiesen.
urlExpired, err := d.SignedURL(ctx, key, -time.Hour)
if err != nil {
t.Fatalf("signedurl (abgelaufen): %v", err)
}
expiredExpiry, expiredSig := parseSignedURLQuery(t, urlExpired)
if err := d.VerifySignedURL(key, expiredExpiry, expiredSig); !errors.Is(err, ErrURLExpired) {
t.Fatalf("abgelaufene url: erwartet ErrURLExpired, habe %v", err)
}
// Manipulierte Signatur wird abgewiesen.
if err := d.VerifySignedURL(key, expiry, "manipuliert"); !errors.Is(err, ErrInvalidSignature) {
t.Fatalf("manipulierte signatur: erwartet ErrInvalidSignature, habe %v", err)
}
}
func parseSignedURLQuery(t *testing.T, rawURL string) (expiry int64, sig string) {
t.Helper()
parsed, err := url.Parse(rawURL)
if err != nil {
t.Fatalf("signierte url parsen: %v (%s)", err, rawURL)
}
q := parsed.Query()
expInt, err := strconv.ParseInt(q.Get("exp"), 10, 64)
if err != nil {
t.Fatalf("exp parsen: %v", err)
}
return expInt, q.Get("sig")
}
-135
View File
@@ -1,135 +0,0 @@
package storage
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/aws/aws-sdk-go-v2/service/s3/types"
"github.com/aws/smithy-go"
)
// S3Driver legt Objekte in einem S3-kompatiblen Objektspeicher ab — der
// Produktions-Treiber (Akzeptanzkriterium 1). Funktioniert gegen echtes
// AWS S3 UND gegen jeden S3-kompatiblen Anbieter (MinIO etc.) ueber
// endpointURL — bewusst offenes Objektformat statt Herstellerbindung
// (Produkt-DNA: "jederzeit ohne Herstellerwerkzeug lesbar").
type S3Driver struct {
client *s3.Client
bucket string
}
// NewS3Driver verbindet zu einem S3-kompatiblen Endpunkt. endpointURL leer
// laesst den AWS-SDK-Standardendpunkt (echtes AWS S3) gelten,
// usePathStyle=true ist fuer die meisten Nicht-AWS-S3-kompatiblen Anbieter
// (MinIO, etc.) noetig.
func NewS3Driver(ctx context.Context, bucket, region, endpointURL, accessKeyID, secretAccessKey string, usePathStyle bool) (*S3Driver, error) {
cfg, err := config.LoadDefaultConfig(ctx,
config.WithRegion(region),
config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(accessKeyID, secretAccessKey, "")),
)
if err != nil {
return nil, fmt.Errorf("storage: s3-konfiguration laden: %w", err)
}
client := s3.NewFromConfig(cfg, func(o *s3.Options) {
if endpointURL != "" {
o.BaseEndpoint = aws.String(endpointURL)
}
o.UsePathStyle = usePathStyle
})
return &S3Driver{client: client, bucket: bucket}, nil
}
func (d *S3Driver) Put(ctx context.Context, key string, r io.Reader, size int64, contentType string) (int64, error) {
buf, err := io.ReadAll(r)
if err != nil {
return 0, fmt.Errorf("storage: objekt vor upload lesen: %w", err)
}
_, err = d.client.PutObject(ctx, &s3.PutObjectInput{
Bucket: aws.String(d.bucket),
Key: aws.String(key),
Body: bytesReader(buf),
ContentLength: aws.Int64(int64(len(buf))),
ContentType: aws.String(contentType),
})
if err != nil {
return 0, fmt.Errorf("storage: s3-upload: %w", err)
}
return int64(len(buf)), nil
}
func (d *S3Driver) Get(ctx context.Context, key string) (io.ReadCloser, error) {
out, err := d.client.GetObject(ctx, &s3.GetObjectInput{
Bucket: aws.String(d.bucket),
Key: aws.String(key),
})
if err != nil {
if isS3NotFound(err) {
return nil, ErrNotFound
}
return nil, fmt.Errorf("storage: s3-download: %w", err)
}
return out.Body, nil
}
func (d *S3Driver) Delete(ctx context.Context, key string) error {
// S3 liefert bei DeleteObject fuer ein nicht existierendes Objekt KEINEN
// Fehler (idempotente Semantik der S3-API) — um denselben Vertrag wie
// LocalDriver (ErrNotFound bei fehlendem Objekt) zu erfuellen, wird die
// Existenz vorher explizit geprueft (Pruefung 3).
_, err := d.client.HeadObject(ctx, &s3.HeadObjectInput{Bucket: aws.String(d.bucket), Key: aws.String(key)})
if err != nil {
if isS3NotFound(err) {
return ErrNotFound
}
return fmt.Errorf("storage: s3-existenzpruefung vor loeschen: %w", err)
}
if _, err := d.client.DeleteObject(ctx, &s3.DeleteObjectInput{
Bucket: aws.String(d.bucket),
Key: aws.String(key),
}); err != nil {
return fmt.Errorf("storage: s3-loeschen: %w", err)
}
return nil
}
func (d *S3Driver) SignedURL(ctx context.Context, key string, ttl time.Duration) (string, error) {
presignClient := s3.NewPresignClient(d.client)
req, err := presignClient.PresignGetObject(ctx, &s3.GetObjectInput{
Bucket: aws.String(d.bucket),
Key: aws.String(key),
}, s3.WithPresignExpires(ttl))
if err != nil {
return "", fmt.Errorf("storage: presigned url erzeugen: %w", err)
}
return req.URL, nil
}
func bytesReader(b []byte) *bytes.Reader {
return bytes.NewReader(b)
}
// isS3NotFound erkennt sowohl den typisierten NoSuchKey-Fehler
// (GetObject) als auch den generischen "NotFound"-API-Fehlercode
// (HeadObject liefert keinen typisierten NoSuchKey, sondern einen
// generischen smithy-API-Fehler mit Code "NotFound").
func isS3NotFound(err error) bool {
var nsk *types.NoSuchKey
if errors.As(err, &nsk) {
return true
}
var apiErr smithy.APIError
if errors.As(err, &apiErr) && apiErr.ErrorCode() == "NotFound" {
return true
}
return false
}
-98
View File
@@ -1,98 +0,0 @@
package storage
import (
"bytes"
"context"
"errors"
"io"
"os"
"testing"
"time"
)
// requireS3TestEnv liefert die S3-Testkonfiguration oder ueberspringt den
// Test — dasselbe Muster wie TEST_ADMIN_DSN im Core-Modul: kein S3-
// kompatibler Speicher in dieser Umgebung verfuegbar/geprueft (siehe
// FDN-03-Pruefprotokoll), daher hier bewusst als optional markiert statt
// den Treiber ungetestet zu lassen.
func requireS3TestEnv(t *testing.T) *S3Driver {
t.Helper()
bucket := os.Getenv("TEST_S3_BUCKET")
if bucket == "" {
t.Skip("TEST_S3_BUCKET nicht gesetzt, S3-Integrationstest uebersprungen")
}
endpoint := os.Getenv("TEST_S3_ENDPOINT")
region := os.Getenv("TEST_S3_REGION")
if region == "" {
region = "us-east-1"
}
accessKey := os.Getenv("TEST_S3_ACCESS_KEY_ID")
secretKey := os.Getenv("TEST_S3_SECRET_ACCESS_KEY")
d, err := NewS3Driver(context.Background(), bucket, region, endpoint, accessKey, secretKey, true)
if err != nil {
t.Fatalf("s3-treiber aufbauen: %v", err)
}
return d
}
// TestS3Driver_RoundTrip ist Pruefung 1 fuer den S3-Treiber.
func TestS3Driver_RoundTrip(t *testing.T) {
d := requireS3TestEnv(t)
ctx := context.Background()
key := "fdn03-test/roundtrip"
content := []byte("s3 roundtrip inhalt")
t.Cleanup(func() { _ = d.Delete(ctx, key) })
if _, err := d.Put(ctx, key, bytes.NewReader(content), int64(len(content)), "text/plain"); err != nil {
t.Fatalf("put: %v", err)
}
rc, err := d.Get(ctx, key)
if err != nil {
t.Fatalf("get: %v", err)
}
defer func() { _ = rc.Close() }()
got, err := io.ReadAll(rc)
if err != nil {
t.Fatalf("lesen: %v", err)
}
if !bytes.Equal(got, content) {
t.Fatalf("gelesener inhalt = %q, want %q", got, content)
}
}
// TestS3Driver_MissingObject ist Pruefung 3 fuer den S3-Treiber.
func TestS3Driver_MissingObject(t *testing.T) {
d := requireS3TestEnv(t)
ctx := context.Background()
if _, err := d.Get(ctx, "fdn03-test/nie-angelegt"); !errors.Is(err, ErrNotFound) {
t.Fatalf("get eines fehlenden objekts: erwartet ErrNotFound, habe %v", err)
}
if err := d.Delete(ctx, "fdn03-test/nie-angelegt"); !errors.Is(err, ErrNotFound) {
t.Fatalf("delete eines fehlenden objekts: erwartet ErrNotFound, habe %v", err)
}
}
// TestS3Driver_SignedURL ist Pruefung 2 fuer den S3-Treiber: eine
// presigned URL wird erzeugt und ist innerhalb der Gueltigkeit abrufbar.
func TestS3Driver_SignedURL(t *testing.T) {
d := requireS3TestEnv(t)
ctx := context.Background()
key := "fdn03-test/signed-url"
content := []byte("presigned")
t.Cleanup(func() { _ = d.Delete(ctx, key) })
if _, err := d.Put(ctx, key, bytes.NewReader(content), int64(len(content)), "text/plain"); err != nil {
t.Fatalf("put: %v", err)
}
url, err := d.SignedURL(ctx, key, time.Minute)
if err != nil {
t.Fatalf("signedurl: %v", err)
}
if url == "" {
t.Fatal("erwartet nicht-leere presigned url")
}
}
-59
View File
@@ -1,59 +0,0 @@
package storage
import (
"context"
"fmt"
"io"
"time"
)
// Service verbindet einen Driver mit der Nutzungsmeldung an Core
// (Akzeptanzkriterium 4) — jeder Schreib-/Loeschvorgang ueber Service loest
// GENAU EINE Meldung mit der tatsaechlich geschriebenen/geloeschten
// Objektgroesse aus. Repository-/Handler-Code (spaetere Kacheln, z.B.
// DOC-01) ruft ausschliesslich Service auf, nie einen Driver direkt — das
// verhindert einen Schreibpfad, der die Nutzungsmeldung vergisst.
type Service struct {
driver Driver
usage UsageReporter
tenantSlug string
}
func NewService(driver Driver, usage UsageReporter, tenantSlug string) *Service {
return &Service{driver: driver, usage: usage, tenantSlug: tenantSlug}
}
// Put legt das Objekt ab und meldet die geschriebene Groesse als positives
// Delta (Pruefung 4).
func (s *Service) Put(ctx context.Context, key string, r io.Reader, size int64, contentType string) (int64, error) {
written, err := s.driver.Put(ctx, key, r, size, contentType)
if err != nil {
return 0, err
}
if err := s.usage.Report(ctx, s.tenantSlug, UsageMetric, written); err != nil {
return written, fmt.Errorf("storage: objekt gespeichert, aber nutzungsmeldung fehlgeschlagen: %w", err)
}
return written, nil
}
func (s *Service) Get(ctx context.Context, key string) (io.ReadCloser, error) {
return s.driver.Get(ctx, key)
}
// Delete entfernt das Objekt und meldet dessen Groesse als negatives Delta
// (Pruefung 4) — dafuer muss der Aufrufer die Groesse kennen (z.B. aus
// file_revisions.size_bytes, FDN-02), da Delete selbst die Groesse eines
// bereits geloeschten Objekts nicht mehr ermitteln kann.
func (s *Service) Delete(ctx context.Context, key string, sizeBytes int64) error {
if err := s.driver.Delete(ctx, key); err != nil {
return err
}
if err := s.usage.Report(ctx, s.tenantSlug, UsageMetric, -sizeBytes); err != nil {
return fmt.Errorf("storage: objekt geloescht, aber nutzungsmeldung fehlgeschlagen: %w", err)
}
return nil
}
func (s *Service) SignedURL(ctx context.Context, key string, ttl time.Duration) (string, error) {
return s.driver.SignedURL(ctx, key, ttl)
}
-89
View File
@@ -1,89 +0,0 @@
package storage
import (
"bytes"
"context"
"sync"
"testing"
)
// fakeUsageReporter zeichnet jeden Report-Aufruf auf, damit Tests
// nachweisen koennen, dass Service tatsaechlich meldet (Akzeptanzkriterium
// 4 / Pruefung 4) — ohne echten HTTP-Aufruf gegen Core.
type fakeUsageReporter struct {
mu sync.Mutex
calls []reportCall
failOn int // wenn >0, schlaegt der reportCall-te Aufruf fehl
}
type reportCall struct {
tenantSlug string
metric string
delta int64
}
func (f *fakeUsageReporter) Report(ctx context.Context, tenantSlug, metric string, delta int64) error {
f.mu.Lock()
defer f.mu.Unlock()
f.calls = append(f.calls, reportCall{tenantSlug, metric, delta})
if f.failOn > 0 && len(f.calls) == f.failOn {
return context.DeadlineExceeded
}
return nil
}
// TestService_PutReportsPositiveDelta ist Pruefung 4 (Schreibvorgang):
// Melde-Aufruf an Core wird bei Put ausgeloest, mit korrekter Groesse.
func TestService_PutReportsPositiveDelta(t *testing.T) {
driver := NewLocalDriver(t.TempDir(), []byte("secret"), "https://files.example.test")
usage := &fakeUsageReporter{}
svc := NewService(driver, usage, "acme")
content := []byte("zwoelf bytes")
if _, err := svc.Put(context.Background(), "documents/d1/revisions/r1", bytes.NewReader(content), int64(len(content)), "text/plain"); err != nil {
t.Fatalf("put: %v", err)
}
if len(usage.calls) != 1 {
t.Fatalf("erwartet 1 nutzungsmeldung, habe %d", len(usage.calls))
}
call := usage.calls[0]
if call.tenantSlug != "acme" || call.metric != UsageMetric || call.delta != int64(len(content)) {
t.Fatalf("unerwarteter meldungsinhalt: %+v", call)
}
}
// TestService_DeleteReportsNegativeDelta ist Pruefung 4 (Loeschvorgang).
func TestService_DeleteReportsNegativeDelta(t *testing.T) {
driver := NewLocalDriver(t.TempDir(), []byte("secret"), "https://files.example.test")
usage := &fakeUsageReporter{}
svc := NewService(driver, usage, "acme")
ctx := context.Background()
key := "documents/d2/revisions/r1"
if _, err := svc.Put(ctx, key, bytes.NewReader([]byte("abc")), 3, "text/plain"); err != nil {
t.Fatalf("put: %v", err)
}
if err := svc.Delete(ctx, key, 3); err != nil {
t.Fatalf("delete: %v", err)
}
if len(usage.calls) != 2 {
t.Fatalf("erwartet 2 nutzungsmeldungen (put+delete), habe %d", len(usage.calls))
}
del := usage.calls[1]
if del.delta != -3 {
t.Fatalf("delete-delta = %d, want -3", del.delta)
}
}
// TestService_GetMissingObjectReturnsClearError ist Pruefung 3 auf
// Service-Ebene.
func TestService_GetMissingObjectReturnsClearError(t *testing.T) {
driver := NewLocalDriver(t.TempDir(), []byte("secret"), "https://files.example.test")
svc := NewService(driver, &fakeUsageReporter{}, "acme")
if _, err := svc.Get(context.Background(), "nie-angelegt"); err == nil {
t.Fatal("get eines fehlenden objekts haette einen fehler liefern muessen")
}
}
-75
View File
@@ -1,75 +0,0 @@
package storage
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
)
// UsageMetric ist der Metrikname, unter dem Core (internal/usage, LIC-05)
// den Speicherverbrauch je Mandant fuehrt — muss exakt
// internal/usage.StorageBytesMetric aus dem NEXARCH-Core-Modul entsprechen
// (Core kann von DMS als eigenem Go-Modul nicht importiert werden, daher
// hier als Konstante gespiegelt statt importiert).
const UsageMetric = "storage_bytes"
// UsageReporter meldet Speicherverbrauchsaenderungen an Core (Akzeptanz-
// kriterium 4). Schmale Schnittstelle, damit Tests einen Fake statt eines
// echten HTTP-Aufrufs einsetzen koennen.
type UsageReporter interface {
Report(ctx context.Context, tenantSlug, metric string, delta int64) error
}
// usageDeltaDTO entspricht Core internal/resync.usageDeltaDTO
// (JSON-Vertrag: tenant_slug/metric/delta) — dieselbe Struktur, hier
// gespiegelt, da DMS Cores internal/-Pakete nicht importieren kann.
type usageDeltaDTO struct {
TenantSlug string `json:"tenant_slug"`
Metric string `json:"metric"`
Delta int64 `json:"delta"`
}
// HTTPUsageReporter meldet ueber Cores Resync-Nutzungs-Endpunkt
// (internal/resync.Handler.UsageHandler, API-06), authentifiziert ueber
// dasselbe Service-Credential-Verfahren wie jeder andere Modul-Core-Aufruf
// (API-02).
type HTTPUsageReporter struct {
endpointURL string
clientID string
clientSecret string
httpClient *http.Client
}
func NewHTTPUsageReporter(endpointURL, clientID, clientSecret string, httpClient *http.Client) *HTTPUsageReporter {
if httpClient == nil {
httpClient = http.DefaultClient
}
return &HTTPUsageReporter{endpointURL: endpointURL, clientID: clientID, clientSecret: clientSecret, httpClient: httpClient}
}
func (r *HTTPUsageReporter) Report(ctx context.Context, tenantSlug, metric string, delta int64) error {
body, err := json.Marshal([]usageDeltaDTO{{TenantSlug: tenantSlug, Metric: metric, Delta: delta}})
if err != nil {
return fmt.Errorf("storage: nutzungsmeldung serialisieren: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, r.endpointURL, bytes.NewReader(body))
if err != nil {
return fmt.Errorf("storage: nutzungsmeldungs-anfrage aufbauen: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Nexarch-Client-Id", r.clientID)
req.Header.Set("X-Nexarch-Client-Secret", r.clientSecret)
resp, err := r.httpClient.Do(req)
if err != nil {
return fmt.Errorf("storage: nutzungsmeldung senden: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("storage: nutzungsmeldung von core abgelehnt: status %d", resp.StatusCode)
}
return nil
}
-62
View File
@@ -1,62 +0,0 @@
package storage
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
// TestHTTPUsageReporter_SendsCorrectContractToCore ist der Nachweis, dass
// HTTPUsageReporter exakt den Vertrag von Core internal/resync.Handler.
// UsageHandler bedient (Service-Credential-Header, JSON-Feldnamen) — echte
// Vernetzung gegen einen laufenden Core-Prozess ist nicht Teil dieses
// Tests (internal/resync.Handler ist in Core aktuell in keinem cmd/*/
// main.go verdrahtet, siehe FDN-03-Pruefprotokoll), daher hier gegen einen
// httptest-Server geprueft, der denselben Vertrag nachbildet.
func TestHTTPUsageReporter_SendsCorrectContractToCore(t *testing.T) {
var gotClientID, gotClientSecret string
var gotBody []usageDeltaDTO
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotClientID = r.Header.Get("X-Nexarch-Client-Id")
gotClientSecret = r.Header.Get("X-Nexarch-Client-Secret")
if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil {
t.Errorf("anfrage-koerper dekodieren: %v", err)
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]int{"applied": len(gotBody)})
}))
defer srv.Close()
reporter := NewHTTPUsageReporter(srv.URL, "dms-service-client", "dms-service-secret", nil)
if err := reporter.Report(context.Background(), "acme", UsageMetric, 4096); err != nil {
t.Fatalf("report: %v", err)
}
if gotClientID != "dms-service-client" || gotClientSecret != "dms-service-secret" {
t.Fatalf("service-credential-header falsch: id=%q secret=%q", gotClientID, gotClientSecret)
}
if len(gotBody) != 1 {
t.Fatalf("erwartet 1 delta im koerper, habe %d", len(gotBody))
}
if gotBody[0].TenantSlug != "acme" || gotBody[0].Metric != UsageMetric || gotBody[0].Delta != 4096 {
t.Fatalf("unerwarteter delta-inhalt: %+v", gotBody[0])
}
}
// TestHTTPUsageReporter_RejectsNonOKStatus prueft, dass ein von Core
// abgelehnter Aufruf (z.B. ungueltiges Service-Credential) als Fehler
// zurueckgegeben wird, statt stillschweigend zu verlieren.
func TestHTTPUsageReporter_RejectsNonOKStatus(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "ungueltiges service-credential", http.StatusUnauthorized)
}))
defer srv.Close()
reporter := NewHTTPUsageReporter(srv.URL, "x", "y", nil)
if err := reporter.Report(context.Background(), "acme", UsageMetric, 1); err == nil {
t.Fatal("erwartet fehler bei abgelehnter nutzungsmeldung, habe nil")
}
}
@@ -1,8 +0,0 @@
DROP TABLE IF EXISTS document_metadata_values;
DROP TABLE IF EXISTS metadata_fields;
DROP TABLE IF EXISTS document_tags;
DROP TABLE IF EXISTS tags;
ALTER TABLE documents DROP CONSTRAINT IF EXISTS fk_documents_current_revision;
DROP TABLE IF EXISTS file_revisions;
DROP TABLE IF EXISTS documents;
DROP TABLE IF EXISTS folders;
@@ -1,88 +0,0 @@
-- Kern-Entitaeten des DMS (FDN-02): Dokument, Datei-Revision, Ordner, Tag,
-- Metadatenfeld. Laeuft in der DB EINES Mandanten (Modell C, siehe Core
-- TEN-01) — keine tenant_id-Spalte, die Tenant-Zugehoerigkeit ist implizit
-- durch die Datenbankverbindung gegeben. FK auf users(id) spiegelt das
-- Benutzer-Datenmodell aus Core IAM-01 (migrations/tenant/0001_users.up.sql
-- im NEXARCH-Core-Modul) — Auth/Benutzerverwaltung liegt vollstaendig in
-- Core (siehe "Nicht Bestandteil" in FDN-02), diese Migration dupliziert sie
-- NICHT, sondern setzt sie als bereits vorhanden voraus (users-Tabelle wird
-- durch Cores eigene Migration in derselben physischen Tenant-Datenbank
-- angelegt, bevor DMS-Migrationen laufen).
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE folders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
parent_folder_id UUID REFERENCES folders(id) ON DELETE CASCADE,
name TEXT NOT NULL,
created_by UUID NOT NULL REFERENCES users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_folders_parent_folder_id ON folders(parent_folder_id);
CREATE TABLE documents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
folder_id UUID REFERENCES folders(id) ON DELETE SET NULL,
title TEXT NOT NULL,
-- current_revision_id verweist erst NACH der Anlage von file_revisions
-- auf eine Zeile (siehe ALTER TABLE unten) — beim INSERT eines Dokuments
-- existiert noch keine Revision, daher NULLable und zirkulaer per
-- nachtraeglichem FOREIGN KEY statt Inline-Referenz geloest.
current_revision_id UUID,
created_by UUID NOT NULL REFERENCES users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ
);
CREATE INDEX idx_documents_folder_id ON documents(folder_id);
CREATE INDEX idx_documents_created_by ON documents(created_by);
CREATE TABLE file_revisions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
revision_number INT NOT NULL,
-- storage_key ist ein Platzhalter fuer die Objekt-Storage-Abstraktion
-- (FDN-03, "Nicht Bestandteil" dieser Kachel) — hier nur die Spalte, die
-- spaetere Kachel legt fest, was tatsaechlich dahinter liegt.
storage_key TEXT NOT NULL,
checksum_sha256 TEXT NOT NULL,
size_bytes BIGINT NOT NULL,
mime_type TEXT NOT NULL,
created_by UUID NOT NULL REFERENCES users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (document_id, revision_number)
);
CREATE INDEX idx_file_revisions_document_id ON file_revisions(document_id);
ALTER TABLE documents
ADD CONSTRAINT fk_documents_current_revision
FOREIGN KEY (current_revision_id) REFERENCES file_revisions(id) ON DELETE SET NULL;
CREATE TABLE tags (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE document_tags (
document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
tag_id UUID NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (document_id, tag_id)
);
CREATE INDEX idx_document_tags_tag_id ON document_tags(tag_id);
CREATE TABLE metadata_fields (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
field_key TEXT NOT NULL UNIQUE,
label TEXT NOT NULL,
field_type TEXT NOT NULL CHECK (field_type IN ('text', 'number', 'date', 'bool', 'select')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE document_metadata_values (
document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
field_id UUID NOT NULL REFERENCES metadata_fields(id) ON DELETE CASCADE,
value TEXT NOT NULL,
PRIMARY KEY (document_id, field_id)
);
CREATE INDEX idx_document_metadata_values_field_id ON document_metadata_values(field_id);
@@ -1 +0,0 @@
DROP TABLE IF EXISTS processing_jobs;
@@ -1,30 +0,0 @@
-- FDN-04: Postgres-Jobqueue fuer asynchrone Verarbeitung (OCR, Konvertierung,
-- Indexierung, Exporte) — kein Redis/AMQP, dasselbe Muster wie das
-- projektweite Postgres-Jobqueue-Konzept (siehe SKALIERUNGSKONZEPT.md).
-- Laeuft in der DB EINES Mandanten (Modell C, siehe Core TEN-01).
CREATE TABLE processing_jobs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
job_type TEXT NOT NULL,
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
-- idempotency_key verhindert doppelte Einreihung DERSELBEN logischen
-- Aufgabe (z.B. "ocr:<revision_id>") — NULL erlaubt mehrere Zeilen ohne
-- Dedup-Anspruch (Standard-Postgres-Verhalten: NULL ist nie gleich NULL
-- im UNIQUE-Index).
idempotency_key TEXT UNIQUE,
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'processing', 'succeeded', 'failed', 'dead_letter')),
attempts INT NOT NULL DEFAULT 0,
max_attempts INT NOT NULL DEFAULT 5,
available_at TIMESTAMPTZ NOT NULL DEFAULT now(),
locked_at TIMESTAMPTZ,
locked_by TEXT,
last_error TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Deckt genau die Zugriffsmuster von Dequeue (status+available_at) und der
-- Stale-Lock-Wiedervorlage (status+locked_at) ab.
CREATE INDEX idx_processing_jobs_pending ON processing_jobs (available_at) WHERE status = 'pending';
CREATE INDEX idx_processing_jobs_processing ON processing_jobs (locked_at) WHERE status = 'processing';
CREATE INDEX idx_processing_jobs_job_type ON processing_jobs (job_type);
-43
View File
@@ -1,43 +0,0 @@
-- Entwicklungs-Seed (Akzeptanzkriterium 3): legt einen Beispielordner, ein
-- Beispieldokument mit einer Revision, ein Tag und ein Metadatenfeld an.
-- Setzt voraus, dass mindestens ein Benutzer existiert (Core IAM-01 legt
-- users an, DMS tut das nicht selbst - siehe "Nicht Bestandteil" in
-- FDN-02) - schlaegt sonst absichtlich mit einer sprechenden Fehlermeldung
-- fehl statt einen Platzhalter-Benutzer anzulegen, den DMS gar nicht
-- verwalten darf.
DO $$
DECLARE
seed_user_id UUID;
seed_folder_id UUID;
seed_document_id UUID;
BEGIN
SELECT id INTO seed_user_id FROM users ORDER BY created_at LIMIT 1;
IF seed_user_id IS NULL THEN
RAISE EXCEPTION 'dev_seed.sql: keine Zeile in users gefunden - zuerst Core-Seed (IAM-01) ausfuehren';
END IF;
INSERT INTO folders (name, created_by) VALUES ('Beispielordner', seed_user_id)
RETURNING id INTO seed_folder_id;
INSERT INTO documents (folder_id, title, created_by) VALUES (seed_folder_id, 'Beispieldokument', seed_user_id)
RETURNING id INTO seed_document_id;
INSERT INTO file_revisions (document_id, revision_number, storage_key, checksum_sha256, size_bytes, mime_type, created_by)
VALUES (seed_document_id, 1, 'dev-seed/beispiel.pdf', repeat('0', 64), 12345, 'application/pdf', seed_user_id);
UPDATE documents SET current_revision_id = (
SELECT id FROM file_revisions WHERE document_id = seed_document_id AND revision_number = 1
) WHERE id = seed_document_id;
INSERT INTO tags (name) VALUES ('Beispiel-Tag')
ON CONFLICT (name) DO NOTHING;
INSERT INTO document_tags (document_id, tag_id)
SELECT seed_document_id, id FROM tags WHERE name = 'Beispiel-Tag';
INSERT INTO metadata_fields (field_key, label, field_type) VALUES ('rechnungsnummer', 'Rechnungsnummer', 'text')
ON CONFLICT (field_key) DO NOTHING;
INSERT INTO document_metadata_values (document_id, field_id, value)
SELECT seed_document_id, id, 'RE-2026-0001' FROM metadata_fields WHERE field_key = 'rechnungsnummer';
END $$;
-22
View File
@@ -1,22 +0,0 @@
#!/usr/bin/env bash
# Setzt die DMS-Testumgebung zurueck: droppt die Tenant-Test-Datenbank und
# legt sie leer neu an. Noetig, weil mehrere Testpakete (internal/jobqueue,
# internal/migrate, ...) dieselbe physische Test-Datenbank ueber
# Sitzungsgrenzen hinweg teilen — ohne Reset sammelt sich Zustand
# (z.B. schema_migrations-Eintraege) an, der Migrations-/Rollback-Tests
# verfaelscht (dieselbe Fehlerklasse wie in NEXARCH Core, siehe
# [[project-nexarch-test-infra]]).
#
# Aufruf: NEXARCH_DMS_TEST_DB_PASSWORD=... TEST_TENANT_DB=dms_tenant_test ./scripts/reset-test-env.sh
set -euo pipefail
PASS="${NEXARCH_DMS_TEST_DB_PASSWORD:?Setze NEXARCH_DMS_TEST_DB_PASSWORD vor dem Aufruf}"
ROLE="${TEST_TENANT_ROLE:-nexarch_dms_test}"
DB="${TEST_TENANT_DB:-dms_tenant_test}"
export PGPASSWORD="$PASS"
psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP DATABASE IF EXISTS ${DB};"
psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "CREATE DATABASE ${DB};"
echo "Testumgebung zurueckgesetzt: ${DB} leer neu angelegt."
+93
View File
@@ -0,0 +1,93 @@
# QA-02 Prüfprotokoll: Prüfgate Identität & Mandanten
Stand: 2026-08-29. Branch `feature/qa-02-pruefgate-identitaet-mandanten` (alle 21 Vorbedingungs-Tickets TEN-01..07 und IAM-01..14 gemergt, per `git merge-base` gegen jeden Feature-Branch verifiziert).
## 1. Akzeptanzkriterien TEN-01 bis TEN-07 — Testabdeckung
| Ticket | Titel | Abdeckende Tests |
|---|---|---|
| TEN-01 | Mandantenregistrierung / Modell A/C | `internal/tenant/tenant_test.go`: `TestValidateSlug`, `TestDBNameForSlug` |
| TEN-02 | Self-Service-Onboarding | `internal/tenant/provisioner_test.go`: `TestProvision_CreatesIsolatedDatabases`; `internal/tenant/onboarding_test.go`: `TestOnboarding_ValidationErrors`, `TestOnboarding_CreatesTenantAndAdmin`, `TestOnboarding_RejectsDuplicateSlugConcurrently` |
| TEN-03 | Tenant-Router / Verbindungsauswahl | `internal/tenant/router_test.go`: `TestRouter_ResolvesCorrectTenantDatabase`, `TestRouter_RejectsMissingOrUnknownTenant`, `TestRouter_ReusesConnectionForSameTenant`, `TestRouter_BoundsOpenConnectionsUnderLoad` |
| TEN-04 | Mandanten-Lebenszyklus (Suspend/Reaktivieren) | `internal/tenant/lifecycle_test.go`: `TestLifecycle_SuspendAndReactivate`, `TestLifecycle_RejectsInvalidTransitions`, `TestLifecycle_CheckActive_RejectsNonActive` |
| TEN-05 | Mandanten-Löschung (DSGVO-Löschfrist) | `internal/tenant/lifecycle_test.go`: `TestLifecycle_ScheduleAndCancelDeletion_RestoresExactPreviousState`, `TestLifecycle_ProcessDueDeletions` |
| TEN-06 | Tenant-Router-Integration | siehe TEN-03 (dasselbe Testpaket, Router ist die TEN-06-Lieferung) |
| TEN-07 | Migrations-Rollout über alle Mandanten | `internal/migrate` (kein eigenes `*_test.go` im Merge-Ergebnis gefunden — siehe Abweichung 4.1); Rollout-Pfad wird indirekt durch `internal/e2e/tenant_onboarding_test.go` und `cross_tenant_isolation_test.go` genutzt (`applyTenantSchema` wendet dieselben `migrations/tenant/*.up.sql` in derselben Reihenfolge an) |
## 2. Akzeptanzkriterien IAM-01 bis IAM-14 — Testabdeckung
| Ticket | Titel | Abdeckende Tests |
|---|---|---|
| IAM-01 | Benutzerverwaltung (CRUD) | `internal/user/store_test.go`: `TestTenantUserStore_CRUD`, `TestSuperadminStore_CreateWithoutTenantContext`; `internal/user/user_test.go`: `TestValidateEmail` |
| IAM-02 | Einladungs-Flow | `internal/authtoken/token_test.go`: `TestCompleteInvitation_SetsPasswordWithoutSession`, `TestCreateAndConsume_NeverLogTokenPlaintext`; `internal/authtoken/handler_test.go` |
| IAM-03 | Passwort-Hashing (bcrypt) | `internal/auth/password_test.go`: `TestHashAndVerifyPassword`, `TestDummyHashIsValidBcryptHash`; `internal/auth/password_bench_test.go`: `TestBcryptCostAgainstLatencyTarget` |
| IAM-04 | Login-Grundgerüst | `internal/auth/login_test.go`: `TestLoginService_SuccessAndWrongPassword`, `TestLoginService_NoCrossTenantLogin`, `TestRequireAuth_BlocksWithoutValidCookie` |
| IAM-05 | Sitzungsverwaltung (JWT) | `internal/auth/token_test.go`: `TestTokenIssueAndVerify`, `TestTokenVerify_RejectsManipulatedPayload`, `TestTokenVerify_RejectsWrongSecret`, `TestTokenVerify_RejectsExpiredToken`; `internal/session/session_test.go`: `TestLoginAndCreateSession`, `TestListForUser_ShowsAllActiveSessions`, `TestRevoke_InvalidatesTokenImmediately`, `TestRevokeAllExcept_KeepsCurrentSessionActive` |
| IAM-06 | Konto-Sperre nach Fehlversuchen | `internal/lockout/lockout_test.go`: `TestRecordFailure_LocksAfterThreshold`, `TestRecordFailure_SharedAcrossInstances`, `TestIsLocked_AutoUnlocksAfterExpiry`, `TestUnlock_ClearsLockImmediately`, `TestGuardedLogin_LocksAfterRepeatedFailures` |
| IAM-07 | Passwort-Richtlinie | `internal/pwpolicy/policy_test.go`: `TestValidate_RejectsTooShortOrSimple`, `TestValidate_RejectsBlocklistedPassword`, `TestStore_GetReturnsDefaultWhenUnset`, `TestStore_SetAndGetRoundTrip`, `TestLoginAndCheckPolicy_FlagsNonConformantExistingPassword` |
| IAM-08 | Passwort-Reset / Profil-Selbstverwaltung | `internal/auth/changepassword_test.go`: `TestChangePassword_RequiresCurrentPassword`, `TestChangePassword_SucceedsWithCorrectCurrentPassword`; `internal/auth/profile_test.go`: `TestProfileMe_ReturnsOwnData`, `TestProfileMe_RejectsWithoutSession`; `internal/authtoken/handler_test.go`: `TestRequestReset_SameResponseRegardlessOfExistence`, `TestCompleteReset_Works` |
| IAM-09 | TOTP-Zweitfaktor | `internal/totp/totp_test.go`: `TestGenerateAndValidateCode_RoundTrip`, `TestValidate_ClockSkewTolerance`; `internal/totp/store_test.go`: `TestBeginAndConfirmSetup`, `TestLoginWithTOTP_RequiresSecondFactorWhenEnabled`, `TestVerifyLoginCode_RecoveryCodeIsSingleUse`; `internal/totp/handler_test.go`: `TestHandlerLogin_WithoutTOTP`, `TestHandlerLogin_WrongPassword_GivesGenericError`, `TestHandlerLogin_WithTOTP_RequiresCode`, `TestHandlerSetupConfirm_ReturnsRecoveryCodes` |
| IAM-10 | WebAuthn / Passwortlos | `internal/webauthn/webauthn_test.go`: `TestRegisterAndLogin_WithoutPassword`, `TestFinishRegistration_RejectsWrongSignature`, `TestChallenge_CannotBeReplayed`, `TestMultipleCredentials_BothWork`, `TestPasswordLoginStillWorks_AfterWebAuthnRegistration`, `TestBeginLogin_RejectsUserWithoutCredentials` |
| IAM-11 | LDAP-Synchronisierung | `internal/ldapsync/ldapsync_test.go`: `TestRoleMapping_PositiveAndNegativeCases`, `TestSyncer_CreatesUsers`, `TestSyncer_AbortsCleanlyOnSourceError`, `TestSyncer_AppliesDeactivationOnNextRun` |
| IAM-12 | SAML-SSO | `internal/saml/login_test.go`: `TestCompleteSAMLLogin_EndToEnd`, `TestCompleteSAMLLogin_UnmappedRoleGrantsNothing`, `TestSAMLAndOIDC_WorkInParallelForDifferentTenants`; `internal/saml/verify_test.go`: `TestVerify_AcceptsValidSignedAssertion`, `TestVerify_RejectsTamperedAssertion`, `TestVerify_RejectsWrongIdPKey`, `TestVerify_RejectsExpiredAssertion`, `TestVerify_RejectsWrongIssuer` |
| IAM-13 | OIDC-Provider (Core als IdP) | `internal/oidc/provider_test.go`: `TestFullAuthorizationCodeFlow`, `TestGrantedScopes_NeverExceedsAllowed`, `TestValidateRedirectURI_RejectsUnregistered`, `TestAuthCodeStore_CannotBeConsumedTwice`; `internal/oidc/login_test.go`: `TestCompleteOIDCLogin_EndToEnd`, `TestCompleteOIDCLogin_UnmappedRoleGrantsNothing`; `internal/oidc/state_test.go`, `internal/oidc/verify_test.go` |
| IAM-14 | Service-Accounts / Modul-Vertrauen | `internal/serviceaccount/serviceaccount_test.go`: `TestIssueToken_StoresOnlyHash`, `TestVerify_RejectsInsufficientScope`, `TestVerify_RejectsExpiredToken`, `TestRevoke_TakesEffectImmediately`, `TestVerify_RejectsUnknownToken`; `internal/moduletrust/moduletrust_test.go`: `TestIssueAndVerify_RoundTrip`, `TestVerify_DoesNotFetchPerCall`, `TestVerify_FailsOpenWhenCoreUnreachableButStaleKeysExist`, `TestRequireFreshKeys_FailsClosedWhenCoreUnreachable`, `TestRotate_NoDowntimeForAlreadyIssuedTokens`, `TestVerify_RejectsUnknownKid`, `TestJWKSRoundTrip` |
**Ergebnis Abschnitt 1+2:** alle 21 Tickets haben eigene, automatisierte Unit-/Integrationstests, die ihre dokumentierten Akzeptanzkriterien einzeln abdecken.
## 3. Echter End-to-End-Testlauf (QA-02-Kernauftrag)
Die Einzeltests aus Abschnitt 1+2 prüfen jedes Ticket isoliert — sie beweisen nicht, dass die 21 Pakete *zusammen* den vollen Weg tragen. Dafür neu geschrieben: `internal/e2e/`.
### 3.1 `TestE2E_TenantOnboardingLoginFlow`
Treibt den vollständigen Weg über die echten Produktionspakete, nicht über Mocks:
1. `internal/tenant.Provisioner.Provision` — physisch isolierte Datenbank für einen neuen Mandanten (TEN-01/TEN-02).
2. Schema-Rollout über **alle** `migrations/tenant/*.up.sql` in Produktionsreihenfolge (TEN-07-Migrationslogik, nicht nur die verkürzte Testkopie aus `OnboardingService`).
3. `internal/user.TenantUserStore.Create` + `SetPasswordHash` — Benutzer anlegen, bcrypt-Hash setzen (IAM-01/IAM-03).
4. `internal/auth.LoginService.Login` — Anmeldung (IAM-04/IAM-05).
5. Assert: Token ist mit `TokenIssuer.Verify` gültig, `Claims.TenantSlug` und `Claims.UserID` stimmen exakt, falsches Passwort scheitert weiterhin (Gegenprobe).
**Ergebnis:** bestanden (siehe Abschnitt 5, Build/Test-Ergebnis auf 131).
### 3.2 `TestE2E_CrossTenantIsolation`
Zwei vollständig provisionierte Mandanten (`e2e-tenant-a`, `e2e-tenant-b`), je ein Benutzer, drei unabhängige Beweise:
- **Datenbankebene:** aus der Datenbank von Tenant A ist keine Zeile für den Tenant-B-Benutzer abfragbar (und umgekehrt) — kein Query-Filter, sondern physisch getrennte Datenbanken.
- **Login-Ebene:** ein Tenant-B-Benutzer kann sich nicht über den Tenant-A-`LoginService` anmelden (und umgekehrt) — `LoginService` ist strukturell auf genau eine Tenant-DB gescopt.
- **Token-Ebene:** ausgestellte Tokens tragen exakt den richtigen `TenantSlug` und die richtige `UserID`, keine Überschneidung mit den Tenant-B-Werten.
**Ergebnis:** bestanden (siehe Abschnitt 5).
## 4. Abweichungen
### 4.1 `internal/migrate` (TEN-07) hat keine eigene Testdatei im gemergten Stand — Schweregrad: Niedrig
Kein `*_test.go` für `internal/migrate/orchestrator.go`/`migrations.go` im Merge-Ergebnis dieses Branches gefunden. Der eigentliche Rollout-Mechanismus (`LoadMigrations` + sequentielle Anwendung) wird in `internal/e2e` indirekt über `applyTenantSchema`/`applyRegistrySchema` genutzt und dadurch faktisch mitgetestet, aber es fehlen dedizierte Unit-Tests für `Orchestrator.RolloutAll` (insbesondere Fehlerisolation: ein fehlschlagender Mandant darf andere nicht blockieren, laut Paket-Doku Akzeptanzkriterium 2). Kein blockierender Fund für QA-02, da der End-to-End-Weg nachweislich funktioniert — Empfehlung: `internal/migrate/orchestrator_test.go` bei Gelegenheit nachziehen.
Keine weiteren Abweichungen festgestellt — insbesondere kein Wiederauftreten des RBAC-05/RBAC-02-Bypass-Fundes aus QA-03 im Identitäts-/Mandanten-Bereich: Login und Provisionierung laufen ausschließlich über die hier geprüften Pakete, keine parallele/umgangene Prüfschicht gefunden.
### 4.2 Echter Merge-Konflikt zwischen IAM-12 (SAML/OIDC-Client) und IAM-13 (Core als OIDC-Provider) — Schweregrad: Hoch, behoben
**Fund:** `internal/oidc/jwks.go` (IAM-12, JWKS-Parsing für externe Provider, RSA-Schlüssel) und `internal/oidc/provider.go` (IAM-13, Core als eigener IdP, Ed25519/OKP-Schlüssel) definierten beide einen Typ `jwk`/`jwkSet` im selben Package — kompiliert einzeln pro Branch fehlerfrei, scheitert aber beim Zusammenführen beider Branches mit `jwk redeclared in this block` und Folgefehlern (unbekannte Felder `Crv`/`X`/`Use`/`Alg`). Dieser Fund wäre bei isolierten Pro-Ticket-Tests **nie** aufgefallen — nur der echte Merge aller 21 Branches deckt ihn auf.
**Behoben:** IAM-13s Typen in `internal/oidc/provider.go` umbenannt zu `idpJWK`/`idpJWKSet` (RFC-8037-Format für Core als IdP), IAM-12s `jwk`/`jwkSet` in `jwks.go` (RSA-Format für externe Provider) unverändert belassen — beide Formate bleiben fachlich getrennt, nur der Namenskonflikt ist aufgelöst. `internal/oidc/provider_test.go` entsprechend angepasst.
**Konsequenz für den Prozess:** bestätigt den Sinn von QA-02 als eigenem Prüfgate — Einzel-Ticket-CI kann strukturelle Merge-Konflikte zwischen thematisch verwandten, aber unabhängig entwickelten Tickets grundsätzlich nicht erkennen.
## 5. Build/Test-Ergebnis auf 131
Durchgeführt 2026-08-29 auf root@192.168.1.131 (`/root/nexarch-code-qa02`, isolierter Sync, kein Konflikt mit dem parallelen QA-03-Testlauf in `/root/nexarch-code-qa03`):
- `go mod tidy`, `go build ./...`, `go vet ./...` — nach Behebung des Merge-Konflikts aus 4.2 alle sauber, keine Fehler.
- `go test ./... -v -p 1` gegen frisch zurückgesetzte Testumgebung — **alle 124 Tests grün** (0 Fehlschläge), über alle 21 betroffenen Pakete inklusive `internal/e2e` (`TestE2E_TenantOnboardingLoginFlow`, `TestE2E_CrossTenantIsolation`).
- Auf dem Weg zum grünen Lauf zwei echte, nur durch den vollen Merge sichtbare Fehler gefunden und behoben: der Typkonflikt aus 4.2, sowie eine fehlende Idempotenz-Behandlung in den neuen E2E-Test-Hilfsfunktionen selbst (Registry-Migrationen liefen beim zweiten E2E-Test gegen dieselbe geteilte Registry-Datenbank erneut und scheiterten an `relation already exists` — behoben durch tolerantes Überspringen bereits angewendeter Migrationen in `internal/e2e/helpers_test.go`, kein Produktionscode betroffen).
- Keine Regressionen in TEN-01..07 oder IAM-01..14 durch den 21-Branch-Merge.
**QA-02 Gesamtergebnis: bestanden.** Ein behobener Hoch-Schweregrad-Merge-Konflikt (4.2, echter struktureller Fund dieses Prüfgates), keine offenen Blocker.
## 6. Gesamtergebnis
**Bestanden.** Der volle End-to-End-Weg über alle 21 Vorbedingungs-Tickets (TEN-01..07, IAM-01..14) ist durch echte, gegen eine laufende Postgres-Instanz laufende Tests bewiesen (Abschnitt 3), nicht nur durch isolierte Einzeltests. Ein struktureller Merge-Konflikt wurde aufgedeckt und behoben (4.2) — genau die Art Fund, für die dieses Prüfgate existiert. Ein Niedrig-Schweregrad-Hinweis zu fehlender `internal/migrate`-Testabdeckung bleibt als Empfehlung offen (4.1).
+13 -1
View File
@@ -2,4 +2,16 @@ module gitea.perlbach24.de/scripte/nexarch
go 1.22
require github.com/jackc/pgx/v5 v5.6.0
require (
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/jackc/pgx/v5 v5.6.0
golang.org/x/crypto v0.17.0
)
require (
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
github.com/jackc/puddle/v2 v2.2.1 // indirect
golang.org/x/sync v0.1.0 // indirect
golang.org/x/text v0.14.0 // indirect
)
+30
View File
@@ -0,0 +1,30 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY=
github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw=
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k=
golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+65
View File
@@ -0,0 +1,65 @@
package auth
import (
"encoding/json"
"net/http"
"gitea.perlbach24.de/scripte/nexarch/internal/user"
)
// ChangePasswordHandler stellt die Passwortaenderung fuer den bereits
// angemeldeten Benutzer bereit (IAM-08 Akzeptanzkriterium 2). Getrennt von
// Handler (Login/Logout), weil hier zusaetzlich der TenantUserStore fuer das
// Aktualisieren des Hashes gebraucht wird.
type ChangePasswordHandler struct {
users *user.TenantUserStore
}
func NewChangePasswordHandler(users *user.TenantUserStore) *ChangePasswordHandler {
return &ChangePasswordHandler{users: users}
}
type changePasswordRequest struct {
CurrentPassword string `json:"current_password"`
NewPassword string `json:"new_password"`
}
// ChangePassword verlangt das aktuelle Passwort, bevor ein neues gesetzt wird
// — ohne diese Pruefung koennte ein gekapertes, noch gueltiges Session-Cookie
// allein zur vollstaendigen Kontoübernahme reichen (Passwort setzen, alte
// Session unbrauchbar machen). Muss hinter RequireAuth haengen.
func (h *ChangePasswordHandler) ChangePassword(w http.ResponseWriter, r *http.Request) {
claims, ok := ClaimsFromContext(r.Context())
if !ok {
http.Error(w, "nicht angemeldet", http.StatusUnauthorized)
return
}
var req changePasswordRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "ungueltige Anfrage", http.StatusBadRequest)
return
}
creds, err := h.users.GetByIDForAuth(r.Context(), claims.UserID)
if err != nil {
http.Error(w, "Aktuelles Passwort oder neues Passwort ungültig.", http.StatusBadRequest)
return
}
if !VerifyPassword(creds.PasswordHash, req.CurrentPassword) {
http.Error(w, "Aktuelles Passwort oder neues Passwort ungültig.", http.StatusBadRequest)
return
}
newHash, err := HashPassword(req.NewPassword)
if err != nil {
http.Error(w, "Aktuelles Passwort oder neues Passwort ungültig.", http.StatusBadRequest)
return
}
if err := h.users.SetPasswordHash(r.Context(), claims.UserID, newHash); err != nil {
http.Error(w, "Passwort konnte nicht gespeichert werden.", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}
+76
View File
@@ -0,0 +1,76 @@
package auth
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"gitea.perlbach24.de/scripte/nexarch/internal/user"
)
// Akzeptanzkriterium 2: Passwortaenderung verlangt das aktuelle Passwort.
func TestChangePassword_RequiresCurrentPassword(t *testing.T) {
pool := setupTenantDB(t, "changepw_test_wrong")
store := user.NewTenantUserStore(pool)
u := createUserWithPassword(t, store, "nutzer@example.com", "altes-passwort")
issuer := NewTokenIssuer("test-session-secret")
sessionToken, err := issuer.Issue(u.ID, "acme")
if err != nil {
t.Fatalf("session-token ausstellen: %v", err)
}
h := NewChangePasswordHandler(store)
body, _ := json.Marshal(map[string]string{"current_password": "falsches-altes-passwort", "new_password": "neues-passwort"})
req := httptest.NewRequest(http.MethodPost, "/account/change-password", bytes.NewReader(body))
req.AddCookie(&http.Cookie{Name: CookieName, Value: sessionToken})
rec := httptest.NewRecorder()
RequireAuth(issuer, h.ChangePassword)(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400 (falsches aktuelles passwort)", rec.Code)
}
creds, err := store.GetByIDForAuth(req.Context(), u.ID)
if err != nil {
t.Fatalf("credentials laden: %v", err)
}
if !VerifyPassword(creds.PasswordHash, "altes-passwort") {
t.Fatal("passwort haette NICHT geaendert werden duerfen")
}
}
func TestChangePassword_SucceedsWithCorrectCurrentPassword(t *testing.T) {
pool := setupTenantDB(t, "changepw_test_ok")
store := user.NewTenantUserStore(pool)
u := createUserWithPassword(t, store, "nutzer2@example.com", "altes-passwort")
issuer := NewTokenIssuer("test-session-secret")
sessionToken, err := issuer.Issue(u.ID, "acme")
if err != nil {
t.Fatalf("session-token ausstellen: %v", err)
}
h := NewChangePasswordHandler(store)
body, _ := json.Marshal(map[string]string{"current_password": "altes-passwort", "new_password": "neues-sicheres-passwort"})
req := httptest.NewRequest(http.MethodPost, "/account/change-password", bytes.NewReader(body))
req.AddCookie(&http.Cookie{Name: CookieName, Value: sessionToken})
rec := httptest.NewRecorder()
RequireAuth(issuer, h.ChangePassword)(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200, body: %s", rec.Code, rec.Body.String())
}
creds, err := store.GetByIDForAuth(req.Context(), u.ID)
if err != nil {
t.Fatalf("credentials laden: %v", err)
}
if !VerifyPassword(creds.PasswordHash, "neues-sicheres-passwort") {
t.Fatal("neues passwort wurde nicht uebernommen")
}
}
+67
View File
@@ -0,0 +1,67 @@
package auth
import (
"encoding/json"
"net/http"
"time"
)
// Handler stellt Login/Logout als HTTP-Endpunkte bereit. Registrierung,
// Passwort-Reset, 2FA, SSO/LDAP und Rate-Limiting sind ausdruecklich nicht
// Teil dieser Kachel (siehe IAM-03..07).
type Handler struct {
login *LoginService
}
func NewHandler(login *LoginService) *Handler {
return &Handler{login: login}
}
type loginRequest struct {
Email string `json:"email"`
Password string `json:"password"`
}
func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
var req loginRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "ungueltige Anfrage", http.StatusBadRequest)
return
}
token, err := h.login.Login(r.Context(), req.Email, req.Password)
if err != nil {
http.Error(w, ErrInvalidCredentials.Error(), http.StatusUnauthorized)
return
}
http.SetCookie(w, &http.Cookie{
Name: CookieName,
Value: token,
Path: "/",
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteStrictMode,
MaxAge: int(AccessTokenTTL.Seconds()),
})
w.WriteHeader(http.StatusOK)
}
// Logout loescht das Session-Cookie. Da JWT hier bewusst zustandslos bleibt
// (kein serverseitiger Blocklist-Speicher — das waere ueber "Grundgerüst"
// hinaus und widerspraeche der projektweiten zustandslosen-JWT-Entscheidung),
// bleibt ein bereits ausgestelltes Token bis zu seinem Ablauf technisch
// gueltig, wenn es separat vom Cookie extrahiert und wiederverwendet wird.
func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{
Name: CookieName,
Value: "",
Path: "/",
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteStrictMode,
MaxAge: -1,
Expires: time.Unix(0, 0),
})
w.WriteHeader(http.StatusOK)
}
+56
View File
@@ -0,0 +1,56 @@
package auth
import (
"context"
"errors"
"gitea.perlbach24.de/scripte/nexarch/internal/user"
)
var ErrInvalidCredentials = errors.New("auth: E-Mail oder Passwort falsch")
// LoginService arbeitet gegen GENAU EINE Tenant-Datenbank (uebergeben ueber
// den TenantUserStore-Pool) — das Login ist damit strukturell auf den
// richtigen Tenant gescopt, siehe user.TenantUserStore.GetByEmailForAuth.
type LoginService struct {
users *user.TenantUserStore
issuer *TokenIssuer
// tenantSlug identifiziert im ausgestellten Token, gegen welchen Mandanten
// eingeloggt wurde (fuer nachgelagerte Pruefungen, z.B. Middleware-Logs).
tenantSlug string
}
func NewLoginService(users *user.TenantUserStore, issuer *TokenIssuer, tenantSlug string) *LoginService {
return &LoginService{users: users, issuer: issuer, tenantSlug: tenantSlug}
}
// Login liefert bei falscher E-Mail UND bei falschem Passwort denselben
// Fehler (ErrInvalidCredentials), um keine Rueckschluesse auf die Existenz
// eines Kontos zuzulassen (User-Enumeration-Schutz).
func (s *LoginService) Login(ctx context.Context, email, password string) (string, error) {
creds, err := s.users.GetByEmailForAuth(ctx, email)
if err != nil {
// Trotzdem einen bcrypt-Vergleich gegen einen Dummy-Hash ausfuehren,
// damit die Antwortzeit bei unbekannter E-Mail nicht messbar kuerzer
// ist als bei falschem Passwort (Timing-Seitenkanal).
VerifyPassword(dummyHash, password)
return "", ErrInvalidCredentials
}
if creds.User.Status != user.StatusActive {
return "", ErrInvalidCredentials
}
if !VerifyPassword(creds.PasswordHash, password) {
return "", ErrInvalidCredentials
}
return s.issuer.Issue(creds.User.ID, s.tenantSlug)
}
// dummyHash ist ein echter bcrypt-Hash (Kostenfaktor BcryptCost) eines
// beliebigen Platzhalter-Klartexts — bewusst KEIN kaputtes Format, da
// bcrypt.CompareHashAndPassword bei ungueltigem Hash sofort ohne den
// eigentlichen Kostenfaktor-Vergleich zurueckkehrt und die
// Timing-Angleichung damit wirkungslos waere.
const dummyHash = "$2a$12$cmwiETrG9DK5/uTM2fg4uetngYUspKjME5P8fNpk0QYTaO64N0r3C"
+177
View File
@@ -0,0 +1,177 @@
package auth
import (
"context"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/internal/user"
)
const usersSchema = `
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
password_hash TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);`
func setupTenantDB(t *testing.T, dbName string) *pgxpool.Pool {
t.Helper()
adminDSN := os.Getenv("TEST_ADMIN_DSN")
if adminDSN == "" {
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
}
ctx := context.Background()
adminPool, err := pgxpool.New(ctx, adminDSN)
if err != nil {
t.Fatalf("admin pool: %v", err)
}
_, _ = adminPool.Exec(ctx, fmt.Sprintf(`DROP DATABASE IF EXISTS %q`, dbName))
if _, err := adminPool.Exec(ctx, fmt.Sprintf(`CREATE DATABASE %q`, dbName)); err != nil {
t.Fatalf("testdatenbank anlegen: %v", err)
}
dsn := strings.Replace(adminDSN, "/postgres?", "/"+dbName+"?", 1)
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatalf("connect testdatenbank: %v", err)
}
if _, err := pool.Exec(ctx, usersSchema); err != nil {
t.Fatalf("schema anwenden: %v", err)
}
t.Cleanup(func() {
pool.Close()
_, _ = adminPool.Exec(ctx, fmt.Sprintf(`DROP DATABASE IF EXISTS %q`, dbName))
adminPool.Close()
})
return pool
}
func createUserWithPassword(t *testing.T, store *user.TenantUserStore, email, password string) user.User {
t.Helper()
ctx := context.Background()
u, err := store.Create(ctx, email, "Test User")
if err != nil {
t.Fatalf("create user: %v", err)
}
hash, err := HashPassword(password)
if err != nil {
t.Fatalf("hash password: %v", err)
}
if err := store.SetPasswordHash(ctx, u.ID, hash); err != nil {
t.Fatalf("set password: %v", err)
}
return u
}
func TestLoginService_SuccessAndWrongPassword(t *testing.T) {
pool := setupTenantDB(t, "test_iam02_login")
store := user.NewTenantUserStore(pool)
createUserWithPassword(t, store, "alice@example.com", "korrektes-passwort")
issuer := NewTokenIssuer("test-secret-nur-fuer-tests")
login := NewLoginService(store, issuer, "acme")
token, err := login.Login(context.Background(), "alice@example.com", "korrektes-passwort")
if err != nil {
t.Fatalf("login: %v", err)
}
if token == "" {
t.Fatal("erwartet nicht-leeres token")
}
if _, err := login.Login(context.Background(), "alice@example.com", "falsches-passwort"); !errors.Is(err, ErrInvalidCredentials) {
t.Fatalf("erwartet ErrInvalidCredentials, habe %v", err)
}
if _, err := login.Login(context.Background(), "unbekannt@example.com", "irgendwas"); !errors.Is(err, ErrInvalidCredentials) {
t.Fatalf("erwartet ErrInvalidCredentials bei unbekannter email, habe %v", err)
}
}
// Pruefung 1: kein Cross-Tenant-Login moeglich, obwohl dieselbe E-Mail in
// zwei unterschiedlichen Tenant-Datenbanken mit unterschiedlichen Passwoertern
// existiert.
func TestLoginService_NoCrossTenantLogin(t *testing.T) {
poolA := setupTenantDB(t, "test_iam02_tenant_a")
poolB := setupTenantDB(t, "test_iam02_tenant_b")
storeA := user.NewTenantUserStore(poolA)
storeB := user.NewTenantUserStore(poolB)
createUserWithPassword(t, storeA, "shared@example.com", "passwort-tenant-a")
createUserWithPassword(t, storeB, "shared@example.com", "passwort-tenant-b")
issuer := NewTokenIssuer("test-secret-nur-fuer-tests")
loginA := NewLoginService(storeA, issuer, "tenant-a")
// Login gegen Tenant A mit dem Passwort von Tenant B darf nicht klappen,
// obwohl die E-Mail-Adresse identisch ist — die Store-Instanz kennt
// strukturell nur die Zeilen ihrer eigenen Datenbank.
if _, err := loginA.Login(context.Background(), "shared@example.com", "passwort-tenant-b"); !errors.Is(err, ErrInvalidCredentials) {
t.Fatalf("erwartet ErrInvalidCredentials fuer fremdes tenant-passwort, habe %v", err)
}
token, err := loginA.Login(context.Background(), "shared@example.com", "passwort-tenant-a")
if err != nil {
t.Fatalf("login gegen eigenen tenant sollte klappen: %v", err)
}
claims, err := issuer.Verify(token)
if err != nil {
t.Fatalf("verify: %v", err)
}
if claims.TenantSlug != "tenant-a" {
t.Fatalf("token tenant = %q, want tenant-a", claims.TenantSlug)
}
}
// Akzeptanzkriterium 3: geschuetzte Route ohne gueltige Session nicht erreichbar.
func TestRequireAuth_BlocksWithoutValidCookie(t *testing.T) {
issuer := NewTokenIssuer("test-secret-nur-fuer-tests")
protected := RequireAuth(issuer, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
// Kein Cookie.
req := httptest.NewRequest(http.MethodGet, "/geschuetzt", nil)
rec := httptest.NewRecorder()
protected(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("ohne cookie: status = %d, want 401", rec.Code)
}
// Manipuliertes Cookie.
req = httptest.NewRequest(http.MethodGet, "/geschuetzt", nil)
req.AddCookie(&http.Cookie{Name: CookieName, Value: "kaputt.token.hier"})
rec = httptest.NewRecorder()
protected(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("mit kaputtem cookie: status = %d, want 401", rec.Code)
}
// Gueltiges Token.
token, err := issuer.Issue("user-1", "acme")
if err != nil {
t.Fatalf("issue: %v", err)
}
req = httptest.NewRequest(http.MethodGet, "/geschuetzt", nil)
req.AddCookie(&http.Cookie{Name: CookieName, Value: token})
rec = httptest.NewRecorder()
protected(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("mit gueltigem cookie: status = %d, want 200", rec.Code)
}
}
+41
View File
@@ -0,0 +1,41 @@
package auth
import (
"context"
"net/http"
)
const CookieName = "nexarch_session"
type contextKey int
const claimsContextKey contextKey = iota
// RequireAuth schuetzt eine Route: ohne gueltiges, nicht abgelaufenes Token
// im Session-Cookie wird 401 zurueckgegeben und der Handler nicht aufgerufen
// (IAM-02 Akzeptanzkriterium 3).
func RequireAuth(issuer *TokenIssuer, next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie(CookieName)
if err != nil {
http.Error(w, "nicht angemeldet", http.StatusUnauthorized)
return
}
claims, err := issuer.Verify(cookie.Value)
if err != nil {
http.Error(w, "nicht angemeldet", http.StatusUnauthorized)
return
}
ctx := context.WithValue(r.Context(), claimsContextKey, claims)
next(w, r.WithContext(ctx))
}
}
// ClaimsFromContext liest die Claims, die RequireAuth in den Request-Context
// gelegt hat.
func ClaimsFromContext(ctx context.Context) (*Claims, bool) {
c, ok := ctx.Value(claimsContextKey).(*Claims)
return c, ok
}
+28
View File
@@ -0,0 +1,28 @@
// Package auth implementiert Core IAM-02: Login/Logout, Passwort-Hashing und
// die Middleware zum Schutz von Routen. Autorisierung (was ein Benutzer darf)
// ist ausdruecklich NICHT Teil dieses Pakets, siehe RBAC-01 — auth prueft nur
// "wer bin ich" (Casbin-Architekturprinzip, siehe IAM-02-Ticket).
package auth
import "golang.org/x/crypto/bcrypt"
// BcryptCost ist bewusst explizit festgelegt statt bcrypt.DefaultCost (10)
// unreflektiert zu uebernehmen (IAM-02 Akzeptanzkriterium 4). Kostenfaktor 12
// wurde gegen die Ziel-Login-Latenz benchmarkt, siehe password_bench_test.go
// und den Pruefungs-Eintrag in der Commit-Nachricht.
const BcryptCost = 12
func HashPassword(plain string) (string, error) {
hash, err := bcrypt.GenerateFromPassword([]byte(plain), BcryptCost)
if err != nil {
return "", err
}
return string(hash), nil
}
// VerifyPassword ist timing-safe: bcrypt.CompareHashAndPassword vergleicht
// konstant in der Zeit bzgl. des Hash-Inhalts (Referenzimplementierung fuer
// die projektweite Timing-safe-Vergleich-Konvention aus IAM-02).
func VerifyPassword(hash, plain string) bool {
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(plain)) == nil
}
+42
View File
@@ -0,0 +1,42 @@
package auth
import (
"testing"
"time"
)
// TargetLoginLatency ist der Zielwert aus IAM-02 Akzeptanzkriterium 4: der
// bcrypt-Vergleich allein darf die Login-Latenz nicht dominieren. 400ms ist
// grosszuegig genug, um auf unterschiedlicher Hardware stabil zu sein, aber
// eng genug, um eine versehentliche Kostenfaktor-Explosion (z.B. 16 statt 12)
// zuverlaessig aufzudecken.
const TargetLoginLatency = 400 * time.Millisecond
// TestBcryptCostAgainstLatencyTarget misst die tatsaechliche Dauer eines
// Passwort-Vergleichs mit dem festgelegten BcryptCost und dokumentiert das
// Ergebnis (IAM-02 Pruefung 4).
func TestBcryptCostAgainstLatencyTarget(t *testing.T) {
hash, err := HashPassword("benchmark-passwort")
if err != nil {
t.Fatalf("hash: %v", err)
}
start := time.Now()
if !VerifyPassword(hash, "benchmark-passwort") {
t.Fatal("verifikation haette erfolgreich sein muessen")
}
elapsed := time.Since(start)
t.Logf("bcrypt-vergleich mit cost=%d dauerte %s (ziel: unter %s)", BcryptCost, elapsed, TargetLoginLatency)
if elapsed > TargetLoginLatency {
t.Fatalf("bcrypt-vergleich zu langsam: %s > ziel %s", elapsed, TargetLoginLatency)
}
}
func BenchmarkVerifyPassword(b *testing.B) {
hash, _ := HashPassword("benchmark-passwort")
b.ResetTimer()
for i := 0; i < b.N; i++ {
VerifyPassword(hash, "benchmark-passwort")
}
}
+28
View File
@@ -0,0 +1,28 @@
package auth
import "testing"
func TestHashAndVerifyPassword(t *testing.T) {
hash, err := HashPassword("s3hr-geheim!")
if err != nil {
t.Fatalf("hash: %v", err)
}
if hash == "s3hr-geheim!" {
t.Fatal("passwort wurde nicht gehasht")
}
if !VerifyPassword(hash, "s3hr-geheim!") {
t.Fatal("erwartet erfolgreiche verifikation")
}
if VerifyPassword(hash, "falsches-passwort") {
t.Fatal("erwartet fehlgeschlagene verifikation")
}
}
func TestDummyHashIsValidBcryptHash(t *testing.T) {
// Stellt sicher, dass der Timing-Angleichs-Hash in login.go tatsaechlich
// ein gueltiges bcrypt-Format hat und den vollen Kostenfaktor durchlaeuft
// (siehe Kommentar dort) statt sofort mit einem Format-Fehler abzubrechen.
if VerifyPassword(dummyHash, "irgendein-text") {
t.Fatal("dummyHash sollte fuer beliebigen text nicht passen")
}
}
+44
View File
@@ -0,0 +1,44 @@
package auth
import (
"encoding/json"
"net/http"
"gitea.perlbach24.de/scripte/nexarch/internal/user"
)
// ProfileHandler liefert die eigenen Stammdaten fuer die Profilseite
// (IAM-08). Getrennt von ChangePasswordHandler, da unterschiedliche
// Lesbarkeitsanforderungen (nur GET, kein Nebeneffekt).
type ProfileHandler struct {
users *user.TenantUserStore
}
func NewProfileHandler(users *user.TenantUserStore) *ProfileHandler {
return &ProfileHandler{users: users}
}
type meResponse struct {
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
}
// Me liefert die Stammdaten des angemeldeten Benutzers. Muss hinter
// RequireAuth haengen.
func (h *ProfileHandler) Me(w http.ResponseWriter, r *http.Request) {
claims, ok := ClaimsFromContext(r.Context())
if !ok {
http.Error(w, "nicht angemeldet", http.StatusUnauthorized)
return
}
u, err := h.users.Get(r.Context(), claims.UserID)
if err != nil {
http.Error(w, "benutzer nicht gefunden", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(meResponse{ID: u.ID, Email: u.Email, Name: u.Name})
}
+48
View File
@@ -0,0 +1,48 @@
package auth
import (
"net/http"
"net/http/httptest"
"testing"
"gitea.perlbach24.de/scripte/nexarch/internal/user"
)
func TestProfileMe_ReturnsOwnData(t *testing.T) {
pool := setupTenantDB(t, "profile_test_me")
store := user.NewTenantUserStore(pool)
u := createUserWithPassword(t, store, "profil@example.com", "irgendein-passwort")
issuer := NewTokenIssuer("test-session-secret")
sessionToken, err := issuer.Issue(u.ID, "acme")
if err != nil {
t.Fatalf("session-token ausstellen: %v", err)
}
h := NewProfileHandler(store)
req := httptest.NewRequest(http.MethodGet, "/account/me", nil)
req.AddCookie(&http.Cookie{Name: CookieName, Value: sessionToken})
rec := httptest.NewRecorder()
RequireAuth(issuer, h.Me)(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200, body: %s", rec.Code, rec.Body.String())
}
}
func TestProfileMe_RejectsWithoutSession(t *testing.T) {
pool := setupTenantDB(t, "profile_test_noauth")
store := user.NewTenantUserStore(pool)
issuer := NewTokenIssuer("test-session-secret")
h := NewProfileHandler(store)
req := httptest.NewRequest(http.MethodGet, "/account/me", nil)
rec := httptest.NewRecorder()
RequireAuth(issuer, h.Me)(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401 ohne session-cookie", rec.Code)
}
}
+63
View File
@@ -0,0 +1,63 @@
package auth
import (
"errors"
"time"
"github.com/golang-jwt/jwt/v5"
)
// AccessTokenTTL ist bewusst kurz gehalten (Session-Ablauf statt langlebiger
// Tokens), passend zur "so vertrauenswuerdig wie noetig"-Produkt-DNA.
const AccessTokenTTL = 30 * time.Minute
var ErrInvalidToken = errors.New("auth: ungueltiges oder abgelaufenes token")
type Claims struct {
UserID string `json:"uid"`
TenantSlug string `json:"tenant"`
jwt.RegisteredClaims
}
// TokenIssuer signiert/verifiziert JWTs mit einem HMAC-Secret. Das
// asymmetrische Core-weite Signaturschema (API-05, kid-Rotation) ist
// ausdruecklich nicht Teil dieser Kachel — hier geht es nur um das
// Login-Grundgerüst innerhalb eines einzelnen Core-Prozesses.
type TokenIssuer struct {
secret []byte
}
func NewTokenIssuer(secret string) *TokenIssuer {
return &TokenIssuer{secret: []byte(secret)}
}
func (i *TokenIssuer) Issue(userID, tenantSlug string) (string, error) {
now := time.Now()
claims := Claims{
UserID: userID,
TenantSlug: tenantSlug,
RegisteredClaims: jwt.RegisteredClaims{
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(now.Add(AccessTokenTTL)),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(i.secret)
}
// Verify prueft Signatur UND Ablauf (jwt.ParseWithClaims lehnt abgelaufene
// Tokens automatisch ab) — der Signaturvergleich in golang-jwt ist
// timing-safe (hmac.Equal).
func (i *TokenIssuer) Verify(tokenString string) (*Claims, error) {
claims := &Claims{}
token, err := jwt.ParseWithClaims(tokenString, claims, func(t *jwt.Token) (interface{}, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, ErrInvalidToken
}
return i.secret, nil
})
if err != nil || !token.Valid {
return nil, ErrInvalidToken
}
return claims, nil
}
+82
View File
@@ -0,0 +1,82 @@
package auth
import (
"strings"
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
)
func TestTokenIssueAndVerify(t *testing.T) {
issuer := NewTokenIssuer("test-secret-nur-fuer-tests")
token, err := issuer.Issue("user-1", "acme")
if err != nil {
t.Fatalf("issue: %v", err)
}
claims, err := issuer.Verify(token)
if err != nil {
t.Fatalf("verify: %v", err)
}
if claims.UserID != "user-1" || claims.TenantSlug != "acme" {
t.Fatalf("claims unerwartet: %+v", claims)
}
}
// Pruefung 2: Token-Manipulationstest.
func TestTokenVerify_RejectsManipulatedPayload(t *testing.T) {
issuer := NewTokenIssuer("test-secret-nur-fuer-tests")
token, err := issuer.Issue("user-1", "acme")
if err != nil {
t.Fatalf("issue: %v", err)
}
parts := strings.Split(token, ".")
if len(parts) != 3 {
t.Fatalf("unerwartetes token-format: %d teile", len(parts))
}
// Payload-Segment leicht veraendern (Signatur passt danach nicht mehr).
tampered := parts[0] + "." + parts[1] + "x" + "." + parts[2]
if _, err := issuer.Verify(tampered); err == nil {
t.Fatal("erwartet fehler bei manipuliertem token, habe nil")
}
}
func TestTokenVerify_RejectsWrongSecret(t *testing.T) {
issuer := NewTokenIssuer("secret-a")
other := NewTokenIssuer("secret-b")
token, err := issuer.Issue("user-1", "acme")
if err != nil {
t.Fatalf("issue: %v", err)
}
if _, err := other.Verify(token); err == nil {
t.Fatal("erwartet fehler bei falschem secret, habe nil")
}
}
// Pruefung 3: abgelaufenes Token erzwingt Neuanmeldung.
func TestTokenVerify_RejectsExpiredToken(t *testing.T) {
issuer := NewTokenIssuer("test-secret-nur-fuer-tests")
claims := Claims{
UserID: "user-1",
TenantSlug: "acme",
RegisteredClaims: jwt.RegisteredClaims{
IssuedAt: jwt.NewNumericDate(time.Now().Add(-2 * AccessTokenTTL)),
ExpiresAt: jwt.NewNumericDate(time.Now().Add(-time.Minute)),
},
}
tok := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
expired, err := tok.SignedString([]byte("test-secret-nur-fuer-tests"))
if err != nil {
t.Fatalf("signieren: %v", err)
}
if _, err := issuer.Verify(expired); err == nil {
t.Fatal("erwartet fehler bei abgelaufenem token, habe nil")
}
}
+40
View File
@@ -0,0 +1,40 @@
package authtoken
import (
"context"
"fmt"
"gitea.perlbach24.de/scripte/nexarch/internal/auth"
"gitea.perlbach24.de/scripte/nexarch/internal/user"
)
// CompletePasswordReset loest ein Reset-Token ein und setzt das neue
// Passwort — komplett OHNE bestehende Session (Akzeptanzkriterium 2 gilt
// analog fuer Reset wie fuer Invite: der Vorgang ersetzt eine Anmeldung).
func CompletePasswordReset(ctx context.Context, tokens *Store, users *user.TenantUserStore, token, newPassword string) error {
return completeWithToken(ctx, tokens, users, token, PurposeReset, newPassword)
}
// CompleteInvitation loest ein Einladungs-Token ein und setzt das
// Erstpasswort eines neu angelegten Benutzers — ohne bestehende Session
// (Akzeptanzkriterium 2).
func CompleteInvitation(ctx context.Context, tokens *Store, users *user.TenantUserStore, token, initialPassword string) error {
return completeWithToken(ctx, tokens, users, token, PurposeInvite, initialPassword)
}
func completeWithToken(ctx context.Context, tokens *Store, users *user.TenantUserStore, token string, purpose Purpose, newPassword string) error {
userID, err := tokens.Consume(ctx, token, purpose)
if err != nil {
return err
}
hash, err := auth.HashPassword(newPassword)
if err != nil {
return fmt.Errorf("passwort hashen: %w", err)
}
if err := users.SetPasswordHash(ctx, userID, hash); err != nil {
return fmt.Errorf("passwort setzen: %w", err)
}
return nil
}
+109
View File
@@ -0,0 +1,109 @@
package authtoken
import (
"context"
"encoding/json"
"log/slog"
"net/http"
"time"
"gitea.perlbach24.de/scripte/nexarch/internal/user"
)
// ResetTokenTTL: 1 Stunde ist der uebliche Kompromiss zwischen Nutzbarkeit
// und Angriffsflaeche fuer Passwort-Reset-Links.
const ResetTokenTTL = time.Hour
// Notifier verschickt das Reset-Token an den Benutzer. Core CFG-02
// (Benachrichtigungs-Dispatcher) ist noch nicht in diesem Branch verfuegbar
// (siehe SICHERHEITSKONZEPT.md / Core-Board-Konvention: Versand laeuft
// grundsaetzlich ueber CFG-02, kein eigener E-Mail-Versand je Modul) — bis
// zur Anbindung protokolliert LogNotifier den Vorgang nur, statt eine E-Mail
// zu verschicken. Produktionsreif ist erst der Austausch gegen einen
// CFG-02-Client, keine neue eigene Versandlogik hier.
type Notifier interface {
NotifyPasswordReset(ctx context.Context, email, token string) error
}
type LogNotifier struct{}
func (LogNotifier) NotifyPasswordReset(_ context.Context, email, _ string) error {
// Token wird bewusst NICHT geloggt (gleiche Regel wie Store.Create).
slog.Info("passwort-reset angefordert (kein CFG-02 in diesem branch, nur protokolliert)", "email_domain_hint", domainHint(email))
return nil
}
func domainHint(email string) string {
for i := len(email) - 1; i >= 0; i-- {
if email[i] == '@' {
return email[i:]
}
}
return ""
}
// Handler stellt Passwort-Reset als HTTP-Endpunkte bereit (IAM-08).
type Handler struct {
tokens *Store
users *user.TenantUserStore
notifier Notifier
}
func NewHandler(tokens *Store, users *user.TenantUserStore, notifier Notifier) *Handler {
if notifier == nil {
notifier = LogNotifier{}
}
return &Handler{tokens: tokens, users: users, notifier: notifier}
}
type requestResetRequest struct {
Email string `json:"email"`
}
// RequestReset liefert IMMER dieselbe Erfolgsmeldung, unabhaengig davon, ob
// die E-Mail-Adresse existiert (Akzeptanzkriterium 3 / Pruefung 1,
// User-Enumeration-Schutz — gleiches Prinzip wie auth.LoginService.Login).
func (h *Handler) RequestReset(w http.ResponseWriter, r *http.Request) {
var req requestResetRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "ungueltige Anfrage", http.StatusBadRequest)
return
}
// Absichtlich: Fehler (unbekannte E-Mail) fuehrt NICHT zu einer anderen
// HTTP-Antwort als der Erfolgsfall — nur der interne Pfad unterscheidet sich.
creds, err := h.users.GetByEmailForAuth(r.Context(), req.Email)
if err == nil {
token, tokenErr := h.tokens.Create(r.Context(), creds.User.ID, PurposeReset, ResetTokenTTL)
if tokenErr == nil {
_ = h.notifier.NotifyPasswordReset(r.Context(), req.Email, token)
}
}
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(map[string]string{
"message": "Falls ein Konto mit dieser E-Mail-Adresse existiert, wurde eine Nachricht mit weiteren Schritten verschickt.",
})
}
type completeResetRequest struct {
Token string `json:"token"`
NewPassword string `json:"new_password"`
}
// CompleteReset schliesst den Reset ab (Akzeptanzkriterium 3: generische
// Fehlermeldung bei ungueltigem/abgelaufenem Token, siehe ErrInvalidToken).
func (h *Handler) CompleteReset(w http.ResponseWriter, r *http.Request) {
var req completeResetRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "ungueltige Anfrage", http.StatusBadRequest)
return
}
if err := CompletePasswordReset(r.Context(), h.tokens, h.users, req.Token, req.NewPassword); err != nil {
http.Error(w, "Link ungültig oder abgelaufen.", http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusOK)
}
+94
View File
@@ -0,0 +1,94 @@
package authtoken
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
// fakeNotifier zeichnet auf, ob/mit welchem Token benachrichtigt wurde —
// ersetzt LogNotifier im Test, damit der Test pruefen kann, OB ein Token
// erzeugt wurde, ohne die HTTP-Antwort danach zu unterscheiden
// (Akzeptanzkriterium 3: die Antwort selbst bleibt in beiden Faellen gleich).
type fakeNotifier struct {
calls []string // e-mail-adressen, fuer die notifiziert wurde
}
func (f *fakeNotifier) NotifyPasswordReset(_ context.Context, email, _ string) error {
f.calls = append(f.calls, email)
return nil
}
// Akzeptanzkriterium 3 + Pruefung 1: Antwort ist IDENTISCH, egal ob die
// E-Mail-Adresse existiert — nur intern (Notifier-Aufruf) unterscheidet sich.
func TestRequestReset_SameResponseRegardlessOfExistence(t *testing.T) {
tokens, users, userID, cleanup := setupTest(t)
defer cleanup()
_ = userID
u, err := users.Get(context.Background(), userID)
if err != nil {
t.Fatalf("benutzer laden: %v", err)
}
notifier := &fakeNotifier{}
h := NewHandler(tokens, users, notifier)
// Fall 1: existierende E-Mail.
body1, _ := json.Marshal(requestResetRequest{Email: u.Email})
req1 := httptest.NewRequest(http.MethodPost, "/auth/password-reset/request", bytes.NewReader(body1))
rec1 := httptest.NewRecorder()
h.RequestReset(rec1, req1)
// Fall 2: nicht existierende E-Mail.
body2, _ := json.Marshal(requestResetRequest{Email: "gibt-es-nicht@example.com"})
req2 := httptest.NewRequest(http.MethodPost, "/auth/password-reset/request", bytes.NewReader(body2))
rec2 := httptest.NewRecorder()
h.RequestReset(rec2, req2)
if rec1.Code != rec2.Code {
t.Fatalf("status codes unterscheiden sich: %d vs %d — verraet Konto-Existenz", rec1.Code, rec2.Code)
}
if rec1.Body.String() != rec2.Body.String() {
t.Fatalf("antwort-body unterscheidet sich: %q vs %q — verraet Konto-Existenz", rec1.Body.String(), rec2.Body.String())
}
// Intern wurde aber nur fuer die existierende Adresse tatsaechlich benachrichtigt.
if len(notifier.calls) != 1 || notifier.calls[0] != u.Email {
t.Fatalf("erwartet genau 1 benachrichtigung fuer %q, habe %v", u.Email, notifier.calls)
}
}
// Akzeptanzkriterium 3: vollstaendiger Reset-Flow ueber HTTP.
func TestCompleteReset_Works(t *testing.T) {
tokens, users, userID, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
token, err := tokens.Create(ctx, userID, PurposeReset, ResetTokenTTL)
if err != nil {
t.Fatalf("token erzeugen: %v", err)
}
h := NewHandler(tokens, users, &fakeNotifier{})
body, _ := json.Marshal(completeResetRequest{Token: token, NewPassword: "neues-sicheres-passwort"})
req := httptest.NewRequest(http.MethodPost, "/auth/password-reset/complete", bytes.NewReader(body))
rec := httptest.NewRecorder()
h.CompleteReset(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200, body: %s", rec.Code, rec.Body.String())
}
// Token ist jetzt verbraucht — zweiter Versuch schlaegt fehl.
body2, _ := json.Marshal(completeResetRequest{Token: token, NewPassword: "noch-ein-passwort"})
req2 := httptest.NewRequest(http.MethodPost, "/auth/password-reset/complete", bytes.NewReader(body2))
rec2 := httptest.NewRecorder()
h.CompleteReset(rec2, req2)
if rec2.Code != http.StatusBadRequest {
t.Fatalf("wiederverwendung: status = %d, want 400", rec2.Code)
}
}
+103
View File
@@ -0,0 +1,103 @@
// Package authtoken implementiert Core IAM-03: einmal-verwendbare Tokens fuer
// Passwort-Reset und Einladung neuer Benutzer, mit Ablaufzeit — bewusst
// getrennt von internal/auth (Sessions/JWT), da es hier um einen
// vorgelagerten, session-losen Vorgang geht (Akzeptanzkriterium 2: Einladung
// setzt das Erstpasswort OHNE bestehende Session).
package authtoken
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"log/slog"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
type Purpose string
const (
PurposeReset Purpose = "reset"
PurposeInvite Purpose = "invite"
)
// ErrInvalidToken deckt sowohl unbekannte als auch bereits verwendete oder
// abgelaufene Tokens ab (Akzeptanzkriterium 3) — bewusst EIN Fehler, damit
// ein Angreifer aus der Antwort nicht ableiten kann, welcher der drei Faelle
// vorliegt.
var ErrInvalidToken = errors.New("authtoken: token ungueltig, bereits verwendet oder abgelaufen")
type Store struct {
pool *pgxpool.Pool
}
func NewStore(pool *pgxpool.Pool) *Store {
return &Store{pool: pool}
}
// Create erzeugt ein neues Einmal-Token und liefert es NUR im Klartext an
// den Aufrufer zurueck (zum Versand per E-Mail, siehe IAM-08) — gespeichert
// wird ausschliesslich der Hash. Es wird niemals geloggt (Akzeptanzkriterium
// 3 / Pruefung 3): das Log-Statement unten enthaelt bewusst nur user_id und
// purpose, nicht token.
func (s *Store) Create(ctx context.Context, userID string, purpose Purpose, ttl time.Duration) (token string, err error) {
token, err = randomToken()
if err != nil {
return "", fmt.Errorf("token erzeugen: %w", err)
}
hash := hashToken(token)
expiresAt := time.Now().Add(ttl)
_, err = s.pool.Exec(ctx, `
INSERT INTO password_tokens (user_id, token_hash, purpose, expires_at)
VALUES ($1, $2, $3, $4)
`, userID, hash, string(purpose), expiresAt)
if err != nil {
return "", fmt.Errorf("token speichern: %w", err)
}
slog.Info("passwort-token erzeugt", "user_id", userID, "purpose", purpose, "laeuft_ab", expiresAt)
return token, nil
}
// Consume loest ein Token ein: gueltig nur, wenn es existiert, noch nicht
// verwendet und nicht abgelaufen ist. Das Markieren als verwendet geschieht
// atomar ueber die WHERE-Klausel (used_at IS NULL) — zwei gleichzeitige
// Einloesungsversuche koennen sich nicht gegenseitig ueberholen
// (Akzeptanzkriterium 1 / Pruefung 1: nur EINE Einloesung gelingt).
func (s *Store) Consume(ctx context.Context, token string, purpose Purpose) (userID string, err error) {
hash := hashToken(token)
err = s.pool.QueryRow(ctx, `
UPDATE password_tokens
SET used_at = now()
WHERE token_hash = $1 AND purpose = $2 AND used_at IS NULL AND expires_at > now()
RETURNING user_id
`, hash, string(purpose)).Scan(&userID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
slog.Warn("token-einloesung abgewiesen", "purpose", purpose, "grund", "ungueltig, bereits verwendet oder abgelaufen")
return "", ErrInvalidToken
}
return "", fmt.Errorf("token einloesen: %w", err)
}
return userID, nil
}
func randomToken() (string, error) {
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return hex.EncodeToString(buf), nil
}
func hashToken(token string) []byte {
sum := sha256.Sum256([]byte(token))
return sum[:]
}
+187
View File
@@ -0,0 +1,187 @@
package authtoken
import (
"bytes"
"context"
"errors"
"fmt"
"log/slog"
"os"
"strings"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/internal/auth"
"gitea.perlbach24.de/scripte/nexarch/internal/user"
)
func setupTest(t *testing.T) (*Store, *user.TenantUserStore, string, func()) {
t.Helper()
adminDSN := os.Getenv("TEST_ADMIN_DSN")
if adminDSN == "" {
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, adminDSN)
if err != nil {
t.Fatalf("pool: %v", err)
}
if _, err := pool.Exec(ctx, `
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), email TEXT NOT NULL UNIQUE, name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active', password_hash TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS password_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES users(id),
token_hash BYTEA NOT NULL UNIQUE, purpose TEXT NOT NULL CHECK (purpose IN ('reset','invite')),
expires_at TIMESTAMPTZ NOT NULL, used_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
`); err != nil {
t.Fatalf("schema: %v", err)
}
userStore := user.NewTenantUserStore(pool)
email := fmt.Sprintf("reset-test-%d@example.com", time.Now().UnixNano())
u, err := userStore.Create(ctx, email, "Reset Test")
if err != nil {
t.Fatalf("testuser anlegen: %v", err)
}
cleanup := func() { pool.Close() }
return NewStore(pool), userStore, u.ID, cleanup
}
// Akzeptanzkriterium 1 + Pruefung 1: Wiederverwendung eines bereits
// eingeloesten Tokens wird abgewiesen.
func TestConsume_RejectsReuse(t *testing.T) {
store, _, userID, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
token, err := store.Create(ctx, userID, PurposeReset, time.Hour)
if err != nil {
t.Fatalf("create: %v", err)
}
gotUserID, err := store.Consume(ctx, token, PurposeReset)
if err != nil {
t.Fatalf("erste einloesung: %v", err)
}
if gotUserID != userID {
t.Fatalf("user id = %q, want %q", gotUserID, userID)
}
if _, err := store.Consume(ctx, token, PurposeReset); !errors.Is(err, ErrInvalidToken) {
t.Fatalf("erwartet ErrInvalidToken bei zweiter einloesung, habe %v", err)
}
}
// Akzeptanzkriterium 1 + 3 + Pruefung 2: Ablaufzeit wird serverseitig
// durchgesetzt.
func TestConsume_RejectsExpiredToken(t *testing.T) {
store, _, userID, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
token, err := store.Create(ctx, userID, PurposeReset, -1*time.Second) // sofort abgelaufen
if err != nil {
t.Fatalf("create: %v", err)
}
if _, err := store.Consume(ctx, token, PurposeReset); !errors.Is(err, ErrInvalidToken) {
t.Fatalf("erwartet ErrInvalidToken bei abgelaufenem token, habe %v", err)
}
}
func TestConsume_RejectsUnknownToken(t *testing.T) {
store, _, _, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
if _, err := store.Consume(ctx, "niemals-ausgestelltes-token", PurposeReset); !errors.Is(err, ErrInvalidToken) {
t.Fatalf("erwartet ErrInvalidToken, habe %v", err)
}
}
func TestConsume_RejectsWrongPurpose(t *testing.T) {
store, _, userID, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
token, err := store.Create(ctx, userID, PurposeInvite, time.Hour)
if err != nil {
t.Fatalf("create: %v", err)
}
if _, err := store.Consume(ctx, token, PurposeReset); !errors.Is(err, ErrInvalidToken) {
t.Fatalf("erwartet ErrInvalidToken bei falschem purpose, habe %v", err)
}
}
// Akzeptanzkriterium 2: Einladungs-Link setzt das Erstpasswort ohne
// bestehende Session — der Ablauf braucht keinerlei Auth-Middleware/Cookie.
func TestCompleteInvitation_SetsPasswordWithoutSession(t *testing.T) {
store, userStore, userID, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
token, err := store.Create(ctx, userID, PurposeInvite, time.Hour)
if err != nil {
t.Fatalf("create: %v", err)
}
if err := CompleteInvitation(ctx, store, userStore, token, "neues-erstpasswort-123"); err != nil {
t.Fatalf("complete invitation: %v", err)
}
u, err := userStore.Get(ctx, userID)
if err != nil {
t.Fatalf("user lesen: %v", err)
}
creds, err := userStore.GetByEmailForAuth(ctx, u.Email)
if err != nil {
t.Fatalf("credentials lesen: %v", err)
}
if !auth.VerifyPassword(creds.PasswordHash, "neues-erstpasswort-123") {
t.Fatal("erwartet, dass das gesetzte erstpasswort verifizierbar ist")
}
// Token ist danach verbraucht, kann nicht doppelt eingeloest werden.
if err := CompleteInvitation(ctx, store, userStore, token, "irgendwas"); !errors.Is(err, ErrInvalidToken) {
t.Fatalf("erwartet ErrInvalidToken bei erneuter einloesung, habe %v", err)
}
}
// Akzeptanzkriterium 3 + Pruefung 3: Token werden nicht im Klartext geloggt.
func TestCreateAndConsume_NeverLogTokenPlaintext(t *testing.T) {
store, _, userID, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
var buf bytes.Buffer
prevLogger := slog.Default()
slog.SetDefault(slog.New(slog.NewTextHandler(&buf, nil)))
defer slog.SetDefault(prevLogger)
token, err := store.Create(ctx, userID, PurposeReset, time.Hour)
if err != nil {
t.Fatalf("create: %v", err)
}
if _, err := store.Consume(ctx, "falsches-token-zum-testen-des-log-pfads", PurposeReset); !errors.Is(err, ErrInvalidToken) {
t.Fatalf("consume (ungueltig): %v", err)
}
if _, err := store.Consume(ctx, token, PurposeReset); err != nil {
t.Fatalf("consume (gueltig): %v", err)
}
logOutput := buf.String()
if strings.Contains(logOutput, token) {
t.Fatalf("log enthaelt das klartext-token — verstoss gegen akzeptanzkriterium 3:\n%s", logOutput)
}
if logOutput == "" {
t.Fatal("erwartet ueberhaupt log-ausgabe fuer diesen stichprobentest")
}
}
+32 -1
View File
@@ -11,7 +11,17 @@ import (
// multi_tenancy: Modell C (physisch getrennte DB pro Mandant).
type Config struct {
ListenAddr string
// RegistryDSN verbindet zur Control-Plane-Registry-Datenbank.
RegistryDSN string
// AdminDSN verbindet zur Wartungsdatenbank (z.B. "postgres") und wird nur
// fuer CREATE/DROP DATABASE beim Tenant-Provisioning verwendet.
AdminDSN string
// TenantDSNTemplate enthaelt genau ein "%s" als Platzhalter fuer den
// Datenbanknamen einer neu provisionierten Tenant-Datenbank.
TenantDSNTemplate string
// TenantSchemaPath zeigt auf migrations/tenant/0001_users.up.sql, mit der
// TEN-02 eine frisch provisionierte Tenant-Datenbank initialisiert.
TenantSchemaPath string
}
func Load() (Config, error) {
@@ -20,10 +30,31 @@ func Load() (Config, error) {
return Config{}, fmt.Errorf("NEXARCH_REGISTRY_DSN not set")
}
adminDSN := os.Getenv("NEXARCH_ADMIN_DSN")
if adminDSN == "" {
return Config{}, fmt.Errorf("NEXARCH_ADMIN_DSN not set")
}
dsnTemplate := os.Getenv("NEXARCH_TENANT_DSN_TEMPLATE")
if dsnTemplate == "" {
return Config{}, fmt.Errorf("NEXARCH_TENANT_DSN_TEMPLATE not set")
}
schemaPath := os.Getenv("NEXARCH_TENANT_SCHEMA_PATH")
if schemaPath == "" {
return Config{}, fmt.Errorf("NEXARCH_TENANT_SCHEMA_PATH not set")
}
addr := os.Getenv("NEXARCH_LISTEN_ADDR")
if addr == "" {
addr = ":8080"
}
return Config{ListenAddr: addr, RegistryDSN: dsn}, nil
return Config{
ListenAddr: addr,
RegistryDSN: dsn,
AdminDSN: adminDSN,
TenantDSNTemplate: dsnTemplate,
TenantSchemaPath: schemaPath,
}, nil
}
+116
View File
@@ -0,0 +1,116 @@
package e2e
import (
"context"
"strings"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/internal/auth"
"gitea.perlbach24.de/scripte/nexarch/internal/tenant"
"gitea.perlbach24.de/scripte/nexarch/internal/user"
)
// TestE2E_CrossTenantIsolation ist der QA-02-Nachweis fuer Akzeptanzkriterium
// "keine Datenlecks zwischen Mandanten": zwei vollstaendig provisionierte
// Mandanten mit je einem Benutzer, dann drei unabhaengige Beweise, dass
// weder Datenbank- noch Token-Ebene eine Vermischung zulassen.
func TestE2E_CrossTenantIsolation(t *testing.T) {
adminDSN := requireAdminDSN(t)
ctx := context.Background()
root := repoRoot(t)
adminPool, err := pgxpool.New(ctx, adminDSN)
if err != nil {
t.Fatalf("admin pool: %v", err)
}
defer adminPool.Close()
applyRegistrySchema(t, ctx, adminPool, root)
registry := tenant.NewRegistry(adminPool)
dsnTemplate := strings.Replace(adminDSN, "/postgres?", "/%s?", 1)
provisioner := tenant.NewProvisioner(adminPool, registry, dsnTemplate)
t.Cleanup(func() {
_, _ = adminPool.Exec(ctx, `DELETE FROM tenants WHERE slug IN ('e2e_tenant_a','e2e_tenant_b')`)
dropTenantDBs(ctx, adminPool, "tenant_e2e_tenant_a", "tenant_e2e_tenant_b")
})
setupTenantWithUser := func(slug, name, email, password string) (tenant.Tenant, user.User, *auth.LoginService, *pgxpool.Pool) {
tn, err := provisioner.Provision(ctx, slug, name)
if err != nil {
t.Fatalf("provision %s: %v", slug, err)
}
pool, err := pgxpool.New(ctx, tn.DBDSN)
if err != nil {
t.Fatalf("connect %s: %v", slug, err)
}
applyTenantSchema(t, ctx, pool, root)
store := user.NewTenantUserStore(pool)
u, err := store.Create(ctx, email, "Benutzer "+slug)
if err != nil {
t.Fatalf("benutzer anlegen %s: %v", slug, err)
}
hash, err := auth.HashPassword(password)
if err != nil {
t.Fatalf("passwort hashen %s: %v", slug, err)
}
if err := store.SetPasswordHash(ctx, u.ID, hash); err != nil {
t.Fatalf("passwort setzen %s: %v", slug, err)
}
issuer := auth.NewTokenIssuer("qa-02-cross-tenant-secret")
login := auth.NewLoginService(store, issuer, tn.Slug)
return tn, u, login, pool
}
tenantA, userA, loginA, poolA := setupTenantWithUser("e2e_tenant_a", "Tenant A GmbH", "user@tenant-a.example", "passwort-a-sicher")
defer poolA.Close()
tenantB, userB, loginB, poolB := setupTenantWithUser("e2e_tenant_b", "Tenant B GmbH", "user@tenant-b.example", "passwort-b-sicher")
defer poolB.Close()
// Beweis 1: physische Trennung - Tenant Bs Benutzer existiert in der
// users-Tabelle von Tenant A schlicht nicht (keine Zeile, kein Filter).
var count int
if err := poolA.QueryRow(ctx, `SELECT count(*) FROM users WHERE email = $1`, "user@tenant-b.example").Scan(&count); err != nil {
t.Fatalf("query tenant a fuer tenant-b-email: %v", err)
}
if count != 0 {
t.Fatalf("tenant a datenbank enthaelt %d zeile(n) fuer tenant-b-benutzer, erwartet 0", count)
}
if err := poolB.QueryRow(ctx, `SELECT count(*) FROM users WHERE email = $1`, "user@tenant-a.example").Scan(&count); err != nil {
t.Fatalf("query tenant b fuer tenant-a-email: %v", err)
}
if count != 0 {
t.Fatalf("tenant b datenbank enthaelt %d zeile(n) fuer tenant-a-benutzer, erwartet 0", count)
}
// Beweis 2: Login schlaegt fehl, wenn ein Benutzer versucht, sich ueber
// den falschen Tenant-LoginService anzumelden - er existiert dort nicht.
if _, err := loginA.Login(ctx, "user@tenant-b.example", "passwort-b-sicher"); err == nil {
t.Fatal("tenant-b-benutzer konnte sich ueber tenant-a-loginservice anmelden")
}
if _, err := loginB.Login(ctx, "user@tenant-a.example", "passwort-a-sicher"); err == nil {
t.Fatal("tenant-a-benutzer konnte sich ueber tenant-b-loginservice anmelden")
}
// Beweis 3: Token tragen den korrekten Tenant-Slug und die korrekte
// User-ID, keine Vermischung ueber gemeinsame IDs oder Slugs.
tokenA, err := loginA.Login(ctx, "user@tenant-a.example", "passwort-a-sicher")
if err != nil {
t.Fatalf("login tenant a: %v", err)
}
issuer := auth.NewTokenIssuer("qa-02-cross-tenant-secret")
claimsA, err := issuer.Verify(tokenA)
if err != nil {
t.Fatalf("verify token a: %v", err)
}
if claimsA.TenantSlug != tenantA.Slug || claimsA.UserID != userA.ID {
t.Fatalf("token a claims = %+v, want tenant %q user %q", claimsA, tenantA.Slug, userA.ID)
}
if claimsA.TenantSlug == tenantB.Slug || claimsA.UserID == userB.ID {
t.Fatal("token a claims stimmen mit tenant-b-daten ueberein")
}
}
+107
View File
@@ -0,0 +1,107 @@
// Package e2e enthaelt QA-02 (Pruefgate Identitaet & Mandanten): echte
// End-to-End-Tests gegen eine laufende Postgres-Instanz, die den vollen Weg
// "Mandant anlegen -> Benutzer anlegen -> anmelden" ueber die tatsaechlichen
// Produktionspakete (internal/tenant, internal/user, internal/auth) treiben,
// statt nur die Einzelpakete isoliert zu testen.
package e2e
import (
"context"
"errors"
"os"
"path/filepath"
"sort"
"strings"
"testing"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
)
// requireAdminDSN liefert TEST_ADMIN_DSN oder ueberspringt den Test - wie in
// allen bestehenden Integrationstests dieses Repos (siehe
// internal/tenant/provisioner_test.go).
func requireAdminDSN(t *testing.T) string {
t.Helper()
dsn := os.Getenv("TEST_ADMIN_DSN")
if dsn == "" {
t.Skip("TEST_ADMIN_DSN nicht gesetzt, E2E-Test uebersprungen")
}
return dsn
}
// repoRoot findet den Modul-Root ausgehend vom Testverzeichnis, damit die
// SQL-Migrationsverzeichnisse unabhaengig vom Arbeitsverzeichnis (go test
// wechselt ins Paketverzeichnis) gefunden werden.
func repoRoot(t *testing.T) string {
t.Helper()
wd, err := os.Getwd()
if err != nil {
t.Fatalf("getwd: %v", err)
}
// internal/e2e -> zwei Ebenen hoch zum Modul-Root.
return filepath.Join(wd, "..", "..")
}
// applyRegistrySchema legt die Control-Plane-Tabellen (tenants, superadmins,
// lifecycle-Spalten) frisch an, indem alle migrations/*.up.sql in
// Dateinamen-Reihenfolge angewendet werden - identisch zur echten
// Rollout-Reihenfolge, nicht eine verkuerzte Testkopie des Schemas.
func applyRegistrySchema(t *testing.T, ctx context.Context, pool *pgxpool.Pool, root string) {
t.Helper()
applyAllUpSQL(t, ctx, pool, filepath.Join(root, "migrations"))
}
// applyTenantSchema initialisiert eine frisch provisionierte Tenant-Datenbank
// mit allen migrations/tenant/*.up.sql (nicht nur 0001_users, damit
// password_hash, role_assignments etc. wie in Produktion vorhanden sind).
func applyTenantSchema(t *testing.T, ctx context.Context, pool *pgxpool.Pool, root string) {
t.Helper()
applyAllUpSQL(t, ctx, pool, filepath.Join(root, "migrations", "tenant"))
}
func applyAllUpSQL(t *testing.T, ctx context.Context, pool *pgxpool.Pool, dir string) {
t.Helper()
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatalf("migrationsverzeichnis %q lesen: %v", dir, err)
}
var names []string
for _, e := range entries {
if !e.IsDir() && strings.HasSuffix(e.Name(), ".up.sql") {
names = append(names, e.Name())
}
}
sort.Strings(names)
for _, name := range names {
sqlBytes, err := os.ReadFile(filepath.Join(dir, name))
if err != nil {
t.Fatalf("migration %q lesen: %v", name, err)
}
if _, err := pool.Exec(ctx, string(sqlBytes)); err != nil {
// Migrationen sind Produktionsartefakte ohne eingebaute
// Idempotenz (in Produktion durch eine Migrations-Tracking-
// Tabelle abgesichert, die dieser Test bewusst nicht nachbaut).
// Zwei E2E-Tests im selben Prozess teilen sich dieselbe
// Registry-Datenbank, daher ist "schon vorhanden" hier kein
// Fehler, sondern der Normalfall beim zweiten Testlauf.
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && (pgErr.Code == "42P07" || pgErr.Code == "42701" || pgErr.Code == "42710") {
continue
}
t.Fatalf("migration %q anwenden: %v", name, err)
}
}
}
// dropTenantDBs entfernt Test-Datenbanken bedingungslos, fuer Cleanup nach
// dem Test unabhaengig vom Testausgang.
func dropTenantDBs(ctx context.Context, adminPool *pgxpool.Pool, names ...string) {
for _, n := range names {
_, _ = adminPool.Exec(ctx, `DROP DATABASE IF EXISTS `+quoteIdent(n))
}
}
func quoteIdent(name string) string {
return `"` + strings.ReplaceAll(name, `"`, `""`) + `"`
}
+112
View File
@@ -0,0 +1,112 @@
package e2e
import (
"context"
"strings"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/internal/auth"
"gitea.perlbach24.de/scripte/nexarch/internal/tenant"
"gitea.perlbach24.de/scripte/nexarch/internal/user"
)
// TestE2E_TenantOnboardingLoginFlow ist der zentrale QA-02-Nachweis: der
// vollstaendige, echte Weg ueber TEN-01..07/IAM-01..14 hinweg -
//
// Mandant provisionieren (internal/tenant.Provisioner, physisch isolierte DB)
// -> Administrator anlegen (internal/user.TenantUserStore.Create +
// SetPasswordHash, wie es internal/tenant.OnboardingService fuer TEN-02
// tut, hier explizit nachgebaut um zusaetzlich ein Passwort zu setzen)
// -> Anmelden (internal/auth.LoginService.Login)
// -> Assert: gueltiges Token, richtiger Tenant.
func TestE2E_TenantOnboardingLoginFlow(t *testing.T) {
adminDSN := requireAdminDSN(t)
ctx := context.Background()
root := repoRoot(t)
adminPool, err := pgxpool.New(ctx, adminDSN)
if err != nil {
t.Fatalf("admin pool: %v", err)
}
defer adminPool.Close()
applyRegistrySchema(t, ctx, adminPool, root)
registry := tenant.NewRegistry(adminPool)
dsnTemplate := strings.Replace(adminDSN, "/postgres?", "/%s?", 1)
provisioner := tenant.NewProvisioner(adminPool, registry, dsnTemplate)
t.Cleanup(func() {
_, _ = adminPool.Exec(ctx, `DELETE FROM tenants WHERE slug = 'e2e_onboard'`)
dropTenantDBs(ctx, adminPool, "tenant_e2e_onboard")
})
// Schritt 1: Mandant provisionieren (TEN-01/TEN-02) - eigene, physisch
// getrennte Datenbank statt gemeinsamer tenant_id-Spalte.
tn, err := provisioner.Provision(ctx, "e2e_onboard", "E2E Onboarding GmbH")
if err != nil {
t.Fatalf("provision: %v", err)
}
tenantPool, err := pgxpool.New(ctx, tn.DBDSN)
if err != nil {
t.Fatalf("connect tenant db: %v", err)
}
defer tenantPool.Close()
// Schema in der frisch angelegten Tenant-Datenbank ausrollen (TEN-07-
// Migrationslogik, hier direkt statt ueber den Orchestrator, um im Test
// nicht von der Registry-Iteration abhaengig zu sein).
applyTenantSchema(t, ctx, tenantPool, root)
// Schritt 2: Benutzer anlegen (IAM-01/IAM-02) und Passwort setzen
// (IAM-03, bcrypt ueber internal/auth.HashPassword).
userStore := user.NewTenantUserStore(tenantPool)
created, err := userStore.Create(ctx, "admin@e2e_onboard.example", "E2E Admin")
if err != nil {
t.Fatalf("benutzer anlegen: %v", err)
}
hash, err := auth.HashPassword("korrekt-pferd-batterie-heftklammer")
if err != nil {
t.Fatalf("passwort hashen: %v", err)
}
if err := userStore.SetPasswordHash(ctx, created.ID, hash); err != nil {
t.Fatalf("passwort-hash setzen: %v", err)
}
// Schritt 3: Anmelden (IAM-04/IAM-05 - LoginService gegen genau diese
// Tenant-DB, kein tenant-uebergreifendes Login moeglich).
issuer := auth.NewTokenIssuer("test-signing-secret-fuer-qa-02-e2e")
login := auth.NewLoginService(userStore, issuer, tn.Slug)
token, err := login.Login(ctx, "admin@e2e_onboard.example", "korrekt-pferd-batterie-heftklammer")
if err != nil {
t.Fatalf("login: %v", err)
}
if token == "" {
t.Fatal("login lieferte leeres token")
}
// Assert: Token ist gueltig UND traegt den richtigen Tenant (Verify statt
// blindem String-Nichtleer-Check - sonst waere ein kaputtes Token auch
// "gruen").
claims, err := issuer.Verify(token)
if err != nil {
t.Fatalf("token verifizieren: %v", err)
}
if claims.TenantSlug != "e2e_onboard" {
t.Fatalf("token tenant = %q, want %q", claims.TenantSlug, "e2e_onboard")
}
if claims.UserID != created.ID {
t.Fatalf("token user id = %q, want %q", claims.UserID, created.ID)
}
// Gegenprobe: falsches Passwort scheitert weiterhin (kein False-Positive
// durch einen zu freizuegigen Testaufbau).
if _, err := login.Login(ctx, "admin@e2e_onboard.example", "falsches-passwort"); err == nil {
t.Fatal("login mit falschem passwort haette scheitern muessen")
}
}
+27
View File
@@ -0,0 +1,27 @@
// Package ldapsync implementiert Core IAM-05: LDAP/Active-Directory-
// Anbindung je Tenant fuer Benutzer-Synchronisation, Login-Delegation und
// Rollenzuordnung aus Gruppen ueber eine explizite Erlaubnis-Matrix.
//
// WICHTIGER HINWEIS: In dieser Umgebung steht kein echter LDAP/AD-Server zur
// Verfuegung (analog zur Archive-Engine bei AUD-05). Client ist deshalb eine
// Schnittstelle — die Sync-/Rollenzuordnungs-Logik ist gegen einen Fake in
// den Tests vollstaendig geprueft, eine echte Verbindungspruefung gegen
// LDAP/AD steht noch aus.
package ldapsync
// Entry ist ein aus LDAP gelesener Benutzerdatensatz.
type Entry struct {
Email string
Name string
Groups []string
Disabled bool
}
// Client abstrahiert die eigentliche LDAP-Verbindung (Bind + Search).
type Client interface {
// Search liefert alle Benutzer, die dem konfigurierten Filter
// entsprechen. Ein Fehler bedeutet "Quelle nicht erreichbar oder
// fehlerhaft" — Run() bricht dann komplett und folgenlos ab
// (Akzeptanzkriterium 2).
Search() ([]Entry, error)
}
+204
View File
@@ -0,0 +1,204 @@
package ldapsync
import (
"context"
"errors"
"fmt"
"os"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/internal/rbac"
"gitea.perlbach24.de/scripte/nexarch/internal/user"
)
type fakeClient struct {
entries []Entry
err error
}
func (f *fakeClient) Search() ([]Entry, error) {
if f.err != nil {
return nil, f.err
}
return f.entries, nil
}
func setupTest(t *testing.T) (*user.TenantUserStore, *rbac.Store, *RoleMappingStore, func()) {
t.Helper()
adminDSN := os.Getenv("TEST_ADMIN_DSN")
if adminDSN == "" {
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, adminDSN)
if err != nil {
t.Fatalf("pool: %v", err)
}
if _, err := pool.Exec(ctx, `
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), email TEXT NOT NULL UNIQUE, name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active', password_hash TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS role_assignments (
user_id UUID PRIMARY KEY REFERENCES users(id), role TEXT NOT NULL CHECK (role IN ('user','tenant_admin')),
granted_by TEXT NOT NULL, granted_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS role_assignment_history (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL, role TEXT NOT NULL,
granted_by TEXT NOT NULL, granted_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS ldap_group_role_mapping (
ldap_group TEXT PRIMARY KEY, role TEXT NOT NULL CHECK (role IN ('user','tenant_admin'))
);
`); err != nil {
t.Fatalf("schema: %v", err)
}
cleanup := func() { pool.Close() }
return user.NewTenantUserStore(pool), rbac.NewStore(pool), NewRoleMappingStore(pool), cleanup
}
func uniqueEmail() string {
return fmt.Sprintf("ldap-test-%d@example.com", time.Now().UnixNano())
}
// Akzeptanzkriterium 3 + Pruefung 1: Rollen-Erlaubnis-Matrix mit Positiv- und
// Negativfaellen — keine Privilege-Escalation ueber Gruppenmitgliedschaft.
func TestRoleMapping_PositiveAndNegativeCases(t *testing.T) {
_, _, mappings, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
if err := mappings.SetMapping(ctx, "cn=IT-Admins,ou=groups,dc=example,dc=com", rbac.RoleTenantAdmin); err != nil {
t.Fatalf("set mapping: %v", err)
}
// Positivfall: gemappte Gruppe liefert die konfigurierte Rolle.
role, ok, err := mappings.HighestRoleFor(ctx, []string{"cn=IT-Admins,ou=groups,dc=example,dc=com"})
if err != nil {
t.Fatalf("highest role: %v", err)
}
if !ok || role != rbac.RoleTenantAdmin {
t.Fatalf("erwartet tenant_admin, habe ok=%v role=%q", ok, role)
}
// Negativfall 1: unbekannte Gruppe vergibt keine Rolle.
_, ok, err = mappings.HighestRoleFor(ctx, []string{"cn=Praktikanten,ou=groups,dc=example,dc=com"})
if err != nil {
t.Fatalf("highest role (unbekannt): %v", err)
}
if ok {
t.Fatal("erwartet KEINE rolle fuer nicht gemappte gruppe")
}
// Negativfall 2 (Privilege-Escalation-Versuch): eine Gruppe, die zufaellig
// wie eine interne Rolle heisst, aber NIE explizit gemappt wurde, darf
// keine Rolle vergeben.
_, ok, err = mappings.HighestRoleFor(ctx, []string{"tenant_admin", "superadmin"})
if err != nil {
t.Fatalf("highest role (namens-trick): %v", err)
}
if ok {
t.Fatal("erwartet KEINE privilege-escalation ueber gruppennamen, die wie rollen aussehen")
}
}
// Akzeptanzkriterium 1: Synchronisationslauf legt Benutzer an.
func TestSyncer_CreatesUsers(t *testing.T) {
users, roles, mappings, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
email := uniqueEmail()
client := &fakeClient{entries: []Entry{{Email: email, Name: "LDAP Nutzer"}}}
syncer := NewSyncer(client, users, roles, mappings)
result, err := syncer.Run(ctx)
if err != nil {
t.Fatalf("run: %v", err)
}
if result.Created != 1 {
t.Fatalf("erwartet 1 angelegten benutzer, habe %d", result.Created)
}
if _, err := users.GetByEmailForAuth(ctx, email); err != nil {
t.Fatalf("benutzer sollte existieren: %v", err)
}
}
// Akzeptanzkriterium 2 + Pruefung 2: fehlerhafte/nicht erreichbare LDAP-
// Quelle bricht kontrolliert ab, ohne bestehende Konten zu beschaedigen.
func TestSyncer_AbortsCleanlyOnSourceError(t *testing.T) {
users, roles, mappings, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
// Ein bereits bestehendes Konto, das UNBERUEHRT bleiben muss.
existingEmail := uniqueEmail()
existing, err := users.Create(ctx, existingEmail, "Bestehender Nutzer")
if err != nil {
t.Fatalf("create existing: %v", err)
}
client := &fakeClient{err: errors.New("ldap-server nicht erreichbar (simuliert)")}
syncer := NewSyncer(client, users, roles, mappings)
if _, err := syncer.Run(ctx); err == nil {
t.Fatal("erwartet fehler bei nicht erreichbarer ldap-quelle")
}
// Bestehendes Konto unveraendert (noch aktiv, keine geloeschten/neuen Zeilen).
got, err := users.Get(ctx, existing.ID)
if err != nil {
t.Fatalf("bestehendes konto sollte weiterhin existieren: %v", err)
}
if got.Status != user.StatusActive {
t.Fatalf("bestehendes konto sollte unveraendert aktiv sein, ist %q", got.Status)
}
}
// Akzeptanzkriterium 3 + Pruefung 3: Deaktivierung in LDAP wird bei
// naechster Synchronisation korrekt uebernommen.
func TestSyncer_AppliesDeactivationOnNextRun(t *testing.T) {
users, roles, mappings, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
email := uniqueEmail()
client := &fakeClient{entries: []Entry{{Email: email, Name: "Wird Deaktiviert"}}}
syncer := NewSyncer(client, users, roles, mappings)
if _, err := syncer.Run(ctx); err != nil {
t.Fatalf("erster lauf: %v", err)
}
u, err := users.GetByEmailForAuth(ctx, email)
if err != nil {
t.Fatalf("benutzer nach erstem lauf: %v", err)
}
if u.User.Status != user.StatusActive {
t.Fatalf("erwartet aktiv nach erstem lauf, habe %q", u.User.Status)
}
// Zweiter Lauf: derselbe Benutzer ist jetzt in LDAP deaktiviert.
client.entries[0].Disabled = true
result, err := syncer.Run(ctx)
if err != nil {
t.Fatalf("zweiter lauf: %v", err)
}
if result.Deactivated != 1 {
t.Fatalf("erwartet 1 deaktivierung, habe %d", result.Deactivated)
}
u, err = users.GetByEmailForAuth(ctx, email)
if err != nil {
t.Fatalf("benutzer nach zweitem lauf: %v", err)
}
if u.User.Status != user.StatusInactive {
t.Fatalf("erwartet inaktiv nach deaktivierung in ldap, habe %q", u.User.Status)
}
}
+79
View File
@@ -0,0 +1,79 @@
package ldapsync
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/internal/rbac"
)
// RoleMappingStore verwaltet die Erlaubnis-Matrix LDAP-Gruppe -> Rolle
// (Akzeptanzkriterium 3). Nur explizit gemappte Gruppen vergeben eine Rolle
// — der Schutz gegen Privilege-Escalation ist strukturell: es gibt keinen
// Code-Pfad, der eine unbekannte Gruppe irgendeiner Rolle zuordnet.
type RoleMappingStore struct {
pool *pgxpool.Pool
}
func NewRoleMappingStore(pool *pgxpool.Pool) *RoleMappingStore {
return &RoleMappingStore{pool: pool}
}
// SetMapping ordnet eine LDAP-Gruppe genau einer NEXARCH-Rolle zu. Nur
// 'user'/'tenant_admin' sind zuweisbar (dieselbe Matrix wie RBAC-01/03) —
// 'superadmin' bleibt mandantenuebergreifend und nie ueber LDAP erreichbar.
func (s *RoleMappingStore) SetMapping(ctx context.Context, ldapGroup string, role rbac.Role) error {
if role != rbac.RoleUser && role != rbac.RoleTenantAdmin {
return fmt.Errorf("ldapsync: rolle %q ist ueber ldap nicht zuweisbar", role)
}
_, err := s.pool.Exec(ctx, `
INSERT INTO ldap_group_role_mapping (ldap_group, role) VALUES ($1, $2)
ON CONFLICT (ldap_group) DO UPDATE SET role = $2
`, ldapGroup, string(role))
if err != nil {
return fmt.Errorf("gruppenzuordnung speichern: %w", err)
}
return nil
}
// roleFor liefert die einer LDAP-Gruppe zugeordnete Rolle, sofern konfiguriert.
func (s *RoleMappingStore) roleFor(ctx context.Context, ldapGroup string) (rbac.Role, bool, error) {
var role string
err := s.pool.QueryRow(ctx, `SELECT role FROM ldap_group_role_mapping WHERE ldap_group = $1`, ldapGroup).Scan(&role)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return "", false, nil
}
return "", false, fmt.Errorf("gruppenzuordnung lesen: %w", err)
}
return rbac.Role(role), true, nil
}
// HighestRoleFor bestimmt aus einer Liste von LDAP-Gruppen die hoechste
// zugeordnete Rolle (tenant_admin > user). Gruppen ohne Eintrag in der
// Matrix tragen NICHTS bei — auch nicht, wenn ihr Name zufaellig wie eine
// interne Rolle aussieht (Akzeptanzkriterium 3 / Pruefung 1: keine
// Privilege-Escalation ueber Gruppenmitgliedschaft).
func (s *RoleMappingStore) HighestRoleFor(ctx context.Context, ldapGroups []string) (rbac.Role, bool, error) {
var best rbac.Role
found := false
for _, g := range ldapGroups {
role, ok, err := s.roleFor(ctx, g)
if err != nil {
return "", false, err
}
if !ok {
continue
}
if !found || role == rbac.RoleTenantAdmin {
best = role
found = true
}
}
return best, found, nil
}
+93
View File
@@ -0,0 +1,93 @@
package ldapsync
import (
"context"
"errors"
"fmt"
"gitea.perlbach24.de/scripte/nexarch/internal/rbac"
"gitea.perlbach24.de/scripte/nexarch/internal/user"
)
// SyncResult fasst das Ergebnis eines Synchronisationslaufs zusammen.
type SyncResult struct {
Created int
Updated int
Deactivated int
Failed []string // Email-Adressen, bei denen die Verarbeitung fehlschlug
}
// Syncer fuehrt die Benutzer-/Rollen-Synchronisation aus LDAP aus.
type Syncer struct {
client Client
users *user.TenantUserStore
roles *rbac.Store
mappings *RoleMappingStore
}
func NewSyncer(client Client, users *user.TenantUserStore, roles *rbac.Store, mappings *RoleMappingStore) *Syncer {
return &Syncer{client: client, users: users, roles: roles, mappings: mappings}
}
// Run synchronisiert Benutzer aus LDAP (Akzeptanzkriterium 1). Ist die
// LDAP-Quelle nicht erreichbar/fehlerhaft, wird SOFORT und OHNE jede
// Aenderung an bestehenden Konten abgebrochen (Akzeptanzkriterium 2 /
// Pruefung 2) — Search() wird als allererstes aufgerufen, bevor irgendein
// Schreibzugriff stattfindet.
func (s *Syncer) Run(ctx context.Context) (SyncResult, error) {
entries, err := s.client.Search()
if err != nil {
return SyncResult{}, fmt.Errorf("ldap-quelle nicht erreichbar, synchronisation abgebrochen: %w", err)
}
var result SyncResult
for _, entry := range entries {
if err := s.syncOne(ctx, entry, &result); err != nil {
// Ein fehlerhafter Einzeleintrag isoliert sich selbst — er
// beschaedigt weder bereits verarbeitete noch nachfolgende
// Konten (Akzeptanzkriterium 2).
result.Failed = append(result.Failed, entry.Email)
}
}
return result, nil
}
func (s *Syncer) syncOne(ctx context.Context, entry Entry, result *SyncResult) error {
existing, err := s.users.GetByEmailForAuth(ctx, entry.Email)
isNew := errors.Is(err, user.ErrNotFound)
if err != nil && !isNew {
return err
}
var userID string
if isNew {
created, err := s.users.Create(ctx, entry.Email, entry.Name)
if err != nil {
return err
}
userID = created.ID
result.Created++
} else {
userID = existing.User.ID
result.Updated++
}
// Deaktivierung in LDAP wird uebernommen (Akzeptanzkriterium 3 / Pruefung 3).
if entry.Disabled {
if _, err := s.users.Deactivate(ctx, userID); err != nil {
return err
}
result.Deactivated++
}
role, ok, err := s.mappings.HighestRoleFor(ctx, entry.Groups)
if err != nil {
return err
}
if ok {
if _, err := s.roles.Assign(ctx, userID, role, "ldap-sync"); err != nil {
return err
}
}
return nil
}
+37
View File
@@ -0,0 +1,37 @@
package lockout
import (
"context"
"errors"
"gitea.perlbach24.de/scripte/nexarch/internal/auth"
)
var ErrAccountLocked = errors.New("lockout: konto ist vorübergehend gesperrt")
// GuardedLogin komponiert IAM-02s LoginService mit dem Lockout-Zustand,
// OHNE LoginService selbst zu veraendern (kein Umbau der Vorbedingung):
// vor jedem Login-Versuch wird die Sperre geprueft, nach jedem Versuch wird
// Erfolg/Fehlschlag im geteilten Postgres-Zustand vermerkt.
func GuardedLogin(ctx context.Context, store *Store, login *auth.LoginService, email, password string) (string, error) {
locked, _, err := store.IsLocked(ctx, email)
if err != nil {
return "", err
}
if locked {
return "", ErrAccountLocked
}
token, err := login.Login(ctx, email, password)
if err != nil {
if _, _, lockErr := store.RecordFailure(ctx, email); lockErr != nil {
return "", lockErr
}
return "", err
}
if err := store.RecordSuccess(ctx, email); err != nil {
return "", err
}
return token, nil
}
+103
View File
@@ -0,0 +1,103 @@
// Package lockout implementiert Core IAM-07: Account-Lockout nach
// Fehlversuchen und Login-Rate-Limiting mit geteiltem, externem
// (Postgres-basiertem) Zustand — kein In-Process-Zaehler, der bei
// Mehrinstanzbetrieb aushebelbar waere (bekannter Fehler aus archivdms
// internal/auth/ratelimit.go).
package lockout
import (
"context"
"fmt"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
// DefaultMaxFailedAttempts/DefaultLockoutDuration sind explizit benannte
// Defaults, ueberschreibbar via WithPolicy.
const (
DefaultMaxFailedAttempts = 5
DefaultLockoutDuration = 15 * time.Minute
)
type Store struct {
pool *pgxpool.Pool
maxFailed int
lockoutDuration time.Duration
}
func NewStore(pool *pgxpool.Pool) *Store {
return &Store{pool: pool, maxFailed: DefaultMaxFailedAttempts, lockoutDuration: DefaultLockoutDuration}
}
func (s *Store) WithPolicy(maxFailed int, lockoutDuration time.Duration) *Store {
return &Store{pool: s.pool, maxFailed: maxFailed, lockoutDuration: lockoutDuration}
}
// IsLocked prueft, ob ein Konto aktuell gesperrt ist. Eine abgelaufene
// Sperre gilt automatisch als nicht mehr gesperrt (Akzeptanzkriterium 3) —
// es ist keine explizite Entsperr-Aktion noetig, der Zeitvergleich reicht.
func (s *Store) IsLocked(ctx context.Context, email string) (locked bool, lockedUntil time.Time, err error) {
err = s.pool.QueryRow(ctx, `
SELECT locked_until FROM login_attempts WHERE email = $1 AND locked_until IS NOT NULL
`, email).Scan(&lockedUntil)
if err != nil {
return false, time.Time{}, nil // kein Datensatz oder kein Lock -> nicht gesperrt
}
return time.Now().Before(lockedUntil), lockedUntil, nil
}
// RecordFailure erhoeht den Fehlversuchszaehler ATOMAR (UPSERT) und sperrt
// das Konto, sobald die Schwelle erreicht ist (Akzeptanzkriterium 1 / 2).
// Der Zustand liegt ausschliesslich in Postgres, mehrere Core-Instanzen
// teilen sich denselben Zaehler (Akzeptanzkriterium 2).
func (s *Store) RecordFailure(ctx context.Context, email string) (locked bool, lockedUntil time.Time, err error) {
var failedCount int
var lockedUntilPtr *time.Time
err = s.pool.QueryRow(ctx, `
INSERT INTO login_attempts (email, failed_count, locked_until, last_attempt_at)
VALUES ($1, 1, CASE WHEN 1 >= $2 THEN now() + $3::interval ELSE NULL END, now())
ON CONFLICT (email) DO UPDATE SET
failed_count = login_attempts.failed_count + 1,
last_attempt_at = now(),
locked_until = CASE
WHEN login_attempts.failed_count + 1 >= $2 THEN now() + $3::interval
ELSE login_attempts.locked_until
END
RETURNING failed_count, locked_until
`, email, s.maxFailed, fmt.Sprintf("%f seconds", s.lockoutDuration.Seconds())).Scan(&failedCount, &lockedUntilPtr)
if err != nil {
return false, time.Time{}, fmt.Errorf("fehlversuch erfassen: %w", err)
}
if lockedUntilPtr != nil {
return time.Now().Before(*lockedUntilPtr), *lockedUntilPtr, nil
}
return false, time.Time{}, nil
}
// RecordSuccess setzt den Fehlversuchszaehler nach erfolgreichem Login zurueck.
func (s *Store) RecordSuccess(ctx context.Context, email string) error {
_, err := s.pool.Exec(ctx, `
INSERT INTO login_attempts (email, failed_count, locked_until, last_attempt_at)
VALUES ($1, 0, NULL, now())
ON CONFLICT (email) DO UPDATE SET failed_count = 0, locked_until = NULL, last_attempt_at = now()
`, email)
if err != nil {
return fmt.Errorf("erfolgreichen login erfassen: %w", err)
}
return nil
}
// Unlock entsperrt ein Konto durch Administratoreingriff, unabhaengig von
// der Sperrzeit (Akzeptanzkriterium 3).
func (s *Store) Unlock(ctx context.Context, email string) error {
_, err := s.pool.Exec(ctx, `
UPDATE login_attempts SET failed_count = 0, locked_until = NULL WHERE email = $1
`, email)
if err != nil {
return fmt.Errorf("konto entsperren: %w", err)
}
return nil
}
+233
View File
@@ -0,0 +1,233 @@
package lockout
import (
"context"
"errors"
"fmt"
"os"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/internal/auth"
"gitea.perlbach24.de/scripte/nexarch/internal/user"
)
const schema = `
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), email TEXT NOT NULL UNIQUE, name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active', password_hash TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS login_attempts (
email TEXT PRIMARY KEY, failed_count INT NOT NULL DEFAULT 0,
locked_until TIMESTAMPTZ, last_attempt_at TIMESTAMPTZ NOT NULL DEFAULT now()
);`
func setupTest(t *testing.T) (*pgxpool.Pool, func()) {
t.Helper()
adminDSN := os.Getenv("TEST_ADMIN_DSN")
if adminDSN == "" {
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, adminDSN)
if err != nil {
t.Fatalf("pool: %v", err)
}
if _, err := pool.Exec(ctx, schema); err != nil {
t.Fatalf("schema: %v", err)
}
cleanup := func() { pool.Close() }
return pool, cleanup
}
func uniqueEmail() string {
return fmt.Sprintf("lockout-test-%d@example.com", time.Now().UnixNano())
}
// Akzeptanzkriterium 1 + Pruefung 2: Sperre greift nach definierten
// Fehlversuchen zuverlaessig.
func TestRecordFailure_LocksAfterThreshold(t *testing.T) {
pool, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
store := NewStore(pool).WithPolicy(3, time.Hour)
email := uniqueEmail()
for i := 0; i < 2; i++ {
locked, _, err := store.RecordFailure(ctx, email)
if err != nil {
t.Fatalf("record failure %d: %v", i, err)
}
if locked {
t.Fatalf("sollte nach %d fehlversuchen noch nicht gesperrt sein", i+1)
}
}
locked, lockedUntil, err := store.RecordFailure(ctx, email)
if err != nil {
t.Fatalf("record failure 3: %v", err)
}
if !locked {
t.Fatal("erwartet gesperrt nach 3 fehlversuchen (schwelle=3)")
}
if !lockedUntil.After(time.Now()) {
t.Fatal("lockedUntil sollte in der zukunft liegen")
}
isLocked, _, err := store.IsLocked(ctx, email)
if err != nil {
t.Fatalf("is locked: %v", err)
}
if !isLocked {
t.Fatal("IsLocked sollte true liefern")
}
}
// Akzeptanzkriterium 2 + Pruefung 1: zwei Dienstinstanzen teilen sich
// denselben Zaehler (Mehrinstanz-Test) — hier durch zwei unabhaengige
// pgxpool.Pool-Verbindungen zur selben Datenbank simuliert.
func TestRecordFailure_SharedAcrossInstances(t *testing.T) {
_, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
email := uniqueEmail()
adminDSN := os.Getenv("TEST_ADMIN_DSN")
instanceA, err := pgxpool.New(ctx, adminDSN)
if err != nil {
t.Fatalf("instance a: %v", err)
}
defer instanceA.Close()
instanceB, err := pgxpool.New(ctx, adminDSN)
if err != nil {
t.Fatalf("instance b: %v", err)
}
defer instanceB.Close()
storeA := NewStore(instanceA).WithPolicy(4, time.Hour)
storeB := NewStore(instanceB).WithPolicy(4, time.Hour)
// Zwei fehlversuche ueber "instanz A", zwei ueber "instanz B" — beide
// muessen sich denselben Zaehler teilen und gemeinsam die Schwelle erreichen.
if _, _, err := storeA.RecordFailure(ctx, email); err != nil {
t.Fatalf("a1: %v", err)
}
if _, _, err := storeB.RecordFailure(ctx, email); err != nil {
t.Fatalf("b1: %v", err)
}
if _, _, err := storeA.RecordFailure(ctx, email); err != nil {
t.Fatalf("a2: %v", err)
}
locked, _, err := storeB.RecordFailure(ctx, email)
if err != nil {
t.Fatalf("b2: %v", err)
}
if !locked {
t.Fatal("erwartet gesperrt nach insgesamt 4 fehlversuchen ueber beide instanzen hinweg")
}
}
// Akzeptanzkriterium 3 + Pruefung 3: automatische Entsperrung nach Ablauf
// der Sperrzeit, Zeitversatz automatisiert getestet.
func TestIsLocked_AutoUnlocksAfterExpiry(t *testing.T) {
pool, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
store := NewStore(pool).WithPolicy(1, 50*time.Millisecond)
email := uniqueEmail()
locked, _, err := store.RecordFailure(ctx, email)
if err != nil {
t.Fatalf("record failure: %v", err)
}
if !locked {
t.Fatal("erwartet sofort gesperrt bei schwelle=1")
}
isLocked, _, err := store.IsLocked(ctx, email)
if err != nil {
t.Fatalf("is locked (vor ablauf): %v", err)
}
if !isLocked {
t.Fatal("erwartet gesperrt vor ablauf der sperrzeit")
}
time.Sleep(70 * time.Millisecond)
isLocked, _, err = store.IsLocked(ctx, email)
if err != nil {
t.Fatalf("is locked (nach ablauf): %v", err)
}
if isLocked {
t.Fatal("erwartet automatisch entsperrt nach ablauf der sperrzeit")
}
}
// Akzeptanzkriterium 3: Entsperrung durch Administratoreingriff.
func TestUnlock_ClearsLockImmediately(t *testing.T) {
pool, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
store := NewStore(pool).WithPolicy(1, time.Hour)
email := uniqueEmail()
if _, _, err := store.RecordFailure(ctx, email); err != nil {
t.Fatalf("record failure: %v", err)
}
locked, _, _ := store.IsLocked(ctx, email)
if !locked {
t.Fatal("erwartet gesperrt vor admin-eingriff")
}
if err := store.Unlock(ctx, email); err != nil {
t.Fatalf("unlock: %v", err)
}
locked, _, err := store.IsLocked(ctx, email)
if err != nil {
t.Fatalf("is locked nach unlock: %v", err)
}
if locked {
t.Fatal("erwartet sofort entsperrt nach admin-eingriff, unabhaengig von der sperrzeit")
}
}
// Integrationstest: GuardedLogin komponiert IAM-02 LoginService mit dem
// Lockout-Zustand, ohne LoginService selbst zu veraendern.
func TestGuardedLogin_LocksAfterRepeatedFailures(t *testing.T) {
pool, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
email := uniqueEmail()
userStore := user.NewTenantUserStore(pool)
u, err := userStore.Create(ctx, email, "Lockout Test")
if err != nil {
t.Fatalf("create user: %v", err)
}
hash, err := auth.HashPassword("korrektes-passwort")
if err != nil {
t.Fatalf("hash: %v", err)
}
if err := userStore.SetPasswordHash(ctx, u.ID, hash); err != nil {
t.Fatalf("set password: %v", err)
}
issuer := auth.NewTokenIssuer("test-secret-nur-fuer-tests")
loginService := auth.NewLoginService(userStore, issuer, "acme")
lockoutStore := NewStore(pool).WithPolicy(3, time.Hour)
for i := 0; i < 3; i++ {
if _, err := GuardedLogin(ctx, lockoutStore, loginService, email, "falsches-passwort"); !errors.Is(err, auth.ErrInvalidCredentials) {
t.Fatalf("versuch %d: erwartet ErrInvalidCredentials, habe %v", i, err)
}
}
// Selbst das RICHTIGE Passwort wird jetzt abgewiesen, weil das Konto gesperrt ist.
if _, err := GuardedLogin(ctx, lockoutStore, loginService, email, "korrektes-passwort"); !errors.Is(err, ErrAccountLocked) {
t.Fatalf("erwartet ErrAccountLocked trotz korrektem passwort, habe %v", err)
}
}
+211
View File
@@ -0,0 +1,211 @@
// Package migrate implementiert Core TEN-07: das automatisierte, pro Tenant
// fehlerisolierte Ausrollen von SQL-Migrationen ueber alle registrierten
// Mandanten-Datenbanken (Modell C — jede Migration muss N-mal statt einmal
// laufen, siehe TEN-01).
package migrate
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/internal/tenant"
)
const upSuffix = ".up.sql"
type Migration struct {
Version string
SQL string
}
// LoadMigrations liest alle *.up.sql-Dateien aus dir und sortiert sie nach
// Dateiname (die bestehende Namenskonvention 0001_..., 0002_... aus TEN-01
// sorgt fuer eine stabile Reihenfolge).
func LoadMigrations(dir string) ([]Migration, error) {
entries, err := os.ReadDir(dir)
if err != nil {
return nil, fmt.Errorf("migrationsverzeichnis lesen: %w", err)
}
var names []string
for _, e := range entries {
if !e.IsDir() && strings.HasSuffix(e.Name(), upSuffix) {
names = append(names, e.Name())
}
}
sort.Strings(names)
migrations := make([]Migration, 0, len(names))
for _, name := range names {
content, err := os.ReadFile(filepath.Join(dir, name))
if err != nil {
return nil, fmt.Errorf("migration %q lesen: %w", name, err)
}
version := strings.TrimSuffix(name, upSuffix)
migrations = append(migrations, Migration{Version: version, SQL: string(content)})
}
return migrations, nil
}
// MigrationStatus ist der pro Tenant und Version nachvollziehbare Stand
// (Akzeptanzkriterium 3): welche Version, wann zuletzt versucht, Erfolg oder
// Fehler.
type MigrationStatus struct {
Version string
AppliedAt time.Time
Success bool
Error string
}
// TenantResult fasst das Ergebnis eines Rollout-Versuchs fuer EINEN Tenant
// zusammen — wird von Orchestrator.RolloutAll pro Tenant gesammelt, damit ein
// Fehlschlag bei einem Mandanten die anderen nicht blockiert (Akzeptanzkriterium 2).
type TenantResult struct {
TenantSlug string
Applied []string // erfolgreich in diesem Lauf angewendete Versionen
FailedAt string // Version, bei der abgebrochen wurde; leer wenn kein Fehlschlag
Err error
}
// Orchestrator rollt Migrationen ueber alle in der Registry gefuehrten
// Mandanten aus. Baut bewusst NICHT auf TEN-06 (Router) auf — Migrations-
// Rollouts sind seltene Batch-Vorgaenge, kein Hot-Path, ein kurzlebiger Pool
// pro Tenant und Lauf ist hier einfacher und unabhaengig von TEN-06 testbar.
type Orchestrator struct {
registry *tenant.Registry
}
func NewOrchestrator(registry *tenant.Registry) *Orchestrator {
return &Orchestrator{registry: registry}
}
const ensureTableSQL = `
CREATE TABLE IF NOT EXISTS schema_migrations (
version TEXT PRIMARY KEY,
applied_at TIMESTAMPTZ NOT NULL DEFAULT now(),
success BOOLEAN NOT NULL,
error TEXT
);`
// RolloutAll wendet migrations auf JEDE registrierte Tenant-Datenbank an.
// Jeder Tenant laeuft unabhaengig — ein Fehlschlag bei einem Mandanten wird
// im jeweiligen TenantResult festgehalten und blockiert die uebrigen nicht
// (Akzeptanzkriterium 1 + 2).
func (o *Orchestrator) RolloutAll(ctx context.Context, migrations []Migration) ([]TenantResult, error) {
tenants, err := o.registry.List(ctx)
if err != nil {
return nil, fmt.Errorf("tenants fuer rollout auflisten: %w", err)
}
results := make([]TenantResult, 0, len(tenants))
for _, t := range tenants {
results = append(results, o.rolloutForTenant(ctx, t, migrations))
}
return results, nil
}
func (o *Orchestrator) rolloutForTenant(ctx context.Context, t tenant.Tenant, migrations []Migration) TenantResult {
result := TenantResult{TenantSlug: t.Slug}
pool, err := pgxpool.New(ctx, t.DBDSN)
if err != nil {
result.Err = fmt.Errorf("verbindung zu tenant %q: %w", t.Slug, err)
return result
}
defer pool.Close()
if _, err := pool.Exec(ctx, ensureTableSQL); err != nil {
result.Err = fmt.Errorf("schema_migrations anlegen fuer tenant %q: %w", t.Slug, err)
return result
}
for _, m := range migrations {
alreadyApplied, err := isAlreadySuccessful(ctx, pool, m.Version)
if err != nil {
result.Err = fmt.Errorf("migrationsstand lesen fuer tenant %q, version %q: %w", t.Slug, m.Version, err)
return result
}
if alreadyApplied {
continue // Akzeptanzkriterium 3 / Pruefung 3: keine erneute Anwendung.
}
_, execErr := pool.Exec(ctx, m.SQL)
if execErr != nil {
recordAttempt(ctx, pool, m.Version, false, execErr.Error())
result.FailedAt = m.Version
result.Err = fmt.Errorf("migration %q fuer tenant %q fehlgeschlagen: %w", m.Version, t.Slug, execErr)
return result // spaetere Migrationen bauen typischerweise auf dieser auf — Abbruch NUR fuer diesen Tenant.
}
recordAttempt(ctx, pool, m.Version, true, "")
result.Applied = append(result.Applied, m.Version)
}
return result
}
func isAlreadySuccessful(ctx context.Context, pool *pgxpool.Pool, version string) (bool, error) {
var success bool
err := pool.QueryRow(ctx, `SELECT success FROM schema_migrations WHERE version = $1`, version).Scan(&success)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return false, nil
}
return false, err
}
return success, nil
}
func recordAttempt(ctx context.Context, pool *pgxpool.Pool, version string, success bool, errMsg string) {
var errVal *string
if errMsg != "" {
errVal = &errMsg
}
_, _ = pool.Exec(ctx, `
INSERT INTO schema_migrations (version, applied_at, success, error)
VALUES ($1, now(), $2, $3)
ON CONFLICT (version) DO UPDATE SET applied_at = now(), success = $2, error = $3
`, version, success, errVal)
}
// Status liefert den Migrationsstand eines einzelnen Tenants (Akzeptanzkriterium 3).
func (o *Orchestrator) Status(ctx context.Context, tenantSlug string) ([]MigrationStatus, error) {
t, err := o.registry.GetBySlug(ctx, tenantSlug)
if err != nil {
return nil, fmt.Errorf("tenant %q nicht gefunden: %w", tenantSlug, err)
}
pool, err := pgxpool.New(ctx, t.DBDSN)
if err != nil {
return nil, fmt.Errorf("verbindung zu tenant %q: %w", tenantSlug, err)
}
defer pool.Close()
rows, err := pool.Query(ctx, `
SELECT version, applied_at, success, COALESCE(error, '')
FROM schema_migrations ORDER BY version
`)
if err != nil {
return nil, fmt.Errorf("migrationsstand abfragen: %w", err)
}
defer rows.Close()
var out []MigrationStatus
for rows.Next() {
var s MigrationStatus
if err := rows.Scan(&s.Version, &s.AppliedAt, &s.Success, &s.Error); err != nil {
return nil, fmt.Errorf("migrationsstand lesen: %w", err)
}
out = append(out, s)
}
return out, rows.Err()
}
+238
View File
@@ -0,0 +1,238 @@
package migrate
import (
"context"
"fmt"
"os"
"strings"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/internal/tenant"
)
func TestLoadMigrations_SortsByFilename(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "0002_second.up.sql", "SELECT 2;")
writeFile(t, dir, "0001_first.up.sql", "SELECT 1;")
writeFile(t, dir, "0001_first.down.sql", "SELECT 'ignored';") // muss ignoriert werden
migrations, err := LoadMigrations(dir)
if err != nil {
t.Fatalf("load: %v", err)
}
if len(migrations) != 2 {
t.Fatalf("erwartet 2 migrationen, habe %d", len(migrations))
}
if migrations[0].Version != "0001_first" || migrations[1].Version != "0002_second" {
t.Fatalf("unerwartete reihenfolge: %+v", migrations)
}
}
func writeFile(t *testing.T, dir, name, content string) {
t.Helper()
if err := os.WriteFile(dir+"/"+name, []byte(content), 0o644); err != nil {
t.Fatalf("write %s: %v", name, err)
}
}
func setupOrchestratorTest(t *testing.T, tenantCount int) (*Orchestrator, []tenant.Tenant, *pgxpool.Pool, func()) {
t.Helper()
adminDSN := os.Getenv("TEST_ADMIN_DSN")
if adminDSN == "" {
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
}
ctx := context.Background()
adminPool, err := pgxpool.New(ctx, adminDSN)
if err != nil {
t.Fatalf("admin pool: %v", err)
}
registryPool, err := pgxpool.New(ctx, adminDSN)
if err != nil {
t.Fatalf("registry pool: %v", err)
}
if _, err := registryPool.Exec(ctx, `
CREATE TABLE IF NOT EXISTS tenants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
slug TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
db_name TEXT NOT NULL UNIQUE,
db_dsn TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)`); err != nil {
t.Fatalf("registry-schema: %v", err)
}
registry := tenant.NewRegistry(registryPool)
dsnTemplate := strings.Replace(adminDSN, "/postgres?", "/%s?", 1)
provisioner := tenant.NewProvisioner(adminPool, registry, dsnTemplate)
var tenants []tenant.Tenant
var slugs []string
for i := 0; i < tenantCount; i++ {
slug := fmt.Sprintf("mig_t%d", i)
slugs = append(slugs, slug)
tn, err := provisioner.Provision(ctx, slug, slug)
if err != nil {
t.Fatalf("provision %s: %v", slug, err)
}
tenants = append(tenants, tn)
}
orchestrator := NewOrchestrator(registry)
cleanup := func() {
for _, slug := range slugs {
_, _ = adminPool.Exec(ctx, fmt.Sprintf(`DROP DATABASE IF EXISTS %q`, "tenant_"+slug))
}
_, _ = registryPool.Exec(ctx, `DELETE FROM tenants WHERE slug = ANY($1)`, slugs)
registryPool.Close()
adminPool.Close()
}
return orchestrator, tenants, adminPool, cleanup
}
// Akzeptanzkriterium 1 + 2 + Pruefung 1: Rollout gegen mehrere Tenants, einer
// davon absichtlich inkompatibel — die anderen laufen trotzdem durch.
func TestRolloutAll_IsolatesFailurePerTenant(t *testing.T) {
orchestrator, tenants, _, cleanup := setupOrchestratorTest(t, 3)
defer cleanup()
ctx := context.Background()
migrations := []Migration{
{Version: "0001_demo_a", SQL: "CREATE TABLE demo_a (id INT);"},
{Version: "0002_demo_b", SQL: "CREATE TABLE demo_b (id INT);"},
}
// tenants[1] absichtlich inkompatibel machen: demo_b existiert schon,
// migration 0002 schlaegt dort mit "already exists" fehl.
badTenant := tenants[1]
badPool, err := pgxpool.New(ctx, badTenant.DBDSN)
if err != nil {
t.Fatalf("connect bad tenant: %v", err)
}
if _, err := badPool.Exec(ctx, "CREATE TABLE demo_b (id INT);"); err != nil {
t.Fatalf("inkompatiblen zustand vorbereiten: %v", err)
}
badPool.Close()
results, err := orchestrator.RolloutAll(ctx, migrations)
if err != nil {
t.Fatalf("rollout: %v", err)
}
if len(results) != 3 {
t.Fatalf("erwartet 3 ergebnisse, habe %d", len(results))
}
byslug := map[string]TenantResult{}
for _, r := range results {
byslug[r.TenantSlug] = r
}
good0 := byslug[tenants[0].Slug]
if good0.Err != nil || len(good0.Applied) != 2 {
t.Fatalf("tenant[0] sollte beide migrationen erhalten, habe %+v", good0)
}
good2 := byslug[tenants[2].Slug]
if good2.Err != nil || len(good2.Applied) != 2 {
t.Fatalf("tenant[2] sollte beide migrationen erhalten, habe %+v", good2)
}
bad := byslug[badTenant.Slug]
if bad.Err == nil {
t.Fatal("erwartet fehler fuer den inkompatiblen tenant")
}
if bad.FailedAt != "0002_demo_b" {
t.Fatalf("failedAt = %q, want 0002_demo_b", bad.FailedAt)
}
if len(bad.Applied) != 1 || bad.Applied[0] != "0001_demo_a" {
t.Fatalf("erwartet dass 0001_demo_a trotzdem erfolgreich war, habe %+v", bad.Applied)
}
}
// Akzeptanzkriterium 3 + Pruefung 2: Migrationsstand-Abfrage liefert fuer
// jeden Tenant den korrekten, unabhaengigen Stand.
func TestStatus_ReflectsPerTenantState(t *testing.T) {
orchestrator, tenants, _, cleanup := setupOrchestratorTest(t, 1)
defer cleanup()
ctx := context.Background()
migrations := []Migration{
{Version: "0001_ok", SQL: "CREATE TABLE ok_table (id INT);"},
{Version: "0002_fail", SQL: "SELECT this_column_does_not_exist FROM ok_table;"},
}
if _, err := orchestrator.RolloutAll(ctx, migrations); err != nil {
t.Fatalf("rollout: %v", err)
}
status, err := orchestrator.Status(ctx, tenants[0].Slug)
if err != nil {
t.Fatalf("status: %v", err)
}
if len(status) != 2 {
t.Fatalf("erwartet 2 status-eintraege, habe %d", len(status))
}
if !status[0].Success || status[0].Version != "0001_ok" {
t.Fatalf("status[0] unerwartet: %+v", status[0])
}
if status[1].Success || status[1].Version != "0002_fail" || status[1].Error == "" {
t.Fatalf("status[1] sollte fehlgeschlagen sein mit fehlertext: %+v", status[1])
}
}
// Pruefung 3: wiederholter Rollout-Versuch wendet bereits erfolgreiche
// Migrationen NICHT erneut an und kann die zuvor fehlgeschlagene nachholen,
// sobald die Ursache behoben ist.
func TestRolloutAll_RetryDoesNotReapplySuccessful(t *testing.T) {
orchestrator, tenants, _, cleanup := setupOrchestratorTest(t, 1)
defer cleanup()
ctx := context.Background()
// 0002 schlaegt beim ersten Versuch fehl, weil demo_conflict schon
// existiert (wir legen sie vorher an, um den Fehlschlag zu erzwingen).
pool, err := pgxpool.New(ctx, tenants[0].DBDSN)
if err != nil {
t.Fatalf("connect: %v", err)
}
if _, err := pool.Exec(ctx, "CREATE TABLE demo_conflict (id INT);"); err != nil {
t.Fatalf("vorbedingung: %v", err)
}
migrations := []Migration{
{Version: "0001_ok", SQL: "CREATE TABLE demo_first (id INT);"}, // OHNE IF NOT EXISTS,
// damit ein erneutes Anwenden nachweislich fehlschlagen wuerde.
{Version: "0002_conflict", SQL: "CREATE TABLE demo_conflict (id INT);"},
}
firstRun, err := orchestrator.RolloutAll(ctx, migrations)
if err != nil {
t.Fatalf("rollout 1: %v", err)
}
if firstRun[0].FailedAt != "0002_conflict" {
t.Fatalf("erwartet fehlschlag bei 0002_conflict im ersten lauf, habe %+v", firstRun[0])
}
// Ursache beheben.
if _, err := pool.Exec(ctx, "DROP TABLE demo_conflict;"); err != nil {
t.Fatalf("ursache beheben: %v", err)
}
pool.Close()
secondRun, err := orchestrator.RolloutAll(ctx, migrations)
if err != nil {
t.Fatalf("rollout 2: %v", err)
}
// Waere 0001_ok erneut angewendet worden ("CREATE TABLE demo_first" ohne
// IF NOT EXISTS), haette das einen Fehler erzeugt statt eines sauberen
// Applied-Eintrags fuer 0002_conflict.
if secondRun[0].Err != nil {
t.Fatalf("zweiter lauf sollte fehlerfrei sein, habe %+v", secondRun[0])
}
if len(secondRun[0].Applied) != 1 || secondRun[0].Applied[0] != "0002_conflict" {
t.Fatalf("erwartet nur 0002_conflict im zweiten lauf angewendet, habe %+v", secondRun[0].Applied)
}
}
+86
View File
@@ -0,0 +1,86 @@
package moduletrust
import (
"context"
"fmt"
"sync"
"time"
)
// StaleCache ist der generische Rechte-/Feature-Flag-Cache-Kontrakt
// (Akzeptanzkriterium 2): TTL-basiert, mit explizitem, benanntem Verhalten
// bei abgelaufenem Cache waehrend Core nicht erreichbar ist.
//
// - Get: FAIL-OPEN fuer Lesevorgaenge. Schlaegt der Refresh fehl, aber es
// gibt bereits einen (wenn auch abgelaufenen) Stand, wird dieser mit
// stale=true zurueckgegeben — Begruendung: ein bereits authentifiziertes
// Modul soll mit dem letztbekannten Stand weiterarbeiten koennen statt
// hart zu blockieren (siehe "Bekannte Fehler vermeiden" im Ticket).
// Existiert noch nie ein Stand, gibt es keinen sinnvollen Fallback —
// dann liefert auch Get einen Fehler.
// - RequireFresh: FAIL-CLOSED fuer sicherheitskritische Aktionen (z.B.
// ein komplett NEUER Login). Nutzt NIEMALS einen zwischengespeicherten
// Stand, ruft immer frisch ab — Begruendung: eine neue Vertrauens-
// entscheidung darf nicht auf veralteten Daten beruhen, auch wenn das
// bedeutet, dass die Aktion bei Core-Ausfall sichtbar fehlschlaegt statt
// unsicher "irgendwie" durchgelassen zu werden.
//
// LIC-02 (internal/flag.Service) implementiert bereits denselben Kontrakt
// fuer Feature-Flags — StaleCache verallgemeinert dasselbe Muster fuer
// JWT-Signaturschluessel, damit beide Faelle derselben dokumentierten
// Policy folgen.
type StaleCache[T any] struct {
mu sync.RWMutex
value T
hasValue bool
fetchedAt time.Time
ttl time.Duration
fetch func(ctx context.Context) (T, error)
}
func NewStaleCache[T any](ttl time.Duration, fetch func(ctx context.Context) (T, error)) *StaleCache[T] {
return &StaleCache[T]{ttl: ttl, fetch: fetch}
}
// Get liefert den Cache-Wert. FAIL-OPEN: bei Refresh-Fehler wird ein
// vorhandener, ggf. abgelaufener Stand zurueckgegeben (stale=true).
func (c *StaleCache[T]) Get(ctx context.Context) (value T, stale bool, err error) {
c.mu.RLock()
fresh := c.hasValue && time.Since(c.fetchedAt) < c.ttl
if fresh {
v := c.value
c.mu.RUnlock()
return v, false, nil
}
c.mu.RUnlock()
newVal, fetchErr := c.fetch(ctx)
if fetchErr == nil {
c.mu.Lock()
c.value, c.hasValue, c.fetchedAt = newVal, true, time.Now()
c.mu.Unlock()
return newVal, false, nil
}
c.mu.RLock()
defer c.mu.RUnlock()
if c.hasValue {
return c.value, true, nil
}
var zero T
return zero, false, fmt.Errorf("cache leer und refresh fehlgeschlagen: %w", fetchErr)
}
// RequireFresh ruft IMMER frisch ab (FAIL-CLOSED) — fuer sicherheitskritische
// Aktionen, die niemals auf einem zwischengespeicherten Stand basieren duerfen.
func (c *StaleCache[T]) RequireFresh(ctx context.Context) (T, error) {
v, err := c.fetch(ctx)
if err != nil {
var zero T
return zero, fmt.Errorf("core nicht erreichbar, sicherheitskritische aktion abgelehnt: %w", err)
}
c.mu.Lock()
c.value, c.hasValue, c.fetchedAt = v, true, time.Now()
c.mu.Unlock()
return v, nil
}
+79
View File
@@ -0,0 +1,79 @@
package moduletrust
import (
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"time"
"github.com/golang-jwt/jwt/v5"
)
type Claims struct {
Subject string `json:"sub"`
TenantSlug string `json:"tenant"`
jwt.RegisteredClaims
}
// Issue signiert ein Token mit dem aktuellen Signierschluessel und traegt
// dessen KID im JWT-Header ein — der Verifier auf Modulseite waehlt darueber
// den passenden oeffentlichen Schluessel aus PublicKeySet() aus.
func (m *KeyManager) Issue(subject, tenantSlug string, ttl time.Duration) (string, error) {
key, err := m.SigningKey()
if err != nil {
return "", err
}
now := time.Now()
claims := Claims{
Subject: subject,
TenantSlug: tenantSlug,
RegisteredClaims: jwt.RegisteredClaims{
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(now.Add(ttl)),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodEdDSA, claims)
token.Header["kid"] = key.KID
return token.SignedString(key.Private)
}
type jwksResponse struct {
Keys []jwksKey `json:"keys"`
}
type jwksKey struct {
Kid string `json:"kid"`
PublicKey string `json:"public_key"` // base64 (raw Ed25519, 32 Byte)
}
// ServeJWKS liefert alle bekannten oeffentlichen Schluessel als JSON —
// Module fragen dies periodisch ab (nicht pro Request), siehe Verifier.
func (m *KeyManager) ServeJWKS(w http.ResponseWriter, r *http.Request) {
set := m.PublicKeySet()
resp := jwksResponse{Keys: make([]jwksKey, 0, len(set))}
for kid, pub := range set {
resp.Keys = append(resp.Keys, jwksKey{Kid: kid, PublicKey: base64.StdEncoding.EncodeToString(pub)})
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(resp)
}
// ParseJWKS dekodiert die JSON-Antwort von ServeJWKS zurueck in kid->PublicKey
// — Hilfsfunktion fuer Module, die JWKS per HTTP abrufen.
func ParseJWKS(data []byte) (map[string][]byte, error) {
var resp jwksResponse
if err := json.Unmarshal(data, &resp); err != nil {
return nil, fmt.Errorf("jwks parsen: %w", err)
}
out := make(map[string][]byte, len(resp.Keys))
for _, k := range resp.Keys {
raw, err := base64.StdEncoding.DecodeString(k.PublicKey)
if err != nil {
return nil, fmt.Errorf("oeffentlichen schluessel %q dekodieren: %w", k.Kid, err)
}
out[k.Kid] = raw
}
return out, nil
}
+83
View File
@@ -0,0 +1,83 @@
// Package moduletrust implementiert Core API-05: asymmetrische JWT-Signatur
// mit Schluesselverteilung (JWKS), damit DMS/Mail/Archive/Workflow JWTs
// LOKAL verifizieren koennen, ohne pro Aufruf einen synchronen Request an
// Core zu stellen — Core darf Fundament sein, ohne zum Flaschenhals zu
// werden (siehe Entscheidungsverlauf "Vertrauensstellung Core<->Module" in
// nexarch-state.json). IAM-02s HS256-Session-Cookie (Browser-Login) bleibt
// unangetastet — dies ist ein zusaetzlicher, getrennter Vertrauensmechanismus
// fuer Modul-zu-Modul/Modul-zu-Core-Aufrufe.
package moduletrust
import (
"crypto/ed25519"
"crypto/rand"
"encoding/hex"
"fmt"
"sync"
)
type KeyPair struct {
KID string
Private ed25519.PrivateKey
Public ed25519.PublicKey
}
// KeyManager haelt ALLE noch gueltigen Schluesselpaare — nicht nur das
// aktuell signierende. Rotate erzeugt ein neues Paar und behaelt die alten
// fuer die Verifikation bereits ausgestellter Tokens (Akzeptanzkriterium 3:
// Rotation ohne Ausfallzeit fuer andere Module).
type KeyManager struct {
mu sync.RWMutex
keys []KeyPair // aeltestes zuerst, neuestes zuletzt
}
func NewKeyManager() (*KeyManager, error) {
m := &KeyManager{}
if _, err := m.Rotate(); err != nil {
return nil, err
}
return m, nil
}
// Rotate erzeugt ein neues Ed25519-Schluesselpaar mit eigener KID und macht
// es zum aktuellen Signierschluessel. Aeltere Schluessel bleiben in
// PublicKeySet() erhalten, damit bereits ausgestellte Tokens weiterhin
// verifizierbar sind.
func (m *KeyManager) Rotate() (KeyPair, error) {
pub, priv, err := ed25519.GenerateKey(nil)
if err != nil {
return KeyPair{}, fmt.Errorf("schluesselpaar erzeugen: %w", err)
}
kidBytes := make([]byte, 8)
if _, err := rand.Read(kidBytes); err != nil {
return KeyPair{}, fmt.Errorf("kid erzeugen: %w", err)
}
kp := KeyPair{KID: hex.EncodeToString(kidBytes), Private: priv, Public: pub}
m.mu.Lock()
m.keys = append(m.keys, kp)
m.mu.Unlock()
return kp, nil
}
// SigningKey liefert den aktuellen (neuesten) Schluessel zum Signieren neuer Tokens.
func (m *KeyManager) SigningKey() (KeyPair, error) {
m.mu.RLock()
defer m.mu.RUnlock()
if len(m.keys) == 0 {
return KeyPair{}, fmt.Errorf("moduletrust: kein schluessel vorhanden")
}
return m.keys[len(m.keys)-1], nil
}
// PublicKeySet liefert ALLE bekannten oeffentlichen Schluessel (kid ->
// public key) — die Grundlage fuer den JWKS-Endpunkt.
func (m *KeyManager) PublicKeySet() map[string]ed25519.PublicKey {
m.mu.RLock()
defer m.mu.RUnlock()
out := make(map[string]ed25519.PublicKey, len(m.keys))
for _, k := range m.keys {
out[k.KID] = k.Public
}
return out
}
+214
View File
@@ -0,0 +1,214 @@
package moduletrust
import (
"context"
"crypto/ed25519"
"errors"
"net/http"
"sync"
"testing"
"time"
)
func TestIssueAndVerify_RoundTrip(t *testing.T) {
km, err := NewKeyManager()
if err != nil {
t.Fatalf("new key manager: %v", err)
}
token, err := km.Issue("user-1", "acme", time.Hour)
if err != nil {
t.Fatalf("issue: %v", err)
}
v := NewVerifier(time.Hour, func(ctx context.Context) (map[string]ed25519.PublicKey, error) {
return km.PublicKeySet(), nil
})
claims, err := v.Verify(context.Background(), token)
if err != nil {
t.Fatalf("verify: %v", err)
}
if claims.Subject != "user-1" || claims.TenantSlug != "acme" {
t.Fatalf("claims unerwartet: %+v", claims)
}
}
// Akzeptanzkriterium 1: Verifikation lokal, kein Request pro Aufruf.
func TestVerify_DoesNotFetchPerCall(t *testing.T) {
km, err := NewKeyManager()
if err != nil {
t.Fatalf("new key manager: %v", err)
}
token, err := km.Issue("user-1", "acme", time.Hour)
if err != nil {
t.Fatalf("issue: %v", err)
}
var mu sync.Mutex
fetchCalls := 0
v := NewVerifier(time.Hour, func(ctx context.Context) (map[string]ed25519.PublicKey, error) {
mu.Lock()
fetchCalls++
mu.Unlock()
return km.PublicKeySet(), nil
})
for i := 0; i < 10; i++ {
if _, err := v.Verify(context.Background(), token); err != nil {
t.Fatalf("verify %d: %v", i, err)
}
}
mu.Lock()
defer mu.Unlock()
if fetchCalls != 1 {
t.Fatalf("erwartet genau 1 fetch fuer 10 Verify-Aufrufe innerhalb der TTL, habe %d", fetchCalls)
}
}
// Akzeptanzkriterium 2 + Pruefung 1: Core simuliert nicht erreichbar,
// bereits authentifizierte Nutzer bleiben funktionsfaehig (Fail-Open mit
// letztbekanntem Schluesselstand).
func TestVerify_FailsOpenWhenCoreUnreachableButStaleKeysExist(t *testing.T) {
km, err := NewKeyManager()
if err != nil {
t.Fatalf("new key manager: %v", err)
}
token, err := km.Issue("user-1", "acme", time.Hour)
if err != nil {
t.Fatalf("issue: %v", err)
}
coreDown := false
v := NewVerifier(30*time.Millisecond, func(ctx context.Context) (map[string]ed25519.PublicKey, error) {
if coreDown {
return nil, errors.New("core nicht erreichbar (simuliert)")
}
return km.PublicKeySet(), nil
})
// Cache vorwaermen, waehrend Core noch erreichbar ist.
if _, err := v.Verify(context.Background(), token); err != nil {
t.Fatalf("verify (warm): %v", err)
}
// "Core abschalten" und TTL ablaufen lassen.
coreDown = true
time.Sleep(50 * time.Millisecond)
if _, err := v.Verify(context.Background(), token); err != nil {
t.Fatalf("verify sollte trotz core-ausfall mit letztbekanntem stand funktionieren: %v", err)
}
}
// Akzeptanzkriterium 2 + Pruefung 2: neue sicherheitskritische Aktionen
// (z.B. neuer Login) schlagen bei Core-Ausfall klar fehl statt unsicher
// durchgelassen zu werden — auch wenn ein (aelterer) Cache-Stand existiert.
func TestRequireFreshKeys_FailsClosedWhenCoreUnreachable(t *testing.T) {
km, err := NewKeyManager()
if err != nil {
t.Fatalf("new key manager: %v", err)
}
coreDown := false
v := NewVerifier(time.Hour, func(ctx context.Context) (map[string]ed25519.PublicKey, error) {
if coreDown {
return nil, errors.New("core nicht erreichbar (simuliert)")
}
return km.PublicKeySet(), nil
})
// Cache vorwaermen (existiert jetzt ein "veralteter" gueltiger Stand).
if _, _, err := v.cache.Get(context.Background()); err != nil {
t.Fatalf("warm cache: %v", err)
}
coreDown = true
if err := v.RequireFreshKeys(context.Background()); err == nil {
t.Fatal("erwartet fehler (fail-closed) bei core-ausfall, habe nil")
}
}
// Akzeptanzkriterium 3 + Pruefung 3: Schluesselrotation ohne Ausfallzeit —
// ein bereits ausgestelltes Token bleibt nach Rotation weiterhin
// verifizierbar, ein zweites (simuliertes) Modul bekommt beide Schluessel.
func TestRotate_NoDowntimeForAlreadyIssuedTokens(t *testing.T) {
km, err := NewKeyManager()
if err != nil {
t.Fatalf("new key manager: %v", err)
}
oldToken, err := km.Issue("user-1", "acme", time.Hour)
if err != nil {
t.Fatalf("issue (alt): %v", err)
}
if _, err := km.Rotate(); err != nil {
t.Fatalf("rotate: %v", err)
}
newToken, err := km.Issue("user-2", "acme", time.Hour)
if err != nil {
t.Fatalf("issue (neu): %v", err)
}
// Simuliertes zweites Modul: fragt den vollstaendigen Schluesselsatz ab.
moduleB := NewVerifier(time.Hour, func(ctx context.Context) (map[string]ed25519.PublicKey, error) {
return km.PublicKeySet(), nil
})
if _, err := moduleB.Verify(context.Background(), oldToken); err != nil {
t.Fatalf("altes token sollte nach rotation weiterhin gueltig sein: %v", err)
}
if _, err := moduleB.Verify(context.Background(), newToken); err != nil {
t.Fatalf("neues token sollte gueltig sein: %v", err)
}
}
func TestVerify_RejectsUnknownKid(t *testing.T) {
km1, _ := NewKeyManager()
km2, _ := NewKeyManager() // komplett anderer, unbekannter schluessel
token, err := km1.Issue("user-1", "acme", time.Hour)
if err != nil {
t.Fatalf("issue: %v", err)
}
v := NewVerifier(time.Hour, func(ctx context.Context) (map[string]ed25519.PublicKey, error) {
return km2.PublicKeySet(), nil // kennt km1s schluessel nicht
})
if _, err := v.Verify(context.Background(), token); !errors.Is(err, ErrInvalidToken) {
t.Fatalf("erwartet ErrInvalidToken, habe %v", err)
}
}
func TestJWKSRoundTrip(t *testing.T) {
km, _ := NewKeyManager()
km.Rotate()
var buf []byte
rec := &captureWriter{}
km.ServeJWKS(rec, nil)
buf = rec.body
parsed, err := ParseJWKS(buf)
if err != nil {
t.Fatalf("parse jwks: %v", err)
}
if len(parsed) != len(km.PublicKeySet()) {
t.Fatalf("erwartet %d schluessel, habe %d", len(km.PublicKeySet()), len(parsed))
}
}
type captureWriter struct {
body []byte
header http.Header
}
func (w *captureWriter) Header() http.Header {
if w.header == nil {
w.header = http.Header{}
}
return w.header
}
func (w *captureWriter) Write(p []byte) (int, error) { w.body = append(w.body, p...); return len(p), nil }
func (w *captureWriter) WriteHeader(statusCode int) {}
+67
View File
@@ -0,0 +1,67 @@
package moduletrust
import (
"context"
"crypto/ed25519"
"errors"
"time"
"github.com/golang-jwt/jwt/v5"
)
var ErrInvalidToken = errors.New("moduletrust: ungueltiges token")
// KeyFetchFunc holt den aktuellen Schluesselsatz von Core (z.B. per HTTP-GET
// auf ServeJWKS + ParseJWKS). Wird vom Verifier nur bei abgelaufener TTL
// aufgerufen — NICHT bei jeder Verify()-Anfrage (Akzeptanzkriterium 1).
type KeyFetchFunc func(ctx context.Context) (map[string]ed25519.PublicKey, error)
// Verifier ist die Modulseite von API-05: verifiziert JWTs LOKAL gegen einen
// per StaleCache zwischengespeicherten Schluesselsatz, ohne pro Aufruf einen
// synchronen Request an Core zu stellen.
type Verifier struct {
cache *StaleCache[map[string]ed25519.PublicKey]
}
func NewVerifier(ttl time.Duration, fetch KeyFetchFunc) *Verifier {
return &Verifier{cache: NewStaleCache(ttl, func(ctx context.Context) (map[string]ed25519.PublicKey, error) {
return fetch(ctx)
})}
}
// Verify prueft die Signatur LOKAL gegen den (ggf. abgelaufenen, aber
// vorhandenen) Schluesselsatz — FAIL-OPEN fuer bereits ausgestellte Tokens
// (Akzeptanzkriterium 2): ist Core nicht erreichbar, aber ein alter
// Schluesselsatz bekannt, wird damit weiter verifiziert.
func (v *Verifier) Verify(ctx context.Context, tokenString string) (*Claims, error) {
keys, _, err := v.cache.Get(ctx)
if err != nil {
return nil, err
}
claims := &Claims{}
token, err := jwt.ParseWithClaims(tokenString, claims, func(t *jwt.Token) (interface{}, error) {
if _, ok := t.Method.(*jwt.SigningMethodEd25519); !ok {
return nil, ErrInvalidToken
}
kid, _ := t.Header["kid"].(string)
pub, ok := keys[kid]
if !ok {
return nil, ErrInvalidToken
}
return pub, nil
})
if err != nil || !token.Valid {
return nil, ErrInvalidToken
}
return claims, nil
}
// RequireFreshKeys ruft IMMER frisch von Core ab (FAIL-CLOSED) — fuer
// sicherheitskritische Aktionen wie einen komplett neuen Login
// (Akzeptanzkriterium 2): schlaegt klar fehl, wenn Core nicht erreichbar
// ist, statt auf einem veralteten Schluesselsatz zu vertrauen.
func (v *Verifier) RequireFreshKeys(ctx context.Context) error {
_, err := v.cache.RequireFresh(ctx)
return err
}
+91
View File
@@ -0,0 +1,91 @@
package oidc
import (
"context"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// AuthCodeTTL folgt RFC 6749 Empfehlung: Authorization Codes sind extrem
// kurzlebig, der Client tauscht sie sofort gegen ein Token.
const AuthCodeTTL = 60 * time.Second
var ErrInvalidAuthCode = errors.New("oidc: ungueltiger, abgelaufener oder bereits verwendeter authorization code")
// AuthCodeData ist das Ergebnis eines eingeloesten Authorization Codes.
type AuthCodeData struct {
ClientID string
UserID string
RedirectURI string
Scopes []string
}
// AuthCodeStore verwaltet Authorization Codes innerhalb GENAU EINER Tenant-Datenbank.
type AuthCodeStore struct {
pool *pgxpool.Pool
}
func NewAuthCodeStore(pool *pgxpool.Pool) *AuthCodeStore {
return &AuthCodeStore{pool: pool}
}
// Issue erzeugt einen neuen Authorization Code (Akzeptanzkriterium 2, Schritt
// 1 des Flows) — gespeichert wird nur der Hash, das Klartext-Code wird per
// Redirect an den Client uebertragen (Standard-OAuth2-Verhalten, der Code
// selbst ist einmalig und kurzlebig genug, dass die Redirect-URL kein
// nennenswertes Risiko darstellt).
func (s *AuthCodeStore) Issue(ctx context.Context, clientID, userID, redirectURI string, scopes []string) (code string, err error) {
code, err = randomToken(32)
if err != nil {
return "", fmt.Errorf("code erzeugen: %w", err)
}
hash := hashSecret(code)
expiresAt := time.Now().Add(AuthCodeTTL)
_, err = s.pool.Exec(ctx, `
INSERT INTO oidc_auth_codes (code_hash, client_id, user_id, redirect_uri, scopes, expires_at)
VALUES ($1, $2, $3, $4, $5, $6)
`, hash, clientID, userID, redirectURI, scopes, expiresAt)
if err != nil {
return "", fmt.Errorf("code speichern: %w", err)
}
return code, nil
}
// Consume loest einen Authorization Code genau einmal ein (Akzeptanzkriterium
// 2, Schritt 2 des Flows) — atomar ueber die WHERE-Klausel (used_at IS NULL
// AND expires_at > now()), gleiches Muster wie internal/authtoken (IAM-03).
func (s *AuthCodeStore) Consume(ctx context.Context, code, clientID, redirectURI string) (AuthCodeData, error) {
hash := hashSecret(code)
var data AuthCodeData
var storedClientID, storedRedirectURI string
row := s.pool.QueryRow(ctx, `
UPDATE oidc_auth_codes
SET used_at = now()
WHERE code_hash = $1 AND used_at IS NULL AND expires_at > now()
RETURNING client_id, user_id, redirect_uri, scopes
`, hash)
if err := row.Scan(&storedClientID, &data.UserID, &storedRedirectURI, &data.Scopes); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return AuthCodeData{}, ErrInvalidAuthCode
}
return AuthCodeData{}, fmt.Errorf("code einloesen: %w", err)
}
// client_id und redirect_uri muessen exakt zu denen des urspruenglichen
// Authorize-Aufrufs passen (RFC 6749 4.1.3) — sonst koennte ein Code, der
// fuer Client A ausgestellt wurde, bei Client B eingeloest werden.
if storedClientID != clientID || storedRedirectURI != redirectURI {
return AuthCodeData{}, ErrInvalidAuthCode
}
data.ClientID = storedClientID
data.RedirectURI = storedRedirectURI
return data, nil
}
+167
View File
@@ -0,0 +1,167 @@
// Package oidc implementiert Core IAM-13: NEXARCH als OIDC/OAuth2-Provider,
// damit Drittanwendungen eines Mandanten sich gegen NEXARCH-Benutzerkonten
// authentifizieren koennen. Baut bewusst auf der bestehenden JWKS-
// Infrastruktur (internal/moduletrust, API-05) auf statt ein zweites
// Schluesselsystem zu bauen — siehe provider.go.
package oidc
import (
"context"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
var (
ErrClientNotFound = errors.New("oidc: client nicht gefunden")
ErrInvalidClientAuth = errors.New("oidc: ungueltige client-authentifizierung")
ErrRedirectURINotAllowed = errors.New("oidc: redirect_uri ist fuer diesen client nicht registriert")
)
// Client ist eine bei NEXARCH registrierte Drittanwendung
// (Akzeptanzkriterium 1). AllowedScopes begrenzt, welche Scopes ein Token
// fuer diesen Client tragen darf — unabhaengig davon, was beim Authorize-
// Aufruf angefragt wird (Akzeptanzkriterium 3 / Pruefung 3).
type Client struct {
ID string
Name string
ClientID string
RedirectURIs []string
AllowedScopes []string
}
// ClientStore verwaltet OAuth2-Clients innerhalb GENAU EINER Tenant-
// Datenbank (gleiches Muster wie internal/user.TenantUserStore, IAM-03).
type ClientStore struct {
pool *pgxpool.Pool
}
func NewClientStore(pool *pgxpool.Pool) *ClientStore {
return &ClientStore{pool: pool}
}
// Register legt einen neuen OAuth2-Client an (Akzeptanzkriterium 1). Das
// Client-Secret wird NUR hier im Klartext zurueckgegeben, gespeichert wird
// ausschliesslich dessen SHA-256-Hash — gleiches Muster wie
// internal/moduleregistry.Provision (API-02).
func (s *ClientStore) Register(ctx context.Context, name string, redirectURIs, allowedScopes []string) (clientID, clientSecret string, err error) {
if name == "" {
return "", "", errors.New("oidc: name darf nicht leer sein")
}
if len(redirectURIs) == 0 {
return "", "", errors.New("oidc: mindestens eine redirect_uri ist erforderlich")
}
clientID, err = randomToken(16)
if err != nil {
return "", "", fmt.Errorf("client-id erzeugen: %w", err)
}
clientSecret, err = randomToken(32)
if err != nil {
return "", "", fmt.Errorf("client-secret erzeugen: %w", err)
}
hash := hashSecret(clientSecret)
_, err = s.pool.Exec(ctx, `
INSERT INTO oidc_clients (name, client_id, secret_hash, redirect_uris, allowed_scopes)
VALUES ($1, $2, $3, $4, $5)
`, name, clientID, hash, redirectURIs, allowedScopes)
if err != nil {
return "", "", fmt.Errorf("client speichern: %w", err)
}
return clientID, clientSecret, nil
}
func (s *ClientStore) getByClientID(ctx context.Context, clientID string) (Client, []byte, error) {
var c Client
var storedHash []byte
row := s.pool.QueryRow(ctx, `
SELECT id, name, client_id, redirect_uris, allowed_scopes, secret_hash
FROM oidc_clients WHERE client_id = $1
`, clientID)
if err := row.Scan(&c.ID, &c.Name, &c.ClientID, &c.RedirectURIs, &c.AllowedScopes, &storedHash); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return Client{}, nil, ErrClientNotFound
}
return Client{}, nil, fmt.Errorf("client laden: %w", err)
}
return c, storedHash, nil
}
// Get laedt einen Client ohne Secret-Pruefung (fuer den Authorize-Schritt,
// der kein Client-Secret kennt — der Browser des Endnutzers, nicht die
// Drittanwendung selbst, ruft /oidc/authorize auf).
func (s *ClientStore) Get(ctx context.Context, clientID string) (Client, error) {
c, _, err := s.getByClientID(ctx, clientID)
return c, err
}
// Authenticate prueft Client-ID + Secret timing-safe (Akzeptanzkriterium 2,
// Token-Endpunkt) — gleiches Muster wie internal/moduleregistry.Authenticate (API-02).
func (s *ClientStore) Authenticate(ctx context.Context, clientID, clientSecret string) (Client, error) {
c, storedHash, err := s.getByClientID(ctx, clientID)
if err != nil {
return Client{}, err
}
if !timingSafeEqual(hashSecret(clientSecret), storedHash) {
return Client{}, ErrInvalidClientAuth
}
return c, nil
}
// ValidateRedirectURI erzwingt exakten Abgleich gegen die registrierte
// Liste (kein Praefix-/Wildcard-Match) — Standardhaertung gegen Open-Redirect
// im Authorization-Code-Flow.
func (c Client) ValidateRedirectURI(redirectURI string) error {
for _, allowed := range c.RedirectURIs {
if allowed == redirectURI {
return nil
}
}
return ErrRedirectURINotAllowed
}
// GrantedScopes schraenkt die angefragten Scopes auf das ein, was fuer den
// Client tatsaechlich erlaubt ist (Akzeptanzkriterium 3) — nie mehr
// gewaehren, als bei der Registrierung zugestanden wurde.
func (c Client) GrantedScopes(requested []string) []string {
allowed := make(map[string]bool, len(c.AllowedScopes))
for _, s := range c.AllowedScopes {
allowed[s] = true
}
var granted []string
for _, s := range requested {
if allowed[s] {
granted = append(granted, s)
}
}
return granted
}
func randomToken(n int) (string, error) {
buf := make([]byte, n)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return hex.EncodeToString(buf), nil
}
func hashSecret(secret string) []byte {
sum := sha256.Sum256([]byte(secret))
return sum[:]
}
// timingSafeEqual folgt demselben Referenzmuster wie internal/moduleregistry
// (API-02) — projektweite Konvention aus SICHERHEITSKONZEPT.md / IAM-15.
func timingSafeEqual(a, b []byte) bool {
if len(a) != len(b) {
return false
}
return subtle.ConstantTimeCompare(a, b) == 1
}
+78
View File
@@ -0,0 +1,78 @@
// Package oidc implementiert Core IAM-06: SSO-Anmeldung ueber OpenID
// Connect gegen externe Identity-Provider.
//
// WICHTIGER HINWEIS: In dieser Umgebung ist kein registrierter externer
// OIDC-Provider (Client-ID/Secret bei Google/Okta/Azure AD o.ae.) verfuegbar,
// gegen den ein echter Authorization-Code-Redirect-Flow end-to-end
// durchgefuehrt werden koennte (analog AUD-05/Archive, IAM-05/LDAP). ANDERS
// als bei LDAP/Archive ist die eigentliche Token-VALIDIERUNG (JWKS-
// Signaturpruefung, Ablauf, Claims, Nonce/State) aber ein rein
// kryptographischer Vorgang ohne Netzwerkabhaengigkeit zur Testzeit — die
// Tests unten erzeugen ein eigenes RSA-Schluesselpaar, signieren ein
// ID-Token selbst und pruefen es exakt so, wie es ein echter Provider
// liefern wuerde. Nur der Redirect zu einem echten externen Provider bleibt
// ungeprueft.
package oidc
import (
"crypto/rsa"
"encoding/base64"
"encoding/binary"
"encoding/json"
"errors"
"fmt"
"math/big"
)
type jwk struct {
Kid string `json:"kid"`
Kty string `json:"kty"`
N string `json:"n"`
E string `json:"e"`
}
type jwkSet struct {
Keys []jwk `json:"keys"`
}
var ErrUnsupportedKeyType = errors.New("oidc: nicht unterstuetzter jwk-schluesseltyp")
// ParseJWKS dekodiert eine JWKS-Antwort (RFC 7517) in kid -> RSA-Public-Key.
// Nur RSA-Schluessel (kty=RSA, Standard bei allen gaengigen OIDC-Providern
// fuer ID-Tokens) werden unterstuetzt.
func ParseJWKS(data []byte) (map[string]*rsa.PublicKey, error) {
var set jwkSet
if err := json.Unmarshal(data, &set); err != nil {
return nil, fmt.Errorf("jwks parsen: %w", err)
}
out := make(map[string]*rsa.PublicKey, len(set.Keys))
for _, k := range set.Keys {
if k.Kty != "RSA" {
continue
}
pub, err := decodeRSAKey(k)
if err != nil {
return nil, fmt.Errorf("schluessel %q dekodieren: %w", k.Kid, err)
}
out[k.Kid] = pub
}
return out, nil
}
func decodeRSAKey(k jwk) (*rsa.PublicKey, error) {
nBytes, err := base64.RawURLEncoding.DecodeString(k.N)
if err != nil {
return nil, fmt.Errorf("modulus dekodieren: %w", err)
}
eBytes, err := base64.RawURLEncoding.DecodeString(k.E)
if err != nil {
return nil, fmt.Errorf("exponent dekodieren: %w", err)
}
eBuf := make([]byte, 8)
copy(eBuf[8-len(eBytes):], eBytes)
e := int(binary.BigEndian.Uint64(eBuf))
return &rsa.PublicKey{N: new(big.Int).SetBytes(nBytes), E: e}, nil
}
+69
View File
@@ -0,0 +1,69 @@
package oidc
import (
"context"
"errors"
"fmt"
"gitea.perlbach24.de/scripte/nexarch/internal/auth"
"gitea.perlbach24.de/scripte/nexarch/internal/ldapsync"
"gitea.perlbach24.de/scripte/nexarch/internal/rbac"
"gitea.perlbach24.de/scripte/nexarch/internal/user"
)
// CompleteOIDCLogin verifiziert das ID-Token (Signatur, Ablauf, Aussteller,
// Nonce — Akzeptanzkriterium 3), loest den State einmalig ein
// (Akzeptanzkriterium/Pruefung 3), legt bei Bedarf einen lokalen Benutzer an
// und mapped Rollen aus den OIDC-Claims ueber DIESELBE Erlaubnis-Matrix wie
// IAM-05/LDAP (ldapsync.RoleMappingStore — kein zweiter, paralleler
// Mapping-Mechanismus, Akzeptanzkriterium 2). Bei Erfolg wird ein normales
// IAM-02-Sitzungs-Token ausgestellt.
func CompleteOIDCLogin(
ctx context.Context,
verifier *Verifier,
states *StateStore,
users *user.TenantUserStore,
roles *rbac.Store,
roleMapping *ldapsync.RoleMappingStore,
issuer *auth.TokenIssuer,
tenantSlug, state, idToken string,
) (token string, err error) {
nonce, err := states.Consume(ctx, state)
if err != nil {
return "", err
}
claims, err := verifier.VerifyWithNonce(idToken, nonce)
if err != nil {
return "", err
}
if claims.Email == "" {
return "", fmt.Errorf("%w: id-token ohne e-mail-claim", ErrInvalidToken)
}
u, err := findOrCreateUser(ctx, users, claims.Email)
if err != nil {
return "", err
}
if role, ok, err := roleMapping.HighestRoleFor(ctx, claims.Roles); err != nil {
return "", err
} else if ok {
if _, err := roles.Assign(ctx, u.ID, role, "oidc-sso"); err != nil {
return "", err
}
}
return issuer.Issue(u.ID, tenantSlug)
}
func findOrCreateUser(ctx context.Context, users *user.TenantUserStore, email string) (user.User, error) {
creds, err := users.GetByEmailForAuth(ctx, email)
if err == nil {
return creds.User, nil
}
if !errors.Is(err, user.ErrNotFound) {
return user.User{}, err
}
return users.Create(ctx, email, email)
}
+172
View File
@@ -0,0 +1,172 @@
package oidc
import (
"context"
"crypto/rsa"
"fmt"
"os"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/golang-jwt/jwt/v5"
"gitea.perlbach24.de/scripte/nexarch/internal/auth"
"gitea.perlbach24.de/scripte/nexarch/internal/ldapsync"
"gitea.perlbach24.de/scripte/nexarch/internal/rbac"
"gitea.perlbach24.de/scripte/nexarch/internal/user"
)
func setupLoginTest(t *testing.T) (*pgxpool.Pool, func()) {
t.Helper()
adminDSN := os.Getenv("TEST_ADMIN_DSN")
if adminDSN == "" {
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, adminDSN)
if err != nil {
t.Fatalf("pool: %v", err)
}
if _, err := pool.Exec(ctx, `
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), email TEXT NOT NULL UNIQUE, name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active', password_hash TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS role_assignments (
user_id UUID PRIMARY KEY REFERENCES users(id), role TEXT NOT NULL CHECK (role IN ('user','tenant_admin')),
granted_by TEXT NOT NULL, granted_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS role_assignment_history (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL, role TEXT NOT NULL,
granted_by TEXT NOT NULL, granted_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS ldap_group_role_mapping (
ldap_group TEXT PRIMARY KEY, role TEXT NOT NULL CHECK (role IN ('user','tenant_admin'))
);
CREATE TABLE IF NOT EXISTS oidc_states (
state TEXT PRIMARY KEY, nonce TEXT NOT NULL, expires_at TIMESTAMPTZ NOT NULL,
used_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
`); err != nil {
t.Fatalf("schema: %v", err)
}
cleanup := func() { pool.Close() }
return pool, cleanup
}
// Akzeptanzkriterium 1 + 3: End-to-End-Login gegen ein (selbst signiertes,
// aber protokollkorrektes) ID-Token — Signatur, Nonce, State, Rollen-Mapping,
// Sitzungs-Token-Ausstellung.
func TestCompleteOIDCLogin_EndToEnd(t *testing.T) {
pool, cleanup := setupLoginTest(t)
defer cleanup()
ctx := context.Background()
key := generateTestKey(t)
verifier := NewVerifier(map[string]*rsa.PublicKey{"kid-1": &key.PublicKey}, "https://idp.example.com")
states := NewStateStore(pool)
users := user.NewTenantUserStore(pool)
roles := rbac.NewStore(pool)
roleMapping := ldapsync.NewRoleMappingStore(pool)
issuer := auth.NewTokenIssuer("test-secret-nur-fuer-tests")
if err := roleMapping.SetMapping(ctx, "sso-admins", rbac.RoleTenantAdmin); err != nil {
t.Fatalf("set mapping: %v", err)
}
state, nonce, err := states.Generate(ctx)
if err != nil {
t.Fatalf("generate state: %v", err)
}
email := fmt.Sprintf("oidc-test-%d@example.com", time.Now().UnixNano())
claims := Claims{
Subject: "idp-subject-123",
Email: email,
Nonce: nonce,
Roles: []string{"sso-admins"},
RegisteredClaims: jwt.RegisteredClaims{
Issuer: "https://idp.example.com",
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
},
}
idToken := signTestToken(t, key, "kid-1", claims)
token, err := CompleteOIDCLogin(ctx, verifier, states, users, roles, roleMapping, issuer, "acme", state, idToken)
if err != nil {
t.Fatalf("complete oidc login: %v", err)
}
if token == "" {
t.Fatal("erwartet nicht-leeres sitzungs-token")
}
assignment, err := roles.Get(ctx, mustGetUserID(t, ctx, users, email))
if err != nil {
t.Fatalf("rollenzuweisung lesen: %v", err)
}
if assignment.Role != rbac.RoleTenantAdmin {
t.Fatalf("erwartet tenant_admin aus sso-admins-mapping, habe %q", assignment.Role)
}
// Replay desselben state schlaegt fehl.
if _, err := CompleteOIDCLogin(ctx, verifier, states, users, roles, roleMapping, issuer, "acme", state, idToken); err == nil {
t.Fatal("erwartet fehler bei wiederverwendetem state (replay)")
}
}
// Akzeptanzkriterium 2 + Pruefung 1: keine Privilege-Escalation ueber
// manipulierte/unbekannte Rollen-Claims.
func TestCompleteOIDCLogin_UnmappedRoleGrantsNothing(t *testing.T) {
pool, cleanup := setupLoginTest(t)
defer cleanup()
ctx := context.Background()
key := generateTestKey(t)
verifier := NewVerifier(map[string]*rsa.PublicKey{"kid-1": &key.PublicKey}, "https://idp.example.com")
states := NewStateStore(pool)
users := user.NewTenantUserStore(pool)
roles := rbac.NewStore(pool)
roleMapping := ldapsync.NewRoleMappingStore(pool)
issuer := auth.NewTokenIssuer("test-secret-nur-fuer-tests")
state, nonce, err := states.Generate(ctx)
if err != nil {
t.Fatalf("generate state: %v", err)
}
email := fmt.Sprintf("oidc-test-%d@example.com", time.Now().UnixNano())
claims := Claims{
Email: email,
Nonce: nonce,
// Versuch, sich ueber einen manipulierten Rollen-Claim
// hochzustufen — diese Gruppen wurden NIE gemappt.
Roles: []string{"tenant_admin", "superadmin"},
RegisteredClaims: jwt.RegisteredClaims{
Issuer: "https://idp.example.com",
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
},
}
idToken := signTestToken(t, key, "kid-1", claims)
if _, err := CompleteOIDCLogin(ctx, verifier, states, users, roles, roleMapping, issuer, "acme", state, idToken); err != nil {
t.Fatalf("complete oidc login: %v", err)
}
userID := mustGetUserID(t, ctx, users, email)
if _, err := roles.Get(ctx, userID); err == nil {
t.Fatal("erwartet KEINE rollenzuweisung fuer nicht gemappte claims (privilege-escalation-versuch)")
}
}
func mustGetUserID(t *testing.T, ctx context.Context, users *user.TenantUserStore, email string) string {
t.Helper()
creds, err := users.GetByEmailForAuth(ctx, email)
if err != nil {
t.Fatalf("get user by email: %v", err)
}
return creds.User.ID
}
+256
View File
@@ -0,0 +1,256 @@
package oidc
import (
"encoding/base64"
"encoding/json"
"net/http"
"net/url"
"strings"
"time"
"github.com/golang-jwt/jwt/v5"
"gitea.perlbach24.de/scripte/nexarch/internal/auth"
"gitea.perlbach24.de/scripte/nexarch/internal/moduletrust"
)
// IDTokenTTL: kurzlebig wie ein normales Access-Token (Produkt-DNA "so
// vertrauenswuerdig wie noetig"), ein Refresh-Flow ist ausdruecklich nicht
// Teil dieser Kachel (kleinste Loesung, die die Akzeptanzkriterien erfuellt).
const IDTokenTTL = 15 * time.Minute
// IDTokenClaims sind die vom Client verifizierbaren Angaben ueber den
// angemeldeten Benutzer — signiert mit demselben Schluesselmaterial wie
// Modul-zu-Modul-Tokens (internal/moduletrust, API-05), aber mit eigener,
// OIDC-spezifischer Struktur (aud, scope), da moduletrust.Claims dafuer
// nicht vorgesehen ist. Kein zweites Schluesselsystem — nur eine zweite
// Claims-Form desselben Signierschluessels.
type IDTokenClaims struct {
Subject string `json:"sub"`
TenantSlug string `json:"tenant"`
Scopes []string `json:"scope"`
jwt.RegisteredClaims
}
// Handler buendelt die drei OIDC-Endpunkte. issuerURL identifiziert NEXARCH
// als Aussteller (iss-Claim, Standard-OIDC-Feld).
type Handler struct {
clients *ClientStore
authCodes *AuthCodeStore
keys *moduletrust.KeyManager
issuerURL string
}
func NewHandler(clients *ClientStore, authCodes *AuthCodeStore, keys *moduletrust.KeyManager, issuerURL string) *Handler {
return &Handler{clients: clients, authCodes: authCodes, keys: keys, issuerURL: issuerURL}
}
type registerClientRequest struct {
Name string `json:"name"`
RedirectURIs []string `json:"redirect_uris"`
AllowedScopes []string `json:"allowed_scopes"`
}
type registerClientResponse struct {
ClientID string `json:"client_id"`
ClientSecret string `json:"client_secret"`
}
// RegisterClient ist Akzeptanzkriterium 1: eine Drittanwendung wird als
// OAuth2-Client registriert. Muss hinter auth.RequireAuth haengen — nur
// angemeldete Benutzer des jeweiligen Mandanten duerfen Clients fuer ihren
// eigenen Mandanten anlegen (ClientStore ist bereits auf die Tenant-DB des
// Aufrufers gebunden, siehe NewHandler-Aufstellung im jeweiligen Modul-Server).
func (h *Handler) RegisterClient(w http.ResponseWriter, r *http.Request) {
if _, ok := auth.ClaimsFromContext(r.Context()); !ok {
http.Error(w, "nicht angemeldet", http.StatusUnauthorized)
return
}
var req registerClientRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "ungueltiger request-body", http.StatusBadRequest)
return
}
clientID, clientSecret, err := h.clients.Register(r.Context(), req.Name, req.RedirectURIs, req.AllowedScopes)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(registerClientResponse{ClientID: clientID, ClientSecret: clientSecret})
}
// Authorize ist Schritt 1 des Authorization-Code-Flows (Akzeptanzkriterium 2).
// Muss hinter auth.RequireAuth haengen — der aufrufende Browser ist bereits
// als NEXARCH-Benutzer angemeldet, hier wird nur noch der Client geprueft
// und ein Code fuer GENAU DIESEN Benutzer ausgestellt.
func (h *Handler) Authorize(w http.ResponseWriter, r *http.Request) {
claims, ok := auth.ClaimsFromContext(r.Context())
if !ok {
http.Error(w, "nicht angemeldet", http.StatusUnauthorized)
return
}
q := r.URL.Query()
if q.Get("response_type") != "code" {
http.Error(w, "nur response_type=code wird unterstuetzt", http.StatusBadRequest)
return
}
clientID := q.Get("client_id")
redirectURI := q.Get("redirect_uri")
state := q.Get("state")
requestedScopes := splitScopes(q.Get("scope"))
client, err := h.clients.Get(r.Context(), clientID)
if err != nil {
http.Error(w, "unbekannter client", http.StatusBadRequest)
return
}
if err := client.ValidateRedirectURI(redirectURI); err != nil {
// Bewusst KEIN Redirect zu einer nicht registrierten redirect_uri —
// Open-Redirect-Schutz geht vor Komfort, Fehler wird direkt angezeigt.
http.Error(w, "redirect_uri ist fuer diesen client nicht registriert", http.StatusBadRequest)
return
}
granted := client.GrantedScopes(requestedScopes)
code, err := h.authCodes.Issue(r.Context(), client.ClientID, claims.UserID, redirectURI, granted)
if err != nil {
http.Error(w, "code konnte nicht ausgestellt werden", http.StatusInternalServerError)
return
}
target, err := url.Parse(redirectURI)
if err != nil {
http.Error(w, "ungueltige redirect_uri", http.StatusBadRequest)
return
}
values := target.Query()
values.Set("code", code)
if state != "" {
values.Set("state", state)
}
target.RawQuery = values.Encode()
http.Redirect(w, r, target.String(), http.StatusFound)
}
// Token ist Schritt 2 des Authorization-Code-Flows (Akzeptanzkriterium 2):
// die Drittanwendung tauscht den Code serverseitig gegen ein ID-Token.
func (h *Handler) Token(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "ungueltiger request-body", http.StatusBadRequest)
return
}
if r.PostForm.Get("grant_type") != "authorization_code" {
http.Error(w, "nur grant_type=authorization_code wird unterstuetzt", http.StatusBadRequest)
return
}
clientID := r.PostForm.Get("client_id")
clientSecret := r.PostForm.Get("client_secret")
code := r.PostForm.Get("code")
redirectURI := r.PostForm.Get("redirect_uri")
client, err := h.clients.Authenticate(r.Context(), clientID, clientSecret)
if err != nil {
http.Error(w, "client-authentifizierung fehlgeschlagen", http.StatusUnauthorized)
return
}
data, err := h.authCodes.Consume(r.Context(), code, client.ClientID, redirectURI)
if err != nil {
http.Error(w, "authorization code ungueltig, abgelaufen oder bereits verwendet", http.StatusBadRequest)
return
}
idToken, err := h.issueIDToken(data)
if err != nil {
http.Error(w, "token konnte nicht ausgestellt werden", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"id_token": idToken,
"access_token": idToken, // kein separates Access-Token-Format in dieser Kachel — das ID-Token traegt bereits Subject+Scopes.
"token_type": "Bearer",
"expires_in": int(IDTokenTTL.Seconds()),
"scope": strings.Join(data.Scopes, " "),
})
}
func (h *Handler) issueIDToken(data AuthCodeData) (string, error) {
key, err := h.keys.SigningKey()
if err != nil {
return "", err
}
now := time.Now()
claims := IDTokenClaims{
Subject: data.UserID,
Scopes: data.Scopes,
RegisteredClaims: jwt.RegisteredClaims{
Issuer: h.issuerURL,
Audience: jwt.ClaimStrings{data.ClientID},
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(now.Add(IDTokenTTL)),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodEdDSA, claims)
token.Header["kid"] = key.KID
return token.SignedString(key.Private)
}
// idpJWK / idpJWKSet folgen RFC 8037 (Ed25519 als OKP-Schluesseltyp) — im
// Unterschied zu moduletrust.ServeJWKS (internes, vereinfachtes Format fuer
// Modul-zu-Modul-Kommunikation) muss dieser Endpunkt von generischen,
// standardkonformen OIDC-Client-Bibliotheken Dritter lesbar sein
// (Akzeptanzkriterium 3). Beide Endpunkte liefern dieselben Schluessel aus
// demselben KeyManager — kein zweites Schluesselsystem, nur ein zweites,
// spezifikationskonformes Format desselben Materials.
//
// Eigener Typname (statt jwk/jwkSet wie in jwks.go), da jwks.go das RSA-
// basierte JWKS-Format fuer die Verifikation EXTERNER OIDC-Provider
// implementiert (IAM-12) — dieser Typ hier ist Core als eigener IdP
// (IAM-13, Ed25519/OKP) und darf nicht verwechselt werden.
type idpJWK struct {
Kty string `json:"kty"`
Crv string `json:"crv"`
Kid string `json:"kid"`
X string `json:"x"`
Use string `json:"use"`
Alg string `json:"alg"`
}
type idpJWKSet struct {
Keys []idpJWK `json:"keys"`
}
func (h *Handler) JWKS(w http.ResponseWriter, r *http.Request) {
set := h.keys.PublicKeySet()
resp := idpJWKSet{Keys: make([]idpJWK, 0, len(set))}
for kid, pub := range set {
resp.Keys = append(resp.Keys, idpJWK{
Kty: "OKP",
Crv: "Ed25519",
Kid: kid,
X: base64.RawURLEncoding.EncodeToString(pub),
Use: "sig",
Alg: "EdDSA",
})
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(resp)
}
func splitScopes(raw string) []string {
if raw == "" {
return nil
}
return strings.Fields(raw)
}
+294
View File
@@ -0,0 +1,294 @@
package oidc
import (
"context"
"crypto/ed25519"
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"os"
"strings"
"testing"
"github.com/golang-jwt/jwt/v5"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/internal/auth"
"gitea.perlbach24.de/scripte/nexarch/internal/moduletrust"
)
// newProviderTestSetup baut Tenant-DB-Schema (users, oidc_clients,
// oidc_auth_codes) direkt gegen TEST_ADMIN_DSN auf, analog zum Muster in
// internal/tenant/lifecycle_test.go — braucht kein volles Tenant-Provisioning,
// nur eine isolierte Datenbank fuer diesen Testlauf.
func newProviderTestSetup(t *testing.T) (*Handler, *pgxpool.Pool, func()) {
t.Helper()
adminDSN := os.Getenv("TEST_ADMIN_DSN")
if adminDSN == "" {
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, adminDSN)
if err != nil {
t.Fatalf("pool: %v", err)
}
if _, err := pool.Exec(ctx, `
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS oidc_clients (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
client_id TEXT NOT NULL UNIQUE,
secret_hash BYTEA NOT NULL,
redirect_uris TEXT[] NOT NULL,
allowed_scopes TEXT[] NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS oidc_auth_codes (
code_hash BYTEA PRIMARY KEY,
client_id TEXT NOT NULL REFERENCES oidc_clients(client_id),
user_id UUID NOT NULL REFERENCES users(id),
redirect_uri TEXT NOT NULL,
scopes TEXT[] NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ NOT NULL,
used_at TIMESTAMPTZ
);
`); err != nil {
t.Fatalf("schema: %v", err)
}
keys, err := moduletrust.NewKeyManager()
if err != nil {
t.Fatalf("keymanager: %v", err)
}
clients := NewClientStore(pool)
authCodes := NewAuthCodeStore(pool)
handler := NewHandler(clients, authCodes, keys, "https://core.test.nexarch.example")
cleanup := func() {
ctx := context.Background()
_, _ = pool.Exec(ctx, `DROP TABLE IF EXISTS oidc_auth_codes, oidc_clients, users CASCADE`)
pool.Close()
}
return handler, pool, cleanup
}
func createTestUser(t *testing.T, pool *pgxpool.Pool, email string) string {
t.Helper()
var id string
err := pool.QueryRow(context.Background(), `
INSERT INTO users (email, name) VALUES ($1, $2) RETURNING id
`, email, "Test Nutzer").Scan(&id)
if err != nil {
t.Fatalf("testnutzer anlegen: %v", err)
}
return id
}
// Akzeptanzkriterium 1 (Client-Registrierung) + 2 (voller Flow) + 3 (JWKS-
// Verifikation, Scope-Einschraenkung). Deckt Pruefungen 1-3 ab.
func TestFullAuthorizationCodeFlow(t *testing.T) {
handler, pool, cleanup := newProviderTestSetup(t)
defer cleanup()
ctx := context.Background()
userID := createTestUser(t, pool, "nutzer@acme.example")
// Pruefung 1 / Akzeptanzkriterium 1: Client registriert sich.
clientID, clientSecret, err := handler.clients.Register(ctx, "Test-Drittanwendung",
[]string{"https://app.example.com/callback"},
[]string{"openid", "profile"}, // erlaubte Scopes: KEIN "admin"
)
if err != nil {
t.Fatalf("register: %v", err)
}
// Session-Cookie fuer den Authorize-Aufruf ausstellen, exakt wie ein
// echter Browser ihn haette — RequireAuth uebernimmt die Verifikation,
// dieselbe Middleware wie in Produktion (nicht simuliert).
issuer := auth.NewTokenIssuer("test-session-secret")
sessionToken, err := issuer.Issue(userID, "acme")
if err != nil {
t.Fatalf("session-token ausstellen: %v", err)
}
authorizeURL := "/oidc/authorize?" + url.Values{
"response_type": {"code"},
"client_id": {clientID},
"redirect_uri": {"https://app.example.com/callback"},
// admin wird angefragt, ist aber NICHT erlaubt -> muss herausgefiltert werden.
"scope": {"openid profile admin"},
"state": {"xyz123"},
}.Encode()
req := httptest.NewRequest(http.MethodGet, authorizeURL, nil)
req.AddCookie(&http.Cookie{Name: auth.CookieName, Value: sessionToken})
rec := httptest.NewRecorder()
protected := auth.RequireAuth(issuer, handler.Authorize)
protected(rec, req)
if rec.Code != http.StatusFound {
t.Fatalf("authorize: status = %d, want %d, body: %s", rec.Code, http.StatusFound, rec.Body.String())
}
loc, err := url.Parse(rec.Header().Get("Location"))
if err != nil {
t.Fatalf("redirect-location parsen: %v", err)
}
if loc.Query().Get("state") != "xyz123" {
t.Fatalf("state = %q, want xyz123 (muss unveraendert durchgereicht werden)", loc.Query().Get("state"))
}
code := loc.Query().Get("code")
if code == "" {
t.Fatal("erwartet gesetzten code-Parameter in der redirect-URL")
}
// Pruefung 1 / Akzeptanzkriterium 2: Code gegen Token tauschen.
tokenForm := url.Values{
"grant_type": {"authorization_code"},
"code": {code},
"redirect_uri": {"https://app.example.com/callback"},
"client_id": {clientID},
"client_secret": {clientSecret},
}
tokenReq := httptest.NewRequest(http.MethodPost, "/oidc/token", strings.NewReader(tokenForm.Encode()))
tokenReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
tokenRec := httptest.NewRecorder()
handler.Token(tokenRec, tokenReq)
if tokenRec.Code != http.StatusOK {
t.Fatalf("token: status = %d, want 200, body: %s", tokenRec.Code, tokenRec.Body.String())
}
var tokenResp struct {
IDToken string `json:"id_token"`
Scope string `json:"scope"`
}
if err := decodeJSON(tokenRec.Body.Bytes(), &tokenResp); err != nil {
t.Fatalf("token-response dekodieren: %v", err)
}
// Akzeptanzkriterium 3 / Pruefung 3: "admin" wurde herausgefiltert.
grantedScopes := strings.Fields(tokenResp.Scope)
for _, s := range grantedScopes {
if s == "admin" {
t.Fatal("nicht erlaubter scope 'admin' wurde dennoch gewaehrt")
}
}
if !containsAll(grantedScopes, "openid", "profile") {
t.Fatalf("erwartete scopes openid+profile, habe %v", grantedScopes)
}
// Pruefung 2 / Akzeptanzkriterium 3: ausgestelltes Token gegen JWKS verifizieren.
jwksRec := httptest.NewRecorder()
handler.JWKS(jwksRec, httptest.NewRequest(http.MethodGet, "/oidc/jwks.json", nil))
var jwks idpJWKSet
if err := decodeJSON(jwksRec.Body.Bytes(), &jwks); err != nil {
t.Fatalf("jwks dekodieren: %v", err)
}
if len(jwks.Keys) == 0 {
t.Fatal("jwks enthaelt keine schluessel")
}
parsedToken, err := jwt.ParseWithClaims(tokenResp.IDToken, &IDTokenClaims{}, func(tok *jwt.Token) (interface{}, error) {
kid, _ := tok.Header["kid"].(string)
for _, k := range jwks.Keys {
if k.Kid == kid {
pub, err := decodeBase64URLEd25519(k.X)
if err != nil {
return nil, err
}
return ed25519.PublicKey(pub), nil
}
}
return nil, jwt.ErrTokenUnverifiable
})
if err != nil || !parsedToken.Valid {
t.Fatalf("id-token gegen jwks verifizieren: %v (valid=%v)", err, parsedToken != nil && parsedToken.Valid)
}
claims := parsedToken.Claims.(*IDTokenClaims)
if claims.Subject != userID {
t.Fatalf("sub = %q, want %q", claims.Subject, userID)
}
if len(claims.Audience) != 1 || claims.Audience[0] != clientID {
t.Fatalf("aud = %v, want [%q]", claims.Audience, clientID)
}
}
// Pruefung 3 (dediziert): Client, der einen Scope anfragt, den er bei der
// Registrierung nicht erhalten hat, bekommt ihn unter keinen Umstaenden.
func TestGrantedScopes_NeverExceedsAllowed(t *testing.T) {
c := Client{AllowedScopes: []string{"openid", "profile"}}
granted := c.GrantedScopes([]string{"openid", "admin", "profile", "billing"})
if !containsAll(granted, "openid", "profile") || len(granted) != 2 {
t.Fatalf("granted = %v, want genau [openid profile]", granted)
}
}
func TestValidateRedirectURI_RejectsUnregistered(t *testing.T) {
c := Client{RedirectURIs: []string{"https://app.example.com/callback"}}
if err := c.ValidateRedirectURI("https://boese-seite.example.com/callback"); err == nil {
t.Fatal("erwartet fehler fuer nicht registrierte redirect_uri")
}
if err := c.ValidateRedirectURI("https://app.example.com/callback"); err != nil {
t.Fatalf("registrierte redirect_uri sollte akzeptiert werden: %v", err)
}
}
func TestAuthCodeStore_CannotBeConsumedTwice(t *testing.T) {
handler, pool, cleanup := newProviderTestSetup(t)
defer cleanup()
ctx := context.Background()
userID := createTestUser(t, pool, "einmalig@acme.example")
clientID, _, err := handler.clients.Register(ctx, "Einmal-Client", []string{"https://a.example/cb"}, []string{"openid"})
if err != nil {
t.Fatalf("register: %v", err)
}
code, err := handler.authCodes.Issue(ctx, clientID, userID, "https://a.example/cb", []string{"openid"})
if err != nil {
t.Fatalf("issue: %v", err)
}
if _, err := handler.authCodes.Consume(ctx, code, clientID, "https://a.example/cb"); err != nil {
t.Fatalf("erster consume sollte funktionieren: %v", err)
}
if _, err := handler.authCodes.Consume(ctx, code, clientID, "https://a.example/cb"); err == nil {
t.Fatal("zweiter consume desselben codes haette fehlschlagen muessen")
}
}
func containsAll(haystack []string, needles ...string) bool {
set := make(map[string]bool, len(haystack))
for _, h := range haystack {
set[h] = true
}
for _, n := range needles {
if !set[n] {
return false
}
}
return true
}
func decodeJSON(data []byte, v any) error {
return json.Unmarshal(data, v)
}
func decodeBase64URLEd25519(s string) ([]byte, error) {
return base64.RawURLEncoding.DecodeString(s)
}
+76
View File
@@ -0,0 +1,76 @@
package oidc
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
var ErrStateInvalid = errors.New("oidc: state ungueltig, bereits verwendet oder abgelaufen")
// DefaultStateTTL begrenzt, wie lange ein ausgestellter State/Nonce fuer den
// Redirect-Umweg zum Identity-Provider gueltig bleibt.
const DefaultStateTTL = 10 * time.Minute
type StateStore struct {
pool *pgxpool.Pool
}
func NewStateStore(pool *pgxpool.Pool) *StateStore {
return &StateStore{pool: pool}
}
// Generate stellt state+nonce fuer einen neuen Login-Redirect aus. state
// wird als OAuth2-"state"-Parameter mitgeschickt (CSRF-Schutz), nonce
// erscheint spaeter im ID-Token und muss exakt uebereinstimmen (Replay-Schutz).
func (s *StateStore) Generate(ctx context.Context) (state, nonce string, err error) {
state, err = randomValue()
if err != nil {
return "", "", err
}
nonce, err = randomValue()
if err != nil {
return "", "", err
}
_, err = s.pool.Exec(ctx, `
INSERT INTO oidc_states (state, nonce, expires_at) VALUES ($1, $2, $3)
`, state, nonce, time.Now().Add(DefaultStateTTL))
if err != nil {
return "", "", fmt.Errorf("state speichern: %w", err)
}
return state, nonce, nil
}
// Consume loest einen State EINMALIG ein (Akzeptanzkriterium/Pruefung 3:
// Replay-Schutz) — atomar ueber WHERE used_at IS NULL, analog IAM-03/IAM-09.
// Ein zweiter Callback mit demselben state (z.B. durch einen Angreifer, der
// die Redirect-URL abgefangen hat) schlaegt fehl.
func (s *StateStore) Consume(ctx context.Context, state string) (nonce string, err error) {
err = s.pool.QueryRow(ctx, `
UPDATE oidc_states SET used_at = now()
WHERE state = $1 AND used_at IS NULL AND expires_at > now()
RETURNING nonce
`, state).Scan(&nonce)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return "", ErrStateInvalid
}
return "", fmt.Errorf("state einloesen: %w", err)
}
return nonce, nil
}
func randomValue() (string, error) {
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return hex.EncodeToString(buf), nil
}
+68
View File
@@ -0,0 +1,68 @@
package oidc
import (
"context"
"errors"
"os"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
)
func setupStateTest(t *testing.T) (*StateStore, func()) {
t.Helper()
adminDSN := os.Getenv("TEST_ADMIN_DSN")
if adminDSN == "" {
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, adminDSN)
if err != nil {
t.Fatalf("pool: %v", err)
}
if _, err := pool.Exec(ctx, `
CREATE TABLE IF NOT EXISTS oidc_states (
state TEXT PRIMARY KEY, nonce TEXT NOT NULL, expires_at TIMESTAMPTZ NOT NULL,
used_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)`); err != nil {
t.Fatalf("schema: %v", err)
}
cleanup := func() { pool.Close() }
return NewStateStore(pool), cleanup
}
// Akzeptanzkriterium/Pruefung 3: State/Nonce-Handling gegen CSRF und Replay.
func TestStateStore_ConsumeIsSingleUse(t *testing.T) {
store, cleanup := setupStateTest(t)
defer cleanup()
ctx := context.Background()
state, nonce, err := store.Generate(ctx)
if err != nil {
t.Fatalf("generate: %v", err)
}
gotNonce, err := store.Consume(ctx, state)
if err != nil {
t.Fatalf("consume (erste einloesung): %v", err)
}
if gotNonce != nonce {
t.Fatalf("nonce = %q, want %q", gotNonce, nonce)
}
if _, err := store.Consume(ctx, state); !errors.Is(err, ErrStateInvalid) {
t.Fatalf("erwartet ErrStateInvalid bei erneutem einloesen (replay), habe %v", err)
}
}
func TestStateStore_RejectsUnknownState(t *testing.T) {
store, cleanup := setupStateTest(t)
defer cleanup()
ctx := context.Background()
if _, err := store.Consume(ctx, "niemals-ausgestellter-state"); !errors.Is(err, ErrStateInvalid) {
t.Fatalf("erwartet ErrStateInvalid, habe %v", err)
}
}
+70
View File
@@ -0,0 +1,70 @@
package oidc
import (
"crypto/rsa"
"errors"
"fmt"
"github.com/golang-jwt/jwt/v5"
)
var ErrInvalidToken = errors.New("oidc: id-token ungueltig, abgelaufen oder falscher aussteller")
// Claims sind die fuer NEXARCH relevanten OIDC-ID-Token-Claims.
type Claims struct {
Subject string `json:"sub"`
Email string `json:"email"`
Nonce string `json:"nonce"`
Roles []string `json:"roles"` // providerabhaengiger Claim-Name, hier vereinfacht fest "roles"
jwt.RegisteredClaims
}
// Verifier prueft ID-Tokens gegen den JWKS-Schluesselsatz eines konfigurierten
// Providers (Akzeptanzkriterium 3: fehlerhafte/abgelaufene Tokens werden
// abgewiesen).
type Verifier struct {
keys map[string]*rsa.PublicKey
issuer string
}
func NewVerifier(keys map[string]*rsa.PublicKey, issuer string) *Verifier {
return &Verifier{keys: keys, issuer: issuer}
}
// Verify prueft Signatur (gegen JWKS), Ablauf (jwt-Bibliothek lehnt
// abgelaufene Tokens automatisch ab) und Aussteller.
func (v *Verifier) Verify(idToken string) (*Claims, error) {
claims := &Claims{}
token, err := jwt.ParseWithClaims(idToken, claims, func(t *jwt.Token) (interface{}, error) {
if _, ok := t.Method.(*jwt.SigningMethodRSA); !ok {
return nil, ErrInvalidToken
}
kid, _ := t.Header["kid"].(string)
key, ok := v.keys[kid]
if !ok {
return nil, ErrInvalidToken
}
return key, nil
})
if err != nil || !token.Valid {
return nil, ErrInvalidToken
}
if claims.Issuer != v.issuer {
return nil, fmt.Errorf("%w: unerwarteter aussteller %q", ErrInvalidToken, claims.Issuer)
}
return claims, nil
}
// VerifyWithNonce prueft zusaetzlich, dass der nonce-Claim im Token exakt
// dem bei der Anfrage ausgestellten Nonce entspricht — verhindert Replay
// eines fremden, gueltigen ID-Tokens (Akzeptanzkriterium/Pruefung 3).
func (v *Verifier) VerifyWithNonce(idToken, expectedNonce string) (*Claims, error) {
claims, err := v.Verify(idToken)
if err != nil {
return nil, err
}
if claims.Nonce == "" || claims.Nonce != expectedNonce {
return nil, fmt.Errorf("%w: nonce stimmt nicht ueberein", ErrInvalidToken)
}
return claims, nil
}
+184
View File
@@ -0,0 +1,184 @@
package oidc
import (
"crypto/rand"
"crypto/rsa"
"encoding/base64"
"encoding/binary"
"encoding/json"
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
)
func generateTestKey(t *testing.T) *rsa.PrivateKey {
t.Helper()
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("generate rsa key: %v", err)
}
return key
}
func signTestToken(t *testing.T, key *rsa.PrivateKey, kid string, claims Claims) string {
t.Helper()
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
token.Header["kid"] = kid
s, err := token.SignedString(key)
if err != nil {
t.Fatalf("sign token: %v", err)
}
return s
}
func TestVerify_AcceptsValidToken(t *testing.T) {
key := generateTestKey(t)
verifier := NewVerifier(map[string]*rsa.PublicKey{"kid-1": &key.PublicKey}, "https://idp.example.com")
claims := Claims{
Subject: "user-123",
Email: "person@example.com",
Nonce: "erwarteter-nonce",
RegisteredClaims: jwt.RegisteredClaims{
Issuer: "https://idp.example.com",
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
},
}
tok := signTestToken(t, key, "kid-1", claims)
got, err := verifier.Verify(tok)
if err != nil {
t.Fatalf("verify: %v", err)
}
if got.Email != "person@example.com" {
t.Fatalf("email = %q", got.Email)
}
}
// Akzeptanzkriterium 3 + Pruefung 2: Token-Signatur- und Ablaufpruefung.
func TestVerify_RejectsExpiredToken(t *testing.T) {
key := generateTestKey(t)
verifier := NewVerifier(map[string]*rsa.PublicKey{"kid-1": &key.PublicKey}, "https://idp.example.com")
claims := Claims{
Subject: "user-123",
RegisteredClaims: jwt.RegisteredClaims{
Issuer: "https://idp.example.com",
ExpiresAt: jwt.NewNumericDate(time.Now().Add(-time.Hour)), // abgelaufen
},
}
tok := signTestToken(t, key, "kid-1", claims)
if _, err := verifier.Verify(tok); err == nil {
t.Fatal("erwartet fehler bei abgelaufenem token")
}
}
func TestVerify_RejectsWrongSigningKey(t *testing.T) {
realKey := generateTestKey(t)
attackerKey := generateTestKey(t)
// Verifier kennt NUR den echten oeffentlichen Schluessel.
verifier := NewVerifier(map[string]*rsa.PublicKey{"kid-1": &realKey.PublicKey}, "https://idp.example.com")
claims := Claims{
Subject: "user-123",
RegisteredClaims: jwt.RegisteredClaims{
Issuer: "https://idp.example.com",
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
},
}
// Mit dem Angreifer-Schluessel signiert, aber unter demselben kid wie der echte.
tok := signTestToken(t, attackerKey, "kid-1", claims)
if _, err := verifier.Verify(tok); err == nil {
t.Fatal("erwartet fehler bei fremd signiertem token")
}
}
func TestVerify_RejectsWrongIssuer(t *testing.T) {
key := generateTestKey(t)
verifier := NewVerifier(map[string]*rsa.PublicKey{"kid-1": &key.PublicKey}, "https://idp.example.com")
claims := Claims{
Subject: "user-123",
RegisteredClaims: jwt.RegisteredClaims{
Issuer: "https://ganz-anderer-idp.example.com",
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
},
}
tok := signTestToken(t, key, "kid-1", claims)
if _, err := verifier.Verify(tok); err == nil {
t.Fatal("erwartet fehler bei unerwartetem aussteller")
}
}
// Akzeptanzkriterium/Pruefung 3: Nonce-Handling gegen Replay.
func TestVerifyWithNonce_RejectsMismatch(t *testing.T) {
key := generateTestKey(t)
verifier := NewVerifier(map[string]*rsa.PublicKey{"kid-1": &key.PublicKey}, "https://idp.example.com")
claims := Claims{
Subject: "user-123",
Nonce: "nonce-a",
RegisteredClaims: jwt.RegisteredClaims{
Issuer: "https://idp.example.com",
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
},
}
tok := signTestToken(t, key, "kid-1", claims)
if _, err := verifier.VerifyWithNonce(tok, "nonce-b"); err == nil {
t.Fatal("erwartet fehler bei nonce-mismatch (moeglicher replay eines fremden tokens)")
}
if _, err := verifier.VerifyWithNonce(tok, "nonce-a"); err != nil {
t.Fatalf("erwartet erfolg bei passendem nonce: %v", err)
}
}
func TestParseJWKS_RoundTrip(t *testing.T) {
key := generateTestKey(t)
nBytes := key.PublicKey.N.Bytes()
eBuf := make([]byte, 8)
binary.BigEndian.PutUint64(eBuf, uint64(key.PublicKey.E))
// Fuehrende Nullbytes des Exponenten abschneiden (ueblich bei JWK-Kodierung).
eStart := 0
for eStart < len(eBuf)-1 && eBuf[eStart] == 0 {
eStart++
}
set := jwkSet{Keys: []jwk{{
Kid: "kid-1",
Kty: "RSA",
N: base64.RawURLEncoding.EncodeToString(nBytes),
E: base64.RawURLEncoding.EncodeToString(eBuf[eStart:]),
}}}
data, err := json.Marshal(set)
if err != nil {
t.Fatalf("marshal: %v", err)
}
parsed, err := ParseJWKS(data)
if err != nil {
t.Fatalf("parse jwks: %v", err)
}
got, ok := parsed["kid-1"]
if !ok {
t.Fatal("erwartet kid-1 im ergebnis")
}
if got.E != key.PublicKey.E || got.N.Cmp(key.PublicKey.N) != 0 {
t.Fatalf("erwartet identischen oeffentlichen schluessel nach roundtrip")
}
// Der wiederhergestellte Schluessel muss ein damit signiertes Token auch tatsaechlich verifizieren.
verifier := NewVerifier(parsed, "https://idp.example.com")
claims := Claims{Subject: "x", RegisteredClaims: jwt.RegisteredClaims{
Issuer: "https://idp.example.com", ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
}}
tok := signTestToken(t, key, "kid-1", claims)
if _, err := verifier.Verify(tok); err != nil {
t.Fatalf("verify mit aus jwks wiederhergestelltem schluessel: %v", err)
}
}
+33
View File
@@ -0,0 +1,33 @@
package pwpolicy
// commonPasswords ist eine kleine, eingebettete Sperrliste bekannt
// haeufig verwendeter/kompromittierter Passwoerter (Akzeptanzkriterium 2).
// Vergleich erfolgt case-insensitive (siehe Validate).
var commonPasswords = map[string]bool{
"123456": true,
"password": true,
"12345678": true,
"qwerty": true,
"123456789": true,
"12345": true,
"1234": true,
"111111": true,
"1234567": true,
"dragon": true,
"123123": true,
"baseball": true,
"iloveyou": true,
"trustno1": true,
"1234567890": true,
"sunshine": true,
"master": true,
"welcome": true,
"admin": true,
"letmein": true,
"login": true,
"passw0rd": true,
"starwars": true,
"abc123": true,
"password1": true,
"qwerty123": true,
}
+30
View File
@@ -0,0 +1,30 @@
package pwpolicy
import (
"context"
"gitea.perlbach24.de/scripte/nexarch/internal/auth"
)
// LoginAndCheckPolicy komponiert IAM-02s LoginService mit der Passwort-
// Richtlinienpruefung, OHNE LoginService selbst zu veraendern. Ein
// bestehender Benutzer mit einem alten, nicht mehr konformen Passwort kann
// sich weiterhin einloggen (Login schlaegt NICHT fehl) — er wird lediglich
// zur Aenderung aufgefordert (Akzeptanzkriterium 3: kein rueckwirkendes
// Aussperren). Die Pruefung ist nur HIER moeglich, da nur beim Login das
// Klartext-Passwort kurzzeitig vorliegt — der gespeicherte bcrypt-Hash laesst
// sich nicht rueckwirkend gegen eine neue Richtlinie pruefen.
func LoginAndCheckPolicy(ctx context.Context, policies *Store, login *auth.LoginService, email, password string) (token string, mustChangePassword bool, err error) {
token, err = login.Login(ctx, email, password)
if err != nil {
return "", false, err
}
policy, err := policies.Get(ctx)
if err != nil {
return "", false, err
}
mustChangePassword = Validate(policy, password) != nil
return token, mustChangePassword, nil
}
+112
View File
@@ -0,0 +1,112 @@
// Package pwpolicy implementiert Core IAM-14: konfigurierbare Passwort-
// Komplexitätsanforderungen pro Tenant — eine schmale Regelschicht VOR dem
// bcrypt-Hashing aus IAM-02, kein eigenes Policy-Framework.
package pwpolicy
import (
"context"
"errors"
"fmt"
"strings"
"unicode"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// Policy sind die Mindestanforderungen (Akzeptanzkriterium 1).
type Policy struct {
MinLength int
RequireUpper bool
RequireLower bool
RequireDigit bool
RequireSpecial bool
}
// DefaultPolicy gilt, solange ein Tenant keine eigene Konfiguration gesetzt hat.
func DefaultPolicy() Policy {
return Policy{MinLength: 12, RequireUpper: true, RequireLower: true, RequireDigit: true, RequireSpecial: false}
}
var ErrPasswordTooWeak = errors.New("pwpolicy: passwort erfuellt die richtlinie nicht")
// Validate prueft ein Passwort gegen policy UND die Sperrliste
// (Akzeptanzkriterium 2). Liefert bei Verstoss ErrPasswordTooWeak,
// gewrappt mit einer fuer Menschen lesbaren Begruendung.
func Validate(policy Policy, password string) error {
if commonPasswords[strings.ToLower(password)] {
return fmt.Errorf("%w: passwort steht auf der sperrliste haeufig verwendeter passwoerter", ErrPasswordTooWeak)
}
if len(password) < policy.MinLength {
return fmt.Errorf("%w: mindestens %d zeichen erforderlich", ErrPasswordTooWeak, policy.MinLength)
}
var hasUpper, hasLower, hasDigit, hasSpecial bool
for _, r := range password {
switch {
case unicode.IsUpper(r):
hasUpper = true
case unicode.IsLower(r):
hasLower = true
case unicode.IsDigit(r):
hasDigit = true
case unicode.IsPunct(r) || unicode.IsSymbol(r):
hasSpecial = true
}
}
if policy.RequireUpper && !hasUpper {
return fmt.Errorf("%w: mindestens ein grossbuchstabe erforderlich", ErrPasswordTooWeak)
}
if policy.RequireLower && !hasLower {
return fmt.Errorf("%w: mindestens ein kleinbuchstabe erforderlich", ErrPasswordTooWeak)
}
if policy.RequireDigit && !hasDigit {
return fmt.Errorf("%w: mindestens eine ziffer erforderlich", ErrPasswordTooWeak)
}
if policy.RequireSpecial && !hasSpecial {
return fmt.Errorf("%w: mindestens ein sonderzeichen erforderlich", ErrPasswordTooWeak)
}
return nil
}
// Store verwaltet die pro-Tenant konfigurierte Richtlinie (eine Zeile je
// Tenant-Datenbank, Modell C).
type Store struct {
pool *pgxpool.Pool
}
func NewStore(pool *pgxpool.Pool) *Store {
return &Store{pool: pool}
}
// Get liefert die konfigurierte Richtlinie, oder DefaultPolicy() wenn noch
// keine gesetzt wurde.
func (s *Store) Get(ctx context.Context) (Policy, error) {
var p Policy
err := s.pool.QueryRow(ctx, `
SELECT min_length, require_upper, require_lower, require_digit, require_special
FROM password_policy WHERE id = true
`).Scan(&p.MinLength, &p.RequireUpper, &p.RequireLower, &p.RequireDigit, &p.RequireSpecial)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return DefaultPolicy(), nil
}
return Policy{}, fmt.Errorf("richtlinie lesen: %w", err)
}
return p, nil
}
// Set legt die Richtlinie fuer diesen Tenant fest (Akzeptanzkriterium 1).
func (s *Store) Set(ctx context.Context, p Policy) error {
_, err := s.pool.Exec(ctx, `
INSERT INTO password_policy (id, min_length, require_upper, require_lower, require_digit, require_special, updated_at)
VALUES (true, $1, $2, $3, $4, $5, now())
ON CONFLICT (id) DO UPDATE SET
min_length = $1, require_upper = $2, require_lower = $3, require_digit = $4, require_special = $5, updated_at = now()
`, p.MinLength, p.RequireUpper, p.RequireLower, p.RequireDigit, p.RequireSpecial)
if err != nil {
return fmt.Errorf("richtlinie speichern: %w", err)
}
return nil
}
+176
View File
@@ -0,0 +1,176 @@
package pwpolicy
import (
"context"
"errors"
"fmt"
"os"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/internal/auth"
"gitea.perlbach24.de/scripte/nexarch/internal/user"
)
// Akzeptanzkriterium 1 + Pruefung 1: zu kurzes/zu einfaches Passwort abgelehnt.
func TestValidate_RejectsTooShortOrSimple(t *testing.T) {
policy := DefaultPolicy()
if err := Validate(policy, "kurz1A"); !errors.Is(err, ErrPasswordTooWeak) {
t.Fatalf("erwartet ErrPasswordTooWeak fuer zu kurzes passwort, habe %v", err)
}
if err := Validate(policy, "alleskleingeschriebenundlang123"); !errors.Is(err, ErrPasswordTooWeak) {
t.Fatalf("erwartet ErrPasswordTooWeak ohne grossbuchstaben, habe %v", err)
}
if err := Validate(policy, "GutesPasswort2026!"); err != nil {
t.Fatalf("erwartet gueltig, habe %v", err)
}
}
// Akzeptanzkriterium 2 + Pruefung 2: Passwort aus Sperrliste abgelehnt.
func TestValidate_RejectsBlocklistedPassword(t *testing.T) {
policy := Policy{MinLength: 4} // absichtlich schwach, damit NUR die sperrliste greift
if err := Validate(policy, "password"); !errors.Is(err, ErrPasswordTooWeak) {
t.Fatalf("erwartet ErrPasswordTooWeak fuer sperrlisten-passwort, habe %v", err)
}
if err := Validate(policy, "PASSWORD"); !errors.Is(err, ErrPasswordTooWeak) {
t.Fatalf("erwartet case-insensitive treffer auf der sperrliste, habe %v", err)
}
if err := Validate(policy, "ein-eher-unueblicher-satz"); err != nil {
t.Fatalf("nicht-sperrlisten-passwort sollte hier gueltig sein: %v", err)
}
}
func setupTest(t *testing.T) (*Store, *pgxpool.Pool, func()) {
t.Helper()
adminDSN := os.Getenv("TEST_ADMIN_DSN")
if adminDSN == "" {
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, adminDSN)
if err != nil {
t.Fatalf("pool: %v", err)
}
if _, err := pool.Exec(ctx, `
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), email TEXT NOT NULL UNIQUE, name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active', password_hash TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS password_policy (
id BOOLEAN PRIMARY KEY DEFAULT true CHECK (id), min_length INT NOT NULL DEFAULT 12,
require_upper BOOLEAN NOT NULL DEFAULT true, require_lower BOOLEAN NOT NULL DEFAULT true,
require_digit BOOLEAN NOT NULL DEFAULT true, require_special BOOLEAN NOT NULL DEFAULT false,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
`); err != nil {
t.Fatalf("schema: %v", err)
}
// Singleton-Tabelle: vor jedem Test leeren, falls ein vorheriger Lauf
// (in dieser geteilten Registry-Tabelle) eine Zeile hinterlassen hat.
if _, err := pool.Exec(ctx, `DELETE FROM password_policy`); err != nil {
t.Fatalf("vorab-bereinigung: %v", err)
}
cleanup := func() {
_, _ = pool.Exec(ctx, `DELETE FROM password_policy`)
pool.Close()
}
return NewStore(pool), pool, cleanup
}
func TestStore_GetReturnsDefaultWhenUnset(t *testing.T) {
store, _, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
got, err := store.Get(ctx)
if err != nil {
t.Fatalf("get: %v", err)
}
if got != DefaultPolicy() {
t.Fatalf("erwartet default policy, habe %+v", got)
}
}
func TestStore_SetAndGetRoundTrip(t *testing.T) {
store, _, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
custom := Policy{MinLength: 16, RequireUpper: true, RequireLower: true, RequireDigit: true, RequireSpecial: true}
if err := store.Set(ctx, custom); err != nil {
t.Fatalf("set: %v", err)
}
got, err := store.Get(ctx)
if err != nil {
t.Fatalf("get: %v", err)
}
if got != custom {
t.Fatalf("erwartet %+v, habe %+v", custom, got)
}
}
// Akzeptanzkriterium 3 + Pruefung 3: bestehender Nutzer mit altem,
// nicht-konformem Passwort kann sich noch einloggen, wird aber zur
// Aenderung aufgefordert — kein rueckwirkendes Aussperren.
func TestLoginAndCheckPolicy_FlagsNonConformantExistingPassword(t *testing.T) {
store, pool, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
email := fmt.Sprintf("pwpolicy-test-%d@example.com", time.Now().UnixNano())
userStore := user.NewTenantUserStore(pool)
u, err := userStore.Create(ctx, email, "Legacy User")
if err != nil {
t.Fatalf("create user: %v", err)
}
// Altes Passwort, das VOR der heutigen Richtlinie gesetzt wurde — erfuellt
// die aktuelle DefaultPolicy() nicht (kein Grossbuchstabe, zu kurz).
oldPassword := "altespasswort"
hash, err := auth.HashPassword(oldPassword)
if err != nil {
t.Fatalf("hash: %v", err)
}
if err := userStore.SetPasswordHash(ctx, u.ID, hash); err != nil {
t.Fatalf("set password hash: %v", err)
}
issuer := auth.NewTokenIssuer("test-secret-nur-fuer-tests")
loginService := auth.NewLoginService(userStore, issuer, "acme")
token, mustChange, err := LoginAndCheckPolicy(ctx, store, loginService, email, oldPassword)
if err != nil {
t.Fatalf("login sollte trotz schwachem altpasswort gelingen: %v", err)
}
if token == "" {
t.Fatal("erwartet gueltiges token")
}
if !mustChange {
t.Fatal("erwartet mustChangePassword=true fuer nicht-konformes altpasswort")
}
// Konformes Passwort -> kein Aenderungszwang.
strongPassword := "EinStarkesPasswort2026!"
hash2, err := auth.HashPassword(strongPassword)
if err != nil {
t.Fatalf("hash 2: %v", err)
}
if err := userStore.SetPasswordHash(ctx, u.ID, hash2); err != nil {
t.Fatalf("set password hash 2: %v", err)
}
_, mustChange, err = LoginAndCheckPolicy(ctx, store, loginService, email, strongPassword)
if err != nil {
t.Fatalf("login mit starkem passwort: %v", err)
}
if mustChange {
t.Fatal("erwartet mustChangePassword=false fuer konformes passwort")
}
}
+71
View File
@@ -0,0 +1,71 @@
// Package rbac implementiert Core RBAC-01: das Rollenmodell mit
// Grundrollen/-rechten, Hierarchie und Zuweisung. Die eigentliche
// Durchsetzung (erlaubt/verboten je Request) ist RBAC-02, dieses Paket
// liefert nur Modell + Zuweisung (Casbin-Prinzip: Policy-Modell getrennt
// von der Entscheidungsfunktion).
package rbac
// Role ist eine der drei Grundrollen. Superadmin lebt mandantenuebergreifend
// (siehe internal/user.SuperadminStore aus IAM-01) und wird deshalb NICHT
// ueber Store (tenant-gescoped) zugewiesen — die Existenz eines Superadmin-
// Kontos IST die Rollenzuweisung.
type Role string
const (
RoleSuperadmin Role = "superadmin"
RoleTenantAdmin Role = "tenant_admin"
RoleUser Role = "user"
)
type Permission string
const (
PermSelfRead Permission = "self.read"
PermSelfUpdate Permission = "self.update"
PermManageUsers Permission = "tenant.manage_users"
PermManageSettings Permission = "tenant.manage_settings"
PermManageTenants Permission = "platform.manage_tenants"
)
// parent bildet die Rollenhierarchie ab (Akzeptanzkriterium 2): eine Rolle
// erbt alle Rechte ihrer Elternrolle. tenant_admin erbt von user,
// superadmin erbt von tenant_admin.
var parent = map[Role]Role{
RoleTenantAdmin: RoleUser,
RoleSuperadmin: RoleTenantAdmin,
}
// direct sind die einer Rolle direkt (ohne Vererbung) zugeordneten Rechte.
var direct = map[Role][]Permission{
RoleUser: {PermSelfRead, PermSelfUpdate},
RoleTenantAdmin: {PermManageUsers, PermManageSettings},
RoleSuperadmin: {PermManageTenants},
}
// EffectivePermissions liefert die vollstaendige, ueber die Hierarchie
// aufgeloeste Rechtemenge einer Rolle (Akzeptanzkriterium 2 / Pruefung 2).
func EffectivePermissions(role Role) []Permission {
seen := make(map[Permission]bool)
var out []Permission
for r, ok := role, true; ok; r, ok = parent[r] {
for _, p := range direct[r] {
if !seen[p] {
seen[p] = true
out = append(out, p)
}
}
}
return out
}
// HasPermission prueft, ob eine Rolle (inklusive geerbter Rechte) ein
// bestimmtes Recht besitzt.
func HasPermission(role Role, perm Permission) bool {
for _, p := range EffectivePermissions(role) {
if p == perm {
return true
}
}
return false
}
+124
View File
@@ -0,0 +1,124 @@
package rbac
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
var (
// ErrRoleNotAssignableInTenantScope wird geliefert, wenn versucht wird,
// eine mandantenuebergreifende Rolle (superadmin) ueber den tenant-
// gescopten Store zu vergeben — die erlaubte Matrix laesst hier nur
// user/tenant_admin zu (Akzeptanzkriterium 1 / Pruefung 1).
ErrRoleNotAssignableInTenantScope = errors.New("rbac: rolle ist in diesem geltungsbereich nicht zuweisbar")
ErrNotFound = errors.New("rbac: keine rollenzuweisung gefunden")
)
// assignableRoles ist die erlaubte Matrix fuer Store (tenant-gescoped).
var assignableRoles = map[Role]bool{
RoleUser: true,
RoleTenantAdmin: true,
}
type Assignment struct {
UserID string
Role Role
GrantedBy string
}
// Store verwaltet Rollenzuweisungen innerhalb GENAU EINER Tenant-Datenbank —
// analog zu internal/user.TenantUserStore (Modell C: der Pool bestimmt den
// Tenant, keine tenant_id-Spalte noetig).
type Store struct {
pool *pgxpool.Pool
}
func NewStore(pool *pgxpool.Pool) *Store {
return &Store{pool: pool}
}
// Assign vergibt eine Rolle an einen Benutzer. grantedBy identifiziert den
// Akteur, der die Zuweisung vorgenommen hat (Akzeptanzkriterium 3). Jede
// Zuweisung wird zusaetzlich in role_assignment_history festgehalten, auch
// wenn sie eine vorherige Rolle ersetzt.
func (s *Store) Assign(ctx context.Context, userID string, role Role, grantedBy string) (Assignment, error) {
if !assignableRoles[role] {
return Assignment{}, ErrRoleNotAssignableInTenantScope
}
if grantedBy == "" {
return Assignment{}, errors.New("rbac: grantedBy darf nicht leer sein")
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return Assignment{}, fmt.Errorf("transaktion starten: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
if _, err := tx.Exec(ctx, `
INSERT INTO role_assignments (user_id, role, granted_by, granted_at)
VALUES ($1, $2, $3, now())
ON CONFLICT (user_id) DO UPDATE SET role = $2, granted_by = $3, granted_at = now()
`, userID, string(role), grantedBy); err != nil {
return Assignment{}, fmt.Errorf("rolle zuweisen: %w", err)
}
if _, err := tx.Exec(ctx, `
INSERT INTO role_assignment_history (user_id, role, granted_by, granted_at)
VALUES ($1, $2, $3, now())
`, userID, string(role), grantedBy); err != nil {
return Assignment{}, fmt.Errorf("historie schreiben: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return Assignment{}, fmt.Errorf("transaktion committen: %w", err)
}
return Assignment{UserID: userID, Role: role, GrantedBy: grantedBy}, nil
}
func (s *Store) Get(ctx context.Context, userID string) (Assignment, error) {
var a Assignment
var role string
a.UserID = userID
if err := s.pool.QueryRow(ctx, `
SELECT role, granted_by FROM role_assignments WHERE user_id = $1
`, userID).Scan(&role, &a.GrantedBy); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return Assignment{}, ErrNotFound
}
return Assignment{}, fmt.Errorf("rollenzuweisung lesen: %w", err)
}
a.Role = Role(role)
return a, nil
}
// History liefert jede Rollenzuweisung eines Benutzers in chronologischer
// Reihenfolge — die Grundlage fuer "wer hat wann welche Rolle vergeben"
// (Akzeptanzkriterium 3).
func (s *Store) History(ctx context.Context, userID string) ([]Assignment, error) {
rows, err := s.pool.Query(ctx, `
SELECT role, granted_by FROM role_assignment_history
WHERE user_id = $1 ORDER BY granted_at
`, userID)
if err != nil {
return nil, fmt.Errorf("historie abfragen: %w", err)
}
defer rows.Close()
var out []Assignment
for rows.Next() {
var role string
a := Assignment{UserID: userID}
if err := rows.Scan(&role, &a.GrantedBy); err != nil {
return nil, fmt.Errorf("historieneintrag lesen: %w", err)
}
a.Role = Role(role)
out = append(out, a)
}
return out, rows.Err()
}
+90
View File
@@ -0,0 +1,90 @@
// Package saml implementiert Core IAM-11: SAML 2.0 als zusaetzlicher
// Anmeldeweg neben OIDC (IAM-06).
//
// WICHTIGER HINWEIS: Die Signaturpruefung hier verifiziert eine RSA-SHA256-
// Signatur ueber die deterministisch (re-)marshalte Assertion — das deckt
// dieselbe Sicherheitseigenschaft ab wie XML-DSig (nur ein gueltig
// signiertes Assertion eines vertrauten IdP wird akzeptiert), implementiert
// aber NICHT die vollstaendige W3C-Exclusive-XML-Canonicalization (C14N) mit
// all ihren Randfaellen (Namespace-Normalisierung, Attribut-Reihenfolge
// etc.), die produktive SAML-Bibliotheken brauchen, um mit echten
// Enterprise-IdPs (ADFS, Okta, Azure AD) byteidentisch zu sein. Das Ticket
// erlaubt ausdruecklich einen "simulierten IdP" fuer die Pruefungen — dieser
// Simulator und der Verifier hier verwenden folgerichtig dieselbe
// deterministische Kodierung, echte Interop mit einem Drittprodukt-IdP ist
// nicht Teil dieser Kachel.
package saml
import (
"encoding/xml"
"fmt"
"time"
)
type attributeXML struct {
Name string `xml:"Name,attr"`
Values []string `xml:"AttributeValue"`
}
type conditionsXML struct {
NotBefore string `xml:"NotBefore,attr"`
NotOnOrAfter string `xml:"NotOnOrAfter,attr"`
}
// AssertionXML ist die (vereinfachte) SAML-Assertion.
type AssertionXML struct {
XMLName xml.Name `xml:"Assertion"`
Issuer string `xml:"Issuer"`
NameID string `xml:"Subject>NameID"`
Conditions conditionsXML `xml:"Conditions"`
Attributes []attributeXML `xml:"AttributeStatement>Attribute"`
}
// ResponseXML umhuellt die Assertion mit der Signatur ueber deren kanonische
// Bytes (siehe Paket-Dokumentation).
type ResponseXML struct {
XMLName xml.Name `xml:"Response"`
Assertion AssertionXML `xml:"Assertion"`
SignatureValue string `xml:"SignatureValue"` // base64
}
// CanonicalAssertionBytes liefert die deterministischen Bytes, ueber die
// signiert/verifiziert wird.
func CanonicalAssertionBytes(a AssertionXML) ([]byte, error) {
b, err := xml.Marshal(a)
if err != nil {
return nil, fmt.Errorf("assertion serialisieren: %w", err)
}
return b, nil
}
// Attribute liefert die Werte eines benannten SAML-Attributs (z.B. "roles"),
// oder nil wenn nicht vorhanden.
func (a AssertionXML) Attribute(name string) []string {
for _, attr := range a.Attributes {
if attr.Name == name {
return attr.Values
}
}
return nil
}
// WithinValidity prueft die Conditions/NotBefore/NotOnOrAfter der Assertion
// gegen die aktuelle Zeit.
func (a AssertionXML) WithinValidity(now time.Time) error {
notBefore, err := time.Parse(time.RFC3339, a.Conditions.NotBefore)
if err != nil {
return fmt.Errorf("notbefore parsen: %w", err)
}
notOnOrAfter, err := time.Parse(time.RFC3339, a.Conditions.NotOnOrAfter)
if err != nil {
return fmt.Errorf("notonorafter parsen: %w", err)
}
if now.Before(notBefore) {
return ErrNotYetValid
}
if !now.Before(notOnOrAfter) {
return ErrExpired
}
return nil
}
+66
View File
@@ -0,0 +1,66 @@
package saml
import (
"context"
"errors"
"fmt"
"gitea.perlbach24.de/scripte/nexarch/internal/auth"
"gitea.perlbach24.de/scripte/nexarch/internal/ldapsync"
"gitea.perlbach24.de/scripte/nexarch/internal/rbac"
"gitea.perlbach24.de/scripte/nexarch/internal/user"
)
// CompleteSAMLLogin verifiziert die SAML-Response, bildet die Assertion auf
// das bestehende Benutzer-/Rollenmodell ab (Akzeptanzkriterium 3) und
// stellt bei Erfolg ein IAM-02-Sitzungs-Token aus. Rollen-Mapping laeuft
// ueber DIESELBE Erlaubnis-Matrix wie IAM-05/LDAP und IAM-06/OIDC
// (ldapsync.RoleMappingStore) — kein dritter paralleler Mapping-Mechanismus.
func CompleteSAMLLogin(
ctx context.Context,
verifier *Verifier,
users *user.TenantUserStore,
roles *rbac.Store,
roleMapping *ldapsync.RoleMappingStore,
issuer *auth.TokenIssuer,
tenantSlug, rawSAMLResponse string,
) (token string, err error) {
resp, err := Parse(rawSAMLResponse)
if err != nil {
return "", err
}
assertion, err := verifier.Verify(resp)
if err != nil {
return "", err
}
if assertion.NameID == "" {
return "", fmt.Errorf("%w: assertion ohne subject-nameid", ErrInvalidSignature)
}
u, err := findOrCreateUser(ctx, users, assertion.NameID)
if err != nil {
return "", err
}
roleAttrs := assertion.Attribute("roles")
if role, ok, err := roleMapping.HighestRoleFor(ctx, roleAttrs); err != nil {
return "", err
} else if ok {
if _, err := roles.Assign(ctx, u.ID, role, "saml-sso"); err != nil {
return "", err
}
}
return issuer.Issue(u.ID, tenantSlug)
}
func findOrCreateUser(ctx context.Context, users *user.TenantUserStore, email string) (user.User, error) {
creds, err := users.GetByEmailForAuth(ctx, email)
if err == nil {
return creds.User, nil
}
if !errors.Is(err, user.ErrNotFound) {
return user.User{}, err
}
return users.Create(ctx, email, email)
}
+227
View File
@@ -0,0 +1,227 @@
package saml
import (
"context"
"crypto/rsa"
"fmt"
"os"
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/internal/auth"
"gitea.perlbach24.de/scripte/nexarch/internal/ldapsync"
"gitea.perlbach24.de/scripte/nexarch/internal/oidc"
"gitea.perlbach24.de/scripte/nexarch/internal/rbac"
"gitea.perlbach24.de/scripte/nexarch/internal/user"
)
const schema = `
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), email TEXT NOT NULL UNIQUE, name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active', password_hash TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS role_assignments (
user_id UUID PRIMARY KEY REFERENCES users(id), role TEXT NOT NULL CHECK (role IN ('user','tenant_admin')),
granted_by TEXT NOT NULL, granted_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS role_assignment_history (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL, role TEXT NOT NULL,
granted_by TEXT NOT NULL, granted_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS ldap_group_role_mapping (
ldap_group TEXT PRIMARY KEY, role TEXT NOT NULL CHECK (role IN ('user','tenant_admin'))
);`
func setupTest(t *testing.T) (*pgxpool.Pool, func()) {
t.Helper()
adminDSN := os.Getenv("TEST_ADMIN_DSN")
if adminDSN == "" {
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, adminDSN)
if err != nil {
t.Fatalf("pool: %v", err)
}
if _, err := pool.Exec(ctx, schema); err != nil {
t.Fatalf("schema: %v", err)
}
cleanup := func() { pool.Close() }
return pool, cleanup
}
// Akzeptanzkriterium 1 + Pruefung 1: End-to-End-Test gegen simulierten SAML-IdP.
// Akzeptanzkriterium 3 + Pruefung 3: Rollenzuordnung aus SAML-Attributen korrekt.
func TestCompleteSAMLLogin_EndToEnd(t *testing.T) {
pool, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
idpKey := generateTestIdPKey(t)
verifier := NewVerifier(&idpKey.PublicKey, "https://test-idp.example.com")
users := user.NewTenantUserStore(pool)
roles := rbac.NewStore(pool)
roleMapping := ldapsync.NewRoleMappingStore(pool)
issuer := auth.NewTokenIssuer("test-secret-nur-fuer-tests")
if err := roleMapping.SetMapping(ctx, "saml-admins", rbac.RoleTenantAdmin); err != nil {
t.Fatalf("set mapping: %v", err)
}
email := fmt.Sprintf("saml-test-%d@example.com", time.Now().UnixNano())
rawResp, err := BuildResponse(idpKey, testAssertion("https://test-idp.example.com", email, []string{"saml-admins"}))
if err != nil {
t.Fatalf("build response (simulierter idp): %v", err)
}
token, err := CompleteSAMLLogin(ctx, verifier, users, roles, roleMapping, issuer, "acme", rawResp)
if err != nil {
t.Fatalf("complete saml login: %v", err)
}
if token == "" {
t.Fatal("erwartet nicht-leeres sitzungs-token")
}
creds, err := users.GetByEmailForAuth(ctx, email)
if err != nil {
t.Fatalf("user nach login: %v", err)
}
assignment, err := roles.Get(ctx, creds.User.ID)
if err != nil {
t.Fatalf("rollenzuweisung lesen: %v", err)
}
if assignment.Role != rbac.RoleTenantAdmin {
t.Fatalf("erwartet tenant_admin aus saml-admins-mapping, habe %q", assignment.Role)
}
}
// Akzeptanzkriterium 3 + Pruefung 3 (Negativfall): keine Privilege-
// Escalation ueber unbekannte/manipulierte Rollen-Attribute.
func TestCompleteSAMLLogin_UnmappedRoleGrantsNothing(t *testing.T) {
pool, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
idpKey := generateTestIdPKey(t)
verifier := NewVerifier(&idpKey.PublicKey, "https://test-idp.example.com")
users := user.NewTenantUserStore(pool)
roles := rbac.NewStore(pool)
roleMapping := ldapsync.NewRoleMappingStore(pool)
issuer := auth.NewTokenIssuer("test-secret-nur-fuer-tests")
email := fmt.Sprintf("saml-test-%d@example.com", time.Now().UnixNano())
rawResp, err := BuildResponse(idpKey, testAssertion("https://test-idp.example.com", email, []string{"tenant_admin", "superadmin"}))
if err != nil {
t.Fatalf("build response: %v", err)
}
if _, err := CompleteSAMLLogin(ctx, verifier, users, roles, roleMapping, issuer, "acme", rawResp); err != nil {
t.Fatalf("complete saml login: %v", err)
}
creds, err := users.GetByEmailForAuth(ctx, email)
if err != nil {
t.Fatalf("user nach login: %v", err)
}
if _, err := roles.Get(ctx, creds.User.ID); err == nil {
t.Fatal("erwartet KEINE rollenzuweisung fuer nicht gemappte rollen-attribute")
}
}
// Akzeptanzkriterium 2 + Pruefung 2: SAML- und OIDC-Anmeldung koennen
// nebeneinander fuer unterschiedliche Tenants konfiguriert sein — hier durch
// zwei physisch getrennte Tenant-Datenbanken demonstriert (Modell C).
func TestSAMLAndOIDC_WorkInParallelForDifferentTenants(t *testing.T) {
adminDSN := os.Getenv("TEST_ADMIN_DSN")
if adminDSN == "" {
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
}
ctx := context.Background()
poolSAML, cleanupSAML := setupTest(t)
defer cleanupSAML()
poolOIDC, err := pgxpool.New(ctx, adminDSN)
if err != nil {
t.Fatalf("pool oidc: %v", err)
}
defer poolOIDC.Close()
if _, err := poolOIDC.Exec(ctx, schema+`
CREATE TABLE IF NOT EXISTS oidc_states (
state TEXT PRIMARY KEY, nonce TEXT NOT NULL, expires_at TIMESTAMPTZ NOT NULL,
used_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)`); err != nil {
t.Fatalf("schema oidc: %v", err)
}
// Tenant "saml-tenant": SAML konfiguriert.
idpKey := generateTestIdPKey(t)
samlVerifier := NewVerifier(&idpKey.PublicKey, "https://test-idp.example.com")
samlUsers := user.NewTenantUserStore(poolSAML)
samlRoles := rbac.NewStore(poolSAML)
samlMapping := ldapsync.NewRoleMappingStore(poolSAML)
issuer := auth.NewTokenIssuer("test-secret-nur-fuer-tests")
samlEmail := fmt.Sprintf("saml-parallel-%d@example.com", time.Now().UnixNano())
samlResp, err := BuildResponse(idpKey, testAssertion("https://test-idp.example.com", samlEmail, nil))
if err != nil {
t.Fatalf("build saml response: %v", err)
}
samlToken, err := CompleteSAMLLogin(ctx, samlVerifier, samlUsers, samlRoles, samlMapping, issuer, "saml-tenant", samlResp)
if err != nil {
t.Fatalf("saml-tenant login: %v", err)
}
if samlToken == "" {
t.Fatal("erwartet token fuer saml-tenant")
}
// Tenant "oidc-tenant": OIDC konfiguriert, voellig unabhaengige Datenbank.
oidcKey := generateTestIdPKey(t)
oidcVerifier := oidc.NewVerifier(map[string]*rsa.PublicKey{"kid-1": &oidcKey.PublicKey}, "https://oidc-idp.example.com")
oidcStates := oidc.NewStateStore(poolOIDC)
oidcUsers := user.NewTenantUserStore(poolOIDC)
oidcRoles := rbac.NewStore(poolOIDC)
oidcMapping := ldapsync.NewRoleMappingStore(poolOIDC)
state, nonce, err := oidcStates.Generate(ctx)
if err != nil {
t.Fatalf("oidc generate state: %v", err)
}
oidcEmail := fmt.Sprintf("oidc-parallel-%d@example.com", time.Now().UnixNano())
claims := oidc.Claims{
Email: oidcEmail,
Nonce: nonce,
RegisteredClaims: jwt.RegisteredClaims{
Issuer: "https://oidc-idp.example.com", ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
},
}
idToken := signOIDCTestToken(t, oidcKey, claims)
oidcToken, err := oidc.CompleteOIDCLogin(ctx, oidcVerifier, oidcStates, oidcUsers, oidcRoles, oidcMapping, issuer, "oidc-tenant", state, idToken)
if err != nil {
t.Fatalf("oidc-tenant login: %v", err)
}
if oidcToken == "" {
t.Fatal("erwartet token fuer oidc-tenant")
}
if samlToken == oidcToken {
t.Fatal("tokens sollten unterschiedlich sein")
}
}
func signOIDCTestToken(t *testing.T, key *rsa.PrivateKey, claims oidc.Claims) string {
t.Helper()
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
token.Header["kid"] = "kid-1"
s, err := token.SignedString(key)
if err != nil {
t.Fatalf("sign oidc test token: %v", err)
}
return s
}
+107
View File
@@ -0,0 +1,107 @@
package saml
import (
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"encoding/base64"
"encoding/xml"
"errors"
"fmt"
"time"
)
var (
ErrInvalidSignature = errors.New("saml: assertion-signatur ungueltig")
ErrNotYetValid = errors.New("saml: assertion noch nicht gueltig")
ErrExpired = errors.New("saml: assertion abgelaufen")
ErrWrongIssuer = errors.New("saml: unerwarteter aussteller")
)
// Verifier prueft SAML-Responses gegen den oeffentlichen Schluessel eines
// konfigurierten IdP (Akzeptanzkriterium 1).
type Verifier struct {
idpPublicKey *rsa.PublicKey
idpIssuer string
}
func NewVerifier(idpPublicKey *rsa.PublicKey, idpIssuer string) *Verifier {
return &Verifier{idpPublicKey: idpPublicKey, idpIssuer: idpIssuer}
}
// Parse dekodiert eine base64-kodierte SAMLResponse (wie sie im
// SAMLResponse-Formularfeld ankommt) in ihre XML-Struktur.
func Parse(rawBase64 string) (ResponseXML, error) {
data, err := base64.StdEncoding.DecodeString(rawBase64)
if err != nil {
return ResponseXML{}, fmt.Errorf("base64 dekodieren: %w", err)
}
var resp ResponseXML
if err := xml.Unmarshal(data, &resp); err != nil {
return ResponseXML{}, fmt.Errorf("saml-xml parsen: %w", err)
}
return resp, nil
}
// Verify prueft Signatur, Gueltigkeitszeitraum und Aussteller
// (Akzeptanzkriterium 1).
func (v *Verifier) Verify(resp ResponseXML) (AssertionXML, error) {
sig, err := base64.StdEncoding.DecodeString(resp.SignatureValue)
if err != nil {
return AssertionXML{}, fmt.Errorf("signatur dekodieren: %w", err)
}
canonical, err := CanonicalAssertionBytes(resp.Assertion)
if err != nil {
return AssertionXML{}, err
}
hash := sha256.Sum256(canonical)
if err := rsa.VerifyPKCS1v15(v.idpPublicKey, crypto.SHA256, hash[:], sig); err != nil {
return AssertionXML{}, ErrInvalidSignature
}
if resp.Assertion.Issuer != v.idpIssuer {
return AssertionXML{}, fmt.Errorf("%w: %q", ErrWrongIssuer, resp.Assertion.Issuer)
}
if err := resp.Assertion.WithinValidity(time.Now()); err != nil {
return AssertionXML{}, err
}
return resp.Assertion, nil
}
// Sign signiert eine Assertion mit dem privaten IdP-Schluessel — wird vom
// (simulierten) Test-IdP verwendet, um eine gueltige Response zu erzeugen.
// Lebt hier statt nur in Tests, da ein echter Test-IdP-Modus fuer
// Entwicklungszwecke (siehe Ticket: "sinnvolle Defaults ohne SAML-
// Expertenwissen") denselben Signiervorgang braucht.
func Sign(priv *rsa.PrivateKey, assertion AssertionXML) (string, error) {
canonical, err := CanonicalAssertionBytes(assertion)
if err != nil {
return "", err
}
hash := sha256.Sum256(canonical)
sig, err := rsa.SignPKCS1v15(rand.Reader, priv, crypto.SHA256, hash[:])
if err != nil {
return "", fmt.Errorf("assertion signieren: %w", err)
}
return base64.StdEncoding.EncodeToString(sig), nil
}
// BuildResponse verpackt eine signierte Assertion in eine base64-kodierte
// SAMLResponse, wie sie ein IdP im Browser-POST-Binding zurueckliefert.
func BuildResponse(priv *rsa.PrivateKey, assertion AssertionXML) (string, error) {
sig, err := Sign(priv, assertion)
if err != nil {
return "", err
}
resp := ResponseXML{Assertion: assertion, SignatureValue: sig}
data, err := xml.Marshal(resp)
if err != nil {
return "", fmt.Errorf("response serialisieren: %w", err)
}
return base64.StdEncoding.EncodeToString(data), nil
}
+123
View File
@@ -0,0 +1,123 @@
package saml
import (
"crypto/rand"
"crypto/rsa"
"testing"
"time"
)
func generateTestIdPKey(t *testing.T) *rsa.PrivateKey {
t.Helper()
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("generate rsa key: %v", err)
}
return key
}
func testAssertion(issuer, nameID string, roles []string) AssertionXML {
now := time.Now()
return AssertionXML{
Issuer: issuer,
NameID: nameID,
Conditions: conditionsXML{
NotBefore: now.Add(-time.Minute).Format(time.RFC3339),
NotOnOrAfter: now.Add(time.Hour).Format(time.RFC3339),
},
Attributes: []attributeXML{
{Name: "roles", Values: roles},
},
}
}
func TestVerify_AcceptsValidSignedAssertion(t *testing.T) {
key := generateTestIdPKey(t)
verifier := NewVerifier(&key.PublicKey, "https://idp.example.com")
rawResp, err := BuildResponse(key, testAssertion("https://idp.example.com", "person@example.com", []string{"sso-admins"}))
if err != nil {
t.Fatalf("build response: %v", err)
}
resp, err := Parse(rawResp)
if err != nil {
t.Fatalf("parse: %v", err)
}
assertion, err := verifier.Verify(resp)
if err != nil {
t.Fatalf("verify: %v", err)
}
if assertion.NameID != "person@example.com" {
t.Fatalf("nameid = %q", assertion.NameID)
}
}
func TestVerify_RejectsTamperedAssertion(t *testing.T) {
key := generateTestIdPKey(t)
verifier := NewVerifier(&key.PublicKey, "https://idp.example.com")
rawResp, err := BuildResponse(key, testAssertion("https://idp.example.com", "person@example.com", nil))
if err != nil {
t.Fatalf("build response: %v", err)
}
resp, err := Parse(rawResp)
if err != nil {
t.Fatalf("parse: %v", err)
}
// Manipulation NACH dem Signieren: NameID aendern (Signatur bleibt die alte).
resp.Assertion.NameID = "angreifer@example.com"
if _, err := verifier.Verify(resp); err != ErrInvalidSignature {
t.Fatalf("erwartet ErrInvalidSignature, habe %v", err)
}
}
func TestVerify_RejectsWrongIdPKey(t *testing.T) {
realKey := generateTestIdPKey(t)
attackerKey := generateTestIdPKey(t)
verifier := NewVerifier(&realKey.PublicKey, "https://idp.example.com")
rawResp, err := BuildResponse(attackerKey, testAssertion("https://idp.example.com", "person@example.com", nil))
if err != nil {
t.Fatalf("build response: %v", err)
}
resp, _ := Parse(rawResp)
if _, err := verifier.Verify(resp); err != ErrInvalidSignature {
t.Fatalf("erwartet ErrInvalidSignature bei fremd signierter assertion, habe %v", err)
}
}
func TestVerify_RejectsExpiredAssertion(t *testing.T) {
key := generateTestIdPKey(t)
verifier := NewVerifier(&key.PublicKey, "https://idp.example.com")
expired := testAssertion("https://idp.example.com", "person@example.com", nil)
expired.Conditions.NotOnOrAfter = time.Now().Add(-time.Hour).Format(time.RFC3339)
rawResp, err := BuildResponse(key, expired)
if err != nil {
t.Fatalf("build response: %v", err)
}
resp, _ := Parse(rawResp)
if _, err := verifier.Verify(resp); err != ErrExpired {
t.Fatalf("erwartet ErrExpired, habe %v", err)
}
}
func TestVerify_RejectsWrongIssuer(t *testing.T) {
key := generateTestIdPKey(t)
verifier := NewVerifier(&key.PublicKey, "https://idp.example.com")
rawResp, err := BuildResponse(key, testAssertion("https://anderer-idp.example.com", "person@example.com", nil))
if err != nil {
t.Fatalf("build response: %v", err)
}
resp, _ := Parse(rawResp)
if _, err := verifier.Verify(resp); err == nil {
t.Fatal("erwartet fehler bei unerwartetem aussteller")
}
}
+151
View File
@@ -0,0 +1,151 @@
// Package serviceaccount implementiert Core IAM-09: Service-Accounts als
// eigenstaendige Identitaeten (getrennt von menschlichen Benutzern, siehe
// internal/user) sowie API-Token mit Scopes und optionaler Ablaufzeit zur
// programmatischen Authentifizierung.
package serviceaccount
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
var (
ErrTokenInvalid = errors.New("serviceaccount: token ungueltig, widerrufen oder abgelaufen")
ErrScopeInsufficient = errors.New("serviceaccount: token hat nicht den erforderlichen scope")
ErrServiceAccountNotFound = errors.New("serviceaccount: nicht gefunden")
)
type ServiceAccount struct {
ID string
Name string
}
type Token struct {
ID string
ServiceAccountID string
Scopes []string
ExpiresAt *time.Time
}
type Store struct {
pool *pgxpool.Pool
}
func NewStore(pool *pgxpool.Pool) *Store {
return &Store{pool: pool}
}
// CreateServiceAccount legt eine neue, eigenstaendige Identitaet an
// (Akzeptanzkriterium 1) — lebt in service_accounts, nicht in users.
func (s *Store) CreateServiceAccount(ctx context.Context, name string) (ServiceAccount, error) {
if name == "" {
return ServiceAccount{}, errors.New("serviceaccount: name darf nicht leer sein")
}
var sa ServiceAccount
sa.Name = name
if err := s.pool.QueryRow(ctx, `
INSERT INTO service_accounts (name) VALUES ($1) RETURNING id
`, name).Scan(&sa.ID); err != nil {
return ServiceAccount{}, fmt.Errorf("service-account anlegen: %w", err)
}
return sa, nil
}
// IssueToken erzeugt ein neues API-Token fuer einen Service-Account.
// scopes beschraenkt, wofuer das Token verwendet werden darf; ttl ist
// optional (nil = kein Ablauf) — beides Akzeptanzkriterium 2. Der
// Klartext-Token wird NUR hier zurueckgegeben, gespeichert wird ausschliesslich
// der Hash (Akzeptanzkriterium 1 / Pruefung 1).
func (s *Store) IssueToken(ctx context.Context, serviceAccountID string, scopes []string, ttl *time.Duration) (id, plaintext string, err error) {
plaintext, err = randomToken()
if err != nil {
return "", "", fmt.Errorf("token erzeugen: %w", err)
}
hash := hashToken(plaintext)
if scopes == nil {
scopes = []string{}
}
var expiresAt *time.Time
if ttl != nil {
t := time.Now().Add(*ttl)
expiresAt = &t
}
err = s.pool.QueryRow(ctx, `
INSERT INTO api_tokens (service_account_id, token_hash, scopes, expires_at)
VALUES ($1, $2, $3, $4)
RETURNING id
`, serviceAccountID, hash, scopes, expiresAt).Scan(&id)
if err != nil {
return "", "", fmt.Errorf("token speichern: %w", err)
}
return id, plaintext, nil
}
// Verify prueft ein Token gegen einen benoetigten Scope. Widerruf und Ablauf
// werden bei JEDEM Aufruf direkt gegen die Datenbank geprueft — es gibt
// keinen Cache, ein widerrufenes Token wird beim naechsten Request sofort
// abgewiesen (Akzeptanzkriterium 3). requiredScope == "" ueberspringt die
// Scope-Pruefung.
func (s *Store) Verify(ctx context.Context, tokenString, requiredScope string) (Token, error) {
hash := hashToken(tokenString)
var tok Token
err := s.pool.QueryRow(ctx, `
SELECT id, service_account_id, scopes, expires_at
FROM api_tokens
WHERE token_hash = $1 AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > now())
`, hash).Scan(&tok.ID, &tok.ServiceAccountID, &tok.Scopes, &tok.ExpiresAt)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return Token{}, ErrTokenInvalid
}
return Token{}, fmt.Errorf("token pruefen: %w", err)
}
if requiredScope != "" && !hasScope(tok.Scopes, requiredScope) {
return Token{}, ErrScopeInsufficient
}
return tok, nil
}
// Revoke widerruft ein Token sofort — Verify schlaegt ab dem naechsten
// Aufruf fehl (Akzeptanzkriterium 3 / Pruefung 2).
func (s *Store) Revoke(ctx context.Context, tokenID string) error {
_, err := s.pool.Exec(ctx, `UPDATE api_tokens SET revoked_at = now() WHERE id = $1`, tokenID)
if err != nil {
return fmt.Errorf("token widerrufen: %w", err)
}
return nil
}
func hasScope(scopes []string, want string) bool {
for _, s := range scopes {
if s == want {
return true
}
}
return false
}
func randomToken() (string, error) {
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return hex.EncodeToString(buf), nil
}
func hashToken(token string) []byte {
sum := sha256.Sum256([]byte(token))
return sum[:]
}

Some files were not shown because too many files have changed in this diff Show More