Fix build and code structure improvements. New but essential UI functionality. CI improvements. Documentation improvements. AI module improvements.
This commit is contained in:
@@ -8,7 +8,5 @@
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="System.Collections.Immutable" Version="9.0.3" />
|
||||
<PackageReference Include="System.Text.Json" Version="9.0.4" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
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.
|
||||
/// </summary>
|
||||
public class AuthorityDbContext : DbContext
|
||||
{
|
||||
public AuthorityDbContext(DbContextOptions<AuthorityDbContext> options)
|
||||
: base(options)
|
||||
{
|
||||
}
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.HasDefaultSchema("authority");
|
||||
base.OnModelCreating(modelBuilder);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Authority.Persistence.Postgres;
|
||||
using StellaOps.Authority.Persistence.Postgres.Repositories;
|
||||
using StellaOps.Infrastructure.Postgres.Options;
|
||||
|
||||
namespace StellaOps.Authority.Persistence.Extensions;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for configuring Authority persistence services.
|
||||
/// </summary>
|
||||
public static class AuthorityPersistenceExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds Authority PostgreSQL persistence services.
|
||||
/// </summary>
|
||||
/// <param name="services">Service collection.</param>
|
||||
/// <param name="configuration">Configuration root.</param>
|
||||
/// <param name="sectionName">Configuration section name for PostgreSQL options.</param>
|
||||
/// <returns>Service collection for chaining.</returns>
|
||||
public static IServiceCollection AddAuthorityPersistence(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration,
|
||||
string sectionName = "Postgres:Authority")
|
||||
{
|
||||
services.Configure<PostgresOptions>(sectionName, configuration.GetSection(sectionName));
|
||||
RegisterAuthorityServices(services);
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds Authority PostgreSQL persistence services with explicit options.
|
||||
/// </summary>
|
||||
/// <param name="services">Service collection.</param>
|
||||
/// <param name="configureOptions">Options configuration action.</param>
|
||||
/// <returns>Service collection for chaining.</returns>
|
||||
public static IServiceCollection AddAuthorityPersistence(
|
||||
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>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
namespace StellaOps.Authority.Persistence.Documents;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a bootstrap invite document.
|
||||
/// </summary>
|
||||
public sealed class AuthorityBootstrapInviteDocument
|
||||
{
|
||||
public string Id { get; set; } = Guid.NewGuid().ToString("N");
|
||||
public string Token { get; set; } = string.Empty;
|
||||
public string Type { get; set; } = string.Empty;
|
||||
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; } = AuthorityBootstrapInviteStatuses.Pending;
|
||||
public Dictionary<string, string?>? Metadata { get; set; }
|
||||
}
|
||||
|
||||
public static class AuthorityBootstrapInviteStatuses
|
||||
{
|
||||
public const string Pending = "pending";
|
||||
public const string Reserved = "reserved";
|
||||
public const string Consumed = "consumed";
|
||||
public const string Expired = "expired";
|
||||
}
|
||||
|
||||
public enum BootstrapInviteReservationStatus
|
||||
{
|
||||
NotFound,
|
||||
Reserved,
|
||||
Expired,
|
||||
AlreadyUsed
|
||||
}
|
||||
|
||||
public sealed record BootstrapInviteReservationResult(BootstrapInviteReservationStatus Status, AuthorityBootstrapInviteDocument? Invite);
|
||||
|
||||
/// <summary>
|
||||
/// Represents a service account document.
|
||||
/// </summary>
|
||||
public sealed class AuthorityServiceAccountDocument
|
||||
{
|
||||
public string Id { get; set; } = Guid.NewGuid().ToString("N");
|
||||
public string AccountId { get; set; } = string.Empty;
|
||||
public string Tenant { get; set; } = string.Empty;
|
||||
public string DisplayName { get; set; } = string.Empty;
|
||||
public string? Description { get; set; }
|
||||
public bool Enabled { get; set; } = true;
|
||||
public List<string> AllowedScopes { get; set; } = new();
|
||||
public List<string> AuthorizedClients { get; set; } = new();
|
||||
public Dictionary<string, List<string>> Attributes { get; set; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
public DateTimeOffset UpdatedAt { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a client document.
|
||||
/// </summary>
|
||||
public sealed class AuthorityClientDocument
|
||||
{
|
||||
public string Id { get; set; } = Guid.NewGuid().ToString("N");
|
||||
public string ClientId { get; set; } = string.Empty;
|
||||
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 List<string> RedirectUris { get; set; } = new();
|
||||
public List<string> PostLogoutRedirectUris { get; set; } = new();
|
||||
public List<string> AllowedScopes { get; set; } = new();
|
||||
public List<string> AllowedGrantTypes { get; set; } = new();
|
||||
public bool RequireClientSecret { get; set; } = true;
|
||||
public bool RequirePkce { get; set; }
|
||||
public bool AllowPlainTextPkce { get; set; }
|
||||
public string? ClientType { get; set; }
|
||||
public Dictionary<string, string?> Properties { get; set; } = new();
|
||||
public List<AuthorityClientCertificateBinding> CertificateBindings { get; set; } = new();
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
public DateTimeOffset UpdatedAt { get; set; }
|
||||
public bool Disabled { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a revocation document.
|
||||
/// </summary>
|
||||
public sealed class AuthorityRevocationDocument
|
||||
{
|
||||
public string Id { get; set; } = Guid.NewGuid().ToString("N");
|
||||
public string Category { get; set; } = string.Empty;
|
||||
public string RevocationId { get; set; } = string.Empty;
|
||||
public string SubjectId { get; set; } = string.Empty;
|
||||
public string? ClientId { get; set; }
|
||||
public string? TokenId { get; set; }
|
||||
public string? TokenType { get; set; }
|
||||
public string Reason { get; set; } = string.Empty;
|
||||
public string? ReasonDescription { get; set; }
|
||||
public DateTimeOffset RevokedAt { get; set; }
|
||||
public DateTimeOffset? EffectiveAt { get; set; }
|
||||
public DateTimeOffset? ExpiresAt { get; set; }
|
||||
public List<string>? Scopes { get; set; }
|
||||
public string? Fingerprint { get; set; }
|
||||
public Dictionary<string, string?> Metadata { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a login attempt document.
|
||||
/// </summary>
|
||||
public sealed class AuthorityLoginAttemptDocument
|
||||
{
|
||||
public string Id { get; set; } = Guid.NewGuid().ToString("N");
|
||||
public string? CorrelationId { get; set; }
|
||||
public string? SubjectId { get; set; }
|
||||
public string? Username { get; set; }
|
||||
public string? ClientId { get; set; }
|
||||
public string? Plugin { get; set; }
|
||||
public string EventType { get; set; } = string.Empty;
|
||||
public string Outcome { get; set; } = string.Empty;
|
||||
public bool Successful { get; set; }
|
||||
public string? Reason { get; set; }
|
||||
public string? RemoteAddress { get; set; }
|
||||
public string? IpAddress { get; set; }
|
||||
public string? UserAgent { get; set; }
|
||||
public string? Tenant { get; set; }
|
||||
public List<string> Scopes { get; set; } = new();
|
||||
public DateTimeOffset OccurredAt { get; set; }
|
||||
public List<AuthorityLoginAttemptPropertyDocument> Properties { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a property in a login attempt document.
|
||||
/// </summary>
|
||||
public sealed class AuthorityLoginAttemptPropertyDocument
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string Value { get; set; } = string.Empty;
|
||||
public bool Sensitive { get; set; }
|
||||
public string Classification { get; set; } = "none";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a token document.
|
||||
/// </summary>
|
||||
public sealed class AuthorityTokenDocument
|
||||
{
|
||||
public string Id { get; set; } = Guid.NewGuid().ToString("N");
|
||||
public string TokenId { get; set; } = string.Empty;
|
||||
public string? SubjectId { get; set; }
|
||||
public string? ClientId { get; set; }
|
||||
public string TokenType { get; set; } = string.Empty;
|
||||
public string Type
|
||||
{
|
||||
get => TokenType;
|
||||
set => TokenType = value;
|
||||
}
|
||||
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 List<string> Scope { get; set; } = new();
|
||||
public string Status { get; set; } = "valid";
|
||||
public string? Tenant { get; set; }
|
||||
public string? Project { get; set; }
|
||||
public string? SenderConstraint { get; set; }
|
||||
public string? SenderNonce { get; set; }
|
||||
public string? SenderCertificateHex { get; set; }
|
||||
public string? SenderKeyThumbprint { get; set; }
|
||||
public string? ServiceAccountId { get; set; }
|
||||
public string? TokenKind { get; set; }
|
||||
public string? VulnerabilityEnvironment { get; set; }
|
||||
public string? VulnerabilityOwner { get; set; }
|
||||
public string? VulnerabilityBusinessTier { get; set; }
|
||||
public List<string> ActorChain { get; set; } = new();
|
||||
public string? IncidentReason { get; set; }
|
||||
public List<string> Devices { get; set; } = new();
|
||||
public Dictionary<string, string?> Properties { get; set; } = new();
|
||||
public DateTimeOffset? RevokedAt { get; set; }
|
||||
public string? RevokedReason { get; set; }
|
||||
public string? RevokedReasonDescription { get; set; }
|
||||
public IReadOnlyDictionary<string, string?>? RevokedMetadata { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a refresh token document.
|
||||
/// </summary>
|
||||
public sealed class AuthorityRefreshTokenDocument
|
||||
{
|
||||
public string Id { get; set; } = Guid.NewGuid().ToString("N");
|
||||
public string TokenId { get; set; } = string.Empty;
|
||||
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>
|
||||
/// Represents an airgap audit document.
|
||||
/// </summary>
|
||||
public sealed class AuthorityAirgapAuditDocument
|
||||
{
|
||||
public string Id { get; set; } = Guid.NewGuid().ToString("N");
|
||||
public string? Tenant { get; set; }
|
||||
public string? SubjectId { get; set; }
|
||||
public string? Username { get; set; }
|
||||
public string? DisplayName { get; set; }
|
||||
public string? ClientId { get; set; }
|
||||
public string? BundleId { get; set; }
|
||||
public string? Status { get; set; }
|
||||
public string EventType { get; set; } = string.Empty;
|
||||
public string? OperatorId { get; set; }
|
||||
public string? ComponentId { get; set; }
|
||||
public string Outcome { get; set; } = string.Empty;
|
||||
public string? Reason { get; set; }
|
||||
public string? TraceId { get; set; }
|
||||
public DateTimeOffset OccurredAt { get; set; }
|
||||
public List<AuthorityAirgapAuditPropertyDocument> Properties { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a property in an airgap audit document.
|
||||
/// </summary>
|
||||
public sealed class AuthorityAirgapAuditPropertyDocument
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string Value { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Query parameters for airgap audit search.
|
||||
/// </summary>
|
||||
public sealed class AuthorityAirgapAuditQuery
|
||||
{
|
||||
public string? Tenant { get; set; }
|
||||
public string? BundleId { get; set; }
|
||||
public string? Status { get; set; }
|
||||
public string? TraceId { get; set; }
|
||||
public string? AfterId { get; set; }
|
||||
public int Limit { get; set; } = 50;
|
||||
}
|
||||
|
||||
public sealed class AuthorityAirgapAuditQueryResult
|
||||
{
|
||||
public AuthorityAirgapAuditQueryResult(IReadOnlyList<AuthorityAirgapAuditDocument> items, string? nextCursor)
|
||||
{
|
||||
Items = items ?? throw new ArgumentNullException(nameof(items));
|
||||
NextCursor = nextCursor;
|
||||
}
|
||||
|
||||
public IReadOnlyList<AuthorityAirgapAuditDocument> Items { get; }
|
||||
public string? NextCursor { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tracks the last exported revocation bundle metadata.
|
||||
/// </summary>
|
||||
public sealed class AuthorityRevocationExportStateDocument
|
||||
{
|
||||
public long Sequence { get; set; }
|
||||
public string? BundleId { get; set; }
|
||||
public DateTimeOffset? IssuedAt { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a certificate binding for client authentication.
|
||||
/// </summary>
|
||||
public sealed class AuthorityClientCertificateBinding
|
||||
{
|
||||
public string? Thumbprint { get; set; }
|
||||
public string? SerialNumber { get; set; }
|
||||
public string? Subject { get; set; }
|
||||
public string? Issuer { get; set; }
|
||||
public List<string> SubjectAlternativeNames { get; set; } = new();
|
||||
public DateTimeOffset? NotBefore { get; set; }
|
||||
public DateTimeOffset? NotAfter { get; set; }
|
||||
public string? Label { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
public DateTimeOffset UpdatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace StellaOps.Authority.Persistence.Documents;
|
||||
|
||||
/// <summary>
|
||||
/// Result status for token usage recording.
|
||||
/// </summary>
|
||||
public enum TokenUsageUpdateStatus
|
||||
{
|
||||
Recorded,
|
||||
MissingMetadata,
|
||||
NotFound,
|
||||
SuspectedReplay
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents the outcome of recording token usage.
|
||||
/// </summary>
|
||||
public sealed record TokenUsageUpdateResult(TokenUsageUpdateStatus Status, string? RemoteAddress, string? UserAgent);
|
||||
@@ -0,0 +1,153 @@
|
||||
using System.Linq.Expressions;
|
||||
|
||||
namespace StellaOps.Authority.InMemoryDriver;
|
||||
|
||||
/// <summary>
|
||||
/// Compatibility shim for collection interface.
|
||||
/// Provides an in-memory implementation.
|
||||
/// </summary>
|
||||
public interface ICollection<TDocument>
|
||||
{
|
||||
IDatabase Database { get; }
|
||||
string CollectionNamespace { get; }
|
||||
|
||||
Task<TDocument?> FindOneAsync(Expression<Func<TDocument, bool>> filter, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<TDocument>> FindAsync(Expression<Func<TDocument, bool>> filter, CancellationToken cancellationToken = default);
|
||||
Task InsertOneAsync(TDocument document, CancellationToken cancellationToken = default);
|
||||
Task ReplaceOneAsync(Expression<Func<TDocument, bool>> filter, TDocument replacement, bool isUpsert = false, CancellationToken cancellationToken = default);
|
||||
Task DeleteOneAsync(Expression<Func<TDocument, bool>> filter, CancellationToken cancellationToken = default);
|
||||
Task<long> CountDocumentsAsync(Expression<Func<TDocument, bool>> filter, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compatibility shim for database interface.
|
||||
/// </summary>
|
||||
public interface IDatabase
|
||||
{
|
||||
string DatabaseNamespace { get; }
|
||||
ICollection<TDocument> GetCollection<TDocument>(string name);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compatibility shim for client interface.
|
||||
/// </summary>
|
||||
public interface IClient
|
||||
{
|
||||
IDatabase GetDatabase(string name);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of ICollection for compatibility.
|
||||
/// </summary>
|
||||
public class InMemoryCollection<TDocument> : ICollection<TDocument>
|
||||
{
|
||||
private readonly List<TDocument> _documents = new();
|
||||
private readonly IDatabase _database;
|
||||
private readonly string _name;
|
||||
|
||||
public InMemoryCollection(IDatabase database, string name)
|
||||
{
|
||||
_database = database;
|
||||
_name = name;
|
||||
}
|
||||
|
||||
public IDatabase Database => _database;
|
||||
public string CollectionNamespace => _name;
|
||||
|
||||
public Task<TDocument?> FindOneAsync(Expression<Func<TDocument, bool>> filter, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var compiled = filter.Compile();
|
||||
var result = _documents.FirstOrDefault(compiled);
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<TDocument>> FindAsync(Expression<Func<TDocument, bool>> filter, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var compiled = filter.Compile();
|
||||
IReadOnlyList<TDocument> result = _documents.Where(compiled).ToList();
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
|
||||
public Task InsertOneAsync(TDocument document, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_documents.Add(document);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task ReplaceOneAsync(Expression<Func<TDocument, bool>> filter, TDocument replacement, bool isUpsert = false, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var compiled = filter.Compile();
|
||||
var index = _documents.FindIndex(d => compiled(d));
|
||||
if (index >= 0)
|
||||
{
|
||||
_documents[index] = replacement;
|
||||
}
|
||||
else if (isUpsert)
|
||||
{
|
||||
_documents.Add(replacement);
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task DeleteOneAsync(Expression<Func<TDocument, bool>> filter, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var compiled = filter.Compile();
|
||||
var item = _documents.FirstOrDefault(compiled);
|
||||
if (item != null)
|
||||
{
|
||||
_documents.Remove(item);
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<long> CountDocumentsAsync(Expression<Func<TDocument, bool>> filter, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var compiled = filter.Compile();
|
||||
var count = _documents.Count(compiled);
|
||||
return Task.FromResult((long)count);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of IDatabase for compatibility.
|
||||
/// </summary>
|
||||
public class InMemoryDatabase : IDatabase
|
||||
{
|
||||
private readonly Dictionary<string, object> _collections = new();
|
||||
private readonly string _name;
|
||||
|
||||
public InMemoryDatabase(string name)
|
||||
{
|
||||
_name = name;
|
||||
}
|
||||
|
||||
public string DatabaseNamespace => _name;
|
||||
|
||||
public ICollection<TDocument> GetCollection<TDocument>(string name)
|
||||
{
|
||||
if (!_collections.TryGetValue(name, out var collection))
|
||||
{
|
||||
collection = new InMemoryCollection<TDocument>(this, name);
|
||||
_collections[name] = collection;
|
||||
}
|
||||
return (ICollection<TDocument>)collection;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of IClient for compatibility.
|
||||
/// </summary>
|
||||
public class InMemoryClient : IClient
|
||||
{
|
||||
private readonly Dictionary<string, IDatabase> _databases = new();
|
||||
|
||||
public IDatabase GetDatabase(string name)
|
||||
{
|
||||
if (!_databases.TryGetValue(name, out var database))
|
||||
{
|
||||
database = new InMemoryDatabase(name);
|
||||
_databases[name] = database;
|
||||
}
|
||||
return database;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Authority.InMemoryDriver;
|
||||
using StellaOps.Authority.Persistence.InMemory.Initialization;
|
||||
using StellaOps.Authority.Persistence.Sessions;
|
||||
using StellaOps.Authority.Persistence.InMemory.Stores;
|
||||
|
||||
namespace StellaOps.Authority.Persistence.Extensions;
|
||||
|
||||
/// <summary>
|
||||
/// Compatibility shim storage options. In PostgreSQL mode, these are largely unused.
|
||||
/// </summary>
|
||||
public sealed class AuthorityStorageOptions
|
||||
{
|
||||
public string ConnectionString { get; set; } = string.Empty;
|
||||
public string DatabaseName { get; set; } = "authority";
|
||||
public TimeSpan CommandTimeout { get; set; } = TimeSpan.FromSeconds(30);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for configuring Authority storage compatibility storage services.
|
||||
/// In PostgreSQL mode, this registers in-memory implementations for the storage interfaces.
|
||||
/// </summary>
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds Authority storage compatibility storage services (in-memory implementations).
|
||||
/// For production PostgreSQL storage, use AddAuthorityPostgresStorage from StellaOps.Authority.Persistence.Postgres.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddAuthorityInMemoryStorage(
|
||||
this IServiceCollection services,
|
||||
Action<AuthorityStorageOptions> configureOptions)
|
||||
{
|
||||
var options = new AuthorityStorageOptions();
|
||||
configureOptions(options);
|
||||
services.AddSingleton(options);
|
||||
|
||||
RegisterInMemoryServices(services, options);
|
||||
return services;
|
||||
}
|
||||
|
||||
private static void RegisterInMemoryServices(IServiceCollection services, AuthorityStorageOptions options)
|
||||
{
|
||||
// Register the initializer (no-op for Postgres mode)
|
||||
services.AddSingleton<AuthorityStorageInitializer>();
|
||||
|
||||
// Register null session accessor
|
||||
services.AddSingleton<IAuthoritySessionAccessor, NullAuthoritySessionAccessor>();
|
||||
|
||||
// Register in-memory shims for compatibility
|
||||
var inMemoryClient = new InMemoryClient();
|
||||
var inMemoryDatabase = inMemoryClient.GetDatabase(options.DatabaseName);
|
||||
services.AddSingleton<IClient>(inMemoryClient);
|
||||
services.AddSingleton<IDatabase>(inMemoryDatabase);
|
||||
|
||||
// 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<IAuthorityRevocationExportStateStore, InMemoryRevocationExportStateStore>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace StellaOps.Authority.Persistence.InMemory.Initialization;
|
||||
|
||||
/// <summary>
|
||||
/// Compatibility shim for storage initializer. In PostgreSQL mode, this is a no-op.
|
||||
/// The actual initialization is handled by PostgreSQL migrations.
|
||||
/// </summary>
|
||||
public sealed class AuthorityStorageInitializer
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes the database. In PostgreSQL mode, this is a no-op as migrations handle setup.
|
||||
/// </summary>
|
||||
public Task InitialiseAsync(object database, CancellationToken cancellationToken)
|
||||
{
|
||||
// No-op for PostgreSQL mode - migrations handle schema setup
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using StellaOps.Storage.Documents;
|
||||
|
||||
namespace StellaOps.Storage.Serialization.Attributes;
|
||||
|
||||
/// <summary>
|
||||
/// Compatibility shim for storage Id attribute.
|
||||
/// In PostgreSQL mode, this attribute is ignored but allows code to compile.
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field)]
|
||||
public class StorageIdAttribute : Attribute
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compatibility shim for storage Element attribute.
|
||||
/// In PostgreSQL mode, this attribute is ignored but allows code to compile.
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field)]
|
||||
public class StorageElementAttribute : Attribute
|
||||
{
|
||||
public string ElementName { get; }
|
||||
|
||||
public StorageElementAttribute(string elementName)
|
||||
{
|
||||
ElementName = elementName;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compatibility shim for storage Ignore attribute.
|
||||
/// In PostgreSQL mode, this attribute is ignored but allows code to compile.
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field)]
|
||||
public class StorageIgnoreAttribute : Attribute
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compatibility shim for storage IgnoreIfNull attribute.
|
||||
/// In PostgreSQL mode, this attribute is ignored but allows code to compile.
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field)]
|
||||
public class StorageIgnoreIfNullAttribute : Attribute
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compatibility shim for storage Representation attribute.
|
||||
/// In PostgreSQL mode, this attribute is ignored but allows code to compile.
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field)]
|
||||
public class StorageRepresentationAttribute : Attribute
|
||||
{
|
||||
public StorageType Representation { get; }
|
||||
|
||||
public StorageRepresentationAttribute(StorageType representation)
|
||||
{
|
||||
Representation = representation;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
namespace StellaOps.Storage.Documents;
|
||||
|
||||
/// <summary>
|
||||
/// Compatibility shim for storage ObjectId.
|
||||
/// In PostgreSQL mode, this wraps a GUID string.
|
||||
/// </summary>
|
||||
public readonly struct ObjectId : IEquatable<ObjectId>, IComparable<ObjectId>
|
||||
{
|
||||
private readonly string _value;
|
||||
|
||||
public static readonly ObjectId Empty = new(string.Empty);
|
||||
|
||||
public ObjectId(string value)
|
||||
{
|
||||
_value = value ?? string.Empty;
|
||||
}
|
||||
|
||||
public static ObjectId GenerateNewId()
|
||||
{
|
||||
return new ObjectId(Guid.NewGuid().ToString("N"));
|
||||
}
|
||||
|
||||
public static ObjectId Parse(string s)
|
||||
{
|
||||
return new ObjectId(s);
|
||||
}
|
||||
|
||||
public static bool TryParse(string s, out ObjectId result)
|
||||
{
|
||||
result = new ObjectId(s);
|
||||
return true;
|
||||
}
|
||||
|
||||
public override string ToString() => _value;
|
||||
|
||||
public bool Equals(ObjectId other) => _value == other._value;
|
||||
|
||||
public override bool Equals(object? obj) => obj is ObjectId other && Equals(other);
|
||||
|
||||
public override int GetHashCode() => _value?.GetHashCode() ?? 0;
|
||||
|
||||
public int CompareTo(ObjectId other) => string.Compare(_value, other._value, StringComparison.Ordinal);
|
||||
|
||||
public static bool operator ==(ObjectId left, ObjectId right) => left.Equals(right);
|
||||
|
||||
public static bool operator !=(ObjectId left, ObjectId right) => !left.Equals(right);
|
||||
|
||||
public static implicit operator string(ObjectId id) => id._value;
|
||||
|
||||
public static implicit operator ObjectId(string value) => new(value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compatibility shim for storage document type enum.
|
||||
/// </summary>
|
||||
public enum StorageType
|
||||
{
|
||||
EndOfDocument = 0,
|
||||
Double = 1,
|
||||
String = 2,
|
||||
Document = 3,
|
||||
Array = 4,
|
||||
Binary = 5,
|
||||
Undefined = 6,
|
||||
ObjectId = 7,
|
||||
Boolean = 8,
|
||||
DateTime = 9,
|
||||
Null = 10,
|
||||
RegularExpression = 11,
|
||||
JavaScript = 13,
|
||||
Symbol = 14,
|
||||
JavaScriptWithScope = 15,
|
||||
Int32 = 16,
|
||||
Timestamp = 17,
|
||||
Int64 = 18,
|
||||
Decimal128 = 19,
|
||||
MinKey = -1,
|
||||
MaxKey = 127
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
namespace StellaOps.Authority.Persistence.Sessions;
|
||||
|
||||
/// <summary>
|
||||
/// Compatibility shim for database session handle. In PostgreSQL mode, this is unused.
|
||||
/// </summary>
|
||||
public interface IClientSessionHandle : IDisposable
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compatibility shim for database session accessor. In PostgreSQL mode, this returns null.
|
||||
/// </summary>
|
||||
public interface IAuthoritySessionAccessor
|
||||
{
|
||||
IClientSessionHandle? CurrentSession { get; }
|
||||
ValueTask<IClientSessionHandle?> GetSessionAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation that always returns null session.
|
||||
/// </summary>
|
||||
public sealed class NullAuthoritySessionAccessor : IAuthoritySessionAccessor
|
||||
{
|
||||
public IClientSessionHandle? CurrentSession => null;
|
||||
|
||||
public ValueTask<IClientSessionHandle?> GetSessionAsync(CancellationToken cancellationToken)
|
||||
=> ValueTask.FromResult<IClientSessionHandle?>(null);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
using StellaOps.Authority.Persistence.Documents;
|
||||
using StellaOps.Authority.Persistence.Sessions;
|
||||
|
||||
namespace StellaOps.Authority.Persistence.InMemory.Stores;
|
||||
|
||||
/// <summary>
|
||||
/// Store interface for bootstrap invites.
|
||||
/// </summary>
|
||||
public interface IAuthorityBootstrapInviteStore
|
||||
{
|
||||
ValueTask<AuthorityBootstrapInviteDocument?> FindByTokenAsync(string token, CancellationToken cancellationToken, IClientSessionHandle? session = null);
|
||||
ValueTask<AuthorityBootstrapInviteDocument> CreateAsync(AuthorityBootstrapInviteDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null);
|
||||
ValueTask<BootstrapInviteReservationResult> TryReserveAsync(string token, string expectedType, DateTimeOffset now, string? reservedBy, CancellationToken cancellationToken, IClientSessionHandle? session = null);
|
||||
ValueTask<bool> ReleaseAsync(string token, CancellationToken cancellationToken, IClientSessionHandle? session = null);
|
||||
ValueTask<bool> MarkConsumedAsync(string token, string? consumedBy, DateTimeOffset consumedAt, CancellationToken cancellationToken, IClientSessionHandle? session = null);
|
||||
ValueTask<IReadOnlyList<AuthorityBootstrapInviteDocument>> ExpireAsync(DateTimeOffset asOf, CancellationToken cancellationToken, IClientSessionHandle? session = null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Store interface for service accounts.
|
||||
/// </summary>
|
||||
public interface IAuthorityServiceAccountStore
|
||||
{
|
||||
ValueTask<AuthorityServiceAccountDocument?> FindByAccountIdAsync(string accountId, CancellationToken cancellationToken, IClientSessionHandle? session = null);
|
||||
ValueTask<IReadOnlyList<AuthorityServiceAccountDocument>> ListAsync(string? tenant = null, CancellationToken cancellationToken = default, IClientSessionHandle? session = null);
|
||||
ValueTask<IReadOnlyList<AuthorityServiceAccountDocument>> ListByTenantAsync(string tenant, CancellationToken cancellationToken = default, IClientSessionHandle? session = null);
|
||||
ValueTask UpsertAsync(AuthorityServiceAccountDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null);
|
||||
ValueTask<bool> DeleteAsync(string accountId, CancellationToken cancellationToken, IClientSessionHandle? session = null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Store interface for clients.
|
||||
/// </summary>
|
||||
public interface IAuthorityClientStore
|
||||
{
|
||||
ValueTask<AuthorityClientDocument?> FindByClientIdAsync(string clientId, CancellationToken cancellationToken, IClientSessionHandle? session = null);
|
||||
ValueTask UpsertAsync(AuthorityClientDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null);
|
||||
ValueTask<bool> DeleteByClientIdAsync(string clientId, CancellationToken cancellationToken, IClientSessionHandle? session = null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Store interface for revocations.
|
||||
/// </summary>
|
||||
public interface IAuthorityRevocationStore
|
||||
{
|
||||
ValueTask UpsertAsync(AuthorityRevocationDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null);
|
||||
ValueTask<IReadOnlyList<AuthorityRevocationDocument>> GetActiveAsync(DateTimeOffset asOf, CancellationToken cancellationToken, IClientSessionHandle? session = null);
|
||||
ValueTask RemoveAsync(string category, string revocationId, CancellationToken cancellationToken, IClientSessionHandle? session = null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Store interface for login attempts.
|
||||
/// </summary>
|
||||
public interface IAuthorityLoginAttemptStore
|
||||
{
|
||||
ValueTask InsertAsync(AuthorityLoginAttemptDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null);
|
||||
ValueTask<IReadOnlyList<AuthorityLoginAttemptDocument>> ListRecentAsync(string subjectId, int limit, CancellationToken cancellationToken, IClientSessionHandle? session = null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Store interface for tokens.
|
||||
/// </summary>
|
||||
public interface IAuthorityTokenStore
|
||||
{
|
||||
ValueTask<AuthorityTokenDocument?> FindByTokenIdAsync(string tokenId, CancellationToken cancellationToken, IClientSessionHandle? session = null);
|
||||
ValueTask<AuthorityTokenDocument?> FindByReferenceIdAsync(string referenceId, CancellationToken cancellationToken, IClientSessionHandle? session = null);
|
||||
ValueTask<IReadOnlyList<AuthorityTokenDocument>> ListBySubjectAsync(string subjectId, int limit, CancellationToken cancellationToken, IClientSessionHandle? session = null);
|
||||
ValueTask<IReadOnlyList<AuthorityTokenDocument>> ListByScopeAsync(string scope, string tenant, DateTimeOffset? issuedAfter, int limit, CancellationToken cancellationToken, IClientSessionHandle? session = null);
|
||||
ValueTask<IReadOnlyList<AuthorityTokenDocument>> ListRevokedAsync(string? tenant, CancellationToken cancellationToken, IClientSessionHandle? session = null);
|
||||
ValueTask<long> CountActiveDelegationTokensAsync(string tenant, string? serviceAccountId, CancellationToken cancellationToken, IClientSessionHandle? session = null);
|
||||
ValueTask<IReadOnlyList<AuthorityTokenDocument>> ListActiveDelegationTokensAsync(string tenant, string? serviceAccountId, CancellationToken cancellationToken, IClientSessionHandle? session = null);
|
||||
ValueTask InsertAsync(AuthorityTokenDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null);
|
||||
ValueTask UpsertAsync(AuthorityTokenDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null);
|
||||
ValueTask UpdateStatusAsync(string tokenId, string status, DateTimeOffset? revokedAt, string? reason, string? reasonDescription, IReadOnlyDictionary<string, string?>? metadata, CancellationToken cancellationToken, IClientSessionHandle? session = null);
|
||||
ValueTask<bool> RevokeAsync(string tokenId, CancellationToken cancellationToken, IClientSessionHandle? session = null);
|
||||
ValueTask<int> RevokeBySubjectAsync(string subjectId, CancellationToken cancellationToken, IClientSessionHandle? session = null);
|
||||
ValueTask<TokenUsageUpdateResult> RecordUsageAsync(string tokenId, string? remoteAddress, string? userAgent, DateTimeOffset observedAt, CancellationToken cancellationToken, IClientSessionHandle? session = null);
|
||||
ValueTask<int> RevokeByClientAsync(string clientId, CancellationToken cancellationToken, IClientSessionHandle? session = null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Store interface for refresh tokens.
|
||||
/// </summary>
|
||||
public interface IAuthorityRefreshTokenStore
|
||||
{
|
||||
ValueTask<AuthorityRefreshTokenDocument?> FindByTokenIdAsync(string tokenId, CancellationToken cancellationToken, IClientSessionHandle? session = null);
|
||||
ValueTask<AuthorityRefreshTokenDocument?> FindByHandleAsync(string handle, CancellationToken cancellationToken, IClientSessionHandle? session = null);
|
||||
ValueTask UpsertAsync(AuthorityRefreshTokenDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null);
|
||||
ValueTask<bool> ConsumeAsync(string tokenId, CancellationToken cancellationToken, IClientSessionHandle? session = null);
|
||||
ValueTask<int> RevokeBySubjectAsync(string subjectId, CancellationToken cancellationToken, IClientSessionHandle? session = null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Store interface for airgap audit entries.
|
||||
/// </summary>
|
||||
public interface IAuthorityAirgapAuditStore
|
||||
{
|
||||
ValueTask InsertAsync(AuthorityAirgapAuditDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null);
|
||||
ValueTask<IReadOnlyList<AuthorityAirgapAuditDocument>> ListAsync(int limit, int offset, CancellationToken cancellationToken, IClientSessionHandle? session = null);
|
||||
ValueTask<AuthorityAirgapAuditQueryResult> QueryAsync(AuthorityAirgapAuditQuery query, CancellationToken cancellationToken, IClientSessionHandle? session = null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tracks revocation export state to enforce monotonic bundle sequencing.
|
||||
/// </summary>
|
||||
public interface IAuthorityRevocationExportStateStore
|
||||
{
|
||||
ValueTask<AuthorityRevocationExportStateDocument?> GetAsync(CancellationToken cancellationToken, IClientSessionHandle? session = null);
|
||||
ValueTask UpdateAsync(long expectedSequence, long newSequence, string bundleId, DateTimeOffset issuedAt, CancellationToken cancellationToken, IClientSessionHandle? session = null);
|
||||
}
|
||||
@@ -0,0 +1,547 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Threading;
|
||||
using StellaOps.Authority.Persistence.Documents;
|
||||
using StellaOps.Authority.Persistence.Sessions;
|
||||
|
||||
namespace StellaOps.Authority.Persistence.InMemory.Stores;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of bootstrap invite store for development/testing.
|
||||
/// </summary>
|
||||
public sealed class InMemoryBootstrapInviteStore : IAuthorityBootstrapInviteStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, AuthorityBootstrapInviteDocument> _invites = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public ValueTask<AuthorityBootstrapInviteDocument?> FindByTokenAsync(string token, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
_invites.TryGetValue(token, out var doc);
|
||||
return ValueTask.FromResult(doc);
|
||||
}
|
||||
|
||||
public ValueTask<AuthorityBootstrapInviteDocument> CreateAsync(AuthorityBootstrapInviteDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
document.CreatedAt = document.CreatedAt == default ? DateTimeOffset.UtcNow : document.CreatedAt;
|
||||
document.IssuedAt = document.IssuedAt == default ? document.CreatedAt : document.IssuedAt;
|
||||
document.Status = AuthorityBootstrapInviteStatuses.Pending;
|
||||
_invites[document.Token] = document;
|
||||
return ValueTask.FromResult(document);
|
||||
}
|
||||
|
||||
public ValueTask<BootstrapInviteReservationResult> TryReserveAsync(string token, string expectedType, DateTimeOffset now, string? reservedBy, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
if (!_invites.TryGetValue(token, out var doc))
|
||||
{
|
||||
return ValueTask.FromResult(new BootstrapInviteReservationResult(BootstrapInviteReservationStatus.NotFound, null));
|
||||
}
|
||||
|
||||
if (!string.Equals(doc.Type, expectedType, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return ValueTask.FromResult(new BootstrapInviteReservationResult(BootstrapInviteReservationStatus.NotFound, null));
|
||||
}
|
||||
|
||||
if (doc.ExpiresAt <= now)
|
||||
{
|
||||
doc.Status = AuthorityBootstrapInviteStatuses.Expired;
|
||||
return ValueTask.FromResult(new BootstrapInviteReservationResult(BootstrapInviteReservationStatus.Expired, doc));
|
||||
}
|
||||
|
||||
if (doc.Consumed || string.Equals(doc.Status, AuthorityBootstrapInviteStatuses.Consumed, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return ValueTask.FromResult(new BootstrapInviteReservationResult(BootstrapInviteReservationStatus.AlreadyUsed, doc));
|
||||
}
|
||||
|
||||
doc.Status = AuthorityBootstrapInviteStatuses.Reserved;
|
||||
doc.ReservedBy = reservedBy;
|
||||
doc.ReservedUntil = now.AddMinutes(15);
|
||||
_invites[token] = doc;
|
||||
return ValueTask.FromResult(new BootstrapInviteReservationResult(BootstrapInviteReservationStatus.Reserved, doc));
|
||||
}
|
||||
|
||||
public ValueTask<bool> ReleaseAsync(string token, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
if (_invites.TryGetValue(token, out var doc) && string.Equals(doc.Status, AuthorityBootstrapInviteStatuses.Reserved, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
doc.Status = AuthorityBootstrapInviteStatuses.Pending;
|
||||
doc.ReservedBy = null;
|
||||
doc.ReservedUntil = null;
|
||||
_invites[token] = doc;
|
||||
return ValueTask.FromResult(true);
|
||||
}
|
||||
|
||||
return ValueTask.FromResult(false);
|
||||
}
|
||||
|
||||
public ValueTask<bool> MarkConsumedAsync(string token, string? consumedBy, DateTimeOffset consumedAt, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
if (_invites.TryGetValue(token, out var doc))
|
||||
{
|
||||
doc.Consumed = true;
|
||||
doc.Status = AuthorityBootstrapInviteStatuses.Consumed;
|
||||
doc.ReservedUntil = consumedAt;
|
||||
doc.ReservedBy = consumedBy;
|
||||
_invites[token] = doc;
|
||||
return ValueTask.FromResult(true);
|
||||
}
|
||||
|
||||
return ValueTask.FromResult(false);
|
||||
}
|
||||
|
||||
public ValueTask<IReadOnlyList<AuthorityBootstrapInviteDocument>> ExpireAsync(DateTimeOffset asOf, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
var expired = _invites.Values
|
||||
.Where(i => !i.Consumed && i.ExpiresAt <= asOf)
|
||||
.ToList();
|
||||
|
||||
foreach (var item in expired)
|
||||
{
|
||||
item.Status = AuthorityBootstrapInviteStatuses.Expired;
|
||||
_invites.TryRemove(item.Token, out _);
|
||||
}
|
||||
|
||||
return ValueTask.FromResult<IReadOnlyList<AuthorityBootstrapInviteDocument>>(expired);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of service account store for development/testing.
|
||||
/// </summary>
|
||||
public sealed class InMemoryServiceAccountStore : IAuthorityServiceAccountStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, AuthorityServiceAccountDocument> _accounts = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public ValueTask<AuthorityServiceAccountDocument?> FindByAccountIdAsync(string accountId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
_accounts.TryGetValue(accountId, out var doc);
|
||||
return ValueTask.FromResult(doc);
|
||||
}
|
||||
|
||||
public ValueTask<IReadOnlyList<AuthorityServiceAccountDocument>> ListAsync(string? tenant = null, CancellationToken cancellationToken = default, IClientSessionHandle? session = null)
|
||||
{
|
||||
var results = tenant is null
|
||||
? _accounts.Values.ToList()
|
||||
: _accounts.Values.Where(a => a.Tenant == tenant).ToList();
|
||||
return ValueTask.FromResult<IReadOnlyList<AuthorityServiceAccountDocument>>(results);
|
||||
}
|
||||
|
||||
public ValueTask<IReadOnlyList<AuthorityServiceAccountDocument>> ListByTenantAsync(string tenant, CancellationToken cancellationToken = default, IClientSessionHandle? session = null)
|
||||
{
|
||||
var results = _accounts.Values.Where(a => string.Equals(a.Tenant, tenant, StringComparison.Ordinal)).ToList();
|
||||
return ValueTask.FromResult<IReadOnlyList<AuthorityServiceAccountDocument>>(results);
|
||||
}
|
||||
|
||||
public ValueTask UpsertAsync(AuthorityServiceAccountDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
document.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
_accounts[document.AccountId] = document;
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
public ValueTask<bool> DeleteAsync(string accountId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
return ValueTask.FromResult(_accounts.TryRemove(accountId, out _));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of client store for development/testing.
|
||||
/// </summary>
|
||||
public sealed class InMemoryClientStore : IAuthorityClientStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, AuthorityClientDocument> _clients = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public ValueTask<AuthorityClientDocument?> FindByClientIdAsync(string clientId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
_clients.TryGetValue(clientId, out var doc);
|
||||
return ValueTask.FromResult(doc);
|
||||
}
|
||||
|
||||
public ValueTask UpsertAsync(AuthorityClientDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
document.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
_clients[document.ClientId] = document;
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
public ValueTask<bool> DeleteByClientIdAsync(string clientId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
return ValueTask.FromResult(_clients.TryRemove(clientId, out _));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of revocation store for development/testing.
|
||||
/// </summary>
|
||||
public sealed class InMemoryRevocationStore : IAuthorityRevocationStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, AuthorityRevocationDocument> _revocations = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public ValueTask UpsertAsync(AuthorityRevocationDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
var key = $"{document.Category}:{document.RevocationId}";
|
||||
_revocations[key] = document;
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
public ValueTask<IReadOnlyList<AuthorityRevocationDocument>> GetActiveAsync(DateTimeOffset asOf, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
var active = _revocations.Values
|
||||
.Where(r => r.ExpiresAt is null || r.ExpiresAt > asOf)
|
||||
.ToList();
|
||||
return ValueTask.FromResult<IReadOnlyList<AuthorityRevocationDocument>>(active);
|
||||
}
|
||||
|
||||
public ValueTask RemoveAsync(string category, string revocationId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
var key = $"{category}:{revocationId}";
|
||||
_revocations.TryRemove(key, out _);
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of login attempt store for development/testing.
|
||||
/// </summary>
|
||||
public sealed class InMemoryLoginAttemptStore : IAuthorityLoginAttemptStore
|
||||
{
|
||||
private readonly ConcurrentBag<AuthorityLoginAttemptDocument> _attempts = new();
|
||||
|
||||
public ValueTask InsertAsync(AuthorityLoginAttemptDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
_attempts.Add(document);
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
public ValueTask<IReadOnlyList<AuthorityLoginAttemptDocument>> ListRecentAsync(string subjectId, int limit, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
var results = _attempts
|
||||
.Where(a => a.SubjectId == subjectId)
|
||||
.OrderByDescending(a => a.OccurredAt)
|
||||
.Take(limit)
|
||||
.ToList();
|
||||
return ValueTask.FromResult<IReadOnlyList<AuthorityLoginAttemptDocument>>(results);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of token store for development/testing.
|
||||
/// </summary>
|
||||
public sealed class InMemoryTokenStore : IAuthorityTokenStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, AuthorityTokenDocument> _tokens = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public ValueTask<AuthorityTokenDocument?> FindByTokenIdAsync(string tokenId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
_tokens.TryGetValue(tokenId, out var doc);
|
||||
return ValueTask.FromResult(doc);
|
||||
}
|
||||
|
||||
public ValueTask<AuthorityTokenDocument?> FindByReferenceIdAsync(string referenceId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
var doc = _tokens.Values.FirstOrDefault(t => t.ReferenceId == referenceId);
|
||||
return ValueTask.FromResult(doc);
|
||||
}
|
||||
|
||||
public ValueTask<IReadOnlyList<AuthorityTokenDocument>> ListBySubjectAsync(string subjectId, int limit, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
var results = _tokens.Values
|
||||
.Where(t => t.SubjectId == subjectId)
|
||||
.OrderByDescending(t => t.CreatedAt)
|
||||
.Take(limit)
|
||||
.ToList();
|
||||
return ValueTask.FromResult<IReadOnlyList<AuthorityTokenDocument>>(results);
|
||||
}
|
||||
|
||||
public ValueTask<IReadOnlyList<AuthorityTokenDocument>> ListByScopeAsync(string scope, string tenant, DateTimeOffset? issuedAfter, int limit, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
var normalizedScope = scope?.Trim();
|
||||
var results = _tokens.Values
|
||||
.Where(t => string.Equals(t.Tenant, tenant, StringComparison.Ordinal))
|
||||
.Where(t => issuedAfter is null || t.CreatedAt >= issuedAfter.Value)
|
||||
.Where(t => t.Scope.Any(s => string.Equals(s, normalizedScope, StringComparison.Ordinal)))
|
||||
.OrderByDescending(t => t.CreatedAt)
|
||||
.Take(limit)
|
||||
.ToList();
|
||||
|
||||
return ValueTask.FromResult<IReadOnlyList<AuthorityTokenDocument>>(results);
|
||||
}
|
||||
|
||||
public ValueTask<IReadOnlyList<AuthorityTokenDocument>> ListRevokedAsync(string? tenant, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
var results = _tokens.Values
|
||||
.Where(t => string.Equals(t.Status, "revoked", StringComparison.OrdinalIgnoreCase))
|
||||
.Where(t => string.IsNullOrWhiteSpace(tenant) || string.Equals(t.Tenant, tenant, StringComparison.Ordinal))
|
||||
.OrderBy(t => t.TokenId, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
return ValueTask.FromResult<IReadOnlyList<AuthorityTokenDocument>>(results);
|
||||
}
|
||||
|
||||
public ValueTask<long> CountActiveDelegationTokensAsync(string tenant, string? serviceAccountId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
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))
|
||||
.Where(t => !string.Equals(t.Status, "revoked", StringComparison.OrdinalIgnoreCase))
|
||||
.Where(t => t.ExpiresAt is null || t.ExpiresAt > now)
|
||||
.LongCount();
|
||||
|
||||
return ValueTask.FromResult(count);
|
||||
}
|
||||
|
||||
public ValueTask<IReadOnlyList<AuthorityTokenDocument>> ListActiveDelegationTokensAsync(string tenant, string? serviceAccountId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
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))
|
||||
.Where(t => !string.Equals(t.Status, "revoked", StringComparison.OrdinalIgnoreCase))
|
||||
.Where(t => t.ExpiresAt is null || t.ExpiresAt > now)
|
||||
.ToList();
|
||||
|
||||
return ValueTask.FromResult<IReadOnlyList<AuthorityTokenDocument>>(items);
|
||||
}
|
||||
|
||||
public ValueTask InsertAsync(AuthorityTokenDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
=> UpsertAsync(document, cancellationToken, session);
|
||||
|
||||
public ValueTask UpsertAsync(AuthorityTokenDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
_tokens[document.TokenId] = document;
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
public ValueTask UpdateStatusAsync(string tokenId, string status, DateTimeOffset? revokedAt, string? reason, string? reasonDescription, IReadOnlyDictionary<string, string?>? metadata, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
if (_tokens.TryGetValue(tokenId, out var doc))
|
||||
{
|
||||
doc.Status = status;
|
||||
doc.RevokedAt = revokedAt;
|
||||
doc.RevokedReason = reason;
|
||||
}
|
||||
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
public ValueTask<bool> RevokeAsync(string tokenId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
if (_tokens.TryGetValue(tokenId, out var doc))
|
||||
{
|
||||
doc.Status = "revoked";
|
||||
doc.RevokedAt = DateTimeOffset.UtcNow;
|
||||
return ValueTask.FromResult(true);
|
||||
}
|
||||
|
||||
return ValueTask.FromResult(false);
|
||||
}
|
||||
|
||||
public ValueTask<int> RevokeBySubjectAsync(string subjectId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var revoked = 0;
|
||||
foreach (var token in _tokens.Values.Where(t => t.SubjectId == subjectId))
|
||||
{
|
||||
token.Status = "revoked";
|
||||
token.RevokedAt = now;
|
||||
revoked++;
|
||||
}
|
||||
|
||||
return ValueTask.FromResult(revoked);
|
||||
}
|
||||
|
||||
public ValueTask<int> RevokeByClientAsync(string clientId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var revoked = 0;
|
||||
foreach (var token in _tokens.Values.Where(t => t.ClientId == clientId))
|
||||
{
|
||||
token.Status = "revoked";
|
||||
token.RevokedAt = now;
|
||||
revoked++;
|
||||
}
|
||||
|
||||
return ValueTask.FromResult(revoked);
|
||||
}
|
||||
|
||||
public ValueTask<TokenUsageUpdateResult> RecordUsageAsync(string tokenId, string? remoteAddress, string? userAgent, DateTimeOffset observedAt, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
if (!_tokens.TryGetValue(tokenId, out var document))
|
||||
{
|
||||
return ValueTask.FromResult(new TokenUsageUpdateResult(TokenUsageUpdateStatus.NotFound, remoteAddress, userAgent));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(remoteAddress) && string.IsNullOrWhiteSpace(userAgent))
|
||||
{
|
||||
return ValueTask.FromResult(new TokenUsageUpdateResult(TokenUsageUpdateStatus.MissingMetadata, remoteAddress, userAgent));
|
||||
}
|
||||
|
||||
var fingerprint = $"{remoteAddress}|{userAgent}";
|
||||
if (document.Devices.All(d => d != fingerprint))
|
||||
{
|
||||
document.Devices.Add(fingerprint);
|
||||
}
|
||||
|
||||
var status = document.Devices.Count > 1 ? TokenUsageUpdateStatus.SuspectedReplay : TokenUsageUpdateStatus.Recorded;
|
||||
return ValueTask.FromResult(new TokenUsageUpdateResult(status, remoteAddress, userAgent));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of refresh token store for development/testing.
|
||||
/// </summary>
|
||||
public sealed class InMemoryRefreshTokenStore : IAuthorityRefreshTokenStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, AuthorityRefreshTokenDocument> _tokens = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public ValueTask<AuthorityRefreshTokenDocument?> FindByTokenIdAsync(string tokenId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
_tokens.TryGetValue(tokenId, out var doc);
|
||||
return ValueTask.FromResult(doc);
|
||||
}
|
||||
|
||||
public ValueTask<AuthorityRefreshTokenDocument?> FindByHandleAsync(string handle, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
var doc = _tokens.Values.FirstOrDefault(t => t.Handle == handle);
|
||||
return ValueTask.FromResult(doc);
|
||||
}
|
||||
|
||||
public ValueTask UpsertAsync(AuthorityRefreshTokenDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
_tokens[document.TokenId] = document;
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
public ValueTask<bool> ConsumeAsync(string tokenId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
if (_tokens.TryGetValue(tokenId, out var doc))
|
||||
{
|
||||
doc.ConsumedAt = DateTimeOffset.UtcNow;
|
||||
return ValueTask.FromResult(true);
|
||||
}
|
||||
return ValueTask.FromResult(false);
|
||||
}
|
||||
|
||||
public ValueTask<int> RevokeBySubjectAsync(string subjectId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
var toRemove = _tokens.Where(kv => kv.Value.SubjectId == subjectId).Select(kv => kv.Key).ToList();
|
||||
foreach (var key in toRemove)
|
||||
{
|
||||
_tokens.TryRemove(key, out _);
|
||||
}
|
||||
return ValueTask.FromResult(toRemove.Count);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of airgap audit store for development/testing.
|
||||
/// </summary>
|
||||
public sealed class InMemoryAirgapAuditStore : IAuthorityAirgapAuditStore
|
||||
{
|
||||
private readonly ConcurrentBag<AuthorityAirgapAuditDocument> _entries = new();
|
||||
|
||||
public ValueTask InsertAsync(AuthorityAirgapAuditDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
_entries.Add(document);
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
public ValueTask<IReadOnlyList<AuthorityAirgapAuditDocument>> ListAsync(int limit, int offset, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
var results = _entries
|
||||
.OrderByDescending(e => e.OccurredAt)
|
||||
.Skip(offset)
|
||||
.Take(limit)
|
||||
.ToList();
|
||||
return ValueTask.FromResult<IReadOnlyList<AuthorityAirgapAuditDocument>>(results);
|
||||
}
|
||||
|
||||
public ValueTask<AuthorityAirgapAuditQueryResult> QueryAsync(AuthorityAirgapAuditQuery query, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(query);
|
||||
|
||||
var filtered = _entries.AsEnumerable();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.Tenant))
|
||||
{
|
||||
filtered = filtered.Where(e => string.Equals(e.Tenant, query.Tenant, StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.BundleId))
|
||||
{
|
||||
filtered = filtered.Where(e => string.Equals(e.BundleId, query.BundleId, StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.Status))
|
||||
{
|
||||
filtered = filtered.Where(e => string.Equals(e.Status, query.Status, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.TraceId))
|
||||
{
|
||||
filtered = filtered.Where(e => string.Equals(e.TraceId, query.TraceId, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
filtered = filtered.OrderByDescending(e => e.OccurredAt).ThenBy(e => e.Id, StringComparer.Ordinal);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.AfterId))
|
||||
{
|
||||
filtered = filtered.SkipWhile(e => !string.Equals(e.Id, query.AfterId, StringComparison.Ordinal)).Skip(1);
|
||||
}
|
||||
|
||||
var take = query.Limit <= 0 ? 50 : query.Limit;
|
||||
var items = filtered.Take(take + 1).ToList();
|
||||
var nextCursor = items.Count > take ? items[^1].Id : null;
|
||||
if (items.Count > take)
|
||||
{
|
||||
items.RemoveAt(items.Count - 1);
|
||||
}
|
||||
|
||||
return ValueTask.FromResult(new AuthorityAirgapAuditQueryResult(items, nextCursor));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of the revocation export state store.
|
||||
/// </summary>
|
||||
public sealed class InMemoryRevocationExportStateStore : IAuthorityRevocationExportStateStore
|
||||
{
|
||||
private readonly SemaphoreSlim gate = new(1, 1);
|
||||
private AuthorityRevocationExportStateDocument state = new() { Sequence = 0 };
|
||||
|
||||
public async ValueTask<AuthorityRevocationExportStateDocument?> GetAsync(CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
await gate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
return state;
|
||||
}
|
||||
finally
|
||||
{
|
||||
gate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask UpdateAsync(long expectedSequence, long newSequence, string bundleId, DateTimeOffset issuedAt, CancellationToken cancellationToken, IClientSessionHandle? session = null)
|
||||
{
|
||||
await gate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
if (state.Sequence != expectedSequence)
|
||||
{
|
||||
throw new InvalidOperationException($"Revocation export sequence mismatch. Expected {expectedSequence}, current {state.Sequence}.");
|
||||
}
|
||||
|
||||
state = new AuthorityRevocationExportStateDocument
|
||||
{
|
||||
Sequence = newSequence,
|
||||
BundleId = bundleId,
|
||||
IssuedAt = issuedAt
|
||||
};
|
||||
}
|
||||
finally
|
||||
{
|
||||
gate.Release();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,609 @@
|
||||
-- Authority Schema: Consolidated Initial Schema
|
||||
-- Consolidated from migrations 001-005 (pre_1.0 archived)
|
||||
-- Creates the complete authority schema for IAM, tenants, users, tokens, RLS, and audit
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- ============================================================================
|
||||
-- SECTION 1: Schema Creation
|
||||
-- ============================================================================
|
||||
|
||||
CREATE SCHEMA IF NOT EXISTS authority;
|
||||
CREATE SCHEMA IF NOT EXISTS authority_app;
|
||||
|
||||
-- ============================================================================
|
||||
-- SECTION 2: Helper Functions
|
||||
-- ============================================================================
|
||||
|
||||
-- Function to update updated_at timestamp
|
||||
CREATE OR REPLACE FUNCTION authority.update_updated_at()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = NOW();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Tenant context helper function for RLS
|
||||
CREATE OR REPLACE FUNCTION authority_app.require_current_tenant()
|
||||
RETURNS TEXT
|
||||
LANGUAGE plpgsql STABLE SECURITY DEFINER
|
||||
AS $$
|
||||
DECLARE
|
||||
v_tenant TEXT;
|
||||
BEGIN
|
||||
v_tenant := current_setting('app.tenant_id', true);
|
||||
IF v_tenant IS NULL OR v_tenant = '' THEN
|
||||
RAISE EXCEPTION 'app.tenant_id session variable not set'
|
||||
USING HINT = 'Set via: SELECT set_config(''app.tenant_id'', ''<tenant>'', false)',
|
||||
ERRCODE = 'P0001';
|
||||
END IF;
|
||||
RETURN v_tenant;
|
||||
END;
|
||||
$$;
|
||||
|
||||
REVOKE ALL ON FUNCTION authority_app.require_current_tenant() FROM PUBLIC;
|
||||
|
||||
-- ============================================================================
|
||||
-- SECTION 3: Core Tables
|
||||
-- ============================================================================
|
||||
|
||||
-- Tenants table (NOT RLS-protected - defines tenant boundaries)
|
||||
CREATE TABLE IF NOT EXISTS authority.tenants (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
display_name TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'suspended', 'deleted')),
|
||||
settings JSONB NOT NULL DEFAULT '{}',
|
||||
metadata JSONB NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
created_by TEXT,
|
||||
updated_by TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX idx_tenants_status ON authority.tenants(status);
|
||||
CREATE INDEX idx_tenants_created_at ON authority.tenants(created_at);
|
||||
|
||||
COMMENT ON TABLE authority.tenants IS
|
||||
'Tenant registry. Not RLS-protected - defines tenant boundaries for the system.';
|
||||
|
||||
-- Users table
|
||||
CREATE TABLE IF NOT EXISTS authority.users (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id TEXT NOT NULL REFERENCES authority.tenants(tenant_id),
|
||||
username TEXT NOT NULL,
|
||||
email TEXT,
|
||||
display_name TEXT,
|
||||
password_hash TEXT,
|
||||
password_salt TEXT,
|
||||
password_algorithm TEXT DEFAULT 'argon2id',
|
||||
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'inactive', 'locked', 'deleted')),
|
||||
email_verified BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
mfa_enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
mfa_secret TEXT,
|
||||
failed_login_attempts INT NOT NULL DEFAULT 0,
|
||||
last_login_at TIMESTAMPTZ,
|
||||
last_password_change_at TIMESTAMPTZ,
|
||||
password_expires_at TIMESTAMPTZ,
|
||||
metadata JSONB NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
created_by TEXT,
|
||||
updated_by TEXT,
|
||||
UNIQUE(tenant_id, username),
|
||||
UNIQUE(tenant_id, email)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_users_tenant_id ON authority.users(tenant_id);
|
||||
CREATE INDEX idx_users_status ON authority.users(tenant_id, status);
|
||||
CREATE INDEX idx_users_email ON authority.users(tenant_id, email);
|
||||
|
||||
-- Roles table
|
||||
CREATE TABLE IF NOT EXISTS authority.roles (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id TEXT NOT NULL REFERENCES authority.tenants(tenant_id),
|
||||
name TEXT NOT NULL,
|
||||
display_name TEXT,
|
||||
description TEXT,
|
||||
is_system BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
metadata JSONB NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE(tenant_id, name)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_roles_tenant_id ON authority.roles(tenant_id);
|
||||
|
||||
-- Permissions table
|
||||
CREATE TABLE IF NOT EXISTS authority.permissions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id TEXT NOT NULL REFERENCES authority.tenants(tenant_id),
|
||||
name TEXT NOT NULL,
|
||||
resource TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
description TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE(tenant_id, name)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_permissions_tenant_id ON authority.permissions(tenant_id);
|
||||
CREATE INDEX idx_permissions_resource ON authority.permissions(tenant_id, resource);
|
||||
|
||||
-- Role-Permission assignments
|
||||
CREATE TABLE IF NOT EXISTS authority.role_permissions (
|
||||
role_id UUID NOT NULL REFERENCES authority.roles(id) ON DELETE CASCADE,
|
||||
permission_id UUID NOT NULL REFERENCES authority.permissions(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (role_id, permission_id)
|
||||
);
|
||||
|
||||
-- User-Role assignments
|
||||
CREATE TABLE IF NOT EXISTS authority.user_roles (
|
||||
user_id UUID NOT NULL REFERENCES authority.users(id) ON DELETE CASCADE,
|
||||
role_id UUID NOT NULL REFERENCES authority.roles(id) ON DELETE CASCADE,
|
||||
granted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
granted_by TEXT,
|
||||
expires_at TIMESTAMPTZ,
|
||||
PRIMARY KEY (user_id, role_id)
|
||||
);
|
||||
|
||||
-- API Keys table
|
||||
CREATE TABLE IF NOT EXISTS authority.api_keys (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id TEXT NOT NULL REFERENCES authority.tenants(tenant_id),
|
||||
user_id UUID REFERENCES authority.users(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
key_hash TEXT NOT NULL,
|
||||
key_prefix TEXT NOT NULL,
|
||||
scopes TEXT[] NOT NULL DEFAULT '{}',
|
||||
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'revoked', 'expired')),
|
||||
last_used_at TIMESTAMPTZ,
|
||||
expires_at TIMESTAMPTZ,
|
||||
metadata JSONB NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
revoked_at TIMESTAMPTZ,
|
||||
revoked_by TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX idx_api_keys_tenant_id ON authority.api_keys(tenant_id);
|
||||
CREATE INDEX idx_api_keys_key_prefix ON authority.api_keys(key_prefix);
|
||||
CREATE INDEX idx_api_keys_user_id ON authority.api_keys(user_id);
|
||||
CREATE INDEX idx_api_keys_status ON authority.api_keys(tenant_id, status);
|
||||
|
||||
-- Tokens table (access tokens)
|
||||
CREATE TABLE IF NOT EXISTS authority.tokens (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id TEXT NOT NULL REFERENCES authority.tenants(tenant_id),
|
||||
user_id UUID REFERENCES authority.users(id) ON DELETE CASCADE,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
token_type TEXT NOT NULL DEFAULT 'access' CHECK (token_type IN ('access', 'refresh', 'api')),
|
||||
scopes TEXT[] NOT NULL DEFAULT '{}',
|
||||
client_id TEXT,
|
||||
issued_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
revoked_at TIMESTAMPTZ,
|
||||
revoked_by TEXT,
|
||||
metadata JSONB NOT NULL DEFAULT '{}'
|
||||
);
|
||||
|
||||
CREATE INDEX idx_tokens_tenant_id ON authority.tokens(tenant_id);
|
||||
CREATE INDEX idx_tokens_user_id ON authority.tokens(user_id);
|
||||
CREATE INDEX idx_tokens_expires_at ON authority.tokens(expires_at);
|
||||
CREATE INDEX idx_tokens_token_hash ON authority.tokens(token_hash);
|
||||
|
||||
-- Refresh Tokens table
|
||||
CREATE TABLE IF NOT EXISTS authority.refresh_tokens (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id TEXT NOT NULL REFERENCES authority.tenants(tenant_id),
|
||||
user_id UUID NOT NULL REFERENCES authority.users(id) ON DELETE CASCADE,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
access_token_id UUID REFERENCES authority.tokens(id) ON DELETE SET NULL,
|
||||
client_id TEXT,
|
||||
issued_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
revoked_at TIMESTAMPTZ,
|
||||
revoked_by TEXT,
|
||||
replaced_by UUID,
|
||||
metadata JSONB NOT NULL DEFAULT '{}'
|
||||
);
|
||||
|
||||
CREATE INDEX idx_refresh_tokens_tenant_id ON authority.refresh_tokens(tenant_id);
|
||||
CREATE INDEX idx_refresh_tokens_user_id ON authority.refresh_tokens(user_id);
|
||||
CREATE INDEX idx_refresh_tokens_expires_at ON authority.refresh_tokens(expires_at);
|
||||
|
||||
-- Sessions table
|
||||
CREATE TABLE IF NOT EXISTS authority.sessions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id TEXT NOT NULL REFERENCES authority.tenants(tenant_id),
|
||||
user_id UUID NOT NULL REFERENCES authority.users(id) ON DELETE CASCADE,
|
||||
session_token_hash TEXT NOT NULL UNIQUE,
|
||||
ip_address TEXT,
|
||||
user_agent TEXT,
|
||||
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
last_activity_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
ended_at TIMESTAMPTZ,
|
||||
end_reason TEXT,
|
||||
metadata JSONB NOT NULL DEFAULT '{}'
|
||||
);
|
||||
|
||||
CREATE INDEX idx_sessions_tenant_id ON authority.sessions(tenant_id);
|
||||
CREATE INDEX idx_sessions_user_id ON authority.sessions(user_id);
|
||||
CREATE INDEX idx_sessions_expires_at ON authority.sessions(expires_at);
|
||||
|
||||
-- Audit log table
|
||||
CREATE TABLE IF NOT EXISTS authority.audit (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
tenant_id TEXT NOT NULL,
|
||||
user_id UUID,
|
||||
action TEXT NOT NULL,
|
||||
resource_type TEXT NOT NULL,
|
||||
resource_id TEXT,
|
||||
old_value JSONB,
|
||||
new_value JSONB,
|
||||
ip_address TEXT,
|
||||
user_agent TEXT,
|
||||
correlation_id TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_audit_tenant_id ON authority.audit(tenant_id);
|
||||
CREATE INDEX idx_audit_user_id ON authority.audit(user_id);
|
||||
CREATE INDEX idx_audit_action ON authority.audit(action);
|
||||
CREATE INDEX idx_audit_resource ON authority.audit(resource_type, resource_id);
|
||||
CREATE INDEX idx_audit_created_at ON authority.audit(created_at);
|
||||
CREATE INDEX idx_audit_correlation_id ON authority.audit(correlation_id);
|
||||
|
||||
-- ============================================================================
|
||||
-- SECTION 4: OIDC and Mongo Store Equivalent Tables
|
||||
-- ============================================================================
|
||||
|
||||
-- Bootstrap invites
|
||||
CREATE TABLE IF NOT EXISTS authority.bootstrap_invites (
|
||||
id TEXT PRIMARY KEY,
|
||||
token TEXT NOT NULL UNIQUE,
|
||||
type TEXT NOT NULL,
|
||||
provider TEXT,
|
||||
target TEXT,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
issued_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
issued_by TEXT,
|
||||
reserved_until TIMESTAMPTZ,
|
||||
reserved_by TEXT,
|
||||
consumed BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
metadata JSONB NOT NULL DEFAULT '{}'
|
||||
);
|
||||
|
||||
-- Service accounts
|
||||
CREATE TABLE IF NOT EXISTS authority.service_accounts (
|
||||
id TEXT PRIMARY KEY,
|
||||
account_id TEXT NOT NULL UNIQUE,
|
||||
tenant TEXT NOT NULL,
|
||||
display_name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
allowed_scopes TEXT[] NOT NULL DEFAULT '{}',
|
||||
authorized_clients TEXT[] NOT NULL DEFAULT '{}',
|
||||
attributes JSONB NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_service_accounts_tenant ON authority.service_accounts(tenant);
|
||||
|
||||
-- Clients
|
||||
CREATE TABLE IF NOT EXISTS authority.clients (
|
||||
id TEXT PRIMARY KEY,
|
||||
client_id TEXT NOT NULL UNIQUE,
|
||||
client_secret TEXT,
|
||||
secret_hash TEXT,
|
||||
display_name TEXT,
|
||||
description TEXT,
|
||||
plugin TEXT,
|
||||
sender_constraint TEXT,
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
redirect_uris TEXT[] NOT NULL DEFAULT '{}',
|
||||
post_logout_redirect_uris TEXT[] NOT NULL DEFAULT '{}',
|
||||
allowed_scopes TEXT[] NOT NULL DEFAULT '{}',
|
||||
allowed_grant_types TEXT[] NOT NULL DEFAULT '{}',
|
||||
require_client_secret BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
require_pkce BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
allow_plain_text_pkce BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
client_type TEXT,
|
||||
properties JSONB NOT NULL DEFAULT '{}',
|
||||
certificate_bindings JSONB NOT NULL DEFAULT '[]',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Revocations
|
||||
CREATE TABLE IF NOT EXISTS authority.revocations (
|
||||
id TEXT PRIMARY KEY,
|
||||
category TEXT NOT NULL,
|
||||
revocation_id TEXT NOT NULL,
|
||||
subject_id TEXT,
|
||||
client_id TEXT,
|
||||
token_id TEXT,
|
||||
reason TEXT NOT NULL,
|
||||
reason_description TEXT,
|
||||
revoked_at TIMESTAMPTZ NOT NULL,
|
||||
effective_at TIMESTAMPTZ NOT NULL,
|
||||
expires_at TIMESTAMPTZ,
|
||||
metadata JSONB NOT NULL DEFAULT '{}'
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_revocations_category_revocation_id
|
||||
ON authority.revocations(category, revocation_id);
|
||||
|
||||
-- Login attempts
|
||||
CREATE TABLE IF NOT EXISTS authority.login_attempts (
|
||||
id TEXT PRIMARY KEY,
|
||||
subject_id TEXT,
|
||||
client_id TEXT,
|
||||
event_type TEXT NOT NULL,
|
||||
outcome TEXT NOT NULL,
|
||||
reason TEXT,
|
||||
ip_address TEXT,
|
||||
user_agent TEXT,
|
||||
occurred_at TIMESTAMPTZ NOT NULL,
|
||||
properties JSONB NOT NULL DEFAULT '[]'
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_login_attempts_subject ON authority.login_attempts(subject_id, occurred_at DESC);
|
||||
|
||||
-- OIDC tokens
|
||||
CREATE TABLE IF NOT EXISTS authority.oidc_tokens (
|
||||
id TEXT PRIMARY KEY,
|
||||
token_id TEXT NOT NULL UNIQUE,
|
||||
subject_id TEXT,
|
||||
client_id TEXT,
|
||||
token_type TEXT NOT NULL,
|
||||
reference_id TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
expires_at TIMESTAMPTZ,
|
||||
redeemed_at TIMESTAMPTZ,
|
||||
payload TEXT,
|
||||
properties JSONB NOT NULL DEFAULT '{}'
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_oidc_tokens_subject ON authority.oidc_tokens(subject_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_oidc_tokens_client ON authority.oidc_tokens(client_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_oidc_tokens_reference ON authority.oidc_tokens(reference_id);
|
||||
|
||||
-- OIDC refresh tokens
|
||||
CREATE TABLE IF NOT EXISTS authority.oidc_refresh_tokens (
|
||||
id TEXT PRIMARY KEY,
|
||||
token_id TEXT NOT NULL UNIQUE,
|
||||
subject_id TEXT,
|
||||
client_id TEXT,
|
||||
handle TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
expires_at TIMESTAMPTZ,
|
||||
consumed_at TIMESTAMPTZ,
|
||||
payload TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_oidc_refresh_tokens_subject ON authority.oidc_refresh_tokens(subject_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_oidc_refresh_tokens_handle ON authority.oidc_refresh_tokens(handle);
|
||||
|
||||
-- Airgap audit
|
||||
CREATE TABLE IF NOT EXISTS authority.airgap_audit (
|
||||
id TEXT PRIMARY KEY,
|
||||
event_type TEXT NOT NULL,
|
||||
operator_id TEXT,
|
||||
component_id TEXT,
|
||||
outcome TEXT NOT NULL,
|
||||
reason TEXT,
|
||||
occurred_at TIMESTAMPTZ NOT NULL,
|
||||
properties JSONB NOT NULL DEFAULT '[]'
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_airgap_audit_occurred_at ON authority.airgap_audit(occurred_at DESC);
|
||||
|
||||
-- Revocation export state (singleton row with optimistic concurrency)
|
||||
CREATE TABLE IF NOT EXISTS authority.revocation_export_state (
|
||||
id INT PRIMARY KEY DEFAULT 1,
|
||||
sequence BIGINT NOT NULL DEFAULT 0,
|
||||
bundle_id TEXT,
|
||||
issued_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
-- Offline Kit Audit
|
||||
CREATE TABLE IF NOT EXISTS authority.offline_kit_audit (
|
||||
event_id UUID PRIMARY KEY,
|
||||
tenant_id TEXT NOT NULL,
|
||||
event_type TEXT NOT NULL,
|
||||
timestamp TIMESTAMPTZ NOT NULL,
|
||||
actor TEXT NOT NULL,
|
||||
details JSONB NOT NULL,
|
||||
result TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_offline_kit_audit_ts ON authority.offline_kit_audit(timestamp DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_offline_kit_audit_type ON authority.offline_kit_audit(event_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_offline_kit_audit_tenant_ts ON authority.offline_kit_audit(tenant_id, timestamp DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_offline_kit_audit_result ON authority.offline_kit_audit(tenant_id, result, timestamp DESC);
|
||||
|
||||
-- Verdict manifests table
|
||||
CREATE TABLE IF NOT EXISTS authority.verdict_manifests (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
manifest_id TEXT NOT NULL,
|
||||
tenant TEXT NOT NULL,
|
||||
asset_digest TEXT NOT NULL,
|
||||
vulnerability_id TEXT NOT NULL,
|
||||
inputs_json JSONB NOT NULL,
|
||||
status TEXT NOT NULL CHECK (status IN ('affected', 'not_affected', 'fixed', 'under_investigation')),
|
||||
confidence DOUBLE PRECISION NOT NULL CHECK (confidence >= 0 AND confidence <= 1),
|
||||
result_json JSONB NOT NULL,
|
||||
policy_hash TEXT NOT NULL,
|
||||
lattice_version TEXT NOT NULL,
|
||||
evaluated_at TIMESTAMPTZ NOT NULL,
|
||||
manifest_digest TEXT NOT NULL,
|
||||
signature_base64 TEXT,
|
||||
rekor_log_id TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT uq_verdict_manifest_id UNIQUE (tenant, manifest_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_verdict_asset_vuln
|
||||
ON authority.verdict_manifests(tenant, asset_digest, vulnerability_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_verdict_policy
|
||||
ON authority.verdict_manifests(tenant, policy_hash, lattice_version);
|
||||
CREATE INDEX IF NOT EXISTS idx_verdict_time
|
||||
ON authority.verdict_manifests USING BRIN (evaluated_at);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_verdict_replay
|
||||
ON authority.verdict_manifests(tenant, asset_digest, vulnerability_id, policy_hash, lattice_version);
|
||||
CREATE INDEX IF NOT EXISTS idx_verdict_digest
|
||||
ON authority.verdict_manifests(manifest_digest);
|
||||
|
||||
COMMENT ON TABLE authority.verdict_manifests IS 'VEX verdict manifests for deterministic replay verification';
|
||||
|
||||
-- ============================================================================
|
||||
-- SECTION 5: Triggers
|
||||
-- ============================================================================
|
||||
|
||||
CREATE TRIGGER trg_tenants_updated_at
|
||||
BEFORE UPDATE ON authority.tenants
|
||||
FOR EACH ROW EXECUTE FUNCTION authority.update_updated_at();
|
||||
|
||||
CREATE TRIGGER trg_users_updated_at
|
||||
BEFORE UPDATE ON authority.users
|
||||
FOR EACH ROW EXECUTE FUNCTION authority.update_updated_at();
|
||||
|
||||
CREATE TRIGGER trg_roles_updated_at
|
||||
BEFORE UPDATE ON authority.roles
|
||||
FOR EACH ROW EXECUTE FUNCTION authority.update_updated_at();
|
||||
|
||||
-- ============================================================================
|
||||
-- SECTION 6: Row-Level Security
|
||||
-- ============================================================================
|
||||
|
||||
-- authority.users
|
||||
ALTER TABLE authority.users ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE authority.users FORCE ROW LEVEL SECURITY;
|
||||
CREATE POLICY users_tenant_isolation ON authority.users
|
||||
FOR ALL
|
||||
USING (tenant_id = authority_app.require_current_tenant())
|
||||
WITH CHECK (tenant_id = authority_app.require_current_tenant());
|
||||
|
||||
-- authority.roles
|
||||
ALTER TABLE authority.roles ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE authority.roles FORCE ROW LEVEL SECURITY;
|
||||
CREATE POLICY roles_tenant_isolation ON authority.roles
|
||||
FOR ALL
|
||||
USING (tenant_id = authority_app.require_current_tenant())
|
||||
WITH CHECK (tenant_id = authority_app.require_current_tenant());
|
||||
|
||||
-- authority.permissions
|
||||
ALTER TABLE authority.permissions ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE authority.permissions FORCE ROW LEVEL SECURITY;
|
||||
CREATE POLICY permissions_tenant_isolation ON authority.permissions
|
||||
FOR ALL
|
||||
USING (tenant_id = authority_app.require_current_tenant())
|
||||
WITH CHECK (tenant_id = authority_app.require_current_tenant());
|
||||
|
||||
-- authority.role_permissions (FK-based, inherits from roles)
|
||||
ALTER TABLE authority.role_permissions ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE authority.role_permissions FORCE ROW LEVEL SECURITY;
|
||||
CREATE POLICY role_permissions_tenant_isolation ON authority.role_permissions
|
||||
FOR ALL
|
||||
USING (
|
||||
role_id IN (
|
||||
SELECT id FROM authority.roles
|
||||
WHERE tenant_id = authority_app.require_current_tenant()
|
||||
)
|
||||
);
|
||||
|
||||
-- authority.user_roles (FK-based, inherits from users)
|
||||
ALTER TABLE authority.user_roles ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE authority.user_roles FORCE ROW LEVEL SECURITY;
|
||||
CREATE POLICY user_roles_tenant_isolation ON authority.user_roles
|
||||
FOR ALL
|
||||
USING (
|
||||
user_id IN (
|
||||
SELECT id FROM authority.users
|
||||
WHERE tenant_id = authority_app.require_current_tenant()
|
||||
)
|
||||
);
|
||||
|
||||
-- authority.api_keys
|
||||
ALTER TABLE authority.api_keys ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE authority.api_keys FORCE ROW LEVEL SECURITY;
|
||||
CREATE POLICY api_keys_tenant_isolation ON authority.api_keys
|
||||
FOR ALL
|
||||
USING (tenant_id = authority_app.require_current_tenant())
|
||||
WITH CHECK (tenant_id = authority_app.require_current_tenant());
|
||||
|
||||
-- authority.tokens
|
||||
ALTER TABLE authority.tokens ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE authority.tokens FORCE ROW LEVEL SECURITY;
|
||||
CREATE POLICY tokens_tenant_isolation ON authority.tokens
|
||||
FOR ALL
|
||||
USING (tenant_id = authority_app.require_current_tenant())
|
||||
WITH CHECK (tenant_id = authority_app.require_current_tenant());
|
||||
|
||||
-- authority.refresh_tokens
|
||||
ALTER TABLE authority.refresh_tokens ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE authority.refresh_tokens FORCE ROW LEVEL SECURITY;
|
||||
CREATE POLICY refresh_tokens_tenant_isolation ON authority.refresh_tokens
|
||||
FOR ALL
|
||||
USING (tenant_id = authority_app.require_current_tenant())
|
||||
WITH CHECK (tenant_id = authority_app.require_current_tenant());
|
||||
|
||||
-- authority.sessions
|
||||
ALTER TABLE authority.sessions ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE authority.sessions FORCE ROW LEVEL SECURITY;
|
||||
CREATE POLICY sessions_tenant_isolation ON authority.sessions
|
||||
FOR ALL
|
||||
USING (tenant_id = authority_app.require_current_tenant())
|
||||
WITH CHECK (tenant_id = authority_app.require_current_tenant());
|
||||
|
||||
-- authority.audit
|
||||
ALTER TABLE authority.audit ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE authority.audit FORCE ROW LEVEL SECURITY;
|
||||
CREATE POLICY audit_tenant_isolation ON authority.audit
|
||||
FOR ALL
|
||||
USING (tenant_id = authority_app.require_current_tenant())
|
||||
WITH CHECK (tenant_id = authority_app.require_current_tenant());
|
||||
|
||||
-- authority.offline_kit_audit
|
||||
ALTER TABLE authority.offline_kit_audit ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE authority.offline_kit_audit FORCE ROW LEVEL SECURITY;
|
||||
CREATE POLICY offline_kit_audit_tenant_isolation ON authority.offline_kit_audit
|
||||
FOR ALL
|
||||
USING (tenant_id = authority_app.require_current_tenant())
|
||||
WITH CHECK (tenant_id = authority_app.require_current_tenant());
|
||||
|
||||
-- authority.verdict_manifests
|
||||
ALTER TABLE authority.verdict_manifests ENABLE ROW LEVEL SECURITY;
|
||||
CREATE POLICY verdict_tenant_isolation ON authority.verdict_manifests
|
||||
USING (tenant = current_setting('app.current_tenant', true))
|
||||
WITH CHECK (tenant = current_setting('app.current_tenant', true));
|
||||
|
||||
-- ============================================================================
|
||||
-- SECTION 7: Roles and Permissions
|
||||
-- ============================================================================
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'authority_admin') THEN
|
||||
CREATE ROLE authority_admin WITH NOLOGIN BYPASSRLS;
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
-- Grant permissions (if role exists)
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT FROM pg_roles WHERE rolname = 'stellaops_app') THEN
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON authority.verdict_manifests TO stellaops_app;
|
||||
GRANT USAGE ON SCHEMA authority TO stellaops_app;
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
COMMIT;
|
||||
@@ -3,7 +3,7 @@ using Microsoft.Extensions.Options;
|
||||
using StellaOps.Infrastructure.Postgres.Connections;
|
||||
using StellaOps.Infrastructure.Postgres.Options;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Postgres;
|
||||
namespace StellaOps.Authority.Persistence.Postgres;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL data source for the Authority module.
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace StellaOps.Authority.Storage.Postgres.Models;
|
||||
namespace StellaOps.Authority.Persistence.Postgres.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Represents an air-gapped audit record.
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace StellaOps.Authority.Storage.Postgres.Models;
|
||||
namespace StellaOps.Authority.Persistence.Postgres.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a bootstrap invite seed.
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace StellaOps.Authority.Storage.Postgres.Models;
|
||||
namespace StellaOps.Authority.Persistence.Postgres.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Represents an OAuth/OpenID Connect client configuration.
|
||||
@@ -22,7 +22,7 @@ public sealed class ClientEntity
|
||||
public bool RequirePkce { get; init; }
|
||||
public bool AllowPlainTextPkce { get; init; }
|
||||
public string? ClientType { get; init; }
|
||||
public IReadOnlyDictionary<string, string> Properties { get; init; } = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
public IReadOnlyDictionary<string, string?> Properties { get; init; } = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
|
||||
public IReadOnlyList<ClientCertificateBindingEntity> CertificateBindings { get; init; } = Array.Empty<ClientCertificateBindingEntity>();
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
public DateTimeOffset UpdatedAt { get; init; }
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace StellaOps.Authority.Storage.Postgres.Models;
|
||||
namespace StellaOps.Authority.Persistence.Postgres.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a login attempt.
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace StellaOps.Authority.Storage.Postgres.Models;
|
||||
namespace StellaOps.Authority.Persistence.Postgres.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Represents an Offline Kit audit record.
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace StellaOps.Authority.Storage.Postgres.Models;
|
||||
namespace StellaOps.Authority.Persistence.Postgres.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Represents an OpenIddict token persisted in PostgreSQL.
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace StellaOps.Authority.Storage.Postgres.Models;
|
||||
namespace StellaOps.Authority.Persistence.Postgres.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a revocation record.
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace StellaOps.Authority.Storage.Postgres.Models;
|
||||
namespace StellaOps.Authority.Persistence.Postgres.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the last exported revocation bundle metadata.
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace StellaOps.Authority.Storage.Postgres.Models;
|
||||
namespace StellaOps.Authority.Persistence.Postgres.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a role entity in the authority schema.
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace StellaOps.Authority.Storage.Postgres.Models;
|
||||
namespace StellaOps.Authority.Persistence.Postgres.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a service account configuration.
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace StellaOps.Authority.Storage.Postgres.Models;
|
||||
namespace StellaOps.Authority.Persistence.Postgres.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a session entity in the authority schema.
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace StellaOps.Authority.Storage.Postgres.Models;
|
||||
namespace StellaOps.Authority.Persistence.Postgres.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a tenant entity in the auth schema.
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace StellaOps.Authority.Storage.Postgres.Models;
|
||||
namespace StellaOps.Authority.Persistence.Postgres.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Represents an access token entity in the authority schema.
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace StellaOps.Authority.Storage.Postgres.Models;
|
||||
namespace StellaOps.Authority.Persistence.Postgres.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a user entity in the auth schema.
|
||||
@@ -1,10 +1,10 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Authority.Storage.Postgres.Models;
|
||||
using StellaOps.Authority.Persistence.Postgres.Models;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Postgres.Repositories;
|
||||
namespace StellaOps.Authority.Persistence.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL repository for airgap audit records.
|
||||
@@ -1,9 +1,9 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Authority.Storage.Postgres.Models;
|
||||
using StellaOps.Authority.Persistence.Postgres.Models;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Postgres.Repositories;
|
||||
namespace StellaOps.Authority.Persistence.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL repository for API key operations.
|
||||
@@ -1,9 +1,9 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Authority.Storage.Postgres.Models;
|
||||
using StellaOps.Authority.Persistence.Postgres.Models;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Postgres.Repositories;
|
||||
namespace StellaOps.Authority.Persistence.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL repository for audit log operations.
|
||||
@@ -1,10 +1,10 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Authority.Storage.Postgres.Models;
|
||||
using StellaOps.Authority.Persistence.Postgres.Models;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Postgres.Repositories;
|
||||
namespace StellaOps.Authority.Persistence.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL repository for bootstrap invites.
|
||||
@@ -1,10 +1,10 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Authority.Storage.Postgres.Models;
|
||||
using StellaOps.Authority.Persistence.Postgres.Models;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Postgres.Repositories;
|
||||
namespace StellaOps.Authority.Persistence.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL repository for OAuth/OpenID clients.
|
||||
@@ -1,6 +1,6 @@
|
||||
using StellaOps.Authority.Storage.Postgres.Models;
|
||||
using StellaOps.Authority.Persistence.Postgres.Models;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Postgres.Repositories;
|
||||
namespace StellaOps.Authority.Persistence.Postgres.Repositories;
|
||||
|
||||
public interface IApiKeyRepository
|
||||
{
|
||||
@@ -1,6 +1,6 @@
|
||||
using StellaOps.Authority.Storage.Postgres.Models;
|
||||
using StellaOps.Authority.Persistence.Postgres.Models;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Postgres.Repositories;
|
||||
namespace StellaOps.Authority.Persistence.Postgres.Repositories;
|
||||
|
||||
public interface IAuditRepository
|
||||
{
|
||||
@@ -1,6 +1,6 @@
|
||||
using StellaOps.Authority.Storage.Postgres.Models;
|
||||
using StellaOps.Authority.Persistence.Postgres.Models;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Postgres.Repositories;
|
||||
namespace StellaOps.Authority.Persistence.Postgres.Repositories;
|
||||
|
||||
public interface IOfflineKitAuditEmitter
|
||||
{
|
||||
@@ -1,6 +1,6 @@
|
||||
using StellaOps.Authority.Storage.Postgres.Models;
|
||||
using StellaOps.Authority.Persistence.Postgres.Models;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Postgres.Repositories;
|
||||
namespace StellaOps.Authority.Persistence.Postgres.Repositories;
|
||||
|
||||
public interface IOfflineKitAuditRepository
|
||||
{
|
||||
@@ -1,6 +1,6 @@
|
||||
using StellaOps.Authority.Storage.Postgres.Models;
|
||||
using StellaOps.Authority.Persistence.Postgres.Models;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Postgres.Repositories;
|
||||
namespace StellaOps.Authority.Persistence.Postgres.Repositories;
|
||||
|
||||
public interface IPermissionRepository
|
||||
{
|
||||
@@ -1,6 +1,6 @@
|
||||
using StellaOps.Authority.Storage.Postgres.Models;
|
||||
using StellaOps.Authority.Persistence.Postgres.Models;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Postgres.Repositories;
|
||||
namespace StellaOps.Authority.Persistence.Postgres.Repositories;
|
||||
|
||||
public interface IRoleRepository
|
||||
{
|
||||
@@ -1,6 +1,6 @@
|
||||
using StellaOps.Authority.Storage.Postgres.Models;
|
||||
using StellaOps.Authority.Persistence.Postgres.Models;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Postgres.Repositories;
|
||||
namespace StellaOps.Authority.Persistence.Postgres.Repositories;
|
||||
|
||||
public interface ISessionRepository
|
||||
{
|
||||
@@ -1,6 +1,6 @@
|
||||
using StellaOps.Authority.Storage.Postgres.Models;
|
||||
using StellaOps.Authority.Persistence.Postgres.Models;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Postgres.Repositories;
|
||||
namespace StellaOps.Authority.Persistence.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Repository interface for tenant operations.
|
||||
@@ -1,6 +1,6 @@
|
||||
using StellaOps.Authority.Storage.Postgres.Models;
|
||||
using StellaOps.Authority.Persistence.Postgres.Models;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Postgres.Repositories;
|
||||
namespace StellaOps.Authority.Persistence.Postgres.Repositories;
|
||||
|
||||
public interface ITokenRepository
|
||||
{
|
||||
@@ -1,6 +1,6 @@
|
||||
using StellaOps.Authority.Storage.Postgres.Models;
|
||||
using StellaOps.Authority.Persistence.Postgres.Models;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Postgres.Repositories;
|
||||
namespace StellaOps.Authority.Persistence.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Repository interface for user operations.
|
||||
@@ -1,10 +1,10 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Authority.Storage.Postgres.Models;
|
||||
using StellaOps.Authority.Persistence.Postgres.Models;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Postgres.Repositories;
|
||||
namespace StellaOps.Authority.Persistence.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL repository for login attempts.
|
||||
@@ -1,7 +1,7 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Authority.Storage.Postgres.Models;
|
||||
using StellaOps.Authority.Persistence.Postgres.Models;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Postgres.Repositories;
|
||||
namespace StellaOps.Authority.Persistence.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Emits Offline Kit audit records to PostgreSQL.
|
||||
@@ -1,9 +1,9 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Authority.Storage.Postgres.Models;
|
||||
using StellaOps.Authority.Persistence.Postgres.Models;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Postgres.Repositories;
|
||||
namespace StellaOps.Authority.Persistence.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL repository for Offline Kit audit records.
|
||||
@@ -1,10 +1,10 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Authority.Storage.Postgres.Models;
|
||||
using StellaOps.Authority.Persistence.Postgres.Models;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Postgres.Repositories;
|
||||
namespace StellaOps.Authority.Persistence.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL repository for OpenIddict tokens and refresh tokens.
|
||||
@@ -1,9 +1,9 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Authority.Storage.Postgres.Models;
|
||||
using StellaOps.Authority.Persistence.Postgres.Models;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Postgres.Repositories;
|
||||
namespace StellaOps.Authority.Persistence.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL repository for permission operations.
|
||||
@@ -1,9 +1,9 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Authority.Storage.Postgres.Models;
|
||||
using StellaOps.Authority.Persistence.Postgres.Models;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Postgres.Repositories;
|
||||
namespace StellaOps.Authority.Persistence.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Repository that persists revocation export sequence state.
|
||||
@@ -1,10 +1,10 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Authority.Storage.Postgres.Models;
|
||||
using StellaOps.Authority.Persistence.Postgres.Models;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Postgres.Repositories;
|
||||
namespace StellaOps.Authority.Persistence.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL repository for revocations.
|
||||
@@ -1,9 +1,9 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Authority.Storage.Postgres.Models;
|
||||
using StellaOps.Authority.Persistence.Postgres.Models;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Postgres.Repositories;
|
||||
namespace StellaOps.Authority.Persistence.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL repository for role operations.
|
||||
@@ -1,10 +1,10 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Authority.Storage.Postgres.Models;
|
||||
using StellaOps.Authority.Persistence.Postgres.Models;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Postgres.Repositories;
|
||||
namespace StellaOps.Authority.Persistence.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL repository for service accounts.
|
||||
@@ -1,9 +1,9 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Authority.Storage.Postgres.Models;
|
||||
using StellaOps.Authority.Persistence.Postgres.Models;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Postgres.Repositories;
|
||||
namespace StellaOps.Authority.Persistence.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL repository for session operations.
|
||||
@@ -1,9 +1,9 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Authority.Storage.Postgres.Models;
|
||||
using StellaOps.Authority.Persistence.Postgres.Models;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Postgres.Repositories;
|
||||
namespace StellaOps.Authority.Persistence.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL repository for tenant operations.
|
||||
@@ -1,9 +1,9 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Authority.Storage.Postgres.Models;
|
||||
using StellaOps.Authority.Persistence.Postgres.Models;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Postgres.Repositories;
|
||||
namespace StellaOps.Authority.Persistence.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL repository for access token operations.
|
||||
@@ -1,9 +1,9 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Authority.Storage.Postgres.Models;
|
||||
using StellaOps.Authority.Persistence.Postgres.Models;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Postgres.Repositories;
|
||||
namespace StellaOps.Authority.Persistence.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL repository for user operations.
|
||||
@@ -1,10 +1,10 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Authority.Storage.Postgres.Repositories;
|
||||
using StellaOps.Authority.Persistence.Postgres.Repositories;
|
||||
using StellaOps.Infrastructure.Postgres;
|
||||
using StellaOps.Infrastructure.Postgres.Options;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Postgres;
|
||||
namespace StellaOps.Authority.Persistence.Postgres;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for configuring Authority PostgreSQL storage services.
|
||||
@@ -3,7 +3,7 @@ using System.Text.Json;
|
||||
using Npgsql;
|
||||
using StellaOps.Authority.Core.Verdicts;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Postgres;
|
||||
namespace StellaOps.Authority.Persistence.Postgres;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL implementation of verdict manifest store.
|
||||
@@ -0,0 +1,34 @@
|
||||
<?xml version="1.0" ?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
<RootNamespace>StellaOps.Authority.Persistence</RootNamespace>
|
||||
<AssemblyName>StellaOps.Authority.Persistence</AssemblyName>
|
||||
<Description>Consolidated persistence layer for StellaOps Authority module (EF Core + Raw SQL)</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="Migrations\*.sql" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" PrivateAssets="all" />
|
||||
<PackageReference Include="Npgsql" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Infrastructure.Postgres\StellaOps.Infrastructure.Postgres.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Infrastructure.EfCore\StellaOps.Infrastructure.EfCore.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Authority.Core\StellaOps.Authority.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,24 +0,0 @@
|
||||
# StellaOps.Authority.Storage.Postgres — Agent Charter
|
||||
|
||||
## Mission
|
||||
Deliver PostgreSQL-backed persistence for Authority (tenants, users, roles, permissions, tokens, refresh tokens, API keys, sessions, audit) per `docs/db/SPECIFICATION.md` §5.1 and enable the Mongo → Postgres dual-write/backfill cutover with deterministic behaviour.
|
||||
|
||||
## Working Directory
|
||||
- `src/Authority/__Libraries/StellaOps.Authority.Storage.Postgres`
|
||||
|
||||
## Required Reading
|
||||
- docs/modules/authority/architecture.md
|
||||
- docs/db/README.md
|
||||
- docs/db/SPECIFICATION.md (Authority schema §5.1; shared rules)
|
||||
- docs/db/RULES.md
|
||||
- docs/db/VERIFICATION.md
|
||||
- docs/db/tasks/PHASE_1_AUTHORITY.md
|
||||
- src/Authority/StellaOps.Authority/AGENTS.md (host integration expectations)
|
||||
|
||||
## Working Agreement
|
||||
- Update related sprint rows in `docs/implplan/SPRINT_*.md` when work starts/finishes; keep statuses `TODO → DOING → DONE/BLOCKED`.
|
||||
- Keep migrations idempotent and deterministic (stable ordering, UTC timestamps). Use NuGet cache at `.nuget/packages/`; no external feeds beyond those in `nuget.config`.
|
||||
- Align schema and repository contracts to `docs/db/SPECIFICATION.md`; mirror any contract/schema change into that spec and the sprint’s Decisions & Risks.
|
||||
- Tests live in `src/Authority/__Tests/StellaOps.Authority.Storage.Postgres.Tests`; maintain deterministic Testcontainers config (fixed image tag, seeded data) and cover all repositories plus determinism of token/refresh generation.
|
||||
- Use `StellaOps.Cryptography` abstractions for password/hash handling; never log secrets or hashes. Ensure transaction boundaries and retries follow `docs/db/RULES.md`.
|
||||
- Coordinate with Authority host service (`StellaOps.Authority`) before altering DI registrations or shared models; avoid cross-module edits unless the sprint explicitly allows and logs them.
|
||||
@@ -1,22 +0,0 @@
|
||||
<?xml version="1.0" ?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
<RootNamespace>StellaOps.Authority.Storage.Postgres</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="Migrations\**\*.sql" LogicalName="%(RecursiveDir)%(Filename)%(Extension)" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Infrastructure.Postgres\StellaOps.Infrastructure.Postgres.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Authority.Core\StellaOps.Authority.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user