ProofedMesh/services/admin-api/README.md
CTO (Paperclip) 9f1eb84032
All checks were successful
validate / Go (vet + test) (push) Successful in 23s
validate / Rego (opa fmt + opa test) (push) Successful in 15s
validate / JSON Schema (ajv) (push) Successful in 20s
admin-api: Pocket-ID session-cookie verifier (OIDC Authorization-Code + PKCE, JWKS, group check)
- 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>
2026-10-25 18:10:02 +00:00

14 KiB
Raw Blame History

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_to setzen) ü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 in per_diem_rates.rate_change_attestation_ref sowie in per_diem_rate_changes persistiert.
  • 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-Cookie proofedmesh_admin_session (HttpOnly, SameSite=Lax, Secure in Prod, TTL default 8h). Autorisierung: Gruppenmitgliedschaft proofedmesh-admin wird aus dem ID-Token gelesen und ist Pflicht für alle Endpunkte außer /login und /callback — fehlt sie, antwortet /callback mit 403; läuft sie nach Login weg (Policy-Entzug), kommt beim nächsten Request ein 302 nach /login. Die Middleware setzt auth.WithPrincipal(sub) und auth.WithSession(sid) auf r.Context(); die Handler lesen das für performed_by.sub/performed_by.session_id im 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 zentrale attest.PocketIDTokenSource cached den Token und zieht ihn automatisch 30 s vor exp neu. Dev-Setups können den Flow mit -bearer=<static token> umgehen; produktiv wird -bearer leer gelassen und -oidc-issuer + POCKET_ID_CLIENT_ID + POCKET_ID_CLIENT_SECRET gesetzt.

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:

  1. attest.buildStatement(event) baut ein in-toto v1 Statement mit stabilem subject.digest (SHA-256 der kern-identifizierenden Rate-Felder, excludes valid_to und rate_change_attestation_ref).
  2. attest.HTTPSigner.SignDSSE(ctx, keyRef, stmt) ruft POST /api/v1/sign/dsse (ADR-0005) — gibt DSSE-Envelope + content_hash zurück.
  3. tlog.HTTPSubmitter.Submit(ctx, envelope, content_hash) ruft POST /api/v1/log/entries — gibt log_index + inclusion_proof zurück. Idempotent: Wiederaufruf mit demselben content_hash liefert dieselbe Antwort.
  4. store.AttachAttestation(rate_id, content_hash) schreibt rate_change_attestation_ref in 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-scope anfordern
  • 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.