Exception Approval Workflow

Audience: security and release-governance owners who request, approve, or audit policy exceptions, and developers working on the Policy exception subsystem (src/Policy).

Reconciliation status (verified against src/Policy): This flow was rewritten to match the implemented exception subsystem. The implementation provides two related surfaces:

  1. Exception Objects/api/policy/exceptions (lifecycle CRUD + governed state machine). Code: StellaOps.Policy.Gateway/Endpoints/ExceptionEndpoints.cs, StellaOps.Policy.Engine/Endpoints/Gateway/ExceptionEndpoints.cs, and the StellaOps.Policy.Exceptions library.
  2. Exception Approval Requests/api/v1/policy/exception (gate-level multi-approver workflow). Code: StellaOps.Policy.Gateway/Endpoints/ExceptionApprovalEndpoints.cs, StellaOps.Policy.Engine/Services/ExceptionApprovalRulesService.cs, and the ExceptionApprovalRequestEntity / ExceptionApprovalRuleEntity persistence models.

Both surfaces are mapped by both the Policy Engine and the Policy Gateway (Program.cs: MapExceptionEndpoints() + MapExceptionApprovalEndpoints()). Org-role concepts such as “Team Lead”, “Security Team”, and “CISO” are not system actors — they exist only as configurable approver role strings in gate-level rules (code-owner, delivery-manager, product-manager, ciso).

Automatic expiry and expiry-warning emission are implemented as in-process hosted BackgroundService workers (ExceptionExpiryWorker, ExceptionLifecycleWorker); lifecycle notifications are emitted to the unified audit/Timeline emitter (IExceptionNotificationService, disabled by default), not the standalone src/Notify module. See Expiration & Lifecycle. (A prior reconciliation pass incorrectly stated these were absent.)

Overview

The Exception Approval Workflow describes how StellaOps handles policy exception requests, from submission through gate-level approval, time-limited validity, and expiry. Exceptions are first-class auditable entities (not suppression files or UI toggles) with full lifecycle tracking, immutable event history, and deterministic evaluation.

Business Value: Maintain security governance while allowing controlled, time-bounded exceptions for legitimate business needs, with a complete audit trail and enforced separation-of-duty.

Actors

ActorTypeRole in the implemented flow
RequesterHumanCreates an exception object (policy:author) or an approval request (exceptions:request).
ApproverHumanApproves/rejects via the gate-level workflow (exceptions:approve) or approves an exception object (policy:operate). Must hold the required role(s) for the gate level.
Policy Engine / Policy GatewayServiceHosts the exception and approval-request APIs, evaluates approval rules, enforces exceptions during policy evaluation.
Expiry / lifecycle workersService (background)Hosted BackgroundService workers in both Policy Gateway and Policy Engine that auto-expire stale exceptions and emit expiry warnings. See Expiration & Lifecycle.

No external Scheduler/JobEngine dependency: expiry and lifecycle transitions run as in-process hosted workers in the Policy services (not as JobEngine jobs). Lifecycle notifications are emitted to the unified audit/Timeline emitter, not to the standalone src/Notify channel-dispatch module (no email/Slack templates exist). See Expiration & Lifecycle and Not Implemented / Roadmap.

Prerequisites

Authorization

Scopes are sourced from the canonical catalog (StellaOps.Auth.Abstractions/StellaOpsScopes.cs). Note the two surfaces use different scopes.

Exception Objects (/api/policy/exceptions)

OperationRequired scope
List / counts / get / history / expiringpolicy:read
Create (POST) / update (PUT)policy:author
Approve / activate / extend / revoke (DELETE)policy:operate

Exception Approval Requests (/api/v1/policy/exception)

OperationRequired scope
Create request / cancelexceptions:request
Get / list / audit / rulesexceptions:read
List pending (approver inbox) / approve / rejectexceptions:approve

Exception Object Model

An exception object (ExceptionObject) is a versioned, append-only-audited entity.

Status (lifecycle)

Proposed → Approved → Active → Expired
                       Active → Revoked
       Proposed/Approved/Active → Revoked   (early termination / rejection)
StatusMeaning
proposedRequested, awaiting approval.
approvedApproved, awaiting activation.
activeCurrently affecting policy evaluation.
expiredexpires_at reached.
revokedExplicitly revoked (or rejected before approval).

Type

TypeMeaning
vulnerabilityException for a specific CVE/CWE.
policyBypass of a specific policy rule.
unknownAllow release despite unknown findings.
componentException for a specific component/package.

Reason code (ExceptionReason)

false_positive, accepted_risk, compensating_control, test_only, vendor_not_affected, scheduled_fix, deprecation_in_progress, runtime_mitigation, network_isolation, other.

Scope (ExceptionScope)

At least one constraint must be set; multiple constraints combine with AND.

FieldNotes
artifact_digestsha256:..., exact match (most specific).
purl_patternSupports * wildcards (e.g. pkg:npm/lodash@*, pkg:maven/org.apache.logging.log4j/*).
vulnerability_idExact CVE/CWE ID.
policy_rule_idPolicy rule being bypassed.
environmentsAllowed environments; empty array = all environments.
tenant_idRLS tenant binding.

Gate-Level Approval Rules

The approval-request workflow (/api/v1/policy/exception) routes each request to a gate level (G0G4). If no tenant-specific ExceptionApprovalRuleEntity is configured (and enabled), these built-in defaults apply (ApprovalRequirements.GetDefault):

GateMinApproversRequired rolesMaxTtlDaysSelf-approvalEvidence req.Comp. controls req.MinRationaleLength
G0 (informational)0 (auto-approve)90yesnono0
G1 (low)160yesnono20
G2 (medium)1code-owner30noyesno50
G3 (high)2delivery-manager, product-manager14noyesyes100
G4 (critical)3ciso, delivery-manager, product-manager7noyesyes200

Notes:

Flow Diagram (gate-level approval request)

┌──────────────────────────────────────────────────────────────────────┐
│         Exception Approval Request Workflow (gate-level)               │
└──────────────────────────────────────────────────────────────────────┘

┌───────────┐                 ┌──────────────────────┐     ┌──────────────┐
│ Requester │                 │ Policy Engine/Gateway │     │  Approver(s) │
└─────┬─────┘                 └──────────┬───────────┘     └──────┬───────┘
      │ POST /request                    │                        │
      │  (justification, gate level,     │                        │
      │   scope, ttl, evidence)          │                        │
      │─────────────────────────────────>│                        │
      │                                  │ Resolve requirements   │
      │                                  │ (tenant rule || default)│
      │                                  │ Validate TTL/rationale/ │
      │                                  │ evidence/controls       │
      │                                  │───┐                     │
      │                                  │<──┘                     │
      │                                  │ If G0 / MinApprovers==0 │
      │                                  │ → auto-approve          │
      │                                  │ Persist + audit         │
      │ 201 Created (EAR-…, status,      │                         │
      │   warnings)                      │                         │
      │<─────────────────────────────────│                        │
      │                                  │                         │
      │                                  │  GET /pending           │
      │                                  │<────────────────────────│
      │                                  │  (approver inbox)       │
      │                                  │  POST /{id}/approve     │
      │                                  │<────────────────────────│
      │                                  │ Validate action:        │
      │                                  │  - not already approved │
      │                                  │  - self-approval rule   │
      │                                  │  - in required list     │
      │                                  │  - role coverage        │
      │                                  │ Record approval; when   │
      │                                  │ MinApprovers met →      │
      │                                  │ status = Approved       │
      │                                  │                         │
      │                                  │  POST /{id}/reject       │
      │                                  │  (mandatory reason)      │
      │                                  │<────────────────────────│
      │                                  │                         │

Step-by-Step (gate-level approval request)

1. Create approval request

POST /api/v1/policy/exception/request (scope exceptions:request).

Request body (CreateApprovalRequestDto):

{
  "exceptionId": "EXC-… (optional link to an exception object)",
  "justification": "Upgrading lodash breaks the auth flow; need time to refactor.",
  "rationale": "Vulnerability requires prototype pollution not reachable in our usage.",
  "gateLevel": "G3",
  "reasonCode": "scheduled_fix",
  "evidenceRefs": ["sha256:…"],
  "compensatingControls": ["WAF rule blocking exploitation vectors"],
  "ticketRef": "JIRA-1234",
  "vulnerabilityId": "CVE-2024-1234",
  "purlPattern": "pkg:npm/lodash@*",
  "artifactDigest": null,
  "imagePattern": "docker.io/myorg/auth-service:*",
  "environments": ["production"],
  "requiredApproverIds": ["alice", "bob"],
  "requestedTtlDays": 14,
  "metadata": { "team": "auth" }
}

Server behaviour:

Response: 201 Created with the full ApprovalRequestDto (includes status, gateLevel, requestExpiresAt, exceptionExpiresAt, and any validation warnings, e.g. “No scope constraints specified.”).

2. Approver inbox

GET /api/v1/policy/exception/pending (scope exceptions:approve) returns requests where the caller is a required approver and has not yet approved.

3. Approve

POST /api/v1/policy/exception/{requestId}/approve (scope exceptions:approve).

ValidateApprovalActionAsync enforces:

When MinApprovers is reached (and outstanding required roles cleared), the request transitions to Approved. Optional comment is captured.

4. Reject

POST /api/v1/policy/exception/{requestId}/reject (scope exceptions:approve). Requires a non-empty reason. Only Pending/Partial requests can be rejected; transitions to Rejected (terminal). The reason is surfaced to the requester.

5. Cancel

POST /api/v1/policy/exception/{requestId}/cancel (scope exceptions:request). Only the original requestor may cancel (returns 403 otherwise); returns 400 if already resolved; 204 No Content on success.

6. Audit & rules

Step-by-Step (exception object lifecycle)

The /api/policy/exceptions surface manages the exception object directly.

StepEndpointScopeNotes
CreatePOST /api/policy/exceptionspolicy:authorStatus proposed. Validates expiry is in the future and ≤ 1 year. ID = EXC-{guid} (20 chars).
UpdatePUT /api/policy/exceptions/{id}policy:authorMutates rationale/evidence/controls/ticket/metadata; bumps version. Cannot update expired/revoked.
ApprovePOST /api/policy/exceptions/{id}/approvepolicy:operateOnly proposedapproved. Self-approval rejected (approver ≠ requester). Appends approver.
ActivatePOST /api/policy/exceptions/{id}/activatepolicy:operateOnly approvedactive.
ExtendPOST /api/policy/exceptions/{id}/extendpolicy:operateOnly active; new expiry must be later than current.
RevokeDELETE /api/policy/exceptions/{id}policy:operateAny non-terminal → revoked. Optional reason.
ListGET /api/policy/exceptionspolicy:readFilters: status, type, vulnerability ID, PURL pattern, environment, owner; paginated (limit 1–100, default 50).
CountsGET /api/policy/exceptions/countspolicy:readTotals by status + expiring-soon.
GetGET /api/policy/exceptions/{id}policy:readFull detail or 404.
HistoryGET /api/policy/exceptions/{id}/historypolicy:readOrdered ExceptionEvent audit trail.
ExpiringGET /api/policy/exceptions/expiring?days=7policy:readActive exceptions expiring within the horizon (default 7 days).

Example: create exception object

{
  "type": "vulnerability",
  "scope": {
    "vulnerabilityId": "CVE-2024-1234",
    "purlPattern": "pkg:npm/lodash@*",
    "environments": ["production"]
  },
  "ownerId": "auth-team",
  "expiresAt": "2025-01-28T14:30:00Z",
  "reasonCode": "scheduled_fix",
  "rationale": "Upgrade breaks the auth flow; WAF mitigation in place while we refactor.",
  "evidenceRefs": ["sha256:…"],
  "compensatingControls": ["WAF rule deployed"],
  "ticketRef": "JIRA-1234",
  "metadata": { "team": "auth" }
}

Exception Enforcement (policy evaluation)

IExceptionEvaluator.EvaluateAsync is invoked during policy evaluation with a FindingContext (artifactDigest, purl, vulnerabilityId, policyRuleId, environment, tenantId). It:

  1. Queries active exceptions for the scope (GetActiveByScopeAsync).
  2. Filters to those that truly match the context (exact match for digest / vuln ID / rule ID; wildcard PURL match; environment membership; tenant binding) and are IsEffectiveAt(now) (status Active and not past expires_at).
  3. Orders matches by a specificity score (artifact digest 100 > exact PURL 50 > vuln ID 40 > policy rule 30 > wildcard PURL 20 > environment 10). The most specific match is the primary.
  4. Returns HasException, all matching exceptions, the primary reason/rationale, and the union of evidence references.

Effective verdict: a finding that would otherwise FAIL passes if (and only if) a matching, currently-effective exception applies.

Recheck Policy & Evidence Requirements (implemented, optional)

The StellaOps.Policy.Exceptions library defines additional governance primitives that an exception object may carry:

These are part of the model and library services; surfacing them through the public HTTP API is partial — treat fields like recheckPolicy / evidenceRequirements as advanced/optional rather than required inputs.

Expiration & Lifecycle

Background workers (verified)

Expiry is not only lazy: two in-process hosted BackgroundService workers run the status sweep and warnings. Both are registered with AddHostedService<…>():

WorkerService / registrationCadenceWhat it does
ExceptionExpiryWorkerPolicy Gateway (Program.cs) and Policy Engine (Program.cs)hourly (Interval, default 1h; InitialDelay 30s)Loads GetExpiredActiveAsync() and flips active → expired (system actor system:expiry-worker, with ConcurrencyException retry-next-cycle), then loads GetExpiringAsync(WarningHorizon) (default 7 days) and calls IExceptionNotificationService.NotifyExceptionExpiringSoonAsync at the 7-, 3-, and 1-day thresholds (ShouldSendWarning). Gated by ExceptionExpiryOptions.Enabled (default true; section Policy:Exceptions:Expiry).
ExceptionLifecycleWorker (+ ExceptionLifecycleService)Policy Engine (Program.cs)polling (PollIntervalSeconds, default 60s)Per-configured-tenant activation/expiry sweep; calls IExceptionRepository.ExpireAsync(tenant) for each tenant in ResourceServer.RequiredTenants. Options: PolicyEngineExceptionLifecycleOptions.

Earlier drafts claimed “no Scheduler job is wired” and that the “7-day / 1-day reminders” were not implemented. Both claims are incorrect against current source — see the worker table above. What does not exist is an external JobEngine/Scheduler job: this work runs as in-process hosted services in the Policy Gateway and Policy Engine.

Lifecycle notifications (verified)

IExceptionNotificationService (interface in StellaOps.Policy.Gateway/Services/ExceptionService.cs) is invoked on exception-object lifecycle transitions and by the expiry worker:

HookFired fromEmitted action
NotifyExceptionCreatedAsyncExceptionService.CreateAsyncexception.created
NotifyExceptionApprovedAsyncExceptionService.ApproveAsyncexception.approved (incl. approverId)
NotifyExceptionActivatedAsyncExceptionService.ActivateAsyncexception.activated
NotifyExceptionExtendedAsyncExceptionService.ExtendAsyncexception.extended (incl. newExpiry)
NotifyExceptionRevokedAsyncExceptionService.RevokeAsyncexception.revoked (incl. reason; severity warning)
NotifyExceptionExpiringSoonAsyncExceptionExpiryWorkerexception.expiring_soon (incl. timeUntilExpirySeconds)

The registered implementation is AuditExceptionNotificationService, which emits an AuditEventPayload (module policy, resource type exception) to the unified audit/Timeline emitter (IAuditEventEmitter). It is disabled by default (ExceptionNotificationOptions.Enabled = false; section Policy:Exceptions:Notifications) — when disabled it logs and skips emission. There is no email/Slack/channel dispatch through the standalone src/Notify module; these notifications are audit/Timeline events only.

Note: the ExceptionService lifecycle methods above are wrapped by the service layer. The minimal-API handlers documented under exception object lifecycle operate directly on IExceptionRepository and therefore do not themselves emit these notifications; the audit history (/history) is still recorded via the repository in both paths.

Data Contracts

Exception object (response — ExceptionResponse)

interface ExceptionResponse {
  exceptionId: string;          // EXC-…
  version: number;
  status: 'proposed' | 'approved' | 'active' | 'expired' | 'revoked';
  type: 'vulnerability' | 'policy' | 'unknown' | 'component';
  scope: {
    artifactDigest?: string;
    purlPattern?: string;
    vulnerabilityId?: string;
    policyRuleId?: string;
    environments: string[];
  };
  ownerId: string;
  requesterId: string;
  approverIds: string[];
  createdAt: string;
  updatedAt: string;
  approvedAt?: string;
  expiresAt: string;
  reasonCode: 'false_positive' | 'accepted_risk' | 'compensating_control'
            | 'test_only' | 'vendor_not_affected' | 'scheduled_fix'
            | 'deprecation_in_progress' | 'runtime_mitigation'
            | 'network_isolation' | 'other';
  rationale: string;
  evidenceRefs: string[];
  compensatingControls: string[];
  ticketRef?: string;
  metadata: Record<string, string>;
}

Approval request (response — ApprovalRequestDto)

interface ApprovalRequestDto {
  requestId: string;            // EAR-yyyyMMdd-XXXXXXXX
  id: string;                   // GUID
  tenantId: string;
  exceptionId?: string;
  requestorId: string;
  requiredApproverIds: string[];
  approvedByIds: string[];
  rejectedById?: string;
  status: 'Pending' | 'Partial' | 'Approved' | 'Rejected' | 'Expired' | 'Cancelled';
  gateLevel: 'G0' | 'G1' | 'G2' | 'G3' | 'G4';
  justification: string;
  rationale?: string;
  reasonCode: string;           // ExceptionReasonCode enum name
  evidenceRefs: string[];
  compensatingControls: string[];
  ticketRef?: string;
  vulnerabilityId?: string;
  purlPattern?: string;
  artifactDigest?: string;
  imagePattern?: string;
  environments: string[];
  requestedTtlDays: number;
  createdAt: string;
  requestExpiresAt: string;
  exceptionExpiresAt?: string;
  resolvedAt?: string;
  rejectionReason?: string;
  version: number;
  updatedAt: string;
  warnings: string[];
}

Approval rule (response — ApprovalRuleDto)

interface ApprovalRuleDto {
  id: string;
  name: string;
  description?: string;
  gateLevel: 'G0' | 'G1' | 'G2' | 'G3' | 'G4';
  minApprovers: number;
  requiredRoles: string[];
  maxTtlDays: number;
  allowSelfApproval: boolean;
  requireEvidence: boolean;
  requireCompensatingControls: boolean;
  minRationaleLength: number;
  enabled: boolean;
}

Error Handling

ConditionHTTPSource
Missing request body400both surfaces
Object expiry in the past or > 1 year400ExceptionEndpoints create
Update/revoke an already expired/revoked object400ExceptionEndpoints
Approve a non-proposed object400ExceptionEndpoints approve
Self-approval of object (approver == requester)400ExceptionEndpoints approve
Activate a non-approved object400ExceptionEndpoints activate
Extend with new expiry ≤ current400ExceptionEndpoints extend
Request TTL > gate MaxTtlDays400CreateApprovalRequestAsync / rules service
Validation failure (rationale/evidence/controls/justification)400ValidateRequestAsync
Approval not allowed (state, dup, self, not-in-list, roles)400ValidateApprovalActionAsync
Reject without reason400RejectRequestAsync
Cancel by non-owner403CancelRequestAsync
Request/exception not found404both surfaces
Missing tenant / actor401approval endpoints

Observability

The specific metric/log-event names below are illustrative and not verified against an emitted-telemetry catalog. The approval-request endpoints are decorated with .Audited("policy", "<action>", resourceType: "exception_approval_request") (create / approve / reject / cancel), so those actions emit standard audit events; structured information logs (e.g. “Exception request {RequestId} approved by {ApproverId}”) are emitted by the handlers.

Audit actions (verified)

EndpointAudit actionResource type
POST /requestcreateexception_approval_request
POST /{id}/approveapproveexception_approval_request
POST /{id}/rejectrejectexception_approval_request
POST /{id}/cancelcancelexception_approval_request

Exception-object events (Created, Updated, Approved, Activated, Extended, Revoked, Expired, EvidenceAttached, CompensatingControlAdded, Rejected) are persisted as ExceptionEvent audit entries and exposed via the /history endpoint.

Not Implemented / Roadmap

The following items appeared in earlier drafts of this flow but are not present in src/and should be treated as roadmap, not current behavior: