wip: doctor/cli/docs/api to vector db consolidation; api hardening for descriptions, tenant, and scopes; migrations and conversions of all DALs to EF v10

This commit is contained in:
master
2026-02-23 15:30:50 +02:00
parent bd8fee6ed8
commit e746577380
1424 changed files with 81225 additions and 25251 deletions

View File

@@ -10,6 +10,8 @@ using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Policy.Persistence.Postgres.Repositories;
namespace StellaOps.Policy.Api.Endpoints;
@@ -34,6 +36,7 @@ public static class ReplayEndpoints
.WithName("ReplayDecision")
.WithSummary("Replay a historical policy decision")
.WithDescription("Re-evaluates a policy decision using frozen snapshots to verify determinism")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyAudit))
.Produces<ReplayResponse>(StatusCodes.Status200OK)
.Produces<ProblemDetails>(StatusCodes.Status400BadRequest)
.Produces<ProblemDetails>(StatusCodes.Status404NotFound);
@@ -42,29 +45,37 @@ public static class ReplayEndpoints
group.MapPost("/batch", BatchReplayAsync)
.WithName("BatchReplay")
.WithSummary("Replay multiple policy decisions")
.WithDescription("Replay a batch of historical policy decisions by verdict hash or Rekor UUID, returning pass/fail and determinism verification results for each item. Used by compliance automation tools to bulk-verify release audit trails.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyAudit))
.Produces<BatchReplayResponse>(StatusCodes.Status200OK);
// GET /api/v1/replay/{replayId} - Get replay result
group.MapGet("/{replayId}", GetReplayResultAsync)
.WithName("GetReplayResult")
.WithSummary("Get the result of a replay operation");
.WithSummary("Get the result of a replay operation")
.WithDescription("Retrieve the stored result of a previously executed replay operation by its replay ID, including verdict match status, digest comparison, and replay duration metadata.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyAudit));
// POST /api/v1/replay/verify-determinism - Verify replay determinism
group.MapPost("/verify-determinism", VerifyDeterminismAsync)
.WithName("VerifyDeterminism")
.WithSummary("Verify that a decision can be deterministically replayed");
.WithSummary("Verify that a decision can be deterministically replayed")
.WithDescription("Execute multiple replay iterations for a verdict hash and report whether all iterations produced the same digest, confirming deterministic reproducibility. Returns the iteration count, number of unique results, and diagnostic details for any non-determinism detected.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyAudit));
// GET /api/v1/replay/audit - Query replay audit trail
group.MapGet("/audit", QueryReplayAuditAsync)
.WithName("QueryReplayAudit")
.WithSummary("Query replay audit records")
.WithDescription("Returns paginated list of replay audit records for compliance and debugging");
.WithDescription("Returns paginated list of replay audit records for compliance and debugging")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyAudit));
// GET /api/v1/replay/audit/metrics - Get replay metrics
group.MapGet("/audit/metrics", GetReplayMetricsAsync)
.WithName("GetReplayMetrics")
.WithSummary("Get aggregated replay metrics")
.WithDescription("Returns replay_attempts_total and replay_match_rate metrics");
.WithDescription("Returns replay_attempts_total and replay_match_rate metrics")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyAudit));
return endpoints;
}

View File

@@ -1,4 +1,6 @@
using Microsoft.AspNetCore.Mvc;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Policy.Engine.AdvisoryAI;
namespace StellaOps.Policy.Engine.Endpoints;
@@ -8,10 +10,14 @@ public static class AdvisoryAiKnobsEndpoint
public static IEndpointRouteBuilder MapAdvisoryAiKnobs(this IEndpointRouteBuilder routes)
{
routes.MapGet("/policy/advisory-ai/knobs", GetAsync)
.WithName("PolicyEngine.AdvisoryAI.Knobs.Get");
.WithName("PolicyEngine.AdvisoryAI.Knobs.Get")
.WithDescription("Retrieve the current advisory AI tuning knobs that control hallucination suppression thresholds, confidence floors, and source-trust decay parameters used during AI-assisted advisory enrichment.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead));
routes.MapPut("/policy/advisory-ai/knobs", PutAsync)
.WithName("PolicyEngine.AdvisoryAI.Knobs.Put");
.WithName("PolicyEngine.AdvisoryAI.Knobs.Put")
.WithDescription("Update advisory AI tuning knobs to adjust how the AI enrichment layer weights and filters advisory signals. Changes take effect immediately for subsequent advisory processing cycles.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyEdit));
return routes;
}

View File

@@ -1,4 +1,6 @@
using Microsoft.AspNetCore.Mvc;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Policy.Engine.AirGap;
namespace StellaOps.Policy.Engine.Endpoints;
@@ -14,13 +16,13 @@ public static class AirGapNotificationEndpoints
group.MapPost("/test", SendTestNotificationAsync)
.WithName("AirGap.TestNotification")
.WithDescription("Send a test notification")
.RequireAuthorization(policy => policy.RequireClaim("scope", "airgap:seal"));
.WithDescription("Dispatch a test notification through all configured air-gap notification channels for the tenant, verifying channel connectivity and delivery. The notification type, severity, title, and message can be customized; defaults to a staleness-warning info notification.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.AirgapSeal));
group.MapGet("/channels", GetChannelsAsync)
.WithName("AirGap.GetNotificationChannels")
.WithDescription("Get configured notification channels")
.RequireAuthorization(policy => policy.RequireClaim("scope", "airgap:status:read"));
.WithDescription("List all notification channels currently registered with the air-gap notification service, enabling operators to verify that alert delivery paths (log, webhook, syslog, etc.) are configured before triggering seal or staleness events.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.AirgapStatusRead));
return routes;
}

View File

@@ -1,6 +1,7 @@
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Policy.Engine.Attestation;
namespace StellaOps.Policy.Engine.Endpoints;
@@ -18,40 +19,46 @@ public static class AttestationReportEndpoints
group.MapGet("/{artifactDigest}", GetReportAsync)
.WithName("Attestor.GetReport")
.WithSummary("Get attestation report for an artifact")
.RequireAuthorization(policy => policy.RequireClaim("scope", StellaOpsScopes.PolicyRead))
.WithDescription("Retrieve the stored attestation report for a specific artifact identified by its digest, including verification status against applicable policies, predicate types present, and signer identity records.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead))
.Produces<ArtifactAttestationReport>(StatusCodes.Status200OK)
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
group.MapPost("/query", ListReportsAsync)
.WithName("Attestor.ListReports")
.WithSummary("Query attestation reports")
.RequireAuthorization(policy => policy.RequireClaim("scope", StellaOpsScopes.PolicyRead))
.WithDescription("Query the attestation report store with flexible filters including artifact digests, URI patterns, policy IDs, predicate types, status, and time range. Supports paginated retrieval of report summaries or full detail records for audit and compliance review.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead))
.Produces<AttestationReportListResponse>(StatusCodes.Status200OK);
group.MapPost("/verify", VerifyArtifactAsync)
.WithName("Attestor.VerifyArtifact")
.WithSummary("Generate attestation report for an artifact")
.RequireAuthorization(policy => policy.RequireClaim("scope", StellaOpsScopes.PolicyRead))
.WithDescription("Generate a fresh attestation verification report for an artifact by evaluating its attestations against all applicable verification policies. Returns pass/fail status per policy, signer details, and any validation errors encountered during verification.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead))
.Produces<ArtifactAttestationReport>(StatusCodes.Status200OK)
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest);
group.MapGet("/statistics", GetStatisticsAsync)
.WithName("Attestor.GetStatistics")
.WithSummary("Get aggregated attestation statistics")
.RequireAuthorization(policy => policy.RequireClaim("scope", StellaOpsScopes.PolicyRead))
.WithDescription("Retrieve aggregated attestation statistics across the report store, including pass/fail counts per policy and predicate type, optionally filtered by time range. Used by the console dashboard to render attestation health metrics.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead))
.Produces<AttestationStatistics>(StatusCodes.Status200OK);
group.MapPost("/store", StoreReportAsync)
.WithName("Attestor.StoreReport")
.WithSummary("Store an attestation report")
.RequireAuthorization(policy => policy.RequireClaim("scope", StellaOpsScopes.PolicyWrite))
.WithDescription("Persist a pre-computed attestation report into the report store with an optional time-to-live, making it available for subsequent retrieval and statistics aggregation. Used by the attestor pipeline after completing artifact verification.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyWrite))
.Produces<StoredAttestationReport>(StatusCodes.Status201Created)
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest);
group.MapDelete("/expired", PurgeExpiredAsync)
.WithName("Attestor.PurgeExpired")
.WithSummary("Purge expired attestation reports")
.RequireAuthorization(policy => policy.RequireClaim("scope", StellaOpsScopes.PolicyWrite))
.WithDescription("Remove all attestation reports that have exceeded their configured time-to-live from the report store. Returns the count of purged records; intended for scheduled maintenance to bound storage growth over time.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyWrite))
.Produces<PurgeExpiredResponse>(StatusCodes.Status200OK);
return routes;

View File

@@ -1,4 +1,6 @@
using Microsoft.AspNetCore.Mvc;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Policy.Engine.BatchContext;
namespace StellaOps.Policy.Engine.Endpoints;
@@ -8,7 +10,9 @@ public static class BatchContextEndpoint
public static IEndpointRouteBuilder MapBatchContext(this IEndpointRouteBuilder routes)
{
routes.MapPost("/policy/batch/context", HandleAsync)
.WithName("PolicyEngine.BatchContext.Create");
.WithName("PolicyEngine.BatchContext.Create")
.WithDescription("Create and cache a shared evaluation context for a batch of policy decisions. Reduces per-decision overhead by pre-loading tenant configuration, active policy bundles, and VEX index into a reusable context object referenced by subsequent batch calls.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead));
return routes;
}

View File

@@ -2,6 +2,7 @@
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Policy.Engine.BatchEvaluation;
using StellaOps.Policy.Engine.Services;
using System.Diagnostics;
@@ -14,12 +15,13 @@ internal static class BatchEvaluationEndpoint
public static IEndpointRouteBuilder MapBatchEvaluation(this IEndpointRouteBuilder routes)
{
var group = routes.MapGroup("/policy/eval")
.RequireAuthorization()
.WithTags("Policy Evaluation");
group.MapPost("/batch", EvaluateBatchAsync)
.WithName("PolicyEngine.BatchEvaluate")
.WithSummary("Batch-evaluate policy packs against advisory/VEX/SBOM tuples with deterministic ordering and cache-aware responses.")
.WithDescription("Evaluate a page of advisory/VEX/SBOM tuples against active policy packs, returning per-item verdicts with severity, rule name, confidence, and cache hit indicators. Supports cursor-based pagination and optional time budgets to bound evaluation latency in high-throughput CI/CD pipelines.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead))
.Produces<BatchEvaluationResponseDto>(StatusCodes.Status200OK)
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest);

View File

@@ -8,6 +8,8 @@
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Policy.Unknowns.Configuration;
using StellaOps.Policy.Unknowns.Models;
using StellaOps.Policy.Unknowns.Services;
@@ -22,33 +24,42 @@ internal static class BudgetEndpoints
public static IEndpointRouteBuilder MapBudgets(this IEndpointRouteBuilder endpoints)
{
var group = endpoints.MapGroup("/api/v1/policy/budgets")
.RequireAuthorization()
.WithTags("Unknown Budgets");
group.MapGet(string.Empty, ListBudgets)
.WithName("ListBudgets")
.WithSummary("List all configured unknown budgets.")
.WithDescription("List all unknown budget configurations registered on this host, including per-environment limits, per-reason-code sub-limits, and global enforcement state, enabling operators to audit budget policy without querying individual environments.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead))
.Produces<BudgetsListResponse>(StatusCodes.Status200OK);
group.MapGet("/{environment}", GetBudget)
.WithName("GetBudget")
.WithSummary("Get budget for a specific environment.")
.WithDescription("Retrieve the unknown budget configuration for a specific deployment environment, including its total limit, per-reason-code sub-limits, and the enforcement action applied when the budget is exceeded.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead))
.Produces<BudgetResponse>(StatusCodes.Status200OK)
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
group.MapGet("/{environment}/status", GetBudgetStatus)
.WithName("GetBudgetStatus")
.WithSummary("Get current budget status for an environment.")
.WithDescription("Retrieve the live unknown budget status for a deployment environment, including the current unknown count, remaining capacity, percentage used, and a per-reason-code breakdown indicating which categories are closest to their sub-limits.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead))
.Produces<BudgetStatusResponse>(StatusCodes.Status200OK);
group.MapPost("/{environment}/check", CheckBudget)
.WithName("CheckBudget")
.WithSummary("Check unknowns against a budget.")
.WithDescription("Evaluate a set of unknown IDs (or all tenant unknowns) against the budget configuration for a specific environment, returning whether the set is within budget, the recommended enforcement action, and per-reason-code violation details.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyOperate))
.Produces<BudgetCheckResponse>(StatusCodes.Status200OK);
group.MapGet("/defaults", GetDefaultBudgets)
.WithName("GetDefaultBudgets")
.WithSummary("Get the default budget configurations.")
.WithDescription("Return the platform-defined default budget configurations for production, staging, development, and generic environments. These values serve as the baseline applied to environments without explicit budget overrides.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead))
.Produces<DefaultBudgetsResponse>(StatusCodes.Status200OK);
return endpoints;

View File

@@ -1,6 +1,7 @@
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Policy.Engine.Services;
using StellaOps.Policy.Persistence.Postgres.Models;
using StellaOps.Policy.Persistence.Postgres.Repositories;
@@ -16,39 +17,50 @@ internal static class ConflictEndpoints
public static IEndpointRouteBuilder MapConflictsApi(this IEndpointRouteBuilder endpoints)
{
var group = endpoints.MapGroup("/api/policy/conflicts")
.RequireAuthorization()
.WithTags("Policy Conflicts");
group.MapGet(string.Empty, ListOpenConflicts)
.WithName("ListOpenPolicyConflicts")
.WithSummary("List open policy conflicts sorted by severity.")
.WithDescription("List all open policy rule conflicts for the authenticated tenant, sorted by severity. Conflicts arise when two or more policy rules produce contradictory verdicts for the same advisory or component scope. Supports pagination via limit and offset parameters.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead))
.Produces<ConflictListResponse>(StatusCodes.Status200OK);
group.MapGet("/{conflictId:guid}", GetConflict)
.WithName("GetPolicyConflict")
.WithSummary("Get a specific policy conflict by ID.")
.WithDescription("Retrieve the full record of a specific policy conflict by its UUID, including the conflicting rule identifiers, affected scope, severity, status, and description explaining the nature of the contradiction.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead))
.Produces<ConflictResponse>(StatusCodes.Status200OK)
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
group.MapGet("/by-type/{conflictType}", GetConflictsByType)
.WithName("GetPolicyConflictsByType")
.WithSummary("Get conflicts filtered by type.")
.WithDescription("Retrieve policy conflicts filtered to a specific conflict type (e.g., severity-mismatch, applicability-overlap), with optional status filtering and pagination. Used by the conflict management console to focus on a specific category of rule contradictions.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead))
.Produces<ConflictListResponse>(StatusCodes.Status200OK);
group.MapGet("/stats/by-severity", GetConflictStatsBySeverity)
.WithName("GetPolicyConflictStatsBySeverity")
.WithSummary("Get open conflict counts grouped by severity.")
.WithDescription("Retrieve a count of currently open policy conflicts grouped by severity level for the authenticated tenant. Used by compliance dashboards to render conflict health indicators and track remediation progress.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead))
.Produces<ConflictStatsResponse>(StatusCodes.Status200OK);
group.MapPost(string.Empty, CreateConflict)
.WithName("CreatePolicyConflict")
.WithSummary("Report a new policy conflict.")
.WithDescription("Report a newly detected policy rule conflict, recording the conflicting rule identifiers, conflict type, severity, affected scope, and a human-readable description. The conflict is created in open status and queued for resolution or dismissal.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyEdit))
.Produces<ConflictResponse>(StatusCodes.Status201Created)
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest);
group.MapPost("/{conflictId:guid}:resolve", ResolveConflict)
.WithName("ResolvePolicyConflict")
.WithSummary("Resolve an open conflict with a resolution description.")
.WithDescription("Mark an open policy conflict as resolved by providing a resolution description explaining how the contradiction was addressed, recording the resolving actor. Only conflicts in open status can be resolved.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyEdit))
.Produces<ConflictActionResponse>(StatusCodes.Status200OK)
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest)
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
@@ -56,6 +68,8 @@ internal static class ConflictEndpoints
group.MapPost("/{conflictId:guid}:dismiss", DismissConflict)
.WithName("DismissPolicyConflict")
.WithSummary("Dismiss an open conflict without resolution.")
.WithDescription("Dismiss an open policy conflict without providing a resolution, recording the dismissing actor. Dismissed conflicts are closed without a resolution record and excluded from open conflict counts.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyEdit))
.Produces<ConflictActionResponse>(StatusCodes.Status200OK)
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);

View File

@@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Mvc;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Policy.Engine.ConsoleSurface;
using StellaOps.Policy.Engine.Options;
@@ -19,21 +20,24 @@ internal static class ConsoleAttestationReportEndpoints
group.MapPost("/reports", QueryReportsAsync)
.WithName("PolicyEngine.ConsoleAttestationReports")
.WithSummary("Query attestation reports for Console")
.RequireAuthorization(policy => policy.RequireClaim("scope", StellaOpsScopes.PolicyRead))
.WithDescription("Query attestation reports for display in the release console, supporting pagination, time range filtering, and grouping by artifact, policy, or predicate type. Returns console-formatted report cards with status badges and signer summaries.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead))
.Produces<ConsoleAttestationReportResponse>(StatusCodes.Status200OK)
.ProducesValidationProblem();
group.MapPost("/dashboard", GetDashboardAsync)
.WithName("PolicyEngine.ConsoleAttestationDashboard")
.WithSummary("Get attestation dashboard for Console")
.RequireAuthorization(policy => policy.RequireClaim("scope", StellaOpsScopes.PolicyRead))
.WithDescription("Retrieve a pre-aggregated attestation health dashboard for the release console, including verification pass rates, recent failures, and trend indicators over the requested time range. Used to render the attestation overview panel without requiring separate query calls.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead))
.Produces<ConsoleAttestationDashboardResponse>(StatusCodes.Status200OK)
.ProducesValidationProblem();
group.MapGet("/report/{artifactDigest}", GetReportAsync)
.WithName("PolicyEngine.ConsoleGetAttestationReport")
.WithSummary("Get attestation report for a specific artifact")
.RequireAuthorization(policy => policy.RequireClaim("scope", StellaOpsScopes.PolicyRead))
.WithDescription("Retrieve the console-formatted attestation report for a single artifact by its digest, including per-policy verification results, predicate type coverage, and signer identity details formatted for display in the artifact detail panel.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead))
.Produces<ConsoleArtifactReport>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound);

View File

@@ -1,4 +1,6 @@
using Microsoft.AspNetCore.Mvc;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Policy.Engine.ConsoleExport;
namespace StellaOps.Policy.Engine.Endpoints;
@@ -15,41 +17,50 @@ public static class ConsoleExportEndpoints
// Job management
group.MapPost("/jobs", CreateJobAsync)
.WithName("Export.CreateJob")
.WithDescription("Create a new export job");
.WithDescription("Create a new console export job defining the data scope, destination, format, and optional schedule. The job is created in a pending state and can be triggered immediately or executed on the configured schedule.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyEdit));
group.MapGet("/jobs", ListJobsAsync)
.WithName("Export.ListJobs")
.WithDescription("List export jobs");
.WithDescription("List console export jobs for the tenant, returning job configuration summaries, current status, and last execution timestamps to support export management dashboards and automation pipelines.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead));
group.MapGet("/jobs/{jobId}", GetJobAsync)
.WithName("Export.GetJob")
.WithDescription("Get an export job by ID");
.WithDescription("Retrieve the full configuration and current status of a specific console export job by its identifier, including destination settings, schedule, and format parameters.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead));
group.MapPut("/jobs/{jobId}", UpdateJobAsync)
.WithName("Export.UpdateJob")
.WithDescription("Update an export job");
.WithDescription("Update the configuration of an existing console export job, allowing changes to destination, schedule, or format settings while preserving the job's execution history.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyEdit));
group.MapDelete("/jobs/{jobId}", DeleteJobAsync)
.WithName("Export.DeleteJob")
.WithDescription("Delete an export job");
.WithDescription("Permanently delete a console export job and release any associated resources. Scheduled future executions will be cancelled; completed execution records and bundles are retained until their configured retention period expires.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyEdit));
// Job execution
group.MapPost("/jobs/{jobId}/run", TriggerJobAsync)
.WithName("Export.TriggerJob")
.WithDescription("Trigger a job execution");
.WithDescription("Trigger an immediate execution of a console export job outside its normal schedule, returning an execution ID that can be polled for status. Useful for ad-hoc exports and manual retries after a previous execution failure.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyOperate));
group.MapGet("/jobs/{jobId}/executions/{executionId}", GetExecutionAsync)
.WithName("Export.GetExecution")
.WithDescription("Get execution status");
.WithDescription("Retrieve the status and metadata of a specific export job execution, including start and end timestamps, byte count, destination path, and any error messages encountered during the export run.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead));
// Bundle retrieval
group.MapGet("/bundles/{bundleId}", GetBundleAsync)
.WithName("Export.GetBundle")
.WithDescription("Get bundle manifest");
.WithDescription("Retrieve the manifest of a completed export bundle including its format, content type, size, and creation timestamp, enabling clients to inspect bundle metadata before initiating a download.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead));
group.MapGet("/bundles/{bundleId}/download", DownloadBundleAsync)
.WithName("Export.DownloadBundle")
.WithDescription("Download bundle content");
.WithDescription("Download the content of a completed export bundle as a file attachment, with the appropriate content type (JSON or NDJSON) and a timestamped filename. Used by operators to retrieve export data for offline processing or air-gap transfer.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead));
return routes;
}

View File

@@ -1,4 +1,6 @@
using Microsoft.AspNetCore.Mvc;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Policy.Engine.ConsoleSurface;
using StellaOps.Policy.Engine.Options;
@@ -11,8 +13,10 @@ internal static class ConsoleSimulationEndpoint
routes.MapPost("/policy/console/simulations/diff", HandleAsync)
.RequireRateLimiting(PolicyEngineRateLimitOptions.PolicyName)
.WithName("PolicyEngine.ConsoleSimulationDiff")
.WithDescription("Compute a structured diff between two policy versions as they would apply to a given evaluation snapshot, highlighting verdict changes, rule transitions, and newly introduced or removed advisory signals. Used by the release console to preview the impact of a policy promotion before it is committed.")
.Produces<ConsoleSimulationDiffResponse>(StatusCodes.Status200OK)
.ProducesValidationProblem();
.ProducesValidationProblem()
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicySimulate));
return routes;
}

View File

@@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using StellaOps.Attestor.Envelope;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Policy.Engine.Services;
using StellaOps.Policy.Scoring;
using StellaOps.Policy.Scoring.Engine;
@@ -20,12 +21,13 @@ internal static class CvssReceiptEndpoints
public static IEndpointRouteBuilder MapCvssReceipts(this IEndpointRouteBuilder endpoints)
{
var group = endpoints.MapGroup("/api/cvss")
.RequireAuthorization()
.WithTags("CVSS Receipts");
group.MapPost("/receipts", CreateReceipt)
.WithName("CreateCvssReceipt")
.WithSummary("Create a CVSS v4.0 receipt with deterministic hashing and optional DSSE attestation.")
.WithDescription("Create a CVSS v4.0 score receipt for a vulnerability, computing a deterministic hash over the base, threat, environmental, and supplemental metrics using the specified scoring policy. Optionally wraps the receipt in a DSSE attestation envelope if a signing key is provided.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRun))
.Produces<CvssScoreReceipt>(StatusCodes.Status201Created)
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest)
.Produces<ProblemHttpResult>(StatusCodes.Status401Unauthorized);
@@ -33,12 +35,16 @@ internal static class CvssReceiptEndpoints
group.MapGet("/receipts/{receiptId}", GetReceipt)
.WithName("GetCvssReceipt")
.WithSummary("Retrieve a CVSS v4.0 receipt by ID.")
.WithDescription("Retrieve a stored CVSS v4.0 score receipt by its identifier, returning the full metric values, computed score vector, deterministic hash, and DSSE attestation envelope if present.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.FindingsRead))
.Produces<CvssScoreReceipt>(StatusCodes.Status200OK)
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
group.MapPut("/receipts/{receiptId}/amend", AmendReceipt)
.WithName("AmendCvssReceipt")
.WithSummary("Append an amendment entry to a CVSS receipt history and optionally re-sign.")
.WithDescription("Append an immutable amendment record to a CVSS receipt's history, recording the changed field, previous and new values, reason, and optional reference URI. Optionally re-signs the updated receipt with a new DSSE envelope to chain attestation integrity.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRun))
.Produces<CvssScoreReceipt>(StatusCodes.Status200OK)
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest)
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
@@ -46,12 +52,16 @@ internal static class CvssReceiptEndpoints
group.MapGet("/receipts/{receiptId}/history", GetReceiptHistory)
.WithName("GetCvssReceiptHistory")
.WithSummary("Return the ordered amendment history for a CVSS receipt.")
.WithDescription("Retrieve the chronologically ordered amendment history for a CVSS score receipt, listing each recorded field change with actor, timestamp, reason, and reference URI for audit and compliance review.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.FindingsRead))
.Produces<IReadOnlyList<ReceiptHistoryEntry>>(StatusCodes.Status200OK)
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
group.MapGet("/policies", ListPolicies)
.WithName("ListCvssPolicies")
.WithSummary("List available CVSS policies configured on this host.")
.WithDescription("List all CVSS scoring policies registered on this host, including their identifiers, algorithm configurations, and deterministic hash values, enabling clients to select the appropriate policy when creating CVSS receipts.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead))
.Produces<IReadOnlyList<CvssPolicy>>(StatusCodes.Status200OK);
return endpoints;

View File

@@ -9,6 +9,8 @@ using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.Logging;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Policy.Determinization.Models;
using StellaOps.Policy.Determinization.Scoring;
@@ -35,7 +37,7 @@ public static class DeltaIfPresentEndpoints
.WithDescription("Shows what the trust score would be if a specific missing signal had a particular value")
.Produces<SingleSignalDeltaResponse>(StatusCodes.Status200OK)
.Produces<ProblemDetails>(StatusCodes.Status400BadRequest)
.RequireAuthorization("PolicyViewer");
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead));
// Calculate full gap analysis
group.MapPost("/analysis", CalculateFullAnalysisAsync)
@@ -44,7 +46,7 @@ public static class DeltaIfPresentEndpoints
.WithDescription("Analyzes all signal gaps with best/worst/prior case scenarios and prioritization by impact")
.Produces<FullAnalysisResponse>(StatusCodes.Status200OK)
.Produces<ProblemDetails>(StatusCodes.Status400BadRequest)
.RequireAuthorization("PolicyViewer");
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead));
// Calculate score bounds
group.MapPost("/bounds", CalculateScoreBoundsAsync)
@@ -53,7 +55,7 @@ public static class DeltaIfPresentEndpoints
.WithDescription("Computes the range of possible trust scores given current gaps")
.Produces<ScoreBoundsResponse>(StatusCodes.Status200OK)
.Produces<ProblemDetails>(StatusCodes.Status400BadRequest)
.RequireAuthorization("PolicyViewer");
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead));
return endpoints;
}

View File

@@ -8,6 +8,8 @@ using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.Logging;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Policy.Determinization;
using System.Security.Claims;
@@ -27,38 +29,43 @@ public static class DeterminizationConfigEndpoints
var group = endpoints.MapGroup("/api/v1/policy/config/determinization")
.WithTags("Determinization Configuration");
// Read endpoints (policy viewer access)
// Read endpoints (policy:read scope)
group.MapGet("", GetEffectiveConfig)
.WithName("GetEffectiveDeterminizationConfig")
.WithSummary("Get effective determinization configuration for the current tenant")
.WithDescription("Retrieve the effective determinization configuration for the authenticated tenant, including EPSS delta thresholds, conflict policy parameters, and per-environment analysis thresholds, with an indicator of whether tenant-specific overrides are in effect or the platform defaults apply.")
.Produces<EffectiveConfigResponse>(StatusCodes.Status200OK)
.RequireAuthorization("PolicyViewer");
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead));
group.MapGet("/defaults", GetDefaultConfig)
.WithName("GetDefaultDeterminizationConfig")
.WithSummary("Get default determinization configuration")
.WithDescription("Return the platform-wide default determinization options used when no tenant-specific configuration has been applied. Useful as a reference baseline before submitting a custom configuration update.")
.Produces<DeterminizationOptions>(StatusCodes.Status200OK)
.RequireAuthorization("PolicyViewer");
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead));
group.MapGet("/audit", GetAuditHistory)
.WithName("GetDeterminizationConfigAuditHistory")
.WithSummary("Get audit history for determinization configuration changes")
.WithDescription("Retrieve the ordered audit history of determinization configuration changes for the authenticated tenant, including the actor, reason, source, and change summary for each modification, supporting compliance reviews of policy analysis parameter evolution.")
.Produces<AuditHistoryResponse>(StatusCodes.Status200OK)
.RequireAuthorization("PolicyViewer");
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead));
// Write endpoints (policy admin access)
// Write endpoints (policy:edit scope)
group.MapPut("", UpdateConfig)
.WithName("UpdateDeterminizationConfig")
.WithSummary("Update determinization configuration for the current tenant")
.WithDescription("Persist a new determinization configuration for the authenticated tenant, validating all threshold and conflict policy parameters before saving. The update is recorded in the audit log with the requesting actor and provided reason string.")
.Produces<EffectiveConfigResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest)
.RequireAuthorization("PolicyAdmin");
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyEdit));
group.MapPost("/validate", ValidateConfig)
.WithName("ValidateDeterminizationConfig")
.WithSummary("Validate determinization configuration without saving")
.WithDescription("Validate a proposed determinization configuration against all threshold and constraint rules without persisting it, returning a structured list of errors and warnings. Allows operators to pre-flight configuration changes before committing them.")
.Produces<ValidationResponse>(StatusCodes.Status200OK)
.RequireAuthorization("PolicyViewer");
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead));
return endpoints;
}

View File

@@ -2,7 +2,9 @@
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Policy.Engine.Services;
using StellaOps.Policy.Engine.Tenancy;
using StellaOps.Policy.RiskProfile.Scope;
using System.Security.Claims;
@@ -10,74 +12,93 @@ namespace StellaOps.Policy.Engine.Endpoints;
/// <summary>
/// Endpoints for managing effective policies per CONTRACT-AUTHORITY-EFFECTIVE-WRITE-008.
/// POL-TEN-03: Tenant enforcement via ITenantContextAccessor.
/// </summary>
internal static class EffectivePolicyEndpoints
{
public static IEndpointRouteBuilder MapEffectivePolicies(this IEndpointRouteBuilder endpoints)
{
var group = endpoints.MapGroup("/api/v1/authority/effective-policies")
.RequireAuthorization()
.WithTags("Effective Policies");
.WithTags("Effective Policies")
.RequireTenantContext();
group.MapPost("/", CreateEffectivePolicy)
.WithName("CreateEffectivePolicy")
.WithSummary("Create a new effective policy with subject pattern and priority.")
.WithDescription("Create a new effective policy binding that associates a policy pack revision with a subject pattern and priority rank, controlling which policy governs releases for matching subjects. Changes are audited per CONTRACT-AUTHORITY-EFFECTIVE-WRITE-008.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.EffectiveWrite))
.Produces<EffectivePolicyResponse>(StatusCodes.Status201Created)
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest);
group.MapGet("/{effectivePolicyId}", GetEffectivePolicy)
.WithName("GetEffectivePolicy")
.WithSummary("Get an effective policy by ID.")
.WithDescription("Retrieve a specific effective policy binding by its identifier, including the subject pattern, priority, pack reference, and any scope attachments that further restrict the policy's applicability.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead))
.Produces<EffectivePolicyResponse>(StatusCodes.Status200OK)
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
group.MapPut("/{effectivePolicyId}", UpdateEffectivePolicy)
.WithName("UpdateEffectivePolicy")
.WithSummary("Update an effective policy's priority, expiration, or scopes.")
.WithDescription("Update the mutable fields of an effective policy binding, including priority, expiration date, and scope constraints, without changing the subject pattern or pack reference. All changes are audited per CONTRACT-AUTHORITY-EFFECTIVE-WRITE-008.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.EffectiveWrite))
.Produces<EffectivePolicyResponse>(StatusCodes.Status200OK)
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
group.MapDelete("/{effectivePolicyId}", DeleteEffectivePolicy)
.WithName("DeleteEffectivePolicy")
.WithSummary("Delete an effective policy.")
.WithDescription("Remove an effective policy binding by identifier, stopping the associated policy from governing matching subjects. The deletion is audited and the binding identifier cannot be reused.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.EffectiveWrite))
.Produces(StatusCodes.Status204NoContent)
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
group.MapGet("/", ListEffectivePolicies)
.WithName("ListEffectivePolicies")
.WithSummary("List effective policies with optional filtering.")
.WithDescription("List effective policy bindings with optional filtering by tenant, policy ID, enabled-only flag, and expiry inclusion. Returns bindings in priority order, used by the resolution engine and console to display active policy governance assignments.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead))
.Produces<EffectivePolicyListResponse>(StatusCodes.Status200OK);
// Scope attachments
var scopeGroup = endpoints.MapGroup("/api/v1/authority/scope-attachments")
.RequireAuthorization()
.WithTags("Authority Scope Attachments");
.WithTags("Authority Scope Attachments")
.RequireTenantContext();
scopeGroup.MapPost("/", AttachScope)
.WithName("AttachAuthorityScope")
.WithSummary("Attach an authorization scope to an effective policy.")
.WithDescription("Attach an authorization scope restriction to an effective policy, narrowing its applicability to requests carrying the specified scope claim. Audited per CONTRACT-AUTHORITY-EFFECTIVE-WRITE-008.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.EffectiveWrite))
.Produces<AuthorityScopeAttachmentResponse>(StatusCodes.Status201Created)
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest);
scopeGroup.MapDelete("/{attachmentId}", DetachScope)
.WithName("DetachAuthorityScope")
.WithSummary("Detach an authorization scope.")
.WithDescription("Remove a scope restriction attachment from an effective policy, allowing the policy to apply to a broader set of authorization scopes. Audited per CONTRACT-AUTHORITY-EFFECTIVE-WRITE-008.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.EffectiveWrite))
.Produces(StatusCodes.Status204NoContent)
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
scopeGroup.MapGet("/policy/{effectivePolicyId}", GetPolicyScopeAttachments)
.WithName("GetPolicyScopeAttachments")
.WithSummary("Get all scope attachments for an effective policy.")
.WithDescription("Retrieve all authorization scope restriction attachments for a specific effective policy, showing which scope claims are required for the policy to apply in each binding context.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead))
.Produces<AuthorityScopeAttachmentListResponse>(StatusCodes.Status200OK);
// Resolution
var resolveGroup = endpoints.MapGroup("/api/v1/authority")
.RequireAuthorization()
.WithTags("Policy Resolution");
.WithTags("Policy Resolution")
.RequireTenantContext();
resolveGroup.MapGet("/resolve", ResolveEffectivePolicy)
.WithName("ResolveEffectivePolicy")
.WithSummary("Resolve the effective policy for a subject.")
.WithDescription("Resolve the highest-priority enabled effective policy that governs a given subject string, respecting tenant scoping and priority ordering. Returns the resolved policy binding with the matching pack reference, used by the gate and evaluation pipeline at decision time.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead))
.Produces<EffectivePolicyResolutionResponse>(StatusCodes.Status200OK);
return endpoints;
@@ -86,6 +107,7 @@ internal static class EffectivePolicyEndpoints
private static IResult CreateEffectivePolicy(
HttpContext context,
[FromBody] CreateEffectivePolicyRequest request,
[FromServices] ITenantContextAccessor tenantAccessor,
[FromServices] EffectivePolicyService policyService,
[FromServices] IEffectivePolicyAuditor auditor)
{
@@ -102,6 +124,7 @@ internal static class EffectivePolicyEndpoints
try
{
var tenantId = tenantAccessor.TenantContext!.TenantId;
var actorId = ResolveActorId(context);
var policy = policyService.Create(request, actorId);
@@ -205,11 +228,11 @@ internal static class EffectivePolicyEndpoints
private static IResult ListEffectivePolicies(
HttpContext context,
[FromQuery] string? tenantId,
[FromQuery] string? policyId,
[FromQuery] bool enabledOnly,
[FromQuery] bool includeExpired,
[FromQuery] int limit,
ITenantContextAccessor tenantAccessor,
EffectivePolicyService policyService)
{
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyRead);
@@ -218,6 +241,9 @@ internal static class EffectivePolicyEndpoints
return scopeResult;
}
// POL-TEN-03: Use middleware-resolved tenant instead of query parameter.
var tenantId = tenantAccessor.TenantContext!.TenantId;
var query = new EffectivePolicyQuery(
TenantId: tenantId,
PolicyId: policyId,
@@ -311,7 +337,7 @@ internal static class EffectivePolicyEndpoints
private static IResult ResolveEffectivePolicy(
HttpContext context,
[FromQuery] string subject,
[FromQuery] string? tenantId,
[FromServices] ITenantContextAccessor tenantAccessor,
[FromServices] EffectivePolicyService policyService)
{
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyRead);
@@ -325,6 +351,8 @@ internal static class EffectivePolicyEndpoints
return Results.BadRequest(CreateProblem("Invalid request", "Subject is required."));
}
// POL-TEN-03: Use middleware-resolved tenant instead of query parameter.
var tenantId = tenantAccessor.TenantContext!.TenantId;
var result = policyService.Resolve(subject, tenantId);
return Results.Ok(new EffectivePolicyResolutionResponse(result));

View File

@@ -1,4 +1,6 @@
using Microsoft.AspNetCore.Mvc;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Policy.Engine.Domain;
using StellaOps.Policy.Engine.Services;
@@ -9,7 +11,9 @@ public static class EvidenceSummaryEndpoint
public static IEndpointRouteBuilder MapEvidenceSummaries(this IEndpointRouteBuilder routes)
{
routes.MapPost("/evidence/summary", HandleAsync)
.WithName("PolicyEngine.EvidenceSummary");
.WithName("PolicyEngine.EvidenceSummary")
.WithDescription("Aggregate and summarize evidence signals for a set of advisory sources, returning per-source severity counts, conflict indicators, and overall trust posture. Used by the policy console and audit reporting to provide human-readable evidence context alongside raw policy decisions.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead));
return routes;
}

View File

@@ -1,4 +1,6 @@
using Microsoft.AspNetCore.Mvc;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Policy.Engine.Ledger;
namespace StellaOps.Policy.Engine.Endpoints;
@@ -8,10 +10,14 @@ public static class LedgerExportEndpoint
public static IEndpointRouteBuilder MapLedgerExport(this IEndpointRouteBuilder routes)
{
routes.MapPost("/policy/ledger/export", BuildAsync)
.WithName("PolicyEngine.Ledger.Export");
.WithName("PolicyEngine.Ledger.Export")
.WithDescription("Initiate an export of the policy decision ledger for a given tenant and time range, producing a structured archive of verdict records, evidence hashes, and trust-weight snapshots suitable for compliance reporting and offline audit replay.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyAudit));
routes.MapGet("/policy/ledger/export/{exportId}", GetAsync)
.WithName("PolicyEngine.Ledger.GetExport");
.WithName("PolicyEngine.Ledger.GetExport")
.WithDescription("Retrieve a completed ledger export package by its identifier, returning the serialized archive of policy decisions and their supporting evidence for download or ingestion into an external audit system.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyAudit));
return routes;
}

View File

@@ -1,6 +1,8 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Policy.Engine.MergePreview;
using System.Threading;
using System.Threading.Tasks;
@@ -16,7 +18,8 @@ public static class MergePreviewEndpoints
group.MapGet("/{cveId}", HandleGetMergePreviewAsync)
.WithName("GetMergePreview")
.WithDescription("Get merge preview showing vendor distro ⊕ internal VEX merge")
.WithDescription("Generate a merge preview showing the layered composition of vendor, distribution, and internal VEX statements for a given CVE and artifact PURL, enabling policy authors to visualize which VEX layer wins and what the effective verdict would be before committing changes.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead))
// TODO: Fix MergePreview type - namespace conflict
// .Produces<MergePreview>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound);

View File

@@ -1,4 +1,6 @@
using Microsoft.AspNetCore.Mvc;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Policy.Engine.Orchestration;
namespace StellaOps.Policy.Engine.Endpoints;
@@ -8,13 +10,19 @@ public static class OrchestratorJobEndpoint
public static IEndpointRouteBuilder MapOrchestratorJobs(this IEndpointRouteBuilder routes)
{
routes.MapPost("/policy/orchestrator/jobs", SubmitAsync)
.WithName("PolicyEngine.Orchestrator.Jobs.Submit");
.WithName("PolicyEngine.Orchestrator.Jobs.Submit")
.WithDescription("Submit a policy orchestrator job for asynchronous execution, scheduling evaluation of one or more component snapshots against active policy bundles. Returns a job record with a tracking identifier for subsequent status polling.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRun));
routes.MapPost("/policy/orchestrator/jobs/preview", PreviewAsync)
.WithName("PolicyEngine.Orchestrator.Jobs.Preview");
.WithName("PolicyEngine.Orchestrator.Jobs.Preview")
.WithDescription("Preview the execution plan of a policy orchestrator job without actually submitting it for execution. Returns the resolved bundle set, scope bindings, and estimated evaluation cost so operators can validate intent before committing.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRun));
routes.MapGet("/policy/orchestrator/jobs/{jobId}", GetAsync)
.WithName("PolicyEngine.Orchestrator.Jobs.Get");
.WithName("PolicyEngine.Orchestrator.Jobs.Get")
.WithDescription("Retrieve the current status and result of a previously submitted orchestrator job by its identifier, including verdict summaries, per-component outcomes, and any errors encountered during execution.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead));
return routes;
}

View File

@@ -1,4 +1,6 @@
using Microsoft.AspNetCore.Mvc;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Policy.Engine.Options;
using StellaOps.Policy.Engine.Overlay;
@@ -10,7 +12,9 @@ public static class OverlaySimulationEndpoint
{
routes.MapPost("/simulation/overlay", HandleAsync)
.RequireRateLimiting(PolicyEngineRateLimitOptions.PolicyName)
.WithName("PolicyEngine.OverlaySimulation");
.WithName("PolicyEngine.OverlaySimulation")
.WithDescription("Simulate the effect of applying VEX/rule overlays to an existing policy evaluation without persisting any changes. Useful for pre-flight validation before committing overlay promotions.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicySimulate));
return routes;
}

View File

@@ -2,6 +2,7 @@
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Policy.Engine.Services;
using StellaOps.Policy.RiskProfile.Overrides;
using System.Security.Claims;
@@ -13,40 +14,51 @@ internal static class OverrideEndpoints
public static IEndpointRouteBuilder MapOverrides(this IEndpointRouteBuilder endpoints)
{
var group = endpoints.MapGroup("/api/risk/overrides")
.RequireAuthorization()
.WithTags("Risk Overrides");
group.MapPost("/", CreateOverride)
.WithName("CreateOverride")
.WithSummary("Create a new override with audit metadata.")
.WithDescription("Create a risk profile override to alter severity, action, or signal values for a specific advisory or component pattern, recording the rationale, author, and expiry for the audit log. Automatically validates for conflicts with existing overrides before persisting.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyEdit))
.Produces<OverrideResponse>(StatusCodes.Status201Created)
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest);
group.MapGet("/{overrideId}", GetOverride)
.WithName("GetOverride")
.WithSummary("Get an override by ID.")
.WithDescription("Retrieve the full definition of a risk override by its identifier, including the override type, target pattern, value, approval status, author, and audit trail metadata.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead))
.Produces<OverrideResponse>(StatusCodes.Status200OK)
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
group.MapDelete("/{overrideId}", DeleteOverride)
.WithName("DeleteOverride")
.WithSummary("Delete an override.")
.WithDescription("Permanently remove a risk override by its identifier. The override will no longer be applied in subsequent evaluations for the affected risk profile.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyEdit))
.Produces(StatusCodes.Status204NoContent)
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
group.MapGet("/profile/{profileId}", ListProfileOverrides)
.WithName("ListProfileOverrides")
.WithSummary("List all overrides for a risk profile.")
.WithDescription("List all overrides registered for a specific risk profile, optionally including inactive overrides. Returns each override's type, target, value, approval status, and audit metadata for management and review.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead))
.Produces<OverrideListResponse>(StatusCodes.Status200OK);
group.MapPost("/validate", ValidateOverride)
.WithName("ValidateOverride")
.WithSummary("Validate an override for conflicts before creating.")
.WithDescription("Validate a proposed override definition against existing overrides to detect conflicts, returning conflict descriptions and advisory warnings. Call this before creating an override to surface issues without committing the override.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead))
.Produces<OverrideValidationResponse>(StatusCodes.Status200OK);
group.MapPost("/{overrideId}:approve", ApproveOverride)
.WithName("ApproveOverride")
.WithSummary("Approve an override that requires review.")
.WithDescription("Approve a pending override that was created with a review requirement, recording the approving actor and transitioning the override to active status. Fails if the override has already been approved by the same actor or is not in a pending state.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyActivate))
.Produces<OverrideResponse>(StatusCodes.Status200OK)
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest)
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
@@ -54,12 +66,16 @@ internal static class OverrideEndpoints
group.MapPost("/{overrideId}:disable", DisableOverride)
.WithName("DisableOverride")
.WithSummary("Disable an active override.")
.WithDescription("Disable an active override without deleting it, recording the disabling actor and an optional reason. Disabled overrides are preserved in the audit log and can be queried by history endpoints but are excluded from active evaluations.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyEdit))
.Produces<OverrideResponse>(StatusCodes.Status200OK)
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
group.MapGet("/{overrideId}/history", GetOverrideHistory)
.WithName("GetOverrideHistory")
.WithSummary("Get application history for an override.")
.WithDescription("Retrieve the chronological application history for a specific override, showing each time the override was applied during an evaluation run, the matching finding, and the resulting value change.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead))
.Produces<OverrideHistoryResponse>(StatusCodes.Status200OK);
return endpoints;

View File

@@ -1,6 +1,8 @@
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Policy.Engine.Options;
using StellaOps.Policy.Engine.Overlay;
using StellaOps.Policy.Engine.Streaming;
@@ -15,7 +17,9 @@ public static class PathScopeSimulationEndpoint
{
routes.MapPost("/simulation/path-scope", HandleAsync)
.RequireRateLimiting(PolicyEngineRateLimitOptions.PolicyName)
.WithName("PolicyEngine.PathScopeSimulation");
.WithName("PolicyEngine.PathScopeSimulation")
.WithDescription("Stream a what-if path-scope simulation showing how a change in call-graph reachability would alter policy verdicts. Returns NDJSON lines for each simulated path segment with optional deterministic trace output.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicySimulate));
return routes;
}

View File

@@ -1,5 +1,7 @@
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Policy;
using StellaOps.Policy.Engine.Compilation;
using StellaOps.Policy.Engine.Services;
@@ -19,7 +21,7 @@ internal static class PolicyCompilationEndpoints
.WithDescription("Compiles a stella-dsl@1 policy document and returns deterministic digest and statistics.")
.Produces<PolicyCompileResponse>(StatusCodes.Status200OK)
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest)
.RequireAuthorization(); // scopes enforced by policy middleware.
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyEdit));
return endpoints;
}

View File

@@ -1,11 +1,15 @@
using Microsoft.AspNetCore.Mvc;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Policy.Engine.Domain;
using StellaOps.Policy.Engine.Services;
using StellaOps.Policy.Engine.Tenancy;
namespace StellaOps.Policy.Engine.Endpoints;
/// <summary>
/// API endpoint for policy decisions with source evidence summaries (POLICY-ENGINE-40-003).
/// POL-TEN-03: Tenant enforcement via ITenantContextAccessor.
/// </summary>
public static class PolicyDecisionEndpoint
{
@@ -13,23 +17,32 @@ public static class PolicyDecisionEndpoint
{
routes.MapPost("/policy/decisions", GetDecisionsAsync)
.WithName("PolicyEngine.Decisions")
.WithDescription("Request policy decisions with source evidence summaries, top severity sources, and conflict counts.");
.WithDescription("Evaluate and retrieve policy decisions for one or more component snapshots, including source evidence summaries, top-severity advisory sources, and signal-conflict counts. Used by CI/CD pipelines and the release console to determine pass/fail/warn verdicts.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead))
.RequireTenantContext();
routes.MapGet("/policy/decisions/{snapshotId}", GetDecisionsBySnapshotAsync)
.WithName("PolicyEngine.Decisions.BySnapshot")
.WithDescription("Get policy decisions for a specific snapshot.");
.WithDescription("Retrieve previously computed policy decisions for a specific snapshot by its identifier, with optional filtering by tenant, component PURL, or advisory ID. Supports the compliance audit trail by allowing replay of recorded verdicts.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead))
.RequireTenantContext();
return routes;
}
private static async Task<IResult> GetDecisionsAsync(
[FromBody] PolicyDecisionRequest request,
ITenantContextAccessor tenantAccessor,
PolicyDecisionService service,
CancellationToken cancellationToken)
{
var tenant = tenantAccessor.TenantContext!;
try
{
var response = await service.GetDecisionsAsync(request, cancellationToken).ConfigureAwait(false);
// POL-TEN-03: Override request tenant with resolved middleware tenant for isolation.
var scopedRequest = request with { TenantId = tenant.TenantId };
var response = await service.GetDecisionsAsync(scopedRequest, cancellationToken).ConfigureAwait(false);
return Results.Ok(response);
}
catch (ArgumentException ex)
@@ -44,19 +57,22 @@ public static class PolicyDecisionEndpoint
private static async Task<IResult> GetDecisionsBySnapshotAsync(
[FromRoute] string snapshotId,
[FromQuery] string? tenantId,
[FromQuery] string? componentPurl,
[FromQuery] string? advisoryId,
[FromQuery] bool includeEvidence = true,
[FromQuery] int maxSources = 5,
ITenantContextAccessor tenantAccessor = default!,
PolicyDecisionService service = default!,
CancellationToken cancellationToken = default)
{
var tenant = tenantAccessor.TenantContext!;
try
{
// POL-TEN-03: Use resolved tenant from middleware instead of query parameter.
var request = new PolicyDecisionRequest(
SnapshotId: snapshotId,
TenantId: tenantId,
TenantId: tenant.TenantId,
ComponentPurl: componentPurl,
AdvisoryId: advisoryId,
IncludeEvidence: includeEvidence,

View File

@@ -1,4 +1,6 @@
using Microsoft.AspNetCore.Mvc;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Policy.Engine.DeterminismGuard;
namespace StellaOps.Policy.Engine.Endpoints;
@@ -15,18 +17,18 @@ public static class PolicyLintEndpoints
group.MapPost("/analyze", AnalyzeSourceAsync)
.WithName("Policy.Lint.Analyze")
.WithDescription("Analyze source code for determinism violations")
.RequireAuthorization(policy => policy.RequireClaim("scope", "policy:read"));
.WithDescription("Analyze a single policy source file for determinism violations including wall-clock access, random number generation, network or filesystem calls in evaluation paths, and unstable iteration patterns. Returns per-violation details with rule ID, category, severity, and remediation guidance.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead));
group.MapPost("/analyze-batch", AnalyzeBatchAsync)
.WithName("Policy.Lint.AnalyzeBatch")
.WithDescription("Analyze multiple source files for determinism violations")
.RequireAuthorization(policy => policy.RequireClaim("scope", "policy:read"));
.WithDescription("Analyze multiple policy source files in a single request for determinism violations, aggregating results across all files. Used by CI pipelines to lint an entire policy bundle before compilation or deployment.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead));
group.MapGet("/rules", GetLintRulesAsync)
.WithName("Policy.Lint.GetRules")
.WithDescription("Get available lint rules and their severities")
.RequireAuthorization(policy => policy.RequireClaim("scope", "policy:read"));
.WithDescription("List all available determinism lint rules with their rule IDs, categories (WallClock, RandomNumber, NetworkAccess, etc.), default severities, and recommended remediations. Used by policy authoring tools to display inline guidance and severity thresholds to developers.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead));
return routes;
}

View File

@@ -1,4 +1,6 @@
using Microsoft.AspNetCore.Mvc;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Policy.Engine.AirGap;
namespace StellaOps.Policy.Engine.Endpoints;
@@ -14,19 +16,22 @@ public static class PolicyPackBundleEndpoints
group.MapPost("", RegisterBundleAsync)
.WithName("AirGap.RegisterBundle")
.WithDescription("Register a bundle for import")
.WithDescription("Register a policy pack bundle for air-gap import, validating the bundle structure and enforcing sealed-mode preconditions. Returns 412 Precondition Failed when sealed-mode blocks the import and 403 Forbidden when the bundle origin is not trusted.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyEdit))
.ProducesProblem(StatusCodes.Status400BadRequest)
.ProducesProblem(StatusCodes.Status403Forbidden)
.ProducesProblem(StatusCodes.Status412PreconditionFailed);
group.MapGet("{bundleId}", GetBundleStatusAsync)
.WithName("AirGap.GetBundleStatus")
.WithDescription("Get bundle import status")
.WithDescription("Retrieve the import status of a registered policy pack bundle by its import ID, including processing state, any validation errors, and metadata extracted from the bundle manifest.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead))
.ProducesProblem(StatusCodes.Status404NotFound);
group.MapGet("", ListBundlesAsync)
.WithName("AirGap.ListBundles")
.WithDescription("List imported bundles");
.WithDescription("List all policy pack bundles registered for a tenant, including their import status, source metadata, and processing timestamps, for use by air-gap operation consoles monitoring bundle delivery pipelines.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead));
return routes;
}

View File

@@ -2,6 +2,7 @@
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Determinism;
using StellaOps.Policy.Engine.Domain;
using StellaOps.Policy.Engine.Services;
@@ -14,34 +15,43 @@ internal static class PolicyPackEndpoints
public static IEndpointRouteBuilder MapPolicyPacks(this IEndpointRouteBuilder endpoints)
{
var group = endpoints.MapGroup("/api/policy/packs")
.RequireAuthorization()
.WithTags("Policy Packs");
group.MapPost(string.Empty, CreatePack)
.WithName("CreatePolicyPack")
.WithSummary("Create a new policy pack container.")
.WithDescription("Create a new policy pack container with an optional display name, establishing the versioned identity under which subsequent policy revisions and compiled bundles will be registered.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyEdit))
.Produces<PolicyPackDto>(StatusCodes.Status201Created);
group.MapGet(string.Empty, ListPacks)
.WithName("ListPolicyPacks")
.WithSummary("List policy packs for the current tenant.")
.WithDescription("List all policy pack containers for the authenticated tenant, returning summaries of each pack including available revision versions and their current activation status.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead))
.Produces<IReadOnlyList<PolicyPackSummaryDto>>(StatusCodes.Status200OK);
group.MapPost("/{packId}/revisions", CreateRevision)
.WithName("CreatePolicyRevision")
.WithSummary("Create or update policy revision metadata.")
.WithDescription("Create or upsert a policy revision entry under a given pack, setting initial status (Draft or Approved) and two-person approval requirements that govern the activation workflow for that revision.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyEdit))
.Produces<PolicyRevisionDto>(StatusCodes.Status201Created)
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest);
group.MapPost("/{packId}/revisions/{version:int}/bundle", CreateBundle)
.WithName("CreatePolicyBundle")
.WithSummary("Compile and sign a policy revision bundle for distribution.")
.WithDescription("Compile a policy DSL source attached to a revision into a signed, immutable bundle ready for distribution and evaluation. Returns the bundle digest and compilation statistics; fails fast if the DSL contains syntax errors or rule conflicts.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyEdit))
.Produces<PolicyBundleResponse>(StatusCodes.Status201Created)
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest);
group.MapPost("/{packId}/revisions/{version:int}/evaluate", EvaluateRevision)
.WithName("EvaluatePolicyRevision")
.WithSummary("Evaluate a policy revision deterministically with in-memory caching.")
.WithDescription("Evaluate a compiled policy revision bundle against a provided advisory/VEX/SBOM context, returning a deterministic verdict with per-rule outcomes. Results are cached in-memory for subsequent identical evaluations to reduce compute overhead during iterative authoring.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead))
.Produces<PolicyEvaluationResponse>(StatusCodes.Status200OK)
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest)
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
@@ -49,6 +59,8 @@ internal static class PolicyPackEndpoints
group.MapPost("/{packId}/revisions/{version:int}:activate", ActivateRevision)
.WithName("ActivatePolicyRevision")
.WithSummary("Activate an approved policy revision, enforcing two-person approval when required.")
.WithDescription("Promote an approved policy revision to active status, recording the activating actor and enforcing two-person approval workflows when configured. Returns 202 Accepted when a second approval is still pending, or 200 OK when the revision is fully activated.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyActivate))
.Produces<PolicyRevisionActivationResponse>(StatusCodes.Status200OK)
.Produces<PolicyRevisionActivationResponse>(StatusCodes.Status202Accepted)
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest)

View File

@@ -1,6 +1,7 @@
using Microsoft.AspNetCore.Mvc;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Policy.Engine.Services;
using StellaOps.Policy.Engine.Snapshots;
@@ -14,17 +15,22 @@ internal static class PolicySnapshotEndpoints
public static IEndpointRouteBuilder MapPolicySnapshotsApi(this IEndpointRouteBuilder endpoints)
{
var group = endpoints.MapGroup("/api/policy/snapshots")
.RequireAuthorization()
.WithTags("Policy Snapshots");
group.MapPost(string.Empty, CreateAsync)
.WithName("PolicyEngine.Api.Snapshots.Create");
.WithName("PolicyEngine.Api.Snapshots.Create")
.WithDescription("Create a new policy evaluation snapshot from a component manifest, capturing PURL set, advisory signals, and applicable policy bundles at a point in time for subsequent policy decision evaluation.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyEdit));
group.MapGet(string.Empty, ListAsync)
.WithName("PolicyEngine.Api.Snapshots.List");
.WithName("PolicyEngine.Api.Snapshots.List")
.WithDescription("List policy evaluation snapshots for the specified tenant, returning snapshot identifiers, creation timestamps, and summary component counts for use in batch evaluation workflows. Supports cursor-based pagination.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead));
group.MapGet("/{snapshotId}", GetAsync)
.WithName("PolicyEngine.Api.Snapshots.Get");
.WithName("PolicyEngine.Api.Snapshots.Get")
.WithDescription("Retrieve a specific policy evaluation snapshot by identifier, including its full component graph, resolved advisory signals, and any cached partial evaluation state.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead));
return endpoints;
}

View File

@@ -1,4 +1,6 @@
using Microsoft.AspNetCore.Mvc;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Policy.Engine.Orchestration;
namespace StellaOps.Policy.Engine.Endpoints;
@@ -8,10 +10,14 @@ public static class PolicyWorkerEndpoint
public static IEndpointRouteBuilder MapPolicyWorker(this IEndpointRouteBuilder routes)
{
routes.MapPost("/policy/worker/run", RunAsync)
.WithName("PolicyEngine.Worker.Run");
.WithName("PolicyEngine.Worker.Run")
.WithDescription("Trigger synchronous execution of a policy worker task, running the evaluation pipeline for a specified job against its resolved bundle and component snapshot. Returns the complete evaluation result upon completion.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRun));
routes.MapGet("/policy/worker/jobs/{jobId}", GetResultAsync)
.WithName("PolicyEngine.Worker.GetResult");
.WithName("PolicyEngine.Worker.GetResult")
.WithDescription("Retrieve the stored result of a previously executed policy worker job, including per-rule verdicts, signal scores, and any advisory hits recorded during evaluation.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead));
return routes;
}

View File

@@ -2,6 +2,7 @@
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Policy.Engine.Events;
using StellaOps.Policy.Engine.Services;
using System.Security.Claims;
@@ -13,33 +14,42 @@ internal static class ProfileEventEndpoints
public static IEndpointRouteBuilder MapProfileEvents(this IEndpointRouteBuilder endpoints)
{
var group = endpoints.MapGroup("/api/risk/events")
.RequireAuthorization()
.WithTags("Profile Events");
group.MapGet("/", GetRecentEvents)
.WithName("GetRecentProfileEvents")
.WithSummary("Get recent profile lifecycle events.")
.WithDescription("Retrieve the most recent risk profile lifecycle events across all profiles, ordered by descending timestamp, up to the specified limit. Events include profile creation, activation, deprecation, archival, and override changes.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead))
.Produces<EventListResponse>(StatusCodes.Status200OK);
group.MapGet("/filter", GetFilteredEvents)
.WithName("GetFilteredProfileEvents")
.WithSummary("Get profile events with optional filtering.")
.WithDescription("Retrieve risk profile lifecycle events filtered by event type, profile ID, and a start timestamp, enabling targeted event streams for monitoring pipelines and compliance queries that need only a subset of event types.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead))
.Produces<EventListResponse>(StatusCodes.Status200OK);
group.MapPost("/subscribe", CreateSubscription)
.WithName("CreateEventSubscription")
.WithSummary("Subscribe to profile lifecycle events.")
.WithDescription("Create a named subscription to one or more profile lifecycle event types, optionally filtered by profile ID. Subscriptions can optionally specify a webhook URL for push delivery; otherwise, events are accumulated for polling via the poll endpoint.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead))
.Produces<SubscriptionResponse>(StatusCodes.Status201Created);
group.MapDelete("/subscribe/{subscriptionId}", DeleteSubscription)
.WithName("DeleteEventSubscription")
.WithSummary("Unsubscribe from profile lifecycle events.")
.WithDescription("Remove a profile lifecycle event subscription by its identifier, stopping future event delivery and releasing any buffered events associated with the subscription.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead))
.Produces(StatusCodes.Status204NoContent)
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
group.MapGet("/subscribe/{subscriptionId}/poll", PollSubscription)
.WithName("PollEventSubscription")
.WithSummary("Poll for events from a subscription.")
.WithDescription("Poll a profile lifecycle event subscription for accumulated events since the last poll, returning up to the specified limit. Returns an empty list if no new events are available; the subscription state is not modified by polling.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead))
.Produces<EventListResponse>(StatusCodes.Status200OK)
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);

View File

@@ -22,18 +22,21 @@ internal static class ProfileExportEndpoints
group.MapPost("/", ExportProfiles)
.WithName("ExportProfiles")
.WithSummary("Export risk profiles as a signed bundle.")
.WithDescription("Export one or more risk profiles into a cryptographically signed bundle suitable for distribution across air-gap boundaries or cross-environment synchronization. Returns the full bundle structure including the content hash and signing metadata.")
.Produces<ExportResponse>(StatusCodes.Status200OK)
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest);
group.MapPost("/download", DownloadBundle)
.WithName("DownloadProfileBundle")
.WithSummary("Export and download risk profiles as a JSON file.")
.WithDescription("Export one or more risk profiles into a signed bundle and return it as a downloadable JSON file attachment, enabling operators to transfer profiles to disconnected environments without API client tooling.")
.Produces<FileContentHttpResult>(StatusCodes.Status200OK, contentType: "application/json");
endpoints.MapPost("/api/risk/profiles/import", ImportProfiles)
.RequireAuthorization(policy => policy.Requirements.Add(new StellaOpsScopeRequirement(new[] { StellaOpsScopes.PolicyEdit })))
.WithName("ImportProfiles")
.WithSummary("Import risk profiles from a signed bundle.")
.WithDescription("Import risk profiles from a previously exported signed bundle, verifying the bundle signature and registering each included profile in the local store. Replaces existing profiles with matching IDs if permitted by the import options.")
.WithTags("Profile Export/Import")
.Produces<ImportResponse>(StatusCodes.Status200OK)
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest);
@@ -42,6 +45,7 @@ internal static class ProfileExportEndpoints
.RequireAuthorization(policy => policy.Requirements.Add(new StellaOpsScopeRequirement(new[] { StellaOpsScopes.PolicyRead })))
.WithName("VerifyProfileBundle")
.WithSummary("Verify the signature of a profile bundle without importing.")
.WithDescription("Verify the cryptographic signature and content hash of a risk profile bundle without importing any profiles, allowing operators to validate bundle integrity before committing to an import operation.")
.WithTags("Profile Export/Import")
.Produces<VerifyResponse>(StatusCodes.Status200OK);

View File

@@ -7,51 +7,67 @@
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Policy.Engine.Tenancy;
using StellaOps.Policy.Gates;
namespace StellaOps.Policy.Engine.Endpoints;
/// <summary>
/// API endpoints for risk budget management.
/// POL-TEN-03: Tenant enforcement via ITenantContextAccessor.
/// </summary>
internal static class RiskBudgetEndpoints
{
public static IEndpointRouteBuilder MapRiskBudgets(this IEndpointRouteBuilder endpoints)
{
var group = endpoints.MapGroup("/api/v1/policy/budget")
.RequireAuthorization()
.WithTags("Risk Budgets");
.WithTags("Risk Budgets")
.RequireTenantContext();
group.MapGet("/status/{serviceId}", GetBudgetStatus)
.WithName("GetRiskBudgetStatus")
.WithSummary("Get current risk budget status for a service.")
.WithDescription("Retrieve the current risk budget status for a specific service, including allocated capacity, consumed points, remaining headroom, and enforcement status for the requested or active budget window.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead))
.Produces<RiskBudgetStatusResponse>(StatusCodes.Status200OK);
group.MapPost("/consume", ConsumeBudget)
.WithName("ConsumeRiskBudget")
.WithSummary("Record budget consumption after a release.")
.WithDescription("Record the risk point consumption for a completed release, deducting the specified points from the service's active budget window ledger. Returns the updated budget state including remaining headroom and enforcement status.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyOperate))
.Produces<BudgetConsumeResponse>(StatusCodes.Status200OK)
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest);
group.MapPost("/check", CheckRelease)
.WithName("CheckRelease")
.WithSummary("Check if a release can proceed given current budget.")
.WithDescription("Evaluate whether a proposed release can proceed given the service's current risk budget, operational context (change freeze, incident state, deployment window), and mitigation factors (feature flags, canary deployment, rollback plan). Returns the required gate level, projected risk points, and pre/post budget state.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead))
.Produces<ReleaseCheckResponse>(StatusCodes.Status200OK);
group.MapGet("/history/{serviceId}", GetBudgetHistory)
.WithName("GetBudgetHistory")
.WithSummary("Get budget consumption history for a service.")
.WithDescription("Retrieve the chronological list of risk budget consumption entries for a service within the specified or current budget window, showing each release's risk point cost and timestamp for audit and trend analysis.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead))
.Produces<BudgetHistoryResponse>(StatusCodes.Status200OK);
group.MapPost("/adjust", AdjustBudget)
.WithName("AdjustBudget")
.WithSummary("Adjust budget allocation (earned capacity or manual override).")
.WithDescription("Apply a positive or negative adjustment to a service's allocated risk budget, supporting earned-capacity rewards and administrative corrections. The adjustment reason is recorded for the audit ledger.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyEdit))
.Produces<RiskBudgetStatusResponse>(StatusCodes.Status200OK)
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest);
group.MapGet("/list", ListBudgets)
.WithName("ListRiskBudgets")
.WithSummary("List all risk budgets with optional filtering.")
.WithDescription("List risk budgets across services with optional filtering by enforcement status and budget window, returning current allocation, consumption, and remaining headroom for each service to support dashboard and compliance reporting.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead))
.Produces<BudgetListResponse>(StatusCodes.Status200OK);
return endpoints;
@@ -60,9 +76,12 @@ internal static class RiskBudgetEndpoints
private static async Task<Ok<RiskBudgetStatusResponse>> GetBudgetStatus(
string serviceId,
[FromQuery] string? window,
ITenantContextAccessor tenantAccessor,
IBudgetLedger ledger,
CancellationToken ct)
{
var tenantId = tenantAccessor.TenantContext!.TenantId;
// POL-TEN-03: tenantId available for downstream scoping when repository layer is wired.
var budget = await ledger.GetBudgetAsync(serviceId, window, ct);
return TypedResults.Ok(new RiskBudgetStatusResponse(
@@ -80,9 +99,11 @@ internal static class RiskBudgetEndpoints
private static async Task<Results<Ok<BudgetConsumeResponse>, ProblemHttpResult>> ConsumeBudget(
[FromBody] BudgetConsumeRequest request,
ITenantContextAccessor tenantAccessor,
IBudgetLedger ledger,
CancellationToken ct)
{
var tenantId = tenantAccessor.TenantContext!.TenantId;
if (request.RiskPoints <= 0)
{
return TypedResults.Problem(
@@ -114,9 +135,11 @@ internal static class RiskBudgetEndpoints
private static async Task<Ok<ReleaseCheckResponse>> CheckRelease(
[FromBody] ReleaseCheckRequest request,
ITenantContextAccessor tenantAccessor,
IBudgetConstraintEnforcer enforcer,
CancellationToken ct)
{
var tenantId = tenantAccessor.TenantContext!.TenantId;
var input = new ReleaseCheckInput
{
ServiceId = request.ServiceId,
@@ -158,9 +181,11 @@ internal static class RiskBudgetEndpoints
private static async Task<Ok<BudgetHistoryResponse>> GetBudgetHistory(
string serviceId,
[FromQuery] string? window,
ITenantContextAccessor tenantAccessor,
IBudgetLedger ledger,
CancellationToken ct)
{
var tenantId = tenantAccessor.TenantContext!.TenantId;
var entries = await ledger.GetHistoryAsync(serviceId, window, ct);
var items = entries.Select(e => new BudgetEntryDto(
@@ -177,9 +202,11 @@ internal static class RiskBudgetEndpoints
private static async Task<Results<Ok<RiskBudgetStatusResponse>, ProblemHttpResult>> AdjustBudget(
[FromBody] BudgetAdjustRequest request,
ITenantContextAccessor tenantAccessor,
IBudgetLedger ledger,
CancellationToken ct)
{
var tenantId = tenantAccessor.TenantContext!.TenantId;
if (request.Adjustment == 0)
{
return TypedResults.Problem(
@@ -209,8 +236,11 @@ internal static class RiskBudgetEndpoints
private static Ok<BudgetListResponse> ListBudgets(
[FromQuery] string? status,
[FromQuery] string? window,
[FromQuery] int limit = 50)
[FromQuery] int limit = 50,
ITenantContextAccessor tenantAccessor = default!)
{
var tenantId = tenantAccessor.TenantContext!.TenantId;
// POL-TEN-03: tenantId available for downstream scoping when repository layer is wired.
// This would query from PostgresBudgetStore.GetBudgetsByStatusAsync or GetBudgetsByWindowAsync
// For now, return empty list - implementation would need to inject the store
return TypedResults.Ok(new BudgetListResponse([], 0));

View File

@@ -15,23 +15,28 @@ public static class RiskProfileAirGapEndpoints
public static IEndpointRouteBuilder MapRiskProfileAirGap(this IEndpointRouteBuilder routes)
{
var group = routes.MapGroup("/api/v1/airgap/risk-profiles")
.RequireAuthorization()
.WithTags("Air-Gap Risk Profiles");
group.MapPost("/export", ExportProfilesAsync)
.WithName("AirGap.ExportRiskProfiles")
.WithSummary("Export risk profiles as an air-gap compatible bundle with signatures.")
.WithDescription("Export one or more risk profiles into an air-gap compatible bundle format with optional cryptographic signing and Merkle tree integrity protection, ready for transfer to a disconnected environment per CONTRACT-MIRROR-BUNDLE-003.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead))
.Produces<RiskProfileAirGapBundle>(StatusCodes.Status200OK)
.Produces<ProblemDetails>(StatusCodes.Status400BadRequest);
group.MapPost("/export/download", DownloadBundleAsync)
.WithName("AirGap.DownloadRiskProfileBundle")
.WithSummary("Export and download risk profiles as an air-gap compatible JSON file.")
.WithDescription("Export risk profiles into an air-gap bundle and return it as a downloadable JSON file attachment, enabling operators to transfer profiles to offline environments without additional tooling beyond a standard HTTP client.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead))
.Produces<FileContentHttpResult>(StatusCodes.Status200OK, contentType: "application/json");
group.MapPost("/import", ImportProfilesAsync)
.WithName("AirGap.ImportRiskProfiles")
.WithSummary("Import risk profiles from an air-gap bundle with sealed-mode enforcement.")
.WithDescription("Import risk profiles from an air-gap bundle with configurable signature verification, Merkle integrity checking, and sealed-mode enforcement. Returns 412 Precondition Failed when sealed-mode prevents the import, and records the import attempt for the air-gap audit log.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyEdit))
.Produces<RiskProfileAirGapImportResult>(StatusCodes.Status200OK)
.Produces<ProblemDetails>(StatusCodes.Status400BadRequest)
.Produces<ProblemDetails>(StatusCodes.Status403Forbidden)
@@ -40,6 +45,8 @@ public static class RiskProfileAirGapEndpoints
group.MapPost("/verify", VerifyBundleAsync)
.WithName("AirGap.VerifyRiskProfileBundle")
.WithSummary("Verify the integrity of an air-gap bundle without importing.")
.WithDescription("Verify the cryptographic signature and Merkle tree integrity of an air-gap risk profile bundle without performing an import, enabling pre-flight validation before committing to the import operation in sealed environments.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead))
.Produces<AirGapBundleVerification>(StatusCodes.Status200OK);
return routes;

View File

@@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Mvc;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Policy.Engine.Services;
using StellaOps.Policy.Engine.Tenancy;
using StellaOps.Policy.RiskProfile.Lifecycle;
using StellaOps.Policy.RiskProfile.Models;
using System.Security.Claims;
@@ -11,45 +12,55 @@ using System.Text.Json;
namespace StellaOps.Policy.Engine.Endpoints;
/// <summary>
/// POL-TEN-03: Tenant enforcement via ITenantContextAccessor.
/// </summary>
internal static class RiskProfileEndpoints
{
public static IEndpointRouteBuilder MapRiskProfiles(this IEndpointRouteBuilder endpoints)
{
var group = endpoints.MapGroup("/api/risk/profiles")
.RequireAuthorization(policy => policy.Requirements.Add(new StellaOpsScopeRequirement(new[] { StellaOpsScopes.PolicyRead })))
.WithTags("Risk Profiles");
.WithTags("Risk Profiles")
.RequireTenantContext();
group.MapGet(string.Empty, ListProfiles)
.WithName("ListRiskProfiles")
.WithSummary("List all available risk profiles.")
.WithDescription("List all registered risk profiles for the current tenant, returning each profile's identifier, current version, and description. Used by the policy console and advisory pipeline to discover which profiles are available for evaluation binding.")
.Produces<RiskProfileListResponse>(StatusCodes.Status200OK);
group.MapGet("/{profileId}", GetProfile)
.WithName("GetRiskProfile")
.WithSummary("Get a risk profile by ID.")
.WithDescription("Retrieve the full definition of a risk profile by identifier, including signal weights, severity override rules, and the deterministic profile hash used for reproducible evaluation runs.")
.Produces<RiskProfileResponse>(StatusCodes.Status200OK)
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
group.MapGet("/{profileId}/versions", ListVersions)
.WithName("ListRiskProfileVersions")
.WithSummary("List all versions of a risk profile.")
.WithDescription("List the full version history of a risk profile, including lifecycle status (Draft, Active, Deprecated, Archived), activation timestamps, and actor identities for each version, supporting compliance audit trails.")
.Produces<RiskProfileVersionListResponse>(StatusCodes.Status200OK);
group.MapGet("/{profileId}/versions/{version}", GetVersion)
.WithName("GetRiskProfileVersion")
.WithSummary("Get a specific version of a risk profile.")
.WithDescription("Retrieve the complete definition and lifecycle metadata for a specific versioned risk profile, including its deterministic hash and version info record, enabling exact-version lookups during policy replay and audit verification.")
.Produces<RiskProfileResponse>(StatusCodes.Status200OK)
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
group.MapPost(string.Empty, CreateProfile)
.WithName("CreateRiskProfile")
.WithSummary("Create a new risk profile version in draft status.")
.WithDescription("Register a new risk profile version in Draft lifecycle status, recording the authoring actor and creation timestamp. The profile must be activated before it can be used in live policy evaluations.")
.Produces<RiskProfileResponse>(StatusCodes.Status201Created)
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest);
group.MapPost("/{profileId}/versions/{version}:activate", ActivateProfile)
.WithName("ActivateRiskProfile")
.WithSummary("Activate a draft risk profile, making it available for use.")
.WithDescription("Transition a Draft risk profile version to Active status, making it available for binding to policy evaluation runs. Records the activating actor and timestamps the transition for the lifecycle audit log.")
.Produces<RiskProfileVersionInfoResponse>(StatusCodes.Status200OK)
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest)
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
@@ -57,6 +68,7 @@ internal static class RiskProfileEndpoints
group.MapPost("/{profileId}/versions/{version}:deprecate", DeprecateProfile)
.WithName("DeprecateRiskProfile")
.WithSummary("Deprecate an active risk profile.")
.WithDescription("Mark an active risk profile version as Deprecated, optionally specifying a successor version and a human-readable reason. Deprecated profiles remain queryable for audit but are excluded from new evaluation bindings.")
.Produces<RiskProfileVersionInfoResponse>(StatusCodes.Status200OK)
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest)
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
@@ -64,29 +76,34 @@ internal static class RiskProfileEndpoints
group.MapPost("/{profileId}/versions/{version}:archive", ArchiveProfile)
.WithName("ArchiveRiskProfile")
.WithSummary("Archive a risk profile, removing it from active use.")
.WithDescription("Transition a risk profile version to Archived status, permanently removing it from active evaluation use while preserving the definition and lifecycle record for historical audit queries.")
.Produces<RiskProfileVersionInfoResponse>(StatusCodes.Status200OK)
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
group.MapGet("/{profileId}/events", GetProfileEvents)
.WithName("GetRiskProfileEvents")
.WithSummary("Get lifecycle events for a risk profile.")
.WithDescription("Retrieve the ordered lifecycle event log for a risk profile, including creation, activation, deprecation, and archival events with actor and timestamp information, supporting compliance reporting and change history review.")
.Produces<RiskProfileEventListResponse>(StatusCodes.Status200OK);
group.MapPost("/compare", CompareProfiles)
.WithName("CompareRiskProfiles")
.WithSummary("Compare two risk profile versions and list differences.")
.WithDescription("Compute a structured diff between two risk profile versions, listing added, removed, and modified signal weights and override rules. Used by policy authors to validate the impact of profile changes before promoting to active.")
.Produces<RiskProfileComparisonResponse>(StatusCodes.Status200OK)
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest);
group.MapGet("/{profileId}/hash", GetProfileHash)
.WithName("GetRiskProfileHash")
.WithSummary("Get the deterministic hash of a risk profile.")
.WithDescription("Compute and return the deterministic hash of a risk profile, optionally restricted to content-only hashing (excluding metadata). Used to verify profile identity across environments and ensure reproducible evaluation inputs.")
.Produces<RiskProfileHashResponse>(StatusCodes.Status200OK)
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
group.MapGet("/{profileId}/metadata", GetProfileMetadata)
.WithName("GetRiskProfileMetadata")
.WithSummary("Export risk profile metadata for notification enrichment (POLICY-RISK-40-002).")
.WithDescription("Export a compact metadata summary of a risk profile including signal names, severity thresholds, active version info, and custom metadata fields. Used by the notification enrichment pipeline to annotate policy-triggered alerts with human-readable risk context.")
.Produces<RiskProfileMetadataExportResponse>(StatusCodes.Status200OK)
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
@@ -95,6 +112,7 @@ internal static class RiskProfileEndpoints
private static IResult ListProfiles(
HttpContext context,
ITenantContextAccessor tenantAccessor,
RiskProfileConfigurationService profileService)
{
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyRead);
@@ -103,6 +121,8 @@ internal static class RiskProfileEndpoints
return scopeResult;
}
var tenantId = tenantAccessor.TenantContext!.TenantId;
// POL-TEN-03: tenantId available for downstream scoping when repository layer is wired.
var ids = profileService.GetProfileIds();
var profiles = ids
.Select(id => profileService.GetProfile(id))
@@ -116,6 +136,7 @@ internal static class RiskProfileEndpoints
private static IResult GetProfile(
HttpContext context,
[FromRoute] string profileId,
ITenantContextAccessor tenantAccessor,
RiskProfileConfigurationService profileService)
{
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyRead);
@@ -124,6 +145,7 @@ internal static class RiskProfileEndpoints
return scopeResult;
}
var tenantId = tenantAccessor.TenantContext!.TenantId;
var profile = profileService.GetProfile(profileId);
if (profile == null)
{
@@ -196,6 +218,7 @@ internal static class RiskProfileEndpoints
private static IResult CreateProfile(
HttpContext context,
[FromBody] CreateRiskProfileRequest request,
ITenantContextAccessor tenantAccessor,
RiskProfileConfigurationService profileService,
RiskProfileLifecycleService lifecycleService)
{
@@ -205,6 +228,8 @@ internal static class RiskProfileEndpoints
return scopeResult;
}
var tenantId = tenantAccessor.TenantContext!.TenantId;
if (request?.Profile == null)
{
return Results.BadRequest(new ProblemDetails
@@ -244,6 +269,7 @@ internal static class RiskProfileEndpoints
HttpContext context,
[FromRoute] string profileId,
[FromRoute] string version,
ITenantContextAccessor tenantAccessor,
RiskProfileLifecycleService lifecycleService)
{
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyActivate);
@@ -252,6 +278,7 @@ internal static class RiskProfileEndpoints
return scopeResult;
}
var tenantId = tenantAccessor.TenantContext!.TenantId;
var actorId = ResolveActorId(context);
try
@@ -285,6 +312,7 @@ internal static class RiskProfileEndpoints
[FromRoute] string profileId,
[FromRoute] string version,
[FromBody] DeprecateRiskProfileRequest? request,
ITenantContextAccessor tenantAccessor,
RiskProfileLifecycleService lifecycleService)
{
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyEdit);
@@ -293,6 +321,7 @@ internal static class RiskProfileEndpoints
return scopeResult;
}
var tenantId = tenantAccessor.TenantContext!.TenantId;
var actorId = ResolveActorId(context);
try
@@ -331,6 +360,7 @@ internal static class RiskProfileEndpoints
HttpContext context,
[FromRoute] string profileId,
[FromRoute] string version,
ITenantContextAccessor tenantAccessor,
RiskProfileLifecycleService lifecycleService)
{
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyEdit);
@@ -339,6 +369,7 @@ internal static class RiskProfileEndpoints
return scopeResult;
}
var tenantId = tenantAccessor.TenantContext!.TenantId;
var actorId = ResolveActorId(context);
try

View File

@@ -18,6 +18,7 @@ internal static class RiskProfileSchemaEndpoints
endpoints.MapGet("/.well-known/risk-profile-schema", GetSchema)
.WithName("GetRiskProfileSchema")
.WithSummary("Get the JSON Schema for risk profile definitions.")
.WithDescription("Serve the canonical JSON Schema for risk profile documents at the well-known discovery URL, including ETag and cache-control headers for efficient polling. Returns 304 Not Modified when the schema version matches the client's cached ETag.")
.WithTags("Schema Discovery")
.Produces<string>(StatusCodes.Status200OK, contentType: JsonSchemaMediaType)
.Produces(StatusCodes.Status304NotModified)
@@ -26,6 +27,7 @@ internal static class RiskProfileSchemaEndpoints
endpoints.MapPost("/api/risk/schema/validate", ValidateProfile)
.WithName("ValidateRiskProfile")
.WithSummary("Validate a risk profile document against the schema.")
.WithDescription("Validate a submitted risk profile JSON document against the canonical schema, returning a structured list of validation issues including the instance path, error keyword, and human-readable message for each violation. Used by editors and CI pipelines to pre-flight profile authoring before submission.")
.WithTags("Schema Validation")
.Produces<RiskProfileValidationResponse>(StatusCodes.Status200OK)
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest)

View File

@@ -1,6 +1,7 @@
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Policy.Engine.Options;
using StellaOps.Policy.Engine.Services;
using StellaOps.Policy.Engine.Simulation;
@@ -16,13 +17,14 @@ internal static class RiskSimulationEndpoints
public static IEndpointRouteBuilder MapRiskSimulation(this IEndpointRouteBuilder endpoints)
{
var group = endpoints.MapGroup("/api/risk/simulation")
.RequireAuthorization()
.RequireRateLimiting(PolicyEngineRateLimitOptions.PolicyName)
.WithTags("Risk Simulation");
group.MapPost("/", RunSimulation)
.WithName("RunRiskSimulation")
.WithSummary("Run a risk simulation with score distributions and contribution breakdowns.")
.WithDescription("Evaluate a set of advisory findings against a specified risk profile, returning per-finding normalized scores, severity assignments, recommended actions, aggregate metrics, and an optional score distribution histogram. Used by policy authors and CI pipelines to validate risk posture against a profile.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead))
.Produces<RiskSimulationResponse>(StatusCodes.Status200OK)
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest)
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
@@ -30,6 +32,8 @@ internal static class RiskSimulationEndpoints
group.MapPost("/quick", RunQuickSimulation)
.WithName("RunQuickRiskSimulation")
.WithSummary("Run a quick risk simulation without detailed breakdowns.")
.WithDescription("Run a lightweight risk simulation returning only aggregate metrics and score distribution without per-finding contribution details. Optimized for high-frequency calls where summary statistics are sufficient and per-finding breakdown overhead is not justified.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead))
.Produces<QuickSimulationResponse>(StatusCodes.Status200OK)
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest)
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
@@ -37,12 +41,16 @@ internal static class RiskSimulationEndpoints
group.MapPost("/compare", CompareProfiles)
.WithName("CompareProfileSimulations")
.WithSummary("Compare risk scoring between two profile configurations.")
.WithDescription("Simulate the same set of findings against two different risk profiles and compute delta metrics showing how mean score, median score, and severity distribution counts differ between the baseline and comparison profiles. Used by policy authors to evaluate the impact of profile changes before promoting.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead))
.Produces<ProfileComparisonResponse>(StatusCodes.Status200OK)
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest);
group.MapPost("/whatif", RunWhatIfSimulation)
.WithName("RunWhatIfSimulation")
.WithSummary("Run a what-if simulation with hypothetical signal changes.")
.WithDescription("Run a baseline simulation and then re-run with hypothetical signal value overrides applied to specified findings, returning both results and an impact summary counting improved, worsened, and unchanged findings. Supports pre-remediation planning and remediation impact assessment.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead))
.Produces<WhatIfSimulationResponse>(StatusCodes.Status200OK)
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest);
@@ -51,6 +59,7 @@ internal static class RiskSimulationEndpoints
.WithName("RunPolicyStudioAnalysis")
.WithSummary("Run a detailed analysis for Policy Studio with full breakdown analytics.")
.WithDescription("Provides comprehensive breakdown including signal analysis, override tracking, score distributions, and component breakdowns for policy authoring.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead))
.Produces<PolicyStudioAnalysisResponse>(StatusCodes.Status200OK)
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest)
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
@@ -58,6 +67,8 @@ internal static class RiskSimulationEndpoints
group.MapPost("/studio/compare", CompareProfilesWithBreakdown)
.WithName("CompareProfilesWithBreakdown")
.WithSummary("Compare profiles with full breakdown analytics and trend analysis.")
.WithDescription("Compare two risk profiles using full breakdown analytics, returning per-signal contribution deltas, override tracking, and severity distribution shifts between baseline and comparison. Used by Policy Studio to render side-by-side profile comparison views with detailed analytical overlays.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead))
.Produces<PolicyStudioComparisonResponse>(StatusCodes.Status200OK)
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest);
@@ -65,6 +76,7 @@ internal static class RiskSimulationEndpoints
.WithName("PreviewProfileChanges")
.WithSummary("Preview impact of profile changes before committing.")
.WithDescription("Simulates findings against both current and proposed profile to show impact.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead))
.Produces<ProfileChangePreviewResponse>(StatusCodes.Status200OK)
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest);

View File

@@ -2,6 +2,7 @@
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Policy.Engine.Services;
using StellaOps.Policy.RiskProfile.Scope;
using System.Security.Claims;
@@ -13,46 +14,59 @@ internal static class ScopeAttachmentEndpoints
public static IEndpointRouteBuilder MapScopeAttachments(this IEndpointRouteBuilder endpoints)
{
var group = endpoints.MapGroup("/api/risk/scopes")
.RequireAuthorization()
.WithTags("Risk Profile Scopes");
group.MapPost("/attachments", CreateAttachment)
.WithName("CreateScopeAttachment")
.WithSummary("Attach a risk profile to a scope (organization, project, environment, or component).")
.WithDescription("Create a scope attachment that binds a risk profile to a specific organizational scope (organization, project, environment, or component), optionally with an expiry time. The attached profile is used for policy evaluation within that scope during the attachment's active lifetime.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyEdit))
.Produces<ScopeAttachmentResponse>(StatusCodes.Status201Created)
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest);
group.MapGet("/attachments/{attachmentId}", GetAttachment)
.WithName("GetScopeAttachment")
.WithSummary("Get a scope attachment by ID.")
.WithDescription("Retrieve the details of a specific scope attachment by its identifier, including the bound risk profile, target scope type and ID, and the attachment's active and expiry timestamps.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead))
.Produces<ScopeAttachmentResponse>(StatusCodes.Status200OK)
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
group.MapDelete("/attachments/{attachmentId}", DeleteAttachment)
.WithName("DeleteScopeAttachment")
.WithSummary("Delete a scope attachment.")
.WithDescription("Permanently remove a risk profile scope attachment by its identifier. The associated risk profile will no longer be applied to evaluations within the previously bound scope after deletion.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyEdit))
.Produces(StatusCodes.Status204NoContent)
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
group.MapPost("/attachments/{attachmentId}:expire", ExpireAttachment)
.WithName("ExpireScopeAttachment")
.WithSummary("Expire a scope attachment immediately.")
.WithDescription("Immediately expire a risk profile scope attachment, recording the expiry actor and timestamp without deleting the attachment record. Expired attachments are excluded from scope resolution but remain queryable for audit purposes.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyEdit))
.Produces<ScopeAttachmentResponse>(StatusCodes.Status200OK)
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
group.MapGet("/attachments", ListAttachments)
.WithName("ListScopeAttachments")
.WithSummary("List scope attachments with optional filtering.")
.WithDescription("List risk profile scope attachments with optional filtering by scope type, scope ID, profile ID, and expiry status. Supports pagination via the limit parameter. Used by the console and scope resolution pipeline to discover active bindings.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead))
.Produces<ScopeAttachmentListResponse>(StatusCodes.Status200OK);
group.MapPost("/resolve", ResolveScope)
.WithName("ResolveScope")
.WithSummary("Resolve the effective risk profile for a given scope selector.")
.WithDescription("Resolve the highest-priority active risk profile attachment for a given scope selector, walking the scope hierarchy (component -> environment -> project -> organization) until an active attachment is found. Used by the evaluation pipeline to determine which profile governs a specific component.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead))
.Produces<ScopeResolutionResponse>(StatusCodes.Status200OK);
group.MapGet("/{scopeType}/{scopeId}/attachments", GetScopeAttachments)
.WithName("GetScopeAttachments")
.WithSummary("Get all attachments for a specific scope.")
.WithDescription("Retrieve all risk profile attachments bound to a specific scope identified by type and ID, including both active and expired attachments, for use in scope management UIs and audit reviews.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead))
.Produces<ScopeAttachmentListResponse>(StatusCodes.Status200OK);
return endpoints;

View File

@@ -1,4 +1,6 @@
using Microsoft.AspNetCore.Mvc;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Policy.Engine.AirGap;
namespace StellaOps.Policy.Engine.Endpoints;
@@ -14,26 +16,26 @@ public static class SealedModeEndpoints
group.MapPost("/seal", SealAsync)
.WithName("AirGap.Seal")
.WithDescription("Seal the environment")
.RequireAuthorization(policy => policy.RequireClaim("scope", "airgap:seal"))
.WithDescription("Transition the environment to sealed mode, enforcing air-gap posture by locking out feed updates and external imports until explicitly unsealed. Requires the airgap:seal scope and records the seal operation for the air-gap audit log.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.AirgapSeal))
.ProducesProblem(StatusCodes.Status400BadRequest)
.ProducesProblem(StatusCodes.Status500InternalServerError);
group.MapPost("/unseal", UnsealAsync)
.WithName("AirGap.Unseal")
.WithDescription("Unseal the environment")
.RequireAuthorization(policy => policy.RequireClaim("scope", "airgap:seal"))
.WithDescription("Exit sealed mode and restore normal air-gap posture, allowing feed updates and bundle imports to resume. Requires the airgap:seal scope; the unseal event is recorded in the air-gap audit log.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.AirgapSeal))
.ProducesProblem(StatusCodes.Status500InternalServerError);
group.MapGet("/status", GetStatusAsync)
.WithName("AirGap.GetStatus")
.WithDescription("Get sealed-mode status")
.RequireAuthorization(policy => policy.RequireClaim("scope", "airgap:status:read"));
.WithDescription("Retrieve the current sealed-mode status for the tenant, including whether the environment is sealed, the time anchor age, and any active enforcement flags that affect bundle import and feed update operations.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.AirgapStatusRead));
group.MapPost("/verify", VerifyBundleAsync)
.WithName("AirGap.VerifyBundle")
.WithDescription("Verify a bundle against trust roots")
.RequireAuthorization(policy => policy.RequireClaim("scope", "airgap:verify"))
.WithDescription("Verify a policy pack or risk profile bundle against the configured trust roots, checking cryptographic signatures and integrity. Returns 422 Unprocessable Entity when the bundle is structurally valid but fails signature verification.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.AirgapSeal))
.ProducesProblem(StatusCodes.Status400BadRequest)
.ProducesProblem(StatusCodes.Status422UnprocessableEntity);

View File

@@ -1,4 +1,6 @@
using Microsoft.AspNetCore.Mvc;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Policy.Engine.Snapshots;
namespace StellaOps.Policy.Engine.Endpoints;
@@ -8,13 +10,19 @@ public static class SnapshotEndpoint
public static IEndpointRouteBuilder MapSnapshots(this IEndpointRouteBuilder routes)
{
routes.MapPost("/policy/snapshots", CreateAsync)
.WithName("PolicyEngine.Snapshots.Create");
.WithName("PolicyEngine.Snapshots.Create")
.WithDescription("Create a new in-memory policy evaluation snapshot from a component manifest, capturing the component graph, PURL set, and applicable advisory signals at a point in time for subsequent policy decision evaluation.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyEdit));
routes.MapGet("/policy/snapshots", ListAsync)
.WithName("PolicyEngine.Snapshots.List");
.WithName("PolicyEngine.Snapshots.List")
.WithDescription("List all active in-memory policy snapshots for a given tenant, returning snapshot identifiers, creation timestamps, and summary component counts for use in batch evaluation workflows.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead));
routes.MapGet("/policy/snapshots/{snapshotId}", GetAsync)
.WithName("PolicyEngine.Snapshots.Get");
.WithName("PolicyEngine.Snapshots.Get")
.WithDescription("Retrieve a specific in-memory policy evaluation snapshot by identifier, including its full component graph, resolved advisory signals, and any cached partial evaluation state.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead));
return routes;
}

View File

@@ -2,6 +2,8 @@ using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration;
using System.Threading;
using System.Threading.Tasks;
@@ -16,14 +18,20 @@ public static class SnapshotEndpoints
group.MapGet("/{snapshotId}/export", HandleExportSnapshotAsync)
.WithName("ExportSnapshot")
.WithDescription("Export a policy evaluation snapshot as a ZIP archive containing component manifests, resolved advisory signals, and evidence hashes. Supports optional export level filtering to control the verbosity of included evidence data.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead))
.Produces(StatusCodes.Status200OK, contentType: "application/zip");
group.MapPost("/{snapshotId}/seal", HandleSealSnapshotAsync)
.WithName("SealSnapshot")
.WithDescription("Seal a policy evaluation snapshot by computing and recording a cryptographic signature over its contents, freezing the snapshot state for deterministic replay and tamper-evident audit preservation.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyEdit))
.Produces(StatusCodes.Status200OK);
group.MapGet("/{snapshotId}/diff", HandleGetDiffAsync)
.WithName("GetSnapshotDiff")
.WithDescription("Compute a structural diff between a snapshot and its predecessor, returning counts and details of added, removed, and modified components and advisory signals. Used by the release console to surface what changed between successive evaluation runs.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead))
.Produces(StatusCodes.Status200OK);
return group;

View File

@@ -1,4 +1,6 @@
using Microsoft.AspNetCore.Mvc;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Policy.Engine.AirGap;
namespace StellaOps.Policy.Engine.Endpoints;
@@ -14,21 +16,23 @@ public static class StalenessEndpoints
group.MapGet("/status", GetStalenessStatusAsync)
.WithName("AirGap.GetStalenessStatus")
.WithDescription("Get staleness signal status for health monitoring");
.WithDescription("Retrieve the current staleness signal status for the tenant including time-anchor age, breach indicators, and warning flags. Returns HTTP 503 when a staleness breach is active, enabling health monitoring systems to detect and alert on feed-gap conditions.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.AirgapStatusRead));
group.MapGet("/fallback", GetFallbackStatusAsync)
.WithName("AirGap.GetFallbackStatus")
.WithDescription("Get fallback mode status and configuration");
.WithDescription("Retrieve the current fallback mode activation state and configuration for the tenant, indicating whether evaluation decisions are being served from cached or degraded policy state due to feed staleness.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.AirgapStatusRead));
group.MapPost("/evaluate", EvaluateStalenessAsync)
.WithName("AirGap.EvaluateStaleness")
.WithDescription("Trigger staleness evaluation and signaling")
.RequireAuthorization(policy => policy.RequireClaim("scope", "airgap:status:read"));
.WithDescription("Trigger an immediate staleness evaluation cycle for the tenant, re-computing the time-anchor age against configured thresholds and emitting any required breach or warning signals. Returns the post-evaluation staleness signal status.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.AirgapStatusRead));
group.MapPost("/recover", SignalRecoveryAsync)
.WithName("AirGap.SignalRecovery")
.WithDescription("Signal staleness recovery after time anchor refresh")
.RequireAuthorization(policy => policy.RequireClaim("scope", "airgap:seal"));
.WithDescription("Signal staleness recovery for the tenant after a successful time-anchor refresh, clearing active breach and warning states. Requires the airgap:seal scope as recovery operations affect sealed-mode enforcement posture.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.AirgapSeal));
return routes;
}

View File

@@ -1,4 +1,6 @@
using Microsoft.AspNetCore.Mvc;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Policy.Engine.TrustWeighting;
namespace StellaOps.Policy.Engine.Endpoints;
@@ -8,13 +10,19 @@ public static class TrustWeightingEndpoint
public static IEndpointRouteBuilder MapTrustWeighting(this IEndpointRouteBuilder routes)
{
routes.MapGet("/policy/trust-weighting", GetAsync)
.WithName("PolicyEngine.TrustWeighting.Get");
.WithName("PolicyEngine.TrustWeighting.Get")
.WithDescription("Retrieve the current active trust-weighting profile, including per-signal weight assignments and the profile hash used for deterministic scoring reproducibility.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead));
routes.MapPut("/policy/trust-weighting", PutAsync)
.WithName("PolicyEngine.TrustWeighting.Put");
.WithName("PolicyEngine.TrustWeighting.Put")
.WithDescription("Replace the active trust-weighting profile with a new set of signal weights. The new weights take effect immediately for subsequent evaluations and trigger a profile hash rotation.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyEdit));
routes.MapGet("/policy/trust-weighting/preview", PreviewAsync)
.WithName("PolicyEngine.TrustWeighting.Preview");
.WithName("PolicyEngine.TrustWeighting.Preview")
.WithDescription("Preview the current trust-weighting profile alongside an optional overlay hash to verify how a proposed overlay would interact with existing signal weights before applying.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead));
return routes;
}

View File

@@ -1,5 +1,8 @@
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Policy.Engine.Tenancy;
using StellaOps.Policy.Unknowns.Models;
using StellaOps.Policy.Unknowns.Repositories;
using StellaOps.Policy.Unknowns.Services;
@@ -8,40 +11,51 @@ namespace StellaOps.Policy.Engine.Endpoints;
/// <summary>
/// API endpoints for managing the Unknowns Registry.
/// POL-TEN-03: Tenant enforcement via ITenantContextAccessor, replacing ad-hoc ResolveTenantId.
/// </summary>
internal static class UnknownsEndpoints
{
public static IEndpointRouteBuilder MapUnknowns(this IEndpointRouteBuilder endpoints)
{
var group = endpoints.MapGroup("/api/v1/policy/unknowns")
.RequireAuthorization()
.WithTags("Unknowns Registry");
.WithTags("Unknowns Registry")
.RequireTenantContext();
group.MapGet(string.Empty, ListUnknowns)
.WithName("ListUnknowns")
.WithSummary("List unknowns with optional band filtering.")
.WithDescription("List unknown entries from the tenant's unknowns registry with optional band filtering (hot, warm, cold). When no band is specified, results are returned in priority order across all bands. Each item includes a short reason code, remediation hint, evidence references, and optional conflict and trigger metadata.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead))
.Produces<UnknownsListResponse>(StatusCodes.Status200OK);
group.MapGet("/summary", GetSummary)
.WithName("GetUnknownsSummary")
.WithSummary("Get summary counts of unknowns by band.")
.WithDescription("Retrieve aggregate counts of unknown entries broken down by band (hot, warm, cold, resolved) for the authenticated tenant, suitable for rendering compliance health indicators and tracking the overall remediation progress of the unknowns backlog.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead))
.Produces<UnknownsSummaryResponse>(StatusCodes.Status200OK);
group.MapGet("/{id:guid}", GetById)
.WithName("GetUnknownById")
.WithSummary("Get a specific unknown by ID.")
.WithDescription("Retrieve the full record of a specific unknown entry by its UUID, including band assignment, uncertainty and exploit pressure scores, evidence references, fingerprint ID, reanalysis triggers, next-action hints, and any conflict information recorded during determinization.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead))
.Produces<UnknownResponse>(StatusCodes.Status200OK)
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
group.MapPost("/{id:guid}/escalate", Escalate)
.WithName("EscalateUnknown")
.WithSummary("Escalate an unknown and trigger a rescan.")
.WithDescription("Promote an unknown entry to the hot band and queue a rescan job to gather updated signals. Used by operators when an unknown requires immediate attention before the next scheduled analysis cycle.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyOperate))
.Produces<UnknownResponse>(StatusCodes.Status200OK)
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
group.MapPost("/{id:guid}/resolve", Resolve)
.WithName("ResolveUnknown")
.WithSummary("Mark an unknown as resolved with a reason.")
.WithDescription("Mark an unknown entry as resolved by recording a mandatory resolution reason, closing it out of the active budget and open-unknowns counts. The resolution reason is persisted for audit purposes.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyOperate))
.Produces<UnknownResponse>(StatusCodes.Status200OK)
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
@@ -53,13 +67,14 @@ internal static class UnknownsEndpoints
[FromQuery] string? band,
[FromQuery] int limit = 100,
[FromQuery] int offset = 0,
ITenantContextAccessor tenantAccessor = null!,
IUnknownsRepository repository = null!,
IRemediationHintsRegistry hintsRegistry = null!,
CancellationToken ct = default)
{
var tenantId = ResolveTenantId(httpContext);
if (tenantId == Guid.Empty)
return TypedResults.Problem("Tenant ID is required.", statusCode: StatusCodes.Status400BadRequest);
// POL-TEN-03: Use middleware-resolved tenant instead of ad-hoc header parsing.
if (!TryResolveTenantGuid(tenantAccessor, out var tenantId))
return TypedResults.Problem("Tenant ID must be a valid GUID.", statusCode: StatusCodes.Status400BadRequest);
IReadOnlyList<Unknown> unknowns;
@@ -86,12 +101,12 @@ internal static class UnknownsEndpoints
private static async Task<Results<Ok<UnknownsSummaryResponse>, ProblemHttpResult>> GetSummary(
HttpContext httpContext,
ITenantContextAccessor tenantAccessor = null!,
IUnknownsRepository repository = null!,
CancellationToken ct = default)
{
var tenantId = ResolveTenantId(httpContext);
if (tenantId == Guid.Empty)
return TypedResults.Problem("Tenant ID is required.", statusCode: StatusCodes.Status400BadRequest);
if (!TryResolveTenantGuid(tenantAccessor, out var tenantId))
return TypedResults.Problem("Tenant ID must be a valid GUID.", statusCode: StatusCodes.Status400BadRequest);
var summary = await repository.GetSummaryAsync(tenantId, ct);
@@ -106,13 +121,13 @@ internal static class UnknownsEndpoints
private static async Task<Results<Ok<UnknownResponse>, ProblemHttpResult>> GetById(
HttpContext httpContext,
Guid id,
ITenantContextAccessor tenantAccessor = null!,
IUnknownsRepository repository = null!,
IRemediationHintsRegistry hintsRegistry = null!,
CancellationToken ct = default)
{
var tenantId = ResolveTenantId(httpContext);
if (tenantId == Guid.Empty)
return TypedResults.Problem("Tenant ID is required.", statusCode: StatusCodes.Status400BadRequest);
if (!TryResolveTenantGuid(tenantAccessor, out var tenantId))
return TypedResults.Problem("Tenant ID must be a valid GUID.", statusCode: StatusCodes.Status400BadRequest);
var unknown = await repository.GetByIdAsync(tenantId, id, ct);
@@ -126,14 +141,14 @@ internal static class UnknownsEndpoints
HttpContext httpContext,
Guid id,
[FromBody] EscalateUnknownRequest request,
ITenantContextAccessor tenantAccessor = null!,
IUnknownsRepository repository = null!,
IUnknownRanker ranker = null!,
IRemediationHintsRegistry hintsRegistry = null!,
CancellationToken ct = default)
{
var tenantId = ResolveTenantId(httpContext);
if (tenantId == Guid.Empty)
return TypedResults.Problem("Tenant ID is required.", statusCode: StatusCodes.Status400BadRequest);
if (!TryResolveTenantGuid(tenantAccessor, out var tenantId))
return TypedResults.Problem("Tenant ID must be a valid GUID.", statusCode: StatusCodes.Status400BadRequest);
var unknown = await repository.GetByIdAsync(tenantId, id, ct);
@@ -165,13 +180,13 @@ internal static class UnknownsEndpoints
HttpContext httpContext,
Guid id,
[FromBody] ResolveUnknownRequest request,
ITenantContextAccessor tenantAccessor = null!,
IUnknownsRepository repository = null!,
IRemediationHintsRegistry hintsRegistry = null!,
CancellationToken ct = default)
{
var tenantId = ResolveTenantId(httpContext);
if (tenantId == Guid.Empty)
return TypedResults.Problem("Tenant ID is required.", statusCode: StatusCodes.Status400BadRequest);
if (!TryResolveTenantGuid(tenantAccessor, out var tenantId))
return TypedResults.Problem("Tenant ID must be a valid GUID.", statusCode: StatusCodes.Status400BadRequest);
if (string.IsNullOrWhiteSpace(request.Reason))
return TypedResults.Problem("Resolution reason is required.", statusCode: StatusCodes.Status400BadRequest);
@@ -186,24 +201,21 @@ internal static class UnknownsEndpoints
return TypedResults.Ok(new UnknownResponse(ToDto(unknown!, hintsRegistry)));
}
private static Guid ResolveTenantId(HttpContext context)
/// <summary>
/// POL-TEN-03: Convert middleware-resolved tenant string to Guid for repository compatibility.
/// The middleware has already validated tenant presence; this converts the string tenant ID to the
/// Guid format expected by the IUnknownsRepository contract.
/// </summary>
private static bool TryResolveTenantGuid(ITenantContextAccessor accessor, out Guid tenantId)
{
// First check header
if (context.Request.Headers.TryGetValue("X-Tenant-Id", out var tenantHeader) &&
!string.IsNullOrWhiteSpace(tenantHeader) &&
Guid.TryParse(tenantHeader.ToString(), out var headerTenantId))
var tenantContext = accessor.TenantContext;
if (tenantContext is not null && Guid.TryParse(tenantContext.TenantId, out tenantId))
{
return headerTenantId;
return true;
}
// Then check claims
var tenantClaim = context.User?.FindFirst("tenant_id")?.Value;
if (!string.IsNullOrEmpty(tenantClaim) && Guid.TryParse(tenantClaim, out var claimTenantId))
{
return claimTenantId;
}
return Guid.Empty;
tenantId = Guid.Empty;
return false;
}
private static UnknownDto ToDto(Unknown u, IRemediationHintsRegistry hintsRegistry)

View File

@@ -1,6 +1,7 @@
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Policy.Engine.Attestation;
namespace StellaOps.Policy.Engine.Endpoints;
@@ -18,26 +19,30 @@ public static class VerificationPolicyEditorEndpoints
group.MapGet("/metadata", GetEditorMetadata)
.WithName("Attestor.GetEditorMetadata")
.WithSummary("Get editor metadata for verification policy forms")
.RequireAuthorization(policy => policy.RequireClaim("scope", StellaOpsScopes.PolicyRead))
.WithDescription("Retrieve static metadata used to populate verification policy editor forms, including available predicate types, signer algorithm options, and validation rule documentation. Used by the UI to render dynamic form fields without hardcoding policy schema knowledge.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead))
.Produces<VerificationPolicyEditorMetadata>(StatusCodes.Status200OK);
group.MapPost("/validate", ValidatePolicyAsync)
.WithName("Attestor.ValidatePolicy")
.WithSummary("Validate a verification policy without persisting")
.RequireAuthorization(policy => policy.RequireClaim("scope", StellaOpsScopes.PolicyRead))
.WithDescription("Validate a verification policy definition against all structural and semantic rules without persisting it to the store. Returns errors, warnings, and authoring suggestions so policy authors can correct issues in the editor before saving.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead))
.Produces<ValidatePolicyResponse>(StatusCodes.Status200OK);
group.MapGet("/{policyId}", GetPolicyEditorViewAsync)
.WithName("Attestor.GetPolicyEditorView")
.WithSummary("Get a verification policy with editor metadata")
.RequireAuthorization(policy => policy.RequireClaim("scope", StellaOpsScopes.PolicyRead))
.WithDescription("Retrieve a verification policy combined with its current validation state, authoring suggestions, and editor permissions (such as whether the policy can be deleted). Used by the UI editor to render the policy form in its current persisted state.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead))
.Produces<VerificationPolicyEditorView>(StatusCodes.Status200OK)
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
group.MapPost("/clone", ClonePolicyAsync)
.WithName("Attestor.ClonePolicy")
.WithSummary("Clone a verification policy")
.RequireAuthorization(policy => policy.RequireClaim("scope", StellaOpsScopes.PolicyWrite))
.WithDescription("Create a new verification policy by cloning an existing policy, preserving the source definition while assigning a new identifier and optionally overriding the version string. Useful for creating policy variants without manual duplication.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyWrite))
.Produces<VerificationPolicy>(StatusCodes.Status201Created)
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest)
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound)
@@ -46,7 +51,8 @@ public static class VerificationPolicyEditorEndpoints
group.MapPost("/compare", ComparePoliciesAsync)
.WithName("Attestor.ComparePolicies")
.WithSummary("Compare two verification policies")
.RequireAuthorization(policy => policy.RequireClaim("scope", StellaOpsScopes.PolicyRead))
.WithDescription("Compute a field-level diff between two verification policies, listing added, removed, and modified predicate types, signer requirements, key fingerprints, and validity window settings. Used by the policy editor and review workflows to highlight changes before promotion.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead))
.Produces<ComparePoliciesResponse>(StatusCodes.Status200OK)
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);

View File

@@ -1,6 +1,7 @@
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Policy.Engine.Attestation;
namespace StellaOps.Policy.Engine.Endpoints;
@@ -18,7 +19,8 @@ public static class VerificationPolicyEndpoints
group.MapPost("/", CreatePolicyAsync)
.WithName("Attestor.CreatePolicy")
.WithSummary("Create a new verification policy")
.RequireAuthorization(policy => policy.RequireClaim("scope", StellaOpsScopes.PolicyWrite))
.WithDescription("Create a new attestation verification policy specifying accepted predicate types, signer requirements, validity windows, and tenant scope. Returns the created policy record with its assigned identifier for use in attestation verification workflows.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyWrite))
.Produces<VerificationPolicy>(StatusCodes.Status201Created)
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest)
.Produces<ProblemHttpResult>(StatusCodes.Status409Conflict);
@@ -26,27 +28,31 @@ public static class VerificationPolicyEndpoints
group.MapGet("/{policyId}", GetPolicyAsync)
.WithName("Attestor.GetPolicy")
.WithSummary("Get a verification policy by ID")
.RequireAuthorization(policy => policy.RequireClaim("scope", StellaOpsScopes.PolicyRead))
.WithDescription("Retrieve a specific attestation verification policy by its identifier, returning the full policy definition including predicate type filters, signer requirements, and validity window configuration.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead))
.Produces<VerificationPolicy>(StatusCodes.Status200OK)
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
group.MapGet("/", ListPoliciesAsync)
.WithName("Attestor.ListPolicies")
.WithSummary("List verification policies")
.RequireAuthorization(policy => policy.RequireClaim("scope", StellaOpsScopes.PolicyRead))
.WithDescription("List all registered attestation verification policies, optionally filtered by tenant scope. Used by the attestor service and policy console to discover available verification constraints for each tenant context.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead))
.Produces<VerificationPolicyListResponse>(StatusCodes.Status200OK);
group.MapPut("/{policyId}", UpdatePolicyAsync)
.WithName("Attestor.UpdatePolicy")
.WithSummary("Update a verification policy")
.RequireAuthorization(policy => policy.RequireClaim("scope", StellaOpsScopes.PolicyWrite))
.WithDescription("Update the mutable fields of an existing attestation verification policy, including predicate types, signer requirements, validity window, and custom metadata. Preserves the policy identifier and creation timestamp while updating the modification timestamp.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyWrite))
.Produces<VerificationPolicy>(StatusCodes.Status200OK)
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
group.MapDelete("/{policyId}", DeletePolicyAsync)
.WithName("Attestor.DeletePolicy")
.WithSummary("Delete a verification policy")
.RequireAuthorization(policy => policy.RequireClaim("scope", StellaOpsScopes.PolicyWrite))
.WithDescription("Permanently remove an attestation verification policy by identifier. Deleted policies are no longer applied in verification runs; existing attestation records that referenced this policy are not retroactively affected.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyWrite))
.Produces(StatusCodes.Status204NoContent)
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);

View File

@@ -2,6 +2,8 @@ using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration;
using System;
using System.Collections.Generic;
using System.Threading;
@@ -18,7 +20,8 @@ public static class VerifyDeterminismEndpoints
group.MapPost("/determinism", HandleVerifyDeterminismAsync)
.WithName("VerifyDeterminism")
.WithDescription("Verify that a verdict can be deterministically replayed")
.WithDescription("Replay a policy verdict from a stored snapshot and compare its digest against the original to verify deterministic reproducibility. Returns the match type, any field-level differences, and replay duration for audit and compliance evidence.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead))
.Produces<VerificationResult>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest);

View File

@@ -1,4 +1,6 @@
using Microsoft.AspNetCore.Mvc;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Policy.Engine.Violations;
namespace StellaOps.Policy.Engine.Endpoints;
@@ -8,13 +10,19 @@ public static class ViolationEndpoint
public static IEndpointRouteBuilder MapViolations(this IEndpointRouteBuilder routes)
{
routes.MapPost("/policy/violations/events", EmitEventsAsync)
.WithName("PolicyEngine.Violations.Events");
.WithName("PolicyEngine.Violations.Events")
.WithDescription("Emit one or more violation events for a snapshot, recording advisory hits and rule breaches into the in-memory violation log. These events feed downstream severity fusion and conflict detection pipelines.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyEdit));
routes.MapPost("/policy/violations/severity", FuseAsync)
.WithName("PolicyEngine.Violations.Severity");
.WithName("PolicyEngine.Violations.Severity")
.WithDescription("Emit violation events and then run severity fusion for the target snapshot, producing a consolidated worst-case severity rating by merging across all recorded advisory signals and rule outcomes.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyEdit));
routes.MapPost("/policy/violations/conflicts", ConflictsAsync)
.WithName("PolicyEngine.Violations.Conflicts");
.WithName("PolicyEngine.Violations.Conflicts")
.WithDescription("Emit violation events, run severity fusion, and then execute conflict detection for the target snapshot, returning a list of advisory signal conflicts where multiple sources disagree on severity or applicability.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyEdit));
return routes;
}

View File

@@ -1,6 +1,7 @@
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Determinism;
using StellaOps.Policy.Engine.Services;
using StellaOps.Policy.Persistence.Postgres.Models;
@@ -17,49 +18,64 @@ internal static class ViolationEndpoints
public static IEndpointRouteBuilder MapViolationEventsApi(this IEndpointRouteBuilder endpoints)
{
var group = endpoints.MapGroup("/api/policy/violations")
.RequireAuthorization()
.WithTags("Policy Violations");
group.MapGet(string.Empty, ListViolations)
.WithName("ListPolicyViolations")
.WithSummary("List policy violations with optional filters.")
.WithDescription("List persisted policy violation events for the authenticated tenant, with optional filtering by policy ID, severity level, and occurrence timestamp. Defaults to returning critical violations when no filter is specified. Supports pagination via limit and offset parameters.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead))
.Produces<ViolationListResponse>(StatusCodes.Status200OK);
group.MapGet("/{violationId:guid}", GetViolation)
.WithName("GetPolicyViolation")
.WithSummary("Get a specific policy violation by ID.")
.WithDescription("Retrieve the full immutable record of a specific policy violation event by its UUID, including the triggering rule, severity, subject PURL or CVE, details payload, and occurrence timestamp.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead))
.Produces<ViolationResponse>(StatusCodes.Status200OK)
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
group.MapGet("/by-policy/{policyId:guid}", GetViolationsByPolicy)
.WithName("GetPolicyViolationsByPolicy")
.WithSummary("Get violations for a specific policy.")
.WithDescription("Retrieve all policy violation events associated with a specific policy UUID, supporting optional time-range filtering and offset-based pagination for audit trail review of a single policy's violation history.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead))
.Produces<ViolationListResponse>(StatusCodes.Status200OK);
group.MapGet("/by-severity/{severity}", GetViolationsBySeverity)
.WithName("GetPolicyViolationsBySeverity")
.WithSummary("Get violations filtered by severity level.")
.WithDescription("Retrieve policy violation events filtered to a specific severity level (critical, high, medium, low), optionally bounded by a start timestamp, enabling targeted monitoring dashboards and severity-specific alert pipelines.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead))
.Produces<ViolationListResponse>(StatusCodes.Status200OK);
group.MapGet("/by-purl/{purl}", GetViolationsByPurl)
.WithName("GetPolicyViolationsByPurl")
.WithSummary("Get violations for a specific package (by PURL).")
.WithDescription("Retrieve all policy violation events for a specific package identified by its URL-encoded Package URL (PURL), enabling component-centric compliance queries and remediation tracking across all policies that flagged the component.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead))
.Produces<ViolationListResponse>(StatusCodes.Status200OK);
group.MapGet("/stats/by-severity", GetViolationStatsBySeverity)
.WithName("GetPolicyViolationStatsBySeverity")
.WithSummary("Get violation counts grouped by severity.")
.WithDescription("Compute aggregated violation counts grouped by severity level (critical, high, medium, low, none) for a specified time window. Used by compliance dashboards to render severity distribution charts and trend indicators.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead))
.Produces<ViolationStatsResponse>(StatusCodes.Status200OK);
group.MapPost(string.Empty, AppendViolation)
.WithName("AppendPolicyViolation")
.WithSummary("Append a new policy violation event (immutable).")
.WithDescription("Append a single immutable policy violation event to the append-only audit log, recording the triggering policy, rule, severity, subject identifier, and occurrence timestamp. Violation records cannot be modified or deleted after creation.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyEdit))
.Produces<ViolationResponse>(StatusCodes.Status201Created)
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest);
group.MapPost("/batch", AppendViolationBatch)
.WithName("AppendPolicyViolationBatch")
.WithSummary("Append multiple policy violation events in a batch.")
.WithDescription("Atomically append a batch of immutable policy violation events to the audit log in a single request, reducing per-event overhead during high-throughput evaluation runs. Returns the count of successfully appended records.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyEdit))
.Produces<ViolationBatchResponse>(StatusCodes.Status201Created)
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest);

View File

@@ -30,6 +30,7 @@ using StellaOps.PolicyDsl;
using System.IO;
using System.Threading.RateLimiting;
using StellaOps.Policy.Engine.Tenancy;
using StellaOps.Router.AspNet;
var builder = WebApplication.CreateBuilder(args);
@@ -242,6 +243,7 @@ builder.Services.AddVexDecisionEmitter(); // POLICY-VEX-401-006
builder.Services.AddStellaOpsCrypto();
builder.Services.AddHttpContextAccessor();
builder.Services.AddTenantContext(builder.Configuration);
builder.Services.AddStellaOpsCors(builder.Environment, builder.Configuration);
builder.Services.AddRouting(options => options.LowercaseUrls = true);
builder.Services.AddProblemDetails();
@@ -356,6 +358,7 @@ app.LogStellaOpsLocalHostname("policy-engine");
app.UseStellaOpsCors();
app.UseAuthentication();
app.UseAuthorization();
app.UseTenantContext(); // POL-TEN-01: tenant enforcement middleware
app.TryUseStellaRouter(routerEnabled);
if (rateLimitOptions.Enabled)
@@ -368,7 +371,8 @@ app.MapGet("/readyz", (PolicyEngineStartupDiagnostics diagnostics) =>
diagnostics.IsReady
? Results.Ok(new { status = "ready" })
: Results.StatusCode(StatusCodes.Status503ServiceUnavailable))
.WithName("Readiness");
.WithName("Readiness")
.AllowAnonymous();
app.MapGet("/", () => Results.Redirect("/healthz"));

View File

@@ -85,21 +85,34 @@ public sealed partial class TenantContextMiddleware
private TenantValidationResult ValidateTenantContext(HttpContext context)
{
// Extract tenant header
// Extract tenant: header first, then canonical claim, then legacy claim fallback.
var tenantHeader = context.Request.Headers[TenantContextConstants.TenantHeader].FirstOrDefault();
// POL-TEN-01: Fall back to canonical stellaops:tenant claim if header is absent.
if (string.IsNullOrWhiteSpace(tenantHeader))
{
tenantHeader = context.User?.FindFirst(TenantContextConstants.CanonicalTenantClaim)?.Value;
}
// POL-TEN-01: Fall back to legacy "tid" claim for backwards compatibility.
if (string.IsNullOrWhiteSpace(tenantHeader))
{
tenantHeader = context.User?.FindFirst(TenantContextConstants.LegacyTenantClaim)?.Value;
}
if (string.IsNullOrWhiteSpace(tenantHeader))
{
if (_options.RequireTenantHeader)
{
_logger.LogWarning(
"Missing required {Header} header for {Path}",
"Missing required tenant context (header {Header} or claim {Claim}) for {Path}",
TenantContextConstants.TenantHeader,
TenantContextConstants.CanonicalTenantClaim,
context.Request.Path);
return TenantValidationResult.Failure(
TenantContextConstants.MissingTenantHeaderErrorCode,
$"The {TenantContextConstants.TenantHeader} header is required.");
$"Tenant context is required. Provide the {TenantContextConstants.TenantHeader} header or a token with the {TenantContextConstants.CanonicalTenantClaim} claim.");
}
// Use default tenant ID when header is not required

View File

@@ -36,6 +36,17 @@ public static class TenantContextConstants
/// </summary>
public const string DefaultTenantId = "public";
/// <summary>
/// Canonical JWT claim for tenant ID (stellaops:tenant).
/// Per ADR-002 and StellaOpsClaimTypes.Tenant.
/// </summary>
public const string CanonicalTenantClaim = "stellaops:tenant";
/// <summary>
/// Legacy JWT claim for tenant ID (backwards compatibility).
/// </summary>
public const string LegacyTenantClaim = "tid";
/// <summary>
/// Error code for missing tenant header (deterministic).
/// </summary>

View File

@@ -132,7 +132,9 @@ public static class DeltasEndpoints
});
}
})
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRun));
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRun))
.WithName("ComputeDelta")
.WithDescription("Compute a security state delta between a baseline snapshot and a target snapshot for a given artifact digest. Selects the baseline automatically using the configured strategy (last-approved, previous-build, production-deployed, or branch-base) unless an explicit baseline snapshot ID is provided. Returns a delta summary and driver count for downstream evaluation.");
// GET /api/policy/deltas/{deltaId} - Get a delta by ID
deltas.MapGet("/{deltaId}", async Task<IResult>(
@@ -162,7 +164,9 @@ public static class DeltasEndpoints
return Results.Ok(DeltaResponse.FromModel(delta));
})
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead));
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead))
.WithName("GetDelta")
.WithDescription("Retrieve a previously computed security state delta by its ID from the in-memory cache. Deltas are retained for 30 minutes after computation. Returns the full driver list, baseline and target snapshot IDs, and risk summary.");
// POST /api/policy/deltas/{deltaId}/evaluate - Evaluate delta and get verdict
deltas.MapPost("/{deltaId}/evaluate", async Task<IResult>(
@@ -237,7 +241,9 @@ public static class DeltasEndpoints
return Results.Ok(DeltaVerdictResponse.FromModel(verdict));
})
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRun));
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRun))
.WithName("EvaluateDelta")
.WithDescription("Evaluate a previously computed delta and produce a gate verdict. Classifies each driver as blocking or advisory based on severity and type (new-reachable-cve, lost-vex-coverage, new-policy-violation), applies any supplied exception IDs, and returns a recommended gate action with risk points and remediation recommendations.");
// GET /api/policy/deltas/{deltaId}/attestation - Get signed attestation
deltas.MapGet("/{deltaId}/attestation", async Task<IResult>(
@@ -309,7 +315,9 @@ public static class DeltasEndpoints
});
}
})
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead));
.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)

View File

@@ -30,55 +30,55 @@ public static class ExceptionApprovalEndpoints
exceptions.MapPost("/request", CreateApprovalRequestAsync)
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.ExceptionsRequest))
.WithName("CreateExceptionApprovalRequest")
.WithDescription("Create a new exception approval request");
.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("Get an exception approval request by ID");
.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");
.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 pending exception approvals for the current user");
.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("Approve an exception request");
.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 an exception request with a reason");
.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 exception request (requestor only)");
.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("Get the audit trail for an exception approval request");
.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("Get exception approval rules for the tenant");
.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.");
}
// ========================================================================

View File

@@ -64,7 +64,9 @@ public static class ExceptionEndpoints
Limit = filter.Limit
});
})
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead));
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead))
.WithName("ListExceptions")
.WithDescription("List policy exceptions with optional filtering by status, type, vulnerability ID, PURL pattern, environment, or owner. Returns paginated results including per-item status, scope, and lifecycle timestamps for exception management dashboards and compliance reporting.");
// GET /api/policy/exceptions/counts - Get exception counts
exceptions.MapGet("/counts", async Task<IResult>(
@@ -83,7 +85,9 @@ public static class ExceptionEndpoints
ExpiringSoon = counts.ExpiringSoon
});
})
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead));
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead))
.WithName("GetExceptionCounts")
.WithDescription("Return aggregate counts of exceptions by lifecycle status (proposed, approved, active, expired, revoked) plus an expiring-soon indicator. Used by governance dashboards to give operators a quick view of the exception portfolio health without fetching the full list.");
// GET /api/policy/exceptions/{id} - Get exception by ID
exceptions.MapGet("/{id}", async Task<IResult>(
@@ -103,7 +107,9 @@ public static class ExceptionEndpoints
}
return Results.Ok(ToDto(exception));
})
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead));
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead))
.WithName("GetException")
.WithDescription("Retrieve the full details of a single exception by its identifier, including scope, rationale, evidence references, compensating controls, and lifecycle timestamps.");
// GET /api/policy/exceptions/{id}/history - Get exception history
exceptions.MapGet("/{id}/history", async Task<IResult>(
@@ -128,7 +134,9 @@ public static class ExceptionEndpoints
}).ToList()
});
})
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead));
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead))
.WithName("GetExceptionHistory")
.WithDescription("Retrieve the ordered audit history of an exception, including every status transition, the actor who performed each action, and descriptive event entries. Supports compliance reviews and traceability of the full exception lifecycle from proposal through resolution.");
// POST /api/policy/exceptions - Create exception
exceptions.MapPost(string.Empty, async Task<IResult>(
@@ -204,7 +212,9 @@ public static class ExceptionEndpoints
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));
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyAuthor))
.WithName("CreateException")
.WithDescription("Create a new policy exception in the proposed state. Validates that the expiry is in the future and does not exceed one year, captures the requesting actor from the authenticated identity, and records the scope (artifact digest, PURL pattern, vulnerability ID, or policy rule), reason code, rationale, and compensating controls.");
// PUT /api/policy/exceptions/{id} - Update exception
exceptions.MapPut("/{id}", async Task<IResult>(
@@ -253,7 +263,9 @@ public static class ExceptionEndpoints
updated, ExceptionEventType.Updated, actorId, "Exception updated", clientInfo, cancellationToken);
return Results.Ok(ToDto(result));
})
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyAuthor));
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyAuthor))
.WithName("UpdateException")
.WithDescription("Update the mutable fields of an existing exception (rationale, evidence references, compensating controls, ticket reference, and metadata). Cannot update expired or revoked exceptions. Version is incremented and the updated-at timestamp is refreshed on every successful update.");
// POST /api/policy/exceptions/{id}/approve - Approve exception
exceptions.MapPost("/{id}/approve", async Task<IResult>(
@@ -307,7 +319,9 @@ public static class ExceptionEndpoints
updated, ExceptionEventType.Approved, actorId, request?.Comment ?? "Exception approved", clientInfo, cancellationToken);
return Results.Ok(ToDto(result));
})
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyOperate));
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyOperate))
.WithName("ApproveException")
.WithDescription("Approve a proposed exception and transition it to the approved state. Enforces separation-of-duty by rejecting self-approval (approver must differ from the requester). Multiple approvers may be recorded before the exception is activated.");
// POST /api/policy/exceptions/{id}/activate - Activate approved exception
exceptions.MapPost("/{id}/activate", async Task<IResult>(
@@ -347,7 +361,9 @@ public static class ExceptionEndpoints
updated, ExceptionEventType.Activated, actorId, "Exception activated", clientInfo, cancellationToken);
return Results.Ok(ToDto(result));
})
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyOperate));
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyOperate))
.WithName("ActivateException")
.WithDescription("Transition an approved exception to the active state, making it eligible for use in policy evaluation. Only exceptions in the approved state may be activated.");
// POST /api/policy/exceptions/{id}/extend - Extend expiry
exceptions.MapPost("/{id}/extend", async Task<IResult>(
@@ -398,7 +414,9 @@ public static class ExceptionEndpoints
updated, ExceptionEventType.Extended, actorId, request.Reason, clientInfo, cancellationToken);
return Results.Ok(ToDto(result));
})
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyOperate));
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyOperate))
.WithName("ExtendException")
.WithDescription("Extend the expiry date of an active exception. The new expiry must be later than the current expiry. Used when a scheduled fix or mitigation requires additional time beyond the original exception window.");
// DELETE /api/policy/exceptions/{id} - Revoke exception
exceptions.MapDelete("/{id}", async Task<IResult>(
@@ -439,7 +457,9 @@ public static class ExceptionEndpoints
updated, ExceptionEventType.Revoked, actorId, request?.Reason ?? "Exception revoked", clientInfo, cancellationToken);
return Results.Ok(ToDto(result));
})
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyOperate));
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyOperate))
.WithName("RevokeException")
.WithDescription("Revoke an exception before its natural expiry, recording an optional revocation reason and transitioning the exception to the revoked terminal state. Cannot revoke exceptions that are already expired or revoked.");
// GET /api/policy/exceptions/expiring - Get exceptions expiring soon
exceptions.MapGet("/expiring", async Task<IResult>(
@@ -451,7 +471,9 @@ public static class ExceptionEndpoints
var results = await repository.GetExpiringAsync(horizon, cancellationToken);
return Results.Ok(results.Select(ToDto).ToList());
})
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead));
.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

View File

@@ -189,7 +189,7 @@ public static class GateEndpoints
})
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRun))
.WithName("EvaluateGate")
.WithDescription("Evaluate CI/CD gate for an image digest and baseline reference");
.WithDescription("Evaluate the CI/CD release gate for a container image by comparing it against a baseline snapshot. Resolves the baseline using a configurable strategy (last-approved, previous-build, production-deployed, or branch-base), computes the security state delta, runs gate rules against the delta context, and returns a pass/warn/block decision with exit codes. If an override justification is supplied on a non-blocking verdict, a bypass audit record is created. Returns HTTP 403 when the gate blocks the release.");
// GET /api/v1/policy/gate/decision/{decisionId} - Get a previous decision
gates.MapGet("/decision/{decisionId}", async Task<IResult>(
@@ -222,13 +222,14 @@ public static class GateEndpoints
})
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead))
.WithName("GetGateDecision")
.WithDescription("Retrieve a previous gate evaluation decision by ID");
.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");
.WithDescription("Health check for the gate evaluation service")
.AllowAnonymous();
}
private static async Task<BaselineSelectionResult> ResolveBaselineAsync(

View File

@@ -11,6 +11,8 @@ 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.Policy.Gates;
using StellaOps.Policy.Persistence.Postgres.Repositories;
using System.Text.Json.Serialization;
@@ -34,34 +36,40 @@ public static class GatesEndpoints
.WithTags("Gates");
group.MapGet("/{bomRef}", GetGateStatus)
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead))
.WithName("GetGateStatus")
.WithSummary("Get gate check result for a component")
.WithDescription("Returns the current unknowns state and gate decision for a BOM reference.");
.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("Performs a fresh gate check with optional verdict.");
.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("Requests approval to bypass blocking unknowns.");
.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("Returns paginated list of historical gate decisions for audit and debugging.");
.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("Returns full details of a specific gate decision.");
.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("Exports gate decision in JUnit, SARIF, or JSON format for CI/CD integration.");
.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;
}

View File

@@ -4,6 +4,8 @@
using Microsoft.AspNetCore.Mvc;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration;
using System.Collections.Concurrent;
using System.Globalization;
using System.Text.Json;
@@ -31,66 +33,81 @@ public static class GovernanceEndpoints
// Sealed Mode endpoints
governance.MapGet("/sealed-mode/status", GetSealedModeStatusAsync)
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.AirgapStatusRead))
.WithName("GetSealedModeStatus")
.WithDescription("Get sealed mode status");
.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 sealed mode overrides");
.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("Toggle sealed mode on/off");
.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 sealed mode override");
.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 a sealed mode override");
.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");
.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("Get a risk profile by ID");
.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");
.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 a risk profile");
.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("Delete a risk profile");
.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("Activate a risk profile");
.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("Deprecate a risk profile");
.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 risk profile");
.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.");
// Audit endpoints
governance.MapGet("/audit/events", GetAuditEventsAsync)
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyAudit))
.WithName("GetGovernanceAuditEvents")
.WithDescription("Get governance audit events");
.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("Get a specific audit event");
.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();

View File

@@ -22,23 +22,27 @@ internal static class RegistryWebhookEndpoints
public static IEndpointRouteBuilder MapRegistryWebhooks(this IEndpointRouteBuilder endpoints)
{
var group = endpoints.MapGroup("/api/v1/webhooks/registry")
.WithTags("Registry Webhooks");
.WithTags("Registry Webhooks")
.AllowAnonymous();
group.MapPost("/docker", HandleDockerRegistryWebhook)
.WithName("DockerRegistryWebhook")
.WithSummary("Handle Docker Registry v2 webhook events")
.WithDescription("Receive Docker Registry v2 notification events and enqueue a gate evaluation job for each push event that includes a valid image digest. Returns a 202 Accepted response with the list of queued job IDs that can be polled for evaluation status.")
.Produces<WebhookAcceptedResponse>(StatusCodes.Status202Accepted)
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest);
group.MapPost("/harbor", HandleHarborWebhook)
.WithName("HarborWebhook")
.WithSummary("Handle Harbor registry webhook events")
.WithDescription("Receive Harbor registry webhook events and enqueue a gate evaluation job for each PUSH_ARTIFACT or pushImage event that contains a resource with a valid digest. Non-push event types are silently acknowledged without queuing any jobs.")
.Produces<WebhookAcceptedResponse>(StatusCodes.Status202Accepted)
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest);
group.MapPost("/generic", HandleGenericWebhook)
.WithName("GenericRegistryWebhook")
.WithSummary("Handle generic registry webhook events with image digest")
.WithDescription("Receive a generic registry webhook payload containing an image digest and enqueue a single gate evaluation job. Supports any registry that can POST a JSON body with imageDigest, repository, tag, and optional baselineRef fields.")
.Produces<WebhookAcceptedResponse>(StatusCodes.Status202Accepted)
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest);

View File

@@ -150,13 +150,14 @@ public static class ScoreGateEndpoints
})
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRun))
.WithName("EvaluateScoreGate")
.WithDescription("Evaluate score-based CI/CD gate for a finding");
.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");
.WithDescription("Health check for the score-based gate evaluation service")
.AllowAnonymous();
// POST /api/v1/gate/evaluate-batch - Batch evaluation for multiple findings
gates.MapPost("/evaluate-batch", async Task<IResult>(
@@ -261,7 +262,7 @@ public static class ScoreGateEndpoints
})
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRun))
.WithName("EvaluateScoreGateBatch")
.WithDescription("Batch evaluate score-based CI/CD gates for multiple findings");
.WithDescription("Batch evaluate score-based CI/CD gates for up to 500 findings in a single request using configurable parallelism. Applies the EWS formula to each finding, produces a per-finding action (pass/warn/block), and returns an aggregate summary with overall action, exit code, and optional per-finding verdict bundles. Supports fail-fast mode to stop processing on the first blocked finding.");
}
private static async Task<List<ScoreGateBatchDecision>> EvaluateBatchAsync(

View File

@@ -328,7 +328,8 @@ app.TryUseStellaRouter(routerEnabled);
app.MapHealthChecks("/healthz");
app.MapGet("/readyz", () => Results.Ok(new { status = "ready" }))
.WithName("Readiness");
.WithName("Readiness")
.AllowAnonymous();
app.MapGet("/", () => Results.Redirect("/healthz"));

View File

@@ -6,7 +6,21 @@ Maintain the Policy module persistence layer and PostgreSQL repositories.
## Required Reading
- docs/modules/policy/architecture.md
- docs/modules/platform/architecture-overview.md
- docs/db/EF_CORE_MODEL_GENERATION_STANDARDS.md
- docs/db/EF_CORE_RUNTIME_CUTOVER_STRATEGY.md
## DAL Technology
- **Primary:** EF Core v10 via `PolicyDbContext` for standard CRUD (reads, inserts, deletes, bulk updates).
- **Secondary:** Raw SQL via `RepositoryBase` helpers preserved where EF Core LINQ cannot cleanly express the query (ON CONFLICT, jsonb containment `@>`, LIKE REPLACE patterns, CASE conditional updates, FOR UPDATE, regex `~`, CTE queries, FILTER/GROUP BY aggregates, NULLS LAST ordering, cross-window INSERT-SELECT, DB functions).
- **Design-time factory:** `EfCore/Context/PolicyDesignTimeDbContextFactory.cs` (for `dotnet ef` CLI).
- **Runtime factory:** `Postgres/PolicyDbContextFactory.cs` (compiled model on default schema, reflection fallback for non-default schemas).
- **Compiled model:** `EfCore/CompiledModels/` (regenerated via `dotnet ef dbcontext optimize`; assembly attribute excluded from compilation to support non-default schema integration tests).
- **Schema:** `policy` (default), injectable via constructor for integration test isolation.
- **Migrations:** SQL files under `Migrations/` embedded as resources; authoritative and never auto-generated from EF models.
## Working Agreement
- Update sprint status in docs/implplan/SPRINT_*.md and local TASKS.md.
- Keep repository ordering deterministic and time/ID generation explicit.
- When converting raw SQL to EF Core: use `AsNoTracking()` for reads, `Add()/SaveChangesAsync()` for inserts, `ExecuteUpdateAsync()`/`ExecuteDeleteAsync()` for bulk mutations.
- Document raw SQL retention rationale with `// Keep raw SQL:` comments.
- Never introduce auto-migrations or runtime schema changes.

View File

@@ -0,0 +1,9 @@
// <auto-generated />
using Microsoft.EntityFrameworkCore.Infrastructure;
using StellaOps.Policy.Persistence.EfCore.CompiledModels;
using StellaOps.Policy.Persistence.EfCore.Context;
#pragma warning disable 219, 612, 618
#nullable disable
[assembly: DbContextModel(typeof(PolicyDbContext), typeof(PolicyDbContextModel))]

View File

@@ -0,0 +1,48 @@
// <auto-generated />
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using StellaOps.Policy.Persistence.EfCore.Context;
#pragma warning disable 219, 612, 618
#nullable disable
namespace StellaOps.Policy.Persistence.EfCore.CompiledModels
{
[DbContext(typeof(PolicyDbContext))]
public partial class PolicyDbContextModel : RuntimeModel
{
private static readonly bool _useOldBehavior31751 =
System.AppContext.TryGetSwitch("Microsoft.EntityFrameworkCore.Issue31751", out var enabled31751) && enabled31751;
static PolicyDbContextModel()
{
var model = new PolicyDbContextModel();
if (_useOldBehavior31751)
{
model.Initialize();
}
else
{
var thread = new System.Threading.Thread(RunInitialization, 10 * 1024 * 1024);
thread.Start();
thread.Join();
void RunInitialization()
{
model.Initialize();
}
}
model.Customize();
_instance = (PolicyDbContextModel)model.FinalizeModel();
}
private static PolicyDbContextModel _instance;
public static IModel Instance => _instance;
partial void Initialize();
partial void Customize();
}
}

View File

@@ -0,0 +1,30 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#pragma warning disable 219, 612, 618
#nullable disable
namespace StellaOps.Policy.Persistence.EfCore.CompiledModels
{
public partial class PolicyDbContextModel
{
private PolicyDbContextModel()
: base(skipDetectChanges: false, modelId: new Guid("a7b2c1d0-3e4f-5a6b-8c9d-0e1f2a3b4c5d"), entityTypeCount: 20)
{
}
partial void Initialize()
{
// Entity types are registered through the DbContext OnModelCreating.
// This compiled model delegates to the runtime model builder for Policy entities.
// When dotnet ef dbcontext optimize is run against a live schema,
// this file will be regenerated with per-entity type registrations.
AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn);
AddAnnotation("ProductVersion", "10.0.0");
AddAnnotation("Relational:MaxIdentifierLength", 63);
}
}
}

View File

@@ -1,21 +1,725 @@
using Microsoft.EntityFrameworkCore;
using StellaOps.Policy.Persistence.Postgres.Models;
namespace StellaOps.Policy.Persistence.EfCore.Context;
/// <summary>
/// EF Core DbContext for Policy module.
/// This is a stub that will be scaffolded from the PostgreSQL database.
/// EF Core DbContext for the Policy module.
/// Scaffolded from SQL migrations 001-005.
/// </summary>
public class PolicyDbContext : DbContext
public partial class PolicyDbContext : DbContext
{
public PolicyDbContext(DbContextOptions<PolicyDbContext> options)
private readonly string _schemaName;
public PolicyDbContext(DbContextOptions<PolicyDbContext> options, string? schemaName = null)
: base(options)
{
_schemaName = string.IsNullOrWhiteSpace(schemaName)
? "policy"
: schemaName.Trim();
}
// ----- Core Policy Management -----
public virtual DbSet<PackEntity> Packs { get; set; }
public virtual DbSet<PackVersionEntity> PackVersions { get; set; }
public virtual DbSet<RuleEntity> Rules { get; set; }
// ----- Risk Profiles -----
public virtual DbSet<RiskProfileEntity> RiskProfiles { get; set; }
// ----- Evaluations -----
public virtual DbSet<EvaluationRunEntity> EvaluationRuns { get; set; }
public virtual DbSet<ExplanationEntity> Explanations { get; set; }
// ----- Snapshots & Events -----
public virtual DbSet<SnapshotEntity> Snapshots { get; set; }
public virtual DbSet<ViolationEventEntity> ViolationEvents { get; set; }
public virtual DbSet<ConflictEntity> Conflicts { get; set; }
public virtual DbSet<LedgerExportEntity> LedgerExports { get; set; }
public virtual DbSet<WorkerResultEntity> WorkerResults { get; set; }
// ----- Exceptions -----
public virtual DbSet<ExceptionEntity> Exceptions { get; set; }
// ----- Budget -----
public virtual DbSet<BudgetLedgerEntity> BudgetLedger { get; set; }
public virtual DbSet<BudgetEntryEntity> BudgetEntries { get; set; }
// ----- Approval -----
public virtual DbSet<ExceptionApprovalRequestEntity> ExceptionApprovalRequests { get; set; }
public virtual DbSet<ExceptionApprovalAuditEntity> ExceptionApprovalAudit { get; set; }
public virtual DbSet<ExceptionApprovalRuleEntity> ExceptionApprovalRules { get; set; }
// ----- Audit -----
public virtual DbSet<PolicyAuditEntity> Audit { get; set; }
// ----- Trusted Keys & Gate Bypass (Migration 002) -----
public virtual DbSet<TrustedKeyEntity> TrustedKeys { get; set; }
public virtual DbSet<GateBypassAuditEntity> GateBypassAudit { get; set; }
// ----- Gate Decisions (Migration 003) -----
public virtual DbSet<GateDecisionEntity> GateDecisions { get; set; }
// ----- Replay Audit (Migration 004) -----
public virtual DbSet<ReplayAuditEntity> ReplayAudit { get; set; }
// ----- Advisory Source Projection (Migration 005) -----
public virtual DbSet<AdvisorySourceImpactEntity> AdvisorySourceImpacts { get; set; }
public virtual DbSet<AdvisorySourceConflictEntity> AdvisorySourceConflicts { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.HasDefaultSchema("policy");
base.OnModelCreating(modelBuilder);
var schemaName = _schemaName;
// === packs ===
modelBuilder.Entity<PackEntity>(entity =>
{
entity.HasKey(e => e.Id).HasName("packs_pkey");
entity.ToTable("packs", schemaName);
entity.HasIndex(e => e.TenantId, "idx_packs_tenant");
entity.HasIndex(e => e.IsBuiltin, "idx_packs_builtin");
entity.HasIndex(e => new { e.TenantId, e.Name }).IsUnique();
entity.Property(e => e.Id).HasDefaultValueSql("gen_random_uuid()").HasColumnName("id");
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
entity.Property(e => e.Name).HasColumnName("name");
entity.Property(e => e.DisplayName).HasColumnName("display_name");
entity.Property(e => e.Description).HasColumnName("description");
entity.Property(e => e.ActiveVersion).HasColumnName("active_version");
entity.Property(e => e.IsBuiltin).HasColumnName("is_builtin");
entity.Property(e => e.IsDeprecated).HasColumnName("is_deprecated");
entity.Property(e => e.Metadata).HasColumnType("jsonb").HasColumnName("metadata");
entity.Property(e => e.CreatedAt).HasDefaultValueSql("now()").HasColumnName("created_at");
entity.Property(e => e.UpdatedAt).HasDefaultValueSql("now()").HasColumnName("updated_at");
entity.Property(e => e.CreatedBy).HasColumnName("created_by");
});
// === pack_versions ===
modelBuilder.Entity<PackVersionEntity>(entity =>
{
entity.HasKey(e => e.Id).HasName("pack_versions_pkey");
entity.ToTable("pack_versions", schemaName);
entity.HasIndex(e => e.PackId, "idx_pack_versions_pack");
entity.HasIndex(e => new { e.PackId, e.IsPublished }, "idx_pack_versions_published");
entity.HasIndex(e => new { e.PackId, e.Version }).IsUnique();
entity.Property(e => e.Id).HasDefaultValueSql("gen_random_uuid()").HasColumnName("id");
entity.Property(e => e.PackId).HasColumnName("pack_id");
entity.Property(e => e.Version).HasColumnName("version");
entity.Property(e => e.Description).HasColumnName("description");
entity.Property(e => e.RulesHash).HasColumnName("rules_hash");
entity.Property(e => e.IsPublished).HasColumnName("is_published");
entity.Property(e => e.PublishedAt).HasColumnName("published_at");
entity.Property(e => e.PublishedBy).HasColumnName("published_by");
entity.Property(e => e.CreatedAt).HasDefaultValueSql("now()").HasColumnName("created_at");
entity.Property(e => e.CreatedBy).HasColumnName("created_by");
});
// === rules ===
modelBuilder.Entity<RuleEntity>(entity =>
{
entity.HasKey(e => e.Id).HasName("rules_pkey");
entity.ToTable("rules", schemaName);
entity.HasIndex(e => e.PackVersionId, "idx_rules_pack_version");
entity.HasIndex(e => new { e.PackVersionId, e.Name }).IsUnique();
entity.Property(e => e.Id).HasDefaultValueSql("gen_random_uuid()").HasColumnName("id");
entity.Property(e => e.PackVersionId).HasColumnName("pack_version_id");
entity.Property(e => e.Name).HasColumnName("name");
entity.Property(e => e.Description).HasColumnName("description");
entity.Property(e => e.RuleType).HasConversion<string>().HasColumnName("rule_type");
entity.Property(e => e.Content).HasColumnName("content");
entity.Property(e => e.ContentHash).HasColumnName("content_hash");
entity.Property(e => e.Severity).HasConversion<string>().HasColumnName("severity");
entity.Property(e => e.Category).HasColumnName("category");
entity.Property(e => e.Tags).HasColumnName("tags");
entity.Property(e => e.Metadata).HasColumnType("jsonb").HasColumnName("metadata");
entity.Property(e => e.CreatedAt).HasDefaultValueSql("now()").HasColumnName("created_at");
});
// === risk_profiles ===
modelBuilder.Entity<RiskProfileEntity>(entity =>
{
entity.HasKey(e => e.Id).HasName("risk_profiles_pkey");
entity.ToTable("risk_profiles", schemaName);
entity.HasIndex(e => e.TenantId, "idx_risk_profiles_tenant");
entity.HasIndex(e => new { e.TenantId, e.Name, e.Version }).IsUnique();
entity.Property(e => e.Id).HasDefaultValueSql("gen_random_uuid()").HasColumnName("id");
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
entity.Property(e => e.Name).HasColumnName("name");
entity.Property(e => e.DisplayName).HasColumnName("display_name");
entity.Property(e => e.Description).HasColumnName("description");
entity.Property(e => e.Version).HasColumnName("version");
entity.Property(e => e.IsActive).HasColumnName("is_active");
entity.Property(e => e.Thresholds).HasColumnType("jsonb").HasColumnName("thresholds");
entity.Property(e => e.ScoringWeights).HasColumnType("jsonb").HasColumnName("scoring_weights");
entity.Property(e => e.Exemptions).HasColumnType("jsonb").HasColumnName("exemptions");
entity.Property(e => e.Metadata).HasColumnType("jsonb").HasColumnName("metadata");
entity.Property(e => e.CreatedAt).HasDefaultValueSql("now()").HasColumnName("created_at");
entity.Property(e => e.UpdatedAt).HasDefaultValueSql("now()").HasColumnName("updated_at");
entity.Property(e => e.CreatedBy).HasColumnName("created_by");
});
// === evaluation_runs ===
modelBuilder.Entity<EvaluationRunEntity>(entity =>
{
entity.HasKey(e => e.Id).HasName("evaluation_runs_pkey");
entity.ToTable("evaluation_runs", schemaName);
entity.HasIndex(e => e.TenantId, "idx_evaluation_runs_tenant");
entity.HasIndex(e => new { e.TenantId, e.ProjectId }, "idx_evaluation_runs_project");
entity.HasIndex(e => new { e.TenantId, e.ArtifactId }, "idx_evaluation_runs_artifact");
entity.HasIndex(e => new { e.TenantId, e.CreatedAt }, "idx_evaluation_runs_created");
entity.HasIndex(e => e.Status, "idx_evaluation_runs_status");
entity.Property(e => e.Id).HasDefaultValueSql("gen_random_uuid()").HasColumnName("id");
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
entity.Property(e => e.ProjectId).HasColumnName("project_id");
entity.Property(e => e.ArtifactId).HasColumnName("artifact_id");
entity.Property(e => e.PackId).HasColumnName("pack_id");
entity.Property(e => e.PackVersion).HasColumnName("pack_version");
entity.Property(e => e.RiskProfileId).HasColumnName("risk_profile_id");
entity.Property(e => e.Status).HasConversion<string>().HasColumnName("status");
entity.Property(e => e.Result).HasConversion<string>().HasColumnName("result");
entity.Property(e => e.Score).HasColumnName("score");
entity.Property(e => e.FindingsCount).HasColumnName("findings_count");
entity.Property(e => e.CriticalCount).HasColumnName("critical_count");
entity.Property(e => e.HighCount).HasColumnName("high_count");
entity.Property(e => e.MediumCount).HasColumnName("medium_count");
entity.Property(e => e.LowCount).HasColumnName("low_count");
entity.Property(e => e.InputHash).HasColumnName("input_hash");
entity.Property(e => e.DurationMs).HasColumnName("duration_ms");
entity.Property(e => e.ErrorMessage).HasColumnName("error_message");
entity.Property(e => e.Metadata).HasColumnType("jsonb").HasColumnName("metadata");
entity.Property(e => e.CreatedAt).HasDefaultValueSql("now()").HasColumnName("created_at");
entity.Property(e => e.StartedAt).HasColumnName("started_at");
entity.Property(e => e.CompletedAt).HasColumnName("completed_at");
entity.Property(e => e.CreatedBy).HasColumnName("created_by");
});
// === explanations ===
modelBuilder.Entity<ExplanationEntity>(entity =>
{
entity.HasKey(e => e.Id).HasName("explanations_pkey");
entity.ToTable("explanations", schemaName);
entity.HasIndex(e => e.EvaluationRunId, "idx_explanations_run");
entity.HasIndex(e => new { e.EvaluationRunId, e.Result }, "idx_explanations_result");
entity.Property(e => e.Id).HasDefaultValueSql("gen_random_uuid()").HasColumnName("id");
entity.Property(e => e.EvaluationRunId).HasColumnName("evaluation_run_id");
entity.Property(e => e.RuleId).HasColumnName("rule_id");
entity.Property(e => e.RuleName).HasColumnName("rule_name");
entity.Property(e => e.Result).HasConversion<string>().HasColumnName("result");
entity.Property(e => e.Severity).HasColumnName("severity");
entity.Property(e => e.Message).HasColumnName("message");
entity.Property(e => e.Details).HasColumnType("jsonb").HasColumnName("details");
entity.Property(e => e.Remediation).HasColumnName("remediation");
entity.Property(e => e.ResourcePath).HasColumnName("resource_path");
entity.Property(e => e.LineNumber).HasColumnName("line_number");
entity.Property(e => e.CreatedAt).HasDefaultValueSql("now()").HasColumnName("created_at");
});
// === snapshots ===
modelBuilder.Entity<SnapshotEntity>(entity =>
{
entity.HasKey(e => e.Id).HasName("snapshots_pkey");
entity.ToTable("snapshots", schemaName);
entity.HasIndex(e => e.TenantId, "idx_snapshots_tenant");
entity.HasIndex(e => new { e.TenantId, e.PolicyId }, "idx_snapshots_policy");
entity.HasIndex(e => e.ContentDigest, "idx_snapshots_digest");
entity.HasIndex(e => new { e.TenantId, e.PolicyId, e.Version }).IsUnique();
entity.Property(e => e.Id).HasDefaultValueSql("gen_random_uuid()").HasColumnName("id");
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
entity.Property(e => e.PolicyId).HasColumnName("policy_id");
entity.Property(e => e.Version).HasColumnName("version");
entity.Property(e => e.ContentDigest).HasColumnName("content_digest");
entity.Property(e => e.Content).HasColumnType("jsonb").HasColumnName("content");
entity.Property(e => e.Metadata).HasColumnType("jsonb").HasColumnName("metadata");
entity.Property(e => e.CreatedBy).HasColumnName("created_by");
entity.Property(e => e.CreatedAt).HasDefaultValueSql("now()").HasColumnName("created_at");
});
// === violation_events ===
modelBuilder.Entity<ViolationEventEntity>(entity =>
{
entity.HasKey(e => e.Id).HasName("violation_events_pkey");
entity.ToTable("violation_events", schemaName);
entity.HasIndex(e => e.TenantId, "idx_violation_events_tenant");
entity.HasIndex(e => new { e.TenantId, e.PolicyId }, "idx_violation_events_policy");
entity.HasIndex(e => new { e.TenantId, e.OccurredAt }, "idx_violation_events_occurred");
entity.Property(e => e.Id).HasDefaultValueSql("gen_random_uuid()").HasColumnName("id");
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
entity.Property(e => e.PolicyId).HasColumnName("policy_id");
entity.Property(e => e.RuleId).HasColumnName("rule_id");
entity.Property(e => e.Severity).HasColumnName("severity");
entity.Property(e => e.SubjectPurl).HasColumnName("subject_purl");
entity.Property(e => e.SubjectCve).HasColumnName("subject_cve");
entity.Property(e => e.Details).HasColumnType("jsonb").HasColumnName("details");
entity.Property(e => e.Remediation).HasColumnName("remediation");
entity.Property(e => e.CorrelationId).HasColumnName("correlation_id");
entity.Property(e => e.OccurredAt).HasColumnName("occurred_at");
entity.Property(e => e.CreatedAt).HasDefaultValueSql("now()").HasColumnName("created_at");
});
// === conflicts ===
modelBuilder.Entity<ConflictEntity>(entity =>
{
entity.HasKey(e => e.Id).HasName("conflicts_pkey");
entity.ToTable("conflicts", schemaName);
entity.HasIndex(e => e.TenantId, "idx_conflicts_tenant");
entity.HasIndex(e => new { e.TenantId, e.Status }, "idx_conflicts_status");
entity.HasIndex(e => e.ConflictType, "idx_conflicts_type");
entity.Property(e => e.Id).HasDefaultValueSql("gen_random_uuid()").HasColumnName("id");
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
entity.Property(e => e.ConflictType).HasColumnName("conflict_type");
entity.Property(e => e.Status).HasColumnName("status");
entity.Property(e => e.Severity).HasColumnName("severity");
entity.Property(e => e.LeftRuleId).HasColumnName("left_rule_id");
entity.Property(e => e.RightRuleId).HasColumnName("right_rule_id");
entity.Property(e => e.AffectedScope).HasColumnName("affected_scope");
entity.Property(e => e.Description).HasColumnName("description");
entity.Property(e => e.Resolution).HasColumnName("resolution");
entity.Property(e => e.ResolvedBy).HasColumnName("resolved_by");
entity.Property(e => e.ResolvedAt).HasColumnName("resolved_at");
entity.Property(e => e.Metadata).HasColumnType("jsonb").HasColumnName("metadata");
entity.Property(e => e.CreatedAt).HasDefaultValueSql("now()").HasColumnName("created_at");
entity.Property(e => e.CreatedBy).HasColumnName("created_by");
});
// === ledger_exports ===
modelBuilder.Entity<LedgerExportEntity>(entity =>
{
entity.HasKey(e => e.Id).HasName("ledger_exports_pkey");
entity.ToTable("ledger_exports", schemaName);
entity.HasIndex(e => e.TenantId, "idx_ledger_exports_tenant");
entity.HasIndex(e => e.Status, "idx_ledger_exports_status");
entity.HasIndex(e => new { e.TenantId, e.CreatedAt }, "idx_ledger_exports_created");
entity.Property(e => e.Id).HasDefaultValueSql("gen_random_uuid()").HasColumnName("id");
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
entity.Property(e => e.ExportType).HasColumnName("export_type");
entity.Property(e => e.Status).HasColumnName("status");
entity.Property(e => e.Format).HasColumnName("format");
entity.Property(e => e.ContentDigest).HasColumnName("content_digest");
entity.Property(e => e.RecordCount).HasColumnName("record_count");
entity.Property(e => e.ByteSize).HasColumnName("byte_size");
entity.Property(e => e.StoragePath).HasColumnName("storage_path");
entity.Property(e => e.StartTime).HasColumnName("start_time");
entity.Property(e => e.EndTime).HasColumnName("end_time");
entity.Property(e => e.ErrorMessage).HasColumnName("error_message");
entity.Property(e => e.Metadata).HasColumnType("jsonb").HasColumnName("metadata");
entity.Property(e => e.CreatedAt).HasDefaultValueSql("now()").HasColumnName("created_at");
entity.Property(e => e.CreatedBy).HasColumnName("created_by");
});
// === worker_results ===
modelBuilder.Entity<WorkerResultEntity>(entity =>
{
entity.HasKey(e => e.Id).HasName("worker_results_pkey");
entity.ToTable("worker_results", schemaName);
entity.HasIndex(e => e.TenantId, "idx_worker_results_tenant");
entity.HasIndex(e => e.Status, "idx_worker_results_status");
entity.HasIndex(e => new { e.TenantId, e.JobType, e.JobId }).IsUnique();
entity.Property(e => e.Id).HasDefaultValueSql("gen_random_uuid()").HasColumnName("id");
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
entity.Property(e => e.JobType).HasColumnName("job_type");
entity.Property(e => e.JobId).HasColumnName("job_id");
entity.Property(e => e.Status).HasColumnName("status");
entity.Property(e => e.InputHash).HasColumnName("input_hash");
entity.Property(e => e.OutputHash).HasColumnName("output_hash");
entity.Property(e => e.Progress).HasColumnName("progress");
entity.Property(e => e.Result).HasColumnType("jsonb").HasColumnName("result");
entity.Property(e => e.ErrorMessage).HasColumnName("error_message");
entity.Property(e => e.RetryCount).HasColumnName("retry_count");
entity.Property(e => e.MaxRetries).HasColumnName("max_retries");
entity.Property(e => e.ScheduledAt).HasColumnName("scheduled_at");
entity.Property(e => e.StartedAt).HasColumnName("started_at");
entity.Property(e => e.CompletedAt).HasColumnName("completed_at");
entity.Property(e => e.Metadata).HasColumnType("jsonb").HasColumnName("metadata");
entity.Property(e => e.CreatedAt).HasDefaultValueSql("now()").HasColumnName("created_at");
entity.Property(e => e.CreatedBy).HasColumnName("created_by");
});
// === exceptions ===
modelBuilder.Entity<ExceptionEntity>(entity =>
{
entity.HasKey(e => e.Id).HasName("exceptions_pkey");
entity.ToTable("exceptions", schemaName);
entity.HasIndex(e => e.TenantId, "idx_exceptions_tenant");
entity.HasIndex(e => new { e.TenantId, e.Status }, "idx_exceptions_status");
entity.HasIndex(e => new { e.TenantId, e.Name }).IsUnique();
entity.HasIndex(e => e.ExceptionId).IsUnique();
entity.Property(e => e.Id).HasDefaultValueSql("gen_random_uuid()").HasColumnName("id");
entity.Property(e => e.ExceptionId).HasColumnName("exception_id");
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
entity.Property(e => e.Name).HasColumnName("name");
entity.Property(e => e.Description).HasColumnName("description");
entity.Property(e => e.RulePattern).HasColumnName("rule_pattern");
entity.Property(e => e.ResourcePattern).HasColumnName("resource_pattern");
entity.Property(e => e.ArtifactPattern).HasColumnName("artifact_pattern");
entity.Property(e => e.ProjectId).HasColumnName("project_id");
entity.Property(e => e.Reason).HasColumnName("reason");
entity.Property(e => e.Status).HasConversion<string>().HasColumnName("status");
entity.Property(e => e.ExpiresAt).HasColumnName("expires_at");
entity.Property(e => e.ApprovedBy).HasColumnName("approved_by");
entity.Property(e => e.ApprovedAt).HasColumnName("approved_at");
entity.Property(e => e.RevokedBy).HasColumnName("revoked_by");
entity.Property(e => e.RevokedAt).HasColumnName("revoked_at");
entity.Property(e => e.Metadata).HasColumnType("jsonb").HasColumnName("metadata");
entity.Property(e => e.CreatedAt).HasDefaultValueSql("now()").HasColumnName("created_at");
entity.Property(e => e.CreatedBy).HasColumnName("created_by");
});
// === budget_ledger ===
modelBuilder.Entity<BudgetLedgerEntity>(entity =>
{
entity.HasKey(e => e.BudgetId).HasName("budget_ledger_pkey");
entity.ToTable("budget_ledger", schemaName);
entity.HasIndex(e => e.ServiceId, "idx_budget_ledger_service_id");
entity.HasIndex(e => e.TenantId, "idx_budget_ledger_tenant_id");
entity.HasIndex(e => e.Window, "idx_budget_ledger_window");
entity.HasIndex(e => e.Status, "idx_budget_ledger_status");
entity.HasIndex(e => new { e.ServiceId, e.Window }).IsUnique().HasDatabaseName("uq_budget_ledger_service_window");
entity.Property(e => e.BudgetId).HasMaxLength(256).HasColumnName("budget_id");
entity.Property(e => e.ServiceId).HasMaxLength(128).HasColumnName("service_id");
entity.Property(e => e.TenantId).HasMaxLength(64).HasColumnName("tenant_id");
entity.Property(e => e.Tier).HasColumnName("tier");
entity.Property(e => e.Window).HasMaxLength(16).HasColumnName("window");
entity.Property(e => e.Allocated).HasColumnName("allocated");
entity.Property(e => e.Consumed).HasColumnName("consumed");
entity.Property(e => e.Status).HasMaxLength(16).HasColumnName("status");
entity.Property(e => e.CreatedAt).HasDefaultValueSql("now()").HasColumnName("created_at");
entity.Property(e => e.UpdatedAt).HasDefaultValueSql("now()").HasColumnName("updated_at");
});
// === budget_entries ===
modelBuilder.Entity<BudgetEntryEntity>(entity =>
{
entity.HasKey(e => e.EntryId).HasName("budget_entries_pkey");
entity.ToTable("budget_entries", schemaName);
entity.HasIndex(e => new { e.ServiceId, e.Window }, "idx_budget_entries_service_window");
entity.HasIndex(e => e.ReleaseId, "idx_budget_entries_release_id");
entity.HasIndex(e => e.ConsumedAt, "idx_budget_entries_consumed_at");
entity.Property(e => e.EntryId).HasMaxLength(64).HasColumnName("entry_id");
entity.Property(e => e.ServiceId).HasMaxLength(128).HasColumnName("service_id");
entity.Property(e => e.Window).HasMaxLength(16).HasColumnName("window");
entity.Property(e => e.ReleaseId).HasMaxLength(128).HasColumnName("release_id");
entity.Property(e => e.RiskPoints).HasColumnName("risk_points");
entity.Property(e => e.Reason).HasMaxLength(512).HasColumnName("reason");
entity.Property(e => e.IsException).HasColumnName("is_exception");
entity.Property(e => e.PenaltyPoints).HasColumnName("penalty_points");
entity.Property(e => e.ConsumedAt).HasDefaultValueSql("now()").HasColumnName("consumed_at");
entity.Property(e => e.ConsumedBy).HasMaxLength(256).HasColumnName("consumed_by");
});
// === exception_approval_requests ===
modelBuilder.Entity<ExceptionApprovalRequestEntity>(entity =>
{
entity.HasKey(e => e.Id).HasName("exception_approval_requests_pkey");
entity.ToTable("exception_approval_requests", schemaName);
entity.HasIndex(e => e.TenantId, "idx_approval_requests_tenant");
entity.HasIndex(e => new { e.TenantId, e.Status }, "idx_approval_requests_status");
entity.HasIndex(e => e.RequestId).IsUnique();
entity.Property(e => e.Id).HasDefaultValueSql("gen_random_uuid()").HasColumnName("id");
entity.Property(e => e.RequestId).HasColumnName("request_id");
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
entity.Property(e => e.ExceptionId).HasColumnName("exception_id");
entity.Property(e => e.RequestorId).HasColumnName("requestor_id");
entity.Property(e => e.RequiredApproverIds).HasColumnName("required_approver_ids");
entity.Property(e => e.ApprovedByIds).HasColumnName("approved_by_ids");
entity.Property(e => e.RejectedById).HasColumnName("rejected_by_id");
entity.Property(e => e.Status).HasConversion<string>().HasColumnName("status");
entity.Property(e => e.GateLevel).HasConversion<int>().HasColumnName("gate_level");
entity.Property(e => e.Justification).HasColumnName("justification");
entity.Property(e => e.Rationale).HasColumnName("rationale");
entity.Property(e => e.ReasonCode).HasConversion<string>().HasColumnName("reason_code");
entity.Property(e => e.EvidenceRefs).HasColumnType("jsonb").HasColumnName("evidence_refs");
entity.Property(e => e.CompensatingControls).HasColumnType("jsonb").HasColumnName("compensating_controls");
entity.Property(e => e.TicketRef).HasColumnName("ticket_ref");
entity.Property(e => e.VulnerabilityId).HasColumnName("vulnerability_id");
entity.Property(e => e.PurlPattern).HasColumnName("purl_pattern");
entity.Property(e => e.ArtifactDigest).HasColumnName("artifact_digest");
entity.Property(e => e.ImagePattern).HasColumnName("image_pattern");
entity.Property(e => e.Environments).HasColumnName("environments");
entity.Property(e => e.RequestedTtlDays).HasColumnName("requested_ttl_days");
entity.Property(e => e.CreatedAt).HasDefaultValueSql("now()").HasColumnName("created_at");
entity.Property(e => e.RequestExpiresAt).HasColumnName("request_expires_at");
entity.Property(e => e.ExceptionExpiresAt).HasColumnName("exception_expires_at");
entity.Property(e => e.ResolvedAt).HasColumnName("resolved_at");
entity.Property(e => e.RejectionReason).HasColumnName("rejection_reason");
entity.Property(e => e.Metadata).HasColumnType("jsonb").HasColumnName("metadata");
entity.Property(e => e.Version).HasColumnName("version");
entity.Property(e => e.UpdatedAt).HasDefaultValueSql("now()").HasColumnName("updated_at");
});
// === exception_approval_audit ===
modelBuilder.Entity<ExceptionApprovalAuditEntity>(entity =>
{
entity.HasKey(e => e.Id).HasName("exception_approval_audit_pkey");
entity.ToTable("exception_approval_audit", schemaName);
entity.HasIndex(e => e.RequestId, "idx_approval_audit_request");
entity.HasIndex(e => new { e.RequestId, e.SequenceNumber }).IsUnique();
entity.Property(e => e.Id).HasDefaultValueSql("gen_random_uuid()").HasColumnName("id");
entity.Property(e => e.RequestId).HasColumnName("request_id");
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
entity.Property(e => e.SequenceNumber).HasColumnName("sequence_number");
entity.Property(e => e.ActionType).HasColumnName("action_type");
entity.Property(e => e.ActorId).HasColumnName("actor_id");
entity.Property(e => e.OccurredAt).HasDefaultValueSql("now()").HasColumnName("occurred_at");
entity.Property(e => e.PreviousStatus).HasColumnName("previous_status");
entity.Property(e => e.NewStatus).HasColumnName("new_status");
entity.Property(e => e.Description).HasColumnName("description");
entity.Property(e => e.Details).HasColumnType("jsonb").HasColumnName("details");
entity.Property(e => e.ClientInfo).HasColumnType("jsonb").HasColumnName("client_info");
});
// === exception_approval_rules ===
modelBuilder.Entity<ExceptionApprovalRuleEntity>(entity =>
{
entity.HasKey(e => e.Id).HasName("exception_approval_rules_pkey");
entity.ToTable("exception_approval_rules", schemaName);
entity.HasIndex(e => new { e.TenantId, e.GateLevel, e.Name }).IsUnique();
entity.Property(e => e.Id).HasDefaultValueSql("gen_random_uuid()").HasColumnName("id");
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
entity.Property(e => e.Name).HasColumnName("name");
entity.Property(e => e.Description).HasColumnName("description");
entity.Property(e => e.GateLevel).HasConversion<int>().HasColumnName("gate_level");
entity.Property(e => e.MinApprovers).HasColumnName("min_approvers");
entity.Property(e => e.RequiredRoles).HasColumnName("required_roles");
entity.Property(e => e.MaxTtlDays).HasColumnName("max_ttl_days");
entity.Property(e => e.AllowSelfApproval).HasColumnName("allow_self_approval");
entity.Property(e => e.RequireEvidence).HasColumnName("require_evidence");
entity.Property(e => e.RequireCompensatingControls).HasColumnName("require_compensating_controls");
entity.Property(e => e.MinRationaleLength).HasColumnName("min_rationale_length");
entity.Property(e => e.Priority).HasColumnName("priority");
entity.Property(e => e.Enabled).HasColumnName("enabled");
entity.Property(e => e.CreatedAt).HasDefaultValueSql("now()").HasColumnName("created_at");
entity.Property(e => e.UpdatedAt).HasDefaultValueSql("now()").HasColumnName("updated_at");
});
// === audit ===
modelBuilder.Entity<PolicyAuditEntity>(entity =>
{
entity.HasKey(e => e.Id).HasName("audit_pkey");
entity.ToTable("audit", schemaName);
entity.HasIndex(e => e.TenantId, "idx_audit_tenant");
entity.HasIndex(e => new { e.ResourceType, e.ResourceId }, "idx_audit_resource");
entity.HasIndex(e => new { e.TenantId, e.CreatedAt }, "idx_audit_created");
entity.Property(e => e.Id).ValueGeneratedOnAdd().HasColumnName("id");
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
entity.Property(e => e.UserId).HasColumnName("user_id");
entity.Property(e => e.Action).HasColumnName("action");
entity.Property(e => e.ResourceType).HasColumnName("resource_type");
entity.Property(e => e.ResourceId).HasColumnName("resource_id");
entity.Property(e => e.OldValue).HasColumnType("jsonb").HasColumnName("old_value");
entity.Property(e => e.NewValue).HasColumnType("jsonb").HasColumnName("new_value");
entity.Property(e => e.CorrelationId).HasColumnName("correlation_id");
entity.Property(e => e.CreatedAt).HasDefaultValueSql("now()").HasColumnName("created_at");
entity.Property(e => e.VexTrustScore).HasColumnName("vex_trust_score");
entity.Property(e => e.VexTrustTier).HasColumnName("vex_trust_tier");
entity.Property(e => e.VexSignatureVerified).HasColumnName("vex_signature_verified");
entity.Property(e => e.VexIssuerId).HasColumnName("vex_issuer_id");
entity.Property(e => e.VexIssuerName).HasColumnName("vex_issuer_name");
entity.Property(e => e.VexTrustGateResult).HasColumnName("vex_trust_gate_result");
entity.Property(e => e.VexTrustGateReason).HasColumnName("vex_trust_gate_reason");
entity.Property(e => e.VexSignatureMethod).HasColumnName("vex_signature_method");
});
// === trusted_keys (Migration 002) ===
modelBuilder.Entity<TrustedKeyEntity>(entity =>
{
entity.HasKey(e => e.Id).HasName("trusted_keys_pkey");
entity.ToTable("trusted_keys", schemaName);
entity.HasIndex(e => e.TenantId, "idx_trusted_keys_tenant");
entity.HasIndex(e => new { e.TenantId, e.KeyId }).IsUnique();
entity.HasIndex(e => new { e.TenantId, e.Fingerprint }).IsUnique();
entity.Property(e => e.Id).HasDefaultValueSql("gen_random_uuid()").HasColumnName("id");
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
entity.Property(e => e.KeyId).HasColumnName("key_id");
entity.Property(e => e.Fingerprint).HasColumnName("fingerprint");
entity.Property(e => e.Algorithm).HasColumnName("algorithm");
entity.Property(e => e.PublicKeyPem).HasColumnName("public_key_pem");
entity.Property(e => e.Owner).HasColumnName("owner");
entity.Property(e => e.IssuerPattern).HasColumnName("issuer_pattern");
entity.Property(e => e.Purposes).HasColumnType("jsonb").HasColumnName("purposes");
entity.Property(e => e.ValidFrom).HasDefaultValueSql("now()").HasColumnName("valid_from");
entity.Property(e => e.ValidUntil).HasColumnName("valid_until");
entity.Property(e => e.IsActive).HasColumnName("is_active");
entity.Property(e => e.RevokedAt).HasColumnName("revoked_at");
entity.Property(e => e.RevokedReason).HasColumnName("revoked_reason");
entity.Property(e => e.Metadata).HasColumnType("jsonb").HasColumnName("metadata");
entity.Property(e => e.CreatedAt).HasDefaultValueSql("now()").HasColumnName("created_at");
entity.Property(e => e.UpdatedAt).HasDefaultValueSql("now()").HasColumnName("updated_at");
entity.Property(e => e.CreatedBy).HasColumnName("created_by");
});
// === gate_bypass_audit (Migration 002) ===
modelBuilder.Entity<GateBypassAuditEntity>(entity =>
{
entity.HasKey(e => e.Id).HasName("gate_bypass_audit_pkey");
entity.ToTable("gate_bypass_audit", schemaName);
entity.HasIndex(e => new { e.TenantId, e.Timestamp }, "idx_gate_bypass_audit_tenant_timestamp").IsDescending(false, true);
entity.Property(e => e.Id).HasDefaultValueSql("gen_random_uuid()").HasColumnName("id");
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
entity.Property(e => e.Timestamp).HasDefaultValueSql("now()").HasColumnName("timestamp");
entity.Property(e => e.DecisionId).HasColumnName("decision_id");
entity.Property(e => e.ImageDigest).HasColumnName("image_digest");
entity.Property(e => e.Repository).HasColumnName("repository");
entity.Property(e => e.Tag).HasColumnName("tag");
entity.Property(e => e.BaselineRef).HasColumnName("baseline_ref");
entity.Property(e => e.OriginalDecision).HasColumnName("original_decision");
entity.Property(e => e.FinalDecision).HasColumnName("final_decision");
entity.Property(e => e.BypassedGates).HasColumnType("jsonb").HasColumnName("bypassed_gates");
entity.Property(e => e.Actor).HasColumnName("actor");
entity.Property(e => e.ActorSubject).HasColumnName("actor_subject");
entity.Property(e => e.ActorEmail).HasColumnName("actor_email");
entity.Property(e => e.ActorIpAddress).HasColumnName("actor_ip_address");
entity.Property(e => e.Justification).HasColumnName("justification");
entity.Property(e => e.PolicyId).HasColumnName("policy_id");
entity.Property(e => e.Source).HasColumnName("source");
entity.Property(e => e.CiContext).HasColumnName("ci_context");
entity.Property(e => e.AttestationDigest).HasColumnName("attestation_digest");
entity.Property(e => e.RekorUuid).HasColumnName("rekor_uuid");
entity.Property(e => e.BypassType).HasColumnName("bypass_type");
entity.Property(e => e.ExpiresAt).HasColumnName("expires_at");
entity.Property(e => e.Metadata).HasColumnType("jsonb").HasColumnName("metadata");
entity.Property(e => e.CreatedAt).HasDefaultValueSql("now()").HasColumnName("created_at");
});
// === gate_decisions (Migration 003) ===
modelBuilder.Entity<GateDecisionEntity>(entity =>
{
entity.HasKey(e => e.DecisionId).HasName("gate_decisions_pkey");
entity.ToTable("gate_decisions", schemaName);
entity.HasIndex(e => new { e.TenantId, e.EvaluatedAt }, "idx_gate_decisions_tenant_evaluated").IsDescending(false, true);
entity.HasIndex(e => new { e.TenantId, e.GateId, e.EvaluatedAt }, "idx_gate_decisions_gate_evaluated").IsDescending(false, false, true);
entity.Property(e => e.DecisionId).HasDefaultValueSql("gen_random_uuid()").HasColumnName("decision_id");
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
entity.Property(e => e.GateId).HasColumnName("gate_id");
entity.Property(e => e.BomRef).HasColumnName("bom_ref");
entity.Property(e => e.ImageDigest).HasColumnName("image_digest");
entity.Property(e => e.GateStatus).HasColumnName("gate_status");
entity.Property(e => e.VerdictHash).HasColumnName("verdict_hash");
entity.Property(e => e.PolicyBundleId).HasColumnName("policy_bundle_id");
entity.Property(e => e.PolicyBundleHash).HasColumnName("policy_bundle_hash");
entity.Property(e => e.EvaluatedAt).HasDefaultValueSql("now()").HasColumnName("evaluated_at");
entity.Property(e => e.CiContext).HasColumnName("ci_context");
entity.Property(e => e.Actor).HasColumnName("actor");
entity.Property(e => e.BlockingUnknownIds).HasColumnType("jsonb").HasColumnName("blocking_unknown_ids");
entity.Property(e => e.Warnings).HasColumnType("jsonb").HasColumnName("warnings");
entity.Property(e => e.CreatedAt).HasDefaultValueSql("now()").HasColumnName("created_at");
});
// === replay_audit (Migration 004) ===
modelBuilder.Entity<ReplayAuditEntity>(entity =>
{
entity.HasKey(e => e.ReplayId).HasName("replay_audit_pkey");
entity.ToTable("replay_audit", schemaName);
entity.HasIndex(e => new { e.TenantId, e.ReplayedAt }, "idx_replay_audit_tenant_replayed").IsDescending(false, true);
entity.HasIndex(e => new { e.TenantId, e.BomRef, e.ReplayedAt }, "idx_replay_audit_bom_ref").IsDescending(false, false, true);
entity.Property(e => e.ReplayId).HasDefaultValueSql("gen_random_uuid()").HasColumnName("replay_id");
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
entity.Property(e => e.BomRef).HasMaxLength(512).HasColumnName("bom_ref");
entity.Property(e => e.VerdictHash).HasMaxLength(128).HasColumnName("verdict_hash");
entity.Property(e => e.RekorUuid).HasMaxLength(128).HasColumnName("rekor_uuid");
entity.Property(e => e.ReplayedAt).HasDefaultValueSql("now()").HasColumnName("replayed_at");
entity.Property(e => e.Match).HasColumnName("match");
entity.Property(e => e.OriginalHash).HasMaxLength(128).HasColumnName("original_hash");
entity.Property(e => e.ReplayedHash).HasMaxLength(128).HasColumnName("replayed_hash");
entity.Property(e => e.MismatchReason).HasColumnName("mismatch_reason");
entity.Property(e => e.PolicyBundleId).HasColumnName("policy_bundle_id");
entity.Property(e => e.PolicyBundleHash).HasMaxLength(128).HasColumnName("policy_bundle_hash");
entity.Property(e => e.VerifierDigest).HasMaxLength(128).HasColumnName("verifier_digest");
entity.Property(e => e.DurationMs).HasColumnName("duration_ms");
entity.Property(e => e.Actor).HasMaxLength(256).HasColumnName("actor");
entity.Property(e => e.Source).HasMaxLength(64).HasColumnName("source");
entity.Property(e => e.RequestContext).HasColumnType("jsonb").HasColumnName("request_context");
entity.Property(e => e.CreatedAt).HasDefaultValueSql("now()").HasColumnName("created_at");
});
// === advisory_source_impacts (Migration 005) ===
modelBuilder.Entity<AdvisorySourceImpactEntity>(entity =>
{
entity.HasKey(e => e.Id).HasName("advisory_source_impacts_pkey");
entity.ToTable("advisory_source_impacts", schemaName);
entity.Property(e => e.Id).HasDefaultValueSql("gen_random_uuid()").HasColumnName("id");
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
entity.Property(e => e.SourceKey).HasColumnName("source_key");
entity.Property(e => e.SourceFamily).HasColumnName("source_family");
entity.Property(e => e.Region).HasColumnName("region");
entity.Property(e => e.Environment).HasColumnName("environment");
entity.Property(e => e.ImpactedDecisionsCount).HasColumnName("impacted_decisions_count");
entity.Property(e => e.ImpactSeverity).HasColumnName("impact_severity");
entity.Property(e => e.LastDecisionAt).HasColumnName("last_decision_at");
entity.Property(e => e.DecisionRefs).HasColumnType("jsonb").HasColumnName("decision_refs");
entity.Property(e => e.UpdatedAt).HasDefaultValueSql("now()").HasColumnName("updated_at");
entity.Property(e => e.UpdatedBy).HasColumnName("updated_by");
});
// === advisory_source_conflicts (Migration 005) ===
modelBuilder.Entity<AdvisorySourceConflictEntity>(entity =>
{
entity.HasKey(e => e.ConflictId).HasName("advisory_source_conflicts_pkey");
entity.ToTable("advisory_source_conflicts", schemaName);
entity.Property(e => e.ConflictId).HasDefaultValueSql("gen_random_uuid()").HasColumnName("conflict_id");
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
entity.Property(e => e.SourceKey).HasColumnName("source_key");
entity.Property(e => e.SourceFamily).HasColumnName("source_family");
entity.Property(e => e.AdvisoryId).HasColumnName("advisory_id");
entity.Property(e => e.PairedSourceKey).HasColumnName("paired_source_key");
entity.Property(e => e.ConflictType).HasColumnName("conflict_type");
entity.Property(e => e.Severity).HasColumnName("severity");
entity.Property(e => e.Status).HasColumnName("status");
entity.Property(e => e.Description).HasColumnName("description");
entity.Property(e => e.FirstDetectedAt).HasDefaultValueSql("now()").HasColumnName("first_detected_at");
entity.Property(e => e.LastDetectedAt).HasDefaultValueSql("now()").HasColumnName("last_detected_at");
entity.Property(e => e.ResolvedAt).HasColumnName("resolved_at");
entity.Property(e => e.DetailsJson).HasColumnType("jsonb").HasColumnName("details_json");
entity.Property(e => e.UpdatedAt).HasDefaultValueSql("now()").HasColumnName("updated_at");
entity.Property(e => e.UpdatedBy).HasColumnName("updated_by");
});
OnModelCreatingPartial(modelBuilder);
}
partial void OnModelCreatingPartial(ModelBuilder modelBuilder);
}

View File

@@ -0,0 +1,33 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
namespace StellaOps.Policy.Persistence.EfCore.Context;
/// <summary>
/// Design-time factory for dotnet ef CLI tooling.
/// Uses reflection-based model discovery (no compiled models).
/// </summary>
public sealed class PolicyDesignTimeDbContextFactory : IDesignTimeDbContextFactory<PolicyDbContext>
{
private const string DefaultConnectionString =
"Host=localhost;Port=55433;Database=postgres;Username=postgres;Password=postgres;Search Path=policy,public";
private const string ConnectionStringEnvironmentVariable =
"STELLAOPS_POLICY_EF_CONNECTION";
public PolicyDbContext CreateDbContext(string[] args)
{
var connectionString = ResolveConnectionString();
var options = new DbContextOptionsBuilder<PolicyDbContext>()
.UseNpgsql(connectionString)
.Options;
return new PolicyDbContext(options);
}
private static string ResolveConnectionString()
{
var fromEnvironment = Environment.GetEnvironmentVariable(ConnectionStringEnvironmentVariable);
return string.IsNullOrWhiteSpace(fromEnvironment) ? DefaultConnectionString : fromEnvironment;
}
}

View File

@@ -0,0 +1,25 @@
namespace StellaOps.Policy.Persistence.Postgres.Models;
/// <summary>
/// Entity representing advisory source conflict records.
/// Maps to policy.advisory_source_conflicts table (Migration 005).
/// </summary>
public sealed class AdvisorySourceConflictEntity
{
public Guid ConflictId { get; init; }
public required string TenantId { get; init; }
public required string SourceKey { get; init; }
public string SourceFamily { get; init; } = string.Empty;
public required string AdvisoryId { get; init; }
public string? PairedSourceKey { get; init; }
public required string ConflictType { get; init; }
public string Severity { get; init; } = "medium";
public string Status { get; init; } = "open";
public string Description { get; init; } = string.Empty;
public DateTimeOffset FirstDetectedAt { get; init; }
public DateTimeOffset LastDetectedAt { get; init; }
public DateTimeOffset? ResolvedAt { get; init; }
public string DetailsJson { get; init; } = "{}";
public DateTimeOffset UpdatedAt { get; init; }
public string UpdatedBy { get; init; } = "system";
}

View File

@@ -0,0 +1,21 @@
namespace StellaOps.Policy.Persistence.Postgres.Models;
/// <summary>
/// Entity representing advisory source impact projections.
/// Maps to policy.advisory_source_impacts table (Migration 005).
/// </summary>
public sealed class AdvisorySourceImpactEntity
{
public Guid Id { get; init; }
public required string TenantId { get; init; }
public required string SourceKey { get; init; }
public string SourceFamily { get; init; } = string.Empty;
public string Region { get; init; } = string.Empty;
public string Environment { get; init; } = string.Empty;
public int ImpactedDecisionsCount { get; init; }
public string ImpactSeverity { get; init; } = "none";
public DateTimeOffset? LastDecisionAt { get; init; }
public string DecisionRefs { get; init; } = "[]";
public DateTimeOffset UpdatedAt { get; init; }
public string UpdatedBy { get; init; } = "system";
}

View File

@@ -0,0 +1,24 @@
namespace StellaOps.Policy.Persistence.Postgres.Models;
/// <summary>
/// Entity representing a historical gate decision for audit and replay.
/// Maps to policy.gate_decisions table (Migration 003).
/// </summary>
public sealed class GateDecisionEntity
{
public Guid DecisionId { get; init; }
public Guid TenantId { get; init; }
public required string GateId { get; init; }
public required string BomRef { get; init; }
public string? ImageDigest { get; init; }
public required string GateStatus { get; init; }
public string? VerdictHash { get; init; }
public string? PolicyBundleId { get; init; }
public string? PolicyBundleHash { get; init; }
public DateTimeOffset EvaluatedAt { get; init; }
public string? CiContext { get; init; }
public string? Actor { get; init; }
public string? BlockingUnknownIds { get; init; }
public string? Warnings { get; init; }
public DateTimeOffset CreatedAt { get; init; }
}

View File

@@ -0,0 +1,27 @@
namespace StellaOps.Policy.Persistence.Postgres.Models;
/// <summary>
/// Entity representing a replay audit record for compliance tracking.
/// Maps to policy.replay_audit table (Migration 004).
/// </summary>
public sealed class ReplayAuditEntity
{
public Guid ReplayId { get; init; }
public Guid TenantId { get; init; }
public required string BomRef { get; init; }
public required string VerdictHash { get; init; }
public string? RekorUuid { get; init; }
public DateTimeOffset ReplayedAt { get; init; }
public bool Match { get; init; }
public string? OriginalHash { get; init; }
public string? ReplayedHash { get; init; }
public string? MismatchReason { get; init; }
public string? PolicyBundleId { get; init; }
public string? PolicyBundleHash { get; init; }
public string? VerifierDigest { get; init; }
public int? DurationMs { get; init; }
public string? Actor { get; init; }
public string? Source { get; init; }
public string? RequestContext { get; init; }
public DateTimeOffset CreatedAt { get; init; }
}

View File

@@ -0,0 +1,32 @@
using Microsoft.EntityFrameworkCore;
using Npgsql;
using StellaOps.Policy.Persistence.EfCore.CompiledModels;
using StellaOps.Policy.Persistence.EfCore.Context;
namespace StellaOps.Policy.Persistence.Postgres;
/// <summary>
/// Runtime factory for <see cref="PolicyDbContext"/>.
/// Uses the static compiled model for the default schema and falls back to
/// reflection-based model building for non-default schemas (integration tests).
/// </summary>
internal static class PolicyDbContextFactory
{
public static PolicyDbContext Create(NpgsqlConnection connection, int commandTimeoutSeconds, string? schemaName)
{
var normalizedSchema = string.IsNullOrWhiteSpace(schemaName)
? PolicyDataSource.DefaultSchemaName
: schemaName.Trim();
var optionsBuilder = new DbContextOptionsBuilder<PolicyDbContext>()
.UseNpgsql(connection, npgsql => npgsql.CommandTimeout(commandTimeoutSeconds));
// Use the static compiled model only when schema matches the default.
if (string.Equals(normalizedSchema, PolicyDataSource.DefaultSchemaName, StringComparison.Ordinal))
{
optionsBuilder.UseModel(PolicyDbContextModel.Instance);
}
return new PolicyDbContext(optionsBuilder.Options, normalizedSchema);
}
}

View File

@@ -1,3 +1,4 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Infrastructure.Postgres.Repositories;
@@ -7,6 +8,8 @@ namespace StellaOps.Policy.Persistence.Postgres.Repositories;
/// <summary>
/// PostgreSQL repository for conflict detection and resolution operations.
/// Uses EF Core for standard CRUD; raw SQL preserved for CASE severity ordering
/// and aggregate GROUP BY queries.
/// </summary>
public sealed class ConflictRepository : RepositoryBase<PolicyDataSource>, IConflictRepository
{
@@ -21,45 +24,27 @@ public sealed class ConflictRepository : RepositoryBase<PolicyDataSource>, IConf
/// <inheritdoc />
public async Task<ConflictEntity> CreateAsync(ConflictEntity conflict, CancellationToken cancellationToken = default)
{
const string sql = """
INSERT INTO policy.conflicts (
id, tenant_id, conflict_type, severity, status, left_rule_id,
right_rule_id, affected_scope, description, metadata, created_by
)
VALUES (
@id, @tenant_id, @conflict_type, @severity, @status, @left_rule_id,
@right_rule_id, @affected_scope, @description, @metadata::jsonb, @created_by
)
RETURNING *
""";
await using var connection = await DataSource.OpenConnectionAsync(conflict.TenantId, "writer", cancellationToken)
.ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
AddConflictParameters(command, conflict);
dbContext.Conflicts.Add(conflict);
await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
return MapConflict(reader);
return conflict;
}
/// <inheritdoc />
public async Task<ConflictEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = "SELECT * FROM policy.conflicts WHERE tenant_id = @tenant_id AND id = @id";
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken)
.ConfigureAwait(false);
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
return await QuerySingleOrDefaultAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "id", id);
},
MapConflict,
cancellationToken).ConfigureAwait(false);
return await dbContext.Conflicts
.AsNoTracking()
.FirstOrDefaultAsync(c => c.TenantId == tenantId && c.Id == id, cancellationToken)
.ConfigureAwait(false);
}
/// <inheritdoc />
@@ -69,6 +54,7 @@ public sealed class ConflictRepository : RepositoryBase<PolicyDataSource>, IConf
int offset = 0,
CancellationToken cancellationToken = default)
{
// Keep raw SQL: CASE severity ordering cannot be cleanly expressed in EF Core LINQ
const string sql = """
SELECT * FROM policy.conflicts
WHERE tenant_id = @tenant_id AND status = 'open'
@@ -104,33 +90,24 @@ public sealed class ConflictRepository : RepositoryBase<PolicyDataSource>, IConf
int limit = 100,
CancellationToken cancellationToken = default)
{
var sql = """
SELECT * FROM policy.conflicts
WHERE tenant_id = @tenant_id AND conflict_type = @conflict_type
""";
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken)
.ConfigureAwait(false);
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
var query = dbContext.Conflicts
.AsNoTracking()
.Where(c => c.TenantId == tenantId && c.ConflictType == conflictType);
if (!string.IsNullOrEmpty(status))
{
sql += " AND status = @status";
query = query.Where(c => c.Status == status);
}
sql += " ORDER BY created_at DESC LIMIT @limit";
return await QueryAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "conflict_type", conflictType);
AddParameter(cmd, "limit", limit);
if (!string.IsNullOrEmpty(status))
{
AddParameter(cmd, "status", status);
}
},
MapConflict,
cancellationToken).ConfigureAwait(false);
return await query
.OrderByDescending(c => c.CreatedAt)
.Take(limit)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
}
/// <inheritdoc />
@@ -141,6 +118,7 @@ public sealed class ConflictRepository : RepositoryBase<PolicyDataSource>, IConf
string resolvedBy,
CancellationToken cancellationToken = default)
{
// Keep raw SQL: conditional update WHERE status = 'open' with NOW()
const string sql = """
UPDATE policy.conflicts
SET status = 'resolved', resolution = @resolution, resolved_by = @resolved_by, resolved_at = NOW()
@@ -169,6 +147,7 @@ public sealed class ConflictRepository : RepositoryBase<PolicyDataSource>, IConf
string dismissedBy,
CancellationToken cancellationToken = default)
{
// Keep raw SQL: conditional update WHERE status = 'open' with NOW()
const string sql = """
UPDATE policy.conflicts
SET status = 'dismissed', resolved_by = @dismissed_by, resolved_at = NOW()
@@ -194,6 +173,7 @@ public sealed class ConflictRepository : RepositoryBase<PolicyDataSource>, IConf
string tenantId,
CancellationToken cancellationToken = default)
{
// Keep raw SQL: GROUP BY aggregate cannot be cleanly expressed as Dictionary return in EF Core
const string sql = """
SELECT severity, COUNT(*)::int as count
FROM policy.conflicts
@@ -220,21 +200,6 @@ public sealed class ConflictRepository : RepositoryBase<PolicyDataSource>, IConf
return results;
}
private static void AddConflictParameters(NpgsqlCommand command, ConflictEntity conflict)
{
AddParameter(command, "id", conflict.Id);
AddParameter(command, "tenant_id", conflict.TenantId);
AddParameter(command, "conflict_type", conflict.ConflictType);
AddParameter(command, "severity", conflict.Severity);
AddParameter(command, "status", conflict.Status);
AddParameter(command, "left_rule_id", conflict.LeftRuleId as object ?? DBNull.Value);
AddParameter(command, "right_rule_id", conflict.RightRuleId as object ?? DBNull.Value);
AddParameter(command, "affected_scope", conflict.AffectedScope as object ?? DBNull.Value);
AddParameter(command, "description", conflict.Description);
AddJsonbParameter(command, "metadata", conflict.Metadata);
AddParameter(command, "created_by", conflict.CreatedBy as object ?? DBNull.Value);
}
private static ConflictEntity MapConflict(NpgsqlDataReader reader) => new()
{
Id = reader.GetGuid(reader.GetOrdinal("id")),

View File

@@ -1,3 +1,4 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Infrastructure.Postgres.Repositories;
@@ -7,6 +8,8 @@ namespace StellaOps.Policy.Persistence.Postgres.Repositories;
/// <summary>
/// PostgreSQL repository for policy evaluation run operations.
/// Uses EF Core for reads and inserts; raw SQL preserved for conditional status transitions
/// and aggregate statistics queries.
/// </summary>
public sealed class EvaluationRunRepository : RepositoryBase<PolicyDataSource>, IEvaluationRunRepository
{
@@ -21,68 +24,27 @@ public sealed class EvaluationRunRepository : RepositoryBase<PolicyDataSource>,
/// <inheritdoc />
public async Task<EvaluationRunEntity> CreateAsync(EvaluationRunEntity run, CancellationToken cancellationToken = default)
{
const string sql = """
INSERT INTO policy.evaluation_runs (
id, tenant_id, project_id, artifact_id, pack_id, pack_version,
risk_profile_id, status, result, score,
findings_count, critical_count, high_count, medium_count, low_count,
input_hash, metadata, duration_ms, error_message, created_by
)
VALUES (
@id, @tenant_id, @project_id, @artifact_id, @pack_id, @pack_version,
@risk_profile_id, @status, @result, @score,
@findings_count, @critical_count, @high_count, @medium_count, @low_count,
@input_hash, @metadata::jsonb, @duration_ms, @error_message, @created_by
)
RETURNING *
""";
await using var connection = await DataSource.OpenConnectionAsync(run.TenantId, "writer", cancellationToken)
.ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
AddParameter(command, "id", run.Id);
AddParameter(command, "tenant_id", run.TenantId);
AddParameter(command, "project_id", run.ProjectId);
AddParameter(command, "artifact_id", run.ArtifactId);
AddParameter(command, "pack_id", run.PackId);
AddParameter(command, "pack_version", run.PackVersion);
AddParameter(command, "risk_profile_id", run.RiskProfileId);
AddParameter(command, "status", StatusToString(run.Status));
AddParameter(command, "result", run.Result.HasValue ? ResultToString(run.Result.Value) : null);
AddParameter(command, "score", run.Score);
AddParameter(command, "findings_count", run.FindingsCount);
AddParameter(command, "critical_count", run.CriticalCount);
AddParameter(command, "high_count", run.HighCount);
AddParameter(command, "medium_count", run.MediumCount);
AddParameter(command, "low_count", run.LowCount);
AddParameter(command, "input_hash", run.InputHash);
AddJsonbParameter(command, "metadata", run.Metadata);
AddParameter(command, "duration_ms", run.DurationMs);
AddParameter(command, "error_message", run.ErrorMessage);
AddParameter(command, "created_by", run.CreatedBy);
dbContext.EvaluationRuns.Add(run);
await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
return MapRun(reader);
return run;
}
/// <inheritdoc />
public async Task<EvaluationRunEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = "SELECT * FROM policy.evaluation_runs WHERE tenant_id = @tenant_id AND id = @id";
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken)
.ConfigureAwait(false);
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
return await QuerySingleOrDefaultAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "id", id);
},
MapRun,
cancellationToken).ConfigureAwait(false);
return await dbContext.EvaluationRuns
.AsNoTracking()
.FirstOrDefaultAsync(r => r.TenantId == tenantId && r.Id == id, cancellationToken)
.ConfigureAwait(false);
}
/// <inheritdoc />
@@ -93,25 +55,18 @@ public sealed class EvaluationRunRepository : RepositoryBase<PolicyDataSource>,
int offset = 0,
CancellationToken cancellationToken = default)
{
const string sql = """
SELECT * FROM policy.evaluation_runs
WHERE tenant_id = @tenant_id AND project_id = @project_id
ORDER BY created_at DESC, id
LIMIT @limit OFFSET @offset
""";
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken)
.ConfigureAwait(false);
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
return await QueryAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "project_id", projectId);
AddParameter(cmd, "limit", limit);
AddParameter(cmd, "offset", offset);
},
MapRun,
cancellationToken).ConfigureAwait(false);
return await dbContext.EvaluationRuns
.AsNoTracking()
.Where(r => r.TenantId == tenantId && r.ProjectId == projectId)
.OrderByDescending(r => r.CreatedAt).ThenBy(r => r.Id)
.Skip(offset)
.Take(limit)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
}
/// <inheritdoc />
@@ -121,24 +76,17 @@ public sealed class EvaluationRunRepository : RepositoryBase<PolicyDataSource>,
int limit = 100,
CancellationToken cancellationToken = default)
{
const string sql = """
SELECT * FROM policy.evaluation_runs
WHERE tenant_id = @tenant_id AND artifact_id = @artifact_id
ORDER BY created_at DESC, id
LIMIT @limit
""";
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken)
.ConfigureAwait(false);
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
return await QueryAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "artifact_id", artifactId);
AddParameter(cmd, "limit", limit);
},
MapRun,
cancellationToken).ConfigureAwait(false);
return await dbContext.EvaluationRuns
.AsNoTracking()
.Where(r => r.TenantId == tenantId && r.ArtifactId == artifactId)
.OrderByDescending(r => r.CreatedAt).ThenBy(r => r.Id)
.Take(limit)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
}
/// <inheritdoc />
@@ -148,24 +96,17 @@ public sealed class EvaluationRunRepository : RepositoryBase<PolicyDataSource>,
int limit = 100,
CancellationToken cancellationToken = default)
{
const string sql = """
SELECT * FROM policy.evaluation_runs
WHERE tenant_id = @tenant_id AND status = @status
ORDER BY created_at, id
LIMIT @limit
""";
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken)
.ConfigureAwait(false);
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
return await QueryAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "status", StatusToString(status));
AddParameter(cmd, "limit", limit);
},
MapRun,
cancellationToken).ConfigureAwait(false);
return await dbContext.EvaluationRuns
.AsNoTracking()
.Where(r => r.TenantId == tenantId && r.Status == status)
.OrderBy(r => r.CreatedAt).ThenBy(r => r.Id)
.Take(limit)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
}
/// <inheritdoc />
@@ -174,28 +115,23 @@ public sealed class EvaluationRunRepository : RepositoryBase<PolicyDataSource>,
int limit = 50,
CancellationToken cancellationToken = default)
{
const string sql = """
SELECT * FROM policy.evaluation_runs
WHERE tenant_id = @tenant_id
ORDER BY created_at DESC, id
LIMIT @limit
""";
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken)
.ConfigureAwait(false);
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
return await QueryAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "limit", limit);
},
MapRun,
cancellationToken).ConfigureAwait(false);
return await dbContext.EvaluationRuns
.AsNoTracking()
.Where(r => r.TenantId == tenantId)
.OrderByDescending(r => r.CreatedAt).ThenBy(r => r.Id)
.Take(limit)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<bool> MarkStartedAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
// Keep raw SQL: conditional status transition WHERE status = 'pending' with NOW()
const string sql = """
UPDATE policy.evaluation_runs
SET status = 'running',
@@ -230,6 +166,7 @@ public sealed class EvaluationRunRepository : RepositoryBase<PolicyDataSource>,
int durationMs,
CancellationToken cancellationToken = default)
{
// Keep raw SQL: conditional status transition WHERE status = 'running' with NOW() and multi-column update
const string sql = """
UPDATE policy.evaluation_runs
SET status = 'completed',
@@ -273,6 +210,7 @@ public sealed class EvaluationRunRepository : RepositoryBase<PolicyDataSource>,
string errorMessage,
CancellationToken cancellationToken = default)
{
// Keep raw SQL: conditional status transition WHERE status IN ('pending', 'running')
const string sql = """
UPDATE policy.evaluation_runs
SET status = 'failed',
@@ -303,6 +241,7 @@ public sealed class EvaluationRunRepository : RepositoryBase<PolicyDataSource>,
DateTimeOffset to,
CancellationToken cancellationToken = default)
{
// Keep raw SQL: FILTER, AVG, SUM aggregate functions cannot be expressed in single EF Core query
const string sql = """
SELECT
COUNT(*) as total,
@@ -343,51 +282,6 @@ public sealed class EvaluationRunRepository : RepositoryBase<PolicyDataSource>,
HighFindings: reader.IsDBNull(8) ? 0 : reader.GetInt64(8));
}
private static EvaluationRunEntity MapRun(NpgsqlDataReader reader) => new()
{
Id = reader.GetGuid(reader.GetOrdinal("id")),
TenantId = reader.GetString(reader.GetOrdinal("tenant_id")),
ProjectId = GetNullableString(reader, reader.GetOrdinal("project_id")),
ArtifactId = GetNullableString(reader, reader.GetOrdinal("artifact_id")),
PackId = GetNullableGuid(reader, reader.GetOrdinal("pack_id")),
PackVersion = GetNullableInt(reader, reader.GetOrdinal("pack_version")),
RiskProfileId = GetNullableGuid(reader, reader.GetOrdinal("risk_profile_id")),
Status = ParseStatus(reader.GetString(reader.GetOrdinal("status"))),
Result = GetNullableResult(reader, reader.GetOrdinal("result")),
Score = GetNullableDecimal(reader, reader.GetOrdinal("score")),
FindingsCount = reader.GetInt32(reader.GetOrdinal("findings_count")),
CriticalCount = reader.GetInt32(reader.GetOrdinal("critical_count")),
HighCount = reader.GetInt32(reader.GetOrdinal("high_count")),
MediumCount = reader.GetInt32(reader.GetOrdinal("medium_count")),
LowCount = reader.GetInt32(reader.GetOrdinal("low_count")),
InputHash = GetNullableString(reader, reader.GetOrdinal("input_hash")),
DurationMs = GetNullableInt(reader, reader.GetOrdinal("duration_ms")),
ErrorMessage = GetNullableString(reader, reader.GetOrdinal("error_message")),
Metadata = reader.GetString(reader.GetOrdinal("metadata")),
CreatedAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("created_at")),
StartedAt = GetNullableDateTimeOffset(reader, reader.GetOrdinal("started_at")),
CompletedAt = GetNullableDateTimeOffset(reader, reader.GetOrdinal("completed_at")),
CreatedBy = GetNullableString(reader, reader.GetOrdinal("created_by"))
};
private static string StatusToString(EvaluationStatus status) => status switch
{
EvaluationStatus.Pending => "pending",
EvaluationStatus.Running => "running",
EvaluationStatus.Completed => "completed",
EvaluationStatus.Failed => "failed",
_ => throw new ArgumentException($"Unknown status: {status}", nameof(status))
};
private static EvaluationStatus ParseStatus(string status) => status switch
{
"pending" => EvaluationStatus.Pending,
"running" => EvaluationStatus.Running,
"completed" => EvaluationStatus.Completed,
"failed" => EvaluationStatus.Failed,
_ => throw new ArgumentException($"Unknown status: {status}", nameof(status))
};
private static string ResultToString(EvaluationResult result) => result switch
{
EvaluationResult.Pass => "pass",
@@ -396,28 +290,4 @@ public sealed class EvaluationRunRepository : RepositoryBase<PolicyDataSource>,
EvaluationResult.Error => "error",
_ => throw new ArgumentException($"Unknown result: {result}", nameof(result))
};
private static EvaluationResult ParseResult(string result) => result switch
{
"pass" => EvaluationResult.Pass,
"fail" => EvaluationResult.Fail,
"warn" => EvaluationResult.Warn,
"error" => EvaluationResult.Error,
_ => throw new ArgumentException($"Unknown result: {result}", nameof(result))
};
private static int? GetNullableInt(NpgsqlDataReader reader, int ordinal)
{
return reader.IsDBNull(ordinal) ? null : reader.GetInt32(ordinal);
}
private static new decimal? GetNullableDecimal(NpgsqlDataReader reader, int ordinal)
{
return reader.IsDBNull(ordinal) ? null : reader.GetDecimal(ordinal);
}
private static EvaluationResult? GetNullableResult(NpgsqlDataReader reader, int ordinal)
{
return reader.IsDBNull(ordinal) ? null : ParseResult(reader.GetString(ordinal));
}
}

View File

@@ -1,3 +1,4 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Determinism;
@@ -8,6 +9,8 @@ namespace StellaOps.Policy.Persistence.Postgres.Repositories;
/// <summary>
/// PostgreSQL repository for explanation operations.
/// Uses EF Core for reads and deletes; raw SQL preserved for inserts that require
/// deterministic ID generation via IGuidProvider.
/// </summary>
public sealed class ExplanationRepository : RepositoryBase<PolicyDataSource>, IExplanationRepository
{
@@ -24,54 +27,44 @@ public sealed class ExplanationRepository : RepositoryBase<PolicyDataSource>, IE
public async Task<ExplanationEntity?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, evaluation_run_id, rule_id, rule_name, result, severity, message, details, remediation, resource_path, line_number, created_at
FROM policy.explanations WHERE id = @id
""";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "id", id);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
return await reader.ReadAsync(cancellationToken).ConfigureAwait(false) ? MapExplanation(reader) : null;
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
return await dbContext.Explanations
.AsNoTracking()
.FirstOrDefaultAsync(e => e.Id == id, cancellationToken)
.ConfigureAwait(false);
}
public async Task<IReadOnlyList<ExplanationEntity>> GetByEvaluationRunIdAsync(Guid evaluationRunId, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, evaluation_run_id, rule_id, rule_name, result, severity, message, details, remediation, resource_path, line_number, created_at
FROM policy.explanations WHERE evaluation_run_id = @evaluation_run_id
ORDER BY created_at
""";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "evaluation_run_id", evaluationRunId);
var results = new List<ExplanationEntity>();
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
results.Add(MapExplanation(reader));
return results;
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
return await dbContext.Explanations
.AsNoTracking()
.Where(e => e.EvaluationRunId == evaluationRunId)
.OrderBy(e => e.CreatedAt)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
}
public async Task<IReadOnlyList<ExplanationEntity>> GetByEvaluationRunIdAndResultAsync(Guid evaluationRunId, RuleResult result, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, evaluation_run_id, rule_id, rule_name, result, severity, message, details, remediation, resource_path, line_number, created_at
FROM policy.explanations WHERE evaluation_run_id = @evaluation_run_id AND result = @result
ORDER BY severity DESC, created_at
""";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "evaluation_run_id", evaluationRunId);
AddParameter(command, "result", ResultToString(result));
var results = new List<ExplanationEntity>();
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
results.Add(MapExplanation(reader));
return results;
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
return await dbContext.Explanations
.AsNoTracking()
.Where(e => e.EvaluationRunId == evaluationRunId && e.Result == result)
.OrderByDescending(e => e.Severity).ThenBy(e => e.CreatedAt)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
}
public async Task<ExplanationEntity> CreateAsync(ExplanationEntity explanation, CancellationToken cancellationToken = default)
{
// Keep raw SQL: deterministic ID generation via IGuidProvider requires mutation before insert
const string sql = """
INSERT INTO policy.explanations (id, evaluation_run_id, rule_id, rule_name, result, severity, message, details, remediation, resource_path, line_number)
VALUES (@id, @evaluation_run_id, @rule_id, @rule_name, @result, @severity, @message, @details::jsonb, @remediation, @resource_path, @line_number)
@@ -99,6 +92,7 @@ public sealed class ExplanationRepository : RepositoryBase<PolicyDataSource>, IE
public async Task<int> CreateBatchAsync(IEnumerable<ExplanationEntity> explanations, CancellationToken cancellationToken = default)
{
// Keep raw SQL: deterministic ID generation via IGuidProvider requires mutation before insert
const string sql = """
INSERT INTO policy.explanations (id, evaluation_run_id, rule_id, rule_name, result, severity, message, details, remediation, resource_path, line_number)
VALUES (@id, @evaluation_run_id, @rule_id, @rule_name, @result, @severity, @message, @details::jsonb, @remediation, @resource_path, @line_number)
@@ -127,11 +121,14 @@ public sealed class ExplanationRepository : RepositoryBase<PolicyDataSource>, IE
public async Task<bool> DeleteByEvaluationRunIdAsync(Guid evaluationRunId, CancellationToken cancellationToken = default)
{
const string sql = "DELETE FROM policy.explanations WHERE evaluation_run_id = @evaluation_run_id";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "evaluation_run_id", evaluationRunId);
var rows = await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
var rows = await dbContext.Explanations
.Where(e => e.EvaluationRunId == evaluationRunId)
.ExecuteDeleteAsync(cancellationToken)
.ConfigureAwait(false);
return rows > 0;
}

View File

@@ -3,8 +3,10 @@
// Sprint: SPRINT_20260118_017_Policy_gate_attestation_verification
// Task: TASK-017-006 - Gate Bypass Audit Persistence
// Description: PostgreSQL implementation of gate bypass audit repository
// Converted to EF Core for reads; raw SQL preserved for compliance-critical operations.
// -----------------------------------------------------------------------------
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Infrastructure.Postgres.Repositories;
@@ -15,6 +17,8 @@ namespace StellaOps.Policy.Persistence.Postgres.Repositories;
/// <summary>
/// PostgreSQL repository for gate bypass audit entries.
/// Records are immutable (append-only) for compliance requirements.
/// Uses EF Core for reads; raw SQL preserved for insert with RETURNING id
/// and aggregate COUNT queries.
/// </summary>
/// <remarks>
/// This table uses insert-only semantics. UPDATE and DELETE operations are not exposed
@@ -30,55 +34,14 @@ public sealed class GateBypassAuditRepository : RepositoryBase<PolicyDataSource>
GateBypassAuditEntity entry,
CancellationToken cancellationToken = default)
{
const string sql = """
INSERT INTO policy.gate_bypass_audit (
id, tenant_id, timestamp, decision_id, image_digest, repository, tag,
baseline_ref, original_decision, final_decision, bypassed_gates,
actor, actor_subject, actor_email, actor_ip_address, justification,
policy_id, source, ci_context, attestation_digest, rekor_uuid,
bypass_type, expires_at, metadata, created_at
) VALUES (
@id, @tenant_id, @timestamp, @decision_id, @image_digest, @repository, @tag,
@baseline_ref, @original_decision, @final_decision, @bypassed_gates::jsonb,
@actor, @actor_subject, @actor_email, @actor_ip_address, @justification,
@policy_id, @source, @ci_context, @attestation_digest, @rekor_uuid,
@bypass_type, @expires_at, @metadata::jsonb, @created_at
)
RETURNING id
""";
await using var connection = await DataSource.OpenConnectionAsync(entry.TenantId, "writer", cancellationToken)
.ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
AddParameter(command, "id", entry.Id);
AddParameter(command, "tenant_id", entry.TenantId);
AddParameter(command, "timestamp", entry.Timestamp);
AddParameter(command, "decision_id", entry.DecisionId);
AddParameter(command, "image_digest", entry.ImageDigest);
AddParameter(command, "repository", entry.Repository);
AddParameter(command, "tag", entry.Tag);
AddParameter(command, "baseline_ref", entry.BaselineRef);
AddParameter(command, "original_decision", entry.OriginalDecision);
AddParameter(command, "final_decision", entry.FinalDecision);
AddJsonbParameter(command, "bypassed_gates", entry.BypassedGates);
AddParameter(command, "actor", entry.Actor);
AddParameter(command, "actor_subject", entry.ActorSubject);
AddParameter(command, "actor_email", entry.ActorEmail);
AddParameter(command, "actor_ip_address", entry.ActorIpAddress);
AddParameter(command, "justification", entry.Justification);
AddParameter(command, "policy_id", entry.PolicyId);
AddParameter(command, "source", entry.Source);
AddParameter(command, "ci_context", entry.CiContext);
AddParameter(command, "attestation_digest", entry.AttestationDigest);
AddParameter(command, "rekor_uuid", entry.RekorUuid);
AddParameter(command, "bypass_type", entry.BypassType);
AddParameter(command, "expires_at", entry.ExpiresAt);
AddJsonbParameter(command, "metadata", entry.Metadata);
AddParameter(command, "created_at", entry.CreatedAt);
dbContext.GateBypassAudit.Add(entry);
await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
var result = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
return (Guid)result!;
return entry.Id;
}
/// <inheritdoc />
@@ -87,23 +50,14 @@ public sealed class GateBypassAuditRepository : RepositoryBase<PolicyDataSource>
Guid id,
CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, timestamp, decision_id, image_digest, repository, tag,
baseline_ref, original_decision, final_decision, bypassed_gates,
actor, actor_subject, actor_email, actor_ip_address, justification,
policy_id, source, ci_context, attestation_digest, rekor_uuid,
bypass_type, expires_at, metadata, created_at
FROM policy.gate_bypass_audit
WHERE tenant_id = @tenant_id AND id = @id
""";
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken)
.ConfigureAwait(false);
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
var results = await QueryAsync(tenantId, sql, cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "id", id);
}, MapEntity, cancellationToken).ConfigureAwait(false);
return results.Count > 0 ? results[0] : null;
return await dbContext.GateBypassAudit
.AsNoTracking()
.FirstOrDefaultAsync(e => e.TenantId == tenantId && e.Id == id, cancellationToken)
.ConfigureAwait(false);
}
/// <inheritdoc />
@@ -112,22 +66,16 @@ public sealed class GateBypassAuditRepository : RepositoryBase<PolicyDataSource>
string decisionId,
CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, timestamp, decision_id, image_digest, repository, tag,
baseline_ref, original_decision, final_decision, bypassed_gates,
actor, actor_subject, actor_email, actor_ip_address, justification,
policy_id, source, ci_context, attestation_digest, rekor_uuid,
bypass_type, expires_at, metadata, created_at
FROM policy.gate_bypass_audit
WHERE tenant_id = @tenant_id AND decision_id = @decision_id
ORDER BY timestamp DESC
""";
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken)
.ConfigureAwait(false);
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
return await QueryAsync(tenantId, sql, cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "decision_id", decisionId);
}, MapEntity, cancellationToken).ConfigureAwait(false);
return await dbContext.GateBypassAudit
.AsNoTracking()
.Where(e => e.TenantId == tenantId && e.DecisionId == decisionId)
.OrderByDescending(e => e.Timestamp)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
}
/// <inheritdoc />
@@ -137,24 +85,17 @@ public sealed class GateBypassAuditRepository : RepositoryBase<PolicyDataSource>
int limit = 100,
CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, timestamp, decision_id, image_digest, repository, tag,
baseline_ref, original_decision, final_decision, bypassed_gates,
actor, actor_subject, actor_email, actor_ip_address, justification,
policy_id, source, ci_context, attestation_digest, rekor_uuid,
bypass_type, expires_at, metadata, created_at
FROM policy.gate_bypass_audit
WHERE tenant_id = @tenant_id AND actor = @actor
ORDER BY timestamp DESC
LIMIT @limit
""";
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken)
.ConfigureAwait(false);
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
return await QueryAsync(tenantId, sql, cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "actor", actor);
AddParameter(cmd, "limit", limit);
}, MapEntity, cancellationToken).ConfigureAwait(false);
return await dbContext.GateBypassAudit
.AsNoTracking()
.Where(e => e.TenantId == tenantId && e.Actor == actor)
.OrderByDescending(e => e.Timestamp)
.Take(limit)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
}
/// <inheritdoc />
@@ -164,24 +105,17 @@ public sealed class GateBypassAuditRepository : RepositoryBase<PolicyDataSource>
int limit = 100,
CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, timestamp, decision_id, image_digest, repository, tag,
baseline_ref, original_decision, final_decision, bypassed_gates,
actor, actor_subject, actor_email, actor_ip_address, justification,
policy_id, source, ci_context, attestation_digest, rekor_uuid,
bypass_type, expires_at, metadata, created_at
FROM policy.gate_bypass_audit
WHERE tenant_id = @tenant_id AND image_digest = @image_digest
ORDER BY timestamp DESC
LIMIT @limit
""";
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken)
.ConfigureAwait(false);
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
return await QueryAsync(tenantId, sql, cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "image_digest", imageDigest);
AddParameter(cmd, "limit", limit);
}, MapEntity, cancellationToken).ConfigureAwait(false);
return await dbContext.GateBypassAudit
.AsNoTracking()
.Where(e => e.TenantId == tenantId && e.ImageDigest == imageDigest)
.OrderByDescending(e => e.Timestamp)
.Take(limit)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
}
/// <inheritdoc />
@@ -191,24 +125,18 @@ public sealed class GateBypassAuditRepository : RepositoryBase<PolicyDataSource>
int offset = 0,
CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, timestamp, decision_id, image_digest, repository, tag,
baseline_ref, original_decision, final_decision, bypassed_gates,
actor, actor_subject, actor_email, actor_ip_address, justification,
policy_id, source, ci_context, attestation_digest, rekor_uuid,
bypass_type, expires_at, metadata, created_at
FROM policy.gate_bypass_audit
WHERE tenant_id = @tenant_id
ORDER BY timestamp DESC
LIMIT @limit OFFSET @offset
""";
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken)
.ConfigureAwait(false);
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
return await QueryAsync(tenantId, sql, cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "limit", limit);
AddParameter(cmd, "offset", offset);
}, MapEntity, cancellationToken).ConfigureAwait(false);
return await dbContext.GateBypassAudit
.AsNoTracking()
.Where(e => e.TenantId == tenantId)
.OrderByDescending(e => e.Timestamp)
.Skip(offset)
.Take(limit)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
}
/// <inheritdoc />
@@ -219,25 +147,17 @@ public sealed class GateBypassAuditRepository : RepositoryBase<PolicyDataSource>
int limit = 1000,
CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, timestamp, decision_id, image_digest, repository, tag,
baseline_ref, original_decision, final_decision, bypassed_gates,
actor, actor_subject, actor_email, actor_ip_address, justification,
policy_id, source, ci_context, attestation_digest, rekor_uuid,
bypass_type, expires_at, metadata, created_at
FROM policy.gate_bypass_audit
WHERE tenant_id = @tenant_id AND timestamp >= @from AND timestamp < @to
ORDER BY timestamp DESC
LIMIT @limit
""";
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken)
.ConfigureAwait(false);
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
return await QueryAsync(tenantId, sql, cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "from", from);
AddParameter(cmd, "to", to);
AddParameter(cmd, "limit", limit);
}, MapEntity, cancellationToken).ConfigureAwait(false);
return await dbContext.GateBypassAudit
.AsNoTracking()
.Where(e => e.TenantId == tenantId && e.Timestamp >= from && e.Timestamp < to)
.OrderByDescending(e => e.Timestamp)
.Take(limit)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
}
/// <inheritdoc />
@@ -247,22 +167,13 @@ public sealed class GateBypassAuditRepository : RepositoryBase<PolicyDataSource>
DateTimeOffset since,
CancellationToken cancellationToken = default)
{
const string sql = """
SELECT COUNT(*)
FROM policy.gate_bypass_audit
WHERE tenant_id = @tenant_id AND actor = @actor AND timestamp >= @since
""";
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken)
.ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
AddParameter(command, "tenant_id", tenantId);
AddParameter(command, "actor", actor);
AddParameter(command, "since", since);
var result = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
return Convert.ToInt32(result);
return await dbContext.GateBypassAudit
.CountAsync(e => e.TenantId == tenantId && e.Actor == actor && e.Timestamp >= since, cancellationToken)
.ConfigureAwait(false);
}
/// <inheritdoc />
@@ -273,51 +184,15 @@ public sealed class GateBypassAuditRepository : RepositoryBase<PolicyDataSource>
CancellationToken cancellationToken = default)
{
// Export in chronological order for compliance reporting
const string sql = """
SELECT id, tenant_id, timestamp, decision_id, image_digest, repository, tag,
baseline_ref, original_decision, final_decision, bypassed_gates,
actor, actor_subject, actor_email, actor_ip_address, justification,
policy_id, source, ci_context, attestation_digest, rekor_uuid,
bypass_type, expires_at, metadata, created_at
FROM policy.gate_bypass_audit
WHERE tenant_id = @tenant_id AND timestamp >= @from AND timestamp < @to
ORDER BY timestamp ASC
""";
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken)
.ConfigureAwait(false);
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
return await QueryAsync(tenantId, sql, cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "from", from);
AddParameter(cmd, "to", to);
}, MapEntity, cancellationToken).ConfigureAwait(false);
return await dbContext.GateBypassAudit
.AsNoTracking()
.Where(e => e.TenantId == tenantId && e.Timestamp >= from && e.Timestamp < to)
.OrderBy(e => e.Timestamp)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
}
private static GateBypassAuditEntity MapEntity(NpgsqlDataReader reader) => new()
{
Id = reader.GetGuid(0),
TenantId = reader.GetString(1),
Timestamp = reader.GetFieldValue<DateTimeOffset>(2),
DecisionId = reader.GetString(3),
ImageDigest = reader.GetString(4),
Repository = GetNullableString(reader, 5),
Tag = GetNullableString(reader, 6),
BaselineRef = GetNullableString(reader, 7),
OriginalDecision = reader.GetString(8),
FinalDecision = reader.GetString(9),
BypassedGates = reader.GetString(10),
Actor = reader.GetString(11),
ActorSubject = GetNullableString(reader, 12),
ActorEmail = GetNullableString(reader, 13),
ActorIpAddress = GetNullableString(reader, 14),
Justification = reader.GetString(15),
PolicyId = GetNullableString(reader, 16),
Source = GetNullableString(reader, 17),
CiContext = GetNullableString(reader, 18),
AttestationDigest = GetNullableString(reader, 19),
RekorUuid = GetNullableString(reader, 20),
BypassType = reader.GetString(21),
ExpiresAt = GetNullableDateTimeOffset(reader, 22),
Metadata = GetNullableString(reader, 23),
CreatedAt = reader.GetFieldValue<DateTimeOffset>(24)
};
}

View File

@@ -1,3 +1,4 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Infrastructure.Postgres.Repositories;
@@ -7,6 +8,8 @@ namespace StellaOps.Policy.Persistence.Postgres.Repositories;
/// <summary>
/// PostgreSQL repository for ledger export operations.
/// Uses EF Core for reads and inserts; raw SQL preserved for
/// conditional CASE updates and system-connection queries.
/// </summary>
public sealed class LedgerExportRepository : RepositoryBase<PolicyDataSource>, ILedgerExportRepository
{
@@ -21,61 +24,39 @@ public sealed class LedgerExportRepository : RepositoryBase<PolicyDataSource>, I
/// <inheritdoc />
public async Task<LedgerExportEntity> CreateAsync(LedgerExportEntity export, CancellationToken cancellationToken = default)
{
const string sql = """
INSERT INTO policy.ledger_exports (
id, tenant_id, export_type, status, format, metadata, created_by
)
VALUES (
@id, @tenant_id, @export_type, @status, @format, @metadata::jsonb, @created_by
)
RETURNING *
""";
await using var connection = await DataSource.OpenConnectionAsync(export.TenantId, "writer", cancellationToken)
.ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
AddExportParameters(command, export);
dbContext.LedgerExports.Add(export);
await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
return MapExport(reader);
return export;
}
/// <inheritdoc />
public async Task<LedgerExportEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = "SELECT * FROM policy.ledger_exports WHERE tenant_id = @tenant_id AND id = @id";
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken)
.ConfigureAwait(false);
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
return await QuerySingleOrDefaultAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "id", id);
},
MapExport,
cancellationToken).ConfigureAwait(false);
return await dbContext.LedgerExports
.AsNoTracking()
.FirstOrDefaultAsync(e => e.TenantId == tenantId && e.Id == id, cancellationToken)
.ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<LedgerExportEntity?> GetByDigestAsync(string contentDigest, CancellationToken cancellationToken = default)
{
const string sql = "SELECT * FROM policy.ledger_exports WHERE content_digest = @content_digest LIMIT 1";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "content_digest", contentDigest);
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
if (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
return MapExport(reader);
}
return null;
return await dbContext.LedgerExports
.AsNoTracking()
.FirstOrDefaultAsync(e => e.ContentDigest == contentDigest, cancellationToken)
.ConfigureAwait(false);
}
/// <inheritdoc />
@@ -86,30 +67,23 @@ public sealed class LedgerExportRepository : RepositoryBase<PolicyDataSource>, I
int offset = 0,
CancellationToken cancellationToken = default)
{
var sql = "SELECT * FROM policy.ledger_exports WHERE tenant_id = @tenant_id";
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken)
.ConfigureAwait(false);
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
var query = dbContext.LedgerExports.AsNoTracking().Where(e => e.TenantId == tenantId);
if (!string.IsNullOrEmpty(status))
{
sql += " AND status = @status";
query = query.Where(e => e.Status == status);
}
sql += " ORDER BY created_at DESC LIMIT @limit OFFSET @offset";
return await QueryAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "limit", limit);
AddParameter(cmd, "offset", offset);
if (!string.IsNullOrEmpty(status))
{
AddParameter(cmd, "status", status);
}
},
MapExport,
cancellationToken).ConfigureAwait(false);
return await query
.OrderByDescending(e => e.CreatedAt)
.Skip(offset)
.Take(limit)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
}
/// <inheritdoc />
@@ -120,6 +94,7 @@ public sealed class LedgerExportRepository : RepositoryBase<PolicyDataSource>, I
string? errorMessage = null,
CancellationToken cancellationToken = default)
{
// Keep raw SQL: CASE conditional update for start_time cannot be expressed in EF Core
const string sql = """
UPDATE policy.ledger_exports
SET status = @status, error_message = @error_message,
@@ -152,6 +127,7 @@ public sealed class LedgerExportRepository : RepositoryBase<PolicyDataSource>, I
string? storagePath,
CancellationToken cancellationToken = default)
{
// Keep raw SQL: multi-column update with NOW()
const string sql = """
UPDATE policy.ledger_exports
SET status = 'completed',
@@ -186,68 +162,22 @@ public sealed class LedgerExportRepository : RepositoryBase<PolicyDataSource>, I
string? exportType = null,
CancellationToken cancellationToken = default)
{
var sql = """
SELECT * FROM policy.ledger_exports
WHERE tenant_id = @tenant_id AND status = 'completed'
""";
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken)
.ConfigureAwait(false);
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
var query = dbContext.LedgerExports
.AsNoTracking()
.Where(e => e.TenantId == tenantId && e.Status == "completed");
if (!string.IsNullOrEmpty(exportType))
{
sql += " AND export_type = @export_type";
query = query.Where(e => e.ExportType == exportType);
}
sql += " ORDER BY end_time DESC LIMIT 1";
return await QuerySingleOrDefaultAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
if (!string.IsNullOrEmpty(exportType))
{
AddParameter(cmd, "export_type", exportType);
}
},
MapExport,
cancellationToken).ConfigureAwait(false);
return await query
.OrderByDescending(e => e.EndTime)
.FirstOrDefaultAsync(cancellationToken)
.ConfigureAwait(false);
}
private static void AddExportParameters(NpgsqlCommand command, LedgerExportEntity export)
{
AddParameter(command, "id", export.Id);
AddParameter(command, "tenant_id", export.TenantId);
AddParameter(command, "export_type", export.ExportType);
AddParameter(command, "status", export.Status);
AddParameter(command, "format", export.Format);
AddJsonbParameter(command, "metadata", export.Metadata);
AddParameter(command, "created_by", export.CreatedBy as object ?? DBNull.Value);
}
private static LedgerExportEntity MapExport(NpgsqlDataReader reader) => new()
{
Id = reader.GetGuid(reader.GetOrdinal("id")),
TenantId = reader.GetString(reader.GetOrdinal("tenant_id")),
ExportType = reader.GetString(reader.GetOrdinal("export_type")),
Status = reader.GetString(reader.GetOrdinal("status")),
Format = reader.GetString(reader.GetOrdinal("format")),
ContentDigest = GetNullableString(reader, reader.GetOrdinal("content_digest")),
RecordCount = reader.IsDBNull(reader.GetOrdinal("record_count"))
? null
: reader.GetInt32(reader.GetOrdinal("record_count")),
ByteSize = reader.IsDBNull(reader.GetOrdinal("byte_size"))
? null
: reader.GetInt64(reader.GetOrdinal("byte_size")),
StoragePath = GetNullableString(reader, reader.GetOrdinal("storage_path")),
StartTime = reader.IsDBNull(reader.GetOrdinal("start_time"))
? null
: reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("start_time")),
EndTime = reader.IsDBNull(reader.GetOrdinal("end_time"))
? null
: reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("end_time")),
ErrorMessage = GetNullableString(reader, reader.GetOrdinal("error_message")),
Metadata = reader.GetString(reader.GetOrdinal("metadata")),
CreatedAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("created_at")),
CreatedBy = GetNullableString(reader, reader.GetOrdinal("created_by"))
};
}

View File

@@ -1,3 +1,4 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Infrastructure.Postgres.Repositories;
@@ -7,6 +8,7 @@ namespace StellaOps.Policy.Persistence.Postgres.Repositories;
/// <summary>
/// PostgreSQL repository for policy pack operations.
/// Uses EF Core for standard CRUD; raw SQL preserved where needed.
/// </summary>
public sealed class PackRepository : RepositoryBase<PolicyDataSource>, IPackRepository
{
@@ -21,71 +23,40 @@ public sealed class PackRepository : RepositoryBase<PolicyDataSource>, IPackRepo
/// <inheritdoc />
public async Task<PackEntity> CreateAsync(PackEntity pack, CancellationToken cancellationToken = default)
{
const string sql = """
INSERT INTO policy.packs (
id, tenant_id, name, display_name, description, active_version,
is_builtin, is_deprecated, metadata, created_by
)
VALUES (
@id, @tenant_id, @name, @display_name, @description, @active_version,
@is_builtin, @is_deprecated, @metadata::jsonb, @created_by
)
RETURNING *
""";
await using var connection = await DataSource.OpenConnectionAsync(pack.TenantId, "writer", cancellationToken)
.ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
AddParameter(command, "id", pack.Id);
AddParameter(command, "tenant_id", pack.TenantId);
AddParameter(command, "name", pack.Name);
AddParameter(command, "display_name", pack.DisplayName);
AddParameter(command, "description", pack.Description);
AddParameter(command, "active_version", pack.ActiveVersion);
AddParameter(command, "is_builtin", pack.IsBuiltin);
AddParameter(command, "is_deprecated", pack.IsDeprecated);
AddJsonbParameter(command, "metadata", pack.Metadata);
AddParameter(command, "created_by", pack.CreatedBy);
dbContext.Packs.Add(pack);
await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
return MapPack(reader);
return pack;
}
/// <inheritdoc />
public async Task<PackEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = "SELECT * FROM policy.packs WHERE tenant_id = @tenant_id AND id = @id";
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken)
.ConfigureAwait(false);
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
return await QuerySingleOrDefaultAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "id", id);
},
MapPack,
cancellationToken).ConfigureAwait(false);
return await dbContext.Packs
.AsNoTracking()
.FirstOrDefaultAsync(p => p.TenantId == tenantId && p.Id == id, cancellationToken)
.ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<PackEntity?> GetByNameAsync(string tenantId, string name, CancellationToken cancellationToken = default)
{
const string sql = "SELECT * FROM policy.packs WHERE tenant_id = @tenant_id AND name = @name";
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken)
.ConfigureAwait(false);
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
return await QuerySingleOrDefaultAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "name", name);
},
MapPack,
cancellationToken).ConfigureAwait(false);
return await dbContext.Packs
.AsNoTracking()
.FirstOrDefaultAsync(p => p.TenantId == tenantId && p.Name == name, cancellationToken)
.ConfigureAwait(false);
}
/// <inheritdoc />
@@ -97,31 +68,28 @@ public sealed class PackRepository : RepositoryBase<PolicyDataSource>, IPackRepo
int offset = 0,
CancellationToken cancellationToken = default)
{
var sql = "SELECT * FROM policy.packs WHERE tenant_id = @tenant_id";
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken)
.ConfigureAwait(false);
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
var query = dbContext.Packs.AsNoTracking().Where(p => p.TenantId == tenantId);
if (includeBuiltin == false)
{
sql += " AND is_builtin = FALSE";
query = query.Where(p => !p.IsBuiltin);
}
if (includeDeprecated == false)
{
sql += " AND is_deprecated = FALSE";
query = query.Where(p => !p.IsDeprecated);
}
sql += " ORDER BY name, id LIMIT @limit OFFSET @offset";
return await QueryAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "limit", limit);
AddParameter(cmd, "offset", offset);
},
MapPack,
cancellationToken).ConfigureAwait(false);
return await query
.OrderBy(p => p.Name).ThenBy(p => p.Id)
.Skip(offset)
.Take(limit)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
}
/// <inheritdoc />
@@ -129,23 +97,23 @@ public sealed class PackRepository : RepositoryBase<PolicyDataSource>, IPackRepo
string tenantId,
CancellationToken cancellationToken = default)
{
const string sql = """
SELECT * FROM policy.packs
WHERE tenant_id = @tenant_id AND is_builtin = TRUE AND is_deprecated = FALSE
ORDER BY name, id
""";
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken)
.ConfigureAwait(false);
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
return await QueryAsync(
tenantId,
sql,
cmd => AddParameter(cmd, "tenant_id", tenantId),
MapPack,
cancellationToken).ConfigureAwait(false);
return await dbContext.Packs
.AsNoTracking()
.Where(p => p.TenantId == tenantId && p.IsBuiltin && !p.IsDeprecated)
.OrderBy(p => p.Name).ThenBy(p => p.Id)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<bool> UpdateAsync(PackEntity pack, CancellationToken cancellationToken = default)
{
// Keep raw SQL: the update has a WHERE clause filtering on is_builtin = FALSE
// which is a conditional update that cannot be cleanly expressed via EF Core tracked update.
const string sql = """
UPDATE policy.packs
SET name = @name,
@@ -181,6 +149,7 @@ public sealed class PackRepository : RepositoryBase<PolicyDataSource>, IPackRepo
int version,
CancellationToken cancellationToken = default)
{
// Keep raw SQL: the EXISTS subquery cannot be expressed in a single EF Core statement.
const string sql = """
UPDATE policy.packs
SET active_version = @version
@@ -208,21 +177,14 @@ public sealed class PackRepository : RepositoryBase<PolicyDataSource>, IPackRepo
/// <inheritdoc />
public async Task<bool> DeprecateAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = """
UPDATE policy.packs
SET is_deprecated = TRUE
WHERE tenant_id = @tenant_id AND id = @id
""";
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken)
.ConfigureAwait(false);
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
var rows = await ExecuteAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "id", id);
},
cancellationToken).ConfigureAwait(false);
var rows = await dbContext.Packs
.Where(p => p.TenantId == tenantId && p.Id == id)
.ExecuteUpdateAsync(s => s.SetProperty(p => p.IsDeprecated, true), cancellationToken)
.ConfigureAwait(false);
return rows > 0;
}
@@ -230,6 +192,7 @@ public sealed class PackRepository : RepositoryBase<PolicyDataSource>, IPackRepo
/// <inheritdoc />
public async Task<bool> DeleteAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
// Keep raw SQL: conditional delete on is_builtin = FALSE
const string sql = "DELETE FROM policy.packs WHERE tenant_id = @tenant_id AND id = @id AND is_builtin = FALSE";
var rows = await ExecuteAsync(
@@ -244,25 +207,4 @@ public sealed class PackRepository : RepositoryBase<PolicyDataSource>, IPackRepo
return rows > 0;
}
private static PackEntity MapPack(NpgsqlDataReader reader) => new()
{
Id = reader.GetGuid(reader.GetOrdinal("id")),
TenantId = reader.GetString(reader.GetOrdinal("tenant_id")),
Name = reader.GetString(reader.GetOrdinal("name")),
DisplayName = GetNullableString(reader, reader.GetOrdinal("display_name")),
Description = GetNullableString(reader, reader.GetOrdinal("description")),
ActiveVersion = GetNullableInt(reader, reader.GetOrdinal("active_version")),
IsBuiltin = reader.GetBoolean(reader.GetOrdinal("is_builtin")),
IsDeprecated = reader.GetBoolean(reader.GetOrdinal("is_deprecated")),
Metadata = reader.GetString(reader.GetOrdinal("metadata")),
CreatedAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("created_at")),
UpdatedAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("updated_at")),
CreatedBy = GetNullableString(reader, reader.GetOrdinal("created_by"))
};
private static int? GetNullableInt(NpgsqlDataReader reader, int ordinal)
{
return reader.IsDBNull(ordinal) ? null : reader.GetInt32(ordinal);
}
}

View File

@@ -1,3 +1,4 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Infrastructure.Postgres.Repositories;
@@ -8,6 +9,7 @@ namespace StellaOps.Policy.Persistence.Postgres.Repositories;
/// <summary>
/// PostgreSQL repository for policy pack version operations.
/// Note: pack_versions table doesn't have tenant_id; tenant context comes from parent pack.
/// Uses EF Core for standard CRUD; raw SQL preserved for COALESCE aggregate and conditional updates.
/// </summary>
public sealed class PackVersionRepository : RepositoryBase<PolicyDataSource>, IPackVersionRepository
{
@@ -22,56 +24,27 @@ public sealed class PackVersionRepository : RepositoryBase<PolicyDataSource>, IP
/// <inheritdoc />
public async Task<PackVersionEntity> CreateAsync(PackVersionEntity version, CancellationToken cancellationToken = default)
{
const string sql = """
INSERT INTO policy.pack_versions (
id, pack_id, version, description, rules_hash,
is_published, published_at, published_by, created_by
)
VALUES (
@id, @pack_id, @version, @description, @rules_hash,
@is_published, @published_at, @published_by, @created_by
)
RETURNING *
""";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken)
.ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
AddParameter(command, "id", version.Id);
AddParameter(command, "pack_id", version.PackId);
AddParameter(command, "version", version.Version);
AddParameter(command, "description", version.Description);
AddParameter(command, "rules_hash", version.RulesHash);
AddParameter(command, "is_published", version.IsPublished);
AddParameter(command, "published_at", version.PublishedAt);
AddParameter(command, "published_by", version.PublishedBy);
AddParameter(command, "created_by", version.CreatedBy);
dbContext.PackVersions.Add(version);
await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
return MapPackVersion(reader);
return version;
}
/// <inheritdoc />
public async Task<PackVersionEntity?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
{
const string sql = "SELECT * FROM policy.pack_versions WHERE id = @id";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken)
.ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
AddParameter(command, "id", id);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
return null;
}
return MapPackVersion(reader);
return await dbContext.PackVersions
.AsNoTracking()
.FirstOrDefaultAsync(pv => pv.Id == id, cancellationToken)
.ConfigureAwait(false);
}
/// <inheritdoc />
@@ -80,47 +53,29 @@ public sealed class PackVersionRepository : RepositoryBase<PolicyDataSource>, IP
int version,
CancellationToken cancellationToken = default)
{
const string sql = "SELECT * FROM policy.pack_versions WHERE pack_id = @pack_id AND version = @version";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken)
.ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
AddParameter(command, "pack_id", packId);
AddParameter(command, "version", version);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
return null;
}
return MapPackVersion(reader);
return await dbContext.PackVersions
.AsNoTracking()
.FirstOrDefaultAsync(pv => pv.PackId == packId && pv.Version == version, cancellationToken)
.ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<PackVersionEntity?> GetLatestAsync(Guid packId, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT * FROM policy.pack_versions
WHERE pack_id = @pack_id
ORDER BY version DESC
LIMIT 1
""";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken)
.ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
AddParameter(command, "pack_id", packId);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
return null;
}
return MapPackVersion(reader);
return await dbContext.PackVersions
.AsNoTracking()
.Where(pv => pv.PackId == packId)
.OrderByDescending(pv => pv.Version)
.FirstOrDefaultAsync(cancellationToken)
.ConfigureAwait(false);
}
/// <inheritdoc />
@@ -129,35 +84,27 @@ public sealed class PackVersionRepository : RepositoryBase<PolicyDataSource>, IP
bool? publishedOnly = null,
CancellationToken cancellationToken = default)
{
var sql = "SELECT * FROM policy.pack_versions WHERE pack_id = @pack_id";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken)
.ConfigureAwait(false);
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
var query = dbContext.PackVersions.AsNoTracking().Where(pv => pv.PackId == packId);
if (publishedOnly == true)
{
sql += " AND is_published = TRUE";
query = query.Where(pv => pv.IsPublished);
}
sql += " ORDER BY version DESC";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken)
return await query
.OrderByDescending(pv => pv.Version)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "pack_id", packId);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
var results = new List<PackVersionEntity>();
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
results.Add(MapPackVersion(reader));
}
return results;
}
/// <inheritdoc />
public async Task<bool> PublishAsync(Guid id, string? publishedBy, CancellationToken cancellationToken = default)
{
// Keep raw SQL: conditional update WHERE is_published = FALSE with NOW()
const string sql = """
UPDATE policy.pack_versions
SET is_published = TRUE,
@@ -180,6 +127,7 @@ public sealed class PackVersionRepository : RepositoryBase<PolicyDataSource>, IP
/// <inheritdoc />
public async Task<int> GetNextVersionAsync(Guid packId, CancellationToken cancellationToken = default)
{
// Keep raw SQL: COALESCE(MAX(version), 0) + 1 cannot be cleanly expressed in EF Core LINQ
const string sql = """
SELECT COALESCE(MAX(version), 0) + 1
FROM policy.pack_versions
@@ -195,18 +143,4 @@ public sealed class PackVersionRepository : RepositoryBase<PolicyDataSource>, IP
var result = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
return Convert.ToInt32(result);
}
private static PackVersionEntity MapPackVersion(NpgsqlDataReader reader) => new()
{
Id = reader.GetGuid(reader.GetOrdinal("id")),
PackId = reader.GetGuid(reader.GetOrdinal("pack_id")),
Version = reader.GetInt32(reader.GetOrdinal("version")),
Description = GetNullableString(reader, reader.GetOrdinal("description")),
RulesHash = reader.GetString(reader.GetOrdinal("rules_hash")),
IsPublished = reader.GetBoolean(reader.GetOrdinal("is_published")),
PublishedAt = GetNullableDateTimeOffset(reader, reader.GetOrdinal("published_at")),
PublishedBy = GetNullableString(reader, reader.GetOrdinal("published_by")),
CreatedAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("created_at")),
CreatedBy = GetNullableString(reader, reader.GetOrdinal("created_by"))
};
}

View File

@@ -1,3 +1,4 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Infrastructure.Postgres.Repositories;
@@ -7,6 +8,7 @@ namespace StellaOps.Policy.Persistence.Postgres.Repositories;
/// <summary>
/// PostgreSQL repository for policy audit operations.
/// Uses EF Core for reads and inserts; raw SQL preserved for system-connection delete.
/// </summary>
public sealed class PolicyAuditRepository : RepositoryBase<PolicyDataSource>, IPolicyAuditRepository
{
@@ -15,91 +17,71 @@ public sealed class PolicyAuditRepository : RepositoryBase<PolicyDataSource>, IP
public async Task<long> CreateAsync(PolicyAuditEntity audit, CancellationToken cancellationToken = default)
{
const string sql = """
INSERT INTO policy.audit (tenant_id, user_id, action, resource_type, resource_id, old_value, new_value, correlation_id)
VALUES (@tenant_id, @user_id, @action, @resource_type, @resource_id, @old_value::jsonb, @new_value::jsonb, @correlation_id)
RETURNING id
""";
await using var connection = await DataSource.OpenConnectionAsync(audit.TenantId, "writer", cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "tenant_id", audit.TenantId);
AddParameter(command, "user_id", audit.UserId);
AddParameter(command, "action", audit.Action);
AddParameter(command, "resource_type", audit.ResourceType);
AddParameter(command, "resource_id", audit.ResourceId);
AddJsonbParameter(command, "old_value", audit.OldValue);
AddJsonbParameter(command, "new_value", audit.NewValue);
AddParameter(command, "correlation_id", audit.CorrelationId);
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
var result = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
return (long)result!;
dbContext.Audit.Add(audit);
await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
return audit.Id;
}
public async Task<IReadOnlyList<PolicyAuditEntity>> ListAsync(string tenantId, int limit = 100, int offset = 0, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, user_id, action, resource_type, resource_id, old_value, new_value, correlation_id, created_at
FROM policy.audit WHERE tenant_id = @tenant_id
ORDER BY created_at DESC LIMIT @limit OFFSET @offset
""";
return await QueryAsync(tenantId, sql, cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "limit", limit);
AddParameter(cmd, "offset", offset);
}, MapAudit, cancellationToken).ConfigureAwait(false);
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken).ConfigureAwait(false);
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
return await dbContext.Audit
.AsNoTracking()
.Where(a => a.TenantId == tenantId)
.OrderByDescending(a => a.CreatedAt)
.Skip(offset)
.Take(limit)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
}
public async Task<IReadOnlyList<PolicyAuditEntity>> GetByResourceAsync(string tenantId, string resourceType, string? resourceId = null, int limit = 100, CancellationToken cancellationToken = default)
{
var sql = """
SELECT id, tenant_id, user_id, action, resource_type, resource_id, old_value, new_value, correlation_id, created_at
FROM policy.audit WHERE tenant_id = @tenant_id AND resource_type = @resource_type
""";
if (resourceId != null) sql += " AND resource_id = @resource_id";
sql += " ORDER BY created_at DESC LIMIT @limit";
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken).ConfigureAwait(false);
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
return await QueryAsync(tenantId, sql, cmd =>
var query = dbContext.Audit
.AsNoTracking()
.Where(a => a.TenantId == tenantId && a.ResourceType == resourceType);
if (resourceId != null)
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "resource_type", resourceType);
if (resourceId != null) AddParameter(cmd, "resource_id", resourceId);
AddParameter(cmd, "limit", limit);
}, MapAudit, cancellationToken).ConfigureAwait(false);
query = query.Where(a => a.ResourceId == resourceId);
}
return await query
.OrderByDescending(a => a.CreatedAt)
.Take(limit)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
}
public async Task<IReadOnlyList<PolicyAuditEntity>> GetByCorrelationIdAsync(string tenantId, string correlationId, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, user_id, action, resource_type, resource_id, old_value, new_value, correlation_id, created_at
FROM policy.audit WHERE tenant_id = @tenant_id AND correlation_id = @correlation_id
ORDER BY created_at
""";
return await QueryAsync(tenantId, sql,
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "correlation_id", correlationId); },
MapAudit, cancellationToken).ConfigureAwait(false);
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken).ConfigureAwait(false);
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
return await dbContext.Audit
.AsNoTracking()
.Where(a => a.TenantId == tenantId && a.CorrelationId == correlationId)
.OrderBy(a => a.CreatedAt)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
}
public async Task<int> DeleteOldAsync(DateTimeOffset cutoff, CancellationToken cancellationToken = default)
{
// Keep raw SQL: system connection (no tenant) for cross-tenant cleanup
const string sql = "DELETE FROM policy.audit WHERE created_at < @cutoff";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "cutoff", cutoff);
return await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
private static PolicyAuditEntity MapAudit(NpgsqlDataReader reader) => new()
{
Id = reader.GetInt64(0),
TenantId = reader.GetString(1),
UserId = GetNullableGuid(reader, 2),
Action = reader.GetString(3),
ResourceType = reader.GetString(4),
ResourceId = GetNullableString(reader, 5),
OldValue = GetNullableString(reader, 6),
NewValue = GetNullableString(reader, 7),
CorrelationId = GetNullableString(reader, 8),
CreatedAt = reader.GetFieldValue<DateTimeOffset>(9)
};
}

View File

@@ -1,3 +1,4 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Infrastructure.Postgres.Repositories;
@@ -7,6 +8,8 @@ namespace StellaOps.Policy.Persistence.Postgres.Repositories;
/// <summary>
/// PostgreSQL repository for risk profile operations.
/// Uses EF Core for reads and simple writes; raw SQL preserved for
/// multi-step transactional operations (version creation + deactivation).
/// </summary>
public sealed class RiskProfileRepository : RepositoryBase<PolicyDataSource>, IRiskProfileRepository
{
@@ -21,45 +24,27 @@ public sealed class RiskProfileRepository : RepositoryBase<PolicyDataSource>, IR
/// <inheritdoc />
public async Task<RiskProfileEntity> CreateAsync(RiskProfileEntity profile, CancellationToken cancellationToken = default)
{
const string sql = """
INSERT INTO policy.risk_profiles (
id, tenant_id, name, display_name, description, version,
is_active, thresholds, scoring_weights, exemptions, metadata, created_by
)
VALUES (
@id, @tenant_id, @name, @display_name, @description, @version,
@is_active, @thresholds::jsonb, @scoring_weights::jsonb, @exemptions::jsonb, @metadata::jsonb, @created_by
)
RETURNING *
""";
await using var connection = await DataSource.OpenConnectionAsync(profile.TenantId, "writer", cancellationToken)
.ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
AddProfileParameters(command, profile);
dbContext.RiskProfiles.Add(profile);
await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
return MapProfile(reader);
return profile;
}
/// <inheritdoc />
public async Task<RiskProfileEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = "SELECT * FROM policy.risk_profiles WHERE tenant_id = @tenant_id AND id = @id";
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken)
.ConfigureAwait(false);
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
return await QuerySingleOrDefaultAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "id", id);
},
MapProfile,
cancellationToken).ConfigureAwait(false);
return await dbContext.RiskProfiles
.AsNoTracking()
.FirstOrDefaultAsync(p => p.TenantId == tenantId && p.Id == id, cancellationToken)
.ConfigureAwait(false);
}
/// <inheritdoc />
@@ -68,23 +53,16 @@ public sealed class RiskProfileRepository : RepositoryBase<PolicyDataSource>, IR
string name,
CancellationToken cancellationToken = default)
{
const string sql = """
SELECT * FROM policy.risk_profiles
WHERE tenant_id = @tenant_id AND name = @name AND is_active = TRUE
ORDER BY version DESC
LIMIT 1
""";
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken)
.ConfigureAwait(false);
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
return await QuerySingleOrDefaultAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "name", name);
},
MapProfile,
cancellationToken).ConfigureAwait(false);
return await dbContext.RiskProfiles
.AsNoTracking()
.Where(p => p.TenantId == tenantId && p.Name == name && p.IsActive)
.OrderByDescending(p => p.Version)
.FirstOrDefaultAsync(cancellationToken)
.ConfigureAwait(false);
}
/// <inheritdoc />
@@ -95,26 +73,23 @@ public sealed class RiskProfileRepository : RepositoryBase<PolicyDataSource>, IR
int offset = 0,
CancellationToken cancellationToken = default)
{
var sql = "SELECT * FROM policy.risk_profiles WHERE tenant_id = @tenant_id";
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken)
.ConfigureAwait(false);
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
var query = dbContext.RiskProfiles.AsNoTracking().Where(p => p.TenantId == tenantId);
if (activeOnly == true)
{
sql += " AND is_active = TRUE";
query = query.Where(p => p.IsActive);
}
sql += " ORDER BY name, version DESC LIMIT @limit OFFSET @offset";
return await QueryAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "limit", limit);
AddParameter(cmd, "offset", offset);
},
MapProfile,
cancellationToken).ConfigureAwait(false);
return await query
.OrderBy(p => p.Name).ThenByDescending(p => p.Version)
.Skip(offset)
.Take(limit)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
}
/// <inheritdoc />
@@ -123,27 +98,22 @@ public sealed class RiskProfileRepository : RepositoryBase<PolicyDataSource>, IR
string name,
CancellationToken cancellationToken = default)
{
const string sql = """
SELECT * FROM policy.risk_profiles
WHERE tenant_id = @tenant_id AND name = @name
ORDER BY version DESC
""";
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken)
.ConfigureAwait(false);
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
return await QueryAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "name", name);
},
MapProfile,
cancellationToken).ConfigureAwait(false);
return await dbContext.RiskProfiles
.AsNoTracking()
.Where(p => p.TenantId == tenantId && p.Name == name)
.OrderByDescending(p => p.Version)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<bool> UpdateAsync(RiskProfileEntity profile, CancellationToken cancellationToken = default)
{
// Keep raw SQL: targeted column update without requiring full entity load
const string sql = """
UPDATE policy.risk_profiles
SET display_name = @display_name,
@@ -181,6 +151,7 @@ public sealed class RiskProfileRepository : RepositoryBase<PolicyDataSource>, IR
RiskProfileEntity newProfile,
CancellationToken cancellationToken = default)
{
// Keep raw SQL: multi-step transaction with COALESCE(MAX) + INSERT + deactivate others
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken)
.ConfigureAwait(false);
await using var transaction = await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
@@ -253,6 +224,7 @@ public sealed class RiskProfileRepository : RepositoryBase<PolicyDataSource>, IR
/// <inheritdoc />
public async Task<bool> ActivateAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
// Keep raw SQL: multi-step transaction (lookup name, deactivate others, activate target)
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken)
.ConfigureAwait(false);
await using var transaction = await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
@@ -301,21 +273,14 @@ public sealed class RiskProfileRepository : RepositoryBase<PolicyDataSource>, IR
/// <inheritdoc />
public async Task<bool> DeactivateAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = """
UPDATE policy.risk_profiles
SET is_active = FALSE
WHERE tenant_id = @tenant_id AND id = @id
""";
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken)
.ConfigureAwait(false);
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
var rows = await ExecuteAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "id", id);
},
cancellationToken).ConfigureAwait(false);
var rows = await dbContext.RiskProfiles
.Where(p => p.TenantId == tenantId && p.Id == id)
.ExecuteUpdateAsync(s => s.SetProperty(p => p.IsActive, false), cancellationToken)
.ConfigureAwait(false);
return rows > 0;
}
@@ -323,37 +288,18 @@ public sealed class RiskProfileRepository : RepositoryBase<PolicyDataSource>, IR
/// <inheritdoc />
public async Task<bool> DeleteAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = "DELETE FROM policy.risk_profiles WHERE tenant_id = @tenant_id AND id = @id";
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken)
.ConfigureAwait(false);
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
var rows = await ExecuteAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "id", id);
},
cancellationToken).ConfigureAwait(false);
var rows = await dbContext.RiskProfiles
.Where(p => p.TenantId == tenantId && p.Id == id)
.ExecuteDeleteAsync(cancellationToken)
.ConfigureAwait(false);
return rows > 0;
}
private static void AddProfileParameters(NpgsqlCommand command, RiskProfileEntity profile)
{
AddParameter(command, "id", profile.Id);
AddParameter(command, "tenant_id", profile.TenantId);
AddParameter(command, "name", profile.Name);
AddParameter(command, "display_name", profile.DisplayName);
AddParameter(command, "description", profile.Description);
AddParameter(command, "version", profile.Version);
AddParameter(command, "is_active", profile.IsActive);
AddJsonbParameter(command, "thresholds", profile.Thresholds);
AddJsonbParameter(command, "scoring_weights", profile.ScoringWeights);
AddJsonbParameter(command, "exemptions", profile.Exemptions);
AddJsonbParameter(command, "metadata", profile.Metadata);
AddParameter(command, "created_by", profile.CreatedBy);
}
private static RiskProfileEntity MapProfile(NpgsqlDataReader reader) => new()
{
Id = reader.GetGuid(reader.GetOrdinal("id")),

View File

@@ -1,3 +1,4 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Infrastructure.Postgres.Repositories;
@@ -8,6 +9,8 @@ namespace StellaOps.Policy.Persistence.Postgres.Repositories;
/// <summary>
/// PostgreSQL repository for policy rule operations.
/// Note: rules table doesn't have tenant_id; tenant context comes from parent pack.
/// Uses EF Core for standard CRUD; raw SQL preserved for batch inserts with transactions
/// and tag array containment queries.
/// </summary>
public sealed class RuleRepository : RepositoryBase<PolicyDataSource>, IRuleRepository
{
@@ -22,28 +25,14 @@ public sealed class RuleRepository : RepositoryBase<PolicyDataSource>, IRuleRepo
/// <inheritdoc />
public async Task<RuleEntity> CreateAsync(RuleEntity rule, CancellationToken cancellationToken = default)
{
const string sql = """
INSERT INTO policy.rules (
id, pack_version_id, name, description, rule_type, content,
content_hash, severity, category, tags, metadata
)
VALUES (
@id, @pack_version_id, @name, @description, @rule_type, @content,
@content_hash, @severity, @category, @tags, @metadata::jsonb
)
RETURNING *
""";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken)
.ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
AddRuleParameters(command, rule);
dbContext.Rules.Add(rule);
await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
return MapRule(reader);
return rule;
}
/// <inheritdoc />
@@ -54,28 +43,12 @@ public sealed class RuleRepository : RepositoryBase<PolicyDataSource>, IRuleRepo
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken)
.ConfigureAwait(false);
await using var transaction = await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
var count = 0;
foreach (var rule in rulesList)
{
const string sql = """
INSERT INTO policy.rules (
id, pack_version_id, name, description, rule_type, content,
content_hash, severity, category, tags, metadata
)
VALUES (
@id, @pack_version_id, @name, @description, @rule_type, @content,
@content_hash, @severity, @category, @tags, @metadata::jsonb
)
""";
await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
command.Transaction = transaction;
AddRuleParameters(command, rule);
count += await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
dbContext.Rules.AddRange(rulesList);
var count = await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
return count;
@@ -84,21 +57,14 @@ public sealed class RuleRepository : RepositoryBase<PolicyDataSource>, IRuleRepo
/// <inheritdoc />
public async Task<RuleEntity?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
{
const string sql = "SELECT * FROM policy.rules WHERE id = @id";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken)
.ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
AddParameter(command, "id", id);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
return null;
}
return MapRule(reader);
return await dbContext.Rules
.AsNoTracking()
.FirstOrDefaultAsync(r => r.Id == id, cancellationToken)
.ConfigureAwait(false);
}
/// <inheritdoc />
@@ -107,22 +73,14 @@ public sealed class RuleRepository : RepositoryBase<PolicyDataSource>, IRuleRepo
string name,
CancellationToken cancellationToken = default)
{
const string sql = "SELECT * FROM policy.rules WHERE pack_version_id = @pack_version_id AND name = @name";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken)
.ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
AddParameter(command, "pack_version_id", packVersionId);
AddParameter(command, "name", name);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
return null;
}
return MapRule(reader);
return await dbContext.Rules
.AsNoTracking()
.FirstOrDefaultAsync(r => r.PackVersionId == packVersionId && r.Name == name, cancellationToken)
.ConfigureAwait(false);
}
/// <inheritdoc />
@@ -130,27 +88,16 @@ public sealed class RuleRepository : RepositoryBase<PolicyDataSource>, IRuleRepo
Guid packVersionId,
CancellationToken cancellationToken = default)
{
const string sql = """
SELECT * FROM policy.rules
WHERE pack_version_id = @pack_version_id
ORDER BY name, id
""";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken)
.ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
AddParameter(command, "pack_version_id", packVersionId);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
var results = new List<RuleEntity>();
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
results.Add(MapRule(reader));
}
return results;
return await dbContext.Rules
.AsNoTracking()
.Where(r => r.PackVersionId == packVersionId)
.OrderBy(r => r.Name).ThenBy(r => r.Id)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
}
/// <inheritdoc />
@@ -159,28 +106,16 @@ public sealed class RuleRepository : RepositoryBase<PolicyDataSource>, IRuleRepo
RuleSeverity severity,
CancellationToken cancellationToken = default)
{
const string sql = """
SELECT * FROM policy.rules
WHERE pack_version_id = @pack_version_id AND severity = @severity
ORDER BY name, id
""";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken)
.ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
AddParameter(command, "pack_version_id", packVersionId);
AddParameter(command, "severity", SeverityToString(severity));
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
var results = new List<RuleEntity>();
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
results.Add(MapRule(reader));
}
return results;
return await dbContext.Rules
.AsNoTracking()
.Where(r => r.PackVersionId == packVersionId && r.Severity == severity)
.OrderBy(r => r.Name).ThenBy(r => r.Id)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
}
/// <inheritdoc />
@@ -189,28 +124,16 @@ public sealed class RuleRepository : RepositoryBase<PolicyDataSource>, IRuleRepo
string category,
CancellationToken cancellationToken = default)
{
const string sql = """
SELECT * FROM policy.rules
WHERE pack_version_id = @pack_version_id AND category = @category
ORDER BY name, id
""";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken)
.ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
AddParameter(command, "pack_version_id", packVersionId);
AddParameter(command, "category", category);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
var results = new List<RuleEntity>();
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
results.Add(MapRule(reader));
}
return results;
return await dbContext.Rules
.AsNoTracking()
.Where(r => r.PackVersionId == packVersionId && r.Category == category)
.OrderBy(r => r.Name).ThenBy(r => r.Id)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
}
/// <inheritdoc />
@@ -219,6 +142,7 @@ public sealed class RuleRepository : RepositoryBase<PolicyDataSource>, IRuleRepo
string tag,
CancellationToken cancellationToken = default)
{
// Keep raw SQL: @tag = ANY(tags) array containment not cleanly supported in EF Core LINQ
const string sql = """
SELECT * FROM policy.rules
WHERE pack_version_id = @pack_version_id AND @tag = ANY(tags)
@@ -246,31 +170,13 @@ public sealed class RuleRepository : RepositoryBase<PolicyDataSource>, IRuleRepo
/// <inheritdoc />
public async Task<int> CountByPackVersionIdAsync(Guid packVersionId, CancellationToken cancellationToken = default)
{
const string sql = "SELECT COUNT(*) FROM policy.rules WHERE pack_version_id = @pack_version_id";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken)
.ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
AddParameter(command, "pack_version_id", packVersionId);
var result = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
return Convert.ToInt32(result);
}
private static void AddRuleParameters(NpgsqlCommand command, RuleEntity rule)
{
AddParameter(command, "id", rule.Id);
AddParameter(command, "pack_version_id", rule.PackVersionId);
AddParameter(command, "name", rule.Name);
AddParameter(command, "description", rule.Description);
AddParameter(command, "rule_type", RuleTypeToString(rule.RuleType));
AddParameter(command, "content", rule.Content);
AddParameter(command, "content_hash", rule.ContentHash);
AddParameter(command, "severity", SeverityToString(rule.Severity));
AddParameter(command, "category", rule.Category);
AddTextArrayParameter(command, "tags", rule.Tags);
AddJsonbParameter(command, "metadata", rule.Metadata);
return await dbContext.Rules
.CountAsync(r => r.PackVersionId == packVersionId, cancellationToken)
.ConfigureAwait(false);
}
private static RuleEntity MapRule(NpgsqlDataReader reader) => new()
@@ -289,14 +195,6 @@ public sealed class RuleRepository : RepositoryBase<PolicyDataSource>, IRuleRepo
CreatedAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("created_at"))
};
private static string RuleTypeToString(RuleType ruleType) => ruleType switch
{
RuleType.Rego => "rego",
RuleType.Json => "json",
RuleType.Yaml => "yaml",
_ => throw new ArgumentException($"Unknown rule type: {ruleType}", nameof(ruleType))
};
private static RuleType ParseRuleType(string ruleType) => ruleType switch
{
"rego" => RuleType.Rego,
@@ -305,16 +203,6 @@ public sealed class RuleRepository : RepositoryBase<PolicyDataSource>, IRuleRepo
_ => throw new ArgumentException($"Unknown rule type: {ruleType}", nameof(ruleType))
};
private static string SeverityToString(RuleSeverity severity) => severity switch
{
RuleSeverity.Critical => "critical",
RuleSeverity.High => "high",
RuleSeverity.Medium => "medium",
RuleSeverity.Low => "low",
RuleSeverity.Info => "info",
_ => throw new ArgumentException($"Unknown severity: {severity}", nameof(severity))
};
private static RuleSeverity ParseSeverity(string severity) => severity switch
{
"critical" => RuleSeverity.Critical,

View File

@@ -1,3 +1,4 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Infrastructure.Postgres.Repositories;
@@ -7,6 +8,7 @@ namespace StellaOps.Policy.Persistence.Postgres.Repositories;
/// <summary>
/// PostgreSQL repository for policy snapshot operations.
/// Uses EF Core for standard CRUD; raw SQL preserved where needed.
/// </summary>
public sealed class SnapshotRepository : RepositoryBase<PolicyDataSource>, ISnapshotRepository
{
@@ -21,45 +23,27 @@ public sealed class SnapshotRepository : RepositoryBase<PolicyDataSource>, ISnap
/// <inheritdoc />
public async Task<SnapshotEntity> CreateAsync(SnapshotEntity snapshot, CancellationToken cancellationToken = default)
{
const string sql = """
INSERT INTO policy.snapshots (
id, tenant_id, policy_id, version, content_digest, content,
created_by, metadata
)
VALUES (
@id, @tenant_id, @policy_id, @version, @content_digest, @content::jsonb,
@created_by, @metadata::jsonb
)
RETURNING *
""";
await using var connection = await DataSource.OpenConnectionAsync(snapshot.TenantId, "writer", cancellationToken)
.ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
AddSnapshotParameters(command, snapshot);
dbContext.Snapshots.Add(snapshot);
await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
return MapSnapshot(reader);
return snapshot;
}
/// <inheritdoc />
public async Task<SnapshotEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = "SELECT * FROM policy.snapshots WHERE tenant_id = @tenant_id AND id = @id";
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken)
.ConfigureAwait(false);
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
return await QuerySingleOrDefaultAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "id", id);
},
MapSnapshot,
cancellationToken).ConfigureAwait(false);
return await dbContext.Snapshots
.AsNoTracking()
.FirstOrDefaultAsync(s => s.TenantId == tenantId && s.Id == id, cancellationToken)
.ConfigureAwait(false);
}
/// <inheritdoc />
@@ -68,41 +52,28 @@ public sealed class SnapshotRepository : RepositoryBase<PolicyDataSource>, ISnap
Guid policyId,
CancellationToken cancellationToken = default)
{
const string sql = """
SELECT * FROM policy.snapshots
WHERE tenant_id = @tenant_id AND policy_id = @policy_id
ORDER BY version DESC
LIMIT 1
""";
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken)
.ConfigureAwait(false);
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
return await QuerySingleOrDefaultAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "policy_id", policyId);
},
MapSnapshot,
cancellationToken).ConfigureAwait(false);
return await dbContext.Snapshots
.AsNoTracking()
.Where(s => s.TenantId == tenantId && s.PolicyId == policyId)
.OrderByDescending(s => s.Version)
.FirstOrDefaultAsync(cancellationToken)
.ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<SnapshotEntity?> GetByDigestAsync(string contentDigest, CancellationToken cancellationToken = default)
{
const string sql = "SELECT * FROM policy.snapshots WHERE content_digest = @content_digest LIMIT 1";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "content_digest", contentDigest);
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
if (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
return MapSnapshot(reader);
}
return null;
return await dbContext.Snapshots
.AsNoTracking()
.FirstOrDefaultAsync(s => s.ContentDigest == contentDigest, cancellationToken)
.ConfigureAwait(false);
}
/// <inheritdoc />
@@ -113,67 +84,32 @@ public sealed class SnapshotRepository : RepositoryBase<PolicyDataSource>, ISnap
int offset = 0,
CancellationToken cancellationToken = default)
{
const string sql = """
SELECT * FROM policy.snapshots
WHERE tenant_id = @tenant_id AND policy_id = @policy_id
ORDER BY version DESC
LIMIT @limit OFFSET @offset
""";
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken)
.ConfigureAwait(false);
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
return await QueryAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "policy_id", policyId);
AddParameter(cmd, "limit", limit);
AddParameter(cmd, "offset", offset);
},
MapSnapshot,
cancellationToken).ConfigureAwait(false);
return await dbContext.Snapshots
.AsNoTracking()
.Where(s => s.TenantId == tenantId && s.PolicyId == policyId)
.OrderByDescending(s => s.Version)
.Skip(offset)
.Take(limit)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<bool> DeleteAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = "DELETE FROM policy.snapshots WHERE tenant_id = @tenant_id AND id = @id";
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken)
.ConfigureAwait(false);
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
var rows = await ExecuteAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "id", id);
},
cancellationToken).ConfigureAwait(false);
var rows = await dbContext.Snapshots
.Where(s => s.TenantId == tenantId && s.Id == id)
.ExecuteDeleteAsync(cancellationToken)
.ConfigureAwait(false);
return rows > 0;
}
private static void AddSnapshotParameters(NpgsqlCommand command, SnapshotEntity snapshot)
{
AddParameter(command, "id", snapshot.Id);
AddParameter(command, "tenant_id", snapshot.TenantId);
AddParameter(command, "policy_id", snapshot.PolicyId);
AddParameter(command, "version", snapshot.Version);
AddParameter(command, "content_digest", snapshot.ContentDigest);
AddParameter(command, "content", snapshot.Content);
AddParameter(command, "created_by", snapshot.CreatedBy);
AddJsonbParameter(command, "metadata", snapshot.Metadata);
}
private static SnapshotEntity MapSnapshot(NpgsqlDataReader reader) => new()
{
Id = reader.GetGuid(reader.GetOrdinal("id")),
TenantId = reader.GetString(reader.GetOrdinal("tenant_id")),
PolicyId = reader.GetGuid(reader.GetOrdinal("policy_id")),
Version = reader.GetInt32(reader.GetOrdinal("version")),
ContentDigest = reader.GetString(reader.GetOrdinal("content_digest")),
Content = reader.GetString(reader.GetOrdinal("content")),
CreatedAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("created_at")),
CreatedBy = reader.GetString(reader.GetOrdinal("created_by")),
Metadata = reader.GetString(reader.GetOrdinal("metadata"))
};
}

View File

@@ -3,9 +3,11 @@
// Sprint: SPRINT_20260118_017_Policy_gate_attestation_verification
// Task: TASK-017-005 - Trusted Key Registry
// Description: PostgreSQL implementation of trusted key repository
// Converted to EF Core for standard reads/writes; raw SQL preserved for
// LIKE REPLACE pattern matching, jsonb containment, and conditional updates.
// -----------------------------------------------------------------------------
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Infrastructure.Postgres.Repositories;
@@ -16,6 +18,8 @@ namespace StellaOps.Policy.Persistence.Postgres.Repositories;
/// <summary>
/// PostgreSQL repository for trusted signing keys.
/// Uses EF Core for standard reads/writes; raw SQL preserved for
/// LIKE REPLACE pattern matching and jsonb @> containment queries.
/// </summary>
public sealed class TrustedKeyRepository : RepositoryBase<PolicyDataSource>, ITrustedKeyRepository
{
@@ -28,21 +32,14 @@ public sealed class TrustedKeyRepository : RepositoryBase<PolicyDataSource>, ITr
string keyId,
CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, key_id, fingerprint, algorithm, public_key_pem, owner,
issuer_pattern, purposes, valid_from, valid_until, is_active,
revoked_at, revoked_reason, metadata, created_at, updated_at, created_by
FROM policy.trusted_keys
WHERE tenant_id = @tenant_id AND key_id = @key_id
""";
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken)
.ConfigureAwait(false);
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
var results = await QueryAsync(tenantId, sql, cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "key_id", keyId);
}, MapEntity, cancellationToken).ConfigureAwait(false);
return results.Count > 0 ? results[0] : null;
return await dbContext.TrustedKeys
.AsNoTracking()
.FirstOrDefaultAsync(k => k.TenantId == tenantId && k.KeyId == keyId, cancellationToken)
.ConfigureAwait(false);
}
/// <inheritdoc />
@@ -51,21 +48,14 @@ public sealed class TrustedKeyRepository : RepositoryBase<PolicyDataSource>, ITr
string fingerprint,
CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, key_id, fingerprint, algorithm, public_key_pem, owner,
issuer_pattern, purposes, valid_from, valid_until, is_active,
revoked_at, revoked_reason, metadata, created_at, updated_at, created_by
FROM policy.trusted_keys
WHERE tenant_id = @tenant_id AND fingerprint = @fingerprint
""";
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken)
.ConfigureAwait(false);
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
var results = await QueryAsync(tenantId, sql, cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "fingerprint", fingerprint);
}, MapEntity, cancellationToken).ConfigureAwait(false);
return results.Count > 0 ? results[0] : null;
return await dbContext.TrustedKeys
.AsNoTracking()
.FirstOrDefaultAsync(k => k.TenantId == tenantId && k.Fingerprint == fingerprint, cancellationToken)
.ConfigureAwait(false);
}
/// <inheritdoc />
@@ -74,8 +64,7 @@ public sealed class TrustedKeyRepository : RepositoryBase<PolicyDataSource>, ITr
string issuer,
CancellationToken cancellationToken = default)
{
// Find keys where the issuer matches the pattern using LIKE
// Pattern stored as "*@example.com" is translated to SQL LIKE pattern
// Keep raw SQL: LIKE REPLACE pattern matching cannot be expressed in EF Core LINQ
const string sql = """
SELECT id, tenant_id, key_id, fingerprint, algorithm, public_key_pem, owner,
issuer_pattern, purposes, valid_from, valid_until, is_active,
@@ -104,6 +93,7 @@ public sealed class TrustedKeyRepository : RepositoryBase<PolicyDataSource>, ITr
int offset = 0,
CancellationToken cancellationToken = default)
{
// Keep raw SQL: OR valid_until > NOW() with NULL check cannot be cleanly translated by EF Core
const string sql = """
SELECT id, tenant_id, key_id, fingerprint, algorithm, public_key_pem, owner,
issuer_pattern, purposes, valid_from, valid_until, is_active,
@@ -131,6 +121,7 @@ public sealed class TrustedKeyRepository : RepositoryBase<PolicyDataSource>, ITr
string purpose,
CancellationToken cancellationToken = default)
{
// Keep raw SQL: jsonb @> containment operator
const string sql = """
SELECT id, tenant_id, key_id, fingerprint, algorithm, public_key_pem, owner,
issuer_pattern, purposes, valid_from, valid_until, is_active,
@@ -156,44 +147,14 @@ public sealed class TrustedKeyRepository : RepositoryBase<PolicyDataSource>, ITr
TrustedKeyEntity key,
CancellationToken cancellationToken = default)
{
const string sql = """
INSERT INTO policy.trusted_keys (
id, tenant_id, key_id, fingerprint, algorithm, public_key_pem, owner,
issuer_pattern, purposes, valid_from, valid_until, is_active,
revoked_at, revoked_reason, metadata, created_at, updated_at, created_by
) VALUES (
@id, @tenant_id, @key_id, @fingerprint, @algorithm, @public_key_pem, @owner,
@issuer_pattern, @purposes::jsonb, @valid_from, @valid_until, @is_active,
@revoked_at, @revoked_reason, @metadata::jsonb, @created_at, @updated_at, @created_by
)
RETURNING id
""";
await using var connection = await DataSource.OpenConnectionAsync(key.TenantId, "writer", cancellationToken)
.ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
AddParameter(command, "id", key.Id);
AddParameter(command, "tenant_id", key.TenantId);
AddParameter(command, "key_id", key.KeyId);
AddParameter(command, "fingerprint", key.Fingerprint);
AddParameter(command, "algorithm", key.Algorithm);
AddParameter(command, "public_key_pem", key.PublicKeyPem);
AddParameter(command, "owner", key.Owner);
AddParameter(command, "issuer_pattern", key.IssuerPattern);
AddJsonbParameter(command, "purposes", key.Purposes);
AddParameter(command, "valid_from", key.ValidFrom);
AddParameter(command, "valid_until", key.ValidUntil);
AddParameter(command, "is_active", key.IsActive);
AddParameter(command, "revoked_at", key.RevokedAt);
AddParameter(command, "revoked_reason", key.RevokedReason);
AddJsonbParameter(command, "metadata", key.Metadata);
AddParameter(command, "created_at", key.CreatedAt);
AddParameter(command, "updated_at", key.UpdatedAt);
AddParameter(command, "created_by", key.CreatedBy);
dbContext.TrustedKeys.Add(key);
await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
var result = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
return (Guid)result!;
return key.Id;
}
/// <inheritdoc />
@@ -201,6 +162,7 @@ public sealed class TrustedKeyRepository : RepositoryBase<PolicyDataSource>, ITr
TrustedKeyEntity key,
CancellationToken cancellationToken = default)
{
// Keep raw SQL: targeted column update with NOW() for updated_at
const string sql = """
UPDATE policy.trusted_keys
SET public_key_pem = @public_key_pem,
@@ -239,6 +201,7 @@ public sealed class TrustedKeyRepository : RepositoryBase<PolicyDataSource>, ITr
string reason,
CancellationToken cancellationToken = default)
{
// Keep raw SQL: conditional update WHERE revoked_at IS NULL with NOW()
const string sql = """
UPDATE policy.trusted_keys
SET is_active = false,
@@ -266,20 +229,16 @@ public sealed class TrustedKeyRepository : RepositoryBase<PolicyDataSource>, ITr
string keyId,
CancellationToken cancellationToken = default)
{
const string sql = """
DELETE FROM policy.trusted_keys
WHERE tenant_id = @tenant_id AND key_id = @key_id
""";
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken)
.ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
AddParameter(command, "tenant_id", tenantId);
AddParameter(command, "key_id", keyId);
var rows = await dbContext.TrustedKeys
.Where(k => k.TenantId == tenantId && k.KeyId == keyId)
.ExecuteDeleteAsync(cancellationToken)
.ConfigureAwait(false);
var rowsAffected = await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
return rowsAffected > 0;
return rows > 0;
}
/// <inheritdoc />
@@ -287,6 +246,7 @@ public sealed class TrustedKeyRepository : RepositoryBase<PolicyDataSource>, ITr
string tenantId,
CancellationToken cancellationToken = default)
{
// Keep raw SQL: OR valid_until > NOW() with NULL check
const string sql = """
SELECT COUNT(*)
FROM policy.trusted_keys

View File

@@ -1,3 +1,4 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Infrastructure.Postgres.Repositories;
@@ -7,6 +8,8 @@ namespace StellaOps.Policy.Persistence.Postgres.Repositories;
/// <summary>
/// PostgreSQL repository for append-only violation event operations.
/// Uses EF Core for reads and single inserts; raw SQL preserved for
/// batch inserts and aggregate GROUP BY queries.
/// </summary>
public sealed class ViolationEventRepository : RepositoryBase<PolicyDataSource>, IViolationEventRepository
{
@@ -21,28 +24,14 @@ public sealed class ViolationEventRepository : RepositoryBase<PolicyDataSource>,
/// <inheritdoc />
public async Task<ViolationEventEntity> AppendAsync(ViolationEventEntity violationEvent, CancellationToken cancellationToken = default)
{
const string sql = """
INSERT INTO policy.violation_events (
id, tenant_id, policy_id, rule_id, severity, subject_purl,
subject_cve, details, remediation, correlation_id, occurred_at
)
VALUES (
@id, @tenant_id, @policy_id, @rule_id, @severity, @subject_purl,
@subject_cve, @details::jsonb, @remediation, @correlation_id, @occurred_at
)
RETURNING *
""";
await using var connection = await DataSource.OpenConnectionAsync(violationEvent.TenantId, "writer", cancellationToken)
.ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
AddViolationParameters(command, violationEvent);
dbContext.ViolationEvents.Add(violationEvent);
await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
return MapViolation(reader);
return violationEvent;
}
/// <inheritdoc />
@@ -51,47 +40,26 @@ public sealed class ViolationEventRepository : RepositoryBase<PolicyDataSource>,
var eventList = events.ToList();
if (eventList.Count == 0) return 0;
const string sql = """
INSERT INTO policy.violation_events (
id, tenant_id, policy_id, rule_id, severity, subject_purl,
subject_cve, details, remediation, correlation_id, occurred_at
)
VALUES (
@id, @tenant_id, @policy_id, @rule_id, @severity, @subject_purl,
@subject_cve, @details::jsonb, @remediation, @correlation_id, @occurred_at
)
""";
var tenantId = eventList[0].TenantId;
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken)
.ConfigureAwait(false);
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
var count = 0;
foreach (var evt in eventList)
{
await using var command = CreateCommand(sql, connection);
AddViolationParameters(command, evt);
count += await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
return count;
dbContext.ViolationEvents.AddRange(eventList);
return await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<ViolationEventEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = "SELECT * FROM policy.violation_events WHERE tenant_id = @tenant_id AND id = @id";
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken)
.ConfigureAwait(false);
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
return await QuerySingleOrDefaultAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "id", id);
},
MapViolation,
cancellationToken).ConfigureAwait(false);
return await dbContext.ViolationEvents
.AsNoTracking()
.FirstOrDefaultAsync(v => v.TenantId == tenantId && v.Id == id, cancellationToken)
.ConfigureAwait(false);
}
/// <inheritdoc />
@@ -103,34 +71,25 @@ public sealed class ViolationEventRepository : RepositoryBase<PolicyDataSource>,
int offset = 0,
CancellationToken cancellationToken = default)
{
var sql = """
SELECT * FROM policy.violation_events
WHERE tenant_id = @tenant_id AND policy_id = @policy_id
""";
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken)
.ConfigureAwait(false);
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
var query = dbContext.ViolationEvents
.AsNoTracking()
.Where(v => v.TenantId == tenantId && v.PolicyId == policyId);
if (since.HasValue)
{
sql += " AND occurred_at >= @since";
query = query.Where(v => v.OccurredAt >= since.Value);
}
sql += " ORDER BY occurred_at DESC LIMIT @limit OFFSET @offset";
return await QueryAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "policy_id", policyId);
AddParameter(cmd, "limit", limit);
AddParameter(cmd, "offset", offset);
if (since.HasValue)
{
AddParameter(cmd, "since", since.Value);
}
},
MapViolation,
cancellationToken).ConfigureAwait(false);
return await query
.OrderByDescending(v => v.OccurredAt)
.Skip(offset)
.Take(limit)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
}
/// <inheritdoc />
@@ -141,33 +100,24 @@ public sealed class ViolationEventRepository : RepositoryBase<PolicyDataSource>,
int limit = 100,
CancellationToken cancellationToken = default)
{
var sql = """
SELECT * FROM policy.violation_events
WHERE tenant_id = @tenant_id AND severity = @severity
""";
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken)
.ConfigureAwait(false);
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
var query = dbContext.ViolationEvents
.AsNoTracking()
.Where(v => v.TenantId == tenantId && v.Severity == severity);
if (since.HasValue)
{
sql += " AND occurred_at >= @since";
query = query.Where(v => v.OccurredAt >= since.Value);
}
sql += " ORDER BY occurred_at DESC LIMIT @limit";
return await QueryAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "severity", severity);
AddParameter(cmd, "limit", limit);
if (since.HasValue)
{
AddParameter(cmd, "since", since.Value);
}
},
MapViolation,
cancellationToken).ConfigureAwait(false);
return await query
.OrderByDescending(v => v.OccurredAt)
.Take(limit)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
}
/// <inheritdoc />
@@ -177,24 +127,17 @@ public sealed class ViolationEventRepository : RepositoryBase<PolicyDataSource>,
int limit = 100,
CancellationToken cancellationToken = default)
{
const string sql = """
SELECT * FROM policy.violation_events
WHERE tenant_id = @tenant_id AND subject_purl = @purl
ORDER BY occurred_at DESC
LIMIT @limit
""";
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken)
.ConfigureAwait(false);
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
return await QueryAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "purl", purl);
AddParameter(cmd, "limit", limit);
},
MapViolation,
cancellationToken).ConfigureAwait(false);
return await dbContext.ViolationEvents
.AsNoTracking()
.Where(v => v.TenantId == tenantId && v.SubjectPurl == purl)
.OrderByDescending(v => v.OccurredAt)
.Take(limit)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
}
/// <inheritdoc />
@@ -204,6 +147,7 @@ public sealed class ViolationEventRepository : RepositoryBase<PolicyDataSource>,
DateTimeOffset until,
CancellationToken cancellationToken = default)
{
// Keep raw SQL: GROUP BY aggregate with cast cannot be cleanly expressed as Dictionary in EF Core
const string sql = """
SELECT severity, COUNT(*)::int as count
FROM policy.violation_events
@@ -231,35 +175,4 @@ public sealed class ViolationEventRepository : RepositoryBase<PolicyDataSource>,
return results;
}
private static void AddViolationParameters(NpgsqlCommand command, ViolationEventEntity violation)
{
AddParameter(command, "id", violation.Id);
AddParameter(command, "tenant_id", violation.TenantId);
AddParameter(command, "policy_id", violation.PolicyId);
AddParameter(command, "rule_id", violation.RuleId);
AddParameter(command, "severity", violation.Severity);
AddParameter(command, "subject_purl", violation.SubjectPurl as object ?? DBNull.Value);
AddParameter(command, "subject_cve", violation.SubjectCve as object ?? DBNull.Value);
AddJsonbParameter(command, "details", violation.Details);
AddParameter(command, "remediation", violation.Remediation as object ?? DBNull.Value);
AddParameter(command, "correlation_id", violation.CorrelationId as object ?? DBNull.Value);
AddParameter(command, "occurred_at", violation.OccurredAt);
}
private static ViolationEventEntity MapViolation(NpgsqlDataReader reader) => new()
{
Id = reader.GetGuid(reader.GetOrdinal("id")),
TenantId = reader.GetString(reader.GetOrdinal("tenant_id")),
PolicyId = reader.GetGuid(reader.GetOrdinal("policy_id")),
RuleId = reader.GetString(reader.GetOrdinal("rule_id")),
Severity = reader.GetString(reader.GetOrdinal("severity")),
SubjectPurl = GetNullableString(reader, reader.GetOrdinal("subject_purl")),
SubjectCve = GetNullableString(reader, reader.GetOrdinal("subject_cve")),
Details = reader.GetString(reader.GetOrdinal("details")),
Remediation = GetNullableString(reader, reader.GetOrdinal("remediation")),
CorrelationId = GetNullableString(reader, reader.GetOrdinal("correlation_id")),
OccurredAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("occurred_at")),
CreatedAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("created_at"))
};
}

View File

@@ -1,3 +1,4 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Infrastructure.Postgres.Repositories;
@@ -7,6 +8,8 @@ namespace StellaOps.Policy.Persistence.Postgres.Repositories;
/// <summary>
/// PostgreSQL repository for worker result operations.
/// Uses EF Core for reads and inserts; raw SQL preserved for conditional CASE updates,
/// system-connection queries, and retry_count increment.
/// </summary>
public sealed class WorkerResultRepository : RepositoryBase<PolicyDataSource>, IWorkerResultRepository
{
@@ -21,45 +24,27 @@ public sealed class WorkerResultRepository : RepositoryBase<PolicyDataSource>, I
/// <inheritdoc />
public async Task<WorkerResultEntity> CreateAsync(WorkerResultEntity result, CancellationToken cancellationToken = default)
{
const string sql = """
INSERT INTO policy.worker_results (
id, tenant_id, job_type, job_id, status, progress,
input_hash, max_retries, scheduled_at, metadata, created_by
)
VALUES (
@id, @tenant_id, @job_type, @job_id, @status, @progress,
@input_hash, @max_retries, @scheduled_at, @metadata::jsonb, @created_by
)
RETURNING *
""";
await using var connection = await DataSource.OpenConnectionAsync(result.TenantId, "writer", cancellationToken)
.ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
AddResultParameters(command, result);
dbContext.WorkerResults.Add(result);
await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
return MapResult(reader);
return result;
}
/// <inheritdoc />
public async Task<WorkerResultEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = "SELECT * FROM policy.worker_results WHERE tenant_id = @tenant_id AND id = @id";
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken)
.ConfigureAwait(false);
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
return await QuerySingleOrDefaultAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "id", id);
},
MapResult,
cancellationToken).ConfigureAwait(false);
return await dbContext.WorkerResults
.AsNoTracking()
.FirstOrDefaultAsync(r => r.TenantId == tenantId && r.Id == id, cancellationToken)
.ConfigureAwait(false);
}
/// <inheritdoc />
@@ -69,22 +54,14 @@ public sealed class WorkerResultRepository : RepositoryBase<PolicyDataSource>, I
string jobId,
CancellationToken cancellationToken = default)
{
const string sql = """
SELECT * FROM policy.worker_results
WHERE tenant_id = @tenant_id AND job_type = @job_type AND job_id = @job_id
""";
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken)
.ConfigureAwait(false);
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
return await QuerySingleOrDefaultAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "job_type", jobType);
AddParameter(cmd, "job_id", jobId);
},
MapResult,
cancellationToken).ConfigureAwait(false);
return await dbContext.WorkerResults
.AsNoTracking()
.FirstOrDefaultAsync(r => r.TenantId == tenantId && r.JobType == jobType && r.JobId == jobId, cancellationToken)
.ConfigureAwait(false);
}
/// <inheritdoc />
@@ -94,24 +71,17 @@ public sealed class WorkerResultRepository : RepositoryBase<PolicyDataSource>, I
int limit = 100,
CancellationToken cancellationToken = default)
{
const string sql = """
SELECT * FROM policy.worker_results
WHERE tenant_id = @tenant_id AND status = @status
ORDER BY created_at DESC
LIMIT @limit
""";
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken)
.ConfigureAwait(false);
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
return await QueryAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "status", status);
AddParameter(cmd, "limit", limit);
},
MapResult,
cancellationToken).ConfigureAwait(false);
return await dbContext.WorkerResults
.AsNoTracking()
.Where(r => r.TenantId == tenantId && r.Status == status)
.OrderByDescending(r => r.CreatedAt)
.Take(limit)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
}
/// <inheritdoc />
@@ -120,6 +90,7 @@ public sealed class WorkerResultRepository : RepositoryBase<PolicyDataSource>, I
int limit = 100,
CancellationToken cancellationToken = default)
{
// Keep raw SQL: system connection (no tenant) + NULLS LAST ordering
var sql = """
SELECT * FROM policy.worker_results
WHERE status = 'pending'
@@ -160,6 +131,7 @@ public sealed class WorkerResultRepository : RepositoryBase<PolicyDataSource>, I
string? errorMessage = null,
CancellationToken cancellationToken = default)
{
// Keep raw SQL: CASE conditional update for started_at
const string sql = """
UPDATE policy.worker_results
SET status = @status, progress = @progress, error_message = @error_message,
@@ -191,6 +163,7 @@ public sealed class WorkerResultRepository : RepositoryBase<PolicyDataSource>, I
string? outputHash = null,
CancellationToken cancellationToken = default)
{
// Keep raw SQL: multi-column update with NOW() and jsonb cast
const string sql = """
UPDATE policy.worker_results
SET status = 'completed', progress = 100, result = @result::jsonb,
@@ -220,6 +193,7 @@ public sealed class WorkerResultRepository : RepositoryBase<PolicyDataSource>, I
string errorMessage,
CancellationToken cancellationToken = default)
{
// Keep raw SQL: conditional update with NOW()
const string sql = """
UPDATE policy.worker_results
SET status = 'failed', error_message = @error_message, completed_at = NOW()
@@ -246,6 +220,7 @@ public sealed class WorkerResultRepository : RepositoryBase<PolicyDataSource>, I
Guid id,
CancellationToken cancellationToken = default)
{
// Keep raw SQL: retry_count increment with conditional WHERE on max_retries
const string sql = """
UPDATE policy.worker_results
SET retry_count = retry_count + 1, status = 'pending', started_at = NULL
@@ -265,21 +240,6 @@ public sealed class WorkerResultRepository : RepositoryBase<PolicyDataSource>, I
return rows > 0;
}
private static void AddResultParameters(NpgsqlCommand command, WorkerResultEntity result)
{
AddParameter(command, "id", result.Id);
AddParameter(command, "tenant_id", result.TenantId);
AddParameter(command, "job_type", result.JobType);
AddParameter(command, "job_id", result.JobId);
AddParameter(command, "status", result.Status);
AddParameter(command, "progress", result.Progress);
AddParameter(command, "input_hash", result.InputHash as object ?? DBNull.Value);
AddParameter(command, "max_retries", result.MaxRetries);
AddParameter(command, "scheduled_at", result.ScheduledAt as object ?? DBNull.Value);
AddJsonbParameter(command, "metadata", result.Metadata);
AddParameter(command, "created_by", result.CreatedBy as object ?? DBNull.Value);
}
private static WorkerResultEntity MapResult(NpgsqlDataReader reader) => new()
{
Id = reader.GetGuid(reader.GetOrdinal("id")),

View File

@@ -13,7 +13,12 @@
</PropertyGroup>
<ItemGroup>
<EmbeddedResource Include="Migrations\*.sql" />
<EmbeddedResource Include="Migrations\**\*.sql" LogicalName="%(RecursiveDir)%(Filename)%(Extension)" />
</ItemGroup>
<ItemGroup>
<!-- Prevent automatic compiled-model binding so non-default schemas can build runtime models. -->
<Compile Remove="EfCore\CompiledModels\PolicyDbContextAssemblyAttributes.cs" />
</ItemGroup>
<ItemGroup>

View File

@@ -1,10 +1,15 @@
# StellaOps.Policy.Persistence Task Board
This board mirrors active sprint tasks for this module.
Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
Source of truth: `docs/implplan/SPRINT_20260222_089_Policy_dal_to_efcore.md`.
| Task ID | Status | Notes |
| --- | --- | --- |
| AUDIT-0448-M | DONE | Revalidated 2026-01-07; maintainability audit for StellaOps.Policy.Persistence. |
| AUDIT-0448-T | DONE | Revalidated 2026-01-07; test coverage audit for StellaOps.Policy.Persistence. |
| AUDIT-0448-A | TODO | Revalidated 2026-01-07 (open findings). |
| POLICY-EF-01 | DONE | Module AGENTS.md verified; migration plugin registered in Platform registry. |
| POLICY-EF-02 | DONE | EF Core model baseline scaffolded: PolicyDbContext (22 DbSets), design-time factory, compiled model stubs, 4 new entity models. |
| POLICY-EF-03 | DONE | 14 repositories converted to EF Core (partial or full); 8 complex repositories retained as raw SQL. Build passes 0W/0E. |
| POLICY-EF-04 | DONE | Compiled model stubs verified; runtime factory uses UseModel on default schema; non-default schema uses reflection fallback. |
| POLICY-EF-05 | DONE | Sequential build validated; AGENTS.md and TASKS.md updated; architecture doc paths corrected. |

View File

@@ -0,0 +1,263 @@
// -----------------------------------------------------------------------------
// TenantIsolationTests.cs
// Sprint: POL-TEN-05 - Tenant Isolation Tests
// Description: Focused unit tests for tenant isolation in the Policy Engine's
// TenantContextMiddleware, TenantContextAccessor, and
// TenantContextEndpointFilter.
// -----------------------------------------------------------------------------
using System.Security.Claims;
using FluentAssertions;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Policy.Engine.Tenancy;
using Xunit;
using MsOptions = Microsoft.Extensions.Options;
namespace StellaOps.Policy.Engine.Tests.Tenancy;
/// <summary>
/// Tests verifying tenant isolation behaviour of the Policy Engine's own
/// tenancy middleware and endpoint filter. These are pure unit tests --
/// no Postgres, no WebApplicationFactory.
/// </summary>
[Trait("Category", "Unit")]
public sealed class TenantIsolationTests
{
private readonly TenantContextAccessor _accessor = new();
private readonly FakeTimeProvider _timeProvider = new(new DateTimeOffset(2026, 2, 23, 0, 0, 0, TimeSpan.Zero));
// ---------------------------------------------------------------
// 1. Canonical claim resolution
// ---------------------------------------------------------------
[Fact]
public async Task Middleware_ResolvesCanonicalStellaTenantClaim_WhenHeaderAbsent()
{
// Arrange
TenantContext? captured = null;
var middleware = BuildMiddleware(
next: _ => { captured = _accessor.TenantContext; return Task.CompletedTask; },
requireHeader: true);
var ctx = CreateHttpContext("/api/policy/decisions", tenantId: null);
ctx.User = new ClaimsPrincipal(new ClaimsIdentity(
new[]
{
new Claim(TenantContextConstants.CanonicalTenantClaim, "acme-corp"),
new Claim("sub", "user-1")
},
"TestAuth"));
// Act
await middleware.InvokeAsync(ctx, _accessor);
// Assert
captured.Should().NotBeNull("middleware should resolve tenant from canonical claim");
captured!.TenantId.Should().Be("acme-corp");
ctx.Response.StatusCode.Should().NotBe(StatusCodes.Status400BadRequest);
_accessor.TenantContext.Should().BeNull("accessor is cleared after pipeline completes");
}
// ---------------------------------------------------------------
// 2. Missing tenant produces null context (not-required mode)
// ---------------------------------------------------------------
[Fact]
public async Task Middleware_MissingTenantAndNoClaimsWithRequireDisabled_DefaultsTenantAndContextIsSet()
{
// Arrange
TenantContext? captured = null;
var middleware = BuildMiddleware(
next: _ => { captured = _accessor.TenantContext; return Task.CompletedTask; },
requireHeader: false);
var ctx = CreateHttpContext("/api/policy/decisions", tenantId: null);
// No claims, no header
// Act
await middleware.InvokeAsync(ctx, _accessor);
// Assert
captured.Should().NotBeNull("with RequireTenantHeader=false, middleware defaults to public tenant");
captured!.TenantId.Should().Be(TenantContextConstants.DefaultTenantId,
"when no header/claim is present and tenant is not required, the default tenant 'public' is used");
_accessor.TenantContext.Should().BeNull("accessor is cleared after pipeline completes");
}
[Fact]
public async Task Middleware_MissingTenantAndNoClaimsWithRequireEnabled_Returns400()
{
// Arrange
var nextCalled = false;
var middleware = BuildMiddleware(
next: _ => { nextCalled = true; return Task.CompletedTask; },
requireHeader: true);
var ctx = CreateHttpContext("/api/policy/decisions", tenantId: null);
// No claims, no header
// Act
await middleware.InvokeAsync(ctx, _accessor);
// Assert
nextCalled.Should().BeFalse("pipeline should be short-circuited");
ctx.Response.StatusCode.Should().Be(StatusCodes.Status400BadRequest);
_accessor.TenantContext.Should().BeNull("no context should be set on failure");
}
// ---------------------------------------------------------------
// 3. Legacy "tid" claim fallback
// ---------------------------------------------------------------
[Fact]
public async Task Middleware_FallsBackToLegacyTidClaim_WhenHeaderAndCanonicalClaimAbsent()
{
// Arrange
TenantContext? captured = null;
var middleware = BuildMiddleware(
next: _ => { captured = _accessor.TenantContext; return Task.CompletedTask; },
requireHeader: true);
var ctx = CreateHttpContext("/api/policy/risk-profiles", tenantId: null);
ctx.User = new ClaimsPrincipal(new ClaimsIdentity(
new[]
{
new Claim(TenantContextConstants.LegacyTenantClaim, "legacy-tenant-42"),
new Claim("sub", "svc-account")
},
"TestAuth"));
// Act
await middleware.InvokeAsync(ctx, _accessor);
// Assert
captured.Should().NotBeNull("middleware should fall back to legacy tid claim");
captured!.TenantId.Should().Be("legacy-tenant-42");
}
// ---------------------------------------------------------------
// 4. TenantContextEndpointFilter rejects tenantless requests
// ---------------------------------------------------------------
[Fact]
public async Task EndpointFilter_RejectsTenantlessRequest_Returns400WithErrorCode()
{
// Arrange
var filter = new TenantContextEndpointFilter();
var services = new ServiceCollection();
services.AddSingleton<ITenantContextAccessor>(_accessor);
var sp = services.BuildServiceProvider();
var httpContext = new DefaultHttpContext { RequestServices = sp };
httpContext.Response.Body = new MemoryStream();
// Deliberately do NOT set _accessor.TenantContext
var filterContext = CreateEndpointFilterContext(httpContext);
// Act
var result = await filter.InvokeAsync(filterContext, _ =>
new ValueTask<object?>(Results.Ok("should not reach")));
// Assert
result.Should().NotBeNull();
// The filter returns a ProblemDetails result (IResult).
// Verify by writing it to the response and checking status code.
if (result is IResult httpResult)
{
await httpResult.ExecuteAsync(httpContext);
httpContext.Response.StatusCode.Should().Be(StatusCodes.Status400BadRequest);
}
}
// ---------------------------------------------------------------
// 5. Header takes precedence over claims (no conflict detection
// in middleware -- header wins, which is the correct design)
// ---------------------------------------------------------------
[Fact]
public async Task Middleware_HeaderTakesPrecedenceOverClaim_WhenBothPresent()
{
// Arrange
TenantContext? captured = null;
var middleware = BuildMiddleware(
next: _ => { captured = _accessor.TenantContext; return Task.CompletedTask; },
requireHeader: true);
var ctx = CreateHttpContext("/api/policy/risk-profiles", tenantId: "header-tenant");
ctx.User = new ClaimsPrincipal(new ClaimsIdentity(
new[]
{
new Claim(TenantContextConstants.CanonicalTenantClaim, "claim-tenant"),
new Claim(TenantContextConstants.LegacyTenantClaim, "legacy-tenant"),
new Claim("sub", "user-1")
},
"TestAuth"));
// Act
await middleware.InvokeAsync(ctx, _accessor);
// Assert
captured.Should().NotBeNull();
captured!.TenantId.Should().Be("header-tenant",
"the X-Stella-Tenant header must take precedence over JWT claims " +
"so that gateway-injected headers are authoritative");
}
// ---------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------
/// <summary>
/// Builds a <see cref="TenantContextMiddleware"/> with configurable options.
/// </summary>
private TenantContextMiddleware BuildMiddleware(
RequestDelegate next,
bool requireHeader = true,
bool enabled = true)
{
var options = new TenantContextOptions
{
Enabled = enabled,
RequireTenantHeader = requireHeader,
ExcludedPaths = ["/healthz", "/readyz"]
};
return new TenantContextMiddleware(
next,
MsOptions.Options.Create(options),
NullLogger<TenantContextMiddleware>.Instance,
_timeProvider);
}
/// <summary>
/// Creates a minimal <see cref="DefaultHttpContext"/> for middleware tests.
/// </summary>
private static DefaultHttpContext CreateHttpContext(string path, string? tenantId)
{
var ctx = new DefaultHttpContext();
ctx.Request.Path = path;
if (!string.IsNullOrEmpty(tenantId))
{
ctx.Request.Headers[TenantContextConstants.TenantHeader] = tenantId;
}
ctx.Response.Body = new MemoryStream();
return ctx;
}
/// <summary>
/// Creates a minimal <see cref="EndpointFilterInvocationContext"/> for filter tests.
/// </summary>
private static EndpointFilterInvocationContext CreateEndpointFilterContext(HttpContext httpContext)
{
// EndpointFilterInvocationContext is abstract; use the default implementation
// via the factory available on DefaultEndpointFilterInvocationContext.
return new DefaultEndpointFilterInvocationContext(httpContext);
}
}