COR-04: LDAP-Feld-Migration #262
No files matched your search
@@ -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.
|
||||
@@ -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).
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
ALTER TABLE user_migration_map
|
||||
DROP COLUMN ldap_uid,
|
||||
DROP COLUMN ldap_synced_at,
|
||||
DROP COLUMN ldap_source;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,4 @@
|
||||
ALTER TABLE users
|
||||
DROP COLUMN ldap_uid,
|
||||
DROP COLUMN ldap_synced_at,
|
||||
DROP COLUMN ldap_source;
|
||||
@@ -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;
|
||||
Reference in new issue
Block a user