New services/approval-service with the Session → Preview → Confirm
pipeline required by DEV-606/DEV-608:
* POST /api/v1/sessions starts a Pocket-ID-bound approval session.
* POST /api/v1/approvals/preview renders content server-side, computes
H_content = sha256(canonical JSON), stores the (nonce, salt, canonical,
challenge) tuple and returns the human verification digits plus the
WebAuthn challenge = nonce || H_content.
* POST /api/v1/approvals/confirm verifies the WebAuthn assertion against
the stored challenge, DSSE-signs the Approval statement via the
signing-service (key_ref=trust-api/approval-signer-2026-q4), submits
to the transparency-log, and returns {approval_id, attestation_ref,
log_index}. TLog failures leave the row at attestation-pending for
the reconciler.
* db/trust/migrations/0004_approval_sessions.{up,down}.sql: new tables
approval_sessions, approval_previews, approvals (schema matches the
Phase 1 store contract).
* internal/content: canonical JSON + H_content + 6-digit verification
code (byte-compatible with signing-service/internal/canonical).
* internal/webauthn: stdlib-only Phase-1 verifier; wire shape stays
WebAuthn-compatible for the Phase-2 swap to go-webauthn/webauthn.
* internal/signer, internal/tlog: HTTP clients mapping 5xx / connection
errors to typed ErrSignerUnavailable / ErrTLogUnavailable so the
HTTP layer can return 502 for signing failures and persist
attestation-pending on TLog failure.
* internal/service.Orchestrator ties the pipeline together and persists
the Approval BEFORE the TLog submit (never return a log_index for
an un-persisted approval).
Tests cover: happy path, tampered H_content → 400, expired challenge
→ 403, signer 5xx + conn-refused → 502, TLog failure → row stays
attestation-pending, duplicate confirm → 409, session expired → 403.
go vet clean, go test ./... passes.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
102 lines
4.5 KiB
SQL
102 lines
4.5 KiB
SQL
-- Migration: 0004_approval_sessions
|
|
-- Content-gebundene Approvals gemäß docs/architecture/v1.1.md §4.3 und
|
|
-- Phase-1-Spezifikation [DEV-608](/DEV/issues/DEV-608).
|
|
--
|
|
-- Drei Tabellen, strikt in der Reihenfolge der Session → Preview → Confirm
|
|
-- Pipeline des approval-service:
|
|
--
|
|
-- 1. `approval_sessions` — eine durch die Pocket-ID-Session gestützte
|
|
-- Approval-Session; sie bindet (session_id, user_sub) an ein Ablauf-
|
|
-- datum und wird vom Approval-UI pro Browser-Login einmal erzeugt.
|
|
-- 2. `approval_previews` — ein serverseitig gerendertes Freigabe-Objekt;
|
|
-- Content-Hash (`H_content`), Salt und Challenge-Nonce werden gleich
|
|
-- bei der Vorschau festgenagelt, damit das Confirm-Step eine *andere*
|
|
-- Payload nicht nachträglich unterschieben kann.
|
|
-- 3. `approvals` — die bestätigte, DSSE-signierte Approval. `tlog_status`
|
|
-- spiegelt den TLog-Einwurf; `attestation-pending` bedeutet die Row
|
|
-- ist persistiert, der TLog-Submit aber noch nicht quittiert und wird
|
|
-- vom attest-reconciler bzw. einem späteren Worker nachgezogen.
|
|
--
|
|
-- Design-Entscheidungen:
|
|
-- * Alle `*_id`-Spalten sind TEXT (opaque IDs; Service-generiert, nicht
|
|
-- UUID gezwungen) — bleibt kompatibel mit dem bestehenden Stil der
|
|
-- Trust-DB.
|
|
-- * `content_canonical` wird in Preview mitgespeichert, damit Confirm
|
|
-- den Hash deterministisch nachrechnen und das signierte Statement
|
|
-- ohne erneutes Rendern aufbauen kann.
|
|
-- * `tlog_status` ist ein Enum via CHECK statt pg-native ENUM, um die
|
|
-- Migration ohne CREATE TYPE drop-safe zu halten.
|
|
|
|
CREATE TABLE IF NOT EXISTS approval_sessions (
|
|
session_id TEXT PRIMARY KEY
|
|
CHECK (length(session_id) BETWEEN 16 AND 128),
|
|
user_sub TEXT NOT NULL
|
|
CHECK (length(user_sub) BETWEEN 1 AND 256),
|
|
active_until TIMESTAMPTZ NOT NULL,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
);
|
|
|
|
CREATE INDEX idx_approval_sessions_user
|
|
ON approval_sessions (user_sub);
|
|
|
|
CREATE INDEX idx_approval_sessions_active_until
|
|
ON approval_sessions (active_until);
|
|
|
|
CREATE TABLE IF NOT EXISTS approval_previews (
|
|
preview_id TEXT PRIMARY KEY
|
|
CHECK (length(preview_id) BETWEEN 16 AND 128),
|
|
session_id TEXT NOT NULL
|
|
REFERENCES approval_sessions(session_id)
|
|
ON DELETE CASCADE,
|
|
content_hash TEXT NOT NULL
|
|
CHECK (content_hash ~ '^sha256:[0-9a-f]{64}$'),
|
|
content_canonical BYTEA NOT NULL,
|
|
salt BYTEA NOT NULL
|
|
CHECK (length(salt) BETWEEN 16 AND 64),
|
|
challenge_nonce BYTEA NOT NULL
|
|
CHECK (length(challenge_nonce) BETWEEN 16 AND 64),
|
|
verification_digits TEXT NOT NULL
|
|
CHECK (verification_digits ~ '^[0-9]{4,12}$'),
|
|
expires_at TIMESTAMPTZ NOT NULL,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
consumed_at TIMESTAMPTZ
|
|
);
|
|
|
|
CREATE INDEX idx_approval_previews_session
|
|
ON approval_previews (session_id);
|
|
|
|
CREATE INDEX idx_approval_previews_expires
|
|
ON approval_previews (expires_at)
|
|
WHERE consumed_at IS NULL;
|
|
|
|
CREATE TABLE IF NOT EXISTS approvals (
|
|
approval_id TEXT PRIMARY KEY
|
|
CHECK (length(approval_id) BETWEEN 16 AND 128),
|
|
preview_id TEXT NOT NULL
|
|
REFERENCES approval_previews(preview_id),
|
|
session_id TEXT NOT NULL
|
|
REFERENCES approval_sessions(session_id),
|
|
user_sub TEXT NOT NULL,
|
|
content_hash TEXT NOT NULL
|
|
CHECK (content_hash ~ '^sha256:[0-9a-f]{64}$'),
|
|
envelope_json JSONB NOT NULL,
|
|
attestation_ref TEXT NOT NULL
|
|
CHECK (attestation_ref ~ '^sha256:[0-9a-f]{64}$'),
|
|
tlog_status TEXT NOT NULL DEFAULT 'attestation-pending'
|
|
CHECK (tlog_status IN ('attestation-pending',
|
|
'logged',
|
|
'failed')),
|
|
log_index BIGINT,
|
|
tlog_error TEXT,
|
|
confirmed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
logged_at TIMESTAMPTZ,
|
|
CONSTRAINT approvals_log_consistency
|
|
CHECK ((tlog_status = 'logged') = (log_index IS NOT NULL))
|
|
);
|
|
|
|
CREATE INDEX idx_approvals_tlog_pending
|
|
ON approvals (confirmed_at)
|
|
WHERE tlog_status = 'attestation-pending';
|
|
|
|
CREATE INDEX idx_approvals_user
|
|
ON approvals (user_sub, confirmed_at);
|