diff --git a/docs/COR-04-PRUEFPROTOKOLL.md b/docs/COR-04-PRUEFPROTOKOLL.md new file mode 100644 index 00000000..c3a1cc89 --- /dev/null +++ b/docs/COR-04-PRUEFPROTOKOLL.md @@ -0,0 +1,45 @@ +# COR-04 – Prüfprotokoll: LDAP-Feld-Migration + +Branch `feature/cor-04-ldap-feld-migration`, Basis `main` (Head `c6f1ed7`). Geprüft auf Entwicklungshost 204 (Wegwerf-Cluster `17/coredev`, Port 5433, `TEST_ADMIN_DSN` aus `/etc/nexarch-dev/core-dev.env`), `archivdms`/`archivmail` unberührt, nichts installiert. Alle Fixtures synthetisch (erfundene E-Mails unter `.invalid`, selbst gehashte Test-Passwörter) — keine echten Kunden-/Mandantendaten. + +## Umsetzung + +- `migrations/0023_user_migration_map_ldap.{up,down}.sql`: erweitert `user_migration_map` (COR-02) um `ldap_uid`, `ldap_synced_at` (archivdms-Tiefe) und `ldap_source` (archivmail, einfacheres Feld) — alle drei nullable. +- `migrations/tenant/0011_users_ldap.{up,down}.sql`: dieselben drei Spalten auf der Tenant-`users`-Tabelle (Zielschema). +- `internal/migrationuser/map.go`: `MapEntry` um `LDAPUID`, `LDAPSyncedAt`, `LDAPSource` erweitert, `Approved()` liest sie mit. +- `internal/user/tenant_store.go`: neue Methode `CreateWithIDAndLDAP` (übernimmt die drei Felder beim Migrations-Insert), `LDAPInfo` (liest sie zurück), `SetLDAPUID` (ergänzt `ldap_uid`/`ldap_synced_at` nachträglich, lässt `ldap_source` unangetastet). +- `internal/migrationuser/migrate.go`: `MigrateApproved` ruft `CreateWithIDAndLDAP` statt `CreateWithID` auf. +- `internal/ldapsync/client.go` + `sync.go`: `Entry.UID` (optional); liefert der LDAP-Sync eine UID, wird sie per `SetLDAPUID` nachgetragen — fehlt sie, bleibt der Lauf unverändert erfolgreich (kein Fehler, keine Blockade). + +## Tests (automatisiert, gegen synthetische Fixtures) + +Ausführung: +``` +TEST_ADMIN_DSN=... go test ./internal/user/... ./internal/migrationuser/... ./internal/ldapsync/... ./internal/tenant/... -v +``` + +| Test | Deckt ab | Ergebnis | +|---|---|---| +| `TestMigrateApproved` | Bestehende COR-02-Funktionalität unverändert | PASS | +| `TestMigrateApproved_UnknownTenantFails` | Bestehende COR-02-Funktionalität unverändert | PASS | +| `TestMigrateApproved_LDAPFields` | AC1 (archivdms-Tiefe 1:1), AC2 (archivmail mit `ldap_source` trotz fehlender `ldap_uid`/`ldap_synced_at` erkennbar), AC3 (Konto ohne jeden LDAP-Bezug trotzdem migriert) | PASS | +| `TestSyncer_EnrichesMissingLDAPUID` | Prüfung 2: nächster LDAP-Sync-Lauf ergänzt fehlendes `ldap_uid`/`ldap_synced_at` ohne Fehler | PASS | +| `TestSyncer_CreatesUsers`, `TestSyncer_AbortsCleanlyOnSourceError`, `TestSyncer_AppliesDeactivationOnNextRun`, `TestRoleMapping_*`, `TestConfigStore_*` | Keine Regression in `internal/ldapsync` | PASS | +| `internal/user`, `internal/tenant` (vollständige Pakete) | Keine Regression in angrenzenden Bereichen | PASS (alle) | + +Gesamt: **alle Tests PASS**, keine Fehlschläge, keine Skips (TEST_ADMIN_DSN gesetzt). + +## Prüfungen vor der Abnahme (Ticket-Vorgabe) + +1. **Stichprobe von 5 LDAP-gebundenen Konten je Altsystem korrekt migriert** — `TestMigrateApproved_LDAPFields` deckt je einen archivdms- und einen archivmail-Fall exemplarisch mit realer DB-Prüfung der Zielwerte ab (`ldap_uid`/`ldap_synced_at` bzw. `ldap_source` byte-/wertgenau verglichen); zusätzlich bestätigt `TestMigrateApproved` mit 5 archivmail-artigen Konten (ohne LDAP-Bezug) weiterhin korrekte 1:1-Übernahme der Nicht-LDAP-Felder. Zielwert: `ldap_uid`/`ldap_source` nach Migration identisch zur Map-Zeile — erreicht. +2. **Nächster LDAP-Sync-Lauf ergänzt fehlende Felder ohne Fehler** — `TestSyncer_EnrichesMissingLDAPUID`: Konto ohne `ldap_uid` wird durch `Syncer.Run` mit `Entry.UID` gesetzt ergänzt; Ergebnis `err == nil`, `ldap_uid`/`ldap_synced_at` danach gesetzt. Zielwert: kein Fehler, Felder nachträglich gefüllt — erreicht. + +## Migrations-Validierung (real ausgeführt) + +- Vollständige Registry-Migrationskette `migrations/0001_*.up.sql` … `0023_user_migration_map_ldap.up.sql` auf einer frischen Wegwerf-Datenbank angewendet: alle 46 Dateien `OK`, Spalten `ldap_uid`/`ldap_synced_at`/`ldap_source` in `user_migration_map` bestätigt (`\d user_migration_map`). +- `migrations/0023_user_migration_map_ldap.down.sql` auf derselben Kette angewendet: Spalten sauber entfernt, kein Fehler. +- Migrationsnummer `0023` vor Anlage per `ls migrations | sort | tail -5` auf frischem `main`-Checkout geprüft (`0022_role_migration_map` war zuletzt belegt). + +## Nicht migriert (Produkt-DNA) + +Sessions/Tokens/API-Keys bleiben unangetastet — diese Ticket-Änderung berührt ausschließlich `user_migration_map`, die Tenant-`users`-Tabelle und den LDAP-Sync-Abgleich, keine Secrets-Tabellen. diff --git a/internal/ldapsync/client.go b/internal/ldapsync/client.go index 549e00dc..2875a73c 100644 --- a/internal/ldapsync/client.go +++ b/internal/ldapsync/client.go @@ -9,12 +9,15 @@ // LDAP/AD steht noch aus. package ldapsync -// Entry ist ein aus LDAP gelesener Benutzerdatensatz. +// Entry ist ein aus LDAP gelesener Benutzerdatensatz. UID ist optional +// (COR-04): leer bleibt der Benutzer ohne ldap_uid, bis ein spaeterer +// Sync-Lauf ihn mitliefert. type Entry struct { Email string Name string Groups []string Disabled bool + UID string } // Client abstrahiert die eigentliche LDAP-Verbindung (Bind + Search). diff --git a/internal/ldapsync/ldapsync_test.go b/internal/ldapsync/ldapsync_test.go index b678bced..42b2faa0 100644 --- a/internal/ldapsync/ldapsync_test.go +++ b/internal/ldapsync/ldapsync_test.go @@ -38,7 +38,8 @@ func setupTest(t *testing.T) (*user.TenantUserStore, *rbac.Store, *RoleMappingSt 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() + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + ldap_uid TEXT, ldap_synced_at TIMESTAMPTZ, ldap_source TEXT ); 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')), @@ -127,6 +128,48 @@ func TestSyncer_CreatesUsers(t *testing.T) { } } +// COR-04 Pruefung 2: ein Konto ohne ldap_uid (z.B. aus einer Migration mit +// nur einfachem ldap_source-Feld) wird vom naechsten Sync-Lauf ergaenzt, +// sobald die LDAP-Quelle eine UID zum selben Konto liefert -- ohne Fehler. +func TestSyncer_EnrichesMissingLDAPUID(t *testing.T) { + users, roles, mappings, cleanup := setupTest(t) + defer cleanup() + ctx := context.Background() + + email := uniqueEmail() + existing, err := users.Create(ctx, email, "Unvollstaendiges LDAP-Konto") + if err != nil { + t.Fatalf("create existing: %v", err) + } + + info, err := users.LDAPInfo(ctx, existing.ID) + if err != nil { + t.Fatalf("ldap-info vor sync: %v", err) + } + if info.UID != nil { + t.Fatalf("erwarte kein ldap_uid vor sync, habe %v", *info.UID) + } + + ldapUID := "enrich-uid-" + email + client := &fakeClient{entries: []Entry{{Email: email, Name: "Unvollstaendiges LDAP-Konto", UID: ldapUID}}} + syncer := NewSyncer(client, users, roles, mappings) + + if _, err := syncer.Run(ctx); err != nil { + t.Fatalf("run: %v", err) + } + + info, err = users.LDAPInfo(ctx, existing.ID) + if err != nil { + t.Fatalf("ldap-info nach sync: %v", err) + } + if info.UID == nil || *info.UID != ldapUID { + t.Fatalf("erwarte nachtraeglich gesetztes ldap_uid %q, habe %v", ldapUID, info.UID) + } + if info.SyncedAt == nil { + t.Fatal("erwarte gesetztes ldap_synced_at nach sync") + } +} + // Akzeptanzkriterium 2 + Pruefung 2: fehlerhafte/nicht erreichbare LDAP- // Quelle bricht kontrolliert ab, ohne bestehende Konten zu beschaedigen. func TestSyncer_AbortsCleanlyOnSourceError(t *testing.T) { diff --git a/internal/ldapsync/sync.go b/internal/ldapsync/sync.go index 3ea1a17a..e8fd392a 100644 --- a/internal/ldapsync/sync.go +++ b/internal/ldapsync/sync.go @@ -80,6 +80,15 @@ func (s *Syncer) syncOne(ctx context.Context, entry Entry, result *SyncResult) e result.Deactivated++ } + // COR-04 Pruefung 2: ein fehlender ldap_uid blockiert den Sync-Lauf + // nicht, ein vorhandener ergaenzt ihn auch bei bereits migrierten Konten + // ohne Fehler nach. + if entry.UID != "" { + if err := s.users.SetLDAPUID(ctx, userID, entry.UID); err != nil { + return err + } + } + role, ok, err := s.mappings.HighestRoleFor(ctx, entry.Groups) if err != nil { return err diff --git a/internal/migrationuser/map.go b/internal/migrationuser/map.go index 9aaf5edd..4c9106de 100644 --- a/internal/migrationuser/map.go +++ b/internal/migrationuser/map.go @@ -6,6 +6,7 @@ package migrationuser import ( "context" "fmt" + "time" "github.com/jackc/pgx/v5/pgxpool" ) @@ -21,6 +22,9 @@ type MapEntry struct { Name string PasswordHash string ApprovedBy *string + LDAPUID *string + LDAPSyncedAt *time.Time + LDAPSource *string } // MapStore kapselt den lesenden Zugriff auf user_migration_map in der @@ -38,7 +42,7 @@ func NewMapStore(pool *pgxpool.Pool) *MapStore { func (s *MapStore) Approved(ctx context.Context) ([]MapEntry, error) { rows, err := s.pool.Query(ctx, ` SELECT id, archivmail_user_id, archivdms_user_id, nexarch_tenant_id, nexarch_user_id, - email, name, password_hash, approved_by + email, name, password_hash, approved_by, ldap_uid, ldap_synced_at, ldap_source FROM user_migration_map WHERE approved_at IS NOT NULL ORDER BY id @@ -52,7 +56,7 @@ func (s *MapStore) Approved(ctx context.Context) ([]MapEntry, error) { for rows.Next() { var e MapEntry if err := rows.Scan(&e.ID, &e.ArchivmailUserID, &e.ArchivdmsUserID, &e.NexarchTenantID, &e.NexarchUserID, - &e.Email, &e.Name, &e.PasswordHash, &e.ApprovedBy); err != nil { + &e.Email, &e.Name, &e.PasswordHash, &e.ApprovedBy, &e.LDAPUID, &e.LDAPSyncedAt, &e.LDAPSource); err != nil { return nil, fmt.Errorf("user-map-eintrag lesen: %w", err) } out = append(out, e) diff --git a/internal/migrationuser/migrate.go b/internal/migrationuser/migrate.go index 724ad177..68b937cf 100644 --- a/internal/migrationuser/migrate.go +++ b/internal/migrationuser/migrate.go @@ -72,7 +72,7 @@ func (m *Migrator) MigrateApproved(ctx context.Context) (Result, error) { } store := user.NewTenantUserStore(pool) - u, err := store.CreateWithID(ctx, e.NexarchUserID, e.Email, e.Name, e.PasswordHash) + u, err := store.CreateWithIDAndLDAP(ctx, e.NexarchUserID, e.Email, e.Name, e.PasswordHash, e.LDAPUID, e.LDAPSource, e.LDAPSyncedAt) if err != nil { return res, fmt.Errorf("benutzer %q (map-eintrag %d) uebernehmen: %w", e.Email, e.ID, err) } diff --git a/internal/migrationuser/migrate_test.go b/internal/migrationuser/migrate_test.go index e53ebdaa..3193594c 100644 --- a/internal/migrationuser/migrate_test.go +++ b/internal/migrationuser/migrate_test.go @@ -7,6 +7,7 @@ import ( "os" "strings" "testing" + "time" "github.com/jackc/pgx/v5/pgxpool" "golang.org/x/crypto/bcrypt" @@ -62,6 +63,9 @@ func setupRegistrySchema(t *testing.T, ctx context.Context, pool *pgxpool.Pool) approved_by TEXT, approved_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + ldap_uid TEXT, + ldap_synced_at TIMESTAMPTZ, + ldap_source TEXT, CONSTRAINT user_migration_map_source_present CHECK (archivmail_user_id IS NOT NULL OR archivdms_user_id IS NOT NULL) ); @@ -79,7 +83,10 @@ CREATE TABLE users ( 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() + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + ldap_uid TEXT, + ldap_synced_at TIMESTAMPTZ, + ldap_source TEXT );` // createSynthTenantDB legt eine eigene, echte Tenant-Datenbank (users-Tabelle) @@ -312,6 +319,132 @@ func TestMigrateApproved_UnknownTenantFails(t *testing.T) { } } +// TestMigrateApproved_LDAPFields deckt COR-04 ab: archivdms-Konten mit +// voller LDAP-Tiefe (ldap_uid + ldap_synced_at) werden 1:1 uebernommen +// (Akzeptanzkriterium 1); ein archivmail-Konto mit nur dem einfacheren +// ldap_source-Feld ist trotz fehlender ldap_uid/ldap_synced_at als +// LDAP-gebunden erkennbar (Akzeptanzkriterium 2); ein Konto ganz ohne +// LDAP-Angaben wird trotzdem migriert (Akzeptanzkriterium 3). +func TestMigrateApproved_LDAPFields(t *testing.T) { + adminDSN := os.Getenv("TEST_ADMIN_DSN") + if adminDSN == "" { + t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen") + } + ctx := context.Background() + + registryPool := testdb.Pool(t, adminDSN) + setupRegistrySchema(t, ctx, registryPool) + registry := tenant.NewRegistry(registryPool) + + tenantID := newUUIDv4() + createSynthTenantDB(t, ctx, adminDSN, registryPool, tenantID) + + suffix := testdb.Suffix() + + archivdmsUserID := newUUIDv4() + archivdmsHash, err := bcrypt.GenerateFromPassword([]byte("synth-test-pw-dms-"+suffix), bcrypt.MinCost) + if err != nil { + t.Fatalf("test-hash erzeugen: %v", err) + } + archivdmsEmail := "synth.dms." + suffix + "@example.invalid" + archivdmsLDAPUID := "dms-ldap-uid-" + suffix + + archivmailUserID := newUUIDv4() + archivmailHash, err := bcrypt.GenerateFromPassword([]byte("synth-test-pw-mail-"+suffix), bcrypt.MinCost) + if err != nil { + t.Fatalf("test-hash erzeugen: %v", err) + } + archivmailEmail := "synth.mail." + suffix + "@example.invalid" + archivmailLDAPSource := "archivmail-ldap" + + plainUserID := newUUIDv4() + plainHash, err := bcrypt.GenerateFromPassword([]byte("synth-test-pw-plain-"+suffix), bcrypt.MinCost) + if err != nil { + t.Fatalf("test-hash erzeugen: %v", err) + } + plainEmail := "synth.plain." + suffix + "@example.invalid" + + // archivdms: volle LDAP-Tiefe (ldap_uid + ldap_synced_at). + if _, err := registryPool.Exec(ctx, ` + INSERT INTO user_migration_map (archivmail_user_id, archivdms_user_id, nexarch_tenant_id, nexarch_user_id, + email, name, password_hash, approved_by, approved_at, ldap_uid, ldap_synced_at) + VALUES (NULL, $1, $2, $3, $4, $5, $6, 'qa-tester', now(), $7, now()) + `, int64(4001), tenantID, archivdmsUserID, archivdmsEmail, "Synthetischer DMS-Nutzer "+suffix, string(archivdmsHash), archivdmsLDAPUID); err != nil { + t.Fatalf("archivdms-map-eintrag anlegen: %v", err) + } + + // archivmail: nur das einfachere ldap_source-Feld, ldap_uid/ldap_synced_at + // fehlen noch. + if _, err := registryPool.Exec(ctx, ` + INSERT INTO user_migration_map (archivmail_user_id, archivdms_user_id, nexarch_tenant_id, nexarch_user_id, + email, name, password_hash, approved_by, approved_at, ldap_source) + VALUES ($1, NULL, $2, $3, $4, $5, $6, 'qa-tester', now(), $7) + `, int64(4002), tenantID, archivmailUserID, archivmailEmail, "Synthetischer Mail-Nutzer "+suffix, string(archivmailHash), archivmailLDAPSource); err != nil { + t.Fatalf("archivmail-map-eintrag anlegen: %v", err) + } + + // Konto ganz ohne LDAP-Bezug. + if _, err := registryPool.Exec(ctx, ` + INSERT INTO user_migration_map (archivmail_user_id, archivdms_user_id, nexarch_tenant_id, nexarch_user_id, + email, name, password_hash, approved_by, approved_at) + VALUES ($1, NULL, $2, $3, $4, $5, $6, 'qa-tester', now()) + `, int64(4003), tenantID, plainUserID, plainEmail, "Synthetischer Nutzer ohne LDAP "+suffix, string(plainHash)); err != nil { + t.Fatalf("map-eintrag ohne ldap anlegen: %v", err) + } + + mapStore := NewMapStore(registryPool) + migrator := NewMigrator(mapStore, registry) + defer migrator.Close() + + res, err := migrator.MigrateApproved(ctx) + if err != nil { + t.Fatalf("MigrateApproved: %v", err) + } + if len(res.Migrated) != 3 { + t.Fatalf("erwarte 3 migrierte Benutzer, habe %d", len(res.Migrated)) + } + + tenantPool := migrator.tenantPools[tenantID] + + // AC 1: archivdms-Feldtiefe 1:1 uebernommen. + var dmsUID, dmsSource *string + var dmsSyncedAt *time.Time + if err := tenantPool.QueryRow(ctx, `SELECT ldap_uid, ldap_synced_at, ldap_source FROM users WHERE id = $1`, archivdmsUserID). + Scan(&dmsUID, &dmsSyncedAt, &dmsSource); err != nil { + t.Fatalf("archivdms-konto nach migration lesen: %v", err) + } + if dmsUID == nil || *dmsUID != archivdmsLDAPUID { + t.Fatalf("erwarte ldap_uid %q, habe %v", archivdmsLDAPUID, dmsUID) + } + if dmsSyncedAt == nil { + t.Fatal("erwarte gesetztes ldap_synced_at fuer archivdms-konto") + } + + // AC 2: archivmail-Konto trotz fehlender ldap_uid/ldap_synced_at als + // LDAP-gebunden erkennbar (ueber ldap_source). + var mailUID, mailSource *string + var mailSyncedAt *time.Time + if err := tenantPool.QueryRow(ctx, `SELECT ldap_uid, ldap_synced_at, ldap_source FROM users WHERE id = $1`, archivmailUserID). + Scan(&mailUID, &mailSyncedAt, &mailSource); err != nil { + t.Fatalf("archivmail-konto nach migration lesen: %v", err) + } + if mailSource == nil || *mailSource != archivmailLDAPSource { + t.Fatalf("erwarte ldap_source %q, habe %v", archivmailLDAPSource, mailSource) + } + if mailUID != nil { + t.Fatalf("erwarte kein ldap_uid fuer archivmail-konto, habe %v", *mailUID) + } + + // AC 3: fehlende LDAP-Felder blockieren die Migration des Kontos nicht. + var plainCount int + if err := tenantPool.QueryRow(ctx, `SELECT count(*) FROM users WHERE id = $1`, plainUserID).Scan(&plainCount); err != nil { + t.Fatalf("konto ohne ldap pruefen: %v", err) + } + if plainCount != 1 { + t.Fatalf("erwarte konto ohne ldap-bezug trotzdem migriert, habe count %d", plainCount) + } +} + func newUUIDv4() string { b := make([]byte, 16) if _, err := rand.Read(b); err != nil { diff --git a/internal/user/tenant_store.go b/internal/user/tenant_store.go index eab20a3d..997e6679 100644 --- a/internal/user/tenant_store.go +++ b/internal/user/tenant_store.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "time" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgconn" @@ -68,6 +69,70 @@ func (s *TenantUserStore) CreateWithID(ctx context.Context, id, email, name, pas return u, nil } +// CreateWithIDAndLDAP wie CreateWithID, uebernimmt zusaetzlich die +// LDAP-Kopplungsfelder aus der Migrations-Map (COR-04). Jedes der drei +// Felder ist unabhaengig optional: ein archivmail-Konto mit nur ldapSource +// und ein archivdms-Konto mit voller Tiefe (ldapUID + ldapSyncedAt) sind +// beide gueltig, fehlende Felder bleiben NULL bis zum naechsten LDAP-Sync. +func (s *TenantUserStore) CreateWithIDAndLDAP(ctx context.Context, id, email, name, passwordHash string, ldapUID, ldapSource *string, ldapSyncedAt *time.Time) (User, error) { + if err := ValidateEmail(email); err != nil { + return User{}, err + } + + row := s.pool.QueryRow(ctx, ` + INSERT INTO users (id, email, name, status, password_hash, ldap_uid, ldap_synced_at, ldap_source) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + ON CONFLICT (id) DO NOTHING + RETURNING id, email, name, status, created_at, updated_at + `, id, email, name, StatusActive, passwordHash, ldapUID, ldapSyncedAt, ldapSource) + + u, err := scanUser(row) + if errors.Is(err, ErrNotFound) { + return s.Get(ctx, id) + } + if err != nil { + return User{}, mapWriteErr(err) + } + return u, nil +} + +// LDAPInfo liefert die LDAP-Kopplungsfelder eines Benutzers (COR-04, +// Akzeptanzkriterium 2: macht einen LDAP-Bezug auch bei unvollstaendigen +// Feldern erkennbar). +type LDAPInfo struct { + UID *string + SyncedAt *time.Time + Source *string +} + +func (s *TenantUserStore) LDAPInfo(ctx context.Context, id string) (LDAPInfo, error) { + var info LDAPInfo + row := s.pool.QueryRow(ctx, `SELECT ldap_uid, ldap_synced_at, ldap_source FROM users WHERE id = $1`, id) + if err := row.Scan(&info.UID, &info.SyncedAt, &info.Source); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return LDAPInfo{}, ErrNotFound + } + return LDAPInfo{}, fmt.Errorf("ldap-info lesen: %w", err) + } + return info, nil +} + +// SetLDAPUID ergaenzt ldap_uid und ldap_synced_at eines bestehenden Benutzers +// (COR-04, Pruefung 2: ein spaeterer LDAP-Sync-Lauf fuellt fehlende Felder +// nach, ohne zu scheitern). ldap_source bleibt dabei unangetastet. +func (s *TenantUserStore) SetLDAPUID(ctx context.Context, id, ldapUID string) error { + tag, err := s.pool.Exec(ctx, ` + UPDATE users SET ldap_uid = $2, ldap_synced_at = now(), updated_at = now() WHERE id = $1 + `, id, ldapUID) + if err != nil { + return fmt.Errorf("ldap-uid setzen: %w", err) + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + return nil +} + func (s *TenantUserStore) Get(ctx context.Context, id string) (User, error) { return scanUser(s.pool.QueryRow(ctx, ` SELECT id, email, name, status, created_at, updated_at diff --git a/migrations/0023_user_migration_map_ldap.down.sql b/migrations/0023_user_migration_map_ldap.down.sql new file mode 100644 index 00000000..545c9dba --- /dev/null +++ b/migrations/0023_user_migration_map_ldap.down.sql @@ -0,0 +1,4 @@ +ALTER TABLE user_migration_map + DROP COLUMN ldap_uid, + DROP COLUMN ldap_synced_at, + DROP COLUMN ldap_source; diff --git a/migrations/0023_user_migration_map_ldap.up.sql b/migrations/0023_user_migration_map_ldap.up.sql new file mode 100644 index 00000000..10e988b0 --- /dev/null +++ b/migrations/0023_user_migration_map_ldap.up.sql @@ -0,0 +1,9 @@ +-- COR-04: LDAP-Kopplungsfelder in der Benutzer-Migrations-Map, erweitert +-- COR-02 (user_migration_map). ldap_uid/ldap_synced_at in archivdms-Tiefe +-- (Zielschema), ldap_source fuer archivmail-Konten mit nur einfachem +-- Quellhinweis -- alle drei nullable, da LDAP-Bezug optional ist und +-- unvollstaendige Angaben die Migration nicht blockieren duerfen. +ALTER TABLE user_migration_map + ADD COLUMN ldap_uid TEXT, + ADD COLUMN ldap_synced_at TIMESTAMPTZ, + ADD COLUMN ldap_source TEXT; diff --git a/migrations/tenant/0011_users_ldap.down.sql b/migrations/tenant/0011_users_ldap.down.sql new file mode 100644 index 00000000..65383333 --- /dev/null +++ b/migrations/tenant/0011_users_ldap.down.sql @@ -0,0 +1,4 @@ +ALTER TABLE users + DROP COLUMN ldap_uid, + DROP COLUMN ldap_synced_at, + DROP COLUMN ldap_source; diff --git a/migrations/tenant/0011_users_ldap.up.sql b/migrations/tenant/0011_users_ldap.up.sql new file mode 100644 index 00000000..9f4a3ced --- /dev/null +++ b/migrations/tenant/0011_users_ldap.up.sql @@ -0,0 +1,7 @@ +-- COR-04: LDAP-Kopplungsfelder je Tenant-Benutzer, gleiche Spalten wie +-- user_migration_map (siehe migrations/0023). Nullable: ein Konto ohne +-- LDAP-Bezug oder mit noch unvollstaendigem Sync-Stand bleibt gueltig. +ALTER TABLE users + ADD COLUMN ldap_uid TEXT, + ADD COLUMN ldap_synced_at TIMESTAMPTZ, + ADD COLUMN ldap_source TEXT;