Add signal contracts for reachability, exploitability, trust, and unknown symbols
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Signals DSSE Sign & Evidence Locker / sign-signals-artifacts (push) Has been cancelled
Signals DSSE Sign & Evidence Locker / verify-signatures (push) Has been cancelled

- Introduced `ReachabilityState`, `RuntimeHit`, `ExploitabilitySignal`, `ReachabilitySignal`, `SignalEnvelope`, `SignalType`, `TrustSignal`, and `UnknownSymbolSignal` records to define various signal types and their properties.
- Implemented JSON serialization attributes for proper data interchange.
- Created project files for the new signal contracts library and corresponding test projects.
- Added deterministic test fixtures for micro-interaction testing.
- Included cryptographic keys for secure operations with cosign.
This commit is contained in:
StellaOps Bot
2025-12-05 00:27:00 +02:00
parent b018949a8d
commit 8768c27f30
192 changed files with 27569 additions and 2552 deletions

View File

@@ -0,0 +1,119 @@
-- Policy Schema Migration 003: Snapshots, Violations, Conflicts, Ledger Exports
-- Adds tables for policy snapshots, violation events, conflict handling, and ledger exports
-- Snapshots table (immutable policy configuration snapshots)
CREATE TABLE IF NOT EXISTS policy.snapshots (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id TEXT NOT NULL,
policy_id UUID NOT NULL,
version INT NOT NULL,
content_digest TEXT NOT NULL,
content JSONB NOT NULL,
metadata JSONB NOT NULL DEFAULT '{}',
created_by TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(tenant_id, policy_id, version)
);
CREATE INDEX idx_snapshots_tenant ON policy.snapshots(tenant_id);
CREATE INDEX idx_snapshots_policy ON policy.snapshots(tenant_id, policy_id);
CREATE INDEX idx_snapshots_digest ON policy.snapshots(content_digest);
-- Violation events table (append-only)
CREATE TABLE IF NOT EXISTS policy.violation_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id TEXT NOT NULL,
policy_id UUID NOT NULL,
rule_id TEXT NOT NULL,
severity TEXT NOT NULL CHECK (severity IN ('critical', 'high', 'medium', 'low', 'info')),
subject_purl TEXT,
subject_cve TEXT,
details JSONB NOT NULL DEFAULT '{}',
remediation TEXT,
correlation_id TEXT,
occurred_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Append-only: no UPDATE trigger, only INSERTs allowed
CREATE INDEX idx_violation_events_tenant ON policy.violation_events(tenant_id);
CREATE INDEX idx_violation_events_policy ON policy.violation_events(tenant_id, policy_id);
CREATE INDEX idx_violation_events_rule ON policy.violation_events(rule_id);
CREATE INDEX idx_violation_events_severity ON policy.violation_events(severity);
CREATE INDEX idx_violation_events_purl ON policy.violation_events(subject_purl) WHERE subject_purl IS NOT NULL;
CREATE INDEX idx_violation_events_occurred ON policy.violation_events(tenant_id, occurred_at);
-- Conflicts table (for conflict detection and resolution)
CREATE TABLE IF NOT EXISTS policy.conflicts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id TEXT NOT NULL,
conflict_type TEXT NOT NULL CHECK (conflict_type IN ('rule_overlap', 'scope_collision', 'version_mismatch', 'precedence', 'other')),
status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'resolved', 'dismissed')),
severity TEXT NOT NULL DEFAULT 'medium' CHECK (severity IN ('critical', 'high', 'medium', 'low')),
left_rule_id TEXT,
right_rule_id TEXT,
affected_scope TEXT,
description TEXT NOT NULL,
resolution TEXT,
resolved_by TEXT,
resolved_at TIMESTAMPTZ,
metadata JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by TEXT
);
CREATE INDEX idx_conflicts_tenant ON policy.conflicts(tenant_id);
CREATE INDEX idx_conflicts_status ON policy.conflicts(tenant_id, status);
CREATE INDEX idx_conflicts_type ON policy.conflicts(conflict_type);
-- Ledger exports table (for tracking ledger exports)
CREATE TABLE IF NOT EXISTS policy.ledger_exports (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id TEXT NOT NULL,
export_type TEXT NOT NULL CHECK (export_type IN ('full', 'incremental', 'snapshot')),
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'running', 'completed', 'failed')),
format TEXT NOT NULL DEFAULT 'ndjson' CHECK (format IN ('ndjson', 'json', 'parquet', 'csv')),
content_digest TEXT,
record_count INT,
byte_size BIGINT,
storage_path TEXT,
start_time TIMESTAMPTZ,
end_time TIMESTAMPTZ,
error_message TEXT,
metadata JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by TEXT
);
CREATE INDEX idx_ledger_exports_tenant ON policy.ledger_exports(tenant_id);
CREATE INDEX idx_ledger_exports_status ON policy.ledger_exports(status);
CREATE INDEX idx_ledger_exports_digest ON policy.ledger_exports(content_digest) WHERE content_digest IS NOT NULL;
CREATE INDEX idx_ledger_exports_created ON policy.ledger_exports(tenant_id, created_at);
-- Worker results table (for background job tracking)
CREATE TABLE IF NOT EXISTS policy.worker_results (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id TEXT NOT NULL,
job_type TEXT NOT NULL,
job_id TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'running', 'completed', 'failed', 'cancelled')),
input_hash TEXT,
output_hash TEXT,
progress INT DEFAULT 0 CHECK (progress >= 0 AND progress <= 100),
result JSONB,
error_message TEXT,
retry_count INT NOT NULL DEFAULT 0,
max_retries INT NOT NULL DEFAULT 3,
scheduled_at TIMESTAMPTZ,
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
metadata JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by TEXT,
UNIQUE(tenant_id, job_type, job_id)
);
CREATE INDEX idx_worker_results_tenant ON policy.worker_results(tenant_id);
CREATE INDEX idx_worker_results_status ON policy.worker_results(status);
CREATE INDEX idx_worker_results_job_type ON policy.worker_results(job_type);
CREATE INDEX idx_worker_results_scheduled ON policy.worker_results(scheduled_at) WHERE status = 'pending';

View File

@@ -0,0 +1,23 @@
namespace StellaOps.Policy.Storage.Postgres.Models;
/// <summary>
/// Entity representing a policy conflict for resolution.
/// </summary>
public sealed record ConflictEntity
{
public Guid Id { get; init; }
public required string TenantId { get; init; }
public required string ConflictType { get; init; }
public string Status { get; init; } = "open";
public string Severity { get; init; } = "medium";
public string? LeftRuleId { get; init; }
public string? RightRuleId { get; init; }
public string? AffectedScope { get; init; }
public required string Description { get; init; }
public string? Resolution { get; init; }
public string? ResolvedBy { get; init; }
public DateTimeOffset? ResolvedAt { get; init; }
public string Metadata { get; init; } = "{}";
public DateTimeOffset CreatedAt { get; init; }
public string? CreatedBy { get; init; }
}

View File

@@ -0,0 +1,23 @@
namespace StellaOps.Policy.Storage.Postgres.Models;
/// <summary>
/// Entity representing a ledger export operation.
/// </summary>
public sealed record LedgerExportEntity
{
public Guid Id { get; init; }
public required string TenantId { get; init; }
public required string ExportType { get; init; }
public string Status { get; init; } = "pending";
public string Format { get; init; } = "ndjson";
public string? ContentDigest { get; init; }
public int? RecordCount { get; init; }
public long? ByteSize { get; init; }
public string? StoragePath { get; init; }
public DateTimeOffset? StartTime { get; init; }
public DateTimeOffset? EndTime { get; init; }
public string? ErrorMessage { get; init; }
public string Metadata { get; init; } = "{}";
public DateTimeOffset CreatedAt { get; init; }
public string? CreatedBy { get; init; }
}

View File

@@ -0,0 +1,17 @@
namespace StellaOps.Policy.Storage.Postgres.Models;
/// <summary>
/// Entity representing an immutable policy configuration snapshot.
/// </summary>
public sealed record SnapshotEntity
{
public Guid Id { get; init; }
public required string TenantId { get; init; }
public Guid PolicyId { get; init; }
public int Version { get; init; }
public required string ContentDigest { get; init; }
public required string Content { get; init; }
public string Metadata { get; init; } = "{}";
public required string CreatedBy { get; init; }
public DateTimeOffset CreatedAt { get; init; }
}

View File

@@ -0,0 +1,20 @@
namespace StellaOps.Policy.Storage.Postgres.Models;
/// <summary>
/// Entity representing an append-only violation event.
/// </summary>
public sealed record ViolationEventEntity
{
public Guid Id { get; init; }
public required string TenantId { get; init; }
public Guid PolicyId { get; init; }
public required string RuleId { get; init; }
public required string Severity { get; init; }
public string? SubjectPurl { get; init; }
public string? SubjectCve { get; init; }
public string Details { get; init; } = "{}";
public string? Remediation { get; init; }
public string? CorrelationId { get; init; }
public DateTimeOffset OccurredAt { get; init; }
public DateTimeOffset CreatedAt { get; init; }
}

View File

@@ -0,0 +1,26 @@
namespace StellaOps.Policy.Storage.Postgres.Models;
/// <summary>
/// Entity representing a background worker job result.
/// </summary>
public sealed record WorkerResultEntity
{
public Guid Id { get; init; }
public required string TenantId { get; init; }
public required string JobType { get; init; }
public required string JobId { get; init; }
public string Status { get; init; } = "pending";
public string? InputHash { get; init; }
public string? OutputHash { get; init; }
public int Progress { get; init; }
public string? Result { get; init; }
public string? ErrorMessage { get; init; }
public int RetryCount { get; init; }
public int MaxRetries { get; init; } = 3;
public DateTimeOffset? ScheduledAt { get; init; }
public DateTimeOffset? StartedAt { get; init; }
public DateTimeOffset? CompletedAt { get; init; }
public string Metadata { get; init; } = "{}";
public DateTimeOffset CreatedAt { get; init; }
public string? CreatedBy { get; init; }
}

View File

@@ -0,0 +1,258 @@
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 conflict detection and resolution operations.
/// </summary>
public sealed class ConflictRepository : RepositoryBase<PolicyDataSource>, IConflictRepository
{
/// <summary>
/// Creates a new conflict repository.
/// </summary>
public ConflictRepository(PolicyDataSource dataSource, ILogger<ConflictRepository> logger)
: base(dataSource, logger)
{
}
/// <inheritdoc />
public async Task<ConflictEntity> CreateAsync(ConflictEntity conflict, CancellationToken cancellationToken = default)
{
const string sql = """
INSERT INTO policy.conflicts (
id, tenant_id, conflict_type, severity, status, left_rule_id,
right_rule_id, affected_scope, description, metadata, created_by
)
VALUES (
@id, @tenant_id, @conflict_type, @severity, @status, @left_rule_id,
@right_rule_id, @affected_scope, @description, @metadata::jsonb, @created_by
)
RETURNING *
""";
await using var connection = await DataSource.OpenConnectionAsync(conflict.TenantId, "writer", cancellationToken)
.ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddConflictParameters(command, conflict);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
return MapConflict(reader);
}
/// <inheritdoc />
public async Task<ConflictEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = "SELECT * FROM policy.conflicts WHERE tenant_id = @tenant_id AND id = @id";
return await QuerySingleOrDefaultAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "id", id);
},
MapConflict,
cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<IReadOnlyList<ConflictEntity>> GetOpenAsync(
string tenantId,
int limit = 100,
int offset = 0,
CancellationToken cancellationToken = default)
{
const string sql = """
SELECT * FROM policy.conflicts
WHERE tenant_id = @tenant_id AND status = 'open'
ORDER BY
CASE severity
WHEN 'critical' THEN 1
WHEN 'high' THEN 2
WHEN 'medium' THEN 3
WHEN 'low' THEN 4
END,
created_at DESC
LIMIT @limit OFFSET @offset
""";
return await QueryAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "limit", limit);
AddParameter(cmd, "offset", offset);
},
MapConflict,
cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<IReadOnlyList<ConflictEntity>> GetByTypeAsync(
string tenantId,
string conflictType,
string? status = null,
int limit = 100,
CancellationToken cancellationToken = default)
{
var sql = """
SELECT * FROM policy.conflicts
WHERE tenant_id = @tenant_id AND conflict_type = @conflict_type
""";
if (!string.IsNullOrEmpty(status))
{
sql += " AND status = @status";
}
sql += " ORDER BY created_at DESC LIMIT @limit";
return await QueryAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "conflict_type", conflictType);
AddParameter(cmd, "limit", limit);
if (!string.IsNullOrEmpty(status))
{
AddParameter(cmd, "status", status);
}
},
MapConflict,
cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<bool> ResolveAsync(
string tenantId,
Guid id,
string resolution,
string resolvedBy,
CancellationToken cancellationToken = default)
{
const string sql = """
UPDATE policy.conflicts
SET status = 'resolved', resolution = @resolution, resolved_by = @resolved_by, resolved_at = NOW()
WHERE tenant_id = @tenant_id AND id = @id AND status = 'open'
""";
var rows = await ExecuteAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "id", id);
AddParameter(cmd, "resolution", resolution);
AddParameter(cmd, "resolved_by", resolvedBy);
},
cancellationToken).ConfigureAwait(false);
return rows > 0;
}
/// <inheritdoc />
public async Task<bool> DismissAsync(
string tenantId,
Guid id,
string dismissedBy,
CancellationToken cancellationToken = default)
{
const string sql = """
UPDATE policy.conflicts
SET status = 'dismissed', resolved_by = @dismissed_by, resolved_at = NOW()
WHERE tenant_id = @tenant_id AND id = @id AND status = 'open'
""";
var rows = await ExecuteAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "id", id);
AddParameter(cmd, "dismissed_by", dismissedBy);
},
cancellationToken).ConfigureAwait(false);
return rows > 0;
}
/// <inheritdoc />
public async Task<Dictionary<string, int>> CountOpenBySeverityAsync(
string tenantId,
CancellationToken cancellationToken = default)
{
const string sql = """
SELECT severity, COUNT(*)::int as count
FROM policy.conflicts
WHERE tenant_id = @tenant_id AND status = 'open'
GROUP BY severity
""";
var results = new Dictionary<string, int>();
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken)
.ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "tenant_id", tenantId);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
var severity = reader.GetString(reader.GetOrdinal("severity"));
var count = reader.GetInt32(reader.GetOrdinal("count"));
results[severity] = count;
}
return results;
}
private static void AddConflictParameters(NpgsqlCommand command, ConflictEntity conflict)
{
AddParameter(command, "id", conflict.Id);
AddParameter(command, "tenant_id", conflict.TenantId);
AddParameter(command, "conflict_type", conflict.ConflictType);
AddParameter(command, "severity", conflict.Severity);
AddParameter(command, "status", conflict.Status);
AddParameter(command, "left_rule_id", conflict.LeftRuleId as object ?? DBNull.Value);
AddParameter(command, "right_rule_id", conflict.RightRuleId as object ?? DBNull.Value);
AddParameter(command, "affected_scope", conflict.AffectedScope as object ?? DBNull.Value);
AddParameter(command, "description", conflict.Description);
AddJsonbParameter(command, "metadata", conflict.Metadata);
AddParameter(command, "created_by", conflict.CreatedBy as object ?? DBNull.Value);
}
private static ConflictEntity MapConflict(NpgsqlDataReader reader) => new()
{
Id = reader.GetGuid(reader.GetOrdinal("id")),
TenantId = reader.GetString(reader.GetOrdinal("tenant_id")),
ConflictType = reader.GetString(reader.GetOrdinal("conflict_type")),
Severity = reader.GetString(reader.GetOrdinal("severity")),
Status = reader.GetString(reader.GetOrdinal("status")),
LeftRuleId = GetNullableString(reader, reader.GetOrdinal("left_rule_id")),
RightRuleId = GetNullableString(reader, reader.GetOrdinal("right_rule_id")),
AffectedScope = GetNullableString(reader, reader.GetOrdinal("affected_scope")),
Description = reader.GetString(reader.GetOrdinal("description")),
Resolution = GetNullableString(reader, reader.GetOrdinal("resolution")),
ResolvedBy = GetNullableString(reader, reader.GetOrdinal("resolved_by")),
ResolvedAt = reader.IsDBNull(reader.GetOrdinal("resolved_at"))
? null
: reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("resolved_at")),
Metadata = reader.GetString(reader.GetOrdinal("metadata")),
CreatedAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("created_at")),
CreatedBy = GetNullableString(reader, reader.GetOrdinal("created_by"))
};
}

View File

@@ -0,0 +1,64 @@
using StellaOps.Policy.Storage.Postgres.Models;
namespace StellaOps.Policy.Storage.Postgres.Repositories;
/// <summary>
/// Repository interface for conflict detection and resolution operations.
/// </summary>
public interface IConflictRepository
{
/// <summary>
/// Creates a new conflict.
/// </summary>
Task<ConflictEntity> CreateAsync(ConflictEntity conflict, CancellationToken cancellationToken = default);
/// <summary>
/// Gets a conflict by ID.
/// </summary>
Task<ConflictEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default);
/// <summary>
/// Gets all open conflicts for a tenant.
/// </summary>
Task<IReadOnlyList<ConflictEntity>> GetOpenAsync(
string tenantId,
int limit = 100,
int offset = 0,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets conflicts by type.
/// </summary>
Task<IReadOnlyList<ConflictEntity>> GetByTypeAsync(
string tenantId,
string conflictType,
string? status = null,
int limit = 100,
CancellationToken cancellationToken = default);
/// <summary>
/// Resolves a conflict.
/// </summary>
Task<bool> ResolveAsync(
string tenantId,
Guid id,
string resolution,
string resolvedBy,
CancellationToken cancellationToken = default);
/// <summary>
/// Dismisses a conflict.
/// </summary>
Task<bool> DismissAsync(
string tenantId,
Guid id,
string dismissedBy,
CancellationToken cancellationToken = default);
/// <summary>
/// Counts open conflicts by severity.
/// </summary>
Task<Dictionary<string, int>> CountOpenBySeverityAsync(
string tenantId,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,64 @@
using StellaOps.Policy.Storage.Postgres.Models;
namespace StellaOps.Policy.Storage.Postgres.Repositories;
/// <summary>
/// Repository interface for ledger export operations.
/// </summary>
public interface ILedgerExportRepository
{
/// <summary>
/// Creates a new ledger export.
/// </summary>
Task<LedgerExportEntity> CreateAsync(LedgerExportEntity export, CancellationToken cancellationToken = default);
/// <summary>
/// Gets a ledger export by ID.
/// </summary>
Task<LedgerExportEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default);
/// <summary>
/// Gets a ledger export by content digest.
/// </summary>
Task<LedgerExportEntity?> GetByDigestAsync(string contentDigest, CancellationToken cancellationToken = default);
/// <summary>
/// Gets all ledger exports for a tenant.
/// </summary>
Task<IReadOnlyList<LedgerExportEntity>> GetAllAsync(
string tenantId,
string? status = null,
int limit = 100,
int offset = 0,
CancellationToken cancellationToken = default);
/// <summary>
/// Updates the status of a ledger export.
/// </summary>
Task<bool> UpdateStatusAsync(
string tenantId,
Guid id,
string status,
string? errorMessage = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Completes a ledger export with results.
/// </summary>
Task<bool> CompleteAsync(
string tenantId,
Guid id,
string contentDigest,
int recordCount,
long byteSize,
string? storagePath,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets the latest completed export for a tenant.
/// </summary>
Task<LedgerExportEntity?> GetLatestCompletedAsync(
string tenantId,
string? exportType = null,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,44 @@
using StellaOps.Policy.Storage.Postgres.Models;
namespace StellaOps.Policy.Storage.Postgres.Repositories;
/// <summary>
/// Repository interface for policy snapshot operations.
/// </summary>
public interface ISnapshotRepository
{
/// <summary>
/// Creates a new snapshot.
/// </summary>
Task<SnapshotEntity> CreateAsync(SnapshotEntity snapshot, CancellationToken cancellationToken = default);
/// <summary>
/// Gets a snapshot by ID.
/// </summary>
Task<SnapshotEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default);
/// <summary>
/// Gets the latest snapshot for a policy.
/// </summary>
Task<SnapshotEntity?> GetLatestByPolicyAsync(string tenantId, Guid policyId, CancellationToken cancellationToken = default);
/// <summary>
/// Gets a snapshot by content digest.
/// </summary>
Task<SnapshotEntity?> GetByDigestAsync(string contentDigest, CancellationToken cancellationToken = default);
/// <summary>
/// Gets all snapshots for a policy.
/// </summary>
Task<IReadOnlyList<SnapshotEntity>> GetByPolicyAsync(
string tenantId,
Guid policyId,
int limit = 100,
int offset = 0,
CancellationToken cancellationToken = default);
/// <summary>
/// Deletes a snapshot.
/// </summary>
Task<bool> DeleteAsync(string tenantId, Guid id, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,63 @@
using StellaOps.Policy.Storage.Postgres.Models;
namespace StellaOps.Policy.Storage.Postgres.Repositories;
/// <summary>
/// Repository interface for append-only violation event operations.
/// </summary>
public interface IViolationEventRepository
{
/// <summary>
/// Appends a new violation event (immutable).
/// </summary>
Task<ViolationEventEntity> AppendAsync(ViolationEventEntity violationEvent, CancellationToken cancellationToken = default);
/// <summary>
/// Appends multiple violation events (immutable).
/// </summary>
Task<int> AppendBatchAsync(IEnumerable<ViolationEventEntity> events, CancellationToken cancellationToken = default);
/// <summary>
/// Gets a violation event by ID.
/// </summary>
Task<ViolationEventEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default);
/// <summary>
/// Gets violation events for a policy.
/// </summary>
Task<IReadOnlyList<ViolationEventEntity>> GetByPolicyAsync(
string tenantId,
Guid policyId,
DateTimeOffset? since = null,
int limit = 100,
int offset = 0,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets violation events by severity.
/// </summary>
Task<IReadOnlyList<ViolationEventEntity>> GetBySeverityAsync(
string tenantId,
string severity,
DateTimeOffset? since = null,
int limit = 100,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets violation events for a PURL.
/// </summary>
Task<IReadOnlyList<ViolationEventEntity>> GetByPurlAsync(
string tenantId,
string purl,
int limit = 100,
CancellationToken cancellationToken = default);
/// <summary>
/// Counts violations by severity for a time range.
/// </summary>
Task<Dictionary<string, int>> CountBySeverityAsync(
string tenantId,
DateTimeOffset since,
DateTimeOffset until,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,83 @@
using StellaOps.Policy.Storage.Postgres.Models;
namespace StellaOps.Policy.Storage.Postgres.Repositories;
/// <summary>
/// Repository interface for worker result operations.
/// </summary>
public interface IWorkerResultRepository
{
/// <summary>
/// Creates a new worker result.
/// </summary>
Task<WorkerResultEntity> CreateAsync(WorkerResultEntity result, CancellationToken cancellationToken = default);
/// <summary>
/// Gets a worker result by ID.
/// </summary>
Task<WorkerResultEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default);
/// <summary>
/// Gets a worker result by job type and job ID.
/// </summary>
Task<WorkerResultEntity?> GetByJobAsync(
string tenantId,
string jobType,
string jobId,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets worker results by status.
/// </summary>
Task<IReadOnlyList<WorkerResultEntity>> GetByStatusAsync(
string tenantId,
string status,
int limit = 100,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets pending worker results ready for execution.
/// </summary>
Task<IReadOnlyList<WorkerResultEntity>> GetPendingAsync(
string? jobType = null,
int limit = 100,
CancellationToken cancellationToken = default);
/// <summary>
/// Updates the status and progress of a worker result.
/// </summary>
Task<bool> UpdateProgressAsync(
string tenantId,
Guid id,
string status,
int progress,
string? errorMessage = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Completes a worker result with the final result.
/// </summary>
Task<bool> CompleteAsync(
string tenantId,
Guid id,
string result,
string? outputHash = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Marks a worker result as failed.
/// </summary>
Task<bool> FailAsync(
string tenantId,
Guid id,
string errorMessage,
CancellationToken cancellationToken = default);
/// <summary>
/// Increments the retry count for a worker result.
/// </summary>
Task<bool> IncrementRetryAsync(
string tenantId,
Guid id,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,253 @@
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 ledger export operations.
/// </summary>
public sealed class LedgerExportRepository : RepositoryBase<PolicyDataSource>, ILedgerExportRepository
{
/// <summary>
/// Creates a new ledger export repository.
/// </summary>
public LedgerExportRepository(PolicyDataSource dataSource, ILogger<LedgerExportRepository> logger)
: base(dataSource, logger)
{
}
/// <inheritdoc />
public async Task<LedgerExportEntity> CreateAsync(LedgerExportEntity export, CancellationToken cancellationToken = default)
{
const string sql = """
INSERT INTO policy.ledger_exports (
id, tenant_id, export_type, status, format, metadata, created_by
)
VALUES (
@id, @tenant_id, @export_type, @status, @format, @metadata::jsonb, @created_by
)
RETURNING *
""";
await using var connection = await DataSource.OpenConnectionAsync(export.TenantId, "writer", cancellationToken)
.ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddExportParameters(command, export);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
return MapExport(reader);
}
/// <inheritdoc />
public async Task<LedgerExportEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = "SELECT * FROM policy.ledger_exports WHERE tenant_id = @tenant_id AND id = @id";
return await QuerySingleOrDefaultAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "id", id);
},
MapExport,
cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<LedgerExportEntity?> GetByDigestAsync(string contentDigest, CancellationToken cancellationToken = default)
{
const string sql = "SELECT * FROM policy.ledger_exports WHERE content_digest = @content_digest LIMIT 1";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "content_digest", contentDigest);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
if (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
return MapExport(reader);
}
return null;
}
/// <inheritdoc />
public async Task<IReadOnlyList<LedgerExportEntity>> GetAllAsync(
string tenantId,
string? status = null,
int limit = 100,
int offset = 0,
CancellationToken cancellationToken = default)
{
var sql = "SELECT * FROM policy.ledger_exports WHERE tenant_id = @tenant_id";
if (!string.IsNullOrEmpty(status))
{
sql += " AND status = @status";
}
sql += " ORDER BY created_at DESC LIMIT @limit OFFSET @offset";
return await QueryAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "limit", limit);
AddParameter(cmd, "offset", offset);
if (!string.IsNullOrEmpty(status))
{
AddParameter(cmd, "status", status);
}
},
MapExport,
cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<bool> UpdateStatusAsync(
string tenantId,
Guid id,
string status,
string? errorMessage = null,
CancellationToken cancellationToken = default)
{
const string sql = """
UPDATE policy.ledger_exports
SET status = @status, error_message = @error_message,
start_time = CASE WHEN @status = 'running' AND start_time IS NULL THEN NOW() ELSE start_time END
WHERE tenant_id = @tenant_id AND id = @id
""";
var rows = await ExecuteAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "id", id);
AddParameter(cmd, "status", status);
AddParameter(cmd, "error_message", errorMessage as object ?? DBNull.Value);
},
cancellationToken).ConfigureAwait(false);
return rows > 0;
}
/// <inheritdoc />
public async Task<bool> CompleteAsync(
string tenantId,
Guid id,
string contentDigest,
int recordCount,
long byteSize,
string? storagePath,
CancellationToken cancellationToken = default)
{
const string sql = """
UPDATE policy.ledger_exports
SET status = 'completed',
content_digest = @content_digest,
record_count = @record_count,
byte_size = @byte_size,
storage_path = @storage_path,
end_time = NOW()
WHERE tenant_id = @tenant_id AND id = @id
""";
var rows = await ExecuteAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "id", id);
AddParameter(cmd, "content_digest", contentDigest);
AddParameter(cmd, "record_count", recordCount);
AddParameter(cmd, "byte_size", byteSize);
AddParameter(cmd, "storage_path", storagePath as object ?? DBNull.Value);
},
cancellationToken).ConfigureAwait(false);
return rows > 0;
}
/// <inheritdoc />
public async Task<LedgerExportEntity?> GetLatestCompletedAsync(
string tenantId,
string? exportType = null,
CancellationToken cancellationToken = default)
{
var sql = """
SELECT * FROM policy.ledger_exports
WHERE tenant_id = @tenant_id AND status = 'completed'
""";
if (!string.IsNullOrEmpty(exportType))
{
sql += " AND export_type = @export_type";
}
sql += " ORDER BY end_time DESC LIMIT 1";
return await QuerySingleOrDefaultAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
if (!string.IsNullOrEmpty(exportType))
{
AddParameter(cmd, "export_type", exportType);
}
},
MapExport,
cancellationToken).ConfigureAwait(false);
}
private static void AddExportParameters(NpgsqlCommand command, LedgerExportEntity export)
{
AddParameter(command, "id", export.Id);
AddParameter(command, "tenant_id", export.TenantId);
AddParameter(command, "export_type", export.ExportType);
AddParameter(command, "status", export.Status);
AddParameter(command, "format", export.Format);
AddJsonbParameter(command, "metadata", export.Metadata);
AddParameter(command, "created_by", export.CreatedBy as object ?? DBNull.Value);
}
private static LedgerExportEntity MapExport(NpgsqlDataReader reader) => new()
{
Id = reader.GetGuid(reader.GetOrdinal("id")),
TenantId = reader.GetString(reader.GetOrdinal("tenant_id")),
ExportType = reader.GetString(reader.GetOrdinal("export_type")),
Status = reader.GetString(reader.GetOrdinal("status")),
Format = reader.GetString(reader.GetOrdinal("format")),
ContentDigest = GetNullableString(reader, reader.GetOrdinal("content_digest")),
RecordCount = reader.IsDBNull(reader.GetOrdinal("record_count"))
? null
: reader.GetInt32(reader.GetOrdinal("record_count")),
ByteSize = reader.IsDBNull(reader.GetOrdinal("byte_size"))
? null
: reader.GetInt64(reader.GetOrdinal("byte_size")),
StoragePath = GetNullableString(reader, reader.GetOrdinal("storage_path")),
StartTime = reader.IsDBNull(reader.GetOrdinal("start_time"))
? null
: reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("start_time")),
EndTime = reader.IsDBNull(reader.GetOrdinal("end_time"))
? null
: reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("end_time")),
ErrorMessage = GetNullableString(reader, reader.GetOrdinal("error_message")),
Metadata = reader.GetString(reader.GetOrdinal("metadata")),
CreatedAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("created_at")),
CreatedBy = GetNullableString(reader, reader.GetOrdinal("created_by"))
};
}

View File

@@ -0,0 +1,179 @@
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 snapshot operations.
/// </summary>
public sealed class SnapshotRepository : RepositoryBase<PolicyDataSource>, ISnapshotRepository
{
/// <summary>
/// Creates a new snapshot repository.
/// </summary>
public SnapshotRepository(PolicyDataSource dataSource, ILogger<SnapshotRepository> logger)
: base(dataSource, logger)
{
}
/// <inheritdoc />
public async Task<SnapshotEntity> CreateAsync(SnapshotEntity snapshot, CancellationToken cancellationToken = default)
{
const string sql = """
INSERT INTO policy.snapshots (
id, tenant_id, policy_id, version, content_digest, content,
created_by, metadata
)
VALUES (
@id, @tenant_id, @policy_id, @version, @content_digest, @content::jsonb,
@created_by, @metadata::jsonb
)
RETURNING *
""";
await using var connection = await DataSource.OpenConnectionAsync(snapshot.TenantId, "writer", cancellationToken)
.ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddSnapshotParameters(command, snapshot);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
return MapSnapshot(reader);
}
/// <inheritdoc />
public async Task<SnapshotEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = "SELECT * FROM policy.snapshots WHERE tenant_id = @tenant_id AND id = @id";
return await QuerySingleOrDefaultAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "id", id);
},
MapSnapshot,
cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<SnapshotEntity?> GetLatestByPolicyAsync(
string tenantId,
Guid policyId,
CancellationToken cancellationToken = default)
{
const string sql = """
SELECT * FROM policy.snapshots
WHERE tenant_id = @tenant_id AND policy_id = @policy_id
ORDER BY version DESC
LIMIT 1
""";
return await QuerySingleOrDefaultAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "policy_id", policyId);
},
MapSnapshot,
cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<SnapshotEntity?> GetByDigestAsync(string contentDigest, CancellationToken cancellationToken = default)
{
const string sql = "SELECT * FROM policy.snapshots WHERE content_digest = @content_digest LIMIT 1";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "content_digest", contentDigest);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
if (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
return MapSnapshot(reader);
}
return null;
}
/// <inheritdoc />
public async Task<IReadOnlyList<SnapshotEntity>> GetByPolicyAsync(
string tenantId,
Guid policyId,
int limit = 100,
int offset = 0,
CancellationToken cancellationToken = default)
{
const string sql = """
SELECT * FROM policy.snapshots
WHERE tenant_id = @tenant_id AND policy_id = @policy_id
ORDER BY version DESC
LIMIT @limit OFFSET @offset
""";
return await QueryAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "policy_id", policyId);
AddParameter(cmd, "limit", limit);
AddParameter(cmd, "offset", offset);
},
MapSnapshot,
cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<bool> DeleteAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = "DELETE FROM policy.snapshots WHERE tenant_id = @tenant_id AND id = @id";
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 AddSnapshotParameters(NpgsqlCommand command, SnapshotEntity snapshot)
{
AddParameter(command, "id", snapshot.Id);
AddParameter(command, "tenant_id", snapshot.TenantId);
AddParameter(command, "policy_id", snapshot.PolicyId);
AddParameter(command, "version", snapshot.Version);
AddParameter(command, "content_digest", snapshot.ContentDigest);
AddParameter(command, "content", snapshot.Content);
AddParameter(command, "created_by", snapshot.CreatedBy);
AddJsonbParameter(command, "metadata", snapshot.Metadata);
}
private static SnapshotEntity MapSnapshot(NpgsqlDataReader reader) => new()
{
Id = reader.GetGuid(reader.GetOrdinal("id")),
TenantId = reader.GetString(reader.GetOrdinal("tenant_id")),
PolicyId = reader.GetGuid(reader.GetOrdinal("policy_id")),
Version = reader.GetInt32(reader.GetOrdinal("version")),
ContentDigest = reader.GetString(reader.GetOrdinal("content_digest")),
Content = reader.GetString(reader.GetOrdinal("content")),
CreatedAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("created_at")),
CreatedBy = reader.GetString(reader.GetOrdinal("created_by")),
Metadata = reader.GetString(reader.GetOrdinal("metadata"))
};
}

View File

@@ -0,0 +1,265 @@
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 append-only violation event operations.
/// </summary>
public sealed class ViolationEventRepository : RepositoryBase<PolicyDataSource>, IViolationEventRepository
{
/// <summary>
/// Creates a new violation event repository.
/// </summary>
public ViolationEventRepository(PolicyDataSource dataSource, ILogger<ViolationEventRepository> logger)
: base(dataSource, logger)
{
}
/// <inheritdoc />
public async Task<ViolationEventEntity> AppendAsync(ViolationEventEntity violationEvent, CancellationToken cancellationToken = default)
{
const string sql = """
INSERT INTO policy.violation_events (
id, tenant_id, policy_id, rule_id, severity, subject_purl,
subject_cve, details, remediation, correlation_id, occurred_at
)
VALUES (
@id, @tenant_id, @policy_id, @rule_id, @severity, @subject_purl,
@subject_cve, @details::jsonb, @remediation, @correlation_id, @occurred_at
)
RETURNING *
""";
await using var connection = await DataSource.OpenConnectionAsync(violationEvent.TenantId, "writer", cancellationToken)
.ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddViolationParameters(command, violationEvent);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
return MapViolation(reader);
}
/// <inheritdoc />
public async Task<int> AppendBatchAsync(IEnumerable<ViolationEventEntity> events, CancellationToken cancellationToken = default)
{
var eventList = events.ToList();
if (eventList.Count == 0) return 0;
const string sql = """
INSERT INTO policy.violation_events (
id, tenant_id, policy_id, rule_id, severity, subject_purl,
subject_cve, details, remediation, correlation_id, occurred_at
)
VALUES (
@id, @tenant_id, @policy_id, @rule_id, @severity, @subject_purl,
@subject_cve, @details::jsonb, @remediation, @correlation_id, @occurred_at
)
""";
var tenantId = eventList[0].TenantId;
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken)
.ConfigureAwait(false);
var count = 0;
foreach (var evt in eventList)
{
await using var command = CreateCommand(sql, connection);
AddViolationParameters(command, evt);
count += await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
return count;
}
/// <inheritdoc />
public async Task<ViolationEventEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = "SELECT * FROM policy.violation_events WHERE tenant_id = @tenant_id AND id = @id";
return await QuerySingleOrDefaultAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "id", id);
},
MapViolation,
cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<IReadOnlyList<ViolationEventEntity>> GetByPolicyAsync(
string tenantId,
Guid policyId,
DateTimeOffset? since = null,
int limit = 100,
int offset = 0,
CancellationToken cancellationToken = default)
{
var sql = """
SELECT * FROM policy.violation_events
WHERE tenant_id = @tenant_id AND policy_id = @policy_id
""";
if (since.HasValue)
{
sql += " AND occurred_at >= @since";
}
sql += " ORDER BY occurred_at DESC LIMIT @limit OFFSET @offset";
return await QueryAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "policy_id", policyId);
AddParameter(cmd, "limit", limit);
AddParameter(cmd, "offset", offset);
if (since.HasValue)
{
AddParameter(cmd, "since", since.Value);
}
},
MapViolation,
cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<IReadOnlyList<ViolationEventEntity>> GetBySeverityAsync(
string tenantId,
string severity,
DateTimeOffset? since = null,
int limit = 100,
CancellationToken cancellationToken = default)
{
var sql = """
SELECT * FROM policy.violation_events
WHERE tenant_id = @tenant_id AND severity = @severity
""";
if (since.HasValue)
{
sql += " AND occurred_at >= @since";
}
sql += " ORDER BY occurred_at DESC LIMIT @limit";
return await QueryAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "severity", severity);
AddParameter(cmd, "limit", limit);
if (since.HasValue)
{
AddParameter(cmd, "since", since.Value);
}
},
MapViolation,
cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<IReadOnlyList<ViolationEventEntity>> GetByPurlAsync(
string tenantId,
string purl,
int limit = 100,
CancellationToken cancellationToken = default)
{
const string sql = """
SELECT * FROM policy.violation_events
WHERE tenant_id = @tenant_id AND subject_purl = @purl
ORDER BY occurred_at DESC
LIMIT @limit
""";
return await QueryAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "purl", purl);
AddParameter(cmd, "limit", limit);
},
MapViolation,
cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<Dictionary<string, int>> CountBySeverityAsync(
string tenantId,
DateTimeOffset since,
DateTimeOffset until,
CancellationToken cancellationToken = default)
{
const string sql = """
SELECT severity, COUNT(*)::int as count
FROM policy.violation_events
WHERE tenant_id = @tenant_id AND occurred_at >= @since AND occurred_at < @until
GROUP BY severity
""";
var results = new Dictionary<string, int>();
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, "since", since);
AddParameter(command, "until", until);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
var severity = reader.GetString(reader.GetOrdinal("severity"));
var count = reader.GetInt32(reader.GetOrdinal("count"));
results[severity] = count;
}
return results;
}
private static void AddViolationParameters(NpgsqlCommand command, ViolationEventEntity violation)
{
AddParameter(command, "id", violation.Id);
AddParameter(command, "tenant_id", violation.TenantId);
AddParameter(command, "policy_id", violation.PolicyId);
AddParameter(command, "rule_id", violation.RuleId);
AddParameter(command, "severity", violation.Severity);
AddParameter(command, "subject_purl", violation.SubjectPurl as object ?? DBNull.Value);
AddParameter(command, "subject_cve", violation.SubjectCve as object ?? DBNull.Value);
AddJsonbParameter(command, "details", violation.Details);
AddParameter(command, "remediation", violation.Remediation as object ?? DBNull.Value);
AddParameter(command, "correlation_id", violation.CorrelationId as object ?? DBNull.Value);
AddParameter(command, "occurred_at", violation.OccurredAt);
}
private static ViolationEventEntity MapViolation(NpgsqlDataReader reader) => new()
{
Id = reader.GetGuid(reader.GetOrdinal("id")),
TenantId = reader.GetString(reader.GetOrdinal("tenant_id")),
PolicyId = reader.GetGuid(reader.GetOrdinal("policy_id")),
RuleId = reader.GetString(reader.GetOrdinal("rule_id")),
Severity = reader.GetString(reader.GetOrdinal("severity")),
SubjectPurl = GetNullableString(reader, reader.GetOrdinal("subject_purl")),
SubjectCve = GetNullableString(reader, reader.GetOrdinal("subject_cve")),
Details = reader.GetString(reader.GetOrdinal("details")),
Remediation = GetNullableString(reader, reader.GetOrdinal("remediation")),
CorrelationId = GetNullableString(reader, reader.GetOrdinal("correlation_id")),
OccurredAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("occurred_at")),
CreatedAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("created_at"))
};
}

View File

@@ -0,0 +1,310 @@
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 worker result operations.
/// </summary>
public sealed class WorkerResultRepository : RepositoryBase<PolicyDataSource>, IWorkerResultRepository
{
/// <summary>
/// Creates a new worker result repository.
/// </summary>
public WorkerResultRepository(PolicyDataSource dataSource, ILogger<WorkerResultRepository> logger)
: base(dataSource, logger)
{
}
/// <inheritdoc />
public async Task<WorkerResultEntity> CreateAsync(WorkerResultEntity result, CancellationToken cancellationToken = default)
{
const string sql = """
INSERT INTO policy.worker_results (
id, tenant_id, job_type, job_id, status, progress,
input_hash, max_retries, scheduled_at, metadata, created_by
)
VALUES (
@id, @tenant_id, @job_type, @job_id, @status, @progress,
@input_hash, @max_retries, @scheduled_at, @metadata::jsonb, @created_by
)
RETURNING *
""";
await using var connection = await DataSource.OpenConnectionAsync(result.TenantId, "writer", cancellationToken)
.ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddResultParameters(command, result);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
return MapResult(reader);
}
/// <inheritdoc />
public async Task<WorkerResultEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = "SELECT * FROM policy.worker_results WHERE tenant_id = @tenant_id AND id = @id";
return await QuerySingleOrDefaultAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "id", id);
},
MapResult,
cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<WorkerResultEntity?> GetByJobAsync(
string tenantId,
string jobType,
string jobId,
CancellationToken cancellationToken = default)
{
const string sql = """
SELECT * FROM policy.worker_results
WHERE tenant_id = @tenant_id AND job_type = @job_type AND job_id = @job_id
""";
return await QuerySingleOrDefaultAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "job_type", jobType);
AddParameter(cmd, "job_id", jobId);
},
MapResult,
cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<IReadOnlyList<WorkerResultEntity>> GetByStatusAsync(
string tenantId,
string status,
int limit = 100,
CancellationToken cancellationToken = default)
{
const string sql = """
SELECT * FROM policy.worker_results
WHERE tenant_id = @tenant_id AND status = @status
ORDER BY created_at DESC
LIMIT @limit
""";
return await QueryAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "status", status);
AddParameter(cmd, "limit", limit);
},
MapResult,
cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<IReadOnlyList<WorkerResultEntity>> GetPendingAsync(
string? jobType = null,
int limit = 100,
CancellationToken cancellationToken = default)
{
var sql = """
SELECT * FROM policy.worker_results
WHERE status = 'pending'
""";
if (!string.IsNullOrEmpty(jobType))
{
sql += " AND job_type = @job_type";
}
sql += " ORDER BY scheduled_at ASC NULLS LAST, created_at ASC LIMIT @limit";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "limit", limit);
if (!string.IsNullOrEmpty(jobType))
{
AddParameter(command, "job_type", jobType);
}
var results = new List<WorkerResultEntity>();
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
results.Add(MapResult(reader));
}
return results;
}
/// <inheritdoc />
public async Task<bool> UpdateProgressAsync(
string tenantId,
Guid id,
string status,
int progress,
string? errorMessage = null,
CancellationToken cancellationToken = default)
{
const string sql = """
UPDATE policy.worker_results
SET status = @status, progress = @progress, error_message = @error_message,
started_at = CASE WHEN @status = 'running' AND started_at IS NULL THEN NOW() ELSE started_at END
WHERE tenant_id = @tenant_id AND id = @id
""";
var rows = await ExecuteAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "id", id);
AddParameter(cmd, "status", status);
AddParameter(cmd, "progress", progress);
AddParameter(cmd, "error_message", errorMessage as object ?? DBNull.Value);
},
cancellationToken).ConfigureAwait(false);
return rows > 0;
}
/// <inheritdoc />
public async Task<bool> CompleteAsync(
string tenantId,
Guid id,
string result,
string? outputHash = null,
CancellationToken cancellationToken = default)
{
const string sql = """
UPDATE policy.worker_results
SET status = 'completed', progress = 100, result = @result::jsonb,
output_hash = @output_hash, completed_at = NOW()
WHERE tenant_id = @tenant_id AND id = @id
""";
var rows = await ExecuteAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "id", id);
AddParameter(cmd, "result", result);
AddParameter(cmd, "output_hash", outputHash as object ?? DBNull.Value);
},
cancellationToken).ConfigureAwait(false);
return rows > 0;
}
/// <inheritdoc />
public async Task<bool> FailAsync(
string tenantId,
Guid id,
string errorMessage,
CancellationToken cancellationToken = default)
{
const string sql = """
UPDATE policy.worker_results
SET status = 'failed', error_message = @error_message, completed_at = NOW()
WHERE tenant_id = @tenant_id AND id = @id
""";
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<bool> IncrementRetryAsync(
string tenantId,
Guid id,
CancellationToken cancellationToken = default)
{
const string sql = """
UPDATE policy.worker_results
SET retry_count = retry_count + 1, status = 'pending', started_at = NULL
WHERE tenant_id = @tenant_id AND id = @id AND retry_count < max_retries
""";
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 AddResultParameters(NpgsqlCommand command, WorkerResultEntity result)
{
AddParameter(command, "id", result.Id);
AddParameter(command, "tenant_id", result.TenantId);
AddParameter(command, "job_type", result.JobType);
AddParameter(command, "job_id", result.JobId);
AddParameter(command, "status", result.Status);
AddParameter(command, "progress", result.Progress);
AddParameter(command, "input_hash", result.InputHash as object ?? DBNull.Value);
AddParameter(command, "max_retries", result.MaxRetries);
AddParameter(command, "scheduled_at", result.ScheduledAt as object ?? DBNull.Value);
AddJsonbParameter(command, "metadata", result.Metadata);
AddParameter(command, "created_by", result.CreatedBy as object ?? DBNull.Value);
}
private static WorkerResultEntity MapResult(NpgsqlDataReader reader) => new()
{
Id = reader.GetGuid(reader.GetOrdinal("id")),
TenantId = reader.GetString(reader.GetOrdinal("tenant_id")),
JobType = reader.GetString(reader.GetOrdinal("job_type")),
JobId = reader.GetString(reader.GetOrdinal("job_id")),
Status = reader.GetString(reader.GetOrdinal("status")),
Progress = reader.GetInt32(reader.GetOrdinal("progress")),
Result = GetNullableString(reader, reader.GetOrdinal("result")),
InputHash = GetNullableString(reader, reader.GetOrdinal("input_hash")),
OutputHash = GetNullableString(reader, reader.GetOrdinal("output_hash")),
ErrorMessage = GetNullableString(reader, reader.GetOrdinal("error_message")),
RetryCount = reader.GetInt32(reader.GetOrdinal("retry_count")),
MaxRetries = reader.GetInt32(reader.GetOrdinal("max_retries")),
ScheduledAt = reader.IsDBNull(reader.GetOrdinal("scheduled_at"))
? null
: reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("scheduled_at")),
StartedAt = reader.IsDBNull(reader.GetOrdinal("started_at"))
? null
: reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("started_at")),
CompletedAt = reader.IsDBNull(reader.GetOrdinal("completed_at"))
? null
: reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("completed_at")),
Metadata = reader.GetString(reader.GetOrdinal("metadata")),
CreatedAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("created_at")),
CreatedBy = GetNullableString(reader, reader.GetOrdinal("created_by"))
};
}

View File

@@ -37,6 +37,11 @@ public static class ServiceCollectionExtensions
services.AddScoped<IReceiptRepository, PostgresReceiptRepository>();
services.AddScoped<IExplanationRepository, ExplanationRepository>();
services.AddScoped<IPolicyAuditRepository, PolicyAuditRepository>();
services.AddScoped<ISnapshotRepository, SnapshotRepository>();
services.AddScoped<IViolationEventRepository, ViolationEventRepository>();
services.AddScoped<IConflictRepository, ConflictRepository>();
services.AddScoped<ILedgerExportRepository, LedgerExportRepository>();
services.AddScoped<IWorkerResultRepository, WorkerResultRepository>();
return services;
}
@@ -64,6 +69,11 @@ public static class ServiceCollectionExtensions
services.AddScoped<IReceiptRepository, PostgresReceiptRepository>();
services.AddScoped<IExplanationRepository, ExplanationRepository>();
services.AddScoped<IPolicyAuditRepository, PolicyAuditRepository>();
services.AddScoped<ISnapshotRepository, SnapshotRepository>();
services.AddScoped<IViolationEventRepository, ViolationEventRepository>();
services.AddScoped<IConflictRepository, ConflictRepository>();
services.AddScoped<ILedgerExportRepository, LedgerExportRepository>();
services.AddScoped<IWorkerResultRepository, WorkerResultRepository>();
return services;
}