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

262 lines
14 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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`](./internal/attest/pocket_id_token_source.go)
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
```bash
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`](../../docs/) und `POST /api/oidc/clients`:
```bash
# 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)
```json
{
"_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`.