up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled

This commit is contained in:
StellaOps Bot
2025-11-29 11:08:08 +02:00
parent 7e7be4d2fd
commit 3488b22c0c
102 changed files with 18487 additions and 969 deletions

View File

@@ -0,0 +1,18 @@
namespace StellaOps.Policy.Storage.Postgres.Models;
/// <summary>
/// Entity representing an audit log entry for the policy module.
/// </summary>
public sealed class PolicyAuditEntity
{
public long Id { get; init; }
public required string TenantId { get; init; }
public Guid? UserId { get; init; }
public required string Action { get; init; }
public required string ResourceType { get; init; }
public string? ResourceId { get; init; }
public string? OldValue { get; init; }
public string? NewValue { get; init; }
public string? CorrelationId { get; init; }
public DateTimeOffset CreatedAt { get; init; }
}

View File

@@ -0,0 +1,162 @@
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 explanation operations.
/// </summary>
public sealed class ExplanationRepository : RepositoryBase<PolicyDataSource>, IExplanationRepository
{
public ExplanationRepository(PolicyDataSource dataSource, ILogger<ExplanationRepository> logger)
: base(dataSource, logger) { }
public async Task<ExplanationEntity?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, evaluation_run_id, rule_id, rule_name, result, severity, message, details, remediation, resource_path, line_number, created_at
FROM policy.explanations WHERE id = @id
""";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "id", id);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
return await reader.ReadAsync(cancellationToken).ConfigureAwait(false) ? MapExplanation(reader) : null;
}
public async Task<IReadOnlyList<ExplanationEntity>> GetByEvaluationRunIdAsync(Guid evaluationRunId, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, evaluation_run_id, rule_id, rule_name, result, severity, message, details, remediation, resource_path, line_number, created_at
FROM policy.explanations WHERE evaluation_run_id = @evaluation_run_id
ORDER BY created_at
""";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "evaluation_run_id", evaluationRunId);
var results = new List<ExplanationEntity>();
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
results.Add(MapExplanation(reader));
return results;
}
public async Task<IReadOnlyList<ExplanationEntity>> GetByEvaluationRunIdAndResultAsync(Guid evaluationRunId, RuleResult result, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, evaluation_run_id, rule_id, rule_name, result, severity, message, details, remediation, resource_path, line_number, created_at
FROM policy.explanations WHERE evaluation_run_id = @evaluation_run_id AND result = @result
ORDER BY severity DESC, created_at
""";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "evaluation_run_id", evaluationRunId);
AddParameter(command, "result", ResultToString(result));
var results = new List<ExplanationEntity>();
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
results.Add(MapExplanation(reader));
return results;
}
public async Task<ExplanationEntity> CreateAsync(ExplanationEntity explanation, CancellationToken cancellationToken = default)
{
const string sql = """
INSERT INTO policy.explanations (id, evaluation_run_id, rule_id, rule_name, result, severity, message, details, remediation, resource_path, line_number)
VALUES (@id, @evaluation_run_id, @rule_id, @rule_name, @result, @severity, @message, @details::jsonb, @remediation, @resource_path, @line_number)
RETURNING *
""";
var id = explanation.Id == Guid.Empty ? Guid.NewGuid() : explanation.Id;
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "id", id);
AddParameter(command, "evaluation_run_id", explanation.EvaluationRunId);
AddParameter(command, "rule_id", explanation.RuleId);
AddParameter(command, "rule_name", explanation.RuleName);
AddParameter(command, "result", ResultToString(explanation.Result));
AddParameter(command, "severity", explanation.Severity);
AddParameter(command, "message", explanation.Message);
AddJsonbParameter(command, "details", explanation.Details);
AddParameter(command, "remediation", explanation.Remediation);
AddParameter(command, "resource_path", explanation.ResourcePath);
AddParameter(command, "line_number", explanation.LineNumber);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
return MapExplanation(reader);
}
public async Task<int> CreateBatchAsync(IEnumerable<ExplanationEntity> explanations, CancellationToken cancellationToken = default)
{
const string sql = """
INSERT INTO policy.explanations (id, evaluation_run_id, rule_id, rule_name, result, severity, message, details, remediation, resource_path, line_number)
VALUES (@id, @evaluation_run_id, @rule_id, @rule_name, @result, @severity, @message, @details::jsonb, @remediation, @resource_path, @line_number)
""";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
var count = 0;
foreach (var explanation in explanations)
{
await using var command = CreateCommand(sql, connection);
var id = explanation.Id == Guid.Empty ? Guid.NewGuid() : explanation.Id;
AddParameter(command, "id", id);
AddParameter(command, "evaluation_run_id", explanation.EvaluationRunId);
AddParameter(command, "rule_id", explanation.RuleId);
AddParameter(command, "rule_name", explanation.RuleName);
AddParameter(command, "result", ResultToString(explanation.Result));
AddParameter(command, "severity", explanation.Severity);
AddParameter(command, "message", explanation.Message);
AddJsonbParameter(command, "details", explanation.Details);
AddParameter(command, "remediation", explanation.Remediation);
AddParameter(command, "resource_path", explanation.ResourcePath);
AddParameter(command, "line_number", explanation.LineNumber);
count += await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
return count;
}
public async Task<bool> DeleteByEvaluationRunIdAsync(Guid evaluationRunId, CancellationToken cancellationToken = default)
{
const string sql = "DELETE FROM policy.explanations WHERE evaluation_run_id = @evaluation_run_id";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "evaluation_run_id", evaluationRunId);
var rows = await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
return rows > 0;
}
private static ExplanationEntity MapExplanation(NpgsqlDataReader reader) => new()
{
Id = reader.GetGuid(0),
EvaluationRunId = reader.GetGuid(1),
RuleId = GetNullableGuid(reader, 2),
RuleName = reader.GetString(3),
Result = ParseResult(reader.GetString(4)),
Severity = reader.GetString(5),
Message = GetNullableString(reader, 6),
Details = reader.GetString(7),
Remediation = GetNullableString(reader, 8),
ResourcePath = GetNullableString(reader, 9),
LineNumber = reader.IsDBNull(10) ? null : reader.GetInt32(10),
CreatedAt = reader.GetFieldValue<DateTimeOffset>(11)
};
private static string ResultToString(RuleResult result) => result switch
{
RuleResult.Pass => "pass",
RuleResult.Fail => "fail",
RuleResult.Skip => "skip",
RuleResult.Error => "error",
_ => throw new ArgumentException($"Unknown result: {result}")
};
private static RuleResult ParseResult(string result) => result switch
{
"pass" => RuleResult.Pass,
"fail" => RuleResult.Fail,
"skip" => RuleResult.Skip,
"error" => RuleResult.Error,
_ => throw new ArgumentException($"Unknown result: {result}")
};
}

View File

@@ -0,0 +1,16 @@
using StellaOps.Policy.Storage.Postgres.Models;
namespace StellaOps.Policy.Storage.Postgres.Repositories;
/// <summary>
/// Repository interface for explanation operations.
/// </summary>
public interface IExplanationRepository
{
Task<ExplanationEntity?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
Task<IReadOnlyList<ExplanationEntity>> GetByEvaluationRunIdAsync(Guid evaluationRunId, CancellationToken cancellationToken = default);
Task<IReadOnlyList<ExplanationEntity>> GetByEvaluationRunIdAndResultAsync(Guid evaluationRunId, RuleResult result, CancellationToken cancellationToken = default);
Task<ExplanationEntity> CreateAsync(ExplanationEntity explanation, CancellationToken cancellationToken = default);
Task<int> CreateBatchAsync(IEnumerable<ExplanationEntity> explanations, CancellationToken cancellationToken = default);
Task<bool> DeleteByEvaluationRunIdAsync(Guid evaluationRunId, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,15 @@
using StellaOps.Policy.Storage.Postgres.Models;
namespace StellaOps.Policy.Storage.Postgres.Repositories;
/// <summary>
/// Repository interface for policy audit operations.
/// </summary>
public interface IPolicyAuditRepository
{
Task<long> CreateAsync(PolicyAuditEntity audit, CancellationToken cancellationToken = default);
Task<IReadOnlyList<PolicyAuditEntity>> ListAsync(string tenantId, int limit = 100, int offset = 0, CancellationToken cancellationToken = default);
Task<IReadOnlyList<PolicyAuditEntity>> GetByResourceAsync(string tenantId, string resourceType, string? resourceId = null, int limit = 100, CancellationToken cancellationToken = default);
Task<IReadOnlyList<PolicyAuditEntity>> GetByCorrelationIdAsync(string tenantId, string correlationId, CancellationToken cancellationToken = default);
Task<int> DeleteOldAsync(DateTimeOffset cutoff, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,105 @@
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 audit operations.
/// </summary>
public sealed class PolicyAuditRepository : RepositoryBase<PolicyDataSource>, IPolicyAuditRepository
{
public PolicyAuditRepository(PolicyDataSource dataSource, ILogger<PolicyAuditRepository> logger)
: base(dataSource, logger) { }
public async Task<long> CreateAsync(PolicyAuditEntity audit, CancellationToken cancellationToken = default)
{
const string sql = """
INSERT INTO policy.audit (tenant_id, user_id, action, resource_type, resource_id, old_value, new_value, correlation_id)
VALUES (@tenant_id, @user_id, @action, @resource_type, @resource_id, @old_value::jsonb, @new_value::jsonb, @correlation_id)
RETURNING id
""";
await using var connection = await DataSource.OpenConnectionAsync(audit.TenantId, "writer", cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "tenant_id", audit.TenantId);
AddParameter(command, "user_id", audit.UserId);
AddParameter(command, "action", audit.Action);
AddParameter(command, "resource_type", audit.ResourceType);
AddParameter(command, "resource_id", audit.ResourceId);
AddJsonbParameter(command, "old_value", audit.OldValue);
AddJsonbParameter(command, "new_value", audit.NewValue);
AddParameter(command, "correlation_id", audit.CorrelationId);
var result = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
return (long)result!;
}
public async Task<IReadOnlyList<PolicyAuditEntity>> ListAsync(string tenantId, int limit = 100, int offset = 0, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, user_id, action, resource_type, resource_id, old_value, new_value, correlation_id, created_at
FROM policy.audit WHERE tenant_id = @tenant_id
ORDER BY created_at DESC LIMIT @limit OFFSET @offset
""";
return await QueryAsync(tenantId, sql, cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "limit", limit);
AddParameter(cmd, "offset", offset);
}, MapAudit, cancellationToken).ConfigureAwait(false);
}
public async Task<IReadOnlyList<PolicyAuditEntity>> GetByResourceAsync(string tenantId, string resourceType, string? resourceId = null, int limit = 100, CancellationToken cancellationToken = default)
{
var sql = """
SELECT id, tenant_id, user_id, action, resource_type, resource_id, old_value, new_value, correlation_id, created_at
FROM policy.audit WHERE tenant_id = @tenant_id AND resource_type = @resource_type
""";
if (resourceId != null) sql += " AND resource_id = @resource_id";
sql += " ORDER BY created_at DESC LIMIT @limit";
return await QueryAsync(tenantId, sql, cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "resource_type", resourceType);
if (resourceId != null) AddParameter(cmd, "resource_id", resourceId);
AddParameter(cmd, "limit", limit);
}, MapAudit, cancellationToken).ConfigureAwait(false);
}
public async Task<IReadOnlyList<PolicyAuditEntity>> GetByCorrelationIdAsync(string tenantId, string correlationId, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, user_id, action, resource_type, resource_id, old_value, new_value, correlation_id, created_at
FROM policy.audit WHERE tenant_id = @tenant_id AND correlation_id = @correlation_id
ORDER BY created_at
""";
return await QueryAsync(tenantId, sql,
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "correlation_id", correlationId); },
MapAudit, cancellationToken).ConfigureAwait(false);
}
public async Task<int> DeleteOldAsync(DateTimeOffset cutoff, CancellationToken cancellationToken = default)
{
const string sql = "DELETE FROM policy.audit WHERE created_at < @cutoff";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "cutoff", cutoff);
return await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
private static PolicyAuditEntity MapAudit(NpgsqlDataReader reader) => new()
{
Id = reader.GetInt64(0),
TenantId = reader.GetString(1),
UserId = GetNullableGuid(reader, 2),
Action = reader.GetString(3),
ResourceType = reader.GetString(4),
ResourceId = GetNullableString(reader, 5),
OldValue = GetNullableString(reader, 6),
NewValue = GetNullableString(reader, 7),
CorrelationId = GetNullableString(reader, 8),
CreatedAt = reader.GetFieldValue<DateTimeOffset>(9)
};
}

View File

@@ -35,6 +35,8 @@ public static class ServiceCollectionExtensions
services.AddScoped<IEvaluationRunRepository, EvaluationRunRepository>();
services.AddScoped<IExceptionRepository, ExceptionRepository>();
services.AddScoped<IReceiptRepository, PostgresReceiptRepository>();
services.AddScoped<IExplanationRepository, ExplanationRepository>();
services.AddScoped<IPolicyAuditRepository, PolicyAuditRepository>();
return services;
}
@@ -60,6 +62,8 @@ public static class ServiceCollectionExtensions
services.AddScoped<IEvaluationRunRepository, EvaluationRunRepository>();
services.AddScoped<IExceptionRepository, ExceptionRepository>();
services.AddScoped<IReceiptRepository, PostgresReceiptRepository>();
services.AddScoped<IExplanationRepository, ExplanationRepository>();
services.AddScoped<IPolicyAuditRepository, PolicyAuditRepository>();
return services;
}