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:
@@ -0,0 +1,37 @@
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
|
||||
#pragma warning disable 219, 612, 618
|
||||
#nullable disable
|
||||
|
||||
namespace StellaOps.Authority.Persistence.EfCore.CompiledModels;
|
||||
|
||||
/// <summary>
|
||||
/// Compiled model stub for AuthorityDbContext.
|
||||
/// This is a placeholder that delegates to runtime model building.
|
||||
/// Replace with output from <c>dotnet ef dbcontext optimize</c> when a provisioned DB is available.
|
||||
/// </summary>
|
||||
[DbContext(typeof(Context.AuthorityDbContext))]
|
||||
public partial class AuthorityDbContextModel : RuntimeModel
|
||||
{
|
||||
private static AuthorityDbContextModel _instance;
|
||||
|
||||
public static IModel Instance
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_instance == null)
|
||||
{
|
||||
_instance = new AuthorityDbContextModel();
|
||||
_instance.Initialize();
|
||||
_instance.Customize();
|
||||
}
|
||||
|
||||
return _instance;
|
||||
}
|
||||
}
|
||||
|
||||
partial void Initialize();
|
||||
|
||||
partial void Customize();
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
|
||||
#pragma warning disable 219, 612, 618
|
||||
#nullable disable
|
||||
|
||||
namespace StellaOps.Authority.Persistence.EfCore.CompiledModels;
|
||||
|
||||
/// <summary>
|
||||
/// Compiled model builder stub for AuthorityDbContext.
|
||||
/// Replace with output from <c>dotnet ef dbcontext optimize</c> when a provisioned DB is available.
|
||||
/// </summary>
|
||||
public partial class AuthorityDbContextModel
|
||||
{
|
||||
partial void Initialize()
|
||||
{
|
||||
// Stub: when a real compiled model is generated, entity types will be registered here.
|
||||
// The runtime factory will fall back to reflection-based model building for all schemas
|
||||
// until this stub is replaced with a full compiled model.
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,559 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using StellaOps.Authority.Persistence.EfCore.Models;
|
||||
|
||||
namespace StellaOps.Authority.Persistence.EfCore.Context;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core DbContext for Authority module.
|
||||
/// This is a stub that will be scaffolded from the PostgreSQL database.
|
||||
/// EF Core DbContext for the Authority module.
|
||||
/// Maps to the authority PostgreSQL schema: tenants, users, roles, permissions,
|
||||
/// tokens, refresh_tokens, sessions, api_keys, audit, clients, bootstrap_invites,
|
||||
/// service_accounts, revocations, login_attempts, oidc_tokens, oidc_refresh_tokens,
|
||||
/// airgap_audit, revocation_export_state, offline_kit_audit, and verdict_manifests tables.
|
||||
/// </summary>
|
||||
public class AuthorityDbContext : DbContext
|
||||
public partial class AuthorityDbContext : DbContext
|
||||
{
|
||||
public AuthorityDbContext(DbContextOptions<AuthorityDbContext> options)
|
||||
private readonly string _schemaName;
|
||||
|
||||
public AuthorityDbContext(DbContextOptions<AuthorityDbContext> options, string? schemaName = null)
|
||||
: base(options)
|
||||
{
|
||||
_schemaName = string.IsNullOrWhiteSpace(schemaName)
|
||||
? "authority"
|
||||
: schemaName.Trim();
|
||||
}
|
||||
|
||||
public virtual DbSet<TenantEfEntity> Tenants { get; set; }
|
||||
public virtual DbSet<UserEfEntity> Users { get; set; }
|
||||
public virtual DbSet<RoleEfEntity> Roles { get; set; }
|
||||
public virtual DbSet<PermissionEfEntity> Permissions { get; set; }
|
||||
public virtual DbSet<RolePermissionEfEntity> RolePermissions { get; set; }
|
||||
public virtual DbSet<UserRoleEfEntity> UserRoles { get; set; }
|
||||
public virtual DbSet<ApiKeyEfEntity> ApiKeys { get; set; }
|
||||
public virtual DbSet<TokenEfEntity> Tokens { get; set; }
|
||||
public virtual DbSet<RefreshTokenEfEntity> RefreshTokens { get; set; }
|
||||
public virtual DbSet<SessionEfEntity> Sessions { get; set; }
|
||||
public virtual DbSet<AuditEfEntity> AuditEntries { get; set; }
|
||||
public virtual DbSet<BootstrapInviteEfEntity> BootstrapInvites { get; set; }
|
||||
public virtual DbSet<ServiceAccountEfEntity> ServiceAccounts { get; set; }
|
||||
public virtual DbSet<ClientEfEntity> Clients { get; set; }
|
||||
public virtual DbSet<RevocationEfEntity> Revocations { get; set; }
|
||||
public virtual DbSet<LoginAttemptEfEntity> LoginAttempts { get; set; }
|
||||
public virtual DbSet<OidcTokenEfEntity> OidcTokens { get; set; }
|
||||
public virtual DbSet<OidcRefreshTokenEfEntity> OidcRefreshTokens { get; set; }
|
||||
public virtual DbSet<AirgapAuditEfEntity> AirgapAuditEntries { get; set; }
|
||||
public virtual DbSet<RevocationExportStateEfEntity> RevocationExportState { get; set; }
|
||||
public virtual DbSet<OfflineKitAuditEfEntity> OfflineKitAuditEntries { get; set; }
|
||||
public virtual DbSet<VerdictManifestEfEntity> VerdictManifests { get; set; }
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.HasDefaultSchema("authority");
|
||||
base.OnModelCreating(modelBuilder);
|
||||
var schemaName = _schemaName;
|
||||
|
||||
// ── tenants ──────────────────────────────────────────────────────
|
||||
modelBuilder.Entity<TenantEfEntity>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id).HasName("tenants_pkey");
|
||||
entity.ToTable("tenants", schemaName);
|
||||
|
||||
entity.HasIndex(e => e.TenantId, "tenants_tenant_id_key").IsUnique();
|
||||
entity.HasIndex(e => e.Status, "idx_tenants_status");
|
||||
entity.HasIndex(e => e.CreatedAt, "idx_tenants_created_at");
|
||||
|
||||
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.Status).HasDefaultValueSql("'active'").HasColumnName("status");
|
||||
entity.Property(e => e.Settings).HasDefaultValueSql("'{}'::jsonb").HasColumnType("jsonb").HasColumnName("settings");
|
||||
entity.Property(e => e.Metadata).HasDefaultValueSql("'{}'::jsonb").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");
|
||||
entity.Property(e => e.UpdatedBy).HasColumnName("updated_by");
|
||||
});
|
||||
|
||||
// ── users ────────────────────────────────────────────────────────
|
||||
modelBuilder.Entity<UserEfEntity>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id).HasName("users_pkey");
|
||||
entity.ToTable("users", schemaName);
|
||||
|
||||
entity.HasIndex(e => e.TenantId, "idx_users_tenant_id");
|
||||
entity.HasIndex(e => new { e.TenantId, e.Status }, "idx_users_status");
|
||||
entity.HasIndex(e => new { e.TenantId, e.Email }, "idx_users_email");
|
||||
entity.HasIndex(e => new { e.TenantId, e.Username }, "users_tenant_id_username_key").IsUnique();
|
||||
entity.HasIndex(e => new { e.TenantId, e.Email }, "users_tenant_id_email_key").IsUnique();
|
||||
|
||||
entity.Property(e => e.Id).HasDefaultValueSql("gen_random_uuid()").HasColumnName("id");
|
||||
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
|
||||
entity.Property(e => e.Username).HasColumnName("username");
|
||||
entity.Property(e => e.Email).HasColumnName("email");
|
||||
entity.Property(e => e.DisplayName).HasColumnName("display_name");
|
||||
entity.Property(e => e.PasswordHash).HasColumnName("password_hash");
|
||||
entity.Property(e => e.PasswordSalt).HasColumnName("password_salt");
|
||||
entity.Property(e => e.Enabled).HasDefaultValue(true).HasColumnName("enabled");
|
||||
entity.Property(e => e.PasswordAlgorithm).HasDefaultValueSql("'argon2id'").HasColumnName("password_algorithm");
|
||||
entity.Property(e => e.Status).HasDefaultValueSql("'active'").HasColumnName("status");
|
||||
entity.Property(e => e.EmailVerified).HasDefaultValue(false).HasColumnName("email_verified");
|
||||
entity.Property(e => e.MfaEnabled).HasDefaultValue(false).HasColumnName("mfa_enabled");
|
||||
entity.Property(e => e.MfaSecret).HasColumnName("mfa_secret");
|
||||
entity.Property(e => e.MfaBackupCodes).HasColumnName("mfa_backup_codes");
|
||||
entity.Property(e => e.FailedLoginAttempts).HasDefaultValue(0).HasColumnName("failed_login_attempts");
|
||||
entity.Property(e => e.LockedUntil).HasColumnName("locked_until");
|
||||
entity.Property(e => e.LastLoginAt).HasColumnName("last_login_at");
|
||||
entity.Property(e => e.PasswordChangedAt).HasColumnName("password_changed_at");
|
||||
entity.Property(e => e.LastPasswordChangeAt).HasColumnName("last_password_change_at");
|
||||
entity.Property(e => e.PasswordExpiresAt).HasColumnName("password_expires_at");
|
||||
entity.Property(e => e.Settings).HasDefaultValueSql("'{}'::jsonb").HasColumnType("jsonb").HasColumnName("settings");
|
||||
entity.Property(e => e.Metadata).HasDefaultValueSql("'{}'::jsonb").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");
|
||||
entity.Property(e => e.UpdatedBy).HasColumnName("updated_by");
|
||||
});
|
||||
|
||||
// ── roles ────────────────────────────────────────────────────────
|
||||
modelBuilder.Entity<RoleEfEntity>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id).HasName("roles_pkey");
|
||||
entity.ToTable("roles", schemaName);
|
||||
|
||||
entity.HasIndex(e => e.TenantId, "idx_roles_tenant_id");
|
||||
entity.HasIndex(e => new { e.TenantId, e.Name }, "roles_tenant_id_name_key").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.IsSystem).HasDefaultValue(false).HasColumnName("is_system");
|
||||
entity.Property(e => e.Metadata).HasDefaultValueSql("'{}'::jsonb").HasColumnType("jsonb").HasColumnName("metadata");
|
||||
entity.Property(e => e.CreatedAt).HasDefaultValueSql("now()").HasColumnName("created_at");
|
||||
entity.Property(e => e.UpdatedAt).HasDefaultValueSql("now()").HasColumnName("updated_at");
|
||||
});
|
||||
|
||||
// ── permissions ──────────────────────────────────────────────────
|
||||
modelBuilder.Entity<PermissionEfEntity>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id).HasName("permissions_pkey");
|
||||
entity.ToTable("permissions", schemaName);
|
||||
|
||||
entity.HasIndex(e => e.TenantId, "idx_permissions_tenant_id");
|
||||
entity.HasIndex(e => new { e.TenantId, e.Resource }, "idx_permissions_resource");
|
||||
entity.HasIndex(e => new { e.TenantId, e.Name }, "permissions_tenant_id_name_key").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.Resource).HasColumnName("resource");
|
||||
entity.Property(e => e.Action).HasColumnName("action");
|
||||
entity.Property(e => e.Description).HasColumnName("description");
|
||||
entity.Property(e => e.CreatedAt).HasDefaultValueSql("now()").HasColumnName("created_at");
|
||||
});
|
||||
|
||||
// ── role_permissions ─────────────────────────────────────────────
|
||||
modelBuilder.Entity<RolePermissionEfEntity>(entity =>
|
||||
{
|
||||
entity.HasKey(e => new { e.RoleId, e.PermissionId }).HasName("role_permissions_pkey");
|
||||
entity.ToTable("role_permissions", schemaName);
|
||||
|
||||
entity.Property(e => e.RoleId).HasColumnName("role_id");
|
||||
entity.Property(e => e.PermissionId).HasColumnName("permission_id");
|
||||
entity.Property(e => e.CreatedAt).HasDefaultValueSql("now()").HasColumnName("created_at");
|
||||
});
|
||||
|
||||
// ── user_roles ───────────────────────────────────────────────────
|
||||
modelBuilder.Entity<UserRoleEfEntity>(entity =>
|
||||
{
|
||||
entity.HasKey(e => new { e.UserId, e.RoleId }).HasName("user_roles_pkey");
|
||||
entity.ToTable("user_roles", schemaName);
|
||||
|
||||
entity.Property(e => e.UserId).HasColumnName("user_id");
|
||||
entity.Property(e => e.RoleId).HasColumnName("role_id");
|
||||
entity.Property(e => e.GrantedAt).HasDefaultValueSql("now()").HasColumnName("granted_at");
|
||||
entity.Property(e => e.GrantedBy).HasColumnName("granted_by");
|
||||
entity.Property(e => e.ExpiresAt).HasColumnName("expires_at");
|
||||
});
|
||||
|
||||
// ── api_keys ────────────────────────────────────────────────────
|
||||
modelBuilder.Entity<ApiKeyEfEntity>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id).HasName("api_keys_pkey");
|
||||
entity.ToTable("api_keys", schemaName);
|
||||
|
||||
entity.HasIndex(e => e.TenantId, "idx_api_keys_tenant_id");
|
||||
entity.HasIndex(e => e.KeyPrefix, "idx_api_keys_key_prefix");
|
||||
entity.HasIndex(e => e.UserId, "idx_api_keys_user_id");
|
||||
entity.HasIndex(e => new { e.TenantId, e.Status }, "idx_api_keys_status");
|
||||
|
||||
entity.Property(e => e.Id).HasDefaultValueSql("gen_random_uuid()").HasColumnName("id");
|
||||
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
|
||||
entity.Property(e => e.UserId).HasColumnName("user_id");
|
||||
entity.Property(e => e.Name).HasColumnName("name");
|
||||
entity.Property(e => e.KeyHash).HasColumnName("key_hash");
|
||||
entity.Property(e => e.KeyPrefix).HasColumnName("key_prefix");
|
||||
entity.Property(e => e.Scopes).HasDefaultValueSql("'{}'::text[]").HasColumnName("scopes");
|
||||
entity.Property(e => e.Status).HasDefaultValueSql("'active'").HasColumnName("status");
|
||||
entity.Property(e => e.LastUsedAt).HasColumnName("last_used_at");
|
||||
entity.Property(e => e.ExpiresAt).HasColumnName("expires_at");
|
||||
entity.Property(e => e.Metadata).HasDefaultValueSql("'{}'::jsonb").HasColumnType("jsonb").HasColumnName("metadata");
|
||||
entity.Property(e => e.CreatedAt).HasDefaultValueSql("now()").HasColumnName("created_at");
|
||||
entity.Property(e => e.RevokedAt).HasColumnName("revoked_at");
|
||||
entity.Property(e => e.RevokedBy).HasColumnName("revoked_by");
|
||||
});
|
||||
|
||||
// ── tokens ──────────────────────────────────────────────────────
|
||||
modelBuilder.Entity<TokenEfEntity>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id).HasName("tokens_pkey");
|
||||
entity.ToTable("tokens", schemaName);
|
||||
|
||||
entity.HasIndex(e => e.TenantId, "idx_tokens_tenant_id");
|
||||
entity.HasIndex(e => e.UserId, "idx_tokens_user_id");
|
||||
entity.HasIndex(e => e.ExpiresAt, "idx_tokens_expires_at");
|
||||
entity.HasIndex(e => e.TokenHash, "idx_tokens_token_hash");
|
||||
entity.HasAlternateKey(e => e.TokenHash).HasName("tokens_token_hash_key");
|
||||
|
||||
entity.Property(e => e.Id).HasDefaultValueSql("gen_random_uuid()").HasColumnName("id");
|
||||
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
|
||||
entity.Property(e => e.UserId).HasColumnName("user_id");
|
||||
entity.Property(e => e.TokenHash).HasColumnName("token_hash");
|
||||
entity.Property(e => e.TokenType).HasDefaultValueSql("'access'").HasColumnName("token_type");
|
||||
entity.Property(e => e.Scopes).HasDefaultValueSql("'{}'::text[]").HasColumnName("scopes");
|
||||
entity.Property(e => e.ClientId).HasColumnName("client_id");
|
||||
entity.Property(e => e.IssuedAt).HasDefaultValueSql("now()").HasColumnName("issued_at");
|
||||
entity.Property(e => e.ExpiresAt).HasColumnName("expires_at");
|
||||
entity.Property(e => e.RevokedAt).HasColumnName("revoked_at");
|
||||
entity.Property(e => e.RevokedBy).HasColumnName("revoked_by");
|
||||
entity.Property(e => e.Metadata).HasDefaultValueSql("'{}'::jsonb").HasColumnType("jsonb").HasColumnName("metadata");
|
||||
});
|
||||
|
||||
// ── refresh_tokens ──────────────────────────────────────────────
|
||||
modelBuilder.Entity<RefreshTokenEfEntity>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id).HasName("refresh_tokens_pkey");
|
||||
entity.ToTable("refresh_tokens", schemaName);
|
||||
|
||||
entity.HasIndex(e => e.TenantId, "idx_refresh_tokens_tenant_id");
|
||||
entity.HasIndex(e => e.UserId, "idx_refresh_tokens_user_id");
|
||||
entity.HasIndex(e => e.ExpiresAt, "idx_refresh_tokens_expires_at");
|
||||
entity.HasAlternateKey(e => e.TokenHash).HasName("refresh_tokens_token_hash_key");
|
||||
|
||||
entity.Property(e => e.Id).HasDefaultValueSql("gen_random_uuid()").HasColumnName("id");
|
||||
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
|
||||
entity.Property(e => e.UserId).HasColumnName("user_id");
|
||||
entity.Property(e => e.TokenHash).HasColumnName("token_hash");
|
||||
entity.Property(e => e.AccessTokenId).HasColumnName("access_token_id");
|
||||
entity.Property(e => e.ClientId).HasColumnName("client_id");
|
||||
entity.Property(e => e.IssuedAt).HasDefaultValueSql("now()").HasColumnName("issued_at");
|
||||
entity.Property(e => e.ExpiresAt).HasColumnName("expires_at");
|
||||
entity.Property(e => e.RevokedAt).HasColumnName("revoked_at");
|
||||
entity.Property(e => e.RevokedBy).HasColumnName("revoked_by");
|
||||
entity.Property(e => e.ReplacedBy).HasColumnName("replaced_by");
|
||||
entity.Property(e => e.Metadata).HasDefaultValueSql("'{}'::jsonb").HasColumnType("jsonb").HasColumnName("metadata");
|
||||
});
|
||||
|
||||
// ── sessions ────────────────────────────────────────────────────
|
||||
modelBuilder.Entity<SessionEfEntity>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id).HasName("sessions_pkey");
|
||||
entity.ToTable("sessions", schemaName);
|
||||
|
||||
entity.HasIndex(e => e.TenantId, "idx_sessions_tenant_id");
|
||||
entity.HasIndex(e => e.UserId, "idx_sessions_user_id");
|
||||
entity.HasIndex(e => e.ExpiresAt, "idx_sessions_expires_at");
|
||||
entity.HasAlternateKey(e => e.SessionTokenHash).HasName("sessions_session_token_hash_key");
|
||||
|
||||
entity.Property(e => e.Id).HasDefaultValueSql("gen_random_uuid()").HasColumnName("id");
|
||||
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
|
||||
entity.Property(e => e.UserId).HasColumnName("user_id");
|
||||
entity.Property(e => e.SessionTokenHash).HasColumnName("session_token_hash");
|
||||
entity.Property(e => e.IpAddress).HasColumnName("ip_address");
|
||||
entity.Property(e => e.UserAgent).HasColumnName("user_agent");
|
||||
entity.Property(e => e.StartedAt).HasDefaultValueSql("now()").HasColumnName("started_at");
|
||||
entity.Property(e => e.LastActivityAt).HasDefaultValueSql("now()").HasColumnName("last_activity_at");
|
||||
entity.Property(e => e.ExpiresAt).HasColumnName("expires_at");
|
||||
entity.Property(e => e.EndedAt).HasColumnName("ended_at");
|
||||
entity.Property(e => e.EndReason).HasColumnName("end_reason");
|
||||
entity.Property(e => e.Metadata).HasDefaultValueSql("'{}'::jsonb").HasColumnType("jsonb").HasColumnName("metadata");
|
||||
});
|
||||
|
||||
// ── audit ───────────────────────────────────────────────────────
|
||||
modelBuilder.Entity<AuditEfEntity>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id).HasName("audit_pkey");
|
||||
entity.ToTable("audit", schemaName);
|
||||
|
||||
entity.HasIndex(e => e.TenantId, "idx_audit_tenant_id");
|
||||
entity.HasIndex(e => e.UserId, "idx_audit_user_id");
|
||||
entity.HasIndex(e => e.Action, "idx_audit_action");
|
||||
entity.HasIndex(e => new { e.ResourceType, e.ResourceId }, "idx_audit_resource");
|
||||
entity.HasIndex(e => e.CreatedAt, "idx_audit_created_at");
|
||||
entity.HasIndex(e => e.CorrelationId, "idx_audit_correlation_id");
|
||||
|
||||
entity.Property(e => e.Id)
|
||||
.UseIdentityByDefaultColumn()
|
||||
.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.IpAddress).HasColumnName("ip_address");
|
||||
entity.Property(e => e.UserAgent).HasColumnName("user_agent");
|
||||
entity.Property(e => e.CorrelationId).HasColumnName("correlation_id");
|
||||
entity.Property(e => e.CreatedAt).HasDefaultValueSql("now()").HasColumnName("created_at");
|
||||
});
|
||||
|
||||
// ── bootstrap_invites ───────────────────────────────────────────
|
||||
modelBuilder.Entity<BootstrapInviteEfEntity>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id).HasName("bootstrap_invites_pkey");
|
||||
entity.ToTable("bootstrap_invites", schemaName);
|
||||
|
||||
entity.HasAlternateKey(e => e.Token).HasName("bootstrap_invites_token_key");
|
||||
|
||||
entity.Property(e => e.Id).HasColumnName("id");
|
||||
entity.Property(e => e.Token).HasColumnName("token");
|
||||
entity.Property(e => e.Type).HasColumnName("type");
|
||||
entity.Property(e => e.Provider).HasColumnName("provider");
|
||||
entity.Property(e => e.Target).HasColumnName("target");
|
||||
entity.Property(e => e.ExpiresAt).HasColumnName("expires_at");
|
||||
entity.Property(e => e.CreatedAt).HasDefaultValueSql("now()").HasColumnName("created_at");
|
||||
entity.Property(e => e.IssuedAt).HasDefaultValueSql("now()").HasColumnName("issued_at");
|
||||
entity.Property(e => e.IssuedBy).HasColumnName("issued_by");
|
||||
entity.Property(e => e.ReservedUntil).HasColumnName("reserved_until");
|
||||
entity.Property(e => e.ReservedBy).HasColumnName("reserved_by");
|
||||
entity.Property(e => e.Consumed).HasDefaultValue(false).HasColumnName("consumed");
|
||||
entity.Property(e => e.Status).HasDefaultValueSql("'pending'").HasColumnName("status");
|
||||
entity.Property(e => e.Metadata).HasDefaultValueSql("'{}'::jsonb").HasColumnType("jsonb").HasColumnName("metadata");
|
||||
});
|
||||
|
||||
// ── service_accounts ────────────────────────────────────────────
|
||||
modelBuilder.Entity<ServiceAccountEfEntity>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id).HasName("service_accounts_pkey");
|
||||
entity.ToTable("service_accounts", schemaName);
|
||||
|
||||
entity.HasAlternateKey(e => e.AccountId).HasName("service_accounts_account_id_key");
|
||||
entity.HasIndex(e => e.Tenant, "idx_service_accounts_tenant");
|
||||
|
||||
entity.Property(e => e.Id).HasColumnName("id");
|
||||
entity.Property(e => e.AccountId).HasColumnName("account_id");
|
||||
entity.Property(e => e.Tenant).HasColumnName("tenant");
|
||||
entity.Property(e => e.DisplayName).HasColumnName("display_name");
|
||||
entity.Property(e => e.Description).HasColumnName("description");
|
||||
entity.Property(e => e.Enabled).HasDefaultValue(true).HasColumnName("enabled");
|
||||
entity.Property(e => e.AllowedScopes).HasDefaultValueSql("'{}'::text[]").HasColumnName("allowed_scopes");
|
||||
entity.Property(e => e.AuthorizedClients).HasDefaultValueSql("'{}'::text[]").HasColumnName("authorized_clients");
|
||||
entity.Property(e => e.Attributes).HasDefaultValueSql("'{}'::jsonb").HasColumnType("jsonb").HasColumnName("attributes");
|
||||
entity.Property(e => e.CreatedAt).HasDefaultValueSql("now()").HasColumnName("created_at");
|
||||
entity.Property(e => e.UpdatedAt).HasDefaultValueSql("now()").HasColumnName("updated_at");
|
||||
});
|
||||
|
||||
// ── clients ─────────────────────────────────────────────────────
|
||||
modelBuilder.Entity<ClientEfEntity>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id).HasName("clients_pkey");
|
||||
entity.ToTable("clients", schemaName);
|
||||
|
||||
entity.HasAlternateKey(e => e.ClientId).HasName("clients_client_id_key");
|
||||
|
||||
entity.Property(e => e.Id).HasColumnName("id");
|
||||
entity.Property(e => e.ClientId).HasColumnName("client_id");
|
||||
entity.Property(e => e.ClientSecret).HasColumnName("client_secret");
|
||||
entity.Property(e => e.SecretHash).HasColumnName("secret_hash");
|
||||
entity.Property(e => e.DisplayName).HasColumnName("display_name");
|
||||
entity.Property(e => e.Description).HasColumnName("description");
|
||||
entity.Property(e => e.Plugin).HasColumnName("plugin");
|
||||
entity.Property(e => e.SenderConstraint).HasColumnName("sender_constraint");
|
||||
entity.Property(e => e.Enabled).HasDefaultValue(true).HasColumnName("enabled");
|
||||
entity.Property(e => e.RedirectUris).HasDefaultValueSql("'{}'::text[]").HasColumnName("redirect_uris");
|
||||
entity.Property(e => e.PostLogoutRedirectUris).HasDefaultValueSql("'{}'::text[]").HasColumnName("post_logout_redirect_uris");
|
||||
entity.Property(e => e.AllowedScopes).HasDefaultValueSql("'{}'::text[]").HasColumnName("allowed_scopes");
|
||||
entity.Property(e => e.AllowedGrantTypes).HasDefaultValueSql("'{}'::text[]").HasColumnName("allowed_grant_types");
|
||||
entity.Property(e => e.RequireClientSecret).HasDefaultValue(true).HasColumnName("require_client_secret");
|
||||
entity.Property(e => e.RequirePkce).HasDefaultValue(false).HasColumnName("require_pkce");
|
||||
entity.Property(e => e.AllowPlainTextPkce).HasDefaultValue(false).HasColumnName("allow_plain_text_pkce");
|
||||
entity.Property(e => e.ClientType).HasColumnName("client_type");
|
||||
entity.Property(e => e.Properties).HasDefaultValueSql("'{}'::jsonb").HasColumnType("jsonb").HasColumnName("properties");
|
||||
entity.Property(e => e.CertificateBindings).HasDefaultValueSql("'[]'::jsonb").HasColumnType("jsonb").HasColumnName("certificate_bindings");
|
||||
entity.Property(e => e.CreatedAt).HasDefaultValueSql("now()").HasColumnName("created_at");
|
||||
entity.Property(e => e.UpdatedAt).HasDefaultValueSql("now()").HasColumnName("updated_at");
|
||||
});
|
||||
|
||||
// ── revocations ─────────────────────────────────────────────────
|
||||
modelBuilder.Entity<RevocationEfEntity>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id).HasName("revocations_pkey");
|
||||
entity.ToTable("revocations", schemaName);
|
||||
|
||||
entity.HasIndex(e => new { e.Category, e.RevocationId }, "idx_revocations_category_revocation_id").IsUnique();
|
||||
|
||||
entity.Property(e => e.Id).HasColumnName("id");
|
||||
entity.Property(e => e.Category).HasColumnName("category");
|
||||
entity.Property(e => e.RevocationId).HasColumnName("revocation_id");
|
||||
entity.Property(e => e.SubjectId).HasColumnName("subject_id");
|
||||
entity.Property(e => e.ClientId).HasColumnName("client_id");
|
||||
entity.Property(e => e.TokenId).HasColumnName("token_id");
|
||||
entity.Property(e => e.Reason).HasColumnName("reason");
|
||||
entity.Property(e => e.ReasonDescription).HasColumnName("reason_description");
|
||||
entity.Property(e => e.RevokedAt).HasColumnName("revoked_at");
|
||||
entity.Property(e => e.EffectiveAt).HasColumnName("effective_at");
|
||||
entity.Property(e => e.ExpiresAt).HasColumnName("expires_at");
|
||||
entity.Property(e => e.Metadata).HasDefaultValueSql("'{}'::jsonb").HasColumnType("jsonb").HasColumnName("metadata");
|
||||
});
|
||||
|
||||
// ── login_attempts ──────────────────────────────────────────────
|
||||
modelBuilder.Entity<LoginAttemptEfEntity>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id).HasName("login_attempts_pkey");
|
||||
entity.ToTable("login_attempts", schemaName);
|
||||
|
||||
entity.HasIndex(e => new { e.SubjectId, e.OccurredAt }, "idx_login_attempts_subject")
|
||||
.IsDescending(false, true);
|
||||
|
||||
entity.Property(e => e.Id).HasColumnName("id");
|
||||
entity.Property(e => e.SubjectId).HasColumnName("subject_id");
|
||||
entity.Property(e => e.ClientId).HasColumnName("client_id");
|
||||
entity.Property(e => e.EventType).HasColumnName("event_type");
|
||||
entity.Property(e => e.Outcome).HasColumnName("outcome");
|
||||
entity.Property(e => e.Reason).HasColumnName("reason");
|
||||
entity.Property(e => e.IpAddress).HasColumnName("ip_address");
|
||||
entity.Property(e => e.UserAgent).HasColumnName("user_agent");
|
||||
entity.Property(e => e.OccurredAt).HasColumnName("occurred_at");
|
||||
entity.Property(e => e.Properties).HasDefaultValueSql("'[]'::jsonb").HasColumnType("jsonb").HasColumnName("properties");
|
||||
});
|
||||
|
||||
// ── oidc_tokens ─────────────────────────────────────────────────
|
||||
modelBuilder.Entity<OidcTokenEfEntity>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id).HasName("oidc_tokens_pkey");
|
||||
entity.ToTable("oidc_tokens", schemaName);
|
||||
|
||||
entity.HasAlternateKey(e => e.TokenId).HasName("oidc_tokens_token_id_key");
|
||||
entity.HasIndex(e => e.SubjectId, "idx_oidc_tokens_subject");
|
||||
entity.HasIndex(e => e.ClientId, "idx_oidc_tokens_client");
|
||||
entity.HasIndex(e => e.ReferenceId, "idx_oidc_tokens_reference");
|
||||
|
||||
entity.Property(e => e.Id).HasColumnName("id");
|
||||
entity.Property(e => e.TokenId).HasColumnName("token_id");
|
||||
entity.Property(e => e.SubjectId).HasColumnName("subject_id");
|
||||
entity.Property(e => e.ClientId).HasColumnName("client_id");
|
||||
entity.Property(e => e.TokenType).HasColumnName("token_type");
|
||||
entity.Property(e => e.ReferenceId).HasColumnName("reference_id");
|
||||
entity.Property(e => e.CreatedAt).HasColumnName("created_at");
|
||||
entity.Property(e => e.ExpiresAt).HasColumnName("expires_at");
|
||||
entity.Property(e => e.RedeemedAt).HasColumnName("redeemed_at");
|
||||
entity.Property(e => e.Payload).HasColumnName("payload");
|
||||
entity.Property(e => e.Properties).HasDefaultValueSql("'{}'::jsonb").HasColumnType("jsonb").HasColumnName("properties");
|
||||
});
|
||||
|
||||
// ── oidc_refresh_tokens ─────────────────────────────────────────
|
||||
modelBuilder.Entity<OidcRefreshTokenEfEntity>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id).HasName("oidc_refresh_tokens_pkey");
|
||||
entity.ToTable("oidc_refresh_tokens", schemaName);
|
||||
|
||||
entity.HasAlternateKey(e => e.TokenId).HasName("oidc_refresh_tokens_token_id_key");
|
||||
entity.HasIndex(e => e.SubjectId, "idx_oidc_refresh_tokens_subject");
|
||||
entity.HasIndex(e => e.Handle, "idx_oidc_refresh_tokens_handle");
|
||||
|
||||
entity.Property(e => e.Id).HasColumnName("id");
|
||||
entity.Property(e => e.TokenId).HasColumnName("token_id");
|
||||
entity.Property(e => e.SubjectId).HasColumnName("subject_id");
|
||||
entity.Property(e => e.ClientId).HasColumnName("client_id");
|
||||
entity.Property(e => e.Handle).HasColumnName("handle");
|
||||
entity.Property(e => e.CreatedAt).HasColumnName("created_at");
|
||||
entity.Property(e => e.ExpiresAt).HasColumnName("expires_at");
|
||||
entity.Property(e => e.ConsumedAt).HasColumnName("consumed_at");
|
||||
entity.Property(e => e.Payload).HasColumnName("payload");
|
||||
});
|
||||
|
||||
// ── airgap_audit ────────────────────────────────────────────────
|
||||
modelBuilder.Entity<AirgapAuditEfEntity>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id).HasName("airgap_audit_pkey");
|
||||
entity.ToTable("airgap_audit", schemaName);
|
||||
|
||||
entity.HasIndex(e => e.OccurredAt, "idx_airgap_audit_occurred_at").IsDescending(true);
|
||||
|
||||
entity.Property(e => e.Id).HasColumnName("id");
|
||||
entity.Property(e => e.EventType).HasColumnName("event_type");
|
||||
entity.Property(e => e.OperatorId).HasColumnName("operator_id");
|
||||
entity.Property(e => e.ComponentId).HasColumnName("component_id");
|
||||
entity.Property(e => e.Outcome).HasColumnName("outcome");
|
||||
entity.Property(e => e.Reason).HasColumnName("reason");
|
||||
entity.Property(e => e.OccurredAt).HasColumnName("occurred_at");
|
||||
entity.Property(e => e.Properties).HasDefaultValueSql("'[]'::jsonb").HasColumnType("jsonb").HasColumnName("properties");
|
||||
});
|
||||
|
||||
// ── revocation_export_state ─────────────────────────────────────
|
||||
modelBuilder.Entity<RevocationExportStateEfEntity>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id).HasName("revocation_export_state_pkey");
|
||||
entity.ToTable("revocation_export_state", schemaName);
|
||||
|
||||
entity.Property(e => e.Id).HasDefaultValue(1).HasColumnName("id");
|
||||
entity.Property(e => e.Sequence).HasDefaultValue(0L).HasColumnName("sequence");
|
||||
entity.Property(e => e.BundleId).HasColumnName("bundle_id");
|
||||
entity.Property(e => e.IssuedAt).HasColumnName("issued_at");
|
||||
});
|
||||
|
||||
// ── offline_kit_audit ───────────────────────────────────────────
|
||||
modelBuilder.Entity<OfflineKitAuditEfEntity>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.EventId).HasName("offline_kit_audit_pkey");
|
||||
entity.ToTable("offline_kit_audit", schemaName);
|
||||
|
||||
entity.HasIndex(e => e.Timestamp, "idx_offline_kit_audit_ts").IsDescending(true);
|
||||
entity.HasIndex(e => e.EventType, "idx_offline_kit_audit_type");
|
||||
entity.HasIndex(e => new { e.TenantId, e.Timestamp }, "idx_offline_kit_audit_tenant_ts").IsDescending(false, true);
|
||||
entity.HasIndex(e => new { e.TenantId, e.Result, e.Timestamp }, "idx_offline_kit_audit_result").IsDescending(false, false, true);
|
||||
|
||||
entity.Property(e => e.EventId).HasColumnName("event_id");
|
||||
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
|
||||
entity.Property(e => e.EventType).HasColumnName("event_type");
|
||||
entity.Property(e => e.Timestamp).HasColumnName("timestamp");
|
||||
entity.Property(e => e.Actor).HasColumnName("actor");
|
||||
entity.Property(e => e.Details).HasColumnType("jsonb").HasColumnName("details");
|
||||
entity.Property(e => e.Result).HasColumnName("result");
|
||||
});
|
||||
|
||||
// ── verdict_manifests ───────────────────────────────────────────
|
||||
modelBuilder.Entity<VerdictManifestEfEntity>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id).HasName("verdict_manifests_pkey");
|
||||
entity.ToTable("verdict_manifests", schemaName);
|
||||
|
||||
entity.HasIndex(e => new { e.Tenant, e.ManifestId }, "uq_verdict_manifest_id").IsUnique();
|
||||
entity.HasIndex(e => new { e.Tenant, e.AssetDigest, e.VulnerabilityId }, "idx_verdict_asset_vuln");
|
||||
entity.HasIndex(e => new { e.Tenant, e.PolicyHash, e.LatticeVersion }, "idx_verdict_policy");
|
||||
entity.HasIndex(e => new { e.Tenant, e.AssetDigest, e.VulnerabilityId, e.PolicyHash, e.LatticeVersion }, "idx_verdict_replay").IsUnique();
|
||||
entity.HasIndex(e => e.ManifestDigest, "idx_verdict_digest");
|
||||
|
||||
entity.Property(e => e.Id).HasDefaultValueSql("gen_random_uuid()").HasColumnName("id");
|
||||
entity.Property(e => e.ManifestId).HasColumnName("manifest_id");
|
||||
entity.Property(e => e.Tenant).HasColumnName("tenant");
|
||||
entity.Property(e => e.AssetDigest).HasColumnName("asset_digest");
|
||||
entity.Property(e => e.VulnerabilityId).HasColumnName("vulnerability_id");
|
||||
entity.Property(e => e.InputsJson).HasColumnType("jsonb").HasColumnName("inputs_json");
|
||||
entity.Property(e => e.Status).HasColumnName("status");
|
||||
entity.Property(e => e.Confidence).HasColumnName("confidence");
|
||||
entity.Property(e => e.ResultJson).HasColumnType("jsonb").HasColumnName("result_json");
|
||||
entity.Property(e => e.PolicyHash).HasColumnName("policy_hash");
|
||||
entity.Property(e => e.LatticeVersion).HasColumnName("lattice_version");
|
||||
entity.Property(e => e.EvaluatedAt).HasColumnName("evaluated_at");
|
||||
entity.Property(e => e.ManifestDigest).HasColumnName("manifest_digest");
|
||||
entity.Property(e => e.SignatureBase64).HasColumnName("signature_base64");
|
||||
entity.Property(e => e.RekorLogId).HasColumnName("rekor_log_id");
|
||||
entity.Property(e => e.CreatedAt).HasDefaultValueSql("now()").HasColumnName("created_at");
|
||||
});
|
||||
|
||||
OnModelCreatingPartial(modelBuilder);
|
||||
}
|
||||
|
||||
partial void OnModelCreatingPartial(ModelBuilder modelBuilder);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Design;
|
||||
|
||||
namespace StellaOps.Authority.Persistence.EfCore.Context;
|
||||
|
||||
/// <summary>
|
||||
/// Design-time factory for <see cref="AuthorityDbContext"/>.
|
||||
/// Used by <c>dotnet ef</c> CLI tooling (scaffold, optimize).
|
||||
/// Does NOT use compiled models (uses reflection-based discovery).
|
||||
/// </summary>
|
||||
public sealed class AuthorityDesignTimeDbContextFactory : IDesignTimeDbContextFactory<AuthorityDbContext>
|
||||
{
|
||||
private const string DefaultConnectionString =
|
||||
"Host=localhost;Port=55433;Database=postgres;Username=postgres;Password=postgres;Search Path=authority,public";
|
||||
|
||||
private const string ConnectionStringEnvironmentVariable = "STELLAOPS_AUTHORITY_EF_CONNECTION";
|
||||
|
||||
public AuthorityDbContext CreateDbContext(string[] args)
|
||||
{
|
||||
var connectionString = ResolveConnectionString();
|
||||
var options = new DbContextOptionsBuilder<AuthorityDbContext>()
|
||||
.UseNpgsql(connectionString)
|
||||
.Options;
|
||||
|
||||
return new AuthorityDbContext(options);
|
||||
}
|
||||
|
||||
private static string ResolveConnectionString()
|
||||
{
|
||||
var fromEnvironment = Environment.GetEnvironmentVariable(ConnectionStringEnvironmentVariable);
|
||||
return string.IsNullOrWhiteSpace(fromEnvironment) ? DefaultConnectionString : fromEnvironment;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
namespace StellaOps.Authority.Persistence.EfCore.Models;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core entity for the authority.tenants table.
|
||||
/// </summary>
|
||||
public class TenantEfEntity
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string TenantId { get; set; } = null!;
|
||||
public string Name { get; set; } = null!;
|
||||
public string? DisplayName { get; set; }
|
||||
public string Status { get; set; } = "active";
|
||||
public string Settings { get; set; } = "{}";
|
||||
public string Metadata { get; set; } = "{}";
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
public DateTimeOffset UpdatedAt { get; set; }
|
||||
public string? CreatedBy { get; set; }
|
||||
public string? UpdatedBy { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EF Core entity for the authority.users table.
|
||||
/// </summary>
|
||||
public class UserEfEntity
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string TenantId { get; set; } = null!;
|
||||
public string Username { get; set; } = null!;
|
||||
public string? Email { get; set; }
|
||||
public string? DisplayName { get; set; }
|
||||
public string? PasswordHash { get; set; }
|
||||
public string? PasswordSalt { get; set; }
|
||||
public bool Enabled { get; set; } = true;
|
||||
public string? PasswordAlgorithm { get; set; }
|
||||
public string Status { get; set; } = "active";
|
||||
public bool EmailVerified { get; set; }
|
||||
public bool MfaEnabled { get; set; }
|
||||
public string? MfaSecret { get; set; }
|
||||
public string? MfaBackupCodes { get; set; }
|
||||
public int FailedLoginAttempts { get; set; }
|
||||
public DateTimeOffset? LockedUntil { get; set; }
|
||||
public DateTimeOffset? LastLoginAt { get; set; }
|
||||
public DateTimeOffset? PasswordChangedAt { get; set; }
|
||||
public DateTimeOffset? LastPasswordChangeAt { get; set; }
|
||||
public DateTimeOffset? PasswordExpiresAt { get; set; }
|
||||
public string Settings { get; set; } = "{}";
|
||||
public string Metadata { get; set; } = "{}";
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
public DateTimeOffset UpdatedAt { get; set; }
|
||||
public string? CreatedBy { get; set; }
|
||||
public string? UpdatedBy { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EF Core entity for the authority.roles table.
|
||||
/// </summary>
|
||||
public class RoleEfEntity
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string TenantId { get; set; } = null!;
|
||||
public string Name { get; set; } = null!;
|
||||
public string? DisplayName { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public bool IsSystem { get; set; }
|
||||
public string Metadata { get; set; } = "{}";
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
public DateTimeOffset UpdatedAt { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EF Core entity for the authority.permissions table.
|
||||
/// </summary>
|
||||
public class PermissionEfEntity
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string TenantId { get; set; } = null!;
|
||||
public string Name { get; set; } = null!;
|
||||
public string Resource { get; set; } = null!;
|
||||
public string Action { get; set; } = null!;
|
||||
public string? Description { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EF Core entity for the authority.role_permissions join table.
|
||||
/// </summary>
|
||||
public class RolePermissionEfEntity
|
||||
{
|
||||
public Guid RoleId { get; set; }
|
||||
public Guid PermissionId { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EF Core entity for the authority.user_roles join table.
|
||||
/// </summary>
|
||||
public class UserRoleEfEntity
|
||||
{
|
||||
public Guid UserId { get; set; }
|
||||
public Guid RoleId { get; set; }
|
||||
public DateTimeOffset GrantedAt { get; set; }
|
||||
public string? GrantedBy { get; set; }
|
||||
public DateTimeOffset? ExpiresAt { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EF Core entity for the authority.api_keys table.
|
||||
/// </summary>
|
||||
public class ApiKeyEfEntity
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string TenantId { get; set; } = null!;
|
||||
public Guid? UserId { get; set; }
|
||||
public string Name { get; set; } = null!;
|
||||
public string KeyHash { get; set; } = null!;
|
||||
public string KeyPrefix { get; set; } = null!;
|
||||
public string[] Scopes { get; set; } = [];
|
||||
public string Status { get; set; } = "active";
|
||||
public DateTimeOffset? LastUsedAt { get; set; }
|
||||
public DateTimeOffset? ExpiresAt { get; set; }
|
||||
public string Metadata { get; set; } = "{}";
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
public DateTimeOffset? RevokedAt { get; set; }
|
||||
public string? RevokedBy { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EF Core entity for the authority.tokens table.
|
||||
/// </summary>
|
||||
public class TokenEfEntity
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string TenantId { get; set; } = null!;
|
||||
public Guid? UserId { get; set; }
|
||||
public string TokenHash { get; set; } = null!;
|
||||
public string TokenType { get; set; } = "access";
|
||||
public string[] Scopes { get; set; } = [];
|
||||
public string? ClientId { get; set; }
|
||||
public DateTimeOffset IssuedAt { get; set; }
|
||||
public DateTimeOffset ExpiresAt { get; set; }
|
||||
public DateTimeOffset? RevokedAt { get; set; }
|
||||
public string? RevokedBy { get; set; }
|
||||
public string Metadata { get; set; } = "{}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EF Core entity for the authority.refresh_tokens table.
|
||||
/// </summary>
|
||||
public class RefreshTokenEfEntity
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string TenantId { get; set; } = null!;
|
||||
public Guid UserId { get; set; }
|
||||
public string TokenHash { get; set; } = null!;
|
||||
public Guid? AccessTokenId { get; set; }
|
||||
public string? ClientId { get; set; }
|
||||
public DateTimeOffset IssuedAt { get; set; }
|
||||
public DateTimeOffset ExpiresAt { get; set; }
|
||||
public DateTimeOffset? RevokedAt { get; set; }
|
||||
public string? RevokedBy { get; set; }
|
||||
public Guid? ReplacedBy { get; set; }
|
||||
public string Metadata { get; set; } = "{}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EF Core entity for the authority.sessions table.
|
||||
/// </summary>
|
||||
public class SessionEfEntity
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string TenantId { get; set; } = null!;
|
||||
public Guid UserId { get; set; }
|
||||
public string SessionTokenHash { get; set; } = null!;
|
||||
public string? IpAddress { get; set; }
|
||||
public string? UserAgent { get; set; }
|
||||
public DateTimeOffset StartedAt { get; set; }
|
||||
public DateTimeOffset LastActivityAt { get; set; }
|
||||
public DateTimeOffset ExpiresAt { get; set; }
|
||||
public DateTimeOffset? EndedAt { get; set; }
|
||||
public string? EndReason { get; set; }
|
||||
public string Metadata { get; set; } = "{}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EF Core entity for the authority.audit table.
|
||||
/// </summary>
|
||||
public class AuditEfEntity
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public string TenantId { get; set; } = null!;
|
||||
public Guid? UserId { get; set; }
|
||||
public string Action { get; set; } = null!;
|
||||
public string ResourceType { get; set; } = null!;
|
||||
public string? ResourceId { get; set; }
|
||||
public string? OldValue { get; set; }
|
||||
public string? NewValue { get; set; }
|
||||
public string? IpAddress { get; set; }
|
||||
public string? UserAgent { get; set; }
|
||||
public string? CorrelationId { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EF Core entity for the authority.bootstrap_invites table.
|
||||
/// </summary>
|
||||
public class BootstrapInviteEfEntity
|
||||
{
|
||||
public string Id { get; set; } = null!;
|
||||
public string Token { get; set; } = null!;
|
||||
public string Type { get; set; } = null!;
|
||||
public string? Provider { get; set; }
|
||||
public string? Target { get; set; }
|
||||
public DateTimeOffset ExpiresAt { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
public DateTimeOffset IssuedAt { get; set; }
|
||||
public string? IssuedBy { get; set; }
|
||||
public DateTimeOffset? ReservedUntil { get; set; }
|
||||
public string? ReservedBy { get; set; }
|
||||
public bool Consumed { get; set; }
|
||||
public string Status { get; set; } = "pending";
|
||||
public string Metadata { get; set; } = "{}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EF Core entity for the authority.service_accounts table.
|
||||
/// </summary>
|
||||
public class ServiceAccountEfEntity
|
||||
{
|
||||
public string Id { get; set; } = null!;
|
||||
public string AccountId { get; set; } = null!;
|
||||
public string Tenant { get; set; } = null!;
|
||||
public string DisplayName { get; set; } = null!;
|
||||
public string? Description { get; set; }
|
||||
public bool Enabled { get; set; } = true;
|
||||
public string[] AllowedScopes { get; set; } = [];
|
||||
public string[] AuthorizedClients { get; set; } = [];
|
||||
public string Attributes { get; set; } = "{}";
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
public DateTimeOffset UpdatedAt { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EF Core entity for the authority.clients table.
|
||||
/// </summary>
|
||||
public class ClientEfEntity
|
||||
{
|
||||
public string Id { get; set; } = null!;
|
||||
public string ClientId { get; set; } = null!;
|
||||
public string? ClientSecret { get; set; }
|
||||
public string? SecretHash { get; set; }
|
||||
public string? DisplayName { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public string? Plugin { get; set; }
|
||||
public string? SenderConstraint { get; set; }
|
||||
public bool Enabled { get; set; } = true;
|
||||
public string[] RedirectUris { get; set; } = [];
|
||||
public string[] PostLogoutRedirectUris { get; set; } = [];
|
||||
public string[] AllowedScopes { get; set; } = [];
|
||||
public string[] AllowedGrantTypes { get; set; } = [];
|
||||
public bool RequireClientSecret { get; set; } = true;
|
||||
public bool RequirePkce { get; set; }
|
||||
public bool AllowPlainTextPkce { get; set; }
|
||||
public string? ClientType { get; set; }
|
||||
public string Properties { get; set; } = "{}";
|
||||
public string CertificateBindings { get; set; } = "[]";
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
public DateTimeOffset UpdatedAt { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EF Core entity for the authority.revocations table.
|
||||
/// </summary>
|
||||
public class RevocationEfEntity
|
||||
{
|
||||
public string Id { get; set; } = null!;
|
||||
public string Category { get; set; } = null!;
|
||||
public string RevocationId { get; set; } = null!;
|
||||
public string? SubjectId { get; set; }
|
||||
public string? ClientId { get; set; }
|
||||
public string? TokenId { get; set; }
|
||||
public string Reason { get; set; } = null!;
|
||||
public string? ReasonDescription { get; set; }
|
||||
public DateTimeOffset RevokedAt { get; set; }
|
||||
public DateTimeOffset EffectiveAt { get; set; }
|
||||
public DateTimeOffset? ExpiresAt { get; set; }
|
||||
public string Metadata { get; set; } = "{}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EF Core entity for the authority.login_attempts table.
|
||||
/// </summary>
|
||||
public class LoginAttemptEfEntity
|
||||
{
|
||||
public string Id { get; set; } = null!;
|
||||
public string? SubjectId { get; set; }
|
||||
public string? ClientId { get; set; }
|
||||
public string EventType { get; set; } = null!;
|
||||
public string Outcome { get; set; } = null!;
|
||||
public string? Reason { get; set; }
|
||||
public string? IpAddress { get; set; }
|
||||
public string? UserAgent { get; set; }
|
||||
public DateTimeOffset OccurredAt { get; set; }
|
||||
public string Properties { get; set; } = "[]";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EF Core entity for the authority.oidc_tokens table.
|
||||
/// </summary>
|
||||
public class OidcTokenEfEntity
|
||||
{
|
||||
public string Id { get; set; } = null!;
|
||||
public string TokenId { get; set; } = null!;
|
||||
public string? SubjectId { get; set; }
|
||||
public string? ClientId { get; set; }
|
||||
public string TokenType { get; set; } = null!;
|
||||
public string? ReferenceId { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
public DateTimeOffset? ExpiresAt { get; set; }
|
||||
public DateTimeOffset? RedeemedAt { get; set; }
|
||||
public string? Payload { get; set; }
|
||||
public string Properties { get; set; } = "{}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EF Core entity for the authority.oidc_refresh_tokens table.
|
||||
/// </summary>
|
||||
public class OidcRefreshTokenEfEntity
|
||||
{
|
||||
public string Id { get; set; } = null!;
|
||||
public string TokenId { get; set; } = null!;
|
||||
public string? SubjectId { get; set; }
|
||||
public string? ClientId { get; set; }
|
||||
public string? Handle { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
public DateTimeOffset? ExpiresAt { get; set; }
|
||||
public DateTimeOffset? ConsumedAt { get; set; }
|
||||
public string? Payload { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EF Core entity for the authority.airgap_audit table.
|
||||
/// </summary>
|
||||
public class AirgapAuditEfEntity
|
||||
{
|
||||
public string Id { get; set; } = null!;
|
||||
public string EventType { get; set; } = null!;
|
||||
public string? OperatorId { get; set; }
|
||||
public string? ComponentId { get; set; }
|
||||
public string Outcome { get; set; } = null!;
|
||||
public string? Reason { get; set; }
|
||||
public DateTimeOffset OccurredAt { get; set; }
|
||||
public string Properties { get; set; } = "[]";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EF Core entity for the authority.revocation_export_state table.
|
||||
/// </summary>
|
||||
public class RevocationExportStateEfEntity
|
||||
{
|
||||
public int Id { get; set; } = 1;
|
||||
public long Sequence { get; set; }
|
||||
public string? BundleId { get; set; }
|
||||
public DateTimeOffset? IssuedAt { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EF Core entity for the authority.offline_kit_audit table.
|
||||
/// </summary>
|
||||
public class OfflineKitAuditEfEntity
|
||||
{
|
||||
public Guid EventId { get; set; }
|
||||
public string TenantId { get; set; } = null!;
|
||||
public string EventType { get; set; } = null!;
|
||||
public DateTimeOffset Timestamp { get; set; }
|
||||
public string Actor { get; set; } = null!;
|
||||
public string Details { get; set; } = null!;
|
||||
public string Result { get; set; } = null!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EF Core entity for the authority.verdict_manifests table.
|
||||
/// </summary>
|
||||
public class VerdictManifestEfEntity
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string ManifestId { get; set; } = null!;
|
||||
public string Tenant { get; set; } = null!;
|
||||
public string AssetDigest { get; set; } = null!;
|
||||
public string VulnerabilityId { get; set; } = null!;
|
||||
public string InputsJson { get; set; } = null!;
|
||||
public string Status { get; set; } = null!;
|
||||
public double Confidence { get; set; }
|
||||
public string ResultJson { get; set; } = null!;
|
||||
public string PolicyHash { get; set; } = null!;
|
||||
public string LatticeVersion { get; set; } = null!;
|
||||
public DateTimeOffset EvaluatedAt { get; set; }
|
||||
public string ManifestDigest { get; set; } = null!;
|
||||
public string? SignatureBase64 { get; set; }
|
||||
public string? RekorLogId { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
}
|
||||
@@ -34,6 +34,7 @@ public interface IAuthorityServiceAccountStore
|
||||
public interface IAuthorityClientStore
|
||||
{
|
||||
ValueTask<AuthorityClientDocument?> FindByClientIdAsync(string clientId, CancellationToken cancellationToken, IClientSessionHandle? session = null);
|
||||
ValueTask<IReadOnlyList<AuthorityClientDocument>> ListAsync(int limit = 500, int offset = 0, CancellationToken cancellationToken = default, IClientSessionHandle? session = null);
|
||||
ValueTask UpsertAsync(AuthorityClientDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null);
|
||||
ValueTask<bool> DeleteByClientIdAsync(string clientId, CancellationToken cancellationToken, IClientSessionHandle? session = null);
|
||||
}
|
||||
|
||||
@@ -211,6 +211,20 @@ public sealed class InMemoryClientStore : IAuthorityClientStore
|
||||
return ValueTask.FromResult(doc);
|
||||
}
|
||||
|
||||
public ValueTask<IReadOnlyList<AuthorityClientDocument>> ListAsync(int limit = 500, int offset = 0, CancellationToken cancellationToken = default, IClientSessionHandle? session = null)
|
||||
{
|
||||
var take = limit <= 0 ? 500 : limit;
|
||||
var skip = offset < 0 ? 0 : offset;
|
||||
|
||||
var results = _clients.Values
|
||||
.OrderBy(client => client.ClientId, StringComparer.Ordinal)
|
||||
.Skip(skip)
|
||||
.Take(take)
|
||||
.ToList();
|
||||
|
||||
return ValueTask.FromResult<IReadOnlyList<AuthorityClientDocument>>(results);
|
||||
}
|
||||
|
||||
public ValueTask UpsertAsync(AuthorityClientDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(document.Id))
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Npgsql;
|
||||
using StellaOps.Authority.Persistence.EfCore.CompiledModels;
|
||||
using StellaOps.Authority.Persistence.EfCore.Context;
|
||||
|
||||
namespace StellaOps.Authority.Persistence.Postgres;
|
||||
|
||||
/// <summary>
|
||||
/// Runtime factory for creating <see cref="AuthorityDbContext"/> instances.
|
||||
/// Uses the static compiled model when schema matches the default; falls back to
|
||||
/// reflection-based model building for non-default schemas (integration tests).
|
||||
/// </summary>
|
||||
internal static class AuthorityDbContextFactory
|
||||
{
|
||||
public static AuthorityDbContext Create(NpgsqlConnection connection, int commandTimeoutSeconds, string schemaName)
|
||||
{
|
||||
var normalizedSchema = string.IsNullOrWhiteSpace(schemaName)
|
||||
? AuthorityDataSource.DefaultSchemaName
|
||||
: schemaName.Trim();
|
||||
|
||||
var optionsBuilder = new DbContextOptionsBuilder<AuthorityDbContext>()
|
||||
.UseNpgsql(connection, npgsql => npgsql.CommandTimeout(commandTimeoutSeconds));
|
||||
|
||||
if (string.Equals(normalizedSchema, AuthorityDataSource.DefaultSchemaName, StringComparison.Ordinal))
|
||||
{
|
||||
// Use the static compiled model when schema mapping matches the default model.
|
||||
optionsBuilder.UseModel(AuthorityDbContextModel.Instance);
|
||||
}
|
||||
|
||||
return new AuthorityDbContext(optionsBuilder.Options, normalizedSchema);
|
||||
}
|
||||
}
|
||||
@@ -1,91 +1,87 @@
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Authority.Persistence.EfCore.Models;
|
||||
using StellaOps.Authority.Persistence.Postgres.Models;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Authority.Persistence.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL repository for airgap audit records.
|
||||
/// PostgreSQL (EF Core) repository for airgap audit records.
|
||||
/// </summary>
|
||||
public sealed class AirgapAuditRepository : RepositoryBase<AuthorityDataSource>, IAirgapAuditRepository
|
||||
public sealed class AirgapAuditRepository : IAirgapAuditRepository
|
||||
{
|
||||
private const int CommandTimeoutSeconds = 30;
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.General);
|
||||
|
||||
private readonly AuthorityDataSource _dataSource;
|
||||
private readonly ILogger<AirgapAuditRepository> _logger;
|
||||
|
||||
public AirgapAuditRepository(AuthorityDataSource dataSource, ILogger<AirgapAuditRepository> logger)
|
||||
: base(dataSource, logger)
|
||||
{
|
||||
_dataSource = dataSource;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task InsertAsync(AirgapAuditEntity entity, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
INSERT INTO authority.airgap_audit
|
||||
(id, event_type, operator_id, component_id, outcome, reason, occurred_at, properties)
|
||||
VALUES (@id, @event_type, @operator_id, @component_id, @outcome, @reason, @occurred_at, @properties)
|
||||
""";
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
await ExecuteAsync(
|
||||
tenantId: string.Empty,
|
||||
sql: sql,
|
||||
configureCommand: cmd =>
|
||||
{
|
||||
AddParameter(cmd, "id", entity.Id);
|
||||
AddParameter(cmd, "event_type", entity.EventType);
|
||||
AddParameter(cmd, "operator_id", entity.OperatorId);
|
||||
AddParameter(cmd, "component_id", entity.ComponentId);
|
||||
AddParameter(cmd, "outcome", entity.Outcome);
|
||||
AddParameter(cmd, "reason", entity.Reason);
|
||||
AddParameter(cmd, "occurred_at", entity.OccurredAt);
|
||||
AddJsonbParameter(cmd, "properties", JsonSerializer.Serialize(entity.Properties, SerializerOptions));
|
||||
},
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
var efEntity = new AirgapAuditEfEntity
|
||||
{
|
||||
Id = entity.Id,
|
||||
EventType = entity.EventType,
|
||||
OperatorId = entity.OperatorId,
|
||||
ComponentId = entity.ComponentId,
|
||||
Outcome = entity.Outcome,
|
||||
Reason = entity.Reason,
|
||||
OccurredAt = entity.OccurredAt,
|
||||
Properties = JsonSerializer.Serialize(entity.Properties, SerializerOptions)
|
||||
};
|
||||
|
||||
dbContext.AirgapAuditEntries.Add(efEntity);
|
||||
await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<AirgapAuditEntity>> ListAsync(int limit, int offset, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, event_type, operator_id, component_id, outcome, reason, occurred_at, properties
|
||||
FROM authority.airgap_audit
|
||||
ORDER BY occurred_at DESC
|
||||
LIMIT @limit OFFSET @offset
|
||||
""";
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
return await QueryAsync(
|
||||
tenantId: string.Empty,
|
||||
sql: sql,
|
||||
configureCommand: cmd =>
|
||||
{
|
||||
AddParameter(cmd, "limit", limit);
|
||||
AddParameter(cmd, "offset", offset);
|
||||
},
|
||||
mapRow: MapAudit,
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
var entities = await dbContext.AirgapAuditEntries
|
||||
.AsNoTracking()
|
||||
.OrderByDescending(a => a.OccurredAt)
|
||||
.Skip(offset)
|
||||
.Take(limit)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return entities.Select(ToModel).ToList();
|
||||
}
|
||||
|
||||
private static AirgapAuditEntity MapAudit(NpgsqlDataReader reader) => new()
|
||||
private static AirgapAuditEntity ToModel(AirgapAuditEfEntity ef) => new()
|
||||
{
|
||||
Id = reader.GetString(0),
|
||||
EventType = reader.GetString(1),
|
||||
OperatorId = GetNullableString(reader, 2),
|
||||
ComponentId = GetNullableString(reader, 3),
|
||||
Outcome = reader.GetString(4),
|
||||
Reason = GetNullableString(reader, 5),
|
||||
OccurredAt = reader.GetFieldValue<DateTimeOffset>(6),
|
||||
Properties = DeserializeProperties(reader, 7)
|
||||
Id = ef.Id,
|
||||
EventType = ef.EventType,
|
||||
OperatorId = ef.OperatorId,
|
||||
ComponentId = ef.ComponentId,
|
||||
Outcome = ef.Outcome,
|
||||
Reason = ef.Reason,
|
||||
OccurredAt = ef.OccurredAt,
|
||||
Properties = DeserializeProperties(ef.Properties)
|
||||
};
|
||||
|
||||
private static IReadOnlyList<AirgapAuditPropertyEntity> DeserializeProperties(NpgsqlDataReader reader, int ordinal)
|
||||
private static IReadOnlyList<AirgapAuditPropertyEntity> DeserializeProperties(string? json)
|
||||
{
|
||||
if (reader.IsDBNull(ordinal))
|
||||
if (string.IsNullOrWhiteSpace(json) || json == "[]")
|
||||
{
|
||||
return Array.Empty<AirgapAuditPropertyEntity>();
|
||||
}
|
||||
|
||||
var json = reader.GetString(ordinal);
|
||||
List<AirgapAuditPropertyEntity>? parsed = JsonSerializer.Deserialize<List<AirgapAuditPropertyEntity>>(json, SerializerOptions);
|
||||
return parsed ?? new List<AirgapAuditPropertyEntity>();
|
||||
return JsonSerializer.Deserialize<List<AirgapAuditPropertyEntity>>(json, SerializerOptions)
|
||||
?? new List<AirgapAuditPropertyEntity>();
|
||||
}
|
||||
|
||||
private static string GetSchemaName() => AuthorityDataSource.DefaultSchemaName;
|
||||
}
|
||||
|
||||
@@ -1,139 +1,160 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Authority.Persistence.EfCore.Models;
|
||||
using StellaOps.Authority.Persistence.Postgres.Models;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
|
||||
namespace StellaOps.Authority.Persistence.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL repository for API key operations.
|
||||
/// PostgreSQL (EF Core) repository for API key operations.
|
||||
/// </summary>
|
||||
public sealed class ApiKeyRepository : RepositoryBase<AuthorityDataSource>, IApiKeyRepository
|
||||
public sealed class ApiKeyRepository : IApiKeyRepository
|
||||
{
|
||||
private const int CommandTimeoutSeconds = 30;
|
||||
|
||||
private readonly AuthorityDataSource _dataSource;
|
||||
private readonly ILogger<ApiKeyRepository> _logger;
|
||||
|
||||
public ApiKeyRepository(AuthorityDataSource dataSource, ILogger<ApiKeyRepository> logger)
|
||||
: base(dataSource, logger) { }
|
||||
{
|
||||
_dataSource = dataSource;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<ApiKeyEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, tenant_id, user_id, name, key_hash, key_prefix, scopes, status, last_used_at, expires_at, metadata, created_at, revoked_at, revoked_by
|
||||
FROM authority.api_keys
|
||||
WHERE tenant_id = @tenant_id AND id = @id
|
||||
""";
|
||||
return await QuerySingleOrDefaultAsync(tenantId, sql,
|
||||
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "id", id); },
|
||||
MapApiKey, cancellationToken).ConfigureAwait(false);
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var entity = await dbContext.ApiKeys
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(k => k.TenantId == tenantId && k.Id == id, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return entity is null ? null : ToModel(entity);
|
||||
}
|
||||
|
||||
public async Task<ApiKeyEntity?> GetByPrefixAsync(string keyPrefix, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, tenant_id, user_id, name, key_hash, key_prefix, scopes, status, last_used_at, expires_at, metadata, created_at, revoked_at, revoked_by
|
||||
FROM authority.api_keys
|
||||
WHERE key_prefix = @key_prefix AND status = 'active'
|
||||
""";
|
||||
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
AddParameter(command, "key_prefix", keyPrefix);
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
return await reader.ReadAsync(cancellationToken).ConfigureAwait(false) ? MapApiKey(reader) : null;
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var entity = await dbContext.ApiKeys
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(k => k.KeyPrefix == keyPrefix && k.Status == "active", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return entity is null ? null : ToModel(entity);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<ApiKeyEntity>> ListAsync(string tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, tenant_id, user_id, name, key_hash, key_prefix, scopes, status, last_used_at, expires_at, metadata, created_at, revoked_at, revoked_by
|
||||
FROM authority.api_keys
|
||||
WHERE tenant_id = @tenant_id
|
||||
ORDER BY created_at DESC
|
||||
""";
|
||||
return await QueryAsync(tenantId, sql,
|
||||
cmd => AddParameter(cmd, "tenant_id", tenantId),
|
||||
MapApiKey, cancellationToken).ConfigureAwait(false);
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var entities = await dbContext.ApiKeys
|
||||
.AsNoTracking()
|
||||
.Where(k => k.TenantId == tenantId)
|
||||
.OrderByDescending(k => k.CreatedAt)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return entities.Select(ToModel).ToList();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<ApiKeyEntity>> GetByUserIdAsync(string tenantId, Guid userId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, tenant_id, user_id, name, key_hash, key_prefix, scopes, status, last_used_at, expires_at, metadata, created_at, revoked_at, revoked_by
|
||||
FROM authority.api_keys
|
||||
WHERE tenant_id = @tenant_id AND user_id = @user_id
|
||||
ORDER BY created_at DESC
|
||||
""";
|
||||
return await QueryAsync(tenantId, sql,
|
||||
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "user_id", userId); },
|
||||
MapApiKey, cancellationToken).ConfigureAwait(false);
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var entities = await dbContext.ApiKeys
|
||||
.AsNoTracking()
|
||||
.Where(k => k.TenantId == tenantId && k.UserId == userId)
|
||||
.OrderByDescending(k => k.CreatedAt)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return entities.Select(ToModel).ToList();
|
||||
}
|
||||
|
||||
public async Task<Guid> CreateAsync(string tenantId, ApiKeyEntity apiKey, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
INSERT INTO authority.api_keys (id, tenant_id, user_id, name, key_hash, key_prefix, scopes, status, expires_at, metadata)
|
||||
VALUES (@id, @tenant_id, @user_id, @name, @key_hash, @key_prefix, @scopes, @status, @expires_at, @metadata::jsonb)
|
||||
RETURNING id
|
||||
""";
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var id = apiKey.Id == Guid.Empty ? Guid.NewGuid() : apiKey.Id;
|
||||
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken).ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
AddParameter(command, "id", id);
|
||||
AddParameter(command, "tenant_id", tenantId);
|
||||
AddParameter(command, "user_id", apiKey.UserId);
|
||||
AddParameter(command, "name", apiKey.Name);
|
||||
AddParameter(command, "key_hash", apiKey.KeyHash);
|
||||
AddParameter(command, "key_prefix", apiKey.KeyPrefix);
|
||||
AddTextArrayParameter(command, "scopes", apiKey.Scopes);
|
||||
AddParameter(command, "status", apiKey.Status);
|
||||
AddParameter(command, "expires_at", apiKey.ExpiresAt);
|
||||
AddJsonbParameter(command, "metadata", apiKey.Metadata);
|
||||
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
var efEntity = new ApiKeyEfEntity
|
||||
{
|
||||
Id = id,
|
||||
TenantId = tenantId,
|
||||
UserId = apiKey.UserId,
|
||||
Name = apiKey.Name,
|
||||
KeyHash = apiKey.KeyHash,
|
||||
KeyPrefix = apiKey.KeyPrefix,
|
||||
Scopes = apiKey.Scopes,
|
||||
Status = apiKey.Status,
|
||||
ExpiresAt = apiKey.ExpiresAt,
|
||||
Metadata = apiKey.Metadata
|
||||
};
|
||||
|
||||
dbContext.ApiKeys.Add(efEntity);
|
||||
await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
return id;
|
||||
}
|
||||
|
||||
public async Task UpdateLastUsedAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = "UPDATE authority.api_keys SET last_used_at = NOW() WHERE tenant_id = @tenant_id AND id = @id";
|
||||
await ExecuteAsync(tenantId, sql,
|
||||
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "id", id); },
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
await dbContext.Database.ExecuteSqlRawAsync(
|
||||
"UPDATE authority.api_keys SET last_used_at = NOW() WHERE tenant_id = {0} AND id = {1}",
|
||||
tenantId, id,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task RevokeAsync(string tenantId, Guid id, string revokedBy, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
UPDATE authority.api_keys SET status = 'revoked', revoked_at = NOW(), revoked_by = @revoked_by
|
||||
WHERE tenant_id = @tenant_id AND id = @id AND status = 'active'
|
||||
""";
|
||||
await ExecuteAsync(tenantId, sql, cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "id", id);
|
||||
AddParameter(cmd, "revoked_by", revokedBy);
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
await dbContext.Database.ExecuteSqlRawAsync(
|
||||
"""
|
||||
UPDATE authority.api_keys SET status = 'revoked', revoked_at = NOW(), revoked_by = {0}
|
||||
WHERE tenant_id = {1} AND id = {2} AND status = 'active'
|
||||
""",
|
||||
revokedBy, tenantId, id,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = "DELETE FROM authority.api_keys WHERE tenant_id = @tenant_id AND id = @id";
|
||||
await ExecuteAsync(tenantId, sql,
|
||||
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "id", id); },
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
await dbContext.ApiKeys
|
||||
.Where(k => k.TenantId == tenantId && k.Id == id)
|
||||
.ExecuteDeleteAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static ApiKeyEntity MapApiKey(NpgsqlDataReader reader) => new()
|
||||
private static ApiKeyEntity ToModel(ApiKeyEfEntity ef) => new()
|
||||
{
|
||||
Id = reader.GetGuid(0),
|
||||
TenantId = reader.GetString(1),
|
||||
UserId = GetNullableGuid(reader, 2),
|
||||
Name = reader.GetString(3),
|
||||
KeyHash = reader.GetString(4),
|
||||
KeyPrefix = reader.GetString(5),
|
||||
Scopes = reader.IsDBNull(6) ? [] : reader.GetFieldValue<string[]>(6),
|
||||
Status = reader.GetString(7),
|
||||
LastUsedAt = GetNullableDateTimeOffset(reader, 8),
|
||||
ExpiresAt = GetNullableDateTimeOffset(reader, 9),
|
||||
Metadata = reader.GetString(10),
|
||||
CreatedAt = reader.GetFieldValue<DateTimeOffset>(11),
|
||||
RevokedAt = GetNullableDateTimeOffset(reader, 12),
|
||||
RevokedBy = GetNullableString(reader, 13)
|
||||
Id = ef.Id,
|
||||
TenantId = ef.TenantId,
|
||||
UserId = ef.UserId,
|
||||
Name = ef.Name,
|
||||
KeyHash = ef.KeyHash,
|
||||
KeyPrefix = ef.KeyPrefix,
|
||||
Scopes = ef.Scopes ?? [],
|
||||
Status = ef.Status,
|
||||
LastUsedAt = ef.LastUsedAt,
|
||||
ExpiresAt = ef.ExpiresAt,
|
||||
Metadata = ef.Metadata,
|
||||
CreatedAt = ef.CreatedAt,
|
||||
RevokedAt = ef.RevokedAt,
|
||||
RevokedBy = ef.RevokedBy
|
||||
};
|
||||
|
||||
private static string GetSchemaName() => AuthorityDataSource.DefaultSchemaName;
|
||||
}
|
||||
|
||||
@@ -1,139 +1,152 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Authority.Persistence.EfCore.Models;
|
||||
using StellaOps.Authority.Persistence.Postgres.Models;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
|
||||
namespace StellaOps.Authority.Persistence.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL repository for audit log operations.
|
||||
/// PostgreSQL (EF Core) repository for audit log operations.
|
||||
/// </summary>
|
||||
public sealed class AuditRepository : RepositoryBase<AuthorityDataSource>, IAuditRepository
|
||||
public sealed class AuditRepository : IAuditRepository
|
||||
{
|
||||
private const int CommandTimeoutSeconds = 30;
|
||||
|
||||
private readonly AuthorityDataSource _dataSource;
|
||||
private readonly ILogger<AuditRepository> _logger;
|
||||
|
||||
public AuditRepository(AuthorityDataSource dataSource, ILogger<AuditRepository> logger)
|
||||
: base(dataSource, logger) { }
|
||||
{
|
||||
_dataSource = dataSource;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<long> CreateAsync(string tenantId, AuditEntity audit, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
INSERT INTO authority.audit (tenant_id, user_id, action, resource_type, resource_id, old_value, new_value, ip_address, user_agent, correlation_id)
|
||||
VALUES (@tenant_id, @user_id, @action, @resource_type, @resource_id, @old_value::jsonb, @new_value::jsonb, @ip_address, @user_agent, @correlation_id)
|
||||
RETURNING id
|
||||
""";
|
||||
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken).ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
AddParameter(command, "tenant_id", 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, "ip_address", audit.IpAddress);
|
||||
AddParameter(command, "user_agent", audit.UserAgent);
|
||||
AddParameter(command, "correlation_id", audit.CorrelationId);
|
||||
var result = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
|
||||
return (long)result!;
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var efEntity = new AuditEfEntity
|
||||
{
|
||||
TenantId = tenantId,
|
||||
UserId = audit.UserId,
|
||||
Action = audit.Action,
|
||||
ResourceType = audit.ResourceType,
|
||||
ResourceId = audit.ResourceId,
|
||||
OldValue = audit.OldValue,
|
||||
NewValue = audit.NewValue,
|
||||
IpAddress = audit.IpAddress,
|
||||
UserAgent = audit.UserAgent,
|
||||
CorrelationId = audit.CorrelationId
|
||||
};
|
||||
|
||||
dbContext.AuditEntries.Add(efEntity);
|
||||
await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
return efEntity.Id;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<AuditEntity>> 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, ip_address, user_agent, correlation_id, created_at
|
||||
FROM authority.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 = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var entities = await dbContext.AuditEntries
|
||||
.AsNoTracking()
|
||||
.Where(a => a.TenantId == tenantId)
|
||||
.OrderByDescending(a => a.CreatedAt)
|
||||
.Skip(offset)
|
||||
.Take(limit)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return entities.Select(ToModel).ToList();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<AuditEntity>> GetByUserIdAsync(string tenantId, Guid userId, int limit = 100, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, tenant_id, user_id, action, resource_type, resource_id, old_value, new_value, ip_address, user_agent, correlation_id, created_at
|
||||
FROM authority.audit
|
||||
WHERE tenant_id = @tenant_id AND user_id = @user_id
|
||||
ORDER BY created_at DESC
|
||||
LIMIT @limit
|
||||
""";
|
||||
return await QueryAsync(tenantId, sql, cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "user_id", userId);
|
||||
AddParameter(cmd, "limit", limit);
|
||||
}, MapAudit, cancellationToken).ConfigureAwait(false);
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var entities = await dbContext.AuditEntries
|
||||
.AsNoTracking()
|
||||
.Where(a => a.TenantId == tenantId && a.UserId == userId)
|
||||
.OrderByDescending(a => a.CreatedAt)
|
||||
.Take(limit)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return entities.Select(ToModel).ToList();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<AuditEntity>> GetByResourceAsync(string tenantId, string resourceType, string? resourceId, int limit = 100, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var sql = """
|
||||
SELECT id, tenant_id, user_id, action, resource_type, resource_id, old_value, new_value, ip_address, user_agent, correlation_id, created_at
|
||||
FROM authority.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 = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
return await QueryAsync(tenantId, sql, cmd =>
|
||||
IQueryable<AuditEfEntity> query = dbContext.AuditEntries
|
||||
.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);
|
||||
}
|
||||
|
||||
var entities = await query
|
||||
.OrderByDescending(a => a.CreatedAt)
|
||||
.Take(limit)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return entities.Select(ToModel).ToList();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<AuditEntity>> 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, ip_address, user_agent, correlation_id, created_at
|
||||
FROM authority.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 = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var entities = await dbContext.AuditEntries
|
||||
.AsNoTracking()
|
||||
.Where(a => a.TenantId == tenantId && a.CorrelationId == correlationId)
|
||||
.OrderBy(a => a.CreatedAt)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return entities.Select(ToModel).ToList();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<AuditEntity>> GetByActionAsync(string tenantId, string action, int limit = 100, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, tenant_id, user_id, action, resource_type, resource_id, old_value, new_value, ip_address, user_agent, correlation_id, created_at
|
||||
FROM authority.audit
|
||||
WHERE tenant_id = @tenant_id AND action = @action
|
||||
ORDER BY created_at DESC
|
||||
LIMIT @limit
|
||||
""";
|
||||
return await QueryAsync(tenantId, sql, cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "action", action);
|
||||
AddParameter(cmd, "limit", limit);
|
||||
}, MapAudit, cancellationToken).ConfigureAwait(false);
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var entities = await dbContext.AuditEntries
|
||||
.AsNoTracking()
|
||||
.Where(a => a.TenantId == tenantId && a.Action == action)
|
||||
.OrderByDescending(a => a.CreatedAt)
|
||||
.Take(limit)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return entities.Select(ToModel).ToList();
|
||||
}
|
||||
|
||||
private static AuditEntity MapAudit(NpgsqlDataReader reader) => new()
|
||||
private static AuditEntity ToModel(AuditEfEntity ef) => 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),
|
||||
IpAddress = GetNullableString(reader, 8),
|
||||
UserAgent = GetNullableString(reader, 9),
|
||||
CorrelationId = GetNullableString(reader, 10),
|
||||
CreatedAt = reader.GetFieldValue<DateTimeOffset>(11)
|
||||
Id = ef.Id,
|
||||
TenantId = ef.TenantId,
|
||||
UserId = ef.UserId,
|
||||
Action = ef.Action,
|
||||
ResourceType = ef.ResourceType,
|
||||
ResourceId = ef.ResourceId,
|
||||
OldValue = ef.OldValue,
|
||||
NewValue = ef.NewValue,
|
||||
IpAddress = ef.IpAddress,
|
||||
UserAgent = ef.UserAgent,
|
||||
CorrelationId = ef.CorrelationId,
|
||||
CreatedAt = ef.CreatedAt
|
||||
};
|
||||
|
||||
private static string GetSchemaName() => AuthorityDataSource.DefaultSchemaName;
|
||||
}
|
||||
|
||||
@@ -1,195 +1,171 @@
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Authority.Persistence.EfCore.Models;
|
||||
using StellaOps.Authority.Persistence.Postgres.Models;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Authority.Persistence.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL repository for bootstrap invites.
|
||||
/// PostgreSQL (EF Core) repository for bootstrap invites.
|
||||
/// </summary>
|
||||
public sealed class BootstrapInviteRepository : RepositoryBase<AuthorityDataSource>, IBootstrapInviteRepository
|
||||
public sealed class BootstrapInviteRepository : IBootstrapInviteRepository
|
||||
{
|
||||
private const int CommandTimeoutSeconds = 30;
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.General);
|
||||
|
||||
private readonly AuthorityDataSource _dataSource;
|
||||
private readonly ILogger<BootstrapInviteRepository> _logger;
|
||||
|
||||
public BootstrapInviteRepository(AuthorityDataSource dataSource, ILogger<BootstrapInviteRepository> logger)
|
||||
: base(dataSource, logger)
|
||||
{
|
||||
_dataSource = dataSource;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<BootstrapInviteEntity?> FindByTokenAsync(string token, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, token, type, provider, target, expires_at, created_at, issued_at, issued_by, reserved_until, reserved_by, consumed, status, metadata
|
||||
FROM authority.bootstrap_invites
|
||||
WHERE token = @token
|
||||
""";
|
||||
return await QuerySingleOrDefaultAsync(
|
||||
tenantId: string.Empty,
|
||||
sql: sql,
|
||||
configureCommand: cmd => AddParameter(cmd, "token", token),
|
||||
mapRow: MapInvite,
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var entity = await dbContext.BootstrapInvites
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(i => i.Token == token, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return entity is null ? null : ToModel(entity);
|
||||
}
|
||||
|
||||
public async Task InsertAsync(BootstrapInviteEntity invite, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
INSERT INTO authority.bootstrap_invites
|
||||
(id, token, type, provider, target, expires_at, created_at, issued_at, issued_by, reserved_until, reserved_by, consumed, status, metadata)
|
||||
VALUES (@id, @token, @type, @provider, @target, @expires_at, @created_at, @issued_at, @issued_by, @reserved_until, @reserved_by, @consumed, @status, @metadata)
|
||||
""";
|
||||
await ExecuteAsync(
|
||||
tenantId: string.Empty,
|
||||
sql: sql,
|
||||
configureCommand: cmd =>
|
||||
{
|
||||
AddParameter(cmd, "id", invite.Id);
|
||||
AddParameter(cmd, "token", invite.Token);
|
||||
AddParameter(cmd, "type", invite.Type);
|
||||
AddParameter(cmd, "provider", invite.Provider);
|
||||
AddParameter(cmd, "target", invite.Target);
|
||||
AddParameter(cmd, "expires_at", invite.ExpiresAt);
|
||||
AddParameter(cmd, "created_at", invite.CreatedAt);
|
||||
AddParameter(cmd, "issued_at", invite.IssuedAt);
|
||||
AddParameter(cmd, "issued_by", invite.IssuedBy);
|
||||
AddParameter(cmd, "reserved_until", invite.ReservedUntil);
|
||||
AddParameter(cmd, "reserved_by", invite.ReservedBy);
|
||||
AddParameter(cmd, "consumed", invite.Consumed);
|
||||
AddParameter(cmd, "status", invite.Status);
|
||||
AddJsonbParameter(cmd, "metadata", JsonSerializer.Serialize(invite.Metadata, SerializerOptions));
|
||||
},
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var efEntity = new BootstrapInviteEfEntity
|
||||
{
|
||||
Id = invite.Id,
|
||||
Token = invite.Token,
|
||||
Type = invite.Type,
|
||||
Provider = invite.Provider,
|
||||
Target = invite.Target,
|
||||
ExpiresAt = invite.ExpiresAt,
|
||||
CreatedAt = invite.CreatedAt,
|
||||
IssuedAt = invite.IssuedAt,
|
||||
IssuedBy = invite.IssuedBy,
|
||||
ReservedUntil = invite.ReservedUntil,
|
||||
ReservedBy = invite.ReservedBy,
|
||||
Consumed = invite.Consumed,
|
||||
Status = invite.Status,
|
||||
Metadata = JsonSerializer.Serialize(invite.Metadata, SerializerOptions)
|
||||
};
|
||||
|
||||
dbContext.BootstrapInvites.Add(efEntity);
|
||||
await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<bool> ConsumeAsync(string token, string? consumedBy, DateTimeOffset consumedAt, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
UPDATE authority.bootstrap_invites
|
||||
SET consumed = TRUE,
|
||||
reserved_by = @consumed_by,
|
||||
reserved_until = @consumed_at,
|
||||
status = 'consumed'
|
||||
WHERE token = @token AND consumed = FALSE
|
||||
""";
|
||||
var rows = await ExecuteAsync(
|
||||
tenantId: string.Empty,
|
||||
sql: sql,
|
||||
configureCommand: cmd =>
|
||||
{
|
||||
AddParameter(cmd, "token", token);
|
||||
AddParameter(cmd, "consumed_by", consumedBy);
|
||||
AddParameter(cmd, "consumed_at", consumedAt);
|
||||
},
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var rows = await dbContext.BootstrapInvites
|
||||
.Where(i => i.Token == token && i.Consumed == false)
|
||||
.ExecuteUpdateAsync(setters => setters
|
||||
.SetProperty(i => i.Consumed, true)
|
||||
.SetProperty(i => i.ReservedBy, consumedBy)
|
||||
.SetProperty(i => i.ReservedUntil, consumedAt)
|
||||
.SetProperty(i => i.Status, "consumed"),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return rows > 0;
|
||||
}
|
||||
|
||||
public async Task<bool> ReleaseAsync(string token, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
UPDATE authority.bootstrap_invites
|
||||
SET status = 'pending',
|
||||
reserved_by = NULL,
|
||||
reserved_until = NULL
|
||||
WHERE token = @token AND status = 'reserved'
|
||||
""";
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var rows = await dbContext.BootstrapInvites
|
||||
.Where(i => i.Token == token && i.Status == "reserved")
|
||||
.ExecuteUpdateAsync(setters => setters
|
||||
.SetProperty(i => i.Status, "pending")
|
||||
.SetProperty(i => i.ReservedBy, (string?)null)
|
||||
.SetProperty(i => i.ReservedUntil, (DateTimeOffset?)null),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var rows = await ExecuteAsync(
|
||||
tenantId: string.Empty,
|
||||
sql: sql,
|
||||
configureCommand: cmd => AddParameter(cmd, "token", token),
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
return rows > 0;
|
||||
}
|
||||
|
||||
public async Task<bool> TryReserveAsync(string token, string expectedType, DateTimeOffset now, string? reservedBy, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
UPDATE authority.bootstrap_invites
|
||||
SET status = 'reserved',
|
||||
reserved_by = @reserved_by,
|
||||
reserved_until = @reserved_until
|
||||
WHERE token = @token
|
||||
AND type = @expected_type
|
||||
AND consumed = FALSE
|
||||
AND expires_at > @now
|
||||
AND (status = 'pending' OR status IS NULL)
|
||||
""";
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var rows = await ExecuteAsync(
|
||||
tenantId: string.Empty,
|
||||
sql: sql,
|
||||
configureCommand: cmd =>
|
||||
{
|
||||
AddParameter(cmd, "reserved_by", reservedBy);
|
||||
AddParameter(cmd, "reserved_until", now.AddMinutes(15));
|
||||
AddParameter(cmd, "token", token);
|
||||
AddParameter(cmd, "expected_type", expectedType);
|
||||
AddParameter(cmd, "now", now);
|
||||
},
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
var reservedUntil = now.AddMinutes(15);
|
||||
|
||||
var rows = await dbContext.BootstrapInvites
|
||||
.Where(i => i.Token == token
|
||||
&& i.Type == expectedType
|
||||
&& i.Consumed == false
|
||||
&& i.ExpiresAt > now
|
||||
&& (i.Status == "pending" || i.Status == null))
|
||||
.ExecuteUpdateAsync(setters => setters
|
||||
.SetProperty(i => i.Status, "reserved")
|
||||
.SetProperty(i => i.ReservedBy, reservedBy)
|
||||
.SetProperty(i => i.ReservedUntil, reservedUntil),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return rows > 0;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<BootstrapInviteEntity>> ExpireAsync(DateTimeOffset asOf, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string selectSql = """
|
||||
SELECT id, token, type, provider, target, expires_at, created_at, issued_at, issued_by, reserved_until, reserved_by, consumed, status, metadata
|
||||
FROM authority.bootstrap_invites
|
||||
WHERE expires_at <= @as_of
|
||||
""";
|
||||
const string deleteSql = """
|
||||
DELETE FROM authority.bootstrap_invites
|
||||
WHERE expires_at <= @as_of
|
||||
""";
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var expired = await QueryAsync(
|
||||
tenantId: string.Empty,
|
||||
sql: selectSql,
|
||||
configureCommand: cmd => AddParameter(cmd, "as_of", asOf),
|
||||
mapRow: MapInvite,
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
// Select first, then delete -- matching original behavior.
|
||||
var expired = await dbContext.BootstrapInvites
|
||||
.AsNoTracking()
|
||||
.Where(i => i.ExpiresAt <= asOf)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
await ExecuteAsync(
|
||||
tenantId: string.Empty,
|
||||
sql: deleteSql,
|
||||
configureCommand: cmd => AddParameter(cmd, "as_of", asOf),
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
await dbContext.BootstrapInvites
|
||||
.Where(i => i.ExpiresAt <= asOf)
|
||||
.ExecuteDeleteAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return expired;
|
||||
return expired.Select(ToModel).ToList();
|
||||
}
|
||||
|
||||
private static BootstrapInviteEntity MapInvite(NpgsqlDataReader reader) => new()
|
||||
private static BootstrapInviteEntity ToModel(BootstrapInviteEfEntity ef) => new()
|
||||
{
|
||||
Id = reader.GetString(0),
|
||||
Token = reader.GetString(1),
|
||||
Type = reader.GetString(2),
|
||||
Provider = GetNullableString(reader, 3),
|
||||
Target = GetNullableString(reader, 4),
|
||||
ExpiresAt = reader.GetFieldValue<DateTimeOffset>(5),
|
||||
CreatedAt = reader.GetFieldValue<DateTimeOffset>(6),
|
||||
IssuedAt = reader.GetFieldValue<DateTimeOffset>(7),
|
||||
IssuedBy = GetNullableString(reader, 8),
|
||||
ReservedUntil = reader.IsDBNull(9) ? null : reader.GetFieldValue<DateTimeOffset>(9),
|
||||
ReservedBy = GetNullableString(reader, 10),
|
||||
Consumed = reader.GetBoolean(11),
|
||||
Status = GetNullableString(reader, 12) ?? "pending",
|
||||
Metadata = DeserializeMetadata(reader, 13)
|
||||
Id = ef.Id,
|
||||
Token = ef.Token,
|
||||
Type = ef.Type,
|
||||
Provider = ef.Provider,
|
||||
Target = ef.Target,
|
||||
ExpiresAt = ef.ExpiresAt,
|
||||
CreatedAt = ef.CreatedAt,
|
||||
IssuedAt = ef.IssuedAt,
|
||||
IssuedBy = ef.IssuedBy,
|
||||
ReservedUntil = ef.ReservedUntil,
|
||||
ReservedBy = ef.ReservedBy,
|
||||
Consumed = ef.Consumed,
|
||||
Status = ef.Status ?? "pending",
|
||||
Metadata = DeserializeMetadata(ef.Metadata)
|
||||
};
|
||||
|
||||
private static IReadOnlyDictionary<string, string?> DeserializeMetadata(NpgsqlDataReader reader, int ordinal)
|
||||
private static IReadOnlyDictionary<string, string?> DeserializeMetadata(string? json)
|
||||
{
|
||||
if (reader.IsDBNull(ordinal))
|
||||
if (string.IsNullOrWhiteSpace(json) || json == "{}")
|
||||
{
|
||||
return new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
var json = reader.GetString(ordinal);
|
||||
return JsonSerializer.Deserialize<Dictionary<string, string?>>(json, SerializerOptions)
|
||||
?? new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static string GetSchemaName() => AuthorityDataSource.DefaultSchemaName;
|
||||
}
|
||||
|
||||
@@ -1,56 +1,82 @@
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Authority.Persistence.EfCore.Context;
|
||||
using StellaOps.Authority.Persistence.EfCore.Models;
|
||||
using StellaOps.Authority.Persistence.Postgres.Models;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Authority.Persistence.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL repository for OAuth/OpenID clients.
|
||||
/// PostgreSQL (EF Core) repository for OAuth/OpenID clients.
|
||||
/// </summary>
|
||||
public sealed class ClientRepository : RepositoryBase<AuthorityDataSource>, IClientRepository
|
||||
public sealed class ClientRepository : IClientRepository
|
||||
{
|
||||
private const int CommandTimeoutSeconds = 30;
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.General);
|
||||
|
||||
private readonly AuthorityDataSource _dataSource;
|
||||
private readonly ILogger<ClientRepository> _logger;
|
||||
|
||||
public ClientRepository(AuthorityDataSource dataSource, ILogger<ClientRepository> logger)
|
||||
: base(dataSource, logger)
|
||||
{
|
||||
_dataSource = dataSource;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<ClientEntity?> FindByClientIdAsync(string clientId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, client_id, client_secret, secret_hash, display_name, description, plugin, sender_constraint,
|
||||
enabled, redirect_uris, post_logout_redirect_uris, allowed_scopes, allowed_grant_types,
|
||||
require_client_secret, require_pkce, allow_plain_text_pkce, client_type, properties, certificate_bindings,
|
||||
created_at, updated_at
|
||||
FROM authority.clients
|
||||
WHERE client_id = @client_id
|
||||
""";
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
return await QuerySingleOrDefaultAsync(
|
||||
tenantId: string.Empty,
|
||||
sql: sql,
|
||||
configureCommand: cmd => AddParameter(cmd, "client_id", clientId),
|
||||
mapRow: MapClient,
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
var entity = await dbContext.Clients
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(c => c.ClientId == clientId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return entity is null ? null : MapToModel(entity);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<ClientEntity>> ListAsync(int limit = 500, int offset = 0, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var safeLimit = limit <= 0 ? 500 : limit;
|
||||
var safeOffset = offset < 0 ? 0 : offset;
|
||||
|
||||
var entities = await dbContext.Clients
|
||||
.AsNoTracking()
|
||||
.OrderBy(c => c.ClientId)
|
||||
.Skip(safeOffset)
|
||||
.Take(safeLimit)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return entities.Select(MapToModel).ToList();
|
||||
}
|
||||
|
||||
public async Task UpsertAsync(ClientEntity entity, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
// The UPSERT has ON CONFLICT (client_id) DO UPDATE. Use raw SQL for the complex upsert pattern.
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var propertiesJson = JsonSerializer.Serialize(entity.Properties, SerializerOptions);
|
||||
var certificateBindingsJson = JsonSerializer.Serialize(entity.CertificateBindings, SerializerOptions);
|
||||
|
||||
await dbContext.Database.ExecuteSqlRawAsync("""
|
||||
INSERT INTO authority.clients
|
||||
(id, client_id, client_secret, secret_hash, display_name, description, plugin, sender_constraint,
|
||||
enabled, redirect_uris, post_logout_redirect_uris, allowed_scopes, allowed_grant_types,
|
||||
require_client_secret, require_pkce, allow_plain_text_pkce, client_type, properties, certificate_bindings,
|
||||
created_at, updated_at)
|
||||
VALUES
|
||||
(@id, @client_id, @client_secret, @secret_hash, @display_name, @description, @plugin, @sender_constraint,
|
||||
@enabled, @redirect_uris, @post_logout_redirect_uris, @allowed_scopes, @allowed_grant_types,
|
||||
@require_client_secret, @require_pkce, @allow_plain_text_pkce, @client_type, @properties, @certificate_bindings,
|
||||
@created_at, @updated_at)
|
||||
({0}, {1}, {2}, {3}, {4}, {5}, {6}, {7},
|
||||
{8}, {9}, {10}, {11}, {12},
|
||||
{13}, {14}, {15}, {16}, {17}::jsonb, {18}::jsonb,
|
||||
{19}, {20})
|
||||
ON CONFLICT (client_id) DO UPDATE
|
||||
SET client_secret = EXCLUDED.client_secret,
|
||||
secret_hash = EXCLUDED.secret_hash,
|
||||
@@ -70,94 +96,84 @@ public sealed class ClientRepository : RepositoryBase<AuthorityDataSource>, ICli
|
||||
properties = EXCLUDED.properties,
|
||||
certificate_bindings = EXCLUDED.certificate_bindings,
|
||||
updated_at = EXCLUDED.updated_at
|
||||
""";
|
||||
|
||||
await ExecuteAsync(
|
||||
tenantId: string.Empty,
|
||||
sql: sql,
|
||||
configureCommand: cmd =>
|
||||
{
|
||||
AddParameter(cmd, "id", entity.Id);
|
||||
AddParameter(cmd, "client_id", entity.ClientId);
|
||||
AddParameter(cmd, "client_secret", entity.ClientSecret);
|
||||
AddParameter(cmd, "secret_hash", entity.SecretHash);
|
||||
AddParameter(cmd, "display_name", entity.DisplayName);
|
||||
AddParameter(cmd, "description", entity.Description);
|
||||
AddParameter(cmd, "plugin", entity.Plugin);
|
||||
AddParameter(cmd, "sender_constraint", entity.SenderConstraint);
|
||||
AddParameter(cmd, "enabled", entity.Enabled);
|
||||
AddParameter(cmd, "redirect_uris", entity.RedirectUris.ToArray());
|
||||
AddParameter(cmd, "post_logout_redirect_uris", entity.PostLogoutRedirectUris.ToArray());
|
||||
AddParameter(cmd, "allowed_scopes", entity.AllowedScopes.ToArray());
|
||||
AddParameter(cmd, "allowed_grant_types", entity.AllowedGrantTypes.ToArray());
|
||||
AddParameter(cmd, "require_client_secret", entity.RequireClientSecret);
|
||||
AddParameter(cmd, "require_pkce", entity.RequirePkce);
|
||||
AddParameter(cmd, "allow_plain_text_pkce", entity.AllowPlainTextPkce);
|
||||
AddParameter(cmd, "client_type", entity.ClientType);
|
||||
AddJsonbParameter(cmd, "properties", JsonSerializer.Serialize(entity.Properties, SerializerOptions));
|
||||
AddJsonbParameter(cmd, "certificate_bindings", JsonSerializer.Serialize(entity.CertificateBindings, SerializerOptions));
|
||||
AddParameter(cmd, "created_at", entity.CreatedAt);
|
||||
AddParameter(cmd, "updated_at", entity.UpdatedAt);
|
||||
},
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
""",
|
||||
entity.Id, entity.ClientId,
|
||||
(object?)entity.ClientSecret ?? DBNull.Value,
|
||||
(object?)entity.SecretHash ?? DBNull.Value,
|
||||
(object?)entity.DisplayName ?? DBNull.Value,
|
||||
(object?)entity.Description ?? DBNull.Value,
|
||||
(object?)entity.Plugin ?? DBNull.Value,
|
||||
(object?)entity.SenderConstraint ?? DBNull.Value,
|
||||
entity.Enabled,
|
||||
entity.RedirectUris.ToArray(),
|
||||
entity.PostLogoutRedirectUris.ToArray(),
|
||||
entity.AllowedScopes.ToArray(),
|
||||
entity.AllowedGrantTypes.ToArray(),
|
||||
entity.RequireClientSecret,
|
||||
entity.RequirePkce,
|
||||
entity.AllowPlainTextPkce,
|
||||
(object?)entity.ClientType ?? DBNull.Value,
|
||||
propertiesJson,
|
||||
certificateBindingsJson,
|
||||
entity.CreatedAt,
|
||||
entity.UpdatedAt,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteByClientIdAsync(string clientId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = "DELETE FROM authority.clients WHERE client_id = @client_id";
|
||||
var rows = await ExecuteAsync(
|
||||
tenantId: string.Empty,
|
||||
sql: sql,
|
||||
configureCommand: cmd => AddParameter(cmd, "client_id", clientId),
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var rows = await dbContext.Clients
|
||||
.Where(c => c.ClientId == clientId)
|
||||
.ExecuteDeleteAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return rows > 0;
|
||||
}
|
||||
|
||||
private static ClientEntity MapClient(NpgsqlDataReader reader) => new()
|
||||
private static ClientEntity MapToModel(ClientEfEntity ef) => new()
|
||||
{
|
||||
Id = reader.GetString(0),
|
||||
ClientId = reader.GetString(1),
|
||||
ClientSecret = GetNullableString(reader, 2),
|
||||
SecretHash = GetNullableString(reader, 3),
|
||||
DisplayName = GetNullableString(reader, 4),
|
||||
Description = GetNullableString(reader, 5),
|
||||
Plugin = GetNullableString(reader, 6),
|
||||
SenderConstraint = GetNullableString(reader, 7),
|
||||
Enabled = reader.GetBoolean(8),
|
||||
RedirectUris = reader.GetFieldValue<string[]>(9),
|
||||
PostLogoutRedirectUris = reader.GetFieldValue<string[]>(10),
|
||||
AllowedScopes = reader.GetFieldValue<string[]>(11),
|
||||
AllowedGrantTypes = reader.GetFieldValue<string[]>(12),
|
||||
RequireClientSecret = reader.GetBoolean(13),
|
||||
RequirePkce = reader.GetBoolean(14),
|
||||
AllowPlainTextPkce = reader.GetBoolean(15),
|
||||
ClientType = GetNullableString(reader, 16),
|
||||
Properties = DeserializeDictionary(reader, 17),
|
||||
CertificateBindings = Deserialize<List<ClientCertificateBindingEntity>>(reader, 18) ?? new List<ClientCertificateBindingEntity>(),
|
||||
CreatedAt = reader.GetFieldValue<DateTimeOffset>(19),
|
||||
UpdatedAt = reader.GetFieldValue<DateTimeOffset>(20)
|
||||
Id = ef.Id,
|
||||
ClientId = ef.ClientId,
|
||||
ClientSecret = ef.ClientSecret,
|
||||
SecretHash = ef.SecretHash,
|
||||
DisplayName = ef.DisplayName,
|
||||
Description = ef.Description,
|
||||
Plugin = ef.Plugin,
|
||||
SenderConstraint = ef.SenderConstraint,
|
||||
Enabled = ef.Enabled,
|
||||
RedirectUris = ef.RedirectUris,
|
||||
PostLogoutRedirectUris = ef.PostLogoutRedirectUris,
|
||||
AllowedScopes = ef.AllowedScopes,
|
||||
AllowedGrantTypes = ef.AllowedGrantTypes,
|
||||
RequireClientSecret = ef.RequireClientSecret,
|
||||
RequirePkce = ef.RequirePkce,
|
||||
AllowPlainTextPkce = ef.AllowPlainTextPkce,
|
||||
ClientType = ef.ClientType,
|
||||
Properties = DeserializeDictionary(ef.Properties),
|
||||
CertificateBindings = DeserializeList<ClientCertificateBindingEntity>(ef.CertificateBindings),
|
||||
CreatedAt = ef.CreatedAt,
|
||||
UpdatedAt = ef.UpdatedAt
|
||||
};
|
||||
|
||||
private static IReadOnlyDictionary<string, string?> DeserializeDictionary(NpgsqlDataReader reader, int ordinal)
|
||||
private static IReadOnlyDictionary<string, string?> DeserializeDictionary(string? json)
|
||||
{
|
||||
if (reader.IsDBNull(ordinal))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
return new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
var json = reader.GetString(ordinal);
|
||||
return JsonSerializer.Deserialize<Dictionary<string, string?>>(json, SerializerOptions) ??
|
||||
new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
|
||||
return JsonSerializer.Deserialize<Dictionary<string, string?>>(json, SerializerOptions)
|
||||
?? new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static T? Deserialize<T>(NpgsqlDataReader reader, int ordinal)
|
||||
private static IReadOnlyList<T> DeserializeList<T>(string? json)
|
||||
{
|
||||
if (reader.IsDBNull(ordinal))
|
||||
{
|
||||
return default;
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
return Array.Empty<T>();
|
||||
|
||||
var json = reader.GetString(ordinal);
|
||||
return JsonSerializer.Deserialize<T>(json, SerializerOptions);
|
||||
return JsonSerializer.Deserialize<List<T>>(json, SerializerOptions) ?? new List<T>();
|
||||
}
|
||||
|
||||
private static string GetSchemaName() => AuthorityDataSource.DefaultSchemaName;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace StellaOps.Authority.Persistence.Postgres.Repositories;
|
||||
public interface IClientRepository
|
||||
{
|
||||
Task<ClientEntity?> FindByClientIdAsync(string clientId, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<ClientEntity>> ListAsync(int limit = 500, int offset = 0, CancellationToken cancellationToken = default);
|
||||
Task UpsertAsync(ClientEntity entity, CancellationToken cancellationToken = default);
|
||||
Task<bool> DeleteByClientIdAsync(string clientId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -1,96 +1,91 @@
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Authority.Persistence.EfCore.Models;
|
||||
using StellaOps.Authority.Persistence.Postgres.Models;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Authority.Persistence.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL repository for login attempts.
|
||||
/// PostgreSQL (EF Core) repository for login attempts.
|
||||
/// </summary>
|
||||
public sealed class LoginAttemptRepository : RepositoryBase<AuthorityDataSource>, ILoginAttemptRepository
|
||||
public sealed class LoginAttemptRepository : ILoginAttemptRepository
|
||||
{
|
||||
private const int CommandTimeoutSeconds = 30;
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.General);
|
||||
|
||||
private readonly AuthorityDataSource _dataSource;
|
||||
private readonly ILogger<LoginAttemptRepository> _logger;
|
||||
|
||||
public LoginAttemptRepository(AuthorityDataSource dataSource, ILogger<LoginAttemptRepository> logger)
|
||||
: base(dataSource, logger)
|
||||
{
|
||||
_dataSource = dataSource;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task InsertAsync(LoginAttemptEntity entity, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
INSERT INTO authority.login_attempts
|
||||
(id, subject_id, client_id, event_type, outcome, reason, ip_address, user_agent, occurred_at, properties)
|
||||
VALUES (@id, @subject_id, @client_id, @event_type, @outcome, @reason, @ip_address, @user_agent, @occurred_at, @properties)
|
||||
""";
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
await ExecuteAsync(
|
||||
tenantId: string.Empty,
|
||||
sql: sql,
|
||||
configureCommand: cmd =>
|
||||
{
|
||||
AddParameter(cmd, "id", entity.Id);
|
||||
AddParameter(cmd, "subject_id", entity.SubjectId);
|
||||
AddParameter(cmd, "client_id", entity.ClientId);
|
||||
AddParameter(cmd, "event_type", entity.EventType);
|
||||
AddParameter(cmd, "outcome", entity.Outcome);
|
||||
AddParameter(cmd, "reason", entity.Reason);
|
||||
AddParameter(cmd, "ip_address", entity.IpAddress);
|
||||
AddParameter(cmd, "user_agent", entity.UserAgent);
|
||||
AddParameter(cmd, "occurred_at", entity.OccurredAt);
|
||||
AddJsonbParameter(cmd, "properties", JsonSerializer.Serialize(entity.Properties, SerializerOptions));
|
||||
},
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
var efEntity = new LoginAttemptEfEntity
|
||||
{
|
||||
Id = entity.Id,
|
||||
SubjectId = entity.SubjectId,
|
||||
ClientId = entity.ClientId,
|
||||
EventType = entity.EventType,
|
||||
Outcome = entity.Outcome,
|
||||
Reason = entity.Reason,
|
||||
IpAddress = entity.IpAddress,
|
||||
UserAgent = entity.UserAgent,
|
||||
OccurredAt = entity.OccurredAt,
|
||||
Properties = JsonSerializer.Serialize(entity.Properties, SerializerOptions)
|
||||
};
|
||||
|
||||
dbContext.LoginAttempts.Add(efEntity);
|
||||
await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<LoginAttemptEntity>> ListRecentAsync(string subjectId, int limit, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, subject_id, client_id, event_type, outcome, reason, ip_address, user_agent, occurred_at, properties
|
||||
FROM authority.login_attempts
|
||||
WHERE subject_id = @subject_id
|
||||
ORDER BY occurred_at DESC
|
||||
LIMIT @limit
|
||||
""";
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
return await QueryAsync(
|
||||
tenantId: string.Empty,
|
||||
sql: sql,
|
||||
configureCommand: cmd =>
|
||||
{
|
||||
AddParameter(cmd, "subject_id", subjectId);
|
||||
AddParameter(cmd, "limit", limit);
|
||||
},
|
||||
mapRow: MapLoginAttempt,
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
var entities = await dbContext.LoginAttempts
|
||||
.AsNoTracking()
|
||||
.Where(la => la.SubjectId == subjectId)
|
||||
.OrderByDescending(la => la.OccurredAt)
|
||||
.Take(limit)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return entities.Select(ToModel).ToList();
|
||||
}
|
||||
|
||||
private static LoginAttemptEntity MapLoginAttempt(NpgsqlDataReader reader) => new()
|
||||
private static LoginAttemptEntity ToModel(LoginAttemptEfEntity ef) => new()
|
||||
{
|
||||
Id = reader.GetString(0),
|
||||
SubjectId = GetNullableString(reader, 1),
|
||||
ClientId = GetNullableString(reader, 2),
|
||||
EventType = reader.GetString(3),
|
||||
Outcome = reader.GetString(4),
|
||||
Reason = GetNullableString(reader, 5),
|
||||
IpAddress = GetNullableString(reader, 6),
|
||||
UserAgent = GetNullableString(reader, 7),
|
||||
OccurredAt = reader.GetFieldValue<DateTimeOffset>(8),
|
||||
Properties = DeserializeProperties(reader, 9)
|
||||
Id = ef.Id,
|
||||
SubjectId = ef.SubjectId,
|
||||
ClientId = ef.ClientId,
|
||||
EventType = ef.EventType,
|
||||
Outcome = ef.Outcome,
|
||||
Reason = ef.Reason,
|
||||
IpAddress = ef.IpAddress,
|
||||
UserAgent = ef.UserAgent,
|
||||
OccurredAt = ef.OccurredAt,
|
||||
Properties = DeserializeProperties(ef.Properties)
|
||||
};
|
||||
|
||||
private static IReadOnlyList<LoginAttemptPropertyEntity> DeserializeProperties(NpgsqlDataReader reader, int ordinal)
|
||||
private static IReadOnlyList<LoginAttemptPropertyEntity> DeserializeProperties(string? json)
|
||||
{
|
||||
if (reader.IsDBNull(ordinal))
|
||||
if (string.IsNullOrWhiteSpace(json) || json == "[]")
|
||||
{
|
||||
return Array.Empty<LoginAttemptPropertyEntity>();
|
||||
}
|
||||
|
||||
var json = reader.GetString(ordinal);
|
||||
List<LoginAttemptPropertyEntity>? parsed = JsonSerializer.Deserialize<List<LoginAttemptPropertyEntity>>(json, SerializerOptions);
|
||||
return parsed ?? new List<LoginAttemptPropertyEntity>();
|
||||
return JsonSerializer.Deserialize<List<LoginAttemptPropertyEntity>>(json, SerializerOptions)
|
||||
?? new List<LoginAttemptPropertyEntity>();
|
||||
}
|
||||
|
||||
private static string GetSchemaName() => AuthorityDataSource.DefaultSchemaName;
|
||||
}
|
||||
|
||||
@@ -1,18 +1,24 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Authority.Persistence.EfCore.Models;
|
||||
using StellaOps.Authority.Persistence.Postgres.Models;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
|
||||
namespace StellaOps.Authority.Persistence.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL repository for Offline Kit audit records.
|
||||
/// PostgreSQL (EF Core) repository for Offline Kit audit records.
|
||||
/// </summary>
|
||||
public sealed class OfflineKitAuditRepository : RepositoryBase<AuthorityDataSource>, IOfflineKitAuditRepository
|
||||
public sealed class OfflineKitAuditRepository : IOfflineKitAuditRepository
|
||||
{
|
||||
private const int CommandTimeoutSeconds = 30;
|
||||
|
||||
private readonly AuthorityDataSource _dataSource;
|
||||
private readonly ILogger<OfflineKitAuditRepository> _logger;
|
||||
|
||||
public OfflineKitAuditRepository(AuthorityDataSource dataSource, ILogger<OfflineKitAuditRepository> logger)
|
||||
: base(dataSource, logger)
|
||||
{
|
||||
_dataSource = dataSource;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task InsertAsync(OfflineKitAuditEntity entity, CancellationToken cancellationToken = default)
|
||||
@@ -24,26 +30,22 @@ public sealed class OfflineKitAuditRepository : RepositoryBase<AuthorityDataSour
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(entity.Details);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(entity.Result);
|
||||
|
||||
const string sql = """
|
||||
INSERT INTO authority.offline_kit_audit
|
||||
(event_id, tenant_id, event_type, timestamp, actor, details, result)
|
||||
VALUES (@event_id, @tenant_id, @event_type, @timestamp, @actor, @details::jsonb, @result)
|
||||
""";
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(entity.TenantId, "writer", cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
await ExecuteAsync(
|
||||
tenantId: entity.TenantId,
|
||||
sql: sql,
|
||||
configureCommand: cmd =>
|
||||
{
|
||||
AddParameter(cmd, "event_id", entity.EventId);
|
||||
AddParameter(cmd, "tenant_id", entity.TenantId);
|
||||
AddParameter(cmd, "event_type", entity.EventType);
|
||||
AddParameter(cmd, "timestamp", entity.Timestamp);
|
||||
AddParameter(cmd, "actor", entity.Actor);
|
||||
AddJsonbParameter(cmd, "details", entity.Details);
|
||||
AddParameter(cmd, "result", entity.Result);
|
||||
},
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
var efEntity = new OfflineKitAuditEfEntity
|
||||
{
|
||||
EventId = entity.EventId,
|
||||
TenantId = entity.TenantId,
|
||||
EventType = entity.EventType,
|
||||
Timestamp = entity.Timestamp,
|
||||
Actor = entity.Actor,
|
||||
Details = entity.Details,
|
||||
Result = entity.Result
|
||||
};
|
||||
|
||||
dbContext.OfflineKitAuditEntries.Add(efEntity);
|
||||
await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<OfflineKitAuditEntity>> ListAsync(
|
||||
@@ -59,45 +61,44 @@ public sealed class OfflineKitAuditRepository : RepositoryBase<AuthorityDataSour
|
||||
limit = Math.Clamp(limit, 1, 1000);
|
||||
offset = Math.Max(0, offset);
|
||||
|
||||
var (whereClause, whereParameters) = BuildWhereClause(
|
||||
("tenant_id = @tenant_id", "tenant_id", tenantId, include: true),
|
||||
("event_type = @event_type", "event_type", eventType, include: !string.IsNullOrWhiteSpace(eventType)),
|
||||
("result = @result", "result", result, include: !string.IsNullOrWhiteSpace(result)));
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var sql = $"""
|
||||
SELECT event_id, tenant_id, event_type, timestamp, actor, details, result
|
||||
FROM authority.offline_kit_audit
|
||||
{whereClause}
|
||||
ORDER BY timestamp DESC, event_id DESC
|
||||
LIMIT @limit OFFSET @offset
|
||||
""";
|
||||
IQueryable<OfflineKitAuditEfEntity> query = dbContext.OfflineKitAuditEntries
|
||||
.AsNoTracking()
|
||||
.Where(a => a.TenantId == tenantId);
|
||||
|
||||
return await QueryAsync(
|
||||
tenantId: tenantId,
|
||||
sql: sql,
|
||||
configureCommand: cmd =>
|
||||
{
|
||||
foreach (var (name, value) in whereParameters)
|
||||
{
|
||||
AddParameter(cmd, name, value);
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(eventType))
|
||||
{
|
||||
query = query.Where(a => a.EventType == eventType);
|
||||
}
|
||||
|
||||
AddParameter(cmd, "limit", limit);
|
||||
AddParameter(cmd, "offset", offset);
|
||||
},
|
||||
mapRow: MapAudit,
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
if (!string.IsNullOrWhiteSpace(result))
|
||||
{
|
||||
query = query.Where(a => a.Result == result);
|
||||
}
|
||||
|
||||
var entities = await query
|
||||
.OrderByDescending(a => a.Timestamp)
|
||||
.ThenByDescending(a => a.EventId)
|
||||
.Skip(offset)
|
||||
.Take(limit)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return entities.Select(ToModel).ToList();
|
||||
}
|
||||
|
||||
private static OfflineKitAuditEntity MapAudit(NpgsqlDataReader reader) => new()
|
||||
private static OfflineKitAuditEntity ToModel(OfflineKitAuditEfEntity ef) => new()
|
||||
{
|
||||
EventId = reader.GetGuid(0),
|
||||
TenantId = reader.GetString(1),
|
||||
EventType = reader.GetString(2),
|
||||
Timestamp = reader.GetFieldValue<DateTimeOffset>(3),
|
||||
Actor = reader.GetString(4),
|
||||
Details = reader.GetString(5),
|
||||
Result = reader.GetString(6)
|
||||
EventId = ef.EventId,
|
||||
TenantId = ef.TenantId,
|
||||
EventType = ef.EventType,
|
||||
Timestamp = ef.Timestamp,
|
||||
Actor = ef.Actor,
|
||||
Details = ef.Details,
|
||||
Result = ef.Result
|
||||
};
|
||||
}
|
||||
|
||||
private static string GetSchemaName() => AuthorityDataSource.DefaultSchemaName;
|
||||
}
|
||||
|
||||
@@ -1,222 +1,218 @@
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Authority.Persistence.EfCore.Models;
|
||||
using StellaOps.Authority.Persistence.Postgres.Models;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Authority.Persistence.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL repository for OpenIddict tokens and refresh tokens.
|
||||
/// PostgreSQL (EF Core) repository for OpenIddict tokens and refresh tokens.
|
||||
/// </summary>
|
||||
public sealed class OidcTokenRepository : RepositoryBase<AuthorityDataSource>, IOidcTokenRepository
|
||||
public sealed class OidcTokenRepository : IOidcTokenRepository
|
||||
{
|
||||
private const int CommandTimeoutSeconds = 30;
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.General);
|
||||
|
||||
private readonly AuthorityDataSource _dataSource;
|
||||
private readonly ILogger<OidcTokenRepository> _logger;
|
||||
|
||||
public OidcTokenRepository(AuthorityDataSource dataSource, ILogger<OidcTokenRepository> logger)
|
||||
: base(dataSource, logger)
|
||||
{
|
||||
_dataSource = dataSource;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<OidcTokenEntity?> FindByTokenIdAsync(string tokenId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, token_id, subject_id, client_id, token_type, reference_id, created_at, expires_at, redeemed_at, payload, properties
|
||||
FROM authority.oidc_tokens
|
||||
WHERE token_id = @token_id
|
||||
""";
|
||||
return await QuerySingleOrDefaultAsync(
|
||||
tenantId: string.Empty,
|
||||
sql: sql,
|
||||
configureCommand: cmd => AddParameter(cmd, "token_id", tokenId),
|
||||
mapRow: MapToken,
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var entity = await dbContext.OidcTokens
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(t => t.TokenId == tokenId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return entity is null ? null : ToModel(entity);
|
||||
}
|
||||
|
||||
public async Task<OidcTokenEntity?> FindByReferenceIdAsync(string referenceId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, token_id, subject_id, client_id, token_type, reference_id, created_at, expires_at, redeemed_at, payload, properties
|
||||
FROM authority.oidc_tokens
|
||||
WHERE reference_id = @reference_id
|
||||
""";
|
||||
return await QuerySingleOrDefaultAsync(
|
||||
tenantId: string.Empty,
|
||||
sql: sql,
|
||||
configureCommand: cmd => AddParameter(cmd, "reference_id", referenceId),
|
||||
mapRow: MapToken,
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var entity = await dbContext.OidcTokens
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(t => t.ReferenceId == referenceId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return entity is null ? null : ToModel(entity);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<OidcTokenEntity>> ListBySubjectAsync(string subjectId, int limit, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, token_id, subject_id, client_id, token_type, reference_id, created_at, expires_at, redeemed_at, payload, properties
|
||||
FROM authority.oidc_tokens
|
||||
WHERE subject_id = @subject_id
|
||||
ORDER BY created_at DESC
|
||||
LIMIT @limit
|
||||
""";
|
||||
return await QueryAsync(
|
||||
tenantId: string.Empty,
|
||||
sql: sql,
|
||||
configureCommand: cmd =>
|
||||
{
|
||||
AddParameter(cmd, "subject_id", subjectId);
|
||||
AddParameter(cmd, "limit", limit);
|
||||
},
|
||||
mapRow: MapToken,
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var entities = await dbContext.OidcTokens
|
||||
.AsNoTracking()
|
||||
.Where(t => t.SubjectId == subjectId)
|
||||
.OrderByDescending(t => t.CreatedAt)
|
||||
.Take(limit)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return entities.Select(ToModel).ToList();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<OidcTokenEntity>> ListByClientAsync(string clientId, int limit, int offset, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, token_id, subject_id, client_id, token_type, reference_id, created_at, expires_at, redeemed_at, payload, properties
|
||||
FROM authority.oidc_tokens
|
||||
WHERE client_id = @client_id
|
||||
ORDER BY created_at DESC, id DESC
|
||||
LIMIT @limit OFFSET @offset
|
||||
""";
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
return await QueryAsync(
|
||||
tenantId: string.Empty,
|
||||
sql: sql,
|
||||
configureCommand: cmd =>
|
||||
{
|
||||
AddParameter(cmd, "client_id", clientId);
|
||||
AddParameter(cmd, "limit", limit);
|
||||
AddParameter(cmd, "offset", offset);
|
||||
},
|
||||
mapRow: MapToken,
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
var entities = await dbContext.OidcTokens
|
||||
.AsNoTracking()
|
||||
.Where(t => t.ClientId == clientId)
|
||||
.OrderByDescending(t => t.CreatedAt)
|
||||
.ThenByDescending(t => t.Id)
|
||||
.Skip(offset)
|
||||
.Take(limit)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return entities.Select(ToModel).ToList();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<OidcTokenEntity>> ListByScopeAsync(string tenant, string scope, DateTimeOffset? issuedAfter, int limit, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, token_id, subject_id, client_id, token_type, reference_id, created_at, expires_at, redeemed_at, payload, properties
|
||||
FROM authority.oidc_tokens
|
||||
WHERE (properties->>'tenant') = @tenant
|
||||
AND position(' ' || @scope || ' ' IN ' ' || COALESCE(properties->>'scope', '') || ' ') > 0
|
||||
AND (@issued_after IS NULL OR created_at >= @issued_after)
|
||||
ORDER BY created_at DESC, id DESC
|
||||
LIMIT @limit
|
||||
""";
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
return await QueryAsync(
|
||||
tenantId: string.Empty,
|
||||
sql: sql,
|
||||
configureCommand: cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant", tenant);
|
||||
AddParameter(cmd, "scope", scope);
|
||||
AddParameter(cmd, "issued_after", issuedAfter);
|
||||
AddParameter(cmd, "limit", limit);
|
||||
},
|
||||
mapRow: MapToken,
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
// Use raw SQL for JSONB property access and string search to preserve exact SQL semantics.
|
||||
var entities = await dbContext.OidcTokens
|
||||
.FromSqlRaw(
|
||||
"""
|
||||
SELECT *
|
||||
FROM authority.oidc_tokens
|
||||
WHERE (properties->>'tenant') = {0}
|
||||
AND position(' ' || {1} || ' ' IN ' ' || COALESCE(properties->>'scope', '') || ' ') > 0
|
||||
AND ({2} IS NULL OR created_at >= {2})
|
||||
ORDER BY created_at DESC, id DESC
|
||||
LIMIT {3}
|
||||
""",
|
||||
tenant, scope,
|
||||
(object?)issuedAfter ?? DBNull.Value,
|
||||
limit)
|
||||
.AsNoTracking()
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return entities.Select(ToModel).ToList();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<OidcTokenEntity>> ListRevokedAsync(string? tenant, int limit, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, token_id, subject_id, client_id, token_type, reference_id, created_at, expires_at, redeemed_at, payload, properties
|
||||
FROM authority.oidc_tokens
|
||||
WHERE lower(COALESCE(properties->>'status', 'valid')) = 'revoked'
|
||||
AND (@tenant IS NULL OR (properties->>'tenant') = @tenant)
|
||||
ORDER BY token_id ASC, id ASC
|
||||
LIMIT @limit
|
||||
""";
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
return await QueryAsync(
|
||||
tenantId: string.Empty,
|
||||
sql: sql,
|
||||
configureCommand: cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant", tenant);
|
||||
AddParameter(cmd, "limit", limit);
|
||||
},
|
||||
mapRow: MapToken,
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
// Use raw SQL for JSONB property access to preserve exact SQL semantics.
|
||||
var entities = await dbContext.OidcTokens
|
||||
.FromSqlRaw(
|
||||
"""
|
||||
SELECT *
|
||||
FROM authority.oidc_tokens
|
||||
WHERE lower(COALESCE(properties->>'status', 'valid')) = 'revoked'
|
||||
AND ({0} IS NULL OR (properties->>'tenant') = {0})
|
||||
ORDER BY token_id ASC, id ASC
|
||||
LIMIT {1}
|
||||
""",
|
||||
(object?)tenant ?? DBNull.Value, limit)
|
||||
.AsNoTracking()
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return entities.Select(ToModel).ToList();
|
||||
}
|
||||
|
||||
public async Task<long> CountActiveDelegationTokensAsync(string tenant, string? serviceAccountId, DateTimeOffset now, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT COUNT(*)
|
||||
FROM authority.oidc_tokens
|
||||
WHERE (properties->>'tenant') = @tenant
|
||||
AND (@service_account_id IS NULL OR (properties->>'service_account_id') = @service_account_id)
|
||||
AND lower(COALESCE(properties->>'status', 'valid')) <> 'revoked'
|
||||
AND (expires_at IS NULL OR expires_at > @now)
|
||||
""";
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var count = await ExecuteScalarAsync<long>(
|
||||
tenantId: string.Empty,
|
||||
sql: sql,
|
||||
configureCommand: cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant", tenant);
|
||||
AddParameter(cmd, "service_account_id", serviceAccountId);
|
||||
AddParameter(cmd, "now", now);
|
||||
},
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
// Use raw SQL for JSONB property access to preserve exact SQL semantics.
|
||||
var results = await dbContext.Database
|
||||
.SqlQueryRaw<long>(
|
||||
"""
|
||||
SELECT COUNT(*)::bigint AS "Value"
|
||||
FROM authority.oidc_tokens
|
||||
WHERE (properties->>'tenant') = {0}
|
||||
AND ({1} IS NULL OR (properties->>'service_account_id') = {1})
|
||||
AND lower(COALESCE(properties->>'status', 'valid')) <> 'revoked'
|
||||
AND (expires_at IS NULL OR expires_at > {2})
|
||||
""",
|
||||
tenant,
|
||||
(object?)serviceAccountId ?? DBNull.Value,
|
||||
now)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return count;
|
||||
return results.FirstOrDefault();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<OidcTokenEntity>> ListActiveDelegationTokensAsync(string tenant, string? serviceAccountId, DateTimeOffset now, int limit, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, token_id, subject_id, client_id, token_type, reference_id, created_at, expires_at, redeemed_at, payload, properties
|
||||
FROM authority.oidc_tokens
|
||||
WHERE (properties->>'tenant') = @tenant
|
||||
AND (@service_account_id IS NULL OR (properties->>'service_account_id') = @service_account_id)
|
||||
AND lower(COALESCE(properties->>'status', 'valid')) <> 'revoked'
|
||||
AND (expires_at IS NULL OR expires_at > @now)
|
||||
ORDER BY created_at DESC, id DESC
|
||||
LIMIT @limit
|
||||
""";
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
return await QueryAsync(
|
||||
tenantId: string.Empty,
|
||||
sql: sql,
|
||||
configureCommand: cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant", tenant);
|
||||
AddParameter(cmd, "service_account_id", serviceAccountId);
|
||||
AddParameter(cmd, "now", now);
|
||||
AddParameter(cmd, "limit", limit);
|
||||
},
|
||||
mapRow: MapToken,
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
// Use raw SQL for JSONB property access to preserve exact SQL semantics.
|
||||
var entities = await dbContext.OidcTokens
|
||||
.FromSqlRaw(
|
||||
"""
|
||||
SELECT *
|
||||
FROM authority.oidc_tokens
|
||||
WHERE (properties->>'tenant') = {0}
|
||||
AND ({1} IS NULL OR (properties->>'service_account_id') = {1})
|
||||
AND lower(COALESCE(properties->>'status', 'valid')) <> 'revoked'
|
||||
AND (expires_at IS NULL OR expires_at > {2})
|
||||
ORDER BY created_at DESC, id DESC
|
||||
LIMIT {3}
|
||||
""",
|
||||
tenant,
|
||||
(object?)serviceAccountId ?? DBNull.Value,
|
||||
now, limit)
|
||||
.AsNoTracking()
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return entities.Select(ToModel).ToList();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<OidcTokenEntity>> ListAsync(int limit, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, token_id, subject_id, client_id, token_type, reference_id, created_at, expires_at, redeemed_at, payload, properties
|
||||
FROM authority.oidc_tokens
|
||||
ORDER BY created_at DESC
|
||||
LIMIT @limit
|
||||
""";
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
return await QueryAsync(
|
||||
tenantId: string.Empty,
|
||||
sql: sql,
|
||||
configureCommand: cmd => AddParameter(cmd, "limit", limit),
|
||||
mapRow: MapToken,
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
var entities = await dbContext.OidcTokens
|
||||
.AsNoTracking()
|
||||
.OrderByDescending(t => t.CreatedAt)
|
||||
.Take(limit)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return entities.Select(ToModel).ToList();
|
||||
}
|
||||
|
||||
public async Task UpsertAsync(OidcTokenEntity entity, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
// Use raw SQL for ON CONFLICT DO UPDATE to preserve exact SQL behavior.
|
||||
await dbContext.Database.ExecuteSqlRawAsync(
|
||||
"""
|
||||
INSERT INTO authority.oidc_tokens
|
||||
(id, token_id, subject_id, client_id, token_type, reference_id, created_at, expires_at, redeemed_at, payload, properties)
|
||||
VALUES (@id, @token_id, @subject_id, @client_id, @token_type, @reference_id, @created_at, @expires_at, @redeemed_at, @payload, @properties)
|
||||
VALUES ({0}, {1}, {2}, {3}, {4}, {5}, {6}, {7}, {8}, {9}, {10}::jsonb)
|
||||
ON CONFLICT (token_id) DO UPDATE
|
||||
SET subject_id = EXCLUDED.subject_id,
|
||||
client_id = EXCLUDED.client_id,
|
||||
@@ -227,95 +223,92 @@ public sealed class OidcTokenRepository : RepositoryBase<AuthorityDataSource>, I
|
||||
redeemed_at = EXCLUDED.redeemed_at,
|
||||
payload = EXCLUDED.payload,
|
||||
properties = EXCLUDED.properties
|
||||
""";
|
||||
|
||||
await ExecuteAsync(
|
||||
tenantId: string.Empty,
|
||||
sql: sql,
|
||||
configureCommand: cmd =>
|
||||
{
|
||||
AddParameter(cmd, "id", entity.Id);
|
||||
AddParameter(cmd, "token_id", entity.TokenId);
|
||||
AddParameter(cmd, "subject_id", entity.SubjectId);
|
||||
AddParameter(cmd, "client_id", entity.ClientId);
|
||||
AddParameter(cmd, "token_type", entity.TokenType);
|
||||
AddParameter(cmd, "reference_id", entity.ReferenceId);
|
||||
AddParameter(cmd, "created_at", entity.CreatedAt);
|
||||
AddParameter(cmd, "expires_at", entity.ExpiresAt);
|
||||
AddParameter(cmd, "redeemed_at", entity.RedeemedAt);
|
||||
AddParameter(cmd, "payload", entity.Payload);
|
||||
AddJsonbParameter(cmd, "properties", JsonSerializer.Serialize(entity.Properties, SerializerOptions));
|
||||
},
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
""",
|
||||
entity.Id, entity.TokenId,
|
||||
(object?)entity.SubjectId ?? DBNull.Value,
|
||||
(object?)entity.ClientId ?? DBNull.Value,
|
||||
entity.TokenType,
|
||||
(object?)entity.ReferenceId ?? DBNull.Value,
|
||||
entity.CreatedAt,
|
||||
(object?)entity.ExpiresAt ?? DBNull.Value,
|
||||
(object?)entity.RedeemedAt ?? DBNull.Value,
|
||||
(object?)entity.Payload ?? DBNull.Value,
|
||||
JsonSerializer.Serialize(entity.Properties, SerializerOptions),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<bool> RevokeAsync(string tokenId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = "DELETE FROM authority.oidc_tokens WHERE token_id = @token_id";
|
||||
var rows = await ExecuteAsync(
|
||||
tenantId: string.Empty,
|
||||
sql: sql,
|
||||
configureCommand: cmd => AddParameter(cmd, "token_id", tokenId),
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var rows = await dbContext.OidcTokens
|
||||
.Where(t => t.TokenId == tokenId)
|
||||
.ExecuteDeleteAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return rows > 0;
|
||||
}
|
||||
|
||||
public async Task<int> RevokeBySubjectAsync(string subjectId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = "DELETE FROM authority.oidc_tokens WHERE subject_id = @subject_id";
|
||||
return await ExecuteAsync(
|
||||
tenantId: string.Empty,
|
||||
sql: sql,
|
||||
configureCommand: cmd => AddParameter(cmd, "subject_id", subjectId),
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
return await dbContext.OidcTokens
|
||||
.Where(t => t.SubjectId == subjectId)
|
||||
.ExecuteDeleteAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<int> RevokeByClientAsync(string clientId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = "DELETE FROM authority.oidc_tokens WHERE client_id = @client_id";
|
||||
return await ExecuteAsync(
|
||||
tenantId: string.Empty,
|
||||
sql: sql,
|
||||
configureCommand: cmd => AddParameter(cmd, "client_id", clientId),
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
return await dbContext.OidcTokens
|
||||
.Where(t => t.ClientId == clientId)
|
||||
.ExecuteDeleteAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<OidcRefreshTokenEntity?> FindRefreshTokenAsync(string tokenId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, token_id, subject_id, client_id, handle, created_at, expires_at, consumed_at, payload
|
||||
FROM authority.oidc_refresh_tokens
|
||||
WHERE token_id = @token_id
|
||||
""";
|
||||
return await QuerySingleOrDefaultAsync(
|
||||
tenantId: string.Empty,
|
||||
sql: sql,
|
||||
configureCommand: cmd => AddParameter(cmd, "token_id", tokenId),
|
||||
mapRow: MapRefreshToken,
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var entity = await dbContext.OidcRefreshTokens
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(t => t.TokenId == tokenId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return entity is null ? null : ToRefreshModel(entity);
|
||||
}
|
||||
|
||||
public async Task<OidcRefreshTokenEntity?> FindRefreshTokenByHandleAsync(string handle, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, token_id, subject_id, client_id, handle, created_at, expires_at, consumed_at, payload
|
||||
FROM authority.oidc_refresh_tokens
|
||||
WHERE handle = @handle
|
||||
""";
|
||||
return await QuerySingleOrDefaultAsync(
|
||||
tenantId: string.Empty,
|
||||
sql: sql,
|
||||
configureCommand: cmd => AddParameter(cmd, "handle", handle),
|
||||
mapRow: MapRefreshToken,
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var entity = await dbContext.OidcRefreshTokens
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(t => t.Handle == handle, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return entity is null ? null : ToRefreshModel(entity);
|
||||
}
|
||||
|
||||
public async Task UpsertRefreshTokenAsync(OidcRefreshTokenEntity entity, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
// Use raw SQL for ON CONFLICT DO UPDATE to preserve exact SQL behavior.
|
||||
await dbContext.Database.ExecuteSqlRawAsync(
|
||||
"""
|
||||
INSERT INTO authority.oidc_refresh_tokens
|
||||
(id, token_id, subject_id, client_id, handle, created_at, expires_at, consumed_at, payload)
|
||||
VALUES (@id, @token_id, @subject_id, @client_id, @handle, @created_at, @expires_at, @consumed_at, @payload)
|
||||
VALUES ({0}, {1}, {2}, {3}, {4}, {5}, {6}, {7}, {8})
|
||||
ON CONFLICT (token_id) DO UPDATE
|
||||
SET subject_id = EXCLUDED.subject_id,
|
||||
client_id = EXCLUDED.client_id,
|
||||
@@ -324,88 +317,85 @@ public sealed class OidcTokenRepository : RepositoryBase<AuthorityDataSource>, I
|
||||
expires_at = EXCLUDED.expires_at,
|
||||
consumed_at = EXCLUDED.consumed_at,
|
||||
payload = EXCLUDED.payload
|
||||
""";
|
||||
|
||||
await ExecuteAsync(
|
||||
tenantId: string.Empty,
|
||||
sql: sql,
|
||||
configureCommand: cmd =>
|
||||
{
|
||||
AddParameter(cmd, "id", entity.Id);
|
||||
AddParameter(cmd, "token_id", entity.TokenId);
|
||||
AddParameter(cmd, "subject_id", entity.SubjectId);
|
||||
AddParameter(cmd, "client_id", entity.ClientId);
|
||||
AddParameter(cmd, "handle", entity.Handle);
|
||||
AddParameter(cmd, "created_at", entity.CreatedAt);
|
||||
AddParameter(cmd, "expires_at", entity.ExpiresAt);
|
||||
AddParameter(cmd, "consumed_at", entity.ConsumedAt);
|
||||
AddParameter(cmd, "payload", entity.Payload);
|
||||
},
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
""",
|
||||
entity.Id, entity.TokenId,
|
||||
(object?)entity.SubjectId ?? DBNull.Value,
|
||||
(object?)entity.ClientId ?? DBNull.Value,
|
||||
(object?)entity.Handle ?? DBNull.Value,
|
||||
entity.CreatedAt,
|
||||
(object?)entity.ExpiresAt ?? DBNull.Value,
|
||||
(object?)entity.ConsumedAt ?? DBNull.Value,
|
||||
(object?)entity.Payload ?? DBNull.Value,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<bool> ConsumeRefreshTokenAsync(string tokenId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
// Use raw SQL for NOW() to preserve DB clock semantics.
|
||||
var rows = await dbContext.Database.ExecuteSqlRawAsync(
|
||||
"""
|
||||
UPDATE authority.oidc_refresh_tokens
|
||||
SET consumed_at = NOW()
|
||||
WHERE token_id = @token_id AND consumed_at IS NULL
|
||||
""";
|
||||
var rows = await ExecuteAsync(
|
||||
tenantId: string.Empty,
|
||||
sql: sql,
|
||||
configureCommand: cmd => AddParameter(cmd, "token_id", tokenId),
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
WHERE token_id = {0} AND consumed_at IS NULL
|
||||
""",
|
||||
tokenId,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return rows > 0;
|
||||
}
|
||||
|
||||
public async Task<int> RevokeRefreshTokensBySubjectAsync(string subjectId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = "DELETE FROM authority.oidc_refresh_tokens WHERE subject_id = @subject_id";
|
||||
return await ExecuteAsync(
|
||||
tenantId: string.Empty,
|
||||
sql: sql,
|
||||
configureCommand: cmd => AddParameter(cmd, "subject_id", subjectId),
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
return await dbContext.OidcRefreshTokens
|
||||
.Where(t => t.SubjectId == subjectId)
|
||||
.ExecuteDeleteAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static OidcTokenEntity MapToken(NpgsqlDataReader reader) => new()
|
||||
private static OidcTokenEntity ToModel(OidcTokenEfEntity ef) => new()
|
||||
{
|
||||
Id = reader.GetString(0),
|
||||
TokenId = reader.GetString(1),
|
||||
SubjectId = GetNullableString(reader, 2),
|
||||
ClientId = GetNullableString(reader, 3),
|
||||
TokenType = reader.GetString(4),
|
||||
ReferenceId = GetNullableString(reader, 5),
|
||||
CreatedAt = reader.GetFieldValue<DateTimeOffset>(6),
|
||||
ExpiresAt = reader.IsDBNull(7) ? null : reader.GetFieldValue<DateTimeOffset>(7),
|
||||
RedeemedAt = reader.IsDBNull(8) ? null : reader.GetFieldValue<DateTimeOffset>(8),
|
||||
Payload = GetNullableString(reader, 9),
|
||||
Properties = DeserializeProperties(reader, 10)
|
||||
Id = ef.Id,
|
||||
TokenId = ef.TokenId,
|
||||
SubjectId = ef.SubjectId,
|
||||
ClientId = ef.ClientId,
|
||||
TokenType = ef.TokenType,
|
||||
ReferenceId = ef.ReferenceId,
|
||||
CreatedAt = ef.CreatedAt,
|
||||
ExpiresAt = ef.ExpiresAt,
|
||||
RedeemedAt = ef.RedeemedAt,
|
||||
Payload = ef.Payload,
|
||||
Properties = DeserializeProperties(ef.Properties)
|
||||
};
|
||||
|
||||
private static OidcRefreshTokenEntity MapRefreshToken(NpgsqlDataReader reader) => new()
|
||||
private static OidcRefreshTokenEntity ToRefreshModel(OidcRefreshTokenEfEntity ef) => new()
|
||||
{
|
||||
Id = reader.GetString(0),
|
||||
TokenId = reader.GetString(1),
|
||||
SubjectId = GetNullableString(reader, 2),
|
||||
ClientId = GetNullableString(reader, 3),
|
||||
Handle = GetNullableString(reader, 4),
|
||||
CreatedAt = reader.GetFieldValue<DateTimeOffset>(5),
|
||||
ExpiresAt = reader.IsDBNull(6) ? null : reader.GetFieldValue<DateTimeOffset>(6),
|
||||
ConsumedAt = reader.IsDBNull(7) ? null : reader.GetFieldValue<DateTimeOffset>(7),
|
||||
Payload = GetNullableString(reader, 8)
|
||||
Id = ef.Id,
|
||||
TokenId = ef.TokenId,
|
||||
SubjectId = ef.SubjectId,
|
||||
ClientId = ef.ClientId,
|
||||
Handle = ef.Handle,
|
||||
CreatedAt = ef.CreatedAt,
|
||||
ExpiresAt = ef.ExpiresAt,
|
||||
ConsumedAt = ef.ConsumedAt,
|
||||
Payload = ef.Payload
|
||||
};
|
||||
|
||||
private static IReadOnlyDictionary<string, string> DeserializeProperties(NpgsqlDataReader reader, int ordinal)
|
||||
private static IReadOnlyDictionary<string, string> DeserializeProperties(string? json)
|
||||
{
|
||||
if (reader.IsDBNull(ordinal))
|
||||
if (string.IsNullOrWhiteSpace(json) || json == "{}")
|
||||
{
|
||||
return new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
var json = reader.GetString(ordinal);
|
||||
return JsonSerializer.Deserialize<Dictionary<string, string>>(json, SerializerOptions) ??
|
||||
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static string GetSchemaName() => AuthorityDataSource.DefaultSchemaName;
|
||||
}
|
||||
|
||||
@@ -1,158 +1,200 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Authority.Persistence.EfCore.Models;
|
||||
using StellaOps.Authority.Persistence.Postgres.Models;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
|
||||
namespace StellaOps.Authority.Persistence.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL repository for permission operations.
|
||||
/// PostgreSQL (EF Core) repository for permission operations.
|
||||
/// </summary>
|
||||
public sealed class PermissionRepository : RepositoryBase<AuthorityDataSource>, IPermissionRepository
|
||||
public sealed class PermissionRepository : IPermissionRepository
|
||||
{
|
||||
private const int CommandTimeoutSeconds = 30;
|
||||
|
||||
private readonly AuthorityDataSource _dataSource;
|
||||
private readonly ILogger<PermissionRepository> _logger;
|
||||
|
||||
public PermissionRepository(AuthorityDataSource dataSource, ILogger<PermissionRepository> logger)
|
||||
: base(dataSource, logger) { }
|
||||
{
|
||||
_dataSource = dataSource;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<PermissionEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, tenant_id, name, resource, action, description, created_at
|
||||
FROM authority.permissions
|
||||
WHERE tenant_id = @tenant_id AND id = @id
|
||||
""";
|
||||
return await QuerySingleOrDefaultAsync(tenantId, sql,
|
||||
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "id", id); },
|
||||
MapPermission, cancellationToken).ConfigureAwait(false);
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var entity = await dbContext.Permissions
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(p => p.TenantId == tenantId && p.Id == id, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return entity is null ? null : ToModel(entity);
|
||||
}
|
||||
|
||||
public async Task<PermissionEntity?> GetByNameAsync(string tenantId, string name, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, tenant_id, name, resource, action, description, created_at
|
||||
FROM authority.permissions
|
||||
WHERE tenant_id = @tenant_id AND name = @name
|
||||
""";
|
||||
return await QuerySingleOrDefaultAsync(tenantId, sql,
|
||||
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "name", name); },
|
||||
MapPermission, cancellationToken).ConfigureAwait(false);
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var entity = await dbContext.Permissions
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(p => p.TenantId == tenantId && p.Name == name, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return entity is null ? null : ToModel(entity);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<PermissionEntity>> ListAsync(string tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, tenant_id, name, resource, action, description, created_at
|
||||
FROM authority.permissions
|
||||
WHERE tenant_id = @tenant_id
|
||||
ORDER BY resource, action
|
||||
""";
|
||||
return await QueryAsync(tenantId, sql,
|
||||
cmd => AddParameter(cmd, "tenant_id", tenantId),
|
||||
MapPermission, cancellationToken).ConfigureAwait(false);
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var entities = await dbContext.Permissions
|
||||
.AsNoTracking()
|
||||
.Where(p => p.TenantId == tenantId)
|
||||
.OrderBy(p => p.Resource)
|
||||
.ThenBy(p => p.Action)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return entities.Select(ToModel).ToList();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<PermissionEntity>> GetByResourceAsync(string tenantId, string resource, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, tenant_id, name, resource, action, description, created_at
|
||||
FROM authority.permissions
|
||||
WHERE tenant_id = @tenant_id AND resource = @resource
|
||||
ORDER BY action
|
||||
""";
|
||||
return await QueryAsync(tenantId, sql,
|
||||
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "resource", resource); },
|
||||
MapPermission, cancellationToken).ConfigureAwait(false);
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var entities = await dbContext.Permissions
|
||||
.AsNoTracking()
|
||||
.Where(p => p.TenantId == tenantId && p.Resource == resource)
|
||||
.OrderBy(p => p.Action)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return entities.Select(ToModel).ToList();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<PermissionEntity>> GetRolePermissionsAsync(string tenantId, Guid roleId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT p.id, p.tenant_id, p.name, p.resource, p.action, p.description, p.created_at
|
||||
FROM authority.permissions p
|
||||
INNER JOIN authority.role_permissions rp ON p.id = rp.permission_id
|
||||
WHERE p.tenant_id = @tenant_id AND rp.role_id = @role_id
|
||||
ORDER BY p.resource, p.action
|
||||
""";
|
||||
return await QueryAsync(tenantId, sql,
|
||||
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "role_id", roleId); },
|
||||
MapPermission, cancellationToken).ConfigureAwait(false);
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
// Use raw SQL for the JOIN to preserve exact SQL semantics.
|
||||
var entities = await dbContext.Permissions
|
||||
.FromSqlRaw(
|
||||
"""
|
||||
SELECT p.*
|
||||
FROM authority.permissions p
|
||||
INNER JOIN authority.role_permissions rp ON p.id = rp.permission_id
|
||||
WHERE p.tenant_id = {0} AND rp.role_id = {1}
|
||||
ORDER BY p.resource, p.action
|
||||
""",
|
||||
tenantId, roleId)
|
||||
.AsNoTracking()
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return entities.Select(ToModel).ToList();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<PermissionEntity>> GetUserPermissionsAsync(string tenantId, Guid userId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT DISTINCT p.id, p.tenant_id, p.name, p.resource, p.action, p.description, p.created_at
|
||||
FROM authority.permissions p
|
||||
INNER JOIN authority.role_permissions rp ON p.id = rp.permission_id
|
||||
INNER JOIN authority.user_roles ur ON rp.role_id = ur.role_id
|
||||
WHERE p.tenant_id = @tenant_id AND ur.user_id = @user_id
|
||||
AND (ur.expires_at IS NULL OR ur.expires_at > NOW())
|
||||
ORDER BY p.resource, p.action
|
||||
""";
|
||||
return await QueryAsync(tenantId, sql,
|
||||
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "user_id", userId); },
|
||||
MapPermission, cancellationToken).ConfigureAwait(false);
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
// Use raw SQL for multi-JOIN with NOW() filtering to preserve exact SQL semantics.
|
||||
var entities = await dbContext.Permissions
|
||||
.FromSqlRaw(
|
||||
"""
|
||||
SELECT DISTINCT p.*
|
||||
FROM authority.permissions p
|
||||
INNER JOIN authority.role_permissions rp ON p.id = rp.permission_id
|
||||
INNER JOIN authority.user_roles ur ON rp.role_id = ur.role_id
|
||||
WHERE p.tenant_id = {0} AND ur.user_id = {1}
|
||||
AND (ur.expires_at IS NULL OR ur.expires_at > NOW())
|
||||
ORDER BY p.resource, p.action
|
||||
""",
|
||||
tenantId, userId)
|
||||
.AsNoTracking()
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return entities.Select(ToModel).ToList();
|
||||
}
|
||||
|
||||
public async Task<Guid> CreateAsync(string tenantId, PermissionEntity permission, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
INSERT INTO authority.permissions (id, tenant_id, name, resource, action, description)
|
||||
VALUES (@id, @tenant_id, @name, @resource, @action, @description)
|
||||
RETURNING id
|
||||
""";
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var id = permission.Id == Guid.Empty ? Guid.NewGuid() : permission.Id;
|
||||
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken).ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
AddParameter(command, "id", id);
|
||||
AddParameter(command, "tenant_id", tenantId);
|
||||
AddParameter(command, "name", permission.Name);
|
||||
AddParameter(command, "resource", permission.Resource);
|
||||
AddParameter(command, "action", permission.Action);
|
||||
AddParameter(command, "description", permission.Description);
|
||||
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
var efEntity = new PermissionEfEntity
|
||||
{
|
||||
Id = id,
|
||||
TenantId = tenantId,
|
||||
Name = permission.Name,
|
||||
Resource = permission.Resource,
|
||||
Action = permission.Action,
|
||||
Description = permission.Description
|
||||
};
|
||||
|
||||
dbContext.Permissions.Add(efEntity);
|
||||
await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
return id;
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = "DELETE FROM authority.permissions WHERE tenant_id = @tenant_id AND id = @id";
|
||||
await ExecuteAsync(tenantId, sql,
|
||||
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "id", id); },
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
await dbContext.Permissions
|
||||
.Where(p => p.TenantId == tenantId && p.Id == id)
|
||||
.ExecuteDeleteAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task AssignToRoleAsync(string tenantId, Guid roleId, Guid permissionId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
// Use raw SQL for ON CONFLICT DO NOTHING to preserve exact SQL behavior.
|
||||
await dbContext.Database.ExecuteSqlRawAsync(
|
||||
"""
|
||||
INSERT INTO authority.role_permissions (role_id, permission_id)
|
||||
VALUES (@role_id, @permission_id)
|
||||
VALUES ({0}, {1})
|
||||
ON CONFLICT (role_id, permission_id) DO NOTHING
|
||||
""";
|
||||
await ExecuteAsync(tenantId, sql, cmd =>
|
||||
{
|
||||
AddParameter(cmd, "role_id", roleId);
|
||||
AddParameter(cmd, "permission_id", permissionId);
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
""",
|
||||
roleId, permissionId,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task RemoveFromRoleAsync(string tenantId, Guid roleId, Guid permissionId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = "DELETE FROM authority.role_permissions WHERE role_id = @role_id AND permission_id = @permission_id";
|
||||
await ExecuteAsync(tenantId, sql, cmd =>
|
||||
{
|
||||
AddParameter(cmd, "role_id", roleId);
|
||||
AddParameter(cmd, "permission_id", permissionId);
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
await dbContext.RolePermissions
|
||||
.Where(rp => rp.RoleId == roleId && rp.PermissionId == permissionId)
|
||||
.ExecuteDeleteAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static PermissionEntity MapPermission(NpgsqlDataReader reader) => new()
|
||||
private static PermissionEntity ToModel(PermissionEfEntity ef) => new()
|
||||
{
|
||||
Id = reader.GetGuid(0),
|
||||
TenantId = reader.GetString(1),
|
||||
Name = reader.GetString(2),
|
||||
Resource = reader.GetString(3),
|
||||
Action = reader.GetString(4),
|
||||
Description = GetNullableString(reader, 5),
|
||||
CreatedAt = reader.GetFieldValue<DateTimeOffset>(6)
|
||||
Id = ef.Id,
|
||||
TenantId = ef.TenantId,
|
||||
Name = ef.Name,
|
||||
Resource = ef.Resource,
|
||||
Action = ef.Action,
|
||||
Description = ef.Description,
|
||||
CreatedAt = ef.CreatedAt
|
||||
};
|
||||
|
||||
private static string GetSchemaName() => AuthorityDataSource.DefaultSchemaName;
|
||||
}
|
||||
|
||||
@@ -1,59 +1,60 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Authority.Persistence.EfCore.Models;
|
||||
using StellaOps.Authority.Persistence.Postgres.Models;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
|
||||
namespace StellaOps.Authority.Persistence.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Repository that persists revocation export sequence state.
|
||||
/// PostgreSQL (EF Core) repository that persists revocation export sequence state.
|
||||
/// </summary>
|
||||
public sealed class RevocationExportStateRepository : RepositoryBase<AuthorityDataSource>
|
||||
public sealed class RevocationExportStateRepository
|
||||
{
|
||||
private const int CommandTimeoutSeconds = 30;
|
||||
|
||||
private readonly AuthorityDataSource _dataSource;
|
||||
private readonly ILogger<RevocationExportStateRepository> _logger;
|
||||
|
||||
public RevocationExportStateRepository(AuthorityDataSource dataSource, ILogger<RevocationExportStateRepository> logger)
|
||||
: base(dataSource, logger)
|
||||
{
|
||||
_dataSource = dataSource;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<RevocationExportStateEntity?> GetAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, sequence, bundle_id, issued_at
|
||||
FROM authority.revocation_export_state
|
||||
WHERE id = 1
|
||||
""";
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
return await QuerySingleOrDefaultAsync(
|
||||
tenantId: string.Empty,
|
||||
sql: sql,
|
||||
configureCommand: static _ => { },
|
||||
mapRow: MapState,
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
var entity = await dbContext.RevocationExportState
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(s => s.Id == 1, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return entity is null ? null : ToModel(entity);
|
||||
}
|
||||
|
||||
public async Task UpsertAsync(long expectedSequence, RevocationExportStateEntity entity, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
// Use raw SQL for ON CONFLICT with optimistic WHERE clause to preserve exact SQL behavior.
|
||||
var affected = await dbContext.Database.ExecuteSqlRawAsync(
|
||||
"""
|
||||
INSERT INTO authority.revocation_export_state (id, sequence, bundle_id, issued_at)
|
||||
VALUES (1, @sequence, @bundle_id, @issued_at)
|
||||
VALUES (1, {0}, {1}, {2})
|
||||
ON CONFLICT (id) DO UPDATE
|
||||
SET sequence = EXCLUDED.sequence,
|
||||
bundle_id = EXCLUDED.bundle_id,
|
||||
issued_at = EXCLUDED.issued_at
|
||||
WHERE authority.revocation_export_state.sequence = @expected_sequence
|
||||
""";
|
||||
|
||||
var affected = await ExecuteAsync(
|
||||
tenantId: string.Empty,
|
||||
sql: sql,
|
||||
configureCommand: cmd =>
|
||||
{
|
||||
AddParameter(cmd, "sequence", entity.Sequence);
|
||||
AddParameter(cmd, "bundle_id", entity.BundleId);
|
||||
AddParameter(cmd, "issued_at", entity.IssuedAt);
|
||||
AddParameter(cmd, "expected_sequence", expectedSequence);
|
||||
},
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
WHERE authority.revocation_export_state.sequence = {3}
|
||||
""",
|
||||
entity.Sequence,
|
||||
(object?)entity.BundleId ?? DBNull.Value,
|
||||
(object?)entity.IssuedAt ?? DBNull.Value,
|
||||
expectedSequence,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (affected == 0)
|
||||
{
|
||||
@@ -61,11 +62,13 @@ public sealed class RevocationExportStateRepository : RepositoryBase<AuthorityDa
|
||||
}
|
||||
}
|
||||
|
||||
private static RevocationExportStateEntity MapState(NpgsqlDataReader reader) => new()
|
||||
private static RevocationExportStateEntity ToModel(RevocationExportStateEfEntity ef) => new()
|
||||
{
|
||||
Id = reader.GetInt32(0),
|
||||
Sequence = reader.GetInt64(1),
|
||||
BundleId = reader.IsDBNull(2) ? null : reader.GetString(2),
|
||||
IssuedAt = reader.IsDBNull(3) ? null : reader.GetFieldValue<DateTimeOffset>(3)
|
||||
Id = ef.Id,
|
||||
Sequence = ef.Sequence,
|
||||
BundleId = ef.BundleId,
|
||||
IssuedAt = ef.IssuedAt
|
||||
};
|
||||
|
||||
private static string GetSchemaName() => AuthorityDataSource.DefaultSchemaName;
|
||||
}
|
||||
|
||||
@@ -1,30 +1,39 @@
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Authority.Persistence.EfCore.Models;
|
||||
using StellaOps.Authority.Persistence.Postgres.Models;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Authority.Persistence.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL repository for revocations.
|
||||
/// PostgreSQL (EF Core) repository for revocations.
|
||||
/// </summary>
|
||||
public sealed class RevocationRepository : RepositoryBase<AuthorityDataSource>, IRevocationRepository
|
||||
public sealed class RevocationRepository : IRevocationRepository
|
||||
{
|
||||
private const int CommandTimeoutSeconds = 30;
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.General);
|
||||
|
||||
private readonly AuthorityDataSource _dataSource;
|
||||
private readonly ILogger<RevocationRepository> _logger;
|
||||
|
||||
public RevocationRepository(AuthorityDataSource dataSource, ILogger<RevocationRepository> logger)
|
||||
: base(dataSource, logger)
|
||||
{
|
||||
_dataSource = dataSource;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task UpsertAsync(RevocationEntity entity, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
// Use raw SQL for ON CONFLICT DO UPDATE to preserve exact SQL behavior.
|
||||
await dbContext.Database.ExecuteSqlRawAsync(
|
||||
"""
|
||||
INSERT INTO authority.revocations
|
||||
(id, category, revocation_id, subject_id, client_id, token_id, reason, reason_description, revoked_at, effective_at, expires_at, metadata)
|
||||
VALUES (@id, @category, @revocation_id, @subject_id, @client_id, @token_id, @reason, @reason_description, @revoked_at, @effective_at, @expires_at, @metadata)
|
||||
VALUES ({0}, {1}, {2}, {3}, {4}, {5}, {6}, {7}, {8}, {9}, {10}, {11}::jsonb)
|
||||
ON CONFLICT (category, revocation_id) DO UPDATE
|
||||
SET subject_id = EXCLUDED.subject_id,
|
||||
client_id = EXCLUDED.client_id,
|
||||
@@ -35,88 +44,70 @@ public sealed class RevocationRepository : RepositoryBase<AuthorityDataSource>,
|
||||
effective_at = EXCLUDED.effective_at,
|
||||
expires_at = EXCLUDED.expires_at,
|
||||
metadata = EXCLUDED.metadata
|
||||
""";
|
||||
|
||||
await ExecuteAsync(
|
||||
tenantId: string.Empty,
|
||||
sql: sql,
|
||||
configureCommand: cmd =>
|
||||
{
|
||||
AddParameter(cmd, "id", entity.Id);
|
||||
AddParameter(cmd, "category", entity.Category);
|
||||
AddParameter(cmd, "revocation_id", entity.RevocationId);
|
||||
AddParameter(cmd, "subject_id", entity.SubjectId);
|
||||
AddParameter(cmd, "client_id", entity.ClientId);
|
||||
AddParameter(cmd, "token_id", entity.TokenId);
|
||||
AddParameter(cmd, "reason", entity.Reason);
|
||||
AddParameter(cmd, "reason_description", entity.ReasonDescription);
|
||||
AddParameter(cmd, "revoked_at", entity.RevokedAt);
|
||||
AddParameter(cmd, "effective_at", entity.EffectiveAt);
|
||||
AddParameter(cmd, "expires_at", entity.ExpiresAt);
|
||||
AddJsonbParameter(cmd, "metadata", JsonSerializer.Serialize(entity.Metadata, SerializerOptions));
|
||||
},
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
""",
|
||||
entity.Id, entity.Category, entity.RevocationId,
|
||||
(object?)entity.SubjectId ?? DBNull.Value,
|
||||
(object?)entity.ClientId ?? DBNull.Value,
|
||||
(object?)entity.TokenId ?? DBNull.Value,
|
||||
entity.Reason,
|
||||
(object?)entity.ReasonDescription ?? DBNull.Value,
|
||||
entity.RevokedAt, entity.EffectiveAt,
|
||||
(object?)entity.ExpiresAt ?? DBNull.Value,
|
||||
JsonSerializer.Serialize(entity.Metadata, SerializerOptions),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<RevocationEntity>> GetActiveAsync(DateTimeOffset asOf, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, category, revocation_id, subject_id, client_id, token_id, reason, reason_description, revoked_at, effective_at, expires_at, metadata
|
||||
FROM authority.revocations
|
||||
WHERE effective_at <= @as_of
|
||||
AND (expires_at IS NULL OR expires_at > @as_of)
|
||||
""";
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
return await QueryAsync(
|
||||
tenantId: string.Empty,
|
||||
sql: sql,
|
||||
configureCommand: cmd => AddParameter(cmd, "as_of", asOf),
|
||||
mapRow: MapRevocation,
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
var entities = await dbContext.Revocations
|
||||
.AsNoTracking()
|
||||
.Where(r => r.EffectiveAt <= asOf && (r.ExpiresAt == null || r.ExpiresAt > asOf))
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return entities.Select(ToModel).ToList();
|
||||
}
|
||||
|
||||
public async Task RemoveAsync(string category, string revocationId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
DELETE FROM authority.revocations
|
||||
WHERE category = @category AND revocation_id = @revocation_id
|
||||
""";
|
||||
await ExecuteAsync(
|
||||
tenantId: string.Empty,
|
||||
sql: sql,
|
||||
configureCommand: cmd =>
|
||||
{
|
||||
AddParameter(cmd, "category", category);
|
||||
AddParameter(cmd, "revocation_id", revocationId);
|
||||
},
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
await dbContext.Revocations
|
||||
.Where(r => r.Category == category && r.RevocationId == revocationId)
|
||||
.ExecuteDeleteAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static RevocationEntity MapRevocation(NpgsqlDataReader reader) => new()
|
||||
private static RevocationEntity ToModel(RevocationEfEntity ef) => new()
|
||||
{
|
||||
Id = reader.GetString(0),
|
||||
Category = reader.GetString(1),
|
||||
RevocationId = reader.GetString(2),
|
||||
SubjectId = reader.IsDBNull(3) ? string.Empty : reader.GetString(3),
|
||||
ClientId = GetNullableString(reader, 4),
|
||||
TokenId = GetNullableString(reader, 5),
|
||||
Reason = reader.GetString(6),
|
||||
ReasonDescription = GetNullableString(reader, 7),
|
||||
RevokedAt = reader.GetFieldValue<DateTimeOffset>(8),
|
||||
EffectiveAt = reader.GetFieldValue<DateTimeOffset>(9),
|
||||
ExpiresAt = reader.IsDBNull(10) ? null : reader.GetFieldValue<DateTimeOffset>(10),
|
||||
Metadata = DeserializeMetadata(reader, 11)
|
||||
Id = ef.Id,
|
||||
Category = ef.Category,
|
||||
RevocationId = ef.RevocationId,
|
||||
SubjectId = ef.SubjectId ?? string.Empty,
|
||||
ClientId = ef.ClientId,
|
||||
TokenId = ef.TokenId,
|
||||
Reason = ef.Reason,
|
||||
ReasonDescription = ef.ReasonDescription,
|
||||
RevokedAt = ef.RevokedAt,
|
||||
EffectiveAt = ef.EffectiveAt,
|
||||
ExpiresAt = ef.ExpiresAt,
|
||||
Metadata = DeserializeMetadata(ef.Metadata)
|
||||
};
|
||||
|
||||
private static IReadOnlyDictionary<string, string?> DeserializeMetadata(NpgsqlDataReader reader, int ordinal)
|
||||
private static IReadOnlyDictionary<string, string?> DeserializeMetadata(string? json)
|
||||
{
|
||||
if (reader.IsDBNull(ordinal))
|
||||
if (string.IsNullOrWhiteSpace(json) || json == "{}")
|
||||
{
|
||||
return new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
var json = reader.GetString(ordinal);
|
||||
Dictionary<string, string?>? parsed = JsonSerializer.Deserialize<Dictionary<string, string?>>(json, SerializerOptions);
|
||||
return parsed ?? new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
|
||||
return JsonSerializer.Deserialize<Dictionary<string, string?>>(json, SerializerOptions)
|
||||
?? new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static string GetSchemaName() => AuthorityDataSource.DefaultSchemaName;
|
||||
}
|
||||
|
||||
@@ -1,156 +1,186 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Authority.Persistence.EfCore.Models;
|
||||
using StellaOps.Authority.Persistence.Postgres.Models;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
|
||||
namespace StellaOps.Authority.Persistence.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL repository for role operations.
|
||||
/// PostgreSQL (EF Core) repository for role operations.
|
||||
/// </summary>
|
||||
public sealed class RoleRepository : RepositoryBase<AuthorityDataSource>, IRoleRepository
|
||||
public sealed class RoleRepository : IRoleRepository
|
||||
{
|
||||
private const int CommandTimeoutSeconds = 30;
|
||||
|
||||
private readonly AuthorityDataSource _dataSource;
|
||||
private readonly ILogger<RoleRepository> _logger;
|
||||
|
||||
public RoleRepository(AuthorityDataSource dataSource, ILogger<RoleRepository> logger)
|
||||
: base(dataSource, logger) { }
|
||||
{
|
||||
_dataSource = dataSource;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<RoleEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, tenant_id, name, display_name, description, is_system, metadata, created_at, updated_at
|
||||
FROM authority.roles
|
||||
WHERE tenant_id = @tenant_id AND id = @id
|
||||
""";
|
||||
return await QuerySingleOrDefaultAsync(tenantId, sql,
|
||||
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "id", id); },
|
||||
MapRole, cancellationToken).ConfigureAwait(false);
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var entity = await dbContext.Roles
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(r => r.TenantId == tenantId && r.Id == id, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return entity is null ? null : ToModel(entity);
|
||||
}
|
||||
|
||||
public async Task<RoleEntity?> GetByNameAsync(string tenantId, string name, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, tenant_id, name, display_name, description, is_system, metadata, created_at, updated_at
|
||||
FROM authority.roles
|
||||
WHERE tenant_id = @tenant_id AND name = @name
|
||||
""";
|
||||
return await QuerySingleOrDefaultAsync(tenantId, sql,
|
||||
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "name", name); },
|
||||
MapRole, cancellationToken).ConfigureAwait(false);
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var entity = await dbContext.Roles
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(r => r.TenantId == tenantId && r.Name == name, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return entity is null ? null : ToModel(entity);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<RoleEntity>> ListAsync(string tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, tenant_id, name, display_name, description, is_system, metadata, created_at, updated_at
|
||||
FROM authority.roles
|
||||
WHERE tenant_id = @tenant_id
|
||||
ORDER BY name
|
||||
""";
|
||||
return await QueryAsync(tenantId, sql,
|
||||
cmd => AddParameter(cmd, "tenant_id", tenantId),
|
||||
MapRole, cancellationToken).ConfigureAwait(false);
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var entities = await dbContext.Roles
|
||||
.AsNoTracking()
|
||||
.Where(r => r.TenantId == tenantId)
|
||||
.OrderBy(r => r.Name)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return entities.Select(ToModel).ToList();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<RoleEntity>> GetUserRolesAsync(string tenantId, Guid userId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT r.id, r.tenant_id, r.name, r.display_name, r.description, r.is_system, r.metadata, r.created_at, r.updated_at
|
||||
FROM authority.roles r
|
||||
INNER JOIN authority.user_roles ur ON r.id = ur.role_id
|
||||
WHERE r.tenant_id = @tenant_id AND ur.user_id = @user_id
|
||||
AND (ur.expires_at IS NULL OR ur.expires_at > NOW())
|
||||
ORDER BY r.name
|
||||
""";
|
||||
return await QueryAsync(tenantId, sql,
|
||||
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "user_id", userId); },
|
||||
MapRole, cancellationToken).ConfigureAwait(false);
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
// Use raw SQL for the JOIN + NOW() comparison to preserve exact SQL semantics.
|
||||
var entities = await dbContext.Roles
|
||||
.FromSqlRaw(
|
||||
"""
|
||||
SELECT r.*
|
||||
FROM authority.roles r
|
||||
INNER JOIN authority.user_roles ur ON r.id = ur.role_id
|
||||
WHERE r.tenant_id = {0} AND ur.user_id = {1}
|
||||
AND (ur.expires_at IS NULL OR ur.expires_at > NOW())
|
||||
ORDER BY r.name
|
||||
""",
|
||||
tenantId, userId)
|
||||
.AsNoTracking()
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return entities.Select(ToModel).ToList();
|
||||
}
|
||||
|
||||
public async Task<Guid> CreateAsync(string tenantId, RoleEntity role, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
INSERT INTO authority.roles (id, tenant_id, name, display_name, description, is_system, metadata)
|
||||
VALUES (@id, @tenant_id, @name, @display_name, @description, @is_system, @metadata::jsonb)
|
||||
RETURNING id
|
||||
""";
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var id = role.Id == Guid.Empty ? Guid.NewGuid() : role.Id;
|
||||
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken).ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
AddParameter(command, "id", id);
|
||||
AddParameter(command, "tenant_id", tenantId);
|
||||
AddParameter(command, "name", role.Name);
|
||||
AddParameter(command, "display_name", role.DisplayName);
|
||||
AddParameter(command, "description", role.Description);
|
||||
AddParameter(command, "is_system", role.IsSystem);
|
||||
AddJsonbParameter(command, "metadata", role.Metadata);
|
||||
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
var efEntity = new RoleEfEntity
|
||||
{
|
||||
Id = id,
|
||||
TenantId = tenantId,
|
||||
Name = role.Name,
|
||||
DisplayName = role.DisplayName,
|
||||
Description = role.Description,
|
||||
IsSystem = role.IsSystem,
|
||||
Metadata = role.Metadata
|
||||
};
|
||||
|
||||
dbContext.Roles.Add(efEntity);
|
||||
await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
return id;
|
||||
}
|
||||
|
||||
public async Task UpdateAsync(string tenantId, RoleEntity role, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
UPDATE authority.roles
|
||||
SET name = @name, display_name = @display_name, description = @description,
|
||||
is_system = @is_system, metadata = @metadata::jsonb
|
||||
WHERE tenant_id = @tenant_id AND id = @id
|
||||
""";
|
||||
await ExecuteAsync(tenantId, sql, cmd =>
|
||||
{
|
||||
AddParameter(cmd, "id", role.Id);
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "name", role.Name);
|
||||
AddParameter(cmd, "display_name", role.DisplayName);
|
||||
AddParameter(cmd, "description", role.Description);
|
||||
AddParameter(cmd, "is_system", role.IsSystem);
|
||||
AddJsonbParameter(cmd, "metadata", role.Metadata);
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var existing = await dbContext.Roles
|
||||
.FirstOrDefaultAsync(r => r.TenantId == tenantId && r.Id == role.Id, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (existing is null) return;
|
||||
|
||||
existing.Name = role.Name;
|
||||
existing.DisplayName = role.DisplayName;
|
||||
existing.Description = role.Description;
|
||||
existing.IsSystem = role.IsSystem;
|
||||
existing.Metadata = role.Metadata;
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = "DELETE FROM authority.roles WHERE tenant_id = @tenant_id AND id = @id";
|
||||
await ExecuteAsync(tenantId, sql,
|
||||
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "id", id); },
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
await dbContext.Roles
|
||||
.Where(r => r.TenantId == tenantId && r.Id == id)
|
||||
.ExecuteDeleteAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task AssignToUserAsync(string tenantId, Guid userId, Guid roleId, string? grantedBy, DateTimeOffset? expiresAt, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
// Use raw SQL for ON CONFLICT DO UPDATE with NOW() to preserve exact SQL behavior.
|
||||
await dbContext.Database.ExecuteSqlRawAsync(
|
||||
"""
|
||||
INSERT INTO authority.user_roles (user_id, role_id, granted_by, expires_at)
|
||||
VALUES (@user_id, @role_id, @granted_by, @expires_at)
|
||||
VALUES ({0}, {1}, {2}, {3})
|
||||
ON CONFLICT (user_id, role_id) DO UPDATE SET
|
||||
granted_at = NOW(), granted_by = EXCLUDED.granted_by, expires_at = EXCLUDED.expires_at
|
||||
""";
|
||||
await ExecuteAsync(tenantId, sql, cmd =>
|
||||
{
|
||||
AddParameter(cmd, "user_id", userId);
|
||||
AddParameter(cmd, "role_id", roleId);
|
||||
AddParameter(cmd, "granted_by", grantedBy);
|
||||
AddParameter(cmd, "expires_at", expiresAt);
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
""",
|
||||
userId, roleId,
|
||||
(object?)grantedBy ?? DBNull.Value,
|
||||
(object?)expiresAt ?? DBNull.Value,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task RemoveFromUserAsync(string tenantId, Guid userId, Guid roleId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = "DELETE FROM authority.user_roles WHERE user_id = @user_id AND role_id = @role_id";
|
||||
await ExecuteAsync(tenantId, sql, cmd =>
|
||||
{
|
||||
AddParameter(cmd, "user_id", userId);
|
||||
AddParameter(cmd, "role_id", roleId);
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
await dbContext.UserRoles
|
||||
.Where(ur => ur.UserId == userId && ur.RoleId == roleId)
|
||||
.ExecuteDeleteAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static RoleEntity MapRole(NpgsqlDataReader reader) => new()
|
||||
private static RoleEntity ToModel(RoleEfEntity ef) => new()
|
||||
{
|
||||
Id = reader.GetGuid(0),
|
||||
TenantId = reader.GetString(1),
|
||||
Name = reader.GetString(2),
|
||||
DisplayName = GetNullableString(reader, 3),
|
||||
Description = GetNullableString(reader, 4),
|
||||
IsSystem = reader.GetBoolean(5),
|
||||
Metadata = reader.GetString(6),
|
||||
CreatedAt = reader.GetFieldValue<DateTimeOffset>(7),
|
||||
UpdatedAt = reader.GetFieldValue<DateTimeOffset>(8)
|
||||
Id = ef.Id,
|
||||
TenantId = ef.TenantId,
|
||||
Name = ef.Name,
|
||||
DisplayName = ef.DisplayName,
|
||||
Description = ef.Description,
|
||||
IsSystem = ef.IsSystem,
|
||||
Metadata = ef.Metadata,
|
||||
CreatedAt = ef.CreatedAt,
|
||||
UpdatedAt = ef.UpdatedAt
|
||||
};
|
||||
|
||||
private static string GetSchemaName() => AuthorityDataSource.DefaultSchemaName;
|
||||
}
|
||||
|
||||
@@ -1,73 +1,71 @@
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Authority.Persistence.EfCore.Models;
|
||||
using StellaOps.Authority.Persistence.Postgres.Models;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Authority.Persistence.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL repository for service accounts.
|
||||
/// PostgreSQL (EF Core) repository for service accounts.
|
||||
/// </summary>
|
||||
public sealed class ServiceAccountRepository : RepositoryBase<AuthorityDataSource>, IServiceAccountRepository
|
||||
public sealed class ServiceAccountRepository : IServiceAccountRepository
|
||||
{
|
||||
private const int CommandTimeoutSeconds = 30;
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.General);
|
||||
|
||||
private readonly AuthorityDataSource _dataSource;
|
||||
private readonly ILogger<ServiceAccountRepository> _logger;
|
||||
|
||||
public ServiceAccountRepository(AuthorityDataSource dataSource, ILogger<ServiceAccountRepository> logger)
|
||||
: base(dataSource, logger)
|
||||
{
|
||||
_dataSource = dataSource;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<ServiceAccountEntity?> FindByAccountIdAsync(string accountId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, account_id, tenant, display_name, description, enabled,
|
||||
allowed_scopes, authorized_clients, attributes, created_at, updated_at
|
||||
FROM authority.service_accounts
|
||||
WHERE account_id = @account_id
|
||||
""";
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
return await QuerySingleOrDefaultAsync(
|
||||
tenantId: string.Empty,
|
||||
sql: sql,
|
||||
configureCommand: cmd => AddParameter(cmd, "account_id", accountId),
|
||||
mapRow: MapServiceAccount,
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
var entity = await dbContext.ServiceAccounts
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(sa => sa.AccountId == accountId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return entity is null ? null : ToModel(entity);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<ServiceAccountEntity>> ListAsync(string? tenant, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var sql = """
|
||||
SELECT id, account_id, tenant, display_name, description, enabled,
|
||||
allowed_scopes, authorized_clients, attributes, created_at, updated_at
|
||||
FROM authority.service_accounts
|
||||
""";
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
IQueryable<ServiceAccountEfEntity> query = dbContext.ServiceAccounts.AsNoTracking();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(tenant))
|
||||
{
|
||||
sql += " WHERE tenant = @tenant";
|
||||
query = query.Where(sa => sa.Tenant == tenant);
|
||||
}
|
||||
|
||||
return await QueryAsync(
|
||||
tenantId: string.Empty,
|
||||
sql: sql,
|
||||
configureCommand: cmd =>
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(tenant))
|
||||
{
|
||||
AddParameter(cmd, "tenant", tenant);
|
||||
}
|
||||
},
|
||||
mapRow: MapServiceAccount,
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
var entities = await query
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return entities.Select(ToModel).ToList();
|
||||
}
|
||||
|
||||
public async Task UpsertAsync(ServiceAccountEntity entity, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
// Use raw SQL for ON CONFLICT DO UPDATE to preserve exact SQL behavior.
|
||||
await dbContext.Database.ExecuteSqlRawAsync(
|
||||
"""
|
||||
INSERT INTO authority.service_accounts
|
||||
(id, account_id, tenant, display_name, description, enabled, allowed_scopes, authorized_clients, attributes, created_at, updated_at)
|
||||
VALUES (@id, @account_id, @tenant, @display_name, @description, @enabled, @allowed_scopes, @authorized_clients, @attributes, @created_at, @updated_at)
|
||||
VALUES ({0}, {1}, {2}, {3}, {4}, {5}, {6}, {7}, {8}::jsonb, {9}, {10})
|
||||
ON CONFLICT (account_id) DO UPDATE
|
||||
SET tenant = EXCLUDED.tenant,
|
||||
display_name = EXCLUDED.display_name,
|
||||
@@ -77,66 +75,54 @@ public sealed class ServiceAccountRepository : RepositoryBase<AuthorityDataSourc
|
||||
authorized_clients = EXCLUDED.authorized_clients,
|
||||
attributes = EXCLUDED.attributes,
|
||||
updated_at = EXCLUDED.updated_at
|
||||
""";
|
||||
|
||||
await ExecuteAsync(
|
||||
tenantId: string.Empty,
|
||||
sql: sql,
|
||||
configureCommand: cmd =>
|
||||
{
|
||||
AddParameter(cmd, "id", entity.Id);
|
||||
AddParameter(cmd, "account_id", entity.AccountId);
|
||||
AddParameter(cmd, "tenant", entity.Tenant);
|
||||
AddParameter(cmd, "display_name", entity.DisplayName);
|
||||
AddParameter(cmd, "description", entity.Description);
|
||||
AddParameter(cmd, "enabled", entity.Enabled);
|
||||
AddParameter(cmd, "allowed_scopes", entity.AllowedScopes.ToArray());
|
||||
AddParameter(cmd, "authorized_clients", entity.AuthorizedClients.ToArray());
|
||||
AddJsonbParameter(cmd, "attributes", JsonSerializer.Serialize(entity.Attributes, SerializerOptions));
|
||||
AddParameter(cmd, "created_at", entity.CreatedAt);
|
||||
AddParameter(cmd, "updated_at", entity.UpdatedAt);
|
||||
},
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
""",
|
||||
entity.Id, entity.AccountId, entity.Tenant, entity.DisplayName,
|
||||
(object?)entity.Description ?? DBNull.Value,
|
||||
entity.Enabled,
|
||||
entity.AllowedScopes.ToArray(), entity.AuthorizedClients.ToArray(),
|
||||
JsonSerializer.Serialize(entity.Attributes, SerializerOptions),
|
||||
entity.CreatedAt, entity.UpdatedAt,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteAsync(string accountId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
DELETE FROM authority.service_accounts WHERE account_id = @account_id
|
||||
""";
|
||||
var rows = await ExecuteAsync(
|
||||
tenantId: string.Empty,
|
||||
sql: sql,
|
||||
configureCommand: cmd => AddParameter(cmd, "account_id", accountId),
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var rows = await dbContext.ServiceAccounts
|
||||
.Where(sa => sa.AccountId == accountId)
|
||||
.ExecuteDeleteAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return rows > 0;
|
||||
}
|
||||
|
||||
private static ServiceAccountEntity MapServiceAccount(NpgsqlDataReader reader) => new()
|
||||
private static ServiceAccountEntity ToModel(ServiceAccountEfEntity ef) => new()
|
||||
{
|
||||
Id = reader.GetString(0),
|
||||
AccountId = reader.GetString(1),
|
||||
Tenant = reader.GetString(2),
|
||||
DisplayName = reader.GetString(3),
|
||||
Description = GetNullableString(reader, 4),
|
||||
Enabled = reader.GetBoolean(5),
|
||||
AllowedScopes = reader.GetFieldValue<string[]>(6),
|
||||
AuthorizedClients = reader.GetFieldValue<string[]>(7),
|
||||
Attributes = ReadDictionary(reader, 8),
|
||||
CreatedAt = reader.GetFieldValue<DateTimeOffset>(9),
|
||||
UpdatedAt = reader.GetFieldValue<DateTimeOffset>(10)
|
||||
Id = ef.Id,
|
||||
AccountId = ef.AccountId,
|
||||
Tenant = ef.Tenant,
|
||||
DisplayName = ef.DisplayName,
|
||||
Description = ef.Description,
|
||||
Enabled = ef.Enabled,
|
||||
AllowedScopes = ef.AllowedScopes ?? [],
|
||||
AuthorizedClients = ef.AuthorizedClients ?? [],
|
||||
Attributes = DeserializeAttributes(ef.Attributes),
|
||||
CreatedAt = ef.CreatedAt,
|
||||
UpdatedAt = ef.UpdatedAt
|
||||
};
|
||||
|
||||
private static IReadOnlyDictionary<string, List<string>> ReadDictionary(NpgsqlDataReader reader, int ordinal)
|
||||
private static IReadOnlyDictionary<string, List<string>> DeserializeAttributes(string? json)
|
||||
{
|
||||
if (reader.IsDBNull(ordinal))
|
||||
if (string.IsNullOrWhiteSpace(json) || json == "{}")
|
||||
{
|
||||
return new Dictionary<string, List<string>>(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
var json = reader.GetString(ordinal);
|
||||
var dictionary = System.Text.Json.JsonSerializer.Deserialize<Dictionary<string, List<string>>>(json) ??
|
||||
return JsonSerializer.Deserialize<Dictionary<string, List<string>>>(json) ??
|
||||
new Dictionary<string, List<string>>(StringComparer.OrdinalIgnoreCase);
|
||||
return dictionary;
|
||||
}
|
||||
|
||||
private static string GetSchemaName() => AuthorityDataSource.DefaultSchemaName;
|
||||
}
|
||||
|
||||
@@ -1,138 +1,181 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Authority.Persistence.EfCore.Models;
|
||||
using StellaOps.Authority.Persistence.Postgres.Models;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
|
||||
namespace StellaOps.Authority.Persistence.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL repository for session operations.
|
||||
/// PostgreSQL (EF Core) repository for session operations.
|
||||
/// </summary>
|
||||
public sealed class SessionRepository : RepositoryBase<AuthorityDataSource>, ISessionRepository
|
||||
public sealed class SessionRepository : ISessionRepository
|
||||
{
|
||||
private const int CommandTimeoutSeconds = 30;
|
||||
|
||||
private readonly AuthorityDataSource _dataSource;
|
||||
private readonly ILogger<SessionRepository> _logger;
|
||||
|
||||
public SessionRepository(AuthorityDataSource dataSource, ILogger<SessionRepository> logger)
|
||||
: base(dataSource, logger) { }
|
||||
{
|
||||
_dataSource = dataSource;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<SessionEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, tenant_id, user_id, session_token_hash, ip_address, user_agent, started_at, last_activity_at, expires_at, ended_at, end_reason, metadata
|
||||
FROM authority.sessions
|
||||
WHERE tenant_id = @tenant_id AND id = @id
|
||||
""";
|
||||
return await QuerySingleOrDefaultAsync(tenantId, sql,
|
||||
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "id", id); },
|
||||
MapSession, cancellationToken).ConfigureAwait(false);
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var entity = await dbContext.Sessions
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(s => s.TenantId == tenantId && s.Id == id, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return entity is null ? null : ToModel(entity);
|
||||
}
|
||||
|
||||
public async Task<SessionEntity?> GetByTokenHashAsync(string sessionTokenHash, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, tenant_id, user_id, session_token_hash, ip_address, user_agent, started_at, last_activity_at, expires_at, ended_at, end_reason, metadata
|
||||
FROM authority.sessions
|
||||
WHERE session_token_hash = @session_token_hash AND ended_at IS NULL AND expires_at > NOW()
|
||||
""";
|
||||
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
AddParameter(command, "session_token_hash", sessionTokenHash);
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
return await reader.ReadAsync(cancellationToken).ConfigureAwait(false) ? MapSession(reader) : null;
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var entities = await dbContext.Sessions
|
||||
.FromSqlRaw(
|
||||
"""
|
||||
SELECT * FROM authority.sessions
|
||||
WHERE session_token_hash = {0} AND ended_at IS NULL AND expires_at > NOW()
|
||||
""",
|
||||
sessionTokenHash)
|
||||
.AsNoTracking()
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var entity = entities.FirstOrDefault();
|
||||
return entity is null ? null : ToModel(entity);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<SessionEntity>> GetByUserIdAsync(string tenantId, Guid userId, bool activeOnly = true, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var sql = """
|
||||
SELECT id, tenant_id, user_id, session_token_hash, ip_address, user_agent, started_at, last_activity_at, expires_at, ended_at, end_reason, metadata
|
||||
FROM authority.sessions
|
||||
WHERE tenant_id = @tenant_id AND user_id = @user_id
|
||||
""";
|
||||
if (activeOnly) sql += " AND ended_at IS NULL AND expires_at > NOW()";
|
||||
sql += " ORDER BY started_at DESC";
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
return await QueryAsync(tenantId, sql,
|
||||
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "user_id", userId); },
|
||||
MapSession, cancellationToken).ConfigureAwait(false);
|
||||
if (activeOnly)
|
||||
{
|
||||
// Use raw SQL for NOW() comparison consistency.
|
||||
var entities = await dbContext.Sessions
|
||||
.FromSqlRaw(
|
||||
"""
|
||||
SELECT * FROM authority.sessions
|
||||
WHERE tenant_id = {0} AND user_id = {1} AND ended_at IS NULL AND expires_at > NOW()
|
||||
ORDER BY started_at DESC
|
||||
""",
|
||||
tenantId, userId)
|
||||
.AsNoTracking()
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return entities.Select(ToModel).ToList();
|
||||
}
|
||||
else
|
||||
{
|
||||
var entities = await dbContext.Sessions
|
||||
.AsNoTracking()
|
||||
.Where(s => s.TenantId == tenantId && s.UserId == userId)
|
||||
.OrderByDescending(s => s.StartedAt)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return entities.Select(ToModel).ToList();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Guid> CreateAsync(string tenantId, SessionEntity session, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
INSERT INTO authority.sessions (id, tenant_id, user_id, session_token_hash, ip_address, user_agent, expires_at, metadata)
|
||||
VALUES (@id, @tenant_id, @user_id, @session_token_hash, @ip_address, @user_agent, @expires_at, @metadata::jsonb)
|
||||
RETURNING id
|
||||
""";
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var id = session.Id == Guid.Empty ? Guid.NewGuid() : session.Id;
|
||||
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken).ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
AddParameter(command, "id", id);
|
||||
AddParameter(command, "tenant_id", tenantId);
|
||||
AddParameter(command, "user_id", session.UserId);
|
||||
AddParameter(command, "session_token_hash", session.SessionTokenHash);
|
||||
AddParameter(command, "ip_address", session.IpAddress);
|
||||
AddParameter(command, "user_agent", session.UserAgent);
|
||||
AddParameter(command, "expires_at", session.ExpiresAt);
|
||||
AddJsonbParameter(command, "metadata", session.Metadata);
|
||||
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
var efEntity = new SessionEfEntity
|
||||
{
|
||||
Id = id,
|
||||
TenantId = tenantId,
|
||||
UserId = session.UserId,
|
||||
SessionTokenHash = session.SessionTokenHash,
|
||||
IpAddress = session.IpAddress,
|
||||
UserAgent = session.UserAgent,
|
||||
ExpiresAt = session.ExpiresAt,
|
||||
Metadata = session.Metadata
|
||||
};
|
||||
|
||||
dbContext.Sessions.Add(efEntity);
|
||||
await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
return id;
|
||||
}
|
||||
|
||||
public async Task UpdateLastActivityAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = "UPDATE authority.sessions SET last_activity_at = NOW() WHERE tenant_id = @tenant_id AND id = @id AND ended_at IS NULL";
|
||||
await ExecuteAsync(tenantId, sql,
|
||||
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "id", id); },
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
await dbContext.Database.ExecuteSqlRawAsync(
|
||||
"UPDATE authority.sessions SET last_activity_at = NOW() WHERE tenant_id = {0} AND id = {1} AND ended_at IS NULL",
|
||||
tenantId, id,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task EndAsync(string tenantId, Guid id, string reason, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
UPDATE authority.sessions SET ended_at = NOW(), end_reason = @end_reason
|
||||
WHERE tenant_id = @tenant_id AND id = @id AND ended_at IS NULL
|
||||
""";
|
||||
await ExecuteAsync(tenantId, sql, cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "id", id);
|
||||
AddParameter(cmd, "end_reason", reason);
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
await dbContext.Database.ExecuteSqlRawAsync(
|
||||
"""
|
||||
UPDATE authority.sessions SET ended_at = NOW(), end_reason = {0}
|
||||
WHERE tenant_id = {1} AND id = {2} AND ended_at IS NULL
|
||||
""",
|
||||
reason, tenantId, id,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task EndByUserIdAsync(string tenantId, Guid userId, string reason, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
UPDATE authority.sessions SET ended_at = NOW(), end_reason = @end_reason
|
||||
WHERE tenant_id = @tenant_id AND user_id = @user_id AND ended_at IS NULL
|
||||
""";
|
||||
await ExecuteAsync(tenantId, sql, cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "user_id", userId);
|
||||
AddParameter(cmd, "end_reason", reason);
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
await dbContext.Database.ExecuteSqlRawAsync(
|
||||
"""
|
||||
UPDATE authority.sessions SET ended_at = NOW(), end_reason = {0}
|
||||
WHERE tenant_id = {1} AND user_id = {2} AND ended_at IS NULL
|
||||
""",
|
||||
reason, tenantId, userId,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task DeleteExpiredAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = "DELETE FROM authority.sessions WHERE expires_at < NOW() - INTERVAL '30 days'";
|
||||
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
await dbContext.Database.ExecuteSqlRawAsync(
|
||||
"DELETE FROM authority.sessions WHERE expires_at < NOW() - INTERVAL '30 days'",
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static SessionEntity MapSession(NpgsqlDataReader reader) => new()
|
||||
private static SessionEntity ToModel(SessionEfEntity ef) => new()
|
||||
{
|
||||
Id = reader.GetGuid(0),
|
||||
TenantId = reader.GetString(1),
|
||||
UserId = reader.GetGuid(2),
|
||||
SessionTokenHash = reader.GetString(3),
|
||||
IpAddress = GetNullableString(reader, 4),
|
||||
UserAgent = GetNullableString(reader, 5),
|
||||
StartedAt = reader.GetFieldValue<DateTimeOffset>(6),
|
||||
LastActivityAt = reader.GetFieldValue<DateTimeOffset>(7),
|
||||
ExpiresAt = reader.GetFieldValue<DateTimeOffset>(8),
|
||||
EndedAt = GetNullableDateTimeOffset(reader, 9),
|
||||
EndReason = GetNullableString(reader, 10),
|
||||
Metadata = reader.GetString(11)
|
||||
Id = ef.Id,
|
||||
TenantId = ef.TenantId,
|
||||
UserId = ef.UserId,
|
||||
SessionTokenHash = ef.SessionTokenHash,
|
||||
IpAddress = ef.IpAddress,
|
||||
UserAgent = ef.UserAgent,
|
||||
StartedAt = ef.StartedAt,
|
||||
LastActivityAt = ef.LastActivityAt,
|
||||
ExpiresAt = ef.ExpiresAt,
|
||||
EndedAt = ef.EndedAt,
|
||||
EndReason = ef.EndReason,
|
||||
Metadata = ef.Metadata
|
||||
};
|
||||
|
||||
private static string GetSchemaName() => AuthorityDataSource.DefaultSchemaName;
|
||||
}
|
||||
|
||||
@@ -1,194 +1,172 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Authority.Persistence.EfCore.Models;
|
||||
using StellaOps.Authority.Persistence.Postgres.Models;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
|
||||
namespace StellaOps.Authority.Persistence.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL repository for tenant operations.
|
||||
/// PostgreSQL (EF Core) repository for tenant operations.
|
||||
/// Tenants table is NOT RLS-protected; uses system connections.
|
||||
/// </summary>
|
||||
public sealed class TenantRepository : RepositoryBase<AuthorityDataSource>, ITenantRepository
|
||||
public sealed class TenantRepository : ITenantRepository
|
||||
{
|
||||
private const string SystemTenantId = "_system";
|
||||
private const int CommandTimeoutSeconds = 30;
|
||||
|
||||
private readonly AuthorityDataSource _dataSource;
|
||||
private readonly ILogger<TenantRepository> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new tenant repository.
|
||||
/// </summary>
|
||||
public TenantRepository(AuthorityDataSource dataSource, ILogger<TenantRepository> logger)
|
||||
: base(dataSource, logger)
|
||||
{
|
||||
_dataSource = dataSource;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<TenantEntity> CreateAsync(TenantEntity tenant, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
INSERT INTO authority.tenants (id, slug, name, description, contact_email, enabled, settings, metadata, created_by)
|
||||
VALUES (@id, @slug, @name, @description, @contact_email, @enabled, @settings::jsonb, @metadata::jsonb, @created_by)
|
||||
RETURNING id, slug, name, description, contact_email, enabled, settings::text, metadata::text, created_at, updated_at, created_by
|
||||
""";
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
var efEntity = ToEfEntity(tenant);
|
||||
dbContext.Tenants.Add(efEntity);
|
||||
await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
AddParameter(command, "id", tenant.Id);
|
||||
AddParameter(command, "slug", tenant.Slug);
|
||||
AddParameter(command, "name", tenant.Name);
|
||||
AddParameter(command, "description", tenant.Description);
|
||||
AddParameter(command, "contact_email", tenant.ContactEmail);
|
||||
AddParameter(command, "enabled", tenant.Enabled);
|
||||
AddJsonbParameter(command, "settings", tenant.Settings);
|
||||
AddJsonbParameter(command, "metadata", tenant.Metadata);
|
||||
AddParameter(command, "created_by", tenant.CreatedBy);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return MapTenant(reader);
|
||||
return ToModel(efEntity);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<TenantEntity?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, slug, name, description, contact_email, enabled, settings::text, metadata::text, created_at, updated_at, created_by
|
||||
FROM authority.tenants
|
||||
WHERE id = @id
|
||||
""";
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
return await QuerySingleOrDefaultAsync(
|
||||
SystemTenantId,
|
||||
sql,
|
||||
cmd => AddParameter(cmd, "id", id),
|
||||
MapTenant,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
var entity = await dbContext.Tenants
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(t => t.Id == id, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return entity is null ? null : ToModel(entity);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<TenantEntity?> GetBySlugAsync(string slug, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, slug, name, description, contact_email, enabled, settings::text, metadata::text, created_at, updated_at, created_by
|
||||
FROM authority.tenants
|
||||
WHERE slug = @slug
|
||||
""";
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
return await QuerySingleOrDefaultAsync(
|
||||
SystemTenantId,
|
||||
sql,
|
||||
cmd => AddParameter(cmd, "slug", slug),
|
||||
MapTenant,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
var entity = await dbContext.Tenants
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(t => t.TenantId == slug, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return entity is null ? null : ToModel(entity);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<TenantEntity>> GetAllAsync(
|
||||
bool? enabled = null,
|
||||
int limit = 100,
|
||||
int offset = 0,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var sql = """
|
||||
SELECT id, slug, name, description, contact_email, enabled, settings::text, metadata::text, created_at, updated_at, created_by
|
||||
FROM authority.tenants
|
||||
""";
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
IQueryable<TenantEfEntity> query = dbContext.Tenants.AsNoTracking();
|
||||
|
||||
if (enabled.HasValue)
|
||||
{
|
||||
sql += " WHERE enabled = @enabled";
|
||||
// The SQL schema uses 'status' column with CHECK constraint, but the domain model
|
||||
// uses the 'enabled' column concept mapped to status = 'active' vs other.
|
||||
// The tenants table has an 'enabled' field in the domain model mapping from slug.
|
||||
// However, the SQL schema doesn't have an 'enabled' column on tenants -- it uses 'status'.
|
||||
// The existing TenantEntity maps: Enabled -> column doesn't directly exist;
|
||||
// the SQL tenants table has: status TEXT NOT NULL DEFAULT 'active'.
|
||||
// For backward compat, filter by status.
|
||||
var statusFilter = enabled.Value ? "active" : "suspended";
|
||||
query = query.Where(t => enabled.Value ? t.Status == "active" : t.Status != "active");
|
||||
}
|
||||
|
||||
sql += " ORDER BY name, id LIMIT @limit OFFSET @offset";
|
||||
var entities = await query
|
||||
.OrderBy(t => t.Name)
|
||||
.ThenBy(t => t.Id)
|
||||
.Skip(offset)
|
||||
.Take(limit)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return await QueryAsync(
|
||||
SystemTenantId,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
if (enabled.HasValue)
|
||||
{
|
||||
AddParameter(cmd, "enabled", enabled.Value);
|
||||
}
|
||||
AddParameter(cmd, "limit", limit);
|
||||
AddParameter(cmd, "offset", offset);
|
||||
},
|
||||
MapTenant,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
return entities.Select(ToModel).ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> UpdateAsync(TenantEntity tenant, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
UPDATE authority.tenants
|
||||
SET name = @name,
|
||||
description = @description,
|
||||
contact_email = @contact_email,
|
||||
enabled = @enabled,
|
||||
settings = @settings::jsonb,
|
||||
metadata = @metadata::jsonb
|
||||
WHERE id = @id
|
||||
""";
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var rows = await ExecuteAsync(
|
||||
SystemTenantId,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "id", tenant.Id);
|
||||
AddParameter(cmd, "name", tenant.Name);
|
||||
AddParameter(cmd, "description", tenant.Description);
|
||||
AddParameter(cmd, "contact_email", tenant.ContactEmail);
|
||||
AddParameter(cmd, "enabled", tenant.Enabled);
|
||||
AddJsonbParameter(cmd, "settings", tenant.Settings);
|
||||
AddJsonbParameter(cmd, "metadata", tenant.Metadata);
|
||||
},
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
var existing = await dbContext.Tenants.FirstOrDefaultAsync(t => t.Id == tenant.Id, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return rows > 0;
|
||||
if (existing is null)
|
||||
return false;
|
||||
|
||||
existing.Name = tenant.Name;
|
||||
existing.DisplayName = tenant.Description;
|
||||
existing.Settings = tenant.Settings;
|
||||
existing.Metadata = tenant.Metadata;
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> DeleteAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = "DELETE FROM authority.tenants WHERE id = @id";
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var rows = await ExecuteAsync(
|
||||
SystemTenantId,
|
||||
sql,
|
||||
cmd => AddParameter(cmd, "id", id),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
var rows = await dbContext.Tenants
|
||||
.Where(t => t.Id == id)
|
||||
.ExecuteDeleteAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return rows > 0;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> SlugExistsAsync(string slug, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = "SELECT EXISTS(SELECT 1 FROM authority.tenants WHERE slug = @slug)";
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var result = await ExecuteScalarAsync<bool>(
|
||||
SystemTenantId,
|
||||
sql,
|
||||
cmd => AddParameter(cmd, "slug", slug),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return result;
|
||||
return await dbContext.Tenants
|
||||
.AsNoTracking()
|
||||
.AnyAsync(t => t.TenantId == slug, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static TenantEntity MapTenant(NpgsqlDataReader reader) => new()
|
||||
private static TenantEfEntity ToEfEntity(TenantEntity model) => new()
|
||||
{
|
||||
Id = reader.GetGuid(0),
|
||||
Slug = reader.GetString(1),
|
||||
Name = reader.GetString(2),
|
||||
Description = GetNullableString(reader, 3),
|
||||
ContactEmail = GetNullableString(reader, 4),
|
||||
Enabled = reader.GetBoolean(5),
|
||||
Settings = reader.GetString(6),
|
||||
Metadata = reader.GetString(7),
|
||||
CreatedAt = reader.GetFieldValue<DateTimeOffset>(8),
|
||||
UpdatedAt = reader.GetFieldValue<DateTimeOffset>(9),
|
||||
CreatedBy = GetNullableString(reader, 10)
|
||||
Id = model.Id,
|
||||
TenantId = model.Slug,
|
||||
Name = model.Name,
|
||||
DisplayName = model.Description,
|
||||
Status = model.Enabled ? "active" : "suspended",
|
||||
Settings = model.Settings,
|
||||
Metadata = model.Metadata,
|
||||
CreatedAt = model.CreatedAt,
|
||||
UpdatedAt = model.UpdatedAt,
|
||||
CreatedBy = model.CreatedBy
|
||||
};
|
||||
|
||||
private static TenantEntity ToModel(TenantEfEntity ef) => new()
|
||||
{
|
||||
Id = ef.Id,
|
||||
Slug = ef.TenantId,
|
||||
Name = ef.Name,
|
||||
Description = ef.DisplayName,
|
||||
ContactEmail = null, // tenant_id column mapped to slug; contact_email not in SQL schema
|
||||
Enabled = string.Equals(ef.Status, "active", StringComparison.OrdinalIgnoreCase),
|
||||
Settings = ef.Settings,
|
||||
Metadata = ef.Metadata,
|
||||
CreatedAt = ef.CreatedAt,
|
||||
UpdatedAt = ef.UpdatedAt,
|
||||
CreatedBy = ef.CreatedBy
|
||||
};
|
||||
|
||||
private static string GetSchemaName() => AuthorityDataSource.DefaultSchemaName;
|
||||
}
|
||||
|
||||
@@ -1,252 +1,301 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Authority.Persistence.EfCore.Models;
|
||||
using StellaOps.Authority.Persistence.Postgres.Models;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
|
||||
namespace StellaOps.Authority.Persistence.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL repository for access token operations.
|
||||
/// PostgreSQL (EF Core) repository for access token operations.
|
||||
/// </summary>
|
||||
public sealed class TokenRepository : RepositoryBase<AuthorityDataSource>, ITokenRepository
|
||||
public sealed class TokenRepository : ITokenRepository
|
||||
{
|
||||
private const int CommandTimeoutSeconds = 30;
|
||||
|
||||
private readonly AuthorityDataSource _dataSource;
|
||||
private readonly ILogger<TokenRepository> _logger;
|
||||
|
||||
public TokenRepository(AuthorityDataSource dataSource, ILogger<TokenRepository> logger)
|
||||
: base(dataSource, logger) { }
|
||||
{
|
||||
_dataSource = dataSource;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<TokenEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, tenant_id, user_id, token_hash, token_type, scopes, client_id, issued_at, expires_at, revoked_at, revoked_by, metadata
|
||||
FROM authority.tokens
|
||||
WHERE tenant_id = @tenant_id AND id = @id
|
||||
""";
|
||||
return await QuerySingleOrDefaultAsync(tenantId, sql,
|
||||
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "id", id); },
|
||||
MapToken, cancellationToken).ConfigureAwait(false);
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var entity = await dbContext.Tokens
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(t => t.TenantId == tenantId && t.Id == id, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return entity is null ? null : ToModel(entity);
|
||||
}
|
||||
|
||||
public async Task<TokenEntity?> GetByHashAsync(string tokenHash, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, tenant_id, user_id, token_hash, token_type, scopes, client_id, issued_at, expires_at, revoked_at, revoked_by, metadata
|
||||
FROM authority.tokens
|
||||
WHERE token_hash = @token_hash AND revoked_at IS NULL AND expires_at > NOW()
|
||||
""";
|
||||
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
AddParameter(command, "token_hash", tokenHash);
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
return await reader.ReadAsync(cancellationToken).ConfigureAwait(false) ? MapToken(reader) : null;
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
// Use raw SQL for NOW() comparison to preserve DB clock semantics.
|
||||
var entities = await dbContext.Tokens
|
||||
.FromSqlRaw(
|
||||
"""
|
||||
SELECT * FROM authority.tokens
|
||||
WHERE token_hash = {0} AND revoked_at IS NULL AND expires_at > NOW()
|
||||
""",
|
||||
tokenHash)
|
||||
.AsNoTracking()
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var entity = entities.FirstOrDefault();
|
||||
return entity is null ? null : ToModel(entity);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<TokenEntity>> GetByUserIdAsync(string tenantId, Guid userId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, tenant_id, user_id, token_hash, token_type, scopes, client_id, issued_at, expires_at, revoked_at, revoked_by, metadata
|
||||
FROM authority.tokens
|
||||
WHERE tenant_id = @tenant_id AND user_id = @user_id AND revoked_at IS NULL
|
||||
ORDER BY issued_at DESC, id ASC
|
||||
""";
|
||||
return await QueryAsync(tenantId, sql,
|
||||
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "user_id", userId); },
|
||||
MapToken, cancellationToken).ConfigureAwait(false);
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var entities = await dbContext.Tokens
|
||||
.AsNoTracking()
|
||||
.Where(t => t.TenantId == tenantId && t.UserId == userId && t.RevokedAt == null)
|
||||
.OrderByDescending(t => t.IssuedAt)
|
||||
.ThenBy(t => t.Id)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return entities.Select(ToModel).ToList();
|
||||
}
|
||||
|
||||
public async Task<Guid> CreateAsync(string tenantId, TokenEntity token, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
INSERT INTO authority.tokens (id, tenant_id, user_id, token_hash, token_type, scopes, client_id, expires_at, metadata)
|
||||
VALUES (@id, @tenant_id, @user_id, @token_hash, @token_type, @scopes, @client_id, @expires_at, @metadata::jsonb)
|
||||
RETURNING id
|
||||
""";
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var id = token.Id == Guid.Empty ? Guid.NewGuid() : token.Id;
|
||||
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken).ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
AddParameter(command, "id", id);
|
||||
AddParameter(command, "tenant_id", tenantId);
|
||||
AddParameter(command, "user_id", token.UserId);
|
||||
AddParameter(command, "token_hash", token.TokenHash);
|
||||
AddParameter(command, "token_type", token.TokenType);
|
||||
AddTextArrayParameter(command, "scopes", token.Scopes);
|
||||
AddParameter(command, "client_id", token.ClientId);
|
||||
AddParameter(command, "expires_at", token.ExpiresAt);
|
||||
AddJsonbParameter(command, "metadata", token.Metadata);
|
||||
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
var efEntity = new TokenEfEntity
|
||||
{
|
||||
Id = id,
|
||||
TenantId = tenantId,
|
||||
UserId = token.UserId,
|
||||
TokenHash = token.TokenHash,
|
||||
TokenType = token.TokenType,
|
||||
Scopes = token.Scopes,
|
||||
ClientId = token.ClientId,
|
||||
ExpiresAt = token.ExpiresAt,
|
||||
Metadata = token.Metadata
|
||||
};
|
||||
|
||||
dbContext.Tokens.Add(efEntity);
|
||||
await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
return id;
|
||||
}
|
||||
|
||||
public async Task RevokeAsync(string tenantId, Guid id, string revokedBy, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
UPDATE authority.tokens SET revoked_at = NOW(), revoked_by = @revoked_by
|
||||
WHERE tenant_id = @tenant_id AND id = @id AND revoked_at IS NULL
|
||||
""";
|
||||
await ExecuteAsync(tenantId, sql, cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "id", id);
|
||||
AddParameter(cmd, "revoked_by", revokedBy);
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
// Use raw SQL to preserve NOW() for revoked_at.
|
||||
await dbContext.Database.ExecuteSqlRawAsync(
|
||||
"""
|
||||
UPDATE authority.tokens SET revoked_at = NOW(), revoked_by = {0}
|
||||
WHERE tenant_id = {1} AND id = {2} AND revoked_at IS NULL
|
||||
""",
|
||||
revokedBy, tenantId, id,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task RevokeByUserIdAsync(string tenantId, Guid userId, string revokedBy, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
UPDATE authority.tokens SET revoked_at = NOW(), revoked_by = @revoked_by
|
||||
WHERE tenant_id = @tenant_id AND user_id = @user_id AND revoked_at IS NULL
|
||||
""";
|
||||
await ExecuteAsync(tenantId, sql, cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "user_id", userId);
|
||||
AddParameter(cmd, "revoked_by", revokedBy);
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
await dbContext.Database.ExecuteSqlRawAsync(
|
||||
"""
|
||||
UPDATE authority.tokens SET revoked_at = NOW(), revoked_by = {0}
|
||||
WHERE tenant_id = {1} AND user_id = {2} AND revoked_at IS NULL
|
||||
""",
|
||||
revokedBy, tenantId, userId,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task DeleteExpiredAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = "DELETE FROM authority.tokens WHERE expires_at < NOW() - INTERVAL '7 days'";
|
||||
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
await dbContext.Database.ExecuteSqlRawAsync(
|
||||
"DELETE FROM authority.tokens WHERE expires_at < NOW() - INTERVAL '7 days'",
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static TokenEntity MapToken(NpgsqlDataReader reader) => new()
|
||||
private static TokenEntity ToModel(TokenEfEntity ef) => new()
|
||||
{
|
||||
Id = reader.GetGuid(0),
|
||||
TenantId = reader.GetString(1),
|
||||
UserId = GetNullableGuid(reader, 2),
|
||||
TokenHash = reader.GetString(3),
|
||||
TokenType = reader.GetString(4),
|
||||
Scopes = reader.IsDBNull(5) ? [] : reader.GetFieldValue<string[]>(5),
|
||||
ClientId = GetNullableString(reader, 6),
|
||||
IssuedAt = reader.GetFieldValue<DateTimeOffset>(7),
|
||||
ExpiresAt = reader.GetFieldValue<DateTimeOffset>(8),
|
||||
RevokedAt = GetNullableDateTimeOffset(reader, 9),
|
||||
RevokedBy = GetNullableString(reader, 10),
|
||||
Metadata = reader.GetString(11)
|
||||
Id = ef.Id,
|
||||
TenantId = ef.TenantId,
|
||||
UserId = ef.UserId,
|
||||
TokenHash = ef.TokenHash,
|
||||
TokenType = ef.TokenType,
|
||||
Scopes = ef.Scopes ?? [],
|
||||
ClientId = ef.ClientId,
|
||||
IssuedAt = ef.IssuedAt,
|
||||
ExpiresAt = ef.ExpiresAt,
|
||||
RevokedAt = ef.RevokedAt,
|
||||
RevokedBy = ef.RevokedBy,
|
||||
Metadata = ef.Metadata
|
||||
};
|
||||
|
||||
private static string GetSchemaName() => AuthorityDataSource.DefaultSchemaName;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL repository for refresh token operations.
|
||||
/// PostgreSQL (EF Core) repository for refresh token operations.
|
||||
/// </summary>
|
||||
public sealed class RefreshTokenRepository : RepositoryBase<AuthorityDataSource>, IRefreshTokenRepository
|
||||
public sealed class RefreshTokenRepository : IRefreshTokenRepository
|
||||
{
|
||||
private const int CommandTimeoutSeconds = 30;
|
||||
|
||||
private readonly AuthorityDataSource _dataSource;
|
||||
private readonly ILogger<RefreshTokenRepository> _logger;
|
||||
|
||||
public RefreshTokenRepository(AuthorityDataSource dataSource, ILogger<RefreshTokenRepository> logger)
|
||||
: base(dataSource, logger) { }
|
||||
{
|
||||
_dataSource = dataSource;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<RefreshTokenEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, tenant_id, user_id, token_hash, access_token_id, client_id, issued_at, expires_at, revoked_at, revoked_by, replaced_by, metadata
|
||||
FROM authority.refresh_tokens
|
||||
WHERE tenant_id = @tenant_id AND id = @id
|
||||
""";
|
||||
return await QuerySingleOrDefaultAsync(tenantId, sql,
|
||||
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "id", id); },
|
||||
MapRefreshToken, cancellationToken).ConfigureAwait(false);
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var entity = await dbContext.RefreshTokens
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(t => t.TenantId == tenantId && t.Id == id, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return entity is null ? null : ToModel(entity);
|
||||
}
|
||||
|
||||
public async Task<RefreshTokenEntity?> GetByHashAsync(string tokenHash, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, tenant_id, user_id, token_hash, access_token_id, client_id, issued_at, expires_at, revoked_at, revoked_by, replaced_by, metadata
|
||||
FROM authority.refresh_tokens
|
||||
WHERE token_hash = @token_hash AND revoked_at IS NULL AND expires_at > NOW()
|
||||
""";
|
||||
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
AddParameter(command, "token_hash", tokenHash);
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
return await reader.ReadAsync(cancellationToken).ConfigureAwait(false) ? MapRefreshToken(reader) : null;
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var entities = await dbContext.RefreshTokens
|
||||
.FromSqlRaw(
|
||||
"""
|
||||
SELECT * FROM authority.refresh_tokens
|
||||
WHERE token_hash = {0} AND revoked_at IS NULL AND expires_at > NOW()
|
||||
""",
|
||||
tokenHash)
|
||||
.AsNoTracking()
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var entity = entities.FirstOrDefault();
|
||||
return entity is null ? null : ToModel(entity);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<RefreshTokenEntity>> GetByUserIdAsync(string tenantId, Guid userId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, tenant_id, user_id, token_hash, access_token_id, client_id, issued_at, expires_at, revoked_at, revoked_by, replaced_by, metadata
|
||||
FROM authority.refresh_tokens
|
||||
WHERE tenant_id = @tenant_id AND user_id = @user_id AND revoked_at IS NULL
|
||||
ORDER BY issued_at DESC, id ASC
|
||||
""";
|
||||
return await QueryAsync(tenantId, sql,
|
||||
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "user_id", userId); },
|
||||
MapRefreshToken, cancellationToken).ConfigureAwait(false);
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var entities = await dbContext.RefreshTokens
|
||||
.AsNoTracking()
|
||||
.Where(t => t.TenantId == tenantId && t.UserId == userId && t.RevokedAt == null)
|
||||
.OrderByDescending(t => t.IssuedAt)
|
||||
.ThenBy(t => t.Id)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return entities.Select(ToModel).ToList();
|
||||
}
|
||||
|
||||
public async Task<Guid> CreateAsync(string tenantId, RefreshTokenEntity token, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
INSERT INTO authority.refresh_tokens (id, tenant_id, user_id, token_hash, access_token_id, client_id, expires_at, metadata)
|
||||
VALUES (@id, @tenant_id, @user_id, @token_hash, @access_token_id, @client_id, @expires_at, @metadata::jsonb)
|
||||
RETURNING id
|
||||
""";
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var id = token.Id == Guid.Empty ? Guid.NewGuid() : token.Id;
|
||||
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken).ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
AddParameter(command, "id", id);
|
||||
AddParameter(command, "tenant_id", tenantId);
|
||||
AddParameter(command, "user_id", token.UserId);
|
||||
AddParameter(command, "token_hash", token.TokenHash);
|
||||
AddParameter(command, "access_token_id", token.AccessTokenId);
|
||||
AddParameter(command, "client_id", token.ClientId);
|
||||
AddParameter(command, "expires_at", token.ExpiresAt);
|
||||
AddJsonbParameter(command, "metadata", token.Metadata);
|
||||
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
var efEntity = new RefreshTokenEfEntity
|
||||
{
|
||||
Id = id,
|
||||
TenantId = tenantId,
|
||||
UserId = token.UserId,
|
||||
TokenHash = token.TokenHash,
|
||||
AccessTokenId = token.AccessTokenId,
|
||||
ClientId = token.ClientId,
|
||||
ExpiresAt = token.ExpiresAt,
|
||||
Metadata = token.Metadata
|
||||
};
|
||||
|
||||
dbContext.RefreshTokens.Add(efEntity);
|
||||
await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
return id;
|
||||
}
|
||||
|
||||
public async Task RevokeAsync(string tenantId, Guid id, string revokedBy, Guid? replacedBy, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
UPDATE authority.refresh_tokens SET revoked_at = NOW(), revoked_by = @revoked_by, replaced_by = @replaced_by
|
||||
WHERE tenant_id = @tenant_id AND id = @id AND revoked_at IS NULL
|
||||
""";
|
||||
await ExecuteAsync(tenantId, sql, cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "id", id);
|
||||
AddParameter(cmd, "revoked_by", revokedBy);
|
||||
AddParameter(cmd, "replaced_by", replacedBy);
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
await dbContext.Database.ExecuteSqlRawAsync(
|
||||
"""
|
||||
UPDATE authority.refresh_tokens SET revoked_at = NOW(), revoked_by = {0}, replaced_by = {1}
|
||||
WHERE tenant_id = {2} AND id = {3} AND revoked_at IS NULL
|
||||
""",
|
||||
revokedBy,
|
||||
(object?)replacedBy ?? DBNull.Value,
|
||||
tenantId, id,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task RevokeByUserIdAsync(string tenantId, Guid userId, string revokedBy, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
UPDATE authority.refresh_tokens SET revoked_at = NOW(), revoked_by = @revoked_by
|
||||
WHERE tenant_id = @tenant_id AND user_id = @user_id AND revoked_at IS NULL
|
||||
""";
|
||||
await ExecuteAsync(tenantId, sql, cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "user_id", userId);
|
||||
AddParameter(cmd, "revoked_by", revokedBy);
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
await dbContext.Database.ExecuteSqlRawAsync(
|
||||
"""
|
||||
UPDATE authority.refresh_tokens SET revoked_at = NOW(), revoked_by = {0}
|
||||
WHERE tenant_id = {1} AND user_id = {2} AND revoked_at IS NULL
|
||||
""",
|
||||
revokedBy, tenantId, userId,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task DeleteExpiredAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = "DELETE FROM authority.refresh_tokens WHERE expires_at < NOW() - INTERVAL '30 days'";
|
||||
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
await dbContext.Database.ExecuteSqlRawAsync(
|
||||
"DELETE FROM authority.refresh_tokens WHERE expires_at < NOW() - INTERVAL '30 days'",
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static RefreshTokenEntity MapRefreshToken(NpgsqlDataReader reader) => new()
|
||||
private static RefreshTokenEntity ToModel(RefreshTokenEfEntity ef) => new()
|
||||
{
|
||||
Id = reader.GetGuid(0),
|
||||
TenantId = reader.GetString(1),
|
||||
UserId = reader.GetGuid(2),
|
||||
TokenHash = reader.GetString(3),
|
||||
AccessTokenId = GetNullableGuid(reader, 4),
|
||||
ClientId = GetNullableString(reader, 5),
|
||||
IssuedAt = reader.GetFieldValue<DateTimeOffset>(6),
|
||||
ExpiresAt = reader.GetFieldValue<DateTimeOffset>(7),
|
||||
RevokedAt = GetNullableDateTimeOffset(reader, 8),
|
||||
RevokedBy = GetNullableString(reader, 9),
|
||||
ReplacedBy = GetNullableGuid(reader, 10),
|
||||
Metadata = reader.GetString(11)
|
||||
Id = ef.Id,
|
||||
TenantId = ef.TenantId,
|
||||
UserId = ef.UserId,
|
||||
TokenHash = ef.TokenHash,
|
||||
AccessTokenId = ef.AccessTokenId,
|
||||
ClientId = ef.ClientId,
|
||||
IssuedAt = ef.IssuedAt,
|
||||
ExpiresAt = ef.ExpiresAt,
|
||||
RevokedAt = ef.RevokedAt,
|
||||
RevokedBy = ef.RevokedBy,
|
||||
ReplacedBy = ef.ReplacedBy,
|
||||
Metadata = ef.Metadata
|
||||
};
|
||||
|
||||
private static string GetSchemaName() => AuthorityDataSource.DefaultSchemaName;
|
||||
}
|
||||
|
||||
@@ -1,153 +1,105 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Authority.Persistence.EfCore.Models;
|
||||
using StellaOps.Authority.Persistence.Postgres.Models;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
|
||||
namespace StellaOps.Authority.Persistence.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL repository for user operations.
|
||||
/// PostgreSQL (EF Core) repository for user operations.
|
||||
/// </summary>
|
||||
public sealed class UserRepository : RepositoryBase<AuthorityDataSource>, IUserRepository
|
||||
public sealed class UserRepository : IUserRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a new user repository.
|
||||
/// </summary>
|
||||
private const int CommandTimeoutSeconds = 30;
|
||||
|
||||
private readonly AuthorityDataSource _dataSource;
|
||||
private readonly ILogger<UserRepository> _logger;
|
||||
|
||||
public UserRepository(AuthorityDataSource dataSource, ILogger<UserRepository> logger)
|
||||
: base(dataSource, logger)
|
||||
{
|
||||
_dataSource = dataSource;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<UserEntity> CreateAsync(UserEntity user, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
INSERT INTO authority.users (
|
||||
id, tenant_id, username, email, display_name, password_hash, password_salt,
|
||||
enabled, email_verified, mfa_enabled, mfa_secret, mfa_backup_codes,
|
||||
settings, metadata, created_by
|
||||
)
|
||||
VALUES (
|
||||
@id, @tenant_id, @username, @email, @display_name, @password_hash, @password_salt,
|
||||
@enabled, @email_verified, @mfa_enabled, @mfa_secret, @mfa_backup_codes,
|
||||
@settings::jsonb, @metadata::jsonb, @created_by
|
||||
)
|
||||
RETURNING id, tenant_id, username, email, display_name, password_hash, password_salt,
|
||||
enabled, email_verified, mfa_enabled, mfa_secret, mfa_backup_codes,
|
||||
failed_login_attempts, locked_until, last_login_at, password_changed_at,
|
||||
settings::text, metadata::text, created_at, updated_at, created_by
|
||||
""";
|
||||
|
||||
await using var connection = await DataSource.OpenConnectionAsync(user.TenantId, "writer", cancellationToken)
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(user.TenantId, "writer", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
AddUserParameters(command, user);
|
||||
var efEntity = ToEfEntity(user);
|
||||
dbContext.Users.Add(efEntity);
|
||||
await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return MapUser(reader);
|
||||
return ToModel(efEntity);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<UserEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, tenant_id, username, email, display_name, password_hash, password_salt,
|
||||
enabled, email_verified, mfa_enabled, mfa_secret, mfa_backup_codes,
|
||||
failed_login_attempts, locked_until, last_login_at, password_changed_at,
|
||||
settings::text, metadata::text, created_at, updated_at, created_by
|
||||
FROM authority.users
|
||||
WHERE tenant_id = @tenant_id AND id = @id
|
||||
""";
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
return await QuerySingleOrDefaultAsync(
|
||||
tenantId,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "id", id);
|
||||
},
|
||||
MapUser,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
var entity = await dbContext.Users
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(u => u.TenantId == tenantId && u.Id == id, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return entity is null ? null : ToModel(entity);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<UserEntity?> GetByUsernameAsync(string tenantId, string username, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, tenant_id, username, email, display_name, password_hash, password_salt,
|
||||
enabled, email_verified, mfa_enabled, mfa_secret, mfa_backup_codes,
|
||||
failed_login_attempts, locked_until, last_login_at, password_changed_at,
|
||||
settings::text, metadata::text, created_at, updated_at, created_by
|
||||
FROM authority.users
|
||||
WHERE tenant_id = @tenant_id AND username = @username
|
||||
""";
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
return await QuerySingleOrDefaultAsync(
|
||||
tenantId,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "username", username);
|
||||
},
|
||||
MapUser,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
var entity = await dbContext.Users
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(u => u.TenantId == tenantId && u.Username == username, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return entity is null ? null : ToModel(entity);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<UserEntity?> GetBySubjectIdAsync(string tenantId, string subjectId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, tenant_id, username, email, display_name, password_hash, password_salt,
|
||||
enabled, email_verified, mfa_enabled, mfa_secret, mfa_backup_codes,
|
||||
failed_login_attempts, locked_until, last_login_at, password_changed_at,
|
||||
settings::text, metadata::text, created_at, updated_at, created_by
|
||||
FROM authority.users
|
||||
WHERE tenant_id = @tenant_id AND metadata->>'subjectId' = @subject_id
|
||||
LIMIT 1
|
||||
""";
|
||||
// The original SQL uses: metadata->>'subjectId' = @subject_id
|
||||
// EF Core doesn't natively translate JSONB property access, so use raw SQL.
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
return await QuerySingleOrDefaultAsync(
|
||||
tenantId,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "subject_id", subjectId);
|
||||
},
|
||||
MapUser,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
var entities = await dbContext.Users
|
||||
.FromSqlRaw(
|
||||
"""
|
||||
SELECT * FROM authority.users
|
||||
WHERE tenant_id = {0} AND metadata->>'subjectId' = {1}
|
||||
LIMIT 1
|
||||
""",
|
||||
tenantId, subjectId)
|
||||
.AsNoTracking()
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var entity = entities.FirstOrDefault();
|
||||
return entity is null ? null : ToModel(entity);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<UserEntity?> GetByEmailAsync(string tenantId, string email, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, tenant_id, username, email, display_name, password_hash, password_salt,
|
||||
enabled, email_verified, mfa_enabled, mfa_secret, mfa_backup_codes,
|
||||
failed_login_attempts, locked_until, last_login_at, password_changed_at,
|
||||
settings::text, metadata::text, created_at, updated_at, created_by
|
||||
FROM authority.users
|
||||
WHERE tenant_id = @tenant_id AND email = @email
|
||||
""";
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
return await QuerySingleOrDefaultAsync(
|
||||
tenantId,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "email", email);
|
||||
},
|
||||
MapUser,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
var entity = await dbContext.Users
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(u => u.TenantId == tenantId && u.Email == email, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return entity is null ? null : ToModel(entity);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<UserEntity>> GetAllAsync(
|
||||
string tenantId,
|
||||
bool? enabled = null,
|
||||
@@ -155,99 +107,72 @@ public sealed class UserRepository : RepositoryBase<AuthorityDataSource>, IUserR
|
||||
int offset = 0,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var sql = """
|
||||
SELECT id, tenant_id, username, email, display_name, password_hash, password_salt,
|
||||
enabled, email_verified, mfa_enabled, mfa_secret, mfa_backup_codes,
|
||||
failed_login_attempts, locked_until, last_login_at, password_changed_at,
|
||||
settings::text, metadata::text, created_at, updated_at, created_by
|
||||
FROM authority.users
|
||||
WHERE tenant_id = @tenant_id
|
||||
""";
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
IQueryable<UserEfEntity> query = dbContext.Users
|
||||
.AsNoTracking()
|
||||
.Where(u => u.TenantId == tenantId);
|
||||
|
||||
if (enabled.HasValue)
|
||||
{
|
||||
sql += " AND enabled = @enabled";
|
||||
query = query.Where(u => u.Enabled == enabled.Value);
|
||||
}
|
||||
|
||||
sql += " ORDER BY username, id LIMIT @limit OFFSET @offset";
|
||||
var entities = await query
|
||||
.OrderBy(u => u.Username)
|
||||
.ThenBy(u => u.Id)
|
||||
.Skip(offset)
|
||||
.Take(limit)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return await QueryAsync(
|
||||
tenantId,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
if (enabled.HasValue)
|
||||
{
|
||||
AddParameter(cmd, "enabled", enabled.Value);
|
||||
}
|
||||
AddParameter(cmd, "limit", limit);
|
||||
AddParameter(cmd, "offset", offset);
|
||||
},
|
||||
MapUser,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
return entities.Select(ToModel).ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> UpdateAsync(UserEntity user, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
UPDATE authority.users
|
||||
SET username = @username,
|
||||
email = @email,
|
||||
display_name = @display_name,
|
||||
enabled = @enabled,
|
||||
email_verified = @email_verified,
|
||||
mfa_enabled = @mfa_enabled,
|
||||
mfa_secret = @mfa_secret,
|
||||
mfa_backup_codes = @mfa_backup_codes,
|
||||
settings = @settings::jsonb,
|
||||
metadata = @metadata::jsonb
|
||||
WHERE tenant_id = @tenant_id AND id = @id
|
||||
""";
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(user.TenantId, "writer", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var rows = await ExecuteAsync(
|
||||
user.TenantId,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", user.TenantId);
|
||||
AddParameter(cmd, "id", user.Id);
|
||||
AddParameter(cmd, "username", user.Username);
|
||||
AddParameter(cmd, "email", user.Email);
|
||||
AddParameter(cmd, "display_name", user.DisplayName);
|
||||
AddParameter(cmd, "enabled", user.Enabled);
|
||||
AddParameter(cmd, "email_verified", user.EmailVerified);
|
||||
AddParameter(cmd, "mfa_enabled", user.MfaEnabled);
|
||||
AddParameter(cmd, "mfa_secret", user.MfaSecret);
|
||||
AddParameter(cmd, "mfa_backup_codes", user.MfaBackupCodes);
|
||||
AddJsonbParameter(cmd, "settings", user.Settings);
|
||||
AddJsonbParameter(cmd, "metadata", user.Metadata);
|
||||
},
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
var existing = await dbContext.Users
|
||||
.FirstOrDefaultAsync(u => u.TenantId == user.TenantId && u.Id == user.Id, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return rows > 0;
|
||||
if (existing is null)
|
||||
return false;
|
||||
|
||||
existing.Username = user.Username;
|
||||
existing.Email = user.Email;
|
||||
existing.DisplayName = user.DisplayName;
|
||||
existing.Enabled = user.Enabled;
|
||||
existing.EmailVerified = user.EmailVerified;
|
||||
existing.MfaEnabled = user.MfaEnabled;
|
||||
existing.MfaSecret = user.MfaSecret;
|
||||
existing.MfaBackupCodes = user.MfaBackupCodes;
|
||||
existing.Settings = user.Settings;
|
||||
existing.Metadata = user.Metadata;
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> DeleteAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = "DELETE FROM authority.users WHERE tenant_id = @tenant_id AND id = @id";
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var rows = await ExecuteAsync(
|
||||
tenantId,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "id", id);
|
||||
},
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
var rows = await dbContext.Users
|
||||
.Where(u => u.TenantId == tenantId && u.Id == id)
|
||||
.ExecuteDeleteAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return rows > 0;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> UpdatePasswordAsync(
|
||||
string tenantId,
|
||||
Guid userId,
|
||||
@@ -255,124 +180,111 @@ public sealed class UserRepository : RepositoryBase<AuthorityDataSource>, IUserR
|
||||
string passwordSalt,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
UPDATE authority.users
|
||||
SET password_hash = @password_hash,
|
||||
password_salt = @password_salt,
|
||||
password_changed_at = NOW()
|
||||
WHERE tenant_id = @tenant_id AND id = @id
|
||||
""";
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var rows = await ExecuteAsync(
|
||||
tenantId,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "id", userId);
|
||||
AddParameter(cmd, "password_hash", passwordHash);
|
||||
AddParameter(cmd, "password_salt", passwordSalt);
|
||||
},
|
||||
// Use raw SQL to preserve NOW() for password_changed_at (DB-generated timestamp).
|
||||
var rows = await dbContext.Database.ExecuteSqlRawAsync(
|
||||
"""
|
||||
UPDATE authority.users
|
||||
SET password_hash = {0}, password_salt = {1}, password_changed_at = NOW()
|
||||
WHERE tenant_id = {2} AND id = {3}
|
||||
""",
|
||||
passwordHash, passwordSalt, tenantId, userId,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return rows > 0;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<int> RecordFailedLoginAsync(
|
||||
string tenantId,
|
||||
Guid userId,
|
||||
DateTimeOffset? lockUntil = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
UPDATE authority.users
|
||||
SET failed_login_attempts = failed_login_attempts + 1,
|
||||
locked_until = @locked_until
|
||||
WHERE tenant_id = @tenant_id AND id = @id
|
||||
RETURNING failed_login_attempts
|
||||
""";
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var result = await ExecuteScalarAsync<int>(
|
||||
tenantId,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "id", userId);
|
||||
AddParameter(cmd, "locked_until", lockUntil);
|
||||
},
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
// Use raw SQL for atomic increment + RETURNING pattern.
|
||||
var result = await dbContext.Database.SqlQueryRaw<int>(
|
||||
"""
|
||||
UPDATE authority.users
|
||||
SET failed_login_attempts = failed_login_attempts + 1, locked_until = {0}
|
||||
WHERE tenant_id = {1} AND id = {2}
|
||||
RETURNING failed_login_attempts
|
||||
""",
|
||||
(object?)lockUntil ?? DBNull.Value, tenantId, userId)
|
||||
.FirstOrDefaultAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task RecordSuccessfulLoginAsync(
|
||||
string tenantId,
|
||||
Guid userId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
UPDATE authority.users
|
||||
SET failed_login_attempts = 0,
|
||||
locked_until = NULL,
|
||||
last_login_at = NOW()
|
||||
WHERE tenant_id = @tenant_id AND id = @id
|
||||
""";
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
await ExecuteAsync(
|
||||
tenantId,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "id", userId);
|
||||
},
|
||||
// Use raw SQL to preserve NOW() for last_login_at (DB-generated timestamp).
|
||||
await dbContext.Database.ExecuteSqlRawAsync(
|
||||
"""
|
||||
UPDATE authority.users
|
||||
SET failed_login_attempts = 0, locked_until = NULL, last_login_at = NOW()
|
||||
WHERE tenant_id = {0} AND id = {1}
|
||||
""",
|
||||
tenantId, userId,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static void AddUserParameters(NpgsqlCommand command, UserEntity user)
|
||||
private static UserEfEntity ToEfEntity(UserEntity model) => new()
|
||||
{
|
||||
AddParameter(command, "id", user.Id);
|
||||
AddParameter(command, "tenant_id", user.TenantId);
|
||||
AddParameter(command, "username", user.Username);
|
||||
AddParameter(command, "email", user.Email);
|
||||
AddParameter(command, "display_name", user.DisplayName);
|
||||
AddParameter(command, "password_hash", user.PasswordHash);
|
||||
AddParameter(command, "password_salt", user.PasswordSalt);
|
||||
AddParameter(command, "enabled", user.Enabled);
|
||||
AddParameter(command, "email_verified", user.EmailVerified);
|
||||
AddParameter(command, "mfa_enabled", user.MfaEnabled);
|
||||
AddParameter(command, "mfa_secret", user.MfaSecret);
|
||||
AddParameter(command, "mfa_backup_codes", user.MfaBackupCodes);
|
||||
AddJsonbParameter(command, "settings", user.Settings);
|
||||
AddJsonbParameter(command, "metadata", user.Metadata);
|
||||
AddParameter(command, "created_by", user.CreatedBy);
|
||||
}
|
||||
|
||||
private static UserEntity MapUser(NpgsqlDataReader reader) => new()
|
||||
{
|
||||
Id = reader.GetGuid(0),
|
||||
TenantId = reader.GetString(1),
|
||||
Username = reader.GetString(2),
|
||||
Email = reader.GetString(3),
|
||||
DisplayName = GetNullableString(reader, 4),
|
||||
PasswordHash = GetNullableString(reader, 5),
|
||||
PasswordSalt = GetNullableString(reader, 6),
|
||||
Enabled = reader.GetBoolean(7),
|
||||
EmailVerified = reader.GetBoolean(8),
|
||||
MfaEnabled = reader.GetBoolean(9),
|
||||
MfaSecret = GetNullableString(reader, 10),
|
||||
MfaBackupCodes = GetNullableString(reader, 11),
|
||||
FailedLoginAttempts = reader.GetInt32(12),
|
||||
LockedUntil = GetNullableDateTimeOffset(reader, 13),
|
||||
LastLoginAt = GetNullableDateTimeOffset(reader, 14),
|
||||
PasswordChangedAt = GetNullableDateTimeOffset(reader, 15),
|
||||
Settings = reader.GetString(16),
|
||||
Metadata = reader.GetString(17),
|
||||
CreatedAt = reader.GetFieldValue<DateTimeOffset>(18),
|
||||
UpdatedAt = reader.GetFieldValue<DateTimeOffset>(19),
|
||||
CreatedBy = GetNullableString(reader, 20)
|
||||
Id = model.Id,
|
||||
TenantId = model.TenantId,
|
||||
Username = model.Username,
|
||||
Email = model.Email,
|
||||
DisplayName = model.DisplayName,
|
||||
PasswordHash = model.PasswordHash,
|
||||
PasswordSalt = model.PasswordSalt,
|
||||
Enabled = model.Enabled,
|
||||
EmailVerified = model.EmailVerified,
|
||||
MfaEnabled = model.MfaEnabled,
|
||||
MfaSecret = model.MfaSecret,
|
||||
MfaBackupCodes = model.MfaBackupCodes,
|
||||
Settings = model.Settings,
|
||||
Metadata = model.Metadata,
|
||||
CreatedBy = model.CreatedBy
|
||||
};
|
||||
|
||||
private static UserEntity ToModel(UserEfEntity ef) => new()
|
||||
{
|
||||
Id = ef.Id,
|
||||
TenantId = ef.TenantId,
|
||||
Username = ef.Username,
|
||||
Email = ef.Email ?? string.Empty,
|
||||
DisplayName = ef.DisplayName,
|
||||
PasswordHash = ef.PasswordHash,
|
||||
PasswordSalt = ef.PasswordSalt,
|
||||
Enabled = ef.Enabled,
|
||||
EmailVerified = ef.EmailVerified,
|
||||
MfaEnabled = ef.MfaEnabled,
|
||||
MfaSecret = ef.MfaSecret,
|
||||
MfaBackupCodes = ef.MfaBackupCodes,
|
||||
FailedLoginAttempts = ef.FailedLoginAttempts,
|
||||
LockedUntil = ef.LockedUntil,
|
||||
LastLoginAt = ef.LastLoginAt,
|
||||
PasswordChangedAt = ef.PasswordChangedAt,
|
||||
Settings = ef.Settings,
|
||||
Metadata = ef.Metadata,
|
||||
CreatedAt = ef.CreatedAt,
|
||||
UpdatedAt = ef.UpdatedAt,
|
||||
CreatedBy = ef.CreatedBy
|
||||
};
|
||||
|
||||
private static string GetSchemaName() => AuthorityDataSource.DefaultSchemaName;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
|
||||
using Npgsql;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using StellaOps.Authority.Core.Verdicts;
|
||||
using StellaOps.Authority.Persistence.EfCore.Models;
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
@@ -8,10 +8,12 @@ using System.Text.Json.Serialization;
|
||||
namespace StellaOps.Authority.Persistence.Postgres;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL implementation of verdict manifest store.
|
||||
/// PostgreSQL (EF Core) implementation of verdict manifest store.
|
||||
/// </summary>
|
||||
public sealed class PostgresVerdictManifestStore : IVerdictManifestStore
|
||||
{
|
||||
private const int CommandTimeoutSeconds = 30;
|
||||
|
||||
private readonly AuthorityDataSource _dataSource;
|
||||
private static readonly JsonSerializerOptions s_jsonOptions = new()
|
||||
{
|
||||
@@ -30,17 +32,22 @@ public sealed class PostgresVerdictManifestStore : IVerdictManifestStore
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(manifest);
|
||||
|
||||
const string sql = """
|
||||
INSERT INTO verdict_manifests (
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(manifest.Tenant, "writer", ct).ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(conn, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
// Use raw SQL for ON CONFLICT DO UPDATE with composite key to preserve exact SQL behavior.
|
||||
await dbContext.Database.ExecuteSqlRawAsync(
|
||||
"""
|
||||
INSERT INTO authority.verdict_manifests (
|
||||
manifest_id, tenant, asset_digest, vulnerability_id,
|
||||
inputs_json, status, confidence, result_json,
|
||||
policy_hash, lattice_version, evaluated_at, manifest_digest,
|
||||
signature_base64, rekor_log_id
|
||||
) VALUES (
|
||||
@manifestId, @tenant, @assetDigest, @vulnerabilityId,
|
||||
@inputsJson::jsonb, @status, @confidence, @resultJson::jsonb,
|
||||
@policyHash, @latticeVersion, @evaluatedAt, @manifestDigest,
|
||||
@signatureBase64, @rekorLogId
|
||||
{0}, {1}, {2}, {3},
|
||||
{4}::jsonb, {5}, {6}, {7}::jsonb,
|
||||
{8}, {9}, {10}, {11},
|
||||
{12}, {13}
|
||||
)
|
||||
ON CONFLICT (tenant, asset_digest, vulnerability_id, policy_hash, lattice_version)
|
||||
DO UPDATE SET
|
||||
@@ -53,30 +60,17 @@ public sealed class PostgresVerdictManifestStore : IVerdictManifestStore
|
||||
manifest_digest = EXCLUDED.manifest_digest,
|
||||
signature_base64 = EXCLUDED.signature_base64,
|
||||
rekor_log_id = EXCLUDED.rekor_log_id
|
||||
""";
|
||||
""",
|
||||
manifest.ManifestId, manifest.Tenant, manifest.AssetDigest, manifest.VulnerabilityId,
|
||||
JsonSerializer.Serialize(manifest.Inputs, s_jsonOptions),
|
||||
StatusToString(manifest.Result.Status),
|
||||
manifest.Result.Confidence,
|
||||
JsonSerializer.Serialize(manifest.Result, s_jsonOptions),
|
||||
manifest.PolicyHash, manifest.LatticeVersion, manifest.EvaluatedAt, manifest.ManifestDigest,
|
||||
(object?)manifest.SignatureBase64 ?? DBNull.Value,
|
||||
(object?)manifest.RekorLogId ?? DBNull.Value,
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(manifest.Tenant, "writer", ct).ConfigureAwait(false);
|
||||
await using var cmd = new NpgsqlCommand(sql, conn)
|
||||
{
|
||||
CommandTimeout = _dataSource.CommandTimeoutSeconds,
|
||||
};
|
||||
|
||||
cmd.Parameters.AddWithValue("manifestId", manifest.ManifestId);
|
||||
cmd.Parameters.AddWithValue("tenant", manifest.Tenant);
|
||||
cmd.Parameters.AddWithValue("assetDigest", manifest.AssetDigest);
|
||||
cmd.Parameters.AddWithValue("vulnerabilityId", manifest.VulnerabilityId);
|
||||
cmd.Parameters.AddWithValue("inputsJson", JsonSerializer.Serialize(manifest.Inputs, s_jsonOptions));
|
||||
cmd.Parameters.AddWithValue("status", StatusToString(manifest.Result.Status));
|
||||
cmd.Parameters.AddWithValue("confidence", manifest.Result.Confidence);
|
||||
cmd.Parameters.AddWithValue("resultJson", JsonSerializer.Serialize(manifest.Result, s_jsonOptions));
|
||||
cmd.Parameters.AddWithValue("policyHash", manifest.PolicyHash);
|
||||
cmd.Parameters.AddWithValue("latticeVersion", manifest.LatticeVersion);
|
||||
cmd.Parameters.AddWithValue("evaluatedAt", manifest.EvaluatedAt);
|
||||
cmd.Parameters.AddWithValue("manifestDigest", manifest.ManifestDigest);
|
||||
cmd.Parameters.AddWithValue("signatureBase64", (object?)manifest.SignatureBase64 ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("rekorLogId", (object?)manifest.RekorLogId ?? DBNull.Value);
|
||||
|
||||
await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
|
||||
return manifest;
|
||||
}
|
||||
|
||||
@@ -85,30 +79,15 @@ public sealed class PostgresVerdictManifestStore : IVerdictManifestStore
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenant);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(manifestId);
|
||||
|
||||
const string sql = """
|
||||
SELECT manifest_id, tenant, asset_digest, vulnerability_id,
|
||||
inputs_json, status, confidence, result_json,
|
||||
policy_hash, lattice_version, evaluated_at, manifest_digest,
|
||||
signature_base64, rekor_log_id
|
||||
FROM verdict_manifests
|
||||
WHERE tenant = @tenant AND manifest_id = @manifestId
|
||||
""";
|
||||
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(tenant, "reader", ct).ConfigureAwait(false);
|
||||
await using var cmd = new NpgsqlCommand(sql, conn)
|
||||
{
|
||||
CommandTimeout = _dataSource.CommandTimeoutSeconds,
|
||||
};
|
||||
cmd.Parameters.AddWithValue("tenant", tenant);
|
||||
cmd.Parameters.AddWithValue("manifestId", manifestId);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(conn, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
|
||||
if (await reader.ReadAsync(ct).ConfigureAwait(false))
|
||||
{
|
||||
return MapFromReader(reader);
|
||||
}
|
||||
var entity = await dbContext.VerdictManifests
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(v => v.Tenant == tenant && v.ManifestId == manifestId, ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return null;
|
||||
return entity is null ? null : ToManifest(entity);
|
||||
}
|
||||
|
||||
public async Task<VerdictManifest?> GetByScopeAsync(
|
||||
@@ -123,55 +102,29 @@ public sealed class PostgresVerdictManifestStore : IVerdictManifestStore
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(assetDigest);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(vulnerabilityId);
|
||||
|
||||
var sql = """
|
||||
SELECT manifest_id, tenant, asset_digest, vulnerability_id,
|
||||
inputs_json, status, confidence, result_json,
|
||||
policy_hash, lattice_version, evaluated_at, manifest_digest,
|
||||
signature_base64, rekor_log_id
|
||||
FROM verdict_manifests
|
||||
WHERE tenant = @tenant
|
||||
AND asset_digest = @assetDigest
|
||||
AND vulnerability_id = @vulnerabilityId
|
||||
""";
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(policyHash))
|
||||
{
|
||||
sql += " AND policy_hash = @policyHash";
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(latticeVersion))
|
||||
{
|
||||
sql += " AND lattice_version = @latticeVersion";
|
||||
}
|
||||
|
||||
sql += " ORDER BY evaluated_at DESC LIMIT 1";
|
||||
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(tenant, "reader", ct).ConfigureAwait(false);
|
||||
await using var cmd = new NpgsqlCommand(sql, conn)
|
||||
{
|
||||
CommandTimeout = _dataSource.CommandTimeoutSeconds,
|
||||
};
|
||||
cmd.Parameters.AddWithValue("tenant", tenant);
|
||||
cmd.Parameters.AddWithValue("assetDigest", assetDigest);
|
||||
cmd.Parameters.AddWithValue("vulnerabilityId", vulnerabilityId);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(conn, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
IQueryable<VerdictManifestEfEntity> query = dbContext.VerdictManifests
|
||||
.AsNoTracking()
|
||||
.Where(v => v.Tenant == tenant && v.AssetDigest == assetDigest && v.VulnerabilityId == vulnerabilityId);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(policyHash))
|
||||
{
|
||||
cmd.Parameters.AddWithValue("policyHash", policyHash);
|
||||
query = query.Where(v => v.PolicyHash == policyHash);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(latticeVersion))
|
||||
{
|
||||
cmd.Parameters.AddWithValue("latticeVersion", latticeVersion);
|
||||
query = query.Where(v => v.LatticeVersion == latticeVersion);
|
||||
}
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
|
||||
if (await reader.ReadAsync(ct).ConfigureAwait(false))
|
||||
{
|
||||
return MapFromReader(reader);
|
||||
}
|
||||
var entity = await query
|
||||
.OrderByDescending(v => v.EvaluatedAt)
|
||||
.FirstOrDefaultAsync(ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return null;
|
||||
return entity is null ? null : ToManifest(entity);
|
||||
}
|
||||
|
||||
public async Task<VerdictManifestPage> ListByPolicyAsync(
|
||||
@@ -189,46 +142,28 @@ public sealed class PostgresVerdictManifestStore : IVerdictManifestStore
|
||||
var offset = ParsePageToken(pageToken);
|
||||
limit = Math.Clamp(limit, 1, 1000);
|
||||
|
||||
const string sql = """
|
||||
SELECT manifest_id, tenant, asset_digest, vulnerability_id,
|
||||
inputs_json, status, confidence, result_json,
|
||||
policy_hash, lattice_version, evaluated_at, manifest_digest,
|
||||
signature_base64, rekor_log_id
|
||||
FROM verdict_manifests
|
||||
WHERE tenant = @tenant
|
||||
AND policy_hash = @policyHash
|
||||
AND lattice_version = @latticeVersion
|
||||
ORDER BY evaluated_at DESC, manifest_id
|
||||
LIMIT @limit OFFSET @offset
|
||||
""";
|
||||
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(tenant, "reader", ct).ConfigureAwait(false);
|
||||
await using var cmd = new NpgsqlCommand(sql, conn)
|
||||
{
|
||||
CommandTimeout = _dataSource.CommandTimeoutSeconds,
|
||||
};
|
||||
cmd.Parameters.AddWithValue("tenant", tenant);
|
||||
cmd.Parameters.AddWithValue("policyHash", policyHash);
|
||||
cmd.Parameters.AddWithValue("latticeVersion", latticeVersion);
|
||||
cmd.Parameters.AddWithValue("limit", limit + 1);
|
||||
cmd.Parameters.AddWithValue("offset", offset);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(conn, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var manifests = new List<VerdictManifest>();
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
|
||||
while (await reader.ReadAsync(ct).ConfigureAwait(false))
|
||||
{
|
||||
manifests.Add(MapFromReader(reader));
|
||||
}
|
||||
var entities = await dbContext.VerdictManifests
|
||||
.AsNoTracking()
|
||||
.Where(v => v.Tenant == tenant && v.PolicyHash == policyHash && v.LatticeVersion == latticeVersion)
|
||||
.OrderByDescending(v => v.EvaluatedAt)
|
||||
.ThenBy(v => v.ManifestId)
|
||||
.Skip(offset)
|
||||
.Take(limit + 1)
|
||||
.ToListAsync(ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var hasMore = manifests.Count > limit;
|
||||
var hasMore = entities.Count > limit;
|
||||
if (hasMore)
|
||||
{
|
||||
manifests.RemoveAt(manifests.Count - 1);
|
||||
entities.RemoveAt(entities.Count - 1);
|
||||
}
|
||||
|
||||
return new VerdictManifestPage
|
||||
{
|
||||
Manifests = manifests.ToImmutableArray(),
|
||||
Manifests = entities.Select(ToManifest).ToImmutableArray(),
|
||||
NextPageToken = hasMore ? (offset + limit).ToString() : null,
|
||||
};
|
||||
}
|
||||
@@ -246,43 +181,28 @@ public sealed class PostgresVerdictManifestStore : IVerdictManifestStore
|
||||
var offset = ParsePageToken(pageToken);
|
||||
limit = Math.Clamp(limit, 1, 1000);
|
||||
|
||||
const string sql = """
|
||||
SELECT manifest_id, tenant, asset_digest, vulnerability_id,
|
||||
inputs_json, status, confidence, result_json,
|
||||
policy_hash, lattice_version, evaluated_at, manifest_digest,
|
||||
signature_base64, rekor_log_id
|
||||
FROM verdict_manifests
|
||||
WHERE tenant = @tenant AND asset_digest = @assetDigest
|
||||
ORDER BY evaluated_at DESC, manifest_id
|
||||
LIMIT @limit OFFSET @offset
|
||||
""";
|
||||
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(tenant, "reader", ct).ConfigureAwait(false);
|
||||
await using var cmd = new NpgsqlCommand(sql, conn)
|
||||
{
|
||||
CommandTimeout = _dataSource.CommandTimeoutSeconds,
|
||||
};
|
||||
cmd.Parameters.AddWithValue("tenant", tenant);
|
||||
cmd.Parameters.AddWithValue("assetDigest", assetDigest);
|
||||
cmd.Parameters.AddWithValue("limit", limit + 1);
|
||||
cmd.Parameters.AddWithValue("offset", offset);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(conn, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var manifests = new List<VerdictManifest>();
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
|
||||
while (await reader.ReadAsync(ct).ConfigureAwait(false))
|
||||
{
|
||||
manifests.Add(MapFromReader(reader));
|
||||
}
|
||||
var entities = await dbContext.VerdictManifests
|
||||
.AsNoTracking()
|
||||
.Where(v => v.Tenant == tenant && v.AssetDigest == assetDigest)
|
||||
.OrderByDescending(v => v.EvaluatedAt)
|
||||
.ThenBy(v => v.ManifestId)
|
||||
.Skip(offset)
|
||||
.Take(limit + 1)
|
||||
.ToListAsync(ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var hasMore = manifests.Count > limit;
|
||||
var hasMore = entities.Count > limit;
|
||||
if (hasMore)
|
||||
{
|
||||
manifests.RemoveAt(manifests.Count - 1);
|
||||
entities.RemoveAt(entities.Count - 1);
|
||||
}
|
||||
|
||||
return new VerdictManifestPage
|
||||
{
|
||||
Manifests = manifests.ToImmutableArray(),
|
||||
Manifests = entities.Select(ToManifest).ToImmutableArray(),
|
||||
NextPageToken = hasMore ? (offset + limit).ToString() : null,
|
||||
};
|
||||
}
|
||||
@@ -292,47 +212,38 @@ public sealed class PostgresVerdictManifestStore : IVerdictManifestStore
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenant);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(manifestId);
|
||||
|
||||
const string sql = """
|
||||
DELETE FROM verdict_manifests
|
||||
WHERE tenant = @tenant AND manifest_id = @manifestId
|
||||
""";
|
||||
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(tenant, "writer", ct).ConfigureAwait(false);
|
||||
await using var cmd = new NpgsqlCommand(sql, conn)
|
||||
{
|
||||
CommandTimeout = _dataSource.CommandTimeoutSeconds,
|
||||
};
|
||||
cmd.Parameters.AddWithValue("tenant", tenant);
|
||||
cmd.Parameters.AddWithValue("manifestId", manifestId);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(conn, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var rows = await dbContext.VerdictManifests
|
||||
.Where(v => v.Tenant == tenant && v.ManifestId == manifestId)
|
||||
.ExecuteDeleteAsync(ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var rows = await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
|
||||
return rows > 0;
|
||||
}
|
||||
|
||||
private static VerdictManifest MapFromReader(NpgsqlDataReader reader)
|
||||
private static VerdictManifest ToManifest(VerdictManifestEfEntity ef)
|
||||
{
|
||||
var inputsJson = reader.GetString(4);
|
||||
var resultJson = reader.GetString(7);
|
||||
|
||||
var inputs = JsonSerializer.Deserialize<VerdictInputs>(inputsJson, s_jsonOptions)
|
||||
var inputs = JsonSerializer.Deserialize<VerdictInputs>(ef.InputsJson, s_jsonOptions)
|
||||
?? throw new InvalidOperationException("Failed to deserialize inputs");
|
||||
var result = JsonSerializer.Deserialize<VerdictResult>(resultJson, s_jsonOptions)
|
||||
var result = JsonSerializer.Deserialize<VerdictResult>(ef.ResultJson, s_jsonOptions)
|
||||
?? throw new InvalidOperationException("Failed to deserialize result");
|
||||
|
||||
return new VerdictManifest
|
||||
{
|
||||
ManifestId = reader.GetString(0),
|
||||
Tenant = reader.GetString(1),
|
||||
AssetDigest = reader.GetString(2),
|
||||
VulnerabilityId = reader.GetString(3),
|
||||
ManifestId = ef.ManifestId,
|
||||
Tenant = ef.Tenant,
|
||||
AssetDigest = ef.AssetDigest,
|
||||
VulnerabilityId = ef.VulnerabilityId,
|
||||
Inputs = inputs,
|
||||
Result = result,
|
||||
PolicyHash = reader.GetString(8),
|
||||
LatticeVersion = reader.GetString(9),
|
||||
EvaluatedAt = reader.GetFieldValue<DateTimeOffset>(10),
|
||||
ManifestDigest = reader.GetString(11),
|
||||
SignatureBase64 = reader.IsDBNull(12) ? null : reader.GetString(12),
|
||||
RekorLogId = reader.IsDBNull(13) ? null : reader.GetString(13),
|
||||
PolicyHash = ef.PolicyHash,
|
||||
LatticeVersion = ef.LatticeVersion,
|
||||
EvaluatedAt = ef.EvaluatedAt,
|
||||
ManifestDigest = ef.ManifestDigest,
|
||||
SignatureBase64 = ef.SignatureBase64,
|
||||
RekorLogId = ef.RekorLogId,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -354,4 +265,6 @@ public sealed class PostgresVerdictManifestStore : IVerdictManifestStore
|
||||
|
||||
return int.TryParse(pageToken, out var offset) ? Math.Max(0, offset) : 0;
|
||||
}
|
||||
|
||||
private static string GetSchemaName() => AuthorityDataSource.DefaultSchemaName;
|
||||
}
|
||||
|
||||
@@ -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\AuthorityDbContextAssemblyAttributes.cs" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Authority 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_081_Authority_dal_to_efcore.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
@@ -9,3 +9,8 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
| AUDIT-0088-T | DONE | Revalidated 2026-01-06 (coverage reviewed). |
|
||||
| AUDIT-0088-A | TODO | Reopened 2026-01-06: replace Guid.NewGuid ID paths with deterministic generator. |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
| AUTH-EF-01 | DONE | AGENTS.md and migration registry wiring verified (2026-02-23). |
|
||||
| AUTH-EF-02 | DONE | EF Core model baseline scaffolded: 22 DbSets, entities, design-time factory (2026-02-23). |
|
||||
| AUTH-EF-03 | DONE | All 18 repositories + VerdictManifestStore converted from Npgsql to EF Core (2026-02-23). |
|
||||
| AUTH-EF-04 | DONE | Compiled model stubs and runtime factory with UseModel created (2026-02-23). |
|
||||
| AUTH-EF-05 | DONE | Sequential builds validated, sprint docs updated (2026-02-23). |
|
||||
|
||||
Reference in New Issue
Block a user