ProofedMesh/db/trust/migrations/0004_approval_sessions.up.sql

103 lines
4.5 KiB
MySQL
Raw Permalink Normal View History

approval-service: Phase-1 content-binding approval service (DEV-608) 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>
2026-10-25 19:42:06 +00:00
-- 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);