Fix build and code structure improvements. New but essential UI functionality. CI improvements. Documentation improvements. AI module improvements.

This commit is contained in:
StellaOps Bot
2025-12-26 21:54:17 +02:00
parent 335ff7da16
commit c2b9cd8d1f
3717 changed files with 264714 additions and 48202 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
namespace StellaOps.Authority.Storage.Postgres.Models;
namespace StellaOps.Authority.Persistence.Postgres.Models;
/// <summary>
/// Represents an air-gapped audit record.

View File

@@ -1,4 +1,4 @@
namespace StellaOps.Authority.Storage.Postgres.Models;
namespace StellaOps.Authority.Persistence.Postgres.Models;
/// <summary>
/// Represents a bootstrap invite seed.

View File

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

View File

@@ -1,4 +1,4 @@
namespace StellaOps.Authority.Storage.Postgres.Models;
namespace StellaOps.Authority.Persistence.Postgres.Models;
/// <summary>
/// Represents a login attempt.

View File

@@ -1,4 +1,4 @@
namespace StellaOps.Authority.Storage.Postgres.Models;
namespace StellaOps.Authority.Persistence.Postgres.Models;
/// <summary>
/// Represents an Offline Kit audit record.

View File

@@ -1,4 +1,4 @@
namespace StellaOps.Authority.Storage.Postgres.Models;
namespace StellaOps.Authority.Persistence.Postgres.Models;
/// <summary>
/// Represents an OpenIddict token persisted in PostgreSQL.

View File

@@ -1,4 +1,4 @@
namespace StellaOps.Authority.Storage.Postgres.Models;
namespace StellaOps.Authority.Persistence.Postgres.Models;
/// <summary>
/// Represents a revocation record.

View File

@@ -1,4 +1,4 @@
namespace StellaOps.Authority.Storage.Postgres.Models;
namespace StellaOps.Authority.Persistence.Postgres.Models;
/// <summary>
/// Represents the last exported revocation bundle metadata.

View File

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

View File

@@ -1,4 +1,4 @@
namespace StellaOps.Authority.Storage.Postgres.Models;
namespace StellaOps.Authority.Persistence.Postgres.Models;
/// <summary>
/// Represents a service account configuration.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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