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
22 changed files with 647 additions and 846 deletions
-20
View File
@@ -1,20 +0,0 @@
package apiserver
import "context"
type contextKey int
const requestContextKey contextKey = iota
// RequestContext ist der Tenant-/Benutzerkontext, den die Middleware-Kette
// fuer nachgelagerte Handler bereitstellt (Akzeptanzkriterium 3).
type RequestContext struct {
UserID string
TenantSlug string
}
// FromContext liest den von der Middleware gesetzten Kontext.
func FromContext(ctx context.Context) (RequestContext, bool) {
rc, ok := ctx.Value(requestContextKey).(RequestContext)
return rc, ok
}
-31
View File
@@ -1,31 +0,0 @@
// Package apiserver implementiert Core API-01: das REST-Grundgerüst mit
// URL-Versionierung, einheitlichem Fehlerformat und Middleware-Kette
// (Auth, Tenant-/Benutzerkontext, Logging).
package apiserver
import (
"encoding/json"
"net/http"
)
// errorBody ist das EINE Fehlerschema fuer alle Endpunkte unter /api/{version}/
// (Akzeptanzkriterium 2).
type errorBody struct {
Error struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
// WriteError schreibt einen Fehler im einheitlichen Schema. code ist ein
// stabiler, maschinenlesbarer Bezeichner (z.B. "unauthenticated"), message
// ein fuer Menschen lesbarer deutscher Text.
func WriteError(w http.ResponseWriter, status int, code, message string) {
var body errorBody
body.Error.Code = code
body.Error.Message = message
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(body)
}
-56
View File
@@ -1,56 +0,0 @@
package apiserver
import (
"context"
"log/slog"
"net/http"
"time"
"gitea.perlbach24.de/scripte/nexarch/internal/auth"
)
// authAndTenantContext prueft die Session (wiederverwendet auth.TokenIssuer.Verify
// aus IAM-02 — keine zweite JWT-Implementierung) und setzt bei Erfolg
// RequestContext fuer nachgelagerte Handler (Akzeptanzkriterium 3). Anders
// als auth.RequireAuth (Klartext-Fehler) antwortet diese Middleware im
// einheitlichen API-01-Fehlerschema (Akzeptanzkriterium 2), damit ALLE
// Endpunkte unter /api/{version}/ dasselbe Format liefern, auch bei
// Auth-Fehlern.
func authAndTenantContext(issuer *auth.TokenIssuer, next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie(auth.CookieName)
if err != nil {
WriteError(w, http.StatusUnauthorized, "unauthenticated", "nicht angemeldet")
return
}
claims, err := issuer.Verify(cookie.Value)
if err != nil {
WriteError(w, http.StatusUnauthorized, "unauthenticated", "nicht angemeldet")
return
}
rc := RequestContext{UserID: claims.UserID, TenantSlug: claims.TenantSlug}
next(w, r.WithContext(context.WithValue(r.Context(), requestContextKey, rc)))
}
}
type statusRecorder struct {
http.ResponseWriter
status int
}
func (s *statusRecorder) WriteHeader(code int) {
s.status = code
s.ResponseWriter.WriteHeader(code)
}
// loggingMiddleware protokolliert jede Anfrage strukturiert.
func loggingMiddleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
start := time.Now()
next(rec, r)
slog.Info("api-anfrage", "method", r.Method, "path", r.URL.Path, "status", rec.status, "dauer", time.Since(start))
}
}
-37
View File
@@ -1,37 +0,0 @@
package apiserver
import (
"net/http"
"gitea.perlbach24.de/scripte/nexarch/internal/auth"
)
// Server registriert versionierte API-Routen (Akzeptanzkriterium 1: unter
// /api/{version}/...) und verdrahtet fuer jede Route dieselbe Middleware-
// Kette (Logging -> Auth+Tenantkontext -> Handler).
type Server struct {
mux *http.ServeMux
issuer *auth.TokenIssuer
}
func NewServer(issuer *auth.TokenIssuer) *Server {
return &Server{mux: http.NewServeMux(), issuer: issuer}
}
// Handle registriert pattern unter der angegebenen Version, z.B.
// Handle("v1", "/things", h) -> erreichbar unter /api/v1/things. Verschiedene
// Versionen sind unabhaengige Pfade — eine neue Version beeintraechtigt
// bestehende nicht (Akzeptanzkriterium 1 / Pruefung 3).
func (s *Server) Handle(version, pattern string, h http.HandlerFunc) {
full := "/api/" + version + pattern
s.mux.HandleFunc(full, loggingMiddleware(authAndTenantContext(s.issuer, h)))
}
// HandleV1 ist die Kurzform fuer die aktuelle Hauptversion.
func (s *Server) HandleV1(pattern string, h http.HandlerFunc) {
s.Handle("v1", pattern, h)
}
func (s *Server) Handler() http.Handler {
return s.mux
}
-149
View File
@@ -1,149 +0,0 @@
package apiserver
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"gitea.perlbach24.de/scripte/nexarch/internal/auth"
)
func newTestServer() (*Server, *auth.TokenIssuer) {
issuer := auth.NewTokenIssuer("test-secret-nur-fuer-tests")
return NewServer(issuer), issuer
}
func withAuthCookie(req *http.Request, token string) *http.Request {
req.AddCookie(&http.Cookie{Name: auth.CookieName, Value: token})
return req
}
// Akzeptanzkriterium 1: API unter versioniertem Pfad erreichbar.
func TestHandleV1_RegistersUnderVersionedPath(t *testing.T) {
srv, issuer := newTestServer()
srv.HandleV1("/things", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
token, err := issuer.Issue("user-1", "acme")
if err != nil {
t.Fatalf("issue: %v", err)
}
req := withAuthCookie(httptest.NewRequest(http.MethodGet, "/api/v1/things", nil), token)
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
}
// Akzeptanzkriterium 2 + Pruefung 1 (Stichprobe): mehrere Endpunkte liefern
// bei fehlerhafter Anfrage dasselbe Fehlerschema.
func TestErrorFormat_ConsistentAcrossEndpoints(t *testing.T) {
srv, _ := newTestServer()
srv.HandleV1("/endpunkt-a", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })
srv.HandleV1("/endpunkt-b", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })
for _, path := range []string{"/api/v1/endpunkt-a", "/api/v1/endpunkt-b"} {
req := httptest.NewRequest(http.MethodGet, path, nil) // ohne cookie -> 401
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("%s: status = %d, want 401", path, rec.Code)
}
var body errorBody
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("%s: fehlerantwort nicht im erwarteten json-schema: %v (body: %s)", path, err, rec.Body.String())
}
if body.Error.Code == "" || body.Error.Message == "" {
t.Fatalf("%s: erwartet nicht-leeren code/message, habe %+v", path, body)
}
}
}
// Akzeptanzkriterium 3 + Pruefung 2: Middleware-Kette setzt Tenant-/
// Benutzerkontext zuverlaessig, nachweislich fuer mehrere Endpunkte.
func TestMiddleware_SetsRequestContextForEveryEndpoint(t *testing.T) {
srv, issuer := newTestServer()
var gotA, gotB RequestContext
srv.HandleV1("/kontext-a", func(w http.ResponseWriter, r *http.Request) {
gotA, _ = FromContext(r.Context())
w.WriteHeader(http.StatusOK)
})
srv.HandleV1("/kontext-b", func(w http.ResponseWriter, r *http.Request) {
gotB, _ = FromContext(r.Context())
w.WriteHeader(http.StatusOK)
})
token, err := issuer.Issue("user-42", "tenant-x")
if err != nil {
t.Fatalf("issue: %v", err)
}
for path, got := range map[string]*RequestContext{"/api/v1/kontext-a": &gotA, "/api/v1/kontext-b": &gotB} {
req := withAuthCookie(httptest.NewRequest(http.MethodGet, path, nil), token)
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("%s: status = %d, want 200", path, rec.Code)
}
if got.UserID != "user-42" || got.TenantSlug != "tenant-x" {
t.Fatalf("%s: request-context unerwartet: %+v", path, *got)
}
}
}
// Akzeptanzkriterium 1 + Pruefung 3: eine neue v2-Route laesst sich anlegen,
// ohne v1 zu beeintraechtigen.
func TestVersioning_V2DoesNotAffectV1(t *testing.T) {
srv, issuer := newTestServer()
srv.HandleV1("/things", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("v1-antwort"))
})
srv.Handle("v2", "/things", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("v2-antwort"))
})
token, err := issuer.Issue("user-1", "acme")
if err != nil {
t.Fatalf("issue: %v", err)
}
reqV1 := withAuthCookie(httptest.NewRequest(http.MethodGet, "/api/v1/things", nil), token)
recV1 := httptest.NewRecorder()
srv.Handler().ServeHTTP(recV1, reqV1)
if recV1.Body.String() != "v1-antwort" {
t.Fatalf("v1 antwort = %q, want v1-antwort", recV1.Body.String())
}
reqV2 := withAuthCookie(httptest.NewRequest(http.MethodGet, "/api/v2/things", nil), token)
recV2 := httptest.NewRecorder()
srv.Handler().ServeHTTP(recV2, reqV2)
if recV2.Body.String() != "v2-antwort" {
t.Fatalf("v2 antwort = %q, want v2-antwort", recV2.Body.String())
}
// v1 nach dem Anlegen von v2 erneut pruefen — unveraendert.
reqV1Again := withAuthCookie(httptest.NewRequest(http.MethodGet, "/api/v1/things", nil), token)
recV1Again := httptest.NewRecorder()
srv.Handler().ServeHTTP(recV1Again, reqV1Again)
if recV1Again.Body.String() != "v1-antwort" {
t.Fatalf("v1 antwort nach v2-anlage = %q, want weiterhin v1-antwort", recV1Again.Body.String())
}
}
func TestAuthAndTenantContext_RejectsInvalidToken(t *testing.T) {
srv, _ := newTestServer()
srv.HandleV1("/geschuetzt", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })
req := withAuthCookie(httptest.NewRequest(http.MethodGet, "/api/v1/geschuetzt", nil), "kaputtes.token.hier")
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401", rec.Code)
}
}
+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()
}
-177
View File
@@ -1,177 +0,0 @@
package webhook
import (
"bytes"
"context"
"crypto/subtle"
"encoding/hex"
"fmt"
"net/http"
"time"
"github.com/jackc/pgx/v5"
)
// DefaultMaxAttempts ist die konfigurierbare Obergrenze, ab der eine
// Zustellung endgueltig als fehlgeschlagen gilt (Akzeptanzkriterium 2).
const DefaultMaxAttempts = 5
// DefaultBaseBackoff ist die Basisdauer fuer exponentielles Backoff:
// naechster Versuch nach BaseBackoff * 2^attempt (Akzeptanzkriterium 2).
const DefaultBaseBackoff = 2 * time.Second
// Dispatcher liefert faellige Zustellungen aus. Konfigurierbar in Tests
// (kleine BaseBackoff, kleine MaxAttempts), damit Retry/Backoff/Obergrenze
// ohne minutenlange Wartezeit real durchlaufen werden koennen.
type Dispatcher struct {
pool pgxIface
client *http.Client
MaxAttempts int
BaseBackoff time.Duration
}
// pgxIface ist die schmale Teilmenge von *pgxpool.Pool, die der Dispatcher
// braucht — als Interface, damit Tests keine echte Verbindung fuer reine
// Signatur-/Backoff-Logik brauchen (wird hier aber durchgehend mit echten
// Integrationstests gegen Postgres verwendet, siehe dispatcher_test.go).
type pgxIface interface {
Begin(ctx context.Context) (pgx.Tx, error)
}
func NewDispatcher(pool pgxIface, client *http.Client) *Dispatcher {
if client == nil {
client = &http.Client{Timeout: 5 * time.Second}
}
return &Dispatcher{pool: pool, client: client, MaxAttempts: DefaultMaxAttempts, BaseBackoff: DefaultBaseBackoff}
}
// backoffFor berechnet die Wartezeit vor dem naechsten Versuch: exponentiell
// wachsend mit der Anzahl bereits unternommener Versuche.
func (d *Dispatcher) backoffFor(attempt int) time.Duration {
return d.BaseBackoff * time.Duration(1<<uint(attempt))
}
// ProcessDue liefert ALLE derzeit faelligen Zustellungen aus — dasselbe
// SELECT ... FOR UPDATE SKIP LOCKED-Muster wie
// internal/tenant.Lifecycle.ProcessDueDeletions, damit mehrere Dispatcher-
// Instanzen dieselbe Zustellung nie doppelt bearbeiten.
func (d *Dispatcher) ProcessDue(ctx context.Context) (int, error) {
tx, err := d.pool.Begin(ctx)
if err != nil {
return 0, fmt.Errorf("transaktion starten: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
rows, err := tx.Query(ctx, `
SELECT wd.id, wd.payload, wd.attempt, ws.target_url, ws.secret
FROM webhook_deliveries wd
JOIN webhook_subscriptions ws ON ws.id = wd.subscription_id
WHERE wd.status = $1 AND wd.next_attempt_at <= now()
FOR UPDATE OF wd SKIP LOCKED
`, StatusPending)
if err != nil {
return 0, fmt.Errorf("faellige zustellungen abfragen: %w", err)
}
var deliveries []delivery
for rows.Next() {
var del delivery
if err := rows.Scan(&del.ID, &del.Payload, &del.Attempt, &del.TargetURL, &del.Secret); err != nil {
rows.Close()
return 0, fmt.Errorf("zustellung lesen: %w", err)
}
deliveries = append(deliveries, del)
}
rows.Close()
if err := rows.Err(); err != nil {
return 0, err
}
for _, del := range deliveries {
d.attemptOne(ctx, tx, del)
}
if err := tx.Commit(ctx); err != nil {
return 0, fmt.Errorf("transaktion committen: %w", err)
}
return len(deliveries), nil
}
// attemptOne fuehrt GENAU EINEN Zustellversuch aus und aktualisiert den
// Zustellungsdatensatz entsprechend — Erfolg (Akzeptanzkriterium 1/Pruefung
// 1), erneuter Fehlversuch mit Backoff, oder endgueltiges Scheitern nach
// DefaultMaxAttempts (Akzeptanzkriterium 2/Pruefung 2). Ein Fehler bei
// GENAU EINER Zustellung darf die anderen in diesem Batch nicht verhindern.
func (d *Dispatcher) attemptOne(ctx context.Context, tx pgx.Tx, del delivery) {
signature := Sign(del.Secret, del.Payload)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, del.TargetURL, bytes.NewReader(del.Payload))
deliveryErr := err
var statusCode int
if err == nil {
req.Header.Set("Content-Type", "application/json")
req.Header.Set(SignatureHeader, signature)
resp, err := d.client.Do(req)
if err != nil {
deliveryErr = err
} else {
statusCode = resp.StatusCode
resp.Body.Close()
if statusCode < 200 || statusCode >= 300 {
deliveryErr = fmt.Errorf("unerwarteter statuscode %d", statusCode)
}
}
}
if deliveryErr == nil {
_, _ = tx.Exec(ctx, `
UPDATE webhook_deliveries SET status = $2, delivered_at = now(), attempt = attempt + 1
WHERE id = $1
`, del.ID, StatusDelivered)
return
}
nextAttempt := del.Attempt + 1
if nextAttempt >= d.MaxAttempts {
_, _ = tx.Exec(ctx, `
UPDATE webhook_deliveries SET status = $2, attempt = $3, last_error = $4
WHERE id = $1
`, del.ID, StatusFailed, nextAttempt, deliveryErr.Error())
return
}
nextAttemptAt := time.Now().Add(d.backoffFor(nextAttempt))
_, _ = tx.Exec(ctx, `
UPDATE webhook_deliveries SET attempt = $2, next_attempt_at = $3, last_error = $4
WHERE id = $1
`, del.ID, nextAttempt, nextAttemptAt, deliveryErr.Error())
}
// Run ruft ProcessDue in festen Abstaenden auf, bis ctx beendet wird —
// dieselbe Konvention wie internal/tenant.Lifecycle.RunSweeper.
func (d *Dispatcher) Run(ctx context.Context, interval time.Duration) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
_, _ = d.ProcessDue(ctx)
}
}
}
// VerifySignature prueft empfaengerseitig, ob signature zu payload und
// secret passt — timing-safe (dasselbe Muster wie internal/audit.timingsafe),
// damit ein Empfaenger die Authentizitaet einer Zustellung pruefen kann
// (Akzeptanzkriterium 3).
func VerifySignature(secret string, payload []byte, signature string) bool {
expected := Sign(secret, payload)
expectedBytes, err1 := hex.DecodeString(expected)
gotBytes, err2 := hex.DecodeString(signature)
if err1 != nil || err2 != nil {
return false
}
return subtle.ConstantTimeCompare(expectedBytes, gotBytes) == 1
}
-131
View File
@@ -1,131 +0,0 @@
// Package webhook implementiert Core API-07: eine zentrale Webhook-Registry
// und Zustellungs-Engine fuer alle Fachmodule (DMS/Mail/Archive/Workflow/AI).
// Module reichen Ereignisse EINMAL zur Zustellung ein und implementieren
// selbst KEINE eigene Retry-/Signatur-Logik — das ist der zentrale Zweck
// dieser Kachel ("Bewusst vermeiden: jedes Modul baut seine eigene
// Webhook-Zustellungs-Engine"). Zustellung laeuft ueber dieselbe
// Postgres-Jobqueue-Konvention (SELECT ... FOR UPDATE SKIP LOCKED) wie
// internal/tenant.Lifecycle.ProcessDueDeletions (TEN-04) und
// internal/notify.Dispatcher (CFG-02) — kein Redis/AMQP.
package webhook
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
const (
StatusPending = "pending"
StatusDelivered = "delivered"
StatusFailed = "failed"
)
// Subscription ist EIN externer Abonnent fuer einen Ereignistyp
// (Akzeptanzkriterium 1: Module registrieren Ereignistypen, externe
// Abonnenten registrieren Ziel-URLs — dieses Paket modelliert die
// Abonnenten-Seite; welche Ereignistypen ein Modul anbietet, ist bewusst
// NICHT Teil dieser Kachel).
type Subscription struct {
ID string
EventType string
TargetURL string
Secret string
CreatedAt time.Time
}
// Store persistiert Abonnements und Zustellversuche.
type Store struct {
pool *pgxpool.Pool
}
func NewStore(pool *pgxpool.Pool) *Store {
return &Store{pool: pool}
}
// Subscribe registriert einen Abonnenten fuer einen Ereignistyp. secret wird
// spaeter zur HMAC-Signierung jeder Zustellung an diesen Abonnenten
// verwendet (Akzeptanzkriterium 3).
func (s *Store) Subscribe(ctx context.Context, eventType, targetURL, secret string) (Subscription, error) {
var sub Subscription
sub.EventType, sub.TargetURL, sub.Secret = eventType, targetURL, secret
row := s.pool.QueryRow(ctx, `
INSERT INTO webhook_subscriptions (event_type, target_url, secret)
VALUES ($1, $2, $3)
RETURNING id, created_at
`, eventType, targetURL, secret)
if err := row.Scan(&sub.ID, &sub.CreatedAt); err != nil {
return Subscription{}, fmt.Errorf("abonnement anlegen: %w", err)
}
return sub, nil
}
// Enqueue reicht EIN Ereignis zur Zustellung an ALLE Abonnenten des
// angegebenen Ereignistyps ein — dies ist die EINZIGE Schnittstelle, die
// ein Fachmodul braucht (Akzeptanzkriterium 1). Jeder Abonnent erhaelt
// einen eigenen, unabhaengigen Zustellversuch-Datensatz.
func (s *Store) Enqueue(ctx context.Context, eventType string, payload any) (int, error) {
payloadJSON, err := json.Marshal(payload)
if err != nil {
return 0, fmt.Errorf("payload serialisieren: %w", err)
}
rows, err := s.pool.Query(ctx, `
SELECT id FROM webhook_subscriptions WHERE event_type = $1
`, eventType)
if err != nil {
return 0, fmt.Errorf("abonnenten ermitteln: %w", err)
}
var subscriptionIDs []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
rows.Close()
return 0, fmt.Errorf("abonnent lesen: %w", err)
}
subscriptionIDs = append(subscriptionIDs, id)
}
rows.Close()
if err := rows.Err(); err != nil {
return 0, err
}
for _, subID := range subscriptionIDs {
if _, err := s.pool.Exec(ctx, `
INSERT INTO webhook_deliveries (subscription_id, event_type, payload, status, next_attempt_at)
VALUES ($1, $2, $3, $4, now())
`, subID, eventType, payloadJSON, StatusPending); err != nil {
return 0, fmt.Errorf("zustellung einreihen: %w", err)
}
}
return len(subscriptionIDs), nil
}
// delivery ist ein interner Datensatz fuer EINEN Zustellversuch, inklusive
// der zugehoerigen Abonnentendaten (per JOIN geladen).
type delivery struct {
ID string
TargetURL string
Secret string
Payload []byte
Attempt int
}
// Sign berechnet die HMAC-SHA256-Signatur des Payloads (Akzeptanzkriterium
// 3) — hex-kodiert, damit sie problemlos als HTTP-Header uebertragen werden
// kann.
func Sign(secret string, payload []byte) string {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(payload)
return hex.EncodeToString(mac.Sum(nil))
}
// SignatureHeader ist der HTTP-Header, unter dem die Signatur uebertragen
// wird — dokumentierter Vertrag fuer Empfaenger (Akzeptanzkriterium 3).
const SignatureHeader = "X-Nexarch-Signature-256"
-205
View File
@@ -1,205 +0,0 @@
package webhook
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"sync/atomic"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
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 webhook_subscriptions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), event_type TEXT NOT NULL,
target_url TEXT NOT NULL, secret TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS webhook_deliveries (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), subscription_id UUID NOT NULL REFERENCES webhook_subscriptions(id),
event_type TEXT NOT NULL, payload JSONB NOT NULL, status TEXT NOT NULL DEFAULT 'pending',
attempt INT NOT NULL DEFAULT 0, next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_error TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), delivered_at TIMESTAMPTZ
);
`); err != nil {
t.Fatalf("schema: %v", err)
}
cleanup := func() { pool.Close() }
return NewStore(pool), pool, cleanup
}
func uniqueEventType(prefix string) string {
return prefix + "-" + time.Now().Format("150405.000000000")
}
// Akzeptanzkriterium 1 + Pruefung 1: ein Modul reicht ein Ereignis ein
// (Enqueue) ohne eigene Zustellungslogik, der zentrale Dispatcher liefert
// zuverlaessig aus.
func TestEnqueueAndProcessDue_DeliversSuccessfully(t *testing.T) {
store, pool, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
var receivedBody []byte
var receivedSignature string
target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
receivedBody = body
receivedSignature = r.Header.Get(SignatureHeader)
w.WriteHeader(http.StatusOK)
}))
defer target.Close()
eventType := uniqueEventType("dms.file.created")
sub, err := store.Subscribe(ctx, eventType, target.URL, "geheimes-secret")
if err != nil {
t.Fatalf("subscribe: %v", err)
}
n, err := store.Enqueue(ctx, eventType, map[string]string{"file_id": "42"})
if err != nil {
t.Fatalf("enqueue: %v", err)
}
if n != 1 {
t.Fatalf("erwartet 1 eingereihte zustellung, habe %d", n)
}
dispatcher := NewDispatcher(pool, target.Client())
processed, err := dispatcher.ProcessDue(ctx)
if err != nil {
t.Fatalf("processdue: %v", err)
}
if processed != 1 {
t.Fatalf("erwartet 1 verarbeitete zustellung, habe %d", processed)
}
var status string
if err := pool.QueryRow(ctx, `SELECT status FROM webhook_deliveries WHERE subscription_id = $1`, sub.ID).Scan(&status); err != nil {
t.Fatalf("status lesen: %v", err)
}
if status != StatusDelivered {
t.Fatalf("status = %q, want %q", status, StatusDelivered)
}
// Postgres' JSONB-Spalte kann die Byte-Repraesentation des Payloads
// gegenueber dem urspruenglichen json.Marshal kanonisieren (z.B.
// Leerzeichen) — das ist unschaedlich, denn der Dispatcher signiert
// IMMER exakt die Bytes, die er auch sendet. Die Pruefung vergleicht
// deshalb Signatur gegen tatsaechlich empfangene Bytes (Selbstkonsistenz),
// nicht gegen eine unabhaengig neu marshalte Referenz.
if !VerifySignature("geheimes-secret", receivedBody, receivedSignature) {
t.Fatalf("empfangene signatur %q passt nicht zum empfangenen payload %q", receivedSignature, receivedBody)
}
var decoded map[string]string
if err := json.Unmarshal(receivedBody, &decoded); err != nil {
t.Fatalf("empfangener payload nicht als json lesbar: %v", err)
}
if decoded["file_id"] != "42" {
t.Fatalf("empfangener payload = %v, want file_id=42", decoded)
}
}
// Akzeptanzkriterium 2 + Pruefung 2: fehlschlagendes Ziel loest Retry mit
// wachsendem Backoff aus und endet nach der konfigurierten Obergrenze in
// "failed".
func TestProcessDue_RetriesWithBackoffThenMarksFailed(t *testing.T) {
store, pool, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
var callCount int32
target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&callCount, 1)
w.WriteHeader(http.StatusInternalServerError)
}))
defer target.Close()
eventType := uniqueEventType("mail.send.failed")
sub, err := store.Subscribe(ctx, eventType, target.URL, "secret")
if err != nil {
t.Fatalf("subscribe: %v", err)
}
if _, err := store.Enqueue(ctx, eventType, map[string]string{"x": "y"}); err != nil {
t.Fatalf("enqueue: %v", err)
}
dispatcher := NewDispatcher(pool, target.Client())
dispatcher.MaxAttempts = 2
dispatcher.BaseBackoff = 30 * time.Millisecond
// 1. Versuch: schlaegt fehl, ist aber noch nicht die Obergrenze.
if _, err := dispatcher.ProcessDue(ctx); err != nil {
t.Fatalf("processdue 1: %v", err)
}
var status string
var attempt int
if err := pool.QueryRow(ctx, `SELECT status, attempt FROM webhook_deliveries WHERE subscription_id = $1`, sub.ID).Scan(&status, &attempt); err != nil {
t.Fatalf("status lesen 1: %v", err)
}
if status != StatusPending || attempt != 1 {
t.Fatalf("nach 1. fehlschlag: status=%q attempt=%d, want pending/1", status, attempt)
}
// Sofort erneut verarbeiten: Backoff ist noch nicht abgelaufen -> nichts faellig.
processedTooEarly, err := dispatcher.ProcessDue(ctx)
if err != nil {
t.Fatalf("processdue (zu frueh): %v", err)
}
if processedTooEarly != 0 {
t.Fatal("erwartet 0 verarbeitete zustellungen, solange backoff nicht abgelaufen ist")
}
time.Sleep(dispatcher.backoffFor(1) + 20*time.Millisecond)
// 2. Versuch: erreicht MaxAttempts=2 -> endgueltig fehlgeschlagen.
if _, err := dispatcher.ProcessDue(ctx); err != nil {
t.Fatalf("processdue 2: %v", err)
}
if err := pool.QueryRow(ctx, `SELECT status, attempt FROM webhook_deliveries WHERE subscription_id = $1`, sub.ID).Scan(&status, &attempt); err != nil {
t.Fatalf("status lesen 2: %v", err)
}
if status != StatusFailed || attempt != 2 {
t.Fatalf("nach 2. fehlschlag: status=%q attempt=%d, want failed/2", status, attempt)
}
if atomic.LoadInt32(&callCount) != 2 {
t.Fatalf("erwartet genau 2 tatsaechliche zustellversuche, habe %d", callCount)
}
}
// Akzeptanzkriterium 3 + Pruefung 3: Signaturpruefung erkennt eine
// manipulierte Payload zuverlaessig.
func TestVerifySignature_DetectsTamperedPayload(t *testing.T) {
secret := "geteiltes-geheimnis"
payload := []byte(`{"file_id":"42"}`)
signature := Sign(secret, payload)
if !VerifySignature(secret, payload, signature) {
t.Fatal("erwartet gueltige signatur fuer unveraenderte payload")
}
tampered := []byte(`{"file_id":"99"}`)
if VerifySignature(secret, tampered, signature) {
t.Fatal("erwartet ungueltige signatur fuer manipulierte payload")
}
if VerifySignature("falsches-secret", payload, signature) {
t.Fatal("erwartet ungueltige signatur bei falschem secret")
}
}
-2
View File
@@ -1,2 +0,0 @@
DROP TABLE webhook_deliveries;
DROP TABLE webhook_subscriptions;
-27
View File
@@ -1,27 +0,0 @@
-- Zentrale Webhook-Registry & Zustellung (API-07, siehe
-- core-kanban/tickets/API-07.md) — Postgres-basierte Jobqueue, kein
-- Redis/AMQP (Ticket-Vorgabe).
CREATE TABLE webhook_subscriptions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
event_type TEXT NOT NULL,
target_url TEXT NOT NULL,
secret TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX webhook_subscriptions_event_type_idx ON webhook_subscriptions (event_type);
CREATE TABLE webhook_deliveries (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
subscription_id UUID NOT NULL REFERENCES webhook_subscriptions(id),
event_type TEXT NOT NULL,
payload JSONB NOT NULL,
status TEXT NOT NULL DEFAULT 'pending', -- pending|delivered|failed
attempt INT NOT NULL DEFAULT 0,
next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_error TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
delivered_at TIMESTAMPTZ
);
CREATE INDEX webhook_deliveries_due_idx ON webhook_deliveries (status, next_attempt_at);
@@ -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);
+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'))
);
-5
View File
@@ -1,16 +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."
-6
View File
@@ -1,18 +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