ProofedMesh/services/approval-service
CTO (Paperclip) 20ba29d3ac
All checks were successful
build-images / signing-service image (push) Successful in 26s
build-images / verification-service image (push) Successful in 27s
build-images / approval-service image (push) Successful in 31s
build-images / dummy-sink image (push) Successful in 29s
validate / JSON Schema (ajv) (push) Successful in 23s
validate / Rego (opa fmt + opa test) (push) Successful in 16s
phase1-smoke / Phase 1 end-to-end smoke (push) Successful in 1m0s
validate / DB (migrate up/down dry-run) (push) Successful in 26s
validate / Go (vet + test) (push) Successful in 1m20s
approval-service: K8s deployment manifests + Pocket-ID OIDC client (DEV-614)
Phase-1 Subtask 8/10 (parent DEV-606):
- deploy/approval-service/: SA (no auto-mount), Deployment (replicas=2,
  distroless-nonroot, read-only rootfs), ClusterIP :8080, NetworkPolicy
  (ingress from pangolin namespace only; egress to signing-service,
  transparency-log, trust-pg in agent-trust + pocket-id + DNS), README.
- 10-secret-dev.example.yaml template + the real 10-secret-dev.sealed.yaml
  carrying the Pocket-ID client id/secret, WebAuthn RP config, and the
  dev signing-service bearer; unsealed in-cluster to Secret
  approval-service-dev.
- services/approval-service/Dockerfile: BuildKit multi-stage, context-at-
  repo-root, matches the signing/verification/dummy-sink pattern.
- .forgejo/workflows/build-images.yml: new approval-service job pushing
  harbor.basicstack.de/basicstack/approval-service:{sha,main} on main.
- Pocket-ID: created proofedmesh-admin group + OIDC client
  "ProofedMesh Approval" with PKCE enforced, callback
  https://approval.proofedmesh.basicstack.de/oidc/callback, group
  restriction to proofedmesh-admin. Client secret stored as Paperclip
  secret proposal and in the SealedSecret above — never pasted inline.

Deployment applied cluster-side pending the first CI image build:
`kubectl apply -f deploy/approval-service/` is green; pods ErrImagePull
until the Forgejo workflow completes and pushes the :main tag.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-25 21:12:01 +00:00
..
cmd/approval-service approval-service: dev auth/credentials routes + predicate chain-control fields (DEV-613) 2026-10-25 20:58:03 +00:00
internal approval-service: dev auth/credentials routes + predicate chain-control fields (DEV-613) 2026-10-25 20:58:03 +00:00
pkg/httpapi approval-service: Phase-1 content-binding approval service (DEV-608) 2026-10-25 19:42:06 +00:00
Dockerfile approval-service: K8s deployment manifests + Pocket-ID OIDC client (DEV-614) 2026-10-25 21:12:01 +00:00
go.mod approval-service: Phase-1 content-binding approval service (DEV-608) 2026-10-25 19:42:06 +00:00
README.md approval-service: Phase-1 content-binding approval service (DEV-608) 2026-10-25 19:42:06 +00:00

approval-service

Phase 1 der ProofedMesh content-gebundenen Approvals (DEV-608, siehe docs/architecture/v1.1.md §4.3). Der Dienst nimmt menschliche Freigaben an, bindet sie kryptographisch an einen deterministisch gerenderten JSON-Inhalt (H_content = sha256(canonical_json)), verifiziert eine WebAuthn- Assertion gegen die Challenge nonce || H_content und erzeugt daraus eine DSSE-signierte Approval-Attestation, die ins Transparency-Log eingetragen wird.

Endpoints

Alle Routen sind JSON-API, Pocket-ID-Session-Cookie-geschützt.

Methode Pfad Zweck
POST /api/v1/sessions Startet eine Approval-Session. Antwort: {session_id, active_until}.
POST /api/v1/approvals/preview Rendert Freigabe-Inhalt serverseitig, berechnet H_content, legt Challenge und Prüfziffer ab.
POST /api/v1/approvals/confirm Nimmt die WebAuthn-Assertion entgegen, signiert und loggt die Approval.
GET /healthz Liveness.

POST /api/v1/sessions

Erzeugt eine Approval-Session an die aktuelle Pocket-ID-sub. active_until ist in Phase 1 fest auf SessionTTL (Default 15 min).

POST /api/v1/approvals/preview

Request:

{
  "session_id": "sess_…",
  "content": { "...application-defined..." }
}

Response:

{
  "preview_id": "pv_…",
  "content_hash": "sha256:<hex>",
  "verification_digits": "734219",
  "webauthn_challenge": "<b64url>",
  "expires_at": "2026-01-01T12:00:00Z"
}

verification_digits ist eine 6-stellige Prüfziffer aus H_content, die der Mensch im UI vor der Freigabe mit der Signaturaufforderung abgleichen MUSS. webauthn_challenge ist bereits nonce || H_content (b64url), damit der Browser direkt eine WebAuthn-Assertion anfordern kann.

POST /api/v1/approvals/confirm

Request:

{
  "preview_id": "pv_…",
  "assertion": {
    "credential_id": "<b64url>",
    "client_data_json": "<b64url>",
    "authenticator_data": "<b64url>",
    "signature": "<b64url>"
  }
}

Response:

{
  "approval_id": "ap_…",
  "attestation_ref": "sha256:<hex>",
  "log_index": 42,
  "tlog_status": "logged"
}

Wenn der TLog-Submit scheitert, bleibt die Row tlog_status = "attestation-pending"; die Antwort meldet dann log_index: null, tlog_status: "attestation-pending". Ein späterer Reconciler zieht den Submit nach (ADR-0005, idempotent per content_hash).

Fehler

Zustand Code Body error.code
Session abgelaufen / unbekannt 403 session_expired
H_content-Mismatch (Assertion vs. Preview) 400 content_hash_mismatch
Challenge abgelaufen 403 challenge_expired
WebAuthn-Assertion ungültig 401 assertion_invalid
Signing-Service nicht erreichbar 502 signer_unavailable

Phase 1 Scope

  • Store ist in-memory (internal/store/memory.go); Postgres-Impl ist in internal/store/postgres.go als Skelett angelegt (Pattern aus admin-api/internal/store/postgres.go). Die SQL-Migration liegt bereits in db/trust/migrations/0004_approval_sessions.*.sql.
  • WebAuthn-Verifikation ist eine stdlib-only Shim im Stil von admin-api/internal/auth/pocket_id.go: In-Memory-Credential-Store, Ed25519-Assertion über authenticator_data || sha256(client_data_json), Challenge-Vergleich konstantzeitig. Die Datenformate sind WebAuthn-kompatibel (b64url-encoded client_data_json mit challenge, type, origin), damit das Phase-2-Umstieg auf github.com/go-webauthn/webauthn ohne API-Bruch möglich bleibt.
  • Keine HTML/UI; nur JSON. Browser-Integration landet in einem Folge-Task.

Konfiguration

Alle über Flags oder Env (APPROVAL_*):

Env / Flag Default Zweck
APPROVAL_LISTEN :8080 HTTP-Listen-Adresse.
APPROVAL_SIGNING_URL (required) Base URL des signing-service (/api/v1/sign/dsse).
APPROVAL_SIGNING_KEY_REF trust-api/approval-signer-2026-q4 DSSE-Signaturschlüssel.
APPROVAL_SIGNING_TOKEN (optional) Bearer-Token für signing-service (Phase 1 Static).
APPROVAL_TLOG_URL (required) Base URL des transparency-log.
APPROVAL_SESSION_TTL 15m Lebensdauer einer Approval-Session.
APPROVAL_CHALLENGE_TTL 2m Max. Zeit zwischen Preview und Confirm.
APPROVAL_WEBAUTHN_RP_ID trust.basicstack.de WebAuthn relying party ID.
APPROVAL_WEBAUTHN_RP_ORIGIN https://trust.basicstack.de Erlaubter Origin in client_data_json.
APPROVAL_POCKET_ID_ISSUER (required in prod) Pocket-ID OIDC-Issuer.
APPROVAL_POCKET_ID_CLIENT (required in prod) Client-ID des approval-service.

Tests

cd services/approval-service
go test ./...

Deckt ab:

  • Happy Path (Session → Preview → Confirm → DSSE verifiziert und ins TLog eingetragen).
  • Tampered H_content in der Assertion → 400.
  • Abgelaufene Challenge → 403.
  • Signing-Service antwortet nicht (connection refused) → 502.
  • TLog-Submit scheitert → Approval bleibt attestation-pending.