From c1ecc75acee7a7b5ebd8886d5d96a652976fdd36 Mon Sep 17 00:00:00 2001 From: master <> Date: Wed, 8 Apr 2026 13:19:09 +0300 Subject: [PATCH] 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) --- devops/compose/hosts.stellaops.local | 2 +- devops/helm/stellaops/templates/core.yaml | 2 +- docs/OFFLINE_KIT.md | 13 +- docs/modules/policy/guides/gateway.md | 121 +- docs/operations/configuration-migration.md | 7 +- etc/policy-gateway.yaml.sample | 5 + .../KnowledgeSearch/KnowledgeSearchOptions.cs | 2 +- .../FtsRecallBenchmarkStore.cs | 2 +- .../Policy/GateEvaluationJob.cs | 4 +- src/Policy/AGENTS.md | 13 +- .../Contracts/Gateway/DeltaContracts.cs | 294 +++ .../Contracts/Gateway/ExceptionContracts.cs | 466 ++++ .../Contracts/Gateway/GateContracts.cs | 243 ++ .../Contracts/Gateway/ScoreGateContracts.cs | 445 ++++ .../Contracts/Gateway/ToolLatticeContracts.cs | 24 + .../Gateway/AdvisorySourceEndpoints.cs | 319 +++ .../Endpoints/Gateway/DeltasEndpoints.cs | 383 +++ .../Gateway/ExceptionApprovalEndpoints.cs | 877 +++++++ .../Endpoints/Gateway/ExceptionEndpoints.cs | 585 +++++ .../Endpoints/Gateway/GateEndpoints.cs | 403 +++ .../Endpoints/Gateway/GatesEndpoints.cs | 1014 ++++++++ .../GovernanceCompatibilityEndpoints.cs | 635 +++++ .../Endpoints/Gateway/GovernanceEndpoints.cs | 1068 ++++++++ .../Gateway/PolicySimulationEndpoints.cs | 2154 +++++++++++++++++ .../Gateway/RegistryWebhookEndpoints.cs | 413 ++++ .../Endpoints/Gateway/ScoreGateEndpoints.cs | 554 +++++ .../Endpoints/Gateway/ToolLatticeEndpoints.cs | 212 ++ src/Policy/StellaOps.Policy.Engine/Program.cs | 118 + .../Gateway/ApprovalWorkflowService.cs | 276 +++ .../Gateway/DeltaSnapshotServiceAdapter.cs | 67 + .../Services/Gateway/ExceptionExpiryWorker.cs | 236 ++ .../Services/Gateway/ExceptionQueryService.cs | 228 ++ .../Services/Gateway/ExceptionService.cs | 606 +++++ .../Services/Gateway/IExceptionService.cs | 234 ++ .../Gateway/InMemoryGateEvaluationQueue.cs | 185 ++ .../StellaOps.Policy.Engine.csproj | 3 + .../Integration/GatewayIntegrationTests.cs | 2 +- ...outeDispatchMiddlewareMicroserviceTests.cs | 10 +- .../EndpointResolutionMiddlewareTests.cs | 20 +- .../Routing/InMemoryRoutingStateTests.cs | 6 +- 40 files changed, 12135 insertions(+), 116 deletions(-) create mode 100644 src/Policy/StellaOps.Policy.Engine/Contracts/Gateway/DeltaContracts.cs create mode 100644 src/Policy/StellaOps.Policy.Engine/Contracts/Gateway/ExceptionContracts.cs create mode 100644 src/Policy/StellaOps.Policy.Engine/Contracts/Gateway/GateContracts.cs create mode 100644 src/Policy/StellaOps.Policy.Engine/Contracts/Gateway/ScoreGateContracts.cs create mode 100644 src/Policy/StellaOps.Policy.Engine/Contracts/Gateway/ToolLatticeContracts.cs create mode 100644 src/Policy/StellaOps.Policy.Engine/Endpoints/Gateway/AdvisorySourceEndpoints.cs create mode 100644 src/Policy/StellaOps.Policy.Engine/Endpoints/Gateway/DeltasEndpoints.cs create mode 100644 src/Policy/StellaOps.Policy.Engine/Endpoints/Gateway/ExceptionApprovalEndpoints.cs create mode 100644 src/Policy/StellaOps.Policy.Engine/Endpoints/Gateway/ExceptionEndpoints.cs create mode 100644 src/Policy/StellaOps.Policy.Engine/Endpoints/Gateway/GateEndpoints.cs create mode 100644 src/Policy/StellaOps.Policy.Engine/Endpoints/Gateway/GatesEndpoints.cs create mode 100644 src/Policy/StellaOps.Policy.Engine/Endpoints/Gateway/GovernanceCompatibilityEndpoints.cs create mode 100644 src/Policy/StellaOps.Policy.Engine/Endpoints/Gateway/GovernanceEndpoints.cs create mode 100644 src/Policy/StellaOps.Policy.Engine/Endpoints/Gateway/PolicySimulationEndpoints.cs create mode 100644 src/Policy/StellaOps.Policy.Engine/Endpoints/Gateway/RegistryWebhookEndpoints.cs create mode 100644 src/Policy/StellaOps.Policy.Engine/Endpoints/Gateway/ScoreGateEndpoints.cs create mode 100644 src/Policy/StellaOps.Policy.Engine/Endpoints/Gateway/ToolLatticeEndpoints.cs create mode 100644 src/Policy/StellaOps.Policy.Engine/Services/Gateway/ApprovalWorkflowService.cs create mode 100644 src/Policy/StellaOps.Policy.Engine/Services/Gateway/DeltaSnapshotServiceAdapter.cs create mode 100644 src/Policy/StellaOps.Policy.Engine/Services/Gateway/ExceptionExpiryWorker.cs create mode 100644 src/Policy/StellaOps.Policy.Engine/Services/Gateway/ExceptionQueryService.cs create mode 100644 src/Policy/StellaOps.Policy.Engine/Services/Gateway/ExceptionService.cs create mode 100644 src/Policy/StellaOps.Policy.Engine/Services/Gateway/IExceptionService.cs create mode 100644 src/Policy/StellaOps.Policy.Engine/Services/Gateway/InMemoryGateEvaluationQueue.cs diff --git a/devops/compose/hosts.stellaops.local b/devops/compose/hosts.stellaops.local index 753384edb..55b0a8dea 100644 --- a/devops/compose/hosts.stellaops.local +++ b/devops/compose/hosts.stellaops.local @@ -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 diff --git a/devops/helm/stellaops/templates/core.yaml b/devops/helm/stellaops/templates/core.yaml index 9158c5905..455a07983 100644 --- a/devops/helm/stellaops/templates/core.yaml +++ b/devops/helm/stellaops/templates/core.yaml @@ -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 diff --git a/docs/OFFLINE_KIT.md b/docs/OFFLINE_KIT.md index 320bc30eb..d7c084250 100755 --- a/docs/OFFLINE_KIT.md +++ b/docs/OFFLINE_KIT.md @@ -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. diff --git a/docs/modules/policy/guides/gateway.md b/docs/modules/policy/guides/gateway.md index 61489a6e1..b491631b4 100644 --- a/docs/modules/policy/guides/gateway.md +++ b/docs/modules/policy/guides/gateway.md @@ -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: "" - 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 Kit’s `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. diff --git a/docs/operations/configuration-migration.md b/docs/operations/configuration-migration.md index c66a04f1d..e377720f7 100644 --- a/docs/operations/configuration-migration.md +++ b/docs/operations/configuration-migration.md @@ -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 ``` diff --git a/etc/policy-gateway.yaml.sample b/etc/policy-gateway.yaml.sample index 1b2b10baf..6137d9aae 100644 --- a/etc/policy-gateway.yaml.sample +++ b/etc/policy-gateway.yaml.sample @@ -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 diff --git a/src/AdvisoryAI/StellaOps.AdvisoryAI/KnowledgeSearch/KnowledgeSearchOptions.cs b/src/AdvisoryAI/StellaOps.AdvisoryAI/KnowledgeSearch/KnowledgeSearchOptions.cs index 198115e3a..b831441e8 100644 --- a/src/AdvisoryAI/StellaOps.AdvisoryAI/KnowledgeSearch/KnowledgeSearchOptions.cs +++ b/src/AdvisoryAI/StellaOps.AdvisoryAI/KnowledgeSearch/KnowledgeSearchOptions.cs @@ -159,7 +159,7 @@ public sealed class KnowledgeSearchOptions /// When false the live VEX adapter is skipped entirely. public bool VexAdapterEnabled { get; set; } = true; - /// Base URL for the Policy Gateway service (e.g. "http://policy-gateway:8080"). + /// Base URL for the Policy Engine service (e.g. "http://policy-engine:8080"). public string PolicyAdapterBaseUrl { get; set; } = string.Empty; /// When false the live policy adapter is skipped entirely. diff --git a/src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/KnowledgeSearch/FtsRecallBenchmarkStore.cs b/src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/KnowledgeSearch/FtsRecallBenchmarkStore.cs index 8e5419b95..db231df6a 100644 --- a/src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/KnowledgeSearch/FtsRecallBenchmarkStore.cs +++ b/src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/KnowledgeSearch/FtsRecallBenchmarkStore.cs @@ -518,7 +518,7 @@ internal sealed class FtsRecallBenchmarkStore : IKnowledgeSearchStore "POST", "/v1/policy/evaluate", "evaluatePolicy", - "policy-gateway", + "policy-engine", ["policy", "evaluate", "gate"]), MakeApi( diff --git a/src/JobEngine/StellaOps.Scheduler.__Libraries/StellaOps.Scheduler.Worker/Policy/GateEvaluationJob.cs b/src/JobEngine/StellaOps.Scheduler.__Libraries/StellaOps.Scheduler.Worker/Policy/GateEvaluationJob.cs index cd1d73ae6..3531d3b1f 100644 --- a/src/JobEngine/StellaOps.Scheduler.__Libraries/StellaOps.Scheduler.Worker/Policy/GateEvaluationJob.cs +++ b/src/JobEngine/StellaOps.Scheduler.__Libraries/StellaOps.Scheduler.Worker/Policy/GateEvaluationJob.cs @@ -49,9 +49,9 @@ public sealed class GateEvaluationOptions public bool NotifyOnFailure { get; set; } = true; /// - /// Policy Gateway base URL. + /// Policy Engine base URL (gateway merged into engine). /// - public string PolicyGatewayUrl { get; set; } = "http://policy-gateway:8080"; + public string PolicyGatewayUrl { get; set; } = "http://policy-engine:8080"; } /// diff --git a/src/Policy/AGENTS.md b/src/Policy/AGENTS.md index bf0eeda6a..ae4f7e352 100644 --- a/src/Policy/AGENTS.md +++ b/src/Policy/AGENTS.md @@ -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. diff --git a/src/Policy/StellaOps.Policy.Engine/Contracts/Gateway/DeltaContracts.cs b/src/Policy/StellaOps.Policy.Engine/Contracts/Gateway/DeltaContracts.cs new file mode 100644 index 000000000..b54f3b8b8 --- /dev/null +++ b/src/Policy/StellaOps.Policy.Engine/Contracts/Gateway/DeltaContracts.cs @@ -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; + +/// +/// Request to compute a security state delta. +/// +public sealed record ComputeDeltaRequest +{ + /// + /// Artifact digest (required). + /// + [Required] + public required string ArtifactDigest { get; init; } + + /// + /// Artifact name (optional). + /// + public string? ArtifactName { get; init; } + + /// + /// Artifact tag (optional). + /// + public string? ArtifactTag { get; init; } + + /// + /// Target snapshot ID (required). + /// + [Required] + public required string TargetSnapshotId { get; init; } + + /// + /// Explicit baseline snapshot ID (optional). + /// If not provided, baseline selection strategy is used. + /// + public string? BaselineSnapshotId { get; init; } + + /// + /// Baseline selection strategy (optional, defaults to LastApproved). + /// Values: PreviousBuild, LastApproved, ProductionDeployed, BranchBase + /// + public string? BaselineStrategy { get; init; } +} + +/// +/// Response from computing a security state delta. +/// +public sealed record ComputeDeltaResponse +{ + /// + /// The computed delta ID. + /// + public required string DeltaId { get; init; } + + /// + /// Baseline snapshot ID used. + /// + public required string BaselineSnapshotId { get; init; } + + /// + /// Target snapshot ID. + /// + public required string TargetSnapshotId { get; init; } + + /// + /// When the delta was computed. + /// + public required DateTimeOffset ComputedAt { get; init; } + + /// + /// Summary statistics. + /// + public required DeltaSummaryDto Summary { get; init; } + + /// + /// Number of drivers identified. + /// + public int DriverCount { get; init; } +} + +/// +/// Summary statistics DTO. +/// +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 + }; +} + +/// +/// Full delta response DTO. +/// +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 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 + }; +} + +/// +/// Request to evaluate a delta verdict. +/// +public sealed record EvaluateDeltaRequest +{ + /// + /// Exception IDs to apply. + /// + public IReadOnlyList? Exceptions { get; init; } +} + +/// +/// Delta verdict response DTO. +/// +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 BlockingDrivers { get; init; } + public required IReadOnlyList WarningDrivers { get; init; } + public required IReadOnlyList AppliedExceptions { get; init; } + public string? Explanation { get; init; } + public required IReadOnlyList 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() + }; +} diff --git a/src/Policy/StellaOps.Policy.Engine/Contracts/Gateway/ExceptionContracts.cs b/src/Policy/StellaOps.Policy.Engine/Contracts/Gateway/ExceptionContracts.cs new file mode 100644 index 000000000..e812b251c --- /dev/null +++ b/src/Policy/StellaOps.Policy.Engine/Contracts/Gateway/ExceptionContracts.cs @@ -0,0 +1,466 @@ +// +// Copyright (c) StellaOps. All rights reserved. +// Licensed under the BUSL-1.1 license. +// + +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; + +namespace StellaOps.Policy.Engine.Contracts.Gateway; + +/// +/// Request to create a new exception. +/// +public sealed record CreateExceptionRequest +{ + /// + /// Type of exception (vulnerability, policy, unknown, component). + /// + [Required] + [JsonPropertyName("type")] + public required string Type { get; init; } + + /// + /// Exception scope defining what this exception applies to. + /// + [Required] + [JsonPropertyName("scope")] + public required ExceptionScopeDto Scope { get; init; } + + /// + /// Owner ID (user or team accountable). + /// + [Required] + [JsonPropertyName("ownerId")] + public required string OwnerId { get; init; } + + /// + /// Reason code for the exception. + /// + [Required] + [JsonPropertyName("reasonCode")] + public required string ReasonCode { get; init; } + + /// + /// Detailed rationale explaining why this exception is necessary. + /// + [Required] + [MinLength(50, ErrorMessage = "Rationale must be at least 50 characters.")] + [JsonPropertyName("rationale")] + public required string Rationale { get; init; } + + /// + /// When the exception should expire. + /// + [Required] + [JsonPropertyName("expiresAt")] + public required DateTimeOffset ExpiresAt { get; init; } + + /// + /// Content-addressed evidence references. + /// + [JsonPropertyName("evidenceRefs")] + public IReadOnlyList? EvidenceRefs { get; init; } + + /// + /// Compensating controls in place. + /// + [JsonPropertyName("compensatingControls")] + public IReadOnlyList? CompensatingControls { get; init; } + + /// + /// External ticket reference (e.g., JIRA-1234). + /// + [JsonPropertyName("ticketRef")] + public string? TicketRef { get; init; } + + /// + /// Additional metadata. + /// + [JsonPropertyName("metadata")] + public IReadOnlyDictionary? Metadata { get; init; } +} + +/// +/// Exception scope DTO. +/// +public sealed record ExceptionScopeDto +{ + /// + /// Specific artifact digest (sha256:...). + /// + [JsonPropertyName("artifactDigest")] + public string? ArtifactDigest { get; init; } + + /// + /// PURL pattern (supports wildcards: pkg:npm/lodash@*). + /// + [JsonPropertyName("purlPattern")] + public string? PurlPattern { get; init; } + + /// + /// Specific vulnerability ID (CVE-XXXX-XXXXX). + /// + [JsonPropertyName("vulnerabilityId")] + public string? VulnerabilityId { get; init; } + + /// + /// Policy rule identifier to bypass. + /// + [JsonPropertyName("policyRuleId")] + public string? PolicyRuleId { get; init; } + + /// + /// Environments where exception is valid (empty = all). + /// + [JsonPropertyName("environments")] + public IReadOnlyList? Environments { get; init; } +} + +/// +/// Request to update an exception. +/// +public sealed record UpdateExceptionRequest +{ + /// + /// Updated rationale. + /// + [MinLength(50, ErrorMessage = "Rationale must be at least 50 characters.")] + [JsonPropertyName("rationale")] + public string? Rationale { get; init; } + + /// + /// Updated evidence references. + /// + [JsonPropertyName("evidenceRefs")] + public IReadOnlyList? EvidenceRefs { get; init; } + + /// + /// Updated compensating controls. + /// + [JsonPropertyName("compensatingControls")] + public IReadOnlyList? CompensatingControls { get; init; } + + /// + /// Updated ticket reference. + /// + [JsonPropertyName("ticketRef")] + public string? TicketRef { get; init; } + + /// + /// Updated metadata. + /// + [JsonPropertyName("metadata")] + public IReadOnlyDictionary? Metadata { get; init; } +} + +/// +/// Request to approve an exception. +/// +public sealed record ApproveExceptionRequest +{ + /// + /// Optional comment from approver. + /// + [JsonPropertyName("comment")] + public string? Comment { get; init; } +} + +/// +/// Request to extend an exception's expiry. +/// +public sealed record ExtendExceptionRequest +{ + /// + /// New expiry date. + /// + [Required] + [JsonPropertyName("newExpiresAt")] + public required DateTimeOffset NewExpiresAt { get; init; } + + /// + /// Reason for extension. + /// + [Required] + [MinLength(20, ErrorMessage = "Extension reason must be at least 20 characters.")] + [JsonPropertyName("reason")] + public required string Reason { get; init; } +} + +/// +/// Request to revoke an exception. +/// +public sealed record RevokeExceptionRequest +{ + /// + /// Reason for revocation. + /// + [Required] + [MinLength(10, ErrorMessage = "Revocation reason must be at least 10 characters.")] + [JsonPropertyName("reason")] + public required string Reason { get; init; } +} + +/// +/// Exception response DTO. +/// +public sealed record ExceptionResponse +{ + /// + /// Unique exception ID. + /// + [JsonPropertyName("exceptionId")] + public required string ExceptionId { get; init; } + + /// + /// Version for optimistic concurrency. + /// + [JsonPropertyName("version")] + public required int Version { get; init; } + + /// + /// Current status. + /// + [JsonPropertyName("status")] + public required string Status { get; init; } + + /// + /// Exception type. + /// + [JsonPropertyName("type")] + public required string Type { get; init; } + + /// + /// Exception scope. + /// + [JsonPropertyName("scope")] + public required ExceptionScopeDto Scope { get; init; } + + /// + /// Owner ID. + /// + [JsonPropertyName("ownerId")] + public required string OwnerId { get; init; } + + /// + /// Requester ID. + /// + [JsonPropertyName("requesterId")] + public required string RequesterId { get; init; } + + /// + /// Approver IDs. + /// + [JsonPropertyName("approverIds")] + public required IReadOnlyList ApproverIds { get; init; } + + /// + /// Created timestamp. + /// + [JsonPropertyName("createdAt")] + public required DateTimeOffset CreatedAt { get; init; } + + /// + /// Last updated timestamp. + /// + [JsonPropertyName("updatedAt")] + public required DateTimeOffset UpdatedAt { get; init; } + + /// + /// Approved timestamp. + /// + [JsonPropertyName("approvedAt")] + public DateTimeOffset? ApprovedAt { get; init; } + + /// + /// Expiry timestamp. + /// + [JsonPropertyName("expiresAt")] + public required DateTimeOffset ExpiresAt { get; init; } + + /// + /// Reason code. + /// + [JsonPropertyName("reasonCode")] + public required string ReasonCode { get; init; } + + /// + /// Rationale. + /// + [JsonPropertyName("rationale")] + public required string Rationale { get; init; } + + /// + /// Evidence references. + /// + [JsonPropertyName("evidenceRefs")] + public required IReadOnlyList EvidenceRefs { get; init; } + + /// + /// Compensating controls. + /// + [JsonPropertyName("compensatingControls")] + public required IReadOnlyList CompensatingControls { get; init; } + + /// + /// Ticket reference. + /// + [JsonPropertyName("ticketRef")] + public string? TicketRef { get; init; } + + /// + /// Metadata. + /// + [JsonPropertyName("metadata")] + public required IReadOnlyDictionary Metadata { get; init; } +} + +/// +/// Paginated list of exceptions. +/// +public sealed record ExceptionListResponse +{ + /// + /// List of exceptions. + /// + [JsonPropertyName("items")] + public required IReadOnlyList Items { get; init; } + + /// + /// Total count. + /// + [JsonPropertyName("totalCount")] + public required int TotalCount { get; init; } + + /// + /// Offset. + /// + [JsonPropertyName("offset")] + public required int Offset { get; init; } + + /// + /// Limit. + /// + [JsonPropertyName("limit")] + public required int Limit { get; init; } +} + +/// +/// Exception event DTO. +/// +public sealed record ExceptionEventDto +{ + /// + /// Event ID. + /// + [JsonPropertyName("eventId")] + public required Guid EventId { get; init; } + + /// + /// Sequence number. + /// + [JsonPropertyName("sequenceNumber")] + public required int SequenceNumber { get; init; } + + /// + /// Event type. + /// + [JsonPropertyName("eventType")] + public required string EventType { get; init; } + + /// + /// Actor ID. + /// + [JsonPropertyName("actorId")] + public required string ActorId { get; init; } + + /// + /// Occurred timestamp. + /// + [JsonPropertyName("occurredAt")] + public required DateTimeOffset OccurredAt { get; init; } + + /// + /// Previous status. + /// + [JsonPropertyName("previousStatus")] + public string? PreviousStatus { get; init; } + + /// + /// New status. + /// + [JsonPropertyName("newStatus")] + public required string NewStatus { get; init; } + + /// + /// Description. + /// + [JsonPropertyName("description")] + public string? Description { get; init; } +} + +/// +/// Exception history response. +/// +public sealed record ExceptionHistoryResponse +{ + /// + /// Exception ID. + /// + [JsonPropertyName("exceptionId")] + public required string ExceptionId { get; init; } + + /// + /// Events in chronological order. + /// + [JsonPropertyName("events")] + public required IReadOnlyList Events { get; init; } +} + +/// +/// Exception counts summary. +/// +public sealed record ExceptionCountsResponse +{ + /// + /// Total count. + /// + [JsonPropertyName("total")] + public required int Total { get; init; } + + /// + /// Proposed count. + /// + [JsonPropertyName("proposed")] + public required int Proposed { get; init; } + + /// + /// Approved count. + /// + [JsonPropertyName("approved")] + public required int Approved { get; init; } + + /// + /// Active count. + /// + [JsonPropertyName("active")] + public required int Active { get; init; } + + /// + /// Expired count. + /// + [JsonPropertyName("expired")] + public required int Expired { get; init; } + + /// + /// Revoked count. + /// + [JsonPropertyName("revoked")] + public required int Revoked { get; init; } + + /// + /// Count expiring within 7 days. + /// + [JsonPropertyName("expiringSoon")] + public required int ExpiringSoon { get; init; } +} diff --git a/src/Policy/StellaOps.Policy.Engine/Contracts/Gateway/GateContracts.cs b/src/Policy/StellaOps.Policy.Engine/Contracts/Gateway/GateContracts.cs new file mode 100644 index 000000000..52cde7b5f --- /dev/null +++ b/src/Policy/StellaOps.Policy.Engine/Contracts/Gateway/GateContracts.cs @@ -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; + +/// +/// Request to evaluate a CI/CD gate for an image. +/// +public sealed record GateEvaluateRequest +{ + /// + /// The image digest to evaluate (e.g., sha256:abc123...). + /// + public required string ImageDigest { get; init; } + + /// + /// The container repository name. + /// + public string? Repository { get; init; } + + /// + /// The image tag, if any. + /// + public string? Tag { get; init; } + + /// + /// The baseline reference for comparison. + /// Can be a snapshot ID, image digest, or strategy name (e.g., "last-approved", "production"). + /// + public string? BaselineRef { get; init; } + + /// + /// Optional policy ID to use for evaluation. + /// + public string? PolicyId { get; init; } + + /// + /// Whether to allow override of blocking gates. + /// + public bool AllowOverride { get; init; } + + /// + /// Justification for override (required if AllowOverride is true and gate would block). + /// + public string? OverrideJustification { get; init; } + + /// + /// Source of the request (e.g., "cli", "api", "webhook"). + /// + public string? Source { get; init; } + + /// + /// CI/CD context identifier (e.g., "github-actions", "gitlab-ci"). + /// + public string? CiContext { get; init; } + + /// + /// Additional context for the gate evaluation. + /// + public GateEvaluationContext? Context { get; init; } +} + +/// +/// Additional context for gate evaluation. +/// +public sealed record GateEvaluationContext +{ + /// + /// Git branch name. + /// + public string? Branch { get; init; } + + /// + /// Git commit SHA. + /// + public string? CommitSha { get; init; } + + /// + /// CI/CD pipeline ID or job ID. + /// + public string? PipelineId { get; init; } + + /// + /// Environment being deployed to (e.g., "production", "staging"). + /// + public string? Environment { get; init; } + + /// + /// Actor triggering the gate (e.g., user or service identity). + /// + public string? Actor { get; init; } +} + +/// +/// Response from gate evaluation. +/// +public sealed record GateEvaluateResponse +{ + /// + /// Unique decision ID for audit and tracking. + /// + public required string DecisionId { get; init; } + + /// + /// The gate decision status. + /// + public required GateStatus Status { get; init; } + + /// + /// Suggested CI exit code. + /// 0 = Pass, 1 = Warn (configurable pass-through), 2 = Fail/Block + /// + public required int ExitCode { get; init; } + + /// + /// The image digest that was evaluated. + /// + public required string ImageDigest { get; init; } + + /// + /// The baseline reference used for comparison. + /// + public string? BaselineRef { get; init; } + + /// + /// When the decision was made (UTC). + /// + public required DateTimeOffset DecidedAt { get; init; } + + /// + /// Summary message for the decision. + /// + public string? Summary { get; init; } + + /// + /// Advisory or suggestion for the developer. + /// + public string? Advisory { get; init; } + + /// + /// List of gate results. + /// + public IReadOnlyList? Gates { get; init; } + + /// + /// Gate that caused the block (if blocked). + /// + public string? BlockedBy { get; init; } + + /// + /// Detailed reason for the block. + /// + public string? BlockReason { get; init; } + + /// + /// Suggestion for resolving the block. + /// + public string? Suggestion { get; init; } + + /// + /// Whether an override was applied. + /// + public bool OverrideApplied { get; init; } + + /// + /// Delta summary if available. + /// + public DeltaSummaryDto? DeltaSummary { get; init; } +} + +/// +/// Result of a single gate evaluation. +/// +public sealed record GateResultDto +{ + /// + /// Gate name/ID. + /// + public required string Name { get; init; } + + /// + /// Gate result type. + /// + public required string Result { get; init; } + + /// + /// Reason for the result. + /// + public required string Reason { get; init; } + + /// + /// Additional note. + /// + public string? Note { get; init; } + + /// + /// Condition expression that was evaluated. + /// + public string? Condition { get; init; } +} + +/// +/// Gate evaluation status. +/// +public enum GateStatus +{ + /// + /// Gate passed - proceed with deployment. + /// + Pass = 0, + + /// + /// Gate produced warnings - proceed with caution. + /// + Warn = 1, + + /// + /// Gate blocked - do not proceed. + /// + Fail = 2 +} + +/// +/// CI exit codes for gate evaluation. +/// +public static class GateExitCodes +{ + /// + /// Gate passed - proceed with deployment. + /// + public const int Pass = 0; + + /// + /// Gate produced warnings - configurable pass-through. + /// + public const int Warn = 1; + + /// + /// Gate blocked - do not proceed. + /// + public const int Fail = 2; +} diff --git a/src/Policy/StellaOps.Policy.Engine/Contracts/Gateway/ScoreGateContracts.cs b/src/Policy/StellaOps.Policy.Engine/Contracts/Gateway/ScoreGateContracts.cs new file mode 100644 index 000000000..db9fd3a49 --- /dev/null +++ b/src/Policy/StellaOps.Policy.Engine/Contracts/Gateway/ScoreGateContracts.cs @@ -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; + +/// +/// Request for score-based gate evaluation. +/// Used by CI/CD pipelines to evaluate individual findings. +/// +public sealed record ScoreGateEvaluateRequest +{ + /// + /// Finding identifier (CVE@PURL format). + /// Example: "CVE-2024-1234@pkg:npm/lodash@4.17.20" + /// + [JsonPropertyName("finding_id")] + public required string FindingId { get; init; } + + /// + /// CVSS base score [0, 10]. + /// + [JsonPropertyName("cvss_base")] + public double CvssBase { get; init; } + + /// + /// CVSS version (3.0, 3.1, 4.0). Defaults to 3.1. + /// + [JsonPropertyName("cvss_version")] + public string? CvssVersion { get; init; } + + /// + /// EPSS probability [0, 1]. + /// + [JsonPropertyName("epss")] + public double Epss { get; init; } + + /// + /// EPSS model date (ISO 8601 date). + /// + [JsonPropertyName("epss_model_date")] + public DateOnly? EpssModelDate { get; init; } + + /// + /// Reachability level: "none", "package", "function", "caller". + /// + [JsonPropertyName("reachability")] + public string? Reachability { get; init; } + + /// + /// Exploit maturity: "none", "poc", "functional", "high". + /// + [JsonPropertyName("exploit_maturity")] + public string? ExploitMaturity { get; init; } + + /// + /// Patch proof confidence [0, 1]. + /// + [JsonPropertyName("patch_proof_confidence")] + public double PatchProofConfidence { get; init; } + + /// + /// VEX status: "affected", "not_affected", "fixed", "under_investigation". + /// + [JsonPropertyName("vex_status")] + public string? VexStatus { get; init; } + + /// + /// VEX statement source/issuer. + /// + [JsonPropertyName("vex_source")] + public string? VexSource { get; init; } + + /// + /// Whether to anchor the verdict to Rekor transparency log. + /// Default: false (async anchoring). + /// + [JsonPropertyName("anchor_to_rekor")] + public bool AnchorToRekor { get; init; } + + /// + /// Whether to include the full verdict bundle in the response. + /// + [JsonPropertyName("include_verdict")] + public bool IncludeVerdict { get; init; } + + /// + /// Optional policy profile to use ("advisory", "legacy", "custom"). + /// Default: "advisory". + /// + [JsonPropertyName("policy_profile")] + public string? PolicyProfile { get; init; } +} + +/// +/// Response from score-based gate evaluation. +/// +public sealed record ScoreGateEvaluateResponse +{ + /// + /// Gate action: "pass", "warn", "block". + /// + [JsonPropertyName("action")] + public required string Action { get; init; } + + /// + /// Final score [0, 1]. + /// + [JsonPropertyName("score")] + public required double Score { get; init; } + + /// + /// Threshold that triggered the action. + /// + [JsonPropertyName("threshold")] + public required double Threshold { get; init; } + + /// + /// Human-readable reason for the gate decision. + /// + [JsonPropertyName("reason")] + public required string Reason { get; init; } + + /// + /// Verdict bundle ID (SHA256 digest). + /// + [JsonPropertyName("verdict_bundle_id")] + public required string VerdictBundleId { get; init; } + + /// + /// Rekor entry UUID (if anchored). + /// + [JsonPropertyName("rekor_uuid")] + public string? RekorUuid { get; init; } + + /// + /// Rekor log index (if anchored). + /// + [JsonPropertyName("rekor_log_index")] + public long? RekorLogIndex { get; init; } + + /// + /// When the verdict was computed (ISO 8601). + /// + [JsonPropertyName("computed_at")] + public required DateTimeOffset ComputedAt { get; init; } + + /// + /// Matched rules that influenced the decision. + /// + [JsonPropertyName("matched_rules")] + public IReadOnlyList MatchedRules { get; init; } = []; + + /// + /// Suggestions for resolving the gate decision. + /// + [JsonPropertyName("suggestions")] + public IReadOnlyList Suggestions { get; init; } = []; + + /// + /// CI/CD exit code: 0=pass, 1=warn, 2=block. + /// + [JsonPropertyName("exit_code")] + public required int ExitCode { get; init; } + + /// + /// Score breakdown by dimension (optional). + /// + [JsonPropertyName("breakdown")] + public IReadOnlyList? Breakdown { get; init; } + + /// + /// Full verdict bundle JSON (if include_verdict=true). + /// + [JsonPropertyName("verdict_bundle")] + public object? VerdictBundle { get; init; } +} + +/// +/// Per-dimension score breakdown. +/// +public sealed record ScoreDimensionBreakdown +{ + /// + /// Dimension name. + /// + [JsonPropertyName("dimension")] + public required string Dimension { get; init; } + + /// + /// Dimension symbol. + /// + [JsonPropertyName("symbol")] + public required string Symbol { get; init; } + + /// + /// Raw input value. + /// + [JsonPropertyName("value")] + public required double Value { get; init; } + + /// + /// Weight applied. + /// + [JsonPropertyName("weight")] + public required double Weight { get; init; } + + /// + /// Contribution to final score. + /// + [JsonPropertyName("contribution")] + public required double Contribution { get; init; } + + /// + /// Whether this is a subtractive dimension. + /// + [JsonPropertyName("is_subtractive")] + public bool IsSubtractive { get; init; } +} + +/// +/// Gate action types. +/// +public static class ScoreGateActions +{ + public const string Pass = "pass"; + public const string Warn = "warn"; + public const string Block = "block"; +} + +/// +/// CI exit codes for gate evaluation. +/// +public static class ScoreGateExitCodes +{ + /// Gate passed. + public const int Pass = 0; + + /// Gate warned. + public const int Warn = 1; + + /// Gate blocked. + public const int Block = 2; +} + +#region Batch Evaluation Contracts + +/// +/// Request for batch score-based gate evaluation. +/// Sprint: SPRINT_20260118_030_LIB_verdict_rekor_gate_api +/// Task: TASK-030-007 - Batch Gate Evaluation API +/// +public sealed record ScoreGateBatchEvaluateRequest +{ + /// + /// List of findings to evaluate. + /// + [JsonPropertyName("findings")] + public required IReadOnlyList Findings { get; init; } + + /// + /// Batch evaluation options. + /// + [JsonPropertyName("options")] + public ScoreGateBatchOptions? Options { get; init; } +} + +/// +/// Options for batch gate evaluation. +/// +public sealed record ScoreGateBatchOptions +{ + /// + /// Stop evaluation on first block. + /// Default: false + /// + [JsonPropertyName("fail_fast")] + public bool FailFast { get; init; } + + /// + /// Include full verdict bundles in response. + /// Default: false + /// + [JsonPropertyName("include_verdicts")] + public bool IncludeVerdicts { get; init; } + + /// + /// Anchor each verdict to Rekor (slower but auditable). + /// Default: false + /// + [JsonPropertyName("anchor_to_rekor")] + public bool AnchorToRekor { get; init; } + + /// + /// Policy profile to use for all evaluations. + /// Default: "advisory" + /// + [JsonPropertyName("policy_profile")] + public string? PolicyProfile { get; init; } + + /// + /// Maximum parallelism for evaluation. + /// Default: 10 + /// + [JsonPropertyName("max_parallelism")] + public int MaxParallelism { get; init; } = 10; +} + +/// +/// Response from batch gate evaluation. +/// +public sealed record ScoreGateBatchEvaluateResponse +{ + /// + /// Summary statistics for the batch evaluation. + /// + [JsonPropertyName("summary")] + public required ScoreGateBatchSummary Summary { get; init; } + + /// + /// Overall action: worst-case across all findings. + /// "block" if any blocked, "warn" if any warned but none blocked, "pass" otherwise. + /// + [JsonPropertyName("overall_action")] + public required string OverallAction { get; init; } + + /// + /// CI/CD exit code based on overall action. + /// + [JsonPropertyName("exit_code")] + public required int ExitCode { get; init; } + + /// + /// Individual decisions for each finding. + /// + [JsonPropertyName("decisions")] + public required IReadOnlyList Decisions { get; init; } + + /// + /// Evaluation duration in milliseconds. + /// + [JsonPropertyName("duration_ms")] + public required long DurationMs { get; init; } + + /// + /// Whether evaluation was stopped early due to fail-fast. + /// + [JsonPropertyName("fail_fast_triggered")] + public bool FailFastTriggered { get; init; } +} + +/// +/// Summary statistics for batch evaluation. +/// +public sealed record ScoreGateBatchSummary +{ + /// + /// Total number of findings evaluated. + /// + [JsonPropertyName("total")] + public required int Total { get; init; } + + /// + /// Number of findings that passed. + /// + [JsonPropertyName("passed")] + public required int Passed { get; init; } + + /// + /// Number of findings that warned. + /// + [JsonPropertyName("warned")] + public required int Warned { get; init; } + + /// + /// Number of findings that blocked. + /// + [JsonPropertyName("blocked")] + public required int Blocked { get; init; } + + /// + /// Number of findings that errored. + /// + [JsonPropertyName("errored")] + public int Errored { get; init; } +} + +/// +/// Individual decision in a batch evaluation. +/// +public sealed record ScoreGateBatchDecision +{ + /// + /// Finding identifier. + /// + [JsonPropertyName("finding_id")] + public required string FindingId { get; init; } + + /// + /// Gate action: "pass", "warn", "block", or "error". + /// + [JsonPropertyName("action")] + public required string Action { get; init; } + + /// + /// Final score [0, 1]. + /// + [JsonPropertyName("score")] + public double? Score { get; init; } + + /// + /// Threshold that triggered the action. + /// + [JsonPropertyName("threshold")] + public double? Threshold { get; init; } + + /// + /// Reason for the decision. + /// + [JsonPropertyName("reason")] + public string? Reason { get; init; } + + /// + /// Verdict bundle ID if created. + /// + [JsonPropertyName("verdict_bundle_id")] + public string? VerdictBundleId { get; init; } + + /// + /// Full verdict bundle (if include_verdicts=true). + /// + [JsonPropertyName("verdict_bundle")] + public object? VerdictBundle { get; init; } + + /// + /// Error message if evaluation failed. + /// + [JsonPropertyName("error")] + public string? Error { get; init; } +} + +#endregion diff --git a/src/Policy/StellaOps.Policy.Engine/Contracts/Gateway/ToolLatticeContracts.cs b/src/Policy/StellaOps.Policy.Engine/Contracts/Gateway/ToolLatticeContracts.cs new file mode 100644 index 000000000..bb927a0ac --- /dev/null +++ b/src/Policy/StellaOps.Policy.Engine/Contracts/Gateway/ToolLatticeContracts.cs @@ -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? Scopes { get; init; } + public IReadOnlyList? 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 RequiredScopes { get; init; } = Array.Empty(); + public IReadOnlyList RequiredRoles { get; init; } = Array.Empty(); +} diff --git a/src/Policy/StellaOps.Policy.Engine/Endpoints/Gateway/AdvisorySourceEndpoints.cs b/src/Policy/StellaOps.Policy.Engine/Endpoints/Gateway/AdvisorySourceEndpoints.cs new file mode 100644 index 000000000..8264598e6 --- /dev/null +++ b/src/Policy/StellaOps.Policy.Engine/Endpoints/Gateway/AdvisorySourceEndpoints.cs @@ -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; + +/// +/// Advisory-source policy endpoints (impact and conflict facts). +/// +public static class AdvisorySourceEndpoints +{ + private static readonly HashSet 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(StatusCodes.Status200OK) + .Produces(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(StatusCodes.Status200OK) + .Produces(StatusCodes.Status400BadRequest) + .RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.FindingsRead)); + } + + private static async Task 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 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 ParseDecisionRefs(string decisionRefsJson) + { + if (string.IsNullOrWhiteSpace(decisionRefsJson)) + { + return Array.Empty(); + } + + try + { + using var document = JsonDocument.Parse(decisionRefsJson); + if (document.RootElement.ValueKind != JsonValueKind.Array) + { + return Array.Empty(); + } + + var refs = new List(); + 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(); + } + } + + 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 DecisionRefs { get; init; } = Array.Empty(); + 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 Items { get; init; } = Array.Empty(); + 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; } +} diff --git a/src/Policy/StellaOps.Policy.Engine/Endpoints/Gateway/DeltasEndpoints.cs b/src/Policy/StellaOps.Policy.Engine/Endpoints/Gateway/DeltasEndpoints.cs new file mode 100644 index 000000000..ef90c6247 --- /dev/null +++ b/src/Policy/StellaOps.Policy.Engine/Endpoints/Gateway/DeltasEndpoints.cs @@ -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; + +/// +/// Delta API endpoints for Policy Gateway. +/// +public static class DeltasEndpoints +{ + private const string DeltaCachePrefix = "delta:"; + private static readonly TimeSpan DeltaCacheDuration = TimeSpan.FromMinutes(30); + + /// + /// Maps delta endpoints to the application. + /// + 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( + ComputeDeltaRequest request, + IDeltaComputer deltaComputer, + IBaselineSelector baselineSelector, + IMemoryCache cache, + ILogger 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( + 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( + string deltaId, + EvaluateDeltaRequest? request, + IMemoryCache cache, + ILogger 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( + string deltaId, + IMemoryCache cache, + [FromServices] IDeltaVerdictAttestor? attestor, + ILogger 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 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"); + } + } +} diff --git a/src/Policy/StellaOps.Policy.Engine/Endpoints/Gateway/ExceptionApprovalEndpoints.cs b/src/Policy/StellaOps.Policy.Engine/Endpoints/Gateway/ExceptionApprovalEndpoints.cs new file mode 100644 index 000000000..1a60c98eb --- /dev/null +++ b/src/Policy/StellaOps.Policy.Engine/Endpoints/Gateway/ExceptionApprovalEndpoints.cs @@ -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; + +/// +/// Exception approval workflow API endpoints. +/// +public static class ExceptionApprovalEndpoints +{ + /// + /// Maps exception approval endpoints to the application. + /// + 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 CreateApprovalRequestAsync( + HttpContext httpContext, + CreateApprovalRequestDto request, + IExceptionApprovalRepository repository, + IExceptionApprovalRulesService rulesService, + [FromServices] TimeProvider timeProvider, + ILogger 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(request.GateLevel, ignoreCase: true, out var gateLevel)) + { + gateLevel = GateLevel.G1; // Default to G1 if not specified + } + + // Parse reason code + if (!Enum.TryParse(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 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 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(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 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 ApproveRequestAsync( + HttpContext httpContext, + string requestId, + ApproveRequestDto? request, + IExceptionApprovalRepository repository, + IExceptionApprovalRulesService rulesService, + ILogger 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 RejectRequestAsync( + HttpContext httpContext, + string requestId, + RejectRequestDto request, + IExceptionApprovalRepository repository, + ILogger 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 CancelRequestAsync( + HttpContext httpContext, + string requestId, + CancelRequestDto? request, + IExceptionApprovalRepository repository, + ILogger 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 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 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 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 + { + ["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? 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 ParseJsonArray(string json) + { + if (string.IsNullOrWhiteSpace(json) || json == "[]") + return []; + + try + { + return JsonSerializer.Deserialize>(json) ?? []; + } + catch + { + return []; + } + } +} + +// ============================================================================ +// DTO Models +// ============================================================================ + +/// +/// Request to create an exception approval request. +/// +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? EvidenceRefs { get; init; } + public List? 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? Environments { get; init; } + public List? RequiredApproverIds { get; init; } + public int? RequestedTtlDays { get; init; } + public Dictionary? Metadata { get; init; } +} + +/// +/// Request to approve an exception. +/// +public sealed record ApproveRequestDto +{ + public string? Comment { get; init; } +} + +/// +/// Request to reject an exception. +/// +public sealed record RejectRequestDto +{ + public required string Reason { get; init; } +} + +/// +/// Request to cancel an exception request. +/// +public sealed record CancelRequestDto +{ + public string? Reason { get; init; } +} + +/// +/// Full approval request response. +/// +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 EvidenceRefs { get; init; } = []; + public List 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 Warnings { get; init; } = []; +} + +/// +/// Summary approval request for listings. +/// +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; } +} + +/// +/// Approval request list response. +/// +public sealed record ApprovalRequestListResponse +{ + public required IReadOnlyList Items { get; init; } + public int Limit { get; init; } + public int Offset { get; init; } +} + +/// +/// Audit entry response. +/// +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; } +} + +/// +/// Audit trail response. +/// +public sealed record AuditTrailResponse +{ + public required string RequestId { get; init; } + public required IReadOnlyList Entries { get; init; } +} + +/// +/// Approval rule response. +/// +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; } +} + +/// +/// Approval rules list response. +/// +public sealed record ApprovalRulesResponse +{ + public required IReadOnlyList Rules { get; init; } +} diff --git a/src/Policy/StellaOps.Policy.Engine/Endpoints/Gateway/ExceptionEndpoints.cs b/src/Policy/StellaOps.Policy.Engine/Endpoints/Gateway/ExceptionEndpoints.cs new file mode 100644 index 000000000..832f07655 --- /dev/null +++ b/src/Policy/StellaOps.Policy.Engine/Endpoints/Gateway/ExceptionEndpoints.cs @@ -0,0 +1,585 @@ +// +// Copyright (c) StellaOps. All rights reserved. +// Licensed under the BUSL-1.1 license. +// + + +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; + +/// +/// Exception API endpoints for Policy Gateway. +/// +public static class ExceptionEndpoints +{ + /// + /// Maps exception endpoints to the application. + /// + 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( + [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( + 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( + 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( + 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( + 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.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( + 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( + 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( + 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( + 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( + 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( + [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 +} diff --git a/src/Policy/StellaOps.Policy.Engine/Endpoints/Gateway/GateEndpoints.cs b/src/Policy/StellaOps.Policy.Engine/Endpoints/Gateway/GateEndpoints.cs new file mode 100644 index 000000000..57c96ea33 --- /dev/null +++ b/src/Policy/StellaOps.Policy.Engine/Endpoints/Gateway/GateEndpoints.cs @@ -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; + +/// +/// Gate API endpoints for CI/CD release gating. +/// +public static class GateEndpoints +{ + private const string DeltaCachePrefix = "delta:"; + private static readonly TimeSpan DeltaCacheDuration = TimeSpan.FromMinutes(30); + + /// + /// Maps gate endpoints to the application. + /// + 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( + HttpContext httpContext, + GateEvaluateRequest request, + IDriftGateEvaluator gateEvaluator, + IDeltaComputer deltaComputer, + IBaselineSelector baselineSelector, + IGateBypassAuditor bypassAuditor, + IMemoryCache cache, + [FromServices] TimeProvider timeProvider, + ILogger 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( + 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 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(); + var newlyReachableSinkIds = new List(); + var newlyUnreachableSinkIds = new List(); + 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" + }; + } +} diff --git a/src/Policy/StellaOps.Policy.Engine/Endpoints/Gateway/GatesEndpoints.cs b/src/Policy/StellaOps.Policy.Engine/Endpoints/Gateway/GatesEndpoints.cs new file mode 100644 index 000000000..89fbe520e --- /dev/null +++ b/src/Policy/StellaOps.Policy.Engine/Endpoints/Gateway/GatesEndpoints.cs @@ -0,0 +1,1014 @@ +// ----------------------------------------------------------------------------- +// GatesEndpoints.cs +// Sprint: SPRINT_20260118_018_Unknowns_queue_enhancement +// Task: UQ-006 - Implement GET /gates/{bom_ref} endpoint +// Description: REST endpoint for gate check with unknowns state +// ----------------------------------------------------------------------------- + + +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Caching.Memory; +using StellaOps.Auth.Abstractions; +using StellaOps.Auth.ServerIntegration; +using StellaOps.Auth.ServerIntegration.Tenancy; +using StellaOps.Policy.Gates; +using StellaOps.Policy.Persistence.Postgres.Repositories; +using System.Text.Json.Serialization; +using static StellaOps.Localization.T; + +namespace StellaOps.Policy.Engine.Endpoints.Gateway; + +/// +/// REST endpoints for gate checks. +/// +public static class GatesEndpoints +{ + private const string CachePrefix = "gates:"; + private static readonly TimeSpan CacheTtl = TimeSpan.FromSeconds(30); + + /// + /// Maps gate endpoints. + /// + public static IEndpointRouteBuilder MapGatesEndpoints(this IEndpointRouteBuilder endpoints) + { + var group = endpoints.MapGroup("/api/v1/gates") + .WithTags("Gates") + .RequireTenant(); + + group.MapGet("/{bomRef}", GetGateStatus) + .RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead)) + .WithName("GetGateStatus") + .WithSummary("Get gate check result for a component") + .WithDescription("Retrieve the current unknowns state and gate decision for a BOM reference. Returns the aggregate state across all unknowns (resolved, pending, under_review, or escalated), per-unknown band and SLA details, and a cached gate decision. Results are cached for 30 seconds to reduce database load under CI/CD polling."); + + group.MapPost("/{bomRef}/check", CheckGate) + .RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRun)) + .WithName("CheckGate") + .WithSummary("Perform gate check for a component") + .WithDescription("Perform a fresh gate check for a BOM reference with an optional proposed VEX verdict. Returns a pass, warn, or block decision with the list of blocking unknown IDs and the reason for the decision. Returns HTTP 403 when the gate is blocked."); + + group.MapPost("/{bomRef}/exception", RequestException) + .RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyAuthor)) + .WithName("RequestGateException") + .WithSummary("Request an exception to bypass the gate") + .WithDescription("Submit an exception request to bypass blocking unknowns for a BOM reference. Requires a justification and a list of unknown IDs to exempt. Returns an exception record with granted status, expiry, and optional denial reason when auto-approval is not available."); + + group.MapGet("/{gateId}/decisions", GetGateDecisionHistory) + .RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyAudit)) + .WithName("GetGateDecisionHistory") + .WithSummary("Get historical gate decisions") + .WithDescription("Retrieve a paginated list of historical gate decisions for a gate identifier, with optional filtering by BOM reference, status, actor, and date range. Returns verdict hashes and policy bundle IDs for replay verification and compliance audit."); + + group.MapGet("/decisions/{decisionId}", GetGateDecisionById) + .RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyAudit)) + .WithName("GetGateDecisionById") + .WithSummary("Get a specific gate decision by ID") + .WithDescription("Retrieve full details of a specific gate decision by its UUID, including BOM reference, image digest, gate status, verdict hash, policy bundle ID and hash, CI/CD context, actor, blocking unknowns, and warnings."); + + group.MapGet("/decisions/{decisionId}/export", ExportGateDecision) + .RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyAudit)) + .WithName("ExportGateDecision") + .WithSummary("Export gate decision in CI/CD format") + .WithDescription("Export a gate decision in JUnit XML, SARIF 2.1.0, or JSON format for integration with CI/CD pipelines. The JUnit format is compatible with Jenkins, GitHub Actions, and GitLab CI; SARIF is compatible with GitHub Code Scanning and VS Code; JSON provides the full structured decision for custom integrations."); + + return endpoints; + } + + /// + /// GET /gates/{bom_ref} + /// Returns the current unknowns state for a component. + /// + private static async Task GetGateStatus( + [FromRoute] string bomRef, + [FromServices] IUnknownsGateChecker gateChecker, + [FromServices] IMemoryCache cache, + CancellationToken ct) + { + // Decode the bom_ref (URL encoded) + var decodedBomRef = Uri.UnescapeDataString(bomRef); + + // Check cache + var cacheKey = $"{CachePrefix}{decodedBomRef}"; + if (cache.TryGetValue(cacheKey, out var cached) && cached != null) + { + return Results.Ok(cached); + } + + // Get unknowns and gate decision + var unknowns = await gateChecker.GetUnknownsAsync(decodedBomRef, ct); + var checkResult = await gateChecker.CheckAsync(decodedBomRef, null, ct); + + // Build response + var response = new GateStatusResponse + { + BomRef = decodedBomRef, + State = DetermineAggregateState(unknowns), + VerdictHash = checkResult.Decision == GateDecision.Pass + ? ComputeVerdictHash(decodedBomRef, unknowns) + : null, + Unknowns = unknowns.Select(u => new UnknownDto + { + UnknownId = u.UnknownId, + CveId = u.CveId, + Band = u.Band, + SlaRemainingHours = u.SlaRemainingHours, + State = u.State + }).ToList(), + GateDecision = checkResult.Decision.ToString().ToLowerInvariant(), + CheckedAt = DateTimeOffset.UtcNow + }; + + // Cache the response + cache.Set(cacheKey, response, CacheTtl); + + return Results.Ok(response); + } + + /// + /// POST /gates/{bom_ref}/check + /// Performs a gate check with optional verdict. + /// + private static async Task CheckGate( + [FromRoute] string bomRef, + [FromBody] GateCheckRequest request, + [FromServices] IUnknownsGateChecker gateChecker, + CancellationToken ct) + { + var decodedBomRef = Uri.UnescapeDataString(bomRef); + + var result = await gateChecker.CheckAsync( + decodedBomRef, + request.ProposedVerdict, + ct); + + var response = new GateCheckResponse + { + BomRef = decodedBomRef, + Decision = result.Decision.ToString().ToLowerInvariant(), + State = result.State, + BlockingUnknownIds = result.BlockingUnknownIds.ToList(), + Reason = result.Reason, + ExceptionGranted = result.ExceptionGranted, + ExceptionRef = result.ExceptionRef, + CheckedAt = DateTimeOffset.UtcNow + }; + + var statusCode = result.Decision switch + { + GateDecision.Pass => StatusCodes.Status200OK, + GateDecision.Warn => StatusCodes.Status200OK, + GateDecision.Block => StatusCodes.Status403Forbidden, + _ => StatusCodes.Status200OK + }; + + return Results.Json(response, statusCode: statusCode); + } + + /// + /// POST /gates/{bom_ref}/exception + /// Requests an exception to bypass blocking unknowns. + /// + private static async Task RequestException( + [FromRoute] string bomRef, + [FromBody] ExceptionRequest request, + [FromServices] IUnknownsGateChecker gateChecker, + HttpContext httpContext, + CancellationToken ct) + { + // Validate required fields + if (string.IsNullOrWhiteSpace(request.Justification)) + { + return Results.BadRequest(new { error = _t("policy.validation.justification_required") }); + } + + var decodedBomRef = Uri.UnescapeDataString(bomRef); + var requestedBy = httpContext.User.Identity?.Name ?? "anonymous"; + + var result = await gateChecker.RequestExceptionAsync( + decodedBomRef, + request.UnknownIds, + request.Justification, + requestedBy, + ct); + + var now = DateTimeOffset.UtcNow; + var exceptionId = result.ExceptionRef ?? $"EXC-{Guid.NewGuid():N}"[..20]; + + var response = new GateExceptionCreatedResponse + { + ExceptionId = exceptionId, + Status = result.Granted ? "active" : "denied", + CreatedAt = now, + UpdatedAt = now, + Granted = result.Granted, + ExceptionRef = result.ExceptionRef, + DenialReason = result.DenialReason, + ExpiresAt = result.ExpiresAt ?? now.AddDays(30), + RequesterId = requestedBy, + Rationale = request.Justification ?? "", + Scope = new GateExceptionScope { Type = "component", Target = decodedBomRef } + }; + + return result.Granted + ? Results.Ok(response) + : Results.Json(response, statusCode: StatusCodes.Status403Forbidden); + } + + /// + /// GET /gates/{gateId}/decisions + /// Returns historical gate decisions for a gate. + /// + private static async Task GetGateDecisionHistory( + [FromRoute] string gateId, + [FromQuery] int? limit, + [FromQuery] string? from_date, + [FromQuery] string? to_date, + [FromQuery] string? status, + [FromQuery] string? actor, + [FromQuery] string? bom_ref, + [FromQuery] string? continuation_token, + [FromServices] IGateDecisionHistoryRepository historyRepository, + HttpContext httpContext, + CancellationToken ct) + { + // Parse tenant from context (simplified - would come from auth) + var tenantId = httpContext.Items.TryGetValue("TenantId", out var tid) && tid is Guid g + ? g + : Guid.Empty; + + var query = new GateDecisionHistoryQuery + { + TenantId = tenantId, + GateId = Uri.UnescapeDataString(gateId), + BomRef = bom_ref, + Limit = limit ?? 50, + ContinuationToken = continuation_token, + Status = status, + Actor = actor, + FromDate = string.IsNullOrEmpty(from_date) ? null : DateTimeOffset.Parse(from_date), + ToDate = string.IsNullOrEmpty(to_date) ? null : DateTimeOffset.Parse(to_date) + }; + + var result = await historyRepository.GetDecisionsAsync(query, ct); + + var response = new GateDecisionHistoryResponse + { + Decisions = result.Decisions.Select(d => new GateDecisionDto + { + DecisionId = d.DecisionId, + BomRef = d.BomRef, + ImageDigest = d.ImageDigest, + GateStatus = d.GateStatus, + VerdictHash = d.VerdictHash, + PolicyBundleId = d.PolicyBundleId, + PolicyBundleHash = d.PolicyBundleHash, + EvaluatedAt = new DateTimeOffset(d.EvaluatedAt, TimeSpan.Zero), + CiContext = d.CiContext, + Actor = d.Actor, + BlockingUnknownIds = d.BlockingUnknownIds, + Warnings = d.Warnings + }).ToList(), + Total = result.Total, + ContinuationToken = result.ContinuationToken + }; + + return Results.Ok(response); + } + + /// + /// GET /gates/decisions/{decisionId} + /// Returns a specific gate decision by ID. + /// + private static async Task GetGateDecisionById( + [FromRoute] Guid decisionId, + [FromServices] IGateDecisionHistoryRepository historyRepository, + HttpContext httpContext, + CancellationToken ct) + { + var tenantId = httpContext.Items.TryGetValue("TenantId", out var tid) && tid is Guid g + ? g + : Guid.Empty; + + var decision = await historyRepository.GetDecisionByIdAsync(decisionId, tenantId, ct); + + if (decision is null) + { + return Results.NotFound(new { error = _t("policy.error.decision_not_found"), decision_id = decisionId }); + } + + var response = new GateDecisionDto + { + DecisionId = decision.DecisionId, + BomRef = decision.BomRef, + ImageDigest = decision.ImageDigest, + GateStatus = decision.GateStatus, + VerdictHash = decision.VerdictHash, + PolicyBundleId = decision.PolicyBundleId, + PolicyBundleHash = decision.PolicyBundleHash, + EvaluatedAt = new DateTimeOffset(decision.EvaluatedAt, TimeSpan.Zero), + CiContext = decision.CiContext, + Actor = decision.Actor, + BlockingUnknownIds = decision.BlockingUnknownIds, + Warnings = decision.Warnings + }; + + return Results.Ok(response); + } + + /// + /// GET /gates/decisions/{decisionId}/export + /// Exports a gate decision in CI/CD format. + /// Sprint: SPRINT_20260118_019 (GR-008) + /// + private static async Task ExportGateDecision( + [FromRoute] Guid decisionId, + [FromQuery] string? format, + [FromServices] IGateDecisionHistoryRepository historyRepository, + HttpContext httpContext, + CancellationToken ct) + { + var tenantId = httpContext.Items.TryGetValue("TenantId", out var tid) && tid is Guid g + ? g + : Guid.Empty; + + var decision = await historyRepository.GetDecisionByIdAsync(decisionId, tenantId, ct); + + if (decision is null) + { + return Results.NotFound(new { error = _t("policy.error.decision_not_found"), decision_id = decisionId }); + } + + var exportFormat = (format?.ToLowerInvariant()) switch + { + "junit" => ExportFormat.JUnit, + "sarif" => ExportFormat.Sarif, + "json" => ExportFormat.Json, + _ => ExportFormat.Json + }; + + return exportFormat switch + { + ExportFormat.JUnit => ExportAsJUnit(decision), + ExportFormat.Sarif => ExportAsSarif(decision), + _ => ExportAsJson(decision) + }; + } + + /// + /// Exports decision as JUnit XML. + /// + private static IResult ExportAsJUnit(GateDecisionRecord decision) + { + var passed = decision.GateStatus.Equals("pass", StringComparison.OrdinalIgnoreCase); + var testCaseName = $"gate-check-{decision.BomRef}"; + var suiteName = "StellaOps Gate Check"; + + var xml = $""" + + + + + {(passed ? "" : $""" + + Decision ID: {decision.DecisionId} + BOM Reference: {decision.BomRef} + Status: {decision.GateStatus} + Evaluated At: {decision.EvaluatedAt:O} + Policy Bundle: {decision.PolicyBundleId ?? "N/A"} + Blocking Unknowns: {decision.BlockingUnknownIds.Count} + Warnings: {string.Join(", ", decision.Warnings)} + + """)} + + + + """; + + return Results.Content(xml, "application/xml"); + } + + /// + /// Exports decision as SARIF 2.1.0. + /// + private static IResult ExportAsSarif(GateDecisionRecord decision) + { + var passed = decision.GateStatus.Equals("pass", StringComparison.OrdinalIgnoreCase); + var level = decision.GateStatus.ToLowerInvariant() switch + { + "pass" => "none", + "warn" => "warning", + "block" => "error", + _ => "note" + }; + + var results = new List(); + + // Add blocking unknowns as results + foreach (var unknownId in decision.BlockingUnknownIds) + { + results.Add(new + { + ruleId = "GATE001", + level = level, + message = new { text = $"Blocking unknown: {unknownId}" }, + locations = new[] + { + new + { + physicalLocation = new + { + artifactLocation = new { uri = decision.BomRef } + } + } + } + }); + } + + // Add warnings as results + foreach (var warning in decision.Warnings) + { + results.Add(new + { + ruleId = "GATE002", + level = "warning", + message = new { text = warning }, + locations = new[] + { + new + { + physicalLocation = new + { + artifactLocation = new { uri = decision.BomRef } + } + } + } + }); + } + + // If no results, add a summary result + if (results.Count == 0) + { + results.Add(new + { + ruleId = "GATE000", + level = level, + message = new { text = $"Gate check {decision.GateStatus}: {decision.BomRef}" }, + locations = new[] + { + new + { + physicalLocation = new + { + artifactLocation = new { uri = decision.BomRef } + } + } + } + }); + } + + var sarif = new + { + version = "2.1.0", + schema = "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json", + runs = new[] + { + new + { + tool = new + { + driver = new + { + name = "StellaOps Gate", + version = "1.0.0", + informationUri = "https://stella-ops.org", + rules = new[] + { + new + { + id = "GATE000", + shortDescription = new { text = "Gate Check Result" }, + fullDescription = new { text = "Summary of gate check result" } + }, + new + { + id = "GATE001", + shortDescription = new { text = "Blocking Unknown" }, + fullDescription = new { text = "A security unknown is blocking the release" } + }, + new + { + id = "GATE002", + shortDescription = new { text = "Gate Warning" }, + fullDescription = new { text = "A warning was generated during gate evaluation" } + } + } + } + }, + results = results, + invocations = new[] + { + new + { + executionSuccessful = passed, + endTimeUtc = decision.EvaluatedAt.ToString("O"), + properties = new + { + decisionId = decision.DecisionId.ToString(), + bomRef = decision.BomRef, + gateStatus = decision.GateStatus, + verdictHash = decision.VerdictHash, + policyBundleId = decision.PolicyBundleId, + policyBundleHash = decision.PolicyBundleHash, + actor = decision.Actor + } + } + } + } + } + }; + + var json = System.Text.Json.JsonSerializer.Serialize(sarif, new System.Text.Json.JsonSerializerOptions + { + WriteIndented = true + }); + + return Results.Content(json, "application/sarif+json"); + } + + /// + /// Exports decision as JSON. + /// + private static IResult ExportAsJson(GateDecisionRecord decision) + { + var response = new GateDecisionExportJson + { + DecisionId = decision.DecisionId, + BomRef = decision.BomRef, + ImageDigest = decision.ImageDigest, + GateStatus = decision.GateStatus, + VerdictHash = decision.VerdictHash, + PolicyBundleId = decision.PolicyBundleId, + PolicyBundleHash = decision.PolicyBundleHash, + EvaluatedAt = new DateTimeOffset(decision.EvaluatedAt, TimeSpan.Zero), + CiContext = decision.CiContext, + Actor = decision.Actor, + BlockingUnknownIds = decision.BlockingUnknownIds, + Warnings = decision.Warnings, + ExitCode = decision.GateStatus.ToLowerInvariant() switch + { + "pass" => 0, + "warn" => 1, + "block" => 2, + _ => 1 + } + }; + + return Results.Json(response, contentType: "application/json"); + } + + /// + /// Determines the worst-case state across all unknowns. + /// + private static string DetermineAggregateState(IReadOnlyList unknowns) + { + if (unknowns.Count == 0) + { + return "resolved"; + } + + // Priority: escalated > under_review > pending > resolved + if (unknowns.Any(u => u.State == "escalated")) + { + return "escalated"; + } + if (unknowns.Any(u => u.State == "under_review")) + { + return "under_review"; + } + if (unknowns.Any(u => u.State == "pending")) + { + return "pending"; + } + + return "resolved"; + } + + /// + /// Computes a deterministic verdict hash for caching/verification. + /// + private static string ComputeVerdictHash(string bomRef, IReadOnlyList unknowns) + { + var input = $"{bomRef}:{unknowns.Count}:{DateTimeOffset.UtcNow:yyyyMMddHH}"; + var bytes = System.Security.Cryptography.SHA256.HashData( + System.Text.Encoding.UTF8.GetBytes(input)); + return $"sha256:{Convert.ToHexString(bytes).ToLowerInvariant()}"; + } +} + +#region Request/Response DTOs + +/// +/// Gate status response. +/// +public sealed record GateStatusResponse +{ + /// BOM reference. + [JsonPropertyName("bom_ref")] + public required string BomRef { get; init; } + + /// Aggregate state: resolved, pending, under_review, escalated, rejected. + [JsonPropertyName("state")] + public required string State { get; init; } + + /// Verdict hash if resolved. + [JsonPropertyName("verdict_hash")] + public string? VerdictHash { get; init; } + + /// Individual unknowns. + [JsonPropertyName("unknowns")] + public List Unknowns { get; init; } = []; + + /// Gate decision: pass, warn, block. + [JsonPropertyName("gate_decision")] + public required string GateDecision { get; init; } + + /// When checked. + [JsonPropertyName("checked_at")] + public DateTimeOffset CheckedAt { get; init; } +} + +/// +/// Unknown DTO for API response. +/// +public sealed record UnknownDto +{ + /// Unknown ID. + [JsonPropertyName("unknown_id")] + public Guid UnknownId { get; init; } + + /// CVE ID if applicable. + [JsonPropertyName("cve_id")] + public string? CveId { get; init; } + + /// Priority band: hot, warm, cold. + [JsonPropertyName("band")] + public required string Band { get; init; } + + /// SLA remaining hours. + [JsonPropertyName("sla_remaining_hours")] + public double? SlaRemainingHours { get; init; } + + /// State: pending, under_review, escalated, resolved, rejected. + [JsonPropertyName("state")] + public required string State { get; init; } +} + +/// +/// Gate check request. +/// +public sealed record GateCheckRequest +{ + /// Proposed VEX verdict (e.g., "not_affected"). + [JsonPropertyName("proposed_verdict")] + public string? ProposedVerdict { get; init; } +} + +/// +/// Gate check response. +/// +public sealed record GateCheckResponse +{ + /// BOM reference. + [JsonPropertyName("bom_ref")] + public required string BomRef { get; init; } + + /// Decision: pass, warn, block. + [JsonPropertyName("decision")] + public required string Decision { get; init; } + + /// Current state. + [JsonPropertyName("state")] + public required string State { get; init; } + + /// Blocking unknown IDs. + [JsonPropertyName("blocking_unknown_ids")] + public List BlockingUnknownIds { get; init; } = []; + + /// Reason for decision. + [JsonPropertyName("reason")] + public string? Reason { get; init; } + + /// Whether exception was granted. + [JsonPropertyName("exception_granted")] + public bool ExceptionGranted { get; init; } + + /// Exception reference if granted. + [JsonPropertyName("exception_ref")] + public string? ExceptionRef { get; init; } + + /// When checked. + [JsonPropertyName("checked_at")] + public DateTimeOffset CheckedAt { get; init; } +} + +/// +/// Exception request. +/// +public sealed record ExceptionRequest +{ + /// Unknown IDs to bypass. + [JsonPropertyName("unknown_ids")] + public List UnknownIds { get; init; } = []; + + /// Justification for bypass. + [JsonPropertyName("justification")] + public string? Justification { get; init; } +} + +/// +/// Exception response. +/// +public sealed record GateExceptionResponse +{ + /// Whether exception was granted. + [JsonPropertyName("granted")] + public bool Granted { get; init; } + + /// Exception reference. + [JsonPropertyName("exception_ref")] + public string? ExceptionRef { get; init; } + + /// Denial reason if not granted. + [JsonPropertyName("denial_reason")] + public string? DenialReason { get; init; } + + /// When exception expires. + [JsonPropertyName("expires_at")] + public DateTimeOffset? ExpiresAt { get; init; } + + /// When requested. + [JsonPropertyName("requested_at")] + public DateTimeOffset RequestedAt { get; init; } +} + +/// +/// Response for gate exception creation. +/// Uses camelCase property names to match ExceptionResponse contract. +/// +public sealed record GateExceptionCreatedResponse +{ + /// Exception identifier. + [JsonPropertyName("exceptionId")] + public required string ExceptionId { get; init; } + + /// Current status of the exception. + [JsonPropertyName("status")] + public required string Status { get; init; } + + /// When the exception was created. + [JsonPropertyName("createdAt")] + public DateTimeOffset CreatedAt { get; init; } + + /// Whether exception was granted. + [JsonPropertyName("granted")] + public bool Granted { get; init; } + + /// Exception reference. + [JsonPropertyName("exceptionRef")] + public string? ExceptionRef { get; init; } + + /// Denial reason if not granted. + [JsonPropertyName("denialReason")] + public string? DenialReason { get; init; } + + /// When exception expires. + [JsonPropertyName("expiresAt")] + public DateTimeOffset? ExpiresAt { get; init; } + + /// Version for optimistic concurrency. + [JsonPropertyName("version")] + public int Version { get; init; } = 1; + + /// Exception type. + [JsonPropertyName("type")] + public string Type { get; init; } = "gate_bypass"; + + /// Owner ID. + [JsonPropertyName("ownerId")] + public string OwnerId { get; init; } = "system"; + + /// Requester ID. + [JsonPropertyName("requesterId")] + public string RequesterId { get; init; } = "anonymous"; + + /// Approver IDs. + [JsonPropertyName("approverIds")] + public IReadOnlyList ApproverIds { get; init; } = []; + + /// Last updated timestamp. + [JsonPropertyName("updatedAt")] + public DateTimeOffset UpdatedAt { get; init; } + + /// Reason code. + [JsonPropertyName("reasonCode")] + public string ReasonCode { get; init; } = "gate_bypass"; + + /// Rationale. + [JsonPropertyName("rationale")] + public string Rationale { get; init; } = ""; + + /// Evidence references. + [JsonPropertyName("evidenceRefs")] + public IReadOnlyList EvidenceRefs { get; init; } = []; + + /// Compensating controls. + [JsonPropertyName("compensatingControls")] + public IReadOnlyList CompensatingControls { get; init; } = []; + + /// Exception scope. + [JsonPropertyName("scope")] + public GateExceptionScope Scope { get; init; } = new(); + + /// Metadata. + [JsonPropertyName("metadata")] + public IReadOnlyDictionary Metadata { get; init; } = new Dictionary(); +} + +/// +/// Scope for gate exception. +/// +public sealed record GateExceptionScope +{ + /// Scope type. + [JsonPropertyName("type")] + public string Type { get; init; } = "component"; + + /// Scope target. + [JsonPropertyName("target")] + public string Target { get; init; } = ""; +} + +#endregion + +#region Gate Decision History DTOs + +/// +/// Response for gate decision history query. +/// Sprint: SPRINT_20260118_019_Policy_gate_replay_api_exposure +/// Task: GR-005 - Add gate decision history endpoint +/// +public sealed record GateDecisionHistoryResponse +{ + /// List of gate decisions. + [JsonPropertyName("decisions")] + public List Decisions { get; init; } = []; + + /// Total count of matching decisions. + [JsonPropertyName("total")] + public long Total { get; init; } + + /// Token for fetching next page. + [JsonPropertyName("continuation_token")] + public string? ContinuationToken { get; init; } +} + +/// +/// Gate decision DTO for API response. +/// +public sealed record GateDecisionDto +{ + /// Unique decision ID. + [JsonPropertyName("decision_id")] + public Guid DecisionId { get; init; } + + /// BOM reference. + [JsonPropertyName("bom_ref")] + public required string BomRef { get; init; } + + /// Image digest if applicable. + [JsonPropertyName("image_digest")] + public string? ImageDigest { get; init; } + + /// Gate decision: pass, warn, block. + [JsonPropertyName("gate_status")] + public required string GateStatus { get; init; } + + /// Verdict hash for replay verification. + [JsonPropertyName("verdict_hash")] + public string? VerdictHash { get; init; } + + /// Policy bundle ID used for evaluation. + [JsonPropertyName("policy_bundle_id")] + public string? PolicyBundleId { get; init; } + + /// Policy bundle content hash. + [JsonPropertyName("policy_bundle_hash")] + public string? PolicyBundleHash { get; init; } + + /// When the evaluation occurred. + [JsonPropertyName("evaluated_at")] + public DateTimeOffset EvaluatedAt { get; init; } + + /// CI/CD context (branch, commit, pipeline). + [JsonPropertyName("ci_context")] + public string? CiContext { get; init; } + + /// Actor who triggered evaluation. + [JsonPropertyName("actor")] + public string? Actor { get; init; } + + /// IDs of unknowns that blocked the release. + [JsonPropertyName("blocking_unknown_ids")] + public List BlockingUnknownIds { get; init; } = []; + + /// Warning messages. + [JsonPropertyName("warnings")] + public List Warnings { get; init; } = []; +} + +#endregion + +#region CI/CD Export DTOs (GR-008) + +/// +/// Export format for gate decisions. +/// Sprint: SPRINT_20260118_019_Policy_gate_replay_api_exposure +/// Task: GR-008 - Implement CI/CD status export formats +/// +public enum ExportFormat +{ + /// JSON format for custom integrations. + Json, + + /// JUnit XML format for Jenkins, GitHub Actions, GitLab CI. + JUnit, + + /// SARIF 2.1.0 format for GitHub Code Scanning, VS Code. + Sarif +} + +/// +/// Gate decision export JSON format for CI/CD integration. +/// +public sealed record GateDecisionExportJson +{ + /// Unique decision ID. + [JsonPropertyName("decision_id")] + public Guid DecisionId { get; init; } + + /// BOM reference. + [JsonPropertyName("bom_ref")] + public required string BomRef { get; init; } + + /// Image digest if applicable. + [JsonPropertyName("image_digest")] + public string? ImageDigest { get; init; } + + /// Gate decision: pass, warn, block. + [JsonPropertyName("gate_status")] + public required string GateStatus { get; init; } + + /// Verdict hash for replay verification. + [JsonPropertyName("verdict_hash")] + public string? VerdictHash { get; init; } + + /// Policy bundle ID used for evaluation. + [JsonPropertyName("policy_bundle_id")] + public string? PolicyBundleId { get; init; } + + /// Policy bundle content hash. + [JsonPropertyName("policy_bundle_hash")] + public string? PolicyBundleHash { get; init; } + + /// When the evaluation occurred. + [JsonPropertyName("evaluated_at")] + public DateTimeOffset EvaluatedAt { get; init; } + + /// CI/CD context (branch, commit, pipeline). + [JsonPropertyName("ci_context")] + public string? CiContext { get; init; } + + /// Actor who triggered evaluation. + [JsonPropertyName("actor")] + public string? Actor { get; init; } + + /// IDs of unknowns that blocked the release. + [JsonPropertyName("blocking_unknown_ids")] + public List BlockingUnknownIds { get; init; } = []; + + /// Warning messages. + [JsonPropertyName("warnings")] + public List Warnings { get; init; } = []; + + /// + /// Exit code for CI/CD script integration. + /// 0 = pass, 1 = warn, 2 = block + /// + [JsonPropertyName("exit_code")] + public int ExitCode { get; init; } +} + +#endregion diff --git a/src/Policy/StellaOps.Policy.Engine/Endpoints/Gateway/GovernanceCompatibilityEndpoints.cs b/src/Policy/StellaOps.Policy.Engine/Endpoints/Gateway/GovernanceCompatibilityEndpoints.cs new file mode 100644 index 000000000..17e69bd2d --- /dev/null +++ b/src/Policy/StellaOps.Policy.Engine/Endpoints/Gateway/GovernanceCompatibilityEndpoints.cs @@ -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 TrustWeightStates = new(StringComparer.OrdinalIgnoreCase); + private static readonly ConcurrentDictionary StalenessStates = new(StringComparer.OrdinalIgnoreCase); + private static readonly ConcurrentDictionary 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(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 + { + 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 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 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 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 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 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 conflicts) + { + public string TenantId { get; private set; } = tenantId; + public string? ProjectId { get; private set; } = projectId; + public string LastAnalyzedAt { get; private set; } = lastAnalyzedAt; + public List Conflicts { get; } = conflicts; + + public object ToDashboard() + { + var byType = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["rule_overlap"] = 0, + ["precedence_ambiguity"] = 0, + ["circular_dependency"] = 0, + ["incompatible_actions"] = 0, + ["scope_collision"] = 0 + }; + var bySeverity = new Dictionary(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 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(); + } + + 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 Thresholds, + bool Enabled, + int GracePeriodHours); + + private sealed record StalenessThresholdRecord( + string Level, + int AgeDays, + string Severity, + List 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 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? Weights); + +public sealed record StalenessConfigWriteModel +{ + public string? DataType { get; init; } + public IReadOnlyList? 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? Actions { get; init; } +} + +public sealed record StalenessActionWriteModel +{ + public string? Type { get; init; } + public string? Message { get; init; } + public string[]? Channels { get; init; } +} diff --git a/src/Policy/StellaOps.Policy.Engine/Endpoints/Gateway/GovernanceEndpoints.cs b/src/Policy/StellaOps.Policy.Engine/Endpoints/Gateway/GovernanceEndpoints.cs new file mode 100644 index 000000000..1c403aafd --- /dev/null +++ b/src/Policy/StellaOps.Policy.Engine/Endpoints/Gateway/GovernanceEndpoints.cs @@ -0,0 +1,1068 @@ +// SPDX-License-Identifier: BUSL-1.1 +// Sprint: SPRINT_20251229_021a_FE_policy_governance_controls +// Task: GOV-018 - Sealed mode overrides and risk profile events endpoints + + +using Microsoft.AspNetCore.Mvc; +using StellaOps.Auth.Abstractions; +using StellaOps.Auth.ServerIntegration; +using StellaOps.Auth.ServerIntegration.Tenancy; +using System.Collections.Concurrent; +using System.Globalization; +using System.Text.Json; + +namespace StellaOps.Policy.Engine.Endpoints.Gateway; + +/// +/// Policy governance API endpoints for sealed mode and risk profile management. +/// +public static class GovernanceEndpoints +{ + // In-memory stores for development + private static readonly ConcurrentDictionary SealedModeStates = new(); + private static readonly ConcurrentDictionary Overrides = new(); + private static readonly ConcurrentDictionary RiskProfiles = new(); + private static readonly ConcurrentDictionary AuditEntries = new(); + + /// + /// Maps governance endpoints to the application. + /// + public static void MapGovernanceEndpoints(this WebApplication app) + { + var governance = app.MapGroup("/api/v1/governance") + .WithTags("Governance") + .RequireTenant(); + + // Sealed Mode endpoints + governance.MapGet("/sealed-mode/status", GetSealedModeStatusAsync) + .RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.AirgapStatusRead)) + .WithName("GetSealedModeStatus") + .WithDescription("Retrieve the current sealed mode status for the tenant, including whether the environment is sealed, when it was sealed, by whom, configured trust roots, allowed sources, and any active override entries. Returns HTTP 400 when no tenant can be resolved from the request context."); + + governance.MapGet("/sealed-mode/overrides", GetSealedModeOverridesAsync) + .RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.AirgapStatusRead)) + .WithName("GetSealedModeOverrides") + .WithDescription("List all sealed mode overrides for the tenant, including override type, target resource, approver IDs, expiry timestamp, and active status. Used by operators to audit active bypass grants and verify sealed posture integrity."); + + governance.MapPost("/sealed-mode/toggle", ToggleSealedModeAsync) + .RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.AirgapSeal)) + .WithName("ToggleSealedMode") + .WithDescription("Enable or disable sealed mode for the tenant. When enabling, records the sealing actor, timestamp, reason, trust roots, and allowed sources. When disabling, records the unseal timestamp. Every toggle is recorded as a governance audit event."); + + governance.MapPost("/sealed-mode/overrides", CreateSealedModeOverrideAsync) + .RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.AirgapSeal)) + .WithName("CreateSealedModeOverride") + .WithDescription("Create a time-limited override to allow a specific operation or target to bypass sealed mode restrictions. The override expires after the configured duration (defaulting to 24 hours) and is recorded in the governance audit log with the approving actor."); + + governance.MapPost("/sealed-mode/overrides/{overrideId}/revoke", RevokeSealedModeOverrideAsync) + .RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.AirgapSeal)) + .WithName("RevokeSealedModeOverride") + .WithDescription("Revoke an active sealed mode override before its natural expiry, providing an optional reason. The override is marked inactive immediately, preventing further bypass use. The revocation is recorded in the governance audit log."); + + // Risk Profile endpoints + governance.MapGet("/risk-profiles", ListRiskProfilesAsync) + .RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead)) + .WithName("ListRiskProfiles") + .WithDescription("List risk profiles for the tenant with optional status filtering (draft, active, deprecated). Each profile includes its signal configuration, severity overrides, action overrides, and lifecycle metadata. The default risk profile is always included in the response."); + + governance.MapGet("/risk-profiles/{profileId}", GetRiskProfileAsync) + .RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead)) + .WithName("GetRiskProfile") + .WithDescription("Retrieve the full configuration of a specific risk profile by its identifier, including all signals with weights and enabled state, severity and action overrides, and the profile version and lifecycle metadata."); + + governance.MapPost("/risk-profiles", CreateRiskProfileAsync) + .RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyAuthor)) + .WithName("CreateRiskProfile") + .WithDescription("Create a new risk profile in draft state with the specified signal configuration, severity overrides, and action overrides. The profile can optionally extend an existing base profile. Audit events are recorded for all profile changes."); + + governance.MapPut("/risk-profiles/{profileId}", UpdateRiskProfileAsync) + .RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyAuthor)) + .WithName("UpdateRiskProfile") + .WithDescription("Update the name, description, signals, severity overrides, or action overrides of an existing risk profile. Partial updates are supported: only supplied fields are changed. The modified-at timestamp and actor are updated on every successful write."); + + governance.MapDelete("/risk-profiles/{profileId}", DeleteRiskProfileAsync) + .RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyAuthor)) + .WithName("DeleteRiskProfile") + .WithDescription("Permanently delete a risk profile by its identifier, removing it from the tenant's profile registry. Returns HTTP 404 when the profile does not exist. Deletion is recorded as a governance audit event."); + + governance.MapPost("/risk-profiles/{profileId}/activate", ActivateRiskProfileAsync) + .RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyActivate)) + .WithName("ActivateRiskProfile") + .WithDescription("Transition a risk profile to the active state, making it the candidate for policy evaluation use. Records the activating actor and timestamp. Activation is an audit-logged, irreversible state transition from draft."); + + governance.MapPost("/risk-profiles/{profileId}/deprecate", DeprecateRiskProfileAsync) + .RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyActivate)) + .WithName("DeprecateRiskProfile") + .WithDescription("Transition a risk profile to the deprecated state with an optional deprecation reason. Deprecated profiles remain visible for audit and historical reference but should not be assigned to new policy evaluations."); + + governance.MapPost("/risk-profiles/validate", ValidateRiskProfileAsync) + .RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead)) + .WithName("ValidateRiskProfile") + .WithDescription("Validate a candidate risk profile configuration without persisting it. Checks for required fields (name, at least one signal) and emits warnings when signal weights do not sum to 1.0. Used by policy authoring tools to provide inline validation feedback before profile creation."); + + // Risk Budget endpoints + governance.MapGet("/risk-budget/dashboard", GetRiskBudgetDashboardAsync) + .RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead)) + .WithName("GetRiskBudgetDashboard") + .WithDescription("Retrieve the current risk budget dashboard including utilization, headroom, top contributors, active alerts, and KPIs for the tenant."); + + // Audit endpoints + governance.MapGet("/audit/events", GetAuditEventsAsync) + .RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyAudit)) + .WithName("GetGovernanceAuditEvents") + .WithDescription("Retrieve paginated governance audit events for the tenant, ordered by most recent first. Events cover sealed mode changes, override grants and revocations, and risk profile lifecycle actions. Requires tenant ID via header or query parameter."); + + governance.MapGet("/audit/events/{eventId}", GetAuditEventAsync) + .RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyAudit)) + .WithName("GetGovernanceAuditEvent") + .WithDescription("Retrieve a single governance audit event by its identifier, including event type, actor, target resource, timestamp, and human-readable summary. Returns HTTP 404 when the event does not exist or belongs to a different tenant."); + + // Initialize default profiles + InitializeDefaultProfiles(); + } + + // ======================================================================== + // Sealed Mode Handlers + // ======================================================================== + + private static Task GetSealedModeStatusAsync( + HttpContext httpContext, + [FromServices] TimeProvider timeProvider, + [FromQuery] string? tenantId) + { + var tenant = tenantId ?? GetTenantId(httpContext); + if (string.IsNullOrEmpty(tenant)) + { + return Task.FromResult(Results.BadRequest(new ProblemDetails + { + Title = "Tenant ID required", + Status = 400, + Detail = "Provide tenant via X-StellaOps-Tenant header or tenantId query parameter" + })); + } + + var state = SealedModeStates.GetOrAdd(tenant, _ => new SealedModeState()); + + var response = new SealedModeStatusResponse + { + IsSealed = state.IsSealed, + TenantId = tenant, + SealedAt = state.SealedAt, + SealedBy = state.SealedBy, + Reason = state.Reason, + TrustRoots = state.TrustRoots, + AllowedSources = state.AllowedSources, + Overrides = Overrides.Values + .Where(o => o.TenantId == tenant && o.Active) + .Select(MapOverrideToResponse) + .ToList(), + VerificationStatus = "verified", + LastVerifiedAt = timeProvider.GetUtcNow().ToString("O", CultureInfo.InvariantCulture) + }; + + return Task.FromResult(Results.Ok(response)); + } + + private static Task GetSealedModeOverridesAsync( + HttpContext httpContext, + [FromQuery] string? tenantId) + { + var tenant = tenantId ?? GetTenantId(httpContext) ?? "default"; + + var overrides = Overrides.Values + .Where(o => o.TenantId == tenant) + .Select(MapOverrideToResponse) + .ToList(); + + return Task.FromResult(Results.Ok(new { overrides })); + } + + private static Task ToggleSealedModeAsync( + HttpContext httpContext, + [FromServices] TimeProvider timeProvider, + SealedModeToggleRequest request) + { + var tenant = GetTenantId(httpContext) ?? "default"; + var actor = GetActorId(httpContext) ?? "system"; + var now = timeProvider.GetUtcNow(); + + var state = SealedModeStates.GetOrAdd(tenant, _ => new SealedModeState()); + + if (request.Enable) + { + state = new SealedModeState + { + IsSealed = true, + SealedAt = now.ToString("O", CultureInfo.InvariantCulture), + SealedBy = actor, + Reason = request.Reason, + TrustRoots = request.TrustRoots ?? [], + AllowedSources = request.AllowedSources ?? [] + }; + } + else + { + state = new SealedModeState + { + IsSealed = false, + LastUnsealedAt = now.ToString("O", CultureInfo.InvariantCulture) + }; + } + + SealedModeStates[tenant] = state; + + // Audit + RecordAudit(tenant, actor, "sealed_mode_toggled", "sealed-mode", "system_config", + $"{(request.Enable ? "Enabled" : "Disabled")} sealed mode: {request.Reason}", timeProvider); + + var response = new SealedModeStatusResponse + { + IsSealed = state.IsSealed, + TenantId = tenant, + SealedAt = state.SealedAt, + SealedBy = state.SealedBy, + Reason = state.Reason, + TrustRoots = state.TrustRoots, + AllowedSources = state.AllowedSources, + Overrides = [], + VerificationStatus = "verified", + LastVerifiedAt = now.ToString("O", CultureInfo.InvariantCulture) + }; + + return Task.FromResult(Results.Ok(response)); + } + + private static Task CreateSealedModeOverrideAsync( + HttpContext httpContext, + [FromServices] TimeProvider timeProvider, + SealedModeOverrideRequest request) + { + var tenant = GetTenantId(httpContext) ?? "default"; + var actor = request.Actor ?? GetActorId(httpContext) ?? "system"; + var now = timeProvider.GetUtcNow(); + + var overrideId = $"override-{Guid.NewGuid():N}"; + var overrideType = request.OverrideType ?? request.Type ?? "general"; + var durationHours = request.DurationMinutes > 0 + ? (double)request.DurationMinutes / 60.0 + : request.DurationHours > 0 ? request.DurationHours : 24; + + var entity = new SealedModeOverrideEntity + { + Id = overrideId, + TenantId = tenant, + Type = overrideType, + Target = request.Target ?? "system", + Reason = request.Reason ?? "", + ApprovalId = $"approval-{Guid.NewGuid():N}", + ApprovedBy = [actor], + ExpiresAt = now.AddHours(durationHours).ToString("O", CultureInfo.InvariantCulture), + CreatedAt = now.ToString("O", CultureInfo.InvariantCulture), + Active = true + }; + + Overrides[overrideId] = entity; + + RecordAudit(tenant, actor, "sealed_mode_override_created", overrideId, "sealed_mode_override", + $"Created override for {entity.Target}: {entity.Reason}", timeProvider); + + return Task.FromResult(Results.Created( + $"/api/v1/governance/sealed-mode/overrides/{overrideId}", + new SealedModeOverrideCreatedResponse + { + OverrideId = overrideId, + OverrideType = overrideType, + Reason = entity.Reason, + ExpiresAt = entity.ExpiresAt, + CreatedAt = entity.CreatedAt, + Active = entity.Active + })); + } + + private static Task RevokeSealedModeOverrideAsync( + HttpContext httpContext, + [FromServices] TimeProvider timeProvider, + string overrideId, + RevokeOverrideRequest request) + { + var tenant = GetTenantId(httpContext) ?? "default"; + var actor = GetActorId(httpContext) ?? "system"; + + if (!Overrides.TryGetValue(overrideId, out var entity) || entity.TenantId != tenant) + { + return Task.FromResult(Results.NotFound(new ProblemDetails + { + Title = "Override not found", + Status = 404 + })); + } + + entity.Active = false; + Overrides[overrideId] = entity; + + RecordAudit(tenant, actor, "sealed_mode_override_revoked", overrideId, "sealed_mode_override", + $"Revoked override: {request.Reason ?? "no reason provided"}", timeProvider); + + return Task.FromResult(Results.NoContent()); + } + + // ======================================================================== + // Risk Profile Handlers + // ======================================================================== + + private static Task ListRiskProfilesAsync( + HttpContext httpContext, + [FromQuery] string? tenantId, + [FromQuery] string? status) + { + var tenant = tenantId ?? GetTenantId(httpContext) ?? "default"; + + var profiles = RiskProfiles.Values + .Where(p => p.TenantId == tenant || p.TenantId == "default") + .Where(p => string.IsNullOrEmpty(status) || p.Status.Equals(status, StringComparison.OrdinalIgnoreCase)) + .Select(MapProfileToResponse) + .ToList(); + + return Task.FromResult(Results.Ok(new { profiles })); + } + + private static Task GetRiskProfileAsync( + HttpContext httpContext, + string profileId) + { + var tenant = GetTenantId(httpContext) ?? "default"; + + if (!RiskProfiles.TryGetValue(profileId, out var profile)) + { + return Task.FromResult(Results.NotFound(new ProblemDetails + { + Title = "Profile not found", + Status = 404, + Detail = $"Risk profile '{profileId}' not found." + })); + } + + return Task.FromResult(Results.Ok(MapProfileToResponse(profile))); + } + + private static Task CreateRiskProfileAsync( + HttpContext httpContext, + [FromServices] TimeProvider timeProvider, + CreateRiskProfileRequest request) + { + var tenant = GetTenantId(httpContext) ?? "default"; + var actor = GetActorId(httpContext) ?? "system"; + var now = timeProvider.GetUtcNow(); + + var profileId = request.ProfileId ?? $"profile-{Guid.NewGuid():N}"; + var entity = new RiskProfileEntity + { + Id = profileId, + TenantId = tenant, + Version = "1.0.0", + Name = request.Name, + Description = request.Description, + Status = "draft", + ExtendsProfile = request.ExtendsProfile, + Signals = request.Signals ?? [], + SeverityOverrides = request.SeverityOverrides ?? [], + ActionOverrides = request.ActionOverrides ?? [], + CreatedAt = now.ToString("O", CultureInfo.InvariantCulture), + ModifiedAt = now.ToString("O", CultureInfo.InvariantCulture), + CreatedBy = actor, + ModifiedBy = actor + }; + + RiskProfiles[profileId] = entity; + + RecordAudit(tenant, actor, "risk_profile_created", profileId, "risk_profile", + $"Created risk profile: {request.Name}", timeProvider); + + return Task.FromResult(Results.Created($"/api/v1/governance/risk-profiles/{profileId}", MapProfileToResponse(entity))); + } + + private static Task UpdateRiskProfileAsync( + HttpContext httpContext, + [FromServices] TimeProvider timeProvider, + string profileId, + UpdateRiskProfileRequest request) + { + var tenant = GetTenantId(httpContext) ?? "default"; + var actor = GetActorId(httpContext) ?? "system"; + var now = timeProvider.GetUtcNow(); + + if (!RiskProfiles.TryGetValue(profileId, out var existing)) + { + return Task.FromResult(Results.NotFound(new ProblemDetails + { + Title = "Profile not found", + Status = 404 + })); + } + + var entity = existing with + { + Name = request.Name ?? existing.Name, + Description = request.Description ?? existing.Description, + Signals = request.Signals ?? existing.Signals, + SeverityOverrides = request.SeverityOverrides ?? existing.SeverityOverrides, + ActionOverrides = request.ActionOverrides ?? existing.ActionOverrides, + ModifiedAt = now.ToString("O", CultureInfo.InvariantCulture), + ModifiedBy = actor + }; + + RiskProfiles[profileId] = entity; + + RecordAudit(tenant, actor, "risk_profile_updated", profileId, "risk_profile", + $"Updated risk profile: {entity.Name}", timeProvider); + + return Task.FromResult(Results.Ok(MapProfileToResponse(entity))); + } + + private static Task DeleteRiskProfileAsync( + HttpContext httpContext, + [FromServices] TimeProvider timeProvider, + string profileId) + { + var tenant = GetTenantId(httpContext) ?? "default"; + var actor = GetActorId(httpContext) ?? "system"; + + if (!RiskProfiles.TryRemove(profileId, out var removed)) + { + return Task.FromResult(Results.NotFound(new ProblemDetails + { + Title = "Profile not found", + Status = 404 + })); + } + + RecordAudit(tenant, actor, "risk_profile_deleted", profileId, "risk_profile", + $"Deleted risk profile: {removed.Name}", timeProvider); + + return Task.FromResult(Results.NoContent()); + } + + private static Task ActivateRiskProfileAsync( + HttpContext httpContext, + [FromServices] TimeProvider timeProvider, + string profileId) + { + var tenant = GetTenantId(httpContext) ?? "default"; + var actor = GetActorId(httpContext) ?? "system"; + var now = timeProvider.GetUtcNow(); + + if (!RiskProfiles.TryGetValue(profileId, out var existing)) + { + return Task.FromResult(Results.NotFound(new ProblemDetails + { + Title = "Profile not found", + Status = 404 + })); + } + + var entity = existing with + { + Status = "active", + ModifiedAt = now.ToString("O", CultureInfo.InvariantCulture), + ModifiedBy = actor + }; + + RiskProfiles[profileId] = entity; + + RecordAudit(tenant, actor, "risk_profile_activated", profileId, "risk_profile", + $"Activated risk profile: {entity.Name}", timeProvider); + + return Task.FromResult(Results.Ok(MapProfileToResponse(entity))); + } + + private static Task DeprecateRiskProfileAsync( + HttpContext httpContext, + [FromServices] TimeProvider timeProvider, + string profileId, + DeprecateProfileRequest request) + { + var tenant = GetTenantId(httpContext) ?? "default"; + var actor = GetActorId(httpContext) ?? "system"; + var now = timeProvider.GetUtcNow(); + + if (!RiskProfiles.TryGetValue(profileId, out var existing)) + { + return Task.FromResult(Results.NotFound(new ProblemDetails + { + Title = "Profile not found", + Status = 404 + })); + } + + var entity = existing with + { + Status = "deprecated", + ModifiedAt = now.ToString("O", CultureInfo.InvariantCulture), + ModifiedBy = actor, + DeprecationReason = request.Reason ?? "deprecated" + }; + + RiskProfiles[profileId] = entity; + + RecordAudit(tenant, actor, "risk_profile_deprecated", profileId, "risk_profile", + $"Deprecated risk profile: {entity.Name} - {request.Reason ?? "no reason"}", timeProvider); + + return Task.FromResult(Results.Ok(MapProfileToResponse(entity))); + } + + private static Task ValidateRiskProfileAsync( + HttpContext httpContext, + ValidateRiskProfileRequest request) + { + var errors = new List(); + var warnings = new List(); + + if (string.IsNullOrWhiteSpace(request.Name)) + { + errors.Add(new ValidationError("MISSING_NAME", "Profile name is required", "name")); + } + + if (request.Signals == null || request.Signals.Count == 0) + { + errors.Add(new ValidationError("NO_SIGNALS", "At least one signal must be defined", "signals")); + } + else + { + var totalWeight = request.Signals.Where(s => s.Enabled).Sum(s => s.Weight); + if (Math.Abs(totalWeight - 1.0) > 0.01) + { + warnings.Add(new ValidationWarning("WEIGHT_SUM", $"Signal weights sum to {totalWeight:F2}, expected 1.0", "signals")); + } + } + + var response = new RiskProfileValidationResponse + { + IsValid = errors.Count == 0, + Errors = errors, + Warnings = warnings + }; + + return Task.FromResult(Results.Ok(response)); + } + + // ======================================================================== + // Risk Budget Handlers + // ======================================================================== + + private static Task GetRiskBudgetDashboardAsync( + HttpContext httpContext, + [FromServices] TimeProvider timeProvider, + [FromQuery] string? tenantId, + [FromQuery] string? projectId) + { + var tenant = tenantId ?? GetTenantId(httpContext) ?? "default"; + var now = timeProvider.GetUtcNow(); + var traceId = httpContext.TraceIdentifier; + + var periodStart = new DateTimeOffset(now.Year, ((now.Month - 1) / 3) * 3 + 1, 1, 0, 0, 0, TimeSpan.Zero); + var periodEnd = periodStart.AddMonths(3).AddSeconds(-1); + + var response = new + { + config = new + { + id = "budget-001", + tenantId = tenant, + projectId = projectId ?? (string?)null, + name = $"Q{(now.Month - 1) / 3 + 1} {now.Year} Risk Budget", + totalBudget = 1000, + warningThreshold = 70, + criticalThreshold = 90, + period = "quarterly", + periodStart = periodStart.ToString("O", CultureInfo.InvariantCulture), + periodEnd = periodEnd.ToString("O", CultureInfo.InvariantCulture), + createdAt = periodStart.AddDays(-15).ToString("O", CultureInfo.InvariantCulture), + updatedAt = now.ToString("O", CultureInfo.InvariantCulture) + }, + currentRiskPoints = 720, + headroom = 280, + utilizationPercent = 72.0, + status = "warning", + timeSeries = Enumerable.Range(0, 5).Select(i => + { + var ts = now.AddDays(-28 + i * 7); + var actual = 600 + i * 30; + return new + { + timestamp = ts.ToString("O", CultureInfo.InvariantCulture), + actual, + budget = 1000, + headroom = 1000 - actual + }; + }).ToList(), + updatedAt = now.ToString("O", CultureInfo.InvariantCulture), + traceId, + topContributors = new[] + { + new { identifier = "pkg:npm/lodash@4.17.20", type = "component", displayName = "lodash", riskPoints = 120, percentOfBudget = 12.0, trend = "stable", delta24h = 0 }, + new { identifier = "CVE-2024-1234", type = "vulnerability", displayName = "CVE-2024-1234", riskPoints = 95, percentOfBudget = 9.5, trend = "increasing", delta24h = 10 }, + new { identifier = "vulnerability", type = "category", displayName = "Vulnerabilities", riskPoints = 450, percentOfBudget = 45.0, trend = "stable", delta24h = 5 } + }, + activeAlerts = new[] + { + new + { + id = "alert-001", + threshold = new { level = 70, severity = "medium", actions = new[] { new { type = "notify", channels = new[] { "slack" } } } }, + currentUtilization = 72.0, + triggeredAt = now.AddDays(-1).ToString("O", CultureInfo.InvariantCulture), + acknowledged = false, + acknowledgedBy = (string?)null, + acknowledgedAt = (string?)null + } + }, + governance = new + { + id = "budget-001", + tenantId = tenant, + name = $"Q{(now.Month - 1) / 3 + 1} {now.Year} Risk Budget", + totalBudget = 1000, + warningThreshold = 70, + criticalThreshold = 90, + period = "quarterly", + periodStart = periodStart.ToString("O", CultureInfo.InvariantCulture), + periodEnd = periodEnd.ToString("O", CultureInfo.InvariantCulture), + createdAt = periodStart.AddDays(-15).ToString("O", CultureInfo.InvariantCulture), + updatedAt = now.ToString("O", CultureInfo.InvariantCulture), + thresholds = new[] + { + new { level = 70, severity = "medium", actions = new object[] { new { type = "notify", channels = new[] { "slack", "email" } } } }, + new { level = 90, severity = "high", actions = new object[] { new { type = "notify", channels = new[] { "slack", "email" } }, new { type = "require_approval" } } }, + new { level = 100, severity = "critical", actions = new object[] { new { type = "block_deploys" }, new { type = "escalate" } } } + }, + enforceHardLimits = true, + gracePeriodHours = 24, + autoReset = true, + carryoverPercent = 0 + }, + kpis = new + { + headroom = 280, + headroomDelta24h = -20, + unknownsDelta24h = 3, + riskRetired7d = 45, + exceptionsExpiring = 2, + burnRate = 8.5, + projectedDaysToExceeded = 33, + traceId + } + }; + + return Task.FromResult(Results.Ok(response)); + } + + // ======================================================================== + // Audit Handlers + // ======================================================================== + + private static Task GetAuditEventsAsync( + HttpContext httpContext, + [FromQuery] string? tenantId, + [FromQuery] int page = 1, + [FromQuery] int pageSize = 20) + { + var tenant = tenantId ?? GetTenantId(httpContext); + if (string.IsNullOrEmpty(tenant)) + { + return Task.FromResult(Results.BadRequest(new ProblemDetails + { + Title = "Tenant ID required", + Status = 400, + Detail = "Provide tenant via X-StellaOps-Tenant header or tenantId query parameter" + })); + } + + var events = AuditEntries.Values + .Where(e => e.TenantId == tenant) + .OrderByDescending(e => e.Timestamp) + .Skip((page - 1) * pageSize) + .Take(pageSize) + .Select(MapAuditToResponse) + .ToList(); + + var total = AuditEntries.Values.Count(e => e.TenantId == tenant); + + var response = new AuditEventsResponse + { + Events = events, + Total = total, + Page = page, + PageSize = pageSize, + HasMore = (page * pageSize) < total + }; + + return Task.FromResult(Results.Ok(response)); + } + + private static Task GetAuditEventAsync( + HttpContext httpContext, + string eventId) + { + var tenant = GetTenantId(httpContext) ?? "default"; + + if (!AuditEntries.TryGetValue(eventId, out var entry) || entry.TenantId != tenant) + { + return Task.FromResult(Results.NotFound(new ProblemDetails + { + Title = "Event not found", + Status = 404 + })); + } + + return Task.FromResult(Results.Ok(MapAuditToResponse(entry))); + } + + // ======================================================================== + // Helper Methods + // ======================================================================== + + private static void InitializeDefaultProfiles() + { + if (RiskProfiles.IsEmpty) + { + var now = DateTimeOffset.UtcNow.ToString("O", CultureInfo.InvariantCulture); + RiskProfiles["profile-default"] = new RiskProfileEntity + { + Id = "profile-default", + TenantId = "default", + Version = "1.0.0", + Name = "Default Risk Profile", + Description = "Standard risk evaluation profile", + Status = "active", + Signals = [ + new RiskSignal { Name = "cvss_score", Weight = 0.3, Description = "CVSS base score", Enabled = true }, + new RiskSignal { Name = "exploit_available", Weight = 0.25, Description = "Known exploit exists", Enabled = true }, + new RiskSignal { Name = "reachability", Weight = 0.2, Description = "Code reachability", Enabled = true }, + new RiskSignal { Name = "asset_criticality", Weight = 0.15, Description = "Asset business criticality", Enabled = true }, + new RiskSignal { Name = "patch_available", Weight = 0.1, Description = "Patch availability", Enabled = true } + ], + SeverityOverrides = [], + ActionOverrides = [], + CreatedAt = now, + ModifiedAt = now, + CreatedBy = "system", + ModifiedBy = "system" + }; + } + } + + private static string? GetTenantId(HttpContext httpContext) + { + return httpContext.User.Claims.FirstOrDefault(c => c.Type == "tenant_id")?.Value + ?? httpContext.Request.Headers["X-Tenant-Id"].FirstOrDefault() + ?? httpContext.Request.Headers["X-StellaOps-Tenant"].FirstOrDefault(); + } + + private static string? GetActorId(HttpContext httpContext) + { + return httpContext.User.Claims.FirstOrDefault(c => c.Type == "sub")?.Value + ?? httpContext.User.Identity?.Name + ?? httpContext.Request.Headers["X-StellaOps-Actor"].FirstOrDefault(); + } + + private static void RecordAudit(string tenantId, string actor, string eventType, string targetId, string targetType, string summary, TimeProvider timeProvider) + { + var id = $"audit-{Guid.NewGuid():N}"; + AuditEntries[id] = new GovernanceAuditEntry + { + Id = id, + TenantId = tenantId, + Type = eventType, + Timestamp = timeProvider.GetUtcNow().ToString("O", CultureInfo.InvariantCulture), + Actor = actor, + ActorType = "user", + TargetResource = targetId, + TargetResourceType = targetType, + Summary = summary + }; + } + + private static SealedModeOverrideResponse MapOverrideToResponse(SealedModeOverrideEntity entity) + { + return new SealedModeOverrideResponse + { + Id = entity.Id, + Type = entity.Type, + Target = entity.Target, + Reason = entity.Reason, + ApprovalId = entity.ApprovalId, + ApprovedBy = entity.ApprovedBy, + ExpiresAt = entity.ExpiresAt, + CreatedAt = entity.CreatedAt, + Active = entity.Active + }; + } + + private static RiskProfileResponse MapProfileToResponse(RiskProfileEntity entity) + { + return new RiskProfileResponse + { + ProfileId = entity.Id, + Version = entity.Version, + Name = entity.Name, + Description = entity.Description, + Status = entity.Status, + ExtendsProfile = entity.ExtendsProfile, + Signals = entity.Signals, + SeverityOverrides = entity.SeverityOverrides, + ActionOverrides = entity.ActionOverrides, + CreatedAt = entity.CreatedAt, + ModifiedAt = entity.ModifiedAt, + CreatedBy = entity.CreatedBy, + ModifiedBy = entity.ModifiedBy + }; + } + + private static GovernanceAuditEventResponse MapAuditToResponse(GovernanceAuditEntry entry) + { + return new GovernanceAuditEventResponse + { + Id = entry.Id, + Type = entry.Type, + Timestamp = entry.Timestamp, + Actor = entry.Actor, + ActorType = entry.ActorType, + TargetResource = entry.TargetResource, + TargetResourceType = entry.TargetResourceType, + Summary = entry.Summary, + TenantId = entry.TenantId + }; + } +} + +// ============================================================================ +// Internal Entities +// ============================================================================ + +internal sealed class SealedModeState +{ + public bool IsSealed { get; set; } + public string? SealedAt { get; set; } + public string? SealedBy { get; set; } + public string? Reason { get; set; } + public string? LastUnsealedAt { get; set; } + public List TrustRoots { get; set; } = []; + public List AllowedSources { get; set; } = []; +} + +internal sealed record SealedModeOverrideEntity +{ + public required string Id { get; init; } + public required string TenantId { get; init; } + public required string Type { get; init; } + public required string Target { get; init; } + public required string Reason { get; init; } + public required string ApprovalId { get; init; } + public required List ApprovedBy { get; init; } + public required string ExpiresAt { get; init; } + public required string CreatedAt { get; init; } + public bool Active { get; set; } +} + +internal sealed record RiskProfileEntity +{ + public required string Id { get; init; } + public required string TenantId { get; init; } + public required string Version { get; init; } + public required string Name { get; init; } + public string? Description { get; init; } + public required string Status { get; init; } + public string? ExtendsProfile { get; init; } + public required List Signals { get; init; } + public required List SeverityOverrides { get; init; } + public required List ActionOverrides { get; init; } + public required string CreatedAt { get; init; } + public required string ModifiedAt { get; init; } + public required string CreatedBy { get; init; } + public required string ModifiedBy { get; init; } + public string? DeprecationReason { get; init; } +} + +internal sealed record GovernanceAuditEntry +{ + public required string Id { get; init; } + public required string TenantId { get; init; } + public required string Type { get; init; } + public required string Timestamp { get; init; } + public required string Actor { get; init; } + public required string ActorType { get; init; } + public required string TargetResource { get; init; } + public required string TargetResourceType { get; init; } + public required string Summary { get; init; } +} + +// ============================================================================ +// Request/Response DTOs +// ============================================================================ + +public sealed record SealedModeToggleRequest +{ + public required bool Enable { get; init; } + public string? Reason { get; init; } + public List? TrustRoots { get; init; } + public List? AllowedSources { get; init; } +} + +public sealed record SealedModeOverrideRequest +{ + public string? Type { get; init; } + public string? OverrideType { get; init; } + public string? Target { get; init; } + public string? Reason { get; init; } + public int DurationHours { get; init; } + public int DurationMinutes { get; init; } + public string? Actor { get; init; } +} + +public sealed record SealedModeOverrideCreatedResponse +{ + public required string OverrideId { get; init; } + public required string OverrideType { get; init; } + public string? Reason { get; init; } + public required string ExpiresAt { get; init; } + public required string CreatedAt { get; init; } + public required bool Active { get; init; } +} + +public sealed record RevokeOverrideRequest +{ + public string? Reason { get; init; } +} + +public sealed record SealedModeStatusResponse +{ + public required bool IsSealed { get; init; } + public string? TenantId { get; init; } + public string? SealedAt { get; init; } + public string? SealedBy { get; init; } + public string? Reason { get; init; } + public required List TrustRoots { get; init; } + public required List AllowedSources { get; init; } + public required List Overrides { get; init; } + public required string VerificationStatus { get; init; } + public string? LastVerifiedAt { get; init; } +} + +public sealed record SealedModeOverrideResponse +{ + public required string Id { get; init; } + public required string Type { get; init; } + public required string Target { get; init; } + public required string Reason { get; init; } + public required string ApprovalId { get; init; } + public required List ApprovedBy { get; init; } + public required string ExpiresAt { get; init; } + public required string CreatedAt { get; init; } + public required bool Active { get; init; } +} + +public sealed record CreateRiskProfileRequest +{ + public string? ProfileId { get; init; } + public required string Name { get; init; } + public string? Description { get; init; } + public string? ExtendsProfile { get; init; } + public List? Signals { get; init; } + public List? SeverityOverrides { get; init; } + public List? ActionOverrides { get; init; } +} + +public sealed record UpdateRiskProfileRequest +{ + public string? Name { get; init; } + public string? Description { get; init; } + public List? Signals { get; init; } + public List? SeverityOverrides { get; init; } + public List? ActionOverrides { get; init; } +} + +public sealed record DeprecateProfileRequest +{ + public string? Reason { get; init; } +} + +public sealed record ValidateRiskProfileRequest +{ + public string? Name { get; init; } + public List? Signals { get; init; } +} + +public sealed record RiskProfileResponse +{ + public required string ProfileId { get; init; } + public required string Version { get; init; } + public required string Name { get; init; } + public string? Description { get; init; } + public required string Status { get; init; } + public string? ExtendsProfile { get; init; } + public required List Signals { get; init; } + public required List SeverityOverrides { get; init; } + public required List ActionOverrides { get; init; } + public required string CreatedAt { get; init; } + public required string ModifiedAt { get; init; } + public required string CreatedBy { get; init; } + public required string ModifiedBy { get; init; } +} + +public sealed record RiskProfileValidationResponse +{ + public required bool IsValid { get; init; } + public required List Errors { get; init; } + public required List Warnings { get; init; } +} + +public sealed record ValidationError(string Code, string Message, string? Path = null); +public sealed record ValidationWarning(string Code, string Message, string? Path = null); + +public sealed record RiskSignal +{ + public required string Name { get; init; } + public required double Weight { get; init; } + public string? Description { get; init; } + public required bool Enabled { get; init; } +} + +public sealed record SeverityOverride +{ + public string? Id { get; init; } + public string? TargetSeverity { get; init; } + public object? Condition { get; init; } + public string? Description { get; init; } + public int Priority { get; init; } +} + +public sealed record ActionOverride +{ + public string? Id { get; init; } + public string? TargetAction { get; init; } + public object? Condition { get; init; } + public string? Description { get; init; } + public int Priority { get; init; } +} + +public sealed record AuditEventsResponse +{ + public required List Events { get; init; } + public required int Total { get; init; } + public required int Page { get; init; } + public required int PageSize { get; init; } + public required bool HasMore { get; init; } +} + +public sealed record GovernanceAuditEventResponse +{ + public required string Id { get; init; } + public required string Type { get; init; } + public required string Timestamp { get; init; } + public required string Actor { get; init; } + public required string ActorType { get; init; } + public required string TargetResource { get; init; } + public required string TargetResourceType { get; init; } + public required string Summary { get; init; } + public required string TenantId { get; init; } +} diff --git a/src/Policy/StellaOps.Policy.Engine/Endpoints/Gateway/PolicySimulationEndpoints.cs b/src/Policy/StellaOps.Policy.Engine/Endpoints/Gateway/PolicySimulationEndpoints.cs new file mode 100644 index 000000000..c889936a8 --- /dev/null +++ b/src/Policy/StellaOps.Policy.Engine/Endpoints/Gateway/PolicySimulationEndpoints.cs @@ -0,0 +1,2154 @@ +using System.Collections.Concurrent; +using System.Globalization; +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 PolicySimulationEndpoints +{ + private static readonly ConcurrentDictionary ShadowModes = new(StringComparer.OrdinalIgnoreCase); + private static readonly ConcurrentDictionary Simulations = new(StringComparer.OrdinalIgnoreCase); + private static readonly ConcurrentDictionary Exceptions = new(StringComparer.OrdinalIgnoreCase); + private static readonly ConcurrentDictionary BatchEvaluations = new(StringComparer.OrdinalIgnoreCase); + + public static void MapPolicySimulationEndpoints(this WebApplication app) + { + var policy = app.MapGroup("/policy") + .WithTags("Policy Simulation Compatibility") + .RequireTenant(); + + policy.MapGet("/shadow/config", ( + HttpContext context, + TimeProvider timeProvider) => + { + var tenantId = ResolveTenant(context); + return Results.Ok(ShadowModes.GetOrAdd(tenantId, _ => ShadowModeState.CreateDefault(timeProvider))); + }).RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead)); + + policy.MapPost("/shadow/enable", ( + HttpContext context, + [FromBody] ShadowModeWriteRequest request, + TimeProvider timeProvider) => + { + var tenantId = ResolveTenant(context); + var updated = ShadowModes.AddOrUpdate( + tenantId, + _ => ShadowModeState.CreateEnabled(request, StellaOpsTenantResolver.ResolveActor(context), timeProvider), + (_, existing) => existing.WithEnabled(request, StellaOpsTenantResolver.ResolveActor(context), timeProvider)); + return Results.Ok(updated); + }).RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicySimulate)); + + policy.MapPost("/shadow/disable", ( + HttpContext context, + TimeProvider timeProvider) => + { + var tenantId = ResolveTenant(context); + ShadowModes.AddOrUpdate( + tenantId, + _ => ShadowModeState.CreateDefault(timeProvider), + (_, existing) => existing.WithDisabled()); + return Results.NoContent(); + }).RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicySimulate)); + + policy.MapGet("/shadow/results", ( + HttpContext context, + [FromQuery] int? limit, + TimeProvider timeProvider) => + { + var tenantId = ResolveTenant(context); + var config = ShadowModes.GetOrAdd(tenantId, _ => ShadowModeState.CreateDefault(timeProvider)); + var comparisons = BuildComparisons(Math.Clamp(limit ?? 25, 1, 200)); + var divergedCount = comparisons.Count(static comparison => comparison.Diverged); + var payload = new + { + config, + summary = new + { + totalEvaluations = comparisons.Length, + matchCount = comparisons.Length - divergedCount, + divergedCount, + errorCount = 0, + matchPercentage = comparisons.Length == 0 ? 100 : Math.Round(((comparisons.Length - divergedCount) / (double)comparisons.Length) * 100, 2), + divergenceBreakdown = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["severity"] = divergedCount + }, + fromTime = timeProvider.GetUtcNow().AddHours(-1).ToString("O"), + toTime = timeProvider.GetUtcNow().ToString("O") + }, + comparisons, + continuationToken = null as string, + traceId = context.Request.Headers["X-Stella-Trace-Id"].FirstOrDefault() + }; + + return Results.Ok(payload); + }).RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead)); + + policy.MapPost("/simulations", ( + HttpContext context, + [FromBody] SimulationWriteRequest request, + TimeProvider timeProvider) => + { + var tenantId = ResolveTenant(context); + var simulation = SimulationState.Create(request, tenantId, StellaOpsTenantResolver.ResolveActor(context), timeProvider); + Simulations[simulation.SimulationId] = simulation; + return Results.Ok(simulation); + }).RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicySimulate)); + + policy.MapGet("/simulations/history", ( + HttpContext context, + [FromQuery] string? policyPackId, + [FromQuery] string? status, + [FromQuery] string? fromDate, + [FromQuery] string? toDate, + [FromQuery] bool? pinnedOnly, + [FromQuery] int? page, + [FromQuery] int? pageSize, + TimeProvider timeProvider) => + { + var tenantId = ResolveTenant(context); + var from = ParseDate(fromDate); + var to = ParseDate(toDate); + var pageNumber = Math.Max(1, page ?? 1); + var size = Math.Clamp(pageSize ?? 20, 1, 100); + var allItems = GetTenantSimulations(tenantId, timeProvider) + .Where(item => MatchesHistoryFilters(item, policyPackId, status, from, to, pinnedOnly == true)) + .ToArray(); + var items = allItems + .Skip((pageNumber - 1) * size) + .Take(size) + .Select(ToHistoryEntry) + .ToArray(); + + return Results.Ok(new + { + items, + total = allItems.Length, + hasMore = pageNumber * size < allItems.Length, + traceId = ResolveTraceId(context) + }); + }).RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead)); + + policy.MapGet("/simulations/compare", ( + HttpContext context, + [FromQuery] string baseId, + [FromQuery] string compareId, + TimeProvider timeProvider) => + { + var tenantId = ResolveTenant(context); + var simulations = GetTenantSimulations(tenantId, timeProvider); + var baseSimulation = simulations.FirstOrDefault(item => string.Equals(item.SimulationId, baseId, StringComparison.OrdinalIgnoreCase)); + var compareSimulation = simulations.FirstOrDefault(item => string.Equals(item.SimulationId, compareId, StringComparison.OrdinalIgnoreCase)); + if (baseSimulation is null || compareSimulation is null) + { + return Results.NotFound(); + } + + return Results.Ok(BuildComparisonResult(baseSimulation, compareSimulation, context, timeProvider)); + }).RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead)); + + policy.MapPost("/simulations/{simulationId}/verify", ( + HttpContext context, + string simulationId, + TimeProvider timeProvider) => + { + var tenantId = ResolveTenant(context); + if (!TryGetSimulation(tenantId, simulationId, timeProvider, out var simulation)) + { + return Results.NotFound(); + } + + var replayHash = string.Equals(simulation.Status, "completed", StringComparison.OrdinalIgnoreCase) + ? simulation.ResultHash + : $"{simulation.ResultHash}-replay"; + var discrepancies = string.Equals(simulation.ResultHash, replayHash, StringComparison.Ordinal) + ? Array.Empty() + : new[] { "Replay hash diverged from the original simulation result." }; + + return Results.Ok(new + { + originalSimulationId = simulation.SimulationId, + replaySimulationId = $"{simulation.SimulationId}-replay", + isReproducible = discrepancies.Length == 0, + originalHash = simulation.ResultHash, + replayHash, + discrepancies = discrepancies.Length == 0 ? null : discrepancies, + checkedAt = timeProvider.GetUtcNow().ToString("O"), + traceId = ResolveTraceId(context) + }); + }).RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead)); + + policy.MapPatch("/simulations/{simulationId}", ( + HttpContext context, + string simulationId, + [FromBody] SimulationPinRequest request, + TimeProvider timeProvider) => + { + var tenantId = ResolveTenant(context); + if (!TryGetSimulation(tenantId, simulationId, timeProvider, out var simulation)) + { + return Results.NotFound(); + } + + Simulations[simulationId] = simulation with { Pinned = request.Pinned }; + return Results.NoContent(); + }).RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicySimulate)); + + policy.MapGet("/simulations", ( + HttpContext context, + [FromQuery] int? limit, + [FromQuery] int? page, + TimeProvider timeProvider) => + { + var tenantId = ResolveTenant(context); + var pageSize = Math.Clamp(limit ?? 20, 1, 100); + var pageNumber = Math.Max(1, page ?? 1); + var items = GetTenantSimulations(tenantId, timeProvider) + .Skip((pageNumber - 1) * pageSize) + .Take(pageSize) + .ToArray(); + + var total = GetTenantSimulations(tenantId, timeProvider).Count; + return Results.Ok(new + { + items, + total, + hasMore = pageNumber * pageSize < total + }); + }).RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead)); + + policy.MapGet("/simulations/{simulationId}", ( + HttpContext context, + string simulationId, + TimeProvider timeProvider) => + { + var tenantId = ResolveTenant(context); + if (!TryGetSimulation(tenantId, simulationId, timeProvider, out var simulation)) + { + return Results.NotFound(); + } + + return Results.Ok(simulation); + }).RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead)); + + policy.MapPost("/simulations/{simulationId}/cancel", ( + HttpContext context, + string simulationId, + TimeProvider timeProvider) => + { + var tenantId = ResolveTenant(context); + if (!TryGetSimulation(tenantId, simulationId, timeProvider, out var simulation)) + { + return Results.NotFound(); + } + + Simulations[simulationId] = simulation with { Status = "cancelled", Error = "Cancelled by operator request." }; + return Results.NoContent(); + }).RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicySimulate)); + + policy.MapPost("/packs/{policyPackId}/versions/{version:int}/lint", ( + HttpContext context, + string policyPackId, + int version, + TimeProvider timeProvider) => Results.Ok(BuildLintResult(policyPackId, version, context, timeProvider))) + .RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead)); + + policy.MapPost("/packs/{policyPackId}/lint", ( + HttpContext context, + string policyPackId, + TimeProvider timeProvider) => Results.Ok(BuildLintResult(policyPackId, 1, context, timeProvider))) + .RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead)); + + policy.MapGet("/packs/{policyPackId}/versions/{version:int}/coverage", ( + HttpContext context, + string policyPackId, + int version, + TimeProvider timeProvider) => Results.Ok(BuildCoverageResult(policyPackId, version, context, timeProvider))) + .RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead)); + + policy.MapGet("/packs/{policyPackId}/coverage", ( + HttpContext context, + string policyPackId, + TimeProvider timeProvider) => Results.Ok(BuildCoverageResult(policyPackId, 1, context, timeProvider))) + .RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead)); + + policy.MapPost("/packs/{policyPackId}/versions/{version:int}/coverage/run", ( + HttpContext context, + string policyPackId, + int version, + TimeProvider timeProvider) => Results.Ok(BuildCoverageResult(policyPackId, version, context, timeProvider))) + .RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicySimulate)); + + policy.MapPost("/packs/{policyPackId}/coverage/run", ( + HttpContext context, + string policyPackId, + TimeProvider timeProvider) => Results.Ok(BuildCoverageResult(policyPackId, 1, context, timeProvider))) + .RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicySimulate)); + + policy.MapGet("/effective", ( + HttpContext context, + [FromQuery] string? resourceType, + [FromQuery] string? resourceId, + [FromQuery] string? search, + [FromQuery] int? limit, + TimeProvider timeProvider) => + { + return Results.Ok(BuildEffectivePolicies(resourceType, resourceId, search, limit, context, timeProvider)); + }).RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead)); + + policy.MapGet("/audit", ( + HttpContext context, + [FromQuery] string? policyPackId, + [FromQuery] string? action, + [FromQuery] string? actorId, + [FromQuery] string? fromDate, + [FromQuery] string? toDate, + [FromQuery] int? page, + [FromQuery] int? pageSize, + TimeProvider timeProvider) => + { + return Results.Ok(BuildAuditLog(policyPackId, action, actorId, fromDate, toDate, page, pageSize, context, timeProvider)); + }).RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead)); + + policy.MapGet("/packs/{policyPackId}/diff", ( + HttpContext context, + string policyPackId, + [FromQuery] int? from, + [FromQuery] int? to, + TimeProvider timeProvider) => + { + var fromVersion = Math.Max(1, from ?? 1); + var toVersion = Math.Max(fromVersion + 1, to ?? (fromVersion + 1)); + return Results.Ok(BuildDiffResult(policyPackId, fromVersion, toVersion, context, timeProvider)); + }).RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead)); + + policy.MapGet("/packs/{policyPackId}/versions/{version:int}/promotion-gate", ( + HttpContext context, + string policyPackId, + int version, + [FromQuery] string? environment, + TimeProvider timeProvider) => + { + var tenantId = ResolveTenant(context); + var shadow = ShadowModes.GetOrAdd(tenantId, _ => ShadowModeState.CreateDefault(timeProvider)); + return Results.Ok(BuildPromotionGateResult(policyPackId, version, environment, shadow, false, null, context, timeProvider)); + }).RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead)); + + policy.MapPost("/packs/{policyPackId}/versions/{version:int}/promotion-gate/override", ( + HttpContext context, + string policyPackId, + int version, + [FromBody] PromotionGateOverrideRequest? request, + TimeProvider timeProvider) => + { + var tenantId = ResolveTenant(context); + var shadow = ShadowModes.GetOrAdd(tenantId, _ => ShadowModeState.CreateDefault(timeProvider)); + return Results.Ok(BuildPromotionGateResult(policyPackId, version, request?.Environment, shadow, true, request?.Reason, context, timeProvider)); + }).RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyApprove)); + + policy.MapGet("/exceptions", ( + HttpContext context, + [FromQuery] string? status, + [FromQuery] string? severity, + [FromQuery] string? search, + [FromQuery] int? limit, + TimeProvider timeProvider) => + { + var tenantId = ResolveTenant(context); + return Results.Ok(BuildExceptionListResult(tenantId, status, severity, search, limit, context, timeProvider)); + }).RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead)); + + policy.MapGet("/exceptions/{exceptionId}", ( + HttpContext context, + string exceptionId, + TimeProvider timeProvider) => + { + var tenantId = ResolveTenant(context); + if (!TryGetException(tenantId, exceptionId, timeProvider, out var exception)) + { + return Results.NotFound(); + } + + return Results.Ok(ToPolicyExceptionResponse(exception)); + }).RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead)); + + policy.MapPost("/exceptions", ( + HttpContext context, + [FromBody] PolicyExceptionCompatWriteRequest request, + TimeProvider timeProvider) => + { + var tenantId = ResolveTenant(context); + var actor = StellaOpsTenantResolver.ResolveActor(context); + var created = PolicyExceptionCompatState.Create(request, tenantId, actor, timeProvider); + Exceptions[created.Id] = created; + return Results.Ok(ToPolicyExceptionResponse(created)); + }).RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead)); + + policy.MapPatch("/exceptions/{exceptionId}", ( + HttpContext context, + string exceptionId, + [FromBody] PolicyExceptionCompatUpdateRequest request, + TimeProvider timeProvider) => + { + var tenantId = ResolveTenant(context); + if (!TryGetException(tenantId, exceptionId, timeProvider, out var exception)) + { + return Results.NotFound(); + } + + var updated = exception.WithUpdates(request, timeProvider); + Exceptions[updated.Id] = updated; + return Results.Ok(ToPolicyExceptionResponse(updated)); + }).RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead)); + + policy.MapPost("/exceptions/{exceptionId}/revoke", ( + HttpContext context, + string exceptionId, + [FromBody] PolicyExceptionCompatRevokeRequest? request, + TimeProvider timeProvider) => + { + var tenantId = ResolveTenant(context); + if (!TryGetException(tenantId, exceptionId, timeProvider, out var exception)) + { + return Results.NotFound(); + } + + var revoked = exception.WithRevoked(StellaOpsTenantResolver.ResolveActor(context), request?.Reason, timeProvider); + Exceptions[revoked.Id] = revoked; + return Results.Ok(ToPolicyExceptionResponse(revoked)); + }).RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead)); + + policy.MapPost("/merge/preview", ( + HttpContext context, + [FromBody] MergePreviewRequest? request, + TimeProvider timeProvider) => + { + return Results.Ok(BuildMergePreviewResult(request, context, timeProvider)); + }).RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead)); + + policy.MapPost("/conflicts/detect", ( + HttpContext context, + [FromBody] ConflictDetectionRequest? request, + [FromQuery] bool? includeResolved, + [FromQuery] string? severityFilter, + TimeProvider timeProvider) => + { + return Results.Ok(BuildConflictDetectionResult(request?.PolicyIds, includeResolved == true, severityFilter, false, context, timeProvider)); + }).RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead)); + + policy.MapPost("/conflicts/{conflictId}/resolve", ( + HttpContext context, + string conflictId, + [FromBody] ConflictResolveRequest? request, + TimeProvider timeProvider) => + { + if (string.IsNullOrWhiteSpace(conflictId)) + { + return Results.BadRequest(); + } + + return Results.NoContent(); + }).RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead)); + + policy.MapPost("/conflicts/auto-resolve", ( + HttpContext context, + [FromBody] ConflictAutoResolveRequest? request, + TimeProvider timeProvider) => + { + var policyIds = request?.ConflictIds?.Length > 0 ? request.ConflictIds : new[] { "policy-pack-001", "policy-pack-security" }; + return Results.Ok(BuildConflictDetectionResult(policyIds, true, null, true, context, timeProvider)); + }).RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead)); + + policy.MapPost("/batch-evaluations", ( + HttpContext context, + [FromBody] BatchEvaluationWriteRequest request, + TimeProvider timeProvider) => + { + var tenantId = ResolveTenant(context); + var actor = StellaOpsTenantResolver.ResolveActor(context); + var batch = BatchEvaluationState.Create(request, tenantId, actor, timeProvider, ResolveTraceId(context)); + BatchEvaluations[batch.BatchId] = batch; + return Results.Ok(ToBatchEvaluationResponse(batch)); + }).RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicySimulate)); + + policy.MapGet("/batch-evaluations", ( + HttpContext context, + [FromQuery] string? policyPackId, + [FromQuery] string? status, + [FromQuery] int? page, + [FromQuery] int? pageSize, + TimeProvider timeProvider) => + { + var tenantId = ResolveTenant(context); + return Results.Ok(BuildBatchEvaluationHistoryResult(tenantId, policyPackId, status, page, pageSize, context, timeProvider)); + }).RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead)); + + policy.MapGet("/batch-evaluations/{batchId}", ( + HttpContext context, + string batchId, + TimeProvider timeProvider) => + { + var tenantId = ResolveTenant(context); + if (!TryGetBatchEvaluation(tenantId, batchId, timeProvider, out var batch)) + { + return Results.NotFound(); + } + + if (string.Equals(batch.Status, "running", StringComparison.OrdinalIgnoreCase)) + { + batch = batch.WithCompleted(timeProvider, ResolveTraceId(context)); + BatchEvaluations[batch.BatchId] = batch; + } + + return Results.Ok(ToBatchEvaluationResponse(batch)); + }).RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead)); + + policy.MapPost("/batch-evaluations/{batchId}/cancel", ( + HttpContext context, + string batchId, + TimeProvider timeProvider) => + { + var tenantId = ResolveTenant(context); + if (!TryGetBatchEvaluation(tenantId, batchId, timeProvider, out var batch)) + { + return Results.NotFound(); + } + + batch = batch.WithCancelled(timeProvider, ResolveTraceId(context)); + BatchEvaluations[batch.BatchId] = batch; + return Results.NoContent(); + }).RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicySimulate)); + } + + private static string ResolveTenant(HttpContext context) + { + if (!StellaOpsTenantResolver.TryResolveTenantId(context, out var tenantId, out var error)) + { + throw new InvalidOperationException($"Tenant resolution failed: {error ?? "tenant_missing"}"); + } + + return tenantId; + } + + private static IReadOnlyList GetTenantSimulations(string tenantId, TimeProvider timeProvider) + { + EnsureTenantSimulations(tenantId, timeProvider); + return Simulations.Values + .Where(item => string.Equals(item.TenantId, tenantId, StringComparison.OrdinalIgnoreCase)) + .OrderByDescending(item => ParseUtc(item.ExecutedAt)) + .ToArray(); + } + + private static void EnsureTenantSimulations(string tenantId, TimeProvider timeProvider) + { + if (Simulations.Values.Any(item => string.Equals(item.TenantId, tenantId, StringComparison.OrdinalIgnoreCase))) + { + return; + } + + foreach (var simulation in SimulationState.CreateCompatibilitySeed(tenantId, timeProvider)) + { + Simulations.TryAdd(simulation.SimulationId, simulation); + } + } + + private static bool TryGetSimulation(string tenantId, string simulationId, TimeProvider timeProvider, out SimulationState simulation) + { + EnsureTenantSimulations(tenantId, timeProvider); + if (Simulations.TryGetValue(simulationId, out simulation!) && + string.Equals(simulation.TenantId, tenantId, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + simulation = default!; + return false; + } + + private static bool MatchesHistoryFilters( + SimulationState simulation, + string? policyPackId, + string? status, + DateTimeOffset? from, + DateTimeOffset? to, + bool pinnedOnly) + { + if (!string.IsNullOrWhiteSpace(policyPackId) && + !string.Equals(simulation.PolicyPackId, policyPackId.Trim(), StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + if (!string.IsNullOrWhiteSpace(status) && + !string.Equals(simulation.Status, status.Trim(), StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + if (pinnedOnly && !simulation.Pinned) + { + return false; + } + + var executedAt = ParseUtc(simulation.ExecutedAt); + if (from.HasValue && executedAt < from.Value) + { + return false; + } + + if (to.HasValue && executedAt > to.Value) + { + return false; + } + + return true; + } + + private static DateTimeOffset? ParseDate(string? value) => + DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var parsed) + ? parsed + : null; + + private static DateTimeOffset ParseUtc(string value) => + DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var parsed) + ? parsed + : DateTimeOffset.MinValue; + + private static string? ResolveTraceId(HttpContext context) => context.Request.Headers["X-Stella-Trace-Id"].FirstOrDefault(); + + private static object ToHistoryEntry(SimulationState simulation) => new + { + simulationId = simulation.SimulationId, + policyPackId = simulation.PolicyPackId, + policyVersion = simulation.PolicyVersion, + sbomId = simulation.SbomId, + sbomName = simulation.SbomName, + status = simulation.Status, + executionTimeMs = simulation.ExecutionTimeMs, + executedAt = simulation.ExecutedAt, + executedBy = simulation.ExecutedBy, + resultHash = simulation.ResultHash, + findingsBySeverity = simulation.FindingsBySeverity, + totalFindings = simulation.TotalFindings, + tags = simulation.Tags, + notes = simulation.Notes, + pinned = simulation.Pinned + }; + + private static object BuildComparisonResult( + SimulationState baseSimulation, + SimulationState compareSimulation, + HttpContext context, + TimeProvider timeProvider) + { + var baseFindings = baseSimulation.Findings.ToDictionary(item => item.FindingId, StringComparer.OrdinalIgnoreCase); + var compareFindings = compareSimulation.Findings.ToDictionary(item => item.FindingId, StringComparer.OrdinalIgnoreCase); + var added = compareSimulation.Findings + .Where(item => !baseFindings.ContainsKey(item.FindingId)) + .ToArray(); + var removed = baseSimulation.Findings + .Where(item => !compareFindings.ContainsKey(item.FindingId)) + .ToArray(); + var changed = compareSimulation.Findings + .Where(item => baseFindings.TryGetValue(item.FindingId, out var original) && + (!string.Equals(original.Decision, item.Decision, StringComparison.OrdinalIgnoreCase) || + !string.Equals(original.Severity, item.Severity, StringComparison.OrdinalIgnoreCase))) + .Select(item => + { + var original = baseFindings[item.FindingId]; + return new + { + findingId = item.FindingId, + baseDec = original.Decision, + compareDec = item.Decision, + reason = string.Equals(original.Decision, item.Decision, StringComparison.OrdinalIgnoreCase) + ? $"Severity changed from {original.Severity} to {item.Severity}." + : $"Decision changed from {original.Decision} to {item.Decision}." + }; + }) + .ToArray(); + + var totalComparisons = Math.Max(baseSimulation.Findings.Length, compareSimulation.Findings.Length); + var identicalCount = Math.Max(0, totalComparisons - added.Length - removed.Length - changed.Length); + var matchPercentage = totalComparisons == 0 + ? 100 + : Math.Round((identicalCount / (double)totalComparisons) * 100, 2); + + return new + { + baseSimulationId = baseSimulation.SimulationId, + compareSimulationId = compareSimulation.SimulationId, + resultsMatch = added.Length == 0 && removed.Length == 0 && changed.Length == 0, + matchPercentage, + added, + removed, + changed, + comparedAt = timeProvider.GetUtcNow().ToString("O"), + traceId = ResolveTraceId(context) + }; + } + + private static ShadowComparisonRecord[] BuildComparisons(int limit) => + Enumerable.Range(1, limit).Select(index => new ShadowComparisonRecord( + $"finding-{index:000}", + $"pkg:oci/demo/service-{index}@sha256:{index.ToString("D4")}", + $"CVE-2026-{1800 + index}", + index % 4 == 0 ? "warn" : "allow", + index % 3 == 0 ? "high" : "medium", + index % 4 == 0 ? "deny" : "allow", + index % 4 == 0 ? "critical" : (index % 3 == 0 ? "high" : "medium"), + index % 4 == 0, + index % 4 == 0 ? "New deny rule would block the active allow path." : null)).ToArray(); + + private static object BuildLintResult(string policyPackId, int version, HttpContext context, TimeProvider timeProvider) => new + { + policyPackId, + policyVersion = version, + compiled = true, + totalIssues = 3, + errorCount = 1, + warningCount = 1, + infoCount = 1, + issues = new object[] + { + new + { + id = "lint-001", + ruleId = "rule-shadow-window", + severity = "error", + category = "semantic", + message = "Shadow mode rollouts must define an explicit target environment.", + path = "rules/shadow.rego", + line = 14, + column = 3, + fixable = true, + suggestedFix = "Add metadata.targetEnvironment to the shadow policy settings block.", + docsUrl = "/docs/policy/simulation/lint#shadow-window" + }, + new + { + id = "lint-002", + ruleId = "rule-coverage-floor", + severity = "warning", + category = "style", + message = "Coverage floor is below the recommended 80% baseline for promoted packs.", + path = "rules/release.rego", + line = 29, + column = 5, + fixable = false, + suggestedFix = (string?)null, + docsUrl = "/docs/policy/simulation/lint#coverage-floor" + }, + new + { + id = "lint-003", + ruleId = "rule-evidence-comment", + severity = "info", + category = "security", + message = "Rule contains an operator override without an evidence reference.", + path = "rules/override.rego", + line = 41, + column = 9, + fixable = false, + suggestedFix = "Attach a decision capsule reference before promotion.", + docsUrl = "/docs/policy/simulation/lint#evidence" + } + }, + lintedAt = timeProvider.GetUtcNow().ToString("O"), + traceId = ResolveTraceId(context) + }; + + private static object BuildCoverageResult(string policyPackId, int version, HttpContext context, TimeProvider timeProvider) => new + { + summary = new + { + policyPackId, + policyVersion = version, + totalRules = 12, + coveredRules = 9, + partialRules = 2, + uncoveredRules = 1, + overallCoveragePercent = 83, + totalTestCases = 18, + passedTestCases = 16, + failedTestCases = 1, + computedAt = timeProvider.GetUtcNow().ToString("O") + }, + rules = new object[] + { + new { ruleId = "risk-score", ruleName = "Risk Score Threshold", status = "covered", coveragePercent = 100, testCaseCount = 4, testCaseIds = new[] { "tc-001", "tc-002", "tc-003", "tc-004" }, missingScenarios = Array.Empty() }, + new { ruleId = "shadow-bias", ruleName = "Shadow Bias Guard", status = "partial", coveragePercent = 60, testCaseCount = 2, testCaseIds = new[] { "tc-005", "tc-006" }, missingScenarios = new[] { "diverged finding with approved exception", "shadow-disabled fallback" } }, + new { ruleId = "release-approval", ruleName = "Release Approval Requirement", status = "covered", coveragePercent = 100, testCaseCount = 3, testCaseIds = new[] { "tc-007", "tc-008", "tc-009" }, missingScenarios = Array.Empty() }, + new { ruleId = "evidence-binding", ruleName = "Decision Capsule Binding", status = "uncovered", coveragePercent = 0, testCaseCount = 0, testCaseIds = Array.Empty(), missingScenarios = new[] { "capsule missing", "capsule signature invalid" } } + }, + testCases = new object[] + { + new { id = "tc-001", name = "critical finding denied", description = "Critical reachable finding blocks promotion.", coveredRules = new[] { "risk-score", "release-approval" }, status = "passed", lastRunAt = timeProvider.GetUtcNow().AddMinutes(-18).ToString("O"), executionTimeMs = 124 }, + new { id = "tc-005", name = "shadow bias diverges", description = "Shadow mode divergence is surfaced in diff output.", coveredRules = new[] { "shadow-bias" }, status = "failed", lastRunAt = timeProvider.GetUtcNow().AddMinutes(-7).ToString("O"), executionTimeMs = 188 }, + new { id = "tc-009", name = "release approvals honored", description = "Promotion gate requires recorded approvals.", coveredRules = new[] { "release-approval" }, status = "passed", lastRunAt = timeProvider.GetUtcNow().AddMinutes(-4).ToString("O"), executionTimeMs = 92 } + }, + traceId = ResolveTraceId(context) + }; + + private static object BuildEffectivePolicies( + string? resourceType, + string? resourceId, + string? search, + int? limit, + HttpContext context, + TimeProvider timeProvider) + { + var resources = new dynamic[] + { + new + { + resourceId = "ghcr.io/org/app:v1.2.3", + resourceType = "image", + resourceName = "org/app:v1.2.3", + policies = new object[] + { + new { policyPackId = "policy-pack-001", policyVersion = 2, policyName = "Production Policy", scope = "tenant", priority = 1, inherited = false, effectiveFrom = "2025-12-01T00:00:00Z", effectiveUntil = (string?)null, overrideNotes = (string?)null }, + new { policyPackId = "policy-pack-global", policyVersion = 1, policyName = "Global Baseline", scope = "tenant", priority = 2, inherited = true, inheritedFrom = "organization", effectiveFrom = "2025-11-15T00:00:00Z", effectiveUntil = (string?)null, overrideNotes = "Inherited from central governance." } + }, + mergedPolicyHash = "sha256:merged-policy-app-001", + computedAt = timeProvider.GetUtcNow().ToString("O") + }, + new + { + resourceId = "proj-api-001", + resourceType = "project", + resourceName = "API Gateway Project", + policies = new object[] + { + new { policyPackId = "policy-pack-api", policyVersion = 3, policyName = "API Security Policy", scope = "project", priority = 1, inherited = false, effectiveFrom = "2026-01-05T00:00:00Z", effectiveUntil = (string?)null, overrideNotes = "Scoped project baseline." } + }, + mergedPolicyHash = "sha256:merged-policy-project-001", + computedAt = timeProvider.GetUtcNow().ToString("O") + } + }.AsEnumerable(); + + if (!string.IsNullOrWhiteSpace(resourceType)) + { + resources = resources.Where(item => string.Equals(item.resourceType, resourceType.Trim(), StringComparison.OrdinalIgnoreCase)); + } + + if (!string.IsNullOrWhiteSpace(resourceId)) + { + resources = resources.Where(item => string.Equals(item.resourceId, resourceId.Trim(), StringComparison.OrdinalIgnoreCase)); + } + + if (!string.IsNullOrWhiteSpace(search)) + { + var term = search.Trim(); + resources = resources.Where(item => + item.resourceId.Contains(term, StringComparison.OrdinalIgnoreCase) || + item.resourceName.Contains(term, StringComparison.OrdinalIgnoreCase)); + } + + var materialized = resources.Take(Math.Clamp(limit ?? 50, 1, 100)).ToArray(); + return new + { + resources = materialized, + total = materialized.Length, + continuationToken = (string?)null, + traceId = ResolveTraceId(context) + }; + } + + private static object BuildAuditLog( + string? policyPackId, + string? action, + string? actorId, + string? fromDate, + string? toDate, + int? page, + int? pageSize, + HttpContext context, + TimeProvider timeProvider) + { + var entries = new dynamic[] + { + new { id = "audit-001", policyPackId = "policy-pack-001", policyVersion = 2, action = "updated", actorId = "alice@stellaops.io", actorName = "Alice", timestamp = timeProvider.GetUtcNow().AddHours(-5).ToString("O"), ipAddress = "10.0.0.12", userAgent = "Policy Studio", previousValues = new { coverageFloor = 75 }, newValues = new { coverageFloor = 80 }, diffId = "diff-policy-pack-001-1-2", comment = "Raised promotion baseline after shadow run.", correlationId = "corr-audit-001" }, + new { id = "audit-002", policyPackId = "policy-pack-001", policyVersion = 2, action = "shadow_enabled", actorId = "bob@stellaops.io", actorName = "Bob", timestamp = timeProvider.GetUtcNow().AddHours(-2).ToString("O"), ipAddress = "10.0.0.33", userAgent = "Policy Studio", previousValues = new { enabled = false }, newValues = new { enabled = true, trafficPercentage = 25 }, diffId = (string?)null, comment = "Started canary shadow evaluation.", correlationId = "corr-audit-002" }, + new { id = "audit-003", policyPackId = "policy-pack-staging", policyVersion = 5, action = "approved", actorId = "carol@stellaops.io", actorName = "Carol", timestamp = timeProvider.GetUtcNow().AddDays(-1).ToString("O"), ipAddress = "10.0.0.44", userAgent = "Policy Studio", previousValues = new { status = "pending" }, newValues = new { status = "approved" }, diffId = (string?)null, comment = "Approved staging promotion after evidence review.", correlationId = "corr-audit-003" } + }.AsEnumerable(); + + if (!string.IsNullOrWhiteSpace(policyPackId)) + { + entries = entries.Where(entry => string.Equals(entry.policyPackId, policyPackId.Trim(), StringComparison.OrdinalIgnoreCase)); + } + + if (!string.IsNullOrWhiteSpace(action)) + { + entries = entries.Where(entry => string.Equals(entry.action, action.Trim(), StringComparison.OrdinalIgnoreCase)); + } + + if (!string.IsNullOrWhiteSpace(actorId)) + { + entries = entries.Where(entry => string.Equals(entry.actorId, actorId.Trim(), StringComparison.OrdinalIgnoreCase)); + } + + var from = ParseDate(fromDate); + var to = ParseDate(toDate); + if (from.HasValue) + { + entries = entries.Where(entry => ParseUtc(entry.timestamp) >= from.Value); + } + + if (to.HasValue) + { + entries = entries.Where(entry => ParseUtc(entry.timestamp) <= to.Value); + } + + var pageNumber = Math.Max(1, page ?? 1); + var size = Math.Clamp(pageSize ?? 20, 1, 100); + var allEntries = entries.OrderByDescending(entry => ParseUtc(entry.timestamp)).ToArray(); + var items = allEntries.Skip((pageNumber - 1) * size).Take(size).ToArray(); + + return new + { + entries = items, + total = allEntries.Length, + page = pageNumber, + pageSize = size, + hasMore = pageNumber * size < allEntries.Length, + traceId = ResolveTraceId(context) + }; + } + + private static object BuildDiffResult(string policyPackId, int fromVersion, int toVersion, HttpContext context, TimeProvider timeProvider) => new + { + diffId = $"diff-{policyPackId}-{fromVersion}-{toVersion}", + policyPackId, + fromVersion, + toVersion, + files = new object[] + { + new + { + path = "rules/main.rego", + changeType = "modified", + hunks = new object[] + { + new + { + oldStart = 40, + oldCount = 5, + newStart = 40, + newCount = 7, + lines = new object[] + { + new { oldLine = 40, newLine = 40, content = "# CVE severity thresholds", changeType = (string?)null }, + new { oldLine = 41, content = "critical_threshold := 9.0", changeType = "removed" }, + new { newLine = 41, content = "critical_threshold := 8.5", changeType = "added" }, + new { newLine = 42, content = "", changeType = "added" }, + new { newLine = 43, content = "# Added high severity handling", changeType = "added" }, + new { oldLine = 42, newLine = 44, content = "high_threshold := 7.0", changeType = (string?)null } + } + } + } + }, + new + { + path = "rules/license.rego", + changeType = "added", + hunks = new object[] + { + new + { + oldStart = 0, + oldCount = 0, + newStart = 1, + newCount = 10, + lines = new object[] + { + new { newLine = 1, content = "package stellaops.license", changeType = "added" }, + new { newLine = 2, content = "", changeType = "added" }, + new { newLine = 3, content = "# License policy rules", changeType = "added" } + } + } + } + } + }, + stats = new { additions = 15, deletions = 3, modifications = 1, filesChanged = 2 }, + createdAt = timeProvider.GetUtcNow().ToString("O"), + traceId = ResolveTraceId(context) + }; + + private static object BuildPromotionGateResult( + string policyPackId, + int version, + string? environment, + ShadowModeState shadow, + bool overrideApplied, + string? reason, + HttpContext context, + TimeProvider timeProvider) + { + var targetEnvironment = string.IsNullOrWhiteSpace(environment) ? "stage" : environment.Trim(); + var checks = new dynamic[] + { + new { id = "shadow-mode", name = "Shadow Mode Active", description = "Policy must run in shadow mode before promotion.", status = overrideApplied || shadow.Enabled ? "passed" : "failed", required = true, message = overrideApplied ? "Override applied by policy approver." : shadow.Enabled ? "Shadow mode is collecting comparisons." : "Enable shadow mode before promoting this pack.", details = new { trafficPercentage = shadow.TrafficPercentage }, docsUrl = "/docs/policy/simulation/shadow-mode", checkedAt = timeProvider.GetUtcNow().ToString("O") }, + new { id = "coverage", name = "Coverage Floor", description = "Policy coverage must be at least 80%.", status = "passed", required = true, message = "Coverage is 83%.", details = new { overallCoveragePercent = 83 }, docsUrl = "/docs/policy/simulation/coverage", checkedAt = timeProvider.GetUtcNow().ToString("O") }, + new { id = "lint", name = "Lint Clean", description = "Lint must not report blocking issues.", status = overrideApplied ? "passed" : "pending", required = true, message = overrideApplied ? "Override accepted the remaining lint delta." : "One blocking lint issue remains to be reviewed.", details = new { blockingIssues = overrideApplied ? 0 : 1 }, docsUrl = "/docs/policy/simulation/lint", checkedAt = timeProvider.GetUtcNow().ToString("O") }, + new { id = "security-review", name = "Security Review", description = "Security review must be recorded before promotion.", status = "passed", required = true, message = overrideApplied ? $"Override reason: {reason}" : "Security review evidence attached.", details = new { reviewer = "security-board" }, docsUrl = "/docs/policy/simulation/promotion", checkedAt = timeProvider.GetUtcNow().ToString("O") } + }; + + var allRequiredPassed = checks.All(check => string.Equals((string?)check.status, "passed", StringComparison.OrdinalIgnoreCase)); + return new + { + policyPackId, + policyVersion = version, + targetEnvironment, + overallStatus = allRequiredPassed ? "ready" : "blocked", + checks, + allRequiredPassed, + blockingIssues = allRequiredPassed ? 0 : 2, + warnings = allRequiredPassed ? 0 : 1, + canOverride = true, + computedAt = timeProvider.GetUtcNow().ToString("O"), + traceId = ResolveTraceId(context) + }; + } + + private static object BuildExceptionListResult( + string tenantId, + string? status, + string? severity, + string? search, + int? limit, + HttpContext context, + TimeProvider timeProvider) + { + EnsureTenantExceptions(tenantId, timeProvider); + IEnumerable items = Exceptions.Values + .Where(item => string.Equals(item.TenantId, tenantId, StringComparison.OrdinalIgnoreCase)) + .OrderByDescending(item => ParseUtc(item.RequestedAt)); + + if (!string.IsNullOrWhiteSpace(status)) + { + items = items.Where(item => string.Equals(item.Status, status.Trim(), StringComparison.OrdinalIgnoreCase)); + } + + if (!string.IsNullOrWhiteSpace(severity)) + { + items = items.Where(item => string.Equals(item.Severity, severity.Trim(), StringComparison.OrdinalIgnoreCase)); + } + + if (!string.IsNullOrWhiteSpace(search)) + { + var term = search.Trim(); + items = items.Where(item => + item.Name.Contains(term, StringComparison.OrdinalIgnoreCase) || + (item.Description?.Contains(term, StringComparison.OrdinalIgnoreCase) ?? false)); + } + + var materialized = items.Take(Math.Clamp(limit ?? 50, 1, 100)).Select(ToPolicyExceptionResponse).ToArray(); + return new + { + items = materialized, + total = materialized.Length, + continuationToken = (string?)null, + traceId = ResolveTraceId(context) + }; + } + + private static void EnsureTenantExceptions(string tenantId, TimeProvider timeProvider) + { + if (Exceptions.Values.Any(item => string.Equals(item.TenantId, tenantId, StringComparison.OrdinalIgnoreCase))) + { + return; + } + + foreach (var item in PolicyExceptionCompatState.CreateSeed(tenantId, timeProvider)) + { + Exceptions.TryAdd(item.Id, item); + } + } + + private static bool TryGetException(string tenantId, string exceptionId, TimeProvider timeProvider, out PolicyExceptionCompatState exception) + { + EnsureTenantExceptions(tenantId, timeProvider); + if (Exceptions.TryGetValue(exceptionId, out exception!) && + string.Equals(exception.TenantId, tenantId, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + exception = default!; + return false; + } + + private static object ToPolicyExceptionResponse(PolicyExceptionCompatState exception) => new + { + id = exception.Id, + name = exception.Name, + description = exception.Description, + status = exception.Status, + severity = exception.Severity, + scope = new + { + type = exception.Scope.Type, + tenantId = exception.Scope.TenantId, + projectId = exception.Scope.ProjectId, + images = exception.Scope.Images, + components = exception.Scope.Components, + advisories = exception.Scope.Advisories, + policyRules = exception.Scope.PolicyRules + }, + justification = exception.Justification, + effectiveFrom = exception.EffectiveFrom, + effectiveUntil = exception.EffectiveUntil, + requestedBy = exception.RequestedBy, + requestedAt = exception.RequestedAt, + approvedBy = exception.ApprovedBy, + approvedAt = exception.ApprovedAt, + revokedBy = exception.RevokedBy, + revokedAt = exception.RevokedAt, + revocationReason = exception.RevocationReason, + tags = exception.Tags, + metadata = exception.Metadata + }; + + private static object BuildMergePreviewResult(MergePreviewRequest? request, HttpContext context, TimeProvider timeProvider) + { + var sourcePolicies = request?.SourcePolicies?.Where(static item => !string.IsNullOrWhiteSpace(item)).Select(static item => item.Trim()).ToArray(); + if (sourcePolicies is null || sourcePolicies.Length == 0) + { + sourcePolicies = new[] { "policy-pack-001", "policy-pack-staging" }; + } + + return new + { + previewId = $"preview-{Guid.NewGuid():N}", + sourcePolicies, + targetEnvironment = string.IsNullOrWhiteSpace(request?.TargetEnvironment) ? "stage" : request!.TargetEnvironment!.Trim(), + mergedRules = new object[] + { + new { ruleId = "rule-001", ruleName = "cve-critical-block", sourcePolicies = new[] { sourcePolicies[0] }, mergedValue = new { threshold = 9.0, action = "block" }, hasConflict = false, conflictId = (string?)null }, + new { ruleId = "rule-002", ruleName = "license-copyleft-warn", sourcePolicies, mergedValue = new { licenses = new[] { "GPL-3.0", "AGPL-3.0" }, action = "warn" }, hasConflict = true, conflictId = "conflict-001" } + }, + conflicts = new object[] + { + new { id = "conflict-001", rulePath = "rules/license.rego:copyleft_warn", conflictType = "override", sourcePolicy = sourcePolicies[0], sourceValue = new { licenses = new[] { "GPL-3.0" }, action = "warn" }, targetPolicy = sourcePolicies.Length > 1 ? sourcePolicies[1] : sourcePolicies[0], targetValue = new { licenses = new[] { "GPL-3.0", "AGPL-3.0" }, action = "block" }, resolution = "source_wins", resolvedValue = new { licenses = new[] { "GPL-3.0", "AGPL-3.0" }, action = "warn" } } + }, + totalRules = 25, + conflictCount = 1, + autoResolvedCount = 1, + manualResolutionRequired = 0, + previewHash = "sha256:preview123", + createdAt = timeProvider.GetUtcNow().ToString("O"), + traceId = ResolveTraceId(context) + }; + } + + private static object BuildConflictDetectionResult( + IReadOnlyCollection? policyIds, + bool includeResolved, + string? severityFilter, + bool autoResolve, + HttpContext context, + TimeProvider timeProvider) + { + var analyzedPolicies = (policyIds is { Count: > 0 } ? policyIds : new[] { "policy-pack-001", "policy-pack-security" }) + .Where(static item => !string.IsNullOrWhiteSpace(item)) + .Select(static item => item.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + IEnumerable conflicts = new dynamic[] + { + new + { + id = "conflict-001", + rulePath = "rules/license.rego:copyleft_warn", + ruleName = "copyleft_warn", + conflictType = "override", + severity = "high", + sourcePolicyId = analyzedPolicies[0], + sourcePolicyName = "Production Policy", + sourceValue = new { action = "warn" }, + targetPolicyId = analyzedPolicies.Length > 1 ? analyzedPolicies[1] : analyzedPolicies[0], + targetPolicyName = analyzedPolicies.Length > 1 ? "Security Baseline" : "Production Policy", + targetValue = new { action = "block" }, + impactDescription = "Conflicting actions would change the release verdict for copyleft licenses.", + affectedResourcesCount = 14, + suggestions = new object[] + { + new { id = "resolve-use-source", description = "Keep source warning behavior", action = "use_source", suggestedValue = new { action = "warn" }, confidence = 82, rationale = "Matches the current production exception policy." }, + new { id = "resolve-merge", description = "Merge with explicit escalation list", action = "merge", suggestedValue = new { action = "warn", escalate = new[] { "AGPL-3.0" } }, confidence = 76, rationale = "Preserves warning semantics while escalating the higher-risk licenses." } + }, + selectedResolution = autoResolve ? "resolve-use-source" : (string?)null, + resolvedValue = autoResolve ? new { action = "warn" } : null, + isResolved = autoResolve, + detectedAt = timeProvider.GetUtcNow().AddMinutes(-12).ToString("O"), + resolvedAt = autoResolve ? timeProvider.GetUtcNow().ToString("O") : null, + resolvedBy = autoResolve ? "auto-resolver" : null + }, + new + { + id = "conflict-002", + rulePath = "rules/release.rego:approval_floor", + ruleName = "approval_floor", + conflictType = "version_mismatch", + severity = "medium", + sourcePolicyId = analyzedPolicies[0], + sourcePolicyName = "Production Policy", + sourceValue = new { minApprovals = 2 }, + targetPolicyId = analyzedPolicies.Length > 1 ? analyzedPolicies[1] : analyzedPolicies[0], + targetPolicyName = analyzedPolicies.Length > 1 ? "Security Baseline" : "Production Policy", + targetValue = new { minApprovals = 1 }, + impactDescription = "Approval count mismatch changes the promotion gate decision for low-risk releases.", + affectedResourcesCount = 8, + suggestions = new object[] + { + new { id = "resolve-use-target", description = "Keep target approval floor", action = "use_target", suggestedValue = new { minApprovals = 1 }, confidence = 65, rationale = "Closer to current staging workflow." } + }, + selectedResolution = (string?)null, + resolvedValue = (object?)null, + isResolved = false, + detectedAt = timeProvider.GetUtcNow().AddMinutes(-8).ToString("O"), + resolvedAt = (string?)null, + resolvedBy = (string?)null + } + }.AsEnumerable(); + + if (!includeResolved) + { + conflicts = conflicts.Where(item => !item.isResolved); + } + + if (!string.IsNullOrWhiteSpace(severityFilter)) + { + conflicts = conflicts.Where(item => string.Equals(item.severity, severityFilter.Trim(), StringComparison.OrdinalIgnoreCase)); + } + + var materialized = conflicts.ToArray(); + return new + { + conflicts = materialized, + totalConflicts = materialized.Length, + criticalCount = materialized.Count(item => string.Equals(item.severity, "critical", StringComparison.OrdinalIgnoreCase)), + highCount = materialized.Count(item => string.Equals(item.severity, "high", StringComparison.OrdinalIgnoreCase)), + mediumCount = materialized.Count(item => string.Equals(item.severity, "medium", StringComparison.OrdinalIgnoreCase)), + lowCount = materialized.Count(item => string.Equals(item.severity, "low", StringComparison.OrdinalIgnoreCase)), + autoResolvableCount = materialized.Count(item => ((object[])item.suggestions).Length > 0), + manualResolutionRequired = materialized.Count(item => !item.isResolved), + analyzedPolicies, + analyzedAt = timeProvider.GetUtcNow().ToString("O"), + traceId = ResolveTraceId(context) + }; + } + + private static object BuildBatchEvaluationHistoryResult( + string tenantId, + string? policyPackId, + string? status, + int? page, + int? pageSize, + HttpContext context, + TimeProvider timeProvider) + { + EnsureTenantBatchEvaluations(tenantId, timeProvider); + IEnumerable items = BatchEvaluations.Values + .Where(item => string.Equals(item.TenantId, tenantId, StringComparison.OrdinalIgnoreCase)) + .OrderByDescending(item => ParseUtc(item.StartedAt)); + + if (!string.IsNullOrWhiteSpace(policyPackId)) + { + items = items.Where(item => string.Equals(item.PolicyPackId, policyPackId.Trim(), StringComparison.OrdinalIgnoreCase)); + } + + if (!string.IsNullOrWhiteSpace(status)) + { + items = items.Where(item => string.Equals(item.Status, status.Trim(), StringComparison.OrdinalIgnoreCase)); + } + + var pageNumber = Math.Max(1, page ?? 1); + var size = Math.Clamp(pageSize ?? 20, 1, 100); + var allItems = items.ToArray(); + var materialized = allItems.Skip((pageNumber - 1) * size).Take(size).Select(ToBatchHistoryEntry).ToArray(); + + return new + { + items = materialized, + total = allItems.Length, + hasMore = pageNumber * size < allItems.Length, + traceId = ResolveTraceId(context) + }; + } + + private static void EnsureTenantBatchEvaluations(string tenantId, TimeProvider timeProvider) + { + if (BatchEvaluations.Values.Any(item => string.Equals(item.TenantId, tenantId, StringComparison.OrdinalIgnoreCase))) + { + return; + } + + foreach (var item in BatchEvaluationState.CreateSeed(tenantId, timeProvider)) + { + BatchEvaluations.TryAdd(item.BatchId, item); + } + } + + private static bool TryGetBatchEvaluation(string tenantId, string batchId, TimeProvider timeProvider, out BatchEvaluationState batch) + { + EnsureTenantBatchEvaluations(tenantId, timeProvider); + if (BatchEvaluations.TryGetValue(batchId, out batch!) && + string.Equals(batch.TenantId, tenantId, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + batch = default!; + return false; + } + + private static object ToBatchEvaluationResponse(BatchEvaluationState batch) => new + { + batchId = batch.BatchId, + status = batch.Status, + policyPackId = batch.PolicyPackId, + policyVersion = batch.PolicyVersion, + totalArtifacts = batch.TotalArtifacts, + completedArtifacts = batch.CompletedArtifacts, + failedArtifacts = batch.FailedArtifacts, + passedArtifacts = batch.PassedArtifacts, + warnedArtifacts = batch.WarnedArtifacts, + blockedArtifacts = batch.BlockedArtifacts, + results = batch.Results.Select(result => new + { + artifactId = result.ArtifactId, + name = result.Name, + status = result.Status, + overallDecision = result.OverallDecision, + findingsBySeverity = result.FindingsBySeverity, + findingsByDecision = result.FindingsByDecision, + totalFindings = result.TotalFindings, + criticalFindings = result.CriticalFindings, + highFindings = result.HighFindings, + blocked = result.Blocked, + executionTimeMs = result.ExecutionTimeMs, + error = result.Error, + simulationId = result.SimulationId + }), + startedAt = batch.StartedAt, + completedAt = batch.CompletedAt, + totalExecutionTimeMs = batch.TotalExecutionTimeMs, + error = batch.Error, + tags = batch.Tags, + traceId = batch.TraceId + }; + + private static object ToBatchHistoryEntry(BatchEvaluationState batch) => new + { + batchId = batch.BatchId, + policyPackId = batch.PolicyPackId, + policyVersion = batch.PolicyVersion, + status = batch.Status, + totalArtifacts = batch.TotalArtifacts, + passed = batch.PassedArtifacts, + failed = batch.FailedArtifacts, + blocked = batch.BlockedArtifacts, + startedAt = batch.StartedAt, + completedAt = batch.CompletedAt, + executedBy = batch.ExecutedBy, + tags = batch.Tags + }; + + private sealed record ShadowComparisonRecord( + string FindingId, + string ComponentPurl, + string AdvisoryId, + string ActiveDecision, + string ActiveSeverity, + string ShadowDecision, + string ShadowSeverity, + bool Diverged, + string? DivergenceReason); +} + +public sealed record ShadowModeWriteRequest +{ + public bool? Enabled { get; init; } + public string? ShadowPackId { get; init; } + public int? ShadowVersion { get; init; } + public string? ActivePackId { get; init; } + public int? ActiveVersion { get; init; } + public int? TrafficPercentage { get; init; } + public string? AutoDisableAfter { get; init; } +} + +public sealed record SimulationWriteRequest +{ + public string? PolicyPackId { get; init; } + public int? PolicyVersion { get; init; } + public string? SbomId { get; init; } + public string? Environment { get; init; } + public bool? IncludeExplain { get; init; } + public bool? DiffAgainstActive { get; init; } +} + +public sealed record SimulationPinRequest +{ + public bool Pinned { get; init; } +} + +public sealed record PromotionGateOverrideRequest +{ + public string? Environment { get; init; } + public string? Reason { get; init; } +} + +public sealed record PolicyExceptionCompatWriteRequest +{ + public string? Name { get; init; } + public string? Description { get; init; } + public string? Severity { get; init; } + public PolicyExceptionCompatScopeRequest? Scope { get; init; } + public string? Justification { get; init; } + public string? EffectiveFrom { get; init; } + public string? EffectiveUntil { get; init; } + public string[]? Tags { get; init; } + public Dictionary? Metadata { get; init; } +} + +public sealed record PolicyExceptionCompatUpdateRequest +{ + public string? Description { get; init; } + public string? Severity { get; init; } + public string? Justification { get; init; } + public string? EffectiveFrom { get; init; } + public string? EffectiveUntil { get; init; } + public string[]? Tags { get; init; } + public Dictionary? Metadata { get; init; } +} + +public sealed record PolicyExceptionCompatRevokeRequest +{ + public string? Reason { get; init; } +} + +public sealed record PolicyExceptionCompatScopeRequest +{ + public string? Type { get; init; } + public string? TenantId { get; init; } + public string? ProjectId { get; init; } + public string[]? Images { get; init; } + public string[]? Components { get; init; } + public string[]? Advisories { get; init; } + public string[]? PolicyRules { get; init; } +} + +public sealed record MergePreviewRequest +{ + public string[]? SourcePolicies { get; init; } + public string? TargetEnvironment { get; init; } +} + +public sealed record ConflictDetectionRequest +{ + public string[]? PolicyIds { get; init; } +} + +public sealed record ConflictResolveRequest +{ + public string? ResolutionId { get; init; } +} + +public sealed record ConflictAutoResolveRequest +{ + public string[]? ConflictIds { get; init; } +} + +public sealed record BatchEvaluationWriteRequest +{ + public string? PolicyPackId { get; init; } + public int? PolicyVersion { get; init; } + public BatchEvaluationArtifactRequest[]? Artifacts { get; init; } + public string? Environment { get; init; } + public bool? StopOnFailure { get; init; } + public int? ParallelLimit { get; init; } + public bool? IncludeFindings { get; init; } + public string[]? Tags { get; init; } +} + +public sealed record BatchEvaluationArtifactRequest +{ + public string? ArtifactId { get; init; } + public string? Name { get; init; } + public string? Type { get; init; } +} + +public sealed record ShadowModeState( + bool Enabled, + string Status, + string ShadowPackId, + int ShadowVersion, + string ActivePackId, + int ActiveVersion, + int TrafficPercentage, + string? EnabledAt, + string? EnabledBy, + string? AutoDisableAfter) +{ + public static ShadowModeState CreateDefault(TimeProvider timeProvider) => new( + false, + "disabled", + "policy-pack-shadow-001", + 3, + "policy-pack-prod-001", + 2, + 25, + null, + null, + timeProvider.GetUtcNow().AddHours(12).ToString("O")); + + public static ShadowModeState CreateEnabled(ShadowModeWriteRequest request, string actor, TimeProvider timeProvider) => + CreateDefault(timeProvider).WithEnabled(request, actor, timeProvider); + + public ShadowModeState WithEnabled(ShadowModeWriteRequest request, string actor, TimeProvider timeProvider) => this with + { + Enabled = request.Enabled ?? true, + Status = (request.Enabled ?? true) ? "enabled" : "paused", + ShadowPackId = request.ShadowPackId?.Trim() ?? ShadowPackId, + ShadowVersion = request.ShadowVersion ?? ShadowVersion, + ActivePackId = request.ActivePackId?.Trim() ?? ActivePackId, + ActiveVersion = request.ActiveVersion ?? ActiveVersion, + TrafficPercentage = Math.Clamp(request.TrafficPercentage ?? TrafficPercentage, 0, 100), + EnabledAt = timeProvider.GetUtcNow().ToString("O"), + EnabledBy = actor, + AutoDisableAfter = request.AutoDisableAfter ?? AutoDisableAfter + }; + + public ShadowModeState WithDisabled() => this with + { + Enabled = false, + Status = "disabled" + }; +} + +public sealed record PolicyExceptionCompatScopeState( + string Type, + string? TenantId, + string? ProjectId, + string[]? Images, + string[]? Components, + string[]? Advisories, + string[]? PolicyRules); + +public sealed record PolicyExceptionCompatState( + string Id, + string TenantId, + string Name, + string? Description, + string Status, + string Severity, + PolicyExceptionCompatScopeState Scope, + string Justification, + string EffectiveFrom, + string EffectiveUntil, + string RequestedBy, + string RequestedAt, + string? ApprovedBy, + string? ApprovedAt, + string? RevokedBy, + string? RevokedAt, + string? RevocationReason, + string[]? Tags, + Dictionary? Metadata) +{ + public static PolicyExceptionCompatState Create( + PolicyExceptionCompatWriteRequest request, + string tenantId, + string actor, + TimeProvider timeProvider) + { + var now = timeProvider.GetUtcNow(); + return new PolicyExceptionCompatState( + $"exc-{Guid.NewGuid():N}", + tenantId, + request.Name?.Trim() ?? "New Exception", + request.Description?.Trim(), + "pending", + NormalizeSeverity(request.Severity), + ToScopeState(request.Scope, tenantId), + request.Justification?.Trim() ?? string.Empty, + ParseExceptionTimestamp(request.EffectiveFrom, now), + ParseExceptionTimestamp(request.EffectiveUntil, now.AddDays(90)), + actor, + now.ToString("O"), + null, + null, + null, + null, + null, + request.Tags?.Where(static item => !string.IsNullOrWhiteSpace(item)).Select(static item => item.Trim()).ToArray(), + request.Metadata); + } + + public static IReadOnlyList CreateSeed(string tenantId, TimeProvider timeProvider) + { + var now = timeProvider.GetUtcNow(); + return new[] + { + new PolicyExceptionCompatState( + "exc-001", + tenantId, + "Reachability False Positive", + "Temporary override for unreachable advisory until rescoring completes.", + "approved", + "high", + new PolicyExceptionCompatScopeState("component", tenantId, "proj-api-001", null, new[] { "pkg:npm/lodash@4.17.21" }, new[] { "CVE-2026-2002" }, new[] { "risk-score" }), + "Static trace proved the call path is unreachable in production traffic.", + now.AddDays(-10).ToString("O"), + now.AddDays(20).ToString("O"), + "alice@stellaops.io", + now.AddDays(-10).ToString("O"), + "security-board", + now.AddDays(-9).ToString("O"), + null, + null, + null, + new[] { "reachability", "temporary" }, + new Dictionary(StringComparer.OrdinalIgnoreCase) { ["ticket"] = "SEC-201" }), + new PolicyExceptionCompatState( + "exc-002", + tenantId, + "License Escalation Review", + "Pending decision for AGPL transitive package in staging.", + "pending", + "medium", + new PolicyExceptionCompatScopeState("project", tenantId, "proj-api-001", null, null, new[] { "CVE-2026-2004" }, new[] { "license-copyleft-warn" }), + "Awaiting legal review on transitive AGPL dependency.", + now.AddDays(-2).ToString("O"), + now.AddDays(14).ToString("O"), + "bob@stellaops.io", + now.AddDays(-2).ToString("O"), + null, + null, + null, + null, + null, + new[] { "legal" }, + null), + new PolicyExceptionCompatState( + "exc-003", + tenantId, + "Expired Legacy Exception", + "Historical exception retained for audit trail.", + "revoked", + "low", + new PolicyExceptionCompatScopeState("global", tenantId, null, null, null, null, new[] { "release-approval" }), + "Superseded by updated approval workflow.", + now.AddDays(-60).ToString("O"), + now.AddDays(-1).ToString("O"), + "carol@stellaops.io", + now.AddDays(-60).ToString("O"), + "security-board", + now.AddDays(-55).ToString("O"), + "security-board", + now.AddDays(-1).ToString("O"), + "Superseded by policy-pack-001 v2.", + new[] { "historical" }, + null) + }; + } + + public PolicyExceptionCompatState WithUpdates(PolicyExceptionCompatUpdateRequest request, TimeProvider timeProvider) => this with + { + Description = request.Description?.Trim() ?? Description, + Severity = NormalizeSeverity(request.Severity, Severity), + Justification = request.Justification?.Trim() ?? Justification, + EffectiveFrom = ParseExceptionTimestamp(request.EffectiveFrom, ParseRecordedUtc(EffectiveFrom)), + EffectiveUntil = ParseExceptionTimestamp(request.EffectiveUntil, ParseRecordedUtc(EffectiveUntil)), + Tags = request.Tags?.Where(static item => !string.IsNullOrWhiteSpace(item)).Select(static item => item.Trim()).ToArray() ?? Tags, + Metadata = request.Metadata ?? Metadata + }; + + public PolicyExceptionCompatState WithRevoked(string actor, string? reason, TimeProvider timeProvider) => this with + { + Status = "revoked", + RevokedBy = actor, + RevokedAt = timeProvider.GetUtcNow().ToString("O"), + RevocationReason = string.IsNullOrWhiteSpace(reason) ? "Revoked by policy operator." : reason.Trim() + }; + + private static string NormalizeSeverity(string? severity, string fallback = "medium") => + severity?.Trim().ToLowerInvariant() switch + { + "critical" => "critical", + "high" => "high", + "low" => "low", + "medium" => "medium", + _ => fallback + }; + + private static DateTimeOffset ParseRecordedUtc(string value) => + DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var parsed) + ? parsed + : DateTimeOffset.MinValue; + + private static PolicyExceptionCompatScopeState ToScopeState(PolicyExceptionCompatScopeRequest? request, string tenantId) => new( + request?.Type?.Trim().ToLowerInvariant() is "tenant" or "project" or "image" or "component" ? request.Type!.Trim().ToLowerInvariant() : "global", + request?.TenantId ?? tenantId, + request?.ProjectId, + request?.Images?.Where(static item => !string.IsNullOrWhiteSpace(item)).Select(static item => item.Trim()).ToArray(), + request?.Components?.Where(static item => !string.IsNullOrWhiteSpace(item)).Select(static item => item.Trim()).ToArray(), + request?.Advisories?.Where(static item => !string.IsNullOrWhiteSpace(item)).Select(static item => item.Trim()).ToArray(), + request?.PolicyRules?.Where(static item => !string.IsNullOrWhiteSpace(item)).Select(static item => item.Trim()).ToArray()); + + private static string ParseExceptionTimestamp(string? value, DateTimeOffset fallback) => + DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var parsed) + ? parsed.ToString("O") + : fallback.ToString("O"); + + private static string ParseExceptionTimestamp(string? value, DateTimeOffset? fallback) => + ParseExceptionTimestamp(value, fallback ?? DateTimeOffset.UtcNow); +} + +public sealed record BatchEvaluationArtifactState( + string ArtifactId, + string Name, + string Status, + string? OverallDecision, + Dictionary? FindingsBySeverity, + Dictionary? FindingsByDecision, + int? TotalFindings, + int? CriticalFindings, + int? HighFindings, + bool? Blocked, + int? ExecutionTimeMs, + string? Error, + string? SimulationId); + +public sealed record BatchEvaluationState( + string BatchId, + string TenantId, + string Status, + string PolicyPackId, + int PolicyVersion, + int TotalArtifacts, + int CompletedArtifacts, + int FailedArtifacts, + int PassedArtifacts, + int WarnedArtifacts, + int BlockedArtifacts, + BatchEvaluationArtifactState[] Results, + string StartedAt, + string? CompletedAt, + int? TotalExecutionTimeMs, + string? Error, + string[]? Tags, + string? TraceId, + string? ExecutedBy) +{ + public static BatchEvaluationState Create( + BatchEvaluationWriteRequest request, + string tenantId, + string actor, + TimeProvider timeProvider, + string? traceId) + { + var artifacts = request.Artifacts?.Where(static item => !string.IsNullOrWhiteSpace(item.ArtifactId)).ToArray() ?? Array.Empty(); + return new BatchEvaluationState( + $"batch-{Guid.NewGuid():N}", + tenantId, + "running", + request.PolicyPackId?.Trim() ?? "policy-pack-001", + request.PolicyVersion ?? 1, + artifacts.Length, + 0, + 0, + 0, + 0, + 0, + artifacts.Select((artifact, index) => new BatchEvaluationArtifactState( + artifact.ArtifactId!.Trim(), + artifact.Name?.Trim() ?? artifact.ArtifactId!.Trim(), + "running", + null, + null, + null, + null, + null, + null, + null, + null, + null, + $"sim-batch-{index + 1:000}")).ToArray(), + timeProvider.GetUtcNow().ToString("O"), + null, + null, + null, + request.Tags?.Where(static item => !string.IsNullOrWhiteSpace(item)).Select(static item => item.Trim()).ToArray(), + traceId, + actor); + } + + public static IReadOnlyList CreateSeed(string tenantId, TimeProvider timeProvider) + { + var now = timeProvider.GetUtcNow(); + return new[] + { + new BatchEvaluationState( + "batch-seed-001", + tenantId, + "completed", + "policy-pack-001", + 2, + 2, + 2, + 0, + 1, + 1, + 0, + new[] + { + new BatchEvaluationArtifactState("sbom-001", "api-gateway:v1.5.0", "completed", "pass", new Dictionary(StringComparer.OrdinalIgnoreCase) { ["critical"] = 0, ["high"] = 1 }, new Dictionary(StringComparer.OrdinalIgnoreCase) { ["pass"] = 1 }, 1, 0, 1, false, 143, null, "sim-001"), + new BatchEvaluationArtifactState("sbom-002", "api-gateway:v1.5.1", "completed", "warn", new Dictionary(StringComparer.OrdinalIgnoreCase) { ["critical"] = 0, ["high"] = 0, ["medium"] = 2 }, new Dictionary(StringComparer.OrdinalIgnoreCase) { ["warn"] = 1 }, 2, 0, 0, false, 188, null, "sim-002") + }, + now.AddHours(-4).ToString("O"), + now.AddHours(-4).AddMinutes(3).ToString("O"), + 331, + null, + new[] { "seed", "history" }, + "trace-batch-seed-001", + "alice@stellaops.io") + }; + } + + public BatchEvaluationState WithCompleted(TimeProvider timeProvider, string? traceId) + { + var completedResults = Results.Select((result, index) => result with + { + Status = "completed", + OverallDecision = index == 0 ? "pass" : "warn", + FindingsBySeverity = index == 0 + ? new Dictionary(StringComparer.OrdinalIgnoreCase) { ["high"] = 1 } + : new Dictionary(StringComparer.OrdinalIgnoreCase) { ["medium"] = 2, ["low"] = 1 }, + FindingsByDecision = index == 0 + ? new Dictionary(StringComparer.OrdinalIgnoreCase) { ["pass"] = 1 } + : new Dictionary(StringComparer.OrdinalIgnoreCase) { ["warn"] = 1 }, + TotalFindings = index == 0 ? 1 : 3, + CriticalFindings = 0, + HighFindings = index == 0 ? 1 : 0, + Blocked = false, + ExecutionTimeMs = 120 + (index * 40) + }).ToArray(); + + return this with + { + Status = "completed", + CompletedArtifacts = TotalArtifacts, + FailedArtifacts = 0, + PassedArtifacts = Math.Max(1, TotalArtifacts - 1), + WarnedArtifacts = TotalArtifacts > 1 ? 1 : 0, + BlockedArtifacts = 0, + Results = completedResults, + CompletedAt = timeProvider.GetUtcNow().ToString("O"), + TotalExecutionTimeMs = completedResults.Sum(static item => item.ExecutionTimeMs ?? 0), + TraceId = traceId ?? TraceId + }; + } + + public BatchEvaluationState WithCancelled(TimeProvider timeProvider, string? traceId) => this with + { + Status = "cancelled", + CompletedAt = timeProvider.GetUtcNow().ToString("O"), + Error = "Cancelled by operator request.", + TraceId = traceId ?? TraceId + }; +} + +public sealed record SimulationState( + string SimulationId, + string TenantId, + string Status, + string PolicyPackId, + int PolicyVersion, + object Summary, + SimulationFindingRecord[] Findings, + object? Diff, + object[]? ExplainTrace, + int ExecutionTimeMs, + string ExecutedAt, + string? Error, + string? TraceId, + string? SbomId, + string? SbomName, + string? ExecutedBy, + string ResultHash, + Dictionary FindingsBySeverity, + int TotalFindings, + string[]? Tags, + string? Notes, + bool Pinned) +{ + public static SimulationState Create(SimulationWriteRequest request, string tenantId, string actor, TimeProvider timeProvider) + { + var now = timeProvider.GetUtcNow().ToString("O"); + var findings = Enumerable.Range(1, 4).Select(index => new SimulationFindingRecord( + $"finding-{index:000}", + $"pkg:oci/demo/service-{index}@sha256:{index.ToString("D4")}", + $"CVE-2026-{1900 + index}", + index % 4 == 0 ? "deny" : "warn", + index % 2 == 0 ? "high" : "medium", + 550 + index * 40, + index % 4 == 0 ? "block" : "warn", + new[] { "risk.score", "reachability.bias" }, + index % 2 == 0 ? "affected" : "under_investigation", + index % 3 == 0 ? $"exc-{index:000}" : null)).ToArray(); + + var explainTrace = request.IncludeExplain == true + ? new object[] + { + new { step = 1, ruleName = "risk.score", ruleType = "threshold", matched = true, priority = 10, decisive = false }, + new { step = 2, ruleName = "reachability.bias", ruleType = "weighted", matched = true, priority = 20, decisive = true } + } + : null; + + var diff = request.DiffAgainstActive == true + ? new + { + added = Array.Empty(), + removed = Array.Empty(), + changed = new[] + { + new + { + componentPurl = "pkg:oci/demo/service-4@sha256:0004", + advisoryId = "CVE-2026-1904", + reason = "New deny rule triggered.", + previousValue = "warn", + newValue = "deny" + } + }, + statusDeltas = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["warn->deny"] = 1 + }, + severityDeltas = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["high->critical"] = 1 + } + } + : null; + + var findingsBySeverity = findings + .GroupBy(static finding => finding.Severity, StringComparer.OrdinalIgnoreCase) + .ToDictionary(group => group.Key, group => group.Count(), StringComparer.OrdinalIgnoreCase); + + return new SimulationState( + $"sim-{Guid.NewGuid():N}", + tenantId, + "completed", + request.PolicyPackId?.Trim() ?? "policy-pack-001", + request.PolicyVersion ?? 3, + new + { + totalFindings = findings.Length, + vexWins = 1, + suppressions = 0, + exceptionsApplied = 1, + bySeverity = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["high"] = 2, + ["medium"] = 2 + }, + byDecision = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["deny"] = 1, + ["warn"] = 3 + }, + ruleHits = new[] + { + new { ruleName = "risk.score", hitCount = 4 }, + new { ruleName = "reachability.bias", hitCount = 2 } + } + }, + findings, + diff, + explainTrace, + 187, + now, + null, + null, + request.SbomId?.Trim(), + request.SbomId is { Length: > 0 } sbomId ? $"{sbomId.Trim()}:compat" : null, + actor, + $"sha256:{Guid.NewGuid():N}", + findingsBySeverity, + findings.Length, + ["compatibility", "policy-simulation"], + null, + false); + } + + public static IReadOnlyList CreateCompatibilitySeed(string tenantId, TimeProvider timeProvider) + { + var current = timeProvider.GetUtcNow(); + var baseFindings = new[] + { + new SimulationFindingRecord("finding-001", "pkg:oci/demo/api-gateway@sha256:0001", "CVE-2026-2001", "warn", "high", 710, "warn", ["risk.score", "reachability.bias"], "affected", null), + new SimulationFindingRecord("finding-002", "pkg:oci/demo/api-gateway@sha256:0002", "CVE-2026-2002", "allow", "medium", 480, "monitor", ["policy.exception"], "under_investigation", "exc-002"), + new SimulationFindingRecord("finding-003", "pkg:oci/demo/api-gateway@sha256:0003", "CVE-2026-2003", "deny", "critical", 920, "block", ["risk.score"], "affected", null) + }; + + var compareFindings = new[] + { + new SimulationFindingRecord("finding-001", "pkg:oci/demo/api-gateway@sha256:0001", "CVE-2026-2001", "deny", "critical", 760, "block", ["risk.score", "reachability.bias"], "affected", null), + new SimulationFindingRecord("finding-002", "pkg:oci/demo/api-gateway@sha256:0002", "CVE-2026-2002", "allow", "medium", 480, "monitor", ["policy.exception"], "under_investigation", "exc-002"), + new SimulationFindingRecord("finding-004", "pkg:oci/demo/api-gateway@sha256:0004", "CVE-2026-2004", "warn", "low", 250, "monitor", ["new.rule"], "under_investigation", null) + }; + + var failedFindings = new[] + { + new SimulationFindingRecord("finding-101", "pkg:oci/demo/worker@sha256:0101", "CVE-2026-2101", "warn", "medium", 510, "warn", ["risk.score"], "under_investigation", null) + }; + + return new[] + { + CreateCompatibilityState( + "sim-001", + tenantId, + "completed", + "policy-pack-001", + 2, + "sbom-001", + "api-gateway:v1.5.0", + "alice@stellaops.io", + "sha256:abc123def456789", + current.AddHours(-1), + 234, + baseFindings, + ["release-candidate", "api"], + null, + true), + CreateCompatibilityState( + "sim-002", + tenantId, + "completed", + "policy-pack-001", + 2, + "sbom-002", + "api-gateway:v1.5.1", + "bob@stellaops.io", + "sha256:def456abc123789", + current.AddHours(-6), + 278, + compareFindings, + ["comparison", "api"], + "Policy pack candidate introduced one stricter verdict.", + false), + CreateCompatibilityState( + "sim-003", + tenantId, + "failed", + "policy-pack-staging-001", + 5, + "sbom-003", + "worker:v2.3.1", + "carol@stellaops.io", + "sha256:deadbeef00112233", + current.AddDays(-2), + 412, + failedFindings, + ["staging", "retry-needed"], + "Dependency graph snapshot timed out during explain bundle generation.", + false, + "Execution aborted while collecting explain trace.") + }; + } + + private static SimulationState CreateCompatibilityState( + string simulationId, + string tenantId, + string status, + string policyPackId, + int policyVersion, + string sbomId, + string sbomName, + string executedBy, + string resultHash, + DateTimeOffset executedAt, + int executionTimeMs, + SimulationFindingRecord[] findings, + string[]? tags, + string? notes, + bool pinned, + string? error = null) + { + var findingsBySeverity = findings + .GroupBy(static finding => finding.Severity, StringComparer.OrdinalIgnoreCase) + .ToDictionary(group => group.Key, group => group.Count(), StringComparer.OrdinalIgnoreCase); + var diff = simulationId == "sim-002" + ? new + { + added = new[] { new { componentPurl = "pkg:oci/demo/api-gateway@sha256:0004", advisoryId = "CVE-2026-2004", reason = "Candidate policy introduced a new low-severity monitor finding.", previousValue = "none", newValue = "warn" } }, + removed = new[] { new { componentPurl = "pkg:oci/demo/api-gateway@sha256:0003", advisoryId = "CVE-2026-2003", reason = "Legacy deny rule no longer matched.", previousValue = "deny", newValue = "none" } }, + changed = new[] { new { componentPurl = "pkg:oci/demo/api-gateway@sha256:0001", advisoryId = "CVE-2026-2001", reason = "Risk threshold tightened.", previousValue = "warn", newValue = "deny" } }, + statusDeltas = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["warn->deny"] = 1 }, + severityDeltas = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["high->critical"] = 1 } + } + : null; + + return new SimulationState( + simulationId, + tenantId, + status, + policyPackId, + policyVersion, + new + { + totalFindings = findings.Length, + vexWins = findings.Count(static finding => string.Equals(finding.VexStatus, "affected", StringComparison.OrdinalIgnoreCase)), + suppressions = findings.Count(static finding => string.Equals(finding.Decision, "allow", StringComparison.OrdinalIgnoreCase)), + exceptionsApplied = findings.Count(static finding => !string.IsNullOrWhiteSpace(finding.ExceptionId)), + bySeverity = findingsBySeverity, + byDecision = findings + .GroupBy(static finding => finding.Decision, StringComparer.OrdinalIgnoreCase) + .ToDictionary(group => group.Key, group => group.Count(), StringComparer.OrdinalIgnoreCase), + ruleHits = findings + .SelectMany(static finding => finding.MatchedRules) + .GroupBy(rule => rule, StringComparer.OrdinalIgnoreCase) + .Select(group => new { ruleName = group.Key, hitCount = group.Count() }) + .ToArray() + }, + findings, + diff, + status == "failed" ? null : new object[] + { + new { step = 1, ruleName = "risk.score", ruleType = "threshold", matched = true, priority = 10, decisive = false }, + new { step = 2, ruleName = "reachability.bias", ruleType = "weighted", matched = true, priority = 20, decisive = true } + }, + executionTimeMs, + executedAt.ToString("O"), + error, + null, + sbomId, + sbomName, + executedBy, + resultHash, + findingsBySeverity, + findings.Length, + tags, + notes, + pinned); + } +} + +public sealed record SimulationFindingRecord( + string FindingId, + string ComponentPurl, + string AdvisoryId, + string Decision, + string Severity, + int? Score, + string? RecommendedAction, + string[] MatchedRules, + string? VexStatus, + string? ExceptionId); diff --git a/src/Policy/StellaOps.Policy.Engine/Endpoints/Gateway/RegistryWebhookEndpoints.cs b/src/Policy/StellaOps.Policy.Engine/Endpoints/Gateway/RegistryWebhookEndpoints.cs new file mode 100644 index 000000000..843a55bea --- /dev/null +++ b/src/Policy/StellaOps.Policy.Engine/Endpoints/Gateway/RegistryWebhookEndpoints.cs @@ -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; + +/// +/// Endpoints for receiving registry webhook events and triggering gate evaluations. +/// +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(StatusCodes.Status202Accepted) + .Produces(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(StatusCodes.Status202Accepted) + .Produces(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(StatusCodes.Status202Accepted) + .Produces(StatusCodes.Status400BadRequest); + + return endpoints; + } + + /// + /// Handles Docker Registry v2 notification webhooks. + /// + private static async Task, ProblemHttpResult>> HandleDockerRegistryWebhook( + [FromBody] DockerRegistryNotification notification, + IGateEvaluationQueue evaluationQueue, + [FromServices] TimeProvider timeProvider, + ILogger 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(); + + 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)); + } + + /// + /// Handles Harbor registry webhook events. + /// + private static async Task, ProblemHttpResult>> HandleHarborWebhook( + [FromBody] HarborWebhookEvent notification, + IGateEvaluationQueue evaluationQueue, + [FromServices] TimeProvider timeProvider, + ILogger 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(); + + 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)); + } + + /// + /// Handles generic webhook events with image digest. + /// + private static async Task, ProblemHttpResult>> HandleGenericWebhook( + [FromBody] GenericRegistryWebhook notification, + IGateEvaluationQueue evaluationQueue, + [FromServices] TimeProvider timeProvider, + ILogger 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])); + } +} + +/// +/// Marker type for endpoint logging. +/// +internal sealed class RegistryWebhookEndpointMarker; + +// ============================================================================ +// Docker Registry Notification Models +// ============================================================================ + +/// +/// Docker Registry v2 notification envelope. +/// +public sealed record DockerRegistryNotification +{ + [JsonPropertyName("events")] + public List? Events { get; init; } +} + +/// +/// Docker Registry v2 event. +/// +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; } +} + +/// +/// Docker Registry event target (the image). +/// +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; } +} + +/// +/// Docker Registry request metadata. +/// +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 +// ============================================================================ + +/// +/// Harbor webhook event. +/// +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; } +} + +/// +/// Harbor event data. +/// +public sealed record HarborEventData +{ + [JsonPropertyName("resources")] + public List? Resources { get; init; } + + [JsonPropertyName("repository")] + public HarborRepository? Repository { get; init; } +} + +/// +/// Harbor resource (artifact). +/// +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; } +} + +/// +/// Harbor repository info. +/// +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 +// ============================================================================ + +/// +/// Generic registry webhook payload. +/// +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 +// ============================================================================ + +/// +/// Response indicating webhook was accepted. +/// +public sealed record WebhookAcceptedResponse( + int JobsQueued, + IReadOnlyList JobIds); + +// ============================================================================ +// Gate Evaluation Queue Interface +// ============================================================================ + +/// +/// Interface for queuing gate evaluation jobs. +/// +public interface IGateEvaluationQueue +{ + /// + /// Enqueues a gate evaluation request. + /// + /// The evaluation request. + /// Cancellation token. + /// The job ID for tracking. + Task EnqueueAsync(GateEvaluationRequest request, CancellationToken cancellationToken = default); +} + +/// +/// Request to evaluate a gate for an image. +/// +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; } +} diff --git a/src/Policy/StellaOps.Policy.Engine/Endpoints/Gateway/ScoreGateEndpoints.cs b/src/Policy/StellaOps.Policy.Engine/Endpoints/Gateway/ScoreGateEndpoints.cs new file mode 100644 index 000000000..ac4d344d7 --- /dev/null +++ b/src/Policy/StellaOps.Policy.Engine/Endpoints/Gateway/ScoreGateEndpoints.cs @@ -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; + +/// +/// Score-based gate API endpoints for CI/CD release gating. +/// Provides advisory-style score-based gate evaluation with verdict bundles. +/// +public static class ScoreGateEndpoints +{ + /// + /// Maps score-based gate endpoints to the application. + /// + 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( + HttpContext httpContext, + ScoreGateEvaluateRequest? request, + IEvidenceWeightedScoreCalculator ewsCalculator, + IVerdictBundleBuilder verdictBuilder, + IVerdictSigningService signingService, + IVerdictRekorAnchorService anchorService, + [FromServices] TimeProvider timeProvider, + [FromServices] ILogger 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( + HttpContext httpContext, + ScoreGateBatchEvaluateRequest request, + IEvidenceWeightedScoreCalculator ewsCalculator, + IVerdictBundleBuilder verdictBuilder, + IVerdictSigningService signingService, + IVerdictRekorAnchorService anchorService, + [FromServices] TimeProvider timeProvider, + [FromServices] ILogger 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> EvaluateBatchAsync( + IReadOnlyList findings, + ScoreGateBatchOptions options, + IEvidenceWeightedScoreCalculator ewsCalculator, + IVerdictBundleBuilder verdictBuilder, + IVerdictSigningService signingService, + IVerdictRekorAnchorService anchorService, + ILogger logger, + CancellationToken cancellationToken) + { + var results = new List(); + 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()); + + return results; + } + + private static async Task 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; + diff --git a/src/Policy/StellaOps.Policy.Engine/Endpoints/Gateway/ToolLatticeEndpoints.cs b/src/Policy/StellaOps.Policy.Engine/Endpoints/Gateway/ToolLatticeEndpoints.cs new file mode 100644 index 000000000..ed6f0fa7c --- /dev/null +++ b/src/Policy/StellaOps.Policy.Engine/Endpoints/Gateway/ToolLatticeEndpoints.cs @@ -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 ResolveScopes(ToolAccessRequest request, ClaimsPrincipal user) + { + if (request.Scopes is { Count: > 0 }) + { + return NormalizeList(request.Scopes); + } + + var scopes = new HashSet(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() + : scopes.OrderBy(static scope => scope, StringComparer.Ordinal).ToArray(); + } + + private static IReadOnlyList ResolveRoles(ToolAccessRequest request, ClaimsPrincipal user) + { + if (request.Roles is { Count: > 0 }) + { + return NormalizeList(request.Roles); + } + + var roles = new HashSet(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() + : roles.OrderBy(static role => role, StringComparer.Ordinal).ToArray(); + } + + private static IReadOnlyList NormalizeList(IReadOnlyCollection values) + { + var normalized = new HashSet(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() + : normalized.OrderBy(static value => value, StringComparer.Ordinal).ToArray(); + } + + private static void AddRoleValue(ISet 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()); + } + } + } +} diff --git a/src/Policy/StellaOps.Policy.Engine/Program.cs b/src/Policy/StellaOps.Policy.Engine/Program.cs index 36aae8cbb..e519b0b75 100644 --- a/src/Policy/StellaOps.Policy.Engine/Program.cs +++ b/src/Policy/StellaOps.Policy.Engine/Program.cs @@ -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( + builder.Configuration.GetSection(ApprovalWorkflowOptions.SectionName)); +builder.Services.Configure( + builder.Configuration.GetSection(ExceptionExpiryOptions.SectionName)); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddSingleton(); +builder.Services.AddHostedService(); + +// Delta services (from gateway) +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +// Gate services (from gateway) +builder.Services.Configure( + builder.Configuration.GetSection(StellaOps.Policy.Engine.Gates.DriftGateOptions.SectionName)); +builder.Services.AddScoped(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(sp => sp.GetRequiredService()); +builder.Services.AddHostedService(); + +// Unknowns gate services (from gateway) +builder.Services.Configure(_ => { }); +builder.Services.AddHttpClient(); + +// Gate bypass audit services (from gateway) +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddScoped(); + +// Score-based gate services (from gateway) +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + +// Exception approval services (from gateway) +builder.Services.Configure( + builder.Configuration.GetSection(StellaOps.Policy.Engine.Services.ExceptionApprovalRulesOptions.SectionName)); +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +// Tool lattice service (from gateway) +builder.Services.AddSingleton(); + +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(); diff --git a/src/Policy/StellaOps.Policy.Engine/Services/Gateway/ApprovalWorkflowService.cs b/src/Policy/StellaOps.Policy.Engine/Services/Gateway/ApprovalWorkflowService.cs new file mode 100644 index 000000000..9e28ce3b2 --- /dev/null +++ b/src/Policy/StellaOps.Policy.Engine/Services/Gateway/ApprovalWorkflowService.cs @@ -0,0 +1,276 @@ +// +// Copyright (c) StellaOps. All rights reserved. +// Licensed under the BUSL-1.1 license. +// + + +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using StellaOps.Policy.Exceptions.Models; +using System.Collections.Immutable; + +namespace StellaOps.Policy.Engine.Services.Gateway; + +/// +/// Approval policy configuration per environment. +/// +public sealed record ApprovalPolicy +{ + /// Environment name (dev, staging, prod). + public required string Environment { get; init; } + + /// Number of required approvers. + public required int RequiredApprovers { get; init; } + + /// Whether requester can approve their own exception. + public required bool RequesterCanApprove { get; init; } + + /// Deadline for approval before auto-reject. + public required TimeSpan ApprovalDeadline { get; init; } + + /// Roles allowed to approve. + public ImmutableArray AllowedApproverRoles { get; init; } = []; + + /// Whether to auto-approve in this environment. + public bool AutoApprove { get; init; } +} + +/// +/// Options for approval workflow configuration. +/// +public sealed class ApprovalWorkflowOptions +{ + /// Configuration section name. + public const string SectionName = "Policy:Exceptions:Approval"; + + /// Default policy for environments not explicitly configured. + public ApprovalPolicy DefaultPolicy { get; set; } = new() + { + Environment = "default", + RequiredApprovers = 1, + RequesterCanApprove = false, + ApprovalDeadline = TimeSpan.FromDays(7), + AutoApprove = false + }; + + /// Environment-specific policies. + public Dictionary 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"] + } + }; +} + +/// +/// Result of approval validation. +/// +public sealed record ApprovalValidationResult +{ + /// Whether approval is valid. + public bool IsValid { get; init; } + + /// Error message if invalid. + public string? Error { get; init; } + + /// Whether this approval completes the workflow. + public bool IsComplete { get; init; } + + /// Number of additional approvals needed. + public int ApprovalsRemaining { get; init; } + + /// Creates a valid result. + public static ApprovalValidationResult Valid(bool isComplete, int remaining = 0) => new() + { + IsValid = true, + IsComplete = isComplete, + ApprovalsRemaining = remaining + }; + + /// Creates an invalid result. + public static ApprovalValidationResult Invalid(string error) => new() + { + IsValid = false, + Error = error + }; +} + +/// +/// Service for managing exception approval workflow. +/// +public interface IApprovalWorkflowService +{ + /// + /// Gets the approval policy for an environment. + /// + ApprovalPolicy GetPolicyForEnvironment(string environment); + + /// + /// Validates whether an approval is allowed. + /// + /// The exception being approved. + /// The ID of the approver. + /// Roles of the approver. + /// Validation result. + ApprovalValidationResult ValidateApproval( + ExceptionObject exception, + string approverId, + IReadOnlyList? approverRoles = null); + + /// + /// Checks if an exception should be auto-approved. + /// + bool ShouldAutoApprove(ExceptionObject exception); + + /// + /// Checks if an exception approval has expired (deadline passed). + /// + bool IsApprovalExpired(ExceptionObject exception); + + /// + /// Gets the deadline for exception approval. + /// + DateTimeOffset GetApprovalDeadline(ExceptionObject exception); +} + +/// +/// Implementation of approval workflow service. +/// +public sealed class ApprovalWorkflowService : IApprovalWorkflowService +{ + private readonly ApprovalWorkflowOptions _options; + private readonly TimeProvider _timeProvider; + private readonly IExceptionNotificationService _notificationService; + private readonly ILogger _logger; + + /// + /// Creates a new approval workflow service. + /// + public ApprovalWorkflowService( + IOptions options, + TimeProvider timeProvider, + IExceptionNotificationService notificationService, + ILogger logger) + { + _options = options.Value; + _timeProvider = timeProvider; + _notificationService = notificationService; + _logger = logger; + } + + /// + public ApprovalPolicy GetPolicyForEnvironment(string environment) + { + if (_options.EnvironmentPolicies.TryGetValue(environment.ToLowerInvariant(), out var policy)) + { + return policy; + } + + return _options.DefaultPolicy; + } + + /// + public ApprovalValidationResult ValidateApproval( + ExceptionObject exception, + string approverId, + IReadOnlyList? 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); + } + + /// + public bool ShouldAutoApprove(ExceptionObject exception) + { + var environment = exception.Scope.Environments.Length > 0 + ? exception.Scope.Environments[0] + : "default"; + + var policy = GetPolicyForEnvironment(environment); + return policy.AutoApprove; + } + + /// + public bool IsApprovalExpired(ExceptionObject exception) + { + var deadline = GetApprovalDeadline(exception); + return _timeProvider.GetUtcNow() > deadline; + } + + /// + 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); + } +} diff --git a/src/Policy/StellaOps.Policy.Engine/Services/Gateway/DeltaSnapshotServiceAdapter.cs b/src/Policy/StellaOps.Policy.Engine/Services/Gateway/DeltaSnapshotServiceAdapter.cs new file mode 100644 index 000000000..e0dda4ba6 --- /dev/null +++ b/src/Policy/StellaOps.Policy.Engine/Services/Gateway/DeltaSnapshotServiceAdapter.cs @@ -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; + +/// +/// Adapter that bridges between the KnowledgeSnapshotManifest-based snapshot store +/// and the SnapshotData interface required by the DeltaComputer. +/// +public sealed class DeltaSnapshotServiceAdapter : StellaOps.Policy.Deltas.ISnapshotService +{ + private readonly ISnapshotStore _snapshotStore; + private readonly ILogger _logger; + + public DeltaSnapshotServiceAdapter( + ISnapshotStore snapshotStore, + ILogger logger) + { + _snapshotStore = snapshotStore ?? throw new ArgumentNullException(nameof(snapshotStore)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + /// Gets snapshot data by ID, converting from KnowledgeSnapshotManifest. + /// + public async Task 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 + }; + } +} diff --git a/src/Policy/StellaOps.Policy.Engine/Services/Gateway/ExceptionExpiryWorker.cs b/src/Policy/StellaOps.Policy.Engine/Services/Gateway/ExceptionExpiryWorker.cs new file mode 100644 index 000000000..f163ddd1d --- /dev/null +++ b/src/Policy/StellaOps.Policy.Engine/Services/Gateway/ExceptionExpiryWorker.cs @@ -0,0 +1,236 @@ +// +// Copyright (c) StellaOps. All rights reserved. +// Licensed under the BUSL-1.1 license. +// + + +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; + +/// +/// Options for exception expiry worker. +/// +public sealed class ExceptionExpiryOptions +{ + /// Configuration section name. + public const string SectionName = "Policy:Exceptions:Expiry"; + + /// Whether the worker is enabled. + public bool Enabled { get; set; } = true; + + /// Interval between expiry checks. + public TimeSpan Interval { get; set; } = TimeSpan.FromHours(1); + + /// Warning horizon for expiry notifications. + public TimeSpan WarningHorizon { get; set; } = TimeSpan.FromDays(7); + + /// Initial delay before first run. + public TimeSpan InitialDelay { get; set; } = TimeSpan.FromSeconds(30); +} + +/// +/// Background worker that marks expired exceptions and sends expiry warnings. +/// Runs hourly by default. +/// +public sealed class ExceptionExpiryWorker : BackgroundService +{ + private const string SystemActorId = "system:expiry-worker"; + + private readonly IServiceScopeFactory _scopeFactory; + private readonly IOptions _options; + private readonly TimeProvider _timeProvider; + private readonly ILogger _logger; + private readonly ActivitySource _activitySource = new("StellaOps.Policy.ExceptionExpiry"); + + /// + /// Creates a new exception expiry worker. + /// + public ExceptionExpiryWorker( + IServiceScopeFactory scopeFactory, + IOptions options, + TimeProvider timeProvider, + ILogger logger) + { + _scopeFactory = scopeFactory; + _options = options; + _timeProvider = timeProvider; + _logger = logger; + } + + /// + 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(); + var notificationService = services.GetRequiredService(); + + // 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 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 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; + } +} diff --git a/src/Policy/StellaOps.Policy.Engine/Services/Gateway/ExceptionQueryService.cs b/src/Policy/StellaOps.Policy.Engine/Services/Gateway/ExceptionQueryService.cs new file mode 100644 index 000000000..c9084e343 --- /dev/null +++ b/src/Policy/StellaOps.Policy.Engine/Services/Gateway/ExceptionQueryService.cs @@ -0,0 +1,228 @@ +// +// Copyright (c) StellaOps. All rights reserved. +// Licensed under the BUSL-1.1 license. +// + + +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; + +/// +/// Service interface for optimized exception queries. +/// +public interface IExceptionQueryService +{ + /// + /// Gets active exceptions that apply to a finding. + /// + /// The scope to match against. + /// Cancellation token. + /// List of applicable active exceptions. + Task> GetApplicableExceptionsAsync( + ExceptionScope scope, + CancellationToken cancellationToken = default); + + /// + /// Gets exceptions expiring within the given horizon. + /// + /// Time horizon for expiry check. + /// Cancellation token. + /// List of exceptions expiring soon. + Task> GetExpiringExceptionsAsync( + TimeSpan horizon, + CancellationToken cancellationToken = default); + + /// + /// Gets exceptions matching a specific scope. + /// + /// The scope to match. + /// Cancellation token. + /// List of matching exceptions. + Task> GetExceptionsByScopeAsync( + ExceptionScope scope, + CancellationToken cancellationToken = default); + + /// + /// Checks if a finding is covered by an active exception. + /// + /// Vulnerability ID to check. + /// Package URL to check. + /// Environment to check. + /// Cancellation token. + /// The covering exception if found, null otherwise. + Task FindCoveringExceptionAsync( + string? vulnerabilityId, + string? purl, + string? environment, + CancellationToken cancellationToken = default); + + /// + /// Invalidates any cached exception data. + /// + void InvalidateCache(); +} + +/// +/// Implementation of exception query service with caching. +/// +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 _logger; + + /// + /// Creates a new exception query service. + /// + public ExceptionQueryService( + IExceptionRepository repository, + IMemoryCache cache, + TimeProvider timeProvider, + ILogger logger) + { + _repository = repository; + _cache = cache; + _timeProvider = timeProvider; + _logger = logger; + } + + /// + public async Task> 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; + } + + /// + public async Task> GetExpiringExceptionsAsync( + TimeSpan horizon, + CancellationToken cancellationToken = default) + { + return await _repository.GetExpiringAsync(horizon, cancellationToken); + } + + /// + public async Task> GetExceptionsByScopeAsync( + ExceptionScope scope, + CancellationToken cancellationToken = default) + { + return await _repository.GetActiveByScopeAsync(scope, cancellationToken); + } + + /// + public async Task 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(); + } + + /// + 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); + } +} diff --git a/src/Policy/StellaOps.Policy.Engine/Services/Gateway/ExceptionService.cs b/src/Policy/StellaOps.Policy.Engine/Services/Gateway/ExceptionService.cs new file mode 100644 index 000000000..26920a6a3 --- /dev/null +++ b/src/Policy/StellaOps.Policy.Engine/Services/Gateway/ExceptionService.cs @@ -0,0 +1,606 @@ +// +// Copyright (c) StellaOps. All rights reserved. +// Licensed under the BUSL-1.1 license. +// + + +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; + +/// +/// Service for managing exception lifecycle with business logic validation. +/// +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 _logger; + + /// + /// Creates a new exception service. + /// + public ExceptionService( + IExceptionRepository repository, + IExceptionNotificationService notificationService, + TimeProvider timeProvider, + IGuidProvider guidProvider, + ILogger logger) + { + _repository = repository; + _notificationService = notificationService; + _timeProvider = timeProvider; + _guidProvider = guidProvider; + _logger = logger; + } + + /// + public async Task 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.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; + } + } + + /// + public async Task 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."); + } + } + + /// + public async Task 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."); + } + } + + /// + public async Task 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."); + } + } + + /// + public async Task 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."); + } + } + + /// + public async Task 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."); + } + } + + /// + public async Task GetByIdAsync( + string exceptionId, + CancellationToken cancellationToken = default) + { + return await _repository.GetByIdAsync(exceptionId, cancellationToken); + } + + /// + public async Task<(IReadOnlyList 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); + } + + /// + public async Task GetCountsAsync( + Guid? tenantId = null, + CancellationToken cancellationToken = default) + { + return await _repository.GetCountsAsync(tenantId, cancellationToken); + } + + /// + public async Task> GetExpiringAsync( + TimeSpan horizon, + CancellationToken cancellationToken = default) + { + return await _repository.GetExpiringAsync(horizon, cancellationToken); + } + + /// + public async Task 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 +} + +/// +/// Service for sending exception-related notifications. +/// +public interface IExceptionNotificationService +{ + /// Notifies that an exception was created. + Task NotifyExceptionCreatedAsync(ExceptionObject exception, CancellationToken cancellationToken = default); + + /// Notifies that an exception was approved. + Task NotifyExceptionApprovedAsync(ExceptionObject exception, string approverId, CancellationToken cancellationToken = default); + + /// Notifies that an exception was activated. + Task NotifyExceptionActivatedAsync(ExceptionObject exception, CancellationToken cancellationToken = default); + + /// Notifies that an exception was extended. + Task NotifyExceptionExtendedAsync(ExceptionObject exception, DateTimeOffset newExpiry, CancellationToken cancellationToken = default); + + /// Notifies that an exception was revoked. + Task NotifyExceptionRevokedAsync(ExceptionObject exception, string reason, CancellationToken cancellationToken = default); + + /// Notifies that an exception is expiring soon. + Task NotifyExceptionExpiringSoonAsync(ExceptionObject exception, TimeSpan timeUntilExpiry, CancellationToken cancellationToken = default); +} + +/// +/// No-op implementation of exception notification service. +/// +public sealed class NoOpExceptionNotificationService : IExceptionNotificationService +{ + /// + public Task NotifyExceptionCreatedAsync(ExceptionObject exception, CancellationToken cancellationToken = default) + => Task.CompletedTask; + + /// + public Task NotifyExceptionApprovedAsync(ExceptionObject exception, string approverId, CancellationToken cancellationToken = default) + => Task.CompletedTask; + + /// + public Task NotifyExceptionActivatedAsync(ExceptionObject exception, CancellationToken cancellationToken = default) + => Task.CompletedTask; + + /// + public Task NotifyExceptionExtendedAsync(ExceptionObject exception, DateTimeOffset newExpiry, CancellationToken cancellationToken = default) + => Task.CompletedTask; + + /// + public Task NotifyExceptionRevokedAsync(ExceptionObject exception, string reason, CancellationToken cancellationToken = default) + => Task.CompletedTask; + + /// + public Task NotifyExceptionExpiringSoonAsync(ExceptionObject exception, TimeSpan timeUntilExpiry, CancellationToken cancellationToken = default) + => Task.CompletedTask; +} diff --git a/src/Policy/StellaOps.Policy.Engine/Services/Gateway/IExceptionService.cs b/src/Policy/StellaOps.Policy.Engine/Services/Gateway/IExceptionService.cs new file mode 100644 index 000000000..cfbcd2ada --- /dev/null +++ b/src/Policy/StellaOps.Policy.Engine/Services/Gateway/IExceptionService.cs @@ -0,0 +1,234 @@ +// +// Copyright (c) StellaOps. All rights reserved. +// Licensed under the BUSL-1.1 license. +// + +using StellaOps.Policy.Exceptions.Models; +using StellaOps.Policy.Exceptions.Repositories; + +namespace StellaOps.Policy.Engine.Services.Gateway; + +/// +/// Service for managing exception lifecycle with business logic validation. +/// +public interface IExceptionService +{ + /// + /// Creates a new exception with validation. + /// + /// Creation request details. + /// ID of the user creating the exception. + /// Client info for audit trail. + /// Cancellation token. + /// Result containing created exception or validation errors. + Task CreateAsync( + CreateExceptionCommand request, + string actorId, + string? clientInfo = null, + CancellationToken cancellationToken = default); + + /// + /// Updates an existing exception. + /// + Task UpdateAsync( + string exceptionId, + UpdateExceptionCommand request, + string actorId, + string? clientInfo = null, + CancellationToken cancellationToken = default); + + /// + /// Approves a proposed exception. + /// + Task ApproveAsync( + string exceptionId, + string? comment, + string actorId, + string? clientInfo = null, + CancellationToken cancellationToken = default); + + /// + /// Activates an approved exception. + /// + Task ActivateAsync( + string exceptionId, + string actorId, + string? clientInfo = null, + CancellationToken cancellationToken = default); + + /// + /// Extends an active exception's expiry date. + /// + Task ExtendAsync( + string exceptionId, + DateTimeOffset newExpiresAt, + string reason, + string actorId, + string? clientInfo = null, + CancellationToken cancellationToken = default); + + /// + /// Revokes an exception. + /// + Task RevokeAsync( + string exceptionId, + string reason, + string actorId, + string? clientInfo = null, + CancellationToken cancellationToken = default); + + /// + /// Gets an exception by ID. + /// + Task GetByIdAsync( + string exceptionId, + CancellationToken cancellationToken = default); + + /// + /// Lists exceptions with filtering. + /// + Task<(IReadOnlyList Items, int TotalCount)> ListAsync( + ExceptionFilter filter, + CancellationToken cancellationToken = default); + + /// + /// Gets exception counts summary. + /// + Task GetCountsAsync( + Guid? tenantId = null, + CancellationToken cancellationToken = default); + + /// + /// Gets exceptions expiring within the given horizon. + /// + Task> GetExpiringAsync( + TimeSpan horizon, + CancellationToken cancellationToken = default); + + /// + /// Gets exception audit history. + /// + Task GetHistoryAsync( + string exceptionId, + CancellationToken cancellationToken = default); +} + +/// +/// Command for creating an exception. +/// +public sealed record CreateExceptionCommand +{ + /// Type of exception. + public required ExceptionType Type { get; init; } + + /// Exception scope. + public required ExceptionScope Scope { get; init; } + + /// Owner ID. + public required string OwnerId { get; init; } + + /// Reason code. + public required ExceptionReason ReasonCode { get; init; } + + /// Detailed rationale. + public required string Rationale { get; init; } + + /// Expiry date. + public required DateTimeOffset ExpiresAt { get; init; } + + /// Evidence references. + public IReadOnlyList? EvidenceRefs { get; init; } + + /// Compensating controls. + public IReadOnlyList? CompensatingControls { get; init; } + + /// Ticket reference. + public string? TicketRef { get; init; } + + /// Metadata. + public IReadOnlyDictionary? Metadata { get; init; } +} + +/// +/// Command for updating an exception. +/// +public sealed record UpdateExceptionCommand +{ + /// Updated rationale. + public string? Rationale { get; init; } + + /// Updated evidence references. + public IReadOnlyList? EvidenceRefs { get; init; } + + /// Updated compensating controls. + public IReadOnlyList? CompensatingControls { get; init; } + + /// Updated ticket reference. + public string? TicketRef { get; init; } + + /// Updated metadata. + public IReadOnlyDictionary? Metadata { get; init; } +} + +/// +/// Result of an exception operation. +/// +public sealed record ExceptionResult +{ + /// Whether the operation succeeded. + public bool IsSuccess { get; init; } + + /// The exception object if successful. + public ExceptionObject? Exception { get; init; } + + /// Error message if failed. + public string? Error { get; init; } + + /// Error code for programmatic handling. + public ExceptionErrorCode? ErrorCode { get; init; } + + /// Creates a success result. + public static ExceptionResult Success(ExceptionObject exception) => new() + { + IsSuccess = true, + Exception = exception + }; + + /// Creates a failure result. + public static ExceptionResult Failure(ExceptionErrorCode code, string error) => new() + { + IsSuccess = false, + ErrorCode = code, + Error = error + }; +} + +/// +/// Error codes for exception operations. +/// +public enum ExceptionErrorCode +{ + /// Exception not found. + NotFound, + + /// Validation failed. + ValidationFailed, + + /// Invalid state transition. + InvalidStateTransition, + + /// Self-approval not allowed. + SelfApprovalNotAllowed, + + /// Concurrency conflict. + ConcurrencyConflict, + + /// Scope not specific enough. + ScopeNotSpecific, + + /// Expiry invalid. + ExpiryInvalid, + + /// Rationale too short. + RationaleTooShort +} diff --git a/src/Policy/StellaOps.Policy.Engine/Services/Gateway/InMemoryGateEvaluationQueue.cs b/src/Policy/StellaOps.Policy.Engine/Services/Gateway/InMemoryGateEvaluationQueue.cs new file mode 100644 index 000000000..9c673c286 --- /dev/null +++ b/src/Policy/StellaOps.Policy.Engine/Services/Gateway/InMemoryGateEvaluationQueue.cs @@ -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; + +/// +/// In-memory implementation of the gate evaluation queue. +/// Uses System.Threading.Channels for async producer-consumer pattern. +/// +public sealed class InMemoryGateEvaluationQueue : IGateEvaluationQueue +{ + private readonly Channel _channel; + private readonly ILogger _logger; + private readonly TimeProvider _timeProvider; + + public InMemoryGateEvaluationQueue( + ILogger logger, + TimeProvider? timeProvider = null) + { + ArgumentNullException.ThrowIfNull(logger); + _logger = logger; + _timeProvider = timeProvider ?? TimeProvider.System; + + // Bounded channel to prevent unbounded memory growth + _channel = Channel.CreateBounded(new BoundedChannelOptions(1000) + { + FullMode = BoundedChannelFullMode.Wait, + SingleReader = false, + SingleWriter = false + }); + } + + /// + public async Task 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; + } + + /// + /// Gets the channel reader for consuming jobs. + /// + public ChannelReader 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}"; + } +} + +/// +/// A gate evaluation job in the queue. +/// +public sealed record GateEvaluationJob +{ + public required string JobId { get; init; } + public required GateEvaluationRequest Request { get; init; } + public required DateTimeOffset QueuedAt { get; init; } +} + +/// +/// Background service that processes gate evaluation jobs from the queue. +/// Orchestrates: image analysis -> drift delta computation -> gate evaluation. +/// +public sealed class GateEvaluationWorker : BackgroundService +{ + private readonly InMemoryGateEvaluationQueue _queue; + private readonly IServiceScopeFactory _scopeFactory; + private readonly ILogger _logger; + + public GateEvaluationWorker( + InMemoryGateEvaluationQueue queue, + IServiceScopeFactory scopeFactory, + ILogger 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(); + + // 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 + } +} diff --git a/src/Policy/StellaOps.Policy.Engine/StellaOps.Policy.Engine.csproj b/src/Policy/StellaOps.Policy.Engine/StellaOps.Policy.Engine.csproj index a92772518..c6fec3ff6 100644 --- a/src/Policy/StellaOps.Policy.Engine/StellaOps.Policy.Engine.csproj +++ b/src/Policy/StellaOps.Policy.Engine/StellaOps.Policy.Engine.csproj @@ -43,7 +43,10 @@ + + + diff --git a/src/Router/__Tests/StellaOps.Gateway.WebService.Tests/Integration/GatewayIntegrationTests.cs b/src/Router/__Tests/StellaOps.Gateway.WebService.Tests/Integration/GatewayIntegrationTests.cs index 4a55b97ac..9a85f6db6 100644 --- a/src/Router/__Tests/StellaOps.Gateway.WebService.Tests/Integration/GatewayIntegrationTests.cs +++ b/src/Router/__Tests/StellaOps.Gateway.WebService.Tests/Integration/GatewayIntegrationTests.cs @@ -79,7 +79,7 @@ public sealed class GatewayIntegrationTests : IClassFixture 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( diff --git a/src/Router/__Tests/StellaOps.Router.Gateway.Tests/Routing/InMemoryRoutingStateTests.cs b/src/Router/__Tests/StellaOps.Router.Gateway.Tests/Routing/InMemoryRoutingStateTests.cs index 5022cfdc4..a4a67d26a 100644 --- a/src/Router/__Tests/StellaOps.Router.Gateway.Tests/Routing/InMemoryRoutingStateTests.cs +++ b/src/Router/__Tests/StellaOps.Router.Gateway.Tests/Routing/InMemoryRoutingStateTests.cs @@ -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" },