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>
205 lines
6.5 KiB
Go
205 lines
6.5 KiB
Go
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)
|
|
}
|
|
}
|