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:
master
2026-02-23 15:30:50 +02:00
parent bd8fee6ed8
commit e746577380
1424 changed files with 81225 additions and 25251 deletions

View File

@@ -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();
}

View File

@@ -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.
}
}

View File

@@ -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);
}

View File

@@ -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;
}
}

View File

@@ -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; }
}

View File

@@ -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);
}

View File

@@ -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))

View File

@@ -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);
}
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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);
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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>

View File

@@ -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). |