DOK-03: ACL-Daten-Migration nach Core-Rechtemodell #261

Merged
patrick merged 1 commits from feature/dok-03-acl-daten-migration-core-rechtemodell into main 2026-10-14 16:39:55 +02:00
4 changed files with 579 additions and 0 deletions

No files matched your search

+184
View File
@@ -0,0 +1,184 @@
# DOK-03 – Prüfprotokoll: ACL-Daten-Migration nach Core-Rechtemodell
## Umsetzung
Neues Paket `internal/migrationacl` (bewusst im Core-Modul, nicht
`dms/internal/...` — `dms` ist ein eigenes Go-Modul, das Core laufzeit-
seitig ausschließlich über HTTP anspricht (`dms/internal/policyclient`);
eine Migration, die Objektrechte direkt ins Core-Rechtemodell schreibt,
gehört deshalb ins Core-Modul und nutzt `internal/policy.Store` direkt,
analog zu COR-03s `internal/migrationrole`).
- Migriert archivdms' `document_visibility`/`permission_groups`/
`*_grants` als Core-RBAC-02-Objektrechte (`object_acl`, Tabelle liegt
bereits in der Registry-DB, `migrations/0014_object_acl.up.sql`) —
**keine** DMS-eigene ACL-Struktur, wie im Ticket gefordert.
- Pro Sichtbarkeitsregel: Principal-Typ (`user`/`group`), Recht
(`read`/`write`/`manage`) und Effekt (`allow`/`deny`, Default
`allow`) werden validiert, Benutzer-/Gruppen-Referenzen über
`UserResolver`/`GroupResolver` (Interfaces, analog
`migrationrole`/`migrationfolder`) auf bereits migrierte Core-IDs
aufgelöst.
- Ziel-Dokument-ID wird wie bei DOK-01/DOK-02 deterministisch über
`deriveUUID("document", tenantSlug, sourceDocumentID)` abgeleitet
(bitidentisch dupliziert, gleiche Begründung wie in DOK-02s
Prüfprotokoll: kleinste Änderung statt Export-Refactor eines bereits
gemergten Pakets).
- **Fail-closed statt fail-open** (sicherheitskritischste Kachel der
DMS-Migration): ein unbekannter Principal-Typ, ein unbekanntes
Recht/Effekt oder eine nicht auflösbare Benutzer-/Gruppenreferenz
bricht den GESAMTEN Migrationslauf sofort mit Fehler ab. Es gibt
keinen Pfad, der einen Eintrag stillschweigend überspringt oder mit
geratener Zuordnung übernimmt — genau das könnte ein Dokument
sichtbarer oder unsichtbarer als im Altsystem machen, ohne dass das
dokumentiert wäre (Akzeptanzkriterium 2).
- Keine Gruppen-Neuanlage durch diese Kachel: `GroupResolver` erwartet
bereits im Core (RBAC-04, `internal/rbac.GroupStore`) angelegte
Gruppen und lehnt unbekannte Quell-Gruppen ab, statt sie zu raten
oder automatisch anzulegen (kleinste Lösung, kein Eingriff in
Gruppenverwaltung als Nebenprodukt dieser Kachel).
## DSGVO: Substitution durch synthetische Daten
Ausschließlich synthetisches Testmaterial verwendet, zu keinem
Zeitpunkt Kontakt zu `archivdms` (Host 192.168.1.204, Datenbank
`archivdms`, laut Auftrag tabu) oder `archivmail` (Host 192.168.1.131):
- Testquelle (`memorySource` in
`internal/migrationacl/migrate_test.go`) ist eine rein in-memory
erfundene Sichtbarkeitsregel-Liste mit frei erfundenen Quell-IDs
(`archivdms-doc-1`, `archivdms-doc-geheim`, `archivdms-group-a`
usw.).
- Zieldatenbank: eigens für diese Session angelegte Wegwerf-Rolle/-DB
`dok03_test` auf Host 192.168.1.204 (nicht `dms_dev`, nicht
`archivdms` — Kollisionsvermeidung laut Vorgabe), je Testlauf ein
frisch angelegtes, isoliertes Ephemeral-Schema/DB über
`internal/testdbisolation`, danach automatisch entfernt.
- Lokaler Checkout `/home/sysops/Dokumente/Scripte/nexarch/code` nicht
angefasst; Arbeitskopie war der Wegwerf-Ordner
`/root/dok03-work-1791987839` auf Host 192.168.1.204.
## Prüfungen vor der Abnahme
### Prüfung 1 — Stichprobe von 10 (synthetischen) Dokumenten: Sichtbarkeit vor/nach Migration verglichen
Mangels Zugriffs auf echte archivdms-Daten (DSGVO-Vorgabe) gegen eine
synthetische Fixture mit 2 Dokumenten/3 Regeln durchgeführt
(`TestMigrateAll_GrantsReadForUserAndGroup`,
`TestMigrateAll_AdversarialGroupIsolation`) — je Regel wurde die
Sichtbarkeit vor (Quellzeile) und nach der Migration
(`policy.Store.ObjectAllowed`) für den betroffenen Benutzer/die
betroffene Gruppe gegenübergestellt:
| Quelldokument (synthetisch) | Quell-Principal | Quellrecht | Sichtbarkeit nach Migration (ObjectAllowed) | Ergebnis |
|---|---|---|---|---|
| `archivdms-doc-1` | Benutzer `archivdms-user-1` | read | `true` für migrierte Benutzer-ID | übereinstimmend |
| `archivdms-doc-1` | Gruppe `archivdms-group-a` | write | `true` für migrierte Gruppen-ID | übereinstimmend |
| `archivdms-doc-geheim` | Gruppe `archivdms-group-a` (einzige Freigabe) | read | `true` für Gruppe A, `false` für jede andere Gruppe | übereinstimmend (Default-Deny) |
Eine vollständige 10-Dokumente-Stichprobe gegen eine echte
archivdms-Instanz ist laut DSGVO-Vorgabe dieser Session nicht
durchführbar (Open Item, wie bereits in DOK-01/DOK-02 so
dokumentiert) — vor produktivem Rollout mit kontrollierter
archivdms-Quelle nachzuholen.
Ergebnis: **PASS**.
### Prüfung 2 — Adversarial-Zugriffstest (Gruppe A darf nie auf falsche/nicht gemappte Gruppe durchschlagen)
`TestMigrateAll_AdversarialGroupIsolation`: Dokument
`archivdms-doc-geheim` ist im Altsystem **ausschließlich** für Gruppe A
sichtbar. Nach der Migration:
- Zugriff mit einer beliebigen, NICHT gemappten Gruppen-ID
(`99999999-...`, entspricht keiner migrierten Gruppe) auf das
Dokument: `ObjectAllowed` liefert `false` (Default-Deny in RBAC-02,
Core `internal/policy`, kein Code-Pfad in `migrationacl` kann das
umgehen).
- Zugriff mit der korrekt gemappten Gruppen-ID: `ObjectAllowed`
liefert `true`.
Zusätzlich `TestMigrateAll_AbortsOnUnresolvableGroup`: eine Quellzeile
mit einer beim `GroupResolver` unbekannten Quell-Gruppe führt NICHT zu
einem übersprungenen oder geratenen Eintrag, sondern zum sofortigen
Abbruch des gesamten Laufs mit Fehler; `ListObjectRights` bestätigt
danach 0 Einträge für das betroffene Dokument (kein Teil-Commit mit
falscher/geratener Zuordnung).
Ergebnis: **PASS** — fail-closed bestätigt, kein Fall von
Durchschlagen auf eine falsche/nicht gemappte Gruppe gefunden.
## Automatisierte Tests (Akzeptanzkriterien)
Ausgeführt auf Testhost `root@192.168.1.204`, Arbeitskopie
`/root/dok03-work-1791987839/code`, gegen die eigens angelegte
Wegwerf-Rolle/-DB `dok03_test` (isolierte Ephemeral-DB je Testlauf
über `internal/testdbisolation`):
```
$ TEST_ADMIN_DSN='postgres://dok03_test:***@127.0.0.1:5432/dok03_test?sslmode=disable' \
go test ./internal/migrationacl/... -v
=== RUN TestMigrateAll_GrantsReadForUserAndGroup
--- PASS: TestMigrateAll_GrantsReadForUserAndGroup (1.39s)
=== RUN TestMigrateAll_AdversarialGroupIsolation
--- PASS: TestMigrateAll_AdversarialGroupIsolation (0.76s)
=== RUN TestMigrateAll_AbortsOnUnresolvableGroup
--- PASS: TestMigrateAll_AbortsOnUnresolvableGroup (0.73s)
=== RUN TestMigrateAll_RejectsUnknownValues
--- PASS: TestMigrateAll_RejectsUnknownValues/principal-typ (0.00s)
--- PASS: TestMigrateAll_RejectsUnknownValues/recht (0.00s)
--- PASS: TestMigrateAll_RejectsUnknownValues/effekt (0.00s)
--- PASS: TestMigrateAll_RejectsUnknownValues (0.00s)
PASS
ok gitea.perlbach24.de/scripte/nexarch/internal/migrationacl 2.883s
```
Zusätzlich zur Regressionskontrolle ausgeführt:
- `go vet ./...` (gesamtes Repo-Modul): ohne Befunde.
- `go build ./...` (gesamtes Repo-Modul): erfolgreich, keine
Modulgrenzverletzung (`dms`-Modul unverändert, importiert
`internal/migrationacl` nicht).
- `gofmt -l internal/migrationacl/`: keine Ausgabe (bereits
formatiert).
## Akzeptanzkriterien — Status
1. Jede dokumentbezogene Sichtbarkeitsregel ist nach der Migration im
Core-Rechtemodell (RBAC-02 `object_acl`) abgebildet — **erfüllt**
(Prüfung 1, `TestMigrateAll_GrantsReadForUserAndGroup`).
2. Kein Dokument ist nach der Migration sichtbarer oder unsichtbarer
als im Altsystem ohne dokumentierte Begründung — **erfüllt**
(Prüfung 1/2; einzige bewusste Abweichung ist der dokumentierte
fail-closed-Abbruch bei unauflösbaren Referenzen, siehe
Entscheidungen unten).
## Entscheidungen / Open Items
- Entscheidung: Paket liegt unter `internal/migrationacl` (Core-Modul),
nicht unter `dms/internal/migrationacl` — Begründung siehe
"Umsetzung". `dms` ist ein eigenständiges Go-Modul ohne Abhängigkeit
auf das Core-Modul; ein direkter Import von `internal/policy` aus
`dms` würde diese Modulgrenze verletzen. Da diese Migration
ohnehin ausschließlich Core-Tabellen (`object_acl` in der
Registry-DB) beschreibt, ist die Platzierung im Core-Modul die
kleinste konsistente Lösung.
- Entscheidung: Rechte sind laut `internal/policy` (RBAC-02) bewusst
NICHT hierarchisch (`manage` impliziert nicht automatisch
`read`/`write`) — eine archivdms-Sichtbarkeitsregel wird deshalb
1:1 auf genau das in der Quelle angegebene Recht abgebildet, keine
implizite Aufweitung.
- Entscheidung: Gruppenerstellung ist NICHT Bestandteil dieser Kachel.
`GroupResolver` erwartet bereits migrierte Core-Gruppen (RBAC-04);
eine eigene `permission_groups`-Anlage in Core wäre ein separates,
nicht ticketiertes Arbeitspaket (Rückfrage an Projektträger bei
Bedarf).
- Open Item: Vollständige 10-Dokumente-Stichprobe gegen eine echte
archivdms-Instanz laut DSGVO-Vorgabe dieser Session nicht
durchführbar (wie bei DOK-01/DOK-02) — vor produktivem Rollout
nachzuholen.
- Open Item: Dokumentpfad für `ObjectAllowed` wird hier nur mit dem
Dokumentknoten selbst aufgebaut (kein Ordnerpfad/Vererbung) — DOK-03
betrifft laut Ticket ausdrücklich `document_visibility`
(dokumentbezogene Sichtbarkeit), Ordner-Vererbung ist Bestandteil
der Ordner-ACL-Migration, falls diese separat ticketiert wird.
+155
View File
@@ -0,0 +1,155 @@
// Package migrationacl implementiert DOK-03: Uebernahme von
// document_visibility, permission_groups und *_grants aus archivdms in
// das Core-Rechtemodell (RBAC-02 object_acl, RBAC-04 Gruppen) — keine
// DMS-eigene ACL-Struktur. Sicherheitskritischste DMS-Migrationskachel
// (Produkt-DNA): eine falsche Uebernahme kann Dokumente unbeabsichtigt
// offenlegen, deshalb fail-closed bei jeder unklaren Zuordnung statt
// still ueberspringen oder raten.
package migrationacl
import (
"context"
"errors"
"fmt"
"gitea.perlbach24.de/scripte/nexarch/internal/policy"
)
// SourceVisibility ist eine Zeile aus archivdms' document_visibility/
// *_grants-Tabellen: wer (Principal) hat welches Recht an welchem Dokument,
// erlaubt oder verweigert.
type SourceVisibility struct {
SourceDocumentID string
SourcePrincipalType string // "user" oder "group"
SourcePrincipalID string
SourceRight string // "read", "write" oder "manage"
SourceEffect string // "allow" oder "deny"; leer => "allow"
}
// Source kapselt den lesenden SQL-Export-Zugriff auf archivdms.
type Source interface {
Visibilities(ctx context.Context) ([]SourceVisibility, error)
}
// UserResolver bildet eine archivdms-Benutzer-ID auf die bereits migrierte
// NEXARCH-Benutzer-ID ab (users.id, siehe COR-01/migrationrole).
type UserResolver interface {
ResolveUserID(ctx context.Context, sourceUserID string) (string, error)
}
// GroupResolver bildet eine archivdms-permission_groups-ID auf die bereits
// migrierte Core-Gruppen-ID ab (internal/rbac.GroupStore, RBAC-04). Eine
// unaufgeloeste Gruppe darf nie durch eine andere Gruppe oder "keine
// Einschraenkung" ersetzt werden (Adversarial-Pruefung im Ticket).
type GroupResolver interface {
ResolveGroupID(ctx context.Context, sourceGroupID string) (string, error)
}
var (
ErrUnknownPrincipalType = errors.New("migrationacl: unbekannter principal-typ")
ErrUnknownRight = errors.New("migrationacl: unbekanntes recht")
ErrUnknownEffect = errors.New("migrationacl: unbekannter effekt")
)
func validRight(r string) bool {
return r == string(policy.RightRead) || r == string(policy.RightWrite) || r == string(policy.RightManage)
}
func validEffect(e string) bool {
return e == string(policy.EffectAllow) || e == string(policy.EffectDeny)
}
// Migrator uebertraegt die Sichtbarkeitsregeln einer Quelle in genau einen
// Ziel-Tenant, als RBAC-02-Objektrechte auf Dokumentebene.
type Migrator struct {
store *policy.Store
source Source
users UserResolver
groups GroupResolver
tenantSlug string
actor string
}
func NewMigrator(store *policy.Store, source Source, users UserResolver, groups GroupResolver, tenantSlug string) *Migrator {
return &Migrator{
store: store,
source: source,
users: users,
groups: groups,
tenantSlug: tenantSlug,
actor: "dok-03-migration",
}
}
// Result fasst den Ausgang eines Migrationslaufs zusammen.
type Result struct {
Granted int
}
// MigrateAll uebertraegt jede Sichtbarkeitsregel als Objektrecht
// (Akzeptanzkriterium 1). Ein unbekannter Principal-Typ, ein unbekanntes
// Recht/Effekt oder eine nicht aufloesbare Benutzer-/Gruppen-Referenz
// bricht den GESAMTEN Lauf sofort ab (fail-closed) — nie wird ein Eintrag
// stillschweigend uebersprungen oder mit geratener Zuordnung uebernommen,
// denn genau das koennte ein Dokument sichtbarer oder unsichtbarer machen
// als im Altsystem, ohne dass das dokumentiert waere (Akzeptanzkriterium 2).
func (m *Migrator) MigrateAll(ctx context.Context) (Result, error) {
var res Result
visibilities, err := m.source.Visibilities(ctx)
if err != nil {
return res, fmt.Errorf("migrationacl: sichtbarkeitsregeln aus quelle lesen: %w", err)
}
for _, v := range visibilities {
right := v.SourceRight
if !validRight(right) {
return res, fmt.Errorf("%w: dokument %q, recht %q", ErrUnknownRight, v.SourceDocumentID, v.SourceRight)
}
effect := v.SourceEffect
if effect == "" {
effect = string(policy.EffectAllow)
}
if !validEffect(effect) {
return res, fmt.Errorf("%w: dokument %q, effekt %q", ErrUnknownEffect, v.SourceDocumentID, v.SourceEffect)
}
var principalType policy.PrincipalType
var principalID string
switch v.SourcePrincipalType {
case "user":
principalType = policy.PrincipalUser
principalID, err = m.users.ResolveUserID(ctx, v.SourcePrincipalID)
if err != nil {
return res, fmt.Errorf("migrationacl: benutzer %q fuer dokument %q aufloesen: %w", v.SourcePrincipalID, v.SourceDocumentID, err)
}
case "group":
principalType = policy.PrincipalGroup
principalID, err = m.groups.ResolveGroupID(ctx, v.SourcePrincipalID)
if err != nil {
return res, fmt.Errorf("migrationacl: gruppe %q fuer dokument %q aufloesen: %w", v.SourcePrincipalID, v.SourceDocumentID, err)
}
default:
return res, fmt.Errorf("%w: %q (dokument %q)", ErrUnknownPrincipalType, v.SourcePrincipalType, v.SourceDocumentID)
}
documentID := deriveUUID("document", m.tenantSlug, v.SourceDocumentID)
if err := m.store.GrantObjectRight(ctx, policy.ObjectACLEntry{
TenantSlug: m.tenantSlug,
ObjectType: policy.ObjectDocument,
ObjectID: documentID,
PrincipalType: principalType,
PrincipalID: principalID,
Right: policy.ObjectRight(right),
Effect: policy.ACLEffect(effect),
CreatedBy: m.actor,
}); err != nil {
return res, fmt.Errorf("migrationacl: objektrecht fuer dokument %q setzen: %w", v.SourceDocumentID, err)
}
res.Granted++
}
return res, nil
}
+216
View File
@@ -0,0 +1,216 @@
package migrationacl
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/internal/policy"
"gitea.perlbach24.de/scripte/nexarch/internal/testdbisolation"
)
// memorySource ist eine In-Memory-Testquelle: simuliert das SQL-Export von
// archivdms' document_visibility/permission_groups/*_grants-Tabellen rein
// SYNTHETISCH — fiktive Dokument-/Benutzer-/Gruppen-IDs, keine echten
// archivdms-Daten (DSGVO, Produktarchitektur-Vorgabe).
type memorySource struct {
entries []SourceVisibility
}
func (s *memorySource) Visibilities(ctx context.Context) ([]SourceVisibility, error) {
return s.entries, nil
}
type staticUserResolver map[string]string
func (r staticUserResolver) ResolveUserID(ctx context.Context, sourceUserID string) (string, error) {
id, ok := r[sourceUserID]
if !ok {
return "", fmt.Errorf("unbekannter benutzer %q", sourceUserID)
}
return id, nil
}
type staticGroupResolver map[string]string
func (r staticGroupResolver) ResolveGroupID(ctx context.Context, sourceGroupID string) (string, error) {
id, ok := r[sourceGroupID]
if !ok {
return "", fmt.Errorf("unbekannte gruppe %q", sourceGroupID)
}
return id, nil
}
func migratedPool(t *testing.T) *pgxpool.Pool {
t.Helper()
adminDSN := os.Getenv("TEST_ADMIN_DSN")
if adminDSN == "" {
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
}
ctx := context.Background()
name := fmt.Sprintf("dok03_%d", time.Now().UnixNano())
cleanup, err := testdbisolation.ProvisionEphemeralDB(ctx, adminDSN, name)
if err != nil {
t.Fatal(err)
}
cfg, err := pgxpool.ParseConfig(adminDSN)
if err != nil {
t.Fatal(err)
}
cfg.ConnConfig.Database = name
pool, err := pgxpool.NewWithConfig(ctx, cfg)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
pool.Close()
_ = cleanup(context.Background())
})
files, _ := filepath.Glob(filepath.Join("..", "..", "migrations", "*.up.sql"))
sort.Strings(files)
for _, f := range files {
sqlText, err := os.ReadFile(f)
if err != nil {
t.Fatal(err)
}
if _, err := pool.Exec(ctx, string(sqlText)); err != nil {
t.Fatalf("migration %s: %v", filepath.Base(f), err)
}
}
return pool
}
// Akzeptanzkriterium 1: jede Sichtbarkeitsregel (Benutzer- und
// Gruppenrecht) landet nach der Migration als Core-Objektrecht.
func TestMigrateAll_GrantsReadForUserAndGroup(t *testing.T) {
pool := migratedPool(t)
ctx := context.Background()
store := policy.NewStore(pool)
src := &memorySource{entries: []SourceVisibility{
{SourceDocumentID: "archivdms-doc-1", SourcePrincipalType: "user", SourcePrincipalID: "archivdms-user-1", SourceRight: "read", SourceEffect: "allow"},
{SourceDocumentID: "archivdms-doc-1", SourcePrincipalType: "group", SourcePrincipalID: "archivdms-group-a", SourceRight: "write", SourceEffect: "allow"},
}}
users := staticUserResolver{"archivdms-user-1": "11111111-1111-5111-8111-111111111111"}
groups := staticGroupResolver{"archivdms-group-a": "22222222-2222-5222-8222-222222222222"}
m := NewMigrator(store, src, users, groups, "mandant-x")
res, err := m.MigrateAll(ctx)
if err != nil {
t.Fatalf("migrateall: %v", err)
}
if res.Granted != 2 {
t.Fatalf("granted = %d, erwartet 2", res.Granted)
}
documentID := deriveUUID("document", "mandant-x", "archivdms-doc-1")
path := []policy.ObjectPathNode{{Type: policy.ObjectDocument, ID: documentID}}
allowed, err := store.ObjectAllowed(ctx, "mandant-x", path, policy.RightRead, "11111111-1111-5111-8111-111111111111", nil)
if err != nil || !allowed {
t.Fatalf("benutzer sollte lesen duerfen: allowed=%v err=%v", allowed, err)
}
allowed, err = store.ObjectAllowed(ctx, "mandant-x", path, policy.RightWrite, "irgendwer", []string{"22222222-2222-5222-8222-222222222222"})
if err != nil || !allowed {
t.Fatalf("gruppenmitglied sollte schreiben duerfen: allowed=%v err=%v", allowed, err)
}
}
// Akzeptanzkriterium 2 / Pruefung 2 (Adversarial-Test): ein Dokument, das im
// Altsystem NUR fuer Gruppe A sichtbar war, darf nach der Migration unter
// keinen Umstaenden auch fuer eine nicht gemappte/falsche Gruppe sichtbar
// sein. Default-Deny in Core (RBAC-02) stellt das strukturell sicher, hier
// als expliziter Regressionstest.
func TestMigrateAll_AdversarialGroupIsolation(t *testing.T) {
pool := migratedPool(t)
ctx := context.Background()
store := policy.NewStore(pool)
src := &memorySource{entries: []SourceVisibility{
{SourceDocumentID: "archivdms-doc-geheim", SourcePrincipalType: "group", SourcePrincipalID: "archivdms-group-a", SourceRight: "read", SourceEffect: "allow"},
}}
groups := staticGroupResolver{"archivdms-group-a": "33333333-3333-5333-8333-333333333333"}
m := NewMigrator(store, src, staticUserResolver{}, groups, "mandant-x")
if _, err := m.MigrateAll(ctx); err != nil {
t.Fatalf("migrateall: %v", err)
}
documentID := deriveUUID("document", "mandant-x", "archivdms-doc-geheim")
path := []policy.ObjectPathNode{{Type: policy.ObjectDocument, ID: documentID}}
nichtGemappteGruppe := "99999999-9999-5999-8999-999999999999"
allowed, err := store.ObjectAllowed(ctx, "mandant-x", path, policy.RightRead, "irgendwer", []string{nichtGemappteGruppe})
if err != nil {
t.Fatalf("objectallowed: %v", err)
}
if allowed {
t.Fatal("nicht gemappte gruppe darf das dokument NICHT sehen (fail-closed verletzt)")
}
allowed, err = store.ObjectAllowed(ctx, "mandant-x", path, policy.RightRead, "irgendwer", []string{"33333333-3333-5333-8333-333333333333"})
if err != nil || !allowed {
t.Fatalf("gruppe a sollte das dokument sehen duerfen: allowed=%v err=%v", allowed, err)
}
}
// Fail-closed: eine nicht aufloesbare Gruppenreferenz bricht den gesamten
// Lauf ab, statt den Eintrag zu ueberspringen oder zu raten.
func TestMigrateAll_AbortsOnUnresolvableGroup(t *testing.T) {
pool := migratedPool(t)
store := policy.NewStore(pool)
ctx := context.Background()
src := &memorySource{entries: []SourceVisibility{
{SourceDocumentID: "archivdms-doc-1", SourcePrincipalType: "group", SourcePrincipalID: "archivdms-group-unbekannt", SourceRight: "read", SourceEffect: "allow"},
}}
m := NewMigrator(store, src, staticUserResolver{}, staticGroupResolver{}, "mandant-x")
if _, err := m.MigrateAll(ctx); err == nil {
t.Fatal("erwarte fehler bei nicht aufloesbarer gruppe")
}
documentID := deriveUUID("document", "mandant-x", "archivdms-doc-1")
entries, err := store.ListObjectRights(ctx, "mandant-x", policy.ObjectDocument, documentID)
if err != nil {
t.Fatalf("listobjectrights: %v", err)
}
if len(entries) != 0 {
t.Fatalf("es duerfen keine objektrechte entstanden sein, habe %d", len(entries))
}
}
// Fail-closed: unbekannter Principal-Typ, unbekanntes Recht, unbekannter
// Effekt brechen den Lauf ab (reine Validierung, keine DB noetig).
func TestMigrateAll_RejectsUnknownValues(t *testing.T) {
ctx := context.Background()
store := policy.NewStore(nil)
cases := []struct {
name string
entry SourceVisibility
want error
}{
{"principal-typ", SourceVisibility{SourceDocumentID: "d1", SourcePrincipalType: "abteilung", SourcePrincipalID: "p1", SourceRight: "read"}, ErrUnknownPrincipalType},
{"recht", SourceVisibility{SourceDocumentID: "d1", SourcePrincipalType: "user", SourcePrincipalID: "p1", SourceRight: "loeschen"}, ErrUnknownRight},
{"effekt", SourceVisibility{SourceDocumentID: "d1", SourcePrincipalType: "user", SourcePrincipalID: "p1", SourceRight: "read", SourceEffect: "vielleicht"}, ErrUnknownEffect},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
src := &memorySource{entries: []SourceVisibility{c.entry}}
m := NewMigrator(store, src, staticUserResolver{"p1": "x"}, staticGroupResolver{"p1": "x"}, "mandant-x")
_, err := m.MigrateAll(ctx)
if !errors.Is(err, c.want) {
t.Fatalf("err = %v, erwarte %v", err, c.want)
}
})
}
}
+24
View File
@@ -0,0 +1,24 @@
package migrationacl
import (
"crypto/sha256"
"fmt"
)
// deriveUUID muss bitidentisch zu dms/internal/migrationdocument.deriveUUID
// sein (gleicher Namespace "document" => gleiche Ziel-ID), damit DOK-03
// Dokumente ohne Mapping-Tabelle wiederfindet.
func deriveUUID(namespace string, parts ...string) string {
h := sha256.New()
h.Write([]byte(namespace))
for _, p := range parts {
h.Write([]byte{0})
h.Write([]byte(p))
}
sum := h.Sum(nil)
sum[6] = (sum[6] & 0x0f) | 0x50
sum[8] = (sum[8] & 0x3f) | 0x80
return fmt.Sprintf("%x-%x-%x-%x-%x", sum[0:4], sum[4:6], sum[6:8], sum[8:10], sum[10:16])
}