package kek import ( "context" "errors" "fmt" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" "gitea.perlbach24.de/scripte/nexarch/internal/audit" ) var ErrNoTenantKEK = errors.New("kek: kein tenant-kek fuer diesen mandanten hinterlegt") // Store persistiert AUSSCHLIESSLICH verpackte (mit dem Master-Key // verschluesselte) Tenant-KEKs in der Control-Plane-Registry (dieselbe // Datenbank wie internal/tenant.Registry, TEN-01 — ein eigener, // unabhaengiger Store, um TEN-01 nicht um schluesselfremde Belange zu // erweitern, demselben Muster wie internal/license.Store). type Store struct { pool *pgxpool.Pool audit *audit.Log } func NewStore(pool *pgxpool.Pool) *Store { return &Store{pool: pool} } // WithAudit liefert einen Store, der jede Schluessel-Rotation zusaetzlich im // zentralen, unveraenderlichen Audit-Log protokolliert (AUD-06) — rein // additiv, log == nil bleibt gueltig (z.B. bestehende Tests). func (s *Store) WithAudit(log *audit.Log) *Store { return &Store{pool: s.pool, audit: log} } // CreateForTenant erzeugt einen NEUEN, zufaelligen Tenant-KEK und speichert // ihn mit dem Master-Key verpackt (Akzeptanzkriterium 2: JEDER Tenant // erhaelt einen EIGENEN Schluessel, niemals ein gemeinsamer). Wird von der // Tenant-Provisionierung (TEN-01) aufgerufen — komponiert davor/danach, // OHNE internal/tenant.Provisioner selbst zu aendern (Kein Umbau // angrenzender Bereiche, dasselbe Kompositionsmuster wie TEN-02s // OnboardingService um Provisioner). func (s *Store) CreateForTenant(ctx context.Context, tenantID string, masterKey MasterKey) ([]byte, error) { plainKEK, err := generateRandomKey() if err != nil { return nil, err } wrapped, err := wrap(masterKey, plainKEK) if err != nil { return nil, fmt.Errorf("tenant-kek verpacken: %w", err) } if _, err := s.pool.Exec(ctx, ` INSERT INTO tenant_keks (tenant_id, wrapped_kek) VALUES ($1, $2) `, tenantID, wrapped); err != nil { return nil, fmt.Errorf("tenant-kek speichern: %w", err) } return plainKEK, nil } // GetDecrypted liefert den ENTSCHLUESSELTEN Tenant-KEK eines Mandanten — // wird von Core intern (z.B. fuer den HTTP-Handler in handler.go) sowie in // Tests verwendet. func (s *Store) GetDecrypted(ctx context.Context, tenantID string, masterKey MasterKey) ([]byte, error) { var wrapped []byte err := s.pool.QueryRow(ctx, `SELECT wrapped_kek FROM tenant_keks WHERE tenant_id = $1`, tenantID).Scan(&wrapped) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, ErrNoTenantKEK } return nil, fmt.Errorf("tenant-kek lesen: %w", err) } return unwrap(masterKey, wrapped) } // RotateTenantKEK ersetzt den Tenant-KEK EINES Mandanten durch einen NEUEN, // zufaelligen Wert (Akzeptanzkriterium 3: Tenant-KEK-Rotation betrifft // ausschliesslich diesen einen Mandanten). Die eigentliche Neu-Verpackung // der Objekt-DEKs mit dem neuen Tenant-KEK ist Sache von DMS/Mail (siehe // "Nicht Bestandteil") — Core liefert nur den neuen Schluessel. func (s *Store) RotateTenantKEK(ctx context.Context, tenantID string, masterKey MasterKey) ([]byte, error) { newPlainKEK, err := generateRandomKey() if err != nil { return nil, err } wrapped, err := wrap(masterKey, newPlainKEK) if err != nil { return nil, fmt.Errorf("neuen tenant-kek verpacken: %w", err) } tag, err := s.pool.Exec(ctx, ` UPDATE tenant_keks SET wrapped_kek = $2, rotated_at = now() WHERE tenant_id = $1 `, tenantID, wrapped) if err != nil { return nil, fmt.Errorf("tenant-kek rotieren: %w", err) } if tag.RowsAffected() == 0 { return nil, ErrNoTenantKEK } if s.audit != nil { _ = s.audit.Record(ctx, audit.Event{ TenantSlug: audit.SystemTenant, Actor: "system", Action: "kek.tenant_rotated", Target: tenantID, }) } return newPlainKEK, nil } // RotateMasterKey verpackt die Tenant-KEKs ALLER Mandanten von oldKey auf // newKey um — der PLAINTEXT jedes Tenant-KEK bleibt dabei UNVERAENDERT // (Akzeptanzkriterium 3: Master-Key-Rotation erfordert keine // Neuverschluesselung der Objektdaten, weil die Tenant-KEKs selbst gleich // bleiben, nur ihre Verpackung wechselt). Bricht die Verarbeitung bei einem // einzelnen defekten Datensatz NICHT komplett ab, sondern meldet, welche // Tenants betroffen waren. func (s *Store) RotateMasterKey(ctx context.Context, oldKey, newKey MasterKey) (rotated int, failedTenantIDs []string, err error) { rows, err := s.pool.Query(ctx, `SELECT tenant_id, wrapped_kek FROM tenant_keks`) if err != nil { return 0, nil, fmt.Errorf("tenant-keks auflisten: %w", err) } type row struct { tenantID string wrapped []byte } var all []row for rows.Next() { var r row if err := rows.Scan(&r.tenantID, &r.wrapped); err != nil { rows.Close() return 0, nil, fmt.Errorf("tenant-kek-zeile lesen: %w", err) } all = append(all, r) } rows.Close() if err := rows.Err(); err != nil { return 0, nil, err } for _, r := range all { plainKEK, err := unwrap(oldKey, r.wrapped) if err != nil { failedTenantIDs = append(failedTenantIDs, r.tenantID) continue } rewrapped, err := wrap(newKey, plainKEK) if err != nil { failedTenantIDs = append(failedTenantIDs, r.tenantID) continue } if _, err := s.pool.Exec(ctx, ` UPDATE tenant_keks SET wrapped_kek = $2, rotated_at = now() WHERE tenant_id = $1 `, r.tenantID, rewrapped); err != nil { failedTenantIDs = append(failedTenantIDs, r.tenantID) continue } rotated++ } if s.audit != nil { _ = s.audit.Record(ctx, audit.Event{ TenantSlug: audit.SystemTenant, Actor: "system", Action: "kek.master_rotated", Target: audit.SystemTenant, Metadata: map[string]any{"rotated": rotated, "failed": failedTenantIDs}, }) } return rotated, failedTenantIDs, nil }