COR-03: Rollen- und Rechte-Migration #258
No files matched your search
@@ -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.
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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{}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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()
|
||||
);
|
||||
Reference in new issue
Block a user