DOK-03: ACL-Daten-Migration nach Core-Rechtemodell #261
No files matched your search
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
Reference in new issue
Block a user