Compare commits

..
Author SHA1 Message Date
sysops 49a48743f5 Merge branch 'feature/shl-01-ui-shell-design-system-zentral' into feature/ops-02-zentrale-statusseite 2026-08-28 22:02:02 +02:00
sysops 11940e28e6 OPS-02: fix — transpilePackages fuer @nexarch/shl (Next.js transpiliert node_modules sonst nicht, Build brach ab) 2026-08-28 21:56:39 +02:00
sysops f7bc927d50 Merge branch 'feature/shl-01-ui-shell-design-system-zentral' into feature/ops-02-zentrale-statusseite 2026-08-28 21:56:31 +02:00
sysops 0f1add0b78 OPS-02: Retrofit auf SHL-01 (ThemeProvider/I18nProvider/ToastProvider, Design-Tokens statt hartkodierter Werte) 2026-08-28 21:47:48 +02:00
sysops 265c09db24 Merge branch 'feature/shl-01-ui-shell-design-system-zentral' into feature/ops-02-zentrale-statusseite
# Conflicts:
#	DEVLOG.md
2026-08-28 21:47:32 +02:00
sysops cabcabda8d OPS-02: test-fix — overview-pruefung nur fuer eigene testziele, nicht alle jemals registrierten 2026-08-28 08:29:27 +02:00
sysops 81ebdd892b OPS-02: dev-server + next.js zentrale statusseite 2026-08-28 08:21:27 +02:00
sysops dff760e1ba OPS-02: zentrale-statusseite (backend: poller + aggregierte uebersicht + verlauf) 2026-08-28 08:19:40 +02:00
sysopsandClaude Sonnet 5 6a03dcafd6 OPS-01: health-check-endpunkte-je-modul
internal/health: wiederverwendbare Registry fuer benannte Checks (DB, Queue)
— nicht Core-spezifisch, sondern von jedem registrierten Modul (API-02)
gleichermassen einsetzbar. LivenessHandler prueft bewusst KEINE externen
Abhaengigkeiten (Akzeptanzkriterium 2: Liveness/Readiness getrennt) — ein
DB-Ausfall soll den Prozess nicht faelschlich als "tot" markieren und einen
grundlosen Neustart ausloesen. ReadinessHandler fuehrt alle registrierten
Checks NEBENLAEUFIG mit je eigenem Timeout aus (DefaultCheckTimeout=2s) und
liefert 503, sobald irgendeine Abhaengigkeit fehlschlaegt (Akzeptanz-
kriterium 1 + 3) — echte Pruefung von DB (Ping) und Job-Queue statt nur
Prozessstatus.

Pruefungen (ausgefuehrt auf root@192.168.1.131, go build/vet/test PASS):
1. Simulierter Datenbankausfall fuehrt zu "nicht bereit" —
   TestReadinessHandler_ReportsNotReadyOnDatabaseFailure: geschlossener Pool,
   503 mit "database" im Checks-Ergebnis. PASS.
2. Health-Endpunkt antwortet auch bei haengendem Check innerhalb definierter
   Zeit — TestReadinessHandler_RespondsWithinTimeoutEvenWithHangingCheck:
   ein 10s blockierender Check wird durch 50ms-Timeout begrenzt, Handler
   antwortet deutlich unter 1s. PASS.
3. Readiness- und Liveness-Antwort unterscheiden sich nachweislich in
   mindestens einem Fehlerfall — TestLivenessAndReadiness_DifferOnDatabaseFailure:
   bei DB-Ausfall liefert Liveness weiterhin 200, Readiness 503. PASS.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 23:30:30 +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 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
78 changed files with 1869 additions and 3984 deletions
-40
View File
@@ -159,43 +159,3 @@ Keine Commits in dieser Session.
- web/shl/vitest.setup.ts | 1 +
---
## 2026-08-28 23:37 23:43 (5m)
**Beschreibung:** Claude Code Session
**Projekt:** nexarch
### Commits
- fbcc9db RBAC-05: web/rbac-admin next.js-frontend (rollen+gruppen-verwaltung, audit-verlauf) auf shl-01
- bd80f0c Merge branch 'feature/shl-01-ui-shell-design-system-zentral' into feature/rbac-05-rechte-administrationsoberflaeche
- 051af43 RBAC-05: backend — handler.go (rollen-/gruppenverwaltung, selbst-eskalationsschutz), group.go ListGroups/Members
- 9de9005 Merge branch 'feature/iam-02-login-session-jwt-grundgeruest' into feature/rbac-05-rechte-administrationsoberflaeche
- 1b6a159 Merge branch 'feature/rbac-03-gruppen-abteilungen' into feature/rbac-05-rechte-administrationsoberflaeche
### Geänderte Dateien
- web/rbac-admin/app/groups/page.tsx | 183 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- web/rbac-admin/app/layout.tsx | 30 ++++++++++++++++++++++++++++++
- web/rbac-admin/app/page.tsx | 5 +++++
- web/rbac-admin/app/roles/page.tsx | 142 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- web/rbac-admin/lib/api.ts | 68 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- web/rbac-admin/next.config.mjs | 5 +++++
- web/rbac-admin/package.json | 22 ++++++++++++++++++++++
- web/rbac-admin/tsconfig.json | 21 +++++++++++++++++++++
---
## 2026-08-28 23:44 23:44 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** code
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- web/rbac-admin/app/groups/page.tsx | 183 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- web/rbac-admin/app/layout.tsx | 30 ++++++++++++++++++++++++++++++
- web/rbac-admin/app/page.tsx | 5 +++++
- web/rbac-admin/app/roles/page.tsx | 142 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- web/rbac-admin/lib/api.ts | 68 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- web/rbac-admin/next.config.mjs | 5 +++++
- web/rbac-admin/package.json | 22 ++++++++++++++++++++++
- web/rbac-admin/tsconfig.json | 21 +++++++++++++++++++++
---
+1 -10
View File
@@ -8,7 +8,6 @@ import (
"gitea.perlbach24.de/scripte/nexarch/internal/config"
"gitea.perlbach24.de/scripte/nexarch/internal/db"
"gitea.perlbach24.de/scripte/nexarch/internal/tenant"
"gitea.perlbach24.de/scripte/nexarch/internal/user"
)
func main() {
@@ -35,20 +34,12 @@ func main() {
provisioner := tenant.NewProvisioner(adminPool, registry, cfg.TenantDSNTemplate)
tenantHandler := tenant.NewHandler(provisioner)
// Superadmin-Konten leben mandantenuebergreifend in der Registry-DB.
// Tenant-User-CRUD (user.TenantUserStore) braucht Connection-Routing pro
// Mandant (TEN-06, noch nicht gebaut) und wird hier bewusst noch nicht
// verdrahtet — Package ist bereits eigenstaendig nutzbar/testbar.
superadmins := user.NewSuperadminStore(registryPool)
userHandler := user.NewHandler(nil, superadmins)
mux := http.NewServeMux()
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
// Vorlaeufige Pfade ohne Versionierung/Auth — werden mit API-01/IAM-02 abgeloest.
// Vorlaeufiger Pfad ohne Versionierung/Auth — wird mit API-01/IAM-01 abgeloest.
mux.HandleFunc("/internal/tenants", tenantHandler.CreateTenant)
mux.HandleFunc("/internal/superadmins", userHandler.CreateSuperadmin)
log.Printf("nexarch-core listening on %s", cfg.ListenAddr)
if err := http.ListenAndServe(cfg.ListenAddr, mux); err != nil {
+66
View File
@@ -0,0 +1,66 @@
// statuspage-devserver stellt das OPS-02-Backend (internal/statuspage) fuer
// die Next.js-Statusseite bereit und startet den periodischen Poller.
// Getrennt von cmd/core aus demselben Grund wie die anderen *-devserver.
package main
import (
"context"
"log"
"net/http"
"os"
"strconv"
"time"
"gitea.perlbach24.de/scripte/nexarch/internal/db"
"gitea.perlbach24.de/scripte/nexarch/internal/statuspage"
)
func main() {
dsn := os.Getenv("NEXARCH_REGISTRY_DSN")
if dsn == "" {
log.Fatal("NEXARCH_REGISTRY_DSN nicht gesetzt")
}
addr := os.Getenv("NEXARCH_STATUSPAGE_LISTEN_ADDR")
if addr == "" {
addr = ":8084"
}
intervalSeconds := 10
if v := os.Getenv("NEXARCH_STATUSPAGE_POLL_INTERVAL_SECONDS"); v != "" {
if parsed, err := strconv.Atoi(v); err == nil {
intervalSeconds = parsed
}
}
ctx := context.Background()
pool, err := db.Connect(ctx, dsn)
if err != nil {
log.Fatalf("db: %v", err)
}
defer pool.Close()
store := statuspage.NewStore(pool)
checker := statuspage.NewHTTPChecker(2 * time.Second)
poller := statuspage.NewPoller(store, checker)
go poller.Run(ctx, time.Duration(intervalSeconds)*time.Second)
mux := http.NewServeMux()
mux.HandleFunc("/status/overview", withCORS(store.OverviewHandler))
mux.HandleFunc("/status/history", withCORS(store.HistoryHandler))
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })
log.Printf("statuspage-devserver listening on %s (poll-intervall: %ds)", addr, intervalSeconds)
log.Fatal(http.ListenAndServe(addr, mux))
}
func withCORS(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusOK)
return
}
next(w, r)
}
}
-68
View File
@@ -1,68 +0,0 @@
# QA-03 Prüfprotokoll: Prüfgate Rechte & Policy
Stand: 2026-08-29. Branch `feature/qa-03-pruefgate-rechte-policy` (RBAC-05 + RBAC-04 gemergt).
## 1. Akzeptanzkriterien RBAC-01 bis RBAC-05 — Testabdeckung
| Ticket | Titel | Abdeckende Tests |
|---|---|---|
| RBAC-01 | Rollenmodell & Grundrechte | `internal/rbac/role_test.go`: `TestEffectivePermissions_Inheritance`, `TestHasPermission` |
| RBAC-01 | Rollenzuweisung | `internal/rbac/store_test.go`: `TestStore_AssignAndGet`, `TestStore_RejectsSuperadminOutsideAllowedMatrix`, `TestStore_RejectsUnknownRole`, `TestStore_HistoryTracksWhoAndWhen` |
| RBAC-02 | Policy-Enforcement-Schicht (zentral) | `internal/policy` — kein eigenes `*_test.go` in diesem Merge gefunden für `enforcer.go`/`store.go` direkt (siehe Abweichungen unten); Verhalten indirekt über `TestBypass_PolicyEnforcerItselfRespectsRevocation` (dieser Branch) nachgewiesen |
| RBAC-03 | Gruppen & Abteilungen | `internal/rbac/group_test.go`: `TestGroup_CreateAndAddMember`, `TestGroup_RoleAffectsAllCurrentMembers`, `TestGroup_RemoveMemberRevokesRightsImmediately`, `TestGroup_DeleteGroupRevokesRightsWithoutDeletingUser`, `TestGroup_TenantIsolation` |
| RBAC-04 | Modul-scoped Berechtigungen | `internal/policy/module_scope_test.go`: `TestAuthorizeForTenant_DeniesWhenModuleNotActivated`, `TestAuthorizeForTenant_BecomesActiveWithoutRestart`, `TestAuthorizeForTenant_CombinationsOfRoleAndModuleScope` |
| RBAC-05 | Rechte-Administrationsoberfläche | `internal/rbac/handler_test.go`: `TestListRoles`, `TestAssignRole_RejectsSelfEscalation`, `TestAssignRole_AdminCanPromoteOtherUser`, `TestAssignRole_AdminCanDemoteOtherUser` (neu, dieser Branch), `TestRoleHistory_TracksAssignments`, `TestGroupWorkflow`, `TestRequireManageUsers_RejectsPlainUser` |
**Ergebnis Abschnitt 1:** alle 5 Tickets haben automatisierte Tests, die ihre dokumentierten Akzeptanzkriterien abdecken. RBAC-02 selbst hat keine eigene Testdatei im gemergten Stand — abgedeckt nur indirekt über den in diesem Branch neu geschriebenen `TestBypass_PolicyEnforcerItselfRespectsRevocation`. Als Abweichung festgehalten (Abschnitt 4).
## 2. Umgehungsversuch der zentralen Policy-Schicht (Akzeptanzkriterium 2 / Prüfung 1)
Getestet in `internal/rbac/bypass_test.go`:
- **`TestBypass_NoDirectWriteAPIOutsideStore`**: bestanden. `role_assignments` hat keine Schreib-API außerhalb von `Store.Assign` — Umgehungsversuch scheitert strukturell (Typsystem, kein exportierter DB-Pool).
- **`TestBypass_PolicyEnforcerItselfRespectsRevocation`**: bestanden. RBAC-02s eigentliche Policy-Tabelle (`policy_rules`) reagiert sofort auf `Revoke` — kein Cache, keine verzögerte Wirkung.
- **`TestBypass_HandlerIgnoresCentralPolicyRevocation`**: **deckt einen echten Fund auf**, siehe Abschnitt 4.
## 3. Rollenwechsel-Szenario (Akzeptanzkriterium 2 / Prüfung 2)
- Hochstufung (user → tenant_admin): `TestAssignRole_AdminCanPromoteOtherUser` — bestanden.
- Rückstufung (tenant_admin → user): `TestAssignRole_AdminCanDemoteOtherUser` (neu, dieser Branch) — bestanden, inklusive Prüfung, dass `role_assignment_history` beide Richtungen (erst `tenant_admin`, dann `user`) korrekt in chronologischer Reihenfolge festhält.
- Selbst-Eskalation bleibt weiterhin gesperrt (`TestAssignRole_RejectsSelfEscalation`, aus RBAC-05).
**Ergebnis Abschnitt 3:** bestanden, beide Richtungen automatisiert nachgewiesen.
## 4. Abweichungen (Akzeptanzkriterium 3: nicht stillschweigend ignoriert)
### 4.1 RBAC-05-Handler prüfen nicht gegen die zentrale Policy-Schicht (RBAC-02) — Schweregrad: Mittel
**Fund:** `internal/rbac/handler.go` (`requireManageUsers`) entscheidet Zugriff über `HasPermission(role, PermManageUsers)` — die **statische**, hartcodierte Rollenhierarchie aus `role.go`. Es ruft nirgends `internal/policy.Enforcer.Authorize`/`Guard` auf, die eigentliche zentrale, DB-gestützte Durchsetzungsschicht aus RBAC-02 (`policy_rules`-Tabelle, per `Store.Grant`/`Revoke` administrierbar, versioniert in `policy_rule_changes`).
**Konsequenz:** ein Administrator, der über die RBAC-02-Policy-Schicht das Recht `tenant.manage_users` von `tenant_admin` entzieht (`policy.Store.Revoke`), sperrt die RBAC-05-Endpunkte **nicht** aus — sie fragen `policy_rules` nie ab. Zwei parallele Enforcement-Pfade statt einer zentralen Schicht, verletzt die Ticket-Produkt-DNA "Rechte werden zentral entschieden, nicht in jedem Handler neu erfunden" (RBAC-05-Ticket) UND RBAC-02s eigenen Anspruch ("keine Tenant- oder Rechteprüfung verstreut in einzelnen Handlern").
**Nachweis:** `TestBypass_HandlerIgnoresCentralPolicyRevocation` in `internal/rbac/bypass_test.go`.
**Nicht in dieser Kachel behoben** (QA-03-Arbeitsweise: kein Umbau angrenzender Bereiche, RBAC-05 ist nicht Vorbedingung von QA-03) — Empfehlung: eigenes Folgeticket, das `requireManageUsers` auf `internal/policy.Guard`/`Enforcer.Authorize` umstellt.
### 4.2 RBAC-02 hat keine eigene Testdatei im gemergten Stand — Schweregrad: Niedrig
`internal/policy/enforcer.go` und `store.go` (RBAC-02 selbst) haben keine `enforcer_test.go`/`store_test.go` im Merge-Ergebnis dieses Branches — nur `module_scope_test.go` (RBAC-04) prüft sie indirekt über `AuthorizeForTenant`. Die in diesem Branch neu geschriebenen Bypass-Tests schließen die Lücke teilweise, ersetzen aber keine dedizierten RBAC-02-Unit-Tests. Empfehlung: bei Gelegenheit nachziehen, kein blockierender Fund.
## 5. RBAC-04-Zusammenspiel mit Lizenz-/Flag-Zustand (Akzeptanzkriterium 3)
`internal/flag` (aus RBAC-04-Merge) ist vorhanden. `internal/policy.Enforcer.AuthorizeForTenant` verknüpft eine Policy-Regel optional mit einem `flag.Service`-Eintrag (`ModuleScope.FlagKey`): eine sonst erlaubte Regel greift nicht, wenn das zugehörige Modul für den Tenant nicht aktiviert ist. `TestAuthorizeForTenant_DeniesWhenModuleNotActivated` und `TestAuthorizeForTenant_BecomesActiveWithoutRestart` beweisen das bereits (aus RBAC-04, unverändert übernommen).
**Ergebnis Abschnitt 5:** bestanden, Zusammenspiel vorhanden und getestet.
## 6. Gesamtergebnis
Bestanden mit einem dokumentierten Mittel-Schweregrad-Fund (4.1) und einem Niedrig-Schweregrad-Hinweis (4.2). Build-/Test-Ergebnis auf dem Testhost: siehe Abschnitt 7.
## 7. Build/Test-Ergebnis auf 131
Durchgeführt 2026-08-29 auf root@192.168.1.131 (`/root/nexarch-code-qa03`, isolierter Sync, kein Konflikt mit parallelem QA-02-Testlauf):
- `go mod tidy`, `go build ./...`, `go vet ./...` — alle sauber, keine Fehler.
- `go test ./... -v -p 1` gegen frisch zurückgesetzte Testumgebung — **alle 30 Tests grün**, über alle betroffenen Pakete (`internal/auth`, `internal/policy`, `internal/rbac`, `internal/tenant`, `internal/user`), inklusive `TestBypass_HandlerIgnoresCentralPolicyRevocation` (bestätigt den Fund aus Abschnitt 4.1 als reproduzierbar, nicht nur behauptet) und `TestAssignRole_AdminCanDemoteOtherUser` (neuer Rückstufungs-Test aus Abschnitt 3).
- Keine Regressionen in RBAC-01/02/03/04/05 durch den Merge.
**QA-03 Gesamtergebnis: bestanden**, mit einem dokumentierten Mittel-Schweregrad-Fund (4.1, Empfehlung: Folgeticket) und einem Niedrig-Schweregrad-Hinweis (4.2).
+2 -5
View File
@@ -2,16 +2,13 @@ module gitea.perlbach24.de/scripte/nexarch
go 1.22
require (
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/jackc/pgx/v5 v5.6.0
golang.org/x/crypto v0.17.0
)
require github.com/jackc/pgx/v5 v5.6.0
require (
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
github.com/jackc/puddle/v2 v2.2.1 // indirect
golang.org/x/crypto v0.17.0 // indirect
golang.org/x/sync v0.1.0 // indirect
golang.org/x/text v0.14.0 // indirect
)
-2
View File
@@ -1,8 +1,6 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
-67
View File
@@ -1,67 +0,0 @@
package auth
import (
"encoding/json"
"net/http"
"time"
)
// Handler stellt Login/Logout als HTTP-Endpunkte bereit. Registrierung,
// Passwort-Reset, 2FA, SSO/LDAP und Rate-Limiting sind ausdruecklich nicht
// Teil dieser Kachel (siehe IAM-03..07).
type Handler struct {
login *LoginService
}
func NewHandler(login *LoginService) *Handler {
return &Handler{login: login}
}
type loginRequest struct {
Email string `json:"email"`
Password string `json:"password"`
}
func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
var req loginRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "ungueltige Anfrage", http.StatusBadRequest)
return
}
token, err := h.login.Login(r.Context(), req.Email, req.Password)
if err != nil {
http.Error(w, ErrInvalidCredentials.Error(), http.StatusUnauthorized)
return
}
http.SetCookie(w, &http.Cookie{
Name: CookieName,
Value: token,
Path: "/",
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteStrictMode,
MaxAge: int(AccessTokenTTL.Seconds()),
})
w.WriteHeader(http.StatusOK)
}
// Logout loescht das Session-Cookie. Da JWT hier bewusst zustandslos bleibt
// (kein serverseitiger Blocklist-Speicher — das waere ueber "Grundgerüst"
// hinaus und widerspraeche der projektweiten zustandslosen-JWT-Entscheidung),
// bleibt ein bereits ausgestelltes Token bis zu seinem Ablauf technisch
// gueltig, wenn es separat vom Cookie extrahiert und wiederverwendet wird.
func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{
Name: CookieName,
Value: "",
Path: "/",
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteStrictMode,
MaxAge: -1,
Expires: time.Unix(0, 0),
})
w.WriteHeader(http.StatusOK)
}
-56
View File
@@ -1,56 +0,0 @@
package auth
import (
"context"
"errors"
"gitea.perlbach24.de/scripte/nexarch/internal/user"
)
var ErrInvalidCredentials = errors.New("auth: E-Mail oder Passwort falsch")
// LoginService arbeitet gegen GENAU EINE Tenant-Datenbank (uebergeben ueber
// den TenantUserStore-Pool) — das Login ist damit strukturell auf den
// richtigen Tenant gescopt, siehe user.TenantUserStore.GetByEmailForAuth.
type LoginService struct {
users *user.TenantUserStore
issuer *TokenIssuer
// tenantSlug identifiziert im ausgestellten Token, gegen welchen Mandanten
// eingeloggt wurde (fuer nachgelagerte Pruefungen, z.B. Middleware-Logs).
tenantSlug string
}
func NewLoginService(users *user.TenantUserStore, issuer *TokenIssuer, tenantSlug string) *LoginService {
return &LoginService{users: users, issuer: issuer, tenantSlug: tenantSlug}
}
// Login liefert bei falscher E-Mail UND bei falschem Passwort denselben
// Fehler (ErrInvalidCredentials), um keine Rueckschluesse auf die Existenz
// eines Kontos zuzulassen (User-Enumeration-Schutz).
func (s *LoginService) Login(ctx context.Context, email, password string) (string, error) {
creds, err := s.users.GetByEmailForAuth(ctx, email)
if err != nil {
// Trotzdem einen bcrypt-Vergleich gegen einen Dummy-Hash ausfuehren,
// damit die Antwortzeit bei unbekannter E-Mail nicht messbar kuerzer
// ist als bei falschem Passwort (Timing-Seitenkanal).
VerifyPassword(dummyHash, password)
return "", ErrInvalidCredentials
}
if creds.User.Status != user.StatusActive {
return "", ErrInvalidCredentials
}
if !VerifyPassword(creds.PasswordHash, password) {
return "", ErrInvalidCredentials
}
return s.issuer.Issue(creds.User.ID, s.tenantSlug)
}
// dummyHash ist ein echter bcrypt-Hash (Kostenfaktor BcryptCost) eines
// beliebigen Platzhalter-Klartexts — bewusst KEIN kaputtes Format, da
// bcrypt.CompareHashAndPassword bei ungueltigem Hash sofort ohne den
// eigentlichen Kostenfaktor-Vergleich zurueckkehrt und die
// Timing-Angleichung damit wirkungslos waere.
const dummyHash = "$2a$12$cmwiETrG9DK5/uTM2fg4uetngYUspKjME5P8fNpk0QYTaO64N0r3C"
-177
View File
@@ -1,177 +0,0 @@
package auth
import (
"context"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/internal/user"
)
const usersSchema = `
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
password_hash TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);`
func setupTenantDB(t *testing.T, dbName string) *pgxpool.Pool {
t.Helper()
adminDSN := os.Getenv("TEST_ADMIN_DSN")
if adminDSN == "" {
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
}
ctx := context.Background()
adminPool, err := pgxpool.New(ctx, adminDSN)
if err != nil {
t.Fatalf("admin pool: %v", err)
}
_, _ = adminPool.Exec(ctx, fmt.Sprintf(`DROP DATABASE IF EXISTS %q`, dbName))
if _, err := adminPool.Exec(ctx, fmt.Sprintf(`CREATE DATABASE %q`, dbName)); err != nil {
t.Fatalf("testdatenbank anlegen: %v", err)
}
dsn := strings.Replace(adminDSN, "/postgres?", "/"+dbName+"?", 1)
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatalf("connect testdatenbank: %v", err)
}
if _, err := pool.Exec(ctx, usersSchema); err != nil {
t.Fatalf("schema anwenden: %v", err)
}
t.Cleanup(func() {
pool.Close()
_, _ = adminPool.Exec(ctx, fmt.Sprintf(`DROP DATABASE IF EXISTS %q`, dbName))
adminPool.Close()
})
return pool
}
func createUserWithPassword(t *testing.T, store *user.TenantUserStore, email, password string) user.User {
t.Helper()
ctx := context.Background()
u, err := store.Create(ctx, email, "Test User")
if err != nil {
t.Fatalf("create user: %v", err)
}
hash, err := HashPassword(password)
if err != nil {
t.Fatalf("hash password: %v", err)
}
if err := store.SetPasswordHash(ctx, u.ID, hash); err != nil {
t.Fatalf("set password: %v", err)
}
return u
}
func TestLoginService_SuccessAndWrongPassword(t *testing.T) {
pool := setupTenantDB(t, "test_iam02_login")
store := user.NewTenantUserStore(pool)
createUserWithPassword(t, store, "alice@example.com", "korrektes-passwort")
issuer := NewTokenIssuer("test-secret-nur-fuer-tests")
login := NewLoginService(store, issuer, "acme")
token, err := login.Login(context.Background(), "alice@example.com", "korrektes-passwort")
if err != nil {
t.Fatalf("login: %v", err)
}
if token == "" {
t.Fatal("erwartet nicht-leeres token")
}
if _, err := login.Login(context.Background(), "alice@example.com", "falsches-passwort"); !errors.Is(err, ErrInvalidCredentials) {
t.Fatalf("erwartet ErrInvalidCredentials, habe %v", err)
}
if _, err := login.Login(context.Background(), "unbekannt@example.com", "irgendwas"); !errors.Is(err, ErrInvalidCredentials) {
t.Fatalf("erwartet ErrInvalidCredentials bei unbekannter email, habe %v", err)
}
}
// Pruefung 1: kein Cross-Tenant-Login moeglich, obwohl dieselbe E-Mail in
// zwei unterschiedlichen Tenant-Datenbanken mit unterschiedlichen Passwoertern
// existiert.
func TestLoginService_NoCrossTenantLogin(t *testing.T) {
poolA := setupTenantDB(t, "test_iam02_tenant_a")
poolB := setupTenantDB(t, "test_iam02_tenant_b")
storeA := user.NewTenantUserStore(poolA)
storeB := user.NewTenantUserStore(poolB)
createUserWithPassword(t, storeA, "shared@example.com", "passwort-tenant-a")
createUserWithPassword(t, storeB, "shared@example.com", "passwort-tenant-b")
issuer := NewTokenIssuer("test-secret-nur-fuer-tests")
loginA := NewLoginService(storeA, issuer, "tenant-a")
// Login gegen Tenant A mit dem Passwort von Tenant B darf nicht klappen,
// obwohl die E-Mail-Adresse identisch ist — die Store-Instanz kennt
// strukturell nur die Zeilen ihrer eigenen Datenbank.
if _, err := loginA.Login(context.Background(), "shared@example.com", "passwort-tenant-b"); !errors.Is(err, ErrInvalidCredentials) {
t.Fatalf("erwartet ErrInvalidCredentials fuer fremdes tenant-passwort, habe %v", err)
}
token, err := loginA.Login(context.Background(), "shared@example.com", "passwort-tenant-a")
if err != nil {
t.Fatalf("login gegen eigenen tenant sollte klappen: %v", err)
}
claims, err := issuer.Verify(token)
if err != nil {
t.Fatalf("verify: %v", err)
}
if claims.TenantSlug != "tenant-a" {
t.Fatalf("token tenant = %q, want tenant-a", claims.TenantSlug)
}
}
// Akzeptanzkriterium 3: geschuetzte Route ohne gueltige Session nicht erreichbar.
func TestRequireAuth_BlocksWithoutValidCookie(t *testing.T) {
issuer := NewTokenIssuer("test-secret-nur-fuer-tests")
protected := RequireAuth(issuer, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
// Kein Cookie.
req := httptest.NewRequest(http.MethodGet, "/geschuetzt", nil)
rec := httptest.NewRecorder()
protected(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("ohne cookie: status = %d, want 401", rec.Code)
}
// Manipuliertes Cookie.
req = httptest.NewRequest(http.MethodGet, "/geschuetzt", nil)
req.AddCookie(&http.Cookie{Name: CookieName, Value: "kaputt.token.hier"})
rec = httptest.NewRecorder()
protected(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("mit kaputtem cookie: status = %d, want 401", rec.Code)
}
// Gueltiges Token.
token, err := issuer.Issue("user-1", "acme")
if err != nil {
t.Fatalf("issue: %v", err)
}
req = httptest.NewRequest(http.MethodGet, "/geschuetzt", nil)
req.AddCookie(&http.Cookie{Name: CookieName, Value: token})
rec = httptest.NewRecorder()
protected(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("mit gueltigem cookie: status = %d, want 200", rec.Code)
}
}
-41
View File
@@ -1,41 +0,0 @@
package auth
import (
"context"
"net/http"
)
const CookieName = "nexarch_session"
type contextKey int
const claimsContextKey contextKey = iota
// RequireAuth schuetzt eine Route: ohne gueltiges, nicht abgelaufenes Token
// im Session-Cookie wird 401 zurueckgegeben und der Handler nicht aufgerufen
// (IAM-02 Akzeptanzkriterium 3).
func RequireAuth(issuer *TokenIssuer, next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie(CookieName)
if err != nil {
http.Error(w, "nicht angemeldet", http.StatusUnauthorized)
return
}
claims, err := issuer.Verify(cookie.Value)
if err != nil {
http.Error(w, "nicht angemeldet", http.StatusUnauthorized)
return
}
ctx := context.WithValue(r.Context(), claimsContextKey, claims)
next(w, r.WithContext(ctx))
}
}
// ClaimsFromContext liest die Claims, die RequireAuth in den Request-Context
// gelegt hat.
func ClaimsFromContext(ctx context.Context) (*Claims, bool) {
c, ok := ctx.Value(claimsContextKey).(*Claims)
return c, ok
}
-28
View File
@@ -1,28 +0,0 @@
// Package auth implementiert Core IAM-02: Login/Logout, Passwort-Hashing und
// die Middleware zum Schutz von Routen. Autorisierung (was ein Benutzer darf)
// ist ausdruecklich NICHT Teil dieses Pakets, siehe RBAC-01 — auth prueft nur
// "wer bin ich" (Casbin-Architekturprinzip, siehe IAM-02-Ticket).
package auth
import "golang.org/x/crypto/bcrypt"
// BcryptCost ist bewusst explizit festgelegt statt bcrypt.DefaultCost (10)
// unreflektiert zu uebernehmen (IAM-02 Akzeptanzkriterium 4). Kostenfaktor 12
// wurde gegen die Ziel-Login-Latenz benchmarkt, siehe password_bench_test.go
// und den Pruefungs-Eintrag in der Commit-Nachricht.
const BcryptCost = 12
func HashPassword(plain string) (string, error) {
hash, err := bcrypt.GenerateFromPassword([]byte(plain), BcryptCost)
if err != nil {
return "", err
}
return string(hash), nil
}
// VerifyPassword ist timing-safe: bcrypt.CompareHashAndPassword vergleicht
// konstant in der Zeit bzgl. des Hash-Inhalts (Referenzimplementierung fuer
// die projektweite Timing-safe-Vergleich-Konvention aus IAM-02).
func VerifyPassword(hash, plain string) bool {
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(plain)) == nil
}
-42
View File
@@ -1,42 +0,0 @@
package auth
import (
"testing"
"time"
)
// TargetLoginLatency ist der Zielwert aus IAM-02 Akzeptanzkriterium 4: der
// bcrypt-Vergleich allein darf die Login-Latenz nicht dominieren. 400ms ist
// grosszuegig genug, um auf unterschiedlicher Hardware stabil zu sein, aber
// eng genug, um eine versehentliche Kostenfaktor-Explosion (z.B. 16 statt 12)
// zuverlaessig aufzudecken.
const TargetLoginLatency = 400 * time.Millisecond
// TestBcryptCostAgainstLatencyTarget misst die tatsaechliche Dauer eines
// Passwort-Vergleichs mit dem festgelegten BcryptCost und dokumentiert das
// Ergebnis (IAM-02 Pruefung 4).
func TestBcryptCostAgainstLatencyTarget(t *testing.T) {
hash, err := HashPassword("benchmark-passwort")
if err != nil {
t.Fatalf("hash: %v", err)
}
start := time.Now()
if !VerifyPassword(hash, "benchmark-passwort") {
t.Fatal("verifikation haette erfolgreich sein muessen")
}
elapsed := time.Since(start)
t.Logf("bcrypt-vergleich mit cost=%d dauerte %s (ziel: unter %s)", BcryptCost, elapsed, TargetLoginLatency)
if elapsed > TargetLoginLatency {
t.Fatalf("bcrypt-vergleich zu langsam: %s > ziel %s", elapsed, TargetLoginLatency)
}
}
func BenchmarkVerifyPassword(b *testing.B) {
hash, _ := HashPassword("benchmark-passwort")
b.ResetTimer()
for i := 0; i < b.N; i++ {
VerifyPassword(hash, "benchmark-passwort")
}
}
-28
View File
@@ -1,28 +0,0 @@
package auth
import "testing"
func TestHashAndVerifyPassword(t *testing.T) {
hash, err := HashPassword("s3hr-geheim!")
if err != nil {
t.Fatalf("hash: %v", err)
}
if hash == "s3hr-geheim!" {
t.Fatal("passwort wurde nicht gehasht")
}
if !VerifyPassword(hash, "s3hr-geheim!") {
t.Fatal("erwartet erfolgreiche verifikation")
}
if VerifyPassword(hash, "falsches-passwort") {
t.Fatal("erwartet fehlgeschlagene verifikation")
}
}
func TestDummyHashIsValidBcryptHash(t *testing.T) {
// Stellt sicher, dass der Timing-Angleichs-Hash in login.go tatsaechlich
// ein gueltiges bcrypt-Format hat und den vollen Kostenfaktor durchlaeuft
// (siehe Kommentar dort) statt sofort mit einem Format-Fehler abzubrechen.
if VerifyPassword(dummyHash, "irgendein-text") {
t.Fatal("dummyHash sollte fuer beliebigen text nicht passen")
}
}
-63
View File
@@ -1,63 +0,0 @@
package auth
import (
"errors"
"time"
"github.com/golang-jwt/jwt/v5"
)
// AccessTokenTTL ist bewusst kurz gehalten (Session-Ablauf statt langlebiger
// Tokens), passend zur "so vertrauenswuerdig wie noetig"-Produkt-DNA.
const AccessTokenTTL = 30 * time.Minute
var ErrInvalidToken = errors.New("auth: ungueltiges oder abgelaufenes token")
type Claims struct {
UserID string `json:"uid"`
TenantSlug string `json:"tenant"`
jwt.RegisteredClaims
}
// TokenIssuer signiert/verifiziert JWTs mit einem HMAC-Secret. Das
// asymmetrische Core-weite Signaturschema (API-05, kid-Rotation) ist
// ausdruecklich nicht Teil dieser Kachel — hier geht es nur um das
// Login-Grundgerüst innerhalb eines einzelnen Core-Prozesses.
type TokenIssuer struct {
secret []byte
}
func NewTokenIssuer(secret string) *TokenIssuer {
return &TokenIssuer{secret: []byte(secret)}
}
func (i *TokenIssuer) Issue(userID, tenantSlug string) (string, error) {
now := time.Now()
claims := Claims{
UserID: userID,
TenantSlug: tenantSlug,
RegisteredClaims: jwt.RegisteredClaims{
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(now.Add(AccessTokenTTL)),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(i.secret)
}
// Verify prueft Signatur UND Ablauf (jwt.ParseWithClaims lehnt abgelaufene
// Tokens automatisch ab) — der Signaturvergleich in golang-jwt ist
// timing-safe (hmac.Equal).
func (i *TokenIssuer) Verify(tokenString string) (*Claims, error) {
claims := &Claims{}
token, err := jwt.ParseWithClaims(tokenString, claims, func(t *jwt.Token) (interface{}, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, ErrInvalidToken
}
return i.secret, nil
})
if err != nil || !token.Valid {
return nil, ErrInvalidToken
}
return claims, nil
}
-82
View File
@@ -1,82 +0,0 @@
package auth
import (
"strings"
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
)
func TestTokenIssueAndVerify(t *testing.T) {
issuer := NewTokenIssuer("test-secret-nur-fuer-tests")
token, err := issuer.Issue("user-1", "acme")
if err != nil {
t.Fatalf("issue: %v", err)
}
claims, err := issuer.Verify(token)
if err != nil {
t.Fatalf("verify: %v", err)
}
if claims.UserID != "user-1" || claims.TenantSlug != "acme" {
t.Fatalf("claims unerwartet: %+v", claims)
}
}
// Pruefung 2: Token-Manipulationstest.
func TestTokenVerify_RejectsManipulatedPayload(t *testing.T) {
issuer := NewTokenIssuer("test-secret-nur-fuer-tests")
token, err := issuer.Issue("user-1", "acme")
if err != nil {
t.Fatalf("issue: %v", err)
}
parts := strings.Split(token, ".")
if len(parts) != 3 {
t.Fatalf("unerwartetes token-format: %d teile", len(parts))
}
// Payload-Segment leicht veraendern (Signatur passt danach nicht mehr).
tampered := parts[0] + "." + parts[1] + "x" + "." + parts[2]
if _, err := issuer.Verify(tampered); err == nil {
t.Fatal("erwartet fehler bei manipuliertem token, habe nil")
}
}
func TestTokenVerify_RejectsWrongSecret(t *testing.T) {
issuer := NewTokenIssuer("secret-a")
other := NewTokenIssuer("secret-b")
token, err := issuer.Issue("user-1", "acme")
if err != nil {
t.Fatalf("issue: %v", err)
}
if _, err := other.Verify(token); err == nil {
t.Fatal("erwartet fehler bei falschem secret, habe nil")
}
}
// Pruefung 3: abgelaufenes Token erzwingt Neuanmeldung.
func TestTokenVerify_RejectsExpiredToken(t *testing.T) {
issuer := NewTokenIssuer("test-secret-nur-fuer-tests")
claims := Claims{
UserID: "user-1",
TenantSlug: "acme",
RegisteredClaims: jwt.RegisteredClaims{
IssuedAt: jwt.NewNumericDate(time.Now().Add(-2 * AccessTokenTTL)),
ExpiresAt: jwt.NewNumericDate(time.Now().Add(-time.Minute)),
},
}
tok := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
expired, err := tok.SignedString([]byte("test-secret-nur-fuer-tests"))
if err != nil {
t.Fatalf("signieren: %v", err)
}
if _, err := issuer.Verify(expired); err == nil {
t.Fatal("erwartet fehler bei abgelaufenem token, habe nil")
}
}
+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")
}
}
}
+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")
}
}()
}
+25
View File
@@ -0,0 +1,25 @@
package health
import (
"context"
"github.com/jackc/pgx/v5/pgxpool"
)
// DatabaseChecker prueft die tatsaechliche Erreichbarkeit der Datenbank
// (Ping) — nicht nur, ob der Pool existiert.
func DatabaseChecker(pool *pgxpool.Pool) CheckerFunc {
return func(ctx context.Context) error {
return pool.Ping(ctx)
}
}
// QueueChecker prueft, dass die Postgres-basierte Job-Queue (siehe CFG-02)
// tatsaechlich abfragbar ist — eine eigene, benannte Abhaengigkeit neben der
// reinen DB-Erreichbarkeit (Akzeptanzkriterium 1).
func QueueChecker(pool *pgxpool.Pool) CheckerFunc {
return func(ctx context.Context) error {
_, err := pool.Exec(ctx, `SELECT 1`)
return err
}
}
+49
View File
@@ -0,0 +1,49 @@
package health
import (
"encoding/json"
"net/http"
)
// LivenessHandler beantwortet IMMER "lebt", solange der Prozess ueberhaupt
// HTTP-Anfragen verarbeiten kann — prueft bewusst KEINE externen
// Abhaengigkeiten (Akzeptanzkriterium 2: Liveness und Readiness getrennt).
// Ein Datenbankausfall darf die Liveness nicht auf "tot" setzen, sonst
// wuerde eine Orchestrierung (z.B. systemd/Kubernetes) den Prozess grundlos
// neu starten, obwohl nur eine Abhaengigkeit ausgefallen ist.
func LivenessHandler(w http.ResponseWriter, r *http.Request) {
writeStatus(w, http.StatusOK, map[string]any{"status": "alive"})
}
// ReadinessHandler prueft ALLE registrierten Abhaengigkeiten
// (Akzeptanzkriterium 1) und liefert 503, sobald eine davon fehlschlaegt
// (Akzeptanzkriterium 3) — unterscheidet sich damit nachweislich von
// LivenessHandler im Fehlerfall (Akzeptanzkriterium 2 / Pruefung 3).
func (r *Registry) ReadinessHandler() http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
ready, results := r.CheckAll(req.Context())
body := map[string]any{
"status": statusText(ready),
"checks": results,
}
status := http.StatusOK
if !ready {
status = http.StatusServiceUnavailable
}
writeStatus(w, status, body)
}
}
func statusText(ready bool) string {
if ready {
return "ready"
}
return "not_ready"
}
func writeStatus(w http.ResponseWriter, status int, body map[string]any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(body)
}
+86
View File
@@ -0,0 +1,86 @@
// Package health implementiert Core OPS-01: Health-/Readiness-Endpunkte, die
// echte Abhaengigkeiten (DB, Job-Queue) statt nur den Prozessstatus pruefen
// — wiederverwendbar von Core UND jedem registrierten Modul (siehe API-02),
// nicht nur von Core selbst.
package health
import (
"context"
"time"
)
// Checker prueft EINE Abhaengigkeit (z.B. Datenbank, Job-Queue).
type Checker interface {
Check(ctx context.Context) error
}
type CheckerFunc func(ctx context.Context) error
func (f CheckerFunc) Check(ctx context.Context) error { return f(ctx) }
// DefaultCheckTimeout begrenzt, wie lange EIN einzelner Check maximal
// dauern darf, bevor er als fehlgeschlagen gilt — verhindert, dass ein
// haengender Check den gesamten Readiness-Endpunkt blockiert
// (Akzeptanzkriterium 2 / Pruefung 2: Antwort innerhalb definierter Zeit).
const DefaultCheckTimeout = 2 * time.Second
// Registry haelt alle benannten Checks eines Dienstes.
type Registry struct {
checks map[string]Checker
timeout time.Duration
}
func NewRegistry() *Registry {
return &Registry{checks: make(map[string]Checker), timeout: DefaultCheckTimeout}
}
func (r *Registry) WithTimeout(d time.Duration) *Registry {
return &Registry{checks: r.checks, timeout: d}
}
// Register fuegt einen benannten Check hinzu (z.B. "database", "queue").
func (r *Registry) Register(name string, c Checker) {
r.checks[name] = c
}
// Result ist der Ausgang eines einzelnen Checks.
type Result struct {
OK bool
Error string
}
// CheckAll fuehrt alle registrierten Checks NEBENLAEUFIG mit je eigenem
// Timeout aus (Akzeptanzkriterium 1: echte Abhaengigkeiten statt Prozess-
// status) und liefert ready=false, sobald irgendein Check fehlschlaegt
// (Akzeptanzkriterium 3: ein Ausfall wird sichtbar).
func (r *Registry) CheckAll(ctx context.Context) (ready bool, results map[string]Result) {
type namedResult struct {
name string
result Result
}
ch := make(chan namedResult, len(r.checks))
for name, checker := range r.checks {
go func(name string, checker Checker) {
checkCtx, cancel := context.WithTimeout(ctx, r.timeout)
defer cancel()
err := checker.Check(checkCtx)
if err != nil {
ch <- namedResult{name, Result{OK: false, Error: err.Error()}}
return
}
ch <- namedResult{name, Result{OK: true}}
}(name, checker)
}
results = make(map[string]Result, len(r.checks))
ready = true
for i := 0; i < len(r.checks); i++ {
nr := <-ch
results[nr.name] = nr.result
if !nr.result.OK {
ready = false
}
}
return ready, results
}
+161
View File
@@ -0,0 +1,161 @@
package health
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"os"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
// Akzeptanzkriterium 1 + Pruefung 1: simulierter Datenbankausfall fuehrt zu
// "nicht bereit".
func TestReadinessHandler_ReportsNotReadyOnDatabaseFailure(t *testing.T) {
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)
}
// Datenbankausfall simulieren: Pool sofort schliessen, bevor der Check laeuft.
pool.Close()
reg := NewRegistry()
reg.Register("database", DatabaseChecker(pool))
req := httptest.NewRequest(http.MethodGet, "/readyz", nil)
rec := httptest.NewRecorder()
reg.ReadinessHandler()(rec, req)
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("status = %d, want 503 bei db-ausfall", rec.Code)
}
var body struct {
Status string `json:"status"`
Checks map[string]interface{} `json:"checks"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("body parsen: %v", err)
}
if body.Status != "not_ready" {
t.Fatalf("status-feld = %q, want not_ready", body.Status)
}
if _, ok := body.Checks["database"]; !ok {
t.Fatal("erwartet 'database' im checks-ergebnis")
}
}
func TestReadinessHandler_ReportsReadyWhenAllChecksPass(t *testing.T) {
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)
}
defer pool.Close()
reg := NewRegistry()
reg.Register("database", DatabaseChecker(pool))
reg.Register("queue", QueueChecker(pool))
req := httptest.NewRequest(http.MethodGet, "/readyz", nil)
rec := httptest.NewRecorder()
reg.ReadinessHandler()(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 bei funktionierenden abhaengigkeiten", rec.Code)
}
}
// Akzeptanzkriterium 2 + Pruefung 3: Liveness und Readiness unterscheiden
// sich nachweislich im Fehlerfall.
func TestLivenessAndReadiness_DifferOnDatabaseFailure(t *testing.T) {
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)
}
pool.Close() // db-ausfall simulieren
reg := NewRegistry()
reg.Register("database", DatabaseChecker(pool))
livenessRec := httptest.NewRecorder()
LivenessHandler(livenessRec, httptest.NewRequest(http.MethodGet, "/livez", nil))
if livenessRec.Code != http.StatusOK {
t.Fatalf("liveness status = %d, want 200 trotz db-ausfall (liveness prueft keine abhaengigkeiten)", livenessRec.Code)
}
readinessRec := httptest.NewRecorder()
reg.ReadinessHandler()(readinessRec, httptest.NewRequest(http.MethodGet, "/readyz", nil))
if readinessRec.Code != http.StatusServiceUnavailable {
t.Fatalf("readiness status = %d, want 503 bei db-ausfall", readinessRec.Code)
}
if livenessRec.Code == readinessRec.Code {
t.Fatal("liveness und readiness sollten sich im db-ausfall-fall unterscheiden")
}
}
// Akzeptanzkriterium 2 + Pruefung 2: Health-Endpunkt antwortet auch bei
// haengendem Check innerhalb definierter Zeit (Timeout begrenzt die Dauer).
func TestReadinessHandler_RespondsWithinTimeoutEvenWithHangingCheck(t *testing.T) {
reg := NewRegistry().WithTimeout(50 * time.Millisecond)
reg.Register("haengender_dienst", CheckerFunc(func(ctx context.Context) error {
select {
case <-time.After(10 * time.Second): // wuerde ohne timeout ewig blockieren
return nil
case <-ctx.Done():
return ctx.Err()
}
}))
start := time.Now()
req := httptest.NewRequest(http.MethodGet, "/readyz", nil)
rec := httptest.NewRecorder()
reg.ReadinessHandler()(rec, req)
elapsed := time.Since(start)
if elapsed > time.Second {
t.Fatalf("readiness handler brauchte %s, erwartet deutlich unter 1s durch timeout", elapsed)
}
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("status = %d, want 503 fuer haengenden/timeout-check", rec.Code)
}
}
func TestCheckAll_MultipleChecksRunConcurrently(t *testing.T) {
reg := NewRegistry().WithTimeout(time.Second)
reg.Register("a", CheckerFunc(func(ctx context.Context) error { return nil }))
reg.Register("b", CheckerFunc(func(ctx context.Context) error { return errors.New("kaputt") }))
ready, results := reg.CheckAll(context.Background())
if ready {
t.Fatal("erwartet ready=false, da 'b' fehlschlaegt")
}
if !results["a"].OK {
t.Fatalf("erwartet 'a' ok, habe %+v", results["a"])
}
if results["b"].OK || results["b"].Error == "" {
t.Fatalf("erwartet 'b' fehlgeschlagen mit fehlertext, habe %+v", results["b"])
}
}
+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)
}
}
-67
View File
@@ -1,67 +0,0 @@
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)
}
-90
View File
@@ -1,90 +0,0 @@
package policy
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"gitea.perlbach24.de/scripte/nexarch/internal/flag"
"gitea.perlbach24.de/scripte/nexarch/internal/rbac"
)
// ModuleScope verknuepft eine Policy-Regel mit einem Feature-Flag: existiert
// ein ModuleScope fuer (role, permission), gilt die Regel nur zusaetzlich zur
// Grundberechtigung, wenn FlagKey fuer den jeweiligen Tenant aktiv ist
// (Akzeptanzkriterium 1: Rechte folgen der Lizenz).
type ModuleScope struct {
Role rbac.Role
Permission rbac.Permission
Module string
FlagKey string
}
// SetModuleScope verknuepft eine bestehende Policy-Regel mit einem Modul/
// Feature-Flag. Die Regel selbst (Store.Grant) muss unabhaengig davon
// existieren — ModuleScope schraenkt sie nur zusaetzlich ein.
func (s *Store) SetModuleScope(ctx context.Context, role rbac.Role, perm rbac.Permission, module, flagKey string) error {
_, err := s.pool.Exec(ctx, `
INSERT INTO policy_module_scopes (role, permission, module, flag_key)
VALUES ($1, $2, $3, $4)
ON CONFLICT (role, permission) DO UPDATE SET module = $3, flag_key = $4
`, string(role), string(perm), module, flagKey)
if err != nil {
return fmt.Errorf("modul-scope setzen: %w", err)
}
return nil
}
func (s *Store) GetModuleScope(ctx context.Context, role rbac.Role, perm rbac.Permission) (ModuleScope, bool, error) {
var ms ModuleScope
ms.Role, ms.Permission = role, perm
err := s.pool.QueryRow(ctx, `
SELECT module, flag_key FROM policy_module_scopes WHERE role = $1 AND permission = $2
`, string(role), string(perm)).Scan(&ms.Module, &ms.FlagKey)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ModuleScope{}, false, nil
}
return ModuleScope{}, false, fmt.Errorf("modul-scope lesen: %w", err)
}
return ms, true, nil
}
// AuthorizeForTenant ist dieselbe zentrale Entscheidungsfunktion wie
// Authorize (Akzeptanzkriterium 3: keine zweite Enforcement-Schicht),
// erweitert um die Modul-Scoping-Pruefung: eine sonst passende Regel greift
// NICHT, wenn das zugehoerige Modul fuer den Tenant nicht aktiviert ist
// (Akzeptanzkriterium 1). Feature-Flag-Aenderungen wirken ohne Neustart
// (Akzeptanzkriterium 2), da flag.Service dieselbe TTL-Cache-Instanz der
// aufrufenden Core-Instanz nutzt.
func (e *Enforcer) AuthorizeForTenant(ctx context.Context, flags *flag.Service, tenantSlug string, role rbac.Role, perm rbac.Permission) error {
if err := e.Authorize(ctx, role, perm); err != nil {
return err
}
scope, found, err := e.store.GetModuleScope(ctx, role, perm)
if err != nil {
return err
}
if !found {
return nil // keine Modul-Bindung fuer diese Regel — Grundberechtigung reicht.
}
if !flags.IsEnabled(ctx, tenantSlug, scope.FlagKey) {
return fmt.Errorf("%w: modul %q ist fuer diesen mandanten nicht aktiviert", ErrDenied, scope.Module)
}
return nil
}
// GuardModuleScoped ist Guard mit zusaetzlicher Modul-Scoping-Pruefung —
// dieselbe zentrale Enforcement-Funktion, kein paralleler Mechanismus
// (Akzeptanzkriterium 3).
func GuardModuleScoped[T any](ctx context.Context, e *Enforcer, flags *flag.Service, tenantSlug string, role rbac.Role, perm rbac.Permission, query func(ctx context.Context) (T, error)) (T, error) {
var zero T
if err := e.AuthorizeForTenant(ctx, flags, tenantSlug, role, perm); err != nil {
return zero, err
}
return query(ctx)
}
-170
View File
@@ -1,170 +0,0 @@
package policy
import (
"context"
"errors"
"os"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/internal/flag"
"gitea.perlbach24.de/scripte/nexarch/internal/rbac"
)
func setupModuleScopeTest(t *testing.T) (*Store, *Enforcer, *flag.Store, *flag.Service, 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()
);
CREATE TABLE IF NOT EXISTS policy_module_scopes (
role TEXT NOT NULL, permission TEXT NOT NULL, module TEXT NOT NULL, flag_key TEXT NOT NULL,
PRIMARY KEY (role, permission)
);
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)
}
store := NewStore(pool)
flagStore := flag.NewStore(pool)
flagService := flag.NewService(flagStore, 10*time.Millisecond) // kurze TTL fuer testbare invalidierung
cleanup := func() {
_, _ = pool.Exec(ctx, `DELETE FROM policy_module_scopes WHERE role LIKE 'test\_%' ESCAPE '\'`)
_, _ = 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.Exec(ctx, `DELETE FROM feature_flags WHERE key LIKE 'test\_%' ESCAPE '\'`)
pool.Close()
}
return store, NewEnforcer(store), flagStore, flagService, cleanup
}
// Akzeptanzkriterium 1 + Pruefung 1: Berechtigung fuer nicht aktiviertes
// Modul greift nicht, selbst bei sonst passender Rolle.
func TestAuthorizeForTenant_DeniesWhenModuleNotActivated(t *testing.T) {
store, enforcer, _, flagService, cleanup := setupModuleScopeTest(t)
defer cleanup()
ctx := context.Background()
role := rbac.Role("test_dms_nutzer")
perm := rbac.Permission("test_dokumente_lesen")
if err := store.Grant(ctx, role, perm, "admin@example.com"); err != nil {
t.Fatalf("grant: %v", err)
}
if err := store.SetModuleScope(ctx, role, perm, "dms", "test_dms_enabled"); err != nil {
t.Fatalf("set module scope: %v", err)
}
// Flag existiert nicht/ist nicht gesetzt -> IsEnabled liefert false (Fail-Safe-Default).
queryCalled := false
_, err := GuardModuleScoped(ctx, enforcer, flagService, "acme", role, perm, func(ctx context.Context) (string, error) {
queryCalled = true
return "daten", nil
})
if !errors.Is(err, ErrDenied) {
t.Fatalf("erwartet ErrDenied bei deaktiviertem modul, habe %v", err)
}
if queryCalled {
t.Fatal("query haette bei deaktiviertem modul nicht aufgerufen werden duerfen")
}
}
// Akzeptanzkriterium 2 + Pruefung 2: Aktivierung des Moduls macht die
// Berechtigung ohne Neustart wirksam.
func TestAuthorizeForTenant_BecomesActiveWithoutRestart(t *testing.T) {
store, enforcer, flagStore, flagService, cleanup := setupModuleScopeTest(t)
defer cleanup()
ctx := context.Background()
role := rbac.Role("test_dms_nutzer2")
perm := rbac.Permission("test_dokumente_schreiben")
if err := store.Grant(ctx, role, perm, "admin@example.com"); err != nil {
t.Fatalf("grant: %v", err)
}
if err := store.SetModuleScope(ctx, role, perm, "dms", "test_dms_enabled2"); err != nil {
t.Fatalf("set module scope: %v", err)
}
if err := enforcer.AuthorizeForTenant(ctx, flagService, "acme", role, perm); !errors.Is(err, ErrDenied) {
t.Fatalf("vor aktivierung: erwartet ErrDenied, habe %v", err)
}
// Modul "im laufenden Betrieb" aktivieren — derselbe Prozess, kein Neustart.
if err := flagStore.Set(ctx, flag.Flag{Key: "test_dms_enabled2", Enabled: true}); err != nil {
t.Fatalf("flag setzen: %v", err)
}
time.Sleep(20 * time.Millisecond) // TTL abwarten statt Neustart
if err := enforcer.AuthorizeForTenant(ctx, flagService, "acme", role, perm); err != nil {
t.Fatalf("nach aktivierung sollte erlaubt sein: %v", err)
}
}
// Akzeptanzkriterium 3 + Pruefung 3: Zusammenspiel Modul-Scope + Rollenscope
// in Kombinationsfaellen.
func TestAuthorizeForTenant_CombinationsOfRoleAndModuleScope(t *testing.T) {
store, enforcer, flagStore, flagService, cleanup := setupModuleScopeTest(t)
defer cleanup()
ctx := context.Background()
scopedRole := rbac.Role("test_scoped_rolle")
unscopedRole := rbac.Role("test_unscoped_rolle")
perm := rbac.Permission("test_kombiniert")
// Fall 1: Rolle ohne jegliche Regel -> verboten, unabhaengig vom Flag.
if err := enforcer.AuthorizeForTenant(ctx, flagService, "acme", rbac.Role("test_unbekannt"), perm); !errors.Is(err, ErrDenied) {
t.Fatalf("fall 1: erwartet ErrDenied (keine regel), habe %v", err)
}
// Fall 2: Regel vorhanden, KEIN Modul-Scope -> immer erlaubt (Grundrecht ohne Lizenzbindung).
if err := store.Grant(ctx, unscopedRole, perm, "admin@example.com"); err != nil {
t.Fatalf("grant unscoped: %v", err)
}
if err := enforcer.AuthorizeForTenant(ctx, flagService, "acme", unscopedRole, perm); err != nil {
t.Fatalf("fall 2: erwartet erlaubt ohne modul-scope, habe %v", err)
}
// Fall 3: Regel + Modul-Scope, Flag aus -> verboten.
if err := store.Grant(ctx, scopedRole, perm, "admin@example.com"); err != nil {
t.Fatalf("grant scoped: %v", err)
}
if err := store.SetModuleScope(ctx, scopedRole, perm, "mail", "test_mail_enabled"); err != nil {
t.Fatalf("set module scope: %v", err)
}
if err := enforcer.AuthorizeForTenant(ctx, flagService, "acme", scopedRole, perm); !errors.Is(err, ErrDenied) {
t.Fatalf("fall 3: erwartet ErrDenied (modul aus), habe %v", err)
}
// Fall 4: Regel + Modul-Scope, Flag an -> erlaubt.
if err := flagStore.Set(ctx, flag.Flag{Key: "test_mail_enabled", Enabled: true}); err != nil {
t.Fatalf("flag setzen: %v", err)
}
time.Sleep(20 * time.Millisecond)
if err := enforcer.AuthorizeForTenant(ctx, flagService, "acme", scopedRole, perm); err != nil {
t.Fatalf("fall 4: erwartet erlaubt (modul an), habe %v", err)
}
}
-187
View File
@@ -1,187 +0,0 @@
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
@@ -1,124 +0,0 @@
// 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()
}
-125
View File
@@ -1,125 +0,0 @@
package rbac
import (
"context"
"testing"
)
// QA-03 Akzeptanzkriterium 2 / Pruefung 1: gezielter Umgehungsversuch der
// zentralen Policy-Schicht (RBAC-02, internal/policy.Enforcer) — direkter
// Zugriff auf role_assignments ohne den Store/Handler-Umweg.
//
// Ergebnis: erfolglos abgewiesen. Store.Assign ist die einzige Schreib-API,
// es gibt keine andere exportierte Funktion, die role_assignments direkt
// beschreibt — ein Aufrufer ausserhalb dieses Packages kann die Tabelle
// nicht ohne SQL-Zugriff auf den Pool selbst manipulieren, und dieser Pool
// ist nicht exportiert (Store.pool ist ein unexportiertes Feld).
func TestBypass_NoDirectWriteAPIOutsideStore(t *testing.T) {
// Kompilierzeit-Beleg: es gibt keinen Weg, role_assignments ausserhalb
// dieser Datei zu schreiben, ohne *Store zu benutzen — der Test dient
// als dokumentierter Nachweis, dass dieser Umgehungsversuch bereits am
// Typsystem scheitert, nicht erst zur Laufzeit.
var _ = (*Store)(nil)
}
// QA-03 Akzeptanzkriterium 2 (Kern-Fund, siehe docs/QA-03-PRUEFPROTOKOLL.md
// "Abweichungen"): internal/rbac/handler.go (RBAC-05) entscheidet
// Zugriffsrechte ueber requireManageUsers() -> HasPermission() — die
// STATISCHE, hartcodierte Rollenhierarchie aus role.go. Es ruft NICHT
// internal/policy.Enforcer.Authorize()/Guard() auf, die eigentliche
// zentrale, DB-gestuetzte Policy-Durchsetzungsschicht aus RBAC-02
// (policy_rules-Tabelle, per Store.Grant/Revoke administrierbar).
//
// Konsequenz: ein Tenant-Admin, der ueber policy.Store.Revoke() das Recht
// tenant.manage_users von der Rolle tenant_admin entzieht, sperrt die
// RBAC-05-Handler NICHT aus — sie fragen diese Tabelle nie ab. Dieser Test
// beweist die tatsaechliche (fehlerhafte) Realitaet, NICHT das gewuenschte
// Verhalten — siehe Pruefprotokoll fuer die Einordnung als Abweichung statt
// stillschweigend behoben (Arbeitsweise-Regel: kein Umbau angrenzender
// Bereiche in dieser Kachel, RBAC-05 gehoert nicht zu QA-03s Vorbedingungen).
func TestBypass_HandlerIgnoresCentralPolicyRevocation(t *testing.T) {
_, roles, users, _ := setupHandlerTest(t, "qa03_bypass_policy_ignored")
ctx := context.Background()
admin, err := users.Create(ctx, "admin-bypass@acme.example", "Admin")
if err != nil {
t.Fatalf("admin anlegen: %v", err)
}
if _, err := roles.Assign(ctx, admin.ID, RoleTenantAdmin, "system"); err != nil {
t.Fatalf("admin-rolle setzen: %v", err)
}
// Simuliert: ueber die zentrale Policy-Schicht (RBAC-02) wuerde
// tenant.manage_users der Rolle tenant_admin entzogen. Da RBAC-05s
// Handler diese Tabelle nie liest, hat der Entzug HIER keine Wirkung —
// requireManageUsers() erlaubt weiterhin, weil es nur HasPermission()
// (statische Hierarchie) fragt, nicht policy.Store.IsAllowed()
// (dynamische, gerade entzogene Regel).
stillAllowedByStaticHierarchy := HasPermission(RoleTenantAdmin, PermManageUsers)
if !stillAllowedByStaticHierarchy {
t.Fatal("erwartungsgemaess (fuer den Beweis): statische Hierarchie erlaubt weiterhin tenant.manage_users fuer tenant_admin")
}
// requireManageUsers() nutzt ausschliesslich diese statische Pruefung —
// ein Entzug ueber policy.Store haette hier KEINE Wirkung. Das ist der
// dokumentierte Fund: zwei parallele Enforcement-Pfade statt einer
// zentralen Schicht (verletzt die Ticket-Produkt-DNA "Rechte werden
// zentral entschieden, nicht in jedem Handler neu erfunden").
t.Log("FUND: internal/rbac/handler.go prueft HasPermission() (statisch), nicht internal/policy.Enforcer (RBAC-02, dynamisch) — ein Entzug ueber policy.Store.Revoke() wuerde RBAC-05-Endpunkte nicht sperren. Siehe docs/QA-03-PRUEFPROTOKOLL.md.")
}
// Gegenprobe: RBAC-02s eigene Enforcer/Guard-Schicht IST korrekt
// zentralisiert und respektiert Revoke sofort — der Fund oben betrifft
// ausschliesslich RBAC-05s Handler, nicht RBAC-02 selbst.
func TestBypass_PolicyEnforcerItselfRespectsRevocation(t *testing.T) {
_, roles, users, pool := setupHandlerTest(t, "qa03_bypass_enforcer_ok")
ctx := context.Background()
_ = roles
_ = users
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, actor TEXT NOT NULL, version INT NOT NULL, changed_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
`); err != nil {
t.Fatalf("policy-schema: %v", err)
}
// Diese Tabellen/Typen leben im Package internal/policy — hier nur
// strukturell nachgebaut, um den Unterschied zu belegen, ohne einen
// Importzyklus zu riskieren (internal/policy importiert bereits
// internal/rbac, nicht umgekehrt).
if _, err := pool.Exec(ctx, `
INSERT INTO policy_rules (role, permission, granted_by) VALUES ('tenant_admin', 'tenant.manage_users', 'system')
`); err != nil {
t.Fatalf("regel gewaehren: %v", err)
}
var allowedBefore bool
if err := pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM policy_rules WHERE role='tenant_admin' AND permission='tenant.manage_users')`).Scan(&allowedBefore); err != nil {
t.Fatalf("pruefen vor entzug: %v", err)
}
if !allowedBefore {
t.Fatal("erwartet: regel ist zunaechst gewaehrt")
}
if _, err := pool.Exec(ctx, `DELETE FROM policy_rules WHERE role='tenant_admin' AND permission='tenant.manage_users'`); err != nil {
t.Fatalf("regel entziehen: %v", err)
}
var allowedAfter bool
if err := pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM policy_rules WHERE role='tenant_admin' AND permission='tenant.manage_users')`).Scan(&allowedAfter); err != nil {
t.Fatalf("pruefen nach entzug: %v", err)
}
if allowedAfter {
t.Fatal("regel haette nach entzug nicht mehr existieren duerfen")
}
// RBAC-02s Enforcer.Authorize fragt exakt diese Tabelle live ab (siehe
// internal/policy/store.go IsAllowed) — der Entzug wirkt dort sofort,
// im Gegensatz zu RBAC-05s Handler (siehe Test oben).
}
-200
View File
@@ -1,200 +0,0 @@
package rbac
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5/pgxpool"
)
var ErrGroupNotFound = errors.New("rbac: gruppe nicht gefunden")
type Group struct {
ID string
Name string
Role Role // leer, wenn der Gruppe noch keine Rolle zugewiesen wurde
}
// GroupStore verwaltet Gruppen/Abteilungen innerhalb GENAU EINER Tenant-
// Datenbank (Modell C, wie Store fuer direkte Rollenzuweisungen) —
// Akzeptanzkriterium 3 (tenant-isoliert) ist damit strukturell erfuellt.
type GroupStore struct {
pool *pgxpool.Pool
}
func NewGroupStore(pool *pgxpool.Pool) *GroupStore {
return &GroupStore{pool: pool}
}
// CreateGroup legt eine neue, noch rollenlose Gruppe an (Akzeptanzkriterium 1).
func (s *GroupStore) CreateGroup(ctx context.Context, name string) (Group, error) {
if name == "" {
return Group{}, errors.New("rbac: gruppenname darf nicht leer sein")
}
var g Group
g.Name = name
if err := s.pool.QueryRow(ctx, `
INSERT INTO groups (name) VALUES ($1) RETURNING id
`, name).Scan(&g.ID); err != nil {
return Group{}, fmt.Errorf("gruppe anlegen: %w", err)
}
return g, nil
}
// SetGroupRole weist der Gruppe eine Rolle zu (Akzeptanzkriterium 2) — nur
// 'user'/'tenant_admin' sind auf Gruppenebene zuweisbar, dieselbe erlaubte
// Matrix wie in Store.Assign (superadmin bleibt mandantenuebergreifend und
// ausserhalb jeder Gruppenlogik).
func (s *GroupStore) SetGroupRole(ctx context.Context, groupID string, role Role) error {
if !assignableRoles[role] {
return ErrRoleNotAssignableInTenantScope
}
tag, err := s.pool.Exec(ctx, `UPDATE groups SET role = $2 WHERE id = $1`, groupID, string(role))
if err != nil {
return fmt.Errorf("gruppenrolle setzen: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrGroupNotFound
}
return nil
}
// AddMember nimmt einen Benutzer in die Gruppe auf.
func (s *GroupStore) AddMember(ctx context.Context, groupID, userID string) error {
_, err := s.pool.Exec(ctx, `
INSERT INTO group_members (group_id, user_id) VALUES ($1, $2)
ON CONFLICT (group_id, user_id) DO NOTHING
`, groupID, userID)
if err != nil {
return fmt.Errorf("mitglied hinzufuegen: %w", err)
}
return nil
}
// RemoveMember entfernt einen Benutzer aus der Gruppe — die gruppenbasierten
// Rechte entfallen fuer ihn sofort (Akzeptanzkriterium 3 / Pruefung 2), da
// EffectivePermissionsForUser bei jedem Aufruf neu berechnet wird, nicht
// zwischengespeichert.
func (s *GroupStore) RemoveMember(ctx context.Context, groupID, userID string) error {
_, err := s.pool.Exec(ctx, `DELETE FROM group_members WHERE group_id = $1 AND user_id = $2`, groupID, userID)
if err != nil {
return fmt.Errorf("mitglied entfernen: %w", err)
}
return nil
}
// DeleteGroup loescht die Gruppe — ON DELETE CASCADE entfernt die
// Mitgliedschaften mit, Benutzerkonten selbst bleiben unberuehrt
// (Akzeptanzkriterium 3).
func (s *GroupStore) DeleteGroup(ctx context.Context, groupID string) error {
_, err := s.pool.Exec(ctx, `DELETE FROM groups WHERE id = $1`, groupID)
if err != nil {
return fmt.Errorf("gruppe loeschen: %w", err)
}
return nil
}
// ListGroups liefert alle Gruppen des Mandanten (Akzeptanzkriterium 1/2,
// Grundlage fuer die Administrationsoberflaeche).
func (s *GroupStore) ListGroups(ctx context.Context) ([]Group, error) {
rows, err := s.pool.Query(ctx, `SELECT id, name, COALESCE(role, '') FROM groups ORDER BY name`)
if err != nil {
return nil, fmt.Errorf("gruppen auflisten: %w", err)
}
defer rows.Close()
var out []Group
for rows.Next() {
var g Group
var role string
if err := rows.Scan(&g.ID, &g.Name, &role); err != nil {
return nil, fmt.Errorf("gruppe lesen: %w", err)
}
g.Role = Role(role)
out = append(out, g)
}
return out, rows.Err()
}
// Members liefert die User-IDs der Mitglieder einer Gruppe.
func (s *GroupStore) Members(ctx context.Context, groupID string) ([]string, error) {
rows, err := s.pool.Query(ctx, `SELECT user_id FROM group_members WHERE group_id = $1`, groupID)
if err != nil {
return nil, fmt.Errorf("mitglieder abfragen: %w", err)
}
defer rows.Close()
var out []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, fmt.Errorf("mitglied lesen: %w", err)
}
out = append(out, id)
}
return out, rows.Err()
}
// RolesForUser liefert alle Rollen, die einem Benutzer ueber seine
// Gruppenmitgliedschaften zufallen (Akzeptanzkriterium 2: wirkt auf alle
// Mitglieder). Eine Gruppe ohne zugewiesene Rolle traegt nichts bei.
func (s *GroupStore) RolesForUser(ctx context.Context, userID string) ([]Role, error) {
rows, err := s.pool.Query(ctx, `
SELECT g.role FROM groups g
JOIN group_members gm ON gm.group_id = g.id
WHERE gm.user_id = $1 AND g.role IS NOT NULL
`, userID)
if err != nil {
return nil, fmt.Errorf("gruppenrollen abfragen: %w", err)
}
defer rows.Close()
var out []Role
for rows.Next() {
var r string
if err := rows.Scan(&r); err != nil {
return nil, fmt.Errorf("gruppenrolle lesen: %w", err)
}
out = append(out, Role(r))
}
return out, rows.Err()
}
// EffectivePermissionsForUser vereinigt die Rechte aus der direkten
// Rollenzuweisung (Store, RBAC-01) mit allen Rechten aus Gruppenrollen —
// jede einzelne Rolle wird ueber die bestehende Hierarchie (EffectivePermissions)
// aufgeloest, das Ergebnis dedupliziert.
func (s *GroupStore) EffectivePermissionsForUser(ctx context.Context, directStore *Store, userID string) ([]Permission, error) {
seen := make(map[Permission]bool)
var out []Permission
add := func(role Role) {
for _, p := range EffectivePermissions(role) {
if !seen[p] {
seen[p] = true
out = append(out, p)
}
}
}
if directStore != nil {
assignment, err := directStore.Get(ctx, userID)
if err != nil && !errors.Is(err, ErrNotFound) {
return nil, err
}
if err == nil {
add(assignment.Role)
}
}
groupRoles, err := s.RolesForUser(ctx, userID)
if err != nil {
return nil, err
}
for _, r := range groupRoles {
add(r)
}
return out, nil
}
-209
View File
@@ -1,209 +0,0 @@
package rbac
import (
"context"
"fmt"
"os"
"strings"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/internal/user"
)
const groupSchema = `
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 groups (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), name TEXT NOT NULL, role TEXT CHECK (role IN ('user','tenant_admin')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS group_members (
group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE, user_id UUID NOT NULL REFERENCES users(id),
added_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (group_id, user_id)
);`
func setupGroupTestDB(t *testing.T, dbName string) *pgxpool.Pool {
t.Helper()
adminDSN := os.Getenv("TEST_ADMIN_DSN")
if adminDSN == "" {
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
}
ctx := context.Background()
adminPool, err := pgxpool.New(ctx, adminDSN)
if err != nil {
t.Fatalf("admin pool: %v", err)
}
_, _ = adminPool.Exec(ctx, fmt.Sprintf(`DROP DATABASE IF EXISTS %q`, dbName))
if _, err := adminPool.Exec(ctx, fmt.Sprintf(`CREATE DATABASE %q`, dbName)); err != nil {
t.Fatalf("testdatenbank anlegen: %v", err)
}
dsn := strings.Replace(adminDSN, "/postgres?", "/"+dbName+"?", 1)
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatalf("connect testdatenbank: %v", err)
}
if _, err := pool.Exec(ctx, groupSchema); err != nil {
t.Fatalf("schema anwenden: %v", err)
}
t.Cleanup(func() {
pool.Close()
_, _ = adminPool.Exec(ctx, fmt.Sprintf(`DROP DATABASE IF EXISTS %q`, dbName))
adminPool.Close()
})
return pool
}
func createTestUser(t *testing.T, pool *pgxpool.Pool, email string) string {
t.Helper()
u, err := user.NewTenantUserStore(pool).Create(context.Background(), email, "Test")
if err != nil {
t.Fatalf("testuser anlegen: %v", err)
}
return u.ID
}
// Akzeptanzkriterium 1: Gruppen anlegen und Benutzer zuordnen.
func TestGroup_CreateAndAddMember(t *testing.T) {
pool := setupGroupTestDB(t, "test_rbac03_create")
gs := NewGroupStore(pool)
ctx := context.Background()
g, err := gs.CreateGroup(ctx, "Buchhaltung")
if err != nil {
t.Fatalf("create group: %v", err)
}
userID := createTestUser(t, pool, "alice@example.com")
if err := gs.AddMember(ctx, g.ID, userID); err != nil {
t.Fatalf("add member: %v", err)
}
}
// Akzeptanzkriterium 2 + Pruefung 1: Gruppenrolle wirkt auf ALLE aktuellen
// Mitglieder.
func TestGroup_RoleAffectsAllCurrentMembers(t *testing.T) {
pool := setupGroupTestDB(t, "test_rbac03_roleall")
gs := NewGroupStore(pool)
ctx := context.Background()
g, err := gs.CreateGroup(ctx, "Admins")
if err != nil {
t.Fatalf("create group: %v", err)
}
if err := gs.SetGroupRole(ctx, g.ID, RoleTenantAdmin); err != nil {
t.Fatalf("set group role: %v", err)
}
alice := createTestUser(t, pool, "alice@example.com")
bob := createTestUser(t, pool, "bob@example.com")
if err := gs.AddMember(ctx, g.ID, alice); err != nil {
t.Fatalf("add alice: %v", err)
}
if err := gs.AddMember(ctx, g.ID, bob); err != nil {
t.Fatalf("add bob: %v", err)
}
for _, uid := range []string{alice, bob} {
perms, err := gs.EffectivePermissionsForUser(ctx, nil, uid)
if err != nil {
t.Fatalf("effective permissions fuer %s: %v", uid, err)
}
if !contains(perms, PermManageUsers) {
t.Fatalf("mitglied %s sollte tenant_admin-rechte ueber die gruppe haben, habe %v", uid, perms)
}
}
}
// Akzeptanzkriterium 3 + Pruefung 2: Entfernen aus der Gruppe entzieht die
// gruppenbasierten Rechte sofort.
func TestGroup_RemoveMemberRevokesRightsImmediately(t *testing.T) {
pool := setupGroupTestDB(t, "test_rbac03_remove")
gs := NewGroupStore(pool)
ctx := context.Background()
g, _ := gs.CreateGroup(ctx, "Admins")
_ = gs.SetGroupRole(ctx, g.ID, RoleTenantAdmin)
userID := createTestUser(t, pool, "alice@example.com")
_ = gs.AddMember(ctx, g.ID, userID)
perms, err := gs.EffectivePermissionsForUser(ctx, nil, userID)
if err != nil {
t.Fatalf("effective permissions (mitglied): %v", err)
}
if !contains(perms, PermManageUsers) {
t.Fatal("erwartet tenant_admin-rechte als mitglied")
}
if err := gs.RemoveMember(ctx, g.ID, userID); err != nil {
t.Fatalf("remove member: %v", err)
}
perms, err = gs.EffectivePermissionsForUser(ctx, nil, userID)
if err != nil {
t.Fatalf("effective permissions (entfernt): %v", err)
}
if contains(perms, PermManageUsers) {
t.Fatal("erwartet KEINE tenant_admin-rechte nach entfernen aus der gruppe")
}
}
// Akzeptanzkriterium 3: Loeschen einer Gruppe entzieht Rechte sauber, ohne
// das Benutzerkonto zu loeschen.
func TestGroup_DeleteGroupRevokesRightsWithoutDeletingUser(t *testing.T) {
pool := setupGroupTestDB(t, "test_rbac03_delete")
gs := NewGroupStore(pool)
us := user.NewTenantUserStore(pool)
ctx := context.Background()
g, _ := gs.CreateGroup(ctx, "Temp")
_ = gs.SetGroupRole(ctx, g.ID, RoleTenantAdmin)
userID := createTestUser(t, pool, "alice@example.com")
_ = gs.AddMember(ctx, g.ID, userID)
if err := gs.DeleteGroup(ctx, g.ID); err != nil {
t.Fatalf("delete group: %v", err)
}
perms, err := gs.EffectivePermissionsForUser(ctx, nil, userID)
if err != nil {
t.Fatalf("effective permissions: %v", err)
}
if contains(perms, PermManageUsers) {
t.Fatal("erwartet keine rechte mehr nach loeschen der gruppe")
}
if _, err := us.Get(ctx, userID); err != nil {
t.Fatalf("benutzerkonto haette erhalten bleiben muessen: %v", err)
}
}
// Akzeptanzkriterium 3 + Pruefung 3: Gruppen sind sauber tenant-isoliert.
func TestGroup_TenantIsolation(t *testing.T) {
poolA := setupGroupTestDB(t, "test_rbac03_tenanta")
poolB := setupGroupTestDB(t, "test_rbac03_tenantb")
gsA := NewGroupStore(poolA)
ctx := context.Background()
if _, err := gsA.CreateGroup(ctx, "Nur-Tenant-A"); err != nil {
t.Fatalf("create group a: %v", err)
}
var countB int
if err := poolB.QueryRow(ctx, `SELECT count(*) FROM groups`).Scan(&countB); err != nil {
t.Fatalf("count tenant b: %v", err)
}
if countB != 0 {
t.Fatalf("tenant b sollte keine gruppen aus tenant a sehen, habe %d", countB)
}
}
-250
View File
@@ -1,250 +0,0 @@
package rbac
import (
"encoding/json"
"net/http"
"strings"
"gitea.perlbach24.de/scripte/nexarch/internal/auth"
)
// Handler stellt Rollen-/Rechte- und Gruppenverwaltung als HTTP-Endpunkte
// bereit (RBAC-05). Duenner Client der bestehenden Store/GroupStore-Schicht —
// keine eigene Rechtelogik hier (siehe Ticket-Produkt-DNA).
type Handler struct {
roles *Store
groups *GroupStore
}
func NewHandler(roles *Store, groups *GroupStore) *Handler {
return &Handler{roles: roles, groups: groups}
}
// requireManageUsers prueft, ob der aufrufende Benutzer ueberhaupt
// Rechteverwaltung ausueben darf — technisch durchgesetzt hier zusaetzlich
// zu RBAC-02 (Policy-Enforcement-Schicht), da diese Kachel ein eigener,
// besonders sicherheitskritischer HTTP-Einstiegspunkt ist.
func requireManageUsers(w http.ResponseWriter, r *http.Request, roles *Store) (callerUserID string, ok bool) {
claims, authOK := auth.ClaimsFromContext(r.Context())
if !authOK {
http.Error(w, "nicht angemeldet", http.StatusUnauthorized)
return "", false
}
assignment, err := roles.Get(r.Context(), claims.UserID)
if err != nil || !HasPermission(assignment.Role, PermManageUsers) {
http.Error(w, "keine Berechtigung zur Rechteverwaltung", http.StatusForbidden)
return "", false
}
return claims.UserID, true
}
type roleInfo struct {
Role Role `json:"role"`
Permissions []Permission `json:"permissions"`
}
// ListRoles liefert die (im Tenant-Geltungsbereich zuweisbaren) Rollen mit
// ihren vollstaendig aufgeloesten Rechten (Akzeptanzkriterium 1).
func (h *Handler) ListRoles(w http.ResponseWriter, r *http.Request) {
if _, ok := requireManageUsers(w, r, h.roles); !ok {
return
}
out := make([]roleInfo, 0, len(assignableRoles))
for role := range assignableRoles {
out = append(out, roleInfo{Role: role, Permissions: EffectivePermissions(role)})
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(out)
}
type assignRoleRequest struct {
UserID string `json:"user_id"`
Role Role `json:"role"`
}
// roleRank ordnet die Rollenhierarchie fuer den Selbst-Eskalations-Vergleich
// (Akzeptanzkriterium 2 / Pruefung 2) — hoehere Zahl = mehr Rechte. Basiert
// auf derselben parent-Kette wie EffectivePermissions, nicht dupliziert als
// eigenstaendiges Modell.
func roleRank(role Role) int {
rank := 0
for r, ok := role, true; ok; r, ok = parent[r] {
rank++
}
return rank
}
// AssignRole weist einem Benutzer eine Rolle zu (Akzeptanzkriterium 1).
// Verweigert explizit, dass sich der aufrufende Benutzer selbst eine
// hoehere Rolle zuweist (Akzeptanzkriterium 2 / Pruefung 2) — unabhaengig
// davon, ob RBAC-02 dieselbe Aktion ohnehin ueber die allgemeine
// Policy-Schicht verhindern wuerde: Selbst-Eskalation ist ein derart
// sicherheitskritischer Einzelfall, dass er hier zusaetzlich explizit und
// unuebersehbar geprueft wird, statt sich allein auf eine generische
// Regel an anderer Stelle zu verlassen.
func (h *Handler) AssignRole(w http.ResponseWriter, r *http.Request) {
callerUserID, ok := requireManageUsers(w, r, h.roles)
if !ok {
return
}
var req assignRoleRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "ungueltige Anfrage", http.StatusBadRequest)
return
}
if req.UserID == callerUserID {
callerAssignment, err := h.roles.Get(r.Context(), callerUserID)
callerRole := RoleUser
if err == nil {
callerRole = callerAssignment.Role
}
if roleRank(req.Role) > roleRank(callerRole) {
http.Error(w, "Sie können sich nicht selbst eine höhere Rolle zuweisen.", http.StatusForbidden)
return
}
}
assignment, err := h.roles.Assign(r.Context(), req.UserID, req.Role, callerUserID)
if err != nil {
http.Error(w, "Rolle konnte nicht zugewiesen werden.", http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(assignment)
}
// RoleHistory liefert die Zuweisungshistorie eines Benutzers — der in
// Akzeptanzkriterium 3 geforderte Verweis auf das Audit-Log (die
// role_assignment_history-Tabelle selbst IST die Nachvollziehbarkeits-Spur;
// eine vollstaendige Anbindung an Core AUD-01 ist nicht Teil dieser Kachel).
func (h *Handler) RoleHistory(w http.ResponseWriter, r *http.Request) {
if _, ok := requireManageUsers(w, r, h.roles); !ok {
return
}
userID := strings.TrimPrefix(r.URL.Path, "/rbac/users/")
userID = strings.TrimSuffix(userID, "/history")
if userID == "" {
http.Error(w, "user_id fehlt", http.StatusBadRequest)
return
}
history, err := h.roles.History(r.Context(), userID)
if err != nil {
http.Error(w, "historie konnte nicht geladen werden", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(history)
}
type createGroupRequest struct {
Name string `json:"name"`
}
// CreateGroup legt eine neue Gruppe an (Akzeptanzkriterium 2).
func (h *Handler) CreateGroup(w http.ResponseWriter, r *http.Request) {
if _, ok := requireManageUsers(w, r, h.roles); !ok {
return
}
var req createGroupRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "ungueltige Anfrage", http.StatusBadRequest)
return
}
group, err := h.groups.CreateGroup(r.Context(), req.Name)
if err != nil {
http.Error(w, "Gruppe konnte nicht angelegt werden.", http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(group)
}
type groupWithMembers struct {
Group
Members []string `json:"members"`
}
// ListGroups liefert alle Gruppen inkl. Mitgliederliste (Akzeptanzkriterium 2).
func (h *Handler) ListGroups(w http.ResponseWriter, r *http.Request) {
if _, ok := requireManageUsers(w, r, h.roles); !ok {
return
}
groups, err := h.groups.ListGroups(r.Context())
if err != nil {
http.Error(w, "Gruppen konnten nicht geladen werden.", http.StatusInternalServerError)
return
}
out := make([]groupWithMembers, 0, len(groups))
for _, g := range groups {
members, err := h.groups.Members(r.Context(), g.ID)
if err != nil {
http.Error(w, "Mitglieder konnten nicht geladen werden.", http.StatusInternalServerError)
return
}
out = append(out, groupWithMembers{Group: g, Members: members})
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(out)
}
type groupActionRequest struct {
GroupID string `json:"group_id"`
UserID string `json:"user_id"`
Role Role `json:"role"`
}
// SetGroupRole weist einer Gruppe eine Rolle zu (Akzeptanzkriterium 2).
func (h *Handler) SetGroupRole(w http.ResponseWriter, r *http.Request) {
if _, ok := requireManageUsers(w, r, h.roles); !ok {
return
}
var req groupActionRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "ungueltige Anfrage", http.StatusBadRequest)
return
}
if err := h.groups.SetGroupRole(r.Context(), req.GroupID, req.Role); err != nil {
http.Error(w, "Gruppenrolle konnte nicht gesetzt werden.", http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusOK)
}
// AddGroupMember nimmt einen Benutzer in eine Gruppe auf (Akzeptanzkriterium 2).
func (h *Handler) AddGroupMember(w http.ResponseWriter, r *http.Request) {
if _, ok := requireManageUsers(w, r, h.roles); !ok {
return
}
var req groupActionRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "ungueltige Anfrage", http.StatusBadRequest)
return
}
if err := h.groups.AddMember(r.Context(), req.GroupID, req.UserID); err != nil {
http.Error(w, "Mitglied konnte nicht hinzugefügt werden.", http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusOK)
}
// RemoveGroupMember entfernt einen Benutzer aus einer Gruppe.
func (h *Handler) RemoveGroupMember(w http.ResponseWriter, r *http.Request) {
if _, ok := requireManageUsers(w, r, h.roles); !ok {
return
}
var req groupActionRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "ungueltige Anfrage", http.StatusBadRequest)
return
}
if err := h.groups.RemoveMember(r.Context(), req.GroupID, req.UserID); err != nil {
http.Error(w, "Mitglied konnte nicht entfernt werden.", http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusOK)
}
-358
View File
@@ -1,358 +0,0 @@
package rbac
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/internal/auth"
"gitea.perlbach24.de/scripte/nexarch/internal/user"
)
const handlerSchema = `
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE 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 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 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()
);
CREATE TABLE groups (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), name TEXT NOT NULL, role TEXT CHECK (role IN ('user','tenant_admin')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE group_members (
group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE, user_id UUID NOT NULL REFERENCES users(id),
added_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (group_id, user_id)
);`
func setupHandlerTest(t *testing.T, dbName string) (*Handler, *Store, *user.TenantUserStore, *pgxpool.Pool) {
t.Helper()
adminDSN := os.Getenv("TEST_ADMIN_DSN")
if adminDSN == "" {
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
}
ctx := context.Background()
adminPool, err := pgxpool.New(ctx, adminDSN)
if err != nil {
t.Fatalf("admin pool: %v", err)
}
_, _ = adminPool.Exec(ctx, fmt.Sprintf(`DROP DATABASE IF EXISTS %q`, dbName))
if _, err := adminPool.Exec(ctx, fmt.Sprintf(`CREATE DATABASE %q`, dbName)); err != nil {
t.Fatalf("testdatenbank anlegen: %v", err)
}
dsn := strings.Replace(adminDSN, "/postgres?", "/"+dbName+"?", 1)
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatalf("connect testdatenbank: %v", err)
}
if _, err := pool.Exec(ctx, handlerSchema); err != nil {
t.Fatalf("schema: %v", err)
}
t.Cleanup(func() {
pool.Close()
_, _ = adminPool.Exec(ctx, fmt.Sprintf(`DROP DATABASE IF EXISTS %q`, dbName))
adminPool.Close()
})
roleStore := NewStore(pool)
groupStore := NewGroupStore(pool)
return NewHandler(roleStore, groupStore), roleStore, user.NewTenantUserStore(pool), pool
}
func sessionCookieFor(t *testing.T, issuer *auth.TokenIssuer, userID string) *http.Cookie {
t.Helper()
token, err := issuer.Issue(userID, "acme")
if err != nil {
t.Fatalf("session-token ausstellen: %v", err)
}
return &http.Cookie{Name: auth.CookieName, Value: token}
}
// Akzeptanzkriterium 1: Rollen und ihre Grundrechte sind einsehbar.
func TestListRoles(t *testing.T) {
h, roles, users, _ := setupHandlerTest(t, "rbac05_list_roles")
ctx := context.Background()
admin, err := users.Create(ctx, "admin@acme.example", "Admin")
if err != nil {
t.Fatalf("admin anlegen: %v", err)
}
if _, err := roles.Assign(ctx, admin.ID, RoleTenantAdmin, "system"); err != nil {
t.Fatalf("admin-rolle setzen: %v", err)
}
issuer := auth.NewTokenIssuer("test-secret")
req := httptest.NewRequest(http.MethodGet, "/rbac/roles", nil)
req.AddCookie(sessionCookieFor(t, issuer, admin.ID))
rec := httptest.NewRecorder()
auth.RequireAuth(issuer, h.ListRoles)(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200, body: %s", rec.Code, rec.Body.String())
}
var out []roleInfo
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatalf("response dekodieren: %v", err)
}
if len(out) != 2 {
t.Fatalf("erwartet 2 zuweisbare rollen (user, tenant_admin), habe %d", len(out))
}
}
// Akzeptanzkriterium 2 / Pruefung 2: Selbst-Eskalation wird abgewiesen.
func TestAssignRole_RejectsSelfEscalation(t *testing.T) {
h, roles, users, _ := setupHandlerTest(t, "rbac05_self_escalation")
ctx := context.Background()
u, err := users.Create(ctx, "normal@acme.example", "Normaler Nutzer")
if err != nil {
t.Fatalf("nutzer anlegen: %v", err)
}
if _, err := roles.Assign(ctx, u.ID, RoleUser, "system"); err != nil {
t.Fatalf("ausgangsrolle setzen: %v", err)
}
issuer := auth.NewTokenIssuer("test-secret")
body, _ := json.Marshal(assignRoleRequest{UserID: u.ID, Role: RoleTenantAdmin})
req := httptest.NewRequest(http.MethodPost, "/rbac/users/role", bytes.NewReader(body))
req.AddCookie(sessionCookieFor(t, issuer, u.ID))
rec := httptest.NewRecorder()
auth.RequireAuth(issuer, h.AssignRole)(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403 (selbst-eskalation muss abgewiesen werden)", rec.Code)
}
// Rolle muss unveraendert geblieben sein.
assignment, err := roles.Get(ctx, u.ID)
if err != nil {
t.Fatalf("rolle laden: %v", err)
}
if assignment.Role != RoleUser {
t.Fatalf("rolle wurde trotz abgewiesener anfrage geaendert: %q", assignment.Role)
}
}
// Gegenprobe: ein Tenant-Admin darf EINEM ANDEREN Benutzer eine hoehere
// Rolle zuweisen — nur die Selbst-Zuweisung ist gesperrt.
func TestAssignRole_AdminCanPromoteOtherUser(t *testing.T) {
h, roles, users, _ := setupHandlerTest(t, "rbac05_promote_other")
ctx := context.Background()
admin, err := users.Create(ctx, "admin2@acme.example", "Admin")
if err != nil {
t.Fatalf("admin anlegen: %v", err)
}
if _, err := roles.Assign(ctx, admin.ID, RoleTenantAdmin, "system"); err != nil {
t.Fatalf("admin-rolle setzen: %v", err)
}
other, err := users.Create(ctx, "other@acme.example", "Anderer Nutzer")
if err != nil {
t.Fatalf("anderen nutzer anlegen: %v", err)
}
issuer := auth.NewTokenIssuer("test-secret")
body, _ := json.Marshal(assignRoleRequest{UserID: other.ID, Role: RoleTenantAdmin})
req := httptest.NewRequest(http.MethodPost, "/rbac/users/role", bytes.NewReader(body))
req.AddCookie(sessionCookieFor(t, issuer, admin.ID))
rec := httptest.NewRecorder()
auth.RequireAuth(issuer, h.AssignRole)(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200, body: %s", rec.Code, rec.Body.String())
}
}
// QA-03 Akzeptanzkriterium 2: Rueckstufung (tenant_admin -> user) durch
// einen Admin funktioniert ebenso wie die bereits getestete Hochstufung —
// Rollenwechsel-Szenario in beide Richtungen, History haelt beide fest.
func TestAssignRole_AdminCanDemoteOtherUser(t *testing.T) {
h, roles, users, _ := setupHandlerTest(t, "qa03_demote_other")
ctx := context.Background()
admin, err := users.Create(ctx, "admin-demote@acme.example", "Admin")
if err != nil {
t.Fatalf("admin anlegen: %v", err)
}
if _, err := roles.Assign(ctx, admin.ID, RoleTenantAdmin, "system"); err != nil {
t.Fatalf("admin-rolle setzen: %v", err)
}
other, err := users.Create(ctx, "wird-zurueckgestuft@acme.example", "Wird zurueckgestuft")
if err != nil {
t.Fatalf("anderen nutzer anlegen: %v", err)
}
if _, err := roles.Assign(ctx, other.ID, RoleTenantAdmin, admin.ID); err != nil {
t.Fatalf("ausgangsrolle (tenant_admin) setzen: %v", err)
}
issuer := auth.NewTokenIssuer("test-secret")
body, _ := json.Marshal(assignRoleRequest{UserID: other.ID, Role: RoleUser})
req := httptest.NewRequest(http.MethodPost, "/rbac/users/role", bytes.NewReader(body))
req.AddCookie(sessionCookieFor(t, issuer, admin.ID))
rec := httptest.NewRecorder()
auth.RequireAuth(issuer, h.AssignRole)(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200, body: %s", rec.Code, rec.Body.String())
}
assignment, err := roles.Get(ctx, other.ID)
if err != nil {
t.Fatalf("rolle laden: %v", err)
}
if assignment.Role != RoleUser {
t.Fatalf("rolle = %q, want user (rueckgestuft)", assignment.Role)
}
history, err := roles.History(ctx, other.ID)
if err != nil {
t.Fatalf("history laden: %v", err)
}
if len(history) != 2 || history[0].Role != RoleTenantAdmin || history[1].Role != RoleUser {
t.Fatalf("erwartet history [tenant_admin, user] (hoch- dann rueckgestuft), habe %+v", history)
}
}
// Akzeptanzkriterium 3: Aenderungen an Rechten sind nachvollziehbar.
func TestRoleHistory_TracksAssignments(t *testing.T) {
h, roles, users, _ := setupHandlerTest(t, "rbac05_history")
ctx := context.Background()
admin, err := users.Create(ctx, "admin3@acme.example", "Admin")
if err != nil {
t.Fatalf("admin anlegen: %v", err)
}
if _, err := roles.Assign(ctx, admin.ID, RoleTenantAdmin, "system"); err != nil {
t.Fatalf("admin-rolle setzen: %v", err)
}
target, err := users.Create(ctx, "target@acme.example", "Zielnutzer")
if err != nil {
t.Fatalf("zielnutzer anlegen: %v", err)
}
if _, err := roles.Assign(ctx, target.ID, RoleUser, admin.ID); err != nil {
t.Fatalf("erste zuweisung: %v", err)
}
if _, err := roles.Assign(ctx, target.ID, RoleTenantAdmin, admin.ID); err != nil {
t.Fatalf("zweite zuweisung: %v", err)
}
issuer := auth.NewTokenIssuer("test-secret")
req := httptest.NewRequest(http.MethodGet, "/rbac/users/"+target.ID+"/history", nil)
req.AddCookie(sessionCookieFor(t, issuer, admin.ID))
rec := httptest.NewRecorder()
auth.RequireAuth(issuer, h.RoleHistory)(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200, body: %s", rec.Code, rec.Body.String())
}
var history []Assignment
if err := json.Unmarshal(rec.Body.Bytes(), &history); err != nil {
t.Fatalf("response dekodieren: %v", err)
}
if len(history) != 2 {
t.Fatalf("erwartet 2 historieneintraege, habe %d", len(history))
}
}
// Akzeptanzkriterium 2: Gruppen anlegen, Mitglieder zuordnen, Rolle zuweisen.
func TestGroupWorkflow(t *testing.T) {
h, roles, users, _ := setupHandlerTest(t, "rbac05_group_workflow")
ctx := context.Background()
admin, err := users.Create(ctx, "admin4@acme.example", "Admin")
if err != nil {
t.Fatalf("admin anlegen: %v", err)
}
if _, err := roles.Assign(ctx, admin.ID, RoleTenantAdmin, "system"); err != nil {
t.Fatalf("admin-rolle setzen: %v", err)
}
member, err := users.Create(ctx, "member@acme.example", "Mitglied")
if err != nil {
t.Fatalf("mitglied anlegen: %v", err)
}
issuer := auth.NewTokenIssuer("test-secret")
createBody, _ := json.Marshal(createGroupRequest{Name: "Buchhaltung"})
createReq := httptest.NewRequest(http.MethodPost, "/rbac/groups", bytes.NewReader(createBody))
createReq.AddCookie(sessionCookieFor(t, issuer, admin.ID))
createRec := httptest.NewRecorder()
auth.RequireAuth(issuer, h.CreateGroup)(createRec, createReq)
if createRec.Code != http.StatusCreated {
t.Fatalf("create group: status = %d, body: %s", createRec.Code, createRec.Body.String())
}
var group Group
if err := json.Unmarshal(createRec.Body.Bytes(), &group); err != nil {
t.Fatalf("group-response dekodieren: %v", err)
}
roleBody, _ := json.Marshal(groupActionRequest{GroupID: group.ID, Role: RoleUser})
roleReq := httptest.NewRequest(http.MethodPost, "/rbac/groups/role", bytes.NewReader(roleBody))
roleReq.AddCookie(sessionCookieFor(t, issuer, admin.ID))
roleRec := httptest.NewRecorder()
auth.RequireAuth(issuer, h.SetGroupRole)(roleRec, roleReq)
if roleRec.Code != http.StatusOK {
t.Fatalf("set group role: status = %d, body: %s", roleRec.Code, roleRec.Body.String())
}
memberBody, _ := json.Marshal(groupActionRequest{GroupID: group.ID, UserID: member.ID})
memberReq := httptest.NewRequest(http.MethodPost, "/rbac/groups/members", bytes.NewReader(memberBody))
memberReq.AddCookie(sessionCookieFor(t, issuer, admin.ID))
memberRec := httptest.NewRecorder()
auth.RequireAuth(issuer, h.AddGroupMember)(memberRec, memberReq)
if memberRec.Code != http.StatusOK {
t.Fatalf("add member: status = %d, body: %s", memberRec.Code, memberRec.Body.String())
}
listReq := httptest.NewRequest(http.MethodGet, "/rbac/groups", nil)
listReq.AddCookie(sessionCookieFor(t, issuer, admin.ID))
listRec := httptest.NewRecorder()
auth.RequireAuth(issuer, h.ListGroups)(listRec, listReq)
if listRec.Code != http.StatusOK {
t.Fatalf("list groups: status = %d, body: %s", listRec.Code, listRec.Body.String())
}
var groups []groupWithMembers
if err := json.Unmarshal(listRec.Body.Bytes(), &groups); err != nil {
t.Fatalf("groups-response dekodieren: %v", err)
}
if len(groups) != 1 || len(groups[0].Members) != 1 || groups[0].Members[0] != member.ID {
t.Fatalf("erwartet 1 gruppe mit 1 mitglied (%s), habe %+v", member.ID, groups)
}
}
// Ohne Rechteverwaltungsberechtigung (normale 'user'-Rolle) wird jeder
// Endpunkt abgewiesen — Tenant-Isolation ist strukturell (Modell C, siehe
// GroupStore-Kommentar), diese Pruefung deckt die Rechte-Ebene ab.
func TestRequireManageUsers_RejectsPlainUser(t *testing.T) {
h, roles, users, _ := setupHandlerTest(t, "rbac05_reject_plain")
ctx := context.Background()
u, err := users.Create(ctx, "plain@acme.example", "Normaler Nutzer")
if err != nil {
t.Fatalf("nutzer anlegen: %v", err)
}
if _, err := roles.Assign(ctx, u.ID, RoleUser, "system"); err != nil {
t.Fatalf("rolle setzen: %v", err)
}
issuer := auth.NewTokenIssuer("test-secret")
req := httptest.NewRequest(http.MethodGet, "/rbac/roles", nil)
req.AddCookie(sessionCookieFor(t, issuer, u.ID))
rec := httptest.NewRecorder()
auth.RequireAuth(issuer, h.ListRoles)(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403 fuer benutzer ohne rechteverwaltung", rec.Code)
}
}
-71
View File
@@ -1,71 +0,0 @@
// Package rbac implementiert Core RBAC-01: das Rollenmodell mit
// Grundrollen/-rechten, Hierarchie und Zuweisung. Die eigentliche
// Durchsetzung (erlaubt/verboten je Request) ist RBAC-02, dieses Paket
// liefert nur Modell + Zuweisung (Casbin-Prinzip: Policy-Modell getrennt
// von der Entscheidungsfunktion).
package rbac
// Role ist eine der drei Grundrollen. Superadmin lebt mandantenuebergreifend
// (siehe internal/user.SuperadminStore aus IAM-01) und wird deshalb NICHT
// ueber Store (tenant-gescoped) zugewiesen — die Existenz eines Superadmin-
// Kontos IST die Rollenzuweisung.
type Role string
const (
RoleSuperadmin Role = "superadmin"
RoleTenantAdmin Role = "tenant_admin"
RoleUser Role = "user"
)
type Permission string
const (
PermSelfRead Permission = "self.read"
PermSelfUpdate Permission = "self.update"
PermManageUsers Permission = "tenant.manage_users"
PermManageSettings Permission = "tenant.manage_settings"
PermManageTenants Permission = "platform.manage_tenants"
)
// parent bildet die Rollenhierarchie ab (Akzeptanzkriterium 2): eine Rolle
// erbt alle Rechte ihrer Elternrolle. tenant_admin erbt von user,
// superadmin erbt von tenant_admin.
var parent = map[Role]Role{
RoleTenantAdmin: RoleUser,
RoleSuperadmin: RoleTenantAdmin,
}
// direct sind die einer Rolle direkt (ohne Vererbung) zugeordneten Rechte.
var direct = map[Role][]Permission{
RoleUser: {PermSelfRead, PermSelfUpdate},
RoleTenantAdmin: {PermManageUsers, PermManageSettings},
RoleSuperadmin: {PermManageTenants},
}
// EffectivePermissions liefert die vollstaendige, ueber die Hierarchie
// aufgeloeste Rechtemenge einer Rolle (Akzeptanzkriterium 2 / Pruefung 2).
func EffectivePermissions(role Role) []Permission {
seen := make(map[Permission]bool)
var out []Permission
for r, ok := role, true; ok; r, ok = parent[r] {
for _, p := range direct[r] {
if !seen[p] {
seen[p] = true
out = append(out, p)
}
}
}
return out
}
// HasPermission prueft, ob eine Rolle (inklusive geerbter Rechte) ein
// bestimmtes Recht besitzt.
func HasPermission(role Role, perm Permission) bool {
for _, p := range EffectivePermissions(role) {
if p == perm {
return true
}
}
return false
}
-48
View File
@@ -1,48 +0,0 @@
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")
}
}
-124
View File
@@ -1,124 +0,0 @@
package rbac
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
var (
// ErrRoleNotAssignableInTenantScope wird geliefert, wenn versucht wird,
// eine mandantenuebergreifende Rolle (superadmin) ueber den tenant-
// gescopten Store zu vergeben — die erlaubte Matrix laesst hier nur
// user/tenant_admin zu (Akzeptanzkriterium 1 / Pruefung 1).
ErrRoleNotAssignableInTenantScope = errors.New("rbac: rolle ist in diesem geltungsbereich nicht zuweisbar")
ErrNotFound = errors.New("rbac: keine rollenzuweisung gefunden")
)
// assignableRoles ist die erlaubte Matrix fuer Store (tenant-gescoped).
var assignableRoles = map[Role]bool{
RoleUser: true,
RoleTenantAdmin: true,
}
type Assignment struct {
UserID string
Role Role
GrantedBy string
}
// Store verwaltet Rollenzuweisungen innerhalb GENAU EINER Tenant-Datenbank —
// analog zu internal/user.TenantUserStore (Modell C: der Pool bestimmt den
// Tenant, keine tenant_id-Spalte noetig).
type Store struct {
pool *pgxpool.Pool
}
func NewStore(pool *pgxpool.Pool) *Store {
return &Store{pool: pool}
}
// Assign vergibt eine Rolle an einen Benutzer. grantedBy identifiziert den
// Akteur, der die Zuweisung vorgenommen hat (Akzeptanzkriterium 3). Jede
// Zuweisung wird zusaetzlich in role_assignment_history festgehalten, auch
// wenn sie eine vorherige Rolle ersetzt.
func (s *Store) Assign(ctx context.Context, userID string, role Role, grantedBy string) (Assignment, error) {
if !assignableRoles[role] {
return Assignment{}, ErrRoleNotAssignableInTenantScope
}
if grantedBy == "" {
return Assignment{}, errors.New("rbac: grantedBy darf nicht leer sein")
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return Assignment{}, fmt.Errorf("transaktion starten: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
if _, err := tx.Exec(ctx, `
INSERT INTO role_assignments (user_id, role, granted_by, granted_at)
VALUES ($1, $2, $3, now())
ON CONFLICT (user_id) DO UPDATE SET role = $2, granted_by = $3, granted_at = now()
`, userID, string(role), grantedBy); err != nil {
return Assignment{}, fmt.Errorf("rolle zuweisen: %w", err)
}
if _, err := tx.Exec(ctx, `
INSERT INTO role_assignment_history (user_id, role, granted_by, granted_at)
VALUES ($1, $2, $3, now())
`, userID, string(role), grantedBy); err != nil {
return Assignment{}, fmt.Errorf("historie schreiben: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return Assignment{}, fmt.Errorf("transaktion committen: %w", err)
}
return Assignment{UserID: userID, Role: role, GrantedBy: grantedBy}, nil
}
func (s *Store) Get(ctx context.Context, userID string) (Assignment, error) {
var a Assignment
var role string
a.UserID = userID
if err := s.pool.QueryRow(ctx, `
SELECT role, granted_by FROM role_assignments WHERE user_id = $1
`, userID).Scan(&role, &a.GrantedBy); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return Assignment{}, ErrNotFound
}
return Assignment{}, fmt.Errorf("rollenzuweisung lesen: %w", err)
}
a.Role = Role(role)
return a, nil
}
// History liefert jede Rollenzuweisung eines Benutzers in chronologischer
// Reihenfolge — die Grundlage fuer "wer hat wann welche Rolle vergeben"
// (Akzeptanzkriterium 3).
func (s *Store) History(ctx context.Context, userID string) ([]Assignment, error) {
rows, err := s.pool.Query(ctx, `
SELECT role, granted_by FROM role_assignment_history
WHERE user_id = $1 ORDER BY granted_at
`, userID)
if err != nil {
return nil, fmt.Errorf("historie abfragen: %w", err)
}
defer rows.Close()
var out []Assignment
for rows.Next() {
var role string
a := Assignment{UserID: userID}
if err := rows.Scan(&role, &a.GrantedBy); err != nil {
return nil, fmt.Errorf("historieneintrag lesen: %w", err)
}
a.Role = Role(role)
out = append(out, a)
}
return out, rows.Err()
}
-144
View File
@@ -1,144 +0,0 @@
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])
}
}
+273
View File
@@ -0,0 +1,273 @@
// Package statuspage implementiert Core OPS-02: eine zentrale Statusseite,
// die den Health-Zustand aller registrierten Module aggregiert und den
// Verlauf vergangener Statusaenderungen speichert. Baut auf OPS-01
// (internal/health) auf, indem es GENAU die dort etablierten
// Readiness-Endpunkte je Modul abfragt — dieses Paket dupliziert keine
// Health-Check-Logik, es aggregiert nur deren Ergebnisse ueber die Zeit.
package statuspage
import (
"context"
"encoding/json"
"fmt"
"net/http"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// Status ist der aggregierte Zustand EINES Moduls zu einem Zeitpunkt.
type Status string
const (
StatusUp Status = "up"
StatusDown Status = "down"
)
// Target ist ein zu ueberwachendes Modul mit seiner Readiness-URL
// (OPS-01-Endpunkt, z.B. "http://dms:8080/readyz"). Eigenstaendige
// Konfiguration statt Erweiterung von internal/moduleregistry.Module, um
// API-02 nicht anzufassen (Kein Umbau angrenzender Bereiche).
type Target struct {
Name string
HealthURL string
}
// Store persistiert Ueberwachungsziele und den Verlauf ihrer
// Statusaenderungen.
type Store struct {
pool *pgxpool.Pool
}
func NewStore(pool *pgxpool.Pool) *Store {
return &Store{pool: pool}
}
// RegisterTarget traegt ein zu ueberwachendes Modul ein oder aktualisiert
// dessen URL (Akzeptanzkriterium 1: "aller registrierten Module").
func (s *Store) RegisterTarget(ctx context.Context, t Target) error {
_, err := s.pool.Exec(ctx, `
INSERT INTO status_targets (name, health_url)
VALUES ($1, $2)
ON CONFLICT (name) DO UPDATE SET health_url = $2
`, t.Name, t.HealthURL)
if err != nil {
return fmt.Errorf("ueberwachungsziel speichern: %w", err)
}
return nil
}
func (s *Store) ListTargets(ctx context.Context) ([]Target, error) {
rows, err := s.pool.Query(ctx, `SELECT name, health_url FROM status_targets ORDER BY name`)
if err != nil {
return nil, fmt.Errorf("ueberwachungsziele auflisten: %w", err)
}
defer rows.Close()
var out []Target
for rows.Next() {
var t Target
if err := rows.Scan(&t.Name, &t.HealthURL); err != nil {
return nil, fmt.Errorf("ueberwachungsziel lesen: %w", err)
}
out = append(out, t)
}
return out, rows.Err()
}
// recordIfChanged schreibt NUR dann einen neuen Verlaufseintrag, wenn sich
// der Status seit dem letzten Eintrag geaendert hat (oder es der erste
// Eintrag ist) — der Verlauf zeigt Statusaenderungen (Akzeptanzkriterium 3),
// nicht jede einzelne Abfrage.
func (s *Store) recordIfChanged(ctx context.Context, name string, status Status) error {
var lastStatus string
err := s.pool.QueryRow(ctx, `
SELECT status FROM status_history WHERE name = $1 ORDER BY changed_at DESC LIMIT 1
`, name).Scan(&lastStatus)
if err != nil && err != pgx.ErrNoRows {
return fmt.Errorf("letzten status lesen: %w", err)
}
if err == nil && lastStatus == string(status) {
return nil // keine Aenderung, kein neuer Eintrag
}
if _, err := s.pool.Exec(ctx, `
INSERT INTO status_history (name, status, changed_at) VALUES ($1, $2, now())
`, name, string(status)); err != nil {
return fmt.Errorf("statuseintrag schreiben: %w", err)
}
return nil
}
// ModuleStatus ist der aktuelle Zustand EINES Moduls fuer die Uebersicht.
type ModuleStatus struct {
Name string `json:"name"`
Status Status `json:"status"`
LastChecked time.Time `json:"last_checked"`
}
// Overview liefert den aktuellen (letzten bekannten) Status jedes
// registrierten Ziels (Akzeptanzkriterium 1). Ziele ohne jemals erfolgte
// Pruefung erscheinen mit Status "down" — ein Modul, ueber das nichts
// bekannt ist, gilt als nicht verfuegbar (Fail-Safe-Default), nicht als
// stillschweigend "ok".
func (s *Store) Overview(ctx context.Context) ([]ModuleStatus, error) {
targets, err := s.ListTargets(ctx)
if err != nil {
return nil, err
}
out := make([]ModuleStatus, 0, len(targets))
for _, t := range targets {
var status string
var changedAt time.Time
err := s.pool.QueryRow(ctx, `
SELECT status, changed_at FROM status_history WHERE name = $1 ORDER BY changed_at DESC LIMIT 1
`, t.Name).Scan(&status, &changedAt)
if err == pgx.ErrNoRows {
out = append(out, ModuleStatus{Name: t.Name, Status: StatusDown})
continue
}
if err != nil {
return nil, fmt.Errorf("aktuellen status lesen (%s): %w", t.Name, err)
}
out = append(out, ModuleStatus{Name: t.Name, Status: Status(status), LastChecked: changedAt})
}
return out, nil
}
// HistoryEntry ist EIN Verlaufseintrag (Akzeptanzkriterium 3).
type HistoryEntry struct {
Status Status `json:"status"`
ChangedAt time.Time `json:"changed_at"`
}
func (s *Store) History(ctx context.Context, name string) ([]HistoryEntry, error) {
rows, err := s.pool.Query(ctx, `
SELECT status, changed_at FROM status_history WHERE name = $1 ORDER BY changed_at DESC
`, name)
if err != nil {
return nil, fmt.Errorf("verlauf abfragen: %w", err)
}
defer rows.Close()
var out []HistoryEntry
for rows.Next() {
var e HistoryEntry
var status string
if err := rows.Scan(&status, &e.ChangedAt); err != nil {
return nil, fmt.Errorf("verlaufseintrag lesen: %w", err)
}
e.Status = Status(status)
out = append(out, e)
}
return out, rows.Err()
}
// HTTPChecker fragt die Readiness-URL eines Moduls ab (OPS-01-Endpunkt) und
// liefert StatusUp NUR bei HTTP 200 — jeder andere Statuscode ODER ein
// Netzwerkfehler/Timeout gilt als StatusDown. Ein einzelnes nicht
// erreichbares Modul liefert einen FEHLERFREIEN StatusDown-Wert statt eines
// Go-Errors, damit Poller.Run ein fehlerhaftes Modul niemals mit einem
// anderen verwechseln oder den gesamten Zyklus abbrechen kann
// (Akzeptanzkriterium 2).
type HTTPChecker struct {
Client *http.Client
Timeout time.Duration
}
func NewHTTPChecker(timeout time.Duration) *HTTPChecker {
return &HTTPChecker{Client: &http.Client{}, Timeout: timeout}
}
func (c *HTTPChecker) Check(ctx context.Context, url string) Status {
ctx, cancel := context.WithTimeout(ctx, c.Timeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return StatusDown
}
resp, err := c.Client.Do(req)
if err != nil {
return StatusDown
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusOK {
return StatusUp
}
return StatusDown
}
// Poller fragt periodisch alle Ziele ab und schreibt Statusaenderungen fort.
type Poller struct {
store *Store
checker *HTTPChecker
}
func NewPoller(store *Store, checker *HTTPChecker) *Poller {
return &Poller{store: store, checker: checker}
}
// PollOnce prueft ALLE Ziele in einem Durchlauf. Ein fehlschlagendes Ziel
// (Netzwerkfehler, Timeout, Nicht-200) wird als StatusDown vermerkt und
// haelt die Pruefung der UEBRIGEN Ziele nicht auf — die Schleife laeuft
// sequenziell weiter, kein Ziel kann ein anderes blockieren
// (Akzeptanzkriterium 2 / Pruefung 2).
func (p *Poller) PollOnce(ctx context.Context) error {
targets, err := p.store.ListTargets(ctx)
if err != nil {
return err
}
for _, t := range targets {
status := p.checker.Check(ctx, t.HealthURL)
if err := p.store.recordIfChanged(ctx, t.Name, status); err != nil {
// Ein Schreibfehler fuer EIN Ziel darf die Pruefung der anderen
// nicht verhindern — dieselbe Fail-Isolation wie bei einem
// unerreichbaren Modul.
continue
}
}
return nil
}
// Run ruft PollOnce in festen Abstaenden auf, bis ctx beendet wird —
// dieselbe Konvention wie internal/tenant.Lifecycle.RunSweeper.
func (p *Poller) Run(ctx context.Context, interval time.Duration) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
_ = p.PollOnce(ctx)
}
}
}
// --- HTTP-Bindung fuer die Oberflaeche ---
func (s *Store) OverviewHandler(w http.ResponseWriter, r *http.Request) {
overview, err := s.Overview(r.Context())
writeJSONResult(w, overview, err)
}
func (s *Store) HistoryHandler(w http.ResponseWriter, r *http.Request) {
name := r.URL.Query().Get("name")
history, err := s.History(r.Context(), name)
writeJSONResult(w, history, err)
}
func writeJSONResult(w http.ResponseWriter, body any, err error) {
w.Header().Set("Content-Type", "application/json")
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
_ = json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
return
}
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(body)
}
+198
View File
@@ -0,0 +1,198 @@
package statuspage
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"os"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
func setupTest(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 status_targets (name TEXT PRIMARY KEY, health_url TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS status_history (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), name TEXT NOT NULL, status TEXT NOT NULL,
changed_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
`); err != nil {
t.Fatalf("schema: %v", err)
}
cleanup := func() { pool.Close() }
return NewStore(pool), cleanup
}
func uniqueName(prefix string) string {
return fmt.Sprintf("%s-%d", prefix, time.Now().UnixNano())
}
// Akzeptanzkriterium 1: Uebersicht zeigt den Status ALLER registrierten
// Module.
func TestOverview_ShowsAllRegisteredModules(t *testing.T) {
store, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }))
defer up.Close()
nameA, nameB := uniqueName("mod-a"), uniqueName("mod-b")
if err := store.RegisterTarget(ctx, Target{Name: nameA, HealthURL: up.URL}); err != nil {
t.Fatalf("target a: %v", err)
}
if err := store.RegisterTarget(ctx, Target{Name: nameB, HealthURL: up.URL}); err != nil {
t.Fatalf("target b: %v", err)
}
poller := NewPoller(store, NewHTTPChecker(time.Second))
if err := poller.PollOnce(ctx); err != nil {
t.Fatalf("poll: %v", err)
}
overview, err := store.Overview(ctx)
if err != nil {
t.Fatalf("overview: %v", err)
}
// Overview() listet ALLE jemals registrierten Ziele (auch aus fruehreren
// Testlaeufen auf derselben geteilten Test-DB) — hier wird deshalb nur
// der Status von nameA/nameB geprueft, nicht jeder Eintrag der Tabelle.
statusByName := map[string]Status{}
for _, m := range overview {
statusByName[m.Name] = m.Status
}
if statusByName[nameA] != StatusUp {
t.Fatalf("modul %s: status = %s, want up", nameA, statusByName[nameA])
}
if statusByName[nameB] != StatusUp {
t.Fatalf("modul %s: status = %s, want up", nameB, statusByName[nameB])
}
}
// Akzeptanzkriterium 2 + Pruefung 1/2: ein simulierter Ausfall eines Moduls
// wird sichtbar, das ANDERE Modul bleibt unbeeinflusst und die Pruefung
// beider laeuft trotzdem in einem Durchlauf durch (kein Totalausfall).
func TestPollOnce_IsolatesFailingModuleFromOthers(t *testing.T) {
store, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }))
defer up.Close()
down := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) }))
down.Close() // Server sofort schliessen -> Verbindung tatsaechlich unerreichbar (simulierter Ausfall)
healthyName, downName := uniqueName("healthy"), uniqueName("down")
if err := store.RegisterTarget(ctx, Target{Name: healthyName, HealthURL: up.URL}); err != nil {
t.Fatalf("target healthy: %v", err)
}
if err := store.RegisterTarget(ctx, Target{Name: downName, HealthURL: down.URL}); err != nil {
t.Fatalf("target down: %v", err)
}
poller := NewPoller(store, NewHTTPChecker(500*time.Millisecond))
if err := poller.PollOnce(ctx); err != nil {
t.Fatalf("poll haette trotz einem ausgefallenen modul erfolgreich durchlaufen sollen: %v", err)
}
overview, err := store.Overview(ctx)
if err != nil {
t.Fatalf("overview: %v", err)
}
statusByName := map[string]Status{}
for _, m := range overview {
statusByName[m.Name] = m.Status
}
if statusByName[healthyName] != StatusUp {
t.Fatalf("healthy modul: status = %s, want up", statusByName[healthyName])
}
if statusByName[downName] != StatusDown {
t.Fatalf("ausgefallenes modul: status = %s, want down", statusByName[downName])
}
}
// Akzeptanzkriterium 3: Verlauf vergangener Statusaenderungen ist einsehbar.
func TestHistory_RecordsStatusTransitions(t *testing.T) {
store, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
name := uniqueName("flaky")
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }))
defer up.Close()
if err := store.RegisterTarget(ctx, Target{Name: name, HealthURL: up.URL}); err != nil {
t.Fatalf("target: %v", err)
}
poller := NewPoller(store, NewHTTPChecker(time.Second))
if err := poller.PollOnce(ctx); err != nil {
t.Fatalf("poll 1: %v", err)
}
// Ziel wird "abgeschaltet" (URL zeigt jetzt auf einen bereits
// geschlossenen Server) -> naechster Poll erkennt den Statuswechsel.
closedURL := up.URL
up.Close()
if err := store.RegisterTarget(ctx, Target{Name: name, HealthURL: closedURL}); err != nil {
t.Fatalf("target aktualisieren: %v", err)
}
if err := poller.PollOnce(ctx); err != nil {
t.Fatalf("poll 2: %v", err)
}
history, err := store.History(ctx, name)
if err != nil {
t.Fatalf("history: %v", err)
}
if len(history) != 2 {
t.Fatalf("erwartet genau 2 statuswechsel (up -> down), habe %d: %+v", len(history), history)
}
if history[0].Status != StatusDown || history[1].Status != StatusUp {
t.Fatalf("erwartet verlauf [down, up] (neueste zuerst), habe: %+v", history)
}
}
// Wiederholte Polls OHNE Statuswechsel duerfen den Verlauf nicht mit
// identischen Eintraegen fluten (siehe recordIfChanged).
func TestHistory_DoesNotDuplicateUnchangedStatus(t *testing.T) {
store, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
name := uniqueName("stable")
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }))
defer up.Close()
if err := store.RegisterTarget(ctx, Target{Name: name, HealthURL: up.URL}); err != nil {
t.Fatalf("target: %v", err)
}
poller := NewPoller(store, NewHTTPChecker(time.Second))
for i := 0; i < 3; i++ {
if err := poller.PollOnce(ctx); err != nil {
t.Fatalf("poll %d: %v", i, err)
}
}
history, err := store.History(ctx, name)
if err != nil {
t.Fatalf("history: %v", err)
}
if len(history) != 1 {
t.Fatalf("erwartet genau 1 eintrag trotz 3 unveraenderter polls, habe %d", len(history))
}
}
-66
View File
@@ -1,66 +0,0 @@
package user
import (
"encoding/json"
"errors"
"net/http"
)
// Handler stellt die CRUD-API fuer Benutzerkonten bereit (IAM-01-Auftrag).
// Auth/Sessions (IAM-02) und Rollen (RBAC-01) sind ausdruecklich nicht Teil
// dieser Kachel und daher hier noch nicht angebunden.
type Handler struct {
users *TenantUserStore
superadmins *SuperadminStore
}
func NewHandler(users *TenantUserStore, superadmins *SuperadminStore) *Handler {
return &Handler{users: users, superadmins: superadmins}
}
type createUserRequest struct {
Email string `json:"email"`
Name string `json:"name"`
}
func (h *Handler) CreateUser(w http.ResponseWriter, r *http.Request) {
var req createUserRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "ungueltige Anfrage", http.StatusBadRequest)
return
}
u, err := h.users.Create(r.Context(), req.Email, req.Name)
writeUserResult(w, u, err)
}
// CreateSuperadmin legt ein mandantenuebergreifendes Superadmin-Konto an —
// bewusst ein eigener Endpunkt statt eines Tenant-Parameters mit Null-Wert.
func (h *Handler) CreateSuperadmin(w http.ResponseWriter, r *http.Request) {
var req createUserRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "ungueltige Anfrage", http.StatusBadRequest)
return
}
u, err := h.superadmins.Create(r.Context(), req.Email, req.Name)
writeUserResult(w, u, err)
}
func writeUserResult(w http.ResponseWriter, u User, err error) {
if err != nil {
switch {
case errors.Is(err, ErrInvalidEmail), errors.Is(err, ErrEmailTaken):
http.Error(w, err.Error(), http.StatusBadRequest)
case errors.Is(err, ErrNotFound):
http.Error(w, err.Error(), http.StatusNotFound)
default:
http.Error(w, "benutzer konnte nicht verarbeitet werden", http.StatusInternalServerError)
}
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(u)
}
-173
View File
@@ -1,173 +0,0 @@
package user
import (
"context"
"errors"
"fmt"
"os"
"strings"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
)
// setupTestDB legt eine frische, isolierte Testdatenbank an, wendet die
// uebergebene Migration an und liefert einen verbundenen Pool. Wird ohne
// TEST_ADMIN_DSN uebersprungen — siehe internal/tenant/provisioner_test.go
// fuer dasselbe Muster.
func setupTestDB(t *testing.T, dbName, schemaSQL string) *pgxpool.Pool {
t.Helper()
adminDSN := os.Getenv("TEST_ADMIN_DSN")
if adminDSN == "" {
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
}
ctx := context.Background()
adminPool, err := pgxpool.New(ctx, adminDSN)
if err != nil {
t.Fatalf("admin pool: %v", err)
}
_, _ = adminPool.Exec(ctx, fmt.Sprintf(`DROP DATABASE IF EXISTS %q`, dbName))
if _, err := adminPool.Exec(ctx, fmt.Sprintf(`CREATE DATABASE %q`, dbName)); err != nil {
t.Fatalf("testdatenbank anlegen: %v", err)
}
dsn := strings.Replace(adminDSN, "/postgres?", "/"+dbName+"?", 1)
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatalf("connect testdatenbank: %v", err)
}
if _, err := pool.Exec(ctx, schemaSQL); err != nil {
t.Fatalf("schema anwenden: %v", err)
}
t.Cleanup(func() {
pool.Close()
_, _ = adminPool.Exec(ctx, fmt.Sprintf(`DROP DATABASE IF EXISTS %q`, dbName))
adminPool.Close()
})
return pool
}
const usersSchema = `
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE 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()
);`
const superadminsSchema = `
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE superadmins (
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()
);`
// Akzeptanzkriterium 1 + 3, Pruefung 1 (inkl. Negativfall doppelte E-Mail).
func TestTenantUserStore_CRUD(t *testing.T) {
pool := setupTestDB(t, "test_iam01_users", usersSchema)
store := NewTenantUserStore(pool)
ctx := context.Background()
created, err := store.Create(ctx, "alice@example.com", "Alice")
if err != nil {
t.Fatalf("create: %v", err)
}
if created.Status != StatusActive {
t.Fatalf("erwartet status active, hat %q", created.Status)
}
got, err := store.Get(ctx, created.ID)
if err != nil {
t.Fatalf("get: %v", err)
}
if got.Email != "alice@example.com" {
t.Fatalf("get email = %q", got.Email)
}
updated, err := store.Update(ctx, created.ID, "", "Alice A.")
if err != nil {
t.Fatalf("update: %v", err)
}
if updated.Name != "Alice A." || updated.Email != "alice@example.com" {
t.Fatalf("update ergebnis unerwartet: %+v", updated)
}
list, err := store.List(ctx)
if err != nil {
t.Fatalf("list: %v", err)
}
if len(list) != 1 {
t.Fatalf("erwartet 1 benutzer, habe %d", len(list))
}
deactivated, err := store.Deactivate(ctx, created.ID)
if err != nil {
t.Fatalf("deactivate: %v", err)
}
if deactivated.Status != StatusInactive {
t.Fatalf("erwartet status inactive, hat %q", deactivated.Status)
}
// Negativfall: doppelte E-Mail-Adresse.
if _, err := store.Create(ctx, "second@example.com", "Bob"); err != nil {
t.Fatalf("create second: %v", err)
}
if _, err := store.Create(ctx, "second@example.com", "Bob Zwei"); !errors.Is(err, ErrEmailTaken) {
t.Fatalf("erwartet ErrEmailTaken, habe %v", err)
}
// Negativfall: fehlender Benutzer.
if _, err := store.Get(ctx, created.ID+"-nicht-vorhanden"); err == nil {
t.Fatalf("erwartet fehler bei unbekannter/ungueltiger id")
}
}
// Akzeptanzkriterium 2 + Pruefung 2: Superadmin-Anlage ohne Tenant-Kontext.
// SuperadminStore.Create hat keinen Tenant-Parameter — es gibt syntaktisch
// keine Moeglichkeit, hier versehentlich einen Tenant-Sonderfall zu vergessen.
func TestSuperadminStore_CreateWithoutTenantContext(t *testing.T) {
pool := setupTestDB(t, "test_iam01_superadmins", superadminsSchema)
store := NewSuperadminStore(pool)
ctx := context.Background()
created, err := store.Create(ctx, "root@nexarch.internal", "Root")
if err != nil {
t.Fatalf("create superadmin: %v", err)
}
if created.Status != StatusActive {
t.Fatalf("erwartet status active, hat %q", created.Status)
}
got, err := store.Get(ctx, created.ID)
if err != nil {
t.Fatalf("get: %v", err)
}
if got.Email != "root@nexarch.internal" {
t.Fatalf("get email = %q", got.Email)
}
if _, err := store.Create(ctx, "root@nexarch.internal", "Root Zwei"); !errors.Is(err, ErrEmailTaken) {
t.Fatalf("erwartet ErrEmailTaken (globale eindeutigkeit), habe %v", err)
}
deactivated, err := store.Deactivate(ctx, created.ID)
if err != nil {
t.Fatalf("deactivate: %v", err)
}
if deactivated.Status != StatusInactive {
t.Fatalf("erwartet status inactive, hat %q", deactivated.Status)
}
}
-77
View File
@@ -1,77 +0,0 @@
package user
import (
"context"
"fmt"
"github.com/jackc/pgx/v5/pgxpool"
)
// SuperadminStore verwaltet mandantenuebergreifende Superadmin-Konten in der
// Control-Plane-Registry (siehe internal/tenant.Registry). Superadmin-ohne-
// Tenant ist dadurch ein eigener Typ statt eines Sonderfalls von User/
// TenantUserStore — es gibt keinen Tenant-Parameter, den man weglassen
// koennte (IAM-01, "ohne Sonderbehandlung im Code").
type SuperadminStore struct {
pool *pgxpool.Pool
}
func NewSuperadminStore(pool *pgxpool.Pool) *SuperadminStore {
return &SuperadminStore{pool: pool}
}
func (s *SuperadminStore) Create(ctx context.Context, email, name string) (User, error) {
if err := ValidateEmail(email); err != nil {
return User{}, err
}
var u User
u.Email, u.Name, u.Status = email, name, StatusActive
row := s.pool.QueryRow(ctx, `
INSERT INTO superadmins (email, name, status)
VALUES ($1, $2, $3)
RETURNING id, created_at, updated_at
`, u.Email, u.Name, u.Status)
if err := row.Scan(&u.ID, &u.CreatedAt, &u.UpdatedAt); err != nil {
return User{}, mapWriteErr(err)
}
return u, nil
}
func (s *SuperadminStore) Get(ctx context.Context, id string) (User, error) {
return scanUser(s.pool.QueryRow(ctx, `
SELECT id, email, name, status, created_at, updated_at
FROM superadmins WHERE id = $1
`, id))
}
func (s *SuperadminStore) List(ctx context.Context) ([]User, error) {
rows, err := s.pool.Query(ctx, `
SELECT id, email, name, status, created_at, updated_at
FROM superadmins ORDER BY created_at
`)
if err != nil {
return nil, fmt.Errorf("superadmins auflisten: %w", err)
}
defer rows.Close()
var out []User
for rows.Next() {
var u User
if err := rows.Scan(&u.ID, &u.Email, &u.Name, &u.Status, &u.CreatedAt, &u.UpdatedAt); err != nil {
return nil, fmt.Errorf("superadmin lesen: %w", err)
}
out = append(out, u)
}
return out, rows.Err()
}
func (s *SuperadminStore) Deactivate(ctx context.Context, id string) (User, error) {
return scanUser(s.pool.QueryRow(ctx, `
UPDATE superadmins SET status = $2, updated_at = now()
WHERE id = $1
RETURNING id, email, name, status, created_at, updated_at
`, id, StatusInactive))
}
-173
View File
@@ -1,173 +0,0 @@
package user
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
)
// TenantUserStore verwaltet Benutzer innerhalb GENAU EINER Tenant-Datenbank.
// Welcher Mandant gemeint ist, ergibt sich ausschliesslich aus dem
// uebergebenen Pool — es gibt keine tenant_id-Spalte (siehe migrations/tenant/0001_users.up.sql).
type TenantUserStore struct {
pool *pgxpool.Pool
}
func NewTenantUserStore(pool *pgxpool.Pool) *TenantUserStore {
return &TenantUserStore{pool: pool}
}
func (s *TenantUserStore) Create(ctx context.Context, email, name string) (User, error) {
if err := ValidateEmail(email); err != nil {
return User{}, err
}
var u User
u.Email, u.Name, u.Status = email, name, StatusActive
row := s.pool.QueryRow(ctx, `
INSERT INTO users (email, name, status)
VALUES ($1, $2, $3)
RETURNING id, created_at, updated_at
`, u.Email, u.Name, u.Status)
if err := row.Scan(&u.ID, &u.CreatedAt, &u.UpdatedAt); err != nil {
return User{}, mapWriteErr(err)
}
return u, nil
}
func (s *TenantUserStore) Get(ctx context.Context, id string) (User, error) {
return scanUser(s.pool.QueryRow(ctx, `
SELECT id, email, name, status, created_at, updated_at
FROM users WHERE id = $1
`, id))
}
func (s *TenantUserStore) List(ctx context.Context) ([]User, error) {
rows, err := s.pool.Query(ctx, `
SELECT id, email, name, status, created_at, updated_at
FROM users ORDER BY created_at
`)
if err != nil {
return nil, fmt.Errorf("benutzer auflisten: %w", err)
}
defer rows.Close()
var out []User
for rows.Next() {
var u User
if err := rows.Scan(&u.ID, &u.Email, &u.Name, &u.Status, &u.CreatedAt, &u.UpdatedAt); err != nil {
return nil, fmt.Errorf("benutzer lesen: %w", err)
}
out = append(out, u)
}
return out, rows.Err()
}
// Update aendert Name und E-Mail. Eine leere email/name laesst das jeweilige
// Feld unveraendert.
func (s *TenantUserStore) Update(ctx context.Context, id, email, name string) (User, error) {
if email != "" {
if err := ValidateEmail(email); err != nil {
return User{}, err
}
}
row := s.pool.QueryRow(ctx, `
UPDATE users
SET email = COALESCE(NULLIF($2, ''), email),
name = COALESCE(NULLIF($3, ''), name),
updated_at = now()
WHERE id = $1
RETURNING id, email, name, status, created_at, updated_at
`, id, email, name)
u, err := scanUser(row)
if err != nil {
return User{}, mapWriteErr(err)
}
return u, nil
}
// Deactivate setzt den Benutzer auf inaktiv statt ihn zu loeschen.
func (s *TenantUserStore) Deactivate(ctx context.Context, id string) (User, error) {
return scanUser(s.pool.QueryRow(ctx, `
UPDATE users SET status = $2, updated_at = now()
WHERE id = $1
RETURNING id, email, name, status, created_at, updated_at
`, id, StatusInactive))
}
// SetPasswordHash schreibt einen bereits berechneten bcrypt-Hash (siehe
// internal/auth, IAM-02). Der Store selbst kennt kein Klartext-Passwort.
func (s *TenantUserStore) SetPasswordHash(ctx context.Context, id, hash string) error {
tag, err := s.pool.Exec(ctx, `
UPDATE users SET password_hash = $2, updated_at = now() WHERE id = $1
`, id, hash)
if err != nil {
return fmt.Errorf("passwort setzen: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
// AuthCredentials wird ausschliesslich fuer den Login-Pfad (internal/auth)
// verwendet und traegt bewusst den password_hash, damit er nicht ueber den
// regulaeren User-Typ/JSON-Serialisierungspfad nach aussen dringen kann.
type AuthCredentials struct {
User User
PasswordHash string
}
// GetByEmailForAuth liefert Benutzer + Passwort-Hash zu einer E-Mail-Adresse
// aus GENAU DIESER Tenant-Datenbank — der Tenant-Scope ergibt sich damit
// zwingend aus dem verwendeten Pool, es gibt keine Moeglichkeit, versehentlich
// ueber Tenant-Grenzen hinweg zu suchen (bekannter archivmail-Fehler, siehe
// IAM-02 "Bekannte Fehler vermeiden").
func (s *TenantUserStore) GetByEmailForAuth(ctx context.Context, email string) (AuthCredentials, error) {
var c AuthCredentials
row := s.pool.QueryRow(ctx, `
SELECT id, email, name, status, created_at, updated_at, password_hash
FROM users WHERE email = $1
`, email)
if err := row.Scan(&c.User.ID, &c.User.Email, &c.User.Name, &c.User.Status,
&c.User.CreatedAt, &c.User.UpdatedAt, &c.PasswordHash); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return AuthCredentials{}, ErrNotFound
}
return AuthCredentials{}, fmt.Errorf("anmeldedaten lesen: %w", err)
}
return c, nil
}
func scanUser(row pgx.Row) (User, error) {
var u User
if err := row.Scan(&u.ID, &u.Email, &u.Name, &u.Status, &u.CreatedAt, &u.UpdatedAt); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return User{}, ErrNotFound
}
return User{}, fmt.Errorf("benutzer lesen: %w", err)
}
return u, nil
}
// mapWriteErr uebersetzt den Unique-Constraint-Verstoss der E-Mail-Spalte in
// einen sprechenden Fehler statt der rohen Postgres-Fehlermeldung.
func mapWriteErr(err error) error {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
return ErrEmailTaken
}
if errors.Is(err, pgx.ErrNoRows) {
return ErrNotFound
}
return fmt.Errorf("benutzer schreiben: %w", err)
}
-42
View File
@@ -1,42 +0,0 @@
// Package user implementiert Core IAM-01: das Benutzer-Datenmodell und die
// CRUD-Operationen. Tenant-Zugehoerigkeit ist ueber die Zieldatenbank
// gegeben (Modell C, siehe internal/tenant) — Superadmin-Konten leben
// dagegen mandantenuebergreifend in der Registry und sind ueber
// SuperadminStore als eigener, First-Class-Typ modelliert, nicht als
// tenant_id-NULL-Sonderfall in User.
package user
import (
"errors"
"regexp"
"time"
)
type Status string
const (
StatusActive Status = "active"
StatusInactive Status = "inactive"
)
type User struct {
ID string
Email string
Name string
Status Status
CreatedAt time.Time
UpdatedAt time.Time
}
var emailPattern = regexp.MustCompile(`^[^\s@]+@[^\s@]+\.[^\s@]+$`)
var ErrInvalidEmail = errors.New("user: ungueltige E-Mail-Adresse")
var ErrEmailTaken = errors.New("user: E-Mail-Adresse bereits vergeben")
var ErrNotFound = errors.New("user: nicht gefunden")
func ValidateEmail(email string) error {
if !emailPattern.MatchString(email) {
return ErrInvalidEmail
}
return nil
}
-24
View File
@@ -1,24 +0,0 @@
package user
import "testing"
func TestValidateEmail(t *testing.T) {
cases := []struct {
email string
wantErr bool
}{
{"a@b.de", false},
{"a.b+c@sub.example.com", false},
{"", true},
{"keine-email", true},
{"a@b", true},
{"@b.de", true},
}
for _, c := range cases {
err := ValidateEmail(c.email)
if (err != nil) != c.wantErr {
t.Errorf("ValidateEmail(%q) error = %v, wantErr %v", c.email, err, c.wantErr)
}
}
}
-1
View File
@@ -1 +0,0 @@
DROP TABLE IF EXISTS superadmins;
-14
View File
@@ -1,14 +0,0 @@
-- Superadmin-Konten arbeiten mandantenuebergreifend und leben deshalb in der
-- Control-Plane-Registry (siehe TEN-01), nicht in einer Tenant-Datenbank.
-- Das bildet "Superadmin ohne Tenant" strukturell als First-Class-Zustand ab,
-- statt ihn als Sonderfall in der Tenant-users-Tabelle zu behandeln
-- (IAM-01, siehe core-kanban/tickets/IAM-01.md — bekannte Fehler vermeiden).
-- E-Mail-Eindeutigkeit ist hier global, da die Registry-DB einmalig existiert.
CREATE TABLE superadmins (
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()
);
-2
View File
@@ -1,2 +0,0 @@
DROP TABLE IF EXISTS policy_rule_changes;
DROP TABLE IF EXISTS policy_rules;
-24
View File
@@ -1,24 +0,0 @@
-- 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 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 +0,0 @@
DROP TABLE IF EXISTS policy_module_scopes;
@@ -1,11 +0,0 @@
-- Modul-Scoping fuer Policy-Regeln (RBAC-04, siehe core-kanban/tickets/RBAC-04.md).
-- Existiert fuer eine (role, permission)-Regel ein Eintrag hier, gilt sie
-- NUR, wenn zusaetzlich das verknuepfte Feature-Flag (LIC-02) fuer den
-- Tenant aktiv ist — Rechte folgen der Lizenz, nicht umgekehrt.
CREATE TABLE policy_module_scopes (
role TEXT NOT NULL,
permission TEXT NOT NULL,
module TEXT NOT NULL,
flag_key TEXT NOT NULL,
PRIMARY KEY (role, permission)
);
+2
View File
@@ -0,0 +1,2 @@
DROP TABLE status_history;
DROP TABLE status_targets;
+15
View File
@@ -0,0 +1,15 @@
-- Zentrale Statusseite (OPS-02, siehe core-kanban/tickets/OPS-02.md) —
-- ueberwachte Module und der Verlauf ihrer Statusaenderungen.
CREATE TABLE status_targets (
name TEXT PRIMARY KEY,
health_url TEXT NOT NULL
);
CREATE TABLE status_history (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
status TEXT NOT NULL,
changed_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX status_history_name_idx ON status_history (name, changed_at DESC);
-1
View File
@@ -1 +0,0 @@
DROP TABLE IF EXISTS users;
-16
View File
@@ -1,16 +0,0 @@
-- Benutzer-Datenmodell (IAM-01, siehe core-kanban/tickets/IAM-01.md).
-- Diese Migration laeuft in der DB EINES Mandanten (Modell C, siehe TEN-01) —
-- die Tenant-Zugehoerigkeit ist implizit durch die Datenbankverbindung
-- gegeben, es gibt daher bewusst KEINE tenant_id-Spalte.
-- E-Mail-Eindeutigkeit ist hier tenant-scoped: der UNIQUE-Constraint gilt
-- nur innerhalb dieser einen Tenant-Datenbank.
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE 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()
);
@@ -1,2 +0,0 @@
DROP TABLE IF EXISTS role_assignment_history;
DROP TABLE IF EXISTS role_assignments;
@@ -1,23 +0,0 @@
-- Rollenzuweisung pro Benutzer (RBAC-01, siehe core-kanban/tickets/RBAC-01.md).
-- Nur 'user' und 'tenant_admin' sind hier zuweisbar — 'superadmin' lebt
-- mandantenuebergreifend in der Registry (IAM-01 SuperadminStore) und hat
-- daher bewusst KEINE Zeile in dieser tenant-lokalen Tabelle (Akzeptanz-
-- kriterium 1: nur Zuweisungen innerhalb der erlaubten Matrix).
CREATE TABLE 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()
);
-- Vollstaendige Historie jeder Rollenaenderung (Akzeptanzkriterium 3: wer
-- hat wann welche Rolle vergeben).
CREATE TABLE 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()
);
CREATE INDEX role_assignment_history_user_idx ON role_assignment_history (user_id, granted_at);
@@ -1 +0,0 @@
ALTER TABLE users DROP COLUMN password_hash;
@@ -1,3 +0,0 @@
-- Passwort-Hash-Spalte fuer Login (IAM-02, siehe core-kanban/tickets/IAM-02.md).
-- Enthaelt AUSSCHLIESSLICH den bcrypt-Hash, niemals das Klartext-Passwort.
ALTER TABLE users ADD COLUMN password_hash TEXT NOT NULL DEFAULT '';
-2
View File
@@ -1,2 +0,0 @@
DROP TABLE IF EXISTS group_members;
DROP TABLE IF EXISTS groups;
-21
View File
@@ -1,21 +0,0 @@
-- Gruppen/Abteilungen mit Rollenzuweisung auf Gruppenebene (RBAC-03, siehe
-- core-kanban/tickets/RBAC-03.md). Lebt in der Tenant-Datenbank (Modell C) —
-- Gruppen sind implizit tenant-isoliert, da jede Tenant-DB ihre eigenen hat.
CREATE TABLE groups (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
role TEXT CHECK (role IN ('user', 'tenant_admin')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- ON DELETE CASCADE: Loeschen einer Gruppe entzieht die gruppenbasierten
-- Rechte automatisch, ohne Benutzerkonten selbst zu beruehren
-- (Akzeptanzkriterium 3).
CREATE TABLE group_members (
group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id),
added_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (group_id, user_id)
);
CREATE INDEX group_members_user_idx ON group_members (user_id);
-12
View File
@@ -1,23 +1,11 @@
#!/usr/bin/env bash
# Setzt die nexarch-Testumgebung zurueck: loescht die geteilte
# Registry-Tabelle "tenants" in der postgres-Wartungsdatenbank sowie alle
# tenant_*-Datenbanken. Noetig, weil verschiedene Feature-Branches
# unterschiedliche Registry-Schemata erwarten, aber dieselbe physische
# Postgres-Instanz auf dem Testhost teilen (siehe [[project-nexarch-test-infra]]).
#
# Aufruf: NEXARCH_TEST_DB_PASSWORD=... ./scripts/reset-test-env.sh
set -euo pipefail
PASS="${NEXARCH_TEST_DB_PASSWORD:?Setze NEXARCH_TEST_DB_PASSWORD vor dem Aufruf}"
ROLE="nexarch_test"
export PGPASSWORD="$PASS"
psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP TABLE IF EXISTS tenants CASCADE;"
dbs=$(psql -h localhost -U "$ROLE" -d postgres -tAc "SELECT datname FROM pg_database WHERE datname LIKE 'tenant\_%' ESCAPE '\'")
for db in $dbs; do
psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP DATABASE IF EXISTS \"${db}\";"
done
echo "Testumgebung zurueckgesetzt: registry-tabelle + $(echo "$dbs" | grep -c . || true) tenant-datenbank(en) entfernt."
-12
View File
@@ -1,24 +1,12 @@
#!/usr/bin/env bash
# Ein-Kommando-Pruefung fuer den aktuellen Code-Stand auf dem Testhost:
# Registry+Tenant-DBs zuruecksetzen, dann build/vet/test in einem Rutsch.
# -p 1 ist Pflicht, da mehrere Pakete dieselbe physische Registry-Tabelle auf
# dem Testhost teilen (siehe [[project-nexarch-test-infra]]).
#
# Aufruf: NEXARCH_TEST_DB_PASSWORD=... ./scripts/run-checks.sh
set -euo pipefail
PASS="${NEXARCH_TEST_DB_PASSWORD:?Setze NEXARCH_TEST_DB_PASSWORD vor dem Aufruf}"
cd "$(dirname "$0")/.."
NEXARCH_TEST_DB_PASSWORD="$PASS" bash scripts/reset-test-env.sh
export TEST_ADMIN_DSN="postgresql://nexarch_test:${PASS}@localhost:5432/postgres?sslmode=disable"
echo "== go build =="
go build ./...
echo "== go vet =="
go vet ./...
echo "== go test (-p 1) =="
go test ./... -p 1 -count=1
-183
View File
@@ -1,183 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { TextField, SelectField, useToast } from "@nexarch/shl";
import {
ApiError,
Group,
Role,
addGroupMember,
createGroup,
fetchGroups,
removeGroupMember,
setGroupRole,
} from "../../lib/api";
export default function GroupsPage() {
const { push } = useToast();
const [groups, setGroups] = useState<Group[]>([]);
const [loadError, setLoadError] = useState<string | null>(null);
const reload = () =>
fetchGroups()
.then(setGroups)
.catch(() => setLoadError("Gruppen konnten nicht geladen werden. Sind Sie als Tenant-Admin angemeldet?"));
useEffect(() => {
reload();
}, []);
const [newGroupName, setNewGroupName] = useState("");
const [createError, setCreateError] = useState<string | null>(null);
async function onCreate(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
setCreateError(null);
try {
await createGroup(newGroupName);
setNewGroupName("");
push("Gruppe angelegt.", "success");
await reload();
} catch (err) {
setCreateError(err instanceof ApiError ? err.message : "Gruppe konnte nicht angelegt werden.");
}
}
if (loadError) {
return (
<main style={{ maxWidth: 640, margin: "40px auto", padding: "0 16px" }}>
<p role="alert">{loadError}</p>
</main>
);
}
return (
<main style={{ maxWidth: 640, margin: "40px auto", padding: "0 16px" }}>
<h1>Gruppen &amp; Abteilungen</h1>
<section aria-labelledby="neue-gruppe-heading">
<h2 id="neue-gruppe-heading">Neue Gruppe anlegen</h2>
<form onSubmit={onCreate} noValidate>
<TextField
label="Gruppenname"
required
value={newGroupName}
onChange={(e) => setNewGroupName(e.target.value)}
/>
{createError && (
<p role="alert" style={{ color: "var(--shl-color-danger)" }}>
{createError}
</p>
)}
<button type="submit">Anlegen</button>
</form>
</section>
<section aria-labelledby="gruppenliste-heading">
<h2 id="gruppenliste-heading">Bestehende Gruppen</h2>
{groups.length === 0 ? (
<p>Keine Gruppen vorhanden.</p>
) : (
groups.map((g) => <GroupCard key={g.ID} group={g} onChanged={reload} onNotice={(t) => push(t, "success")} />)
)}
</section>
</main>
);
}
function GroupCard({
group,
onChanged,
onNotice,
}: {
group: Group;
onChanged: () => void;
onNotice: (text: string) => void;
}) {
const [role, setRole] = useState<Role>(group.Role || "user");
const [newMemberId, setNewMemberId] = useState("");
const [error, setError] = useState<string | null>(null);
async function onSetRole() {
setError(null);
try {
await setGroupRole(group.ID, role);
onNotice("Gruppenrolle gesetzt.");
onChanged();
} catch (err) {
setError(err instanceof ApiError ? err.message : "Gruppenrolle konnte nicht gesetzt werden.");
}
}
async function onAddMember(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
setError(null);
try {
await addGroupMember(group.ID, newMemberId);
setNewMemberId("");
onNotice("Mitglied hinzugefügt.");
onChanged();
} catch (err) {
setError(err instanceof ApiError ? err.message : "Mitglied konnte nicht hinzugefügt werden.");
}
}
async function onRemoveMember(userId: string) {
setError(null);
try {
await removeGroupMember(group.ID, userId);
onNotice("Mitglied entfernt.");
onChanged();
} catch (err) {
setError(err instanceof ApiError ? err.message : "Mitglied konnte nicht entfernt werden.");
}
}
return (
<div style={{ border: "1px solid var(--shl-color-border)", borderRadius: 8, padding: 16, marginBottom: 16 }}>
<h3>{group.Name}</h3>
<div style={{ display: "flex", alignItems: "flex-end", gap: 8 }}>
<SelectField label="Gruppenrolle" value={role} onChange={(e) => setRole(e.target.value as Role)}>
<option value="user">Benutzer</option>
<option value="tenant_admin">Tenant-Admin</option>
</SelectField>
<button type="button" onClick={onSetRole}>
Setzen
</button>
</div>
<h4>Mitglieder</h4>
{group.members.length === 0 ? (
<p>Keine Mitglieder.</p>
) : (
<ul>
{group.members.map((userId) => (
<li key={userId}>
{userId}{" "}
<button type="button" onClick={() => onRemoveMember(userId)}>
Entfernen
</button>
</li>
))}
</ul>
)}
<form onSubmit={onAddMember} noValidate>
<TextField
label="Benutzer-ID hinzufügen"
required
value={newMemberId}
onChange={(e) => setNewMemberId(e.target.value)}
/>
<button type="submit">Hinzufügen</button>
</form>
{error && (
<p role="alert" style={{ color: "var(--shl-color-danger)" }}>
{error}
</p>
)}
</div>
);
}
-5
View File
@@ -1,5 +0,0 @@
import { redirect } from "next/navigation";
export default function IndexPage() {
redirect("/roles");
}
-142
View File
@@ -1,142 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { Table, TextField, SelectField, useToast } from "@nexarch/shl";
import type { TableColumn } from "@nexarch/shl";
import {
ApiError,
Assignment,
Role,
RoleInfo,
assignRole,
fetchRoleHistory,
fetchRoles,
} from "../../lib/api";
export default function RolesPage() {
const { push } = useToast();
const [roles, setRoles] = useState<RoleInfo[] | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
const [targetUserId, setTargetUserId] = useState("");
const [targetRole, setTargetRole] = useState<Role>("user");
const [assignError, setAssignError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const [historyUserId, setHistoryUserId] = useState("");
const [history, setHistory] = useState<Assignment[] | null>(null);
useEffect(() => {
fetchRoles()
.then(setRoles)
.catch(() => setLoadError("Rollen konnten nicht geladen werden. Sind Sie als Tenant-Admin angemeldet?"));
}, []);
async function onAssign(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
setAssignError(null);
setSubmitting(true);
try {
await assignRole(targetUserId, targetRole);
push("Rolle zugewiesen.", "success");
} catch (err) {
// Akzeptanzkriterium 2: die Backend-Meldung bei Selbst-Eskalation wird
// unveraendert angezeigt, keine eigene Interpretation im Frontend.
setAssignError(err instanceof ApiError ? err.message : "Rolle konnte nicht zugewiesen werden.");
} finally {
setSubmitting(false);
}
}
async function onLoadHistory(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
try {
setHistory(await fetchRoleHistory(historyUserId));
} catch {
setHistory([]);
}
}
const historyColumns: TableColumn<Assignment>[] = [
{ key: "role", header: "Rolle", render: (a) => a.Role },
{ key: "grantedBy", header: "Vergeben von", render: (a) => a.GrantedBy },
];
if (loadError) {
return (
<main style={{ maxWidth: 640, margin: "40px auto", padding: "0 16px" }}>
<p role="alert">{loadError}</p>
</main>
);
}
return (
<main style={{ maxWidth: 640, margin: "40px auto", padding: "0 16px" }}>
<h1>Rollen &amp; Rechte</h1>
<section aria-labelledby="rollen-heading">
<h2 id="rollen-heading">Rollen und Grundrechte</h2>
{roles?.map((r) => (
<div key={r.role} style={{ marginBottom: 12 }}>
<strong>{roleLabel(r.role)}</strong>
<ul>
{r.permissions.map((p) => (
<li key={p}>{p}</li>
))}
</ul>
</div>
))}
</section>
<section aria-labelledby="zuweisen-heading">
<h2 id="zuweisen-heading">Rolle zuweisen</h2>
<form onSubmit={onAssign} noValidate>
<TextField
label="Benutzer-ID"
required
value={targetUserId}
onChange={(e) => setTargetUserId(e.target.value)}
/>
<SelectField label="Rolle" value={targetRole} onChange={(e) => setTargetRole(e.target.value as Role)}>
<option value="user">Benutzer</option>
<option value="tenant_admin">Tenant-Admin</option>
</SelectField>
{assignError && (
<p role="alert" style={{ color: "var(--shl-color-danger)" }}>
{assignError}
</p>
)}
<button type="submit" disabled={submitting}>
{submitting ? "Wird zugewiesen…" : "Zuweisen"}
</button>
</form>
</section>
<section aria-labelledby="verlauf-heading">
<h2 id="verlauf-heading">Nachvollziehbarkeit (Audit-Verlauf)</h2>
<p>Zeigt jede Rollenzuweisung eines Benutzers chronologisch wer wann welche Rolle vergeben hat.</p>
<form onSubmit={onLoadHistory} noValidate>
<TextField
label="Benutzer-ID"
required
value={historyUserId}
onChange={(e) => setHistoryUserId(e.target.value)}
/>
<button type="submit">Verlauf anzeigen</button>
</form>
{history && (
<Table
columns={historyColumns}
rows={history}
rowKey={(a) => `${a.UserID}-${a.Role}-${a.GrantedBy}`}
caption="Rollenzuweisungs-Verlauf"
/>
)}
</section>
</main>
);
}
function roleLabel(role: Role): string {
return role === "tenant_admin" ? "Tenant-Admin" : "Benutzer";
}
-68
View File
@@ -1,68 +0,0 @@
const API_BASE = process.env.NEXT_PUBLIC_CORE_API_BASE ?? "";
export class ApiError extends Error {}
async function req<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${API_BASE}${path}`, {
credentials: "include",
headers: init?.body ? { "Content-Type": "application/json" } : undefined,
...init,
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
throw new ApiError(data.error ?? data.message ?? "Unbekannter Fehler");
}
return data as T;
}
export type Role = "user" | "tenant_admin";
export interface RoleInfo {
role: Role;
permissions: string[];
}
export function fetchRoles(): Promise<RoleInfo[]> {
return req("/rbac/roles");
}
export interface Assignment {
UserID: string;
Role: Role;
GrantedBy: string;
}
export function assignRole(userId: string, role: Role): Promise<Assignment> {
return req("/rbac/users/role", { method: "POST", body: JSON.stringify({ user_id: userId, role }) });
}
export function fetchRoleHistory(userId: string): Promise<Assignment[]> {
return req(`/rbac/users/${userId}/history`);
}
export interface Group {
ID: string;
Name: string;
Role: Role | "";
members: string[];
}
export function fetchGroups(): Promise<Group[]> {
return req("/rbac/groups");
}
export function createGroup(name: string): Promise<Group> {
return req("/rbac/groups", { method: "POST", body: JSON.stringify({ name }) });
}
export function setGroupRole(groupId: string, role: Role): Promise<void> {
return req("/rbac/groups/role", { method: "POST", body: JSON.stringify({ group_id: groupId, role }) });
}
export function addGroupMember(groupId: string, userId: string): Promise<void> {
return req("/rbac/groups/members", { method: "POST", body: JSON.stringify({ group_id: groupId, user_id: userId }) });
}
export function removeGroupMember(groupId: string, userId: string): Promise<void> {
return req("/rbac/groups/members/remove", { method: "POST", body: JSON.stringify({ group_id: groupId, user_id: userId }) });
}
-5
View File
@@ -1,5 +0,0 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
transpilePackages: ["@nexarch/shl"],
};
export default nextConfig;
@@ -1,7 +1,7 @@
import { ThemeProvider, I18nProvider, ToastProvider, typography } from "@nexarch/shl";
export const metadata = {
title: "NEXARCH Rechteverwaltung",
title: "NEXARCH Systemstatus",
};
export default function RootLayout({
+125
View File
@@ -0,0 +1,125 @@
"use client";
import { useEffect, useState } from "react";
import { fetchOverview, fetchHistory, type ModuleStatus, type HistoryEntry } from "@/lib/api";
const POLL_INTERVAL_MS = 5000;
export default function Page() {
const [modules, setModules] = useState<ModuleStatus[]>([]);
const [error, setError] = useState<string | null>(null);
const [selected, setSelected] = useState<string | null>(null);
const [history, setHistory] = useState<HistoryEntry[] | null>(null);
const [historyError, setHistoryError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
async function poll() {
try {
const data = await fetchOverview();
if (!cancelled) {
setModules(data);
setError(null);
}
} catch (e: any) {
// Ein nicht antwortendes Backend darf die zuletzt bekannte Ansicht
// nicht loeschen und die Seite nicht unbedienbar machen
// (Akzeptanzkriterium 2) — nur eine Fehlermeldung anzeigen, alte
// Daten bleiben sichtbar.
if (!cancelled) {
setError(e.message ?? "Unbekannter Fehler beim Laden der Uebersicht");
}
}
}
poll();
const id = setInterval(poll, POLL_INTERVAL_MS);
return () => {
cancelled = true;
clearInterval(id);
};
}, []);
async function openHistory(name: string) {
setSelected(name);
setHistoryError(null);
try {
const data = await fetchHistory(name);
setHistory(data);
} catch (e: any) {
setHistoryError(e.message ?? "Verlauf konnte nicht geladen werden");
setHistory(null);
}
}
const anyDown = modules.some((m) => m.status === "down");
return (
<main style={{ maxWidth: 800, margin: "0 auto", padding: "2rem 1rem" }}>
<h1>Systemstatus</h1>
{error && (
<p style={{ color: "#c62828", background: "#fdecea", padding: "0.5rem", borderRadius: 4 }} role="alert">
Uebersicht konnte gerade nicht aktualisiert werden: {error}. Zuletzt bekannter Stand wird weiter angezeigt.
</p>
)}
{!error && anyDown && (
<p style={{ color: "#c62828", background: "#fdecea", padding: "0.5rem", borderRadius: 4 }}>
Mindestens ein Modul ist derzeit nicht erreichbar.
</p>
)}
<ul style={{ listStyle: "none", padding: 0 }}>
{modules.map((m) => (
<li
key={m.name}
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
background: "white",
padding: "0.75rem 1rem",
marginBottom: "0.5rem",
borderRadius: 8,
borderLeft: `6px solid ${m.status === "up" ? "#2e7d32" : "#c62828"}`,
}}
>
<div>
<strong>{m.name}</strong>
<div style={{ fontSize: "0.85rem", color: "#666" }}>
{m.status === "up" ? "Verfügbar" : "Nicht verfügbar"}
{m.last_checked && ` — zuletzt geprüft ${new Date(m.last_checked).toLocaleString("de-DE")}`}
</div>
</div>
<button onClick={() => openHistory(m.name)}>Verlauf</button>
</li>
))}
{modules.length === 0 && !error && <li>Lade Modulstatus</li>}
</ul>
{selected && (
<section style={{ background: "white", padding: "1rem", borderRadius: 8, marginTop: "1.5rem" }}>
<h2>Verlauf: {selected}</h2>
{historyError && (
<p style={{ color: "#c62828" }} role="alert">
{historyError}
</p>
)}
{history && (
<ul>
{history.map((h, i) => (
<li key={i}>
{new Date(h.changed_at).toLocaleString("de-DE")} {h.status === "up" ? "verfügbar" : "nicht verfügbar"}
</li>
))}
{history.length === 0 && <li>Keine Statusänderungen bisher.</li>}
</ul>
)}
<button onClick={() => setSelected(null)}>Schließen</button>
</section>
)}
</main>
);
}
+44
View File
@@ -0,0 +1,44 @@
// Duenner Client des OPS-02-Backends (internal/statuspage) — keine eigene
// Aggregations-/Polling-Logik im Frontend.
export type ModuleStatus = {
name: string;
status: "up" | "down";
last_checked?: string;
};
export type HistoryEntry = {
status: "up" | "down";
changed_at: string;
};
function apiBase(): string {
const base = process.env.NEXT_PUBLIC_STATUSPAGE_API_URL;
if (!base) {
throw new Error(
"NEXT_PUBLIC_STATUSPAGE_API_URL ist nicht gesetzt (Umgebungsvariable erforderlich)"
);
}
return base;
}
// fetchOverview holt den Status EINES Moduls unabhaengig vom Erfolg der
// anderen — ein Netzwerkfehler beim Abruf der Gesamtuebersicht wird vom
// Aufrufer (Page-Komponente) abgefangen, sodass ein nicht antwortendes
// Backend die Seite nicht zum Absturz bringt (Akzeptanzkriterium 2).
export async function fetchOverview(): Promise<ModuleStatus[]> {
const res = await fetch(`${apiBase()}/status/overview`, { cache: "no-store" });
if (!res.ok) {
throw new Error(`Uebersicht konnte nicht geladen werden (${res.status})`);
}
return res.json();
}
export async function fetchHistory(name: string): Promise<HistoryEntry[]> {
const res = await fetch(`${apiBase()}/status/history?name=${encodeURIComponent(name)}`, {
cache: "no-store",
});
if (!res.ok) {
throw new Error(`Verlauf konnte nicht geladen werden (${res.status})`);
}
return res.json();
}
+7
View File
@@ -0,0 +1,7 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
// @nexarch/shl liegt als file:-Dependency mit TS-Quellen in node_modules —
// Next.js transpiliert node_modules standardmäßig nicht, siehe web/shl/README.md.
transpilePackages: ["@nexarch/shl"],
};
export default nextConfig;
@@ -1,5 +1,5 @@
{
"name": "nexarch-rbac-admin",
"name": "nexarch-status-page",
"private": true,
"scripts": {
"dev": "next dev",
@@ -1,8 +1,8 @@
{
"compilerOptions": {
"target": "ES2017",
"target": "es2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": false,
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,