This commit is contained in:
StellaOps Bot
2025-11-29 02:19:50 +02:00
parent 2548abc56f
commit b34f13dc03
86 changed files with 9625 additions and 640 deletions

View File

@@ -8,5 +8,7 @@ namespace StellaOps.Policy.Scoring.Receipts;
/// </summary>
public interface IReceiptRepository
{
Task<CvssScoreReceipt> SaveAsync(CvssScoreReceipt receipt, CancellationToken cancellationToken = default);
Task<CvssScoreReceipt> SaveAsync(string tenantId, CvssScoreReceipt receipt, CancellationToken cancellationToken = default);
Task<CvssScoreReceipt?> GetAsync(string tenantId, string receiptId, CancellationToken cancellationToken = default);
Task<CvssScoreReceipt> UpdateAsync(string tenantId, CvssScoreReceipt receipt, CancellationToken cancellationToken = default);
}

View File

@@ -4,6 +4,7 @@ using System.Text;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Serialization;
using StellaOps.Attestor.Envelope;
using StellaOps.Policy.Scoring.Engine;
namespace StellaOps.Policy.Scoring.Receipts;
@@ -20,6 +21,7 @@ public sealed record CreateReceiptRequest
public CvssEnvironmentalMetrics? EnvironmentalMetrics { get; init; }
public CvssSupplementalMetrics? SupplementalMetrics { get; init; }
public ImmutableList<CvssEvidenceItem> Evidence { get; init; } = [];
public EnvelopeKey? SigningKey { get; init; }
}
public interface IReceiptBuilder
@@ -32,7 +34,7 @@ public interface IReceiptBuilder
/// </summary>
public sealed class ReceiptBuilder : IReceiptBuilder
{
private static readonly JsonSerializerOptions CanonicalSerializerOptions = new()
internal static readonly JsonSerializerOptions SerializerOptions = new()
{
PropertyNamingPolicy = null,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
@@ -42,11 +44,13 @@ public sealed class ReceiptBuilder : IReceiptBuilder
private readonly ICvssV4Engine _engine;
private readonly IReceiptRepository _repository;
private readonly EnvelopeSignatureService _signatureService;
public ReceiptBuilder(ICvssV4Engine engine, IReceiptRepository repository)
{
_engine = engine;
_repository = repository;
_signatureService = new EnvelopeSignatureService();
}
public async Task<CvssScoreReceipt> CreateAsync(CreateReceiptRequest request, CancellationToken cancellationToken = default)
@@ -115,7 +119,15 @@ public sealed class ReceiptBuilder : IReceiptBuilder
SupersededReason = null
};
return await _repository.SaveAsync(receipt, cancellationToken).ConfigureAwait(false);
if (request.SigningKey is not null)
{
receipt = receipt with
{
AttestationRefs = CreateAttestationRefs(receipt, request.SigningKey)
};
}
return await _repository.SaveAsync(request.TenantId, receipt, cancellationToken).ConfigureAwait(false);
}
private static void ValidateEvidence(CvssPolicy policy, ImmutableList<CvssEvidenceItem> evidence)
@@ -170,34 +182,34 @@ public sealed class ReceiptBuilder : IReceiptBuilder
writer.WriteString("vector", vector);
writer.WritePropertyName("baseMetrics");
WriteCanonical(JsonSerializer.SerializeToElement(request.BaseMetrics, CanonicalSerializerOptions), writer);
WriteCanonical(JsonSerializer.SerializeToElement(request.BaseMetrics, SerializerOptions), writer);
writer.WritePropertyName("threatMetrics");
if (request.ThreatMetrics is not null)
WriteCanonical(JsonSerializer.SerializeToElement(request.ThreatMetrics, CanonicalSerializerOptions), writer);
WriteCanonical(JsonSerializer.SerializeToElement(request.ThreatMetrics, SerializerOptions), writer);
else
writer.WriteNullValue();
writer.WritePropertyName("environmentalMetrics");
if (request.EnvironmentalMetrics is not null)
WriteCanonical(JsonSerializer.SerializeToElement(request.EnvironmentalMetrics, CanonicalSerializerOptions), writer);
WriteCanonical(JsonSerializer.SerializeToElement(request.EnvironmentalMetrics, SerializerOptions), writer);
else
writer.WriteNullValue();
writer.WritePropertyName("supplementalMetrics");
if (request.SupplementalMetrics is not null)
WriteCanonical(JsonSerializer.SerializeToElement(request.SupplementalMetrics, CanonicalSerializerOptions), writer);
WriteCanonical(JsonSerializer.SerializeToElement(request.SupplementalMetrics, SerializerOptions), writer);
else
writer.WriteNullValue();
writer.WritePropertyName("scores");
WriteCanonical(JsonSerializer.SerializeToElement(scores, CanonicalSerializerOptions), writer);
WriteCanonical(JsonSerializer.SerializeToElement(scores, SerializerOptions), writer);
writer.WritePropertyName("evidence");
writer.WriteStartArray();
foreach (var ev in evidence)
{
WriteCanonical(JsonSerializer.SerializeToElement(ev, CanonicalSerializerOptions), writer);
WriteCanonical(JsonSerializer.SerializeToElement(ev, SerializerOptions), writer);
}
writer.WriteEndArray();
@@ -208,6 +220,41 @@ public sealed class ReceiptBuilder : IReceiptBuilder
return Convert.ToHexString(hash).ToLowerInvariant();
}
private ImmutableList<string> CreateAttestationRefs(CvssScoreReceipt receipt, EnvelopeKey signingKey)
{
// Serialize receipt deterministically as DSSE payload
var payload = JsonSerializer.SerializeToUtf8Bytes(receipt, SerializerOptions);
var signatureResult = _signatureService.Sign(payload, signingKey);
if (!signatureResult.IsSuccess)
{
throw new InvalidOperationException($"Failed to sign receipt: {signatureResult.Error?.Message}");
}
var envelope = new DsseEnvelope(
payloadType: "stella.ops/cvssReceipt@v1",
payload: payload,
signatures: new[] { DsseSignature.FromBytes(signatureResult.Value.Value.Span, signatureResult.Value.KeyId) });
var serialized = DsseEnvelopeSerializer.Serialize(envelope, new DsseEnvelopeSerializationOptions
{
EmitCompactJson = true,
EmitExpandedJson = false,
CompressionAlgorithm = DsseCompressionAlgorithm.None
});
// store compact JSON as base64 for transport; include payload hash for lookup
var compactBase64 = serialized.CompactJson is null
? null
: Convert.ToBase64String(serialized.CompactJson);
var refString = compactBase64 is null
? $"dsse:{serialized.PayloadSha256}:{signingKey.KeyId}"
: $"dsse:{serialized.PayloadSha256}:{signingKey.KeyId}:{compactBase64}";
return ImmutableList<string>.Empty.Add(refString);
}
private static void WriteCanonical(JsonElement element, Utf8JsonWriter writer)
{
switch (element.ValueKind)

View File

@@ -0,0 +1,107 @@
using System.Collections.Immutable;
using StellaOps.Attestor.Envelope;
namespace StellaOps.Policy.Scoring.Receipts;
public sealed record AmendReceiptRequest
{
public required string ReceiptId { get; init; }
public required string TenantId { get; init; }
public required string Actor { get; init; }
public required string Field { get; init; }
public string? PreviousValue { get; init; }
public string? NewValue { get; init; }
public required string Reason { get; init; }
public string? ReferenceUri { get; init; }
public EnvelopeKey? SigningKey { get; init; }
}
public interface IReceiptHistoryService
{
Task<CvssScoreReceipt> AmendAsync(AmendReceiptRequest request, CancellationToken cancellationToken = default);
}
public sealed class ReceiptHistoryService : IReceiptHistoryService
{
private readonly IReceiptRepository _repository;
private readonly EnvelopeSignatureService _signatureService = new();
public ReceiptHistoryService(IReceiptRepository repository)
{
_repository = repository;
}
public async Task<CvssScoreReceipt> AmendAsync(AmendReceiptRequest request, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
var existing = await _repository.GetAsync(request.TenantId, request.ReceiptId, cancellationToken)
?? throw new InvalidOperationException($"Receipt '{request.ReceiptId}' not found.");
var now = DateTimeOffset.UtcNow;
var historyId = Guid.NewGuid().ToString("N");
var newHistory = existing.History.Add(new ReceiptHistoryEntry
{
HistoryId = historyId,
Timestamp = now,
Actor = request.Actor,
ChangeType = ReceiptChangeType.Amended,
Field = request.Field,
PreviousValue = request.PreviousValue,
NewValue = request.NewValue,
Reason = request.Reason,
ReferenceUri = request.ReferenceUri,
Signature = null
});
var amended = existing with
{
ModifiedAt = now,
ModifiedBy = request.Actor,
History = newHistory
};
if (request.SigningKey is not null)
{
amended = amended with
{
AttestationRefs = SignReceipt(amended, request.SigningKey)
};
}
return await _repository.UpdateAsync(request.TenantId, amended, cancellationToken).ConfigureAwait(false);
}
private ImmutableList<string> SignReceipt(CvssScoreReceipt receipt, EnvelopeKey signingKey)
{
var payload = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(receipt, ReceiptBuilder.SerializerOptions);
var signatureResult = _signatureService.Sign(payload, signingKey);
if (!signatureResult.IsSuccess)
{
throw new InvalidOperationException($"Failed to sign amended receipt: {signatureResult.Error?.Message}");
}
var envelope = new DsseEnvelope(
payloadType: "stella.ops/cvssReceipt@v1",
payload: payload,
signatures: new[] { DsseSignature.FromBytes(signatureResult.Value.Value.Span, signatureResult.Value.KeyId) });
var serialized = DsseEnvelopeSerializer.Serialize(envelope, new DsseEnvelopeSerializationOptions
{
EmitCompactJson = true,
EmitExpandedJson = false,
CompressionAlgorithm = DsseCompressionAlgorithm.None
});
var compactBase64 = serialized.CompactJson is null
? null
: Convert.ToBase64String(serialized.CompactJson);
var refString = compactBase64 is null
? $"dsse:{serialized.PayloadSha256}:{signingKey.KeyId}"
: $"dsse:{serialized.PayloadSha256}:{signingKey.KeyId}:{compactBase64}";
return ImmutableList<string>.Empty.Add(refString);
}
}

View File

@@ -12,6 +12,7 @@
<PackageReference Include="System.Text.Json" Version="10.0.0" />
<PackageReference Include="JsonSchema.Net" Version="5.3.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0-rc.2.25502.107" />
<ProjectReference Include="..\..\Attestor\StellaOps.Attestor.Envelope\StellaOps.Attestor.Envelope.csproj" />
</ItemGroup>
<ItemGroup>

View File

@@ -0,0 +1,42 @@
-- 002_cvss_receipts.sql
-- Description: Create cvss_receipts table for CVSS v4 receipts with attestation references.
-- Module: Policy
CREATE TABLE IF NOT EXISTS policy.cvss_receipts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
vulnerability_id TEXT NOT NULL,
receipt_format TEXT NOT NULL,
schema_version TEXT NOT NULL,
cvss_version TEXT NOT NULL,
vector TEXT NOT NULL,
severity TEXT NOT NULL CHECK (severity IN ('None','Low','Medium','High','Critical')),
base_score NUMERIC(4,1) NOT NULL,
threat_score NUMERIC(4,1),
environmental_score NUMERIC(4,1),
full_score NUMERIC(4,1),
effective_score NUMERIC(4,1) NOT NULL,
effective_score_type TEXT NOT NULL CHECK (effective_score_type IN ('Base','Threat','Environmental','Full')),
policy_id TEXT NOT NULL,
policy_version TEXT NOT NULL,
policy_hash TEXT NOT NULL,
base_metrics JSONB NOT NULL,
threat_metrics JSONB,
environmental_metrics JSONB,
supplemental_metrics JSONB,
evidence JSONB NOT NULL DEFAULT '[]'::jsonb,
attestation_refs JSONB NOT NULL DEFAULT '[]'::jsonb,
input_hash TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by TEXT NOT NULL,
modified_at TIMESTAMPTZ,
modified_by TEXT,
history JSONB NOT NULL DEFAULT '[]'::jsonb,
amends_receipt_id UUID,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
superseded_reason TEXT,
CONSTRAINT cvss_receipts_input_hash_key UNIQUE (tenant_id, input_hash)
);
CREATE INDEX IF NOT EXISTS idx_cvss_receipts_tenant_created ON policy.cvss_receipts (tenant_id, created_at DESC, id);
CREATE INDEX IF NOT EXISTS idx_cvss_receipts_tenant_vuln ON policy.cvss_receipts (tenant_id, vulnerability_id);

View File

@@ -0,0 +1,174 @@
namespace StellaOps.Policy.Storage.Postgres.Models;
/// <summary>
/// Evaluation run status enumeration.
/// </summary>
public enum EvaluationStatus
{
/// <summary>
/// Evaluation is pending.
/// </summary>
Pending,
/// <summary>
/// Evaluation is running.
/// </summary>
Running,
/// <summary>
/// Evaluation completed successfully.
/// </summary>
Completed,
/// <summary>
/// Evaluation failed.
/// </summary>
Failed
}
/// <summary>
/// Evaluation result enumeration.
/// </summary>
public enum EvaluationResult
{
/// <summary>
/// All rules passed.
/// </summary>
Pass,
/// <summary>
/// One or more rules failed.
/// </summary>
Fail,
/// <summary>
/// Warning - advisory findings.
/// </summary>
Warn,
/// <summary>
/// Evaluation encountered an error.
/// </summary>
Error
}
/// <summary>
/// Entity representing a policy evaluation run.
/// </summary>
public sealed class EvaluationRunEntity
{
/// <summary>
/// Unique identifier.
/// </summary>
public required Guid Id { get; init; }
/// <summary>
/// Tenant identifier.
/// </summary>
public required string TenantId { get; init; }
/// <summary>
/// Project identifier.
/// </summary>
public string? ProjectId { get; init; }
/// <summary>
/// Artifact identifier (container image reference).
/// </summary>
public string? ArtifactId { get; init; }
/// <summary>
/// Policy pack used for evaluation.
/// </summary>
public Guid? PackId { get; init; }
/// <summary>
/// Pack version number.
/// </summary>
public int? PackVersion { get; init; }
/// <summary>
/// Risk profile used for scoring.
/// </summary>
public Guid? RiskProfileId { get; init; }
/// <summary>
/// Current status.
/// </summary>
public EvaluationStatus Status { get; init; } = EvaluationStatus.Pending;
/// <summary>
/// Overall result.
/// </summary>
public EvaluationResult? Result { get; init; }
/// <summary>
/// Calculated risk score.
/// </summary>
public decimal? Score { get; init; }
/// <summary>
/// Total number of findings.
/// </summary>
public int FindingsCount { get; init; }
/// <summary>
/// Critical severity findings count.
/// </summary>
public int CriticalCount { get; init; }
/// <summary>
/// High severity findings count.
/// </summary>
public int HighCount { get; init; }
/// <summary>
/// Medium severity findings count.
/// </summary>
public int MediumCount { get; init; }
/// <summary>
/// Low severity findings count.
/// </summary>
public int LowCount { get; init; }
/// <summary>
/// Hash of input data for caching.
/// </summary>
public string? InputHash { get; init; }
/// <summary>
/// Evaluation duration in milliseconds.
/// </summary>
public int? DurationMs { get; init; }
/// <summary>
/// Error message if evaluation failed.
/// </summary>
public string? ErrorMessage { get; init; }
/// <summary>
/// Additional metadata as JSON.
/// </summary>
public string Metadata { get; init; } = "{}";
/// <summary>
/// Creation timestamp.
/// </summary>
public DateTimeOffset CreatedAt { get; init; }
/// <summary>
/// When evaluation started.
/// </summary>
public DateTimeOffset? StartedAt { get; init; }
/// <summary>
/// When evaluation completed.
/// </summary>
public DateTimeOffset? CompletedAt { get; init; }
/// <summary>
/// User who initiated the evaluation.
/// </summary>
public string? CreatedBy { get; init; }
}

View File

@@ -0,0 +1,118 @@
namespace StellaOps.Policy.Storage.Postgres.Models;
/// <summary>
/// Exception status enumeration.
/// </summary>
public enum ExceptionStatus
{
/// <summary>
/// Exception is active.
/// </summary>
Active,
/// <summary>
/// Exception has expired.
/// </summary>
Expired,
/// <summary>
/// Exception was revoked.
/// </summary>
Revoked
}
/// <summary>
/// Entity representing a policy exception/waiver.
/// </summary>
public sealed class ExceptionEntity
{
/// <summary>
/// Unique identifier.
/// </summary>
public required Guid Id { get; init; }
/// <summary>
/// Tenant identifier.
/// </summary>
public required string TenantId { get; init; }
/// <summary>
/// Exception name unique within tenant.
/// </summary>
public required string Name { get; init; }
/// <summary>
/// Exception description.
/// </summary>
public string? Description { get; init; }
/// <summary>
/// Pattern to match rule names.
/// </summary>
public string? RulePattern { get; init; }
/// <summary>
/// Pattern to match resource paths.
/// </summary>
public string? ResourcePattern { get; init; }
/// <summary>
/// Pattern to match artifact identifiers.
/// </summary>
public string? ArtifactPattern { get; init; }
/// <summary>
/// Specific project to apply exception to.
/// </summary>
public string? ProjectId { get; init; }
/// <summary>
/// Reason/justification for the exception.
/// </summary>
public required string Reason { get; init; }
/// <summary>
/// Current status.
/// </summary>
public ExceptionStatus Status { get; init; } = ExceptionStatus.Active;
/// <summary>
/// When the exception expires.
/// </summary>
public DateTimeOffset? ExpiresAt { get; init; }
/// <summary>
/// User who approved the exception.
/// </summary>
public string? ApprovedBy { get; init; }
/// <summary>
/// When the exception was approved.
/// </summary>
public DateTimeOffset? ApprovedAt { get; init; }
/// <summary>
/// User who revoked the exception.
/// </summary>
public string? RevokedBy { get; init; }
/// <summary>
/// When the exception was revoked.
/// </summary>
public DateTimeOffset? RevokedAt { get; init; }
/// <summary>
/// Additional metadata as JSON.
/// </summary>
public string Metadata { get; init; } = "{}";
/// <summary>
/// Creation timestamp.
/// </summary>
public DateTimeOffset CreatedAt { get; init; }
/// <summary>
/// User who created the exception.
/// </summary>
public string? CreatedBy { get; init; }
}

View File

@@ -0,0 +1,93 @@
namespace StellaOps.Policy.Storage.Postgres.Models;
/// <summary>
/// Rule evaluation result enumeration.
/// </summary>
public enum RuleResult
{
/// <summary>
/// Rule passed.
/// </summary>
Pass,
/// <summary>
/// Rule failed.
/// </summary>
Fail,
/// <summary>
/// Rule was skipped.
/// </summary>
Skip,
/// <summary>
/// Rule evaluation error.
/// </summary>
Error
}
/// <summary>
/// Entity representing a single rule evaluation within an evaluation run.
/// </summary>
public sealed class ExplanationEntity
{
/// <summary>
/// Unique identifier.
/// </summary>
public required Guid Id { get; init; }
/// <summary>
/// Parent evaluation run identifier.
/// </summary>
public required Guid EvaluationRunId { get; init; }
/// <summary>
/// Rule identifier (if rule still exists).
/// </summary>
public Guid? RuleId { get; init; }
/// <summary>
/// Rule name at time of evaluation.
/// </summary>
public required string RuleName { get; init; }
/// <summary>
/// Rule evaluation result.
/// </summary>
public required RuleResult Result { get; init; }
/// <summary>
/// Severity at time of evaluation.
/// </summary>
public required string Severity { get; init; }
/// <summary>
/// Explanation message.
/// </summary>
public string? Message { get; init; }
/// <summary>
/// Detailed findings as JSON.
/// </summary>
public string Details { get; init; } = "{}";
/// <summary>
/// Suggested remediation.
/// </summary>
public string? Remediation { get; init; }
/// <summary>
/// Path to the affected resource.
/// </summary>
public string? ResourcePath { get; init; }
/// <summary>
/// Line number in source if applicable.
/// </summary>
public int? LineNumber { get; init; }
/// <summary>
/// Creation timestamp.
/// </summary>
public DateTimeOffset CreatedAt { get; init; }
}

View File

@@ -0,0 +1,67 @@
namespace StellaOps.Policy.Storage.Postgres.Models;
/// <summary>
/// Entity representing a policy pack (container for rules).
/// </summary>
public sealed class PackEntity
{
/// <summary>
/// Unique identifier.
/// </summary>
public required Guid Id { get; init; }
/// <summary>
/// Tenant identifier.
/// </summary>
public required string TenantId { get; init; }
/// <summary>
/// Unique pack name within tenant.
/// </summary>
public required string Name { get; init; }
/// <summary>
/// Human-readable display name.
/// </summary>
public string? DisplayName { get; init; }
/// <summary>
/// Pack description.
/// </summary>
public string? Description { get; init; }
/// <summary>
/// Currently active version number.
/// </summary>
public int? ActiveVersion { get; init; }
/// <summary>
/// Whether this is a built-in system pack.
/// </summary>
public bool IsBuiltin { get; init; }
/// <summary>
/// Whether this pack is deprecated.
/// </summary>
public bool IsDeprecated { get; init; }
/// <summary>
/// Additional metadata as JSON.
/// </summary>
public string Metadata { get; init; } = "{}";
/// <summary>
/// Creation timestamp.
/// </summary>
public DateTimeOffset CreatedAt { get; init; }
/// <summary>
/// Last update timestamp.
/// </summary>
public DateTimeOffset UpdatedAt { get; init; }
/// <summary>
/// User who created this pack.
/// </summary>
public string? CreatedBy { get; init; }
}

View File

@@ -0,0 +1,57 @@
namespace StellaOps.Policy.Storage.Postgres.Models;
/// <summary>
/// Entity representing an immutable policy pack version.
/// </summary>
public sealed class PackVersionEntity
{
/// <summary>
/// Unique identifier.
/// </summary>
public required Guid Id { get; init; }
/// <summary>
/// Parent pack identifier.
/// </summary>
public required Guid PackId { get; init; }
/// <summary>
/// Sequential version number.
/// </summary>
public required int Version { get; init; }
/// <summary>
/// Version description.
/// </summary>
public string? Description { get; init; }
/// <summary>
/// Hash of all rules in this version.
/// </summary>
public required string RulesHash { get; init; }
/// <summary>
/// Whether this version is published and available for use.
/// </summary>
public bool IsPublished { get; init; }
/// <summary>
/// When this version was published.
/// </summary>
public DateTimeOffset? PublishedAt { get; init; }
/// <summary>
/// User who published this version.
/// </summary>
public string? PublishedBy { get; init; }
/// <summary>
/// Creation timestamp.
/// </summary>
public DateTimeOffset CreatedAt { get; init; }
/// <summary>
/// User who created this version.
/// </summary>
public string? CreatedBy { get; init; }
}

View File

@@ -0,0 +1,77 @@
namespace StellaOps.Policy.Storage.Postgres.Models;
/// <summary>
/// Entity representing a risk scoring profile.
/// </summary>
public sealed class RiskProfileEntity
{
/// <summary>
/// Unique identifier.
/// </summary>
public required Guid Id { get; init; }
/// <summary>
/// Tenant identifier.
/// </summary>
public required string TenantId { get; init; }
/// <summary>
/// Profile name unique within tenant and version.
/// </summary>
public required string Name { get; init; }
/// <summary>
/// Human-readable display name.
/// </summary>
public string? DisplayName { get; init; }
/// <summary>
/// Profile description.
/// </summary>
public string? Description { get; init; }
/// <summary>
/// Profile version number.
/// </summary>
public int Version { get; init; } = 1;
/// <summary>
/// Whether this profile is currently active.
/// </summary>
public bool IsActive { get; init; } = true;
/// <summary>
/// Risk thresholds as JSON.
/// </summary>
public string Thresholds { get; init; } = "{}";
/// <summary>
/// Scoring weights as JSON.
/// </summary>
public string ScoringWeights { get; init; } = "{}";
/// <summary>
/// Exemptions list as JSON.
/// </summary>
public string Exemptions { get; init; } = "[]";
/// <summary>
/// Additional metadata as JSON.
/// </summary>
public string Metadata { get; init; } = "{}";
/// <summary>
/// Creation timestamp.
/// </summary>
public DateTimeOffset CreatedAt { get; init; }
/// <summary>
/// Last update timestamp.
/// </summary>
public DateTimeOffset UpdatedAt { get; init; }
/// <summary>
/// User who created this profile.
/// </summary>
public string? CreatedBy { get; init; }
}

View File

@@ -0,0 +1,119 @@
namespace StellaOps.Policy.Storage.Postgres.Models;
/// <summary>
/// Rule type enumeration.
/// </summary>
public enum RuleType
{
/// <summary>
/// OPA Rego rule.
/// </summary>
Rego,
/// <summary>
/// JSON-based rule.
/// </summary>
Json,
/// <summary>
/// YAML-based rule.
/// </summary>
Yaml
}
/// <summary>
/// Rule severity enumeration.
/// </summary>
public enum RuleSeverity
{
/// <summary>
/// Critical severity.
/// </summary>
Critical,
/// <summary>
/// High severity.
/// </summary>
High,
/// <summary>
/// Medium severity.
/// </summary>
Medium,
/// <summary>
/// Low severity.
/// </summary>
Low,
/// <summary>
/// Informational only.
/// </summary>
Info
}
/// <summary>
/// Entity representing a policy rule.
/// </summary>
public sealed class RuleEntity
{
/// <summary>
/// Unique identifier.
/// </summary>
public required Guid Id { get; init; }
/// <summary>
/// Parent pack version identifier.
/// </summary>
public required Guid PackVersionId { get; init; }
/// <summary>
/// Unique rule name within pack version.
/// </summary>
public required string Name { get; init; }
/// <summary>
/// Rule description.
/// </summary>
public string? Description { get; init; }
/// <summary>
/// Type of rule (rego, json, yaml).
/// </summary>
public RuleType RuleType { get; init; } = RuleType.Rego;
/// <summary>
/// Rule content/definition.
/// </summary>
public required string Content { get; init; }
/// <summary>
/// Hash of the rule content.
/// </summary>
public required string ContentHash { get; init; }
/// <summary>
/// Rule severity.
/// </summary>
public RuleSeverity Severity { get; init; } = RuleSeverity.Medium;
/// <summary>
/// Rule category.
/// </summary>
public string? Category { get; init; }
/// <summary>
/// Tags for categorization.
/// </summary>
public string[] Tags { get; init; } = [];
/// <summary>
/// Additional metadata as JSON.
/// </summary>
public string Metadata { get; init; } = "{}";
/// <summary>
/// Creation timestamp.
/// </summary>
public DateTimeOffset CreatedAt { get; init; }
}

View File

@@ -0,0 +1,410 @@
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Infrastructure.Postgres.Repositories;
using StellaOps.Policy.Storage.Postgres.Models;
namespace StellaOps.Policy.Storage.Postgres.Repositories;
/// <summary>
/// PostgreSQL repository for policy evaluation run operations.
/// </summary>
public sealed class EvaluationRunRepository : RepositoryBase<PolicyDataSource>, IEvaluationRunRepository
{
/// <summary>
/// Creates a new evaluation run repository.
/// </summary>
public EvaluationRunRepository(PolicyDataSource dataSource, ILogger<EvaluationRunRepository> logger)
: base(dataSource, logger)
{
}
/// <inheritdoc />
public async Task<EvaluationRunEntity> CreateAsync(EvaluationRunEntity run, CancellationToken cancellationToken = default)
{
const string sql = """
INSERT INTO policy.evaluation_runs (
id, tenant_id, project_id, artifact_id, pack_id, pack_version,
risk_profile_id, status, input_hash, metadata, created_by
)
VALUES (
@id, @tenant_id, @project_id, @artifact_id, @pack_id, @pack_version,
@risk_profile_id, @status, @input_hash, @metadata::jsonb, @created_by
)
RETURNING *
""";
await using var connection = await DataSource.OpenConnectionAsync(run.TenantId, "writer", cancellationToken)
.ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "id", run.Id);
AddParameter(command, "tenant_id", run.TenantId);
AddParameter(command, "project_id", run.ProjectId);
AddParameter(command, "artifact_id", run.ArtifactId);
AddParameter(command, "pack_id", run.PackId);
AddParameter(command, "pack_version", run.PackVersion);
AddParameter(command, "risk_profile_id", run.RiskProfileId);
AddParameter(command, "status", StatusToString(run.Status));
AddParameter(command, "input_hash", run.InputHash);
AddJsonbParameter(command, "metadata", run.Metadata);
AddParameter(command, "created_by", run.CreatedBy);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
return MapRun(reader);
}
/// <inheritdoc />
public async Task<EvaluationRunEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = "SELECT * FROM policy.evaluation_runs WHERE tenant_id = @tenant_id AND id = @id";
return await QuerySingleOrDefaultAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "id", id);
},
MapRun,
cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<IReadOnlyList<EvaluationRunEntity>> GetByProjectIdAsync(
string tenantId,
string projectId,
int limit = 100,
int offset = 0,
CancellationToken cancellationToken = default)
{
const string sql = """
SELECT * FROM policy.evaluation_runs
WHERE tenant_id = @tenant_id AND project_id = @project_id
ORDER BY created_at DESC, id
LIMIT @limit OFFSET @offset
""";
return await QueryAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "project_id", projectId);
AddParameter(cmd, "limit", limit);
AddParameter(cmd, "offset", offset);
},
MapRun,
cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<IReadOnlyList<EvaluationRunEntity>> GetByArtifactIdAsync(
string tenantId,
string artifactId,
int limit = 100,
CancellationToken cancellationToken = default)
{
const string sql = """
SELECT * FROM policy.evaluation_runs
WHERE tenant_id = @tenant_id AND artifact_id = @artifact_id
ORDER BY created_at DESC, id
LIMIT @limit
""";
return await QueryAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "artifact_id", artifactId);
AddParameter(cmd, "limit", limit);
},
MapRun,
cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<IReadOnlyList<EvaluationRunEntity>> GetByStatusAsync(
string tenantId,
EvaluationStatus status,
int limit = 100,
CancellationToken cancellationToken = default)
{
const string sql = """
SELECT * FROM policy.evaluation_runs
WHERE tenant_id = @tenant_id AND status = @status
ORDER BY created_at, id
LIMIT @limit
""";
return await QueryAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "status", StatusToString(status));
AddParameter(cmd, "limit", limit);
},
MapRun,
cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<IReadOnlyList<EvaluationRunEntity>> GetRecentAsync(
string tenantId,
int limit = 50,
CancellationToken cancellationToken = default)
{
const string sql = """
SELECT * FROM policy.evaluation_runs
WHERE tenant_id = @tenant_id
ORDER BY created_at DESC, id
LIMIT @limit
""";
return await QueryAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "limit", limit);
},
MapRun,
cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<bool> MarkStartedAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = """
UPDATE policy.evaluation_runs
SET status = 'running',
started_at = NOW()
WHERE tenant_id = @tenant_id AND id = @id AND status = 'pending'
""";
var rows = await ExecuteAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "id", id);
},
cancellationToken).ConfigureAwait(false);
return rows > 0;
}
/// <inheritdoc />
public async Task<bool> MarkCompletedAsync(
string tenantId,
Guid id,
EvaluationResult result,
decimal? score,
int findingsCount,
int criticalCount,
int highCount,
int mediumCount,
int lowCount,
int durationMs,
CancellationToken cancellationToken = default)
{
const string sql = """
UPDATE policy.evaluation_runs
SET status = 'completed',
result = @result,
score = @score,
findings_count = @findings_count,
critical_count = @critical_count,
high_count = @high_count,
medium_count = @medium_count,
low_count = @low_count,
duration_ms = @duration_ms,
completed_at = NOW()
WHERE tenant_id = @tenant_id AND id = @id AND status = 'running'
""";
var rows = await ExecuteAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "id", id);
AddParameter(cmd, "result", ResultToString(result));
AddParameter(cmd, "score", score);
AddParameter(cmd, "findings_count", findingsCount);
AddParameter(cmd, "critical_count", criticalCount);
AddParameter(cmd, "high_count", highCount);
AddParameter(cmd, "medium_count", mediumCount);
AddParameter(cmd, "low_count", lowCount);
AddParameter(cmd, "duration_ms", durationMs);
},
cancellationToken).ConfigureAwait(false);
return rows > 0;
}
/// <inheritdoc />
public async Task<bool> MarkFailedAsync(
string tenantId,
Guid id,
string errorMessage,
CancellationToken cancellationToken = default)
{
const string sql = """
UPDATE policy.evaluation_runs
SET status = 'failed',
result = 'error',
error_message = @error_message,
completed_at = NOW()
WHERE tenant_id = @tenant_id AND id = @id AND status IN ('pending', 'running')
""";
var rows = await ExecuteAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "id", id);
AddParameter(cmd, "error_message", errorMessage);
},
cancellationToken).ConfigureAwait(false);
return rows > 0;
}
/// <inheritdoc />
public async Task<EvaluationStats> GetStatsAsync(
string tenantId,
DateTimeOffset from,
DateTimeOffset to,
CancellationToken cancellationToken = default)
{
const string sql = """
SELECT
COUNT(*) as total,
COUNT(*) FILTER (WHERE result = 'pass') as passed,
COUNT(*) FILTER (WHERE result = 'fail') as failed,
COUNT(*) FILTER (WHERE result = 'warn') as warned,
COUNT(*) FILTER (WHERE result = 'error') as errored,
AVG(score) as avg_score,
SUM(findings_count) as total_findings,
SUM(critical_count) as critical_findings,
SUM(high_count) as high_findings
FROM policy.evaluation_runs
WHERE tenant_id = @tenant_id
AND created_at >= @from
AND created_at < @to
""";
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken)
.ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "tenant_id", tenantId);
AddParameter(command, "from", from);
AddParameter(command, "to", to);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
return new EvaluationStats(
Total: reader.GetInt64(0),
Passed: reader.GetInt64(1),
Failed: reader.GetInt64(2),
Warned: reader.GetInt64(3),
Errored: reader.GetInt64(4),
AverageScore: reader.IsDBNull(5) ? null : reader.GetDecimal(5),
TotalFindings: reader.IsDBNull(6) ? 0 : reader.GetInt64(6),
CriticalFindings: reader.IsDBNull(7) ? 0 : reader.GetInt64(7),
HighFindings: reader.IsDBNull(8) ? 0 : reader.GetInt64(8));
}
private static EvaluationRunEntity MapRun(NpgsqlDataReader reader) => new()
{
Id = reader.GetGuid(reader.GetOrdinal("id")),
TenantId = reader.GetString(reader.GetOrdinal("tenant_id")),
ProjectId = GetNullableString(reader, reader.GetOrdinal("project_id")),
ArtifactId = GetNullableString(reader, reader.GetOrdinal("artifact_id")),
PackId = GetNullableGuid(reader, reader.GetOrdinal("pack_id")),
PackVersion = GetNullableInt(reader, reader.GetOrdinal("pack_version")),
RiskProfileId = GetNullableGuid(reader, reader.GetOrdinal("risk_profile_id")),
Status = ParseStatus(reader.GetString(reader.GetOrdinal("status"))),
Result = GetNullableResult(reader, reader.GetOrdinal("result")),
Score = GetNullableDecimal(reader, reader.GetOrdinal("score")),
FindingsCount = reader.GetInt32(reader.GetOrdinal("findings_count")),
CriticalCount = reader.GetInt32(reader.GetOrdinal("critical_count")),
HighCount = reader.GetInt32(reader.GetOrdinal("high_count")),
MediumCount = reader.GetInt32(reader.GetOrdinal("medium_count")),
LowCount = reader.GetInt32(reader.GetOrdinal("low_count")),
InputHash = GetNullableString(reader, reader.GetOrdinal("input_hash")),
DurationMs = GetNullableInt(reader, reader.GetOrdinal("duration_ms")),
ErrorMessage = GetNullableString(reader, reader.GetOrdinal("error_message")),
Metadata = reader.GetString(reader.GetOrdinal("metadata")),
CreatedAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("created_at")),
StartedAt = GetNullableDateTimeOffset(reader, reader.GetOrdinal("started_at")),
CompletedAt = GetNullableDateTimeOffset(reader, reader.GetOrdinal("completed_at")),
CreatedBy = GetNullableString(reader, reader.GetOrdinal("created_by"))
};
private static string StatusToString(EvaluationStatus status) => status switch
{
EvaluationStatus.Pending => "pending",
EvaluationStatus.Running => "running",
EvaluationStatus.Completed => "completed",
EvaluationStatus.Failed => "failed",
_ => throw new ArgumentException($"Unknown status: {status}", nameof(status))
};
private static EvaluationStatus ParseStatus(string status) => status switch
{
"pending" => EvaluationStatus.Pending,
"running" => EvaluationStatus.Running,
"completed" => EvaluationStatus.Completed,
"failed" => EvaluationStatus.Failed,
_ => throw new ArgumentException($"Unknown status: {status}", nameof(status))
};
private static string ResultToString(EvaluationResult result) => result switch
{
EvaluationResult.Pass => "pass",
EvaluationResult.Fail => "fail",
EvaluationResult.Warn => "warn",
EvaluationResult.Error => "error",
_ => throw new ArgumentException($"Unknown result: {result}", nameof(result))
};
private static EvaluationResult ParseResult(string result) => result switch
{
"pass" => EvaluationResult.Pass,
"fail" => EvaluationResult.Fail,
"warn" => EvaluationResult.Warn,
"error" => EvaluationResult.Error,
_ => throw new ArgumentException($"Unknown result: {result}", nameof(result))
};
private static int? GetNullableInt(NpgsqlDataReader reader, int ordinal)
{
return reader.IsDBNull(ordinal) ? null : reader.GetInt32(ordinal);
}
private static decimal? GetNullableDecimal(NpgsqlDataReader reader, int ordinal)
{
return reader.IsDBNull(ordinal) ? null : reader.GetDecimal(ordinal);
}
private static EvaluationResult? GetNullableResult(NpgsqlDataReader reader, int ordinal)
{
return reader.IsDBNull(ordinal) ? null : ParseResult(reader.GetString(ordinal));
}
}

View File

@@ -0,0 +1,349 @@
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Infrastructure.Postgres.Repositories;
using StellaOps.Policy.Storage.Postgres.Models;
namespace StellaOps.Policy.Storage.Postgres.Repositories;
/// <summary>
/// PostgreSQL repository for policy exception operations.
/// </summary>
public sealed class ExceptionRepository : RepositoryBase<PolicyDataSource>, IExceptionRepository
{
/// <summary>
/// Creates a new exception repository.
/// </summary>
public ExceptionRepository(PolicyDataSource dataSource, ILogger<ExceptionRepository> logger)
: base(dataSource, logger)
{
}
/// <inheritdoc />
public async Task<ExceptionEntity> CreateAsync(ExceptionEntity exception, CancellationToken cancellationToken = default)
{
const string sql = """
INSERT INTO policy.exceptions (
id, tenant_id, name, description, rule_pattern, resource_pattern,
artifact_pattern, project_id, reason, status, expires_at, metadata, created_by
)
VALUES (
@id, @tenant_id, @name, @description, @rule_pattern, @resource_pattern,
@artifact_pattern, @project_id, @reason, @status, @expires_at, @metadata::jsonb, @created_by
)
RETURNING *
""";
await using var connection = await DataSource.OpenConnectionAsync(exception.TenantId, "writer", cancellationToken)
.ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddExceptionParameters(command, exception);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
return MapException(reader);
}
/// <inheritdoc />
public async Task<ExceptionEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = "SELECT * FROM policy.exceptions WHERE tenant_id = @tenant_id AND id = @id";
return await QuerySingleOrDefaultAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "id", id);
},
MapException,
cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<ExceptionEntity?> GetByNameAsync(string tenantId, string name, CancellationToken cancellationToken = default)
{
const string sql = "SELECT * FROM policy.exceptions WHERE tenant_id = @tenant_id AND name = @name";
return await QuerySingleOrDefaultAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "name", name);
},
MapException,
cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<IReadOnlyList<ExceptionEntity>> GetAllAsync(
string tenantId,
ExceptionStatus? status = null,
int limit = 100,
int offset = 0,
CancellationToken cancellationToken = default)
{
var sql = "SELECT * FROM policy.exceptions WHERE tenant_id = @tenant_id";
if (status.HasValue)
{
sql += " AND status = @status";
}
sql += " ORDER BY name, id LIMIT @limit OFFSET @offset";
return await QueryAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
if (status.HasValue)
{
AddParameter(cmd, "status", StatusToString(status.Value));
}
AddParameter(cmd, "limit", limit);
AddParameter(cmd, "offset", offset);
},
MapException,
cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<IReadOnlyList<ExceptionEntity>> GetActiveForProjectAsync(
string tenantId,
string projectId,
CancellationToken cancellationToken = default)
{
const string sql = """
SELECT * FROM policy.exceptions
WHERE tenant_id = @tenant_id
AND status = 'active'
AND (expires_at IS NULL OR expires_at > NOW())
AND (project_id IS NULL OR project_id = @project_id)
ORDER BY name, id
""";
return await QueryAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "project_id", projectId);
},
MapException,
cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<IReadOnlyList<ExceptionEntity>> GetActiveForRuleAsync(
string tenantId,
string ruleName,
CancellationToken cancellationToken = default)
{
const string sql = """
SELECT * FROM policy.exceptions
WHERE tenant_id = @tenant_id
AND status = 'active'
AND (expires_at IS NULL OR expires_at > NOW())
AND (rule_pattern IS NULL OR @rule_name ~ rule_pattern)
ORDER BY name, id
""";
return await QueryAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "rule_name", ruleName);
},
MapException,
cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<bool> UpdateAsync(ExceptionEntity exception, CancellationToken cancellationToken = default)
{
const string sql = """
UPDATE policy.exceptions
SET name = @name,
description = @description,
rule_pattern = @rule_pattern,
resource_pattern = @resource_pattern,
artifact_pattern = @artifact_pattern,
project_id = @project_id,
reason = @reason,
expires_at = @expires_at,
metadata = @metadata::jsonb
WHERE tenant_id = @tenant_id AND id = @id AND status = 'active'
""";
var rows = await ExecuteAsync(
exception.TenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", exception.TenantId);
AddParameter(cmd, "id", exception.Id);
AddParameter(cmd, "name", exception.Name);
AddParameter(cmd, "description", exception.Description);
AddParameter(cmd, "rule_pattern", exception.RulePattern);
AddParameter(cmd, "resource_pattern", exception.ResourcePattern);
AddParameter(cmd, "artifact_pattern", exception.ArtifactPattern);
AddParameter(cmd, "project_id", exception.ProjectId);
AddParameter(cmd, "reason", exception.Reason);
AddParameter(cmd, "expires_at", exception.ExpiresAt);
AddJsonbParameter(cmd, "metadata", exception.Metadata);
},
cancellationToken).ConfigureAwait(false);
return rows > 0;
}
/// <inheritdoc />
public async Task<bool> ApproveAsync(string tenantId, Guid id, string approvedBy, CancellationToken cancellationToken = default)
{
const string sql = """
UPDATE policy.exceptions
SET approved_by = @approved_by,
approved_at = NOW()
WHERE tenant_id = @tenant_id AND id = @id AND status = 'active'
""";
var rows = await ExecuteAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "id", id);
AddParameter(cmd, "approved_by", approvedBy);
},
cancellationToken).ConfigureAwait(false);
return rows > 0;
}
/// <inheritdoc />
public async Task<bool> RevokeAsync(string tenantId, Guid id, string revokedBy, CancellationToken cancellationToken = default)
{
const string sql = """
UPDATE policy.exceptions
SET status = 'revoked',
revoked_by = @revoked_by,
revoked_at = NOW()
WHERE tenant_id = @tenant_id AND id = @id AND status = 'active'
""";
var rows = await ExecuteAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "id", id);
AddParameter(cmd, "revoked_by", revokedBy);
},
cancellationToken).ConfigureAwait(false);
return rows > 0;
}
/// <inheritdoc />
public async Task<int> ExpireAsync(string tenantId, CancellationToken cancellationToken = default)
{
const string sql = """
UPDATE policy.exceptions
SET status = 'expired'
WHERE tenant_id = @tenant_id
AND status = 'active'
AND expires_at IS NOT NULL
AND expires_at <= NOW()
""";
return await ExecuteAsync(
tenantId,
sql,
cmd => AddParameter(cmd, "tenant_id", tenantId),
cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<bool> DeleteAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = "DELETE FROM policy.exceptions WHERE tenant_id = @tenant_id AND id = @id";
var rows = await ExecuteAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "id", id);
},
cancellationToken).ConfigureAwait(false);
return rows > 0;
}
private static void AddExceptionParameters(NpgsqlCommand command, ExceptionEntity exception)
{
AddParameter(command, "id", exception.Id);
AddParameter(command, "tenant_id", exception.TenantId);
AddParameter(command, "name", exception.Name);
AddParameter(command, "description", exception.Description);
AddParameter(command, "rule_pattern", exception.RulePattern);
AddParameter(command, "resource_pattern", exception.ResourcePattern);
AddParameter(command, "artifact_pattern", exception.ArtifactPattern);
AddParameter(command, "project_id", exception.ProjectId);
AddParameter(command, "reason", exception.Reason);
AddParameter(command, "status", StatusToString(exception.Status));
AddParameter(command, "expires_at", exception.ExpiresAt);
AddJsonbParameter(command, "metadata", exception.Metadata);
AddParameter(command, "created_by", exception.CreatedBy);
}
private static ExceptionEntity MapException(NpgsqlDataReader reader) => new()
{
Id = reader.GetGuid(reader.GetOrdinal("id")),
TenantId = reader.GetString(reader.GetOrdinal("tenant_id")),
Name = reader.GetString(reader.GetOrdinal("name")),
Description = GetNullableString(reader, reader.GetOrdinal("description")),
RulePattern = GetNullableString(reader, reader.GetOrdinal("rule_pattern")),
ResourcePattern = GetNullableString(reader, reader.GetOrdinal("resource_pattern")),
ArtifactPattern = GetNullableString(reader, reader.GetOrdinal("artifact_pattern")),
ProjectId = GetNullableString(reader, reader.GetOrdinal("project_id")),
Reason = reader.GetString(reader.GetOrdinal("reason")),
Status = ParseStatus(reader.GetString(reader.GetOrdinal("status"))),
ExpiresAt = GetNullableDateTimeOffset(reader, reader.GetOrdinal("expires_at")),
ApprovedBy = GetNullableString(reader, reader.GetOrdinal("approved_by")),
ApprovedAt = GetNullableDateTimeOffset(reader, reader.GetOrdinal("approved_at")),
RevokedBy = GetNullableString(reader, reader.GetOrdinal("revoked_by")),
RevokedAt = GetNullableDateTimeOffset(reader, reader.GetOrdinal("revoked_at")),
Metadata = reader.GetString(reader.GetOrdinal("metadata")),
CreatedAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("created_at")),
CreatedBy = GetNullableString(reader, reader.GetOrdinal("created_by"))
};
private static string StatusToString(ExceptionStatus status) => status switch
{
ExceptionStatus.Active => "active",
ExceptionStatus.Expired => "expired",
ExceptionStatus.Revoked => "revoked",
_ => throw new ArgumentException($"Unknown status: {status}", nameof(status))
};
private static ExceptionStatus ParseStatus(string status) => status switch
{
"active" => ExceptionStatus.Active,
"expired" => ExceptionStatus.Expired,
"revoked" => ExceptionStatus.Revoked,
_ => throw new ArgumentException($"Unknown status: {status}", nameof(status))
};
}

View File

@@ -0,0 +1,108 @@
using StellaOps.Policy.Storage.Postgres.Models;
namespace StellaOps.Policy.Storage.Postgres.Repositories;
/// <summary>
/// Repository interface for policy evaluation run operations.
/// </summary>
public interface IEvaluationRunRepository
{
/// <summary>
/// Creates a new evaluation run.
/// </summary>
Task<EvaluationRunEntity> CreateAsync(EvaluationRunEntity run, CancellationToken cancellationToken = default);
/// <summary>
/// Gets an evaluation run by ID.
/// </summary>
Task<EvaluationRunEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default);
/// <summary>
/// Gets evaluation runs for a project.
/// </summary>
Task<IReadOnlyList<EvaluationRunEntity>> GetByProjectIdAsync(
string tenantId,
string projectId,
int limit = 100,
int offset = 0,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets evaluation runs for an artifact.
/// </summary>
Task<IReadOnlyList<EvaluationRunEntity>> GetByArtifactIdAsync(
string tenantId,
string artifactId,
int limit = 100,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets evaluation runs by status.
/// </summary>
Task<IReadOnlyList<EvaluationRunEntity>> GetByStatusAsync(
string tenantId,
EvaluationStatus status,
int limit = 100,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets recent evaluation runs.
/// </summary>
Task<IReadOnlyList<EvaluationRunEntity>> GetRecentAsync(
string tenantId,
int limit = 50,
CancellationToken cancellationToken = default);
/// <summary>
/// Marks an evaluation as started.
/// </summary>
Task<bool> MarkStartedAsync(string tenantId, Guid id, CancellationToken cancellationToken = default);
/// <summary>
/// Marks an evaluation as completed.
/// </summary>
Task<bool> MarkCompletedAsync(
string tenantId,
Guid id,
EvaluationResult result,
decimal? score,
int findingsCount,
int criticalCount,
int highCount,
int mediumCount,
int lowCount,
int durationMs,
CancellationToken cancellationToken = default);
/// <summary>
/// Marks an evaluation as failed.
/// </summary>
Task<bool> MarkFailedAsync(
string tenantId,
Guid id,
string errorMessage,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets evaluation statistics for a tenant.
/// </summary>
Task<EvaluationStats> GetStatsAsync(
string tenantId,
DateTimeOffset from,
DateTimeOffset to,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Evaluation statistics.
/// </summary>
public sealed record EvaluationStats(
long Total,
long Passed,
long Failed,
long Warned,
long Errored,
decimal? AverageScore,
long TotalFindings,
long CriticalFindings,
long HighFindings);

View File

@@ -0,0 +1,75 @@
using StellaOps.Policy.Storage.Postgres.Models;
namespace StellaOps.Policy.Storage.Postgres.Repositories;
/// <summary>
/// Repository interface for policy exception operations.
/// </summary>
public interface IExceptionRepository
{
/// <summary>
/// Creates a new exception.
/// </summary>
Task<ExceptionEntity> CreateAsync(ExceptionEntity exception, CancellationToken cancellationToken = default);
/// <summary>
/// Gets an exception by ID.
/// </summary>
Task<ExceptionEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default);
/// <summary>
/// Gets an exception by name.
/// </summary>
Task<ExceptionEntity?> GetByNameAsync(string tenantId, string name, CancellationToken cancellationToken = default);
/// <summary>
/// Gets all exceptions for a tenant.
/// </summary>
Task<IReadOnlyList<ExceptionEntity>> GetAllAsync(
string tenantId,
ExceptionStatus? status = null,
int limit = 100,
int offset = 0,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets active exceptions for a project.
/// </summary>
Task<IReadOnlyList<ExceptionEntity>> GetActiveForProjectAsync(
string tenantId,
string projectId,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets active exceptions matching a rule pattern.
/// </summary>
Task<IReadOnlyList<ExceptionEntity>> GetActiveForRuleAsync(
string tenantId,
string ruleName,
CancellationToken cancellationToken = default);
/// <summary>
/// Updates an exception.
/// </summary>
Task<bool> UpdateAsync(ExceptionEntity exception, CancellationToken cancellationToken = default);
/// <summary>
/// Approves an exception.
/// </summary>
Task<bool> ApproveAsync(string tenantId, Guid id, string approvedBy, CancellationToken cancellationToken = default);
/// <summary>
/// Revokes an exception.
/// </summary>
Task<bool> RevokeAsync(string tenantId, Guid id, string revokedBy, CancellationToken cancellationToken = default);
/// <summary>
/// Expires exceptions that have passed their expiration date.
/// </summary>
Task<int> ExpireAsync(string tenantId, CancellationToken cancellationToken = default);
/// <summary>
/// Deletes an exception.
/// </summary>
Task<bool> DeleteAsync(string tenantId, Guid id, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,66 @@
using StellaOps.Policy.Storage.Postgres.Models;
namespace StellaOps.Policy.Storage.Postgres.Repositories;
/// <summary>
/// Repository interface for policy pack operations.
/// </summary>
public interface IPackRepository
{
/// <summary>
/// Creates a new pack.
/// </summary>
Task<PackEntity> CreateAsync(PackEntity pack, CancellationToken cancellationToken = default);
/// <summary>
/// Gets a pack by ID.
/// </summary>
Task<PackEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default);
/// <summary>
/// Gets a pack by name.
/// </summary>
Task<PackEntity?> GetByNameAsync(string tenantId, string name, CancellationToken cancellationToken = default);
/// <summary>
/// Gets all packs for a tenant.
/// </summary>
Task<IReadOnlyList<PackEntity>> GetAllAsync(
string tenantId,
bool? includeBuiltin = true,
bool? includeDeprecated = false,
int limit = 100,
int offset = 0,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets built-in packs.
/// </summary>
Task<IReadOnlyList<PackEntity>> GetBuiltinAsync(
string tenantId,
CancellationToken cancellationToken = default);
/// <summary>
/// Updates a pack.
/// </summary>
Task<bool> UpdateAsync(PackEntity pack, CancellationToken cancellationToken = default);
/// <summary>
/// Sets the active version for a pack.
/// </summary>
Task<bool> SetActiveVersionAsync(
string tenantId,
Guid id,
int version,
CancellationToken cancellationToken = default);
/// <summary>
/// Deprecates a pack.
/// </summary>
Task<bool> DeprecateAsync(string tenantId, Guid id, CancellationToken cancellationToken = default);
/// <summary>
/// Deletes a pack.
/// </summary>
Task<bool> DeleteAsync(string tenantId, Guid id, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,50 @@
using StellaOps.Policy.Storage.Postgres.Models;
namespace StellaOps.Policy.Storage.Postgres.Repositories;
/// <summary>
/// Repository interface for policy pack version operations.
/// </summary>
public interface IPackVersionRepository
{
/// <summary>
/// Creates a new pack version.
/// </summary>
Task<PackVersionEntity> CreateAsync(PackVersionEntity version, CancellationToken cancellationToken = default);
/// <summary>
/// Gets a pack version by ID.
/// </summary>
Task<PackVersionEntity?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
/// <summary>
/// Gets a specific version of a pack.
/// </summary>
Task<PackVersionEntity?> GetByPackAndVersionAsync(
Guid packId,
int version,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets the latest version of a pack.
/// </summary>
Task<PackVersionEntity?> GetLatestAsync(Guid packId, CancellationToken cancellationToken = default);
/// <summary>
/// Gets all versions of a pack.
/// </summary>
Task<IReadOnlyList<PackVersionEntity>> GetByPackIdAsync(
Guid packId,
bool? publishedOnly = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Publishes a pack version.
/// </summary>
Task<bool> PublishAsync(Guid id, string? publishedBy, CancellationToken cancellationToken = default);
/// <summary>
/// Gets the next version number for a pack.
/// </summary>
Task<int> GetNextVersionAsync(Guid packId, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,74 @@
using StellaOps.Policy.Storage.Postgres.Models;
namespace StellaOps.Policy.Storage.Postgres.Repositories;
/// <summary>
/// Repository interface for risk profile operations.
/// </summary>
public interface IRiskProfileRepository
{
/// <summary>
/// Creates a new risk profile.
/// </summary>
Task<RiskProfileEntity> CreateAsync(RiskProfileEntity profile, CancellationToken cancellationToken = default);
/// <summary>
/// Gets a risk profile by ID.
/// </summary>
Task<RiskProfileEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default);
/// <summary>
/// Gets the active version of a profile by name.
/// </summary>
Task<RiskProfileEntity?> GetActiveByNameAsync(
string tenantId,
string name,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets all risk profiles for a tenant.
/// </summary>
Task<IReadOnlyList<RiskProfileEntity>> GetAllAsync(
string tenantId,
bool? activeOnly = true,
int limit = 100,
int offset = 0,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets all versions of a profile by name.
/// </summary>
Task<IReadOnlyList<RiskProfileEntity>> GetVersionsByNameAsync(
string tenantId,
string name,
CancellationToken cancellationToken = default);
/// <summary>
/// Updates a risk profile.
/// </summary>
Task<bool> UpdateAsync(RiskProfileEntity profile, CancellationToken cancellationToken = default);
/// <summary>
/// Creates a new version of a profile.
/// </summary>
Task<RiskProfileEntity> CreateVersionAsync(
string tenantId,
string name,
RiskProfileEntity newProfile,
CancellationToken cancellationToken = default);
/// <summary>
/// Activates a specific profile version.
/// </summary>
Task<bool> ActivateAsync(string tenantId, Guid id, CancellationToken cancellationToken = default);
/// <summary>
/// Deactivates a profile.
/// </summary>
Task<bool> DeactivateAsync(string tenantId, Guid id, CancellationToken cancellationToken = default);
/// <summary>
/// Deletes a risk profile.
/// </summary>
Task<bool> DeleteAsync(string tenantId, Guid id, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,68 @@
using StellaOps.Policy.Storage.Postgres.Models;
namespace StellaOps.Policy.Storage.Postgres.Repositories;
/// <summary>
/// Repository interface for policy rule operations.
/// </summary>
public interface IRuleRepository
{
/// <summary>
/// Creates a new rule.
/// </summary>
Task<RuleEntity> CreateAsync(RuleEntity rule, CancellationToken cancellationToken = default);
/// <summary>
/// Creates multiple rules in a batch.
/// </summary>
Task<int> CreateBatchAsync(IEnumerable<RuleEntity> rules, CancellationToken cancellationToken = default);
/// <summary>
/// Gets a rule by ID.
/// </summary>
Task<RuleEntity?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
/// <summary>
/// Gets a rule by name within a pack version.
/// </summary>
Task<RuleEntity?> GetByNameAsync(
Guid packVersionId,
string name,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets all rules for a pack version.
/// </summary>
Task<IReadOnlyList<RuleEntity>> GetByPackVersionIdAsync(
Guid packVersionId,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets rules by severity.
/// </summary>
Task<IReadOnlyList<RuleEntity>> GetBySeverityAsync(
Guid packVersionId,
RuleSeverity severity,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets rules by category.
/// </summary>
Task<IReadOnlyList<RuleEntity>> GetByCategoryAsync(
Guid packVersionId,
string category,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets rules by tag.
/// </summary>
Task<IReadOnlyList<RuleEntity>> GetByTagAsync(
Guid packVersionId,
string tag,
CancellationToken cancellationToken = default);
/// <summary>
/// Counts rules in a pack version.
/// </summary>
Task<int> CountByPackVersionIdAsync(Guid packVersionId, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,268 @@
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Infrastructure.Postgres.Repositories;
using StellaOps.Policy.Storage.Postgres.Models;
namespace StellaOps.Policy.Storage.Postgres.Repositories;
/// <summary>
/// PostgreSQL repository for policy pack operations.
/// </summary>
public sealed class PackRepository : RepositoryBase<PolicyDataSource>, IPackRepository
{
/// <summary>
/// Creates a new pack repository.
/// </summary>
public PackRepository(PolicyDataSource dataSource, ILogger<PackRepository> logger)
: base(dataSource, logger)
{
}
/// <inheritdoc />
public async Task<PackEntity> CreateAsync(PackEntity pack, CancellationToken cancellationToken = default)
{
const string sql = """
INSERT INTO policy.packs (
id, tenant_id, name, display_name, description, active_version,
is_builtin, is_deprecated, metadata, created_by
)
VALUES (
@id, @tenant_id, @name, @display_name, @description, @active_version,
@is_builtin, @is_deprecated, @metadata::jsonb, @created_by
)
RETURNING *
""";
await using var connection = await DataSource.OpenConnectionAsync(pack.TenantId, "writer", cancellationToken)
.ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "id", pack.Id);
AddParameter(command, "tenant_id", pack.TenantId);
AddParameter(command, "name", pack.Name);
AddParameter(command, "display_name", pack.DisplayName);
AddParameter(command, "description", pack.Description);
AddParameter(command, "active_version", pack.ActiveVersion);
AddParameter(command, "is_builtin", pack.IsBuiltin);
AddParameter(command, "is_deprecated", pack.IsDeprecated);
AddJsonbParameter(command, "metadata", pack.Metadata);
AddParameter(command, "created_by", pack.CreatedBy);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
return MapPack(reader);
}
/// <inheritdoc />
public async Task<PackEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = "SELECT * FROM policy.packs WHERE tenant_id = @tenant_id AND id = @id";
return await QuerySingleOrDefaultAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "id", id);
},
MapPack,
cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<PackEntity?> GetByNameAsync(string tenantId, string name, CancellationToken cancellationToken = default)
{
const string sql = "SELECT * FROM policy.packs WHERE tenant_id = @tenant_id AND name = @name";
return await QuerySingleOrDefaultAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "name", name);
},
MapPack,
cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<IReadOnlyList<PackEntity>> GetAllAsync(
string tenantId,
bool? includeBuiltin = true,
bool? includeDeprecated = false,
int limit = 100,
int offset = 0,
CancellationToken cancellationToken = default)
{
var sql = "SELECT * FROM policy.packs WHERE tenant_id = @tenant_id";
if (includeBuiltin == false)
{
sql += " AND is_builtin = FALSE";
}
if (includeDeprecated == false)
{
sql += " AND is_deprecated = FALSE";
}
sql += " ORDER BY name, id LIMIT @limit OFFSET @offset";
return await QueryAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "limit", limit);
AddParameter(cmd, "offset", offset);
},
MapPack,
cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<IReadOnlyList<PackEntity>> GetBuiltinAsync(
string tenantId,
CancellationToken cancellationToken = default)
{
const string sql = """
SELECT * FROM policy.packs
WHERE tenant_id = @tenant_id AND is_builtin = TRUE AND is_deprecated = FALSE
ORDER BY name, id
""";
return await QueryAsync(
tenantId,
sql,
cmd => AddParameter(cmd, "tenant_id", tenantId),
MapPack,
cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<bool> UpdateAsync(PackEntity pack, CancellationToken cancellationToken = default)
{
const string sql = """
UPDATE policy.packs
SET name = @name,
display_name = @display_name,
description = @description,
is_deprecated = @is_deprecated,
metadata = @metadata::jsonb
WHERE tenant_id = @tenant_id AND id = @id AND is_builtin = FALSE
""";
var rows = await ExecuteAsync(
pack.TenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", pack.TenantId);
AddParameter(cmd, "id", pack.Id);
AddParameter(cmd, "name", pack.Name);
AddParameter(cmd, "display_name", pack.DisplayName);
AddParameter(cmd, "description", pack.Description);
AddParameter(cmd, "is_deprecated", pack.IsDeprecated);
AddJsonbParameter(cmd, "metadata", pack.Metadata);
},
cancellationToken).ConfigureAwait(false);
return rows > 0;
}
/// <inheritdoc />
public async Task<bool> SetActiveVersionAsync(
string tenantId,
Guid id,
int version,
CancellationToken cancellationToken = default)
{
const string sql = """
UPDATE policy.packs
SET active_version = @version
WHERE tenant_id = @tenant_id AND id = @id
AND EXISTS (
SELECT 1 FROM policy.pack_versions pv
WHERE pv.pack_id = @id AND pv.version = @version AND pv.is_published = TRUE
)
""";
var rows = await ExecuteAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "id", id);
AddParameter(cmd, "version", version);
},
cancellationToken).ConfigureAwait(false);
return rows > 0;
}
/// <inheritdoc />
public async Task<bool> DeprecateAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = """
UPDATE policy.packs
SET is_deprecated = TRUE
WHERE tenant_id = @tenant_id AND id = @id
""";
var rows = await ExecuteAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "id", id);
},
cancellationToken).ConfigureAwait(false);
return rows > 0;
}
/// <inheritdoc />
public async Task<bool> DeleteAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = "DELETE FROM policy.packs WHERE tenant_id = @tenant_id AND id = @id AND is_builtin = FALSE";
var rows = await ExecuteAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "id", id);
},
cancellationToken).ConfigureAwait(false);
return rows > 0;
}
private static PackEntity MapPack(NpgsqlDataReader reader) => new()
{
Id = reader.GetGuid(reader.GetOrdinal("id")),
TenantId = reader.GetString(reader.GetOrdinal("tenant_id")),
Name = reader.GetString(reader.GetOrdinal("name")),
DisplayName = GetNullableString(reader, reader.GetOrdinal("display_name")),
Description = GetNullableString(reader, reader.GetOrdinal("description")),
ActiveVersion = GetNullableInt(reader, reader.GetOrdinal("active_version")),
IsBuiltin = reader.GetBoolean(reader.GetOrdinal("is_builtin")),
IsDeprecated = reader.GetBoolean(reader.GetOrdinal("is_deprecated")),
Metadata = reader.GetString(reader.GetOrdinal("metadata")),
CreatedAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("created_at")),
UpdatedAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("updated_at")),
CreatedBy = GetNullableString(reader, reader.GetOrdinal("created_by"))
};
private static int? GetNullableInt(NpgsqlDataReader reader, int ordinal)
{
return reader.IsDBNull(ordinal) ? null : reader.GetInt32(ordinal);
}
}

View File

@@ -0,0 +1,212 @@
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Infrastructure.Postgres.Repositories;
using StellaOps.Policy.Storage.Postgres.Models;
namespace StellaOps.Policy.Storage.Postgres.Repositories;
/// <summary>
/// PostgreSQL repository for policy pack version operations.
/// Note: pack_versions table doesn't have tenant_id; tenant context comes from parent pack.
/// </summary>
public sealed class PackVersionRepository : RepositoryBase<PolicyDataSource>, IPackVersionRepository
{
/// <summary>
/// Creates a new pack version repository.
/// </summary>
public PackVersionRepository(PolicyDataSource dataSource, ILogger<PackVersionRepository> logger)
: base(dataSource, logger)
{
}
/// <inheritdoc />
public async Task<PackVersionEntity> CreateAsync(PackVersionEntity version, CancellationToken cancellationToken = default)
{
const string sql = """
INSERT INTO policy.pack_versions (
id, pack_id, version, description, rules_hash,
is_published, published_at, published_by, created_by
)
VALUES (
@id, @pack_id, @version, @description, @rules_hash,
@is_published, @published_at, @published_by, @created_by
)
RETURNING *
""";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken)
.ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "id", version.Id);
AddParameter(command, "pack_id", version.PackId);
AddParameter(command, "version", version.Version);
AddParameter(command, "description", version.Description);
AddParameter(command, "rules_hash", version.RulesHash);
AddParameter(command, "is_published", version.IsPublished);
AddParameter(command, "published_at", version.PublishedAt);
AddParameter(command, "published_by", version.PublishedBy);
AddParameter(command, "created_by", version.CreatedBy);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
return MapPackVersion(reader);
}
/// <inheritdoc />
public async Task<PackVersionEntity?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
{
const string sql = "SELECT * FROM policy.pack_versions WHERE id = @id";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken)
.ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "id", id);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
return null;
}
return MapPackVersion(reader);
}
/// <inheritdoc />
public async Task<PackVersionEntity?> GetByPackAndVersionAsync(
Guid packId,
int version,
CancellationToken cancellationToken = default)
{
const string sql = "SELECT * FROM policy.pack_versions WHERE pack_id = @pack_id AND version = @version";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken)
.ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "pack_id", packId);
AddParameter(command, "version", version);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
return null;
}
return MapPackVersion(reader);
}
/// <inheritdoc />
public async Task<PackVersionEntity?> GetLatestAsync(Guid packId, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT * FROM policy.pack_versions
WHERE pack_id = @pack_id
ORDER BY version DESC
LIMIT 1
""";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken)
.ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "pack_id", packId);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
return null;
}
return MapPackVersion(reader);
}
/// <inheritdoc />
public async Task<IReadOnlyList<PackVersionEntity>> GetByPackIdAsync(
Guid packId,
bool? publishedOnly = null,
CancellationToken cancellationToken = default)
{
var sql = "SELECT * FROM policy.pack_versions WHERE pack_id = @pack_id";
if (publishedOnly == true)
{
sql += " AND is_published = TRUE";
}
sql += " ORDER BY version DESC";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken)
.ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "pack_id", packId);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
var results = new List<PackVersionEntity>();
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
results.Add(MapPackVersion(reader));
}
return results;
}
/// <inheritdoc />
public async Task<bool> PublishAsync(Guid id, string? publishedBy, CancellationToken cancellationToken = default)
{
const string sql = """
UPDATE policy.pack_versions
SET is_published = TRUE,
published_at = NOW(),
published_by = @published_by
WHERE id = @id AND is_published = FALSE
""";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken)
.ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "id", id);
AddParameter(command, "published_by", publishedBy);
var rows = await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
return rows > 0;
}
/// <inheritdoc />
public async Task<int> GetNextVersionAsync(Guid packId, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT COALESCE(MAX(version), 0) + 1
FROM policy.pack_versions
WHERE pack_id = @pack_id
""";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken)
.ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "pack_id", packId);
var result = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
return Convert.ToInt32(result);
}
private static PackVersionEntity MapPackVersion(NpgsqlDataReader reader) => new()
{
Id = reader.GetGuid(reader.GetOrdinal("id")),
PackId = reader.GetGuid(reader.GetOrdinal("pack_id")),
Version = reader.GetInt32(reader.GetOrdinal("version")),
Description = GetNullableString(reader, reader.GetOrdinal("description")),
RulesHash = reader.GetString(reader.GetOrdinal("rules_hash")),
IsPublished = reader.GetBoolean(reader.GetOrdinal("is_published")),
PublishedAt = GetNullableDateTimeOffset(reader, reader.GetOrdinal("published_at")),
PublishedBy = GetNullableString(reader, reader.GetOrdinal("published_by")),
CreatedAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("created_at")),
CreatedBy = GetNullableString(reader, reader.GetOrdinal("created_by"))
};
}

View File

@@ -0,0 +1,265 @@
using System.Text.Json;
using System;
using Microsoft.Extensions.Logging;
using Npgsql;
using NpgsqlTypes;
using StellaOps.Infrastructure.Postgres.Repositories;
using StellaOps.Policy.Scoring;
using StellaOps.Policy.Scoring.Receipts;
namespace StellaOps.Policy.Storage.Postgres.Repositories;
/// <summary>
/// PostgreSQL repository for CVSS score receipts.
/// </summary>
public sealed class PostgresReceiptRepository : RepositoryBase<PolicyDataSource>, IReceiptRepository
{
private const string Columns = "id, tenant_id, vulnerability_id, receipt_format, schema_version, cvss_version, vector, severity, base_score, threat_score, environmental_score, full_score, effective_score, effective_score_type, policy_id, policy_version, policy_hash, base_metrics, threat_metrics, environmental_metrics, supplemental_metrics, evidence, attestation_refs, input_hash, created_at, created_by, modified_at, modified_by, history, amends_receipt_id, is_active, superseded_reason";
private static readonly JsonSerializerOptions JsonOptions = ReceiptBuilder.SerializerOptions;
public PostgresReceiptRepository(PolicyDataSource dataSource, ILogger<PostgresReceiptRepository> logger)
: base(dataSource, logger)
{
}
public async Task<CvssScoreReceipt> SaveAsync(string tenantId, CvssScoreReceipt receipt, CancellationToken cancellationToken = default)
{
const string sql = $@"insert into policy.cvss_receipts (
{Columns}
) values (
@id, @tenant_id, @vulnerability_id, @receipt_format, @schema_version, @cvss_version,
@vector, @severity, @base_score, @threat_score, @environmental_score, @full_score,
@effective_score, @effective_score_type,
@policy_id, @policy_version, @policy_hash,
@base_metrics, @threat_metrics, @environmental_metrics, @supplemental_metrics,
@evidence, @attestation_refs, @input_hash,
@created_at, @created_by, @modified_at, @modified_by, @history, @amends_receipt_id, @is_active, @superseded_reason
) returning {Columns};";
return await QuerySingleOrDefaultAsync(tenantId, sql, cmd => BindParameters(cmd, tenantId, receipt), Map, cancellationToken)
?? throw new InvalidOperationException("Failed to insert receipt.");
}
public async Task<CvssScoreReceipt?> GetAsync(string tenantId, string receiptId, CancellationToken cancellationToken = default)
{
const string sql = $@"select {Columns}
from policy.cvss_receipts
where tenant_id = @tenant_id and id = @id";
return await QuerySingleOrDefaultAsync(tenantId, sql, cmd =>
{
cmd.Parameters.AddWithValue("tenant_id", Guid.Parse(tenantId));
cmd.Parameters.AddWithValue("id", Guid.Parse(receiptId));
}, Map, cancellationToken);
}
public async Task<CvssScoreReceipt> UpdateAsync(string tenantId, CvssScoreReceipt receipt, CancellationToken cancellationToken = default)
{
const string sql = $@"update policy.cvss_receipts set
vulnerability_id = @vulnerability_id,
receipt_format = @receipt_format,
schema_version = @schema_version,
cvss_version = @cvss_version,
vector = @vector,
severity = @severity,
base_score = @base_score,
threat_score = @threat_score,
environmental_score = @environmental_score,
full_score = @full_score,
effective_score = @effective_score,
effective_score_type = @effective_score_type,
policy_id = @policy_id,
policy_version = @policy_version,
policy_hash = @policy_hash,
base_metrics = @base_metrics,
threat_metrics = @threat_metrics,
environmental_metrics = @environmental_metrics,
supplemental_metrics = @supplemental_metrics,
evidence = @evidence,
attestation_refs = @attestation_refs,
input_hash = @input_hash,
created_at = @created_at,
created_by = @created_by,
modified_at = @modified_at,
modified_by = @modified_by,
history = @history,
amends_receipt_id = @amends_receipt_id,
is_active = @is_active,
superseded_reason = @superseded_reason
where tenant_id = @tenant_id and id = @id
returning {Columns};";
return await QuerySingleOrDefaultAsync(tenantId, sql, cmd => BindParameters(cmd, tenantId, receipt), Map, cancellationToken)
?? throw new InvalidOperationException("Failed to update receipt.");
}
private static void BindParameters(NpgsqlCommand cmd, string tenantId, CvssScoreReceipt receipt)
{
cmd.Parameters.AddWithValue("id", Guid.Parse(receipt.ReceiptId));
cmd.Parameters.AddWithValue("tenant_id", Guid.Parse(tenantId));
cmd.Parameters.AddWithValue("vulnerability_id", receipt.VulnerabilityId);
cmd.Parameters.AddWithValue("receipt_format", receipt.Format);
cmd.Parameters.AddWithValue("schema_version", receipt.SchemaVersion);
cmd.Parameters.AddWithValue("cvss_version", receipt.CvssVersion);
cmd.Parameters.AddWithValue("vector", receipt.VectorString);
cmd.Parameters.AddWithValue("severity", receipt.Severity.ToString());
cmd.Parameters.AddWithValue("base_score", receipt.Scores.BaseScore);
cmd.Parameters.AddWithValue("threat_score", (object?)receipt.Scores.ThreatScore ?? DBNull.Value);
cmd.Parameters.AddWithValue("environmental_score", (object?)receipt.Scores.EnvironmentalScore ?? DBNull.Value);
cmd.Parameters.AddWithValue("full_score", (object?)receipt.Scores.FullScore ?? DBNull.Value);
cmd.Parameters.AddWithValue("effective_score", receipt.Scores.EffectiveScore);
cmd.Parameters.AddWithValue("effective_score_type", receipt.Scores.EffectiveScoreType.ToString());
cmd.Parameters.AddWithValue("policy_id", receipt.PolicyRef.PolicyId);
cmd.Parameters.AddWithValue("policy_version", receipt.PolicyRef.Version);
cmd.Parameters.AddWithValue("policy_hash", receipt.PolicyRef.Hash);
AddJsonbParameter(cmd, "base_metrics", Serialize(receipt.BaseMetrics));
AddJsonbParameter(cmd, "threat_metrics", receipt.ThreatMetrics is null ? null : Serialize(receipt.ThreatMetrics));
AddJsonbParameter(cmd, "environmental_metrics", receipt.EnvironmentalMetrics is null ? null : Serialize(receipt.EnvironmentalMetrics));
AddJsonbParameter(cmd, "supplemental_metrics", receipt.SupplementalMetrics is null ? null : Serialize(receipt.SupplementalMetrics));
AddJsonbParameter(cmd, "evidence", Serialize(receipt.Evidence));
AddJsonbParameter(cmd, "attestation_refs", Serialize(receipt.AttestationRefs));
cmd.Parameters.AddWithValue("input_hash", receipt.InputHash);
cmd.Parameters.AddWithValue("created_at", receipt.CreatedAt);
cmd.Parameters.AddWithValue("created_by", receipt.CreatedBy);
cmd.Parameters.AddWithValue("modified_at", (object?)receipt.ModifiedAt ?? DBNull.Value);
cmd.Parameters.AddWithValue("modified_by", (object?)receipt.ModifiedBy ?? DBNull.Value);
AddJsonbParameter(cmd, "history", Serialize(receipt.History));
cmd.Parameters.AddWithValue("amends_receipt_id", receipt.AmendsReceiptId is null ? DBNull.Value : Guid.Parse(receipt.AmendsReceiptId));
cmd.Parameters.AddWithValue("is_active", receipt.IsActive);
cmd.Parameters.AddWithValue("superseded_reason", (object?)receipt.SupersededReason ?? DBNull.Value);
}
private static CvssScoreReceipt Map(NpgsqlDataReader reader)
{
var idx = new ReceiptOrdinal(reader);
var scores = new CvssScores
{
BaseScore = reader.GetDouble(idx.BaseScore),
ThreatScore = reader.IsDBNull(idx.ThreatScore) ? null : reader.GetDouble(idx.ThreatScore),
EnvironmentalScore = reader.IsDBNull(idx.EnvironmentalScore) ? null : reader.GetDouble(idx.EnvironmentalScore),
FullScore = reader.IsDBNull(idx.FullScore) ? null : reader.GetDouble(idx.FullScore),
EffectiveScore = reader.GetDouble(idx.EffectiveScore),
EffectiveScoreType = Enum.Parse<EffectiveScoreType>(reader.GetString(idx.EffectiveScoreType), ignoreCase: true)
};
return new CvssScoreReceipt
{
ReceiptId = reader.GetGuid(idx.Id).ToString(),
TenantId = reader.GetGuid(idx.TenantId).ToString(),
VulnerabilityId = reader.GetString(idx.VulnId),
Format = reader.GetString(idx.Format),
SchemaVersion = reader.GetString(idx.SchemaVersion),
CvssVersion = reader.GetString(idx.CvssVersion),
VectorString = reader.GetString(idx.Vector),
Severity = Enum.Parse<CvssSeverity>(reader.GetString(idx.Severity), true),
Scores = scores,
PolicyRef = new CvssPolicyReference
{
PolicyId = reader.GetString(idx.PolicyId),
Version = reader.GetString(idx.PolicyVersion),
Hash = reader.GetString(idx.PolicyHash),
ActivatedAt = null
},
BaseMetrics = Deserialize<CvssBaseMetrics>(reader, idx.BaseMetrics),
ThreatMetrics = Deserialize<CvssThreatMetrics>(reader, idx.ThreatMetrics),
EnvironmentalMetrics = Deserialize<CvssEnvironmentalMetrics>(reader, idx.EnvironmentalMetrics),
SupplementalMetrics = Deserialize<CvssSupplementalMetrics>(reader, idx.SupplementalMetrics),
Evidence = Deserialize<ImmutableList<CvssEvidenceItem>>(reader, idx.Evidence) ?? ImmutableList<CvssEvidenceItem>.Empty,
AttestationRefs = Deserialize<ImmutableList<string>>(reader, idx.AttestationRefs) ?? ImmutableList<string>.Empty,
InputHash = reader.GetString(idx.InputHash),
CreatedAt = reader.GetFieldValue<DateTimeOffset>(idx.CreatedAt),
CreatedBy = reader.GetString(idx.CreatedBy),
ModifiedAt = GetNullableDateTimeOffset(reader, idx.ModifiedAt),
ModifiedBy = GetNullableString(reader, idx.ModifiedBy),
History = Deserialize<ImmutableList<ReceiptHistoryEntry>>(reader, idx.History) ?? ImmutableList<ReceiptHistoryEntry>.Empty,
AmendsReceiptId = GetNullableGuid(reader, idx.AmendsReceiptId)?.ToString(),
IsActive = reader.GetBoolean(idx.IsActive),
SupersededReason = GetNullableString(reader, idx.SupersededReason)
};
}
private static string? Serialize<T>(T value) => value is null ? null : JsonSerializer.Serialize(value, JsonOptions);
private static T? Deserialize<T>(NpgsqlDataReader reader, int ordinal)
{
if (reader.IsDBNull(ordinal)) return default;
var json = reader.GetString(ordinal);
return JsonSerializer.Deserialize<T>(json, JsonOptions);
}
private sealed record ReceiptOrdinal
{
public ReceiptOrdinal(NpgsqlDataReader reader)
{
Id = reader.GetOrdinal("id");
TenantId = reader.GetOrdinal("tenant_id");
VulnId = reader.GetOrdinal("vulnerability_id");
Format = reader.GetOrdinal("receipt_format");
SchemaVersion = reader.GetOrdinal("schema_version");
CvssVersion = reader.GetOrdinal("cvss_version");
Vector = reader.GetOrdinal("vector");
Severity = reader.GetOrdinal("severity");
BaseScore = reader.GetOrdinal("base_score");
ThreatScore = reader.GetOrdinal("threat_score");
EnvironmentalScore = reader.GetOrdinal("environmental_score");
FullScore = reader.GetOrdinal("full_score");
EffectiveScore = reader.GetOrdinal("effective_score");
EffectiveScoreType = reader.GetOrdinal("effective_score_type");
PolicyId = reader.GetOrdinal("policy_id");
PolicyVersion = reader.GetOrdinal("policy_version");
PolicyHash = reader.GetOrdinal("policy_hash");
BaseMetrics = reader.GetOrdinal("base_metrics");
ThreatMetrics = reader.GetOrdinal("threat_metrics");
EnvironmentalMetrics = reader.GetOrdinal("environmental_metrics");
SupplementalMetrics = reader.GetOrdinal("supplemental_metrics");
Evidence = reader.GetOrdinal("evidence");
AttestationRefs = reader.GetOrdinal("attestation_refs");
InputHash = reader.GetOrdinal("input_hash");
CreatedAt = reader.GetOrdinal("created_at");
CreatedBy = reader.GetOrdinal("created_by");
ModifiedAt = reader.GetOrdinal("modified_at");
ModifiedBy = reader.GetOrdinal("modified_by");
History = reader.GetOrdinal("history");
AmendsReceiptId = reader.GetOrdinal("amends_receipt_id");
IsActive = reader.GetOrdinal("is_active");
SupersededReason = reader.GetOrdinal("superseded_reason");
}
public int Id { get; }
public int TenantId { get; }
public int VulnId { get; }
public int Format { get; }
public int SchemaVersion { get; }
public int CvssVersion { get; }
public int Vector { get; }
public int Severity { get; }
public int BaseScore { get; }
public int ThreatScore { get; }
public int EnvironmentalScore { get; }
public int FullScore { get; }
public int EffectiveScore { get; }
public int EffectiveScoreType { get; }
public int PolicyId { get; }
public int PolicyVersion { get; }
public int PolicyHash { get; }
public int BaseMetrics { get; }
public int ThreatMetrics { get; }
public int EnvironmentalMetrics { get; }
public int SupplementalMetrics { get; }
public int Evidence { get; }
public int AttestationRefs { get; }
public int InputHash { get; }
public int CreatedAt { get; }
public int CreatedBy { get; }
public int ModifiedAt { get; }
public int ModifiedBy { get; }
public int History { get; }
public int AmendsReceiptId { get; }
public int IsActive { get; }
public int SupersededReason { get; }
}
}

View File

@@ -0,0 +1,374 @@
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Infrastructure.Postgres.Repositories;
using StellaOps.Policy.Storage.Postgres.Models;
namespace StellaOps.Policy.Storage.Postgres.Repositories;
/// <summary>
/// PostgreSQL repository for risk profile operations.
/// </summary>
public sealed class RiskProfileRepository : RepositoryBase<PolicyDataSource>, IRiskProfileRepository
{
/// <summary>
/// Creates a new risk profile repository.
/// </summary>
public RiskProfileRepository(PolicyDataSource dataSource, ILogger<RiskProfileRepository> logger)
: base(dataSource, logger)
{
}
/// <inheritdoc />
public async Task<RiskProfileEntity> CreateAsync(RiskProfileEntity profile, CancellationToken cancellationToken = default)
{
const string sql = """
INSERT INTO policy.risk_profiles (
id, tenant_id, name, display_name, description, version,
is_active, thresholds, scoring_weights, exemptions, metadata, created_by
)
VALUES (
@id, @tenant_id, @name, @display_name, @description, @version,
@is_active, @thresholds::jsonb, @scoring_weights::jsonb, @exemptions::jsonb, @metadata::jsonb, @created_by
)
RETURNING *
""";
await using var connection = await DataSource.OpenConnectionAsync(profile.TenantId, "writer", cancellationToken)
.ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddProfileParameters(command, profile);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
return MapProfile(reader);
}
/// <inheritdoc />
public async Task<RiskProfileEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = "SELECT * FROM policy.risk_profiles WHERE tenant_id = @tenant_id AND id = @id";
return await QuerySingleOrDefaultAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "id", id);
},
MapProfile,
cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<RiskProfileEntity?> GetActiveByNameAsync(
string tenantId,
string name,
CancellationToken cancellationToken = default)
{
const string sql = """
SELECT * FROM policy.risk_profiles
WHERE tenant_id = @tenant_id AND name = @name AND is_active = TRUE
ORDER BY version DESC
LIMIT 1
""";
return await QuerySingleOrDefaultAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "name", name);
},
MapProfile,
cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<IReadOnlyList<RiskProfileEntity>> GetAllAsync(
string tenantId,
bool? activeOnly = true,
int limit = 100,
int offset = 0,
CancellationToken cancellationToken = default)
{
var sql = "SELECT * FROM policy.risk_profiles WHERE tenant_id = @tenant_id";
if (activeOnly == true)
{
sql += " AND is_active = TRUE";
}
sql += " ORDER BY name, version DESC LIMIT @limit OFFSET @offset";
return await QueryAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "limit", limit);
AddParameter(cmd, "offset", offset);
},
MapProfile,
cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<IReadOnlyList<RiskProfileEntity>> GetVersionsByNameAsync(
string tenantId,
string name,
CancellationToken cancellationToken = default)
{
const string sql = """
SELECT * FROM policy.risk_profiles
WHERE tenant_id = @tenant_id AND name = @name
ORDER BY version DESC
""";
return await QueryAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "name", name);
},
MapProfile,
cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<bool> UpdateAsync(RiskProfileEntity profile, CancellationToken cancellationToken = default)
{
const string sql = """
UPDATE policy.risk_profiles
SET display_name = @display_name,
description = @description,
thresholds = @thresholds::jsonb,
scoring_weights = @scoring_weights::jsonb,
exemptions = @exemptions::jsonb,
metadata = @metadata::jsonb
WHERE tenant_id = @tenant_id AND id = @id
""";
var rows = await ExecuteAsync(
profile.TenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", profile.TenantId);
AddParameter(cmd, "id", profile.Id);
AddParameter(cmd, "display_name", profile.DisplayName);
AddParameter(cmd, "description", profile.Description);
AddJsonbParameter(cmd, "thresholds", profile.Thresholds);
AddJsonbParameter(cmd, "scoring_weights", profile.ScoringWeights);
AddJsonbParameter(cmd, "exemptions", profile.Exemptions);
AddJsonbParameter(cmd, "metadata", profile.Metadata);
},
cancellationToken).ConfigureAwait(false);
return rows > 0;
}
/// <inheritdoc />
public async Task<RiskProfileEntity> CreateVersionAsync(
string tenantId,
string name,
RiskProfileEntity newProfile,
CancellationToken cancellationToken = default)
{
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken)
.ConfigureAwait(false);
await using var transaction = await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
// Get next version number
const string versionSql = """
SELECT COALESCE(MAX(version), 0) + 1
FROM policy.risk_profiles
WHERE tenant_id = @tenant_id AND name = @name
""";
await using var versionCmd = CreateCommand(versionSql, connection);
versionCmd.Transaction = transaction;
AddParameter(versionCmd, "tenant_id", tenantId);
AddParameter(versionCmd, "name", name);
var nextVersion = Convert.ToInt32(await versionCmd.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false));
// Insert new version
const string insertSql = """
INSERT INTO policy.risk_profiles (
id, tenant_id, name, display_name, description, version,
is_active, thresholds, scoring_weights, exemptions, metadata, created_by
)
VALUES (
@id, @tenant_id, @name, @display_name, @description, @version,
TRUE, @thresholds::jsonb, @scoring_weights::jsonb, @exemptions::jsonb, @metadata::jsonb, @created_by
)
RETURNING *
""";
await using var insertCmd = CreateCommand(insertSql, connection);
insertCmd.Transaction = transaction;
AddParameter(insertCmd, "id", newProfile.Id);
AddParameter(insertCmd, "tenant_id", tenantId);
AddParameter(insertCmd, "name", name);
AddParameter(insertCmd, "display_name", newProfile.DisplayName);
AddParameter(insertCmd, "description", newProfile.Description);
AddParameter(insertCmd, "version", nextVersion);
AddJsonbParameter(insertCmd, "thresholds", newProfile.Thresholds);
AddJsonbParameter(insertCmd, "scoring_weights", newProfile.ScoringWeights);
AddJsonbParameter(insertCmd, "exemptions", newProfile.Exemptions);
AddJsonbParameter(insertCmd, "metadata", newProfile.Metadata);
AddParameter(insertCmd, "created_by", newProfile.CreatedBy);
await using var reader = await insertCmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
var result = MapProfile(reader);
await reader.CloseAsync().ConfigureAwait(false);
// Deactivate other versions
const string deactivateSql = """
UPDATE policy.risk_profiles
SET is_active = FALSE
WHERE tenant_id = @tenant_id AND name = @name AND id != @id
""";
await using var deactivateCmd = CreateCommand(deactivateSql, connection);
deactivateCmd.Transaction = transaction;
AddParameter(deactivateCmd, "tenant_id", tenantId);
AddParameter(deactivateCmd, "name", name);
AddParameter(deactivateCmd, "id", result.Id);
await deactivateCmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
return result;
}
/// <inheritdoc />
public async Task<bool> ActivateAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken)
.ConfigureAwait(false);
await using var transaction = await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
// Get the profile name
const string nameSql = "SELECT name FROM policy.risk_profiles WHERE tenant_id = @tenant_id AND id = @id";
await using var nameCmd = CreateCommand(nameSql, connection);
nameCmd.Transaction = transaction;
AddParameter(nameCmd, "tenant_id", tenantId);
AddParameter(nameCmd, "id", id);
var name = await nameCmd.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false) as string;
if (name == null) return false;
// Deactivate other versions
const string deactivateSql = """
UPDATE policy.risk_profiles
SET is_active = FALSE
WHERE tenant_id = @tenant_id AND name = @name AND id != @id
""";
await using var deactivateCmd = CreateCommand(deactivateSql, connection);
deactivateCmd.Transaction = transaction;
AddParameter(deactivateCmd, "tenant_id", tenantId);
AddParameter(deactivateCmd, "name", name);
AddParameter(deactivateCmd, "id", id);
await deactivateCmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
// Activate this version
const string activateSql = """
UPDATE policy.risk_profiles
SET is_active = TRUE
WHERE tenant_id = @tenant_id AND id = @id
""";
await using var activateCmd = CreateCommand(activateSql, connection);
activateCmd.Transaction = transaction;
AddParameter(activateCmd, "tenant_id", tenantId);
AddParameter(activateCmd, "id", id);
var rows = await activateCmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
return rows > 0;
}
/// <inheritdoc />
public async Task<bool> DeactivateAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = """
UPDATE policy.risk_profiles
SET is_active = FALSE
WHERE tenant_id = @tenant_id AND id = @id
""";
var rows = await ExecuteAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "id", id);
},
cancellationToken).ConfigureAwait(false);
return rows > 0;
}
/// <inheritdoc />
public async Task<bool> DeleteAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = "DELETE FROM policy.risk_profiles WHERE tenant_id = @tenant_id AND id = @id";
var rows = await ExecuteAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "id", id);
},
cancellationToken).ConfigureAwait(false);
return rows > 0;
}
private static void AddProfileParameters(NpgsqlCommand command, RiskProfileEntity profile)
{
AddParameter(command, "id", profile.Id);
AddParameter(command, "tenant_id", profile.TenantId);
AddParameter(command, "name", profile.Name);
AddParameter(command, "display_name", profile.DisplayName);
AddParameter(command, "description", profile.Description);
AddParameter(command, "version", profile.Version);
AddParameter(command, "is_active", profile.IsActive);
AddJsonbParameter(command, "thresholds", profile.Thresholds);
AddJsonbParameter(command, "scoring_weights", profile.ScoringWeights);
AddJsonbParameter(command, "exemptions", profile.Exemptions);
AddJsonbParameter(command, "metadata", profile.Metadata);
AddParameter(command, "created_by", profile.CreatedBy);
}
private static RiskProfileEntity MapProfile(NpgsqlDataReader reader) => new()
{
Id = reader.GetGuid(reader.GetOrdinal("id")),
TenantId = reader.GetString(reader.GetOrdinal("tenant_id")),
Name = reader.GetString(reader.GetOrdinal("name")),
DisplayName = GetNullableString(reader, reader.GetOrdinal("display_name")),
Description = GetNullableString(reader, reader.GetOrdinal("description")),
Version = reader.GetInt32(reader.GetOrdinal("version")),
IsActive = reader.GetBoolean(reader.GetOrdinal("is_active")),
Thresholds = reader.GetString(reader.GetOrdinal("thresholds")),
ScoringWeights = reader.GetString(reader.GetOrdinal("scoring_weights")),
Exemptions = reader.GetString(reader.GetOrdinal("exemptions")),
Metadata = reader.GetString(reader.GetOrdinal("metadata")),
CreatedAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("created_at")),
UpdatedAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("updated_at")),
CreatedBy = GetNullableString(reader, reader.GetOrdinal("created_by"))
};
}

View File

@@ -0,0 +1,335 @@
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Infrastructure.Postgres.Repositories;
using StellaOps.Policy.Storage.Postgres.Models;
namespace StellaOps.Policy.Storage.Postgres.Repositories;
/// <summary>
/// PostgreSQL repository for policy rule operations.
/// Note: rules table doesn't have tenant_id; tenant context comes from parent pack.
/// </summary>
public sealed class RuleRepository : RepositoryBase<PolicyDataSource>, IRuleRepository
{
/// <summary>
/// Creates a new rule repository.
/// </summary>
public RuleRepository(PolicyDataSource dataSource, ILogger<RuleRepository> logger)
: base(dataSource, logger)
{
}
/// <inheritdoc />
public async Task<RuleEntity> CreateAsync(RuleEntity rule, CancellationToken cancellationToken = default)
{
const string sql = """
INSERT INTO policy.rules (
id, pack_version_id, name, description, rule_type, content,
content_hash, severity, category, tags, metadata
)
VALUES (
@id, @pack_version_id, @name, @description, @rule_type, @content,
@content_hash, @severity, @category, @tags, @metadata::jsonb
)
RETURNING *
""";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken)
.ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddRuleParameters(command, rule);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
return MapRule(reader);
}
/// <inheritdoc />
public async Task<int> CreateBatchAsync(IEnumerable<RuleEntity> rules, CancellationToken cancellationToken = default)
{
var rulesList = rules.ToList();
if (rulesList.Count == 0) return 0;
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken)
.ConfigureAwait(false);
await using var transaction = await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
var count = 0;
foreach (var rule in rulesList)
{
const string sql = """
INSERT INTO policy.rules (
id, pack_version_id, name, description, rule_type, content,
content_hash, severity, category, tags, metadata
)
VALUES (
@id, @pack_version_id, @name, @description, @rule_type, @content,
@content_hash, @severity, @category, @tags, @metadata::jsonb
)
""";
await using var command = CreateCommand(sql, connection);
command.Transaction = transaction;
AddRuleParameters(command, rule);
count += await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
return count;
}
/// <inheritdoc />
public async Task<RuleEntity?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
{
const string sql = "SELECT * FROM policy.rules WHERE id = @id";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken)
.ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "id", id);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
return null;
}
return MapRule(reader);
}
/// <inheritdoc />
public async Task<RuleEntity?> GetByNameAsync(
Guid packVersionId,
string name,
CancellationToken cancellationToken = default)
{
const string sql = "SELECT * FROM policy.rules WHERE pack_version_id = @pack_version_id AND name = @name";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken)
.ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "pack_version_id", packVersionId);
AddParameter(command, "name", name);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
return null;
}
return MapRule(reader);
}
/// <inheritdoc />
public async Task<IReadOnlyList<RuleEntity>> GetByPackVersionIdAsync(
Guid packVersionId,
CancellationToken cancellationToken = default)
{
const string sql = """
SELECT * FROM policy.rules
WHERE pack_version_id = @pack_version_id
ORDER BY name, id
""";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken)
.ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "pack_version_id", packVersionId);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
var results = new List<RuleEntity>();
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
results.Add(MapRule(reader));
}
return results;
}
/// <inheritdoc />
public async Task<IReadOnlyList<RuleEntity>> GetBySeverityAsync(
Guid packVersionId,
RuleSeverity severity,
CancellationToken cancellationToken = default)
{
const string sql = """
SELECT * FROM policy.rules
WHERE pack_version_id = @pack_version_id AND severity = @severity
ORDER BY name, id
""";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken)
.ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "pack_version_id", packVersionId);
AddParameter(command, "severity", SeverityToString(severity));
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
var results = new List<RuleEntity>();
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
results.Add(MapRule(reader));
}
return results;
}
/// <inheritdoc />
public async Task<IReadOnlyList<RuleEntity>> GetByCategoryAsync(
Guid packVersionId,
string category,
CancellationToken cancellationToken = default)
{
const string sql = """
SELECT * FROM policy.rules
WHERE pack_version_id = @pack_version_id AND category = @category
ORDER BY name, id
""";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken)
.ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "pack_version_id", packVersionId);
AddParameter(command, "category", category);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
var results = new List<RuleEntity>();
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
results.Add(MapRule(reader));
}
return results;
}
/// <inheritdoc />
public async Task<IReadOnlyList<RuleEntity>> GetByTagAsync(
Guid packVersionId,
string tag,
CancellationToken cancellationToken = default)
{
const string sql = """
SELECT * FROM policy.rules
WHERE pack_version_id = @pack_version_id AND @tag = ANY(tags)
ORDER BY name, id
""";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken)
.ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "pack_version_id", packVersionId);
AddParameter(command, "tag", tag);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
var results = new List<RuleEntity>();
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
results.Add(MapRule(reader));
}
return results;
}
/// <inheritdoc />
public async Task<int> CountByPackVersionIdAsync(Guid packVersionId, CancellationToken cancellationToken = default)
{
const string sql = "SELECT COUNT(*) FROM policy.rules WHERE pack_version_id = @pack_version_id";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken)
.ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "pack_version_id", packVersionId);
var result = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
return Convert.ToInt32(result);
}
private static void AddRuleParameters(NpgsqlCommand command, RuleEntity rule)
{
AddParameter(command, "id", rule.Id);
AddParameter(command, "pack_version_id", rule.PackVersionId);
AddParameter(command, "name", rule.Name);
AddParameter(command, "description", rule.Description);
AddParameter(command, "rule_type", RuleTypeToString(rule.RuleType));
AddParameter(command, "content", rule.Content);
AddParameter(command, "content_hash", rule.ContentHash);
AddParameter(command, "severity", SeverityToString(rule.Severity));
AddParameter(command, "category", rule.Category);
AddTextArrayParameter(command, "tags", rule.Tags);
AddJsonbParameter(command, "metadata", rule.Metadata);
}
private static RuleEntity MapRule(NpgsqlDataReader reader) => new()
{
Id = reader.GetGuid(reader.GetOrdinal("id")),
PackVersionId = reader.GetGuid(reader.GetOrdinal("pack_version_id")),
Name = reader.GetString(reader.GetOrdinal("name")),
Description = GetNullableString(reader, reader.GetOrdinal("description")),
RuleType = ParseRuleType(reader.GetString(reader.GetOrdinal("rule_type"))),
Content = reader.GetString(reader.GetOrdinal("content")),
ContentHash = reader.GetString(reader.GetOrdinal("content_hash")),
Severity = ParseSeverity(reader.GetString(reader.GetOrdinal("severity"))),
Category = GetNullableString(reader, reader.GetOrdinal("category")),
Tags = GetTextArray(reader, reader.GetOrdinal("tags")),
Metadata = reader.GetString(reader.GetOrdinal("metadata")),
CreatedAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("created_at"))
};
private static string RuleTypeToString(RuleType ruleType) => ruleType switch
{
RuleType.Rego => "rego",
RuleType.Json => "json",
RuleType.Yaml => "yaml",
_ => throw new ArgumentException($"Unknown rule type: {ruleType}", nameof(ruleType))
};
private static RuleType ParseRuleType(string ruleType) => ruleType switch
{
"rego" => RuleType.Rego,
"json" => RuleType.Json,
"yaml" => RuleType.Yaml,
_ => throw new ArgumentException($"Unknown rule type: {ruleType}", nameof(ruleType))
};
private static string SeverityToString(RuleSeverity severity) => severity switch
{
RuleSeverity.Critical => "critical",
RuleSeverity.High => "high",
RuleSeverity.Medium => "medium",
RuleSeverity.Low => "low",
RuleSeverity.Info => "info",
_ => throw new ArgumentException($"Unknown severity: {severity}", nameof(severity))
};
private static RuleSeverity ParseSeverity(string severity) => severity switch
{
"critical" => RuleSeverity.Critical,
"high" => RuleSeverity.High,
"medium" => RuleSeverity.Medium,
"low" => RuleSeverity.Low,
"info" => RuleSeverity.Info,
_ => throw new ArgumentException($"Unknown severity: {severity}", nameof(severity))
};
private static string[] GetTextArray(NpgsqlDataReader reader, int ordinal)
{
if (reader.IsDBNull(ordinal))
return [];
return reader.GetFieldValue<string[]>(ordinal);
}
}

View File

@@ -2,6 +2,8 @@ using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Infrastructure.Postgres;
using StellaOps.Infrastructure.Postgres.Options;
using StellaOps.Policy.Scoring.Receipts;
using StellaOps.Policy.Storage.Postgres.Repositories;
namespace StellaOps.Policy.Storage.Postgres;
@@ -25,6 +27,15 @@ public static class ServiceCollectionExtensions
services.Configure<PostgresOptions>(sectionName, configuration.GetSection(sectionName));
services.AddSingleton<PolicyDataSource>();
// Register repositories
services.AddScoped<IPackRepository, PackRepository>();
services.AddScoped<IPackVersionRepository, PackVersionRepository>();
services.AddScoped<IRuleRepository, RuleRepository>();
services.AddScoped<IRiskProfileRepository, RiskProfileRepository>();
services.AddScoped<IEvaluationRunRepository, EvaluationRunRepository>();
services.AddScoped<IExceptionRepository, ExceptionRepository>();
services.AddScoped<IReceiptRepository, PostgresReceiptRepository>();
return services;
}
@@ -41,6 +52,15 @@ public static class ServiceCollectionExtensions
services.Configure(configureOptions);
services.AddSingleton<PolicyDataSource>();
// Register repositories
services.AddScoped<IPackRepository, PackRepository>();
services.AddScoped<IPackVersionRepository, PackVersionRepository>();
services.AddScoped<IRuleRepository, RuleRepository>();
services.AddScoped<IRiskProfileRepository, RiskProfileRepository>();
services.AddScoped<IEvaluationRunRepository, EvaluationRunRepository>();
services.AddScoped<IExceptionRepository, ExceptionRepository>();
services.AddScoped<IReceiptRepository, PostgresReceiptRepository>();
return services;
}
}

View File

@@ -11,10 +11,11 @@
</PropertyGroup>
<ItemGroup>
<None Include="Migrations\**\*.sql" CopyToOutputDirectory="PreserveNewest" />
<EmbeddedResource Include="Migrations\**\*.sql" LogicalName="%(RecursiveDir)%(Filename)%(Extension)" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\StellaOps.Policy.Scoring\StellaOps.Policy.Scoring.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Infrastructure.Postgres\StellaOps.Infrastructure.Postgres.csproj" />
</ItemGroup>

View File

@@ -7,11 +7,23 @@ internal sealed class InMemoryReceiptRepository : IReceiptRepository
{
private readonly ConcurrentDictionary<string, CvssScoreReceipt> _store = new();
public Task<CvssScoreReceipt> SaveAsync(CvssScoreReceipt receipt, CancellationToken cancellationToken = default)
public Task<CvssScoreReceipt> SaveAsync(string tenantId, CvssScoreReceipt receipt, CancellationToken cancellationToken = default)
{
_store[receipt.ReceiptId] = receipt;
return Task.FromResult(receipt);
}
public bool Contains(string receiptId) => _store.ContainsKey(receiptId);
public Task<CvssScoreReceipt?> GetAsync(string tenantId, string receiptId, CancellationToken cancellationToken = default)
{
_store.TryGetValue(receiptId, out var receipt);
return Task.FromResult(receipt);
}
public Task<CvssScoreReceipt> UpdateAsync(string tenantId, CvssScoreReceipt receipt, CancellationToken cancellationToken = default)
{
_store[receipt.ReceiptId] = receipt;
return Task.FromResult(receipt);
}
}

View File

@@ -0,0 +1,15 @@
using System;
namespace StellaOps.Policy.Scoring.Tests.Fakes;
internal static class TestKeys
{
// Ed25519 test key material from Attestor envelope tests
public static readonly byte[] Ed25519PrivateExpanded = Convert.FromHexString(
"9D61B19DEFFD5A60BA844AF492EC2CC4" +
"4449C5697B326919703BAC031CAE7F60D75A980182B10AB7D54BFED3C964073A" +
"0EE172F3DAA62325AF021A68F707511A");
public static readonly byte[] Ed25519Public = Convert.FromHexString(
"D75A980182B10AB7D54BFED3C964073A0EE172F3DAA62325AF021A68F707511A");
}

View File

@@ -1,5 +1,6 @@
using System.Collections.Immutable;
using FluentAssertions;
using StellaOps.Attestor.Envelope;
using StellaOps.Policy.Scoring.Engine;
using StellaOps.Policy.Scoring.Receipts;
using StellaOps.Policy.Scoring.Tests.Fakes;
@@ -70,6 +71,54 @@ public sealed class ReceiptBuilderTests
_repository.Contains(receipt1.ReceiptId).Should().BeTrue();
}
[Fact]
public async Task CreateAsync_WithSigningKey_AttachesDsseReference()
{
// Arrange
var signingKey = EnvelopeKey.CreateEd25519Signer(TestKeys.Ed25519PrivateExpanded, TestKeys.Ed25519Public, "test-key");
var policy = new CvssPolicy
{
PolicyId = "default",
Version = "1.0.0",
Name = "Default",
EffectiveFrom = DateTimeOffset.UtcNow,
Hash = "abc123"
};
var request = new CreateReceiptRequest
{
VulnerabilityId = "CVE-2025-0003",
TenantId = "tenant-c",
CreatedBy = "tester",
Policy = policy,
BaseMetrics = new CvssBaseMetrics
{
AttackVector = AttackVector.Network,
AttackComplexity = AttackComplexity.Low,
AttackRequirements = AttackRequirements.None,
PrivilegesRequired = PrivilegesRequired.None,
UserInteraction = UserInteraction.None,
VulnerableSystemConfidentiality = ImpactMetricValue.High,
VulnerableSystemIntegrity = ImpactMetricValue.High,
VulnerableSystemAvailability = ImpactMetricValue.High,
SubsequentSystemConfidentiality = ImpactMetricValue.High,
SubsequentSystemIntegrity = ImpactMetricValue.High,
SubsequentSystemAvailability = ImpactMetricValue.High
},
SigningKey = signingKey
};
var builder = new ReceiptBuilder(_engine, _repository);
// Act
var receipt = await builder.CreateAsync(request);
// Assert
receipt.AttestationRefs.Should().NotBeEmpty();
receipt.AttestationRefs[0].Should().StartWith("dsse:");
}
[Fact]
public async Task CreateAsync_EnforcesEvidenceRequirements()
{

View File

@@ -0,0 +1,28 @@
using System.Reflection;
using StellaOps.Infrastructure.Postgres.Testing;
using StellaOps.Policy.Storage.Postgres;
using Xunit;
namespace StellaOps.Policy.Storage.Postgres.Tests;
/// <summary>
/// PostgreSQL integration test fixture for the Policy module.
/// Runs migrations from embedded resources and provides test isolation.
/// </summary>
public sealed class PolicyPostgresFixture : PostgresIntegrationFixture, ICollectionFixture<PolicyPostgresFixture>
{
protected override Assembly? GetMigrationAssembly()
=> typeof(PolicyDataSource).Assembly;
protected override string GetModuleName() => "Policy";
}
/// <summary>
/// Collection definition for Policy PostgreSQL integration tests.
/// Tests in this collection share a single PostgreSQL container instance.
/// </summary>
[CollectionDefinition(Name)]
public sealed class PolicyPostgresCollection : ICollectionFixture<PolicyPostgresFixture>
{
public const string Name = "PolicyPostgres";
}

View File

@@ -0,0 +1,109 @@
using System.Collections.Immutable;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Policy.Scoring;
using StellaOps.Policy.Scoring.Receipts;
using StellaOps.Policy.Storage.Postgres.Repositories;
using StellaOps.Policy.Storage.Postgres.Tests;
using StellaOps.Policy.Storage.Postgres;
using Xunit;
namespace StellaOps.Policy.Storage.Postgres.Tests;
[Collection(PolicyPostgresCollection.Name)]
public sealed class PostgresReceiptRepositoryTests : IAsyncLifetime
{
private readonly PolicyPostgresFixture _fixture;
private readonly PostgresReceiptRepository _repository;
private readonly string _tenantId = Guid.NewGuid().ToString();
public PostgresReceiptRepositoryTests(PolicyPostgresFixture fixture)
{
_fixture = fixture;
var options = fixture.Fixture.CreateOptions();
options.SchemaName = fixture.SchemaName;
var dataSource = new PolicyDataSource(Options.Create(options), NullLogger<PolicyDataSource>.Instance);
_repository = new PostgresReceiptRepository(dataSource, NullLogger<PostgresReceiptRepository>.Instance);
}
public Task InitializeAsync() => _fixture.TruncateAllTablesAsync();
public Task DisposeAsync() => Task.CompletedTask;
[Fact]
public async Task SaveAndGet_RoundTripsReceipt()
{
var receipt = CreateReceipt(_tenantId);
var saved = await _repository.SaveAsync(_tenantId, receipt);
var fetched = await _repository.GetAsync(_tenantId, saved.ReceiptId);
fetched.Should().NotBeNull();
fetched!.ReceiptId.Should().Be(saved.ReceiptId);
fetched.InputHash.Should().Be(saved.InputHash);
fetched.PolicyRef.PolicyId.Should().Be("default");
fetched.AttestationRefs.Should().BeEquivalentTo(saved.AttestationRefs);
}
private static CvssScoreReceipt CreateReceipt(string tenantId)
{
var baseMetrics = new CvssBaseMetrics
{
AttackVector = AttackVector.Network,
AttackComplexity = AttackComplexity.Low,
AttackRequirements = AttackRequirements.None,
PrivilegesRequired = PrivilegesRequired.None,
UserInteraction = UserInteraction.None,
VulnerableSystemConfidentiality = ImpactMetricValue.High,
VulnerableSystemIntegrity = ImpactMetricValue.High,
VulnerableSystemAvailability = ImpactMetricValue.High,
SubsequentSystemConfidentiality = ImpactMetricValue.High,
SubsequentSystemIntegrity = ImpactMetricValue.High,
SubsequentSystemAvailability = ImpactMetricValue.High
};
var scores = new CvssScores
{
BaseScore = 10.0,
ThreatScore = null,
EnvironmentalScore = null,
FullScore = null,
EffectiveScore = 10.0,
EffectiveScoreType = EffectiveScoreType.Base
};
return new CvssScoreReceipt
{
ReceiptId = Guid.NewGuid().ToString(),
SchemaVersion = "1.0.0",
Format = "stella.ops/cvssReceipt@v1",
VulnerabilityId = "CVE-2025-0001",
TenantId = tenantId,
CreatedAt = DateTimeOffset.UtcNow,
CreatedBy = "tester",
CvssVersion = "4.0",
BaseMetrics = baseMetrics,
ThreatMetrics = null,
EnvironmentalMetrics = null,
SupplementalMetrics = null,
Scores = scores,
VectorString = "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:H/SI:H/SA:H",
Severity = CvssSeverity.Critical,
PolicyRef = new CvssPolicyReference
{
PolicyId = "default",
Version = "1.0.0",
Hash = "abc123",
ActivatedAt = null
},
Evidence = ImmutableList<CvssEvidenceItem>.Empty,
AttestationRefs = ImmutableList<string>.Empty,
InputHash = "hash123",
History = ImmutableList<ReceiptHistoryEntry>.Empty,
AmendsReceiptId = null,
IsActive = true,
SupersededReason = null
};
}
}

View File

@@ -0,0 +1,34 @@
<?xml version="1.0" ?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="Moq" Version="4.20.70" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" Version="6.0.4">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\__Libraries\StellaOps.Policy.Storage.Postgres\StellaOps.Policy.Storage.Postgres.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Infrastructure.Postgres.Testing\StellaOps.Infrastructure.Postgres.Testing.csproj" />
<ProjectReference Include="..\..\StellaOps.Policy.Scoring\StellaOps.Policy.Scoring.csproj" />
</ItemGroup>
</Project>