2026-10-25 17:28:14 +00:00
# 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).
2026-10-25 17:51:53 +00:00
**Status: in-progress.** HTTP-Handler, HTML-Templates, Modell-Typen,
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:03:48 +00:00
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.
2026-10-25 17:28:14 +00:00
## 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: 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:03:48 +00:00
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.
2026-10-25 17:28:14 +00:00
## 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/
2026-10-25 17:51:53 +00:00
│ │ ├── 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
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:03:48 +00:00
│ │ ├── http_signer.go — POST /api/v1/sign/dsse Client (TokenSource)
2026-10-25 17:51:53 +00:00
│ │ ├── http_signer_test.go — httptest, Fehlerpfade, Hash-Stabilität
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:03:48 +00:00
│ │ ├── pocket_id_token_source.go — Pocket-ID client_credentials + Discovery + Cache
│ │ ├── pocket_id_token_source_test.go — httptest-OIDC-Mock, Cache + exp-30s Refresh
2026-10-25 17:51:53 +00:00
│ │ └── orchestrator_test.go — SignAndLog mit Fake-Signer/-Submitter
│ └── tlog/
│ ├── submitter.go — POST /api/v1/log/entries Client
│ └── submitter_test.go — httptest, Idempotenz, Fehlerpfade
2026-10-25 17:28:14 +00:00
└── web/
2026-10-25 17:51:53 +00:00
└── templates/per_diem_rates_{list,detail}.html
2026-10-25 17:28:14 +00:00
```
2026-10-25 17:51:53 +00:00
## Lokal bauen
2026-10-25 17:28:14 +00:00
```bash
cd services/admin-api
2026-10-25 17:51:53 +00:00
go vet ./... & & go test ./...
# Dev-Modus (keine externen Dienste nötig):
2026-10-25 17:28:14 +00:00
go run ./cmd/admin-api -store=memory -listen=:8080
2026-10-25 17:51:53 +00:00
# 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
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:03:48 +00:00
# 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
2026-10-25 17:28:14 +00:00
go run ./cmd/admin-api -store=postgres \
2026-10-25 17:51:53 +00:00
-db="postgres://trust:…@trust -postgres:5432/trust?sslmode=verify-full" \
-signer-url=https://trust.basicstack.de -tlog-url=https://trust.basicstack.de \
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:03:48 +00:00
-signer-key=trust-api/rate-change-signer-2026-q4 \
-oidc-issuer=https://auth.basicstack.de
2026-10-25 17:28:14 +00:00
```
2026-10-25 17:51:53 +00:00
## 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
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:03:48 +00:00
| 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.
2026-10-25 17:51:53 +00:00
2026-10-25 17:28:14 +00:00
## 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` .