up
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
devportal-offline / build-offline (push) Has been cancelled

This commit is contained in:
StellaOps Bot
2025-12-03 00:10:19 +02:00
parent ea1d58a89b
commit 37cba83708
158 changed files with 147438 additions and 867 deletions

View File

@@ -14,7 +14,7 @@ public sealed class AuthorityDataSource : DataSourceBase
/// <summary>
/// Default schema name for Authority tables.
/// </summary>
public const string DefaultSchemaName = "auth";
public const string DefaultSchemaName = "authority";
/// <summary>
/// Creates a new Authority data source.

View File

@@ -0,0 +1,155 @@
using Microsoft.Extensions.Logging;
using StellaOps.Authority.Storage.Postgres.Models;
using StellaOps.Authority.Storage.Postgres.Repositories;
using System.Security.Cryptography;
using System.Text;
namespace StellaOps.Authority.Storage.Postgres.Backfill;
/// <summary>
/// Performs one-way backfill from the secondary (legacy) store into the primary PostgreSQL store.
/// </summary>
public sealed class AuthorityBackfillService
{
private readonly ITokenRepository _primaryTokens;
private readonly ISecondaryTokenRepository _secondaryTokens;
private readonly IRefreshTokenRepository _primaryRefreshTokens;
private readonly ISecondaryRefreshTokenRepository _secondaryRefreshTokens;
private readonly IUserRepository _primaryUsers;
private readonly ISecondaryUserRepository _secondaryUsers;
private readonly ILogger<AuthorityBackfillService> _logger;
public AuthorityBackfillService(
ITokenRepository primaryTokens,
ISecondaryTokenRepository secondaryTokens,
IRefreshTokenRepository primaryRefreshTokens,
ISecondaryRefreshTokenRepository secondaryRefreshTokens,
IUserRepository primaryUsers,
ISecondaryUserRepository secondaryUsers,
ILogger<AuthorityBackfillService> logger)
{
_primaryTokens = primaryTokens;
_secondaryTokens = secondaryTokens;
_primaryRefreshTokens = primaryRefreshTokens;
_secondaryRefreshTokens = secondaryRefreshTokens;
_primaryUsers = primaryUsers;
_secondaryUsers = secondaryUsers;
_logger = logger;
}
public async Task<BackfillResult> BackfillAsync(string tenantId, CancellationToken cancellationToken = default)
{
var users = await _secondaryUsers.GetAllAsync(tenantId, null, int.MaxValue, 0, cancellationToken).ConfigureAwait(false);
var tokensCopied = 0;
var tokensSkipped = 0;
var refreshCopied = 0;
var refreshSkipped = 0;
var primaryTokensSnapshot = new List<TokenEntity>();
var secondaryTokensSnapshot = new List<TokenEntity>();
var primaryRefreshSnapshot = new List<RefreshTokenEntity>();
var secondaryRefreshSnapshot = new List<RefreshTokenEntity>();
foreach (var user in users)
{
cancellationToken.ThrowIfCancellationRequested();
var primaryUser = await _primaryUsers.GetByIdAsync(tenantId, user.Id, cancellationToken).ConfigureAwait(false);
if (primaryUser is null)
{
await _primaryUsers.CreateAsync(user, cancellationToken).ConfigureAwait(false);
}
var secondaryTokens = await _secondaryTokens.GetByUserIdAsync(tenantId, user.Id, cancellationToken).ConfigureAwait(false);
var primaryTokens = await _primaryTokens.GetByUserIdAsync(tenantId, user.Id, cancellationToken).ConfigureAwait(false);
primaryTokensSnapshot.AddRange(primaryTokens);
secondaryTokensSnapshot.AddRange(secondaryTokens);
foreach (var token in secondaryTokens)
{
if (await _primaryTokens.GetByIdAsync(tenantId, token.Id, cancellationToken).ConfigureAwait(false) is null)
{
await _primaryTokens.CreateAsync(tenantId, token, cancellationToken).ConfigureAwait(false);
primaryTokensSnapshot.Add(token);
tokensCopied++;
}
else
{
tokensSkipped++;
}
}
var secondaryRefreshTokens = await _secondaryRefreshTokens.GetByUserIdAsync(tenantId, user.Id, cancellationToken).ConfigureAwait(false);
var primaryRefreshTokens = await _primaryRefreshTokens.GetByUserIdAsync(tenantId, user.Id, cancellationToken).ConfigureAwait(false);
primaryRefreshSnapshot.AddRange(primaryRefreshTokens);
secondaryRefreshSnapshot.AddRange(secondaryRefreshTokens);
foreach (var refresh in secondaryRefreshTokens)
{
if (await _primaryRefreshTokens.GetByIdAsync(tenantId, refresh.Id, cancellationToken).ConfigureAwait(false) is null)
{
await _primaryRefreshTokens.CreateAsync(tenantId, refresh, cancellationToken).ConfigureAwait(false);
primaryRefreshSnapshot.Add(refresh);
refreshCopied++;
}
else
{
refreshSkipped++;
}
}
}
var secondaryChecksum = ComputeChecksums(secondaryTokensSnapshot, secondaryRefreshSnapshot);
var primaryChecksum = ComputeChecksums(primaryTokensSnapshot, primaryRefreshSnapshot);
return new BackfillResult(
tenantId,
users.Count,
tokensCopied,
tokensSkipped,
refreshCopied,
refreshSkipped,
primaryChecksum,
secondaryChecksum);
}
private static BackfillChecksum ComputeChecksums(
IReadOnlyCollection<TokenEntity> tokens,
IReadOnlyCollection<RefreshTokenEntity> refreshTokens)
{
var tokenHash = ComputeHash(tokens.Select(t =>
$"{t.Id}|{t.TenantId}|{t.UserId}|{t.TokenHash}|{t.TokenType}|{t.ExpiresAt.UtcDateTime:o}|{t.RevokedAt?.UtcDateTime:o}|{t.RevokedBy}|{string.Join(',', t.Scopes)}"));
var refreshHash = ComputeHash(refreshTokens.Select(t =>
$"{t.Id}|{t.TenantId}|{t.UserId}|{t.TokenHash}|{t.AccessTokenId}|{t.ClientId}|{t.ExpiresAt.UtcDateTime:o}|{t.RevokedAt?.UtcDateTime:o}|{t.RevokedBy}|{t.ReplacedBy}"));
return new BackfillChecksum(tokens.Count, refreshTokens.Count, tokenHash, refreshHash);
}
private static string ComputeHash(IEnumerable<string> lines)
{
using var sha = SHA256.Create();
foreach (var line in lines.OrderBy(l => l, StringComparer.Ordinal))
{
var bytes = Encoding.UTF8.GetBytes(line);
sha.TransformBlock(bytes, 0, bytes.Length, null, 0);
}
sha.TransformFinalBlock(Array.Empty<byte>(), 0, 0);
return Convert.ToHexString(sha.Hash ?? Array.Empty<byte>());
}
}
public sealed record BackfillChecksum(int TokenCount, int RefreshTokenCount, string TokenChecksum, string RefreshTokenChecksum);
public sealed record BackfillResult(
string TenantId,
int UsersProcessed,
int TokensCopied,
int TokensSkipped,
int RefreshTokensCopied,
int RefreshTokensSkipped,
BackfillChecksum PrimaryChecksum,
BackfillChecksum SecondaryChecksum)
{
public bool ChecksumsMatch =>
PrimaryChecksum.TokenChecksum == SecondaryChecksum.TokenChecksum &&
PrimaryChecksum.RefreshTokenChecksum == SecondaryChecksum.RefreshTokenChecksum &&
PrimaryChecksum.TokenCount == SecondaryChecksum.TokenCount &&
PrimaryChecksum.RefreshTokenCount == SecondaryChecksum.RefreshTokenCount;
}

View File

@@ -0,0 +1,31 @@
using System.Diagnostics.Metrics;
using System.Threading;
namespace StellaOps.Authority.Storage.Postgres;
/// <summary>
/// Captures counters for dual-write operations to aid verification during cutover.
/// </summary>
public sealed class DualWriteMetrics : IDisposable
{
private readonly Meter _meter = new("StellaOps.Authority.Storage.Postgres.DualWrite", "1.0.0");
private readonly Counter<long> _primaryWrites;
private readonly Counter<long> _secondaryWrites;
private readonly Counter<long> _secondaryWriteFailures;
private readonly Counter<long> _fallbackReads;
public DualWriteMetrics()
{
_primaryWrites = _meter.CreateCounter<long>("authority.dualwrite.primary.writes");
_secondaryWrites = _meter.CreateCounter<long>("authority.dualwrite.secondary.writes");
_secondaryWriteFailures = _meter.CreateCounter<long>("authority.dualwrite.secondary.write.failures");
_fallbackReads = _meter.CreateCounter<long>("authority.dualwrite.fallback.reads");
}
public void RecordPrimaryWrite() => _primaryWrites.Add(1);
public void RecordSecondaryWrite() => _secondaryWrites.Add(1);
public void RecordSecondaryWriteFailure() => _secondaryWriteFailures.Add(1);
public void RecordFallbackRead() => _fallbackReads.Add(1);
public void Dispose() => _meter.Dispose();
}

View File

@@ -0,0 +1,46 @@
using System.Diagnostics.CodeAnalysis;
namespace StellaOps.Authority.Storage.Postgres;
/// <summary>
/// Options controlling dual-write behaviour during Mongo → PostgreSQL cutover.
/// </summary>
public sealed class DualWriteOptions
{
/// <summary>
/// Whether dual-write is enabled. When false, repositories run primary-only.
/// </summary>
public bool Enabled { get; set; }
/// <summary>
/// When true, write operations are attempted against both primary and secondary repositories.
/// </summary>
public bool WriteSecondary { get; set; } = true;
/// <summary>
/// When true, reads will fall back to the secondary repository if the primary has no result.
/// </summary>
public bool FallbackToSecondary { get; set; } = true;
/// <summary>
/// When true, any secondary write failure is logged but does not throw; primary success is preserved.
/// </summary>
public bool LogSecondaryFailuresOnly { get; set; } = true;
/// <summary>
/// When true, secondary write/read failures propagate to callers.
/// </summary>
public bool FailFastOnSecondary { get; set; }
/// <summary>
/// Optional tag describing which backend is primary (for metrics/logging only).
/// </summary>
[AllowNull]
public string PrimaryBackend { get; set; } = "Postgres";
/// <summary>
/// Optional tag describing which backend is secondary (for metrics/logging only).
/// </summary>
[AllowNull]
public string? SecondaryBackend { get; set; } = "Mongo";
}

View File

@@ -0,0 +1,175 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Authority.Storage.Postgres.Models;
namespace StellaOps.Authority.Storage.Postgres.Repositories;
/// <summary>
/// Decorator that writes refresh tokens to both primary and secondary stores during cutover.
/// </summary>
public sealed class DualWriteRefreshTokenRepository : IRefreshTokenRepository
{
private readonly IRefreshTokenRepository _primary;
private readonly ISecondaryRefreshTokenRepository _secondary;
private readonly DualWriteOptions _options;
private readonly DualWriteMetrics _metrics;
private readonly ILogger<DualWriteRefreshTokenRepository> _logger;
public DualWriteRefreshTokenRepository(
IRefreshTokenRepository primary,
ISecondaryRefreshTokenRepository secondary,
IOptions<DualWriteOptions> options,
DualWriteMetrics metrics,
ILogger<DualWriteRefreshTokenRepository> logger)
{
_primary = primary;
_secondary = secondary;
_options = options.Value;
_metrics = metrics;
_logger = logger;
}
public async Task<RefreshTokenEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
var primary = await _primary.GetByIdAsync(tenantId, id, cancellationToken).ConfigureAwait(false);
if (primary is not null || !_options.FallbackToSecondary)
{
return primary;
}
var secondary = await SafeSecondaryCall(() => _secondary.GetByIdAsync(tenantId, id, cancellationToken)).ConfigureAwait(false);
if (secondary is not null)
{
_metrics.RecordFallbackRead();
_logger.LogInformation("Dual-write fallback refresh token hit for tenant {TenantId} token {TokenId}", tenantId, id);
}
return secondary;
}
public async Task<RefreshTokenEntity?> GetByHashAsync(string tokenHash, CancellationToken cancellationToken = default)
{
var primary = await _primary.GetByHashAsync(tokenHash, cancellationToken).ConfigureAwait(false);
if (primary is not null || !_options.FallbackToSecondary)
{
return primary;
}
var secondary = await SafeSecondaryCall(() => _secondary.GetByHashAsync(tokenHash, cancellationToken)).ConfigureAwait(false);
if (secondary is not null)
{
_metrics.RecordFallbackRead();
_logger.LogInformation("Dual-write fallback refresh token hash hit for {Hash}", tokenHash);
}
return secondary;
}
public async Task<IReadOnlyList<RefreshTokenEntity>> GetByUserIdAsync(string tenantId, Guid userId, CancellationToken cancellationToken = default)
{
var primary = await _primary.GetByUserIdAsync(tenantId, userId, cancellationToken).ConfigureAwait(false);
if (primary.Count > 0 || !_options.FallbackToSecondary)
{
return primary;
}
var secondary = await SafeSecondaryCall(() => _secondary.GetByUserIdAsync(tenantId, userId, cancellationToken)).ConfigureAwait(false);
if (secondary.Count > 0)
{
_metrics.RecordFallbackRead();
_logger.LogInformation("Dual-write fallback refresh tokens for tenant {TenantId} user {UserId}", tenantId, userId);
}
return secondary;
}
public async Task<Guid> CreateAsync(string tenantId, RefreshTokenEntity token, CancellationToken cancellationToken = default)
{
var id = await _primary.CreateAsync(tenantId, token, cancellationToken).ConfigureAwait(false);
_metrics.RecordPrimaryWrite();
if (_options.WriteSecondary)
{
await SafeSecondaryWrite(async () =>
{
await _secondary.CreateAsync(tenantId, token, cancellationToken).ConfigureAwait(false);
}, tenantId, token.Id);
}
return id;
}
public async Task RevokeAsync(string tenantId, Guid id, string revokedBy, Guid? replacedBy, CancellationToken cancellationToken = default)
{
await _primary.RevokeAsync(tenantId, id, revokedBy, replacedBy, cancellationToken).ConfigureAwait(false);
_metrics.RecordPrimaryWrite();
if (_options.WriteSecondary)
{
await SafeSecondaryWrite(() => _secondary.RevokeAsync(tenantId, id, revokedBy, replacedBy, cancellationToken), tenantId, id);
}
}
public async Task RevokeByUserIdAsync(string tenantId, Guid userId, string revokedBy, CancellationToken cancellationToken = default)
{
await _primary.RevokeByUserIdAsync(tenantId, userId, revokedBy, cancellationToken).ConfigureAwait(false);
_metrics.RecordPrimaryWrite();
if (_options.WriteSecondary)
{
await SafeSecondaryWrite(() => _secondary.RevokeByUserIdAsync(tenantId, userId, revokedBy, cancellationToken), tenantId, userId);
}
}
public async Task DeleteExpiredAsync(CancellationToken cancellationToken = default)
{
await _primary.DeleteExpiredAsync(cancellationToken).ConfigureAwait(false);
_metrics.RecordPrimaryWrite();
if (_options.WriteSecondary)
{
await SafeSecondaryWrite(() => _secondary.DeleteExpiredAsync(cancellationToken), tenantId: "system", id: Guid.Empty);
}
}
private async Task<T> SafeSecondaryCall<T>(Func<Task<T>> call)
{
try
{
return await call().ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Dual-write secondary refresh read failed for backend {Backend}", _options.SecondaryBackend);
if (_options.FailFastOnSecondary && !_options.LogSecondaryFailuresOnly)
{
throw;
}
return default!;
}
}
private async Task SafeSecondaryWrite(Func<Task> call, string tenantId, Guid id)
{
try
{
await call().ConfigureAwait(false);
_metrics.RecordSecondaryWrite();
}
catch (Exception ex)
{
_metrics.RecordSecondaryWriteFailure();
_logger.LogWarning(ex,
"Dual-write secondary refresh write failed for tenant {TenantId}, id {Id}, primary={Primary}, secondary={Secondary}",
tenantId,
id,
_options.PrimaryBackend,
_options.SecondaryBackend);
if (_options.FailFastOnSecondary && !_options.LogSecondaryFailuresOnly)
{
throw;
}
}
}
}

View File

@@ -0,0 +1,175 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Authority.Storage.Postgres.Models;
namespace StellaOps.Authority.Storage.Postgres.Repositories;
/// <summary>
/// Decorator that writes to both primary (PostgreSQL) and secondary (legacy/Mongo) stores during cutover.
/// </summary>
public sealed class DualWriteTokenRepository : ITokenRepository
{
private readonly ITokenRepository _primary;
private readonly ISecondaryTokenRepository _secondary;
private readonly DualWriteOptions _options;
private readonly DualWriteMetrics _metrics;
private readonly ILogger<DualWriteTokenRepository> _logger;
public DualWriteTokenRepository(
ITokenRepository primary,
ISecondaryTokenRepository secondary,
IOptions<DualWriteOptions> options,
DualWriteMetrics metrics,
ILogger<DualWriteTokenRepository> logger)
{
_primary = primary;
_secondary = secondary;
_options = options.Value;
_metrics = metrics;
_logger = logger;
}
public async Task<TokenEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
var primary = await _primary.GetByIdAsync(tenantId, id, cancellationToken).ConfigureAwait(false);
if (primary is not null || !_options.FallbackToSecondary)
{
return primary;
}
var secondary = await SafeSecondaryCall(() => _secondary.GetByIdAsync(tenantId, id, cancellationToken)).ConfigureAwait(false);
if (secondary is not null)
{
_metrics.RecordFallbackRead();
_logger.LogInformation("Dual-write fallback token hit for tenant {TenantId} token {TokenId}", tenantId, id);
}
return secondary;
}
public async Task<TokenEntity?> GetByHashAsync(string tokenHash, CancellationToken cancellationToken = default)
{
var primary = await _primary.GetByHashAsync(tokenHash, cancellationToken).ConfigureAwait(false);
if (primary is not null || !_options.FallbackToSecondary)
{
return primary;
}
var secondary = await SafeSecondaryCall(() => _secondary.GetByHashAsync(tokenHash, cancellationToken)).ConfigureAwait(false);
if (secondary is not null)
{
_metrics.RecordFallbackRead();
_logger.LogInformation("Dual-write fallback token hash hit for {Hash}", tokenHash);
}
return secondary;
}
public async Task<IReadOnlyList<TokenEntity>> GetByUserIdAsync(string tenantId, Guid userId, CancellationToken cancellationToken = default)
{
var primary = await _primary.GetByUserIdAsync(tenantId, userId, cancellationToken).ConfigureAwait(false);
if (primary.Count > 0 || !_options.FallbackToSecondary)
{
return primary;
}
var secondary = await SafeSecondaryCall(() => _secondary.GetByUserIdAsync(tenantId, userId, cancellationToken)).ConfigureAwait(false);
if (secondary.Count > 0)
{
_metrics.RecordFallbackRead();
_logger.LogInformation("Dual-write fallback tokens for tenant {TenantId} user {UserId}", tenantId, userId);
}
return secondary;
}
public async Task<Guid> CreateAsync(string tenantId, TokenEntity token, CancellationToken cancellationToken = default)
{
var id = await _primary.CreateAsync(tenantId, token, cancellationToken).ConfigureAwait(false);
_metrics.RecordPrimaryWrite();
if (_options.WriteSecondary)
{
await SafeSecondaryWrite(async () =>
{
await _secondary.CreateAsync(tenantId, token, cancellationToken).ConfigureAwait(false);
}, tenantId, token.Id);
}
return id;
}
public async Task RevokeAsync(string tenantId, Guid id, string revokedBy, CancellationToken cancellationToken = default)
{
await _primary.RevokeAsync(tenantId, id, revokedBy, cancellationToken).ConfigureAwait(false);
_metrics.RecordPrimaryWrite();
if (_options.WriteSecondary)
{
await SafeSecondaryWrite(() => _secondary.RevokeAsync(tenantId, id, revokedBy, cancellationToken), tenantId, id);
}
}
public async Task RevokeByUserIdAsync(string tenantId, Guid userId, string revokedBy, CancellationToken cancellationToken = default)
{
await _primary.RevokeByUserIdAsync(tenantId, userId, revokedBy, cancellationToken).ConfigureAwait(false);
_metrics.RecordPrimaryWrite();
if (_options.WriteSecondary)
{
await SafeSecondaryWrite(() => _secondary.RevokeByUserIdAsync(tenantId, userId, revokedBy, cancellationToken), tenantId, userId);
}
}
public async Task DeleteExpiredAsync(CancellationToken cancellationToken = default)
{
await _primary.DeleteExpiredAsync(cancellationToken).ConfigureAwait(false);
_metrics.RecordPrimaryWrite();
if (_options.WriteSecondary)
{
await SafeSecondaryWrite(() => _secondary.DeleteExpiredAsync(cancellationToken), tenantId: "system", id: Guid.Empty);
}
}
private async Task<T> SafeSecondaryCall<T>(Func<Task<T>> call)
{
try
{
return await call().ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Dual-write secondary read failed for backend {Backend}", _options.SecondaryBackend);
if (_options.FailFastOnSecondary && !_options.LogSecondaryFailuresOnly)
{
throw;
}
return default!;
}
}
private async Task SafeSecondaryWrite(Func<Task> call, string tenantId, Guid id)
{
try
{
await call().ConfigureAwait(false);
_metrics.RecordSecondaryWrite();
}
catch (Exception ex)
{
_metrics.RecordSecondaryWriteFailure();
_logger.LogWarning(ex,
"Dual-write secondary write failed for tenant {TenantId}, id {Id}, primary={Primary}, secondary={Secondary}",
tenantId,
id,
_options.PrimaryBackend,
_options.SecondaryBackend);
if (_options.FailFastOnSecondary && !_options.LogSecondaryFailuresOnly)
{
throw;
}
}
}
}

View File

@@ -0,0 +1,106 @@
using StellaOps.Authority.Storage.Postgres.Models;
namespace StellaOps.Authority.Storage.Postgres.Repositories;
/// <summary>
/// Marker interface for secondary (legacy/Mongo) token repository.
/// </summary>
public interface ISecondaryTokenRepository : ITokenRepository { }
/// <summary>
/// Marker interface for secondary refresh token repository.
/// </summary>
public interface ISecondaryRefreshTokenRepository : IRefreshTokenRepository { }
/// <summary>
/// Marker interface for secondary user repository.
/// </summary>
public interface ISecondaryUserRepository : IUserRepository { }
/// <summary>
/// No-op secondary token repository used when dual-write is enabled without a configured secondary backend.
/// </summary>
internal sealed class NullSecondaryTokenRepository : ISecondaryTokenRepository
{
public Task<TokenEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default) =>
Task.FromResult<TokenEntity?>(null);
public Task<TokenEntity?> GetByHashAsync(string tokenHash, CancellationToken cancellationToken = default) =>
Task.FromResult<TokenEntity?>(null);
public Task<IReadOnlyList<TokenEntity>> GetByUserIdAsync(string tenantId, Guid userId, CancellationToken cancellationToken = default) =>
Task.FromResult<IReadOnlyList<TokenEntity>>(Array.Empty<TokenEntity>());
public Task<Guid> CreateAsync(string tenantId, TokenEntity token, CancellationToken cancellationToken = default) =>
Task.FromResult(token.Id == Guid.Empty ? Guid.NewGuid() : token.Id);
public Task RevokeAsync(string tenantId, Guid id, string revokedBy, CancellationToken cancellationToken = default) =>
Task.CompletedTask;
public Task RevokeByUserIdAsync(string tenantId, Guid userId, string revokedBy, CancellationToken cancellationToken = default) =>
Task.CompletedTask;
public Task DeleteExpiredAsync(CancellationToken cancellationToken = default) => Task.CompletedTask;
}
/// <summary>
/// No-op secondary refresh token repository used when dual-write is enabled without a configured secondary backend.
/// </summary>
internal sealed class NullSecondaryRefreshTokenRepository : ISecondaryRefreshTokenRepository
{
public Task<RefreshTokenEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default) =>
Task.FromResult<RefreshTokenEntity?>(null);
public Task<RefreshTokenEntity?> GetByHashAsync(string tokenHash, CancellationToken cancellationToken = default) =>
Task.FromResult<RefreshTokenEntity?>(null);
public Task<IReadOnlyList<RefreshTokenEntity>> GetByUserIdAsync(string tenantId, Guid userId, CancellationToken cancellationToken = default) =>
Task.FromResult<IReadOnlyList<RefreshTokenEntity>>(Array.Empty<RefreshTokenEntity>());
public Task<Guid> CreateAsync(string tenantId, RefreshTokenEntity token, CancellationToken cancellationToken = default) =>
Task.FromResult(token.Id == Guid.Empty ? Guid.NewGuid() : token.Id);
public Task RevokeAsync(string tenantId, Guid id, string revokedBy, Guid? replacedBy, CancellationToken cancellationToken = default) =>
Task.CompletedTask;
public Task RevokeByUserIdAsync(string tenantId, Guid userId, string revokedBy, CancellationToken cancellationToken = default) =>
Task.CompletedTask;
public Task DeleteExpiredAsync(CancellationToken cancellationToken = default) => Task.CompletedTask;
}
/// <summary>
/// No-op secondary user repository used when dual-write is enabled without a configured secondary backend.
/// </summary>
internal sealed class NullSecondaryUserRepository : ISecondaryUserRepository
{
public Task<UserEntity> CreateAsync(UserEntity user, CancellationToken cancellationToken = default) =>
Task.FromResult(user);
public Task<UserEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default) =>
Task.FromResult<UserEntity?>(null);
public Task<UserEntity?> GetByUsernameAsync(string tenantId, string username, CancellationToken cancellationToken = default) =>
Task.FromResult<UserEntity?>(null);
public Task<UserEntity?> GetByEmailAsync(string tenantId, string email, CancellationToken cancellationToken = default) =>
Task.FromResult<UserEntity?>(null);
public Task<IReadOnlyList<UserEntity>> GetAllAsync(string tenantId, bool? enabled = null, int limit = 100, int offset = 0, CancellationToken cancellationToken = default) =>
Task.FromResult<IReadOnlyList<UserEntity>>(Array.Empty<UserEntity>());
public Task<bool> UpdateAsync(UserEntity user, CancellationToken cancellationToken = default) =>
Task.FromResult(false);
public Task<bool> DeleteAsync(string tenantId, Guid id, CancellationToken cancellationToken = default) =>
Task.FromResult(false);
public Task<bool> UpdatePasswordAsync(string tenantId, Guid userId, string passwordHash, string passwordSalt, CancellationToken cancellationToken = default) =>
Task.FromResult(false);
public Task<int> RecordFailedLoginAsync(string tenantId, Guid userId, DateTimeOffset? lockUntil = null, CancellationToken cancellationToken = default) =>
Task.FromResult(0);
public Task RecordSuccessfulLoginAsync(string tenantId, Guid userId, CancellationToken cancellationToken = default) =>
Task.CompletedTask;
}

View File

@@ -24,7 +24,7 @@ public sealed class TenantRepository : RepositoryBase<AuthorityDataSource>, ITen
public async Task<TenantEntity> CreateAsync(TenantEntity tenant, CancellationToken cancellationToken = default)
{
const string sql = """
INSERT INTO auth.tenants (id, slug, name, description, contact_email, enabled, settings, metadata, created_by)
INSERT INTO authority.tenants (id, slug, name, description, contact_email, enabled, settings, metadata, created_by)
VALUES (@id, @slug, @name, @description, @contact_email, @enabled, @settings::jsonb, @metadata::jsonb, @created_by)
RETURNING id, slug, name, description, contact_email, enabled, settings::text, metadata::text, created_at, updated_at, created_by
""";
@@ -53,7 +53,7 @@ public sealed class TenantRepository : RepositoryBase<AuthorityDataSource>, ITen
{
const string sql = """
SELECT id, slug, name, description, contact_email, enabled, settings::text, metadata::text, created_at, updated_at, created_by
FROM auth.tenants
FROM authority.tenants
WHERE id = @id
""";
@@ -70,7 +70,7 @@ public sealed class TenantRepository : RepositoryBase<AuthorityDataSource>, ITen
{
const string sql = """
SELECT id, slug, name, description, contact_email, enabled, settings::text, metadata::text, created_at, updated_at, created_by
FROM auth.tenants
FROM authority.tenants
WHERE slug = @slug
""";
@@ -91,7 +91,7 @@ public sealed class TenantRepository : RepositoryBase<AuthorityDataSource>, ITen
{
var sql = """
SELECT id, slug, name, description, contact_email, enabled, settings::text, metadata::text, created_at, updated_at, created_by
FROM auth.tenants
FROM authority.tenants
""";
if (enabled.HasValue)

View File

@@ -1,5 +1,9 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Authority.Storage.Postgres.Backfill;
using StellaOps.Authority.Storage.Postgres.Repositories;
using StellaOps.Infrastructure.Postgres;
using StellaOps.Infrastructure.Postgres.Options;
@@ -24,19 +28,11 @@ public static class ServiceCollectionExtensions
string sectionName = "Postgres:Authority")
{
services.Configure<PostgresOptions>(sectionName, configuration.GetSection(sectionName));
services.AddSingleton<AuthorityDataSource>();
// Register repositories
services.AddScoped<ITenantRepository, TenantRepository>();
services.AddScoped<IUserRepository, UserRepository>();
services.AddScoped<IRoleRepository, RoleRepository>();
services.AddScoped<IPermissionRepository, PermissionRepository>();
services.AddScoped<ITokenRepository, TokenRepository>();
services.AddScoped<IRefreshTokenRepository, RefreshTokenRepository>();
services.AddScoped<IApiKeyRepository, ApiKeyRepository>();
services.AddScoped<ISessionRepository, SessionRepository>();
services.AddScoped<IAuditRepository, AuditRepository>();
var dualWriteSection = configuration.GetSection($"{sectionName}:DualWrite");
services.Configure<DualWriteOptions>(dualWriteSection);
var dualWriteEnabled = dualWriteSection.GetValue<bool>("Enabled");
RegisterAuthorityServices(services, dualWriteEnabled);
return services;
}
@@ -51,19 +47,62 @@ public static class ServiceCollectionExtensions
Action<PostgresOptions> configureOptions)
{
services.Configure(configureOptions);
services.AddSingleton<AuthorityDataSource>();
// Register repositories
services.AddScoped<ITenantRepository, TenantRepository>();
services.AddScoped<IUserRepository, UserRepository>();
services.AddScoped<IRoleRepository, RoleRepository>();
services.AddScoped<IPermissionRepository, PermissionRepository>();
services.AddScoped<ITokenRepository, TokenRepository>();
services.AddScoped<IRefreshTokenRepository, RefreshTokenRepository>();
services.AddScoped<IApiKeyRepository, ApiKeyRepository>();
services.AddScoped<ISessionRepository, SessionRepository>();
services.AddScoped<IAuditRepository, AuditRepository>();
RegisterAuthorityServices(services, dualWriteEnabled: false);
return services;
}
private static void RegisterAuthorityServices(IServiceCollection services, bool dualWriteEnabled)
{
services.AddSingleton<AuthorityDataSource>();
services.AddSingleton<DualWriteMetrics>();
// Primary repositories
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>());
if (dualWriteEnabled)
{
services.TryAddScoped<ISecondaryTokenRepository, NullSecondaryTokenRepository>();
services.TryAddScoped<ISecondaryRefreshTokenRepository, NullSecondaryRefreshTokenRepository>();
services.TryAddScoped<ISecondaryUserRepository, NullSecondaryUserRepository>();
services.AddScoped<ITokenRepository>(sp => new DualWriteTokenRepository(
sp.GetRequiredService<TokenRepository>(),
sp.GetRequiredService<ISecondaryTokenRepository>(),
sp.GetRequiredService<IOptions<DualWriteOptions>>(),
sp.GetRequiredService<DualWriteMetrics>(),
sp.GetRequiredService<ILogger<DualWriteTokenRepository>>()));
services.AddScoped<IRefreshTokenRepository>(sp => new DualWriteRefreshTokenRepository(
sp.GetRequiredService<RefreshTokenRepository>(),
sp.GetRequiredService<ISecondaryRefreshTokenRepository>(),
sp.GetRequiredService<IOptions<DualWriteOptions>>(),
sp.GetRequiredService<DualWriteMetrics>(),
sp.GetRequiredService<ILogger<DualWriteRefreshTokenRepository>>()));
// Backfill service available only when dual-write is enabled.
services.AddScoped<AuthorityBackfillService>();
}
else
{
services.AddScoped<ITokenRepository>(sp => sp.GetRequiredService<TokenRepository>());
services.AddScoped<IRefreshTokenRepository>(sp => sp.GetRequiredService<RefreshTokenRepository>());
}
}
}