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

@@ -36,6 +36,7 @@ public class AuthAbstractionsConstantsTests
{
[nameof(StellaOpsClaimTypes.Subject)] = "sub",
[nameof(StellaOpsClaimTypes.Tenant)] = "stellaops:tenant",
[nameof(StellaOpsClaimTypes.AllowedTenants)] = "stellaops:allowed_tenants",
[nameof(StellaOpsClaimTypes.Project)] = "stellaops:project",
[nameof(StellaOpsClaimTypes.ClientId)] = "client_id",
[nameof(StellaOpsClaimTypes.ServiceAccount)] = "stellaops:service_account",
@@ -67,6 +68,7 @@ public class AuthAbstractionsConstantsTests
Assert.Equal(StellaOpsClaimTypes.Subject, expected[nameof(StellaOpsClaimTypes.Subject)]);
Assert.Equal(StellaOpsClaimTypes.Tenant, expected[nameof(StellaOpsClaimTypes.Tenant)]);
Assert.Equal(StellaOpsClaimTypes.AllowedTenants, expected[nameof(StellaOpsClaimTypes.AllowedTenants)]);
Assert.Equal(StellaOpsClaimTypes.Project, expected[nameof(StellaOpsClaimTypes.Project)]);
Assert.Equal(StellaOpsClaimTypes.ClientId, expected[nameof(StellaOpsClaimTypes.ClientId)]);
Assert.Equal(StellaOpsClaimTypes.ServiceAccount, expected[nameof(StellaOpsClaimTypes.ServiceAccount)]);

View File

@@ -15,6 +15,11 @@ public static class StellaOpsClaimTypes
/// </summary>
public const string Tenant = "stellaops:tenant";
/// <summary>
/// Space-separated set of tenant identifiers assigned to the token subject/client.
/// </summary>
public const string AllowedTenants = "stellaops:allowed_tenants";
/// <summary>
/// StellaOps project identifier claim (optional project scoping within a tenant).
/// </summary>

View File

@@ -607,6 +607,60 @@ public static class StellaOpsScopes
public const string DoctorExport = "doctor:export";
public const string DoctorAdmin = "doctor:admin";
// Doctor Scheduler scopes
public const string DoctorSchedulerRead = "doctor-scheduler:read";
public const string DoctorSchedulerWrite = "doctor-scheduler:write";
// OpsMemory scopes
public const string OpsMemoryRead = "ops-memory:read";
public const string OpsMemoryWrite = "ops-memory:write";
// Unknowns scopes
public const string UnknownsRead = "unknowns:read";
public const string UnknownsWrite = "unknowns:write";
public const string UnknownsAdmin = "unknowns:admin";
// Replay scopes
public const string ReplayRead = "replay:read";
public const string ReplayWrite = "replay:write";
// Symbols scopes
public const string SymbolsRead = "symbols:read";
public const string SymbolsWrite = "symbols:write";
// VexHub scopes
public const string VexHubRead = "vexhub:read";
public const string VexHubAdmin = "vexhub:admin";
// RiskEngine scopes
public const string RiskEngineRead = "risk-engine:read";
public const string RiskEngineOperate = "risk-engine:operate";
// SmRemote (SM cryptography service) scopes
public const string SmRemoteSign = "sm-remote:sign";
public const string SmRemoteVerify = "sm-remote:verify";
// TaskRunner scopes
public const string TaskRunnerRead = "taskrunner:read";
public const string TaskRunnerOperate = "taskrunner:operate";
public const string TaskRunnerAdmin = "taskrunner:admin";
// Integration catalog scopes
/// <summary>
/// Scope granting read-only access to integration catalog entries and health status.
/// </summary>
public const string IntegrationRead = "integration:read";
/// <summary>
/// Scope granting permission to create, update, and delete integration catalog entries.
/// </summary>
public const string IntegrationWrite = "integration:write";
/// <summary>
/// Scope granting permission to execute integration operations (test connections, run AI Code Guard).
/// </summary>
public const string IntegrationOperate = "integration:operate";
private static readonly IReadOnlyList<string> AllScopes = BuildAllScopes();
private static readonly HashSet<string> KnownScopes = new(AllScopes, StringComparer.OrdinalIgnoreCase);

View File

@@ -72,6 +72,14 @@ public sealed class StellaOpsAuthClientOptions
/// </summary>
public TimeSpan ExpirationSkew { get; set; } = TimeSpan.FromSeconds(30);
/// <summary>
/// Default tenant identifier included in token requests when callers do not provide
/// an explicit <c>tenant</c> additional parameter. When set, the token client will
/// automatically add <c>tenant=&lt;value&gt;</c> to <see cref="StellaOpsTokenClient"/>
/// token requests so the issued token carries the correct <c>stellaops:tenant</c> claim.
/// </summary>
public string? DefaultTenant { get; set; }
/// <summary>
/// Gets or sets a value indicating whether cached discovery/JWKS responses may be served when the Authority is unreachable.
/// </summary>
@@ -137,6 +145,7 @@ public sealed class StellaOpsAuthClientOptions
throw new InvalidOperationException("Offline cache tolerance must be greater than or equal to zero.");
}
DefaultTenant = string.IsNullOrWhiteSpace(DefaultTenant) ? null : DefaultTenant.Trim().ToLowerInvariant();
AuthorityUri = authorityUri;
NormalizedScopes = NormalizeScopes(scopes);
NormalizedRetryDelays = EnableRetries ? NormalizeRetryDelays(retryDelays) : Array.Empty<TimeSpan>();

View File

@@ -108,17 +108,19 @@ internal sealed class StellaOpsBearerTokenHandler : DelegatingHandler
await tokenClient.ClearCachedTokenAsync(cacheKey, cancellationToken).ConfigureAwait(false);
}
var tenantParameters = BuildTenantParameters(options);
StellaOpsTokenResult result = options.Mode switch
{
StellaOpsApiAuthMode.ClientCredentials => await tokenClient.RequestClientCredentialsTokenAsync(
options.Scope,
null,
tenantParameters,
cancellationToken).ConfigureAwait(false),
StellaOpsApiAuthMode.Password => await tokenClient.RequestPasswordTokenAsync(
options.Username!,
options.Password!,
options.Scope,
null,
tenantParameters,
cancellationToken).ConfigureAwait(false),
_ => throw new InvalidOperationException($"Unsupported authentication mode '{options.Mode}'.")
};
@@ -135,6 +137,19 @@ internal sealed class StellaOpsBearerTokenHandler : DelegatingHandler
}
}
private static IReadOnlyDictionary<string, string>? BuildTenantParameters(StellaOpsApiAuthenticationOptions options)
{
if (string.IsNullOrWhiteSpace(options.Tenant))
{
return null;
}
return new Dictionary<string, string>(1, StringComparer.Ordinal)
{
["tenant"] = options.Tenant.Trim().ToLowerInvariant()
};
}
private TimeSpan GetRefreshBuffer(StellaOpsApiAuthenticationOptions options)
{
var authOptions = authClientOptions.CurrentValue;

View File

@@ -89,6 +89,8 @@ public sealed class StellaOpsTokenClient : IStellaOpsTokenClient
}
}
AppendDefaultTenant(parameters, options);
return RequestTokenAsync(parameters, cancellationToken);
}
@@ -126,6 +128,8 @@ public sealed class StellaOpsTokenClient : IStellaOpsTokenClient
}
}
AppendDefaultTenant(parameters, options);
return RequestTokenAsync(parameters, cancellationToken);
}
@@ -186,6 +190,24 @@ public sealed class StellaOpsTokenClient : IStellaOpsTokenClient
return result;
}
/// <summary>
/// Injects the configured default tenant into the token request when callers have not
/// provided an explicit <c>tenant</c> parameter. This ensures the issued token carries
/// the correct <c>stellaops:tenant</c> claim for multi-tenant deployments.
/// </summary>
private static void AppendDefaultTenant(IDictionary<string, string> parameters, StellaOpsAuthClientOptions options)
{
if (parameters.ContainsKey("tenant"))
{
return;
}
if (!string.IsNullOrWhiteSpace(options.DefaultTenant))
{
parameters["tenant"] = options.DefaultTenant;
}
}
private static void AppendScope(IDictionary<string, string> parameters, string? scope, StellaOpsAuthClientOptions options)
{
var resolvedScope = scope;

View File

@@ -0,0 +1,19 @@
namespace StellaOps.Auth.ServerIntegration.Tenancy;
/// <summary>
/// Provides access to the resolved <see cref="StellaOpsTenantContext"/> for the current request.
/// Injected via DI; populated by <see cref="StellaOpsTenantMiddleware"/>.
/// </summary>
public interface IStellaOpsTenantAccessor
{
/// <summary>
/// The resolved tenant context, or <c>null</c> if tenant was not resolved
/// (e.g. for system/global endpoints that do not require a tenant).
/// </summary>
StellaOpsTenantContext? TenantContext { get; set; }
/// <summary>
/// Shortcut to <see cref="StellaOpsTenantContext.TenantId"/> or <c>null</c>.
/// </summary>
string? TenantId => TenantContext?.TenantId;
}

View File

@@ -0,0 +1,17 @@
namespace StellaOps.Auth.ServerIntegration.Tenancy;
/// <summary>
/// Default AsyncLocal-based implementation of <see cref="IStellaOpsTenantAccessor"/>.
/// Safe across async boundaries within a single request.
/// </summary>
internal sealed class StellaOpsTenantAccessor : IStellaOpsTenantAccessor
{
private static readonly AsyncLocal<StellaOpsTenantContext?> _current = new();
/// <inheritdoc />
public StellaOpsTenantContext? TenantContext
{
get => _current.Value;
set => _current.Value = value;
}
}

View File

@@ -0,0 +1,47 @@
using System;
namespace StellaOps.Auth.ServerIntegration.Tenancy;
/// <summary>
/// Immutable resolved tenant context for an HTTP request.
/// </summary>
public sealed record StellaOpsTenantContext
{
/// <summary>
/// The resolved tenant identifier (normalised, lower-case).
/// </summary>
public required string TenantId { get; init; }
/// <summary>
/// The actor who made the request (sub, client_id, or anonymous).
/// </summary>
public string ActorId { get; init; } = "anonymous";
/// <summary>
/// Optional project scope within the tenant.
/// </summary>
public string? ProjectId { get; init; }
/// <summary>
/// Where the tenant was resolved from (for diagnostics).
/// </summary>
public TenantSource Source { get; init; } = TenantSource.Unknown;
}
/// <summary>
/// Identifies the source that provided the tenant identifier.
/// </summary>
public enum TenantSource
{
/// <summary>Source unknown or not set.</summary>
Unknown = 0,
/// <summary>Resolved from the canonical <c>stellaops:tenant</c> JWT claim.</summary>
Claim = 1,
/// <summary>Resolved from the <c>X-StellaOps-Tenant</c> header.</summary>
CanonicalHeader = 2,
/// <summary>Resolved from a legacy header (<c>X-Stella-Tenant</c>, <c>X-Tenant-Id</c>).</summary>
LegacyHeader = 3,
}

View File

@@ -0,0 +1,92 @@
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using System;
using System.Text.Json;
using System.Threading.Tasks;
namespace StellaOps.Auth.ServerIntegration.Tenancy;
/// <summary>
/// ASP.NET Core middleware that resolves tenant context from every request and
/// populates <see cref="IStellaOpsTenantAccessor"/> for downstream handlers.
/// <para>
/// Endpoints that require tenant context should use the <c>RequireTenant()</c> endpoint filter
/// rather than relying on this middleware to reject tenantless requests — this middleware
/// is intentionally permissive so that global/system endpoints can proceed without a tenant.
/// </para>
/// </summary>
internal sealed class StellaOpsTenantMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<StellaOpsTenantMiddleware> _logger;
public StellaOpsTenantMiddleware(RequestDelegate next, ILogger<StellaOpsTenantMiddleware> logger)
{
_next = next ?? throw new ArgumentNullException(nameof(next));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task InvokeAsync(HttpContext context, IStellaOpsTenantAccessor accessor)
{
try
{
if (StellaOpsTenantResolver.TryResolve(context, out var tenantContext, out var error))
{
accessor.TenantContext = tenantContext;
_logger.LogDebug("Tenant resolved: {TenantId} from {Source}", tenantContext!.TenantId, tenantContext.Source);
}
else
{
_logger.LogDebug("Tenant not resolved: {Error}", error);
}
await _next(context);
}
finally
{
accessor.TenantContext = null;
}
}
}
/// <summary>
/// Endpoint filter that rejects requests without a resolved tenant context with HTTP 400.
/// Apply to route groups or individual endpoints via <c>.RequireTenant()</c>.
/// </summary>
internal sealed class StellaOpsTenantEndpointFilter : IEndpointFilter
{
public async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)
{
var accessor = context.HttpContext.RequestServices.GetService(typeof(IStellaOpsTenantAccessor)) as IStellaOpsTenantAccessor;
if (accessor?.TenantContext is not null)
{
return await next(context);
}
// Tenant middleware ran but couldn't resolve — try to get the error reason.
// Return an IResult instead of writing directly to the response to avoid
// "response headers cannot be modified" when the framework also tries to
// serialize the filter's return value.
if (!StellaOpsTenantResolver.TryResolveTenantId(context.HttpContext, out _, out var error))
{
return Results.Json(new
{
type = "https://stellaops.org/errors/tenant-required",
title = "Tenant context is required",
status = 400,
detail = error switch
{
"tenant_missing" => "A valid tenant identifier must be provided via the stellaops:tenant claim or X-StellaOps-Tenant header.",
"tenant_conflict" => "Conflicting tenant identifiers detected across claims and headers.",
"tenant_invalid_format" => "Tenant identifier is not in the expected format.",
_ => $"Tenant resolution failed: {error}",
},
error_code = error,
}, statusCode: StatusCodes.Status400BadRequest, contentType: "application/problem+json");
}
// Should not happen (accessor is null but resolver succeeds) — internal error
return Results.StatusCode(StatusCodes.Status500InternalServerError);
}
}

View File

@@ -0,0 +1,266 @@
using Microsoft.AspNetCore.Http;
using StellaOps.Auth.Abstractions;
using System;
using System.Security.Claims;
namespace StellaOps.Auth.ServerIntegration.Tenancy;
/// <summary>
/// Unified tenant resolver for all StellaOps backend services.
/// Resolves tenant identity from JWT claims and HTTP headers using a deterministic priority order:
/// <list type="number">
/// <item>Canonical claim: <c>stellaops:tenant</c></item>
/// <item>Legacy claim: <c>tid</c></item>
/// <item>Canonical header: <c>X-StellaOps-Tenant</c></item>
/// <item>Legacy header: <c>X-Stella-Tenant</c></item>
/// <item>Alternate header: <c>X-Tenant-Id</c></item>
/// </list>
/// Claims always win over headers. Conflicting headers or claim-header mismatches return an error.
/// </summary>
public static class StellaOpsTenantResolver
{
private const string LegacyTenantClaim = "tid";
private const string LegacyTenantHeader = "X-Stella-Tenant";
private const string AlternateTenantHeader = "X-Tenant-Id";
private const string ActorHeader = "X-StellaOps-Actor";
private const string ProjectHeader = "X-Stella-Project";
/// <summary>
/// Attempts to resolve a full <see cref="StellaOpsTenantContext"/> from the request.
/// </summary>
/// <param name="context">The HTTP context.</param>
/// <param name="tenantContext">The resolved tenant context on success.</param>
/// <param name="error">A machine-readable error code on failure (e.g. <c>tenant_missing</c>, <c>tenant_conflict</c>).</param>
/// <returns><c>true</c> if the tenant was resolved; <c>false</c> otherwise.</returns>
public static bool TryResolve(
HttpContext context,
out StellaOpsTenantContext? tenantContext,
out string? error)
{
ArgumentNullException.ThrowIfNull(context);
tenantContext = null;
error = null;
if (!TryResolveTenant(context, out var tenantId, out var source, out error))
{
return false;
}
var actorId = ResolveActor(context);
var projectId = ResolveProject(context);
tenantContext = new StellaOpsTenantContext
{
TenantId = tenantId,
ActorId = actorId,
ProjectId = projectId,
Source = source,
};
return true;
}
/// <summary>
/// Resolves only the tenant identifier (lightweight; no actor/project resolution).
/// </summary>
/// <param name="context">The HTTP context.</param>
/// <param name="tenantId">The resolved tenant identifier (normalised, lower-case).</param>
/// <param name="error">A machine-readable error code on failure.</param>
/// <returns><c>true</c> if the tenant was resolved; <c>false</c> otherwise.</returns>
public static bool TryResolveTenantId(
HttpContext context,
out string tenantId,
out string? error)
{
return TryResolveTenant(context, out tenantId, out _, out error);
}
/// <summary>
/// Resolves tenant ID or returns a default value if tenant is not available.
/// Useful for endpoints that support optional tenancy (e.g. system-scoped with optional tenant).
/// </summary>
public static string ResolveTenantIdOrDefault(HttpContext context, string defaultTenant = "default")
{
if (TryResolveTenantId(context, out var tenantId, out _))
{
return tenantId;
}
return NormalizeTenant(defaultTenant) ?? "default";
}
/// <summary>
/// Resolves the actor identifier from claims/headers. Falls back to <c>anonymous</c>.
/// </summary>
public static string ResolveActor(HttpContext context, string fallback = "anonymous")
{
ArgumentNullException.ThrowIfNull(context);
var subject = context.User.FindFirstValue(StellaOpsClaimTypes.Subject);
if (!string.IsNullOrWhiteSpace(subject))
return subject.Trim();
var clientId = context.User.FindFirstValue(StellaOpsClaimTypes.ClientId);
if (!string.IsNullOrWhiteSpace(clientId))
return clientId.Trim();
if (TryReadHeader(context, ActorHeader, out var actor))
return actor;
var identityName = context.User.Identity?.Name;
if (!string.IsNullOrWhiteSpace(identityName))
return identityName.Trim();
return fallback;
}
/// <summary>
/// Resolves the optional project scope from claims/headers.
/// </summary>
public static string? ResolveProject(HttpContext context)
{
ArgumentNullException.ThrowIfNull(context);
var projectClaim = context.User.FindFirstValue(StellaOpsClaimTypes.Project);
if (!string.IsNullOrWhiteSpace(projectClaim))
return projectClaim.Trim();
if (TryReadHeader(context, ProjectHeader, out var project))
return project;
return null;
}
/// <summary>
/// Attempts to parse the resolved tenant ID as a <see cref="Guid"/>.
/// Useful for modules that use GUID-typed tenant identifiers in their repositories.
/// </summary>
public static bool TryResolveTenantGuid(
HttpContext context,
out Guid tenantGuid,
out string? error)
{
tenantGuid = Guid.Empty;
if (!TryResolveTenantId(context, out var tenantId, out error))
return false;
if (!Guid.TryParse(tenantId, out tenantGuid))
{
error = "tenant_invalid_format";
return false;
}
return true;
}
// ── Core resolution ───────────────────────────────────────────────
private static bool TryResolveTenant(
HttpContext context,
out string tenantId,
out TenantSource source,
out string? error)
{
tenantId = string.Empty;
source = TenantSource.Unknown;
error = null;
// 1. Claims (highest priority)
var claimTenant = NormalizeTenant(
context.User.FindFirstValue(StellaOpsClaimTypes.Tenant)
?? context.User.FindFirstValue(LegacyTenantClaim));
// 2. Headers (fallback)
var canonicalHeader = ReadTenantHeader(context, StellaOpsHttpHeaderNames.Tenant);
var legacyHeader = ReadTenantHeader(context, LegacyTenantHeader);
var alternateHeader = ReadTenantHeader(context, AlternateTenantHeader);
// Detect header conflicts
if (HasConflicts(canonicalHeader, legacyHeader, alternateHeader))
{
error = "tenant_conflict";
return false;
}
var headerTenant = canonicalHeader ?? legacyHeader ?? alternateHeader;
var headerSource = canonicalHeader is not null ? TenantSource.CanonicalHeader
: legacyHeader is not null ? TenantSource.LegacyHeader
: alternateHeader is not null ? TenantSource.LegacyHeader
: TenantSource.Unknown;
// Claim wins if available
if (!string.IsNullOrWhiteSpace(claimTenant))
{
// Detect claim-header mismatch
if (!string.IsNullOrWhiteSpace(headerTenant)
&& !string.Equals(claimTenant, headerTenant, StringComparison.Ordinal))
{
error = "tenant_conflict";
return false;
}
tenantId = claimTenant;
source = TenantSource.Claim;
return true;
}
// Header fallback
if (!string.IsNullOrWhiteSpace(headerTenant))
{
tenantId = headerTenant;
source = headerSource;
return true;
}
error = "tenant_missing";
return false;
}
private static bool HasConflicts(params string?[] candidates)
{
string? baseline = null;
foreach (var candidate in candidates)
{
if (string.IsNullOrWhiteSpace(candidate))
continue;
if (baseline is null)
{
baseline = candidate;
continue;
}
if (!string.Equals(baseline, candidate, StringComparison.Ordinal))
return true;
}
return false;
}
private static string? ReadTenantHeader(HttpContext context, string headerName)
{
return TryReadHeader(context, headerName, out var value)
? NormalizeTenant(value)
: null;
}
private static bool TryReadHeader(HttpContext context, string headerName, out string value)
{
value = string.Empty;
if (!context.Request.Headers.TryGetValue(headerName, out var values))
return false;
var raw = values.ToString();
if (string.IsNullOrWhiteSpace(raw))
return false;
value = raw.Trim();
return true;
}
private static string? NormalizeTenant(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim().ToLowerInvariant();
}

View File

@@ -0,0 +1,59 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
namespace StellaOps.Auth.ServerIntegration.Tenancy;
/// <summary>
/// Extension methods for registering the unified StellaOps tenant infrastructure.
/// </summary>
public static class StellaOpsTenantServiceCollectionExtensions
{
/// <summary>
/// Registers the <see cref="IStellaOpsTenantAccessor"/> in the DI container.
/// Call <see cref="UseStellaOpsTenantMiddleware"/> to activate the middleware.
/// </summary>
public static IServiceCollection AddStellaOpsTenantServices(this IServiceCollection services)
{
services.TryAddSingleton<IStellaOpsTenantAccessor, StellaOpsTenantAccessor>();
return services;
}
/// <summary>
/// Adds the <see cref="StellaOpsTenantMiddleware"/> to the pipeline.
/// Must be placed after authentication/authorization middleware.
/// </summary>
public static IApplicationBuilder UseStellaOpsTenantMiddleware(this IApplicationBuilder app)
{
return app.UseMiddleware<StellaOpsTenantMiddleware>();
}
/// <summary>
/// Adds a <see cref="StellaOpsTenantEndpointFilter"/> that rejects requests without
/// a resolved tenant context with HTTP 400.
/// Apply to route groups that require tenant scoping.
/// </summary>
/// <example>
/// <code>
/// var group = app.MapGroup("/api/profiles").RequireTenant();
/// </code>
/// </example>
public static RouteGroupBuilder RequireTenant(this RouteGroupBuilder builder)
{
builder.AddEndpointFilter<StellaOpsTenantEndpointFilter>();
return builder;
}
/// <summary>
/// Adds a <see cref="StellaOpsTenantEndpointFilter"/> that rejects requests without
/// a resolved tenant context with HTTP 400.
/// Apply to individual route handlers.
/// </summary>
public static RouteHandlerBuilder RequireTenant(this RouteHandlerBuilder builder)
{
builder.AddEndpointFilter<StellaOpsTenantEndpointFilter>();
return builder;
}
}

View File

@@ -58,6 +58,47 @@ public sealed class LdapClientProvisioningStoreTests
Assert.Single(auditStore.Records);
}
[Fact]
public async Task CreateOrUpdateAsync_NormalizesTenantAssignments()
{
var clientStore = new TrackingClientStore();
var revocationStore = new TrackingRevocationStore();
var fakeConnection = new FakeLdapConnection();
var options = CreateOptions();
var optionsMonitor = new TestOptionsMonitor<LdapPluginOptions>(options);
var auditStore = new TestAirgapAuditStore();
var store = new LdapClientProvisioningStore(
"ldap",
clientStore,
revocationStore,
new FakeLdapConnectionFactory(fakeConnection),
optionsMonitor,
auditStore,
timeProvider,
NullLogger<LdapClientProvisioningStore>.Instance);
var registration = new AuthorityClientRegistration(
clientId: "svc-tenant-multi",
confidential: false,
displayName: "Tenant Multi Client",
clientSecret: null,
allowedGrantTypes: new[] { "client_credentials" },
allowedScopes: new[] { "jobs:read" },
tenant: null,
properties: new Dictionary<string, string?>
{
[AuthorityClientMetadataKeys.Tenants] = " tenant-bravo tenant-alpha tenant-bravo "
});
var result = await store.CreateOrUpdateAsync(registration, TestContext.Current.CancellationToken);
Assert.True(result.Succeeded);
Assert.True(clientStore.Documents.TryGetValue("svc-tenant-multi", out var document));
Assert.NotNull(document);
Assert.Equal("tenant-alpha tenant-bravo", document!.Properties[AuthorityClientMetadataKeys.Tenants]);
Assert.False(document.Properties.ContainsKey(AuthorityClientMetadataKeys.Tenant));
}
[Fact]
public async Task DeleteAsync_RemovesClientAndLogsRevocation()
{
@@ -171,6 +212,19 @@ public sealed class LdapClientProvisioningStoreTests
return ValueTask.FromResult(document);
}
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 page = Documents.Values
.OrderBy(client => client.ClientId, StringComparer.Ordinal)
.Skip(skip)
.Take(take)
.ToList();
return ValueTask.FromResult<IReadOnlyList<AuthorityClientDocument>>(page);
}
public ValueTask UpsertAsync(AuthorityClientDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
Documents[document.ClientId] = document;

View File

@@ -275,11 +275,36 @@ internal sealed class LdapClientProvisioningStore : IClientProvisioningStore
document.Properties[AuthorityClientMetadataKeys.AllowedScopes] = JoinValues(registration.AllowedScopes);
document.Properties[AuthorityClientMetadataKeys.Audiences] = JoinValues(registration.AllowedAudiences);
foreach (var (key, value) in registration.Properties)
{
document.Properties[key] = value;
}
var tenant = NormalizeTenant(registration.Tenant);
var normalizedTenants = NormalizeTenants(
registration.Properties.TryGetValue(AuthorityClientMetadataKeys.Tenants, out var tenantAssignments) ? tenantAssignments : null,
tenant);
if (normalizedTenants.Count > 0)
{
document.Properties[AuthorityClientMetadataKeys.Tenants] = string.Join(" ", normalizedTenants);
}
else
{
document.Properties.Remove(AuthorityClientMetadataKeys.Tenants);
}
if (!string.IsNullOrWhiteSpace(tenant))
{
document.Properties[AuthorityClientMetadataKeys.Tenant] = tenant;
}
else if (normalizedTenants.Count == 1)
{
document.Properties[AuthorityClientMetadataKeys.Tenant] = normalizedTenants[0];
}
else
{
document.Properties.Remove(AuthorityClientMetadataKeys.Tenant);
}
document.Properties[AuthorityClientMetadataKeys.Project] = registration.Project ?? StellaOpsTenancyDefaults.AnyProject;
@@ -362,6 +387,34 @@ internal sealed class LdapClientProvisioningStore : IClientProvisioningStore
private static string? NormalizeTenant(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim().ToLowerInvariant();
private static IReadOnlyList<string> NormalizeTenants(string? rawTenants, string? scalarTenant)
{
var values = new List<string>();
if (!string.IsNullOrWhiteSpace(rawTenants))
{
values.AddRange(rawTenants.Split([' ', ',', ';', '\t', '\r', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries));
}
if (!string.IsNullOrWhiteSpace(scalarTenant))
{
values.Add(scalarTenant);
}
if (values.Count == 0)
{
return Array.Empty<string>();
}
return values
.Select(NormalizeTenant)
.Where(static value => !string.IsNullOrWhiteSpace(value))
.Select(static value => value!)
.Distinct(StringComparer.Ordinal)
.OrderBy(static value => value, StringComparer.Ordinal)
.ToArray();
}
private static AuthorityClientCertificateBinding MapCertificateBinding(
AuthorityClientCertificateBindingRegistration registration,
DateTimeOffset now)

View File

@@ -104,6 +104,35 @@ public class StandardClientProvisioningStoreTests
Assert.Equal(new[] { "attestor", "signer" }, descriptor!.AllowedAudiences.OrderBy(value => value, StringComparer.Ordinal));
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task CreateOrUpdateAsync_NormalizesTenantAssignments()
{
var store = new TrackingClientStore();
var revocations = new TrackingRevocationStore();
var provisioning = new StandardClientProvisioningStore("standard", store, revocations, TimeProvider.System);
var registration = new AuthorityClientRegistration(
clientId: "tenant-multi-client",
confidential: false,
displayName: "Tenant Multi Client",
clientSecret: null,
allowedGrantTypes: new[] { "client_credentials" },
allowedScopes: new[] { "jobs:read" },
tenant: null,
properties: new Dictionary<string, string?>
{
[AuthorityClientMetadataKeys.Tenants] = " tenant-bravo tenant-alpha tenant-bravo "
});
await provisioning.CreateOrUpdateAsync(registration, TestContext.Current.CancellationToken);
Assert.True(store.Documents.TryGetValue("tenant-multi-client", out var document));
Assert.NotNull(document);
Assert.Equal("tenant-alpha tenant-bravo", document!.Properties[AuthorityClientMetadataKeys.Tenants]);
Assert.False(document.Properties.ContainsKey(AuthorityClientMetadataKeys.Tenant));
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task CreateOrUpdateAsync_MapsCertificateBindings()
@@ -191,6 +220,19 @@ public class StandardClientProvisioningStoreTests
return ValueTask.FromResult(document);
}
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 page = Documents.Values
.OrderBy(client => client.ClientId, StringComparer.Ordinal)
.Skip(skip)
.Take(take)
.ToList();
return ValueTask.FromResult<IReadOnlyList<AuthorityClientDocument>>(page);
}
public ValueTask UpsertAsync(AuthorityClientDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
Documents[document.ClientId] = document;

View File

@@ -322,6 +322,20 @@ internal sealed class InMemoryClientStore : IAuthorityClientStore
return ValueTask.FromResult(document);
}
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 page = clients.Values
.OrderBy(client => client.ClientId, StringComparer.Ordinal)
.Skip(skip)
.Take(take)
.ToList();
return ValueTask.FromResult<IReadOnlyList<AuthorityClientDocument>>(page);
}
public ValueTask UpsertAsync(AuthorityClientDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
clients[document.ClientId] = document;

View File

@@ -72,10 +72,27 @@ internal sealed class StandardClientProvisioningStore : IClientProvisioningStore
}
var normalizedTenant = NormalizeTenant(registration.Tenant);
var normalizedTenants = NormalizeTenants(
registration.Properties.TryGetValue(AuthorityClientMetadataKeys.Tenants, out var tenantAssignments) ? tenantAssignments : null,
normalizedTenant);
if (normalizedTenants.Count > 0)
{
document.Properties[AuthorityClientMetadataKeys.Tenants] = string.Join(" ", normalizedTenants);
}
else
{
document.Properties.Remove(AuthorityClientMetadataKeys.Tenants);
}
if (normalizedTenant is not null)
{
document.Properties[AuthorityClientMetadataKeys.Tenant] = normalizedTenant;
}
else if (normalizedTenants.Count == 1)
{
document.Properties[AuthorityClientMetadataKeys.Tenant] = normalizedTenants[0];
}
else
{
document.Properties.Remove(AuthorityClientMetadataKeys.Tenant);
@@ -205,6 +222,34 @@ internal sealed class StandardClientProvisioningStore : IClientProvisioningStore
private static string? NormalizeTenant(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim().ToLowerInvariant();
private static IReadOnlyList<string> NormalizeTenants(string? rawTenants, string? scalarTenant)
{
var values = new List<string>();
if (!string.IsNullOrWhiteSpace(rawTenants))
{
values.AddRange(rawTenants.Split([' ', ',', ';', '\t', '\r', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries));
}
if (!string.IsNullOrWhiteSpace(scalarTenant))
{
values.Add(scalarTenant);
}
if (values.Count == 0)
{
return Array.Empty<string>();
}
return values
.Select(NormalizeTenant)
.Where(static value => !string.IsNullOrWhiteSpace(value))
.Select(static value => value!)
.Distinct(StringComparer.Ordinal)
.OrderBy(static value => value, StringComparer.Ordinal)
.ToArray();
}
private static AuthorityClientCertificateBinding MapCertificateBinding(
AuthorityClientCertificateBindingRegistration registration,
DateTimeOffset now)

View File

@@ -12,6 +12,7 @@ public static class AuthorityClientMetadataKeys
public const string PostLogoutRedirectUris = "postLogoutRedirectUris";
public const string SenderConstraint = "senderConstraint";
public const string Tenant = "tenant";
public const string Tenants = "tenants";
public const string Project = "project";
public const string ServiceIdentity = "serviceIdentity";
public const string RequiresAirGapSealConfirmation = "requiresAirgapSealConfirmation";

View File

@@ -19,8 +19,11 @@ using OpenIddict.Abstractions;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Authority.Console.Admin;
using StellaOps.Authority.Persistence.Documents;
using StellaOps.Authority.Persistence.InMemory.Stores;
using StellaOps.Authority.Persistence.Postgres.Models;
using StellaOps.Authority.Persistence.Postgres.Repositories;
using StellaOps.Authority.Persistence.Sessions;
using StellaOps.Cryptography.Audit;
using Xunit;
@@ -158,10 +161,256 @@ public sealed class ConsoleAdminEndpointsTests
Assert.Contains(payload!.Users, static user => user.Username == "legacy-api-user");
}
[Fact]
public async Task CreateClient_WithMultiTenantAssignments_PersistsNormalizedAssignments()
{
var now = new DateTimeOffset(2026, 2, 20, 15, 0, 0, TimeSpan.Zero);
var timeProvider = new FakeTimeProvider(now);
var sink = new RecordingAuthEventSink();
var users = new InMemoryUserRepository();
var clients = new InMemoryClientStore();
await using var app = await CreateApplicationAsync(timeProvider, sink, users, clients);
var principalAccessor = app.Services.GetRequiredService<AdminTestPrincipalAccessor>();
principalAccessor.Principal = CreatePrincipal(
tenant: "tenant-alpha",
scopes: new[] { StellaOpsScopes.UiAdmin, StellaOpsScopes.AuthorityClientsRead, StellaOpsScopes.AuthorityClientsWrite },
expiresAt: now.AddMinutes(10));
using var client = CreateTestClient(app);
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(AdminAuthenticationDefaults.AuthenticationScheme);
client.DefaultRequestHeaders.Add(AuthorityHttpHeaders.Tenant, "tenant-alpha");
var createResponse = await client.PostAsJsonAsync(
"/console/admin/clients",
new
{
clientId = "svc-alpha",
displayName = "Service Alpha",
grantTypes = new[] { "client_credentials", "client_credentials" },
scopes = new[] { "platform:read", "scanner:read" },
tenant = "tenant-alpha",
tenants = new[] { "tenant-bravo", "tenant-alpha" },
requireClientSecret = false
});
Assert.Equal(HttpStatusCode.Created, createResponse.StatusCode);
var created = await createResponse.Content.ReadFromJsonAsync<ClientSummary>();
Assert.NotNull(created);
Assert.Equal("svc-alpha", created!.ClientId);
Assert.Equal("tenant-alpha", created.DefaultTenant);
Assert.Equal(new[] { "tenant-alpha", "tenant-bravo" }, created.Tenants);
Assert.Equal(new[] { "client_credentials" }, created.AllowedGrantTypes);
var createEvent = Assert.Single(sink.Events.Where(record => record.EventType == "authority.admin.clients.create"));
Assert.Equal("tenant-alpha tenant-bravo", GetPropertyValue(createEvent, "client.tenants.after"));
var listResponse = await client.GetAsync("/console/admin/clients");
Assert.Equal(HttpStatusCode.OK, listResponse.StatusCode);
var listed = await listResponse.Content.ReadFromJsonAsync<ClientListPayload>();
Assert.NotNull(listed);
Assert.Contains(listed!.Clients, static result => result.ClientId == "svc-alpha");
}
[Fact]
public async Task UpdateClient_UpdatesTenantAssignmentsAndDefaultTenant()
{
var now = new DateTimeOffset(2026, 2, 20, 16, 0, 0, TimeSpan.Zero);
var timeProvider = new FakeTimeProvider(now);
var sink = new RecordingAuthEventSink();
var users = new InMemoryUserRepository();
var clients = new InMemoryClientStore();
await clients.UpsertAsync(
new AuthorityClientDocument
{
ClientId = "svc-update",
DisplayName = "Original",
Enabled = true,
AllowedGrantTypes = new List<string> { "client_credentials" },
AllowedScopes = new List<string> { "platform:read" },
Properties = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase)
{
["tenant"] = "tenant-alpha",
["tenants"] = "tenant-alpha tenant-bravo",
["allowed_grant_types"] = "client_credentials",
["allowed_scopes"] = "platform:read"
},
CreatedAt = now.AddHours(-1),
UpdatedAt = now.AddHours(-1)
},
CancellationToken.None);
await using var app = await CreateApplicationAsync(timeProvider, sink, users, clients);
var principalAccessor = app.Services.GetRequiredService<AdminTestPrincipalAccessor>();
principalAccessor.Principal = CreatePrincipal(
tenant: "tenant-alpha",
scopes: new[] { StellaOpsScopes.UiAdmin, StellaOpsScopes.AuthorityClientsRead, StellaOpsScopes.AuthorityClientsWrite },
expiresAt: now.AddMinutes(10));
using var client = CreateTestClient(app);
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(AdminAuthenticationDefaults.AuthenticationScheme);
client.DefaultRequestHeaders.Add(AuthorityHttpHeaders.Tenant, "tenant-alpha");
var updateResponse = await client.PatchAsJsonAsync(
"/console/admin/clients/svc-update",
new
{
displayName = "Updated Name",
tenants = new[] { "tenant-bravo", "tenant-charlie" },
tenant = "tenant-bravo"
});
Assert.Equal(HttpStatusCode.OK, updateResponse.StatusCode);
var updated = await updateResponse.Content.ReadFromJsonAsync<ClientSummary>();
Assert.NotNull(updated);
Assert.Equal("Updated Name", updated!.DisplayName);
Assert.Equal("tenant-bravo", updated.DefaultTenant);
Assert.Equal(new[] { "tenant-bravo", "tenant-charlie" }, updated.Tenants);
var updateEvent = Assert.Single(sink.Events.Where(record => record.EventType == "authority.admin.clients.update"));
Assert.Equal("tenant-alpha tenant-bravo", GetPropertyValue(updateEvent, "client.tenants.before"));
Assert.Equal("tenant-bravo tenant-charlie", GetPropertyValue(updateEvent, "client.tenants.after"));
}
[Fact]
public async Task CreateClient_RejectsDuplicateTenantAssignments()
{
var now = new DateTimeOffset(2026, 2, 20, 17, 0, 0, TimeSpan.Zero);
var timeProvider = new FakeTimeProvider(now);
var sink = new RecordingAuthEventSink();
var users = new InMemoryUserRepository();
var clients = new InMemoryClientStore();
await using var app = await CreateApplicationAsync(timeProvider, sink, users, clients);
var principalAccessor = app.Services.GetRequiredService<AdminTestPrincipalAccessor>();
principalAccessor.Principal = CreatePrincipal(
tenant: "tenant-alpha",
scopes: new[] { StellaOpsScopes.UiAdmin, StellaOpsScopes.AuthorityClientsRead, StellaOpsScopes.AuthorityClientsWrite },
expiresAt: now.AddMinutes(10));
using var client = CreateTestClient(app);
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(AdminAuthenticationDefaults.AuthenticationScheme);
client.DefaultRequestHeaders.Add(AuthorityHttpHeaders.Tenant, "tenant-alpha");
var createResponse = await client.PostAsJsonAsync(
"/console/admin/clients",
new
{
clientId = "svc-duplicate",
grantTypes = new[] { "client_credentials" },
scopes = new[] { "platform:read" },
tenants = new[] { "tenant-alpha", "TENANT-ALPHA" },
requireClientSecret = false
});
Assert.Equal(HttpStatusCode.BadRequest, createResponse.StatusCode);
var payload = await createResponse.Content.ReadFromJsonAsync<ErrorPayload>();
Assert.NotNull(payload);
Assert.Equal("duplicate_tenant_assignment", payload!.Error);
}
[Fact]
public async Task CreateClient_RejectsMissingTenantAssignments()
{
var now = new DateTimeOffset(2026, 2, 20, 17, 30, 0, TimeSpan.Zero);
var timeProvider = new FakeTimeProvider(now);
var sink = new RecordingAuthEventSink();
var users = new InMemoryUserRepository();
var clients = new InMemoryClientStore();
await using var app = await CreateApplicationAsync(timeProvider, sink, users, clients);
var principalAccessor = app.Services.GetRequiredService<AdminTestPrincipalAccessor>();
principalAccessor.Principal = CreatePrincipal(
tenant: "tenant-alpha",
scopes: new[] { StellaOpsScopes.UiAdmin, StellaOpsScopes.AuthorityClientsRead, StellaOpsScopes.AuthorityClientsWrite },
expiresAt: now.AddMinutes(10));
using var client = CreateTestClient(app);
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(AdminAuthenticationDefaults.AuthenticationScheme);
client.DefaultRequestHeaders.Add(AuthorityHttpHeaders.Tenant, "tenant-alpha");
var createResponse = await client.PostAsJsonAsync(
"/console/admin/clients",
new
{
clientId = "svc-missing-tenants",
grantTypes = new[] { "client_credentials" },
scopes = new[] { "platform:read" },
tenants = Array.Empty<string>(),
requireClientSecret = false
});
Assert.Equal(HttpStatusCode.BadRequest, createResponse.StatusCode);
var payload = await createResponse.Content.ReadFromJsonAsync<ErrorPayload>();
Assert.NotNull(payload);
Assert.Equal("tenant_assignment_required", payload!.Error);
}
[Fact]
public async Task UpdateClient_RejectsInvalidTenantIdentifier()
{
var now = new DateTimeOffset(2026, 2, 20, 18, 0, 0, TimeSpan.Zero);
var timeProvider = new FakeTimeProvider(now);
var sink = new RecordingAuthEventSink();
var users = new InMemoryUserRepository();
var clients = new InMemoryClientStore();
await clients.UpsertAsync(
new AuthorityClientDocument
{
ClientId = "svc-invalid-update",
DisplayName = "Original",
Enabled = true,
AllowedGrantTypes = new List<string> { "client_credentials" },
AllowedScopes = new List<string> { "platform:read" },
Properties = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase)
{
["tenant"] = "tenant-alpha",
["tenants"] = "tenant-alpha tenant-bravo",
["allowed_grant_types"] = "client_credentials",
["allowed_scopes"] = "platform:read"
},
CreatedAt = now.AddHours(-1),
UpdatedAt = now.AddHours(-1)
},
CancellationToken.None);
await using var app = await CreateApplicationAsync(timeProvider, sink, users, clients);
var principalAccessor = app.Services.GetRequiredService<AdminTestPrincipalAccessor>();
principalAccessor.Principal = CreatePrincipal(
tenant: "tenant-alpha",
scopes: new[] { StellaOpsScopes.UiAdmin, StellaOpsScopes.AuthorityClientsRead, StellaOpsScopes.AuthorityClientsWrite },
expiresAt: now.AddMinutes(10));
using var client = CreateTestClient(app);
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(AdminAuthenticationDefaults.AuthenticationScheme);
client.DefaultRequestHeaders.Add(AuthorityHttpHeaders.Tenant, "tenant-alpha");
var updateResponse = await client.PatchAsJsonAsync(
"/console/admin/clients/svc-invalid-update",
new
{
tenants = new[] { "tenant-alpha", "Tenant Invalid" },
tenant = "tenant-alpha"
});
Assert.Equal(HttpStatusCode.BadRequest, updateResponse.StatusCode);
var payload = await updateResponse.Content.ReadFromJsonAsync<ErrorPayload>();
Assert.NotNull(payload);
Assert.Equal("invalid_tenant_assignment", payload!.Error);
}
private static async Task<WebApplication> CreateApplicationAsync(
FakeTimeProvider timeProvider,
RecordingAuthEventSink sink,
IUserRepository userRepository)
IUserRepository userRepository,
IAuthorityClientStore? clientStore = null)
{
var builder = WebApplication.CreateBuilder(new WebApplicationOptions
{
@@ -173,6 +422,7 @@ public sealed class ConsoleAdminEndpointsTests
builder.Services.AddSingleton<TimeProvider>(timeProvider);
builder.Services.AddSingleton<IAuthEventSink>(sink);
builder.Services.AddSingleton(userRepository);
builder.Services.AddSingleton<IAuthorityClientStore>(clientStore ?? new InMemoryClientStore());
builder.Services.AddSingleton<AdminTestPrincipalAccessor>();
builder.Services.AddHttpContextAccessor();
builder.Services.AddSingleton<StellaOpsBypassEvaluator>();
@@ -232,10 +482,28 @@ public sealed class ConsoleAdminEndpointsTests
return server.CreateClient();
}
private static string? GetPropertyValue(AuthEventRecord record, string propertyName)
{
return record.Properties
.FirstOrDefault(property => string.Equals(property.Name, propertyName, StringComparison.Ordinal))
?.Value.Value;
}
private sealed class RecordingAuthEventSink : IAuthEventSink
{
private readonly List<AuthEventRecord> events = new();
public IReadOnlyList<AuthEventRecord> Events => events;
public ValueTask WriteAsync(AuthEventRecord record, CancellationToken cancellationToken)
=> ValueTask.CompletedTask;
{
lock (events)
{
events.Add(record);
}
return ValueTask.CompletedTask;
}
}
private sealed class AdminTestPrincipalAccessor
@@ -424,6 +692,17 @@ public sealed class ConsoleAdminEndpointsTests
}
private sealed record UserListPayload(IReadOnlyList<UserSummary> Users, int Count);
private sealed record ClientListPayload(IReadOnlyList<ClientSummary> Clients, int Count, string SelectedTenant);
private sealed record ClientSummary(
string ClientId,
string DisplayName,
bool Enabled,
string? DefaultTenant,
IReadOnlyList<string> Tenants,
IReadOnlyList<string> AllowedGrantTypes,
IReadOnlyList<string> AllowedScopes,
DateTimeOffset UpdatedAt);
private sealed record ErrorPayload(string Error, string? Message);
private sealed record UserSummary(
string Id,
@@ -434,4 +713,53 @@ public sealed class ConsoleAdminEndpointsTests
string Status,
DateTimeOffset CreatedAt,
DateTimeOffset? LastLoginAt);
private sealed class InMemoryClientStore : IAuthorityClientStore
{
private readonly object sync = new();
private readonly Dictionary<string, AuthorityClientDocument> documents = new(StringComparer.OrdinalIgnoreCase);
public ValueTask<AuthorityClientDocument?> FindByClientIdAsync(string clientId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
lock (sync)
{
documents.TryGetValue(clientId, out var document);
return ValueTask.FromResult<AuthorityClientDocument?>(document);
}
}
public ValueTask<IReadOnlyList<AuthorityClientDocument>> ListAsync(int limit = 500, int offset = 0, CancellationToken cancellationToken = default, IClientSessionHandle? session = null)
{
lock (sync)
{
var take = limit <= 0 ? 500 : limit;
var skip = offset < 0 ? 0 : offset;
var results = documents.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)
{
lock (sync)
{
documents[document.ClientId] = document;
return ValueTask.CompletedTask;
}
}
public ValueTask<bool> DeleteByClientIdAsync(string clientId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
lock (sync)
{
var removed = documents.Remove(clientId);
return ValueTask.FromResult(removed);
}
}
}
}

View File

@@ -53,6 +53,7 @@ public sealed class ConsoleEndpointsTests
var tenants = json.RootElement.GetProperty("tenants");
Assert.Equal(1, tenants.GetArrayLength());
Assert.Equal("tenant-default", tenants[0].GetProperty("id").GetString());
Assert.Equal("tenant-default", json.RootElement.GetProperty("selectedTenant").GetString());
var events = sink.Events;
var authorizeEvent = Assert.Single(events, evt => evt.EventType == "authority.resource.authorize");
@@ -60,12 +61,12 @@ public sealed class ConsoleEndpointsTests
var consoleEvent = Assert.Single(events, evt => evt.EventType == "authority.console.tenants.read");
Assert.Equal(AuthEventOutcome.Success, consoleEvent.Outcome);
Assert.Contains("tenant.resolved", consoleEvent.Properties.Select(property => property.Name));
Assert.Contains("tenant.selected", consoleEvent.Properties.Select(property => property.Name));
Assert.Equal(2, events.Count);
}
[Fact]
public async Task Tenants_ReturnsBadRequest_WhenHeaderMissing()
public async Task Tenants_UsesClaimTenant_WhenHeaderMissing()
{
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-10-31T12:00:00Z"));
var sink = new RecordingAuthEventSink();
@@ -81,11 +82,50 @@ public sealed class ConsoleEndpointsTests
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(TestAuthenticationDefaults.AuthenticationScheme);
var response = await client.GetAsync("/console/tenants");
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
var authEvent = Assert.Single(sink.Events);
Assert.Equal("authority.resource.authorize", authEvent.EventType);
Assert.Equal(AuthEventOutcome.Success, authEvent.Outcome);
Assert.DoesNotContain(sink.Events, evt => evt.EventType.StartsWith("authority.console.", System.StringComparison.Ordinal));
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
using var json = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
Assert.Equal("tenant-default", json.RootElement.GetProperty("selectedTenant").GetString());
Assert.Equal(1, json.RootElement.GetProperty("tenants").GetArrayLength());
var events = sink.Events;
Assert.Contains(events, evt => evt.EventType == "authority.resource.authorize" && evt.Outcome == AuthEventOutcome.Success);
Assert.Contains(events, evt => evt.EventType == "authority.console.tenants.read" && evt.Outcome == AuthEventOutcome.Success);
Assert.Equal(2, events.Count);
}
[Fact]
public async Task Tenants_ReturnsAllowedTenantAssignments_WithSelectedMarker()
{
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-10-31T12:00:00Z"));
var sink = new RecordingAuthEventSink();
await using var app = await CreateApplicationAsync(
timeProvider,
sink,
new AuthorityTenantView("tenant-alpha", "Tenant Alpha", "active", "shared", Array.Empty<string>(), Array.Empty<string>()),
new AuthorityTenantView("tenant-bravo", "Tenant Bravo", "active", "shared", Array.Empty<string>(), Array.Empty<string>()),
new AuthorityTenantView("tenant-charlie", "Tenant Charlie", "active", "shared", Array.Empty<string>(), Array.Empty<string>()));
var accessor = app.Services.GetRequiredService<TestPrincipalAccessor>();
accessor.Principal = CreatePrincipal(
tenant: "tenant-alpha",
scopes: new[] { StellaOpsScopes.UiRead, StellaOpsScopes.AuthorityTenantsRead },
expiresAt: timeProvider.GetUtcNow().AddMinutes(5),
allowedTenants: new[] { "tenant-alpha", "tenant-bravo" });
var client = app.CreateTestClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(TestAuthenticationDefaults.AuthenticationScheme);
client.DefaultRequestHeaders.Add(AuthorityHttpHeaders.Tenant, "tenant-alpha");
var response = await client.GetAsync("/console/tenants");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
using var json = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
var tenants = json.RootElement.GetProperty("tenants");
Assert.Equal(2, tenants.GetArrayLength());
Assert.Equal("tenant-alpha", tenants[0].GetProperty("id").GetString());
Assert.Equal("tenant-bravo", tenants[1].GetProperty("id").GetString());
Assert.Equal("tenant-alpha", json.RootElement.GetProperty("selectedTenant").GetString());
}
[Fact]
@@ -530,7 +570,8 @@ public sealed class ConsoleEndpointsTests
string? subject = null,
string? username = null,
string? displayName = null,
string? tokenId = null)
string? tokenId = null,
IReadOnlyCollection<string>? allowedTenants = null)
{
var claims = new List<Claim>
{
@@ -570,6 +611,11 @@ public sealed class ConsoleEndpointsTests
claims.Add(new Claim(StellaOpsClaimTypes.TokenId, tokenId));
}
if (allowedTenants is { Count: > 0 })
{
claims.Add(new Claim(StellaOpsClaimTypes.AllowedTenants, string.Join(' ', allowedTenants)));
}
var identity = new ClaimsIdentity(claims, TestAuthenticationDefaults.AuthenticationScheme);
return new ClaimsPrincipal(identity);
}

View File

@@ -149,6 +149,118 @@ public class ClientCredentialsHandlersTests
Assert.Equal(clientDocument.Plugin, context.Transaction.Properties[AuthorityOpenIddictConstants.ClientProviderTransactionProperty]);
}
[Fact]
public async Task ValidateClientCredentials_SelectsRequestedTenant_WhenAssigned()
{
var clientDocument = CreateClient(
secret: "s3cr3t!",
allowedGrantTypes: "client_credentials",
allowedScopes: "jobs:read",
tenant: null);
clientDocument.Properties[AuthorityClientMetadataKeys.Tenants] = "tenant-alpha tenant-bravo";
var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument));
var options = TestHelpers.CreateAuthorityOptions();
var handler = new ValidateClientCredentialsHandler(
new TestClientStore(clientDocument),
registry,
TestInstruments.ActivitySource,
new TestAuthEventSink(),
new TestRateLimiterMetadataAccessor(),
new TestServiceAccountStore(),
new TestTokenStore(),
TimeProvider.System,
new NoopCertificateValidator(),
new HttpContextAccessor(),
options,
NullLogger<ValidateClientCredentialsHandler>.Instance);
var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "jobs:read");
SetParameter(transaction, AuthorityOpenIddictConstants.TenantParameterName, "Tenant-Bravo");
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
await handler.HandleAsync(context);
Assert.False(context.IsRejected, $"Rejected: {context.Error} - {context.ErrorDescription}");
var selectedTenant = Assert.IsType<string>(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientTenantProperty]);
Assert.Equal("tenant-bravo", selectedTenant);
var allowedTenants = Assert.IsType<string[]>(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientAllowedTenantsProperty]);
Assert.Equal(new[] { "tenant-alpha", "tenant-bravo" }, allowedTenants);
}
[Fact]
public async Task ValidateClientCredentials_Rejects_WhenRequestedTenantNotAssigned()
{
var clientDocument = CreateClient(
secret: "s3cr3t!",
allowedGrantTypes: "client_credentials",
allowedScopes: "jobs:read",
tenant: null);
clientDocument.Properties[AuthorityClientMetadataKeys.Tenants] = "tenant-alpha tenant-bravo";
var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument));
var options = TestHelpers.CreateAuthorityOptions();
var handler = new ValidateClientCredentialsHandler(
new TestClientStore(clientDocument),
registry,
TestInstruments.ActivitySource,
new TestAuthEventSink(),
new TestRateLimiterMetadataAccessor(),
new TestServiceAccountStore(),
new TestTokenStore(),
TimeProvider.System,
new NoopCertificateValidator(),
new HttpContextAccessor(),
options,
NullLogger<ValidateClientCredentialsHandler>.Instance);
var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "jobs:read");
SetParameter(transaction, AuthorityOpenIddictConstants.TenantParameterName, "tenant-charlie");
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
await handler.HandleAsync(context);
Assert.True(context.IsRejected);
Assert.Equal(OpenIddictConstants.Errors.InvalidRequest, context.Error);
Assert.Equal("Requested tenant is not assigned to this client.", context.ErrorDescription);
}
[Fact]
public async Task ValidateClientCredentials_Rejects_WhenTenantSelectionIsAmbiguous()
{
var clientDocument = CreateClient(
secret: "s3cr3t!",
allowedGrantTypes: "client_credentials",
allowedScopes: "jobs:read",
tenant: null);
clientDocument.Properties[AuthorityClientMetadataKeys.Tenants] = "tenant-alpha tenant-bravo";
var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument));
var options = TestHelpers.CreateAuthorityOptions();
var handler = new ValidateClientCredentialsHandler(
new TestClientStore(clientDocument),
registry,
TestInstruments.ActivitySource,
new TestAuthEventSink(),
new TestRateLimiterMetadataAccessor(),
new TestServiceAccountStore(),
new TestTokenStore(),
TimeProvider.System,
new NoopCertificateValidator(),
new HttpContextAccessor(),
options,
NullLogger<ValidateClientCredentialsHandler>.Instance);
var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "jobs:read");
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
await handler.HandleAsync(context);
Assert.True(context.IsRejected);
Assert.Equal(OpenIddictConstants.Errors.InvalidRequest, context.Error);
Assert.Equal("Tenant selection is required for this client.", context.ErrorDescription);
}
[Fact]
public async Task ValidateClientCredentials_Allows_NewIngestionScopes()
{
@@ -3221,6 +3333,60 @@ public class ClientCredentialsHandlersTests
Assert.Equal(new[] { "jobs:trigger" }, persisted.Scope);
}
[Fact]
public async Task HandleClientCredentials_EmitsAllowedTenantsClaim()
{
var clientDocument = CreateClient(
secret: "s3cr3t!",
allowedGrantTypes: "client_credentials",
allowedScopes: "jobs:read",
tenant: null);
clientDocument.Properties[AuthorityClientMetadataKeys.Tenants] = "tenant-alpha tenant-bravo";
var descriptor = CreateDescriptor(clientDocument);
var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: descriptor);
var tokenStore = new TestTokenStore();
var sessionAccessor = new NullSessionAccessor();
var metadataAccessor = new TestRateLimiterMetadataAccessor();
var validateHandler = new ValidateClientCredentialsHandler(
new TestClientStore(clientDocument),
registry,
TestInstruments.ActivitySource,
new TestAuthEventSink(),
metadataAccessor,
new TestServiceAccountStore(),
tokenStore,
TimeProvider.System,
new NoopCertificateValidator(),
new HttpContextAccessor(),
TestHelpers.CreateAuthorityOptions(),
NullLogger<ValidateClientCredentialsHandler>.Instance);
var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "jobs:read");
SetParameter(transaction, AuthorityOpenIddictConstants.TenantParameterName, "tenant-alpha");
var validateContext = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
await validateHandler.HandleAsync(validateContext);
Assert.False(validateContext.IsRejected, $"Rejected: {validateContext.Error} - {validateContext.ErrorDescription}");
var handler = new HandleClientCredentialsHandler(
registry,
tokenStore,
sessionAccessor,
metadataAccessor,
TimeProvider.System,
TestInstruments.ActivitySource,
NullLogger<HandleClientCredentialsHandler>.Instance);
var context = new OpenIddictServerEvents.HandleTokenRequestContext(transaction);
await handler.HandleAsync(context);
var principal = context.Principal ?? throw new InvalidOperationException("Principal missing");
Assert.Equal("tenant-alpha", principal.FindFirstValue(StellaOpsClaimTypes.Tenant));
Assert.Equal("tenant-alpha tenant-bravo", principal.FindFirstValue(StellaOpsClaimTypes.AllowedTenants));
}
[Fact]
public async Task HandleClientCredentials_PersistsServiceAccountMetadata()
{
@@ -3736,7 +3902,61 @@ public class TokenValidationHandlersTests
Assert.True(context.IsRejected);
Assert.Equal(OpenIddictConstants.Errors.InvalidToken, context.Error);
Assert.Equal("The token tenant does not match the registered client tenant.", context.ErrorDescription);
Assert.Equal("The token tenant does not match the registered client tenant assignments.", context.ErrorDescription);
}
[Fact]
public async Task ValidateAccessTokenHandler_Rejects_WhenTenantOutsideMultiTenantAssignments()
{
var clientDocument = CreateClient(tenant: null);
clientDocument.Properties[AuthorityClientMetadataKeys.Tenants] = "tenant-alpha tenant-bravo";
var tokenStore = new TestTokenStore
{
Inserted = new AuthorityTokenDocument
{
TokenId = "token-tenant",
Status = "valid",
ClientId = clientDocument.ClientId
}
};
var metadataAccessor = new TestRateLimiterMetadataAccessor();
var auditSink = new TestAuthEventSink();
var sessionAccessor = new NullSessionAccessor();
var handler = new ValidateAccessTokenHandler(
tokenStore,
sessionAccessor,
new TestClientStore(clientDocument),
CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)),
metadataAccessor,
auditSink,
TimeProvider.System,
TestInstruments.ActivitySource,
TestInstruments.Meter,
NullLogger<ValidateAccessTokenHandler>.Instance);
var transaction = new OpenIddictServerTransaction
{
Options = new OpenIddictServerOptions(),
EndpointType = OpenIddictServerEndpointType.Token,
Request = new OpenIddictRequest()
};
var principal = CreatePrincipal(clientDocument.ClientId, "token-tenant", ResolveProvider(clientDocument));
principal.Identities.First().AddClaim(new Claim(StellaOpsClaimTypes.Tenant, "tenant-charlie"));
var context = new OpenIddictServerEvents.ValidateTokenContext(transaction)
{
Principal = principal,
TokenId = "token-tenant"
};
await handler.HandleAsync(context);
Assert.True(context.IsRejected);
Assert.Equal(OpenIddictConstants.Errors.InvalidToken, context.Error);
Assert.Equal("The token tenant does not match the registered client tenant assignments.", context.ErrorDescription);
}
[Fact]
@@ -4109,6 +4329,19 @@ internal sealed class TestClientStore : IAuthorityClientStore
return ValueTask.FromResult(document);
}
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 page = clients.Values
.OrderBy(client => client.ClientId, StringComparer.Ordinal)
.Skip(skip)
.Take(take)
.ToList();
return ValueTask.FromResult<IReadOnlyList<AuthorityClientDocument>>(page);
}
public ValueTask UpsertAsync(AuthorityClientDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
clients[document.ClientId] = document;

View File

@@ -62,6 +62,110 @@ public class PasswordGrantHandlersTests
Assert.Equal("tenant-alpha", metadata?.Tenant);
}
[Fact]
public async Task ValidatePasswordGrant_SelectsRequestedTenant_WhenAssigned()
{
var sink = new TestAuthEventSink();
var metadataAccessor = new TestRateLimiterMetadataAccessor();
var registry = CreateRegistry(new SuccessCredentialStore());
var clientDocument = CreateClientDocument("jobs:trigger");
clientDocument.Properties.Remove(AuthorityClientMetadataKeys.Tenant);
clientDocument.Properties[AuthorityClientMetadataKeys.Tenants] = "tenant-alpha tenant-bravo";
var clientStore = new StubClientStore(clientDocument);
var validate = new ValidatePasswordGrantHandler(
registry,
TestActivitySource,
sink,
metadataAccessor,
clientStore,
TimeProvider.System,
NullLogger<ValidatePasswordGrantHandler>.Instance,
auditContextAccessor: auditContextAccessor);
var transaction = CreatePasswordTransaction("alice", "Password1!", "jobs:trigger");
SetParameter(transaction, AuthorityOpenIddictConstants.TenantParameterName, "tenant-bravo");
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
await validate.HandleAsync(context);
Assert.False(context.IsRejected, $"Rejected: {context.Error} - {context.ErrorDescription}");
var selectedTenant = Assert.IsType<string>(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientTenantProperty]);
Assert.Equal("tenant-bravo", selectedTenant);
var allowedTenants = Assert.IsType<string[]>(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientAllowedTenantsProperty]);
Assert.Equal(new[] { "tenant-alpha", "tenant-bravo" }, allowedTenants);
}
[Fact]
public async Task ValidatePasswordGrant_Rejects_WhenTenantSelectionIsAmbiguous()
{
var sink = new TestAuthEventSink();
var metadataAccessor = new TestRateLimiterMetadataAccessor();
var registry = CreateRegistry(new SuccessCredentialStore());
var clientDocument = CreateClientDocument("jobs:trigger");
clientDocument.Properties.Remove(AuthorityClientMetadataKeys.Tenant);
clientDocument.Properties[AuthorityClientMetadataKeys.Tenants] = "tenant-alpha tenant-bravo";
var clientStore = new StubClientStore(clientDocument);
var validate = new ValidatePasswordGrantHandler(
registry,
TestActivitySource,
sink,
metadataAccessor,
clientStore,
TimeProvider.System,
NullLogger<ValidatePasswordGrantHandler>.Instance,
auditContextAccessor: auditContextAccessor);
var transaction = CreatePasswordTransaction("alice", "Password1!", "jobs:trigger");
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
await validate.HandleAsync(context);
Assert.True(context.IsRejected);
Assert.Equal(OpenIddictConstants.Errors.InvalidRequest, context.Error);
Assert.Equal("Tenant selection is required for this client.", context.ErrorDescription);
}
[Fact]
public async Task HandlePasswordGrant_EmitsAllowedTenantsClaim()
{
var sink = new TestAuthEventSink();
var metadataAccessor = new TestRateLimiterMetadataAccessor();
var registry = CreateRegistry(new SuccessCredentialStore());
var clientDocument = CreateClientDocument("jobs:trigger");
clientDocument.Properties.Remove(AuthorityClientMetadataKeys.Tenant);
clientDocument.Properties[AuthorityClientMetadataKeys.Tenants] = "tenant-alpha tenant-bravo";
var clientStore = new StubClientStore(clientDocument);
var validate = new ValidatePasswordGrantHandler(
registry,
TestActivitySource,
sink,
metadataAccessor,
clientStore,
TimeProvider.System,
NullLogger<ValidatePasswordGrantHandler>.Instance,
auditContextAccessor: auditContextAccessor);
var handle = new HandlePasswordGrantHandler(
registry,
clientStore,
TestActivitySource,
sink,
metadataAccessor,
TimeProvider.System,
NullLogger<HandlePasswordGrantHandler>.Instance,
auditContextAccessor);
var transaction = CreatePasswordTransaction("alice", "Password1!", "jobs:trigger");
SetParameter(transaction, AuthorityOpenIddictConstants.TenantParameterName, "tenant-alpha");
await validate.HandleAsync(new OpenIddictServerEvents.ValidateTokenRequestContext(transaction));
var handleContext = new OpenIddictServerEvents.HandleTokenRequestContext(transaction);
await handle.HandleAsync(handleContext);
var principal = handleContext.Principal ?? throw new InvalidOperationException("Principal missing.");
Assert.Equal("tenant-alpha", principal.FindFirstValue(StellaOpsClaimTypes.Tenant));
Assert.Equal("tenant-alpha tenant-bravo", principal.FindFirstValue(StellaOpsClaimTypes.AllowedTenants));
}
[Fact]
public async Task ValidatePasswordGrant_Rejects_WhenSealedEvidenceMissing()
{
@@ -948,6 +1052,16 @@ public class PasswordGrantHandlersTests
return ValueTask.FromResult(result);
}
public ValueTask<IReadOnlyList<AuthorityClientDocument>> ListAsync(int limit = 500, int offset = 0, CancellationToken cancellationToken = default, IClientSessionHandle? session = null)
{
if (document is null)
{
return ValueTask.FromResult<IReadOnlyList<AuthorityClientDocument>>(Array.Empty<AuthorityClientDocument>());
}
return ValueTask.FromResult<IReadOnlyList<AuthorityClientDocument>>(new[] { document });
}
public ValueTask UpsertAsync(AuthorityClientDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
this.document = document ?? throw new ArgumentNullException(nameof(document));

View File

@@ -205,6 +205,16 @@ public sealed class PostgresAdapterTests
public Task<ClientEntity?> FindByClientIdAsync(string clientId, CancellationToken cancellationToken = default)
=> Task.FromResult<ClientEntity?>(LastUpsert);
public Task<IReadOnlyList<ClientEntity>> ListAsync(int limit = 500, int offset = 0, CancellationToken cancellationToken = default)
{
if (LastUpsert is null)
{
return Task.FromResult<IReadOnlyList<ClientEntity>>(Array.Empty<ClientEntity>());
}
return Task.FromResult<IReadOnlyList<ClientEntity>>(new[] { LastUpsert });
}
public Task UpsertAsync(ClientEntity entity, CancellationToken cancellationToken = default)
{
LastUpsert = entity;

View File

@@ -38,7 +38,7 @@ internal static class AirgapAuditEndpointExtensions
ArgumentNullException.ThrowIfNull(app);
var group = app.MapGroup("/authority/audit/airgap")
.RequireAuthorization()
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.AirgapStatusRead))
.WithTags("AuthorityAirgapAudit");
group.AddEndpointFilter(new TenantHeaderFilter());

View File

@@ -24,8 +24,14 @@ internal static class AuthorizeEndpointExtensions
{
public static void MapAuthorizeEndpoint(this WebApplication app)
{
app.MapGet("/authorize", HandleAuthorize);
app.MapPost("/authorize", HandleAuthorize);
app.MapGet("/authorize", HandleAuthorize)
.WithName("AuthorizeGet")
.WithDescription("OpenID Connect authorization endpoint (GET). Renders the interactive login form for the authorization code flow. Accepts OIDC parameters (client_id, redirect_uri, scope, state, nonce, code_challenge, etc.). Handles prompt=none for silent refresh with redirect-based error response.")
.AllowAnonymous();
app.MapPost("/authorize", HandleAuthorize)
.WithName("AuthorizePost")
.WithDescription("OpenID Connect authorization endpoint (POST). Validates credentials submitted via the login form and issues an authorization code via OpenIddict SignIn on success. Redirects the browser back to the client redirect_uri with the authorization code.")
.AllowAnonymous();
}
private static async Task<IResult> HandleAuthorize(

View File

@@ -5,9 +5,12 @@ using Microsoft.AspNetCore.Mvc;
using OpenIddict.Abstractions;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Authority.OpenIddict.Handlers;
using StellaOps.Authority.Persistence.Documents;
using StellaOps.Authority.Persistence.InMemory.Stores;
using StellaOps.Authority.Persistence.Postgres.Models;
using StellaOps.Authority.Persistence.Postgres.Repositories;
using StellaOps.Authority.Plugins.Abstractions;
using StellaOps.Authority.Tenants;
using StellaOps.Cryptography.Audit;
using System;
@@ -17,11 +20,16 @@ using System.Globalization;
using System.Linq;
using System.Security.Claims;
using System.Text.Json;
using System.Text.RegularExpressions;
namespace StellaOps.Authority.Console.Admin;
internal static class ConsoleAdminEndpointExtensions
{
private static readonly Regex TenantIdPattern = new(
"^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$",
RegexOptions.Compiled | RegexOptions.CultureInvariant);
public static void MapConsoleAdminEndpoints(this WebApplication app)
{
ArgumentNullException.ThrowIfNull(app);
@@ -561,10 +569,19 @@ internal static class ConsoleAdminEndpointExtensions
private static async Task<IResult> ListClients(
HttpContext httpContext,
IAuthorityClientStore clientStore,
IAuthEventSink auditSink,
TimeProvider timeProvider,
CancellationToken cancellationToken)
{
var selectedTenant = ResolveTenantId(httpContext);
var clients = await clientStore.ListAsync(limit: 500, offset: 0, cancellationToken).ConfigureAwait(false);
var summaries = clients
.Select(ToAdminClientSummary)
.Where(client => IsClientVisibleForTenant(client, selectedTenant))
.OrderBy(client => client.ClientId, StringComparer.Ordinal)
.ToList();
await WriteAdminAuditAsync(
httpContext,
auditSink,
@@ -572,19 +589,85 @@ internal static class ConsoleAdminEndpointExtensions
"authority.admin.clients.list",
AuthEventOutcome.Success,
null,
Array.Empty<AuthEventProperty>(),
BuildProperties(
("tenant.selected", selectedTenant),
("clients.count", summaries.Count.ToString(CultureInfo.InvariantCulture))),
cancellationToken).ConfigureAwait(false);
return Results.Ok(new { clients = Array.Empty<object>(), message = "Client list: implementation pending" });
return Results.Ok(new { clients = summaries, count = summaries.Count, selectedTenant });
}
private static async Task<IResult> CreateClient(
HttpContext httpContext,
CreateClientRequest request,
IAuthorityClientStore clientStore,
IAuthEventSink auditSink,
TimeProvider timeProvider,
CancellationToken cancellationToken)
{
if (request is null || string.IsNullOrWhiteSpace(request.ClientId))
{
return Results.BadRequest(new { error = "invalid_request", message = "clientId is required." });
}
if (!TryResolveCreateTenantAssignments(
request.Tenant,
request.Tenants,
out var tenantAssignments,
out var defaultTenant,
out var tenantError))
{
return Results.BadRequest(tenantError);
}
var clientId = request.ClientId.Trim();
var existing = await clientStore.FindByClientIdAsync(clientId, cancellationToken).ConfigureAwait(false);
if (existing is not null)
{
return Results.Conflict(new { error = "client_already_exists", clientId });
}
var grantTypes = NormalizeValues(request.GrantTypes);
var scopes = NormalizeValues(request.Scopes);
var requireClientSecret = request.RequireClientSecret ?? true;
var clientSecret = string.IsNullOrWhiteSpace(request.ClientSecret) ? null : request.ClientSecret.Trim();
if (requireClientSecret && string.IsNullOrWhiteSpace(clientSecret))
{
return Results.BadRequest(new { error = "client_secret_required", message = "clientSecret is required when requireClientSecret is true." });
}
var now = timeProvider.GetUtcNow();
var properties = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase)
{
[AuthorityClientMetadataKeys.AllowedGrantTypes] = JoinValues(grantTypes),
[AuthorityClientMetadataKeys.AllowedScopes] = JoinValues(scopes),
[AuthorityClientMetadataKeys.Project] = StellaOpsTenancyDefaults.AnyProject
};
ApplyTenantAssignments(properties, tenantAssignments, defaultTenant);
var document = new AuthorityClientDocument
{
ClientId = clientId,
DisplayName = NormalizeOptional(request.DisplayName) ?? clientId,
Description = NormalizeOptional(request.Description),
Enabled = request.Enabled ?? true,
Disabled = !(request.Enabled ?? true),
AllowedGrantTypes = grantTypes.ToList(),
AllowedScopes = scopes.ToList(),
RequireClientSecret = requireClientSecret,
ClientType = requireClientSecret ? "confidential" : "public",
SecretHash = !string.IsNullOrWhiteSpace(clientSecret)
? AuthoritySecretHasher.ComputeHash(clientSecret)
: null,
Properties = properties,
CreatedAt = now,
UpdatedAt = now
};
await clientStore.UpsertAsync(document, cancellationToken).ConfigureAwait(false);
var summary = ToAdminClientSummary(document);
await WriteAdminAuditAsync(
httpContext,
auditSink,
@@ -592,20 +675,128 @@ internal static class ConsoleAdminEndpointExtensions
"authority.admin.clients.create",
AuthEventOutcome.Success,
null,
BuildProperties(("client.id", request?.ClientId ?? "unknown")),
BuildProperties(
("client.id", summary.ClientId),
("client.tenant.before", null),
("client.tenants.before", null),
("client.tenant.after", summary.DefaultTenant),
("client.tenants.after", string.Join(" ", summary.Tenants))),
cancellationToken).ConfigureAwait(false);
return Results.Created("/console/admin/clients/new", new { message = "Client creation: implementation pending" });
var requestPath = httpContext.Request.Path.Value ?? string.Empty;
var locationPrefix = requestPath.StartsWith("/api/admin", StringComparison.OrdinalIgnoreCase)
? "/api/admin/clients"
: "/console/admin/clients";
return Results.Created($"{locationPrefix}/{summary.ClientId}", summary);
}
private static async Task<IResult> UpdateClient(
HttpContext httpContext,
string clientId,
UpdateClientRequest request,
IAuthorityClientStore clientStore,
IAuthEventSink auditSink,
TimeProvider timeProvider,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(clientId))
{
return Results.BadRequest(new { error = "invalid_request", message = "clientId is required." });
}
if (request is null)
{
return Results.BadRequest(new { error = "invalid_request", message = "Request body is required." });
}
var document = await clientStore.FindByClientIdAsync(clientId.Trim(), cancellationToken).ConfigureAwait(false);
if (document is null)
{
return Results.NotFound(new { error = "client_not_found", clientId = clientId.Trim() });
}
var beforeSummary = ToAdminClientSummary(document);
if (!TryResolveUpdatedTenantAssignments(
request,
beforeSummary.Tenants,
beforeSummary.DefaultTenant,
out var tenantAssignments,
out var defaultTenant,
out var tenantError))
{
return Results.BadRequest(tenantError);
}
if (request.DisplayName is not null)
{
document.DisplayName = NormalizeOptional(request.DisplayName);
}
if (request.Description is not null)
{
document.Description = NormalizeOptional(request.Description);
}
if (request.Enabled.HasValue)
{
document.Enabled = request.Enabled.Value;
document.Disabled = !request.Enabled.Value;
}
if (request.GrantTypes is not null)
{
document.AllowedGrantTypes = NormalizeValues(request.GrantTypes).ToList();
}
if (request.Scopes is not null)
{
document.AllowedScopes = NormalizeValues(request.Scopes).ToList();
}
if (request.RequireClientSecret.HasValue)
{
document.RequireClientSecret = request.RequireClientSecret.Value;
document.ClientType = request.RequireClientSecret.Value ? "confidential" : "public";
if (!request.RequireClientSecret.Value)
{
document.SecretHash = null;
}
}
if (request.ClientSecret is not null)
{
if (string.IsNullOrWhiteSpace(request.ClientSecret))
{
if (document.RequireClientSecret)
{
return Results.BadRequest(new { error = "client_secret_required", message = "clientSecret cannot be empty when requireClientSecret is true." });
}
document.SecretHash = null;
}
else
{
document.SecretHash = AuthoritySecretHasher.ComputeHash(request.ClientSecret.Trim());
}
}
document.Properties ??= new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
document.Properties[AuthorityClientMetadataKeys.AllowedGrantTypes] = JoinValues(document.AllowedGrantTypes);
document.Properties[AuthorityClientMetadataKeys.AllowedScopes] = JoinValues(document.AllowedScopes);
if (!document.Properties.ContainsKey(AuthorityClientMetadataKeys.Project))
{
document.Properties[AuthorityClientMetadataKeys.Project] = StellaOpsTenancyDefaults.AnyProject;
}
ApplyTenantAssignments(document.Properties, tenantAssignments, defaultTenant);
document.UpdatedAt = timeProvider.GetUtcNow();
await clientStore.UpsertAsync(document, cancellationToken).ConfigureAwait(false);
var afterSummary = ToAdminClientSummary(document);
await WriteAdminAuditAsync(
httpContext,
auditSink,
@@ -613,10 +804,15 @@ internal static class ConsoleAdminEndpointExtensions
"authority.admin.clients.update",
AuthEventOutcome.Success,
null,
BuildProperties(("client.id", clientId)),
BuildProperties(
("client.id", afterSummary.ClientId),
("client.tenant.before", beforeSummary.DefaultTenant),
("client.tenants.before", string.Join(" ", beforeSummary.Tenants)),
("client.tenant.after", afterSummary.DefaultTenant),
("client.tenants.after", string.Join(" ", afterSummary.Tenants))),
cancellationToken).ConfigureAwait(false);
return Results.Ok(new { message = "Client update: implementation pending" });
return Results.Ok(afterSummary);
}
private static async Task<IResult> RotateClient(
@@ -811,6 +1007,293 @@ internal static class ConsoleAdminEndpointExtensions
.ToList();
}
private static IReadOnlyList<string> NormalizeValues(IReadOnlyList<string>? values)
{
if (values is null || values.Count == 0)
{
return Array.Empty<string>();
}
return values
.Where(static value => !string.IsNullOrWhiteSpace(value))
.Select(static value => value.Trim())
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(static value => value, StringComparer.Ordinal)
.ToList();
}
private static bool TryResolveCreateTenantAssignments(
string? tenant,
IReadOnlyList<string>? tenants,
out IReadOnlyList<string> assignments,
out string? defaultTenant,
out object tenantError)
{
assignments = Array.Empty<string>();
defaultTenant = null;
tenantError = new { error = "tenant_assignment_required", message = "At least one tenant assignment is required." };
if (!TryNormalizeTenantAssignments(tenants, out var normalizedTenants, out tenantError))
{
return false;
}
if (normalizedTenants.Count == 0 && string.IsNullOrWhiteSpace(tenant))
{
tenantError = new { error = "tenant_assignment_required", message = "At least one tenant assignment is required." };
return false;
}
if (!TryNormalizeTenant(tenant, out var normalizedTenant, out tenantError))
{
return false;
}
if (!string.IsNullOrWhiteSpace(normalizedTenant))
{
if (normalizedTenants.Count == 0)
{
assignments = new[] { normalizedTenant };
}
else if (!normalizedTenants.Contains(normalizedTenant, StringComparer.Ordinal))
{
tenantError = new { error = "default_tenant_not_assigned", message = "tenant must be included in tenants assignments." };
return false;
}
else
{
assignments = normalizedTenants;
}
defaultTenant = normalizedTenant;
return true;
}
assignments = normalizedTenants;
defaultTenant = assignments.Count == 1 ? assignments[0] : null;
return true;
}
private static bool TryResolveUpdatedTenantAssignments(
UpdateClientRequest request,
IReadOnlyList<string> currentTenants,
string? currentDefaultTenant,
out IReadOnlyList<string> assignments,
out string? defaultTenant,
out object tenantError)
{
assignments = currentTenants;
defaultTenant = currentDefaultTenant;
tenantError = new { error = "invalid_tenant_assignment", message = "Invalid tenant assignment." };
if (request.Tenants is null && request.Tenant is null)
{
return true;
}
if (!TryNormalizeTenant(request.Tenant, out var normalizedRequestedTenant, out tenantError))
{
return false;
}
if (request.Tenants is not null)
{
if (!TryNormalizeTenantAssignments(request.Tenants, out var normalizedTenants, out tenantError))
{
return false;
}
if (normalizedTenants.Count == 0)
{
tenantError = new { error = "tenant_assignment_required", message = "At least one tenant assignment is required." };
return false;
}
assignments = normalizedTenants;
if (!string.IsNullOrWhiteSpace(normalizedRequestedTenant))
{
if (!assignments.Contains(normalizedRequestedTenant, StringComparer.Ordinal))
{
tenantError = new { error = "default_tenant_not_assigned", message = "tenant must be included in tenants assignments." };
return false;
}
defaultTenant = normalizedRequestedTenant;
return true;
}
if (!string.IsNullOrWhiteSpace(currentDefaultTenant) &&
assignments.Contains(currentDefaultTenant, StringComparer.Ordinal))
{
defaultTenant = currentDefaultTenant;
}
else
{
defaultTenant = assignments.Count == 1 ? assignments[0] : null;
}
return true;
}
if (string.IsNullOrWhiteSpace(normalizedRequestedTenant))
{
tenantError = new { error = "invalid_tenant_assignment", message = "tenant must be a valid tenant identifier." };
return false;
}
if (currentTenants.Count == 0)
{
assignments = new[] { normalizedRequestedTenant };
defaultTenant = normalizedRequestedTenant;
return true;
}
if (!currentTenants.Contains(normalizedRequestedTenant, StringComparer.Ordinal))
{
tenantError = new { error = "default_tenant_not_assigned", message = "tenant must be included in existing tenants assignments." };
return false;
}
assignments = currentTenants;
defaultTenant = normalizedRequestedTenant;
return true;
}
private static bool TryNormalizeTenantAssignments(
IReadOnlyList<string>? rawTenants,
out IReadOnlyList<string> tenants,
out object tenantError)
{
tenantError = new { error = "invalid_tenant_assignment", message = "Invalid tenant assignment." };
tenants = Array.Empty<string>();
if (rawTenants is null)
{
return true;
}
var normalized = new HashSet<string>(StringComparer.Ordinal);
foreach (var rawTenant in rawTenants)
{
if (!TryNormalizeTenant(rawTenant, out var tenantId, out tenantError))
{
return false;
}
if (string.IsNullOrWhiteSpace(tenantId))
{
tenantError = new { error = "invalid_tenant_assignment", message = "Tenant identifiers cannot be empty." };
return false;
}
if (!normalized.Add(tenantId))
{
tenantError = new { error = "duplicate_tenant_assignment", message = $"Duplicate tenant assignment '{tenantId}' is not allowed." };
return false;
}
}
tenants = normalized
.OrderBy(static tenant => tenant, StringComparer.Ordinal)
.ToArray();
return true;
}
private static bool TryNormalizeTenant(string? rawTenant, out string? normalizedTenant, out object tenantError)
{
tenantError = new { error = "invalid_tenant_assignment", message = "Invalid tenant assignment." };
normalizedTenant = ClientCredentialHandlerHelpers.NormalizeTenant(rawTenant);
if (string.IsNullOrWhiteSpace(normalizedTenant))
{
return true;
}
if (!TenantIdPattern.IsMatch(normalizedTenant))
{
tenantError = new { error = "invalid_tenant_assignment", message = $"Tenant '{normalizedTenant}' is not a valid tenant identifier." };
normalizedTenant = null;
return false;
}
return true;
}
private static void ApplyTenantAssignments(
IDictionary<string, string?> properties,
IReadOnlyList<string> tenantAssignments,
string? defaultTenant)
{
if (tenantAssignments.Count > 0)
{
properties[AuthorityClientMetadataKeys.Tenants] = string.Join(" ", tenantAssignments);
}
else
{
properties.Remove(AuthorityClientMetadataKeys.Tenants);
}
if (!string.IsNullOrWhiteSpace(defaultTenant))
{
properties[AuthorityClientMetadataKeys.Tenant] = defaultTenant;
return;
}
if (tenantAssignments.Count == 1)
{
properties[AuthorityClientMetadataKeys.Tenant] = tenantAssignments[0];
return;
}
properties.Remove(AuthorityClientMetadataKeys.Tenant);
}
private static AdminClientSummary ToAdminClientSummary(AuthorityClientDocument document)
{
var properties = document.Properties ?? new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
var allowedGrantTypes = document.AllowedGrantTypes.Count > 0
? NormalizeValues(document.AllowedGrantTypes)
: NormalizeValues(ClientCredentialHandlerHelpers.Split(properties, AuthorityClientMetadataKeys.AllowedGrantTypes).ToArray());
var allowedScopes = document.AllowedScopes.Count > 0
? NormalizeValues(document.AllowedScopes)
: NormalizeValues(ClientCredentialHandlerHelpers.Split(properties, AuthorityClientMetadataKeys.AllowedScopes).ToArray());
var tenants = ClientCredentialHandlerHelpers.ResolveAllowedTenants(properties);
var defaultTenant = ClientCredentialHandlerHelpers.ResolveDefaultTenant(properties);
return new AdminClientSummary(
ClientId: document.ClientId,
DisplayName: string.IsNullOrWhiteSpace(document.DisplayName) ? document.ClientId : document.DisplayName!,
Enabled: document.Enabled && !document.Disabled,
DefaultTenant: defaultTenant,
Tenants: tenants,
AllowedGrantTypes: allowedGrantTypes,
AllowedScopes: allowedScopes,
UpdatedAt: document.UpdatedAt == default ? document.CreatedAt : document.UpdatedAt);
}
private static bool IsClientVisibleForTenant(AdminClientSummary client, string selectedTenant)
{
if (string.IsNullOrWhiteSpace(selectedTenant))
{
return true;
}
if (client.Tenants.Contains(selectedTenant, StringComparer.Ordinal))
{
return true;
}
return string.Equals(client.DefaultTenant, selectedTenant, StringComparison.Ordinal);
}
private static string JoinValues(IReadOnlyList<string> values)
=> values.Count == 0
? string.Empty
: string.Join(" ", values.OrderBy(static value => value, StringComparer.Ordinal));
private static string? NormalizeOptional(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
private static AdminUserSummary ToAdminUserSummary(UserEntity user, DateTimeOffset now)
{
var metadata = ParseMetadata(user.Metadata);
@@ -933,10 +1416,38 @@ internal sealed record CreateUserRequest(string Username, string Email, string?
internal sealed record UpdateUserRequest(string? DisplayName, List<string>? Roles);
internal sealed record CreateRoleRequest(string RoleId, string DisplayName, List<string> Scopes);
internal sealed record UpdateRoleRequest(string? DisplayName, List<string>? Scopes);
internal sealed record CreateClientRequest(string ClientId, string DisplayName, List<string> GrantTypes, List<string> Scopes);
internal sealed record UpdateClientRequest(string? DisplayName, List<string>? Scopes);
internal sealed record CreateClientRequest(
string ClientId,
string? DisplayName,
List<string>? GrantTypes,
List<string>? Scopes,
string? Tenant,
List<string>? Tenants,
string? Description,
bool? Enabled,
bool? RequireClientSecret,
string? ClientSecret);
internal sealed record UpdateClientRequest(
string? DisplayName,
List<string>? GrantTypes,
List<string>? Scopes,
string? Tenant,
List<string>? Tenants,
string? Description,
bool? Enabled,
bool? RequireClientSecret,
string? ClientSecret);
internal sealed record RevokeTokensRequest(List<string> TokenIds, string? Reason);
internal sealed record RoleBundle(string RoleId, string DisplayName, IReadOnlyList<string> Scopes);
internal sealed record AdminClientSummary(
string ClientId,
string DisplayName,
bool Enabled,
string? DefaultTenant,
IReadOnlyList<string> Tenants,
IReadOnlyList<string> AllowedGrantTypes,
IReadOnlyList<string> AllowedScopes,
DateTimeOffset UpdatedAt);
internal sealed record AdminUserSummary(
string Id,
string Username,

View File

@@ -24,7 +24,7 @@ internal static class ConsoleEndpointExtensions
ArgumentNullException.ThrowIfNull(app);
var group = app.MapGroup("/console")
.RequireAuthorization()
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.UiRead))
.WithTags("Console");
group.AddEndpointFilter(new TenantHeaderFilter());
@@ -101,27 +101,38 @@ internal static class ConsoleEndpointExtensions
ArgumentNullException.ThrowIfNull(auditSink);
ArgumentNullException.ThrowIfNull(timeProvider);
var normalizedTenant = TenantHeaderFilter.GetTenant(httpContext);
if (string.IsNullOrWhiteSpace(normalizedTenant))
var selectedTenant = TenantHeaderFilter.GetTenant(httpContext);
if (string.IsNullOrWhiteSpace(selectedTenant))
{
await WriteAuditAsync(
httpContext,
auditSink,
timeProvider,
"authority.console.tenants.read",
AuthEventOutcome.Failure,
"tenant_header_missing",
BuildProperties(("tenant.header", null)),
cancellationToken).ConfigureAwait(false);
return Results.BadRequest(new { error = "tenant_header_missing", message = $"Header '{AuthorityHttpHeaders.Tenant}' is required." });
selectedTenant = Normalize(httpContext.User.FindFirstValue(StellaOpsClaimTypes.Tenant))?.ToLowerInvariant();
}
var tenants = tenantCatalog.GetTenants();
var selected = tenants.FirstOrDefault(tenant =>
string.Equals(tenant.Id, normalizedTenant, StringComparison.Ordinal));
var allowedTenants = ResolveAllowedTenants(httpContext.User);
if (allowedTenants.Count == 0 && !string.IsNullOrWhiteSpace(selectedTenant))
{
allowedTenants = new List<string> { selectedTenant };
}
if (selected is null)
var catalogTenants = tenantCatalog.GetTenants();
IReadOnlyList<AuthorityTenantView> visibleTenants;
if (allowedTenants.Count == 0)
{
visibleTenants = catalogTenants
.OrderBy(static tenant => tenant.Id, StringComparer.Ordinal)
.ToArray();
}
else
{
var allowedSet = new HashSet<string>(allowedTenants, StringComparer.Ordinal);
visibleTenants = catalogTenants
.Where(tenant => allowedSet.Contains(Normalize(tenant.Id)?.ToLowerInvariant() ?? string.Empty))
.OrderBy(static tenant => tenant.Id, StringComparer.Ordinal)
.ToArray();
}
if (!string.IsNullOrWhiteSpace(selectedTenant) &&
!visibleTenants.Any(tenant => string.Equals(Normalize(tenant.Id)?.ToLowerInvariant(), selectedTenant, StringComparison.Ordinal)))
{
await WriteAuditAsync(
httpContext,
@@ -129,11 +140,11 @@ internal static class ConsoleEndpointExtensions
timeProvider,
"authority.console.tenants.read",
AuthEventOutcome.Failure,
"tenant_not_configured",
BuildProperties(("tenant.requested", normalizedTenant)),
"tenant_not_assigned",
BuildProperties(("tenant.selected", selectedTenant)),
cancellationToken).ConfigureAwait(false);
return Results.NotFound(new { error = "tenant_not_configured", message = $"Tenant '{normalizedTenant}' is not configured." });
return Results.Forbid();
}
await WriteAuditAsync(
@@ -143,10 +154,12 @@ internal static class ConsoleEndpointExtensions
"authority.console.tenants.read",
AuthEventOutcome.Success,
null,
BuildProperties(("tenant.resolved", selected.Id)),
BuildProperties(
("tenant.selected", selectedTenant),
("tenant.count", visibleTenants.Count.ToString(CultureInfo.InvariantCulture))),
cancellationToken).ConfigureAwait(false);
var response = new TenantCatalogResponse(new[] { selected });
var response = new TenantCatalogResponse(visibleTenants, selectedTenant);
return Results.Ok(response);
}
@@ -902,6 +915,22 @@ internal static class ConsoleEndpointExtensions
return input.Trim();
}
private static List<string> ResolveAllowedTenants(ClaimsPrincipal principal)
{
ArgumentNullException.ThrowIfNull(principal);
var tenants = principal.FindAll(StellaOpsClaimTypes.AllowedTenants)
.SelectMany(claim => claim.Value.Split([' ', ',', ';', '\t', '\r', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
.Select(Normalize)
.Where(static value => !string.IsNullOrWhiteSpace(value))
.Select(static value => value!.ToLowerInvariant())
.Distinct(StringComparer.Ordinal)
.OrderBy(static value => value, StringComparer.Ordinal)
.ToList();
return tenants;
}
private static string? FormatInstant(DateTimeOffset? instant)
{
return instant?.ToString("O", CultureInfo.InvariantCulture);
@@ -910,7 +939,7 @@ internal static class ConsoleEndpointExtensions
private const string XForwardedForHeader = "X-Forwarded-For";
}
internal sealed record TenantCatalogResponse(IReadOnlyList<AuthorityTenantView> Tenants);
internal sealed record TenantCatalogResponse(IReadOnlyList<AuthorityTenantView> Tenants, string? SelectedTenant);
internal sealed record ConsoleProfileResponse(
string? SubjectId,

View File

@@ -2,6 +2,7 @@
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Primitives;
using StellaOps.Auth.Abstractions;
using System.Linq;
using System.Security.Claims;
namespace StellaOps.Authority.Console;
@@ -9,6 +10,7 @@ namespace StellaOps.Authority.Console;
internal sealed class TenantHeaderFilter : IEndpointFilter
{
private const string TenantItemKey = "__authority-console-tenant";
private static readonly char[] TenantSeparators = [' ', ',', ';', '\t', '\r', '\n'];
public ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)
{
@@ -25,18 +27,24 @@ internal sealed class TenantHeaderFilter : IEndpointFilter
var tenantHeader = httpContext.Request.Headers[AuthorityHttpHeaders.Tenant];
var claimTenant = principal.FindFirstValue(StellaOpsClaimTypes.Tenant);
var allowedTenants = GetAllowedTenantSet(principal);
// Determine effective tenant:
// 1. If both header and claim present: they must match
// 2. If header present but no claim: use header value (bootstrapped users have no tenant claim)
// 3. If no header but claim present: use claim value
// 4. If neither present: default to "default"
string effectiveTenant;
// 1. If both header and claim present: they must match.
// 2. If header present but no claim: it must be in allowed tenant assignments when provided.
// 3. If no header but claim present: use claim value.
// 4. If neither present: leave unresolved (null).
string? effectiveTenant = null;
if (!IsMissing(tenantHeader))
{
var normalizedHeader = tenantHeader.ToString().Trim().ToLowerInvariant();
if (allowedTenants.Count > 0 && !allowedTenants.Contains(normalizedHeader))
{
return ValueTask.FromResult<object?>(Results.Forbid());
}
if (!string.IsNullOrWhiteSpace(claimTenant))
{
var normalizedClaim = claimTenant.Trim().ToLowerInvariant();
@@ -52,12 +60,16 @@ internal sealed class TenantHeaderFilter : IEndpointFilter
{
effectiveTenant = claimTenant.Trim().ToLowerInvariant();
}
if (!string.IsNullOrWhiteSpace(effectiveTenant))
{
httpContext.Items[TenantItemKey] = effectiveTenant;
}
else
{
effectiveTenant = "default";
httpContext.Items.Remove(TenantItemKey);
}
httpContext.Items[TenantItemKey] = effectiveTenant;
return next(context);
}
@@ -83,4 +95,14 @@ internal sealed class TenantHeaderFilter : IEndpointFilter
var value = values.ToString();
return string.IsNullOrWhiteSpace(value);
}
private static HashSet<string> GetAllowedTenantSet(ClaimsPrincipal principal)
{
var values = principal.FindAll(StellaOpsClaimTypes.AllowedTenants)
.SelectMany(claim => claim.Value.Split(TenantSeparators, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
.Where(value => !string.IsNullOrWhiteSpace(value))
.Select(value => value.Trim().ToLowerInvariant());
return new HashSet<string>(values, StringComparer.Ordinal);
}
}

View File

@@ -30,6 +30,7 @@ internal static class AuthorityOpenIddictConstants
internal const string MtlsCertificateHexProperty = "authority:mtls_thumbprint_hex";
internal const string MtlsCertificateHexClaimType = "authority_sender_certificate_hex";
internal const string ClientTenantProperty = "authority:client_tenant";
internal const string ClientAllowedTenantsProperty = "authority:client_allowed_tenants";
internal const string ClientProjectProperty = "authority:client_project";
internal const string ClientAttributesProperty = "authority:client_attributes";
internal const string OperatorReasonProperty = "authority:operator_reason";
@@ -51,6 +52,7 @@ internal static class AuthorityOpenIddictConstants
internal const string BackfillReasonParameterName = "backfill_reason";
internal const string BackfillTicketParameterName = "backfill_ticket";
internal const string ServiceAccountParameterName = "service_account";
internal const string TenantParameterName = "tenant";
internal const string DelegationActorParameterName = "delegation_actor";
internal const string ServiceAccountProperty = "authority:service_account";
internal const string TokenKindProperty = "authority:token_kind";

View File

@@ -360,6 +360,39 @@ internal sealed class ValidateClientCredentialsHandler : IOpenIddictServerHandle
return;
}
var requestedTenant = NormalizeMetadata(context.Request.GetParameter(AuthorityOpenIddictConstants.TenantParameterName)?.Value?.ToString());
var tenantSelection = ClientCredentialHandlerHelpers.ResolveTenantSelection(document.Properties, requestedTenant);
if (!tenantSelection.Succeeded)
{
context.Reject(OpenIddictConstants.Errors.InvalidRequest, tenantSelection.ErrorDescription);
logger.LogWarning(
"Client credentials validation failed for {ClientId}: tenant selection rejected. RequestedTenant={RequestedTenant}. Reason={Reason}",
document.ClientId,
requestedTenant ?? "(none)",
tenantSelection.ErrorDescription ?? "tenant_selection_invalid");
return;
}
if (tenantSelection.AllowedTenants.Count > 0)
{
context.Transaction.Properties[AuthorityOpenIddictConstants.ClientAllowedTenantsProperty] = tenantSelection.AllowedTenants.ToArray();
}
else
{
context.Transaction.Properties.Remove(AuthorityOpenIddictConstants.ClientAllowedTenantsProperty);
}
if (!string.IsNullOrWhiteSpace(tenantSelection.SelectedTenant))
{
context.Transaction.Properties[AuthorityOpenIddictConstants.ClientTenantProperty] = tenantSelection.SelectedTenant;
metadataAccessor.SetTenant(tenantSelection.SelectedTenant);
activity?.SetTag("authority.tenant", tenantSelection.SelectedTenant);
}
else
{
context.Transaction.Properties.Remove(AuthorityOpenIddictConstants.ClientTenantProperty);
}
var allowedScopes = ClientCredentialHandlerHelpers.Split(document.Properties, AuthorityClientMetadataKeys.AllowedScopes);
var resolvedScopes = ClientCredentialHandlerHelpers.ResolveGrantedScopes(
allowedScopes,
@@ -540,18 +573,6 @@ internal sealed class ValidateClientCredentialsHandler : IOpenIddictServerHandle
return true;
}
if (document.Properties.TryGetValue(AuthorityClientMetadataKeys.Tenant, out var tenantProperty))
{
var normalizedTenant = ClientCredentialHandlerHelpers.NormalizeTenant(tenantProperty);
if (normalizedTenant is not null)
{
context.Transaction.Properties[AuthorityOpenIddictConstants.ClientTenantProperty] = normalizedTenant;
metadataAccessor.SetTenant(normalizedTenant);
activity?.SetTag("authority.tenant", normalizedTenant);
return true;
}
}
context.Transaction.Properties.Remove(AuthorityOpenIddictConstants.ClientTenantProperty);
return false;
}
@@ -1658,6 +1679,16 @@ internal sealed class HandleClientCredentialsHandler : IOpenIddictServerHandler<
_ => new[] { OpenIddictConstants.Destinations.AccessToken }
});
var allowedTenants = context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.ClientAllowedTenantsProperty, out var allowedTenantsObject)
? allowedTenantsObject switch
{
IReadOnlyList<string> assignedTenants => ClientCredentialHandlerHelpers.NormalizeTenants(assignedTenants),
IEnumerable<string> assignedTenantSequence => ClientCredentialHandlerHelpers.NormalizeTenants(assignedTenantSequence),
string assignedTenantValue => ClientCredentialHandlerHelpers.NormalizeTenants([assignedTenantValue]),
_ => Array.Empty<string>()
}
: ClientCredentialHandlerHelpers.ResolveAllowedTenants(document.Properties);
string? tenant = null;
if (context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.ClientTenantProperty, out var tenantValue) &&
tenantValue is string storedTenant &&
@@ -1665,9 +1696,9 @@ internal sealed class HandleClientCredentialsHandler : IOpenIddictServerHandler<
{
tenant = storedTenant;
}
else if (document.Properties.TryGetValue(AuthorityClientMetadataKeys.Tenant, out var tenantProperty))
else
{
tenant = ClientCredentialHandlerHelpers.NormalizeTenant(tenantProperty);
tenant = ClientCredentialHandlerHelpers.ResolveDefaultTenant(document.Properties);
}
if (!string.IsNullOrWhiteSpace(tenant))
@@ -1678,6 +1709,14 @@ internal sealed class HandleClientCredentialsHandler : IOpenIddictServerHandler<
activity?.SetTag("authority.tenant", tenant);
}
if (allowedTenants.Count > 0)
{
var allowedTenantsClaim = string.Join(" ", allowedTenants);
identity.SetClaim(StellaOpsClaimTypes.AllowedTenants, allowedTenantsClaim);
context.Transaction.Properties[AuthorityOpenIddictConstants.ClientAllowedTenantsProperty] = allowedTenants.ToArray();
activity?.SetTag("authority.allowed_tenants", allowedTenantsClaim);
}
string? project = null;
if (context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.ClientProjectProperty, out var projectValue) &&
projectValue is string storedProject &&
@@ -1810,10 +1849,32 @@ internal sealed class HandleClientCredentialsHandler : IOpenIddictServerHandler<
if (!string.IsNullOrWhiteSpace(descriptor.Tenant))
{
identity.SetClaim(StellaOpsClaimTypes.Tenant, descriptor.Tenant);
context.Transaction.Properties[AuthorityOpenIddictConstants.ClientTenantProperty] = descriptor.Tenant;
metadataAccessor.SetTenant(descriptor.Tenant);
activity?.SetTag("authority.tenant", descriptor.Tenant);
var descriptorTenant = ClientCredentialHandlerHelpers.NormalizeTenant(descriptor.Tenant);
var selectedTenant = context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.ClientTenantProperty, out var selectedTenantObj) &&
selectedTenantObj is string selectedTenantValue &&
!string.IsNullOrWhiteSpace(selectedTenantValue)
? selectedTenantValue
: null;
if (!string.IsNullOrWhiteSpace(descriptorTenant))
{
if (!string.IsNullOrWhiteSpace(selectedTenant) &&
!string.Equals(selectedTenant, descriptorTenant, StringComparison.Ordinal))
{
context.Reject(OpenIddictConstants.Errors.InvalidClient, "Identity provider tenant does not match selected tenant.");
logger.LogWarning(
"Client credentials handling failed for {ClientId}: identity provider tenant {ProviderTenant} does not match selected tenant {SelectedTenant}.",
document.ClientId,
descriptorTenant,
selectedTenant);
return;
}
identity.SetClaim(StellaOpsClaimTypes.Tenant, descriptorTenant);
context.Transaction.Properties[AuthorityOpenIddictConstants.ClientTenantProperty] = descriptorTenant;
metadataAccessor.SetTenant(descriptorTenant);
activity?.SetTag("authority.tenant", descriptorTenant);
}
}
if (!string.IsNullOrWhiteSpace(descriptor.Project))
@@ -2062,6 +2123,14 @@ internal sealed class HandleClientCredentialsHandler : IOpenIddictServerHandler<
internal static class ClientCredentialHandlerHelpers
{
private static readonly char[] TenantValueDelimiters = [' ', ',', ';', '\t', '\r', '\n'];
public sealed record TenantSelectionResult(
bool Succeeded,
string? SelectedTenant,
IReadOnlyList<string> AllowedTenants,
string? ErrorDescription);
public static IReadOnlyList<string> Split(IReadOnlyDictionary<string, string?> properties, string key)
{
if (!properties.TryGetValue(key, out var value) || string.IsNullOrWhiteSpace(value))
@@ -2078,6 +2147,119 @@ internal static class ClientCredentialHandlerHelpers
public static string? NormalizeProject(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim().ToLowerInvariant();
public static IReadOnlyList<string> NormalizeTenants(IEnumerable<string> values)
{
ArgumentNullException.ThrowIfNull(values);
var set = new SortedSet<string>(StringComparer.Ordinal);
foreach (var value in values)
{
if (string.IsNullOrWhiteSpace(value))
{
continue;
}
var normalized = NormalizeTenant(value);
if (!string.IsNullOrWhiteSpace(normalized))
{
set.Add(normalized);
}
}
return set.Count == 0 ? Array.Empty<string>() : set.ToArray();
}
public static IReadOnlyList<string> ResolveAllowedTenants(IReadOnlyDictionary<string, string?> properties)
{
ArgumentNullException.ThrowIfNull(properties);
var values = new List<string>();
if (properties.TryGetValue(AuthorityClientMetadataKeys.Tenants, out var tenantsRaw) &&
!string.IsNullOrWhiteSpace(tenantsRaw))
{
values.AddRange(tenantsRaw.Split(TenantValueDelimiters, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries));
}
if (properties.TryGetValue(AuthorityClientMetadataKeys.Tenant, out var tenantRaw) &&
!string.IsNullOrWhiteSpace(tenantRaw))
{
values.Add(tenantRaw);
}
return NormalizeTenants(values);
}
public static string? ResolveDefaultTenant(IReadOnlyDictionary<string, string?> properties)
{
ArgumentNullException.ThrowIfNull(properties);
if (properties.TryGetValue(AuthorityClientMetadataKeys.Tenant, out var tenantRaw))
{
var normalized = NormalizeTenant(tenantRaw);
if (!string.IsNullOrWhiteSpace(normalized))
{
return normalized;
}
}
var allowedTenants = ResolveAllowedTenants(properties);
return allowedTenants.Count == 1 ? allowedTenants[0] : null;
}
public static TenantSelectionResult ResolveTenantSelection(
IReadOnlyDictionary<string, string?> properties,
string? requestedTenant)
{
ArgumentNullException.ThrowIfNull(properties);
var allowedTenants = ResolveAllowedTenants(properties);
var requested = NormalizeTenant(requestedTenant);
var allowedSet = new HashSet<string>(allowedTenants, StringComparer.Ordinal);
if (!string.IsNullOrWhiteSpace(requested))
{
if (allowedSet.Count == 0 || !allowedSet.Contains(requested))
{
return new TenantSelectionResult(
Succeeded: false,
SelectedTenant: null,
AllowedTenants: allowedTenants,
ErrorDescription: "Requested tenant is not assigned to this client.");
}
return new TenantSelectionResult(
Succeeded: true,
SelectedTenant: requested,
AllowedTenants: allowedTenants,
ErrorDescription: null);
}
var defaultTenant = ResolveDefaultTenant(properties);
if (!string.IsNullOrWhiteSpace(defaultTenant))
{
return new TenantSelectionResult(
Succeeded: true,
SelectedTenant: defaultTenant,
AllowedTenants: allowedTenants,
ErrorDescription: null);
}
if (allowedSet.Count > 1)
{
return new TenantSelectionResult(
Succeeded: false,
SelectedTenant: null,
AllowedTenants: allowedTenants,
ErrorDescription: "Tenant selection is required for this client.");
}
return new TenantSelectionResult(
Succeeded: true,
SelectedTenant: null,
AllowedTenants: allowedTenants,
ErrorDescription: null);
}
public static (string[] Scopes, string? InvalidScope) ResolveGrantedScopes(
IReadOnlyCollection<string> allowedScopes,
IReadOnlyList<string> requestedScopes)

View File

@@ -186,13 +186,58 @@ internal sealed class ValidatePasswordGrantHandler : IOpenIddictServerHandler<Op
context.Transaction.Properties[AuthorityOpenIddictConstants.ClientTransactionProperty] = clientDocument;
context.Transaction.Properties[AuthorityOpenIddictConstants.AuditClientIdProperty] = clientId;
var tenant = PasswordGrantAuditHelper.NormalizeTenant(clientDocument.Properties.TryGetValue(AuthorityClientMetadataKeys.Tenant, out var tenantValue) ? tenantValue : null);
var requestedTenant = ClientCredentialHandlerHelpers.NormalizeTenant(
context.Request.GetParameter(AuthorityOpenIddictConstants.TenantParameterName)?.Value?.ToString());
var tenantSelection = ClientCredentialHandlerHelpers.ResolveTenantSelection(clientDocument.Properties, requestedTenant);
if (!tenantSelection.Succeeded)
{
var selectionFailureRecord = PasswordGrantAuditHelper.CreatePasswordGrantRecord(
timeProvider,
context.Transaction,
metadata,
AuthEventOutcome.Failure,
tenantSelection.ErrorDescription ?? "Tenant selection failed.",
clientId,
providerName: null,
tenant: null,
user: null,
username: context.Request.Username,
scopes: requestedScopes,
retryAfter: null,
failureCode: AuthorityCredentialFailureCode.InvalidCredentials,
extraProperties: null);
await auditSink.WriteAsync(selectionFailureRecord, context.CancellationToken).ConfigureAwait(false);
context.Reject(OpenIddictConstants.Errors.InvalidRequest, tenantSelection.ErrorDescription ?? "Tenant selection failed.");
logger.LogWarning(
"Password grant validation failed for client {ClientId}: tenant selection rejected. RequestedTenant={RequestedTenant}. Reason={Reason}",
clientId,
requestedTenant ?? "(none)",
tenantSelection.ErrorDescription ?? "tenant_selection_invalid");
return;
}
if (tenantSelection.AllowedTenants.Count > 0)
{
context.Transaction.Properties[AuthorityOpenIddictConstants.ClientAllowedTenantsProperty] = tenantSelection.AllowedTenants.ToArray();
}
else
{
context.Transaction.Properties.Remove(AuthorityOpenIddictConstants.ClientAllowedTenantsProperty);
}
var tenant = tenantSelection.SelectedTenant;
if (!string.IsNullOrWhiteSpace(tenant))
{
context.Transaction.Properties[AuthorityOpenIddictConstants.ClientTenantProperty] = tenant;
metadataAccessor.SetTenant(tenant);
activity?.SetTag("authority.tenant", tenant);
}
else
{
context.Transaction.Properties.Remove(AuthorityOpenIddictConstants.ClientTenantProperty);
}
var allowedGrantTypes = ClientCredentialHandlerHelpers.Split(clientDocument.Properties, AuthorityClientMetadataKeys.AllowedGrantTypes);
if (allowedGrantTypes.Count > 0 &&
@@ -1070,8 +1115,22 @@ internal sealed class HandlePasswordGrantHandler : IOpenIddictServerHandler<Open
context.Transaction.Properties[AuthorityOpenIddictConstants.ClientGrantedScopesProperty] = grantedScopes;
}
var tenant = PasswordGrantAuditHelper.NormalizeTenant(
clientDocument.Properties.TryGetValue(AuthorityClientMetadataKeys.Tenant, out var tenantValue) ? tenantValue : null);
var allowedTenants = context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.ClientAllowedTenantsProperty, out var allowedTenantsObject)
? allowedTenantsObject switch
{
IReadOnlyList<string> assignedTenants => ClientCredentialHandlerHelpers.NormalizeTenants(assignedTenants),
IEnumerable<string> assignedTenantSequence => ClientCredentialHandlerHelpers.NormalizeTenants(assignedTenantSequence),
string assignedTenantValue => ClientCredentialHandlerHelpers.NormalizeTenants([assignedTenantValue]),
_ => Array.Empty<string>()
}
: ClientCredentialHandlerHelpers.ResolveAllowedTenants(clientDocument.Properties);
var tenant = context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.ClientTenantProperty, out var existingTenantObj) &&
existingTenantObj is string existingTenant &&
!string.IsNullOrWhiteSpace(existingTenant)
? existingTenant
: ClientCredentialHandlerHelpers.ResolveDefaultTenant(clientDocument.Properties);
if (!string.IsNullOrWhiteSpace(tenant))
{
context.Transaction.Properties[AuthorityOpenIddictConstants.ClientTenantProperty] = tenant;
@@ -1249,6 +1308,14 @@ internal sealed class HandlePasswordGrantHandler : IOpenIddictServerHandler<Open
identity.SetClaim(StellaOpsClaimTypes.Tenant, tenant);
}
if (allowedTenants.Count > 0)
{
var allowedTenantsClaim = string.Join(" ", allowedTenants);
identity.SetClaim(StellaOpsClaimTypes.AllowedTenants, allowedTenantsClaim);
context.Transaction.Properties[AuthorityOpenIddictConstants.ClientAllowedTenantsProperty] = allowedTenants.ToArray();
activity?.SetTag("authority.allowed_tenants", allowedTenantsClaim);
}
if (context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.IncidentReasonProperty, out var incidentReasonValueObj) &&
incidentReasonValueObj is string incidentReasonValue &&
!string.IsNullOrWhiteSpace(incidentReasonValue))

View File

@@ -230,32 +230,35 @@ internal sealed class ValidateAccessTokenHandler : IOpenIddictServerHandler<Open
}
}
if (clientDocument is not null &&
clientDocument.Properties.TryGetValue(AuthorityClientMetadataKeys.Tenant, out var clientTenantRaw))
if (clientDocument is not null)
{
var clientTenant = NormalizeTenant(clientTenantRaw);
if (clientTenant is not null)
var allowedClientTenants = ClientCredentialHandlerHelpers.ResolveAllowedTenants(clientDocument.Properties);
if (allowedClientTenants.Count > 0)
{
var allowedTenantSet = new HashSet<string>(allowedClientTenants, StringComparer.Ordinal);
if (principalTenant is null)
{
if (identity is not null)
var defaultClientTenant = ClientCredentialHandlerHelpers.ResolveDefaultTenant(clientDocument.Properties);
if (!string.IsNullOrWhiteSpace(defaultClientTenant) && identity is not null)
{
identity.SetClaim(StellaOpsClaimTypes.Tenant, clientTenant);
principalTenant = clientTenant;
identity.SetClaim(StellaOpsClaimTypes.Tenant, defaultClientTenant);
principalTenant = defaultClientTenant;
}
}
else if (!string.Equals(principalTenant, clientTenant, StringComparison.Ordinal))
if (principalTenant is null || !allowedTenantSet.Contains(principalTenant))
{
context.Reject(OpenIddictConstants.Errors.InvalidToken, "The token tenant does not match the registered client tenant.");
context.Reject(OpenIddictConstants.Errors.InvalidToken, "The token tenant does not match the registered client tenant assignments.");
logger.LogWarning(
"Access token validation failed: tenant mismatch for client {ClientId}. PrincipalTenant={PrincipalTenant}; ClientTenant={ClientTenant}.",
"Access token validation failed: tenant mismatch for client {ClientId}. PrincipalTenant={PrincipalTenant}; AllowedTenants={AllowedTenants}.",
clientId,
principalTenant,
clientTenant);
principalTenant ?? "(none)",
string.Join(",", allowedClientTenants));
return;
}
metadataAccessor.SetTenant(clientTenant);
metadataAccessor.SetTenant(principalTenant);
}
}

View File

@@ -33,6 +33,12 @@ internal sealed class PostgresClientStore : IAuthorityClientStore
return entity is null ? null : Map(entity);
}
public async ValueTask<IReadOnlyList<AuthorityClientDocument>> ListAsync(int limit = 500, int offset = 0, CancellationToken cancellationToken = default, IClientSessionHandle? session = null)
{
var entities = await repository.ListAsync(limit, offset, cancellationToken).ConfigureAwait(false);
return entities.Select(Map).ToList();
}
public async ValueTask UpsertAsync(AuthorityClientDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
var now = timeProvider.GetUtcNow();

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