- internal/auth/pocket_id.go: Middleware mit /login + /callback, OIDC-Discovery + JWKS-Cache, RS256-Signatur-Prüfung, PKCE S256, AES-256-GCM-versiegeltes Session-Cookie, Erzwingung von Gruppenmitgliedschaft 'proofedmesh-admin' (konfigurierbar). Protect(h) setzt WithPrincipal(sub) + WithSession(sid) auf ctx; fehlendes/ungültiges Cookie → 302 nach /login (next= bewahrt). - internal/auth/context.go: WithPrincipal/WithSession/PrincipalFrom /SessionFrom aus httpapi/rates.go in internal/auth gezogen, damit auth und httpapi dieselben Kontext-Keys teilen (keine import cycle: httpapi importiert auth). - httpapi/rates.go: delegiert jetzt an auth.*; die Shim-Exports WithPrincipal/WithSession bleiben für bestehende Tests. - cmd/admin-api/main.go: Pocket-ID-Middleware aktivieren, sobald -oidc-client-id, -oidc-client-secret, -oidc-redirect-url und -session-cookie-secret gesetzt sind; ohne sie bleibt das Stub-Admin-Prinzipal (bestehendes Verhalten für Dev-Mode). - Tests (10 neue, 36/36 insgesamt grün, go vet clean): httptest-Mock-Pocket-ID mit JWKS + /token-Endpoint deckt den vollständigen Flow ab, inkl. missing cookie, falsche Gruppe, Nonce/State-Mismatch, Audience-Mismatch, abgelaufenes ID-Token, unbekannte kid, und Session-Ablauf nach TTL. - README: Auth-Abschnitt + Konfigurations-Flags aktualisiert. DEV-603 Co-Authored-By: Paperclip <noreply@paperclip.ing>
14 KiB
admin-api
Admin-only HTTP service for ProofedMesh. Currently covers one concern:
Verwaltung der Per-Diem-Pauschalen (per_diem_rates), die vom
agent-per-diem-calculator deterministisch angewandt werden
(siehe docs/architecture/v1.1.md §6.2 und ADR-0003).
Status: in-progress. HTTP-Handler, HTML-Templates, Modell-Typen,
Store-Interface, Signing-Service-Client, Transparency-Log-Client,
outbound Pocket-ID-TokenSource (client_credentials mit Discovery + Cache)
und inbound Pocket-ID-Session-Cookie-Verifier (Authorization-Code + PKCE,
JWKS-Signatur-Prüfung, Erzwingung proofedmesh-admin-Gruppenmitgliedschaft)
sind vollständig und getestet (46 Go-Tests, go vet clean). Noch Stub:
Postgres-Store (pgx) — wird in Phase 2 nachgezogen. Prod-Deployment folgt
nach Phase-1-Trust-Core.
Scope
- Admin-Oberfläche
/admin/per-diem-rates— Liste + Neuanlage + „Retire" (valid_tosetzen) über HTML + HTMX-ähnlichen simple-POST-Flow. - JSON-API hinter denselben Pfaden für Automatisierung (
Accept: application/json). - Jede Create/Retire-Operation erzeugt eine Rate Change Attestation über den Signing Service — predicateType
https://basicstack.de/attestations/rate_change/v1. Rückgabe-Hash wird inper_diem_rates.rate_change_attestation_refsowie inper_diem_rate_changespersistiert. - Keine Delete-Operation. Historie ist unveränderlich (Schema-Trigger erzwingt das).
Auth
Admin-API hat zwei Auth-Richtungen und beide laufen über Pocket ID:
- Inbound (Admins → Admin-API): OIDC Authorization-Code-Flow mit PKCE (S256),
Session-Cookie. Discovery via
/.well-known/openid-configuration, Signatur- Verifikation gegen das vom OP veröffentlichte JWKS (RS256). Session-Zustand liegt in einem versiegelten AES-256-GCM-Cookieproofedmesh_admin_session(HttpOnly, SameSite=Lax,Securein Prod, TTL default 8h). Autorisierung: Gruppenmitgliedschaftproofedmesh-adminwird aus dem ID-Token gelesen und ist Pflicht für alle Endpunkte außer/loginund/callback— fehlt sie, antwortet/callbackmit 403; läuft sie nach Login weg (Policy-Entzug), kommt beim nächsten Request ein 302 nach/login. Die Middleware setztauth.WithPrincipal(sub)undauth.WithSession(sid)aufr.Context(); die Handler lesen das fürperformed_by.sub/performed_by.session_idim Attestations-Predicate. Login akzeptiert?next=/path; off-site-Redirects werden gefiltert. Admin-API ist human-only — Hintergrund-Dienste rufen die Admin-API nicht an. - Outbound (Admin-API → Signing/Transparency-Log): Pocket ID
client_credentials-Grant. Admin-API ist dort als eigener OIDC-Client registriert und erhält kurzlebige Bearer-Token. Der zentraleattest.PocketIDTokenSourcecached den Token und zieht ihn automatisch 30 s vorexpneu. Dev-Setups können den Flow mit-bearer=<static token>umgehen; produktiv wird-bearerleer gelassen und-oidc-issuer+POCKET_ID_CLIENT_ID+POCKET_ID_CLIENT_SECRETgesetzt.
HTTP-Endpunkte (geplant)
| Methode | Pfad | Beschreibung |
|---|---|---|
| GET | /admin/per-diem-rates/ |
Liste aller Rates (gefiltert nach Country, aktiv/retired) |
| GET | /admin/per-diem-rates/{rate_id} |
Detailansicht einer Rate |
| POST | /admin/per-diem-rates/ |
Neue Rate anlegen (JSON oder Form) |
| POST | /admin/per-diem-rates/{rate_id}/retire |
valid_to setzen (Rate ablaufen lassen) |
Content-Negotiation per Accept-Header: text/html → Templates, application/json → JSON.
Layout
services/admin-api/
├── README.md — diese Datei
├── go.mod
├── cmd/admin-api/main.go — HTTP-Entry (bind + router + graceful shutdown)
├── internal/
│ ├── model/rate.go — Rate struct, validation
│ ├── store/
│ │ ├── store.go — RateStore interface
│ │ ├── memory.go — in-memory impl (für Tests + Dev)
│ │ ├── memory_test.go
│ │ └── postgres.go — pgx impl, derzeit Stub
│ ├── auth/
│ │ ├── context.go — WithPrincipal/WithSession context accessors
│ │ ├── pocket_id.go — Pocket-ID OIDC-Middleware (/login, /callback, Protect)
│ │ └── pocket_id_test.go — httptest-Mock-OP (JWKS + /token) end-to-end
│ ├── httpapi/
│ │ ├── rates.go — Handler (JSON + HTML, Signer-Orchestrierung)
│ │ ├── rates_test.go — Handler-Tests gegen in-memory store
│ │ └── attest_integration_test.go — HTTP+Signer+TLog end-to-end
│ ├── attest/
│ │ ├── ratechange.go — Statement-Bau, Orchestrator SignAndLog
│ │ ├── hash.go — stabile JSON-Serialisierung + SHA-256
│ │ ├── http_signer.go — POST /api/v1/sign/dsse Client (TokenSource)
│ │ ├── http_signer_test.go — httptest, Fehlerpfade, Hash-Stabilität
│ │ ├── pocket_id_token_source.go — Pocket-ID client_credentials + Discovery + Cache
│ │ ├── pocket_id_token_source_test.go — httptest-OIDC-Mock, Cache + exp-30s Refresh
│ │ └── orchestrator_test.go — SignAndLog mit Fake-Signer/-Submitter
│ └── tlog/
│ ├── submitter.go — POST /api/v1/log/entries Client
│ └── submitter_test.go — httptest, Idempotenz, Fehlerpfade
└── web/
└── templates/per_diem_rates_{list,detail}.html
Lokal bauen
cd services/admin-api
go vet ./... && go test ./...
# Dev-Modus (keine externen Dienste nötig):
go run ./cmd/admin-api -store=memory -listen=:8080
# Dev-Modus mit echter Attestation-Pipeline (lokale Mock-Server):
go run ./cmd/admin-api -store=memory \
-signer-url=http://localhost:9001 -tlog-url=http://localhost:9002 \
-bearer=dev-token
# Prod-Modus (sobald postgres.go fertig). Credentials nur über Env,
# nicht als Flag — damit sie nicht in Prozess-Listings landen.
export POCKET_ID_CLIENT_ID=proofedmesh-admin-api
export POCKET_ID_CLIENT_SECRET=… # aus Pocket-ID OIDC-Client-Registrierung
go run ./cmd/admin-api -store=postgres \
-db="postgres://trust:…@trust-postgres:5432/trust?sslmode=verify-full" \
-signer-url=https://trust.basicstack.de -tlog-url=https://trust.basicstack.de \
-signer-key=trust-api/rate-change-signer-2026-q4 \
-oidc-issuer=https://auth.basicstack.de
Attestations-Fluss (implementiert)
Jede erfolgreiche Create/Retire-Operation ruft intern attest.Orchestrator.SignAndLog:
attest.buildStatement(event)baut ein in-toto v1 Statement mit stabilemsubject.digest(SHA-256 der kern-identifizierenden Rate-Felder, excludesvalid_toundrate_change_attestation_ref).attest.HTTPSigner.SignDSSE(ctx, keyRef, stmt)ruftPOST /api/v1/sign/dsse(ADR-0005) — gibt DSSE-Envelope +content_hashzurück.tlog.HTTPSubmitter.Submit(ctx, envelope, content_hash)ruftPOST /api/v1/log/entries— gibtlog_index+inclusion_proofzurück. Idempotent: Wiederaufruf mit demselbencontent_hashliefert dieselbe Antwort.store.AttachAttestation(rate_id, content_hash)schreibtrate_change_attestation_refin die DB.
Fehlerpfad: Scheitert Schritt 2 oder 3, bleibt die Row bestehen, aber ohne rate_change_attestation_ref. Die Row ist dann "attestation pending" — ein künftiger Reconciliation-Worker (eigenes Service, später) wiederholt Schritte 2–4 für solche Rows. Der Handler loggt den Fehler, antwortet dem Admin aber mit 201 Created, weil der eigentliche DB-Insert erfolgreich war (die Historie ist mindestens lokal konsistent).
Konfigurations-Flags
| Flag | Default | Beschreibung |
|---|---|---|
-listen |
:8080 |
HTTP-Bind |
-store |
memory |
memory oder postgres |
-signer-url |
(leer) | Signing-Service-Base-URL — leer → Attestation deaktiviert |
-tlog-url |
(leer) | Transparency-Log-Base-URL — leer → Attestation deaktiviert |
-signer-key |
trust-api/rate-change-signer-2026-q4 |
Signing-Key-Referenz (OpenBao Transit) |
-bearer |
(leer) | Statischer Pocket-ID-Token für Dev; umgeht den client_credentials-Flow |
-oidc-issuer |
https://auth.basicstack.de |
Pocket-ID-Base-URL — Discovery für beide Richtungen + performed_by.iss-Claim |
-pocket-id-scope |
(leer) | Optionaler scope im Outbound client_credentials-Request |
-pocket-id-audience |
(leer) | Optionale audience im Outbound client_credentials-Request |
-admin-oidc-client-id |
(leer) | Inbound-OIDC-Client-ID für den Admin-Login; leer → Session-Middleware deaktiviert (Stub-Admin) |
-admin-oidc-redirect-url |
(leer) | Vollqualifizierte Callback-URL für die Admin-Login-Registrierung (→ /callback) |
-admin-oidc-required-group |
proofedmesh-admin |
Pocket-ID-Gruppenname, der für /admin/*-Zugriff vorhanden sein muss |
-admin-cookie-secure |
true |
Secure-Flag für Admin-Session-Cookies; nur für Localhost-Dev deaktivieren |
Zusätzliche Env-Variablen (werden gelesen, aber nie als Flag akzeptiert, damit sie nicht in Prozess-Listings landen):
| Env | Beschreibung |
|---|---|
POCKET_ID_CLIENT_ID |
Outbound-OIDC-Client-ID (admin-api → Signing/TLog) |
POCKET_ID_CLIENT_SECRET |
Outbound-OIDC-Client-Secret |
ADMIN_OIDC_CLIENT_SECRET |
Inbound-OIDC-Client-Secret (Admin-Login) |
ADMIN_SESSION_COOKIE_SECRET |
32-Byte-AES-256-Key als Hex (64 Zeichen) oder Base64 (44 Zeichen) zum Versiegeln der Admin-Session-Cookies |
Fehlen beim Outbound POCKET_ID_CLIENT_ID/POCKET_ID_CLIENT_SECRET, aber
-oidc-issuer ist gesetzt und -bearer ist leer, bricht admin-api direkt
beim Start ab — kein stilles Weiterlaufen mit nicht authentifizierten
Signatur-Requests. Fehlen beim Inbound -admin-oidc-client-id,
-admin-oidc-redirect-url, ADMIN_OIDC_CLIENT_SECRET oder
ADMIN_SESSION_COOKIE_SECRET, läuft admin-api mit dem Stub-Admin-Prinzipal
weiter und loggt eine Warnung — nur für Dev und Tests gedacht.
Pocket-ID-Client-Registrierung für admin-api
Für den Outbound-Flow (Admin-API → Signing/Transparency-Log) muss admin-api in Pocket ID als eigener OIDC-Client angelegt werden.
Admin-UI (https://auth.basicstack.de/settings/oidc-clients):
- Name:
proofedmesh-admin-api - Grant Type:
client_credentials - Redirect URIs: leer lassen (kein Browser-Flow)
- PKCE: deaktivieren (Server-zu-Server-Flow, kein User-Agent)
- Scopes: default; falls Signing-Service später eine eigene Scope erzwingt,
hier zusätzlich eintragen und per
-pocket-id-scopeanfordern - Allowed user groups: leer (kein User-bezogener Flow)
Für den Inbound-Flow (Admin-Login → Admin-API) wird ein separater OIDC- Client angelegt:
- Name:
proofedmesh-admin-login - Grant Type:
authorization_code - Redirect URIs: Deployment-URL +
/callback(z.B.https://admin.basicstack.de/callback) - PKCE: aktiviert (S256) — der Session-Middleware-Flow erwartet PKCE
- Allowed user groups:
proofedmesh-admin(reine Policy-Vorprüfung; die Middleware prüft die Gruppenmitgliedschaft im ID-Token nochmal selbst)
Alternativ via API, siehe
skills/pocket-id-api und POST /api/oidc/clients:
# Outbound (admin-api als Service-Client)
curl -X POST "https://auth.basicstack.de/api/oidc/clients" \
-H "X-API-Key: $POCKET_ID_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "proofedmesh-admin-api",
"callbackUrls": [],
"pkceEnabled": false,
"isPublic": false,
"credentialsConfidentialClient": true
}'
# Inbound (admin-login für Menschen)
curl -X POST "https://auth.basicstack.de/api/oidc/clients" \
-H "X-API-Key: $POCKET_ID_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "proofedmesh-admin-login",
"callbackUrls": ["https://admin.basicstack.de/callback"],
"pkceEnabled": true,
"isPublic": false,
"credentialsConfidentialClient": true
}'
Die generierten clientId + clientSecret landen danach als Secrets in der
Deployment-Pipeline und werden dem admin-api-Container als
POCKET_ID_CLIENT_ID / POCKET_ID_CLIENT_SECRET (outbound) bzw. als
-admin-oidc-client-id + ADMIN_OIDC_CLIENT_SECRET (inbound) injiziert.
Die Secret-Felder sind NICHT als -flag konfigurierbar, damit sie nicht in
Prozess-Listings landen.
Rate Change Attestation (Entwurf)
{
"_type": "https://in-toto.io/Statement/v1",
"subject": [
{ "name": "per_diem_rate/PDR-42", "digest": { "sha256": "<content hash>" } }
],
"predicateType": "https://basicstack.de/attestations/rate_change/v1",
"predicate": {
"change_kind": "create",
"rate_id": "PDR-42",
"country": "DE",
"city": null,
"currency": "EUR",
"full_day": "28.00",
"partial_day_min8h": "14.00",
"arrival_day": "14.00",
"departure_day": "14.00",
"valid_from": "2026-01-01",
"valid_to": null,
"source_regulation": "BMF-Schreiben …",
"performed_by": {
"iss": "https://auth.basicstack.de",
"sub": "user-admin-42",
"session_id": "SES-admin-…"
},
"performed_at": "2026-10-25T18:30:00Z"
}
}
Signiert vom Signing Service, Attestations-Hash wandert zurück in die Tabellen per_diem_rates.rate_change_attestation_ref und per_diem_rate_changes.rate_change_attestation_ref.