- 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>
262 lines
14 KiB
Markdown
262 lines
14 KiB
Markdown
# 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`.
|