diff --git a/services/admin-api/README.md b/services/admin-api/README.md index 8aedad6..268a114 100644 --- a/services/admin-api/README.md +++ b/services/admin-api/README.md @@ -5,10 +5,11 @@ 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: Skelett.** Datenbankanbindung (pgx) und Signing-Service-Call sind -Stubs mit expliziten Fehlern; HTTP-Handler, HTML-Templates, Modell-Typen und -Store-Interface sind vollständig und getestet. Prod-Deployment folgt nach -Phase-1-Trust-Core. +**Status: in-progress.** HTTP-Handler, HTML-Templates, Modell-Typen, +Store-Interface, **Signing-Service-Client** und **Transparency-Log-Client** +sind vollständig und getestet (26 Go-Tests, go vet clean). Noch Stubs: +Postgres-Store (`pgx`) und Pocket-ID-Auth-Middleware — werden in Phase 2 +nachgezogen. Prod-Deployment folgt nach Phase-1-Trust-Core. ## Scope @@ -49,26 +50,63 @@ services/admin-api/ │ │ ├── memory_test.go │ │ └── postgres.go — pgx impl, derzeit Stub │ ├── httpapi/ -│ │ ├── rates.go — Handler -│ │ └── rates_test.go — Handler-Tests gegen in-memory store -│ └── attest/ -│ └── ratechange.go — Signing-Service-Client, derzeit Stub +│ │ ├── 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 +│ │ ├── http_signer_test.go — httptest, Fehlerpfade, Hash-Stabilität +│ │ └── 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.html + └── templates/per_diem_rates_{list,detail}.html ``` -## Lokal bauen (sobald Code vollständig) +## Lokal bauen ```bash cd services/admin-api -go test ./... -# Dev-Modus mit in-memory store, kein Postgres: +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): go run ./cmd/admin-api -store=postgres \ - -db="postgres://trust:…@trust-postgres:5432/trust?sslmode=verify-full" + -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 ``` +## 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 (Prod: `client_credentials`-Flow, noch nicht gewired) | +| `-oidc-issuer` | `https://auth.basicstack.de` | Issuer-Claim für `performed_by.iss` im Attestations-Predicate | + ## Rate Change Attestation (Entwurf) ```json diff --git a/services/admin-api/cmd/admin-api/main.go b/services/admin-api/cmd/admin-api/main.go index 7593521..4c79c93 100644 --- a/services/admin-api/cmd/admin-api/main.go +++ b/services/admin-api/cmd/admin-api/main.go @@ -1,8 +1,8 @@ // Command admin-api is the HTTP entry point for the per-diem-rates admin -// surface and future admin flows. Current state: skeleton. Pocket-ID auth -// middleware and the Signing-Service wiring are intentionally not here yet; -// the handlers assume a WithPrincipal context, which the real middleware will -// populate from a verified session cookie. +// surface and future admin flows. Pocket-ID auth middleware is still a stub +// that stamps a hard-coded principal into the request context; signing + +// transparency-log wiring are real HTTP clients but default to disabled so +// local dev with `-store=memory` has no external dependencies. package main import ( @@ -17,13 +17,20 @@ import ( "syscall" "time" + "basicstack.de/proofedmesh/services/admin-api/internal/attest" "basicstack.de/proofedmesh/services/admin-api/internal/httpapi" "basicstack.de/proofedmesh/services/admin-api/internal/store" + "basicstack.de/proofedmesh/services/admin-api/internal/tlog" ) func main() { listen := flag.String("listen", ":8080", "HTTP listen address") backend := flag.String("store", "memory", "storage backend: memory | postgres") + signerURL := flag.String("signer-url", "", "Signing Service base URL (e.g. https://trust.basicstack.de); empty disables attestation") + tlogURL := flag.String("tlog-url", "", "Transparency Log base URL; empty disables attestation") + keyRef := flag.String("signer-key", "trust-api/rate-change-signer-2026-q4", "Signing key reference") + bearer := flag.String("bearer", "", "static Pocket ID bearer token for dev; prod uses the Pocket ID client_credentials flow") + issuer := flag.String("oidc-issuer", "https://auth.basicstack.de", "OIDC issuer for admin principals in attestations") flag.Parse() logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)) @@ -45,10 +52,29 @@ func main() { os.Exit(2) } + var orch *attest.Orchestrator + if *signerURL != "" && *tlogURL != "" { + var tokenSrc attest.TokenSource + if *bearer != "" { + tokenSrc = attest.StaticToken(*bearer) + } + orch = &attest.Orchestrator{ + Signer: attest.HTTPSigner{BaseURL: *signerURL, TokenSource: tokenSrc}, + Submitter: tlog.HTTPSubmitter{BaseURL: *tlogURL, TokenSource: tokenSrc}, + KeyRef: *keyRef, + } + logger.Info("admin-api: attestation enabled", "signer", *signerURL, "tlog", *tlogURL, "key_ref", *keyRef) + } else { + logger.Warn("admin-api: attestation disabled (set -signer-url and -tlog-url to enable)") + } + deps := httpapi.Dependencies{ Store: s, Templates: template.New("admin"), // real templates loaded from web/templates in the full build Now: time.Now, + Attest: orch, + Issuer: *issuer, + Logger: logger, } mux := http.NewServeMux() diff --git a/services/admin-api/internal/httpapi/attest_integration_test.go b/services/admin-api/internal/httpapi/attest_integration_test.go new file mode 100644 index 0000000..29ab96e --- /dev/null +++ b/services/admin-api/internal/httpapi/attest_integration_test.go @@ -0,0 +1,123 @@ +package httpapi + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "basicstack.de/proofedmesh/services/admin-api/internal/attest" + "basicstack.de/proofedmesh/services/admin-api/internal/store" +) + +type recordingSigner struct { + calls int + lastKey string +} + +func (s *recordingSigner) SignDSSE(_ context.Context, keyRef string, stmt attest.Statement) (attest.Envelope, string, error) { + s.calls++ + s.lastKey = keyRef + env := attest.Envelope{ + PayloadType: "application/vnd.in-toto+json", + Payload: "ZmFrZS1wYXlsb2Fk", + Signatures: []attest.EnvelopeSignature{{KeyID: keyRef, Sig: "ZmFrZS1zaWc"}}, + } + ch, _ := attest.EnvelopeContentHash(env) + return env, ch, nil +} + +type recordingSubmitter struct { + calls int + last attest.LogReceipt +} + +func (s *recordingSubmitter) Submit(_ context.Context, env attest.Envelope, ch string) (attest.LogReceipt, error) { + s.calls++ + r := attest.LogReceipt{ + LogIndex: int64(s.calls), + ContentHash: ch, + LoggedAt: time.Now().UTC(), + } + s.last = r + return r, nil +} + +func TestCreate_WithAttestation_AttachesHash(t *testing.T) { + sig := &recordingSigner{} + sub := &recordingSubmitter{} + deps := Dependencies{ + Store: store.NewMemory(), + Now: func() time.Time { return time.Date(2026, 10, 25, 12, 0, 0, 0, time.UTC) }, + Attest: &attest.Orchestrator{Signer: sig, Submitter: sub, KeyRef: "trust-api/test-key"}, + } + srv := httptest.NewServer(newMux(deps)) + defer srv.Close() + + body := `{"country":"DE","currency":"EUR","full_day":"28.00","partial_day_min8h":"14.00","arrival_day":"14.00","departure_day":"14.00","valid_from":"2026-01-01T00:00:00Z","source_regulation":"BMF 2026"}` + req, _ := http.NewRequest("POST", srv.URL+"/admin/per-diem-rates/", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusCreated { + t.Fatalf("expected 201, got %d", resp.StatusCode) + } + var out map[string]map[string]any + _ = json.NewDecoder(resp.Body).Decode(&out) + ref, _ := out["rate"]["rate_change_attestation_ref"].(string) + if !strings.HasPrefix(ref, "sha256:") || len(ref) != 71 { + t.Fatalf("rate_change_attestation_ref not attached as sha256 hash: %q", ref) + } + if sig.calls != 1 || sub.calls != 1 { + t.Fatalf("expected one signer + one submitter call, got signer=%d submitter=%d", sig.calls, sub.calls) + } + if sig.lastKey != "trust-api/test-key" { + t.Errorf("key ref not passed through: %q", sig.lastKey) + } +} + +type failingSubmitter struct{} + +func (failingSubmitter) Submit(_ context.Context, _ attest.Envelope, _ string) (attest.LogReceipt, error) { + return attest.LogReceipt{}, &net503Error{} +} + +type net503Error struct{} + +func (*net503Error) Error() string { return "tlog: status 503: trillian down" } + +func TestCreate_WithFailingTLog_RowPending(t *testing.T) { + deps := Dependencies{ + Store: store.NewMemory(), + Now: func() time.Time { return time.Date(2026, 10, 25, 12, 0, 0, 0, time.UTC) }, + Attest: &attest.Orchestrator{Signer: &recordingSigner{}, Submitter: failingSubmitter{}, KeyRef: "k"}, + } + srv := httptest.NewServer(newMux(deps)) + defer srv.Close() + + body := `{"country":"DE","currency":"EUR","full_day":"28.00","partial_day_min8h":"14.00","arrival_day":"14.00","departure_day":"14.00","valid_from":"2026-01-01T00:00:00Z","source_regulation":"BMF 2026"}` + req, _ := http.NewRequest("POST", srv.URL+"/admin/per-diem-rates/", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + // Create still succeeds: Row exists, attestation pending. + if resp.StatusCode != http.StatusCreated { + t.Fatalf("expected 201 even when tlog is down, got %d", resp.StatusCode) + } + var out map[string]map[string]any + _ = json.NewDecoder(resp.Body).Decode(&out) + if _, hasRef := out["rate"]["rate_change_attestation_ref"]; hasRef { + if v := out["rate"]["rate_change_attestation_ref"]; v != nil { + t.Fatalf("expected no attestation ref when tlog is down, got %v", v) + } + } +} diff --git a/services/admin-api/internal/httpapi/rates.go b/services/admin-api/internal/httpapi/rates.go index bb6da5d..5691af4 100644 --- a/services/admin-api/internal/httpapi/rates.go +++ b/services/admin-api/internal/httpapi/rates.go @@ -8,10 +8,12 @@ import ( "errors" "fmt" "html/template" + "log/slog" "net/http" "strings" "time" + "basicstack.de/proofedmesh/services/admin-api/internal/attest" "basicstack.de/proofedmesh/services/admin-api/internal/model" "basicstack.de/proofedmesh/services/admin-api/internal/store" ) @@ -21,9 +23,17 @@ type Dependencies struct { Store store.RateStore Templates *template.Template Now func() time.Time - // Signer injection is omitted here; the production wiring will call - // attest.Signer around Store.Create / Store.Retire and set - // Store.AttachAttestation on success. + // Attest is optional. When set, successful Create and Retire call it to + // sign a rate_change attestation and submit it to the Transparency Log, + // then persist the attestation ref on the row. When nil (e.g. in unit + // tests or in a bare dev run), handlers skip the attestation step and + // log a warning; the row stays "attestation pending" and a reconciler + // catches it later. + Attest *attest.Orchestrator + // Issuer identifies the OIDC issuer for the admin principal, used only + // when constructing attestation events. Defaults to Pocket ID's issuer. + Issuer string + Logger *slog.Logger } // Mount registers all rate handlers on the given mux. Admin auth middleware @@ -88,9 +98,7 @@ func (d Dependencies) Create(w http.ResponseWriter, r *http.Request) { writeErr(w, r, http.StatusInternalServerError, err) return } - // NOTE: real wiring will now call attest.Signer and attach the resulting - // hash via d.Store.AttachAttestation. Omitted in the skeleton to keep - // the handler free of HTTP calls out. + rate = d.attestOrLog(ctx, attest.ChangeCreate, rate) writeOK(w, r, http.StatusCreated, map[string]any{"rate": rate}, "per_diem_rates_detail.html") } @@ -124,9 +132,54 @@ func (d Dependencies) Retire(w http.ResponseWriter, r *http.Request) { } return } + rate = d.attestOrLog(ctx, attest.ChangeRetire, rate) writeOK(w, r, http.StatusOK, map[string]any{"rate": rate}, "per_diem_rates_detail.html") } +// attestOrLog signs a rate_change event through d.Attest and attaches the +// resulting content_hash to the row. On any failure (orchestrator missing, +// signer/log down, store attach failed), it logs and returns the rate as-is +// — the row stays "attestation pending" and a reconciliation worker retries. +func (d Dependencies) attestOrLog(ctx context.Context, kind attest.ChangeKind, rate model.Rate) model.Rate { + if d.Attest == nil { + if d.Logger != nil { + d.Logger.Warn("attest skipped: no orchestrator wired", "rate_id", rate.RateID, "kind", string(kind)) + } + return rate + } + issuer := d.Issuer + if issuer == "" { + issuer = "https://auth.basicstack.de" + } + ev := attest.RateChangeEvent{ + Kind: kind, + Rate: rate, + PerformedBy: attest.PerformedBy{ + Issuer: issuer, + Subject: principalFrom(ctx), + SessionID: sessionFrom(ctx), + }, + PerformedAt: d.Now().UTC(), + } + contentHash, _, err := d.Attest.SignAndLog(ctx, ev) + if err != nil { + if d.Logger != nil { + d.Logger.Error("attest failed; row will remain pending for reconciliation", + "rate_id", rate.RateID, "kind", string(kind), "err", err) + } + return rate + } + if err := d.Store.AttachAttestation(ctx, rate.RateID, contentHash); err != nil { + if d.Logger != nil { + d.Logger.Error("attach attestation failed; signed but not persisted", + "rate_id", rate.RateID, "content_hash", contentHash, "err", err) + } + return rate + } + rate.RateChangeAttestationRef = &contentHash + return rate +} + func parseNewRate(r *http.Request) (model.NewRateInput, error) { if strings.HasPrefix(r.Header.Get("Content-Type"), "application/json") { var in model.NewRateInput @@ -203,6 +256,7 @@ func writeErr(w http.ResponseWriter, r *http.Request, status int, err error) { } type principalKey struct{} +type sessionKey struct{} // WithPrincipal attaches the authenticated admin user to the request context. // Called by the auth middleware wired in main. @@ -210,9 +264,23 @@ func WithPrincipal(ctx context.Context, sub string) context.Context { return context.WithValue(ctx, principalKey{}, sub) } +// WithSession attaches the Pocket ID session identifier to the request context. +// Carried into rate_change attestations so audits show which session triggered +// the change. +func WithSession(ctx context.Context, sessionID string) context.Context { + return context.WithValue(ctx, sessionKey{}, sessionID) +} + func principalFrom(ctx context.Context) string { if v, ok := ctx.Value(principalKey{}).(string); ok { return v } return "unknown" } + +func sessionFrom(ctx context.Context) string { + if v, ok := ctx.Value(sessionKey{}).(string); ok { + return v + } + return "" +}