From 8f75ab3b375302390714d336f0ea99c8b2a7ce95 Mon Sep 17 00:00:00 2001 From: patrick Date: Wed, 14 Oct 2026 20:45:03 +0000 Subject: [PATCH 1/5] CUT-01: trockenlauf-1-entwicklung - orchestrator erster entwurf Orchestrator (internal/cutoverorchestrator, cmd/cutover-dryrun) fuer den Trockenlauf aller Migrationspakete gegen eine vollstaendig synthetische Testkopie: Ablaufplan mit Abhaengigkeitspruefung, Abbruch beim ersten Fehler, JSON-/Textprotokoll mit Redigierung von Zugangsdaten, Generator und Anonymisierungsnachweis der Testkopie. Modulstufen (dms/mail/archive/workflow cmd/cutover-stage) liefern die Quell-Adapter und rufen die bestehenden Migratoren der jeweiligen Module auf, da deren internal-Pakete aus dem Root-Modul nicht importierbar sind. migrationdocument.DocumentID macht die DOK-01-Ziel-ID fuer TAR-01/TAR-02 im Archive-Modul verfuegbar. Co-Authored-By: Claude Sonnet 5 --- archive/cmd/cutover-stage/main.go | 37 + archive/internal/cutoverstage/env.go | 171 +++++ archive/internal/cutoverstage/stages.go | 414 ++++++++++ cmd/cutover-dryrun/main.go | 80 ++ dms/cmd/cutover-stage/main.go | 37 + dms/internal/cutoverstage/env.go | 194 +++++ dms/internal/cutoverstage/sources.go | 282 +++++++ dms/internal/cutoverstage/stages.go | 305 ++++++++ dms/internal/migrationdocument/migrate.go | 7 + internal/cutoverorchestrator/anonymization.go | 144 ++++ internal/cutoverorchestrator/cluster.go | 137 ++++ internal/cutoverorchestrator/dataset.go | 708 ++++++++++++++++++ internal/cutoverorchestrator/dataset_test.go | 153 ++++ internal/cutoverorchestrator/dryrun.go | 189 +++++ internal/cutoverorchestrator/legacyschema.go | 302 ++++++++ internal/cutoverorchestrator/plan.go | 51 ++ internal/cutoverorchestrator/protocol.go | 72 ++ internal/cutoverorchestrator/redact.go | 42 ++ internal/cutoverorchestrator/runner.go | 125 ++++ internal/cutoverorchestrator/runner_test.go | 169 +++++ internal/cutoverorchestrator/seed.go | 222 ++++++ internal/cutoverorchestrator/stages.go | 119 +++ internal/cutoverorchestrator/steps_acl.go | 181 +++++ internal/cutoverorchestrator/steps_core.go | 476 ++++++++++++ internal/cutoverorchestrator/steps_prep.go | 109 +++ internal/cutoverorchestrator/steps_verify.go | 430 +++++++++++ mail/cmd/cutover-stage/main.go | 37 + mail/internal/cutoverstage/env.go | 142 ++++ mail/internal/cutoverstage/stages.go | 229 ++++++ workflow/cmd/cutover-stage/main.go | 37 + workflow/internal/cutoverstage/env.go | 172 +++++ workflow/internal/cutoverstage/stages.go | 122 +++ 32 files changed, 5895 insertions(+) create mode 100644 archive/cmd/cutover-stage/main.go create mode 100644 archive/internal/cutoverstage/env.go create mode 100644 archive/internal/cutoverstage/stages.go create mode 100644 cmd/cutover-dryrun/main.go create mode 100644 dms/cmd/cutover-stage/main.go create mode 100644 dms/internal/cutoverstage/env.go create mode 100644 dms/internal/cutoverstage/sources.go create mode 100644 dms/internal/cutoverstage/stages.go create mode 100644 internal/cutoverorchestrator/anonymization.go create mode 100644 internal/cutoverorchestrator/cluster.go create mode 100644 internal/cutoverorchestrator/dataset.go create mode 100644 internal/cutoverorchestrator/dataset_test.go create mode 100644 internal/cutoverorchestrator/dryrun.go create mode 100644 internal/cutoverorchestrator/legacyschema.go create mode 100644 internal/cutoverorchestrator/plan.go create mode 100644 internal/cutoverorchestrator/protocol.go create mode 100644 internal/cutoverorchestrator/redact.go create mode 100644 internal/cutoverorchestrator/runner.go create mode 100644 internal/cutoverorchestrator/runner_test.go create mode 100644 internal/cutoverorchestrator/seed.go create mode 100644 internal/cutoverorchestrator/stages.go create mode 100644 internal/cutoverorchestrator/steps_acl.go create mode 100644 internal/cutoverorchestrator/steps_core.go create mode 100644 internal/cutoverorchestrator/steps_prep.go create mode 100644 internal/cutoverorchestrator/steps_verify.go create mode 100644 mail/cmd/cutover-stage/main.go create mode 100644 mail/internal/cutoverstage/env.go create mode 100644 mail/internal/cutoverstage/stages.go create mode 100644 workflow/cmd/cutover-stage/main.go create mode 100644 workflow/internal/cutoverstage/env.go create mode 100644 workflow/internal/cutoverstage/stages.go 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..b4a42b21 --- /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}) + } + 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/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/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/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..ed4ca26b --- /dev/null +++ b/internal/cutoverorchestrator/cluster.go @@ -0,0 +1,137 @@ +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) { + cfg, err := pgxpool.ParseConfig(c.adminDSN) + if err != nil { + return nil, errDSNFormat + } + cfg.ConnConfig.Database = database + 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..1d6491c9 --- /dev/null +++ b/internal/cutoverorchestrator/dryrun.go @@ -0,0 +1,189 @@ +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 + 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} { + 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 = 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: "MAP-01", Title: "Tenant-Export und Slug/Domain-Abgleich", DependsOn: []string{"PREP-03"}, 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/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..9ebbc5f3 --- /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", "MAP-01"}, + } + 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..e3c2d251 --- /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.archivmail, "archivmail") + if err != nil { + return Outcome{}, err + } + dms, err := tenantmatch.ExportTenants(ctx, d.archivdms, "archivdms") + if err != nil { + return Outcome{}, err + } + for name, p := range map[string]*pgxpool.Pool{"archivmail": d.archivmail, "archivdms": d.archivdms} { + 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.archivmail, "archivmail") + if err != nil { + return Outcome{}, err + } + dms, err := usermatch.ExportUsers(ctx, d.archivdms, "archivdms") + if err != nil { + return Outcome{}, err + } + for name, p := range map[string]*pgxpool.Pool{"archivmail": d.archivmail, "archivdms": d.archivdms} { + 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/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 +} -- 2.47.3 From 25812b47b8b4e5f4f6bf43c0e9d68747bc3ab99f Mon Sep 17 00:00:00 2001 From: patrick Date: Wed, 14 Oct 2026 20:53:52 +0000 Subject: [PATCH 2/5] CUT-01: trockenlauf-1-entwicklung - korrekturen aus iteration 1 Iteration 1 (Laeufe 1.1-1.5 gegen den Wegwerf-Cluster auf Host 204): - I-1 (Lauf 1.1, MAP-01/MAP-03): Export erwartete tenants.domain und users.name, die Altsysteme fuehren tenant_domains bzw. users.username. Behoben ueber Export-Sichten (Schritt EXP-01) statt Aenderung der MAP-Pakete. - I-2 (Lauf 1.2, MAP-02/MAP-03): einseitige Mandanten (nur in einem Altsystem) wurden von InsertCandidates verworfen und von usermatch uebersprungen und waeren nie migriert worden. Jetzt pending in der Map (Freigabe bleibt Pflicht), Benutzer als einseitige Kandidaten. - I-3 (Lauf 1.3, COR-03): dokumentierte archivmail-Rollen domain_admin, auditor, domain_auditor ohne Core-Rolle. Ergaenzt (Pruefer -> user), superadmin bleibt fail-closed. - I-4 (Lauf 1.4, TAR-01 -> MAI-03): retention_objects ohne data_subject_ref, offene DSGVO-Anfragen liefen ins Leere. TAR-01 uebernimmt die betroffene Person aus der Quelle. Lauf 1.5 lief vollstaendig durch. Protokolle unter docs/cut-01/iteration-1. Co-Authored-By: Claude Sonnet 5 --- archive/internal/cutoverstage/stages.go | 2 +- .../retentionmigration/retentionmigration.go | 13 +- .../retentionmigration_test.go | 36 ++ dms/internal/cutoverstage/sources_test.go | 42 ++ .../iteration-1/lauf-1.1-cut01479c9d.json | 419 +++++++++++++ .../iteration-1/lauf-1.1-cut01479c9d.txt | 70 +++ .../iteration-1/lauf-1.2-cut01a6173e.json | 445 ++++++++++++++ .../iteration-1/lauf-1.2-cut01a6173e.txt | 74 +++ .../iteration-1/lauf-1.3-cut01c33bb7.json | 459 +++++++++++++++ .../iteration-1/lauf-1.3-cut01c33bb7.txt | 73 +++ .../iteration-1/lauf-1.4-cut0171964d.json | 508 ++++++++++++++++ .../iteration-1/lauf-1.4-cut0171964d.txt | 81 +++ .../iteration-1/lauf-1.5-cut01748e71.json | 555 ++++++++++++++++++ .../iteration-1/lauf-1.5-cut01748e71.txt | 88 +++ .../lauf-1.5-val01-zeilenzahl-abgleich.md | 16 + internal/cutoverorchestrator/cluster.go | 7 + internal/cutoverorchestrator/dryrun.go | 35 +- internal/cutoverorchestrator/export.go | 56 ++ internal/cutoverorchestrator/runner_test.go | 2 +- internal/cutoverorchestrator/steps_core.go | 12 +- internal/migrationrole/mapping.go | 19 +- internal/migrationrole/mapping_test.go | 30 + internal/tenantapproval/store.go | 10 +- internal/tenantapproval/store_test.go | 26 +- internal/usermatch/match.go | 14 +- internal/usermatch/match_test.go | 32 + 26 files changed, 3082 insertions(+), 42 deletions(-) create mode 100644 dms/internal/cutoverstage/sources_test.go create mode 100644 docs/cut-01/iteration-1/lauf-1.1-cut01479c9d.json create mode 100644 docs/cut-01/iteration-1/lauf-1.1-cut01479c9d.txt create mode 100644 docs/cut-01/iteration-1/lauf-1.2-cut01a6173e.json create mode 100644 docs/cut-01/iteration-1/lauf-1.2-cut01a6173e.txt create mode 100644 docs/cut-01/iteration-1/lauf-1.3-cut01c33bb7.json create mode 100644 docs/cut-01/iteration-1/lauf-1.3-cut01c33bb7.txt create mode 100644 docs/cut-01/iteration-1/lauf-1.4-cut0171964d.json create mode 100644 docs/cut-01/iteration-1/lauf-1.4-cut0171964d.txt create mode 100644 docs/cut-01/iteration-1/lauf-1.5-cut01748e71.json create mode 100644 docs/cut-01/iteration-1/lauf-1.5-cut01748e71.txt create mode 100644 docs/cut-01/iteration-1/lauf-1.5-val01-zeilenzahl-abgleich.md create mode 100644 internal/cutoverorchestrator/export.go create mode 100644 internal/migrationrole/mapping_test.go diff --git a/archive/internal/cutoverstage/stages.go b/archive/internal/cutoverstage/stages.go index b4a42b21..29dbb8ab 100644 --- a/archive/internal/cutoverstage/stages.go +++ b/archive/internal/cutoverstage/stages.go @@ -133,7 +133,7 @@ func (e *Env) tar01(ctx context.Context, t Tenant, res *Result) error { } } for _, m := range msgs { - src = append(src, retentionmigration.SourceObject{ObjectType: objectTypeMessage, ObjectReference: m.ObjectReference, RetainUntil: m.RetainUntil}) + 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 { 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/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/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/internal/cutoverorchestrator/cluster.go b/internal/cutoverorchestrator/cluster.go index ed4ca26b..f8ae98d0 100644 --- a/internal/cutoverorchestrator/cluster.go +++ b/internal/cutoverorchestrator/cluster.go @@ -48,11 +48,18 @@ func (c *Cluster) TenantDSNTemplate() string { } 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 { diff --git a/internal/cutoverorchestrator/dryrun.go b/internal/cutoverorchestrator/dryrun.go index 1d6491c9..fa96fbc9 100644 --- a/internal/cutoverorchestrator/dryrun.go +++ b/internal/cutoverorchestrator/dryrun.go @@ -32,19 +32,21 @@ type Dryrun struct { kekSeed string now time.Time - dataset *Dataset - registryDB string - archivmailDB string - archivdmsDB string - registry *pgxpool.Pool - archivmail *pgxpool.Pool - archivdms *pgxpool.Pool - tenantCands []tenantmatch.Candidate - userCands []usermatch.Candidate - tenants []ContextTenant - tenantPools map[string]*pgxpool.Pool - stages StageRunner - contextPath string + 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) { @@ -119,7 +121,7 @@ func (d *Dryrun) Close() { for _, p := range d.tenantPools { p.Close() } - for _, p := range []*pgxpool.Pool{d.registry, d.archivmail, d.archivdms} { + for _, p := range []*pgxpool.Pool{d.registry, d.archivmail, d.archivdms, d.exportArchivmail, d.exportArchivdms} { if p != nil { p.Close() } @@ -131,7 +133,7 @@ func (d *Dryrun) Close() { func (d *Dryrun) Cleanup(ctx context.Context) ([]string, error) { d.Close() d.tenantPools = map[string]*pgxpool.Pool{} - d.registry, d.archivmail, d.archivdms = nil, nil, nil + 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)) @@ -153,7 +155,8 @@ func (d *Dryrun) Plan() []Step { {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: "MAP-01", Title: "Tenant-Export und Slug/Domain-Abgleich", DependsOn: []string{"PREP-03"}, Run: d.map01}, + {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}, 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/runner_test.go b/internal/cutoverorchestrator/runner_test.go index 9ebbc5f3..da28301c 100644 --- a/internal/cutoverorchestrator/runner_test.go +++ b/internal/cutoverorchestrator/runner_test.go @@ -154,7 +154,7 @@ func TestDryrunPlan_RespectsMigrationDependencies(t *testing.T) { {"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", "MAP-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]] { diff --git a/internal/cutoverorchestrator/steps_core.go b/internal/cutoverorchestrator/steps_core.go index e3c2d251..ae0a5b4a 100644 --- a/internal/cutoverorchestrator/steps_core.go +++ b/internal/cutoverorchestrator/steps_core.go @@ -24,15 +24,15 @@ import ( const dryrunReviewer = "trockenlauf-pruefer (synthetische freigabe, CUT-01)" func (d *Dryrun) map01(ctx context.Context) (Outcome, error) { - am, err := tenantmatch.ExportTenants(ctx, d.archivmail, "archivmail") + am, err := tenantmatch.ExportTenants(ctx, d.exportArchivmail, "archivmail") if err != nil { return Outcome{}, err } - dms, err := tenantmatch.ExportTenants(ctx, d.archivdms, "archivdms") + dms, err := tenantmatch.ExportTenants(ctx, d.exportArchivdms, "archivdms") if err != nil { return Outcome{}, err } - for name, p := range map[string]*pgxpool.Pool{"archivmail": d.archivmail, "archivdms": d.archivdms} { + 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 @@ -117,15 +117,15 @@ func (d *Dryrun) map02(ctx context.Context) (Outcome, error) { } func (d *Dryrun) map03(ctx context.Context) (Outcome, error) { - am, err := usermatch.ExportUsers(ctx, d.archivmail, "archivmail") + am, err := usermatch.ExportUsers(ctx, d.exportArchivmail, "archivmail") if err != nil { return Outcome{}, err } - dms, err := usermatch.ExportUsers(ctx, d.archivdms, "archivdms") + dms, err := usermatch.ExportUsers(ctx, d.exportArchivdms, "archivdms") if err != nil { return Outcome{}, err } - for name, p := range map[string]*pgxpool.Pool{"archivmail": d.archivmail, "archivdms": d.archivdms} { + 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 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) + } +} -- 2.47.3 From 936c66888fa855abafbd9bcb4599ecb49c9a646c Mon Sep 17 00:00:00 2001 From: patrick Date: Wed, 14 Oct 2026 20:55:37 +0000 Subject: [PATCH 3/5] CUT-01: trockenlauf-1-entwicklung - iteration 2 bestaetigt Zwei Bestaetigungslaeufe auf Commit 4fdbb9b ohne Codeaenderung dazwischen (Laeufe 2.1 cut017f8b14 und 2.2 cut01d7d2a2), echter Postgres-Wegwerf- Cluster auf Host 204: jeweils alle 31 Schritte ohne Abbruch, VAL-01 ohne offenen Befund, VAL-02 ohne Hash-Abweichung, VAL-05 12/12 Logins, CHK-01 ohne Befund, alle 15 Lauf-Datenbanken je Lauf wieder entfernt. Co-Authored-By: Claude Sonnet 5 --- .../iteration-2/lauf-2.1-cut017f8b14.json | 555 ++++++++++++++++++ .../iteration-2/lauf-2.1-cut017f8b14.txt | 88 +++ .../lauf-2.1-val01-zeilenzahl-abgleich.md | 16 + ...t017f8b14_ingenieurbuero_erfundenhausen.md | 11 + ...gleich-cut017f8b14_kanzlei_musterhausen.md | 14 + ...shvergleich-cut017f8b14_praxis_fantasia.md | 9 + ...h-cut017f8b14_steuerbuero_beispielstadt.md | 14 + .../iteration-2/lauf-2.2-cut01d7d2a2.json | 555 ++++++++++++++++++ .../iteration-2/lauf-2.2-cut01d7d2a2.txt | 88 +++ .../lauf-2.2-val01-zeilenzahl-abgleich.md | 16 + ...t01d7d2a2_ingenieurbuero_erfundenhausen.md | 11 + ...gleich-cut01d7d2a2_kanzlei_musterhausen.md | 14 + ...shvergleich-cut01d7d2a2_praxis_fantasia.md | 9 + ...h-cut01d7d2a2_steuerbuero_beispielstadt.md | 14 + 14 files changed, 1414 insertions(+) create mode 100644 docs/cut-01/iteration-2/lauf-2.1-cut017f8b14.json create mode 100644 docs/cut-01/iteration-2/lauf-2.1-cut017f8b14.txt create mode 100644 docs/cut-01/iteration-2/lauf-2.1-val01-zeilenzahl-abgleich.md create mode 100644 docs/cut-01/iteration-2/lauf-2.1-val02-hashvergleich-cut017f8b14_ingenieurbuero_erfundenhausen.md create mode 100644 docs/cut-01/iteration-2/lauf-2.1-val02-hashvergleich-cut017f8b14_kanzlei_musterhausen.md create mode 100644 docs/cut-01/iteration-2/lauf-2.1-val02-hashvergleich-cut017f8b14_praxis_fantasia.md create mode 100644 docs/cut-01/iteration-2/lauf-2.1-val02-hashvergleich-cut017f8b14_steuerbuero_beispielstadt.md create mode 100644 docs/cut-01/iteration-2/lauf-2.2-cut01d7d2a2.json create mode 100644 docs/cut-01/iteration-2/lauf-2.2-cut01d7d2a2.txt create mode 100644 docs/cut-01/iteration-2/lauf-2.2-val01-zeilenzahl-abgleich.md create mode 100644 docs/cut-01/iteration-2/lauf-2.2-val02-hashvergleich-cut01d7d2a2_ingenieurbuero_erfundenhausen.md create mode 100644 docs/cut-01/iteration-2/lauf-2.2-val02-hashvergleich-cut01d7d2a2_kanzlei_musterhausen.md create mode 100644 docs/cut-01/iteration-2/lauf-2.2-val02-hashvergleich-cut01d7d2a2_praxis_fantasia.md create mode 100644 docs/cut-01/iteration-2/lauf-2.2-val02-hashvergleich-cut01d7d2a2_steuerbuero_beispielstadt.md 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. -- 2.47.3 From f1bf748fa8451d24a4edf92d309f42a4f6dbf764 Mon Sep 17 00:00:00 2001 From: patrick Date: Wed, 14 Oct 2026 20:57:45 +0000 Subject: [PATCH 4/5] CUT-01: trockenlauf-1-entwicklung - pruefprotokoll Pruefprotokoll mit Ablaufplan und Abhaengigkeitsbegruendung, Iterationsprotokoll (Laeufe 1.1-1.5 mit Korrekturen I-1 bis I-4, Bestaetigungslaeufe 2.1/2.2), Zielwert/Ergebnis je Pruefung, Anonymisierungsnachweis fuer Akzeptanzkriterium 3 und offenen Befunden ohne Abbruch zur Uebergabe an CUT-02 und die Modul-Boards. Co-Authored-By: Claude Sonnet 5 --- docs/CUT-01-PRUEFPROTOKOLL.md | 342 ++++++++++++++++++++++++++++++++++ 1 file changed, 342 insertions(+) create mode 100644 docs/CUT-01-PRUEFPROTOKOLL.md diff --git a/docs/CUT-01-PRUEFPROTOKOLL.md b/docs/CUT-01-PRUEFPROTOKOLL.md new file mode 100644 index 00000000..79d5db1e --- /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-03 | Objektrechte auf migrierte Dokumente/Gruppen | +| 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. -- 2.47.3 From 7f37126049fce22859567fd920595373be2c81c1 Mon Sep 17 00:00:00 2001 From: patrick Date: Wed, 14 Oct 2026 23:15:51 +0200 Subject: [PATCH 5/5] CUT-01: Dokubegruendung DOK-03-Abhaengigkeit korrigieren (COR-02 statt COR-03) Co-Authored-By: Claude Sonnet 5 --- docs/CUT-01-PRUEFPROTOKOLL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/CUT-01-PRUEFPROTOKOLL.md b/docs/CUT-01-PRUEFPROTOKOLL.md index 79d5db1e..b7ddeb29 100644 --- a/docs/CUT-01-PRUEFPROTOKOLL.md +++ b/docs/CUT-01-PRUEFPROTOKOLL.md @@ -71,7 +71,7 @@ Abhängigkeit muss im Plan vorher stehen, sonst läuft kein einziger Schritt. | 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-03 | Objektrechte auf migrierte Dokumente/Gruppen | +| 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 | -- 2.47.3