refactor(policy): merge policy gateway into policy-engine

- Move 24 gateway source files (endpoints, services, contracts) into engine
  under Endpoints/Gateway/, Services/Gateway/, Contracts/Gateway/ namespaces
- Add gateway DI registrations and endpoint mappings to engine Program.cs
- Add missing project references (StellaOps.Policy.Scoring, DeltaVerdict, Localization)
- Remove HTTP proxy layer (PolicyEngineClient, DPoP, forwarding context not copied)
- Update gateway routes in router appsettings to point to policy-engine
- Comment out policy service in docker-compose, add backwards-compat network alias
- Update services-matrix (gateway build line commented out)
- Update all codebase references: AdvisoryAI, JobEngine, CLI, router tests, helm
- Update docs: OFFLINE_KIT, configuration-migration, gateway guide, port-registry
- Deprecate etc/policy-gateway.yaml.sample with notice
- Eliminates 1 container, 9 HTTP round-trips, DPoP token flow

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
master
2026-04-08 13:19:09 +03:00
parent 9eec100204
commit c1ecc75ace
40 changed files with 12135 additions and 116 deletions

View File

@@ -21,7 +21,7 @@
127.1.0.12 vexlens.stella-ops.local
127.1.0.13 vulnexplorer.stella-ops.local
127.1.0.14 policy-engine.stella-ops.local
127.1.0.15 policy-gateway.stella-ops.local
127.1.0.14 policy-gateway.stella-ops.local # backwards-compat alias (merged into policy-engine)
127.1.0.16 riskengine.stella-ops.local
127.1.0.17 orchestrator.stella-ops.local
127.1.0.18 taskrunner.stella-ops.local

View File

@@ -5,7 +5,7 @@
{{- if $hasPolicyActivationConfig -}}
{{- $policyActivationConfigName = include "stellaops.fullname" (dict "root" $root "name" "policy-engine-activation") -}}
{{- end -}}
{{- $policyActivationTargets := dict "policy-engine" true "policy-gateway" true -}}
{{- $policyActivationTargets := dict "policy-engine" true -}}
{{- range $name, $svc := .Values.services }}
{{- $configMounts := (default (list) $svc.configMounts) }}
apiVersion: apps/v1

View File

@@ -198,12 +198,15 @@ Outputs:
- `telemetry/telemetry-offline-bundle.tar.gz` + `.sha256` — packaged OTLP collector assets for environments without upstream access
- `plugins/scanner/analyzers/lang/StellaOps.Scanner.Analyzers.Lang.Python/*.sig` (+ `.sha256`) — Cosign signatures for the Python analyzer DLL and manifest
### Policy Gateway configuration bundle
### Policy Engine configuration bundle (formerly Policy Gateway)
- Copy `etc/policy-gateway.yaml` (or the `*.sample` template if you expect operators to override values) into `config/policy-gateway/policy-gateway.yaml` within the staging tree.
- Include the gateway DPoP private key under `secrets/policy-gateway/policy-gateway-dpop.pem` and reference the location inside the manifest notes. Set the permissions explicitly (`chmod 600 secrets/policy-gateway/policy-gateway-dpop.pem`) so only the kit importer can read it; the importer will refuse keys that are broader.
- Document the gateway base URL and activation verification steps in `docs/modules/policy/guides/gateway.md` (bundled alongside the kit). Operators can use those curl snippets to smoke-test pack CRUD once the Offline Kit is imported.
- Ensure the Prometheus snapshot captured during packaging contains `policy_gateway_activation_requests_total` so auditors can reconcile activation attempts performed via the gateway during the validation window.
> **Note:** The Policy Gateway service has been merged into Policy Engine.
> All gateway endpoints (exceptions, deltas, gates, governance, tool-lattice)
> are now served directly by the policy-engine process.
- Copy `etc/policy-engine.yaml` (or the `*.sample` template if you expect operators to override values) into `config/policy-engine/policy-engine.yaml` within the staging tree.
- Document the policy-engine base URL and activation verification steps in `docs/modules/policy/guides/gateway.md` (bundled alongside the kit). Operators can use those curl snippets to smoke-test pack CRUD once the Offline Kit is imported.
- Ensure the Prometheus snapshot captured during packaging contains `policy_engine_activation_requests_total` so auditors can reconcile activation attempts performed during the validation window.
Provide `--cosign-key` / `--cosign-identity-token` (and optional `--cosign-password`) to generate Cosign signatures for both the tarball and manifest.

View File

@@ -1,17 +1,22 @@
# Policy Gateway
# Policy Gateway (Merged into Policy Engine)
> **Delivery scope:** `StellaOps.Policy.Gateway` minimal API service fronting Policy Engine pack CRUD + activation endpoints for UI/CLI clients. Sender-constrained with DPoP and tenant headers, suitable for online and Offline Kit deployments.
> **Status:** The `StellaOps.Policy.Gateway` service has been **merged into
> `StellaOps.Policy.Engine`**. All gateway endpoints (exceptions, deltas,
> gates, governance, tool-lattice, pack CRUD, activation) are now served
> directly by the policy-engine process. The separate gateway container,
> HTTP proxy layer, DPoP token flow, and `PolicyEngineClient` have been
> removed.
## 1 · Responsibilities
## 1 - Responsibilities (now handled by Policy Engine)
- Proxy policy pack CRUD and activation requests to Policy Engine while enforcing scope policies (`policy:read`, `policy:author`, `policy:review`, `policy:operate`, `policy:activate`).
- Normalise responses (DTO + `ProblemDetails`) so Console, CLI, and automation receive consistent payloads.
- Guard activation actions with structured logging and metrics so approvals are auditable.
- Support dual auth modes:
- Forwarded caller tokens (Console/CLI) with DPoP proofs + `X-Stella-Tenant` header.
- Gateway client credentials (DPoP) for service automation or Offline Kit flows when no caller token is present.
- Policy pack CRUD and activation endpoints enforcing scope policies (`policy:read`, `policy:author`, `policy:review`, `policy:operate`, `policy:activate`).
- Normalised responses (DTO + `ProblemDetails`) so Console, CLI, and automation receive consistent payloads.
- Structured logging and metrics for activation actions so approvals are auditable.
- Exception management, delta computation, gate evaluation, governance, and tool-lattice endpoints.
## 2 · Endpoints
## 2 - Endpoints
All endpoints below are served by the **policy-engine** service.
| Route | Method | Description | Required scope(s) |
|-------|--------|-------------|-------------------|
@@ -23,116 +28,76 @@
### Response shapes
- Successful responses return camel-case DTOs matching `PolicyPackDto`, `PolicyRevisionDto`, or `PolicyRevisionActivationDto` as described in the Policy Engine API doc (`/docs/api/policy.md`).
- Errors always return RFC 7807 `ProblemDetails` with deterministic fields (`title`, `detail`, `status`). Missing caller credentials now surface `401` with `"Upstream authorization missing"` detail.
- Errors always return RFC 7807 `ProblemDetails` with deterministic fields (`title`, `detail`, `status`).
### Dual-control activation
- **Config-driven.** Set `PolicyEngine.activation.forceTwoPersonApproval=true` when every activation must collect two distinct `policy:activate` approvals. When false, operators can opt into dual-control per revision (`requiresTwoPersonApproval: true`).
- **Defaults.** `PolicyEngine.activation.defaultRequiresTwoPersonApproval` feeds the default when callers omit the checkbox/flag.
- **Statuses.** First approval on a dual-control revision returns `202 pending_second_approval`; duplicate actors get `400 duplicate_approval`; the second distinct approver receives the usual `200 activated`.
- **Audit trail.** With `PolicyEngine.activation.emitAuditLogs` on, Policy Engine emits structured `policy.activation.*` scopes (pack id, revision, tenant, approver IDs, comments) so the gateway metrics/ELK dashboards can show who approved what.
- **Audit trail.** With `PolicyEngine.activation.emitAuditLogs` on, Policy Engine emits structured `policy.activation.*` scopes (pack id, revision, tenant, approver IDs, comments).
#### Activation configuration wiring
- **Helm ConfigMap.** `devops/helm/stellaops/values*.yaml` now include a `policy-engine-activation` ConfigMap. The chart automatically injects it via `envFrom` into both the Policy Engine and Policy Gateway pods, so overriding the ConfigMap data updates the services with no manifest edits.
- **Type safety.** Quote ConfigMap values (e.g., `"true"`, `"false"`) because Kubernetes ConfigMaps carry string data. This mirrors the defaults checked into the repo and keeps `helm template` deterministic.
- **File-based overrides (optional).** The Policy Engine host already probes `/config/policy-engine/activation.yaml`, `../etc/policy-engine.activation.yaml`, and ambient `policy-engine.activation.yaml` files beside the binary. Mounting the ConfigMap as a file at `/config/policy-engine/activation.yaml` works immediately if/when we add a volume.
- **Helm ConfigMap.** `devops/helm/stellaops/values*.yaml` now include a `policy-engine-activation` ConfigMap. The chart automatically injects it via `envFrom` into the Policy Engine pod, so overriding the ConfigMap data updates the service with no manifest edits.
- **Type safety.** Quote ConfigMap values (e.g., `"true"`, `"false"`) because Kubernetes ConfigMaps carry string data.
- **File-based overrides (optional).** The Policy Engine host probes `/config/policy-engine/activation.yaml`, `../etc/policy-engine.activation.yaml`, and ambient `policy-engine.activation.yaml` files beside the binary.
- **Offline/Compose.** Compose/offline bundles can continue exporting `STELLAOPS_POLICY_ENGINE__ACTIVATION__*` variables directly; the ConfigMap wiring simply mirrors those keys for Kubernetes clusters.
## 3 · Authentication & headers
## 3 - Authentication & headers
| Header | Source | Notes |
|--------|--------|-------|
| `Authorization` | Forwarded caller token *or* gateway client credentials. | Caller tokens must include tenant scope; gateway tokens default to `DPoP` scheme. |
| `DPoP` | Caller or gateway. | Required when Authority mandates proof-of-possession (default). Generated per request; gateway keeps ES256/ES384 key material under `etc/policy-gateway-dpop.pem`. |
| `X-Stella-Tenant` | Caller | Tenant isolation header. Forwarded unchanged; gateway automation omits it. |
| `Authorization` | Caller token. | Caller tokens must include tenant scope. |
| `X-Stella-Tenant` | Caller | Tenant isolation header. |
Gateway client credentials are configured in `policy-gateway.yaml`:
> **Note:** The previous DPoP proxy layer (gateway client credentials, `PolicyEngineClient`,
> `PolicyGatewayDpopHandler`) has been removed. Callers authenticate directly with Policy Engine
> using standard StellaOps resource server authentication.
```yaml
policyEngine:
baseAddress: "https://policy-engine.internal"
audience: "api://policy-engine"
clientCredentials:
enabled: true
clientId: "policy-gateway"
clientSecret: "<secret>"
scopes:
- policy:read
- policy:author
- policy:review
- policy:operate
- policy:activate
dpop:
enabled: true
keyPath: "../etc/policy-gateway-dpop.pem"
algorithm: "ES256"
```
> 🔐 **DPoP key** store the private key alongside Offline Kit secrets; rotate it whenever the gateway identity or Authority configuration changes.
## 4 · Metrics & logging
## 4 - Metrics & logging
All activation calls emit:
- `policy_gateway_activation_requests_total{outcome,source}` counter labelled with `outcome` (`activated`, `pending_second_approval`, `already_active`, `bad_request`, `not_found`, `unauthorized`, `forbidden`, `error`) and `source` (`caller`, `service`).
- `policy_gateway_activation_latency_ms{outcome,source}` histogram measuring proxy latency.
- `policy_engine_activation_requests_total{outcome,source}` -- counter labelled with `outcome` (`activated`, `pending_second_approval`, `already_active`, `bad_request`, `not_found`, `unauthorized`, `forbidden`, `error`) and `source` (`caller`, `service`).
Structured logs (category `StellaOps.Policy.Gateway.Activation`) include `PackId`, `Version`, `Outcome`, `Source`, and upstream status code for audit trails.
Structured logs (category `StellaOps.Policy.Engine.Activation`) include `PackId`, `Version`, `Outcome`, `Source`, and status code for audit trails.
## 5 · Sample `curl` workflows
## 5 - Sample `curl` workflows
Assuming you already obtained a DPoP-bound access token (`$TOKEN`) for tenant `acme`:
Assuming you already obtained an access token (`$TOKEN`) for tenant `acme`:
```bash
# Generate a DPoP proof for GET via the CLI helper
DPoP_PROOF=$(stella auth dpop proof \
--htu https://gateway.example.com/api/policy/packs \
--htm GET \
--token "$TOKEN")
curl -sS https://gateway.example.com/api/policy/packs \
-H "Authorization: DPoP $TOKEN" \
-H "DPoP: $DPoP_PROOF" \
-H "Authorization: Bearer $TOKEN" \
-H "X-Stella-Tenant: acme"
# Draft a new revision
DPoP_PROOF=$(stella auth dpop proof \
--htu https://gateway.example.com/api/policy/packs/policy.core/revisions \
--htm POST \
--token "$TOKEN")
curl -sS https://gateway.example.com/api/policy/packs/policy.core/revisions \
-H "Authorization: DPoP $TOKEN" \
-H "DPoP: $DPoP_PROOF" \
-H "Authorization: Bearer $TOKEN" \
-H "X-Stella-Tenant: acme" \
-H "Content-Type: application/json" \
-d '{"version":5,"requiresTwoPersonApproval":true,"initialStatus":"Draft"}'
# Activate revision 5 (returns 202 when awaiting the second approver)
DPoP_PROOF=$(stella auth dpop proof \
--htu https://gateway.example.com/api/policy/packs/policy.core/revisions/5:activate \
--htm POST \
--token "$TOKEN")
curl -sS https://gateway.example.com/api/policy/packs/policy.core/revisions/5:activate \
-H "Authorization: DPoP $TOKEN" \
-H "DPoP: $DPoP_PROOF" \
-H "Authorization: Bearer $TOKEN" \
-H "X-Stella-Tenant: acme" \
-H "Content-Type: application/json" \
-d '{"comment":"Rollout baseline"}'
```
For air-gapped environments, bundle `policy-gateway.yaml` and the DPoP key in the Offline Kit (see `/docs/OFFLINE_KIT.md` §5.7).
## 6 - Offline Kit guidance
> **DPoP proof helper:** Use `stella auth dpop proof` to mint sender-constrained proofs locally. The command accepts `--htu`, `--htm`, and `--token` arguments and emits a ready-to-use header value. Teams maintaining alternate tooling (for example, `scripts/make-dpop.sh`) can substitute it as long as the inputs and output match the CLI behaviour.
- Include `policy-engine.yaml.sample` and the resolved runtime config in the Offline Kit's `config/` tree.
- When building verification scripts, use the policy-engine endpoints above. The Offline Kit validator expects `policy_engine_activation_requests_total` metrics in the Prometheus snapshot.
## 6 · Offline Kit guidance
## 7 - Backwards compatibility
- Include `policy-gateway.yaml.sample` and the resolved runtime config in the Offline Kits `config/` tree.
- Place the DPoP private key under `secrets/policy-gateway-dpop.pem` with restricted permissions; document rotation steps in the manifest.
- When building verification scripts, use the gateway endpoints above instead of hitting Policy Engine directly. The Offline Kit validator now expects `policy_gateway_activation_requests_total` metrics in the Prometheus snapshot.
- The `policy-gateway.stella-ops.local` hostname is preserved as a network alias on the policy-engine container in both docker-compose and hosts files. Existing configurations referencing `policy-gateway.stella-ops.local` will continue to work.
- The `STELLAOPS_POLICY_GATEWAY_URL` environment variable has been removed from service defaults. Services that previously used it should use `STELLAOPS_POLICY_ENGINE_URL` instead.
## 7 · Change log
## 8 - Change log
- **2025-10-27 Sprint 18.5**: Initial gateway bootstrap + activation metrics + DPoP client credentials.
- **2026-04-08 -- Gateway merge**: Gateway merged into Policy Engine. Separate container, HTTP proxy layer, DPoP flow, and `PolicyEngineClient` removed. All endpoints served directly by policy-engine.
- **2025-10-27 -- Sprint 18.5**: Initial gateway bootstrap + activation metrics + DPoP client credentials.

View File

@@ -163,10 +163,13 @@ authority:
- ../../etc/certificates/signing:/app/etc/signing:ro
```
### Policy Gateway
### Policy Engine (formerly Policy Gateway)
> **Note:** The `policy-gateway` service has been merged into `policy-engine`.
> Mount policy configuration on the `policy-engine` service instead.
```yaml
policy-gateway:
policy-engine:
volumes:
- ../../etc/policy:/app/etc/policy:ro
```

View File

@@ -1,3 +1,8 @@
# DEPRECATED: The Policy Gateway has been merged into Policy Engine.
# This file is kept for reference only. Use policy-engine.yaml instead.
# See docs/modules/policy/guides/gateway.md for migration guidance.
#
# Original description:
# StellaOps Policy Gateway configuration template.
# Copy to ../etc/policy-gateway.yaml (relative to the gateway content root)
# and adjust values to fit your environment. Environment variables prefixed with

View File

@@ -159,7 +159,7 @@ public sealed class KnowledgeSearchOptions
/// <summary>When false the live VEX adapter is skipped entirely.</summary>
public bool VexAdapterEnabled { get; set; } = true;
/// <summary>Base URL for the Policy Gateway service (e.g. "http://policy-gateway:8080").</summary>
/// <summary>Base URL for the Policy Engine service (e.g. "http://policy-engine:8080").</summary>
public string PolicyAdapterBaseUrl { get; set; } = string.Empty;
/// <summary>When false the live policy adapter is skipped entirely.</summary>

View File

@@ -518,7 +518,7 @@ internal sealed class FtsRecallBenchmarkStore : IKnowledgeSearchStore
"POST",
"/v1/policy/evaluate",
"evaluatePolicy",
"policy-gateway",
"policy-engine",
["policy", "evaluate", "gate"]),
MakeApi(

View File

@@ -49,9 +49,9 @@ public sealed class GateEvaluationOptions
public bool NotifyOnFailure { get; set; } = true;
/// <summary>
/// Policy Gateway base URL.
/// Policy Engine base URL (gateway merged into engine).
/// </summary>
public string PolicyGatewayUrl { get; set; } = "http://policy-gateway:8080";
public string PolicyGatewayUrl { get; set; } = "http://policy-engine:8080";
}
/// <summary>

View File

@@ -3,7 +3,7 @@
> Sprint: SPRINT_3500_0002_0001 (Smart-Diff Foundation)
## Roles
- **Backend / Policy Engineer**: .NET 10 (preview) for policy engine, gateways, scoring; keep evaluation deterministic.
- **Backend / Policy Engineer**: .NET 10 (preview) for policy engine, scoring; keep evaluation deterministic.
- **QA Engineer**: Adds policy test fixtures, regression tests under `__Tests`.
- **Docs Touches (light)**: Update module docs when contracts change; mirror in sprint notes.
@@ -16,7 +16,8 @@
- Current sprint file
## Working Directory & Boundaries
- Primary scope: `src/Policy/**` (Engine, Gateway, Registry, RiskProfile, Scoring, __Libraries, __Tests).
- Primary scope: `src/Policy/**` (Engine, RiskProfile, Scoring, __Libraries, __Tests).
- Note: The Policy Gateway has been merged into Policy Engine. The `StellaOps.Policy.Gateway` project is retained for reference only.
- Avoid cross-module edits unless sprint explicitly permits.
## Suppression Contracts (Sprint 3500)
@@ -72,7 +73,7 @@ The Policy module includes suppression primitives for Smart-Diff:
- Local alias: https://policy-engine.stella-ops.local, http://policy-engine.stella-ops.local
- Env var: STELLAOPS_POLICY_ENGINE_URL
### Policy Gateway (Slot 15)
- Development: https://localhost:10150, http://localhost:10151
- Local alias: https://policy-gateway.stella-ops.local, http://policy-gateway.stella-ops.local
- Env var: STELLAOPS_POLICY_GATEWAY_URL
### Policy Gateway (Slot 15 -- DEPRECATED, merged into Policy Engine)
- All gateway endpoints are now served by Policy Engine (Slot 14).
- The `policy-gateway.stella-ops.local` hostname is a backwards-compat alias for `policy-engine.stella-ops.local`.
- The `STELLAOPS_POLICY_GATEWAY_URL` env var has been removed.

View File

@@ -0,0 +1,294 @@
// SPDX-License-Identifier: BUSL-1.1
// Sprint: SPRINT_4100_0004_0001 - Security State Delta & Verdict
// Task: T6 - Add Delta API endpoints
using PolicyDeltaSummary = StellaOps.Policy.Deltas.DeltaSummary;
using StellaOps.Policy.Deltas;
using System.ComponentModel.DataAnnotations;
namespace StellaOps.Policy.Engine.Contracts.Gateway;
/// <summary>
/// Request to compute a security state delta.
/// </summary>
public sealed record ComputeDeltaRequest
{
/// <summary>
/// Artifact digest (required).
/// </summary>
[Required]
public required string ArtifactDigest { get; init; }
/// <summary>
/// Artifact name (optional).
/// </summary>
public string? ArtifactName { get; init; }
/// <summary>
/// Artifact tag (optional).
/// </summary>
public string? ArtifactTag { get; init; }
/// <summary>
/// Target snapshot ID (required).
/// </summary>
[Required]
public required string TargetSnapshotId { get; init; }
/// <summary>
/// Explicit baseline snapshot ID (optional).
/// If not provided, baseline selection strategy is used.
/// </summary>
public string? BaselineSnapshotId { get; init; }
/// <summary>
/// Baseline selection strategy (optional, defaults to LastApproved).
/// Values: PreviousBuild, LastApproved, ProductionDeployed, BranchBase
/// </summary>
public string? BaselineStrategy { get; init; }
}
/// <summary>
/// Response from computing a security state delta.
/// </summary>
public sealed record ComputeDeltaResponse
{
/// <summary>
/// The computed delta ID.
/// </summary>
public required string DeltaId { get; init; }
/// <summary>
/// Baseline snapshot ID used.
/// </summary>
public required string BaselineSnapshotId { get; init; }
/// <summary>
/// Target snapshot ID.
/// </summary>
public required string TargetSnapshotId { get; init; }
/// <summary>
/// When the delta was computed.
/// </summary>
public required DateTimeOffset ComputedAt { get; init; }
/// <summary>
/// Summary statistics.
/// </summary>
public required DeltaSummaryDto Summary { get; init; }
/// <summary>
/// Number of drivers identified.
/// </summary>
public int DriverCount { get; init; }
}
/// <summary>
/// Summary statistics DTO.
/// </summary>
public sealed record DeltaSummaryDto
{
public int TotalChanges { get; init; }
public int RiskIncreasing { get; init; }
public int RiskDecreasing { get; init; }
public int Neutral { get; init; }
public decimal RiskScore { get; init; }
public required string RiskDirection { get; init; }
public static DeltaSummaryDto FromModel(PolicyDeltaSummary summary) => new()
{
TotalChanges = summary.TotalChanges,
RiskIncreasing = summary.RiskIncreasing,
RiskDecreasing = summary.RiskDecreasing,
Neutral = summary.Neutral,
RiskScore = summary.RiskScore,
RiskDirection = summary.RiskDirection
};
}
/// <summary>
/// Full delta response DTO.
/// </summary>
public sealed record DeltaResponse
{
public required string DeltaId { get; init; }
public required DateTimeOffset ComputedAt { get; init; }
public required string BaselineSnapshotId { get; init; }
public required string TargetSnapshotId { get; init; }
public required ArtifactRefDto Artifact { get; init; }
public required SbomDeltaDto Sbom { get; init; }
public required ReachabilityDeltaDto Reachability { get; init; }
public required VexDeltaDto Vex { get; init; }
public required PolicyDeltaDto Policy { get; init; }
public required UnknownsDeltaDto Unknowns { get; init; }
public required IReadOnlyList<DeltaDriverDto> Drivers { get; init; }
public required DeltaSummaryDto Summary { get; init; }
public static DeltaResponse FromModel(SecurityStateDelta delta) => new()
{
DeltaId = delta.DeltaId,
ComputedAt = delta.ComputedAt,
BaselineSnapshotId = delta.BaselineSnapshotId,
TargetSnapshotId = delta.TargetSnapshotId,
Artifact = ArtifactRefDto.FromModel(delta.Artifact),
Sbom = SbomDeltaDto.FromModel(delta.Sbom),
Reachability = ReachabilityDeltaDto.FromModel(delta.Reachability),
Vex = VexDeltaDto.FromModel(delta.Vex),
Policy = PolicyDeltaDto.FromModel(delta.Policy),
Unknowns = UnknownsDeltaDto.FromModel(delta.Unknowns),
Drivers = delta.Drivers.Select(DeltaDriverDto.FromModel).ToList(),
Summary = DeltaSummaryDto.FromModel(delta.Summary)
};
}
public sealed record ArtifactRefDto
{
public required string Digest { get; init; }
public string? Name { get; init; }
public string? Tag { get; init; }
public static ArtifactRefDto FromModel(ArtifactRef artifact) => new()
{
Digest = artifact.Digest,
Name = artifact.Name,
Tag = artifact.Tag
};
}
public sealed record SbomDeltaDto
{
public int PackagesAdded { get; init; }
public int PackagesRemoved { get; init; }
public int PackagesModified { get; init; }
public static SbomDeltaDto FromModel(SbomDelta sbom) => new()
{
PackagesAdded = sbom.PackagesAdded,
PackagesRemoved = sbom.PackagesRemoved,
PackagesModified = sbom.PackagesModified
};
}
public sealed record ReachabilityDeltaDto
{
public int NewReachable { get; init; }
public int NewUnreachable { get; init; }
public int ChangedReachability { get; init; }
public static ReachabilityDeltaDto FromModel(ReachabilityDelta reach) => new()
{
NewReachable = reach.NewReachable,
NewUnreachable = reach.NewUnreachable,
ChangedReachability = reach.ChangedReachability
};
}
public sealed record VexDeltaDto
{
public int NewVexStatements { get; init; }
public int RevokedVexStatements { get; init; }
public int CoverageIncrease { get; init; }
public int CoverageDecrease { get; init; }
public static VexDeltaDto FromModel(VexDelta vex) => new()
{
NewVexStatements = vex.NewVexStatements,
RevokedVexStatements = vex.RevokedVexStatements,
CoverageIncrease = vex.CoverageIncrease,
CoverageDecrease = vex.CoverageDecrease
};
}
public sealed record PolicyDeltaDto
{
public int NewViolations { get; init; }
public int ResolvedViolations { get; init; }
public int PolicyVersionChanged { get; init; }
public static PolicyDeltaDto FromModel(PolicyDelta policy) => new()
{
NewViolations = policy.NewViolations,
ResolvedViolations = policy.ResolvedViolations,
PolicyVersionChanged = policy.PolicyVersionChanged
};
}
public sealed record UnknownsDeltaDto
{
public int NewUnknowns { get; init; }
public int ResolvedUnknowns { get; init; }
public int TotalBaselineUnknowns { get; init; }
public int TotalTargetUnknowns { get; init; }
public static UnknownsDeltaDto FromModel(UnknownsDelta unknowns) => new()
{
NewUnknowns = unknowns.NewUnknowns,
ResolvedUnknowns = unknowns.ResolvedUnknowns,
TotalBaselineUnknowns = unknowns.TotalBaselineUnknowns,
TotalTargetUnknowns = unknowns.TotalTargetUnknowns
};
}
public sealed record DeltaDriverDto
{
public required string Type { get; init; }
public required string Severity { get; init; }
public required string Description { get; init; }
public string? CveId { get; init; }
public string? Purl { get; init; }
public static DeltaDriverDto FromModel(DeltaDriver driver) => new()
{
Type = driver.Type,
Severity = driver.Severity.ToString().ToLowerInvariant(),
Description = driver.Description,
CveId = driver.CveId,
Purl = driver.Purl
};
}
/// <summary>
/// Request to evaluate a delta verdict.
/// </summary>
public sealed record EvaluateDeltaRequest
{
/// <summary>
/// Exception IDs to apply.
/// </summary>
public IReadOnlyList<string>? Exceptions { get; init; }
}
/// <summary>
/// Delta verdict response DTO.
/// </summary>
public sealed record DeltaVerdictResponse
{
public required string VerdictId { get; init; }
public required string DeltaId { get; init; }
public required DateTimeOffset EvaluatedAt { get; init; }
public required string Status { get; init; }
public required string RecommendedGate { get; init; }
public int RiskPoints { get; init; }
public required IReadOnlyList<DeltaDriverDto> BlockingDrivers { get; init; }
public required IReadOnlyList<DeltaDriverDto> WarningDrivers { get; init; }
public required IReadOnlyList<string> AppliedExceptions { get; init; }
public string? Explanation { get; init; }
public required IReadOnlyList<string> Recommendations { get; init; }
public static DeltaVerdictResponse FromModel(StellaOps.Policy.Deltas.DeltaVerdict verdict) => new()
{
VerdictId = verdict.VerdictId,
DeltaId = verdict.DeltaId,
EvaluatedAt = verdict.EvaluatedAt,
Status = verdict.Status.ToString().ToLowerInvariant(),
RecommendedGate = verdict.RecommendedGate.ToString(),
RiskPoints = verdict.RiskPoints,
BlockingDrivers = verdict.BlockingDrivers.Select(DeltaDriverDto.FromModel).ToList(),
WarningDrivers = verdict.WarningDrivers.Select(DeltaDriverDto.FromModel).ToList(),
AppliedExceptions = verdict.AppliedExceptions.ToList(),
Explanation = verdict.Explanation,
Recommendations = verdict.Recommendations.ToList()
};
}

View File

@@ -0,0 +1,466 @@
// <copyright file="ExceptionContracts.cs" company="StellaOps">
// Copyright (c) StellaOps. All rights reserved.
// Licensed under the BUSL-1.1 license.
// </copyright>
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;
namespace StellaOps.Policy.Engine.Contracts.Gateway;
/// <summary>
/// Request to create a new exception.
/// </summary>
public sealed record CreateExceptionRequest
{
/// <summary>
/// Type of exception (vulnerability, policy, unknown, component).
/// </summary>
[Required]
[JsonPropertyName("type")]
public required string Type { get; init; }
/// <summary>
/// Exception scope defining what this exception applies to.
/// </summary>
[Required]
[JsonPropertyName("scope")]
public required ExceptionScopeDto Scope { get; init; }
/// <summary>
/// Owner ID (user or team accountable).
/// </summary>
[Required]
[JsonPropertyName("ownerId")]
public required string OwnerId { get; init; }
/// <summary>
/// Reason code for the exception.
/// </summary>
[Required]
[JsonPropertyName("reasonCode")]
public required string ReasonCode { get; init; }
/// <summary>
/// Detailed rationale explaining why this exception is necessary.
/// </summary>
[Required]
[MinLength(50, ErrorMessage = "Rationale must be at least 50 characters.")]
[JsonPropertyName("rationale")]
public required string Rationale { get; init; }
/// <summary>
/// When the exception should expire.
/// </summary>
[Required]
[JsonPropertyName("expiresAt")]
public required DateTimeOffset ExpiresAt { get; init; }
/// <summary>
/// Content-addressed evidence references.
/// </summary>
[JsonPropertyName("evidenceRefs")]
public IReadOnlyList<string>? EvidenceRefs { get; init; }
/// <summary>
/// Compensating controls in place.
/// </summary>
[JsonPropertyName("compensatingControls")]
public IReadOnlyList<string>? CompensatingControls { get; init; }
/// <summary>
/// External ticket reference (e.g., JIRA-1234).
/// </summary>
[JsonPropertyName("ticketRef")]
public string? TicketRef { get; init; }
/// <summary>
/// Additional metadata.
/// </summary>
[JsonPropertyName("metadata")]
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
}
/// <summary>
/// Exception scope DTO.
/// </summary>
public sealed record ExceptionScopeDto
{
/// <summary>
/// Specific artifact digest (sha256:...).
/// </summary>
[JsonPropertyName("artifactDigest")]
public string? ArtifactDigest { get; init; }
/// <summary>
/// PURL pattern (supports wildcards: pkg:npm/lodash@*).
/// </summary>
[JsonPropertyName("purlPattern")]
public string? PurlPattern { get; init; }
/// <summary>
/// Specific vulnerability ID (CVE-XXXX-XXXXX).
/// </summary>
[JsonPropertyName("vulnerabilityId")]
public string? VulnerabilityId { get; init; }
/// <summary>
/// Policy rule identifier to bypass.
/// </summary>
[JsonPropertyName("policyRuleId")]
public string? PolicyRuleId { get; init; }
/// <summary>
/// Environments where exception is valid (empty = all).
/// </summary>
[JsonPropertyName("environments")]
public IReadOnlyList<string>? Environments { get; init; }
}
/// <summary>
/// Request to update an exception.
/// </summary>
public sealed record UpdateExceptionRequest
{
/// <summary>
/// Updated rationale.
/// </summary>
[MinLength(50, ErrorMessage = "Rationale must be at least 50 characters.")]
[JsonPropertyName("rationale")]
public string? Rationale { get; init; }
/// <summary>
/// Updated evidence references.
/// </summary>
[JsonPropertyName("evidenceRefs")]
public IReadOnlyList<string>? EvidenceRefs { get; init; }
/// <summary>
/// Updated compensating controls.
/// </summary>
[JsonPropertyName("compensatingControls")]
public IReadOnlyList<string>? CompensatingControls { get; init; }
/// <summary>
/// Updated ticket reference.
/// </summary>
[JsonPropertyName("ticketRef")]
public string? TicketRef { get; init; }
/// <summary>
/// Updated metadata.
/// </summary>
[JsonPropertyName("metadata")]
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
}
/// <summary>
/// Request to approve an exception.
/// </summary>
public sealed record ApproveExceptionRequest
{
/// <summary>
/// Optional comment from approver.
/// </summary>
[JsonPropertyName("comment")]
public string? Comment { get; init; }
}
/// <summary>
/// Request to extend an exception's expiry.
/// </summary>
public sealed record ExtendExceptionRequest
{
/// <summary>
/// New expiry date.
/// </summary>
[Required]
[JsonPropertyName("newExpiresAt")]
public required DateTimeOffset NewExpiresAt { get; init; }
/// <summary>
/// Reason for extension.
/// </summary>
[Required]
[MinLength(20, ErrorMessage = "Extension reason must be at least 20 characters.")]
[JsonPropertyName("reason")]
public required string Reason { get; init; }
}
/// <summary>
/// Request to revoke an exception.
/// </summary>
public sealed record RevokeExceptionRequest
{
/// <summary>
/// Reason for revocation.
/// </summary>
[Required]
[MinLength(10, ErrorMessage = "Revocation reason must be at least 10 characters.")]
[JsonPropertyName("reason")]
public required string Reason { get; init; }
}
/// <summary>
/// Exception response DTO.
/// </summary>
public sealed record ExceptionResponse
{
/// <summary>
/// Unique exception ID.
/// </summary>
[JsonPropertyName("exceptionId")]
public required string ExceptionId { get; init; }
/// <summary>
/// Version for optimistic concurrency.
/// </summary>
[JsonPropertyName("version")]
public required int Version { get; init; }
/// <summary>
/// Current status.
/// </summary>
[JsonPropertyName("status")]
public required string Status { get; init; }
/// <summary>
/// Exception type.
/// </summary>
[JsonPropertyName("type")]
public required string Type { get; init; }
/// <summary>
/// Exception scope.
/// </summary>
[JsonPropertyName("scope")]
public required ExceptionScopeDto Scope { get; init; }
/// <summary>
/// Owner ID.
/// </summary>
[JsonPropertyName("ownerId")]
public required string OwnerId { get; init; }
/// <summary>
/// Requester ID.
/// </summary>
[JsonPropertyName("requesterId")]
public required string RequesterId { get; init; }
/// <summary>
/// Approver IDs.
/// </summary>
[JsonPropertyName("approverIds")]
public required IReadOnlyList<string> ApproverIds { get; init; }
/// <summary>
/// Created timestamp.
/// </summary>
[JsonPropertyName("createdAt")]
public required DateTimeOffset CreatedAt { get; init; }
/// <summary>
/// Last updated timestamp.
/// </summary>
[JsonPropertyName("updatedAt")]
public required DateTimeOffset UpdatedAt { get; init; }
/// <summary>
/// Approved timestamp.
/// </summary>
[JsonPropertyName("approvedAt")]
public DateTimeOffset? ApprovedAt { get; init; }
/// <summary>
/// Expiry timestamp.
/// </summary>
[JsonPropertyName("expiresAt")]
public required DateTimeOffset ExpiresAt { get; init; }
/// <summary>
/// Reason code.
/// </summary>
[JsonPropertyName("reasonCode")]
public required string ReasonCode { get; init; }
/// <summary>
/// Rationale.
/// </summary>
[JsonPropertyName("rationale")]
public required string Rationale { get; init; }
/// <summary>
/// Evidence references.
/// </summary>
[JsonPropertyName("evidenceRefs")]
public required IReadOnlyList<string> EvidenceRefs { get; init; }
/// <summary>
/// Compensating controls.
/// </summary>
[JsonPropertyName("compensatingControls")]
public required IReadOnlyList<string> CompensatingControls { get; init; }
/// <summary>
/// Ticket reference.
/// </summary>
[JsonPropertyName("ticketRef")]
public string? TicketRef { get; init; }
/// <summary>
/// Metadata.
/// </summary>
[JsonPropertyName("metadata")]
public required IReadOnlyDictionary<string, string> Metadata { get; init; }
}
/// <summary>
/// Paginated list of exceptions.
/// </summary>
public sealed record ExceptionListResponse
{
/// <summary>
/// List of exceptions.
/// </summary>
[JsonPropertyName("items")]
public required IReadOnlyList<ExceptionResponse> Items { get; init; }
/// <summary>
/// Total count.
/// </summary>
[JsonPropertyName("totalCount")]
public required int TotalCount { get; init; }
/// <summary>
/// Offset.
/// </summary>
[JsonPropertyName("offset")]
public required int Offset { get; init; }
/// <summary>
/// Limit.
/// </summary>
[JsonPropertyName("limit")]
public required int Limit { get; init; }
}
/// <summary>
/// Exception event DTO.
/// </summary>
public sealed record ExceptionEventDto
{
/// <summary>
/// Event ID.
/// </summary>
[JsonPropertyName("eventId")]
public required Guid EventId { get; init; }
/// <summary>
/// Sequence number.
/// </summary>
[JsonPropertyName("sequenceNumber")]
public required int SequenceNumber { get; init; }
/// <summary>
/// Event type.
/// </summary>
[JsonPropertyName("eventType")]
public required string EventType { get; init; }
/// <summary>
/// Actor ID.
/// </summary>
[JsonPropertyName("actorId")]
public required string ActorId { get; init; }
/// <summary>
/// Occurred timestamp.
/// </summary>
[JsonPropertyName("occurredAt")]
public required DateTimeOffset OccurredAt { get; init; }
/// <summary>
/// Previous status.
/// </summary>
[JsonPropertyName("previousStatus")]
public string? PreviousStatus { get; init; }
/// <summary>
/// New status.
/// </summary>
[JsonPropertyName("newStatus")]
public required string NewStatus { get; init; }
/// <summary>
/// Description.
/// </summary>
[JsonPropertyName("description")]
public string? Description { get; init; }
}
/// <summary>
/// Exception history response.
/// </summary>
public sealed record ExceptionHistoryResponse
{
/// <summary>
/// Exception ID.
/// </summary>
[JsonPropertyName("exceptionId")]
public required string ExceptionId { get; init; }
/// <summary>
/// Events in chronological order.
/// </summary>
[JsonPropertyName("events")]
public required IReadOnlyList<ExceptionEventDto> Events { get; init; }
}
/// <summary>
/// Exception counts summary.
/// </summary>
public sealed record ExceptionCountsResponse
{
/// <summary>
/// Total count.
/// </summary>
[JsonPropertyName("total")]
public required int Total { get; init; }
/// <summary>
/// Proposed count.
/// </summary>
[JsonPropertyName("proposed")]
public required int Proposed { get; init; }
/// <summary>
/// Approved count.
/// </summary>
[JsonPropertyName("approved")]
public required int Approved { get; init; }
/// <summary>
/// Active count.
/// </summary>
[JsonPropertyName("active")]
public required int Active { get; init; }
/// <summary>
/// Expired count.
/// </summary>
[JsonPropertyName("expired")]
public required int Expired { get; init; }
/// <summary>
/// Revoked count.
/// </summary>
[JsonPropertyName("revoked")]
public required int Revoked { get; init; }
/// <summary>
/// Count expiring within 7 days.
/// </summary>
[JsonPropertyName("expiringSoon")]
public required int ExpiringSoon { get; init; }
}

View File

@@ -0,0 +1,243 @@
// SPDX-License-Identifier: BUSL-1.1
// Sprint: SPRINT_20251226_001_BE_cicd_gate_integration
// Task: CICD-GATE-01 - Create gate/evaluate endpoint contracts
namespace StellaOps.Policy.Engine.Contracts.Gateway;
/// <summary>
/// Request to evaluate a CI/CD gate for an image.
/// </summary>
public sealed record GateEvaluateRequest
{
/// <summary>
/// The image digest to evaluate (e.g., sha256:abc123...).
/// </summary>
public required string ImageDigest { get; init; }
/// <summary>
/// The container repository name.
/// </summary>
public string? Repository { get; init; }
/// <summary>
/// The image tag, if any.
/// </summary>
public string? Tag { get; init; }
/// <summary>
/// The baseline reference for comparison.
/// Can be a snapshot ID, image digest, or strategy name (e.g., "last-approved", "production").
/// </summary>
public string? BaselineRef { get; init; }
/// <summary>
/// Optional policy ID to use for evaluation.
/// </summary>
public string? PolicyId { get; init; }
/// <summary>
/// Whether to allow override of blocking gates.
/// </summary>
public bool AllowOverride { get; init; }
/// <summary>
/// Justification for override (required if AllowOverride is true and gate would block).
/// </summary>
public string? OverrideJustification { get; init; }
/// <summary>
/// Source of the request (e.g., "cli", "api", "webhook").
/// </summary>
public string? Source { get; init; }
/// <summary>
/// CI/CD context identifier (e.g., "github-actions", "gitlab-ci").
/// </summary>
public string? CiContext { get; init; }
/// <summary>
/// Additional context for the gate evaluation.
/// </summary>
public GateEvaluationContext? Context { get; init; }
}
/// <summary>
/// Additional context for gate evaluation.
/// </summary>
public sealed record GateEvaluationContext
{
/// <summary>
/// Git branch name.
/// </summary>
public string? Branch { get; init; }
/// <summary>
/// Git commit SHA.
/// </summary>
public string? CommitSha { get; init; }
/// <summary>
/// CI/CD pipeline ID or job ID.
/// </summary>
public string? PipelineId { get; init; }
/// <summary>
/// Environment being deployed to (e.g., "production", "staging").
/// </summary>
public string? Environment { get; init; }
/// <summary>
/// Actor triggering the gate (e.g., user or service identity).
/// </summary>
public string? Actor { get; init; }
}
/// <summary>
/// Response from gate evaluation.
/// </summary>
public sealed record GateEvaluateResponse
{
/// <summary>
/// Unique decision ID for audit and tracking.
/// </summary>
public required string DecisionId { get; init; }
/// <summary>
/// The gate decision status.
/// </summary>
public required GateStatus Status { get; init; }
/// <summary>
/// Suggested CI exit code.
/// 0 = Pass, 1 = Warn (configurable pass-through), 2 = Fail/Block
/// </summary>
public required int ExitCode { get; init; }
/// <summary>
/// The image digest that was evaluated.
/// </summary>
public required string ImageDigest { get; init; }
/// <summary>
/// The baseline reference used for comparison.
/// </summary>
public string? BaselineRef { get; init; }
/// <summary>
/// When the decision was made (UTC).
/// </summary>
public required DateTimeOffset DecidedAt { get; init; }
/// <summary>
/// Summary message for the decision.
/// </summary>
public string? Summary { get; init; }
/// <summary>
/// Advisory or suggestion for the developer.
/// </summary>
public string? Advisory { get; init; }
/// <summary>
/// List of gate results.
/// </summary>
public IReadOnlyList<GateResultDto>? Gates { get; init; }
/// <summary>
/// Gate that caused the block (if blocked).
/// </summary>
public string? BlockedBy { get; init; }
/// <summary>
/// Detailed reason for the block.
/// </summary>
public string? BlockReason { get; init; }
/// <summary>
/// Suggestion for resolving the block.
/// </summary>
public string? Suggestion { get; init; }
/// <summary>
/// Whether an override was applied.
/// </summary>
public bool OverrideApplied { get; init; }
/// <summary>
/// Delta summary if available.
/// </summary>
public DeltaSummaryDto? DeltaSummary { get; init; }
}
/// <summary>
/// Result of a single gate evaluation.
/// </summary>
public sealed record GateResultDto
{
/// <summary>
/// Gate name/ID.
/// </summary>
public required string Name { get; init; }
/// <summary>
/// Gate result type.
/// </summary>
public required string Result { get; init; }
/// <summary>
/// Reason for the result.
/// </summary>
public required string Reason { get; init; }
/// <summary>
/// Additional note.
/// </summary>
public string? Note { get; init; }
/// <summary>
/// Condition expression that was evaluated.
/// </summary>
public string? Condition { get; init; }
}
/// <summary>
/// Gate evaluation status.
/// </summary>
public enum GateStatus
{
/// <summary>
/// Gate passed - proceed with deployment.
/// </summary>
Pass = 0,
/// <summary>
/// Gate produced warnings - proceed with caution.
/// </summary>
Warn = 1,
/// <summary>
/// Gate blocked - do not proceed.
/// </summary>
Fail = 2
}
/// <summary>
/// CI exit codes for gate evaluation.
/// </summary>
public static class GateExitCodes
{
/// <summary>
/// Gate passed - proceed with deployment.
/// </summary>
public const int Pass = 0;
/// <summary>
/// Gate produced warnings - configurable pass-through.
/// </summary>
public const int Warn = 1;
/// <summary>
/// Gate blocked - do not proceed.
/// </summary>
public const int Fail = 2;
}

View File

@@ -0,0 +1,445 @@
// SPDX-License-Identifier: BUSL-1.1
// Copyright (c) 2026 StellaOps
// Sprint: SPRINT_20260118_030_LIB_verdict_rekor_gate_api
// Task: TASK-030-006 - Gate Decision API Endpoint
using System.Text.Json.Serialization;
namespace StellaOps.Policy.Engine.Contracts.Gateway;
/// <summary>
/// Request for score-based gate evaluation.
/// Used by CI/CD pipelines to evaluate individual findings.
/// </summary>
public sealed record ScoreGateEvaluateRequest
{
/// <summary>
/// Finding identifier (CVE@PURL format).
/// Example: "CVE-2024-1234@pkg:npm/lodash@4.17.20"
/// </summary>
[JsonPropertyName("finding_id")]
public required string FindingId { get; init; }
/// <summary>
/// CVSS base score [0, 10].
/// </summary>
[JsonPropertyName("cvss_base")]
public double CvssBase { get; init; }
/// <summary>
/// CVSS version (3.0, 3.1, 4.0). Defaults to 3.1.
/// </summary>
[JsonPropertyName("cvss_version")]
public string? CvssVersion { get; init; }
/// <summary>
/// EPSS probability [0, 1].
/// </summary>
[JsonPropertyName("epss")]
public double Epss { get; init; }
/// <summary>
/// EPSS model date (ISO 8601 date).
/// </summary>
[JsonPropertyName("epss_model_date")]
public DateOnly? EpssModelDate { get; init; }
/// <summary>
/// Reachability level: "none", "package", "function", "caller".
/// </summary>
[JsonPropertyName("reachability")]
public string? Reachability { get; init; }
/// <summary>
/// Exploit maturity: "none", "poc", "functional", "high".
/// </summary>
[JsonPropertyName("exploit_maturity")]
public string? ExploitMaturity { get; init; }
/// <summary>
/// Patch proof confidence [0, 1].
/// </summary>
[JsonPropertyName("patch_proof_confidence")]
public double PatchProofConfidence { get; init; }
/// <summary>
/// VEX status: "affected", "not_affected", "fixed", "under_investigation".
/// </summary>
[JsonPropertyName("vex_status")]
public string? VexStatus { get; init; }
/// <summary>
/// VEX statement source/issuer.
/// </summary>
[JsonPropertyName("vex_source")]
public string? VexSource { get; init; }
/// <summary>
/// Whether to anchor the verdict to Rekor transparency log.
/// Default: false (async anchoring).
/// </summary>
[JsonPropertyName("anchor_to_rekor")]
public bool AnchorToRekor { get; init; }
/// <summary>
/// Whether to include the full verdict bundle in the response.
/// </summary>
[JsonPropertyName("include_verdict")]
public bool IncludeVerdict { get; init; }
/// <summary>
/// Optional policy profile to use ("advisory", "legacy", "custom").
/// Default: "advisory".
/// </summary>
[JsonPropertyName("policy_profile")]
public string? PolicyProfile { get; init; }
}
/// <summary>
/// Response from score-based gate evaluation.
/// </summary>
public sealed record ScoreGateEvaluateResponse
{
/// <summary>
/// Gate action: "pass", "warn", "block".
/// </summary>
[JsonPropertyName("action")]
public required string Action { get; init; }
/// <summary>
/// Final score [0, 1].
/// </summary>
[JsonPropertyName("score")]
public required double Score { get; init; }
/// <summary>
/// Threshold that triggered the action.
/// </summary>
[JsonPropertyName("threshold")]
public required double Threshold { get; init; }
/// <summary>
/// Human-readable reason for the gate decision.
/// </summary>
[JsonPropertyName("reason")]
public required string Reason { get; init; }
/// <summary>
/// Verdict bundle ID (SHA256 digest).
/// </summary>
[JsonPropertyName("verdict_bundle_id")]
public required string VerdictBundleId { get; init; }
/// <summary>
/// Rekor entry UUID (if anchored).
/// </summary>
[JsonPropertyName("rekor_uuid")]
public string? RekorUuid { get; init; }
/// <summary>
/// Rekor log index (if anchored).
/// </summary>
[JsonPropertyName("rekor_log_index")]
public long? RekorLogIndex { get; init; }
/// <summary>
/// When the verdict was computed (ISO 8601).
/// </summary>
[JsonPropertyName("computed_at")]
public required DateTimeOffset ComputedAt { get; init; }
/// <summary>
/// Matched rules that influenced the decision.
/// </summary>
[JsonPropertyName("matched_rules")]
public IReadOnlyList<string> MatchedRules { get; init; } = [];
/// <summary>
/// Suggestions for resolving the gate decision.
/// </summary>
[JsonPropertyName("suggestions")]
public IReadOnlyList<string> Suggestions { get; init; } = [];
/// <summary>
/// CI/CD exit code: 0=pass, 1=warn, 2=block.
/// </summary>
[JsonPropertyName("exit_code")]
public required int ExitCode { get; init; }
/// <summary>
/// Score breakdown by dimension (optional).
/// </summary>
[JsonPropertyName("breakdown")]
public IReadOnlyList<ScoreDimensionBreakdown>? Breakdown { get; init; }
/// <summary>
/// Full verdict bundle JSON (if include_verdict=true).
/// </summary>
[JsonPropertyName("verdict_bundle")]
public object? VerdictBundle { get; init; }
}
/// <summary>
/// Per-dimension score breakdown.
/// </summary>
public sealed record ScoreDimensionBreakdown
{
/// <summary>
/// Dimension name.
/// </summary>
[JsonPropertyName("dimension")]
public required string Dimension { get; init; }
/// <summary>
/// Dimension symbol.
/// </summary>
[JsonPropertyName("symbol")]
public required string Symbol { get; init; }
/// <summary>
/// Raw input value.
/// </summary>
[JsonPropertyName("value")]
public required double Value { get; init; }
/// <summary>
/// Weight applied.
/// </summary>
[JsonPropertyName("weight")]
public required double Weight { get; init; }
/// <summary>
/// Contribution to final score.
/// </summary>
[JsonPropertyName("contribution")]
public required double Contribution { get; init; }
/// <summary>
/// Whether this is a subtractive dimension.
/// </summary>
[JsonPropertyName("is_subtractive")]
public bool IsSubtractive { get; init; }
}
/// <summary>
/// Gate action types.
/// </summary>
public static class ScoreGateActions
{
public const string Pass = "pass";
public const string Warn = "warn";
public const string Block = "block";
}
/// <summary>
/// CI exit codes for gate evaluation.
/// </summary>
public static class ScoreGateExitCodes
{
/// <summary>Gate passed.</summary>
public const int Pass = 0;
/// <summary>Gate warned.</summary>
public const int Warn = 1;
/// <summary>Gate blocked.</summary>
public const int Block = 2;
}
#region Batch Evaluation Contracts
/// <summary>
/// Request for batch score-based gate evaluation.
/// Sprint: SPRINT_20260118_030_LIB_verdict_rekor_gate_api
/// Task: TASK-030-007 - Batch Gate Evaluation API
/// </summary>
public sealed record ScoreGateBatchEvaluateRequest
{
/// <summary>
/// List of findings to evaluate.
/// </summary>
[JsonPropertyName("findings")]
public required IReadOnlyList<ScoreGateEvaluateRequest> Findings { get; init; }
/// <summary>
/// Batch evaluation options.
/// </summary>
[JsonPropertyName("options")]
public ScoreGateBatchOptions? Options { get; init; }
}
/// <summary>
/// Options for batch gate evaluation.
/// </summary>
public sealed record ScoreGateBatchOptions
{
/// <summary>
/// Stop evaluation on first block.
/// Default: false
/// </summary>
[JsonPropertyName("fail_fast")]
public bool FailFast { get; init; }
/// <summary>
/// Include full verdict bundles in response.
/// Default: false
/// </summary>
[JsonPropertyName("include_verdicts")]
public bool IncludeVerdicts { get; init; }
/// <summary>
/// Anchor each verdict to Rekor (slower but auditable).
/// Default: false
/// </summary>
[JsonPropertyName("anchor_to_rekor")]
public bool AnchorToRekor { get; init; }
/// <summary>
/// Policy profile to use for all evaluations.
/// Default: "advisory"
/// </summary>
[JsonPropertyName("policy_profile")]
public string? PolicyProfile { get; init; }
/// <summary>
/// Maximum parallelism for evaluation.
/// Default: 10
/// </summary>
[JsonPropertyName("max_parallelism")]
public int MaxParallelism { get; init; } = 10;
}
/// <summary>
/// Response from batch gate evaluation.
/// </summary>
public sealed record ScoreGateBatchEvaluateResponse
{
/// <summary>
/// Summary statistics for the batch evaluation.
/// </summary>
[JsonPropertyName("summary")]
public required ScoreGateBatchSummary Summary { get; init; }
/// <summary>
/// Overall action: worst-case across all findings.
/// "block" if any blocked, "warn" if any warned but none blocked, "pass" otherwise.
/// </summary>
[JsonPropertyName("overall_action")]
public required string OverallAction { get; init; }
/// <summary>
/// CI/CD exit code based on overall action.
/// </summary>
[JsonPropertyName("exit_code")]
public required int ExitCode { get; init; }
/// <summary>
/// Individual decisions for each finding.
/// </summary>
[JsonPropertyName("decisions")]
public required IReadOnlyList<ScoreGateBatchDecision> Decisions { get; init; }
/// <summary>
/// Evaluation duration in milliseconds.
/// </summary>
[JsonPropertyName("duration_ms")]
public required long DurationMs { get; init; }
/// <summary>
/// Whether evaluation was stopped early due to fail-fast.
/// </summary>
[JsonPropertyName("fail_fast_triggered")]
public bool FailFastTriggered { get; init; }
}
/// <summary>
/// Summary statistics for batch evaluation.
/// </summary>
public sealed record ScoreGateBatchSummary
{
/// <summary>
/// Total number of findings evaluated.
/// </summary>
[JsonPropertyName("total")]
public required int Total { get; init; }
/// <summary>
/// Number of findings that passed.
/// </summary>
[JsonPropertyName("passed")]
public required int Passed { get; init; }
/// <summary>
/// Number of findings that warned.
/// </summary>
[JsonPropertyName("warned")]
public required int Warned { get; init; }
/// <summary>
/// Number of findings that blocked.
/// </summary>
[JsonPropertyName("blocked")]
public required int Blocked { get; init; }
/// <summary>
/// Number of findings that errored.
/// </summary>
[JsonPropertyName("errored")]
public int Errored { get; init; }
}
/// <summary>
/// Individual decision in a batch evaluation.
/// </summary>
public sealed record ScoreGateBatchDecision
{
/// <summary>
/// Finding identifier.
/// </summary>
[JsonPropertyName("finding_id")]
public required string FindingId { get; init; }
/// <summary>
/// Gate action: "pass", "warn", "block", or "error".
/// </summary>
[JsonPropertyName("action")]
public required string Action { get; init; }
/// <summary>
/// Final score [0, 1].
/// </summary>
[JsonPropertyName("score")]
public double? Score { get; init; }
/// <summary>
/// Threshold that triggered the action.
/// </summary>
[JsonPropertyName("threshold")]
public double? Threshold { get; init; }
/// <summary>
/// Reason for the decision.
/// </summary>
[JsonPropertyName("reason")]
public string? Reason { get; init; }
/// <summary>
/// Verdict bundle ID if created.
/// </summary>
[JsonPropertyName("verdict_bundle_id")]
public string? VerdictBundleId { get; init; }
/// <summary>
/// Full verdict bundle (if include_verdicts=true).
/// </summary>
[JsonPropertyName("verdict_bundle")]
public object? VerdictBundle { get; init; }
/// <summary>
/// Error message if evaluation failed.
/// </summary>
[JsonPropertyName("error")]
public string? Error { get; init; }
}
#endregion

View File

@@ -0,0 +1,24 @@
using System;
using System.Collections.Generic;
namespace StellaOps.Policy.Engine.Contracts.Gateway;
public sealed record ToolAccessRequest
{
public string? TenantId { get; init; }
public string? Tool { get; init; }
public string? Action { get; init; }
public string? Resource { get; init; }
public IReadOnlyList<string>? Scopes { get; init; }
public IReadOnlyList<string>? Roles { get; init; }
}
public sealed record ToolAccessResponse
{
public bool Allowed { get; init; }
public string Reason { get; init; } = string.Empty;
public string? RuleId { get; init; }
public string? RuleEffect { get; init; }
public IReadOnlyList<string> RequiredScopes { get; init; } = Array.Empty<string>();
public IReadOnlyList<string> RequiredRoles { get; init; } = Array.Empty<string>();
}

View File

@@ -0,0 +1,319 @@
using Microsoft.AspNetCore.Mvc;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Auth.ServerIntegration.Tenancy;
using StellaOps.Policy.Persistence.Postgres.Repositories;
using System.Text.Json;
namespace StellaOps.Policy.Engine.Endpoints.Gateway;
/// <summary>
/// Advisory-source policy endpoints (impact and conflict facts).
/// </summary>
public static class AdvisorySourceEndpoints
{
private static readonly HashSet<string> AllowedConflictStatuses = new(StringComparer.OrdinalIgnoreCase)
{
"open",
"resolved",
"dismissed"
};
public static void MapAdvisorySourcePolicyEndpoints(this WebApplication app)
{
var group = app.MapGroup("/api/v1/advisory-sources")
.WithTags("Advisory Sources")
.RequireTenant();
group.MapGet("/{sourceId}/impact", GetImpactAsync)
.WithName("GetAdvisorySourceImpact")
.WithDescription("Get policy impact facts for an advisory source.")
.Produces<AdvisorySourceImpactResponse>(StatusCodes.Status200OK)
.Produces<ProblemDetails>(StatusCodes.Status400BadRequest)
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.FindingsRead));
group.MapGet("/{sourceId}/conflicts", GetConflictsAsync)
.WithName("GetAdvisorySourceConflicts")
.WithDescription("Get active/resolved advisory conflicts for a source.")
.Produces<AdvisorySourceConflictListResponse>(StatusCodes.Status200OK)
.Produces<ProblemDetails>(StatusCodes.Status400BadRequest)
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.FindingsRead));
}
private static async Task<IResult> GetImpactAsync(
HttpContext httpContext,
[FromRoute] string sourceId,
[FromQuery] string? region,
[FromQuery] string? environment,
[FromQuery] string? sourceFamily,
[FromServices] IAdvisorySourcePolicyReadRepository repository,
[FromServices] TimeProvider timeProvider,
CancellationToken cancellationToken)
{
if (!TryGetTenantId(httpContext, out var tenantId))
{
return TenantMissingProblem();
}
if (string.IsNullOrWhiteSpace(sourceId))
{
return Results.BadRequest(new ProblemDetails
{
Title = "sourceId is required.",
Status = StatusCodes.Status400BadRequest
});
}
var normalizedSourceId = sourceId.Trim();
var normalizedRegion = NormalizeOptional(region);
var normalizedEnvironment = NormalizeOptional(environment);
var normalizedSourceFamily = NormalizeOptional(sourceFamily);
var impact = await repository.GetImpactAsync(
tenantId,
normalizedSourceId,
normalizedRegion,
normalizedEnvironment,
normalizedSourceFamily,
cancellationToken).ConfigureAwait(false);
var response = new AdvisorySourceImpactResponse
{
SourceId = normalizedSourceId,
SourceFamily = impact.SourceFamily ?? normalizedSourceFamily ?? string.Empty,
Region = normalizedRegion,
Environment = normalizedEnvironment,
ImpactedDecisionsCount = impact.ImpactedDecisionsCount,
ImpactSeverity = impact.ImpactSeverity,
LastDecisionAt = impact.LastDecisionAt,
DecisionRefs = ParseDecisionRefs(impact.DecisionRefsJson),
DataAsOf = timeProvider.GetUtcNow()
};
return Results.Ok(response);
}
private static async Task<IResult> GetConflictsAsync(
HttpContext httpContext,
[FromRoute] string sourceId,
[FromQuery] string? status,
[FromQuery] int? limit,
[FromQuery] int? offset,
[FromServices] IAdvisorySourcePolicyReadRepository repository,
[FromServices] TimeProvider timeProvider,
CancellationToken cancellationToken)
{
if (!TryGetTenantId(httpContext, out var tenantId))
{
return TenantMissingProblem();
}
if (string.IsNullOrWhiteSpace(sourceId))
{
return Results.BadRequest(new ProblemDetails
{
Title = "sourceId is required.",
Status = StatusCodes.Status400BadRequest
});
}
var normalizedStatus = NormalizeOptional(status) ?? "open";
if (!AllowedConflictStatuses.Contains(normalizedStatus))
{
return Results.BadRequest(new ProblemDetails
{
Title = "status must be one of: open, resolved, dismissed.",
Status = StatusCodes.Status400BadRequest
});
}
var normalizedSourceId = sourceId.Trim();
var normalizedLimit = Math.Clamp(limit ?? 50, 1, 200);
var normalizedOffset = Math.Max(offset ?? 0, 0);
var page = await repository.ListConflictsAsync(
tenantId,
normalizedSourceId,
normalizedStatus,
normalizedLimit,
normalizedOffset,
cancellationToken).ConfigureAwait(false);
var items = page.Items.Select(static item => new AdvisorySourceConflictResponse
{
ConflictId = item.ConflictId,
AdvisoryId = item.AdvisoryId,
PairedSourceKey = item.PairedSourceKey,
ConflictType = item.ConflictType,
Severity = item.Severity,
Status = item.Status,
Description = item.Description,
FirstDetectedAt = item.FirstDetectedAt,
LastDetectedAt = item.LastDetectedAt,
ResolvedAt = item.ResolvedAt,
Details = ParseDetails(item.DetailsJson)
}).ToList();
return Results.Ok(new AdvisorySourceConflictListResponse
{
SourceId = normalizedSourceId,
Status = normalizedStatus,
Limit = normalizedLimit,
Offset = normalizedOffset,
TotalCount = page.TotalCount,
Items = items,
DataAsOf = timeProvider.GetUtcNow()
});
}
private static string? NormalizeOptional(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
private static bool TryGetTenantId(HttpContext httpContext, out string tenantId)
{
tenantId = string.Empty;
var claimTenant = httpContext.User.Claims.FirstOrDefault(claim => claim.Type == "tenant_id")?.Value;
if (!string.IsNullOrWhiteSpace(claimTenant))
{
tenantId = claimTenant.Trim();
return true;
}
var stellaHeaderTenant = httpContext.Request.Headers["X-StellaOps-Tenant"].FirstOrDefault();
if (!string.IsNullOrWhiteSpace(stellaHeaderTenant))
{
tenantId = stellaHeaderTenant.Trim();
return true;
}
var tenantHeader = httpContext.Request.Headers["X-Tenant-Id"].FirstOrDefault();
if (!string.IsNullOrWhiteSpace(tenantHeader))
{
tenantId = tenantHeader.Trim();
return true;
}
return false;
}
private static IResult TenantMissingProblem()
{
return Results.Problem(
title: "Tenant header required.",
detail: "Provide tenant via X-StellaOps-Tenant, X-Tenant-Id, or tenant_id claim.",
statusCode: StatusCodes.Status400BadRequest);
}
private static IReadOnlyList<AdvisorySourceDecisionRef> ParseDecisionRefs(string decisionRefsJson)
{
if (string.IsNullOrWhiteSpace(decisionRefsJson))
{
return Array.Empty<AdvisorySourceDecisionRef>();
}
try
{
using var document = JsonDocument.Parse(decisionRefsJson);
if (document.RootElement.ValueKind != JsonValueKind.Array)
{
return Array.Empty<AdvisorySourceDecisionRef>();
}
var refs = new List<AdvisorySourceDecisionRef>();
foreach (var item in document.RootElement.EnumerateArray())
{
if (item.ValueKind != JsonValueKind.Object)
{
continue;
}
refs.Add(new AdvisorySourceDecisionRef
{
DecisionId = TryGetString(item, "decisionId") ?? TryGetString(item, "decision_id") ?? string.Empty,
DecisionType = TryGetString(item, "decisionType") ?? TryGetString(item, "decision_type"),
Label = TryGetString(item, "label"),
Route = TryGetString(item, "route")
});
}
return refs;
}
catch (JsonException)
{
return Array.Empty<AdvisorySourceDecisionRef>();
}
}
private static JsonElement? ParseDetails(string detailsJson)
{
if (string.IsNullOrWhiteSpace(detailsJson))
{
return null;
}
try
{
using var document = JsonDocument.Parse(detailsJson);
return document.RootElement.Clone();
}
catch (JsonException)
{
return null;
}
}
private static string? TryGetString(JsonElement element, string propertyName)
{
return element.TryGetProperty(propertyName, out var property) && property.ValueKind == JsonValueKind.String
? property.GetString()
: null;
}
}
public sealed record AdvisorySourceImpactResponse
{
public string SourceId { get; init; } = string.Empty;
public string SourceFamily { get; init; } = string.Empty;
public string? Region { get; init; }
public string? Environment { get; init; }
public int ImpactedDecisionsCount { get; init; }
public string ImpactSeverity { get; init; } = "none";
public DateTimeOffset? LastDecisionAt { get; init; }
public IReadOnlyList<AdvisorySourceDecisionRef> DecisionRefs { get; init; } = Array.Empty<AdvisorySourceDecisionRef>();
public DateTimeOffset DataAsOf { get; init; }
}
public sealed record AdvisorySourceDecisionRef
{
public string DecisionId { get; init; } = string.Empty;
public string? DecisionType { get; init; }
public string? Label { get; init; }
public string? Route { get; init; }
}
public sealed record AdvisorySourceConflictListResponse
{
public string SourceId { get; init; } = string.Empty;
public string Status { get; init; } = "open";
public int Limit { get; init; }
public int Offset { get; init; }
public int TotalCount { get; init; }
public IReadOnlyList<AdvisorySourceConflictResponse> Items { get; init; } = Array.Empty<AdvisorySourceConflictResponse>();
public DateTimeOffset DataAsOf { get; init; }
}
public sealed record AdvisorySourceConflictResponse
{
public Guid ConflictId { get; init; }
public string AdvisoryId { get; init; } = string.Empty;
public string? PairedSourceKey { get; init; }
public string ConflictType { get; init; } = string.Empty;
public string Severity { get; init; } = string.Empty;
public string Status { get; init; } = string.Empty;
public string Description { get; init; } = string.Empty;
public DateTimeOffset FirstDetectedAt { get; init; }
public DateTimeOffset LastDetectedAt { get; init; }
public DateTimeOffset? ResolvedAt { get; init; }
public JsonElement? Details { get; init; }
}

View File

@@ -0,0 +1,383 @@
// SPDX-License-Identifier: BUSL-1.1
// Sprint: SPRINT_4100_0004_0001 - Security State Delta & Verdict
// Task: T6 - Add Delta API endpoints
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Caching.Memory;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Auth.ServerIntegration.Tenancy;
using StellaOps.Policy.Deltas;
using StellaOps.Policy.Engine.Contracts.Gateway;
namespace StellaOps.Policy.Engine.Endpoints.Gateway;
/// <summary>
/// Delta API endpoints for Policy Gateway.
/// </summary>
public static class DeltasEndpoints
{
private const string DeltaCachePrefix = "delta:";
private static readonly TimeSpan DeltaCacheDuration = TimeSpan.FromMinutes(30);
/// <summary>
/// Maps delta endpoints to the application.
/// </summary>
public static void MapDeltasEndpoints(this WebApplication app)
{
var deltas = app.MapGroup("/api/policy/deltas")
.WithTags("Deltas")
.RequireTenant();
// POST /api/policy/deltas/compute - Compute a security state delta
deltas.MapPost("/compute", async Task<IResult>(
ComputeDeltaRequest request,
IDeltaComputer deltaComputer,
IBaselineSelector baselineSelector,
IMemoryCache cache,
ILogger<DeltaComputer> logger,
CancellationToken cancellationToken) =>
{
if (request is null)
{
return Results.BadRequest(new ProblemDetails
{
Title = "Request body required",
Status = 400
});
}
if (string.IsNullOrWhiteSpace(request.ArtifactDigest))
{
return Results.BadRequest(new ProblemDetails
{
Title = "Artifact digest required",
Status = 400
});
}
if (string.IsNullOrWhiteSpace(request.TargetSnapshotId))
{
return Results.BadRequest(new ProblemDetails
{
Title = "Target snapshot ID required",
Status = 400
});
}
try
{
// Select baseline
BaselineSelectionResult baselineResult;
if (!string.IsNullOrWhiteSpace(request.BaselineSnapshotId))
{
baselineResult = await baselineSelector.SelectExplicitAsync(
request.BaselineSnapshotId,
cancellationToken);
}
else
{
var strategy = ParseStrategy(request.BaselineStrategy);
baselineResult = await baselineSelector.SelectBaselineAsync(
request.ArtifactDigest,
strategy,
cancellationToken);
}
if (!baselineResult.IsFound)
{
return Results.NotFound(new ProblemDetails
{
Title = "Baseline not found",
Status = 404,
Detail = baselineResult.Error
});
}
// Compute delta
var delta = await deltaComputer.ComputeDeltaAsync(
baselineResult.Snapshot!.SnapshotId,
request.TargetSnapshotId,
new ArtifactRef(
request.ArtifactDigest,
request.ArtifactName,
request.ArtifactTag),
cancellationToken);
// Cache the delta for subsequent retrieval
cache.Set(
DeltaCachePrefix + delta.DeltaId,
delta,
DeltaCacheDuration);
logger.LogInformation(
"Computed delta {DeltaId} between {Baseline} and {Target}",
delta.DeltaId, delta.BaselineSnapshotId, delta.TargetSnapshotId);
return Results.Ok(new ComputeDeltaResponse
{
DeltaId = delta.DeltaId,
BaselineSnapshotId = delta.BaselineSnapshotId,
TargetSnapshotId = delta.TargetSnapshotId,
ComputedAt = delta.ComputedAt,
Summary = DeltaSummaryDto.FromModel(delta.Summary),
DriverCount = delta.Drivers.Count
});
}
catch (InvalidOperationException ex) when (ex.Message.Contains("not found"))
{
return Results.NotFound(new ProblemDetails
{
Title = "Snapshot not found",
Status = 404,
Detail = ex.Message
});
}
})
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRun))
.WithName("ComputeDelta")
.WithDescription("Compute a security state delta between a baseline snapshot and a target snapshot for a given artifact digest. Selects the baseline automatically using the configured strategy (last-approved, previous-build, production-deployed, or branch-base) unless an explicit baseline snapshot ID is provided. Returns a delta summary and driver count for downstream evaluation.");
// GET /api/policy/deltas/{deltaId} - Get a delta by ID
deltas.MapGet("/{deltaId}", async Task<IResult>(
string deltaId,
IMemoryCache cache,
CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(deltaId))
{
return Results.BadRequest(new ProblemDetails
{
Title = "Delta ID required",
Status = 400
});
}
// Try to retrieve from cache
if (!cache.TryGetValue(DeltaCachePrefix + deltaId, out SecurityStateDelta? delta) || delta is null)
{
return Results.NotFound(new ProblemDetails
{
Title = "Delta not found",
Status = 404,
Detail = $"No delta found with ID: {deltaId}. Deltas are cached for {DeltaCacheDuration.TotalMinutes} minutes after computation."
});
}
return Results.Ok(DeltaResponse.FromModel(delta));
})
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead))
.WithName("GetDelta")
.WithDescription("Retrieve a previously computed security state delta by its ID from the in-memory cache. Deltas are retained for 30 minutes after computation. Returns the full driver list, baseline and target snapshot IDs, and risk summary.");
// POST /api/policy/deltas/{deltaId}/evaluate - Evaluate delta and get verdict
deltas.MapPost("/{deltaId}/evaluate", async Task<IResult>(
string deltaId,
EvaluateDeltaRequest? request,
IMemoryCache cache,
ILogger<DeltaComputer> logger,
CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(deltaId))
{
return Results.BadRequest(new ProblemDetails
{
Title = "Delta ID required",
Status = 400
});
}
// Try to retrieve delta from cache
if (!cache.TryGetValue(DeltaCachePrefix + deltaId, out SecurityStateDelta? delta) || delta is null)
{
return Results.NotFound(new ProblemDetails
{
Title = "Delta not found",
Status = 404,
Detail = $"No delta found with ID: {deltaId}"
});
}
// Build verdict from delta drivers
var builder = new DeltaVerdictBuilder();
// Apply risk points based on summary
builder.WithRiskPoints((int)delta.Summary.RiskScore);
// Categorize drivers as blocking or warning
foreach (var driver in delta.Drivers)
{
if (IsBlockingDriver(driver))
{
builder.AddBlockingDriver(driver);
}
else if (driver.Severity >= DeltaDriverSeverity.Medium)
{
builder.AddWarningDriver(driver);
}
}
// Apply exceptions if provided
if (request?.Exceptions is not null)
{
foreach (var exceptionId in request.Exceptions)
{
builder.AddException(exceptionId);
}
}
// Add recommendations based on drivers
AddRecommendations(builder, delta.Drivers);
var verdict = builder.Build(deltaId);
// Cache the verdict
cache.Set(
DeltaCachePrefix + deltaId + ":verdict",
verdict,
DeltaCacheDuration);
logger.LogInformation(
"Evaluated delta {DeltaId}: status={Status}, gate={Gate}",
deltaId, verdict.Status, verdict.RecommendedGate);
return Results.Ok(DeltaVerdictResponse.FromModel(verdict));
})
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRun))
.WithName("EvaluateDelta")
.WithDescription("Evaluate a previously computed delta and produce a gate verdict. Classifies each driver as blocking or advisory based on severity and type (new-reachable-cve, lost-vex-coverage, new-policy-violation), applies any supplied exception IDs, and returns a recommended gate action with risk points and remediation recommendations.");
// GET /api/policy/deltas/{deltaId}/attestation - Get signed attestation
deltas.MapGet("/{deltaId}/attestation", async Task<IResult>(
string deltaId,
IMemoryCache cache,
[FromServices] IDeltaVerdictAttestor? attestor,
ILogger<DeltaComputer> logger,
CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(deltaId))
{
return Results.BadRequest(new ProblemDetails
{
Title = "Delta ID required",
Status = 400
});
}
// Try to retrieve delta from cache
if (!cache.TryGetValue(DeltaCachePrefix + deltaId, out SecurityStateDelta? delta) || delta is null)
{
return Results.NotFound(new ProblemDetails
{
Title = "Delta not found",
Status = 404,
Detail = $"No delta found with ID: {deltaId}"
});
}
// Try to retrieve verdict from cache
if (!cache.TryGetValue(DeltaCachePrefix + deltaId + ":verdict", out StellaOps.Policy.Deltas.DeltaVerdict? verdict) || verdict is null)
{
return Results.NotFound(new ProblemDetails
{
Title = "Verdict not found",
Status = 404,
Detail = "Delta must be evaluated before attestation can be generated. Call POST /evaluate first."
});
}
if (attestor is null)
{
return Results.Problem(new ProblemDetails
{
Title = "Attestor not configured",
Status = 501,
Detail = "Delta verdict attestation requires a signer to be configured"
});
}
try
{
var envelope = await attestor.AttestAsync(delta, verdict, cancellationToken);
logger.LogInformation(
"Created attestation for delta {DeltaId} verdict {VerdictId}",
deltaId, verdict.VerdictId);
return Results.Ok(envelope);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to create attestation for delta {DeltaId}", deltaId);
return Results.Problem(new ProblemDetails
{
Title = "Attestation failed",
Status = 500,
Detail = "Failed to create signed attestation"
});
}
})
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead))
.WithName("GetDeltaAttestation")
.WithDescription("Retrieve a signed attestation envelope for a delta verdict, combining the security state delta with its evaluated verdict and producing a cryptographically signed in-toto statement. Requires that the delta has been evaluated via POST /{deltaId}/evaluate before an attestation can be generated.");
}
private static BaselineSelectionStrategy ParseStrategy(string? strategy)
{
if (string.IsNullOrWhiteSpace(strategy))
return BaselineSelectionStrategy.LastApproved;
return strategy.ToLowerInvariant() switch
{
"previousbuild" or "previous_build" or "previous-build" => BaselineSelectionStrategy.PreviousBuild,
"lastapproved" or "last_approved" or "last-approved" => BaselineSelectionStrategy.LastApproved,
"productiondeployed" or "production_deployed" or "production-deployed" or "production" => BaselineSelectionStrategy.ProductionDeployed,
"branchbase" or "branch_base" or "branch-base" => BaselineSelectionStrategy.BranchBase,
_ => BaselineSelectionStrategy.LastApproved
};
}
private static bool IsBlockingDriver(DeltaDriver driver)
{
// Block on critical/high severity negative drivers
if (driver.Severity is DeltaDriverSeverity.Critical or DeltaDriverSeverity.High)
{
// These types indicate risk increase
return driver.Type is
"new-reachable-cve" or
"lost-vex-coverage" or
"vex-status-downgrade" or
"new-policy-violation";
}
return false;
}
private static void AddRecommendations(DeltaVerdictBuilder builder, IReadOnlyList<DeltaDriver> drivers)
{
var hasReachableCve = drivers.Any(d => d.Type == "new-reachable-cve");
var hasLostVex = drivers.Any(d => d.Type == "lost-vex-coverage");
var hasNewViolation = drivers.Any(d => d.Type == "new-policy-violation");
var hasNewUnknowns = drivers.Any(d => d.Type == "new-unknowns");
if (hasReachableCve)
{
builder.AddRecommendation("Review new reachable CVEs and apply VEX statements or patches");
}
if (hasLostVex)
{
builder.AddRecommendation("Investigate lost VEX coverage - statements may have expired or been revoked");
}
if (hasNewViolation)
{
builder.AddRecommendation("Address policy violations or request exceptions");
}
if (hasNewUnknowns)
{
builder.AddRecommendation("Investigate new unknown packages - consider adding SBOM metadata");
}
}
}

View File

@@ -0,0 +1,877 @@
// SPDX-License-Identifier: BUSL-1.1
// Sprint: SPRINT_20251226_003_BE_exception_approval
// Task: EXCEPT-05, EXCEPT-06, EXCEPT-07 - Exception approval API endpoints
using Microsoft.AspNetCore.Mvc;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Auth.ServerIntegration.Tenancy;
using StellaOps.Policy.Engine.Services;
using StellaOps.Policy.Persistence.Postgres.Models;
using StellaOps.Policy.Persistence.Postgres.Repositories;
using System.Text.Json;
namespace StellaOps.Policy.Engine.Endpoints.Gateway;
/// <summary>
/// Exception approval workflow API endpoints.
/// </summary>
public static class ExceptionApprovalEndpoints
{
/// <summary>
/// Maps exception approval endpoints to the application.
/// </summary>
public static void MapExceptionApprovalEndpoints(this WebApplication app)
{
var exceptions = app.MapGroup("/api/v1/policy/exception")
.WithTags("Exception Approvals")
.RequireTenant();
// POST /api/v1/policy/exception/request - Create a new exception approval request
exceptions.MapPost("/request", CreateApprovalRequestAsync)
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.ExceptionsRequest))
.WithName("CreateExceptionApprovalRequest")
.WithDescription("Create a new exception approval request for a vulnerability, policy rule, PURL pattern, or artifact digest. Validates the requested TTL against the gate-level maximum, enforces approval rules for the gate level, and optionally auto-approves low-risk requests that meet the configured criteria. The request enters the pending state and is routed to configured approvers.");
// GET /api/v1/policy/exception/request/{requestId} - Get an approval request
exceptions.MapGet("/request/{requestId}", GetApprovalRequestAsync)
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.ExceptionsRead))
.WithName("GetExceptionApprovalRequest")
.WithDescription("Retrieve the full details of a specific exception approval request by its request ID, including status, gate level, approval progress (approved count vs required count), scope, lifecycle timestamps, and any validation warnings from the creation step.");
// GET /api/v1/policy/exception/requests - List approval requests
exceptions.MapGet("/requests", ListApprovalRequestsAsync)
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.ExceptionsRead))
.WithName("ListExceptionApprovalRequests")
.WithDescription("List exception approval requests for the tenant with optional status filtering and pagination. Returns summary DTOs with request ID, status, gate level, requester, vulnerability or PURL scope, reason code, and approval progress. Used by governance dashboards and approval queue UIs.");
// GET /api/v1/policy/exception/pending - List pending approvals for current user
exceptions.MapGet("/pending", ListPendingApprovalsAsync)
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.ExceptionsApprove))
.WithName("ListPendingApprovals")
.WithDescription("List exception approval requests that are currently pending and require action from the authenticated approver. Used to drive approver inbox views and notification counts, returning only requests where the calling user is listed as a required approver and has not yet recorded an approval.");
// POST /api/v1/policy/exception/{requestId}/approve - Approve an exception request
exceptions.MapPost("/{requestId}/approve", ApproveRequestAsync)
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.ExceptionsApprove))
.WithName("ApproveExceptionRequest")
.WithDescription("Record an approval action for a pending exception request. Validates that the approver is authorized at the request's gate level, records the approver's identity, and optionally captures a comment. When sufficient approvers have acted, the request transitions to the approved state and the approval workflow is considered complete.");
// POST /api/v1/policy/exception/{requestId}/reject - Reject an exception request
exceptions.MapPost("/{requestId}/reject", RejectRequestAsync)
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.ExceptionsApprove))
.WithName("RejectExceptionRequest")
.WithDescription("Reject a pending or partially-approved exception request, providing a mandatory reason that is recorded in the audit trail. Transitions the request to the rejected terminal state, preventing further approval actions. The rejection reason is surfaced to the requester for remediation guidance.");
// POST /api/v1/policy/exception/{requestId}/cancel - Cancel an exception request
exceptions.MapPost("/{requestId}/cancel", CancelRequestAsync)
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.ExceptionsRequest))
.WithName("CancelExceptionRequest")
.WithDescription("Cancel an open exception approval request, accessible only to the original requester. Enforces ownership by comparing the authenticated actor against the stored requestor ID. Returns HTTP 403 when called by a non-owner and HTTP 400 when the request is already in a terminal state.");
// GET /api/v1/policy/exception/{requestId}/audit - Get audit trail for a request
exceptions.MapGet("/{requestId}/audit", GetAuditTrailAsync)
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.ExceptionsRead))
.WithName("GetExceptionApprovalAudit")
.WithDescription("Retrieve the ordered audit trail for an exception approval request, returning all recorded lifecycle events with sequence numbers, actor IDs, status transitions, and descriptive entries. Used for compliance reporting and post-incident review of the approval workflow.");
// GET /api/v1/policy/exception/rules - Get approval rules for the tenant
exceptions.MapGet("/rules", GetApprovalRulesAsync)
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.ExceptionsRead))
.WithName("GetExceptionApprovalRules")
.WithDescription("Retrieve the exception approval rules configured for the tenant, including per-gate-level minimum approver counts, required approver roles, maximum TTL days, self-approval policy, and evidence and compensating-control requirements. Used by policy authoring tools to display approval requirements to requestors before submission.");
}
// ========================================================================
// Endpoint Handlers
// ========================================================================
private static async Task<IResult> CreateApprovalRequestAsync(
HttpContext httpContext,
CreateApprovalRequestDto request,
IExceptionApprovalRepository repository,
IExceptionApprovalRulesService rulesService,
[FromServices] TimeProvider timeProvider,
ILogger<ExceptionApprovalRequestEntity> logger,
CancellationToken cancellationToken)
{
if (request is null)
{
return Results.BadRequest(new ProblemDetails
{
Title = "Request body required",
Status = 400
});
}
var tenantId = GetTenantId(httpContext);
var requestorId = GetActorId(httpContext);
if (string.IsNullOrWhiteSpace(tenantId) || string.IsNullOrWhiteSpace(requestorId))
{
return Results.Unauthorized();
}
// Generate request ID
var requestId = $"EAR-{timeProvider.GetUtcNow():yyyyMMdd}-{Guid.NewGuid().ToString("N")[..8].ToUpperInvariant()}";
// Parse gate level
if (!Enum.TryParse<GateLevel>(request.GateLevel, ignoreCase: true, out var gateLevel))
{
gateLevel = GateLevel.G1; // Default to G1 if not specified
}
// Parse reason code
if (!Enum.TryParse<ExceptionReasonCode>(request.ReasonCode, ignoreCase: true, out var reasonCode))
{
reasonCode = ExceptionReasonCode.Other;
}
// Get requirements for validation
var requirements = await rulesService.GetRequirementsAsync(tenantId, gateLevel, cancellationToken);
// Validate TTL
var requestedTtl = request.RequestedTtlDays ?? 30;
if (requestedTtl > requirements.MaxTtlDays)
{
return Results.BadRequest(new ProblemDetails
{
Title = "Invalid TTL",
Status = 400,
Detail = $"Requested TTL ({requestedTtl} days) exceeds maximum allowed ({requirements.MaxTtlDays} days) for gate level {gateLevel}."
});
}
var now = timeProvider.GetUtcNow();
var entity = new ExceptionApprovalRequestEntity
{
Id = Guid.NewGuid(),
RequestId = requestId,
TenantId = tenantId,
ExceptionId = request.ExceptionId,
RequestorId = requestorId,
RequiredApproverIds = request.RequiredApproverIds?.ToArray() ?? [],
ApprovedByIds = [],
Status = ApprovalRequestStatus.Pending,
GateLevel = gateLevel,
Justification = request.Justification ?? string.Empty,
Rationale = request.Rationale,
ReasonCode = reasonCode,
EvidenceRefs = JsonSerializer.Serialize(request.EvidenceRefs ?? []),
CompensatingControls = JsonSerializer.Serialize(request.CompensatingControls ?? []),
TicketRef = request.TicketRef,
VulnerabilityId = request.VulnerabilityId,
PurlPattern = request.PurlPattern,
ArtifactDigest = request.ArtifactDigest,
ImagePattern = request.ImagePattern,
Environments = request.Environments?.ToArray() ?? [],
RequestedTtlDays = requestedTtl,
CreatedAt = now,
RequestExpiresAt = now.AddDays(7), // 7-day approval window
ExceptionExpiresAt = now.AddDays(requestedTtl),
Metadata = request.Metadata is not null ? JsonSerializer.Serialize(request.Metadata) : "{}",
Version = 1,
UpdatedAt = now
};
// Validate request
var validation = await rulesService.ValidateRequestAsync(entity, cancellationToken);
if (!validation.IsValid)
{
return Results.BadRequest(new ProblemDetails
{
Title = "Validation failed",
Status = 400,
Detail = string.Join("; ", validation.Errors)
});
}
// Check if auto-approve is allowed
if (await rulesService.CanAutoApproveAsync(entity, cancellationToken))
{
entity = entity with
{
Status = ApprovalRequestStatus.Approved,
ResolvedAt = now
};
logger.LogInformation(
"Exception request {RequestId} auto-approved (gate level {GateLevel})",
requestId,
gateLevel);
}
// Create the request
var created = await repository.CreateRequestAsync(entity, cancellationToken);
// Record audit entry
await repository.RecordAuditAsync(new ExceptionApprovalAuditEntity
{
Id = Guid.NewGuid(),
RequestId = requestId,
TenantId = tenantId,
SequenceNumber = 1,
ActionType = "requested",
ActorId = requestorId,
OccurredAt = now,
PreviousStatus = null,
NewStatus = created.Status.ToString().ToLowerInvariant(),
Description = $"Exception approval request created for {request.VulnerabilityId ?? request.PurlPattern ?? "general exception"}",
Details = JsonSerializer.Serialize(new { gateLevel = gateLevel.ToString(), reasonCode = reasonCode.ToString() }),
ClientInfo = BuildClientInfo(httpContext)
}, cancellationToken);
logger.LogInformation(
"Exception approval request created: {RequestId}, GateLevel={GateLevel}, Requestor={Requestor}",
requestId,
gateLevel,
requestorId);
return Results.Created(
$"/api/v1/policy/exception/request/{requestId}",
MapToDto(created, validation.Warnings));
}
private static async Task<IResult> GetApprovalRequestAsync(
HttpContext httpContext,
string requestId,
IExceptionApprovalRepository repository,
CancellationToken cancellationToken)
{
var tenantId = GetTenantId(httpContext);
if (string.IsNullOrWhiteSpace(tenantId))
{
return Results.Unauthorized();
}
var request = await repository.GetRequestAsync(tenantId, requestId, cancellationToken);
if (request is null)
{
return Results.NotFound(new ProblemDetails
{
Title = "Request not found",
Status = 404,
Detail = $"Exception approval request '{requestId}' not found."
});
}
return Results.Ok(MapToDto(request));
}
private static async Task<IResult> ListApprovalRequestsAsync(
HttpContext httpContext,
IExceptionApprovalRepository repository,
[FromQuery] string? status,
[FromQuery] int limit = 100,
[FromQuery] int offset = 0,
CancellationToken cancellationToken = default)
{
var tenantId = GetTenantId(httpContext);
if (string.IsNullOrWhiteSpace(tenantId))
{
return Results.Unauthorized();
}
ApprovalRequestStatus? statusFilter = null;
if (!string.IsNullOrWhiteSpace(status) &&
Enum.TryParse<ApprovalRequestStatus>(status, ignoreCase: true, out var parsed))
{
statusFilter = parsed;
}
var requests = await repository.ListRequestsAsync(
tenantId,
statusFilter,
Math.Min(limit, 500),
Math.Max(offset, 0),
cancellationToken);
return Results.Ok(new ApprovalRequestListResponse
{
Items = requests.Select(r => MapToSummaryDto(r)).ToList(),
Limit = limit,
Offset = offset
});
}
private static async Task<IResult> ListPendingApprovalsAsync(
HttpContext httpContext,
IExceptionApprovalRepository repository,
[FromQuery] int limit = 100,
CancellationToken cancellationToken = default)
{
var tenantId = GetTenantId(httpContext);
var approverId = GetActorId(httpContext);
if (string.IsNullOrWhiteSpace(tenantId) || string.IsNullOrWhiteSpace(approverId))
{
return Results.Unauthorized();
}
var requests = await repository.ListPendingForApproverAsync(
tenantId,
approverId,
Math.Min(limit, 500),
cancellationToken);
return Results.Ok(new ApprovalRequestListResponse
{
Items = requests.Select(r => MapToSummaryDto(r)).ToList(),
Limit = limit,
Offset = 0
});
}
private static async Task<IResult> ApproveRequestAsync(
HttpContext httpContext,
string requestId,
ApproveRequestDto? request,
IExceptionApprovalRepository repository,
IExceptionApprovalRulesService rulesService,
ILogger<ExceptionApprovalRequestEntity> logger,
CancellationToken cancellationToken)
{
var tenantId = GetTenantId(httpContext);
var approverId = GetActorId(httpContext);
var approverRoles = GetActorRoles(httpContext);
if (string.IsNullOrWhiteSpace(tenantId) || string.IsNullOrWhiteSpace(approverId))
{
return Results.Unauthorized();
}
var existing = await repository.GetRequestAsync(tenantId, requestId, cancellationToken);
if (existing is null)
{
return Results.NotFound(new ProblemDetails
{
Title = "Request not found",
Status = 404,
Detail = $"Exception approval request '{requestId}' not found."
});
}
// Validate approval action
var validation = await rulesService.ValidateApprovalActionAsync(
existing,
approverId,
approverRoles,
cancellationToken);
if (!validation.IsValid)
{
return Results.BadRequest(new ProblemDetails
{
Title = "Approval not allowed",
Status = 400,
Detail = string.Join("; ", validation.Errors)
});
}
var approved = await repository.ApproveAsync(
tenantId,
requestId,
approverId,
request?.Comment,
cancellationToken);
if (approved is null)
{
return Results.Problem(new ProblemDetails
{
Title = "Approval failed",
Status = 500,
Detail = "Failed to record approval. The request may have been modified."
});
}
logger.LogInformation(
"Exception request {RequestId} approved by {ApproverId}, CompletesWorkflow={Completes}",
requestId,
approverId,
validation.CompletesWorkflow);
return Results.Ok(MapToDto(approved));
}
private static async Task<IResult> RejectRequestAsync(
HttpContext httpContext,
string requestId,
RejectRequestDto request,
IExceptionApprovalRepository repository,
ILogger<ExceptionApprovalRequestEntity> logger,
CancellationToken cancellationToken)
{
if (request is null || string.IsNullOrWhiteSpace(request.Reason))
{
return Results.BadRequest(new ProblemDetails
{
Title = "Rejection reason required",
Status = 400,
Detail = "A reason is required when rejecting an exception request."
});
}
var tenantId = GetTenantId(httpContext);
var rejectorId = GetActorId(httpContext);
if (string.IsNullOrWhiteSpace(tenantId) || string.IsNullOrWhiteSpace(rejectorId))
{
return Results.Unauthorized();
}
var existing = await repository.GetRequestAsync(tenantId, requestId, cancellationToken);
if (existing is null)
{
return Results.NotFound(new ProblemDetails
{
Title = "Request not found",
Status = 404,
Detail = $"Exception approval request '{requestId}' not found."
});
}
if (existing.Status != ApprovalRequestStatus.Pending &&
existing.Status != ApprovalRequestStatus.Partial)
{
return Results.BadRequest(new ProblemDetails
{
Title = "Cannot reject",
Status = 400,
Detail = $"Request is in {existing.Status} status and cannot be rejected."
});
}
var rejected = await repository.RejectAsync(
tenantId,
requestId,
rejectorId,
request.Reason,
cancellationToken);
if (rejected is null)
{
return Results.Problem(new ProblemDetails
{
Title = "Rejection failed",
Status = 500,
Detail = "Failed to record rejection. The request may have been modified."
});
}
logger.LogInformation(
"Exception request {RequestId} rejected by {RejectorId}: {Reason}",
requestId,
rejectorId,
request.Reason);
return Results.Ok(MapToDto(rejected));
}
private static async Task<IResult> CancelRequestAsync(
HttpContext httpContext,
string requestId,
CancelRequestDto? request,
IExceptionApprovalRepository repository,
ILogger<ExceptionApprovalRequestEntity> logger,
CancellationToken cancellationToken)
{
var tenantId = GetTenantId(httpContext);
var actorId = GetActorId(httpContext);
if (string.IsNullOrWhiteSpace(tenantId) || string.IsNullOrWhiteSpace(actorId))
{
return Results.Unauthorized();
}
var existing = await repository.GetRequestAsync(tenantId, requestId, cancellationToken);
if (existing is null)
{
return Results.NotFound(new ProblemDetails
{
Title = "Request not found",
Status = 404,
Detail = $"Exception approval request '{requestId}' not found."
});
}
// Only requestor can cancel
if (!string.Equals(existing.RequestorId, actorId, StringComparison.OrdinalIgnoreCase))
{
return Results.Forbid();
}
var cancelled = await repository.CancelRequestAsync(
tenantId,
requestId,
actorId,
request?.Reason,
cancellationToken);
if (!cancelled)
{
return Results.BadRequest(new ProblemDetails
{
Title = "Cannot cancel",
Status = 400,
Detail = "Request cannot be cancelled. It may already be resolved."
});
}
logger.LogInformation(
"Exception request {RequestId} cancelled by {ActorId}",
requestId,
actorId);
return Results.NoContent();
}
private static async Task<IResult> GetAuditTrailAsync(
HttpContext httpContext,
string requestId,
IExceptionApprovalRepository repository,
CancellationToken cancellationToken)
{
var tenantId = GetTenantId(httpContext);
if (string.IsNullOrWhiteSpace(tenantId))
{
return Results.Unauthorized();
}
var audit = await repository.GetAuditTrailAsync(tenantId, requestId, cancellationToken);
return Results.Ok(new AuditTrailResponse
{
RequestId = requestId,
Entries = audit.Select(MapToAuditDto).ToList()
});
}
private static async Task<IResult> GetApprovalRulesAsync(
HttpContext httpContext,
IExceptionApprovalRepository repository,
CancellationToken cancellationToken)
{
var tenantId = GetTenantId(httpContext);
if (string.IsNullOrWhiteSpace(tenantId))
{
return Results.Unauthorized();
}
var rules = await repository.ListApprovalRulesAsync(tenantId, cancellationToken);
return Results.Ok(new ApprovalRulesResponse
{
Rules = rules.Select(MapToRuleDto).ToList()
});
}
// ========================================================================
// Helper Methods
// ========================================================================
private static string? GetTenantId(HttpContext httpContext)
{
return httpContext.User.Claims.FirstOrDefault(c => c.Type == "tenant_id")?.Value
?? httpContext.Request.Headers["X-Tenant-Id"].FirstOrDefault();
}
private static string? GetActorId(HttpContext httpContext)
{
return httpContext.User.Claims.FirstOrDefault(c => c.Type == "sub")?.Value
?? httpContext.User.Identity?.Name;
}
private static HashSet<string> GetActorRoles(HttpContext httpContext)
{
return httpContext.User.Claims
.Where(c => c.Type == "role" || c.Type == "roles")
.Select(c => c.Value)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
}
private static string BuildClientInfo(HttpContext httpContext)
{
var info = new Dictionary<string, string?>
{
["ip"] = httpContext.Connection.RemoteIpAddress?.ToString(),
["user_agent"] = httpContext.Request.Headers.UserAgent.FirstOrDefault(),
["correlation_id"] = httpContext.Request.Headers["X-Correlation-Id"].FirstOrDefault()
};
return JsonSerializer.Serialize(info);
}
private static ApprovalRequestDto MapToDto(
ExceptionApprovalRequestEntity entity,
IReadOnlyList<string>? warnings = null)
{
return new ApprovalRequestDto
{
RequestId = entity.RequestId,
Id = entity.Id,
TenantId = entity.TenantId,
ExceptionId = entity.ExceptionId,
RequestorId = entity.RequestorId,
RequiredApproverIds = entity.RequiredApproverIds,
ApprovedByIds = entity.ApprovedByIds,
RejectedById = entity.RejectedById,
Status = entity.Status.ToString(),
GateLevel = entity.GateLevel.ToString(),
Justification = entity.Justification,
Rationale = entity.Rationale,
ReasonCode = entity.ReasonCode.ToString(),
EvidenceRefs = ParseJsonArray(entity.EvidenceRefs),
CompensatingControls = ParseJsonArray(entity.CompensatingControls),
TicketRef = entity.TicketRef,
VulnerabilityId = entity.VulnerabilityId,
PurlPattern = entity.PurlPattern,
ArtifactDigest = entity.ArtifactDigest,
ImagePattern = entity.ImagePattern,
Environments = entity.Environments,
RequestedTtlDays = entity.RequestedTtlDays,
CreatedAt = entity.CreatedAt,
RequestExpiresAt = entity.RequestExpiresAt,
ExceptionExpiresAt = entity.ExceptionExpiresAt,
ResolvedAt = entity.ResolvedAt,
RejectionReason = entity.RejectionReason,
Version = entity.Version,
UpdatedAt = entity.UpdatedAt,
Warnings = warnings ?? []
};
}
private static ApprovalRequestSummaryDto MapToSummaryDto(ExceptionApprovalRequestEntity entity)
{
return new ApprovalRequestSummaryDto
{
RequestId = entity.RequestId,
Status = entity.Status.ToString(),
GateLevel = entity.GateLevel.ToString(),
RequestorId = entity.RequestorId,
VulnerabilityId = entity.VulnerabilityId,
PurlPattern = entity.PurlPattern,
ReasonCode = entity.ReasonCode.ToString(),
CreatedAt = entity.CreatedAt,
RequestExpiresAt = entity.RequestExpiresAt,
ApprovedCount = entity.ApprovedByIds.Length,
RequiredCount = entity.RequiredApproverIds.Length
};
}
private static AuditEntryDto MapToAuditDto(ExceptionApprovalAuditEntity entity)
{
return new AuditEntryDto
{
Id = entity.Id,
SequenceNumber = entity.SequenceNumber,
ActionType = entity.ActionType,
ActorId = entity.ActorId,
OccurredAt = entity.OccurredAt,
PreviousStatus = entity.PreviousStatus,
NewStatus = entity.NewStatus,
Description = entity.Description
};
}
private static ApprovalRuleDto MapToRuleDto(ExceptionApprovalRuleEntity entity)
{
return new ApprovalRuleDto
{
Id = entity.Id,
Name = entity.Name,
Description = entity.Description,
GateLevel = entity.GateLevel.ToString(),
MinApprovers = entity.MinApprovers,
RequiredRoles = entity.RequiredRoles,
MaxTtlDays = entity.MaxTtlDays,
AllowSelfApproval = entity.AllowSelfApproval,
RequireEvidence = entity.RequireEvidence,
RequireCompensatingControls = entity.RequireCompensatingControls,
MinRationaleLength = entity.MinRationaleLength,
Enabled = entity.Enabled
};
}
private static List<string> ParseJsonArray(string json)
{
if (string.IsNullOrWhiteSpace(json) || json == "[]")
return [];
try
{
return JsonSerializer.Deserialize<List<string>>(json) ?? [];
}
catch
{
return [];
}
}
}
// ============================================================================
// DTO Models
// ============================================================================
/// <summary>
/// Request to create an exception approval request.
/// </summary>
public sealed record CreateApprovalRequestDto
{
public string? ExceptionId { get; init; }
public required string Justification { get; init; }
public string? Rationale { get; init; }
public string? GateLevel { get; init; }
public string? ReasonCode { get; init; }
public List<string>? EvidenceRefs { get; init; }
public List<string>? CompensatingControls { get; init; }
public string? TicketRef { get; init; }
public string? VulnerabilityId { get; init; }
public string? PurlPattern { get; init; }
public string? ArtifactDigest { get; init; }
public string? ImagePattern { get; init; }
public List<string>? Environments { get; init; }
public List<string>? RequiredApproverIds { get; init; }
public int? RequestedTtlDays { get; init; }
public Dictionary<string, string>? Metadata { get; init; }
}
/// <summary>
/// Request to approve an exception.
/// </summary>
public sealed record ApproveRequestDto
{
public string? Comment { get; init; }
}
/// <summary>
/// Request to reject an exception.
/// </summary>
public sealed record RejectRequestDto
{
public required string Reason { get; init; }
}
/// <summary>
/// Request to cancel an exception request.
/// </summary>
public sealed record CancelRequestDto
{
public string? Reason { get; init; }
}
/// <summary>
/// Full approval request response.
/// </summary>
public sealed record ApprovalRequestDto
{
public required string RequestId { get; init; }
public Guid Id { get; init; }
public required string TenantId { get; init; }
public string? ExceptionId { get; init; }
public required string RequestorId { get; init; }
public string[] RequiredApproverIds { get; init; } = [];
public string[] ApprovedByIds { get; init; } = [];
public string? RejectedById { get; init; }
public required string Status { get; init; }
public required string GateLevel { get; init; }
public required string Justification { get; init; }
public string? Rationale { get; init; }
public required string ReasonCode { get; init; }
public List<string> EvidenceRefs { get; init; } = [];
public List<string> CompensatingControls { get; init; } = [];
public string? TicketRef { get; init; }
public string? VulnerabilityId { get; init; }
public string? PurlPattern { get; init; }
public string? ArtifactDigest { get; init; }
public string? ImagePattern { get; init; }
public string[] Environments { get; init; } = [];
public int RequestedTtlDays { get; init; }
public DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset RequestExpiresAt { get; init; }
public DateTimeOffset? ExceptionExpiresAt { get; init; }
public DateTimeOffset? ResolvedAt { get; init; }
public string? RejectionReason { get; init; }
public int Version { get; init; }
public DateTimeOffset UpdatedAt { get; init; }
public IReadOnlyList<string> Warnings { get; init; } = [];
}
/// <summary>
/// Summary approval request for listings.
/// </summary>
public sealed record ApprovalRequestSummaryDto
{
public required string RequestId { get; init; }
public required string Status { get; init; }
public required string GateLevel { get; init; }
public required string RequestorId { get; init; }
public string? VulnerabilityId { get; init; }
public string? PurlPattern { get; init; }
public required string ReasonCode { get; init; }
public DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset RequestExpiresAt { get; init; }
public int ApprovedCount { get; init; }
public int RequiredCount { get; init; }
}
/// <summary>
/// Approval request list response.
/// </summary>
public sealed record ApprovalRequestListResponse
{
public required IReadOnlyList<ApprovalRequestSummaryDto> Items { get; init; }
public int Limit { get; init; }
public int Offset { get; init; }
}
/// <summary>
/// Audit entry response.
/// </summary>
public sealed record AuditEntryDto
{
public Guid Id { get; init; }
public int SequenceNumber { get; init; }
public required string ActionType { get; init; }
public required string ActorId { get; init; }
public DateTimeOffset OccurredAt { get; init; }
public string? PreviousStatus { get; init; }
public required string NewStatus { get; init; }
public string? Description { get; init; }
}
/// <summary>
/// Audit trail response.
/// </summary>
public sealed record AuditTrailResponse
{
public required string RequestId { get; init; }
public required IReadOnlyList<AuditEntryDto> Entries { get; init; }
}
/// <summary>
/// Approval rule response.
/// </summary>
public sealed record ApprovalRuleDto
{
public Guid Id { get; init; }
public required string Name { get; init; }
public string? Description { get; init; }
public required string GateLevel { get; init; }
public int MinApprovers { get; init; }
public string[] RequiredRoles { get; init; } = [];
public int MaxTtlDays { get; init; }
public bool AllowSelfApproval { get; init; }
public bool RequireEvidence { get; init; }
public bool RequireCompensatingControls { get; init; }
public int MinRationaleLength { get; init; }
public bool Enabled { get; init; }
}
/// <summary>
/// Approval rules list response.
/// </summary>
public sealed record ApprovalRulesResponse
{
public required IReadOnlyList<ApprovalRuleDto> Rules { get; init; }
}

View File

@@ -0,0 +1,585 @@
// <copyright file="ExceptionEndpoints.cs" company="StellaOps">
// Copyright (c) StellaOps. All rights reserved.
// Licensed under the BUSL-1.1 license.
// </copyright>
using Microsoft.AspNetCore.Mvc;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Auth.ServerIntegration.Tenancy;
using StellaOps.Policy.Exceptions.Models;
using StellaOps.Policy.Exceptions.Repositories;
using StellaOps.Policy.Engine.Contracts.Gateway;
using System.Collections.Immutable;
using System.Security.Claims;
using static StellaOps.Localization.T;
namespace StellaOps.Policy.Engine.Endpoints.Gateway;
/// <summary>
/// Exception API endpoints for Policy Gateway.
/// </summary>
public static class ExceptionEndpoints
{
/// <summary>
/// Maps exception endpoints to the application.
/// </summary>
public static void MapExceptionEndpoints(this WebApplication app)
{
var exceptions = app.MapGroup("/api/policy/exceptions")
.WithTags("Exceptions")
.RequireTenant();
// GET /api/policy/exceptions - List exceptions with filters
exceptions.MapGet(string.Empty, async Task<IResult>(
[FromQuery] string? status,
[FromQuery] string? type,
[FromQuery] string? vulnerabilityId,
[FromQuery] string? purlPattern,
[FromQuery] string? environment,
[FromQuery] string? ownerId,
[FromQuery] int? limit,
[FromQuery] int? offset,
IExceptionRepository repository,
CancellationToken cancellationToken) =>
{
var filter = new ExceptionFilter
{
Status = ParseStatus(status),
Type = ParseType(type),
VulnerabilityId = vulnerabilityId,
PurlPattern = purlPattern,
Environment = environment,
OwnerId = ownerId,
Limit = Math.Clamp(limit ?? 50, 1, 100),
Offset = offset ?? 0
};
var results = await repository.GetByFilterAsync(filter, cancellationToken);
var counts = await repository.GetCountsAsync(null, cancellationToken);
return Results.Ok(new ExceptionListResponse
{
Items = results.Select(ToDto).ToList(),
TotalCount = counts.Total,
Offset = filter.Offset,
Limit = filter.Limit
});
})
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead))
.WithName("ListExceptions")
.WithDescription("List policy exceptions with optional filtering by status, type, vulnerability ID, PURL pattern, environment, or owner. Returns paginated results including per-item status, scope, and lifecycle timestamps for exception management dashboards and compliance reporting.");
// GET /api/policy/exceptions/counts - Get exception counts
exceptions.MapGet("/counts", async Task<IResult>(
IExceptionRepository repository,
CancellationToken cancellationToken) =>
{
var counts = await repository.GetCountsAsync(null, cancellationToken);
return Results.Ok(new ExceptionCountsResponse
{
Total = counts.Total,
Proposed = counts.Proposed,
Approved = counts.Approved,
Active = counts.Active,
Expired = counts.Expired,
Revoked = counts.Revoked,
ExpiringSoon = counts.ExpiringSoon
});
})
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead))
.WithName("GetExceptionCounts")
.WithDescription("Return aggregate counts of exceptions by lifecycle status (proposed, approved, active, expired, revoked) plus an expiring-soon indicator. Used by governance dashboards to give operators a quick view of the exception portfolio health without fetching the full list.");
// GET /api/policy/exceptions/{id} - Get exception by ID
exceptions.MapGet("/{id}", async Task<IResult>(
string id,
IExceptionRepository repository,
CancellationToken cancellationToken) =>
{
var exception = await repository.GetByIdAsync(id, cancellationToken);
if (exception is null)
{
return Results.NotFound(new ProblemDetails
{
Title = _t("policy.error.exception_not_found"),
Status = 404,
Detail = $"No exception found with ID: {id}"
});
}
return Results.Ok(ToDto(exception));
})
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead))
.WithName("GetException")
.WithDescription("Retrieve the full details of a single exception by its identifier, including scope, rationale, evidence references, compensating controls, and lifecycle timestamps.");
// GET /api/policy/exceptions/{id}/history - Get exception history
exceptions.MapGet("/{id}/history", async Task<IResult>(
string id,
IExceptionRepository repository,
CancellationToken cancellationToken) =>
{
var history = await repository.GetHistoryAsync(id, cancellationToken);
return Results.Ok(new ExceptionHistoryResponse
{
ExceptionId = history.ExceptionId,
Events = history.Events.Select(e => new ExceptionEventDto
{
EventId = e.EventId,
SequenceNumber = e.SequenceNumber,
EventType = e.EventType.ToString().ToLowerInvariant(),
ActorId = e.ActorId,
OccurredAt = e.OccurredAt,
PreviousStatus = e.PreviousStatus?.ToString().ToLowerInvariant(),
NewStatus = e.NewStatus.ToString().ToLowerInvariant(),
Description = e.Description
}).ToList()
});
})
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead))
.WithName("GetExceptionHistory")
.WithDescription("Retrieve the ordered audit history of an exception, including every status transition, the actor who performed each action, and descriptive event entries. Supports compliance reviews and traceability of the full exception lifecycle from proposal through resolution.");
// POST /api/policy/exceptions - Create exception
exceptions.MapPost(string.Empty, async Task<IResult>(
CreateExceptionRequest request,
HttpContext context,
IExceptionRepository repository,
[FromServices] TimeProvider timeProvider,
CancellationToken cancellationToken) =>
{
if (request is null)
{
return Results.BadRequest(new ProblemDetails
{
Title = "Request body required",
Status = 400
});
}
// Validate expiry is in future
if (request.ExpiresAt <= timeProvider.GetUtcNow())
{
return Results.BadRequest(new ProblemDetails
{
Title = "Invalid expiry",
Status = 400,
Detail = "Expiry date must be in the future"
});
}
// Validate expiry is not more than 1 year
if (request.ExpiresAt > timeProvider.GetUtcNow().AddYears(1))
{
return Results.BadRequest(new ProblemDetails
{
Title = "Invalid expiry",
Status = 400,
Detail = "Expiry date cannot be more than 1 year in the future"
});
}
var actorId = GetActorId(context);
var clientInfo = GetClientInfo(context);
var exceptionId = $"EXC-{Guid.NewGuid():N}"[..20];
var exception = new ExceptionObject
{
ExceptionId = exceptionId,
Version = 1,
Status = ExceptionStatus.Proposed,
Type = ParseTypeRequired(request.Type),
Scope = new ExceptionScope
{
ArtifactDigest = request.Scope.ArtifactDigest,
PurlPattern = request.Scope.PurlPattern,
VulnerabilityId = request.Scope.VulnerabilityId,
PolicyRuleId = request.Scope.PolicyRuleId,
Environments = request.Scope.Environments?.ToImmutableArray() ?? []
},
OwnerId = request.OwnerId,
RequesterId = actorId,
CreatedAt = timeProvider.GetUtcNow(),
UpdatedAt = timeProvider.GetUtcNow(),
ExpiresAt = request.ExpiresAt,
ReasonCode = ParseReasonRequired(request.ReasonCode),
Rationale = request.Rationale,
EvidenceRefs = request.EvidenceRefs?.ToImmutableArray() ?? [],
CompensatingControls = request.CompensatingControls?.ToImmutableArray() ?? [],
TicketRef = request.TicketRef,
Metadata = request.Metadata?.ToImmutableDictionary() ?? ImmutableDictionary<string, string>.Empty
};
var created = await repository.CreateAsync(exception, actorId, clientInfo, cancellationToken);
return Results.Created($"/api/policy/exceptions/{created.ExceptionId}", ToDto(created));
})
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyAuthor))
.WithName("CreateException")
.WithDescription("Create a new policy exception in the proposed state. Validates that the expiry is in the future and does not exceed one year, captures the requesting actor from the authenticated identity, and records the scope (artifact digest, PURL pattern, vulnerability ID, or policy rule), reason code, rationale, and compensating controls.");
// PUT /api/policy/exceptions/{id} - Update exception
exceptions.MapPut("/{id}", async Task<IResult>(
string id,
UpdateExceptionRequest request,
HttpContext context,
IExceptionRepository repository,
[FromServices] TimeProvider timeProvider,
CancellationToken cancellationToken) =>
{
var existing = await repository.GetByIdAsync(id, cancellationToken);
if (existing is null)
{
return Results.NotFound(new ProblemDetails
{
Title = "Exception not found",
Status = 404
});
}
if (existing.Status is ExceptionStatus.Expired or ExceptionStatus.Revoked)
{
return Results.BadRequest(new ProblemDetails
{
Title = "Cannot update",
Status = 400,
Detail = "Cannot update an expired or revoked exception"
});
}
var actorId = GetActorId(context);
var clientInfo = GetClientInfo(context);
var updated = existing with
{
Version = existing.Version + 1,
UpdatedAt = timeProvider.GetUtcNow(),
Rationale = request.Rationale ?? existing.Rationale,
EvidenceRefs = request.EvidenceRefs?.ToImmutableArray() ?? existing.EvidenceRefs,
CompensatingControls = request.CompensatingControls?.ToImmutableArray() ?? existing.CompensatingControls,
TicketRef = request.TicketRef ?? existing.TicketRef,
Metadata = request.Metadata?.ToImmutableDictionary() ?? existing.Metadata
};
var result = await repository.UpdateAsync(
updated, ExceptionEventType.Updated, actorId, "Exception updated", clientInfo, cancellationToken);
return Results.Ok(ToDto(result));
})
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyAuthor))
.WithName("UpdateException")
.WithDescription("Update the mutable fields of an existing exception (rationale, evidence references, compensating controls, ticket reference, and metadata). Cannot update expired or revoked exceptions. Version is incremented and the updated-at timestamp is refreshed on every successful update.");
// POST /api/policy/exceptions/{id}/approve - Approve exception
exceptions.MapPost("/{id}/approve", async Task<IResult>(
string id,
ApproveExceptionRequest? request,
HttpContext context,
IExceptionRepository repository,
[FromServices] TimeProvider timeProvider,
CancellationToken cancellationToken) =>
{
var existing = await repository.GetByIdAsync(id, cancellationToken);
if (existing is null)
{
return Results.NotFound(new ProblemDetails { Title = _t("policy.error.exception_not_found"), Status = 404 });
}
if (existing.Status != ExceptionStatus.Proposed)
{
return Results.BadRequest(new ProblemDetails
{
Title = "Invalid state transition",
Status = 400,
Detail = "Only proposed exceptions can be approved"
});
}
var actorId = GetActorId(context);
var clientInfo = GetClientInfo(context);
// Approver cannot be requester
if (actorId == existing.RequesterId)
{
return Results.BadRequest(new ProblemDetails
{
Title = "Self-approval not allowed",
Status = 400,
Detail = "Requester cannot approve their own exception"
});
}
var updated = existing with
{
Version = existing.Version + 1,
Status = ExceptionStatus.Approved,
UpdatedAt = timeProvider.GetUtcNow(),
ApprovedAt = timeProvider.GetUtcNow(),
ApproverIds = existing.ApproverIds.Add(actorId)
};
var result = await repository.UpdateAsync(
updated, ExceptionEventType.Approved, actorId, request?.Comment ?? "Exception approved", clientInfo, cancellationToken);
return Results.Ok(ToDto(result));
})
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyOperate))
.WithName("ApproveException")
.WithDescription("Approve a proposed exception and transition it to the approved state. Enforces separation-of-duty by rejecting self-approval (approver must differ from the requester). Multiple approvers may be recorded before the exception is activated.");
// POST /api/policy/exceptions/{id}/activate - Activate approved exception
exceptions.MapPost("/{id}/activate", async Task<IResult>(
string id,
HttpContext context,
IExceptionRepository repository,
[FromServices] TimeProvider timeProvider,
CancellationToken cancellationToken) =>
{
var existing = await repository.GetByIdAsync(id, cancellationToken);
if (existing is null)
{
return Results.NotFound(new ProblemDetails { Title = _t("policy.error.exception_not_found"), Status = 404 });
}
if (existing.Status != ExceptionStatus.Approved)
{
return Results.BadRequest(new ProblemDetails
{
Title = "Invalid state transition",
Status = 400,
Detail = "Only approved exceptions can be activated"
});
}
var actorId = GetActorId(context);
var clientInfo = GetClientInfo(context);
var updated = existing with
{
Version = existing.Version + 1,
Status = ExceptionStatus.Active,
UpdatedAt = timeProvider.GetUtcNow()
};
var result = await repository.UpdateAsync(
updated, ExceptionEventType.Activated, actorId, "Exception activated", clientInfo, cancellationToken);
return Results.Ok(ToDto(result));
})
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyOperate))
.WithName("ActivateException")
.WithDescription("Transition an approved exception to the active state, making it eligible for use in policy evaluation. Only exceptions in the approved state may be activated.");
// POST /api/policy/exceptions/{id}/extend - Extend expiry
exceptions.MapPost("/{id}/extend", async Task<IResult>(
string id,
ExtendExceptionRequest request,
HttpContext context,
IExceptionRepository repository,
[FromServices] TimeProvider timeProvider,
CancellationToken cancellationToken) =>
{
var existing = await repository.GetByIdAsync(id, cancellationToken);
if (existing is null)
{
return Results.NotFound(new ProblemDetails { Title = _t("policy.error.exception_not_found"), Status = 404 });
}
if (existing.Status != ExceptionStatus.Active)
{
return Results.BadRequest(new ProblemDetails
{
Title = "Invalid state",
Status = 400,
Detail = "Only active exceptions can be extended"
});
}
if (request.NewExpiresAt <= existing.ExpiresAt)
{
return Results.BadRequest(new ProblemDetails
{
Title = "Invalid expiry",
Status = 400,
Detail = "New expiry must be after current expiry"
});
}
var actorId = GetActorId(context);
var clientInfo = GetClientInfo(context);
var updated = existing with
{
Version = existing.Version + 1,
UpdatedAt = timeProvider.GetUtcNow(),
ExpiresAt = request.NewExpiresAt
};
var result = await repository.UpdateAsync(
updated, ExceptionEventType.Extended, actorId, request.Reason, clientInfo, cancellationToken);
return Results.Ok(ToDto(result));
})
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyOperate))
.WithName("ExtendException")
.WithDescription("Extend the expiry date of an active exception. The new expiry must be later than the current expiry. Used when a scheduled fix or mitigation requires additional time beyond the original exception window.");
// DELETE /api/policy/exceptions/{id} - Revoke exception
exceptions.MapDelete("/{id}", async Task<IResult>(
string id,
[FromBody] RevokeExceptionRequest? request,
HttpContext context,
IExceptionRepository repository,
[FromServices] TimeProvider timeProvider,
CancellationToken cancellationToken) =>
{
var existing = await repository.GetByIdAsync(id, cancellationToken);
if (existing is null)
{
return Results.NotFound(new ProblemDetails { Title = _t("policy.error.exception_not_found"), Status = 404 });
}
if (existing.Status is ExceptionStatus.Expired or ExceptionStatus.Revoked)
{
return Results.BadRequest(new ProblemDetails
{
Title = "Invalid state",
Status = 400,
Detail = "Exception is already expired or revoked"
});
}
var actorId = GetActorId(context);
var clientInfo = GetClientInfo(context);
var updated = existing with
{
Version = existing.Version + 1,
Status = ExceptionStatus.Revoked,
UpdatedAt = timeProvider.GetUtcNow()
};
var result = await repository.UpdateAsync(
updated, ExceptionEventType.Revoked, actorId, request?.Reason ?? "Exception revoked", clientInfo, cancellationToken);
return Results.Ok(ToDto(result));
})
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyOperate))
.WithName("RevokeException")
.WithDescription("Revoke an exception before its natural expiry, recording an optional revocation reason and transitioning the exception to the revoked terminal state. Cannot revoke exceptions that are already expired or revoked.");
// GET /api/policy/exceptions/expiring - Get exceptions expiring soon
exceptions.MapGet("/expiring", async Task<IResult>(
[FromQuery] int? days,
IExceptionRepository repository,
CancellationToken cancellationToken) =>
{
var horizon = TimeSpan.FromDays(days ?? 7);
var results = await repository.GetExpiringAsync(horizon, cancellationToken);
return Results.Ok(results.Select(ToDto).ToList());
})
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead))
.WithName("GetExpiringExceptions")
.WithDescription("List active exceptions that will expire within the specified number of days (default 7). Used by notification and alerting workflows to proactively alert owners before exceptions lapse and cause unexpected policy failures.");
}
#region Helpers
private static string GetActorId(HttpContext context)
{
return context.User.FindFirstValue(ClaimTypes.NameIdentifier)
?? context.User.FindFirstValue("sub")
?? "anonymous";
}
private static string? GetClientInfo(HttpContext context)
{
var ip = context.Connection.RemoteIpAddress?.ToString();
var userAgent = context.Request.Headers.UserAgent.FirstOrDefault();
return string.IsNullOrEmpty(ip) ? null : $"{ip}; {userAgent}";
}
private static ExceptionResponse ToDto(ExceptionObject ex) => new()
{
ExceptionId = ex.ExceptionId,
Version = ex.Version,
Status = ex.Status.ToString().ToLowerInvariant(),
Type = ex.Type.ToString().ToLowerInvariant(),
Scope = new ExceptionScopeDto
{
ArtifactDigest = ex.Scope.ArtifactDigest,
PurlPattern = ex.Scope.PurlPattern,
VulnerabilityId = ex.Scope.VulnerabilityId,
PolicyRuleId = ex.Scope.PolicyRuleId,
Environments = ex.Scope.Environments.ToList()
},
OwnerId = ex.OwnerId,
RequesterId = ex.RequesterId,
ApproverIds = ex.ApproverIds.ToList(),
CreatedAt = ex.CreatedAt,
UpdatedAt = ex.UpdatedAt,
ApprovedAt = ex.ApprovedAt,
ExpiresAt = ex.ExpiresAt,
ReasonCode = ex.ReasonCode.ToString().ToLowerInvariant(),
Rationale = ex.Rationale,
EvidenceRefs = ex.EvidenceRefs.ToList(),
CompensatingControls = ex.CompensatingControls.ToList(),
TicketRef = ex.TicketRef,
Metadata = ex.Metadata.ToDictionary(kvp => kvp.Key, kvp => kvp.Value)
};
private static ExceptionStatus? ParseStatus(string? status)
{
if (string.IsNullOrEmpty(status)) return null;
return status.ToLowerInvariant() switch
{
"proposed" => ExceptionStatus.Proposed,
"approved" => ExceptionStatus.Approved,
"active" => ExceptionStatus.Active,
"expired" => ExceptionStatus.Expired,
"revoked" => ExceptionStatus.Revoked,
_ => null
};
}
private static ExceptionType? ParseType(string? type)
{
if (string.IsNullOrEmpty(type)) return null;
return type.ToLowerInvariant() switch
{
"vulnerability" => ExceptionType.Vulnerability,
"policy" => ExceptionType.Policy,
"unknown" => ExceptionType.Unknown,
"component" => ExceptionType.Component,
_ => null
};
}
private static ExceptionType ParseTypeRequired(string type)
{
return type.ToLowerInvariant() switch
{
"vulnerability" => ExceptionType.Vulnerability,
"policy" => ExceptionType.Policy,
"unknown" => ExceptionType.Unknown,
"component" => ExceptionType.Component,
_ => throw new ArgumentException($"Invalid exception type: {type}")
};
}
private static ExceptionReason ParseReasonRequired(string reason)
{
return reason.ToLowerInvariant() switch
{
"false_positive" or "falsepositive" => ExceptionReason.FalsePositive,
"accepted_risk" or "acceptedrisk" => ExceptionReason.AcceptedRisk,
"compensating_control" or "compensatingcontrol" => ExceptionReason.CompensatingControl,
"test_only" or "testonly" => ExceptionReason.TestOnly,
"vendor_not_affected" or "vendornotaffected" => ExceptionReason.VendorNotAffected,
"scheduled_fix" or "scheduledfix" => ExceptionReason.ScheduledFix,
"deprecation_in_progress" or "deprecationinprogress" => ExceptionReason.DeprecationInProgress,
"runtime_mitigation" or "runtimemitigation" => ExceptionReason.RuntimeMitigation,
"network_isolation" or "networkisolation" => ExceptionReason.NetworkIsolation,
"other" => ExceptionReason.Other,
_ => throw new ArgumentException($"Invalid reason code: {reason}")
};
}
#endregion
}

View File

@@ -0,0 +1,403 @@
// SPDX-License-Identifier: BUSL-1.1
// Sprint: SPRINT_20251226_001_BE_cicd_gate_integration
// Task: CICD-GATE-01 - Create POST /api/v1/policy/gate/evaluate endpoint
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Caching.Memory;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Auth.ServerIntegration.Tenancy;
using StellaOps.Policy.Audit;
using StellaOps.Policy.Deltas;
using StellaOps.Policy.Engine.Gates;
using StellaOps.Policy.Engine.Services;
using StellaOps.Policy.Engine.Contracts.Gateway;
namespace StellaOps.Policy.Engine.Endpoints.Gateway;
/// <summary>
/// Gate API endpoints for CI/CD release gating.
/// </summary>
public static class GateEndpoints
{
private const string DeltaCachePrefix = "delta:";
private static readonly TimeSpan DeltaCacheDuration = TimeSpan.FromMinutes(30);
/// <summary>
/// Maps gate endpoints to the application.
/// </summary>
public static void MapGateEndpoints(this WebApplication app)
{
var gates = app.MapGroup("/api/v1/policy/gate")
.WithTags("Gates")
.RequireTenant();
// POST /api/v1/policy/gate/evaluate - Evaluate gate for image
gates.MapPost("/evaluate", async Task<IResult>(
HttpContext httpContext,
GateEvaluateRequest request,
IDriftGateEvaluator gateEvaluator,
IDeltaComputer deltaComputer,
IBaselineSelector baselineSelector,
IGateBypassAuditor bypassAuditor,
IMemoryCache cache,
[FromServices] TimeProvider timeProvider,
ILogger<DriftGateEvaluator> logger,
CancellationToken cancellationToken) =>
{
if (request is null)
{
return Results.BadRequest(new ProblemDetails
{
Title = "Request body required",
Status = 400
});
}
if (string.IsNullOrWhiteSpace(request.ImageDigest))
{
return Results.BadRequest(new ProblemDetails
{
Title = "Image digest is required",
Status = 400,
Detail = "Provide a valid container image digest (e.g., sha256:abc123...)"
});
}
try
{
// Step 1: Resolve baseline snapshot
var baselineResult = await ResolveBaselineAsync(
request.ImageDigest,
request.BaselineRef,
baselineSelector,
cancellationToken);
if (!baselineResult.IsFound)
{
// If no baseline, allow with a note (first build scenario)
logger.LogInformation(
"No baseline found for {ImageDigest}, allowing first build",
request.ImageDigest);
return Results.Ok(new GateEvaluateResponse
{
DecisionId = $"gate:{timeProvider.GetUtcNow():yyyyMMddHHmmss}:{Guid.NewGuid():N}",
Status = GateStatus.Pass,
ExitCode = GateExitCodes.Pass,
ImageDigest = request.ImageDigest,
BaselineRef = request.BaselineRef,
DecidedAt = timeProvider.GetUtcNow(),
Summary = "First build - no baseline for comparison",
Advisory = "This appears to be a first build. Future builds will be compared against this baseline."
});
}
// Step 2: Compute delta between baseline and current
var delta = await deltaComputer.ComputeDeltaAsync(
baselineResult.Snapshot!.SnapshotId,
request.ImageDigest, // Use image digest as target snapshot ID
new ArtifactRef(request.ImageDigest, null, null),
cancellationToken);
// Cache the delta for audit
cache.Set(
DeltaCachePrefix + delta.DeltaId,
delta,
DeltaCacheDuration);
// Step 3: Build gate context from delta
var gateContext = BuildGateContext(delta);
// Step 4: Evaluate gates
var gateRequest = new DriftGateRequest
{
Context = gateContext,
PolicyId = request.PolicyId,
AllowOverride = request.AllowOverride,
OverrideJustification = request.OverrideJustification
};
var gateDecision = await gateEvaluator.EvaluateAsync(gateRequest, cancellationToken);
logger.LogInformation(
"Gate evaluated for {ImageDigest}: decision={Decision}, decisionId={DecisionId}",
request.ImageDigest,
gateDecision.Decision,
gateDecision.DecisionId);
// Step 5: Record bypass audit if override was applied
if (request.AllowOverride &&
!string.IsNullOrWhiteSpace(request.OverrideJustification) &&
gateDecision.Decision != DriftGateDecisionType.Allow)
{
var actor = httpContext.User.Identity?.Name ?? "unknown";
var actorSubject = httpContext.User.Claims
.FirstOrDefault(c => c.Type == "sub")?.Value;
var actorEmail = httpContext.User.Claims
.FirstOrDefault(c => c.Type == "email")?.Value;
var actorIp = httpContext.Connection.RemoteIpAddress?.ToString();
var bypassContext = new GateBypassContext
{
Decision = gateDecision,
Request = gateRequest,
ImageDigest = request.ImageDigest,
Repository = request.Repository,
Tag = request.Tag,
BaselineRef = request.BaselineRef,
Actor = actor,
ActorSubject = actorSubject,
ActorEmail = actorEmail,
ActorIpAddress = actorIp,
Justification = request.OverrideJustification,
Source = request.Source ?? "api",
CiContext = request.CiContext
};
await bypassAuditor.RecordBypassAsync(bypassContext, cancellationToken);
}
// Step 6: Build response
var response = BuildResponse(request, gateDecision, delta);
// Return appropriate status code based on decision
return gateDecision.Decision switch
{
DriftGateDecisionType.Block => Results.Json(response, statusCode: 403),
DriftGateDecisionType.Warn => Results.Ok(response),
_ => Results.Ok(response)
};
}
catch (InvalidOperationException ex) when (ex.Message.Contains("not found"))
{
return Results.NotFound(new ProblemDetails
{
Title = "Resource not found",
Status = 404,
Detail = ex.Message
});
}
catch (Exception ex)
{
logger.LogError(ex, "Gate evaluation failed for {ImageDigest}", request.ImageDigest);
return Results.Problem(new ProblemDetails
{
Title = "Gate evaluation failed",
Status = 500,
Detail = "An error occurred during gate evaluation"
});
}
})
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRun))
.WithName("EvaluateGate")
.WithDescription("Evaluate the CI/CD release gate for a container image by comparing it against a baseline snapshot. Resolves the baseline using a configurable strategy (last-approved, previous-build, production-deployed, or branch-base), computes the security state delta, runs gate rules against the delta context, and returns a pass/warn/block decision with exit codes. If an override justification is supplied on a non-blocking verdict, a bypass audit record is created. Returns HTTP 403 when the gate blocks the release.");
// GET /api/v1/policy/gate/decision/{decisionId} - Get a previous decision
gates.MapGet("/decision/{decisionId}", async Task<IResult>(
string decisionId,
IMemoryCache cache,
CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(decisionId))
{
return Results.BadRequest(new ProblemDetails
{
Title = "Decision ID required",
Status = 400
});
}
// Try to retrieve cached decision
var cacheKey = $"gate:decision:{decisionId}";
if (!cache.TryGetValue(cacheKey, out GateEvaluateResponse? response) || response is null)
{
return Results.NotFound(new ProblemDetails
{
Title = "Decision not found",
Status = 404,
Detail = $"No gate decision found with ID: {decisionId}"
});
}
return Results.Ok(response);
})
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead))
.WithName("GetGateDecision")
.WithDescription("Retrieve a previously cached gate evaluation decision by its decision ID. Gate decisions are retained in memory for 30 minutes after evaluation, after which this endpoint returns HTTP 404. Used by CI/CD pipelines to poll for results when the evaluation was triggered asynchronously via a registry webhook.");
// GET /api/v1/policy/gate/health - Health check for gate service
gates.MapGet("/health", ([FromServices] TimeProvider timeProvider) =>
Results.Ok(new { status = "healthy", timestamp = timeProvider.GetUtcNow() }))
.WithName("GateHealth")
.WithDescription("Health check for the gate evaluation service")
.AllowAnonymous();
}
private static async Task<BaselineSelectionResult> ResolveBaselineAsync(
string imageDigest,
string? baselineRef,
IBaselineSelector baselineSelector,
CancellationToken cancellationToken)
{
if (!string.IsNullOrWhiteSpace(baselineRef))
{
// Check if it's an explicit snapshot ID
if (baselineRef.StartsWith("snapshot:") || Guid.TryParse(baselineRef, out _))
{
return await baselineSelector.SelectExplicitAsync(
baselineRef.Replace("snapshot:", ""),
cancellationToken);
}
// Parse as strategy name
var strategy = baselineRef.ToLowerInvariant() switch
{
"last-approved" or "lastapproved" => BaselineSelectionStrategy.LastApproved,
"previous-build" or "previousbuild" => BaselineSelectionStrategy.PreviousBuild,
"production" or "production-deployed" => BaselineSelectionStrategy.ProductionDeployed,
"branch-base" or "branchbase" => BaselineSelectionStrategy.BranchBase,
_ => BaselineSelectionStrategy.LastApproved
};
return await baselineSelector.SelectBaselineAsync(imageDigest, strategy, cancellationToken);
}
// Default to LastApproved strategy
return await baselineSelector.SelectBaselineAsync(
imageDigest,
BaselineSelectionStrategy.LastApproved,
cancellationToken);
}
private static DriftGateContext BuildGateContext(SecurityStateDelta delta)
{
var newlyReachableVexStatuses = new List<string>();
var newlyReachableSinkIds = new List<string>();
var newlyUnreachableSinkIds = new List<string>();
double? maxCvss = null;
double? maxEpss = null;
var hasKev = false;
var deltaReachable = 0;
var deltaUnreachable = 0;
// Extract metrics from delta drivers
foreach (var driver in delta.Drivers)
{
if (driver.Type is "new-reachable-cve" or "new-reachable-path")
{
deltaReachable++;
if (driver.CveId is not null)
{
newlyReachableSinkIds.Add(driver.CveId);
}
// Extract optional details from the Details dictionary
if (driver.Details.TryGetValue("vex_status", out var vexStatus))
{
newlyReachableVexStatuses.Add(vexStatus);
}
if (driver.Details.TryGetValue("cvss", out var cvssStr) &&
double.TryParse(cvssStr, out var cvss))
{
if (!maxCvss.HasValue || cvss > maxCvss.Value)
{
maxCvss = cvss;
}
}
if (driver.Details.TryGetValue("epss", out var epssStr) &&
double.TryParse(epssStr, out var epss))
{
if (!maxEpss.HasValue || epss > maxEpss.Value)
{
maxEpss = epss;
}
}
if (driver.Details.TryGetValue("is_kev", out var kevStr) &&
bool.TryParse(kevStr, out var isKev) && isKev)
{
hasKev = true;
}
}
else if (driver.Type is "removed-reachable-cve" or "removed-reachable-path")
{
deltaUnreachable++;
if (driver.CveId is not null)
{
newlyUnreachableSinkIds.Add(driver.CveId);
}
}
}
return new DriftGateContext
{
DeltaReachable = deltaReachable,
DeltaUnreachable = deltaUnreachable,
HasKevReachable = hasKev,
NewlyReachableVexStatuses = newlyReachableVexStatuses,
MaxCvss = maxCvss,
MaxEpss = maxEpss,
BaseScanId = delta.BaselineSnapshotId,
HeadScanId = delta.TargetSnapshotId,
NewlyReachableSinkIds = newlyReachableSinkIds,
NewlyUnreachableSinkIds = newlyUnreachableSinkIds
};
}
private static GateEvaluateResponse BuildResponse(
GateEvaluateRequest request,
DriftGateDecision decision,
SecurityStateDelta delta)
{
var status = decision.Decision switch
{
DriftGateDecisionType.Allow => GateStatus.Pass,
DriftGateDecisionType.Warn => GateStatus.Warn,
DriftGateDecisionType.Block => GateStatus.Fail,
_ => GateStatus.Pass
};
var exitCode = decision.Decision switch
{
DriftGateDecisionType.Allow => GateExitCodes.Pass,
DriftGateDecisionType.Warn => GateExitCodes.Warn,
DriftGateDecisionType.Block => GateExitCodes.Fail,
_ => GateExitCodes.Pass
};
return new GateEvaluateResponse
{
DecisionId = decision.DecisionId,
Status = status,
ExitCode = exitCode,
ImageDigest = request.ImageDigest,
BaselineRef = request.BaselineRef,
DecidedAt = decision.DecidedAt,
Summary = BuildSummary(decision),
Advisory = decision.Advisory,
Gates = decision.Gates.Select(g => new GateResultDto
{
Name = g.Name,
Result = g.Result.ToString(),
Reason = g.Reason,
Note = g.Note,
Condition = g.Condition
}).ToList(),
BlockedBy = decision.BlockedBy,
BlockReason = decision.BlockReason,
Suggestion = decision.Suggestion,
OverrideApplied = request.AllowOverride && decision.Decision == DriftGateDecisionType.Warn && !string.IsNullOrWhiteSpace(request.OverrideJustification),
DeltaSummary = DeltaSummaryDto.FromModel(delta.Summary)
};
}
private static string BuildSummary(DriftGateDecision decision)
{
return decision.Decision switch
{
DriftGateDecisionType.Allow => "Gate passed - release may proceed",
DriftGateDecisionType.Warn => $"Gate passed with warnings - review recommended{(decision.Advisory is not null ? $": {decision.Advisory}" : "")}",
DriftGateDecisionType.Block => $"Gate blocked - {decision.BlockReason ?? "release cannot proceed"}",
_ => "Gate evaluation complete"
};
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,635 @@
using System.Collections.Concurrent;
using Microsoft.AspNetCore.Mvc;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Auth.ServerIntegration.Tenancy;
namespace StellaOps.Policy.Engine.Endpoints.Gateway;
public static class GovernanceCompatibilityEndpoints
{
private static readonly ConcurrentDictionary<string, TrustWeightConfigState> TrustWeightStates = new(StringComparer.OrdinalIgnoreCase);
private static readonly ConcurrentDictionary<string, StalenessConfigState> StalenessStates = new(StringComparer.OrdinalIgnoreCase);
private static readonly ConcurrentDictionary<string, PolicyConflictState> ConflictStates = new(StringComparer.OrdinalIgnoreCase);
public static void MapGovernanceCompatibilityEndpoints(this WebApplication app)
{
var governance = app.MapGroup("/api/v1/governance")
.WithTags("Governance Compatibility")
.RequireTenant();
governance.MapGet("/trust-weights", (
HttpContext context,
[FromQuery] string? projectId,
TimeProvider timeProvider) =>
{
var scope = ResolveScope(context, projectId);
var state = TrustWeightStates.GetOrAdd(scope.Key, _ => CreateDefaultTrustWeightState(scope.TenantId, scope.ProjectId, timeProvider));
return Results.Ok(state.ToResponse());
}).RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead));
governance.MapPut("/trust-weights/{weightId}", (
HttpContext context,
string weightId,
[FromBody] TrustWeightWriteModel request,
[FromQuery] string? projectId,
TimeProvider timeProvider) =>
{
var scope = ResolveScope(context, projectId);
var state = TrustWeightStates.GetOrAdd(scope.Key, _ => CreateDefaultTrustWeightState(scope.TenantId, scope.ProjectId, timeProvider));
var updated = state.Upsert(weightId, request, timeProvider, StellaOpsTenantResolver.ResolveActor(context));
return Results.Ok(updated);
}).RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyAuthor));
governance.MapDelete("/trust-weights/{weightId}", (
HttpContext context,
string weightId,
[FromQuery] string? projectId,
TimeProvider timeProvider) =>
{
var scope = ResolveScope(context, projectId);
var state = TrustWeightStates.GetOrAdd(scope.Key, _ => CreateDefaultTrustWeightState(scope.TenantId, scope.ProjectId, timeProvider));
state.Delete(weightId, timeProvider);
return Results.NoContent();
}).RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyAuthor));
governance.MapPost("/trust-weights/preview-impact", (
[FromBody] TrustWeightPreviewRequest request) =>
{
var weights = request.Weights ?? [];
var severityChanges = weights.Count(static weight => weight.Weight >= 1.2m);
var decisionChanges = weights.Count(static weight => weight.Active != false);
var payload = new
{
affectedVulnerabilities = Math.Max(3, weights.Count * 9),
severityChanges,
decisionChanges,
sampleAffected = weights.Take(3).Select((weight, index) => new
{
findingId = $"finding-{index + 1:000}",
componentPurl = $"pkg:oci/{weight.IssuerId ?? "stellaops"}/runtime-{index + 1}@sha256:{(index + 1).ToString("D4")}",
advisoryId = $"CVE-2026-{1400 + index}",
currentSeverity = index == 0 ? "high" : "medium",
projectedSeverity = weight.Weight >= 1.2m ? "critical" : "high",
currentDecision = "warn",
projectedDecision = weight.Active != false ? "deny" : "warn"
}).ToArray(),
severityTransitions = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase)
{
["medium->high"] = Math.Max(1, severityChanges),
["high->critical"] = Math.Max(1, severityChanges / 2)
}
};
return Results.Ok(payload);
}).RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyAuthor));
governance.MapGet("/staleness/config", (
HttpContext context,
[FromQuery] string? projectId,
TimeProvider timeProvider) =>
{
var scope = ResolveScope(context, projectId);
var state = StalenessStates.GetOrAdd(scope.Key, _ => CreateDefaultStalenessState(scope.TenantId, scope.ProjectId, timeProvider));
return Results.Ok(state.ToResponse());
}).RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead));
governance.MapPut("/staleness/config/{dataType}", (
HttpContext context,
string dataType,
[FromBody] StalenessConfigWriteModel request,
[FromQuery] string? projectId,
TimeProvider timeProvider) =>
{
var scope = ResolveScope(context, projectId);
var state = StalenessStates.GetOrAdd(scope.Key, _ => CreateDefaultStalenessState(scope.TenantId, scope.ProjectId, timeProvider));
var updated = state.Upsert(dataType, request, timeProvider);
return Results.Ok(updated);
}).RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyAuthor));
governance.MapGet("/staleness/status", (
HttpContext context,
[FromQuery] string? projectId,
TimeProvider timeProvider) =>
{
var scope = ResolveScope(context, projectId);
var state = StalenessStates.GetOrAdd(scope.Key, _ => CreateDefaultStalenessState(scope.TenantId, scope.ProjectId, timeProvider));
return Results.Ok(state.BuildStatus());
}).RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead));
governance.MapGet("/conflicts/dashboard", (
HttpContext context,
[FromQuery] string? projectId,
TimeProvider timeProvider) =>
{
var scope = ResolveScope(context, projectId);
var state = ConflictStates.GetOrAdd(scope.Key, _ => CreateDefaultConflictState(scope.TenantId, scope.ProjectId, timeProvider));
return Results.Ok(state.ToDashboard());
}).RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead));
governance.MapGet("/conflicts", (
HttpContext context,
[FromQuery] string? projectId,
[FromQuery] string? type,
[FromQuery] string? severity,
TimeProvider timeProvider) =>
{
var scope = ResolveScope(context, projectId);
var state = ConflictStates.GetOrAdd(scope.Key, _ => CreateDefaultConflictState(scope.TenantId, scope.ProjectId, timeProvider));
return Results.Ok(state.List(type, severity));
}).RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead));
governance.MapPost("/conflicts/{conflictId}/resolve", (
HttpContext context,
string conflictId,
[FromBody] ConflictResolutionWriteModel request,
[FromQuery] string? projectId,
TimeProvider timeProvider) =>
{
var scope = ResolveScope(context, projectId);
var state = ConflictStates.GetOrAdd(scope.Key, _ => CreateDefaultConflictState(scope.TenantId, scope.ProjectId, timeProvider));
var actor = StellaOpsTenantResolver.ResolveActor(context);
var updated = state.Resolve(conflictId, request.Resolution, actor, timeProvider);
return updated is null ? Results.NotFound() : Results.Ok(updated);
}).RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyAuthor));
governance.MapPost("/conflicts/{conflictId}/ignore", (
HttpContext context,
string conflictId,
[FromBody] ConflictIgnoreWriteModel request,
[FromQuery] string? projectId,
TimeProvider timeProvider) =>
{
var scope = ResolveScope(context, projectId);
var state = ConflictStates.GetOrAdd(scope.Key, _ => CreateDefaultConflictState(scope.TenantId, scope.ProjectId, timeProvider));
var actor = StellaOpsTenantResolver.ResolveActor(context);
var updated = state.Ignore(conflictId, request.Reason, actor, timeProvider);
return updated is null ? Results.NotFound() : Results.Ok(updated);
}).RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyAuthor));
}
private static GovernanceScope ResolveScope(HttpContext context, string? projectId)
{
if (!StellaOpsTenantResolver.TryResolveTenantId(context, out var tenantId, out var error))
{
throw new InvalidOperationException($"Tenant resolution failed: {error ?? "tenant_missing"}");
}
var scopedProject = string.IsNullOrWhiteSpace(projectId)
? StellaOpsTenantResolver.ResolveProject(context)
: projectId.Trim();
return new GovernanceScope(
tenantId,
string.IsNullOrWhiteSpace(scopedProject) ? null : scopedProject,
string.IsNullOrWhiteSpace(scopedProject) ? tenantId : $"{tenantId}:{scopedProject}");
}
private static TrustWeightConfigState CreateDefaultTrustWeightState(string tenantId, string? projectId, TimeProvider timeProvider)
{
var now = timeProvider.GetUtcNow().ToString("O");
return new TrustWeightConfigState(
tenantId,
projectId,
now,
"\"trust-weights-v1\"",
[
new TrustWeightRecord("tw-001", "cisa", "CISA", "cisa", 1.50m, 1, true, "Government authoritative source", now, "system"),
new TrustWeightRecord("tw-002", "nist", "NIST NVD", "nist", 1.30m, 2, true, "Primary CVE source", now, "system"),
new TrustWeightRecord("tw-003", "vendor-redhat", "Red Hat", "vendor", 1.20m, 3, true, "Trusted vendor feed", now, "system")
]);
}
private static StalenessConfigState CreateDefaultStalenessState(string tenantId, string? projectId, TimeProvider timeProvider)
{
var now = timeProvider.GetUtcNow().ToString("O");
return new StalenessConfigState(
tenantId,
projectId,
now,
"\"staleness-v1\"",
[
new StalenessConfigRecord("sbom", BuildThresholds(7, 14, 30, 45), true, 12),
new StalenessConfigRecord("vulnerability_data", BuildThresholds(1, 3, 7, 14), true, 6),
new StalenessConfigRecord("vex_statements", BuildThresholds(3, 7, 14, 21), true, 12),
new StalenessConfigRecord("policy", BuildThresholds(14, 30, 45, 60), false, 24),
new StalenessConfigRecord("attestation", BuildThresholds(7, 14, 21, 30), true, 8),
new StalenessConfigRecord("scan_result", BuildThresholds(1, 2, 5, 10), true, 4)
]);
}
private static PolicyConflictState CreateDefaultConflictState(string tenantId, string? projectId, TimeProvider timeProvider)
{
var now = timeProvider.GetUtcNow();
return new PolicyConflictState(
tenantId,
projectId,
now.ToString("O"),
new List<PolicyConflictRecord>
{
new(
"conflict-001",
"rule_overlap",
"warning",
"Overlapping severity rules in profiles",
"The strict and default profiles both escalate exploitable high CVSS findings, creating ambiguous severity outcomes for shared scopes.",
new PolicyConflictSourceRecord("profile-default", "profile", "Default Risk Profile", "1.0.0", "severityOverrides[0]"),
new PolicyConflictSourceRecord("profile-strict", "profile", "Strict Security Profile", "1.1.0", "severityOverrides[0]"),
new[] { "production", "staging" },
"High reachability findings may oscillate between warn and block decisions across identical releases.",
"Consolidate the overlapping rules or assign a strict precedence order.",
now.AddHours(-8).ToString("O"),
"open",
null,
null,
null),
new(
"conflict-002",
"precedence_ambiguity",
"info",
"Ambiguous rule precedence",
"Two release gate rules with the same priority can evaluate the same evidence set in a non-deterministic order.",
new PolicyConflictSourceRecord("gate-cvss-high", "rule", "CVSS High Escalation", null, "rules[2]"),
new PolicyConflictSourceRecord("gate-exploit-available", "rule", "Exploit Available Escalation", null, "rules[5]"),
new[] { "all" },
"Operators may see inconsistent explain traces between runs with identical inputs.",
"Assign distinct priorities so replay and live evaluation remain identical.",
now.AddDays(-1).ToString("O"),
"acknowledged",
now.AddHours(-4).ToString("O"),
"policy-reviewer",
"Captured during route-action scratch verification.")
});
}
private static List<StalenessThresholdRecord> BuildThresholds(int fresh, int aging, int stale, int expired) =>
[
new("fresh", fresh, "low", [new StalenessActionRecord("warn", "Still within freshness SLA.")]),
new("aging", aging, "medium", [new StalenessActionRecord("notify", "Approaching review window.")]),
new("stale", stale, "high", [new StalenessActionRecord("flag_review", "Operator review required.")]),
new("expired", expired, "critical", [new StalenessActionRecord("block", "Fresh data required before continue.")])
];
private sealed record GovernanceScope(string TenantId, string? ProjectId, string Key);
private sealed class TrustWeightConfigState(
string tenantId,
string? projectId,
string modifiedAt,
string etag,
List<TrustWeightRecord> weights)
{
public string TenantId { get; private set; } = tenantId;
public string? ProjectId { get; private set; } = projectId;
public string ModifiedAt { get; private set; } = modifiedAt;
public string Etag { get; private set; } = etag;
public List<TrustWeightRecord> Weights { get; } = weights;
public object ToResponse() => new
{
tenantId = TenantId,
projectId = ProjectId,
weights = Weights.OrderBy(static weight => weight.Priority).ThenBy(static weight => weight.IssuerName, StringComparer.OrdinalIgnoreCase),
defaultWeight = 1.0m,
modifiedAt = ModifiedAt,
etag = Etag
};
public object Upsert(string routeWeightId, TrustWeightWriteModel request, TimeProvider timeProvider, string actor)
{
var effectiveId = string.IsNullOrWhiteSpace(request.Id) ? routeWeightId : request.Id.Trim();
var index = Weights.FindIndex(weight => string.Equals(weight.Id, effectiveId, StringComparison.OrdinalIgnoreCase));
var existing = index >= 0 ? Weights[index] : null;
var now = timeProvider.GetUtcNow().ToString("O");
var updated = new TrustWeightRecord(
effectiveId,
request.IssuerId?.Trim() ?? existing?.IssuerId ?? effectiveId,
request.IssuerName?.Trim() ?? existing?.IssuerName ?? effectiveId,
NormalizeSource(request.Source ?? existing?.Source),
request.Weight ?? existing?.Weight ?? 1.0m,
request.Priority ?? existing?.Priority ?? Weights.Count + 1,
request.Active ?? existing?.Active ?? true,
request.Reason?.Trim() ?? existing?.Reason,
now,
actor);
if (index >= 0)
{
Weights[index] = updated;
}
else
{
Weights.Add(updated);
}
ModifiedAt = now;
Etag = $"\"trust-weights-{Weights.Count}-{now}\"";
return updated;
}
public void Delete(string weightId, TimeProvider timeProvider)
{
Weights.RemoveAll(weight => string.Equals(weight.Id, weightId, StringComparison.OrdinalIgnoreCase));
ModifiedAt = timeProvider.GetUtcNow().ToString("O");
Etag = $"\"trust-weights-{Weights.Count}-{ModifiedAt}\"";
}
}
private sealed class StalenessConfigState(
string tenantId,
string? projectId,
string modifiedAt,
string etag,
List<StalenessConfigRecord> configs)
{
public string TenantId { get; private set; } = tenantId;
public string? ProjectId { get; private set; } = projectId;
public string ModifiedAt { get; private set; } = modifiedAt;
public string Etag { get; private set; } = etag;
public List<StalenessConfigRecord> Configs { get; } = configs;
public object ToResponse() => new
{
tenantId = TenantId,
projectId = ProjectId,
configs = Configs.OrderBy(static config => config.DataType, StringComparer.OrdinalIgnoreCase),
modifiedAt = ModifiedAt,
etag = Etag
};
public object Upsert(string dataType, StalenessConfigWriteModel request, TimeProvider timeProvider)
{
var effectiveType = string.IsNullOrWhiteSpace(dataType) ? request.DataType?.Trim() ?? "sbom" : dataType.Trim();
var thresholds = request.Thresholds?.Count > 0
? request.Thresholds.Select(threshold => new StalenessThresholdRecord(
NormalizeLevel(threshold.Level),
threshold.AgeDays,
NormalizeSeverity(threshold.Severity),
threshold.Actions?.Select(action => new StalenessActionRecord(NormalizeActionType(action.Type), action.Message, action.Channels)).ToList() ?? [])).ToList()
: BuildThresholds(7, 14, 30, 45);
var updated = new StalenessConfigRecord(
effectiveType,
thresholds,
request.Enabled ?? true,
request.GracePeriodHours ?? 12);
var index = Configs.FindIndex(config => string.Equals(config.DataType, effectiveType, StringComparison.OrdinalIgnoreCase));
if (index >= 0)
{
Configs[index] = updated;
}
else
{
Configs.Add(updated);
}
ModifiedAt = timeProvider.GetUtcNow().ToString("O");
Etag = $"\"staleness-{Configs.Count}-{ModifiedAt}\"";
return updated;
}
public object[] BuildStatus() =>
Configs.Select((config, index) => new
{
dataType = config.DataType,
itemId = $"{config.DataType}-asset-{index + 1}",
itemName = $"{config.DataType.Replace('_', ' ')} snapshot {index + 1}",
lastUpdatedAt = DateTimeOffset.Parse(ModifiedAt).AddDays(-(index + 1) * 3).ToString("O"),
ageDays = (index + 1) * 3,
level = index == 0 ? "fresh" : index == 1 ? "aging" : index == 2 ? "stale" : "expired",
blocked = index >= 2 && config.Enabled
}).ToArray();
}
private sealed class PolicyConflictState(
string tenantId,
string? projectId,
string lastAnalyzedAt,
List<PolicyConflictRecord> conflicts)
{
public string TenantId { get; private set; } = tenantId;
public string? ProjectId { get; private set; } = projectId;
public string LastAnalyzedAt { get; private set; } = lastAnalyzedAt;
public List<PolicyConflictRecord> Conflicts { get; } = conflicts;
public object ToDashboard()
{
var byType = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase)
{
["rule_overlap"] = 0,
["precedence_ambiguity"] = 0,
["circular_dependency"] = 0,
["incompatible_actions"] = 0,
["scope_collision"] = 0
};
var bySeverity = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase)
{
["info"] = 0,
["warning"] = 0,
["error"] = 0,
["critical"] = 0
};
foreach (var conflict in Conflicts)
{
byType[conflict.Type] = byType.GetValueOrDefault(conflict.Type) + 1;
bySeverity[conflict.Severity] = bySeverity.GetValueOrDefault(conflict.Severity) + 1;
}
var trend = Enumerable.Range(0, 7)
.Select(offset => DateTimeOffset.Parse(LastAnalyzedAt).UtcDateTime.Date.AddDays(offset - 6))
.Select(day => new
{
date = day.ToString("yyyy-MM-dd"),
count = Conflicts.Count(conflict =>
DateTimeOffset.Parse(conflict.DetectedAt).UtcDateTime.Date == day)
})
.ToArray();
return new
{
totalConflicts = Conflicts.Count,
openConflicts = Conflicts.Count(conflict => string.Equals(conflict.Status, "open", StringComparison.OrdinalIgnoreCase)),
byType,
bySeverity,
recentConflicts = Conflicts
.OrderByDescending(conflict => conflict.DetectedAt, StringComparer.Ordinal)
.Take(5)
.ToArray(),
trend,
lastAnalyzedAt = LastAnalyzedAt
};
}
public object[] List(string? type, string? severity)
{
IEnumerable<PolicyConflictRecord> query = Conflicts;
if (!string.IsNullOrWhiteSpace(type))
{
query = query.Where(conflict => string.Equals(conflict.Type, type.Trim(), StringComparison.OrdinalIgnoreCase));
}
if (!string.IsNullOrWhiteSpace(severity))
{
query = query.Where(conflict => string.Equals(conflict.Severity, severity.Trim(), StringComparison.OrdinalIgnoreCase));
}
return query
.OrderByDescending(conflict => conflict.DetectedAt, StringComparer.Ordinal)
.ToArray<object>();
}
public object? Resolve(string conflictId, string? resolution, string actor, TimeProvider timeProvider)
{
var index = Conflicts.FindIndex(conflict => string.Equals(conflict.Id, conflictId, StringComparison.OrdinalIgnoreCase));
if (index < 0)
{
return null;
}
var now = timeProvider.GetUtcNow().ToString("O");
var current = Conflicts[index];
var updated = current with
{
Status = "resolved",
ResolvedAt = now,
ResolvedBy = actor,
ResolutionNotes = string.IsNullOrWhiteSpace(resolution) ? current.SuggestedResolution : resolution.Trim()
};
Conflicts[index] = updated;
LastAnalyzedAt = now;
return updated;
}
public object? Ignore(string conflictId, string? reason, string actor, TimeProvider timeProvider)
{
var index = Conflicts.FindIndex(conflict => string.Equals(conflict.Id, conflictId, StringComparison.OrdinalIgnoreCase));
if (index < 0)
{
return null;
}
var now = timeProvider.GetUtcNow().ToString("O");
var current = Conflicts[index];
var updated = current with
{
Status = "ignored",
ResolvedAt = now,
ResolvedBy = actor,
ResolutionNotes = string.IsNullOrWhiteSpace(reason) ? "Ignored by operator." : reason.Trim()
};
Conflicts[index] = updated;
LastAnalyzedAt = now;
return updated;
}
}
private static string NormalizeSource(string? source) =>
string.IsNullOrWhiteSpace(source) ? "custom" : source.Trim().ToLowerInvariant();
private static string NormalizeSeverity(string? severity) =>
string.IsNullOrWhiteSpace(severity) ? "medium" : severity.Trim().ToLowerInvariant();
private static string NormalizeLevel(string? level) =>
string.IsNullOrWhiteSpace(level) ? "fresh" : level.Trim().ToLowerInvariant();
private static string NormalizeActionType(string? actionType) =>
string.IsNullOrWhiteSpace(actionType) ? "warn" : actionType.Trim().ToLowerInvariant();
private sealed record TrustWeightRecord(
string Id,
string IssuerId,
string IssuerName,
string Source,
decimal Weight,
int Priority,
bool Active,
string? Reason,
string ModifiedAt,
string ModifiedBy);
private sealed record StalenessConfigRecord(
string DataType,
List<StalenessThresholdRecord> Thresholds,
bool Enabled,
int GracePeriodHours);
private sealed record StalenessThresholdRecord(
string Level,
int AgeDays,
string Severity,
List<StalenessActionRecord> Actions);
private sealed record StalenessActionRecord(
string Type,
string? Message = null,
string[]? Channels = null);
}
public sealed record ConflictResolutionWriteModel(string? Resolution);
public sealed record ConflictIgnoreWriteModel(string? Reason);
public sealed record PolicyConflictSourceRecord(
string Id,
string Type,
string Name,
string? Version,
string? Path);
public sealed record PolicyConflictRecord(
string Id,
string Type,
string Severity,
string Summary,
string Description,
PolicyConflictSourceRecord SourceA,
PolicyConflictSourceRecord SourceB,
IReadOnlyList<string> AffectedScope,
string ImpactAssessment,
string? SuggestedResolution,
string DetectedAt,
string Status,
string? ResolvedAt,
string? ResolvedBy,
string? ResolutionNotes);
public sealed record TrustWeightWriteModel
{
public string? Id { get; init; }
public string? IssuerId { get; init; }
public string? IssuerName { get; init; }
public string? Source { get; init; }
public decimal? Weight { get; init; }
public int? Priority { get; init; }
public bool? Active { get; init; }
public string? Reason { get; init; }
}
public sealed record TrustWeightPreviewRequest(IReadOnlyList<TrustWeightWriteModel>? Weights);
public sealed record StalenessConfigWriteModel
{
public string? DataType { get; init; }
public IReadOnlyList<StalenessThresholdWriteModel>? Thresholds { get; init; }
public bool? Enabled { get; init; }
public int? GracePeriodHours { get; init; }
}
public sealed record StalenessThresholdWriteModel
{
public string? Level { get; init; }
public int AgeDays { get; init; }
public string? Severity { get; init; }
public IReadOnlyList<StalenessActionWriteModel>? Actions { get; init; }
}
public sealed record StalenessActionWriteModel
{
public string? Type { get; init; }
public string? Message { get; init; }
public string[]? Channels { get; init; }
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,413 @@
// -----------------------------------------------------------------------------
// RegistryWebhookEndpoints.cs
// Sprint: SPRINT_20251226_001_BE_cicd_gate_integration
// Task: CICD-GATE-02 - Webhook handler for registry image-push events
// Description: Receives webhooks from container registries and triggers gate evaluation
// -----------------------------------------------------------------------------
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using StellaOps.Auth.ServerIntegration.Tenancy;
using StellaOps.Policy.Engine.Gates;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace StellaOps.Policy.Engine.Endpoints.Gateway;
/// <summary>
/// Endpoints for receiving registry webhook events and triggering gate evaluations.
/// </summary>
internal static class RegistryWebhookEndpoints
{
public static IEndpointRouteBuilder MapRegistryWebhooks(this IEndpointRouteBuilder endpoints)
{
var group = endpoints.MapGroup("/api/v1/webhooks/registry")
.WithTags("Registry Webhooks")
.AllowAnonymous()
.RequireTenant();
group.MapPost("/docker", HandleDockerRegistryWebhook)
.WithName("DockerRegistryWebhook")
.WithSummary("Handle Docker Registry v2 webhook events")
.WithDescription("Receive Docker Registry v2 notification events and enqueue a gate evaluation job for each push event that includes a valid image digest. Returns a 202 Accepted response with the list of queued job IDs that can be polled for evaluation status.")
.Produces<WebhookAcceptedResponse>(StatusCodes.Status202Accepted)
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest);
group.MapPost("/harbor", HandleHarborWebhook)
.WithName("HarborWebhook")
.WithSummary("Handle Harbor registry webhook events")
.WithDescription("Receive Harbor registry webhook events and enqueue a gate evaluation job for each PUSH_ARTIFACT or pushImage event that contains a resource with a valid digest. Non-push event types are silently acknowledged without queuing any jobs.")
.Produces<WebhookAcceptedResponse>(StatusCodes.Status202Accepted)
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest);
group.MapPost("/generic", HandleGenericWebhook)
.WithName("GenericRegistryWebhook")
.WithSummary("Handle generic registry webhook events with image digest")
.WithDescription("Receive a generic registry webhook payload containing an image digest and enqueue a single gate evaluation job. Supports any registry that can POST a JSON body with imageDigest, repository, tag, and optional baselineRef fields.")
.Produces<WebhookAcceptedResponse>(StatusCodes.Status202Accepted)
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest);
return endpoints;
}
/// <summary>
/// Handles Docker Registry v2 notification webhooks.
/// </summary>
private static async Task<Results<Accepted<WebhookAcceptedResponse>, ProblemHttpResult>> HandleDockerRegistryWebhook(
[FromBody] DockerRegistryNotification notification,
IGateEvaluationQueue evaluationQueue,
[FromServices] TimeProvider timeProvider,
ILogger<RegistryWebhookEndpointMarker> logger,
CancellationToken ct)
{
if (notification.Events is null || notification.Events.Count == 0)
{
return TypedResults.Problem(
"No events in notification",
statusCode: StatusCodes.Status400BadRequest);
}
var jobs = new List<string>();
foreach (var evt in notification.Events.Where(e => e.Action == "push"))
{
if (string.IsNullOrEmpty(evt.Target?.Digest))
{
logger.LogWarning("Skipping push event without digest: {Repository}", evt.Target?.Repository);
continue;
}
var jobId = await evaluationQueue.EnqueueAsync(new GateEvaluationRequest
{
ImageDigest = evt.Target.Digest,
Repository = evt.Target.Repository ?? "unknown",
Tag = evt.Target.Tag,
RegistryUrl = evt.Request?.Host,
Source = "docker-registry",
Timestamp = evt.Timestamp ?? timeProvider.GetUtcNow()
}, ct);
jobs.Add(jobId);
logger.LogInformation(
"Queued gate evaluation for {Repository}@{Digest}, job: {JobId}",
evt.Target.Repository,
evt.Target.Digest,
jobId);
}
return TypedResults.Accepted(
$"/api/v1/policy/gate/jobs/{jobs.FirstOrDefault()}",
new WebhookAcceptedResponse(jobs.Count, jobs));
}
/// <summary>
/// Handles Harbor registry webhook events.
/// </summary>
private static async Task<Results<Accepted<WebhookAcceptedResponse>, ProblemHttpResult>> HandleHarborWebhook(
[FromBody] HarborWebhookEvent notification,
IGateEvaluationQueue evaluationQueue,
[FromServices] TimeProvider timeProvider,
ILogger<RegistryWebhookEndpointMarker> logger,
CancellationToken ct)
{
// Only process push events
if (notification.Type != "PUSH_ARTIFACT" && notification.Type != "pushImage")
{
logger.LogDebug("Ignoring Harbor event type: {Type}", notification.Type);
return TypedResults.Accepted(
"/api/v1/policy/gate/jobs",
new WebhookAcceptedResponse(0, []));
}
if (notification.EventData?.Resources is null || notification.EventData.Resources.Count == 0)
{
return TypedResults.Problem(
"No resources in Harbor notification",
statusCode: StatusCodes.Status400BadRequest);
}
var jobs = new List<string>();
foreach (var resource in notification.EventData.Resources)
{
if (string.IsNullOrEmpty(resource.Digest))
{
logger.LogWarning("Skipping resource without digest: {ResourceUrl}", resource.ResourceUrl);
continue;
}
var jobId = await evaluationQueue.EnqueueAsync(new GateEvaluationRequest
{
ImageDigest = resource.Digest,
Repository = notification.EventData.Repository?.Name ?? "unknown",
Tag = resource.Tag,
RegistryUrl = notification.EventData.Repository?.RepoFullName,
Source = "harbor",
Timestamp = notification.OccurAt ?? timeProvider.GetUtcNow()
}, ct);
jobs.Add(jobId);
logger.LogInformation(
"Queued gate evaluation for {Repository}@{Digest}, job: {JobId}",
notification.EventData.Repository?.Name,
resource.Digest,
jobId);
}
return TypedResults.Accepted(
$"/api/v1/policy/gate/jobs/{jobs.FirstOrDefault()}",
new WebhookAcceptedResponse(jobs.Count, jobs));
}
/// <summary>
/// Handles generic webhook events with image digest.
/// </summary>
private static async Task<Results<Accepted<WebhookAcceptedResponse>, ProblemHttpResult>> HandleGenericWebhook(
[FromBody] GenericRegistryWebhook notification,
IGateEvaluationQueue evaluationQueue,
[FromServices] TimeProvider timeProvider,
ILogger<RegistryWebhookEndpointMarker> logger,
CancellationToken ct)
{
if (string.IsNullOrEmpty(notification.ImageDigest))
{
return TypedResults.Problem(
"imageDigest is required",
statusCode: StatusCodes.Status400BadRequest);
}
var jobId = await evaluationQueue.EnqueueAsync(new GateEvaluationRequest
{
ImageDigest = notification.ImageDigest,
Repository = notification.Repository ?? "unknown",
Tag = notification.Tag,
RegistryUrl = notification.RegistryUrl,
BaselineRef = notification.BaselineRef,
Source = notification.Source ?? "generic",
Timestamp = timeProvider.GetUtcNow()
}, ct);
logger.LogInformation(
"Queued gate evaluation for {Repository}@{Digest}, job: {JobId}",
notification.Repository,
notification.ImageDigest,
jobId);
return TypedResults.Accepted(
$"/api/v1/policy/gate/jobs/{jobId}",
new WebhookAcceptedResponse(1, [jobId]));
}
}
/// <summary>
/// Marker type for endpoint logging.
/// </summary>
internal sealed class RegistryWebhookEndpointMarker;
// ============================================================================
// Docker Registry Notification Models
// ============================================================================
/// <summary>
/// Docker Registry v2 notification envelope.
/// </summary>
public sealed record DockerRegistryNotification
{
[JsonPropertyName("events")]
public List<DockerRegistryEvent>? Events { get; init; }
}
/// <summary>
/// Docker Registry v2 event.
/// </summary>
public sealed record DockerRegistryEvent
{
[JsonPropertyName("id")]
public string? Id { get; init; }
[JsonPropertyName("timestamp")]
public DateTimeOffset? Timestamp { get; init; }
[JsonPropertyName("action")]
public string? Action { get; init; }
[JsonPropertyName("target")]
public DockerRegistryTarget? Target { get; init; }
[JsonPropertyName("request")]
public DockerRegistryRequest? Request { get; init; }
}
/// <summary>
/// Docker Registry event target (the image).
/// </summary>
public sealed record DockerRegistryTarget
{
[JsonPropertyName("mediaType")]
public string? MediaType { get; init; }
[JsonPropertyName("size")]
public long? Size { get; init; }
[JsonPropertyName("digest")]
public string? Digest { get; init; }
[JsonPropertyName("repository")]
public string? Repository { get; init; }
[JsonPropertyName("tag")]
public string? Tag { get; init; }
}
/// <summary>
/// Docker Registry request metadata.
/// </summary>
public sealed record DockerRegistryRequest
{
[JsonPropertyName("id")]
public string? Id { get; init; }
[JsonPropertyName("host")]
public string? Host { get; init; }
[JsonPropertyName("method")]
public string? Method { get; init; }
}
// ============================================================================
// Harbor Webhook Models
// ============================================================================
/// <summary>
/// Harbor webhook event.
/// </summary>
public sealed record HarborWebhookEvent
{
[JsonPropertyName("type")]
public string? Type { get; init; }
[JsonPropertyName("occur_at")]
public DateTimeOffset? OccurAt { get; init; }
[JsonPropertyName("operator")]
public string? Operator { get; init; }
[JsonPropertyName("event_data")]
public HarborEventData? EventData { get; init; }
}
/// <summary>
/// Harbor event data.
/// </summary>
public sealed record HarborEventData
{
[JsonPropertyName("resources")]
public List<HarborResource>? Resources { get; init; }
[JsonPropertyName("repository")]
public HarborRepository? Repository { get; init; }
}
/// <summary>
/// Harbor resource (artifact).
/// </summary>
public sealed record HarborResource
{
[JsonPropertyName("digest")]
public string? Digest { get; init; }
[JsonPropertyName("tag")]
public string? Tag { get; init; }
[JsonPropertyName("resource_url")]
public string? ResourceUrl { get; init; }
}
/// <summary>
/// Harbor repository info.
/// </summary>
public sealed record HarborRepository
{
[JsonPropertyName("name")]
public string? Name { get; init; }
[JsonPropertyName("namespace")]
public string? Namespace { get; init; }
[JsonPropertyName("repo_full_name")]
public string? RepoFullName { get; init; }
}
// ============================================================================
// Generic Webhook Models
// ============================================================================
/// <summary>
/// Generic registry webhook payload.
/// </summary>
public sealed record GenericRegistryWebhook
{
[JsonPropertyName("imageDigest")]
public string? ImageDigest { get; init; }
[JsonPropertyName("repository")]
public string? Repository { get; init; }
[JsonPropertyName("tag")]
public string? Tag { get; init; }
[JsonPropertyName("registryUrl")]
public string? RegistryUrl { get; init; }
[JsonPropertyName("baselineRef")]
public string? BaselineRef { get; init; }
[JsonPropertyName("source")]
public string? Source { get; init; }
}
// ============================================================================
// Response Models
// ============================================================================
/// <summary>
/// Response indicating webhook was accepted.
/// </summary>
public sealed record WebhookAcceptedResponse(
int JobsQueued,
IReadOnlyList<string> JobIds);
// ============================================================================
// Gate Evaluation Queue Interface
// ============================================================================
/// <summary>
/// Interface for queuing gate evaluation jobs.
/// </summary>
public interface IGateEvaluationQueue
{
/// <summary>
/// Enqueues a gate evaluation request.
/// </summary>
/// <param name="request">The evaluation request.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The job ID for tracking.</returns>
Task<string> EnqueueAsync(GateEvaluationRequest request, CancellationToken cancellationToken = default);
}
/// <summary>
/// Request to evaluate a gate for an image.
/// </summary>
public sealed record GateEvaluationRequest
{
public required string ImageDigest { get; init; }
public required string Repository { get; init; }
public string? Tag { get; init; }
public string? RegistryUrl { get; init; }
public string? BaselineRef { get; init; }
public required string Source { get; init; }
public required DateTimeOffset Timestamp { get; init; }
}

View File

@@ -0,0 +1,554 @@
// SPDX-License-Identifier: BUSL-1.1
// Copyright (c) 2026 StellaOps
// Sprint: SPRINT_20260118_030_LIB_verdict_rekor_gate_api
// Task: TASK-030-006 - Gate Decision API Endpoint
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Mvc;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Auth.ServerIntegration.Tenancy;
using StellaOps.DeltaVerdict.Bundles;
using StellaOps.Policy.Engine.Contracts.Gateway;
using StellaOps.Signals.EvidenceWeightedScore;
namespace StellaOps.Policy.Engine.Endpoints.Gateway;
/// <summary>
/// Score-based gate API endpoints for CI/CD release gating.
/// Provides advisory-style score-based gate evaluation with verdict bundles.
/// </summary>
public static class ScoreGateEndpoints
{
/// <summary>
/// Maps score-based gate endpoints to the application.
/// </summary>
public static void MapScoreGateEndpoints(this WebApplication app)
{
var gates = app.MapGroup("/api/v1/gate")
.WithTags("Score Gates")
.RequireTenant();
// POST /api/v1/gate/evaluate - Evaluate score-based gate for a finding
gates.MapPost("/evaluate", async Task<IResult>(
HttpContext httpContext,
ScoreGateEvaluateRequest? request,
IEvidenceWeightedScoreCalculator ewsCalculator,
IVerdictBundleBuilder verdictBuilder,
IVerdictSigningService signingService,
IVerdictRekorAnchorService anchorService,
[FromServices] TimeProvider timeProvider,
[FromServices] ILogger<ScoreGateLog> logger,
CancellationToken cancellationToken) =>
{
if (request is null)
{
return Results.BadRequest(new ProblemDetails
{
Title = "Request body required",
Status = 400
});
}
if (string.IsNullOrWhiteSpace(request.FindingId))
{
return Results.BadRequest(new ProblemDetails
{
Title = "Finding ID is required",
Status = 400,
Detail = "Provide a valid finding identifier (e.g., CVE-2024-1234@pkg:npm/lodash@4.17.20)"
});
}
try
{
// Step 1: Build EWS input from request
var ewsInput = BuildEwsInput(request);
// Step 2: Get policy (default to advisory)
var policy = GetPolicy(request.PolicyProfile);
// Step 3: Calculate score
var ewsResult = ewsCalculator.Calculate(ewsInput, policy);
// Step 4: Build verdict bundle (includes gate evaluation)
var gateConfig = GateConfiguration.Default;
var verdictBundle = verdictBuilder.Build(ewsResult, ewsInput, policy, gateConfig);
logger.LogInformation(
"Gate evaluated for {FindingId}: action={Action}, score={Score:F2}",
request.FindingId,
verdictBundle.Gate.Action,
verdictBundle.FinalScore);
// Step 5: Sign the bundle
var signingOptions = new VerdictSigningOptions
{
KeyId = "stella-gate-api",
Algorithm = VerdictSigningAlgorithm.HmacSha256,
SecretBase64 = GetSigningSecret()
};
var signedBundle = await signingService.SignAsync(verdictBundle, signingOptions, cancellationToken);
// Step 6: Optionally anchor to Rekor
VerdictBundle finalBundle = signedBundle;
string? rekorUuid = null;
long? rekorLogIndex = null;
if (request.AnchorToRekor)
{
var anchorOptions = new VerdictAnchorOptions
{
RekorUrl = GetRekorUrl()
};
var anchorResult = await anchorService.AnchorAsync(signedBundle, anchorOptions, cancellationToken);
if (anchorResult.IsSuccess)
{
finalBundle = anchorResult.AnchoredBundle!;
rekorUuid = anchorResult.Linkage?.Uuid;
rekorLogIndex = anchorResult.Linkage?.LogIndex;
logger.LogInformation(
"Verdict anchored to Rekor: uuid={Uuid}, logIndex={LogIndex}",
rekorUuid,
rekorLogIndex);
}
else
{
logger.LogWarning(
"Rekor anchoring failed: {Error}",
anchorResult.Error);
}
}
// Step 7: Build response
var response = BuildResponse(
finalBundle,
ewsResult,
rekorUuid,
rekorLogIndex,
request.IncludeVerdict);
// Return appropriate status code based on action
return finalBundle.Gate.Action switch
{
GateAction.Block => Results.Json(response, statusCode: 403),
GateAction.Warn => Results.Ok(response),
_ => Results.Ok(response)
};
}
catch (Exception ex)
{
logger.LogError(ex, "Gate evaluation failed for {FindingId}", request.FindingId);
return Results.Problem(new ProblemDetails
{
Title = "Gate evaluation failed",
Status = 500,
Detail = "An error occurred during gate evaluation"
});
}
})
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRun))
.WithName("EvaluateScoreGate")
.WithDescription("Evaluate a score-based CI/CD release gate for a single security finding using the Evidence Weighted Score (EWS) formula. Computes a composite risk score from CVSS, EPSS, reachability, exploit maturity, patch proof, and VEX status inputs, applies the gate policy thresholds to produce a pass/warn/block action, signs the verdict bundle, and optionally anchors it to a Rekor transparency log. Returns HTTP 403 when the gate action is block.");
// GET /api/v1/gate/health - Health check for gate service
gates.MapGet("/health", ([FromServices] TimeProvider timeProvider) =>
Results.Ok(new { status = "healthy", timestamp = timeProvider.GetUtcNow() }))
.WithName("ScoreGateHealth")
.WithDescription("Health check for the score-based gate evaluation service")
.AllowAnonymous();
// POST /api/v1/gate/evaluate-batch - Batch evaluation for multiple findings
gates.MapPost("/evaluate-batch", async Task<IResult>(
HttpContext httpContext,
ScoreGateBatchEvaluateRequest request,
IEvidenceWeightedScoreCalculator ewsCalculator,
IVerdictBundleBuilder verdictBuilder,
IVerdictSigningService signingService,
IVerdictRekorAnchorService anchorService,
[FromServices] TimeProvider timeProvider,
[FromServices] ILogger<ScoreGateLog> logger,
CancellationToken cancellationToken) =>
{
if (request is null || request.Findings is null || request.Findings.Count == 0)
{
return Results.BadRequest(new ProblemDetails
{
Title = "Request body required",
Status = 400,
Detail = "Provide at least one finding to evaluate"
});
}
const int maxBatchSize = 500;
if (request.Findings.Count > maxBatchSize)
{
return Results.BadRequest(new ProblemDetails
{
Title = "Batch size exceeded",
Status = 400,
Detail = $"Maximum batch size is {maxBatchSize}, got {request.Findings.Count}"
});
}
var options = request.Options ?? new ScoreGateBatchOptions();
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
try
{
var results = await EvaluateBatchAsync(
request.Findings,
options,
ewsCalculator,
verdictBuilder,
signingService,
anchorService,
logger,
cancellationToken);
stopwatch.Stop();
var summary = new ScoreGateBatchSummary
{
Total = results.Count,
Passed = results.Count(r => r.Action == ScoreGateActions.Pass),
Warned = results.Count(r => r.Action == ScoreGateActions.Warn),
Blocked = results.Count(r => r.Action == ScoreGateActions.Block),
Errored = results.Count(r => r.Action == "error")
};
// Determine overall action (worst case)
var overallAction = summary.Blocked > 0 ? ScoreGateActions.Block
: summary.Warned > 0 ? ScoreGateActions.Warn
: ScoreGateActions.Pass;
var exitCode = overallAction switch
{
ScoreGateActions.Block => ScoreGateExitCodes.Block,
ScoreGateActions.Warn => ScoreGateExitCodes.Warn,
_ => ScoreGateExitCodes.Pass
};
var response = new ScoreGateBatchEvaluateResponse
{
Summary = summary,
OverallAction = overallAction,
ExitCode = exitCode,
Decisions = results,
DurationMs = stopwatch.ElapsedMilliseconds,
FailFastTriggered = options.FailFast && summary.Blocked > 0 && results.Count < request.Findings.Count
};
logger.LogInformation(
"Batch gate evaluated: total={Total}, passed={Passed}, warned={Warned}, blocked={Blocked}, duration={Duration}ms",
summary.Total, summary.Passed, summary.Warned, summary.Blocked, stopwatch.ElapsedMilliseconds);
// Return appropriate status based on overall action
return overallAction == ScoreGateActions.Block
? Results.Json(response, statusCode: 403)
: Results.Ok(response);
}
catch (Exception ex)
{
logger.LogError(ex, "Batch gate evaluation failed");
return Results.Problem(new ProblemDetails
{
Title = "Batch evaluation failed",
Status = 500,
Detail = "An error occurred during batch gate evaluation"
});
}
})
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRun))
.WithName("EvaluateScoreGateBatch")
.WithDescription("Batch evaluate score-based CI/CD gates for up to 500 findings in a single request using configurable parallelism. Applies the EWS formula to each finding, produces a per-finding action (pass/warn/block), and returns an aggregate summary with overall action, exit code, and optional per-finding verdict bundles. Supports fail-fast mode to stop processing on the first blocked finding.");
}
private static async Task<List<ScoreGateBatchDecision>> EvaluateBatchAsync(
IReadOnlyList<ScoreGateEvaluateRequest> findings,
ScoreGateBatchOptions options,
IEvidenceWeightedScoreCalculator ewsCalculator,
IVerdictBundleBuilder verdictBuilder,
IVerdictSigningService signingService,
IVerdictRekorAnchorService anchorService,
ILogger logger,
CancellationToken cancellationToken)
{
var results = new List<ScoreGateBatchDecision>();
var policy = GetPolicy(options.PolicyProfile);
var gateConfig = GateConfiguration.Default;
var parallelism = Math.Clamp(options.MaxParallelism, 1, 20);
var semaphore = new SemaphoreSlim(parallelism);
var failFastToken = new CancellationTokenSource();
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, failFastToken.Token);
var tasks = findings.Select(async finding =>
{
try
{
await semaphore.WaitAsync(linkedCts.Token);
}
catch (OperationCanceledException)
{
// Fail-fast triggered; skip remaining findings
return null;
}
try
{
if (linkedCts.Token.IsCancellationRequested)
{
return null;
}
var decision = await EvaluateSingleAsync(
finding,
options,
policy,
gateConfig,
ewsCalculator,
verdictBuilder,
signingService,
anchorService,
logger,
linkedCts.Token);
// Check fail-fast
if (options.FailFast && decision.Action == ScoreGateActions.Block)
{
await failFastToken.CancelAsync();
}
return decision;
}
catch (OperationCanceledException)
{
// Fail-fast triggered during evaluation; skip this finding
return null;
}
finally
{
semaphore.Release();
}
}).ToList();
var completedTasks = await Task.WhenAll(tasks);
results.AddRange(completedTasks.Where(d => d is not null).Cast<ScoreGateBatchDecision>());
return results;
}
private static async Task<ScoreGateBatchDecision> EvaluateSingleAsync(
ScoreGateEvaluateRequest request,
ScoreGateBatchOptions batchOptions,
EvidenceWeightPolicy policy,
GateConfiguration gateConfig,
IEvidenceWeightedScoreCalculator ewsCalculator,
IVerdictBundleBuilder verdictBuilder,
IVerdictSigningService signingService,
IVerdictRekorAnchorService anchorService,
ILogger logger,
CancellationToken cancellationToken)
{
try
{
// Build EWS input
var ewsInput = BuildEwsInput(request);
// Calculate score
var ewsResult = ewsCalculator.Calculate(ewsInput, policy);
// Build verdict bundle
var verdictBundle = verdictBuilder.Build(ewsResult, ewsInput, policy, gateConfig);
// Sign the bundle
var signingOptions = new VerdictSigningOptions
{
KeyId = "stella-gate-api",
Algorithm = VerdictSigningAlgorithm.HmacSha256,
SecretBase64 = GetSigningSecret()
};
var signedBundle = await signingService.SignAsync(verdictBundle, signingOptions, cancellationToken);
// Optionally anchor to Rekor
VerdictBundle finalBundle = signedBundle;
if (batchOptions.AnchorToRekor)
{
var anchorOptions = new VerdictAnchorOptions { RekorUrl = GetRekorUrl() };
var anchorResult = await anchorService.AnchorAsync(signedBundle, anchorOptions, cancellationToken);
if (anchorResult.IsSuccess)
{
finalBundle = anchorResult.AnchoredBundle!;
}
}
var action = finalBundle.Gate.Action switch
{
GateAction.Pass => ScoreGateActions.Pass,
GateAction.Warn => ScoreGateActions.Warn,
GateAction.Block => ScoreGateActions.Block,
_ => ScoreGateActions.Pass
};
return new ScoreGateBatchDecision
{
FindingId = request.FindingId,
Action = action,
Score = finalBundle.FinalScore,
Threshold = finalBundle.Gate.Threshold,
Reason = finalBundle.Gate.Reason,
VerdictBundleId = finalBundle.BundleId,
VerdictBundle = batchOptions.IncludeVerdicts ? finalBundle : null
};
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to evaluate finding {FindingId}", request.FindingId);
return new ScoreGateBatchDecision
{
FindingId = request.FindingId,
Action = "error",
Error = ex.Message
};
}
}
private static EvidenceWeightedScoreInput BuildEwsInput(ScoreGateEvaluateRequest request)
{
// Parse reachability level to normalized value
var reachabilityValue = ParseReachabilityLevel(request.Reachability);
// Parse exploit maturity level
var exploitMaturity = ParseExploitMaturity(request.ExploitMaturity);
return new EvidenceWeightedScoreInput
{
FindingId = request.FindingId,
CvssBase = request.CvssBase,
CvssVersion = request.CvssVersion ?? "3.1",
EpssScore = request.Epss,
ExploitMaturity = exploitMaturity,
PatchProofConfidence = request.PatchProofConfidence,
VexStatus = request.VexStatus,
VexSource = request.VexSource,
// Map reachability to legacy Rch field (used by advisory formula)
Rch = reachabilityValue,
// Legacy fields with safe defaults
Rts = 0.0,
Bkp = request.PatchProofConfidence,
Xpl = request.Epss,
Src = 0.5,
Mit = 0.0
};
}
private static double ParseReachabilityLevel(string? level)
{
return level?.ToLowerInvariant() switch
{
"caller" => 0.9,
"function" or "function_level" => 0.7,
"package" or "package_level" => 0.3,
"none" => 0.0,
// Unknown/unspecified: use conservative default assuming partial reachability
_ => 0.5
};
}
private static ExploitMaturityLevel ParseExploitMaturity(string? maturity)
{
return maturity?.ToLowerInvariant() switch
{
"high" or "active" or "kev" => ExploitMaturityLevel.High,
"functional" => ExploitMaturityLevel.Functional,
"poc" or "proof_of_concept" or "proofofconcept" => ExploitMaturityLevel.ProofOfConcept,
"none" => ExploitMaturityLevel.None,
_ => ExploitMaturityLevel.Unknown
};
}
private static EvidenceWeightPolicy GetPolicy(string? profile)
{
return profile?.ToLowerInvariant() switch
{
"legacy" => EvidenceWeightPolicy.DefaultProduction,
"advisory" or null => EvidenceWeightPolicy.AdvisoryProduction,
_ => EvidenceWeightPolicy.AdvisoryProduction
};
}
private static ScoreGateEvaluateResponse BuildResponse(
VerdictBundle bundle,
EvidenceWeightedScoreResult ewsResult,
string? rekorUuid,
long? rekorLogIndex,
bool includeVerdict)
{
var action = bundle.Gate.Action switch
{
GateAction.Pass => ScoreGateActions.Pass,
GateAction.Warn => ScoreGateActions.Warn,
GateAction.Block => ScoreGateActions.Block,
_ => ScoreGateActions.Pass
};
var exitCode = bundle.Gate.Action switch
{
GateAction.Pass => ScoreGateExitCodes.Pass,
GateAction.Warn => ScoreGateExitCodes.Warn,
GateAction.Block => ScoreGateExitCodes.Block,
_ => ScoreGateExitCodes.Pass
};
var breakdown = ewsResult.Breakdown
.Select(b => new ScoreDimensionBreakdown
{
Dimension = b.Dimension,
Symbol = b.Symbol,
Value = b.InputValue,
Weight = b.Weight,
Contribution = b.Contribution,
IsSubtractive = b.IsSubtractive
})
.ToList();
return new ScoreGateEvaluateResponse
{
Action = action,
Score = bundle.FinalScore,
Threshold = bundle.Gate.Threshold,
Reason = bundle.Gate.Reason,
VerdictBundleId = bundle.BundleId,
RekorUuid = rekorUuid,
RekorLogIndex = rekorLogIndex,
ComputedAt = bundle.ComputedAt,
MatchedRules = bundle.Gate.MatchedRules.ToList(),
Suggestions = bundle.Gate.Suggestions.ToList(),
ExitCode = exitCode,
Breakdown = breakdown,
VerdictBundle = includeVerdict ? bundle : null
};
}
private static string GetSigningSecret()
{
// In production, this should come from configuration/secrets management
// For now, return a placeholder that should be overridden
return Environment.GetEnvironmentVariable("STELLA_GATE_SIGNING_SECRET")
?? Convert.ToBase64String(new byte[32]);
}
private static string GetRekorUrl()
{
return Environment.GetEnvironmentVariable("STELLA_REKOR_URL")
?? "https://rekor.sigstore.dev";
}
}
internal sealed class ScoreGateLog;

View File

@@ -0,0 +1,212 @@
using Microsoft.AspNetCore.Mvc;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Auth.ServerIntegration.Tenancy;
using StellaOps.Policy.Engine.Contracts.Gateway;
using StellaOps.Policy.ToolLattice;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;
namespace StellaOps.Policy.Engine.Endpoints.Gateway;
public static class ToolLatticeEndpoints
{
public static void MapToolLatticeEndpoints(this WebApplication app)
{
var tools = app.MapGroup("/api/v1/policy/assistant/tools")
.WithTags("Assistant Tools")
.RequireTenant();
tools.MapPost("/evaluate", (HttpContext httpContext, ToolAccessRequest request, IToolAccessEvaluator evaluator) =>
{
if (request is null)
{
return Results.BadRequest(new ProblemDetails
{
Title = "Request body required",
Status = StatusCodes.Status400BadRequest
});
}
var tenantId = !string.IsNullOrWhiteSpace(request.TenantId)
? request.TenantId
: GetTenantId(httpContext);
if (string.IsNullOrWhiteSpace(tenantId))
{
return Results.BadRequest(new ProblemDetails
{
Title = "Tenant id required",
Status = StatusCodes.Status400BadRequest,
Detail = "Provide tenant_id claim or X-Tenant-Id header."
});
}
if (string.IsNullOrWhiteSpace(request.Tool))
{
return Results.BadRequest(new ProblemDetails
{
Title = "Tool name required",
Status = StatusCodes.Status400BadRequest
});
}
if (string.IsNullOrWhiteSpace(request.Action))
{
return Results.BadRequest(new ProblemDetails
{
Title = "Tool action required",
Status = StatusCodes.Status400BadRequest,
Detail = "Use read or action for tool requests."
});
}
var scopes = ResolveScopes(request, httpContext.User);
var roles = ResolveRoles(request, httpContext.User);
var decision = evaluator.Evaluate(new ToolAccessContext
{
TenantId = tenantId.Trim(),
Tool = request.Tool.Trim(),
Action = request.Action.Trim(),
Resource = request.Resource?.Trim(),
Scopes = scopes,
Roles = roles
});
return Results.Ok(new ToolAccessResponse
{
Allowed = decision.Allowed,
Reason = decision.Reason,
RuleId = decision.RuleId,
RuleEffect = decision.RuleEffect?.ToString().ToLowerInvariant(),
RequiredScopes = decision.RequiredScopes,
RequiredRoles = decision.RequiredRoles
});
})
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead))
.WithName("EvaluateToolAccess")
.WithDescription("Evaluate assistant tool access using the tool lattice rules.");
}
private static string? GetTenantId(HttpContext httpContext)
{
return httpContext.User.FindFirstValue(StellaOpsClaimTypes.Tenant)
?? httpContext.User.FindFirstValue("tenant_id")
?? httpContext.Request.Headers["X-Tenant-Id"].FirstOrDefault()
?? httpContext.Request.Headers["X-StellaOps-Tenant"].FirstOrDefault()
?? httpContext.Request.Headers["X-Stella-Tenant"].FirstOrDefault();
}
private static IReadOnlyList<string> ResolveScopes(ToolAccessRequest request, ClaimsPrincipal user)
{
if (request.Scopes is { Count: > 0 })
{
return NormalizeList(request.Scopes);
}
var scopes = new HashSet<string>(StringComparer.Ordinal);
foreach (var claim in user.FindAll(StellaOpsClaimTypes.ScopeItem))
{
var normalized = StellaOpsScopes.Normalize(claim.Value);
if (normalized is not null)
{
scopes.Add(normalized);
}
}
foreach (var claim in user.FindAll(StellaOpsClaimTypes.Scope))
{
if (string.IsNullOrWhiteSpace(claim.Value))
{
continue;
}
var parts = claim.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
foreach (var part in parts)
{
var normalized = StellaOpsScopes.Normalize(part);
if (normalized is not null)
{
scopes.Add(normalized);
}
}
}
return scopes.Count == 0
? Array.Empty<string>()
: scopes.OrderBy(static scope => scope, StringComparer.Ordinal).ToArray();
}
private static IReadOnlyList<string> ResolveRoles(ToolAccessRequest request, ClaimsPrincipal user)
{
if (request.Roles is { Count: > 0 })
{
return NormalizeList(request.Roles);
}
var roles = new HashSet<string>(StringComparer.Ordinal);
foreach (var claim in user.FindAll(ClaimTypes.Role))
{
AddRoleValue(roles, claim.Value);
}
foreach (var claim in user.FindAll("role"))
{
AddRoleValue(roles, claim.Value);
}
foreach (var claim in user.FindAll("roles"))
{
AddRoleValue(roles, claim.Value);
}
return roles.Count == 0
? Array.Empty<string>()
: roles.OrderBy(static role => role, StringComparer.Ordinal).ToArray();
}
private static IReadOnlyList<string> NormalizeList(IReadOnlyCollection<string> values)
{
var normalized = new HashSet<string>(StringComparer.Ordinal);
foreach (var value in values)
{
var trimmed = value?.Trim();
if (string.IsNullOrWhiteSpace(trimmed))
{
continue;
}
normalized.Add(trimmed.ToLowerInvariant());
}
return normalized.Count == 0
? Array.Empty<string>()
: normalized.OrderBy(static value => value, StringComparer.Ordinal).ToArray();
}
private static void AddRoleValue(ISet<string> roles, string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return;
}
var parts = value.Split(
new[] { ' ', ',' },
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
foreach (var part in parts)
{
if (!string.IsNullOrWhiteSpace(part))
{
roles.Add(part.Trim().ToLowerInvariant());
}
}
}
}

View File

@@ -30,7 +30,15 @@ using StellaOps.PolicyDsl;
using System.IO;
using System.Threading.RateLimiting;
using Microsoft.AspNetCore.Mvc;
using StellaOps.Determinism;
using StellaOps.Policy.Engine.Tenancy;
using StellaOps.Policy.Engine.Endpoints.Gateway;
using StellaOps.Policy.Engine.Services.Gateway;
using StellaOps.Policy.Engine.Contracts.Gateway;
using StellaOps.Policy.Deltas;
using StellaOps.Policy.Snapshots;
using StellaOps.Policy.ToolLattice;
using StellaOps.Router.AspNet;
var builder = WebApplication.CreateBuilder(args);
@@ -242,6 +250,8 @@ builder.Services.AddSingleton<IRuntimeEvaluationExecutor, RuntimeEvaluationExecu
builder.Services.AddVexDecisionEmitter(); // POLICY-VEX-401-006
builder.Services.AddStellaOpsCrypto();
builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddSystemGuidProvider();
builder.Services.AddHttpContextAccessor();
builder.Services.AddTenantContext(builder.Configuration);
builder.Services.AddStellaOpsCors(builder.Environment, builder.Configuration);
@@ -345,6 +355,71 @@ if (bootstrap.Options.Authority.Enabled)
});
}
// ── Merged from Policy Gateway ──────────────────────────────────────────────
// Exception services (from gateway)
builder.Services.Configure<ApprovalWorkflowOptions>(
builder.Configuration.GetSection(ApprovalWorkflowOptions.SectionName));
builder.Services.Configure<ExceptionExpiryOptions>(
builder.Configuration.GetSection(ExceptionExpiryOptions.SectionName));
builder.Services.AddScoped<IExceptionService, ExceptionService>();
builder.Services.AddScoped<IExceptionQueryService, ExceptionQueryService>();
builder.Services.AddScoped<IApprovalWorkflowService, ApprovalWorkflowService>();
builder.Services.AddSingleton<IExceptionNotificationService, NoOpExceptionNotificationService>();
builder.Services.AddHostedService<ExceptionExpiryWorker>();
// Delta services (from gateway)
builder.Services.AddScoped<IDeltaComputer, DeltaComputer>();
builder.Services.AddScoped<IBaselineSelector, BaselineSelector>();
builder.Services.AddScoped<ISnapshotStore, InMemorySnapshotStore>();
builder.Services.AddScoped<StellaOps.Policy.Deltas.ISnapshotService, DeltaSnapshotServiceAdapter>();
// Gate services (from gateway)
builder.Services.Configure<StellaOps.Policy.Engine.Gates.DriftGateOptions>(
builder.Configuration.GetSection(StellaOps.Policy.Engine.Gates.DriftGateOptions.SectionName));
builder.Services.AddScoped<StellaOps.Policy.Engine.Gates.IDriftGateEvaluator, StellaOps.Policy.Engine.Gates.DriftGateEvaluator>();
builder.Services.AddSingleton<InMemoryGateEvaluationQueue>();
builder.Services.AddSingleton<IGateEvaluationQueue>(sp => sp.GetRequiredService<InMemoryGateEvaluationQueue>());
builder.Services.AddHostedService<GateEvaluationWorker>();
// Unknowns gate services (from gateway)
builder.Services.Configure<StellaOps.Policy.Gates.UnknownsGateOptions>(_ => { });
builder.Services.AddHttpClient<StellaOps.Policy.Gates.IUnknownsGateChecker, StellaOps.Policy.Gates.UnknownsGateChecker>();
// Gate bypass audit services (from gateway)
builder.Services.AddSingleton<StellaOps.Policy.Audit.IGateBypassAuditRepository,
StellaOps.Policy.Audit.InMemoryGateBypassAuditRepository>();
builder.Services.AddSingleton<StellaOps.Policy.Engine.Services.GateBypassAuditOptions>();
builder.Services.AddScoped<StellaOps.Policy.Engine.Services.IGateBypassAuditor,
StellaOps.Policy.Engine.Services.GateBypassAuditor>();
// Score-based gate services (from gateway)
builder.Services.AddSingleton<StellaOps.Signals.EvidenceWeightedScore.IEvidenceWeightedScoreCalculator,
StellaOps.Signals.EvidenceWeightedScore.EvidenceWeightedScoreCalculator>();
builder.Services.AddSingleton<StellaOps.DeltaVerdict.Bundles.IGateEvaluator,
StellaOps.DeltaVerdict.Bundles.GateEvaluator>();
builder.Services.AddSingleton<StellaOps.DeltaVerdict.Bundles.IVerdictBundleBuilder,
StellaOps.DeltaVerdict.Bundles.VerdictBundleBuilder>();
builder.Services.AddSingleton<StellaOps.DeltaVerdict.Bundles.IVerdictSigningService,
StellaOps.DeltaVerdict.Bundles.VerdictSigningService>();
builder.Services.AddSingleton<StellaOps.DeltaVerdict.Signing.IRekorSubmissionClient,
StellaOps.DeltaVerdict.Bundles.StubVerdictRekorClient>();
builder.Services.AddSingleton<StellaOps.DeltaVerdict.Bundles.IVerdictRekorAnchorService,
StellaOps.DeltaVerdict.Bundles.VerdictRekorAnchorService>();
// Exception approval services (from gateway)
builder.Services.Configure<StellaOps.Policy.Engine.Services.ExceptionApprovalRulesOptions>(
builder.Configuration.GetSection(StellaOps.Policy.Engine.Services.ExceptionApprovalRulesOptions.SectionName));
builder.Services.AddScoped<StellaOps.Policy.Persistence.Postgres.Repositories.IExceptionApprovalRepository,
StellaOps.Policy.Persistence.Postgres.Repositories.ExceptionApprovalRepository>();
builder.Services.AddScoped<StellaOps.Policy.Engine.Services.IExceptionApprovalRulesService,
StellaOps.Policy.Engine.Services.ExceptionApprovalRulesService>();
// Tool lattice service (from gateway)
builder.Services.AddSingleton<IToolAccessEvaluator, ToolAccessEvaluator>();
builder.Services.AddMemoryCache();
// ── End merged gateway services ────────────────────────────────────────────
// Stella Router integration
var routerEnabled = builder.Services.AddRouterMicroservice(
builder.Configuration,
@@ -418,6 +493,49 @@ app.MapPolicySnapshotsApi();
app.MapViolationEventsApi();
app.MapConflictsApi();
// ── Merged from Policy Gateway ──────────────────────────────────────────────
// Exception management endpoints
app.MapExceptionEndpoints();
// Delta management endpoints
app.MapDeltasEndpoints();
// Policy simulation compatibility endpoints
app.MapPolicySimulationEndpoints();
// Gate evaluation endpoints
app.MapGateEndpoints();
// Unknowns gate endpoints
app.MapGatesEndpoints();
// Score-based gate endpoints
app.MapScoreGateEndpoints();
// Registry webhook endpoints
app.MapRegistryWebhooks();
// Exception approval endpoints
app.MapExceptionApprovalEndpoints();
// Governance endpoints
app.MapGovernanceEndpoints();
app.MapGovernanceCompatibilityEndpoints();
// Advisory source endpoints
app.MapAdvisorySourcePolicyEndpoints();
// Tool lattice endpoints
app.MapToolLatticeEndpoints();
// Quota endpoint (was inline in gateway Program.cs)
app.MapGet("/api/policy/quota", ([FromServices] TimeProvider timeProvider) =>
{
var now = timeProvider.GetUtcNow();
var resetAt = now.Date.AddDays(1).ToString("O", System.Globalization.CultureInfo.InvariantCulture);
return Results.Ok(new
{
simulationsPerDay = 1000,
simulationsUsed = 0,
evaluationsPerDay = 5000,
evaluationsUsed = 0,
resetAt
});
})
.WithTags("Policy Quota")
.WithName("PolicyQuota.Get")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOps.Auth.Abstractions.StellaOpsScopes.PolicyRead));
// ── End merged gateway endpoints ────────────────────────────────────────────
app.TryRefreshStellaRouterEndpoints(routerEnabled);
app.Run();

View File

@@ -0,0 +1,276 @@
// <copyright file="ApprovalWorkflowService.cs" company="StellaOps">
// Copyright (c) StellaOps. All rights reserved.
// Licensed under the BUSL-1.1 license.
// </copyright>
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Policy.Exceptions.Models;
using System.Collections.Immutable;
namespace StellaOps.Policy.Engine.Services.Gateway;
/// <summary>
/// Approval policy configuration per environment.
/// </summary>
public sealed record ApprovalPolicy
{
/// <summary>Environment name (dev, staging, prod).</summary>
public required string Environment { get; init; }
/// <summary>Number of required approvers.</summary>
public required int RequiredApprovers { get; init; }
/// <summary>Whether requester can approve their own exception.</summary>
public required bool RequesterCanApprove { get; init; }
/// <summary>Deadline for approval before auto-reject.</summary>
public required TimeSpan ApprovalDeadline { get; init; }
/// <summary>Roles allowed to approve.</summary>
public ImmutableArray<string> AllowedApproverRoles { get; init; } = [];
/// <summary>Whether to auto-approve in this environment.</summary>
public bool AutoApprove { get; init; }
}
/// <summary>
/// Options for approval workflow configuration.
/// </summary>
public sealed class ApprovalWorkflowOptions
{
/// <summary>Configuration section name.</summary>
public const string SectionName = "Policy:Exceptions:Approval";
/// <summary>Default policy for environments not explicitly configured.</summary>
public ApprovalPolicy DefaultPolicy { get; set; } = new()
{
Environment = "default",
RequiredApprovers = 1,
RequesterCanApprove = false,
ApprovalDeadline = TimeSpan.FromDays(7),
AutoApprove = false
};
/// <summary>Environment-specific policies.</summary>
public Dictionary<string, ApprovalPolicy> EnvironmentPolicies { get; set; } = new()
{
["dev"] = new ApprovalPolicy
{
Environment = "dev",
RequiredApprovers = 0,
RequesterCanApprove = true,
ApprovalDeadline = TimeSpan.FromDays(30),
AutoApprove = true
},
["staging"] = new ApprovalPolicy
{
Environment = "staging",
RequiredApprovers = 1,
RequesterCanApprove = false,
ApprovalDeadline = TimeSpan.FromDays(14)
},
["prod"] = new ApprovalPolicy
{
Environment = "prod",
RequiredApprovers = 2,
RequesterCanApprove = false,
ApprovalDeadline = TimeSpan.FromDays(7),
AllowedApproverRoles = ["security-lead", "security-admin"]
}
};
}
/// <summary>
/// Result of approval validation.
/// </summary>
public sealed record ApprovalValidationResult
{
/// <summary>Whether approval is valid.</summary>
public bool IsValid { get; init; }
/// <summary>Error message if invalid.</summary>
public string? Error { get; init; }
/// <summary>Whether this approval completes the workflow.</summary>
public bool IsComplete { get; init; }
/// <summary>Number of additional approvals needed.</summary>
public int ApprovalsRemaining { get; init; }
/// <summary>Creates a valid result.</summary>
public static ApprovalValidationResult Valid(bool isComplete, int remaining = 0) => new()
{
IsValid = true,
IsComplete = isComplete,
ApprovalsRemaining = remaining
};
/// <summary>Creates an invalid result.</summary>
public static ApprovalValidationResult Invalid(string error) => new()
{
IsValid = false,
Error = error
};
}
/// <summary>
/// Service for managing exception approval workflow.
/// </summary>
public interface IApprovalWorkflowService
{
/// <summary>
/// Gets the approval policy for an environment.
/// </summary>
ApprovalPolicy GetPolicyForEnvironment(string environment);
/// <summary>
/// Validates whether an approval is allowed.
/// </summary>
/// <param name="exception">The exception being approved.</param>
/// <param name="approverId">The ID of the approver.</param>
/// <param name="approverRoles">Roles of the approver.</param>
/// <returns>Validation result.</returns>
ApprovalValidationResult ValidateApproval(
ExceptionObject exception,
string approverId,
IReadOnlyList<string>? approverRoles = null);
/// <summary>
/// Checks if an exception should be auto-approved.
/// </summary>
bool ShouldAutoApprove(ExceptionObject exception);
/// <summary>
/// Checks if an exception approval has expired (deadline passed).
/// </summary>
bool IsApprovalExpired(ExceptionObject exception);
/// <summary>
/// Gets the deadline for exception approval.
/// </summary>
DateTimeOffset GetApprovalDeadline(ExceptionObject exception);
}
/// <summary>
/// Implementation of approval workflow service.
/// </summary>
public sealed class ApprovalWorkflowService : IApprovalWorkflowService
{
private readonly ApprovalWorkflowOptions _options;
private readonly TimeProvider _timeProvider;
private readonly IExceptionNotificationService _notificationService;
private readonly ILogger<ApprovalWorkflowService> _logger;
/// <summary>
/// Creates a new approval workflow service.
/// </summary>
public ApprovalWorkflowService(
IOptions<ApprovalWorkflowOptions> options,
TimeProvider timeProvider,
IExceptionNotificationService notificationService,
ILogger<ApprovalWorkflowService> logger)
{
_options = options.Value;
_timeProvider = timeProvider;
_notificationService = notificationService;
_logger = logger;
}
/// <inheritdoc />
public ApprovalPolicy GetPolicyForEnvironment(string environment)
{
if (_options.EnvironmentPolicies.TryGetValue(environment.ToLowerInvariant(), out var policy))
{
return policy;
}
return _options.DefaultPolicy;
}
/// <inheritdoc />
public ApprovalValidationResult ValidateApproval(
ExceptionObject exception,
string approverId,
IReadOnlyList<string>? approverRoles = null)
{
// Determine environment from scope
var environment = exception.Scope.Environments.Length > 0
? exception.Scope.Environments[0]
: "default";
var policy = GetPolicyForEnvironment(environment);
// Check if self-approval is allowed
if (approverId == exception.RequesterId && !policy.RequesterCanApprove)
{
return ApprovalValidationResult.Invalid("Requester cannot approve their own exception in this environment.");
}
// Check if approver already approved
if (exception.ApproverIds.Contains(approverId))
{
return ApprovalValidationResult.Invalid("You have already approved this exception.");
}
// Check role requirements
if (policy.AllowedApproverRoles.Length > 0)
{
var hasRequiredRole = approverRoles?.Any(r =>
policy.AllowedApproverRoles.Contains(r, StringComparer.OrdinalIgnoreCase)) ?? false;
if (!hasRequiredRole)
{
return ApprovalValidationResult.Invalid(
$"Approval requires one of these roles: {string.Join(", ", policy.AllowedApproverRoles)}");
}
}
// Check approval deadline
if (IsApprovalExpired(exception))
{
return ApprovalValidationResult.Invalid("Approval deadline has passed. Exception must be re-submitted.");
}
// Calculate remaining approvals needed
var currentApprovals = exception.ApproverIds.Length + 1; // +1 for this approval
var remaining = Math.Max(0, policy.RequiredApprovers - currentApprovals);
var isComplete = remaining == 0;
_logger.LogDebug(
"Approval validated for {ExceptionId}: current={Current}, required={Required}, complete={Complete}",
exception.ExceptionId, currentApprovals, policy.RequiredApprovers, isComplete);
return ApprovalValidationResult.Valid(isComplete, remaining);
}
/// <inheritdoc />
public bool ShouldAutoApprove(ExceptionObject exception)
{
var environment = exception.Scope.Environments.Length > 0
? exception.Scope.Environments[0]
: "default";
var policy = GetPolicyForEnvironment(environment);
return policy.AutoApprove;
}
/// <inheritdoc />
public bool IsApprovalExpired(ExceptionObject exception)
{
var deadline = GetApprovalDeadline(exception);
return _timeProvider.GetUtcNow() > deadline;
}
/// <inheritdoc />
public DateTimeOffset GetApprovalDeadline(ExceptionObject exception)
{
var environment = exception.Scope.Environments.Length > 0
? exception.Scope.Environments[0]
: "default";
var policy = GetPolicyForEnvironment(environment);
return exception.CreatedAt.Add(policy.ApprovalDeadline);
}
}

View File

@@ -0,0 +1,67 @@
// SPDX-License-Identifier: BUSL-1.1
// Sprint: SPRINT_4100_0004_0001 - Security State Delta & Verdict
// Task: T6 - Add Delta API endpoints
using StellaOps.Policy.Deltas;
using StellaOps.Policy.Snapshots;
namespace StellaOps.Policy.Engine.Services.Gateway;
/// <summary>
/// Adapter that bridges between the KnowledgeSnapshotManifest-based snapshot store
/// and the SnapshotData interface required by the DeltaComputer.
/// </summary>
public sealed class DeltaSnapshotServiceAdapter : StellaOps.Policy.Deltas.ISnapshotService
{
private readonly ISnapshotStore _snapshotStore;
private readonly ILogger<DeltaSnapshotServiceAdapter> _logger;
public DeltaSnapshotServiceAdapter(
ISnapshotStore snapshotStore,
ILogger<DeltaSnapshotServiceAdapter> logger)
{
_snapshotStore = snapshotStore ?? throw new ArgumentNullException(nameof(snapshotStore));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>
/// Gets snapshot data by ID, converting from KnowledgeSnapshotManifest.
/// </summary>
public async Task<SnapshotData?> GetSnapshotAsync(string snapshotId, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(snapshotId))
{
return null;
}
var manifest = await _snapshotStore.GetAsync(snapshotId, ct).ConfigureAwait(false);
if (manifest is null)
{
_logger.LogDebug("Snapshot {SnapshotId} not found in store", snapshotId);
return null;
}
return ConvertToSnapshotData(manifest);
}
private static SnapshotData ConvertToSnapshotData(KnowledgeSnapshotManifest manifest)
{
// Get policy version from manifest sources
var policySource = manifest.Sources.FirstOrDefault(s => s.Type == KnowledgeSourceTypes.Policy);
var policyVersion = policySource?.Digest;
// Note: In a full implementation, we would fetch and parse the bundled content
// from each source to extract packages, reachability, VEX statements, etc.
// For now, we return the manifest metadata only.
return new SnapshotData
{
SnapshotId = manifest.SnapshotId,
Packages = [],
Reachability = [],
VexStatements = [],
PolicyViolations = [],
Unknowns = [],
PolicyVersion = policyVersion
};
}
}

View File

@@ -0,0 +1,236 @@
// <copyright file="ExceptionExpiryWorker.cs" company="StellaOps">
// Copyright (c) StellaOps. All rights reserved.
// Licensed under the BUSL-1.1 license.
// </copyright>
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Policy.Exceptions.Models;
using StellaOps.Policy.Exceptions.Repositories;
using System.Diagnostics;
namespace StellaOps.Policy.Engine.Services.Gateway;
/// <summary>
/// Options for exception expiry worker.
/// </summary>
public sealed class ExceptionExpiryOptions
{
/// <summary>Configuration section name.</summary>
public const string SectionName = "Policy:Exceptions:Expiry";
/// <summary>Whether the worker is enabled.</summary>
public bool Enabled { get; set; } = true;
/// <summary>Interval between expiry checks.</summary>
public TimeSpan Interval { get; set; } = TimeSpan.FromHours(1);
/// <summary>Warning horizon for expiry notifications.</summary>
public TimeSpan WarningHorizon { get; set; } = TimeSpan.FromDays(7);
/// <summary>Initial delay before first run.</summary>
public TimeSpan InitialDelay { get; set; } = TimeSpan.FromSeconds(30);
}
/// <summary>
/// Background worker that marks expired exceptions and sends expiry warnings.
/// Runs hourly by default.
/// </summary>
public sealed class ExceptionExpiryWorker : BackgroundService
{
private const string SystemActorId = "system:expiry-worker";
private readonly IServiceScopeFactory _scopeFactory;
private readonly IOptions<ExceptionExpiryOptions> _options;
private readonly TimeProvider _timeProvider;
private readonly ILogger<ExceptionExpiryWorker> _logger;
private readonly ActivitySource _activitySource = new("StellaOps.Policy.ExceptionExpiry");
/// <summary>
/// Creates a new exception expiry worker.
/// </summary>
public ExceptionExpiryWorker(
IServiceScopeFactory scopeFactory,
IOptions<ExceptionExpiryOptions> options,
TimeProvider timeProvider,
ILogger<ExceptionExpiryWorker> logger)
{
_scopeFactory = scopeFactory;
_options = options;
_timeProvider = timeProvider;
_logger = logger;
}
/// <inheritdoc />
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("Exception expiry worker started");
// Initial delay to let the system stabilize
await Task.Delay(_options.Value.InitialDelay, stoppingToken);
while (!stoppingToken.IsCancellationRequested)
{
var opts = _options.Value;
if (!opts.Enabled)
{
_logger.LogDebug("Exception expiry worker is disabled");
await Task.Delay(opts.Interval, stoppingToken);
continue;
}
using var activity = _activitySource.StartActivity("exception.expiry.check", ActivityKind.Internal);
try
{
await using var scope = _scopeFactory.CreateAsyncScope();
await RunExpiryCycleAsync(scope.ServiceProvider, opts, stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
_logger.LogError(ex, "Exception expiry cycle failed");
activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
}
await Task.Delay(opts.Interval, stoppingToken);
}
_logger.LogInformation("Exception expiry worker stopped");
}
private async Task RunExpiryCycleAsync(
IServiceProvider services,
ExceptionExpiryOptions options,
CancellationToken cancellationToken)
{
var repository = services.GetRequiredService<IExceptionRepository>();
var notificationService = services.GetRequiredService<IExceptionNotificationService>();
// Process expired exceptions
var expiredCount = await ProcessExpiredExceptionsAsync(repository, cancellationToken);
// Send warnings for exceptions expiring soon
var warnedCount = await ProcessExpiringWarningsAsync(
repository, notificationService, options.WarningHorizon, cancellationToken);
if (expiredCount > 0 || warnedCount > 0)
{
_logger.LogInformation(
"Exception expiry cycle complete: {ExpiredCount} expired, {WarnedCount} warnings sent",
expiredCount, warnedCount);
}
}
private async Task<int> ProcessExpiredExceptionsAsync(
IExceptionRepository repository,
CancellationToken cancellationToken)
{
var expired = await repository.GetExpiredActiveAsync(cancellationToken);
if (expired.Count == 0)
{
return 0;
}
_logger.LogDebug("Found {Count} expired active exceptions to process", expired.Count);
var processedCount = 0;
foreach (var exception in expired)
{
try
{
var updated = exception with
{
Version = exception.Version + 1,
Status = ExceptionStatus.Expired,
UpdatedAt = _timeProvider.GetUtcNow()
};
await repository.UpdateAsync(
updated,
ExceptionEventType.Expired,
SystemActorId,
"Exception expired automatically",
"system:expiry-worker",
cancellationToken);
_logger.LogInformation(
"Exception {ExceptionId} marked as expired",
exception.ExceptionId);
processedCount++;
}
catch (ConcurrencyException)
{
_logger.LogWarning(
"Concurrency conflict expiring exception {ExceptionId}, will retry next cycle",
exception.ExceptionId);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to expire exception {ExceptionId}", exception.ExceptionId);
}
}
return processedCount;
}
private async Task<int> ProcessExpiringWarningsAsync(
IExceptionRepository repository,
IExceptionNotificationService notificationService,
TimeSpan horizon,
CancellationToken cancellationToken)
{
var expiring = await repository.GetExpiringAsync(horizon, cancellationToken);
if (expiring.Count == 0)
{
return 0;
}
_logger.LogDebug("Found {Count} exceptions expiring within {Horizon}", expiring.Count, horizon);
var now = _timeProvider.GetUtcNow();
var notifiedCount = 0;
foreach (var exception in expiring)
{
try
{
var timeUntilExpiry = exception.ExpiresAt - now;
// Only warn once per day threshold (1 day, 3 days, 7 days)
if (ShouldSendWarning(timeUntilExpiry))
{
await notificationService.NotifyExceptionExpiringSoonAsync(
exception, timeUntilExpiry, cancellationToken);
notifiedCount++;
}
}
catch (Exception ex)
{
_logger.LogError(ex,
"Failed to send expiry warning for exception {ExceptionId}",
exception.ExceptionId);
}
}
return notifiedCount;
}
private static bool ShouldSendWarning(TimeSpan timeUntilExpiry)
{
// Send warnings at specific thresholds
var days = (int)timeUntilExpiry.TotalDays;
// Warn at 7 days, 3 days, 1 day
return days is 7 or 3 or 1;
}
}

View File

@@ -0,0 +1,228 @@
// <copyright file="ExceptionQueryService.cs" company="StellaOps">
// Copyright (c) StellaOps. All rights reserved.
// Licensed under the BUSL-1.1 license.
// </copyright>
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using StellaOps.Policy.Exceptions.Models;
using StellaOps.Policy.Exceptions.Repositories;
using System.Collections.Concurrent;
namespace StellaOps.Policy.Engine.Services.Gateway;
/// <summary>
/// Service interface for optimized exception queries.
/// </summary>
public interface IExceptionQueryService
{
/// <summary>
/// Gets active exceptions that apply to a finding.
/// </summary>
/// <param name="scope">The scope to match against.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>List of applicable active exceptions.</returns>
Task<IReadOnlyList<ExceptionObject>> GetApplicableExceptionsAsync(
ExceptionScope scope,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets exceptions expiring within the given horizon.
/// </summary>
/// <param name="horizon">Time horizon for expiry check.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>List of exceptions expiring soon.</returns>
Task<IReadOnlyList<ExceptionObject>> GetExpiringExceptionsAsync(
TimeSpan horizon,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets exceptions matching a specific scope.
/// </summary>
/// <param name="scope">The scope to match.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>List of matching exceptions.</returns>
Task<IReadOnlyList<ExceptionObject>> GetExceptionsByScopeAsync(
ExceptionScope scope,
CancellationToken cancellationToken = default);
/// <summary>
/// Checks if a finding is covered by an active exception.
/// </summary>
/// <param name="vulnerabilityId">Vulnerability ID to check.</param>
/// <param name="purl">Package URL to check.</param>
/// <param name="environment">Environment to check.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The covering exception if found, null otherwise.</returns>
Task<ExceptionObject?> FindCoveringExceptionAsync(
string? vulnerabilityId,
string? purl,
string? environment,
CancellationToken cancellationToken = default);
/// <summary>
/// Invalidates any cached exception data.
/// </summary>
void InvalidateCache();
}
/// <summary>
/// Implementation of exception query service with caching.
/// </summary>
public sealed class ExceptionQueryService : IExceptionQueryService
{
private static readonly TimeSpan DefaultCacheDuration = TimeSpan.FromMinutes(5);
private const string ActiveExceptionsCacheKey = "exceptions:active:all";
private readonly IExceptionRepository _repository;
private readonly IMemoryCache _cache;
private readonly TimeProvider _timeProvider;
private readonly ILogger<ExceptionQueryService> _logger;
/// <summary>
/// Creates a new exception query service.
/// </summary>
public ExceptionQueryService(
IExceptionRepository repository,
IMemoryCache cache,
TimeProvider timeProvider,
ILogger<ExceptionQueryService> logger)
{
_repository = repository;
_cache = cache;
_timeProvider = timeProvider;
_logger = logger;
}
/// <inheritdoc />
public async Task<IReadOnlyList<ExceptionObject>> GetApplicableExceptionsAsync(
ExceptionScope scope,
CancellationToken cancellationToken = default)
{
// Get all active exceptions that could match this scope
var activeExceptions = await _repository.GetActiveByScopeAsync(scope, cancellationToken);
// Filter by environment if specified in scope
if (scope.Environments.Length > 0)
{
activeExceptions = activeExceptions
.Where(e => e.Scope.Environments.Length == 0 ||
e.Scope.Environments.Any(env => scope.Environments.Contains(env)))
.ToList();
}
_logger.LogDebug(
"Found {Count} applicable exceptions for scope: vuln={VulnId}, purl={Purl}",
activeExceptions.Count,
scope.VulnerabilityId,
scope.PurlPattern);
return activeExceptions;
}
/// <inheritdoc />
public async Task<IReadOnlyList<ExceptionObject>> GetExpiringExceptionsAsync(
TimeSpan horizon,
CancellationToken cancellationToken = default)
{
return await _repository.GetExpiringAsync(horizon, cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<ExceptionObject>> GetExceptionsByScopeAsync(
ExceptionScope scope,
CancellationToken cancellationToken = default)
{
return await _repository.GetActiveByScopeAsync(scope, cancellationToken);
}
/// <inheritdoc />
public async Task<ExceptionObject?> FindCoveringExceptionAsync(
string? vulnerabilityId,
string? purl,
string? environment,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrEmpty(vulnerabilityId) && string.IsNullOrEmpty(purl))
{
return null;
}
var scope = new ExceptionScope
{
VulnerabilityId = vulnerabilityId,
PurlPattern = purl,
Environments = string.IsNullOrEmpty(environment) ? [] : [environment]
};
var exceptions = await GetApplicableExceptionsAsync(scope, cancellationToken);
// Return the most specific matching exception
// Priority: exact PURL match > wildcard PURL > vulnerability-only
return exceptions
.OrderByDescending(e => GetSpecificityScore(e, vulnerabilityId, purl))
.FirstOrDefault();
}
/// <inheritdoc />
public void InvalidateCache()
{
_cache.Remove(ActiveExceptionsCacheKey);
_logger.LogDebug("Exception cache invalidated");
}
private static int GetSpecificityScore(ExceptionObject exception, string? vulnerabilityId, string? purl)
{
var score = 0;
// Exact vulnerability match
if (!string.IsNullOrEmpty(exception.Scope.VulnerabilityId) &&
exception.Scope.VulnerabilityId == vulnerabilityId)
{
score += 100;
}
// PURL matching
if (!string.IsNullOrEmpty(exception.Scope.PurlPattern) && !string.IsNullOrEmpty(purl))
{
if (exception.Scope.PurlPattern == purl)
{
score += 50; // Exact match
}
else if (MatchesPurlPattern(purl, exception.Scope.PurlPattern))
{
score += 25; // Wildcard match
}
}
// Artifact digest match (most specific)
if (!string.IsNullOrEmpty(exception.Scope.ArtifactDigest))
{
score += 200;
}
// Environment specificity
if (exception.Scope.Environments.Length > 0)
{
score += 10;
}
return score;
}
private static bool MatchesPurlPattern(string purl, string pattern)
{
// Simple wildcard matching: pkg:npm/lodash@* matches pkg:npm/lodash@4.17.21
if (!pattern.Contains('*'))
{
return pattern.Equals(purl, StringComparison.OrdinalIgnoreCase);
}
// Split on wildcard and check prefix match
var prefixEnd = pattern.IndexOf('*');
var prefix = pattern[..prefixEnd];
return purl.StartsWith(prefix, StringComparison.OrdinalIgnoreCase);
}
}

View File

@@ -0,0 +1,606 @@
// <copyright file="ExceptionService.cs" company="StellaOps">
// Copyright (c) StellaOps. All rights reserved.
// Licensed under the BUSL-1.1 license.
// </copyright>
using Microsoft.Extensions.Logging;
using StellaOps.Determinism;
using StellaOps.Policy.Exceptions.Models;
using StellaOps.Policy.Exceptions.Repositories;
using System.Collections.Immutable;
namespace StellaOps.Policy.Engine.Services.Gateway;
/// <summary>
/// Service for managing exception lifecycle with business logic validation.
/// </summary>
public sealed class ExceptionService : IExceptionService
{
private const int MinRationaleLength = 50;
private static readonly TimeSpan MaxExpiryHorizon = TimeSpan.FromDays(365);
private readonly IExceptionRepository _repository;
private readonly IExceptionNotificationService _notificationService;
private readonly TimeProvider _timeProvider;
private readonly IGuidProvider _guidProvider;
private readonly ILogger<ExceptionService> _logger;
/// <summary>
/// Creates a new exception service.
/// </summary>
public ExceptionService(
IExceptionRepository repository,
IExceptionNotificationService notificationService,
TimeProvider timeProvider,
IGuidProvider guidProvider,
ILogger<ExceptionService> logger)
{
_repository = repository;
_notificationService = notificationService;
_timeProvider = timeProvider;
_guidProvider = guidProvider;
_logger = logger;
}
/// <inheritdoc />
public async Task<ExceptionResult> CreateAsync(
CreateExceptionCommand request,
string actorId,
string? clientInfo = null,
CancellationToken cancellationToken = default)
{
var now = _timeProvider.GetUtcNow();
// Validate scope is specific enough
var scopeValidation = ValidateScope(request.Scope);
if (!scopeValidation.IsValid)
{
return ExceptionResult.Failure(ExceptionErrorCode.ScopeNotSpecific, scopeValidation.Error!);
}
// Validate expiry
var expiryValidation = ValidateExpiry(request.ExpiresAt, now);
if (!expiryValidation.IsValid)
{
return ExceptionResult.Failure(ExceptionErrorCode.ExpiryInvalid, expiryValidation.Error!);
}
// Validate rationale
if (string.IsNullOrWhiteSpace(request.Rationale) || request.Rationale.Length < MinRationaleLength)
{
return ExceptionResult.Failure(
ExceptionErrorCode.RationaleTooShort,
$"Rationale must be at least {MinRationaleLength} characters.");
}
var exceptionId = GenerateExceptionId();
var exception = new ExceptionObject
{
ExceptionId = exceptionId,
Version = 1,
Status = ExceptionStatus.Proposed,
Type = request.Type,
Scope = request.Scope,
OwnerId = request.OwnerId,
RequesterId = actorId,
CreatedAt = now,
UpdatedAt = now,
ExpiresAt = request.ExpiresAt,
ReasonCode = request.ReasonCode,
Rationale = request.Rationale,
EvidenceRefs = request.EvidenceRefs?.ToImmutableArray() ?? [],
CompensatingControls = request.CompensatingControls?.ToImmutableArray() ?? [],
TicketRef = request.TicketRef,
Metadata = request.Metadata?.ToImmutableDictionary() ?? ImmutableDictionary<string, string>.Empty
};
try
{
var created = await _repository.CreateAsync(exception, actorId, clientInfo, cancellationToken);
_logger.LogInformation(
"Exception {ExceptionId} created by {ActorId} for {Type}",
exceptionId, actorId, request.Type);
await _notificationService.NotifyExceptionCreatedAsync(created, cancellationToken);
return ExceptionResult.Success(created);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to create exception for {ActorId}", actorId);
throw;
}
}
/// <inheritdoc />
public async Task<ExceptionResult> UpdateAsync(
string exceptionId,
UpdateExceptionCommand request,
string actorId,
string? clientInfo = null,
CancellationToken cancellationToken = default)
{
var existing = await _repository.GetByIdAsync(exceptionId, cancellationToken);
if (existing is null)
{
return ExceptionResult.Failure(ExceptionErrorCode.NotFound, "Exception not found.");
}
// Check state allows updates
if (existing.Status is ExceptionStatus.Expired or ExceptionStatus.Revoked)
{
return ExceptionResult.Failure(
ExceptionErrorCode.InvalidStateTransition,
"Cannot update an expired or revoked exception.");
}
// Validate rationale if provided
if (request.Rationale is not null && request.Rationale.Length < MinRationaleLength)
{
return ExceptionResult.Failure(
ExceptionErrorCode.RationaleTooShort,
$"Rationale must be at least {MinRationaleLength} characters.");
}
var now = _timeProvider.GetUtcNow();
var updated = existing with
{
Version = existing.Version + 1,
UpdatedAt = now,
Rationale = request.Rationale ?? existing.Rationale,
EvidenceRefs = request.EvidenceRefs?.ToImmutableArray() ?? existing.EvidenceRefs,
CompensatingControls = request.CompensatingControls?.ToImmutableArray() ?? existing.CompensatingControls,
TicketRef = request.TicketRef ?? existing.TicketRef,
Metadata = request.Metadata?.ToImmutableDictionary() ?? existing.Metadata
};
try
{
var result = await _repository.UpdateAsync(
updated,
ExceptionEventType.Updated,
actorId,
"Exception updated",
clientInfo,
cancellationToken);
_logger.LogInformation(
"Exception {ExceptionId} updated by {ActorId}",
exceptionId, actorId);
return ExceptionResult.Success(result);
}
catch (ConcurrencyException)
{
return ExceptionResult.Failure(
ExceptionErrorCode.ConcurrencyConflict,
"Exception was modified by another user. Please refresh and try again.");
}
}
/// <inheritdoc />
public async Task<ExceptionResult> ApproveAsync(
string exceptionId,
string? comment,
string actorId,
string? clientInfo = null,
CancellationToken cancellationToken = default)
{
var existing = await _repository.GetByIdAsync(exceptionId, cancellationToken);
if (existing is null)
{
return ExceptionResult.Failure(ExceptionErrorCode.NotFound, "Exception not found.");
}
// Validate state transition
if (existing.Status != ExceptionStatus.Proposed)
{
return ExceptionResult.Failure(
ExceptionErrorCode.InvalidStateTransition,
"Only proposed exceptions can be approved.");
}
// Validate approver is not requester
if (actorId == existing.RequesterId)
{
return ExceptionResult.Failure(
ExceptionErrorCode.SelfApprovalNotAllowed,
"Requester cannot approve their own exception.");
}
var now = _timeProvider.GetUtcNow();
var updated = existing with
{
Version = existing.Version + 1,
Status = ExceptionStatus.Approved,
UpdatedAt = now,
ApprovedAt = now,
ApproverIds = existing.ApproverIds.Add(actorId)
};
try
{
var result = await _repository.UpdateAsync(
updated,
ExceptionEventType.Approved,
actorId,
comment ?? "Exception approved",
clientInfo,
cancellationToken);
_logger.LogInformation(
"Exception {ExceptionId} approved by {ActorId}",
exceptionId, actorId);
await _notificationService.NotifyExceptionApprovedAsync(result, actorId, cancellationToken);
return ExceptionResult.Success(result);
}
catch (ConcurrencyException)
{
return ExceptionResult.Failure(
ExceptionErrorCode.ConcurrencyConflict,
"Exception was modified by another user. Please refresh and try again.");
}
}
/// <inheritdoc />
public async Task<ExceptionResult> ActivateAsync(
string exceptionId,
string actorId,
string? clientInfo = null,
CancellationToken cancellationToken = default)
{
var existing = await _repository.GetByIdAsync(exceptionId, cancellationToken);
if (existing is null)
{
return ExceptionResult.Failure(ExceptionErrorCode.NotFound, "Exception not found.");
}
// Validate state transition
if (existing.Status != ExceptionStatus.Approved)
{
return ExceptionResult.Failure(
ExceptionErrorCode.InvalidStateTransition,
"Only approved exceptions can be activated.");
}
var now = _timeProvider.GetUtcNow();
var updated = existing with
{
Version = existing.Version + 1,
Status = ExceptionStatus.Active,
UpdatedAt = now
};
try
{
var result = await _repository.UpdateAsync(
updated,
ExceptionEventType.Activated,
actorId,
"Exception activated",
clientInfo,
cancellationToken);
_logger.LogInformation(
"Exception {ExceptionId} activated by {ActorId}",
exceptionId, actorId);
await _notificationService.NotifyExceptionActivatedAsync(result, cancellationToken);
return ExceptionResult.Success(result);
}
catch (ConcurrencyException)
{
return ExceptionResult.Failure(
ExceptionErrorCode.ConcurrencyConflict,
"Exception was modified by another user. Please refresh and try again.");
}
}
/// <inheritdoc />
public async Task<ExceptionResult> ExtendAsync(
string exceptionId,
DateTimeOffset newExpiresAt,
string reason,
string actorId,
string? clientInfo = null,
CancellationToken cancellationToken = default)
{
var existing = await _repository.GetByIdAsync(exceptionId, cancellationToken);
if (existing is null)
{
return ExceptionResult.Failure(ExceptionErrorCode.NotFound, "Exception not found.");
}
// Validate state
if (existing.Status != ExceptionStatus.Active)
{
return ExceptionResult.Failure(
ExceptionErrorCode.InvalidStateTransition,
"Only active exceptions can be extended.");
}
// Validate new expiry is after current
if (newExpiresAt <= existing.ExpiresAt)
{
return ExceptionResult.Failure(
ExceptionErrorCode.ExpiryInvalid,
"New expiry must be after current expiry.");
}
// Validate reason length
if (string.IsNullOrWhiteSpace(reason) || reason.Length < 20)
{
return ExceptionResult.Failure(
ExceptionErrorCode.ValidationFailed,
"Extension reason must be at least 20 characters.");
}
var now = _timeProvider.GetUtcNow();
var updated = existing with
{
Version = existing.Version + 1,
UpdatedAt = now,
ExpiresAt = newExpiresAt
};
try
{
var result = await _repository.UpdateAsync(
updated,
ExceptionEventType.Extended,
actorId,
reason,
clientInfo,
cancellationToken);
_logger.LogInformation(
"Exception {ExceptionId} extended by {ActorId} to {NewExpiry}",
exceptionId, actorId, newExpiresAt);
await _notificationService.NotifyExceptionExtendedAsync(result, newExpiresAt, cancellationToken);
return ExceptionResult.Success(result);
}
catch (ConcurrencyException)
{
return ExceptionResult.Failure(
ExceptionErrorCode.ConcurrencyConflict,
"Exception was modified by another user. Please refresh and try again.");
}
}
/// <inheritdoc />
public async Task<ExceptionResult> RevokeAsync(
string exceptionId,
string reason,
string actorId,
string? clientInfo = null,
CancellationToken cancellationToken = default)
{
var existing = await _repository.GetByIdAsync(exceptionId, cancellationToken);
if (existing is null)
{
return ExceptionResult.Failure(ExceptionErrorCode.NotFound, "Exception not found.");
}
// Validate state
if (existing.Status is ExceptionStatus.Expired or ExceptionStatus.Revoked)
{
return ExceptionResult.Failure(
ExceptionErrorCode.InvalidStateTransition,
"Exception is already expired or revoked.");
}
// Validate reason length
if (string.IsNullOrWhiteSpace(reason) || reason.Length < 10)
{
return ExceptionResult.Failure(
ExceptionErrorCode.ValidationFailed,
"Revocation reason must be at least 10 characters.");
}
var now = _timeProvider.GetUtcNow();
var updated = existing with
{
Version = existing.Version + 1,
Status = ExceptionStatus.Revoked,
UpdatedAt = now
};
try
{
var result = await _repository.UpdateAsync(
updated,
ExceptionEventType.Revoked,
actorId,
reason,
clientInfo,
cancellationToken);
_logger.LogInformation(
"Exception {ExceptionId} revoked by {ActorId}",
exceptionId, actorId);
await _notificationService.NotifyExceptionRevokedAsync(result, reason, cancellationToken);
return ExceptionResult.Success(result);
}
catch (ConcurrencyException)
{
return ExceptionResult.Failure(
ExceptionErrorCode.ConcurrencyConflict,
"Exception was modified by another user. Please refresh and try again.");
}
}
/// <inheritdoc />
public async Task<ExceptionObject?> GetByIdAsync(
string exceptionId,
CancellationToken cancellationToken = default)
{
return await _repository.GetByIdAsync(exceptionId, cancellationToken);
}
/// <inheritdoc />
public async Task<(IReadOnlyList<ExceptionObject> Items, int TotalCount)> ListAsync(
ExceptionFilter filter,
CancellationToken cancellationToken = default)
{
var items = await _repository.GetByFilterAsync(filter, cancellationToken);
var counts = await _repository.GetCountsAsync(filter.TenantId, cancellationToken);
return (items, counts.Total);
}
/// <inheritdoc />
public async Task<ExceptionCounts> GetCountsAsync(
Guid? tenantId = null,
CancellationToken cancellationToken = default)
{
return await _repository.GetCountsAsync(tenantId, cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<ExceptionObject>> GetExpiringAsync(
TimeSpan horizon,
CancellationToken cancellationToken = default)
{
return await _repository.GetExpiringAsync(horizon, cancellationToken);
}
/// <inheritdoc />
public async Task<ExceptionHistory> GetHistoryAsync(
string exceptionId,
CancellationToken cancellationToken = default)
{
return await _repository.GetHistoryAsync(exceptionId, cancellationToken);
}
#region Validation Helpers
private static (bool IsValid, string? Error) ValidateScope(ExceptionScope scope)
{
// Scope must have at least one specific field
var hasArtifact = !string.IsNullOrEmpty(scope.ArtifactDigest);
var hasVulnerability = !string.IsNullOrEmpty(scope.VulnerabilityId);
var hasPurl = !string.IsNullOrEmpty(scope.PurlPattern);
var hasPolicy = !string.IsNullOrEmpty(scope.PolicyRuleId);
if (!hasArtifact && !hasVulnerability && !hasPurl && !hasPolicy)
{
return (false, "Exception scope must specify at least one of: artifactDigest, vulnerabilityId, purlPattern, or policyRuleId.");
}
// Validate PURL pattern if provided
if (hasPurl && !IsValidPurlPattern(scope.PurlPattern!))
{
return (false, "Invalid PURL pattern format. Must start with 'pkg:' and follow PURL specification.");
}
// Validate vulnerability ID format if provided
if (hasVulnerability && !IsValidVulnerabilityId(scope.VulnerabilityId!))
{
return (false, "Invalid vulnerability ID format. Must be CVE-XXXX-XXXXX, GHSA-xxxx-xxxx-xxxx, or similar.");
}
return (true, null);
}
private (bool IsValid, string? Error) ValidateExpiry(DateTimeOffset expiresAt, DateTimeOffset now)
{
if (expiresAt <= now)
{
return (false, "Expiry date must be in the future.");
}
if (expiresAt > now.Add(MaxExpiryHorizon))
{
return (false, $"Expiry date cannot be more than {MaxExpiryHorizon.Days} days in the future.");
}
return (true, null);
}
private static bool IsValidPurlPattern(string pattern)
{
// Basic PURL validation: must start with pkg: and have at least type/name
return pattern.StartsWith("pkg:", StringComparison.OrdinalIgnoreCase) &&
pattern.Contains('/');
}
private static bool IsValidVulnerabilityId(string id)
{
// Accept CVE, GHSA, OSV, and other common formats
return id.StartsWith("CVE-", StringComparison.OrdinalIgnoreCase) ||
id.StartsWith("GHSA-", StringComparison.OrdinalIgnoreCase) ||
id.StartsWith("OSV-", StringComparison.OrdinalIgnoreCase) ||
id.StartsWith("SNYK-", StringComparison.OrdinalIgnoreCase) ||
id.StartsWith("GO-", StringComparison.OrdinalIgnoreCase);
}
private string GenerateExceptionId()
{
// Format: EXC-{random alphanumeric}
return $"EXC-{_guidProvider.NewGuid():N}"[..20];
}
#endregion
}
/// <summary>
/// Service for sending exception-related notifications.
/// </summary>
public interface IExceptionNotificationService
{
/// <summary>Notifies that an exception was created.</summary>
Task NotifyExceptionCreatedAsync(ExceptionObject exception, CancellationToken cancellationToken = default);
/// <summary>Notifies that an exception was approved.</summary>
Task NotifyExceptionApprovedAsync(ExceptionObject exception, string approverId, CancellationToken cancellationToken = default);
/// <summary>Notifies that an exception was activated.</summary>
Task NotifyExceptionActivatedAsync(ExceptionObject exception, CancellationToken cancellationToken = default);
/// <summary>Notifies that an exception was extended.</summary>
Task NotifyExceptionExtendedAsync(ExceptionObject exception, DateTimeOffset newExpiry, CancellationToken cancellationToken = default);
/// <summary>Notifies that an exception was revoked.</summary>
Task NotifyExceptionRevokedAsync(ExceptionObject exception, string reason, CancellationToken cancellationToken = default);
/// <summary>Notifies that an exception is expiring soon.</summary>
Task NotifyExceptionExpiringSoonAsync(ExceptionObject exception, TimeSpan timeUntilExpiry, CancellationToken cancellationToken = default);
}
/// <summary>
/// No-op implementation of exception notification service.
/// </summary>
public sealed class NoOpExceptionNotificationService : IExceptionNotificationService
{
/// <inheritdoc />
public Task NotifyExceptionCreatedAsync(ExceptionObject exception, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
/// <inheritdoc />
public Task NotifyExceptionApprovedAsync(ExceptionObject exception, string approverId, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
/// <inheritdoc />
public Task NotifyExceptionActivatedAsync(ExceptionObject exception, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
/// <inheritdoc />
public Task NotifyExceptionExtendedAsync(ExceptionObject exception, DateTimeOffset newExpiry, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
/// <inheritdoc />
public Task NotifyExceptionRevokedAsync(ExceptionObject exception, string reason, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
/// <inheritdoc />
public Task NotifyExceptionExpiringSoonAsync(ExceptionObject exception, TimeSpan timeUntilExpiry, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
}

View File

@@ -0,0 +1,234 @@
// <copyright file="IExceptionService.cs" company="StellaOps">
// Copyright (c) StellaOps. All rights reserved.
// Licensed under the BUSL-1.1 license.
// </copyright>
using StellaOps.Policy.Exceptions.Models;
using StellaOps.Policy.Exceptions.Repositories;
namespace StellaOps.Policy.Engine.Services.Gateway;
/// <summary>
/// Service for managing exception lifecycle with business logic validation.
/// </summary>
public interface IExceptionService
{
/// <summary>
/// Creates a new exception with validation.
/// </summary>
/// <param name="request">Creation request details.</param>
/// <param name="actorId">ID of the user creating the exception.</param>
/// <param name="clientInfo">Client info for audit trail.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Result containing created exception or validation errors.</returns>
Task<ExceptionResult> CreateAsync(
CreateExceptionCommand request,
string actorId,
string? clientInfo = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Updates an existing exception.
/// </summary>
Task<ExceptionResult> UpdateAsync(
string exceptionId,
UpdateExceptionCommand request,
string actorId,
string? clientInfo = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Approves a proposed exception.
/// </summary>
Task<ExceptionResult> ApproveAsync(
string exceptionId,
string? comment,
string actorId,
string? clientInfo = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Activates an approved exception.
/// </summary>
Task<ExceptionResult> ActivateAsync(
string exceptionId,
string actorId,
string? clientInfo = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Extends an active exception's expiry date.
/// </summary>
Task<ExceptionResult> ExtendAsync(
string exceptionId,
DateTimeOffset newExpiresAt,
string reason,
string actorId,
string? clientInfo = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Revokes an exception.
/// </summary>
Task<ExceptionResult> RevokeAsync(
string exceptionId,
string reason,
string actorId,
string? clientInfo = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets an exception by ID.
/// </summary>
Task<ExceptionObject?> GetByIdAsync(
string exceptionId,
CancellationToken cancellationToken = default);
/// <summary>
/// Lists exceptions with filtering.
/// </summary>
Task<(IReadOnlyList<ExceptionObject> Items, int TotalCount)> ListAsync(
ExceptionFilter filter,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets exception counts summary.
/// </summary>
Task<ExceptionCounts> GetCountsAsync(
Guid? tenantId = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets exceptions expiring within the given horizon.
/// </summary>
Task<IReadOnlyList<ExceptionObject>> GetExpiringAsync(
TimeSpan horizon,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets exception audit history.
/// </summary>
Task<ExceptionHistory> GetHistoryAsync(
string exceptionId,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Command for creating an exception.
/// </summary>
public sealed record CreateExceptionCommand
{
/// <summary>Type of exception.</summary>
public required ExceptionType Type { get; init; }
/// <summary>Exception scope.</summary>
public required ExceptionScope Scope { get; init; }
/// <summary>Owner ID.</summary>
public required string OwnerId { get; init; }
/// <summary>Reason code.</summary>
public required ExceptionReason ReasonCode { get; init; }
/// <summary>Detailed rationale.</summary>
public required string Rationale { get; init; }
/// <summary>Expiry date.</summary>
public required DateTimeOffset ExpiresAt { get; init; }
/// <summary>Evidence references.</summary>
public IReadOnlyList<string>? EvidenceRefs { get; init; }
/// <summary>Compensating controls.</summary>
public IReadOnlyList<string>? CompensatingControls { get; init; }
/// <summary>Ticket reference.</summary>
public string? TicketRef { get; init; }
/// <summary>Metadata.</summary>
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
}
/// <summary>
/// Command for updating an exception.
/// </summary>
public sealed record UpdateExceptionCommand
{
/// <summary>Updated rationale.</summary>
public string? Rationale { get; init; }
/// <summary>Updated evidence references.</summary>
public IReadOnlyList<string>? EvidenceRefs { get; init; }
/// <summary>Updated compensating controls.</summary>
public IReadOnlyList<string>? CompensatingControls { get; init; }
/// <summary>Updated ticket reference.</summary>
public string? TicketRef { get; init; }
/// <summary>Updated metadata.</summary>
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
}
/// <summary>
/// Result of an exception operation.
/// </summary>
public sealed record ExceptionResult
{
/// <summary>Whether the operation succeeded.</summary>
public bool IsSuccess { get; init; }
/// <summary>The exception object if successful.</summary>
public ExceptionObject? Exception { get; init; }
/// <summary>Error message if failed.</summary>
public string? Error { get; init; }
/// <summary>Error code for programmatic handling.</summary>
public ExceptionErrorCode? ErrorCode { get; init; }
/// <summary>Creates a success result.</summary>
public static ExceptionResult Success(ExceptionObject exception) => new()
{
IsSuccess = true,
Exception = exception
};
/// <summary>Creates a failure result.</summary>
public static ExceptionResult Failure(ExceptionErrorCode code, string error) => new()
{
IsSuccess = false,
ErrorCode = code,
Error = error
};
}
/// <summary>
/// Error codes for exception operations.
/// </summary>
public enum ExceptionErrorCode
{
/// <summary>Exception not found.</summary>
NotFound,
/// <summary>Validation failed.</summary>
ValidationFailed,
/// <summary>Invalid state transition.</summary>
InvalidStateTransition,
/// <summary>Self-approval not allowed.</summary>
SelfApprovalNotAllowed,
/// <summary>Concurrency conflict.</summary>
ConcurrencyConflict,
/// <summary>Scope not specific enough.</summary>
ScopeNotSpecific,
/// <summary>Expiry invalid.</summary>
ExpiryInvalid,
/// <summary>Rationale too short.</summary>
RationaleTooShort
}

View File

@@ -0,0 +1,185 @@
// -----------------------------------------------------------------------------
// InMemoryGateEvaluationQueue.cs
// Sprint: SPRINT_20251226_001_BE_cicd_gate_integration
// Task: CICD-GATE-02 - Gate evaluation queue implementation
// Description: In-memory queue for gate evaluation jobs with background processing
// -----------------------------------------------------------------------------
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using StellaOps.Policy.Engine.Gates;
using StellaOps.Policy.Engine.Endpoints.Gateway;
using System.Threading.Channels;
namespace StellaOps.Policy.Engine.Services.Gateway;
/// <summary>
/// In-memory implementation of the gate evaluation queue.
/// Uses System.Threading.Channels for async producer-consumer pattern.
/// </summary>
public sealed class InMemoryGateEvaluationQueue : IGateEvaluationQueue
{
private readonly Channel<GateEvaluationJob> _channel;
private readonly ILogger<InMemoryGateEvaluationQueue> _logger;
private readonly TimeProvider _timeProvider;
public InMemoryGateEvaluationQueue(
ILogger<InMemoryGateEvaluationQueue> logger,
TimeProvider? timeProvider = null)
{
ArgumentNullException.ThrowIfNull(logger);
_logger = logger;
_timeProvider = timeProvider ?? TimeProvider.System;
// Bounded channel to prevent unbounded memory growth
_channel = Channel.CreateBounded<GateEvaluationJob>(new BoundedChannelOptions(1000)
{
FullMode = BoundedChannelFullMode.Wait,
SingleReader = false,
SingleWriter = false
});
}
/// <inheritdoc />
public async Task<string> EnqueueAsync(GateEvaluationRequest request, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
var jobId = GenerateJobId();
var job = new GateEvaluationJob
{
JobId = jobId,
Request = request,
QueuedAt = _timeProvider.GetUtcNow()
};
await _channel.Writer.WriteAsync(job, cancellationToken).ConfigureAwait(false);
_logger.LogDebug(
"Enqueued gate evaluation job {JobId} for {Repository}@{Digest}",
jobId,
request.Repository,
request.ImageDigest);
return jobId;
}
/// <summary>
/// Gets the channel reader for consuming jobs.
/// </summary>
public ChannelReader<GateEvaluationJob> Reader => _channel.Reader;
private string GenerateJobId()
{
// Format: gate-{timestamp}-{random}
var timestamp = _timeProvider.GetUtcNow().ToUnixTimeMilliseconds();
var random = Guid.NewGuid().ToString("N")[..8];
return $"gate-{timestamp}-{random}";
}
}
/// <summary>
/// A gate evaluation job in the queue.
/// </summary>
public sealed record GateEvaluationJob
{
public required string JobId { get; init; }
public required GateEvaluationRequest Request { get; init; }
public required DateTimeOffset QueuedAt { get; init; }
}
/// <summary>
/// Background service that processes gate evaluation jobs from the queue.
/// Orchestrates: image analysis -> drift delta computation -> gate evaluation.
/// </summary>
public sealed class GateEvaluationWorker : BackgroundService
{
private readonly InMemoryGateEvaluationQueue _queue;
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<GateEvaluationWorker> _logger;
public GateEvaluationWorker(
InMemoryGateEvaluationQueue queue,
IServiceScopeFactory scopeFactory,
ILogger<GateEvaluationWorker> logger)
{
ArgumentNullException.ThrowIfNull(queue);
ArgumentNullException.ThrowIfNull(scopeFactory);
ArgumentNullException.ThrowIfNull(logger);
_queue = queue;
_scopeFactory = scopeFactory;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("Gate evaluation worker starting");
await foreach (var job in _queue.Reader.ReadAllAsync(stoppingToken))
{
try
{
await ProcessJobAsync(job, stoppingToken).ConfigureAwait(false);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogError(ex,
"Error processing gate evaluation job {JobId} for {Repository}@{Digest}",
job.JobId,
job.Request.Repository,
job.Request.ImageDigest);
}
}
_logger.LogInformation("Gate evaluation worker stopping");
}
private async Task ProcessJobAsync(GateEvaluationJob job, CancellationToken cancellationToken)
{
_logger.LogInformation(
"Processing gate evaluation job {JobId} for {Repository}@{Digest}",
job.JobId,
job.Request.Repository,
job.Request.ImageDigest);
using var scope = _scopeFactory.CreateScope();
var evaluator = scope.ServiceProvider.GetRequiredService<IDriftGateEvaluator>();
// Build a minimal context for the gate evaluation.
// In production, this would involve:
// 1. Fetching or triggering a scan of the image
// 2. Computing the reachability delta against the baseline
// 3. Building the DriftGateContext with actual metrics
//
// For now, we create a placeholder context that represents "no drift detected"
// which allows the gate to pass. The full implementation requires Scanner integration.
var driftContext = new DriftGateContext
{
DeltaReachable = 0,
DeltaUnreachable = 0,
HasKevReachable = false,
BaseScanId = job.Request.BaselineRef,
HeadScanId = job.Request.ImageDigest
};
var evalRequest = new DriftGateRequest
{
Context = driftContext,
PolicyId = null, // Use default policy
AllowOverride = false
};
var result = await evaluator.EvaluateAsync(evalRequest, cancellationToken).ConfigureAwait(false);
_logger.LogInformation(
"Gate evaluation {JobId} completed: Decision={Decision}, GateCount={GateCount}",
job.JobId,
result.Decision,
result.Gates.Length);
// TODO: Store result and notify via webhook/event
// This will be implemented in CICD-GATE-03
}
}

View File

@@ -43,7 +43,10 @@
<ProjectReference Include="../../Telemetry/StellaOps.Telemetry.Core/StellaOps.Telemetry.Core/StellaOps.Telemetry.Core.csproj" />
<ProjectReference Include="../../Attestor/StellaOps.Attestor.Envelope/StellaOps.Attestor.Envelope.csproj" />
<ProjectReference Include="../StellaOps.Policy.RiskProfile/StellaOps.Policy.RiskProfile.csproj" />
<ProjectReference Include="../StellaOps.Policy.Scoring/StellaOps.Policy.Scoring.csproj" />
<ProjectReference Include="../__Libraries/StellaOps.Policy.Persistence/StellaOps.Policy.Persistence.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.DeltaVerdict/StellaOps.DeltaVerdict.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Localization/StellaOps.Localization.csproj" />
<ProjectReference Include="../../Scanner/__Libraries/StellaOps.Scanner.ProofSpine/StellaOps.Scanner.ProofSpine.csproj" />
<ProjectReference Include="../../Signals/StellaOps.Signals/StellaOps.Signals.csproj" />
<ProjectReference Include="../../SbomService/__Libraries/StellaOps.SbomService.Persistence/StellaOps.SbomService.Persistence.csproj" />

View File

@@ -79,7 +79,7 @@ public sealed class GatewayIntegrationTests : IClassFixture<GatewayWebApplicatio
Instance = new InstanceDescriptor
{
InstanceId = "policy-01",
ServiceName = "policy-gateway",
ServiceName = "policy-engine",
Version = "1.0.0",
Region = "test"
},

View File

@@ -134,7 +134,7 @@ public sealed class RouteDispatchMiddlewareMicroserviceTests
{
Type = StellaOpsRouteType.Microservice,
Path = "/policy",
TranslatesTo = "http://policy-gateway.stella-ops.local/policy"
TranslatesTo = "http://policy-engine.stella-ops.local/policy"
}
]);
@@ -154,7 +154,7 @@ public sealed class RouteDispatchMiddlewareMicroserviceTests
Assert.False(context.Items.ContainsKey(RouterHttpContextKeys.TranslatedRequestPath));
Assert.Equal(
"policy-gateway",
"policy-engine",
context.Items[RouterHttpContextKeys.RouteTargetMicroservice] as string);
}
@@ -211,7 +211,7 @@ public sealed class RouteDispatchMiddlewareMicroserviceTests
{
Type = StellaOpsRouteType.Microservice,
Path = "/policy",
TranslatesTo = "http://policy-gateway.stella-ops.local"
TranslatesTo = "http://policy-engine.stella-ops.local"
},
new StellaOpsRoute
{
@@ -553,7 +553,7 @@ public sealed class RouteDispatchMiddlewareMicroserviceTests
{
Type = StellaOpsRouteType.Microservice,
Path = "/api/v1/policy",
TranslatesTo = "http://policy-gateway.stella-ops.local/api/v1/policy"
TranslatesTo = "http://policy-engine.stella-ops.local/api/v1/policy"
},
new StellaOpsRoute
{
@@ -588,7 +588,7 @@ public sealed class RouteDispatchMiddlewareMicroserviceTests
Assert.True(nextCalled);
Assert.Equal(
"policy-gateway",
"policy-engine",
context.Items[RouterHttpContextKeys.RouteTargetMicroservice] as string);
}
finally

View File

@@ -115,7 +115,7 @@ public sealed class EndpointResolutionMiddlewareTests
var policyEndpoint = new EndpointDescriptor
{
ServiceName = "policy-gateway",
ServiceName = "policy-engine",
Version = "1.0.0",
Method = HttpMethods.Get,
Path = "/api/v1/timeline/events"
@@ -126,7 +126,7 @@ public sealed class EndpointResolutionMiddlewareTests
.Setup(state => state.GetAllConnections())
.Returns(
[
CreateConnection("conn-policy", "policy-gateway", policyEndpoint)
CreateConnection("conn-policy", "policy-engine", policyEndpoint)
]);
routingState
@@ -158,7 +158,7 @@ public sealed class EndpointResolutionMiddlewareTests
context.Request.Method = HttpMethods.Get;
context.Request.Path = "/api/v1/policy/__router_smoke__";
context.Items[RouterHttpContextKeys.TranslatedRequestPath] = "/api/v1/policy/__router_smoke__";
context.Items[RouterHttpContextKeys.RouteTargetMicroservice] = "policy-gateway";
context.Items[RouterHttpContextKeys.RouteTargetMicroservice] = "policy-engine";
var siblingEndpoint = new EndpointDescriptor
{
@@ -203,7 +203,7 @@ public sealed class EndpointResolutionMiddlewareTests
var otherEndpoint = new EndpointDescriptor
{
ServiceName = "policy-gateway",
ServiceName = "policy-engine",
Version = "1.0.0",
Method = HttpMethods.Get,
Path = "/api/v1/governance/staleness/config"
@@ -214,7 +214,7 @@ public sealed class EndpointResolutionMiddlewareTests
.Setup(state => state.GetAllConnections())
.Returns(
[
CreateConnection("conn-policy", "policy-gateway", otherEndpoint)
CreateConnection("conn-policy", "policy-engine", otherEndpoint)
]);
var nextCalled = false;
@@ -277,7 +277,7 @@ public sealed class EndpointResolutionMiddlewareTests
var staleEndpoint = new EndpointDescriptor
{
ServiceName = "policy-gateway",
ServiceName = "policy-engine",
Version = "1.0.0",
Method = HttpMethods.Get,
Path = "/api/v1/governance/staleness/config"
@@ -285,7 +285,7 @@ public sealed class EndpointResolutionMiddlewareTests
var healthyEndpoint = new EndpointDescriptor
{
ServiceName = "policy-gateway",
ServiceName = "policy-engine",
Version = "1.0.1",
Method = HttpMethods.Get,
Path = "/api/v1/governance/staleness/config"
@@ -296,8 +296,8 @@ public sealed class EndpointResolutionMiddlewareTests
.Setup(state => state.GetAllConnections())
.Returns(
[
CreateConnection("conn-stale", "policy-gateway", staleEndpoint, InstanceHealthStatus.Unhealthy, new DateTime(2026, 3, 10, 1, 10, 0, DateTimeKind.Utc)),
CreateConnection("conn-healthy", "policy-gateway", healthyEndpoint, InstanceHealthStatus.Healthy, new DateTime(2026, 3, 10, 1, 11, 0, DateTimeKind.Utc))
CreateConnection("conn-stale", "policy-engine", staleEndpoint, InstanceHealthStatus.Unhealthy, new DateTime(2026, 3, 10, 1, 10, 0, DateTimeKind.Utc)),
CreateConnection("conn-healthy", "policy-engine", healthyEndpoint, InstanceHealthStatus.Healthy, new DateTime(2026, 3, 10, 1, 11, 0, DateTimeKind.Utc))
]);
var middleware = new EndpointResolutionMiddleware(_ => Task.CompletedTask);
@@ -306,7 +306,7 @@ public sealed class EndpointResolutionMiddlewareTests
context.Response.StatusCode.Should().NotBe(StatusCodes.Status404NotFound);
context.Items[RouterHttpContextKeys.EndpointDescriptor].Should().BeSameAs(healthyEndpoint);
context.Items[RouterHttpContextKeys.TargetMicroservice].Should().Be("policy-gateway");
context.Items[RouterHttpContextKeys.TargetMicroservice].Should().Be("policy-engine");
}
private static ConnectionState CreateConnection(

View File

@@ -60,14 +60,14 @@ public sealed class InMemoryRoutingStateTests
var state = new InMemoryRoutingState();
var staleConnection = CreateConnection(
connectionId: "conn-stale",
instanceId: "policy-gateway-old",
instanceId: "policy-engine-old",
endpointPath: "/api/v1/governance/staleness/config",
version: "1.0.0",
status: InstanceHealthStatus.Unhealthy,
lastHeartbeatUtc: new DateTime(2026, 3, 10, 1, 10, 0, DateTimeKind.Utc));
var healthyConnection = CreateConnection(
connectionId: "conn-healthy",
instanceId: "policy-gateway-new",
instanceId: "policy-engine-new",
endpointPath: "/api/v1/governance/staleness/config",
version: "1.0.1",
status: InstanceHealthStatus.Healthy,
@@ -104,7 +104,7 @@ public sealed class InMemoryRoutingStateTests
Instance = new InstanceDescriptor
{
InstanceId = instanceId,
ServiceName = endpointPath.Contains("/governance/", StringComparison.Ordinal) ? "policy-gateway" : "integrations",
ServiceName = endpointPath.Contains("/governance/", StringComparison.Ordinal) ? "policy-engine" : "integrations",
Version = version,
Region = "local"
},