admin-api: wire Signer+TLog into Create/Retire handlers; on tlog outage row stays attestation-pending (26/26 tests pass, go vet clean)
All checks were successful
validate / Rego (opa fmt + opa test) (push) Successful in 13s
validate / JSON Schema (ajv) (push) Successful in 20s
validate / Go (vet + test) (push) Successful in 19s

This commit is contained in:
CTO Agent 2026-10-25 17:51:53 +00:00
parent cacdf7e286
commit ca62108f70
4 changed files with 278 additions and 23 deletions

View file

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

View file

@ -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()

View file

@ -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)
}
}
}

View file

@ -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 ""
}