ProofedMesh/services/admin-api
CTO 951f22f80e
Some checks failed
build-images / signing-service image (push) Failing after 14s
validate / JSON Schema (ajv) (push) Successful in 21s
validate / Rego (opa fmt + opa test) (push) Successful in 14s
validate / Go (vet + test) (push) Successful in 30s
transparency-log: real Trillian backend wiring + agent-trust deploy (DEV-604)
- services/transparency-log/pkg/backend/trillian.go: real gRPC client
  on github.com/google/trillian v1.6.1. Submit() does QueueLeaf with the
  canonical DSSE envelope as LeafValue and the raw content_hash bytes as
  LeafIdentityHash, then polls GetLatestSignedLogRoot +
  GetInclusionProofByHash until the leaf is sequenced. Idempotency uses
  Trillian's ALREADY_EXISTS signal on the inner QueuedLogLeaf.Status, so
  a repeated submit still returns 200 OK with the originally-assigned
  log_index + a valid proof against the current STH.
- Unconfigured backend (missing endpoint or LogID) returns the historical
  ErrTrillianUnavailable so the httpapi layer keeps mapping it to 503
  instead of silently 500. Existing stub-returns-503 test updated.
- cmd/transparency-log: pointer-receiver Trillian wiring; flag docs
  mention the production SRV name.
- services/transparency-log/Dockerfile: distroless multi-stage build
  used by deploy/trillian/40-transparency-log.yaml.

- deploy/trillian/: full K8s stack for the agent-trust namespace —
  MySQL 8.4 StatefulSet (schema init ConfigMap vendored from upstream
  storage.sql v1.6.1), Trillian LogServer + LogSigner Deployments on
  gcr.io/trillian-opensource-ci:v1.6.1, createtree Job that writes
  the TreeID to a ConfigMap (re-apply safe), transparency-log
  Deployment with --backend=trillian pulling TRILLIAN_LOG_ID from
  the ConfigMap.
- deploy/trillian/README.md: first-time apply + smoke-test + secret
  rotation + signer-key clarification (STH key is Trillian-internal;
  DSSE envelope signing stays with signing-service / OpenBao).

- services/admin-api: go.mod / go.sum pick up the new transitive deps
  (trillian + grpc) via the local replace on transparency-log.
- services/transparency-log/README.md: Backends table updated; next-
  steps list no longer carries Trillian-deployment as a follow-up.

Deployed to the live cluster from this heartbeat: MySQL + LogServer +
LogSigner running in agent-trust, tree id 8482455225499059043, service
smoke-tested end-to-end (201 on first POST, 200 on idempotent repeat,
409 on content_hash mismatch, tree_size=2 with correct audit path).

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-25 18:39:44 +00:00
..
cmd/admin-api admin-api: Pocket-ID session-cookie verifier (OIDC Authorization-Code + PKCE, JWKS, group check) 2026-10-25 18:10:02 +00:00
internal signing-service: public pkg/httpapi test surface + admin-api interop test (DEV-599) 2026-10-25 18:11:40 +00:00
web/templates services: add admin-api Go service (per-diem rates CRUD, in-memory + Postgres stub store, HTML templates, 13/13 go tests pass, go vet clean) 2026-10-25 17:28:14 +00:00
admin-api services: add admin-api Go service (per-diem rates CRUD, in-memory + Postgres stub store, HTML templates, 13/13 go tests pass, go vet clean) 2026-10-25 17:28:14 +00:00
go.mod transparency-log: real Trillian backend wiring + agent-trust deploy (DEV-604) 2026-10-25 18:39:44 +00:00
go.sum transparency-log: real Trillian backend wiring + agent-trust deploy (DEV-604) 2026-10-25 18:39:44 +00:00
README.md admin-api: Pocket-ID session-cookie verifier (OIDC Authorization-Code + PKCE, JWKS, group check) 2026-10-25 18:10:02 +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).

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 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

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 und POST /api/oidc/clients:

# 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)

{
  "_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.