save progress

This commit is contained in:
StellaOps Bot
2025-12-20 12:15:16 +02:00
parent 439f10966b
commit 0ada1b583f
95 changed files with 12400 additions and 65 deletions

View File

@@ -0,0 +1,98 @@
using StellaOps.Policy.Unknowns.Models;
namespace StellaOps.Policy.Unknowns.Repositories;
/// <summary>
/// Repository interface for unknown tracking operations.
/// </summary>
public interface IUnknownsRepository
{
/// <summary>
/// Gets an unknown by its unique identifier.
/// </summary>
/// <param name="tenantId">Tenant identifier for RLS.</param>
/// <param name="id">Unknown identifier.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The unknown if found; otherwise, null.</returns>
Task<Unknown?> GetByIdAsync(Guid tenantId, Guid id, CancellationToken cancellationToken = default);
/// <summary>
/// Gets an unknown by package coordinates.
/// </summary>
/// <param name="tenantId">Tenant identifier for RLS.</param>
/// <param name="packageId">Package identifier (PURL or NEVRA).</param>
/// <param name="packageVersion">Package version.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The unknown if found; otherwise, null.</returns>
Task<Unknown?> GetByPackageAsync(
Guid tenantId,
string packageId,
string packageVersion,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets all unknowns for a tenant in a specific band.
/// </summary>
/// <param name="tenantId">Tenant identifier for RLS.</param>
/// <param name="band">Band to filter by.</param>
/// <param name="limit">Maximum number of results.</param>
/// <param name="offset">Number of results to skip.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Ordered list of unknowns in the band (by score descending).</returns>
Task<IReadOnlyList<Unknown>> GetByBandAsync(
Guid tenantId,
UnknownBand band,
int limit = 100,
int offset = 0,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets a summary of unknowns by band for a tenant.
/// </summary>
/// <param name="tenantId">Tenant identifier for RLS.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Summary counts by band.</returns>
Task<UnknownsSummary> GetSummaryAsync(Guid tenantId, CancellationToken cancellationToken = default);
/// <summary>
/// Creates a new unknown.
/// </summary>
/// <param name="unknown">Unknown to create.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The created unknown with generated ID.</returns>
Task<Unknown> CreateAsync(Unknown unknown, CancellationToken cancellationToken = default);
/// <summary>
/// Updates an existing unknown.
/// </summary>
/// <param name="unknown">Unknown to update.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>True if updated; false if not found.</returns>
Task<bool> UpdateAsync(Unknown unknown, CancellationToken cancellationToken = default);
/// <summary>
/// Marks an unknown as resolved.
/// </summary>
/// <param name="tenantId">Tenant identifier for RLS.</param>
/// <param name="id">Unknown identifier.</param>
/// <param name="resolutionReason">Reason for resolution.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>True if resolved; false if not found.</returns>
Task<bool> ResolveAsync(
Guid tenantId,
Guid id,
string resolutionReason,
CancellationToken cancellationToken = default);
/// <summary>
/// Batch upserts unknowns from a re-evaluation pass.
/// </summary>
/// <param name="tenantId">Tenant identifier for RLS.</param>
/// <param name="unknowns">Unknowns to upsert.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Number of rows affected.</returns>
Task<int> UpsertBatchAsync(
Guid tenantId,
IEnumerable<Unknown> unknowns,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,330 @@
using System.Data;
using Dapper;
using StellaOps.Policy.Unknowns.Models;
namespace StellaOps.Policy.Unknowns.Repositories;
/// <summary>
/// Dapper-based PostgreSQL implementation of <see cref="IUnknownsRepository"/>.
/// </summary>
/// <remarks>
/// <para>This implementation relies on PostgreSQL Row-Level Security (RLS) for tenant isolation.</para>
/// <para>All queries set <c>app.current_tenant</c> before execution.</para>
/// </remarks>
public sealed class UnknownsRepository : IUnknownsRepository
{
private readonly IDbConnection _connection;
public UnknownsRepository(IDbConnection connection)
=> _connection = connection ?? throw new ArgumentNullException(nameof(connection));
/// <inheritdoc />
public async Task<Unknown?> GetByIdAsync(Guid tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT set_config('app.current_tenant', @TenantId::text, true);
SELECT id, tenant_id, package_id, package_version, band, score,
uncertainty_factor, exploit_pressure, first_seen_at,
last_evaluated_at, resolution_reason, resolved_at,
created_at, updated_at
FROM policy.unknowns
WHERE id = @Id;
""";
var param = new { TenantId = tenantId, Id = id };
using var reader = await _connection.QueryMultipleAsync(sql, param);
// Skip set_config result
await reader.ReadAsync();
var row = await reader.ReadFirstOrDefaultAsync<UnknownRow>();
return row?.ToModel();
}
/// <inheritdoc />
public async Task<Unknown?> GetByPackageAsync(
Guid tenantId,
string packageId,
string packageVersion,
CancellationToken cancellationToken = default)
{
const string sql = """
SELECT set_config('app.current_tenant', @TenantId::text, true);
SELECT id, tenant_id, package_id, package_version, band, score,
uncertainty_factor, exploit_pressure, first_seen_at,
last_evaluated_at, resolution_reason, resolved_at,
created_at, updated_at
FROM policy.unknowns
WHERE package_id = @PackageId AND package_version = @PackageVersion;
""";
var param = new { TenantId = tenantId, PackageId = packageId, PackageVersion = packageVersion };
using var reader = await _connection.QueryMultipleAsync(sql, param);
await reader.ReadAsync();
var row = await reader.ReadFirstOrDefaultAsync<UnknownRow>();
return row?.ToModel();
}
/// <inheritdoc />
public async Task<IReadOnlyList<Unknown>> GetByBandAsync(
Guid tenantId,
UnknownBand band,
int limit = 100,
int offset = 0,
CancellationToken cancellationToken = default)
{
const string sql = """
SELECT set_config('app.current_tenant', @TenantId::text, true);
SELECT id, tenant_id, package_id, package_version, band, score,
uncertainty_factor, exploit_pressure, first_seen_at,
last_evaluated_at, resolution_reason, resolved_at,
created_at, updated_at
FROM policy.unknowns
WHERE band = @Band
ORDER BY score DESC, package_id ASC
LIMIT @Limit OFFSET @Offset;
""";
var param = new { TenantId = tenantId, Band = band.ToString().ToLowerInvariant(), Limit = limit, Offset = offset };
using var reader = await _connection.QueryMultipleAsync(sql, param);
await reader.ReadAsync();
var rows = await reader.ReadAsync<UnknownRow>();
return rows.Select(r => r.ToModel()).ToList().AsReadOnly();
}
/// <inheritdoc />
public async Task<UnknownsSummary> GetSummaryAsync(Guid tenantId, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT set_config('app.current_tenant', @TenantId::text, true);
SELECT
COUNT(*) FILTER (WHERE band = 'hot') as hot_count,
COUNT(*) FILTER (WHERE band = 'warm') as warm_count,
COUNT(*) FILTER (WHERE band = 'cold') as cold_count,
COUNT(*) FILTER (WHERE band = 'resolved') as resolved_count
FROM policy.unknowns;
""";
using var reader = await _connection.QueryMultipleAsync(sql, new { TenantId = tenantId });
await reader.ReadAsync();
var row = await reader.ReadSingleAsync<SummaryRow>();
return new UnknownsSummary(row.hot_count, row.warm_count, row.cold_count, row.resolved_count);
}
/// <inheritdoc />
public async Task<Unknown> CreateAsync(Unknown unknown, CancellationToken cancellationToken = default)
{
var id = unknown.Id == Guid.Empty ? Guid.NewGuid() : unknown.Id;
var now = DateTimeOffset.UtcNow;
const string sql = """
SELECT set_config('app.current_tenant', @TenantId::text, true);
INSERT INTO policy.unknowns (
id, tenant_id, package_id, package_version, band, score,
uncertainty_factor, exploit_pressure, first_seen_at,
last_evaluated_at, resolution_reason, resolved_at,
created_at, updated_at
) VALUES (
@Id, @TenantId, @PackageId, @PackageVersion, @Band, @Score,
@UncertaintyFactor, @ExploitPressure, @FirstSeenAt,
@LastEvaluatedAt, @ResolutionReason, @ResolvedAt,
@CreatedAt, @UpdatedAt
)
RETURNING id, tenant_id, package_id, package_version, band, score,
uncertainty_factor, exploit_pressure, first_seen_at,
last_evaluated_at, resolution_reason, resolved_at,
created_at, updated_at;
""";
var param = new
{
Id = id,
unknown.TenantId,
unknown.PackageId,
unknown.PackageVersion,
Band = unknown.Band.ToString().ToLowerInvariant(),
unknown.Score,
unknown.UncertaintyFactor,
unknown.ExploitPressure,
FirstSeenAt = unknown.FirstSeenAt == default ? now : unknown.FirstSeenAt,
LastEvaluatedAt = unknown.LastEvaluatedAt == default ? now : unknown.LastEvaluatedAt,
unknown.ResolutionReason,
unknown.ResolvedAt,
CreatedAt = now,
UpdatedAt = now
};
using var reader = await _connection.QueryMultipleAsync(sql, param);
await reader.ReadAsync();
var row = await reader.ReadSingleAsync<UnknownRow>();
return row.ToModel();
}
/// <inheritdoc />
public async Task<bool> UpdateAsync(Unknown unknown, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT set_config('app.current_tenant', @TenantId::text, true);
UPDATE policy.unknowns
SET band = @Band,
score = @Score,
uncertainty_factor = @UncertaintyFactor,
exploit_pressure = @ExploitPressure,
last_evaluated_at = @LastEvaluatedAt,
resolution_reason = @ResolutionReason,
resolved_at = @ResolvedAt,
updated_at = @UpdatedAt
WHERE id = @Id;
""";
var param = new
{
unknown.TenantId,
unknown.Id,
Band = unknown.Band.ToString().ToLowerInvariant(),
unknown.Score,
unknown.UncertaintyFactor,
unknown.ExploitPressure,
unknown.LastEvaluatedAt,
unknown.ResolutionReason,
unknown.ResolvedAt,
UpdatedAt = DateTimeOffset.UtcNow
};
var affected = await _connection.ExecuteAsync(sql, param);
return affected > 0;
}
/// <inheritdoc />
public async Task<bool> ResolveAsync(
Guid tenantId,
Guid id,
string resolutionReason,
CancellationToken cancellationToken = default)
{
const string sql = """
SELECT set_config('app.current_tenant', @TenantId::text, true);
UPDATE policy.unknowns
SET band = 'resolved',
resolution_reason = @ResolutionReason,
resolved_at = @ResolvedAt,
updated_at = @UpdatedAt
WHERE id = @Id;
""";
var now = DateTimeOffset.UtcNow;
var param = new
{
TenantId = tenantId,
Id = id,
ResolutionReason = resolutionReason,
ResolvedAt = now,
UpdatedAt = now
};
var affected = await _connection.ExecuteAsync(sql, param);
return affected > 0;
}
/// <inheritdoc />
public async Task<int> UpsertBatchAsync(
Guid tenantId,
IEnumerable<Unknown> unknowns,
CancellationToken cancellationToken = default)
{
var now = DateTimeOffset.UtcNow;
var total = 0;
const string sql = """
SELECT set_config('app.current_tenant', @TenantId::text, true);
INSERT INTO policy.unknowns (
id, tenant_id, package_id, package_version, band, score,
uncertainty_factor, exploit_pressure, first_seen_at,
last_evaluated_at, resolution_reason, resolved_at,
created_at, updated_at
) VALUES (
@Id, @TenantId, @PackageId, @PackageVersion, @Band, @Score,
@UncertaintyFactor, @ExploitPressure, @FirstSeenAt,
@LastEvaluatedAt, @ResolutionReason, @ResolvedAt,
@CreatedAt, @UpdatedAt
)
ON CONFLICT (tenant_id, package_id, package_version)
DO UPDATE SET
band = EXCLUDED.band,
score = EXCLUDED.score,
uncertainty_factor = EXCLUDED.uncertainty_factor,
exploit_pressure = EXCLUDED.exploit_pressure,
last_evaluated_at = EXCLUDED.last_evaluated_at,
updated_at = EXCLUDED.updated_at;
""";
foreach (var unknown in unknowns)
{
var id = unknown.Id == Guid.Empty ? Guid.NewGuid() : unknown.Id;
var param = new
{
Id = id,
TenantId = tenantId,
unknown.PackageId,
unknown.PackageVersion,
Band = unknown.Band.ToString().ToLowerInvariant(),
unknown.Score,
unknown.UncertaintyFactor,
unknown.ExploitPressure,
FirstSeenAt = unknown.FirstSeenAt == default ? now : unknown.FirstSeenAt,
LastEvaluatedAt = now,
unknown.ResolutionReason,
unknown.ResolvedAt,
CreatedAt = now,
UpdatedAt = now
};
var affected = await _connection.ExecuteAsync(sql, param);
total += affected > 0 ? 1 : 0;
}
return total;
}
#region Row Mapping
private sealed record UnknownRow(
Guid id,
Guid tenant_id,
string package_id,
string package_version,
string band,
decimal score,
decimal uncertainty_factor,
decimal exploit_pressure,
DateTimeOffset first_seen_at,
DateTimeOffset last_evaluated_at,
string? resolution_reason,
DateTimeOffset? resolved_at,
DateTimeOffset created_at,
DateTimeOffset updated_at)
{
public Unknown ToModel() => new()
{
Id = id,
TenantId = tenant_id,
PackageId = package_id,
PackageVersion = package_version,
Band = Enum.Parse<UnknownBand>(band, ignoreCase: true),
Score = score,
UncertaintyFactor = uncertainty_factor,
ExploitPressure = exploit_pressure,
FirstSeenAt = first_seen_at,
LastEvaluatedAt = last_evaluated_at,
ResolutionReason = resolution_reason,
ResolvedAt = resolved_at,
CreatedAt = created_at,
UpdatedAt = updated_at
};
}
private sealed record SummaryRow(int hot_count, int warm_count, int cold_count, int resolved_count);
#endregion
}