save progress
This commit is contained in:
@@ -23,9 +23,7 @@ public static class AuthorityPersistenceExtensions
|
||||
IConfiguration configuration,
|
||||
string sectionName = "Postgres:Authority")
|
||||
{
|
||||
services.Configure<PostgresOptions>(sectionName, configuration.GetSection(sectionName));
|
||||
RegisterAuthorityServices(services);
|
||||
return services;
|
||||
return services.AddAuthorityPostgresStorage(configuration, sectionName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -38,54 +36,6 @@ public static class AuthorityPersistenceExtensions
|
||||
this IServiceCollection services,
|
||||
Action<PostgresOptions> configureOptions)
|
||||
{
|
||||
services.Configure(configureOptions);
|
||||
RegisterAuthorityServices(services);
|
||||
return services;
|
||||
}
|
||||
|
||||
private static void RegisterAuthorityServices(IServiceCollection services)
|
||||
{
|
||||
services.AddSingleton<AuthorityDataSource>();
|
||||
services.AddScoped<TenantRepository>();
|
||||
services.AddScoped<UserRepository>();
|
||||
services.AddScoped<RoleRepository>();
|
||||
services.AddScoped<PermissionRepository>();
|
||||
services.AddScoped<TokenRepository>();
|
||||
services.AddScoped<RefreshTokenRepository>();
|
||||
services.AddScoped<ApiKeyRepository>();
|
||||
services.AddScoped<SessionRepository>();
|
||||
services.AddScoped<AuditRepository>();
|
||||
|
||||
// Default interface bindings
|
||||
services.AddScoped<ITenantRepository>(sp => sp.GetRequiredService<TenantRepository>());
|
||||
services.AddScoped<IUserRepository>(sp => sp.GetRequiredService<UserRepository>());
|
||||
services.AddScoped<IRoleRepository>(sp => sp.GetRequiredService<RoleRepository>());
|
||||
services.AddScoped<IPermissionRepository>(sp => sp.GetRequiredService<PermissionRepository>());
|
||||
services.AddScoped<IApiKeyRepository>(sp => sp.GetRequiredService<ApiKeyRepository>());
|
||||
services.AddScoped<ISessionRepository>(sp => sp.GetRequiredService<SessionRepository>());
|
||||
services.AddScoped<IAuditRepository>(sp => sp.GetRequiredService<AuditRepository>());
|
||||
services.AddScoped<ITokenRepository>(sp => sp.GetRequiredService<TokenRepository>());
|
||||
services.AddScoped<IRefreshTokenRepository>(sp => sp.GetRequiredService<RefreshTokenRepository>());
|
||||
|
||||
// Additional stores (PostgreSQL-backed)
|
||||
services.AddScoped<BootstrapInviteRepository>();
|
||||
services.AddScoped<ServiceAccountRepository>();
|
||||
services.AddScoped<ClientRepository>();
|
||||
services.AddScoped<RevocationRepository>();
|
||||
services.AddScoped<LoginAttemptRepository>();
|
||||
services.AddScoped<OidcTokenRepository>();
|
||||
services.AddScoped<AirgapAuditRepository>();
|
||||
services.AddScoped<OfflineKitAuditRepository>();
|
||||
services.AddScoped<IOfflineKitAuditRepository>(sp => sp.GetRequiredService<OfflineKitAuditRepository>());
|
||||
services.AddScoped<IOfflineKitAuditEmitter, OfflineKitAuditEmitter>();
|
||||
services.AddScoped<RevocationExportStateRepository>();
|
||||
|
||||
services.AddScoped<IBootstrapInviteRepository>(sp => sp.GetRequiredService<BootstrapInviteRepository>());
|
||||
services.AddScoped<IServiceAccountRepository>(sp => sp.GetRequiredService<ServiceAccountRepository>());
|
||||
services.AddScoped<IClientRepository>(sp => sp.GetRequiredService<ClientRepository>());
|
||||
services.AddScoped<IRevocationRepository>(sp => sp.GetRequiredService<RevocationRepository>());
|
||||
services.AddScoped<ILoginAttemptRepository>(sp => sp.GetRequiredService<LoginAttemptRepository>());
|
||||
services.AddScoped<IOidcTokenRepository>(sp => sp.GetRequiredService<OidcTokenRepository>());
|
||||
services.AddScoped<IAirgapAuditRepository>(sp => sp.GetRequiredService<AirgapAuditRepository>());
|
||||
return services.AddAuthorityPostgresStorage(configureOptions);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ namespace StellaOps.Authority.Persistence.Documents;
|
||||
/// </summary>
|
||||
public sealed class AuthorityBootstrapInviteDocument
|
||||
{
|
||||
public string Id { get; set; } = Guid.NewGuid().ToString("N");
|
||||
public string Id { get; set; } = string.Empty;
|
||||
public string Token { get; set; } = string.Empty;
|
||||
public string Type { get; set; } = string.Empty;
|
||||
public string? Provider { get; set; }
|
||||
@@ -44,7 +44,7 @@ public sealed record BootstrapInviteReservationResult(BootstrapInviteReservation
|
||||
/// </summary>
|
||||
public sealed class AuthorityServiceAccountDocument
|
||||
{
|
||||
public string Id { get; set; } = Guid.NewGuid().ToString("N");
|
||||
public string Id { get; set; } = string.Empty;
|
||||
public string AccountId { get; set; } = string.Empty;
|
||||
public string Tenant { get; set; } = string.Empty;
|
||||
public string DisplayName { get; set; } = string.Empty;
|
||||
@@ -62,7 +62,7 @@ public sealed class AuthorityServiceAccountDocument
|
||||
/// </summary>
|
||||
public sealed class AuthorityClientDocument
|
||||
{
|
||||
public string Id { get; set; } = Guid.NewGuid().ToString("N");
|
||||
public string Id { get; set; } = string.Empty;
|
||||
public string ClientId { get; set; } = string.Empty;
|
||||
public string? ClientSecret { get; set; }
|
||||
public string? SecretHash { get; set; }
|
||||
@@ -91,7 +91,7 @@ public sealed class AuthorityClientDocument
|
||||
/// </summary>
|
||||
public sealed class AuthorityRevocationDocument
|
||||
{
|
||||
public string Id { get; set; } = Guid.NewGuid().ToString("N");
|
||||
public string Id { get; set; } = string.Empty;
|
||||
public string Category { get; set; } = string.Empty;
|
||||
public string RevocationId { get; set; } = string.Empty;
|
||||
public string SubjectId { get; set; } = string.Empty;
|
||||
@@ -113,7 +113,7 @@ public sealed class AuthorityRevocationDocument
|
||||
/// </summary>
|
||||
public sealed class AuthorityLoginAttemptDocument
|
||||
{
|
||||
public string Id { get; set; } = Guid.NewGuid().ToString("N");
|
||||
public string Id { get; set; } = string.Empty;
|
||||
public string? CorrelationId { get; set; }
|
||||
public string? SubjectId { get; set; }
|
||||
public string? Username { get; set; }
|
||||
@@ -148,7 +148,7 @@ public sealed class AuthorityLoginAttemptPropertyDocument
|
||||
/// </summary>
|
||||
public sealed class AuthorityTokenDocument
|
||||
{
|
||||
public string Id { get; set; } = Guid.NewGuid().ToString("N");
|
||||
public string Id { get; set; } = string.Empty;
|
||||
public string TokenId { get; set; } = string.Empty;
|
||||
public string? SubjectId { get; set; }
|
||||
public string? ClientId { get; set; }
|
||||
@@ -191,7 +191,7 @@ public sealed class AuthorityTokenDocument
|
||||
/// </summary>
|
||||
public sealed class AuthorityRefreshTokenDocument
|
||||
{
|
||||
public string Id { get; set; } = Guid.NewGuid().ToString("N");
|
||||
public string Id { get; set; } = string.Empty;
|
||||
public string TokenId { get; set; } = string.Empty;
|
||||
public string? SubjectId { get; set; }
|
||||
public string? ClientId { get; set; }
|
||||
@@ -207,7 +207,7 @@ public sealed class AuthorityRefreshTokenDocument
|
||||
/// </summary>
|
||||
public sealed class AuthorityAirgapAuditDocument
|
||||
{
|
||||
public string Id { get; set; } = Guid.NewGuid().ToString("N");
|
||||
public string Id { get; set; } = string.Empty;
|
||||
public string? Tenant { get; set; }
|
||||
public string? SubjectId { get; set; }
|
||||
public string? Username { get; set; }
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using StellaOps.Authority.InMemoryDriver;
|
||||
using StellaOps.Authority.Persistence.InMemory.Initialization;
|
||||
using StellaOps.Authority.Persistence.Sessions;
|
||||
@@ -46,6 +47,9 @@ public static class ServiceCollectionExtensions
|
||||
// Register null session accessor
|
||||
services.AddSingleton<IAuthoritySessionAccessor, NullAuthoritySessionAccessor>();
|
||||
|
||||
services.TryAddSingleton(TimeProvider.System);
|
||||
services.TryAddSingleton<IAuthorityInMemoryIdGenerator, GuidAuthorityInMemoryIdGenerator>();
|
||||
|
||||
// Register in-memory shims for compatibility
|
||||
var inMemoryClient = new InMemoryClient();
|
||||
var inMemoryDatabase = inMemoryClient.GetDatabase(options.DatabaseName);
|
||||
@@ -54,14 +58,22 @@ public static class ServiceCollectionExtensions
|
||||
|
||||
// Register in-memory store implementations
|
||||
// These should be replaced by Postgres-backed implementations over time
|
||||
services.AddSingleton<IAuthorityBootstrapInviteStore, InMemoryBootstrapInviteStore>();
|
||||
services.AddSingleton<IAuthorityServiceAccountStore, InMemoryServiceAccountStore>();
|
||||
services.AddSingleton<IAuthorityClientStore, InMemoryClientStore>();
|
||||
services.AddSingleton<IAuthorityRevocationStore, InMemoryRevocationStore>();
|
||||
services.AddSingleton<IAuthorityLoginAttemptStore, InMemoryLoginAttemptStore>();
|
||||
services.AddSingleton<IAuthorityTokenStore, InMemoryTokenStore>();
|
||||
services.AddSingleton<IAuthorityRefreshTokenStore, InMemoryRefreshTokenStore>();
|
||||
services.AddSingleton<IAuthorityAirgapAuditStore, InMemoryAirgapAuditStore>();
|
||||
services.AddSingleton<IAuthorityBootstrapInviteStore>(sp =>
|
||||
new InMemoryBootstrapInviteStore(sp.GetRequiredService<TimeProvider>(), sp.GetRequiredService<IAuthorityInMemoryIdGenerator>()));
|
||||
services.AddSingleton<IAuthorityServiceAccountStore>(sp =>
|
||||
new InMemoryServiceAccountStore(sp.GetRequiredService<TimeProvider>(), sp.GetRequiredService<IAuthorityInMemoryIdGenerator>()));
|
||||
services.AddSingleton<IAuthorityClientStore>(sp =>
|
||||
new InMemoryClientStore(sp.GetRequiredService<TimeProvider>(), sp.GetRequiredService<IAuthorityInMemoryIdGenerator>()));
|
||||
services.AddSingleton<IAuthorityRevocationStore>(sp =>
|
||||
new InMemoryRevocationStore(sp.GetRequiredService<IAuthorityInMemoryIdGenerator>()));
|
||||
services.AddSingleton<IAuthorityLoginAttemptStore>(sp =>
|
||||
new InMemoryLoginAttemptStore(sp.GetRequiredService<TimeProvider>(), sp.GetRequiredService<IAuthorityInMemoryIdGenerator>()));
|
||||
services.AddSingleton<IAuthorityTokenStore>(sp =>
|
||||
new InMemoryTokenStore(sp.GetRequiredService<TimeProvider>(), sp.GetRequiredService<IAuthorityInMemoryIdGenerator>()));
|
||||
services.AddSingleton<IAuthorityRefreshTokenStore>(sp =>
|
||||
new InMemoryRefreshTokenStore(sp.GetRequiredService<TimeProvider>(), sp.GetRequiredService<IAuthorityInMemoryIdGenerator>()));
|
||||
services.AddSingleton<IAuthorityAirgapAuditStore>(sp =>
|
||||
new InMemoryAirgapAuditStore(sp.GetRequiredService<TimeProvider>(), sp.GetRequiredService<IAuthorityInMemoryIdGenerator>()));
|
||||
services.AddSingleton<IAuthorityRevocationExportStateStore, InMemoryRevocationExportStateStore>();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace StellaOps.Authority.Persistence.InMemory.Stores;
|
||||
|
||||
public interface IAuthorityInMemoryIdGenerator
|
||||
{
|
||||
string NextId();
|
||||
}
|
||||
|
||||
public sealed class GuidAuthorityInMemoryIdGenerator : IAuthorityInMemoryIdGenerator
|
||||
{
|
||||
public string NextId() => Guid.NewGuid().ToString("N");
|
||||
}
|
||||
@@ -11,6 +11,19 @@ namespace StellaOps.Authority.Persistence.InMemory.Stores;
|
||||
public sealed class InMemoryBootstrapInviteStore : IAuthorityBootstrapInviteStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, AuthorityBootstrapInviteDocument> _invites = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IAuthorityInMemoryIdGenerator _idGenerator;
|
||||
|
||||
public InMemoryBootstrapInviteStore()
|
||||
: this(TimeProvider.System, new GuidAuthorityInMemoryIdGenerator())
|
||||
{
|
||||
}
|
||||
|
||||
public InMemoryBootstrapInviteStore(TimeProvider timeProvider, IAuthorityInMemoryIdGenerator idGenerator)
|
||||
{
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_idGenerator = idGenerator ?? throw new ArgumentNullException(nameof(idGenerator));
|
||||
}
|
||||
|
||||
public ValueTask<AuthorityBootstrapInviteDocument?> FindByTokenAsync(string token, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
@@ -20,7 +33,12 @@ public sealed class InMemoryBootstrapInviteStore : IAuthorityBootstrapInviteStor
|
||||
|
||||
public ValueTask<AuthorityBootstrapInviteDocument> CreateAsync(AuthorityBootstrapInviteDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
document.CreatedAt = document.CreatedAt == default ? DateTimeOffset.UtcNow : document.CreatedAt;
|
||||
if (string.IsNullOrWhiteSpace(document.Id))
|
||||
{
|
||||
document.Id = _idGenerator.NextId();
|
||||
}
|
||||
|
||||
document.CreatedAt = document.CreatedAt == default ? _timeProvider.GetUtcNow() : document.CreatedAt;
|
||||
document.IssuedAt = document.IssuedAt == default ? document.CreatedAt : document.IssuedAt;
|
||||
document.Status = AuthorityBootstrapInviteStatuses.Pending;
|
||||
_invites[document.Token] = document;
|
||||
@@ -109,6 +127,19 @@ public sealed class InMemoryBootstrapInviteStore : IAuthorityBootstrapInviteStor
|
||||
public sealed class InMemoryServiceAccountStore : IAuthorityServiceAccountStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, AuthorityServiceAccountDocument> _accounts = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IAuthorityInMemoryIdGenerator _idGenerator;
|
||||
|
||||
public InMemoryServiceAccountStore()
|
||||
: this(TimeProvider.System, new GuidAuthorityInMemoryIdGenerator())
|
||||
{
|
||||
}
|
||||
|
||||
public InMemoryServiceAccountStore(TimeProvider timeProvider, IAuthorityInMemoryIdGenerator idGenerator)
|
||||
{
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_idGenerator = idGenerator ?? throw new ArgumentNullException(nameof(idGenerator));
|
||||
}
|
||||
|
||||
public ValueTask<AuthorityServiceAccountDocument?> FindByAccountIdAsync(string accountId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
@@ -132,7 +163,17 @@ public sealed class InMemoryServiceAccountStore : IAuthorityServiceAccountStore
|
||||
|
||||
public ValueTask UpsertAsync(AuthorityServiceAccountDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
document.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
if (string.IsNullOrWhiteSpace(document.Id))
|
||||
{
|
||||
document.Id = _idGenerator.NextId();
|
||||
}
|
||||
|
||||
if (document.CreatedAt == default)
|
||||
{
|
||||
document.CreatedAt = _timeProvider.GetUtcNow();
|
||||
}
|
||||
|
||||
document.UpdatedAt = _timeProvider.GetUtcNow();
|
||||
_accounts[document.AccountId] = document;
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
@@ -149,6 +190,19 @@ public sealed class InMemoryServiceAccountStore : IAuthorityServiceAccountStore
|
||||
public sealed class InMemoryClientStore : IAuthorityClientStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, AuthorityClientDocument> _clients = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IAuthorityInMemoryIdGenerator _idGenerator;
|
||||
|
||||
public InMemoryClientStore()
|
||||
: this(TimeProvider.System, new GuidAuthorityInMemoryIdGenerator())
|
||||
{
|
||||
}
|
||||
|
||||
public InMemoryClientStore(TimeProvider timeProvider, IAuthorityInMemoryIdGenerator idGenerator)
|
||||
{
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_idGenerator = idGenerator ?? throw new ArgumentNullException(nameof(idGenerator));
|
||||
}
|
||||
|
||||
public ValueTask<AuthorityClientDocument?> FindByClientIdAsync(string clientId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
@@ -158,7 +212,17 @@ public sealed class InMemoryClientStore : IAuthorityClientStore
|
||||
|
||||
public ValueTask UpsertAsync(AuthorityClientDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
document.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
if (string.IsNullOrWhiteSpace(document.Id))
|
||||
{
|
||||
document.Id = _idGenerator.NextId();
|
||||
}
|
||||
|
||||
if (document.CreatedAt == default)
|
||||
{
|
||||
document.CreatedAt = _timeProvider.GetUtcNow();
|
||||
}
|
||||
|
||||
document.UpdatedAt = _timeProvider.GetUtcNow();
|
||||
_clients[document.ClientId] = document;
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
@@ -175,9 +239,25 @@ public sealed class InMemoryClientStore : IAuthorityClientStore
|
||||
public sealed class InMemoryRevocationStore : IAuthorityRevocationStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, AuthorityRevocationDocument> _revocations = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly IAuthorityInMemoryIdGenerator _idGenerator;
|
||||
|
||||
public InMemoryRevocationStore()
|
||||
: this(new GuidAuthorityInMemoryIdGenerator())
|
||||
{
|
||||
}
|
||||
|
||||
public InMemoryRevocationStore(IAuthorityInMemoryIdGenerator idGenerator)
|
||||
{
|
||||
_idGenerator = idGenerator ?? throw new ArgumentNullException(nameof(idGenerator));
|
||||
}
|
||||
|
||||
public ValueTask UpsertAsync(AuthorityRevocationDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(document.Id))
|
||||
{
|
||||
document.Id = _idGenerator.NextId();
|
||||
}
|
||||
|
||||
var key = $"{document.Category}:{document.RevocationId}";
|
||||
_revocations[key] = document;
|
||||
return ValueTask.CompletedTask;
|
||||
@@ -205,9 +285,32 @@ public sealed class InMemoryRevocationStore : IAuthorityRevocationStore
|
||||
public sealed class InMemoryLoginAttemptStore : IAuthorityLoginAttemptStore
|
||||
{
|
||||
private readonly ConcurrentBag<AuthorityLoginAttemptDocument> _attempts = new();
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IAuthorityInMemoryIdGenerator _idGenerator;
|
||||
|
||||
public InMemoryLoginAttemptStore()
|
||||
: this(TimeProvider.System, new GuidAuthorityInMemoryIdGenerator())
|
||||
{
|
||||
}
|
||||
|
||||
public InMemoryLoginAttemptStore(TimeProvider timeProvider, IAuthorityInMemoryIdGenerator idGenerator)
|
||||
{
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_idGenerator = idGenerator ?? throw new ArgumentNullException(nameof(idGenerator));
|
||||
}
|
||||
|
||||
public ValueTask InsertAsync(AuthorityLoginAttemptDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(document.Id))
|
||||
{
|
||||
document.Id = _idGenerator.NextId();
|
||||
}
|
||||
|
||||
if (document.OccurredAt == default)
|
||||
{
|
||||
document.OccurredAt = _timeProvider.GetUtcNow();
|
||||
}
|
||||
|
||||
_attempts.Add(document);
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
@@ -229,6 +332,19 @@ public sealed class InMemoryLoginAttemptStore : IAuthorityLoginAttemptStore
|
||||
public sealed class InMemoryTokenStore : IAuthorityTokenStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, AuthorityTokenDocument> _tokens = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IAuthorityInMemoryIdGenerator _idGenerator;
|
||||
|
||||
public InMemoryTokenStore()
|
||||
: this(TimeProvider.System, new GuidAuthorityInMemoryIdGenerator())
|
||||
{
|
||||
}
|
||||
|
||||
public InMemoryTokenStore(TimeProvider timeProvider, IAuthorityInMemoryIdGenerator idGenerator)
|
||||
{
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_idGenerator = idGenerator ?? throw new ArgumentNullException(nameof(idGenerator));
|
||||
}
|
||||
|
||||
public ValueTask<AuthorityTokenDocument?> FindByTokenIdAsync(string tokenId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
@@ -279,7 +395,7 @@ public sealed class InMemoryTokenStore : IAuthorityTokenStore
|
||||
|
||||
public ValueTask<long> CountActiveDelegationTokensAsync(string tenant, string? serviceAccountId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var count = _tokens.Values
|
||||
.Where(t => string.Equals(t.Tenant, tenant, StringComparison.Ordinal))
|
||||
.Where(t => string.IsNullOrWhiteSpace(serviceAccountId) || string.Equals(t.ServiceAccountId, serviceAccountId, StringComparison.Ordinal))
|
||||
@@ -292,7 +408,7 @@ public sealed class InMemoryTokenStore : IAuthorityTokenStore
|
||||
|
||||
public ValueTask<IReadOnlyList<AuthorityTokenDocument>> ListActiveDelegationTokensAsync(string tenant, string? serviceAccountId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var items = _tokens.Values
|
||||
.Where(t => string.Equals(t.Tenant, tenant, StringComparison.Ordinal))
|
||||
.Where(t => string.IsNullOrWhiteSpace(serviceAccountId) || string.Equals(t.ServiceAccountId, serviceAccountId, StringComparison.Ordinal))
|
||||
@@ -308,6 +424,16 @@ public sealed class InMemoryTokenStore : IAuthorityTokenStore
|
||||
|
||||
public ValueTask UpsertAsync(AuthorityTokenDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(document.Id))
|
||||
{
|
||||
document.Id = _idGenerator.NextId();
|
||||
}
|
||||
|
||||
if (document.CreatedAt == default)
|
||||
{
|
||||
document.CreatedAt = _timeProvider.GetUtcNow();
|
||||
}
|
||||
|
||||
_tokens[document.TokenId] = document;
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
@@ -329,7 +455,7 @@ public sealed class InMemoryTokenStore : IAuthorityTokenStore
|
||||
if (_tokens.TryGetValue(tokenId, out var doc))
|
||||
{
|
||||
doc.Status = "revoked";
|
||||
doc.RevokedAt = DateTimeOffset.UtcNow;
|
||||
doc.RevokedAt = _timeProvider.GetUtcNow();
|
||||
return ValueTask.FromResult(true);
|
||||
}
|
||||
|
||||
@@ -338,7 +464,7 @@ public sealed class InMemoryTokenStore : IAuthorityTokenStore
|
||||
|
||||
public ValueTask<int> RevokeBySubjectAsync(string subjectId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var revoked = 0;
|
||||
foreach (var token in _tokens.Values.Where(t => t.SubjectId == subjectId))
|
||||
{
|
||||
@@ -352,7 +478,7 @@ public sealed class InMemoryTokenStore : IAuthorityTokenStore
|
||||
|
||||
public ValueTask<int> RevokeByClientAsync(string clientId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var revoked = 0;
|
||||
foreach (var token in _tokens.Values.Where(t => t.ClientId == clientId))
|
||||
{
|
||||
@@ -393,6 +519,19 @@ public sealed class InMemoryTokenStore : IAuthorityTokenStore
|
||||
public sealed class InMemoryRefreshTokenStore : IAuthorityRefreshTokenStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, AuthorityRefreshTokenDocument> _tokens = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IAuthorityInMemoryIdGenerator _idGenerator;
|
||||
|
||||
public InMemoryRefreshTokenStore()
|
||||
: this(TimeProvider.System, new GuidAuthorityInMemoryIdGenerator())
|
||||
{
|
||||
}
|
||||
|
||||
public InMemoryRefreshTokenStore(TimeProvider timeProvider, IAuthorityInMemoryIdGenerator idGenerator)
|
||||
{
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_idGenerator = idGenerator ?? throw new ArgumentNullException(nameof(idGenerator));
|
||||
}
|
||||
|
||||
public ValueTask<AuthorityRefreshTokenDocument?> FindByTokenIdAsync(string tokenId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
@@ -408,6 +547,16 @@ public sealed class InMemoryRefreshTokenStore : IAuthorityRefreshTokenStore
|
||||
|
||||
public ValueTask UpsertAsync(AuthorityRefreshTokenDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(document.Id))
|
||||
{
|
||||
document.Id = _idGenerator.NextId();
|
||||
}
|
||||
|
||||
if (document.CreatedAt == default)
|
||||
{
|
||||
document.CreatedAt = _timeProvider.GetUtcNow();
|
||||
}
|
||||
|
||||
_tokens[document.TokenId] = document;
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
@@ -416,7 +565,7 @@ public sealed class InMemoryRefreshTokenStore : IAuthorityRefreshTokenStore
|
||||
{
|
||||
if (_tokens.TryGetValue(tokenId, out var doc))
|
||||
{
|
||||
doc.ConsumedAt = DateTimeOffset.UtcNow;
|
||||
doc.ConsumedAt = _timeProvider.GetUtcNow();
|
||||
return ValueTask.FromResult(true);
|
||||
}
|
||||
return ValueTask.FromResult(false);
|
||||
@@ -439,9 +588,32 @@ public sealed class InMemoryRefreshTokenStore : IAuthorityRefreshTokenStore
|
||||
public sealed class InMemoryAirgapAuditStore : IAuthorityAirgapAuditStore
|
||||
{
|
||||
private readonly ConcurrentBag<AuthorityAirgapAuditDocument> _entries = new();
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IAuthorityInMemoryIdGenerator _idGenerator;
|
||||
|
||||
public InMemoryAirgapAuditStore()
|
||||
: this(TimeProvider.System, new GuidAuthorityInMemoryIdGenerator())
|
||||
{
|
||||
}
|
||||
|
||||
public InMemoryAirgapAuditStore(TimeProvider timeProvider, IAuthorityInMemoryIdGenerator idGenerator)
|
||||
{
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_idGenerator = idGenerator ?? throw new ArgumentNullException(nameof(idGenerator));
|
||||
}
|
||||
|
||||
public ValueTask InsertAsync(AuthorityAirgapAuditDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(document.Id))
|
||||
{
|
||||
document.Id = _idGenerator.NextId();
|
||||
}
|
||||
|
||||
if (document.OccurredAt == default)
|
||||
{
|
||||
document.OccurredAt = _timeProvider.GetUtcNow();
|
||||
}
|
||||
|
||||
_entries.Add(document);
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
@@ -29,11 +29,21 @@ public sealed class AuthorityDataSource : DataSourceBase
|
||||
|
||||
private static PostgresOptions CreateOptions(PostgresOptions baseOptions)
|
||||
{
|
||||
// Use default schema if not specified
|
||||
if (string.IsNullOrWhiteSpace(baseOptions.SchemaName))
|
||||
var schemaName = string.IsNullOrWhiteSpace(baseOptions.SchemaName)
|
||||
? DefaultSchemaName
|
||||
: baseOptions.SchemaName;
|
||||
|
||||
return new PostgresOptions
|
||||
{
|
||||
baseOptions.SchemaName = DefaultSchemaName;
|
||||
}
|
||||
return baseOptions;
|
||||
ConnectionString = baseOptions.ConnectionString,
|
||||
CommandTimeoutSeconds = baseOptions.CommandTimeoutSeconds,
|
||||
MaxPoolSize = baseOptions.MaxPoolSize,
|
||||
MinPoolSize = baseOptions.MinPoolSize,
|
||||
ConnectionIdleLifetimeSeconds = baseOptions.ConnectionIdleLifetimeSeconds,
|
||||
Pooling = baseOptions.Pooling,
|
||||
SchemaName = schemaName,
|
||||
AutoMigrate = baseOptions.AutoMigrate,
|
||||
MigrationsPath = baseOptions.MigrationsPath,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,16 +137,16 @@ public sealed class ClientRepository : RepositoryBase<AuthorityDataSource>, ICli
|
||||
UpdatedAt = reader.GetFieldValue<DateTimeOffset>(20)
|
||||
};
|
||||
|
||||
private static IReadOnlyDictionary<string, string> DeserializeDictionary(NpgsqlDataReader reader, int ordinal)
|
||||
private static IReadOnlyDictionary<string, string?> DeserializeDictionary(NpgsqlDataReader reader, int ordinal)
|
||||
{
|
||||
if (reader.IsDBNull(ordinal))
|
||||
{
|
||||
return new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
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)
|
||||
|
||||
@@ -22,6 +22,11 @@ public interface IUserRepository
|
||||
/// </summary>
|
||||
Task<UserEntity?> GetByUsernameAsync(string tenantId, string username, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a user by subject identifier stored in metadata.
|
||||
/// </summary>
|
||||
Task<UserEntity?> GetBySubjectIdAsync(string tenantId, string subjectId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a user by email.
|
||||
/// </summary>
|
||||
|
||||
@@ -163,7 +163,7 @@ public sealed class OidcTokenRepository : RepositoryBase<AuthorityDataSource>, I
|
||||
},
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return count ?? 0;
|
||||
return count;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<OidcTokenEntity>> ListActiveDelegationTokensAsync(string tenant, string? serviceAccountId, DateTimeOffset now, int limit, CancellationToken cancellationToken = default)
|
||||
|
||||
@@ -98,6 +98,31 @@ public sealed class UserRepository : RepositoryBase<AuthorityDataSource>, IUserR
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <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
|
||||
""";
|
||||
|
||||
return await QuerySingleOrDefaultAsync(
|
||||
tenantId,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "subject_id", subjectId);
|
||||
},
|
||||
MapUser,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<UserEntity?> GetByEmailAsync(string tenantId, string email, CancellationToken cancellationToken = default)
|
||||
{
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Npgsql;
|
||||
using StellaOps.Authority.Core.Verdicts;
|
||||
|
||||
@@ -10,14 +11,16 @@ namespace StellaOps.Authority.Persistence.Postgres;
|
||||
/// </summary>
|
||||
public sealed class PostgresVerdictManifestStore : IVerdictManifestStore
|
||||
{
|
||||
private readonly NpgsqlDataSource _dataSource;
|
||||
private readonly AuthorityDataSource _dataSource;
|
||||
private static readonly JsonSerializerOptions s_jsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||
WriteIndented = false,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.SnakeCaseLower) },
|
||||
};
|
||||
|
||||
public PostgresVerdictManifestStore(NpgsqlDataSource dataSource)
|
||||
public PostgresVerdictManifestStore(AuthorityDataSource dataSource)
|
||||
{
|
||||
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
|
||||
}
|
||||
@@ -27,7 +30,7 @@ public sealed class PostgresVerdictManifestStore : IVerdictManifestStore
|
||||
ArgumentNullException.ThrowIfNull(manifest);
|
||||
|
||||
const string sql = """
|
||||
INSERT INTO authority.verdict_manifests (
|
||||
INSERT INTO verdict_manifests (
|
||||
manifest_id, tenant, asset_digest, vulnerability_id,
|
||||
inputs_json, status, confidence, result_json,
|
||||
policy_hash, lattice_version, evaluated_at, manifest_digest,
|
||||
@@ -51,8 +54,11 @@ public sealed class PostgresVerdictManifestStore : IVerdictManifestStore
|
||||
rekor_log_id = EXCLUDED.rekor_log_id
|
||||
""";
|
||||
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(ct).ConfigureAwait(false);
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
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);
|
||||
@@ -83,12 +89,15 @@ public sealed class PostgresVerdictManifestStore : IVerdictManifestStore
|
||||
inputs_json, status, confidence, result_json,
|
||||
policy_hash, lattice_version, evaluated_at, manifest_digest,
|
||||
signature_base64, rekor_log_id
|
||||
FROM authority.verdict_manifests
|
||||
FROM verdict_manifests
|
||||
WHERE tenant = @tenant AND manifest_id = @manifestId
|
||||
""";
|
||||
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(ct).ConfigureAwait(false);
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
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);
|
||||
|
||||
@@ -118,7 +127,7 @@ public sealed class PostgresVerdictManifestStore : IVerdictManifestStore
|
||||
inputs_json, status, confidence, result_json,
|
||||
policy_hash, lattice_version, evaluated_at, manifest_digest,
|
||||
signature_base64, rekor_log_id
|
||||
FROM authority.verdict_manifests
|
||||
FROM verdict_manifests
|
||||
WHERE tenant = @tenant
|
||||
AND asset_digest = @assetDigest
|
||||
AND vulnerability_id = @vulnerabilityId
|
||||
@@ -136,8 +145,11 @@ public sealed class PostgresVerdictManifestStore : IVerdictManifestStore
|
||||
|
||||
sql += " ORDER BY evaluated_at DESC LIMIT 1";
|
||||
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(ct).ConfigureAwait(false);
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
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);
|
||||
@@ -181,7 +193,7 @@ public sealed class PostgresVerdictManifestStore : IVerdictManifestStore
|
||||
inputs_json, status, confidence, result_json,
|
||||
policy_hash, lattice_version, evaluated_at, manifest_digest,
|
||||
signature_base64, rekor_log_id
|
||||
FROM authority.verdict_manifests
|
||||
FROM verdict_manifests
|
||||
WHERE tenant = @tenant
|
||||
AND policy_hash = @policyHash
|
||||
AND lattice_version = @latticeVersion
|
||||
@@ -189,8 +201,11 @@ public sealed class PostgresVerdictManifestStore : IVerdictManifestStore
|
||||
LIMIT @limit OFFSET @offset
|
||||
""";
|
||||
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(ct).ConfigureAwait(false);
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
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);
|
||||
@@ -235,14 +250,17 @@ public sealed class PostgresVerdictManifestStore : IVerdictManifestStore
|
||||
inputs_json, status, confidence, result_json,
|
||||
policy_hash, lattice_version, evaluated_at, manifest_digest,
|
||||
signature_base64, rekor_log_id
|
||||
FROM authority.verdict_manifests
|
||||
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(ct).ConfigureAwait(false);
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
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);
|
||||
@@ -274,12 +292,15 @@ public sealed class PostgresVerdictManifestStore : IVerdictManifestStore
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(manifestId);
|
||||
|
||||
const string sql = """
|
||||
DELETE FROM authority.verdict_manifests
|
||||
DELETE FROM verdict_manifests
|
||||
WHERE tenant = @tenant AND manifest_id = @manifestId
|
||||
""";
|
||||
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(ct).ConfigureAwait(false);
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
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);
|
||||
|
||||
@@ -307,7 +328,7 @@ public sealed class PostgresVerdictManifestStore : IVerdictManifestStore
|
||||
Result = result,
|
||||
PolicyHash = reader.GetString(8),
|
||||
LatticeVersion = reader.GetString(9),
|
||||
EvaluatedAt = reader.GetDateTime(10),
|
||||
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),
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<RootNamespace>StellaOps.Authority.Persistence</RootNamespace>
|
||||
<AssemblyName>StellaOps.Authority.Persistence</AssemblyName>
|
||||
<Description>Consolidated persistence layer for StellaOps Authority module (EF Core + Raw SQL)</Description>
|
||||
|
||||
@@ -7,4 +7,4 @@ Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0088-M | DONE | Maintainability audit for StellaOps.Authority.Persistence. |
|
||||
| AUDIT-0088-T | DONE | Test coverage audit for StellaOps.Authority.Persistence. |
|
||||
| AUDIT-0088-A | TODO | Pending approval for changes. |
|
||||
| AUDIT-0088-A | DONE | Applied updates and tests. |
|
||||
|
||||
Reference in New Issue
Block a user