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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
internal/rbac/group.go: GroupStore verwaltet Gruppen innerhalb GENAU EINER
Tenant-Datenbank (Modell C, analog Store/TenantUserStore) — Tenant-Isolation
ist damit strukturell erfuellt, keine zusaetzliche Filterlogik noetig
(Akzeptanzkriterium 3). Nur 'user'/'tenant_admin' sind auf Gruppenebene
zuweisbar (dieselbe assignableRoles-Matrix wie bei direkter Zuweisung) —
superadmin bleibt mandantenuebergreifend und ausserhalb jeder Gruppenlogik.
EffectivePermissionsForUser vereinigt die direkte Rollenzuweisung (RBAC-01
Store) mit allen Rechten aus Gruppenrollen, live berechnet bei jedem Aufruf
statt zwischengespeichert — RemoveMember/DeleteGroup wirken dadurch sofort
(Akzeptanzkriterium 3). ON DELETE CASCADE auf group_members entzieht beim
Loeschen einer Gruppe die Mitgliedschaften automatisch, Benutzerkonten
selbst bleiben unberuehrt.
Pruefungen (ausgefuehrt auf root@192.168.1.131, go build/vet/test PASS):
1. Gruppenrolle wirkt korrekt auf ALLE aktuellen Mitglieder —
TestGroup_RoleAffectsAllCurrentMembers: zwei Mitglieder, beide erhalten
die Gruppenrolle-Rechte. PASS.
2. Entfernen eines Benutzers aus der Gruppe entzieht Rechte sofort —
TestGroup_RemoveMemberRevokesRightsImmediately. PASS.
3. Gruppen sauber tenant-isoliert (Stichprobe ueber zwei Tenants) —
TestGroup_TenantIsolation: Gruppe in Tenant A taucht in Tenant B nicht
auf. PASS.
Zusaetzlich: TestGroup_DeleteGroupRevokesRightsWithoutDeletingUser belegt
Akzeptanzkriterium 3 (Loeschung ohne Benutzerkonto-Verlust) konkret. PASS.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
internal/policy: deklarativer, DB-gehaltener Regelsatz (policy_rules) statt
hartcodierter Go-Entscheidungslogik — Store.IsAllowed schaut ausschliesslich
in die Datenbank, kein Go-Fallback. Ein Regelwechsel (Grant/Revoke) wirkt
sich sofort aus, ohne Codeaenderung/Deploy (Akzeptanzkriterium 3). Jede
Aenderung wird atomar mit einem versionierten Historieneintrag in
policy_rule_changes festgehalten (grant/revoke, Akteur, Version).
Enforcer.Authorize ist Default-Deny: existiert keine Regel fuer role+
permission, ist der Zugriff verboten (Akzeptanzkriterium 2), fuer sich
genommen ohne Anwendungslogik testbar.
Guard/GuardTenantScoped sind die zentrale Enforcement-Funktion
(Akzeptanzkriterium 1): die uebergebene Query-Funktion wird NUR bei
erfolgreicher Autorisierung aufgerufen — es gibt keinen Weg, Daten ohne
vorherige Authorize-Entscheidung zu erhalten. GuardTenantScoped erzwingt
zusaetzlich per Funktionssignatur, dass tenantSlug TEIL der Query-Funktion
ist (Akzeptanzkriterium 3) — ein nachgelagerter Post-Filter (der
archivmail-Fehler aus "Bekannte Fehler vermeiden": Tenant-Filter nach statt
in der Query) ist mit dieser Signatur strukturell nicht moeglich, da die
Repository-Implementierung tenantSlug selbst fuer ihre eigene WHERE-Klausel
entgegennimmt.
Pruefungen (ausgefuehrt auf root@192.168.1.131, go build/vet/test PASS):
1. Kein Datenzugriffs-Pfad umgeht die zentrale Enforcement-Schicht —
TestGuard_NeverCallsQueryWithoutAuthorization: query-Funktion wird
nachweislich NICHT aufgerufen ohne vorherige Regel, erst nach Grant. PASS.
2. Anfrage ohne passende Policy wird zuverlaessig abgewiesen (Default-Deny) —
TestAuthorize_DefaultDeny: keine Regel konfiguriert -> ErrDenied, nicht
automatisch erlaubt. PASS.
3. Policy-Regelsatz versioniert, Regelwechsel ohne Codeaenderung
nachvollziehbar — TestGrantRevoke_ChangesBehaviorWithoutCodeChange:
Verhalten aendert sich durch reinen Datenbank-Grant/Revoke, Historie
zeigt beide Versionen korrekt. PASS.
Zusaetzlich: TestGuardTenantScoped_IsolatesDataBetweenTenants belegt das
Tenant-Scoping-Muster aus Akzeptanzkriterium 3 konkret anhand zweier
Tenants. PASS.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
internal/moduleregistry: Registry.Register traegt Fachmodule mit Name,
Version und benoetigten Feature-Flags ein (Akzeptanzkriterium 1), fehlende
Pflichtangaben werden abgewiesen. IsActive kombiniert Registrierung + LIC-02
Feature-Flag-Auswertung (ALLE benoetigten Flags muessen fuer den Tenant
aktiv sein) — ein nicht registriertes Modul ist nie aktiv. List liefert alle
Module fuer Statusseite/Lizenzoberflaeche (Akzeptanzkriterium 3).
RequireActiveModule ist die zentrale Durchsetzungs-Middleware (Casbin-
Prinzip): weist Anfragen an ein deaktiviertes Modul ab, BEVOR der
Modul-Handler ueberhaupt aufgerufen wird (Akzeptanzkriterium 2) —
Pruefung per Test belegt, dass der Handler bei Deaktivierung nachweislich
nicht erreicht wird.
Service-Credentials (Akzeptanzkriterium 4): Registry.Provision stellt pro
Modul-Instanz Client-ID + Secret aus, gespeichert wird nur der SHA-256-Hash
des Secrets. Registry.Authenticate vergleicht timing-safe (dieselbe
subtle.ConstantTimeCompare-Referenzimplementierung wie AUD-02).
RequireServiceCredential-Middleware liest X-Client-Id/X-Client-Secret und
weist Aufrufe ohne gueltiges Credential mit 401 ab, bevor der Core-seitige
Endpunkt (z.B. Audit-Nachlieferung, Nutzungsmeldung) erreicht wird.
Pruefungen (ausgefuehrt auf root@192.168.1.131, go build/vet/test PASS):
1. Anfrage an deaktiviertes Modul nachweislich vor Modul-Logik abgewiesen —
TestRequireActiveModule_BlocksBeforeHandler: handlerReached bleibt false
bei 403, wird true erst nach Aktivierung bei 200. PASS.
2. Registrierung mit fehlenden Pflichtangaben abgewiesen —
TestRegister_RejectsMissingFields (leerer Name, leere Version). PASS.
3. Registry-Abfrage liefert konsistente Daten nach Aktivierung/Deaktivierung —
TestIsActive_ReflectsFlagStateConsistently: aus/an/aus-Zyklus, IsActive
folgt dem Flag-Zustand korrekt. PASS.
4. Aufruf mit ungueltigem/fehlendem Service-Credential abgewiesen, mit
gueltigem angenommen — TestRequireServiceCredential_RejectsInvalidAcceptsValid
und TestProvisionAndAuthenticate (falsches Secret, unbekannte Client-ID,
korrektes Credential). PASS.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
internal/rbac: drei Grundrollen (superadmin, tenant_admin, user) mit
Hierarchie ueber eine einfache Eltern-Map (tenant_admin erbt von user,
superadmin erbt von tenant_admin) — EffectivePermissions loest die volle
vererbte Rechtemenge auf (Akzeptanzkriterium 2). Policy-Modell bewusst als
reine Go-Datenstruktur getrennt von der Durchsetzung (RBAC-02), nach
Casbin-Prinzip.
Store verwaltet Rollenzuweisungen innerhalb EINER Tenant-Datenbank (Modell C,
analog internal/user.TenantUserStore) — nur 'user' und 'tenant_admin' sind
hier zuweisbar (assignableRoles-Matrix). Ein Zuweisungsversuch fuer
'superadmin' wird abgewiesen, da diese Rolle mandantenuebergreifend ist und
bereits durch die Existenz eines Kontos in IAM-01s SuperadminStore
repraesentiert wird — keine doppelte Modellierung. Jede Zuweisung schreibt
zusaetzlich einen Historieneintrag (role_assignment_history) mit
grantedBy/grantedAt, atomar in derselben Transaktion (Akzeptanzkriterium 3).
Pruefungen (ausgefuehrt auf root@192.168.1.131, go build/vet/test PASS):
1. Zuweisung ausserhalb der erlaubten Matrix abgewiesen —
TestStore_RejectsSuperadminOutsideAllowedMatrix und
TestStore_RejectsUnknownRole: beide ErrRoleNotAssignableInTenantScope,
kein Datensatz hinterlassen. PASS.
2. Rollenhierarchie liefert erwartete effektive Rechtemenge —
TestEffectivePermissions_Inheritance: tenant_admin hat geerbte
user-Rechte + eigene, aber nicht platform.manage_tenants; superadmin hat
die volle Kette. PASS.
3. Datenmodell von zweiter Person gegen Dokumentation geprueft — NICHT
durchgefuehrt (keine zweite Person in dieser Session verfuegbar). Offen.
Zusaetzlich automatisiert getestet (Akzeptanzkriterium 3):
TestStore_HistoryTracksWhoAndWhen — zwei aufeinanderfolgende Zuweisungen,
Historie liefert beide mit korrektem grantedBy in chronologischer
Reihenfolge. PASS.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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>
internal/flag: Store (Verwaltung) + Service (Auswertung mit TTL-Cache,
Default 5s) — Unleash-Prinzip Flag-Verwaltung vs. Flag-Auswertung getrennt,
als Kernfunktion des Core-Dienstes selbst statt separater Infrastruktur.
evaluate() wendet drei Strategien in fester Reihenfolge an: global an/aus,
Tenant-Zielgruppe, deterministischer Prozentsatz-Rollout (FNV-Hash aus
Tenant+Key, stabil pro Tenant). IsEnabled liefert IMMER nur bool (kein
Fehlerwert) — ein nicht erreichbarer Flag-Dienst kann damit keinen
Aufrufer zum Absturz bringen: bei DB-Fehler wird der zuletzt bekannte
Cache-Stand verwendet, ohne jeglichen Stand faellt der Dienst sicher auf
false zurueck. Service.Invalidate erzwingt sofortiges Neuladen fuer den
Schreiber selbst, andere Instanzen sehen Aenderungen spaetestens nach der
TTL (Akzeptanzkriterium 3, kein Neustart noetig).
Bugfix waehrend Tests: Store.Set uebergab ein nil-TargetTenantSlugs-Slice
als SQL NULL statt leerem Array (NOT-NULL-Verletzung) — auf leeres Slice
normalisiert.
Akzeptanzkriterium 4 (Deaktivierung loescht keine Daten): dieses Paket
besitzt ausschliesslich die eigene feature_flags-Zeile, hat keinerlei
Code-Pfad, der Modul-Geschaeftsdaten anfassen koennte — Loeschung bleibt
strukturell der Archive-Retention-Engine vorbehalten.
Pruefungen (ausgefuehrt auf root@192.168.1.131, go build/vet/test PASS):
1. Cache-Invalidierungszeit automatisiert gemessen —
TestService_CacheInvalidationTiming: Aenderung wirksam nach 153ms bei
TTL=150ms (innerhalb Ziel+Toleranz), vorher nachweislich noch alter Stand. PASS.
2. Zielgruppen-Strategie liefert erwartete Auswertung —
TestService_TargetTenantStrategy / TestEvaluate_TargetTenantStrategy. PASS.
3. Ausfall des Flag-Dienstes fuehrt zu dokumentiertem Fallback, kein Absturz —
TestService_FallsBackOnStoreFailure (mit recover()-Absicherung): Fallback
auf Cache-Stand bzw. sicheres false bei komplett unerreichbarer DB, geloggt. PASS.
4. Modul-Deaktivierung/Reaktivierung ohne Datenverlust — architektonisch durch
fehlenden Code-Pfad sichergestellt (siehe oben), zusaetzlich durch
TestService_InvalidateForcesImmediateRefresh (Toggle aus/an bleibt
konsistent nachvollziehbar) mitabgedeckt. PASS.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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>
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>
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>
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>