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:
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 =>
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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" />
|
||||
|
||||
Reference in New Issue
Block a user