Compare commits

...
Author SHA1 Message Date
sysopsandClaude Sonnet 5 9f641fec76 QA-04: sicherheits-penetrationspruefung-core
Voller Merge von RBAC-02/IAM-06/IAM-07/API-03/API-10/API-08/API-09/IAM-10/
IAM-11/IAM-13 plus echte Angriffstests (internal/pentest) gegen SSO/OIDC
(alg=none, Fremdschluessel, Claims-Manipulation, Nonce-Replay), Rate-
Limiting/Lockout im simulierten Mehrinstanz-Betrieb, zentrale Policy-
Durchsetzung (Rechteausweitung, struktureller Guard-Bypass) und Master-
Key-/Tenant-KEK-Rotation. 29/29 Pakete gruen auf 192.168.1.131.

Vier real gefundene Testinfrastruktur-Fehler behoben: reset-test-env.sh
liess tenant_keks (und weitere neuere Registry-Tabellen) beim Reset stehen
(FK-CASCADE loescht nur die Constraint, keine Zeilen); zwei E2E-Tests und
kek_test.go schlossen ihren adminPool per defer VOR ihrer t.Cleanup-
Bereinigung (t.Cleanup laeuft immer nach allen defers); migrate_test.go
hatte ein Testschema ohne die TEN-04-Lifecycle-Spalten. Alle vier Fixes
betreffen ausschliesslich Testcode, kein Produktionscode geaendert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HhgFcLS8tYMhDJpP74C6AQ
2026-08-29 09:57:36 +02:00
sysops 5e6cb4e2db Merge branch 'feature/api-09-mtls-zwischen-modul-instanzen-verteilte-installation' into feature/qa-04-sicherheits-penetrationspruefung-core 2026-08-29 09:43:42 +02:00
sysops b8c3f1b989 Merge branch 'feature/api-08-security-header-baseline-fuer-alle-frontends' into feature/qa-04-sicherheits-penetrationspruefung-core 2026-08-29 09:43:42 +02:00
sysops ca547bcd54 Merge branch 'feature/api-10-master-key-verwaltung-tenant-schluesselhierarchie-kms-anbindung' into feature/qa-04-sicherheits-penetrationspruefung-core
# Conflicts:
#	go.mod
#	go.sum
#	scripts/reset-test-env.sh
2026-08-29 09:43:34 +02:00
sysops cbbf5a348a Merge branch 'feature/api-03-zentrales-rate-limiting-api-gateway-schicht' into feature/qa-04-sicherheits-penetrationspruefung-core
# Conflicts:
#	scripts/reset-test-env.sh
#	scripts/run-checks.sh
2026-08-29 09:43:05 +02:00
sysops 550fc70d50 Merge branch 'feature/rbac-02-policy-enforcement-schicht-zentral' into feature/qa-04-sicherheits-penetrationspruefung-core
# Conflicts:
#	scripts/reset-test-env.sh
2026-08-29 09:42:39 +02:00
sysops 46fccd9c09 API-10: test-fix — rotatemasterkey-assertion nur fuer eigene test-tenants pruefen (geteilte test-db) 2026-08-28 10:04:51 +02:00
sysops a631ac8770 API-10: internal/apiserver-port wieder entfernen (ungenutzt, zieht internal/auth als fehlende abhaengigkeit nach) 2026-08-28 10:03:41 +02:00
sysops bc2126f3b1 API-10: master-key-verwaltung-tenant-schluesselhierarchie-kms-anbindung (envelope encryption, isolierte tenant-keks, rotation) 2026-08-28 10:03:27 +02:00
sysops 3fcd8f92af API-09: mtls-zwischen-modul-instanzen-verteilte-installation (interne ca, rotation ohne ausfallzeit, stufe-1-optout) 2026-08-28 09:32:51 +02:00
sysops 43dca005f2 API-08: SetHeaders-helfer fuer redirect-freie umgebungen + devserver fuer csp-live-verifikation 2026-08-28 09:16:20 +02:00
sysops 24a522e10c API-08: security-header-baseline-fuer-alle-frontends (gemeinsame middleware, csp/hsts/coverage-scan) 2026-08-28 09:11:33 +02:00
sysops a67adcefa1 API-03: zentrales-rate-limiting-api-gateway-schicht (postgres-basierter shared state) 2026-08-28 08:14:20 +02:00
sysopsandClaude Sonnet 5 d63fcbb49e RBAC-02: policy-enforcement-schicht-zentral
internal/policy: deklarativer, DB-gehaltener Regelsatz (policy_rules) statt
hartcodierter Go-Entscheidungslogik — Store.IsAllowed schaut ausschliesslich
in die Datenbank, kein Go-Fallback. Ein Regelwechsel (Grant/Revoke) wirkt
sich sofort aus, ohne Codeaenderung/Deploy (Akzeptanzkriterium 3). Jede
Aenderung wird atomar mit einem versionierten Historieneintrag in
policy_rule_changes festgehalten (grant/revoke, Akteur, Version).

Enforcer.Authorize ist Default-Deny: existiert keine Regel fuer role+
permission, ist der Zugriff verboten (Akzeptanzkriterium 2), fuer sich
genommen ohne Anwendungslogik testbar.

Guard/GuardTenantScoped sind die zentrale Enforcement-Funktion
(Akzeptanzkriterium 1): die uebergebene Query-Funktion wird NUR bei
erfolgreicher Autorisierung aufgerufen — es gibt keinen Weg, Daten ohne
vorherige Authorize-Entscheidung zu erhalten. GuardTenantScoped erzwingt
zusaetzlich per Funktionssignatur, dass tenantSlug TEIL der Query-Funktion
ist (Akzeptanzkriterium 3) — ein nachgelagerter Post-Filter (der
archivmail-Fehler aus "Bekannte Fehler vermeiden": Tenant-Filter nach statt
in der Query) ist mit dieser Signatur strukturell nicht moeglich, da die
Repository-Implementierung tenantSlug selbst fuer ihre eigene WHERE-Klausel
entgegennimmt.

Pruefungen (ausgefuehrt auf root@192.168.1.131, go build/vet/test PASS):
1. Kein Datenzugriffs-Pfad umgeht die zentrale Enforcement-Schicht —
   TestGuard_NeverCallsQueryWithoutAuthorization: query-Funktion wird
   nachweislich NICHT aufgerufen ohne vorherige Regel, erst nach Grant. PASS.
2. Anfrage ohne passende Policy wird zuverlaessig abgewiesen (Default-Deny) —
   TestAuthorize_DefaultDeny: keine Regel konfiguriert -> ErrDenied, nicht
   automatisch erlaubt. PASS.
3. Policy-Regelsatz versioniert, Regelwechsel ohne Codeaenderung
   nachvollziehbar — TestGrantRevoke_ChangesBehaviorWithoutCodeChange:
   Verhalten aendert sich durch reinen Datenbank-Grant/Revoke, Historie
   zeigt beide Versionen korrekt. PASS.

Zusaetzlich: TestGuardTenantScoped_IsolatesDataBetweenTenants belegt das
Tenant-Scoping-Muster aus Akzeptanzkriterium 3 konkret anhand zweier
Tenants. PASS.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 21:43:26 +02:00
sysopsandClaude Sonnet 5 27f9866264 API-01: rest-api-grundgeruest-versionierung
internal/apiserver: Server.Handle(version, pattern, h) registriert Routen
unter /api/{version}/... (Akzeptanzkriterium 1) — verschiedene Versionen
sind unabhaengige Pfade im ServeMux, eine neue Version beeintraechtigt
bestehende nicht. HandleV1 ist die Kurzform fuer die aktuelle Hauptversion.

Einheitliches Fehlerschema {"error":{"code","message"}} ueber WriteError
(Akzeptanzkriterium 2) — bewusst NICHT auth.RequireAuth aus IAM-02
wiederverwendet, da dessen Klartext-Fehlerantworten nicht zum einheitlichen
JSON-Schema passen wuerden; stattdessen authAndTenantContext nutzt
auth.TokenIssuer.Verify direkt (dieselbe Kryptographie, keine Duplikation)
und antwortet im API-01-Schema, auch bei 401.

authAndTenantContext ist die Middleware, die JEDEM ueber Handle registrierten
Endpunkt Tenant-/Benutzerkontext bereitstellt (Akzeptanzkriterium 3, ueber
apiserver.FromContext abrufbar) — kein Handler prueft Auth selbst.
loggingMiddleware protokolliert jede Anfrage strukturiert.

Pruefungen (ausgefuehrt auf root@192.168.1.131, go build/vet/test PASS):
1. Fehlerhafte Anfrage liefert ueber mehrere Endpunkte hinweg dasselbe
   Fehlerschema (Stichprobe) — TestErrorFormat_ConsistentAcrossEndpoints:
   zwei unabhaengige Endpunkte, beide liefern 401 im identischen
   {"error":{"code","message"}}-Schema. PASS.
2. Middleware-Kette nachweislich von jedem Endpunkt durchlaufen —
   TestMiddleware_SetsRequestContextForEveryEndpoint: zwei Endpunkte lesen
   RequestContext, beide erhalten korrekten UserID/TenantSlug aus dem Token. PASS.
3. Versionswechsel (fiktive v2-Route) ohne v1 zu beeintraechtigen —
   TestVersioning_V2DoesNotAffectV1: v1 vor und nach Anlage von v2 liefert
   unveraendert dieselbe Antwort. PASS.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 21:32:27 +02:00
sysopsandClaude Sonnet 5 b23cd1961f API-02: modul-registry-aktivierungspruefung
internal/moduleregistry: Registry.Register traegt Fachmodule mit Name,
Version und benoetigten Feature-Flags ein (Akzeptanzkriterium 1), fehlende
Pflichtangaben werden abgewiesen. IsActive kombiniert Registrierung + LIC-02
Feature-Flag-Auswertung (ALLE benoetigten Flags muessen fuer den Tenant
aktiv sein) — ein nicht registriertes Modul ist nie aktiv. List liefert alle
Module fuer Statusseite/Lizenzoberflaeche (Akzeptanzkriterium 3).

RequireActiveModule ist die zentrale Durchsetzungs-Middleware (Casbin-
Prinzip): weist Anfragen an ein deaktiviertes Modul ab, BEVOR der
Modul-Handler ueberhaupt aufgerufen wird (Akzeptanzkriterium 2) —
Pruefung per Test belegt, dass der Handler bei Deaktivierung nachweislich
nicht erreicht wird.

Service-Credentials (Akzeptanzkriterium 4): Registry.Provision stellt pro
Modul-Instanz Client-ID + Secret aus, gespeichert wird nur der SHA-256-Hash
des Secrets. Registry.Authenticate vergleicht timing-safe (dieselbe
subtle.ConstantTimeCompare-Referenzimplementierung wie AUD-02).
RequireServiceCredential-Middleware liest X-Client-Id/X-Client-Secret und
weist Aufrufe ohne gueltiges Credential mit 401 ab, bevor der Core-seitige
Endpunkt (z.B. Audit-Nachlieferung, Nutzungsmeldung) erreicht wird.

Pruefungen (ausgefuehrt auf root@192.168.1.131, go build/vet/test PASS):
1. Anfrage an deaktiviertes Modul nachweislich vor Modul-Logik abgewiesen —
   TestRequireActiveModule_BlocksBeforeHandler: handlerReached bleibt false
   bei 403, wird true erst nach Aktivierung bei 200. PASS.
2. Registrierung mit fehlenden Pflichtangaben abgewiesen —
   TestRegister_RejectsMissingFields (leerer Name, leere Version). PASS.
3. Registry-Abfrage liefert konsistente Daten nach Aktivierung/Deaktivierung —
   TestIsActive_ReflectsFlagStateConsistently: aus/an/aus-Zyklus, IsActive
   folgt dem Flag-Zustand korrekt. PASS.
4. Aufruf mit ungueltigem/fehlendem Service-Credential abgewiesen, mit
   gueltigem angenommen — TestRequireServiceCredential_RejectsInvalidAcceptsValid
   und TestProvisionAndAuthenticate (falsches Secret, unbekannte Client-ID,
   korrektes Credential). PASS.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 21:17:50 +02:00
sysopsandClaude Sonnet 5 ed67887385 RBAC-01: rollenmodell-grundrechte
internal/rbac: drei Grundrollen (superadmin, tenant_admin, user) mit
Hierarchie ueber eine einfache Eltern-Map (tenant_admin erbt von user,
superadmin erbt von tenant_admin) — EffectivePermissions loest die volle
vererbte Rechtemenge auf (Akzeptanzkriterium 2). Policy-Modell bewusst als
reine Go-Datenstruktur getrennt von der Durchsetzung (RBAC-02), nach
Casbin-Prinzip.

Store verwaltet Rollenzuweisungen innerhalb EINER Tenant-Datenbank (Modell C,
analog internal/user.TenantUserStore) — nur 'user' und 'tenant_admin' sind
hier zuweisbar (assignableRoles-Matrix). Ein Zuweisungsversuch fuer
'superadmin' wird abgewiesen, da diese Rolle mandantenuebergreifend ist und
bereits durch die Existenz eines Kontos in IAM-01s SuperadminStore
repraesentiert wird — keine doppelte Modellierung. Jede Zuweisung schreibt
zusaetzlich einen Historieneintrag (role_assignment_history) mit
grantedBy/grantedAt, atomar in derselben Transaktion (Akzeptanzkriterium 3).

Pruefungen (ausgefuehrt auf root@192.168.1.131, go build/vet/test PASS):
1. Zuweisung ausserhalb der erlaubten Matrix abgewiesen —
   TestStore_RejectsSuperadminOutsideAllowedMatrix und
   TestStore_RejectsUnknownRole: beide ErrRoleNotAssignableInTenantScope,
   kein Datensatz hinterlassen. PASS.
2. Rollenhierarchie liefert erwartete effektive Rechtemenge —
   TestEffectivePermissions_Inheritance: tenant_admin hat geerbte
   user-Rechte + eigene, aber nicht platform.manage_tenants; superadmin hat
   die volle Kette. PASS.
3. Datenmodell von zweiter Person gegen Dokumentation geprueft — NICHT
   durchgefuehrt (keine zweite Person in dieser Session verfuegbar). Offen.

Zusaetzlich automatisiert getestet (Akzeptanzkriterium 3):
TestStore_HistoryTracksWhoAndWhen — zwei aufeinanderfolgende Zuweisungen,
Historie liefert beide mit korrektem grantedBy in chronologischer
Reihenfolge. PASS.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 19:45:16 +02:00
sysopsandClaude Sonnet 5 4a30345e07 LIC-02: feature-flag-service-je-tenant
internal/flag: Store (Verwaltung) + Service (Auswertung mit TTL-Cache,
Default 5s) — Unleash-Prinzip Flag-Verwaltung vs. Flag-Auswertung getrennt,
als Kernfunktion des Core-Dienstes selbst statt separater Infrastruktur.

evaluate() wendet drei Strategien in fester Reihenfolge an: global an/aus,
Tenant-Zielgruppe, deterministischer Prozentsatz-Rollout (FNV-Hash aus
Tenant+Key, stabil pro Tenant). IsEnabled liefert IMMER nur bool (kein
Fehlerwert) — ein nicht erreichbarer Flag-Dienst kann damit keinen
Aufrufer zum Absturz bringen: bei DB-Fehler wird der zuletzt bekannte
Cache-Stand verwendet, ohne jeglichen Stand faellt der Dienst sicher auf
false zurueck. Service.Invalidate erzwingt sofortiges Neuladen fuer den
Schreiber selbst, andere Instanzen sehen Aenderungen spaetestens nach der
TTL (Akzeptanzkriterium 3, kein Neustart noetig).

Bugfix waehrend Tests: Store.Set uebergab ein nil-TargetTenantSlugs-Slice
als SQL NULL statt leerem Array (NOT-NULL-Verletzung) — auf leeres Slice
normalisiert.

Akzeptanzkriterium 4 (Deaktivierung loescht keine Daten): dieses Paket
besitzt ausschliesslich die eigene feature_flags-Zeile, hat keinerlei
Code-Pfad, der Modul-Geschaeftsdaten anfassen koennte — Loeschung bleibt
strukturell der Archive-Retention-Engine vorbehalten.

Pruefungen (ausgefuehrt auf root@192.168.1.131, go build/vet/test PASS):
1. Cache-Invalidierungszeit automatisiert gemessen —
   TestService_CacheInvalidationTiming: Aenderung wirksam nach 153ms bei
   TTL=150ms (innerhalb Ziel+Toleranz), vorher nachweislich noch alter Stand. PASS.
2. Zielgruppen-Strategie liefert erwartete Auswertung —
   TestService_TargetTenantStrategy / TestEvaluate_TargetTenantStrategy. PASS.
3. Ausfall des Flag-Dienstes fuehrt zu dokumentiertem Fallback, kein Absturz —
   TestService_FallsBackOnStoreFailure (mit recover()-Absicherung): Fallback
   auf Cache-Stand bzw. sicheres false bei komplett unerreichbarer DB, geloggt. PASS.
4. Modul-Deaktivierung/Reaktivierung ohne Datenverlust — architektonisch durch
   fehlenden Code-Pfad sichergestellt (siehe oben), zusaetzlich durch
   TestService_InvalidateForcesImmediateRefresh (Toggle aus/an bleibt
   konsistent nachvollziehbar) mitabgedeckt. PASS.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 19:19:59 +02:00
48 changed files with 4719 additions and 3 deletions
+40
View File
@@ -0,0 +1,40 @@
// secheaders-devserver dient der manuellen/e2e-Verifikation von API-08
// (Pruefung 2: CSP blockiert Inline-Script) mit einem echten Browser.
// Zwei Pfade: /protected (mit Middleware) und /unprotected (ohne) — beide
// liefern dieselbe Seite mit einem Inline-Script, das ein DOM-Element
// aendert. Ein echter Browser zeigt den Unterschied.
package main
import (
"log"
"net/http"
"gitea.perlbach24.de/scripte/nexarch/internal/secheaders"
)
const page = `<!doctype html>
<html><body>
<div id="marker">BLOCKED</div>
<script>document.getElementById('marker').textContent = 'INLINE_RAN';</script>
</body></html>`
func servePage(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte(page))
}
func main() {
cfg := secheaders.DefaultConfig()
mux := http.NewServeMux()
// SetHeaders statt Middleware: dieser lokale Demo-Server laeuft ohne
// TLS-Terminierung, der HTTPS-Redirect-Teil von Middleware wuerde hier
// in eine Endlosschleife fuehren (siehe secheaders.go-Dokumentation).
mux.HandleFunc("/protected", func(w http.ResponseWriter, r *http.Request) {
secheaders.SetHeaders(cfg, w)
servePage(w, r)
})
mux.HandleFunc("/unprotected", servePage)
log.Println("secheaders-devserver listening on :8086")
log.Fatal(http.ListenAndServe(":8086", mux))
}
+149
View File
@@ -0,0 +1,149 @@
# QA-04 Prüfprotokoll: Sicherheits- & Penetrationsprüfung Core
Welle 6. Voraussetzung: RBAC-02, IAM-06, IAM-07, API-03, API-10, API-08,
API-09, IAM-10, IAM-11, IAM-13 (alle Status "Fertig"). Branch:
`feature/qa-04-sicherheits-penetrationspruefung-core`, alle 10 Vorbedingungen
real gemergt (kein isolierter Cherry-Pick).
## 1. Gezielte Angriffsversuche und Ergebnis
Alle Tests in `internal/pentest/qa04_pentest_test.go`, echte Angriffe gegen
die tatsächlichen Produktionspakete, nicht gegen Mocks.
### 1.1 SSO/OIDC gegen Token-Manipulation, Replay, Claims-Manipulation (Akzeptanzkriterium 1)
| Angriff | Test | Zielwert | Ergebnis |
|---|---|---|---|
| `alg=none`-Angriff gegen externen OIDC-Verifier (IAM-06) | `TestPentest_OIDC_AlgNoneAttack` | abgewiesen | **bestanden** |
| Signatur mit fremdem (Angreifer-)Schlüssel unter bekannter `kid` | `TestPentest_OIDC_ForeignKeySignature` | abgewiesen | **bestanden** |
| Claims nachträglich manipuliert (Rechteausweitung `roles`), alte Signatur wiederverwendet | `TestPentest_OIDC_TamperedClaims` | abgewiesen | **bestanden** |
| Replay eines gültigen ID-Tokens mit abweichendem Nonce | `TestPentest_OIDC_NonceReplay` | abgewiesen | **bestanden** |
| Core als eigener IdP (IAM-13): ID-Token mit Angreiferschlüssel unter vorgetäuschter `kid` | `TestPentest_OIDC_IdPTokenManipulation` | abgewiesen | **bestanden** |
Ergebnis: `internal/oidc.Verifier` erzwingt Algorithmus (`RS256` bzw.
`EdDSA`), prüft Signatur ausschließlich gegen den unter der `kid` tatsächlich
registrierten Schlüssel, prüft Aussteller und Nonce. Kein Umgehungsweg
gefunden.
### 1.2 Rate-Limiting/Account-Lockout im Mehrinstanz-Betrieb (Akzeptanzkriterium 2, Prüfung 1)
| Angriff | Test | Zielwert | Ergebnis |
|---|---|---|---|
| Rate-Limit über 4 unabhängige "Instanzen" (eigene DB-Pools) parallel umgehen | `TestPentest_RateLimit_MultiInstanceBypassAttempt` | Gesamtsumme erlaubter Anfragen = konfiguriertes Limit, nicht mehr | **bestanden** 200 Anfragen über 4 Instanzen, exakt 100 (=Limit) erlaubt |
| Account-Lockout über 3 unabhängige Instanzen verteilte Brute-Force-Versuche umgehen | `TestPentest_Lockout_MultiInstanceBypassAttempt` | Konto wird trotz Verteilung gesperrt | **bestanden** 12 Versuche über 3 Instanzen, Sperre nach Erreichen von `maxFailed=5` |
Ergebnis: beide Mechanismen nutzen ein atomares SQL-`UPSERT` gegen
geteilten, externen Zustand (Postgres) statt In-Process-Zähler exakt der
in archivdms als Fehlerklasse dokumentierte Schwachpunkt wird hier vermieden.
### 1.3 Zentrale Policy-Durchsetzung, gezielter Umgehungsversuch je Modul (Akzeptanzkriterium 3, Prüfung 3)
| Angriff | Test | Zielwert | Ergebnis |
|---|---|---|---|
| Rechteausweitung: Rolle `user`/`tenant_admin` versucht `platform.manage_tenants` (Identität/Mandanten-Modul) | `TestPentest_Policy_PrivilegeEscalationViaWrongRole` | `policy.ErrDenied` | **bestanden** |
| Strukturelle Umgehung von `policy.Guard` (Durchsetzungsschicht selbst): Query darf bei verweigerter Autorisierung nie ausgeführt werden | `TestPentest_Policy_GuardBypassStructurallyImpossible` | Query-Zähler bleibt bei 0 trotz 10 Versuchen | **bestanden** |
Ergebnis: Default-Deny greift zuverlässig (keine Regel = kein Zugriff);
`policy.Guard`/`GuardTenantScoped` lassen strukturell keinen Weg zu, die
Query-Funktion ohne vorherige Autorisierung aufzurufen.
### 1.4 Master-Key- und Tenant-KEK-Verwaltung gegen Kompromittierung/Rotation (Akzeptanzkriterium 4)
| Angriff | Test | Zielwert | Ergebnis |
|---|---|---|---|
| Kompromittierter ALTER Master-Key nach Rotation weiterverwenden | `TestPentest_KEK_CompromisedMasterKeyCannotDecryptAfterRotation` | alter Key entschlüsselt nicht mehr, Plaintext-KEK unverändert | **bestanden** |
| Tenant-KEK-Rotation eines Mandanten wirkt sich auf einen anderen Mandanten aus | `TestPentest_KEK_TenantKEKRotationDoesNotAffectOtherTenants` | Mandant B unberührt | **bestanden** |
| Manipulierter, gespeicherter `wrapped_kek` (DB-Zugriff ohne Master-Key) wird beim Entschlüsseln akzeptiert | `TestPentest_KEK_TamperedWrappedKeyRejected` | `ErrUnwrapFailed` | **bestanden** |
Ergebnis: AES-256-GCM (authentifizierend) verhindert Akzeptanz manipulierter
Chiffretexte; Master-Key-Rotation ändert ausschließlich die Verpackung, nie
den Plaintext-Tenant-KEK; Tenant-KEK-Isolation (Modell C, Fortsetzung auf
Schlüsselebene) hält auch unter gezielter Rotation eines einzelnen Mandanten.
## 2. Beim Merge/Pentest gefundene Fehler (nicht vorab bekannt)
### 2.1 `scripts/reset-test-env.sh`: fehlende Bereinigung von `tenant_keks` (Schweregrad **Hoch**, behoben)
Beim ersten vollen Testlauf über alle 29 Pakete schlug
`TestPentest_KEK_CompromisedMasterKeyCannotDecryptAfterRotation` mit 48
unerwarteten `failedTenantIDs` in `RotateMasterKey` fehl. Ursache:
`DROP TABLE tenants CASCADE` entfernt bei einer referenzierenden Tabelle
(`tenant_keks.tenant_id REFERENCES tenants(id)`) nur die FK-Constraint,
NICHT deren Zeilen. Da `reset-test-env.sh` `tenant_keks` (API-10-Migration
`0006_tenant_keks.up.sql`) nie explizit droppte, überlebten verwaiste
`tenant_keks`-Zeilen aus früheren `internal/kek`-Testläufen jeden Reset und
verfälschten `RotateMasterKey`s ungefilterte `SELECT * FROM tenant_keks`.
Behoben: `reset-test-env.sh` droppt jetzt explizit alle Registry-Tabellen aus
`migrations/*.up.sql` (`tenant_keks`, `rate_limit_counters`,
`rate_limit_configs`, `policy_rule_changes`, `policy_rules`,
`feature_flags`, `module_credentials`, `modules`), nicht nur die vier
ursprünglich behandelten. Reiner Testinfrastruktur-Fix, keine
Produktionsmigration verändert.
### 2.2 `internal/e2e`-Tests: `defer adminPool.Close()` lief vor `t.Cleanup`-Bereinigung (Schweregrad **Mittel**, behoben)
Voller Testlauf zeigte anschließend `internal/migrate`-Fehlschläge
(„erwartet 3 Ergebnisse, habe 15" bzw. 12, mit Zeilen wie `e2e_tenant_a`).
Ursache: `TestE2E_TenantOnboardingLoginFlow` und `TestE2E_CrossTenantIsolation`
schlossen den `adminPool` per `defer adminPool.Close()`, ihre
`t.Cleanup`-Funktion (welche die angelegten Testmandanten wieder löscht)
lief aber wie in Go üblich ERST NACH allen `defer`-Aufrufen der
Testfunktion, also gegen einen bereits geschlossenen Pool. Der Fehler wurde
durch `_, _ = adminPool.Exec(...)` verschluckt; die Zeilen blieben dauerhaft
in der geteilten `tenants`-Tabelle stehen und verfälschten
`internal/migrate`s ungefilterte `registry.List()`-Abfrage im selben
Prozess/derselben Datenbank.
Behoben: `adminPool.Close()` in beiden Tests über `t.Cleanup` statt `defer`
registriert (vor der Lösch-Cleanup, sodass LIFO die richtige Reihenfolge
erzwingt Pool bleibt bis nach dem Löschen offen).
### 2.3 `internal/kek/kek_test.go`: derselbe defer/`t.Cleanup`-Fehler, zusätzlich fehlende Zeilen-Bereinigung (Schweregrad **Mittel**, behoben)
Dieselbe Fehlerklasse wie 2.2, zusätzlich verschärft: `createTenant` legte
Zeilen mit `db_dsn='unused'` an, ohne JEMALS eine eigene Aufräumfunktion zu
registrieren `setupTest`s `cleanup := func(){ pool.Close() }` schloss nur
den Pool, löschte aber nie die angelegten `tenants`/`tenant_keks`-Zeilen.
Behoben: `createTenant` registriert jetzt eine eigene `t.Cleanup`, die
Zeile+abhängigen `tenant_keks`-Eintrag löscht; `setupTest` schließt den Pool
über `t.Cleanup` (nicht mehr über den zurückgegebenen `cleanup()`, den
Aufrufer per `defer` aufriefen) damit läuft die Lösch-Cleanup (später
registriert) vor dem Pool-Schließen (früher registriert).
### 2.4 `internal/migrate/migrate_test.go`: hartkodiertes Testschema ohne Lifecycle-Spalten (Schweregrad **Mittel**, behoben)
`setupOrchestratorTest` legte `tenants` über ein eigenes,
hartkodiertes `CREATE TABLE IF NOT EXISTS` ohne die Spalten
`previous_status`/`deletion_scheduled_at` an, die TEN-04
(`internal/tenant/registry.go`, `GetBySlug`/`List`) inzwischen mitliest.
Lief das Paket ISOLIERT (Tabelle wird von diesem Test selbst frisch
angelegt), fiel der Fehler nicht auf; erst im vollen Testlauf bei dem die
Tabelle bereits über die echten Registry-Migrationen existiert griff
`CREATE TABLE IF NOT EXISTS` nicht mehr und der Fehler
`column "previous_status" does not exist` trat zutage.
Behoben: fehlende Spalten im Testschema ergänzt (kein Produktionscode
betroffen).
## 3. Build/Test-Ergebnis auf 192.168.1.131 (`/root/nexarch-code-qa04`)
```
go mod tidy -> clean
go build ./... -> clean
go vet ./... -> clean
go test ./... -p 1 -count=1 -> 29/29 Pakete ok, 0 Fehlschläge
```
Enthält insbesondere `internal/pentest` (12 neue Angriffstests, alle
bestanden) sowie die bereits bestehenden 124 Tests aus QA-02/QA-03 und alle
Tests der 10 frisch gemergten Vorbedingungs-Branches.
## 4. Gesamtergebnis
**Bestanden.** Alle vier Akzeptanzkriterien durch gezielte, dokumentierte
Angriffsversuche mit Ergebnisprotokoll erfüllt; alle drei Pflichtprüfungen
durchgeführt und protokolliert (Abschnitte 1.21.4). Vier real gefundene
Fehler (Abschnitt 2, Schweregrad Hoch/Mittel) behoben ausschließlich
Testinfrastruktur, kein Produktionscode verändert.
+20
View File
@@ -0,0 +1,20 @@
package apiserver
import "context"
type contextKey int
const requestContextKey contextKey = iota
// RequestContext ist der Tenant-/Benutzerkontext, den die Middleware-Kette
// fuer nachgelagerte Handler bereitstellt (Akzeptanzkriterium 3).
type RequestContext struct {
UserID string
TenantSlug string
}
// FromContext liest den von der Middleware gesetzten Kontext.
func FromContext(ctx context.Context) (RequestContext, bool) {
rc, ok := ctx.Value(requestContextKey).(RequestContext)
return rc, ok
}
+31
View File
@@ -0,0 +1,31 @@
// Package apiserver implementiert Core API-01: das REST-Grundgerüst mit
// URL-Versionierung, einheitlichem Fehlerformat und Middleware-Kette
// (Auth, Tenant-/Benutzerkontext, Logging).
package apiserver
import (
"encoding/json"
"net/http"
)
// errorBody ist das EINE Fehlerschema fuer alle Endpunkte unter /api/{version}/
// (Akzeptanzkriterium 2).
type errorBody struct {
Error struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
// WriteError schreibt einen Fehler im einheitlichen Schema. code ist ein
// stabiler, maschinenlesbarer Bezeichner (z.B. "unauthenticated"), message
// ein fuer Menschen lesbarer deutscher Text.
func WriteError(w http.ResponseWriter, status int, code, message string) {
var body errorBody
body.Error.Code = code
body.Error.Message = message
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(body)
}
+56
View File
@@ -0,0 +1,56 @@
package apiserver
import (
"context"
"log/slog"
"net/http"
"time"
"gitea.perlbach24.de/scripte/nexarch/internal/auth"
)
// authAndTenantContext prueft die Session (wiederverwendet auth.TokenIssuer.Verify
// aus IAM-02 — keine zweite JWT-Implementierung) und setzt bei Erfolg
// RequestContext fuer nachgelagerte Handler (Akzeptanzkriterium 3). Anders
// als auth.RequireAuth (Klartext-Fehler) antwortet diese Middleware im
// einheitlichen API-01-Fehlerschema (Akzeptanzkriterium 2), damit ALLE
// Endpunkte unter /api/{version}/ dasselbe Format liefern, auch bei
// Auth-Fehlern.
func authAndTenantContext(issuer *auth.TokenIssuer, next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie(auth.CookieName)
if err != nil {
WriteError(w, http.StatusUnauthorized, "unauthenticated", "nicht angemeldet")
return
}
claims, err := issuer.Verify(cookie.Value)
if err != nil {
WriteError(w, http.StatusUnauthorized, "unauthenticated", "nicht angemeldet")
return
}
rc := RequestContext{UserID: claims.UserID, TenantSlug: claims.TenantSlug}
next(w, r.WithContext(context.WithValue(r.Context(), requestContextKey, rc)))
}
}
type statusRecorder struct {
http.ResponseWriter
status int
}
func (s *statusRecorder) WriteHeader(code int) {
s.status = code
s.ResponseWriter.WriteHeader(code)
}
// loggingMiddleware protokolliert jede Anfrage strukturiert.
func loggingMiddleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
start := time.Now()
next(rec, r)
slog.Info("api-anfrage", "method", r.Method, "path", r.URL.Path, "status", rec.status, "dauer", time.Since(start))
}
}
+37
View File
@@ -0,0 +1,37 @@
package apiserver
import (
"net/http"
"gitea.perlbach24.de/scripte/nexarch/internal/auth"
)
// Server registriert versionierte API-Routen (Akzeptanzkriterium 1: unter
// /api/{version}/...) und verdrahtet fuer jede Route dieselbe Middleware-
// Kette (Logging -> Auth+Tenantkontext -> Handler).
type Server struct {
mux *http.ServeMux
issuer *auth.TokenIssuer
}
func NewServer(issuer *auth.TokenIssuer) *Server {
return &Server{mux: http.NewServeMux(), issuer: issuer}
}
// Handle registriert pattern unter der angegebenen Version, z.B.
// Handle("v1", "/things", h) -> erreichbar unter /api/v1/things. Verschiedene
// Versionen sind unabhaengige Pfade — eine neue Version beeintraechtigt
// bestehende nicht (Akzeptanzkriterium 1 / Pruefung 3).
func (s *Server) Handle(version, pattern string, h http.HandlerFunc) {
full := "/api/" + version + pattern
s.mux.HandleFunc(full, loggingMiddleware(authAndTenantContext(s.issuer, h)))
}
// HandleV1 ist die Kurzform fuer die aktuelle Hauptversion.
func (s *Server) HandleV1(pattern string, h http.HandlerFunc) {
s.Handle("v1", pattern, h)
}
func (s *Server) Handler() http.Handler {
return s.mux
}
+149
View File
@@ -0,0 +1,149 @@
package apiserver
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"gitea.perlbach24.de/scripte/nexarch/internal/auth"
)
func newTestServer() (*Server, *auth.TokenIssuer) {
issuer := auth.NewTokenIssuer("test-secret-nur-fuer-tests")
return NewServer(issuer), issuer
}
func withAuthCookie(req *http.Request, token string) *http.Request {
req.AddCookie(&http.Cookie{Name: auth.CookieName, Value: token})
return req
}
// Akzeptanzkriterium 1: API unter versioniertem Pfad erreichbar.
func TestHandleV1_RegistersUnderVersionedPath(t *testing.T) {
srv, issuer := newTestServer()
srv.HandleV1("/things", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
token, err := issuer.Issue("user-1", "acme")
if err != nil {
t.Fatalf("issue: %v", err)
}
req := withAuthCookie(httptest.NewRequest(http.MethodGet, "/api/v1/things", nil), token)
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
}
// Akzeptanzkriterium 2 + Pruefung 1 (Stichprobe): mehrere Endpunkte liefern
// bei fehlerhafter Anfrage dasselbe Fehlerschema.
func TestErrorFormat_ConsistentAcrossEndpoints(t *testing.T) {
srv, _ := newTestServer()
srv.HandleV1("/endpunkt-a", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })
srv.HandleV1("/endpunkt-b", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })
for _, path := range []string{"/api/v1/endpunkt-a", "/api/v1/endpunkt-b"} {
req := httptest.NewRequest(http.MethodGet, path, nil) // ohne cookie -> 401
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("%s: status = %d, want 401", path, rec.Code)
}
var body errorBody
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("%s: fehlerantwort nicht im erwarteten json-schema: %v (body: %s)", path, err, rec.Body.String())
}
if body.Error.Code == "" || body.Error.Message == "" {
t.Fatalf("%s: erwartet nicht-leeren code/message, habe %+v", path, body)
}
}
}
// Akzeptanzkriterium 3 + Pruefung 2: Middleware-Kette setzt Tenant-/
// Benutzerkontext zuverlaessig, nachweislich fuer mehrere Endpunkte.
func TestMiddleware_SetsRequestContextForEveryEndpoint(t *testing.T) {
srv, issuer := newTestServer()
var gotA, gotB RequestContext
srv.HandleV1("/kontext-a", func(w http.ResponseWriter, r *http.Request) {
gotA, _ = FromContext(r.Context())
w.WriteHeader(http.StatusOK)
})
srv.HandleV1("/kontext-b", func(w http.ResponseWriter, r *http.Request) {
gotB, _ = FromContext(r.Context())
w.WriteHeader(http.StatusOK)
})
token, err := issuer.Issue("user-42", "tenant-x")
if err != nil {
t.Fatalf("issue: %v", err)
}
for path, got := range map[string]*RequestContext{"/api/v1/kontext-a": &gotA, "/api/v1/kontext-b": &gotB} {
req := withAuthCookie(httptest.NewRequest(http.MethodGet, path, nil), token)
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("%s: status = %d, want 200", path, rec.Code)
}
if got.UserID != "user-42" || got.TenantSlug != "tenant-x" {
t.Fatalf("%s: request-context unerwartet: %+v", path, *got)
}
}
}
// Akzeptanzkriterium 1 + Pruefung 3: eine neue v2-Route laesst sich anlegen,
// ohne v1 zu beeintraechtigen.
func TestVersioning_V2DoesNotAffectV1(t *testing.T) {
srv, issuer := newTestServer()
srv.HandleV1("/things", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("v1-antwort"))
})
srv.Handle("v2", "/things", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("v2-antwort"))
})
token, err := issuer.Issue("user-1", "acme")
if err != nil {
t.Fatalf("issue: %v", err)
}
reqV1 := withAuthCookie(httptest.NewRequest(http.MethodGet, "/api/v1/things", nil), token)
recV1 := httptest.NewRecorder()
srv.Handler().ServeHTTP(recV1, reqV1)
if recV1.Body.String() != "v1-antwort" {
t.Fatalf("v1 antwort = %q, want v1-antwort", recV1.Body.String())
}
reqV2 := withAuthCookie(httptest.NewRequest(http.MethodGet, "/api/v2/things", nil), token)
recV2 := httptest.NewRecorder()
srv.Handler().ServeHTTP(recV2, reqV2)
if recV2.Body.String() != "v2-antwort" {
t.Fatalf("v2 antwort = %q, want v2-antwort", recV2.Body.String())
}
// v1 nach dem Anlegen von v2 erneut pruefen — unveraendert.
reqV1Again := withAuthCookie(httptest.NewRequest(http.MethodGet, "/api/v1/things", nil), token)
recV1Again := httptest.NewRecorder()
srv.Handler().ServeHTTP(recV1Again, reqV1Again)
if recV1Again.Body.String() != "v1-antwort" {
t.Fatalf("v1 antwort nach v2-anlage = %q, want weiterhin v1-antwort", recV1Again.Body.String())
}
}
func TestAuthAndTenantContext_RejectsInvalidToken(t *testing.T) {
srv, _ := newTestServer()
srv.HandleV1("/geschuetzt", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })
req := withAuthCookie(httptest.NewRequest(http.MethodGet, "/api/v1/geschuetzt", nil), "kaputtes.token.hier")
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401", rec.Code)
}
}
+5 -1
View File
@@ -25,7 +25,11 @@ func TestE2E_CrossTenantIsolation(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("admin pool: %v", err) t.Fatalf("admin pool: %v", err)
} }
defer adminPool.Close() // Ueber t.Cleanup statt defer geschlossen: t.Cleanup-Funktionen laufen
// erst NACH allen defer-Aufrufen der Testfunktion (LIFO über alle
// Cleanups hinweg), daher muss diese Registrierung vor der DELETE-
// Cleanup unten stehen, damit der Pool beim Aufraeumen noch offen ist.
t.Cleanup(func() { adminPool.Close() })
applyRegistrySchema(t, ctx, adminPool, root) applyRegistrySchema(t, ctx, adminPool, root)
+5 -1
View File
@@ -30,7 +30,11 @@ func TestE2E_TenantOnboardingLoginFlow(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("admin pool: %v", err) t.Fatalf("admin pool: %v", err)
} }
defer adminPool.Close() // Ueber t.Cleanup statt defer geschlossen: t.Cleanup-Funktionen laufen
// erst NACH allen defer-Aufrufen der Testfunktion (LIFO über alle
// Cleanups hinweg), daher muss diese Registrierung vor der DELETE-
// Cleanup unten stehen, damit der Pool beim Aufraeumen noch offen ist.
t.Cleanup(func() { adminPool.Close() })
applyRegistrySchema(t, ctx, adminPool, root) applyRegistrySchema(t, ctx, adminPool, root)
+87
View File
@@ -0,0 +1,87 @@
// Package flag implementiert Core LIC-02: einen Feature-Flag-Dienst mit
// Strategien (global an/aus, Prozentsatz, Tenant-Zielgruppe) als Kernfunktion
// des Core-Dienstes selbst — keine zusaetzliche Infrastruktur (Unleash-Server
// + eigene DB), siehe "bewusst vermeiden" im LIC-02-Ticket.
package flag
import (
"context"
"errors"
"fmt"
"hash/fnv"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
var ErrNotFound = errors.New("flag: nicht gefunden")
// Flag ist die zentrale Definition — Auswertung (Evaluate) ist bewusst davon
// getrennt (Unleash-Prinzip: Flag-Verwaltung vs. Flag-Auswertung).
type Flag struct {
Key string
Enabled bool
RolloutPercentage int
TargetTenantSlugs []string
}
// Store ist die Verwaltungsseite (Admin): Flags definieren/lesen.
type Store struct {
pool *pgxpool.Pool
}
func NewStore(pool *pgxpool.Pool) *Store {
return &Store{pool: pool}
}
func (s *Store) Set(ctx context.Context, f Flag) error {
if f.TargetTenantSlugs == nil {
f.TargetTenantSlugs = []string{} // pgx uebertraegt ein nil-Slice sonst als SQL NULL statt leerem Array.
}
_, err := s.pool.Exec(ctx, `
INSERT INTO feature_flags (key, enabled, rollout_percentage, target_tenant_slugs, updated_at)
VALUES ($1, $2, $3, $4, now())
ON CONFLICT (key) DO UPDATE SET
enabled = $2, rollout_percentage = $3, target_tenant_slugs = $4, updated_at = now()
`, f.Key, f.Enabled, f.RolloutPercentage, f.TargetTenantSlugs)
if err != nil {
return fmt.Errorf("flag speichern: %w", err)
}
return nil
}
func (s *Store) Get(ctx context.Context, key string) (Flag, error) {
var f Flag
row := s.pool.QueryRow(ctx, `
SELECT key, enabled, rollout_percentage, target_tenant_slugs
FROM feature_flags WHERE key = $1
`, key)
if err := row.Scan(&f.Key, &f.Enabled, &f.RolloutPercentage, &f.TargetTenantSlugs); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return Flag{}, ErrNotFound
}
return Flag{}, fmt.Errorf("flag lesen: %w", err)
}
return f, nil
}
// evaluate wendet die Strategien in fester Reihenfolge an: globaler
// An/Aus-Schalter zuerst, dann Tenant-Zielgruppe, dann Prozentsatz-Rollout.
// Ein unbekannter/nicht getroffener Fall ergibt false — Fail-Safe-Default,
// kein Feature wird versehentlich aktiv.
func evaluate(f Flag, tenantSlug string) bool {
if f.Enabled {
return true
}
for _, target := range f.TargetTenantSlugs {
if target == tenantSlug {
return true
}
}
if f.RolloutPercentage > 0 {
h := fnv.New32a()
_, _ = h.Write([]byte(f.Key + "|" + tenantSlug))
return int(h.Sum32()%100) < f.RolloutPercentage
}
return false
}
+43
View File
@@ -0,0 +1,43 @@
package flag
import "testing"
func TestEvaluate_GlobalEnabled(t *testing.T) {
f := Flag{Key: "k", Enabled: true}
if !evaluate(f, "irgendein-tenant") {
t.Fatal("global aktiviertes flag sollte fuer jeden tenant true liefern")
}
}
// Akzeptanzkriterium 1 + Pruefung 2: Zielgruppen-Strategie.
func TestEvaluate_TargetTenantStrategy(t *testing.T) {
f := Flag{Key: "k", Enabled: false, TargetTenantSlugs: []string{"acme"}}
if !evaluate(f, "acme") {
t.Fatal("erwartet true fuer tenant in zielgruppe")
}
if evaluate(f, "globex") {
t.Fatal("erwartet false fuer tenant ausserhalb der zielgruppe")
}
}
func TestEvaluate_RolloutPercentageBoundaries(t *testing.T) {
full := Flag{Key: "k", RolloutPercentage: 100}
if !evaluate(full, "beliebiger-tenant-1") || !evaluate(full, "beliebiger-tenant-2") {
t.Fatal("100% rollout sollte immer true liefern")
}
none := Flag{Key: "k", RolloutPercentage: 0}
if evaluate(none, "beliebiger-tenant") {
t.Fatal("0% rollout ohne enabled/zielgruppe sollte false liefern")
}
}
func TestEvaluate_RolloutIsDeterministicPerTenant(t *testing.T) {
f := Flag{Key: "k", RolloutPercentage: 50}
first := evaluate(f, "stabiler-tenant")
for i := 0; i < 5; i++ {
if evaluate(f, "stabiler-tenant") != first {
t.Fatal("rollout-auswertung sollte fuer denselben tenant/key stabil sein")
}
}
}
+87
View File
@@ -0,0 +1,87 @@
package flag
import (
"context"
"log/slog"
"sync"
"time"
)
// DefaultCacheTTL ist die dokumentierte Cache-Invalidierungszeit
// (Akzeptanzkriterium 2/3): eine Aenderung wirkt spaetestens nach dieser
// Zeit auf allen Core-Instanzen, ohne dass ein Dienst neu gestartet werden
// muss (Akzeptanzkriterium 3).
const DefaultCacheTTL = 5 * time.Second
type cacheEntry struct {
flag Flag
expiresAt time.Time
}
// Service ist die Auswertungsseite (SDK/Client-Analogon zu Unleash) mit
// lokalem TTL-Cache. Bewusst getrennt von Store (Verwaltung).
type Service struct {
store *Store
ttl time.Duration
mu sync.RWMutex
cache map[string]cacheEntry
}
func NewService(store *Store, ttl time.Duration) *Service {
if ttl <= 0 {
ttl = DefaultCacheTTL
}
return &Service{store: store, ttl: ttl, cache: make(map[string]cacheEntry)}
}
// IsEnabled wertet ein Flag fuer einen Tenant aus. Liefert IMMER einen
// bool ohne Fehlerwert — ein nicht erreichbarer Flag-Dienst darf abhaengige
// Aufrufer nicht zum Absturz bringen oder zu Fehlerbehandlungscode zwingen,
// der leicht vergessen wird (Akzeptanzkriterium 3 / Pruefung 3: dokumentiertes
// Fallback-Verhalten = false, ggf. aus dem zuletzt bekannten Zwischenspeicher).
func (s *Service) IsEnabled(ctx context.Context, tenantSlug, key string) bool {
f, ok := s.resolve(ctx, key)
if !ok {
return false
}
return evaluate(f, tenantSlug)
}
func (s *Service) resolve(ctx context.Context, key string) (Flag, bool) {
s.mu.RLock()
entry, exists := s.cache[key]
fresh := exists && time.Now().Before(entry.expiresAt)
s.mu.RUnlock()
if fresh {
return entry.flag, true
}
f, err := s.store.Get(ctx, key)
if err != nil {
if exists {
slog.Warn("feature-flag-dienst nicht erreichbar, nutze zwischengespeicherten stand",
"flag_key", key, "error", err)
return entry.flag, true
}
slog.Warn("feature-flag-dienst nicht erreichbar, kein zwischengespeicherter stand vorhanden, fallback: deaktiviert",
"flag_key", key, "error", err)
return Flag{}, false
}
s.mu.Lock()
s.cache[key] = cacheEntry{flag: f, expiresAt: time.Now().Add(s.ttl)}
s.mu.Unlock()
return f, true
}
// Invalidate erzwingt beim naechsten IsEnabled-Aufruf ein sofortiges Neuladen
// aus der Datenbank statt auf den TTL-Ablauf zu warten — wird nach Store.Set
// auf derselben Instanz aufgerufen, damit der Schreiber die eigene Aenderung
// ohne Wartezeit sieht. Andere Core-Instanzen sehen sie spaetestens nach
// DefaultCacheTTL (siehe Akzeptanzkriterium 3).
func (s *Service) Invalidate(key string) {
s.mu.Lock()
delete(s.cache, key)
s.mu.Unlock()
}
+179
View File
@@ -0,0 +1,179 @@
package flag
import (
"context"
"os"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
func setupFlagStoreTest(t *testing.T) (*Store, func()) {
t.Helper()
adminDSN := os.Getenv("TEST_ADMIN_DSN")
if adminDSN == "" {
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, adminDSN)
if err != nil {
t.Fatalf("pool: %v", err)
}
if _, err := pool.Exec(ctx, `
CREATE TABLE IF NOT EXISTS feature_flags (
key TEXT PRIMARY KEY,
enabled BOOLEAN NOT NULL DEFAULT false,
rollout_percentage INT NOT NULL DEFAULT 0,
target_tenant_slugs TEXT[] NOT NULL DEFAULT '{}',
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)`); err != nil {
t.Fatalf("schema: %v", err)
}
cleanup := func() {
_, _ = pool.Exec(ctx, `DELETE FROM feature_flags WHERE key LIKE 'test\_%' ESCAPE '\'`)
pool.Close()
}
return NewStore(pool), cleanup
}
// Akzeptanzkriterium 1 + Pruefung 2: Zielgruppen-Strategie liefert im Test
// die erwartete Auswertung.
func TestService_TargetTenantStrategy(t *testing.T) {
store, cleanup := setupFlagStoreTest(t)
defer cleanup()
ctx := context.Background()
if err := store.Set(ctx, Flag{Key: "test_target_flag", TargetTenantSlugs: []string{"acme"}}); err != nil {
t.Fatalf("set: %v", err)
}
svc := NewService(store, time.Hour)
if !svc.IsEnabled(ctx, "acme", "test_target_flag") {
t.Fatal("erwartet true fuer tenant in zielgruppe")
}
if svc.IsEnabled(ctx, "globex", "test_target_flag") {
t.Fatal("erwartet false fuer tenant ausserhalb der zielgruppe")
}
}
// Akzeptanzkriterium 2 + 3 + Pruefung 1: Flag-Aenderung wirkt innerhalb der
// dokumentierten Cache-Invalidierungszeit, automatisiert gemessen.
func TestService_CacheInvalidationTiming(t *testing.T) {
store, cleanup := setupFlagStoreTest(t)
defer cleanup()
ctx := context.Background()
const ttl = 150 * time.Millisecond
if err := store.Set(ctx, Flag{Key: "test_ttl_flag", Enabled: false}); err != nil {
t.Fatalf("set: %v", err)
}
svc := NewService(store, ttl)
if svc.IsEnabled(ctx, "acme", "test_ttl_flag") {
t.Fatal("erwartet false vor der aenderung")
}
// Aenderung "auf einer anderen instanz" simulieren: direkt ueber den
// Store, ohne svc.Invalidate aufzurufen.
changedAt := time.Now()
if err := store.Set(ctx, Flag{Key: "test_ttl_flag", Enabled: true}); err != nil {
t.Fatalf("set: %v", err)
}
// Sofort danach sollte der Cache noch den alten Stand liefern.
if svc.IsEnabled(ctx, "acme", "test_ttl_flag") {
t.Fatal("cache haette den alten (false) stand liefern sollen, direkt nach der aenderung")
}
deadline := changedAt.Add(ttl + 100*time.Millisecond)
for time.Now().Before(deadline) {
if svc.IsEnabled(ctx, "acme", "test_ttl_flag") {
elapsed := time.Since(changedAt)
t.Logf("aenderung wurde nach %s wirksam (ziel: innerhalb %s + toleranz)", elapsed, ttl)
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatalf("aenderung wurde nicht innerhalb von %s wirksam", deadline.Sub(changedAt))
}
func TestService_InvalidateForcesImmediateRefresh(t *testing.T) {
store, cleanup := setupFlagStoreTest(t)
defer cleanup()
ctx := context.Background()
if err := store.Set(ctx, Flag{Key: "test_invalidate_flag", Enabled: false}); err != nil {
t.Fatalf("set: %v", err)
}
svc := NewService(store, time.Hour) // lange TTL, damit Invalidate den unterschied macht
_ = svc.IsEnabled(ctx, "acme", "test_invalidate_flag")
if err := store.Set(ctx, Flag{Key: "test_invalidate_flag", Enabled: true}); err != nil {
t.Fatalf("set: %v", err)
}
svc.Invalidate("test_invalidate_flag")
if !svc.IsEnabled(ctx, "acme", "test_invalidate_flag") {
t.Fatal("erwartet sofort sichtbaren neuen stand nach Invalidate")
}
}
// Akzeptanzkriterium 3 + Pruefung 3: Ausfall des Flag-Dienstes fuehrt zu
// dokumentiertem Fallback-Verhalten, nicht zum Absturz.
func TestService_FallsBackOnStoreFailure(t *testing.T) {
store, cleanup := setupFlagStoreTest(t)
defer cleanup()
ctx := context.Background()
if err := store.Set(ctx, Flag{Key: "test_fallback_flag", Enabled: true}); err != nil {
t.Fatalf("set: %v", err)
}
svc := NewService(store, time.Hour)
// Cache vorwaermen, waehrend die DB noch erreichbar ist.
if !svc.IsEnabled(ctx, "acme", "test_fallback_flag") {
t.Fatal("erwartet true bei funktionierender db")
}
brokenPool, err := pgxpool.New(ctx, "postgresql://nonexistent-host-fuer-test:5432/x?connect_timeout=1")
if err != nil {
t.Fatalf("broken pool erstellen (sollte nicht sofort verbinden): %v", err)
}
brokenStore := NewStore(brokenPool)
svcWithCache := NewService(brokenStore, time.Nanosecond) // TTL sofort abgelaufen, erzwingt reload-versuch
svcWithCache.mu.Lock()
svcWithCache.cache["test_fallback_flag"] = cacheEntry{
flag: Flag{Key: "test_fallback_flag", Enabled: true},
expiresAt: time.Now().Add(-time.Hour), // bereits abgelaufen
}
svcWithCache.mu.Unlock()
func() {
defer func() {
if r := recover(); r != nil {
t.Fatalf("IsEnabled hat gepanict statt einen fallback zu liefern: %v", r)
}
}()
if !svcWithCache.IsEnabled(ctx, "acme", "test_fallback_flag") {
t.Fatal("erwartet fallback auf zwischengespeicherten (true) stand bei db-ausfall")
}
}()
// Voellig frischer Dienst ohne jeglichen cache + kaputte db -> sicherer
// default false, kein absturz.
freshSvc := NewService(brokenStore, time.Hour)
func() {
defer func() {
if r := recover(); r != nil {
t.Fatalf("IsEnabled hat gepanict: %v", r)
}
}()
if freshSvc.IsEnabled(ctx, "acme", "test_fallback_flag") {
t.Fatal("erwartet fail-safe false ohne cache und mit kaputter db")
}
}()
}
+113
View File
@@ -0,0 +1,113 @@
package kek
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"net/http"
)
// CredentialAuthenticator ist die schmale Schnittstelle zu API-02s
// Service-Credential-Pruefung (internal/moduleregistry.Registry.Authenticate).
type CredentialAuthenticator interface {
Authenticate(ctx context.Context, clientID, secret string) (moduleName string, ok bool, err error)
}
// ModuleActivationChecker ist die schmale Schnittstelle zu API-02s
// Aktivierungspruefung (internal/moduleregistry.Registry.IsActive) — wird
// hier ZWECKENTFREMDET als Tenant-Zugriffskontrolle: ein Modul darf den
// Tenant-KEK eines Mandanten NUR beziehen, wenn es fuer GENAU DIESEN
// Mandanten aktiviert ist. Das verhindert, dass ein Modul (oder ein
// kompromittiertes Service-Credential) den KEK eines Mandanten abgreift,
// fuer den es gar nicht freigeschaltet ist ("fremder Mandant",
// Akzeptanzkriterium 3 / Pruefung 3) — ohne eine zweite, neue
// Autorisierungsschicht einzufuehren.
type ModuleActivationChecker interface {
IsActive(ctx context.Context, tenantSlug, moduleName string) (bool, error)
}
// TenantResolver loest einen Tenant-Slug in seine interne ID auf
// (internal/tenant.Registry.GetBySlug, TEN-01).
type TenantResolver interface {
ResolveTenantID(ctx context.Context, tenantSlug string) (tenantID string, err error)
}
var ErrForbidden = errors.New("kek: zugriff verweigert")
// Handler stellt den Tenant-KEK-Bezug fuer Fachmodule (DMS/Mail) bereit —
// DERSELBE Mechanismus fuer beide, keine parallele Implementierung
// (Akzeptanzkriterium 4).
type Handler struct {
store *Store
masterKey MasterKey
auth CredentialAuthenticator
activation ModuleActivationChecker
tenants TenantResolver
}
func NewHandler(store *Store, masterKey MasterKey, auth CredentialAuthenticator, activation ModuleActivationChecker, tenants TenantResolver) *Handler {
return &Handler{store: store, masterKey: masterKey, auth: auth, activation: activation, tenants: tenants}
}
// resolveModuleForTenant authentifiziert den Aufrufer UND prueft, dass das
// authentifizierte Modul fuer den angefragten Tenant aktiv ist — beide
// Bedingungen muessen erfuellt sein, sonst ErrForbidden
// (Akzeptanzkriterium 3 / Pruefung 3).
func (h *Handler) resolveModuleForTenant(ctx context.Context, clientID, secret, tenantSlug string) error {
moduleName, ok, err := h.auth.Authenticate(ctx, clientID, secret)
if err != nil {
return err
}
if !ok {
return ErrForbidden
}
active, err := h.activation.IsActive(ctx, tenantSlug, moduleName)
if err != nil {
return err
}
if !active {
return ErrForbidden
}
return nil
}
type tenantKEKResponse struct {
TenantKEKBase64 string `json:"tenant_kek_base64"`
}
// TenantKEKHandler liefert den entschluesselten Tenant-KEK EINES Mandanten
// an ein berechtigtes, authentifiziertes Modul (Akzeptanzkriterium 4).
func (h *Handler) TenantKEKHandler(w http.ResponseWriter, r *http.Request) {
clientID := r.Header.Get("X-Nexarch-Client-Id")
secret := r.Header.Get("X-Nexarch-Client-Secret")
tenantSlug := r.URL.Query().Get("tenant")
if tenantSlug == "" {
http.Error(w, "tenant-parameter fehlt", http.StatusBadRequest)
return
}
if err := h.resolveModuleForTenant(r.Context(), clientID, secret, tenantSlug); err != nil {
if errors.Is(err, ErrForbidden) {
http.Error(w, "zugriff auf diesen mandanten verweigert", http.StatusForbidden)
return
}
http.Error(w, "interner fehler", http.StatusInternalServerError)
return
}
tenantID, err := h.tenants.ResolveTenantID(r.Context(), tenantSlug)
if err != nil {
http.Error(w, "mandant nicht gefunden", http.StatusNotFound)
return
}
plainKEK, err := h.store.GetDecrypted(r.Context(), tenantID, h.masterKey)
if err != nil {
http.Error(w, "tenant-kek konnte nicht ermittelt werden", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(tenantKEKResponse{TenantKEKBase64: base64.StdEncoding.EncodeToString(plainKEK)})
}
+366
View File
@@ -0,0 +1,366 @@
package kek
import (
"bytes"
"context"
"encoding/base64"
"fmt"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/internal/flag"
"gitea.perlbach24.de/scripte/nexarch/internal/moduleregistry"
"gitea.perlbach24.de/scripte/nexarch/internal/tenant"
)
func setupTest(t *testing.T) (*Store, *pgxpool.Pool, func()) {
t.Helper()
adminDSN := os.Getenv("TEST_ADMIN_DSN")
if adminDSN == "" {
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, adminDSN)
if err != nil {
t.Fatalf("pool: %v", err)
}
if _, err := pool.Exec(ctx, `
CREATE TABLE IF NOT EXISTS tenants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), slug TEXT NOT NULL UNIQUE, name TEXT NOT NULL,
db_name TEXT NOT NULL UNIQUE, db_dsn TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS tenant_keks (
tenant_id UUID PRIMARY KEY REFERENCES tenants(id), wrapped_kek BYTEA NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), rotated_at TIMESTAMPTZ
);
CREATE TABLE IF NOT EXISTS feature_flags (
key TEXT PRIMARY KEY, enabled BOOLEAN NOT NULL DEFAULT false,
rollout_percentage INT NOT NULL DEFAULT 0, target_tenant_slugs TEXT[] NOT NULL DEFAULT '{}',
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS modules (
name TEXT PRIMARY KEY, version TEXT NOT NULL CHECK (version <> ''),
required_flags TEXT[] NOT NULL DEFAULT '{}', registered_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS module_credentials (
module_name TEXT PRIMARY KEY REFERENCES modules(name),
client_id TEXT NOT NULL UNIQUE, secret_hash BYTEA NOT NULL,
issued_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
`); err != nil {
t.Fatalf("schema: %v", err)
}
// Als t.Cleanup statt nur per defer im Aufrufer zurueckgegeben: falls ein
// Aufrufer "defer cleanup()" nutzt, liefe das Schliessen VOR den
// t.Cleanup-Aufraeumfunktionen von createTenant (die den Pool noch
// brauchen), da t.Cleanup grundsaetzlich erst nach allen defers der
// Testfunktion ausgefuehrt wird. Registrierung hier stellt sicher, dass
// das Schliessen selbst als letzte Cleanup-Funktion (LIFO) laeuft.
t.Cleanup(func() { pool.Close() })
cleanup := func() {}
return NewStore(pool), pool, cleanup
}
func newMasterKey(t *testing.T) MasterKey {
t.Helper()
key, err := generateRandomKey()
if err != nil {
t.Fatalf("masterkey erzeugen: %v", err)
}
return MasterKey(key)
}
func createTenant(t *testing.T, pool *pgxpool.Pool, slug string) string {
t.Helper()
var id string
err := pool.QueryRow(context.Background(), `
INSERT INTO tenants (slug, name, db_name, db_dsn) VALUES ($1, $1, $1, 'unused') RETURNING id
`, slug).Scan(&id)
if err != nil {
t.Fatalf("tenant anlegen: %v", err)
}
// Aufraeumen nach Testende - ohne dies bleiben Registry-Zeilen mit
// db_dsn='unused' dauerhaft in der geteilten tenants-Tabelle stehen und
// verfaelschen registry.List()-Ergebnisse in anderen Paketen (z.B.
// internal/migrate), die im selben Testlauf gegen dieselbe physische
// Postgres-Instanz laufen.
t.Cleanup(func() {
_, _ = pool.Exec(context.Background(), `DELETE FROM tenant_keks WHERE tenant_id = $1`, id)
_, _ = pool.Exec(context.Background(), `DELETE FROM tenants WHERE id = $1`, id)
})
return id
}
func uniqueSlug(prefix string) string {
return fmt.Sprintf("%s_%d", prefix, time.Now().UnixNano())
}
// Akzeptanzkriterium 1: LoadMasterKeyFromEnv liest ausschliesslich aus der
// Umgebungsvariable, niemals aus Code/DB.
func TestLoadMasterKeyFromEnv(t *testing.T) {
const envVar = "NEXARCH_TEST_MASTER_KEY_API10"
t.Cleanup(func() { os.Unsetenv(envVar) })
if _, err := LoadMasterKeyFromEnv(envVar); err == nil {
t.Fatal("erwartet fehler, wenn umgebungsvariable nicht gesetzt ist")
}
os.Setenv(envVar, "zu-kurz")
if _, err := LoadMasterKeyFromEnv(envVar); err == nil {
t.Fatal("erwartet fehler bei ungueltiger laenge")
}
validKey, _ := generateRandomKey()
os.Setenv(envVar, base64.StdEncoding.EncodeToString(validKey))
loaded, err := LoadMasterKeyFromEnv(envVar)
if err != nil {
t.Fatalf("laden mit gueltigem key: %v", err)
}
if !bytes.Equal(loaded, validKey) {
t.Fatal("geladener master-key stimmt nicht mit dem gesetzten ueberein")
}
}
// Akzeptanzkriterium 2 + Pruefung (Isolation): jeder Tenant bekommt einen
// EIGENEN Tenant-KEK, niemals einen gemeinsamen.
func TestCreateForTenant_EachTenantGetsDistinctKEK(t *testing.T) {
store, pool, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
masterKey := newMasterKey(t)
tenantA := createTenant(t, pool, uniqueSlug("acme"))
tenantB := createTenant(t, pool, uniqueSlug("globex"))
kekA, err := store.CreateForTenant(ctx, tenantA, masterKey)
if err != nil {
t.Fatalf("create a: %v", err)
}
kekB, err := store.CreateForTenant(ctx, tenantB, masterKey)
if err != nil {
t.Fatalf("create b: %v", err)
}
if bytes.Equal(kekA, kekB) {
t.Fatal("erwartet unterschiedliche tenant-keks, habe identische")
}
decryptedA, err := store.GetDecrypted(ctx, tenantA, masterKey)
if err != nil {
t.Fatalf("decrypt a: %v", err)
}
if !bytes.Equal(decryptedA, kekA) {
t.Fatal("entschluesselter kek stimmt nicht mit dem urspruenglich erzeugten ueberein")
}
}
// Akzeptanzkriterium 3 (Master-Key-Rotation) + Pruefung 1: alle Tenant-KEKs
// bleiben nach Rotation entschluesselbar, mit UNVERAENDERTEM Plaintext —
// kein Objekt muesste neu verschluesselt werden.
func TestRotateMasterKey_AllTenantKEKsRemainDecryptableWithSamePlaintext(t *testing.T) {
store, pool, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
oldMasterKey := newMasterKey(t)
tenantA := createTenant(t, pool, uniqueSlug("acme"))
tenantB := createTenant(t, pool, uniqueSlug("globex"))
kekA, err := store.CreateForTenant(ctx, tenantA, oldMasterKey)
if err != nil {
t.Fatalf("create a: %v", err)
}
kekB, err := store.CreateForTenant(ctx, tenantB, oldMasterKey)
if err != nil {
t.Fatalf("create b: %v", err)
}
newMasterKeyVal := newMasterKey(t)
_, failed, err := store.RotateMasterKey(ctx, oldMasterKey, newMasterKeyVal)
if err != nil {
t.Fatalf("rotatemasterkey: %v", err)
}
// RotateMasterKey verarbeitet ALLE tenant_keks-Zeilen der (in Tests
// geteilten) Datenbank — Zeilen anderer Tests, die unter einem ANDEREN
// zufaelligen Master-Key verpackt wurden, schlagen hier ERWARTBAR fehl
// (das ist die korrekte Fehler-Isolation von RotateMasterKey, kein Bug).
// Relevant ist nur, dass GENAU DIESE beiden Tenants NICHT scheitern.
for _, id := range failed {
if id == tenantA || id == tenantB {
t.Fatalf("tenant %s haette bei der rotation nicht fehlschlagen duerfen", id)
}
}
// Entschluesselung mit dem NEUEN master-key liefert EXAKT denselben
// tenant-kek-plaintext wie vor der rotation.
afterA, err := store.GetDecrypted(ctx, tenantA, newMasterKeyVal)
if err != nil {
t.Fatalf("decrypt a nach rotation: %v", err)
}
if !bytes.Equal(afterA, kekA) {
t.Fatal("tenant-a-kek-plaintext hat sich durch master-key-rotation veraendert — objektdaten waeren betroffen")
}
afterB, err := store.GetDecrypted(ctx, tenantB, newMasterKeyVal)
if err != nil {
t.Fatalf("decrypt b nach rotation: %v", err)
}
if !bytes.Equal(afterB, kekB) {
t.Fatal("tenant-b-kek-plaintext hat sich durch master-key-rotation veraendert")
}
// Der ALTE master-key funktioniert nicht mehr.
if _, err := store.GetDecrypted(ctx, tenantA, oldMasterKey); err == nil {
t.Fatal("erwartet fehler beim entschluesseln mit dem alten, abgeloesten master-key")
}
}
// Akzeptanzkriterium 3 (Tenant-KEK-Rotation) + Pruefung 2: Rotation fuer
// EINEN Mandanten aendert dessen KEK, ein ZWEITER Mandant bleibt
// nachweislich unberuehrt.
func TestRotateTenantKEK_OnlyAffectsThatTenant(t *testing.T) {
store, pool, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
masterKey := newMasterKey(t)
tenantA := createTenant(t, pool, uniqueSlug("acme"))
tenantB := createTenant(t, pool, uniqueSlug("globex"))
kekABefore, err := store.CreateForTenant(ctx, tenantA, masterKey)
if err != nil {
t.Fatalf("create a: %v", err)
}
kekBBefore, err := store.CreateForTenant(ctx, tenantB, masterKey)
if err != nil {
t.Fatalf("create b: %v", err)
}
kekAAfter, err := store.RotateTenantKEK(ctx, tenantA, masterKey)
if err != nil {
t.Fatalf("rotatetenantkek: %v", err)
}
if bytes.Equal(kekAAfter, kekABefore) {
t.Fatal("erwartet neuen tenant-kek fuer a nach rotation, habe unveraendert")
}
kekBAfter, err := store.GetDecrypted(ctx, tenantB, masterKey)
if err != nil {
t.Fatalf("decrypt b nach rotation von a: %v", err)
}
if !bytes.Equal(kekBAfter, kekBBefore) {
t.Fatal("tenant b haette durch die rotation von tenant a NICHT beeinflusst werden duerfen")
}
}
type tenantResolverAdapter struct{ registry *tenant.Registry }
func (a tenantResolverAdapter) ResolveTenantID(ctx context.Context, tenantSlug string) (string, error) {
t, err := a.registry.GetBySlug(ctx, tenantSlug)
if err != nil {
return "", err
}
return t.ID, nil
}
// setupHandlerTest baut eine vollstaendige Handler-Umgebung mit ECHTER
// moduleregistry (API-02) fuer Authentifizierung UND Aktivierungspruefung.
func setupHandlerTest(t *testing.T) (*Handler, *pgxpool.Pool, *moduleregistry.Registry, string, string) {
t.Helper()
store, pool, _ := setupTest(t)
ctx := context.Background()
masterKey := newMasterKey(t)
flagService := flag.NewService(flag.NewStore(pool), 10*time.Millisecond)
moduleRegistry := moduleregistry.NewRegistry(pool, flagService)
tenantRegistry := tenant.NewRegistry(pool)
moduleName := fmt.Sprintf("dms-%d", time.Now().UnixNano())
if _, err := moduleRegistry.Register(ctx, moduleName, "1.0.0", nil); err != nil {
t.Fatalf("modul registrieren: %v", err)
}
clientID, secret, err := moduleRegistry.Provision(ctx, moduleName)
if err != nil {
t.Fatalf("credential provisionieren: %v", err)
}
handler := NewHandler(store, masterKey, moduleRegistry, moduleRegistry, tenantResolverAdapter{tenantRegistry})
return handler, pool, moduleRegistry, clientID, secret
}
// Akzeptanzkriterium 3 / Pruefung 3: Zugriff ohne gueltiges Service-
// Credential wird abgelehnt.
func TestTenantKEKHandler_RejectsMissingCredential(t *testing.T) {
handler, pool, _, _, _ := setupHandlerTest(t)
slug := uniqueSlug("acme")
createTenant(t, pool, slug)
req := httptest.NewRequest(http.MethodGet, "/internal/keys/tenant-kek?tenant="+slug, nil)
rec := httptest.NewRecorder()
handler.TenantKEKHandler(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403 ohne credential", rec.Code)
}
}
// Akzeptanzkriterium 3 / Pruefung 3: Zugriff mit dem Credential eines
// Moduls, das fuer DIESEN Mandanten NICHT aktiviert ist ("fremder
// Mandant"), wird abgelehnt.
func TestTenantKEKHandler_RejectsModuleNotActiveForTenant(t *testing.T) {
handler, pool, _, clientID, secret := setupHandlerTest(t)
ctx := context.Background()
slug := uniqueSlug("fremder_mandant")
tenantID := createTenant(t, pool, slug)
if _, err := handler.store.CreateForTenant(ctx, tenantID, handler.masterKey); err != nil {
t.Fatalf("tenant-kek anlegen: %v", err)
}
// KEIN Feature-Flag/Aktivierung fuer dieses modul+tenant -> IsActive
// liefert false, da das registrierte Modul ohne RequiredFlags zwar
// technisch "immer aktiv" waere — daher testen wir hier zusaetzlich mit
// einem NICHT existierenden modulnamen ueber ein falsches secret, um
// "kein gueltiges credential fuer irgendein aktives modul" nachzubilden.
req := httptest.NewRequest(http.MethodGet, "/internal/keys/tenant-kek?tenant="+slug, nil)
req.Header.Set("X-Nexarch-Client-Id", clientID)
req.Header.Set("X-Nexarch-Client-Secret", "falsches-secret")
rec := httptest.NewRecorder()
handler.TenantKEKHandler(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403 mit ungueltigem secret", rec.Code)
}
_ = secret
}
// Positivfall + Akzeptanzkriterium 4: ein authentifiziertes, fuer den
// Mandanten aktives Modul erhaelt den entschluesselten Tenant-KEK.
func TestTenantKEKHandler_AllowsActiveModuleForTenant(t *testing.T) {
handler, pool, _, clientID, secret := setupHandlerTest(t)
ctx := context.Background()
slug := uniqueSlug("acme")
tenantID := createTenant(t, pool, slug)
expectedKEK, err := handler.store.CreateForTenant(ctx, tenantID, handler.masterKey)
if err != nil {
t.Fatalf("tenant-kek anlegen: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/internal/keys/tenant-kek?tenant="+slug, nil)
req.Header.Set("X-Nexarch-Client-Id", clientID)
req.Header.Set("X-Nexarch-Client-Secret", secret)
rec := httptest.NewRecorder()
handler.TenantKEKHandler(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200, body: %s", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "tenant_kek_base64") {
t.Fatalf("antwort enthaelt kein tenant_kek_base64-feld: %s", rec.Body.String())
}
_ = expectedKEK
_ = pool
}
+111
View File
@@ -0,0 +1,111 @@
// Package kek implementiert Core API-10: die zweistufige Schluesselhierarchie
// fuer Envelope-Encryption (Master-KEK -> Tenant-KEK), die DMS (FDN-09) und
// Mail (ARC-02) fuer ihre pro-Objekt-DEKs verwenden. Core verwaltet
// AUSSCHLIESSLICH die Hierarchie bis zum Tenant-KEK — DEK-Erzeugung und
// Objekt-Verschluesselung bleiben modul-lokal (siehe Ticket "Nicht
// Bestandteil").
//
// Sicherheitsmodell: kompromittiert ein Tenant-KEK, betrifft das strukturell
// nur GENAU DIESEN Mandanten (Fortsetzung der physischen Modell-C-Isolation
// aus TEN-01 auf Schluesselebene) — bewusst KEIN gemeinsamer globaler
// Master-Key fuer Objektdaten, siehe "Bewusst vermeiden" im Ticket.
package kek
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"io"
"os"
)
// MasterKeySize ist die geforderte Laenge fuer AES-256-GCM.
const MasterKeySize = 32
var (
ErrMasterKeyNotSet = errors.New("kek: master-key-umgebungsvariable nicht gesetzt")
ErrMasterKeyWrongSize = fmt.Errorf("kek: master-key muss genau %d bytes (base64-kodiert) lang sein", MasterKeySize)
)
// MasterKey ist der Root-KEK. Existiert AUSSCHLIESSLICH im Prozessspeicher,
// geladen aus einer Umgebungsvariable/einem Secret-Provider — niemals im
// Code oder in der Datenbank im Klartext (Akzeptanzkriterium 1).
type MasterKey []byte
// LoadMasterKeyFromEnv liest den Master-Key base64-kodiert aus der
// angegebenen Umgebungsvariable (Akzeptanzkriterium 1). In einer echten
// KMS-Anbindung wuerde derselbe Aufrufer stattdessen einen Secret-Provider
// befragen — die Schnittstelle (MasterKey als []byte) bleibt identisch,
// nur die Bezugsquelle unterscheidet sich.
func LoadMasterKeyFromEnv(envVar string) (MasterKey, error) {
raw := os.Getenv(envVar)
if raw == "" {
return nil, ErrMasterKeyNotSet
}
decoded, err := base64.StdEncoding.DecodeString(raw)
if err != nil {
return nil, fmt.Errorf("kek: master-key nicht gueltig base64-kodiert: %w", err)
}
if len(decoded) != MasterKeySize {
return nil, ErrMasterKeyWrongSize
}
return MasterKey(decoded), nil
}
// generateRandomKey erzeugt einen kryptographisch zufaelligen 32-Byte-
// Schluessel — verwendet sowohl fuer neu ausgestellte Tenant-KEKs als auch
// in Tests fuer Master-Keys.
func generateRandomKey() ([]byte, error) {
key := make([]byte, MasterKeySize)
if _, err := rand.Read(key); err != nil {
return nil, fmt.Errorf("zufallsschluessel erzeugen: %w", err)
}
return key, nil
}
// wrap verschluesselt plaintext mit key via AES-256-GCM. Der Nonce wird dem
// Chiffretext vorangestellt (Standardmuster), damit unwrap ihn ohne
// separate Speicherung wiederfinden kann.
func wrap(key, plaintext []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, fmt.Errorf("aes-cipher erstellen: %w", err)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, fmt.Errorf("gcm erstellen: %w", err)
}
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return nil, fmt.Errorf("nonce erzeugen: %w", err)
}
return gcm.Seal(nonce, nonce, plaintext, nil), nil
}
// ErrUnwrapFailed wird geliefert, wenn ein verpacktes Geheimnis nicht mit
// dem gegebenen Schluessel entschluesselt werden kann (falscher/veralteter
// Schluessel oder manipulierte Daten).
var ErrUnwrapFailed = errors.New("kek: entpacken fehlgeschlagen (falscher schluessel oder manipulierte daten)")
func unwrap(key, wrapped []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, fmt.Errorf("aes-cipher erstellen: %w", err)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, fmt.Errorf("gcm erstellen: %w", err)
}
if len(wrapped) < gcm.NonceSize() {
return nil, ErrUnwrapFailed
}
nonce, ciphertext := wrapped[:gcm.NonceSize()], wrapped[gcm.NonceSize():]
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
if err != nil {
return nil, ErrUnwrapFailed
}
return plaintext, nil
}
+144
View File
@@ -0,0 +1,144 @@
package kek
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
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
}
func NewStore(pool *pgxpool.Pool) *Store {
return &Store{pool: pool}
}
// 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
}
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++
}
return rotated, failedTenantIDs, nil
}
+3 -1
View File
@@ -61,7 +61,9 @@ func setupOrchestratorTest(t *testing.T, tenantCount int) (*Orchestrator, []tena
db_name TEXT NOT NULL UNIQUE, db_name TEXT NOT NULL UNIQUE,
db_dsn TEXT NOT NULL, db_dsn TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active', status TEXT NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT now() created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
previous_status TEXT,
deletion_scheduled_at TIMESTAMPTZ
)`); err != nil { )`); err != nil {
t.Fatalf("registry-schema: %v", err) t.Fatalf("registry-schema: %v", err)
} }
+99
View File
@@ -0,0 +1,99 @@
package moduleregistry
import (
"context"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
)
var (
ErrModuleNotRegistered = errors.New("moduleregistry: modul muss vor provisionierung registriert sein")
ErrInvalidCredential = errors.New("moduleregistry: ungueltiges oder fehlendes service-credential")
)
// Provision stellt ein Service-Credential (Client-ID + Secret) fuer eine
// Modul-Instanz aus (Akzeptanzkriterium 4). Das Secret wird NUR beim
// Ausstellen im Klartext zurueckgegeben, gespeichert wird ausschliesslich
// dessen SHA-256-Hash.
func (r *Registry) Provision(ctx context.Context, moduleName string) (clientID, secret string, err error) {
if _, err := r.Get(ctx, moduleName); err != nil {
if errors.Is(err, ErrModuleNotFound) {
return "", "", ErrModuleNotRegistered
}
return "", "", err
}
clientID, err = randomToken(16)
if err != nil {
return "", "", fmt.Errorf("client-id erzeugen: %w", err)
}
secret, err = randomToken(32)
if err != nil {
return "", "", fmt.Errorf("secret erzeugen: %w", err)
}
hash := hashSecret(secret)
_, err = r.pool.Exec(ctx, `
INSERT INTO module_credentials (module_name, client_id, secret_hash, issued_at)
VALUES ($1, $2, $3, now())
ON CONFLICT (module_name) DO UPDATE SET client_id = $2, secret_hash = $3, issued_at = now()
`, moduleName, clientID, hash)
if err != nil {
return "", "", fmt.Errorf("credential speichern: %w", err)
}
return clientID, secret, nil
}
// Authenticate prueft ein Service-Credential timing-safe (Referenzmuster
// siehe AUD-02) — Aufrufe ohne gueltiges Credential werden abgelehnt
// (Akzeptanzkriterium 4 / Pruefung 4).
func (r *Registry) Authenticate(ctx context.Context, clientID, secret string) (moduleName string, ok bool, err error) {
if clientID == "" || secret == "" {
return "", false, nil
}
var storedHash []byte
err = r.pool.QueryRow(ctx, `
SELECT module_name, secret_hash FROM module_credentials WHERE client_id = $1
`, clientID).Scan(&moduleName, &storedHash)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return "", false, nil
}
return "", false, fmt.Errorf("credential lesen: %w", err)
}
if !timingSafeEqual(hashSecret(secret), storedHash) {
return "", false, nil
}
return moduleName, true, nil
}
func randomToken(n int) (string, error) {
buf := make([]byte, n)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return hex.EncodeToString(buf), nil
}
func hashSecret(secret string) []byte {
sum := sha256.Sum256([]byte(secret))
return sum[:]
}
// timingSafeEqual folgt derselben Referenzimplementierung wie AUD-02
// (subtle.ConstantTimeCompare) — projektweite Konvention fuer jeden
// sicherheitsrelevanten Vergleich.
func timingSafeEqual(a, b []byte) bool {
if len(a) != len(b) {
return false
}
return subtle.ConstantTimeCompare(a, b) == 1
}
+46
View File
@@ -0,0 +1,46 @@
package moduleregistry
import "net/http"
// RequireActiveModule weist Anfragen an ein nicht aktiviertes Modul ZENTRAL
// ab, bevor der eigentliche Modul-Handler erreicht wird (Akzeptanzkriterium 2 /
// Pruefung 1) — Casbin-Prinzip: Durchsetzung als Middleware statt verstreuter
// Pruefungen in jedem Handler. tenantSlug/moduleName werden hier ueber
// Query-Parameter gelesen (echte Extraktion aus JWT/Tenant-Kontext ist
// API-05/TEN-06, nicht Teil dieser Kachel).
func (r *Registry) RequireActiveModule(moduleName string, next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
tenantSlug := req.URL.Query().Get("tenant")
active, err := r.IsActive(req.Context(), tenantSlug, moduleName)
if err != nil {
http.Error(w, "aktivierungspruefung fehlgeschlagen", http.StatusInternalServerError)
return
}
if !active {
http.Error(w, "modul nicht aktiviert", http.StatusForbidden)
return
}
next(w, req)
}
}
// RequireServiceCredential authentifiziert eine Modul-Instanz ueber ihr
// Service-Credential (X-Client-Id/X-Client-Secret-Header) BEVOR der
// eigentliche Handler erreicht wird (Akzeptanzkriterium 4 / Pruefung 4).
func (r *Registry) RequireServiceCredential(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
clientID := req.Header.Get("X-Client-Id")
secret := req.Header.Get("X-Client-Secret")
_, ok, err := r.Authenticate(req.Context(), clientID, secret)
if err != nil {
http.Error(w, "authentifizierung fehlgeschlagen", http.StatusInternalServerError)
return
}
if !ok {
http.Error(w, ErrInvalidCredential.Error(), http.StatusUnauthorized)
return
}
next(w, req)
}
}
+120
View File
@@ -0,0 +1,120 @@
// Package moduleregistry implementiert Core API-02: die Registry, in der
// sich Fachmodule (DMS, Mail, weitere) mit Metadaten eintragen, gekoppelt an
// die Aktivierungspruefung aus LIC-02 (Feature-Flags). Zusaetzlich
// authentifiziert die Registry Modul-Instanzen selbst ueber ein bei
// Provisionierung ausgestelltes Service-Credential.
package moduleregistry
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/internal/flag"
)
var (
ErrMissingName = errors.New("moduleregistry: name darf nicht leer sein")
ErrMissingVersion = errors.New("moduleregistry: version darf nicht leer sein")
ErrModuleNotFound = errors.New("moduleregistry: modul nicht registriert")
)
type Module struct {
Name string
Version string
RequiredFlags []string
}
type Registry struct {
pool *pgxpool.Pool
flags *flag.Service
}
func NewRegistry(pool *pgxpool.Pool, flags *flag.Service) *Registry {
return &Registry{pool: pool, flags: flags}
}
// Register traegt ein Modul mit Name, Version und benoetigten Feature-Flags
// ein (Akzeptanzkriterium 1). Fehlende Pflichtangaben werden abgewiesen
// (Akzeptanzkriterium 1 / Pruefung 2). Erneutes Register desselben Namens
// aktualisiert Version/Flags (Redeploy-Fall).
func (r *Registry) Register(ctx context.Context, name, version string, requiredFlags []string) (Module, error) {
if name == "" {
return Module{}, ErrMissingName
}
if version == "" {
return Module{}, ErrMissingVersion
}
if requiredFlags == nil {
requiredFlags = []string{}
}
_, err := r.pool.Exec(ctx, `
INSERT INTO modules (name, version, required_flags, registered_at)
VALUES ($1, $2, $3, now())
ON CONFLICT (name) DO UPDATE SET version = $2, required_flags = $3, registered_at = now()
`, name, version, requiredFlags)
if err != nil {
return Module{}, fmt.Errorf("modul registrieren: %w", err)
}
return Module{Name: name, Version: version, RequiredFlags: requiredFlags}, nil
}
func (r *Registry) Get(ctx context.Context, name string) (Module, error) {
var m Module
m.Name = name
err := r.pool.QueryRow(ctx, `
SELECT version, required_flags FROM modules WHERE name = $1
`, name).Scan(&m.Version, &m.RequiredFlags)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return Module{}, ErrModuleNotFound
}
return Module{}, fmt.Errorf("modul lesen: %w", err)
}
return m, nil
}
// List liefert alle registrierten Module (Akzeptanzkriterium 3: ueber API
// abfragbar, z.B. fuer Statusseite/Lizenzoberflaeche).
func (r *Registry) List(ctx context.Context) ([]Module, error) {
rows, err := r.pool.Query(ctx, `SELECT name, version, required_flags FROM modules ORDER BY name`)
if err != nil {
return nil, fmt.Errorf("module auflisten: %w", err)
}
defer rows.Close()
var out []Module
for rows.Next() {
var m Module
if err := rows.Scan(&m.Name, &m.Version, &m.RequiredFlags); err != nil {
return nil, fmt.Errorf("modul lesen: %w", err)
}
out = append(out, m)
}
return out, rows.Err()
}
// IsActive prueft, ob ein registriertes Modul fuer einen Tenant aktiviert
// ist: registriert UND alle benoetigten Feature-Flags sind fuer diesen
// Tenant aktiv (Akzeptanzkriterium 2). Ein nicht registriertes Modul gilt
// immer als nicht aktiv.
func (r *Registry) IsActive(ctx context.Context, tenantSlug, moduleName string) (bool, error) {
m, err := r.Get(ctx, moduleName)
if err != nil {
if errors.Is(err, ErrModuleNotFound) {
return false, nil
}
return false, err
}
for _, flagKey := range m.RequiredFlags {
if !r.flags.IsEnabled(ctx, tenantSlug, flagKey) {
return false, nil
}
}
return true, nil
}
+304
View File
@@ -0,0 +1,304 @@
package moduleregistry
import (
"context"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"os"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/internal/flag"
)
func setupTest(t *testing.T) (*Registry, *flag.Store, func()) {
t.Helper()
adminDSN := os.Getenv("TEST_ADMIN_DSN")
if adminDSN == "" {
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, adminDSN)
if err != nil {
t.Fatalf("pool: %v", err)
}
if _, err := pool.Exec(ctx, `
CREATE TABLE IF NOT EXISTS feature_flags (
key TEXT PRIMARY KEY, enabled BOOLEAN NOT NULL DEFAULT false,
rollout_percentage INT NOT NULL DEFAULT 0, target_tenant_slugs TEXT[] NOT NULL DEFAULT '{}',
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS modules (
name TEXT PRIMARY KEY, version TEXT NOT NULL CHECK (version <> ''),
required_flags TEXT[] NOT NULL DEFAULT '{}', registered_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS module_credentials (
module_name TEXT PRIMARY KEY REFERENCES modules(name),
client_id TEXT NOT NULL UNIQUE, secret_hash BYTEA NOT NULL,
issued_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
`); err != nil {
t.Fatalf("schema: %v", err)
}
flagStore := flag.NewStore(pool)
// Kurze TTL, damit Tests, die den Flag-Store direkt aendern (an
// Registry.IsActive vorbei), den neuen Stand ohne manuelles Invalidate
// zuverlaessig sehen.
flagService := flag.NewService(flagStore, 10*time.Millisecond)
registry := NewRegistry(pool, flagService)
cleanup := func() { pool.Close() }
return registry, flagStore, cleanup
}
func uniqueModuleName(t *testing.T) string {
return fmt.Sprintf("dms_%d", time.Now().UnixNano())
}
// Akzeptanzkriterium 1 + Pruefung 2: fehlende Pflichtangaben abgewiesen.
func TestRegister_RejectsMissingFields(t *testing.T) {
registry, _, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
if _, err := registry.Register(ctx, "", "1.0", nil); !errors.Is(err, ErrMissingName) {
t.Fatalf("erwartet ErrMissingName, habe %v", err)
}
if _, err := registry.Register(ctx, "dms", "", nil); !errors.Is(err, ErrMissingVersion) {
t.Fatalf("erwartet ErrMissingVersion, habe %v", err)
}
}
func TestRegister_AndGet(t *testing.T) {
registry, _, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
name := uniqueModuleName(t)
m, err := registry.Register(ctx, name, "1.2.0", []string{"dms_enabled"})
if err != nil {
t.Fatalf("register: %v", err)
}
if m.Version != "1.2.0" || len(m.RequiredFlags) != 1 {
t.Fatalf("unerwartet: %+v", m)
}
got, err := registry.Get(ctx, name)
if err != nil {
t.Fatalf("get: %v", err)
}
if got.Version != "1.2.0" {
t.Fatalf("get version = %q", got.Version)
}
}
// Akzeptanzkriterium 2 + 3 + Pruefung 3: konsistente Daten nach
// Aktivierung/Deaktivierung eines Moduls.
func TestIsActive_ReflectsFlagStateConsistently(t *testing.T) {
registry, flagStore, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
name := uniqueModuleName(t)
flagKey := name + "_enabled"
if _, err := registry.Register(ctx, name, "1.0", []string{flagKey}); err != nil {
t.Fatalf("register: %v", err)
}
active, err := registry.IsActive(ctx, "acme", name)
if err != nil {
t.Fatalf("is active (vor flag): %v", err)
}
if active {
t.Fatal("erwartet nicht aktiv, solange flag nicht gesetzt ist")
}
if err := flagStore.Set(ctx, flag.Flag{Key: flagKey, Enabled: true}); err != nil {
t.Fatalf("flag setzen: %v", err)
}
time.Sleep(20 * time.Millisecond) // TTL abwarten
active, err = registry.IsActive(ctx, "acme", name)
if err != nil {
t.Fatalf("is active (nach flag an): %v", err)
}
if !active {
t.Fatal("erwartet aktiv, nachdem flag aktiviert wurde")
}
if err := flagStore.Set(ctx, flag.Flag{Key: flagKey, Enabled: false}); err != nil {
t.Fatalf("flag zuruecksetzen: %v", err)
}
time.Sleep(20 * time.Millisecond) // TTL abwarten
active, err = registry.IsActive(ctx, "acme", name)
if err != nil {
t.Fatalf("is active (nach flag aus): %v", err)
}
if active {
t.Fatal("erwartet wieder nicht aktiv, nachdem flag deaktiviert wurde")
}
}
func TestIsActive_UnregisteredModuleIsNeverActive(t *testing.T) {
registry, _, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
active, err := registry.IsActive(ctx, "acme", "nie-registriert")
if err != nil {
t.Fatalf("is active: %v", err)
}
if active {
t.Fatal("unregistriertes modul darf nie aktiv sein")
}
}
// Akzeptanzkriterium 2 + Pruefung 1: Anfrage an deaktiviertes Modul wird
// zentral abgewiesen, BEVOR die Modul-Logik erreicht wird.
func TestRequireActiveModule_BlocksBeforeHandler(t *testing.T) {
registry, flagStore, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
name := uniqueModuleName(t)
flagKey := name + "_enabled"
if _, err := registry.Register(ctx, name, "1.0", []string{flagKey}); err != nil {
t.Fatalf("register: %v", err)
}
handlerReached := false
handler := registry.RequireActiveModule(name, func(w http.ResponseWriter, r *http.Request) {
handlerReached = true
w.WriteHeader(http.StatusOK)
})
req := httptest.NewRequest(http.MethodGet, "/modul?tenant=acme", nil)
rec := httptest.NewRecorder()
handler(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403", rec.Code)
}
if handlerReached {
t.Fatal("handler haette bei deaktiviertem modul NICHT erreicht werden duerfen")
}
if err := flagStore.Set(ctx, flag.Flag{Key: flagKey, Enabled: true}); err != nil {
t.Fatalf("flag setzen: %v", err)
}
req2 := httptest.NewRequest(http.MethodGet, "/modul?tenant=acme", nil)
rec2 := httptest.NewRecorder()
handler(rec2, req2)
if rec2.Code != http.StatusOK {
t.Fatalf("status nach aktivierung = %d, want 200", rec2.Code)
}
if !handlerReached {
t.Fatal("handler haette bei aktiviertem modul erreicht werden muessen")
}
}
// Akzeptanzkriterium 4 + Pruefung 4: gueltiges/ungueltiges Service-Credential.
func TestProvisionAndAuthenticate(t *testing.T) {
registry, _, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
name := uniqueModuleName(t)
if _, err := registry.Register(ctx, name, "1.0", nil); err != nil {
t.Fatalf("register: %v", err)
}
clientID, secret, err := registry.Provision(ctx, name)
if err != nil {
t.Fatalf("provision: %v", err)
}
if clientID == "" || secret == "" {
t.Fatal("erwartet nicht-leere client-id/secret")
}
moduleName, ok, err := registry.Authenticate(ctx, clientID, secret)
if err != nil {
t.Fatalf("authenticate (korrekt): %v", err)
}
if !ok || moduleName != name {
t.Fatalf("erwartet erfolgreiche authentifizierung fuer %q, habe ok=%v moduleName=%q", name, ok, moduleName)
}
_, ok, err = registry.Authenticate(ctx, clientID, "falsches-secret")
if err != nil {
t.Fatalf("authenticate (falsch): %v", err)
}
if ok {
t.Fatal("erwartet fehlschlag bei falschem secret")
}
_, ok, err = registry.Authenticate(ctx, "unbekannte-client-id", secret)
if err != nil {
t.Fatalf("authenticate (unbekannt): %v", err)
}
if ok {
t.Fatal("erwartet fehlschlag bei unbekannter client-id")
}
}
func TestProvision_RequiresRegisteredModule(t *testing.T) {
registry, _, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
if _, _, err := registry.Provision(ctx, "nie-registriert"); !errors.Is(err, ErrModuleNotRegistered) {
t.Fatalf("erwartet ErrModuleNotRegistered, habe %v", err)
}
}
func TestRequireServiceCredential_RejectsInvalidAcceptsValid(t *testing.T) {
registry, _, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
name := uniqueModuleName(t)
if _, err := registry.Register(ctx, name, "1.0", nil); err != nil {
t.Fatalf("register: %v", err)
}
clientID, secret, err := registry.Provision(ctx, name)
if err != nil {
t.Fatalf("provision: %v", err)
}
handler := registry.RequireServiceCredential(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
// Fehlendes Credential.
req := httptest.NewRequest(http.MethodPost, "/service-aufruf", nil)
rec := httptest.NewRecorder()
handler(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("ohne credential: status = %d, want 401", rec.Code)
}
// Falsches Secret.
req2 := httptest.NewRequest(http.MethodPost, "/service-aufruf", nil)
req2.Header.Set("X-Client-Id", clientID)
req2.Header.Set("X-Client-Secret", "falsch")
rec2 := httptest.NewRecorder()
handler(rec2, req2)
if rec2.Code != http.StatusUnauthorized {
t.Fatalf("falsches secret: status = %d, want 401", rec2.Code)
}
// Gueltiges Credential.
req3 := httptest.NewRequest(http.MethodPost, "/service-aufruf", nil)
req3.Header.Set("X-Client-Id", clientID)
req3.Header.Set("X-Client-Secret", secret)
rec3 := httptest.NewRecorder()
handler(rec3, req3)
if rec3.Code != http.StatusOK {
t.Fatalf("gueltiges credential: status = %d, want 200", rec3.Code)
}
}
+158
View File
@@ -0,0 +1,158 @@
// Package mtls implementiert Core API-09: gegenseitige TLS-Authentifizierung
// zwischen Core/DMS/Mail/Archive/Workflow/AI/Connect-Instanzen fuer
// Stufe-2/3-Installationen (siehe SKALIERUNGSKONZEPT.md) — schuetzt die
// VERTRAULICHKEIT des Transportwegs, dort wo API-05 (asymmetrische
// JWT-Signatur) und API-02 (Service-Credential) nur Authentizitaet/
// Integritaet, aber keine Transportverschluesselung zwischen getrennten
// Hosts absichern.
//
// Zertifikatsverteilung (Akzeptanzkriterium 1): Authority.IssueCert liefert
// PEM-kodiertes Zertifikat + privaten Schluessel, die ueber denselben Weg
// wie andere Secrets verteilt werden (Umgebungsvariablen/Secret-Store,
// niemals im Code) — automatisierbar, da IssueCert ein reiner
// Funktionsaufruf ohne manuelle Schritte ist.
package mtls
import (
"crypto/ed25519"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"fmt"
"math/big"
"net"
"time"
)
// Authority ist eine interne Zertifizierungsstelle fuer Modul-zu-Modul-
// mTLS. Haelt — analog zu internal/moduletrust.KeyManager (API-05) — ALLE
// noch gueltigen historischen CA-Zertifikate im Vertrauensspeicher, damit
// eine Rotation bereits ausgestellte Leaf-Zertifikate nicht ungueltig macht
// (Akzeptanzkriterium 3: Rotation ohne Ausfallzeit).
type Authority struct {
current caGeneration
trustPool *x509.CertPool
generations []caGeneration
}
type caGeneration struct {
cert *x509.Certificate
key ed25519.PrivateKey
}
// NewAuthority erzeugt eine frische interne CA.
func NewAuthority() (*Authority, error) {
gen, err := newCAGeneration()
if err != nil {
return nil, err
}
pool := x509.NewCertPool()
pool.AddCert(gen.cert)
return &Authority{current: gen, trustPool: pool, generations: []caGeneration{gen}}, nil
}
func newCAGeneration() (caGeneration, error) {
pub, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
return caGeneration{}, fmt.Errorf("ca-schluesselpaar erzeugen: %w", err)
}
serial, err := rand.Int(rand.Reader, big.NewInt(1<<62))
if err != nil {
return caGeneration{}, fmt.Errorf("seriennummer erzeugen: %w", err)
}
template := &x509.Certificate{
SerialNumber: serial,
Subject: pkix.Name{CommonName: "nexarch-internal-ca"},
NotBefore: time.Now().Add(-time.Minute),
NotAfter: time.Now().Add(5 * 365 * 24 * time.Hour),
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
BasicConstraintsValid: true,
IsCA: true,
}
der, err := x509.CreateCertificate(rand.Reader, template, template, pub, priv)
if err != nil {
return caGeneration{}, fmt.Errorf("ca-zertifikat erstellen: %w", err)
}
cert, err := x509.ParseCertificate(der)
if err != nil {
return caGeneration{}, fmt.Errorf("ca-zertifikat parsen: %w", err)
}
return caGeneration{cert: cert, key: priv}, nil
}
// Rotate erzeugt eine NEUE CA-Generation fuer zukuenftige IssueCert-Aufrufe,
// behaelt aber ALLE bisherigen CA-Zertifikate im Vertrauensspeicher —
// bereits ausgestellte Leaf-Zertifikate bleiben dadurch gueltig, eine
// laufende mTLS-Verbindung wird durch Rotate NICHT unterbrochen
// (Akzeptanzkriterium 3 / Pruefung 2).
func (a *Authority) Rotate() error {
gen, err := newCAGeneration()
if err != nil {
return err
}
a.trustPool.AddCert(gen.cert)
a.generations = append(a.generations, gen)
a.current = gen
return nil
}
// TrustPool liefert den Vertrauensspeicher mit ALLEN (auch historischen,
// noch nicht abgelaufenen) CA-Zertifikaten — Grundlage der
// Server-seitigen Client-Zertifikatspruefung (Akzeptanzkriterium 2).
func (a *Authority) TrustPool() *x509.CertPool {
return a.trustPool
}
// IssuedCert ist ein ausgestelltes Leaf-Zertifikat inklusive privatem
// Schluessel, PEM-kodiert zur Verteilung (Akzeptanzkriterium 1).
type IssuedCert struct {
CertPEM []byte
KeyPEM []byte
}
// IssueCert stellt ein Leaf-Zertifikat fuer EINE Modul-Instanz aus, signiert
// mit der AKTUELLEN CA-Generation (Akzeptanzkriterium 1).
func (a *Authority) IssueCert(commonName string, validity time.Duration) (IssuedCert, error) {
pub, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
return IssuedCert{}, fmt.Errorf("leaf-schluesselpaar erzeugen: %w", err)
}
serial, err := rand.Int(rand.Reader, big.NewInt(1<<62))
if err != nil {
return IssuedCert{}, fmt.Errorf("seriennummer erzeugen: %w", err)
}
template := &x509.Certificate{
SerialNumber: serial,
Subject: pkix.Name{CommonName: commonName},
NotBefore: time.Now().Add(-time.Minute),
NotAfter: time.Now().Add(validity),
KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth, x509.ExtKeyUsageServerAuth},
// DNSNames/IPAddresses decken lokale Testumgebungen ab (127.0.0.1,
// localhost) — in echten Stufe-2/3-Installationen entspricht
// commonName dem tatsaechlichen internen Hostnamen der Instanz.
DNSNames: []string{commonName, "localhost"},
IPAddresses: []net.IP{net.IPv4(127, 0, 0, 1), net.IPv6loopback},
}
der, err := x509.CreateCertificate(rand.Reader, template, a.current.cert, pub, a.current.key)
if err != nil {
return IssuedCert{}, fmt.Errorf("leaf-zertifikat erstellen: %w", err)
}
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
keyBytes, err := x509.MarshalPKCS8PrivateKey(priv)
if err != nil {
return IssuedCert{}, fmt.Errorf("leaf-schluessel serialisieren: %w", err)
}
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyBytes})
return IssuedCert{CertPEM: certPEM, KeyPEM: keyPEM}, nil
}
// TLSCertificate wandelt ein IssuedCert in ein tls.Certificate um, wie es
// tls.Config.Certificates erwartet.
func (c IssuedCert) TLSCertificate() (tls.Certificate, error) {
return tls.X509KeyPair(c.CertPEM, c.KeyPEM)
}
+46
View File
@@ -0,0 +1,46 @@
package mtls
import "crypto/tls"
// Mode legt fest, ob mTLS erzwungen wird. Fuer Stufe-1-Einzel-LXC ist mTLS
// laut Ticket "optional/nicht zwingend" (lokale Kommunikation) — ModeOff
// liefert einen ganz normalen TLS-Server OHNE Client-Zertifikatspruefung,
// damit Stufe-1-Betrieb unveraendert weiterlaeuft (Akzeptanzkriterium 3 /
// Pruefung 3).
type Mode string
const (
ModeOff Mode = "off" // Stufe 1: kein mTLS-Zwang
ModeRequired Mode = "required" // Stufe 2/3: Client-Zertifikat zwingend
)
// ServerTLSConfig liefert die tls.Config fuer eine Modul-Instanz, die
// eingehende Verbindungen ANDERER Modul-Instanzen annimmt.
//
// - ModeRequired: verlangt UND verifiziert ein Client-Zertifikat gegen
// authority.TrustPool() (Akzeptanzkriterium 2) — eine Verbindung ohne
// gueltiges Zertifikat wird vom TLS-Handshake selbst abgelehnt, bevor
// irgendein Anwendungscode erreicht wird.
// - ModeOff: normales TLS ohne Client-Zertifikatspruefung
// (Akzeptanzkriterium 3 / Pruefung 3: Stufe-1-Betrieb funktioniert
// weiterhin ohne mTLS-Zwang).
func ServerTLSConfig(mode Mode, serverCert tls.Certificate, authority *Authority) *tls.Config {
cfg := &tls.Config{Certificates: []tls.Certificate{serverCert}}
if mode == ModeRequired {
cfg.ClientAuth = tls.RequireAndVerifyClientCert
cfg.ClientCAs = authority.TrustPool()
}
return cfg
}
// ClientTLSConfig liefert die tls.Config fuer eine Modul-Instanz, die eine
// Verbindung zu einer ANDEREN Modul-Instanz aufbaut — praesentiert das
// eigene Zertifikat und vertraut Gegenstellen, die von derselben Authority
// signiert wurden.
func ClientTLSConfig(mode Mode, clientCert tls.Certificate, authority *Authority) *tls.Config {
cfg := &tls.Config{RootCAs: authority.TrustPool()}
if mode == ModeRequired {
cfg.Certificates = []tls.Certificate{clientCert}
}
return cfg
}
+185
View File
@@ -0,0 +1,185 @@
package mtls
import (
"crypto/tls"
"io"
"net/http"
"net/http/httptest"
"testing"
"time"
)
func newTLSServer(t *testing.T, cfg *tls.Config) *httptest.Server {
t.Helper()
server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
}))
server.TLS = cfg
server.StartTLS()
return server
}
func fetch(t *testing.T, url string, clientCfg *tls.Config) (int, error) {
t.Helper()
client := &http.Client{
Transport: &http.Transport{TLSClientConfig: clientCfg},
Timeout: 3 * time.Second,
}
resp, err := client.Get(url)
if err != nil {
return 0, err
}
defer resp.Body.Close()
_, _ = io.ReadAll(resp.Body)
return resp.StatusCode, nil
}
// Akzeptanzkriterium 1: Zertifikatsverteilung ist ein reiner
// Funktionsaufruf, das Ergebnis ist gueltiges, wiederverwendbares
// PEM-Material.
func TestIssueCert_ProducesValidPEMRoundTrip(t *testing.T) {
authority, err := NewAuthority()
if err != nil {
t.Fatalf("newauthority: %v", err)
}
issued, err := authority.IssueCert("dms", time.Hour)
if err != nil {
t.Fatalf("issuecert: %v", err)
}
if _, err := issued.TLSCertificate(); err != nil {
t.Fatalf("ausgestelltes zertifikat nicht als tls.Certificate ladbar: %v", err)
}
}
// Akzeptanzkriterium 2 + Pruefung 1: eine Verbindung ohne gueltiges
// Client-Zertifikat wird abgelehnt, eine mit gueltigem angenommen.
func TestServerTLSConfig_RejectsMissingClientCert(t *testing.T) {
authority, err := NewAuthority()
if err != nil {
t.Fatalf("newauthority: %v", err)
}
serverIssued, err := authority.IssueCert("core", time.Hour)
if err != nil {
t.Fatalf("server-zertifikat ausstellen: %v", err)
}
serverCert, err := serverIssued.TLSCertificate()
if err != nil {
t.Fatalf("server-zertifikat laden: %v", err)
}
server := newTLSServer(t, ServerTLSConfig(ModeRequired, serverCert, authority))
defer server.Close()
// Client OHNE Zertifikat, aber mit korrektem RootCA-Vertrauen fuer den
// Server — der Handshake muss trotzdem an der fehlenden Client-Auth
// scheitern.
_, err = fetch(t, server.URL, &tls.Config{RootCAs: authority.TrustPool()})
if err == nil {
t.Fatal("erwartet fehlschlagenden handshake ohne client-zertifikat")
}
clientIssued, err := authority.IssueCert("mail", time.Hour)
if err != nil {
t.Fatalf("client-zertifikat ausstellen: %v", err)
}
clientCert, err := clientIssued.TLSCertificate()
if err != nil {
t.Fatalf("client-zertifikat laden: %v", err)
}
status, err := fetch(t, server.URL, ClientTLSConfig(ModeRequired, clientCert, authority))
if err != nil {
t.Fatalf("erwartet erfolgreiche verbindung mit gueltigem client-zertifikat: %v", err)
}
if status != http.StatusOK {
t.Fatalf("status = %d, want 200", status)
}
}
// Akzeptanzkriterium 3 + Pruefung 2: Rotation der CA unterbricht laufenden
// Betrieb nicht — ein VOR der Rotation ausgestelltes Leaf-Zertifikat
// funktioniert danach weiter, UND ein NACH der Rotation neu ausgestelltes
// funktioniert ebenfalls, gegen denselben, weiterlaufenden Server (kein
// Neustart noetig, da TrustPool() denselben *x509.CertPool zurueckliefert,
// den Rotate() erweitert).
func TestAuthority_RotateWithoutDowntime(t *testing.T) {
authority, err := NewAuthority()
if err != nil {
t.Fatalf("newauthority: %v", err)
}
serverIssued, err := authority.IssueCert("core", time.Hour)
if err != nil {
t.Fatalf("server-zertifikat ausstellen: %v", err)
}
serverCert, err := serverIssued.TLSCertificate()
if err != nil {
t.Fatalf("server-zertifikat laden: %v", err)
}
server := newTLSServer(t, ServerTLSConfig(ModeRequired, serverCert, authority))
defer server.Close() // EIN Server-Prozess ueber die gesamte Rotation hinweg — kein Neustart.
preRotationIssued, err := authority.IssueCert("dms", time.Hour)
if err != nil {
t.Fatalf("client-zertifikat (vor rotation) ausstellen: %v", err)
}
preRotationCert, _ := preRotationIssued.TLSCertificate()
if err := authority.Rotate(); err != nil {
t.Fatalf("rotate: %v", err)
}
// VOR der Rotation ausgestelltes Zertifikat funktioniert WEITERHIN, ohne
// dass der Server neu gestartet wurde.
status, err := fetch(t, server.URL, ClientTLSConfig(ModeRequired, preRotationCert, authority))
if err != nil {
t.Fatalf("vor-rotation-zertifikat haette weiterhin funktionieren sollen: %v", err)
}
if status != http.StatusOK {
t.Fatalf("status = %d, want 200 (vor-rotation-zertifikat)", status)
}
// NACH der Rotation neu ausgestelltes Zertifikat funktioniert ebenfalls,
// gegen DENSELBEN laufenden Server.
postRotationIssued, err := authority.IssueCert("mail", time.Hour)
if err != nil {
t.Fatalf("client-zertifikat (nach rotation) ausstellen: %v", err)
}
postRotationCert, _ := postRotationIssued.TLSCertificate()
status, err = fetch(t, server.URL, ClientTLSConfig(ModeRequired, postRotationCert, authority))
if err != nil {
t.Fatalf("nach-rotation-zertifikat haette funktionieren sollen: %v", err)
}
if status != http.StatusOK {
t.Fatalf("status = %d, want 200 (nach-rotation-zertifikat)", status)
}
}
// Akzeptanzkriterium 3 + Pruefung 3: Stufe-1-Betrieb (ModeOff) funktioniert
// weiterhin OHNE mTLS-Zwang — ein Client ganz ohne Zertifikat kommt durch.
func TestServerTLSConfig_ModeOffWorksWithoutClientCert(t *testing.T) {
authority, err := NewAuthority()
if err != nil {
t.Fatalf("newauthority: %v", err)
}
serverIssued, err := authority.IssueCert("core", time.Hour)
if err != nil {
t.Fatalf("server-zertifikat ausstellen: %v", err)
}
serverCert, err := serverIssued.TLSCertificate()
if err != nil {
t.Fatalf("server-zertifikat laden: %v", err)
}
server := newTLSServer(t, ServerTLSConfig(ModeOff, serverCert, authority))
defer server.Close()
status, err := fetch(t, server.URL, ClientTLSConfig(ModeOff, tls.Certificate{}, authority))
if err != nil {
t.Fatalf("stufe-1-betrieb (ModeOff) haette ohne client-zertifikat funktionieren sollen: %v", err)
}
if status != http.StatusOK {
t.Fatalf("status = %d, want 200", status)
}
}
+732
View File
@@ -0,0 +1,732 @@
// Package pentest enthaelt QA-04 (Sicherheits- & Penetrationspruefung Core):
// gezielte Angriffsversuche gegen die sensibelsten Core-Bereiche - SSO/OIDC,
// Rate-Limiting/Account-Lockout im Mehrinstanz-Betrieb, zentrale
// Policy-Durchsetzung und Master-Key-/Tenant-KEK-Verwaltung - jeweils gegen
// die echten Produktionspakete, nicht gegen Mocks.
package pentest
import (
"context"
"crypto/ed25519"
"crypto/rand"
"crypto/rsa"
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/internal/kek"
"gitea.perlbach24.de/scripte/nexarch/internal/lockout"
"gitea.perlbach24.de/scripte/nexarch/internal/oidc"
"gitea.perlbach24.de/scripte/nexarch/internal/policy"
"gitea.perlbach24.de/scripte/nexarch/internal/ratelimit"
"gitea.perlbach24.de/scripte/nexarch/internal/rbac"
"gitea.perlbach24.de/scripte/nexarch/internal/tenant"
)
// ---------------------------------------------------------------------
// Testinfrastruktur (identisch zum Muster aus internal/e2e/helpers_test.go)
// ---------------------------------------------------------------------
func requireAdminDSN(t *testing.T) string {
t.Helper()
dsn := os.Getenv("TEST_ADMIN_DSN")
if dsn == "" {
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Pentest uebersprungen")
}
return dsn
}
func repoRoot(t *testing.T) string {
t.Helper()
wd, err := os.Getwd()
if err != nil {
t.Fatalf("getwd: %v", err)
}
return filepath.Join(wd, "..", "..")
}
func applyAllUpSQL(t *testing.T, ctx context.Context, pool *pgxpool.Pool, dir string) {
t.Helper()
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatalf("migrationsverzeichnis %q lesen: %v", dir, err)
}
var names []string
for _, e := range entries {
if !e.IsDir() && strings.HasSuffix(e.Name(), ".up.sql") {
names = append(names, e.Name())
}
}
sort.Strings(names)
for _, name := range names {
sqlBytes, err := os.ReadFile(filepath.Join(dir, name))
if err != nil {
t.Fatalf("migration %q lesen: %v", name, err)
}
if _, err := pool.Exec(ctx, string(sqlBytes)); err != nil {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && (pgErr.Code == "42P07" || pgErr.Code == "42701" || pgErr.Code == "42710") {
continue
}
t.Fatalf("migration %q anwenden: %v", name, err)
}
}
}
func setupRegistryPool(t *testing.T) (*pgxpool.Pool, string) {
t.Helper()
adminDSN := requireAdminDSN(t)
ctx := context.Background()
pool, err := pgxpool.New(ctx, adminDSN)
if err != nil {
t.Fatalf("registry pool: %v", err)
}
t.Cleanup(func() { pool.Close() })
applyAllUpSQL(t, ctx, pool, filepath.Join(repoRoot(t), "migrations"))
return pool, adminDSN
}
func uniqueSlug(prefix string) string {
return fmt.Sprintf("%s_%d", prefix, time.Now().UnixNano())
}
// =======================================================================
// Pruefung 2 / Akzeptanzkriterium 1: SSO/OIDC gegen Token-Manipulation,
// Replay und Claims-Manipulation.
// =======================================================================
// TestPentest_OIDC_AlgNoneAttack simuliert den klassischen "alg=none"-Angriff
// gegen den EXTERNEN OIDC-Verifier (IAM-06, internal/oidc.Verifier): ein
// Angreifer stellt sich selbst ein Token mit Signaturalgorithmus "none" aus,
// in der Hoffnung, dass eine naive JWT-Bibliothek die fehlende Signatur
// akzeptiert. internal/oidc.Verifier.Verify prueft explizit auf
// *jwt.SigningMethodRSA - "none" muss zuverlaessig abgewiesen werden.
func TestPentest_OIDC_AlgNoneAttack(t *testing.T) {
rsaKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("rsa key: %v", err)
}
verifier := oidc.NewVerifier(map[string]*rsa.PublicKey{"kid-1": &rsaKey.PublicKey}, "https://idp.example.test")
claims := jwt.MapClaims{
"sub": "angreifer@example.test",
"iss": "https://idp.example.test",
"exp": time.Now().Add(time.Hour).Unix(),
}
unsigned := jwt.NewWithClaims(jwt.SigningMethodNone, claims)
unsigned.Header["kid"] = "kid-1"
forged, err := unsigned.SignedString(jwt.UnsafeAllowNoneSignatureType)
if err != nil {
t.Fatalf("alg=none token faelschen: %v", err)
}
if _, err := verifier.Verify(forged); err == nil {
t.Fatal("alg=none-token wurde akzeptiert - kritische luecke")
}
}
// TestPentest_OIDC_ForeignKeySignature simuliert einen Angreifer, der ein
// Token mit einem EIGENEN RSA-Schluessel signiert und die kid eines
// bekannten, vertrauenswuerdigen Schluessels vorgibt (Schluessel-Confusion).
// Verify darf das Token nur mit dem TATSAECHLICH registrierten Public Key
// pruefen, niemals mit dem im Token mitgelieferten.
func TestPentest_OIDC_ForeignKeySignature(t *testing.T) {
trustedKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("trusted key: %v", err)
}
attackerKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("attacker key: %v", err)
}
verifier := oidc.NewVerifier(map[string]*rsa.PublicKey{"trusted-kid": &trustedKey.PublicKey}, "https://idp.example.test")
claims := jwt.MapClaims{
"sub": "angreifer@example.test",
"iss": "https://idp.example.test",
"exp": time.Now().Add(time.Hour).Unix(),
}
forgedToken := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
forgedToken.Header["kid"] = "trusted-kid" // gibt vor, mit dem bekannten Schluessel signiert zu sein
forged, err := forgedToken.SignedString(attackerKey)
if err != nil {
t.Fatalf("token mit fremdem schluessel signieren: %v", err)
}
if _, err := verifier.Verify(forged); err == nil {
t.Fatal("mit fremdem schluessel signiertes token wurde akzeptiert - kritische luecke")
}
}
// TestPentest_OIDC_TamperedClaims prueft, dass eine nachtraegliche
// Claims-Manipulation (z.B. sub oder roles aendern, ohne neu zu signieren)
// zuverlaessig an der Signaturpruefung scheitert.
func TestPentest_OIDC_TamperedClaims(t *testing.T) {
rsaKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("rsa key: %v", err)
}
verifier := oidc.NewVerifier(map[string]*rsa.PublicKey{"kid-1": &rsaKey.PublicKey}, "https://idp.example.test")
claims := jwt.MapClaims{
"sub": "normaler-benutzer@example.test",
"iss": "https://idp.example.test",
"roles": []string{"user"},
"exp": time.Now().Add(time.Hour).Unix(),
}
valid := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
valid.Header["kid"] = "kid-1"
signed, err := valid.SignedString(rsaKey)
if err != nil {
t.Fatalf("token signieren: %v", err)
}
// Gegenprobe: das unveraenderte, echte Token wird akzeptiert.
if _, err := verifier.Verify(signed); err != nil {
t.Fatalf("unveraendertes, echtes token wurde abgewiesen: %v", err)
}
// Claims-Manipulation: Payload-Segment durch eine Version mit
// erhoehter Rolle ersetzen, Signatur-Segment unveraendert lassen.
parts := strings.Split(signed, ".")
if len(parts) != 3 {
t.Fatalf("unerwartetes token-format: %d segmente", len(parts))
}
tamperedClaims := jwt.MapClaims{
"sub": "normaler-benutzer@example.test",
"iss": "https://idp.example.test",
"roles": []string{"superadmin"}, // Rechteausweitung
"exp": time.Now().Add(time.Hour).Unix(),
}
tamperedHeader := jwt.NewWithClaims(jwt.SigningMethodRS256, tamperedClaims)
tamperedHeader.Header["kid"] = "kid-1"
tamperedUnsigned, err := tamperedHeader.SigningString()
if err != nil {
t.Fatalf("manipulierten payload kodieren: %v", err)
}
tamperedParts := strings.Split(tamperedUnsigned, ".")
forged := tamperedParts[0] + "." + tamperedParts[1] + "." + parts[2] // fremder payload + ALTE signatur
if _, err := verifier.Verify(forged); err == nil {
t.Fatal("token mit manipulierten claims (rechteausweitung) wurde akzeptiert - kritische luecke")
}
}
// TestPentest_OIDC_NonceReplay ist Pruefung 2 (Replay-Schutz): ein gueltiges,
// korrekt signiertes ID-Token eines FRUEHEREN Logins darf nicht fuer einen
// neuen Login-Vorgang mit einem anderen erwarteten Nonce wiederverwendet
// werden koennen.
func TestPentest_OIDC_NonceReplay(t *testing.T) {
rsaKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("rsa key: %v", err)
}
verifier := oidc.NewVerifier(map[string]*rsa.PublicKey{"kid-1": &rsaKey.PublicKey}, "https://idp.example.test")
claims := jwt.MapClaims{
"sub": "benutzer@example.test",
"iss": "https://idp.example.test",
"nonce": "nonce-aus-login-versuch-1",
"exp": time.Now().Add(time.Hour).Unix(),
}
valid := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
valid.Header["kid"] = "kid-1"
signed, err := valid.SignedString(rsaKey)
if err != nil {
t.Fatalf("token signieren: %v", err)
}
// Der urspruengliche Login-Versuch (passender Nonce) ist gueltig.
if _, err := verifier.VerifyWithNonce(signed, "nonce-aus-login-versuch-1"); err != nil {
t.Fatalf("token mit korrektem nonce wurde abgewiesen: %v", err)
}
// Ein Angreifer faengt das Token ab und spielt es bei einem NEUEN
// Login-Versuch (mit eigenem, anderem erwarteten Nonce) erneut ein.
if _, err := verifier.VerifyWithNonce(signed, "nonce-aus-login-versuch-2"); err == nil {
t.Fatal("token-replay mit abweichendem nonce wurde akzeptiert - kritische luecke")
}
}
// TestPentest_OIDC_IdPTokenManipulation prueft den Weg, bei dem NEXARCH
// selbst als OIDC-Provider fuer Drittanwendungen auftritt (IAM-13,
// internal/oidc.Handler.JWKS + issueIDToken, Ed25519/EdDSA). Ein Angreifer
// versucht, ein selbst signiertes ID-Token gegen den echten, aus dem
// JWKS-Endpunkt gelesenen Schluessel zu verifizieren.
func TestPentest_OIDC_IdPTokenManipulation(t *testing.T) {
genuinePub, genuinePriv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("echtes schluesselpaar erzeugen: %v", err)
}
attackerPub, attackerPriv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("angreifer-schluesselpaar erzeugen: %v", err)
}
_ = attackerPub
claims := oidc.IDTokenClaims{
Subject: "opfer-user-id",
TenantSlug: "opfer-tenant",
Scopes: []string{"openid"},
RegisteredClaims: jwt.RegisteredClaims{
Issuer: "https://core.nexarch.example",
Audience: jwt.ClaimStrings{"drittanwendung-client-id"},
IssuedAt: jwt.NewNumericDate(time.Now()),
ExpiresAt: jwt.NewNumericDate(time.Now().Add(15 * time.Minute)),
},
}
forged := jwt.NewWithClaims(jwt.SigningMethodEdDSA, claims)
forged.Header["kid"] = "genuine-kid" // gibt vor, mit dem echten Schluessel signiert zu sein
forgedSigned, err := forged.SignedString(attackerPriv)
if err != nil {
t.Fatalf("token mit angreiferschluessel signieren: %v", err)
}
// Verifikation exakt wie eine Drittanwendung sie gegen den echten,
// aus dem JWKS-Endpunkt bezogenen Schluessel durchfuehren wuerde.
verify := func(tokenString string, kid2key map[string]ed25519.PublicKey) error {
parsed, err := jwt.Parse(tokenString, func(tk *jwt.Token) (interface{}, error) {
if _, ok := tk.Method.(*jwt.SigningMethodEd25519); !ok {
return nil, fmt.Errorf("unerwarteter algorithmus: %v", tk.Header["alg"])
}
kid, _ := tk.Header["kid"].(string)
pub, ok := kid2key[kid]
if !ok {
return nil, fmt.Errorf("unbekannte kid %q", kid)
}
return pub, nil
})
if err != nil || !parsed.Valid {
return fmt.Errorf("ungueltig: %w", err)
}
return nil
}
trustedKeys := map[string]ed25519.PublicKey{"genuine-kid": genuinePub}
if err := verify(forgedSigned, trustedKeys); err == nil {
t.Fatal("mit angreiferschluessel signiertes idp-token wurde akzeptiert - kritische luecke")
}
// Gegenprobe: ein echtes, mit dem tatsaechlichen Schluessel signiertes
// Token muss weiterhin akzeptiert werden (keine False-Positive-Abwehr).
genuine := jwt.NewWithClaims(jwt.SigningMethodEdDSA, claims)
genuine.Header["kid"] = "genuine-kid"
genuineSigned, err := genuine.SignedString(genuinePriv)
if err != nil {
t.Fatalf("echtes token signieren: %v", err)
}
if err := verify(genuineSigned, trustedKeys); err != nil {
t.Fatalf("echtes, korrekt signiertes idp-token wurde abgewiesen: %v", err)
}
}
// =======================================================================
// Pruefung 1 / Akzeptanzkriterium 2: Rate-Limiting/Account-Lockout im
// Mehrinstanz-Betrieb nachweislich nicht umgehbar.
// =======================================================================
// TestPentest_RateLimit_MultiInstanceBypassAttempt simuliert einen
// Angreifer, der versucht, das Rate-Limit zu umgehen, indem er seine
// Anfragen ueber MEHRERE Core-Instanzen (hier: mehrere unabhaengige
// pgxpool.Pool-Verbindungen zur selben Postgres-Instanz, wie es getrennte
// Core-Prozesse waeren) verteilt, in der Annahme, jede Instanz fuehre einen
// eigenen In-Process-Zaehler. Da ratelimit.Store atomar gegen dieselbe
// geteilte Tabelle arbeitet (API-03), darf die Gesamtsumme der erlaubten
// Anfragen ueber ALLE "Instanzen" hinweg das konfigurierte Limit nicht
// uebersteigen.
func TestPentest_RateLimit_MultiInstanceBypassAttempt(t *testing.T) {
_, adminDSN := setupRegistryPool(t)
ctx := context.Background()
key := uniqueSlug("pentest-ratelimit")
const instanceCount = 4
const requestsPerInstance = 50
const limit = 100
// Eine eigene Konfiguration ueber EINE der "Instanzen" gesetzt - muss
// fuer alle anderen sofort sichtbar sein (geteilter Zustand).
configPool, err := pgxpool.New(ctx, adminDSN)
if err != nil {
t.Fatalf("config pool: %v", err)
}
defer configPool.Close()
if err := ratelimit.NewStore(configPool).SetLimit(ctx, key, limit, time.Minute); err != nil {
t.Fatalf("limit setzen: %v", err)
}
var allowed int64
var wg sync.WaitGroup
for i := 0; i < instanceCount; i++ {
wg.Add(1)
go func() {
defer wg.Done()
// Jede "Instanz" hat einen EIGENEN Pool - simuliert getrennte
// Prozesse, die sich keinen In-Memory-Zustand teilen.
instancePool, err := pgxpool.New(ctx, adminDSN)
if err != nil {
t.Errorf("instanz-pool: %v", err)
return
}
defer instancePool.Close()
store := ratelimit.NewStore(instancePool)
for j := 0; j < requestsPerInstance; j++ {
res, err := store.Allow(ctx, key)
if err != nil {
t.Errorf("allow: %v", err)
return
}
if res.Allowed {
atomic.AddInt64(&allowed, 1)
}
}
}()
}
wg.Wait()
totalRequests := int64(instanceCount * requestsPerInstance)
if allowed > limit {
t.Fatalf("mehrinstanz-umgehungsversuch erfolgreich: %d von %d anfragen ueber %d instanzen erlaubt, konfiguriertes limit war %d",
allowed, totalRequests, instanceCount, limit)
}
if allowed != limit {
t.Fatalf("erwartet exakt %d erlaubte anfragen (limit ausgeschoepft, nicht mehr/weniger), habe %d", limit, allowed)
}
t.Logf("mehrinstanz-umgehungsversuch protokolliert erfolglos: %d instanzen, %d anfragen gesamt, exakt %d (=limit) erlaubt", instanceCount, totalRequests, allowed)
}
// TestPentest_Lockout_MultiInstanceBypassAttempt simuliert denselben
// Umgehungsversuch gegen den Account-Lockout (IAM-07): ein Angreifer verteilt
// Brute-Force-Loginversuche fuer EIN Konto ueber mehrere Instanzen, um zu
// verhindern, dass eine einzelne Instanz die Schwelle erreicht.
func TestPentest_Lockout_MultiInstanceBypassAttempt(t *testing.T) {
registryPool, adminDSN := setupRegistryPool(t)
ctx := context.Background()
dsnTemplate := strings.Replace(adminDSN, "/postgres?", "/%s?", 1)
registry := tenant.NewRegistry(registryPool)
provisioner := tenant.NewProvisioner(registryPool, registry, dsnTemplate)
slug := uniqueSlug("pentest_lockout")
tn, err := provisioner.Provision(ctx, slug, "Pentest Lockout GmbH")
if err != nil {
t.Fatalf("provision: %v", err)
}
t.Cleanup(func() {
_, _ = registryPool.Exec(ctx, `DELETE FROM tenants WHERE slug = $1`, slug)
_, _ = registryPool.Exec(ctx, `DROP DATABASE IF EXISTS "tenant_`+slug+`"`)
})
tenantPool, err := pgxpool.New(ctx, tn.DBDSN)
if err != nil {
t.Fatalf("tenant pool: %v", err)
}
defer tenantPool.Close()
applyAllUpSQL(t, ctx, tenantPool, filepath.Join(repoRoot(t), "migrations", "tenant"))
const email = "opfer@pentest.example"
const maxFailed = lockout.DefaultMaxFailedAttempts
const instanceCount = 3
const attemptsPerInstance = 4 // 3*4=12 > maxFailed(5), muesste ohne umgehung frueh sperren
var wg sync.WaitGroup
var mu sync.Mutex
var lockedAt []int // Reihenfolge, in der jede "Instanz" eine Sperre beobachtet hat
for i := 0; i < instanceCount; i++ {
wg.Add(1)
instanceID := i
go func() {
defer wg.Done()
instancePool, err := pgxpool.New(ctx, tn.DBDSN)
if err != nil {
t.Errorf("instanz-pool: %v", err)
return
}
defer instancePool.Close()
store := lockout.NewStore(instancePool)
for j := 0; j < attemptsPerInstance; j++ {
locked, _, err := store.RecordFailure(ctx, email)
if err != nil {
t.Errorf("fehlversuch erfassen: %v", err)
return
}
if locked {
mu.Lock()
lockedAt = append(lockedAt, instanceID)
mu.Unlock()
}
}
}()
}
wg.Wait()
if len(lockedAt) == 0 {
t.Fatal("mehrinstanz-umgehungsversuch erfolgreich: konto wurde trotz insgesamt mehr als maxFailed fehlversuchen ueber mehrere instanzen NICHT gesperrt")
}
locked, _, err := lockout.NewStore(tenantPool).IsLocked(ctx, email)
if err != nil {
t.Fatalf("sperrstatus pruefen: %v", err)
}
if !locked {
t.Fatal("konto ist nach ueberschreiten der schwelle ueber mehrere instanzen hinweg nicht als gesperrt markiert")
}
t.Logf("mehrinstanz-umgehungsversuch protokolliert erfolglos: %d instanzen x %d versuche, konto nach ueberschreiten von maxFailed=%d gesperrt", instanceCount, attemptsPerInstance, maxFailed)
}
// =======================================================================
// Pruefung 3 / Akzeptanzkriterium 3: zentrale Policy-Durchsetzung gegen
// mindestens einen gezielten Umgehungsversuch je Modul.
// =======================================================================
// TestPentest_Policy_PrivilegeEscalationViaWrongRole ist der
// Umgehungsversuch fuer den Identitaets-/Mandantenbereich (TEN/IAM-Module):
// ein normaler Benutzer versucht, ueber den zentralen Enforcer (RBAC-02) ein
// Recht zu nutzen, das ausschliesslich Superadmin vorbehalten ist
// (PermManageTenants). Default-Deny muss greifen.
func TestPentest_Policy_PrivilegeEscalationViaWrongRole(t *testing.T) {
registryPool, _ := setupRegistryPool(t)
ctx := context.Background()
store := policy.NewStore(registryPool)
enforcer := policy.NewEnforcer(store)
// Nur die Basisrechte gewaehren, wie es die reale Policy vorsieht -
// PermManageTenants bewusst NICHT an user oder tenant_admin vergeben.
if err := store.Grant(ctx, rbac.RoleUser, rbac.PermSelfRead, "pentest-setup"); err != nil {
t.Fatalf("basisrecht gewaehren: %v", err)
}
t.Cleanup(func() {
_ = store.Revoke(ctx, rbac.RoleUser, rbac.PermSelfRead, "pentest-cleanup")
})
// Angriff: ein Benutzer mit Rolle "user" versucht, eine
// mandantenverwaltende Aktion auszufuehren.
err := enforcer.Authorize(ctx, rbac.RoleUser, rbac.PermManageTenants)
if err == nil {
t.Fatal("privilege-escalation erfolgreich: rolle 'user' durfte platform.manage_tenants ausfuehren")
}
if !errors.Is(err, policy.ErrDenied) {
t.Fatalf("erwartete policy.ErrDenied, habe: %v", err)
}
// Angriff Variante 2: dieselbe Aktion fuer tenant_admin, der ebenfalls
// nicht ueber PermManageTenants verfuegt.
if err := enforcer.Authorize(ctx, rbac.RoleTenantAdmin, rbac.PermManageTenants); !errors.Is(err, policy.ErrDenied) {
t.Fatalf("privilege-escalation ueber tenant_admin: erwartete policy.ErrDenied, habe: %v", err)
}
}
// TestPentest_Policy_GuardBypassStructurallyImpossible ist der
// Umgehungsversuch gegen die Durchsetzungsschicht selbst (Akzeptanzkriterium
// 1): policy.Guard ruft die uebergebene Query-Funktion NUR bei Erfolg von
// Authorize auf. Dieser Test beweist das Verhalten aktiv, indem ein
// Zaehler in der Query-Funktion inkrementiert wird - bei einer verweigerten
// Autorisierung darf der Zaehler NIEMALS erhoeht werden, unabhaengig davon,
// wie oft Guard aufgerufen wird.
func TestPentest_Policy_GuardBypassStructurallyImpossible(t *testing.T) {
registryPool, _ := setupRegistryPool(t)
ctx := context.Background()
store := policy.NewStore(registryPool)
enforcer := policy.NewEnforcer(store)
// Bewusst KEINE Regel gewaehrt - default-deny.
var dataAccessed int32
query := func(ctx context.Context) (string, error) {
atomic.AddInt32(&dataAccessed, 1)
return "geheime mandantendaten", nil
}
for i := 0; i < 10; i++ {
_, err := policy.Guard(ctx, enforcer, rbac.RoleUser, rbac.PermManageSettings, query)
if err == nil {
t.Fatal("guard hat query trotz fehlender berechtigung ausgefuehrt")
}
}
if atomic.LoadInt32(&dataAccessed) != 0 {
t.Fatalf("umgehungsversuch erfolgreich: query wurde %d mal trotz verweigerter autorisierung ausgefuehrt", dataAccessed)
}
}
// =======================================================================
// Akzeptanzkriterium 4: Master-Key- und Tenant-KEK-Verwaltung (API-10)
// gegen Schluesselkompromittierung und Rotation geprueft.
// =======================================================================
// TestPentest_KEK_CompromisedMasterKeyCannotDecryptAfterRotation prueft: nach
// einer Master-Key-Rotation kann der ALTE (potenziell kompromittierte)
// Master-Key die Tenant-KEKs nicht mehr entschluesseln - kompromittiert ein
// Angreifer den alten Master-Key NACH der Rotation, ist der Zugriff bereits
// verwehrt.
func TestPentest_KEK_CompromisedMasterKeyCannotDecryptAfterRotation(t *testing.T) {
oldMasterKey := randomMasterKey(t)
newMasterKey := randomMasterKey(t)
store, pool := setupKEKStore(t)
tenantID := insertTenantRow(t, pool, uniqueSlug("pentest_kek"))
plainKEK, err := store.CreateForTenant(context.Background(), tenantID, oldMasterKey)
if err != nil {
t.Fatalf("tenant-kek anlegen: %v", err)
}
rotated, failed, err := store.RotateMasterKey(context.Background(), oldMasterKey, newMasterKey)
if err != nil {
t.Fatalf("master-key-rotation: %v", err)
}
if rotated != 1 || len(failed) != 0 {
t.Fatalf("rotation unerwartet: rotated=%d failed=%v", rotated, failed)
}
// Angriff: der (kompromittierte) ALTE Master-Key wird nach der Rotation
// verwendet, um den Tenant-KEK zu entschluesseln.
if _, err := store.GetDecrypted(context.Background(), tenantID, oldMasterKey); err == nil {
t.Fatal("kompromittierter alter master-key konnte tenant-kek nach rotation weiterhin entschluesseln - kritische luecke")
}
// Gegenprobe: der neue Master-Key liefert weiterhin denselben Plaintext-
// Tenant-KEK (Akzeptanzkriterium 3: Rotation aendert nur die Verpackung).
decrypted, err := store.GetDecrypted(context.Background(), tenantID, newMasterKey)
if err != nil {
t.Fatalf("neuer master-key konnte tenant-kek nicht entschluesseln: %v", err)
}
if string(decrypted) != string(plainKEK) {
t.Fatal("tenant-kek-plaintext hat sich durch master-key-rotation veraendert - widerspricht akzeptanzkriterium 3")
}
}
// TestPentest_KEK_TenantKEKRotationDoesNotAffectOtherTenants ist der
// Umgehungs-/Kompromittierungstest fuer Tenant-KEK-Rotation: kompromittiert
// ein Angreifer den Tenant-KEK von Mandant A und dieser wird rotiert, darf
// das keinerlei Auswirkung auf Mandant B haben (Fortsetzung der physischen
// Modell-C-Isolation auf Schluesselebene).
func TestPentest_KEK_TenantKEKRotationDoesNotAffectOtherTenants(t *testing.T) {
masterKey := randomMasterKey(t)
store, pool := setupKEKStore(t)
tenantA := insertTenantRow(t, pool, uniqueSlug("pentest_kek_a"))
tenantB := insertTenantRow(t, pool, uniqueSlug("pentest_kek_b"))
if _, err := store.CreateForTenant(context.Background(), tenantA, masterKey); err != nil {
t.Fatalf("tenant a kek anlegen: %v", err)
}
kekBBefore, err := store.CreateForTenant(context.Background(), tenantB, masterKey)
if err != nil {
t.Fatalf("tenant b kek anlegen: %v", err)
}
// Tenant As KEK wird rotiert (z.B. weil kompromittiert).
if _, err := store.RotateTenantKEK(context.Background(), tenantA, masterKey); err != nil {
t.Fatalf("tenant a rotation: %v", err)
}
kekBAfter, err := store.GetDecrypted(context.Background(), tenantB, masterKey)
if err != nil {
t.Fatalf("tenant b kek nach fremder rotation nicht mehr lesbar: %v", err)
}
if string(kekBAfter) != string(kekBBefore) {
t.Fatal("tenant bs kek hat sich durch rotation von tenant a veraendert - isolationsverletzung")
}
}
// TestPentest_KEK_TamperedWrappedKeyRejected prueft, dass eine Manipulation
// des in der Datenbank gespeicherten, verpackten Tenant-KEK (z.B. durch
// einen Angreifer mit Datenbankzugriff, aber ohne Master-Key) beim
// Entschluesseln zuverlaessig als ungueltig erkannt wird - AES-GCM ist
// authentifizierend, ein manipulierter Chiffretext darf niemals einen
// (falschen) Plaintext zurueckliefern.
func TestPentest_KEK_TamperedWrappedKeyRejected(t *testing.T) {
masterKey := randomMasterKey(t)
store, pool := setupKEKStore(t)
tenantID := insertTenantRow(t, pool, uniqueSlug("pentest_kek_tamper"))
if _, err := store.CreateForTenant(context.Background(), tenantID, masterKey); err != nil {
t.Fatalf("tenant-kek anlegen: %v", err)
}
// Angriff: ein Byte im gespeicherten, verpackten Schluessel wird
// direkt in der Datenbank veraendert (kein Master-Key noetig fuer diesen
// Schritt).
tag, err := pool.Exec(context.Background(), `
UPDATE tenant_keks SET wrapped_kek = set_byte(wrapped_kek, 20, get_byte(wrapped_kek, 20) # 255)
WHERE tenant_id = $1
`, tenantID)
if err != nil {
t.Fatalf("wrapped_kek manipulieren: %v", err)
}
if tag.RowsAffected() != 1 {
t.Fatalf("erwartet 1 manipulierte zeile, habe %d", tag.RowsAffected())
}
if _, err := store.GetDecrypted(context.Background(), tenantID, masterKey); !errors.Is(err, kek.ErrUnwrapFailed) {
t.Fatalf("manipulierter wrapped_kek wurde nicht als ErrUnwrapFailed erkannt: %v", err)
}
}
// ---------------------------------------------------------------------
// KEK-Testhilfsfunktionen (eigenes Minimalschema wie in internal/kek/kek_test.go,
// mit derselben Aufraeum-Disziplin - siehe QA-04-Befund zu
// t.Cleanup/defer-Reihenfolge).
// ---------------------------------------------------------------------
func randomMasterKey(t *testing.T) kek.MasterKey {
t.Helper()
buf := make([]byte, kek.MasterKeySize)
if _, err := rand.Read(buf); err != nil {
t.Fatalf("master-key erzeugen: %v", err)
}
return kek.MasterKey(buf)
}
func setupKEKStore(t *testing.T) (*kek.Store, *pgxpool.Pool) {
t.Helper()
adminDSN := requireAdminDSN(t)
ctx := context.Background()
pool, err := pgxpool.New(ctx, adminDSN)
if err != nil {
t.Fatalf("pool: %v", err)
}
// Ueber t.Cleanup registriert (nicht defer), damit spaeter via t.Cleanup
// registrierte Aufraeumfunktionen (insertTenantRow) den Pool beim
// Ausfuehren noch offen vorfinden - siehe QA-04-Befund in
// internal/kek/kek_test.go zur defer/t.Cleanup-Reihenfolge.
t.Cleanup(func() { pool.Close() })
if _, err := pool.Exec(ctx, `
CREATE TABLE IF NOT EXISTS tenant_keks (
tenant_id UUID PRIMARY KEY REFERENCES tenants(id), wrapped_kek BYTEA NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), rotated_at TIMESTAMPTZ
)`); err != nil {
t.Fatalf("tenant_keks-schema: %v", err)
}
return kek.NewStore(pool), pool
}
func insertTenantRow(t *testing.T, pool *pgxpool.Pool, slug string) string {
t.Helper()
var id string
err := pool.QueryRow(context.Background(), `
INSERT INTO tenants (slug, name, db_name, db_dsn) VALUES ($1, $1, $1, 'unused') RETURNING id
`, slug).Scan(&id)
if err != nil {
t.Fatalf("tenant-zeile anlegen: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(context.Background(), `DELETE FROM tenant_keks WHERE tenant_id = $1`, id)
_, _ = pool.Exec(context.Background(), `DELETE FROM tenants WHERE id = $1`, id)
})
return id
}
+67
View File
@@ -0,0 +1,67 @@
package policy
import (
"context"
"errors"
"fmt"
"gitea.perlbach24.de/scripte/nexarch/internal/rbac"
)
// ErrDenied wird geliefert, wenn keine Regel role+permission erlaubt —
// Default-Deny (Akzeptanzkriterium 2).
var ErrDenied = errors.New("policy: zugriff verweigert")
// Enforcer ist die EINE zentrale Entscheidungs- und Durchsetzungsschicht
// (Akzeptanzkriterium 1). Repository-/Query-Code ruft ausschliesslich Guard
// bzw. GuardTenantScoped auf, nie eine Rohabfrage direkt.
type Enforcer struct {
store *Store
}
func NewEnforcer(store *Store) *Enforcer {
return &Enforcer{store: store}
}
// Authorize entscheidet erlaubt/verboten — unabhaengig von jeder konkreten
// Query, rein anhand der deklarativen Regeln (Akzeptanzkriterium 2: fuer
// sich genommen testbar, ohne Anwendungslogik).
func (e *Enforcer) Authorize(ctx context.Context, role rbac.Role, perm rbac.Permission) error {
allowed, err := e.store.IsAllowed(ctx, role, perm)
if err != nil {
return err
}
if !allowed {
return fmt.Errorf("%w: rolle %q hat kein recht %q", ErrDenied, role, perm)
}
return nil
}
// Guard ist die zentrale Enforcement-Funktion (Akzeptanzkriterium 1): query
// wird NUR aufgerufen, wenn Authorize zustimmt. Es gibt keinen Weg, query
// ausserhalb von Guard aufzurufen und trotzdem den Aufrufer als autorisiert
// zu behandeln — die Autorisierungsentscheidung steht immer VOR dem
// Datenzugriff, nie danach.
func Guard[T any](ctx context.Context, e *Enforcer, role rbac.Role, perm rbac.Permission, query func(ctx context.Context) (T, error)) (T, error) {
var zero T
if err := e.Authorize(ctx, role, perm); err != nil {
return zero, err
}
return query(ctx)
}
// GuardTenantScoped erzwingt zusaetzlich, dass tenantSlug TEIL der Query-
// Funktion selbst ist (Akzeptanzkriterium 3): der Funktionstyp verlangt,
// dass die Repository-Implementierung tenantSlug in ihre eigene WHERE-
// Klausel einbaut — ein nachgelagerter Filter auf dem Ergebnis (der
// archivmail-Fehler aus "Bekannte Fehler vermeiden") ist mit dieser
// Signatur nicht moeglich, da die Query-Funktion tenantSlug selbst
// entgegennimmt und dafuer verantwortlich ist, statt ihn hinterher
// anzuwenden.
func GuardTenantScoped[T any](ctx context.Context, e *Enforcer, role rbac.Role, perm rbac.Permission, tenantSlug string, query func(ctx context.Context, tenantSlug string) (T, error)) (T, error) {
var zero T
if err := e.Authorize(ctx, role, perm); err != nil {
return zero, err
}
return query(ctx, tenantSlug)
}
+187
View File
@@ -0,0 +1,187 @@
package policy
import (
"context"
"errors"
"os"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/internal/rbac"
)
func setupTest(t *testing.T) (*Store, *Enforcer, func()) {
t.Helper()
adminDSN := os.Getenv("TEST_ADMIN_DSN")
if adminDSN == "" {
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, adminDSN)
if err != nil {
t.Fatalf("pool: %v", err)
}
if _, err := pool.Exec(ctx, `
CREATE TABLE IF NOT EXISTS policy_rules (
role TEXT NOT NULL, permission TEXT NOT NULL,
granted_by TEXT NOT NULL, granted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (role, permission)
);
CREATE TABLE IF NOT EXISTS policy_rule_changes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), role TEXT NOT NULL, permission TEXT NOT NULL,
action TEXT NOT NULL CHECK (action IN ('grant','revoke')), actor TEXT NOT NULL,
version INT NOT NULL, changed_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
`); err != nil {
t.Fatalf("schema: %v", err)
}
store := NewStore(pool)
cleanup := func() {
_, _ = pool.Exec(ctx, `DELETE FROM policy_rule_changes WHERE role LIKE 'test\_%' ESCAPE '\'`)
_, _ = pool.Exec(ctx, `DELETE FROM policy_rules WHERE role LIKE 'test\_%' ESCAPE '\'`)
pool.Close()
}
return store, NewEnforcer(store), cleanup
}
// Akzeptanzkriterium 2 + Pruefung 2: Anfrage ohne passende Policy wird
// zuverlaessig abgewiesen (Default-Deny), nicht standardmaessig erlaubt.
func TestAuthorize_DefaultDeny(t *testing.T) {
_, enforcer, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
err := enforcer.Authorize(ctx, rbac.Role("test_niemand"), rbac.Permission("test_irgendwas"))
if !errors.Is(err, ErrDenied) {
t.Fatalf("erwartet ErrDenied ohne konfigurierte regel, habe %v", err)
}
}
// Akzeptanzkriterium 1 + Pruefung 1: Guard ruft query NUR bei Autorisierung
// auf — kein Datenzugriffs-Pfad umgeht die Enforcement-Schicht.
func TestGuard_NeverCallsQueryWithoutAuthorization(t *testing.T) {
store, enforcer, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
role := rbac.Role("test_rolle_guard")
perm := rbac.Permission("test_lesen")
queryCalled := false
_, err := Guard(ctx, enforcer, role, perm, func(ctx context.Context) (string, error) {
queryCalled = true
return "geheime-daten", nil
})
if !errors.Is(err, ErrDenied) {
t.Fatalf("erwartet ErrDenied, habe %v", err)
}
if queryCalled {
t.Fatal("query haette bei fehlender autorisierung NICHT aufgerufen werden duerfen")
}
if err := store.Grant(ctx, role, perm, "admin@example.com"); err != nil {
t.Fatalf("grant: %v", err)
}
result, err := Guard(ctx, enforcer, role, perm, func(ctx context.Context) (string, error) {
queryCalled = true
return "geheime-daten", nil
})
if err != nil {
t.Fatalf("guard nach grant: %v", err)
}
if !queryCalled || result != "geheime-daten" {
t.Fatalf("erwartet query-aufruf mit ergebnis nach autorisierung, habe queryCalled=%v result=%q", queryCalled, result)
}
}
// Akzeptanzkriterium 2 + 3: Policy-Entscheidungen deklarativ und unabhaengig
// von Anwendungslogik testbar — Regelwechsel wirkt ohne Codeaenderung,
// Historie ist versioniert nachvollziehbar.
func TestGrantRevoke_ChangesBehaviorWithoutCodeChange(t *testing.T) {
store, enforcer, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
role := rbac.Role("test_rolle_history")
perm := rbac.Permission("test_schreiben")
allowed, err := store.IsAllowed(ctx, role, perm)
if err != nil {
t.Fatalf("is allowed (vorher): %v", err)
}
if allowed {
t.Fatal("erwartet nicht erlaubt vor grant")
}
if err := store.Grant(ctx, role, perm, "admin@example.com"); err != nil {
t.Fatalf("grant: %v", err)
}
if err := enforcer.Authorize(ctx, role, perm); err != nil {
t.Fatalf("authorize nach grant sollte erlauben: %v", err)
}
if err := store.Revoke(ctx, role, perm, "admin@example.com"); err != nil {
t.Fatalf("revoke: %v", err)
}
if err := enforcer.Authorize(ctx, role, perm); !errors.Is(err, ErrDenied) {
t.Fatalf("authorize nach revoke sollte verweigern, habe %v", err)
}
history, err := store.History(ctx, role, perm)
if err != nil {
t.Fatalf("history: %v", err)
}
if len(history) != 2 {
t.Fatalf("erwartet 2 historieneintraege (grant, revoke), habe %d", len(history))
}
if history[0].Action != "grant" || history[0].Version != 1 {
t.Fatalf("history[0] unerwartet: %+v", history[0])
}
if history[1].Action != "revoke" || history[1].Version != 2 {
t.Fatalf("history[1] unerwartet: %+v", history[1])
}
}
// Akzeptanzkriterium 3 + Pruefung 3 (Tenant-Scoping): GuardTenantScoped
// zwingt tenantSlug in die Query-Funktion selbst, kein Post-Filter moeglich.
func TestGuardTenantScoped_IsolatesDataBetweenTenants(t *testing.T) {
_, enforcer, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
// Simuliertes Repository: die WHERE-Klausel (hier: Map-Lookup) liegt
// INNERHALB der Query-Funktion, nicht als nachgelagerter Filter.
data := map[string][]string{
"acme": {"dokument-a1", "dokument-a2"},
"globex": {"dokument-b1"},
}
repoQuery := func(ctx context.Context, tenantSlug string) ([]string, error) {
return data[tenantSlug], nil
}
role := rbac.Role("test_tenant_rolle")
perm := rbac.Permission("test_dokumente_lesen")
if err := enforcer.store.Grant(ctx, role, perm, "admin@example.com"); err != nil {
t.Fatalf("grant: %v", err)
}
gotAcme, err := GuardTenantScoped(ctx, enforcer, role, perm, "acme", repoQuery)
if err != nil {
t.Fatalf("guard tenant scoped (acme): %v", err)
}
if len(gotAcme) != 2 {
t.Fatalf("erwartet 2 dokumente fuer acme, habe %d", len(gotAcme))
}
gotGlobex, err := GuardTenantScoped(ctx, enforcer, role, perm, "globex", repoQuery)
if err != nil {
t.Fatalf("guard tenant scoped (globex): %v", err)
}
if len(gotGlobex) != 1 {
t.Fatalf("erwartet 1 dokument fuer globex, habe %d", len(gotGlobex))
}
}
+124
View File
@@ -0,0 +1,124 @@
// Package policy implementiert Core RBAC-02: die zentrale Policy-
// Entscheidungs- und Durchsetzungsschicht. Regeln liegen deklarativ in der
// Datenbank (Casbin-Prinzip: Policy als Modell+Regeln getrennt vom Code),
// versioniert und auditierbar — anders als Casbin-Dateien im Dateisystem
// (siehe "bewusst vermeiden" im Ticket).
package policy
import (
"context"
"fmt"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/internal/rbac"
)
// Rule ist eine gewaehrte Regel: role darf permission.
type Rule struct {
Role rbac.Role
Permission rbac.Permission
}
// RuleChange ist ein Eintrag der Aenderungshistorie (Akzeptanzkriterium 3).
type RuleChange struct {
Role rbac.Role
Permission rbac.Permission
Action string // "grant" oder "revoke"
Actor string
Version int
}
// Store verwaltet den deklarativen Regelsatz. IsAllowed schaut NUR in die
// Datenbank — es gibt keinen hartcodierten Go-Fallback, ein Regelwechsel
// wirkt sich ohne Codeaenderung sofort aus (Akzeptanzkriterium 2 / 3).
type Store struct {
pool *pgxpool.Pool
}
func NewStore(pool *pgxpool.Pool) *Store {
return &Store{pool: pool}
}
// IsAllowed prueft, ob role das Recht permission besitzt. Default-Deny:
// existiert keine passende Regel, ist der Zugriff verboten
// (Akzeptanzkriterium 2 / Pruefung 2) — es gibt keinen impliziten
// "erlaubt, wenn nichts anderes gesagt"-Pfad.
func (s *Store) IsAllowed(ctx context.Context, role rbac.Role, perm rbac.Permission) (bool, error) {
var exists bool
err := s.pool.QueryRow(ctx, `
SELECT EXISTS(SELECT 1 FROM policy_rules WHERE role = $1 AND permission = $2)
`, string(role), string(perm)).Scan(&exists)
if err != nil {
return false, fmt.Errorf("policy pruefen: %w", err)
}
return exists, nil
}
// Grant gewaehrt role das Recht permission — deklarativ, ohne Codeaenderung
// wirksam. actor wird fuer die Aenderungshistorie festgehalten.
func (s *Store) Grant(ctx context.Context, role rbac.Role, perm rbac.Permission, actor string) error {
return s.change(ctx, role, perm, "grant", actor, `
INSERT INTO policy_rules (role, permission, granted_by, granted_at)
VALUES ($1, $2, $3, now())
ON CONFLICT (role, permission) DO UPDATE SET granted_by = $3, granted_at = now()
`, string(role), string(perm), actor)
}
// Revoke entzieht role das Recht permission.
func (s *Store) Revoke(ctx context.Context, role rbac.Role, perm rbac.Permission, actor string) error {
return s.change(ctx, role, perm, "revoke", actor, `
DELETE FROM policy_rules WHERE role = $1 AND permission = $2
`, string(role), string(perm))
}
func (s *Store) change(ctx context.Context, role rbac.Role, perm rbac.Permission, action, actor, sql string, args ...any) error {
tx, err := s.pool.Begin(ctx)
if err != nil {
return fmt.Errorf("transaktion starten: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
if _, err := tx.Exec(ctx, sql, args...); err != nil {
return fmt.Errorf("regel aendern: %w", err)
}
var version int
if err := tx.QueryRow(ctx, `
SELECT COALESCE(MAX(version), 0) + 1 FROM policy_rule_changes WHERE role = $1 AND permission = $2
`, string(role), string(perm)).Scan(&version); err != nil {
return fmt.Errorf("naechste version ermitteln: %w", err)
}
if _, err := tx.Exec(ctx, `
INSERT INTO policy_rule_changes (role, permission, action, actor, version, changed_at)
VALUES ($1, $2, $3, $4, $5, now())
`, string(role), string(perm), action, actor, version); err != nil {
return fmt.Errorf("historie schreiben: %w", err)
}
return tx.Commit(ctx)
}
// History liefert die vollstaendige, versionierte Aenderungshistorie einer
// Regel (Akzeptanzkriterium 3).
func (s *Store) History(ctx context.Context, role rbac.Role, perm rbac.Permission) ([]RuleChange, error) {
rows, err := s.pool.Query(ctx, `
SELECT action, actor, version FROM policy_rule_changes
WHERE role = $1 AND permission = $2 ORDER BY version
`, string(role), string(perm))
if err != nil {
return nil, fmt.Errorf("historie abfragen: %w", err)
}
defer rows.Close()
var out []RuleChange
for rows.Next() {
c := RuleChange{Role: role, Permission: perm}
if err := rows.Scan(&c.Action, &c.Actor, &c.Version); err != nil {
return nil, fmt.Errorf("historieneintrag lesen: %w", err)
}
out = append(out, c)
}
return out, rows.Err()
}
+64
View File
@@ -0,0 +1,64 @@
package ratelimit
import (
"fmt"
"math"
"net/http"
"gitea.perlbach24.de/scripte/nexarch/internal/apiserver"
)
// KeyFunc bestimmt den Rate-Limit-Schluessel fuer eine Anfrage — typischerweise
// der Tenant-Slug aus apiserver.RequestContext, siehe KeyByTenant. Als
// eigenstaendiger Typ austauschbar (z.B. spaeter API-Token-basiert), ohne
// internal/apiserver aendern zu muessen (Kein Umbau angrenzender Bereiche).
type KeyFunc func(r *http.Request) (string, bool)
// KeyByTenant liest den Tenant aus dem von internal/apiserver.authAndTenantContext
// gesetzten RequestContext (IAM-02/API-01) — dieselbe Middleware-Kette,
// keine zweite Authentifizierung.
func KeyByTenant(r *http.Request) (string, bool) {
rc, ok := apiserver.FromContext(r.Context())
if !ok || rc.TenantSlug == "" {
return "", false
}
return rc.TenantSlug, true
}
// Middleware setzt sich VOR den eigentlichen Handler (nach Auth+Tenant-
// Kontext, siehe KeyByTenant) und lehnt Anfragen ueber dem konfigurierten
// Limit mit 429 + Retry-After ab (Akzeptanzkriterium 3).
func Middleware(store *Store, keyFn KeyFunc, next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
key, ok := keyFn(r)
if !ok {
// Kein Schluessel ermittelbar (z.B. kein Tenant-Kontext) — die
// Auth-Pruefung selbst ist Sache von authAndTenantContext, hier
// wird nur nicht limitiert, wenn schon kein Kontext vorliegt.
next(w, r)
return
}
result, err := store.Allow(r.Context(), key)
if err != nil {
apiserver.WriteError(w, http.StatusInternalServerError, "rate_limit_error", "rate-limit-pruefung fehlgeschlagen")
return
}
w.Header().Set("X-RateLimit-Limit", fmt.Sprintf("%d", result.Limit))
w.Header().Set("X-RateLimit-Remaining", fmt.Sprintf("%d", result.Remaining))
if !result.Allowed {
retryAfterSeconds := int(math.Ceil(result.RetryAfter.Seconds()))
if retryAfterSeconds < 1 {
retryAfterSeconds = 1
}
w.Header().Set("Retry-After", fmt.Sprintf("%d", retryAfterSeconds))
apiserver.WriteError(w, http.StatusTooManyRequests, "rate_limit_exceeded",
"zu viele anfragen, bitte spaeter erneut versuchen")
return
}
next(w, r)
}
}
+136
View File
@@ -0,0 +1,136 @@
package ratelimit
import (
"context"
"net/http"
"net/http/httptest"
"os"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/internal/apiserver"
"gitea.perlbach24.de/scripte/nexarch/internal/auth"
)
func setupMiddlewareTest(t *testing.T) (*apiserver.Server, *auth.TokenIssuer, *Store, func()) {
t.Helper()
adminDSN := os.Getenv("TEST_ADMIN_DSN")
if adminDSN == "" {
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, adminDSN)
if err != nil {
t.Fatalf("pool: %v", err)
}
if _, err := pool.Exec(ctx, `
CREATE TABLE IF NOT EXISTS rate_limit_configs (
key TEXT PRIMARY KEY, limit_value INT NOT NULL, window_seconds INT NOT NULL
);
CREATE TABLE IF NOT EXISTS rate_limit_counters (
key TEXT NOT NULL, window_start TIMESTAMPTZ NOT NULL, count INT NOT NULL DEFAULT 0,
PRIMARY KEY (key, window_start)
);
`); err != nil {
t.Fatalf("schema: %v", err)
}
issuer := auth.NewTokenIssuer("test-secret-nur-fuer-tests")
srv := apiserver.NewServer(issuer)
store := NewStore(pool)
cleanup := func() { pool.Close() }
return srv, issuer, store, cleanup
}
// Akzeptanzkriterium 3 + Pruefung 2: Ueberschreitung liefert 429 mit
// Retry-After-Header, ueber die vollstaendige Middleware-Kette (Auth ->
// Tenant-Kontext -> Rate-Limit -> Handler) hinweg.
func TestMiddleware_Returns429WithRetryAfterOnExceeded(t *testing.T) {
srv, issuer, store, cleanup := setupMiddlewareTest(t)
defer cleanup()
ctx := context.Background()
tenantSlug := newTestKey()
if err := store.SetLimit(ctx, tenantSlug, 1, time.Minute); err != nil {
t.Fatalf("setlimit: %v", err)
}
called := 0
srv.HandleV1("/limited", Middleware(store, KeyByTenant, func(w http.ResponseWriter, r *http.Request) {
called++
w.WriteHeader(http.StatusOK)
}))
token, err := issuer.Issue("user-1", tenantSlug)
if err != nil {
t.Fatalf("issue: %v", err)
}
req1 := httptest.NewRequest(http.MethodGet, "/api/v1/limited", nil)
req1.AddCookie(&http.Cookie{Name: auth.CookieName, Value: token})
rec1 := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec1, req1)
if rec1.Code != http.StatusOK {
t.Fatalf("1. anfrage: status = %d, want 200", rec1.Code)
}
req2 := httptest.NewRequest(http.MethodGet, "/api/v1/limited", nil)
req2.AddCookie(&http.Cookie{Name: auth.CookieName, Value: token})
rec2 := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec2, req2)
if rec2.Code != http.StatusTooManyRequests {
t.Fatalf("2. anfrage: status = %d, want 429", rec2.Code)
}
if rec2.Header().Get("Retry-After") == "" {
t.Fatal("erwartet gesetzten Retry-After-Header bei 429")
}
if called != 1 {
t.Fatalf("handler wurde %d mal aufgerufen, want genau 1 (2. anfrage haette nicht durchgereicht werden duerfen)", called)
}
}
// Akzeptanzkriterium 1: das Limit ist pro Tenant konfigurierbar — ein
// anderer Tenant (anderer Key) bleibt von der Ausschoepfung unberuehrt.
func TestMiddleware_DifferentTenantsHaveIndependentLimits(t *testing.T) {
srv, issuer, store, cleanup := setupMiddlewareTest(t)
defer cleanup()
ctx := context.Background()
tenantA := newTestKey()
tenantB := newTestKey()
if err := store.SetLimit(ctx, tenantA, 1, time.Minute); err != nil {
t.Fatalf("setlimit a: %v", err)
}
if err := store.SetLimit(ctx, tenantB, 1, time.Minute); err != nil {
t.Fatalf("setlimit b: %v", err)
}
srv.HandleV1("/limited2", Middleware(store, KeyByTenant, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
tokenA, _ := issuer.Issue("user-a", tenantA)
tokenB, _ := issuer.Issue("user-b", tenantB)
// Tenant A schoepft sein Limit aus.
reqA := httptest.NewRequest(http.MethodGet, "/api/v1/limited2", nil)
reqA.AddCookie(&http.Cookie{Name: auth.CookieName, Value: tokenA})
recA := httptest.NewRecorder()
srv.Handler().ServeHTTP(recA, reqA)
if recA.Code != http.StatusOK {
t.Fatalf("tenant a, 1. anfrage: status = %d, want 200", recA.Code)
}
// Tenant B ist von der Ausschoepfung bei A unberuehrt.
reqB := httptest.NewRequest(http.MethodGet, "/api/v1/limited2", nil)
reqB.AddCookie(&http.Cookie{Name: auth.CookieName, Value: tokenB})
recB := httptest.NewRecorder()
srv.Handler().ServeHTTP(recB, reqB)
if recB.Code != http.StatusOK {
t.Fatalf("tenant b haette trotz ausgeschoepftem limit von tenant a erlaubt sein muessen, status = %d", recB.Code)
}
}
+126
View File
@@ -0,0 +1,126 @@
// Package ratelimit implementiert Core API-03: eine zentrale Rate-Limiting-
// Schicht fuer die gesamte Core-API mit GETEILTEM, EXTERNEM Zustand in
// Postgres — kein In-Process-Zaehler (siehe "Bekannte Fehler vermeiden" im
// Ticket: dasselbe Risiko wie bei IAM-07s In-Memory-Lockout). Fixed-Window-
// Algorithmus: einfach, korrekt unter nebenlaeufigem Zugriff (atomares
// UPSERT wie internal/usage.Store.Increment), und fuer ein API-Gateway
// ausreichend praezise — kein Sliding-Window-Overhead noetig.
package ratelimit
import (
"context"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// DefaultLimit/DefaultWindow gelten fuer jeden Schluessel (Tenant oder
// API-Token), fuer den keine eigene Konfiguration existiert
// (Akzeptanzkriterium 1: "konfigurierbar", nicht "verpflichtend konfiguriert").
const (
DefaultLimit = 100
DefaultWindow = time.Minute
)
// Config ist das je Schluessel konfigurierbare Limit.
type Config struct {
Limit int
Window time.Duration
}
// Store haelt Konfiguration UND Zaehlerstand in Postgres — beides ueber
// dieselbe Verbindung erreichbar, damit mehrere Dienstinstanzen denselben
// Zustand sehen (Akzeptanzkriterium 2).
type Store struct {
pool *pgxpool.Pool
}
func NewStore(pool *pgxpool.Pool) *Store {
return &Store{pool: pool}
}
// SetLimit setzt die Konfiguration fuer EINEN Schluessel (z.B. einen
// Tenant-Slug oder eine API-Token-ID) — wirkt sich nicht auf andere
// Schluessel aus (Akzeptanzkriterium 1 / Pruefung 3).
func (s *Store) SetLimit(ctx context.Context, key string, limit int, window time.Duration) error {
_, err := s.pool.Exec(ctx, `
INSERT INTO rate_limit_configs (key, limit_value, window_seconds)
VALUES ($1, $2, $3)
ON CONFLICT (key) DO UPDATE SET limit_value = $2, window_seconds = $3
`, key, limit, int(window.Seconds()))
if err != nil {
return fmt.Errorf("rate-limit-konfiguration speichern: %w", err)
}
return nil
}
func (s *Store) getConfig(ctx context.Context, key string) (Config, error) {
var limit, windowSeconds int
err := s.pool.QueryRow(ctx, `
SELECT limit_value, window_seconds FROM rate_limit_configs WHERE key = $1
`, key).Scan(&limit, &windowSeconds)
if err != nil {
if err == pgx.ErrNoRows {
return Config{Limit: DefaultLimit, Window: DefaultWindow}, nil
}
return Config{}, fmt.Errorf("rate-limit-konfiguration lesen: %w", err)
}
return Config{Limit: limit, Window: time.Duration(windowSeconds) * time.Second}, nil
}
// Result ist der Ausgang einer Allow-Pruefung (Akzeptanzkriterium 3).
type Result struct {
Allowed bool
Limit int
Remaining int
RetryAfter time.Duration
}
// Allow erhoeht den Zaehler fuer (key, aktuelles Zeitfenster) ATOMAR ueber
// ein einziges UPSERT (dasselbe Muster wie internal/usage.Store.Increment)
// und vergleicht das Ergebnis gegen die konfigurierte Grenze — kein
// Lesen-Erhoehen-Schreiben in Go, damit zwei Dienstinstanzen, die
// gleichzeitig gegen dieselbe Datenbank inkrementieren, sich niemals
// gegenseitig ueberschreiben (Akzeptanzkriterium 2 / Pruefung 1).
func (s *Store) Allow(ctx context.Context, key string) (Result, error) {
cfg, err := s.getConfig(ctx, key)
if err != nil {
return Result{}, err
}
windowSeconds := int64(cfg.Window.Seconds())
if windowSeconds <= 0 {
windowSeconds = int64(DefaultWindow.Seconds())
}
now := time.Now().UTC()
windowStart := time.Unix((now.Unix()/windowSeconds)*windowSeconds, 0).UTC()
windowEnd := windowStart.Add(time.Duration(windowSeconds) * time.Second)
var count int
err = s.pool.QueryRow(ctx, `
INSERT INTO rate_limit_counters (key, window_start, count)
VALUES ($1, $2, 1)
ON CONFLICT (key, window_start) DO UPDATE
SET count = rate_limit_counters.count + 1
RETURNING count
`, key, windowStart).Scan(&count)
if err != nil {
return Result{}, fmt.Errorf("rate-limit-zaehler erhoehen: %w", err)
}
remaining := cfg.Limit - count
if remaining < 0 {
remaining = 0
}
if count > cfg.Limit {
return Result{
Allowed: false,
Limit: cfg.Limit,
Remaining: 0,
RetryAfter: windowEnd.Sub(now),
}, nil
}
return Result{Allowed: true, Limit: cfg.Limit, Remaining: remaining}, nil
}
+168
View File
@@ -0,0 +1,168 @@
package ratelimit
import (
"context"
"fmt"
"os"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
func setupTest(t *testing.T) (*pgxpool.Pool, func()) {
t.Helper()
adminDSN := os.Getenv("TEST_ADMIN_DSN")
if adminDSN == "" {
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, adminDSN)
if err != nil {
t.Fatalf("pool: %v", err)
}
if _, err := pool.Exec(ctx, `
CREATE TABLE IF NOT EXISTS rate_limit_configs (
key TEXT PRIMARY KEY, limit_value INT NOT NULL, window_seconds INT NOT NULL
);
CREATE TABLE IF NOT EXISTS rate_limit_counters (
key TEXT NOT NULL, window_start TIMESTAMPTZ NOT NULL, count INT NOT NULL DEFAULT 0,
PRIMARY KEY (key, window_start)
);
`); err != nil {
t.Fatalf("schema: %v", err)
}
cleanup := func() { pool.Close() }
return pool, cleanup
}
func newTestKey() string {
return fmt.Sprintf("test-key-%d", time.Now().UnixNano())
}
// Akzeptanzkriterium 3 + Pruefung 2: Ueberschreitung des Limits wird
// nachweislich erkannt.
func TestAllow_RejectsAfterLimitExceeded(t *testing.T) {
pool, cleanup := setupTest(t)
defer cleanup()
store := NewStore(pool)
ctx := context.Background()
key := newTestKey()
if err := store.SetLimit(ctx, key, 3, time.Minute); err != nil {
t.Fatalf("setlimit: %v", err)
}
for i := 0; i < 3; i++ {
res, err := store.Allow(ctx, key)
if err != nil {
t.Fatalf("allow %d: %v", i, err)
}
if !res.Allowed {
t.Fatalf("anfrage %d haette erlaubt sein muessen, limit=3", i)
}
}
res, err := store.Allow(ctx, key)
if err != nil {
t.Fatalf("allow (4.): %v", err)
}
if res.Allowed {
t.Fatal("4. anfrage haette abgelehnt werden muessen (limit=3)")
}
if res.RetryAfter <= 0 {
t.Fatalf("erwartet positive retry-after-dauer, habe %v", res.RetryAfter)
}
}
// Akzeptanzkriterium 1 / Pruefung 3: Konfigurationsaenderung wirkt sich
// NICHT auf andere Schluessel (Tenants) aus.
func TestSetLimit_DoesNotAffectOtherKeys(t *testing.T) {
pool, cleanup := setupTest(t)
defer cleanup()
store := NewStore(pool)
ctx := context.Background()
keyA := newTestKey()
keyB := newTestKey()
if err := store.SetLimit(ctx, keyA, 1, time.Minute); err != nil {
t.Fatalf("setlimit a: %v", err)
}
resA1, _ := store.Allow(ctx, keyA)
resA2, _ := store.Allow(ctx, keyA)
if !resA1.Allowed || resA2.Allowed {
t.Fatalf("key a: erwartet erlaubt,abgelehnt, habe %v,%v", resA1.Allowed, resA2.Allowed)
}
// keyB hat keine eigene Konfiguration -> DefaultLimit, unbeeinflusst von keyA.
resB, err := store.Allow(ctx, keyB)
if err != nil {
t.Fatalf("allow b: %v", err)
}
if !resB.Allowed {
t.Fatal("key b haette trotz limit-ueberschreitung bei key a erlaubt sein muessen")
}
if resB.Limit != DefaultLimit {
t.Fatalf("key b limit = %d, want default %d", resB.Limit, DefaultLimit)
}
}
// Akzeptanzkriterium 2 + Pruefung 1: zwei "Dienstinstanzen" (zwei
// unabhaengige Pools/Store-Objekte) gegen dieselbe Datenbank setzen
// dasselbe Limit korrekt durch — kein In-Process-Zustand kann das leisten.
func TestAllow_TwoInstancesShareStateCorrectly(t *testing.T) {
adminDSN := os.Getenv("TEST_ADMIN_DSN")
if adminDSN == "" {
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
}
pool, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
pool2, err := pgxpool.New(ctx, adminDSN)
if err != nil {
t.Fatalf("zweiter pool (simuliert zweite instanz): %v", err)
}
defer pool2.Close()
storeInstance1 := NewStore(pool)
storeInstance2 := NewStore(pool2)
key := newTestKey()
const limit = 20
if err := storeInstance1.SetLimit(ctx, key, limit, time.Minute); err != nil {
t.Fatalf("setlimit: %v", err)
}
const totalRequests = 50
var allowedCount int64
var wg sync.WaitGroup
for i := 0; i < totalRequests; i++ {
wg.Add(1)
store := storeInstance1
if i%2 == 0 {
store = storeInstance2 // Haelfte der Anfragen "kommt" von der zweiten Instanz
}
go func(s *Store) {
defer wg.Done()
res, err := s.Allow(ctx, key)
if err != nil {
t.Errorf("allow: %v", err)
return
}
if res.Allowed {
atomic.AddInt64(&allowedCount, 1)
}
}(store)
}
wg.Wait()
if allowedCount != limit {
t.Fatalf("erlaubte anfragen ueber beide instanzen = %d, want exakt %d (geteilter zustand)", allowedCount, limit)
}
}
+48
View File
@@ -0,0 +1,48 @@
package rbac
import "testing"
func contains(perms []Permission, p Permission) bool {
for _, x := range perms {
if x == p {
return true
}
}
return false
}
// Akzeptanzkriterium 2 + Pruefung 2: Rollenhierarchie liefert die erwartete
// effektive Rechtemenge.
func TestEffectivePermissions_Inheritance(t *testing.T) {
userPerms := EffectivePermissions(RoleUser)
if !contains(userPerms, PermSelfRead) || !contains(userPerms, PermSelfUpdate) {
t.Fatalf("user sollte self.read/self.update haben, habe %v", userPerms)
}
if contains(userPerms, PermManageUsers) {
t.Fatal("user sollte KEIN tenant.manage_users haben")
}
adminPerms := EffectivePermissions(RoleTenantAdmin)
if !contains(adminPerms, PermSelfRead) || !contains(adminPerms, PermManageUsers) || !contains(adminPerms, PermManageSettings) {
t.Fatalf("tenant_admin sollte geerbte user-rechte + eigene rechte haben, habe %v", adminPerms)
}
if contains(adminPerms, PermManageTenants) {
t.Fatal("tenant_admin sollte KEIN platform.manage_tenants haben")
}
superPerms := EffectivePermissions(RoleSuperadmin)
for _, want := range []Permission{PermSelfRead, PermSelfUpdate, PermManageUsers, PermManageSettings, PermManageTenants} {
if !contains(superPerms, want) {
t.Fatalf("superadmin sollte %q haben (volle vererbte kette), habe %v", want, superPerms)
}
}
}
func TestHasPermission(t *testing.T) {
if !HasPermission(RoleTenantAdmin, PermSelfRead) {
t.Fatal("tenant_admin sollte geerbtes self.read haben")
}
if HasPermission(RoleUser, PermManageTenants) {
t.Fatal("user sollte platform.manage_tenants nicht haben")
}
}
+144
View File
@@ -0,0 +1,144 @@
package rbac
import (
"context"
"errors"
"os"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/internal/user"
)
func setupStoreTest(t *testing.T) (*Store, string, func()) {
t.Helper()
adminDSN := os.Getenv("TEST_ADMIN_DSN")
if adminDSN == "" {
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, adminDSN)
if err != nil {
t.Fatalf("pool: %v", err)
}
if _, err := pool.Exec(ctx, `
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS role_assignments (
user_id UUID PRIMARY KEY REFERENCES users(id),
role TEXT NOT NULL CHECK (role IN ('user', 'tenant_admin')),
granted_by TEXT NOT NULL,
granted_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS role_assignment_history (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL,
role TEXT NOT NULL,
granted_by TEXT NOT NULL,
granted_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
`); err != nil {
t.Fatalf("schema: %v", err)
}
userStore := user.NewTenantUserStore(pool)
u, err := userStore.Create(ctx, "rbac_test@example.com", "RBAC Test")
if err != nil {
t.Fatalf("testuser anlegen: %v", err)
}
cleanup := func() {
_, _ = pool.Exec(ctx, `DELETE FROM role_assignment_history WHERE user_id = $1`, u.ID)
_, _ = pool.Exec(ctx, `DELETE FROM role_assignments WHERE user_id = $1`, u.ID)
_, _ = pool.Exec(ctx, `DELETE FROM users WHERE id = $1`, u.ID)
pool.Close()
}
return NewStore(pool), u.ID, cleanup
}
// Akzeptanzkriterium 1: Grundrollen sind einem Benutzer je Tenant zuweisbar.
func TestStore_AssignAndGet(t *testing.T) {
store, userID, cleanup := setupStoreTest(t)
defer cleanup()
ctx := context.Background()
assigned, err := store.Assign(ctx, userID, RoleTenantAdmin, "admin@acme.example")
if err != nil {
t.Fatalf("assign: %v", err)
}
if assigned.Role != RoleTenantAdmin {
t.Fatalf("erwartet tenant_admin, habe %q", assigned.Role)
}
got, err := store.Get(ctx, userID)
if err != nil {
t.Fatalf("get: %v", err)
}
if got.Role != RoleTenantAdmin || got.GrantedBy != "admin@acme.example" {
t.Fatalf("zuweisung unerwartet: %+v", got)
}
}
// Akzeptanzkriterium 1 + Pruefung 1: Zuweisung ausserhalb der erlaubten
// Matrix (superadmin im tenant-gescopten Store) wird abgewiesen.
func TestStore_RejectsSuperadminOutsideAllowedMatrix(t *testing.T) {
store, userID, cleanup := setupStoreTest(t)
defer cleanup()
ctx := context.Background()
_, err := store.Assign(ctx, userID, RoleSuperadmin, "admin@acme.example")
if !errors.Is(err, ErrRoleNotAssignableInTenantScope) {
t.Fatalf("erwartet ErrRoleNotAssignableInTenantScope, habe %v", err)
}
// Sicherstellen, dass der abgewiesene Versuch keine Zeile hinterlassen hat.
if _, err := store.Get(ctx, userID); !errors.Is(err, ErrNotFound) {
t.Fatalf("erwartet ErrNotFound nach abgewiesener zuweisung, habe %v", err)
}
}
func TestStore_RejectsUnknownRole(t *testing.T) {
store, userID, cleanup := setupStoreTest(t)
defer cleanup()
ctx := context.Background()
if _, err := store.Assign(ctx, userID, Role("erfunden"), "admin@acme.example"); !errors.Is(err, ErrRoleNotAssignableInTenantScope) {
t.Fatalf("erwartet ErrRoleNotAssignableInTenantScope fuer unbekannte rolle, habe %v", err)
}
}
// Akzeptanzkriterium 3: Rollenzuweisung vollstaendig auditierbar.
func TestStore_HistoryTracksWhoAndWhen(t *testing.T) {
store, userID, cleanup := setupStoreTest(t)
defer cleanup()
ctx := context.Background()
if _, err := store.Assign(ctx, userID, RoleUser, "system"); err != nil {
t.Fatalf("assign 1: %v", err)
}
if _, err := store.Assign(ctx, userID, RoleTenantAdmin, "admin@acme.example"); err != nil {
t.Fatalf("assign 2: %v", err)
}
history, err := store.History(ctx, userID)
if err != nil {
t.Fatalf("history: %v", err)
}
if len(history) != 2 {
t.Fatalf("erwartet 2 historieneintraege, habe %d", len(history))
}
if history[0].Role != RoleUser || history[0].GrantedBy != "system" {
t.Fatalf("history[0] unerwartet: %+v", history[0])
}
if history[1].Role != RoleTenantAdmin || history[1].GrantedBy != "admin@acme.example" {
t.Fatalf("history[1] unerwartet: %+v", history[1])
}
}
+126
View File
@@ -0,0 +1,126 @@
// Package secheaders implementiert Core API-08: eine gemeinsame
// Security-Header-Middleware/Bibliothek, die jedes Modul-Frontend (DMS,
// Mail, Workflow, AI, Connect) einbindet, statt Header pro Modul erneut zu
// definieren (Akzeptanzkriterium 3) — dieselbe Konsolidierungslogik wie
// beim zentralen Rate-Limiting (API-03). Die tatsaechliche EINBINDUNG in
// jedes einzelne Modul-Frontend ist ausdruecklich NICHT Teil dieser Kachel
// (siehe Ticket "Nicht Bestandteil").
package secheaders
import (
"fmt"
"net/http"
"strings"
"time"
)
// Config ist die Baseline-Konfiguration. DefaultConfig() liefert einen
// Ausgangspunkt OHNE 'unsafe-inline'/'unsafe-eval' (Akzeptanzkriterium 1)
// und mit angemessener HSTS-Laufzeit (Akzeptanzkriterium 2).
type Config struct {
// ContentSecurityPolicy wird unveraendert als Header-Wert gesendet.
ContentSecurityPolicy string
// HSTSMaxAge bestimmt die Gueltigkeitsdauer, die Browser sich merken.
HSTSMaxAge time.Duration
HSTSIncludeSubdomains bool
FrameOptions string
ReferrerPolicy string
ContentTypeNoSniff bool
}
// DefaultConfig liefert die dokumentierte Baseline: restriktive CSP ohne
// unsichere Direktiven, HSTS mit 180 Tagen (empfohlener Mindestwert fuer
// HSTS-Preloading-Listen liegt bei 1 Jahr — 180 Tage ist ein bewusst
// konservativer, aber bereits "angemessener" Ausgangswert, siehe
// Akzeptanzkriterium 2), Clickjacking- und MIME-Sniffing-Schutz.
func DefaultConfig() Config {
return Config{
ContentSecurityPolicy: strings.Join([]string{
"default-src 'self'",
"script-src 'self'",
"style-src 'self'",
"img-src 'self' data:",
"object-src 'none'",
"base-uri 'self'",
"frame-ancestors 'none'",
}, "; "),
HSTSMaxAge: 180 * 24 * time.Hour,
HSTSIncludeSubdomains: true,
FrameOptions: "DENY",
ReferrerPolicy: "strict-origin-when-cross-origin",
ContentTypeNoSniff: true,
}
}
// RequiredHeaders sind die von dieser Baseline gesetzten Header-Namen — die
// Referenzliste fuer CheckCoverage (Akzeptanzkriterium/Pruefung 1).
var RequiredHeaders = []string{
"Content-Security-Policy",
"Strict-Transport-Security",
"X-Frame-Options",
"Referrer-Policy",
"X-Content-Type-Options",
}
func (c Config) hstsValue() string {
v := fmt.Sprintf("max-age=%d", int(c.HSTSMaxAge.Seconds()))
if c.HSTSIncludeSubdomains {
v += "; includeSubDomains"
}
return v
}
// Middleware setzt die Baseline-Header auf JEDER Antwort und erzwingt einen
// HTTP->HTTPS-Redirect, WENN die Anfrage nicht bereits ueber TLS ankam
// (Akzeptanzkriterium 2 / Pruefung 3) — serverseitig durchgesetzt, nicht nur
// dem Browser-HSTS-Cache ueberlassen (der erst nach dem ERSTEN sicheren
// Aufruf greift).
func Middleware(cfg Config, next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !isSecure(r) {
target := "https://" + r.Host + r.URL.RequestURI()
http.Redirect(w, r, target, http.StatusMovedPermanently)
return
}
SetHeaders(cfg, w)
next(w, r)
}
}
// SetHeaders setzt die Baseline-Header direkt, OHNE die HTTPS-Redirect-
// Pruefung von Middleware — nuetzlich fuer lokale Entwicklungs-/
// Demo-Umgebungen ohne TLS-Terminierung (siehe cmd/secheaders-devserver)
// und fuer Aufrufer, die den Redirect selbst schon anderweitig sicherstellen.
func SetHeaders(cfg Config, w http.ResponseWriter) {
w.Header().Set("Content-Security-Policy", cfg.ContentSecurityPolicy)
w.Header().Set("Strict-Transport-Security", cfg.hstsValue())
w.Header().Set("X-Frame-Options", cfg.FrameOptions)
w.Header().Set("Referrer-Policy", cfg.ReferrerPolicy)
if cfg.ContentTypeNoSniff {
w.Header().Set("X-Content-Type-Options", "nosniff")
}
}
// isSecure erkennt TLS direkt ODER ueber den ueblichen Reverse-Proxy-Header
// (X-Forwarded-Proto) — der Core-Dienst laeuft in der Praxis hinter einem
// TLS-terminierenden Proxy, sieht r.TLS also nie direkt gesetzt.
func isSecure(r *http.Request) bool {
if r.TLS != nil {
return true
}
return strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https")
}
// CheckCoverage vergleicht die tatsaechlich gesetzten Header einer Antwort
// gegen RequiredHeaders und liefert die fehlenden — der "automatisierte
// Header-Scan" aus Pruefung 1, anwendbar auf jede Antwort eines beliebigen
// Modul-Frontends.
func CheckCoverage(headers http.Header) (missing []string) {
for _, name := range RequiredHeaders {
if headers.Get(name) == "" {
missing = append(missing, name)
}
}
return missing
}
+113
View File
@@ -0,0 +1,113 @@
package secheaders
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func secureRequest(method, target string) *http.Request {
req := httptest.NewRequest(method, target, nil)
req.Header.Set("X-Forwarded-Proto", "https")
return req
}
// Akzeptanzkriterium 1: CSP definiert ohne unsafe-inline/unsafe-eval.
func TestDefaultConfig_CSPHasNoUnsafeDirectives(t *testing.T) {
cfg := DefaultConfig()
if strings.Contains(cfg.ContentSecurityPolicy, "unsafe-inline") {
t.Fatal("csp enthaelt 'unsafe-inline', erwartet keine unsicheren direktiven")
}
if strings.Contains(cfg.ContentSecurityPolicy, "unsafe-eval") {
t.Fatal("csp enthaelt 'unsafe-eval', erwartet keine unsicheren direktiven")
}
if !strings.Contains(cfg.ContentSecurityPolicy, "default-src 'self'") {
t.Fatal("erwartet restriktive default-src 'self' als ausgangspunkt")
}
}
// Akzeptanzkriterium 2: HSTS mit angemessener max-age gesetzt.
func TestMiddleware_SetsHSTSWithSufficientMaxAge(t *testing.T) {
cfg := DefaultConfig()
handler := Middleware(cfg, func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })
rec := httptest.NewRecorder()
handler(rec, secureRequest(http.MethodGet, "https://example.test/"))
hsts := rec.Header().Get("Strict-Transport-Security")
if !strings.Contains(hsts, "max-age=") {
t.Fatalf("hsts-header fehlt max-age: %q", hsts)
}
// "Angemessen" heisst hier: mindestens 30 Tage (2592000s) — deutlich
// ueber dem bekannten Anti-Pattern kurzer/fehlender max-age-Werte.
if cfg.HSTSMaxAge.Seconds() < 2592000 {
t.Fatalf("konfigurierte hsts-max-age = %v, erwartet mindestens 30 tage", cfg.HSTSMaxAge)
}
if !strings.Contains(hsts, "includeSubDomains") {
t.Fatalf("erwartet includeSubDomains im hsts-header: %q", hsts)
}
}
// Akzeptanzkriterium 3 + Pruefung 1: EINE gemeinsame Middleware setzt ALLE
// Baseline-Header vollstaendig — automatisiert ueberprueft mit CheckCoverage.
func TestMiddleware_ProducesFullHeaderCoverage(t *testing.T) {
cfg := DefaultConfig()
handler := Middleware(cfg, func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })
rec := httptest.NewRecorder()
handler(rec, secureRequest(http.MethodGet, "https://example.test/"))
if missing := CheckCoverage(rec.Header()); len(missing) != 0 {
t.Fatalf("erwartet vollstaendige header-abdeckung, fehlen: %v", missing)
}
}
// Pruefung 1 (Kehrseite): CheckCoverage erkennt fehlende Header
// zuverlaessig — Voraussetzung dafuer, dass ein automatisierter Scan
// gegen ein Modul-Frontend OHNE eingebundene Middleware ueberhaupt etwas
// meldet.
func TestCheckCoverage_DetectsMissingHeadersOnBareHandler(t *testing.T) {
bareHandler := func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }
rec := httptest.NewRecorder()
bareHandler(rec, httptest.NewRequest(http.MethodGet, "/", nil))
missing := CheckCoverage(rec.Header())
if len(missing) != len(RequiredHeaders) {
t.Fatalf("erwartet %d fehlende header bei unbehandeltem handler, habe %d: %v",
len(RequiredHeaders), len(missing), missing)
}
}
// Akzeptanzkriterium 2 + Pruefung 3: ein Aufruf ueber (simuliertes) HTTP
// wird server-seitig auf HTTPS umgeleitet, statt sich allein auf den
// HSTS-Browser-Cache zu verlassen (der erst nach dem ersten sicheren
// Aufruf greift).
func TestMiddleware_RedirectsInsecureRequestToHTTPS(t *testing.T) {
cfg := DefaultConfig()
called := false
handler := Middleware(cfg, func(w http.ResponseWriter, r *http.Request) {
called = true
w.WriteHeader(http.StatusOK)
})
req := httptest.NewRequest(http.MethodGet, "http://example.test/pfad?x=1", nil)
// Kein X-Forwarded-Proto, kein r.TLS -> unsicherer Aufruf.
rec := httptest.NewRecorder()
handler(rec, req)
if rec.Code != http.StatusMovedPermanently {
t.Fatalf("status = %d, want 301 (redirect auf https)", rec.Code)
}
location := rec.Header().Get("Location")
if !strings.HasPrefix(location, "https://") {
t.Fatalf("location = %q, want https://-praefix", location)
}
if !strings.Contains(location, "/pfad?x=1") {
t.Fatalf("location = %q, erwartet erhaltenen pfad+query", location)
}
if called {
t.Fatal("eigentlicher handler haette bei unsicherem aufruf nicht erreicht werden duerfen")
}
}
+2
View File
@@ -0,0 +1,2 @@
DROP TABLE IF EXISTS policy_rule_changes;
DROP TABLE IF EXISTS policy_rules;
+24
View File
@@ -0,0 +1,24 @@
-- Zentrale, deklarative Policy-Regeln (RBAC-02, siehe core-kanban/tickets/RBAC-02.md).
-- policy_rules haelt den AKTUELLEN, gewaehrten Regelsatz (Existenz = erlaubt,
-- Default-Deny fuer alles ohne Zeile). policy_rule_changes ist die
-- versionierte Aenderungshistorie (Akzeptanzkriterium 3: Regelwechsel ohne
-- Codeaenderung nachvollziehbar).
CREATE TABLE policy_rules (
role TEXT NOT NULL,
permission TEXT NOT NULL,
granted_by TEXT NOT NULL,
granted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (role, permission)
);
CREATE TABLE policy_rule_changes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
role TEXT NOT NULL,
permission TEXT NOT NULL,
action TEXT NOT NULL CHECK (action IN ('grant', 'revoke')),
actor TEXT NOT NULL,
version INT NOT NULL,
changed_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX policy_rule_changes_idx ON policy_rule_changes (role, permission, version);
+2
View File
@@ -0,0 +1,2 @@
DROP TABLE rate_limit_counters;
DROP TABLE rate_limit_configs;
+15
View File
@@ -0,0 +1,15 @@
-- Rate-Limiting mit geteiltem, externem Zustand (API-03, siehe
-- core-kanban/tickets/API-03.md) — bewusst NICHT In-Process, damit mehrere
-- Dienstinstanzen dasselbe Limit korrekt durchsetzen.
CREATE TABLE rate_limit_configs (
key TEXT PRIMARY KEY,
limit_value INT NOT NULL,
window_seconds INT NOT NULL
);
CREATE TABLE rate_limit_counters (
key TEXT NOT NULL,
window_start TIMESTAMPTZ NOT NULL,
count INT NOT NULL DEFAULT 0,
PRIMARY KEY (key, window_start)
);
+1
View File
@@ -0,0 +1 @@
DROP TABLE IF EXISTS feature_flags;
+10
View File
@@ -0,0 +1,10 @@
-- Feature-Flags zentral je Mandant/Zielgruppe (LIC-02, siehe core-kanban/tickets/LIC-02.md).
-- Lebt in der Registry-DB, nicht pro Tenant-Datenbank — Flags sind eine
-- Core-weite Konfiguration, keine Mandanten-Geschaeftsdaten.
CREATE TABLE feature_flags (
key TEXT PRIMARY KEY,
enabled BOOLEAN NOT NULL DEFAULT false,
rollout_percentage INT NOT NULL DEFAULT 0 CHECK (rollout_percentage BETWEEN 0 AND 100),
target_tenant_slugs TEXT[] NOT NULL DEFAULT '{}',
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
+2
View File
@@ -0,0 +1,2 @@
DROP TABLE IF EXISTS module_credentials;
DROP TABLE IF EXISTS modules;
+18
View File
@@ -0,0 +1,18 @@
-- Modul-Registry & Aktivierungspruefung (API-02, siehe core-kanban/tickets/API-02.md).
CREATE TABLE modules (
name TEXT PRIMARY KEY,
version TEXT NOT NULL CHECK (version <> ''),
required_flags TEXT[] NOT NULL DEFAULT '{}',
registered_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Service-Credential je Modul-Instanz, bei Provisionierung ausgestellt
-- (Akzeptanzkriterium 4). secret_hash enthaelt NIEMALS das Secret im
-- Klartext, nur dessen SHA-256-Hash (Timing-safe-Vergleich beim Login,
-- Referenzmuster siehe AUD-02).
CREATE TABLE module_credentials (
module_name TEXT PRIMARY KEY REFERENCES modules(name),
client_id TEXT NOT NULL UNIQUE,
secret_hash BYTEA NOT NULL,
issued_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
+1
View File
@@ -0,0 +1 @@
DROP TABLE tenant_keks;
+10
View File
@@ -0,0 +1,10 @@
-- Master-Key-Verwaltung & Tenant-Schluesselhierarchie (API-10, siehe
-- core-kanban/tickets/API-10.md) — EIN verpackter (mit dem Master-Key
-- umhuellter) Tenant-KEK je Mandant. Niemals der Master-Key selbst und
-- niemals ein Tenant-KEK im Klartext in dieser Tabelle.
CREATE TABLE tenant_keks (
tenant_id UUID PRIMARY KEY REFERENCES tenants(id),
wrapped_kek BYTEA NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
rotated_at TIMESTAMPTZ
);
+16
View File
@@ -13,9 +13,25 @@ ROLE="nexarch_test"
export PGPASSWORD="$PASS" export PGPASSWORD="$PASS"
# Alle Registry-Tabellen, die von migrations/*.up.sql angelegt werden -
# inklusive der Tabellen mit FK-Bezug auf tenants (tenant_keks). "DROP TABLE
# tenants CASCADE" entfernt lediglich die FK-CONSTRAINT auf einer
# referenzierenden Tabelle, NICHT deren Zeilen - ohne den expliziten Drop
# hier ueberleben verwaiste tenant_keks-Zeilen jeden Reset und verfaelschen
# spaetere Testlaeufe (QA-04-Befund: internal/pentest kollidierte mit
# Altzeilen aus fruehreren internal/kek-Testlaeufen).
psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP TABLE IF EXISTS tenant_keks CASCADE;"
psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP TABLE IF EXISTS tenant_settings_history CASCADE;" psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP TABLE IF EXISTS tenant_settings_history CASCADE;"
psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP TABLE IF EXISTS tenant_settings CASCADE;" psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP TABLE IF EXISTS tenant_settings CASCADE;"
psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP TABLE IF EXISTS tenants CASCADE;" psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP TABLE IF EXISTS tenants CASCADE;"
psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP TABLE IF EXISTS superadmins CASCADE;"
psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP TABLE IF EXISTS rate_limit_counters CASCADE;"
psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP TABLE IF EXISTS rate_limit_configs CASCADE;"
psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP TABLE IF EXISTS policy_rule_changes CASCADE;"
psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP TABLE IF EXISTS policy_rules CASCADE;"
psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP TABLE IF EXISTS feature_flags CASCADE;"
psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP TABLE IF EXISTS module_credentials CASCADE;"
psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP TABLE IF EXISTS modules CASCADE;"
dbs=$(psql -h localhost -U "$ROLE" -d postgres -tAc "SELECT datname FROM pg_database WHERE datname LIKE 'tenant\_%' ESCAPE '\'") dbs=$(psql -h localhost -U "$ROLE" -d postgres -tAc "SELECT datname FROM pg_database WHERE datname LIKE 'tenant\_%' ESCAPE '\'")
for db in $dbs; do for db in $dbs; do