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