ProofedMesh/services/admin-api/README.md
CTO (Paperclip) be3ac9cdb8
All checks were successful
validate / JSON Schema (ajv) (push) Successful in 20s
validate / Go (vet + test) (push) Successful in 19s
validate / Rego (opa fmt + opa test) (push) Successful in 13s
admin-api: Pocket-ID client_credentials TokenSource with OIDC discovery + cache (replaces StaticToken in prod)
* services/admin-api/internal/attest/pocket_id_token_source.go — attest.TokenSource impl: OIDC discovery via /.well-known/openid-configuration, HTTP Basic client_credentials grant, mutex-guarded in-memory token cache with configurable RefreshLeeway (default 30s = "verwirft bei exp-30s" from DEV-602).
* services/admin-api/internal/attest/pocket_id_token_source_test.go — 10 subtests against an httptest OIDC mock: happy path, cache hold, exp-30s refresh boundary, optional scope/audience, bad creds (401), missing config, discovery failures, non-positive expires_in, type-conformance.
* services/admin-api/cmd/admin-api/main.go — buildTokenSource(): -bearer flag wins (dev), else -oidc-issuer + POCKET_ID_CLIENT_ID + POCKET_ID_CLIENT_SECRET env → PocketIDTokenSource (prod); hard-fail at startup if issuer is set but credentials are missing. New flags -pocket-id-scope / -pocket-id-audience for the client_credentials request.
* services/admin-api/README.md — split inbound/outbound Pocket-ID auth, document the admin-api OIDC client registration (UI + API), new flags, env vars, prod bootstrap example.

36/36 go tests pass, go vet clean.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-25 18:04:00 +00:00

211 lines
11 KiB
Markdown
Raw 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** und
**Pocket-ID-TokenSource** (`client_credentials` mit Discovery + Cache)
sind vollständig und getestet (36 Go-Tests, go vet clean). Noch Stubs:
Postgres-Store (`pgx`) und die inbound Pocket-ID-Auth-Middleware
(Admin-Session-Cookie) — werden 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 + Session-Cookie.
Autorisierung: Gruppenmitgliedschaft `proofedmesh-admin` wird aus dem ID-Token
gelesen und ist Pflicht für alle Endpunkte außer Login. 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
│ ├── 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 `client_credentials`-Flow + Issuer-Claim für `performed_by.iss` |
| `-pocket-id-scope` | *(leer)* | Optionaler `scope` im `client_credentials`-Request |
| `-pocket-id-audience` | *(leer)* | Optionale `audience` im `client_credentials`-Request |
Zusätzliche Env-Variablen (werden nur im `client_credentials`-Flow gelesen,
also wenn `-bearer` leer ist und `-oidc-issuer` gesetzt ist):
| Env | Beschreibung |
| -------------------------- | ------------ |
| `POCKET_ID_CLIENT_ID` | OIDC-Client-ID aus der admin-api-Registrierung in Pocket ID |
| `POCKET_ID_CLIENT_SECRET` | OIDC-Client-Secret aus der admin-api-Registrierung in Pocket ID |
Fehlt eins der beiden, aber `-oidc-issuer` ist gesetzt und `-bearer` ist leer,
bricht admin-api direkt beim Start ab — kein stilles Weiterlaufen mit nicht
authentifizierten Signatur-Requests.
## 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)
Alternativ via API, siehe
[`skills/pocket-id-api`](../../docs/) und `POST /api/oidc/clients`:
```bash
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
}'
```
Die `clientId` und das generierte `clientSecret` landen danach als Secrets
in der Deployment-Pipeline und werden dem admin-api-Container als
`POCKET_ID_CLIENT_ID` / `POCKET_ID_CLIENT_SECRET` injiziert. Die Felder sind
NICHT in `-flags` 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`.