Refactor and update test projects, remove obsolete tests, and upgrade dependencies

- Deleted obsolete test files for SchedulerAuditService and SchedulerMongoSessionFactory.
- Removed unused TestDataFactory class.
- Updated project files for Mongo.Tests to remove references to deleted files.
- Upgraded BouncyCastle.Cryptography package to version 2.6.2 across multiple projects.
- Replaced Microsoft.Extensions.Http.Polly with Microsoft.Extensions.Http.Resilience in Zastava.Webhook project.
- Updated NetEscapades.Configuration.Yaml package to version 3.1.0 in Configuration library.
- Upgraded Pkcs11Interop package to version 5.1.2 in Cryptography libraries.
- Refactored Argon2idPasswordHasher to use BouncyCastle for hashing instead of Konscious.
- Updated JsonSchema.Net package to version 7.3.2 in Microservice project.
- Updated global.json to use .NET SDK version 10.0.101.
This commit is contained in:
master
2025-12-10 19:13:29 +02:00
parent a3c7fe5e88
commit b7059d523e
369 changed files with 11125 additions and 14245 deletions

View File

@@ -31,8 +31,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Authority.Plugin.
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Plugin", "..\__Libraries\StellaOps.Plugin\StellaOps.Plugin.csproj", "{7F9552C7-7E41-4EA6-9F5E-17E8049C9F10}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Authority.Storage.Mongo", "StellaOps.Authority\StellaOps.Authority.Storage.Mongo\StellaOps.Authority.Storage.Mongo.csproj", "{1FFF91AB-C2D2-4A12-A77B-AB9806116F7A}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography.DependencyInjection", "..\__Libraries\StellaOps.Cryptography.DependencyInjection\StellaOps.Cryptography.DependencyInjection.csproj", "{208FE840-FFDD-43A5-9F64-F1F3C45C51F7}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Auth.Security", "..\__Libraries\StellaOps.Auth.Security\StellaOps.Auth.Security.csproj", "{6EE9BB3A-A55F-4FDC-95F1-9304DB341AB1}"
@@ -209,18 +207,6 @@ Global
{7F9552C7-7E41-4EA6-9F5E-17E8049C9F10}.Release|x64.Build.0 = Release|Any CPU
{7F9552C7-7E41-4EA6-9F5E-17E8049C9F10}.Release|x86.ActiveCfg = Release|Any CPU
{7F9552C7-7E41-4EA6-9F5E-17E8049C9F10}.Release|x86.Build.0 = Release|Any CPU
{1FFF91AB-C2D2-4A12-A77B-AB9806116F7A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{1FFF91AB-C2D2-4A12-A77B-AB9806116F7A}.Debug|Any CPU.Build.0 = Debug|Any CPU
{1FFF91AB-C2D2-4A12-A77B-AB9806116F7A}.Debug|x64.ActiveCfg = Debug|Any CPU
{1FFF91AB-C2D2-4A12-A77B-AB9806116F7A}.Debug|x64.Build.0 = Debug|Any CPU
{1FFF91AB-C2D2-4A12-A77B-AB9806116F7A}.Debug|x86.ActiveCfg = Debug|Any CPU
{1FFF91AB-C2D2-4A12-A77B-AB9806116F7A}.Debug|x86.Build.0 = Debug|Any CPU
{1FFF91AB-C2D2-4A12-A77B-AB9806116F7A}.Release|Any CPU.ActiveCfg = Release|Any CPU
{1FFF91AB-C2D2-4A12-A77B-AB9806116F7A}.Release|Any CPU.Build.0 = Release|Any CPU
{1FFF91AB-C2D2-4A12-A77B-AB9806116F7A}.Release|x64.ActiveCfg = Release|Any CPU
{1FFF91AB-C2D2-4A12-A77B-AB9806116F7A}.Release|x64.Build.0 = Release|Any CPU
{1FFF91AB-C2D2-4A12-A77B-AB9806116F7A}.Release|x86.ActiveCfg = Release|Any CPU
{1FFF91AB-C2D2-4A12-A77B-AB9806116F7A}.Release|x86.Build.0 = Release|Any CPU
{208FE840-FFDD-43A5-9F64-F1F3C45C51F7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{208FE840-FFDD-43A5-9F64-F1F3C45C51F7}.Debug|Any CPU.Build.0 = Debug|Any CPU
{208FE840-FFDD-43A5-9F64-F1F3C45C51F7}.Debug|x64.ActiveCfg = Debug|Any CPU
@@ -295,7 +281,6 @@ Global
{BE1E685F-33D8-47E5-B4FA-BC4DDED255D3} = {BDB24B64-FE4E-C4BD-9F80-9428F98EDF6F}
{614EDC46-4654-40F7-A779-8F127B8FD956} = {BDB24B64-FE4E-C4BD-9F80-9428F98EDF6F}
{4B12E120-E39B-44A7-A25E-D3151D5AE914} = {BDB24B64-FE4E-C4BD-9F80-9428F98EDF6F}
{1FFF91AB-C2D2-4A12-A77B-AB9806116F7A} = {BDB24B64-FE4E-C4BD-9F80-9428F98EDF6F}
{168986E2-E127-4E03-BE45-4CC306E4E880} = {BDB24B64-FE4E-C4BD-9F80-9428F98EDF6F}
{A461EFE2-CBB1-4650-9CA0-05CECFAC3AE3} = {BDB24B64-FE4E-C4BD-9F80-9428F98EDF6F}
{24BBDF59-7B30-4620-8464-BDACB1AEF49D} = {BDB24B64-FE4E-C4BD-9F80-9428F98EDF6F}

View File

@@ -9,7 +9,7 @@
<ProjectReference Include="..\StellaOps.Auth.Abstractions\StellaOps.Auth.Abstractions.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Http.Resilience" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.0" />
</ItemGroup>
</Project>

View File

@@ -3,10 +3,10 @@ using System.Net;
using System.Net.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Http.Resilience;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Polly;
using Polly.Extensions.Http;
using StellaOps.AirGap.Policy;
namespace StellaOps.Auth.Client;
@@ -35,21 +35,21 @@ public static class ServiceCollectionExtensions
var options = provider.GetRequiredService<IOptionsMonitor<StellaOpsAuthClientOptions>>().CurrentValue;
EnsureEgressAllowed(provider, options, "authority-discovery");
client.Timeout = options.HttpTimeout;
}).AddPolicyHandler(static (provider, _) => CreateRetryPolicy(provider));
}).AddResilienceHandler("authority-discovery", ConfigureResilience);
services.AddHttpClient<StellaOpsJwksCache>((provider, client) =>
{
var options = provider.GetRequiredService<IOptionsMonitor<StellaOpsAuthClientOptions>>().CurrentValue;
EnsureEgressAllowed(provider, options, "authority-jwks");
client.Timeout = options.HttpTimeout;
}).AddPolicyHandler(static (provider, _) => CreateRetryPolicy(provider));
}).AddResilienceHandler("authority-jwks", ConfigureResilience);
services.AddHttpClient<IStellaOpsTokenClient, StellaOpsTokenClient>((provider, client) =>
{
var options = provider.GetRequiredService<IOptionsMonitor<StellaOpsAuthClientOptions>>().CurrentValue;
EnsureEgressAllowed(provider, options, "authority-token");
client.Timeout = options.HttpTimeout;
}).AddPolicyHandler(static (provider, _) => CreateRetryPolicy(provider));
}).AddResilienceHandler("authority-token", ConfigureResilience);
return services;
}
@@ -95,49 +95,19 @@ public static class ServiceCollectionExtensions
return builder;
}
private static IAsyncPolicy<HttpResponseMessage> CreateRetryPolicy(IServiceProvider provider)
private static void ConfigureResilience(ResiliencePipelineBuilder<HttpResponseMessage> builder)
{
var options = provider.GetRequiredService<IOptionsMonitor<StellaOpsAuthClientOptions>>().CurrentValue;
var delays = options.NormalizedRetryDelays;
if (delays.Count == 0)
builder.AddRetry(new HttpRetryStrategyOptions
{
return Policy.NoOpAsync<HttpResponseMessage>();
}
var logger = provider.GetService<ILoggerFactory>()?.CreateLogger("StellaOps.Auth.Client.HttpRetry");
return HttpPolicyExtensions
.HandleTransientHttpError()
.OrResult(static response => response.StatusCode == HttpStatusCode.TooManyRequests)
.WaitAndRetryAsync(
delays.Count,
attempt => delays[attempt - 1],
(outcome, delay, attempt, _) =>
{
if (logger is null)
{
return;
}
if (outcome.Exception is not null)
{
logger.LogWarning(
outcome.Exception,
"Retrying Authority HTTP call ({Attempt}/{TotalAttempts}) after exception; waiting {Delay}.",
attempt,
delays.Count,
delay);
}
else
{
logger.LogWarning(
"Retrying Authority HTTP call ({Attempt}/{TotalAttempts}) due to status {StatusCode}; waiting {Delay}.",
attempt,
delays.Count,
outcome.Result!.StatusCode,
delay);
}
});
MaxRetryAttempts = 3,
Delay = TimeSpan.FromSeconds(1),
BackoffType = DelayBackoffType.Exponential,
ShouldHandle = static args => ValueTask.FromResult(
args.Outcome.Exception is not null ||
args.Outcome.Result?.StatusCode is HttpStatusCode.RequestTimeout
or HttpStatusCode.TooManyRequests
or >= HttpStatusCode.InternalServerError)
});
}
private static void EnsureEgressAllowed(

View File

@@ -32,7 +32,7 @@
<ProjectReference Include="../../../__Libraries/StellaOps.Configuration/StellaOps.Configuration.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Http.Resilience" Version="10.0.0" />
<PackageReference Include="Microsoft.SourceLink.GitLab" Version="8.0.0" PrivateAssets="All" />
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.15.0" />

View File

@@ -11,11 +11,7 @@
<ItemGroup>
<ProjectReference Include="..\StellaOps.Authority.Plugin.Ldap\StellaOps.Authority.Plugin.Ldap.csproj" />
<ProjectReference Include="..\StellaOps.Authority.Plugins.Abstractions\StellaOps.Authority.Plugins.Abstractions.csproj" />
<ProjectReference Include="..\StellaOps.Authority.Storage.Mongo\StellaOps.Authority.Storage.Mongo.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="MongoDB.Driver" Version="3.5.0" />
<PackageReference Include="Mongo2Go" Version="4.1.0" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Authority.Storage.Postgres\StellaOps.Authority.Storage.Postgres.csproj" />
</ItemGroup>
</Project>

View File

@@ -13,12 +13,13 @@
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.0" />
<PackageReference Include="System.DirectoryServices.Protocols" Version="8.0.0" />
<PackageReference Include="MongoDB.Driver" Version="3.5.0" />
<!-- MongoDB.Driver removed - using Mongo compatibility shim -->
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\\StellaOps.Authority.Plugins.Abstractions\\StellaOps.Authority.Plugins.Abstractions.csproj" />
<ProjectReference Include="..\\StellaOps.Auth.Abstractions\\StellaOps.Auth.Abstractions.csproj" />
<ProjectReference Include="..\\..\\..\\__Libraries\\StellaOps.Plugin\\StellaOps.Plugin.csproj" />
<ProjectReference Include="..\\StellaOps.Authority.Storage.Mongo\\StellaOps.Authority.Storage.Mongo.csproj" />
<ProjectReference Include="..\\..\\..\\__Libraries\\StellaOps.Plugin\\StellaOps.Plugin.csproj" />
<ProjectReference Include="..\\..\\..\\__Libraries\\StellaOps.Authority.Storage.Postgres\\StellaOps.Authority.Storage.Postgres.csproj" />
</ItemGroup>
</Project>

View File

@@ -10,6 +10,6 @@
<ProjectReference Include="..\StellaOps.Authority.Plugins.Abstractions\StellaOps.Authority.Plugins.Abstractions.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="MongoDB.Driver" Version="3.5.0" />
<!-- MongoDB.Driver removed - using Mongo compatibility shim via Plugin.Standard project reference -->
</ItemGroup>
</Project>

View File

@@ -6,6 +6,8 @@ namespace StellaOps.Authority.Plugin.Standard;
internal sealed class StandardPluginOptions
{
public string? TenantId { get; set; }
public BootstrapUserOptions? BootstrapUser { get; set; }
public PasswordPolicyOptions PasswordPolicy { get; set; } = new();

View File

@@ -3,12 +3,12 @@ using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using MongoDB.Driver;
using StellaOps.Authority.Plugins.Abstractions;
using StellaOps.Authority.Plugin.Standard.Bootstrap;
using StellaOps.Authority.Plugin.Standard.Security;
using StellaOps.Authority.Plugin.Standard.Storage;
using StellaOps.Authority.Storage.Mongo.Stores;
using StellaOps.Authority.Storage.Postgres.Repositories;
using StellaOps.Cryptography;
using StellaOps.Cryptography.DependencyInjection;
@@ -16,6 +16,8 @@ namespace StellaOps.Authority.Plugin.Standard;
internal sealed class StandardPluginRegistrar : IAuthorityPluginRegistrar
{
private const string DefaultTenantId = "default";
public string PluginType => "standard";
public void Register(AuthorityPluginRegistrationContext context)
@@ -27,12 +29,12 @@ internal sealed class StandardPluginRegistrar : IAuthorityPluginRegistrar
var pluginName = context.Plugin.Manifest.Name;
context.Services.AddSingleton<StandardClaimsEnricher>();
context.Services.AddSingleton<IClaimsEnricher>(sp => sp.GetRequiredService<StandardClaimsEnricher>());
context.Services.AddStellaOpsCrypto();
var configPath = context.Plugin.Manifest.ConfigPath;
context.Services.AddSingleton<StandardClaimsEnricher>();
context.Services.AddSingleton<IClaimsEnricher>(sp => sp.GetRequiredService<StandardClaimsEnricher>());
context.Services.AddStellaOpsCrypto();
var configPath = context.Plugin.Manifest.ConfigPath;
context.Services.AddOptions<StandardPluginOptions>(pluginName)
.Bind(context.Plugin.Configuration)
@@ -43,21 +45,21 @@ internal sealed class StandardPluginRegistrar : IAuthorityPluginRegistrar
})
.ValidateOnStart();
context.Services.AddScoped<IStandardCredentialAuditLogger, StandardCredentialAuditLogger>();
context.Services.AddScoped(sp =>
{
var database = sp.GetRequiredService<IMongoDatabase>();
var optionsMonitor = sp.GetRequiredService<IOptionsMonitor<StandardPluginOptions>>();
var pluginOptions = optionsMonitor.Get(pluginName);
var cryptoProvider = sp.GetRequiredService<ICryptoProvider>();
var passwordHasher = new CryptoPasswordHasher(pluginOptions, cryptoProvider);
var loggerFactory = sp.GetRequiredService<ILoggerFactory>();
var registrarLogger = loggerFactory.CreateLogger<StandardPluginRegistrar>();
var auditLogger = sp.GetRequiredService<IStandardCredentialAuditLogger>();
var baselinePolicy = new PasswordPolicyOptions();
if (pluginOptions.PasswordPolicy.IsWeakerThan(baselinePolicy))
context.Services.AddScoped<IStandardCredentialAuditLogger, StandardCredentialAuditLogger>();
context.Services.AddScoped(sp =>
{
var userRepository = sp.GetRequiredService<IUserRepository>();
var optionsMonitor = sp.GetRequiredService<IOptionsMonitor<StandardPluginOptions>>();
var pluginOptions = optionsMonitor.Get(pluginName);
var cryptoProvider = sp.GetRequiredService<ICryptoProvider>();
var passwordHasher = new CryptoPasswordHasher(pluginOptions, cryptoProvider);
var loggerFactory = sp.GetRequiredService<ILoggerFactory>();
var registrarLogger = loggerFactory.CreateLogger<StandardPluginRegistrar>();
var auditLogger = sp.GetRequiredService<IStandardCredentialAuditLogger>();
var baselinePolicy = new PasswordPolicyOptions();
if (pluginOptions.PasswordPolicy.IsWeakerThan(baselinePolicy))
{
registrarLogger.LogWarning(
"Standard plugin '{Plugin}' configured a weaker password policy (minLength={Length}, requireUpper={Upper}, requireLower={Lower}, requireDigit={Digit}, requireSymbol={Symbol}) than the baseline (minLength={BaseLength}, requireUpper={BaseUpper}, requireLower={BaseLower}, requireDigit={BaseDigit}, requireSymbol={BaseSymbol}).",
@@ -73,15 +75,19 @@ internal sealed class StandardPluginRegistrar : IAuthorityPluginRegistrar
baselinePolicy.RequireDigit,
baselinePolicy.RequireSymbol);
}
return new StandardUserCredentialStore(
pluginName,
database,
pluginOptions,
passwordHasher,
auditLogger,
loggerFactory.CreateLogger<StandardUserCredentialStore>());
});
// Use tenant from options or default
var tenantId = pluginOptions.TenantId ?? DefaultTenantId;
return new StandardUserCredentialStore(
pluginName,
tenantId,
userRepository,
pluginOptions,
passwordHasher,
auditLogger,
loggerFactory.CreateLogger<StandardUserCredentialStore>());
});
context.Services.AddScoped(sp =>
{

View File

@@ -12,13 +12,13 @@
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.0" />
<PackageReference Include="MongoDB.Driver" Version="3.5.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Authority.Plugins.Abstractions\StellaOps.Authority.Plugins.Abstractions.csproj" />
<ProjectReference Include="..\StellaOps.Auth.Abstractions\StellaOps.Auth.Abstractions.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.Plugin/StellaOps.Plugin.csproj" />
<ProjectReference Include="..\StellaOps.Authority.Storage.Mongo\StellaOps.Authority.Storage.Mongo.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.Plugin/StellaOps.Plugin.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Authority.Storage.Postgres/StellaOps.Authority.Storage.Postgres.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.Cryptography/StellaOps.Cryptography.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.Cryptography.DependencyInjection/StellaOps.Cryptography.DependencyInjection.csproj" />
</ItemGroup>

View File

@@ -2,45 +2,44 @@ using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using MongoDB.Bson;
using MongoDB.Driver;
using StellaOps.Authority.Plugins.Abstractions;
using StellaOps.Authority.Plugin.Standard.Security;
using StellaOps.Authority.Storage.Postgres.Repositories;
using StellaOps.Authority.Storage.Postgres.Models;
using StellaOps.Cryptography.Audit;
namespace StellaOps.Authority.Plugin.Standard.Storage;
internal sealed class StandardUserCredentialStore : IUserCredentialStore
{
private readonly IMongoCollection<StandardUserDocument> users;
private readonly IUserRepository userRepository;
private readonly StandardPluginOptions options;
private readonly IPasswordHasher passwordHasher;
private readonly IStandardCredentialAuditLogger auditLogger;
private readonly ILogger<StandardUserCredentialStore> logger;
private readonly string pluginName;
private readonly string tenantId;
public StandardUserCredentialStore(
string pluginName,
IMongoDatabase database,
string tenantId,
IUserRepository userRepository,
StandardPluginOptions options,
IPasswordHasher passwordHasher,
IStandardCredentialAuditLogger auditLogger,
ILogger<StandardUserCredentialStore> logger)
{
this.pluginName = pluginName ?? throw new ArgumentNullException(nameof(pluginName));
this.tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId));
this.userRepository = userRepository ?? throw new ArgumentNullException(nameof(userRepository));
this.options = options ?? throw new ArgumentNullException(nameof(options));
this.passwordHasher = passwordHasher ?? throw new ArgumentNullException(nameof(passwordHasher));
this.auditLogger = auditLogger ?? throw new ArgumentNullException(nameof(auditLogger));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
ArgumentNullException.ThrowIfNull(database);
var collectionName = $"authority_users_{pluginName.ToLowerInvariant()}";
users = database.GetCollection<StandardUserDocument>(collectionName);
EnsureIndexes();
}
public async ValueTask<AuthorityCredentialVerificationResult> VerifyPasswordAsync(
@@ -56,11 +55,10 @@ internal sealed class StandardUserCredentialStore : IUserCredentialStore
}
var normalized = NormalizeUsername(username);
var user = await users.Find(u => u.NormalizedUsername == normalized)
.FirstOrDefaultAsync(cancellationToken)
var userEntity = await userRepository.GetByUsernameAsync(tenantId, normalized, cancellationToken)
.ConfigureAwait(false);
if (user is null)
if (userEntity is null)
{
logger.LogWarning("Plugin {PluginName} failed password verification for unknown user {Username}.", pluginName, normalized);
await RecordAuditAsync(
@@ -74,7 +72,9 @@ internal sealed class StandardUserCredentialStore : IUserCredentialStore
return AuthorityCredentialVerificationResult.Failure(AuthorityCredentialFailureCode.InvalidCredentials, auditProperties: auditProperties);
}
if (options.Lockout.Enabled && user.Lockout.LockoutEnd is { } lockoutEnd && lockoutEnd > DateTimeOffset.UtcNow)
var user = MapToDocument(userEntity);
if (options.Lockout.Enabled && userEntity.LockedUntil is { } lockoutEnd && lockoutEnd > DateTimeOffset.UtcNow)
{
var retryAfter = lockoutEnd - DateTimeOffset.UtcNow;
logger.LogWarning("Plugin {PluginName} denied access for {Username} due to lockout (retry after {RetryAfter}).", pluginName, normalized, retryAfter);
@@ -101,12 +101,14 @@ internal sealed class StandardUserCredentialStore : IUserCredentialStore
auditProperties);
}
var verification = passwordHasher.Verify(password, user.PasswordHash);
var verification = passwordHasher.Verify(password, userEntity.PasswordHash ?? string.Empty);
if (verification is PasswordVerificationResult.Success or PasswordVerificationResult.SuccessRehashNeeded)
{
if (verification == PasswordVerificationResult.SuccessRehashNeeded)
{
user.PasswordHash = passwordHasher.Hash(password);
var newHash = passwordHasher.Hash(password);
await userRepository.UpdatePasswordAsync(tenantId, userEntity.Id, newHash, "", cancellationToken)
.ConfigureAwait(false);
auditProperties.Add(new AuthEventProperty
{
Name = "plugin.rehashed",
@@ -114,13 +116,9 @@ internal sealed class StandardUserCredentialStore : IUserCredentialStore
});
}
var previousFailures = user.Lockout.FailedAttempts;
ResetLockout(user);
user.UpdatedAt = DateTimeOffset.UtcNow;
await users.ReplaceOneAsync(
Builders<StandardUserDocument>.Filter.Eq(u => u.Id, user.Id),
user,
cancellationToken: cancellationToken).ConfigureAwait(false);
var previousFailures = userEntity.FailedLoginAttempts;
await userRepository.RecordSuccessfulLoginAsync(tenantId, userEntity.Id, cancellationToken)
.ConfigureAwait(false);
if (previousFailures > 0)
{
@@ -146,23 +144,27 @@ internal sealed class StandardUserCredentialStore : IUserCredentialStore
auditProperties);
}
await RegisterFailureAsync(user, cancellationToken).ConfigureAwait(false);
await RegisterFailureAsync(userEntity, cancellationToken).ConfigureAwait(false);
var code = options.Lockout.Enabled && user.Lockout.LockoutEnd is { } lockout
// Re-fetch to get updated lockout state
var updatedUser = await userRepository.GetByIdAsync(tenantId, userEntity.Id, cancellationToken)
.ConfigureAwait(false);
var code = options.Lockout.Enabled && updatedUser?.LockedUntil is { } lockout
? AuthorityCredentialFailureCode.LockedOut
: AuthorityCredentialFailureCode.InvalidCredentials;
TimeSpan? retry = user.Lockout.LockoutEnd is { } lockoutTime && lockoutTime > DateTimeOffset.UtcNow
TimeSpan? retry = updatedUser?.LockedUntil is { } lockoutTime && lockoutTime > DateTimeOffset.UtcNow
? lockoutTime - DateTimeOffset.UtcNow
: null;
auditProperties.Add(new AuthEventProperty
{
Name = "plugin.failed_attempts",
Value = ClassifiedString.Public(user.Lockout.FailedAttempts.ToString(CultureInfo.InvariantCulture))
Value = ClassifiedString.Public((updatedUser?.FailedLoginAttempts ?? 0).ToString(CultureInfo.InvariantCulture))
});
if (user.Lockout.LockoutEnd is { } pendingLockout)
if (updatedUser?.LockedUntil is { } pendingLockout)
{
auditProperties.Add(new AuthEventProperty
{
@@ -207,8 +209,7 @@ internal sealed class StandardUserCredentialStore : IUserCredentialStore
}
}
var existing = await users.Find(u => u.NormalizedUsername == normalized)
.FirstOrDefaultAsync(cancellationToken)
var existing = await userRepository.GetByUsernameAsync(tenantId, normalized, cancellationToken)
.ConfigureAwait(false);
if (existing is null)
@@ -218,57 +219,79 @@ internal sealed class StandardUserCredentialStore : IUserCredentialStore
return AuthorityPluginOperationResult<AuthorityUserDescriptor>.Failure("password_required", "New users require a password.");
}
var document = new StandardUserDocument
var metadata = new Dictionary<string, object?>
{
Username = registration.Username,
NormalizedUsername = normalized,
DisplayName = registration.DisplayName,
Email = registration.Email,
PasswordHash = passwordHasher.Hash(registration.Password!),
RequirePasswordReset = registration.RequirePasswordReset,
Roles = registration.Roles.ToList(),
Attributes = new Dictionary<string, string?>(registration.Attributes, StringComparer.OrdinalIgnoreCase),
CreatedAt = now,
UpdatedAt = now
["subjectId"] = Guid.NewGuid().ToString("N"),
["roles"] = registration.Roles.ToList(),
["attributes"] = registration.Attributes,
["requirePasswordReset"] = registration.RequirePasswordReset
};
await users.InsertOneAsync(document, cancellationToken: cancellationToken).ConfigureAwait(false);
return AuthorityPluginOperationResult<AuthorityUserDescriptor>.Success(ToDescriptor(document));
var newUser = new UserEntity
{
Id = Guid.NewGuid(),
TenantId = tenantId,
Username = normalized,
Email = registration.Email ?? $"{normalized}@local",
DisplayName = registration.DisplayName,
PasswordHash = passwordHasher.Hash(registration.Password!),
PasswordSalt = "",
Enabled = true,
Metadata = JsonSerializer.Serialize(metadata)
};
var created = await userRepository.CreateAsync(newUser, cancellationToken).ConfigureAwait(false);
return AuthorityPluginOperationResult<AuthorityUserDescriptor>.Success(ToDescriptor(MapToDocument(created)));
}
existing.Username = registration.Username;
existing.DisplayName = registration.DisplayName ?? existing.DisplayName;
existing.Email = registration.Email ?? existing.Email;
existing.Roles = registration.Roles.Any()
? registration.Roles.ToList()
: existing.Roles;
// Update existing user
var existingMetadata = ParseMetadata(existing.Metadata);
if (registration.Roles.Any())
{
existingMetadata["roles"] = registration.Roles.ToList();
}
if (registration.Attributes.Count > 0)
{
var attrs = existingMetadata.TryGetValue("attributes", out var existingAttrs) && existingAttrs is Dictionary<string, string?> dict
? dict
: new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
foreach (var pair in registration.Attributes)
{
existing.Attributes[pair.Key] = pair.Value;
attrs[pair.Key] = pair.Value;
}
existingMetadata["attributes"] = attrs;
}
if (!string.IsNullOrEmpty(registration.Password))
{
existing.PasswordHash = passwordHasher.Hash(registration.Password!);
existing.RequirePasswordReset = registration.RequirePasswordReset;
await userRepository.UpdatePasswordAsync(tenantId, existing.Id, passwordHasher.Hash(registration.Password!), "", cancellationToken)
.ConfigureAwait(false);
existingMetadata["requirePasswordReset"] = registration.RequirePasswordReset;
}
else if (registration.RequirePasswordReset)
{
existing.RequirePasswordReset = true;
existingMetadata["requirePasswordReset"] = true;
}
existing.UpdatedAt = now;
var updatedUser = new UserEntity
{
Id = existing.Id,
TenantId = tenantId,
Username = normalized,
Email = registration.Email ?? existing.Email,
DisplayName = registration.DisplayName ?? existing.DisplayName,
PasswordHash = existing.PasswordHash,
PasswordSalt = existing.PasswordSalt,
Enabled = existing.Enabled,
Metadata = JsonSerializer.Serialize(existingMetadata)
};
await users.ReplaceOneAsync(
Builders<StandardUserDocument>.Filter.Eq(u => u.Id, existing.Id),
existing,
cancellationToken: cancellationToken).ConfigureAwait(false);
await userRepository.UpdateAsync(updatedUser, cancellationToken).ConfigureAwait(false);
return AuthorityPluginOperationResult<AuthorityUserDescriptor>.Success(ToDescriptor(existing));
return AuthorityPluginOperationResult<AuthorityUserDescriptor>.Success(ToDescriptor(MapToDocument(updatedUser, existingMetadata)));
}
public async ValueTask<AuthorityUserDescriptor?> FindBySubjectAsync(string subjectId, CancellationToken cancellationToken)
@@ -278,11 +301,21 @@ internal sealed class StandardUserCredentialStore : IUserCredentialStore
return null;
}
var user = await users.Find(u => u.SubjectId == subjectId)
.FirstOrDefaultAsync(cancellationToken)
// We need to search by subjectId which is stored in metadata
// For now, get all users and filter - in production, add a dedicated query
var users = await userRepository.GetAllAsync(tenantId, enabled: null, limit: 1000, cancellationToken: cancellationToken)
.ConfigureAwait(false);
return user is null ? null : ToDescriptor(user);
foreach (var user in users)
{
var metadata = ParseMetadata(user.Metadata);
if (metadata.TryGetValue("subjectId", out var sid) && sid?.ToString() == subjectId)
{
return ToDescriptor(MapToDocument(user, metadata));
}
}
return null;
}
public async Task EnsureBootstrapUserAsync(BootstrapUserOptions bootstrap, CancellationToken cancellationToken)
@@ -312,19 +345,10 @@ internal sealed class StandardUserCredentialStore : IUserCredentialStore
}
}
public async Task<AuthorityPluginHealthResult> CheckHealthAsync(CancellationToken cancellationToken)
public Task<AuthorityPluginHealthResult> CheckHealthAsync(CancellationToken cancellationToken)
{
try
{
var command = new BsonDocument("ping", 1);
await users.Database.RunCommandAsync<BsonDocument>(command, cancellationToken: cancellationToken).ConfigureAwait(false);
return AuthorityPluginHealthResult.Healthy();
}
catch (Exception ex)
{
logger.LogError(ex, "Plugin {PluginName} failed MongoDB health check.", pluginName);
return AuthorityPluginHealthResult.Unavailable(ex.Message);
}
// PostgreSQL health is checked at infrastructure level
return Task.FromResult(AuthorityPluginHealthResult.Healthy());
}
private string? ValidatePassword(string password)
@@ -357,33 +381,76 @@ internal sealed class StandardUserCredentialStore : IUserCredentialStore
return null;
}
private async Task RegisterFailureAsync(StandardUserDocument user, CancellationToken cancellationToken)
private async Task RegisterFailureAsync(UserEntity user, CancellationToken cancellationToken)
{
user.Lockout.LastFailure = DateTimeOffset.UtcNow;
user.Lockout.FailedAttempts += 1;
DateTimeOffset? lockUntil = null;
if (options.Lockout.Enabled && user.Lockout.FailedAttempts >= options.Lockout.MaxAttempts)
if (options.Lockout.Enabled && user.FailedLoginAttempts + 1 >= options.Lockout.MaxAttempts)
{
user.Lockout.LockoutEnd = DateTimeOffset.UtcNow + options.Lockout.Window;
user.Lockout.FailedAttempts = 0;
lockUntil = DateTimeOffset.UtcNow + options.Lockout.Window;
}
await users.ReplaceOneAsync(
Builders<StandardUserDocument>.Filter.Eq(u => u.Id, user.Id),
user,
cancellationToken: cancellationToken).ConfigureAwait(false);
}
private static void ResetLockout(StandardUserDocument user)
{
user.Lockout.FailedAttempts = 0;
user.Lockout.LockoutEnd = null;
user.Lockout.LastFailure = null;
await userRepository.RecordFailedLoginAsync(tenantId, user.Id, lockUntil, cancellationToken)
.ConfigureAwait(false);
}
private static string NormalizeUsername(string username)
=> username.Trim().ToLowerInvariant();
private static StandardUserDocument MapToDocument(UserEntity entity, Dictionary<string, object?>? metadata = null)
{
metadata ??= ParseMetadata(entity.Metadata);
var subjectId = metadata.TryGetValue("subjectId", out var sid) ? sid?.ToString() ?? entity.Id.ToString("N") : entity.Id.ToString("N");
var roles = metadata.TryGetValue("roles", out var r) && r is JsonElement rolesElement
? rolesElement.EnumerateArray().Select(e => e.GetString() ?? "").Where(s => !string.IsNullOrEmpty(s)).ToList()
: new List<string>();
var attrs = metadata.TryGetValue("attributes", out var a) && a is JsonElement attrsElement
? attrsElement.EnumerateObject().ToDictionary(p => p.Name, p => p.Value.GetString(), StringComparer.OrdinalIgnoreCase)
: new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
var requireReset = metadata.TryGetValue("requirePasswordReset", out var rr) && rr is JsonElement rrElement && rrElement.GetBoolean();
return new StandardUserDocument
{
Id = entity.Id,
SubjectId = subjectId,
Username = entity.Username,
NormalizedUsername = entity.Username.ToLowerInvariant(),
PasswordHash = entity.PasswordHash ?? string.Empty,
DisplayName = entity.DisplayName,
Email = entity.Email,
RequirePasswordReset = requireReset,
Roles = roles,
Attributes = attrs!,
Lockout = new StandardLockoutState
{
FailedAttempts = entity.FailedLoginAttempts,
LockoutEnd = entity.LockedUntil,
LastFailure = entity.FailedLoginAttempts > 0 ? entity.UpdatedAt : null
},
CreatedAt = entity.CreatedAt,
UpdatedAt = entity.UpdatedAt
};
}
private static Dictionary<string, object?> ParseMetadata(string? json)
{
if (string.IsNullOrWhiteSpace(json) || json == "{}")
{
return new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
}
try
{
return JsonSerializer.Deserialize<Dictionary<string, object?>>(json)
?? new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
}
catch
{
return new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
}
}
private AuthorityUserDescriptor ToDescriptor(StandardUserDocument document)
=> new(
document.SubjectId,
@@ -393,25 +460,6 @@ internal sealed class StandardUserCredentialStore : IUserCredentialStore
document.Roles,
document.Attributes);
private void EnsureIndexes()
{
var indexKeys = Builders<StandardUserDocument>.IndexKeys
.Ascending(u => u.NormalizedUsername);
var indexModel = new CreateIndexModel<StandardUserDocument>(
indexKeys,
new CreateIndexOptions { Unique = true, Name = "idx_normalized_username" });
try
{
users.Indexes.CreateOne(indexModel);
}
catch (MongoCommandException ex) when (ex.CodeName.Equals("IndexOptionsConflict", StringComparison.OrdinalIgnoreCase))
{
logger.LogDebug("Plugin {PluginName} skipped index creation due to existing index.", pluginName);
}
}
private async ValueTask RecordAuditAsync(
string normalizedUsername,
string? subjectId,

View File

@@ -1,64 +1,42 @@
using System;
using System.Collections.Generic;
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
namespace StellaOps.Authority.Plugin.Standard.Storage;
internal sealed class StandardUserDocument
{
[BsonId]
public ObjectId Id { get; set; }
public Guid Id { get; set; } = Guid.NewGuid();
[BsonElement("subjectId")]
public string SubjectId { get; set; } = Guid.NewGuid().ToString("N");
[BsonElement("username")]
public string Username { get; set; } = string.Empty;
[BsonElement("normalizedUsername")]
public string NormalizedUsername { get; set; } = string.Empty;
[BsonElement("passwordHash")]
public string PasswordHash { get; set; } = string.Empty;
[BsonElement("displayName")]
[BsonIgnoreIfNull]
public string? DisplayName { get; set; }
[BsonElement("email")]
[BsonIgnoreIfNull]
public string? Email { get; set; }
[BsonElement("requirePasswordReset")]
public bool RequirePasswordReset { get; set; }
[BsonElement("roles")]
public List<string> Roles { get; set; } = new();
[BsonElement("attributes")]
public Dictionary<string, string?> Attributes { get; set; } = new(StringComparer.OrdinalIgnoreCase);
[BsonElement("lockout")]
public StandardLockoutState Lockout { get; set; } = new();
[BsonElement("createdAt")]
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
[BsonElement("updatedAt")]
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
}
internal sealed class StandardLockoutState
{
[BsonElement("failedAttempts")]
public int FailedAttempts { get; set; }
[BsonElement("lockoutEnd")]
[BsonIgnoreIfNull]
public DateTimeOffset? LockoutEnd { get; set; }
[BsonElement("lastFailure")]
[BsonIgnoreIfNull]
public DateTimeOffset? LastFailure { get; set; }
}

View File

@@ -0,0 +1,60 @@
using MongoDB.Bson;
namespace MongoDB.Bson.Serialization.Attributes;
/// <summary>
/// Compatibility shim for MongoDB BsonId attribute.
/// In PostgreSQL mode, this attribute is ignored but allows code to compile.
/// </summary>
[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field)]
public class BsonIdAttribute : Attribute
{
}
/// <summary>
/// Compatibility shim for MongoDB BsonElement attribute.
/// In PostgreSQL mode, this attribute is ignored but allows code to compile.
/// </summary>
[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field)]
public class BsonElementAttribute : Attribute
{
public string ElementName { get; }
public BsonElementAttribute(string elementName)
{
ElementName = elementName;
}
}
/// <summary>
/// Compatibility shim for MongoDB BsonIgnore attribute.
/// In PostgreSQL mode, this attribute is ignored but allows code to compile.
/// </summary>
[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field)]
public class BsonIgnoreAttribute : Attribute
{
}
/// <summary>
/// Compatibility shim for MongoDB BsonIgnoreIfNull attribute.
/// In PostgreSQL mode, this attribute is ignored but allows code to compile.
/// </summary>
[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field)]
public class BsonIgnoreIfNullAttribute : Attribute
{
}
/// <summary>
/// Compatibility shim for MongoDB BsonRepresentation attribute.
/// In PostgreSQL mode, this attribute is ignored but allows code to compile.
/// </summary>
[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field)]
public class BsonRepresentationAttribute : Attribute
{
public BsonType Representation { get; }
public BsonRepresentationAttribute(BsonType representation)
{
Representation = representation;
}
}

View File

@@ -0,0 +1,79 @@
namespace MongoDB.Bson;
/// <summary>
/// Compatibility shim for MongoDB 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 MongoDB BsonType enum.
/// </summary>
public enum BsonType
{
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,183 @@
namespace StellaOps.Authority.Storage.Mongo.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 bool Consumed { get; set; }
}
/// <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, string> Attributes { get; set; } = new();
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; }
}
/// <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 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 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? SubjectId { get; set; }
public string? ClientId { get; set; }
public string EventType { get; set; } = string.Empty;
public string Outcome { get; set; } = string.Empty;
public string? Reason { get; set; }
public string? IpAddress { get; set; }
public string? UserAgent { get; set; }
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; }
}
/// <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? ReferenceId { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset? ExpiresAt { get; set; }
public DateTimeOffset? RedeemedAt { get; set; }
public string? Payload { get; set; }
public Dictionary<string, string> Properties { get; set; } = new();
}
/// <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 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 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>
/// 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,153 @@
using System.Linq.Expressions;
namespace MongoDB.Driver;
/// <summary>
/// Compatibility shim for MongoDB IMongoCollection interface.
/// In PostgreSQL mode, this provides an in-memory implementation.
/// </summary>
public interface IMongoCollection<TDocument>
{
IMongoDatabase 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 MongoDB IMongoDatabase interface.
/// </summary>
public interface IMongoDatabase
{
string DatabaseNamespace { get; }
IMongoCollection<TDocument> GetCollection<TDocument>(string name);
}
/// <summary>
/// Compatibility shim for MongoDB IMongoClient interface.
/// </summary>
public interface IMongoClient
{
IMongoDatabase GetDatabase(string name);
}
/// <summary>
/// In-memory implementation of IMongoCollection for compatibility.
/// </summary>
public class InMemoryMongoCollection<TDocument> : IMongoCollection<TDocument>
{
private readonly List<TDocument> _documents = new();
private readonly IMongoDatabase _database;
private readonly string _name;
public InMemoryMongoCollection(IMongoDatabase database, string name)
{
_database = database;
_name = name;
}
public IMongoDatabase 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 IMongoDatabase for compatibility.
/// </summary>
public class InMemoryMongoDatabase : IMongoDatabase
{
private readonly Dictionary<string, object> _collections = new();
private readonly string _name;
public InMemoryMongoDatabase(string name)
{
_name = name;
}
public string DatabaseNamespace => _name;
public IMongoCollection<TDocument> GetCollection<TDocument>(string name)
{
if (!_collections.TryGetValue(name, out var collection))
{
collection = new InMemoryMongoCollection<TDocument>(this, name);
_collections[name] = collection;
}
return (IMongoCollection<TDocument>)collection;
}
}
/// <summary>
/// In-memory implementation of IMongoClient for compatibility.
/// </summary>
public class InMemoryMongoClient : IMongoClient
{
private readonly Dictionary<string, IMongoDatabase> _databases = new();
public IMongoDatabase GetDatabase(string name)
{
if (!_databases.TryGetValue(name, out var database))
{
database = new InMemoryMongoDatabase(name);
_databases[name] = database;
}
return database;
}
}

View File

@@ -0,0 +1,66 @@
using Microsoft.Extensions.DependencyInjection;
using MongoDB.Driver;
using StellaOps.Authority.Storage.Mongo.Initialization;
using StellaOps.Authority.Storage.Mongo.Sessions;
using StellaOps.Authority.Storage.Mongo.Stores;
namespace StellaOps.Authority.Storage.Mongo.Extensions;
/// <summary>
/// Compatibility shim storage options. In PostgreSQL mode, these are largely unused.
/// </summary>
public sealed class AuthorityMongoStorageOptions
{
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 MongoDB compatibility storage services.
/// In PostgreSQL mode, this registers in-memory implementations for the Mongo interfaces.
/// </summary>
public static class ServiceCollectionExtensions
{
/// <summary>
/// Adds Authority MongoDB compatibility storage services (in-memory implementations).
/// For production PostgreSQL storage, use AddAuthorityPostgresStorage from StellaOps.Authority.Storage.Postgres.
/// </summary>
public static IServiceCollection AddAuthorityMongoStorage(
this IServiceCollection services,
Action<AuthorityMongoStorageOptions> configureOptions)
{
var options = new AuthorityMongoStorageOptions();
configureOptions(options);
services.AddSingleton(options);
RegisterMongoCompatServices(services, options);
return services;
}
private static void RegisterMongoCompatServices(IServiceCollection services, AuthorityMongoStorageOptions options)
{
// Register the initializer (no-op for Postgres mode)
services.AddSingleton<AuthorityMongoInitializer>();
// Register null session accessor
services.AddSingleton<IAuthorityMongoSessionAccessor, NullAuthorityMongoSessionAccessor>();
// Register in-memory MongoDB shims for compatibility
var inMemoryClient = new InMemoryMongoClient();
var inMemoryDatabase = inMemoryClient.GetDatabase(options.DatabaseName);
services.AddSingleton<IMongoClient>(inMemoryClient);
services.AddSingleton<IMongoDatabase>(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>();
}
}

View File

@@ -0,0 +1,17 @@
namespace StellaOps.Authority.Storage.Mongo.Initialization;
/// <summary>
/// Compatibility shim for MongoDB initializer. In PostgreSQL mode, this is a no-op.
/// The actual initialization is handled by PostgreSQL migrations.
/// </summary>
public sealed class AuthorityMongoInitializer
{
/// <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,24 @@
namespace StellaOps.Authority.Storage.Mongo.Sessions;
/// <summary>
/// Compatibility shim for MongoDB session handle. In PostgreSQL mode, this is unused.
/// </summary>
public interface IClientSessionHandle : IDisposable
{
}
/// <summary>
/// Compatibility shim for MongoDB session accessor. In PostgreSQL mode, this returns null.
/// </summary>
public interface IAuthorityMongoSessionAccessor
{
IClientSessionHandle? CurrentSession { get; }
}
/// <summary>
/// In-memory implementation that always returns null session.
/// </summary>
public sealed class NullAuthorityMongoSessionAccessor : IAuthorityMongoSessionAccessor
{
public IClientSessionHandle? CurrentSession => null;
}

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<RootNamespace>StellaOps.Authority.Storage.Mongo</RootNamespace>
<Description>MongoDB compatibility shim for Authority storage - provides in-memory implementations for Mongo interfaces while PostgreSQL migration is in progress</Description>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,90 @@
using StellaOps.Authority.Storage.Mongo.Documents;
using StellaOps.Authority.Storage.Mongo.Sessions;
namespace StellaOps.Authority.Storage.Mongo.Stores;
/// <summary>
/// Store interface for bootstrap invites.
/// </summary>
public interface IAuthorityBootstrapInviteStore
{
ValueTask<AuthorityBootstrapInviteDocument?> FindByTokenAsync(string token, CancellationToken cancellationToken, IClientSessionHandle? session = null);
ValueTask InsertAsync(AuthorityBootstrapInviteDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null);
ValueTask<bool> ConsumeAsync(string token, 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 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 UpsertAsync(AuthorityTokenDocument document, 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<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);
}

View File

@@ -0,0 +1,294 @@
using System.Collections.Concurrent;
using StellaOps.Authority.Storage.Mongo.Documents;
using StellaOps.Authority.Storage.Mongo.Sessions;
namespace StellaOps.Authority.Storage.Mongo.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 InsertAsync(AuthorityBootstrapInviteDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
_invites[document.Token] = document;
return ValueTask.CompletedTask;
}
public ValueTask<bool> ConsumeAsync(string token, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
if (_invites.TryGetValue(token, out var doc))
{
doc.Consumed = true;
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)
{
_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 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 UpsertAsync(AuthorityTokenDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
_tokens[document.TokenId] = document;
return ValueTask.CompletedTask;
}
public ValueTask<bool> RevokeAsync(string tokenId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
return ValueTask.FromResult(_tokens.TryRemove(tokenId, out _));
}
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);
}
public ValueTask<int> RevokeByClientAsync(string clientId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
var toRemove = _tokens.Where(kv => kv.Value.ClientId == clientId).Select(kv => kv.Key).ToList();
foreach (var key in toRemove)
{
_tokens.TryRemove(key, out _);
}
return ValueTask.FromResult(toRemove.Count);
}
}
/// <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);
}
}

View File

@@ -12,7 +12,7 @@
<ProjectReference Include="../../../__Libraries/StellaOps.Auth.Security/StellaOps.Auth.Security.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="MongoDB.Driver" Version="3.5.0" />
<!-- MongoDB.Driver removed - using Mongo compatibility shim via Authority project reference -->
</ItemGroup>
<ItemGroup>
<Compile Include="../../../../tests/shared/OpenSslLegacyShim.cs" Link="Infrastructure/OpenSslLegacyShim.cs" />

View File

@@ -29,8 +29,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Concelier.Testing
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Concelier.Connector.Common", "..\StellaOps.Concelier.Connector.Common\StellaOps.Concelier.Connector.Common.csproj", "{E0B9CD7A-C4FF-44EB-BE04-9B998C1C4166}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Concelier.Storage.Mongo", "..\StellaOps.Concelier.Storage.Mongo\StellaOps.Concelier.Storage.Mongo.csproj", "{67C85AC6-1670-4A0D-A81F-6015574F46C7}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Concelier.Core", "..\StellaOps.Concelier.Core\StellaOps.Concelier.Core.csproj", "{17829125-C0F5-47E6-A16C-EC142BD58220}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Concelier.Models", "..\StellaOps.Concelier.Models\StellaOps.Concelier.Models.csproj", "{9B4BA030-C979-4191-8B4F-7E2AD9F88A94}"
@@ -41,8 +39,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Authority.Tests",
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Authority.Plugin.Standard.Tests", "StellaOps.Authority.Plugin.Standard.Tests\StellaOps.Authority.Plugin.Standard.Tests.csproj", "{0C222CD9-96B1-4152-BD29-65FFAE27C880}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Authority.Storage.Mongo", "StellaOps.Authority.Storage.Mongo\StellaOps.Authority.Storage.Mongo.csproj", "{977FD870-91B5-44BA-944B-496B2C68DAA0}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Auth.Abstractions.Tests", "StellaOps.Auth.Abstractions.Tests\StellaOps.Auth.Abstractions.Tests.csproj", "{4A5D29B8-959A-4EAC-A827-979CD058EC16}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Auth.ServerIntegration.Tests", "StellaOps.Auth.ServerIntegration.Tests\StellaOps.Auth.ServerIntegration.Tests.csproj", "{CB7FD547-1EC7-4A6F-87FE-F73003512AFE}"
@@ -227,18 +223,6 @@ Global
{E0B9CD7A-C4FF-44EB-BE04-9B998C1C4166}.Release|x64.Build.0 = Release|Any CPU
{E0B9CD7A-C4FF-44EB-BE04-9B998C1C4166}.Release|x86.ActiveCfg = Release|Any CPU
{E0B9CD7A-C4FF-44EB-BE04-9B998C1C4166}.Release|x86.Build.0 = Release|Any CPU
{67C85AC6-1670-4A0D-A81F-6015574F46C7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{67C85AC6-1670-4A0D-A81F-6015574F46C7}.Debug|Any CPU.Build.0 = Debug|Any CPU
{67C85AC6-1670-4A0D-A81F-6015574F46C7}.Debug|x64.ActiveCfg = Debug|Any CPU
{67C85AC6-1670-4A0D-A81F-6015574F46C7}.Debug|x64.Build.0 = Debug|Any CPU
{67C85AC6-1670-4A0D-A81F-6015574F46C7}.Debug|x86.ActiveCfg = Debug|Any CPU
{67C85AC6-1670-4A0D-A81F-6015574F46C7}.Debug|x86.Build.0 = Debug|Any CPU
{67C85AC6-1670-4A0D-A81F-6015574F46C7}.Release|Any CPU.ActiveCfg = Release|Any CPU
{67C85AC6-1670-4A0D-A81F-6015574F46C7}.Release|Any CPU.Build.0 = Release|Any CPU
{67C85AC6-1670-4A0D-A81F-6015574F46C7}.Release|x64.ActiveCfg = Release|Any CPU
{67C85AC6-1670-4A0D-A81F-6015574F46C7}.Release|x64.Build.0 = Release|Any CPU
{67C85AC6-1670-4A0D-A81F-6015574F46C7}.Release|x86.ActiveCfg = Release|Any CPU
{67C85AC6-1670-4A0D-A81F-6015574F46C7}.Release|x86.Build.0 = Release|Any CPU
{17829125-C0F5-47E6-A16C-EC142BD58220}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{17829125-C0F5-47E6-A16C-EC142BD58220}.Debug|Any CPU.Build.0 = Debug|Any CPU
{17829125-C0F5-47E6-A16C-EC142BD58220}.Debug|x64.ActiveCfg = Debug|Any CPU
@@ -299,18 +283,6 @@ Global
{0C222CD9-96B1-4152-BD29-65FFAE27C880}.Release|x64.Build.0 = Release|Any CPU
{0C222CD9-96B1-4152-BD29-65FFAE27C880}.Release|x86.ActiveCfg = Release|Any CPU
{0C222CD9-96B1-4152-BD29-65FFAE27C880}.Release|x86.Build.0 = Release|Any CPU
{977FD870-91B5-44BA-944B-496B2C68DAA0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{977FD870-91B5-44BA-944B-496B2C68DAA0}.Debug|Any CPU.Build.0 = Debug|Any CPU
{977FD870-91B5-44BA-944B-496B2C68DAA0}.Debug|x64.ActiveCfg = Debug|Any CPU
{977FD870-91B5-44BA-944B-496B2C68DAA0}.Debug|x64.Build.0 = Debug|Any CPU
{977FD870-91B5-44BA-944B-496B2C68DAA0}.Debug|x86.ActiveCfg = Debug|Any CPU
{977FD870-91B5-44BA-944B-496B2C68DAA0}.Debug|x86.Build.0 = Debug|Any CPU
{977FD870-91B5-44BA-944B-496B2C68DAA0}.Release|Any CPU.ActiveCfg = Release|Any CPU
{977FD870-91B5-44BA-944B-496B2C68DAA0}.Release|Any CPU.Build.0 = Release|Any CPU
{977FD870-91B5-44BA-944B-496B2C68DAA0}.Release|x64.ActiveCfg = Release|Any CPU
{977FD870-91B5-44BA-944B-496B2C68DAA0}.Release|x64.Build.0 = Release|Any CPU
{977FD870-91B5-44BA-944B-496B2C68DAA0}.Release|x86.ActiveCfg = Release|Any CPU
{977FD870-91B5-44BA-944B-496B2C68DAA0}.Release|x86.Build.0 = Release|Any CPU
{4A5D29B8-959A-4EAC-A827-979CD058EC16}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{4A5D29B8-959A-4EAC-A827-979CD058EC16}.Debug|Any CPU.Build.0 = Debug|Any CPU
{4A5D29B8-959A-4EAC-A827-979CD058EC16}.Debug|x64.ActiveCfg = Debug|Any CPU

View File

@@ -19,7 +19,7 @@ using Microsoft.Net.Http.Headers;
using OpenIddict.Abstractions;
using OpenIddict.Server;
using OpenIddict.Server.AspNetCore;
using MongoDB.Driver;
// MongoDB.Driver removed - using PostgreSQL storage with Mongo compatibility shim
using Serilog;
using Serilog.Events;
using StellaOps.Authority;
@@ -399,9 +399,9 @@ builder.Services.Configure<OpenIddictServerOptions>(options =>
var app = builder.Build();
// Initialize storage (Mongo shim delegates to PostgreSQL migrations)
var mongoInitializer = app.Services.GetRequiredService<AuthorityMongoInitializer>();
var mongoDatabase = app.Services.GetRequiredService<IMongoDatabase>();
await mongoInitializer.InitialiseAsync(mongoDatabase, CancellationToken.None);
await mongoInitializer.InitialiseAsync(null!, CancellationToken.None);
var serviceAccountStore = app.Services.GetRequiredService<IAuthorityServiceAccountStore>();
if (authorityOptions.Delegation.ServiceAccounts.Count > 0)

View File

@@ -23,6 +23,7 @@
<ProjectReference Include="..\StellaOps.Authority.Plugins.Abstractions\StellaOps.Authority.Plugins.Abstractions.csproj" />
<ProjectReference Include="..\StellaOps.Authority.Plugin.Standard\StellaOps.Authority.Plugin.Standard.csproj" />
<ProjectReference Include="..\StellaOps.Authority.Storage.Mongo\StellaOps.Authority.Storage.Mongo.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.Authority.Storage.Postgres\StellaOps.Authority.Storage.Postgres.csproj" />
<ProjectReference Include="..\StellaOps.Auth.Abstractions\StellaOps.Auth.Abstractions.csproj" />
<ProjectReference Include="..\StellaOps.Auth.ServerIntegration\StellaOps.Auth.ServerIntegration.csproj" />
<ProjectReference Include="..\StellaOps.Auth.Client\StellaOps.Auth.Client.csproj" />