COR-03: Rollen- und Rechte-Migration #258

Merged
patrick merged 2 commits from feature/cor-03-rollen-rechte-migration into main 2026-10-14 16:19:24 +02:00
7 changed files with 711 additions and 0 deletions

No files matched your search

+87
View File
@@ -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.
+64
View File
@@ -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()
}
+68
View File
@@ -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
}
+129
View File
@@ -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{}
}
+346
View File
@@ -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])
}
@@ -0,0 +1 @@
DROP TABLE IF EXISTS role_migration_map;
+16
View File
@@ -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()
);