save progress

This commit is contained in:
StellaOps Bot
2026-01-03 00:47:24 +02:00
parent 3f197814c5
commit ca578801fd
319 changed files with 32478 additions and 2202 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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