Files
certctl/internal/domain/approval.go
T
shankar0123 2025275b43 domain, migrations: ApprovalRequest type + issuance_approval_requests + RequiresApproval
Rank 7 of the 2026-05-03 Infisical deep-research deliverable, commit 1 of 4
(cowork/rank-7-approval-workflow-primitive-prompt.md). The four-commit
chain ships the issuance approval-workflow primitive (request → human review
→ CA call) closing the two-person integrity / four-eyes principle
procurement gap for PCI-DSS Level 1, FedRAMP Moderate / High, SOC 2
Type II, and HIPAA-regulated PHI deployments.

This commit lands ONLY the foundation — schema, types, repository
interface, postgres implementation. No service / handler wiring yet.
The four-commit shape is bisectable: the schema can land in production
behind a flag (via the default RequiresApproval=false on every existing
profile) without any operator-visible behavior change until commits 2-4
wire the surrounding workflow.

Existing scaffolding REUSED (not redefined here):
  - JobStatusAwaitingApproval enum value (internal/domain/job.go).
  - JobRepository.ListTimedOutAwaitingJobs (postgres reaper query).
  - Config.Scheduler.AwaitingApprovalTimeout (env-mapped via
    CERTCTL_JOB_AWAITING_APPROVAL_TIMEOUT, default 168h = 7 days).
  - Scheduler.SetAwaitingApprovalTimeout wiring.

Files added:
  internal/domain/approval.go              - ApprovalRequest type,
                                              ApprovalState closed enum
                                              (pending/approved/rejected/
                                              expired), IsValidApprovalState +
                                              IsTerminal helpers, outcome
                                              const block + bypass-actor
                                              sentinel.
  internal/repository/postgres/approval.go - ApprovalRepository
                                              implementation: Create
                                              (ar-<slug> ID gen + JSONB
                                              metadata round-trip + lib/pq
                                              23505 → ErrAlreadyExists
                                              translation), Get, GetByJobID,
                                              List (paginated with state /
                                              cert / requester filters),
                                              UpdateState (pending→terminal
                                              transitions only, with
                                              already-terminal disambiguation),
                                              ExpireStale (bulk reaper,
                                              decided_by='system-reaper').
  migrations/000027_approval_workflow.{up,down}.sql
                                            - Idempotent IF NOT EXISTS /
                                              IF EXISTS. Adds
                                              certificate_profiles.requires_approval
                                              BOOLEAN NOT NULL DEFAULT false,
                                              issuance_approval_requests
                                              table with FK to
                                              managed_certificates / jobs /
                                              certificate_profiles, four
                                              indexes (state, certificate,
                                              pending-age, partial-unique
                                              pending-per-job), and the
                                              approval_decision_consistency
                                              CHECK constraint enforcing
                                              decided_by/decided_at must be
                                              non-null for terminal states.

Files modified:
  internal/domain/profile.go               - Adds CertificateProfile.RequiresApproval
                                              bool field with full doc
                                              comment + JSON tag. Defaults
                                              to false (back-compat — every
                                              existing profile keeps the
                                              unattended renewal path).
  internal/repository/interfaces.go        - Adds ApprovalRepository
                                              interface (6 methods) +
                                              ApprovalFilter struct.
  internal/repository/errors.go            - Adds ErrAlreadyExists sentinel
                                              for postgres SQLSTATE 23505
                                              (unique-constraint violations
                                              from the partial-unique
                                              pending-per-job index, plus
                                              the "already terminal" state-
                                              transition signal). Mirrors
                                              the existing ErrNotFound +
                                              ErrForeignKeyConstraint shape.

Verified:
  gofmt: clean.
  go vet ./internal/domain/... ./internal/repository/...: exit 0.
  go build ./internal/domain/... ./internal/repository/...: exit 0.

Out of scope for this commit (lands in commits 2-4):
  - service/approval.go (RequestApproval / Approve / Reject / ListPending
    / ExpireStale + same-actor RBAC + bypass mode + audit + metrics).
  - service/approval_metrics.go (decisions counter + pending-age histogram).
  - 8 service-level table-driven tests including the load-bearing
    TestApproval_Approve_RejectsSameActor two-person integrity pin.
  - api/handler/approval.go (5 endpoints + RBAC integration).
  - api/openapi.yaml (5 new operationIds).
  - Integration into CertificateService.TriggerRenewal +
    RenewalService.CheckExpiringCertificates + Scheduler.ReapTimedOutJobs.
  - cmd/server/main.go wiring.
  - Config.Approval.BypassEnabled + CERTCTL_APPROVAL_BYPASS env var.
  - docs/connectors.md CertificateProfile config-table row.
  - docs/approval-workflow.md operator playbook + compliance control mapping.

Reference: cowork/infisical-deep-research-results.md Part 5 Rank 7.
Acquisition prompt: cowork/rank-7-approval-workflow-primitive-prompt.md.
2026-05-04 00:55:17 +00:00

103 lines
4.5 KiB
Go

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-<slug>
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"