ProofedMesh/services/approval-service/internal/httpapi/server.go

246 lines
8 KiB
Go
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
// Package httpapi mounts the approval-service HTTP endpoints.
package httpapi
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"log/slog"
"net/http"
"time"
"basicstack.de/proofedmesh/services/approval-service/internal/auth"
"basicstack.de/proofedmesh/services/approval-service/internal/content"
"basicstack.de/proofedmesh/services/approval-service/internal/service"
"basicstack.de/proofedmesh/services/approval-service/internal/sessions"
"basicstack.de/proofedmesh/services/approval-service/internal/signer"
"basicstack.de/proofedmesh/services/approval-service/internal/store"
"basicstack.de/proofedmesh/services/approval-service/internal/webauthn"
pkgapi "basicstack.de/proofedmesh/services/approval-service/pkg/httpapi"
)
// AuthResolver resolves the authenticated principal from the request.
// Prod wires it to the Pocket-ID cookie middleware; tests inject a
// fixed principal via a shim.
type AuthResolver interface {
Resolve(r *http.Request) (auth.Principal, error)
}
// AuthFunc adapts a plain function to AuthResolver.
type AuthFunc func(r *http.Request) (auth.Principal, error)
// Resolve calls f.
func (f AuthFunc) Resolve(r *http.Request) (auth.Principal, error) { return f(r) }
// Server is the HTTP handler for the approval-service.
type Server struct {
Auth AuthResolver
Sessions *sessions.Manager
Orchestrator *service.Orchestrator
Logger *slog.Logger
Now func() time.Time
}
// Mount registers the three API routes plus /healthz on mux.
func (s *Server) Mount(mux *http.ServeMux) {
mux.HandleFunc("POST /api/v1/sessions", s.handleCreateSession)
mux.HandleFunc("POST /api/v1/approvals/preview", s.handlePreview)
mux.HandleFunc("POST /api/v1/approvals/confirm", s.handleConfirm)
mux.HandleFunc("GET /healthz", s.handleHealth)
}
func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"status":"ok"}`))
}
func (s *Server) handleCreateSession(w http.ResponseWriter, r *http.Request) {
p, err := s.resolve(r)
if err != nil {
writeErr(w, http.StatusUnauthorized, "unauthorized", err.Error())
return
}
// POST /sessions has no body in Phase 1; the Pocket-ID principal
// alone identifies the user.
sess, err := s.Sessions.StartSession(r.Context(), p.Sub)
if err != nil {
s.logError("start session failed", err)
writeErr(w, http.StatusInternalServerError, "session_create_failed", err.Error())
return
}
writeJSON(w, http.StatusCreated, pkgapi.CreateSessionResponse{
SessionID: sess.ID,
UserSub: sess.UserSub,
ActiveUntil: sess.ActiveUntil,
})
}
func (s *Server) handlePreview(w http.ResponseWriter, r *http.Request) {
p, err := s.resolve(r)
if err != nil {
writeErr(w, http.StatusUnauthorized, "unauthorized", err.Error())
return
}
var req pkgapi.PreviewRequest
if err := decodeJSON(r, &req); err != nil {
writeErr(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
if req.SessionID == "" {
writeErr(w, http.StatusBadRequest, "bad_request", "session_id is required")
return
}
if len(req.Content) == 0 {
writeErr(w, http.StatusBadRequest, "bad_request", "content is required")
return
}
sess, err := s.Sessions.GetActiveSession(r.Context(), req.SessionID, p.Sub)
switch {
case errors.Is(err, store.ErrNotFound):
writeErr(w, http.StatusNotFound, "session_not_found", "session_id unknown")
return
case errors.Is(err, sessions.ErrSessionExpired):
writeErr(w, http.StatusForbidden, "session_expired", "approval session expired")
return
case err != nil:
s.logError("session lookup failed", err)
writeErr(w, http.StatusInternalServerError, "session_lookup_failed", err.Error())
return
}
bind, err := content.Bind(req.Content)
if err != nil {
writeErr(w, http.StatusBadRequest, "bad_request", "content: "+err.Error())
return
}
previewID, err := s.Orchestrator.NewID("pv")
if err != nil {
writeErr(w, http.StatusInternalServerError, "id_gen_failed", err.Error())
return
}
now := s.now()
pv := store.Preview{
ID: previewID,
SessionID: sess.ID,
UserSub: p.Sub,
ContentHash: bind.ContentHash,
ContentCanonical: bind.ContentCanonical,
Salt: bind.Salt,
Nonce: bind.Nonce,
VerificationDigits: bind.VerificationDigits,
ExpiresAt: now.Add(s.Sessions.ChallengeTTL),
CreatedAt: now,
}
if err := s.Orchestrator.Store.SavePreview(r.Context(), pv); err != nil {
s.logError("save preview failed", err)
writeErr(w, http.StatusInternalServerError, "save_preview_failed", err.Error())
return
}
writeJSON(w, http.StatusOK, pkgapi.PreviewResponse{
PreviewID: pv.ID,
ContentHash: pv.ContentHash,
VerificationDigits: pv.VerificationDigits,
WebauthnChallenge: base64.RawURLEncoding.EncodeToString(bind.Challenge),
ExpiresAt: pv.ExpiresAt,
})
}
func (s *Server) handleConfirm(w http.ResponseWriter, r *http.Request) {
p, err := s.resolve(r)
if err != nil {
writeErr(w, http.StatusUnauthorized, "unauthorized", err.Error())
return
}
var req pkgapi.ConfirmRequest
if err := decodeJSON(r, &req); err != nil {
writeErr(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
if req.PreviewID == "" {
writeErr(w, http.StatusBadRequest, "bad_request", "preview_id is required")
return
}
assertion := webauthn.Assertion(req.Assertion)
res, err := s.Orchestrator.Confirm(r.Context(), req.PreviewID, assertion, p.Sub)
if err != nil {
s.mapConfirmError(w, err)
return
}
writeJSON(w, http.StatusOK, pkgapi.ConfirmResponse{
ApprovalID: res.ApprovalID,
AttestationRef: res.AttestationRef,
LogIndex: res.LogIndex,
TLogStatus: string(res.TLogStatus),
})
}
func (s *Server) mapConfirmError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, store.ErrNotFound):
writeErr(w, http.StatusNotFound, "preview_not_found", "preview_id unknown")
case errors.Is(err, store.ErrAlreadyConsumed):
writeErr(w, http.StatusConflict, "preview_consumed", "preview already consumed")
case errors.Is(err, service.ErrChallengeExpired):
writeErr(w, http.StatusForbidden, "challenge_expired", "approval challenge expired")
case errors.Is(err, service.ErrContentHashMismatch):
writeErr(w, http.StatusBadRequest, "content_hash_mismatch", err.Error())
case errors.Is(err, webauthn.ErrChallengeMismatch):
writeErr(w, http.StatusBadRequest, "content_hash_mismatch", err.Error())
case errors.Is(err, webauthn.ErrCredentialUnknown):
writeErr(w, http.StatusUnauthorized, "credential_unknown", "credential_id not registered")
case errors.Is(err, webauthn.ErrAssertionInvalid):
writeErr(w, http.StatusUnauthorized, "assertion_invalid", "webauthn assertion verification failed")
case errors.Is(err, signer.ErrSignerUnavailable):
writeErr(w, http.StatusBadGateway, "signer_unavailable", err.Error())
default:
s.logError("confirm failed", err)
writeErr(w, http.StatusInternalServerError, "confirm_failed", err.Error())
}
}
func (s *Server) resolve(r *http.Request) (auth.Principal, error) {
if s.Auth == nil {
return auth.Principal{}, errors.New("no auth resolver configured")
}
return s.Auth.Resolve(r)
}
func (s *Server) now() time.Time {
if s.Now != nil {
return s.Now()
}
return time.Now()
}
func (s *Server) logError(msg string, err error) {
if s.Logger != nil {
s.Logger.Error("approval-service: "+msg, "err", err)
}
}
func decodeJSON(r *http.Request, v any) error {
dec := json.NewDecoder(http.MaxBytesReader(nil, r.Body, 1<<20))
dec.DisallowUnknownFields()
if err := dec.Decode(v); err != nil {
return err
}
return nil
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func writeErr(w http.ResponseWriter, status int, code, msg string) {
writeJSON(w, status, pkgapi.Error{Error: pkgapi.ErrorBody{Code: code, Message: msg}})
}
// Context sanity: ensure we never return a canceled error as a 500 just
// because the client disconnected mid-flight.
var _ context.Context = context.Background()