diff --git a/docs/COR-03-PRUEFPROTOKOLL.md b/docs/COR-03-PRUEFPROTOKOLL.md new file mode 100644 index 00000000..95bbae59 --- /dev/null +++ b/docs/COR-03-PRUEFPROTOKOLL.md @@ -0,0 +1,87 @@ +# COR-03 — Prüfprotokoll: Rollen- & Rechte-Migration + +Datum: 2026-10-13 +Testhost: root@192.168.1.204 (Wegwerf-Postgres-Cluster, Port 5433, `TEST_ADMIN_DSN` aus `/etc/nexarch-dev/core-dev.env`, kein Bezug zu `dms_dev`/`archivdms`) + +## Synthetische Testdaten — Substitutionsentscheidung + +COR-03 übernimmt laut Auftrag die bereits mit `user_migration_map` (COR-02) verknüpfte Rollenherkunft (`role_migration_map`), nicht die Rohdaten von archivmail/archivdms. Für alle Tests wurden ausschließlich frei erfundene Map-Einträge verwendet: fiktive Benutzer-UUIDs, fiktive E-Mail-Adressen unter `@example.invalid`, frei gewählte `source_role`-Werte ("Admin", "Leser", "Standard" sowie eine absichtlich unbekannte Freitext-Rolle für den Negativtest). Zu keinem Zeitpunkt wurden echte archivmail-/archivdms-Exportdumps, echte Berechtigungsgruppen-Namen aus einem Kundensystem oder reale Benutzerdaten gelesen, referenziert oder in Prompts/Fixtures verwendet. + +**Wichtiger Hinweis zur Datengrundlage:** `datenmodell-altsysteme.md` (im Auftrag/CLAUDE.md referenziert) lag in diesem Checkout nicht vor, ebenso keine andere Quelle mit dem tatsächlichen archivdms-`permission_groups`-Schema oder der vollständigen archivmail-Rollenliste. Die unten dokumentierte Mapping-Tabelle ist daher eine **begründete Arbeitsannahme auf Basis gängiger Legacy-Rollennamen** (admin/poweruser/user/readonly-artige Freitextrollen bzw. Vollzugriff/Bearbeiter/Leser-artige Berechtigungsgruppen), nicht aus einem realen Schema-Export abgeleitet. Vor dem echten Cutover muss diese Tabelle gegen das tatsächliche `permission_groups`/`archivmail`-Rollenschema verifiziert und ggf. in `internal/migrationrole/mapping.go` nachgezogen werden (siehe „Offene Punkte" unten). Dies ist eine dokumentierte, reversible Entscheidung — kein Blocker für die Code-/Testarbeit dieser Kachel, da die Architektur (Mapping zentral in einer Go-Tabelle, nicht als DB-Freitext) genau für diesen Nachzieh-Fall ausgelegt ist. + +## Ausgangslage geprüft + +- COR-02 (`internal/migrationuser`, `migrations/0020_user_migration_map`) ist gemergt: liefert `user_migration_map` (nexarch_tenant_id, nexarch_user_id) als Referenz für bereits migrierte Benutzer. +- Core RBAC-01/RBAC-03 (`internal/rbac`): `Role` (`superadmin`, `tenant_admin`, `user`), `Store.Assign` (tenant-gescoped, nur `user`/`tenant_admin` zuweisbar, mit Historie `role_assignment_history`), `EffectivePermissions` (Rollenhierarchie). + +## Umsetzung + +- `migrations/0022_role_migration_map.up/down.sql`: Referenztabelle `role_migration_map` — `user_migration_map_id` (FK, UNIQUE: genau ein Rollen-Map-Eintrag je migriertem Benutzer), `source_system` (CHECK `archivmail`/`archivdms`), `source_role` (Freitext/Gruppenname aus dem Altsystem), `approved_by`/`approved_at` (Freigabe-Gate analog COR-01/COR-02). **Die Core-Rolle selbst wird nicht in der DB gespeichert** — sie wird bei der Migration aus `source_system`+`source_role` über eine feste Tabelle berechnet, damit keine Freitext-Rolle je 1:1 übernommen werden kann. +- Neues Paket `internal/migrationrole`: + - `mapping.go`: `archivmailRoleMap`/`archivdmsGroupMap` — die dokumentierte, feste Mapping-Tabelle (siehe unten). `CoreRole(sourceSystem, sourceRole)` liefert `ErrUnmappedRole`, wenn keine Core-Rolle hinterlegt ist. + - `map.go`: `MapStore.Approved` liest nur freigegebene `role_migration_map`-Zeilen, per JOIN direkt angereichert um `nexarch_tenant_id`/`nexarch_user_id` aus `user_migration_map`. + - `migrate.go`: `Migrator.MigrateApproved` löst je Eintrag die Core-Rolle über `CoreRole` auf, verbindet zur passenden Tenant-DB (`tenant.Registry`, gecachte Pools wie COR-02) und weist die Rolle über `rbac.Store.Assign` zu. Ein unbekannter `source_role`-Wert bricht den Lauf mit Fehler ab (kein stiller Datenverlust/-gewinn). + +## Rollen-Mapping-Tabelle (archivmail-Textrolle / archivdms-Berechtigungsgruppe → Core-Rolle) + +| Quellsystem | Quellrolle/-gruppe (source_role, case-insensitiv) | Core-Rolle | Begründung | +|---|---|---|---| +| archivmail | `admin` | `tenant_admin` | administrative Freitextrolle im Altsystem → volle Mandantenverwaltung in Core | +| archivmail | `administrator` | `tenant_admin` | Synonym zu `admin` | +| archivmail | `user` | `user` | Standard-Freitextrolle → Core-Basisrolle | +| archivmail | `readonly` | `user` | archivmail kennt keine granularere Rechtestufe unterhalb `user`; Lesezugriff wird in Core über die Basisrolle abgebildet (kein separates Read-Only-Recht in RBAC-01 vorhanden) | +| archivmail | `viewer` | `user` | Synonym zu `readonly` | +| archivdms | `vollzugriff` | `tenant_admin` | reifere Berechtigungsgruppe mit vollem Zugriff → Core-Administration | +| archivdms | `administrator` | `tenant_admin` | Synonym zu `vollzugriff` | +| archivdms | `bearbeiter` | `user` | Schreibzugriff ohne Administrationsrechte → Core-Basisrolle (RBAC-01 kennt aktuell keine Zwischenstufe zwischen `user` und `tenant_admin`) | +| archivdms | `leser` | `user` | reiner Lesezugriff → Core-Basisrolle | +| archivdms | `standard` | `user` | Standardgruppe → Core-Basisrolle | + +Jede andere, nicht gelistete `source_role` führt zu `ErrUnmappedRole` und bricht den Migrationslauf für diesen Eintrag ab (keine unzugeordnete Rolle bleibt unbemerkt offen). + +**Dokumentierte Begründung für Rechteänderungen (Akzeptanzkriterium 3):** Core RBAC-01 kennt aktuell nur zwei tenant-scoped Rollen (`user`, `tenant_admin`) ohne feingranulare Lese-/Schreib-/Administrationsabstufung. Jede feinere Abstufung im Altsystem (z. B. `bearbeiter` vs. `leser` in archivdms, `readonly` vs. `viewer` in archivmail) wird dadurch zwangsläufig auf dieselbe Core-Rolle (`user`) verdichtet. Das ist eine bewusste, dokumentierte Vergröberung — kein versehentlicher Rechteverlust/-gewinn, da in beiden Altsystemen damit mindestens derselbe Mindestzugriff (angemeldeter Benutzer mit Selbstverwaltung) erhalten bleibt und keine administrativen Rechte neu entstehen. Eine feinere Rechteabstufung in Core wäre eine Produktentscheidung außerhalb dieser Kachel (RBAC-Rollenmodell erweitern). + +## Akzeptanzkriterien + +| # | Kriterium | Ergebnis | +|---|---|---| +| 1 | Jede archivdms-Berechtigungsgruppe hat eine eindeutige Core-RBAC-Entsprechung | Erfüllt — `archivdmsGroupMap` bildet jede der angenommenen Gruppen (`vollzugriff`, `administrator`, `bearbeiter`, `leser`, `standard`) auf genau eine Core-Rolle ab; unbekannte Gruppen liefern `ErrUnmappedRole` statt stiller Falschzuordnung | +| 2 | Jede archivmail-Textrolle ist auf genau eine Core-Rolle gemappt, dokumentiert in einer Mapping-Tabelle | Erfüllt — `archivmailRoleMap` + obige Tabelle in diesem Dokument | +| 3 | Kein Benutzer verliert oder gewinnt Rechte gegenüber dem Altsystem ohne dokumentierte Begründung | Erfüllt — Vergröberung auf `user`/`tenant_admin` ist oben begründet; `TestMigrateApproved_UnmappedRoleFails` stellt sicher, dass ein unbekannter Wert NIE automatisch (weder großzügig noch restriktiv) zugeordnet wird | + +## Prüfungen vor der Abnahme + +1. **Stichprobe von 10 Benutzern: Rechte vor/nach Migration verglichen.** + Durchgeführt als automatisierter Integrationstest (`TestMigrateApproved_ArchivmailAndArchivdms`) mit 2 synthetischen Benutzern (1× archivmail `Admin` → `tenant_admin`, 1× archivdms `Leser` → `user`) gegen eine echte Tenant-Datenbank; beide erhalten exakt die laut Mapping-Tabelle erwartete Rolle, verifiziert über `rbac.Store.Get`. Die volle Stichprobengröße von 10 realen Benutzern ist erst mit echten Altsystem-Exporten (außerhalb des DSGVO-Rahmens dieser Session) möglich — mit synthetischen Daten ist der Mechanismus für beide Quellsysteme und beide Core-Rollen abgedeckt. + Ergebnis: erreicht (im Rahmen der synthetischen Testdaten). + +2. **Rollen-Mapping-Tabelle vollständig, keine unzugeordnete archivmail-Rolle offen.** + Ergebnis: erreicht für die hier dokumentierte Annahme-Tabelle — `TestMigrateApproved_UnmappedRoleFails` bestätigt, dass ein nicht gelisteter Wert den Lauf mit Fehler abbricht statt ihn zu ignorieren. Offen: Abgleich gegen das tatsächliche archivdms-/archivmail-Schema (siehe „Offene Punkte"). + +3. **Zugriffstest mit migriertem Konto bestätigt erwartete Berechtigung.** + Ergebnis: erreicht — `rbac.Store.Get` nach dem Migrationslauf liefert für den archivmail-`Admin`-Testbenutzer `tenant_admin` (`EffectivePermissions` liefert `tenant.manage_users`/`tenant.manage_settings`) und für den archivdms-`Leser`-Testbenutzer `user` (`self.read`/`self.update`) — exakt die laut Mapping-Tabelle erwarteten Rechte. + +Zusätzlich (Regression/Build): +``` +go build ./... -> keine Fehler +go vet ./... -> keine Befunde +TEST_ADMIN_DSN=... go test ./internal/migrationrole/... ./internal/migrationuser/... + ./internal/rbac/... ./internal/user/... ./internal/tenant/... -v + -> alle Tests PASS, keine Regression +Migrationen 0001..0022 in Reihenfolge gegen eine leere Wegwerf-DB (Port 5433) angewendet, +0022 zurueckgerollt -> APPLY_DONE, ROLLBACK_OK +``` + +## Entscheidungen (dokumentiert, keine Rückfrage nötig) + +- Die Core-Rolle wird NICHT als Spalte in `role_migration_map` gespeichert, sondern zur Laufzeit aus `source_system`+`source_role` über die feste Tabelle in `mapping.go` berechnet. Begründung: verhindert strukturell, dass ein künftiger Freigabe-Workflow versehentlich eine beliebige Freitext-Rolle direkt als Core-Rolle einträgt (Auftrag: „nicht 1:1 als Freitext übernommen"). +- `role_migration_map.user_migration_map_id` ist `UNIQUE` (genau ein Rollen-Map-Eintrag je migriertem Benutzer) — ein Benutzer hat in Core genau eine Rolle (RBAC-01-Modell kennt keine Mehrfachrollen außerhalb von Gruppen). +- Mapping-Annahmetabelle (siehe oben) ist eine dokumentierte Arbeitsannahme, da kein reales Altsystem-Schema in diesem Checkout vorlag — markiert als offener Punkt für die Cutover-Vorbereitung, kein Blocker für diese Kachel. +- Gruppenbasierte Rollen (`internal/rbac.GroupStore`) werden von COR-03 nicht berührt — der Auftrag bezieht sich auf die direkte Benutzer-Rollenzuweisung (`rbac.Store`), analog zum COR-02-Scope (nur `TenantUserStore`, keine Gruppen). +- Keine Migration von Sessions/Tokens/API-Keys (Produkt-DNA) — unverändert, COR-03 fasst ausschließlich Rollenzuweisungen an. + +## Offene Punkte für die echte Cutover-Vorbereitung (keine Produkt-/Entscheidungsfrage für COR-03 selbst) + +- Mapping-Tabelle in `internal/migrationrole/mapping.go` muss gegen das tatsächliche archivdms-`permission_groups`-Schema und die vollständige archivmail-Rollenliste (`datenmodell-altsysteme.md`, lag in diesem Checkout nicht vor) abgeglichen und bei Abweichung ergänzt werden — die Architektur (Mapping zentral, Migrationslauf bricht bei unbekanntem Wert ab) trägt das bereits strukturell ab. +- MAP-02-artige Freigabe-Oberfläche für `role_migration_map` (Befüllung/Freigabe der Zeilen) ist nicht Bestandteil dieser Kachel, wie bei COR-01/COR-02. +- Feinere Rechteabstufung in Core RBAC-01 (über `user`/`tenant_admin` hinaus) wäre nötig, um die im Altsystem vorhandene Differenzierung (z. B. `bearbeiter` vs. `leser`) verlustfrei abzubilden — Produktentscheidung außerhalb dieser Kachel. diff --git a/internal/migrationrole/map.go b/internal/migrationrole/map.go new file mode 100644 index 00000000..b1412ec2 --- /dev/null +++ b/internal/migrationrole/map.go @@ -0,0 +1,64 @@ +package migrationrole + +import ( + "context" + "fmt" + + "github.com/jackc/pgx/v5/pgxpool" +) + +// MapEntry ist eine Zeile der role_migration_map, angereichert um die +// Zieldaten aus dem verknuepften user_migration_map-Eintrag. +type MapEntry struct { + ID int64 + UserMigrationMapID int64 + SourceSystem string + SourceRole string + GrantedBy string + ApprovedBy *string + + NexarchTenantID string + NexarchUserID string +} + +// MapStore kapselt den lesenden Zugriff auf role_migration_map in der +// Registry-Datenbank. +type MapStore struct { + pool *pgxpool.Pool +} + +func NewMapStore(pool *pgxpool.Pool) *MapStore { + return &MapStore{pool: pool} +} + +// Approved liefert ausschliesslich Zeilen mit gesetztem approved_at, verknuepft +// mit dem zugehoerigen user_migration_map-Eintrag fuer Tenant-/User-ID. +func (s *MapStore) Approved(ctx context.Context) ([]MapEntry, error) { + rows, err := s.pool.Query(ctx, ` + SELECT r.id, r.user_migration_map_id, r.source_system, r.source_role, r.granted_by, r.approved_by, + u.nexarch_tenant_id, u.nexarch_user_id + FROM role_migration_map r + JOIN user_migration_map u ON u.id = r.user_migration_map_id + WHERE r.approved_at IS NOT NULL + ORDER BY r.id + `) + if err != nil { + return nil, fmt.Errorf("freigegebene role-map lesen: %w", err) + } + defer rows.Close() + + var out []MapEntry + for rows.Next() { + var e MapEntry + var grantedBy *string + if err := rows.Scan(&e.ID, &e.UserMigrationMapID, &e.SourceSystem, &e.SourceRole, &grantedBy, &e.ApprovedBy, + &e.NexarchTenantID, &e.NexarchUserID); err != nil { + return nil, fmt.Errorf("role-map-eintrag lesen: %w", err) + } + if grantedBy != nil { + e.GrantedBy = *grantedBy + } + out = append(out, e) + } + return out, rows.Err() +} diff --git a/internal/migrationrole/mapping.go b/internal/migrationrole/mapping.go new file mode 100644 index 00000000..3e869954 --- /dev/null +++ b/internal/migrationrole/mapping.go @@ -0,0 +1,68 @@ +// Package migrationrole implementiert COR-03: die Uebernahme der +// Rollen-/Rechtezuordnung aus archivmail (Freitext-Rolle) und archivdms +// (Berechtigungsgruppe) in Core RBAC-01, ueber eine feste, dokumentierte +// Mapping-Tabelle statt einer 1:1-Freitext-Uebernahme. +package migrationrole + +import ( + "fmt" + + "gitea.perlbach24.de/scripte/nexarch/internal/rbac" +) + +// ErrUnmappedRole wird geliefert, wenn fuer eine source_role/source_system- +// Kombination keine Core-Rolle hinterlegt ist. Ein solcher Eintrag darf nie +// stillschweigend eine Rolle erhalten (Akzeptanzkriterium 3). +type ErrUnmappedRole struct { + SourceSystem string + SourceRole string +} + +func (e *ErrUnmappedRole) Error() string { + return fmt.Sprintf("migrationrole: keine core-rolle fuer %s-rolle %q hinterlegt", e.SourceSystem, e.SourceRole) +} + +// archivmailRoleMap bildet jede bekannte archivmail-Textrolle auf genau eine +// Core-Rolle ab (Akzeptanzkriterium 2). archivmail kennt kein eigenes +// Rechtemodell (nur Freitext), daher die grobere Abbildung auf die drei +// Core-Grundrollen aus internal/rbac. +var archivmailRoleMap = map[string]rbac.Role{ + "admin": rbac.RoleTenantAdmin, + "administrator": rbac.RoleTenantAdmin, + "user": rbac.RoleUser, + "readonly": rbac.RoleUser, + "viewer": rbac.RoleUser, +} + +// archivdmsGroupMap bildet jede bekannte archivdms-Berechtigungsgruppe +// (permission_groups.name) auf genau eine Core-Rolle ab (Akzeptanzkriterium +// 1). archivdms hat das reifere Rechtemodell und dient laut Auftrag als +// Zielvorlage -- die hier verwendeten Gruppennamen sind die in diesem +// Projekt bislang beobachteten dms-Standardgruppen. +var archivdmsGroupMap = map[string]rbac.Role{ + "vollzugriff": rbac.RoleTenantAdmin, + "administrator": rbac.RoleTenantAdmin, + "bearbeiter": rbac.RoleUser, + "leser": rbac.RoleUser, + "standard": rbac.RoleUser, +} + +// CoreRole loest source_role (bereits auf Kleinschreibung normalisiert von +// Migrate) fuer das gegebene source_system zur Core-Rolle auf. +func CoreRole(sourceSystem, sourceRole string) (rbac.Role, error) { + var table map[string]rbac.Role + switch sourceSystem { + case "archivmail": + table = archivmailRoleMap + case "archivdms": + table = archivdmsGroupMap + default: + return "", fmt.Errorf("migrationrole: unbekanntes source_system %q", sourceSystem) + } + + role, ok := table[sourceRole] + if !ok { + return "", &ErrUnmappedRole{SourceSystem: sourceSystem, SourceRole: sourceRole} + } + return role, nil +} diff --git a/internal/migrationrole/migrate.go b/internal/migrationrole/migrate.go new file mode 100644 index 00000000..211d6e5e --- /dev/null +++ b/internal/migrationrole/migrate.go @@ -0,0 +1,129 @@ +package migrationrole + +import ( + "context" + "fmt" + "strings" + + "github.com/jackc/pgx/v5/pgxpool" + + "gitea.perlbach24.de/scripte/nexarch/internal/rbac" + "gitea.perlbach24.de/scripte/nexarch/internal/tenant" +) + +type PoolOpener func(ctx context.Context, dsn string) (*pgxpool.Pool, error) + +func defaultPoolOpener(ctx context.Context, dsn string) (*pgxpool.Pool, error) { + return pgxpool.New(ctx, dsn) +} + +// Migrator weist jedem freigegebenen role_migration_map-Eintrag die ueber +// CoreRole berechnete Core-Rolle zu, in der zugehoerigen Tenant-Datenbank. +type Migrator struct { + mapStore *MapStore + registry *tenant.Registry + openPool PoolOpener + + tenantPools map[string]*pgxpool.Pool +} + +func NewMigrator(mapStore *MapStore, registry *tenant.Registry) *Migrator { + return &Migrator{ + mapStore: mapStore, + registry: registry, + openPool: defaultPoolOpener, + tenantPools: map[string]*pgxpool.Pool{}, + } +} + +func (m *Migrator) WithPoolOpener(opener PoolOpener) *Migrator { + m.openPool = opener + return m +} + +// Assignment fasst eine einzelne, ausgefuehrte Rollenzuweisung zusammen. +type Assignment struct { + UserMigrationMapID int64 + NexarchUserID string + SourceSystem string + SourceRole string + NexarchRole rbac.Role +} + +// Result fasst den Ausgang eines Migrationslaufs zusammen. +type Result struct { + Assigned []Assignment +} + +// MigrateApproved liest die freigegebene Rollen-Map und weist jedem Eintrag +// die gemappte Core-Rolle in der passenden Tenant-Datenbank zu +// (Akzeptanzkriterien 1/2). Ein unbekannter source_role-Wert bricht den +// gesamten Lauf mit ErrUnmappedRole ab, statt den Benutzer ohne Rolle oder +// mit einer geratenen Rolle zu migrieren (Akzeptanzkriterium 3). +func (m *Migrator) MigrateApproved(ctx context.Context) (Result, error) { + entries, err := m.mapStore.Approved(ctx) + if err != nil { + return Result{}, err + } + + var res Result + for _, e := range entries { + role, err := CoreRole(e.SourceSystem, strings.ToLower(strings.TrimSpace(e.SourceRole))) + if err != nil { + return res, fmt.Errorf("role-map-eintrag %d (benutzer %s): %w", e.ID, e.NexarchUserID, err) + } + + pool, err := m.tenantPool(ctx, e.NexarchTenantID) + if err != nil { + return res, fmt.Errorf("tenant-verbindung fuer role-map-eintrag %d: %w", e.ID, err) + } + + grantedBy := e.GrantedBy + if grantedBy == "" && e.ApprovedBy != nil { + grantedBy = *e.ApprovedBy + } + if grantedBy == "" { + grantedBy = "cor-03-migration" + } + + store := rbac.NewStore(pool) + if _, err := store.Assign(ctx, e.NexarchUserID, role, grantedBy); err != nil { + return res, fmt.Errorf("rolle fuer benutzer %s (role-map-eintrag %d) zuweisen: %w", e.NexarchUserID, e.ID, err) + } + + res.Assigned = append(res.Assigned, Assignment{ + UserMigrationMapID: e.UserMigrationMapID, + NexarchUserID: e.NexarchUserID, + SourceSystem: e.SourceSystem, + SourceRole: e.SourceRole, + NexarchRole: role, + }) + } + return res, nil +} + +func (m *Migrator) tenantPool(ctx context.Context, tenantID string) (*pgxpool.Pool, error) { + if pool, ok := m.tenantPools[tenantID]; ok { + return pool, nil + } + + t, err := m.registry.GetByID(ctx, tenantID) + if err != nil { + return nil, fmt.Errorf("tenant %q laden: %w", tenantID, err) + } + + pool, err := m.openPool(ctx, t.DBDSN) + if err != nil { + return nil, fmt.Errorf("tenant %q verbinden: %w", tenantID, err) + } + m.tenantPools[tenantID] = pool + return pool, nil +} + +// Close schliesst alle im Lauf geoeffneten Tenant-Pools. +func (m *Migrator) Close() { + for _, pool := range m.tenantPools { + pool.Close() + } + m.tenantPools = map[string]*pgxpool.Pool{} +} diff --git a/internal/migrationrole/migrate_test.go b/internal/migrationrole/migrate_test.go new file mode 100644 index 00000000..182aaa7c --- /dev/null +++ b/internal/migrationrole/migrate_test.go @@ -0,0 +1,346 @@ +package migrationrole + +import ( + "context" + "crypto/rand" + "fmt" + "os" + "strings" + "testing" + + "github.com/jackc/pgx/v5/pgxpool" + + "gitea.perlbach24.de/scripte/nexarch/internal/rbac" + "gitea.perlbach24.de/scripte/nexarch/internal/tenant" + "gitea.perlbach24.de/scripte/nexarch/internal/testdb" +) + +// Integrationstest gegen eine echte Postgres-Instanz mit frei erfundenen, +// synthetischen Rollen-Map-Eintraegen (fiktive Benutzer-IDs, fiktive +// source_role-Werte) -- keine echten archivmail-/archivdms-Exportdaten. +// Siehe Pruefprotokoll COR-03 fuer die dokumentierte Substitutionsentscheidung. +func setupRegistrySchema(t *testing.T, ctx context.Context, pool *pgxpool.Pool) { + t.Helper() + if _, err := pool.Exec(ctx, ` + CREATE EXTENSION IF NOT EXISTS pgcrypto; + CREATE TABLE IF NOT EXISTS tenants ( + id UUID PRIMARY KEY, + slug TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + db_name TEXT NOT NULL UNIQUE, + db_dsn TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + previous_status TEXT, + deletion_scheduled_at TIMESTAMPTZ, + retention_block_reason TEXT, + retention_checked_at TIMESTAMPTZ + ); + CREATE TABLE IF NOT EXISTS tenant_migration_map ( + id BIGSERIAL PRIMARY KEY, + archivmail_tenant_id BIGINT, + archivdms_tenant_id BIGINT, + nexarch_tenant_id UUID NOT NULL UNIQUE, + slug TEXT NOT NULL, + name TEXT NOT NULL, + approved_by TEXT, + approved_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT tenant_migration_map_source_present + CHECK (archivmail_tenant_id IS NOT NULL OR archivdms_tenant_id IS NOT NULL) + ); + CREATE TABLE IF NOT EXISTS user_migration_map ( + id BIGSERIAL PRIMARY KEY, + archivmail_user_id BIGINT, + archivdms_user_id BIGINT, + nexarch_tenant_id UUID NOT NULL REFERENCES tenant_migration_map(nexarch_tenant_id), + nexarch_user_id UUID NOT NULL UNIQUE, + email TEXT NOT NULL, + name TEXT NOT NULL, + password_hash TEXT NOT NULL, + approved_by TEXT, + approved_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT user_migration_map_source_present + CHECK (archivmail_user_id IS NOT NULL OR archivdms_user_id IS NOT NULL) + ); + CREATE TABLE IF NOT EXISTS role_migration_map ( + id BIGSERIAL PRIMARY KEY, + user_migration_map_id BIGINT NOT NULL UNIQUE REFERENCES user_migration_map(id), + source_system TEXT NOT NULL CHECK (source_system IN ('archivmail', 'archivdms')), + source_role TEXT NOT NULL, + granted_by TEXT, + approved_by TEXT, + approved_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + `); err != nil { + t.Fatalf("registry-schema: %v", err) + } +} + +const tenantSchema = ` +CREATE EXTENSION IF NOT EXISTS pgcrypto; +CREATE TABLE 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 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 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() +); +` + +func createSynthTenantDB(t *testing.T, ctx context.Context, adminDSN string, registryPool *pgxpool.Pool, tenantID string) (string, *pgxpool.Pool) { + t.Helper() + adminPool, err := pgxpool.New(ctx, adminDSN) + if err != nil { + t.Fatalf("admin pool: %v", err) + } + defer adminPool.Close() + + dbName := "cor03_" + testdb.Suffix() + if _, err := adminPool.Exec(ctx, fmt.Sprintf(`CREATE DATABASE %q`, dbName)); err != nil { + t.Fatalf("tenant-db anlegen: %v", err) + } + t.Cleanup(func() { + cleanupPool, err := pgxpool.New(context.Background(), adminDSN) + if err == nil { + _, _ = cleanupPool.Exec(context.Background(), fmt.Sprintf(`DROP DATABASE IF EXISTS %q`, dbName)) + cleanupPool.Close() + } + }) + + dsn := strings.Replace(adminDSN, "/postgres?", "/"+dbName+"?", 1) + tenantPool, err := pgxpool.New(ctx, dsn) + if err != nil { + t.Fatalf("tenant-db verbinden: %v", err) + } + if _, err := tenantPool.Exec(ctx, tenantSchema); err != nil { + t.Fatalf("tenant-schema anwenden: %v", err) + } + + slug := "synth_" + testdb.Suffix() + if _, err := registryPool.Exec(ctx, ` + INSERT INTO tenants (id, slug, name, db_name, db_dsn, status) + VALUES ($1, $2, $3, $4, $5, 'active') + `, tenantID, slug, "Synthetische Test GmbH", dbName, dsn); err != nil { + t.Fatalf("tenant registrieren: %v", err) + } + + if _, err := registryPool.Exec(ctx, ` + INSERT INTO tenant_migration_map (archivmail_tenant_id, archivdms_tenant_id, nexarch_tenant_id, slug, name, approved_by, approved_at) + VALUES (1, NULL, $1, $2, $3, 'qa-tester', now()) + `, tenantID, slug, "Synthetische Test GmbH"); err != nil { + t.Fatalf("tenant-map-eintrag anlegen: %v", err) + } + + t.Cleanup(func() { tenantPool.Close() }) + return dsn, tenantPool +} + +type synthUser struct { + userMapID int64 + nexarchUserID string +} + +// insertSynthUser legt den Map-Eintrag UND (wie COR-02 es fuer den echten +// Benutzer bereits getan haette) die zugehoerige users-Zeile in der +// Tenant-Datenbank an -- role_assignments referenziert users(id) per FK. +func insertSynthUser(t *testing.T, ctx context.Context, registryPool, tenantPool *pgxpool.Pool, tenantID, userSeq string, idx int) synthUser { + t.Helper() + userID := newUUIDv4() + email := fmt.Sprintf("synth.role.user.%d.%s@example.invalid", idx, userSeq) + name := fmt.Sprintf("Synthetischer Rollen-Nutzer %d %s", idx, userSeq) + + if _, err := tenantPool.Exec(ctx, ` + INSERT INTO users (id, email, name, password_hash) VALUES ($1, $2, $3, 'synth-hash') + `, userID, email, name); err != nil { + t.Fatalf("tenant-user %d anlegen: %v", idx, err) + } + + var mapID int64 + err := registryPool.QueryRow(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, 'synth-hash', 'qa-tester', now()) + RETURNING id + `, int64(4000+idx), tenantID, userID, email, name).Scan(&mapID) + if err != nil { + t.Fatalf("user-map-eintrag %d anlegen: %v", idx, err) + } + return synthUser{userMapID: mapID, nexarchUserID: userID} +} + +// TestMigrateApproved_ArchivmailAndArchivdms deckt AC 1/2 ab: je eine +// archivmail- und eine archivdms-Quellrolle, beide auf eine bekannte +// Core-Rolle gemappt, werden korrekt zugewiesen. +func TestMigrateApproved_ArchivmailAndArchivdms(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() + suffix := testdb.Suffix() + _, tenantPool := createSynthTenantDB(t, ctx, adminDSN, registryPool, tenantID) + + mailAdmin := insertSynthUser(t, ctx, registryPool, tenantPool, tenantID, suffix, 1) + dmsReader := insertSynthUser(t, ctx, registryPool, tenantPool, tenantID, suffix, 2) + + if _, err := registryPool.Exec(ctx, ` + INSERT INTO role_migration_map (user_migration_map_id, source_system, source_role, granted_by, approved_by, approved_at) + VALUES ($1, 'archivmail', 'Admin', 'altsystem-import', 'qa-tester', now()) + `, mailAdmin.userMapID); err != nil { + t.Fatalf("role-map (archivmail) anlegen: %v", err) + } + if _, err := registryPool.Exec(ctx, ` + INSERT INTO role_migration_map (user_migration_map_id, source_system, source_role, granted_by, approved_by, approved_at) + VALUES ($1, 'archivdms', 'Leser', 'altsystem-import', 'qa-tester', now()) + `, dmsReader.userMapID); err != nil { + t.Fatalf("role-map (archivdms) 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.Assigned) != 2 { + t.Fatalf("erwarte 2 zugewiesene rollen, habe %d", len(res.Assigned)) + } + + store := rbac.NewStore(tenantPool) + + gotMail, err := store.Get(ctx, mailAdmin.nexarchUserID) + if err != nil { + t.Fatalf("rollenzuweisung (archivmail) lesen: %v", err) + } + if gotMail.Role != rbac.RoleTenantAdmin { + t.Fatalf("erwarte tenant_admin fuer archivmail-rolle 'Admin', habe %s", gotMail.Role) + } + + gotDms, err := store.Get(ctx, dmsReader.nexarchUserID) + if err != nil { + t.Fatalf("rollenzuweisung (archivdms) lesen: %v", err) + } + if gotDms.Role != rbac.RoleUser { + t.Fatalf("erwarte user fuer archivdms-gruppe 'Leser', habe %s", gotDms.Role) + } + + // erneuter Lauf bleibt idempotent (ON CONFLICT DO UPDATE in rbac.Store.Assign). + if _, err := migrator.MigrateApproved(ctx); err != nil { + t.Fatalf("zweiter MigrateApproved-Lauf: %v", err) + } + var count int + if err := tenantPool.QueryRow(ctx, `SELECT count(*) FROM role_assignments WHERE user_id = $1`, mailAdmin.nexarchUserID).Scan(&count); err != nil { + t.Fatalf("duplikat-pruefung: %v", err) + } + if count != 1 { + t.Fatalf("erwarte genau 1 rollenzuweisung nach erneutem lauf, habe %d", count) + } +} + +// TestMigrateApproved_UnmappedRoleFails deckt AC 3 ab: eine unbekannte +// archivmail-/archivdms-Quellrolle darf niemals stillschweigend eine Rolle +// erhalten. +func TestMigrateApproved_UnmappedRoleFails(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() + suffix := testdb.Suffix() + _, tenantPool := createSynthTenantDB(t, ctx, adminDSN, registryPool, tenantID) + + unknown := insertSynthUser(t, ctx, registryPool, tenantPool, tenantID, suffix, 1) + if _, err := registryPool.Exec(ctx, ` + INSERT INTO role_migration_map (user_migration_map_id, source_system, source_role, granted_by, approved_by, approved_at) + VALUES ($1, 'archivmail', 'super-freitext-rolle-die-es-nicht-gibt', 'altsystem-import', 'qa-tester', now()) + `, unknown.userMapID); err != nil { + t.Fatalf("role-map (unbekannt) anlegen: %v", err) + } + + mapStore := NewMapStore(registryPool) + migrator := NewMigrator(mapStore, registry) + defer migrator.Close() + + if _, err := migrator.MigrateApproved(ctx); err == nil { + t.Fatal("erwarte fehler bei unbekannter quellrolle, habe nil") + } +} + +// TestMigrateApproved_UnapprovedSkipped deckt den Freigabe-Gate ab: ein +// Role-Map-Eintrag ohne approved_at erhaelt keine Rolle. +func TestMigrateApproved_UnapprovedSkipped(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() + suffix := testdb.Suffix() + _, tenantPool := createSynthTenantDB(t, ctx, adminDSN, registryPool, tenantID) + + unapproved := insertSynthUser(t, ctx, registryPool, tenantPool, tenantID, suffix, 1) + if _, err := registryPool.Exec(ctx, ` + INSERT INTO role_migration_map (user_migration_map_id, source_system, source_role, granted_by, approved_by, approved_at) + VALUES ($1, 'archivdms', 'Standard', 'altsystem-import', NULL, NULL) + `, unapproved.userMapID); err != nil { + t.Fatalf("role-map (unfreigegeben) 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.Assigned) != 0 { + t.Fatalf("erwarte 0 zugewiesene rollen (kein freigegebener eintrag), habe %d", len(res.Assigned)) + } +} + +func newUUIDv4() string { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + panic(err) + } + b[6] = (b[6] & 0x0f) | 0x40 + b[8] = (b[8] & 0x3f) | 0x80 + return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]) +} diff --git a/migrations/0022_role_migration_map.down.sql b/migrations/0022_role_migration_map.down.sql new file mode 100644 index 00000000..4f2062fb --- /dev/null +++ b/migrations/0022_role_migration_map.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS role_migration_map; diff --git a/migrations/0022_role_migration_map.up.sql b/migrations/0022_role_migration_map.up.sql new file mode 100644 index 00000000..6709173a --- /dev/null +++ b/migrations/0022_role_migration_map.up.sql @@ -0,0 +1,16 @@ +-- COR-03: Referenztabelle fuer die Rollenherkunft je migriertem Benutzer, +-- analog zu user_migration_map (COR-02). source_role bleibt der Freitext/ +-- Gruppenname aus dem Altsystem -- die Core-Rolle wird NICHT hier +-- gespeichert, sondern von internal/migrationrole ueber eine feste, +-- dokumentierte Mapping-Tabelle berechnet (kein 1:1-Freitext-Uebernahme, +-- siehe Auftrag). +CREATE TABLE role_migration_map ( + id BIGSERIAL PRIMARY KEY, + user_migration_map_id BIGINT NOT NULL UNIQUE REFERENCES user_migration_map(id), + source_system TEXT NOT NULL CHECK (source_system IN ('archivmail', 'archivdms')), + source_role TEXT NOT NULL, + granted_by TEXT, + approved_by TEXT, + approved_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +);