Compare commits

..
Author SHA1 Message Date
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 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
26 changed files with 1310 additions and 2 deletions
+5 -2
View File
@@ -2,13 +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/crypto v0.17.0 // indirect
golang.org/x/sync v0.1.0 // indirect
golang.org/x/text v0.14.0 // indirect
)
+2
View File
@@ -1,6 +1,8 @@
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=
+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")
}
}
+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")
}
}
+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
}
+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()
}
+45
View File
@@ -103,6 +103,51 @@ func (s *TenantUserStore) Deactivate(ctx context.Context, id string) (User, erro
`, id, StatusInactive))
}
// SetPasswordHash schreibt einen bereits berechneten bcrypt-Hash (siehe
// internal/auth, IAM-02). Der Store selbst kennt kein Klartext-Passwort.
func (s *TenantUserStore) SetPasswordHash(ctx context.Context, id, hash string) error {
tag, err := s.pool.Exec(ctx, `
UPDATE users SET password_hash = $2, updated_at = now() WHERE id = $1
`, id, hash)
if err != nil {
return fmt.Errorf("passwort setzen: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
// AuthCredentials wird ausschliesslich fuer den Login-Pfad (internal/auth)
// verwendet und traegt bewusst den password_hash, damit er nicht ueber den
// regulaeren User-Typ/JSON-Serialisierungspfad nach aussen dringen kann.
type AuthCredentials struct {
User User
PasswordHash string
}
// GetByEmailForAuth liefert Benutzer + Passwort-Hash zu einer E-Mail-Adresse
// aus GENAU DIESER Tenant-Datenbank — der Tenant-Scope ergibt sich damit
// zwingend aus dem verwendeten Pool, es gibt keine Moeglichkeit, versehentlich
// ueber Tenant-Grenzen hinweg zu suchen (bekannter archivmail-Fehler, siehe
// IAM-02 "Bekannte Fehler vermeiden").
func (s *TenantUserStore) GetByEmailForAuth(ctx context.Context, email string) (AuthCredentials, error) {
var c AuthCredentials
row := s.pool.QueryRow(ctx, `
SELECT id, email, name, status, created_at, updated_at, password_hash
FROM users WHERE email = $1
`, email)
if err := row.Scan(&c.User.ID, &c.User.Email, &c.User.Name, &c.User.Status,
&c.User.CreatedAt, &c.User.UpdatedAt, &c.PasswordHash); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return AuthCredentials{}, ErrNotFound
}
return AuthCredentials{}, fmt.Errorf("anmeldedaten lesen: %w", err)
}
return c, nil
}
func scanUser(row pgx.Row) (User, error) {
var u User
if err := row.Scan(&u.ID, &u.Email, &u.Name, &u.Status, &u.CreatedAt, &u.UpdatedAt); err != nil {
@@ -0,0 +1,2 @@
DROP TABLE IF EXISTS role_assignment_history;
DROP TABLE IF EXISTS role_assignments;
@@ -0,0 +1,23 @@
-- Rollenzuweisung pro Benutzer (RBAC-01, siehe core-kanban/tickets/RBAC-01.md).
-- Nur 'user' und 'tenant_admin' sind hier zuweisbar — 'superadmin' lebt
-- mandantenuebergreifend in der Registry (IAM-01 SuperadminStore) und hat
-- daher bewusst KEINE Zeile in dieser tenant-lokalen Tabelle (Akzeptanz-
-- kriterium 1: nur Zuweisungen innerhalb der erlaubten Matrix).
CREATE TABLE 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()
);
-- Vollstaendige Historie jeder Rollenaenderung (Akzeptanzkriterium 3: wer
-- hat wann welche Rolle vergeben).
CREATE TABLE 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 INDEX role_assignment_history_user_idx ON role_assignment_history (user_id, granted_at);
@@ -0,0 +1 @@
ALTER TABLE users DROP COLUMN password_hash;
@@ -0,0 +1,3 @@
-- Passwort-Hash-Spalte fuer Login (IAM-02, siehe core-kanban/tickets/IAM-02.md).
-- Enthaelt AUSSCHLIESSLICH den bcrypt-Hash, niemals das Klartext-Passwort.
ALTER TABLE users ADD COLUMN password_hash TEXT NOT NULL DEFAULT '';
+2
View File
@@ -0,0 +1,2 @@
DROP TABLE IF EXISTS ldap_group_role_mapping;
DROP TABLE IF EXISTS ldap_config;
+22
View File
@@ -0,0 +1,22 @@
-- LDAP/Active-Directory-Anbindung (IAM-05, siehe core-kanban/tickets/IAM-05.md).
-- Bind-Credentials liegen NIE in dieser Tabelle im Klartext — bind_password_env
-- nennt nur den Namen einer Umgebungsvariable, aus der das Passwort zur
-- Laufzeit gelesen wird (Projekt-Konvention: Secrets nur ueber Env-Vars).
CREATE TABLE ldap_config (
id BOOLEAN PRIMARY KEY DEFAULT true CHECK (id),
url TEXT NOT NULL,
bind_dn TEXT NOT NULL,
bind_password_env TEXT NOT NULL,
base_dn TEXT NOT NULL,
user_filter TEXT NOT NULL DEFAULT '(objectClass=person)',
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Erlaubnis-Matrix LDAP-Gruppe -> NEXARCH-Rolle (Akzeptanzkriterium 3).
-- Eine LDAP-Gruppe OHNE Eintrag hier vergibt KEINE Rolle — kein impliziter
-- Zugriff durch Gruppennamen-Zufall (Privilege-Escalation-Schutz, siehe
-- "Bekannte Fehler vermeiden" im Ticket).
CREATE TABLE ldap_group_role_mapping (
ldap_group TEXT PRIMARY KEY,
role TEXT NOT NULL CHECK (role IN ('user', 'tenant_admin'))
);
+11
View File
@@ -0,0 +1,11 @@
#!/usr/bin/env bash
set -euo pipefail
PASS="${NEXARCH_TEST_DB_PASSWORD:?Setze NEXARCH_TEST_DB_PASSWORD vor dem Aufruf}"
ROLE="nexarch_test"
export PGPASSWORD="$PASS"
psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP TABLE IF EXISTS tenants CASCADE;"
dbs=$(psql -h localhost -U "$ROLE" -d postgres -tAc "SELECT datname FROM pg_database WHERE datname LIKE 'tenant\_%' ESCAPE '\'")
for db in $dbs; do
psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP DATABASE IF EXISTS \"${db}\";"
done
echo "Testumgebung zurueckgesetzt: registry-tabelle + $(echo "$dbs" | grep -c . || true) tenant-datenbank(en) entfernt."
+12
View File
@@ -0,0 +1,12 @@
#!/usr/bin/env bash
set -euo pipefail
PASS="${NEXARCH_TEST_DB_PASSWORD:?Setze NEXARCH_TEST_DB_PASSWORD vor dem Aufruf}"
cd "$(dirname "$0")/.."
NEXARCH_TEST_DB_PASSWORD="$PASS" bash scripts/reset-test-env.sh
export TEST_ADMIN_DSN="postgresql://nexarch_test:${PASS}@localhost:5432/postgres?sslmode=disable"
echo "== go build =="
go build ./...
echo "== go vet =="
go vet ./...
echo "== go test (-p 1) =="
go test ./... -p 1 -count=1