Compare commits

..
Author SHA1 Message Date
patrick ad00c6258d Merge pull request 'common: moduleauth (Modul-Token-Verifier + Bearer-Middleware)' (#394) from feat/common-moduleauth into main
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (push) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (push) Canceled after 0s
CI / Connect Security-Test-Gate (QA-01 AC4, Merge-Gate) (push) Canceled after 0s
common Versions-Tag / tag (push) Successful in 13m39s
Supply-Chain-Scan (Go) / govulncheck (push) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (push) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (push) Canceled after 0s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (push) Canceled after 0s
CI / Web (dms-browser) (push) Canceled after 0s
CI / Web (pdf-viewer) (push) Canceled after 0s
CI / Web (shl) (push) Canceled after 0s
2026-10-28 10:41:06 +01:00
patrick 2e9ec88b81 Merge pull request 'common v0.2.0: migrate mit Tabellenname als Parameter' (#385) from feat/common-migrate-v02 into main
CI / Connect Security-Test-Gate (QA-01 AC4, Merge-Gate) (push) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (push) Canceled after 0s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (push) Canceled after 0s
CI / Web (dms-browser) (push) Canceled after 0s
CI / Web (pdf-viewer) (push) Canceled after 0s
Supply-Chain-Scan (Go) / govulncheck (push) Canceled after 0s
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (push) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (push) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (push) Canceled after 0s
CI / Web (shl) (push) Canceled after 0s
common Versions-Tag / tag (push) Successful in 13m48s
2026-10-28 10:40:59 +01:00
patrickandClaude Sonnet 5.5 51b639625f common/moduleauth: Modul-Token-Verifier (Ed25519, JWKS) und Bearer-Middleware
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (push) Failing after 18s
CI / Web (pdf-viewer) (push) Failing after 57s
CI / Web (shl) (push) Failing after 11s
CI / Core-Modul (Build/Vet/Test) (push) Failing after 55s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (push) Successful in 3s
CI / Connect Security-Test-Gate (QA-01 AC4, Merge-Gate) (push) Successful in 3s
CI / Web (dms-browser) (pull_request) Failing after 1m16s
CI / Web (shl) (pull_request) Failing after 11s
Core-Schnittstellen-Vertragstests / contract-tests (pull_request) Successful in 12m16s
Supply-Chain-Scan (Go) / govulncheck (pull_request) Failing after 52s
Core-Pflichttest-Gate / pflichttest-gate (pull_request) Failing after 21m17s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (pull_request) Failing after 3s
DB-Integrationstests / db-tests (pull_request) Canceled after 0s
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (push) Canceled after 0s
CI / Web (dms-browser) (push) Failing after 1m18s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (pull_request) Successful in 4s
Supply-Chain-Scan (Go) / govulncheck (push) Failing after 57s
CI / Core-Modul (Build/Vet/Test) (pull_request) Failing after 1m1s
CI / Connect Security-Test-Gate (QA-01 AC4, Merge-Gate) (pull_request) Successful in 4s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (pull_request) Failing after 21s
CI / Web (pdf-viewer) (pull_request) Failing after 57s
common Pflichttest-Gate / pflichttest-gate (pull_request) Successful in 11m57s
Supply-Chain-Scan (npm) / npm-audit (pull_request) Failing after 1m58s
RBAC-Rollenhierarchie-Pflichtgate / rbac-policy-invariant (pull_request) Successful in 12m19s
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (pull_request) Failing after 31s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (push) Failing after 3s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 23:40:30 +01:00
patrickandClaude Sonnet 5.5 f8905e9c64 common/migrate v0.2.0: Tabellenname als Parameter (migrate.New), Tag-Workflow mit common/MINOR
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (push) Canceled after 0s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (push) Failing after 25s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (pull_request) Failing after 20s
CI / Web (dms-browser) (pull_request) Failing after 1m27s
CI / Web (shl) (pull_request) Failing after 13s
common Pflichttest-Gate / pflichttest-gate (pull_request) Successful in 9m21s
Supply-Chain-Scan (Go) / govulncheck (pull_request) Failing after 3m4s
Supply-Chain-Scan (npm) / npm-audit (pull_request) Failing after 1m57s
RBAC-Rollenhierarchie-Pflichtgate / rbac-policy-invariant (pull_request) Successful in 9m12s
CI / Web (dms-browser) (push) Failing after 1m28s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (pull_request) Successful in 4s
Supply-Chain-Scan (Go) / govulncheck (push) Failing after 2m46s
Core-Pflichttest-Gate / pflichttest-gate (pull_request) Successful in 3m3s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (push) Failing after 2s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (pull_request) Failing after 3s
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (pull_request) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (push) Failing after 1m4s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (push) Successful in 4s
CI / Connect Security-Test-Gate (QA-01 AC4, Merge-Gate) (push) Successful in 4s
CI / Connect Security-Test-Gate (QA-01 AC4, Merge-Gate) (pull_request) Successful in 5s
CI / Web (pdf-viewer) (pull_request) Failing after 1m2s
Core-Schnittstellen-Vertragstests / contract-tests (pull_request) Successful in 2m56s
CI / Web (pdf-viewer) (push) Failing after 1m8s
CI / Web (shl) (push) Failing after 15s
CI / Core-Modul (Build/Vet/Test) (pull_request) Failing after 1m2s
DB-Integrationstests / db-tests (pull_request) Canceled after 0s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 23:26:27 +01:00
patrick 184a219c8a Merge pull request 'SHL-04: AccountBar-Dopplung aufgeloest' (#372) from feature/shl-03-accountbar-dedup into main
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (push) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (push) Canceled after 0s
CI / Web (dms-browser) (push) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (push) Canceled after 0s
CI / Connect Security-Test-Gate (QA-01 AC4, Merge-Gate) (push) Canceled after 0s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (push) Canceled after 0s
CI / Web (pdf-viewer) (push) Canceled after 0s
CI / Web (shl) (push) Canceled after 0s
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (push) Canceled after 0s
2026-10-27 23:07:29 +01:00
patrick 118a524cec Merge pull request 'Portal: Gruppen, Arbeitsvorrat, Betriebsblock, Mandant aus Sitzung' (#382) from feature/portal-ausbau into feature/shl-03-accountbar-dedup
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (push) Canceled after 0s
CI / Web (dms-browser) (push) Canceled after 0s
Core-Schnittstellen-Vertragstests / contract-tests (pull_request) Canceled after 0s
Supply-Chain-Scan (Go) / govulncheck (pull_request) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (push) Canceled after 0s
CI / Connect Security-Test-Gate (QA-01 AC4, Merge-Gate) (push) Canceled after 0s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (push) Canceled after 0s
CI / Web (shl) (push) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (pull_request) Canceled after 0s
CI / Connect Security-Test-Gate (QA-01 AC4, Merge-Gate) (pull_request) Canceled after 0s
CI / Web (dms-browser) (pull_request) Canceled after 0s
CI / Web (pdf-viewer) (pull_request) Canceled after 0s
CI / Web (shl) (pull_request) Canceled after 0s
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (pull_request) Canceled after 0s
Supply-Chain-Scan (npm) / npm-audit (pull_request) Canceled after 0s
Core-Pflichttest-Gate / pflichttest-gate (pull_request) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (push) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (push) Canceled after 0s
CI / Web (pdf-viewer) (push) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (pull_request) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (pull_request) Canceled after 0s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (pull_request) Canceled after 0s
RBAC-Rollenhierarchie-Pflichtgate / rbac-policy-invariant (pull_request) Canceled after 0s
2026-10-27 23:06:34 +01:00
patrickandClaude Sonnet 5.5 ab826dac3c portal: Betriebsblock (Systemstatus, Kennzahlen-Summe), Caches und Policy aus den Route-Dateien ausgelagert
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (pull_request) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (pull_request) Canceled after 0s
CI / Connect Security-Test-Gate (QA-01 AC4, Merge-Gate) (pull_request) Canceled after 0s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (pull_request) Canceled after 0s
CI / Web (dms-browser) (pull_request) Canceled after 0s
CI / Web (pdf-viewer) (pull_request) Canceled after 0s
CI / Web (shl) (pull_request) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (push) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (push) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (push) Canceled after 0s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (push) Canceled after 0s
CI / Web (dms-browser) (push) Canceled after 0s
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (push) Canceled after 0s
Supply-Chain-Scan (Go) / govulncheck (pull_request) Canceled after 0s
Supply-Chain-Scan (npm) / npm-audit (pull_request) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (pull_request) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (pull_request) Canceled after 0s
Core-Pflichttest-Gate / pflichttest-gate (pull_request) Canceled after 0s
CI / Connect Security-Test-Gate (QA-01 AC4, Merge-Gate) (push) Canceled after 0s
CI / Web (pdf-viewer) (push) Canceled after 0s
CI / Web (shl) (push) Canceled after 0s
Core-Schnittstellen-Vertragstests / contract-tests (pull_request) Canceled after 0s
RBAC-Rollenhierarchie-Pflichtgate / rbac-policy-invariant (pull_request) Canceled after 0s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 23:05:44 +01:00
patrickandClaude Sonnet 5.5 d5acbe1029 portal: Gruppen, Arbeitsvorrat, Mandant aus Sitzung, URL-Override; Arbeitskarten
CI / Core-Modul (Build/Vet/Test) (push) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (push) Canceled after 0s
CI / Connect Security-Test-Gate (QA-01 AC4, Merge-Gate) (push) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (push) Canceled after 0s
CI / Web (shl) (push) Canceled after 0s
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (push) Canceled after 0s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (push) Canceled after 0s
CI / Web (dms-browser) (push) Canceled after 0s
CI / Web (pdf-viewer) (push) Canceled after 0s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 23:02:36 +01:00
patrickandClaude Sonnet 5.5 6b42de39b9 portal: README mit Umgebungsvariablen und Verhalten der Route
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (pull_request) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (push) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (pull_request) Canceled after 0s
CI / Connect Security-Test-Gate (QA-01 AC4, Merge-Gate) (pull_request) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (pull_request) Canceled after 0s
CI / Web (shl) (pull_request) Canceled after 0s
CI / Connect Security-Test-Gate (QA-01 AC4, Merge-Gate) (push) Canceled after 0s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (push) Canceled after 0s
CI / Web (dms-browser) (push) Canceled after 0s
CI / Web (shl) (push) Canceled after 0s
Core-Schnittstellen-Vertragstests / contract-tests (pull_request) Canceled after 0s
RBAC-Rollenhierarchie-Pflichtgate / rbac-policy-invariant (pull_request) Canceled after 0s
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (push) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (push) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (pull_request) Canceled after 0s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (pull_request) Canceled after 0s
CI / Web (dms-browser) (pull_request) Canceled after 0s
CI / Web (pdf-viewer) (pull_request) Canceled after 0s
Supply-Chain-Scan (Go) / govulncheck (pull_request) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (push) Canceled after 0s
CI / Web (pdf-viewer) (push) Canceled after 0s
Core-Pflichttest-Gate / pflichttest-gate (pull_request) Canceled after 0s
Supply-Chain-Scan (npm) / npm-audit (pull_request) Canceled after 0s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 22:59:03 +01:00
patrickandClaude Sonnet 5.5 fd4a0568a3 portal: visible-modules prueft Modul-Token (Mandant/Rolle aus Claims), 30s-Cache je Mandant/Rolle
RBAC-Rollenhierarchie-Pflichtgate / rbac-policy-invariant (pull_request) Canceled after 0s
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (pull_request) Canceled after 0s
Core-Pflichttest-Gate / pflichttest-gate (pull_request) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (push) Canceled after 0s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (push) Canceled after 0s
CI / Web (dms-browser) (push) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (pull_request) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (pull_request) Canceled after 0s
CI / Connect Security-Test-Gate (QA-01 AC4, Merge-Gate) (pull_request) Canceled after 0s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (pull_request) Canceled after 0s
CI / Web (pdf-viewer) (pull_request) Canceled after 0s
Supply-Chain-Scan (Go) / govulncheck (pull_request) Canceled after 0s
Supply-Chain-Scan (npm) / npm-audit (pull_request) Canceled after 0s
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (push) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (push) Canceled after 0s
CI / Connect Security-Test-Gate (QA-01 AC4, Merge-Gate) (push) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (push) Canceled after 0s
CI / Web (pdf-viewer) (push) Canceled after 0s
CI / Web (shl) (push) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (pull_request) Canceled after 0s
CI / Web (dms-browser) (pull_request) Canceled after 0s
CI / Web (shl) (pull_request) Canceled after 0s
Core-Schnittstellen-Vertragstests / contract-tests (pull_request) Canceled after 0s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 22:49:22 +01:00
patrickandClaude Sonnet 5.5 65cc0ac7bf portal: AccountBar mit Benutzer/Rolle/Mandant, kein Neuladen der Module beim Sprachwechsel
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (pull_request) Canceled after 0s
Supply-Chain-Scan (npm) / npm-audit (pull_request) Canceled after 0s
Core-Pflichttest-Gate / pflichttest-gate (pull_request) Canceled after 0s
RBAC-Rollenhierarchie-Pflichtgate / rbac-policy-invariant (pull_request) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (push) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (push) Canceled after 0s
CI / Connect Security-Test-Gate (QA-01 AC4, Merge-Gate) (push) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (push) Canceled after 0s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (push) Canceled after 0s
CI / Web (shl) (push) Canceled after 0s
CI / Connect Security-Test-Gate (QA-01 AC4, Merge-Gate) (pull_request) Canceled after 0s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (pull_request) Canceled after 0s
CI / Web (dms-browser) (pull_request) Canceled after 0s
CI / Web (pdf-viewer) (pull_request) Canceled after 0s
CI / Web (shl) (pull_request) Canceled after 0s
Core-Schnittstellen-Vertragstests / contract-tests (pull_request) Canceled after 0s
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (push) Canceled after 0s
CI / Web (dms-browser) (push) Canceled after 0s
CI / Web (pdf-viewer) (push) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (pull_request) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (pull_request) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (pull_request) Canceled after 0s
Supply-Chain-Scan (Go) / govulncheck (pull_request) Canceled after 0s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 22:46:35 +01:00
patrickandClaude Sonnet 5.5 bc47c1331b SHL-04: AccountBar zeigt Benutzer, Rolle und Mandant aus der Sitzung
CI / Core-Modul (Build/Vet/Test) (push) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (pull_request) Canceled after 0s
CI / Connect Security-Test-Gate (QA-01 AC4, Merge-Gate) (pull_request) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (pull_request) Canceled after 0s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (pull_request) Canceled after 0s
CI / Web (dms-browser) (pull_request) Canceled after 0s
Core-Schnittstellen-Vertragstests / contract-tests (pull_request) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (push) Canceled after 0s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (push) Canceled after 0s
CI / Web (dms-browser) (push) Canceled after 0s
CI / Web (pdf-viewer) (push) Canceled after 0s
CI / Web (shl) (push) Canceled after 0s
Supply-Chain-Scan (npm) / npm-audit (pull_request) Canceled after 0s
RBAC-Rollenhierarchie-Pflichtgate / rbac-policy-invariant (pull_request) Canceled after 0s
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (push) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (pull_request) Canceled after 0s
CI / Web (pdf-viewer) (pull_request) Canceled after 0s
CI / Web (shl) (pull_request) Canceled after 0s
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (pull_request) Canceled after 0s
CI / Connect Security-Test-Gate (QA-01 AC4, Merge-Gate) (push) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (push) Canceled after 0s
Supply-Chain-Scan (Go) / govulncheck (pull_request) Canceled after 0s
Core-Pflichttest-Gate / pflichttest-gate (pull_request) Canceled after 0s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 22:31:17 +01:00
patrick b775de651b Merge pull request 'common/testdb: AdminDSN mit REQUIRE_DB + DB-Test-Workflow' (#381) from feat/common-requiredb into main
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (push) Canceled after 0s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (push) Canceled after 0s
CI / Web (dms-browser) (push) Canceled after 0s
CI / Web (pdf-viewer) (push) Canceled after 0s
CI / Web (shl) (push) Canceled after 0s
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (push) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (push) Canceled after 0s
CI / Connect Security-Test-Gate (QA-01 AC4, Merge-Gate) (push) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (push) Canceled after 0s
Supply-Chain-Scan (Go) / govulncheck (push) Canceled after 0s
common Versions-Tag / tag (push) Successful in 7m50s
2026-10-27 18:15:39 +01:00
patrick 8db663b7db Merge pull request 'feat(common): parametrisiertes Pflichttest-Gate für common/' (#379) from feat/common-pflichttestgate into main
CI / Web (pdf-viewer) (push) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (push) Canceled after 0s
CI / Web (dms-browser) (push) Canceled after 0s
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (push) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (push) Canceled after 0s
CI / Connect Security-Test-Gate (QA-01 AC4, Merge-Gate) (push) Canceled after 0s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (push) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (push) Canceled after 0s
Supply-Chain-Scan (Go) / govulncheck (push) Canceled after 0s
CI / Web (shl) (push) Canceled after 0s
common Versions-Tag / tag (push) Successful in 7m1s
2026-10-27 18:15:30 +01:00
patrick 0b90c8b339 Merge pull request 'feat: versioniertes Modul common/ (ohne Verbraucher)' (#378) from feat/common-modul into main
CI / Core-Modul (Build/Vet/Test) (push) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (push) Canceled after 0s
CI / Connect Security-Test-Gate (QA-01 AC4, Merge-Gate) (push) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (push) Canceled after 0s
CI / Web (shl) (push) Canceled after 0s
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (push) Canceled after 0s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (push) Canceled after 0s
CI / Web (dms-browser) (push) Canceled after 0s
CI / Web (pdf-viewer) (push) Canceled after 0s
Supply-Chain-Scan (Go) / govulncheck (push) Canceled after 0s
common Versions-Tag / tag (push) Successful in 7m23s
2026-10-27 18:15:03 +01:00
patrickandClaude Sonnet 5.5 3f4ac77b1e common/testdb: AdminDSN mit REQUIRE_DB, DB-Test-Workflow fuer Runner nexarch-test
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (pull_request) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (pull_request) Canceled after 0s
CI / Connect Security-Test-Gate (QA-01 AC4, Merge-Gate) (pull_request) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (pull_request) Canceled after 0s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (pull_request) Canceled after 0s
CI / Web (dms-browser) (pull_request) Canceled after 0s
DB-Integrationstests / db-tests (pull_request) Canceled after 0s
Supply-Chain-Scan (Go) / govulncheck (pull_request) Canceled after 0s
Supply-Chain-Scan (npm) / npm-audit (pull_request) Canceled after 0s
Core-Pflichttest-Gate / pflichttest-gate (pull_request) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (push) Canceled after 0s
CI / Connect Security-Test-Gate (QA-01 AC4, Merge-Gate) (push) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (push) Canceled after 0s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (push) Canceled after 0s
CI / Web (pdf-viewer) (push) Canceled after 0s
CI / Web (shl) (push) Canceled after 0s
Core-Schnittstellen-Vertragstests / contract-tests (pull_request) Canceled after 0s
Supply-Chain-Scan (Go) / govulncheck (push) Canceled after 0s
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (push) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (pull_request) Canceled after 0s
CI / Web (pdf-viewer) (pull_request) Canceled after 0s
CI / Web (shl) (pull_request) Canceled after 0s
RBAC-Rollenhierarchie-Pflichtgate / rbac-policy-invariant (pull_request) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (push) Canceled after 0s
CI / Web (dms-browser) (push) Canceled after 0s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 17:58:25 +01:00
patrickandClaude Sonnet 5.5 0c8b3fe2b3 feat(common): parametrisiertes Pflichttest-Gate, Gate fuer common/ selbst
CI / Core-Modul (Build/Vet/Test) (push) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (push) Canceled after 0s
CI / Connect Security-Test-Gate (QA-01 AC4, Merge-Gate) (push) Canceled after 0s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (push) Canceled after 0s
CI / Web (dms-browser) (push) Canceled after 0s
CI / Web (pdf-viewer) (push) Canceled after 0s
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (push) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (pull_request) Canceled after 0s
CI / Connect Security-Test-Gate (QA-01 AC4, Merge-Gate) (pull_request) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (pull_request) Canceled after 0s
CI / Web (dms-browser) (pull_request) Canceled after 0s
CI / Web (pdf-viewer) (pull_request) Canceled after 0s
Supply-Chain-Scan (Go) / govulncheck (push) Canceled after 0s
common Pflichttest-Gate / pflichttest-gate (pull_request) Canceled after 0s
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (pull_request) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (push) Canceled after 0s
CI / Web (shl) (push) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (pull_request) Canceled after 0s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (pull_request) Canceled after 0s
CI / Web (shl) (pull_request) Canceled after 0s
Core-Schnittstellen-Vertragstests / contract-tests (pull_request) Canceled after 0s
Supply-Chain-Scan (Go) / govulncheck (pull_request) Canceled after 0s
Supply-Chain-Scan (npm) / npm-audit (pull_request) Canceled after 0s
Core-Pflichttest-Gate / pflichttest-gate (pull_request) Canceled after 0s
RBAC-Rollenhierarchie-Pflichtgate / rbac-policy-invariant (pull_request) Canceled after 0s
CheckDiff(changed, patterns): gleiche Logik wie die Modul-Gates, Pfadmuster
vom Aufrufer. cmd/pflichttestgate + Workflow decken svcauth,
tenantregistryclient, auditclient, policyclient, apierror, httpharden ab.
README: Auswirkungsliste und Minor-Version bei Breaking Changes.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 15:25:26 +01:00
patrickandClaude Sonnet 5.5 4f07bc73a2 feat: versioniertes Modul common/ (ohne Verbraucher)
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (pull_request) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (pull_request) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (pull_request) Canceled after 0s
CI / Connect Security-Test-Gate (QA-01 AC4, Merge-Gate) (pull_request) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (push) Canceled after 0s
CI / Connect Security-Test-Gate (QA-01 AC4, Merge-Gate) (push) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (push) Canceled after 0s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (push) Canceled after 0s
CI / Web (pdf-viewer) (push) Canceled after 0s
CI / Web (pdf-viewer) (pull_request) Canceled after 0s
CI / Web (shl) (pull_request) Canceled after 0s
Core-Schnittstellen-Vertragstests / contract-tests (pull_request) Canceled after 0s
Supply-Chain-Scan (Go) / govulncheck (pull_request) Canceled after 0s
Supply-Chain-Scan (npm) / npm-audit (pull_request) Canceled after 0s
Core-Pflichttest-Gate / pflichttest-gate (pull_request) Canceled after 0s
RBAC-Rollenhierarchie-Pflichtgate / rbac-policy-invariant (pull_request) Canceled after 0s
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (push) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (pull_request) Canceled after 0s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (pull_request) Canceled after 0s
CI / Web (dms-browser) (pull_request) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (push) Canceled after 0s
CI / Web (dms-browser) (push) Canceled after 0s
CI / Web (shl) (push) Canceled after 0s
Supply-Chain-Scan (Go) / govulncheck (push) Canceled after 0s
Pakete: apierror, auditclient, httpclient, httpharden, loadtest, migrate,
policyclient, svcauth, tenantregistryclient, testdb. Code stammt aus den
bisher byte-gleichen Modulkopien (ohne Kommentare/Importpfade verglichen).
migrate/testdb brauchen pgx v5.9.2.

Auslieferung per Git-Tag common/vX.Y.Z (Workflow common-tag.yml, setzt nach
Merge in main den naechsten Patch-Tag nach Build/Vet/Test). Kein replace,
damit Module einzeln baubar bleiben. Verbraucher werden in Folge-PRs
umgestellt.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 14:40:46 +01:00
patrickandClaude Sonnet 5 2518160c92 SHL-04: AccountBar-Dopplung aufgeloest (duenne Wrapper statt kopierter JSX)
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (pull_request) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (pull_request) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (pull_request) Canceled after 0s
CI / Connect Security-Test-Gate (QA-01 AC4, Merge-Gate) (pull_request) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (push) Canceled after 0s
CI / Connect Security-Test-Gate (QA-01 AC4, Merge-Gate) (push) Canceled after 0s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (push) Canceled after 0s
Core-Schnittstellen-Vertragstests / contract-tests (pull_request) Canceled after 0s
Supply-Chain-Scan (npm) / npm-audit (pull_request) Canceled after 0s
Core-Pflichttest-Gate / pflichttest-gate (pull_request) Canceled after 0s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (pull_request) Canceled after 0s
CI / Web (dms-browser) (pull_request) Canceled after 0s
CI / Web (pdf-viewer) (pull_request) Canceled after 0s
CI / Web (shl) (pull_request) Canceled after 0s
CI / Web (pdf-viewer) (push) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (push) Canceled after 0s
CI / Web (dms-browser) (push) Canceled after 0s
CI / Web (shl) (push) Canceled after 0s
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (push) Canceled after 0s
Supply-Chain-Scan (Go) / govulncheck (pull_request) Canceled after 0s
RBAC-Rollenhierarchie-Pflichtgate / rbac-policy-invariant (pull_request) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (pull_request) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (push) Canceled after 0s
Fable-Review auf Code-Dopplung (P1). web/dms-browser, web/dms-upload,
web/pdf-viewer hatten byteidentische components/AccountBar.tsx. Darstellung
jetzt einmal in web/shl/components/AccountBar.tsx (visible/onLogout als
Props). Die drei App-lokalen Dateien bleiben als duenne 10-Zeilen-Wrapper
bestehen (loesen authMode()/getSession() aus dem jeweils eigenen,
unterschiedlichen lib/http.ts auf) statt komplett zu verschwinden --
direktes Reichen der onLogout-Funktion von layout.tsx (Server Component)
an die shl-Komponente verletzt sonst die RSC-Props-Grenze ("Event handlers
cannot be passed to Client Component props"), erst beim Build entdeckt
und korrigiert.

next build + npx vitest run auf allen 3 Apps gruen (Testhost 131).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-19 23:17:02 +02:00
72 changed files with 3763 additions and 230 deletions

No files matched your search

@@ -0,0 +1,23 @@
name: common Pflichttest-Gate
on:
pull_request:
paths:
- "common/**"
jobs:
pflichttest-gate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-go@v5
with:
go-version: "1.25"
- name: Gate bauen
working-directory: common
run: go build -o /tmp/pflichttestgate ./cmd/pflichttestgate
- name: Geänderte Dateien gegen Pflichttest-Regel pruefen
run: |
git diff --name-only "origin/${{ github.base_ref }}...HEAD" -- common/ | /tmp/pflichttestgate
+33
View File
@@ -0,0 +1,33 @@
name: common Versions-Tag
on:
push:
branches: [main]
paths:
- "common/**"
jobs:
tag:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-go@v5
with:
go-version: "1.25"
- name: Build, Vet, Test
working-directory: common
run: |
go build ./...
go vet ./...
go test ./... -count=1
- name: Naechsten Tag setzen
run: |
minor=$(tr -d '[:space:]' < common/MINOR)
last=$(git tag -l "common/v0.${minor}.*" --sort=-v:refname | head -n1)
if [ -z "$last" ]; then next="common/v0.${minor}.0"; else
patch=${last##*.}; base=${last%.*}; next="${base}.$((patch + 1))"
fi
git tag "$next"
git push origin "$next"
+28
View File
@@ -0,0 +1,28 @@
name: DB-Integrationstests
on:
pull_request:
paths:
- "common/**"
- "dms/**"
- "mail/**"
- "archive/**"
- "ai/**"
- "connect/**"
- "workflow/**"
- "internal/**"
jobs:
db-tests:
runs-on: nexarch-test
env:
TEST_ADMIN_DSN: ${{ secrets.TEST_ADMIN_DSN }}
REQUIRE_DB: "1"
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: "1.25"
- name: Tests (common)
working-directory: common
run: go test ./testdb/... ./migrate/... -count=1 -p 1
+2
View File
@@ -64,6 +64,8 @@ Kein Modul kopiert die Daten eines anderen — stattdessen ein wiederkehrendes *
Gemeinsamer Nenner aller drei: **Referenz statt Kopie, Adapter statt Direktkopplung.**
Go-`internal/`-Pakete sind zwischen den Modulen nicht sichtbar; identische Querschnittspakete liegen deshalb im versionierten Modul `common/` (Git-Tags `common/vX.Y.Z`, siehe `common/README.md`). So bleiben Module einzeln baubar (Docker, eigenes Repo, Partner-Build). Fachlich verschiedene Pakete (`audittrail`, `cutoverstage`, `crypto`) bleiben modul-lokal.
Dasselbe Prinzip gilt auch innerhalb eines Moduls, auf Code-Ebene: Hauptdateien/Entry-Points bleiben klein, Fachlogik wandert in eigene Module. Vor einer neuen Funktions-/Modul-Datei wird geprüft, ob dieselbe Funktionalität nicht schon woanders existiert (auch modulübergreifend, z. B. in Core) — keine doppelten Funktionsdateien. Das eigene Kanban-Planungswerkzeug wurde nach genau diesem Muster korrigiert (sieben `kanban.html` enthielten identisches JS/CSS als Volltext-Duplikat, damals in `_shared/kanban.js`/`kanban.css` ausgelagert) — die Boards selbst sind inzwischen komplett aus dem Repo entfernt (Ticket-Historie liegt in der Gitea-PR-Historie), das Prinzip "keine doppelten Funktionsdateien" gilt unverändert für den eigentlichen NEXARCH-Code.
## 6. Technische Architektur (Kurzfassung, Details in eigenen Dokumenten)
+1
View File
@@ -0,0 +1 @@
2
+26
View File
@@ -0,0 +1,26 @@
# common
Geteiltes Go-Modul für Pakete, die in mehreren NEXARCH-Modulen identisch gebraucht werden (`internal/` ist modulübergreifend nicht sichtbar).
Pakete: `pflichttestgate` (parametrisierte Testpflicht-Logik), `apierror`, `auditclient`, `httpclient`, `httpharden`, `loadtest`, `migrate`, `moduleauth`, `policyclient`, `svcauth`, `tenantregistryclient`, `testdb`.
## Einbinden
```
go env -w GOPRIVATE=gitea.perlbach24.de
go get gitea.perlbach24.de/scripte/nexarch/common@latest
```
Build-Orte brauchen Lesezugriff auf Gitea: Read-only-Token in `~/.netrc` (`machine gitea.perlbach24.de login <user> password <token>`).
## Versionen
Tags `common/v0.X.Y` setzt `.gitea/workflows/common-tag.yml` nach jedem Merge in `main`, der `common/` ändert. Module aktualisieren einzeln (`go get ...common@v0.X.Y`). Lokal mit mehreren Modulen gleichzeitig arbeiten: nicht eingechecktes `go.work`.
## Regeln
- Nur Code, der in mindestens zwei Modulen byte-gleich (ohne Kommentare) gebraucht wird; fachlich verschiedene Pakete bleiben modul-lokal.
- Änderungen abwärtskompatibel halten, solange Module nicht gleichzeitig umgestellt werden.
- Jeder PR an `common/` nennt die betroffenen Module (Auswirkungsliste). Breaking Changes nur mit neuer Minor-Version (`v0.2.0`) und Umstellungs-PRs je Modul; die aktuelle Minor steht in `common/MINOR`, der Tag-Workflow setzt bei einer neuen Minor `v0.<minor>.0`.
- Sicherheitskritische Pakete (`svcauth`, `tenantregistryclient`, `auditclient`, `policyclient`, `apierror`, `httpharden`) unterliegen dem Pflichttest-Gate (`cmd/pflichttestgate`, Workflow `common-pflichttest-gate.yml`): Änderung ohne begleitende `_test.go` im selben Package schlägt fehl.
+191
View File
@@ -0,0 +1,191 @@
// Package apierror erzwingt das Core-Fehlerformat (API-01) auf allen
// HTTP-Antworten und schliesst QA-15 F-14: 116 Stellen in Core, DMS, Archive
// und Mail betteten err.Error() direkt in Antworten ein, darunter 500er mit
// Datenbank- und Dateipfadtexten. Statt jede einzelne Aufrufstelle
// anzufassen, schreibt Normalize JEDE Fehlerantwort am aeussersten Rand
// (httpharden.ListenAndServe) um: 4xx-Meldungen bleiben inhaltlich
// erhalten (fachlich, keine Interna), 5xx-Antworten werden PRINZIPIELL
// durch eine allgemeine Meldung mit Korrelations-ID ersetzt — der
// urspruengliche Text (der die Interna enthalten haben KANN) geht nur ins
// Log, niemals in die Antwort. Erweitert dasselbe Grundmuster wie
// dms/internal/apierror (INT-01, dort das "Vorbild" laut API-20).
package apierror
import (
"bytes"
"crypto/rand"
"encoding/hex"
"encoding/json"
"log"
"net/http"
"strings"
)
func CodeForStatus(status int) string {
switch status {
case http.StatusBadRequest:
return "invalid_request"
case http.StatusUnauthorized:
return "unauthenticated"
case http.StatusForbidden:
return "forbidden"
case http.StatusNotFound:
return "not_found"
case http.StatusMethodNotAllowed:
return "method_not_allowed"
case http.StatusConflict:
return "conflict"
case http.StatusGone:
return "gone"
case http.StatusRequestEntityTooLarge:
return "payload_too_large"
case http.StatusUnsupportedMediaType:
return "unsupported_media_type"
case http.StatusUnprocessableEntity:
return "unprocessable"
case http.StatusLocked:
return "locked"
case http.StatusTooManyRequests:
return "rate_limited"
case http.StatusServiceUnavailable:
return "unavailable"
}
if status >= 500 {
return "internal_error"
}
return "invalid_request"
}
type body struct {
Error struct {
Code string `json:"code"`
Message string `json:"message"`
CorrelationID string `json:"correlation_id,omitempty"`
} `json:"error"`
}
func Write(w http.ResponseWriter, status int, code, message string) {
var b body
b.Error.Code = code
b.Error.Message = message
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(b)
}
func newCorrelationID() string {
buf := make([]byte, 8)
if _, err := rand.Read(buf); err != nil {
// crypto/rand-Ausfall ist praktisch nie beobachtbar; eine feste
// Ersatz-ID ist besser als ein Absturz in einem Fehlerpfad, der
// selbst schon einen Fehler meldet.
return "korrelations-id-nicht-verfuegbar"
}
return hex.EncodeToString(buf)
}
// Normalize schreibt jede Fehlerantwort (Status >= 400) im Core-Format um.
// 4xx: der vom Handler geschriebene Text bleibt als "message" erhalten (er
// ist fachlich, kein Interna-Leck). 5xx (Akzeptanzkriterium 1): der Text
// wird verworfen, NIE an den Aufrufer weitergegeben — stattdessen eine
// allgemeine Meldung mit Korrelations-ID; derselbe Text landet mitsamt
// Korrelations-ID im Server-Log (Prozess-stderr, wie ueberall sonst in
// diesem Projekt).
func Normalize(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
rec := &recorder{ResponseWriter: w}
next.ServeHTTP(rec, r)
rec.finish(r)
})
}
type recorder struct {
http.ResponseWriter
status int
buffered bool
buf bytes.Buffer
}
func (r *recorder) WriteHeader(status int) {
if r.status != 0 {
return
}
r.status = status
if status >= 400 {
r.buffered = true
return
}
r.ResponseWriter.WriteHeader(status)
}
func (r *recorder) Write(p []byte) (int, error) {
if r.status == 0 {
r.WriteHeader(http.StatusOK)
}
if r.buffered {
return r.buf.Write(p)
}
return r.ResponseWriter.Write(p)
}
func (r *recorder) Flush() {
if f, ok := r.ResponseWriter.(http.Flusher); ok && !r.buffered {
f.Flush()
}
}
func (r *recorder) Unwrap() http.ResponseWriter { return r.ResponseWriter }
func (r *recorder) finish(req *http.Request) {
if !r.buffered {
return
}
code := CodeForStatus(r.status)
rawMessage := strings.TrimSpace(r.buf.String())
if r.status >= 500 {
corrID := newCorrelationID()
log.Printf("apierror: 5xx %s %s status=%d korrelations-id=%s ursprung=%q", req.Method, req.URL.Path, r.status, corrID, rawMessage)
out := body{}
out.Error.Code = code
out.Error.Message = "Interner Fehler"
out.Error.CorrelationID = corrID
encoded, _ := json.Marshal(out)
r.flush(append(encoded, '\n'))
return
}
out := map[string]any{}
message := rawMessage
var parsed map[string]any
if json.Unmarshal(r.buf.Bytes(), &parsed) == nil && parsed != nil {
switch e := parsed["error"].(type) {
case string:
message = e
delete(parsed, "error")
out = parsed
case map[string]any:
if c, _ := e["code"].(string); c != "" {
if m, _ := e["message"].(string); m != "" {
r.flush(r.buf.Bytes())
return
}
}
}
}
if message == "" {
message = http.StatusText(r.status)
}
out["error"] = map[string]string{"code": code, "message": message}
encoded, _ := json.Marshal(out)
r.flush(append(encoded, '\n'))
}
func (r *recorder) flush(payload []byte) {
h := r.ResponseWriter.Header()
h.Set("Content-Type", "application/json")
h.Del("Content-Length")
h.Set("X-Content-Type-Options", "nosniff")
r.ResponseWriter.WriteHeader(r.status)
_, _ = r.ResponseWriter.Write(payload)
}
+184
View File
@@ -0,0 +1,184 @@
package apierror
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func decode(t *testing.T, rec *httptest.ResponseRecorder) map[string]any {
t.Helper()
var m map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &m); err != nil {
t.Fatalf("keine json-antwort: %q (%v)", rec.Body.String(), err)
}
return m
}
func errOf(t *testing.T, m map[string]any) map[string]any {
t.Helper()
e, ok := m["error"].(map[string]any)
if !ok {
t.Fatalf("error ist kein objekt: %v", m)
}
return e
}
// Akzeptanzkriterium 2: fachliche 4xx-Meldungen bleiben verstaendlich.
func TestNormalize_4xxKeepsHandlerMessage(t *testing.T) {
cases := []struct {
name string
handler http.HandlerFunc
status int
wantCode string
wantMsg string
}{
{"http.Error klartext", func(w http.ResponseWriter, r *http.Request) { http.Error(w, "dokument nicht gefunden", 404) }, 404, "not_found", "dokument nicht gefunden"},
{"json string", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(422)
_, _ = w.Write([]byte(`{"error":"ungueltiger wert"}`))
}, 422, "unprocessable", "ungueltiger wert"},
{"leerer body", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(403) }, 403, "forbidden", "Forbidden"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
rec := httptest.NewRecorder()
Normalize(c.handler).ServeHTTP(rec, httptest.NewRequest("GET", "/x", nil))
if rec.Code != c.status {
t.Fatalf("status = %d", rec.Code)
}
if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/json") {
t.Errorf("content-type = %q", ct)
}
e := errOf(t, decode(t, rec))
if e["code"] != c.wantCode || e["message"] != c.wantMsg {
t.Errorf("code=%v msg=%v, erwartet %q/%q", e["code"], e["message"], c.wantCode, c.wantMsg)
}
if _, has := e["correlation_id"]; has {
t.Errorf("4xx sollte keine korrelations-id tragen: %v", e)
}
})
}
}
// Akzeptanzkriterium 1: 5xx-Antworten enthalten NIE den Handler-Text
// (der die Interna leaken kann), nur eine allgemeine Meldung mit
// Korrelations-ID — egal, was der Handler geschrieben hat.
func TestNormalize_5xxNeverLeaksHandlerText(t *testing.T) {
cases := []struct {
name string
handler http.HandlerFunc
}{
{"http.Error mit db-fehler", func(w http.ResponseWriter, r *http.Request) {
http.Error(w, `pq: connection to server at "10.0.0.5", port 5432 failed`, 500)
}},
{"http.Error mit dateipfad", func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "open /var/lib/nexarch/secrets/tenant.key: permission denied", 502)
}},
{"json mit interna", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(503)
_, _ = w.Write([]byte(`{"error":"dial tcp 127.0.0.1:5432: connect: connection refused"}`))
}},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
rec := httptest.NewRecorder()
Normalize(c.handler).ServeHTTP(rec, httptest.NewRequest("GET", "/x", nil))
body := rec.Body.String()
for _, leak := range []string{"pq:", "10.0.0.5", "/var/lib/nexarch", "permission denied", "127.0.0.1", "dial tcp"} {
if strings.Contains(body, leak) {
t.Errorf("5xx-antwort enthaelt interna %q: %s", leak, body)
}
}
e := errOf(t, decode(t, rec))
if e["message"] != "Interner Fehler" {
t.Errorf("message = %v, erwartet generische meldung", e["message"])
}
corrID, _ := e["correlation_id"].(string)
if corrID == "" {
t.Fatalf("keine korrelations-id in der antwort: %v", e)
}
if len(corrID) < 8 {
t.Errorf("korrelations-id zu kurz/vorhersagbar: %q", corrID)
}
})
}
}
// Zwei Anfragen mit demselben 5xx-Fehler bekommen unterschiedliche
// Korrelations-IDs (sonst waere die ID im Log nicht eindeutig zuordenbar).
func TestNormalize_5xxCorrelationIDsAreUnique(t *testing.T) {
handler := func(w http.ResponseWriter, r *http.Request) { http.Error(w, "boom", 500) }
var ids []string
for i := 0; i < 5; i++ {
rec := httptest.NewRecorder()
Normalize(http.HandlerFunc(handler)).ServeHTTP(rec, httptest.NewRequest("GET", "/x", nil))
e := errOf(t, decode(t, rec))
id, _ := e["correlation_id"].(string)
for _, seen := range ids {
if seen == id {
t.Fatalf("korrelations-id doppelt vergeben: %q", id)
}
}
ids = append(ids, id)
}
}
func TestNormalize_KeepsExtraFieldsOn4xx(t *testing.T) {
rec := httptest.NewRecorder()
Normalize(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(409)
_, _ = w.Write([]byte(`{"error":"duplikat","documentId":"d1"}`))
})).ServeHTTP(rec, httptest.NewRequest("POST", "/x", nil))
m := decode(t, rec)
if m["documentId"] != "d1" {
t.Errorf("zusatzfeld verloren: %v", m)
}
if errOf(t, m)["code"] != "conflict" {
t.Errorf("code = %v", errOf(t, m)["code"])
}
rec = httptest.NewRecorder()
Normalize(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Write(w, 401, "unauthenticated", "nicht angemeldet")
})).ServeHTTP(rec, httptest.NewRequest("GET", "/x", nil))
e := errOf(t, decode(t, rec))
if e["code"] != "unauthenticated" || e["message"] != "nicht angemeldet" {
t.Errorf("core-format veraendert: %v", e)
}
}
func TestNormalize_SuccessPassesThroughUnbuffered(t *testing.T) {
rec := httptest.NewRecorder()
Normalize(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/pdf")
_, _ = w.Write([]byte("%PDF-1.7"))
})).ServeHTTP(rec, httptest.NewRequest("GET", "/x", nil))
if rec.Code != 200 || rec.Body.String() != "%PDF-1.7" || rec.Header().Get("Content-Type") != "application/pdf" {
t.Errorf("erfolgsantwort veraendert: %d %q %q", rec.Code, rec.Body.String(), rec.Header().Get("Content-Type"))
}
}
func TestNormalize_MuxNotFoundAndMethodNotAllowed(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("GET /a", func(w http.ResponseWriter, r *http.Request) {})
h := Normalize(mux)
for _, c := range []struct {
method, path string
status int
code string
}{{"GET", "/nope", 404, "not_found"}, {"POST", "/a", 405, "method_not_allowed"}} {
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(c.method, c.path, nil))
if rec.Code != c.status {
t.Fatalf("%s %s: status %d", c.method, c.path, rec.Code)
}
if errOf(t, decode(t, rec))["code"] != c.code {
t.Errorf("%s %s: code %v", c.method, c.path, errOf(t, decode(t, rec))["code"])
}
}
}
+71
View File
@@ -0,0 +1,71 @@
// Package auditclient ist ein schlanker HTTP-Client fuer Core AUD-08
// (internal/audit, POST /audit/ingest). Connect ist ein physisch
// getrenntes Go-Modul und kann Cores internal/audit daher nicht direkt
// importieren — nur ueber dessen HTTP-Schnittstelle. Gleiches Muster wie
// archive/internal/auditclient.
package auditclient
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"gitea.perlbach24.de/scripte/nexarch/common/httpclient"
)
type Client struct {
BaseURL string
ServiceToken string
HTTPClient *http.Client
}
func New(baseURL, serviceToken string) *Client {
return &Client{BaseURL: baseURL, ServiceToken: serviceToken, HTTPClient: httpclient.Default()}
}
type ingestRequest struct {
TenantSlug string `json:"tenant_slug"`
Actor string `json:"actor"`
Action string `json:"action"`
Target string `json:"target"`
Metadata map[string]any `json:"metadata"`
}
// Ingest meldet EIN Audit-Ereignis an Core. Jeder Fehler (Transport,
// Timeout, unerwarteter Status) wird als Fehler zurueckgegeben — der
// Aufrufer entscheidet, ob/wie er das nachbehandelt (siehe
// connect/internal/audittrail fuer den ausfalltoleranten Wrapper).
func (c *Client) Ingest(ctx context.Context, tenantSlug, actor, action, target string, metadata map[string]any) error {
body, err := json.Marshal(ingestRequest{
TenantSlug: tenantSlug, Actor: actor, Action: action, Target: target, Metadata: metadata,
})
if err != nil {
return fmt.Errorf("auditclient: request kodieren: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.BaseURL+"/audit/ingest", bytes.NewReader(body))
if err != nil {
return fmt.Errorf("auditclient: request bauen: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Service-Token", c.ServiceToken)
resp, err := c.httpClient().Do(req)
if err != nil {
return fmt.Errorf("auditclient: aufruf fehlgeschlagen: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusAccepted {
return fmt.Errorf("auditclient: unerwarteter status %d", resp.StatusCode)
}
return nil
}
func (c *Client) httpClient() *http.Client {
if c.HTTPClient != nil {
return c.HTTPClient
}
return httpclient.Default()
}
+57
View File
@@ -0,0 +1,57 @@
package auditclient
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestIngest_SendsTenantSlugAndServiceToken(t *testing.T) {
var gotBody ingestRequest
var gotToken string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotToken = r.Header.Get("X-Service-Token")
if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil {
t.Fatalf("body dekodieren: %v", err)
}
w.WriteHeader(http.StatusAccepted)
}))
defer srv.Close()
c := New(srv.URL, "svc-token-123")
err := c.Ingest(context.Background(), "acme", "user-1", "connector.sync", "erp:kunde:42", map[string]any{"count": 3})
if err != nil {
t.Fatalf("Ingest: %v", err)
}
if gotToken != "svc-token-123" {
t.Fatalf("Service-Token nicht gesendet, erhalten: %q", gotToken)
}
if gotBody.TenantSlug != "acme" {
t.Fatalf("TenantSlug fehlt/falsch im Audit-Ereignis: %q", gotBody.TenantSlug)
}
if gotBody.Action != "connector.sync" {
t.Fatalf("Action falsch: %q", gotBody.Action)
}
}
func TestIngest_UnexpectedStatus_ReturnsError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer srv.Close()
c := New(srv.URL, "svc-token-123")
if err := c.Ingest(context.Background(), "acme", "user-1", "connector.sync", "erp:kunde:42", nil); err == nil {
t.Fatal("erwarteter Fehler bei Status 500 blieb aus")
}
}
func TestIngest_TransportError_ReturnsError(t *testing.T) {
c := New("http://127.0.0.1:1", "svc-token-123")
if err := c.Ingest(context.Background(), "acme", "user-1", "connector.sync", "erp:kunde:42", nil); err == nil {
t.Fatal("erwarteter Transport-Fehler blieb aus")
}
}
+49
View File
@@ -0,0 +1,49 @@
// Command pflichttestgate ist das CI-Gate für common/.
// Aufruf: pflichttestgate < geänderte-Dateien (eine pro Zeile, via stdin)
// Exit-Code 1 mit Liste der Verstöße, wenn ein sicherheitskritisches Package ohne
// begleitende Testdatei geändert wurde.
package main
import (
"bufio"
"fmt"
"os"
"regexp"
"gitea.perlbach24.de/scripte/nexarch/common/pflichttestgate"
)
var sensitive = []*regexp.Regexp{
regexp.MustCompile(`(^|/)common/svcauth/`),
regexp.MustCompile(`(^|/)common/tenantregistryclient/`),
regexp.MustCompile(`(^|/)common/auditclient/`),
regexp.MustCompile(`(^|/)common/policyclient/`),
regexp.MustCompile(`(^|/)common/apierror/`),
regexp.MustCompile(`(^|/)common/httpharden/`),
}
func main() {
var changedFiles []string
scanner := bufio.NewScanner(os.Stdin)
for scanner.Scan() {
if line := scanner.Text(); line != "" {
changedFiles = append(changedFiles, line)
}
}
if err := scanner.Err(); err != nil {
fmt.Fprintf(os.Stderr, "pflichttestgate: Eingabe konnte nicht gelesen werden: %v\n", err)
os.Exit(2)
}
violations := pflichttestgate.CheckDiff(changedFiles, sensitive)
if len(violations) == 0 {
fmt.Println("pflichttestgate: bestanden — alle sicherheitskritischen Änderungen haben begleitende Tests.")
return
}
fmt.Fprintln(os.Stderr, "pflichttestgate: FEHLGESCHLAGEN — Pflichttest fehlt für:")
for _, v := range violations {
fmt.Fprintf(os.Stderr, " - Package %q (Datei %q hat keine begleitende _test.go-Änderung)\n", v.Package, v.ChangedFile)
}
os.Exit(1)
}
+15
View File
@@ -0,0 +1,15 @@
module gitea.perlbach24.de/scripte/nexarch/common
go 1.25.0
toolchain go1.25.13
require github.com/jackc/pgx/v5 v5.9.2
require (
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
golang.org/x/sync v0.17.0 // indirect
golang.org/x/text v0.29.0 // indirect
)
+26
View File
@@ -0,0 +1,26 @@
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/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-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+29
View File
@@ -0,0 +1,29 @@
// Package httpclient liefert den HTTP-Client fuer ausgehende Dienstaufrufe
// (QA-15 F-11): Zeitlimit statt endlosem Warten auf einen haengenden
// Nachbardienst, und keine Weiterleitungen (ein Service-Token wuerde sonst an
// das Weiterleitungsziel mitgesendet).
package httpclient
import (
"net/http"
"time"
)
// DefaultTimeout gilt fuer einen kompletten Aufruf (Verbindung bis Antwortende).
const DefaultTimeout = 15 * time.Second
var shared = New(DefaultTimeout)
// Default ist der gemeinsame Client mit DefaultTimeout.
func Default() *http.Client { return shared }
// New liefert einen Client mit dem angegebenen Zeitlimit, der Weiterleitungen
// nicht folgt (die 3xx-Antwort wird zurueckgegeben).
func New(timeout time.Duration) *http.Client {
return &http.Client{
Timeout: timeout,
CheckRedirect: func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
},
}
}
+105
View File
@@ -0,0 +1,105 @@
package httpclient
import (
"io/fs"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"regexp"
"strings"
"testing"
"time"
)
func TestDefaultHasTimeout(t *testing.T) {
if Default().Timeout != DefaultTimeout || DefaultTimeout <= 0 {
t.Fatalf("timeout = %s", Default().Timeout)
}
}
func TestClientGivesUpOnHangingServer(t *testing.T) {
release := make(chan struct{})
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { <-release }))
defer srv.Close()
defer close(release)
start := time.Now()
resp, err := New(200 * time.Millisecond).Get(srv.URL)
if err == nil {
_ = resp.Body.Close()
t.Fatal("kein fehler bei haengendem server")
}
if elapsed := time.Since(start); elapsed > 5*time.Second {
t.Fatalf("aufruf dauerte %s", elapsed)
}
}
func TestClientDoesNotFollowRedirects(t *testing.T) {
target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Error("weiterleitungsziel wurde aufgerufen (Service-Token-Leck)")
}))
defer target.Close()
redirector := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, target.URL, http.StatusTemporaryRedirect)
}))
defer redirector.Close()
req, _ := http.NewRequest(http.MethodPost, redirector.URL, strings.NewReader("x"))
req.Header.Set("X-Service-Token", "geheim")
resp, err := Default().Do(req)
if err != nil {
t.Fatalf("do: %v", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusTemporaryRedirect {
t.Fatalf("status = %d, erwartet 307", resp.StatusCode)
}
}
// Architekturtest: im Produktivcode von common gibt es keine Aufrufe ueber den
// Standard-Client ohne Zeitlimit. Die Verbraucher-Module pruefen ihren eigenen
// Code selbst.
func TestNoUnboundedDefaultClientInProductionCode(t *testing.T) {
root, err := filepath.Abs(filepath.Join(".."))
if err != nil {
t.Fatal(err)
}
scanNoDefaultClient(t, root, root)
}
func scanNoDefaultClient(t *testing.T, repoRoot, root string) {
t.Helper()
forbidden := regexp.MustCompile(`http\.DefaultClient|http\.(Get|Post|Head|PostForm)\(`)
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
if n := d.Name(); n == "node_modules" || n == "testdata" || n == ".git" {
return filepath.SkipDir
}
return nil
}
if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
return nil
}
data, err := os.ReadFile(path)
if err != nil {
return err
}
for i, line := range strings.Split(string(data), "\n") {
if t := strings.TrimSpace(line); strings.HasPrefix(t, "//") {
continue
}
if forbidden.MatchString(line) {
rel, _ := filepath.Rel(repoRoot, path)
t.Errorf("%s:%d nutzt den Standard-Client ohne Zeitlimit: %s", rel, i+1, strings.TrimSpace(line))
}
}
return nil
})
if err != nil {
t.Fatal(err)
}
}
+119
View File
@@ -0,0 +1,119 @@
// Package httpharden liefert gehaertete http.Server-Grundeinstellungen
// (Timeouts, Header-Limit) und ein Body-Limit fuer alle Dienste (QA-15 F-05).
// Dieselbe Datei existiert in archive/internal/httpharden und
// dms/internal/httpharden, weil die Module getrennte go.mod-Dateien haben.
package httpharden
import (
"log"
"net/http"
"os"
"strconv"
"time"
"gitea.perlbach24.de/scripte/nexarch/common/apierror"
)
const (
DefaultReadHeaderTimeout = 10 * time.Second
DefaultReadTimeout = 30 * time.Second
DefaultWriteTimeout = 60 * time.Second
DefaultIdleTimeout = 120 * time.Second
DefaultMaxHeaderBytes = 1 << 20
DefaultMaxBodyBytes = 1 << 20
EnvReadHeaderTimeout = "NEXARCH_HTTP_READ_HEADER_TIMEOUT_SECONDS"
EnvReadTimeout = "NEXARCH_HTTP_READ_TIMEOUT_SECONDS"
EnvWriteTimeout = "NEXARCH_HTTP_WRITE_TIMEOUT_SECONDS"
EnvIdleTimeout = "NEXARCH_HTTP_IDLE_TIMEOUT_SECONDS"
EnvMaxBodyBytes = "NEXARCH_HTTP_MAX_BODY_BYTES"
)
// Options weicht vom Standard ab. Nullwerte bedeuten Standard bzw. Env-Wert;
// StreamingBodies/StreamingResponses setzen ReadTimeout/WriteTimeout auf 0
// (unbegrenzt), damit lange Uploads und Downloads nicht abbrechen. Schutz
// gegen langsame Header bleibt ueber ReadHeaderTimeout und IdleTimeout.
type Options struct {
StreamingBodies bool
StreamingResponses bool
}
func envSeconds(name string, def time.Duration) time.Duration {
v := os.Getenv(name)
if v == "" {
return def
}
n, err := strconv.Atoi(v)
if err != nil || n < 1 || n > 3600 {
log.Fatalf("%s muss eine ganze Zahl zwischen 1 und 3600 sein, war %q", name, v)
}
return time.Duration(n) * time.Second
}
// MaxBodyBytes liest die Body-Obergrenze (Standard 1 MiB, Env
// NEXARCH_HTTP_MAX_BODY_BYTES, 1 KiB bis 1 GiB).
func MaxBodyBytes() int64 {
v := os.Getenv(EnvMaxBodyBytes)
if v == "" {
return DefaultMaxBodyBytes
}
n, err := strconv.ParseInt(v, 10, 64)
if err != nil || n < 1<<10 || n > 1<<30 {
log.Fatalf("%s muss zwischen 1024 und 1073741824 liegen, war %q", EnvMaxBodyBytes, v)
}
return n
}
// NewServer baut einen http.Server mit gehaerteten Timeouts.
func NewServer(addr string, h http.Handler, opt Options) *http.Server {
s := &http.Server{
Addr: addr,
Handler: h,
ReadHeaderTimeout: envSeconds(EnvReadHeaderTimeout, DefaultReadHeaderTimeout),
ReadTimeout: envSeconds(EnvReadTimeout, DefaultReadTimeout),
WriteTimeout: envSeconds(EnvWriteTimeout, DefaultWriteTimeout),
IdleTimeout: envSeconds(EnvIdleTimeout, DefaultIdleTimeout),
MaxHeaderBytes: DefaultMaxHeaderBytes,
}
if opt.StreamingBodies {
s.ReadTimeout = 0
}
if opt.StreamingResponses {
s.WriteTimeout = 0
}
return s
}
// ListenAndServe ersetzt http.ListenAndServe: JSON-API mit Body-Limit und
// (QA-15 F-14) apierror.Normalize aussen um jeden Handler — jeder der ueber
// diesen einen Einstiegspunkt laufenden Core-Dienste bekommt damit
// automatisch die Korrelations-ID-Vernebelung von 5xx-Antworten, ohne dass
// jede einzelne Aufrufstelle angefasst werden muss.
func ListenAndServe(addr string, h http.Handler) error {
return NewServer(addr, apierror.Normalize(LimitBody(MaxBodyBytes(), h)), Options{}).ListenAndServe()
}
// ListenAndServeStreaming fuer Dienste mit grossen Uploads/Downloads: keine
// globale Body-Grenze und keine Lese-/Schreib-Gesamtzeit, nur Header-Timeout.
// apierror.Normalize puffert NUR Fehlerantworten (Status >= 400) — der
// Streaming-Erfolgspfad (grosse Downloads) bleibt unangetastet.
func ListenAndServeStreaming(addr string, h http.Handler) error {
return NewServer(addr, apierror.Normalize(h), Options{StreamingBodies: true, StreamingResponses: true}).ListenAndServe()
}
// LimitBody begrenzt Request-Bodies. Ein Content-Length ueber dem Limit wird
// vor dem Handler mit 413 abgewiesen, chunked Bodies ueber MaxBytesReader.
func LimitBody(max int64, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.ContentLength > max {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusRequestEntityTooLarge)
_, _ = w.Write([]byte(`{"error":{"code":"payload_too_large","message":"Anfrage zu gross"}}`))
return
}
if r.Body != nil && r.Body != http.NoBody {
r.Body = http.MaxBytesReader(w, r.Body, max)
}
next.ServeHTTP(w, r)
})
}
+105
View File
@@ -0,0 +1,105 @@
package httpharden
import (
"bufio"
"io"
"net"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func TestLimitBody_ContentLengthOverLimitIs413(t *testing.T) {
called := false
h := LimitBody(10, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { called = true }))
req := httptest.NewRequest("POST", "/", strings.NewReader(strings.Repeat("a", 11)))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusRequestEntityTooLarge || called {
t.Fatalf("code=%d called=%v", rec.Code, called)
}
}
func TestLimitBody_ChunkedOverLimitFailsRead(t *testing.T) {
var readErr error
h := LimitBody(10, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, readErr = io.ReadAll(r.Body)
}))
req := httptest.NewRequest("POST", "/", strings.NewReader(strings.Repeat("a", 50)))
req.ContentLength = -1
h.ServeHTTP(httptest.NewRecorder(), req)
if readErr == nil {
t.Fatal("lesen ueber dem Limit muss fehlschlagen")
}
}
func TestLimitBody_UnderLimitPasses(t *testing.T) {
var got string
h := LimitBody(10, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
b, _ := io.ReadAll(r.Body)
got = string(b)
}))
h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("POST", "/", strings.NewReader("abc")))
if got != "abc" {
t.Fatalf("got %q", got)
}
}
func TestNewServer_DefaultsAndStreaming(t *testing.T) {
s := NewServer(":0", nil, Options{})
if s.ReadHeaderTimeout == 0 || s.ReadTimeout == 0 || s.WriteTimeout == 0 || s.IdleTimeout == 0 {
t.Fatalf("Timeouts muessen gesetzt sein: %+v", s)
}
st := NewServer(":0", nil, Options{StreamingBodies: true, StreamingResponses: true})
if st.ReadTimeout != 0 || st.WriteTimeout != 0 || st.ReadHeaderTimeout == 0 || st.IdleTimeout == 0 {
t.Fatalf("Streaming: nur Lese-/Schreib-Gesamtzeit 0: %+v", st)
}
}
func TestNewServer_EnvOverride(t *testing.T) {
t.Setenv(EnvReadHeaderTimeout, "3")
if got := NewServer(":0", nil, Options{}).ReadHeaderTimeout; got != 3*time.Second {
t.Fatalf("got %v", got)
}
}
func TestMaxBodyBytes_EnvOverride(t *testing.T) {
if MaxBodyBytes() != DefaultMaxBodyBytes {
t.Fatal("Standard")
}
t.Setenv(EnvMaxBodyBytes, "2048")
if MaxBodyBytes() != 2048 {
t.Fatal("Env")
}
}
// Slowloris: Verbindung sendet unvollstaendige Header, der Server trennt nach ReadHeaderTimeout.
func TestSlowHeadersAreClosed(t *testing.T) {
t.Setenv(EnvReadHeaderTimeout, "1")
srv := NewServer("127.0.0.1:0", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}), Options{})
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
go srv.Serve(ln)
defer srv.Close()
conn, err := net.Dial("tcp", ln.Addr().String())
if err != nil {
t.Fatal(err)
}
defer conn.Close()
if _, err := conn.Write([]byte("GET / HTTP/1.1\r\nHost: x\r\nX-Slow: a")); err != nil {
t.Fatal(err)
}
conn.SetReadDeadline(time.Now().Add(5 * time.Second))
start := time.Now()
_, err = bufio.NewReader(conn).ReadByte()
if err == nil {
t.Log("Server antwortete (z. B. 408)")
}
if time.Since(start) > 4*time.Second {
t.Fatalf("Verbindung wurde nicht nach dem Header-Timeout beendet (%v)", time.Since(start))
}
}
+141
View File
@@ -0,0 +1,141 @@
// Package loadtest implementiert die für QA-07 gemeinsam genutzte
// Last- und Leistungsmessung: parallele Sessions ausführen, Latenzen
// aufzeichnen, Durchsatz und Ressourcenverbrauch bestimmen. k6/vegeta
// sind auf diesem Rechner nicht installierbar (Projektregel: keine
// zusätzlichen Toolchains/Dienste installieren) — echter Ersatz ohne
// externe Abhängigkeit: reale nebenläufige TCP-Sessions über die
// Go-Standardbibliothek, gegen die echten, in dieser Sitzung gebauten
// Protokollserver.
package loadtest
import (
"context"
"os"
"runtime"
"sort"
"strconv"
"strings"
"sync"
"time"
)
// Result fasst Latenz- und Durchsatzwerte eines Lasttest-Laufs
// zusammen (Akzeptanzkriterium 2).
type Result struct {
Protocol string
Concurrency int
Sessions int
Errors int
Duration time.Duration
Latencies []time.Duration // aufsteigend sortiert
}
// Throughput liefert Sessions pro Sekunde über die Gesamtlaufzeit.
func (r Result) Throughput() float64 {
if r.Duration <= 0 {
return 0
}
return float64(r.Sessions) / r.Duration.Seconds()
}
// Percentile liefert die Latenz am p-ten Perzentil (0.0–1.0).
func (r Result) Percentile(p float64) time.Duration {
if len(r.Latencies) == 0 {
return 0
}
idx := int(p * float64(len(r.Latencies)-1))
if idx < 0 {
idx = 0
}
if idx >= len(r.Latencies) {
idx = len(r.Latencies) - 1
}
return r.Latencies[idx]
}
// Max liefert die höchste gemessene Latenz.
func (r Result) Max() time.Duration {
if len(r.Latencies) == 0 {
return 0
}
return r.Latencies[len(r.Latencies)-1]
}
// Run führt totalSessions Sessions über maximal concurrency parallele
// Worker aus (Akzeptanzkriterium 1: realistische Anzahl paralleler
// Sessions) und misst je Session die Latenz von sessionFunc.
func Run(ctx context.Context, protocol string, concurrency, totalSessions int, sessionFunc func(ctx context.Context) error) Result {
var mu sync.Mutex
latencies := make([]time.Duration, 0, totalSessions)
errCount := 0
sem := make(chan struct{}, concurrency)
var wg sync.WaitGroup
start := time.Now()
for i := 0; i < totalSessions; i++ {
sem <- struct{}{}
wg.Add(1)
go func() {
defer wg.Done()
defer func() { <-sem }()
t0 := time.Now()
err := sessionFunc(ctx)
elapsed := time.Since(t0)
mu.Lock()
latencies = append(latencies, elapsed)
if err != nil {
errCount++
}
mu.Unlock()
}()
}
wg.Wait()
duration := time.Since(start)
sort.Slice(latencies, func(i, j int) bool { return latencies[i] < latencies[j] })
return Result{
Protocol: protocol,
Concurrency: concurrency,
Sessions: totalSessions,
Errors: errCount,
Duration: duration,
Latencies: latencies,
}
}
// ResourceSample ist eine Momentaufnahme des Ressourcenverbrauchs
// DIESES Prozesses (Akzeptanzkriterium/Pflichtprüfung 3): Heap über die
// Go-Runtime, kumulierte CPU-Zeit über /proc/self/stat (Linux, kein
// externes Werkzeug nötig).
type ResourceSample struct {
HeapAllocBytes uint64
CPUTimeSeconds float64
}
func SampleResources() ResourceSample {
var m runtime.MemStats
runtime.ReadMemStats(&m)
return ResourceSample{HeapAllocBytes: m.HeapAlloc, CPUTimeSeconds: readSelfCPUTimeSeconds()}
}
// readSelfCPUTimeSeconds liefert die kumulierte User+System-CPU-Zeit
// dieses Prozesses in Sekunden. Liefert 0, wenn /proc/self/stat nicht
// lesbar ist (z. B. auf Nicht-Linux-Systemen) — Aufrufer behandeln das
// als "keine CPU-Messung verfügbar", kein harter Fehler.
func readSelfCPUTimeSeconds() float64 {
data, err := os.ReadFile("/proc/self/stat")
if err != nil {
return 0
}
fields := strings.Fields(string(data))
if len(fields) < 15 {
return 0
}
utime, err1 := strconv.ParseFloat(fields[13], 64)
stime, err2 := strconv.ParseFloat(fields[14], 64)
if err1 != nil || err2 != nil {
return 0
}
const clockTicksPerSecond = 100 // Linux-Standard-USER_HZ
return (utime + stime) / clockTicksPerSecond
}
+247
View File
@@ -0,0 +1,247 @@
// Package migrate implements versioned, revertible SQL migrations (no ORM).
// Jedes Modul benennt seine Fortschrittstabelle selbst (New), damit sich
// Module in derselben Datenbank nicht gegenseitig den Fortschritt
// ueberschreiben (FDN-13).
package migrate
import (
"context"
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"github.com/jackc/pgx/v5/pgxpool"
)
// Migration ist eine einzelne versionierte Migrationsdatei.
type Migration struct {
Version string // Dateiname ohne .up.sql/.down.sql, z.B. "0001_documents"
UpSQL string
DownSQL string
}
// Load liest alle *.up.sql/*.down.sql-Paare aus dir, sortiert nach
// Dateiname (Akzeptanzkriterium: Migrationen sind versioniert).
func Load(dir string) ([]Migration, error) {
entries, err := os.ReadDir(dir)
if err != nil {
return nil, fmt.Errorf("migrationsverzeichnis %q lesen: %w", dir, err)
}
var versions []string
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".up.sql") {
continue
}
versions = append(versions, strings.TrimSuffix(e.Name(), ".up.sql"))
}
sort.Strings(versions)
migrations := make([]Migration, 0, len(versions))
for _, v := range versions {
up, err := os.ReadFile(filepath.Join(dir, v+".up.sql"))
if err != nil {
return nil, fmt.Errorf("migration %q: up.sql lesen: %w", v, err)
}
down, err := os.ReadFile(filepath.Join(dir, v+".down.sql"))
if err != nil {
return nil, fmt.Errorf("migration %q: down.sql lesen (jede Migration braucht ein Rollback): %w", v, err)
}
migrations = append(migrations, Migration{Version: v, UpSQL: string(up), DownSQL: string(down)})
}
return migrations, nil
}
var tableNamePattern = regexp.MustCompile(`^[a-z_][a-z0-9_]{0,62}$`)
// Migrator fuehrt Migrationen gegen eine modulspezifisch benannte Tabelle aus.
type Migrator struct {
table string
}
// New liefert einen Migrator fuer die Fortschrittstabelle table (z. B.
// "dms_schema_migrations"). Der Name geht unquotiert in SQL und muss daher
// ^[a-z_][a-z0-9_]{0,62}$ entsprechen.
func New(table string) (*Migrator, error) {
if !tableNamePattern.MatchString(table) {
return nil, fmt.Errorf("migrate: ungueltiger tabellenname %q", table)
}
return &Migrator{table: table}, nil
}
// MustNew wie New, panikt bei ungueltigem Namen (fuer Paketvariablen).
func MustNew(table string) *Migrator {
mg, err := New(table)
if err != nil {
panic(err)
}
return mg
}
// Table liefert den Namen der Fortschrittstabelle.
func (mg *Migrator) Table() string { return mg.table }
const legacyTrackingTable = "schema_migrations"
// ensureTrackingTable legt mg.table an. Beim erstmaligen Anlegen wird
// ein vorhandener Bestand aus der frueheren, unqualifizierten
// schema_migrations uebernommen (nur Versionen, die dieses Modul kennt, und
// nur wenn dessen Basisversion dort steht — eine schema_migrations eines
// anderen Moduls oder eines anderen Schemas wird weder gelesen noch
// veraendert). Die alte Tabelle
// bleibt unangetastet.
func (mg *Migrator) ensureTrackingTable(ctx context.Context, pool *pgxpool.Pool, migrations []Migration) error {
tx, err := pool.Begin(ctx)
if err != nil {
return fmt.Errorf("%s anlegen: %w", mg.table, err)
}
defer func() { _ = tx.Rollback(ctx) }()
// Serialisiert gleichzeitige Erstanlage/Uebernahme (mehrere App-Instanzen).
if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtext($1))`, mg.table); err != nil {
return fmt.Errorf("%s anlegen: %w", mg.table, err)
}
var existed bool
if err := tx.QueryRow(ctx, `SELECT to_regclass(current_schema() || '.' || $1::text) IS NOT NULL`, mg.table).Scan(&existed); err != nil {
return fmt.Errorf("%s anlegen: %w", mg.table, err)
}
if existed {
return tx.Commit(ctx)
}
if _, err := tx.Exec(ctx, `
CREATE TABLE `+mg.table+` (
version TEXT PRIMARY KEY,
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
`); err != nil {
return fmt.Errorf("%s anlegen: %w", mg.table, err)
}
if len(migrations) > 0 {
var legacy bool
if err := tx.QueryRow(ctx, `SELECT to_regclass(current_schema() || '.' || $1::text) IS NOT NULL`, legacyTrackingTable).Scan(&legacy); err != nil {
return fmt.Errorf("%s pruefen: %w", legacyTrackingTable, err)
}
if legacy {
versions := make([]string, len(migrations))
for i, m := range migrations {
versions[i] = m.Version
}
var hasBase bool
if err := tx.QueryRow(ctx,
`SELECT EXISTS (SELECT 1 FROM `+legacyTrackingTable+` WHERE version = $1)`, versions[0],
).Scan(&hasBase); err != nil {
return fmt.Errorf("%s lesen: %w", legacyTrackingTable, err)
}
if hasBase {
if _, err := tx.Exec(ctx, `
INSERT INTO `+mg.table+` (version, applied_at)
SELECT version, applied_at FROM `+legacyTrackingTable+` WHERE version = ANY($1)
`, versions); err != nil {
return fmt.Errorf("bestand aus %s uebernehmen: %w", legacyTrackingTable, err)
}
}
}
}
return tx.Commit(ctx)
}
func (mg *Migrator) appliedVersions(ctx context.Context, pool *pgxpool.Pool) (map[string]bool, error) {
rows, err := pool.Query(ctx, `SELECT version FROM `+mg.table)
if err != nil {
return nil, fmt.Errorf("angewendete migrationen lesen: %w", err)
}
defer rows.Close()
applied := map[string]bool{}
for rows.Next() {
var v string
if err := rows.Scan(&v); err != nil {
return nil, fmt.Errorf("migrationsversion lesen: %w", err)
}
applied[v] = true
}
return applied, rows.Err()
}
// Up wendet alle noch nicht angewendeten Migrationen in Reihenfolge an
// (Akzeptanzkriterium 2: vorwaerts ausfuehrbar) — bereits angewendete
// werden uebersprungen, damit Up auf einer leeren UND auf einer bestehenden
// DB funktioniert (Pruefung 1).
func (mg *Migrator) Up(ctx context.Context, pool *pgxpool.Pool, migrations []Migration) (applied []string, err error) {
if err := mg.ensureTrackingTable(ctx, pool, migrations); err != nil {
return nil, err
}
already, err := mg.appliedVersions(ctx, pool)
if err != nil {
return nil, err
}
for _, m := range migrations {
if already[m.Version] {
continue
}
tx, err := pool.Begin(ctx)
if err != nil {
return applied, fmt.Errorf("transaktion fuer %q starten: %w", m.Version, err)
}
if _, err := tx.Exec(ctx, m.UpSQL); err != nil {
_ = tx.Rollback(ctx)
return applied, fmt.Errorf("migration %q anwenden: %w", m.Version, err)
}
if _, err := tx.Exec(ctx, `INSERT INTO `+mg.table+` (version) VALUES ($1)`, m.Version); err != nil {
_ = tx.Rollback(ctx)
return applied, fmt.Errorf("migration %q als angewendet markieren: %w", m.Version, err)
}
if err := tx.Commit(ctx); err != nil {
return applied, fmt.Errorf("migration %q committen: %w", m.Version, err)
}
applied = append(applied, m.Version)
}
return applied, nil
}
// DownOne macht die zuletzt angewendete Migration rueckgaengig
// (Akzeptanzkriterium 2: rueckwaerts ausfuehrbar) und liefert deren Version,
// oder "" falls keine Migration angewendet war.
func (mg *Migrator) DownOne(ctx context.Context, pool *pgxpool.Pool, migrations []Migration) (version string, err error) {
if err := mg.ensureTrackingTable(ctx, pool, migrations); err != nil {
return "", err
}
already, err := mg.appliedVersions(ctx, pool)
if err != nil {
return "", err
}
var last *Migration
for i := len(migrations) - 1; i >= 0; i-- {
if already[migrations[i].Version] {
last = &migrations[i]
break
}
}
if last == nil {
return "", nil
}
tx, err := pool.Begin(ctx)
if err != nil {
return "", fmt.Errorf("transaktion fuer rollback von %q starten: %w", last.Version, err)
}
if _, err := tx.Exec(ctx, last.DownSQL); err != nil {
_ = tx.Rollback(ctx)
return "", fmt.Errorf("migration %q zurueckrollen: %w", last.Version, err)
}
if _, err := tx.Exec(ctx, `DELETE FROM `+mg.table+` WHERE version = $1`, last.Version); err != nil {
_ = tx.Rollback(ctx)
return "", fmt.Errorf("migration %q aus %s entfernen: %w", last.Version, mg.table, err)
}
if err := tx.Commit(ctx); err != nil {
return "", fmt.Errorf("rollback von %q committen: %w", last.Version, err)
}
return last.Version, nil
}
+98
View File
@@ -0,0 +1,98 @@
package migrate
import (
"context"
"os"
"path/filepath"
"testing"
"gitea.perlbach24.de/scripte/nexarch/common/testdb"
)
func TestNewValidatesTableName(t *testing.T) {
for _, ok := range []string{"dms_schema_migrations", "_x", "a1"} {
if _, err := New(ok); err != nil {
t.Errorf("New(%q): %v", ok, err)
}
}
for _, bad := range []string{"", "1abc", "Dms", "a-b", "a;drop table x", "a b", "schema.table"} {
if _, err := New(bad); err == nil {
t.Errorf("New(%q) sollte scheitern", bad)
}
}
}
func writeMigrations(t *testing.T, files map[string]string) []Migration {
t.Helper()
dir := t.TempDir()
for name, body := range files {
if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o600); err != nil {
t.Fatal(err)
}
}
migs, err := Load(dir)
if err != nil {
t.Fatal(err)
}
return migs
}
func TestUpDownAndSeparateTrackingTables(t *testing.T) {
ctx := context.Background()
pool := testdb.Pool(t, testdb.AdminDSN(t))
migs := writeMigrations(t, map[string]string{
"0001_a.up.sql": "CREATE TABLE mig_a (id int)",
"0001_a.down.sql": "DROP TABLE mig_a",
"0002_b.up.sql": "CREATE TABLE mig_b (id int)",
"0002_b.down.sql": "DROP TABLE mig_b",
})
one := MustNew("one_schema_migrations")
two := MustNew("two_schema_migrations")
applied, err := one.Up(ctx, pool, migs)
if err != nil || len(applied) != 2 {
t.Fatalf("Up: applied=%v err=%v", applied, err)
}
again, err := one.Up(ctx, pool, migs)
if err != nil || len(again) != 0 {
t.Fatalf("zweites Up soll nichts anwenden: applied=%v err=%v", again, err)
}
var n int
if err := pool.QueryRow(ctx, `SELECT count(*) FROM two_schema_migrations`).Scan(&n); err == nil {
t.Fatal("two_schema_migrations darf vor dem ersten Lauf von two nicht existieren")
}
if _, err := two.DownOne(ctx, pool, migs); err != nil {
t.Fatalf("two.DownOne: %v", err)
}
if err := pool.QueryRow(ctx, `SELECT count(*) FROM one_schema_migrations`).Scan(&n); err != nil || n != 2 {
t.Fatalf("Fortschritt von one muss unberuehrt sein: n=%d err=%v", n, err)
}
v, err := one.DownOne(ctx, pool, migs)
if err != nil || v != "0002_b" {
t.Fatalf("DownOne: v=%q err=%v", v, err)
}
}
func TestLegacyTrackingTableIsAdopted(t *testing.T) {
ctx := context.Background()
pool := testdb.Pool(t, testdb.AdminDSN(t))
migs := writeMigrations(t, map[string]string{
"0001_a.up.sql": "CREATE TABLE mig_leg (id int)",
"0001_a.down.sql": "DROP TABLE mig_leg",
})
for _, q := range []string{
`CREATE TABLE schema_migrations (version TEXT PRIMARY KEY, applied_at TIMESTAMPTZ NOT NULL DEFAULT now())`,
`INSERT INTO schema_migrations (version) VALUES ('0001_a')`,
`CREATE TABLE mig_leg (id int)`,
} {
if _, err := pool.Exec(ctx, q); err != nil {
t.Fatal(err)
}
}
applied, err := MustNew("leg_schema_migrations").Up(ctx, pool, migs)
if err != nil || len(applied) != 0 {
t.Fatalf("uebernommene Version darf nicht erneut laufen: applied=%v err=%v", applied, err)
}
}
+76
View File
@@ -0,0 +1,76 @@
package moduleauth
import (
"context"
"encoding/json"
"errors"
"net/http"
"strings"
)
// Identity ist die aus einem gueltigen Modul-Token gewonnene Identitaet.
// Der Mandant kommt ausschliesslich hieraus, nie aus einem Anfrageparameter
// (INT-18, Befund F-20: Mail-API las den Mandanten bisher aus "?tenant=").
type Identity struct {
UserID string
Tenant string
Role string
}
type identityKey struct{}
func WithIdentity(ctx context.Context, id Identity) context.Context {
return context.WithValue(ctx, identityKey{}, id)
}
func IdentityFrom(ctx context.Context) (Identity, bool) {
id, ok := ctx.Value(identityKey{}).(Identity)
return id, ok
}
type errorResponse struct {
Error string `json:"error"`
}
func writeError(w http.ResponseWriter, status int, message string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(errorResponse{Error: message})
}
// RequireAuth verlangt ein gueltiges Modul-Token (Bearer) fuer next. Es gibt
// hier bewusst keinen "caller"-Uebergangsmodus wie im DMS: die Mail-API war
// bislang nirgends verdrahtet (kein cmd/-Aufrufer), es gibt also keinen
// bestehenden Client, dessen Kompatibilitaet zu wahren waere.
func RequireAuth(v *Verifier, next http.Handler) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
token, ok := bearer(r)
if !ok {
writeError(w, http.StatusUnauthorized, "nicht angemeldet")
return
}
if v == nil {
writeError(w, http.StatusUnauthorized, "nicht angemeldet")
return
}
claims, err := v.Verify(r.Context(), token)
if err != nil {
if errors.Is(err, ErrInvalidToken) {
writeError(w, http.StatusUnauthorized, "nicht angemeldet")
} else {
writeError(w, http.StatusServiceUnavailable, "identitaetsdienst nicht erreichbar")
}
return
}
id := Identity{UserID: claims.Subject, Tenant: claims.Tenant, Role: claims.Role}
next.ServeHTTP(w, r.WithContext(WithIdentity(r.Context(), id)))
}
}
func bearer(r *http.Request) (string, bool) {
h := r.Header.Get("Authorization")
if len(h) > 7 && strings.EqualFold(h[:7], "bearer ") {
return strings.TrimSpace(h[7:]), true
}
return "", false
}
+66
View File
@@ -0,0 +1,66 @@
package moduleauth
import (
"context"
"crypto/ed25519"
"crypto/rand"
"encoding/base64"
"encoding/json"
"time"
)
// TestIssuer signiert Modul-Tokens mit einem einmalig erzeugten Testschluessel
// (nur fuer Tests anderer Pakete gedacht, siehe INT-18: mailapi/mailrulesapi
// koennen ErrInvalidToken/echte Signaturpruefung nicht ohne einen Aussteller
// testen, ein echter Kontodienst ist in Unit-Tests nicht verfuegbar).
type TestIssuer struct {
kid string
pub ed25519.PublicKey
prv ed25519.PrivateKey
}
// NewTestVerifierAndIssuer erzeugt ein Ed25519-Testschluesselpaar, einen
// Verifier, der ausschliesslich diesen Schluessel kennt, und den passenden
// Aussteller. audience/issuer leer = keine Bindung geprueft (Verhalten wie
// im Uebergang vor API-18/API-15 verdrahtet sind).
func NewTestVerifierAndIssuer() (*Verifier, *TestIssuer) {
pub, prv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
panic(err)
}
iss := &TestIssuer{kid: "test-key", pub: pub, prv: prv}
v := NewVerifier(time.Minute, "", func(context.Context) (map[string]ed25519.PublicKey, error) {
return map[string]ed25519.PublicKey{iss.kid: iss.pub}, nil
})
return v, iss
}
func b64json(v any) string {
raw, _ := json.Marshal(v)
return base64.RawURLEncoding.EncodeToString(raw)
}
// Token stellt ein gueltiges Modul-Token fuer subject/tenant/role aus,
// optional mit audience/issuer (leer = Claim wird weggelassen).
func (t *TestIssuer) Token(subject, tenant, role, audience, issuer string) string {
return t.TokenExpiringAt(subject, tenant, role, audience, issuer, time.Now().Add(time.Hour))
}
// TokenExpiringAt wie Token, aber mit fest vorgegebenem Ablaufzeitpunkt
// (fuer Tests, die ein bereits abgelaufenes Token brauchen).
func (t *TestIssuer) TokenExpiringAt(subject, tenant, role, audience, issuer string, exp time.Time) string {
now := time.Now()
claims := map[string]any{
"sub": subject, "tenant": tenant, "role": role,
"iat": now.Unix(), "exp": exp.Unix(),
}
if audience != "" {
claims["aud"] = audience
}
if issuer != "" {
claims["iss"] = issuer
}
h := b64json(map[string]string{"alg": "EdDSA", "kid": t.kid, "typ": "JWT"})
signing := h + "." + b64json(claims)
return signing + "." + base64.RawURLEncoding.EncodeToString(ed25519.Sign(t.prv, []byte(signing)))
}
+258
View File
@@ -0,0 +1,258 @@
// Package moduleauth verifiziert Core-Modul-JWTs (API-05: EdDSA, kid, JWKS) lokal.
package moduleauth
import (
"context"
"crypto/ed25519"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"sync"
"time"
)
var ErrInvalidToken = errors.New("moduleauth: ungueltiges token")
const (
leeway = 30 * time.Second
minRefreshInterval = 30 * time.Second
maxJWKSBytes = 1 << 20
)
type Claims struct {
Subject string
Tenant string
Role string
}
type KeyFetchFunc func(ctx context.Context) (map[string]ed25519.PublicKey, error)
type Verifier struct {
fetch KeyFetchFunc
ttl time.Duration
tenant string
now func() time.Time
audience string
issuer string
enforceAudience bool
mu sync.Mutex
keys map[string]ed25519.PublicKey
fetchedAt time.Time
lastAttempt time.Time
}
func NewVerifier(ttl time.Duration, tenant string, fetch KeyFetchFunc) *Verifier {
return &Verifier{fetch: fetch, ttl: ttl, tenant: tenant, now: time.Now}
}
// Expect bindet das Modul an ein Zielmodul ("aud") und einen Aussteller ("iss")
// (QA-15 F-07). Ist enforce gesetzt, weist der Verifier Tokens ohne passende
// "aud" ab; ohne enforce (Uebergang beim Ausrollen) werden Tokens ohne "aud"
// noch angenommen, ein Token mit fremder "aud" wird aber immer abgewiesen.
// "iss" wird geprueft, sobald ein Aussteller konfiguriert ist und das Token
// einen traegt bzw. enforce gilt.
func (v *Verifier) Expect(audience, issuer string, enforce bool) *Verifier {
v.audience, v.issuer, v.enforceAudience = audience, issuer, enforce
return v
}
// keySet liefert die Schluessel: frisch aus dem Cache, sonst per Abruf; schlaegt
// der Abruf fehl, gilt der letzte bekannte Satz weiter (Core-Ausfall), ohne
// Satz ist die Verifikation nicht moeglich. force erzwingt einen Abruf (unbekannte
// kid nach Rotation), hoechstens einmal je minRefreshInterval.
func (v *Verifier) keySet(ctx context.Context, force bool) (map[string]ed25519.PublicKey, error) {
v.mu.Lock()
defer v.mu.Unlock()
now := v.now()
fresh := v.keys != nil && now.Sub(v.fetchedAt) < v.ttl
if fresh && !force {
return v.keys, nil
}
if force && now.Sub(v.lastAttempt) < minRefreshInterval {
if v.keys != nil {
return v.keys, nil
}
}
v.lastAttempt = now
keys, err := v.fetch(ctx)
if err == nil {
v.keys, v.fetchedAt = keys, now
return keys, nil
}
if v.keys != nil {
return v.keys, nil
}
return nil, fmt.Errorf("moduleauth: schluesselsatz nicht verfuegbar: %w", err)
}
type header struct {
Alg string `json:"alg"`
Kid string `json:"kid"`
}
type payload struct {
Sub string `json:"sub"`
Tenant string `json:"tenant"`
Role string `json:"role"`
Exp float64 `json:"exp"`
Iat float64 `json:"iat"`
Iss string `json:"iss"`
Aud json.RawMessage `json:"aud"`
}
// audiences liest "aud" als String oder Liste (RFC 7519 erlaubt beides).
func (p payload) audiences() ([]string, bool) {
if len(p.Aud) == 0 || string(p.Aud) == "null" {
return nil, true
}
var one string
if err := json.Unmarshal(p.Aud, &one); err == nil {
return []string{one}, true
}
var many []string
if err := json.Unmarshal(p.Aud, &many); err == nil {
return many, true
}
return nil, false
}
func (v *Verifier) checkScope(p payload) bool {
auds, ok := p.audiences()
if !ok {
return false
}
if v.audience != "" {
if len(auds) == 0 {
if v.enforceAudience {
return false
}
} else {
match := false
for _, a := range auds {
if a == v.audience {
match = true
break
}
}
if !match {
return false
}
}
}
if v.issuer != "" && (p.Iss != "" || v.enforceAudience) && p.Iss != v.issuer {
return false
}
return true
}
func (v *Verifier) Verify(ctx context.Context, token string) (Claims, error) {
parts := strings.Split(token, ".")
if len(parts) != 3 {
return Claims{}, ErrInvalidToken
}
var h header
if err := decodePart(parts[0], &h); err != nil || h.Alg != "EdDSA" || h.Kid == "" {
return Claims{}, ErrInvalidToken
}
sig, err := base64.RawURLEncoding.DecodeString(parts[2])
if err != nil || len(sig) != ed25519.SignatureSize {
return Claims{}, ErrInvalidToken
}
keys, err := v.keySet(ctx, false)
if err != nil {
return Claims{}, err
}
pub, ok := keys[h.Kid]
if !ok {
if keys, err = v.keySet(ctx, true); err != nil {
return Claims{}, err
}
if pub, ok = keys[h.Kid]; !ok {
return Claims{}, ErrInvalidToken
}
}
if !ed25519.Verify(pub, []byte(parts[0]+"."+parts[1]), sig) {
return Claims{}, ErrInvalidToken
}
var p payload
if err := decodePart(parts[1], &p); err != nil || p.Sub == "" || p.Exp == 0 {
return Claims{}, ErrInvalidToken
}
now := v.now()
if now.After(time.Unix(int64(p.Exp), 0).Add(leeway)) {
return Claims{}, ErrInvalidToken
}
if p.Iat != 0 && time.Unix(int64(p.Iat), 0).After(now.Add(leeway)) {
return Claims{}, ErrInvalidToken
}
if v.tenant != "" && p.Tenant != v.tenant {
return Claims{}, ErrInvalidToken
}
if !v.checkScope(p) {
return Claims{}, ErrInvalidToken
}
return Claims{Subject: p.Sub, Tenant: p.Tenant, Role: p.Role}, nil
}
func decodePart(part string, into any) error {
raw, err := base64.RawURLEncoding.DecodeString(part)
if err != nil {
return err
}
return json.Unmarshal(raw, into)
}
type jwksResponse struct {
Keys []struct {
Kid string `json:"kid"`
PublicKey string `json:"public_key"`
} `json:"keys"`
}
func HTTPKeyFetcher(url string, client *http.Client) KeyFetchFunc {
if client == nil {
client = &http.Client{Timeout: 5 * time.Second}
}
return func(ctx context.Context) (map[string]ed25519.PublicKey, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("jwks-abruf: status %d", resp.StatusCode)
}
raw, err := io.ReadAll(io.LimitReader(resp.Body, maxJWKSBytes))
if err != nil {
return nil, err
}
var r jwksResponse
if err := json.Unmarshal(raw, &r); err != nil {
return nil, fmt.Errorf("jwks parsen: %w", err)
}
out := make(map[string]ed25519.PublicKey, len(r.Keys))
for _, k := range r.Keys {
b, err := base64.StdEncoding.DecodeString(k.PublicKey)
if err != nil || len(b) != ed25519.PublicKeySize || k.Kid == "" {
return nil, fmt.Errorf("jwks: schluessel %q ungueltig", k.Kid)
}
out[k.Kid] = ed25519.PublicKey(b)
}
if len(out) == 0 {
return nil, errors.New("jwks: leerer schluesselsatz")
}
return out, nil
}
}
+62
View File
@@ -0,0 +1,62 @@
package moduleauth
import (
"context"
"testing"
"time"
)
// TestVerify_AcceptsValidToken belegt den Grundfall: ein vom Testaussteller
// signiertes, gueltiges Token liefert die erwarteten Claims.
func TestVerify_AcceptsValidToken(t *testing.T) {
v, iss := NewTestVerifierAndIssuer()
tok := iss.Token("u1", "acme", "user", "", "")
c, err := v.Verify(context.Background(), tok)
if err != nil || c.Subject != "u1" || c.Tenant != "acme" || c.Role != "user" {
t.Fatalf("claims=%+v err=%v", c, err)
}
}
// TestVerify_RejectsForeignSignature: ein Token eines anderen Schluessels
// (der Verifier kennt dessen kid nicht) wird abgewiesen.
func TestVerify_RejectsForeignSignature(t *testing.T) {
v, _ := NewTestVerifierAndIssuer()
_, other := NewTestVerifierAndIssuer()
if _, err := v.Verify(context.Background(), other.Token("u1", "acme", "user", "", "")); err == nil {
t.Fatal("erwartete fehler bei fremder signatur")
}
}
// TestScope_AudienceMismatchRejected ist der Kern von API-18/F-07: mit
// Expect(audience="mail", ..., enforce=true) gebundene Verifier weisen ein
// Token mit aud="dms" ab, akzeptieren aber aud="mail".
func TestScope_AudienceMismatchRejected(t *testing.T) {
v, iss := NewTestVerifierAndIssuer()
v.Expect("mail", "", true)
if _, err := v.Verify(context.Background(), iss.Token("u1", "acme", "user", "dms", "")); err == nil {
t.Fatal("erwartete fehler bei aud=dms gegen einen mail-gebundenen verifier")
}
if _, err := v.Verify(context.Background(), iss.Token("u1", "acme", "user", "mail", "")); err != nil {
t.Fatalf("erwartete erfolg bei aud=mail, habe: %v", err)
}
}
// TestScope_EnforceRejectsTokenWithoutAudience: mit enforce=true wird ein
// Token OHNE aud-Claim ebenfalls abgewiesen (kein stiller Rueckfall).
func TestScope_EnforceRejectsTokenWithoutAudience(t *testing.T) {
v, iss := NewTestVerifierAndIssuer()
v.Expect("mail", "", true)
if _, err := v.Verify(context.Background(), iss.Token("u1", "acme", "user", "", "")); err == nil {
t.Fatal("erwartete fehler bei fehlendem aud unter enforce=true")
}
}
// TestVerify_RejectsExpiredToken.
func TestVerify_RejectsExpiredToken(t *testing.T) {
v, iss := NewTestVerifierAndIssuer()
expired := iss.TokenExpiringAt("u1", "acme", "user", "", "", time.Now().Add(-2*time.Hour))
if _, err := v.Verify(context.Background(), expired); err == nil {
t.Fatal("erwartete fehler bei abgelaufenem token")
}
}
+53
View File
@@ -0,0 +1,53 @@
// Package pflichttestgate erzwingt: jede geänderte Go-Datei in einem sicherheitskritischen
// Bereich muss von einer geänderten oder neuen _test.go-Datei im selben Package begleitet sein.
// Die Bereiche (Pfadmuster) liefert der Aufrufer, die Logik ist für alle Module dieselbe.
package pflichttestgate
import (
"path"
"regexp"
"strings"
)
type Violation struct {
Package string
ChangedFile string
}
func IsSensitive(file string, patterns []*regexp.Regexp) bool {
if !strings.HasSuffix(file, ".go") || strings.HasSuffix(file, "_test.go") {
return false
}
for _, re := range patterns {
if re.MatchString(file) {
return true
}
}
return false
}
func CheckDiff(changedFiles []string, patterns []*regexp.Regexp) []Violation {
sensitiveByPkg := map[string]string{}
testTouchedPkgs := map[string]bool{}
for _, f := range changedFiles {
pkg := path.Dir(f)
if strings.HasSuffix(f, "_test.go") {
testTouchedPkgs[pkg] = true
continue
}
if IsSensitive(f, patterns) {
if _, seen := sensitiveByPkg[pkg]; !seen {
sensitiveByPkg[pkg] = f
}
}
}
var violations []Violation
for pkg, file := range sensitiveByPkg {
if !testTouchedPkgs[pkg] {
violations = append(violations, Violation{Package: pkg, ChangedFile: file})
}
}
return violations
}
+54
View File
@@ -0,0 +1,54 @@
package pflichttestgate
import (
"regexp"
"testing"
)
var testPatterns = []*regexp.Regexp{
regexp.MustCompile(`(^|/)common/svcauth/`),
regexp.MustCompile(`(^|/)common/tenantregistryclient/`),
}
func TestCheckDiff_BlocksSensitiveChangeWithoutTest(t *testing.T) {
changed := []string{"common/svcauth/svcauth.go", "common/loadtest/loadtest.go"}
violations := CheckDiff(changed, testPatterns)
if len(violations) != 1 {
t.Fatalf("erwartet 1 Verstoß, bekommen %d: %+v", len(violations), violations)
}
if violations[0].Package != "common/svcauth" || violations[0].ChangedFile != "common/svcauth/svcauth.go" {
t.Errorf("unerwarteter Verstoß: %+v", violations[0])
}
}
func TestCheckDiff_PassesWhenTestFileAccompaniesChange(t *testing.T) {
changed := []string{"common/svcauth/svcauth.go", "common/svcauth/svcauth_test.go"}
if v := CheckDiff(changed, testPatterns); len(v) != 0 {
t.Fatalf("erwartet keinen Verstoß, bekommen %+v", v)
}
}
func TestCheckDiff_TestInOtherPackageDoesNotCount(t *testing.T) {
changed := []string{"common/svcauth/svcauth.go", "common/tenantregistryclient/tenantregistryclient_test.go"}
if v := CheckDiff(changed, testPatterns); len(v) != 1 {
t.Fatalf("erwartet 1 Verstoß, bekommen %+v", v)
}
}
func TestCheckDiff_IgnoresNonGoAndUnrelated(t *testing.T) {
changed := []string{"common/svcauth/README.md", "common/httpclient/httpclient.go"}
if v := CheckDiff(changed, testPatterns); len(v) != 0 {
t.Fatalf("erwartet keinen Verstoß, bekommen %+v", v)
}
}
func TestCheckDiff_NoPatternsMeansNoSensitiveAreas(t *testing.T) {
if v := CheckDiff([]string{"common/svcauth/svcauth.go"}, nil); len(v) != 0 {
t.Fatalf("ohne Muster darf nichts als sensibel gelten, bekommen %+v", v)
}
}
+52
View File
@@ -0,0 +1,52 @@
// Package policyclient fragt Core (RBAC-06, POST /authorize) nach der
// Rechteentscheidung; Fehler und Ausfall fuehren immer zur Ablehnung.
package policyclient
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
)
type Client struct {
BaseURL string
ServiceToken string
HTTP *http.Client
}
func New(baseURL, serviceToken string) *Client {
return &Client{BaseURL: strings.TrimRight(baseURL, "/"), ServiceToken: serviceToken, HTTP: &http.Client{Timeout: 5 * time.Second}}
}
func (c *Client) Allowed(ctx context.Context, tenant, role, permission string) (bool, error) {
body := map[string]string{"role": role, "permission": permission}
if tenant != "" {
body["tenant"] = tenant
}
payload, _ := json.Marshal(body)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.BaseURL+"/authorize", bytes.NewReader(payload))
if err != nil {
return false, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Service-Token", c.ServiceToken)
resp, err := c.HTTP.Do(req)
if err != nil {
return false, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return false, fmt.Errorf("policy: status %d", resp.StatusCode)
}
var out struct {
Allowed *bool `json:"allowed"`
}
if err := json.NewDecoder(http.MaxBytesReader(nil, resp.Body, 1<<16)).Decode(&out); err != nil || out.Allowed == nil {
return false, fmt.Errorf("policy: antwort ohne entscheidung")
}
return *out.Allowed, nil
}
+80
View File
@@ -0,0 +1,80 @@
package policyclient
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestAllowed_TenantScopedRequest_TrueOnAllow(t *testing.T) {
var gotBody map[string]string
var gotToken string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotToken = r.Header.Get("X-Service-Token")
if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil {
t.Fatalf("body dekodieren: %v", err)
}
json.NewEncoder(w).Encode(map[string]bool{"allowed": true})
}))
defer srv.Close()
c := New(srv.URL, "svc-token-123")
allowed, err := c.Allowed(context.Background(), "acme", "editor", "connector.write")
if err != nil {
t.Fatalf("Allowed: %v", err)
}
if !allowed {
t.Fatal("erwartet allowed=true")
}
if gotToken != "svc-token-123" {
t.Fatalf("Service-Token nicht gesendet, erhalten: %q", gotToken)
}
if gotBody["tenant"] != "acme" {
t.Fatalf("Tenant fehlt/falsch in Anfrage: %q", gotBody["tenant"])
}
}
func TestAllowed_DeniedResponse_False(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(map[string]bool{"allowed": false})
}))
defer srv.Close()
c := New(srv.URL, "svc-token-123")
allowed, err := c.Allowed(context.Background(), "acme", "viewer", "connector.write")
if err != nil {
t.Fatalf("Allowed: %v", err)
}
if allowed {
t.Fatal("erwartet allowed=false")
}
}
func TestAllowed_ServiceFailure_DeniesInsteadOfSilentAllow(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer srv.Close()
c := New(srv.URL, "svc-token-123")
allowed, err := c.Allowed(context.Background(), "acme", "editor", "connector.write")
if err == nil {
t.Fatal("erwarteter Fehler bei Status 500 blieb aus")
}
if allowed {
t.Fatal("Ausfall darf nie zu allowed=true fuehren")
}
}
func TestAllowed_TransportFailure_DeniesInsteadOfSilentAllow(t *testing.T) {
c := New("http://127.0.0.1:1", "svc-token-123")
allowed, err := c.Allowed(context.Background(), "acme", "editor", "connector.write")
if err == nil {
t.Fatal("erwarteter Transport-Fehler blieb aus")
}
if allowed {
t.Fatal("Ausfall darf nie zu allowed=true fuehren")
}
}
+18
View File
@@ -0,0 +1,18 @@
// Package svcauth vergleicht Service-Token in konstanter Zeit (QA-15 F-23).
package svcauth
import (
"crypto/sha256"
"crypto/subtle"
)
// Equal vergleicht die SHA-256-Werte beider Eingaben in konstanter Zeit; anders
// als ein direkter ConstantTimeCompare verraet der Vergleich nicht die Laenge
// des erwarteten Tokens. Ein leeres erwartetes Token stimmt nie.
func Equal(got, want string) bool {
if want == "" {
return false
}
g, w := sha256.Sum256([]byte(got)), sha256.Sum256([]byte(want))
return subtle.ConstantTimeCompare(g[:], w[:]) == 1
}
+23
View File
@@ -0,0 +1,23 @@
package svcauth
import "testing"
func TestEqual(t *testing.T) {
cases := []struct {
got, want string
ok bool
}{
{"geheim", "geheim", true},
{"geheim ", "geheim", false},
{"", "geheim", false},
{"geheim", "", false},
{"", "", false},
{"g", "geheim", false},
{"geheimgeheim", "geheim", false},
}
for _, c := range cases {
if got := Equal(c.got, c.want); got != c.ok {
t.Errorf("Equal(%q,%q) = %v, erwartet %v", c.got, c.want, got, c.ok)
}
}
}
@@ -0,0 +1,47 @@
// Package tenantregistryclient ist ein schlanker HTTP-Client fuer Core
// TEN-01 (Tenant-Registry); AI ist ein eigenes Go-Modul und kann Cores
// Pakete nicht importieren. Gleiches Muster wie archive/internal/
// tenantregistryclient und dms/internal/tenantregistryclient (RET-12).
package tenantregistryclient
import (
"context"
"fmt"
"net/http"
"net/url"
"time"
)
type Client struct {
BaseURL string
ServiceToken string
HTTPClient *http.Client
}
func New(baseURL, serviceToken string) *Client {
return &Client{BaseURL: baseURL, ServiceToken: serviceToken, HTTPClient: &http.Client{Timeout: 10 * time.Second}}
}
// VerifyTenant fragt Core, ob tenantSlug als Tenant gefuehrt wird. Transport-
// und Serverfehler kommen als Fehler zurueck, nie als "gueltig": der Aufrufer
// muss fail-closed reagieren.
func (c *Client) VerifyTenant(ctx context.Context, tenantSlug string) (bool, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.BaseURL+"/tenants/"+url.PathEscape(tenantSlug), nil)
if err != nil {
return false, fmt.Errorf("tenantregistryclient: request bauen: %w", err)
}
req.Header.Set("X-Service-Token", c.ServiceToken)
resp, err := c.HTTPClient.Do(req)
if err != nil {
return false, fmt.Errorf("tenantregistryclient: aufruf fehlgeschlagen: %w", err)
}
defer func() { _ = resp.Body.Close() }()
switch resp.StatusCode {
case http.StatusOK:
return true, nil
case http.StatusNotFound:
return false, nil
default:
return false, fmt.Errorf("tenantregistryclient: unerwarteter status %d", resp.StatusCode)
}
}
@@ -0,0 +1,59 @@
package tenantregistryclient
import (
"context"
"net/http"
"net/http/httptest"
"testing"
)
func TestVerifyTenant_ExistingTenant(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/tenants/mandant-a" {
t.Errorf("pfad = %q, want /tenants/mandant-a", r.URL.Path)
}
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
c := New(server.URL, "test-token")
ok, err := c.VerifyTenant(context.Background(), "mandant-a")
if err != nil {
t.Fatalf("verifytenant: %v", err)
}
if !ok {
t.Fatal("erwartet gueltigen tenant, habe false")
}
}
func TestVerifyTenant_UnknownTenant(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
}))
defer server.Close()
c := New(server.URL, "test-token")
ok, err := c.VerifyTenant(context.Background(), "unbekannt")
if err != nil {
t.Fatalf("verifytenant: %v", err)
}
if ok {
t.Fatal("erwartet ungueltigen tenant, habe true")
}
}
func TestVerifyTenant_ServerErrorIsFailClosed(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer server.Close()
c := New(server.URL, "test-token")
ok, err := c.VerifyTenant(context.Background(), "mandant-a")
if err == nil {
t.Fatal("erwartet fehler bei 500-antwort der tenant-registry")
}
if ok {
t.Fatal("erwartet false bei fehler (fail-closed)")
}
}
+78
View File
@@ -0,0 +1,78 @@
// Package testdb liefert Integrationstests einen Pool auf ein eigenes,
// frisch angelegtes Schema (search_path = Schema, public). Tabellen, die ein
// Test per CREATE TABLE anlegt, entstehen damit nur in diesem Schema; Pakete,
// Laeufe und parallele Checkouts teilen sich keine Tabellen mehr.
package testdb
import (
"context"
"fmt"
"os"
"sync/atomic"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
var seq atomic.Int64
// AdminDSN liefert TEST_ADMIN_DSN. Fehlt er, wird uebersprungen; mit
// REQUIRE_DB=1 (CI) zaehlt das Fehlen als Fehler.
func AdminDSN(t testing.TB) string {
t.Helper()
dsn := os.Getenv("TEST_ADMIN_DSN")
if dsn != "" {
return dsn
}
if os.Getenv("REQUIRE_DB") == "1" {
t.Fatal("testdb: TEST_ADMIN_DSN nicht gesetzt, aber REQUIRE_DB=1")
}
t.Skip("testdb: TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
return ""
}
// Pool legt ein Schema an und liefert einen darauf ausgerichteten Pool;
// beides wird per t.Cleanup wieder entfernt.
func Pool(t testing.TB, dsn string) *pgxpool.Pool {
t.Helper()
pool, _ := PoolWithSchema(t, dsn, nil)
return pool
}
// PoolWithSchema wie Pool, zusaetzlich mit dem Schemanamen und der
// Moeglichkeit, die Verbindungskonfiguration anzupassen.
func PoolWithSchema(t testing.TB, dsn string, adjust func(*pgxpool.Config)) (*pgxpool.Pool, string) {
t.Helper()
ctx := context.Background()
admin, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatalf("testdb: verbindung: %v", err)
}
schema := fmt.Sprintf("connecttest_%d_%d", time.Now().UnixNano(), seq.Add(1))
if _, err := admin.Exec(ctx, "CREATE SCHEMA "+schema); err != nil {
admin.Close()
t.Fatalf("testdb: schema anlegen: %v", err)
}
cfg, err := pgxpool.ParseConfig(dsn)
if err != nil {
admin.Close()
t.Fatalf("testdb: dsn: %v", err)
}
cfg.ConnConfig.RuntimeParams["search_path"] = schema + ",public"
if adjust != nil {
adjust(cfg)
}
pool, err := pgxpool.NewWithConfig(ctx, cfg)
if err != nil {
admin.Close()
t.Fatalf("testdb: pool: %v", err)
}
t.Cleanup(func() {
pool.Close()
_, _ = admin.Exec(context.Background(), "DROP SCHEMA "+schema+" CASCADE")
admin.Close()
})
return pool, schema
}
+11
View File
@@ -0,0 +1,11 @@
package testdb
import "testing"
func TestAdminDSNGesetzt(t *testing.T) {
t.Setenv("TEST_ADMIN_DSN", "postgres://x")
t.Setenv("REQUIRE_DB", "1")
if got := AdminDSN(t); got != "postgres://x" {
t.Fatalf("dsn = %q", got)
}
}
+7 -34
View File
@@ -3,10 +3,7 @@ package apierror
import (
"bytes"
"crypto/rand"
"encoding/hex"
"encoding/json"
"log"
"net/http"
"strings"
)
@@ -48,9 +45,8 @@ func CodeForStatus(status int) string {
type body struct {
Error struct {
Code string `json:"code"`
Message string `json:"message"`
CorrelationID string `json:"correlation_id,omitempty"`
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
@@ -63,22 +59,13 @@ func Write(w http.ResponseWriter, status int, code, message string) {
_ = json.NewEncoder(w).Encode(b)
}
func newCorrelationID() string {
buf := make([]byte, 8)
if _, err := rand.Read(buf); err != nil {
return "korrelations-id-nicht-verfuegbar"
}
return hex.EncodeToString(buf)
}
// Normalize schreibt jede Fehlerantwort (Status >= 400) im Core-Format um;
// Erfolgsantworten laufen ungepuffert durch. 5xx-Texte werden nie an den
// Aufrufer gegeben (Interna-Leck, QA-15 F-14), nur mit Korrelations-ID geloggt.
// Erfolgsantworten laufen ungepuffert durch.
func Normalize(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
rec := &recorder{ResponseWriter: w}
next.ServeHTTP(rec, r)
rec.finish(r)
rec.finish()
})
}
@@ -119,27 +106,13 @@ func (r *recorder) Flush() {
func (r *recorder) Unwrap() http.ResponseWriter { return r.ResponseWriter }
func (r *recorder) finish(req *http.Request) {
func (r *recorder) finish() {
if !r.buffered {
return
}
code := CodeForStatus(r.status)
rawMessage := strings.TrimSpace(r.buf.String())
if r.status >= 500 {
corrID := newCorrelationID()
log.Printf("apierror: 5xx %s %s status=%d korrelations-id=%s ursprung=%q", req.Method, req.URL.Path, r.status, corrID, rawMessage)
out := body{}
out.Error.Code = code
out.Error.Message = "Interner Fehler"
out.Error.CorrelationID = corrID
encoded, _ := json.Marshal(out)
r.flush(append(encoded, '\n'))
return
}
out := map[string]any{}
message := rawMessage
message := strings.TrimSpace(r.buf.String())
code := CodeForStatus(r.status)
var parsed map[string]any
if json.Unmarshal(r.buf.Bytes(), &parsed) == nil && parsed != nil {
+1 -1
View File
@@ -43,7 +43,7 @@ func TestNormalizeRewritesLegacyErrorShapes(t *testing.T) {
_, _ = w.Write([]byte(`{"error":"ungueltiger wert"}`))
}, 422, "unprocessable", "ungueltiger wert"},
{"leerer body", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(403) }, 403, "forbidden", "Forbidden"},
{"5xx", func(w http.ResponseWriter, r *http.Request) { http.Error(w, "boom", 500) }, 500, "internal_error", "Interner Fehler"},
{"5xx", func(w http.ResponseWriter, r *http.Request) { http.Error(w, "boom", 500) }, 500, "internal_error", "boom"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
-39
View File
@@ -1,39 +0,0 @@
package apierror
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestNormalize5xxHidesInternalsAndSetsCorrelationID(t *testing.T) {
const secret = "pq: connection refused to db-internal:5432 /var/lib/dms/secret.key"
rec := httptest.NewRecorder()
Normalize(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, secret, http.StatusInternalServerError)
})).ServeHTTP(rec, httptest.NewRequest("GET", "/x", nil))
if strings.Contains(rec.Body.String(), "db-internal") || strings.Contains(rec.Body.String(), "secret.key") {
t.Fatalf("5xx-Interna im Body: %q", rec.Body.String())
}
e, _ := decode(t, rec)["error"].(map[string]any)
if id, _ := e["correlation_id"].(string); id == "" {
t.Errorf("correlation_id fehlt: %v", e)
}
}
func TestNormalize4xxKeepsMessageWithoutCorrelationID(t *testing.T) {
rec := httptest.NewRecorder()
Normalize(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "dokument nicht gefunden", http.StatusNotFound)
})).ServeHTTP(rec, httptest.NewRequest("GET", "/x", nil))
e, _ := decode(t, rec)["error"].(map[string]any)
if _, has := e["correlation_id"]; has {
t.Errorf("4xx darf keine correlation_id tragen: %v", e)
}
if msg, _ := e["message"].(string); msg != "dokument nicht gefunden" {
t.Errorf("message = %q", msg)
}
}
+68
View File
@@ -0,0 +1,68 @@
# Arbeitskarten (Stand 2026-10-27)
Reihenfolge = Abarbeitungsfolge. Status: offen / blockiert / Entscheidung nötig / erledigt.
Regel je Karte: eigener Branch, eigener PR, Tests auf 131 bzw. 204 (nie archivdms), PR-Beschreibung nennt Auswirkungen.
## A. common/-Modul
### A-01 Tag common/v0.1.0 — blockiert
- Ziel: Tag-Workflow `common-tag.yml` erzeugt `common/v0.1.0`.
- Blockiert durch: Runner `nexarch-131-runner` arbeitet CI-Warteschlange ab (Unit `act_runner.service` auf 131).
- Schritte: Warteschlange beobachten; bei Lauf-Fehler Log lesen und Workflow korrigieren; Tag per `git ls-remote --tags origin 'common/*'` prüfen.
- Fertig, wenn: Tag existiert und `go list -m gitea.perlbach24.de/scripte/nexarch/common@v0.1.0` auf 131 mit GOPRIVATE und `.netrc` funktioniert.
- Hinweis: Lesezugriff-Token auf 131 legt der User an; Claude fasst keine Tokens an.
### A-02 Umstellung archive (Pilot) — wartet auf A-01
- Vorlage: Branches `refactor/common-modul-pilot`, `refactor/common-batch-2`.
- Schritte: `require .../common v0.1.0` und go.sum; Importe von httpharden, svcauth, auditclient, tenantregistryclient (soweit in archive genutzt) umschreiben; interne Kopien löschen; gofmt wegen Import-Reihenfolge; Pflichttest-Gate von archive um common-Muster erweitern; Modul-Guards wiederherstellen.
- Test: `go build/vet/test ./...` im Modul auf 131 in frischem Wurfverzeichnis; DB-Tests auf 204 mit `dms_dev` und `TEST_ADMIN_DSN`.
- Nachweis: normalisierter md5-Vergleich (ohne Kommentare/Imports) zeigt, dass gelöschte Kopie und common-Paket gleich waren.
- Fertig, wenn: PR offen, Tests grün, Auswirkungsliste in der PR-Beschreibung.
### A-03 bis A-07 Umstellung weiterer Module — je nach A-02
Reihenfolge: dms, mail, ai, connect, workflow; danach Root-Modul.
- Pro Modul wie A-02. NICHT nach common: audittrail, cutoverstage, crypto, notifyclient, dms-Varianten von apierror/policyclient/httpharden, Root-httpclient.
- Je Modul eigener PR.
### A-08 CI-DB-Tests ausweiten — Entscheidung nötig
- Stand: `testdb.AdminDSN` und `.gitea/workflows/db-tests.yml` (Label `nexarch-test`) sind gemergt, Workflow deckt nur `common/` ab.
- Entscheidungen des Users nötig: Runner mit Label `nexarch-test` auf 204 registrieren, Secret `TEST_ADMIN_DSN` (Wegwerf-DB, nie archivdms) setzen.
- Schritte danach: Modul-Tests auf `testdb.AdminDSN` umstellen (jeweils mit A-02..A-07), Workflow-Pfade ergänzen.
### A-09 Reviewer für common/ — Entscheidung nötig
- Offene Frage: Gitea-Benutzernamen für CODEOWNERS/Review-Pflicht auf `common/`.
## B. Portal (Vorschlag Fable, Detail siehe Gesprächsverlauf)
### B-01 Fundament — erledigt auf feature/portal-ausbau (Entscheidung: Ports bleiben)
- Entscheidung: Adressen per Proxy-Pfad `/app/<key>/` oder weiter Ports.
- Schritte: `ModuleRegistryEntry` um `group`, `path`, optional `module`; Hrefs relativ, `resolveModuleHref` nur Fallback; Route `GET /api/module-urls` (liest `/.well-known/nexarch-modules`, Env-Override `NEXARCH_MODULE_URL_<KEY>`, Cache); `ModuleGrid` nach `group` gruppieren; `tenantLabel` in `ModuleNavShell` aus Sitzungs-Identität statt Env.
- Dateien: `web/shl/components/ModuleNavShell.tsx`, `web/portal/lib/modules.ts`, `web/portal/app/ModuleGrid.tsx`, neu `web/portal/app/api/module-urls/route.ts`.
- Test: `web/portal` und `web/shl` vitest auf 131; Betriebsauftrag: Produktionsdatei des Modulverzeichnisses fehlt.
### B-02 Arbeitsvorrat — erledigt auf feature/portal-ausbau (Entscheidung: X-User-Id als Übergang)
- Entscheidung: Übergang mit `X-User-Id` aus verifiziertem Token-`sub`, solange Workflow kein Bearer kennt.
- Schritte: Route `GET /api/worklist` (Token prüfen, serverseitig `/approvals`, `/tasks`, `/reminders/kanban`, Antwort nur Zähler); Cache-Schlüssel mit `sub`, 15 s, `Cache-Control: no-store`; Badges an Workflow-Kacheln und "Heute"-Leiste; Fehler je Quelle tolerant; Schnellsuche-Zeile als Deep-Link; Env `PORTAL_WORKFLOW_API_URL`.
- Datenschutz: nur eigene Zähler, keine Inhalte.
### B-03 Admin-/Prüfer-Sicht — erledigt auf feature/portal-ausbau (ohne Favoriten und ohne Ampel an Kacheln, Statusnamen passen nicht sicher zu Registry-Keys)
- Route `GET /api/system-status` (nur nach `authorize(core.tenant.admin)`, Status-Endpunkt nur serverseitig); Route `GET /api/kennzahlen-summary` (Mandant und Rolle aus Claims, nur Totals); Ampel an Kacheln; Favoriten (localStorage, nur Modul-Keys) optional.
### B-04 Umgebungsvariablen dokumentieren — erledigt (web/portal/README.md)
- Neue Variablen: `PORTAL_CORE_API_URL`, `PORTAL_TOKEN_ISSUER`, `PORTAL_TOKEN_AUDIENCE`, `PORTAL_VISIBLE_MODULES_CACHE_SECONDS`; dazu `PORTAL_POLICY_API_URL`, `PORTAL_POLICY_SERVICE_TOKEN`. Nirgends dokumentiert; Doku im Portal anlegen.
### B-05 Zielmodul `portal` im Core — Entscheidung nötig
- Der Core stellt Modul-Tokens mit Zielmodul aus (Standard `dms`). Soll `portal` in `NEXARCH_ACCOUNT_MODULE_AUDIENCES` aufgenommen und `PORTAL_TOKEN_AUDIENCE=portal` gesetzt werden? Dann muss die Sitzung `?module=portal` mitschicken.
### B-06 Workflow auf Bearer umstellen — Folgeticket
- Workflow-API von `X-User-Id` auf Bearer (Muster: `mail/internal/moduleauth`); danach Header im Portal-BFF entfernen. Gleiches gilt für Audit-Log in dms (`caller`-Query).
## C. Laufender Betrieb
### C-01 Offene PRs mergen (User)
- #373 (qa*-Gates, migration*), #374 (workflow contracttest), #375 (dms apierror 5xx), #380 (Docs), #372 (SHL: AccountBar, Portal-Route).
- Vor Merge DB-Tests auf 204 für #373, #374, #375 laufen lassen (nie gelaufen).
### C-02 Aufräumen — nur mit Freigabe
- `/root/manticore-backup-pre29` auf 131 löschen, wenn der User es sagt.
- 204: Dateileichen im Manticore-Datenverzeichnis (`dms_src13_*`, `mail_documents_reindex_*`, `qa10loadtest_*`) nur nach Sichtung durch den User.
+14 -8
View File
@@ -1,13 +1,19 @@
"use client";
import { LogoutButton } from "@nexarch/shl";
import { AccountBar as SharedAccountBar } from "@nexarch/shl";
import { authMode, getSession } from "../lib/http";
export function AccountBar() {
if (authMode() !== "token") return null;
return (
<header style={{ display: "flex", justifyContent: "flex-end", padding: "8px 16px" }}>
<LogoutButton onLogout={() => getSession().logout()} />
</header>
);
// Duenner Wrapper statt kopierter JSX (Fable-Review auf Code-Dopplung):
// die eigentliche Darstellung lebt in @nexarch/shl, authMode()/getSession()
// sind aber App-spezifisch (eigenes lib/http.ts je App) und duerfen als
// Funktionen nicht von layout.tsx (Server Component) ueber die Props-Grenze
// an eine Client Component gereicht werden -- deshalb hier lokal aufgeloest.
async function loadIdentity() {
const s = getSession();
await s.token();
return s.identity();
}
export function AccountBar() {
return <SharedAccountBar visible={authMode() === "token"} onLogout={() => getSession().logout()} loadIdentity={loadIdentity} />;
}
+14 -8
View File
@@ -1,13 +1,19 @@
"use client";
import { LogoutButton } from "@nexarch/shl";
import { AccountBar as SharedAccountBar } from "@nexarch/shl";
import { authMode, getSession } from "../lib/http";
export function AccountBar() {
if (authMode() !== "token") return null;
return (
<header style={{ display: "flex", justifyContent: "flex-end", padding: "8px 16px" }}>
<LogoutButton onLogout={() => getSession().logout()} />
</header>
);
// Duenner Wrapper statt kopierter JSX (Fable-Review auf Code-Dopplung):
// die eigentliche Darstellung lebt in @nexarch/shl, authMode()/getSession()
// sind aber App-spezifisch (eigenes lib/http.ts je App) und duerfen als
// Funktionen nicht von layout.tsx (Server Component) ueber die Props-Grenze
// an eine Client Component gereicht werden -- deshalb hier lokal aufgeloest.
async function loadIdentity() {
const s = getSession();
await s.token();
return s.identity();
}
export function AccountBar() {
return <SharedAccountBar visible={authMode() === "token"} onLogout={() => getSession().logout()} loadIdentity={loadIdentity} />;
}
+14 -8
View File
@@ -1,13 +1,19 @@
"use client";
import { LogoutButton } from "@nexarch/shl";
import { AccountBar as SharedAccountBar } from "@nexarch/shl";
import { authMode, getSession } from "../lib/http";
export function AccountBar() {
if (authMode() !== "token") return null;
return (
<header style={{ display: "flex", justifyContent: "flex-end", padding: "8px 16px" }}>
<LogoutButton onLogout={() => getSession().logout()} />
</header>
);
// Duenner Wrapper statt kopierter JSX (Fable-Review auf Code-Dopplung):
// die eigentliche Darstellung lebt in @nexarch/shl, authMode()/getSession()
// sind aber App-spezifisch (eigenes lib/http.ts je App) und duerfen als
// Funktionen nicht von layout.tsx (Server Component) ueber die Props-Grenze
// an eine Client Component gereicht werden -- deshalb hier lokal aufgeloest.
async function loadIdentity() {
const s = getSession();
await s.token();
return s.identity();
}
export function AccountBar() {
return <SharedAccountBar visible={authMode() === "token"} onLogout={() => getSession().logout()} loadIdentity={loadIdentity} />;
}
+55
View File
@@ -0,0 +1,55 @@
# NEXARCH Portal
Startseite nach der Anmeldung: Kacheln der Module, die der angemeldete Benutzer sehen darf.
Die Modulliste kommt aus `MODULE_REGISTRY` in `@nexarch/shl`; `/api/visible-modules` filtert sie gegen die policy-api.
## Umgebungsvariablen
Browser-seitig (werden beim Build eingebettet):
| Variable | Pflicht | Bedeutung |
|---|---|---|
| `NEXT_PUBLIC_CORE_API_BASE` | ja | Basis-URL des Core (`/auth/module-token`) |
| `NEXT_PUBLIC_LOGIN_URL` | nein | Ziel der Weiterleitung, wenn die Sitzung fehlt |
Server-seitig (nur Route `/api/visible-modules`, nie im Browser sichtbar):
| Variable | Pflicht | Bedeutung |
|---|---|---|
| `PORTAL_POLICY_API_URL` | ja | Basis-URL der policy-api (`POST /authorize`) |
| `PORTAL_POLICY_SERVICE_TOKEN` | ja | Dienst-Token für die policy-api (Secret) |
| `PORTAL_CORE_API_URL` | nein | Basis-URL für `/auth/module-jwks`; Standard: `NEXT_PUBLIC_CORE_API_BASE` |
| `PORTAL_TOKEN_ISSUER` | nein | Wenn gesetzt, muss der Token-Aussteller (`iss`) übereinstimmen |
| `PORTAL_TOKEN_AUDIENCE` | nein | Wenn gesetzt, muss das Token dieses Zielmodul (`aud`) tragen |
| `PORTAL_VISIBLE_MODULES_CACHE_SECONDS` | nein | Cache je Mandant und Rolle, Standard `30`, `0` schaltet ihn ab |
| `PORTAL_WORKFLOW_API_URL` | nein | Basis-URL der Workflow-API für den Arbeitsvorrat; ohne sie antwortet `/api/worklist` mit 503 und die Kacheln bleiben ohne Zähler |
| `PORTAL_WORKLIST_CACHE_SECONDS` | nein | Cache des Arbeitsvorrats je Mandant und Benutzer, Standard `15` |
| `PORTAL_STATUS_API_URL` | nein | Basis-URL der Statusseite (`/status/overview`) für den Betriebsblock (nur Admins) |
| `PORTAL_KENNZAHLEN_API_URL` | nein | Basis-URL der Kennzahlen-API (`/kennzahlen/totals`) für die Summenzeile (Recht `kennzahlen.read`) |
| `NEXARCH_MODULE_URL_<KEY>` | nein | Ersetzt den festen Port-Href eines Moduls (z. B. `NEXARCH_MODULE_URL_DMS=https://dms.example`), ohne Rebuild |
Fehlt `PORTAL_POLICY_API_URL`, `PORTAL_POLICY_SERVICE_TOKEN` oder die Core-URL, antwortet die Route mit 503.
## Verhalten der Route
- `GET /api/visible-modules` verlangt `Authorization: Bearer <Modul-Token>`; Signatur (Ed25519, JWKS vom Core) und Ablaufzeit werden geprüft, sonst 401.
- Mandant und Rolle stammen ausschließlich aus dem Token, nie aus der URL.
- Eine Rechteänderung wirkt wegen des Caches bis zu `PORTAL_VISIBLE_MODULES_CACHE_SECONDS` verzögert.
- Fällt die policy-api aus, bleibt das jeweilige Modul unsichtbar (fail-closed).
## Arbeitsvorrat
`GET /api/worklist` liefert nur Zähler (`approvals`, `tasks`, `remindersDue`), keine Inhalte. Benutzer und Mandant kommen aus dem Token; die Workflow-API kennt bisher nur den Header `X-User-Id` (Übergangsmodus), den das Portal serverseitig setzt. Gezählt werden nur eigene Einträge: Freigaben des Benutzers, offene Aufgaben mit ihm als Person, von ihm angelegte Wiedervorlagen (überfällig oder diese Woche). Fällt eine Quelle aus, ist ihr Wert `null` und die übrigen bleiben erhalten. Die Antwort trägt `Cache-Control: no-store`.
## Betriebsblock
`GET /api/system-status` (Recht `core.tenant.admin`) und `GET /api/kennzahlen-summary` (Recht `kennzahlen.read`) prüfen das Recht über die policy-api, bevor sie die Quelle abfragen, und geben nur Modulname, Status, Zeitpunkt bzw. drei Summenwerte zurück (Objekte, Löschvorgänge, Speicher). Die Kennzahlen-Quelle erhält Mandant und Rolle aus dem Token. Die Statusseite ist selbst nicht authentifiziert und darf nur vom Portal-Server aufgerufen werden. Ohne Recht antworten die Routen mit 403, der Block bleibt ausgeblendet.
## Oberfläche
Kacheln sind nach Arbeiten, Verwalten und Prüfen gruppiert (`group` in `MODULE_REGISTRY`). Das Mandanten-Label in der Navigation stammt aus der Sitzung. Die Suchzeile leitet an `<DMS-Adresse>/search?q=` weiter und erscheint nur, wenn das DMS für den Benutzer sichtbar ist.
## Tests
Auf dem Test-Host (nicht lokal): `npm install && npx vitest run && npx tsc --noEmit`.
Bekannt: `tsc` meldet Fehler zu `process` in `web/shl/components/ModuleNavShell.tsx` (`@types/node` fehlt).
+14
View File
@@ -0,0 +1,14 @@
"use client";
import { AccountBar as SharedAccountBar } from "@nexarch/shl";
import { getSession } from "../lib/http";
async function loadIdentity() {
const s = getSession();
await s.token();
return s.identity();
}
export function AccountBar() {
return <SharedAccountBar visible onLogout={() => getSession().logout()} loadIdentity={loadIdentity} />;
}
+63
View File
@@ -0,0 +1,63 @@
"use client";
import { useEffect, useState } from "react";
import { useI18n } from "@nexarch/shl";
import { getSession } from "../lib/http";
import type { KennzahlenSummary, ModuleStatusRow } from "../lib/adminData";
async function getJson<T>(path: string): Promise<T | null> {
try {
const res = await getSession().fetch(path);
return res.ok ? ((await res.json()) as T) : null;
} catch {
return null;
}
}
export function statusLabel(status: string): string {
return status === "up" ? "in Betrieb" : status === "down" ? "gestört" : "unbekannt";
}
export function AdminPanel() {
const { t } = useI18n();
const [status, setStatus] = useState<ModuleStatusRow[] | null>(null);
const [kpi, setKpi] = useState<KennzahlenSummary | null>(null);
useEffect(() => {
let cancelled = false;
(async () => {
const [s, k] = await Promise.all([
getJson<{ modules: ModuleStatusRow[] }>("/api/system-status"),
getJson<KennzahlenSummary>("/api/kennzahlen-summary"),
]);
if (cancelled) return;
setStatus(s?.modules ?? null);
setKpi(k);
})();
return () => {
cancelled = true;
};
}, []);
if (!status && !kpi) return null;
return (
<section aria-labelledby="portal-admin" style={{ marginTop: 24 }}>
<h2 id="portal-admin" style={{ fontSize: 18 }}>{t("portal.admin", "Betrieb")}</h2>
{status && (
<ul style={{ listStyle: "none", margin: "0 0 16px", padding: 0 }}>
{status.map((m) => (
<li key={m.name}>
<span aria-hidden="true">{m.status === "up" ? "●" : "○"}</span> {m.name}: {statusLabel(m.status)}
</li>
))}
</ul>
)}
{kpi && (
<p>
{t("portal.kpi.objects", "Objekte")}: {kpi.objektBestand} · {t("portal.kpi.deletions", "Löschvorgänge")}: {kpi.loeschvorgaenge} ·{" "}
{t("portal.kpi.storage", "Speicher")}: {(kpi.speichervolumenBytes / 1024 ** 3).toFixed(2)} GB
</p>
)}
</section>
);
}
+29 -6
View File
@@ -1,25 +1,24 @@
import { describe, expect, it, vi } from "vitest";
import { resolveModuleHref } from "@nexarch/shl";
import { fetchVisibleModules } from "./ModuleGrid";
import { badgeFor, fetchWorklist, groupModules, fetchVisibleModules } from "./ModuleGrid";
describe("fetchVisibleModules", () => {
it("ruft die Portal-eigene Route mit tenant/role auf und gibt die Modulliste zurueck", async () => {
it("ruft die Portal-eigene Route ohne tenant/role in der URL auf und gibt die Modulliste zurueck", async () => {
const fetchImpl = vi.fn(
async () => new Response(JSON.stringify({ modules: [{ key: "dms", label: "Dokumente", description: "x", href: "http://a", permission: "dms.document.read" }] }), { status: 200 })
) as unknown as typeof fetch;
const modules = await fetchVisibleModules("acme", "user", fetchImpl);
const modules = await fetchVisibleModules(fetchImpl);
expect(modules).toHaveLength(1);
expect(modules[0].key).toBe("dms");
const calledUrl = (fetchImpl as ReturnType<typeof vi.fn>).mock.calls[0][0] as string;
expect(calledUrl).toContain("tenant=acme");
expect(calledUrl).toContain("role=user");
expect(calledUrl).toBe("/api/visible-modules");
});
it("gibt eine leere Liste zurueck wenn die Route fehlschlaegt", async () => {
const fetchImpl = vi.fn(async () => new Response("", { status: 500 })) as unknown as typeof fetch;
const modules = await fetchVisibleModules("acme", "user", fetchImpl);
const modules = await fetchVisibleModules(fetchImpl);
expect(modules).toEqual([]);
});
});
@@ -37,3 +36,27 @@ describe("resolveModuleHref", () => {
expect(resolveModuleHref("http://localhost:3107/", "localhost")).toBe("http://localhost:3107/");
});
});
describe("groupModules / badgeFor / fetchWorklist", () => {
const m = (key: string, group?: "work" | "admin" | "audit") => ({ key, group, label: key, description: "", href: "http://a", permission: "p" });
it("gruppiert in Reihenfolge Arbeiten, Verwalten, Pruefen und sortiert Unbekanntes unter Arbeiten ein", () => {
const g = groupModules([m("a", "audit"), m("b", "admin"), m("c", "work"), m("d")]);
expect(g.map((x) => x.key)).toEqual(["work", "admin", "audit"]);
expect(g[0].modules.map((x) => x.key)).toEqual(["c", "d"]);
});
it("zeigt Badges nur fuer Workflow-Kacheln und summiert Freigaben und Aufgaben", () => {
const w = { approvals: 2, tasks: 3, remindersDue: 4 };
expect(badgeFor("workflow", w)).toBe(5);
expect(badgeFor("workflow_cases", w)).toBe(4);
expect(badgeFor("dms", w)).toBeNull();
expect(badgeFor("workflow", null)).toBeNull();
expect(badgeFor("workflow", { approvals: null, tasks: null, remindersDue: null })).toBeNull();
});
it("fetchWorklist liefert null bei Fehlern", async () => {
expect(await fetchWorklist((async () => new Response("", { status: 500 })) as unknown as typeof fetch)).toBeNull();
expect(await fetchWorklist((async () => { throw new Error("x"); }) as unknown as typeof fetch)).toBeNull();
});
});
+115 -41
View File
@@ -3,6 +3,7 @@
import { useEffect, useState } from "react";
import { useI18n, resolveModuleHref } from "@nexarch/shl";
import type { ModuleEntry } from "../lib/modules";
import type { Worklist } from "../lib/worklist";
import { getSession } from "../lib/http";
interface VisibleModulesResponse {
@@ -10,23 +11,51 @@ interface VisibleModulesResponse {
error?: string;
}
export async function fetchVisibleModules(
tenant: string,
role: string,
fetchImpl: typeof fetch = fetch
): Promise<ModuleEntry[]> {
const res = await fetchImpl(
`/api/visible-modules?tenant=${encodeURIComponent(tenant)}&role=${encodeURIComponent(role)}`
);
export async function fetchVisibleModules(fetchImpl: typeof fetch): Promise<ModuleEntry[]> {
const res = await fetchImpl("/api/visible-modules");
if (!res.ok) return [];
const body = (await res.json()) as VisibleModulesResponse;
return body.modules ?? [];
}
export async function fetchWorklist(fetchImpl: typeof fetch): Promise<Worklist | null> {
try {
const res = await fetchImpl("/api/worklist");
return res.ok ? ((await res.json()) as Worklist) : null;
} catch {
return null;
}
}
const GROUPS = [
{ key: "work", title: "Arbeiten" },
{ key: "admin", title: "Verwalten" },
{ key: "audit", title: "Prüfen" },
] as const;
export function groupModules(modules: ModuleEntry[]): { key: string; title: string; modules: ModuleEntry[] }[] {
const known = new Set<string>(GROUPS.map((g) => g.key));
const out = GROUPS.map((g) => ({ ...g, modules: modules.filter((m) => m.group === g.key) }));
const rest = modules.filter((m) => !m.group || !known.has(m.group));
if (rest.length > 0) out[0].modules.push(...rest);
return out.filter((g) => g.modules.length > 0);
}
export function badgeFor(key: string, w: Worklist | null): number | null {
if (!w) return null;
if (key === "workflow") {
const parts = [w.approvals, w.tasks].filter((n): n is number => n !== null);
return parts.length ? parts.reduce((a, b) => a + b, 0) : null;
}
if (key === "workflow_cases") return w.remindersDue;
return null;
}
type State =
| { status: "loading" }
| { status: "noIdentity" }
| { status: "error"; message: string }
| { status: "ready"; modules: ModuleEntry[] };
| { status: "ready"; modules: ModuleEntry[]; worklist: Worklist | null };
export function ModuleGrid() {
const { t } = useI18n();
@@ -38,13 +67,13 @@ export function ModuleGrid() {
try {
const session = getSession();
await session.token();
const identity = session.identity();
if (!identity) {
if (!cancelled) setState({ status: "error", message: t("portal.noIdentity", "Anmeldung erforderlich.") });
if (!session.identity()) {
if (!cancelled) setState({ status: "noIdentity" });
return;
}
const modules = await fetchVisibleModules(identity.tenant, identity.role);
if (!cancelled) setState({ status: "ready", modules });
const call = (input: RequestInfo | URL, init?: RequestInit) => session.fetch(input, init);
const [modules, worklist] = await Promise.all([fetchVisibleModules(call), fetchWorklist(call)]);
if (!cancelled) setState({ status: "ready", modules, worklist });
} catch (err) {
if (!cancelled) {
setState({ status: "error", message: (err as Error).message });
@@ -54,44 +83,89 @@ export function ModuleGrid() {
return () => {
cancelled = true;
};
}, [t]);
}, []);
if (state.status === "loading") return <p>{t("portal.loading", "Lade Übersicht …")}</p>;
if (state.status === "noIdentity") return <p role="alert">{t("portal.noIdentity", "Anmeldung erforderlich.")}</p>;
if (state.status === "error") return <p role="alert">{state.message}</p>;
if (state.modules.length === 0) {
return <p>{t("portal.noModules", "Für Ihr Konto sind derzeit keine Module freigeschaltet.")}</p>;
}
const { modules, worklist } = state;
const dms = modules.find((m) => m.key === "dms");
const today = [
{ label: t("portal.approvals", "Offene Freigaben"), value: worklist?.approvals ?? null },
{ label: t("portal.tasks", "Meine Aufgaben"), value: worklist?.tasks ?? null },
{ label: t("portal.reminders", "Fällige Wiedervorlagen"), value: worklist?.remindersDue ?? null },
].filter((x) => x.value !== null);
return (
<ul
style={{
listStyle: "none",
margin: 0,
padding: 0,
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(240px, 1fr))",
gap: 16,
}}
>
{state.modules.map((mod) => (
<li key={mod.key}>
<a
href={resolveModuleHref(mod.href)}
<div>
{dms && (
<form action={`${resolveModuleHref(dms.href).replace(/\/$/, "")}/search`} method="get" role="search" style={{ marginBottom: 24 }}>
<input
name="q"
type="search"
aria-label={t("portal.search", "Dokumente durchsuchen")}
placeholder={t("portal.search", "Dokumente durchsuchen")}
style={{ width: "100%", boxSizing: "border-box", minHeight: 44, padding: "0 12px" }}
/>
</form>
)}
{today.length > 0 && (
<section aria-label={t("portal.today", "Heute")} style={{ display: "flex", gap: 16, flexWrap: "wrap", marginBottom: 24 }}>
{today.map((x) => (
<div key={x.label} style={{ padding: 12, border: "1px solid var(--shl-color-border, #d7dbe0)", borderRadius: 8, minWidth: 160 }}>
<strong style={{ fontSize: 24 }}>{x.value}</strong>
<div>{x.label}</div>
</div>
))}
</section>
)}
{groupModules(modules).map((g) => (
<section key={g.key} aria-labelledby={`grp-${g.key}`} style={{ marginBottom: 24 }}>
<h2 id={`grp-${g.key}`} style={{ fontSize: 18 }}>{t(`portal.group.${g.key}`, g.title)}</h2>
<ul
style={{
display: "block",
minHeight: 44,
padding: 16,
border: "1px solid var(--shl-color-border, #d7dbe0)",
borderRadius: 8,
textDecoration: "none",
color: "inherit",
listStyle: "none",
margin: 0,
padding: 0,
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(240px, 1fr))",
gap: 16,
}}
>
<strong>{mod.label}</strong>
<p style={{ margin: "4px 0 0" }}>{mod.description}</p>
</a>
</li>
{g.modules.map((mod) => {
const badge = badgeFor(mod.key, worklist);
return (
<li key={mod.key}>
<a
href={resolveModuleHref(mod.href)}
style={{
display: "block",
minHeight: 44,
padding: 16,
border: "1px solid var(--shl-color-border, #d7dbe0)",
borderRadius: 8,
textDecoration: "none",
color: "inherit",
}}
>
<strong>{mod.label}</strong>
{badge !== null && badge > 0 && (
<span aria-label={`${badge}`} style={{ marginLeft: 8, padding: "0 8px", borderRadius: 12, border: "1px solid currentColor" }}>
{badge}
</span>
)}
<p style={{ margin: "4px 0 0" }}>{mod.description}</p>
</a>
</li>
);
})}
</ul>
</section>
))}
</ul>
</div>
);
}
+30
View File
@@ -0,0 +1,30 @@
"use client";
import { useEffect, useState } from "react";
import type { ReactNode } from "react";
import { ModuleNavShell } from "@nexarch/shl";
import { getSession } from "../lib/http";
export function PortalShell({ children }: { children: ReactNode }) {
const [tenant, setTenant] = useState<string | undefined>(undefined);
useEffect(() => {
let cancelled = false;
const s = getSession();
s.token().then(
() => {
if (!cancelled) setTenant(s.identity()?.tenant);
},
() => {},
);
return () => {
cancelled = true;
};
}, []);
return (
<ModuleNavShell activeKey="portal" tenantLabel={tenant}>
{children}
</ModuleNavShell>
);
}
@@ -0,0 +1,38 @@
import { NextRequest, NextResponse } from "next/server";
import { authenticate, coreConfigured } from "../../../lib/auth";
import { kennzahlenCache } from "../../../lib/caches";
import { loadKennzahlenSummary } from "../../../lib/adminData";
import { policyAuthorize } from "../../../lib/policy";
export const dynamic = "force-dynamic";
const CACHE_TTL_MS = 15_000;
export async function GET(request: NextRequest) {
const policyBase = process.env.PORTAL_POLICY_API_URL;
const policyToken = process.env.PORTAL_POLICY_SERVICE_TOKEN;
const base = process.env.PORTAL_KENNZAHLEN_API_URL;
if (!policyBase || !policyToken || !base || !coreConfigured()) {
return NextResponse.json({ error: "Portal nicht konfiguriert" }, { status: 503 });
}
const claims = await authenticate(request);
if (!claims) return NextResponse.json({ error: "Anmeldung erforderlich" }, { status: 401 });
let allowed = false;
try {
allowed = await policyAuthorize(policyBase, policyToken)(claims.tenant, claims.role, "kennzahlen.read");
} catch {
allowed = false;
}
if (!allowed) return NextResponse.json({ error: "Keine Berechtigung" }, { status: 403 });
const noStore = { "Cache-Control": "no-store" };
const key = `${claims.tenant}\u0000${claims.role}`;
const hit = kennzahlenCache.get(key, CACHE_TTL_MS);
if (hit) return NextResponse.json(hit, { headers: noStore });
const data = await loadKennzahlenSummary(base, claims.tenant, claims.role);
if (data === null) return NextResponse.json({ error: "Quelle nicht erreichbar" }, { status: 502, headers: noStore });
kennzahlenCache.set(key, data);
return NextResponse.json(data, { headers: noStore });
}
@@ -0,0 +1,77 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { NextRequest } from "next/server";
import { generateKeyPairSync, sign } from "node:crypto";
import { resetKeyCacheForTests } from "../../../lib/verifyToken";
import { kennzahlenCache, statusCache } from "../../../lib/caches";
const originalFetch = global.fetch;
const { publicKey, privateKey } = generateKeyPairSync("ed25519");
const rawPub = (publicKey.export({ format: "der", type: "spki" }) as Buffer).subarray(-32);
function mint(claims: Record<string, unknown>): string {
const enc = (o: unknown) => Buffer.from(JSON.stringify(o)).toString("base64url");
const head = enc({ alg: "EdDSA", typ: "JWT", kid: "k1" });
const body = enc({ exp: Math.floor(Date.now() / 1000) + 300, ...claims });
return `${head}.${body}.${sign(null, Buffer.from(`${head}.${body}`), privateKey).toString("base64url")}`;
}
describe("Admin-Routen: system-status und kennzahlen-summary", () => {
let allowedPerms: Set<string>;
let upstream: string[];
const req = (path: string, token?: string) =>
new NextRequest(`http://portal.example${path}`, { headers: token ? { authorization: `Bearer ${token}` } : {} });
beforeEach(() => {
for (const [k, v] of Object.entries({
PORTAL_POLICY_API_URL: "http://policy.example",
PORTAL_POLICY_SERVICE_TOKEN: "svc",
PORTAL_CORE_API_URL: "http://core.example",
PORTAL_STATUS_API_URL: "http://status.example",
PORTAL_KENNZAHLEN_API_URL: "http://kz.example",
})) process.env[k] = v;
resetKeyCacheForTests();
statusCache.clear();
kennzahlenCache.clear();
allowedPerms = new Set();
upstream = [];
global.fetch = vi.fn(async (url: string | URL | Request, init?: RequestInit) => {
const u = String(url);
if (u.endsWith("/auth/module-jwks")) return new Response(JSON.stringify({ keys: [{ kid: "k1", public_key: rawPub.toString("base64") }] }), { status: 200 });
if (u.endsWith("/authorize")) {
const b = JSON.parse(String(init?.body));
return new Response(JSON.stringify({ allowed: allowedPerms.has(b.permission) }), { status: 200 });
}
upstream.push(u);
if (u.includes("/status/overview")) return new Response(JSON.stringify([{ name: "dms", status: "up" }]), { status: 200 });
return new Response(JSON.stringify({ objekt_bestand: 1, loeschvorgaenge: 0, speichervolumen_bytes: 0 }), { status: 200 });
}) as typeof fetch;
});
afterEach(() => {
global.fetch = originalFetch;
for (const k of ["PORTAL_POLICY_API_URL", "PORTAL_POLICY_SERVICE_TOKEN", "PORTAL_CORE_API_URL", "PORTAL_STATUS_API_URL", "PORTAL_KENNZAHLEN_API_URL"]) delete process.env[k];
});
it("system-status: 401 ohne Token, 403 ohne Admin-Recht, 200 mit Recht", async () => {
const { GET } = await import("./route");
const token = mint({ sub: "u", tenant: "acme", role: "user" });
expect((await GET(req("/api/system-status"))).status).toBe(401);
expect((await GET(req("/api/system-status", token))).status).toBe(403);
expect(upstream).toEqual([]);
allowedPerms.add("core.tenant.admin");
const res = await GET(req("/api/system-status", token));
expect(res.status).toBe(200);
expect((await res.json()).modules[0].name).toBe("dms");
});
it("kennzahlen-summary: Mandant aus dem Token, 403 ohne kennzahlen.read", async () => {
const { GET } = await import("../kennzahlen-summary/route");
const token = mint({ sub: "u", tenant: "acme", role: "auditor" });
expect((await GET(req("/api/kennzahlen-summary?tenant=fremd", token))).status).toBe(403);
allowedPerms.add("kennzahlen.read");
const res = await GET(req("/api/kennzahlen-summary?tenant=fremd", token));
expect(res.status).toBe(200);
expect(upstream[0]).toContain("tenant=acme");
expect(upstream[0]).not.toContain("fremd");
});
});
+38
View File
@@ -0,0 +1,38 @@
import { NextRequest, NextResponse } from "next/server";
import { authenticate, coreConfigured } from "../../../lib/auth";
import { statusCache } from "../../../lib/caches";
import { loadSystemStatus } from "../../../lib/adminData";
import { policyAuthorize } from "../../../lib/policy";
export const dynamic = "force-dynamic";
const CACHE_TTL_MS = 15_000;
export async function GET(request: NextRequest) {
const policyBase = process.env.PORTAL_POLICY_API_URL;
const policyToken = process.env.PORTAL_POLICY_SERVICE_TOKEN;
const base = process.env.PORTAL_STATUS_API_URL;
if (!policyBase || !policyToken || !base || !coreConfigured()) {
return NextResponse.json({ error: "Portal nicht konfiguriert" }, { status: 503 });
}
const claims = await authenticate(request);
if (!claims) return NextResponse.json({ error: "Anmeldung erforderlich" }, { status: 401 });
let allowed = false;
try {
allowed = await policyAuthorize(policyBase, policyToken)(claims.tenant, claims.role, "core.tenant.admin");
} catch {
allowed = false;
}
if (!allowed) return NextResponse.json({ error: "Keine Berechtigung" }, { status: 403 });
const noStore = { "Cache-Control": "no-store" };
const key = "all";
const hit = statusCache.get(key, CACHE_TTL_MS);
if (hit) return NextResponse.json({ modules: hit }, { headers: noStore });
const data = await loadSystemStatus(base);
if (data === null) return NextResponse.json({ error: "Quelle nicht erreichbar" }, { status: 502, headers: noStore });
statusCache.set(key, data);
return NextResponse.json({ modules: data }, { headers: noStore });
}
@@ -1,7 +1,9 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { NextRequest } from "next/server";
import { filterVisibleModules, type Authorize } from "./route";
import { generateKeyPairSync, sign } from "node:crypto";
import { filterVisibleModules, type Authorize } from "../../../lib/policy";
import type { ModuleEntry } from "../../../lib/modules";
import { resetKeyCacheForTests } from "../../../lib/verifyToken";
const originalFetch = global.fetch;
@@ -49,40 +51,91 @@ describe("filterVisibleModules (Pruefungen 1 und 4)", () => {
});
});
const { publicKey, privateKey } = generateKeyPairSync("ed25519");
const rawPub = (publicKey.export({ format: "der", type: "spki" }) as Buffer).subarray(-32);
function mint(claims: Record<string, unknown>, kid = "k1", key = privateKey): string {
const enc = (o: unknown) => Buffer.from(JSON.stringify(o)).toString("base64url");
const head = enc({ alg: "EdDSA", typ: "JWT", kid });
const body = enc({ exp: Math.floor(Date.now() / 1000) + 300, ...claims });
return `${head}.${body}.${sign(null, Buffer.from(`${head}.${body}`), key).toString("base64url")}`;
}
describe("GET /api/visible-modules (HTTP-Schicht)", () => {
beforeEach(() => {
let policyCalls: { tenant: string; role: string }[];
function req(token?: string, query = ""): NextRequest {
return new NextRequest(`http://portal.example/api/visible-modules${query}`, {
headers: token ? { authorization: `Bearer ${token}` } : {},
});
}
beforeEach(async () => {
process.env.PORTAL_POLICY_API_URL = "http://policy.example";
process.env.PORTAL_POLICY_SERVICE_TOKEN = "test-token";
process.env.PORTAL_CORE_API_URL = "http://core.example";
policyCalls = [];
resetKeyCacheForTests();
(await import("../../../lib/caches")).visibleModulesCache.clear();
global.fetch = vi.fn(async (url: string | URL | Request, init?: RequestInit) => {
const u = String(url);
if (u.endsWith("/auth/module-jwks")) {
return new Response(JSON.stringify({ keys: [{ kid: "k1", public_key: rawPub.toString("base64") }] }), { status: 200 });
}
policyCalls.push(JSON.parse(String(init?.body)));
return new Response(JSON.stringify({ allowed: true }), { status: 200 });
}) as typeof fetch;
});
afterEach(() => {
global.fetch = originalFetch;
delete process.env.PORTAL_POLICY_API_URL;
delete process.env.PORTAL_POLICY_SERVICE_TOKEN;
for (const k of ["PORTAL_POLICY_API_URL", "PORTAL_POLICY_SERVICE_TOKEN", "PORTAL_CORE_API_URL", "PORTAL_TOKEN_AUDIENCE"]) delete process.env[k];
});
it("gibt 400 ohne tenant/role zurueck", async () => {
it("gibt 401 ohne Token zurueck", async () => {
const { GET } = await import("./route");
const req = new NextRequest("http://portal.example/api/visible-modules");
const res = await GET(req);
expect(res.status).toBe(400);
expect((await GET(req())).status).toBe(401);
});
it("gibt 401 bei falscher Signatur, abgelaufenem Token oder unbekannter kid", async () => {
const { GET } = await import("./route");
const other = generateKeyPairSync("ed25519").privateKey;
const claims = { sub: "u", tenant: "acme", role: "user" };
expect((await GET(req(mint(claims, "k1", other)))).status).toBe(401);
expect((await GET(req(mint({ ...claims, exp: 1 })))).status).toBe(401);
expect((await GET(req(mint(claims, "unbekannt")))).status).toBe(401);
});
it("gibt 503 zurueck wenn Portal nicht konfiguriert ist", async () => {
delete process.env.PORTAL_POLICY_API_URL;
const { GET } = await import("./route");
const req = new NextRequest("http://portal.example/api/visible-modules?tenant=acme&role=user");
const res = await GET(req);
expect(res.status).toBe(503);
expect((await GET(req(mint({ sub: "u", tenant: "acme", role: "user" })))).status).toBe(503);
});
it("ruft policy-api mit Dienst-Token auf, Token nie im Response sichtbar", async () => {
global.fetch = vi.fn(async () => new Response(JSON.stringify({ allowed: true }), { status: 200 })) as typeof fetch;
it("nimmt Mandant und Rolle aus dem Token, nicht aus der URL", async () => {
const { GET } = await import("./route");
const req = new NextRequest("http://portal.example/api/visible-modules?tenant=acme&role=user");
const res = await GET(req);
const body = await res.json();
const res = await GET(req(mint({ sub: "u", tenant: "acme", role: "user" }), "?tenant=fremd&role=admin"));
expect(res.status).toBe(200);
expect(JSON.stringify(body)).not.toContain("test-token");
expect((global.fetch as ReturnType<typeof vi.fn>).mock.calls[0][1].headers["X-Service-Token"]).toBe("test-token");
expect(policyCalls.length).toBeGreaterThan(0);
expect(policyCalls.every((c) => c.tenant === "acme" && c.role === "user")).toBe(true);
expect(JSON.stringify(await res.json())).not.toContain("test-token");
});
it("verlangt das konfigurierte Zielmodul", async () => {
process.env.PORTAL_TOKEN_AUDIENCE = "portal";
const { GET } = await import("./route");
const claims = { sub: "u", tenant: "acme", role: "user" };
expect((await GET(req(mint({ ...claims, aud: ["dms"] })))).status).toBe(401);
expect((await GET(req(mint({ ...claims, aud: ["portal"] })))).status).toBe(200);
});
it("fragt die policy-api je Mandant/Rolle nur einmal innerhalb der Cache-Zeit", async () => {
const { GET } = await import("./route");
const token = mint({ sub: "u", tenant: "acme", role: "user" });
await GET(req(token));
const first = policyCalls.length;
await GET(req(token));
expect(policyCalls.length).toBe(first);
await GET(req(mint({ sub: "u", tenant: "acme", role: "admin" })));
expect(policyCalls.length).toBeGreaterThan(first);
});
});
+23 -47
View File
@@ -1,65 +1,41 @@
import { NextRequest, NextResponse } from "next/server";
import { loadModuleRegistry, type ModuleEntry } from "../../../lib/modules";
import { applyUrlOverrides, loadModuleRegistry } from "../../../lib/modules";
import { authenticate, coreConfigured } from "../../../lib/auth";
import { visibleModulesCache } from "../../../lib/caches";
import { filterVisibleModules, policyAuthorize } from "../../../lib/policy";
export const dynamic = "force-dynamic";
export type Authorize = (tenant: string, role: string, permission: string) => Promise<boolean>;
// Reine, testbare Kernlogik: iteriert generisch ueber JEDE Registry, egal
// wie viele/welche Eintraege config/modules.json enthaelt (Akzeptanz-
// kriterium 3 -- ein neues Modul braucht hier keine Codeaenderung).
// Ausfall/Verweigerung von 'authorize' lassen ein Modul nie durch
// (fail-closed, Pruefung 4: kein Modul ohne Berechtigung sichtbar).
export async function filterVisibleModules(
registry: ModuleEntry[],
tenant: string,
role: string,
authorize: Authorize
): Promise<ModuleEntry[]> {
const results = await Promise.all(
registry.map(async (mod) => {
try {
return (await authorize(tenant, role, mod.permission)) ? mod : null;
} catch {
return null;
}
})
);
return results.filter((m): m is ModuleEntry => m !== null);
}
function policyAuthorize(base: string, token: string): Authorize {
return async (tenant, role, permission) => {
const res = await fetch(`${base.replace(/\/$/, "")}/authorize`, {
method: "POST",
headers: { "X-Service-Token": token, "Content-Type": "application/json" },
body: JSON.stringify({ tenant, role, permission }),
cache: "no-store",
});
if (!res.ok) return false;
const body = (await res.json()) as { allowed?: boolean };
return body.allowed === true;
};
}
const CACHE_TTL_MS = Number(process.env.PORTAL_VISIBLE_MODULES_CACHE_SECONDS ?? "30") * 1000;
// Mandant und Rolle kommen ausschliesslich aus dem verifizierten Token,
// nie aus der URL. Der Cache haelt nur positive Ergebnisse je Mandant/Rolle
// kurz vor; Rechteaenderungen wirken bis zu CACHE_TTL_MS verzoegert.
// Backend-for-Frontend: haelt das Dienst-Token fuer policy-api server-
// seitig (der Browser bekommt es nie zu Gesicht, gleiches Prinzip wie
// tenant-admins Proxy-Route). Core entscheidet ueber Rolle UND Feature-
// Flag in einem Aufruf (AuthorizeRequest), das Portal trifft keine
// eigene zweite Entscheidung (Akzeptanzkriterium 2).
export async function GET(request: NextRequest) {
const tenant = request.nextUrl.searchParams.get("tenant");
const role = request.nextUrl.searchParams.get("role");
if (!tenant || !role) {
return NextResponse.json({ error: "'tenant' und 'role' sind erforderlich" }, { status: 400 });
}
const base = process.env.PORTAL_POLICY_API_URL;
const token = process.env.PORTAL_POLICY_SERVICE_TOKEN;
if (!base || !token) {
if (!base || !token || !coreConfigured()) {
return NextResponse.json({ error: "Portal nicht konfiguriert" }, { status: 503 });
}
const modules = await filterVisibleModules(loadModuleRegistry(), tenant, role, policyAuthorize(base, token));
const claims = await authenticate(request);
if (!claims) {
return NextResponse.json({ error: "Anmeldung erforderlich" }, { status: 401 });
}
const cacheKey = `${claims.tenant}\u0000${claims.role}`;
const hit = visibleModulesCache.get(cacheKey, CACHE_TTL_MS);
if (hit) return NextResponse.json({ modules: hit });
const modules = applyUrlOverrides(
await filterVisibleModules(loadModuleRegistry(), claims.tenant, claims.role, policyAuthorize(base, token)),
process.env
);
visibleModulesCache.set(cacheKey, modules);
return NextResponse.json({ modules });
}
+69
View File
@@ -0,0 +1,69 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { NextRequest } from "next/server";
import { generateKeyPairSync, sign } from "node:crypto";
import { resetKeyCacheForTests } from "../../../lib/verifyToken";
const originalFetch = global.fetch;
const { publicKey, privateKey } = generateKeyPairSync("ed25519");
const rawPub = (publicKey.export({ format: "der", type: "spki" }) as Buffer).subarray(-32);
function mint(claims: Record<string, unknown>): string {
const enc = (o: unknown) => Buffer.from(JSON.stringify(o)).toString("base64url");
const head = enc({ alg: "EdDSA", typ: "JWT", kid: "k1" });
const body = enc({ exp: Math.floor(Date.now() / 1000) + 300, ...claims });
return `${head}.${body}.${sign(null, Buffer.from(`${head}.${body}`), privateKey).toString("base64url")}`;
}
describe("GET /api/worklist", () => {
let wfCalls: { url: string; user: string }[];
const req = (token?: string, query = "") =>
new NextRequest(`http://portal.example/api/worklist${query}`, { headers: token ? { authorization: `Bearer ${token}` } : {} });
beforeEach(async () => {
process.env.PORTAL_WORKFLOW_API_URL = "http://wf.example";
process.env.PORTAL_CORE_API_URL = "http://core.example";
wfCalls = [];
resetKeyCacheForTests();
(await import("../../../lib/caches")).worklistCache.clear();
global.fetch = vi.fn(async (url: string | URL | Request, init?: RequestInit) => {
const u = String(url);
if (u.endsWith("/auth/module-jwks")) {
return new Response(JSON.stringify({ keys: [{ kid: "k1", public_key: rawPub.toString("base64") }] }), { status: 200 });
}
wfCalls.push({ url: u, user: (init?.headers as Record<string, string>)["X-User-Id"] });
return new Response(JSON.stringify([]), { status: 200 });
}) as typeof fetch;
});
afterEach(() => {
global.fetch = originalFetch;
delete process.env.PORTAL_WORKFLOW_API_URL;
delete process.env.PORTAL_CORE_API_URL;
});
it("gibt 401 ohne Token und 503 ohne Konfiguration zurueck", async () => {
const { GET } = await import("./route");
expect((await GET(req())).status).toBe(401);
delete process.env.PORTAL_WORKFLOW_API_URL;
expect((await GET(req(mint({ sub: "u1", tenant: "acme" })))).status).toBe(503);
});
it("nimmt den Benutzer aus dem Token, nie aus der URL, und sendet no-store", async () => {
const { GET } = await import("./route");
const res = await GET(req(mint({ sub: "u1", tenant: "acme", role: "user" }), "?userId=fremd"));
expect(res.status).toBe(200);
expect(res.headers.get("cache-control")).toBe("no-store");
expect(wfCalls.length).toBe(3);
expect(wfCalls.every((c) => c.user === "u1")).toBe(true);
});
it("cached je Mandant und Benutzer", async () => {
const { GET } = await import("./route");
await GET(req(mint({ sub: "u1", tenant: "acme" })));
await GET(req(mint({ sub: "u1", tenant: "acme" })));
expect(wfCalls.length).toBe(3);
await GET(req(mint({ sub: "u2", tenant: "acme" })));
expect(wfCalls.length).toBe(6);
expect(wfCalls.slice(3).every((c) => c.user === "u2")).toBe(true);
});
});
+27
View File
@@ -0,0 +1,27 @@
import { NextRequest, NextResponse } from "next/server";
import { authenticate, coreConfigured } from "../../../lib/auth";
import { worklistCache } from "../../../lib/caches";
import { loadWorklist } from "../../../lib/worklist";
export const dynamic = "force-dynamic";
const CACHE_TTL_MS = Number(process.env.PORTAL_WORKLIST_CACHE_SECONDS ?? "15") * 1000;
// Nur Zaehler, keine Inhalte. Benutzer und Mandant stammen aus dem verifizierten Token;
// die Workflow-API kennt bisher nur X-User-Id (Uebergangsmodus), der Header wird hier serverseitig gesetzt.
export async function GET(request: NextRequest) {
const base = process.env.PORTAL_WORKFLOW_API_URL;
if (!base || !coreConfigured()) {
return NextResponse.json({ error: "Portal nicht konfiguriert" }, { status: 503 });
}
const claims = await authenticate(request);
if (!claims) return NextResponse.json({ error: "Anmeldung erforderlich" }, { status: 401 });
const noStore = { "Cache-Control": "no-store" };
const key = `${claims.tenant}\u0000${claims.sub}`;
const hit = worklistCache.get(key, CACHE_TTL_MS);
if (hit) return NextResponse.json(hit, { headers: noStore });
const data = await loadWorklist(base, claims.tenant, claims.sub);
worklistCache.set(key, data);
return NextResponse.json(data, { headers: noStore });
}
+4 -2
View File
@@ -1,6 +1,8 @@
import Script from "next/script";
import { ThemeProvider, I18nProvider, ToastProvider, ModuleNavShell, typography, themeInitScript, focusVisibleStyles } from "@nexarch/shl";
import { ThemeProvider, I18nProvider, ToastProvider, typography, themeInitScript, focusVisibleStyles } from "@nexarch/shl";
import type { ReactNode } from "react";
import { AccountBar } from "./AccountBar";
import { PortalShell } from "./PortalShell";
export const metadata = {
title: "NEXARCH Portal",
@@ -25,7 +27,7 @@ export default function RootLayout({ children }: { children: ReactNode }) {
>
<ThemeProvider>
<I18nProvider initialLocale="de">
<ToastProvider><ModuleNavShell activeKey="portal">{children}</ModuleNavShell></ToastProvider>
<ToastProvider><PortalShell><AccountBar />{children}</PortalShell></ToastProvider>
</I18nProvider>
</ThemeProvider>
</body>
+2
View File
@@ -1,3 +1,4 @@
import { AdminPanel } from "./AdminPanel";
import { ModuleGrid } from "./ModuleGrid";
export default function Page() {
@@ -5,6 +6,7 @@ export default function Page() {
<div style={{ maxWidth: 960, margin: "0 auto", padding: 24 }}>
<h1>NEXARCH Portal</h1>
<ModuleGrid />
<AdminPanel />
</div>
);
}
+32
View File
@@ -0,0 +1,32 @@
import { describe, expect, it, vi } from "vitest";
import { loadKennzahlenSummary, loadSystemStatus } from "./adminData";
describe("loadSystemStatus", () => {
it("mappt Name, Status und Zeitpunkt", async () => {
const f = vi.fn(async () => new Response(JSON.stringify([{ name: "dms", status: "up", last_checked: "2026-10-27T10:00:00Z", host: "geheim" }]), { status: 200 })) as unknown as typeof fetch;
const r = await loadSystemStatus("http://s.example/", f);
expect(r).toEqual([{ name: "dms", status: "up", lastChecked: "2026-10-27T10:00:00Z" }]);
expect(JSON.stringify(r)).not.toContain("geheim");
});
it("liefert null bei Fehler oder falscher Form", async () => {
expect(await loadSystemStatus("http://s", (async () => new Response("", { status: 500 })) as unknown as typeof fetch)).toBeNull();
expect(await loadSystemStatus("http://s", (async () => new Response("{}", { status: 200 })) as unknown as typeof fetch)).toBeNull();
expect(await loadSystemStatus("http://s", (async () => { throw new Error("x"); }) as unknown as typeof fetch)).toBeNull();
});
});
describe("loadKennzahlenSummary", () => {
it("sendet Mandant und Rolle aus den Argumenten und gibt nur die drei Summen zurueck", async () => {
const f = vi.fn(async () => new Response(JSON.stringify({ objekt_bestand: 5, loeschvorgaenge: 2, speichervolumen_bytes: 1024, aufbewahrungsstatus: { a: 1 } }), { status: 200 }));
const r = await loadKennzahlenSummary("http://k.example", "acme", "auditor", f as unknown as typeof fetch);
expect(r).toEqual({ objektBestand: 5, loeschvorgaenge: 2, speichervolumenBytes: 1024 });
const [url, init] = f.mock.calls[0] as unknown as [string, RequestInit];
expect(url).toBe("http://k.example/kennzahlen/totals?tenant=acme");
expect((init.headers as Record<string, string>)["X-User-Role"]).toBe("auditor");
});
it("liefert null, wenn die Quelle ablehnt", async () => {
expect(await loadKennzahlenSummary("http://k", "t", "r", (async () => new Response("", { status: 403 })) as unknown as typeof fetch)).toBeNull();
});
});
+53
View File
@@ -0,0 +1,53 @@
export interface ModuleStatusRow {
name: string;
status: string;
lastChecked: string | null;
}
export interface KennzahlenSummary {
objektBestand: number;
loeschvorgaenge: number;
speichervolumenBytes: number;
}
const num = (v: unknown): number => (typeof v === "number" && Number.isFinite(v) ? v : 0);
export async function loadSystemStatus(base: string, fetchImpl: typeof fetch = fetch): Promise<ModuleStatusRow[] | null> {
try {
const res = await fetchImpl(`${base.replace(/\/$/, "")}/status/overview`, { cache: "no-store" });
if (!res.ok) return null;
const body = (await res.json()) as unknown;
if (!Array.isArray(body)) return null;
return body.map((r: { name?: unknown; status?: unknown; last_checked?: unknown }) => ({
name: String(r.name ?? ""),
status: String(r.status ?? "unknown"),
lastChecked: typeof r.last_checked === "string" ? r.last_checked : null,
}));
} catch {
return null;
}
}
// Nur die drei Summenwerte verlassen das Portal; Mandant und Rolle kommen aus dem Token.
export async function loadKennzahlenSummary(
base: string,
tenant: string,
role: string,
fetchImpl: typeof fetch = fetch
): Promise<KennzahlenSummary | null> {
try {
const res = await fetchImpl(`${base.replace(/\/$/, "")}/kennzahlen/totals?tenant=${encodeURIComponent(tenant)}`, {
headers: { "X-User-Role": role, Accept: "application/json" },
cache: "no-store",
});
if (!res.ok) return null;
const b = (await res.json()) as Record<string, unknown>;
return {
objektBestand: num(b.objekt_bestand),
loeschvorgaenge: num(b.loeschvorgaenge),
speichervolumenBytes: num(b.speichervolumen_bytes),
};
} catch {
return null;
}
}
+19
View File
@@ -0,0 +1,19 @@
import type { NextRequest } from "next/server";
import { verifyModuleToken, type VerifiedClaims } from "./verifyToken";
export async function authenticate(request: NextRequest): Promise<VerifiedClaims | null> {
const coreBase = process.env.PORTAL_CORE_API_URL ?? process.env.NEXT_PUBLIC_CORE_API_BASE;
if (!coreBase) return null;
const auth = request.headers.get("authorization") ?? "";
const bearer = auth.startsWith("Bearer ") ? auth.slice(7).trim() : "";
if (!bearer) return null;
return verifyModuleToken(bearer, {
jwksUrl: `${coreBase.replace(/\/$/, "")}/auth/module-jwks`,
issuer: process.env.PORTAL_TOKEN_ISSUER,
audience: process.env.PORTAL_TOKEN_AUDIENCE,
});
}
export function coreConfigured(): boolean {
return Boolean(process.env.PORTAL_CORE_API_URL ?? process.env.NEXT_PUBLIC_CORE_API_BASE);
}
+24
View File
@@ -0,0 +1,24 @@
import type { ModuleEntry } from "./modules";
import type { Worklist } from "./worklist";
export class TtlCache<T> {
private readonly entries = new Map<string, { at: number; value: T }>();
get(key: string, ttlMs: number): T | undefined {
const hit = this.entries.get(key);
return hit && Date.now() - hit.at < ttlMs ? hit.value : undefined;
}
set(key: string, value: T): void {
this.entries.set(key, { at: Date.now(), value });
}
clear(): void {
this.entries.clear();
}
}
export const visibleModulesCache = new TtlCache<ModuleEntry[]>();
export const worklistCache = new TtlCache<Worklist>();
export const statusCache = new TtlCache<unknown>();
export const kennzahlenCache = new TtlCache<unknown>();
+14
View File
@@ -83,3 +83,17 @@ describe("loadModuleRegistry", () => {
expect(new Set(keys).size).toBe(keys.length);
});
});
describe("applyUrlOverrides", () => {
const mods = [{ key: "dms", label: "D", description: "x", href: "http://localhost:3101", permission: "p" }];
it("ersetzt den Href per NEXARCH_MODULE_URL_<KEY>", async () => {
const { applyUrlOverrides } = await import("./modules");
expect(applyUrlOverrides(mods, { NEXARCH_MODULE_URL_DMS: "https://dms.example" })[0].href).toBe("https://dms.example");
});
it("laesst den Href ohne Override unveraendert", async () => {
const { applyUrlOverrides } = await import("./modules");
expect(applyUrlOverrides(mods, {})[0].href).toBe("http://localhost:3101");
});
});
+10 -1
View File
@@ -1,7 +1,8 @@
import { MODULE_REGISTRY } from "@nexarch/shl";
import { MODULE_REGISTRY, type ModuleGroup } from "@nexarch/shl";
export interface ModuleEntry {
key: string;
group?: ModuleGroup;
label: string;
description: string;
href: string;
@@ -18,3 +19,11 @@ export function loadModuleRegistry(): ModuleEntry[] {
(m): m is ModuleEntry => m.key !== "portal" && m.description !== undefined && m.permission !== undefined
);
}
// NEXARCH_MODULE_URL_<KEY> ersetzt den festen Port-Href eines Moduls, ohne Rebuild.
export function applyUrlOverrides(modules: ModuleEntry[], env: Record<string, string | undefined>): ModuleEntry[] {
return modules.map((m) => {
const override = env[`NEXARCH_MODULE_URL_${m.key.toUpperCase()}`];
return override ? { ...m, href: override } : m;
});
}
+41
View File
@@ -0,0 +1,41 @@
import type { ModuleEntry } from "./modules";
export type Authorize = (tenant: string, role: string, permission: string) => Promise<boolean>;
// Reine, testbare Kernlogik: iteriert generisch ueber JEDE Registry, egal
// wie viele/welche Eintraege config/modules.json enthaelt (Akzeptanz-
// kriterium 3 -- ein neues Modul braucht hier keine Codeaenderung).
// Ausfall/Verweigerung von 'authorize' lassen ein Modul nie durch
// (fail-closed, Pruefung 4: kein Modul ohne Berechtigung sichtbar).
export async function filterVisibleModules(
registry: ModuleEntry[],
tenant: string,
role: string,
authorize: Authorize
): Promise<ModuleEntry[]> {
const results = await Promise.all(
registry.map(async (mod) => {
try {
return (await authorize(tenant, role, mod.permission)) ? mod : null;
} catch {
return null;
}
})
);
return results.filter((m): m is ModuleEntry => m !== null);
}
export function policyAuthorize(base: string, token: string): Authorize {
return async (tenant, role, permission) => {
const res = await fetch(`${base.replace(/\/$/, "")}/authorize`, {
method: "POST",
headers: { "X-Service-Token": token, "Content-Type": "application/json" },
body: JSON.stringify({ tenant, role, permission }),
cache: "no-store",
});
if (!res.ok) return false;
const body = (await res.json()) as { allowed?: boolean };
return body.allowed === true;
};
}
+84
View File
@@ -0,0 +1,84 @@
import { createPublicKey, verify, type KeyObject } from "node:crypto";
export interface VerifiedClaims {
sub: string;
tenant: string;
role: string;
}
export interface VerifyOptions {
jwksUrl: string;
issuer?: string;
audience?: string;
fetchImpl?: typeof fetch;
now?: () => number;
}
const SPKI_ED25519_PREFIX = Buffer.from("302a300506032b6570032100", "hex");
const JWKS_TTL_MS = 60_000;
let keyCache: { url: string; at: number; keys: Map<string, KeyObject> } | null = null;
export function resetKeyCacheForTests(): void {
keyCache = null;
}
async function loadKeys(opts: VerifyOptions, force: boolean): Promise<Map<string, KeyObject>> {
const now = (opts.now ?? Date.now)();
if (!force && keyCache && keyCache.url === opts.jwksUrl && now - keyCache.at < JWKS_TTL_MS) return keyCache.keys;
const res = await (opts.fetchImpl ?? fetch)(opts.jwksUrl, { cache: "no-store" });
if (!res.ok) throw new Error(`jwks ${res.status}`);
const body = (await res.json()) as { keys?: { kid?: string; public_key?: string }[] };
const keys = new Map<string, KeyObject>();
for (const k of body.keys ?? []) {
if (!k.kid || !k.public_key) continue;
const raw = Buffer.from(k.public_key, "base64");
if (raw.length !== 32) continue;
keys.set(k.kid, createPublicKey({ key: Buffer.concat([SPKI_ED25519_PREFIX, raw]), format: "der", type: "spki" }));
}
keyCache = { url: opts.jwksUrl, at: now, keys };
return keys;
}
function parseJson(part: string): Record<string, unknown> | null {
try {
const v = JSON.parse(Buffer.from(part, "base64url").toString("utf8"));
return typeof v === "object" && v !== null ? v : null;
} catch {
return null;
}
}
// Gibt nur bei gueltiger Signatur, Ablaufzeit und (falls gesetzt) Aussteller/Zielmodul Claims zurueck.
export async function verifyModuleToken(token: string, opts: VerifyOptions): Promise<VerifiedClaims | null> {
const parts = token.split(".");
if (parts.length !== 3) return null;
const header = parseJson(parts[0]);
const claims = parseJson(parts[1]);
if (!header || !claims || header.alg !== "EdDSA" || typeof header.kid !== "string") return null;
let keys: Map<string, KeyObject>;
try {
keys = await loadKeys(opts, false);
if (!keys.has(header.kid)) keys = await loadKeys(opts, true);
} catch {
return null;
}
const key = keys.get(header.kid);
if (!key) return null;
const signed = Buffer.from(`${parts[0]}.${parts[1]}`);
const sig = Buffer.from(parts[2], "base64url");
if (!verify(null, signed, key, sig)) return null;
const nowSec = Math.floor((opts.now ?? Date.now)() / 1000);
if (typeof claims.exp !== "number" || claims.exp <= nowSec) return null;
if (typeof claims.nbf === "number" && claims.nbf > nowSec) return null;
if (opts.issuer && claims.iss !== opts.issuer) return null;
if (opts.audience) {
const aud = Array.isArray(claims.aud) ? claims.aud : [claims.aud];
if (!aud.includes(opts.audience)) return null;
}
if (typeof claims.sub !== "string" || typeof claims.tenant !== "string" || !claims.sub || !claims.tenant) return null;
return { sub: claims.sub, tenant: claims.tenant, role: typeof claims.role === "string" ? claims.role : "" };
}
+57
View File
@@ -0,0 +1,57 @@
import { describe, expect, it, vi } from "vitest";
import { countApprovals, countOwnReminders, countOwnTasks, loadWorklist } from "./worklist";
describe("Zaehler", () => {
it("zaehlt nur eigene, nicht erledigte Personen-Aufgaben", () => {
const rows = [
{ AssigneeType: "person", AssigneeID: "u1", Status: "open" },
{ AssigneeType: "person", AssigneeID: "u1", Status: "done" },
{ AssigneeType: "person", AssigneeID: "u2", Status: "open" },
{ AssigneeType: "group", AssigneeID: "u1", Status: "open" },
];
expect(countOwnTasks(rows, "u1")).toBe(1);
});
it("zaehlt nur eigene ueberfaellige oder diese Woche faellige Wiedervorlagen", () => {
const rows = [
{ Column: "overdue", Reminder: { CreatedBy: "u1" } },
{ Column: "this_week", Reminder: { CreatedBy: "u1" } },
{ Column: "open", Reminder: { CreatedBy: "u1" } },
{ Column: "overdue", Reminder: { CreatedBy: "u2" } },
];
expect(countOwnReminders(rows, "u1")).toBe(2);
});
it("liefert 0 bei unerwarteter Antwortform", () => {
expect(countApprovals(null)).toBe(0);
expect(countOwnTasks({}, "u1")).toBe(0);
expect(countOwnReminders("x", "u1")).toBe(0);
});
});
describe("loadWorklist", () => {
it("setzt X-User-Id und X-Tenant-Slug und gibt nur Zaehler zurueck", async () => {
const calls: { url: string; headers: Record<string, string> }[] = [];
const fetchImpl = vi.fn(async (url: string, init: RequestInit) => {
calls.push({ url, headers: init.headers as Record<string, string> });
if (url.endsWith("/approvals")) return new Response(JSON.stringify([{ ID: "a" }, { ID: "b" }]), { status: 200 });
if (url.endsWith("/tasks")) return new Response(JSON.stringify([{ AssigneeType: "person", AssigneeID: "u1", Status: "open", Title: "geheim" }]), { status: 200 });
return new Response(JSON.stringify([]), { status: 200 });
}) as unknown as typeof fetch;
const w = await loadWorklist("http://wf.example/", "acme", "u1", fetchImpl);
expect(w).toEqual({ approvals: 2, tasks: 1, remindersDue: 0 });
expect(JSON.stringify(w)).not.toContain("geheim");
expect(calls.every((c) => c.headers["X-User-Id"] === "u1" && c.headers["X-Tenant-Slug"] === "acme")).toBe(true);
});
it("markiert ausgefallene Quellen mit null, ohne die anderen zu verlieren", async () => {
const fetchImpl = vi.fn(async (url: string) => {
if (url.endsWith("/tasks")) throw new Error("down");
if (url.endsWith("/reminders/kanban")) return new Response("", { status: 500 });
return new Response(JSON.stringify([{ ID: "a" }]), { status: 200 });
}) as unknown as typeof fetch;
expect(await loadWorklist("http://wf.example", "acme", "u1", fetchImpl)).toEqual({ approvals: 1, tasks: null, remindersDue: null });
});
});
+60
View File
@@ -0,0 +1,60 @@
export interface Worklist {
approvals: number | null;
tasks: number | null;
remindersDue: number | null;
}
interface TaskRow {
AssigneeType?: string;
AssigneeID?: string;
Status?: string;
}
interface KanbanRow {
Column?: string;
Reminder?: { CreatedBy?: string };
}
export function countApprovals(rows: unknown): number {
return Array.isArray(rows) ? rows.length : 0;
}
// /tasks liefert alle Aufgaben der Instanz; nur die eigenen, offenen werden gezaehlt.
export function countOwnTasks(rows: unknown, userId: string): number {
if (!Array.isArray(rows)) return 0;
return (rows as TaskRow[]).filter(
(t) => t.AssigneeType === "person" && t.AssigneeID === userId && t.Status !== "done"
).length;
}
// /reminders/kanban liefert alle Wiedervorlagen; gezaehlt werden die eigenen, ueberfaelligen oder diese Woche faelligen.
export function countOwnReminders(rows: unknown, userId: string): number {
if (!Array.isArray(rows)) return 0;
return (rows as KanbanRow[]).filter(
(k) => k.Reminder?.CreatedBy === userId && (k.Column === "overdue" || k.Column === "this_week")
).length;
}
export async function loadWorklist(
base: string,
tenant: string,
userId: string,
fetchImpl: typeof fetch = fetch
): Promise<Worklist> {
const root = base.replace(/\/$/, "");
const headers = { "X-User-Id": userId, "X-Tenant-Slug": tenant, Accept: "application/json" };
const get = async (path: string): Promise<unknown | null> => {
try {
const res = await fetchImpl(`${root}${path}`, { headers, cache: "no-store" });
return res.ok ? await res.json() : null;
} catch {
return null;
}
};
const [approvals, tasks, reminders] = await Promise.all([get("/approvals"), get("/tasks"), get("/reminders/kanban")]);
return {
approvals: approvals === null ? null : countApprovals(approvals),
tasks: tasks === null ? null : countOwnTasks(tasks, userId),
remindersDue: reminders === null ? null : countOwnReminders(reminders, userId),
};
}
+36
View File
@@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest";
import { render, screen } from "@testing-library/react";
import { AccountBar } from "../components/AccountBar";
import { I18nProvider } from "../i18n/i18n";
import { ThemeProvider } from "../theme/ThemeProvider";
function renderBar(props: Partial<React.ComponentProps<typeof AccountBar>>) {
return render(
<ThemeProvider>
<I18nProvider>
<AccountBar visible onLogout={() => {}} {...props} />
</I18nProvider>
</ThemeProvider>,
);
}
describe("AccountBar", () => {
it("zeigt Benutzer, Rolle und Mandant", async () => {
renderBar({ loadIdentity: async () => ({ userId: "u-1", tenant: "t-1", role: "admin" }) });
const el = await screen.findByTestId("account-identity");
expect(el.textContent).toContain("u-1");
expect(el.textContent).toContain("admin");
expect(el.textContent).toContain("t-1");
});
it("zeigt nur Abmelden, wenn die Identitaet nicht ladbar ist", async () => {
renderBar({ loadIdentity: async () => { throw new Error("x"); } });
await screen.findByRole("button");
expect(screen.queryByTestId("account-identity")).toBeNull();
});
it("rendert nichts, wenn nicht sichtbar", () => {
const { container } = renderBar({ visible: false });
expect(container.querySelector("header")).toBeNull();
});
});
+2
View File
@@ -35,6 +35,8 @@ export interface Identity {
role: string;
}
export type IdentityLoader = () => Promise<Identity | null>;
export interface SessionOptions {
coreBase: string;
fetchImpl?: typeof fetch;
+43
View File
@@ -0,0 +1,43 @@
"use client";
import { useEffect, useState } from "react";
import { LogoutButton } from "../auth/LogoutButton";
import type { Identity, IdentityLoader } from "../auth/session";
import { useI18n } from "../i18n/i18n";
export interface AccountBarProps {
visible: boolean;
onLogout: () => void;
loadIdentity?: IdentityLoader;
}
export function AccountBar({ visible, onLogout, loadIdentity }: AccountBarProps) {
const { t } = useI18n();
const [identity, setIdentity] = useState<Identity | null>(null);
useEffect(() => {
if (!visible || !loadIdentity) return;
let cancelled = false;
loadIdentity().then(
(id) => {
if (!cancelled) setIdentity(id);
},
() => {},
);
return () => {
cancelled = true;
};
}, [visible, loadIdentity]);
if (!visible) return null;
return (
<header style={{ display: "flex", justifyContent: "flex-end", alignItems: "center", gap: 16, padding: "8px 16px" }}>
{identity && (
<span data-testid="account-identity">
{identity.userId} · {t("shl.auth.role")}: {identity.role} · {t("shl.auth.tenant")}: {identity.tenant}
</span>
)}
<LogoutButton onLogout={onLogout} />
</header>
);
}
+19 -15
View File
@@ -17,8 +17,11 @@ import { useEffect, useState } from "react";
import type { ReactNode } from "react";
import { Shell } from "./Shell";
export type ModuleGroup = "work" | "admin" | "audit";
export interface ModuleRegistryEntry {
key: string;
group?: ModuleGroup;
label: string;
href: string;
description?: string;
@@ -27,55 +30,55 @@ export interface ModuleRegistryEntry {
export const MODULE_REGISTRY: ModuleRegistryEntry[] = [
{
key: "dms", label: "Dokumente", href: "http://localhost:3101",
key: "dms", group: "work", label: "Dokumente", href: "http://localhost:3101",
description: "Dokumente durchsuchen und verwalten.", permission: "dms.document.read",
},
{
key: "dms_upload", label: "Dokumente hochladen", href: "http://localhost:3107",
key: "dms_upload", group: "work", label: "Dokumente hochladen", href: "http://localhost:3107",
description: "Dokumente und Kassenbelege hochladen, inklusive mobiler Kamera-Erfassung.", permission: "dms.upload.read",
},
{
key: "mail", label: "Postfach", href: "http://localhost:3102",
key: "mail", group: "work", label: "Postfach", href: "http://localhost:3102",
description: "E-Mails durchsuchen und verwalten.", permission: "mail.message.read",
},
{
key: "workflow", label: "Aufgaben & Freigaben", href: "http://localhost:3103",
key: "workflow", group: "work", label: "Aufgaben & Freigaben", href: "http://localhost:3103",
description: "Freigaben bearbeiten, Akten einsehen, Aufgaben verwalten.", permission: "workflow.approval.read",
},
{
key: "workflow_cases", label: "Akten & Vorgänge", href: "http://localhost:3108",
key: "workflow_cases", group: "work", label: "Akten & Vorgänge", href: "http://localhost:3108",
description: "Akten, Vermerke und Wiedervorlagen einsehen.", permission: "workflow.case.read",
},
{
key: "routing_rules", label: "Routing-Regeln", href: "http://localhost:3109",
key: "routing_rules", group: "admin", label: "Routing-Regeln", href: "http://localhost:3109",
description: "Regelbasiertes Routing anlegen und testen.", permission: "workflow.routingrule.read",
},
{
key: "ai", label: "KI-Funktionen", href: "http://localhost:3104",
key: "ai", group: "work", label: "KI-Funktionen", href: "http://localhost:3104",
description: "Zusammenfassungen, Extraktion und semantische Suche.", permission: "ai.summary.read",
},
{
key: "ai_extraction", label: "Extraktions-Korrektur", href: "http://localhost:3110",
key: "ai_extraction", group: "work", label: "Extraktions-Korrektur", href: "http://localhost:3110",
description: "Von der KI extrahierte Werte prüfen und korrigieren.", permission: "ai.extraction.read",
},
{
key: "connect", label: "Externe Anbindungen", href: "http://localhost:3105",
key: "connect", group: "admin", label: "Externe Anbindungen", href: "http://localhost:3105",
description: "Status externer Systemanbindungen (ERP, DATEV, CRM).", permission: "connect.status.read",
},
{
key: "connect_datev", label: "DATEV-Export", href: "http://localhost:3111",
key: "connect_datev", group: "admin", label: "DATEV-Export", href: "http://localhost:3111",
description: "DATEV-Exporte anstoßen und Übertragungsprotokoll einsehen.", permission: "connect.datev.read",
},
{
key: "tenant_admin", label: "Mandantenverwaltung", href: "http://localhost:3106",
key: "tenant_admin", group: "admin", label: "Mandantenverwaltung", href: "http://localhost:3106",
description: "Mandanten und Import-Verzeichnisse verwalten.", permission: "core.tenant.admin",
},
{
key: "rbac_admin", label: "Rollen & Rechte", href: "http://localhost:3112",
key: "rbac_admin", group: "admin", label: "Rollen & Rechte", href: "http://localhost:3112",
description: "Rollen und Berechtigungen verwalten.", permission: "core.tenant.admin",
},
{
key: "kennzahlen_dashboard", label: "Kennzahlen", href: "http://localhost:3113",
key: "kennzahlen_dashboard", group: "audit", label: "Kennzahlen", href: "http://localhost:3113",
description: "Löschkennzahlen je Tenant und Modul einsehen.", permission: "kennzahlen.read",
},
{ key: "portal", label: "Übersicht", href: "http://localhost:3900" },
@@ -83,6 +86,7 @@ export const MODULE_REGISTRY: ModuleRegistryEntry[] = [
export interface ModuleNavShellProps {
activeKey: string;
tenantLabel?: string;
children: ReactNode;
}
@@ -119,7 +123,7 @@ function tenantLabel(): string {
return process.env.NEXT_PUBLIC_TENANT_SLUG ?? process.env.NEXT_PUBLIC_MAIL_TENANT_SLUG ?? "demo";
}
export function ModuleNavShell({ activeKey, children }: ModuleNavShellProps) {
export function ModuleNavShell({ activeKey, tenantLabel: tenantLabelProp, children }: ModuleNavShellProps) {
const [hrefs, setHrefs] = useState<Record<string, string>>({});
useEffect(() => {
@@ -134,7 +138,7 @@ export function ModuleNavShell({ activeKey, children }: ModuleNavShellProps) {
}));
return (
<Shell modules={modules} tenantLabel={tenantLabel()}>
<Shell modules={modules} tenantLabel={tenantLabelProp ?? tenantLabel()}>
{children}
</Shell>
);
+4
View File
@@ -19,6 +19,8 @@ const baseMessages: MessageBundle = {
"shl.theme.toggleToLight": "Helles Erscheinungsbild",
"shl.theme.toggleToDark": "Dunkles Erscheinungsbild",
"shl.auth.logout": "Abmelden",
"shl.auth.role": "Rolle",
"shl.auth.tenant": "Mandant",
},
en: {
"shl.dialog.close": "Close",
@@ -27,6 +29,8 @@ const baseMessages: MessageBundle = {
"shl.theme.toggleToLight": "Switch to light theme",
"shl.theme.toggleToDark": "Switch to dark theme",
"shl.auth.logout": "Sign out",
"shl.auth.role": "Role",
"shl.auth.tenant": "Tenant",
},
};
+5 -2
View File
@@ -15,7 +15,10 @@ export { Shell } from "./components/Shell";
export type { ShellProps, ModuleLink } from "./components/Shell";
export { ModuleNavShell, MODULE_REGISTRY, resolveModuleHref } from "./components/ModuleNavShell";
export type { ModuleNavShellProps, ModuleRegistryEntry } from "./components/ModuleNavShell";
export type { ModuleNavShellProps, ModuleRegistryEntry, ModuleGroup } from "./components/ModuleNavShell";
export { AccountBar } from "./components/AccountBar";
export type { AccountBarProps } from "./components/AccountBar";
export { Dialog } from "./components/Dialog";
export type { DialogProps } from "./components/Dialog";
@@ -27,7 +30,7 @@ export { TextField, SelectField, CheckboxField } from "./components/FormElements
export type { TextFieldProps, SelectFieldProps, CheckboxFieldProps } from "./components/FormElements";
export { createSession, redirectToLogin, describeHttpError, authMessages, AuthError } from "./auth/session";
export type { Session, SessionOptions, Identity, AuthErrorKind } from "./auth/session";
export type { Session, SessionOptions, Identity, IdentityLoader, AuthErrorKind } from "./auth/session";
export { LogoutButton } from "./auth/LogoutButton";
export type { LogoutButtonProps } from "./auth/LogoutButton";