up
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
};
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user