16 KiB
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:
- Requester cannot approve their own promotion (if
requireSeparationOfDutiesis true) - Same user cannot approve twice
- At least N different users must approve (based on
requiredCount) - At least one approver must match
requiredRolesif specified - At least one approver must be in
requiredGroupsif 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:
- Freeze Window Check: Is environment in freeze?
- Approval Check: All required approvals received?
- Security Gate: No blocking vulnerabilities?
- Custom Policy Gates: All OPA policies pass?
- 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;
}[];
}