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