Files
git.stella-ops.org/docs/modules/release-orchestrator/modules/promotion-manager.md

16 KiB

PROMOT: Promotion & Approval Manager

Purpose: Manage promotion requests, approvals, gates, and decision records.

Modules

Module: promotion-manager

Aspect Specification
Responsibility Promotion request lifecycle; state management
Dependencies release-manager, environment-manager, workflow-engine
Data Entities Promotion, PromotionState
Events Produced promotion.requested, promotion.approved, promotion.rejected, promotion.started, promotion.completed, promotion.failed, promotion.rolled_back

Key Operations:

RequestPromotion(releaseId, targetEnvironmentId, reason) → Promotion
ApprovePromotion(promotionId, comment) → Promotion
RejectPromotion(promotionId, reason) → Promotion
CancelPromotion(promotionId) → Promotion
GetPromotionStatus(promotionId) → PromotionState
GetDecisionRecord(promotionId) → DecisionRecord

Promotion Entity:

interface Promotion {
  id: UUID;
  tenantId: UUID;
  releaseId: UUID;
  sourceEnvironmentId: UUID | null;  // null for first deployment
  targetEnvironmentId: UUID;
  status: PromotionStatus;
  decisionRecord: DecisionRecord;
  workflowRunId: UUID | null;
  requestedAt: DateTime;
  requestedBy: UUID;
  requestReason: string;
  decidedAt: DateTime | null;
  startedAt: DateTime | null;
  completedAt: DateTime | null;
  evidencePacketId: UUID | null;
}

type PromotionStatus =
  | "pending_approval"   // Waiting for human approval
  | "pending_gate"       // Waiting for gate evaluation
  | "approved"           // Ready for deployment
  | "rejected"           // Blocked by approval or gate
  | "deploying"          // Deployment in progress
  | "deployed"           // Successfully deployed
  | "failed"             // Deployment failed
  | "cancelled"          // User cancelled
  | "rolled_back";       // Rolled back after failure

Module: approval-gateway

Aspect Specification
Responsibility Approval collection; separation of duties enforcement
Dependencies authority (for user/group lookup)
Data Entities Approval, ApprovalPolicy
Events Produced approval.granted, approval.denied

Approval Policy Entity:

interface ApprovalPolicy {
  id: UUID;
  tenantId: UUID;
  environmentId: UUID;
  requiredCount: number;           // Minimum approvals required
  requiredRoles: string[];         // At least one approver must have role
  requiredGroups: string[];        // At least one approver must be in group
  requireSeparationOfDuties: boolean;  // Requester cannot approve
  allowSelfApproval: boolean;      // Override SoD for specific users
  expirationMinutes: number;       // Approval expires after N minutes
}

interface Approval {
  id: UUID;
  tenantId: UUID;
  promotionId: UUID;
  approverId: UUID;
  action: "approved" | "rejected";
  comment: string;
  approvedAt: DateTime;
  approverRole: string;
  approverGroups: string[];
}

Separation of Duties (SoD) Rules:

  1. Requester cannot approve their own promotion (if requireSeparationOfDuties is true)
  2. Same user cannot approve twice
  3. At least N different users must approve (based on requiredCount)
  4. At least one approver must match requiredRoles if specified
  5. At least one approver must be in requiredGroups if specified

Module: decision-engine

Aspect Specification
Responsibility Gate evaluation; policy integration; decision record generation
Dependencies gate-registry, policy (OPA integration), scanner (security data)
Data Entities DecisionRecord, GateResult
Events Produced decision.evaluated, decision.recorded

Decision Record Structure:

interface DecisionRecord {
  promotionId: UUID;
  evaluatedAt: DateTime;
  decision: "allow" | "deny" | "pending";

  // What was evaluated
  release: {
    id: UUID;
    name: string;
    components: Array<{
      name: string;
      digest: string;
      semver: string;
    }>;
  };

  environment: {
    id: UUID;
    name: string;
    requiredApprovals: number;
    freezeWindow: boolean;
  };

  // Gate evaluation results
  gates: GateResult[];

  // Approval status
  approvalStatus: {
    required: number;
    received: number;
    approvers: Array<{
      userId: UUID;
      action: string;
      at: DateTime;
    }>;
    sodViolation: boolean;
  };

  // Reason for decision
  reasons: string[];

  // Hash of all inputs for replay verification
  inputsHash: string;
}

interface GateResult {
  gateType: string;
  gateName: string;
  status: "passed" | "failed" | "warning" | "skipped";
  message: string;
  details: Record<string, any>;
  evaluatedAt: DateTime;
  durationMs: number;
}

Gate Evaluation Order:

  1. Freeze Window Check: Is environment in freeze?
  2. Approval Check: All required approvals received?
  3. Security Gate: No blocking vulnerabilities?
  4. Custom Policy Gates: All OPA policies pass?
  5. Integration Gates: External system checks pass?

Module: gate-registry

Aspect Specification
Responsibility Built-in + custom gate registration
Dependencies plugin-registry
Data Entities GateDefinition, GateConfig

Built-in Gates:

Gate Type Description
freeze-window Check if environment is in freeze
approval Check if required approvals received
security-scan Check for blocking vulnerabilities
scan-freshness Check if scan is recent enough
digest-verification Verify digests haven't changed
environment-sequence Enforce promotion order
custom-opa Custom OPA/Rego policy
webhook External webhook gate

Gate Definition:

interface GateDefinition {
  type: string;
  displayName: string;
  description: string;
  configSchema: JSONSchema;
  evaluator: "builtin" | UUID;  // builtin or plugin ID
  blocking: boolean;            // Can block promotion
  cacheable: boolean;           // Can cache result
  cacheTtlSeconds: number;
}

Promotion State Machine

┌─────────────────────────────────────────────────────────────────────────────┐
│                    PROMOTION STATE MACHINE                                  │
│                                                                             │
│    ┌───────────────┐                                                        │
│    │   REQUESTED   │ ◄──── User requests promotion                         │
│    └───────┬───────┘                                                        │
│            │                                                                │
│            ▼                                                                │
│    ┌───────────────┐      ┌───────────────┐                                │
│    │   PENDING     │─────►│   REJECTED    │ ◄──── Approver rejects        │
│    │   APPROVAL    │      └───────────────┘                                │
│    └───────┬───────┘                                                        │
│            │ approval received                                              │
│            ▼                                                                │
│    ┌───────────────┐      ┌───────────────┐                                │
│    │   PENDING     │─────►│   REJECTED    │ ◄──── Gate fails              │
│    │   GATE        │      └───────────────┘                                │
│    └───────┬───────┘                                                        │
│            │ all gates pass                                                 │
│            ▼                                                                │
│    ┌───────────────┐                                                        │
│    │   APPROVED    │ ◄──── Ready for deployment                            │
│    └───────┬───────┘                                                        │
│            │ workflow starts                                                │
│            ▼                                                                │
│    ┌───────────────┐      ┌───────────────┐      ┌───────────────┐        │
│    │   DEPLOYING   │─────►│    FAILED     │─────►│  ROLLED_BACK  │        │
│    └───────┬───────┘      └───────────────┘      └───────────────┘        │
│            │                                                                │
│            │ deployment complete                                            │
│            ▼                                                                │
│    ┌───────────────┐                                                        │
│    │   DEPLOYED    │ ◄──── Success!                                        │
│    └───────────────┘                                                        │
│                                                                             │
│    Additional transitions:                                                  │
│    - Any non-terminal → CANCELLED: user cancels                            │
│                                                                             │
└─────────────────────────────────────────────────────────────────────────────┘

Database Schema

-- Promotions
CREATE TABLE release.promotions (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
    release_id UUID NOT NULL REFERENCES release.releases(id),
    source_environment_id UUID REFERENCES release.environments(id),
    target_environment_id UUID NOT NULL REFERENCES release.environments(id),
    status VARCHAR(50) NOT NULL DEFAULT 'pending_approval' CHECK (status IN (
        'pending_approval', 'pending_gate', 'approved', 'rejected',
        'deploying', 'deployed', 'failed', 'cancelled', 'rolled_back'
    )),
    decision_record JSONB,
    workflow_run_id UUID REFERENCES release.workflow_runs(id),
    requested_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    requested_by UUID NOT NULL REFERENCES users(id),
    request_reason TEXT,
    decided_at TIMESTAMPTZ,
    started_at TIMESTAMPTZ,
    completed_at TIMESTAMPTZ,
    evidence_packet_id UUID
);

CREATE INDEX idx_promotions_tenant ON release.promotions(tenant_id);
CREATE INDEX idx_promotions_release ON release.promotions(release_id);
CREATE INDEX idx_promotions_status ON release.promotions(status);
CREATE INDEX idx_promotions_target_env ON release.promotions(target_environment_id);

-- Approvals
CREATE TABLE release.approvals (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
    promotion_id UUID NOT NULL REFERENCES release.promotions(id) ON DELETE CASCADE,
    approver_id UUID NOT NULL REFERENCES users(id),
    action VARCHAR(50) NOT NULL CHECK (action IN ('approved', 'rejected')),
    comment TEXT,
    approved_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    approver_role VARCHAR(255),
    approver_groups JSONB NOT NULL DEFAULT '[]'
);

CREATE INDEX idx_approvals_promotion ON release.approvals(promotion_id);
CREATE INDEX idx_approvals_approver ON release.approvals(approver_id);

-- Approval Policies
CREATE TABLE release.approval_policies (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
    environment_id UUID NOT NULL REFERENCES release.environments(id) ON DELETE CASCADE,
    required_count INTEGER NOT NULL DEFAULT 1,
    required_roles JSONB NOT NULL DEFAULT '[]',
    required_groups JSONB NOT NULL DEFAULT '[]',
    require_sod BOOLEAN NOT NULL DEFAULT FALSE,
    allow_self_approval BOOLEAN NOT NULL DEFAULT FALSE,
    expiration_minutes INTEGER NOT NULL DEFAULT 1440,
    UNIQUE (tenant_id, environment_id)
);

API Endpoints

# Promotions
POST   /api/v1/promotions
       Body: { releaseId, targetEnvironmentId, reason? }
       Response: Promotion

GET    /api/v1/promotions
       Query: ?status={status}&releaseId={uuid}&environmentId={uuid}&page={n}
       Response: { data: Promotion[], meta: PaginationMeta }

GET    /api/v1/promotions/{id}
       Response: Promotion (with decision record, approvals)

POST   /api/v1/promotions/{id}/approve
       Body: { comment? }
       Response: Promotion

POST   /api/v1/promotions/{id}/reject
       Body: { reason }
       Response: Promotion

POST   /api/v1/promotions/{id}/cancel
       Response: Promotion

GET    /api/v1/promotions/{id}/decision
       Response: DecisionRecord

GET    /api/v1/promotions/{id}/approvals
       Response: Approval[]

GET    /api/v1/promotions/{id}/evidence
       Response: EvidencePacket

# Gate Evaluation Preview
POST   /api/v1/promotions/preview-gates
       Body: { releaseId, targetEnvironmentId }
       Response: { wouldPass: boolean, gates: GateResult[] }

# Approval Policies
POST   /api/v1/approval-policies
GET    /api/v1/approval-policies
GET    /api/v1/approval-policies/{id}
PUT    /api/v1/approval-policies/{id}
DELETE /api/v1/approval-policies/{id}

# Pending Approvals (for current user)
GET    /api/v1/my/pending-approvals
       Response: Promotion[]

Security Gate Integration

The security gate evaluates the release against vulnerability data from the Scanner module:

interface SecurityGateConfig {
  blockOnCritical: boolean;        // Block if any critical severity
  blockOnHigh: boolean;            // Block if any high severity
  maxCritical: number;             // Max allowed critical (0 for strict)
  maxHigh: number;                 // Max allowed high
  requireFreshScan: boolean;       // Require scan within N hours
  scanFreshnessHours: number;      // How recent scan must be
  allowExceptions: boolean;        // Allow VEX exceptions
  requireVexJustification: boolean; // Require VEX for exceptions
}

interface SecurityGateResult {
  passed: boolean;
  summary: {
    critical: number;
    high: number;
    medium: number;
    low: number;
  };
  blocking: Array<{
    cve: string;
    severity: string;
    component: string;
    digest: string;
    fixAvailable: boolean;
  }>;
  exceptions: Array<{
    cve: string;
    vexStatus: string;
    justification: string;
  }>;
  scanAge: {
    component: string;
    scannedAt: DateTime;
    ageHours: number;
    fresh: boolean;
  }[];
}

References