diff --git a/archive/cmd/cutover-stage/main.go b/archive/cmd/cutover-stage/main.go new file mode 100644 index 00000000..13194c85 --- /dev/null +++ b/archive/cmd/cutover-stage/main.go @@ -0,0 +1,37 @@ +// cutover-stage fuehrt eine archive-Stufe des CUT-01-Trockenlaufs aus; Aufruf +// ausschliesslich durch cmd/cutover-dryrun im Root-Modul. +package main + +import ( + "context" + "encoding/json" + "fmt" + "os" + + "gitea.perlbach24.de/scripte/nexarch/archive/internal/cutoverstage" +) + +func main() { + if len(os.Args) != 2 { + fmt.Fprintln(os.Stderr, "aufruf: cutover-stage ") + os.Exit(2) + } + env, err := cutoverstage.LoadEnv() + if err != nil { + fmt.Fprintln(os.Stderr, "fehler:", err) + os.Exit(1) + } + defer env.Close() + + res, err := cutoverstage.Run(context.Background(), env, os.Args[1]) + if err != nil { + fmt.Fprintln(os.Stderr, "fehler:", err) + env.Close() + os.Exit(1) + } + if err := json.NewEncoder(os.Stdout).Encode(res); err != nil { + fmt.Fprintln(os.Stderr, "fehler:", err) + env.Close() + os.Exit(1) + } +} diff --git a/archive/internal/cutoverstage/env.go b/archive/internal/cutoverstage/env.go new file mode 100644 index 00000000..a4ab8638 --- /dev/null +++ b/archive/internal/cutoverstage/env.go @@ -0,0 +1,171 @@ +// Package cutoverstage ist die Archive-Modulstufe des CUT-01-Trockenlaufs +// (Orchestrator: internal/cutoverorchestrator im Root-Modul): Quell-Adapter +// fuer TAR-01, MAI-03, MAI-04 und TAR-02, ohne eigene Migrationslogik. +package cutoverstage + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + + "github.com/jackc/pgx/v5/pgxpool" +) + +type Tenant struct { + Slug string `json:"slug"` + NexarchTenantID string `json:"nexarch_tenant_id"` + ArchivmailTenantID *int64 `json:"archivmail_tenant_id"` + ArchivdmsTenantID *int64 `json:"archivdms_tenant_id"` + TenantDB string `json:"tenant_db"` + ArchiveDB string `json:"archive_db"` + WorkflowDB string `json:"workflow_db"` +} + +type Context struct { + RunTag string `json:"run_tag"` + RunDir string `json:"run_dir"` + RegistryDB string `json:"registry_db"` + ArchivmailDB string `json:"archivmail_db"` + ArchivdmsDB string `json:"archivdms_db"` + LegacyObjects string `json:"legacy_objects_dir"` + MigrationsDirs map[string]string `json:"migrations_dirs"` + Tenants []Tenant `json:"tenants"` +} + +type Result struct { + Stage string `json:"stage"` + Counts map[string]int `json:"counts"` + Notes []string `json:"notes"` +} + +func (r *Result) add(key string, n int) { + if r.Counts == nil { + r.Counts = map[string]int{} + } + r.Counts[key] += n +} + +func (r *Result) note(format string, args ...any) { + r.Notes = append(r.Notes, fmt.Sprintf(format, args...)) +} + +type Env struct { + Ctx Context + adminDSN string + pools map[string]*pgxpool.Pool +} + +func LoadEnv() (*Env, error) { + adminDSN := os.Getenv("NEXARCH_CUTOVER_ADMIN_DSN") + if adminDSN == "" { + return nil, errors.New("NEXARCH_CUTOVER_ADMIN_DSN nicht gesetzt") + } + ctxPath := os.Getenv("NEXARCH_CUTOVER_CONTEXT") + if ctxPath == "" { + return nil, errors.New("NEXARCH_CUTOVER_CONTEXT nicht gesetzt") + } + raw, err := os.ReadFile(ctxPath) + if err != nil { + return nil, fmt.Errorf("laufkontext lesen: %w", err) + } + e := &Env{adminDSN: adminDSN, pools: map[string]*pgxpool.Pool{}} + if err := json.Unmarshal(raw, &e.Ctx); err != nil { + return nil, fmt.Errorf("laufkontext parsen: %w", err) + } + return e, nil +} + +func (e *Env) Pool(ctx context.Context, database string) (*pgxpool.Pool, error) { + if p, ok := e.pools[database]; ok { + return p, nil + } + cfg, err := pgxpool.ParseConfig(e.adminDSN) + if err != nil { + return nil, errors.New("admin-dsn nicht lesbar") + } + cfg.ConnConfig.Database = database + cfg.MaxConns = 4 + p, err := pgxpool.NewWithConfig(ctx, cfg) + if err != nil { + return nil, fmt.Errorf("verbindung zu %q: %w", database, err) + } + e.pools[database] = p + return p, nil +} + +func (e *Env) Close() { + for _, p := range e.pools { + p.Close() + } +} + +func (e *Env) HandoffDir(slug string) string { + return filepath.Join(e.Ctx.RunDir, "uebergabe", slug) +} + +func (e *Env) TargetDir(kind, slug string) string { + return filepath.Join(e.Ctx.RunDir, "ziel-objekte", kind, slug) +} + +type registryUserResolver struct { + registry *pgxpool.Pool + tenantID string +} + +func (r registryUserResolver) ResolveUserID(ctx context.Context, sourceUserID string) (string, error) { + id, err := strconv.ParseInt(strings.TrimSpace(sourceUserID), 10, 64) + if err != nil { + return "", fmt.Errorf("archivdms-benutzer-id %q ungueltig", sourceUserID) + } + var out string + err = r.registry.QueryRow(ctx, ` + SELECT nexarch_user_id::text FROM user_migration_map + WHERE nexarch_tenant_id = $1 AND archivdms_user_id = $2 AND approved_at IS NOT NULL + `, r.tenantID, id).Scan(&out) + if err != nil { + return "", fmt.Errorf("archivdms-benutzer %d nicht in freigegebener user_migration_map: %w", id, err) + } + return out, nil +} + +func (e *Env) userResolver(ctx context.Context, t Tenant) (registryUserResolver, error) { + reg, err := e.Pool(ctx, e.Ctx.RegistryDB) + if err != nil { + return registryUserResolver{}, err + } + return registryUserResolver{registry: reg, tenantID: t.NexarchTenantID}, nil +} + +// legacyPath haelt Objektschluessel der Quelle innerhalb des Objektverzeichnisses. +func legacyPath(root, key string) (string, error) { + full := filepath.Join(root, filepath.FromSlash(key)) + rel, err := filepath.Rel(root, full) + if err != nil || strings.HasPrefix(rel, "..") { + return "", fmt.Errorf("objektschluessel %q verlaesst das quellverzeichnis", key) + } + return full, nil +} + +func writeJSON(path string, v any) error { + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return err + } + data, err := json.MarshalIndent(v, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, data, 0o600) +} + +func readJSON(path string, v any) error { + data, err := os.ReadFile(path) + if err != nil { + return err + } + return json.Unmarshal(data, v) +} diff --git a/archive/internal/cutoverstage/stages.go b/archive/internal/cutoverstage/stages.go new file mode 100644 index 00000000..29dbb8ab --- /dev/null +++ b/archive/internal/cutoverstage/stages.go @@ -0,0 +1,414 @@ +package cutoverstage + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + + "gitea.perlbach24.de/scripte/nexarch/archive/internal/deletionrequestmigration" + "gitea.perlbach24.de/scripte/nexarch/archive/internal/dsgvorequestmigration" + "gitea.perlbach24.de/scripte/nexarch/archive/internal/reconcilereportmigration" + "gitea.perlbach24.de/scripte/nexarch/archive/internal/retention" + "gitea.perlbach24.de/scripte/nexarch/archive/internal/retentionmigration" +) + +const ( + objectTypeDocument = "dms_document" + objectTypeMessage = "mail_message" +) + +type DocumentHandoff struct { + SourceDocumentID string `json:"source_document_id"` + ObjectReference string `json:"object_reference"` + RetainUntil *time.Time `json:"retain_until"` + Migrated bool `json:"migrated"` +} + +type MessageHandoff struct { + SourceMessageID string `json:"source_message_id"` + ObjectReference string `json:"object_reference"` + RetainUntil *time.Time `json:"retain_until"` + DataSubjectRef string `json:"data_subject_ref"` +} + +func Run(ctx context.Context, e *Env, stage string) (Result, error) { + res := Result{Stage: stage} + var err error + switch stage { + case "schema": + err = e.forTenants(ctx, &res, e.schema) + case "tar01": + err = e.forTenants(ctx, &res, e.tar01) + case "mai03": + err = e.forTenants(ctx, &res, e.mai03) + case "mai04": + err = e.forTenants(ctx, &res, e.mai04) + case "tar02": + err = e.forTenants(ctx, &res, e.tar02) + default: + err = fmt.Errorf("unbekannte archive-stufe %q", stage) + } + return res, err +} + +func (e *Env) forTenants(ctx context.Context, res *Result, fn func(context.Context, Tenant, *Result) error) error { + for _, t := range e.Ctx.Tenants { + if err := fn(ctx, t, res); err != nil { + return fmt.Errorf("mandant %s: %w", t.Slug, err) + } + } + return nil +} + +// schema wendet archive/migrations auf die frisch angelegte, leere +// Mandanten-Archivdatenbank an; das Archive-Modul hat keinen eigenen +// Migrationslaeufer, eine Versionstabelle ist fuer die Wegwerf-DB unnoetig. +func (e *Env) schema(ctx context.Context, t Tenant, res *Result) error { + pool, err := e.Pool(ctx, t.ArchiveDB) + if err != nil { + return err + } + files, err := filepath.Glob(filepath.Join(e.Ctx.MigrationsDirs["archive"], "*.up.sql")) + if err != nil { + return err + } + sort.Strings(files) + for _, f := range files { + sqlText, err := os.ReadFile(f) + if err != nil { + return err + } + if _, err := pool.Exec(ctx, string(sqlText)); err != nil { + return fmt.Errorf("archive-migration %s: %w", filepath.Base(f), err) + } + } + res.add("mandanten", 1) + res.add("archive_migrationen_angewendet", len(files)) + return nil +} + +func (e *Env) handoffDocuments(slug string) ([]DocumentHandoff, error) { + var out []DocumentHandoff + err := readJSON(filepath.Join(e.HandoffDir(slug), "dms_objekte.json"), &out) + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + return out, err +} + +func (e *Env) handoffMessages(slug string) ([]MessageHandoff, error) { + var out []MessageHandoff + err := readJSON(filepath.Join(e.HandoffDir(slug), "mail_objekte.json"), &out) + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + return out, err +} + +type staticRetentionSource []retentionmigration.SourceObject + +func (s staticRetentionSource) Objects(context.Context) ([]retentionmigration.SourceObject, error) { + return s, nil +} + +func (e *Env) tar01(ctx context.Context, t Tenant, res *Result) error { + docs, err := e.handoffDocuments(t.Slug) + if err != nil { + return err + } + msgs, err := e.handoffMessages(t.Slug) + if err != nil { + return err + } + var src staticRetentionSource + for _, d := range docs { + if d.Migrated { + src = append(src, retentionmigration.SourceObject{ObjectType: objectTypeDocument, ObjectReference: d.ObjectReference, RetainUntil: d.RetainUntil}) + } + } + for _, m := range msgs { + src = append(src, retentionmigration.SourceObject{ObjectType: objectTypeMessage, ObjectReference: m.ObjectReference, RetainUntil: m.RetainUntil, DataSubjectRef: m.DataSubjectRef}) + } + pool, err := e.Pool(ctx, t.ArchiveDB) + if err != nil { + return err + } + out, err := retentionmigration.NewMigrator(pool, src).MigrateAll(ctx, time.Now()) + if err != nil { + return err + } + res.add("objekte_registriert", out.ObjectsRegistered) + res.add("fristen_uebernommen", out.RetentionDatesSet) + + for _, o := range src { + var id string + if err := pool.QueryRow(ctx, `SELECT id FROM retention_objects WHERE object_type = $1 AND object_reference = $2`, o.ObjectType, o.ObjectReference).Scan(&id); err != nil { + return fmt.Errorf("retention-objekt %s/%s nicht registriert: %w", o.ObjectType, o.ObjectReference, err) + } + got, ok, err := retention.RetainUntil(ctx, pool, id) + if err != nil { + return err + } + switch { + case o.RetainUntil == nil && ok: + return fmt.Errorf("retention-objekt %s/%s: frist gesetzt, obwohl die quelle keine hatte", o.ObjectType, o.ObjectReference) + case o.RetainUntil != nil && (!ok || !got.Equal(*o.RetainUntil)): + return fmt.Errorf("retention-objekt %s/%s: frist weicht von der quelle ab", o.ObjectType, o.ObjectReference) + } + } + res.add("fristen_geprueft", len(src)) + return nil +} + +type dsgvoSource struct { + legacy *pgxpool.Pool + tenantID int64 +} + +// Requests: "partial" und "failed" sind in archivmail nicht abgeschlossene +// Art.-17-Antraege und bleiben deshalb aktive Vorgaenge ("open"). +func (s dsgvoSource) Requests(ctx context.Context) ([]dsgvorequestmigration.SourceRequest, error) { + rows, err := s.legacy.Query(ctx, ` + SELECT id::text, requested_address, requested_by, created_at, status + FROM dsgvo_requests WHERE tenant_id = $1 ORDER BY id + `, s.tenantID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []dsgvorequestmigration.SourceRequest + for rows.Next() { + var r dsgvorequestmigration.SourceRequest + var status string + if err := rows.Scan(&r.SourceRequestID, &r.DataSubjectRef, &r.RequestedBy, &r.RequestedAt, &status); err != nil { + return nil, err + } + switch status { + case "open", "partial", "failed": + r.Status = dsgvorequestmigration.StatusOpen + case "completed": + r.Status = dsgvorequestmigration.StatusCompleted + default: + r.Status = status + } + out = append(out, r) + } + return out, rows.Err() +} + +func (e *Env) mai03(ctx context.Context, t Tenant, res *Result) error { + if t.ArchivmailTenantID == nil { + res.note("%s: kein archivmail-mandant, stufe entfaellt", t.Slug) + return nil + } + legacy, err := e.Pool(ctx, e.Ctx.ArchivmailDB) + if err != nil { + return err + } + pool, err := e.Pool(ctx, t.ArchiveDB) + if err != nil { + return err + } + src := dsgvoSource{legacy: legacy, tenantID: *t.ArchivmailTenantID} + out, err := dsgvorequestmigration.NewMigrator(pool, src).MigrateAll(ctx, time.Now()) + if err != nil { + return err + } + res.add("offene_anfragen_verarbeitet", out.OpenRequestsProcessed) + res.add("historische_eintraege", out.HistoricalEntriesLogged) + + reqs, err := src.Requests(ctx) + if err != nil { + return err + } + msgs, err := e.handoffMessages(t.Slug) + if err != nil { + return err + } + for _, r := range reqs { + if r.Status != dsgvorequestmigration.StatusOpen { + continue + } + objects := 0 + for _, m := range msgs { + if m.DataSubjectRef == r.DataSubjectRef { + objects++ + } + } + var decisions int + if err := pool.QueryRow(ctx, `SELECT count(*) FROM dsgvo_decision_log WHERE data_subject_ref = $1`, r.DataSubjectRef).Scan(&decisions); err != nil { + return err + } + if objects > 0 && decisions == 0 { + return fmt.Errorf("offene dsgvo-anfrage %s: %d migrierte objekte der betroffenen person, aber keine einzelentscheidung protokolliert", r.SourceRequestID, objects) + } + res.add("einzelentscheidungen", decisions) + } + return nil +} + +type reportSource struct { + legacy *pgxpool.Pool + tenantID int64 +} + +func (s reportSource) Reports(ctx context.Context) ([]reconcilereportmigration.SourceReport, error) { + rows, err := s.legacy.Query(ctx, ` + SELECT r.id::text, r.created_at, row_to_json(r)::text + FROM reconciliation_reports r WHERE r.tenant_id = $1 ORDER BY r.id + `, s.tenantID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []reconcilereportmigration.SourceReport + for rows.Next() { + var r reconcilereportmigration.SourceReport + var raw string + if err := rows.Scan(&r.SourceReportID, &r.GeneratedAt, &raw); err != nil { + return nil, err + } + r.ReportJSON = []byte(raw) + out = append(out, r) + } + return out, rows.Err() +} + +func (e *Env) mai04(ctx context.Context, t Tenant, res *Result) error { + if t.ArchivmailTenantID == nil { + res.note("%s: kein archivmail-mandant, stufe entfaellt", t.Slug) + return nil + } + legacy, err := e.Pool(ctx, e.Ctx.ArchivmailDB) + if err != nil { + return err + } + pool, err := e.Pool(ctx, t.ArchiveDB) + if err != nil { + return err + } + out, err := reconcilereportmigration.NewMigrator(reportSource{legacy: legacy, tenantID: *t.ArchivmailTenantID}, pool).MigrateAll(ctx) + if err != nil { + return err + } + res.add("berichte", out.ReportsMigrated) + res.add("berichte_uebersprungen", out.ReportsSkipped) + return nil +} + +type deleteRequestSource struct { + legacy *pgxpool.Pool + tenantID int64 + refs map[string]string + users registryUserResolver + skipped *int +} + +// DeleteRequests: pending/confirmed/blocked_retention sind offene +// Vier-Augen-Vorgaenge, executed nur noch Audit, cancelled entfaellt. +// Antragsteller/Bestaetiger werden auf NEXARCH-Benutzer-IDs abgebildet, +// sonst wuerde der Vier-Augen-Vergleich nach der Migration Altsystem-IDs +// mit NEXARCH-IDs vergleichen. +func (s deleteRequestSource) DeleteRequests(ctx context.Context) ([]deletionrequestmigration.SourceDeleteRequest, error) { + rows, err := s.legacy.Query(ctx, ` + SELECT document_id::text, requested_by::text, requested_at, confirmed_by::text, confirmed_at, status + FROM document_delete_requests WHERE tenant_id = $1 ORDER BY id + `, s.tenantID) + if err != nil { + return nil, err + } + type row struct { + docID, requestedBy string + requestedAt time.Time + confirmedBy *string + confirmedAt *time.Time + status string + } + var raw []row + for rows.Next() { + var r row + if err := rows.Scan(&r.docID, &r.requestedBy, &r.requestedAt, &r.confirmedBy, &r.confirmedAt, &r.status); err != nil { + rows.Close() + return nil, err + } + raw = append(raw, r) + } + rows.Close() + if err := rows.Err(); err != nil { + return nil, err + } + + var out []deletionrequestmigration.SourceDeleteRequest + for _, r := range raw { + ref, ok := s.refs[r.docID] + if !ok { + return nil, fmt.Errorf("loeschanfrage zu dokument %s: keine objektreferenz aus dok01", r.docID) + } + req := deletionrequestmigration.SourceDeleteRequest{ObjectType: objectTypeDocument, ObjectReference: ref, RequestedAt: r.requestedAt} + if req.RequestedBy, err = s.users.ResolveUserID(ctx, r.requestedBy); err != nil { + return nil, err + } + switch r.status { + case "pending", "confirmed", "blocked_retention": + case "executed": + if r.confirmedBy == nil || r.confirmedAt == nil { + return nil, fmt.Errorf("vollzogene loeschung zu dokument %s ohne bestaetigung", r.docID) + } + req.Executed = true + req.ExecutedAt = *r.confirmedAt + if req.ExecutedBy, err = s.users.ResolveUserID(ctx, *r.confirmedBy); err != nil { + return nil, err + } + case "cancelled": + *s.skipped++ + continue + default: + return nil, fmt.Errorf("loeschanfrage zu dokument %s: unbekannter status %q", r.docID, r.status) + } + out = append(out, req) + } + return out, nil +} + +func (e *Env) tar02(ctx context.Context, t Tenant, res *Result) error { + if t.ArchivdmsTenantID == nil { + res.note("%s: kein archivdms-mandant, stufe entfaellt", t.Slug) + return nil + } + docs, err := e.handoffDocuments(t.Slug) + if err != nil { + return err + } + refs := map[string]string{} + for _, d := range docs { + refs[d.SourceDocumentID] = d.ObjectReference + } + legacy, err := e.Pool(ctx, e.Ctx.ArchivdmsDB) + if err != nil { + return err + } + pool, err := e.Pool(ctx, t.ArchiveDB) + if err != nil { + return err + } + users, err := e.userResolver(ctx, t) + if err != nil { + return err + } + skipped := 0 + src := deleteRequestSource{legacy: legacy, tenantID: *t.ArchivdmsTenantID, refs: refs, users: users, skipped: &skipped} + out, err := deletionrequestmigration.NewMigrator(pool, src).MigrateAll(ctx) + if err != nil { + return err + } + res.add("offene_loeschanfragen", out.OpenRequestsMigrated) + res.add("vollzogene_loeschungen_audit", out.ExecutedAudited) + res.add("abgebrochene_nicht_migriert", skipped) + return nil +} diff --git a/archive/internal/retentionmigration/retentionmigration.go b/archive/internal/retentionmigration/retentionmigration.go index 47f9999c..d2339027 100644 --- a/archive/internal/retentionmigration/retentionmigration.go +++ b/archive/internal/retentionmigration/retentionmigration.go @@ -16,11 +16,15 @@ import ( // SourceObject ist EIN bereits migriertes Kernobjekt (Dokument oder // Nachricht) mit optionaler Frist aus der Quelle. RetainUntil == nil -// bedeutet: Objekt hatte in der Quelle keine Frist. +// bedeutet: Objekt hatte in der Quelle keine Frist. DataSubjectRef +// verknuepft das Objekt mit der betroffenen Person, nach der MAI-03 +// (dsgvorequest/dpreport) sucht; ohne sie liefe eine offene +// DSGVO-Loeschanfrage ins Leere (CUT-01, Befund I-4). type SourceObject struct { ObjectType string ObjectReference string RetainUntil *time.Time + DataSubjectRef string } // Source liefert die bereits migrierten Kernobjekte je Altsystem-Adapter @@ -60,7 +64,12 @@ func (m *Migrator) MigrateAll(ctx context.Context, now time.Time) (Result, error } for _, obj := range objs { - id, err := retention.RegisterObject(ctx, m.pool, obj.ObjectType, obj.ObjectReference) + var id string + if obj.DataSubjectRef != "" { + id, err = retention.RegisterObjectForSubject(ctx, m.pool, obj.ObjectType, obj.ObjectReference, obj.DataSubjectRef) + } else { + id, err = retention.RegisterObject(ctx, m.pool, obj.ObjectType, obj.ObjectReference) + } if err != nil { return res, fmt.Errorf("retentionmigration: objekt %s/%s registrieren: %w", obj.ObjectType, obj.ObjectReference, err) } diff --git a/archive/internal/retentionmigration/retentionmigration_test.go b/archive/internal/retentionmigration/retentionmigration_test.go index 63d0d4f6..fd2a1112 100644 --- a/archive/internal/retentionmigration/retentionmigration_test.go +++ b/archive/internal/retentionmigration/retentionmigration_test.go @@ -43,6 +43,9 @@ func requireTestPool(t *testing.T) *pgxpool.Pool { if _, err := pool.Exec(ctx, `ALTER TABLE retention_objects ADD COLUMN IF NOT EXISTS retain_until TIMESTAMPTZ`); err != nil { t.Fatalf("schema (retain_until nachruesten): %v", err) } + if _, err := pool.Exec(ctx, `ALTER TABLE retention_objects ADD COLUMN IF NOT EXISTS data_subject_ref TEXT`); err != nil { + t.Fatalf("schema (data_subject_ref nachruesten): %v", err) + } t.Cleanup(func() { _, _ = pool.Exec(context.Background(), `TRUNCATE retention_objects CASCADE`) }) @@ -185,3 +188,36 @@ func TestMigrateAll_ResultIndependentOfInjectedNow(t *testing.T) { t.Fatalf("lauf mit now=%v lieferte andere frist als lauf mit now=%v: %v != %v", nowA, nowB, gotA, gotB) } } + +// CUT-01 Befund I-4: die betroffene Person aus der Quelle landet an +// retention_objects, damit MAI-03 die Objekte einer offenen DSGVO-Anfrage +// findet; Objekte ohne Personenbezug bleiben ohne Referenz. +func TestMigrateAll_CarriesDataSubjectRef(t *testing.T) { + pool := requireTestPool(t) + due := time.Date(2031, time.March, 1, 0, 0, 0, 0, time.UTC) + + m := NewMigrator(pool, &fixtureSource{objects: []SourceObject{ + {ObjectType: "mail_message", ObjectReference: "msg-mit-person", RetainUntil: &due, DataSubjectRef: "betroffene@synth.example"}, + {ObjectType: "dms_document", ObjectReference: "doc-ohne-person"}, + }}) + if _, err := m.MigrateAll(context.Background(), time.Now()); err != nil { + t.Fatalf("migrateall: %v", err) + } + + var ref *string + if err := pool.QueryRow(context.Background(), `SELECT data_subject_ref FROM retention_objects WHERE object_reference = 'msg-mit-person'`).Scan(&ref); err != nil { + t.Fatal(err) + } + if ref == nil || *ref != "betroffene@synth.example" { + t.Fatalf("data_subject_ref = %v, erwartet betroffene@synth.example", ref) + } + if got, ok := mustRetainUntil(t, pool, objectID(t, pool, "mail_message", "msg-mit-person")); !ok || !got.Equal(due) { + t.Fatalf("frist ging bei registrierung mit person verloren: %v %v", got, ok) + } + if err := pool.QueryRow(context.Background(), `SELECT data_subject_ref FROM retention_objects WHERE object_reference = 'doc-ohne-person'`).Scan(&ref); err != nil { + t.Fatal(err) + } + if ref != nil { + t.Fatalf("objekt ohne personenbezug hat data_subject_ref %q", *ref) + } +} diff --git a/cmd/cutover-dryrun/main.go b/cmd/cutover-dryrun/main.go new file mode 100644 index 00000000..00675b37 --- /dev/null +++ b/cmd/cutover-dryrun/main.go @@ -0,0 +1,80 @@ +// cutover-dryrun fuehrt CUT-01 aus: Trockenlauf aller Migrationsschritte +// gegen eine vollstaendig synthetische Testkopie auf einem Wegwerf-Cluster. +// Zugang ausschliesslich ueber NEXARCH_CUTOVER_ADMIN_DSN (ersatzweise +// TEST_ADMIN_DSN); der DSN braucht CREATEDB-Rechte. +package main + +import ( + "context" + "flag" + "fmt" + "os" + "os/signal" + "path/filepath" + "syscall" + + "gitea.perlbach24.de/scripte/nexarch/internal/cutoverorchestrator" +) + +func main() { + repo := flag.String("repo", ".", "Wurzel des Repository-Checkouts") + workRoot := flag.String("arbeitsverzeichnis", filepath.Join(os.TempDir(), "nexarch-cut01"), "Basisverzeichnis fuer Laufdaten und Protokolle") + seed := flag.Int64("seed", 20261014, "Seed des Testdaten-Generators") + cost := flag.Int("bcrypt-kosten", 12, "bcrypt-Kostenfaktor der synthetischen Passwort-Hashes") + label := flag.String("bezeichnung", "Trockenlauf", "Bezeichnung des Laufs im Protokoll") + keep := flag.Bool("behalten", false, "Datenbanken und Arbeitsdateien nach dem Lauf nicht entfernen") + flag.Parse() + + adminDSN := os.Getenv("NEXARCH_CUTOVER_ADMIN_DSN") + if adminDSN == "" { + adminDSN = os.Getenv("TEST_ADMIN_DSN") + } + if adminDSN == "" { + fmt.Fprintln(os.Stderr, "NEXARCH_CUTOVER_ADMIN_DSN (oder TEST_ADMIN_DSN) nicht gesetzt") + os.Exit(2) + } + repoRoot, err := filepath.Abs(*repo) + if err != nil { + fmt.Fprintln(os.Stderr, "repo-pfad:", err) + os.Exit(2) + } + + dr, err := cutoverorchestrator.NewDryrun(cutoverorchestrator.Config{ + RepoRoot: repoRoot, WorkRoot: *workRoot, Seed: *seed, BcryptCost: *cost, + }, adminDSN) + if err != nil { + fmt.Fprintln(os.Stderr, "vorbereitung:", err) + os.Exit(2) + } + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + runner := cutoverorchestrator.Runner{Log: os.Stdout, Redactor: dr.Redactor} + fmt.Printf("CUT-01 Trockenlauf %s (%s), Arbeitsverzeichnis %s\n", dr.RunTag, *label, dr.RunDir) + rep := runner.Run(ctx, dr.RunTag, *label, dr.Plan()) + + if err := os.MkdirAll(dr.RunDir, 0o700); err == nil { + if err := cutoverorchestrator.WriteJSON(filepath.Join(dr.RunDir, "protokoll.json"), rep); err != nil { + fmt.Fprintln(os.Stderr, dr.Redactor.Redact(err.Error())) + } + if err := os.WriteFile(filepath.Join(dr.RunDir, "protokoll.txt"), []byte(cutoverorchestrator.RenderText(rep)), 0o644); err != nil { + fmt.Fprintln(os.Stderr, dr.Redactor.Redact(err.Error())) + } + } + fmt.Print("\n" + cutoverorchestrator.RenderText(rep)) + + if *keep { + dr.Close() + fmt.Printf("\nDatenbanken und Arbeitsdateien behalten (Laufkennung %s)\n", dr.RunTag) + } else { + dropped, err := dr.Cleanup(context.Background()) + if err != nil { + fmt.Fprintln(os.Stderr, "aufraeumen:", dr.Redactor.Redact(err.Error())) + } + fmt.Printf("\nAufgeraeumt: %d Lauf-Datenbanken entfernt, Protokolle unter %s\n", len(dropped), dr.RunDir) + } + if !rep.Success { + os.Exit(1) + } +} diff --git a/dms/cmd/cutover-stage/main.go b/dms/cmd/cutover-stage/main.go new file mode 100644 index 00000000..73e5e5b7 --- /dev/null +++ b/dms/cmd/cutover-stage/main.go @@ -0,0 +1,37 @@ +// cutover-stage fuehrt eine DMS-Stufe des CUT-01-Trockenlaufs aus; Aufruf +// ausschliesslich durch cmd/cutover-dryrun im Root-Modul. +package main + +import ( + "context" + "encoding/json" + "fmt" + "os" + + "gitea.perlbach24.de/scripte/nexarch/dms/internal/cutoverstage" +) + +func main() { + if len(os.Args) != 2 { + fmt.Fprintln(os.Stderr, "aufruf: cutover-stage ") + os.Exit(2) + } + env, err := cutoverstage.LoadEnv() + if err != nil { + fmt.Fprintln(os.Stderr, "fehler:", err) + os.Exit(1) + } + defer env.Close() + + res, err := cutoverstage.Run(context.Background(), env, os.Args[1]) + if err != nil { + fmt.Fprintln(os.Stderr, "fehler:", err) + env.Close() + os.Exit(1) + } + if err := json.NewEncoder(os.Stdout).Encode(res); err != nil { + fmt.Fprintln(os.Stderr, "fehler:", err) + env.Close() + os.Exit(1) + } +} diff --git a/dms/internal/cutoverstage/env.go b/dms/internal/cutoverstage/env.go new file mode 100644 index 00000000..3379033e --- /dev/null +++ b/dms/internal/cutoverstage/env.go @@ -0,0 +1,194 @@ +// Package cutoverstage ist die DMS-Modulstufe des CUT-01-Trockenlaufs +// (Orchestrator: internal/cutoverorchestrator im Root-Modul). Sie liefert +// die Quell-Adapter gegen die synthetische archivdms-Testkopie und ruft die +// bestehenden DMS-Migratoren auf; eigene Migrationslogik enthaelt sie nicht. +package cutoverstage + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + + "github.com/jackc/pgx/v5/pgxpool" +) + +type Tenant struct { + Slug string `json:"slug"` + NexarchTenantID string `json:"nexarch_tenant_id"` + ArchivmailTenantID *int64 `json:"archivmail_tenant_id"` + ArchivdmsTenantID *int64 `json:"archivdms_tenant_id"` + TenantDB string `json:"tenant_db"` + ArchiveDB string `json:"archive_db"` + WorkflowDB string `json:"workflow_db"` +} + +type Context struct { + RunTag string `json:"run_tag"` + RunDir string `json:"run_dir"` + RegistryDB string `json:"registry_db"` + ArchivmailDB string `json:"archivmail_db"` + ArchivdmsDB string `json:"archivdms_db"` + LegacyObjects string `json:"legacy_objects_dir"` + MigrationsDirs map[string]string `json:"migrations_dirs"` + Tenants []Tenant `json:"tenants"` +} + +type Result struct { + Stage string `json:"stage"` + Counts map[string]int `json:"counts"` + Notes []string `json:"notes"` +} + +func (r *Result) add(key string, n int) { + if r.Counts == nil { + r.Counts = map[string]int{} + } + r.Counts[key] += n +} + +func (r *Result) note(format string, args ...any) { + r.Notes = append(r.Notes, fmt.Sprintf(format, args...)) +} + +type Env struct { + Ctx Context + adminDSN string + kekSeed []byte + pools map[string]*pgxpool.Pool +} + +func LoadEnv() (*Env, error) { + adminDSN := os.Getenv("NEXARCH_CUTOVER_ADMIN_DSN") + if adminDSN == "" { + return nil, errors.New("NEXARCH_CUTOVER_ADMIN_DSN nicht gesetzt") + } + ctxPath := os.Getenv("NEXARCH_CUTOVER_CONTEXT") + if ctxPath == "" { + return nil, errors.New("NEXARCH_CUTOVER_CONTEXT nicht gesetzt") + } + raw, err := os.ReadFile(ctxPath) + if err != nil { + return nil, fmt.Errorf("laufkontext lesen: %w", err) + } + e := &Env{adminDSN: adminDSN, pools: map[string]*pgxpool.Pool{}} + if err := json.Unmarshal(raw, &e.Ctx); err != nil { + return nil, fmt.Errorf("laufkontext parsen: %w", err) + } + if seed := os.Getenv("NEXARCH_CUTOVER_KEK_SEED"); seed != "" { + e.kekSeed, err = hex.DecodeString(seed) + if err != nil || len(e.kekSeed) < 32 { + return nil, errors.New("NEXARCH_CUTOVER_KEK_SEED ungueltig") + } + } + return e, nil +} + +func (e *Env) Pool(ctx context.Context, database string) (*pgxpool.Pool, error) { + if p, ok := e.pools[database]; ok { + return p, nil + } + cfg, err := pgxpool.ParseConfig(e.adminDSN) + if err != nil { + return nil, errors.New("admin-dsn nicht lesbar") + } + cfg.ConnConfig.Database = database + cfg.MaxConns = 4 + p, err := pgxpool.NewWithConfig(ctx, cfg) + if err != nil { + return nil, fmt.Errorf("verbindung zu %q: %w", database, err) + } + e.pools[database] = p + return p, nil +} + +func (e *Env) Close() { + for _, p := range e.pools { + p.Close() + } +} + +// TenantKEK ersetzt im Trockenlauf die Core-KEK-API: pro Lauf zufaelliger +// Seed, pro Mandant per HMAC abgeleitet, damit DOK-01 (verschluesseln) und +// VAL-02 (entschluesseln) in getrennten Prozessen denselben Schluessel sehen. +func (e *Env) TenantKEK(_ context.Context, tenantSlug string) ([]byte, error) { + if len(e.kekSeed) == 0 { + return nil, errors.New("kein kek-seed fuer den trockenlauf gesetzt") + } + mac := hmac.New(sha256.New, e.kekSeed) + mac.Write([]byte("cut01-dryrun-kek\x00" + tenantSlug)) + return mac.Sum(nil), nil +} + +func (e *Env) HandoffDir(slug string) string { + return filepath.Join(e.Ctx.RunDir, "uebergabe", slug) +} + +func (e *Env) TargetDir(kind, slug string) string { + return filepath.Join(e.Ctx.RunDir, "ziel-objekte", kind, slug) +} + +type registryUserResolver struct { + registry *pgxpool.Pool + tenantID string +} + +func (r registryUserResolver) ResolveUserID(ctx context.Context, sourceUserID string) (string, error) { + id, err := strconv.ParseInt(strings.TrimSpace(sourceUserID), 10, 64) + if err != nil { + return "", fmt.Errorf("archivdms-benutzer-id %q ungueltig", sourceUserID) + } + var out string + err = r.registry.QueryRow(ctx, ` + SELECT nexarch_user_id::text FROM user_migration_map + WHERE nexarch_tenant_id = $1 AND archivdms_user_id = $2 AND approved_at IS NOT NULL + `, r.tenantID, id).Scan(&out) + if err != nil { + return "", fmt.Errorf("archivdms-benutzer %d nicht in freigegebener user_migration_map: %w", id, err) + } + return out, nil +} + +func (e *Env) userResolver(ctx context.Context, t Tenant) (registryUserResolver, error) { + reg, err := e.Pool(ctx, e.Ctx.RegistryDB) + if err != nil { + return registryUserResolver{}, err + } + return registryUserResolver{registry: reg, tenantID: t.NexarchTenantID}, nil +} + +// legacyPath haelt Objektschluessel der Quelle innerhalb des Objektverzeichnisses. +func legacyPath(root, key string) (string, error) { + full := filepath.Join(root, filepath.FromSlash(key)) + rel, err := filepath.Rel(root, full) + if err != nil || strings.HasPrefix(rel, "..") { + return "", fmt.Errorf("objektschluessel %q verlaesst das quellverzeichnis", key) + } + return full, nil +} + +func writeJSON(path string, v any) error { + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return err + } + data, err := json.MarshalIndent(v, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, data, 0o600) +} + +func readJSON(path string, v any) error { + data, err := os.ReadFile(path) + if err != nil { + return err + } + return json.Unmarshal(data, v) +} diff --git a/dms/internal/cutoverstage/sources.go b/dms/internal/cutoverstage/sources.go new file mode 100644 index 00000000..dad01a56 --- /dev/null +++ b/dms/internal/cutoverstage/sources.go @@ -0,0 +1,282 @@ +package cutoverstage + +import ( + "context" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "os" + "path" + "regexp" + "strconv" + "strings" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + + "gitea.perlbach24.de/scripte/nexarch/dms/internal/migrationdocument" + "gitea.perlbach24.de/scripte/nexarch/dms/internal/migrationfolder" + "gitea.perlbach24.de/scripte/nexarch/dms/internal/migrationmetadata" + "gitea.perlbach24.de/scripte/nexarch/dms/internal/migrationsharelink" +) + +// documentSource: archivdms kennt keine Revisionstabelle (WORM, eine Datei +// je Dokument), daher liefert der Adapter genau eine Revision 1 je Dokument. +// Bereits vollzogen geloeschte Dokumente (deleted_at) haben keine Datei mehr +// und werden nicht als Dokument uebernommen (TAR-02 fuehrt sie als Audit). +type documentSource struct { + legacy *pgxpool.Pool + tenantID int64 + objectRoot string +} + +func (s documentSource) Documents(ctx context.Context) ([]migrationdocument.SourceDocument, error) { + rows, err := s.legacy.Query(ctx, ` + SELECT id::text, title, COALESCE(created_by::text, '') + FROM documents WHERE tenant_id = $1 AND deleted_at IS NULL ORDER BY id + `, s.tenantID) + if err != nil { + return nil, err + } + return pgx.CollectRows(rows, func(r pgx.CollectableRow) (migrationdocument.SourceDocument, error) { + var d migrationdocument.SourceDocument + err := r.Scan(&d.SourceDocumentID, &d.Title, &d.SourceUserID) + return d, err + }) +} + +func mimeForPath(p string) string { + switch strings.ToLower(path.Ext(p)) { + case ".pdf": + return "application/pdf" + case ".txt": + return "text/plain" + case ".png": + return "image/png" + case ".jpg", ".jpeg": + return "image/jpeg" + } + return "application/octet-stream" +} + +func (s documentSource) Revisions(ctx context.Context, sourceDocumentID string) ([]migrationdocument.SourceRevision, error) { + var storagePath, hash, createdBy string + err := s.legacy.QueryRow(ctx, ` + SELECT storage_path, content_hash, COALESCE(created_by::text, '') FROM documents WHERE id = $1::bigint + `, sourceDocumentID).Scan(&storagePath, &hash, &createdBy) + if err != nil { + return nil, err + } + full, err := legacyPath(s.objectRoot, storagePath) + if err != nil { + return nil, err + } + info, err := os.Stat(full) + if err != nil { + return nil, fmt.Errorf("quelldatei zu dokument %s: %w", sourceDocumentID, err) + } + return []migrationdocument.SourceRevision{{ + SourceRevisionID: sourceDocumentID + "-r1", + SourceDocumentID: sourceDocumentID, + RevisionNumber: 1, + ChecksumSHA256: hash, + SizeBytes: info.Size(), + MimeType: mimeForPath(storagePath), + SourceUserID: createdBy, + ObjectKey: storagePath, + }}, nil +} + +func (s documentSource) OpenObject(_ context.Context, objectKey string) (io.ReadCloser, error) { + full, err := legacyPath(s.objectRoot, objectKey) + if err != nil { + return nil, err + } + return os.Open(full) +} + +type folderSource struct { + legacy *pgxpool.Pool + tenantID int64 +} + +// archivdms-Akten sind flach (keine Elternspalte), jede Akte wird Wurzelordner. +func (s folderSource) Folders(ctx context.Context) ([]migrationfolder.SourceFolder, error) { + rows, err := s.legacy.Query(ctx, ` + SELECT id::text, titel, COALESCE(created_by::text, '') FROM akten WHERE tenant_id = $1 ORDER BY id + `, s.tenantID) + if err != nil { + return nil, err + } + return pgx.CollectRows(rows, func(r pgx.CollectableRow) (migrationfolder.SourceFolder, error) { + var f migrationfolder.SourceFolder + err := r.Scan(&f.SourceFolderID, &f.Name, &f.SourceUserID) + return f, err + }) +} + +func (s folderSource) DocumentFolders(ctx context.Context) ([]migrationfolder.SourceDocumentFolder, error) { + rows, err := s.legacy.Query(ctx, ` + SELECT id::text, akte_id::text FROM documents + WHERE tenant_id = $1 AND akte_id IS NOT NULL AND deleted_at IS NULL ORDER BY id + `, s.tenantID) + if err != nil { + return nil, err + } + return pgx.CollectRows(rows, func(r pgx.CollectableRow) (migrationfolder.SourceDocumentFolder, error) { + var df migrationfolder.SourceDocumentFolder + err := r.Scan(&df.SourceDocumentID, &df.SourceFolderID) + return df, err + }) +} + +type metadataSource struct { + legacy *pgxpool.Pool + tenantID int64 +} + +var nonKeyChars = regexp.MustCompile(`[^a-z0-9]+`) + +func fieldKey(name string) string { + k := strings.ToLower(name) + k = strings.NewReplacer("ä", "ae", "ö", "oe", "ü", "ue", "ß", "ss").Replace(k) + return strings.Trim(nonKeyChars.ReplaceAllString(k, "_"), "_") +} + +// targetFieldType bildet die archivdms-Feldtypen auf die von MET-02 +// (metadata_fields.field_type CHECK) erlaubten Typen ab. +func targetFieldType(legacy string) (string, error) { + switch legacy { + case "text", "number", "date": + return legacy, nil + case "boolean": + return "bool", nil + case "enum": + return "select", nil + case "monetary": + return "number", nil + } + return "", fmt.Errorf("unbekannter archivdms-feldtyp %q", legacy) +} + +func (s metadataSource) CustomFields(ctx context.Context) ([]migrationmetadata.SourceCustomField, error) { + rows, err := s.legacy.Query(ctx, ` + SELECT id::text, name, field_type, required, enum_options FROM custom_field_defs WHERE tenant_id = $1 ORDER BY id + `, s.tenantID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []migrationmetadata.SourceCustomField + for rows.Next() { + var id, name, legacyType string + var required bool + var enumRaw []byte + if err := rows.Scan(&id, &name, &legacyType, &required, &enumRaw); err != nil { + return nil, err + } + t, err := targetFieldType(legacyType) + if err != nil { + return nil, err + } + rule := map[string]any{} + if len(enumRaw) > 0 { + var opts []any + if err := json.Unmarshal(enumRaw, &opts); err != nil { + return nil, fmt.Errorf("auswahlwerte von feld %s: %w", id, err) + } + rule["options"] = opts + } + if legacyType == "monetary" { + rule["min"] = float64(0) + } + out = append(out, migrationmetadata.SourceCustomField{ + SourceFieldID: id, Key: fieldKey(name), Label: name, Type: t, Required: required, ValidationRule: rule, + }) + } + return out, rows.Err() +} + +func (s metadataSource) ClassificationTemplates(ctx context.Context) ([]migrationmetadata.SourceClassificationTemplate, error) { + rows, err := s.legacy.Query(ctx, ` + SELECT t.id::text, t.name, t.retention_years, + COALESCE(array_agg(d.field_def_id::text ORDER BY d.field_def_id) FILTER (WHERE d.field_def_id IS NOT NULL), '{}') + FROM classification_templates t + LEFT JOIN classification_template_field_defaults d ON d.template_id = t.id + WHERE t.tenant_id = $1 + GROUP BY t.id ORDER BY t.id + `, s.tenantID) + if err != nil { + return nil, err + } + return pgx.CollectRows(rows, func(r pgx.CollectableRow) (migrationmetadata.SourceClassificationTemplate, error) { + var t migrationmetadata.SourceClassificationTemplate + var years *int + if err := r.Scan(&t.SourceTemplateID, &t.Name, &years, &t.SourceFieldIDs); err != nil { + return t, err + } + if years != nil { + rc := strconv.Itoa(*years) + "-jahre" + t.RetentionClass = &rc + } + return t, nil + }) +} + +func (s metadataSource) FieldValues(ctx context.Context) ([]migrationmetadata.SourceFieldValue, error) { + rows, err := s.legacy.Query(ctx, ` + SELECT v.document_id::text, v.field_def_id::text, v.value + FROM document_field_values v JOIN documents d ON d.id = v.document_id + WHERE d.tenant_id = $1 AND d.deleted_at IS NULL AND v.value IS NOT NULL + ORDER BY v.document_id, v.field_def_id + `, s.tenantID) + if err != nil { + return nil, err + } + return pgx.CollectRows(rows, func(r pgx.CollectableRow) (migrationmetadata.SourceFieldValue, error) { + var v migrationmetadata.SourceFieldValue + err := r.Scan(&v.SourceDocumentID, &v.SourceFieldID, &v.Value) + return v, err + }) +} + +// shareSource liefert nur nicht widerrufene Links auf noch vorhandene +// Dokumente; SourceShareLink kennt kein Widerrufsfeld, ein widerrufener Link +// waere nach der Migration sonst wieder gueltig. +type shareSource struct { + legacy *pgxpool.Pool + tenantID int64 +} + +func (s shareSource) ShareLinks(ctx context.Context) ([]migrationsharelink.SourceShareLink, error) { + rows, err := s.legacy.Query(ctx, ` + SELECT s.id::text, s.document_id::text, s.created_by::text, s.token_hash, s.password_hash, + s.max_accesses, s.access_count, s.created_at, s.expires_at + FROM document_shares s JOIN documents d ON d.id = s.document_id + WHERE s.tenant_id = $1 AND s.revoked_at IS NULL AND d.deleted_at IS NULL + ORDER BY s.id + `, s.tenantID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []migrationsharelink.SourceShareLink + for rows.Next() { + var l migrationsharelink.SourceShareLink + var tokenHex string + var created, expires time.Time + if err := rows.Scan(&l.SourceLinkID, &l.SourceDocumentID, &l.SourceUserID, &tokenHex, &l.PasswordHash, + &l.DownloadLimit, &l.DownloadCount, &created, &expires); err != nil { + return nil, err + } + l.TokenHash, err = hex.DecodeString(strings.TrimSpace(tokenHex)) + if err != nil { + return nil, fmt.Errorf("token-hash von freigabelink %s ist kein hex: %w", l.SourceLinkID, err) + } + l.CreatedAt, l.ExpiresAt = created, expires + out = append(out, l) + } + return out, rows.Err() +} diff --git a/dms/internal/cutoverstage/sources_test.go b/dms/internal/cutoverstage/sources_test.go new file mode 100644 index 00000000..599a96ee --- /dev/null +++ b/dms/internal/cutoverstage/sources_test.go @@ -0,0 +1,42 @@ +package cutoverstage + +import ( + "os" + "path/filepath" + "testing" +) + +func TestTargetFieldType_MapsAllArchivdmsTypesToMET02(t *testing.T) { + allowed := map[string]bool{"text": true, "number": true, "date": true, "bool": true, "select": true} + for _, legacy := range []string{"text", "number", "date", "boolean", "enum", "monetary"} { + got, err := targetFieldType(legacy) + if err != nil || !allowed[got] { + t.Errorf("archivdms-typ %q -> %q (err %v) verletzt metadata_fields.field_type", legacy, got, err) + } + } + if _, err := targetFieldType("geodaten"); err == nil { + t.Fatal("unbekannter typ muss abgelehnt werden statt still zu text zu werden") + } +} + +func TestFieldKey(t *testing.T) { + cases := map[string]string{"Faellig am": "faellig_am", "Größe (€)": "groesse", "Aktenzeichen": "aktenzeichen"} + for in, want := range cases { + if got := fieldKey(in); got != want { + t.Errorf("fieldKey(%q) = %q, erwartet %q", in, got, want) + } + } +} + +func TestLegacyPath_RejectsTraversal(t *testing.T) { + root := t.TempDir() + if _, err := legacyPath(root, "../../etc/passwd"); err == nil { + t.Fatal("objektschluessel ausserhalb des quellverzeichnisses muss abgelehnt werden") + } + if err := os.WriteFile(filepath.Join(root, "ok.txt"), []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + if p, err := legacyPath(root, "ok.txt"); err != nil || p != filepath.Join(root, "ok.txt") { + t.Fatalf("gueltiger schluessel abgelehnt: %v", err) + } +} diff --git a/dms/internal/cutoverstage/stages.go b/dms/internal/cutoverstage/stages.go new file mode 100644 index 00000000..8e39c28c --- /dev/null +++ b/dms/internal/cutoverstage/stages.go @@ -0,0 +1,305 @@ +package cutoverstage + +import ( + "context" + "crypto/rand" + "fmt" + "io" + "os" + "path/filepath" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + + dmscrypto "gitea.perlbach24.de/scripte/nexarch/dms/internal/crypto" + "gitea.perlbach24.de/scripte/nexarch/dms/internal/migrate" + "gitea.perlbach24.de/scripte/nexarch/dms/internal/migrationdocument" + "gitea.perlbach24.de/scripte/nexarch/dms/internal/migrationfolder" + "gitea.perlbach24.de/scripte/nexarch/dms/internal/migrationmetadata" + "gitea.perlbach24.de/scripte/nexarch/dms/internal/migrationsharelink" + "gitea.perlbach24.de/scripte/nexarch/dms/internal/storage" +) + +type DocumentHandoff struct { + SourceDocumentID string `json:"source_document_id"` + SourceObjectKey string `json:"source_object_key"` + ObjectReference string `json:"object_reference"` + RetainUntil *time.Time `json:"retain_until"` + Migrated bool `json:"migrated"` +} + +type Sample struct { + Label string `json:"label"` + SourceObjectKey string `json:"source_object_key"` + StorageKey string `json:"storage_key"` +} + +func Run(ctx context.Context, e *Env, stage string) (Result, error) { + res := Result{Stage: stage} + var err error + switch stage { + case "schema": + err = e.schema(ctx, &res) + case "dok01": + err = e.forDMSTenants(ctx, &res, e.dok01) + case "dok02": + err = e.forDMSTenants(ctx, &res, e.dok02) + case "dok04": + err = e.forDMSTenants(ctx, &res, e.dok04) + case "dok05": + err = e.forDMSTenants(ctx, &res, e.dok05) + case "val02": + err = e.forDMSTenants(ctx, &res, e.val02) + default: + err = fmt.Errorf("unbekannte dms-stufe %q", stage) + } + return res, err +} + +func (e *Env) forDMSTenants(ctx context.Context, res *Result, fn func(context.Context, Tenant, int64, *Result) error) error { + for _, t := range e.Ctx.Tenants { + if t.ArchivdmsTenantID == nil { + res.note("%s: kein archivdms-mandant, stufe entfaellt", t.Slug) + continue + } + if err := fn(ctx, t, *t.ArchivdmsTenantID, res); err != nil { + return fmt.Errorf("mandant %s: %w", t.Slug, err) + } + } + return nil +} + +func (e *Env) schema(ctx context.Context, res *Result) error { + migrations, err := migrate.Load(e.Ctx.MigrationsDirs["dms"]) + if err != nil { + return err + } + for _, t := range e.Ctx.Tenants { + pool, err := e.Pool(ctx, t.TenantDB) + if err != nil { + return err + } + var usersPresent bool + if err := pool.QueryRow(ctx, `SELECT to_regclass('users') IS NOT NULL`).Scan(&usersPresent); err != nil { + return err + } + if !usersPresent { + return fmt.Errorf("mandant %s: tabelle users fehlt, core-tenant-schema nicht ausgerollt", t.Slug) + } + applied, err := migrate.Up(ctx, pool, migrations) + if err != nil { + return fmt.Errorf("mandant %s: dms-migrationen: %w", t.Slug, err) + } + res.add("mandanten", 1) + res.add("dms_migrationen_angewendet", len(applied)) + } + return nil +} + +type noopUsage struct{} + +func (noopUsage) Report(context.Context, string, string, int64) error { return nil } + +func (e *Env) dmsServices(t Tenant) (*storage.Service, *dmscrypto.Service, error) { + secret := make([]byte, 32) + if _, err := rand.Read(secret); err != nil { + return nil, nil, err + } + dir := e.TargetDir("dms", t.Slug) + if err := os.MkdirAll(dir, 0o700); err != nil { + return nil, nil, err + } + driver := storage.NewLocalDriver(dir, secret, "", 0) + return storage.NewService(driver, noopUsage{}, t.Slug), dmscrypto.NewService(e), nil +} + +func (e *Env) dok01(ctx context.Context, t Tenant, legacyTenantID int64, res *Result) error { + legacy, err := e.Pool(ctx, e.Ctx.ArchivdmsDB) + if err != nil { + return err + } + target, err := e.Pool(ctx, t.TenantDB) + if err != nil { + return err + } + users, err := e.userResolver(ctx, t) + if err != nil { + return err + } + storageSvc, cryptoSvc, err := e.dmsServices(t) + if err != nil { + return err + } + src := documentSource{legacy: legacy, tenantID: legacyTenantID, objectRoot: filepath.Join(e.Ctx.LegacyObjects, "archivdms")} + out, err := migrationdocument.NewMigrator(target, src, users, storageSvc, cryptoSvc, t.Slug).MigrateAll(ctx) + if err != nil { + return err + } + res.add("dokumente", out.DocumentsMigrated) + res.add("revisionen", out.RevisionsMigrated) + res.add("revisionen_uebersprungen", out.RevisionsSkipped) + + rows, err := legacy.Query(ctx, ` + SELECT id::text, storage_path, retain_until::timestamptz, deleted_at IS NULL + FROM documents WHERE tenant_id = $1 ORDER BY id + `, legacyTenantID) + if err != nil { + return err + } + defer rows.Close() + var handoff []DocumentHandoff + for rows.Next() { + var h DocumentHandoff + if err := rows.Scan(&h.SourceDocumentID, &h.SourceObjectKey, &h.RetainUntil, &h.Migrated); err != nil { + return err + } + h.SourceObjectKey = "archivdms/" + h.SourceObjectKey + h.ObjectReference = migrationdocument.DocumentID(t.Slug, h.SourceDocumentID) + handoff = append(handoff, h) + } + if err := rows.Err(); err != nil { + return err + } + res.add("uebergabe_objekte", len(handoff)) + return writeJSON(filepath.Join(e.HandoffDir(t.Slug), "dms_objekte.json"), handoff) +} + +func (e *Env) dok02(ctx context.Context, t Tenant, legacyTenantID int64, res *Result) error { + legacy, target, users, err := e.legacyTargetUsers(ctx, t) + if err != nil { + return err + } + out, err := migrationfolder.NewMigrator(target, folderSource{legacy: legacy, tenantID: legacyTenantID}, users, t.Slug).MigrateAll(ctx) + if err != nil { + return err + } + res.add("akten", out.FoldersMigrated) + res.add("akten_uebersprungen", out.FoldersSkipped) + res.add("dokumente_verknuepft", out.DocumentsLinked) + return nil +} + +func (e *Env) dok04(ctx context.Context, t Tenant, legacyTenantID int64, res *Result) error { + legacy, target, _, err := e.legacyTargetUsers(ctx, t) + if err != nil { + return err + } + out, err := migrationmetadata.NewMigrator(target, metadataSource{legacy: legacy, tenantID: legacyTenantID}, t.Slug).MigrateAll(ctx) + if err != nil { + return err + } + res.add("felder", out.CustomFieldsMigrated) + res.add("vorlagen", out.TemplatesMigrated) + res.add("feldwerte", out.FieldValuesMigrated) + return nil +} + +func (e *Env) dok05(ctx context.Context, t Tenant, legacyTenantID int64, res *Result) error { + legacy, target, users, err := e.legacyTargetUsers(ctx, t) + if err != nil { + return err + } + out, err := migrationsharelink.NewMigrator(target, shareSource{legacy: legacy, tenantID: legacyTenantID}, users, t.Slug).MigrateAll(ctx) + if err != nil { + return err + } + res.add("freigabelinks", out.LinksMigrated) + res.add("freigabelinks_abgelaufen_verworfen", out.LinksExpired) + return nil +} + +func (e *Env) legacyTargetUsers(ctx context.Context, t Tenant) (legacy, target *pgxpool.Pool, users registryUserResolver, err error) { + if legacy, err = e.Pool(ctx, e.Ctx.ArchivdmsDB); err != nil { + return + } + if target, err = e.Pool(ctx, t.TenantDB); err != nil { + return + } + users, err = e.userResolver(ctx, t) + return +} + +const val02SampleSize = 5 + +// val02 entschluesselt eine Stichprobe migrierter Revisionen ueber denselben +// crypto.Service wie der Lesepfad; den eigentlichen Hash-Vergleich macht +// internal/migrationverify (VAL-02) im Root-Modul auf diesen Klartexten. +func (e *Env) val02(ctx context.Context, t Tenant, _ int64, res *Result) error { + target, err := e.Pool(ctx, t.TenantDB) + if err != nil { + return err + } + storageSvc, cryptoSvc, err := e.dmsServices(t) + if err != nil { + return err + } + var handoff []DocumentHandoff + if err := readJSON(filepath.Join(e.HandoffDir(t.Slug), "dms_objekte.json"), &handoff); err != nil { + return fmt.Errorf("uebergabe aus dok01 lesen: %w", err) + } + sourceKeyByRef := map[string]string{} + for _, h := range handoff { + sourceKeyByRef[h.ObjectReference] = h.SourceObjectKey + } + + rows, err := target.Query(ctx, ` + SELECT fr.id::text, fr.document_id::text, fr.storage_key, fr.wrapped_dek + FROM file_revisions fr ORDER BY fr.created_at, fr.id LIMIT $1 + `, val02SampleSize) + if err != nil { + return err + } + type rev struct { + id, docID, key string + dek []byte + } + var revs []rev + for rows.Next() { + var r rev + if err := rows.Scan(&r.id, &r.docID, &r.key, &r.dek); err != nil { + rows.Close() + return err + } + revs = append(revs, r) + } + rows.Close() + + outDir := filepath.Join(e.Ctx.RunDir, "val02", t.Slug) + if err := os.MkdirAll(outDir, 0o700); err != nil { + return err + } + var samples []Sample + for _, r := range revs { + src, ok := sourceKeyByRef[r.docID] + if !ok { + return fmt.Errorf("revision %s: kein quellobjekt in der dok01-uebergabe", r.id) + } + if err := decryptTo(ctx, storageSvc, cryptoSvc, t.Slug, r.key, r.dek, filepath.Join(outDir, r.id)); err != nil { + return fmt.Errorf("revision %s entschluesseln: %w", r.id, err) + } + samples = append(samples, Sample{Label: t.Slug + "/" + r.id, SourceObjectKey: src, StorageKey: filepath.ToSlash(filepath.Join(t.Slug, r.id))}) + } + res.add("stichprobe", len(samples)) + return writeJSON(filepath.Join(outDir, "stichprobe.json"), samples) +} + +func decryptTo(ctx context.Context, s *storage.Service, c *dmscrypto.Service, slug, key string, dek []byte, dst string) error { + rc, err := s.Get(ctx, key) + if err != nil { + return err + } + defer func() { _ = rc.Close() }() + plain, err := c.Open(ctx, slug, dek, rc) + if err != nil { + return err + } + f, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600) + if err != nil { + return err + } + if _, err := io.Copy(f, plain); err != nil { + _ = f.Close() + return err + } + return f.Close() +} diff --git a/dms/internal/migrationdocument/migrate.go b/dms/internal/migrationdocument/migrate.go index 32b45d27..793bd237 100644 --- a/dms/internal/migrationdocument/migrate.go +++ b/dms/internal/migrationdocument/migrate.go @@ -85,6 +85,13 @@ type Result struct { RevisionsSkipped int } +// DocumentID ist die Ziel-ID, unter der MigrateAll ein Quelldokument ablegt. +// Nachgelagerte Schritte in anderen Modulen (TAR-01/TAR-02 in archive) +// brauchen sie als ObjectReference, koennen deriveUUID aber nicht importieren. +func DocumentID(tenantSlug, sourceDocumentID string) string { + return deriveUUID("document", tenantSlug, sourceDocumentID) +} + // MigrateAll uebertraegt alle Dokumente samt Revisionen (Akzeptanzkriterium // 1). Dokument- und Revisions-IDs im Ziel werden deterministisch aus den // Quell-IDs abgeleitet (deriveUUID) statt per gen_random_uuid() vergeben — diff --git a/docs/CUT-01-PRUEFPROTOKOLL.md b/docs/CUT-01-PRUEFPROTOKOLL.md new file mode 100644 index 00000000..b7ddeb29 --- /dev/null +++ b/docs/CUT-01-PRUEFPROTOKOLL.md @@ -0,0 +1,342 @@ +# CUT-01 – Prüfprotokoll: Trockenlauf 1 – Entwicklung + +Branch `feature/cut-01-trockenlauf-1-entwicklung`, Testhost `root@192.168.1.204`, +Wegwerf-Cluster `coredev` (PostgreSQL 17, Port 5433, Zugang über +`TEST_ADMIN_DSN` aus `/etc/nexarch-dev/core-dev.env`, nie ausgegeben). +Keine Verbindung zu archivmail/archivdms oder deren Daten, kein Zugriff auf +den Cluster `main` (5432) und die dort laufende archivdms-Instanz. + +## Umfang + +Zentrales Orchestrierungswerkzeug für den ersten Trockenlauf aller +Migrationsskripte. Es erzeugt eine vollständig synthetische Testkopie beider +Altsysteme, führt die **bestehenden** Migratoren in technisch korrekter +Reihenfolge aus und protokolliert jeden Schritt (JSON + Text). Migrationslogik +wird nicht dupliziert; das Werkzeug liefert Reihenfolge, Quell-Adapter gegen +die Testkopie, Testkopie und Protokoll. + +| Pfad | Inhalt | +|---|---| +| `cmd/cutover-dryrun` | Einstiegspunkt (Flags: `-repo`, `-arbeitsverzeichnis`, `-seed`, `-bcrypt-kosten`, `-bezeichnung`, `-behalten`) | +| `internal/cutoverorchestrator` | Ablaufplan + Abhängigkeitsprüfung (`plan.go`), Runner mit Abbruch/Protokoll (`runner.go`, `protocol.go`), Redigierung (`redact.go`), Generator der Testkopie (`dataset.go`, `legacyschema.go`, `seed.go`), Anonymisierungsnachweis (`anonymization.go`), Export-Sichten (`export.go`), Schritte (`steps_*.go`), Modulstufen-Aufruf (`stages.go`), Cluster-Hilfen (`cluster.go`) | +| `dms/cmd/cutover-stage`, `dms/internal/cutoverstage` | DMS-Stufe: Schema, DOK-01, DOK-02, DOK-04, DOK-05, VAL-02-Entschlüsselung | +| `mail/cmd/cutover-stage`, `mail/internal/cutoverstage` | Mail-Stufe: MAI-02 | +| `archive/cmd/cutover-stage`, `archive/internal/cutoverstage` | Archive-Stufe: Schema, TAR-01, MAI-03, MAI-04, TAR-02 | +| `workflow/cmd/cutover-stage`, `workflow/internal/cutoverstage` | Workflow-Stufe: Schema, WFL-01 | +| `docs/cut-01/iteration-1`, `docs/cut-01/iteration-2` | Original-Protokolle aller Läufe (Text, JSON, VAL-01-/VAL-02-Berichte) | + +**Warum Modulstufen als eigene Prozesse:** `dms/`, `mail/`, `archive/` und +`workflow/` sind eigene Go-Module; ihre `internal`-Pakete (die Migratoren) sind +aus dem Root-Modul nicht importierbar. Der Orchestrator baut je Modul ein +Stufen-Binary (`go build`), startet es mit minimaler Umgebung (Zugangsdaten nur +per Umgebungsvariable, nie als Argument) und liest das Ergebnis als JSON. +Gemeinsamer Laufkontext: `kontext.json` (DB-Namen, Tenant-Zuordnung, keine +Zugangsdaten). Übergaben zwischen Modulen (z. B. DOK-01 → TAR-01: Ziel-IDs der +Dokumente) laufen über Dateien unter `uebergabe//`. + +**Datenbank-Topologie im Lauf** (alle Namen mit Laufkennung `cut01xxxxxx`, +dadurch keine Kollision mit fremden Test-DBs, Aufräumen nur über diese Kennung): + +| Datenbank | Inhalt | +|---|---| +| `_quelle_archivmail`, `_quelle_archivdms` | synthetische Testkopie der Altsysteme | +| `_registry` | Core-Registry (alle `migrations/*.up.sql`) inkl. Maps, `object_acl`, `audit_events` | +| `tenant_` (von COR-01 provisioniert) | Core-Tenant-Schema + DMS-Schema (gleiche physische DB, siehe DMS FDN-02) | +| `_archive` | Archive-Schema je Mandant (Modell C) | +| `_workflow` | Workflow-Schema je Mandant (eigene DB: `module_registrations` existiert in Archive **und** Workflow) | + +## Ablaufplan und Reihenfolge + +Der Plan wird vor dem ersten Datenbankzugriff von `ValidatePlan` geprüft: jede +Abhängigkeit muss im Plan vorher stehen, sonst läuft kein einziger Schritt. + +| # | Schritt | Paket / Funktion | hängt ab von | Begründung | +|---|---|---|---|---| +| 1 | PREP-01 | Arbeitsverzeichnis, Modulstufen bauen | – | | +| 2 | PREP-02 | Testkopie erzeugen | PREP-01 | | +| 3 | PREP-03 | Anonymisierungsnachweis | PREP-02 | vor jeder Verarbeitung | +| 4 | PREP-04 | Core-Registry (Core-Migrationen) | PREP-01 | Maps/Registry-Tabellen | +| 5 | EXP-01 | Export-Sichten (Iteration-1-Korrektur I-1) | PREP-03 | Exportform für MAP-01/MAP-03 | +| 6 | MAP-01 | `tenantmatch.ExportTenants/Match` | EXP-01 | Tenant-Identität vor allem anderen | +| 7 | MAP-02 | `tenantapproval.InsertCandidates/Approve/Correct` | MAP-01, PREP-04 | Freigabe Pflicht vor COR-01 | +| 8 | MAP-03 | `usermatch.ExportUsers/Match` | MAP-02 | Abgleich nur in freigegebenen Tenants | +| 9 | MAP-04* | Trockenlauf-Ersatz: `user_migration_map`/`role_migration_map` | MAP-03 | MAP-04 ist nicht gebaut (siehe Befunde) | +| 10 | COR-01 | `migrationtenant` + `tenant.Provisioner` | MAP-02 | legt `tenant_` an | +| 11 | SCHEMA-01 | `migrate.Orchestrator.RolloutAll` (TEN-07) | COR-01 | `users`-Tabelle vor DMS-Schema | +| 12 | SCHEMA-02 | DMS `migrate.Up`, Archive-SQL, Workflow `migrate.Up` | SCHEMA-01 | DMS braucht `users` | +| 13 | COR-02 | `migrationuser` (inkl. LDAP-Felder COR-04) | MAP-04*, SCHEMA-01 | | +| 14 | COR-04 | Prüfung `user.LDAPInfo` gegen Map | COR-02 | COR-04 hat keinen eigenen Migrator, steckt in COR-02 | +| 15 | COR-03 | `migrationrole` | COR-02 | `role_assignments` → FK `users` | +| 16 | COR-05 | Staging aus `audit_log` + `migrationaudit` | MAP-04*, COR-01 | braucht beide Maps | +| 17 | DOK-01 | `migrationdocument` | COR-02, SCHEMA-02 | `created_by` → migrierte Benutzer | +| 18 | DOK-02 | `migrationfolder` | DOK-01 | verknüpft migrierte Dokumente | +| 19 | DOK-04 | `migrationmetadata` | DOK-01 | Feldwerte an Dokumenten | +| 20 | DOK-03 | `migrationacl` + Core-Gruppen (`rbac.GroupStore`) | DOK-01, COR-02 | Objektrechte auf migrierte Dokumente/Gruppen (korrigiert durch Verifikation: tatsächliche Abhängigkeit ist COR-02/`user_migration_map`, nicht COR-03; Ausführungsreihenfolge war davon unberührt, da COR-02 ohnehin vor COR-03 läuft) | +| 21 | DOK-05 | `migrationsharelink` | DOK-01 | FK `share_links.document_id` | +| 22 | MAI-02 | `messagemigration` | COR-01 | Ziel-Slug | +| 23 | TAR-01 | `retentionmigration` | DOK-01, MAI-02, SCHEMA-02 | ObjectReference kommt aus DOK-01/MAI-02 | +| 24 | MAI-03 | `dsgvorequestmigration` | TAR-01 | sucht Objekte der betroffenen Person | +| 25 | MAI-04 | `reconcilereportmigration` | SCHEMA-02 | | +| 26 | TAR-02 | `deletionrequestmigration` | TAR-01, COR-02 | gleiche Retention-Objekte, Antragsteller → NEXARCH-ID | +| 27 | WFL-01 | `migrationworkflow` | COR-02, SCHEMA-02 | Akteursauflösung | +| 28 | VAL-01 | `migrationverify.Run` | COR-02, DOK-01, MAI-02 | | +| 29 | VAL-02 | `migrationverify.CompareSamples` | DOK-01, MAI-02 | | +| 30 | VAL-05 | `auth.LoginService.Login` | COR-02 | | +| 31 | CHK-01 | modulübergreifende Kettenprüfung | DOK-03, DOK-05, TAR-02, MAI-03, MAI-04, WFL-01 | | + +Abbruchverhalten: beim ersten Fehler wird abgebrochen, alle Folgeschritte +werden mit „nicht ausgeführt: Abbruch nach Fehler in X“ protokolliert (die +Einzelpakete arbeiten selbst fail-closed, ein Weiterlaufen würde nur +Folgefehler protokollieren). Panics werden als Fehler des Schritts erfasst. + +## Synthetische Testkopie + +Erzeugt ausschließlich von `GenerateDataset` (deterministisch über `-seed`, +Standard 20261014). Strukturell nach `datenmodell-altsysteme.md`: archivmail +mit `tenants`/`tenant_domains`, `users.username`/`role`/`source`, `emails` + +`storage_objects` + `attachments` + `email_attachments` + `email_refs`, +`dsgvo_requests`, `reconciliation_reports`, append-only `audit_log`; +archivdms mit `users` inkl. `auth_source`/`ldap_uid`/`ldap_synced_at`, +`permission_groups`/`_members`, `document_visibility`, `document_grants` +(inkl. `deny`), `akten`, WORM-`documents` ohne Revisionstabelle, +`custom_field_defs`, `classification_templates`, `document_shares`, +`document_delete_requests`, `workflows`/`workflow_runs`, `audit_log`. + +Abgebildete Konstellationen: Mandant in beiden Systemen per Slug-Treffer +(„Kanzlei Musterhausen“), Mandant nur über die manuelle Kundenzuordnungsliste +zuordenbar („Steuerbüro Beispielstadt“, abweichende Slugs, zwei Domains), +je ein Mandant nur in archivmail („Praxis Fantasia“) bzw. nur in archivdms +(„Ingenieurbüro Erfundenhausen“); Doppelkonten mit unterschiedlicher +Groß-/Kleinschreibung; LDAP-Konten beider Feldtiefen; alle dokumentierten +archivmail-Rollen außer `superadmin`; Fristen in Zukunft/Vergangenheit/keine; +aktive, abgelaufene, widerrufene Freigabelinks und Link auf gelöschtes +Dokument; offene/vollzogene/abgebrochene Löschanfragen; laufende, +abgeschlossene, abgebrochene Workflows; DSGVO-Anfragen `open`/`partial`/ +`completed`; eine Mail mit Sichtbarkeit in zwei Mandanten. + +Mengen je Lauf: 6 Altsystem-Mandanten, 30 Konten, 24 Dokumente, 18 Mails, +9 Anhänge, 48 Binärobjekte, 431 Tabellenzeilen. + +## Akzeptanzkriterium 3: Anonymisierung nachweislich wirksam + +**Argumentation:** Die Testkopie ist keine anonymisierte Kopie echter Daten, +sondern von Grund auf erzeugt. Das Werkzeug hat keinen Eingang für Fremddaten: +`GenerateDataset(seed, now, bcryptCost)` hat keinen Parameter für Dateien, +DSNs oder Exporte, die einzigen Datenquellen sind Konstanten und ein +Pseudozufallsgenerator in `internal/cutoverorchestrator/dataset.go`. Die +Quell-Datenbanken werden in jedem Lauf neu angelegt (`CREATE DATABASE`) und +ausschließlich daraus befüllt. Es existiert kein Codepfad, der eine Verbindung +zu archivmail/archivdms aufbaut. Echte Personendaten können daher im Testlauf +nicht vorkommen. + +**Technischer Nachweis je Lauf (Schritt PREP-03, `VerifyAnonymization`)**, +unabhängig vom Generator: jede Zeile jeder Tabelle beider Quell-DBs wird als +JSON gelesen und durchsucht. + +| Prüfung | Zielwert | Ergebnis (Läufe 2.1 und 2.2) | +|---|---|---| +| E-Mail-Adressen nur unter `.example` (RFC 2606 reserviert) | 0 Verstöße | 93 Adressen, 0 Verstöße | +| IP-Adressen nur aus Dokumentationsnetzen (RFC 5737) | 0 Verstöße | 24 Adressen, 0 Verstöße | +| Konten mit synthetischem Präfix „testperson“ | alle | 30/30 | +| Binärobjekte beginnen mit Marker `SYNTHETISCH` | alle | 48/48 | +| Tabellen/Zeilen geprüft | alle | 30 Tabellen, 431 Zeilen | + +Unit-Tests: `TestGenerateDataset_ContainsOnlySyntheticPersonalData` (alle +erzeugten Personenangaben synthetisch) und +`TestAnonymizationCheck_DetectsRealLookingData` (die Prüfung erkennt eine +reale Domain und eine öffentliche IP – der Nachweis ist also wirksam, nicht +nur formal). Passwörter: zufällig erzeugt, nur als bcrypt-Hash (Kosten 12) in +der Testkopie, Klartext nur im Speicher des Laufs für VAL-05. + +## Iterationsprotokoll + +### Iteration 1 – Fehlerfindung (Läufe 1.1 bis 1.5) + +| Lauf | Kennung | Abbruch bei | Fehler | Korrektur vor dem nächsten Lauf | +|---|---|---|---|---| +| 1.1 | cut01479c9d | MAP-01 | `tenants-export (archivmail): column "domain" does not exist` | **I-1** siehe unten | +| 1.2 | cut01a6173e | MAP-02 | Vollständigkeit: `archivmail/praxis-fantasia`, `archivdms/ingenieurbuero-erfundenhausen` ohne Eintrag in der freigegebenen `tenant_migration_map` | **I-2** | +| 1.3 | cut01c33bb7 | COR-03 | `keine core-rolle fuer archivmail-rolle "auditor" hinterlegt` | **I-3** | +| 1.4 | cut0171964d | MAI-03 | offene DSGVO-Anfrage: 2 migrierte Objekte der betroffenen Person, aber keine Einzelentscheidung | **I-4** | +| 1.5 | cut01748e71 | – | alle 31 Schritte erfolgreich | – | + +**I-1 – Export-Schemaannahme MAP-01/MAP-03 (Integrationsfehler).** +`tenantmatch.ExportTenants` liest `tenants(id, slug, name, domain)`, +`usermatch.ExportUsers` liest `users(id, tenant_id, email, name, +password_hash)`. archivmail führt Domains in `tenant_domains` (1:n), beide +Altsysteme kennen nur `users.username`, archivmail-Superadmins haben +`tenant_id NULL`. Das MAP-01-Prüfprotokoll hatte genau diese Annahme als offen +markiert. Bei der Ursachenanalyse zu Lauf 1.1 wurde die gleiche Annahme in +MAP-03 per Spaltenabfrage der Testkopie bestätigt und mitbehoben. +Korrektur (ohne Änderung der MAP-Pakete): neuer Schritt **EXP-01** legt in +jeder Quelle das Schema `cutover_export` mit Sichten `tenants` (archivmail: +erste registrierte Domain je Mandant) und `users` (`username AS name`, +archivmail nur Konten mit Mandant) an; MAP-01/MAP-03 lesen über Pools mit +`search_path=cutover_export,public` (`export.go`, `Cluster.ConnectSchema`). + +**I-2 – Einseitige Mandanten gingen verloren (Kettenfehler in MAP-02/MAP-03).** +`tenantapproval.InsertCandidates` verwarf Kandidaten, die nur in einem +Altsystem existieren; `usermatch.Match` übersprang solche Mandanten. Da die +`tenant_migration_map` die einzige Quelle für COR-01 ist, wären diese +Mandanten samt Benutzern, Dokumenten und Mails nie migriert worden – ohne +Fehlermeldung. MAP-01 gibt einseitige Kandidaten ausdrücklich aus, „damit sie +in der Freigabeprüfung sichtbar bleiben“. +Korrektur: `InsertCandidates` legt einseitige Kandidaten als `pending` an +(nur Kandidaten ganz ohne Quelle werden verworfen), Freigabepflicht +unverändert; `Match` liefert für einseitig freigegebene Mandanten die Benutzer +der vorhandenen Seite als einseitige Kandidaten. Test +`TestInsertCandidates_SkipsOneSidedCandidates` ersetzt durch +`TestInsertCandidates_KeepsOneSidedCandidatesPending` (prüft zusätzlich: ohne +Freigabe nicht nutzbar), neu `TestMatch_EinseitigFreigegebenerTenantLiefertEinseitigeKandidaten`. + +**I-3 – Dokumentierte archivmail-Rollen ohne Core-Rolle (COR-03).** +`archivmailRoleMap` kannte `domain_admin`, `auditor`, `domain_auditor` nicht +(alle in `datenmodell-altsysteme.md` belegt) → fail-closed-Abbruch. +Korrektur: `domain_admin → tenant_admin`, `auditor`/`domain_auditor → user` +(Core hat keine Prüferrolle; geringste Rolle statt Rechteausweitung), +`superadmin` bleibt bewusst ungemappt. Test `TestCoreRole_DocumentedArchivmailRoles`. + +**I-4 – DSGVO-Anfragen ohne Objektbezug (Kettenfehler TAR-01 → MAI-03).** +MAI-03 (`dsgvorequest.ProcessDeletionRequest` → `dpreport.SubjectReport`) +findet Objekte nur über `retention_objects.data_subject_ref`; TAR-01 +registrierte über `retention.RegisterObject` ohne diese Referenz. Folge: eine +offene Art.-17-Anfrage wurde als „verarbeitet“ gezählt, aber ohne eine +einzige Einzelentscheidung. Erkannt durch die Nachprüfung der Archive-Stufe. +Korrektur: `retentionmigration.SourceObject.DataSubjectRef`; gesetzt → +`retention.RegisterObjectForSubject` (bestehende CMP-02-Funktion), sonst +unverändert `RegisterObject`. Die Mail-Stufe liefert den Absender als +betroffene Person. Test `TestMigrateAll_CarriesDataSubjectRef`. + +Bereits vor Lauf 1.1 (Integrationsanalyse, keine Laufkorrektur): +`migrationdocument.DocumentID` exportiert die DOK-01-Ziel-ID, weil TAR-01/ +TAR-02 im Archive-Modul die ObjectReference brauchen, `deriveUUID` aber nicht +importieren können (gleiches Muster wie `messagemigration.MessageID`). + +### Iteration 2 – Bestätigung (Läufe 2.1 und 2.2) + +Beide Läufe auf Commit `4fdbb9b` (Stand nach allen Iteration-1-Korrekturen), +ohne Codeänderung zwischen den Läufen, ohne `-behalten` (Aufräumen durch das +Werkzeug selbst). + +| Lauf | Kennung | Ergebnis | Dauer | aufgeräumt | +|---|---|---|---|---| +| 2.1 | cut017f8b14 | alle 31 Schritte erfolgreich | 28,8 s | 15 Lauf-DBs entfernt | +| 2.2 | cut01d7d2a2 | alle 31 Schritte erfolgreich | 27,4 s | 15 Lauf-DBs entfernt | + +Danach: 0 Datenbanken mit Laufkennung auf dem Cluster, alle fremden +Test-Datenbanken unverändert. + +## Prüfungen + +| # | Prüfung | Zielwert | Ergebnis | +|---|---|---|---| +| T1 | Vollständiger Lauf gegen anonymisierte Kopie protokolliert (Ticket-Prüfung 1) | Lauf ohne Abbruch, Protokoll vorhanden | **erfüllt** – Läufe 1.5, 2.1, 2.2; Protokolle `docs/cut-01/iteration-*/` | +| T2 | Mind. zwei Iterationen mit dokumentierten Korrekturen (Ticket-Prüfung 2) | ≥ 2 | **erfüllt** – Iteration 1 (5 Läufe, 4 Korrekturen), Iteration 2 (2 Läufe) | +| AK1 | Alle Migrationsskripte laufen mind. einmal vollständig durch | jeder Schritt `erfolgreich` | **erfüllt** – 31/31 in 1.5, 2.1, 2.2 (Ausnahme MAI-01, siehe B-5) | +| AK2 | Gefundene Fehler vor Trockenlauf 2 behoben | alle abbrechenden Fehler behoben | **erfüllt** – I-1 bis I-4 behoben und in Iteration 2 bestätigt; nicht abbrechende Befunde mit Fach-/Zielsystem-Entscheidung siehe unten | +| AK3 | Anonymisierung nachweislich wirksam | 0 Verstöße, Prüfung wirksam | **erfüllt** – siehe Abschnitt AK3 | +| V1 | VAL-01 Zeilenzahl je Tenant/Kerntabelle | 0 offene Befunde | **erfüllt** – 12 Vergleiche; Dokumente 24→21, Differenz 3 erklärt (vollzogene Löschungen, nur Audit) | +| V2 | VAL-02 Hash-Stichprobe Binärdateien | 0 Abweichungen | **erfüllt** – 15 DMS-Dateien (über `crypto.Service` entschlüsselt) + 9 Mail-Anhänge, 0 Abweichungen | +| V3 | VAL-05 Login mit migrierten Zugangsdaten | alle Stichproben-Logins ok, falsches Passwort abgewiesen | **erfüllt** – 12/12 Logins (3 je Tenant), 12/12 Ablehnungen | +| V4 | TAR-01 Fristen exakt übernommen | 0 Abweichungen | **erfüllt** – 40 Objekte geprüft, 27 Fristen identisch, 13 ohne Frist bleiben ohne | +| V5 | COR-05 Audit-Kontinuität | Zeitstempel/Aktion unverändert, nichts unmigriert | **erfüllt** – 24/24 Ereignisse | +| V6 | CHK-01 modulübergreifend | 0 Befunde | **erfüllt** – je Dokument/Nachricht ein Retention-Objekt (21/19), keine Objektrechte auf nicht migrierte Dokumente, Freigabelinks 6, Workflows 9, offene Löschanfragen 3, vollzogene 3, Akten 9, Reconciliation-Berichte 9 = Quelle | +| U1 | Orchestrator-Tests (Reihenfolge, Fehlerprotokoll, Abbruch) | grün | **erfüllt** – `go test ./internal/cutoverorchestrator`: Reihenfolge, Abbruch nach Fehler mit Protokoll/Konsole/Text, Panic, ungültiger Plan führt nichts aus, Kontext-Abbruch, Redigierung, Abhängigkeiten des echten Plans, Generator, Anonymisierungsprüfung, Slug/DSN | +| U2 | Regression aller orchestrierten Pakete, real gegen Postgres (`-p 1`) | grün | **erfüllt** – 21 Pakete in root/dms/archive/workflow/mail grün, plus `dms/internal/cutoverstage` | +| S1 | Keine Zugangsdaten in Code/Protokoll | keine Treffer | **erfüllt** – DSN nur aus Umgebung, Redigierung (Literal + URL-Userinfo + `password=`) in Runner und Stufen-Fehlern, Scan der Protokolle ohne Treffer | + +## Offene Befunde ohne Abbruch (Übergabe an CUT-02 und Modul-Boards) + +Diese Punkte brechen die Kette nicht ab und verfälschen keine Daten in +Richtung Datenverlust oder Rechteausweitung, verlangen aber eine Fach-, +Compliance- oder Zielsystem-Entscheidung („Keine Änderung der +Zielsystem-Fachlogik selbst“, Ticket CUT-01). Sie sind vor dem Stichtag zu +entscheiden. + +- **B-1 Archive: abgelaufene Altsystem-Fristen bleiben `active`.** TAR-01 + übernimmt `retain_until`, migrierte Objekte haben aber keine + Aufbewahrungsklasse; `deletionworkflow.ReleaseExpired` wertet nur + Klassenzuordnungen aus, nicht `retain_until`. Eine Mail mit Frist in der + Vergangenheit wird von MAI-03 mit „Aufbewahrungsfrist ist noch nicht + abgelaufen“ abgelehnt. GoBD-sicher (nichts wird zu früh gelöscht), aber + sachlich falsche Begründung gegenüber der betroffenen Person. + Empfehlung: RET-02/RET-03 berücksichtigen `retain_until` (Archive-Board). +- **B-2 MAI-03: abgeschlossene DSGVO-Anfragen hinterlassen keine Spur.** + `logHistorical` schreibt nur je noch vorhandenem Objekt; nach einer + vollzogenen Löschung gibt es keine Objekte mehr, `dsgvo_decision_log` + verlangt aber ein Objekt. Im Lauf: 3 abgeschlossene Anfragen, 0 Einträge. + Entscheidung nötig, wo der historische Nachweis liegt (z. B. Archive-Audit). +- **B-3 Dokumenttypen nicht migriert.** archivdms-`document_types` und + `documents.doc_type_id` haben kein Migrationspaket; DOK-04 belegt + `document_types` mit Klassifikationsvorlagen. Feld-Vorgaben der Vorlagen + (`classification_template_field_defaults.default_value`) gehen verloren, + da `SourceClassificationTemplate` kein Feld dafür hat. +- **B-4 Akten-Status.** `akten.status` (offen/geschlossen) hat im + Ordnermodell keine Entsprechung und wird nicht übernommen. +- **B-5 MAI-01 nicht im Lauf.** Die Postfach-Migration verlangt je Postfach + ein vom Betreiber neu vergebenes Passwort und einen echten IMAP-Test; im + Trockenlauf gibt es weder IMAP-Testserver noch sinnvoll erfindbare + Zugangsdaten. Gehört in CUT-02 mit Test-IMAP. +- **B-6 Suchindex-Ersatz.** MAI-02 schreibt Metadaten nur in Manticore; die + einzige Manticore-Instanz auf Host 204 teilt sich das Altsystem archivdms + und bleibt tabu. Im Trockenlauf vertritt ein dateibasierter Index + (`ziel-objekte/mail-index/.json`) den `SearchIndexer` – gleiches + Interface wie die Paket-Tests. CUT-02 braucht eine eigene Manticore-Instanz. +- **B-7 KEK-Ersatz.** DOK-01/VAL-02 nutzen statt der Core-KEK-API einen + pro Lauf zufälligen Seed (HMAC je Mandant, nur per Umgebungsvariable an die + DMS-Stufe). CUT-02 gegen die echte KEK-API. +- **B-8 MAP-04 fehlt.** Die menschliche Freigabe der Benutzer-Map ist nicht + gebaut; der Trockenlauf gibt jeden MAP-03-Kandidaten mit dem Prüfer + „trockenlauf-pruefer (synthetische freigabe, CUT-01)“ frei, übernimmt + LDAP-Felder aus der Quelle und füllt `role_migration_map` (archivdms: + erste Berechtigungsgruppe, sonst archivmail-Rolle). Ebenso simuliert MAP-02 + die Freigabe und korrigiert per `Correct` den Ziel-Slug (Bindestrich → + Unterstrich, Laufpräfix): Altsystem-Slugs mit Bindestrich verletzen die + Core-Slug-Regel, ein Prüfer muss sie bei der Freigabe korrigieren. +- **B-9 archivmail mit mehreren Domains.** MAP-01 vergleicht genau eine + Domain; die Export-Sicht liefert die erste registrierte. +- **B-10 Plattformkonten.** archivmail-Superadmins (`tenant_id NULL`) und die + Rolle `superadmin` werden bewusst nicht mandantenbezogen migriert. +- **B-11 Mandanteneigene Gruppennamen.** COR-03 kennt nur die + archivdms-Standardgruppen; eigene Gruppennamen eines Mandanten brechen + fail-closed ab. Die Testkopie nutzt nur Standardgruppen; für reale Daten + braucht COR-03 eine pflegbare Zuordnung. +- **B-12 Soft-gelöschte Dokumente ohne vollzogene Löschung** sind in der + Testkopie nicht enthalten; DOK-01 kennt keinen Löschzustand und würde sie + als aktiv übernehmen. Der Adapter übernimmt nur `deleted_at IS NULL`. + +Beobachtungen an bestehender Test-Infrastruktur (nicht Teil von CUT-01): +Archive-Pakettests teilen sich `TEST_TENANT_DSN` und kollidieren bei +parallelem Paketlauf (mit `-p 1` grün); `internal/migrationtenant`-Test räumt +seine Tenant-DBs nicht ab (`t.Cleanup` nutzt einen per `defer` bereits +geschlossenen Pool) – die durch den eigenen Regressionslauf entstandenen fünf +Datenbanken wurden entfernt. + +## Entscheidungen (QA) + +1. Modulstufen als Prozesse statt `go.work`/`replace`: keine Änderung an + `go.mod` der Module, `internal`-Sichtbarkeit bleibt unangetastet. +2. Export-Sichten statt Änderung von MAP-01/MAP-03: die Normalisierung + gehört zum Export-Dump des Stichtags, die Pakete bleiben schemagenerisch. +3. Adapter-Entscheidungen (in den Stufen dokumentiert): archivdms-WORM-Datei + = Revision 1; `boolean→bool`, `enum→select`, `monetary→number`; + widerrufene Links und Links auf gelöschte Dokumente werden nicht + angeboten; DSGVO `partial`/`failed` = offen; Löschanfrage + `pending`/`confirmed`/`blocked_retention` = offen, `cancelled` entfällt; + Antragsteller/Bestätiger werden auf NEXARCH-IDs abgebildet (Vier-Augen- + Vergleich); `document_visibility` liefert Erlaubnisse, + `document_grants.deny` die Verbote. +4. Datenbankname enthält stets die Laufkennung, Aufräumen nur über sie; das + Werkzeug löscht nie fremde Datenbanken. +5. Abbruch beim ersten Fehler, kein „weiter bei Fehler“-Modus. + +## Bedienung + +```bash +set -a && . /etc/nexarch-dev/core-dev.env && set +a # liefert TEST_ADMIN_DSN, nie ausgeben +go run ./cmd/cutover-dryrun -repo . -arbeitsverzeichnis /root/cut01-dryrun -bezeichnung "Trockenlauf" +``` + +Ergebnis: `protokoll.json`, `protokoll.txt`, VAL-01-/VAL-02-Berichte im +Laufverzeichnis; Exitcode 0 nur bei vollständigem Durchlauf. Mit `-behalten` +bleiben Datenbanken und Arbeitsdateien zur Analyse erhalten. diff --git a/docs/cut-01/iteration-1/lauf-1.1-cut01479c9d.json b/docs/cut-01/iteration-1/lauf-1.1-cut01479c9d.json new file mode 100644 index 00000000..80bcf5d3 --- /dev/null +++ b/docs/cut-01/iteration-1/lauf-1.1-cut01479c9d.json @@ -0,0 +1,419 @@ +{ + "lauf_kennung": "cut01479c9d", + "bezeichnung": "Iteration 1 / Lauf 1.1", + "gestartet": "2026-10-14T20:45:13.821217555Z", + "beendet": "2026-10-14T20:45:28.00646213Z", + "erfolgreich": false, + "abbruch_bei": "MAP-01", + "schritte": [ + { + "id": "PREP-01", + "titel": "Arbeitsverzeichnis anlegen, Modulstufen bauen", + "status": "erfolgreich", + "gestartet": "2026-10-14T20:45:13.821239068Z", + "dauer_ms": 6353, + "zaehler": { + "modulstufen": 4 + }, + "hinweise": [ + "arbeitsverzeichnis /root/cut01-dryrun/cut01479c9d" + ] + }, + { + "id": "PREP-02", + "titel": "Synthetische Testkopie archivmail/archivdms erzeugen", + "haengt_ab_von": [ + "PREP-01" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:45:20.17496792Z", + "dauer_ms": 7370, + "zaehler": { + "archivdms_akten": 9, + "archivdms_audit": 12, + "archivdms_delete_requests": 9, + "archivdms_documents": 24, + "archivdms_shares": 15, + "archivdms_tenants": 3, + "archivdms_users": 15, + "archivdms_workflow_runs": 12, + "archivmail_attachments": 9, + "archivmail_audit": 12, + "archivmail_dsgvo": 9, + "archivmail_emails": 18, + "archivmail_reports": 9, + "archivmail_tenants": 3, + "archivmail_users": 15, + "binaerobjekte": 48 + }, + "hinweise": [ + "generator-seed 20261014, quelle ausschliesslich internal/cutoverorchestrator/dataset.go" + ] + }, + { + "id": "PREP-03", + "titel": "Anonymisierungsnachweis der Testkopie", + "haengt_ab_von": [ + "PREP-02" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:45:27.545213158Z", + "dauer_ms": 23, + "zaehler": { + "binaerobjekte": 48, + "e_mail_adressen": 93, + "ip_adressen": 24, + "personen": 30, + "tabellen": 30, + "verstoesse": 0, + "zeilen": 431 + }, + "hinweise": [ + "alle e-mail-adressen unter der reservierten domain .example (RFC 2606)", + "alle ip-adressen aus dokumentationsnetzen (RFC 5737)", + "alle konten mit synthetischem praefix, alle binaerobjekte mit marker SYNTHETISCH" + ] + }, + { + "id": "PREP-04", + "titel": "Core-Registry anlegen (Core-Migrationen)", + "haengt_ab_von": [ + "PREP-01" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:45:27.56894503Z", + "dauer_ms": 436, + "zaehler": { + "core_migrationen": 42 + } + }, + { + "id": "MAP-01", + "titel": "Tenant-Export und Slug/Domain-Abgleich", + "haengt_ab_von": [ + "PREP-03" + ], + "status": "fehlgeschlagen", + "gestartet": "2026-10-14T20:45:28.00539893Z", + "dauer_ms": 0, + "fehler": "tenants-export (archivmail): ERROR: column \"domain\" does not exist (SQLSTATE 42703)" + }, + { + "id": "MAP-02", + "titel": "Tenant-Migration-Map: Freigabe (Trockenlauf-Pruefer)", + "haengt_ab_von": [ + "MAP-01", + "PREP-04" + ], + "status": "nicht ausgefuehrt", + "dauer_ms": 0, + "hinweise": [ + "nicht ausgefuehrt: Abbruch nach Fehler in MAP-01" + ] + }, + { + "id": "MAP-03", + "titel": "User-Export und E-Mail-Abgleich je freigegebenem Tenant", + "haengt_ab_von": [ + "MAP-02" + ], + "status": "nicht ausgefuehrt", + "dauer_ms": 0, + "hinweise": [ + "nicht ausgefuehrt: Abbruch nach Fehler in MAP-01" + ] + }, + { + "id": "MAP-04*", + "titel": "Benutzer-/Rollen-Map freigeben (Trockenlauf-Ersatz fuer MAP-04)", + "haengt_ab_von": [ + "MAP-03" + ], + "status": "nicht ausgefuehrt", + "dauer_ms": 0, + "hinweise": [ + "nicht ausgefuehrt: Abbruch nach Fehler in MAP-01" + ] + }, + { + "id": "COR-01", + "titel": "Tenant-Migration (Provisionierung der Mandanten-DBs)", + "haengt_ab_von": [ + "MAP-02" + ], + "status": "nicht ausgefuehrt", + "dauer_ms": 0, + "hinweise": [ + "nicht ausgefuehrt: Abbruch nach Fehler in MAP-01" + ] + }, + { + "id": "SCHEMA-01", + "titel": "Core-Tenant-Schema ausrollen (TEN-07)", + "haengt_ab_von": [ + "COR-01" + ], + "status": "nicht ausgefuehrt", + "dauer_ms": 0, + "hinweise": [ + "nicht ausgefuehrt: Abbruch nach Fehler in MAP-01" + ] + }, + { + "id": "SCHEMA-02", + "titel": "Modul-Schemata anlegen (DMS, Archive, Workflow)", + "haengt_ab_von": [ + "SCHEMA-01" + ], + "status": "nicht ausgefuehrt", + "dauer_ms": 0, + "hinweise": [ + "nicht ausgefuehrt: Abbruch nach Fehler in MAP-01" + ] + }, + { + "id": "COR-02", + "titel": "Benutzer-Migration inkl. bcrypt-Hash 1:1", + "haengt_ab_von": [ + "MAP-04*", + "SCHEMA-01" + ], + "status": "nicht ausgefuehrt", + "dauer_ms": 0, + "hinweise": [ + "nicht ausgefuehrt: Abbruch nach Fehler in MAP-01" + ] + }, + { + "id": "COR-04", + "titel": "LDAP-Feld-Migration pruefen", + "haengt_ab_von": [ + "COR-02" + ], + "status": "nicht ausgefuehrt", + "dauer_ms": 0, + "hinweise": [ + "nicht ausgefuehrt: Abbruch nach Fehler in MAP-01" + ] + }, + { + "id": "COR-03", + "titel": "Rollen- und Rechte-Migration", + "haengt_ab_von": [ + "COR-02" + ], + "status": "nicht ausgefuehrt", + "dauer_ms": 0, + "hinweise": [ + "nicht ausgefuehrt: Abbruch nach Fehler in MAP-01" + ] + }, + { + "id": "COR-05", + "titel": "Audit-Log-Zusammenfuehrung", + "haengt_ab_von": [ + "MAP-04*", + "COR-01" + ], + "status": "nicht ausgefuehrt", + "dauer_ms": 0, + "hinweise": [ + "nicht ausgefuehrt: Abbruch nach Fehler in MAP-01" + ] + }, + { + "id": "DOK-01", + "titel": "Dokument- und Revisions-Migration", + "haengt_ab_von": [ + "COR-02", + "SCHEMA-02" + ], + "status": "nicht ausgefuehrt", + "dauer_ms": 0, + "hinweise": [ + "nicht ausgefuehrt: Abbruch nach Fehler in MAP-01" + ] + }, + { + "id": "DOK-02", + "titel": "Akten-/Ordnerstruktur-Migration", + "haengt_ab_von": [ + "DOK-01" + ], + "status": "nicht ausgefuehrt", + "dauer_ms": 0, + "hinweise": [ + "nicht ausgefuehrt: Abbruch nach Fehler in MAP-01" + ] + }, + { + "id": "DOK-04", + "titel": "Custom-Fields- und Klassifikationsvorlagen-Migration", + "haengt_ab_von": [ + "DOK-01" + ], + "status": "nicht ausgefuehrt", + "dauer_ms": 0, + "hinweise": [ + "nicht ausgefuehrt: Abbruch nach Fehler in MAP-01" + ] + }, + { + "id": "DOK-03", + "titel": "ACL-Daten-Migration nach Core-Rechtemodell", + "haengt_ab_von": [ + "DOK-01", + "COR-03" + ], + "status": "nicht ausgefuehrt", + "dauer_ms": 0, + "hinweise": [ + "nicht ausgefuehrt: Abbruch nach Fehler in MAP-01" + ] + }, + { + "id": "DOK-05", + "titel": "Freigabelinks-Migration", + "haengt_ab_von": [ + "DOK-01" + ], + "status": "nicht ausgefuehrt", + "dauer_ms": 0, + "hinweise": [ + "nicht ausgefuehrt: Abbruch nach Fehler in MAP-01" + ] + }, + { + "id": "MAI-02", + "titel": "Nachrichten- und Anhang-Migration", + "haengt_ab_von": [ + "COR-01" + ], + "status": "nicht ausgefuehrt", + "dauer_ms": 0, + "hinweise": [ + "nicht ausgefuehrt: Abbruch nach Fehler in MAP-01" + ] + }, + { + "id": "TAR-01", + "titel": "Retention-Fristen-Migration ohne Reset", + "haengt_ab_von": [ + "DOK-01", + "MAI-02", + "SCHEMA-02" + ], + "status": "nicht ausgefuehrt", + "dauer_ms": 0, + "hinweise": [ + "nicht ausgefuehrt: Abbruch nach Fehler in MAP-01" + ] + }, + { + "id": "MAI-03", + "titel": "DSGVO-Loeschanfragen-Migration", + "haengt_ab_von": [ + "TAR-01" + ], + "status": "nicht ausgefuehrt", + "dauer_ms": 0, + "hinweise": [ + "nicht ausgefuehrt: Abbruch nach Fehler in MAP-01" + ] + }, + { + "id": "MAI-04", + "titel": "Reconciliation-Reports-Archivierung", + "haengt_ab_von": [ + "SCHEMA-02" + ], + "status": "nicht ausgefuehrt", + "dauer_ms": 0, + "hinweise": [ + "nicht ausgefuehrt: Abbruch nach Fehler in MAP-01" + ] + }, + { + "id": "TAR-02", + "titel": "Vier-Augen-Loeschworkflow-Migration", + "haengt_ab_von": [ + "TAR-01", + "COR-02" + ], + "status": "nicht ausgefuehrt", + "dauer_ms": 0, + "hinweise": [ + "nicht ausgefuehrt: Abbruch nach Fehler in MAP-01" + ] + }, + { + "id": "WFL-01", + "titel": "Workflow-Historie-Migration", + "haengt_ab_von": [ + "COR-02", + "SCHEMA-02" + ], + "status": "nicht ausgefuehrt", + "dauer_ms": 0, + "hinweise": [ + "nicht ausgefuehrt: Abbruch nach Fehler in MAP-01" + ] + }, + { + "id": "VAL-01", + "titel": "Zeilenzahl-Abgleich je Kerntabelle", + "haengt_ab_von": [ + "COR-02", + "DOK-01", + "MAI-02" + ], + "status": "nicht ausgefuehrt", + "dauer_ms": 0, + "hinweise": [ + "nicht ausgefuehrt: Abbruch nach Fehler in MAP-01" + ] + }, + { + "id": "VAL-02", + "titel": "Hash-Vergleich migrierter Binaerdateien", + "haengt_ab_von": [ + "DOK-01", + "MAI-02" + ], + "status": "nicht ausgefuehrt", + "dauer_ms": 0, + "hinweise": [ + "nicht ausgefuehrt: Abbruch nach Fehler in MAP-01" + ] + }, + { + "id": "VAL-05", + "titel": "Login-Test mit migrierten Zugangsdaten", + "haengt_ab_von": [ + "COR-02" + ], + "status": "nicht ausgefuehrt", + "dauer_ms": 0, + "hinweise": [ + "nicht ausgefuehrt: Abbruch nach Fehler in MAP-01" + ] + }, + { + "id": "CHK-01", + "titel": "Modulgrenzen-uebergreifende Kettenpruefung", + "haengt_ab_von": [ + "DOK-03", + "DOK-05", + "TAR-02", + "MAI-03", + "MAI-04", + "WFL-01" + ], + "status": "nicht ausgefuehrt", + "dauer_ms": 0, + "hinweise": [ + "nicht ausgefuehrt: Abbruch nach Fehler in MAP-01" + ] + } + ] +} diff --git a/docs/cut-01/iteration-1/lauf-1.1-cut01479c9d.txt b/docs/cut-01/iteration-1/lauf-1.1-cut01479c9d.txt new file mode 100644 index 00000000..697a5772 --- /dev/null +++ b/docs/cut-01/iteration-1/lauf-1.1-cut01479c9d.txt @@ -0,0 +1,70 @@ +CUT-01 Trockenlauf-Protokoll +Lauf: cut01479c9d (Iteration 1 / Lauf 1.1) +Beginn: 2026-10-14 20:45:13 UTC Ende: 2026-10-14 20:45:28 UTC Dauer: 14.185s +Ergebnis: ABGEBROCHEN bei MAP-01 + + 1. [PREP-01] Arbeitsverzeichnis anlegen, Modulstufen bauen – erfolgreich (6353 ms) + Zaehler: [modulstufen=4] + Hinweis: arbeitsverzeichnis /root/cut01-dryrun/cut01479c9d + 2. [PREP-02] Synthetische Testkopie archivmail/archivdms erzeugen – erfolgreich (7370 ms) + Zaehler: [archivdms_akten=9 archivdms_audit=12 archivdms_delete_requests=9 archivdms_documents=24 archivdms_shares=15 archivdms_tenants=3 archivdms_users=15 archivdms_workflow_runs=12 archivmail_attachments=9 archivmail_audit=12 archivmail_dsgvo=9 archivmail_emails=18 archivmail_reports=9 archivmail_tenants=3 archivmail_users=15 binaerobjekte=48] + Hinweis: generator-seed 20261014, quelle ausschliesslich internal/cutoverorchestrator/dataset.go + 3. [PREP-03] Anonymisierungsnachweis der Testkopie – erfolgreich (23 ms) + Zaehler: [binaerobjekte=48 e_mail_adressen=93 ip_adressen=24 personen=30 tabellen=30 verstoesse=0 zeilen=431] + Hinweis: alle e-mail-adressen unter der reservierten domain .example (RFC 2606) + Hinweis: alle ip-adressen aus dokumentationsnetzen (RFC 5737) + Hinweis: alle konten mit synthetischem praefix, alle binaerobjekte mit marker SYNTHETISCH + 4. [PREP-04] Core-Registry anlegen (Core-Migrationen) – erfolgreich (436 ms) + Zaehler: [core_migrationen=42] + 5. [MAP-01] Tenant-Export und Slug/Domain-Abgleich – fehlgeschlagen (0 ms) + FEHLER: tenants-export (archivmail): ERROR: column "domain" does not exist (SQLSTATE 42703) + 6. [MAP-02] Tenant-Migration-Map: Freigabe (Trockenlauf-Pruefer) – nicht ausgefuehrt + Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in MAP-01 + 7. [MAP-03] User-Export und E-Mail-Abgleich je freigegebenem Tenant – nicht ausgefuehrt + Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in MAP-01 + 8. [MAP-04*] Benutzer-/Rollen-Map freigeben (Trockenlauf-Ersatz fuer MAP-04) – nicht ausgefuehrt + Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in MAP-01 + 9. [COR-01] Tenant-Migration (Provisionierung der Mandanten-DBs) – nicht ausgefuehrt + Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in MAP-01 +10. [SCHEMA-01] Core-Tenant-Schema ausrollen (TEN-07) – nicht ausgefuehrt + Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in MAP-01 +11. [SCHEMA-02] Modul-Schemata anlegen (DMS, Archive, Workflow) – nicht ausgefuehrt + Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in MAP-01 +12. [COR-02] Benutzer-Migration inkl. bcrypt-Hash 1:1 – nicht ausgefuehrt + Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in MAP-01 +13. [COR-04] LDAP-Feld-Migration pruefen – nicht ausgefuehrt + Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in MAP-01 +14. [COR-03] Rollen- und Rechte-Migration – nicht ausgefuehrt + Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in MAP-01 +15. [COR-05] Audit-Log-Zusammenfuehrung – nicht ausgefuehrt + Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in MAP-01 +16. [DOK-01] Dokument- und Revisions-Migration – nicht ausgefuehrt + Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in MAP-01 +17. [DOK-02] Akten-/Ordnerstruktur-Migration – nicht ausgefuehrt + Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in MAP-01 +18. [DOK-04] Custom-Fields- und Klassifikationsvorlagen-Migration – nicht ausgefuehrt + Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in MAP-01 +19. [DOK-03] ACL-Daten-Migration nach Core-Rechtemodell – nicht ausgefuehrt + Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in MAP-01 +20. [DOK-05] Freigabelinks-Migration – nicht ausgefuehrt + Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in MAP-01 +21. [MAI-02] Nachrichten- und Anhang-Migration – nicht ausgefuehrt + Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in MAP-01 +22. [TAR-01] Retention-Fristen-Migration ohne Reset – nicht ausgefuehrt + Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in MAP-01 +23. [MAI-03] DSGVO-Loeschanfragen-Migration – nicht ausgefuehrt + Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in MAP-01 +24. [MAI-04] Reconciliation-Reports-Archivierung – nicht ausgefuehrt + Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in MAP-01 +25. [TAR-02] Vier-Augen-Loeschworkflow-Migration – nicht ausgefuehrt + Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in MAP-01 +26. [WFL-01] Workflow-Historie-Migration – nicht ausgefuehrt + Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in MAP-01 +27. [VAL-01] Zeilenzahl-Abgleich je Kerntabelle – nicht ausgefuehrt + Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in MAP-01 +28. [VAL-02] Hash-Vergleich migrierter Binaerdateien – nicht ausgefuehrt + Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in MAP-01 +29. [VAL-05] Login-Test mit migrierten Zugangsdaten – nicht ausgefuehrt + Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in MAP-01 +30. [CHK-01] Modulgrenzen-uebergreifende Kettenpruefung – nicht ausgefuehrt + Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in MAP-01 diff --git a/docs/cut-01/iteration-1/lauf-1.2-cut01a6173e.json b/docs/cut-01/iteration-1/lauf-1.2-cut01a6173e.json new file mode 100644 index 00000000..c4778aaf --- /dev/null +++ b/docs/cut-01/iteration-1/lauf-1.2-cut01a6173e.json @@ -0,0 +1,445 @@ +{ + "lauf_kennung": "cut01a6173e", + "bezeichnung": "Iteration 1 / Lauf 1.2", + "gestartet": "2026-10-14T20:47:20.105409174Z", + "beendet": "2026-10-14T20:47:33.294769948Z", + "erfolgreich": false, + "abbruch_bei": "MAP-02", + "schritte": [ + { + "id": "PREP-01", + "titel": "Arbeitsverzeichnis anlegen, Modulstufen bauen", + "status": "erfolgreich", + "gestartet": "2026-10-14T20:47:20.105419302Z", + "dauer_ms": 5584, + "zaehler": { + "modulstufen": 4 + }, + "hinweise": [ + "arbeitsverzeichnis /root/cut01-dryrun/cut01a6173e" + ] + }, + { + "id": "PREP-02", + "titel": "Synthetische Testkopie archivmail/archivdms erzeugen", + "haengt_ab_von": [ + "PREP-01" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:47:25.69028809Z", + "dauer_ms": 7135, + "zaehler": { + "archivdms_akten": 9, + "archivdms_audit": 12, + "archivdms_delete_requests": 9, + "archivdms_documents": 24, + "archivdms_shares": 15, + "archivdms_tenants": 3, + "archivdms_users": 15, + "archivdms_workflow_runs": 12, + "archivmail_attachments": 9, + "archivmail_audit": 12, + "archivmail_dsgvo": 9, + "archivmail_emails": 18, + "archivmail_reports": 9, + "archivmail_tenants": 3, + "archivmail_users": 15, + "binaerobjekte": 48 + }, + "hinweise": [ + "generator-seed 20261014, quelle ausschliesslich internal/cutoverorchestrator/dataset.go" + ] + }, + { + "id": "PREP-03", + "titel": "Anonymisierungsnachweis der Testkopie", + "haengt_ab_von": [ + "PREP-02" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:47:32.825455409Z", + "dauer_ms": 19, + "zaehler": { + "binaerobjekte": 48, + "e_mail_adressen": 93, + "ip_adressen": 24, + "personen": 30, + "tabellen": 30, + "verstoesse": 0, + "zeilen": 431 + }, + "hinweise": [ + "alle e-mail-adressen unter der reservierten domain .example (RFC 2606)", + "alle ip-adressen aus dokumentationsnetzen (RFC 5737)", + "alle konten mit synthetischem praefix, alle binaerobjekte mit marker SYNTHETISCH" + ] + }, + { + "id": "PREP-04", + "titel": "Core-Registry anlegen (Core-Migrationen)", + "haengt_ab_von": [ + "PREP-01" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:47:32.845252493Z", + "dauer_ms": 387, + "zaehler": { + "core_migrationen": 42 + } + }, + { + "id": "EXP-01", + "titel": "Export-Sichten der Altsysteme bereitstellen", + "haengt_ab_von": [ + "PREP-03" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:47:33.233288547Z", + "dauer_ms": 13, + "zaehler": { + "export_sichten": 4, + "plattformkonten_ohne_mandant": 0 + }, + "hinweise": [ + "archivmail-domain fuer MAP-01: erste registrierte domain je mandant (MAP-01 vergleicht genau eine domain)" + ] + }, + { + "id": "MAP-01", + "titel": "Tenant-Export und Slug/Domain-Abgleich", + "haengt_ab_von": [ + "EXP-01" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:47:33.246423416Z", + "dauer_ms": 13, + "zaehler": { + "archivdms_tenants": 3, + "archivmail_tenants": 3, + "kandidaten": 4, + "konflikte": 0, + "manuelle_zuordnungen": 1 + } + }, + { + "id": "MAP-02", + "titel": "Tenant-Migration-Map: Freigabe (Trockenlauf-Pruefer)", + "haengt_ab_von": [ + "MAP-01", + "PREP-04" + ], + "status": "fehlgeschlagen", + "gestartet": "2026-10-14T20:47:33.260088906Z", + "dauer_ms": 34, + "zaehler": { + "eintraege_angelegt": 2, + "freigegeben": 2 + }, + "fehler": "vollstaendigkeit: 2 altsystem-mandanten ohne eintrag in der freigegebenen tenant_migration_map, sie wuerden nie migriert: archivmail/praxis-fantasia, archivdms/ingenieurbuero-erfundenhausen" + }, + { + "id": "MAP-03", + "titel": "User-Export und E-Mail-Abgleich je freigegebenem Tenant", + "haengt_ab_von": [ + "MAP-02" + ], + "status": "nicht ausgefuehrt", + "dauer_ms": 0, + "hinweise": [ + "nicht ausgefuehrt: Abbruch nach Fehler in MAP-02" + ] + }, + { + "id": "MAP-04*", + "titel": "Benutzer-/Rollen-Map freigeben (Trockenlauf-Ersatz fuer MAP-04)", + "haengt_ab_von": [ + "MAP-03" + ], + "status": "nicht ausgefuehrt", + "dauer_ms": 0, + "hinweise": [ + "nicht ausgefuehrt: Abbruch nach Fehler in MAP-02" + ] + }, + { + "id": "COR-01", + "titel": "Tenant-Migration (Provisionierung der Mandanten-DBs)", + "haengt_ab_von": [ + "MAP-02" + ], + "status": "nicht ausgefuehrt", + "dauer_ms": 0, + "hinweise": [ + "nicht ausgefuehrt: Abbruch nach Fehler in MAP-02" + ] + }, + { + "id": "SCHEMA-01", + "titel": "Core-Tenant-Schema ausrollen (TEN-07)", + "haengt_ab_von": [ + "COR-01" + ], + "status": "nicht ausgefuehrt", + "dauer_ms": 0, + "hinweise": [ + "nicht ausgefuehrt: Abbruch nach Fehler in MAP-02" + ] + }, + { + "id": "SCHEMA-02", + "titel": "Modul-Schemata anlegen (DMS, Archive, Workflow)", + "haengt_ab_von": [ + "SCHEMA-01" + ], + "status": "nicht ausgefuehrt", + "dauer_ms": 0, + "hinweise": [ + "nicht ausgefuehrt: Abbruch nach Fehler in MAP-02" + ] + }, + { + "id": "COR-02", + "titel": "Benutzer-Migration inkl. bcrypt-Hash 1:1", + "haengt_ab_von": [ + "MAP-04*", + "SCHEMA-01" + ], + "status": "nicht ausgefuehrt", + "dauer_ms": 0, + "hinweise": [ + "nicht ausgefuehrt: Abbruch nach Fehler in MAP-02" + ] + }, + { + "id": "COR-04", + "titel": "LDAP-Feld-Migration pruefen", + "haengt_ab_von": [ + "COR-02" + ], + "status": "nicht ausgefuehrt", + "dauer_ms": 0, + "hinweise": [ + "nicht ausgefuehrt: Abbruch nach Fehler in MAP-02" + ] + }, + { + "id": "COR-03", + "titel": "Rollen- und Rechte-Migration", + "haengt_ab_von": [ + "COR-02" + ], + "status": "nicht ausgefuehrt", + "dauer_ms": 0, + "hinweise": [ + "nicht ausgefuehrt: Abbruch nach Fehler in MAP-02" + ] + }, + { + "id": "COR-05", + "titel": "Audit-Log-Zusammenfuehrung", + "haengt_ab_von": [ + "MAP-04*", + "COR-01" + ], + "status": "nicht ausgefuehrt", + "dauer_ms": 0, + "hinweise": [ + "nicht ausgefuehrt: Abbruch nach Fehler in MAP-02" + ] + }, + { + "id": "DOK-01", + "titel": "Dokument- und Revisions-Migration", + "haengt_ab_von": [ + "COR-02", + "SCHEMA-02" + ], + "status": "nicht ausgefuehrt", + "dauer_ms": 0, + "hinweise": [ + "nicht ausgefuehrt: Abbruch nach Fehler in MAP-02" + ] + }, + { + "id": "DOK-02", + "titel": "Akten-/Ordnerstruktur-Migration", + "haengt_ab_von": [ + "DOK-01" + ], + "status": "nicht ausgefuehrt", + "dauer_ms": 0, + "hinweise": [ + "nicht ausgefuehrt: Abbruch nach Fehler in MAP-02" + ] + }, + { + "id": "DOK-04", + "titel": "Custom-Fields- und Klassifikationsvorlagen-Migration", + "haengt_ab_von": [ + "DOK-01" + ], + "status": "nicht ausgefuehrt", + "dauer_ms": 0, + "hinweise": [ + "nicht ausgefuehrt: Abbruch nach Fehler in MAP-02" + ] + }, + { + "id": "DOK-03", + "titel": "ACL-Daten-Migration nach Core-Rechtemodell", + "haengt_ab_von": [ + "DOK-01", + "COR-03" + ], + "status": "nicht ausgefuehrt", + "dauer_ms": 0, + "hinweise": [ + "nicht ausgefuehrt: Abbruch nach Fehler in MAP-02" + ] + }, + { + "id": "DOK-05", + "titel": "Freigabelinks-Migration", + "haengt_ab_von": [ + "DOK-01" + ], + "status": "nicht ausgefuehrt", + "dauer_ms": 0, + "hinweise": [ + "nicht ausgefuehrt: Abbruch nach Fehler in MAP-02" + ] + }, + { + "id": "MAI-02", + "titel": "Nachrichten- und Anhang-Migration", + "haengt_ab_von": [ + "COR-01" + ], + "status": "nicht ausgefuehrt", + "dauer_ms": 0, + "hinweise": [ + "nicht ausgefuehrt: Abbruch nach Fehler in MAP-02" + ] + }, + { + "id": "TAR-01", + "titel": "Retention-Fristen-Migration ohne Reset", + "haengt_ab_von": [ + "DOK-01", + "MAI-02", + "SCHEMA-02" + ], + "status": "nicht ausgefuehrt", + "dauer_ms": 0, + "hinweise": [ + "nicht ausgefuehrt: Abbruch nach Fehler in MAP-02" + ] + }, + { + "id": "MAI-03", + "titel": "DSGVO-Loeschanfragen-Migration", + "haengt_ab_von": [ + "TAR-01" + ], + "status": "nicht ausgefuehrt", + "dauer_ms": 0, + "hinweise": [ + "nicht ausgefuehrt: Abbruch nach Fehler in MAP-02" + ] + }, + { + "id": "MAI-04", + "titel": "Reconciliation-Reports-Archivierung", + "haengt_ab_von": [ + "SCHEMA-02" + ], + "status": "nicht ausgefuehrt", + "dauer_ms": 0, + "hinweise": [ + "nicht ausgefuehrt: Abbruch nach Fehler in MAP-02" + ] + }, + { + "id": "TAR-02", + "titel": "Vier-Augen-Loeschworkflow-Migration", + "haengt_ab_von": [ + "TAR-01", + "COR-02" + ], + "status": "nicht ausgefuehrt", + "dauer_ms": 0, + "hinweise": [ + "nicht ausgefuehrt: Abbruch nach Fehler in MAP-02" + ] + }, + { + "id": "WFL-01", + "titel": "Workflow-Historie-Migration", + "haengt_ab_von": [ + "COR-02", + "SCHEMA-02" + ], + "status": "nicht ausgefuehrt", + "dauer_ms": 0, + "hinweise": [ + "nicht ausgefuehrt: Abbruch nach Fehler in MAP-02" + ] + }, + { + "id": "VAL-01", + "titel": "Zeilenzahl-Abgleich je Kerntabelle", + "haengt_ab_von": [ + "COR-02", + "DOK-01", + "MAI-02" + ], + "status": "nicht ausgefuehrt", + "dauer_ms": 0, + "hinweise": [ + "nicht ausgefuehrt: Abbruch nach Fehler in MAP-02" + ] + }, + { + "id": "VAL-02", + "titel": "Hash-Vergleich migrierter Binaerdateien", + "haengt_ab_von": [ + "DOK-01", + "MAI-02" + ], + "status": "nicht ausgefuehrt", + "dauer_ms": 0, + "hinweise": [ + "nicht ausgefuehrt: Abbruch nach Fehler in MAP-02" + ] + }, + { + "id": "VAL-05", + "titel": "Login-Test mit migrierten Zugangsdaten", + "haengt_ab_von": [ + "COR-02" + ], + "status": "nicht ausgefuehrt", + "dauer_ms": 0, + "hinweise": [ + "nicht ausgefuehrt: Abbruch nach Fehler in MAP-02" + ] + }, + { + "id": "CHK-01", + "titel": "Modulgrenzen-uebergreifende Kettenpruefung", + "haengt_ab_von": [ + "DOK-03", + "DOK-05", + "TAR-02", + "MAI-03", + "MAI-04", + "WFL-01" + ], + "status": "nicht ausgefuehrt", + "dauer_ms": 0, + "hinweise": [ + "nicht ausgefuehrt: Abbruch nach Fehler in MAP-02" + ] + } + ] +} diff --git a/docs/cut-01/iteration-1/lauf-1.2-cut01a6173e.txt b/docs/cut-01/iteration-1/lauf-1.2-cut01a6173e.txt new file mode 100644 index 00000000..35350a1f --- /dev/null +++ b/docs/cut-01/iteration-1/lauf-1.2-cut01a6173e.txt @@ -0,0 +1,74 @@ +CUT-01 Trockenlauf-Protokoll +Lauf: cut01a6173e (Iteration 1 / Lauf 1.2) +Beginn: 2026-10-14 20:47:20 UTC Ende: 2026-10-14 20:47:33 UTC Dauer: 13.189s +Ergebnis: ABGEBROCHEN bei MAP-02 + + 1. [PREP-01] Arbeitsverzeichnis anlegen, Modulstufen bauen – erfolgreich (5584 ms) + Zaehler: [modulstufen=4] + Hinweis: arbeitsverzeichnis /root/cut01-dryrun/cut01a6173e + 2. [PREP-02] Synthetische Testkopie archivmail/archivdms erzeugen – erfolgreich (7135 ms) + Zaehler: [archivdms_akten=9 archivdms_audit=12 archivdms_delete_requests=9 archivdms_documents=24 archivdms_shares=15 archivdms_tenants=3 archivdms_users=15 archivdms_workflow_runs=12 archivmail_attachments=9 archivmail_audit=12 archivmail_dsgvo=9 archivmail_emails=18 archivmail_reports=9 archivmail_tenants=3 archivmail_users=15 binaerobjekte=48] + Hinweis: generator-seed 20261014, quelle ausschliesslich internal/cutoverorchestrator/dataset.go + 3. [PREP-03] Anonymisierungsnachweis der Testkopie – erfolgreich (19 ms) + Zaehler: [binaerobjekte=48 e_mail_adressen=93 ip_adressen=24 personen=30 tabellen=30 verstoesse=0 zeilen=431] + Hinweis: alle e-mail-adressen unter der reservierten domain .example (RFC 2606) + Hinweis: alle ip-adressen aus dokumentationsnetzen (RFC 5737) + Hinweis: alle konten mit synthetischem praefix, alle binaerobjekte mit marker SYNTHETISCH + 4. [PREP-04] Core-Registry anlegen (Core-Migrationen) – erfolgreich (387 ms) + Zaehler: [core_migrationen=42] + 5. [EXP-01] Export-Sichten der Altsysteme bereitstellen – erfolgreich (13 ms) + Zaehler: [export_sichten=4 plattformkonten_ohne_mandant=0] + Hinweis: archivmail-domain fuer MAP-01: erste registrierte domain je mandant (MAP-01 vergleicht genau eine domain) + 6. [MAP-01] Tenant-Export und Slug/Domain-Abgleich – erfolgreich (13 ms) + Zaehler: [archivdms_tenants=3 archivmail_tenants=3 kandidaten=4 konflikte=0 manuelle_zuordnungen=1] + 7. [MAP-02] Tenant-Migration-Map: Freigabe (Trockenlauf-Pruefer) – fehlgeschlagen (34 ms) + Zaehler: [eintraege_angelegt=2 freigegeben=2] + FEHLER: vollstaendigkeit: 2 altsystem-mandanten ohne eintrag in der freigegebenen tenant_migration_map, sie wuerden nie migriert: archivmail/praxis-fantasia, archivdms/ingenieurbuero-erfundenhausen + 8. [MAP-03] User-Export und E-Mail-Abgleich je freigegebenem Tenant – nicht ausgefuehrt + Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in MAP-02 + 9. [MAP-04*] Benutzer-/Rollen-Map freigeben (Trockenlauf-Ersatz fuer MAP-04) – nicht ausgefuehrt + Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in MAP-02 +10. [COR-01] Tenant-Migration (Provisionierung der Mandanten-DBs) – nicht ausgefuehrt + Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in MAP-02 +11. [SCHEMA-01] Core-Tenant-Schema ausrollen (TEN-07) – nicht ausgefuehrt + Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in MAP-02 +12. [SCHEMA-02] Modul-Schemata anlegen (DMS, Archive, Workflow) – nicht ausgefuehrt + Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in MAP-02 +13. [COR-02] Benutzer-Migration inkl. bcrypt-Hash 1:1 – nicht ausgefuehrt + Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in MAP-02 +14. [COR-04] LDAP-Feld-Migration pruefen – nicht ausgefuehrt + Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in MAP-02 +15. [COR-03] Rollen- und Rechte-Migration – nicht ausgefuehrt + Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in MAP-02 +16. [COR-05] Audit-Log-Zusammenfuehrung – nicht ausgefuehrt + Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in MAP-02 +17. [DOK-01] Dokument- und Revisions-Migration – nicht ausgefuehrt + Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in MAP-02 +18. [DOK-02] Akten-/Ordnerstruktur-Migration – nicht ausgefuehrt + Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in MAP-02 +19. [DOK-04] Custom-Fields- und Klassifikationsvorlagen-Migration – nicht ausgefuehrt + Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in MAP-02 +20. [DOK-03] ACL-Daten-Migration nach Core-Rechtemodell – nicht ausgefuehrt + Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in MAP-02 +21. [DOK-05] Freigabelinks-Migration – nicht ausgefuehrt + Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in MAP-02 +22. [MAI-02] Nachrichten- und Anhang-Migration – nicht ausgefuehrt + Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in MAP-02 +23. [TAR-01] Retention-Fristen-Migration ohne Reset – nicht ausgefuehrt + Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in MAP-02 +24. [MAI-03] DSGVO-Loeschanfragen-Migration – nicht ausgefuehrt + Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in MAP-02 +25. [MAI-04] Reconciliation-Reports-Archivierung – nicht ausgefuehrt + Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in MAP-02 +26. [TAR-02] Vier-Augen-Loeschworkflow-Migration – nicht ausgefuehrt + Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in MAP-02 +27. [WFL-01] Workflow-Historie-Migration – nicht ausgefuehrt + Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in MAP-02 +28. [VAL-01] Zeilenzahl-Abgleich je Kerntabelle – nicht ausgefuehrt + Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in MAP-02 +29. [VAL-02] Hash-Vergleich migrierter Binaerdateien – nicht ausgefuehrt + Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in MAP-02 +30. [VAL-05] Login-Test mit migrierten Zugangsdaten – nicht ausgefuehrt + Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in MAP-02 +31. [CHK-01] Modulgrenzen-uebergreifende Kettenpruefung – nicht ausgefuehrt + Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in MAP-02 diff --git a/docs/cut-01/iteration-1/lauf-1.3-cut01c33bb7.json b/docs/cut-01/iteration-1/lauf-1.3-cut01c33bb7.json new file mode 100644 index 00000000..845e56d7 --- /dev/null +++ b/docs/cut-01/iteration-1/lauf-1.3-cut01c33bb7.json @@ -0,0 +1,459 @@ +{ + "lauf_kennung": "cut01c33bb7", + "bezeichnung": "Iteration 1 / Lauf 1.3", + "gestartet": "2026-10-14T20:48:40.027043948Z", + "beendet": "2026-10-14T20:48:57.908776698Z", + "erfolgreich": false, + "abbruch_bei": "COR-03", + "schritte": [ + { + "id": "PREP-01", + "titel": "Arbeitsverzeichnis anlegen, Modulstufen bauen", + "status": "erfolgreich", + "gestartet": "2026-10-14T20:48:40.027055263Z", + "dauer_ms": 5252, + "zaehler": { + "modulstufen": 4 + }, + "hinweise": [ + "arbeitsverzeichnis /root/cut01-dryrun/cut01c33bb7" + ] + }, + { + "id": "PREP-02", + "titel": "Synthetische Testkopie archivmail/archivdms erzeugen", + "haengt_ab_von": [ + "PREP-01" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:48:45.279907908Z", + "dauer_ms": 6804, + "zaehler": { + "archivdms_akten": 9, + "archivdms_audit": 12, + "archivdms_delete_requests": 9, + "archivdms_documents": 24, + "archivdms_shares": 15, + "archivdms_tenants": 3, + "archivdms_users": 15, + "archivdms_workflow_runs": 12, + "archivmail_attachments": 9, + "archivmail_audit": 12, + "archivmail_dsgvo": 9, + "archivmail_emails": 18, + "archivmail_reports": 9, + "archivmail_tenants": 3, + "archivmail_users": 15, + "binaerobjekte": 48 + }, + "hinweise": [ + "generator-seed 20261014, quelle ausschliesslich internal/cutoverorchestrator/dataset.go" + ] + }, + { + "id": "PREP-03", + "titel": "Anonymisierungsnachweis der Testkopie", + "haengt_ab_von": [ + "PREP-02" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:48:52.084254643Z", + "dauer_ms": 21, + "zaehler": { + "binaerobjekte": 48, + "e_mail_adressen": 93, + "ip_adressen": 24, + "personen": 30, + "tabellen": 30, + "verstoesse": 0, + "zeilen": 431 + }, + "hinweise": [ + "alle e-mail-adressen unter der reservierten domain .example (RFC 2606)", + "alle ip-adressen aus dokumentationsnetzen (RFC 5737)", + "alle konten mit synthetischem praefix, alle binaerobjekte mit marker SYNTHETISCH" + ] + }, + { + "id": "PREP-04", + "titel": "Core-Registry anlegen (Core-Migrationen)", + "haengt_ab_von": [ + "PREP-01" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:48:52.10549922Z", + "dauer_ms": 400, + "zaehler": { + "core_migrationen": 42 + } + }, + { + "id": "EXP-01", + "titel": "Export-Sichten der Altsysteme bereitstellen", + "haengt_ab_von": [ + "PREP-03" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:48:52.506305266Z", + "dauer_ms": 12, + "zaehler": { + "export_sichten": 4, + "plattformkonten_ohne_mandant": 0 + }, + "hinweise": [ + "archivmail-domain fuer MAP-01: erste registrierte domain je mandant (MAP-01 vergleicht genau eine domain)" + ] + }, + { + "id": "MAP-01", + "titel": "Tenant-Export und Slug/Domain-Abgleich", + "haengt_ab_von": [ + "EXP-01" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:48:52.518623143Z", + "dauer_ms": 12, + "zaehler": { + "archivdms_tenants": 3, + "archivmail_tenants": 3, + "kandidaten": 4, + "konflikte": 0, + "manuelle_zuordnungen": 1 + } + }, + { + "id": "MAP-02", + "titel": "Tenant-Migration-Map: Freigabe (Trockenlauf-Pruefer)", + "haengt_ab_von": [ + "MAP-01", + "PREP-04" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:48:52.53114518Z", + "dauer_ms": 69, + "zaehler": { + "eintraege_angelegt": 4, + "freigegeben": 4 + } + }, + { + "id": "MAP-03", + "titel": "User-Export und E-Mail-Abgleich je freigegebenem Tenant", + "haengt_ab_von": [ + "MAP-02" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:48:52.60035004Z", + "dauer_ms": 2, + "zaehler": { + "archivdms_users": 15, + "archivmail_users": 15, + "kandidaten": 24, + "konflikte": 0, + "zusammengefuehrt": 6 + } + }, + { + "id": "MAP-04*", + "titel": "Benutzer-/Rollen-Map freigeben (Trockenlauf-Ersatz fuer MAP-04)", + "haengt_ab_von": [ + "MAP-03" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:48:52.602544679Z", + "dauer_ms": 220, + "zaehler": { + "benutzer_freigegeben": 24, + "mit_ldap_bezug": 6, + "rollen_freigegeben": 24 + } + }, + { + "id": "COR-01", + "titel": "Tenant-Migration (Provisionierung der Mandanten-DBs)", + "haengt_ab_von": [ + "MAP-02" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:48:52.823552627Z", + "dauer_ms": 326, + "zaehler": { + "tenants_migriert": 4 + } + }, + { + "id": "SCHEMA-01", + "titel": "Core-Tenant-Schema ausrollen (TEN-07)", + "haengt_ab_von": [ + "COR-01" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:48:53.14978522Z", + "dauer_ms": 1049, + "zaehler": { + "core_tenant_migrationen_angewendet": 84, + "tenants": 4 + } + }, + { + "id": "SCHEMA-02", + "titel": "Modul-Schemata anlegen (DMS, Archive, Workflow)", + "haengt_ab_von": [ + "SCHEMA-01" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:48:54.198832012Z", + "dauer_ms": 3509, + "zaehler": { + "archive_migrationen_angewendet": 72, + "dms_migrationen_angewendet": 148, + "workflow_migrationen_angewendet": 72 + } + }, + { + "id": "COR-02", + "titel": "Benutzer-Migration inkl. bcrypt-Hash 1:1", + "haengt_ab_von": [ + "MAP-04*", + "SCHEMA-01" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:48:57.708862809Z", + "dauer_ms": 149, + "zaehler": { + "benutzer_migriert": 24 + } + }, + { + "id": "COR-04", + "titel": "LDAP-Feld-Migration pruefen", + "haengt_ab_von": [ + "COR-02" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:48:57.858476496Z", + "dauer_ms": 24, + "zaehler": { + "benutzer_mit_ldap_bezug_geprueft": 6 + } + }, + { + "id": "COR-03", + "titel": "Rollen- und Rechte-Migration", + "haengt_ab_von": [ + "COR-02" + ], + "status": "fehlgeschlagen", + "gestartet": "2026-10-14T20:48:57.883311102Z", + "dauer_ms": 25, + "fehler": "role-map-eintrag 4 (benutzer 6826ed87-1d21-4d5c-8af1-40b700d6c64d): migrationrole: keine core-rolle fuer archivmail-rolle \"auditor\" hinterlegt" + }, + { + "id": "COR-05", + "titel": "Audit-Log-Zusammenfuehrung", + "haengt_ab_von": [ + "MAP-04*", + "COR-01" + ], + "status": "nicht ausgefuehrt", + "dauer_ms": 0, + "hinweise": [ + "nicht ausgefuehrt: Abbruch nach Fehler in COR-03" + ] + }, + { + "id": "DOK-01", + "titel": "Dokument- und Revisions-Migration", + "haengt_ab_von": [ + "COR-02", + "SCHEMA-02" + ], + "status": "nicht ausgefuehrt", + "dauer_ms": 0, + "hinweise": [ + "nicht ausgefuehrt: Abbruch nach Fehler in COR-03" + ] + }, + { + "id": "DOK-02", + "titel": "Akten-/Ordnerstruktur-Migration", + "haengt_ab_von": [ + "DOK-01" + ], + "status": "nicht ausgefuehrt", + "dauer_ms": 0, + "hinweise": [ + "nicht ausgefuehrt: Abbruch nach Fehler in COR-03" + ] + }, + { + "id": "DOK-04", + "titel": "Custom-Fields- und Klassifikationsvorlagen-Migration", + "haengt_ab_von": [ + "DOK-01" + ], + "status": "nicht ausgefuehrt", + "dauer_ms": 0, + "hinweise": [ + "nicht ausgefuehrt: Abbruch nach Fehler in COR-03" + ] + }, + { + "id": "DOK-03", + "titel": "ACL-Daten-Migration nach Core-Rechtemodell", + "haengt_ab_von": [ + "DOK-01", + "COR-03" + ], + "status": "nicht ausgefuehrt", + "dauer_ms": 0, + "hinweise": [ + "nicht ausgefuehrt: Abbruch nach Fehler in COR-03" + ] + }, + { + "id": "DOK-05", + "titel": "Freigabelinks-Migration", + "haengt_ab_von": [ + "DOK-01" + ], + "status": "nicht ausgefuehrt", + "dauer_ms": 0, + "hinweise": [ + "nicht ausgefuehrt: Abbruch nach Fehler in COR-03" + ] + }, + { + "id": "MAI-02", + "titel": "Nachrichten- und Anhang-Migration", + "haengt_ab_von": [ + "COR-01" + ], + "status": "nicht ausgefuehrt", + "dauer_ms": 0, + "hinweise": [ + "nicht ausgefuehrt: Abbruch nach Fehler in COR-03" + ] + }, + { + "id": "TAR-01", + "titel": "Retention-Fristen-Migration ohne Reset", + "haengt_ab_von": [ + "DOK-01", + "MAI-02", + "SCHEMA-02" + ], + "status": "nicht ausgefuehrt", + "dauer_ms": 0, + "hinweise": [ + "nicht ausgefuehrt: Abbruch nach Fehler in COR-03" + ] + }, + { + "id": "MAI-03", + "titel": "DSGVO-Loeschanfragen-Migration", + "haengt_ab_von": [ + "TAR-01" + ], + "status": "nicht ausgefuehrt", + "dauer_ms": 0, + "hinweise": [ + "nicht ausgefuehrt: Abbruch nach Fehler in COR-03" + ] + }, + { + "id": "MAI-04", + "titel": "Reconciliation-Reports-Archivierung", + "haengt_ab_von": [ + "SCHEMA-02" + ], + "status": "nicht ausgefuehrt", + "dauer_ms": 0, + "hinweise": [ + "nicht ausgefuehrt: Abbruch nach Fehler in COR-03" + ] + }, + { + "id": "TAR-02", + "titel": "Vier-Augen-Loeschworkflow-Migration", + "haengt_ab_von": [ + "TAR-01", + "COR-02" + ], + "status": "nicht ausgefuehrt", + "dauer_ms": 0, + "hinweise": [ + "nicht ausgefuehrt: Abbruch nach Fehler in COR-03" + ] + }, + { + "id": "WFL-01", + "titel": "Workflow-Historie-Migration", + "haengt_ab_von": [ + "COR-02", + "SCHEMA-02" + ], + "status": "nicht ausgefuehrt", + "dauer_ms": 0, + "hinweise": [ + "nicht ausgefuehrt: Abbruch nach Fehler in COR-03" + ] + }, + { + "id": "VAL-01", + "titel": "Zeilenzahl-Abgleich je Kerntabelle", + "haengt_ab_von": [ + "COR-02", + "DOK-01", + "MAI-02" + ], + "status": "nicht ausgefuehrt", + "dauer_ms": 0, + "hinweise": [ + "nicht ausgefuehrt: Abbruch nach Fehler in COR-03" + ] + }, + { + "id": "VAL-02", + "titel": "Hash-Vergleich migrierter Binaerdateien", + "haengt_ab_von": [ + "DOK-01", + "MAI-02" + ], + "status": "nicht ausgefuehrt", + "dauer_ms": 0, + "hinweise": [ + "nicht ausgefuehrt: Abbruch nach Fehler in COR-03" + ] + }, + { + "id": "VAL-05", + "titel": "Login-Test mit migrierten Zugangsdaten", + "haengt_ab_von": [ + "COR-02" + ], + "status": "nicht ausgefuehrt", + "dauer_ms": 0, + "hinweise": [ + "nicht ausgefuehrt: Abbruch nach Fehler in COR-03" + ] + }, + { + "id": "CHK-01", + "titel": "Modulgrenzen-uebergreifende Kettenpruefung", + "haengt_ab_von": [ + "DOK-03", + "DOK-05", + "TAR-02", + "MAI-03", + "MAI-04", + "WFL-01" + ], + "status": "nicht ausgefuehrt", + "dauer_ms": 0, + "hinweise": [ + "nicht ausgefuehrt: Abbruch nach Fehler in COR-03" + ] + } + ] +} diff --git a/docs/cut-01/iteration-1/lauf-1.3-cut01c33bb7.txt b/docs/cut-01/iteration-1/lauf-1.3-cut01c33bb7.txt new file mode 100644 index 00000000..e550fa58 --- /dev/null +++ b/docs/cut-01/iteration-1/lauf-1.3-cut01c33bb7.txt @@ -0,0 +1,73 @@ +CUT-01 Trockenlauf-Protokoll +Lauf: cut01c33bb7 (Iteration 1 / Lauf 1.3) +Beginn: 2026-10-14 20:48:40 UTC Ende: 2026-10-14 20:48:57 UTC Dauer: 17.882s +Ergebnis: ABGEBROCHEN bei COR-03 + + 1. [PREP-01] Arbeitsverzeichnis anlegen, Modulstufen bauen – erfolgreich (5252 ms) + Zaehler: [modulstufen=4] + Hinweis: arbeitsverzeichnis /root/cut01-dryrun/cut01c33bb7 + 2. [PREP-02] Synthetische Testkopie archivmail/archivdms erzeugen – erfolgreich (6804 ms) + Zaehler: [archivdms_akten=9 archivdms_audit=12 archivdms_delete_requests=9 archivdms_documents=24 archivdms_shares=15 archivdms_tenants=3 archivdms_users=15 archivdms_workflow_runs=12 archivmail_attachments=9 archivmail_audit=12 archivmail_dsgvo=9 archivmail_emails=18 archivmail_reports=9 archivmail_tenants=3 archivmail_users=15 binaerobjekte=48] + Hinweis: generator-seed 20261014, quelle ausschliesslich internal/cutoverorchestrator/dataset.go + 3. [PREP-03] Anonymisierungsnachweis der Testkopie – erfolgreich (21 ms) + Zaehler: [binaerobjekte=48 e_mail_adressen=93 ip_adressen=24 personen=30 tabellen=30 verstoesse=0 zeilen=431] + Hinweis: alle e-mail-adressen unter der reservierten domain .example (RFC 2606) + Hinweis: alle ip-adressen aus dokumentationsnetzen (RFC 5737) + Hinweis: alle konten mit synthetischem praefix, alle binaerobjekte mit marker SYNTHETISCH + 4. [PREP-04] Core-Registry anlegen (Core-Migrationen) – erfolgreich (400 ms) + Zaehler: [core_migrationen=42] + 5. [EXP-01] Export-Sichten der Altsysteme bereitstellen – erfolgreich (12 ms) + Zaehler: [export_sichten=4 plattformkonten_ohne_mandant=0] + Hinweis: archivmail-domain fuer MAP-01: erste registrierte domain je mandant (MAP-01 vergleicht genau eine domain) + 6. [MAP-01] Tenant-Export und Slug/Domain-Abgleich – erfolgreich (12 ms) + Zaehler: [archivdms_tenants=3 archivmail_tenants=3 kandidaten=4 konflikte=0 manuelle_zuordnungen=1] + 7. [MAP-02] Tenant-Migration-Map: Freigabe (Trockenlauf-Pruefer) – erfolgreich (69 ms) + Zaehler: [eintraege_angelegt=4 freigegeben=4] + 8. [MAP-03] User-Export und E-Mail-Abgleich je freigegebenem Tenant – erfolgreich (2 ms) + Zaehler: [archivdms_users=15 archivmail_users=15 kandidaten=24 konflikte=0 zusammengefuehrt=6] + 9. [MAP-04*] Benutzer-/Rollen-Map freigeben (Trockenlauf-Ersatz fuer MAP-04) – erfolgreich (220 ms) + Zaehler: [benutzer_freigegeben=24 mit_ldap_bezug=6 rollen_freigegeben=24] +10. [COR-01] Tenant-Migration (Provisionierung der Mandanten-DBs) – erfolgreich (326 ms) + Zaehler: [tenants_migriert=4] +11. [SCHEMA-01] Core-Tenant-Schema ausrollen (TEN-07) – erfolgreich (1049 ms) + Zaehler: [core_tenant_migrationen_angewendet=84 tenants=4] +12. [SCHEMA-02] Modul-Schemata anlegen (DMS, Archive, Workflow) – erfolgreich (3509 ms) + Zaehler: [archive_migrationen_angewendet=72 dms_migrationen_angewendet=148 workflow_migrationen_angewendet=72] +13. [COR-02] Benutzer-Migration inkl. bcrypt-Hash 1:1 – erfolgreich (149 ms) + Zaehler: [benutzer_migriert=24] +14. [COR-04] LDAP-Feld-Migration pruefen – erfolgreich (24 ms) + Zaehler: [benutzer_mit_ldap_bezug_geprueft=6] +15. [COR-03] Rollen- und Rechte-Migration – fehlgeschlagen (25 ms) + FEHLER: role-map-eintrag 4 (benutzer 6826ed87-1d21-4d5c-8af1-40b700d6c64d): migrationrole: keine core-rolle fuer archivmail-rolle "auditor" hinterlegt +16. [COR-05] Audit-Log-Zusammenfuehrung – nicht ausgefuehrt + Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in COR-03 +17. [DOK-01] Dokument- und Revisions-Migration – nicht ausgefuehrt + Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in COR-03 +18. [DOK-02] Akten-/Ordnerstruktur-Migration – nicht ausgefuehrt + Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in COR-03 +19. [DOK-04] Custom-Fields- und Klassifikationsvorlagen-Migration – nicht ausgefuehrt + Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in COR-03 +20. [DOK-03] ACL-Daten-Migration nach Core-Rechtemodell – nicht ausgefuehrt + Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in COR-03 +21. [DOK-05] Freigabelinks-Migration – nicht ausgefuehrt + Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in COR-03 +22. [MAI-02] Nachrichten- und Anhang-Migration – nicht ausgefuehrt + Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in COR-03 +23. [TAR-01] Retention-Fristen-Migration ohne Reset – nicht ausgefuehrt + Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in COR-03 +24. [MAI-03] DSGVO-Loeschanfragen-Migration – nicht ausgefuehrt + Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in COR-03 +25. [MAI-04] Reconciliation-Reports-Archivierung – nicht ausgefuehrt + Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in COR-03 +26. [TAR-02] Vier-Augen-Loeschworkflow-Migration – nicht ausgefuehrt + Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in COR-03 +27. [WFL-01] Workflow-Historie-Migration – nicht ausgefuehrt + Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in COR-03 +28. [VAL-01] Zeilenzahl-Abgleich je Kerntabelle – nicht ausgefuehrt + Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in COR-03 +29. [VAL-02] Hash-Vergleich migrierter Binaerdateien – nicht ausgefuehrt + Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in COR-03 +30. [VAL-05] Login-Test mit migrierten Zugangsdaten – nicht ausgefuehrt + Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in COR-03 +31. [CHK-01] Modulgrenzen-uebergreifende Kettenpruefung – nicht ausgefuehrt + Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in COR-03 diff --git a/docs/cut-01/iteration-1/lauf-1.4-cut0171964d.json b/docs/cut-01/iteration-1/lauf-1.4-cut0171964d.json new file mode 100644 index 00000000..652fbfe0 --- /dev/null +++ b/docs/cut-01/iteration-1/lauf-1.4-cut0171964d.json @@ -0,0 +1,508 @@ +{ + "lauf_kennung": "cut0171964d", + "bezeichnung": "Iteration 1 / Lauf 1.4", + "gestartet": "2026-10-14T20:49:38.491352447Z", + "beendet": "2026-10-14T20:49:58.84425628Z", + "erfolgreich": false, + "abbruch_bei": "MAI-03", + "schritte": [ + { + "id": "PREP-01", + "titel": "Arbeitsverzeichnis anlegen, Modulstufen bauen", + "status": "erfolgreich", + "gestartet": "2026-10-14T20:49:38.49135999Z", + "dauer_ms": 5139, + "zaehler": { + "modulstufen": 4 + }, + "hinweise": [ + "arbeitsverzeichnis /root/cut01-dryrun/cut0171964d" + ] + }, + { + "id": "PREP-02", + "titel": "Synthetische Testkopie archivmail/archivdms erzeugen", + "haengt_ab_von": [ + "PREP-01" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:49:43.630921656Z", + "dauer_ms": 6907, + "zaehler": { + "archivdms_akten": 9, + "archivdms_audit": 12, + "archivdms_delete_requests": 9, + "archivdms_documents": 24, + "archivdms_shares": 15, + "archivdms_tenants": 3, + "archivdms_users": 15, + "archivdms_workflow_runs": 12, + "archivmail_attachments": 9, + "archivmail_audit": 12, + "archivmail_dsgvo": 9, + "archivmail_emails": 18, + "archivmail_reports": 9, + "archivmail_tenants": 3, + "archivmail_users": 15, + "binaerobjekte": 48 + }, + "hinweise": [ + "generator-seed 20261014, quelle ausschliesslich internal/cutoverorchestrator/dataset.go" + ] + }, + { + "id": "PREP-03", + "titel": "Anonymisierungsnachweis der Testkopie", + "haengt_ab_von": [ + "PREP-02" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:49:50.538504233Z", + "dauer_ms": 21, + "zaehler": { + "binaerobjekte": 48, + "e_mail_adressen": 93, + "ip_adressen": 24, + "personen": 30, + "tabellen": 30, + "verstoesse": 0, + "zeilen": 431 + }, + "hinweise": [ + "alle e-mail-adressen unter der reservierten domain .example (RFC 2606)", + "alle ip-adressen aus dokumentationsnetzen (RFC 5737)", + "alle konten mit synthetischem praefix, alle binaerobjekte mit marker SYNTHETISCH" + ] + }, + { + "id": "PREP-04", + "titel": "Core-Registry anlegen (Core-Migrationen)", + "haengt_ab_von": [ + "PREP-01" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:49:50.560460544Z", + "dauer_ms": 381, + "zaehler": { + "core_migrationen": 42 + } + }, + { + "id": "EXP-01", + "titel": "Export-Sichten der Altsysteme bereitstellen", + "haengt_ab_von": [ + "PREP-03" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:49:50.941720324Z", + "dauer_ms": 12, + "zaehler": { + "export_sichten": 4, + "plattformkonten_ohne_mandant": 0 + }, + "hinweise": [ + "archivmail-domain fuer MAP-01: erste registrierte domain je mandant (MAP-01 vergleicht genau eine domain)" + ] + }, + { + "id": "MAP-01", + "titel": "Tenant-Export und Slug/Domain-Abgleich", + "haengt_ab_von": [ + "EXP-01" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:49:50.954548777Z", + "dauer_ms": 11, + "zaehler": { + "archivdms_tenants": 3, + "archivmail_tenants": 3, + "kandidaten": 4, + "konflikte": 0, + "manuelle_zuordnungen": 1 + } + }, + { + "id": "MAP-02", + "titel": "Tenant-Migration-Map: Freigabe (Trockenlauf-Pruefer)", + "haengt_ab_von": [ + "MAP-01", + "PREP-04" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:49:50.966417682Z", + "dauer_ms": 62, + "zaehler": { + "eintraege_angelegt": 4, + "freigegeben": 4 + } + }, + { + "id": "MAP-03", + "titel": "User-Export und E-Mail-Abgleich je freigegebenem Tenant", + "haengt_ab_von": [ + "MAP-02" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:49:51.028954249Z", + "dauer_ms": 1, + "zaehler": { + "archivdms_users": 15, + "archivmail_users": 15, + "kandidaten": 24, + "konflikte": 0, + "zusammengefuehrt": 6 + } + }, + { + "id": "MAP-04*", + "titel": "Benutzer-/Rollen-Map freigeben (Trockenlauf-Ersatz fuer MAP-04)", + "haengt_ab_von": [ + "MAP-03" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:49:51.03075293Z", + "dauer_ms": 228, + "zaehler": { + "benutzer_freigegeben": 24, + "mit_ldap_bezug": 6, + "rollen_freigegeben": 24 + } + }, + { + "id": "COR-01", + "titel": "Tenant-Migration (Provisionierung der Mandanten-DBs)", + "haengt_ab_von": [ + "MAP-02" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:49:51.259338569Z", + "dauer_ms": 345, + "zaehler": { + "tenants_migriert": 4 + } + }, + { + "id": "SCHEMA-01", + "titel": "Core-Tenant-Schema ausrollen (TEN-07)", + "haengt_ab_von": [ + "COR-01" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:49:51.605116869Z", + "dauer_ms": 1132, + "zaehler": { + "core_tenant_migrationen_angewendet": 84, + "tenants": 4 + } + }, + { + "id": "SCHEMA-02", + "titel": "Modul-Schemata anlegen (DMS, Archive, Workflow)", + "haengt_ab_von": [ + "SCHEMA-01" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:49:52.737345175Z", + "dauer_ms": 3486, + "zaehler": { + "archive_migrationen_angewendet": 72, + "dms_migrationen_angewendet": 148, + "workflow_migrationen_angewendet": 72 + } + }, + { + "id": "COR-02", + "titel": "Benutzer-Migration inkl. bcrypt-Hash 1:1", + "haengt_ab_von": [ + "MAP-04*", + "SCHEMA-01" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:49:56.223427227Z", + "dauer_ms": 149, + "zaehler": { + "benutzer_migriert": 24 + } + }, + { + "id": "COR-04", + "titel": "LDAP-Feld-Migration pruefen", + "haengt_ab_von": [ + "COR-02" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:49:56.372462099Z", + "dauer_ms": 23, + "zaehler": { + "benutzer_mit_ldap_bezug_geprueft": 6 + } + }, + { + "id": "COR-03", + "titel": "Rollen- und Rechte-Migration", + "haengt_ab_von": [ + "COR-02" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:49:56.395782437Z", + "dauer_ms": 155, + "zaehler": { + "rolle_tenant_admin": 7, + "rolle_user": 17, + "rollen_zugewiesen": 24 + } + }, + { + "id": "COR-05", + "titel": "Audit-Log-Zusammenfuehrung", + "haengt_ab_von": [ + "MAP-04*", + "COR-01" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:49:56.551118769Z", + "dauer_ms": 255, + "zaehler": { + "audit_ereignisse_migriert": 24, + "staging_zeilen": 24 + }, + "hinweise": [ + "zeitstempel und aktion je ereignis gegen staging unveraendert" + ] + }, + { + "id": "DOK-01", + "titel": "Dokument- und Revisions-Migration", + "haengt_ab_von": [ + "COR-02", + "SCHEMA-02" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:49:56.806206383Z", + "dauer_ms": 463, + "zaehler": { + "dokumente": 21, + "revisionen": 21, + "revisionen_uebersprungen": 0, + "uebergabe_objekte": 24 + }, + "hinweise": [ + "cut0171964d_praxis_fantasia: kein archivdms-mandant, stufe entfaellt" + ] + }, + { + "id": "DOK-02", + "titel": "Akten-/Ordnerstruktur-Migration", + "haengt_ab_von": [ + "DOK-01" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:49:57.269900323Z", + "dauer_ms": 162, + "zaehler": { + "akten": 9, + "akten_uebersprungen": 0, + "dokumente_verknuepft": 15 + }, + "hinweise": [ + "cut0171964d_praxis_fantasia: kein archivdms-mandant, stufe entfaellt" + ] + }, + { + "id": "DOK-04", + "titel": "Custom-Fields- und Klassifikationsvorlagen-Migration", + "haengt_ab_von": [ + "DOK-01" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:49:57.432042823Z", + "dauer_ms": 419, + "zaehler": { + "felder": 15, + "feldwerte": 18, + "vorlagen": 9 + }, + "hinweise": [ + "cut0171964d_praxis_fantasia: kein archivdms-mandant, stufe entfaellt" + ] + }, + { + "id": "DOK-03", + "titel": "ACL-Daten-Migration nach Core-Rechtemodell", + "haengt_ab_von": [ + "DOK-01", + "COR-03" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:49:57.851818414Z", + "dauer_ms": 393, + "zaehler": { + "core_gruppen": 9, + "gruppenmitgliedschaften": 15, + "objektrechte": 48 + }, + "hinweise": [ + "cut0171964d_praxis_fantasia: kein archivdms-mandant, stufe entfaellt" + ] + }, + { + "id": "DOK-05", + "titel": "Freigabelinks-Migration", + "haengt_ab_von": [ + "DOK-01" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:49:58.245324758Z", + "dauer_ms": 77, + "zaehler": { + "freigabelinks": 6, + "freigabelinks_abgelaufen_verworfen": 3 + }, + "hinweise": [ + "cut0171964d_praxis_fantasia: kein archivdms-mandant, stufe entfaellt" + ] + }, + { + "id": "MAI-02", + "titel": "Nachrichten- und Anhang-Migration", + "haengt_ab_von": [ + "COR-01" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:49:58.322705541Z", + "dauer_ms": 93, + "zaehler": { + "anhaenge": 9, + "nachrichten": 19, + "nachrichten_uebersprungen": 0, + "uebergabe_objekte": 19 + }, + "hinweise": [ + "cut0171964d_ingenieurbuero_erfundenhausen: kein archivmail-mandant, stufe entfaellt", + "suchindex im trockenlauf durch dateibasierten ersatz (ziel-objekte/mail-index) vertreten, siehe pruefprotokoll" + ] + }, + { + "id": "TAR-01", + "titel": "Retention-Fristen-Migration ohne Reset", + "haengt_ab_von": [ + "DOK-01", + "MAI-02", + "SCHEMA-02" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:49:58.416722865Z", + "dauer_ms": 397, + "zaehler": { + "fristen_geprueft": 40, + "fristen_uebernommen": 27, + "objekte_registriert": 40 + } + }, + { + "id": "MAI-03", + "titel": "DSGVO-Loeschanfragen-Migration", + "haengt_ab_von": [ + "TAR-01" + ], + "status": "fehlgeschlagen", + "gestartet": "2026-10-14T20:49:58.813805756Z", + "dauer_ms": 30, + "fehler": "modulstufe archive/mai03: exit status 1: fehler: mandant cut0171964d_kanzlei_musterhausen: offene dsgvo-anfrage 1: 2 migrierte objekte der betroffenen person, aber keine einzelentscheidung protokolliert" + }, + { + "id": "MAI-04", + "titel": "Reconciliation-Reports-Archivierung", + "haengt_ab_von": [ + "SCHEMA-02" + ], + "status": "nicht ausgefuehrt", + "dauer_ms": 0, + "hinweise": [ + "nicht ausgefuehrt: Abbruch nach Fehler in MAI-03" + ] + }, + { + "id": "TAR-02", + "titel": "Vier-Augen-Loeschworkflow-Migration", + "haengt_ab_von": [ + "TAR-01", + "COR-02" + ], + "status": "nicht ausgefuehrt", + "dauer_ms": 0, + "hinweise": [ + "nicht ausgefuehrt: Abbruch nach Fehler in MAI-03" + ] + }, + { + "id": "WFL-01", + "titel": "Workflow-Historie-Migration", + "haengt_ab_von": [ + "COR-02", + "SCHEMA-02" + ], + "status": "nicht ausgefuehrt", + "dauer_ms": 0, + "hinweise": [ + "nicht ausgefuehrt: Abbruch nach Fehler in MAI-03" + ] + }, + { + "id": "VAL-01", + "titel": "Zeilenzahl-Abgleich je Kerntabelle", + "haengt_ab_von": [ + "COR-02", + "DOK-01", + "MAI-02" + ], + "status": "nicht ausgefuehrt", + "dauer_ms": 0, + "hinweise": [ + "nicht ausgefuehrt: Abbruch nach Fehler in MAI-03" + ] + }, + { + "id": "VAL-02", + "titel": "Hash-Vergleich migrierter Binaerdateien", + "haengt_ab_von": [ + "DOK-01", + "MAI-02" + ], + "status": "nicht ausgefuehrt", + "dauer_ms": 0, + "hinweise": [ + "nicht ausgefuehrt: Abbruch nach Fehler in MAI-03" + ] + }, + { + "id": "VAL-05", + "titel": "Login-Test mit migrierten Zugangsdaten", + "haengt_ab_von": [ + "COR-02" + ], + "status": "nicht ausgefuehrt", + "dauer_ms": 0, + "hinweise": [ + "nicht ausgefuehrt: Abbruch nach Fehler in MAI-03" + ] + }, + { + "id": "CHK-01", + "titel": "Modulgrenzen-uebergreifende Kettenpruefung", + "haengt_ab_von": [ + "DOK-03", + "DOK-05", + "TAR-02", + "MAI-03", + "MAI-04", + "WFL-01" + ], + "status": "nicht ausgefuehrt", + "dauer_ms": 0, + "hinweise": [ + "nicht ausgefuehrt: Abbruch nach Fehler in MAI-03" + ] + } + ] +} diff --git a/docs/cut-01/iteration-1/lauf-1.4-cut0171964d.txt b/docs/cut-01/iteration-1/lauf-1.4-cut0171964d.txt new file mode 100644 index 00000000..ad143e28 --- /dev/null +++ b/docs/cut-01/iteration-1/lauf-1.4-cut0171964d.txt @@ -0,0 +1,81 @@ +CUT-01 Trockenlauf-Protokoll +Lauf: cut0171964d (Iteration 1 / Lauf 1.4) +Beginn: 2026-10-14 20:49:38 UTC Ende: 2026-10-14 20:49:58 UTC Dauer: 20.353s +Ergebnis: ABGEBROCHEN bei MAI-03 + + 1. [PREP-01] Arbeitsverzeichnis anlegen, Modulstufen bauen – erfolgreich (5139 ms) + Zaehler: [modulstufen=4] + Hinweis: arbeitsverzeichnis /root/cut01-dryrun/cut0171964d + 2. [PREP-02] Synthetische Testkopie archivmail/archivdms erzeugen – erfolgreich (6907 ms) + Zaehler: [archivdms_akten=9 archivdms_audit=12 archivdms_delete_requests=9 archivdms_documents=24 archivdms_shares=15 archivdms_tenants=3 archivdms_users=15 archivdms_workflow_runs=12 archivmail_attachments=9 archivmail_audit=12 archivmail_dsgvo=9 archivmail_emails=18 archivmail_reports=9 archivmail_tenants=3 archivmail_users=15 binaerobjekte=48] + Hinweis: generator-seed 20261014, quelle ausschliesslich internal/cutoverorchestrator/dataset.go + 3. [PREP-03] Anonymisierungsnachweis der Testkopie – erfolgreich (21 ms) + Zaehler: [binaerobjekte=48 e_mail_adressen=93 ip_adressen=24 personen=30 tabellen=30 verstoesse=0 zeilen=431] + Hinweis: alle e-mail-adressen unter der reservierten domain .example (RFC 2606) + Hinweis: alle ip-adressen aus dokumentationsnetzen (RFC 5737) + Hinweis: alle konten mit synthetischem praefix, alle binaerobjekte mit marker SYNTHETISCH + 4. [PREP-04] Core-Registry anlegen (Core-Migrationen) – erfolgreich (381 ms) + Zaehler: [core_migrationen=42] + 5. [EXP-01] Export-Sichten der Altsysteme bereitstellen – erfolgreich (12 ms) + Zaehler: [export_sichten=4 plattformkonten_ohne_mandant=0] + Hinweis: archivmail-domain fuer MAP-01: erste registrierte domain je mandant (MAP-01 vergleicht genau eine domain) + 6. [MAP-01] Tenant-Export und Slug/Domain-Abgleich – erfolgreich (11 ms) + Zaehler: [archivdms_tenants=3 archivmail_tenants=3 kandidaten=4 konflikte=0 manuelle_zuordnungen=1] + 7. [MAP-02] Tenant-Migration-Map: Freigabe (Trockenlauf-Pruefer) – erfolgreich (62 ms) + Zaehler: [eintraege_angelegt=4 freigegeben=4] + 8. [MAP-03] User-Export und E-Mail-Abgleich je freigegebenem Tenant – erfolgreich (1 ms) + Zaehler: [archivdms_users=15 archivmail_users=15 kandidaten=24 konflikte=0 zusammengefuehrt=6] + 9. [MAP-04*] Benutzer-/Rollen-Map freigeben (Trockenlauf-Ersatz fuer MAP-04) – erfolgreich (228 ms) + Zaehler: [benutzer_freigegeben=24 mit_ldap_bezug=6 rollen_freigegeben=24] +10. [COR-01] Tenant-Migration (Provisionierung der Mandanten-DBs) – erfolgreich (345 ms) + Zaehler: [tenants_migriert=4] +11. [SCHEMA-01] Core-Tenant-Schema ausrollen (TEN-07) – erfolgreich (1132 ms) + Zaehler: [core_tenant_migrationen_angewendet=84 tenants=4] +12. [SCHEMA-02] Modul-Schemata anlegen (DMS, Archive, Workflow) – erfolgreich (3486 ms) + Zaehler: [archive_migrationen_angewendet=72 dms_migrationen_angewendet=148 workflow_migrationen_angewendet=72] +13. [COR-02] Benutzer-Migration inkl. bcrypt-Hash 1:1 – erfolgreich (149 ms) + Zaehler: [benutzer_migriert=24] +14. [COR-04] LDAP-Feld-Migration pruefen – erfolgreich (23 ms) + Zaehler: [benutzer_mit_ldap_bezug_geprueft=6] +15. [COR-03] Rollen- und Rechte-Migration – erfolgreich (155 ms) + Zaehler: [rolle_tenant_admin=7 rolle_user=17 rollen_zugewiesen=24] +16. [COR-05] Audit-Log-Zusammenfuehrung – erfolgreich (255 ms) + Zaehler: [audit_ereignisse_migriert=24 staging_zeilen=24] + Hinweis: zeitstempel und aktion je ereignis gegen staging unveraendert +17. [DOK-01] Dokument- und Revisions-Migration – erfolgreich (463 ms) + Zaehler: [dokumente=21 revisionen=21 revisionen_uebersprungen=0 uebergabe_objekte=24] + Hinweis: cut0171964d_praxis_fantasia: kein archivdms-mandant, stufe entfaellt +18. [DOK-02] Akten-/Ordnerstruktur-Migration – erfolgreich (162 ms) + Zaehler: [akten=9 akten_uebersprungen=0 dokumente_verknuepft=15] + Hinweis: cut0171964d_praxis_fantasia: kein archivdms-mandant, stufe entfaellt +19. [DOK-04] Custom-Fields- und Klassifikationsvorlagen-Migration – erfolgreich (419 ms) + Zaehler: [felder=15 feldwerte=18 vorlagen=9] + Hinweis: cut0171964d_praxis_fantasia: kein archivdms-mandant, stufe entfaellt +20. [DOK-03] ACL-Daten-Migration nach Core-Rechtemodell – erfolgreich (393 ms) + Zaehler: [core_gruppen=9 gruppenmitgliedschaften=15 objektrechte=48] + Hinweis: cut0171964d_praxis_fantasia: kein archivdms-mandant, stufe entfaellt +21. [DOK-05] Freigabelinks-Migration – erfolgreich (77 ms) + Zaehler: [freigabelinks=6 freigabelinks_abgelaufen_verworfen=3] + Hinweis: cut0171964d_praxis_fantasia: kein archivdms-mandant, stufe entfaellt +22. [MAI-02] Nachrichten- und Anhang-Migration – erfolgreich (93 ms) + Zaehler: [anhaenge=9 nachrichten=19 nachrichten_uebersprungen=0 uebergabe_objekte=19] + Hinweis: cut0171964d_ingenieurbuero_erfundenhausen: kein archivmail-mandant, stufe entfaellt + Hinweis: suchindex im trockenlauf durch dateibasierten ersatz (ziel-objekte/mail-index) vertreten, siehe pruefprotokoll +23. [TAR-01] Retention-Fristen-Migration ohne Reset – erfolgreich (397 ms) + Zaehler: [fristen_geprueft=40 fristen_uebernommen=27 objekte_registriert=40] +24. [MAI-03] DSGVO-Loeschanfragen-Migration – fehlgeschlagen (30 ms) + FEHLER: modulstufe archive/mai03: exit status 1: fehler: mandant cut0171964d_kanzlei_musterhausen: offene dsgvo-anfrage 1: 2 migrierte objekte der betroffenen person, aber keine einzelentscheidung protokolliert +25. [MAI-04] Reconciliation-Reports-Archivierung – nicht ausgefuehrt + Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in MAI-03 +26. [TAR-02] Vier-Augen-Loeschworkflow-Migration – nicht ausgefuehrt + Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in MAI-03 +27. [WFL-01] Workflow-Historie-Migration – nicht ausgefuehrt + Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in MAI-03 +28. [VAL-01] Zeilenzahl-Abgleich je Kerntabelle – nicht ausgefuehrt + Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in MAI-03 +29. [VAL-02] Hash-Vergleich migrierter Binaerdateien – nicht ausgefuehrt + Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in MAI-03 +30. [VAL-05] Login-Test mit migrierten Zugangsdaten – nicht ausgefuehrt + Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in MAI-03 +31. [CHK-01] Modulgrenzen-uebergreifende Kettenpruefung – nicht ausgefuehrt + Hinweis: nicht ausgefuehrt: Abbruch nach Fehler in MAI-03 diff --git a/docs/cut-01/iteration-1/lauf-1.5-cut01748e71.json b/docs/cut-01/iteration-1/lauf-1.5-cut01748e71.json new file mode 100644 index 00000000..b7f82262 --- /dev/null +++ b/docs/cut-01/iteration-1/lauf-1.5-cut01748e71.json @@ -0,0 +1,555 @@ +{ + "lauf_kennung": "cut01748e71", + "bezeichnung": "Iteration 1 / Lauf 1.5", + "gestartet": "2026-10-14T20:50:42.771952947Z", + "beendet": "2026-10-14T20:51:10.516841945Z", + "erfolgreich": true, + "schritte": [ + { + "id": "PREP-01", + "titel": "Arbeitsverzeichnis anlegen, Modulstufen bauen", + "status": "erfolgreich", + "gestartet": "2026-10-14T20:50:42.771993597Z", + "dauer_ms": 5710, + "zaehler": { + "modulstufen": 4 + }, + "hinweise": [ + "arbeitsverzeichnis /root/cut01-dryrun/cut01748e71" + ] + }, + { + "id": "PREP-02", + "titel": "Synthetische Testkopie archivmail/archivdms erzeugen", + "haengt_ab_von": [ + "PREP-01" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:50:48.484430778Z", + "dauer_ms": 6859, + "zaehler": { + "archivdms_akten": 9, + "archivdms_audit": 12, + "archivdms_delete_requests": 9, + "archivdms_documents": 24, + "archivdms_shares": 15, + "archivdms_tenants": 3, + "archivdms_users": 15, + "archivdms_workflow_runs": 12, + "archivmail_attachments": 9, + "archivmail_audit": 12, + "archivmail_dsgvo": 9, + "archivmail_emails": 18, + "archivmail_reports": 9, + "archivmail_tenants": 3, + "archivmail_users": 15, + "binaerobjekte": 48 + }, + "hinweise": [ + "generator-seed 20261014, quelle ausschliesslich internal/cutoverorchestrator/dataset.go" + ] + }, + { + "id": "PREP-03", + "titel": "Anonymisierungsnachweis der Testkopie", + "haengt_ab_von": [ + "PREP-02" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:50:55.343613461Z", + "dauer_ms": 34, + "zaehler": { + "binaerobjekte": 48, + "e_mail_adressen": 93, + "ip_adressen": 24, + "personen": 30, + "tabellen": 30, + "verstoesse": 0, + "zeilen": 431 + }, + "hinweise": [ + "alle e-mail-adressen unter der reservierten domain .example (RFC 2606)", + "alle ip-adressen aus dokumentationsnetzen (RFC 5737)", + "alle konten mit synthetischem praefix, alle binaerobjekte mit marker SYNTHETISCH" + ] + }, + { + "id": "PREP-04", + "titel": "Core-Registry anlegen (Core-Migrationen)", + "haengt_ab_von": [ + "PREP-01" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:50:55.377889744Z", + "dauer_ms": 411, + "zaehler": { + "core_migrationen": 42 + } + }, + { + "id": "EXP-01", + "titel": "Export-Sichten der Altsysteme bereitstellen", + "haengt_ab_von": [ + "PREP-03" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:50:55.789037855Z", + "dauer_ms": 13, + "zaehler": { + "export_sichten": 4, + "plattformkonten_ohne_mandant": 0 + }, + "hinweise": [ + "archivmail-domain fuer MAP-01: erste registrierte domain je mandant (MAP-01 vergleicht genau eine domain)" + ] + }, + { + "id": "MAP-01", + "titel": "Tenant-Export und Slug/Domain-Abgleich", + "haengt_ab_von": [ + "EXP-01" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:50:55.80221065Z", + "dauer_ms": 13, + "zaehler": { + "archivdms_tenants": 3, + "archivmail_tenants": 3, + "kandidaten": 4, + "konflikte": 0, + "manuelle_zuordnungen": 1 + } + }, + { + "id": "MAP-02", + "titel": "Tenant-Migration-Map: Freigabe (Trockenlauf-Pruefer)", + "haengt_ab_von": [ + "MAP-01", + "PREP-04" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:50:55.815579713Z", + "dauer_ms": 67, + "zaehler": { + "eintraege_angelegt": 4, + "freigegeben": 4 + } + }, + { + "id": "MAP-03", + "titel": "User-Export und E-Mail-Abgleich je freigegebenem Tenant", + "haengt_ab_von": [ + "MAP-02" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:50:55.882714748Z", + "dauer_ms": 2, + "zaehler": { + "archivdms_users": 15, + "archivmail_users": 15, + "kandidaten": 24, + "konflikte": 0, + "zusammengefuehrt": 6 + } + }, + { + "id": "MAP-04*", + "titel": "Benutzer-/Rollen-Map freigeben (Trockenlauf-Ersatz fuer MAP-04)", + "haengt_ab_von": [ + "MAP-03" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:50:55.884759847Z", + "dauer_ms": 246, + "zaehler": { + "benutzer_freigegeben": 24, + "mit_ldap_bezug": 6, + "rollen_freigegeben": 24 + } + }, + { + "id": "COR-01", + "titel": "Tenant-Migration (Provisionierung der Mandanten-DBs)", + "haengt_ab_von": [ + "MAP-02" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:50:56.130914334Z", + "dauer_ms": 364, + "zaehler": { + "tenants_migriert": 4 + } + }, + { + "id": "SCHEMA-01", + "titel": "Core-Tenant-Schema ausrollen (TEN-07)", + "haengt_ab_von": [ + "COR-01" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:50:56.495818495Z", + "dauer_ms": 1166, + "zaehler": { + "core_tenant_migrationen_angewendet": 84, + "tenants": 4 + } + }, + { + "id": "SCHEMA-02", + "titel": "Modul-Schemata anlegen (DMS, Archive, Workflow)", + "haengt_ab_von": [ + "SCHEMA-01" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:50:57.662836035Z", + "dauer_ms": 3917, + "zaehler": { + "archive_migrationen_angewendet": 72, + "dms_migrationen_angewendet": 148, + "workflow_migrationen_angewendet": 72 + } + }, + { + "id": "COR-02", + "titel": "Benutzer-Migration inkl. bcrypt-Hash 1:1", + "haengt_ab_von": [ + "MAP-04*", + "SCHEMA-01" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:51:01.580878777Z", + "dauer_ms": 163, + "zaehler": { + "benutzer_migriert": 24 + } + }, + { + "id": "COR-04", + "titel": "LDAP-Feld-Migration pruefen", + "haengt_ab_von": [ + "COR-02" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:51:01.744190992Z", + "dauer_ms": 29, + "zaehler": { + "benutzer_mit_ldap_bezug_geprueft": 6 + } + }, + { + "id": "COR-03", + "titel": "Rollen- und Rechte-Migration", + "haengt_ab_von": [ + "COR-02" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:51:01.774062351Z", + "dauer_ms": 172, + "zaehler": { + "rolle_tenant_admin": 7, + "rolle_user": 17, + "rollen_zugewiesen": 24 + } + }, + { + "id": "COR-05", + "titel": "Audit-Log-Zusammenfuehrung", + "haengt_ab_von": [ + "MAP-04*", + "COR-01" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:51:01.946826651Z", + "dauer_ms": 283, + "zaehler": { + "audit_ereignisse_migriert": 24, + "staging_zeilen": 24 + }, + "hinweise": [ + "zeitstempel und aktion je ereignis gegen staging unveraendert" + ] + }, + { + "id": "DOK-01", + "titel": "Dokument- und Revisions-Migration", + "haengt_ab_von": [ + "COR-02", + "SCHEMA-02" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:51:02.229918144Z", + "dauer_ms": 470, + "zaehler": { + "dokumente": 21, + "revisionen": 21, + "revisionen_uebersprungen": 0, + "uebergabe_objekte": 24 + }, + "hinweise": [ + "cut01748e71_praxis_fantasia: kein archivdms-mandant, stufe entfaellt" + ] + }, + { + "id": "DOK-02", + "titel": "Akten-/Ordnerstruktur-Migration", + "haengt_ab_von": [ + "DOK-01" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:51:02.700087531Z", + "dauer_ms": 182, + "zaehler": { + "akten": 9, + "akten_uebersprungen": 0, + "dokumente_verknuepft": 15 + }, + "hinweise": [ + "cut01748e71_praxis_fantasia: kein archivdms-mandant, stufe entfaellt" + ] + }, + { + "id": "DOK-04", + "titel": "Custom-Fields- und Klassifikationsvorlagen-Migration", + "haengt_ab_von": [ + "DOK-01" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:51:02.8830651Z", + "dauer_ms": 495, + "zaehler": { + "felder": 15, + "feldwerte": 18, + "vorlagen": 9 + }, + "hinweise": [ + "cut01748e71_praxis_fantasia: kein archivdms-mandant, stufe entfaellt" + ] + }, + { + "id": "DOK-03", + "titel": "ACL-Daten-Migration nach Core-Rechtemodell", + "haengt_ab_von": [ + "DOK-01", + "COR-03" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:51:03.378798147Z", + "dauer_ms": 384, + "zaehler": { + "core_gruppen": 9, + "gruppenmitgliedschaften": 15, + "objektrechte": 48 + }, + "hinweise": [ + "cut01748e71_praxis_fantasia: kein archivdms-mandant, stufe entfaellt" + ] + }, + { + "id": "DOK-05", + "titel": "Freigabelinks-Migration", + "haengt_ab_von": [ + "DOK-01" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:51:03.763562184Z", + "dauer_ms": 104, + "zaehler": { + "freigabelinks": 6, + "freigabelinks_abgelaufen_verworfen": 3 + }, + "hinweise": [ + "cut01748e71_praxis_fantasia: kein archivdms-mandant, stufe entfaellt" + ] + }, + { + "id": "MAI-02", + "titel": "Nachrichten- und Anhang-Migration", + "haengt_ab_von": [ + "COR-01" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:51:03.867882599Z", + "dauer_ms": 78, + "zaehler": { + "anhaenge": 9, + "nachrichten": 19, + "nachrichten_uebersprungen": 0, + "uebergabe_objekte": 19 + }, + "hinweise": [ + "cut01748e71_ingenieurbuero_erfundenhausen: kein archivmail-mandant, stufe entfaellt", + "suchindex im trockenlauf durch dateibasierten ersatz (ziel-objekte/mail-index) vertreten, siehe pruefprotokoll" + ] + }, + { + "id": "TAR-01", + "titel": "Retention-Fristen-Migration ohne Reset", + "haengt_ab_von": [ + "DOK-01", + "MAI-02", + "SCHEMA-02" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:51:03.94680929Z", + "dauer_ms": 385, + "zaehler": { + "fristen_geprueft": 40, + "fristen_uebernommen": 27, + "objekte_registriert": 40 + } + }, + { + "id": "MAI-03", + "titel": "DSGVO-Loeschanfragen-Migration", + "haengt_ab_von": [ + "TAR-01" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:51:04.332566261Z", + "dauer_ms": 100, + "zaehler": { + "einzelentscheidungen": 9, + "historische_eintraege": 0, + "offene_anfragen_verarbeitet": 6 + }, + "hinweise": [ + "cut01748e71_ingenieurbuero_erfundenhausen: kein archivmail-mandant, stufe entfaellt" + ] + }, + { + "id": "MAI-04", + "titel": "Reconciliation-Reports-Archivierung", + "haengt_ab_von": [ + "SCHEMA-02" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:51:04.432704212Z", + "dauer_ms": 71, + "zaehler": { + "berichte": 9, + "berichte_uebersprungen": 0 + }, + "hinweise": [ + "cut01748e71_ingenieurbuero_erfundenhausen: kein archivmail-mandant, stufe entfaellt" + ] + }, + { + "id": "TAR-02", + "titel": "Vier-Augen-Loeschworkflow-Migration", + "haengt_ab_von": [ + "TAR-01", + "COR-02" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:51:04.50391785Z", + "dauer_ms": 114, + "zaehler": { + "abgebrochene_nicht_migriert": 3, + "offene_loeschanfragen": 3, + "vollzogene_loeschungen_audit": 3 + }, + "hinweise": [ + "cut01748e71_praxis_fantasia: kein archivdms-mandant, stufe entfaellt" + ] + }, + { + "id": "WFL-01", + "titel": "Workflow-Historie-Migration", + "haengt_ab_von": [ + "COR-02", + "SCHEMA-02" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:51:04.61860834Z", + "dauer_ms": 211, + "zaehler": { + "historieneintraege": 21, + "vorgaenge_migriert": 9, + "vorgaenge_offen_nicht_migriert": 3 + }, + "hinweise": [ + "cut01748e71_kanzlei_musterhausen: 1 laufende vorgaenge muessen vor dem stichtag im altsystem abgeschlossen werden", + "cut01748e71_steuerbuero_beispielstadt: 1 laufende vorgaenge muessen vor dem stichtag im altsystem abgeschlossen werden", + "cut01748e71_praxis_fantasia: kein archivdms-mandant, stufe entfaellt", + "cut01748e71_ingenieurbuero_erfundenhausen: 1 laufende vorgaenge muessen vor dem stichtag im altsystem abgeschlossen werden" + ] + }, + { + "id": "VAL-01", + "titel": "Zeilenzahl-Abgleich je Kerntabelle", + "haengt_ab_von": [ + "COR-02", + "DOK-01", + "MAI-02" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:51:04.830201222Z", + "dauer_ms": 20, + "zaehler": { + "quelle_documents": 24, + "quelle_messages": 19, + "quelle_users": 24, + "vergleiche": 12, + "ziel_documents": 21, + "ziel_messages": 19, + "ziel_users": 24 + } + }, + { + "id": "VAL-02", + "titel": "Hash-Vergleich migrierter Binaerdateien", + "haengt_ab_von": [ + "DOK-01", + "MAI-02" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:51:04.850764449Z", + "dauer_ms": 62, + "zaehler": { + "abweichungen": 0, + "dms_dateien": 15, + "mail_anhaenge": 9 + } + }, + { + "id": "VAL-05", + "titel": "Login-Test mit migrierten Zugangsdaten", + "haengt_ab_von": [ + "COR-02" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:51:04.913283485Z", + "dauer_ms": 5525, + "zaehler": { + "falsche_passwoerter_abgewiesen": 12, + "logins_erfolgreich": 12 + } + }, + { + "id": "CHK-01", + "titel": "Modulgrenzen-uebergreifende Kettenpruefung", + "haengt_ab_von": [ + "DOK-03", + "DOK-05", + "TAR-02", + "MAI-03", + "MAI-04", + "WFL-01" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:51:10.439233602Z", + "dauer_ms": 77, + "zaehler": { + "akten": 9, + "befunde": 0, + "dokumente_mit_retention": 21, + "freigabelinks": 6, + "nachrichten_mit_retention": 19, + "offene_loeschanfragen": 3, + "reconciliation_berichte": 9, + "vollzogene_loeschungen": 3, + "workflow_vorgaenge": 9 + } + } + ] +} diff --git a/docs/cut-01/iteration-1/lauf-1.5-cut01748e71.txt b/docs/cut-01/iteration-1/lauf-1.5-cut01748e71.txt new file mode 100644 index 00000000..9f7d12c1 --- /dev/null +++ b/docs/cut-01/iteration-1/lauf-1.5-cut01748e71.txt @@ -0,0 +1,88 @@ +CUT-01 Trockenlauf-Protokoll +Lauf: cut01748e71 (Iteration 1 / Lauf 1.5) +Beginn: 2026-10-14 20:50:42 UTC Ende: 2026-10-14 20:51:10 UTC Dauer: 27.745s +Ergebnis: ERFOLGREICH – alle 31 Schritte ohne Abbruch durchgelaufen + + 1. [PREP-01] Arbeitsverzeichnis anlegen, Modulstufen bauen – erfolgreich (5710 ms) + Zaehler: [modulstufen=4] + Hinweis: arbeitsverzeichnis /root/cut01-dryrun/cut01748e71 + 2. [PREP-02] Synthetische Testkopie archivmail/archivdms erzeugen – erfolgreich (6859 ms) + Zaehler: [archivdms_akten=9 archivdms_audit=12 archivdms_delete_requests=9 archivdms_documents=24 archivdms_shares=15 archivdms_tenants=3 archivdms_users=15 archivdms_workflow_runs=12 archivmail_attachments=9 archivmail_audit=12 archivmail_dsgvo=9 archivmail_emails=18 archivmail_reports=9 archivmail_tenants=3 archivmail_users=15 binaerobjekte=48] + Hinweis: generator-seed 20261014, quelle ausschliesslich internal/cutoverorchestrator/dataset.go + 3. [PREP-03] Anonymisierungsnachweis der Testkopie – erfolgreich (34 ms) + Zaehler: [binaerobjekte=48 e_mail_adressen=93 ip_adressen=24 personen=30 tabellen=30 verstoesse=0 zeilen=431] + Hinweis: alle e-mail-adressen unter der reservierten domain .example (RFC 2606) + Hinweis: alle ip-adressen aus dokumentationsnetzen (RFC 5737) + Hinweis: alle konten mit synthetischem praefix, alle binaerobjekte mit marker SYNTHETISCH + 4. [PREP-04] Core-Registry anlegen (Core-Migrationen) – erfolgreich (411 ms) + Zaehler: [core_migrationen=42] + 5. [EXP-01] Export-Sichten der Altsysteme bereitstellen – erfolgreich (13 ms) + Zaehler: [export_sichten=4 plattformkonten_ohne_mandant=0] + Hinweis: archivmail-domain fuer MAP-01: erste registrierte domain je mandant (MAP-01 vergleicht genau eine domain) + 6. [MAP-01] Tenant-Export und Slug/Domain-Abgleich – erfolgreich (13 ms) + Zaehler: [archivdms_tenants=3 archivmail_tenants=3 kandidaten=4 konflikte=0 manuelle_zuordnungen=1] + 7. [MAP-02] Tenant-Migration-Map: Freigabe (Trockenlauf-Pruefer) – erfolgreich (67 ms) + Zaehler: [eintraege_angelegt=4 freigegeben=4] + 8. [MAP-03] User-Export und E-Mail-Abgleich je freigegebenem Tenant – erfolgreich (2 ms) + Zaehler: [archivdms_users=15 archivmail_users=15 kandidaten=24 konflikte=0 zusammengefuehrt=6] + 9. [MAP-04*] Benutzer-/Rollen-Map freigeben (Trockenlauf-Ersatz fuer MAP-04) – erfolgreich (246 ms) + Zaehler: [benutzer_freigegeben=24 mit_ldap_bezug=6 rollen_freigegeben=24] +10. [COR-01] Tenant-Migration (Provisionierung der Mandanten-DBs) – erfolgreich (364 ms) + Zaehler: [tenants_migriert=4] +11. [SCHEMA-01] Core-Tenant-Schema ausrollen (TEN-07) – erfolgreich (1166 ms) + Zaehler: [core_tenant_migrationen_angewendet=84 tenants=4] +12. [SCHEMA-02] Modul-Schemata anlegen (DMS, Archive, Workflow) – erfolgreich (3917 ms) + Zaehler: [archive_migrationen_angewendet=72 dms_migrationen_angewendet=148 workflow_migrationen_angewendet=72] +13. [COR-02] Benutzer-Migration inkl. bcrypt-Hash 1:1 – erfolgreich (163 ms) + Zaehler: [benutzer_migriert=24] +14. [COR-04] LDAP-Feld-Migration pruefen – erfolgreich (29 ms) + Zaehler: [benutzer_mit_ldap_bezug_geprueft=6] +15. [COR-03] Rollen- und Rechte-Migration – erfolgreich (172 ms) + Zaehler: [rolle_tenant_admin=7 rolle_user=17 rollen_zugewiesen=24] +16. [COR-05] Audit-Log-Zusammenfuehrung – erfolgreich (283 ms) + Zaehler: [audit_ereignisse_migriert=24 staging_zeilen=24] + Hinweis: zeitstempel und aktion je ereignis gegen staging unveraendert +17. [DOK-01] Dokument- und Revisions-Migration – erfolgreich (470 ms) + Zaehler: [dokumente=21 revisionen=21 revisionen_uebersprungen=0 uebergabe_objekte=24] + Hinweis: cut01748e71_praxis_fantasia: kein archivdms-mandant, stufe entfaellt +18. [DOK-02] Akten-/Ordnerstruktur-Migration – erfolgreich (182 ms) + Zaehler: [akten=9 akten_uebersprungen=0 dokumente_verknuepft=15] + Hinweis: cut01748e71_praxis_fantasia: kein archivdms-mandant, stufe entfaellt +19. [DOK-04] Custom-Fields- und Klassifikationsvorlagen-Migration – erfolgreich (495 ms) + Zaehler: [felder=15 feldwerte=18 vorlagen=9] + Hinweis: cut01748e71_praxis_fantasia: kein archivdms-mandant, stufe entfaellt +20. [DOK-03] ACL-Daten-Migration nach Core-Rechtemodell – erfolgreich (384 ms) + Zaehler: [core_gruppen=9 gruppenmitgliedschaften=15 objektrechte=48] + Hinweis: cut01748e71_praxis_fantasia: kein archivdms-mandant, stufe entfaellt +21. [DOK-05] Freigabelinks-Migration – erfolgreich (104 ms) + Zaehler: [freigabelinks=6 freigabelinks_abgelaufen_verworfen=3] + Hinweis: cut01748e71_praxis_fantasia: kein archivdms-mandant, stufe entfaellt +22. [MAI-02] Nachrichten- und Anhang-Migration – erfolgreich (78 ms) + Zaehler: [anhaenge=9 nachrichten=19 nachrichten_uebersprungen=0 uebergabe_objekte=19] + Hinweis: cut01748e71_ingenieurbuero_erfundenhausen: kein archivmail-mandant, stufe entfaellt + Hinweis: suchindex im trockenlauf durch dateibasierten ersatz (ziel-objekte/mail-index) vertreten, siehe pruefprotokoll +23. [TAR-01] Retention-Fristen-Migration ohne Reset – erfolgreich (385 ms) + Zaehler: [fristen_geprueft=40 fristen_uebernommen=27 objekte_registriert=40] +24. [MAI-03] DSGVO-Loeschanfragen-Migration – erfolgreich (100 ms) + Zaehler: [einzelentscheidungen=9 historische_eintraege=0 offene_anfragen_verarbeitet=6] + Hinweis: cut01748e71_ingenieurbuero_erfundenhausen: kein archivmail-mandant, stufe entfaellt +25. [MAI-04] Reconciliation-Reports-Archivierung – erfolgreich (71 ms) + Zaehler: [berichte=9 berichte_uebersprungen=0] + Hinweis: cut01748e71_ingenieurbuero_erfundenhausen: kein archivmail-mandant, stufe entfaellt +26. [TAR-02] Vier-Augen-Loeschworkflow-Migration – erfolgreich (114 ms) + Zaehler: [abgebrochene_nicht_migriert=3 offene_loeschanfragen=3 vollzogene_loeschungen_audit=3] + Hinweis: cut01748e71_praxis_fantasia: kein archivdms-mandant, stufe entfaellt +27. [WFL-01] Workflow-Historie-Migration – erfolgreich (211 ms) + Zaehler: [historieneintraege=21 vorgaenge_migriert=9 vorgaenge_offen_nicht_migriert=3] + Hinweis: cut01748e71_kanzlei_musterhausen: 1 laufende vorgaenge muessen vor dem stichtag im altsystem abgeschlossen werden + Hinweis: cut01748e71_steuerbuero_beispielstadt: 1 laufende vorgaenge muessen vor dem stichtag im altsystem abgeschlossen werden + Hinweis: cut01748e71_praxis_fantasia: kein archivdms-mandant, stufe entfaellt + Hinweis: cut01748e71_ingenieurbuero_erfundenhausen: 1 laufende vorgaenge muessen vor dem stichtag im altsystem abgeschlossen werden +28. [VAL-01] Zeilenzahl-Abgleich je Kerntabelle – erfolgreich (20 ms) + Zaehler: [quelle_documents=24 quelle_messages=19 quelle_users=24 vergleiche=12 ziel_documents=21 ziel_messages=19 ziel_users=24] +29. [VAL-02] Hash-Vergleich migrierter Binaerdateien – erfolgreich (62 ms) + Zaehler: [abweichungen=0 dms_dateien=15 mail_anhaenge=9] +30. [VAL-05] Login-Test mit migrierten Zugangsdaten – erfolgreich (5525 ms) + Zaehler: [falsche_passwoerter_abgewiesen=12 logins_erfolgreich=12] +31. [CHK-01] Modulgrenzen-uebergreifende Kettenpruefung – erfolgreich (77 ms) + Zaehler: [akten=9 befunde=0 dokumente_mit_retention=21 freigabelinks=6 nachrichten_mit_retention=19 offene_loeschanfragen=3 reconciliation_berichte=9 vollzogene_loeschungen=3 workflow_vorgaenge=9] diff --git a/docs/cut-01/iteration-1/lauf-1.5-val01-zeilenzahl-abgleich.md b/docs/cut-01/iteration-1/lauf-1.5-val01-zeilenzahl-abgleich.md new file mode 100644 index 00000000..6f735ea6 --- /dev/null +++ b/docs/cut-01/iteration-1/lauf-1.5-val01-zeilenzahl-abgleich.md @@ -0,0 +1,16 @@ +# Zeilenzahl-Abgleich je Kerntabelle + +| Tenant | Tabelle | Quelle | Ziel | Differenz | Erklaert | Status | Begruendung | +|---|---|---|---|---|---|---|---| +| cut01748e71_ingenieurbuero_erfundenhausen | documents | 8 | 7 | 1 | 1 | OK | im Altsystem bereits vollzogene Loeschung, nur Audit-Eintrag (TAR-02) | +| cut01748e71_ingenieurbuero_erfundenhausen | messages | 0 | 0 | 0 | 0 | OK | - | +| cut01748e71_ingenieurbuero_erfundenhausen | users | 5 | 5 | 0 | 0 | OK | - | +| cut01748e71_kanzlei_musterhausen | documents | 8 | 7 | 1 | 1 | OK | im Altsystem bereits vollzogene Loeschung, nur Audit-Eintrag (TAR-02) | +| cut01748e71_kanzlei_musterhausen | messages | 6 | 6 | 0 | 0 | OK | - | +| cut01748e71_kanzlei_musterhausen | users | 7 | 7 | 0 | 0 | OK | - | +| cut01748e71_praxis_fantasia | documents | 0 | 0 | 0 | 0 | OK | - | +| cut01748e71_praxis_fantasia | messages | 7 | 7 | 0 | 0 | OK | - | +| cut01748e71_praxis_fantasia | users | 5 | 5 | 0 | 0 | OK | - | +| cut01748e71_steuerbuero_beispielstadt | documents | 8 | 7 | 1 | 1 | OK | im Altsystem bereits vollzogene Loeschung, nur Audit-Eintrag (TAR-02) | +| cut01748e71_steuerbuero_beispielstadt | messages | 6 | 6 | 0 | 0 | OK | - | +| cut01748e71_steuerbuero_beispielstadt | users | 7 | 7 | 0 | 0 | OK | - | diff --git a/docs/cut-01/iteration-2/lauf-2.1-cut017f8b14.json b/docs/cut-01/iteration-2/lauf-2.1-cut017f8b14.json new file mode 100644 index 00000000..1aaf9682 --- /dev/null +++ b/docs/cut-01/iteration-2/lauf-2.1-cut017f8b14.json @@ -0,0 +1,555 @@ +{ + "lauf_kennung": "cut017f8b14", + "bezeichnung": "Iteration 2 / Lauf 2.1 (Commit 4fdbb9b)", + "gestartet": "2026-10-14T20:54:05.633268835Z", + "beendet": "2026-10-14T20:54:34.442325027Z", + "erfolgreich": true, + "schritte": [ + { + "id": "PREP-01", + "titel": "Arbeitsverzeichnis anlegen, Modulstufen bauen", + "status": "erfolgreich", + "gestartet": "2026-10-14T20:54:05.633288672Z", + "dauer_ms": 6723, + "zaehler": { + "modulstufen": 4 + }, + "hinweise": [ + "arbeitsverzeichnis /root/cut01-dryrun/cut017f8b14" + ] + }, + { + "id": "PREP-02", + "titel": "Synthetische Testkopie archivmail/archivdms erzeugen", + "haengt_ab_von": [ + "PREP-01" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:54:12.35648661Z", + "dauer_ms": 6851, + "zaehler": { + "archivdms_akten": 9, + "archivdms_audit": 12, + "archivdms_delete_requests": 9, + "archivdms_documents": 24, + "archivdms_shares": 15, + "archivdms_tenants": 3, + "archivdms_users": 15, + "archivdms_workflow_runs": 12, + "archivmail_attachments": 9, + "archivmail_audit": 12, + "archivmail_dsgvo": 9, + "archivmail_emails": 18, + "archivmail_reports": 9, + "archivmail_tenants": 3, + "archivmail_users": 15, + "binaerobjekte": 48 + }, + "hinweise": [ + "generator-seed 20261014, quelle ausschliesslich internal/cutoverorchestrator/dataset.go" + ] + }, + { + "id": "PREP-03", + "titel": "Anonymisierungsnachweis der Testkopie", + "haengt_ab_von": [ + "PREP-02" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:54:19.208245457Z", + "dauer_ms": 20, + "zaehler": { + "binaerobjekte": 48, + "e_mail_adressen": 93, + "ip_adressen": 24, + "personen": 30, + "tabellen": 30, + "verstoesse": 0, + "zeilen": 431 + }, + "hinweise": [ + "alle e-mail-adressen unter der reservierten domain .example (RFC 2606)", + "alle ip-adressen aus dokumentationsnetzen (RFC 5737)", + "alle konten mit synthetischem praefix, alle binaerobjekte mit marker SYNTHETISCH" + ] + }, + { + "id": "PREP-04", + "titel": "Core-Registry anlegen (Core-Migrationen)", + "haengt_ab_von": [ + "PREP-01" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:54:19.228890754Z", + "dauer_ms": 388, + "zaehler": { + "core_migrationen": 42 + } + }, + { + "id": "EXP-01", + "titel": "Export-Sichten der Altsysteme bereitstellen", + "haengt_ab_von": [ + "PREP-03" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:54:19.617812112Z", + "dauer_ms": 12, + "zaehler": { + "export_sichten": 4, + "plattformkonten_ohne_mandant": 0 + }, + "hinweise": [ + "archivmail-domain fuer MAP-01: erste registrierte domain je mandant (MAP-01 vergleicht genau eine domain)" + ] + }, + { + "id": "MAP-01", + "titel": "Tenant-Export und Slug/Domain-Abgleich", + "haengt_ab_von": [ + "EXP-01" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:54:19.630219253Z", + "dauer_ms": 12, + "zaehler": { + "archivdms_tenants": 3, + "archivmail_tenants": 3, + "kandidaten": 4, + "konflikte": 0, + "manuelle_zuordnungen": 1 + } + }, + { + "id": "MAP-02", + "titel": "Tenant-Migration-Map: Freigabe (Trockenlauf-Pruefer)", + "haengt_ab_von": [ + "MAP-01", + "PREP-04" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:54:19.642981561Z", + "dauer_ms": 85, + "zaehler": { + "eintraege_angelegt": 4, + "freigegeben": 4 + } + }, + { + "id": "MAP-03", + "titel": "User-Export und E-Mail-Abgleich je freigegebenem Tenant", + "haengt_ab_von": [ + "MAP-02" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:54:19.728803754Z", + "dauer_ms": 2, + "zaehler": { + "archivdms_users": 15, + "archivmail_users": 15, + "kandidaten": 24, + "konflikte": 0, + "zusammengefuehrt": 6 + } + }, + { + "id": "MAP-04*", + "titel": "Benutzer-/Rollen-Map freigeben (Trockenlauf-Ersatz fuer MAP-04)", + "haengt_ab_von": [ + "MAP-03" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:54:19.730860587Z", + "dauer_ms": 250, + "zaehler": { + "benutzer_freigegeben": 24, + "mit_ldap_bezug": 6, + "rollen_freigegeben": 24 + } + }, + { + "id": "COR-01", + "titel": "Tenant-Migration (Provisionierung der Mandanten-DBs)", + "haengt_ab_von": [ + "MAP-02" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:54:19.981356091Z", + "dauer_ms": 384, + "zaehler": { + "tenants_migriert": 4 + } + }, + { + "id": "SCHEMA-01", + "titel": "Core-Tenant-Schema ausrollen (TEN-07)", + "haengt_ab_von": [ + "COR-01" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:54:20.365686804Z", + "dauer_ms": 1173, + "zaehler": { + "core_tenant_migrationen_angewendet": 84, + "tenants": 4 + } + }, + { + "id": "SCHEMA-02", + "titel": "Modul-Schemata anlegen (DMS, Archive, Workflow)", + "haengt_ab_von": [ + "SCHEMA-01" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:54:21.539254046Z", + "dauer_ms": 3399, + "zaehler": { + "archive_migrationen_angewendet": 72, + "dms_migrationen_angewendet": 148, + "workflow_migrationen_angewendet": 72 + } + }, + { + "id": "COR-02", + "titel": "Benutzer-Migration inkl. bcrypt-Hash 1:1", + "haengt_ab_von": [ + "MAP-04*", + "SCHEMA-01" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:54:24.938592031Z", + "dauer_ms": 153, + "zaehler": { + "benutzer_migriert": 24 + } + }, + { + "id": "COR-04", + "titel": "LDAP-Feld-Migration pruefen", + "haengt_ab_von": [ + "COR-02" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:54:25.092002703Z", + "dauer_ms": 20, + "zaehler": { + "benutzer_mit_ldap_bezug_geprueft": 6 + } + }, + { + "id": "COR-03", + "titel": "Rollen- und Rechte-Migration", + "haengt_ab_von": [ + "COR-02" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:54:25.112792651Z", + "dauer_ms": 175, + "zaehler": { + "rolle_tenant_admin": 7, + "rolle_user": 17, + "rollen_zugewiesen": 24 + } + }, + { + "id": "COR-05", + "titel": "Audit-Log-Zusammenfuehrung", + "haengt_ab_von": [ + "MAP-04*", + "COR-01" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:54:25.288480717Z", + "dauer_ms": 382, + "zaehler": { + "audit_ereignisse_migriert": 24, + "staging_zeilen": 24 + }, + "hinweise": [ + "zeitstempel und aktion je ereignis gegen staging unveraendert" + ] + }, + { + "id": "DOK-01", + "titel": "Dokument- und Revisions-Migration", + "haengt_ab_von": [ + "COR-02", + "SCHEMA-02" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:54:25.671154049Z", + "dauer_ms": 780, + "zaehler": { + "dokumente": 21, + "revisionen": 21, + "revisionen_uebersprungen": 0, + "uebergabe_objekte": 24 + }, + "hinweise": [ + "cut017f8b14_praxis_fantasia: kein archivdms-mandant, stufe entfaellt" + ] + }, + { + "id": "DOK-02", + "titel": "Akten-/Ordnerstruktur-Migration", + "haengt_ab_von": [ + "DOK-01" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:54:26.451690737Z", + "dauer_ms": 192, + "zaehler": { + "akten": 9, + "akten_uebersprungen": 0, + "dokumente_verknuepft": 15 + }, + "hinweise": [ + "cut017f8b14_praxis_fantasia: kein archivdms-mandant, stufe entfaellt" + ] + }, + { + "id": "DOK-04", + "titel": "Custom-Fields- und Klassifikationsvorlagen-Migration", + "haengt_ab_von": [ + "DOK-01" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:54:26.64442555Z", + "dauer_ms": 486, + "zaehler": { + "felder": 15, + "feldwerte": 18, + "vorlagen": 9 + }, + "hinweise": [ + "cut017f8b14_praxis_fantasia: kein archivdms-mandant, stufe entfaellt" + ] + }, + { + "id": "DOK-03", + "titel": "ACL-Daten-Migration nach Core-Rechtemodell", + "haengt_ab_von": [ + "DOK-01", + "COR-03" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:54:27.13065029Z", + "dauer_ms": 487, + "zaehler": { + "core_gruppen": 9, + "gruppenmitgliedschaften": 15, + "objektrechte": 48 + }, + "hinweise": [ + "cut017f8b14_praxis_fantasia: kein archivdms-mandant, stufe entfaellt" + ] + }, + { + "id": "DOK-05", + "titel": "Freigabelinks-Migration", + "haengt_ab_von": [ + "DOK-01" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:54:27.618391812Z", + "dauer_ms": 90, + "zaehler": { + "freigabelinks": 6, + "freigabelinks_abgelaufen_verworfen": 3 + }, + "hinweise": [ + "cut017f8b14_praxis_fantasia: kein archivdms-mandant, stufe entfaellt" + ] + }, + { + "id": "MAI-02", + "titel": "Nachrichten- und Anhang-Migration", + "haengt_ab_von": [ + "COR-01" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:54:27.708803393Z", + "dauer_ms": 70, + "zaehler": { + "anhaenge": 9, + "nachrichten": 19, + "nachrichten_uebersprungen": 0, + "uebergabe_objekte": 19 + }, + "hinweise": [ + "cut017f8b14_ingenieurbuero_erfundenhausen: kein archivmail-mandant, stufe entfaellt", + "suchindex im trockenlauf durch dateibasierten ersatz (ziel-objekte/mail-index) vertreten, siehe pruefprotokoll" + ] + }, + { + "id": "TAR-01", + "titel": "Retention-Fristen-Migration ohne Reset", + "haengt_ab_von": [ + "DOK-01", + "MAI-02", + "SCHEMA-02" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:54:27.779646288Z", + "dauer_ms": 481, + "zaehler": { + "fristen_geprueft": 40, + "fristen_uebernommen": 27, + "objekte_registriert": 40 + } + }, + { + "id": "MAI-03", + "titel": "DSGVO-Loeschanfragen-Migration", + "haengt_ab_von": [ + "TAR-01" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:54:28.261431321Z", + "dauer_ms": 102, + "zaehler": { + "einzelentscheidungen": 9, + "historische_eintraege": 0, + "offene_anfragen_verarbeitet": 6 + }, + "hinweise": [ + "cut017f8b14_ingenieurbuero_erfundenhausen: kein archivmail-mandant, stufe entfaellt" + ] + }, + { + "id": "MAI-04", + "titel": "Reconciliation-Reports-Archivierung", + "haengt_ab_von": [ + "SCHEMA-02" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:54:28.363694555Z", + "dauer_ms": 92, + "zaehler": { + "berichte": 9, + "berichte_uebersprungen": 0 + }, + "hinweise": [ + "cut017f8b14_ingenieurbuero_erfundenhausen: kein archivmail-mandant, stufe entfaellt" + ] + }, + { + "id": "TAR-02", + "titel": "Vier-Augen-Loeschworkflow-Migration", + "haengt_ab_von": [ + "TAR-01", + "COR-02" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:54:28.455763518Z", + "dauer_ms": 138, + "zaehler": { + "abgebrochene_nicht_migriert": 3, + "offene_loeschanfragen": 3, + "vollzogene_loeschungen_audit": 3 + }, + "hinweise": [ + "cut017f8b14_praxis_fantasia: kein archivdms-mandant, stufe entfaellt" + ] + }, + { + "id": "WFL-01", + "titel": "Workflow-Historie-Migration", + "haengt_ab_von": [ + "COR-02", + "SCHEMA-02" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:54:28.594705328Z", + "dauer_ms": 251, + "zaehler": { + "historieneintraege": 21, + "vorgaenge_migriert": 9, + "vorgaenge_offen_nicht_migriert": 3 + }, + "hinweise": [ + "cut017f8b14_kanzlei_musterhausen: 1 laufende vorgaenge muessen vor dem stichtag im altsystem abgeschlossen werden", + "cut017f8b14_steuerbuero_beispielstadt: 1 laufende vorgaenge muessen vor dem stichtag im altsystem abgeschlossen werden", + "cut017f8b14_praxis_fantasia: kein archivdms-mandant, stufe entfaellt", + "cut017f8b14_ingenieurbuero_erfundenhausen: 1 laufende vorgaenge muessen vor dem stichtag im altsystem abgeschlossen werden" + ] + }, + { + "id": "VAL-01", + "titel": "Zeilenzahl-Abgleich je Kerntabelle", + "haengt_ab_von": [ + "COR-02", + "DOK-01", + "MAI-02" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:54:28.84671894Z", + "dauer_ms": 13, + "zaehler": { + "quelle_documents": 24, + "quelle_messages": 19, + "quelle_users": 24, + "vergleiche": 12, + "ziel_documents": 21, + "ziel_messages": 19, + "ziel_users": 24 + } + }, + { + "id": "VAL-02", + "titel": "Hash-Vergleich migrierter Binaerdateien", + "haengt_ab_von": [ + "DOK-01", + "MAI-02" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:54:28.859773974Z", + "dauer_ms": 52, + "zaehler": { + "abweichungen": 0, + "dms_dateien": 15, + "mail_anhaenge": 9 + } + }, + { + "id": "VAL-05", + "titel": "Login-Test mit migrierten Zugangsdaten", + "haengt_ab_von": [ + "COR-02" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:54:28.911947173Z", + "dauer_ms": 5464, + "zaehler": { + "falsche_passwoerter_abgewiesen": 12, + "logins_erfolgreich": 12 + } + }, + { + "id": "CHK-01", + "titel": "Modulgrenzen-uebergreifende Kettenpruefung", + "haengt_ab_von": [ + "DOK-03", + "DOK-05", + "TAR-02", + "MAI-03", + "MAI-04", + "WFL-01" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:54:34.376907441Z", + "dauer_ms": 65, + "zaehler": { + "akten": 9, + "befunde": 0, + "dokumente_mit_retention": 21, + "freigabelinks": 6, + "nachrichten_mit_retention": 19, + "offene_loeschanfragen": 3, + "reconciliation_berichte": 9, + "vollzogene_loeschungen": 3, + "workflow_vorgaenge": 9 + } + } + ] +} diff --git a/docs/cut-01/iteration-2/lauf-2.1-cut017f8b14.txt b/docs/cut-01/iteration-2/lauf-2.1-cut017f8b14.txt new file mode 100644 index 00000000..f6cb2c22 --- /dev/null +++ b/docs/cut-01/iteration-2/lauf-2.1-cut017f8b14.txt @@ -0,0 +1,88 @@ +CUT-01 Trockenlauf-Protokoll +Lauf: cut017f8b14 (Iteration 2 / Lauf 2.1 (Commit 4fdbb9b)) +Beginn: 2026-10-14 20:54:05 UTC Ende: 2026-10-14 20:54:34 UTC Dauer: 28.809s +Ergebnis: ERFOLGREICH – alle 31 Schritte ohne Abbruch durchgelaufen + + 1. [PREP-01] Arbeitsverzeichnis anlegen, Modulstufen bauen – erfolgreich (6723 ms) + Zaehler: [modulstufen=4] + Hinweis: arbeitsverzeichnis /root/cut01-dryrun/cut017f8b14 + 2. [PREP-02] Synthetische Testkopie archivmail/archivdms erzeugen – erfolgreich (6851 ms) + Zaehler: [archivdms_akten=9 archivdms_audit=12 archivdms_delete_requests=9 archivdms_documents=24 archivdms_shares=15 archivdms_tenants=3 archivdms_users=15 archivdms_workflow_runs=12 archivmail_attachments=9 archivmail_audit=12 archivmail_dsgvo=9 archivmail_emails=18 archivmail_reports=9 archivmail_tenants=3 archivmail_users=15 binaerobjekte=48] + Hinweis: generator-seed 20261014, quelle ausschliesslich internal/cutoverorchestrator/dataset.go + 3. [PREP-03] Anonymisierungsnachweis der Testkopie – erfolgreich (20 ms) + Zaehler: [binaerobjekte=48 e_mail_adressen=93 ip_adressen=24 personen=30 tabellen=30 verstoesse=0 zeilen=431] + Hinweis: alle e-mail-adressen unter der reservierten domain .example (RFC 2606) + Hinweis: alle ip-adressen aus dokumentationsnetzen (RFC 5737) + Hinweis: alle konten mit synthetischem praefix, alle binaerobjekte mit marker SYNTHETISCH + 4. [PREP-04] Core-Registry anlegen (Core-Migrationen) – erfolgreich (388 ms) + Zaehler: [core_migrationen=42] + 5. [EXP-01] Export-Sichten der Altsysteme bereitstellen – erfolgreich (12 ms) + Zaehler: [export_sichten=4 plattformkonten_ohne_mandant=0] + Hinweis: archivmail-domain fuer MAP-01: erste registrierte domain je mandant (MAP-01 vergleicht genau eine domain) + 6. [MAP-01] Tenant-Export und Slug/Domain-Abgleich – erfolgreich (12 ms) + Zaehler: [archivdms_tenants=3 archivmail_tenants=3 kandidaten=4 konflikte=0 manuelle_zuordnungen=1] + 7. [MAP-02] Tenant-Migration-Map: Freigabe (Trockenlauf-Pruefer) – erfolgreich (85 ms) + Zaehler: [eintraege_angelegt=4 freigegeben=4] + 8. [MAP-03] User-Export und E-Mail-Abgleich je freigegebenem Tenant – erfolgreich (2 ms) + Zaehler: [archivdms_users=15 archivmail_users=15 kandidaten=24 konflikte=0 zusammengefuehrt=6] + 9. [MAP-04*] Benutzer-/Rollen-Map freigeben (Trockenlauf-Ersatz fuer MAP-04) – erfolgreich (250 ms) + Zaehler: [benutzer_freigegeben=24 mit_ldap_bezug=6 rollen_freigegeben=24] +10. [COR-01] Tenant-Migration (Provisionierung der Mandanten-DBs) – erfolgreich (384 ms) + Zaehler: [tenants_migriert=4] +11. [SCHEMA-01] Core-Tenant-Schema ausrollen (TEN-07) – erfolgreich (1173 ms) + Zaehler: [core_tenant_migrationen_angewendet=84 tenants=4] +12. [SCHEMA-02] Modul-Schemata anlegen (DMS, Archive, Workflow) – erfolgreich (3399 ms) + Zaehler: [archive_migrationen_angewendet=72 dms_migrationen_angewendet=148 workflow_migrationen_angewendet=72] +13. [COR-02] Benutzer-Migration inkl. bcrypt-Hash 1:1 – erfolgreich (153 ms) + Zaehler: [benutzer_migriert=24] +14. [COR-04] LDAP-Feld-Migration pruefen – erfolgreich (20 ms) + Zaehler: [benutzer_mit_ldap_bezug_geprueft=6] +15. [COR-03] Rollen- und Rechte-Migration – erfolgreich (175 ms) + Zaehler: [rolle_tenant_admin=7 rolle_user=17 rollen_zugewiesen=24] +16. [COR-05] Audit-Log-Zusammenfuehrung – erfolgreich (382 ms) + Zaehler: [audit_ereignisse_migriert=24 staging_zeilen=24] + Hinweis: zeitstempel und aktion je ereignis gegen staging unveraendert +17. [DOK-01] Dokument- und Revisions-Migration – erfolgreich (780 ms) + Zaehler: [dokumente=21 revisionen=21 revisionen_uebersprungen=0 uebergabe_objekte=24] + Hinweis: cut017f8b14_praxis_fantasia: kein archivdms-mandant, stufe entfaellt +18. [DOK-02] Akten-/Ordnerstruktur-Migration – erfolgreich (192 ms) + Zaehler: [akten=9 akten_uebersprungen=0 dokumente_verknuepft=15] + Hinweis: cut017f8b14_praxis_fantasia: kein archivdms-mandant, stufe entfaellt +19. [DOK-04] Custom-Fields- und Klassifikationsvorlagen-Migration – erfolgreich (486 ms) + Zaehler: [felder=15 feldwerte=18 vorlagen=9] + Hinweis: cut017f8b14_praxis_fantasia: kein archivdms-mandant, stufe entfaellt +20. [DOK-03] ACL-Daten-Migration nach Core-Rechtemodell – erfolgreich (487 ms) + Zaehler: [core_gruppen=9 gruppenmitgliedschaften=15 objektrechte=48] + Hinweis: cut017f8b14_praxis_fantasia: kein archivdms-mandant, stufe entfaellt +21. [DOK-05] Freigabelinks-Migration – erfolgreich (90 ms) + Zaehler: [freigabelinks=6 freigabelinks_abgelaufen_verworfen=3] + Hinweis: cut017f8b14_praxis_fantasia: kein archivdms-mandant, stufe entfaellt +22. [MAI-02] Nachrichten- und Anhang-Migration – erfolgreich (70 ms) + Zaehler: [anhaenge=9 nachrichten=19 nachrichten_uebersprungen=0 uebergabe_objekte=19] + Hinweis: cut017f8b14_ingenieurbuero_erfundenhausen: kein archivmail-mandant, stufe entfaellt + Hinweis: suchindex im trockenlauf durch dateibasierten ersatz (ziel-objekte/mail-index) vertreten, siehe pruefprotokoll +23. [TAR-01] Retention-Fristen-Migration ohne Reset – erfolgreich (481 ms) + Zaehler: [fristen_geprueft=40 fristen_uebernommen=27 objekte_registriert=40] +24. [MAI-03] DSGVO-Loeschanfragen-Migration – erfolgreich (102 ms) + Zaehler: [einzelentscheidungen=9 historische_eintraege=0 offene_anfragen_verarbeitet=6] + Hinweis: cut017f8b14_ingenieurbuero_erfundenhausen: kein archivmail-mandant, stufe entfaellt +25. [MAI-04] Reconciliation-Reports-Archivierung – erfolgreich (92 ms) + Zaehler: [berichte=9 berichte_uebersprungen=0] + Hinweis: cut017f8b14_ingenieurbuero_erfundenhausen: kein archivmail-mandant, stufe entfaellt +26. [TAR-02] Vier-Augen-Loeschworkflow-Migration – erfolgreich (138 ms) + Zaehler: [abgebrochene_nicht_migriert=3 offene_loeschanfragen=3 vollzogene_loeschungen_audit=3] + Hinweis: cut017f8b14_praxis_fantasia: kein archivdms-mandant, stufe entfaellt +27. [WFL-01] Workflow-Historie-Migration – erfolgreich (251 ms) + Zaehler: [historieneintraege=21 vorgaenge_migriert=9 vorgaenge_offen_nicht_migriert=3] + Hinweis: cut017f8b14_kanzlei_musterhausen: 1 laufende vorgaenge muessen vor dem stichtag im altsystem abgeschlossen werden + Hinweis: cut017f8b14_steuerbuero_beispielstadt: 1 laufende vorgaenge muessen vor dem stichtag im altsystem abgeschlossen werden + Hinweis: cut017f8b14_praxis_fantasia: kein archivdms-mandant, stufe entfaellt + Hinweis: cut017f8b14_ingenieurbuero_erfundenhausen: 1 laufende vorgaenge muessen vor dem stichtag im altsystem abgeschlossen werden +28. [VAL-01] Zeilenzahl-Abgleich je Kerntabelle – erfolgreich (13 ms) + Zaehler: [quelle_documents=24 quelle_messages=19 quelle_users=24 vergleiche=12 ziel_documents=21 ziel_messages=19 ziel_users=24] +29. [VAL-02] Hash-Vergleich migrierter Binaerdateien – erfolgreich (52 ms) + Zaehler: [abweichungen=0 dms_dateien=15 mail_anhaenge=9] +30. [VAL-05] Login-Test mit migrierten Zugangsdaten – erfolgreich (5464 ms) + Zaehler: [falsche_passwoerter_abgewiesen=12 logins_erfolgreich=12] +31. [CHK-01] Modulgrenzen-uebergreifende Kettenpruefung – erfolgreich (65 ms) + Zaehler: [akten=9 befunde=0 dokumente_mit_retention=21 freigabelinks=6 nachrichten_mit_retention=19 offene_loeschanfragen=3 reconciliation_berichte=9 vollzogene_loeschungen=3 workflow_vorgaenge=9] diff --git a/docs/cut-01/iteration-2/lauf-2.1-val01-zeilenzahl-abgleich.md b/docs/cut-01/iteration-2/lauf-2.1-val01-zeilenzahl-abgleich.md new file mode 100644 index 00000000..3e60d94a --- /dev/null +++ b/docs/cut-01/iteration-2/lauf-2.1-val01-zeilenzahl-abgleich.md @@ -0,0 +1,16 @@ +# Zeilenzahl-Abgleich je Kerntabelle + +| Tenant | Tabelle | Quelle | Ziel | Differenz | Erklaert | Status | Begruendung | +|---|---|---|---|---|---|---|---| +| cut017f8b14_ingenieurbuero_erfundenhausen | documents | 8 | 7 | 1 | 1 | OK | im Altsystem bereits vollzogene Loeschung, nur Audit-Eintrag (TAR-02) | +| cut017f8b14_ingenieurbuero_erfundenhausen | messages | 0 | 0 | 0 | 0 | OK | - | +| cut017f8b14_ingenieurbuero_erfundenhausen | users | 5 | 5 | 0 | 0 | OK | - | +| cut017f8b14_kanzlei_musterhausen | documents | 8 | 7 | 1 | 1 | OK | im Altsystem bereits vollzogene Loeschung, nur Audit-Eintrag (TAR-02) | +| cut017f8b14_kanzlei_musterhausen | messages | 6 | 6 | 0 | 0 | OK | - | +| cut017f8b14_kanzlei_musterhausen | users | 7 | 7 | 0 | 0 | OK | - | +| cut017f8b14_praxis_fantasia | documents | 0 | 0 | 0 | 0 | OK | - | +| cut017f8b14_praxis_fantasia | messages | 7 | 7 | 0 | 0 | OK | - | +| cut017f8b14_praxis_fantasia | users | 5 | 5 | 0 | 0 | OK | - | +| cut017f8b14_steuerbuero_beispielstadt | documents | 8 | 7 | 1 | 1 | OK | im Altsystem bereits vollzogene Loeschung, nur Audit-Eintrag (TAR-02) | +| cut017f8b14_steuerbuero_beispielstadt | messages | 6 | 6 | 0 | 0 | OK | - | +| cut017f8b14_steuerbuero_beispielstadt | users | 7 | 7 | 0 | 0 | OK | - | diff --git a/docs/cut-01/iteration-2/lauf-2.1-val02-hashvergleich-cut017f8b14_ingenieurbuero_erfundenhausen.md b/docs/cut-01/iteration-2/lauf-2.1-val02-hashvergleich-cut017f8b14_ingenieurbuero_erfundenhausen.md new file mode 100644 index 00000000..a86f2d12 --- /dev/null +++ b/docs/cut-01/iteration-2/lauf-2.1-val02-hashvergleich-cut017f8b14_ingenieurbuero_erfundenhausen.md @@ -0,0 +1,11 @@ +# Hash-Vergleichsbericht: Mandant cut017f8b14_ingenieurbuero_erfundenhausen + +| Datei | Quell-Hash (SHA-256) | Ziel-Hash (SHA-256) | Ergebnis | +|---|---|---|---| +| cut017f8b14_ingenieurbuero_erfundenhausen/9f5caa7d-ce26-5dee-bf56-8f1141f42c11 | 9739fe94ad9e68f82603c65f9e9742bc660d9c7d7cea6e1426cf9cca212fa2ec | 9739fe94ad9e68f82603c65f9e9742bc660d9c7d7cea6e1426cf9cca212fa2ec | OK | +| cut017f8b14_ingenieurbuero_erfundenhausen/09d053fa-5fd4-523c-b06d-5685ac3a7415 | 263d8f1a626b397efc5b54d5b5cca2db9c022edb468eb0ba8db88e180330ee68 | 263d8f1a626b397efc5b54d5b5cca2db9c022edb468eb0ba8db88e180330ee68 | OK | +| cut017f8b14_ingenieurbuero_erfundenhausen/3ba7bfd8-3a8d-5754-94e0-80261e83b0c4 | 52e75f303ac84d26020e66dce23d186427768813b7e7884bf588d8ad86cd49d5 | 52e75f303ac84d26020e66dce23d186427768813b7e7884bf588d8ad86cd49d5 | OK | +| cut017f8b14_ingenieurbuero_erfundenhausen/ab49f53f-4f07-5be3-9305-d48343908b52 | d08a98fa681910e8543231b7b944231ee2ae184554d1d7c92c9a723eb6f00d63 | d08a98fa681910e8543231b7b944231ee2ae184554d1d7c92c9a723eb6f00d63 | OK | +| cut017f8b14_ingenieurbuero_erfundenhausen/c012f9b7-cd1a-5279-ad21-248d38442461 | b87b729fbc968ba41a9fff266b86fd42d251088e602ab78a91897f51a5c2d67d | b87b729fbc968ba41a9fff266b86fd42d251088e602ab78a91897f51a5c2d67d | OK | + +Stichprobe: 5 Dateien, 0 Abweichungen, 0 Fehler. diff --git a/docs/cut-01/iteration-2/lauf-2.1-val02-hashvergleich-cut017f8b14_kanzlei_musterhausen.md b/docs/cut-01/iteration-2/lauf-2.1-val02-hashvergleich-cut017f8b14_kanzlei_musterhausen.md new file mode 100644 index 00000000..fb01c0fc --- /dev/null +++ b/docs/cut-01/iteration-2/lauf-2.1-val02-hashvergleich-cut017f8b14_kanzlei_musterhausen.md @@ -0,0 +1,14 @@ +# Hash-Vergleichsbericht: Mandant cut017f8b14_kanzlei_musterhausen + +| Datei | Quell-Hash (SHA-256) | Ziel-Hash (SHA-256) | Ergebnis | +|---|---|---|---| +| cut017f8b14_kanzlei_musterhausen/a779942e-b551-5d8f-9073-5c2e9b1666cc | 76a0bf6a4f856b15343668c6340374e3b3ec62c27d0aeac248e7f828629f227e | 76a0bf6a4f856b15343668c6340374e3b3ec62c27d0aeac248e7f828629f227e | OK | +| cut017f8b14_kanzlei_musterhausen/11977af2-9943-50e7-9cba-5539f21fba89 | a711c9ccfdda0e9f10a739a6eee039710b08b0376c83c62e4c5b2669bc9fc0f4 | a711c9ccfdda0e9f10a739a6eee039710b08b0376c83c62e4c5b2669bc9fc0f4 | OK | +| cut017f8b14_kanzlei_musterhausen/c000878e-55e2-555f-8c07-96994114b6e3 | 7ee184bee3142957a0b1edae81fa5c675ea2d809942680115677983d61bc6082 | 7ee184bee3142957a0b1edae81fa5c675ea2d809942680115677983d61bc6082 | OK | +| cut017f8b14_kanzlei_musterhausen/7d517898-8c03-5739-a10d-35e726342aaf | 8f37d732b90a2ea079d79f27b1077a73e56fef492a94761efa296927952e0355 | 8f37d732b90a2ea079d79f27b1077a73e56fef492a94761efa296927952e0355 | OK | +| cut017f8b14_kanzlei_musterhausen/aa416387-4f99-57fc-9f00-d93f573c00c7 | d66baa994620dc936ac00952295ad1ead2e1d9260a0e0ee47a6588a587754f89 | d66baa994620dc936ac00952295ad1ead2e1d9260a0e0ee47a6588a587754f89 | OK | +| cut017f8b14_kanzlei_musterhausen/messages/mig-5c42a32b8c8c4de467dd212802f098a7/parts/0 | 91751d6fd6568a2d240f4035f084dfdecb63dc9fc41d6e0aab1469585201556a | 91751d6fd6568a2d240f4035f084dfdecb63dc9fc41d6e0aab1469585201556a | OK | +| cut017f8b14_kanzlei_musterhausen/messages/mig-ab874da042dbb570e09de407f06656f4/parts/0 | 74819a3fb832036c19322cd96218ec71529bf7538247a24a2ba6e00f68ddb408 | 74819a3fb832036c19322cd96218ec71529bf7538247a24a2ba6e00f68ddb408 | OK | +| cut017f8b14_kanzlei_musterhausen/messages/mig-ab874da042dbb570e09de407f06656f4/parts/1 | 933ea7ed9ebeb836de694a42f5304be415db5cbb62074df426e664330c642481 | 933ea7ed9ebeb836de694a42f5304be415db5cbb62074df426e664330c642481 | OK | + +Stichprobe: 8 Dateien, 0 Abweichungen, 0 Fehler. diff --git a/docs/cut-01/iteration-2/lauf-2.1-val02-hashvergleich-cut017f8b14_praxis_fantasia.md b/docs/cut-01/iteration-2/lauf-2.1-val02-hashvergleich-cut017f8b14_praxis_fantasia.md new file mode 100644 index 00000000..9c3b5679 --- /dev/null +++ b/docs/cut-01/iteration-2/lauf-2.1-val02-hashvergleich-cut017f8b14_praxis_fantasia.md @@ -0,0 +1,9 @@ +# Hash-Vergleichsbericht: Mandant cut017f8b14_praxis_fantasia + +| Datei | Quell-Hash (SHA-256) | Ziel-Hash (SHA-256) | Ergebnis | +|---|---|---|---| +| cut017f8b14_praxis_fantasia/messages/mig-3fbf5bbf5dc00daf9dd107911471a0ee/parts/0 | 26024e77f1f4a06d49f2c683fa2dcebfc3234887b26f0b5cfe32be00f0dda5c1 | 26024e77f1f4a06d49f2c683fa2dcebfc3234887b26f0b5cfe32be00f0dda5c1 | OK | +| cut017f8b14_praxis_fantasia/messages/mig-bd88ce4e949582ca44db0b25295d7206/parts/0 | 3a2c49492fe8c219a4b49ec2204e5fa9176b53f2d9fa5a5a283b2e49567f81a7 | 3a2c49492fe8c219a4b49ec2204e5fa9176b53f2d9fa5a5a283b2e49567f81a7 | OK | +| cut017f8b14_praxis_fantasia/messages/mig-bd88ce4e949582ca44db0b25295d7206/parts/1 | ed68a78a4f8c96f4b8ce0c47bc4a9e366571c06bea6641a750c7588a3e47f2d8 | ed68a78a4f8c96f4b8ce0c47bc4a9e366571c06bea6641a750c7588a3e47f2d8 | OK | + +Stichprobe: 3 Dateien, 0 Abweichungen, 0 Fehler. diff --git a/docs/cut-01/iteration-2/lauf-2.1-val02-hashvergleich-cut017f8b14_steuerbuero_beispielstadt.md b/docs/cut-01/iteration-2/lauf-2.1-val02-hashvergleich-cut017f8b14_steuerbuero_beispielstadt.md new file mode 100644 index 00000000..1d9ea8fc --- /dev/null +++ b/docs/cut-01/iteration-2/lauf-2.1-val02-hashvergleich-cut017f8b14_steuerbuero_beispielstadt.md @@ -0,0 +1,14 @@ +# Hash-Vergleichsbericht: Mandant cut017f8b14_steuerbuero_beispielstadt + +| Datei | Quell-Hash (SHA-256) | Ziel-Hash (SHA-256) | Ergebnis | +|---|---|---|---| +| cut017f8b14_steuerbuero_beispielstadt/9d630a9d-0424-5fbc-9d4b-534da0f1300c | 8b6e944d7dd4940ea7310fd7703591242548069a0dd51f29778fa83d0d9017fa | 8b6e944d7dd4940ea7310fd7703591242548069a0dd51f29778fa83d0d9017fa | OK | +| cut017f8b14_steuerbuero_beispielstadt/5a160e43-b3b3-54e1-93d1-f4d7993bff1f | f08beacdbbb5be09ffbafb8058a92f3dab0520e757c6483a22971c49ff7a9b58 | f08beacdbbb5be09ffbafb8058a92f3dab0520e757c6483a22971c49ff7a9b58 | OK | +| cut017f8b14_steuerbuero_beispielstadt/02b93e24-06f0-5486-bbf2-ce734fa06043 | 8fce59d9c0149e32e62e65beb7caee7a9e01a4f17afe6aebb233386c656a4755 | 8fce59d9c0149e32e62e65beb7caee7a9e01a4f17afe6aebb233386c656a4755 | OK | +| cut017f8b14_steuerbuero_beispielstadt/81669a6f-a392-5d45-bd16-0886a6acb82e | e37b1950fc6a04fbbf7c61680d34e6409a4b0c46448a3562bc7058b84333d160 | e37b1950fc6a04fbbf7c61680d34e6409a4b0c46448a3562bc7058b84333d160 | OK | +| cut017f8b14_steuerbuero_beispielstadt/1ed9d9f3-b598-5a7b-b00d-56b66a98ead7 | 874391dfe252371d0b53a78e73d6aa1ba98791c93b7fec937f1d281df6ada65d | 874391dfe252371d0b53a78e73d6aa1ba98791c93b7fec937f1d281df6ada65d | OK | +| cut017f8b14_steuerbuero_beispielstadt/messages/mig-dda2393a74f8db6d68be9459e15451aa/parts/0 | e9f522acce4e80578049e2eee4e09c5606e2c138ddfd9dcf8ca58c7d52782200 | e9f522acce4e80578049e2eee4e09c5606e2c138ddfd9dcf8ca58c7d52782200 | OK | +| cut017f8b14_steuerbuero_beispielstadt/messages/mig-cb7588459aaeeeead005a0436b0426d9/parts/0 | a8cd7d1636e3afa78808a4261f4a9856dcc970d5919f3313704f6d5c5623953d | a8cd7d1636e3afa78808a4261f4a9856dcc970d5919f3313704f6d5c5623953d | OK | +| cut017f8b14_steuerbuero_beispielstadt/messages/mig-cb7588459aaeeeead005a0436b0426d9/parts/1 | 6663f295f956e2f17ac5467f42fcc7db5017f340db694be991e73f00d610d41f | 6663f295f956e2f17ac5467f42fcc7db5017f340db694be991e73f00d610d41f | OK | + +Stichprobe: 8 Dateien, 0 Abweichungen, 0 Fehler. diff --git a/docs/cut-01/iteration-2/lauf-2.2-cut01d7d2a2.json b/docs/cut-01/iteration-2/lauf-2.2-cut01d7d2a2.json new file mode 100644 index 00000000..371afc8a --- /dev/null +++ b/docs/cut-01/iteration-2/lauf-2.2-cut01d7d2a2.json @@ -0,0 +1,555 @@ +{ + "lauf_kennung": "cut01d7d2a2", + "bezeichnung": "Iteration 2 / Lauf 2.2 (Commit 4fdbb9b)", + "gestartet": "2026-10-14T20:54:38.00195361Z", + "beendet": "2026-10-14T20:55:05.415408429Z", + "erfolgreich": true, + "schritte": [ + { + "id": "PREP-01", + "titel": "Arbeitsverzeichnis anlegen, Modulstufen bauen", + "status": "erfolgreich", + "gestartet": "2026-10-14T20:54:38.001974564Z", + "dauer_ms": 5361, + "zaehler": { + "modulstufen": 4 + }, + "hinweise": [ + "arbeitsverzeichnis /root/cut01-dryrun/cut01d7d2a2" + ] + }, + { + "id": "PREP-02", + "titel": "Synthetische Testkopie archivmail/archivdms erzeugen", + "haengt_ab_von": [ + "PREP-01" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:54:43.363047532Z", + "dauer_ms": 6799, + "zaehler": { + "archivdms_akten": 9, + "archivdms_audit": 12, + "archivdms_delete_requests": 9, + "archivdms_documents": 24, + "archivdms_shares": 15, + "archivdms_tenants": 3, + "archivdms_users": 15, + "archivdms_workflow_runs": 12, + "archivmail_attachments": 9, + "archivmail_audit": 12, + "archivmail_dsgvo": 9, + "archivmail_emails": 18, + "archivmail_reports": 9, + "archivmail_tenants": 3, + "archivmail_users": 15, + "binaerobjekte": 48 + }, + "hinweise": [ + "generator-seed 20261014, quelle ausschliesslich internal/cutoverorchestrator/dataset.go" + ] + }, + { + "id": "PREP-03", + "titel": "Anonymisierungsnachweis der Testkopie", + "haengt_ab_von": [ + "PREP-02" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:54:50.16227878Z", + "dauer_ms": 19, + "zaehler": { + "binaerobjekte": 48, + "e_mail_adressen": 93, + "ip_adressen": 24, + "personen": 30, + "tabellen": 30, + "verstoesse": 0, + "zeilen": 431 + }, + "hinweise": [ + "alle e-mail-adressen unter der reservierten domain .example (RFC 2606)", + "alle ip-adressen aus dokumentationsnetzen (RFC 5737)", + "alle konten mit synthetischem praefix, alle binaerobjekte mit marker SYNTHETISCH" + ] + }, + { + "id": "PREP-04", + "titel": "Core-Registry anlegen (Core-Migrationen)", + "haengt_ab_von": [ + "PREP-01" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:54:50.181953214Z", + "dauer_ms": 401, + "zaehler": { + "core_migrationen": 42 + } + }, + { + "id": "EXP-01", + "titel": "Export-Sichten der Altsysteme bereitstellen", + "haengt_ab_von": [ + "PREP-03" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:54:50.583047169Z", + "dauer_ms": 12, + "zaehler": { + "export_sichten": 4, + "plattformkonten_ohne_mandant": 0 + }, + "hinweise": [ + "archivmail-domain fuer MAP-01: erste registrierte domain je mandant (MAP-01 vergleicht genau eine domain)" + ] + }, + { + "id": "MAP-01", + "titel": "Tenant-Export und Slug/Domain-Abgleich", + "haengt_ab_von": [ + "EXP-01" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:54:50.596038992Z", + "dauer_ms": 12, + "zaehler": { + "archivdms_tenants": 3, + "archivmail_tenants": 3, + "kandidaten": 4, + "konflikte": 0, + "manuelle_zuordnungen": 1 + } + }, + { + "id": "MAP-02", + "titel": "Tenant-Migration-Map: Freigabe (Trockenlauf-Pruefer)", + "haengt_ab_von": [ + "MAP-01", + "PREP-04" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:54:50.608962436Z", + "dauer_ms": 67, + "zaehler": { + "eintraege_angelegt": 4, + "freigegeben": 4 + } + }, + { + "id": "MAP-03", + "titel": "User-Export und E-Mail-Abgleich je freigegebenem Tenant", + "haengt_ab_von": [ + "MAP-02" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:54:50.676113397Z", + "dauer_ms": 1, + "zaehler": { + "archivdms_users": 15, + "archivmail_users": 15, + "kandidaten": 24, + "konflikte": 0, + "zusammengefuehrt": 6 + } + }, + { + "id": "MAP-04*", + "titel": "Benutzer-/Rollen-Map freigeben (Trockenlauf-Ersatz fuer MAP-04)", + "haengt_ab_von": [ + "MAP-03" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:54:50.67808397Z", + "dauer_ms": 246, + "zaehler": { + "benutzer_freigegeben": 24, + "mit_ldap_bezug": 6, + "rollen_freigegeben": 24 + } + }, + { + "id": "COR-01", + "titel": "Tenant-Migration (Provisionierung der Mandanten-DBs)", + "haengt_ab_von": [ + "MAP-02" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:54:50.924911917Z", + "dauer_ms": 379, + "zaehler": { + "tenants_migriert": 4 + } + }, + { + "id": "SCHEMA-01", + "titel": "Core-Tenant-Schema ausrollen (TEN-07)", + "haengt_ab_von": [ + "COR-01" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:54:51.304456415Z", + "dauer_ms": 1199, + "zaehler": { + "core_tenant_migrationen_angewendet": 84, + "tenants": 4 + } + }, + { + "id": "SCHEMA-02", + "titel": "Modul-Schemata anlegen (DMS, Archive, Workflow)", + "haengt_ab_von": [ + "SCHEMA-01" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:54:52.504142805Z", + "dauer_ms": 3374, + "zaehler": { + "archive_migrationen_angewendet": 72, + "dms_migrationen_angewendet": 148, + "workflow_migrationen_angewendet": 72 + } + }, + { + "id": "COR-02", + "titel": "Benutzer-Migration inkl. bcrypt-Hash 1:1", + "haengt_ab_von": [ + "MAP-04*", + "SCHEMA-01" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:54:55.879041655Z", + "dauer_ms": 172, + "zaehler": { + "benutzer_migriert": 24 + } + }, + { + "id": "COR-04", + "titel": "LDAP-Feld-Migration pruefen", + "haengt_ab_von": [ + "COR-02" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:54:56.051096318Z", + "dauer_ms": 24, + "zaehler": { + "benutzer_mit_ldap_bezug_geprueft": 6 + } + }, + { + "id": "COR-03", + "titel": "Rollen- und Rechte-Migration", + "haengt_ab_von": [ + "COR-02" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:54:56.07582329Z", + "dauer_ms": 170, + "zaehler": { + "rolle_tenant_admin": 7, + "rolle_user": 17, + "rollen_zugewiesen": 24 + } + }, + { + "id": "COR-05", + "titel": "Audit-Log-Zusammenfuehrung", + "haengt_ab_von": [ + "MAP-04*", + "COR-01" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:54:56.246391695Z", + "dauer_ms": 298, + "zaehler": { + "audit_ereignisse_migriert": 24, + "staging_zeilen": 24 + }, + "hinweise": [ + "zeitstempel und aktion je ereignis gegen staging unveraendert" + ] + }, + { + "id": "DOK-01", + "titel": "Dokument- und Revisions-Migration", + "haengt_ab_von": [ + "COR-02", + "SCHEMA-02" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:54:56.544854606Z", + "dauer_ms": 456, + "zaehler": { + "dokumente": 21, + "revisionen": 21, + "revisionen_uebersprungen": 0, + "uebergabe_objekte": 24 + }, + "hinweise": [ + "cut01d7d2a2_praxis_fantasia: kein archivdms-mandant, stufe entfaellt" + ] + }, + { + "id": "DOK-02", + "titel": "Akten-/Ordnerstruktur-Migration", + "haengt_ab_von": [ + "DOK-01" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:54:57.001630278Z", + "dauer_ms": 176, + "zaehler": { + "akten": 9, + "akten_uebersprungen": 0, + "dokumente_verknuepft": 15 + }, + "hinweise": [ + "cut01d7d2a2_praxis_fantasia: kein archivdms-mandant, stufe entfaellt" + ] + }, + { + "id": "DOK-04", + "titel": "Custom-Fields- und Klassifikationsvorlagen-Migration", + "haengt_ab_von": [ + "DOK-01" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:54:57.178488059Z", + "dauer_ms": 461, + "zaehler": { + "felder": 15, + "feldwerte": 18, + "vorlagen": 9 + }, + "hinweise": [ + "cut01d7d2a2_praxis_fantasia: kein archivdms-mandant, stufe entfaellt" + ] + }, + { + "id": "DOK-03", + "titel": "ACL-Daten-Migration nach Core-Rechtemodell", + "haengt_ab_von": [ + "DOK-01", + "COR-03" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:54:57.640399037Z", + "dauer_ms": 408, + "zaehler": { + "core_gruppen": 9, + "gruppenmitgliedschaften": 15, + "objektrechte": 48 + }, + "hinweise": [ + "cut01d7d2a2_praxis_fantasia: kein archivdms-mandant, stufe entfaellt" + ] + }, + { + "id": "DOK-05", + "titel": "Freigabelinks-Migration", + "haengt_ab_von": [ + "DOK-01" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:54:58.048496407Z", + "dauer_ms": 78, + "zaehler": { + "freigabelinks": 6, + "freigabelinks_abgelaufen_verworfen": 3 + }, + "hinweise": [ + "cut01d7d2a2_praxis_fantasia: kein archivdms-mandant, stufe entfaellt" + ] + }, + { + "id": "MAI-02", + "titel": "Nachrichten- und Anhang-Migration", + "haengt_ab_von": [ + "COR-01" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:54:58.126793783Z", + "dauer_ms": 99, + "zaehler": { + "anhaenge": 9, + "nachrichten": 19, + "nachrichten_uebersprungen": 0, + "uebergabe_objekte": 19 + }, + "hinweise": [ + "cut01d7d2a2_ingenieurbuero_erfundenhausen: kein archivmail-mandant, stufe entfaellt", + "suchindex im trockenlauf durch dateibasierten ersatz (ziel-objekte/mail-index) vertreten, siehe pruefprotokoll" + ] + }, + { + "id": "TAR-01", + "titel": "Retention-Fristen-Migration ohne Reset", + "haengt_ab_von": [ + "DOK-01", + "MAI-02", + "SCHEMA-02" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:54:58.226332874Z", + "dauer_ms": 405, + "zaehler": { + "fristen_geprueft": 40, + "fristen_uebernommen": 27, + "objekte_registriert": 40 + } + }, + { + "id": "MAI-03", + "titel": "DSGVO-Loeschanfragen-Migration", + "haengt_ab_von": [ + "TAR-01" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:54:58.632067065Z", + "dauer_ms": 106, + "zaehler": { + "einzelentscheidungen": 9, + "historische_eintraege": 0, + "offene_anfragen_verarbeitet": 6 + }, + "hinweise": [ + "cut01d7d2a2_ingenieurbuero_erfundenhausen: kein archivmail-mandant, stufe entfaellt" + ] + }, + { + "id": "MAI-04", + "titel": "Reconciliation-Reports-Archivierung", + "haengt_ab_von": [ + "SCHEMA-02" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:54:58.738701138Z", + "dauer_ms": 75, + "zaehler": { + "berichte": 9, + "berichte_uebersprungen": 0 + }, + "hinweise": [ + "cut01d7d2a2_ingenieurbuero_erfundenhausen: kein archivmail-mandant, stufe entfaellt" + ] + }, + { + "id": "TAR-02", + "titel": "Vier-Augen-Loeschworkflow-Migration", + "haengt_ab_von": [ + "TAR-01", + "COR-02" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:54:58.814358815Z", + "dauer_ms": 111, + "zaehler": { + "abgebrochene_nicht_migriert": 3, + "offene_loeschanfragen": 3, + "vollzogene_loeschungen_audit": 3 + }, + "hinweise": [ + "cut01d7d2a2_praxis_fantasia: kein archivdms-mandant, stufe entfaellt" + ] + }, + { + "id": "WFL-01", + "titel": "Workflow-Historie-Migration", + "haengt_ab_von": [ + "COR-02", + "SCHEMA-02" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:54:58.926068826Z", + "dauer_ms": 215, + "zaehler": { + "historieneintraege": 21, + "vorgaenge_migriert": 9, + "vorgaenge_offen_nicht_migriert": 3 + }, + "hinweise": [ + "cut01d7d2a2_kanzlei_musterhausen: 1 laufende vorgaenge muessen vor dem stichtag im altsystem abgeschlossen werden", + "cut01d7d2a2_steuerbuero_beispielstadt: 1 laufende vorgaenge muessen vor dem stichtag im altsystem abgeschlossen werden", + "cut01d7d2a2_praxis_fantasia: kein archivdms-mandant, stufe entfaellt", + "cut01d7d2a2_ingenieurbuero_erfundenhausen: 1 laufende vorgaenge muessen vor dem stichtag im altsystem abgeschlossen werden" + ] + }, + { + "id": "VAL-01", + "titel": "Zeilenzahl-Abgleich je Kerntabelle", + "haengt_ab_von": [ + "COR-02", + "DOK-01", + "MAI-02" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:54:59.142060837Z", + "dauer_ms": 13, + "zaehler": { + "quelle_documents": 24, + "quelle_messages": 19, + "quelle_users": 24, + "vergleiche": 12, + "ziel_documents": 21, + "ziel_messages": 19, + "ziel_users": 24 + } + }, + { + "id": "VAL-02", + "titel": "Hash-Vergleich migrierter Binaerdateien", + "haengt_ab_von": [ + "DOK-01", + "MAI-02" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:54:59.156065221Z", + "dauer_ms": 60, + "zaehler": { + "abweichungen": 0, + "dms_dateien": 15, + "mail_anhaenge": 9 + } + }, + { + "id": "VAL-05", + "titel": "Login-Test mit migrierten Zugangsdaten", + "haengt_ab_von": [ + "COR-02" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:54:59.216868135Z", + "dauer_ms": 6119, + "zaehler": { + "falsche_passwoerter_abgewiesen": 12, + "logins_erfolgreich": 12 + } + }, + { + "id": "CHK-01", + "titel": "Modulgrenzen-uebergreifende Kettenpruefung", + "haengt_ab_von": [ + "DOK-03", + "DOK-05", + "TAR-02", + "MAI-03", + "MAI-04", + "WFL-01" + ], + "status": "erfolgreich", + "gestartet": "2026-10-14T20:55:05.336524344Z", + "dauer_ms": 78, + "zaehler": { + "akten": 9, + "befunde": 0, + "dokumente_mit_retention": 21, + "freigabelinks": 6, + "nachrichten_mit_retention": 19, + "offene_loeschanfragen": 3, + "reconciliation_berichte": 9, + "vollzogene_loeschungen": 3, + "workflow_vorgaenge": 9 + } + } + ] +} diff --git a/docs/cut-01/iteration-2/lauf-2.2-cut01d7d2a2.txt b/docs/cut-01/iteration-2/lauf-2.2-cut01d7d2a2.txt new file mode 100644 index 00000000..302ebf17 --- /dev/null +++ b/docs/cut-01/iteration-2/lauf-2.2-cut01d7d2a2.txt @@ -0,0 +1,88 @@ +CUT-01 Trockenlauf-Protokoll +Lauf: cut01d7d2a2 (Iteration 2 / Lauf 2.2 (Commit 4fdbb9b)) +Beginn: 2026-10-14 20:54:38 UTC Ende: 2026-10-14 20:55:05 UTC Dauer: 27.413s +Ergebnis: ERFOLGREICH – alle 31 Schritte ohne Abbruch durchgelaufen + + 1. [PREP-01] Arbeitsverzeichnis anlegen, Modulstufen bauen – erfolgreich (5361 ms) + Zaehler: [modulstufen=4] + Hinweis: arbeitsverzeichnis /root/cut01-dryrun/cut01d7d2a2 + 2. [PREP-02] Synthetische Testkopie archivmail/archivdms erzeugen – erfolgreich (6799 ms) + Zaehler: [archivdms_akten=9 archivdms_audit=12 archivdms_delete_requests=9 archivdms_documents=24 archivdms_shares=15 archivdms_tenants=3 archivdms_users=15 archivdms_workflow_runs=12 archivmail_attachments=9 archivmail_audit=12 archivmail_dsgvo=9 archivmail_emails=18 archivmail_reports=9 archivmail_tenants=3 archivmail_users=15 binaerobjekte=48] + Hinweis: generator-seed 20261014, quelle ausschliesslich internal/cutoverorchestrator/dataset.go + 3. [PREP-03] Anonymisierungsnachweis der Testkopie – erfolgreich (19 ms) + Zaehler: [binaerobjekte=48 e_mail_adressen=93 ip_adressen=24 personen=30 tabellen=30 verstoesse=0 zeilen=431] + Hinweis: alle e-mail-adressen unter der reservierten domain .example (RFC 2606) + Hinweis: alle ip-adressen aus dokumentationsnetzen (RFC 5737) + Hinweis: alle konten mit synthetischem praefix, alle binaerobjekte mit marker SYNTHETISCH + 4. [PREP-04] Core-Registry anlegen (Core-Migrationen) – erfolgreich (401 ms) + Zaehler: [core_migrationen=42] + 5. [EXP-01] Export-Sichten der Altsysteme bereitstellen – erfolgreich (12 ms) + Zaehler: [export_sichten=4 plattformkonten_ohne_mandant=0] + Hinweis: archivmail-domain fuer MAP-01: erste registrierte domain je mandant (MAP-01 vergleicht genau eine domain) + 6. [MAP-01] Tenant-Export und Slug/Domain-Abgleich – erfolgreich (12 ms) + Zaehler: [archivdms_tenants=3 archivmail_tenants=3 kandidaten=4 konflikte=0 manuelle_zuordnungen=1] + 7. [MAP-02] Tenant-Migration-Map: Freigabe (Trockenlauf-Pruefer) – erfolgreich (67 ms) + Zaehler: [eintraege_angelegt=4 freigegeben=4] + 8. [MAP-03] User-Export und E-Mail-Abgleich je freigegebenem Tenant – erfolgreich (1 ms) + Zaehler: [archivdms_users=15 archivmail_users=15 kandidaten=24 konflikte=0 zusammengefuehrt=6] + 9. [MAP-04*] Benutzer-/Rollen-Map freigeben (Trockenlauf-Ersatz fuer MAP-04) – erfolgreich (246 ms) + Zaehler: [benutzer_freigegeben=24 mit_ldap_bezug=6 rollen_freigegeben=24] +10. [COR-01] Tenant-Migration (Provisionierung der Mandanten-DBs) – erfolgreich (379 ms) + Zaehler: [tenants_migriert=4] +11. [SCHEMA-01] Core-Tenant-Schema ausrollen (TEN-07) – erfolgreich (1199 ms) + Zaehler: [core_tenant_migrationen_angewendet=84 tenants=4] +12. [SCHEMA-02] Modul-Schemata anlegen (DMS, Archive, Workflow) – erfolgreich (3374 ms) + Zaehler: [archive_migrationen_angewendet=72 dms_migrationen_angewendet=148 workflow_migrationen_angewendet=72] +13. [COR-02] Benutzer-Migration inkl. bcrypt-Hash 1:1 – erfolgreich (172 ms) + Zaehler: [benutzer_migriert=24] +14. [COR-04] LDAP-Feld-Migration pruefen – erfolgreich (24 ms) + Zaehler: [benutzer_mit_ldap_bezug_geprueft=6] +15. [COR-03] Rollen- und Rechte-Migration – erfolgreich (170 ms) + Zaehler: [rolle_tenant_admin=7 rolle_user=17 rollen_zugewiesen=24] +16. [COR-05] Audit-Log-Zusammenfuehrung – erfolgreich (298 ms) + Zaehler: [audit_ereignisse_migriert=24 staging_zeilen=24] + Hinweis: zeitstempel und aktion je ereignis gegen staging unveraendert +17. [DOK-01] Dokument- und Revisions-Migration – erfolgreich (456 ms) + Zaehler: [dokumente=21 revisionen=21 revisionen_uebersprungen=0 uebergabe_objekte=24] + Hinweis: cut01d7d2a2_praxis_fantasia: kein archivdms-mandant, stufe entfaellt +18. [DOK-02] Akten-/Ordnerstruktur-Migration – erfolgreich (176 ms) + Zaehler: [akten=9 akten_uebersprungen=0 dokumente_verknuepft=15] + Hinweis: cut01d7d2a2_praxis_fantasia: kein archivdms-mandant, stufe entfaellt +19. [DOK-04] Custom-Fields- und Klassifikationsvorlagen-Migration – erfolgreich (461 ms) + Zaehler: [felder=15 feldwerte=18 vorlagen=9] + Hinweis: cut01d7d2a2_praxis_fantasia: kein archivdms-mandant, stufe entfaellt +20. [DOK-03] ACL-Daten-Migration nach Core-Rechtemodell – erfolgreich (408 ms) + Zaehler: [core_gruppen=9 gruppenmitgliedschaften=15 objektrechte=48] + Hinweis: cut01d7d2a2_praxis_fantasia: kein archivdms-mandant, stufe entfaellt +21. [DOK-05] Freigabelinks-Migration – erfolgreich (78 ms) + Zaehler: [freigabelinks=6 freigabelinks_abgelaufen_verworfen=3] + Hinweis: cut01d7d2a2_praxis_fantasia: kein archivdms-mandant, stufe entfaellt +22. [MAI-02] Nachrichten- und Anhang-Migration – erfolgreich (99 ms) + Zaehler: [anhaenge=9 nachrichten=19 nachrichten_uebersprungen=0 uebergabe_objekte=19] + Hinweis: cut01d7d2a2_ingenieurbuero_erfundenhausen: kein archivmail-mandant, stufe entfaellt + Hinweis: suchindex im trockenlauf durch dateibasierten ersatz (ziel-objekte/mail-index) vertreten, siehe pruefprotokoll +23. [TAR-01] Retention-Fristen-Migration ohne Reset – erfolgreich (405 ms) + Zaehler: [fristen_geprueft=40 fristen_uebernommen=27 objekte_registriert=40] +24. [MAI-03] DSGVO-Loeschanfragen-Migration – erfolgreich (106 ms) + Zaehler: [einzelentscheidungen=9 historische_eintraege=0 offene_anfragen_verarbeitet=6] + Hinweis: cut01d7d2a2_ingenieurbuero_erfundenhausen: kein archivmail-mandant, stufe entfaellt +25. [MAI-04] Reconciliation-Reports-Archivierung – erfolgreich (75 ms) + Zaehler: [berichte=9 berichte_uebersprungen=0] + Hinweis: cut01d7d2a2_ingenieurbuero_erfundenhausen: kein archivmail-mandant, stufe entfaellt +26. [TAR-02] Vier-Augen-Loeschworkflow-Migration – erfolgreich (111 ms) + Zaehler: [abgebrochene_nicht_migriert=3 offene_loeschanfragen=3 vollzogene_loeschungen_audit=3] + Hinweis: cut01d7d2a2_praxis_fantasia: kein archivdms-mandant, stufe entfaellt +27. [WFL-01] Workflow-Historie-Migration – erfolgreich (215 ms) + Zaehler: [historieneintraege=21 vorgaenge_migriert=9 vorgaenge_offen_nicht_migriert=3] + Hinweis: cut01d7d2a2_kanzlei_musterhausen: 1 laufende vorgaenge muessen vor dem stichtag im altsystem abgeschlossen werden + Hinweis: cut01d7d2a2_steuerbuero_beispielstadt: 1 laufende vorgaenge muessen vor dem stichtag im altsystem abgeschlossen werden + Hinweis: cut01d7d2a2_praxis_fantasia: kein archivdms-mandant, stufe entfaellt + Hinweis: cut01d7d2a2_ingenieurbuero_erfundenhausen: 1 laufende vorgaenge muessen vor dem stichtag im altsystem abgeschlossen werden +28. [VAL-01] Zeilenzahl-Abgleich je Kerntabelle – erfolgreich (13 ms) + Zaehler: [quelle_documents=24 quelle_messages=19 quelle_users=24 vergleiche=12 ziel_documents=21 ziel_messages=19 ziel_users=24] +29. [VAL-02] Hash-Vergleich migrierter Binaerdateien – erfolgreich (60 ms) + Zaehler: [abweichungen=0 dms_dateien=15 mail_anhaenge=9] +30. [VAL-05] Login-Test mit migrierten Zugangsdaten – erfolgreich (6119 ms) + Zaehler: [falsche_passwoerter_abgewiesen=12 logins_erfolgreich=12] +31. [CHK-01] Modulgrenzen-uebergreifende Kettenpruefung – erfolgreich (78 ms) + Zaehler: [akten=9 befunde=0 dokumente_mit_retention=21 freigabelinks=6 nachrichten_mit_retention=19 offene_loeschanfragen=3 reconciliation_berichte=9 vollzogene_loeschungen=3 workflow_vorgaenge=9] diff --git a/docs/cut-01/iteration-2/lauf-2.2-val01-zeilenzahl-abgleich.md b/docs/cut-01/iteration-2/lauf-2.2-val01-zeilenzahl-abgleich.md new file mode 100644 index 00000000..6651e8b8 --- /dev/null +++ b/docs/cut-01/iteration-2/lauf-2.2-val01-zeilenzahl-abgleich.md @@ -0,0 +1,16 @@ +# Zeilenzahl-Abgleich je Kerntabelle + +| Tenant | Tabelle | Quelle | Ziel | Differenz | Erklaert | Status | Begruendung | +|---|---|---|---|---|---|---|---| +| cut01d7d2a2_ingenieurbuero_erfundenhausen | documents | 8 | 7 | 1 | 1 | OK | im Altsystem bereits vollzogene Loeschung, nur Audit-Eintrag (TAR-02) | +| cut01d7d2a2_ingenieurbuero_erfundenhausen | messages | 0 | 0 | 0 | 0 | OK | - | +| cut01d7d2a2_ingenieurbuero_erfundenhausen | users | 5 | 5 | 0 | 0 | OK | - | +| cut01d7d2a2_kanzlei_musterhausen | documents | 8 | 7 | 1 | 1 | OK | im Altsystem bereits vollzogene Loeschung, nur Audit-Eintrag (TAR-02) | +| cut01d7d2a2_kanzlei_musterhausen | messages | 6 | 6 | 0 | 0 | OK | - | +| cut01d7d2a2_kanzlei_musterhausen | users | 7 | 7 | 0 | 0 | OK | - | +| cut01d7d2a2_praxis_fantasia | documents | 0 | 0 | 0 | 0 | OK | - | +| cut01d7d2a2_praxis_fantasia | messages | 7 | 7 | 0 | 0 | OK | - | +| cut01d7d2a2_praxis_fantasia | users | 5 | 5 | 0 | 0 | OK | - | +| cut01d7d2a2_steuerbuero_beispielstadt | documents | 8 | 7 | 1 | 1 | OK | im Altsystem bereits vollzogene Loeschung, nur Audit-Eintrag (TAR-02) | +| cut01d7d2a2_steuerbuero_beispielstadt | messages | 6 | 6 | 0 | 0 | OK | - | +| cut01d7d2a2_steuerbuero_beispielstadt | users | 7 | 7 | 0 | 0 | OK | - | diff --git a/docs/cut-01/iteration-2/lauf-2.2-val02-hashvergleich-cut01d7d2a2_ingenieurbuero_erfundenhausen.md b/docs/cut-01/iteration-2/lauf-2.2-val02-hashvergleich-cut01d7d2a2_ingenieurbuero_erfundenhausen.md new file mode 100644 index 00000000..14999844 --- /dev/null +++ b/docs/cut-01/iteration-2/lauf-2.2-val02-hashvergleich-cut01d7d2a2_ingenieurbuero_erfundenhausen.md @@ -0,0 +1,11 @@ +# Hash-Vergleichsbericht: Mandant cut01d7d2a2_ingenieurbuero_erfundenhausen + +| Datei | Quell-Hash (SHA-256) | Ziel-Hash (SHA-256) | Ergebnis | +|---|---|---|---| +| cut01d7d2a2_ingenieurbuero_erfundenhausen/5fa55479-709b-50fc-bb4a-4e1d72f258ad | 9739fe94ad9e68f82603c65f9e9742bc660d9c7d7cea6e1426cf9cca212fa2ec | 9739fe94ad9e68f82603c65f9e9742bc660d9c7d7cea6e1426cf9cca212fa2ec | OK | +| cut01d7d2a2_ingenieurbuero_erfundenhausen/9929fb0f-7a91-54f7-b1fb-b47106a70b5d | 263d8f1a626b397efc5b54d5b5cca2db9c022edb468eb0ba8db88e180330ee68 | 263d8f1a626b397efc5b54d5b5cca2db9c022edb468eb0ba8db88e180330ee68 | OK | +| cut01d7d2a2_ingenieurbuero_erfundenhausen/3afe5a33-2728-5edd-a5ab-8374163a5afa | 52e75f303ac84d26020e66dce23d186427768813b7e7884bf588d8ad86cd49d5 | 52e75f303ac84d26020e66dce23d186427768813b7e7884bf588d8ad86cd49d5 | OK | +| cut01d7d2a2_ingenieurbuero_erfundenhausen/6dd21d9a-1324-5c89-8ad8-9aa243825d8d | d08a98fa681910e8543231b7b944231ee2ae184554d1d7c92c9a723eb6f00d63 | d08a98fa681910e8543231b7b944231ee2ae184554d1d7c92c9a723eb6f00d63 | OK | +| cut01d7d2a2_ingenieurbuero_erfundenhausen/abbea734-500a-5e83-9e36-b45f79484395 | b87b729fbc968ba41a9fff266b86fd42d251088e602ab78a91897f51a5c2d67d | b87b729fbc968ba41a9fff266b86fd42d251088e602ab78a91897f51a5c2d67d | OK | + +Stichprobe: 5 Dateien, 0 Abweichungen, 0 Fehler. diff --git a/docs/cut-01/iteration-2/lauf-2.2-val02-hashvergleich-cut01d7d2a2_kanzlei_musterhausen.md b/docs/cut-01/iteration-2/lauf-2.2-val02-hashvergleich-cut01d7d2a2_kanzlei_musterhausen.md new file mode 100644 index 00000000..fc584cac --- /dev/null +++ b/docs/cut-01/iteration-2/lauf-2.2-val02-hashvergleich-cut01d7d2a2_kanzlei_musterhausen.md @@ -0,0 +1,14 @@ +# Hash-Vergleichsbericht: Mandant cut01d7d2a2_kanzlei_musterhausen + +| Datei | Quell-Hash (SHA-256) | Ziel-Hash (SHA-256) | Ergebnis | +|---|---|---|---| +| cut01d7d2a2_kanzlei_musterhausen/5c90b9d4-8b9c-563f-802e-f712eba55f7f | 76a0bf6a4f856b15343668c6340374e3b3ec62c27d0aeac248e7f828629f227e | 76a0bf6a4f856b15343668c6340374e3b3ec62c27d0aeac248e7f828629f227e | OK | +| cut01d7d2a2_kanzlei_musterhausen/6598fdf2-e945-5f32-a59f-be9ea3063c3a | a711c9ccfdda0e9f10a739a6eee039710b08b0376c83c62e4c5b2669bc9fc0f4 | a711c9ccfdda0e9f10a739a6eee039710b08b0376c83c62e4c5b2669bc9fc0f4 | OK | +| cut01d7d2a2_kanzlei_musterhausen/ff94f333-f60a-5c91-b5e3-31eac6c210f3 | 7ee184bee3142957a0b1edae81fa5c675ea2d809942680115677983d61bc6082 | 7ee184bee3142957a0b1edae81fa5c675ea2d809942680115677983d61bc6082 | OK | +| cut01d7d2a2_kanzlei_musterhausen/4fa047d4-7766-508c-8141-f32b485ec0bc | 8f37d732b90a2ea079d79f27b1077a73e56fef492a94761efa296927952e0355 | 8f37d732b90a2ea079d79f27b1077a73e56fef492a94761efa296927952e0355 | OK | +| cut01d7d2a2_kanzlei_musterhausen/f826412b-6e79-57a9-9e15-2b94c43d0c2e | d66baa994620dc936ac00952295ad1ead2e1d9260a0e0ee47a6588a587754f89 | d66baa994620dc936ac00952295ad1ead2e1d9260a0e0ee47a6588a587754f89 | OK | +| cut01d7d2a2_kanzlei_musterhausen/messages/mig-f4fdc7e5c1bc7df3412f83fe079094d4/parts/0 | 91751d6fd6568a2d240f4035f084dfdecb63dc9fc41d6e0aab1469585201556a | 91751d6fd6568a2d240f4035f084dfdecb63dc9fc41d6e0aab1469585201556a | OK | +| cut01d7d2a2_kanzlei_musterhausen/messages/mig-a801b5c366c7a2b74d052a38bc031a0d/parts/0 | 74819a3fb832036c19322cd96218ec71529bf7538247a24a2ba6e00f68ddb408 | 74819a3fb832036c19322cd96218ec71529bf7538247a24a2ba6e00f68ddb408 | OK | +| cut01d7d2a2_kanzlei_musterhausen/messages/mig-a801b5c366c7a2b74d052a38bc031a0d/parts/1 | 933ea7ed9ebeb836de694a42f5304be415db5cbb62074df426e664330c642481 | 933ea7ed9ebeb836de694a42f5304be415db5cbb62074df426e664330c642481 | OK | + +Stichprobe: 8 Dateien, 0 Abweichungen, 0 Fehler. diff --git a/docs/cut-01/iteration-2/lauf-2.2-val02-hashvergleich-cut01d7d2a2_praxis_fantasia.md b/docs/cut-01/iteration-2/lauf-2.2-val02-hashvergleich-cut01d7d2a2_praxis_fantasia.md new file mode 100644 index 00000000..5ae57ac6 --- /dev/null +++ b/docs/cut-01/iteration-2/lauf-2.2-val02-hashvergleich-cut01d7d2a2_praxis_fantasia.md @@ -0,0 +1,9 @@ +# Hash-Vergleichsbericht: Mandant cut01d7d2a2_praxis_fantasia + +| Datei | Quell-Hash (SHA-256) | Ziel-Hash (SHA-256) | Ergebnis | +|---|---|---|---| +| cut01d7d2a2_praxis_fantasia/messages/mig-56abab033555e94bb89512bddcec6606/parts/0 | 26024e77f1f4a06d49f2c683fa2dcebfc3234887b26f0b5cfe32be00f0dda5c1 | 26024e77f1f4a06d49f2c683fa2dcebfc3234887b26f0b5cfe32be00f0dda5c1 | OK | +| cut01d7d2a2_praxis_fantasia/messages/mig-b6722e823fd59249f1d08586c13da54a/parts/0 | 3a2c49492fe8c219a4b49ec2204e5fa9176b53f2d9fa5a5a283b2e49567f81a7 | 3a2c49492fe8c219a4b49ec2204e5fa9176b53f2d9fa5a5a283b2e49567f81a7 | OK | +| cut01d7d2a2_praxis_fantasia/messages/mig-b6722e823fd59249f1d08586c13da54a/parts/1 | ed68a78a4f8c96f4b8ce0c47bc4a9e366571c06bea6641a750c7588a3e47f2d8 | ed68a78a4f8c96f4b8ce0c47bc4a9e366571c06bea6641a750c7588a3e47f2d8 | OK | + +Stichprobe: 3 Dateien, 0 Abweichungen, 0 Fehler. diff --git a/docs/cut-01/iteration-2/lauf-2.2-val02-hashvergleich-cut01d7d2a2_steuerbuero_beispielstadt.md b/docs/cut-01/iteration-2/lauf-2.2-val02-hashvergleich-cut01d7d2a2_steuerbuero_beispielstadt.md new file mode 100644 index 00000000..8070e576 --- /dev/null +++ b/docs/cut-01/iteration-2/lauf-2.2-val02-hashvergleich-cut01d7d2a2_steuerbuero_beispielstadt.md @@ -0,0 +1,14 @@ +# Hash-Vergleichsbericht: Mandant cut01d7d2a2_steuerbuero_beispielstadt + +| Datei | Quell-Hash (SHA-256) | Ziel-Hash (SHA-256) | Ergebnis | +|---|---|---|---| +| cut01d7d2a2_steuerbuero_beispielstadt/ac3f9720-0727-5dfe-b87c-2e682d8464c1 | 8b6e944d7dd4940ea7310fd7703591242548069a0dd51f29778fa83d0d9017fa | 8b6e944d7dd4940ea7310fd7703591242548069a0dd51f29778fa83d0d9017fa | OK | +| cut01d7d2a2_steuerbuero_beispielstadt/08b4f6a4-d525-516a-b8d2-ec4c01971a1d | f08beacdbbb5be09ffbafb8058a92f3dab0520e757c6483a22971c49ff7a9b58 | f08beacdbbb5be09ffbafb8058a92f3dab0520e757c6483a22971c49ff7a9b58 | OK | +| cut01d7d2a2_steuerbuero_beispielstadt/d18c7e75-9871-53ee-9a47-323dea8e4269 | 8fce59d9c0149e32e62e65beb7caee7a9e01a4f17afe6aebb233386c656a4755 | 8fce59d9c0149e32e62e65beb7caee7a9e01a4f17afe6aebb233386c656a4755 | OK | +| cut01d7d2a2_steuerbuero_beispielstadt/fbf67d7f-0105-5d06-ac79-3d05f3de160d | e37b1950fc6a04fbbf7c61680d34e6409a4b0c46448a3562bc7058b84333d160 | e37b1950fc6a04fbbf7c61680d34e6409a4b0c46448a3562bc7058b84333d160 | OK | +| cut01d7d2a2_steuerbuero_beispielstadt/beaa8b3a-4784-52fc-9afd-7794071665e6 | 874391dfe252371d0b53a78e73d6aa1ba98791c93b7fec937f1d281df6ada65d | 874391dfe252371d0b53a78e73d6aa1ba98791c93b7fec937f1d281df6ada65d | OK | +| cut01d7d2a2_steuerbuero_beispielstadt/messages/mig-bc0db504d27ad4e942968e8ccc87ae32/parts/0 | e9f522acce4e80578049e2eee4e09c5606e2c138ddfd9dcf8ca58c7d52782200 | e9f522acce4e80578049e2eee4e09c5606e2c138ddfd9dcf8ca58c7d52782200 | OK | +| cut01d7d2a2_steuerbuero_beispielstadt/messages/mig-c4f9000fec46a0ea7786a9a5d7acdb38/parts/0 | a8cd7d1636e3afa78808a4261f4a9856dcc970d5919f3313704f6d5c5623953d | a8cd7d1636e3afa78808a4261f4a9856dcc970d5919f3313704f6d5c5623953d | OK | +| cut01d7d2a2_steuerbuero_beispielstadt/messages/mig-c4f9000fec46a0ea7786a9a5d7acdb38/parts/1 | 6663f295f956e2f17ac5467f42fcc7db5017f340db694be991e73f00d610d41f | 6663f295f956e2f17ac5467f42fcc7db5017f340db694be991e73f00d610d41f | OK | + +Stichprobe: 8 Dateien, 0 Abweichungen, 0 Fehler. diff --git a/internal/cutoverorchestrator/anonymization.go b/internal/cutoverorchestrator/anonymization.go new file mode 100644 index 00000000..b84092ee --- /dev/null +++ b/internal/cutoverorchestrator/anonymization.go @@ -0,0 +1,144 @@ +package cutoverorchestrator + +import ( + "bytes" + "context" + "fmt" + "io/fs" + "net" + "os" + "path/filepath" + "regexp" + "strings" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +var ( + emailInText = regexp.MustCompile(`[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}`) + ipv4InText = regexp.MustCompile(`\b(?:\d{1,3}\.){3}\d{1,3}\b`) +) + +// Nur Dokumentationsnetze nach RFC 5737 sind in der Testkopie zulaessig. +var documentationNets = []string{"192.0.2.0/24", "198.51.100.0/24", "203.0.113.0/24"} + +type AnonymizationReport struct { + Tables int + Rows int + Emails int + IPs int + Persons int + Files int + Violations []string +} + +func (r AnonymizationReport) Counts() map[string]int { + return map[string]int{ + "tabellen": r.Tables, "zeilen": r.Rows, "e_mail_adressen": r.Emails, "ip_adressen": r.IPs, + "personen": r.Persons, "binaerobjekte": r.Files, "verstoesse": len(r.Violations), + } +} + +func inDocumentationNet(ip string) bool { + parsed := net.ParseIP(ip) + if parsed == nil { + return true + } + for _, cidr := range documentationNets { + _, n, _ := net.ParseCIDR(cidr) + if n.Contains(parsed) { + return true + } + } + return false +} + +func (r *AnonymizationReport) checkText(where, text string) { + for _, addr := range emailInText.FindAllString(text, -1) { + r.Emails++ + if !strings.HasSuffix(strings.ToLower(addr), ReservedMailDomainSuffix) && !strings.HasSuffix(strings.ToLower(addr), ReservedMailDomainSuffix+">") { + r.Violations = append(r.Violations, fmt.Sprintf("%s: e-mail-adresse ausserhalb der reservierten domain %s", where, ReservedMailDomainSuffix)) + } + } + for _, ip := range ipv4InText.FindAllString(text, -1) { + r.IPs++ + if !inDocumentationNet(ip) { + r.Violations = append(r.Violations, fmt.Sprintf("%s: ip-adresse ausserhalb der dokumentationsnetze (rfc 5737)", where)) + } + } +} + +// VerifyAnonymization prueft die Testkopie unabhaengig vom Generator: jede +// Zeile jeder Tabelle beider Altsystem-Kopien wird als JSON gelesen und auf +// E-Mail-/IP-Muster durchsucht, Personenkonten muessen das synthetische +// Praefix tragen und jedes Binaerobjekt muss mit dem Synthetik-Marker beginnen. +func VerifyAnonymization(ctx context.Context, pools map[string]*pgxpool.Pool, objectRoot string) (AnonymizationReport, error) { + var rep AnonymizationReport + for system, pool := range pools { + rows, err := pool.Query(ctx, `SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_type = 'BASE TABLE' ORDER BY 1`) + if err != nil { + return rep, fmt.Errorf("%s: tabellen auflisten: %w", system, err) + } + tables, err := pgx.CollectRows(rows, pgx.RowTo[string]) + if err != nil { + return rep, fmt.Errorf("%s: tabellen lesen: %w", system, err) + } + for _, table := range tables { + rep.Tables++ + q := fmt.Sprintf(`SELECT row_to_json(t)::text FROM %s t`, pgx.Identifier{table}.Sanitize()) + rows, err := pool.Query(ctx, q) + if err != nil { + return rep, fmt.Errorf("%s.%s lesen: %w", system, table, err) + } + texts, err := pgx.CollectRows(rows, pgx.RowTo[string]) + if err != nil { + return rep, fmt.Errorf("%s.%s lesen: %w", system, table, err) + } + for i, text := range texts { + rep.Rows++ + rep.checkText(fmt.Sprintf("%s.%s zeile %d", system, table, i+1), text) + } + } + + rows, err = pool.Query(ctx, `SELECT username, email FROM users`) + if err != nil { + return rep, fmt.Errorf("%s: benutzer lesen: %w", system, err) + } + for rows.Next() { + var username, email string + if err := rows.Scan(&username, &email); err != nil { + rows.Close() + return rep, err + } + rep.Persons++ + if !strings.Contains(strings.ToLower(username), "testperson") || !strings.Contains(strings.ToLower(email), "testperson") { + rep.Violations = append(rep.Violations, fmt.Sprintf("%s.users: konto ohne synthetisches praefix", system)) + } + } + rows.Close() + if err := rows.Err(); err != nil { + return rep, err + } + } + + err := filepath.WalkDir(objectRoot, func(path string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() { + return err + } + rep.Files++ + content, err := os.ReadFile(path) + if err != nil { + return err + } + if !bytes.HasPrefix(content, []byte(SyntheticMarker)) { + rel, _ := filepath.Rel(objectRoot, path) + rep.Violations = append(rep.Violations, fmt.Sprintf("binaerobjekt %s ohne synthetik-marker", rel)) + } + return nil + }) + if err != nil { + return rep, fmt.Errorf("binaerobjekte pruefen: %w", err) + } + return rep, nil +} diff --git a/internal/cutoverorchestrator/cluster.go b/internal/cutoverorchestrator/cluster.go new file mode 100644 index 00000000..f8ae98d0 --- /dev/null +++ b/internal/cutoverorchestrator/cluster.go @@ -0,0 +1,144 @@ +package cutoverorchestrator + +import ( + "context" + "errors" + "fmt" + "net/url" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +var errDSNFormat = errors.New("cutoverorchestrator: admin-dsn muss im url-format postgres[ql]://.../ vorliegen") + +type Cluster struct { + adminDSN string + parsed *url.URL +} + +func NewCluster(adminDSN string) (*Cluster, error) { + u, err := url.Parse(adminDSN) + if err != nil || (u.Scheme != "postgres" && u.Scheme != "postgresql") || u.Host == "" { + return nil, errDSNFormat + } + return &Cluster{adminDSN: adminDSN, parsed: u}, nil +} + +func (c *Cluster) AdminDSN() string { return c.adminDSN } + +func (c *Cluster) DSNFor(database string) string { + u := *c.parsed + u.Path = "/" + database + u.RawPath = "" + return u.String() +} + +// TenantDSNTemplate liefert die von tenant.Provisioner erwartete Vorlage mit +// genau einem %s; vorhandene Prozentzeichen (url-kodierte Passwoerter) +// werden verdoppelt, sonst wuerde fmt.Sprintf sie als Verben deuten. +func (c *Cluster) TenantDSNTemplate() string { + const placeholder = "cutoverdbnameplaceholder" + dsn := strings.ReplaceAll(c.DSNFor(placeholder), "%", "%%") + return strings.Replace(dsn, placeholder, "%s", 1) +} + +func (c *Cluster) Connect(ctx context.Context, database string) (*pgxpool.Pool, error) { + return c.ConnectSchema(ctx, database, "") +} + +func (c *Cluster) ConnectSchema(ctx context.Context, database, schema string) (*pgxpool.Pool, error) { + cfg, err := pgxpool.ParseConfig(c.adminDSN) + if err != nil { + return nil, errDSNFormat + } + cfg.ConnConfig.Database = database + if schema != "" { + cfg.ConnConfig.RuntimeParams["search_path"] = schema + ",public" + } + cfg.MaxConns = 4 + pool, err := pgxpool.NewWithConfig(ctx, cfg) + if err != nil { + return nil, fmt.Errorf("verbindung zu datenbank %q: %w", database, err) + } + return pool, nil +} + +func (c *Cluster) admin(ctx context.Context) (*pgx.Conn, error) { + conn, err := pgx.Connect(ctx, c.adminDSN) + if err != nil { + return nil, fmt.Errorf("verbindung zur wartungsdatenbank: %w", err) + } + return conn, nil +} + +func (c *Cluster) CreateDatabase(ctx context.Context, name string) error { + conn, err := c.admin(ctx) + if err != nil { + return err + } + defer conn.Close(ctx) + if _, err := conn.Exec(ctx, "CREATE DATABASE "+pgx.Identifier{name}.Sanitize()); err != nil { + return fmt.Errorf("datenbank %q anlegen: %w", name, err) + } + return nil +} + +// DropRunDatabases entfernt ausschliesslich Datenbanken, deren Name die +// Laufkennung enthaelt – fremde Test-Datenbanken auf dem geteilten +// Wegwerf-Cluster bleiben unberuehrt. +func (c *Cluster) DropRunDatabases(ctx context.Context, runTag string) ([]string, error) { + if len(runTag) < 6 { + return nil, fmt.Errorf("laufkennung %q zu kurz fuer sicheres aufraeumen", runTag) + } + conn, err := c.admin(ctx) + if err != nil { + return nil, err + } + defer conn.Close(ctx) + + rows, err := conn.Query(ctx, `SELECT datname FROM pg_database WHERE position($1 in datname) > 0`, runTag) + if err != nil { + return nil, fmt.Errorf("lauf-datenbanken auflisten: %w", err) + } + var names []string + for rows.Next() { + var n string + if err := rows.Scan(&n); err != nil { + rows.Close() + return nil, err + } + names = append(names, n) + } + rows.Close() + sort.Strings(names) + + for _, n := range names { + if _, err := conn.Exec(ctx, "DROP DATABASE IF EXISTS "+pgx.Identifier{n}.Sanitize()+" WITH (FORCE)"); err != nil { + return names, fmt.Errorf("datenbank %q entfernen: %w", n, err) + } + } + return names, nil +} + +func applySQLDir(ctx context.Context, pool *pgxpool.Pool, dir string) (int, error) { + files, err := filepath.Glob(filepath.Join(dir, "*.up.sql")) + if err != nil { + return 0, err + } + sort.Strings(files) + for _, f := range files { + sqlText, err := os.ReadFile(f) + if err != nil { + return 0, fmt.Errorf("migration %s lesen: %w", filepath.Base(f), err) + } + if _, err := pool.Exec(ctx, string(sqlText)); err != nil { + return 0, fmt.Errorf("migration %s anwenden: %w", filepath.Base(f), err) + } + } + return len(files), nil +} diff --git a/internal/cutoverorchestrator/dataset.go b/internal/cutoverorchestrator/dataset.go new file mode 100644 index 00000000..16f06c27 --- /dev/null +++ b/internal/cutoverorchestrator/dataset.go @@ -0,0 +1,708 @@ +package cutoverorchestrator + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "math/rand" + "strings" + "time" + + "golang.org/x/crypto/bcrypt" +) + +// SyntheticMarker steht am Anfang jedes erzeugten Binaerobjekts und jedes +// Freitexts, damit der Anonymisierungsnachweis jede Zeile der Testkopie als +// erzeugt erkennen kann. +const SyntheticMarker = "SYNTHETISCH" + +// ReservedMailDomainSuffix: RFC 2606 reserviert ".example" – Adressen +// darunter koennen keiner realen Person gehoeren. +const ReservedMailDomainSuffix = ".example" + +const syntheticPersonPrefix = "Testperson" + +type LegacyTenant struct { + ID int64 + Slug string + Name string + Domains []string +} + +type ArchivmailUser struct { + ID int64 + TenantID int64 + Username string + Email string + PasswordHash string + Role string + Source string +} + +type ArchivdmsUser struct { + ID int64 + TenantID int64 + Username string + Email string + PasswordHash string + Role string + AuthSource string + LDAPUID *string + LDAPSyncedAt *time.Time +} + +type StorageObject struct { + ID int64 + Path string + Content []byte +} + +type MailAttachment struct { + ID int64 + Filename string + MimeType string + StorageID int64 +} + +type Email struct { + ID string + TenantID int64 + ReceivedAt time.Time + From string + To string + Subject string + RetainUntil *time.Time + StorageID int64 + AttachmentIDs []int64 + SharedWith []int64 + IndexedAt *time.Time +} + +type DsgvoRequest struct { + ID int64 + TenantID int64 + Address string + RequestedBy string + Status string + CreatedAt time.Time +} + +type ReconciliationReport struct { + ID int64 + TenantID int64 + Date time.Time + SourceType string + SourceID int64 + Expected int + Archived int +} + +type LegacyAuditEntry struct { + ID int64 + At time.Time + EventType string + Username string + IP string + Ref string + Query string + Success bool + Detail string + TenantID int64 +} + +type PermissionGroup struct { + ID int64 + TenantID int64 + Name string + Members []int64 +} + +type Akte struct { + ID int64 + TenantID int64 + Titel string + Status string + CreatedBy int64 +} + +type DocumentType struct { + ID int64 + TenantID int64 + Name string +} + +type LegacyDocument struct { + ID int64 + TenantID int64 + Title string + StoragePath string + ContentHash string + Content []byte + RetainUntil *time.Time + CreatedBy int64 + CreatedAt time.Time + AkteID *int64 + DocTypeID *int64 + DeletedAt *time.Time + DeletedBy *int64 +} + +type GroupAccess struct { + DocumentID int64 + GroupID int64 + Access string +} + +type FieldDef struct { + ID int64 + TenantID int64 + Name string + Type string + Required bool + EnumOptions []string +} + +type TemplateField struct { + FieldID int64 + Default *string +} + +type ClassificationTemplate struct { + ID int64 + TenantID int64 + Name string + DocTypeID *int64 + RetentionYears *int + Fields []TemplateField +} + +type FieldValue struct { + DocumentID int64 + FieldID int64 + Value string +} + +type DocumentShare struct { + ID int64 + TenantID int64 + DocumentID int64 + TokenHashHex string + CreatedBy int64 + CreatedAt time.Time + ExpiresAt time.Time + MaxAccesses *int + AccessCount int + PasswordHash *string + RevokedAt *time.Time +} + +type DeleteRequest struct { + ID int64 + DocumentID int64 + TenantID int64 + RequestedBy int64 + RequestedAt time.Time + ConfirmedBy *int64 + ConfirmedAt *time.Time + Status string +} + +type WorkflowEvent struct { + From string + To string + ActorID int64 + Note string + At time.Time +} + +type WorkflowRun struct { + ID int64 + WorkflowID int64 + TenantID int64 + DocumentID *int64 + Status string + FinalState string + StartedAt time.Time + FinishedAt *time.Time + Events []WorkflowEvent +} + +type LegacyWorkflow struct { + ID int64 + TenantID int64 + Name string +} + +type ArchivmailData struct { + Tenants []LegacyTenant + Users []ArchivmailUser + StorageObjects []StorageObject + Attachments []MailAttachment + Emails []Email + DsgvoRequests []DsgvoRequest + Reports []ReconciliationReport + Audit []LegacyAuditEntry +} + +type ArchivdmsData struct { + Tenants []LegacyTenant + Users []ArchivdmsUser + Groups []PermissionGroup + DocTypes []DocumentType + Akten []Akte + Documents []LegacyDocument + Visibility []GroupAccess + Grants []GroupAccess + FieldDefs []FieldDef + Templates []ClassificationTemplate + FieldValues []FieldValue + Shares []DocumentShare + DeleteRequests []DeleteRequest + Workflows []LegacyWorkflow + WorkflowRuns []WorkflowRun + Audit []LegacyAuditEntry +} + +type Dataset struct { + Archivmail ArchivmailData + Archivdms ArchivdmsData + ManualDecisionsCSV string + // Passwords haelt die erzeugten Klartext-Passwoerter nur im Speicher + // des Laufs (fuer VAL-05), Schluessel "archivmail:"/"archivdms:". + Passwords map[string]string +} + +type orgSpec struct { + key string + name string + mailDomain string + archivmailID int64 + archivmailSlug string + extraDomains []string + archivdmsID int64 + archivdmsSlug string +} + +// syntheticOrganisations: zwei Organisationen nutzen beide Altsysteme +// (einmal per Slug, einmal nur ueber die manuelle Kundenliste zuordenbar), +// je eine nutzt nur eines der beiden Systeme – die teilweise +// Ueberschneidung aus MIGRATIONSKONZEPT.md Abschnitt 1. +var syntheticOrganisations = []orgSpec{ + {key: "musterhausen", name: "Kanzlei Musterhausen", mailDomain: "kanzlei-musterhausen.example", + archivmailID: 101, archivmailSlug: "kanzlei-musterhausen", archivdmsID: 7, archivdmsSlug: "kanzlei-musterhausen"}, + {key: "beispielstadt", name: "Steuerbuero Beispielstadt", mailDomain: "stb-beispielstadt.example", + archivmailID: 102, archivmailSlug: "steuerbuero-beispielstadt", extraDomains: []string{"steuerbuero-beispielstadt.example"}, + archivdmsID: 8, archivdmsSlug: "stb-beispielstadt"}, + {key: "fantasia", name: "Praxis Fantasia", mailDomain: "praxis-fantasia.example", + archivmailID: 103, archivmailSlug: "praxis-fantasia"}, + {key: "erfundenhausen", name: "Ingenieurbuero Erfundenhausen", mailDomain: "ingenieurbuero-erfundenhausen.example", + archivdmsID: 9, archivdmsSlug: "ingenieurbuero-erfundenhausen"}, +} + +const ( + SubjectOpenRequest = "betroffene.person@kundschaft-beispiel.example" + SubjectCompletedRequest = "ehemalige.person@kundschaft-beispiel.example" + SubjectPartialRequest = "teilweise.person@kundschaft-beispiel.example" +) + +type idSeq struct{ next int64 } + +func (s *idSeq) take() int64 { + s.next++ + return s.next +} + +type generator struct { + rnd *rand.Rand + now time.Time + bcryptCost int + ds *Dataset + ids map[string]*idSeq +} + +func (g *generator) id(kind string, start int64) int64 { + s, ok := g.ids[kind] + if !ok { + s = &idSeq{next: start} + g.ids[kind] = s + } + return s.take() +} + +func (g *generator) randomHex(n int) string { + b := make([]byte, n) + g.rnd.Read(b) + return hex.EncodeToString(b) +} + +func (g *generator) password(key string) (string, string, error) { + pw := "Syn-" + g.randomHex(8) + "-Pw" + hash, err := bcrypt.GenerateFromPassword([]byte(pw), g.bcryptCost) + if err != nil { + return "", "", fmt.Errorf("synthetischen passwort-hash erzeugen: %w", err) + } + g.ds.Passwords[key] = pw + return pw, string(hash), nil +} + +func timePtr(t time.Time) *time.Time { return &t } +func int64Ptr(v int64) *int64 { return &v } +func intPtr(v int) *int { return &v } +func strPtr(v string) *string { return &v } + +// GenerateDataset erzeugt die komplette Testkopie ausschliesslich aus +// diesem Code: es gibt keinen Parameter, ueber den Daten aus einem +// Altsystem, einem Export oder einer Datei einfliessen koennten. +func GenerateDataset(seed int64, now time.Time, bcryptCost int) (*Dataset, error) { + g := &generator{ + rnd: rand.New(rand.NewSource(seed)), + now: now.UTC().Truncate(time.Second), + bcryptCost: bcryptCost, + ds: &Dataset{Passwords: map[string]string{}}, + ids: map[string]*idSeq{}, + } + + var manual strings.Builder + manual.WriteString("archivmail_slug,archivdms_slug,match,note\n") + + for _, org := range syntheticOrganisations { + if org.archivmailID != 0 { + if err := g.archivmailTenant(org); err != nil { + return nil, err + } + } + if org.archivdmsID != 0 { + if err := g.archivdmsTenant(org); err != nil { + return nil, err + } + } + if org.archivmailID != 0 && org.archivdmsID != 0 && org.archivmailSlug != org.archivdmsSlug { + fmt.Fprintf(&manual, "%s,%s,true,synthetische Kundenzuordnungsliste (%s)\n", org.archivmailSlug, org.archivdmsSlug, org.name) + } + } + g.ds.ManualDecisionsCSV = manual.String() + return g.ds, nil +} + +func personEmail(n int, domain string) string { + return fmt.Sprintf("testperson%02d@%s", n, domain) +} + +func personName(n int, org orgSpec) string { + return fmt.Sprintf("%s %02d (%s)", syntheticPersonPrefix, n, org.name) +} + +func (g *generator) archivmailTenant(org orgSpec) error { + am := &g.ds.Archivmail + domains := append([]string{org.mailDomain}, org.extraDomains...) + am.Tenants = append(am.Tenants, LegacyTenant{ID: org.archivmailID, Slug: org.archivmailSlug, Name: org.name, Domains: domains}) + + roles := []string{"domain_admin", "user", "user", "auditor", "admin"} + users := make([]ArchivmailUser, 0, len(roles)) + for i, role := range roles { + n := i + 1 + id := g.id("am_user", 1000) + _, hash, err := g.password(fmt.Sprintf("archivmail:%d", id)) + if err != nil { + return err + } + source := "local" + if n == 2 { + source = "ldap" + } + users = append(users, ArchivmailUser{ + ID: id, TenantID: org.archivmailID, + Username: fmt.Sprintf("testperson%02d.%s", n, org.key), + Email: personEmail(n, org.mailDomain), + PasswordHash: hash, Role: role, Source: source, + }) + } + am.Users = append(am.Users, users...) + + type mailSpec struct { + from string + retain *time.Time + attachments int + } + specs := []mailSpec{ + {from: SubjectOpenRequest, retain: timePtr(g.now.AddDate(6, 0, 0))}, + {from: "lieferant02@lieferant-beispiel.example", retain: timePtr(g.now.AddDate(10, 0, 0)), attachments: 1}, + {from: SubjectOpenRequest, retain: timePtr(g.now.AddDate(-1, 0, 0))}, + {from: "lieferant04@lieferant-beispiel.example", attachments: 2}, + {from: SubjectPartialRequest, retain: timePtr(g.now.AddDate(2, 0, 0))}, + {from: "rundschreiben@verband-beispiel.example"}, + } + for i, spec := range specs { + n := i + 1 + body := fmt.Sprintf("%s – frei erfundener Nachrichtentext %d fuer %s. Zufallsanteil %s.", SyntheticMarker, n, org.name, g.randomHex(6)) + bodyObj := g.storageObject(fmt.Sprintf("mails/%d/%s.eml", org.archivmailID, g.randomHex(8)), []byte(body)) + e := Email{ + ID: fmt.Sprintf("am-%d-%04d", org.archivmailID, n), + TenantID: org.archivmailID, + ReceivedAt: g.now.AddDate(0, -n, -n), + From: spec.from, + To: personEmail(1, org.mailDomain), + Subject: fmt.Sprintf("%s Testnachricht %d (%s)", SyntheticMarker, n, org.name), + RetainUntil: spec.retain, + StorageID: bodyObj.ID, + } + if n%2 == 0 { + e.IndexedAt = timePtr(e.ReceivedAt.Add(time.Hour)) + } + for a := 1; a <= spec.attachments; a++ { + content := []byte(fmt.Sprintf("%s – erfundener Anhang %d zu Nachricht %s, Zufallsanteil %s", SyntheticMarker, a, e.ID, g.randomHex(6))) + obj := g.storageObject(fmt.Sprintf("attachments/%d/%s.bin", org.archivmailID, g.randomHex(8)), content) + att := MailAttachment{ID: g.id("am_att", 0), Filename: fmt.Sprintf("anlage-%d.txt", a), MimeType: "text/plain", StorageID: obj.ID} + am.Attachments = append(am.Attachments, att) + e.AttachmentIDs = append(e.AttachmentIDs, att.ID) + } + am.Emails = append(am.Emails, e) + } + + am.DsgvoRequests = append(am.DsgvoRequests, + DsgvoRequest{ID: g.id("am_dsgvo", 0), TenantID: org.archivmailID, Address: SubjectOpenRequest, RequestedBy: users[0].Username, Status: "open", CreatedAt: g.now.AddDate(0, 0, -14)}, + DsgvoRequest{ID: g.id("am_dsgvo", 0), TenantID: org.archivmailID, Address: SubjectCompletedRequest, RequestedBy: users[0].Username, Status: "completed", CreatedAt: g.now.AddDate(-1, 0, 0)}, + DsgvoRequest{ID: g.id("am_dsgvo", 0), TenantID: org.archivmailID, Address: SubjectPartialRequest, RequestedBy: users[4].Username, Status: "partial", CreatedAt: g.now.AddDate(0, 0, -3)}, + ) + + for d := 1; d <= 3; d++ { + expected := 10 + d + am.Reports = append(am.Reports, ReconciliationReport{ + ID: g.id("am_recon", 0), TenantID: org.archivmailID, Date: g.now.AddDate(0, 0, -d).Truncate(24 * time.Hour), + SourceType: "imap", SourceID: int64(d), Expected: expected, Archived: expected - (d % 2), + }) + } + + am.Audit = append(am.Audit, + LegacyAuditEntry{ID: g.id("am_audit", 0), At: g.now.AddDate(0, -2, 0), EventType: "login", Username: users[0].Username, IP: "192.0.2.10", Success: true, TenantID: org.archivmailID}, + LegacyAuditEntry{ID: g.id("am_audit", 0), At: g.now.AddDate(0, -1, 0), EventType: "search", Username: users[1].Username, IP: "192.0.2.11", Query: "rechnung", Success: true, TenantID: org.archivmailID}, + LegacyAuditEntry{ID: g.id("am_audit", 0), At: g.now.AddDate(0, 0, -20), EventType: "mail_export", Username: users[1].Username, IP: "192.0.2.11", Ref: fmt.Sprintf("am-%d-0002", org.archivmailID), Success: true, TenantID: org.archivmailID}, + LegacyAuditEntry{ID: g.id("am_audit", 0), At: g.now.AddDate(0, 0, -2), EventType: "login", Username: users[3].Username, IP: "192.0.2.12", Success: false, Detail: SyntheticMarker + " falsches Passwort (erfunden)", TenantID: org.archivmailID}, + ) + + if org.key == "musterhausen" { + for i := range am.Emails { + if am.Emails[i].ID == fmt.Sprintf("am-%d-0006", org.archivmailID) { + am.Emails[i].SharedWith = []int64{103} + } + } + } + return nil +} + +func (g *generator) storageObject(path string, content []byte) StorageObject { + obj := StorageObject{ID: g.id("am_storage", 0), Path: path, Content: content} + g.ds.Archivmail.StorageObjects = append(g.ds.Archivmail.StorageObjects, obj) + return obj +} + +func sha256Hex(b []byte) string { + sum := sha256.Sum256(b) + return hex.EncodeToString(sum[:]) +} + +func (g *generator) archivdmsTenant(org orgSpec) error { + dm := &g.ds.Archivdms + tid := org.archivdmsID + dm.Tenants = append(dm.Tenants, LegacyTenant{ID: tid, Slug: org.archivdmsSlug, Name: org.name, Domains: []string{org.mailDomain}}) + + shared := org.archivmailID != 0 + users := make([]ArchivdmsUser, 0, 5) + for n := 1; n <= 5; n++ { + id := g.id("dms_user", 500) + _, hash, err := g.password(fmt.Sprintf("archivdms:%d", id)) + if err != nil { + return err + } + email := personEmail(n, org.mailDomain) + username := fmt.Sprintf("testperson%02d.%s", n, org.key) + if shared && n == 2 { + email = fmt.Sprintf("TestPerson%02d@%s", n, org.mailDomain) + } + if !shared || n > 3 { + email = fmt.Sprintf("dms.testperson%02d@%s", n, org.mailDomain) + username = "dms." + username + } + u := ArchivdmsUser{ID: id, TenantID: tid, Username: username, Email: email, PasswordHash: hash, Role: "user", AuthSource: "local"} + if n == 1 { + u.Role = "domain_admin" + } + if n == 3 { + u.AuthSource = "ldap" + u.LDAPUID = strPtr(fmt.Sprintf("uid=testperson%02d,ou=%s,dc=beispiel,dc=example", n, org.key)) + u.LDAPSyncedAt = timePtr(g.now.AddDate(0, 0, -1)) + } + users = append(users, u) + } + dm.Users = append(dm.Users, users...) + + groupSpec := []struct { + name string + members []int + }{ + {"Vollzugriff", []int{0}}, + {"Bearbeiter", []int{1, 2}}, + {"Leser", []int{3, 4}}, + } + groups := make([]PermissionGroup, 0, len(groupSpec)) + for _, gs := range groupSpec { + pg := PermissionGroup{ID: g.id("dms_group", 0), TenantID: tid, Name: gs.name} + for _, m := range gs.members { + pg.Members = append(pg.Members, users[m].ID) + } + groups = append(groups, pg) + } + dm.Groups = append(dm.Groups, groups...) + + docTypes := make([]DocumentType, 0, 3) + for _, name := range []string{"Rechnung", "Vertrag", "Korrespondenz"} { + docTypes = append(docTypes, DocumentType{ID: g.id("dms_doctype", 0), TenantID: tid, Name: name}) + } + dm.DocTypes = append(dm.DocTypes, docTypes...) + + akten := make([]Akte, 0, 3) + for i, titel := range []string{"Testakte Musterfall A", "Testakte Musterfall B", "Testakte leer"} { + status := "offen" + if i == 1 { + status = "geschlossen" + } + akten = append(akten, Akte{ID: g.id("dms_akte", 0), TenantID: tid, Titel: titel + " (" + org.name + ")", Status: status, CreatedBy: users[0].ID}) + } + dm.Akten = append(dm.Akten, akten...) + + retain := []*time.Time{ + timePtr(g.now.AddDate(8, 0, 0)), timePtr(g.now.AddDate(3, 0, 0)), timePtr(g.now.AddDate(0, -6, 0)), nil, + timePtr(g.now.AddDate(10, 0, 0)), nil, timePtr(g.now.AddDate(-2, 0, 0)), timePtr(g.now.AddDate(1, 0, 0)), + } + docs := make([]LegacyDocument, 0, len(retain)) + for i := range retain { + n := i + 1 + ext := "txt" + if n%2 == 0 { + ext = "pdf" + } + content := []byte(fmt.Sprintf("%s – Testdokument %d (%s), frei erfunden. Zufallsanteil %s.", SyntheticMarker, n, org.name, g.randomHex(12))) + hash := sha256Hex(content) + created := g.now.AddDate(0, -12+n, 0) + d := LegacyDocument{ + ID: g.id("dms_doc", 0), TenantID: tid, + Title: fmt.Sprintf("%s Testdokument %d.%s", SyntheticMarker, n, ext), + StoragePath: fmt.Sprintf("store/%d/%04d/%02d/%s.%s", tid, created.Year(), int(created.Month()), hash, ext), + ContentHash: hash, Content: content, RetainUntil: retain[i], + CreatedBy: users[(n-1)%3].ID, CreatedAt: created, + DocTypeID: int64Ptr(docTypes[(n-1)%3].ID), + } + switch { + case n <= 3: + d.AkteID = int64Ptr(akten[0].ID) + case n <= 5: + d.AkteID = int64Ptr(akten[1].ID) + } + if n == 7 { + d.DeletedAt = timePtr(g.now.AddDate(0, -1, 0)) + d.DeletedBy = int64Ptr(users[0].ID) + } + docs = append(docs, d) + } + dm.Documents = append(dm.Documents, docs...) + + for i, d := range docs { + n := i + 1 + if d.DeletedAt != nil { + continue + } + dm.Visibility = append(dm.Visibility, GroupAccess{DocumentID: d.ID, GroupID: groups[0].ID, Access: "write"}) + dm.Grants = append(dm.Grants, GroupAccess{DocumentID: d.ID, GroupID: groups[0].ID, Access: "write"}) + if n <= 5 { + dm.Visibility = append(dm.Visibility, GroupAccess{DocumentID: d.ID, GroupID: groups[1].ID, Access: "write"}) + dm.Grants = append(dm.Grants, GroupAccess{DocumentID: d.ID, GroupID: groups[1].ID, Access: "write"}) + } + if n <= 3 { + dm.Visibility = append(dm.Visibility, GroupAccess{DocumentID: d.ID, GroupID: groups[2].ID, Access: "read"}) + dm.Grants = append(dm.Grants, GroupAccess{DocumentID: d.ID, GroupID: groups[2].ID, Access: "read"}) + } + if n == 5 { + dm.Grants = append(dm.Grants, GroupAccess{DocumentID: d.ID, GroupID: groups[2].ID, Access: "deny"}) + } + } + + fAZ := FieldDef{ID: g.id("dms_field", 0), TenantID: tid, Name: "Aktenzeichen", Type: "text", Required: true} + fBetrag := FieldDef{ID: g.id("dms_field", 0), TenantID: tid, Name: "Betrag", Type: "monetary"} + fFaellig := FieldDef{ID: g.id("dms_field", 0), TenantID: tid, Name: "Faellig am", Type: "date"} + fVertraulich := FieldDef{ID: g.id("dms_field", 0), TenantID: tid, Name: "Vertraulich", Type: "boolean"} + fKategorie := FieldDef{ID: g.id("dms_field", 0), TenantID: tid, Name: "Kategorie", Type: "enum", EnumOptions: []string{"intern", "extern"}} + dm.FieldDefs = append(dm.FieldDefs, fAZ, fBetrag, fFaellig, fVertraulich, fKategorie) + + dm.Templates = append(dm.Templates, + ClassificationTemplate{ID: g.id("dms_tpl", 0), TenantID: tid, Name: "Eingangsrechnung", DocTypeID: int64Ptr(docTypes[0].ID), RetentionYears: intPtr(10), + Fields: []TemplateField{{FieldID: fAZ.ID}, {FieldID: fBetrag.ID}, {FieldID: fFaellig.ID}}}, + ClassificationTemplate{ID: g.id("dms_tpl", 0), TenantID: tid, Name: "Vertragsunterlagen", DocTypeID: int64Ptr(docTypes[1].ID), RetentionYears: intPtr(30), + Fields: []TemplateField{{FieldID: fAZ.ID}, {FieldID: fKategorie.ID}, {FieldID: fVertraulich.ID, Default: strPtr("false")}}}, + ClassificationTemplate{ID: g.id("dms_tpl", 0), TenantID: tid, Name: "Allgemeiner Schriftverkehr"}, + ) + dm.FieldValues = append(dm.FieldValues, + FieldValue{DocumentID: docs[0].ID, FieldID: fAZ.ID, Value: fmt.Sprintf("AZ-SYN-%d-0001", tid)}, + FieldValue{DocumentID: docs[0].ID, FieldID: fBetrag.ID, Value: "199.99"}, + FieldValue{DocumentID: docs[0].ID, FieldID: fFaellig.ID, Value: g.now.AddDate(0, 1, 0).Format("2006-01-02")}, + FieldValue{DocumentID: docs[1].ID, FieldID: fAZ.ID, Value: fmt.Sprintf("AZ-SYN-%d-0002", tid)}, + FieldValue{DocumentID: docs[1].ID, FieldID: fKategorie.ID, Value: "intern"}, + FieldValue{DocumentID: docs[3].ID, FieldID: fVertraulich.ID, Value: "true"}, + ) + + shareHash := func() string { return sha256Hex([]byte("token-" + g.randomHex(16))) } + pwHash := "$2a$10$" + strings.Repeat("S", 53) + dm.Shares = append(dm.Shares, + DocumentShare{ID: g.id("dms_share", 0), TenantID: tid, DocumentID: docs[0].ID, TokenHashHex: shareHash(), CreatedBy: users[1].ID, + CreatedAt: g.now.AddDate(0, 0, -5), ExpiresAt: g.now.AddDate(0, 0, 30), MaxAccesses: intPtr(5), AccessCount: 1}, + DocumentShare{ID: g.id("dms_share", 0), TenantID: tid, DocumentID: docs[1].ID, TokenHashHex: shareHash(), CreatedBy: users[1].ID, + CreatedAt: g.now.AddDate(0, 0, -3), ExpiresAt: g.now.AddDate(0, 2, 0), PasswordHash: strPtr(pwHash)}, + DocumentShare{ID: g.id("dms_share", 0), TenantID: tid, DocumentID: docs[2].ID, TokenHashHex: shareHash(), CreatedBy: users[2].ID, + CreatedAt: g.now.AddDate(0, -2, 0), ExpiresAt: g.now.AddDate(0, 0, -10)}, + DocumentShare{ID: g.id("dms_share", 0), TenantID: tid, DocumentID: docs[3].ID, TokenHashHex: shareHash(), CreatedBy: users[0].ID, + CreatedAt: g.now.AddDate(0, 0, -7), ExpiresAt: g.now.AddDate(0, 0, 20), RevokedAt: timePtr(g.now.AddDate(0, 0, -1))}, + DocumentShare{ID: g.id("dms_share", 0), TenantID: tid, DocumentID: docs[6].ID, TokenHashHex: shareHash(), CreatedBy: users[1].ID, + CreatedAt: g.now.AddDate(0, -2, 0), ExpiresAt: g.now.AddDate(0, 0, 15)}, + ) + + dm.DeleteRequests = append(dm.DeleteRequests, + DeleteRequest{ID: g.id("dms_delreq", 0), DocumentID: docs[6].ID, TenantID: tid, RequestedBy: users[1].ID, RequestedAt: g.now.AddDate(0, -1, -2), + ConfirmedBy: int64Ptr(users[0].ID), ConfirmedAt: timePtr(g.now.AddDate(0, -1, 0)), Status: "executed"}, + DeleteRequest{ID: g.id("dms_delreq", 0), DocumentID: docs[7].ID, TenantID: tid, RequestedBy: users[1].ID, RequestedAt: g.now.AddDate(0, 0, -4), Status: "pending"}, + DeleteRequest{ID: g.id("dms_delreq", 0), DocumentID: docs[5].ID, TenantID: tid, RequestedBy: users[2].ID, RequestedAt: g.now.AddDate(0, 0, -9), Status: "cancelled"}, + ) + + wf := LegacyWorkflow{ID: g.id("dms_wf", 0), TenantID: tid, Name: "Eingangsrechnung pruefen"} + dm.Workflows = append(dm.Workflows, wf) + t0 := g.now.AddDate(0, -3, 0) + dm.WorkflowRuns = append(dm.WorkflowRuns, + WorkflowRun{ID: g.id("dms_wfrun", 0), WorkflowID: wf.ID, TenantID: tid, DocumentID: int64Ptr(docs[0].ID), Status: "completed", FinalState: "freigegeben", + StartedAt: t0, FinishedAt: timePtr(t0.Add(48 * time.Hour)), Events: []WorkflowEvent{ + {From: "", To: "offen", ActorID: users[1].ID, Note: "gestartet", At: t0}, + {From: "offen", To: "in_pruefung", ActorID: users[1].ID, Note: "Pruefung begonnen", At: t0.Add(24 * time.Hour)}, + {From: "in_pruefung", To: "freigegeben", ActorID: users[0].ID, Note: "freigegeben", At: t0.Add(48 * time.Hour)}, + }}, + WorkflowRun{ID: g.id("dms_wfrun", 0), WorkflowID: wf.ID, TenantID: tid, DocumentID: int64Ptr(docs[1].ID), Status: "aborted", FinalState: "abgebrochen", + StartedAt: t0.Add(72 * time.Hour), FinishedAt: timePtr(t0.Add(96 * time.Hour)), Events: []WorkflowEvent{ + {From: "", To: "offen", ActorID: users[2].ID, Note: "gestartet", At: t0.Add(72 * time.Hour)}, + {From: "offen", To: "abgebrochen", ActorID: users[2].ID, Note: "Dublette", At: t0.Add(96 * time.Hour)}, + }}, + WorkflowRun{ID: g.id("dms_wfrun", 0), WorkflowID: wf.ID, TenantID: tid, DocumentID: int64Ptr(docs[3].ID), Status: "running", FinalState: "in_pruefung", + StartedAt: g.now.AddDate(0, 0, -2), Events: []WorkflowEvent{ + {From: "", To: "offen", ActorID: users[1].ID, Note: "gestartet", At: g.now.AddDate(0, 0, -2)}, + {From: "offen", To: "in_pruefung", ActorID: users[1].ID, Note: "Pruefung begonnen", At: g.now.AddDate(0, 0, -1)}, + }}, + WorkflowRun{ID: g.id("dms_wfrun", 0), WorkflowID: wf.ID, TenantID: tid, Status: "completed", FinalState: "erledigt", + StartedAt: t0.Add(120 * time.Hour), FinishedAt: timePtr(t0.Add(121 * time.Hour)), Events: []WorkflowEvent{ + {From: "", To: "offen", ActorID: users[3].ID, Note: "gestartet", At: t0.Add(120 * time.Hour)}, + {From: "offen", To: "erledigt", ActorID: users[3].ID, Note: "ohne Befund", At: t0.Add(121 * time.Hour)}, + }}, + ) + + dm.Audit = append(dm.Audit, + LegacyAuditEntry{ID: g.id("dms_audit", 0), At: docs[0].CreatedAt, EventType: "document_upload", Username: users[0].Username, IP: "198.51.100.20", Ref: fmt.Sprint(docs[0].ID), Success: true, TenantID: tid}, + LegacyAuditEntry{ID: g.id("dms_audit", 0), At: g.now.AddDate(0, 0, -30), EventType: "document_view", Username: users[3].Username, IP: "198.51.100.21", Ref: fmt.Sprint(docs[1].ID), Success: true, TenantID: tid}, + LegacyAuditEntry{ID: g.id("dms_audit", 0), At: g.now.AddDate(0, -1, 0), EventType: "document_delete_executed", Username: users[0].Username, IP: "198.51.100.20", Ref: fmt.Sprint(docs[6].ID), Success: true, TenantID: tid}, + LegacyAuditEntry{ID: g.id("dms_audit", 0), At: g.now.AddDate(0, 0, -1), EventType: "share_created", Username: users[1].Username, IP: "198.51.100.22", Ref: fmt.Sprint(docs[0].ID), Success: true, Detail: SyntheticMarker + " Freigabelink erzeugt", TenantID: tid}, + ) + return nil +} diff --git a/internal/cutoverorchestrator/dataset_test.go b/internal/cutoverorchestrator/dataset_test.go new file mode 100644 index 00000000..70312658 --- /dev/null +++ b/internal/cutoverorchestrator/dataset_test.go @@ -0,0 +1,153 @@ +package cutoverorchestrator + +import ( + "bytes" + "fmt" + "strings" + "testing" + "time" + + "gitea.perlbach24.de/scripte/nexarch/internal/tenant" +) + +func testDataset(t *testing.T) *Dataset { + t.Helper() + ds, err := GenerateDataset(7, time.Date(2026, 10, 14, 12, 0, 0, 0, time.UTC), 4) + if err != nil { + t.Fatal(err) + } + return ds +} + +func TestGenerateDataset_IsDeterministic(t *testing.T) { + a, b := testDataset(t), testDataset(t) + if len(a.Archivdms.Documents) != len(b.Archivdms.Documents) || len(a.Archivmail.Emails) != len(b.Archivmail.Emails) { + t.Fatal("gleicher seed muss gleiche mengen liefern") + } + for i := range a.Archivdms.Documents { + if !bytes.Equal(a.Archivdms.Documents[i].Content, b.Archivdms.Documents[i].Content) { + t.Fatalf("dokument %d unterscheidet sich trotz gleichem seed", i) + } + } + for k, pw := range a.Passwords { + if b.Passwords[k] != pw { + t.Fatalf("passwort %s unterscheidet sich trotz gleichem seed", k) + } + } +} + +// Akzeptanzkriterium 3: jede personenbezogen wirkende Angabe der Testkopie +// ist erkennbar synthetisch. +func TestGenerateDataset_ContainsOnlySyntheticPersonalData(t *testing.T) { + ds := testDataset(t) + var rep AnonymizationReport + for _, u := range ds.Archivmail.Users { + rep.checkText("archivmail.users", u.Email+" "+u.Username) + if !strings.Contains(u.Username, "testperson") { + t.Errorf("archivmail-benutzer %q ohne synthetisches praefix", u.Username) + } + } + for _, u := range ds.Archivdms.Users { + rep.checkText("archivdms.users", u.Email+" "+u.Username) + if !strings.Contains(strings.ToLower(u.Email), "testperson") { + t.Errorf("archivdms-benutzer %q ohne synthetisches praefix", u.Email) + } + } + for _, e := range ds.Archivmail.Emails { + rep.checkText("emails", e.From+" "+e.To+" "+e.Subject) + } + for _, r := range ds.Archivmail.DsgvoRequests { + rep.checkText("dsgvo", r.Address) + } + for _, a := range append(ds.Archivmail.Audit, ds.Archivdms.Audit...) { + rep.checkText("audit", a.IP+" "+a.Detail) + } + for _, o := range ds.Archivmail.StorageObjects { + if !bytes.HasPrefix(o.Content, []byte(SyntheticMarker)) { + t.Errorf("mail-objekt %s ohne synthetik-marker", o.Path) + } + } + for _, d := range ds.Archivdms.Documents { + if !bytes.HasPrefix(d.Content, []byte(SyntheticMarker)) { + t.Errorf("dokument %d ohne synthetik-marker", d.ID) + } + } + if len(rep.Violations) > 0 { + t.Fatalf("nicht-synthetische angaben: %v", rep.Violations) + } + if rep.Emails == 0 || rep.IPs == 0 { + t.Fatalf("pruefung hat nichts gesehen: %+v", rep) + } +} + +func TestAnonymizationCheck_DetectsRealLookingData(t *testing.T) { + var rep AnonymizationReport + rep.checkText("probe", `{"email":"jemand@realer-anbieter.de","ip":"8.8.8.8"}`) + if len(rep.Violations) != 2 { + t.Fatalf("echte domain und oeffentliche ip muessen auffallen, verstoesse: %v", rep.Violations) + } +} + +func TestGenerateDataset_CoversPartialTenantOverlap(t *testing.T) { + ds := testDataset(t) + amSlugs, dmsSlugs := map[string]bool{}, map[string]bool{} + for _, tn := range ds.Archivmail.Tenants { + amSlugs[tn.Slug] = true + } + for _, tn := range ds.Archivdms.Tenants { + dmsSlugs[tn.Slug] = true + } + onlyMail, onlyDMS, both := 0, 0, 0 + for s := range amSlugs { + if dmsSlugs[s] { + both++ + } else { + onlyMail++ + } + } + for s := range dmsSlugs { + if !amSlugs[s] { + onlyDMS++ + } + } + if both == 0 || onlyMail == 0 || onlyDMS == 0 || !strings.Contains(ds.ManualDecisionsCSV, "true") { + t.Fatalf("testkopie muss slug-treffer, manuelle zuordnung und einseitige mandanten enthalten: both=%d mail=%d dms=%d", both, onlyMail, onlyDMS) + } +} + +func TestTargetSlug_SatisfiesCoreSlugRules(t *testing.T) { + for _, legacy := range []string{"kanzlei-musterhausen", "ingenieurbuero-erfundenhausen", strings.Repeat("sehr-lang-", 10)} { + s := TargetSlug("cut01abcdef", legacy) + if err := tenant.ValidateSlug(s); err != nil { + t.Errorf("ziel-slug %q fuer %q verletzt core-regel: %v", s, legacy, err) + } + } +} + +func TestClusterTemplateAndDSN(t *testing.T) { + c, err := NewCluster("postgresql://u:p%40ss@db.intern:5433/postgres?sslmode=disable") + if err != nil { + t.Fatal(err) + } + got := fmt.Sprintf(c.TenantDSNTemplate(), "tenant_x") + if got != "postgresql://u:p%40ss@db.intern:5433/tenant_x?sslmode=disable" { + t.Fatalf("vorlage falsch: %s", got) + } + if c.DSNFor("registry") != "postgresql://u:p%40ss@db.intern:5433/registry?sslmode=disable" { + t.Fatalf("dsn falsch: %s", c.DSNFor("registry")) + } + if _, err := NewCluster("host=db user=u password=p dbname=postgres"); err == nil { + t.Fatal("schluessel/wert-dsn muss abgelehnt werden") + } +} + +func TestRedactor(t *testing.T) { + r := NewRedactor("literal-geheimnis-xyz") + in := "a postgres://user:pw@host/db b password=abc c literal-geheimnis-xyz" + out := r.Redact(in) + for _, leak := range []string{":pw@", "abc", "literal-geheimnis-xyz"} { + if strings.Contains(out, leak) { + t.Fatalf("%q nicht redigiert: %s", leak, out) + } + } +} diff --git a/internal/cutoverorchestrator/dryrun.go b/internal/cutoverorchestrator/dryrun.go new file mode 100644 index 00000000..fa96fbc9 --- /dev/null +++ b/internal/cutoverorchestrator/dryrun.go @@ -0,0 +1,192 @@ +package cutoverorchestrator + +import ( + "context" + "crypto/rand" + "encoding/hex" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + + "gitea.perlbach24.de/scripte/nexarch/internal/tenantmatch" + "gitea.perlbach24.de/scripte/nexarch/internal/usermatch" +) + +type Config struct { + RepoRoot string + WorkRoot string + Seed int64 + BcryptCost int +} + +type Dryrun struct { + cfg Config + cluster *Cluster + RunTag string + RunDir string + Redactor *Redactor + kekSeed string + now time.Time + + dataset *Dataset + registryDB string + archivmailDB string + archivdmsDB string + registry *pgxpool.Pool + archivmail *pgxpool.Pool + archivdms *pgxpool.Pool + exportArchivmail *pgxpool.Pool + exportArchivdms *pgxpool.Pool + tenantCands []tenantmatch.Candidate + userCands []usermatch.Candidate + tenants []ContextTenant + tenantPools map[string]*pgxpool.Pool + stages StageRunner + contextPath string +} + +func newRunTag() (string, error) { + b := make([]byte, 3) + if _, err := rand.Read(b); err != nil { + return "", err + } + return "cut01" + hex.EncodeToString(b), nil +} + +func NewDryrun(cfg Config, adminDSN string) (*Dryrun, error) { + cluster, err := NewCluster(adminDSN) + if err != nil { + return nil, err + } + tag, err := newRunTag() + if err != nil { + return nil, err + } + seed := make([]byte, 32) + if _, err := rand.Read(seed); err != nil { + return nil, err + } + kekSeed := hex.EncodeToString(seed) + d := &Dryrun{ + cfg: cfg, + cluster: cluster, + RunTag: tag, + RunDir: filepath.Join(cfg.WorkRoot, tag), + Redactor: NewRedactor(adminDSN, kekSeed), + kekSeed: kekSeed, + now: time.Now().UTC(), + registryDB: tag + "_registry", + archivmailDB: tag + "_quelle_archivmail", + archivdmsDB: tag + "_quelle_archivdms", + tenantPools: map[string]*pgxpool.Pool{}, + } + d.contextPath = filepath.Join(d.RunDir, "kontext.json") + d.stages = StageRunner{ + BinDir: filepath.Join(d.RunDir, "bin"), ContextPath: d.contextPath, + AdminDSN: adminDSN, KEKSeed: kekSeed, Redactor: d.Redactor, + } + return d, nil +} + +func (d *Dryrun) legacyObjectsDir() string { return filepath.Join(d.RunDir, "altsystem-objekte") } + +// TargetSlug: Ziel-Slugs muessen dem Core-Muster ^[a-z][a-z0-9_]{1,48}$ +// genuegen und duerfen auf dem geteilten Wegwerf-Cluster nicht mit +// fremden tenant_*-Datenbanken kollidieren – daher Laufpraefix. +func TargetSlug(runTag, legacySlug string) string { + s := runTag + "_" + strings.ReplaceAll(strings.ToLower(legacySlug), "-", "_") + if len(s) > 49 { + s = s[:49] + } + return strings.TrimRight(s, "_") +} + +func (d *Dryrun) tenantPool(ctx context.Context, dbName string) (*pgxpool.Pool, error) { + if p, ok := d.tenantPools[dbName]; ok { + return p, nil + } + p, err := d.cluster.Connect(ctx, dbName) + if err != nil { + return nil, err + } + d.tenantPools[dbName] = p + return p, nil +} + +func (d *Dryrun) Close() { + for _, p := range d.tenantPools { + p.Close() + } + for _, p := range []*pgxpool.Pool{d.registry, d.archivmail, d.archivdms, d.exportArchivmail, d.exportArchivdms} { + if p != nil { + p.Close() + } + } +} + +// Cleanup entfernt alle Datenbanken und Arbeitsdateien des Laufs bis auf +// das Protokollverzeichnis. +func (d *Dryrun) Cleanup(ctx context.Context) ([]string, error) { + d.Close() + d.tenantPools = map[string]*pgxpool.Pool{} + d.registry, d.archivmail, d.archivdms, d.exportArchivmail, d.exportArchivdms = nil, nil, nil, nil, nil + dropped, err := d.cluster.DropRunDatabases(ctx, d.RunTag) + for _, sub := range []string{"altsystem-objekte", "ziel-objekte", "uebergabe", "val02", "bin", "kontext.json"} { + _ = os.RemoveAll(filepath.Join(d.RunDir, sub)) + } + return dropped, err +} + +func countsOf(kv ...any) map[string]int { + m := map[string]int{} + for i := 0; i+1 < len(kv); i += 2 { + m[fmt.Sprint(kv[i])] = kv[i+1].(int) + } + return m +} + +func (d *Dryrun) Plan() []Step { + return []Step{ + {ID: "PREP-01", Title: "Arbeitsverzeichnis anlegen, Modulstufen bauen", Run: d.prepWorkdir}, + {ID: "PREP-02", Title: "Synthetische Testkopie archivmail/archivdms erzeugen", DependsOn: []string{"PREP-01"}, Run: d.prepLegacyCopy}, + {ID: "PREP-03", Title: "Anonymisierungsnachweis der Testkopie", DependsOn: []string{"PREP-02"}, Run: d.prepAnonymization}, + {ID: "PREP-04", Title: "Core-Registry anlegen (Core-Migrationen)", DependsOn: []string{"PREP-01"}, Run: d.prepRegistry}, + {ID: "EXP-01", Title: "Export-Sichten der Altsysteme bereitstellen", DependsOn: []string{"PREP-03"}, Run: d.exportViews}, + {ID: "MAP-01", Title: "Tenant-Export und Slug/Domain-Abgleich", DependsOn: []string{"EXP-01"}, Run: d.map01}, + {ID: "MAP-02", Title: "Tenant-Migration-Map: Freigabe (Trockenlauf-Pruefer)", DependsOn: []string{"MAP-01", "PREP-04"}, Run: d.map02}, + {ID: "MAP-03", Title: "User-Export und E-Mail-Abgleich je freigegebenem Tenant", DependsOn: []string{"MAP-02"}, Run: d.map03}, + {ID: "MAP-04*", Title: "Benutzer-/Rollen-Map freigeben (Trockenlauf-Ersatz fuer MAP-04)", DependsOn: []string{"MAP-03"}, Run: d.map04}, + {ID: "COR-01", Title: "Tenant-Migration (Provisionierung der Mandanten-DBs)", DependsOn: []string{"MAP-02"}, Run: d.cor01}, + {ID: "SCHEMA-01", Title: "Core-Tenant-Schema ausrollen (TEN-07)", DependsOn: []string{"COR-01"}, Run: d.schemaCore}, + {ID: "SCHEMA-02", Title: "Modul-Schemata anlegen (DMS, Archive, Workflow)", DependsOn: []string{"SCHEMA-01"}, Run: d.schemaModules}, + {ID: "COR-02", Title: "Benutzer-Migration inkl. bcrypt-Hash 1:1", DependsOn: []string{"MAP-04*", "SCHEMA-01"}, Run: d.cor02}, + {ID: "COR-04", Title: "LDAP-Feld-Migration pruefen", DependsOn: []string{"COR-02"}, Run: d.cor04}, + {ID: "COR-03", Title: "Rollen- und Rechte-Migration", DependsOn: []string{"COR-02"}, Run: d.cor03}, + {ID: "COR-05", Title: "Audit-Log-Zusammenfuehrung", DependsOn: []string{"MAP-04*", "COR-01"}, Run: d.cor05}, + {ID: "DOK-01", Title: "Dokument- und Revisions-Migration", DependsOn: []string{"COR-02", "SCHEMA-02"}, Run: d.stage("dms", "dok01")}, + {ID: "DOK-02", Title: "Akten-/Ordnerstruktur-Migration", DependsOn: []string{"DOK-01"}, Run: d.stage("dms", "dok02")}, + {ID: "DOK-04", Title: "Custom-Fields- und Klassifikationsvorlagen-Migration", DependsOn: []string{"DOK-01"}, Run: d.stage("dms", "dok04")}, + {ID: "DOK-03", Title: "ACL-Daten-Migration nach Core-Rechtemodell", DependsOn: []string{"DOK-01", "COR-03"}, Run: d.dok03}, + {ID: "DOK-05", Title: "Freigabelinks-Migration", DependsOn: []string{"DOK-01"}, Run: d.stage("dms", "dok05")}, + {ID: "MAI-02", Title: "Nachrichten- und Anhang-Migration", DependsOn: []string{"COR-01"}, Run: d.stage("mail", "mai02")}, + {ID: "TAR-01", Title: "Retention-Fristen-Migration ohne Reset", DependsOn: []string{"DOK-01", "MAI-02", "SCHEMA-02"}, Run: d.stage("archive", "tar01")}, + {ID: "MAI-03", Title: "DSGVO-Loeschanfragen-Migration", DependsOn: []string{"TAR-01"}, Run: d.stage("archive", "mai03")}, + {ID: "MAI-04", Title: "Reconciliation-Reports-Archivierung", DependsOn: []string{"SCHEMA-02"}, Run: d.stage("archive", "mai04")}, + {ID: "TAR-02", Title: "Vier-Augen-Loeschworkflow-Migration", DependsOn: []string{"TAR-01", "COR-02"}, Run: d.stage("archive", "tar02")}, + {ID: "WFL-01", Title: "Workflow-Historie-Migration", DependsOn: []string{"COR-02", "SCHEMA-02"}, Run: d.stage("workflow", "wfl01")}, + {ID: "VAL-01", Title: "Zeilenzahl-Abgleich je Kerntabelle", DependsOn: []string{"COR-02", "DOK-01", "MAI-02"}, Run: d.val01}, + {ID: "VAL-02", Title: "Hash-Vergleich migrierter Binaerdateien", DependsOn: []string{"DOK-01", "MAI-02"}, Run: d.val02}, + {ID: "VAL-05", Title: "Login-Test mit migrierten Zugangsdaten", DependsOn: []string{"COR-02"}, Run: d.val05}, + {ID: "CHK-01", Title: "Modulgrenzen-uebergreifende Kettenpruefung", DependsOn: []string{"DOK-03", "DOK-05", "TAR-02", "MAI-03", "MAI-04", "WFL-01"}, Run: d.chk01}, + } +} + +func (d *Dryrun) stage(module, stage string) func(context.Context) (Outcome, error) { + return func(ctx context.Context) (Outcome, error) { + return d.stages.Run(ctx, module, stage) + } +} diff --git a/internal/cutoverorchestrator/export.go b/internal/cutoverorchestrator/export.go new file mode 100644 index 00000000..59ff1a90 --- /dev/null +++ b/internal/cutoverorchestrator/export.go @@ -0,0 +1,56 @@ +package cutoverorchestrator + +import ( + "context" + "fmt" +) + +const exportSchema = "cutover_export" + +// Export-Sichten normalisieren die Altsystem-Tabellen auf die Form, die +// tenantmatch.ExportTenants (id, slug, name, domain) und +// usermatch.ExportUsers (id, tenant_id, email, name, password_hash) lesen. +// Lauf 1.1 zeigte: archivmail fuehrt Domains in tenant_domains (1:n), beide +// Altsysteme kennen nur users.username, archivmail-Superadmins haben keinen +// Mandanten. Teil des Export-Dumps, nicht der MAP-Pakete. +const archivmailExportViewsSQL = ` +CREATE SCHEMA cutover_export; +CREATE VIEW cutover_export.tenants AS + SELECT t.id, t.slug, t.name, + COALESCE((SELECT d.domain FROM public.tenant_domains d WHERE d.tenant_id = t.id ORDER BY d.id LIMIT 1), '') AS domain + FROM public.tenants t; +CREATE VIEW cutover_export.users AS + SELECT u.id, u.tenant_id, u.email, u.username AS name, u.password_hash + FROM public.users u WHERE u.tenant_id IS NOT NULL; +` + +const archivdmsExportViewsSQL = ` +CREATE SCHEMA cutover_export; +CREATE VIEW cutover_export.tenants AS + SELECT t.id, t.slug, t.name, COALESCE(t.domain, '') AS domain FROM public.tenants t; +CREATE VIEW cutover_export.users AS + SELECT u.id, u.tenant_id, u.email, u.username AS name, u.password_hash FROM public.users u; +` + +func (d *Dryrun) exportViews(ctx context.Context) (Outcome, error) { + if _, err := d.archivmail.Exec(ctx, archivmailExportViewsSQL); err != nil { + return Outcome{}, fmt.Errorf("archivmail-export-sichten: %w", err) + } + if _, err := d.archivdms.Exec(ctx, archivdmsExportViewsSQL); err != nil { + return Outcome{}, fmt.Errorf("archivdms-export-sichten: %w", err) + } + var err error + if d.exportArchivmail, err = d.cluster.ConnectSchema(ctx, d.archivmailDB, exportSchema); err != nil { + return Outcome{}, err + } + if d.exportArchivdms, err = d.cluster.ConnectSchema(ctx, d.archivdmsDB, exportSchema); err != nil { + return Outcome{}, err + } + var platformAccounts int + if err := d.archivmail.QueryRow(ctx, `SELECT count(*) FROM users WHERE tenant_id IS NULL`).Scan(&platformAccounts); err != nil { + return Outcome{}, err + } + out := Outcome{Counts: countsOf("export_sichten", 4, "plattformkonten_ohne_mandant", platformAccounts)} + out.Notes = []string{"archivmail-domain fuer MAP-01: erste registrierte domain je mandant (MAP-01 vergleicht genau eine domain)"} + return out, nil +} diff --git a/internal/cutoverorchestrator/legacyschema.go b/internal/cutoverorchestrator/legacyschema.go new file mode 100644 index 00000000..dd3b9829 --- /dev/null +++ b/internal/cutoverorchestrator/legacyschema.go @@ -0,0 +1,302 @@ +package cutoverorchestrator + +// Strukturell an datenmodell-altsysteme.md angelehnte Teilmenge der +// Altsystem-Tabellen, soweit sie fuer die Migrationsschritte gelesen +// werden. Spaltennamen folgen bewusst den Altsystemen (z.B. archivmail +// users.username statt name, tenants ohne domain-Spalte), damit +// Schema-Annahmen der Migrationspakete im Trockenlauf auffallen. + +const archivmailSchemaSQL = ` +CREATE TABLE tenants ( + id BIGSERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL UNIQUE, + slug VARCHAR(100) NOT NULL UNIQUE, + active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + retention_days INT NOT NULL DEFAULT 0 +); +CREATE TABLE tenant_domains ( + id BIGSERIAL PRIMARY KEY, + tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + domain VARCHAR(255) NOT NULL UNIQUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE TABLE users ( + id BIGSERIAL PRIMARY KEY, + username VARCHAR(255) NOT NULL UNIQUE, + email VARCHAR(255) NOT NULL UNIQUE, + password_hash VARCHAR(255) NOT NULL, + role VARCHAR(20) NOT NULL DEFAULT 'user', + source VARCHAR(20) NOT NULL DEFAULT 'local', + active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_login_at TIMESTAMPTZ, + tenant_id BIGINT +); +CREATE TABLE storage_objects ( + id BIGSERIAL PRIMARY KEY, + storage_type TEXT NOT NULL DEFAULT 'filesystem', + path TEXT NOT NULL, + compression TEXT, + size_original BIGINT, + size_compressed BIGINT, + checksum CHAR(64) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE TABLE emails ( + id TEXT PRIMARY KEY, + received_at TIMESTAMPTZ NOT NULL, + mail_from TEXT, + mail_to TEXT, + subject TEXT, + size_bytes BIGINT, + has_attach BOOLEAN NOT NULL DEFAULT false, + indexed_at TIMESTAMPTZ, + tenant_id BIGINT, + message_id TEXT, + retain_until TIMESTAMPTZ, + retain_until_source TEXT, + storage_id BIGINT REFERENCES storage_objects(id) +); +CREATE TABLE attachments ( + id BIGSERIAL PRIMARY KEY, + filename TEXT, + mime_type TEXT, + size_bytes BIGINT, + hash CHAR(64) UNIQUE, + storage_id BIGINT REFERENCES storage_objects(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE TABLE email_attachments ( + email_id TEXT NOT NULL REFERENCES emails(id), + attachment_id BIGINT NOT NULL REFERENCES attachments(id), + PRIMARY KEY (email_id, attachment_id) +); +CREATE TABLE email_refs ( + id BIGSERIAL PRIMARY KEY, + email_id TEXT NOT NULL REFERENCES emails(id), + tenant_id BIGINT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (email_id, tenant_id) +); +CREATE TABLE audit_log ( + id BIGSERIAL PRIMARY KEY, + timestamp TIMESTAMPTZ NOT NULL, + event_type TEXT NOT NULL, + username TEXT, + ip_address TEXT, + query TEXT, + mail_id TEXT, + success BOOLEAN, + detail TEXT, + tenant_id BIGINT +); +CREATE FUNCTION audit_log_append_only() RETURNS trigger AS $$ +BEGIN + RAISE EXCEPTION 'audit_log ist append-only'; +END; +$$ LANGUAGE plpgsql; +CREATE TRIGGER audit_log_no_mutation BEFORE UPDATE OR DELETE ON audit_log + FOR EACH ROW EXECUTE FUNCTION audit_log_append_only(); +CREATE TABLE reconciliation_reports ( + id BIGSERIAL PRIMARY KEY, + date DATE NOT NULL, + tenant_id BIGINT NOT NULL, + source_type TEXT, + source_id BIGINT, + expected_count INT, + archived_count INT, + delta INT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE TABLE dsgvo_requests ( + id BIGSERIAL PRIMARY KEY, + tenant_id BIGINT NOT NULL, + requested_address TEXT NOT NULL, + requested_by TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + status TEXT NOT NULL CHECK (status IN ('open', 'partial', 'completed', 'failed')), + result_summary JSONB +); +` + +const archivdmsSchemaSQL = ` +CREATE TABLE tenants ( + id BIGSERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + slug VARCHAR(100) NOT NULL UNIQUE, + domain VARCHAR(255), + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE UNIQUE INDEX tenants_domain_uniq ON tenants (domain) WHERE domain IS NOT NULL; +CREATE TABLE users ( + id BIGSERIAL PRIMARY KEY, + username VARCHAR(255) NOT NULL UNIQUE, + email VARCHAR(255) NOT NULL UNIQUE, + password_hash VARCHAR(255) NOT NULL, + role VARCHAR(20) NOT NULL DEFAULT 'user', + active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_login_at TIMESTAMPTZ, + tenant_id BIGINT NOT NULL, + auth_source VARCHAR(20) NOT NULL DEFAULT 'local', + ldap_uid TEXT, + ldap_synced_at TIMESTAMPTZ +); +CREATE UNIQUE INDEX users_tenant_ldap_uid ON users (tenant_id, ldap_uid) WHERE ldap_uid IS NOT NULL; +CREATE TABLE permission_groups ( + id BIGSERIAL PRIMARY KEY, + tenant_id BIGINT NOT NULL, + name TEXT NOT NULL, + UNIQUE (tenant_id, name) +); +CREATE TABLE permission_group_members ( + group_id BIGINT NOT NULL REFERENCES permission_groups(id) ON DELETE CASCADE, + user_id BIGINT NOT NULL, + PRIMARY KEY (group_id, user_id) +); +CREATE TABLE document_types ( + id BIGSERIAL PRIMARY KEY, + tenant_id BIGINT NOT NULL, + name TEXT NOT NULL, + UNIQUE (tenant_id, name) +); +CREATE TABLE akten ( + id BIGSERIAL PRIMARY KEY, + tenant_id BIGINT NOT NULL, + titel TEXT NOT NULL, + beschreibung TEXT, + status TEXT NOT NULL DEFAULT 'offen' CHECK (status IN ('offen', 'geschlossen')), + created_by BIGINT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + closed_at TIMESTAMPTZ +); +CREATE TABLE documents ( + id BIGSERIAL PRIMARY KEY, + tenant_id BIGINT NOT NULL, + title TEXT NOT NULL, + storage_path TEXT NOT NULL, + content_hash TEXT NOT NULL, + retain_until DATE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + created_by BIGINT, + doc_type_id BIGINT REFERENCES document_types(id), + akte_id BIGINT REFERENCES akten(id) ON DELETE SET NULL, + deleted_at TIMESTAMPTZ, + deleted_by BIGINT, + UNIQUE (tenant_id, content_hash) +); +CREATE TABLE document_grants ( + id BIGSERIAL PRIMARY KEY, + document_id BIGINT NOT NULL REFERENCES documents(id) ON DELETE CASCADE, + group_id BIGINT NOT NULL REFERENCES permission_groups(id) ON DELETE CASCADE, + access TEXT NOT NULL CHECK (access IN ('read', 'write', 'deny')), + UNIQUE (document_id, group_id, access) +); +CREATE TABLE document_visibility ( + document_id BIGINT NOT NULL, + group_id BIGINT NOT NULL, + access TEXT NOT NULL CHECK (access IN ('read', 'write')), + PRIMARY KEY (document_id, group_id) +); +CREATE TABLE custom_field_defs ( + id BIGSERIAL PRIMARY KEY, + tenant_id BIGINT NOT NULL, + name TEXT NOT NULL, + field_type TEXT NOT NULL CHECK (field_type IN ('text', 'number', 'date', 'boolean', 'enum', 'monetary')), + required BOOLEAN NOT NULL DEFAULT false, + enum_options JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (tenant_id, name) +); +CREATE TABLE document_field_values ( + document_id BIGINT NOT NULL REFERENCES documents(id) ON DELETE CASCADE, + field_def_id BIGINT NOT NULL REFERENCES custom_field_defs(id), + value TEXT, + PRIMARY KEY (document_id, field_def_id) +); +CREATE TABLE classification_templates ( + id BIGSERIAL PRIMARY KEY, + tenant_id BIGINT NOT NULL, + name TEXT NOT NULL, + doc_type_id BIGINT REFERENCES document_types(id), + retention_years INT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (tenant_id, name) +); +CREATE TABLE classification_template_field_defaults ( + template_id BIGINT NOT NULL REFERENCES classification_templates(id) ON DELETE CASCADE, + field_def_id BIGINT NOT NULL REFERENCES custom_field_defs(id), + default_value TEXT, + PRIMARY KEY (template_id, field_def_id) +); +CREATE TABLE document_shares ( + id BIGSERIAL PRIMARY KEY, + tenant_id BIGINT NOT NULL, + document_id BIGINT NOT NULL REFERENCES documents(id) ON DELETE CASCADE, + token_hash TEXT NOT NULL UNIQUE, + created_by BIGINT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + expires_at TIMESTAMPTZ NOT NULL, + max_accesses INT, + access_count INT NOT NULL DEFAULT 0, + password_hash TEXT, + revoked_at TIMESTAMPTZ, + revoked_by BIGINT +); +CREATE TABLE document_delete_requests ( + id BIGSERIAL PRIMARY KEY, + document_id BIGINT NOT NULL REFERENCES documents(id) ON DELETE CASCADE, + tenant_id BIGINT NOT NULL, + requested_by BIGINT NOT NULL, + requested_at TIMESTAMPTZ NOT NULL, + confirmed_by BIGINT, + confirmed_at TIMESTAMPTZ, + status TEXT NOT NULL CHECK (status IN ('pending', 'confirmed', 'executed', 'cancelled', 'blocked_retention')), + UNIQUE (document_id, status) +); +CREATE TABLE workflows ( + id BIGSERIAL PRIMARY KEY, + tenant_id BIGINT NOT NULL, + name TEXT NOT NULL, + active BOOLEAN NOT NULL DEFAULT true +); +CREATE TABLE workflow_runs ( + id BIGSERIAL PRIMARY KEY, + workflow_id BIGINT REFERENCES workflows(id), + tenant_id BIGINT NOT NULL, + document_id BIGINT, + status TEXT NOT NULL CHECK (status IN ('running', 'completed', 'aborted')), + final_state TEXT, + started_at TIMESTAMPTZ NOT NULL, + finished_at TIMESTAMPTZ +); +CREATE TABLE workflow_run_events ( + id BIGSERIAL PRIMARY KEY, + run_id BIGINT NOT NULL REFERENCES workflow_runs(id) ON DELETE CASCADE, + from_state TEXT NOT NULL, + to_state TEXT NOT NULL, + actor_user_id BIGINT NOT NULL, + note TEXT NOT NULL DEFAULT '', + occurred_at TIMESTAMPTZ NOT NULL +); +CREATE TABLE audit_log ( + id BIGSERIAL PRIMARY KEY, + timestamp TIMESTAMPTZ NOT NULL, + event_type TEXT NOT NULL, + username TEXT, + ip_address TEXT, + document_id VARCHAR(64), + success BOOLEAN, + detail TEXT, + tenant_id BIGINT +); +CREATE FUNCTION audit_log_append_only() RETURNS trigger AS $$ +BEGIN + RAISE EXCEPTION 'audit_log ist append-only'; +END; +$$ LANGUAGE plpgsql; +CREATE TRIGGER audit_log_no_mutation BEFORE UPDATE OR DELETE ON audit_log + FOR EACH ROW EXECUTE FUNCTION audit_log_append_only(); +` diff --git a/internal/cutoverorchestrator/plan.go b/internal/cutoverorchestrator/plan.go new file mode 100644 index 00000000..d26cf6da --- /dev/null +++ b/internal/cutoverorchestrator/plan.go @@ -0,0 +1,51 @@ +// Package cutoverorchestrator implementiert CUT-01: den Trockenlauf aller +// Migrationsschritte in technisch korrekter Reihenfolge gegen eine +// vollstaendig synthetische Testkopie der Altsysteme archivmail/archivdms. +// Die Migrationslogik selbst bleibt in den jeweiligen Paketen; hier liegen +// nur Reihenfolge, Quell-Adapter, Testkopie und Protokoll. +package cutoverorchestrator + +import ( + "context" + "errors" + "fmt" +) + +type Outcome struct { + Counts map[string]int + Notes []string +} + +type Step struct { + ID string + Title string + DependsOn []string + Run func(ctx context.Context) (Outcome, error) +} + +var ErrInvalidPlan = errors.New("cutoverorchestrator: ungueltiger ablaufplan") + +// ValidatePlan erzwingt, dass jede Abhaengigkeit eines Schritts vorher im +// Plan steht; eine Umsortierung, die z.B. DOK-03 vor DOK-01 legt, scheitert +// damit vor dem ersten Datenbankzugriff. +func ValidatePlan(steps []Step) error { + seen := make(map[string]bool, len(steps)) + for i, s := range steps { + if s.ID == "" { + return fmt.Errorf("%w: schritt an position %d ohne id", ErrInvalidPlan, i+1) + } + if s.Run == nil { + return fmt.Errorf("%w: schritt %s ohne ausfuehrung", ErrInvalidPlan, s.ID) + } + if seen[s.ID] { + return fmt.Errorf("%w: schritt %s doppelt im plan", ErrInvalidPlan, s.ID) + } + for _, dep := range s.DependsOn { + if !seen[dep] { + return fmt.Errorf("%w: %s haengt von %s ab, das nicht vorher im plan steht", ErrInvalidPlan, s.ID, dep) + } + } + seen[s.ID] = true + } + return nil +} diff --git a/internal/cutoverorchestrator/protocol.go b/internal/cutoverorchestrator/protocol.go new file mode 100644 index 00000000..4137769a --- /dev/null +++ b/internal/cutoverorchestrator/protocol.go @@ -0,0 +1,72 @@ +package cutoverorchestrator + +import ( + "encoding/json" + "fmt" + "os" + "sort" + "strings" +) + +func formatCounts(counts map[string]int) string { + if len(counts) == 0 { + return "" + } + keys := make([]string, 0, len(counts)) + for k := range counts { + keys = append(keys, k) + } + sort.Strings(keys) + parts := make([]string, 0, len(keys)) + for _, k := range keys { + parts = append(parts, fmt.Sprintf("%s=%d", k, counts[k])) + } + return "[" + strings.Join(parts, " ") + "]" +} + +func WriteJSON(path string, rep Report) error { + data, err := json.MarshalIndent(rep, "", " ") + if err != nil { + return fmt.Errorf("protokoll serialisieren: %w", err) + } + if err := os.WriteFile(path, append(data, '\n'), 0o644); err != nil { + return fmt.Errorf("protokoll schreiben: %w", err) + } + return nil +} + +func RenderText(rep Report) string { + var b strings.Builder + fmt.Fprintf(&b, "CUT-01 Trockenlauf-Protokoll\n") + fmt.Fprintf(&b, "Lauf: %s (%s)\n", rep.RunTag, rep.Label) + fmt.Fprintf(&b, "Beginn: %s Ende: %s Dauer: %s\n", + rep.StartedAt.Format("2006-01-02 15:04:05 MST"), rep.FinishedAt.Format("2006-01-02 15:04:05 MST"), + rep.FinishedAt.Sub(rep.StartedAt).Round(1e6)) + if rep.Success { + fmt.Fprintf(&b, "Ergebnis: ERFOLGREICH – alle %d Schritte ohne Abbruch durchgelaufen\n", len(rep.Steps)) + } else { + fmt.Fprintf(&b, "Ergebnis: ABGEBROCHEN bei %s\n", rep.AbortedAt) + } + if rep.PlanError != "" { + fmt.Fprintf(&b, "Planfehler: %s\n", rep.PlanError) + } + b.WriteString("\n") + + for i, s := range rep.Steps { + fmt.Fprintf(&b, "%2d. [%s] %s – %s", i+1, s.ID, s.Title, s.Status) + if s.Status != StatusNotRun { + fmt.Fprintf(&b, " (%d ms)", s.DurationMS) + } + b.WriteString("\n") + if c := formatCounts(s.Counts); c != "" { + fmt.Fprintf(&b, " Zaehler: %s\n", c) + } + for _, n := range s.Notes { + fmt.Fprintf(&b, " Hinweis: %s\n", n) + } + if s.Error != "" { + fmt.Fprintf(&b, " FEHLER: %s\n", s.Error) + } + } + return b.String() +} diff --git a/internal/cutoverorchestrator/redact.go b/internal/cutoverorchestrator/redact.go new file mode 100644 index 00000000..14cf7357 --- /dev/null +++ b/internal/cutoverorchestrator/redact.go @@ -0,0 +1,42 @@ +package cutoverorchestrator + +import ( + "regexp" + "strings" +) + +var ( + urlUserinfo = regexp.MustCompile(`(?i)\b([a-z][a-z0-9+.-]*://)[^\s/@]+@`) + kvPassword = regexp.MustCompile(`(?i)\b(password|passwd|pwd)(\s*=\s*)('[^']*'|"[^"]*"|[^\s&]+)`) +) + +const redacted = "" + +type Redactor struct { + literals []string +} + +func NewRedactor(literals ...string) *Redactor { + r := &Redactor{} + for _, l := range literals { + if len(l) >= 6 { + r.literals = append(r.literals, l) + } + } + return r +} + +// Redact entfernt Zugangsdaten aus Texten, die in Protokolle oder auf die +// Konsole gelangen. Literale zuerst, weil ein bekannter Wert (z.B. der +// komplette Admin-DSN) auch dann ersetzt werden muss, wenn er in einer +// Form auftaucht, die die Muster nicht erkennen. +func (r *Redactor) Redact(s string) string { + if r != nil { + for _, l := range r.literals { + s = strings.ReplaceAll(s, l, redacted) + } + } + s = urlUserinfo.ReplaceAllString(s, "${1}"+redacted+"@") + s = kvPassword.ReplaceAllString(s, "${1}${2}"+redacted) + return s +} diff --git a/internal/cutoverorchestrator/runner.go b/internal/cutoverorchestrator/runner.go new file mode 100644 index 00000000..dbfad937 --- /dev/null +++ b/internal/cutoverorchestrator/runner.go @@ -0,0 +1,125 @@ +package cutoverorchestrator + +import ( + "context" + "fmt" + "io" + "time" +) + +type Status string + +const ( + StatusSucceeded Status = "erfolgreich" + StatusFailed Status = "fehlgeschlagen" + StatusNotRun Status = "nicht ausgefuehrt" +) + +type StepRecord struct { + ID string `json:"id"` + Title string `json:"titel"` + DependsOn []string `json:"haengt_ab_von,omitempty"` + Status Status `json:"status"` + StartedAt *time.Time `json:"gestartet,omitempty"` + DurationMS int64 `json:"dauer_ms"` + Counts map[string]int `json:"zaehler,omitempty"` + Notes []string `json:"hinweise,omitempty"` + Error string `json:"fehler,omitempty"` +} + +type Report struct { + RunTag string `json:"lauf_kennung"` + Label string `json:"bezeichnung"` + StartedAt time.Time `json:"gestartet"` + FinishedAt time.Time `json:"beendet"` + Success bool `json:"erfolgreich"` + AbortedAt string `json:"abbruch_bei,omitempty"` + PlanError string `json:"planfehler,omitempty"` + Steps []StepRecord `json:"schritte"` +} + +type Runner struct { + Now func() time.Time + Log io.Writer + Redactor *Redactor +} + +func (r Runner) now() time.Time { + if r.Now != nil { + return r.Now() + } + return time.Now() +} + +func (r Runner) logf(format string, args ...any) { + if r.Log == nil { + return + } + _, _ = fmt.Fprintf(r.Log, "%s\n", r.Redactor.Redact(fmt.Sprintf(format, args...))) +} + +// Run fuehrt die Schritte strikt in Planreihenfolge aus und bricht beim +// ersten Fehler ab: alle Folgeschritte bauen auf den Daten der Vorgaenger +// auf, ein Weiterlaufen wuerde Folgefehler statt der Ursache protokollieren. +func (r Runner) Run(ctx context.Context, runTag, label string, steps []Step) Report { + rep := Report{RunTag: runTag, Label: label, StartedAt: r.now()} + + if err := ValidatePlan(steps); err != nil { + rep.PlanError = r.Redactor.Redact(err.Error()) + rep.AbortedAt = "ablaufplan" + for _, s := range steps { + rep.Steps = append(rep.Steps, StepRecord{ID: s.ID, Title: s.Title, DependsOn: s.DependsOn, Status: StatusNotRun}) + } + rep.FinishedAt = r.now() + r.logf("ABBRUCH vor dem ersten Schritt: %s", rep.PlanError) + return rep + } + + for _, s := range steps { + rec := StepRecord{ID: s.ID, Title: s.Title, DependsOn: s.DependsOn, Status: StatusNotRun} + if rep.AbortedAt != "" { + rec.Notes = []string{"nicht ausgefuehrt: Abbruch nach Fehler in " + rep.AbortedAt} + rep.Steps = append(rep.Steps, rec) + continue + } + + started := r.now() + rec.StartedAt = &started + r.logf("[%s] %s ...", s.ID, s.Title) + + var out Outcome + err := ctx.Err() + if err == nil { + out, err = runGuarded(ctx, s) + } + rec.DurationMS = r.now().Sub(started).Milliseconds() + rec.Counts = out.Counts + for _, n := range out.Notes { + rec.Notes = append(rec.Notes, r.Redactor.Redact(n)) + } + + if err != nil { + rec.Status = StatusFailed + rec.Error = r.Redactor.Redact(err.Error()) + rep.AbortedAt = s.ID + r.logf("[%s] FEHLGESCHLAGEN nach %d ms: %s", s.ID, rec.DurationMS, rec.Error) + } else { + rec.Status = StatusSucceeded + r.logf("[%s] erfolgreich (%d ms) %s", s.ID, rec.DurationMS, formatCounts(rec.Counts)) + } + rep.Steps = append(rep.Steps, rec) + } + + rep.FinishedAt = r.now() + rep.Success = rep.AbortedAt == "" + return rep +} + +func runGuarded(ctx context.Context, s Step) (out Outcome, err error) { + defer func() { + if p := recover(); p != nil { + err = fmt.Errorf("unerwarteter programmabbruch (panic) in %s: %v", s.ID, p) + } + }() + return s.Run(ctx) +} diff --git a/internal/cutoverorchestrator/runner_test.go b/internal/cutoverorchestrator/runner_test.go new file mode 100644 index 00000000..da28301c --- /dev/null +++ b/internal/cutoverorchestrator/runner_test.go @@ -0,0 +1,169 @@ +package cutoverorchestrator + +import ( + "bytes" + "context" + "errors" + "strings" + "testing" +) + +func recordingStep(id string, order *[]string, deps ...string) Step { + return Step{ID: id, Title: "Schritt " + id, DependsOn: deps, Run: func(context.Context) (Outcome, error) { + *order = append(*order, id) + return Outcome{Counts: map[string]int{"zeilen": len(*order)}}, nil + }} +} + +func TestRunner_ExecutesStepsInPlanOrder(t *testing.T) { + var order []string + steps := []Step{ + recordingStep("A", &order), + recordingStep("B", &order, "A"), + recordingStep("C", &order, "A", "B"), + } + rep := Runner{}.Run(context.Background(), "lauf", "test", steps) + if !rep.Success || rep.AbortedAt != "" { + t.Fatalf("lauf sollte erfolgreich sein: %+v", rep) + } + if strings.Join(order, ",") != "A,B,C" { + t.Fatalf("reihenfolge = %v, erwartet A,B,C", order) + } + for i, s := range rep.Steps { + if s.Status != StatusSucceeded || s.Counts["zeilen"] != i+1 { + t.Fatalf("schritt %s: status=%s zaehler=%v", s.ID, s.Status, s.Counts) + } + } +} + +func TestRunner_AbortsAfterFailingStepAndRecordsError(t *testing.T) { + var order []string + steps := []Step{ + recordingStep("A", &order), + {ID: "B", Title: "faellt aus", DependsOn: []string{"A"}, Run: func(context.Context) (Outcome, error) { + order = append(order, "B") + return Outcome{Notes: []string{"teilweise gelaufen"}}, errors.New("tenant x: zeile 3 kaputt") + }}, + recordingStep("C", &order, "B"), + recordingStep("D", &order), + } + var log bytes.Buffer + rep := Runner{Log: &log}.Run(context.Background(), "lauf", "test", steps) + + if rep.Success || rep.AbortedAt != "B" { + t.Fatalf("erwarte abbruch bei B, habe success=%v abortedAt=%q", rep.Success, rep.AbortedAt) + } + if strings.Join(order, ",") != "A,B" { + t.Fatalf("nach dem fehler darf kein schritt mehr laufen, ausgefuehrt: %v", order) + } + b := rep.Steps[1] + if b.Status != StatusFailed || b.Error != "tenant x: zeile 3 kaputt" || len(b.Notes) != 1 { + t.Fatalf("fehlerprotokoll von B unvollstaendig: %+v", b) + } + for _, s := range rep.Steps[2:] { + if s.Status != StatusNotRun || len(s.Notes) == 0 || !strings.Contains(s.Notes[0], "Abbruch nach Fehler in B") { + t.Fatalf("schritt %s muss als nicht ausgefuehrt mit abbruchgrund protokolliert sein: %+v", s.ID, s) + } + } + if !strings.Contains(log.String(), "[B] FEHLGESCHLAGEN") { + t.Fatalf("konsolenprotokoll ohne fehlerzeile: %s", log.String()) + } + text := RenderText(rep) + if !strings.Contains(text, "ABGEBROCHEN bei B") || !strings.Contains(text, "FEHLER: tenant x: zeile 3 kaputt") { + t.Fatalf("textprotokoll unvollstaendig:\n%s", text) + } +} + +func TestRunner_PanicIsRecordedAsFailure(t *testing.T) { + var order []string + steps := []Step{ + {ID: "A", Title: "panik", Run: func(context.Context) (Outcome, error) { panic("unerwartet") }}, + recordingStep("B", &order, "A"), + } + rep := Runner{}.Run(context.Background(), "lauf", "test", steps) + if rep.AbortedAt != "A" || rep.Steps[0].Status != StatusFailed || !strings.Contains(rep.Steps[0].Error, "panic") { + t.Fatalf("panic muss als fehler protokolliert werden: %+v", rep.Steps[0]) + } + if len(order) != 0 { + t.Fatalf("nach panic darf nichts mehr laufen: %v", order) + } +} + +func TestRunner_InvalidOrderRunsNothing(t *testing.T) { + var order []string + steps := []Step{ + recordingStep("DOK-03", &order, "DOK-01"), + recordingStep("DOK-01", &order), + } + rep := Runner{}.Run(context.Background(), "lauf", "test", steps) + if rep.Success || rep.PlanError == "" || len(order) != 0 { + t.Fatalf("ein plan mit verletzter abhaengigkeit darf keinen schritt ausfuehren: %+v order=%v", rep, order) + } + for _, s := range rep.Steps { + if s.Status != StatusNotRun { + t.Fatalf("schritt %s status %s", s.ID, s.Status) + } + } +} + +func TestRunner_CancelledContextStopsRun(t *testing.T) { + var order []string + ctx, cancel := context.WithCancel(context.Background()) + steps := []Step{ + {ID: "A", Title: "bricht ab", Run: func(context.Context) (Outcome, error) { + cancel() + return Outcome{}, nil + }}, + recordingStep("B", &order, "A"), + } + rep := Runner{}.Run(ctx, "lauf", "test", steps) + if rep.AbortedAt != "B" || len(order) != 0 { + t.Fatalf("abgebrochener kontext muss den naechsten schritt verhindern: %+v", rep) + } +} + +func TestRunner_RedactsSecretsInErrorsAndLog(t *testing.T) { + secretDSN := "postgresql://cutover:sehr-geheim-123@db.intern:5433/postgres?sslmode=disable" + steps := []Step{{ID: "A", Title: "verbindung", Run: func(context.Context) (Outcome, error) { + return Outcome{Notes: []string{"dsn " + secretDSN}}, errors.New("connect " + secretDSN + " failed; host=db password=nochmal-geheim") + }}} + var log bytes.Buffer + rep := Runner{Log: &log, Redactor: NewRedactor(secretDSN)}.Run(context.Background(), "lauf", "test", steps) + all := rep.Steps[0].Error + strings.Join(rep.Steps[0].Notes, " ") + log.String() + RenderText(rep) + for _, leak := range []string{"sehr-geheim-123", "nochmal-geheim", secretDSN} { + if strings.Contains(all, leak) { + t.Fatalf("geheimnis %q im protokoll: %s", leak, all) + } + } +} + +func TestDryrunPlan_RespectsMigrationDependencies(t *testing.T) { + d, err := NewDryrun(Config{RepoRoot: ".", WorkRoot: t.TempDir(), Seed: 1, BcryptCost: 4}, "postgresql://u:p@localhost:5433/postgres?sslmode=disable") + if err != nil { + t.Fatal(err) + } + plan := d.Plan() + if err := ValidatePlan(plan); err != nil { + t.Fatalf("ablaufplan ungueltig: %v", err) + } + pos := map[string]int{} + for i, s := range plan { + pos[s.ID] = i + } + before := [][2]string{ + {"MAP-01", "MAP-02"}, {"MAP-02", "COR-01"}, {"MAP-02", "MAP-03"}, {"COR-01", "COR-02"}, + {"COR-02", "COR-03"}, {"COR-02", "DOK-01"}, {"DOK-01", "DOK-02"}, {"DOK-01", "DOK-03"}, + {"DOK-01", "DOK-04"}, {"DOK-01", "DOK-05"}, {"COR-02", "WFL-01"}, {"DOK-01", "TAR-01"}, + {"MAI-02", "TAR-01"}, {"TAR-01", "MAI-03"}, {"TAR-01", "TAR-02"}, {"PREP-03", "EXP-01"}, {"EXP-01", "MAP-01"}, {"EXP-01", "MAP-03"}, + } + for _, b := range before { + if pos[b[0]] >= pos[b[1]] { + t.Errorf("%s muss vor %s laufen", b[0], b[1]) + } + } + for _, id := range []string{"COR-03", "COR-04", "DOK-02", "DOK-03", "DOK-04", "DOK-05", "MAI-03", "MAI-04", "MAP-03", "TAR-01", "TAR-02", "WFL-01", "VAL-01", "VAL-02", "VAL-05"} { + if _, ok := pos[id]; !ok { + t.Errorf("migrationspaket %s fehlt im ablaufplan", id) + } + } +} diff --git a/internal/cutoverorchestrator/seed.go b/internal/cutoverorchestrator/seed.go new file mode 100644 index 00000000..7ee07f6d --- /dev/null +++ b/internal/cutoverorchestrator/seed.go @@ -0,0 +1,222 @@ +package cutoverorchestrator + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +type seedCounts map[string]int + +func execBatch(ctx context.Context, pool *pgxpool.Pool, b *pgx.Batch) error { + if b.Len() == 0 { + return nil + } + return pool.SendBatch(ctx, b).Close() +} + +func SeedArchivmail(ctx context.Context, pool *pgxpool.Pool, d ArchivmailData) (seedCounts, error) { + if _, err := pool.Exec(ctx, archivmailSchemaSQL); err != nil { + return nil, fmt.Errorf("archivmail-schema anlegen: %w", err) + } + b := &pgx.Batch{} + for _, t := range d.Tenants { + b.Queue(`INSERT INTO tenants (id, name, slug) VALUES ($1, $2, $3)`, t.ID, t.Name, t.Slug) + for _, dom := range t.Domains { + b.Queue(`INSERT INTO tenant_domains (tenant_id, domain) VALUES ($1, $2)`, t.ID, dom) + } + } + for _, u := range d.Users { + b.Queue(`INSERT INTO users (id, username, email, password_hash, role, source, tenant_id) VALUES ($1, $2, $3, $4, $5, $6, $7)`, + u.ID, u.Username, u.Email, u.PasswordHash, u.Role, u.Source, u.TenantID) + } + for _, o := range d.StorageObjects { + b.Queue(`INSERT INTO storage_objects (id, path, size_original, checksum) VALUES ($1, $2, $3, $4)`, + o.ID, o.Path, len(o.Content), sha256Hex(o.Content)) + } + contentByStorage := map[int64][]byte{} + for _, o := range d.StorageObjects { + contentByStorage[o.ID] = o.Content + } + for _, a := range d.Attachments { + content := contentByStorage[a.StorageID] + b.Queue(`INSERT INTO attachments (id, filename, mime_type, size_bytes, hash, storage_id) VALUES ($1, $2, $3, $4, $5, $6)`, + a.ID, a.Filename, a.MimeType, len(content), sha256Hex(content), a.StorageID) + } + for _, e := range d.Emails { + b.Queue(`INSERT INTO emails (id, received_at, mail_from, mail_to, subject, size_bytes, has_attach, indexed_at, tenant_id, message_id, retain_until, retain_until_source, storage_id) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)`, + e.ID, e.ReceivedAt, e.From, e.To, e.Subject, len(contentByStorage[e.StorageID]), len(e.AttachmentIDs) > 0, e.IndexedAt, + e.TenantID, "<"+e.ID+"@synthetisch.example>", e.RetainUntil, retainSource(e.RetainUntil != nil), e.StorageID) + for _, aid := range e.AttachmentIDs { + b.Queue(`INSERT INTO email_attachments (email_id, attachment_id) VALUES ($1, $2)`, e.ID, aid) + } + for _, tid := range e.SharedWith { + b.Queue(`INSERT INTO email_refs (email_id, tenant_id) VALUES ($1, $2)`, e.ID, tid) + } + } + for _, r := range d.DsgvoRequests { + b.Queue(`INSERT INTO dsgvo_requests (id, tenant_id, requested_address, requested_by, created_at, status) VALUES ($1, $2, $3, $4, $5, $6)`, + r.ID, r.TenantID, r.Address, r.RequestedBy, r.CreatedAt, r.Status) + } + for _, r := range d.Reports { + b.Queue(`INSERT INTO reconciliation_reports (id, date, tenant_id, source_type, source_id, expected_count, archived_count, delta) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, + r.ID, r.Date, r.TenantID, r.SourceType, r.SourceID, r.Expected, r.Archived, r.Expected-r.Archived) + } + for _, a := range d.Audit { + b.Queue(`INSERT INTO audit_log (id, timestamp, event_type, username, ip_address, query, mail_id, success, detail, tenant_id) + VALUES ($1, $2, $3, $4, $5, NULLIF($6, ''), NULLIF($7, ''), $8, NULLIF($9, ''), $10)`, + a.ID, a.At, a.EventType, a.Username, a.IP, a.Query, a.Ref, a.Success, a.Detail, a.TenantID) + } + if err := execBatch(ctx, pool, b); err != nil { + return nil, fmt.Errorf("archivmail-testkopie befuellen: %w", err) + } + return seedCounts{ + "archivmail_tenants": len(d.Tenants), "archivmail_users": len(d.Users), "archivmail_emails": len(d.Emails), + "archivmail_attachments": len(d.Attachments), "archivmail_dsgvo": len(d.DsgvoRequests), + "archivmail_reports": len(d.Reports), "archivmail_audit": len(d.Audit), + }, nil +} + +func retainSource(set bool) any { + if set { + return "archiving_rule" + } + return nil +} + +func SeedArchivdms(ctx context.Context, pool *pgxpool.Pool, d ArchivdmsData) (seedCounts, error) { + if _, err := pool.Exec(ctx, archivdmsSchemaSQL); err != nil { + return nil, fmt.Errorf("archivdms-schema anlegen: %w", err) + } + b := &pgx.Batch{} + for _, t := range d.Tenants { + b.Queue(`INSERT INTO tenants (id, name, slug, domain) VALUES ($1, $2, $3, $4)`, t.ID, t.Name, t.Slug, t.Domains[0]) + } + for _, u := range d.Users { + b.Queue(`INSERT INTO users (id, username, email, password_hash, role, tenant_id, auth_source, ldap_uid, ldap_synced_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, + u.ID, u.Username, u.Email, u.PasswordHash, u.Role, u.TenantID, u.AuthSource, u.LDAPUID, u.LDAPSyncedAt) + } + for _, g := range d.Groups { + b.Queue(`INSERT INTO permission_groups (id, tenant_id, name) VALUES ($1, $2, $3)`, g.ID, g.TenantID, g.Name) + for _, m := range g.Members { + b.Queue(`INSERT INTO permission_group_members (group_id, user_id) VALUES ($1, $2)`, g.ID, m) + } + } + for _, t := range d.DocTypes { + b.Queue(`INSERT INTO document_types (id, tenant_id, name) VALUES ($1, $2, $3)`, t.ID, t.TenantID, t.Name) + } + for _, a := range d.Akten { + b.Queue(`INSERT INTO akten (id, tenant_id, titel, status, created_by) VALUES ($1, $2, $3, $4, $5)`, a.ID, a.TenantID, a.Titel, a.Status, a.CreatedBy) + } + for _, doc := range d.Documents { + var retain any + if doc.RetainUntil != nil { + retain = doc.RetainUntil.Format("2006-01-02") + } + b.Queue(`INSERT INTO documents (id, tenant_id, title, storage_path, content_hash, retain_until, created_at, updated_at, created_by, doc_type_id, akte_id, deleted_at, deleted_by) + VALUES ($1, $2, $3, $4, $5, $6::date, $7, $7, $8, $9, $10, $11, $12)`, + doc.ID, doc.TenantID, doc.Title, doc.StoragePath, doc.ContentHash, retain, doc.CreatedAt, doc.CreatedBy, doc.DocTypeID, doc.AkteID, doc.DeletedAt, doc.DeletedBy) + } + for _, v := range d.Visibility { + b.Queue(`INSERT INTO document_visibility (document_id, group_id, access) VALUES ($1, $2, $3)`, v.DocumentID, v.GroupID, v.Access) + } + for _, gr := range d.Grants { + b.Queue(`INSERT INTO document_grants (document_id, group_id, access) VALUES ($1, $2, $3)`, gr.DocumentID, gr.GroupID, gr.Access) + } + for _, f := range d.FieldDefs { + var opts any + if len(f.EnumOptions) > 0 { + raw, err := json.Marshal(f.EnumOptions) + if err != nil { + return nil, err + } + opts = string(raw) + } + b.Queue(`INSERT INTO custom_field_defs (id, tenant_id, name, field_type, required, enum_options) VALUES ($1, $2, $3, $4, $5, $6::jsonb)`, + f.ID, f.TenantID, f.Name, f.Type, f.Required, opts) + } + for _, t := range d.Templates { + b.Queue(`INSERT INTO classification_templates (id, tenant_id, name, doc_type_id, retention_years) VALUES ($1, $2, $3, $4, $5)`, + t.ID, t.TenantID, t.Name, t.DocTypeID, t.RetentionYears) + for _, f := range t.Fields { + b.Queue(`INSERT INTO classification_template_field_defaults (template_id, field_def_id, default_value) VALUES ($1, $2, $3)`, t.ID, f.FieldID, f.Default) + } + } + for _, v := range d.FieldValues { + b.Queue(`INSERT INTO document_field_values (document_id, field_def_id, value) VALUES ($1, $2, $3)`, v.DocumentID, v.FieldID, v.Value) + } + for _, s := range d.Shares { + b.Queue(`INSERT INTO document_shares (id, tenant_id, document_id, token_hash, created_by, created_at, expires_at, max_accesses, access_count, password_hash, revoked_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`, + s.ID, s.TenantID, s.DocumentID, s.TokenHashHex, s.CreatedBy, s.CreatedAt, s.ExpiresAt, s.MaxAccesses, s.AccessCount, s.PasswordHash, s.RevokedAt) + } + for _, r := range d.DeleteRequests { + b.Queue(`INSERT INTO document_delete_requests (id, document_id, tenant_id, requested_by, requested_at, confirmed_by, confirmed_at, status) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, + r.ID, r.DocumentID, r.TenantID, r.RequestedBy, r.RequestedAt, r.ConfirmedBy, r.ConfirmedAt, r.Status) + } + for _, w := range d.Workflows { + b.Queue(`INSERT INTO workflows (id, tenant_id, name) VALUES ($1, $2, $3)`, w.ID, w.TenantID, w.Name) + } + for _, r := range d.WorkflowRuns { + b.Queue(`INSERT INTO workflow_runs (id, workflow_id, tenant_id, document_id, status, final_state, started_at, finished_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, + r.ID, r.WorkflowID, r.TenantID, r.DocumentID, r.Status, r.FinalState, r.StartedAt, r.FinishedAt) + for _, e := range r.Events { + b.Queue(`INSERT INTO workflow_run_events (run_id, from_state, to_state, actor_user_id, note, occurred_at) VALUES ($1, $2, $3, $4, $5, $6)`, + r.ID, e.From, e.To, e.ActorID, e.Note, e.At) + } + } + for _, a := range d.Audit { + b.Queue(`INSERT INTO audit_log (id, timestamp, event_type, username, ip_address, document_id, success, detail, tenant_id) + VALUES ($1, $2, $3, $4, $5, NULLIF($6, ''), $7, NULLIF($8, ''), $9)`, + a.ID, a.At, a.EventType, a.Username, a.IP, a.Ref, a.Success, a.Detail, a.TenantID) + } + if err := execBatch(ctx, pool, b); err != nil { + return nil, fmt.Errorf("archivdms-testkopie befuellen: %w", err) + } + return seedCounts{ + "archivdms_tenants": len(d.Tenants), "archivdms_users": len(d.Users), "archivdms_documents": len(d.Documents), + "archivdms_akten": len(d.Akten), "archivdms_shares": len(d.Shares), "archivdms_delete_requests": len(d.DeleteRequests), + "archivdms_workflow_runs": len(d.WorkflowRuns), "archivdms_audit": len(d.Audit), + }, nil +} + +// WriteLegacyObjects legt die Binaerobjekte der Testkopie ab. Dokumente mit +// vollzogener Loeschung erhalten bewusst keine Datei – in archivdms ist die +// WORM-Datei nach "executed" physisch entfernt. +func WriteLegacyObjects(root string, ds *Dataset) (int, error) { + n := 0 + write := func(rel string, content []byte) error { + full := filepath.Join(root, filepath.FromSlash(rel)) + if err := os.MkdirAll(filepath.Dir(full), 0o700); err != nil { + return err + } + n++ + return os.WriteFile(full, content, 0o400) + } + for _, o := range ds.Archivmail.StorageObjects { + if err := write(filepath.Join("archivmail", o.Path), o.Content); err != nil { + return n, fmt.Errorf("archivmail-objekt ablegen: %w", err) + } + } + executed := map[int64]bool{} + for _, r := range ds.Archivdms.DeleteRequests { + if r.Status == "executed" { + executed[r.DocumentID] = true + } + } + for _, d := range ds.Archivdms.Documents { + if executed[d.ID] { + continue + } + if err := write(filepath.Join("archivdms", d.StoragePath), d.Content); err != nil { + return n, fmt.Errorf("archivdms-objekt ablegen: %w", err) + } + } + return n, nil +} diff --git a/internal/cutoverorchestrator/stages.go b/internal/cutoverorchestrator/stages.go new file mode 100644 index 00000000..8d87ea02 --- /dev/null +++ b/internal/cutoverorchestrator/stages.go @@ -0,0 +1,119 @@ +package cutoverorchestrator + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" +) + +// Modulstufen laufen als eigene Prozesse, weil dms/, mail/, archive/ und +// workflow/ eigene Go-Module sind: ihre internal-Pakete (die eigentlichen +// Migratoren) sind aus dem Root-Modul heraus nicht importierbar. + +const ( + EnvAdminDSN = "NEXARCH_CUTOVER_ADMIN_DSN" + EnvKEKSeed = "NEXARCH_CUTOVER_KEK_SEED" + EnvContext = "NEXARCH_CUTOVER_CONTEXT" +) + +var StageModules = []string{"dms", "mail", "archive", "workflow"} + +type ContextTenant struct { + Slug string `json:"slug"` + NexarchTenantID string `json:"nexarch_tenant_id"` + ArchivmailTenantID *int64 `json:"archivmail_tenant_id"` + ArchivdmsTenantID *int64 `json:"archivdms_tenant_id"` + TenantDB string `json:"tenant_db"` + ArchiveDB string `json:"archive_db"` + WorkflowDB string `json:"workflow_db"` +} + +type StageContext struct { + RunTag string `json:"run_tag"` + RunDir string `json:"run_dir"` + RegistryDB string `json:"registry_db"` + ArchivmailDB string `json:"archivmail_db"` + ArchivdmsDB string `json:"archivdms_db"` + LegacyObjects string `json:"legacy_objects_dir"` + MigrationsDirs map[string]string `json:"migrations_dirs"` + Tenants []ContextTenant `json:"tenants"` +} + +func WriteStageContext(path string, sc StageContext) error { + data, err := json.MarshalIndent(sc, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, data, 0o600) +} + +type StageResult struct { + Stage string `json:"stage"` + Counts map[string]int `json:"counts"` + Notes []string `json:"notes"` +} + +type StageRunner struct { + BinDir string + ContextPath string + AdminDSN string + KEKSeed string + Redactor *Redactor +} + +func StageBinary(binDir, module string) string { + return filepath.Join(binDir, module+"-cutover-stage") +} + +func BuildStageBinaries(ctx context.Context, repoRoot, binDir string) error { + for _, module := range StageModules { + cmd := exec.CommandContext(ctx, "go", "build", "-o", StageBinary(binDir, module), "./cmd/cutover-stage") + cmd.Dir = filepath.Join(repoRoot, module) + var out bytes.Buffer + cmd.Stdout = &out + cmd.Stderr = &out + if err := cmd.Run(); err != nil { + return fmt.Errorf("modulstufe %s bauen: %v: %s", module, err, strings.TrimSpace(out.String())) + } + } + return nil +} + +// Run startet eine Modulstufe mit minimaler Umgebung: Zugangsdaten nur ueber +// Umgebungsvariablen, nie als Argument (Argumente sind in der Prozessliste +// fuer andere Benutzer sichtbar). +func (s StageRunner) Run(ctx context.Context, module, stage string) (Outcome, error) { + cmd := exec.CommandContext(ctx, StageBinary(s.BinDir, module), stage) + cmd.Env = []string{ + "PATH=" + os.Getenv("PATH"), + "HOME=" + os.Getenv("HOME"), + EnvAdminDSN + "=" + s.AdminDSN, + EnvKEKSeed + "=" + s.KEKSeed, + EnvContext + "=" + s.ContextPath, + } + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + runErr := cmd.Run() + if runErr != nil { + return Outcome{}, fmt.Errorf("modulstufe %s/%s: %v: %s", module, stage, runErr, s.Redactor.Redact(lastLines(stderr.String(), 8))) + } + var res StageResult + if err := json.Unmarshal(stdout.Bytes(), &res); err != nil { + return Outcome{}, fmt.Errorf("modulstufe %s/%s: ergebnis nicht lesbar: %w", module, stage, err) + } + return Outcome{Counts: res.Counts, Notes: res.Notes}, nil +} + +func lastLines(s string, n int) string { + lines := strings.Split(strings.TrimSpace(s), "\n") + if len(lines) > n { + lines = lines[len(lines)-n:] + } + return strings.Join(lines, " | ") +} diff --git a/internal/cutoverorchestrator/steps_acl.go b/internal/cutoverorchestrator/steps_acl.go new file mode 100644 index 00000000..22bbefaf --- /dev/null +++ b/internal/cutoverorchestrator/steps_acl.go @@ -0,0 +1,181 @@ +package cutoverorchestrator + +import ( + "context" + "fmt" + "strconv" + "strings" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + + "gitea.perlbach24.de/scripte/nexarch/internal/migrationacl" + "gitea.perlbach24.de/scripte/nexarch/internal/policy" + "gitea.perlbach24.de/scripte/nexarch/internal/rbac" +) + +// aclSource liest die materialisierte document_visibility (wirksame +// Erlaubnisse je Gruppe) und ergaenzt explizite Verbote aus document_grants, +// die in der Materialisierung nicht als Zeile auftauchen. +type aclSource struct { + legacy *pgxpool.Pool + tenantID int64 +} + +func (s aclSource) Visibilities(ctx context.Context) ([]migrationacl.SourceVisibility, error) { + rows, err := s.legacy.Query(ctx, ` + SELECT v.document_id::text, v.group_id::text, v.access, 'allow' + FROM document_visibility v JOIN documents d ON d.id = v.document_id + WHERE d.tenant_id = $1 AND d.deleted_at IS NULL + UNION ALL + SELECT g.document_id::text, g.group_id::text, 'read', 'deny' + FROM document_grants g JOIN documents d ON d.id = g.document_id + WHERE d.tenant_id = $1 AND d.deleted_at IS NULL AND g.access = 'deny' + ORDER BY 1, 2 + `, s.tenantID) + if err != nil { + return nil, err + } + return pgx.CollectRows(rows, func(r pgx.CollectableRow) (migrationacl.SourceVisibility, error) { + v := migrationacl.SourceVisibility{SourcePrincipalType: "group"} + err := r.Scan(&v.SourceDocumentID, &v.SourcePrincipalID, &v.SourceRight, &v.SourceEffect) + return v, err + }) +} + +type registryUserResolver struct { + registry *pgxpool.Pool + tenantID string + column string +} + +func (r registryUserResolver) ResolveUserID(ctx context.Context, sourceUserID string) (string, error) { + id, err := strconv.ParseInt(strings.TrimSpace(sourceUserID), 10, 64) + if err != nil { + return "", fmt.Errorf("altsystem-benutzer-id %q ungueltig", sourceUserID) + } + var out string + err = r.registry.QueryRow(ctx, fmt.Sprintf(` + SELECT nexarch_user_id::text FROM user_migration_map + WHERE nexarch_tenant_id = $1 AND %s = $2 AND approved_at IS NOT NULL + `, pgx.Identifier{r.column}.Sanitize()), r.tenantID, id).Scan(&out) + if err != nil { + return "", fmt.Errorf("altsystem-benutzer %d nicht in freigegebener user_migration_map: %w", id, err) + } + return out, nil +} + +// coreGroupResolver legt die archivdms-Berechtigungsgruppen als Core-Gruppen +// (RBAC-03/RBAC-04) samt Mitgliedschaften an; DOK-03 setzt bereits +// migrierte Gruppen voraus, ein eigener Migrationsschritt dafuer existiert +// nicht. +type coreGroupResolver struct { + ids map[string]string +} + +func (r coreGroupResolver) ResolveGroupID(_ context.Context, sourceGroupID string) (string, error) { + id, ok := r.ids[sourceGroupID] + if !ok { + return "", fmt.Errorf("archivdms-gruppe %s ohne core-gruppe", sourceGroupID) + } + return id, nil +} + +func ensureCoreGroups(ctx context.Context, legacy, tenantDB *pgxpool.Pool, legacyTenantID int64, users registryUserResolver) (coreGroupResolver, int, error) { + res := coreGroupResolver{ids: map[string]string{}} + store := rbac.NewGroupStore(tenantDB) + existing, err := store.ListGroups(ctx) + if err != nil { + return res, 0, err + } + byName := map[string]string{} + for _, g := range existing { + byName[g.Name] = g.ID + } + + rows, err := legacy.Query(ctx, ` + SELECT g.id::text, g.name, COALESCE(array_agg(m.user_id::text ORDER BY m.user_id) FILTER (WHERE m.user_id IS NOT NULL), '{}') + FROM permission_groups g LEFT JOIN permission_group_members m ON m.group_id = g.id + WHERE g.tenant_id = $1 GROUP BY g.id ORDER BY g.id + `, legacyTenantID) + if err != nil { + return res, 0, err + } + type group struct { + id, name string + members []string + } + groups, err := pgx.CollectRows(rows, func(r pgx.CollectableRow) (group, error) { + var g group + err := r.Scan(&g.id, &g.name, &g.members) + return g, err + }) + if err != nil { + return res, 0, err + } + + memberships := 0 + for _, g := range groups { + coreID, ok := byName[g.name] + if !ok { + created, err := store.CreateGroup(ctx, g.name) + if err != nil { + return res, 0, err + } + coreID = created.ID + byName[g.name] = coreID + for _, m := range g.members { + uid, err := users.ResolveUserID(ctx, m) + if err != nil { + return res, 0, fmt.Errorf("mitglied von gruppe %s: %w", g.name, err) + } + if err := store.AddMember(ctx, coreID, uid); err != nil { + return res, 0, err + } + memberships++ + } + } + res.ids[g.id] = coreID + } + return res, memberships, nil +} + +func (d *Dryrun) dok03(ctx context.Context) (Outcome, error) { + store := policy.NewStore(d.registry) + out := Outcome{Counts: map[string]int{}} + for _, t := range d.tenants { + if t.ArchivdmsTenantID == nil { + out.Notes = append(out.Notes, t.Slug+": kein archivdms-mandant, stufe entfaellt") + continue + } + tenantDB, err := d.tenantPool(ctx, t.TenantDB) + if err != nil { + return out, err + } + users := registryUserResolver{registry: d.registry, tenantID: t.NexarchTenantID, column: "archivdms_user_id"} + groups, members, err := ensureCoreGroups(ctx, d.archivdms, tenantDB, *t.ArchivdmsTenantID, users) + if err != nil { + return out, fmt.Errorf("mandant %s: gruppen: %w", t.Slug, err) + } + src := aclSource{legacy: d.archivdms, tenantID: *t.ArchivdmsTenantID} + res, err := migrationacl.NewMigrator(store, src, users, groups, t.Slug).MigrateAll(ctx) + if err != nil { + return out, fmt.Errorf("mandant %s: %w", t.Slug, err) + } + vis, err := src.Visibilities(ctx) + if err != nil { + return out, err + } + var stored int + if err := d.registry.QueryRow(ctx, `SELECT count(*) FROM object_acl WHERE tenant_slug = $1`, t.Slug).Scan(&stored); err != nil { + return out, err + } + if stored != len(vis) { + return out, fmt.Errorf("mandant %s: %d objektrechte gespeichert, %d sichtbarkeitsregeln in der quelle", t.Slug, stored, len(vis)) + } + out.Counts["core_gruppen"] += len(groups.ids) + out.Counts["gruppenmitgliedschaften"] += members + out.Counts["objektrechte"] += res.Granted + } + return out, nil +} diff --git a/internal/cutoverorchestrator/steps_core.go b/internal/cutoverorchestrator/steps_core.go new file mode 100644 index 00000000..ae0a5b4a --- /dev/null +++ b/internal/cutoverorchestrator/steps_core.go @@ -0,0 +1,476 @@ +package cutoverorchestrator + +import ( + "context" + "fmt" + "path/filepath" + "strings" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + + "gitea.perlbach24.de/scripte/nexarch/internal/migrate" + "gitea.perlbach24.de/scripte/nexarch/internal/migrationaudit" + "gitea.perlbach24.de/scripte/nexarch/internal/migrationrole" + "gitea.perlbach24.de/scripte/nexarch/internal/migrationtenant" + "gitea.perlbach24.de/scripte/nexarch/internal/migrationuser" + "gitea.perlbach24.de/scripte/nexarch/internal/tenant" + "gitea.perlbach24.de/scripte/nexarch/internal/tenantapproval" + "gitea.perlbach24.de/scripte/nexarch/internal/tenantmatch" + "gitea.perlbach24.de/scripte/nexarch/internal/user" + "gitea.perlbach24.de/scripte/nexarch/internal/usermatch" +) + +const dryrunReviewer = "trockenlauf-pruefer (synthetische freigabe, CUT-01)" + +func (d *Dryrun) map01(ctx context.Context) (Outcome, error) { + am, err := tenantmatch.ExportTenants(ctx, d.exportArchivmail, "archivmail") + if err != nil { + return Outcome{}, err + } + dms, err := tenantmatch.ExportTenants(ctx, d.exportArchivdms, "archivdms") + if err != nil { + return Outcome{}, err + } + for name, p := range map[string]*pgxpool.Pool{"archivmail": d.exportArchivmail, "archivdms": d.exportArchivdms} { + n, err := tenantmatch.CountTenants(ctx, p) + if err != nil { + return Outcome{}, err + } + exported := len(am) + if name == "archivdms" { + exported = len(dms) + } + if n != exported { + return Outcome{}, fmt.Errorf("%s: export unvollstaendig (%d von %d)", name, exported, n) + } + } + manual, err := tenantmatch.LoadManualDecisions(strings.NewReader(d.dataset.ManualDecisionsCSV)) + if err != nil { + return Outcome{}, err + } + d.tenantCands = tenantmatch.Match(am, dms, manual) + conflicts := 0 + for _, c := range d.tenantCands { + if c.Conflict { + conflicts++ + } + } + return Outcome{Counts: countsOf("archivmail_tenants", len(am), "archivdms_tenants", len(dms), + "kandidaten", len(d.tenantCands), "konflikte", conflicts, "manuelle_zuordnungen", len(manual))}, nil +} + +// map02 simuliert die menschliche Freigabe ueber die bestehenden +// MAP-02-Operationen. Die Korrektur des Slugs auf das Ziel-Format ist +// dieselbe Operation, die ein Pruefer fuer Altsystem-Slugs mit Bindestrich +// ausfuehren muesste (Core erlaubt nur [a-z0-9_]). +func (d *Dryrun) map02(ctx context.Context) (Outcome, error) { + store := tenantapproval.NewStore(d.registry) + ids, err := store.InsertCandidates(ctx, d.tenantCands) + if err != nil { + return Outcome{}, err + } + for _, id := range ids { + if err := store.Approve(ctx, id, dryrunReviewer, "CUT-01 Trockenlauf"); err != nil { + return Outcome{}, err + } + e, err := store.Get(ctx, id) + if err != nil { + return Outcome{}, err + } + slug := TargetSlug(d.RunTag, e.Slug) + if err := store.Correct(ctx, id, dryrunReviewer, "Ziel-Slug-Format und Laufpraefix", tenantapproval.Correction{Slug: &slug}); err != nil { + return Outcome{}, err + } + } + + approved, err := store.Approved(ctx) + if err != nil { + return Outcome{}, err + } + covered := map[string]bool{} + for _, e := range approved { + if e.ArchivmailTenantID != nil { + covered[fmt.Sprintf("archivmail:%d", *e.ArchivmailTenantID)] = true + } + if e.ArchivdmsTenantID != nil { + covered[fmt.Sprintf("archivdms:%d", *e.ArchivdmsTenantID)] = true + } + } + var missing []string + for _, t := range d.dataset.Archivmail.Tenants { + if !covered[fmt.Sprintf("archivmail:%d", t.ID)] { + missing = append(missing, "archivmail/"+t.Slug) + } + } + for _, t := range d.dataset.Archivdms.Tenants { + if !covered[fmt.Sprintf("archivdms:%d", t.ID)] { + missing = append(missing, "archivdms/"+t.Slug) + } + } + out := Outcome{Counts: countsOf("eintraege_angelegt", len(ids), "freigegeben", len(approved))} + if len(missing) > 0 { + return out, fmt.Errorf("vollstaendigkeit: %d altsystem-mandanten ohne eintrag in der freigegebenen tenant_migration_map, sie wuerden nie migriert: %s", + len(missing), strings.Join(missing, ", ")) + } + return out, nil +} + +func (d *Dryrun) map03(ctx context.Context) (Outcome, error) { + am, err := usermatch.ExportUsers(ctx, d.exportArchivmail, "archivmail") + if err != nil { + return Outcome{}, err + } + dms, err := usermatch.ExportUsers(ctx, d.exportArchivdms, "archivdms") + if err != nil { + return Outcome{}, err + } + for name, p := range map[string]*pgxpool.Pool{"archivmail": d.exportArchivmail, "archivdms": d.exportArchivdms} { + n, err := usermatch.CountUsers(ctx, p) + if err != nil { + return Outcome{}, err + } + exported := len(am) + if name == "archivdms" { + exported = len(dms) + } + if n != exported { + return Outcome{}, fmt.Errorf("%s: benutzer-export unvollstaendig (%d von %d)", name, exported, n) + } + } + approved, err := tenantapproval.NewStore(d.registry).Approved(ctx) + if err != nil { + return Outcome{}, err + } + d.userCands = usermatch.Match(am, dms, approved) + matched, conflicts := 0, 0 + for _, c := range d.userCands { + if c.ArchivmailUserID != nil && c.ArchivdmsUserID != nil { + matched++ + } + if c.Conflict { + conflicts++ + } + } + return Outcome{Counts: countsOf("archivmail_users", len(am), "archivdms_users", len(dms), + "kandidaten", len(d.userCands), "zusammengefuehrt", matched, "konflikte", conflicts)}, nil +} + +// map04 ersetzt die noch nicht gebaute MAP-04-Freigabe: jeder MAP-03-Kandidat +// wird mit dokumentiertem Trockenlauf-Pruefer freigegeben, LDAP-Felder +// (COR-04) und Rollenherkunft (COR-03) kommen 1:1 aus der Testkopie. +func (d *Dryrun) map04(ctx context.Context) (Outcome, error) { + amByID := map[int64]ArchivmailUser{} + for _, u := range d.dataset.Archivmail.Users { + amByID[u.ID] = u + } + dmsByID := map[int64]ArchivdmsUser{} + for _, u := range d.dataset.Archivdms.Users { + dmsByID[u.ID] = u + } + firstGroup := map[int64]string{} + for _, g := range d.dataset.Archivdms.Groups { + for _, m := range g.Members { + if _, ok := firstGroup[m]; !ok { + firstGroup[m] = g.Name + } + } + } + + users, roles, ldap := 0, 0, 0 + for _, c := range d.userCands { + var ldapUID, ldapSource *string + var ldapSynced *time.Time + if c.ArchivdmsUserID != nil { + du := dmsByID[*c.ArchivdmsUserID] + ldapUID, ldapSynced = du.LDAPUID, du.LDAPSyncedAt + if du.AuthSource != "local" { + src := du.AuthSource + ldapSource = &src + } + } + if c.ArchivmailUserID != nil && ldapSource == nil { + if src := amByID[*c.ArchivmailUserID].Source; src != "local" { + ldapSource = &src + } + } + if ldapUID != nil || ldapSource != nil { + ldap++ + } + + var mapID int64 + err := d.registry.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, ldap_uid, ldap_synced_at, ldap_source) + VALUES ($1, $2, $3, gen_random_uuid(), $4, $5, $6, $7, now(), $8, $9, $10) + RETURNING id + `, c.ArchivmailUserID, c.ArchivdmsUserID, c.NexarchTenantID, c.Email, c.Name, c.PasswordHash, dryrunReviewer, + ldapUID, ldapSynced, ldapSource).Scan(&mapID) + if err != nil { + return Outcome{}, fmt.Errorf("user_migration_map fuer %s: %w", c.Email, err) + } + users++ + + var system, role string + switch { + case c.ArchivdmsUserID != nil && firstGroup[*c.ArchivdmsUserID] != "": + system, role = "archivdms", firstGroup[*c.ArchivdmsUserID] + case c.ArchivmailUserID != nil: + system, role = "archivmail", amByID[*c.ArchivmailUserID].Role + default: + continue + } + if _, err := d.registry.Exec(ctx, ` + INSERT INTO role_migration_map (user_migration_map_id, source_system, source_role, granted_by, approved_by, approved_at) + VALUES ($1, $2, $3, $4, $4, now()) + `, mapID, system, role, dryrunReviewer); err != nil { + return Outcome{}, fmt.Errorf("role_migration_map fuer %s: %w", c.Email, err) + } + roles++ + } + return Outcome{Counts: countsOf("benutzer_freigegeben", users, "rollen_freigegeben", roles, "mit_ldap_bezug", ldap)}, nil +} + +func (d *Dryrun) cor01(ctx context.Context) (Outcome, error) { + adminPool, err := pgxpool.New(ctx, d.cluster.AdminDSN()) + if err != nil { + return Outcome{}, fmt.Errorf("admin-verbindung: %w", err) + } + defer adminPool.Close() + + registry := tenant.NewRegistry(d.registry) + provisioner := tenant.NewProvisioner(adminPool, registry, d.cluster.TenantDSNTemplate()) + res, err := migrationtenant.NewMigrator(migrationtenant.NewMapStore(d.registry), provisioner).MigrateApproved(ctx) + if err != nil { + return Outcome{}, err + } + + approved, err := tenantapproval.NewStore(d.registry).Approved(ctx) + if err != nil { + return Outcome{}, err + } + d.tenants = nil + for _, e := range approved { + t, err := registry.GetByID(ctx, e.NexarchTenantID) + if err != nil { + return Outcome{}, fmt.Errorf("tenant %s nach cor-01 nicht in der registry: %w", e.Slug, err) + } + d.tenants = append(d.tenants, ContextTenant{ + Slug: t.Slug, NexarchTenantID: t.ID, + ArchivmailTenantID: e.ArchivmailTenantID, ArchivdmsTenantID: e.ArchivdmsTenantID, + TenantDB: t.DBName, ArchiveDB: t.Slug + "_archive", WorkflowDB: t.Slug + "_workflow", + }) + } + if err := d.writeContext(); err != nil { + return Outcome{}, err + } + return Outcome{Counts: countsOf("tenants_migriert", len(res.Migrated))}, nil +} + +func (d *Dryrun) writeContext() error { + return WriteStageContext(d.contextPath, StageContext{ + RunTag: d.RunTag, RunDir: d.RunDir, RegistryDB: d.registryDB, + ArchivmailDB: d.archivmailDB, ArchivdmsDB: d.archivdmsDB, LegacyObjects: d.legacyObjectsDir(), + MigrationsDirs: map[string]string{ + "dms": filepath.Join(d.cfg.RepoRoot, "dms", "migrations", "tenant"), + "archive": filepath.Join(d.cfg.RepoRoot, "archive", "migrations"), + "workflow": filepath.Join(d.cfg.RepoRoot, "workflow", "migrations", "tenant"), + }, + Tenants: d.tenants, + }) +} + +func (d *Dryrun) schemaCore(ctx context.Context) (Outcome, error) { + migrations, err := migrate.LoadMigrations(filepath.Join(d.cfg.RepoRoot, "migrations", "tenant")) + if err != nil { + return Outcome{}, err + } + results, err := migrate.NewOrchestrator(tenant.NewRegistry(d.registry)).RolloutAll(ctx, migrations, migrate.GenerateManifest(migrations)) + if err != nil { + return Outcome{}, err + } + applied := 0 + for _, r := range results { + if r.Err != nil { + return Outcome{}, fmt.Errorf("tenant %s: %w", r.TenantSlug, r.Err) + } + applied += len(r.Applied) + } + return Outcome{Counts: countsOf("tenants", len(results), "core_tenant_migrationen_angewendet", applied)}, nil +} + +func (d *Dryrun) schemaModules(ctx context.Context) (Outcome, error) { + for _, t := range d.tenants { + for _, db := range []string{t.ArchiveDB, t.WorkflowDB} { + if err := d.cluster.CreateDatabase(ctx, db); err != nil { + return Outcome{}, err + } + } + } + out := Outcome{Counts: map[string]int{}} + for _, module := range []string{"dms", "archive", "workflow"} { + o, err := d.stages.Run(ctx, module, "schema") + if err != nil { + return out, err + } + for k, v := range o.Counts { + if k != "mandanten" { + out.Counts[k] = v + } + } + } + return out, nil +} + +func (d *Dryrun) cor02(ctx context.Context) (Outcome, error) { + m := migrationuser.NewMigrator(migrationuser.NewMapStore(d.registry), tenant.NewRegistry(d.registry)) + defer m.Close() + res, err := m.MigrateApproved(ctx) + if err != nil { + return Outcome{}, err + } + return Outcome{Counts: countsOf("benutzer_migriert", len(res.Migrated))}, nil +} + +func (d *Dryrun) tenantByID(id string) (ContextTenant, bool) { + for _, t := range d.tenants { + if t.NexarchTenantID == id { + return t, true + } + } + return ContextTenant{}, false +} + +func (d *Dryrun) cor04(ctx context.Context) (Outcome, error) { + entries, err := migrationuser.NewMapStore(d.registry).Approved(ctx) + if err != nil { + return Outcome{}, err + } + checked := 0 + for _, e := range entries { + if e.LDAPUID == nil && e.LDAPSource == nil && e.LDAPSyncedAt == nil { + continue + } + t, ok := d.tenantByID(e.NexarchTenantID) + if !ok { + return Outcome{}, fmt.Errorf("tenant %s unbekannt", e.NexarchTenantID) + } + pool, err := d.tenantPool(ctx, t.TenantDB) + if err != nil { + return Outcome{}, err + } + info, err := user.NewTenantUserStore(pool).LDAPInfo(ctx, e.NexarchUserID) + if err != nil { + return Outcome{}, fmt.Errorf("ldap-felder von %s: %w", e.Email, err) + } + if !sameStr(info.UID, e.LDAPUID) || !sameStr(info.Source, e.LDAPSource) || !sameTime(info.SyncedAt, e.LDAPSyncedAt) { + return Outcome{}, fmt.Errorf("ldap-felder von %s weichen von der freigegebenen map ab", e.Email) + } + checked++ + } + return Outcome{Counts: countsOf("benutzer_mit_ldap_bezug_geprueft", checked)}, nil +} + +func sameStr(a, b *string) bool { + return (a == nil && b == nil) || (a != nil && b != nil && *a == *b) +} + +func sameTime(a, b *time.Time) bool { + return (a == nil && b == nil) || (a != nil && b != nil && a.Equal(*b)) +} + +func (d *Dryrun) cor03(ctx context.Context) (Outcome, error) { + m := migrationrole.NewMigrator(migrationrole.NewMapStore(d.registry), tenant.NewRegistry(d.registry)) + defer m.Close() + res, err := m.MigrateApproved(ctx) + if err != nil { + return Outcome{}, err + } + out := Outcome{Counts: map[string]int{"rollen_zugewiesen": len(res.Assigned)}} + for _, a := range res.Assigned { + out.Counts["rolle_"+string(a.NexarchRole)]++ + } + return out, nil +} + +// cor05 befuellt die Staging-Tabelle aus den audit_log-Exporten beider +// Altsysteme. Die Altsysteme protokollieren den Benutzernamen statt der ID; +// die Aufloesung erfolgt ueber users.username des jeweiligen Altsystems. +func (d *Dryrun) cor05(ctx context.Context) (Outcome, error) { + staged := 0 + for _, src := range []struct { + system string + pool *pgxpool.Pool + refCol string + }{{"archivmail", d.archivmail, "mail_id"}, {"archivdms", d.archivdms, "document_id"}} { + rows, err := src.pool.Query(ctx, fmt.Sprintf(` + SELECT a.id, a.tenant_id, u.id, a.timestamp, a.event_type, COALESCE(a.%s, ''), + json_build_object('ip_address', a.ip_address, 'success', a.success, 'detail', a.detail, 'quelle_benutzername', a.username)::text + FROM audit_log a LEFT JOIN users u ON u.username = a.username + ORDER BY a.id + `, src.refCol)) + if err != nil { + return Outcome{}, fmt.Errorf("%s audit_log lesen: %w", src.system, err) + } + type row struct { + id, tenantID int64 + userID *int64 + at time.Time + action, ref string + meta string + } + var batch []row + for rows.Next() { + var r row + if err := rows.Scan(&r.id, &r.tenantID, &r.userID, &r.at, &r.action, &r.ref, &r.meta); err != nil { + rows.Close() + return Outcome{}, err + } + batch = append(batch, r) + } + rows.Close() + if err := rows.Err(); err != nil { + return Outcome{}, err + } + for _, r := range batch { + if r.userID == nil { + return Outcome{}, fmt.Errorf("%s audit_log %d: benutzername nicht im altsystem aufloesbar", src.system, r.id) + } + if _, err := d.registry.Exec(ctx, ` + INSERT INTO legacy_audit_log_staging (source_system, source_id, source_tenant_id, source_user_id, occurred_at, action, target, metadata) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb) + ON CONFLICT (source_system, source_id) DO NOTHING + `, src.system, r.id, r.tenantID, *r.userID, r.at, r.action, r.ref, r.meta); err != nil { + return Outcome{}, fmt.Errorf("%s audit_log %d in staging: %w", src.system, r.id, err) + } + staged++ + } + } + + res, err := migrationaudit.NewMigrator(d.registry).MigrateAll(ctx) + if err != nil { + return Outcome{}, err + } + + var unmigrated int + if err := d.registry.QueryRow(ctx, `SELECT count(*) FROM legacy_audit_log_staging WHERE migrated_event_id IS NULL`).Scan(&unmigrated); err != nil { + return Outcome{}, err + } + if unmigrated > 0 { + return Outcome{}, fmt.Errorf("%d audit-zeilen nach cor-05 nicht migriert", unmigrated) + } + var mismatched int + if err := d.registry.QueryRow(ctx, ` + SELECT count(*) FROM legacy_audit_log_staging s JOIN audit_events e ON e.id = s.migrated_event_id + WHERE e.occurred_at <> s.occurred_at OR e.action <> s.action + `).Scan(&mismatched); err != nil { + return Outcome{}, err + } + if mismatched > 0 { + return Outcome{}, fmt.Errorf("%d audit-ereignisse mit veraendertem zeitstempel oder aktion", mismatched) + } + return Outcome{ + Counts: countsOf("staging_zeilen", staged, "audit_ereignisse_migriert", res.Migrated), + Notes: []string{"zeitstempel und aktion je ereignis gegen staging unveraendert"}, + }, nil +} diff --git a/internal/cutoverorchestrator/steps_prep.go b/internal/cutoverorchestrator/steps_prep.go new file mode 100644 index 00000000..dc9edb3b --- /dev/null +++ b/internal/cutoverorchestrator/steps_prep.go @@ -0,0 +1,109 @@ +package cutoverorchestrator + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/jackc/pgx/v5/pgxpool" +) + +func (d *Dryrun) prepWorkdir(ctx context.Context) (Outcome, error) { + for _, sub := range []string{"bin", "altsystem-objekte", "ziel-objekte", "uebergabe", "val02"} { + if err := os.MkdirAll(filepath.Join(d.RunDir, sub), 0o700); err != nil { + return Outcome{}, err + } + } + if err := BuildStageBinaries(ctx, d.cfg.RepoRoot, d.stages.BinDir); err != nil { + return Outcome{}, err + } + return Outcome{ + Counts: countsOf("modulstufen", len(StageModules)), + Notes: []string{"arbeitsverzeichnis " + d.RunDir}, + }, nil +} + +func (d *Dryrun) prepLegacyCopy(ctx context.Context) (Outcome, error) { + ds, err := GenerateDataset(d.cfg.Seed, d.now, d.cfg.BcryptCost) + if err != nil { + return Outcome{}, err + } + d.dataset = ds + + for _, db := range []string{d.archivmailDB, d.archivdmsDB} { + if err := d.cluster.CreateDatabase(ctx, db); err != nil { + return Outcome{}, err + } + } + if d.archivmail, err = d.cluster.Connect(ctx, d.archivmailDB); err != nil { + return Outcome{}, err + } + if d.archivdms, err = d.cluster.Connect(ctx, d.archivdmsDB); err != nil { + return Outcome{}, err + } + out := Outcome{Counts: map[string]int{}} + amCounts, err := SeedArchivmail(ctx, d.archivmail, ds.Archivmail) + if err != nil { + return out, err + } + dmsCounts, err := SeedArchivdms(ctx, d.archivdms, ds.Archivdms) + if err != nil { + return out, err + } + for k, v := range amCounts { + out.Counts[k] = v + } + for k, v := range dmsCounts { + out.Counts[k] = v + } + files, err := WriteLegacyObjects(d.legacyObjectsDir(), ds) + if err != nil { + return out, err + } + out.Counts["binaerobjekte"] = files + if err := os.WriteFile(filepath.Join(d.RunDir, "kundenzuordnungsliste.csv"), []byte(ds.ManualDecisionsCSV), 0o600); err != nil { + return out, err + } + out.Notes = append(out.Notes, fmt.Sprintf("generator-seed %d, quelle ausschliesslich internal/cutoverorchestrator/dataset.go", d.cfg.Seed)) + return out, nil +} + +func (d *Dryrun) prepAnonymization(ctx context.Context) (Outcome, error) { + rep, err := VerifyAnonymization(ctx, map[string]*pgxpool.Pool{ + "archivmail": d.archivmail, "archivdms": d.archivdms, + }, d.legacyObjectsDir()) + out := Outcome{Counts: rep.Counts()} + if err != nil { + return out, err + } + if len(rep.Violations) > 0 { + max := len(rep.Violations) + if max > 5 { + max = 5 + } + return out, fmt.Errorf("anonymisierungsnachweis: %d verstoesse, u.a.: %s", len(rep.Violations), strings.Join(rep.Violations[:max], "; ")) + } + out.Notes = []string{ + "alle e-mail-adressen unter der reservierten domain " + ReservedMailDomainSuffix + " (RFC 2606)", + "alle ip-adressen aus dokumentationsnetzen (RFC 5737)", + "alle konten mit synthetischem praefix, alle binaerobjekte mit marker " + SyntheticMarker, + } + return out, nil +} + +func (d *Dryrun) prepRegistry(ctx context.Context) (Outcome, error) { + if err := d.cluster.CreateDatabase(ctx, d.registryDB); err != nil { + return Outcome{}, err + } + var err error + if d.registry, err = d.cluster.Connect(ctx, d.registryDB); err != nil { + return Outcome{}, err + } + n, err := applySQLDir(ctx, d.registry, filepath.Join(d.cfg.RepoRoot, "migrations")) + if err != nil { + return Outcome{}, err + } + return Outcome{Counts: countsOf("core_migrationen", n)}, nil +} diff --git a/internal/cutoverorchestrator/steps_verify.go b/internal/cutoverorchestrator/steps_verify.go new file mode 100644 index 00000000..79ba3bb1 --- /dev/null +++ b/internal/cutoverorchestrator/steps_verify.go @@ -0,0 +1,430 @@ +package cutoverorchestrator + +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/jackc/pgx/v5/pgxpool" + + "gitea.perlbach24.de/scripte/nexarch/internal/auth" + "gitea.perlbach24.de/scripte/nexarch/internal/migrationuser" + "gitea.perlbach24.de/scripte/nexarch/internal/migrationverify" + "gitea.perlbach24.de/scripte/nexarch/internal/user" +) + +func (d *Dryrun) mailIndexCount(slug string) (int64, error) { + raw, err := os.ReadFile(filepath.Join(d.RunDir, "ziel-objekte", "mail-index", slug+".json")) + if errors.Is(err, os.ErrNotExist) { + return 0, nil + } + if err != nil { + return 0, err + } + var docs map[string]json.RawMessage + if err := json.Unmarshal(raw, &docs); err != nil { + return 0, err + } + return int64(len(docs)), nil +} + +type dryrunTargetCounts struct { + d *Dryrun + pg migrationverify.PostgresTenantCountProvider +} + +func (c dryrunTargetCounts) Counts(ctx context.Context, slug string) (migrationverify.TenantCounts, error) { + tc, err := c.pg.Counts(ctx, slug) + if err != nil { + return tc, err + } + tc.Messages, err = c.d.mailIndexCount(slug) + return tc, err +} + +func (d *Dryrun) tenantBySlug(slug string) (ContextTenant, error) { + for _, t := range d.tenants { + if t.Slug == slug { + return t, nil + } + } + return ContextTenant{}, fmt.Errorf("tenant %q unbekannt", slug) +} + +func (d *Dryrun) val01(ctx context.Context) (Outcome, error) { + var slugs []string + idBySlug := map[string]string{} + sourceRest := migrationverify.MapCountProvider{} + var explanations []migrationverify.Explanation + for _, t := range d.tenants { + slugs = append(slugs, t.Slug) + idBySlug[t.Slug] = t.NexarchTenantID + var docs, deleted, msgs int64 + if t.ArchivdmsTenantID != nil { + if err := d.archivdms.QueryRow(ctx, ` + SELECT count(*), count(*) FILTER (WHERE deleted_at IS NOT NULL) FROM documents WHERE tenant_id = $1 + `, *t.ArchivdmsTenantID).Scan(&docs, &deleted); err != nil { + return Outcome{}, err + } + } + if t.ArchivmailTenantID != nil { + if err := d.archivmail.QueryRow(ctx, ` + SELECT count(*) FROM emails e + WHERE e.tenant_id = $1 OR EXISTS (SELECT 1 FROM email_refs r WHERE r.email_id = e.id AND r.tenant_id = $1) + `, *t.ArchivmailTenantID).Scan(&msgs); err != nil { + return Outcome{}, err + } + } + sourceRest[t.Slug] = migrationverify.TenantCounts{TenantSlug: t.Slug, Documents: docs, Messages: msgs} + if deleted > 0 { + explanations = append(explanations, migrationverify.Explanation{TenantSlug: t.Slug, Table: migrationverify.TableDocuments, Count: deleted, + Reason: "im Altsystem bereits vollzogene Loeschung, nur Audit-Eintrag (TAR-02)"}) + } + if err := d.checkUserSourceCount(ctx, t); err != nil { + return Outcome{}, err + } + } + + source := migrationverify.CombineUsers(migrationverify.RegistryApprovedUserCountProvider{RegistryPool: d.registry, TenantIDBySlug: idBySlug}, sourceRest) + target := dryrunTargetCounts{d: d, pg: migrationverify.PostgresTenantCountProvider{Resolver: func(ctx context.Context, slug string) (*pgxpool.Pool, error) { + t, err := d.tenantBySlug(slug) + if err != nil { + return nil, err + } + return d.tenantPool(ctx, t.TenantDB) + }}} + reports, err := migrationverify.Run(ctx, slugs, source, target, explanations) + if err != nil { + return Outcome{}, err + } + if err := os.WriteFile(filepath.Join(d.RunDir, "val01-zeilenzahl-abgleich.md"), []byte(migrationverify.RenderBericht(reports)), 0o644); err != nil { + return Outcome{}, err + } + out := Outcome{Counts: map[string]int{"vergleiche": len(reports)}} + for _, r := range reports { + out.Counts["quelle_"+string(r.Table)] += int(r.SourceCount) + out.Counts["ziel_"+string(r.Table)] += int(r.TargetCount) + } + if offen := migrationverify.OffeneBefunde(reports); len(offen) > 0 { + var lines []string + for _, r := range offen { + lines = append(lines, r.String()) + } + return out, fmt.Errorf("zeilenzahl-abgleich: %d offene befunde: %s", len(offen), strings.Join(lines, "; ")) + } + return out, nil +} + +// checkUserSourceCount schliesst die Luecke zwischen Altsystem und +// user_migration_map, die VAL-01 selbst nicht sieht (VAL-01 zaehlt als +// Quelle bereits die freigegebene Map). +func (d *Dryrun) checkUserSourceCount(ctx context.Context, t ContextTenant) error { + var legacy, merged, mapped int64 + if t.ArchivmailTenantID != nil { + var n int64 + if err := d.archivmail.QueryRow(ctx, `SELECT count(*) FROM users WHERE tenant_id = $1`, *t.ArchivmailTenantID).Scan(&n); err != nil { + return err + } + legacy += n + } + if t.ArchivdmsTenantID != nil { + var n int64 + if err := d.archivdms.QueryRow(ctx, `SELECT count(*) FROM users WHERE tenant_id = $1`, *t.ArchivdmsTenantID).Scan(&n); err != nil { + return err + } + legacy += n + } + if err := d.registry.QueryRow(ctx, ` + SELECT count(*) FILTER (WHERE archivmail_user_id IS NOT NULL AND archivdms_user_id IS NOT NULL), count(*) + FROM user_migration_map WHERE nexarch_tenant_id = $1 AND approved_at IS NOT NULL + `, t.NexarchTenantID).Scan(&merged, &mapped); err != nil { + return err + } + if legacy-merged != mapped { + return fmt.Errorf("tenant %s: %d altsystem-konten (%d zusammengefuehrt) ergeben %d personen, freigegebene map enthaelt %d", t.Slug, legacy, merged, legacy-merged, mapped) + } + return nil +} + +type dirObjects struct{ root string } + +func (o dirObjects) open(key string) (io.ReadCloser, error) { + full := filepath.Join(o.root, filepath.FromSlash(key)) + rel, err := filepath.Rel(o.root, full) + if err != nil || strings.HasPrefix(rel, "..") { + return nil, fmt.Errorf("objektschluessel %q ausserhalb von %s", key, o.root) + } + return os.Open(full) +} + +func (o dirObjects) OpenObject(_ context.Context, key string) (io.ReadCloser, error) { + return o.open(key) +} +func (o dirObjects) Get(_ context.Context, key string) (io.ReadCloser, error) { return o.open(key) } + +type mailHandoff struct { + ObjectReference string `json:"object_reference"` + Attachments []struct { + SourceObjectKey string `json:"source_object_key"` + StorageKey string `json:"storage_key"` + } `json:"attachments"` +} + +const val02MailSampleSize = 5 + +func (d *Dryrun) val02(ctx context.Context) (Outcome, error) { + if _, err := d.stages.Run(ctx, "dms", "val02"); err != nil { + return Outcome{}, err + } + legacy := dirObjects{root: d.legacyObjectsDir()} + out := Outcome{Counts: map[string]int{}} + var failed []string + for _, t := range d.tenants { + var results []migrationverify.FileResult + if t.ArchivdmsTenantID != nil { + var raw []struct { + Label string `json:"label"` + SourceObjectKey string `json:"source_object_key"` + StorageKey string `json:"storage_key"` + } + if err := readJSONFile(filepath.Join(d.RunDir, "val02", t.Slug, "stichprobe.json"), &raw); err != nil { + return out, fmt.Errorf("dms-stichprobe %s: %w", t.Slug, err) + } + var samples []migrationverify.Sample + for _, r := range raw { + samples = append(samples, migrationverify.Sample{Label: r.Label, SourceObjectKey: r.SourceObjectKey, StorageKey: r.StorageKey}) + } + results = append(results, migrationverify.CompareSamples(ctx, t.Slug, legacy, dirObjects{root: filepath.Join(d.RunDir, "val02")}, nil, samples)...) + out.Counts["dms_dateien"] += len(samples) + } + if t.ArchivmailTenantID != nil { + var handoff []mailHandoff + if err := readJSONFile(filepath.Join(d.RunDir, "uebergabe", t.Slug, "mail_objekte.json"), &handoff); err != nil { + return out, fmt.Errorf("mail-uebergabe %s: %w", t.Slug, err) + } + var samples []migrationverify.Sample + for _, m := range handoff { + for _, a := range m.Attachments { + if len(samples) < val02MailSampleSize { + samples = append(samples, migrationverify.Sample{Label: t.Slug + "/" + a.StorageKey, SourceObjectKey: a.SourceObjectKey, StorageKey: a.StorageKey}) + } + } + } + results = append(results, migrationverify.CompareSamples(ctx, t.Slug, legacy, dirObjects{root: filepath.Join(d.RunDir, "ziel-objekte", "mail", t.Slug)}, nil, samples)...) + out.Counts["mail_anhaenge"] += len(samples) + } + report := migrationverify.RenderReport(t.Slug, results) + if err := os.WriteFile(filepath.Join(d.RunDir, "val02-hashvergleich-"+t.Slug+".md"), []byte(report), 0o644); err != nil { + return out, err + } + for _, r := range results { + if r.Err != nil || !r.Match { + failed = append(failed, r.Label) + } + } + } + out.Counts["abweichungen"] = len(failed) + if len(failed) > 0 { + return out, fmt.Errorf("hash-vergleich: %d abweichungen/fehler: %s", len(failed), strings.Join(failed, ", ")) + } + return out, nil +} + +func readJSONFile(path string, v any) error { + raw, err := os.ReadFile(path) + if err != nil { + return err + } + return json.Unmarshal(raw, v) +} + +const val05SamplePerTenant = 3 + +func (d *Dryrun) val05(ctx context.Context) (Outcome, error) { + secret := make([]byte, 32) + if _, err := rand.Read(secret); err != nil { + return Outcome{}, err + } + issuer := auth.NewTokenIssuer(hex.EncodeToString(secret)) + entries, err := migrationuser.NewMapStore(d.registry).Approved(ctx) + if err != nil { + return Outcome{}, err + } + perTenant := map[string]int{} + ok, rejected := 0, 0 + for _, e := range entries { + if perTenant[e.NexarchTenantID] >= val05SamplePerTenant { + continue + } + key := "" + switch { + case e.ArchivmailUserID != nil: + key = fmt.Sprintf("archivmail:%d", *e.ArchivmailUserID) + case e.ArchivdmsUserID != nil: + key = fmt.Sprintf("archivdms:%d", *e.ArchivdmsUserID) + } + pw, known := d.dataset.Passwords[key] + if !known { + continue + } + t, found := d.tenantByID(e.NexarchTenantID) + if !found { + return Outcome{}, fmt.Errorf("tenant %s unbekannt", e.NexarchTenantID) + } + pool, err := d.tenantPool(ctx, t.TenantDB) + if err != nil { + return Outcome{}, err + } + login := auth.NewLoginService(user.NewTenantUserStore(pool), issuer, t.Slug) + token, err := login.Login(ctx, e.Email, pw) + if err != nil { + return Outcome{}, fmt.Errorf("login mit migriertem passwort fuer %s (%s) fehlgeschlagen: %w", e.Email, t.Slug, err) + } + claims, err := issuer.Verify(token) + if err != nil || claims.UserID != e.NexarchUserID { + return Outcome{}, fmt.Errorf("token fuer %s gehoert nicht zum migrierten benutzer", e.Email) + } + if _, err := login.Login(ctx, e.Email, pw+"-falsch"); err == nil { + return Outcome{}, fmt.Errorf("login mit falschem passwort fuer %s haette scheitern muessen", e.Email) + } + perTenant[e.NexarchTenantID]++ + ok++ + rejected++ + } + for _, t := range d.tenants { + if perTenant[t.NexarchTenantID] == 0 { + return Outcome{}, fmt.Errorf("tenant %s: keine login-stichprobe moeglich", t.Slug) + } + } + return Outcome{Counts: countsOf("logins_erfolgreich", ok, "falsche_passwoerter_abgewiesen", rejected)}, nil +} + +func (d *Dryrun) chk01(ctx context.Context) (Outcome, error) { + out := Outcome{Counts: map[string]int{}} + var findings []string + finding := func(format string, args ...any) { findings = append(findings, fmt.Sprintf(format, args...)) } + + for _, t := range d.tenants { + tenantDB, err := d.tenantPool(ctx, t.TenantDB) + if err != nil { + return out, err + } + archiveDB, err := d.tenantPool(ctx, t.ArchiveDB) + if err != nil { + return out, err + } + workflowDB, err := d.tenantPool(ctx, t.WorkflowDB) + if err != nil { + return out, err + } + + var docIDs []string + if err := collectStrings(ctx, tenantDB, `SELECT id::text FROM documents`, &docIDs); err != nil { + return out, err + } + var withRetention int + if err := archiveDB.QueryRow(ctx, ` + SELECT count(*) FROM retention_objects WHERE object_type = 'dms_document' AND object_reference = ANY($1) + `, docIDs).Scan(&withRetention); err != nil { + return out, err + } + if withRetention != len(docIDs) { + finding("%s: %d von %d dokumenten ohne retention-objekt", t.Slug, len(docIDs)-withRetention, len(docIDs)) + } + out.Counts["dokumente_mit_retention"] += withRetention + + msgs, err := d.mailIndexCount(t.Slug) + if err != nil { + return out, err + } + var msgRetention int64 + if err := archiveDB.QueryRow(ctx, `SELECT count(*) FROM retention_objects WHERE object_type = 'mail_message'`).Scan(&msgRetention); err != nil { + return out, err + } + if msgRetention != msgs { + finding("%s: %d nachrichten im index, %d retention-objekte fuer nachrichten", t.Slug, msgs, msgRetention) + } + out.Counts["nachrichten_mit_retention"] += int(msgRetention) + + var orphanACL int + if err := d.registry.QueryRow(ctx, ` + SELECT count(*) FROM object_acl WHERE tenant_slug = $1 AND object_type = 'document' AND NOT (object_id = ANY($2)) + `, t.Slug, docIDs).Scan(&orphanACL); err != nil { + return out, err + } + if orphanACL > 0 { + finding("%s: %d objektrechte verweisen auf nicht migrierte dokumente", t.Slug, orphanACL) + } + + if t.ArchivdmsTenantID != nil { + lt := *t.ArchivdmsTenantID + checks := []struct { + label string + legacySQL string + targetPool *pgxpool.Pool + targetSQL string + }{ + {"freigabelinks", `SELECT count(*) FROM document_shares s JOIN documents d ON d.id = s.document_id + WHERE s.tenant_id = $1 AND s.revoked_at IS NULL AND d.deleted_at IS NULL AND s.expires_at > now()`, tenantDB, `SELECT count(*) FROM share_links`}, + {"workflow_vorgaenge", `SELECT count(*) FROM workflow_runs WHERE tenant_id = $1 AND status IN ('completed', 'aborted')`, workflowDB, `SELECT count(*) FROM workflow_instances`}, + {"offene_loeschanfragen", `SELECT count(*) FROM document_delete_requests WHERE tenant_id = $1 AND status IN ('pending', 'confirmed', 'blocked_retention')`, archiveDB, `SELECT count(*) FROM deletion_requests`}, + {"vollzogene_loeschungen", `SELECT count(*) FROM document_delete_requests WHERE tenant_id = $1 AND status = 'executed'`, archiveDB, `SELECT count(*) FROM destruction_log`}, + {"akten", `SELECT count(*) FROM akten WHERE tenant_id = $1`, tenantDB, `SELECT count(*) FROM folders`}, + } + for _, c := range checks { + var want, got int + if err := d.archivdms.QueryRow(ctx, c.legacySQL, lt).Scan(&want); err != nil { + return out, fmt.Errorf("%s quelle: %w", c.label, err) + } + if err := c.targetPool.QueryRow(ctx, c.targetSQL).Scan(&got); err != nil { + return out, fmt.Errorf("%s ziel: %w", c.label, err) + } + if want != got { + finding("%s: %s quelle=%d ziel=%d", t.Slug, c.label, want, got) + } + out.Counts[c.label] += got + } + } + if t.ArchivmailTenantID != nil { + var want, got int + if err := d.archivmail.QueryRow(ctx, `SELECT count(*) FROM reconciliation_reports WHERE tenant_id = $1`, *t.ArchivmailTenantID).Scan(&want); err != nil { + return out, err + } + if err := archiveDB.QueryRow(ctx, `SELECT count(*) FROM reconciliation_report_archive`).Scan(&got); err != nil { + return out, err + } + if want != got { + finding("%s: reconciliation-berichte quelle=%d ziel=%d", t.Slug, want, got) + } + out.Counts["reconciliation_berichte"] += got + } + } + out.Counts["befunde"] = len(findings) + if len(findings) > 0 { + return out, fmt.Errorf("kettenpruefung: %s", strings.Join(findings, "; ")) + } + return out, nil +} + +func collectStrings(ctx context.Context, pool *pgxpool.Pool, sql string, dst *[]string) error { + rows, err := pool.Query(ctx, sql) + if err != nil { + return err + } + defer rows.Close() + for rows.Next() { + var s string + if err := rows.Scan(&s); err != nil { + return err + } + *dst = append(*dst, s) + } + return rows.Err() +} diff --git a/internal/migrationrole/mapping.go b/internal/migrationrole/mapping.go index 3e869954..c5d7bbe6 100644 --- a/internal/migrationrole/mapping.go +++ b/internal/migrationrole/mapping.go @@ -26,12 +26,21 @@ func (e *ErrUnmappedRole) Error() string { // Core-Rolle ab (Akzeptanzkriterium 2). archivmail kennt kein eigenes // Rechtemodell (nur Freitext), daher die grobere Abbildung auf die drei // Core-Grundrollen aus internal/rbac. +// +// domain_admin/auditor/domain_auditor sind die in datenmodell-altsysteme.md +// dokumentierten archivmail-Rollen, die im CUT-01-Trockenlauf (Befund I-3) +// fehlten. Core kennt keine Pruefer-Rolle, daher landen beide Auditor- +// Varianten bei der geringsten Rolle statt bei tenant_admin. superadmin +// bleibt bewusst ungemappt: Plattformkonten sind keine Mandantenrolle. var archivmailRoleMap = map[string]rbac.Role{ - "admin": rbac.RoleTenantAdmin, - "administrator": rbac.RoleTenantAdmin, - "user": rbac.RoleUser, - "readonly": rbac.RoleUser, - "viewer": rbac.RoleUser, + "admin": rbac.RoleTenantAdmin, + "administrator": rbac.RoleTenantAdmin, + "domain_admin": rbac.RoleTenantAdmin, + "user": rbac.RoleUser, + "readonly": rbac.RoleUser, + "viewer": rbac.RoleUser, + "auditor": rbac.RoleUser, + "domain_auditor": rbac.RoleUser, } // archivdmsGroupMap bildet jede bekannte archivdms-Berechtigungsgruppe diff --git a/internal/migrationrole/mapping_test.go b/internal/migrationrole/mapping_test.go new file mode 100644 index 00000000..5a20717b --- /dev/null +++ b/internal/migrationrole/mapping_test.go @@ -0,0 +1,30 @@ +package migrationrole + +import ( + "errors" + "testing" + + "gitea.perlbach24.de/scripte/nexarch/internal/rbac" +) + +// CUT-01 Befund I-3: alle dokumentierten archivmail-Rollen ausser superadmin +// sind abgebildet, Pruefer-Rollen nie hoeher als user. +func TestCoreRole_DocumentedArchivmailRoles(t *testing.T) { + want := map[string]rbac.Role{ + "user": rbac.RoleUser, + "admin": rbac.RoleTenantAdmin, + "domain_admin": rbac.RoleTenantAdmin, + "auditor": rbac.RoleUser, + "domain_auditor": rbac.RoleUser, + } + for source, role := range want { + got, err := CoreRole("archivmail", source) + if err != nil || got != role { + t.Errorf("archivmail-rolle %q: got %q err %v, erwartet %q", source, got, err, role) + } + } + var unmapped *ErrUnmappedRole + if _, err := CoreRole("archivmail", "superadmin"); !errors.As(err, &unmapped) { + t.Fatalf("superadmin muss fail-closed bleiben, err=%v", err) + } +} diff --git a/internal/tenantapproval/store.go b/internal/tenantapproval/store.go index e26a3538..d01024b3 100644 --- a/internal/tenantapproval/store.go +++ b/internal/tenantapproval/store.go @@ -67,14 +67,14 @@ func NewStore(pool *pgxpool.Pool) *Store { } // InsertCandidates legt MAP-01-Kandidaten als unbestaetigte (status=pending) -// tenant_migration_map-Zeilen an. Einseitige Kandidaten (nur archivmail ODER -// nur archivdms) werden uebersprungen: eine Zusammenfuehrung setzt per -// Definition beide Quellen voraus, Freigabe-Pflicht gilt nur fuer echte -// Merges. +// tenant_migration_map-Zeilen an. Einseitige Kandidaten (nur in einem +// Altsystem) werden ebenfalls angelegt: die tenant_migration_map ist die +// einzige Quelle fuer COR-01, ein hier verworfener Mandant wuerde nie +// migriert (CUT-01, Trockenlauf 1, Befund I-2). Freigabe bleibt Pflicht. func (s *Store) InsertCandidates(ctx context.Context, candidates []tenantmatch.Candidate) ([]int64, error) { var ids []int64 for _, c := range candidates { - if c.ArchivmailTenantID == nil || c.ArchivdmsTenantID == nil { + if c.ArchivmailTenantID == nil && c.ArchivdmsTenantID == nil { continue } var id int64 diff --git a/internal/tenantapproval/store_test.go b/internal/tenantapproval/store_test.go index 17402165..940c7f88 100644 --- a/internal/tenantapproval/store_test.go +++ b/internal/tenantapproval/store_test.go @@ -232,18 +232,38 @@ func TestCorrect_UpdatesApprovedEntryAndLogsPreviousValues(t *testing.T) { } } -func TestInsertCandidates_SkipsOneSidedCandidates(t *testing.T) { +// CUT-01 Befund I-2: ein nur in einem Altsystem vorhandener Mandant muss in +// die Map, sonst migriert COR-01 ihn nie – aber nur als pending, ohne Freigabe. +func TestInsertCandidates_KeepsOneSidedCandidatesPending(t *testing.T) { ctx := context.Background() pool := testPool(t) store := NewStore(pool) ids, err := store.InsertCandidates(ctx, []tenantmatch.Candidate{ {ArchivmailTenantID: mustID(1), Slug: testdb.Slug("onesided"), Name: "Nur Archivmail"}, + {ArchivdmsTenantID: mustID(2), Slug: testdb.Slug("onesided"), Name: "Nur Archivdms"}, + {Slug: testdb.Slug("ohnequelle"), Name: "Ohne Quelle"}, }) if err != nil { t.Fatalf("InsertCandidates: %v", err) } - if len(ids) != 0 { - t.Fatalf("einseitiger kandidat (kein merge) darf keine map-zeile anlegen, habe %d", len(ids)) + if len(ids) != 2 { + t.Fatalf("erwarte 2 einseitige map-zeilen (kandidat ohne quelle verworfen), habe %d", len(ids)) + } + for _, id := range ids { + e, err := store.Get(ctx, id) + if err != nil { + t.Fatalf("Get: %v", err) + } + if e.Status != StatusPending || e.ApprovedAt != nil { + t.Fatalf("einseitiger kandidat darf nicht automatisch freigegeben sein: %+v", e) + } + } + approved, err := store.Approved(ctx) + if err != nil { + t.Fatalf("Approved: %v", err) + } + if len(approved) != 0 { + t.Fatalf("ohne menschliche freigabe darf kein eintrag nutzbar sein, habe %d", len(approved)) } } diff --git a/internal/usermatch/match.go b/internal/usermatch/match.go index 39ff3852..4e5d5011 100644 --- a/internal/usermatch/match.go +++ b/internal/usermatch/match.go @@ -45,12 +45,20 @@ func Match(archivmailUsers, archivdmsUsers []ExportedUser, approvedTenants []ten if t.Status != tenantapproval.StatusApproved { continue } - if t.ArchivmailTenantID == nil || t.ArchivdmsTenantID == nil { + if t.ArchivmailTenantID == nil && t.ArchivdmsTenantID == nil { continue } - amUsers := filterByTenant(archivmailUsers, *t.ArchivmailTenantID) - dmsUsers := filterByTenant(archivdmsUsers, *t.ArchivdmsTenantID) + // Einseitig freigegebene Mandanten (CUT-01 Befund I-2): die Benutzer + // der vorhandenen Seite werden einseitige Kandidaten, sonst fehlten + // sie in user_migration_map und damit in COR-02. + var amUsers, dmsUsers []ExportedUser + if t.ArchivmailTenantID != nil { + amUsers = filterByTenant(archivmailUsers, *t.ArchivmailTenantID) + } + if t.ArchivdmsTenantID != nil { + dmsUsers = filterByTenant(archivdmsUsers, *t.ArchivdmsTenantID) + } dmsByEmail := make(map[string][]ExportedUser, len(dmsUsers)) for _, d := range dmsUsers { diff --git a/internal/usermatch/match_test.go b/internal/usermatch/match_test.go index 97137e58..23763728 100644 --- a/internal/usermatch/match_test.go +++ b/internal/usermatch/match_test.go @@ -130,3 +130,35 @@ func TestMatch_IdentischeEmailUeberTenantGrenzenBleibtGetrennt(t *testing.T) { t.Fatalf("tenant b sollte nur den archivdms-benutzer einseitig sehen, habe %+v", tenantB) } } + +// CUT-01 Befund I-2: ein freigegebener Mandant, der nur in einem Altsystem +// existiert, liefert seine Benutzer als einseitige Kandidaten – ohne Bezug zu +// gleichlautenden Adressen anderer Mandanten. +func TestMatch_EinseitigFreigegebenerTenantLiefertEinseitigeKandidaten(t *testing.T) { + amOnly := int64(50) + dmsOnly := int64(60) + entries := []tenantapproval.Entry{ + {ID: 3, ArchivmailTenantID: &amOnly, NexarchTenantID: "nexarch-nur-mail", Status: tenantapproval.StatusApproved}, + {ID: 4, ArchivdmsTenantID: &dmsOnly, NexarchTenantID: "nexarch-nur-dms", Status: tenantapproval.StatusApproved}, + } + archivmail := []ExportedUser{ + {SourceID: 7, TenantSourceID: 50, Email: "nur-mail@synth.example", Name: "Nur Mail", PasswordHash: "hash-am"}, + {SourceID: 8, TenantSourceID: 99, Email: "fremd@synth.example", Name: "Fremder Mandant"}, + } + archivdms := []ExportedUser{ + {SourceID: 207, TenantSourceID: 60, Email: "nur-mail@synth.example", Name: "Nur DMS", PasswordHash: "hash-dms"}, + } + + candidates := Match(archivmail, archivdms, entries) + if len(candidates) != 2 { + t.Fatalf("erwarte je einen einseitigen kandidaten fuer beide mandanten, habe %d: %+v", len(candidates), candidates) + } + mailSide := findByEmail(t, candidates, "nexarch-nur-mail", "nur-mail@synth.example") + if len(mailSide) != 1 || mailSide[0].ArchivdmsUserID != nil || mailSide[0].PasswordHash != "hash-am" { + t.Fatalf("archivmail-seite falsch: %+v", mailSide) + } + dmsSide := findByEmail(t, candidates, "nexarch-nur-dms", "nur-mail@synth.example") + if len(dmsSide) != 1 || dmsSide[0].ArchivmailUserID != nil || dmsSide[0].PasswordHash != "hash-dms" { + t.Fatalf("archivdms-seite falsch: %+v", dmsSide) + } +} diff --git a/mail/cmd/cutover-stage/main.go b/mail/cmd/cutover-stage/main.go new file mode 100644 index 00000000..10cb3a87 --- /dev/null +++ b/mail/cmd/cutover-stage/main.go @@ -0,0 +1,37 @@ +// cutover-stage fuehrt eine mail-Stufe des CUT-01-Trockenlaufs aus; Aufruf +// ausschliesslich durch cmd/cutover-dryrun im Root-Modul. +package main + +import ( + "context" + "encoding/json" + "fmt" + "os" + + "gitea.perlbach24.de/scripte/nexarch/mail/internal/cutoverstage" +) + +func main() { + if len(os.Args) != 2 { + fmt.Fprintln(os.Stderr, "aufruf: cutover-stage ") + os.Exit(2) + } + env, err := cutoverstage.LoadEnv() + if err != nil { + fmt.Fprintln(os.Stderr, "fehler:", err) + os.Exit(1) + } + defer env.Close() + + res, err := cutoverstage.Run(context.Background(), env, os.Args[1]) + if err != nil { + fmt.Fprintln(os.Stderr, "fehler:", err) + env.Close() + os.Exit(1) + } + if err := json.NewEncoder(os.Stdout).Encode(res); err != nil { + fmt.Fprintln(os.Stderr, "fehler:", err) + env.Close() + os.Exit(1) + } +} diff --git a/mail/internal/cutoverstage/env.go b/mail/internal/cutoverstage/env.go new file mode 100644 index 00000000..ccd47fe5 --- /dev/null +++ b/mail/internal/cutoverstage/env.go @@ -0,0 +1,142 @@ +// Package cutoverstage ist die Mail-Modulstufe des CUT-01-Trockenlaufs +// (Orchestrator: internal/cutoverorchestrator im Root-Modul): Quell-Adapter +// gegen die synthetische archivmail-Testkopie fuer MAI-02, ohne eigene +// Migrationslogik. +package cutoverstage + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/jackc/pgx/v5/pgxpool" +) + +type Tenant struct { + Slug string `json:"slug"` + NexarchTenantID string `json:"nexarch_tenant_id"` + ArchivmailTenantID *int64 `json:"archivmail_tenant_id"` + ArchivdmsTenantID *int64 `json:"archivdms_tenant_id"` + TenantDB string `json:"tenant_db"` + ArchiveDB string `json:"archive_db"` + WorkflowDB string `json:"workflow_db"` +} + +type Context struct { + RunTag string `json:"run_tag"` + RunDir string `json:"run_dir"` + RegistryDB string `json:"registry_db"` + ArchivmailDB string `json:"archivmail_db"` + ArchivdmsDB string `json:"archivdms_db"` + LegacyObjects string `json:"legacy_objects_dir"` + MigrationsDirs map[string]string `json:"migrations_dirs"` + Tenants []Tenant `json:"tenants"` +} + +type Result struct { + Stage string `json:"stage"` + Counts map[string]int `json:"counts"` + Notes []string `json:"notes"` +} + +func (r *Result) add(key string, n int) { + if r.Counts == nil { + r.Counts = map[string]int{} + } + r.Counts[key] += n +} + +func (r *Result) note(format string, args ...any) { + r.Notes = append(r.Notes, fmt.Sprintf(format, args...)) +} + +type Env struct { + Ctx Context + adminDSN string + pools map[string]*pgxpool.Pool +} + +func LoadEnv() (*Env, error) { + adminDSN := os.Getenv("NEXARCH_CUTOVER_ADMIN_DSN") + if adminDSN == "" { + return nil, errors.New("NEXARCH_CUTOVER_ADMIN_DSN nicht gesetzt") + } + ctxPath := os.Getenv("NEXARCH_CUTOVER_CONTEXT") + if ctxPath == "" { + return nil, errors.New("NEXARCH_CUTOVER_CONTEXT nicht gesetzt") + } + raw, err := os.ReadFile(ctxPath) + if err != nil { + return nil, fmt.Errorf("laufkontext lesen: %w", err) + } + e := &Env{adminDSN: adminDSN, pools: map[string]*pgxpool.Pool{}} + if err := json.Unmarshal(raw, &e.Ctx); err != nil { + return nil, fmt.Errorf("laufkontext parsen: %w", err) + } + return e, nil +} + +func (e *Env) Pool(ctx context.Context, database string) (*pgxpool.Pool, error) { + if p, ok := e.pools[database]; ok { + return p, nil + } + cfg, err := pgxpool.ParseConfig(e.adminDSN) + if err != nil { + return nil, errors.New("admin-dsn nicht lesbar") + } + cfg.ConnConfig.Database = database + cfg.MaxConns = 4 + p, err := pgxpool.NewWithConfig(ctx, cfg) + if err != nil { + return nil, fmt.Errorf("verbindung zu %q: %w", database, err) + } + e.pools[database] = p + return p, nil +} + +func (e *Env) Close() { + for _, p := range e.pools { + p.Close() + } +} + +func (e *Env) HandoffDir(slug string) string { + return filepath.Join(e.Ctx.RunDir, "uebergabe", slug) +} + +func (e *Env) TargetDir(kind, slug string) string { + return filepath.Join(e.Ctx.RunDir, "ziel-objekte", kind, slug) +} + +// legacyPath haelt Objektschluessel der Quelle innerhalb des Objektverzeichnisses. +func legacyPath(root, key string) (string, error) { + full := filepath.Join(root, filepath.FromSlash(key)) + rel, err := filepath.Rel(root, full) + if err != nil || strings.HasPrefix(rel, "..") { + return "", fmt.Errorf("objektschluessel %q verlaesst das quellverzeichnis", key) + } + return full, nil +} + +func writeJSON(path string, v any) error { + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return err + } + data, err := json.MarshalIndent(v, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, data, 0o600) +} + +func readJSON(path string, v any) error { + data, err := os.ReadFile(path) + if err != nil { + return err + } + return json.Unmarshal(data, v) +} diff --git a/mail/internal/cutoverstage/stages.go b/mail/internal/cutoverstage/stages.go new file mode 100644 index 00000000..10e832a8 --- /dev/null +++ b/mail/internal/cutoverstage/stages.go @@ -0,0 +1,229 @@ +package cutoverstage + +import ( + "context" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + + "gitea.perlbach24.de/scripte/nexarch/mail/internal/messagemigration" + "gitea.perlbach24.de/scripte/nexarch/mail/internal/search" + "gitea.perlbach24.de/scripte/nexarch/mail/internal/storage" +) + +type AttachmentHandoff struct { + SourceObjectKey string `json:"source_object_key"` + StorageKey string `json:"storage_key"` +} + +type MessageHandoff struct { + SourceMessageID string `json:"source_message_id"` + ObjectReference string `json:"object_reference"` + RetainUntil *time.Time `json:"retain_until"` + DataSubjectRef string `json:"data_subject_ref"` + Attachments []AttachmentHandoff `json:"attachments"` +} + +func Run(ctx context.Context, e *Env, stage string) (Result, error) { + res := Result{Stage: stage} + switch stage { + case "mai02": + for _, t := range e.Ctx.Tenants { + if t.ArchivmailTenantID == nil { + res.note("%s: kein archivmail-mandant, stufe entfaellt", t.Slug) + continue + } + if err := e.mai02(ctx, t, *t.ArchivmailTenantID, &res); err != nil { + return res, fmt.Errorf("mandant %s: %w", t.Slug, err) + } + } + res.note("suchindex im trockenlauf durch dateibasierten ersatz (ziel-objekte/mail-index) vertreten, siehe pruefprotokoll") + return res, nil + } + return res, fmt.Errorf("unbekannte mail-stufe %q", stage) +} + +// messageSource: Nachrichten eines Mandanten sind die eigenen (emails.tenant_id) +// plus die per email_refs zusaetzlich sichtbaren; der Rohinhalt liegt als +// Objekt unter storage_objects.path. +type messageSource struct { + legacy *pgxpool.Pool + tenantID int64 + objectRoot string +} + +func (s messageSource) Messages(ctx context.Context) ([]messagemigration.SourceMessage, error) { + rows, err := s.legacy.Query(ctx, ` + SELECT e.id, COALESCE(e.mail_to, ''), COALESCE(e.mail_from, ''), COALESCE(e.subject, ''), + e.received_at, e.indexed_at, so.path + FROM emails e JOIN storage_objects so ON so.id = e.storage_id + WHERE e.tenant_id = $1 OR EXISTS (SELECT 1 FROM email_refs r WHERE r.email_id = e.id AND r.tenant_id = $1) + ORDER BY e.id + `, s.tenantID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []messagemigration.SourceMessage + for rows.Next() { + var m messagemigration.SourceMessage + var received time.Time + var indexed *time.Time + var bodyPath string + if err := rows.Scan(&m.SourceMessageID, &m.Mailbox, &m.Sender, &m.Subject, &received, &indexed, &bodyPath); err != nil { + return nil, err + } + body, err := s.read(bodyPath) + if err != nil { + return nil, fmt.Errorf("nachricht %s: rohinhalt: %w", m.SourceMessageID, err) + } + m.Body = string(body) + m.SentAtUnixEpoch = received.Unix() + if indexed != nil { + v := indexed.Unix() + m.IndexedAt = &v + } + out = append(out, m) + } + return out, rows.Err() +} + +func (s messageSource) Attachments(ctx context.Context, sourceMessageID string) ([]messagemigration.SourceAttachment, error) { + rows, err := s.legacy.Query(ctx, ` + SELECT a.id::text, COALESCE(a.mime_type, 'application/octet-stream'), COALESCE(a.size_bytes, 0), a.hash, so.path + FROM email_attachments ea + JOIN attachments a ON a.id = ea.attachment_id + JOIN storage_objects so ON so.id = a.storage_id + WHERE ea.email_id = $1 ORDER BY a.id + `, sourceMessageID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []messagemigration.SourceAttachment + for rows.Next() { + var a messagemigration.SourceAttachment + if err := rows.Scan(&a.SourceAttachmentID, &a.MimeType, &a.SizeBytes, &a.ChecksumSHA256, &a.ObjectKey); err != nil { + return nil, err + } + a.ChecksumSHA256 = strings.TrimSpace(a.ChecksumSHA256) + a.Index = len(out) + out = append(out, a) + } + return out, rows.Err() +} + +func (s messageSource) read(key string) ([]byte, error) { + full, err := legacyPath(s.objectRoot, key) + if err != nil { + return nil, err + } + return os.ReadFile(full) +} + +func (s messageSource) OpenObject(_ context.Context, objectKey string) (io.ReadCloser, error) { + full, err := legacyPath(s.objectRoot, objectKey) + if err != nil { + return nil, err + } + return os.Open(full) +} + +// fileIndexer vertritt den Manticore-Suchindex: die einzige Manticore- +// Instanz auf dem Testhost wird mit dem Altsystem archivdms geteilt und +// bleibt fuer den Trockenlauf tabu. +type fileIndexer struct { + mu sync.Mutex + path string + docs map[string]search.Document +} + +func openFileIndexer(path string) (*fileIndexer, error) { + idx := &fileIndexer{path: path, docs: map[string]search.Document{}} + if _, err := os.Stat(path); err == nil { + if err := readJSON(path, &idx.docs); err != nil { + return nil, err + } + } + return idx, nil +} + +func (f *fileIndexer) GetByMessageID(_ context.Context, tenantSlug, messageID string) (search.Document, bool, error) { + f.mu.Lock() + defer f.mu.Unlock() + d, ok := f.docs[messageID] + if ok && d.TenantSlug != tenantSlug { + return search.Document{}, false, nil + } + return d, ok, nil +} + +func (f *fileIndexer) Index(_ context.Context, doc search.Document) error { + f.mu.Lock() + defer f.mu.Unlock() + f.docs[doc.MessageID] = doc + return writeJSON(f.path, f.docs) +} + +type noopUsage struct{} + +func (noopUsage) Report(context.Context, string, string, int64) error { return nil } + +func (e *Env) mai02(ctx context.Context, t Tenant, legacyTenantID int64, res *Result) error { + legacy, err := e.Pool(ctx, e.Ctx.ArchivmailDB) + if err != nil { + return err + } + storeDir := e.TargetDir("mail", t.Slug) + if err := os.MkdirAll(storeDir, 0o700); err != nil { + return err + } + idx, err := openFileIndexer(filepath.Join(e.Ctx.RunDir, "ziel-objekte", "mail-index", t.Slug+".json")) + if err != nil { + return err + } + src := messageSource{legacy: legacy, tenantID: legacyTenantID, objectRoot: filepath.Join(e.Ctx.LegacyObjects, "archivmail")} + storageSvc := storage.NewService(storage.NewLocalDriver(storeDir), noopUsage{}, t.Slug) + out, err := messagemigration.NewMigrator(src, storageSvc, idx, t.Slug).MigrateAll(ctx) + if err != nil { + return err + } + res.add("nachrichten", out.MessagesMigrated) + res.add("nachrichten_uebersprungen", out.MessagesSkipped) + res.add("anhaenge", out.AttachmentsMigrated) + + msgs, err := src.Messages(ctx) + if err != nil { + return err + } + var handoff []MessageHandoff + for _, m := range msgs { + h := MessageHandoff{ + SourceMessageID: m.SourceMessageID, + ObjectReference: messagemigration.MessageID(t.Slug, m.SourceMessageID), + DataSubjectRef: strings.ToLower(m.Sender), + } + if err := legacy.QueryRow(ctx, `SELECT retain_until FROM emails WHERE id = $1`, m.SourceMessageID).Scan(&h.RetainUntil); err != nil { + return err + } + atts, err := src.Attachments(ctx, m.SourceMessageID) + if err != nil { + return err + } + for _, a := range atts { + h.Attachments = append(h.Attachments, AttachmentHandoff{ + SourceObjectKey: "archivmail/" + a.ObjectKey, + StorageKey: storage.ObjectKey(h.ObjectReference, a.Index), + }) + } + handoff = append(handoff, h) + } + res.add("uebergabe_objekte", len(handoff)) + return writeJSON(filepath.Join(e.HandoffDir(t.Slug), "mail_objekte.json"), handoff) +} diff --git a/workflow/cmd/cutover-stage/main.go b/workflow/cmd/cutover-stage/main.go new file mode 100644 index 00000000..75bff689 --- /dev/null +++ b/workflow/cmd/cutover-stage/main.go @@ -0,0 +1,37 @@ +// cutover-stage fuehrt eine workflow-Stufe des CUT-01-Trockenlaufs aus; Aufruf +// ausschliesslich durch cmd/cutover-dryrun im Root-Modul. +package main + +import ( + "context" + "encoding/json" + "fmt" + "os" + + "gitea.perlbach24.de/scripte/nexarch/workflow/internal/cutoverstage" +) + +func main() { + if len(os.Args) != 2 { + fmt.Fprintln(os.Stderr, "aufruf: cutover-stage ") + os.Exit(2) + } + env, err := cutoverstage.LoadEnv() + if err != nil { + fmt.Fprintln(os.Stderr, "fehler:", err) + os.Exit(1) + } + defer env.Close() + + res, err := cutoverstage.Run(context.Background(), env, os.Args[1]) + if err != nil { + fmt.Fprintln(os.Stderr, "fehler:", err) + env.Close() + os.Exit(1) + } + if err := json.NewEncoder(os.Stdout).Encode(res); err != nil { + fmt.Fprintln(os.Stderr, "fehler:", err) + env.Close() + os.Exit(1) + } +} diff --git a/workflow/internal/cutoverstage/env.go b/workflow/internal/cutoverstage/env.go new file mode 100644 index 00000000..24241a6a --- /dev/null +++ b/workflow/internal/cutoverstage/env.go @@ -0,0 +1,172 @@ +// Package cutoverstage ist die Workflow-Modulstufe des CUT-01-Trockenlaufs +// (Orchestrator: internal/cutoverorchestrator im Root-Modul): Quell-Adapter +// gegen die synthetische archivdms-Testkopie fuer WFL-01, ohne eigene +// Migrationslogik. +package cutoverstage + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + + "github.com/jackc/pgx/v5/pgxpool" +) + +type Tenant struct { + Slug string `json:"slug"` + NexarchTenantID string `json:"nexarch_tenant_id"` + ArchivmailTenantID *int64 `json:"archivmail_tenant_id"` + ArchivdmsTenantID *int64 `json:"archivdms_tenant_id"` + TenantDB string `json:"tenant_db"` + ArchiveDB string `json:"archive_db"` + WorkflowDB string `json:"workflow_db"` +} + +type Context struct { + RunTag string `json:"run_tag"` + RunDir string `json:"run_dir"` + RegistryDB string `json:"registry_db"` + ArchivmailDB string `json:"archivmail_db"` + ArchivdmsDB string `json:"archivdms_db"` + LegacyObjects string `json:"legacy_objects_dir"` + MigrationsDirs map[string]string `json:"migrations_dirs"` + Tenants []Tenant `json:"tenants"` +} + +type Result struct { + Stage string `json:"stage"` + Counts map[string]int `json:"counts"` + Notes []string `json:"notes"` +} + +func (r *Result) add(key string, n int) { + if r.Counts == nil { + r.Counts = map[string]int{} + } + r.Counts[key] += n +} + +func (r *Result) note(format string, args ...any) { + r.Notes = append(r.Notes, fmt.Sprintf(format, args...)) +} + +type Env struct { + Ctx Context + adminDSN string + pools map[string]*pgxpool.Pool +} + +func LoadEnv() (*Env, error) { + adminDSN := os.Getenv("NEXARCH_CUTOVER_ADMIN_DSN") + if adminDSN == "" { + return nil, errors.New("NEXARCH_CUTOVER_ADMIN_DSN nicht gesetzt") + } + ctxPath := os.Getenv("NEXARCH_CUTOVER_CONTEXT") + if ctxPath == "" { + return nil, errors.New("NEXARCH_CUTOVER_CONTEXT nicht gesetzt") + } + raw, err := os.ReadFile(ctxPath) + if err != nil { + return nil, fmt.Errorf("laufkontext lesen: %w", err) + } + e := &Env{adminDSN: adminDSN, pools: map[string]*pgxpool.Pool{}} + if err := json.Unmarshal(raw, &e.Ctx); err != nil { + return nil, fmt.Errorf("laufkontext parsen: %w", err) + } + return e, nil +} + +func (e *Env) Pool(ctx context.Context, database string) (*pgxpool.Pool, error) { + if p, ok := e.pools[database]; ok { + return p, nil + } + cfg, err := pgxpool.ParseConfig(e.adminDSN) + if err != nil { + return nil, errors.New("admin-dsn nicht lesbar") + } + cfg.ConnConfig.Database = database + cfg.MaxConns = 4 + p, err := pgxpool.NewWithConfig(ctx, cfg) + if err != nil { + return nil, fmt.Errorf("verbindung zu %q: %w", database, err) + } + e.pools[database] = p + return p, nil +} + +func (e *Env) Close() { + for _, p := range e.pools { + p.Close() + } +} + +func (e *Env) HandoffDir(slug string) string { + return filepath.Join(e.Ctx.RunDir, "uebergabe", slug) +} + +func (e *Env) TargetDir(kind, slug string) string { + return filepath.Join(e.Ctx.RunDir, "ziel-objekte", kind, slug) +} + +type registryUserResolver struct { + registry *pgxpool.Pool + tenantID string +} + +func (r registryUserResolver) ResolveUserID(ctx context.Context, sourceUserID string) (string, error) { + id, err := strconv.ParseInt(strings.TrimSpace(sourceUserID), 10, 64) + if err != nil { + return "", fmt.Errorf("archivdms-benutzer-id %q ungueltig", sourceUserID) + } + var out string + err = r.registry.QueryRow(ctx, ` + SELECT nexarch_user_id::text FROM user_migration_map + WHERE nexarch_tenant_id = $1 AND archivdms_user_id = $2 AND approved_at IS NOT NULL + `, r.tenantID, id).Scan(&out) + if err != nil { + return "", fmt.Errorf("archivdms-benutzer %d nicht in freigegebener user_migration_map: %w", id, err) + } + return out, nil +} + +func (e *Env) userResolver(ctx context.Context, t Tenant) (registryUserResolver, error) { + reg, err := e.Pool(ctx, e.Ctx.RegistryDB) + if err != nil { + return registryUserResolver{}, err + } + return registryUserResolver{registry: reg, tenantID: t.NexarchTenantID}, nil +} + +// legacyPath haelt Objektschluessel der Quelle innerhalb des Objektverzeichnisses. +func legacyPath(root, key string) (string, error) { + full := filepath.Join(root, filepath.FromSlash(key)) + rel, err := filepath.Rel(root, full) + if err != nil || strings.HasPrefix(rel, "..") { + return "", fmt.Errorf("objektschluessel %q verlaesst das quellverzeichnis", key) + } + return full, nil +} + +func writeJSON(path string, v any) error { + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return err + } + data, err := json.MarshalIndent(v, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, data, 0o600) +} + +func readJSON(path string, v any) error { + data, err := os.ReadFile(path) + if err != nil { + return err + } + return json.Unmarshal(data, v) +} diff --git a/workflow/internal/cutoverstage/stages.go b/workflow/internal/cutoverstage/stages.go new file mode 100644 index 00000000..7392fb36 --- /dev/null +++ b/workflow/internal/cutoverstage/stages.go @@ -0,0 +1,122 @@ +package cutoverstage + +import ( + "context" + "fmt" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + + "gitea.perlbach24.de/scripte/nexarch/workflow/internal/migrate" + "gitea.perlbach24.de/scripte/nexarch/workflow/internal/migrationworkflow" +) + +func Run(ctx context.Context, e *Env, stage string) (Result, error) { + res := Result{Stage: stage} + switch stage { + case "schema": + migrations, err := migrate.Load(e.Ctx.MigrationsDirs["workflow"]) + if err != nil { + return res, err + } + for _, t := range e.Ctx.Tenants { + pool, err := e.Pool(ctx, t.WorkflowDB) + if err != nil { + return res, err + } + applied, err := migrate.Up(ctx, pool, migrations) + if err != nil { + return res, fmt.Errorf("mandant %s: workflow-migrationen: %w", t.Slug, err) + } + res.add("mandanten", 1) + res.add("workflow_migrationen_angewendet", len(applied)) + } + return res, nil + case "wfl01": + for _, t := range e.Ctx.Tenants { + if t.ArchivdmsTenantID == nil { + res.note("%s: kein archivdms-mandant, stufe entfaellt", t.Slug) + continue + } + if err := e.wfl01(ctx, t, *t.ArchivdmsTenantID, &res); err != nil { + return res, fmt.Errorf("mandant %s: %w", t.Slug, err) + } + } + return res, nil + } + return res, fmt.Errorf("unbekannte workflow-stufe %q", stage) +} + +// workflowSource: archivdms-Workflowlaeufe (workflow_runs) sind die Vorgaenge, +// ihre Statuswechsel (workflow_run_events) die Historie. +type workflowSource struct { + legacy *pgxpool.Pool + tenantID int64 +} + +func (s workflowSource) Cases(ctx context.Context) ([]migrationworkflow.SourceCase, error) { + rows, err := s.legacy.Query(ctx, ` + SELECT r.id::text, w.name, COALESCE(r.final_state, ''), r.status + FROM workflow_runs r JOIN workflows w ON w.id = r.workflow_id + WHERE r.tenant_id = $1 ORDER BY r.id + `, s.tenantID) + if err != nil { + return nil, err + } + return pgx.CollectRows(rows, func(r pgx.CollectableRow) (migrationworkflow.SourceCase, error) { + var c migrationworkflow.SourceCase + var status string + err := r.Scan(&c.SourceCaseID, &c.ProcessName, &c.FinalState, &status) + c.Status = migrationworkflow.Status(status) + return c, err + }) +} + +func (s workflowSource) History(ctx context.Context, sourceCaseID string) ([]migrationworkflow.SourceHistoryEntry, error) { + rows, err := s.legacy.Query(ctx, ` + SELECT from_state, to_state, actor_user_id::text, note, occurred_at + FROM workflow_run_events WHERE run_id = $1::bigint ORDER BY occurred_at, id + `, sourceCaseID) + if err != nil { + return nil, err + } + return pgx.CollectRows(rows, func(r pgx.CollectableRow) (migrationworkflow.SourceHistoryEntry, error) { + var h migrationworkflow.SourceHistoryEntry + var at time.Time + err := r.Scan(&h.FromState, &h.ToState, &h.SourceActor, &h.Reason, &at) + h.OccurredAtRFC3339 = at.UTC().Format(time.RFC3339Nano) + return h, err + }) +} + +func (e *Env) wfl01(ctx context.Context, t Tenant, legacyTenantID int64, res *Result) error { + legacy, err := e.Pool(ctx, e.Ctx.ArchivdmsDB) + if err != nil { + return err + } + pool, err := e.Pool(ctx, t.WorkflowDB) + if err != nil { + return err + } + users, err := e.userResolver(ctx, t) + if err != nil { + return err + } + m := migrationworkflow.NewMigrator(pool, workflowSource{legacy: legacy, tenantID: legacyTenantID}, users) + out, err := m.MigrateCompleted(ctx) + if err != nil { + return err + } + open, err := m.OpenCases(ctx) + if err != nil { + return err + } + res.add("vorgaenge_migriert", out.InstancesMigrated) + res.add("historieneintraege", out.HistoryEntriesMigrated) + res.add("vorgaenge_offen_nicht_migriert", out.InstancesSkippedOpen) + if len(open) > 0 { + res.note("%s: %d laufende vorgaenge muessen vor dem stichtag im altsystem abgeschlossen werden", t.Slug, len(open)) + } + return nil +}