diff --git a/internal/domain/approval.go b/internal/domain/approval.go new file mode 100644 index 0000000..6f7e742 --- /dev/null +++ b/internal/domain/approval.go @@ -0,0 +1,102 @@ +package domain + +import "time" + +// ApprovalRequest represents a pending issuance / renewal that requires +// human approval before the issuer connector is dispatched. One row per +// (CertificateID, JobID) pair; the JobID points at the blocked Job whose +// Status is JobStatusAwaitingApproval. +// +// Lifecycle: +// +// pending → approved (Approve called by a non-requester) +// pending → rejected (Reject called) +// pending → expired (scheduler reaper at approvalCutoff) +// +// Once terminal, the row is immutable; the audit_events table is the +// durable record of who approved + why. +// +// Rank 7 of the 2026-05-03 Infisical deep-research deliverable +// (cowork/infisical-deep-research-results.md Part 5). Closes the +// "two-person integrity / four-eyes principle" procurement gap for +// PCI-DSS Level 1, FedRAMP Moderate / High, and SOC 2 Type II +// customers. +type ApprovalRequest struct { + ID string `json:"id"` // ar- + CertificateID string `json:"certificate_id"` // FK managed_certificates.id + JobID string `json:"job_id"` // FK jobs.id (the blocked Job) + ProfileID string `json:"profile_id"` // CertificateProfile that triggered the gate + RequestedBy string `json:"requested_by"` // actor that triggered the renewal + State ApprovalState `json:"state"` // pending / approved / rejected / expired + DecidedBy *string `json:"decided_by,omitempty"` // null while state=pending + DecidedAt *time.Time `json:"decided_at,omitempty"` // null while state=pending + DecisionNote *string `json:"decision_note,omitempty"` // operator's reason text + Metadata map[string]string `json:"metadata,omitempty"` // common_name, sans, issuer_id, severity_tier + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// ApprovalState is the closed enum of approval lifecycle states. +type ApprovalState string + +const ( + // ApprovalStatePending is the initial state — created by RequestApproval, + // blocking the linked Job at JobStatusAwaitingApproval. The scheduler does + // NOT dispatch the job until the approval transitions to approved. + ApprovalStatePending ApprovalState = "pending" + + // ApprovalStateApproved is the success terminal state. Approve sets + // DecidedBy / DecidedAt / DecisionNote and transitions the linked Job + // from AwaitingApproval to Pending so the job processor picks it up. + ApprovalStateApproved ApprovalState = "approved" + + // ApprovalStateRejected is the human-rejected terminal state. The + // linked Job transitions from AwaitingApproval to Cancelled. + ApprovalStateRejected ApprovalState = "rejected" + + // ApprovalStateExpired is the timeout terminal state. The scheduler's + // reaper transitions stale pending requests to expired after the + // CERTCTL_JOB_AWAITING_APPROVAL_TIMEOUT cutoff (default 168h = 7 days). + ApprovalStateExpired ApprovalState = "expired" +) + +// IsValidApprovalState reports whether s is a closed-enum value. Used by +// repository validation + handler request-body parsing to defend against +// off-enum typos at write time. +func IsValidApprovalState(s ApprovalState) bool { + switch s { + case ApprovalStatePending, ApprovalStateApproved, + ApprovalStateRejected, ApprovalStateExpired: + return true + } + return false +} + +// IsTerminal reports whether s is one of the immutable terminal states +// (approved / rejected / expired). Once terminal, an ApprovalRequest's +// row cannot be mutated; subsequent Approve / Reject calls return +// ErrApprovalAlreadyDecided. +func (s ApprovalState) IsTerminal() bool { + switch s { + case ApprovalStateApproved, ApprovalStateRejected, ApprovalStateExpired: + return true + } + return false +} + +// Approval-decision outcome strings used by the metrics counter +// (certctl_approval_decisions_total{outcome,profile_id}). Matches the +// Prometheus convention: lower-case, snake_case, bounded cardinality. +const ( + ApprovalOutcomeApproved = "approved" + ApprovalOutcomeRejected = "rejected" + ApprovalOutcomeExpired = "expired" + ApprovalOutcomeBypassed = "bypassed" +) + +// ApprovalActorSystemBypass is the synthetic actor identity stamped on +// audit rows + DecidedBy when CERTCTL_APPROVAL_BYPASS=true short-circuits +// the workflow for dev/CI. Production deploys MUST leave the bypass +// unset; compliance auditors run `SELECT FROM audit_events WHERE +// actor='system-bypass'` to confirm zero rows. +const ApprovalActorSystemBypass = "system-bypass" diff --git a/internal/domain/profile.go b/internal/domain/profile.go index c2f1fc9..53b657e 100644 --- a/internal/domain/profile.go +++ b/internal/domain/profile.go @@ -72,6 +72,24 @@ type CertificateProfile struct { // "trust_authenticated". ACMEAuthMode string `json:"acme_auth_mode,omitempty"` + // RequiresApproval, when true, gates issuance + renewal of any + // certificate bound to this profile on a parallel ApprovalRequest + // row. The renewal-loop tick creates the job at + // JobStatusAwaitingApproval; the scheduler does NOT dispatch + // until ApprovalService.Approve transitions the request to + // approved. Compliance customers (PCI-DSS Level 1, FedRAMP + // Moderate / High, SOC 2 Type II, HIPAA) configure this on + // production-tier profiles to satisfy the two-person integrity + // procurement question. + // + // Defaults to false for back-compat — the unattended renewal + // path remains the default for non-compliance customers. + // + // Backed by certificate_profiles.requires_approval added in + // migration 000027_approval_workflow. Rank 7 of the 2026-05-03 + // Infisical deep-research deliverable. + RequiresApproval bool `json:"requires_approval,omitempty"` + Enabled bool `json:"enabled"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` diff --git a/internal/repository/errors.go b/internal/repository/errors.go index e2b7469..bb8d861 100644 --- a/internal/repository/errors.go +++ b/internal/repository/errors.go @@ -27,6 +27,19 @@ import ( // rather than substring-match. var ErrNotFound = errors.New("repository: row not found") +// ErrAlreadyExists is the canonical sentinel for postgres unique- +// constraint (SQLSTATE 23505) violations bubbling up from an INSERT +// (or partial-unique INSERT, like Rank 7's idx_approval_pending_per_job +// which enforces "at most one pending approval per job"). Handlers +// that surface a 409 Conflict should +// `errors.Is(err, repository.ErrAlreadyExists)`. +// +// The repo also reuses ErrAlreadyExists for "row is already terminal" +// state-transition attempts (e.g., Approve called on an already- +// approved request) — semantically the same "you're trying to create +// a state that already exists" failure mode. +var ErrAlreadyExists = errors.New("repository: row already exists") + // ErrForeignKeyConstraint is the canonical sentinel for PostgreSQL // FK / RESTRICT violations bubbling up from a DELETE or UPDATE. // Handlers that surface a 409 Conflict should diff --git a/internal/repository/interfaces.go b/internal/repository/interfaces.go index 0d6e3f6..7e926c5 100644 --- a/internal/repository/interfaces.go +++ b/internal/repository/interfaces.go @@ -713,3 +713,60 @@ type HealthCheckFilter struct { // PerPage is the number of results per page. PerPage int } + +// ApprovalRepository defines operations for managing issuance approval requests. +// Rank 7 of the 2026-05-03 Infisical deep-research deliverable — closes the +// two-person integrity / four-eyes principle procurement gap for PCI-DSS +// Level 1, FedRAMP Moderate / High, SOC 2 Type II, HIPAA-regulated PHI. +// +// Lifecycle: Create inserts a row at state=pending; UpdateState transitions +// to one of (approved, rejected, expired) with the decider identity + +// timestamp + optional note; ExpireStale is the bulk reaper called from +// the scheduler. Once terminal, rows are immutable via the +// approval_decision_consistency CHECK constraint at the schema layer. +type ApprovalRepository interface { + // Create inserts a new ApprovalRequest at state=pending. Returns + // ErrAlreadyExists if a pending request already exists for the + // job_id (the partial-unique index enforces at most one pending + // per job). + Create(ctx context.Context, req *domain.ApprovalRequest) error + + // Get returns the request by ID or ErrNotFound. + Get(ctx context.Context, id string) (*domain.ApprovalRequest, error) + + // GetByJobID returns the most-recently-created request for the + // given job_id, regardless of state. Used by the renewal entry + // point to detect "is there already a pending approval for this + // job?" and avoid creating a duplicate. + GetByJobID(ctx context.Context, jobID string) (*domain.ApprovalRequest, error) + + // List returns approval requests filtered by ApprovalFilter. + // Supports paginated dashboard queries. + List(ctx context.Context, filter *ApprovalFilter) ([]*domain.ApprovalRequest, error) + + // UpdateState transitions a row from state=pending to one of + // (approved, rejected, expired). Returns ErrNotFound if the ID + // does not exist; returns the schema's CHECK-violation as a + // repository error if the row is already terminal. + UpdateState(ctx context.Context, id string, state domain.ApprovalState, + decidedBy string, decidedAt time.Time, note string) error + + // ExpireStale transitions every row with state=pending and + // created_at <= before to state=expired. Returns the number of + // rows transitioned. Called from the scheduler reaper loop. + ExpireStale(ctx context.Context, before time.Time) (int, error) +} + +// ApprovalFilter filters approval-request queries. +type ApprovalFilter struct { + // State filters by lifecycle state (pending, approved, rejected, expired). + State string + // CertificateID filters by managed certificate ID. + CertificateID string + // RequestedBy filters to requests created by the given actor. + RequestedBy string + // Page is the page number (1-indexed). + Page int + // PerPage is the number of results per page. + PerPage int +} diff --git a/internal/repository/postgres/approval.go b/internal/repository/postgres/approval.go new file mode 100644 index 0000000..775b9cd --- /dev/null +++ b/internal/repository/postgres/approval.go @@ -0,0 +1,309 @@ +package postgres + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/google/uuid" + "github.com/lib/pq" + + "github.com/certctl-io/certctl/internal/domain" + "github.com/certctl-io/certctl/internal/repository" +) + +// ApprovalRepository is the postgres implementation of +// repository.ApprovalRepository. Rank 7 of the 2026-05-03 Infisical +// deep-research deliverable. +type ApprovalRepository struct { + db *sql.DB +} + +// NewApprovalRepository constructs an ApprovalRepository against the +// given *sql.DB. The schema is defined by migration +// 000027_approval_workflow.up.sql. +func NewApprovalRepository(db *sql.DB) *ApprovalRepository { + return &ApprovalRepository{db: db} +} + +// Create inserts a new ApprovalRequest at state=pending. Generates the +// ar- ID if req.ID is empty. Returns +// repository.ErrAlreadyExists if the partial-unique index +// (idx_approval_pending_per_job) trips — i.e., a pending request +// already exists for the given job_id. +func (r *ApprovalRepository) Create(ctx context.Context, req *domain.ApprovalRequest) error { + if req.ID == "" { + req.ID = "ar-" + uuid.NewString() + } + if req.State == "" { + req.State = domain.ApprovalStatePending + } + if !domain.IsValidApprovalState(req.State) { + return fmt.Errorf("invalid approval state %q", req.State) + } + now := time.Now().UTC() + if req.CreatedAt.IsZero() { + req.CreatedAt = now + } + if req.UpdatedAt.IsZero() { + req.UpdatedAt = now + } + + metadataJSON, err := json.Marshal(req.Metadata) + if err != nil { + return fmt.Errorf("marshal approval metadata: %w", err) + } + if len(metadataJSON) == 0 || string(metadataJSON) == "null" { + metadataJSON = []byte("{}") + } + + const q = ` + INSERT INTO issuance_approval_requests + (id, certificate_id, job_id, profile_id, requested_by, + state, decided_by, decided_at, decision_note, metadata, + created_at, updated_at) + VALUES + ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) + ` + + _, err = r.db.ExecContext(ctx, q, + req.ID, req.CertificateID, req.JobID, req.ProfileID, req.RequestedBy, + string(req.State), req.DecidedBy, req.DecidedAt, req.DecisionNote, metadataJSON, + req.CreatedAt, req.UpdatedAt, + ) + if err != nil { + var pqErr *pq.Error + if errors.As(err, &pqErr) && pqErr.Code == "23505" { // unique_violation + return repository.ErrAlreadyExists + } + return fmt.Errorf("insert approval request: %w", err) + } + return nil +} + +// Get returns the request by ID or repository.ErrNotFound. +func (r *ApprovalRepository) Get(ctx context.Context, id string) (*domain.ApprovalRequest, error) { + const q = ` + SELECT id, certificate_id, job_id, profile_id, requested_by, + state, decided_by, decided_at, decision_note, metadata, + created_at, updated_at + FROM issuance_approval_requests + WHERE id = $1 + ` + row := r.db.QueryRowContext(ctx, q, id) + return scanApprovalRow(row) +} + +// GetByJobID returns the most-recently-created request for the given +// job_id, regardless of state. +func (r *ApprovalRepository) GetByJobID(ctx context.Context, jobID string) (*domain.ApprovalRequest, error) { + const q = ` + SELECT id, certificate_id, job_id, profile_id, requested_by, + state, decided_by, decided_at, decision_note, metadata, + created_at, updated_at + FROM issuance_approval_requests + WHERE job_id = $1 + ORDER BY created_at DESC + LIMIT 1 + ` + row := r.db.QueryRowContext(ctx, q, jobID) + return scanApprovalRow(row) +} + +// List returns approval requests filtered by repository.ApprovalFilter. +// Supports paginated dashboard queries. +func (r *ApprovalRepository) List(ctx context.Context, filter *repository.ApprovalFilter) ([]*domain.ApprovalRequest, error) { + if filter == nil { + filter = &repository.ApprovalFilter{} + } + page := filter.Page + if page < 1 { + page = 1 + } + perPage := filter.PerPage + if perPage < 1 || perPage > 500 { + perPage = 50 + } + + q := ` + SELECT id, certificate_id, job_id, profile_id, requested_by, + state, decided_by, decided_at, decision_note, metadata, + created_at, updated_at + FROM issuance_approval_requests + WHERE 1 = 1 + ` + args := []interface{}{} + idx := 1 + if filter.State != "" { + q += fmt.Sprintf(" AND state = $%d", idx) + args = append(args, filter.State) + idx++ + } + if filter.CertificateID != "" { + q += fmt.Sprintf(" AND certificate_id = $%d", idx) + args = append(args, filter.CertificateID) + idx++ + } + if filter.RequestedBy != "" { + q += fmt.Sprintf(" AND requested_by = $%d", idx) + args = append(args, filter.RequestedBy) + idx++ + } + q += fmt.Sprintf(" ORDER BY created_at DESC LIMIT $%d OFFSET $%d", idx, idx+1) + args = append(args, perPage, (page-1)*perPage) + + rows, err := r.db.QueryContext(ctx, q, args...) + if err != nil { + return nil, fmt.Errorf("list approval requests: %w", err) + } + defer rows.Close() + + var out []*domain.ApprovalRequest + for rows.Next() { + req, err := scanApprovalRow(rows) + if err != nil { + return nil, err + } + out = append(out, req) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate approval rows: %w", err) + } + return out, nil +} + +// UpdateState transitions a row from state=pending to a terminal state. +// Returns repository.ErrNotFound if the ID does not exist. +// +// The schema's approval_decision_consistency CHECK constraint enforces +// that decided_by + decided_at MUST be non-null for terminal states, +// so a same-state update on an already-decided row returns a +// constraint-violation error from postgres. +func (r *ApprovalRepository) UpdateState(ctx context.Context, id string, state domain.ApprovalState, + decidedBy string, decidedAt time.Time, note string) error { + if !domain.IsValidApprovalState(state) { + return fmt.Errorf("invalid approval state %q", state) + } + if !state.IsTerminal() { + return fmt.Errorf("UpdateState only accepts terminal states; got %q", state) + } + + var notePtr *string + if note != "" { + notePtr = ¬e + } + + const q = ` + UPDATE issuance_approval_requests + SET state = $2, + decided_by = $3, + decided_at = $4, + decision_note = $5, + updated_at = NOW() + WHERE id = $1 + AND state = 'pending' + ` + res, err := r.db.ExecContext(ctx, q, id, string(state), decidedBy, decidedAt, notePtr) + if err != nil { + return fmt.Errorf("update approval state: %w", err) + } + n, err := res.RowsAffected() + if err != nil { + return fmt.Errorf("update approval rows affected: %w", err) + } + if n == 0 { + // Either the ID does not exist, or the row is already terminal. + // Disambiguate via a follow-up Get. + existing, getErr := r.Get(ctx, id) + if getErr != nil { + return getErr // ErrNotFound or scan error + } + if existing.State.IsTerminal() { + return repository.ErrAlreadyExists // signals "already decided" + } + return repository.ErrNotFound + } + return nil +} + +// ExpireStale transitions every row with state=pending and created_at <= +// before to state=expired. Returns the number of rows transitioned. +// +// The decided_at is stamped with time.Now() rather than `before` so +// audit dashboards see the actual reaper-firing wall-clock, not the +// reaper's deadline-cutoff input. The decided_by is set to a sentinel +// "system-reaper" so SELECT FROM audit_events WHERE actor matches both +// human-decided and reaper-decided rows for compliance review. +func (r *ApprovalRepository) ExpireStale(ctx context.Context, before time.Time) (int, error) { + const q = ` + UPDATE issuance_approval_requests + SET state = 'expired', + decided_by = 'system-reaper', + decided_at = NOW(), + decision_note = 'auto-expired by scheduler reaper at CERTCTL_JOB_AWAITING_APPROVAL_TIMEOUT', + updated_at = NOW() + WHERE state = 'pending' + AND created_at <= $1 + ` + res, err := r.db.ExecContext(ctx, q, before) + if err != nil { + return 0, fmt.Errorf("expire stale approvals: %w", err) + } + n, err := res.RowsAffected() + if err != nil { + return 0, fmt.Errorf("expire stale rows affected: %w", err) + } + return int(n), nil +} + +// scanApprovalRow scans a single row into a *domain.ApprovalRequest. +// Used by Get / GetByJobID (sql.Row) + List (*sql.Rows) — accepts the +// rowScanner interface. JSONB metadata is unmarshaled defensively. +type rowScanner interface { + Scan(dest ...interface{}) error +} + +func scanApprovalRow(row rowScanner) (*domain.ApprovalRequest, error) { + var ( + req domain.ApprovalRequest + stateStr string + decidedBy sql.NullString + decidedAt sql.NullTime + decisionNote sql.NullString + metadataJSON []byte + ) + err := row.Scan( + &req.ID, &req.CertificateID, &req.JobID, &req.ProfileID, &req.RequestedBy, + &stateStr, &decidedBy, &decidedAt, &decisionNote, &metadataJSON, + &req.CreatedAt, &req.UpdatedAt, + ) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, repository.ErrNotFound + } + return nil, fmt.Errorf("scan approval row: %w", err) + } + + req.State = domain.ApprovalState(stateStr) + if decidedBy.Valid { + s := decidedBy.String + req.DecidedBy = &s + } + if decidedAt.Valid { + t := decidedAt.Time + req.DecidedAt = &t + } + if decisionNote.Valid { + s := decisionNote.String + req.DecisionNote = &s + } + if len(metadataJSON) > 0 { + if err := json.Unmarshal(metadataJSON, &req.Metadata); err != nil { + return nil, fmt.Errorf("unmarshal approval metadata: %w", err) + } + } + return &req, nil +} diff --git a/migrations/000027_approval_workflow.down.sql b/migrations/000027_approval_workflow.down.sql new file mode 100644 index 0000000..bcca030 --- /dev/null +++ b/migrations/000027_approval_workflow.down.sql @@ -0,0 +1,14 @@ +-- 000027_approval_workflow.down.sql — reverse of the up migration. +-- Drops the issuance_approval_requests table and the +-- requires_approval column from certificate_profiles. Idempotent: +-- IF EXISTS on every drop. + +DROP INDEX IF EXISTS idx_approval_pending_age; +DROP INDEX IF EXISTS idx_approval_certificate; +DROP INDEX IF EXISTS idx_approval_state; +DROP INDEX IF EXISTS idx_approval_pending_per_job; + +DROP TABLE IF EXISTS issuance_approval_requests; + +ALTER TABLE certificate_profiles + DROP COLUMN IF EXISTS requires_approval; diff --git a/migrations/000027_approval_workflow.up.sql b/migrations/000027_approval_workflow.up.sql new file mode 100644 index 0000000..df202ae --- /dev/null +++ b/migrations/000027_approval_workflow.up.sql @@ -0,0 +1,66 @@ +-- 000027_approval_workflow.up.sql +-- Rank 7 of the 2026-05-03 Infisical deep-research deliverable +-- (cowork/infisical-deep-research-results.md Part 5). Two-person +-- integrity / four-eyes principle for compliance-tier certificate +-- issuance. CertificateProfile.RequiresApproval gates the renewal- +-- loop entry; issuance_approval_requests captures the per-job +-- decision with full audit trail. +-- +-- All operations use IF NOT EXISTS / IF EXISTS so the migration is +-- idempotent — safe to re-run on every certctl-server boot per the +-- "Idempotent migrations" architecture decision in CLAUDE.md. +-- +-- Existing scaffolding REUSED (not redefined here): +-- - JobStatusAwaitingApproval enum value (internal/domain/job.go). +-- - JobRepository.ListTimedOutAwaitingJobs (postgres reaper query). +-- - Config.Scheduler.AwaitingApprovalTimeout (env-mapped, default +-- 168h via CERTCTL_JOB_AWAITING_APPROVAL_TIMEOUT). +-- +-- The lifecycle states are pinned at the schema level via a CHECK +-- constraint matching internal/domain/approval.go::ApprovalState. + +ALTER TABLE certificate_profiles + ADD COLUMN IF NOT EXISTS requires_approval BOOLEAN NOT NULL DEFAULT false; + +CREATE TABLE IF NOT EXISTS issuance_approval_requests ( + id TEXT PRIMARY KEY, + certificate_id TEXT NOT NULL REFERENCES managed_certificates(id) ON DELETE CASCADE, + job_id TEXT NOT NULL REFERENCES jobs(id) ON DELETE CASCADE, + profile_id TEXT NOT NULL REFERENCES certificate_profiles(id) ON DELETE RESTRICT, + requested_by TEXT NOT NULL, + state VARCHAR(20) NOT NULL DEFAULT 'pending', + decided_by TEXT, + decided_at TIMESTAMPTZ, + decision_note TEXT, + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT approval_state_check CHECK ( + state IN ('pending', 'approved', 'rejected', 'expired') + ), + CONSTRAINT approval_decision_consistency CHECK ( + (state = 'pending' AND decided_by IS NULL AND decided_at IS NULL) + OR (state IN ('approved', 'rejected', 'expired') AND decided_at IS NOT NULL) + ) +); + +-- Partial-unique index: at most one PENDING approval request per job +-- ID. Creates / re-creates idempotently. Terminal-state rows +-- (approved / rejected / expired) are not constrained — operators +-- can audit-trail multiple decisions over a job's lifetime, though +-- in practice each job creates exactly one ApprovalRequest at +-- AwaitingApproval entry and never recreates it. +CREATE UNIQUE INDEX IF NOT EXISTS idx_approval_pending_per_job + ON issuance_approval_requests(job_id) + WHERE state = 'pending'; + +CREATE INDEX IF NOT EXISTS idx_approval_state + ON issuance_approval_requests(state); + +CREATE INDEX IF NOT EXISTS idx_approval_certificate + ON issuance_approval_requests(certificate_id); + +CREATE INDEX IF NOT EXISTS idx_approval_pending_age + ON issuance_approval_requests(created_at) + WHERE state = 'pending';