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:
- Exception Objects —
/api/policy/exceptions(lifecycle CRUD + governed state machine). Code:StellaOps.Policy.Engine/Endpoints/Gateway/ExceptionEndpoints.csand theStellaOps.Policy.Exceptionslibrary.- Exception Approval Requests —
/api/v1/policy/exception(gate-level multi-approver workflow). Code:StellaOps.Policy.Engine/Endpoints/Gateway/ExceptionApprovalEndpoints.cs,StellaOps.Policy.Engine/Services/ExceptionApprovalRulesService.cs, and theExceptionApprovalRequestEntity/ExceptionApprovalRuleEntitypersistence models.Both surfaces are mapped by the Policy Engine (
Program.cs:MapExceptionEndpoints()
MapExceptionApprovalEndpoints()). The separate Policy Gateway host was withdrawn; the Engine serves its routes. 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
BackgroundServiceworkers (ExceptionExpiryWorker,ExceptionLifecycleWorker); expiry warnings are emitted to the unified audit/Timeline emitter (IExceptionNotificationService, disabled by default), not the standalonesrc/Notifymodule. 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
| Actor | Type | Role in the implemented flow |
|---|---|---|
| Requester | Human | Creates an exception object (policy:author) or an approval request (exceptions:request). |
| Approver | Human | Approves/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 | Service | Hosts the exception and approval-request APIs and evaluates approval rules. Where an exception object takes effect is described under Exception Enforcement. |
| Expiry / lifecycle workers | Service (background) | Hosted BackgroundService workers in the Policy Engine that record expiry 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). Expiry warnings are emitted to the unified audit/Timeline emitter, not to the standalone
src/Notifychannel-dispatch module (no email/Slack templates exist). See Expiration & Lifecycle and Not Implemented / Roadmap.
Prerequisites
- Caller authenticated with the appropriate scope (see Authorization).
- Tenant context present (all endpoint groups call
RequireTenant()). - For the gate-level workflow: approval rules exist for the tenant, or the built-in per-gate defaults apply.
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)
| Operation | Required scope |
|---|---|
| List / counts / get / history / expiring | policy:read |
| Create (POST) / update (PUT) | policy:author |
| Approve / activate / extend / revoke (DELETE) | policy:operate |
Exception Approval Requests (/api/v1/policy/exception)
| Operation | Required scope |
|---|---|
| Create request / cancel | exceptions:request |
| Get / list / audit / rules | exceptions:read |
| List pending (approver inbox) / approve / reject | exceptions: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)
| Status | Meaning |
|---|---|
proposed | Requested, awaiting approval. |
approved | Approved, awaiting activation. |
active | Currently affecting policy evaluation. |
expired | expires_at reached. |
revoked | Explicitly revoked (or rejected before approval). |
Type
| Type | Meaning |
|---|---|
vulnerability | Exception for a specific CVE/CWE. |
policy | Bypass of a specific policy rule. |
unknown | Allow release despite unknown findings. |
component | Exception 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.
| Field | Notes |
|---|---|
artifact_digest | sha256:..., exact match (most specific). |
purl_pattern | Supports * wildcards (e.g. pkg:npm/lodash@*, pkg:maven/org.apache.logging.log4j/*). |
vulnerability_id | Exact CVE/CWE ID. |
policy_rule_id | Policy rule being bypassed. |
environments | Allowed environments; empty array = all environments. |
tenant_id | Owning tenant, stored as text. The create route stores the tenant of the authenticated request and the approval workflow stores the approval request’s tenant; a create without a tenant is refused. The list, counts, by-id, history and expiring reads and the update, approve, activate, extend and revoke routes see only the caller’s tenant: another tenant’s exception id answers 404, as an unknown id does. Objects created before the tenant was stored are kept under tenant default and are not reassigned. |
Gate-Level Approval Rules
The approval-request workflow (/api/v1/policy/exception) routes each request to a gate level (G0–G4). If no tenant-specific ExceptionApprovalRuleEntity is configured (and enabled), these built-in defaults apply (ApprovalRequirements.GetDefault):
| Gate | MinApprovers | Required roles | MaxTtlDays | Self-approval | Evidence req. | Comp. controls req. | MinRationaleLength |
|---|---|---|---|---|---|---|---|
| G0 (informational) | 0 (auto-approve) | — | 90 | yes | no | no | 0 |
| G1 (low) | 1 | — | 60 | yes | no | no | 20 |
| G2 (medium) | 1 | code-owner | 30 | no | yes | no | 50 |
| G3 (high) | 2 | delivery-manager, product-manager | 14 | no | yes | yes | 100 |
| G4 (critical) | 3 | ciso, delivery-manager, product-manager | 7 | no | yes | yes | 200 |
Notes:
- Unparseable gate level defaults to G1; unparseable reason code defaults to other.
- Requested TTL is rejected if it exceeds the gate’s
MaxTtlDays. G0requests withMinApprovers == 0are auto-approved at creation (CanAutoApproveAsync).- A global
MinJustificationLength(default 10) is enforced on every request.
Flow Diagram (gate-level approval request)
┌──────────────────────────────────────────────────────────────────────┐
│ Exception Approval Request Workflow (gate-level) │
└──────────────────────────────────────────────────────────────────────┘
┌───────────┐ ┌──────────────────────┐ ┌──────────────┐
│ Requester │ │ Policy Engine │ │ 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:
- Generates
requestId=EAR-{yyyyMMdd}-{8 hex}. - Resolves requirements (tenant rule if present and enabled, else gate default).
- Validates 1 ≤ TTL ≤
MaxTtlDays(days), rationale length, evidence/compensating-control presence, justification length, and required-approver count. - Sets
requestExpiresAt = now + 7 days(approval window, enforced at approve) andexceptionExpiresAt = now + requestedTtlDays. - Auto-approves when the gate requires 0 approvers (G0).
- Records audit entry
requested(sequence 1).
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:
- Request must be
PendingorPartial. - Caller must not have already approved.
- Self-approval rejected unless the gate allows it.
- The approval happens before
exceptionExpiresAtand beforerequestExpiresAt. At or after either instant the approve answers400and the request is unchanged; the requester submits a new request. Nothing moves such a request toExpired: it stays listed asPendinguntil it is rejected or cancelled. - If
requiredApproverIdsis non-empty, caller must be in it. - Required roles are tracked toward completion.
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
GET /api/v1/policy/exception/{requestId}/audit— ordered audit trail (exceptions:read).GET /api/v1/policy/exception/rules— tenant approval rules (exceptions:read).
Step-by-Step (exception object lifecycle)
The /api/policy/exceptions surface manages the exception object directly.
| Step | Endpoint | Scope | Notes |
|---|---|---|---|
| Create | POST /api/policy/exceptions | policy:author | Status proposed. Validates expiry is in the future and ≤ 1 year. ID = EXC-{guid} (20 chars). |
| Update | PUT /api/policy/exceptions/{id} | policy:author | Mutates rationale/evidence/controls/ticket/metadata; bumps version. Cannot update expired/revoked. |
| Approve | POST /api/policy/exceptions/{id}/approve | policy:operate | Only proposed → approved, and only before expires_at. Self-approval rejected (approver ≠ requester). Appends approver. |
| Activate | POST /api/policy/exceptions/{id}/activate | policy:operate | Only approved → active, and only before expires_at. |
| Extend | POST /api/policy/exceptions/{id}/extend | policy:operate | Only active and only before expires_at; new expiry must be later than current. A lapsed exception needs a new request. |
| Revoke | DELETE /api/policy/exceptions/{id} | policy:operate | Any non-terminal → revoked. Optional reason. |
| List | GET /api/policy/exceptions | policy:read | Filters: status, type, vulnerability ID, PURL pattern, environment, owner; paginated (limit 1–100, default 50). |
| Counts | GET /api/policy/exceptions/counts | policy:read | Totals by status + expiring-soon. |
| Get | GET /api/policy/exceptions/{id} | policy:read | Full detail or 404. |
| History | GET /api/policy/exceptions/{id}/history | policy:read | Ordered ExceptionEvent audit trail. |
| Expiring | GET /api/policy/exceptions/expiring?days=7 | policy:read | Active 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)
An exception object is effective only while its status is active and the current time is before expires_at (ExceptionObject.IsEffectiveAt). Where an exception object takes effect today:
- Release promotion gate: no effect. The Policy pack evaluation that Release Orchestrator calls for a promotion (
PolicyRuntimeEvaluator) reads no exception object, so an exception object neither allows nor blocks a promotion, an approval or a deployment. The override for a release gate is Release Orchestrator’s own signed, time-limited gate exception, which its guard re-checks at approve and again at dispatch. - Findings exposure. Findings reads active exception objects through the list route (
HttpPolicyExceptionExposureReader). - Lifecycle routes. Approve, activate and extend refuse an object whose
expires_athas passed, whether or not the expiry worker has stored it asexpired.
The library evaluator IExceptionEvaluator.EvaluateAsync (StellaOps.Policy.Exceptions) has no consumer in the product. Its only caller, the exception recheck build gate, and the exception adapter for runtime evaluation (AddPolicyExceptionIntegration) were never registered by the Policy Engine host and were withdrawn on 2026-10-11 (SPRINT_20261007_028 CCP-073). Given a FindingContext (artifactDigest, purl, vulnerabilityId, policyRuleId, environment, tenantId), the evaluator:
- Queries active exceptions for the scope (
GetActiveByScopeAsync). - 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)(statusActiveand not pastexpires_at). - 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.
- Returns
HasException, all matching exceptions, the primary reason/rationale, and the union of evidence references.
Recheck Policy & Evidence Requirements (implemented, optional)
The StellaOps.Policy.Exceptions library defines additional governance primitives that an exception object may carry:
RecheckPolicy(RecheckConditionType:ReachGraphChange,EPSSAbove,CVSSAbove,UnknownsAbove, …) — conditions that trigger re-evaluation. When triggered, the recommendedRecheckActioncanBlockan exception (IsBlockedByRecheck) orRequireReapproval(RequiresReapproval).EvidenceHook(EvidenceType:FeatureFlagDisabled,BackportMerged,CompensatingControl, …) — mandatory/optional evidence requirements with freshness (MaxAge) and trust-score (MinTrustScore) constraints, validated byEvidenceRequirementValidator.
These are part of the model and library services only. The create and update requests carry no recheckPolicy and no route, worker or gate evaluates recheck conditions (the unregistered exception recheck build gate was withdrawn on 2026-10-11), so a triggered condition changes nothing today. See Recheck Policy Reference.
Expiration & Lifecycle
- Exceptions are time-bounded by
expires_at(object) /exceptionExpiresAt(approval request). Object expiry is capped at 1 year at creation. - An object stops being effective at its
expires_atinstant (IsEffectiveAt(now)), and from that instant approve, activate and extend refuse it, on the routes and inExceptionServicealike. The stored status changes toexpiredonly when an expiry sweep next runs (the expiry worker hourly by default), so a list filtered onstatus=activecan still return a lapsed object until then. The repository exposesGetExpiredActiveAsync()(active rows past expiry, every tenant),GetExpiredActiveAsync(tenantId)(one tenant) andGetExpiringAsync(horizon)/ the/expiringendpoint (proactive surfacing). - The approval workflow refuses an approval at or after the request’s
exceptionExpiresAtand refuses a TTL below one day, so it does not materialize an object that has already lapsed. - One expiry path. Every expiry goes through one audited transition (
ExceptionExpiryWorker.ExpireAsync): the object moves fromactivetoexpiredwith the next version and oneExpiredhistory event bysystem:expiry-worker, in one repository transaction, whichever worker runs it (see the worker table below). When both workers reach the same object, the version check lets one of them write it; the other writes nothing and the object has oneExpiredevent. Nothing else moves an object toexpired. The model factoryExceptionEvent.ForExpired(actor"system") has no production caller, and the schema functionpolicy.mark_expired_exceptions()(baseline migration; every tenant, actorsystem, database clock) has no caller.
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<…>():
| Worker | Service / registration | Cadence | What it does |
|---|---|---|---|
ExceptionExpiryWorker | Policy Engine (Program.cs) | hourly (Interval, default 1h; InitialDelay 30s), on the host TimeProvider | Loads GetExpiredActiveAsync() (every tenant) and applies the audited transition: active → expired with a new version and an Expired history event (system actor system:expiry-worker, with ConcurrencyException retry-next-cycle); earlier events are kept. Then loads GetExpiringAsync(WarningHorizon) (default 7 days) and calls IExceptionNotificationService.NotifyExceptionExpiringSoonAsync once when the time until expiry crosses the 7-, 3- or 1-day threshold within the window since its previous successful read (CrossedWarningThreshold). Gated by ExceptionExpiryOptions.Enabled (default true; section Policy:Exceptions:Expiry). |
ExceptionLifecycleWorker (+ ExceptionLifecycleService) | Policy Engine (Program.cs) | polling (PollIntervalSeconds, default 60s), on the host TimeProvider | Per-configured-tenant expiry sweep: for each tenant in PolicyEngine:ResourceServer:RequiredTenants it loads GetExpiredActiveAsync(tenant) and applies the same audited transition as ExceptionExpiryWorker (new version, one Expired event by system:expiry-worker). With no tenant configured it does nothing; the compose files under devops/compose configure none for policy-engine. 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 Engine.
Lifecycle notifications (verified)
IExceptionNotificationService (interface in StellaOps.Policy.Engine/Services/Gateway/ExceptionService.cs) has these hooks. Only the expiry worker’s warning is fired today: the other five are fired by ExceptionService, which no route or worker calls (see the note below).
| Hook | Fired from | Emitted action |
|---|---|---|
NotifyExceptionCreatedAsync | ExceptionService.CreateAsync | exception.created |
NotifyExceptionApprovedAsync | ExceptionService.ApproveAsync | exception.approved (incl. approverId) |
NotifyExceptionActivatedAsync | ExceptionService.ActivateAsync | exception.activated |
NotifyExceptionExtendedAsync | ExceptionService.ExtendAsync | exception.extended (incl. newExpiry) |
NotifyExceptionRevokedAsync | ExceptionService.RevokeAsync | exception.revoked (incl. reason; severity warning) |
NotifyExceptionExpiringSoonAsync | ExceptionExpiryWorker | exception.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:
ExceptionService(IExceptionService) is registered in the Policy Engine but has no caller. The minimal-API handlers documented under exception object lifecycle operate directly onIExceptionRepositoryand therefore do not emit these notifications; the audit history (/history) is recorded via the repository.ExceptionServiceapplies the same rules as the routes: every call names the tenant, another tenant’s exception answersNotFound, and approve, activate and extend refuse an exception at or after itsexpires_at.
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
| Condition | HTTP | Source |
|---|---|---|
| Missing request body | 400 | both surfaces |
| Object expiry in the past or > 1 year | 400 | ExceptionEndpoints create |
| Update/revoke an already expired/revoked object | 400 | ExceptionEndpoints |
Approve a non-proposed object, or one past expires_at | 400 | ExceptionEndpoints approve |
| Self-approval of object (approver == requester) | 400 | ExceptionEndpoints approve |
Activate a non-approved object, or one past expires_at | 400 | ExceptionEndpoints activate |
Extend a non-active object or one past expires_at, or with new expiry ≤ current | 400 | ExceptionEndpoints extend |
Request TTL > gate MaxTtlDays | 400 | CreateApprovalRequestAsync / rules service |
| Validation failure (rationale/evidence/controls/justification) | 400 | ValidateRequestAsync |
| Approval not allowed (state, dup, self, not-in-list, roles) | 400 | ValidateApprovalActionAsync |
| Reject without reason | 400 | RejectRequestAsync |
| Cancel by non-owner | 403 | CancelRequestAsync |
| Request/exception not found | 404 | both surfaces |
| Missing tenant / actor | 401 | approval 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)
| Endpoint | Audit action | Resource type |
|---|---|---|
POST /request | create | exception_approval_request |
POST /{id}/approve | approve | exception_approval_request |
POST /{id}/reject | reject | exception_approval_request |
POST /{id}/cancel | cancel | exception_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:
- Exception types
temporary/extended/permanent/emergencywith fixed max durations (30/90/indefinite/7 days). The implemented object types arevulnerability/policy/unknown/component; duration limits come from gate-levelMaxTtlDays, not a type/duration table. - Severity-driven approval matrix routing to Team Lead → Security Team → CISO. The implemented routing is by gate level (G0–G4) with configurable role strings (
code-owner,delivery-manager,product-manager,ciso). - External Scheduler/JobEngine job for expiry. Implemented as in-process hosted
BackgroundServiceworkers instead (ExceptionExpiryWorker,ExceptionLifecycleWorker) — see Background workers. Automatic deactivation (active → expired) and 7-/3-/1-day expiry warnings are implemented; a distinct “mid-point review” reminder is not. - Notify-module dispatch for approval/expiry emails or Slack (
approval_url,#security-approvals). Expiry warnings are emitted, but to the unified audit/Timeline emitter (IExceptionNotificationService→AuditExceptionNotificationService, disabled by default) — see Lifecycle notifications. There is no email/Slack channel dispatch through the standalonesrc/Notifymodule. scope.images/scope.tenantsarrays on the exception object. The object scope isartifactDigest/purlPattern/vulnerabilityId/policyRuleId/environments(+tenantId). AnimagePatternfield exists only on the approval-request entity, not on the exception object scope.- Object statuses
pending/rejected. The object state machine usesproposed/approved/active/expired/revoked;pending/rejectedbelong to the separate approval-request status set. - Renewal request as a distinct API. The implemented mechanism is
POST /api/policy/exceptions/{id}/extendon an active object.
Related Flows
- Policy Evaluation Flow — exception enforcement during evaluation
- Multi-Tenant Policy Rollout Flow — policy/tenant context
