Refactor and update test projects, remove obsolete tests, and upgrade dependencies
- Deleted obsolete test files for SchedulerAuditService and SchedulerMongoSessionFactory. - Removed unused TestDataFactory class. - Updated project files for Mongo.Tests to remove references to deleted files. - Upgraded BouncyCastle.Cryptography package to version 2.6.2 across multiple projects. - Replaced Microsoft.Extensions.Http.Polly with Microsoft.Extensions.Http.Resilience in Zastava.Webhook project. - Updated NetEscapades.Configuration.Yaml package to version 3.1.0 in Configuration library. - Upgraded Pkcs11Interop package to version 5.1.2 in Cryptography libraries. - Refactored Argon2idPasswordHasher to use BouncyCastle for hashing instead of Konscious. - Updated JsonSchema.Net package to version 7.3.2 in Microservice project. - Updated global.json to use .NET SDK version 10.0.101.
This commit is contained in:
@@ -15,18 +15,17 @@ using StellaOps.Policy.Engine.BatchEvaluation;
|
||||
using StellaOps.Policy.Engine.DependencyInjection;
|
||||
using StellaOps.PolicyDsl;
|
||||
using StellaOps.Policy.Engine.Services;
|
||||
using StellaOps.Policy.Engine.Workers;
|
||||
using StellaOps.Policy.Engine.Streaming;
|
||||
using StellaOps.Policy.Engine.Telemetry;
|
||||
using StellaOps.Policy.Engine.ConsoleSurface;
|
||||
using StellaOps.AirGap.Policy;
|
||||
using StellaOps.Policy.Engine.Orchestration;
|
||||
using StellaOps.Policy.Engine.ReachabilityFacts;
|
||||
using StellaOps.Policy.Engine.Storage.InMemory;
|
||||
using StellaOps.Policy.Engine.Storage.Mongo.Repositories;
|
||||
using StellaOps.Policy.Scoring.Engine;
|
||||
using StellaOps.Policy.Scoring.Receipts;
|
||||
using StellaOps.Policy.Storage.Postgres;
|
||||
using StellaOps.Policy.Engine.Workers;
|
||||
using StellaOps.Policy.Engine.Streaming;
|
||||
using StellaOps.Policy.Engine.Telemetry;
|
||||
using StellaOps.Policy.Engine.ConsoleSurface;
|
||||
using StellaOps.AirGap.Policy;
|
||||
using StellaOps.Policy.Engine.Orchestration;
|
||||
using StellaOps.Policy.Engine.ReachabilityFacts;
|
||||
using StellaOps.Policy.Engine.Storage.InMemory;
|
||||
using StellaOps.Policy.Scoring.Engine;
|
||||
using StellaOps.Policy.Scoring.Receipts;
|
||||
using StellaOps.Policy.Storage.Postgres;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
@@ -95,16 +94,16 @@ var bootstrap = StellaOpsConfigurationBootstrapper.Build<PolicyEngineOptions>(op
|
||||
|
||||
builder.Configuration.AddConfiguration(bootstrap.Configuration);
|
||||
|
||||
builder.ConfigurePolicyEngineTelemetry(bootstrap.Options);
|
||||
|
||||
builder.Services.AddAirGapEgressPolicy(builder.Configuration, sectionName: "AirGap");
|
||||
|
||||
// CVSS receipts rely on PostgreSQL storage for deterministic persistence.
|
||||
builder.Services.AddPolicyPostgresStorage(builder.Configuration, sectionName: "Postgres:Policy");
|
||||
|
||||
builder.Services.AddSingleton<ICvssV4Engine, CvssV4Engine>();
|
||||
builder.Services.AddScoped<IReceiptBuilder, ReceiptBuilder>();
|
||||
builder.Services.AddScoped<IReceiptHistoryService, ReceiptHistoryService>();
|
||||
builder.ConfigurePolicyEngineTelemetry(bootstrap.Options);
|
||||
|
||||
builder.Services.AddAirGapEgressPolicy(builder.Configuration, sectionName: "AirGap");
|
||||
|
||||
// CVSS receipts rely on PostgreSQL storage for deterministic persistence.
|
||||
builder.Services.AddPolicyPostgresStorage(builder.Configuration, sectionName: "Postgres:Policy");
|
||||
|
||||
builder.Services.AddSingleton<ICvssV4Engine, CvssV4Engine>();
|
||||
builder.Services.AddScoped<IReceiptBuilder, ReceiptBuilder>();
|
||||
builder.Services.AddScoped<IReceiptHistoryService, ReceiptHistoryService>();
|
||||
|
||||
builder.Services.AddOptions<PolicyEngineOptions>()
|
||||
.Bind(builder.Configuration.GetSection(PolicyEngineOptions.SectionName))
|
||||
@@ -324,30 +323,30 @@ app.MapAdvisoryAiKnobs();
|
||||
app.MapBatchContext();
|
||||
app.MapOrchestratorJobs();
|
||||
app.MapPolicyWorker();
|
||||
app.MapLedgerExport();
|
||||
app.MapConsoleExportJobs(); // CONTRACT-EXPORT-BUNDLE-009
|
||||
app.MapPolicyPackBundles(); // CONTRACT-MIRROR-BUNDLE-003
|
||||
app.MapSealedMode(); // CONTRACT-SEALED-MODE-004
|
||||
app.MapStalenessSignaling(); // CONTRACT-SEALED-MODE-004 staleness
|
||||
app.MapAirGapNotifications(); // Air-gap notifications
|
||||
app.MapPolicyLint(); // POLICY-AOC-19-001 determinism linting
|
||||
app.MapVerificationPolicies(); // CONTRACT-VERIFICATION-POLICY-006 attestation policies
|
||||
app.MapVerificationPolicyEditor(); // CONTRACT-VERIFICATION-POLICY-006 editor DTOs/validation
|
||||
app.MapAttestationReports(); // CONTRACT-VERIFICATION-POLICY-006 attestation reports
|
||||
app.MapConsoleAttestationReports(); // CONTRACT-VERIFICATION-POLICY-006 Console integration
|
||||
app.MapSnapshots();
|
||||
app.MapViolations();
|
||||
app.MapPolicyDecisions();
|
||||
app.MapRiskProfiles();
|
||||
app.MapRiskProfileSchema();
|
||||
app.MapScopeAttachments();
|
||||
app.MapEffectivePolicies(); // CONTRACT-AUTHORITY-EFFECTIVE-WRITE-008
|
||||
app.MapRiskSimulation();
|
||||
app.MapOverrides();
|
||||
app.MapProfileExport();
|
||||
app.MapRiskProfileAirGap(); // CONTRACT-MIRROR-BUNDLE-003 risk profile air-gap
|
||||
app.MapProfileEvents();
|
||||
app.MapCvssReceipts(); // CVSS v4 receipt CRUD & history
|
||||
app.MapLedgerExport();
|
||||
app.MapConsoleExportJobs(); // CONTRACT-EXPORT-BUNDLE-009
|
||||
app.MapPolicyPackBundles(); // CONTRACT-MIRROR-BUNDLE-003
|
||||
app.MapSealedMode(); // CONTRACT-SEALED-MODE-004
|
||||
app.MapStalenessSignaling(); // CONTRACT-SEALED-MODE-004 staleness
|
||||
app.MapAirGapNotifications(); // Air-gap notifications
|
||||
app.MapPolicyLint(); // POLICY-AOC-19-001 determinism linting
|
||||
app.MapVerificationPolicies(); // CONTRACT-VERIFICATION-POLICY-006 attestation policies
|
||||
app.MapVerificationPolicyEditor(); // CONTRACT-VERIFICATION-POLICY-006 editor DTOs/validation
|
||||
app.MapAttestationReports(); // CONTRACT-VERIFICATION-POLICY-006 attestation reports
|
||||
app.MapConsoleAttestationReports(); // CONTRACT-VERIFICATION-POLICY-006 Console integration
|
||||
app.MapSnapshots();
|
||||
app.MapViolations();
|
||||
app.MapPolicyDecisions();
|
||||
app.MapRiskProfiles();
|
||||
app.MapRiskProfileSchema();
|
||||
app.MapScopeAttachments();
|
||||
app.MapEffectivePolicies(); // CONTRACT-AUTHORITY-EFFECTIVE-WRITE-008
|
||||
app.MapRiskSimulation();
|
||||
app.MapOverrides();
|
||||
app.MapProfileExport();
|
||||
app.MapRiskProfileAirGap(); // CONTRACT-MIRROR-BUNDLE-003 risk profile air-gap
|
||||
app.MapProfileEvents();
|
||||
app.MapCvssReceipts(); // CVSS v4 receipt CRUD & history
|
||||
|
||||
// Phase 5: Multi-tenant PostgreSQL-backed API endpoints
|
||||
app.MapPolicySnapshotsApi();
|
||||
|
||||
@@ -1,325 +0,0 @@
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Bson.Serialization.Attributes;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Storage.Mongo.Documents;
|
||||
|
||||
/// <summary>
|
||||
/// MongoDB document representing an effective finding after policy evaluation.
|
||||
/// Collection: effective_finding_{policyId}
|
||||
/// Tenant-scoped with unique constraint on (tenantId, componentPurl, advisoryId).
|
||||
/// </summary>
|
||||
[BsonIgnoreExtraElements]
|
||||
public sealed class EffectiveFindingDocument
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique identifier: sha256:{hash of tenantId|policyId|componentPurl|advisoryId}
|
||||
/// </summary>
|
||||
[BsonId]
|
||||
[BsonElement("_id")]
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Tenant identifier (normalized to lowercase).
|
||||
/// </summary>
|
||||
[BsonElement("tenantId")]
|
||||
public string TenantId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Policy identifier.
|
||||
/// </summary>
|
||||
[BsonElement("policyId")]
|
||||
public string PolicyId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Policy version at time of evaluation.
|
||||
/// </summary>
|
||||
[BsonElement("policyVersion")]
|
||||
public int PolicyVersion { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Component PURL from the SBOM.
|
||||
/// </summary>
|
||||
[BsonElement("componentPurl")]
|
||||
public string ComponentPurl { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Component name.
|
||||
/// </summary>
|
||||
[BsonElement("componentName")]
|
||||
public string ComponentName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Component version.
|
||||
/// </summary>
|
||||
[BsonElement("componentVersion")]
|
||||
public string ComponentVersion { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Package ecosystem (npm, maven, pypi, etc.).
|
||||
/// </summary>
|
||||
[BsonElement("ecosystem")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? Ecosystem { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Advisory identifier (CVE, GHSA, etc.).
|
||||
/// </summary>
|
||||
[BsonElement("advisoryId")]
|
||||
public string AdvisoryId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Advisory source.
|
||||
/// </summary>
|
||||
[BsonElement("advisorySource")]
|
||||
public string AdvisorySource { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Vulnerability ID (may differ from advisory ID).
|
||||
/// </summary>
|
||||
[BsonElement("vulnerabilityId")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? VulnerabilityId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Policy evaluation status (affected, blocked, suppressed, etc.).
|
||||
/// </summary>
|
||||
[BsonElement("status")]
|
||||
public string Status { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Normalized severity (Critical, High, Medium, Low, None).
|
||||
/// </summary>
|
||||
[BsonElement("severity")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? Severity { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// CVSS score (if available).
|
||||
/// </summary>
|
||||
[BsonElement("cvssScore")]
|
||||
[BsonIgnoreIfNull]
|
||||
public double? CvssScore { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Rule name that matched.
|
||||
/// </summary>
|
||||
[BsonElement("ruleName")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? RuleName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Rule priority.
|
||||
/// </summary>
|
||||
[BsonElement("rulePriority")]
|
||||
[BsonIgnoreIfNull]
|
||||
public int? RulePriority { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// VEX status overlay (if VEX was applied).
|
||||
/// </summary>
|
||||
[BsonElement("vexStatus")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? VexStatus { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// VEX justification (if VEX was applied).
|
||||
/// </summary>
|
||||
[BsonElement("vexJustification")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? VexJustification { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// VEX provider/vendor.
|
||||
/// </summary>
|
||||
[BsonElement("vexVendor")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? VexVendor { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether a VEX override was applied.
|
||||
/// </summary>
|
||||
[BsonElement("isVexOverride")]
|
||||
public bool IsVexOverride { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// SBOM ID where component was found.
|
||||
/// </summary>
|
||||
[BsonElement("sbomId")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? SbomId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Product key associated with the SBOM.
|
||||
/// </summary>
|
||||
[BsonElement("productKey")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? ProductKey { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Policy evaluation annotations.
|
||||
/// </summary>
|
||||
[BsonElement("annotations")]
|
||||
public Dictionary<string, string> Annotations { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Current history version (incremented on each update).
|
||||
/// </summary>
|
||||
[BsonElement("historyVersion")]
|
||||
public long HistoryVersion { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Reference to the policy run that produced this finding.
|
||||
/// </summary>
|
||||
[BsonElement("policyRunId")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? PolicyRunId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Trace ID for distributed tracing.
|
||||
/// </summary>
|
||||
[BsonElement("traceId")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? TraceId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Span ID for distributed tracing.
|
||||
/// </summary>
|
||||
[BsonElement("spanId")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? SpanId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When this finding was first created.
|
||||
/// </summary>
|
||||
[BsonElement("createdAt")]
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When this finding was last updated.
|
||||
/// </summary>
|
||||
[BsonElement("updatedAt")]
|
||||
public DateTimeOffset UpdatedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Content hash for deduplication and change detection.
|
||||
/// </summary>
|
||||
[BsonElement("contentHash")]
|
||||
public string ContentHash { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// MongoDB document for effective finding history (append-only).
|
||||
/// Collection: effective_finding_history_{policyId}
|
||||
/// </summary>
|
||||
[BsonIgnoreExtraElements]
|
||||
public sealed class EffectiveFindingHistoryDocument
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique identifier: {findingId}:v{version}
|
||||
/// </summary>
|
||||
[BsonId]
|
||||
[BsonElement("_id")]
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Tenant identifier.
|
||||
/// </summary>
|
||||
[BsonElement("tenantId")]
|
||||
public string TenantId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Reference to the effective finding.
|
||||
/// </summary>
|
||||
[BsonElement("findingId")]
|
||||
public string FindingId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Policy identifier.
|
||||
/// </summary>
|
||||
[BsonElement("policyId")]
|
||||
public string PolicyId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// History version number (monotonically increasing).
|
||||
/// </summary>
|
||||
[BsonElement("version")]
|
||||
public long Version { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Type of change (Created, StatusChanged, SeverityChanged, VexApplied, etc.).
|
||||
/// </summary>
|
||||
[BsonElement("changeType")]
|
||||
public string ChangeType { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Previous status (for status changes).
|
||||
/// </summary>
|
||||
[BsonElement("previousStatus")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? PreviousStatus { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// New status.
|
||||
/// </summary>
|
||||
[BsonElement("newStatus")]
|
||||
public string NewStatus { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Previous severity (for severity changes).
|
||||
/// </summary>
|
||||
[BsonElement("previousSeverity")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? PreviousSeverity { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// New severity.
|
||||
/// </summary>
|
||||
[BsonElement("newSeverity")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? NewSeverity { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Previous content hash.
|
||||
/// </summary>
|
||||
[BsonElement("previousContentHash")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? PreviousContentHash { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// New content hash.
|
||||
/// </summary>
|
||||
[BsonElement("newContentHash")]
|
||||
public string NewContentHash { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Policy run that triggered this change.
|
||||
/// </summary>
|
||||
[BsonElement("policyRunId")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? PolicyRunId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Trace ID for distributed tracing.
|
||||
/// </summary>
|
||||
[BsonElement("traceId")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? TraceId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When this change occurred.
|
||||
/// </summary>
|
||||
[BsonElement("occurredAt")]
|
||||
public DateTimeOffset OccurredAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// TTL expiration timestamp for automatic cleanup.
|
||||
/// </summary>
|
||||
[BsonElement("expiresAt")]
|
||||
[BsonIgnoreIfNull]
|
||||
public DateTimeOffset? ExpiresAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates the composite ID for a history entry.
|
||||
/// </summary>
|
||||
public static string CreateId(string findingId, long version) => $"{findingId}:v{version}";
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Bson.Serialization.Attributes;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Storage.Mongo.Documents;
|
||||
|
||||
/// <summary>
|
||||
/// MongoDB document for policy audit log entries.
|
||||
/// Collection: policy_audit
|
||||
/// Tracks all policy-related actions for compliance and debugging.
|
||||
/// </summary>
|
||||
[BsonIgnoreExtraElements]
|
||||
public sealed class PolicyAuditDocument
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique audit entry identifier.
|
||||
/// </summary>
|
||||
[BsonId]
|
||||
[BsonElement("_id")]
|
||||
public ObjectId Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Tenant identifier.
|
||||
/// </summary>
|
||||
[BsonElement("tenantId")]
|
||||
public string TenantId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Action type (PolicyCreated, PolicyUpdated, RevisionApproved, RunStarted, etc.).
|
||||
/// </summary>
|
||||
[BsonElement("action")]
|
||||
public string Action { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Resource type (Policy, Revision, Bundle, Run, Finding).
|
||||
/// </summary>
|
||||
[BsonElement("resourceType")]
|
||||
public string ResourceType { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Resource identifier.
|
||||
/// </summary>
|
||||
[BsonElement("resourceId")]
|
||||
public string ResourceId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Actor identifier (user ID or service account).
|
||||
/// </summary>
|
||||
[BsonElement("actorId")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? ActorId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Actor type (User, ServiceAccount, System).
|
||||
/// </summary>
|
||||
[BsonElement("actorType")]
|
||||
public string ActorType { get; set; } = "System";
|
||||
|
||||
/// <summary>
|
||||
/// Previous state snapshot (for update actions).
|
||||
/// </summary>
|
||||
[BsonElement("previousState")]
|
||||
[BsonIgnoreIfNull]
|
||||
public BsonDocument? PreviousState { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// New state snapshot (for create/update actions).
|
||||
/// </summary>
|
||||
[BsonElement("newState")]
|
||||
[BsonIgnoreIfNull]
|
||||
public BsonDocument? NewState { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional context/metadata.
|
||||
/// </summary>
|
||||
[BsonElement("metadata")]
|
||||
public Dictionary<string, string> Metadata { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Correlation ID for distributed tracing.
|
||||
/// </summary>
|
||||
[BsonElement("correlationId")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? CorrelationId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Trace ID for OpenTelemetry.
|
||||
/// </summary>
|
||||
[BsonElement("traceId")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? TraceId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Client IP address.
|
||||
/// </summary>
|
||||
[BsonElement("clientIp")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? ClientIp { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// User agent string.
|
||||
/// </summary>
|
||||
[BsonElement("userAgent")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? UserAgent { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When the action occurred.
|
||||
/// </summary>
|
||||
[BsonElement("occurredAt")]
|
||||
public DateTimeOffset OccurredAt { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Audit action types for policy operations.
|
||||
/// </summary>
|
||||
public static class PolicyAuditActions
|
||||
{
|
||||
public const string PolicyCreated = "PolicyCreated";
|
||||
public const string PolicyUpdated = "PolicyUpdated";
|
||||
public const string PolicyDeleted = "PolicyDeleted";
|
||||
public const string RevisionCreated = "RevisionCreated";
|
||||
public const string RevisionApproved = "RevisionApproved";
|
||||
public const string RevisionActivated = "RevisionActivated";
|
||||
public const string RevisionArchived = "RevisionArchived";
|
||||
public const string BundleCompiled = "BundleCompiled";
|
||||
public const string RunStarted = "RunStarted";
|
||||
public const string RunCompleted = "RunCompleted";
|
||||
public const string RunFailed = "RunFailed";
|
||||
public const string RunCancelled = "RunCancelled";
|
||||
public const string FindingCreated = "FindingCreated";
|
||||
public const string FindingUpdated = "FindingUpdated";
|
||||
public const string SimulationStarted = "SimulationStarted";
|
||||
public const string SimulationCompleted = "SimulationCompleted";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resource types for policy audit entries.
|
||||
/// </summary>
|
||||
public static class PolicyAuditResourceTypes
|
||||
{
|
||||
public const string Policy = "Policy";
|
||||
public const string Revision = "Revision";
|
||||
public const string Bundle = "Bundle";
|
||||
public const string Run = "Run";
|
||||
public const string Finding = "Finding";
|
||||
public const string Simulation = "Simulation";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Actor types for policy audit entries.
|
||||
/// </summary>
|
||||
public static class PolicyAuditActorTypes
|
||||
{
|
||||
public const string User = "User";
|
||||
public const string ServiceAccount = "ServiceAccount";
|
||||
public const string System = "System";
|
||||
}
|
||||
@@ -1,343 +0,0 @@
|
||||
using System.Collections.Immutable;
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Bson.Serialization.Attributes;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Storage.Mongo.Documents;
|
||||
|
||||
/// <summary>
|
||||
/// MongoDB document representing a policy pack.
|
||||
/// Collection: policies
|
||||
/// </summary>
|
||||
[BsonIgnoreExtraElements]
|
||||
public sealed class PolicyDocument
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique identifier (packId).
|
||||
/// </summary>
|
||||
[BsonId]
|
||||
[BsonElement("_id")]
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Tenant identifier (normalized to lowercase).
|
||||
/// </summary>
|
||||
[BsonElement("tenantId")]
|
||||
public string TenantId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Display name for the policy pack.
|
||||
/// </summary>
|
||||
[BsonElement("displayName")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? DisplayName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Description of the policy pack.
|
||||
/// </summary>
|
||||
[BsonElement("description")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? Description { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Current active revision version (null if none active).
|
||||
/// </summary>
|
||||
[BsonElement("activeVersion")]
|
||||
[BsonIgnoreIfNull]
|
||||
public int? ActiveVersion { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Latest revision version.
|
||||
/// </summary>
|
||||
[BsonElement("latestVersion")]
|
||||
public int LatestVersion { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Tags for categorization and filtering.
|
||||
/// </summary>
|
||||
[BsonElement("tags")]
|
||||
public List<string> Tags { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Creation timestamp.
|
||||
/// </summary>
|
||||
[BsonElement("createdAt")]
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Last update timestamp.
|
||||
/// </summary>
|
||||
[BsonElement("updatedAt")]
|
||||
public DateTimeOffset UpdatedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// User who created the policy pack.
|
||||
/// </summary>
|
||||
[BsonElement("createdBy")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? CreatedBy { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// MongoDB document representing a policy revision.
|
||||
/// Collection: policy_revisions
|
||||
/// </summary>
|
||||
[BsonIgnoreExtraElements]
|
||||
public sealed class PolicyRevisionDocument
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique identifier: {packId}:{version}
|
||||
/// </summary>
|
||||
[BsonId]
|
||||
[BsonElement("_id")]
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Tenant identifier.
|
||||
/// </summary>
|
||||
[BsonElement("tenantId")]
|
||||
public string TenantId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Reference to policy pack.
|
||||
/// </summary>
|
||||
[BsonElement("packId")]
|
||||
public string PackId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Revision version number.
|
||||
/// </summary>
|
||||
[BsonElement("version")]
|
||||
public int Version { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Revision status (Draft, Approved, Active, Archived).
|
||||
/// </summary>
|
||||
[BsonElement("status")]
|
||||
public string Status { get; set; } = "Draft";
|
||||
|
||||
/// <summary>
|
||||
/// Whether two-person approval is required.
|
||||
/// </summary>
|
||||
[BsonElement("requiresTwoPersonApproval")]
|
||||
public bool RequiresTwoPersonApproval { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Approval records.
|
||||
/// </summary>
|
||||
[BsonElement("approvals")]
|
||||
public List<PolicyApprovalRecord> Approvals { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Reference to the compiled bundle.
|
||||
/// </summary>
|
||||
[BsonElement("bundleId")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? BundleId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// SHA256 digest of the bundle.
|
||||
/// </summary>
|
||||
[BsonElement("bundleDigest")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? BundleDigest { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Creation timestamp.
|
||||
/// </summary>
|
||||
[BsonElement("createdAt")]
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Activation timestamp (when status became Active).
|
||||
/// </summary>
|
||||
[BsonElement("activatedAt")]
|
||||
[BsonIgnoreIfNull]
|
||||
public DateTimeOffset? ActivatedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates the composite ID for a revision.
|
||||
/// </summary>
|
||||
public static string CreateId(string packId, int version) => $"{packId}:{version}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Embedded approval record for policy revisions.
|
||||
/// </summary>
|
||||
[BsonIgnoreExtraElements]
|
||||
public sealed class PolicyApprovalRecord
|
||||
{
|
||||
/// <summary>
|
||||
/// User who approved.
|
||||
/// </summary>
|
||||
[BsonElement("actorId")]
|
||||
public string ActorId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Approval timestamp.
|
||||
/// </summary>
|
||||
[BsonElement("approvedAt")]
|
||||
public DateTimeOffset ApprovedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional comment.
|
||||
/// </summary>
|
||||
[BsonElement("comment")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? Comment { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// MongoDB document for compiled policy bundles.
|
||||
/// Collection: policy_bundles
|
||||
/// </summary>
|
||||
[BsonIgnoreExtraElements]
|
||||
public sealed class PolicyBundleDocument
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique identifier (SHA256 digest).
|
||||
/// </summary>
|
||||
[BsonId]
|
||||
[BsonElement("_id")]
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Tenant identifier.
|
||||
/// </summary>
|
||||
[BsonElement("tenantId")]
|
||||
public string TenantId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Reference to policy pack.
|
||||
/// </summary>
|
||||
[BsonElement("packId")]
|
||||
public string PackId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Revision version.
|
||||
/// </summary>
|
||||
[BsonElement("version")]
|
||||
public int Version { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Cryptographic signature.
|
||||
/// </summary>
|
||||
[BsonElement("signature")]
|
||||
public string Signature { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Bundle size in bytes.
|
||||
/// </summary>
|
||||
[BsonElement("sizeBytes")]
|
||||
public int SizeBytes { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Compiled bundle payload (binary).
|
||||
/// </summary>
|
||||
[BsonElement("payload")]
|
||||
public byte[] Payload { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// AOC metadata for compliance tracking.
|
||||
/// </summary>
|
||||
[BsonElement("aocMetadata")]
|
||||
[BsonIgnoreIfNull]
|
||||
public PolicyAocMetadataDocument? AocMetadata { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Creation timestamp.
|
||||
/// </summary>
|
||||
[BsonElement("createdAt")]
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Embedded AOC metadata document.
|
||||
/// </summary>
|
||||
[BsonIgnoreExtraElements]
|
||||
public sealed class PolicyAocMetadataDocument
|
||||
{
|
||||
[BsonElement("compilationId")]
|
||||
public string CompilationId { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("compilerVersion")]
|
||||
public string CompilerVersion { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("compiledAt")]
|
||||
public DateTimeOffset CompiledAt { get; set; }
|
||||
|
||||
[BsonElement("sourceDigest")]
|
||||
public string SourceDigest { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("artifactDigest")]
|
||||
public string ArtifactDigest { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("complexityScore")]
|
||||
public double ComplexityScore { get; set; }
|
||||
|
||||
[BsonElement("ruleCount")]
|
||||
public int RuleCount { get; set; }
|
||||
|
||||
[BsonElement("durationMilliseconds")]
|
||||
public long DurationMilliseconds { get; set; }
|
||||
|
||||
[BsonElement("provenance")]
|
||||
[BsonIgnoreIfNull]
|
||||
public PolicyProvenanceDocument? Provenance { get; set; }
|
||||
|
||||
[BsonElement("attestationRef")]
|
||||
[BsonIgnoreIfNull]
|
||||
public PolicyAttestationRefDocument? AttestationRef { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Embedded provenance document.
|
||||
/// </summary>
|
||||
[BsonIgnoreExtraElements]
|
||||
public sealed class PolicyProvenanceDocument
|
||||
{
|
||||
[BsonElement("sourceType")]
|
||||
public string SourceType { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("sourceUrl")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? SourceUrl { get; set; }
|
||||
|
||||
[BsonElement("submitter")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? Submitter { get; set; }
|
||||
|
||||
[BsonElement("commitSha")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? CommitSha { get; set; }
|
||||
|
||||
[BsonElement("branch")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? Branch { get; set; }
|
||||
|
||||
[BsonElement("ingestedAt")]
|
||||
public DateTimeOffset IngestedAt { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Embedded attestation reference document.
|
||||
/// </summary>
|
||||
[BsonIgnoreExtraElements]
|
||||
public sealed class PolicyAttestationRefDocument
|
||||
{
|
||||
[BsonElement("attestationId")]
|
||||
public string AttestationId { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("envelopeDigest")]
|
||||
public string EnvelopeDigest { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("uri")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? Uri { get; set; }
|
||||
|
||||
[BsonElement("signingKeyId")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? SigningKeyId { get; set; }
|
||||
|
||||
[BsonElement("createdAt")]
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
}
|
||||
@@ -1,482 +0,0 @@
|
||||
using System.Collections.Immutable;
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Bson.Serialization.Attributes;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Storage.Mongo.Documents;
|
||||
|
||||
/// <summary>
|
||||
/// MongoDB document representing a policy exception.
|
||||
/// Collection: exceptions
|
||||
/// </summary>
|
||||
[BsonIgnoreExtraElements]
|
||||
public sealed class PolicyExceptionDocument
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique identifier.
|
||||
/// </summary>
|
||||
[BsonId]
|
||||
[BsonElement("_id")]
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Tenant identifier (normalized to lowercase).
|
||||
/// </summary>
|
||||
[BsonElement("tenantId")]
|
||||
public string TenantId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable name for the exception.
|
||||
/// </summary>
|
||||
[BsonElement("name")]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Description and justification for the exception.
|
||||
/// </summary>
|
||||
[BsonElement("description")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? Description { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Exception type: waiver, override, temporary, permanent.
|
||||
/// </summary>
|
||||
[BsonElement("exceptionType")]
|
||||
public string ExceptionType { get; set; } = "waiver";
|
||||
|
||||
/// <summary>
|
||||
/// Exception status: draft, pending_review, approved, active, expired, revoked.
|
||||
/// </summary>
|
||||
[BsonElement("status")]
|
||||
public string Status { get; set; } = "draft";
|
||||
|
||||
/// <summary>
|
||||
/// Scope of the exception (e.g., advisory IDs, PURL patterns, CVE IDs).
|
||||
/// </summary>
|
||||
[BsonElement("scope")]
|
||||
public ExceptionScopeDocument Scope { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Risk assessment and mitigation details.
|
||||
/// </summary>
|
||||
[BsonElement("riskAssessment")]
|
||||
[BsonIgnoreIfNull]
|
||||
public ExceptionRiskAssessmentDocument? RiskAssessment { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Compensating controls in place while exception is active.
|
||||
/// </summary>
|
||||
[BsonElement("compensatingControls")]
|
||||
public List<string> CompensatingControls { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Tags for categorization and filtering.
|
||||
/// </summary>
|
||||
[BsonElement("tags")]
|
||||
public List<string> Tags { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Priority for conflict resolution (higher = more precedence).
|
||||
/// </summary>
|
||||
[BsonElement("priority")]
|
||||
public int Priority { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When the exception becomes active (null = immediately upon approval).
|
||||
/// </summary>
|
||||
[BsonElement("effectiveFrom")]
|
||||
[BsonIgnoreIfNull]
|
||||
public DateTimeOffset? EffectiveFrom { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When the exception expires (null = no expiration).
|
||||
/// </summary>
|
||||
[BsonElement("expiresAt")]
|
||||
[BsonIgnoreIfNull]
|
||||
public DateTimeOffset? ExpiresAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// User who created the exception.
|
||||
/// </summary>
|
||||
[BsonElement("createdBy")]
|
||||
public string CreatedBy { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Creation timestamp.
|
||||
/// </summary>
|
||||
[BsonElement("createdAt")]
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Last update timestamp.
|
||||
/// </summary>
|
||||
[BsonElement("updatedAt")]
|
||||
public DateTimeOffset UpdatedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When the exception was activated.
|
||||
/// </summary>
|
||||
[BsonElement("activatedAt")]
|
||||
[BsonIgnoreIfNull]
|
||||
public DateTimeOffset? ActivatedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When the exception was revoked.
|
||||
/// </summary>
|
||||
[BsonElement("revokedAt")]
|
||||
[BsonIgnoreIfNull]
|
||||
public DateTimeOffset? RevokedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// User who revoked the exception.
|
||||
/// </summary>
|
||||
[BsonElement("revokedBy")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? RevokedBy { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Reason for revocation.
|
||||
/// </summary>
|
||||
[BsonElement("revocationReason")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? RevocationReason { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Reference to the active review (if pending_review status).
|
||||
/// </summary>
|
||||
[BsonElement("activeReviewId")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? ActiveReviewId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Correlation ID for tracing.
|
||||
/// </summary>
|
||||
[BsonElement("correlationId")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? CorrelationId { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Embedded document for exception scope definition.
|
||||
/// </summary>
|
||||
[BsonIgnoreExtraElements]
|
||||
public sealed class ExceptionScopeDocument
|
||||
{
|
||||
/// <summary>
|
||||
/// Advisory IDs covered by this exception.
|
||||
/// </summary>
|
||||
[BsonElement("advisoryIds")]
|
||||
public List<string> AdvisoryIds { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// CVE IDs covered by this exception.
|
||||
/// </summary>
|
||||
[BsonElement("cveIds")]
|
||||
public List<string> CveIds { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// PURL patterns (supports wildcards) covered by this exception.
|
||||
/// </summary>
|
||||
[BsonElement("purlPatterns")]
|
||||
public List<string> PurlPatterns { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Specific asset IDs covered.
|
||||
/// </summary>
|
||||
[BsonElement("assetIds")]
|
||||
public List<string> AssetIds { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Repository IDs covered (scope limiter).
|
||||
/// </summary>
|
||||
[BsonElement("repositoryIds")]
|
||||
public List<string> RepositoryIds { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot IDs covered (scope limiter).
|
||||
/// </summary>
|
||||
[BsonElement("snapshotIds")]
|
||||
public List<string> SnapshotIds { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Severity levels to apply exception to.
|
||||
/// </summary>
|
||||
[BsonElement("severities")]
|
||||
public List<string> Severities { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Whether this exception applies to all assets (tenant-wide).
|
||||
/// </summary>
|
||||
[BsonElement("applyToAll")]
|
||||
public bool ApplyToAll { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Embedded document for risk assessment.
|
||||
/// </summary>
|
||||
[BsonIgnoreExtraElements]
|
||||
public sealed class ExceptionRiskAssessmentDocument
|
||||
{
|
||||
/// <summary>
|
||||
/// Original risk level being excepted.
|
||||
/// </summary>
|
||||
[BsonElement("originalRiskLevel")]
|
||||
public string OriginalRiskLevel { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Residual risk level after compensating controls.
|
||||
/// </summary>
|
||||
[BsonElement("residualRiskLevel")]
|
||||
public string ResidualRiskLevel { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Business justification for accepting the risk.
|
||||
/// </summary>
|
||||
[BsonElement("businessJustification")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? BusinessJustification { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Impact assessment if vulnerability is exploited.
|
||||
/// </summary>
|
||||
[BsonElement("impactAssessment")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? ImpactAssessment { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Exploitability assessment.
|
||||
/// </summary>
|
||||
[BsonElement("exploitability")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? Exploitability { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// MongoDB document representing an exception review.
|
||||
/// Collection: exception_reviews
|
||||
/// </summary>
|
||||
[BsonIgnoreExtraElements]
|
||||
public sealed class ExceptionReviewDocument
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique identifier.
|
||||
/// </summary>
|
||||
[BsonId]
|
||||
[BsonElement("_id")]
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Tenant identifier.
|
||||
/// </summary>
|
||||
[BsonElement("tenantId")]
|
||||
public string TenantId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Reference to the exception being reviewed.
|
||||
/// </summary>
|
||||
[BsonElement("exceptionId")]
|
||||
public string ExceptionId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Review status: pending, approved, rejected.
|
||||
/// </summary>
|
||||
[BsonElement("status")]
|
||||
public string Status { get; set; } = "pending";
|
||||
|
||||
/// <summary>
|
||||
/// Type of review: initial, renewal, modification.
|
||||
/// </summary>
|
||||
[BsonElement("reviewType")]
|
||||
public string ReviewType { get; set; } = "initial";
|
||||
|
||||
/// <summary>
|
||||
/// Whether multiple approvers are required.
|
||||
/// </summary>
|
||||
[BsonElement("requiresMultipleApprovers")]
|
||||
public bool RequiresMultipleApprovers { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Minimum number of approvals required.
|
||||
/// </summary>
|
||||
[BsonElement("requiredApprovals")]
|
||||
public int RequiredApprovals { get; set; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// Designated reviewers (user or group IDs).
|
||||
/// </summary>
|
||||
[BsonElement("designatedReviewers")]
|
||||
public List<string> DesignatedReviewers { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Individual approval/rejection decisions.
|
||||
/// </summary>
|
||||
[BsonElement("decisions")]
|
||||
public List<ReviewDecisionDocument> Decisions { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// User who requested the review.
|
||||
/// </summary>
|
||||
[BsonElement("requestedBy")]
|
||||
public string RequestedBy { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// When the review was requested.
|
||||
/// </summary>
|
||||
[BsonElement("requestedAt")]
|
||||
public DateTimeOffset RequestedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When the review was completed.
|
||||
/// </summary>
|
||||
[BsonElement("completedAt")]
|
||||
[BsonIgnoreIfNull]
|
||||
public DateTimeOffset? CompletedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Review deadline.
|
||||
/// </summary>
|
||||
[BsonElement("deadline")]
|
||||
[BsonIgnoreIfNull]
|
||||
public DateTimeOffset? Deadline { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Notes or comments on the review.
|
||||
/// </summary>
|
||||
[BsonElement("notes")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? Notes { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates the composite ID for a review.
|
||||
/// </summary>
|
||||
public static string CreateId(string exceptionId, string reviewType, DateTimeOffset timestamp)
|
||||
=> $"{exceptionId}:{reviewType}:{timestamp:yyyyMMddHHmmss}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Embedded document for an individual reviewer's decision.
|
||||
/// </summary>
|
||||
[BsonIgnoreExtraElements]
|
||||
public sealed class ReviewDecisionDocument
|
||||
{
|
||||
/// <summary>
|
||||
/// Reviewer ID (user or service account).
|
||||
/// </summary>
|
||||
[BsonElement("reviewerId")]
|
||||
public string ReviewerId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Decision: approved, rejected, abstained.
|
||||
/// </summary>
|
||||
[BsonElement("decision")]
|
||||
public string Decision { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Timestamp of the decision.
|
||||
/// </summary>
|
||||
[BsonElement("decidedAt")]
|
||||
public DateTimeOffset DecidedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Comment explaining the decision.
|
||||
/// </summary>
|
||||
[BsonElement("comment")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? Comment { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Conditions attached to approval.
|
||||
/// </summary>
|
||||
[BsonElement("conditions")]
|
||||
public List<string> Conditions { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// MongoDB document representing an exception binding to specific assets.
|
||||
/// Collection: exception_bindings
|
||||
/// </summary>
|
||||
[BsonIgnoreExtraElements]
|
||||
public sealed class ExceptionBindingDocument
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique identifier: {exceptionId}:{assetId}:{advisoryId}
|
||||
/// </summary>
|
||||
[BsonId]
|
||||
[BsonElement("_id")]
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Tenant identifier.
|
||||
/// </summary>
|
||||
[BsonElement("tenantId")]
|
||||
public string TenantId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Reference to the exception.
|
||||
/// </summary>
|
||||
[BsonElement("exceptionId")]
|
||||
public string ExceptionId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Asset ID (PURL or other identifier) this binding applies to.
|
||||
/// </summary>
|
||||
[BsonElement("assetId")]
|
||||
public string AssetId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Advisory ID this binding covers.
|
||||
/// </summary>
|
||||
[BsonElement("advisoryId")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? AdvisoryId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// CVE ID this binding covers.
|
||||
/// </summary>
|
||||
[BsonElement("cveId")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? CveId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot ID where binding was created.
|
||||
/// </summary>
|
||||
[BsonElement("snapshotId")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? SnapshotId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Binding status: active, expired, revoked.
|
||||
/// </summary>
|
||||
[BsonElement("status")]
|
||||
public string Status { get; set; } = "active";
|
||||
|
||||
/// <summary>
|
||||
/// Policy decision override applied by this binding.
|
||||
/// </summary>
|
||||
[BsonElement("decisionOverride")]
|
||||
public string DecisionOverride { get; set; } = "allow";
|
||||
|
||||
/// <summary>
|
||||
/// When the binding becomes effective.
|
||||
/// </summary>
|
||||
[BsonElement("effectiveFrom")]
|
||||
public DateTimeOffset EffectiveFrom { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When the binding expires.
|
||||
/// </summary>
|
||||
[BsonElement("expiresAt")]
|
||||
[BsonIgnoreIfNull]
|
||||
public DateTimeOffset? ExpiresAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When the binding was created.
|
||||
/// </summary>
|
||||
[BsonElement("createdAt")]
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates the composite ID for a binding.
|
||||
/// </summary>
|
||||
public static string CreateId(string exceptionId, string assetId, string? advisoryId)
|
||||
=> $"{exceptionId}:{assetId}:{advisoryId ?? "all"}";
|
||||
}
|
||||
@@ -1,383 +0,0 @@
|
||||
using System.Collections.Immutable;
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Bson.Serialization.Attributes;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Storage.Mongo.Documents;
|
||||
|
||||
/// <summary>
|
||||
/// MongoDB document for storing policy explain traces.
|
||||
/// Collection: policy_explains
|
||||
/// </summary>
|
||||
[BsonIgnoreExtraElements]
|
||||
public sealed class PolicyExplainDocument
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique identifier (combination of runId and subjectHash).
|
||||
/// </summary>
|
||||
[BsonId]
|
||||
[BsonElement("_id")]
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Tenant identifier.
|
||||
/// </summary>
|
||||
[BsonElement("tenantId")]
|
||||
public string TenantId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Policy run identifier.
|
||||
/// </summary>
|
||||
[BsonElement("runId")]
|
||||
public string RunId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Policy pack identifier.
|
||||
/// </summary>
|
||||
[BsonElement("policyId")]
|
||||
public string PolicyId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Policy version at time of evaluation.
|
||||
/// </summary>
|
||||
[BsonElement("policyVersion")]
|
||||
[BsonIgnoreIfNull]
|
||||
public int? PolicyVersion { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Hash of the evaluation subject (component + advisory).
|
||||
/// </summary>
|
||||
[BsonElement("subjectHash")]
|
||||
public string SubjectHash { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Hash of the policy bundle used.
|
||||
/// </summary>
|
||||
[BsonElement("bundleDigest")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? BundleDigest { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Evaluation timestamp (deterministic).
|
||||
/// </summary>
|
||||
[BsonElement("evaluatedAt")]
|
||||
public DateTimeOffset EvaluatedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Evaluation duration in milliseconds.
|
||||
/// </summary>
|
||||
[BsonElement("durationMs")]
|
||||
public long DurationMs { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Final outcome of the evaluation.
|
||||
/// </summary>
|
||||
[BsonElement("finalOutcome")]
|
||||
public string FinalOutcome { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Input context information.
|
||||
/// </summary>
|
||||
[BsonElement("inputContext")]
|
||||
public ExplainInputContextDocument InputContext { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Rule evaluation steps.
|
||||
/// </summary>
|
||||
[BsonElement("ruleSteps")]
|
||||
public List<ExplainRuleStepDocument> RuleSteps { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// VEX evidence applied.
|
||||
/// </summary>
|
||||
[BsonElement("vexEvidence")]
|
||||
public List<ExplainVexEvidenceDocument> VexEvidence { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Statistics summary.
|
||||
/// </summary>
|
||||
[BsonElement("statistics")]
|
||||
public ExplainStatisticsDocument Statistics { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Determinism hash for reproducibility verification.
|
||||
/// </summary>
|
||||
[BsonElement("determinismHash")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? DeterminismHash { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Reference to AOC chain for this evaluation.
|
||||
/// </summary>
|
||||
[BsonElement("aocChain")]
|
||||
[BsonIgnoreIfNull]
|
||||
public ExplainAocChainDocument? AocChain { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional metadata.
|
||||
/// </summary>
|
||||
[BsonElement("metadata")]
|
||||
public Dictionary<string, string> Metadata { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Creation timestamp.
|
||||
/// </summary>
|
||||
[BsonElement("createdAt")]
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// TTL expiration timestamp for automatic cleanup.
|
||||
/// </summary>
|
||||
[BsonElement("expiresAt")]
|
||||
[BsonIgnoreIfNull]
|
||||
public DateTimeOffset? ExpiresAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates the composite ID for an explain trace.
|
||||
/// </summary>
|
||||
public static string CreateId(string runId, string subjectHash) => $"{runId}:{subjectHash}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Input context embedded document.
|
||||
/// </summary>
|
||||
[BsonIgnoreExtraElements]
|
||||
public sealed class ExplainInputContextDocument
|
||||
{
|
||||
[BsonElement("componentPurl")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? ComponentPurl { get; set; }
|
||||
|
||||
[BsonElement("componentName")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? ComponentName { get; set; }
|
||||
|
||||
[BsonElement("componentVersion")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? ComponentVersion { get; set; }
|
||||
|
||||
[BsonElement("advisoryId")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? AdvisoryId { get; set; }
|
||||
|
||||
[BsonElement("vulnerabilityId")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? VulnerabilityId { get; set; }
|
||||
|
||||
[BsonElement("inputSeverity")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? InputSeverity { get; set; }
|
||||
|
||||
[BsonElement("inputCvssScore")]
|
||||
[BsonIgnoreIfNull]
|
||||
public decimal? InputCvssScore { get; set; }
|
||||
|
||||
[BsonElement("environment")]
|
||||
public Dictionary<string, string> Environment { get; set; } = new();
|
||||
|
||||
[BsonElement("sbomTags")]
|
||||
public List<string> SbomTags { get; set; } = [];
|
||||
|
||||
[BsonElement("reachabilityState")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? ReachabilityState { get; set; }
|
||||
|
||||
[BsonElement("reachabilityConfidence")]
|
||||
[BsonIgnoreIfNull]
|
||||
public double? ReachabilityConfidence { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rule step embedded document.
|
||||
/// </summary>
|
||||
[BsonIgnoreExtraElements]
|
||||
public sealed class ExplainRuleStepDocument
|
||||
{
|
||||
[BsonElement("stepNumber")]
|
||||
public int StepNumber { get; set; }
|
||||
|
||||
[BsonElement("ruleName")]
|
||||
public string RuleName { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("rulePriority")]
|
||||
public int RulePriority { get; set; }
|
||||
|
||||
[BsonElement("ruleCategory")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? RuleCategory { get; set; }
|
||||
|
||||
[BsonElement("expression")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? Expression { get; set; }
|
||||
|
||||
[BsonElement("matched")]
|
||||
public bool Matched { get; set; }
|
||||
|
||||
[BsonElement("outcome")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? Outcome { get; set; }
|
||||
|
||||
[BsonElement("assignedSeverity")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? AssignedSeverity { get; set; }
|
||||
|
||||
[BsonElement("isFinalMatch")]
|
||||
public bool IsFinalMatch { get; set; }
|
||||
|
||||
[BsonElement("explanation")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? Explanation { get; set; }
|
||||
|
||||
[BsonElement("evaluationMicroseconds")]
|
||||
public long EvaluationMicroseconds { get; set; }
|
||||
|
||||
[BsonElement("intermediateValues")]
|
||||
public Dictionary<string, string> IntermediateValues { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// VEX evidence embedded document.
|
||||
/// </summary>
|
||||
[BsonIgnoreExtraElements]
|
||||
public sealed class ExplainVexEvidenceDocument
|
||||
{
|
||||
[BsonElement("vendor")]
|
||||
public string Vendor { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("status")]
|
||||
public string Status { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("justification")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? Justification { get; set; }
|
||||
|
||||
[BsonElement("confidence")]
|
||||
[BsonIgnoreIfNull]
|
||||
public double? Confidence { get; set; }
|
||||
|
||||
[BsonElement("wasApplied")]
|
||||
public bool WasApplied { get; set; }
|
||||
|
||||
[BsonElement("explanation")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? Explanation { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Statistics embedded document.
|
||||
/// </summary>
|
||||
[BsonIgnoreExtraElements]
|
||||
public sealed class ExplainStatisticsDocument
|
||||
{
|
||||
[BsonElement("totalRulesEvaluated")]
|
||||
public int TotalRulesEvaluated { get; set; }
|
||||
|
||||
[BsonElement("totalRulesFired")]
|
||||
public int TotalRulesFired { get; set; }
|
||||
|
||||
[BsonElement("totalVexOverrides")]
|
||||
public int TotalVexOverrides { get; set; }
|
||||
|
||||
[BsonElement("totalEvaluationMs")]
|
||||
public long TotalEvaluationMs { get; set; }
|
||||
|
||||
[BsonElement("averageRuleEvaluationMicroseconds")]
|
||||
public double AverageRuleEvaluationMicroseconds { get; set; }
|
||||
|
||||
[BsonElement("rulesFiredByCategory")]
|
||||
public Dictionary<string, int> RulesFiredByCategory { get; set; } = new();
|
||||
|
||||
[BsonElement("rulesFiredByOutcome")]
|
||||
public Dictionary<string, int> RulesFiredByOutcome { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// AOC chain reference for linking decisions to attestations.
|
||||
/// </summary>
|
||||
[BsonIgnoreExtraElements]
|
||||
public sealed class ExplainAocChainDocument
|
||||
{
|
||||
/// <summary>
|
||||
/// Compilation ID that produced the policy bundle.
|
||||
/// </summary>
|
||||
[BsonElement("compilationId")]
|
||||
public string CompilationId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Compiler version used.
|
||||
/// </summary>
|
||||
[BsonElement("compilerVersion")]
|
||||
public string CompilerVersion { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Source digest of the policy document.
|
||||
/// </summary>
|
||||
[BsonElement("sourceDigest")]
|
||||
public string SourceDigest { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Artifact digest of the compiled bundle.
|
||||
/// </summary>
|
||||
[BsonElement("artifactDigest")]
|
||||
public string ArtifactDigest { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Reference to the signed attestation.
|
||||
/// </summary>
|
||||
[BsonElement("attestationRef")]
|
||||
[BsonIgnoreIfNull]
|
||||
public ExplainAttestationRefDocument? AttestationRef { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Provenance information.
|
||||
/// </summary>
|
||||
[BsonElement("provenance")]
|
||||
[BsonIgnoreIfNull]
|
||||
public ExplainProvenanceDocument? Provenance { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attestation reference embedded document.
|
||||
/// </summary>
|
||||
[BsonIgnoreExtraElements]
|
||||
public sealed class ExplainAttestationRefDocument
|
||||
{
|
||||
[BsonElement("attestationId")]
|
||||
public string AttestationId { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("envelopeDigest")]
|
||||
public string EnvelopeDigest { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("uri")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? Uri { get; set; }
|
||||
|
||||
[BsonElement("signingKeyId")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? SigningKeyId { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Provenance embedded document.
|
||||
/// </summary>
|
||||
[BsonIgnoreExtraElements]
|
||||
public sealed class ExplainProvenanceDocument
|
||||
{
|
||||
[BsonElement("sourceType")]
|
||||
public string SourceType { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("sourceUrl")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? SourceUrl { get; set; }
|
||||
|
||||
[BsonElement("submitter")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? Submitter { get; set; }
|
||||
|
||||
[BsonElement("commitSha")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? CommitSha { get; set; }
|
||||
|
||||
[BsonElement("branch")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? Branch { get; set; }
|
||||
}
|
||||
@@ -1,319 +0,0 @@
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Bson.Serialization.Attributes;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Storage.Mongo.Documents;
|
||||
|
||||
/// <summary>
|
||||
/// MongoDB document representing a policy evaluation run.
|
||||
/// Collection: policy_runs
|
||||
/// </summary>
|
||||
[BsonIgnoreExtraElements]
|
||||
public sealed class PolicyRunDocument
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique run identifier.
|
||||
/// </summary>
|
||||
[BsonId]
|
||||
[BsonElement("_id")]
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Tenant identifier.
|
||||
/// </summary>
|
||||
[BsonElement("tenantId")]
|
||||
public string TenantId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Policy pack identifier.
|
||||
/// </summary>
|
||||
[BsonElement("policyId")]
|
||||
public string PolicyId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Policy version evaluated.
|
||||
/// </summary>
|
||||
[BsonElement("policyVersion")]
|
||||
public int PolicyVersion { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Run mode (full, incremental, simulation, batch).
|
||||
/// </summary>
|
||||
[BsonElement("mode")]
|
||||
public string Mode { get; set; } = "full";
|
||||
|
||||
/// <summary>
|
||||
/// Run status (pending, running, completed, failed, cancelled).
|
||||
/// </summary>
|
||||
[BsonElement("status")]
|
||||
public string Status { get; set; } = "pending";
|
||||
|
||||
/// <summary>
|
||||
/// Trigger type (scheduled, manual, event, api).
|
||||
/// </summary>
|
||||
[BsonElement("triggerType")]
|
||||
public string TriggerType { get; set; } = "manual";
|
||||
|
||||
/// <summary>
|
||||
/// Correlation ID for distributed tracing.
|
||||
/// </summary>
|
||||
[BsonElement("correlationId")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? CorrelationId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Trace ID for OpenTelemetry.
|
||||
/// </summary>
|
||||
[BsonElement("traceId")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? TraceId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Parent span ID if part of larger operation.
|
||||
/// </summary>
|
||||
[BsonElement("parentSpanId")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? ParentSpanId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// User or service that initiated the run.
|
||||
/// </summary>
|
||||
[BsonElement("initiatedBy")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? InitiatedBy { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic evaluation timestamp used for this run.
|
||||
/// </summary>
|
||||
[BsonElement("evaluationTimestamp")]
|
||||
public DateTimeOffset EvaluationTimestamp { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When the run started.
|
||||
/// </summary>
|
||||
[BsonElement("startedAt")]
|
||||
public DateTimeOffset StartedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When the run completed (null if still running).
|
||||
/// </summary>
|
||||
[BsonElement("completedAt")]
|
||||
[BsonIgnoreIfNull]
|
||||
public DateTimeOffset? CompletedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Run metrics and statistics.
|
||||
/// </summary>
|
||||
[BsonElement("metrics")]
|
||||
public PolicyRunMetricsDocument Metrics { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Input parameters for the run.
|
||||
/// </summary>
|
||||
[BsonElement("input")]
|
||||
[BsonIgnoreIfNull]
|
||||
public PolicyRunInputDocument? Input { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Run outcome summary.
|
||||
/// </summary>
|
||||
[BsonElement("outcome")]
|
||||
[BsonIgnoreIfNull]
|
||||
public PolicyRunOutcomeDocument? Outcome { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Error information if run failed.
|
||||
/// </summary>
|
||||
[BsonElement("error")]
|
||||
[BsonIgnoreIfNull]
|
||||
public PolicyRunErrorDocument? Error { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Determinism hash for reproducibility verification.
|
||||
/// </summary>
|
||||
[BsonElement("determinismHash")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? DeterminismHash { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// TTL expiration timestamp for automatic cleanup.
|
||||
/// </summary>
|
||||
[BsonElement("expiresAt")]
|
||||
[BsonIgnoreIfNull]
|
||||
public DateTimeOffset? ExpiresAt { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Embedded metrics document for policy runs.
|
||||
/// </summary>
|
||||
[BsonIgnoreExtraElements]
|
||||
public sealed class PolicyRunMetricsDocument
|
||||
{
|
||||
/// <summary>
|
||||
/// Total components evaluated.
|
||||
/// </summary>
|
||||
[BsonElement("totalComponents")]
|
||||
public int TotalComponents { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Total advisories evaluated.
|
||||
/// </summary>
|
||||
[BsonElement("totalAdvisories")]
|
||||
public int TotalAdvisories { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Total findings generated.
|
||||
/// </summary>
|
||||
[BsonElement("totalFindings")]
|
||||
public int TotalFindings { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Rules evaluated count.
|
||||
/// </summary>
|
||||
[BsonElement("rulesEvaluated")]
|
||||
public int RulesEvaluated { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Rules that matched/fired.
|
||||
/// </summary>
|
||||
[BsonElement("rulesFired")]
|
||||
public int RulesFired { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// VEX overrides applied.
|
||||
/// </summary>
|
||||
[BsonElement("vexOverridesApplied")]
|
||||
public int VexOverridesApplied { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Findings created (new).
|
||||
/// </summary>
|
||||
[BsonElement("findingsCreated")]
|
||||
public int FindingsCreated { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Findings updated (changed).
|
||||
/// </summary>
|
||||
[BsonElement("findingsUpdated")]
|
||||
public int FindingsUpdated { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Findings unchanged.
|
||||
/// </summary>
|
||||
[BsonElement("findingsUnchanged")]
|
||||
public int FindingsUnchanged { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Duration in milliseconds.
|
||||
/// </summary>
|
||||
[BsonElement("durationMs")]
|
||||
public long DurationMs { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Memory used in bytes.
|
||||
/// </summary>
|
||||
[BsonElement("memoryUsedBytes")]
|
||||
public long MemoryUsedBytes { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Embedded input parameters document.
|
||||
/// </summary>
|
||||
[BsonIgnoreExtraElements]
|
||||
public sealed class PolicyRunInputDocument
|
||||
{
|
||||
/// <summary>
|
||||
/// SBOM IDs included in evaluation.
|
||||
/// </summary>
|
||||
[BsonElement("sbomIds")]
|
||||
public List<string> SbomIds { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Product keys included in evaluation.
|
||||
/// </summary>
|
||||
[BsonElement("productKeys")]
|
||||
public List<string> ProductKeys { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Advisory IDs to evaluate (empty = all).
|
||||
/// </summary>
|
||||
[BsonElement("advisoryIds")]
|
||||
public List<string> AdvisoryIds { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Filter criteria applied.
|
||||
/// </summary>
|
||||
[BsonElement("filters")]
|
||||
[BsonIgnoreIfNull]
|
||||
public Dictionary<string, string>? Filters { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Embedded outcome summary document.
|
||||
/// </summary>
|
||||
[BsonIgnoreExtraElements]
|
||||
public sealed class PolicyRunOutcomeDocument
|
||||
{
|
||||
/// <summary>
|
||||
/// Overall outcome (pass, fail, warn).
|
||||
/// </summary>
|
||||
[BsonElement("result")]
|
||||
public string Result { get; set; } = "pass";
|
||||
|
||||
/// <summary>
|
||||
/// Findings by severity.
|
||||
/// </summary>
|
||||
[BsonElement("bySeverity")]
|
||||
public Dictionary<string, int> BySeverity { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Findings by status.
|
||||
/// </summary>
|
||||
[BsonElement("byStatus")]
|
||||
public Dictionary<string, int> ByStatus { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Blocking findings count.
|
||||
/// </summary>
|
||||
[BsonElement("blockingCount")]
|
||||
public int BlockingCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Summary message.
|
||||
/// </summary>
|
||||
[BsonElement("message")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? Message { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Embedded error document.
|
||||
/// </summary>
|
||||
[BsonIgnoreExtraElements]
|
||||
public sealed class PolicyRunErrorDocument
|
||||
{
|
||||
/// <summary>
|
||||
/// Error code.
|
||||
/// </summary>
|
||||
[BsonElement("code")]
|
||||
public string Code { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Error message.
|
||||
/// </summary>
|
||||
[BsonElement("message")]
|
||||
public string Message { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Stack trace (if available).
|
||||
/// </summary>
|
||||
[BsonElement("stackTrace")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? StackTrace { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Inner error details.
|
||||
/// </summary>
|
||||
[BsonElement("innerError")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? InnerError { get; set; }
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Policy.Engine.Storage.Mongo.Options;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Storage.Mongo.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// MongoDB context for Policy Engine storage operations.
|
||||
/// Provides configured access to the database with appropriate read/write concerns.
|
||||
/// </summary>
|
||||
internal sealed class PolicyEngineMongoContext
|
||||
{
|
||||
public PolicyEngineMongoContext(IOptions<PolicyEngineMongoOptions> options, ILogger<PolicyEngineMongoContext> logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
var value = options?.Value ?? throw new ArgumentNullException(nameof(options));
|
||||
|
||||
if (string.IsNullOrWhiteSpace(value.ConnectionString))
|
||||
{
|
||||
throw new InvalidOperationException("Policy Engine Mongo connection string is not configured.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(value.Database))
|
||||
{
|
||||
throw new InvalidOperationException("Policy Engine Mongo database name is not configured.");
|
||||
}
|
||||
|
||||
Client = new MongoClient(value.ConnectionString);
|
||||
var settings = new MongoDatabaseSettings();
|
||||
if (value.UseMajorityReadConcern)
|
||||
{
|
||||
settings.ReadConcern = ReadConcern.Majority;
|
||||
}
|
||||
|
||||
if (value.UseMajorityWriteConcern)
|
||||
{
|
||||
settings.WriteConcern = WriteConcern.WMajority;
|
||||
}
|
||||
|
||||
Database = Client.GetDatabase(value.Database, settings);
|
||||
Options = value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// MongoDB client instance.
|
||||
/// </summary>
|
||||
public MongoClient Client { get; }
|
||||
|
||||
/// <summary>
|
||||
/// MongoDB database instance with configured read/write concerns.
|
||||
/// </summary>
|
||||
public IMongoDatabase Database { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Policy Engine MongoDB options.
|
||||
/// </summary>
|
||||
public PolicyEngineMongoOptions Options { get; }
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Policy.Engine.Storage.Mongo.Migrations;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Storage.Mongo.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for Policy Engine MongoDB initialization.
|
||||
/// </summary>
|
||||
internal interface IPolicyEngineMongoInitializer
|
||||
{
|
||||
/// <summary>
|
||||
/// Ensures all migrations are applied to the database.
|
||||
/// </summary>
|
||||
Task EnsureMigrationsAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes Policy Engine MongoDB storage by applying migrations.
|
||||
/// </summary>
|
||||
internal sealed class PolicyEngineMongoInitializer : IPolicyEngineMongoInitializer
|
||||
{
|
||||
private readonly PolicyEngineMongoContext _context;
|
||||
private readonly PolicyEngineMigrationRunner _migrationRunner;
|
||||
private readonly ILogger<PolicyEngineMongoInitializer> _logger;
|
||||
|
||||
public PolicyEngineMongoInitializer(
|
||||
PolicyEngineMongoContext context,
|
||||
PolicyEngineMigrationRunner migrationRunner,
|
||||
ILogger<PolicyEngineMongoInitializer> logger)
|
||||
{
|
||||
_context = context ?? throw new ArgumentNullException(nameof(context));
|
||||
_migrationRunner = migrationRunner ?? throw new ArgumentNullException(nameof(migrationRunner));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task EnsureMigrationsAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Ensuring Policy Engine Mongo migrations are applied for database {Database}.",
|
||||
_context.Options.Database);
|
||||
await _migrationRunner.RunAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
using MongoDB.Driver;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Storage.Mongo.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// Builds tenant-scoped filters for Policy Engine MongoDB queries.
|
||||
/// Ensures all queries are properly scoped to the current tenant.
|
||||
/// </summary>
|
||||
internal static class TenantFilterBuilder
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a filter that matches documents for the specified tenant.
|
||||
/// </summary>
|
||||
/// <typeparam name="TDocument">Document type with tenantId field.</typeparam>
|
||||
/// <param name="tenantId">Tenant identifier (will be normalized to lowercase).</param>
|
||||
/// <returns>A filter definition scoped to the tenant.</returns>
|
||||
public static FilterDefinition<TDocument> ForTenant<TDocument>(string tenantId)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
var normalizedTenantId = tenantId.ToLowerInvariant();
|
||||
return Builders<TDocument>.Filter.Eq("tenantId", normalizedTenantId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Combines a tenant filter with an additional filter using AND.
|
||||
/// </summary>
|
||||
/// <typeparam name="TDocument">Document type with tenantId field.</typeparam>
|
||||
/// <param name="tenantId">Tenant identifier (will be normalized to lowercase).</param>
|
||||
/// <param name="additionalFilter">Additional filter to combine.</param>
|
||||
/// <returns>A combined filter definition.</returns>
|
||||
public static FilterDefinition<TDocument> ForTenantAnd<TDocument>(
|
||||
string tenantId,
|
||||
FilterDefinition<TDocument> additionalFilter)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentNullException.ThrowIfNull(additionalFilter);
|
||||
|
||||
var tenantFilter = ForTenant<TDocument>(tenantId);
|
||||
return Builders<TDocument>.Filter.And(tenantFilter, additionalFilter);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a filter that matches documents by ID within a tenant scope.
|
||||
/// </summary>
|
||||
/// <typeparam name="TDocument">Document type with tenantId and _id fields.</typeparam>
|
||||
/// <param name="tenantId">Tenant identifier (will be normalized to lowercase).</param>
|
||||
/// <param name="documentId">Document identifier.</param>
|
||||
/// <returns>A filter definition matching both tenant and ID.</returns>
|
||||
public static FilterDefinition<TDocument> ForTenantById<TDocument>(string tenantId, string documentId)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(documentId);
|
||||
|
||||
var tenantFilter = ForTenant<TDocument>(tenantId);
|
||||
var idFilter = Builders<TDocument>.Filter.Eq("_id", documentId);
|
||||
return Builders<TDocument>.Filter.And(tenantFilter, idFilter);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Normalizes a tenant ID to lowercase for consistent storage and queries.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Tenant identifier.</param>
|
||||
/// <returns>Normalized (lowercase) tenant identifier.</returns>
|
||||
public static string NormalizeTenantId(string tenantId)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
return tenantId.ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
@@ -1,283 +0,0 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Policy.Engine.Storage.Mongo.Internal;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Storage.Mongo.Migrations;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes effective_finding_* and effective_finding_history_* collections for a policy.
|
||||
/// Creates collections and indexes on-demand when a policy is first evaluated.
|
||||
/// </summary>
|
||||
internal interface IEffectiveFindingCollectionInitializer
|
||||
{
|
||||
/// <summary>
|
||||
/// Ensures the effective finding collection and indexes exist for a policy.
|
||||
/// </summary>
|
||||
/// <param name="policyId">The policy identifier.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
ValueTask EnsureCollectionAsync(string policyId, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
internal sealed class EffectiveFindingCollectionInitializer : IEffectiveFindingCollectionInitializer
|
||||
{
|
||||
private readonly PolicyEngineMongoContext _context;
|
||||
private readonly ILogger<EffectiveFindingCollectionInitializer> _logger;
|
||||
private readonly HashSet<string> _initializedCollections = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly SemaphoreSlim _lock = new(1, 1);
|
||||
|
||||
public EffectiveFindingCollectionInitializer(
|
||||
PolicyEngineMongoContext context,
|
||||
ILogger<EffectiveFindingCollectionInitializer> logger)
|
||||
{
|
||||
_context = context ?? throw new ArgumentNullException(nameof(context));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask EnsureCollectionAsync(string policyId, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(policyId);
|
||||
|
||||
var findingsCollectionName = _context.Options.GetEffectiveFindingsCollectionName(policyId);
|
||||
var historyCollectionName = _context.Options.GetEffectiveFindingsHistoryCollectionName(policyId);
|
||||
|
||||
// Fast path: already initialized in memory
|
||||
if (_initializedCollections.Contains(findingsCollectionName))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await _lock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
// Double-check after acquiring lock
|
||||
if (_initializedCollections.Contains(findingsCollectionName))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await EnsureEffectiveFindingCollectionAsync(findingsCollectionName, cancellationToken).ConfigureAwait(false);
|
||||
await EnsureEffectiveFindingHistoryCollectionAsync(historyCollectionName, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_initializedCollections.Add(findingsCollectionName);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task EnsureEffectiveFindingCollectionAsync(string collectionName, CancellationToken cancellationToken)
|
||||
{
|
||||
var cursor = await _context.Database
|
||||
.ListCollectionNamesAsync(cancellationToken: cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var existing = await cursor.ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!existing.Contains(collectionName, StringComparer.Ordinal))
|
||||
{
|
||||
_logger.LogInformation("Creating effective finding collection '{CollectionName}'.", collectionName);
|
||||
await _context.Database.CreateCollectionAsync(collectionName, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var collection = _context.Database.GetCollection<BsonDocument>(collectionName);
|
||||
|
||||
// Unique constraint on (tenantId, componentPurl, advisoryId)
|
||||
var tenantComponentAdvisory = new CreateIndexModel<BsonDocument>(
|
||||
Builders<BsonDocument>.IndexKeys
|
||||
.Ascending("tenantId")
|
||||
.Ascending("componentPurl")
|
||||
.Ascending("advisoryId"),
|
||||
new CreateIndexOptions<BsonDocument>
|
||||
{
|
||||
Name = "tenant_component_advisory_unique",
|
||||
Unique = true
|
||||
});
|
||||
|
||||
// Tenant + severity for filtering by risk level
|
||||
var tenantSeverity = new CreateIndexModel<BsonDocument>(
|
||||
Builders<BsonDocument>.IndexKeys
|
||||
.Ascending("tenantId")
|
||||
.Ascending("severity")
|
||||
.Descending("updatedAt"),
|
||||
new CreateIndexOptions<BsonDocument>
|
||||
{
|
||||
Name = "tenant_severity_updatedAt_desc"
|
||||
});
|
||||
|
||||
// Tenant + status for filtering by policy status
|
||||
var tenantStatus = new CreateIndexModel<BsonDocument>(
|
||||
Builders<BsonDocument>.IndexKeys
|
||||
.Ascending("tenantId")
|
||||
.Ascending("status")
|
||||
.Descending("updatedAt"),
|
||||
new CreateIndexOptions<BsonDocument>
|
||||
{
|
||||
Name = "tenant_status_updatedAt_desc"
|
||||
});
|
||||
|
||||
// Product key lookup for SBOM-based queries
|
||||
var tenantProduct = new CreateIndexModel<BsonDocument>(
|
||||
Builders<BsonDocument>.IndexKeys
|
||||
.Ascending("tenantId")
|
||||
.Ascending("productKey"),
|
||||
new CreateIndexOptions<BsonDocument>
|
||||
{
|
||||
Name = "tenant_product",
|
||||
PartialFilterExpression = Builders<BsonDocument>.Filter.Exists("productKey", true)
|
||||
});
|
||||
|
||||
// SBOM ID lookup
|
||||
var tenantSbom = new CreateIndexModel<BsonDocument>(
|
||||
Builders<BsonDocument>.IndexKeys
|
||||
.Ascending("tenantId")
|
||||
.Ascending("sbomId"),
|
||||
new CreateIndexOptions<BsonDocument>
|
||||
{
|
||||
Name = "tenant_sbom",
|
||||
PartialFilterExpression = Builders<BsonDocument>.Filter.Exists("sbomId", true)
|
||||
});
|
||||
|
||||
// Component name lookup for search
|
||||
var tenantComponentName = new CreateIndexModel<BsonDocument>(
|
||||
Builders<BsonDocument>.IndexKeys
|
||||
.Ascending("tenantId")
|
||||
.Ascending("componentName"),
|
||||
new CreateIndexOptions<BsonDocument>
|
||||
{
|
||||
Name = "tenant_componentName"
|
||||
});
|
||||
|
||||
// Advisory ID lookup for cross-policy queries
|
||||
var tenantAdvisory = new CreateIndexModel<BsonDocument>(
|
||||
Builders<BsonDocument>.IndexKeys
|
||||
.Ascending("tenantId")
|
||||
.Ascending("advisoryId"),
|
||||
new CreateIndexOptions<BsonDocument>
|
||||
{
|
||||
Name = "tenant_advisory"
|
||||
});
|
||||
|
||||
// Policy run reference for traceability
|
||||
var policyRun = new CreateIndexModel<BsonDocument>(
|
||||
Builders<BsonDocument>.IndexKeys
|
||||
.Ascending("policyRunId"),
|
||||
new CreateIndexOptions<BsonDocument>
|
||||
{
|
||||
Name = "policyRun_lookup",
|
||||
PartialFilterExpression = Builders<BsonDocument>.Filter.Exists("policyRunId", true)
|
||||
});
|
||||
|
||||
// Content hash for deduplication checks
|
||||
var contentHash = new CreateIndexModel<BsonDocument>(
|
||||
Builders<BsonDocument>.IndexKeys
|
||||
.Ascending("contentHash"),
|
||||
new CreateIndexOptions<BsonDocument>
|
||||
{
|
||||
Name = "contentHash_lookup"
|
||||
});
|
||||
|
||||
await collection.Indexes.CreateManyAsync(
|
||||
new[]
|
||||
{
|
||||
tenantComponentAdvisory,
|
||||
tenantSeverity,
|
||||
tenantStatus,
|
||||
tenantProduct,
|
||||
tenantSbom,
|
||||
tenantComponentName,
|
||||
tenantAdvisory,
|
||||
policyRun,
|
||||
contentHash
|
||||
},
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation("Created indexes for effective finding collection '{CollectionName}'.", collectionName);
|
||||
}
|
||||
|
||||
private async Task EnsureEffectiveFindingHistoryCollectionAsync(string collectionName, CancellationToken cancellationToken)
|
||||
{
|
||||
var cursor = await _context.Database
|
||||
.ListCollectionNamesAsync(cancellationToken: cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var existing = await cursor.ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!existing.Contains(collectionName, StringComparer.Ordinal))
|
||||
{
|
||||
_logger.LogInformation("Creating effective finding history collection '{CollectionName}'.", collectionName);
|
||||
await _context.Database.CreateCollectionAsync(collectionName, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var collection = _context.Database.GetCollection<BsonDocument>(collectionName);
|
||||
|
||||
// Finding + version for retrieving history
|
||||
var findingVersion = new CreateIndexModel<BsonDocument>(
|
||||
Builders<BsonDocument>.IndexKeys
|
||||
.Ascending("findingId")
|
||||
.Descending("version"),
|
||||
new CreateIndexOptions<BsonDocument>
|
||||
{
|
||||
Name = "finding_version_desc"
|
||||
});
|
||||
|
||||
// Tenant + occurred for chronological history
|
||||
var tenantOccurred = new CreateIndexModel<BsonDocument>(
|
||||
Builders<BsonDocument>.IndexKeys
|
||||
.Ascending("tenantId")
|
||||
.Descending("occurredAt"),
|
||||
new CreateIndexOptions<BsonDocument>
|
||||
{
|
||||
Name = "tenant_occurredAt_desc"
|
||||
});
|
||||
|
||||
// Change type lookup for filtering history events
|
||||
var tenantChangeType = new CreateIndexModel<BsonDocument>(
|
||||
Builders<BsonDocument>.IndexKeys
|
||||
.Ascending("tenantId")
|
||||
.Ascending("changeType"),
|
||||
new CreateIndexOptions<BsonDocument>
|
||||
{
|
||||
Name = "tenant_changeType"
|
||||
});
|
||||
|
||||
// Policy run reference
|
||||
var policyRun = new CreateIndexModel<BsonDocument>(
|
||||
Builders<BsonDocument>.IndexKeys
|
||||
.Ascending("policyRunId"),
|
||||
new CreateIndexOptions<BsonDocument>
|
||||
{
|
||||
Name = "policyRun_lookup",
|
||||
PartialFilterExpression = Builders<BsonDocument>.Filter.Exists("policyRunId", true)
|
||||
});
|
||||
|
||||
var models = new List<CreateIndexModel<BsonDocument>>
|
||||
{
|
||||
findingVersion,
|
||||
tenantOccurred,
|
||||
tenantChangeType,
|
||||
policyRun
|
||||
};
|
||||
|
||||
// TTL index for automatic cleanup of old history entries
|
||||
if (_context.Options.EffectiveFindingsHistoryRetention > TimeSpan.Zero)
|
||||
{
|
||||
var ttlModel = new CreateIndexModel<BsonDocument>(
|
||||
Builders<BsonDocument>.IndexKeys.Ascending("expiresAt"),
|
||||
new CreateIndexOptions<BsonDocument>
|
||||
{
|
||||
Name = "expiresAt_ttl",
|
||||
ExpireAfter = TimeSpan.Zero
|
||||
});
|
||||
|
||||
models.Add(ttlModel);
|
||||
}
|
||||
|
||||
await collection.Indexes.CreateManyAsync(models, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation("Created indexes for effective finding history collection '{CollectionName}'.", collectionName);
|
||||
}
|
||||
}
|
||||
@@ -1,345 +0,0 @@
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Policy.Engine.Storage.Mongo.Internal;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Storage.Mongo.Migrations;
|
||||
|
||||
/// <summary>
|
||||
/// Migration to ensure all required indexes exist for exception collections.
|
||||
/// Creates indexes for efficient tenant-scoped queries and status lookups.
|
||||
/// </summary>
|
||||
internal sealed class EnsureExceptionIndexesMigration : IPolicyEngineMongoMigration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public string Id => "20251128_exception_indexes_v1";
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask ExecuteAsync(PolicyEngineMongoContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
await EnsureExceptionsIndexesAsync(context, cancellationToken).ConfigureAwait(false);
|
||||
await EnsureExceptionReviewsIndexesAsync(context, cancellationToken).ConfigureAwait(false);
|
||||
await EnsureExceptionBindingsIndexesAsync(context, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates indexes for the exceptions collection.
|
||||
/// </summary>
|
||||
private static async Task EnsureExceptionsIndexesAsync(PolicyEngineMongoContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
var collection = context.Database.GetCollection<BsonDocument>(context.Options.ExceptionsCollection);
|
||||
|
||||
// Tenant + status for finding active/pending exceptions
|
||||
var tenantStatus = new CreateIndexModel<BsonDocument>(
|
||||
Builders<BsonDocument>.IndexKeys
|
||||
.Ascending("tenantId")
|
||||
.Ascending("status"),
|
||||
new CreateIndexOptions<BsonDocument>
|
||||
{
|
||||
Name = "tenant_status"
|
||||
});
|
||||
|
||||
// Tenant + type + status for filtering
|
||||
var tenantTypeStatus = new CreateIndexModel<BsonDocument>(
|
||||
Builders<BsonDocument>.IndexKeys
|
||||
.Ascending("tenantId")
|
||||
.Ascending("exceptionType")
|
||||
.Ascending("status"),
|
||||
new CreateIndexOptions<BsonDocument>
|
||||
{
|
||||
Name = "tenant_type_status"
|
||||
});
|
||||
|
||||
// Tenant + created descending for recent exceptions
|
||||
var tenantCreated = new CreateIndexModel<BsonDocument>(
|
||||
Builders<BsonDocument>.IndexKeys
|
||||
.Ascending("tenantId")
|
||||
.Descending("createdAt"),
|
||||
new CreateIndexOptions<BsonDocument>
|
||||
{
|
||||
Name = "tenant_createdAt_desc"
|
||||
});
|
||||
|
||||
// Tenant + tags for filtering by tag
|
||||
var tenantTags = new CreateIndexModel<BsonDocument>(
|
||||
Builders<BsonDocument>.IndexKeys
|
||||
.Ascending("tenantId")
|
||||
.Ascending("tags"),
|
||||
new CreateIndexOptions<BsonDocument>
|
||||
{
|
||||
Name = "tenant_tags"
|
||||
});
|
||||
|
||||
// Tenant + expiresAt for finding expiring exceptions
|
||||
var tenantExpires = new CreateIndexModel<BsonDocument>(
|
||||
Builders<BsonDocument>.IndexKeys
|
||||
.Ascending("tenantId")
|
||||
.Ascending("status")
|
||||
.Ascending("expiresAt"),
|
||||
new CreateIndexOptions<BsonDocument>
|
||||
{
|
||||
Name = "tenant_status_expiresAt",
|
||||
PartialFilterExpression = Builders<BsonDocument>.Filter.Exists("expiresAt", true)
|
||||
});
|
||||
|
||||
// Tenant + effectiveFrom for finding pending activations
|
||||
var tenantEffectiveFrom = new CreateIndexModel<BsonDocument>(
|
||||
Builders<BsonDocument>.IndexKeys
|
||||
.Ascending("tenantId")
|
||||
.Ascending("status")
|
||||
.Ascending("effectiveFrom"),
|
||||
new CreateIndexOptions<BsonDocument>
|
||||
{
|
||||
Name = "tenant_status_effectiveFrom",
|
||||
PartialFilterExpression = Builders<BsonDocument>.Filter.Eq("status", "approved")
|
||||
});
|
||||
|
||||
// Scope advisory IDs for finding applicable exceptions
|
||||
var scopeAdvisoryIds = new CreateIndexModel<BsonDocument>(
|
||||
Builders<BsonDocument>.IndexKeys
|
||||
.Ascending("tenantId")
|
||||
.Ascending("status")
|
||||
.Ascending("scope.advisoryIds"),
|
||||
new CreateIndexOptions<BsonDocument>
|
||||
{
|
||||
Name = "tenant_status_scope_advisoryIds"
|
||||
});
|
||||
|
||||
// Scope asset IDs for finding applicable exceptions
|
||||
var scopeAssetIds = new CreateIndexModel<BsonDocument>(
|
||||
Builders<BsonDocument>.IndexKeys
|
||||
.Ascending("tenantId")
|
||||
.Ascending("status")
|
||||
.Ascending("scope.assetIds"),
|
||||
new CreateIndexOptions<BsonDocument>
|
||||
{
|
||||
Name = "tenant_status_scope_assetIds"
|
||||
});
|
||||
|
||||
// Scope CVE IDs for finding applicable exceptions
|
||||
var scopeCveIds = new CreateIndexModel<BsonDocument>(
|
||||
Builders<BsonDocument>.IndexKeys
|
||||
.Ascending("tenantId")
|
||||
.Ascending("status")
|
||||
.Ascending("scope.cveIds"),
|
||||
new CreateIndexOptions<BsonDocument>
|
||||
{
|
||||
Name = "tenant_status_scope_cveIds"
|
||||
});
|
||||
|
||||
// CreatedBy for audit queries
|
||||
var tenantCreatedBy = new CreateIndexModel<BsonDocument>(
|
||||
Builders<BsonDocument>.IndexKeys
|
||||
.Ascending("tenantId")
|
||||
.Ascending("createdBy"),
|
||||
new CreateIndexOptions<BsonDocument>
|
||||
{
|
||||
Name = "tenant_createdBy"
|
||||
});
|
||||
|
||||
// Priority for ordering applicable exceptions
|
||||
var tenantPriority = new CreateIndexModel<BsonDocument>(
|
||||
Builders<BsonDocument>.IndexKeys
|
||||
.Ascending("tenantId")
|
||||
.Ascending("status")
|
||||
.Descending("priority"),
|
||||
new CreateIndexOptions<BsonDocument>
|
||||
{
|
||||
Name = "tenant_status_priority_desc"
|
||||
});
|
||||
|
||||
// Correlation ID for tracing
|
||||
var correlationId = new CreateIndexModel<BsonDocument>(
|
||||
Builders<BsonDocument>.IndexKeys
|
||||
.Ascending("correlationId"),
|
||||
new CreateIndexOptions<BsonDocument>
|
||||
{
|
||||
Name = "correlationId_lookup",
|
||||
PartialFilterExpression = Builders<BsonDocument>.Filter.Exists("correlationId", true)
|
||||
});
|
||||
|
||||
await collection.Indexes.CreateManyAsync(
|
||||
new[]
|
||||
{
|
||||
tenantStatus,
|
||||
tenantTypeStatus,
|
||||
tenantCreated,
|
||||
tenantTags,
|
||||
tenantExpires,
|
||||
tenantEffectiveFrom,
|
||||
scopeAdvisoryIds,
|
||||
scopeAssetIds,
|
||||
scopeCveIds,
|
||||
tenantCreatedBy,
|
||||
tenantPriority,
|
||||
correlationId
|
||||
},
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates indexes for the exception_reviews collection.
|
||||
/// </summary>
|
||||
private static async Task EnsureExceptionReviewsIndexesAsync(PolicyEngineMongoContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
var collection = context.Database.GetCollection<BsonDocument>(context.Options.ExceptionReviewsCollection);
|
||||
|
||||
// Tenant + exception for finding reviews of an exception
|
||||
var tenantException = new CreateIndexModel<BsonDocument>(
|
||||
Builders<BsonDocument>.IndexKeys
|
||||
.Ascending("tenantId")
|
||||
.Ascending("exceptionId")
|
||||
.Descending("requestedAt"),
|
||||
new CreateIndexOptions<BsonDocument>
|
||||
{
|
||||
Name = "tenant_exceptionId_requestedAt_desc"
|
||||
});
|
||||
|
||||
// Tenant + status for finding pending reviews
|
||||
var tenantStatus = new CreateIndexModel<BsonDocument>(
|
||||
Builders<BsonDocument>.IndexKeys
|
||||
.Ascending("tenantId")
|
||||
.Ascending("status"),
|
||||
new CreateIndexOptions<BsonDocument>
|
||||
{
|
||||
Name = "tenant_status"
|
||||
});
|
||||
|
||||
// Tenant + designated reviewers for reviewer's queue
|
||||
var tenantReviewers = new CreateIndexModel<BsonDocument>(
|
||||
Builders<BsonDocument>.IndexKeys
|
||||
.Ascending("tenantId")
|
||||
.Ascending("status")
|
||||
.Ascending("designatedReviewers"),
|
||||
new CreateIndexOptions<BsonDocument>
|
||||
{
|
||||
Name = "tenant_status_designatedReviewers"
|
||||
});
|
||||
|
||||
// Deadline for finding overdue reviews
|
||||
var tenantDeadline = new CreateIndexModel<BsonDocument>(
|
||||
Builders<BsonDocument>.IndexKeys
|
||||
.Ascending("tenantId")
|
||||
.Ascending("status")
|
||||
.Ascending("deadline"),
|
||||
new CreateIndexOptions<BsonDocument>
|
||||
{
|
||||
Name = "tenant_status_deadline",
|
||||
PartialFilterExpression = Builders<BsonDocument>.Filter.And(
|
||||
Builders<BsonDocument>.Filter.Eq("status", "pending"),
|
||||
Builders<BsonDocument>.Filter.Exists("deadline", true))
|
||||
});
|
||||
|
||||
// RequestedBy for audit queries
|
||||
var tenantRequestedBy = new CreateIndexModel<BsonDocument>(
|
||||
Builders<BsonDocument>.IndexKeys
|
||||
.Ascending("tenantId")
|
||||
.Ascending("requestedBy"),
|
||||
new CreateIndexOptions<BsonDocument>
|
||||
{
|
||||
Name = "tenant_requestedBy"
|
||||
});
|
||||
|
||||
await collection.Indexes.CreateManyAsync(
|
||||
new[]
|
||||
{
|
||||
tenantException,
|
||||
tenantStatus,
|
||||
tenantReviewers,
|
||||
tenantDeadline,
|
||||
tenantRequestedBy
|
||||
},
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates indexes for the exception_bindings collection.
|
||||
/// </summary>
|
||||
private static async Task EnsureExceptionBindingsIndexesAsync(PolicyEngineMongoContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
var collection = context.Database.GetCollection<BsonDocument>(context.Options.ExceptionBindingsCollection);
|
||||
|
||||
// Tenant + exception for finding bindings of an exception
|
||||
var tenantException = new CreateIndexModel<BsonDocument>(
|
||||
Builders<BsonDocument>.IndexKeys
|
||||
.Ascending("tenantId")
|
||||
.Ascending("exceptionId"),
|
||||
new CreateIndexOptions<BsonDocument>
|
||||
{
|
||||
Name = "tenant_exceptionId"
|
||||
});
|
||||
|
||||
// Tenant + asset for finding bindings for an asset
|
||||
var tenantAsset = new CreateIndexModel<BsonDocument>(
|
||||
Builders<BsonDocument>.IndexKeys
|
||||
.Ascending("tenantId")
|
||||
.Ascending("assetId")
|
||||
.Ascending("status"),
|
||||
new CreateIndexOptions<BsonDocument>
|
||||
{
|
||||
Name = "tenant_assetId_status"
|
||||
});
|
||||
|
||||
// Tenant + advisory for finding bindings by advisory
|
||||
var tenantAdvisory = new CreateIndexModel<BsonDocument>(
|
||||
Builders<BsonDocument>.IndexKeys
|
||||
.Ascending("tenantId")
|
||||
.Ascending("advisoryId")
|
||||
.Ascending("status"),
|
||||
new CreateIndexOptions<BsonDocument>
|
||||
{
|
||||
Name = "tenant_advisoryId_status",
|
||||
PartialFilterExpression = Builders<BsonDocument>.Filter.Exists("advisoryId", true)
|
||||
});
|
||||
|
||||
// Tenant + CVE for finding bindings by CVE
|
||||
var tenantCve = new CreateIndexModel<BsonDocument>(
|
||||
Builders<BsonDocument>.IndexKeys
|
||||
.Ascending("tenantId")
|
||||
.Ascending("cveId")
|
||||
.Ascending("status"),
|
||||
new CreateIndexOptions<BsonDocument>
|
||||
{
|
||||
Name = "tenant_cveId_status",
|
||||
PartialFilterExpression = Builders<BsonDocument>.Filter.Exists("cveId", true)
|
||||
});
|
||||
|
||||
// Tenant + status + expiresAt for finding expired bindings
|
||||
var tenantExpires = new CreateIndexModel<BsonDocument>(
|
||||
Builders<BsonDocument>.IndexKeys
|
||||
.Ascending("tenantId")
|
||||
.Ascending("status")
|
||||
.Ascending("expiresAt"),
|
||||
new CreateIndexOptions<BsonDocument>
|
||||
{
|
||||
Name = "tenant_status_expiresAt",
|
||||
PartialFilterExpression = Builders<BsonDocument>.Filter.Exists("expiresAt", true)
|
||||
});
|
||||
|
||||
// Effective time range for finding active bindings at a point in time
|
||||
var tenantEffectiveRange = new CreateIndexModel<BsonDocument>(
|
||||
Builders<BsonDocument>.IndexKeys
|
||||
.Ascending("tenantId")
|
||||
.Ascending("assetId")
|
||||
.Ascending("status")
|
||||
.Ascending("effectiveFrom")
|
||||
.Ascending("expiresAt"),
|
||||
new CreateIndexOptions<BsonDocument>
|
||||
{
|
||||
Name = "tenant_asset_status_effectiveRange"
|
||||
});
|
||||
|
||||
await collection.Indexes.CreateManyAsync(
|
||||
new[]
|
||||
{
|
||||
tenantException,
|
||||
tenantAsset,
|
||||
tenantAdvisory,
|
||||
tenantCve,
|
||||
tenantExpires,
|
||||
tenantEffectiveRange
|
||||
},
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Policy.Engine.Storage.Mongo.Internal;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Storage.Mongo.Migrations;
|
||||
|
||||
/// <summary>
|
||||
/// Migration to ensure all required Policy Engine collections exist.
|
||||
/// Creates: policies, policy_revisions, policy_bundles, policy_runs, policy_audit, _policy_migrations
|
||||
/// Note: effective_finding_* and effective_finding_history_* collections are created dynamically per-policy.
|
||||
/// </summary>
|
||||
internal sealed class EnsurePolicyCollectionsMigration : IPolicyEngineMongoMigration
|
||||
{
|
||||
private readonly ILogger<EnsurePolicyCollectionsMigration> _logger;
|
||||
|
||||
public EnsurePolicyCollectionsMigration(ILogger<EnsurePolicyCollectionsMigration> logger)
|
||||
=> _logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Id => "20251128_policy_collections_v1";
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask ExecuteAsync(PolicyEngineMongoContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
var requiredCollections = new[]
|
||||
{
|
||||
context.Options.PoliciesCollection,
|
||||
context.Options.PolicyRevisionsCollection,
|
||||
context.Options.PolicyBundlesCollection,
|
||||
context.Options.PolicyRunsCollection,
|
||||
context.Options.AuditCollection,
|
||||
context.Options.MigrationsCollection
|
||||
};
|
||||
|
||||
var cursor = await context.Database
|
||||
.ListCollectionNamesAsync(cancellationToken: cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var existing = await cursor.ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
foreach (var collection in requiredCollections)
|
||||
{
|
||||
if (existing.Contains(collection, StringComparer.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Creating Policy Engine Mongo collection '{CollectionName}'.", collection);
|
||||
await context.Database.CreateCollectionAsync(collection, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,312 +0,0 @@
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Policy.Engine.Storage.Mongo.Internal;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Storage.Mongo.Migrations;
|
||||
|
||||
/// <summary>
|
||||
/// Migration to ensure all required indexes exist for Policy Engine collections.
|
||||
/// Creates indexes for efficient tenant-scoped queries and TTL cleanup.
|
||||
/// </summary>
|
||||
internal sealed class EnsurePolicyIndexesMigration : IPolicyEngineMongoMigration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public string Id => "20251128_policy_indexes_v1";
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask ExecuteAsync(PolicyEngineMongoContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
await EnsurePoliciesIndexesAsync(context, cancellationToken).ConfigureAwait(false);
|
||||
await EnsurePolicyRevisionsIndexesAsync(context, cancellationToken).ConfigureAwait(false);
|
||||
await EnsurePolicyBundlesIndexesAsync(context, cancellationToken).ConfigureAwait(false);
|
||||
await EnsurePolicyRunsIndexesAsync(context, cancellationToken).ConfigureAwait(false);
|
||||
await EnsureAuditIndexesAsync(context, cancellationToken).ConfigureAwait(false);
|
||||
await EnsureExplainsIndexesAsync(context, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates indexes for the policies collection.
|
||||
/// </summary>
|
||||
private static async Task EnsurePoliciesIndexesAsync(PolicyEngineMongoContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
var collection = context.Database.GetCollection<BsonDocument>(context.Options.PoliciesCollection);
|
||||
|
||||
// Tenant lookup with optional tag filtering
|
||||
var tenantTags = new CreateIndexModel<BsonDocument>(
|
||||
Builders<BsonDocument>.IndexKeys
|
||||
.Ascending("tenantId")
|
||||
.Ascending("tags"),
|
||||
new CreateIndexOptions<BsonDocument>
|
||||
{
|
||||
Name = "tenant_tags"
|
||||
});
|
||||
|
||||
// Tenant + updated for recent changes
|
||||
var tenantUpdated = new CreateIndexModel<BsonDocument>(
|
||||
Builders<BsonDocument>.IndexKeys
|
||||
.Ascending("tenantId")
|
||||
.Descending("updatedAt"),
|
||||
new CreateIndexOptions<BsonDocument>
|
||||
{
|
||||
Name = "tenant_updatedAt_desc"
|
||||
});
|
||||
|
||||
await collection.Indexes.CreateManyAsync(new[] { tenantTags, tenantUpdated }, cancellationToken: cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates indexes for the policy_revisions collection.
|
||||
/// </summary>
|
||||
private static async Task EnsurePolicyRevisionsIndexesAsync(PolicyEngineMongoContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
var collection = context.Database.GetCollection<BsonDocument>(context.Options.PolicyRevisionsCollection);
|
||||
|
||||
// Tenant + pack for finding revisions of a policy
|
||||
var tenantPack = new CreateIndexModel<BsonDocument>(
|
||||
Builders<BsonDocument>.IndexKeys
|
||||
.Ascending("tenantId")
|
||||
.Ascending("packId")
|
||||
.Descending("version"),
|
||||
new CreateIndexOptions<BsonDocument>
|
||||
{
|
||||
Name = "tenant_pack_version_desc"
|
||||
});
|
||||
|
||||
// Status lookup for finding active/draft revisions
|
||||
var tenantStatus = new CreateIndexModel<BsonDocument>(
|
||||
Builders<BsonDocument>.IndexKeys
|
||||
.Ascending("tenantId")
|
||||
.Ascending("status"),
|
||||
new CreateIndexOptions<BsonDocument>
|
||||
{
|
||||
Name = "tenant_status"
|
||||
});
|
||||
|
||||
// Bundle digest lookup for integrity verification
|
||||
var bundleDigest = new CreateIndexModel<BsonDocument>(
|
||||
Builders<BsonDocument>.IndexKeys
|
||||
.Ascending("bundleDigest"),
|
||||
new CreateIndexOptions<BsonDocument>
|
||||
{
|
||||
Name = "bundleDigest_lookup",
|
||||
PartialFilterExpression = Builders<BsonDocument>.Filter.Exists("bundleDigest", true)
|
||||
});
|
||||
|
||||
await collection.Indexes.CreateManyAsync(new[] { tenantPack, tenantStatus, bundleDigest }, cancellationToken: cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates indexes for the policy_bundles collection.
|
||||
/// </summary>
|
||||
private static async Task EnsurePolicyBundlesIndexesAsync(PolicyEngineMongoContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
var collection = context.Database.GetCollection<BsonDocument>(context.Options.PolicyBundlesCollection);
|
||||
|
||||
// Tenant + pack + version for finding specific bundles
|
||||
var tenantPackVersion = new CreateIndexModel<BsonDocument>(
|
||||
Builders<BsonDocument>.IndexKeys
|
||||
.Ascending("tenantId")
|
||||
.Ascending("packId")
|
||||
.Ascending("version"),
|
||||
new CreateIndexOptions<BsonDocument>
|
||||
{
|
||||
Name = "tenant_pack_version",
|
||||
Unique = true
|
||||
});
|
||||
|
||||
await collection.Indexes.CreateManyAsync(new[] { tenantPackVersion }, cancellationToken: cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates indexes for the policy_runs collection.
|
||||
/// </summary>
|
||||
private static async Task EnsurePolicyRunsIndexesAsync(PolicyEngineMongoContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
var collection = context.Database.GetCollection<BsonDocument>(context.Options.PolicyRunsCollection);
|
||||
|
||||
// Tenant + policy + started for recent runs
|
||||
var tenantPolicyStarted = new CreateIndexModel<BsonDocument>(
|
||||
Builders<BsonDocument>.IndexKeys
|
||||
.Ascending("tenantId")
|
||||
.Ascending("policyId")
|
||||
.Descending("startedAt"),
|
||||
new CreateIndexOptions<BsonDocument>
|
||||
{
|
||||
Name = "tenant_policy_startedAt_desc"
|
||||
});
|
||||
|
||||
// Status lookup for finding pending/running evaluations
|
||||
var tenantStatus = new CreateIndexModel<BsonDocument>(
|
||||
Builders<BsonDocument>.IndexKeys
|
||||
.Ascending("tenantId")
|
||||
.Ascending("status"),
|
||||
new CreateIndexOptions<BsonDocument>
|
||||
{
|
||||
Name = "tenant_status"
|
||||
});
|
||||
|
||||
// Correlation ID lookup for tracing
|
||||
var correlationId = new CreateIndexModel<BsonDocument>(
|
||||
Builders<BsonDocument>.IndexKeys
|
||||
.Ascending("correlationId"),
|
||||
new CreateIndexOptions<BsonDocument>
|
||||
{
|
||||
Name = "correlationId_lookup",
|
||||
PartialFilterExpression = Builders<BsonDocument>.Filter.Exists("correlationId", true)
|
||||
});
|
||||
|
||||
// Trace ID lookup for OpenTelemetry
|
||||
var traceId = new CreateIndexModel<BsonDocument>(
|
||||
Builders<BsonDocument>.IndexKeys
|
||||
.Ascending("traceId"),
|
||||
new CreateIndexOptions<BsonDocument>
|
||||
{
|
||||
Name = "traceId_lookup",
|
||||
PartialFilterExpression = Builders<BsonDocument>.Filter.Exists("traceId", true)
|
||||
});
|
||||
|
||||
var models = new List<CreateIndexModel<BsonDocument>>
|
||||
{
|
||||
tenantPolicyStarted,
|
||||
tenantStatus,
|
||||
correlationId,
|
||||
traceId
|
||||
};
|
||||
|
||||
// TTL index for automatic cleanup of completed runs
|
||||
if (context.Options.PolicyRunRetention > TimeSpan.Zero)
|
||||
{
|
||||
var ttlModel = new CreateIndexModel<BsonDocument>(
|
||||
Builders<BsonDocument>.IndexKeys.Ascending("expiresAt"),
|
||||
new CreateIndexOptions<BsonDocument>
|
||||
{
|
||||
Name = "expiresAt_ttl",
|
||||
ExpireAfter = TimeSpan.Zero
|
||||
});
|
||||
|
||||
models.Add(ttlModel);
|
||||
}
|
||||
|
||||
await collection.Indexes.CreateManyAsync(models, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates indexes for the policy_audit collection.
|
||||
/// </summary>
|
||||
private static async Task EnsureAuditIndexesAsync(PolicyEngineMongoContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
var collection = context.Database.GetCollection<BsonDocument>(context.Options.AuditCollection);
|
||||
|
||||
// Tenant + occurred for chronological audit trail
|
||||
var tenantOccurred = new CreateIndexModel<BsonDocument>(
|
||||
Builders<BsonDocument>.IndexKeys
|
||||
.Ascending("tenantId")
|
||||
.Descending("occurredAt"),
|
||||
new CreateIndexOptions<BsonDocument>
|
||||
{
|
||||
Name = "tenant_occurredAt_desc"
|
||||
});
|
||||
|
||||
// Actor lookup for finding actions by user
|
||||
var tenantActor = new CreateIndexModel<BsonDocument>(
|
||||
Builders<BsonDocument>.IndexKeys
|
||||
.Ascending("tenantId")
|
||||
.Ascending("actorId"),
|
||||
new CreateIndexOptions<BsonDocument>
|
||||
{
|
||||
Name = "tenant_actor"
|
||||
});
|
||||
|
||||
// Resource lookup for finding actions on specific policy
|
||||
var tenantResource = new CreateIndexModel<BsonDocument>(
|
||||
Builders<BsonDocument>.IndexKeys
|
||||
.Ascending("tenantId")
|
||||
.Ascending("resourceType")
|
||||
.Ascending("resourceId"),
|
||||
new CreateIndexOptions<BsonDocument>
|
||||
{
|
||||
Name = "tenant_resource"
|
||||
});
|
||||
|
||||
await collection.Indexes.CreateManyAsync(new[] { tenantOccurred, tenantActor, tenantResource }, cancellationToken: cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates indexes for the policy_explains collection.
|
||||
/// </summary>
|
||||
private static async Task EnsureExplainsIndexesAsync(PolicyEngineMongoContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
var collection = context.Database.GetCollection<BsonDocument>(context.Options.PolicyExplainsCollection);
|
||||
|
||||
// Tenant + run for finding all explains in a run
|
||||
var tenantRun = new CreateIndexModel<BsonDocument>(
|
||||
Builders<BsonDocument>.IndexKeys
|
||||
.Ascending("tenantId")
|
||||
.Ascending("runId"),
|
||||
new CreateIndexOptions<BsonDocument>
|
||||
{
|
||||
Name = "tenant_runId"
|
||||
});
|
||||
|
||||
// Tenant + policy + evaluated time for recent explains
|
||||
var tenantPolicyEvaluated = new CreateIndexModel<BsonDocument>(
|
||||
Builders<BsonDocument>.IndexKeys
|
||||
.Ascending("tenantId")
|
||||
.Ascending("policyId")
|
||||
.Descending("evaluatedAt"),
|
||||
new CreateIndexOptions<BsonDocument>
|
||||
{
|
||||
Name = "tenant_policy_evaluatedAt_desc"
|
||||
});
|
||||
|
||||
// Subject hash lookup for decision linkage
|
||||
var subjectHash = new CreateIndexModel<BsonDocument>(
|
||||
Builders<BsonDocument>.IndexKeys
|
||||
.Ascending("tenantId")
|
||||
.Ascending("subjectHash"),
|
||||
new CreateIndexOptions<BsonDocument>
|
||||
{
|
||||
Name = "tenant_subjectHash"
|
||||
});
|
||||
|
||||
// AOC chain lookup for attestation queries
|
||||
var aocCompilation = new CreateIndexModel<BsonDocument>(
|
||||
Builders<BsonDocument>.IndexKeys
|
||||
.Ascending("aocChain.compilationId"),
|
||||
new CreateIndexOptions<BsonDocument>
|
||||
{
|
||||
Name = "aocChain_compilationId",
|
||||
PartialFilterExpression = Builders<BsonDocument>.Filter.Exists("aocChain.compilationId", true)
|
||||
});
|
||||
|
||||
var models = new List<CreateIndexModel<BsonDocument>>
|
||||
{
|
||||
tenantRun,
|
||||
tenantPolicyEvaluated,
|
||||
subjectHash,
|
||||
aocCompilation
|
||||
};
|
||||
|
||||
// TTL index for automatic cleanup
|
||||
if (context.Options.ExplainTraceRetention > TimeSpan.Zero)
|
||||
{
|
||||
var ttlModel = new CreateIndexModel<BsonDocument>(
|
||||
Builders<BsonDocument>.IndexKeys.Ascending("expiresAt"),
|
||||
new CreateIndexOptions<BsonDocument>
|
||||
{
|
||||
Name = "expiresAt_ttl",
|
||||
ExpireAfter = TimeSpan.Zero
|
||||
});
|
||||
|
||||
models.Add(ttlModel);
|
||||
}
|
||||
|
||||
await collection.Indexes.CreateManyAsync(models, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
using StellaOps.Policy.Engine.Storage.Mongo.Internal;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Storage.Mongo.Migrations;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for Policy Engine MongoDB migrations.
|
||||
/// Migrations are applied in lexical order by Id and tracked to ensure idempotency.
|
||||
/// </summary>
|
||||
internal interface IPolicyEngineMongoMigration
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique migration identifier.
|
||||
/// Format: YYYYMMDD_description_vN (e.g., "20251128_policy_collections_v1")
|
||||
/// </summary>
|
||||
string Id { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Executes the migration against the Policy Engine database.
|
||||
/// </summary>
|
||||
/// <param name="context">MongoDB context with database access.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
ValueTask ExecuteAsync(PolicyEngineMongoContext context, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Bson.Serialization.Attributes;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Storage.Mongo.Migrations;
|
||||
|
||||
/// <summary>
|
||||
/// MongoDB document for tracking applied migrations.
|
||||
/// Collection: _policy_migrations
|
||||
/// </summary>
|
||||
[BsonIgnoreExtraElements]
|
||||
internal sealed class PolicyEngineMigrationRecord
|
||||
{
|
||||
/// <summary>
|
||||
/// MongoDB ObjectId.
|
||||
/// </summary>
|
||||
[BsonId]
|
||||
public ObjectId Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Unique migration identifier (matches IPolicyEngineMongoMigration.Id).
|
||||
/// </summary>
|
||||
[BsonElement("migrationId")]
|
||||
public string MigrationId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// When the migration was applied.
|
||||
/// </summary>
|
||||
[BsonElement("appliedAt")]
|
||||
public DateTimeOffset AppliedAt { get; set; }
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Policy.Engine.Storage.Mongo.Internal;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Storage.Mongo.Migrations;
|
||||
|
||||
/// <summary>
|
||||
/// Executes Policy Engine MongoDB migrations in order.
|
||||
/// Tracks applied migrations to ensure idempotency.
|
||||
/// </summary>
|
||||
internal sealed class PolicyEngineMigrationRunner
|
||||
{
|
||||
private readonly PolicyEngineMongoContext _context;
|
||||
private readonly IReadOnlyList<IPolicyEngineMongoMigration> _migrations;
|
||||
private readonly ILogger<PolicyEngineMigrationRunner> _logger;
|
||||
|
||||
public PolicyEngineMigrationRunner(
|
||||
PolicyEngineMongoContext context,
|
||||
IEnumerable<IPolicyEngineMongoMigration> migrations,
|
||||
ILogger<PolicyEngineMigrationRunner> logger)
|
||||
{
|
||||
_context = context ?? throw new ArgumentNullException(nameof(context));
|
||||
ArgumentNullException.ThrowIfNull(migrations);
|
||||
_migrations = migrations.OrderBy(m => m.Id, StringComparer.Ordinal).ToArray();
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs all pending migrations.
|
||||
/// </summary>
|
||||
public async ValueTask RunAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_migrations.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var collection = _context.Database.GetCollection<PolicyEngineMigrationRecord>(_context.Options.MigrationsCollection);
|
||||
await EnsureMigrationIndexAsync(collection, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var applied = await collection
|
||||
.Find(FilterDefinition<PolicyEngineMigrationRecord>.Empty)
|
||||
.Project(record => record.MigrationId)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var appliedSet = applied.ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
foreach (var migration in _migrations)
|
||||
{
|
||||
if (appliedSet.Contains(migration.Id))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Applying Policy Engine Mongo migration {MigrationId}.", migration.Id);
|
||||
await migration.ExecuteAsync(_context, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var record = new PolicyEngineMigrationRecord
|
||||
{
|
||||
Id = ObjectId.GenerateNewId(),
|
||||
MigrationId = migration.Id,
|
||||
AppliedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
await collection.InsertOneAsync(record, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogInformation("Completed Policy Engine Mongo migration {MigrationId}.", migration.Id);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task EnsureMigrationIndexAsync(
|
||||
IMongoCollection<PolicyEngineMigrationRecord> collection,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var keys = Builders<PolicyEngineMigrationRecord>.IndexKeys.Ascending(record => record.MigrationId);
|
||||
var model = new CreateIndexModel<PolicyEngineMigrationRecord>(keys, new CreateIndexOptions
|
||||
{
|
||||
Name = "migrationId_unique",
|
||||
Unique = true
|
||||
});
|
||||
|
||||
await collection.Indexes.CreateOneAsync(model, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
namespace StellaOps.Policy.Engine.Storage.Mongo.Options;
|
||||
|
||||
/// <summary>
|
||||
/// Configures MongoDB connectivity and collection names for Policy Engine storage.
|
||||
/// </summary>
|
||||
public sealed class PolicyEngineMongoOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// MongoDB connection string.
|
||||
/// </summary>
|
||||
public string ConnectionString { get; set; } = "mongodb://localhost:27017";
|
||||
|
||||
/// <summary>
|
||||
/// Database name for policy storage.
|
||||
/// </summary>
|
||||
public string Database { get; set; } = "stellaops_policy";
|
||||
|
||||
/// <summary>
|
||||
/// Collection name for policy packs.
|
||||
/// </summary>
|
||||
public string PoliciesCollection { get; set; } = "policies";
|
||||
|
||||
/// <summary>
|
||||
/// Collection name for policy revisions.
|
||||
/// </summary>
|
||||
public string PolicyRevisionsCollection { get; set; } = "policy_revisions";
|
||||
|
||||
/// <summary>
|
||||
/// Collection name for policy bundles (compiled artifacts).
|
||||
/// </summary>
|
||||
public string PolicyBundlesCollection { get; set; } = "policy_bundles";
|
||||
|
||||
/// <summary>
|
||||
/// Collection name for policy evaluation runs.
|
||||
/// </summary>
|
||||
public string PolicyRunsCollection { get; set; } = "policy_runs";
|
||||
|
||||
/// <summary>
|
||||
/// Collection prefix for effective findings (per-policy tenant-scoped).
|
||||
/// Final collection name: {prefix}_{policyId}
|
||||
/// </summary>
|
||||
public string EffectiveFindingsCollectionPrefix { get; set; } = "effective_finding";
|
||||
|
||||
/// <summary>
|
||||
/// Collection prefix for effective findings history (append-only).
|
||||
/// Final collection name: {prefix}_{policyId}
|
||||
/// </summary>
|
||||
public string EffectiveFindingsHistoryCollectionPrefix { get; set; } = "effective_finding_history";
|
||||
|
||||
/// <summary>
|
||||
/// Collection name for policy audit log.
|
||||
/// </summary>
|
||||
public string AuditCollection { get; set; } = "policy_audit";
|
||||
|
||||
/// <summary>
|
||||
/// Collection name for policy explain traces.
|
||||
/// </summary>
|
||||
public string PolicyExplainsCollection { get; set; } = "policy_explains";
|
||||
|
||||
/// <summary>
|
||||
/// Collection name for policy exceptions.
|
||||
/// </summary>
|
||||
public string ExceptionsCollection { get; set; } = "exceptions";
|
||||
|
||||
/// <summary>
|
||||
/// Collection name for exception reviews.
|
||||
/// </summary>
|
||||
public string ExceptionReviewsCollection { get; set; } = "exception_reviews";
|
||||
|
||||
/// <summary>
|
||||
/// Collection name for exception bindings.
|
||||
/// </summary>
|
||||
public string ExceptionBindingsCollection { get; set; } = "exception_bindings";
|
||||
|
||||
/// <summary>
|
||||
/// Collection name for tracking applied migrations.
|
||||
/// </summary>
|
||||
public string MigrationsCollection { get; set; } = "_policy_migrations";
|
||||
|
||||
/// <summary>
|
||||
/// TTL for completed policy runs. Zero or negative disables TTL.
|
||||
/// </summary>
|
||||
public TimeSpan PolicyRunRetention { get; set; } = TimeSpan.FromDays(90);
|
||||
|
||||
/// <summary>
|
||||
/// TTL for effective findings history entries. Zero or negative disables TTL.
|
||||
/// </summary>
|
||||
public TimeSpan EffectiveFindingsHistoryRetention { get; set; } = TimeSpan.FromDays(365);
|
||||
|
||||
/// <summary>
|
||||
/// TTL for explain traces. Zero or negative disables TTL.
|
||||
/// </summary>
|
||||
public TimeSpan ExplainTraceRetention { get; set; } = TimeSpan.FromDays(30);
|
||||
|
||||
/// <summary>
|
||||
/// Use majority read concern for consistency.
|
||||
/// </summary>
|
||||
public bool UseMajorityReadConcern { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Use majority write concern for durability.
|
||||
/// </summary>
|
||||
public bool UseMajorityWriteConcern { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Command timeout in seconds.
|
||||
/// </summary>
|
||||
public int CommandTimeoutSeconds { get; set; } = 30;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the effective findings collection name for a policy.
|
||||
/// </summary>
|
||||
public string GetEffectiveFindingsCollectionName(string policyId)
|
||||
{
|
||||
var safePolicyId = SanitizeCollectionName(policyId);
|
||||
return $"{EffectiveFindingsCollectionPrefix}_{safePolicyId}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the effective findings history collection name for a policy.
|
||||
/// </summary>
|
||||
public string GetEffectiveFindingsHistoryCollectionName(string policyId)
|
||||
{
|
||||
var safePolicyId = SanitizeCollectionName(policyId);
|
||||
return $"{EffectiveFindingsHistoryCollectionPrefix}_{safePolicyId}";
|
||||
}
|
||||
|
||||
private static string SanitizeCollectionName(string name)
|
||||
{
|
||||
// Replace invalid characters with underscores
|
||||
return string.Create(name.Length, name, (span, source) =>
|
||||
{
|
||||
for (int i = 0; i < source.Length; i++)
|
||||
{
|
||||
var c = source[i];
|
||||
span[i] = char.IsLetterOrDigit(c) || c == '_' || c == '-' ? c : '_';
|
||||
}
|
||||
}).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
@@ -1,261 +0,0 @@
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Policy.Engine.Storage.Mongo.Documents;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Storage.Mongo.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Repository interface for policy exception operations.
|
||||
/// </summary>
|
||||
internal interface IExceptionRepository
|
||||
{
|
||||
// Exception operations
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new exception.
|
||||
/// </summary>
|
||||
Task<PolicyExceptionDocument> CreateExceptionAsync(
|
||||
PolicyExceptionDocument exception,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets an exception by ID.
|
||||
/// </summary>
|
||||
Task<PolicyExceptionDocument?> GetExceptionAsync(
|
||||
string tenantId,
|
||||
string exceptionId,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Updates an existing exception.
|
||||
/// </summary>
|
||||
Task<PolicyExceptionDocument?> UpdateExceptionAsync(
|
||||
PolicyExceptionDocument exception,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Lists exceptions for a tenant with filtering and pagination.
|
||||
/// </summary>
|
||||
Task<ImmutableArray<PolicyExceptionDocument>> ListExceptionsAsync(
|
||||
string tenantId,
|
||||
ExceptionQueryOptions options,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Lists exceptions across all tenants with filtering and pagination.
|
||||
/// </summary>
|
||||
Task<ImmutableArray<PolicyExceptionDocument>> ListExceptionsAsync(
|
||||
ExceptionQueryOptions options,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Finds active exceptions that apply to a specific asset/advisory.
|
||||
/// </summary>
|
||||
Task<ImmutableArray<PolicyExceptionDocument>> FindApplicableExceptionsAsync(
|
||||
string tenantId,
|
||||
string assetId,
|
||||
string? advisoryId,
|
||||
DateTimeOffset evaluationTime,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Updates exception status.
|
||||
/// </summary>
|
||||
Task<bool> UpdateExceptionStatusAsync(
|
||||
string tenantId,
|
||||
string exceptionId,
|
||||
string newStatus,
|
||||
DateTimeOffset timestamp,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Revokes an exception.
|
||||
/// </summary>
|
||||
Task<bool> RevokeExceptionAsync(
|
||||
string tenantId,
|
||||
string exceptionId,
|
||||
string revokedBy,
|
||||
string? reason,
|
||||
DateTimeOffset timestamp,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets exceptions expiring within a time window.
|
||||
/// </summary>
|
||||
Task<ImmutableArray<PolicyExceptionDocument>> GetExpiringExceptionsAsync(
|
||||
string tenantId,
|
||||
DateTimeOffset from,
|
||||
DateTimeOffset to,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets exceptions that should be auto-activated.
|
||||
/// </summary>
|
||||
Task<ImmutableArray<PolicyExceptionDocument>> GetPendingActivationsAsync(
|
||||
string tenantId,
|
||||
DateTimeOffset asOf,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
// Review operations
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new review for an exception.
|
||||
/// </summary>
|
||||
Task<ExceptionReviewDocument> CreateReviewAsync(
|
||||
ExceptionReviewDocument review,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a review by ID.
|
||||
/// </summary>
|
||||
Task<ExceptionReviewDocument?> GetReviewAsync(
|
||||
string tenantId,
|
||||
string reviewId,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Adds a decision to a review.
|
||||
/// </summary>
|
||||
Task<ExceptionReviewDocument?> AddReviewDecisionAsync(
|
||||
string tenantId,
|
||||
string reviewId,
|
||||
ReviewDecisionDocument decision,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Completes a review with final status.
|
||||
/// </summary>
|
||||
Task<ExceptionReviewDocument?> CompleteReviewAsync(
|
||||
string tenantId,
|
||||
string reviewId,
|
||||
string finalStatus,
|
||||
DateTimeOffset completedAt,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets reviews for an exception.
|
||||
/// </summary>
|
||||
Task<ImmutableArray<ExceptionReviewDocument>> GetReviewsForExceptionAsync(
|
||||
string tenantId,
|
||||
string exceptionId,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets pending reviews for a reviewer.
|
||||
/// </summary>
|
||||
Task<ImmutableArray<ExceptionReviewDocument>> GetPendingReviewsAsync(
|
||||
string tenantId,
|
||||
string? reviewerId,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
// Binding operations
|
||||
|
||||
/// <summary>
|
||||
/// Creates or updates a binding.
|
||||
/// </summary>
|
||||
Task<ExceptionBindingDocument> UpsertBindingAsync(
|
||||
ExceptionBindingDocument binding,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets bindings for an exception.
|
||||
/// </summary>
|
||||
Task<ImmutableArray<ExceptionBindingDocument>> GetBindingsForExceptionAsync(
|
||||
string tenantId,
|
||||
string exceptionId,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets active bindings for an asset.
|
||||
/// </summary>
|
||||
Task<ImmutableArray<ExceptionBindingDocument>> GetActiveBindingsForAssetAsync(
|
||||
string tenantId,
|
||||
string assetId,
|
||||
DateTimeOffset asOf,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes bindings for an exception.
|
||||
/// </summary>
|
||||
Task<long> DeleteBindingsForExceptionAsync(
|
||||
string tenantId,
|
||||
string exceptionId,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Updates binding status.
|
||||
/// </summary>
|
||||
Task<bool> UpdateBindingStatusAsync(
|
||||
string tenantId,
|
||||
string bindingId,
|
||||
string newStatus,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets expired bindings for cleanup.
|
||||
/// </summary>
|
||||
Task<ImmutableArray<ExceptionBindingDocument>> GetExpiredBindingsAsync(
|
||||
string tenantId,
|
||||
DateTimeOffset asOf,
|
||||
int limit,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
// Statistics
|
||||
|
||||
/// <summary>
|
||||
/// Gets exception counts by status.
|
||||
/// </summary>
|
||||
Task<IReadOnlyDictionary<string, int>> GetExceptionCountsByStatusAsync(
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Query options for listing exceptions.
|
||||
/// </summary>
|
||||
public sealed record ExceptionQueryOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Filter by status.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> Statuses { get; init; } = ImmutableArray<string>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Filter by exception type.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> Types { get; init; } = ImmutableArray<string>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Filter by tag.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> Tags { get; init; } = ImmutableArray<string>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Filter by creator.
|
||||
/// </summary>
|
||||
public string? CreatedBy { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Include expired exceptions.
|
||||
/// </summary>
|
||||
public bool IncludeExpired { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Skip count for pagination.
|
||||
/// </summary>
|
||||
public int Skip { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Limit for pagination (default 100).
|
||||
/// </summary>
|
||||
public int Limit { get; init; } = 100;
|
||||
|
||||
/// <summary>
|
||||
/// Sort field.
|
||||
/// </summary>
|
||||
public string SortBy { get; init; } = "createdAt";
|
||||
|
||||
/// <summary>
|
||||
/// Sort direction (asc or desc).
|
||||
/// </summary>
|
||||
public string SortDirection { get; init; } = "desc";
|
||||
}
|
||||
@@ -1,647 +0,0 @@
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Policy.Engine.Storage.Mongo.Documents;
|
||||
using StellaOps.Policy.Engine.Storage.Mongo.Options;
|
||||
using StellaOps.Policy.Engine.Telemetry;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Storage.Mongo.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// MongoDB implementation of the exception repository.
|
||||
/// </summary>
|
||||
internal sealed class MongoExceptionRepository : IExceptionRepository
|
||||
{
|
||||
private readonly IMongoDatabase _database;
|
||||
private readonly PolicyEngineMongoOptions _options;
|
||||
private readonly ILogger<MongoExceptionRepository> _logger;
|
||||
|
||||
public MongoExceptionRepository(
|
||||
IMongoClient mongoClient,
|
||||
IOptions<PolicyEngineMongoOptions> options,
|
||||
ILogger<MongoExceptionRepository> logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(mongoClient);
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
_options = options.Value;
|
||||
_database = mongoClient.GetDatabase(_options.Database);
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
private IMongoCollection<PolicyExceptionDocument> Exceptions
|
||||
=> _database.GetCollection<PolicyExceptionDocument>(_options.ExceptionsCollection);
|
||||
|
||||
private IMongoCollection<ExceptionReviewDocument> Reviews
|
||||
=> _database.GetCollection<ExceptionReviewDocument>(_options.ExceptionReviewsCollection);
|
||||
|
||||
private IMongoCollection<ExceptionBindingDocument> Bindings
|
||||
=> _database.GetCollection<ExceptionBindingDocument>(_options.ExceptionBindingsCollection);
|
||||
|
||||
#region Exception Operations
|
||||
|
||||
public async Task<PolicyExceptionDocument> CreateExceptionAsync(
|
||||
PolicyExceptionDocument exception,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(exception);
|
||||
|
||||
exception.TenantId = exception.TenantId.ToLowerInvariant();
|
||||
await Exceptions.InsertOneAsync(exception, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Created exception {ExceptionId} for tenant {TenantId}",
|
||||
exception.Id, exception.TenantId);
|
||||
|
||||
PolicyEngineTelemetry.RecordExceptionOperation(exception.TenantId, "create");
|
||||
|
||||
return exception;
|
||||
}
|
||||
|
||||
public async Task<PolicyExceptionDocument?> GetExceptionAsync(
|
||||
string tenantId,
|
||||
string exceptionId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var filter = Builders<PolicyExceptionDocument>.Filter.And(
|
||||
Builders<PolicyExceptionDocument>.Filter.Eq(e => e.TenantId, tenantId.ToLowerInvariant()),
|
||||
Builders<PolicyExceptionDocument>.Filter.Eq(e => e.Id, exceptionId));
|
||||
|
||||
return await Exceptions.Find(filter).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<PolicyExceptionDocument?> UpdateExceptionAsync(
|
||||
PolicyExceptionDocument exception,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(exception);
|
||||
|
||||
var filter = Builders<PolicyExceptionDocument>.Filter.And(
|
||||
Builders<PolicyExceptionDocument>.Filter.Eq(e => e.TenantId, exception.TenantId.ToLowerInvariant()),
|
||||
Builders<PolicyExceptionDocument>.Filter.Eq(e => e.Id, exception.Id));
|
||||
|
||||
var result = await Exceptions.ReplaceOneAsync(filter, exception, cancellationToken: cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (result.ModifiedCount > 0)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Updated exception {ExceptionId} for tenant {TenantId}",
|
||||
exception.Id, exception.TenantId);
|
||||
PolicyEngineTelemetry.RecordExceptionOperation(exception.TenantId, "update");
|
||||
return exception;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public async Task<ImmutableArray<PolicyExceptionDocument>> ListExceptionsAsync(
|
||||
string tenantId,
|
||||
ExceptionQueryOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var filter = BuildFilter(options, tenantId.ToLowerInvariant());
|
||||
var sort = BuildSort(options);
|
||||
|
||||
var results = await Exceptions
|
||||
.Find(filter)
|
||||
.Sort(sort)
|
||||
.Skip(options.Skip)
|
||||
.Limit(options.Limit)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return results.ToImmutableArray();
|
||||
}
|
||||
|
||||
public async Task<ImmutableArray<PolicyExceptionDocument>> ListExceptionsAsync(
|
||||
ExceptionQueryOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var filter = BuildFilter(options, tenantId: null);
|
||||
var sort = BuildSort(options);
|
||||
|
||||
var results = await Exceptions
|
||||
.Find(filter)
|
||||
.Sort(sort)
|
||||
.Skip(options.Skip)
|
||||
.Limit(options.Limit)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return results.ToImmutableArray();
|
||||
}
|
||||
|
||||
private static FilterDefinition<PolicyExceptionDocument> BuildFilter(
|
||||
ExceptionQueryOptions options,
|
||||
string? tenantId)
|
||||
{
|
||||
var filterBuilder = Builders<PolicyExceptionDocument>.Filter;
|
||||
var filters = new List<FilterDefinition<PolicyExceptionDocument>>();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
filters.Add(filterBuilder.Eq(e => e.TenantId, tenantId));
|
||||
}
|
||||
|
||||
if (options.Statuses.Length > 0)
|
||||
{
|
||||
filters.Add(filterBuilder.In(e => e.Status, options.Statuses));
|
||||
}
|
||||
|
||||
if (options.Types.Length > 0)
|
||||
{
|
||||
filters.Add(filterBuilder.In(e => e.ExceptionType, options.Types));
|
||||
}
|
||||
|
||||
if (options.Tags.Length > 0)
|
||||
{
|
||||
filters.Add(filterBuilder.AnyIn(e => e.Tags, options.Tags));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(options.CreatedBy))
|
||||
{
|
||||
filters.Add(filterBuilder.Eq(e => e.CreatedBy, options.CreatedBy));
|
||||
}
|
||||
|
||||
if (!options.IncludeExpired)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
filters.Add(filterBuilder.Or(
|
||||
filterBuilder.Eq(e => e.ExpiresAt, null),
|
||||
filterBuilder.Gt(e => e.ExpiresAt, now)));
|
||||
}
|
||||
|
||||
if (filters.Count == 0)
|
||||
{
|
||||
return FilterDefinition<PolicyExceptionDocument>.Empty;
|
||||
}
|
||||
|
||||
return filterBuilder.And(filters);
|
||||
}
|
||||
|
||||
private static SortDefinition<PolicyExceptionDocument> BuildSort(ExceptionQueryOptions options)
|
||||
{
|
||||
return options.SortDirection.Equals("asc", StringComparison.OrdinalIgnoreCase)
|
||||
? Builders<PolicyExceptionDocument>.Sort.Ascending(options.SortBy)
|
||||
: Builders<PolicyExceptionDocument>.Sort.Descending(options.SortBy);
|
||||
}
|
||||
|
||||
public async Task<ImmutableArray<PolicyExceptionDocument>> FindApplicableExceptionsAsync(
|
||||
string tenantId,
|
||||
string assetId,
|
||||
string? advisoryId,
|
||||
DateTimeOffset evaluationTime,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var filterBuilder = Builders<PolicyExceptionDocument>.Filter;
|
||||
var filters = new List<FilterDefinition<PolicyExceptionDocument>>
|
||||
{
|
||||
filterBuilder.Eq(e => e.TenantId, tenantId.ToLowerInvariant()),
|
||||
filterBuilder.Eq(e => e.Status, "active"),
|
||||
filterBuilder.Or(
|
||||
filterBuilder.Eq(e => e.EffectiveFrom, null),
|
||||
filterBuilder.Lte(e => e.EffectiveFrom, evaluationTime)),
|
||||
filterBuilder.Or(
|
||||
filterBuilder.Eq(e => e.ExpiresAt, null),
|
||||
filterBuilder.Gt(e => e.ExpiresAt, evaluationTime))
|
||||
};
|
||||
|
||||
// Scope matching - must match at least one criterion
|
||||
var scopeFilters = new List<FilterDefinition<PolicyExceptionDocument>>
|
||||
{
|
||||
filterBuilder.Eq("scope.applyToAll", true),
|
||||
filterBuilder.AnyEq("scope.assetIds", assetId)
|
||||
};
|
||||
|
||||
// Add PURL pattern matching (simplified - would need regex in production)
|
||||
scopeFilters.Add(filterBuilder.Not(filterBuilder.Size("scope.purlPatterns", 0)));
|
||||
|
||||
if (!string.IsNullOrEmpty(advisoryId))
|
||||
{
|
||||
scopeFilters.Add(filterBuilder.AnyEq("scope.advisoryIds", advisoryId));
|
||||
}
|
||||
|
||||
filters.Add(filterBuilder.Or(scopeFilters));
|
||||
|
||||
var filter = filterBuilder.And(filters);
|
||||
|
||||
var results = await Exceptions
|
||||
.Find(filter)
|
||||
.Sort(Builders<PolicyExceptionDocument>.Sort.Descending(e => e.Priority))
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return results.ToImmutableArray();
|
||||
}
|
||||
|
||||
public async Task<bool> UpdateExceptionStatusAsync(
|
||||
string tenantId,
|
||||
string exceptionId,
|
||||
string newStatus,
|
||||
DateTimeOffset timestamp,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var filter = Builders<PolicyExceptionDocument>.Filter.And(
|
||||
Builders<PolicyExceptionDocument>.Filter.Eq(e => e.TenantId, tenantId.ToLowerInvariant()),
|
||||
Builders<PolicyExceptionDocument>.Filter.Eq(e => e.Id, exceptionId));
|
||||
|
||||
var updateBuilder = Builders<PolicyExceptionDocument>.Update;
|
||||
var updates = new List<UpdateDefinition<PolicyExceptionDocument>>
|
||||
{
|
||||
updateBuilder.Set(e => e.Status, newStatus),
|
||||
updateBuilder.Set(e => e.UpdatedAt, timestamp)
|
||||
};
|
||||
|
||||
if (newStatus == "active")
|
||||
{
|
||||
updates.Add(updateBuilder.Set(e => e.ActivatedAt, timestamp));
|
||||
}
|
||||
|
||||
var update = updateBuilder.Combine(updates);
|
||||
var result = await Exceptions.UpdateOneAsync(filter, update, cancellationToken: cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (result.ModifiedCount > 0)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Updated exception {ExceptionId} status to {Status} for tenant {TenantId}",
|
||||
exceptionId, newStatus, tenantId);
|
||||
PolicyEngineTelemetry.RecordExceptionOperation(tenantId, $"status_{newStatus}");
|
||||
}
|
||||
|
||||
return result.ModifiedCount > 0;
|
||||
}
|
||||
|
||||
public async Task<bool> RevokeExceptionAsync(
|
||||
string tenantId,
|
||||
string exceptionId,
|
||||
string revokedBy,
|
||||
string? reason,
|
||||
DateTimeOffset timestamp,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var filter = Builders<PolicyExceptionDocument>.Filter.And(
|
||||
Builders<PolicyExceptionDocument>.Filter.Eq(e => e.TenantId, tenantId.ToLowerInvariant()),
|
||||
Builders<PolicyExceptionDocument>.Filter.Eq(e => e.Id, exceptionId));
|
||||
|
||||
var update = Builders<PolicyExceptionDocument>.Update
|
||||
.Set(e => e.Status, "revoked")
|
||||
.Set(e => e.RevokedAt, timestamp)
|
||||
.Set(e => e.RevokedBy, revokedBy)
|
||||
.Set(e => e.RevocationReason, reason)
|
||||
.Set(e => e.UpdatedAt, timestamp);
|
||||
|
||||
var result = await Exceptions.UpdateOneAsync(filter, update, cancellationToken: cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (result.ModifiedCount > 0)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Revoked exception {ExceptionId} by {RevokedBy} for tenant {TenantId}",
|
||||
exceptionId, revokedBy, tenantId);
|
||||
PolicyEngineTelemetry.RecordExceptionOperation(tenantId, "revoke");
|
||||
}
|
||||
|
||||
return result.ModifiedCount > 0;
|
||||
}
|
||||
|
||||
public async Task<ImmutableArray<PolicyExceptionDocument>> GetExpiringExceptionsAsync(
|
||||
string tenantId,
|
||||
DateTimeOffset from,
|
||||
DateTimeOffset to,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var filter = Builders<PolicyExceptionDocument>.Filter.And(
|
||||
Builders<PolicyExceptionDocument>.Filter.Eq(e => e.TenantId, tenantId.ToLowerInvariant()),
|
||||
Builders<PolicyExceptionDocument>.Filter.Eq(e => e.Status, "active"),
|
||||
Builders<PolicyExceptionDocument>.Filter.Gte(e => e.ExpiresAt, from),
|
||||
Builders<PolicyExceptionDocument>.Filter.Lte(e => e.ExpiresAt, to));
|
||||
|
||||
var results = await Exceptions
|
||||
.Find(filter)
|
||||
.Sort(Builders<PolicyExceptionDocument>.Sort.Ascending(e => e.ExpiresAt))
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return results.ToImmutableArray();
|
||||
}
|
||||
|
||||
public async Task<ImmutableArray<PolicyExceptionDocument>> GetPendingActivationsAsync(
|
||||
string tenantId,
|
||||
DateTimeOffset asOf,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var filter = Builders<PolicyExceptionDocument>.Filter.And(
|
||||
Builders<PolicyExceptionDocument>.Filter.Eq(e => e.TenantId, tenantId.ToLowerInvariant()),
|
||||
Builders<PolicyExceptionDocument>.Filter.Eq(e => e.Status, "approved"),
|
||||
Builders<PolicyExceptionDocument>.Filter.Lte(e => e.EffectiveFrom, asOf));
|
||||
|
||||
var results = await Exceptions
|
||||
.Find(filter)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return results.ToImmutableArray();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Review Operations
|
||||
|
||||
public async Task<ExceptionReviewDocument> CreateReviewAsync(
|
||||
ExceptionReviewDocument review,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(review);
|
||||
|
||||
review.TenantId = review.TenantId.ToLowerInvariant();
|
||||
await Reviews.InsertOneAsync(review, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Created review {ReviewId} for exception {ExceptionId}, tenant {TenantId}",
|
||||
review.Id, review.ExceptionId, review.TenantId);
|
||||
|
||||
PolicyEngineTelemetry.RecordExceptionOperation(review.TenantId, "review_create");
|
||||
|
||||
return review;
|
||||
}
|
||||
|
||||
public async Task<ExceptionReviewDocument?> GetReviewAsync(
|
||||
string tenantId,
|
||||
string reviewId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var filter = Builders<ExceptionReviewDocument>.Filter.And(
|
||||
Builders<ExceptionReviewDocument>.Filter.Eq(r => r.TenantId, tenantId.ToLowerInvariant()),
|
||||
Builders<ExceptionReviewDocument>.Filter.Eq(r => r.Id, reviewId));
|
||||
|
||||
return await Reviews.Find(filter).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<ExceptionReviewDocument?> AddReviewDecisionAsync(
|
||||
string tenantId,
|
||||
string reviewId,
|
||||
ReviewDecisionDocument decision,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var filter = Builders<ExceptionReviewDocument>.Filter.And(
|
||||
Builders<ExceptionReviewDocument>.Filter.Eq(r => r.TenantId, tenantId.ToLowerInvariant()),
|
||||
Builders<ExceptionReviewDocument>.Filter.Eq(r => r.Id, reviewId),
|
||||
Builders<ExceptionReviewDocument>.Filter.Eq(r => r.Status, "pending"));
|
||||
|
||||
var update = Builders<ExceptionReviewDocument>.Update
|
||||
.Push(r => r.Decisions, decision);
|
||||
|
||||
var options = new FindOneAndUpdateOptions<ExceptionReviewDocument>
|
||||
{
|
||||
ReturnDocument = ReturnDocument.After
|
||||
};
|
||||
|
||||
var result = await Reviews.FindOneAndUpdateAsync(filter, update, options, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (result is not null)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Added decision from {ReviewerId} to review {ReviewId} for tenant {TenantId}",
|
||||
decision.ReviewerId, reviewId, tenantId);
|
||||
PolicyEngineTelemetry.RecordExceptionOperation(tenantId, $"review_decision_{decision.Decision}");
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task<ExceptionReviewDocument?> CompleteReviewAsync(
|
||||
string tenantId,
|
||||
string reviewId,
|
||||
string finalStatus,
|
||||
DateTimeOffset completedAt,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var filter = Builders<ExceptionReviewDocument>.Filter.And(
|
||||
Builders<ExceptionReviewDocument>.Filter.Eq(r => r.TenantId, tenantId.ToLowerInvariant()),
|
||||
Builders<ExceptionReviewDocument>.Filter.Eq(r => r.Id, reviewId));
|
||||
|
||||
var update = Builders<ExceptionReviewDocument>.Update
|
||||
.Set(r => r.Status, finalStatus)
|
||||
.Set(r => r.CompletedAt, completedAt);
|
||||
|
||||
var options = new FindOneAndUpdateOptions<ExceptionReviewDocument>
|
||||
{
|
||||
ReturnDocument = ReturnDocument.After
|
||||
};
|
||||
|
||||
var result = await Reviews.FindOneAndUpdateAsync(filter, update, options, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (result is not null)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Completed review {ReviewId} with status {Status} for tenant {TenantId}",
|
||||
reviewId, finalStatus, tenantId);
|
||||
PolicyEngineTelemetry.RecordExceptionOperation(tenantId, $"review_complete_{finalStatus}");
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task<ImmutableArray<ExceptionReviewDocument>> GetReviewsForExceptionAsync(
|
||||
string tenantId,
|
||||
string exceptionId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var filter = Builders<ExceptionReviewDocument>.Filter.And(
|
||||
Builders<ExceptionReviewDocument>.Filter.Eq(r => r.TenantId, tenantId.ToLowerInvariant()),
|
||||
Builders<ExceptionReviewDocument>.Filter.Eq(r => r.ExceptionId, exceptionId));
|
||||
|
||||
var results = await Reviews
|
||||
.Find(filter)
|
||||
.Sort(Builders<ExceptionReviewDocument>.Sort.Descending(r => r.RequestedAt))
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return results.ToImmutableArray();
|
||||
}
|
||||
|
||||
public async Task<ImmutableArray<ExceptionReviewDocument>> GetPendingReviewsAsync(
|
||||
string tenantId,
|
||||
string? reviewerId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var filterBuilder = Builders<ExceptionReviewDocument>.Filter;
|
||||
var filters = new List<FilterDefinition<ExceptionReviewDocument>>
|
||||
{
|
||||
filterBuilder.Eq(r => r.TenantId, tenantId.ToLowerInvariant()),
|
||||
filterBuilder.Eq(r => r.Status, "pending")
|
||||
};
|
||||
|
||||
if (!string.IsNullOrEmpty(reviewerId))
|
||||
{
|
||||
filters.Add(filterBuilder.AnyEq(r => r.DesignatedReviewers, reviewerId));
|
||||
}
|
||||
|
||||
var filter = filterBuilder.And(filters);
|
||||
|
||||
var results = await Reviews
|
||||
.Find(filter)
|
||||
.Sort(Builders<ExceptionReviewDocument>.Sort.Ascending(r => r.Deadline))
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return results.ToImmutableArray();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Binding Operations
|
||||
|
||||
public async Task<ExceptionBindingDocument> UpsertBindingAsync(
|
||||
ExceptionBindingDocument binding,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(binding);
|
||||
|
||||
binding.TenantId = binding.TenantId.ToLowerInvariant();
|
||||
|
||||
var filter = Builders<ExceptionBindingDocument>.Filter.And(
|
||||
Builders<ExceptionBindingDocument>.Filter.Eq(b => b.TenantId, binding.TenantId),
|
||||
Builders<ExceptionBindingDocument>.Filter.Eq(b => b.Id, binding.Id));
|
||||
|
||||
var options = new ReplaceOptions { IsUpsert = true };
|
||||
await Bindings.ReplaceOneAsync(filter, binding, options, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Upserted binding {BindingId} for tenant {TenantId}",
|
||||
binding.Id, binding.TenantId);
|
||||
|
||||
return binding;
|
||||
}
|
||||
|
||||
public async Task<ImmutableArray<ExceptionBindingDocument>> GetBindingsForExceptionAsync(
|
||||
string tenantId,
|
||||
string exceptionId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var filter = Builders<ExceptionBindingDocument>.Filter.And(
|
||||
Builders<ExceptionBindingDocument>.Filter.Eq(b => b.TenantId, tenantId.ToLowerInvariant()),
|
||||
Builders<ExceptionBindingDocument>.Filter.Eq(b => b.ExceptionId, exceptionId));
|
||||
|
||||
var results = await Bindings
|
||||
.Find(filter)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return results.ToImmutableArray();
|
||||
}
|
||||
|
||||
public async Task<ImmutableArray<ExceptionBindingDocument>> GetActiveBindingsForAssetAsync(
|
||||
string tenantId,
|
||||
string assetId,
|
||||
DateTimeOffset asOf,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var filter = Builders<ExceptionBindingDocument>.Filter.And(
|
||||
Builders<ExceptionBindingDocument>.Filter.Eq(b => b.TenantId, tenantId.ToLowerInvariant()),
|
||||
Builders<ExceptionBindingDocument>.Filter.Eq(b => b.AssetId, assetId),
|
||||
Builders<ExceptionBindingDocument>.Filter.Eq(b => b.Status, "active"),
|
||||
Builders<ExceptionBindingDocument>.Filter.Lte(b => b.EffectiveFrom, asOf),
|
||||
Builders<ExceptionBindingDocument>.Filter.Or(
|
||||
Builders<ExceptionBindingDocument>.Filter.Eq(b => b.ExpiresAt, null),
|
||||
Builders<ExceptionBindingDocument>.Filter.Gt(b => b.ExpiresAt, asOf)));
|
||||
|
||||
var results = await Bindings
|
||||
.Find(filter)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return results.ToImmutableArray();
|
||||
}
|
||||
|
||||
public async Task<long> DeleteBindingsForExceptionAsync(
|
||||
string tenantId,
|
||||
string exceptionId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var filter = Builders<ExceptionBindingDocument>.Filter.And(
|
||||
Builders<ExceptionBindingDocument>.Filter.Eq(b => b.TenantId, tenantId.ToLowerInvariant()),
|
||||
Builders<ExceptionBindingDocument>.Filter.Eq(b => b.ExceptionId, exceptionId));
|
||||
|
||||
var result = await Bindings.DeleteManyAsync(filter, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Deleted {Count} bindings for exception {ExceptionId} tenant {TenantId}",
|
||||
result.DeletedCount, exceptionId, tenantId);
|
||||
|
||||
return result.DeletedCount;
|
||||
}
|
||||
|
||||
public async Task<bool> UpdateBindingStatusAsync(
|
||||
string tenantId,
|
||||
string bindingId,
|
||||
string newStatus,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var filter = Builders<ExceptionBindingDocument>.Filter.And(
|
||||
Builders<ExceptionBindingDocument>.Filter.Eq(b => b.TenantId, tenantId.ToLowerInvariant()),
|
||||
Builders<ExceptionBindingDocument>.Filter.Eq(b => b.Id, bindingId));
|
||||
|
||||
var update = Builders<ExceptionBindingDocument>.Update.Set(b => b.Status, newStatus);
|
||||
|
||||
var result = await Bindings.UpdateOneAsync(filter, update, cancellationToken: cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return result.ModifiedCount > 0;
|
||||
}
|
||||
|
||||
public async Task<ImmutableArray<ExceptionBindingDocument>> GetExpiredBindingsAsync(
|
||||
string tenantId,
|
||||
DateTimeOffset asOf,
|
||||
int limit,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var filter = Builders<ExceptionBindingDocument>.Filter.And(
|
||||
Builders<ExceptionBindingDocument>.Filter.Eq(b => b.TenantId, tenantId.ToLowerInvariant()),
|
||||
Builders<ExceptionBindingDocument>.Filter.Eq(b => b.Status, "active"),
|
||||
Builders<ExceptionBindingDocument>.Filter.Lt(b => b.ExpiresAt, asOf));
|
||||
|
||||
var results = await Bindings
|
||||
.Find(filter)
|
||||
.Limit(limit)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return results.ToImmutableArray();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Statistics
|
||||
|
||||
public async Task<IReadOnlyDictionary<string, int>> GetExceptionCountsByStatusAsync(
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var pipeline = new BsonDocument[]
|
||||
{
|
||||
new("$match", new BsonDocument("tenantId", tenantId.ToLowerInvariant())),
|
||||
new("$group", new BsonDocument
|
||||
{
|
||||
{ "_id", "$status" },
|
||||
{ "count", new BsonDocument("$sum", 1) }
|
||||
})
|
||||
};
|
||||
|
||||
var results = await Exceptions
|
||||
.Aggregate<BsonDocument>(pipeline, cancellationToken: cancellationToken)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return results.ToDictionary(
|
||||
r => r["_id"].AsString,
|
||||
r => r["count"].AsInt32);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -1,496 +0,0 @@
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Policy.Engine.Domain;
|
||||
using StellaOps.Policy.Engine.Services;
|
||||
using StellaOps.Policy.Engine.Storage.Mongo.Internal;
|
||||
|
||||
// Alias to disambiguate from StellaOps.Policy.PolicyDocument (compiled policy IR)
|
||||
using PolicyPackDocument = StellaOps.Policy.Engine.Storage.Mongo.Documents.PolicyDocument;
|
||||
using PolicyRevisionDoc = StellaOps.Policy.Engine.Storage.Mongo.Documents.PolicyRevisionDocument;
|
||||
using PolicyBundleDoc = StellaOps.Policy.Engine.Storage.Mongo.Documents.PolicyBundleDocument;
|
||||
using PolicyApprovalRec = StellaOps.Policy.Engine.Storage.Mongo.Documents.PolicyApprovalRecord;
|
||||
using PolicyAocMetadataDoc = StellaOps.Policy.Engine.Storage.Mongo.Documents.PolicyAocMetadataDocument;
|
||||
using PolicyProvenanceDoc = StellaOps.Policy.Engine.Storage.Mongo.Documents.PolicyProvenanceDocument;
|
||||
using PolicyAttestationRefDoc = StellaOps.Policy.Engine.Storage.Mongo.Documents.PolicyAttestationRefDocument;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Storage.Mongo.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// MongoDB implementation of policy pack repository with tenant scoping.
|
||||
/// </summary>
|
||||
internal sealed class MongoPolicyPackRepository : IPolicyPackRepository
|
||||
{
|
||||
private readonly PolicyEngineMongoContext _context;
|
||||
private readonly ILogger<MongoPolicyPackRepository> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly string _tenantId;
|
||||
|
||||
public MongoPolicyPackRepository(
|
||||
PolicyEngineMongoContext context,
|
||||
ILogger<MongoPolicyPackRepository> logger,
|
||||
TimeProvider timeProvider,
|
||||
string tenantId)
|
||||
{
|
||||
_context = context ?? throw new ArgumentNullException(nameof(context));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_tenantId = tenantId?.ToLowerInvariant() ?? throw new ArgumentNullException(nameof(tenantId));
|
||||
}
|
||||
|
||||
private IMongoCollection<PolicyPackDocument> Policies =>
|
||||
_context.Database.GetCollection<PolicyPackDocument>(_context.Options.PoliciesCollection);
|
||||
|
||||
private IMongoCollection<PolicyRevisionDoc> Revisions =>
|
||||
_context.Database.GetCollection<PolicyRevisionDoc>(_context.Options.PolicyRevisionsCollection);
|
||||
|
||||
private IMongoCollection<PolicyBundleDoc> Bundles =>
|
||||
_context.Database.GetCollection<PolicyBundleDoc>(_context.Options.PolicyBundlesCollection);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<PolicyPackRecord> CreateAsync(string packId, string? displayName, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(packId);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var document = new PolicyPackDocument
|
||||
{
|
||||
Id = packId,
|
||||
TenantId = _tenantId,
|
||||
DisplayName = displayName,
|
||||
LatestVersion = 0,
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
await Policies.InsertOneAsync(document, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogDebug("Created policy pack {PackId} for tenant {TenantId}", packId, _tenantId);
|
||||
}
|
||||
catch (MongoWriteException ex) when (ex.WriteError.Category == ServerErrorCategory.DuplicateKey)
|
||||
{
|
||||
_logger.LogDebug("Policy pack {PackId} already exists for tenant {TenantId}", packId, _tenantId);
|
||||
var existing = await Policies.Find(p => p.Id == packId && p.TenantId == _tenantId)
|
||||
.FirstOrDefaultAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (existing is null)
|
||||
{
|
||||
throw new InvalidOperationException($"Policy pack {packId} exists but not for tenant {_tenantId}");
|
||||
}
|
||||
|
||||
return ToDomain(existing);
|
||||
}
|
||||
|
||||
return ToDomain(document);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<PolicyPackRecord>> ListAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var documents = await Policies
|
||||
.Find(p => p.TenantId == _tenantId)
|
||||
.SortBy(p => p.Id)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return documents.Select(ToDomain).ToList().AsReadOnly();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<PolicyRevisionRecord> UpsertRevisionAsync(
|
||||
string packId,
|
||||
int version,
|
||||
bool requiresTwoPersonApproval,
|
||||
PolicyRevisionStatus initialStatus,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
// Ensure pack exists
|
||||
var pack = await Policies.Find(p => p.Id == packId && p.TenantId == _tenantId)
|
||||
.FirstOrDefaultAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (pack is null)
|
||||
{
|
||||
pack = new PolicyPackDocument
|
||||
{
|
||||
Id = packId,
|
||||
TenantId = _tenantId,
|
||||
LatestVersion = 0,
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
await Policies.InsertOneAsync(pack, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (MongoWriteException ex) when (ex.WriteError.Category == ServerErrorCategory.DuplicateKey)
|
||||
{
|
||||
pack = await Policies.Find(p => p.Id == packId && p.TenantId == _tenantId)
|
||||
.FirstAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Determine version
|
||||
var targetVersion = version > 0 ? version : pack.LatestVersion + 1;
|
||||
var revisionId = PolicyRevisionDoc.CreateId(packId, targetVersion);
|
||||
|
||||
// Upsert revision
|
||||
var filter = Builders<PolicyRevisionDoc>.Filter.Eq(r => r.Id, revisionId);
|
||||
var update = Builders<PolicyRevisionDoc>.Update
|
||||
.SetOnInsert(r => r.Id, revisionId)
|
||||
.SetOnInsert(r => r.TenantId, _tenantId)
|
||||
.SetOnInsert(r => r.PackId, packId)
|
||||
.SetOnInsert(r => r.Version, targetVersion)
|
||||
.SetOnInsert(r => r.RequiresTwoPersonApproval, requiresTwoPersonApproval)
|
||||
.SetOnInsert(r => r.CreatedAt, now)
|
||||
.Set(r => r.Status, initialStatus.ToString());
|
||||
|
||||
var options = new FindOneAndUpdateOptions<PolicyRevisionDoc>
|
||||
{
|
||||
IsUpsert = true,
|
||||
ReturnDocument = ReturnDocument.After
|
||||
};
|
||||
|
||||
var revision = await Revisions.FindOneAndUpdateAsync(filter, update, options, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
// Update pack latest version
|
||||
if (targetVersion > pack.LatestVersion)
|
||||
{
|
||||
await Policies.UpdateOneAsync(
|
||||
p => p.Id == packId && p.TenantId == _tenantId,
|
||||
Builders<PolicyPackDocument>.Update
|
||||
.Set(p => p.LatestVersion, targetVersion)
|
||||
.Set(p => p.UpdatedAt, now),
|
||||
cancellationToken: cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
"Upserted revision {PackId}:{Version} for tenant {TenantId}",
|
||||
packId, targetVersion, _tenantId);
|
||||
|
||||
return ToDomain(revision);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<PolicyRevisionRecord?> GetRevisionAsync(string packId, int version, CancellationToken cancellationToken)
|
||||
{
|
||||
var revisionId = PolicyRevisionDoc.CreateId(packId, version);
|
||||
var revision = await Revisions
|
||||
.Find(r => r.Id == revisionId && r.TenantId == _tenantId)
|
||||
.FirstOrDefaultAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (revision is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Load bundle if referenced
|
||||
PolicyBundleDoc? bundle = null;
|
||||
if (!string.IsNullOrEmpty(revision.BundleId))
|
||||
{
|
||||
bundle = await Bundles
|
||||
.Find(b => b.Id == revision.BundleId && b.TenantId == _tenantId)
|
||||
.FirstOrDefaultAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return ToDomain(revision, bundle);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<PolicyActivationResult> RecordActivationAsync(
|
||||
string packId,
|
||||
int version,
|
||||
string actorId,
|
||||
DateTimeOffset timestamp,
|
||||
string? comment,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var revisionId = PolicyRevisionDoc.CreateId(packId, version);
|
||||
|
||||
// Get current revision
|
||||
var revision = await Revisions
|
||||
.Find(r => r.Id == revisionId && r.TenantId == _tenantId)
|
||||
.FirstOrDefaultAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (revision is null)
|
||||
{
|
||||
var pack = await Policies.Find(p => p.Id == packId && p.TenantId == _tenantId)
|
||||
.FirstOrDefaultAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return pack is null
|
||||
? new PolicyActivationResult(PolicyActivationResultStatus.PackNotFound, null)
|
||||
: new PolicyActivationResult(PolicyActivationResultStatus.RevisionNotFound, null);
|
||||
}
|
||||
|
||||
if (revision.Status == PolicyRevisionStatus.Active.ToString())
|
||||
{
|
||||
return new PolicyActivationResult(PolicyActivationResultStatus.AlreadyActive, ToDomain(revision));
|
||||
}
|
||||
|
||||
if (revision.Status != PolicyRevisionStatus.Approved.ToString())
|
||||
{
|
||||
return new PolicyActivationResult(PolicyActivationResultStatus.NotApproved, ToDomain(revision));
|
||||
}
|
||||
|
||||
// Check for duplicate approval
|
||||
if (revision.Approvals.Any(a => a.ActorId.Equals(actorId, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
return new PolicyActivationResult(PolicyActivationResultStatus.DuplicateApproval, ToDomain(revision));
|
||||
}
|
||||
|
||||
// Add approval
|
||||
var approval = new PolicyApprovalRec
|
||||
{
|
||||
ActorId = actorId,
|
||||
ApprovedAt = timestamp,
|
||||
Comment = comment
|
||||
};
|
||||
|
||||
var approvalUpdate = Builders<PolicyRevisionDoc>.Update.Push(r => r.Approvals, approval);
|
||||
await Revisions.UpdateOneAsync(r => r.Id == revisionId, approvalUpdate, cancellationToken: cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
revision.Approvals.Add(approval);
|
||||
|
||||
// Check if we have enough approvals
|
||||
var approvalCount = revision.Approvals.Count;
|
||||
if (revision.RequiresTwoPersonApproval && approvalCount < 2)
|
||||
{
|
||||
return new PolicyActivationResult(PolicyActivationResultStatus.PendingSecondApproval, ToDomain(revision));
|
||||
}
|
||||
|
||||
// Activate
|
||||
var activateUpdate = Builders<PolicyRevisionDoc>.Update
|
||||
.Set(r => r.Status, PolicyRevisionStatus.Active.ToString())
|
||||
.Set(r => r.ActivatedAt, timestamp);
|
||||
|
||||
await Revisions.UpdateOneAsync(r => r.Id == revisionId, activateUpdate, cancellationToken: cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
// Update pack active version
|
||||
await Policies.UpdateOneAsync(
|
||||
p => p.Id == packId && p.TenantId == _tenantId,
|
||||
Builders<PolicyPackDocument>.Update
|
||||
.Set(p => p.ActiveVersion, version)
|
||||
.Set(p => p.UpdatedAt, timestamp),
|
||||
cancellationToken: cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
revision.Status = PolicyRevisionStatus.Active.ToString();
|
||||
revision.ActivatedAt = timestamp;
|
||||
|
||||
_logger.LogInformation(
|
||||
"Activated revision {PackId}:{Version} for tenant {TenantId} by {ActorId}",
|
||||
packId, version, _tenantId, actorId);
|
||||
|
||||
return new PolicyActivationResult(PolicyActivationResultStatus.Activated, ToDomain(revision));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<PolicyBundleRecord> StoreBundleAsync(
|
||||
string packId,
|
||||
int version,
|
||||
PolicyBundleRecord bundle,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(bundle);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
// Ensure revision exists
|
||||
await UpsertRevisionAsync(packId, version, requiresTwoPersonApproval: false, PolicyRevisionStatus.Draft, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
// Create bundle document
|
||||
var bundleDoc = new PolicyBundleDoc
|
||||
{
|
||||
Id = bundle.Digest,
|
||||
TenantId = _tenantId,
|
||||
PackId = packId,
|
||||
Version = version,
|
||||
Signature = bundle.Signature,
|
||||
SizeBytes = bundle.Size,
|
||||
Payload = bundle.Payload.ToArray(),
|
||||
CreatedAt = bundle.CreatedAt,
|
||||
AocMetadata = bundle.AocMetadata is not null ? ToDocument(bundle.AocMetadata) : null
|
||||
};
|
||||
|
||||
// Upsert bundle
|
||||
await Bundles.ReplaceOneAsync(
|
||||
b => b.Id == bundle.Digest && b.TenantId == _tenantId,
|
||||
bundleDoc,
|
||||
new ReplaceOptions { IsUpsert = true },
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
// Link revision to bundle
|
||||
var revisionId = PolicyRevisionDoc.CreateId(packId, version);
|
||||
await Revisions.UpdateOneAsync(
|
||||
r => r.Id == revisionId && r.TenantId == _tenantId,
|
||||
Builders<PolicyRevisionDoc>.Update
|
||||
.Set(r => r.BundleId, bundle.Digest)
|
||||
.Set(r => r.BundleDigest, bundle.Digest),
|
||||
cancellationToken: cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Stored bundle {Digest} for {PackId}:{Version} tenant {TenantId}",
|
||||
bundle.Digest, packId, version, _tenantId);
|
||||
|
||||
return bundle;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<PolicyBundleRecord?> GetBundleAsync(string packId, int version, CancellationToken cancellationToken)
|
||||
{
|
||||
var bundle = await Bundles
|
||||
.Find(b => b.PackId == packId && b.Version == version && b.TenantId == _tenantId)
|
||||
.FirstOrDefaultAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return bundle is null ? null : ToDomain(bundle);
|
||||
}
|
||||
|
||||
#region Mapping
|
||||
|
||||
private static PolicyPackRecord ToDomain(PolicyPackDocument doc)
|
||||
{
|
||||
return new PolicyPackRecord(doc.Id, doc.DisplayName, doc.CreatedAt);
|
||||
}
|
||||
|
||||
private static PolicyRevisionRecord ToDomain(PolicyRevisionDoc doc, PolicyBundleDoc? bundleDoc = null)
|
||||
{
|
||||
var status = Enum.TryParse<PolicyRevisionStatus>(doc.Status, ignoreCase: true, out var s)
|
||||
? s
|
||||
: PolicyRevisionStatus.Draft;
|
||||
|
||||
var revision = new PolicyRevisionRecord(doc.Version, doc.RequiresTwoPersonApproval, status, doc.CreatedAt);
|
||||
|
||||
if (doc.ActivatedAt.HasValue)
|
||||
{
|
||||
revision.SetStatus(PolicyRevisionStatus.Active, doc.ActivatedAt.Value);
|
||||
}
|
||||
|
||||
foreach (var approval in doc.Approvals)
|
||||
{
|
||||
revision.AddApproval(new PolicyActivationApproval(approval.ActorId, approval.ApprovedAt, approval.Comment));
|
||||
}
|
||||
|
||||
if (bundleDoc is not null)
|
||||
{
|
||||
revision.SetBundle(ToDomain(bundleDoc));
|
||||
}
|
||||
|
||||
return revision;
|
||||
}
|
||||
|
||||
private static PolicyBundleRecord ToDomain(PolicyBundleDoc doc)
|
||||
{
|
||||
PolicyAocMetadata? aocMetadata = null;
|
||||
if (doc.AocMetadata is not null)
|
||||
{
|
||||
var aoc = doc.AocMetadata;
|
||||
PolicyProvenance? provenance = null;
|
||||
if (aoc.Provenance is not null)
|
||||
{
|
||||
var p = aoc.Provenance;
|
||||
provenance = new PolicyProvenance(
|
||||
p.SourceType,
|
||||
p.SourceUrl,
|
||||
p.Submitter,
|
||||
p.CommitSha,
|
||||
p.Branch,
|
||||
p.IngestedAt);
|
||||
}
|
||||
|
||||
PolicyAttestationRef? attestationRef = null;
|
||||
if (aoc.AttestationRef is not null)
|
||||
{
|
||||
var a = aoc.AttestationRef;
|
||||
attestationRef = new PolicyAttestationRef(
|
||||
a.AttestationId,
|
||||
a.EnvelopeDigest,
|
||||
a.Uri,
|
||||
a.SigningKeyId,
|
||||
a.CreatedAt);
|
||||
}
|
||||
|
||||
aocMetadata = new PolicyAocMetadata(
|
||||
aoc.CompilationId,
|
||||
aoc.CompilerVersion,
|
||||
aoc.CompiledAt,
|
||||
aoc.SourceDigest,
|
||||
aoc.ArtifactDigest,
|
||||
aoc.ComplexityScore,
|
||||
aoc.RuleCount,
|
||||
aoc.DurationMilliseconds,
|
||||
provenance,
|
||||
attestationRef);
|
||||
}
|
||||
|
||||
return new PolicyBundleRecord(
|
||||
doc.Id,
|
||||
doc.Signature,
|
||||
doc.SizeBytes,
|
||||
doc.CreatedAt,
|
||||
doc.Payload.ToImmutableArray(),
|
||||
CompiledDocument: null, // Cannot serialize IR document to/from Mongo
|
||||
aocMetadata);
|
||||
}
|
||||
|
||||
private static PolicyAocMetadataDoc ToDocument(PolicyAocMetadata aoc)
|
||||
{
|
||||
return new PolicyAocMetadataDoc
|
||||
{
|
||||
CompilationId = aoc.CompilationId,
|
||||
CompilerVersion = aoc.CompilerVersion,
|
||||
CompiledAt = aoc.CompiledAt,
|
||||
SourceDigest = aoc.SourceDigest,
|
||||
ArtifactDigest = aoc.ArtifactDigest,
|
||||
ComplexityScore = aoc.ComplexityScore,
|
||||
RuleCount = aoc.RuleCount,
|
||||
DurationMilliseconds = aoc.DurationMilliseconds,
|
||||
Provenance = aoc.Provenance is not null ? ToDocument(aoc.Provenance) : null,
|
||||
AttestationRef = aoc.AttestationRef is not null ? ToDocument(aoc.AttestationRef) : null
|
||||
};
|
||||
}
|
||||
|
||||
private static PolicyProvenanceDoc ToDocument(PolicyProvenance p)
|
||||
{
|
||||
return new PolicyProvenanceDoc
|
||||
{
|
||||
SourceType = p.SourceType,
|
||||
SourceUrl = p.SourceUrl,
|
||||
Submitter = p.Submitter,
|
||||
CommitSha = p.CommitSha,
|
||||
Branch = p.Branch,
|
||||
IngestedAt = p.IngestedAt
|
||||
};
|
||||
}
|
||||
|
||||
private static PolicyAttestationRefDoc ToDocument(PolicyAttestationRef a)
|
||||
{
|
||||
return new PolicyAttestationRefDoc
|
||||
{
|
||||
AttestationId = a.AttestationId,
|
||||
EnvelopeDigest = a.EnvelopeDigest,
|
||||
Uri = a.Uri,
|
||||
SigningKeyId = a.SigningKeyId,
|
||||
CreatedAt = a.CreatedAt
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Policy.Engine.Storage.Mongo.Internal;
|
||||
using StellaOps.Policy.Engine.Storage.Mongo.Migrations;
|
||||
using StellaOps.Policy.Engine.Storage.Mongo.Options;
|
||||
using StellaOps.Policy.Engine.Storage.Mongo.Repositories;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Storage.Mongo;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for registering Policy Engine MongoDB storage services.
|
||||
/// </summary>
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds Policy Engine MongoDB storage services to the service collection.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <param name="configure">Optional configuration action for PolicyEngineMongoOptions.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddPolicyEngineMongoStorage(
|
||||
this IServiceCollection services,
|
||||
Action<PolicyEngineMongoOptions>? configure = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
|
||||
// Register options
|
||||
if (configure is not null)
|
||||
{
|
||||
services.Configure(configure);
|
||||
}
|
||||
|
||||
// Register context (singleton for connection pooling)
|
||||
services.AddSingleton<PolicyEngineMongoContext>();
|
||||
|
||||
// Register migrations
|
||||
services.AddSingleton<IPolicyEngineMongoMigration, EnsurePolicyCollectionsMigration>();
|
||||
services.AddSingleton<IPolicyEngineMongoMigration, EnsurePolicyIndexesMigration>();
|
||||
services.AddSingleton<IPolicyEngineMongoMigration, EnsureExceptionIndexesMigration>();
|
||||
|
||||
// Register migration runner
|
||||
services.AddSingleton<PolicyEngineMigrationRunner>();
|
||||
|
||||
// Register initializer
|
||||
services.AddSingleton<IPolicyEngineMongoInitializer, PolicyEngineMongoInitializer>();
|
||||
|
||||
// Register dynamic collection initializer for effective findings
|
||||
services.AddSingleton<IEffectiveFindingCollectionInitializer, EffectiveFindingCollectionInitializer>();
|
||||
|
||||
// Register repositories
|
||||
services.AddSingleton<IExceptionRepository, MongoExceptionRepository>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds Policy Engine MongoDB storage services with configuration binding from a configuration section.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <param name="configuration">Configuration section containing PolicyEngineMongoOptions.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddPolicyEngineMongoStorage(
|
||||
this IServiceCollection services,
|
||||
Microsoft.Extensions.Configuration.IConfigurationSection configuration)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(configuration);
|
||||
|
||||
services.Configure<PolicyEngineMongoOptions>(configuration);
|
||||
|
||||
return services.AddPolicyEngineMongoStorage(configure: null);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user