# 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=` 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": "" } } ], "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`.