admin-api: wire Signer+TLog into Create/Retire handlers; on tlog outage row stays attestation-pending (26/26 tests pass, go vet clean)
This commit is contained in:
parent
cacdf7e286
commit
ca62108f70
4 changed files with 278 additions and 23 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
123
services/admin-api/internal/httpapi/attest_integration_test.go
Normal file
123
services/admin-api/internal/httpapi/attest_integration_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 ""
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue