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>
94 lines
2.5 KiB
Go
94 lines
2.5 KiB
Go
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
|
|
}
|