wip: doctor/cli/docs/api to vector db consolidation; api hardening for descriptions, tenant, and scopes; migrations and conversions of all DALs to EF v10
This commit is contained in:
@@ -6,7 +6,21 @@ Maintain the Policy module persistence layer and PostgreSQL repositories.
|
||||
## Required Reading
|
||||
- docs/modules/policy/architecture.md
|
||||
- docs/modules/platform/architecture-overview.md
|
||||
- docs/db/EF_CORE_MODEL_GENERATION_STANDARDS.md
|
||||
- docs/db/EF_CORE_RUNTIME_CUTOVER_STRATEGY.md
|
||||
|
||||
## DAL Technology
|
||||
- **Primary:** EF Core v10 via `PolicyDbContext` for standard CRUD (reads, inserts, deletes, bulk updates).
|
||||
- **Secondary:** Raw SQL via `RepositoryBase` helpers preserved where EF Core LINQ cannot cleanly express the query (ON CONFLICT, jsonb containment `@>`, LIKE REPLACE patterns, CASE conditional updates, FOR UPDATE, regex `~`, CTE queries, FILTER/GROUP BY aggregates, NULLS LAST ordering, cross-window INSERT-SELECT, DB functions).
|
||||
- **Design-time factory:** `EfCore/Context/PolicyDesignTimeDbContextFactory.cs` (for `dotnet ef` CLI).
|
||||
- **Runtime factory:** `Postgres/PolicyDbContextFactory.cs` (compiled model on default schema, reflection fallback for non-default schemas).
|
||||
- **Compiled model:** `EfCore/CompiledModels/` (regenerated via `dotnet ef dbcontext optimize`; assembly attribute excluded from compilation to support non-default schema integration tests).
|
||||
- **Schema:** `policy` (default), injectable via constructor for integration test isolation.
|
||||
- **Migrations:** SQL files under `Migrations/` embedded as resources; authoritative and never auto-generated from EF models.
|
||||
|
||||
## Working Agreement
|
||||
- Update sprint status in docs/implplan/SPRINT_*.md and local TASKS.md.
|
||||
- Keep repository ordering deterministic and time/ID generation explicit.
|
||||
- When converting raw SQL to EF Core: use `AsNoTracking()` for reads, `Add()/SaveChangesAsync()` for inserts, `ExecuteUpdateAsync()`/`ExecuteDeleteAsync()` for bulk mutations.
|
||||
- Document raw SQL retention rationale with `// Keep raw SQL:` comments.
|
||||
- Never introduce auto-migrations or runtime schema changes.
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
// <auto-generated />
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using StellaOps.Policy.Persistence.EfCore.CompiledModels;
|
||||
using StellaOps.Policy.Persistence.EfCore.Context;
|
||||
|
||||
#pragma warning disable 219, 612, 618
|
||||
#nullable disable
|
||||
|
||||
[assembly: DbContextModel(typeof(PolicyDbContext), typeof(PolicyDbContextModel))]
|
||||
@@ -0,0 +1,48 @@
|
||||
// <auto-generated />
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using StellaOps.Policy.Persistence.EfCore.Context;
|
||||
|
||||
#pragma warning disable 219, 612, 618
|
||||
#nullable disable
|
||||
|
||||
namespace StellaOps.Policy.Persistence.EfCore.CompiledModels
|
||||
{
|
||||
[DbContext(typeof(PolicyDbContext))]
|
||||
public partial class PolicyDbContextModel : RuntimeModel
|
||||
{
|
||||
private static readonly bool _useOldBehavior31751 =
|
||||
System.AppContext.TryGetSwitch("Microsoft.EntityFrameworkCore.Issue31751", out var enabled31751) && enabled31751;
|
||||
|
||||
static PolicyDbContextModel()
|
||||
{
|
||||
var model = new PolicyDbContextModel();
|
||||
|
||||
if (_useOldBehavior31751)
|
||||
{
|
||||
model.Initialize();
|
||||
}
|
||||
else
|
||||
{
|
||||
var thread = new System.Threading.Thread(RunInitialization, 10 * 1024 * 1024);
|
||||
thread.Start();
|
||||
thread.Join();
|
||||
|
||||
void RunInitialization()
|
||||
{
|
||||
model.Initialize();
|
||||
}
|
||||
}
|
||||
|
||||
model.Customize();
|
||||
_instance = (PolicyDbContextModel)model.FinalizeModel();
|
||||
}
|
||||
|
||||
private static PolicyDbContextModel _instance;
|
||||
public static IModel Instance => _instance;
|
||||
|
||||
partial void Initialize();
|
||||
|
||||
partial void Customize();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#pragma warning disable 219, 612, 618
|
||||
#nullable disable
|
||||
|
||||
namespace StellaOps.Policy.Persistence.EfCore.CompiledModels
|
||||
{
|
||||
public partial class PolicyDbContextModel
|
||||
{
|
||||
private PolicyDbContextModel()
|
||||
: base(skipDetectChanges: false, modelId: new Guid("a7b2c1d0-3e4f-5a6b-8c9d-0e1f2a3b4c5d"), entityTypeCount: 20)
|
||||
{
|
||||
}
|
||||
|
||||
partial void Initialize()
|
||||
{
|
||||
// Entity types are registered through the DbContext OnModelCreating.
|
||||
// This compiled model delegates to the runtime model builder for Policy entities.
|
||||
// When dotnet ef dbcontext optimize is run against a live schema,
|
||||
// this file will be regenerated with per-entity type registrations.
|
||||
AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn);
|
||||
AddAnnotation("ProductVersion", "10.0.0");
|
||||
AddAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,725 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using StellaOps.Policy.Persistence.Postgres.Models;
|
||||
|
||||
namespace StellaOps.Policy.Persistence.EfCore.Context;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core DbContext for Policy module.
|
||||
/// This is a stub that will be scaffolded from the PostgreSQL database.
|
||||
/// EF Core DbContext for the Policy module.
|
||||
/// Scaffolded from SQL migrations 001-005.
|
||||
/// </summary>
|
||||
public class PolicyDbContext : DbContext
|
||||
public partial class PolicyDbContext : DbContext
|
||||
{
|
||||
public PolicyDbContext(DbContextOptions<PolicyDbContext> options)
|
||||
private readonly string _schemaName;
|
||||
|
||||
public PolicyDbContext(DbContextOptions<PolicyDbContext> options, string? schemaName = null)
|
||||
: base(options)
|
||||
{
|
||||
_schemaName = string.IsNullOrWhiteSpace(schemaName)
|
||||
? "policy"
|
||||
: schemaName.Trim();
|
||||
}
|
||||
|
||||
// ----- Core Policy Management -----
|
||||
public virtual DbSet<PackEntity> Packs { get; set; }
|
||||
public virtual DbSet<PackVersionEntity> PackVersions { get; set; }
|
||||
public virtual DbSet<RuleEntity> Rules { get; set; }
|
||||
|
||||
// ----- Risk Profiles -----
|
||||
public virtual DbSet<RiskProfileEntity> RiskProfiles { get; set; }
|
||||
|
||||
// ----- Evaluations -----
|
||||
public virtual DbSet<EvaluationRunEntity> EvaluationRuns { get; set; }
|
||||
public virtual DbSet<ExplanationEntity> Explanations { get; set; }
|
||||
|
||||
// ----- Snapshots & Events -----
|
||||
public virtual DbSet<SnapshotEntity> Snapshots { get; set; }
|
||||
public virtual DbSet<ViolationEventEntity> ViolationEvents { get; set; }
|
||||
public virtual DbSet<ConflictEntity> Conflicts { get; set; }
|
||||
public virtual DbSet<LedgerExportEntity> LedgerExports { get; set; }
|
||||
public virtual DbSet<WorkerResultEntity> WorkerResults { get; set; }
|
||||
|
||||
// ----- Exceptions -----
|
||||
public virtual DbSet<ExceptionEntity> Exceptions { get; set; }
|
||||
|
||||
// ----- Budget -----
|
||||
public virtual DbSet<BudgetLedgerEntity> BudgetLedger { get; set; }
|
||||
public virtual DbSet<BudgetEntryEntity> BudgetEntries { get; set; }
|
||||
|
||||
// ----- Approval -----
|
||||
public virtual DbSet<ExceptionApprovalRequestEntity> ExceptionApprovalRequests { get; set; }
|
||||
public virtual DbSet<ExceptionApprovalAuditEntity> ExceptionApprovalAudit { get; set; }
|
||||
public virtual DbSet<ExceptionApprovalRuleEntity> ExceptionApprovalRules { get; set; }
|
||||
|
||||
// ----- Audit -----
|
||||
public virtual DbSet<PolicyAuditEntity> Audit { get; set; }
|
||||
|
||||
// ----- Trusted Keys & Gate Bypass (Migration 002) -----
|
||||
public virtual DbSet<TrustedKeyEntity> TrustedKeys { get; set; }
|
||||
public virtual DbSet<GateBypassAuditEntity> GateBypassAudit { get; set; }
|
||||
|
||||
// ----- Gate Decisions (Migration 003) -----
|
||||
public virtual DbSet<GateDecisionEntity> GateDecisions { get; set; }
|
||||
|
||||
// ----- Replay Audit (Migration 004) -----
|
||||
public virtual DbSet<ReplayAuditEntity> ReplayAudit { get; set; }
|
||||
|
||||
// ----- Advisory Source Projection (Migration 005) -----
|
||||
public virtual DbSet<AdvisorySourceImpactEntity> AdvisorySourceImpacts { get; set; }
|
||||
public virtual DbSet<AdvisorySourceConflictEntity> AdvisorySourceConflicts { get; set; }
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.HasDefaultSchema("policy");
|
||||
base.OnModelCreating(modelBuilder);
|
||||
var schemaName = _schemaName;
|
||||
|
||||
// === packs ===
|
||||
modelBuilder.Entity<PackEntity>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id).HasName("packs_pkey");
|
||||
entity.ToTable("packs", schemaName);
|
||||
|
||||
entity.HasIndex(e => e.TenantId, "idx_packs_tenant");
|
||||
entity.HasIndex(e => e.IsBuiltin, "idx_packs_builtin");
|
||||
entity.HasIndex(e => new { e.TenantId, e.Name }).IsUnique();
|
||||
|
||||
entity.Property(e => e.Id).HasDefaultValueSql("gen_random_uuid()").HasColumnName("id");
|
||||
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
|
||||
entity.Property(e => e.Name).HasColumnName("name");
|
||||
entity.Property(e => e.DisplayName).HasColumnName("display_name");
|
||||
entity.Property(e => e.Description).HasColumnName("description");
|
||||
entity.Property(e => e.ActiveVersion).HasColumnName("active_version");
|
||||
entity.Property(e => e.IsBuiltin).HasColumnName("is_builtin");
|
||||
entity.Property(e => e.IsDeprecated).HasColumnName("is_deprecated");
|
||||
entity.Property(e => e.Metadata).HasColumnType("jsonb").HasColumnName("metadata");
|
||||
entity.Property(e => e.CreatedAt).HasDefaultValueSql("now()").HasColumnName("created_at");
|
||||
entity.Property(e => e.UpdatedAt).HasDefaultValueSql("now()").HasColumnName("updated_at");
|
||||
entity.Property(e => e.CreatedBy).HasColumnName("created_by");
|
||||
});
|
||||
|
||||
// === pack_versions ===
|
||||
modelBuilder.Entity<PackVersionEntity>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id).HasName("pack_versions_pkey");
|
||||
entity.ToTable("pack_versions", schemaName);
|
||||
|
||||
entity.HasIndex(e => e.PackId, "idx_pack_versions_pack");
|
||||
entity.HasIndex(e => new { e.PackId, e.IsPublished }, "idx_pack_versions_published");
|
||||
entity.HasIndex(e => new { e.PackId, e.Version }).IsUnique();
|
||||
|
||||
entity.Property(e => e.Id).HasDefaultValueSql("gen_random_uuid()").HasColumnName("id");
|
||||
entity.Property(e => e.PackId).HasColumnName("pack_id");
|
||||
entity.Property(e => e.Version).HasColumnName("version");
|
||||
entity.Property(e => e.Description).HasColumnName("description");
|
||||
entity.Property(e => e.RulesHash).HasColumnName("rules_hash");
|
||||
entity.Property(e => e.IsPublished).HasColumnName("is_published");
|
||||
entity.Property(e => e.PublishedAt).HasColumnName("published_at");
|
||||
entity.Property(e => e.PublishedBy).HasColumnName("published_by");
|
||||
entity.Property(e => e.CreatedAt).HasDefaultValueSql("now()").HasColumnName("created_at");
|
||||
entity.Property(e => e.CreatedBy).HasColumnName("created_by");
|
||||
});
|
||||
|
||||
// === rules ===
|
||||
modelBuilder.Entity<RuleEntity>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id).HasName("rules_pkey");
|
||||
entity.ToTable("rules", schemaName);
|
||||
|
||||
entity.HasIndex(e => e.PackVersionId, "idx_rules_pack_version");
|
||||
entity.HasIndex(e => new { e.PackVersionId, e.Name }).IsUnique();
|
||||
|
||||
entity.Property(e => e.Id).HasDefaultValueSql("gen_random_uuid()").HasColumnName("id");
|
||||
entity.Property(e => e.PackVersionId).HasColumnName("pack_version_id");
|
||||
entity.Property(e => e.Name).HasColumnName("name");
|
||||
entity.Property(e => e.Description).HasColumnName("description");
|
||||
entity.Property(e => e.RuleType).HasConversion<string>().HasColumnName("rule_type");
|
||||
entity.Property(e => e.Content).HasColumnName("content");
|
||||
entity.Property(e => e.ContentHash).HasColumnName("content_hash");
|
||||
entity.Property(e => e.Severity).HasConversion<string>().HasColumnName("severity");
|
||||
entity.Property(e => e.Category).HasColumnName("category");
|
||||
entity.Property(e => e.Tags).HasColumnName("tags");
|
||||
entity.Property(e => e.Metadata).HasColumnType("jsonb").HasColumnName("metadata");
|
||||
entity.Property(e => e.CreatedAt).HasDefaultValueSql("now()").HasColumnName("created_at");
|
||||
});
|
||||
|
||||
// === risk_profiles ===
|
||||
modelBuilder.Entity<RiskProfileEntity>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id).HasName("risk_profiles_pkey");
|
||||
entity.ToTable("risk_profiles", schemaName);
|
||||
|
||||
entity.HasIndex(e => e.TenantId, "idx_risk_profiles_tenant");
|
||||
entity.HasIndex(e => new { e.TenantId, e.Name, e.Version }).IsUnique();
|
||||
|
||||
entity.Property(e => e.Id).HasDefaultValueSql("gen_random_uuid()").HasColumnName("id");
|
||||
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
|
||||
entity.Property(e => e.Name).HasColumnName("name");
|
||||
entity.Property(e => e.DisplayName).HasColumnName("display_name");
|
||||
entity.Property(e => e.Description).HasColumnName("description");
|
||||
entity.Property(e => e.Version).HasColumnName("version");
|
||||
entity.Property(e => e.IsActive).HasColumnName("is_active");
|
||||
entity.Property(e => e.Thresholds).HasColumnType("jsonb").HasColumnName("thresholds");
|
||||
entity.Property(e => e.ScoringWeights).HasColumnType("jsonb").HasColumnName("scoring_weights");
|
||||
entity.Property(e => e.Exemptions).HasColumnType("jsonb").HasColumnName("exemptions");
|
||||
entity.Property(e => e.Metadata).HasColumnType("jsonb").HasColumnName("metadata");
|
||||
entity.Property(e => e.CreatedAt).HasDefaultValueSql("now()").HasColumnName("created_at");
|
||||
entity.Property(e => e.UpdatedAt).HasDefaultValueSql("now()").HasColumnName("updated_at");
|
||||
entity.Property(e => e.CreatedBy).HasColumnName("created_by");
|
||||
});
|
||||
|
||||
// === evaluation_runs ===
|
||||
modelBuilder.Entity<EvaluationRunEntity>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id).HasName("evaluation_runs_pkey");
|
||||
entity.ToTable("evaluation_runs", schemaName);
|
||||
|
||||
entity.HasIndex(e => e.TenantId, "idx_evaluation_runs_tenant");
|
||||
entity.HasIndex(e => new { e.TenantId, e.ProjectId }, "idx_evaluation_runs_project");
|
||||
entity.HasIndex(e => new { e.TenantId, e.ArtifactId }, "idx_evaluation_runs_artifact");
|
||||
entity.HasIndex(e => new { e.TenantId, e.CreatedAt }, "idx_evaluation_runs_created");
|
||||
entity.HasIndex(e => e.Status, "idx_evaluation_runs_status");
|
||||
|
||||
entity.Property(e => e.Id).HasDefaultValueSql("gen_random_uuid()").HasColumnName("id");
|
||||
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
|
||||
entity.Property(e => e.ProjectId).HasColumnName("project_id");
|
||||
entity.Property(e => e.ArtifactId).HasColumnName("artifact_id");
|
||||
entity.Property(e => e.PackId).HasColumnName("pack_id");
|
||||
entity.Property(e => e.PackVersion).HasColumnName("pack_version");
|
||||
entity.Property(e => e.RiskProfileId).HasColumnName("risk_profile_id");
|
||||
entity.Property(e => e.Status).HasConversion<string>().HasColumnName("status");
|
||||
entity.Property(e => e.Result).HasConversion<string>().HasColumnName("result");
|
||||
entity.Property(e => e.Score).HasColumnName("score");
|
||||
entity.Property(e => e.FindingsCount).HasColumnName("findings_count");
|
||||
entity.Property(e => e.CriticalCount).HasColumnName("critical_count");
|
||||
entity.Property(e => e.HighCount).HasColumnName("high_count");
|
||||
entity.Property(e => e.MediumCount).HasColumnName("medium_count");
|
||||
entity.Property(e => e.LowCount).HasColumnName("low_count");
|
||||
entity.Property(e => e.InputHash).HasColumnName("input_hash");
|
||||
entity.Property(e => e.DurationMs).HasColumnName("duration_ms");
|
||||
entity.Property(e => e.ErrorMessage).HasColumnName("error_message");
|
||||
entity.Property(e => e.Metadata).HasColumnType("jsonb").HasColumnName("metadata");
|
||||
entity.Property(e => e.CreatedAt).HasDefaultValueSql("now()").HasColumnName("created_at");
|
||||
entity.Property(e => e.StartedAt).HasColumnName("started_at");
|
||||
entity.Property(e => e.CompletedAt).HasColumnName("completed_at");
|
||||
entity.Property(e => e.CreatedBy).HasColumnName("created_by");
|
||||
});
|
||||
|
||||
// === explanations ===
|
||||
modelBuilder.Entity<ExplanationEntity>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id).HasName("explanations_pkey");
|
||||
entity.ToTable("explanations", schemaName);
|
||||
|
||||
entity.HasIndex(e => e.EvaluationRunId, "idx_explanations_run");
|
||||
entity.HasIndex(e => new { e.EvaluationRunId, e.Result }, "idx_explanations_result");
|
||||
|
||||
entity.Property(e => e.Id).HasDefaultValueSql("gen_random_uuid()").HasColumnName("id");
|
||||
entity.Property(e => e.EvaluationRunId).HasColumnName("evaluation_run_id");
|
||||
entity.Property(e => e.RuleId).HasColumnName("rule_id");
|
||||
entity.Property(e => e.RuleName).HasColumnName("rule_name");
|
||||
entity.Property(e => e.Result).HasConversion<string>().HasColumnName("result");
|
||||
entity.Property(e => e.Severity).HasColumnName("severity");
|
||||
entity.Property(e => e.Message).HasColumnName("message");
|
||||
entity.Property(e => e.Details).HasColumnType("jsonb").HasColumnName("details");
|
||||
entity.Property(e => e.Remediation).HasColumnName("remediation");
|
||||
entity.Property(e => e.ResourcePath).HasColumnName("resource_path");
|
||||
entity.Property(e => e.LineNumber).HasColumnName("line_number");
|
||||
entity.Property(e => e.CreatedAt).HasDefaultValueSql("now()").HasColumnName("created_at");
|
||||
});
|
||||
|
||||
// === snapshots ===
|
||||
modelBuilder.Entity<SnapshotEntity>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id).HasName("snapshots_pkey");
|
||||
entity.ToTable("snapshots", schemaName);
|
||||
|
||||
entity.HasIndex(e => e.TenantId, "idx_snapshots_tenant");
|
||||
entity.HasIndex(e => new { e.TenantId, e.PolicyId }, "idx_snapshots_policy");
|
||||
entity.HasIndex(e => e.ContentDigest, "idx_snapshots_digest");
|
||||
entity.HasIndex(e => new { e.TenantId, e.PolicyId, e.Version }).IsUnique();
|
||||
|
||||
entity.Property(e => e.Id).HasDefaultValueSql("gen_random_uuid()").HasColumnName("id");
|
||||
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
|
||||
entity.Property(e => e.PolicyId).HasColumnName("policy_id");
|
||||
entity.Property(e => e.Version).HasColumnName("version");
|
||||
entity.Property(e => e.ContentDigest).HasColumnName("content_digest");
|
||||
entity.Property(e => e.Content).HasColumnType("jsonb").HasColumnName("content");
|
||||
entity.Property(e => e.Metadata).HasColumnType("jsonb").HasColumnName("metadata");
|
||||
entity.Property(e => e.CreatedBy).HasColumnName("created_by");
|
||||
entity.Property(e => e.CreatedAt).HasDefaultValueSql("now()").HasColumnName("created_at");
|
||||
});
|
||||
|
||||
// === violation_events ===
|
||||
modelBuilder.Entity<ViolationEventEntity>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id).HasName("violation_events_pkey");
|
||||
entity.ToTable("violation_events", schemaName);
|
||||
|
||||
entity.HasIndex(e => e.TenantId, "idx_violation_events_tenant");
|
||||
entity.HasIndex(e => new { e.TenantId, e.PolicyId }, "idx_violation_events_policy");
|
||||
entity.HasIndex(e => new { e.TenantId, e.OccurredAt }, "idx_violation_events_occurred");
|
||||
|
||||
entity.Property(e => e.Id).HasDefaultValueSql("gen_random_uuid()").HasColumnName("id");
|
||||
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
|
||||
entity.Property(e => e.PolicyId).HasColumnName("policy_id");
|
||||
entity.Property(e => e.RuleId).HasColumnName("rule_id");
|
||||
entity.Property(e => e.Severity).HasColumnName("severity");
|
||||
entity.Property(e => e.SubjectPurl).HasColumnName("subject_purl");
|
||||
entity.Property(e => e.SubjectCve).HasColumnName("subject_cve");
|
||||
entity.Property(e => e.Details).HasColumnType("jsonb").HasColumnName("details");
|
||||
entity.Property(e => e.Remediation).HasColumnName("remediation");
|
||||
entity.Property(e => e.CorrelationId).HasColumnName("correlation_id");
|
||||
entity.Property(e => e.OccurredAt).HasColumnName("occurred_at");
|
||||
entity.Property(e => e.CreatedAt).HasDefaultValueSql("now()").HasColumnName("created_at");
|
||||
});
|
||||
|
||||
// === conflicts ===
|
||||
modelBuilder.Entity<ConflictEntity>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id).HasName("conflicts_pkey");
|
||||
entity.ToTable("conflicts", schemaName);
|
||||
|
||||
entity.HasIndex(e => e.TenantId, "idx_conflicts_tenant");
|
||||
entity.HasIndex(e => new { e.TenantId, e.Status }, "idx_conflicts_status");
|
||||
entity.HasIndex(e => e.ConflictType, "idx_conflicts_type");
|
||||
|
||||
entity.Property(e => e.Id).HasDefaultValueSql("gen_random_uuid()").HasColumnName("id");
|
||||
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
|
||||
entity.Property(e => e.ConflictType).HasColumnName("conflict_type");
|
||||
entity.Property(e => e.Status).HasColumnName("status");
|
||||
entity.Property(e => e.Severity).HasColumnName("severity");
|
||||
entity.Property(e => e.LeftRuleId).HasColumnName("left_rule_id");
|
||||
entity.Property(e => e.RightRuleId).HasColumnName("right_rule_id");
|
||||
entity.Property(e => e.AffectedScope).HasColumnName("affected_scope");
|
||||
entity.Property(e => e.Description).HasColumnName("description");
|
||||
entity.Property(e => e.Resolution).HasColumnName("resolution");
|
||||
entity.Property(e => e.ResolvedBy).HasColumnName("resolved_by");
|
||||
entity.Property(e => e.ResolvedAt).HasColumnName("resolved_at");
|
||||
entity.Property(e => e.Metadata).HasColumnType("jsonb").HasColumnName("metadata");
|
||||
entity.Property(e => e.CreatedAt).HasDefaultValueSql("now()").HasColumnName("created_at");
|
||||
entity.Property(e => e.CreatedBy).HasColumnName("created_by");
|
||||
});
|
||||
|
||||
// === ledger_exports ===
|
||||
modelBuilder.Entity<LedgerExportEntity>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id).HasName("ledger_exports_pkey");
|
||||
entity.ToTable("ledger_exports", schemaName);
|
||||
|
||||
entity.HasIndex(e => e.TenantId, "idx_ledger_exports_tenant");
|
||||
entity.HasIndex(e => e.Status, "idx_ledger_exports_status");
|
||||
entity.HasIndex(e => new { e.TenantId, e.CreatedAt }, "idx_ledger_exports_created");
|
||||
|
||||
entity.Property(e => e.Id).HasDefaultValueSql("gen_random_uuid()").HasColumnName("id");
|
||||
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
|
||||
entity.Property(e => e.ExportType).HasColumnName("export_type");
|
||||
entity.Property(e => e.Status).HasColumnName("status");
|
||||
entity.Property(e => e.Format).HasColumnName("format");
|
||||
entity.Property(e => e.ContentDigest).HasColumnName("content_digest");
|
||||
entity.Property(e => e.RecordCount).HasColumnName("record_count");
|
||||
entity.Property(e => e.ByteSize).HasColumnName("byte_size");
|
||||
entity.Property(e => e.StoragePath).HasColumnName("storage_path");
|
||||
entity.Property(e => e.StartTime).HasColumnName("start_time");
|
||||
entity.Property(e => e.EndTime).HasColumnName("end_time");
|
||||
entity.Property(e => e.ErrorMessage).HasColumnName("error_message");
|
||||
entity.Property(e => e.Metadata).HasColumnType("jsonb").HasColumnName("metadata");
|
||||
entity.Property(e => e.CreatedAt).HasDefaultValueSql("now()").HasColumnName("created_at");
|
||||
entity.Property(e => e.CreatedBy).HasColumnName("created_by");
|
||||
});
|
||||
|
||||
// === worker_results ===
|
||||
modelBuilder.Entity<WorkerResultEntity>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id).HasName("worker_results_pkey");
|
||||
entity.ToTable("worker_results", schemaName);
|
||||
|
||||
entity.HasIndex(e => e.TenantId, "idx_worker_results_tenant");
|
||||
entity.HasIndex(e => e.Status, "idx_worker_results_status");
|
||||
entity.HasIndex(e => new { e.TenantId, e.JobType, e.JobId }).IsUnique();
|
||||
|
||||
entity.Property(e => e.Id).HasDefaultValueSql("gen_random_uuid()").HasColumnName("id");
|
||||
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
|
||||
entity.Property(e => e.JobType).HasColumnName("job_type");
|
||||
entity.Property(e => e.JobId).HasColumnName("job_id");
|
||||
entity.Property(e => e.Status).HasColumnName("status");
|
||||
entity.Property(e => e.InputHash).HasColumnName("input_hash");
|
||||
entity.Property(e => e.OutputHash).HasColumnName("output_hash");
|
||||
entity.Property(e => e.Progress).HasColumnName("progress");
|
||||
entity.Property(e => e.Result).HasColumnType("jsonb").HasColumnName("result");
|
||||
entity.Property(e => e.ErrorMessage).HasColumnName("error_message");
|
||||
entity.Property(e => e.RetryCount).HasColumnName("retry_count");
|
||||
entity.Property(e => e.MaxRetries).HasColumnName("max_retries");
|
||||
entity.Property(e => e.ScheduledAt).HasColumnName("scheduled_at");
|
||||
entity.Property(e => e.StartedAt).HasColumnName("started_at");
|
||||
entity.Property(e => e.CompletedAt).HasColumnName("completed_at");
|
||||
entity.Property(e => e.Metadata).HasColumnType("jsonb").HasColumnName("metadata");
|
||||
entity.Property(e => e.CreatedAt).HasDefaultValueSql("now()").HasColumnName("created_at");
|
||||
entity.Property(e => e.CreatedBy).HasColumnName("created_by");
|
||||
});
|
||||
|
||||
// === exceptions ===
|
||||
modelBuilder.Entity<ExceptionEntity>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id).HasName("exceptions_pkey");
|
||||
entity.ToTable("exceptions", schemaName);
|
||||
|
||||
entity.HasIndex(e => e.TenantId, "idx_exceptions_tenant");
|
||||
entity.HasIndex(e => new { e.TenantId, e.Status }, "idx_exceptions_status");
|
||||
entity.HasIndex(e => new { e.TenantId, e.Name }).IsUnique();
|
||||
entity.HasIndex(e => e.ExceptionId).IsUnique();
|
||||
|
||||
entity.Property(e => e.Id).HasDefaultValueSql("gen_random_uuid()").HasColumnName("id");
|
||||
entity.Property(e => e.ExceptionId).HasColumnName("exception_id");
|
||||
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
|
||||
entity.Property(e => e.Name).HasColumnName("name");
|
||||
entity.Property(e => e.Description).HasColumnName("description");
|
||||
entity.Property(e => e.RulePattern).HasColumnName("rule_pattern");
|
||||
entity.Property(e => e.ResourcePattern).HasColumnName("resource_pattern");
|
||||
entity.Property(e => e.ArtifactPattern).HasColumnName("artifact_pattern");
|
||||
entity.Property(e => e.ProjectId).HasColumnName("project_id");
|
||||
entity.Property(e => e.Reason).HasColumnName("reason");
|
||||
entity.Property(e => e.Status).HasConversion<string>().HasColumnName("status");
|
||||
entity.Property(e => e.ExpiresAt).HasColumnName("expires_at");
|
||||
entity.Property(e => e.ApprovedBy).HasColumnName("approved_by");
|
||||
entity.Property(e => e.ApprovedAt).HasColumnName("approved_at");
|
||||
entity.Property(e => e.RevokedBy).HasColumnName("revoked_by");
|
||||
entity.Property(e => e.RevokedAt).HasColumnName("revoked_at");
|
||||
entity.Property(e => e.Metadata).HasColumnType("jsonb").HasColumnName("metadata");
|
||||
entity.Property(e => e.CreatedAt).HasDefaultValueSql("now()").HasColumnName("created_at");
|
||||
entity.Property(e => e.CreatedBy).HasColumnName("created_by");
|
||||
});
|
||||
|
||||
// === budget_ledger ===
|
||||
modelBuilder.Entity<BudgetLedgerEntity>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.BudgetId).HasName("budget_ledger_pkey");
|
||||
entity.ToTable("budget_ledger", schemaName);
|
||||
|
||||
entity.HasIndex(e => e.ServiceId, "idx_budget_ledger_service_id");
|
||||
entity.HasIndex(e => e.TenantId, "idx_budget_ledger_tenant_id");
|
||||
entity.HasIndex(e => e.Window, "idx_budget_ledger_window");
|
||||
entity.HasIndex(e => e.Status, "idx_budget_ledger_status");
|
||||
entity.HasIndex(e => new { e.ServiceId, e.Window }).IsUnique().HasDatabaseName("uq_budget_ledger_service_window");
|
||||
|
||||
entity.Property(e => e.BudgetId).HasMaxLength(256).HasColumnName("budget_id");
|
||||
entity.Property(e => e.ServiceId).HasMaxLength(128).HasColumnName("service_id");
|
||||
entity.Property(e => e.TenantId).HasMaxLength(64).HasColumnName("tenant_id");
|
||||
entity.Property(e => e.Tier).HasColumnName("tier");
|
||||
entity.Property(e => e.Window).HasMaxLength(16).HasColumnName("window");
|
||||
entity.Property(e => e.Allocated).HasColumnName("allocated");
|
||||
entity.Property(e => e.Consumed).HasColumnName("consumed");
|
||||
entity.Property(e => e.Status).HasMaxLength(16).HasColumnName("status");
|
||||
entity.Property(e => e.CreatedAt).HasDefaultValueSql("now()").HasColumnName("created_at");
|
||||
entity.Property(e => e.UpdatedAt).HasDefaultValueSql("now()").HasColumnName("updated_at");
|
||||
});
|
||||
|
||||
// === budget_entries ===
|
||||
modelBuilder.Entity<BudgetEntryEntity>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.EntryId).HasName("budget_entries_pkey");
|
||||
entity.ToTable("budget_entries", schemaName);
|
||||
|
||||
entity.HasIndex(e => new { e.ServiceId, e.Window }, "idx_budget_entries_service_window");
|
||||
entity.HasIndex(e => e.ReleaseId, "idx_budget_entries_release_id");
|
||||
entity.HasIndex(e => e.ConsumedAt, "idx_budget_entries_consumed_at");
|
||||
|
||||
entity.Property(e => e.EntryId).HasMaxLength(64).HasColumnName("entry_id");
|
||||
entity.Property(e => e.ServiceId).HasMaxLength(128).HasColumnName("service_id");
|
||||
entity.Property(e => e.Window).HasMaxLength(16).HasColumnName("window");
|
||||
entity.Property(e => e.ReleaseId).HasMaxLength(128).HasColumnName("release_id");
|
||||
entity.Property(e => e.RiskPoints).HasColumnName("risk_points");
|
||||
entity.Property(e => e.Reason).HasMaxLength(512).HasColumnName("reason");
|
||||
entity.Property(e => e.IsException).HasColumnName("is_exception");
|
||||
entity.Property(e => e.PenaltyPoints).HasColumnName("penalty_points");
|
||||
entity.Property(e => e.ConsumedAt).HasDefaultValueSql("now()").HasColumnName("consumed_at");
|
||||
entity.Property(e => e.ConsumedBy).HasMaxLength(256).HasColumnName("consumed_by");
|
||||
});
|
||||
|
||||
// === exception_approval_requests ===
|
||||
modelBuilder.Entity<ExceptionApprovalRequestEntity>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id).HasName("exception_approval_requests_pkey");
|
||||
entity.ToTable("exception_approval_requests", schemaName);
|
||||
|
||||
entity.HasIndex(e => e.TenantId, "idx_approval_requests_tenant");
|
||||
entity.HasIndex(e => new { e.TenantId, e.Status }, "idx_approval_requests_status");
|
||||
entity.HasIndex(e => e.RequestId).IsUnique();
|
||||
|
||||
entity.Property(e => e.Id).HasDefaultValueSql("gen_random_uuid()").HasColumnName("id");
|
||||
entity.Property(e => e.RequestId).HasColumnName("request_id");
|
||||
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
|
||||
entity.Property(e => e.ExceptionId).HasColumnName("exception_id");
|
||||
entity.Property(e => e.RequestorId).HasColumnName("requestor_id");
|
||||
entity.Property(e => e.RequiredApproverIds).HasColumnName("required_approver_ids");
|
||||
entity.Property(e => e.ApprovedByIds).HasColumnName("approved_by_ids");
|
||||
entity.Property(e => e.RejectedById).HasColumnName("rejected_by_id");
|
||||
entity.Property(e => e.Status).HasConversion<string>().HasColumnName("status");
|
||||
entity.Property(e => e.GateLevel).HasConversion<int>().HasColumnName("gate_level");
|
||||
entity.Property(e => e.Justification).HasColumnName("justification");
|
||||
entity.Property(e => e.Rationale).HasColumnName("rationale");
|
||||
entity.Property(e => e.ReasonCode).HasConversion<string>().HasColumnName("reason_code");
|
||||
entity.Property(e => e.EvidenceRefs).HasColumnType("jsonb").HasColumnName("evidence_refs");
|
||||
entity.Property(e => e.CompensatingControls).HasColumnType("jsonb").HasColumnName("compensating_controls");
|
||||
entity.Property(e => e.TicketRef).HasColumnName("ticket_ref");
|
||||
entity.Property(e => e.VulnerabilityId).HasColumnName("vulnerability_id");
|
||||
entity.Property(e => e.PurlPattern).HasColumnName("purl_pattern");
|
||||
entity.Property(e => e.ArtifactDigest).HasColumnName("artifact_digest");
|
||||
entity.Property(e => e.ImagePattern).HasColumnName("image_pattern");
|
||||
entity.Property(e => e.Environments).HasColumnName("environments");
|
||||
entity.Property(e => e.RequestedTtlDays).HasColumnName("requested_ttl_days");
|
||||
entity.Property(e => e.CreatedAt).HasDefaultValueSql("now()").HasColumnName("created_at");
|
||||
entity.Property(e => e.RequestExpiresAt).HasColumnName("request_expires_at");
|
||||
entity.Property(e => e.ExceptionExpiresAt).HasColumnName("exception_expires_at");
|
||||
entity.Property(e => e.ResolvedAt).HasColumnName("resolved_at");
|
||||
entity.Property(e => e.RejectionReason).HasColumnName("rejection_reason");
|
||||
entity.Property(e => e.Metadata).HasColumnType("jsonb").HasColumnName("metadata");
|
||||
entity.Property(e => e.Version).HasColumnName("version");
|
||||
entity.Property(e => e.UpdatedAt).HasDefaultValueSql("now()").HasColumnName("updated_at");
|
||||
});
|
||||
|
||||
// === exception_approval_audit ===
|
||||
modelBuilder.Entity<ExceptionApprovalAuditEntity>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id).HasName("exception_approval_audit_pkey");
|
||||
entity.ToTable("exception_approval_audit", schemaName);
|
||||
|
||||
entity.HasIndex(e => e.RequestId, "idx_approval_audit_request");
|
||||
entity.HasIndex(e => new { e.RequestId, e.SequenceNumber }).IsUnique();
|
||||
|
||||
entity.Property(e => e.Id).HasDefaultValueSql("gen_random_uuid()").HasColumnName("id");
|
||||
entity.Property(e => e.RequestId).HasColumnName("request_id");
|
||||
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
|
||||
entity.Property(e => e.SequenceNumber).HasColumnName("sequence_number");
|
||||
entity.Property(e => e.ActionType).HasColumnName("action_type");
|
||||
entity.Property(e => e.ActorId).HasColumnName("actor_id");
|
||||
entity.Property(e => e.OccurredAt).HasDefaultValueSql("now()").HasColumnName("occurred_at");
|
||||
entity.Property(e => e.PreviousStatus).HasColumnName("previous_status");
|
||||
entity.Property(e => e.NewStatus).HasColumnName("new_status");
|
||||
entity.Property(e => e.Description).HasColumnName("description");
|
||||
entity.Property(e => e.Details).HasColumnType("jsonb").HasColumnName("details");
|
||||
entity.Property(e => e.ClientInfo).HasColumnType("jsonb").HasColumnName("client_info");
|
||||
});
|
||||
|
||||
// === exception_approval_rules ===
|
||||
modelBuilder.Entity<ExceptionApprovalRuleEntity>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id).HasName("exception_approval_rules_pkey");
|
||||
entity.ToTable("exception_approval_rules", schemaName);
|
||||
|
||||
entity.HasIndex(e => new { e.TenantId, e.GateLevel, e.Name }).IsUnique();
|
||||
|
||||
entity.Property(e => e.Id).HasDefaultValueSql("gen_random_uuid()").HasColumnName("id");
|
||||
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
|
||||
entity.Property(e => e.Name).HasColumnName("name");
|
||||
entity.Property(e => e.Description).HasColumnName("description");
|
||||
entity.Property(e => e.GateLevel).HasConversion<int>().HasColumnName("gate_level");
|
||||
entity.Property(e => e.MinApprovers).HasColumnName("min_approvers");
|
||||
entity.Property(e => e.RequiredRoles).HasColumnName("required_roles");
|
||||
entity.Property(e => e.MaxTtlDays).HasColumnName("max_ttl_days");
|
||||
entity.Property(e => e.AllowSelfApproval).HasColumnName("allow_self_approval");
|
||||
entity.Property(e => e.RequireEvidence).HasColumnName("require_evidence");
|
||||
entity.Property(e => e.RequireCompensatingControls).HasColumnName("require_compensating_controls");
|
||||
entity.Property(e => e.MinRationaleLength).HasColumnName("min_rationale_length");
|
||||
entity.Property(e => e.Priority).HasColumnName("priority");
|
||||
entity.Property(e => e.Enabled).HasColumnName("enabled");
|
||||
entity.Property(e => e.CreatedAt).HasDefaultValueSql("now()").HasColumnName("created_at");
|
||||
entity.Property(e => e.UpdatedAt).HasDefaultValueSql("now()").HasColumnName("updated_at");
|
||||
});
|
||||
|
||||
// === audit ===
|
||||
modelBuilder.Entity<PolicyAuditEntity>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id).HasName("audit_pkey");
|
||||
entity.ToTable("audit", schemaName);
|
||||
|
||||
entity.HasIndex(e => e.TenantId, "idx_audit_tenant");
|
||||
entity.HasIndex(e => new { e.ResourceType, e.ResourceId }, "idx_audit_resource");
|
||||
entity.HasIndex(e => new { e.TenantId, e.CreatedAt }, "idx_audit_created");
|
||||
|
||||
entity.Property(e => e.Id).ValueGeneratedOnAdd().HasColumnName("id");
|
||||
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
|
||||
entity.Property(e => e.UserId).HasColumnName("user_id");
|
||||
entity.Property(e => e.Action).HasColumnName("action");
|
||||
entity.Property(e => e.ResourceType).HasColumnName("resource_type");
|
||||
entity.Property(e => e.ResourceId).HasColumnName("resource_id");
|
||||
entity.Property(e => e.OldValue).HasColumnType("jsonb").HasColumnName("old_value");
|
||||
entity.Property(e => e.NewValue).HasColumnType("jsonb").HasColumnName("new_value");
|
||||
entity.Property(e => e.CorrelationId).HasColumnName("correlation_id");
|
||||
entity.Property(e => e.CreatedAt).HasDefaultValueSql("now()").HasColumnName("created_at");
|
||||
entity.Property(e => e.VexTrustScore).HasColumnName("vex_trust_score");
|
||||
entity.Property(e => e.VexTrustTier).HasColumnName("vex_trust_tier");
|
||||
entity.Property(e => e.VexSignatureVerified).HasColumnName("vex_signature_verified");
|
||||
entity.Property(e => e.VexIssuerId).HasColumnName("vex_issuer_id");
|
||||
entity.Property(e => e.VexIssuerName).HasColumnName("vex_issuer_name");
|
||||
entity.Property(e => e.VexTrustGateResult).HasColumnName("vex_trust_gate_result");
|
||||
entity.Property(e => e.VexTrustGateReason).HasColumnName("vex_trust_gate_reason");
|
||||
entity.Property(e => e.VexSignatureMethod).HasColumnName("vex_signature_method");
|
||||
});
|
||||
|
||||
// === trusted_keys (Migration 002) ===
|
||||
modelBuilder.Entity<TrustedKeyEntity>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id).HasName("trusted_keys_pkey");
|
||||
entity.ToTable("trusted_keys", schemaName);
|
||||
|
||||
entity.HasIndex(e => e.TenantId, "idx_trusted_keys_tenant");
|
||||
entity.HasIndex(e => new { e.TenantId, e.KeyId }).IsUnique();
|
||||
entity.HasIndex(e => new { e.TenantId, e.Fingerprint }).IsUnique();
|
||||
|
||||
entity.Property(e => e.Id).HasDefaultValueSql("gen_random_uuid()").HasColumnName("id");
|
||||
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
|
||||
entity.Property(e => e.KeyId).HasColumnName("key_id");
|
||||
entity.Property(e => e.Fingerprint).HasColumnName("fingerprint");
|
||||
entity.Property(e => e.Algorithm).HasColumnName("algorithm");
|
||||
entity.Property(e => e.PublicKeyPem).HasColumnName("public_key_pem");
|
||||
entity.Property(e => e.Owner).HasColumnName("owner");
|
||||
entity.Property(e => e.IssuerPattern).HasColumnName("issuer_pattern");
|
||||
entity.Property(e => e.Purposes).HasColumnType("jsonb").HasColumnName("purposes");
|
||||
entity.Property(e => e.ValidFrom).HasDefaultValueSql("now()").HasColumnName("valid_from");
|
||||
entity.Property(e => e.ValidUntil).HasColumnName("valid_until");
|
||||
entity.Property(e => e.IsActive).HasColumnName("is_active");
|
||||
entity.Property(e => e.RevokedAt).HasColumnName("revoked_at");
|
||||
entity.Property(e => e.RevokedReason).HasColumnName("revoked_reason");
|
||||
entity.Property(e => e.Metadata).HasColumnType("jsonb").HasColumnName("metadata");
|
||||
entity.Property(e => e.CreatedAt).HasDefaultValueSql("now()").HasColumnName("created_at");
|
||||
entity.Property(e => e.UpdatedAt).HasDefaultValueSql("now()").HasColumnName("updated_at");
|
||||
entity.Property(e => e.CreatedBy).HasColumnName("created_by");
|
||||
});
|
||||
|
||||
// === gate_bypass_audit (Migration 002) ===
|
||||
modelBuilder.Entity<GateBypassAuditEntity>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id).HasName("gate_bypass_audit_pkey");
|
||||
entity.ToTable("gate_bypass_audit", schemaName);
|
||||
|
||||
entity.HasIndex(e => new { e.TenantId, e.Timestamp }, "idx_gate_bypass_audit_tenant_timestamp").IsDescending(false, true);
|
||||
|
||||
entity.Property(e => e.Id).HasDefaultValueSql("gen_random_uuid()").HasColumnName("id");
|
||||
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
|
||||
entity.Property(e => e.Timestamp).HasDefaultValueSql("now()").HasColumnName("timestamp");
|
||||
entity.Property(e => e.DecisionId).HasColumnName("decision_id");
|
||||
entity.Property(e => e.ImageDigest).HasColumnName("image_digest");
|
||||
entity.Property(e => e.Repository).HasColumnName("repository");
|
||||
entity.Property(e => e.Tag).HasColumnName("tag");
|
||||
entity.Property(e => e.BaselineRef).HasColumnName("baseline_ref");
|
||||
entity.Property(e => e.OriginalDecision).HasColumnName("original_decision");
|
||||
entity.Property(e => e.FinalDecision).HasColumnName("final_decision");
|
||||
entity.Property(e => e.BypassedGates).HasColumnType("jsonb").HasColumnName("bypassed_gates");
|
||||
entity.Property(e => e.Actor).HasColumnName("actor");
|
||||
entity.Property(e => e.ActorSubject).HasColumnName("actor_subject");
|
||||
entity.Property(e => e.ActorEmail).HasColumnName("actor_email");
|
||||
entity.Property(e => e.ActorIpAddress).HasColumnName("actor_ip_address");
|
||||
entity.Property(e => e.Justification).HasColumnName("justification");
|
||||
entity.Property(e => e.PolicyId).HasColumnName("policy_id");
|
||||
entity.Property(e => e.Source).HasColumnName("source");
|
||||
entity.Property(e => e.CiContext).HasColumnName("ci_context");
|
||||
entity.Property(e => e.AttestationDigest).HasColumnName("attestation_digest");
|
||||
entity.Property(e => e.RekorUuid).HasColumnName("rekor_uuid");
|
||||
entity.Property(e => e.BypassType).HasColumnName("bypass_type");
|
||||
entity.Property(e => e.ExpiresAt).HasColumnName("expires_at");
|
||||
entity.Property(e => e.Metadata).HasColumnType("jsonb").HasColumnName("metadata");
|
||||
entity.Property(e => e.CreatedAt).HasDefaultValueSql("now()").HasColumnName("created_at");
|
||||
});
|
||||
|
||||
// === gate_decisions (Migration 003) ===
|
||||
modelBuilder.Entity<GateDecisionEntity>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.DecisionId).HasName("gate_decisions_pkey");
|
||||
entity.ToTable("gate_decisions", schemaName);
|
||||
|
||||
entity.HasIndex(e => new { e.TenantId, e.EvaluatedAt }, "idx_gate_decisions_tenant_evaluated").IsDescending(false, true);
|
||||
entity.HasIndex(e => new { e.TenantId, e.GateId, e.EvaluatedAt }, "idx_gate_decisions_gate_evaluated").IsDescending(false, false, true);
|
||||
|
||||
entity.Property(e => e.DecisionId).HasDefaultValueSql("gen_random_uuid()").HasColumnName("decision_id");
|
||||
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
|
||||
entity.Property(e => e.GateId).HasColumnName("gate_id");
|
||||
entity.Property(e => e.BomRef).HasColumnName("bom_ref");
|
||||
entity.Property(e => e.ImageDigest).HasColumnName("image_digest");
|
||||
entity.Property(e => e.GateStatus).HasColumnName("gate_status");
|
||||
entity.Property(e => e.VerdictHash).HasColumnName("verdict_hash");
|
||||
entity.Property(e => e.PolicyBundleId).HasColumnName("policy_bundle_id");
|
||||
entity.Property(e => e.PolicyBundleHash).HasColumnName("policy_bundle_hash");
|
||||
entity.Property(e => e.EvaluatedAt).HasDefaultValueSql("now()").HasColumnName("evaluated_at");
|
||||
entity.Property(e => e.CiContext).HasColumnName("ci_context");
|
||||
entity.Property(e => e.Actor).HasColumnName("actor");
|
||||
entity.Property(e => e.BlockingUnknownIds).HasColumnType("jsonb").HasColumnName("blocking_unknown_ids");
|
||||
entity.Property(e => e.Warnings).HasColumnType("jsonb").HasColumnName("warnings");
|
||||
entity.Property(e => e.CreatedAt).HasDefaultValueSql("now()").HasColumnName("created_at");
|
||||
});
|
||||
|
||||
// === replay_audit (Migration 004) ===
|
||||
modelBuilder.Entity<ReplayAuditEntity>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.ReplayId).HasName("replay_audit_pkey");
|
||||
entity.ToTable("replay_audit", schemaName);
|
||||
|
||||
entity.HasIndex(e => new { e.TenantId, e.ReplayedAt }, "idx_replay_audit_tenant_replayed").IsDescending(false, true);
|
||||
entity.HasIndex(e => new { e.TenantId, e.BomRef, e.ReplayedAt }, "idx_replay_audit_bom_ref").IsDescending(false, false, true);
|
||||
|
||||
entity.Property(e => e.ReplayId).HasDefaultValueSql("gen_random_uuid()").HasColumnName("replay_id");
|
||||
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
|
||||
entity.Property(e => e.BomRef).HasMaxLength(512).HasColumnName("bom_ref");
|
||||
entity.Property(e => e.VerdictHash).HasMaxLength(128).HasColumnName("verdict_hash");
|
||||
entity.Property(e => e.RekorUuid).HasMaxLength(128).HasColumnName("rekor_uuid");
|
||||
entity.Property(e => e.ReplayedAt).HasDefaultValueSql("now()").HasColumnName("replayed_at");
|
||||
entity.Property(e => e.Match).HasColumnName("match");
|
||||
entity.Property(e => e.OriginalHash).HasMaxLength(128).HasColumnName("original_hash");
|
||||
entity.Property(e => e.ReplayedHash).HasMaxLength(128).HasColumnName("replayed_hash");
|
||||
entity.Property(e => e.MismatchReason).HasColumnName("mismatch_reason");
|
||||
entity.Property(e => e.PolicyBundleId).HasColumnName("policy_bundle_id");
|
||||
entity.Property(e => e.PolicyBundleHash).HasMaxLength(128).HasColumnName("policy_bundle_hash");
|
||||
entity.Property(e => e.VerifierDigest).HasMaxLength(128).HasColumnName("verifier_digest");
|
||||
entity.Property(e => e.DurationMs).HasColumnName("duration_ms");
|
||||
entity.Property(e => e.Actor).HasMaxLength(256).HasColumnName("actor");
|
||||
entity.Property(e => e.Source).HasMaxLength(64).HasColumnName("source");
|
||||
entity.Property(e => e.RequestContext).HasColumnType("jsonb").HasColumnName("request_context");
|
||||
entity.Property(e => e.CreatedAt).HasDefaultValueSql("now()").HasColumnName("created_at");
|
||||
});
|
||||
|
||||
// === advisory_source_impacts (Migration 005) ===
|
||||
modelBuilder.Entity<AdvisorySourceImpactEntity>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id).HasName("advisory_source_impacts_pkey");
|
||||
entity.ToTable("advisory_source_impacts", schemaName);
|
||||
|
||||
entity.Property(e => e.Id).HasDefaultValueSql("gen_random_uuid()").HasColumnName("id");
|
||||
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
|
||||
entity.Property(e => e.SourceKey).HasColumnName("source_key");
|
||||
entity.Property(e => e.SourceFamily).HasColumnName("source_family");
|
||||
entity.Property(e => e.Region).HasColumnName("region");
|
||||
entity.Property(e => e.Environment).HasColumnName("environment");
|
||||
entity.Property(e => e.ImpactedDecisionsCount).HasColumnName("impacted_decisions_count");
|
||||
entity.Property(e => e.ImpactSeverity).HasColumnName("impact_severity");
|
||||
entity.Property(e => e.LastDecisionAt).HasColumnName("last_decision_at");
|
||||
entity.Property(e => e.DecisionRefs).HasColumnType("jsonb").HasColumnName("decision_refs");
|
||||
entity.Property(e => e.UpdatedAt).HasDefaultValueSql("now()").HasColumnName("updated_at");
|
||||
entity.Property(e => e.UpdatedBy).HasColumnName("updated_by");
|
||||
});
|
||||
|
||||
// === advisory_source_conflicts (Migration 005) ===
|
||||
modelBuilder.Entity<AdvisorySourceConflictEntity>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.ConflictId).HasName("advisory_source_conflicts_pkey");
|
||||
entity.ToTable("advisory_source_conflicts", schemaName);
|
||||
|
||||
entity.Property(e => e.ConflictId).HasDefaultValueSql("gen_random_uuid()").HasColumnName("conflict_id");
|
||||
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
|
||||
entity.Property(e => e.SourceKey).HasColumnName("source_key");
|
||||
entity.Property(e => e.SourceFamily).HasColumnName("source_family");
|
||||
entity.Property(e => e.AdvisoryId).HasColumnName("advisory_id");
|
||||
entity.Property(e => e.PairedSourceKey).HasColumnName("paired_source_key");
|
||||
entity.Property(e => e.ConflictType).HasColumnName("conflict_type");
|
||||
entity.Property(e => e.Severity).HasColumnName("severity");
|
||||
entity.Property(e => e.Status).HasColumnName("status");
|
||||
entity.Property(e => e.Description).HasColumnName("description");
|
||||
entity.Property(e => e.FirstDetectedAt).HasDefaultValueSql("now()").HasColumnName("first_detected_at");
|
||||
entity.Property(e => e.LastDetectedAt).HasDefaultValueSql("now()").HasColumnName("last_detected_at");
|
||||
entity.Property(e => e.ResolvedAt).HasColumnName("resolved_at");
|
||||
entity.Property(e => e.DetailsJson).HasColumnType("jsonb").HasColumnName("details_json");
|
||||
entity.Property(e => e.UpdatedAt).HasDefaultValueSql("now()").HasColumnName("updated_at");
|
||||
entity.Property(e => e.UpdatedBy).HasColumnName("updated_by");
|
||||
});
|
||||
|
||||
OnModelCreatingPartial(modelBuilder);
|
||||
}
|
||||
|
||||
partial void OnModelCreatingPartial(ModelBuilder modelBuilder);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Design;
|
||||
|
||||
namespace StellaOps.Policy.Persistence.EfCore.Context;
|
||||
|
||||
/// <summary>
|
||||
/// Design-time factory for dotnet ef CLI tooling.
|
||||
/// Uses reflection-based model discovery (no compiled models).
|
||||
/// </summary>
|
||||
public sealed class PolicyDesignTimeDbContextFactory : IDesignTimeDbContextFactory<PolicyDbContext>
|
||||
{
|
||||
private const string DefaultConnectionString =
|
||||
"Host=localhost;Port=55433;Database=postgres;Username=postgres;Password=postgres;Search Path=policy,public";
|
||||
|
||||
private const string ConnectionStringEnvironmentVariable =
|
||||
"STELLAOPS_POLICY_EF_CONNECTION";
|
||||
|
||||
public PolicyDbContext CreateDbContext(string[] args)
|
||||
{
|
||||
var connectionString = ResolveConnectionString();
|
||||
var options = new DbContextOptionsBuilder<PolicyDbContext>()
|
||||
.UseNpgsql(connectionString)
|
||||
.Options;
|
||||
|
||||
return new PolicyDbContext(options);
|
||||
}
|
||||
|
||||
private static string ResolveConnectionString()
|
||||
{
|
||||
var fromEnvironment = Environment.GetEnvironmentVariable(ConnectionStringEnvironmentVariable);
|
||||
return string.IsNullOrWhiteSpace(fromEnvironment) ? DefaultConnectionString : fromEnvironment;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
namespace StellaOps.Policy.Persistence.Postgres.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Entity representing advisory source conflict records.
|
||||
/// Maps to policy.advisory_source_conflicts table (Migration 005).
|
||||
/// </summary>
|
||||
public sealed class AdvisorySourceConflictEntity
|
||||
{
|
||||
public Guid ConflictId { get; init; }
|
||||
public required string TenantId { get; init; }
|
||||
public required string SourceKey { get; init; }
|
||||
public string SourceFamily { get; init; } = string.Empty;
|
||||
public required string AdvisoryId { get; init; }
|
||||
public string? PairedSourceKey { get; init; }
|
||||
public required string ConflictType { get; init; }
|
||||
public string Severity { get; init; } = "medium";
|
||||
public string Status { get; init; } = "open";
|
||||
public string Description { get; init; } = string.Empty;
|
||||
public DateTimeOffset FirstDetectedAt { get; init; }
|
||||
public DateTimeOffset LastDetectedAt { get; init; }
|
||||
public DateTimeOffset? ResolvedAt { get; init; }
|
||||
public string DetailsJson { get; init; } = "{}";
|
||||
public DateTimeOffset UpdatedAt { get; init; }
|
||||
public string UpdatedBy { get; init; } = "system";
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace StellaOps.Policy.Persistence.Postgres.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Entity representing advisory source impact projections.
|
||||
/// Maps to policy.advisory_source_impacts table (Migration 005).
|
||||
/// </summary>
|
||||
public sealed class AdvisorySourceImpactEntity
|
||||
{
|
||||
public Guid Id { get; init; }
|
||||
public required string TenantId { get; init; }
|
||||
public required string SourceKey { get; init; }
|
||||
public string SourceFamily { get; init; } = string.Empty;
|
||||
public string Region { get; init; } = string.Empty;
|
||||
public string Environment { get; init; } = string.Empty;
|
||||
public int ImpactedDecisionsCount { get; init; }
|
||||
public string ImpactSeverity { get; init; } = "none";
|
||||
public DateTimeOffset? LastDecisionAt { get; init; }
|
||||
public string DecisionRefs { get; init; } = "[]";
|
||||
public DateTimeOffset UpdatedAt { get; init; }
|
||||
public string UpdatedBy { get; init; } = "system";
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
namespace StellaOps.Policy.Persistence.Postgres.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Entity representing a historical gate decision for audit and replay.
|
||||
/// Maps to policy.gate_decisions table (Migration 003).
|
||||
/// </summary>
|
||||
public sealed class GateDecisionEntity
|
||||
{
|
||||
public Guid DecisionId { get; init; }
|
||||
public Guid TenantId { get; init; }
|
||||
public required string GateId { get; init; }
|
||||
public required string BomRef { get; init; }
|
||||
public string? ImageDigest { get; init; }
|
||||
public required string GateStatus { get; init; }
|
||||
public string? VerdictHash { get; init; }
|
||||
public string? PolicyBundleId { get; init; }
|
||||
public string? PolicyBundleHash { get; init; }
|
||||
public DateTimeOffset EvaluatedAt { get; init; }
|
||||
public string? CiContext { get; init; }
|
||||
public string? Actor { get; init; }
|
||||
public string? BlockingUnknownIds { get; init; }
|
||||
public string? Warnings { get; init; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace StellaOps.Policy.Persistence.Postgres.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Entity representing a replay audit record for compliance tracking.
|
||||
/// Maps to policy.replay_audit table (Migration 004).
|
||||
/// </summary>
|
||||
public sealed class ReplayAuditEntity
|
||||
{
|
||||
public Guid ReplayId { get; init; }
|
||||
public Guid TenantId { get; init; }
|
||||
public required string BomRef { get; init; }
|
||||
public required string VerdictHash { get; init; }
|
||||
public string? RekorUuid { get; init; }
|
||||
public DateTimeOffset ReplayedAt { get; init; }
|
||||
public bool Match { get; init; }
|
||||
public string? OriginalHash { get; init; }
|
||||
public string? ReplayedHash { get; init; }
|
||||
public string? MismatchReason { get; init; }
|
||||
public string? PolicyBundleId { get; init; }
|
||||
public string? PolicyBundleHash { get; init; }
|
||||
public string? VerifierDigest { get; init; }
|
||||
public int? DurationMs { get; init; }
|
||||
public string? Actor { get; init; }
|
||||
public string? Source { get; init; }
|
||||
public string? RequestContext { get; init; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Npgsql;
|
||||
using StellaOps.Policy.Persistence.EfCore.CompiledModels;
|
||||
using StellaOps.Policy.Persistence.EfCore.Context;
|
||||
|
||||
namespace StellaOps.Policy.Persistence.Postgres;
|
||||
|
||||
/// <summary>
|
||||
/// Runtime factory for <see cref="PolicyDbContext"/>.
|
||||
/// Uses the static compiled model for the default schema and falls back to
|
||||
/// reflection-based model building for non-default schemas (integration tests).
|
||||
/// </summary>
|
||||
internal static class PolicyDbContextFactory
|
||||
{
|
||||
public static PolicyDbContext Create(NpgsqlConnection connection, int commandTimeoutSeconds, string? schemaName)
|
||||
{
|
||||
var normalizedSchema = string.IsNullOrWhiteSpace(schemaName)
|
||||
? PolicyDataSource.DefaultSchemaName
|
||||
: schemaName.Trim();
|
||||
|
||||
var optionsBuilder = new DbContextOptionsBuilder<PolicyDbContext>()
|
||||
.UseNpgsql(connection, npgsql => npgsql.CommandTimeout(commandTimeoutSeconds));
|
||||
|
||||
// Use the static compiled model only when schema matches the default.
|
||||
if (string.Equals(normalizedSchema, PolicyDataSource.DefaultSchemaName, StringComparison.Ordinal))
|
||||
{
|
||||
optionsBuilder.UseModel(PolicyDbContextModel.Instance);
|
||||
}
|
||||
|
||||
return new PolicyDbContext(optionsBuilder.Options, normalizedSchema);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
@@ -7,6 +8,8 @@ namespace StellaOps.Policy.Persistence.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL repository for conflict detection and resolution operations.
|
||||
/// Uses EF Core for standard CRUD; raw SQL preserved for CASE severity ordering
|
||||
/// and aggregate GROUP BY queries.
|
||||
/// </summary>
|
||||
public sealed class ConflictRepository : RepositoryBase<PolicyDataSource>, IConflictRepository
|
||||
{
|
||||
@@ -21,45 +24,27 @@ public sealed class ConflictRepository : RepositoryBase<PolicyDataSource>, IConf
|
||||
/// <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);
|
||||
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
|
||||
|
||||
AddConflictParameters(command, conflict);
|
||||
dbContext.Conflicts.Add(conflict);
|
||||
await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return MapConflict(reader);
|
||||
return conflict;
|
||||
}
|
||||
|
||||
/// <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";
|
||||
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
|
||||
|
||||
return await QuerySingleOrDefaultAsync(
|
||||
tenantId,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "id", id);
|
||||
},
|
||||
MapConflict,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
return await dbContext.Conflicts
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(c => c.TenantId == tenantId && c.Id == id, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -69,6 +54,7 @@ public sealed class ConflictRepository : RepositoryBase<PolicyDataSource>, IConf
|
||||
int offset = 0,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Keep raw SQL: CASE severity ordering cannot be cleanly expressed in EF Core LINQ
|
||||
const string sql = """
|
||||
SELECT * FROM policy.conflicts
|
||||
WHERE tenant_id = @tenant_id AND status = 'open'
|
||||
@@ -104,33 +90,24 @@ public sealed class ConflictRepository : RepositoryBase<PolicyDataSource>, IConf
|
||||
int limit = 100,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var sql = """
|
||||
SELECT * FROM policy.conflicts
|
||||
WHERE tenant_id = @tenant_id AND conflict_type = @conflict_type
|
||||
""";
|
||||
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
|
||||
|
||||
var query = dbContext.Conflicts
|
||||
.AsNoTracking()
|
||||
.Where(c => c.TenantId == tenantId && c.ConflictType == conflictType);
|
||||
|
||||
if (!string.IsNullOrEmpty(status))
|
||||
{
|
||||
sql += " AND status = @status";
|
||||
query = query.Where(c => c.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);
|
||||
return await query
|
||||
.OrderByDescending(c => c.CreatedAt)
|
||||
.Take(limit)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -141,6 +118,7 @@ public sealed class ConflictRepository : RepositoryBase<PolicyDataSource>, IConf
|
||||
string resolvedBy,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Keep raw SQL: conditional update WHERE status = 'open' with NOW()
|
||||
const string sql = """
|
||||
UPDATE policy.conflicts
|
||||
SET status = 'resolved', resolution = @resolution, resolved_by = @resolved_by, resolved_at = NOW()
|
||||
@@ -169,6 +147,7 @@ public sealed class ConflictRepository : RepositoryBase<PolicyDataSource>, IConf
|
||||
string dismissedBy,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Keep raw SQL: conditional update WHERE status = 'open' with NOW()
|
||||
const string sql = """
|
||||
UPDATE policy.conflicts
|
||||
SET status = 'dismissed', resolved_by = @dismissed_by, resolved_at = NOW()
|
||||
@@ -194,6 +173,7 @@ public sealed class ConflictRepository : RepositoryBase<PolicyDataSource>, IConf
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Keep raw SQL: GROUP BY aggregate cannot be cleanly expressed as Dictionary return in EF Core
|
||||
const string sql = """
|
||||
SELECT severity, COUNT(*)::int as count
|
||||
FROM policy.conflicts
|
||||
@@ -220,21 +200,6 @@ public sealed class ConflictRepository : RepositoryBase<PolicyDataSource>, IConf
|
||||
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")),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
@@ -7,6 +8,8 @@ namespace StellaOps.Policy.Persistence.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL repository for policy evaluation run operations.
|
||||
/// Uses EF Core for reads and inserts; raw SQL preserved for conditional status transitions
|
||||
/// and aggregate statistics queries.
|
||||
/// </summary>
|
||||
public sealed class EvaluationRunRepository : RepositoryBase<PolicyDataSource>, IEvaluationRunRepository
|
||||
{
|
||||
@@ -21,68 +24,27 @@ public sealed class EvaluationRunRepository : RepositoryBase<PolicyDataSource>,
|
||||
/// <inheritdoc />
|
||||
public async Task<EvaluationRunEntity> CreateAsync(EvaluationRunEntity run, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
INSERT INTO policy.evaluation_runs (
|
||||
id, tenant_id, project_id, artifact_id, pack_id, pack_version,
|
||||
risk_profile_id, status, result, score,
|
||||
findings_count, critical_count, high_count, medium_count, low_count,
|
||||
input_hash, metadata, duration_ms, error_message, created_by
|
||||
)
|
||||
VALUES (
|
||||
@id, @tenant_id, @project_id, @artifact_id, @pack_id, @pack_version,
|
||||
@risk_profile_id, @status, @result, @score,
|
||||
@findings_count, @critical_count, @high_count, @medium_count, @low_count,
|
||||
@input_hash, @metadata::jsonb, @duration_ms, @error_message, @created_by
|
||||
)
|
||||
RETURNING *
|
||||
""";
|
||||
|
||||
await using var connection = await DataSource.OpenConnectionAsync(run.TenantId, "writer", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
|
||||
|
||||
AddParameter(command, "id", run.Id);
|
||||
AddParameter(command, "tenant_id", run.TenantId);
|
||||
AddParameter(command, "project_id", run.ProjectId);
|
||||
AddParameter(command, "artifact_id", run.ArtifactId);
|
||||
AddParameter(command, "pack_id", run.PackId);
|
||||
AddParameter(command, "pack_version", run.PackVersion);
|
||||
AddParameter(command, "risk_profile_id", run.RiskProfileId);
|
||||
AddParameter(command, "status", StatusToString(run.Status));
|
||||
AddParameter(command, "result", run.Result.HasValue ? ResultToString(run.Result.Value) : null);
|
||||
AddParameter(command, "score", run.Score);
|
||||
AddParameter(command, "findings_count", run.FindingsCount);
|
||||
AddParameter(command, "critical_count", run.CriticalCount);
|
||||
AddParameter(command, "high_count", run.HighCount);
|
||||
AddParameter(command, "medium_count", run.MediumCount);
|
||||
AddParameter(command, "low_count", run.LowCount);
|
||||
AddParameter(command, "input_hash", run.InputHash);
|
||||
AddJsonbParameter(command, "metadata", run.Metadata);
|
||||
AddParameter(command, "duration_ms", run.DurationMs);
|
||||
AddParameter(command, "error_message", run.ErrorMessage);
|
||||
AddParameter(command, "created_by", run.CreatedBy);
|
||||
dbContext.EvaluationRuns.Add(run);
|
||||
await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return MapRun(reader);
|
||||
return run;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<EvaluationRunEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = "SELECT * FROM policy.evaluation_runs WHERE tenant_id = @tenant_id AND id = @id";
|
||||
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
|
||||
|
||||
return await QuerySingleOrDefaultAsync(
|
||||
tenantId,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "id", id);
|
||||
},
|
||||
MapRun,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
return await dbContext.EvaluationRuns
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(r => r.TenantId == tenantId && r.Id == id, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -93,25 +55,18 @@ public sealed class EvaluationRunRepository : RepositoryBase<PolicyDataSource>,
|
||||
int offset = 0,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT * FROM policy.evaluation_runs
|
||||
WHERE tenant_id = @tenant_id AND project_id = @project_id
|
||||
ORDER BY created_at DESC, id
|
||||
LIMIT @limit OFFSET @offset
|
||||
""";
|
||||
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
|
||||
|
||||
return await QueryAsync(
|
||||
tenantId,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "project_id", projectId);
|
||||
AddParameter(cmd, "limit", limit);
|
||||
AddParameter(cmd, "offset", offset);
|
||||
},
|
||||
MapRun,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
return await dbContext.EvaluationRuns
|
||||
.AsNoTracking()
|
||||
.Where(r => r.TenantId == tenantId && r.ProjectId == projectId)
|
||||
.OrderByDescending(r => r.CreatedAt).ThenBy(r => r.Id)
|
||||
.Skip(offset)
|
||||
.Take(limit)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -121,24 +76,17 @@ public sealed class EvaluationRunRepository : RepositoryBase<PolicyDataSource>,
|
||||
int limit = 100,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT * FROM policy.evaluation_runs
|
||||
WHERE tenant_id = @tenant_id AND artifact_id = @artifact_id
|
||||
ORDER BY created_at DESC, id
|
||||
LIMIT @limit
|
||||
""";
|
||||
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
|
||||
|
||||
return await QueryAsync(
|
||||
tenantId,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "artifact_id", artifactId);
|
||||
AddParameter(cmd, "limit", limit);
|
||||
},
|
||||
MapRun,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
return await dbContext.EvaluationRuns
|
||||
.AsNoTracking()
|
||||
.Where(r => r.TenantId == tenantId && r.ArtifactId == artifactId)
|
||||
.OrderByDescending(r => r.CreatedAt).ThenBy(r => r.Id)
|
||||
.Take(limit)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -148,24 +96,17 @@ public sealed class EvaluationRunRepository : RepositoryBase<PolicyDataSource>,
|
||||
int limit = 100,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT * FROM policy.evaluation_runs
|
||||
WHERE tenant_id = @tenant_id AND status = @status
|
||||
ORDER BY created_at, id
|
||||
LIMIT @limit
|
||||
""";
|
||||
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
|
||||
|
||||
return await QueryAsync(
|
||||
tenantId,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "status", StatusToString(status));
|
||||
AddParameter(cmd, "limit", limit);
|
||||
},
|
||||
MapRun,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
return await dbContext.EvaluationRuns
|
||||
.AsNoTracking()
|
||||
.Where(r => r.TenantId == tenantId && r.Status == status)
|
||||
.OrderBy(r => r.CreatedAt).ThenBy(r => r.Id)
|
||||
.Take(limit)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -174,28 +115,23 @@ public sealed class EvaluationRunRepository : RepositoryBase<PolicyDataSource>,
|
||||
int limit = 50,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT * FROM policy.evaluation_runs
|
||||
WHERE tenant_id = @tenant_id
|
||||
ORDER BY created_at DESC, id
|
||||
LIMIT @limit
|
||||
""";
|
||||
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
|
||||
|
||||
return await QueryAsync(
|
||||
tenantId,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "limit", limit);
|
||||
},
|
||||
MapRun,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
return await dbContext.EvaluationRuns
|
||||
.AsNoTracking()
|
||||
.Where(r => r.TenantId == tenantId)
|
||||
.OrderByDescending(r => r.CreatedAt).ThenBy(r => r.Id)
|
||||
.Take(limit)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> MarkStartedAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Keep raw SQL: conditional status transition WHERE status = 'pending' with NOW()
|
||||
const string sql = """
|
||||
UPDATE policy.evaluation_runs
|
||||
SET status = 'running',
|
||||
@@ -230,6 +166,7 @@ public sealed class EvaluationRunRepository : RepositoryBase<PolicyDataSource>,
|
||||
int durationMs,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Keep raw SQL: conditional status transition WHERE status = 'running' with NOW() and multi-column update
|
||||
const string sql = """
|
||||
UPDATE policy.evaluation_runs
|
||||
SET status = 'completed',
|
||||
@@ -273,6 +210,7 @@ public sealed class EvaluationRunRepository : RepositoryBase<PolicyDataSource>,
|
||||
string errorMessage,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Keep raw SQL: conditional status transition WHERE status IN ('pending', 'running')
|
||||
const string sql = """
|
||||
UPDATE policy.evaluation_runs
|
||||
SET status = 'failed',
|
||||
@@ -303,6 +241,7 @@ public sealed class EvaluationRunRepository : RepositoryBase<PolicyDataSource>,
|
||||
DateTimeOffset to,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Keep raw SQL: FILTER, AVG, SUM aggregate functions cannot be expressed in single EF Core query
|
||||
const string sql = """
|
||||
SELECT
|
||||
COUNT(*) as total,
|
||||
@@ -343,51 +282,6 @@ public sealed class EvaluationRunRepository : RepositoryBase<PolicyDataSource>,
|
||||
HighFindings: reader.IsDBNull(8) ? 0 : reader.GetInt64(8));
|
||||
}
|
||||
|
||||
private static EvaluationRunEntity MapRun(NpgsqlDataReader reader) => new()
|
||||
{
|
||||
Id = reader.GetGuid(reader.GetOrdinal("id")),
|
||||
TenantId = reader.GetString(reader.GetOrdinal("tenant_id")),
|
||||
ProjectId = GetNullableString(reader, reader.GetOrdinal("project_id")),
|
||||
ArtifactId = GetNullableString(reader, reader.GetOrdinal("artifact_id")),
|
||||
PackId = GetNullableGuid(reader, reader.GetOrdinal("pack_id")),
|
||||
PackVersion = GetNullableInt(reader, reader.GetOrdinal("pack_version")),
|
||||
RiskProfileId = GetNullableGuid(reader, reader.GetOrdinal("risk_profile_id")),
|
||||
Status = ParseStatus(reader.GetString(reader.GetOrdinal("status"))),
|
||||
Result = GetNullableResult(reader, reader.GetOrdinal("result")),
|
||||
Score = GetNullableDecimal(reader, reader.GetOrdinal("score")),
|
||||
FindingsCount = reader.GetInt32(reader.GetOrdinal("findings_count")),
|
||||
CriticalCount = reader.GetInt32(reader.GetOrdinal("critical_count")),
|
||||
HighCount = reader.GetInt32(reader.GetOrdinal("high_count")),
|
||||
MediumCount = reader.GetInt32(reader.GetOrdinal("medium_count")),
|
||||
LowCount = reader.GetInt32(reader.GetOrdinal("low_count")),
|
||||
InputHash = GetNullableString(reader, reader.GetOrdinal("input_hash")),
|
||||
DurationMs = GetNullableInt(reader, reader.GetOrdinal("duration_ms")),
|
||||
ErrorMessage = GetNullableString(reader, reader.GetOrdinal("error_message")),
|
||||
Metadata = reader.GetString(reader.GetOrdinal("metadata")),
|
||||
CreatedAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("created_at")),
|
||||
StartedAt = GetNullableDateTimeOffset(reader, reader.GetOrdinal("started_at")),
|
||||
CompletedAt = GetNullableDateTimeOffset(reader, reader.GetOrdinal("completed_at")),
|
||||
CreatedBy = GetNullableString(reader, reader.GetOrdinal("created_by"))
|
||||
};
|
||||
|
||||
private static string StatusToString(EvaluationStatus status) => status switch
|
||||
{
|
||||
EvaluationStatus.Pending => "pending",
|
||||
EvaluationStatus.Running => "running",
|
||||
EvaluationStatus.Completed => "completed",
|
||||
EvaluationStatus.Failed => "failed",
|
||||
_ => throw new ArgumentException($"Unknown status: {status}", nameof(status))
|
||||
};
|
||||
|
||||
private static EvaluationStatus ParseStatus(string status) => status switch
|
||||
{
|
||||
"pending" => EvaluationStatus.Pending,
|
||||
"running" => EvaluationStatus.Running,
|
||||
"completed" => EvaluationStatus.Completed,
|
||||
"failed" => EvaluationStatus.Failed,
|
||||
_ => throw new ArgumentException($"Unknown status: {status}", nameof(status))
|
||||
};
|
||||
|
||||
private static string ResultToString(EvaluationResult result) => result switch
|
||||
{
|
||||
EvaluationResult.Pass => "pass",
|
||||
@@ -396,28 +290,4 @@ public sealed class EvaluationRunRepository : RepositoryBase<PolicyDataSource>,
|
||||
EvaluationResult.Error => "error",
|
||||
_ => throw new ArgumentException($"Unknown result: {result}", nameof(result))
|
||||
};
|
||||
|
||||
private static EvaluationResult ParseResult(string result) => result switch
|
||||
{
|
||||
"pass" => EvaluationResult.Pass,
|
||||
"fail" => EvaluationResult.Fail,
|
||||
"warn" => EvaluationResult.Warn,
|
||||
"error" => EvaluationResult.Error,
|
||||
_ => throw new ArgumentException($"Unknown result: {result}", nameof(result))
|
||||
};
|
||||
|
||||
private static int? GetNullableInt(NpgsqlDataReader reader, int ordinal)
|
||||
{
|
||||
return reader.IsDBNull(ordinal) ? null : reader.GetInt32(ordinal);
|
||||
}
|
||||
|
||||
private static new decimal? GetNullableDecimal(NpgsqlDataReader reader, int ordinal)
|
||||
{
|
||||
return reader.IsDBNull(ordinal) ? null : reader.GetDecimal(ordinal);
|
||||
}
|
||||
|
||||
private static EvaluationResult? GetNullableResult(NpgsqlDataReader reader, int ordinal)
|
||||
{
|
||||
return reader.IsDBNull(ordinal) ? null : ParseResult(reader.GetString(ordinal));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Determinism;
|
||||
@@ -8,6 +9,8 @@ namespace StellaOps.Policy.Persistence.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL repository for explanation operations.
|
||||
/// Uses EF Core for reads and deletes; raw SQL preserved for inserts that require
|
||||
/// deterministic ID generation via IGuidProvider.
|
||||
/// </summary>
|
||||
public sealed class ExplanationRepository : RepositoryBase<PolicyDataSource>, IExplanationRepository
|
||||
{
|
||||
@@ -24,54 +27,44 @@ public sealed class ExplanationRepository : RepositoryBase<PolicyDataSource>, IE
|
||||
|
||||
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;
|
||||
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
|
||||
|
||||
return await dbContext.Explanations
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(e => e.Id == id, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
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;
|
||||
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
|
||||
|
||||
return await dbContext.Explanations
|
||||
.AsNoTracking()
|
||||
.Where(e => e.EvaluationRunId == evaluationRunId)
|
||||
.OrderBy(e => e.CreatedAt)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
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;
|
||||
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
|
||||
|
||||
return await dbContext.Explanations
|
||||
.AsNoTracking()
|
||||
.Where(e => e.EvaluationRunId == evaluationRunId && e.Result == result)
|
||||
.OrderByDescending(e => e.Severity).ThenBy(e => e.CreatedAt)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<ExplanationEntity> CreateAsync(ExplanationEntity explanation, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Keep raw SQL: deterministic ID generation via IGuidProvider requires mutation before insert
|
||||
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)
|
||||
@@ -99,6 +92,7 @@ public sealed class ExplanationRepository : RepositoryBase<PolicyDataSource>, IE
|
||||
|
||||
public async Task<int> CreateBatchAsync(IEnumerable<ExplanationEntity> explanations, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Keep raw SQL: deterministic ID generation via IGuidProvider requires mutation before insert
|
||||
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)
|
||||
@@ -127,11 +121,14 @@ public sealed class ExplanationRepository : RepositoryBase<PolicyDataSource>, IE
|
||||
|
||||
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);
|
||||
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
|
||||
|
||||
var rows = await dbContext.Explanations
|
||||
.Where(e => e.EvaluationRunId == evaluationRunId)
|
||||
.ExecuteDeleteAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return rows > 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
// Sprint: SPRINT_20260118_017_Policy_gate_attestation_verification
|
||||
// Task: TASK-017-006 - Gate Bypass Audit Persistence
|
||||
// Description: PostgreSQL implementation of gate bypass audit repository
|
||||
// Converted to EF Core for reads; raw SQL preserved for compliance-critical operations.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
@@ -15,6 +17,8 @@ namespace StellaOps.Policy.Persistence.Postgres.Repositories;
|
||||
/// <summary>
|
||||
/// PostgreSQL repository for gate bypass audit entries.
|
||||
/// Records are immutable (append-only) for compliance requirements.
|
||||
/// Uses EF Core for reads; raw SQL preserved for insert with RETURNING id
|
||||
/// and aggregate COUNT queries.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This table uses insert-only semantics. UPDATE and DELETE operations are not exposed
|
||||
@@ -30,55 +34,14 @@ public sealed class GateBypassAuditRepository : RepositoryBase<PolicyDataSource>
|
||||
GateBypassAuditEntity entry,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
INSERT INTO policy.gate_bypass_audit (
|
||||
id, tenant_id, timestamp, decision_id, image_digest, repository, tag,
|
||||
baseline_ref, original_decision, final_decision, bypassed_gates,
|
||||
actor, actor_subject, actor_email, actor_ip_address, justification,
|
||||
policy_id, source, ci_context, attestation_digest, rekor_uuid,
|
||||
bypass_type, expires_at, metadata, created_at
|
||||
) VALUES (
|
||||
@id, @tenant_id, @timestamp, @decision_id, @image_digest, @repository, @tag,
|
||||
@baseline_ref, @original_decision, @final_decision, @bypassed_gates::jsonb,
|
||||
@actor, @actor_subject, @actor_email, @actor_ip_address, @justification,
|
||||
@policy_id, @source, @ci_context, @attestation_digest, @rekor_uuid,
|
||||
@bypass_type, @expires_at, @metadata::jsonb, @created_at
|
||||
)
|
||||
RETURNING id
|
||||
""";
|
||||
|
||||
await using var connection = await DataSource.OpenConnectionAsync(entry.TenantId, "writer", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
|
||||
|
||||
AddParameter(command, "id", entry.Id);
|
||||
AddParameter(command, "tenant_id", entry.TenantId);
|
||||
AddParameter(command, "timestamp", entry.Timestamp);
|
||||
AddParameter(command, "decision_id", entry.DecisionId);
|
||||
AddParameter(command, "image_digest", entry.ImageDigest);
|
||||
AddParameter(command, "repository", entry.Repository);
|
||||
AddParameter(command, "tag", entry.Tag);
|
||||
AddParameter(command, "baseline_ref", entry.BaselineRef);
|
||||
AddParameter(command, "original_decision", entry.OriginalDecision);
|
||||
AddParameter(command, "final_decision", entry.FinalDecision);
|
||||
AddJsonbParameter(command, "bypassed_gates", entry.BypassedGates);
|
||||
AddParameter(command, "actor", entry.Actor);
|
||||
AddParameter(command, "actor_subject", entry.ActorSubject);
|
||||
AddParameter(command, "actor_email", entry.ActorEmail);
|
||||
AddParameter(command, "actor_ip_address", entry.ActorIpAddress);
|
||||
AddParameter(command, "justification", entry.Justification);
|
||||
AddParameter(command, "policy_id", entry.PolicyId);
|
||||
AddParameter(command, "source", entry.Source);
|
||||
AddParameter(command, "ci_context", entry.CiContext);
|
||||
AddParameter(command, "attestation_digest", entry.AttestationDigest);
|
||||
AddParameter(command, "rekor_uuid", entry.RekorUuid);
|
||||
AddParameter(command, "bypass_type", entry.BypassType);
|
||||
AddParameter(command, "expires_at", entry.ExpiresAt);
|
||||
AddJsonbParameter(command, "metadata", entry.Metadata);
|
||||
AddParameter(command, "created_at", entry.CreatedAt);
|
||||
dbContext.GateBypassAudit.Add(entry);
|
||||
await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var result = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
|
||||
return (Guid)result!;
|
||||
return entry.Id;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -87,23 +50,14 @@ public sealed class GateBypassAuditRepository : RepositoryBase<PolicyDataSource>
|
||||
Guid id,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, tenant_id, timestamp, decision_id, image_digest, repository, tag,
|
||||
baseline_ref, original_decision, final_decision, bypassed_gates,
|
||||
actor, actor_subject, actor_email, actor_ip_address, justification,
|
||||
policy_id, source, ci_context, attestation_digest, rekor_uuid,
|
||||
bypass_type, expires_at, metadata, created_at
|
||||
FROM policy.gate_bypass_audit
|
||||
WHERE tenant_id = @tenant_id AND id = @id
|
||||
""";
|
||||
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
|
||||
|
||||
var results = await QueryAsync(tenantId, sql, cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "id", id);
|
||||
}, MapEntity, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return results.Count > 0 ? results[0] : null;
|
||||
return await dbContext.GateBypassAudit
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(e => e.TenantId == tenantId && e.Id == id, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -112,22 +66,16 @@ public sealed class GateBypassAuditRepository : RepositoryBase<PolicyDataSource>
|
||||
string decisionId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, tenant_id, timestamp, decision_id, image_digest, repository, tag,
|
||||
baseline_ref, original_decision, final_decision, bypassed_gates,
|
||||
actor, actor_subject, actor_email, actor_ip_address, justification,
|
||||
policy_id, source, ci_context, attestation_digest, rekor_uuid,
|
||||
bypass_type, expires_at, metadata, created_at
|
||||
FROM policy.gate_bypass_audit
|
||||
WHERE tenant_id = @tenant_id AND decision_id = @decision_id
|
||||
ORDER BY timestamp DESC
|
||||
""";
|
||||
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
|
||||
|
||||
return await QueryAsync(tenantId, sql, cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "decision_id", decisionId);
|
||||
}, MapEntity, cancellationToken).ConfigureAwait(false);
|
||||
return await dbContext.GateBypassAudit
|
||||
.AsNoTracking()
|
||||
.Where(e => e.TenantId == tenantId && e.DecisionId == decisionId)
|
||||
.OrderByDescending(e => e.Timestamp)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -137,24 +85,17 @@ public sealed class GateBypassAuditRepository : RepositoryBase<PolicyDataSource>
|
||||
int limit = 100,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, tenant_id, timestamp, decision_id, image_digest, repository, tag,
|
||||
baseline_ref, original_decision, final_decision, bypassed_gates,
|
||||
actor, actor_subject, actor_email, actor_ip_address, justification,
|
||||
policy_id, source, ci_context, attestation_digest, rekor_uuid,
|
||||
bypass_type, expires_at, metadata, created_at
|
||||
FROM policy.gate_bypass_audit
|
||||
WHERE tenant_id = @tenant_id AND actor = @actor
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT @limit
|
||||
""";
|
||||
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
|
||||
|
||||
return await QueryAsync(tenantId, sql, cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "actor", actor);
|
||||
AddParameter(cmd, "limit", limit);
|
||||
}, MapEntity, cancellationToken).ConfigureAwait(false);
|
||||
return await dbContext.GateBypassAudit
|
||||
.AsNoTracking()
|
||||
.Where(e => e.TenantId == tenantId && e.Actor == actor)
|
||||
.OrderByDescending(e => e.Timestamp)
|
||||
.Take(limit)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -164,24 +105,17 @@ public sealed class GateBypassAuditRepository : RepositoryBase<PolicyDataSource>
|
||||
int limit = 100,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, tenant_id, timestamp, decision_id, image_digest, repository, tag,
|
||||
baseline_ref, original_decision, final_decision, bypassed_gates,
|
||||
actor, actor_subject, actor_email, actor_ip_address, justification,
|
||||
policy_id, source, ci_context, attestation_digest, rekor_uuid,
|
||||
bypass_type, expires_at, metadata, created_at
|
||||
FROM policy.gate_bypass_audit
|
||||
WHERE tenant_id = @tenant_id AND image_digest = @image_digest
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT @limit
|
||||
""";
|
||||
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
|
||||
|
||||
return await QueryAsync(tenantId, sql, cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "image_digest", imageDigest);
|
||||
AddParameter(cmd, "limit", limit);
|
||||
}, MapEntity, cancellationToken).ConfigureAwait(false);
|
||||
return await dbContext.GateBypassAudit
|
||||
.AsNoTracking()
|
||||
.Where(e => e.TenantId == tenantId && e.ImageDigest == imageDigest)
|
||||
.OrderByDescending(e => e.Timestamp)
|
||||
.Take(limit)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -191,24 +125,18 @@ public sealed class GateBypassAuditRepository : RepositoryBase<PolicyDataSource>
|
||||
int offset = 0,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, tenant_id, timestamp, decision_id, image_digest, repository, tag,
|
||||
baseline_ref, original_decision, final_decision, bypassed_gates,
|
||||
actor, actor_subject, actor_email, actor_ip_address, justification,
|
||||
policy_id, source, ci_context, attestation_digest, rekor_uuid,
|
||||
bypass_type, expires_at, metadata, created_at
|
||||
FROM policy.gate_bypass_audit
|
||||
WHERE tenant_id = @tenant_id
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT @limit OFFSET @offset
|
||||
""";
|
||||
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
|
||||
|
||||
return await QueryAsync(tenantId, sql, cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "limit", limit);
|
||||
AddParameter(cmd, "offset", offset);
|
||||
}, MapEntity, cancellationToken).ConfigureAwait(false);
|
||||
return await dbContext.GateBypassAudit
|
||||
.AsNoTracking()
|
||||
.Where(e => e.TenantId == tenantId)
|
||||
.OrderByDescending(e => e.Timestamp)
|
||||
.Skip(offset)
|
||||
.Take(limit)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -219,25 +147,17 @@ public sealed class GateBypassAuditRepository : RepositoryBase<PolicyDataSource>
|
||||
int limit = 1000,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, tenant_id, timestamp, decision_id, image_digest, repository, tag,
|
||||
baseline_ref, original_decision, final_decision, bypassed_gates,
|
||||
actor, actor_subject, actor_email, actor_ip_address, justification,
|
||||
policy_id, source, ci_context, attestation_digest, rekor_uuid,
|
||||
bypass_type, expires_at, metadata, created_at
|
||||
FROM policy.gate_bypass_audit
|
||||
WHERE tenant_id = @tenant_id AND timestamp >= @from AND timestamp < @to
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT @limit
|
||||
""";
|
||||
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
|
||||
|
||||
return await QueryAsync(tenantId, sql, cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "from", from);
|
||||
AddParameter(cmd, "to", to);
|
||||
AddParameter(cmd, "limit", limit);
|
||||
}, MapEntity, cancellationToken).ConfigureAwait(false);
|
||||
return await dbContext.GateBypassAudit
|
||||
.AsNoTracking()
|
||||
.Where(e => e.TenantId == tenantId && e.Timestamp >= from && e.Timestamp < to)
|
||||
.OrderByDescending(e => e.Timestamp)
|
||||
.Take(limit)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -247,22 +167,13 @@ public sealed class GateBypassAuditRepository : RepositoryBase<PolicyDataSource>
|
||||
DateTimeOffset since,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT COUNT(*)
|
||||
FROM policy.gate_bypass_audit
|
||||
WHERE tenant_id = @tenant_id AND actor = @actor AND timestamp >= @since
|
||||
""";
|
||||
|
||||
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
|
||||
|
||||
AddParameter(command, "tenant_id", tenantId);
|
||||
AddParameter(command, "actor", actor);
|
||||
AddParameter(command, "since", since);
|
||||
|
||||
var result = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
|
||||
return Convert.ToInt32(result);
|
||||
return await dbContext.GateBypassAudit
|
||||
.CountAsync(e => e.TenantId == tenantId && e.Actor == actor && e.Timestamp >= since, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -273,51 +184,15 @@ public sealed class GateBypassAuditRepository : RepositoryBase<PolicyDataSource>
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Export in chronological order for compliance reporting
|
||||
const string sql = """
|
||||
SELECT id, tenant_id, timestamp, decision_id, image_digest, repository, tag,
|
||||
baseline_ref, original_decision, final_decision, bypassed_gates,
|
||||
actor, actor_subject, actor_email, actor_ip_address, justification,
|
||||
policy_id, source, ci_context, attestation_digest, rekor_uuid,
|
||||
bypass_type, expires_at, metadata, created_at
|
||||
FROM policy.gate_bypass_audit
|
||||
WHERE tenant_id = @tenant_id AND timestamp >= @from AND timestamp < @to
|
||||
ORDER BY timestamp ASC
|
||||
""";
|
||||
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
|
||||
|
||||
return await QueryAsync(tenantId, sql, cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "from", from);
|
||||
AddParameter(cmd, "to", to);
|
||||
}, MapEntity, cancellationToken).ConfigureAwait(false);
|
||||
return await dbContext.GateBypassAudit
|
||||
.AsNoTracking()
|
||||
.Where(e => e.TenantId == tenantId && e.Timestamp >= from && e.Timestamp < to)
|
||||
.OrderBy(e => e.Timestamp)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static GateBypassAuditEntity MapEntity(NpgsqlDataReader reader) => new()
|
||||
{
|
||||
Id = reader.GetGuid(0),
|
||||
TenantId = reader.GetString(1),
|
||||
Timestamp = reader.GetFieldValue<DateTimeOffset>(2),
|
||||
DecisionId = reader.GetString(3),
|
||||
ImageDigest = reader.GetString(4),
|
||||
Repository = GetNullableString(reader, 5),
|
||||
Tag = GetNullableString(reader, 6),
|
||||
BaselineRef = GetNullableString(reader, 7),
|
||||
OriginalDecision = reader.GetString(8),
|
||||
FinalDecision = reader.GetString(9),
|
||||
BypassedGates = reader.GetString(10),
|
||||
Actor = reader.GetString(11),
|
||||
ActorSubject = GetNullableString(reader, 12),
|
||||
ActorEmail = GetNullableString(reader, 13),
|
||||
ActorIpAddress = GetNullableString(reader, 14),
|
||||
Justification = reader.GetString(15),
|
||||
PolicyId = GetNullableString(reader, 16),
|
||||
Source = GetNullableString(reader, 17),
|
||||
CiContext = GetNullableString(reader, 18),
|
||||
AttestationDigest = GetNullableString(reader, 19),
|
||||
RekorUuid = GetNullableString(reader, 20),
|
||||
BypassType = reader.GetString(21),
|
||||
ExpiresAt = GetNullableDateTimeOffset(reader, 22),
|
||||
Metadata = GetNullableString(reader, 23),
|
||||
CreatedAt = reader.GetFieldValue<DateTimeOffset>(24)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
@@ -7,6 +8,8 @@ namespace StellaOps.Policy.Persistence.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL repository for ledger export operations.
|
||||
/// Uses EF Core for reads and inserts; raw SQL preserved for
|
||||
/// conditional CASE updates and system-connection queries.
|
||||
/// </summary>
|
||||
public sealed class LedgerExportRepository : RepositoryBase<PolicyDataSource>, ILedgerExportRepository
|
||||
{
|
||||
@@ -21,61 +24,39 @@ public sealed class LedgerExportRepository : RepositoryBase<PolicyDataSource>, I
|
||||
/// <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);
|
||||
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
|
||||
|
||||
AddExportParameters(command, export);
|
||||
dbContext.LedgerExports.Add(export);
|
||||
await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return MapExport(reader);
|
||||
return export;
|
||||
}
|
||||
|
||||
/// <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";
|
||||
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
|
||||
|
||||
return await QuerySingleOrDefaultAsync(
|
||||
tenantId,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "id", id);
|
||||
},
|
||||
MapExport,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
return await dbContext.LedgerExports
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(e => e.TenantId == tenantId && e.Id == id, 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 dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
return MapExport(reader);
|
||||
}
|
||||
|
||||
return null;
|
||||
return await dbContext.LedgerExports
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(e => e.ContentDigest == contentDigest, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -86,30 +67,23 @@ public sealed class LedgerExportRepository : RepositoryBase<PolicyDataSource>, I
|
||||
int offset = 0,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var sql = "SELECT * FROM policy.ledger_exports WHERE tenant_id = @tenant_id";
|
||||
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
|
||||
|
||||
var query = dbContext.LedgerExports.AsNoTracking().Where(e => e.TenantId == tenantId);
|
||||
|
||||
if (!string.IsNullOrEmpty(status))
|
||||
{
|
||||
sql += " AND status = @status";
|
||||
query = query.Where(e => e.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);
|
||||
return await query
|
||||
.OrderByDescending(e => e.CreatedAt)
|
||||
.Skip(offset)
|
||||
.Take(limit)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -120,6 +94,7 @@ public sealed class LedgerExportRepository : RepositoryBase<PolicyDataSource>, I
|
||||
string? errorMessage = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Keep raw SQL: CASE conditional update for start_time cannot be expressed in EF Core
|
||||
const string sql = """
|
||||
UPDATE policy.ledger_exports
|
||||
SET status = @status, error_message = @error_message,
|
||||
@@ -152,6 +127,7 @@ public sealed class LedgerExportRepository : RepositoryBase<PolicyDataSource>, I
|
||||
string? storagePath,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Keep raw SQL: multi-column update with NOW()
|
||||
const string sql = """
|
||||
UPDATE policy.ledger_exports
|
||||
SET status = 'completed',
|
||||
@@ -186,68 +162,22 @@ public sealed class LedgerExportRepository : RepositoryBase<PolicyDataSource>, I
|
||||
string? exportType = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var sql = """
|
||||
SELECT * FROM policy.ledger_exports
|
||||
WHERE tenant_id = @tenant_id AND status = 'completed'
|
||||
""";
|
||||
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
|
||||
|
||||
var query = dbContext.LedgerExports
|
||||
.AsNoTracking()
|
||||
.Where(e => e.TenantId == tenantId && e.Status == "completed");
|
||||
|
||||
if (!string.IsNullOrEmpty(exportType))
|
||||
{
|
||||
sql += " AND export_type = @export_type";
|
||||
query = query.Where(e => e.ExportType == exportType);
|
||||
}
|
||||
|
||||
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);
|
||||
return await query
|
||||
.OrderByDescending(e => e.EndTime)
|
||||
.FirstOrDefaultAsync(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"))
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
@@ -7,6 +8,7 @@ namespace StellaOps.Policy.Persistence.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL repository for policy pack operations.
|
||||
/// Uses EF Core for standard CRUD; raw SQL preserved where needed.
|
||||
/// </summary>
|
||||
public sealed class PackRepository : RepositoryBase<PolicyDataSource>, IPackRepository
|
||||
{
|
||||
@@ -21,71 +23,40 @@ public sealed class PackRepository : RepositoryBase<PolicyDataSource>, IPackRepo
|
||||
/// <inheritdoc />
|
||||
public async Task<PackEntity> CreateAsync(PackEntity pack, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
INSERT INTO policy.packs (
|
||||
id, tenant_id, name, display_name, description, active_version,
|
||||
is_builtin, is_deprecated, metadata, created_by
|
||||
)
|
||||
VALUES (
|
||||
@id, @tenant_id, @name, @display_name, @description, @active_version,
|
||||
@is_builtin, @is_deprecated, @metadata::jsonb, @created_by
|
||||
)
|
||||
RETURNING *
|
||||
""";
|
||||
|
||||
await using var connection = await DataSource.OpenConnectionAsync(pack.TenantId, "writer", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
|
||||
|
||||
AddParameter(command, "id", pack.Id);
|
||||
AddParameter(command, "tenant_id", pack.TenantId);
|
||||
AddParameter(command, "name", pack.Name);
|
||||
AddParameter(command, "display_name", pack.DisplayName);
|
||||
AddParameter(command, "description", pack.Description);
|
||||
AddParameter(command, "active_version", pack.ActiveVersion);
|
||||
AddParameter(command, "is_builtin", pack.IsBuiltin);
|
||||
AddParameter(command, "is_deprecated", pack.IsDeprecated);
|
||||
AddJsonbParameter(command, "metadata", pack.Metadata);
|
||||
AddParameter(command, "created_by", pack.CreatedBy);
|
||||
dbContext.Packs.Add(pack);
|
||||
await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return MapPack(reader);
|
||||
return pack;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<PackEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = "SELECT * FROM policy.packs WHERE tenant_id = @tenant_id AND id = @id";
|
||||
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
|
||||
|
||||
return await QuerySingleOrDefaultAsync(
|
||||
tenantId,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "id", id);
|
||||
},
|
||||
MapPack,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
return await dbContext.Packs
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(p => p.TenantId == tenantId && p.Id == id, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<PackEntity?> GetByNameAsync(string tenantId, string name, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = "SELECT * FROM policy.packs WHERE tenant_id = @tenant_id AND name = @name";
|
||||
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
|
||||
|
||||
return await QuerySingleOrDefaultAsync(
|
||||
tenantId,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "name", name);
|
||||
},
|
||||
MapPack,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
return await dbContext.Packs
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(p => p.TenantId == tenantId && p.Name == name, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -97,31 +68,28 @@ public sealed class PackRepository : RepositoryBase<PolicyDataSource>, IPackRepo
|
||||
int offset = 0,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var sql = "SELECT * FROM policy.packs WHERE tenant_id = @tenant_id";
|
||||
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
|
||||
|
||||
var query = dbContext.Packs.AsNoTracking().Where(p => p.TenantId == tenantId);
|
||||
|
||||
if (includeBuiltin == false)
|
||||
{
|
||||
sql += " AND is_builtin = FALSE";
|
||||
query = query.Where(p => !p.IsBuiltin);
|
||||
}
|
||||
|
||||
if (includeDeprecated == false)
|
||||
{
|
||||
sql += " AND is_deprecated = FALSE";
|
||||
query = query.Where(p => !p.IsDeprecated);
|
||||
}
|
||||
|
||||
sql += " ORDER BY name, id LIMIT @limit OFFSET @offset";
|
||||
|
||||
return await QueryAsync(
|
||||
tenantId,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "limit", limit);
|
||||
AddParameter(cmd, "offset", offset);
|
||||
},
|
||||
MapPack,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
return await query
|
||||
.OrderBy(p => p.Name).ThenBy(p => p.Id)
|
||||
.Skip(offset)
|
||||
.Take(limit)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -129,23 +97,23 @@ public sealed class PackRepository : RepositoryBase<PolicyDataSource>, IPackRepo
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT * FROM policy.packs
|
||||
WHERE tenant_id = @tenant_id AND is_builtin = TRUE AND is_deprecated = FALSE
|
||||
ORDER BY name, id
|
||||
""";
|
||||
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
|
||||
|
||||
return await QueryAsync(
|
||||
tenantId,
|
||||
sql,
|
||||
cmd => AddParameter(cmd, "tenant_id", tenantId),
|
||||
MapPack,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
return await dbContext.Packs
|
||||
.AsNoTracking()
|
||||
.Where(p => p.TenantId == tenantId && p.IsBuiltin && !p.IsDeprecated)
|
||||
.OrderBy(p => p.Name).ThenBy(p => p.Id)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> UpdateAsync(PackEntity pack, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Keep raw SQL: the update has a WHERE clause filtering on is_builtin = FALSE
|
||||
// which is a conditional update that cannot be cleanly expressed via EF Core tracked update.
|
||||
const string sql = """
|
||||
UPDATE policy.packs
|
||||
SET name = @name,
|
||||
@@ -181,6 +149,7 @@ public sealed class PackRepository : RepositoryBase<PolicyDataSource>, IPackRepo
|
||||
int version,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Keep raw SQL: the EXISTS subquery cannot be expressed in a single EF Core statement.
|
||||
const string sql = """
|
||||
UPDATE policy.packs
|
||||
SET active_version = @version
|
||||
@@ -208,21 +177,14 @@ public sealed class PackRepository : RepositoryBase<PolicyDataSource>, IPackRepo
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> DeprecateAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
UPDATE policy.packs
|
||||
SET is_deprecated = TRUE
|
||||
WHERE tenant_id = @tenant_id AND id = @id
|
||||
""";
|
||||
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
|
||||
|
||||
var rows = await ExecuteAsync(
|
||||
tenantId,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "id", id);
|
||||
},
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
var rows = await dbContext.Packs
|
||||
.Where(p => p.TenantId == tenantId && p.Id == id)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(p => p.IsDeprecated, true), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return rows > 0;
|
||||
}
|
||||
@@ -230,6 +192,7 @@ public sealed class PackRepository : RepositoryBase<PolicyDataSource>, IPackRepo
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> DeleteAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Keep raw SQL: conditional delete on is_builtin = FALSE
|
||||
const string sql = "DELETE FROM policy.packs WHERE tenant_id = @tenant_id AND id = @id AND is_builtin = FALSE";
|
||||
|
||||
var rows = await ExecuteAsync(
|
||||
@@ -244,25 +207,4 @@ public sealed class PackRepository : RepositoryBase<PolicyDataSource>, IPackRepo
|
||||
|
||||
return rows > 0;
|
||||
}
|
||||
|
||||
private static PackEntity MapPack(NpgsqlDataReader reader) => new()
|
||||
{
|
||||
Id = reader.GetGuid(reader.GetOrdinal("id")),
|
||||
TenantId = reader.GetString(reader.GetOrdinal("tenant_id")),
|
||||
Name = reader.GetString(reader.GetOrdinal("name")),
|
||||
DisplayName = GetNullableString(reader, reader.GetOrdinal("display_name")),
|
||||
Description = GetNullableString(reader, reader.GetOrdinal("description")),
|
||||
ActiveVersion = GetNullableInt(reader, reader.GetOrdinal("active_version")),
|
||||
IsBuiltin = reader.GetBoolean(reader.GetOrdinal("is_builtin")),
|
||||
IsDeprecated = reader.GetBoolean(reader.GetOrdinal("is_deprecated")),
|
||||
Metadata = reader.GetString(reader.GetOrdinal("metadata")),
|
||||
CreatedAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("created_at")),
|
||||
UpdatedAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("updated_at")),
|
||||
CreatedBy = GetNullableString(reader, reader.GetOrdinal("created_by"))
|
||||
};
|
||||
|
||||
private static int? GetNullableInt(NpgsqlDataReader reader, int ordinal)
|
||||
{
|
||||
return reader.IsDBNull(ordinal) ? null : reader.GetInt32(ordinal);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
@@ -8,6 +9,7 @@ namespace StellaOps.Policy.Persistence.Postgres.Repositories;
|
||||
/// <summary>
|
||||
/// PostgreSQL repository for policy pack version operations.
|
||||
/// Note: pack_versions table doesn't have tenant_id; tenant context comes from parent pack.
|
||||
/// Uses EF Core for standard CRUD; raw SQL preserved for COALESCE aggregate and conditional updates.
|
||||
/// </summary>
|
||||
public sealed class PackVersionRepository : RepositoryBase<PolicyDataSource>, IPackVersionRepository
|
||||
{
|
||||
@@ -22,56 +24,27 @@ public sealed class PackVersionRepository : RepositoryBase<PolicyDataSource>, IP
|
||||
/// <inheritdoc />
|
||||
public async Task<PackVersionEntity> CreateAsync(PackVersionEntity version, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
INSERT INTO policy.pack_versions (
|
||||
id, pack_id, version, description, rules_hash,
|
||||
is_published, published_at, published_by, created_by
|
||||
)
|
||||
VALUES (
|
||||
@id, @pack_id, @version, @description, @rules_hash,
|
||||
@is_published, @published_at, @published_by, @created_by
|
||||
)
|
||||
RETURNING *
|
||||
""";
|
||||
|
||||
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
|
||||
|
||||
AddParameter(command, "id", version.Id);
|
||||
AddParameter(command, "pack_id", version.PackId);
|
||||
AddParameter(command, "version", version.Version);
|
||||
AddParameter(command, "description", version.Description);
|
||||
AddParameter(command, "rules_hash", version.RulesHash);
|
||||
AddParameter(command, "is_published", version.IsPublished);
|
||||
AddParameter(command, "published_at", version.PublishedAt);
|
||||
AddParameter(command, "published_by", version.PublishedBy);
|
||||
AddParameter(command, "created_by", version.CreatedBy);
|
||||
dbContext.PackVersions.Add(version);
|
||||
await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return MapPackVersion(reader);
|
||||
return version;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<PackVersionEntity?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = "SELECT * FROM policy.pack_versions WHERE id = @id";
|
||||
|
||||
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
|
||||
|
||||
AddParameter(command, "id", id);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return MapPackVersion(reader);
|
||||
return await dbContext.PackVersions
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(pv => pv.Id == id, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -80,47 +53,29 @@ public sealed class PackVersionRepository : RepositoryBase<PolicyDataSource>, IP
|
||||
int version,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = "SELECT * FROM policy.pack_versions WHERE pack_id = @pack_id AND version = @version";
|
||||
|
||||
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
|
||||
|
||||
AddParameter(command, "pack_id", packId);
|
||||
AddParameter(command, "version", version);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return MapPackVersion(reader);
|
||||
return await dbContext.PackVersions
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(pv => pv.PackId == packId && pv.Version == version, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<PackVersionEntity?> GetLatestAsync(Guid packId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT * FROM policy.pack_versions
|
||||
WHERE pack_id = @pack_id
|
||||
ORDER BY version DESC
|
||||
LIMIT 1
|
||||
""";
|
||||
|
||||
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
|
||||
|
||||
AddParameter(command, "pack_id", packId);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return MapPackVersion(reader);
|
||||
return await dbContext.PackVersions
|
||||
.AsNoTracking()
|
||||
.Where(pv => pv.PackId == packId)
|
||||
.OrderByDescending(pv => pv.Version)
|
||||
.FirstOrDefaultAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -129,35 +84,27 @@ public sealed class PackVersionRepository : RepositoryBase<PolicyDataSource>, IP
|
||||
bool? publishedOnly = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var sql = "SELECT * FROM policy.pack_versions WHERE pack_id = @pack_id";
|
||||
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
|
||||
|
||||
var query = dbContext.PackVersions.AsNoTracking().Where(pv => pv.PackId == packId);
|
||||
|
||||
if (publishedOnly == true)
|
||||
{
|
||||
sql += " AND is_published = TRUE";
|
||||
query = query.Where(pv => pv.IsPublished);
|
||||
}
|
||||
|
||||
sql += " ORDER BY version DESC";
|
||||
|
||||
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken)
|
||||
return await query
|
||||
.OrderByDescending(pv => pv.Version)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
|
||||
AddParameter(command, "pack_id", packId);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
var results = new List<PackVersionEntity>();
|
||||
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
results.Add(MapPackVersion(reader));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> PublishAsync(Guid id, string? publishedBy, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Keep raw SQL: conditional update WHERE is_published = FALSE with NOW()
|
||||
const string sql = """
|
||||
UPDATE policy.pack_versions
|
||||
SET is_published = TRUE,
|
||||
@@ -180,6 +127,7 @@ public sealed class PackVersionRepository : RepositoryBase<PolicyDataSource>, IP
|
||||
/// <inheritdoc />
|
||||
public async Task<int> GetNextVersionAsync(Guid packId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Keep raw SQL: COALESCE(MAX(version), 0) + 1 cannot be cleanly expressed in EF Core LINQ
|
||||
const string sql = """
|
||||
SELECT COALESCE(MAX(version), 0) + 1
|
||||
FROM policy.pack_versions
|
||||
@@ -195,18 +143,4 @@ public sealed class PackVersionRepository : RepositoryBase<PolicyDataSource>, IP
|
||||
var result = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
|
||||
return Convert.ToInt32(result);
|
||||
}
|
||||
|
||||
private static PackVersionEntity MapPackVersion(NpgsqlDataReader reader) => new()
|
||||
{
|
||||
Id = reader.GetGuid(reader.GetOrdinal("id")),
|
||||
PackId = reader.GetGuid(reader.GetOrdinal("pack_id")),
|
||||
Version = reader.GetInt32(reader.GetOrdinal("version")),
|
||||
Description = GetNullableString(reader, reader.GetOrdinal("description")),
|
||||
RulesHash = reader.GetString(reader.GetOrdinal("rules_hash")),
|
||||
IsPublished = reader.GetBoolean(reader.GetOrdinal("is_published")),
|
||||
PublishedAt = GetNullableDateTimeOffset(reader, reader.GetOrdinal("published_at")),
|
||||
PublishedBy = GetNullableString(reader, reader.GetOrdinal("published_by")),
|
||||
CreatedAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("created_at")),
|
||||
CreatedBy = GetNullableString(reader, reader.GetOrdinal("created_by"))
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
@@ -7,6 +8,7 @@ namespace StellaOps.Policy.Persistence.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL repository for policy audit operations.
|
||||
/// Uses EF Core for reads and inserts; raw SQL preserved for system-connection delete.
|
||||
/// </summary>
|
||||
public sealed class PolicyAuditRepository : RepositoryBase<PolicyDataSource>, IPolicyAuditRepository
|
||||
{
|
||||
@@ -15,91 +17,71 @@ public sealed class PolicyAuditRepository : RepositoryBase<PolicyDataSource>, IP
|
||||
|
||||
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);
|
||||
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
|
||||
|
||||
var result = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
|
||||
return (long)result!;
|
||||
dbContext.Audit.Add(audit);
|
||||
await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return audit.Id;
|
||||
}
|
||||
|
||||
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);
|
||||
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
|
||||
|
||||
return await dbContext.Audit
|
||||
.AsNoTracking()
|
||||
.Where(a => a.TenantId == tenantId)
|
||||
.OrderByDescending(a => a.CreatedAt)
|
||||
.Skip(offset)
|
||||
.Take(limit)
|
||||
.ToListAsync(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";
|
||||
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
|
||||
|
||||
return await QueryAsync(tenantId, sql, cmd =>
|
||||
var query = dbContext.Audit
|
||||
.AsNoTracking()
|
||||
.Where(a => a.TenantId == tenantId && a.ResourceType == resourceType);
|
||||
|
||||
if (resourceId != null)
|
||||
{
|
||||
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);
|
||||
query = query.Where(a => a.ResourceId == resourceId);
|
||||
}
|
||||
|
||||
return await query
|
||||
.OrderByDescending(a => a.CreatedAt)
|
||||
.Take(limit)
|
||||
.ToListAsync(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);
|
||||
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
|
||||
|
||||
return await dbContext.Audit
|
||||
.AsNoTracking()
|
||||
.Where(a => a.TenantId == tenantId && a.CorrelationId == correlationId)
|
||||
.OrderBy(a => a.CreatedAt)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<int> DeleteOldAsync(DateTimeOffset cutoff, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Keep raw SQL: system connection (no tenant) for cross-tenant cleanup
|
||||
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)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
@@ -7,6 +8,8 @@ namespace StellaOps.Policy.Persistence.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL repository for risk profile operations.
|
||||
/// Uses EF Core for reads and simple writes; raw SQL preserved for
|
||||
/// multi-step transactional operations (version creation + deactivation).
|
||||
/// </summary>
|
||||
public sealed class RiskProfileRepository : RepositoryBase<PolicyDataSource>, IRiskProfileRepository
|
||||
{
|
||||
@@ -21,45 +24,27 @@ public sealed class RiskProfileRepository : RepositoryBase<PolicyDataSource>, IR
|
||||
/// <inheritdoc />
|
||||
public async Task<RiskProfileEntity> CreateAsync(RiskProfileEntity profile, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
INSERT INTO policy.risk_profiles (
|
||||
id, tenant_id, name, display_name, description, version,
|
||||
is_active, thresholds, scoring_weights, exemptions, metadata, created_by
|
||||
)
|
||||
VALUES (
|
||||
@id, @tenant_id, @name, @display_name, @description, @version,
|
||||
@is_active, @thresholds::jsonb, @scoring_weights::jsonb, @exemptions::jsonb, @metadata::jsonb, @created_by
|
||||
)
|
||||
RETURNING *
|
||||
""";
|
||||
|
||||
await using var connection = await DataSource.OpenConnectionAsync(profile.TenantId, "writer", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
|
||||
|
||||
AddProfileParameters(command, profile);
|
||||
dbContext.RiskProfiles.Add(profile);
|
||||
await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return MapProfile(reader);
|
||||
return profile;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<RiskProfileEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = "SELECT * FROM policy.risk_profiles WHERE tenant_id = @tenant_id AND id = @id";
|
||||
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
|
||||
|
||||
return await QuerySingleOrDefaultAsync(
|
||||
tenantId,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "id", id);
|
||||
},
|
||||
MapProfile,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
return await dbContext.RiskProfiles
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(p => p.TenantId == tenantId && p.Id == id, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -68,23 +53,16 @@ public sealed class RiskProfileRepository : RepositoryBase<PolicyDataSource>, IR
|
||||
string name,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT * FROM policy.risk_profiles
|
||||
WHERE tenant_id = @tenant_id AND name = @name AND is_active = TRUE
|
||||
ORDER BY version DESC
|
||||
LIMIT 1
|
||||
""";
|
||||
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
|
||||
|
||||
return await QuerySingleOrDefaultAsync(
|
||||
tenantId,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "name", name);
|
||||
},
|
||||
MapProfile,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
return await dbContext.RiskProfiles
|
||||
.AsNoTracking()
|
||||
.Where(p => p.TenantId == tenantId && p.Name == name && p.IsActive)
|
||||
.OrderByDescending(p => p.Version)
|
||||
.FirstOrDefaultAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -95,26 +73,23 @@ public sealed class RiskProfileRepository : RepositoryBase<PolicyDataSource>, IR
|
||||
int offset = 0,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var sql = "SELECT * FROM policy.risk_profiles WHERE tenant_id = @tenant_id";
|
||||
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
|
||||
|
||||
var query = dbContext.RiskProfiles.AsNoTracking().Where(p => p.TenantId == tenantId);
|
||||
|
||||
if (activeOnly == true)
|
||||
{
|
||||
sql += " AND is_active = TRUE";
|
||||
query = query.Where(p => p.IsActive);
|
||||
}
|
||||
|
||||
sql += " ORDER BY name, version DESC LIMIT @limit OFFSET @offset";
|
||||
|
||||
return await QueryAsync(
|
||||
tenantId,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "limit", limit);
|
||||
AddParameter(cmd, "offset", offset);
|
||||
},
|
||||
MapProfile,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
return await query
|
||||
.OrderBy(p => p.Name).ThenByDescending(p => p.Version)
|
||||
.Skip(offset)
|
||||
.Take(limit)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -123,27 +98,22 @@ public sealed class RiskProfileRepository : RepositoryBase<PolicyDataSource>, IR
|
||||
string name,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT * FROM policy.risk_profiles
|
||||
WHERE tenant_id = @tenant_id AND name = @name
|
||||
ORDER BY version DESC
|
||||
""";
|
||||
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
|
||||
|
||||
return await QueryAsync(
|
||||
tenantId,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "name", name);
|
||||
},
|
||||
MapProfile,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
return await dbContext.RiskProfiles
|
||||
.AsNoTracking()
|
||||
.Where(p => p.TenantId == tenantId && p.Name == name)
|
||||
.OrderByDescending(p => p.Version)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> UpdateAsync(RiskProfileEntity profile, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Keep raw SQL: targeted column update without requiring full entity load
|
||||
const string sql = """
|
||||
UPDATE policy.risk_profiles
|
||||
SET display_name = @display_name,
|
||||
@@ -181,6 +151,7 @@ public sealed class RiskProfileRepository : RepositoryBase<PolicyDataSource>, IR
|
||||
RiskProfileEntity newProfile,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Keep raw SQL: multi-step transaction with COALESCE(MAX) + INSERT + deactivate others
|
||||
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var transaction = await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
|
||||
@@ -253,6 +224,7 @@ public sealed class RiskProfileRepository : RepositoryBase<PolicyDataSource>, IR
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> ActivateAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Keep raw SQL: multi-step transaction (lookup name, deactivate others, activate target)
|
||||
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var transaction = await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
|
||||
@@ -301,21 +273,14 @@ public sealed class RiskProfileRepository : RepositoryBase<PolicyDataSource>, IR
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> DeactivateAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
UPDATE policy.risk_profiles
|
||||
SET is_active = FALSE
|
||||
WHERE tenant_id = @tenant_id AND id = @id
|
||||
""";
|
||||
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
|
||||
|
||||
var rows = await ExecuteAsync(
|
||||
tenantId,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "id", id);
|
||||
},
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
var rows = await dbContext.RiskProfiles
|
||||
.Where(p => p.TenantId == tenantId && p.Id == id)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(p => p.IsActive, false), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return rows > 0;
|
||||
}
|
||||
@@ -323,37 +288,18 @@ public sealed class RiskProfileRepository : RepositoryBase<PolicyDataSource>, IR
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> DeleteAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = "DELETE FROM policy.risk_profiles WHERE tenant_id = @tenant_id AND id = @id";
|
||||
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
|
||||
|
||||
var rows = await ExecuteAsync(
|
||||
tenantId,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "id", id);
|
||||
},
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
var rows = await dbContext.RiskProfiles
|
||||
.Where(p => p.TenantId == tenantId && p.Id == id)
|
||||
.ExecuteDeleteAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return rows > 0;
|
||||
}
|
||||
|
||||
private static void AddProfileParameters(NpgsqlCommand command, RiskProfileEntity profile)
|
||||
{
|
||||
AddParameter(command, "id", profile.Id);
|
||||
AddParameter(command, "tenant_id", profile.TenantId);
|
||||
AddParameter(command, "name", profile.Name);
|
||||
AddParameter(command, "display_name", profile.DisplayName);
|
||||
AddParameter(command, "description", profile.Description);
|
||||
AddParameter(command, "version", profile.Version);
|
||||
AddParameter(command, "is_active", profile.IsActive);
|
||||
AddJsonbParameter(command, "thresholds", profile.Thresholds);
|
||||
AddJsonbParameter(command, "scoring_weights", profile.ScoringWeights);
|
||||
AddJsonbParameter(command, "exemptions", profile.Exemptions);
|
||||
AddJsonbParameter(command, "metadata", profile.Metadata);
|
||||
AddParameter(command, "created_by", profile.CreatedBy);
|
||||
}
|
||||
|
||||
private static RiskProfileEntity MapProfile(NpgsqlDataReader reader) => new()
|
||||
{
|
||||
Id = reader.GetGuid(reader.GetOrdinal("id")),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
@@ -8,6 +9,8 @@ namespace StellaOps.Policy.Persistence.Postgres.Repositories;
|
||||
/// <summary>
|
||||
/// PostgreSQL repository for policy rule operations.
|
||||
/// Note: rules table doesn't have tenant_id; tenant context comes from parent pack.
|
||||
/// Uses EF Core for standard CRUD; raw SQL preserved for batch inserts with transactions
|
||||
/// and tag array containment queries.
|
||||
/// </summary>
|
||||
public sealed class RuleRepository : RepositoryBase<PolicyDataSource>, IRuleRepository
|
||||
{
|
||||
@@ -22,28 +25,14 @@ public sealed class RuleRepository : RepositoryBase<PolicyDataSource>, IRuleRepo
|
||||
/// <inheritdoc />
|
||||
public async Task<RuleEntity> CreateAsync(RuleEntity rule, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
INSERT INTO policy.rules (
|
||||
id, pack_version_id, name, description, rule_type, content,
|
||||
content_hash, severity, category, tags, metadata
|
||||
)
|
||||
VALUES (
|
||||
@id, @pack_version_id, @name, @description, @rule_type, @content,
|
||||
@content_hash, @severity, @category, @tags, @metadata::jsonb
|
||||
)
|
||||
RETURNING *
|
||||
""";
|
||||
|
||||
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
|
||||
|
||||
AddRuleParameters(command, rule);
|
||||
dbContext.Rules.Add(rule);
|
||||
await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return MapRule(reader);
|
||||
return rule;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -54,28 +43,12 @@ public sealed class RuleRepository : RepositoryBase<PolicyDataSource>, IRuleRepo
|
||||
|
||||
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var transaction = await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
|
||||
|
||||
var count = 0;
|
||||
foreach (var rule in rulesList)
|
||||
{
|
||||
const string sql = """
|
||||
INSERT INTO policy.rules (
|
||||
id, pack_version_id, name, description, rule_type, content,
|
||||
content_hash, severity, category, tags, metadata
|
||||
)
|
||||
VALUES (
|
||||
@id, @pack_version_id, @name, @description, @rule_type, @content,
|
||||
@content_hash, @severity, @category, @tags, @metadata::jsonb
|
||||
)
|
||||
""";
|
||||
await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
command.Transaction = transaction;
|
||||
AddRuleParameters(command, rule);
|
||||
|
||||
count += await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
dbContext.Rules.AddRange(rulesList);
|
||||
var count = await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
|
||||
return count;
|
||||
@@ -84,21 +57,14 @@ public sealed class RuleRepository : RepositoryBase<PolicyDataSource>, IRuleRepo
|
||||
/// <inheritdoc />
|
||||
public async Task<RuleEntity?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = "SELECT * FROM policy.rules WHERE id = @id";
|
||||
|
||||
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
|
||||
|
||||
AddParameter(command, "id", id);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return MapRule(reader);
|
||||
return await dbContext.Rules
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(r => r.Id == id, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -107,22 +73,14 @@ public sealed class RuleRepository : RepositoryBase<PolicyDataSource>, IRuleRepo
|
||||
string name,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = "SELECT * FROM policy.rules WHERE pack_version_id = @pack_version_id AND name = @name";
|
||||
|
||||
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
|
||||
|
||||
AddParameter(command, "pack_version_id", packVersionId);
|
||||
AddParameter(command, "name", name);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return MapRule(reader);
|
||||
return await dbContext.Rules
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(r => r.PackVersionId == packVersionId && r.Name == name, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -130,27 +88,16 @@ public sealed class RuleRepository : RepositoryBase<PolicyDataSource>, IRuleRepo
|
||||
Guid packVersionId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT * FROM policy.rules
|
||||
WHERE pack_version_id = @pack_version_id
|
||||
ORDER BY name, id
|
||||
""";
|
||||
|
||||
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
|
||||
|
||||
AddParameter(command, "pack_version_id", packVersionId);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
var results = new List<RuleEntity>();
|
||||
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
results.Add(MapRule(reader));
|
||||
}
|
||||
|
||||
return results;
|
||||
return await dbContext.Rules
|
||||
.AsNoTracking()
|
||||
.Where(r => r.PackVersionId == packVersionId)
|
||||
.OrderBy(r => r.Name).ThenBy(r => r.Id)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -159,28 +106,16 @@ public sealed class RuleRepository : RepositoryBase<PolicyDataSource>, IRuleRepo
|
||||
RuleSeverity severity,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT * FROM policy.rules
|
||||
WHERE pack_version_id = @pack_version_id AND severity = @severity
|
||||
ORDER BY name, id
|
||||
""";
|
||||
|
||||
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
|
||||
|
||||
AddParameter(command, "pack_version_id", packVersionId);
|
||||
AddParameter(command, "severity", SeverityToString(severity));
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
var results = new List<RuleEntity>();
|
||||
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
results.Add(MapRule(reader));
|
||||
}
|
||||
|
||||
return results;
|
||||
return await dbContext.Rules
|
||||
.AsNoTracking()
|
||||
.Where(r => r.PackVersionId == packVersionId && r.Severity == severity)
|
||||
.OrderBy(r => r.Name).ThenBy(r => r.Id)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -189,28 +124,16 @@ public sealed class RuleRepository : RepositoryBase<PolicyDataSource>, IRuleRepo
|
||||
string category,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT * FROM policy.rules
|
||||
WHERE pack_version_id = @pack_version_id AND category = @category
|
||||
ORDER BY name, id
|
||||
""";
|
||||
|
||||
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
|
||||
|
||||
AddParameter(command, "pack_version_id", packVersionId);
|
||||
AddParameter(command, "category", category);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
var results = new List<RuleEntity>();
|
||||
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
results.Add(MapRule(reader));
|
||||
}
|
||||
|
||||
return results;
|
||||
return await dbContext.Rules
|
||||
.AsNoTracking()
|
||||
.Where(r => r.PackVersionId == packVersionId && r.Category == category)
|
||||
.OrderBy(r => r.Name).ThenBy(r => r.Id)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -219,6 +142,7 @@ public sealed class RuleRepository : RepositoryBase<PolicyDataSource>, IRuleRepo
|
||||
string tag,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Keep raw SQL: @tag = ANY(tags) array containment not cleanly supported in EF Core LINQ
|
||||
const string sql = """
|
||||
SELECT * FROM policy.rules
|
||||
WHERE pack_version_id = @pack_version_id AND @tag = ANY(tags)
|
||||
@@ -246,31 +170,13 @@ public sealed class RuleRepository : RepositoryBase<PolicyDataSource>, IRuleRepo
|
||||
/// <inheritdoc />
|
||||
public async Task<int> CountByPackVersionIdAsync(Guid packVersionId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = "SELECT COUNT(*) FROM policy.rules WHERE pack_version_id = @pack_version_id";
|
||||
|
||||
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
|
||||
|
||||
AddParameter(command, "pack_version_id", packVersionId);
|
||||
|
||||
var result = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
|
||||
return Convert.ToInt32(result);
|
||||
}
|
||||
|
||||
private static void AddRuleParameters(NpgsqlCommand command, RuleEntity rule)
|
||||
{
|
||||
AddParameter(command, "id", rule.Id);
|
||||
AddParameter(command, "pack_version_id", rule.PackVersionId);
|
||||
AddParameter(command, "name", rule.Name);
|
||||
AddParameter(command, "description", rule.Description);
|
||||
AddParameter(command, "rule_type", RuleTypeToString(rule.RuleType));
|
||||
AddParameter(command, "content", rule.Content);
|
||||
AddParameter(command, "content_hash", rule.ContentHash);
|
||||
AddParameter(command, "severity", SeverityToString(rule.Severity));
|
||||
AddParameter(command, "category", rule.Category);
|
||||
AddTextArrayParameter(command, "tags", rule.Tags);
|
||||
AddJsonbParameter(command, "metadata", rule.Metadata);
|
||||
return await dbContext.Rules
|
||||
.CountAsync(r => r.PackVersionId == packVersionId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static RuleEntity MapRule(NpgsqlDataReader reader) => new()
|
||||
@@ -289,14 +195,6 @@ public sealed class RuleRepository : RepositoryBase<PolicyDataSource>, IRuleRepo
|
||||
CreatedAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("created_at"))
|
||||
};
|
||||
|
||||
private static string RuleTypeToString(RuleType ruleType) => ruleType switch
|
||||
{
|
||||
RuleType.Rego => "rego",
|
||||
RuleType.Json => "json",
|
||||
RuleType.Yaml => "yaml",
|
||||
_ => throw new ArgumentException($"Unknown rule type: {ruleType}", nameof(ruleType))
|
||||
};
|
||||
|
||||
private static RuleType ParseRuleType(string ruleType) => ruleType switch
|
||||
{
|
||||
"rego" => RuleType.Rego,
|
||||
@@ -305,16 +203,6 @@ public sealed class RuleRepository : RepositoryBase<PolicyDataSource>, IRuleRepo
|
||||
_ => throw new ArgumentException($"Unknown rule type: {ruleType}", nameof(ruleType))
|
||||
};
|
||||
|
||||
private static string SeverityToString(RuleSeverity severity) => severity switch
|
||||
{
|
||||
RuleSeverity.Critical => "critical",
|
||||
RuleSeverity.High => "high",
|
||||
RuleSeverity.Medium => "medium",
|
||||
RuleSeverity.Low => "low",
|
||||
RuleSeverity.Info => "info",
|
||||
_ => throw new ArgumentException($"Unknown severity: {severity}", nameof(severity))
|
||||
};
|
||||
|
||||
private static RuleSeverity ParseSeverity(string severity) => severity switch
|
||||
{
|
||||
"critical" => RuleSeverity.Critical,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
@@ -7,6 +8,7 @@ namespace StellaOps.Policy.Persistence.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL repository for policy snapshot operations.
|
||||
/// Uses EF Core for standard CRUD; raw SQL preserved where needed.
|
||||
/// </summary>
|
||||
public sealed class SnapshotRepository : RepositoryBase<PolicyDataSource>, ISnapshotRepository
|
||||
{
|
||||
@@ -21,45 +23,27 @@ public sealed class SnapshotRepository : RepositoryBase<PolicyDataSource>, ISnap
|
||||
/// <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);
|
||||
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
|
||||
|
||||
AddSnapshotParameters(command, snapshot);
|
||||
dbContext.Snapshots.Add(snapshot);
|
||||
await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return MapSnapshot(reader);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
/// <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";
|
||||
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
|
||||
|
||||
return await QuerySingleOrDefaultAsync(
|
||||
tenantId,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "id", id);
|
||||
},
|
||||
MapSnapshot,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
return await dbContext.Snapshots
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(s => s.TenantId == tenantId && s.Id == id, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -68,41 +52,28 @@ public sealed class SnapshotRepository : RepositoryBase<PolicyDataSource>, ISnap
|
||||
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
|
||||
""";
|
||||
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
|
||||
|
||||
return await QuerySingleOrDefaultAsync(
|
||||
tenantId,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "policy_id", policyId);
|
||||
},
|
||||
MapSnapshot,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
return await dbContext.Snapshots
|
||||
.AsNoTracking()
|
||||
.Where(s => s.TenantId == tenantId && s.PolicyId == policyId)
|
||||
.OrderByDescending(s => s.Version)
|
||||
.FirstOrDefaultAsync(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 dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
return MapSnapshot(reader);
|
||||
}
|
||||
|
||||
return null;
|
||||
return await dbContext.Snapshots
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(s => s.ContentDigest == contentDigest, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -113,67 +84,32 @@ public sealed class SnapshotRepository : RepositoryBase<PolicyDataSource>, ISnap
|
||||
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
|
||||
""";
|
||||
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
|
||||
|
||||
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);
|
||||
return await dbContext.Snapshots
|
||||
.AsNoTracking()
|
||||
.Where(s => s.TenantId == tenantId && s.PolicyId == policyId)
|
||||
.OrderByDescending(s => s.Version)
|
||||
.Skip(offset)
|
||||
.Take(limit)
|
||||
.ToListAsync(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";
|
||||
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
|
||||
|
||||
var rows = await ExecuteAsync(
|
||||
tenantId,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "id", id);
|
||||
},
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
var rows = await dbContext.Snapshots
|
||||
.Where(s => s.TenantId == tenantId && s.Id == id)
|
||||
.ExecuteDeleteAsync(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"))
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
// Sprint: SPRINT_20260118_017_Policy_gate_attestation_verification
|
||||
// Task: TASK-017-005 - Trusted Key Registry
|
||||
// Description: PostgreSQL implementation of trusted key repository
|
||||
// Converted to EF Core for standard reads/writes; raw SQL preserved for
|
||||
// LIKE REPLACE pattern matching, jsonb containment, and conditional updates.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
@@ -16,6 +18,8 @@ namespace StellaOps.Policy.Persistence.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL repository for trusted signing keys.
|
||||
/// Uses EF Core for standard reads/writes; raw SQL preserved for
|
||||
/// LIKE REPLACE pattern matching and jsonb @> containment queries.
|
||||
/// </summary>
|
||||
public sealed class TrustedKeyRepository : RepositoryBase<PolicyDataSource>, ITrustedKeyRepository
|
||||
{
|
||||
@@ -28,21 +32,14 @@ public sealed class TrustedKeyRepository : RepositoryBase<PolicyDataSource>, ITr
|
||||
string keyId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, tenant_id, key_id, fingerprint, algorithm, public_key_pem, owner,
|
||||
issuer_pattern, purposes, valid_from, valid_until, is_active,
|
||||
revoked_at, revoked_reason, metadata, created_at, updated_at, created_by
|
||||
FROM policy.trusted_keys
|
||||
WHERE tenant_id = @tenant_id AND key_id = @key_id
|
||||
""";
|
||||
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
|
||||
|
||||
var results = await QueryAsync(tenantId, sql, cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "key_id", keyId);
|
||||
}, MapEntity, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return results.Count > 0 ? results[0] : null;
|
||||
return await dbContext.TrustedKeys
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(k => k.TenantId == tenantId && k.KeyId == keyId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -51,21 +48,14 @@ public sealed class TrustedKeyRepository : RepositoryBase<PolicyDataSource>, ITr
|
||||
string fingerprint,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, tenant_id, key_id, fingerprint, algorithm, public_key_pem, owner,
|
||||
issuer_pattern, purposes, valid_from, valid_until, is_active,
|
||||
revoked_at, revoked_reason, metadata, created_at, updated_at, created_by
|
||||
FROM policy.trusted_keys
|
||||
WHERE tenant_id = @tenant_id AND fingerprint = @fingerprint
|
||||
""";
|
||||
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
|
||||
|
||||
var results = await QueryAsync(tenantId, sql, cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "fingerprint", fingerprint);
|
||||
}, MapEntity, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return results.Count > 0 ? results[0] : null;
|
||||
return await dbContext.TrustedKeys
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(k => k.TenantId == tenantId && k.Fingerprint == fingerprint, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -74,8 +64,7 @@ public sealed class TrustedKeyRepository : RepositoryBase<PolicyDataSource>, ITr
|
||||
string issuer,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Find keys where the issuer matches the pattern using LIKE
|
||||
// Pattern stored as "*@example.com" is translated to SQL LIKE pattern
|
||||
// Keep raw SQL: LIKE REPLACE pattern matching cannot be expressed in EF Core LINQ
|
||||
const string sql = """
|
||||
SELECT id, tenant_id, key_id, fingerprint, algorithm, public_key_pem, owner,
|
||||
issuer_pattern, purposes, valid_from, valid_until, is_active,
|
||||
@@ -104,6 +93,7 @@ public sealed class TrustedKeyRepository : RepositoryBase<PolicyDataSource>, ITr
|
||||
int offset = 0,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Keep raw SQL: OR valid_until > NOW() with NULL check cannot be cleanly translated by EF Core
|
||||
const string sql = """
|
||||
SELECT id, tenant_id, key_id, fingerprint, algorithm, public_key_pem, owner,
|
||||
issuer_pattern, purposes, valid_from, valid_until, is_active,
|
||||
@@ -131,6 +121,7 @@ public sealed class TrustedKeyRepository : RepositoryBase<PolicyDataSource>, ITr
|
||||
string purpose,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Keep raw SQL: jsonb @> containment operator
|
||||
const string sql = """
|
||||
SELECT id, tenant_id, key_id, fingerprint, algorithm, public_key_pem, owner,
|
||||
issuer_pattern, purposes, valid_from, valid_until, is_active,
|
||||
@@ -156,44 +147,14 @@ public sealed class TrustedKeyRepository : RepositoryBase<PolicyDataSource>, ITr
|
||||
TrustedKeyEntity key,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
INSERT INTO policy.trusted_keys (
|
||||
id, tenant_id, key_id, fingerprint, algorithm, public_key_pem, owner,
|
||||
issuer_pattern, purposes, valid_from, valid_until, is_active,
|
||||
revoked_at, revoked_reason, metadata, created_at, updated_at, created_by
|
||||
) VALUES (
|
||||
@id, @tenant_id, @key_id, @fingerprint, @algorithm, @public_key_pem, @owner,
|
||||
@issuer_pattern, @purposes::jsonb, @valid_from, @valid_until, @is_active,
|
||||
@revoked_at, @revoked_reason, @metadata::jsonb, @created_at, @updated_at, @created_by
|
||||
)
|
||||
RETURNING id
|
||||
""";
|
||||
|
||||
await using var connection = await DataSource.OpenConnectionAsync(key.TenantId, "writer", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
|
||||
|
||||
AddParameter(command, "id", key.Id);
|
||||
AddParameter(command, "tenant_id", key.TenantId);
|
||||
AddParameter(command, "key_id", key.KeyId);
|
||||
AddParameter(command, "fingerprint", key.Fingerprint);
|
||||
AddParameter(command, "algorithm", key.Algorithm);
|
||||
AddParameter(command, "public_key_pem", key.PublicKeyPem);
|
||||
AddParameter(command, "owner", key.Owner);
|
||||
AddParameter(command, "issuer_pattern", key.IssuerPattern);
|
||||
AddJsonbParameter(command, "purposes", key.Purposes);
|
||||
AddParameter(command, "valid_from", key.ValidFrom);
|
||||
AddParameter(command, "valid_until", key.ValidUntil);
|
||||
AddParameter(command, "is_active", key.IsActive);
|
||||
AddParameter(command, "revoked_at", key.RevokedAt);
|
||||
AddParameter(command, "revoked_reason", key.RevokedReason);
|
||||
AddJsonbParameter(command, "metadata", key.Metadata);
|
||||
AddParameter(command, "created_at", key.CreatedAt);
|
||||
AddParameter(command, "updated_at", key.UpdatedAt);
|
||||
AddParameter(command, "created_by", key.CreatedBy);
|
||||
dbContext.TrustedKeys.Add(key);
|
||||
await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var result = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
|
||||
return (Guid)result!;
|
||||
return key.Id;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -201,6 +162,7 @@ public sealed class TrustedKeyRepository : RepositoryBase<PolicyDataSource>, ITr
|
||||
TrustedKeyEntity key,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Keep raw SQL: targeted column update with NOW() for updated_at
|
||||
const string sql = """
|
||||
UPDATE policy.trusted_keys
|
||||
SET public_key_pem = @public_key_pem,
|
||||
@@ -239,6 +201,7 @@ public sealed class TrustedKeyRepository : RepositoryBase<PolicyDataSource>, ITr
|
||||
string reason,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Keep raw SQL: conditional update WHERE revoked_at IS NULL with NOW()
|
||||
const string sql = """
|
||||
UPDATE policy.trusted_keys
|
||||
SET is_active = false,
|
||||
@@ -266,20 +229,16 @@ public sealed class TrustedKeyRepository : RepositoryBase<PolicyDataSource>, ITr
|
||||
string keyId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
DELETE FROM policy.trusted_keys
|
||||
WHERE tenant_id = @tenant_id AND key_id = @key_id
|
||||
""";
|
||||
|
||||
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
|
||||
|
||||
AddParameter(command, "tenant_id", tenantId);
|
||||
AddParameter(command, "key_id", keyId);
|
||||
var rows = await dbContext.TrustedKeys
|
||||
.Where(k => k.TenantId == tenantId && k.KeyId == keyId)
|
||||
.ExecuteDeleteAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var rowsAffected = await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
return rowsAffected > 0;
|
||||
return rows > 0;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -287,6 +246,7 @@ public sealed class TrustedKeyRepository : RepositoryBase<PolicyDataSource>, ITr
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Keep raw SQL: OR valid_until > NOW() with NULL check
|
||||
const string sql = """
|
||||
SELECT COUNT(*)
|
||||
FROM policy.trusted_keys
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
@@ -7,6 +8,8 @@ namespace StellaOps.Policy.Persistence.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL repository for append-only violation event operations.
|
||||
/// Uses EF Core for reads and single inserts; raw SQL preserved for
|
||||
/// batch inserts and aggregate GROUP BY queries.
|
||||
/// </summary>
|
||||
public sealed class ViolationEventRepository : RepositoryBase<PolicyDataSource>, IViolationEventRepository
|
||||
{
|
||||
@@ -21,28 +24,14 @@ public sealed class ViolationEventRepository : RepositoryBase<PolicyDataSource>,
|
||||
/// <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);
|
||||
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
|
||||
|
||||
AddViolationParameters(command, violationEvent);
|
||||
dbContext.ViolationEvents.Add(violationEvent);
|
||||
await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return MapViolation(reader);
|
||||
return violationEvent;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -51,47 +40,26 @@ public sealed class ViolationEventRepository : RepositoryBase<PolicyDataSource>,
|
||||
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);
|
||||
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
|
||||
|
||||
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;
|
||||
dbContext.ViolationEvents.AddRange(eventList);
|
||||
return await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <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";
|
||||
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
|
||||
|
||||
return await QuerySingleOrDefaultAsync(
|
||||
tenantId,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "id", id);
|
||||
},
|
||||
MapViolation,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
return await dbContext.ViolationEvents
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(v => v.TenantId == tenantId && v.Id == id, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -103,34 +71,25 @@ public sealed class ViolationEventRepository : RepositoryBase<PolicyDataSource>,
|
||||
int offset = 0,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var sql = """
|
||||
SELECT * FROM policy.violation_events
|
||||
WHERE tenant_id = @tenant_id AND policy_id = @policy_id
|
||||
""";
|
||||
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
|
||||
|
||||
var query = dbContext.ViolationEvents
|
||||
.AsNoTracking()
|
||||
.Where(v => v.TenantId == tenantId && v.PolicyId == policyId);
|
||||
|
||||
if (since.HasValue)
|
||||
{
|
||||
sql += " AND occurred_at >= @since";
|
||||
query = query.Where(v => v.OccurredAt >= since.Value);
|
||||
}
|
||||
|
||||
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);
|
||||
return await query
|
||||
.OrderByDescending(v => v.OccurredAt)
|
||||
.Skip(offset)
|
||||
.Take(limit)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -141,33 +100,24 @@ public sealed class ViolationEventRepository : RepositoryBase<PolicyDataSource>,
|
||||
int limit = 100,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var sql = """
|
||||
SELECT * FROM policy.violation_events
|
||||
WHERE tenant_id = @tenant_id AND severity = @severity
|
||||
""";
|
||||
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
|
||||
|
||||
var query = dbContext.ViolationEvents
|
||||
.AsNoTracking()
|
||||
.Where(v => v.TenantId == tenantId && v.Severity == severity);
|
||||
|
||||
if (since.HasValue)
|
||||
{
|
||||
sql += " AND occurred_at >= @since";
|
||||
query = query.Where(v => v.OccurredAt >= since.Value);
|
||||
}
|
||||
|
||||
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);
|
||||
return await query
|
||||
.OrderByDescending(v => v.OccurredAt)
|
||||
.Take(limit)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -177,24 +127,17 @@ public sealed class ViolationEventRepository : RepositoryBase<PolicyDataSource>,
|
||||
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
|
||||
""";
|
||||
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
|
||||
|
||||
return await QueryAsync(
|
||||
tenantId,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "purl", purl);
|
||||
AddParameter(cmd, "limit", limit);
|
||||
},
|
||||
MapViolation,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
return await dbContext.ViolationEvents
|
||||
.AsNoTracking()
|
||||
.Where(v => v.TenantId == tenantId && v.SubjectPurl == purl)
|
||||
.OrderByDescending(v => v.OccurredAt)
|
||||
.Take(limit)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -204,6 +147,7 @@ public sealed class ViolationEventRepository : RepositoryBase<PolicyDataSource>,
|
||||
DateTimeOffset until,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Keep raw SQL: GROUP BY aggregate with cast cannot be cleanly expressed as Dictionary in EF Core
|
||||
const string sql = """
|
||||
SELECT severity, COUNT(*)::int as count
|
||||
FROM policy.violation_events
|
||||
@@ -231,35 +175,4 @@ public sealed class ViolationEventRepository : RepositoryBase<PolicyDataSource>,
|
||||
|
||||
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"))
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
@@ -7,6 +8,8 @@ namespace StellaOps.Policy.Persistence.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL repository for worker result operations.
|
||||
/// Uses EF Core for reads and inserts; raw SQL preserved for conditional CASE updates,
|
||||
/// system-connection queries, and retry_count increment.
|
||||
/// </summary>
|
||||
public sealed class WorkerResultRepository : RepositoryBase<PolicyDataSource>, IWorkerResultRepository
|
||||
{
|
||||
@@ -21,45 +24,27 @@ public sealed class WorkerResultRepository : RepositoryBase<PolicyDataSource>, I
|
||||
/// <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);
|
||||
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
|
||||
|
||||
AddResultParameters(command, result);
|
||||
dbContext.WorkerResults.Add(result);
|
||||
await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return MapResult(reader);
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <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";
|
||||
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
|
||||
|
||||
return await QuerySingleOrDefaultAsync(
|
||||
tenantId,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "id", id);
|
||||
},
|
||||
MapResult,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
return await dbContext.WorkerResults
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(r => r.TenantId == tenantId && r.Id == id, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -69,22 +54,14 @@ public sealed class WorkerResultRepository : RepositoryBase<PolicyDataSource>, I
|
||||
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
|
||||
""";
|
||||
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
|
||||
|
||||
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);
|
||||
return await dbContext.WorkerResults
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(r => r.TenantId == tenantId && r.JobType == jobType && r.JobId == jobId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -94,24 +71,17 @@ public sealed class WorkerResultRepository : RepositoryBase<PolicyDataSource>, I
|
||||
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
|
||||
""";
|
||||
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
|
||||
|
||||
return await QueryAsync(
|
||||
tenantId,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "status", status);
|
||||
AddParameter(cmd, "limit", limit);
|
||||
},
|
||||
MapResult,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
return await dbContext.WorkerResults
|
||||
.AsNoTracking()
|
||||
.Where(r => r.TenantId == tenantId && r.Status == status)
|
||||
.OrderByDescending(r => r.CreatedAt)
|
||||
.Take(limit)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -120,6 +90,7 @@ public sealed class WorkerResultRepository : RepositoryBase<PolicyDataSource>, I
|
||||
int limit = 100,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Keep raw SQL: system connection (no tenant) + NULLS LAST ordering
|
||||
var sql = """
|
||||
SELECT * FROM policy.worker_results
|
||||
WHERE status = 'pending'
|
||||
@@ -160,6 +131,7 @@ public sealed class WorkerResultRepository : RepositoryBase<PolicyDataSource>, I
|
||||
string? errorMessage = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Keep raw SQL: CASE conditional update for started_at
|
||||
const string sql = """
|
||||
UPDATE policy.worker_results
|
||||
SET status = @status, progress = @progress, error_message = @error_message,
|
||||
@@ -191,6 +163,7 @@ public sealed class WorkerResultRepository : RepositoryBase<PolicyDataSource>, I
|
||||
string? outputHash = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Keep raw SQL: multi-column update with NOW() and jsonb cast
|
||||
const string sql = """
|
||||
UPDATE policy.worker_results
|
||||
SET status = 'completed', progress = 100, result = @result::jsonb,
|
||||
@@ -220,6 +193,7 @@ public sealed class WorkerResultRepository : RepositoryBase<PolicyDataSource>, I
|
||||
string errorMessage,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Keep raw SQL: conditional update with NOW()
|
||||
const string sql = """
|
||||
UPDATE policy.worker_results
|
||||
SET status = 'failed', error_message = @error_message, completed_at = NOW()
|
||||
@@ -246,6 +220,7 @@ public sealed class WorkerResultRepository : RepositoryBase<PolicyDataSource>, I
|
||||
Guid id,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Keep raw SQL: retry_count increment with conditional WHERE on max_retries
|
||||
const string sql = """
|
||||
UPDATE policy.worker_results
|
||||
SET retry_count = retry_count + 1, status = 'pending', started_at = NULL
|
||||
@@ -265,21 +240,6 @@ public sealed class WorkerResultRepository : RepositoryBase<PolicyDataSource>, I
|
||||
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")),
|
||||
|
||||
@@ -13,7 +13,12 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="Migrations\*.sql" />
|
||||
<EmbeddedResource Include="Migrations\**\*.sql" LogicalName="%(RecursiveDir)%(Filename)%(Extension)" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Prevent automatic compiled-model binding so non-default schemas can build runtime models. -->
|
||||
<Compile Remove="EfCore\CompiledModels\PolicyDbContextAssemblyAttributes.cs" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
# StellaOps.Policy.Persistence Task Board
|
||||
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
Source of truth: `docs/implplan/SPRINT_20260222_089_Policy_dal_to_efcore.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0448-M | DONE | Revalidated 2026-01-07; maintainability audit for StellaOps.Policy.Persistence. |
|
||||
| AUDIT-0448-T | DONE | Revalidated 2026-01-07; test coverage audit for StellaOps.Policy.Persistence. |
|
||||
| AUDIT-0448-A | TODO | Revalidated 2026-01-07 (open findings). |
|
||||
| POLICY-EF-01 | DONE | Module AGENTS.md verified; migration plugin registered in Platform registry. |
|
||||
| POLICY-EF-02 | DONE | EF Core model baseline scaffolded: PolicyDbContext (22 DbSets), design-time factory, compiled model stubs, 4 new entity models. |
|
||||
| POLICY-EF-03 | DONE | 14 repositories converted to EF Core (partial or full); 8 complex repositories retained as raw SQL. Build passes 0W/0E. |
|
||||
| POLICY-EF-04 | DONE | Compiled model stubs verified; runtime factory uses UseModel on default schema; non-default schema uses reflection fallback. |
|
||||
| POLICY-EF-05 | DONE | Sequential build validated; AGENTS.md and TASKS.md updated; architecture doc paths corrected. |
|
||||
|
||||
Reference in New Issue
Block a user