diff --git a/docs/DOK-03-PRUEFPROTOKOLL.md b/docs/DOK-03-PRUEFPROTOKOLL.md new file mode 100644 index 00000000..e837e47a --- /dev/null +++ b/docs/DOK-03-PRUEFPROTOKOLL.md @@ -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. diff --git a/internal/migrationacl/migrate.go b/internal/migrationacl/migrate.go new file mode 100644 index 00000000..efa3ad19 --- /dev/null +++ b/internal/migrationacl/migrate.go @@ -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 +} diff --git a/internal/migrationacl/migrate_test.go b/internal/migrationacl/migrate_test.go new file mode 100644 index 00000000..3ece04e2 --- /dev/null +++ b/internal/migrationacl/migrate_test.go @@ -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) + } + }) + } +} diff --git a/internal/migrationacl/uuid.go b/internal/migrationacl/uuid.go new file mode 100644 index 00000000..803a3ec0 --- /dev/null +++ b/internal/migrationacl/uuid.go @@ -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]) +}