search and ai stabilization work, localization stablized.
This commit is contained in:
@@ -107,6 +107,17 @@ public static class StellaOpsLocalHostnameExtensions
|
||||
var currentUrls = builder.WebHost.GetSetting(WebHostDefaults.ServerUrlsKey) ?? "";
|
||||
builder.WebHost.ConfigureKestrel((context, kestrel) =>
|
||||
{
|
||||
// Load the configured default certificate (if any) so programmatic
|
||||
// UseHttps() calls can present a valid cert instead of relying on
|
||||
// the ASP.NET dev-cert (which doesn't exist in containers).
|
||||
X509Certificate2? defaultCert = null;
|
||||
var certPath = context.Configuration["Kestrel:Certificates:Default:Path"];
|
||||
var certPass = context.Configuration["Kestrel:Certificates:Default:Password"];
|
||||
if (!string.IsNullOrEmpty(certPath) && System.IO.File.Exists(certPath))
|
||||
{
|
||||
defaultCert = X509CertificateLoader.LoadPkcs12FromFile(certPath, certPass);
|
||||
}
|
||||
|
||||
// Re-add dev-port bindings from launchSettings.json / ASPNETCORE_URLS
|
||||
foreach (var rawUrl in currentUrls.Split(';', StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
@@ -119,7 +130,13 @@ public static class StellaOpsLocalHostnameExtensions
|
||||
|
||||
if (isHttps)
|
||||
{
|
||||
kestrel.Listen(addr, uri.Port, lo => lo.UseHttps());
|
||||
kestrel.Listen(addr, uri.Port, lo =>
|
||||
{
|
||||
if (defaultCert is not null)
|
||||
lo.UseHttps(defaultCert);
|
||||
else
|
||||
lo.UseHttps();
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -133,7 +150,10 @@ public static class StellaOpsLocalHostnameExtensions
|
||||
{
|
||||
kestrel.Listen(bindIp, HttpsPort, listenOptions =>
|
||||
{
|
||||
listenOptions.UseHttps();
|
||||
if (defaultCert is not null)
|
||||
listenOptions.UseHttps(defaultCert);
|
||||
else
|
||||
listenOptions.UseHttps();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -13,20 +13,98 @@ namespace StellaOps.Authority;
|
||||
internal sealed class AuthorityIdentityProviderRegistry : IAuthorityIdentityProviderRegistry
|
||||
{
|
||||
private readonly IServiceProvider serviceProvider;
|
||||
private readonly IReadOnlyDictionary<string, AuthorityIdentityProviderMetadata> providersByName;
|
||||
private readonly ReadOnlyCollection<AuthorityIdentityProviderMetadata> providers;
|
||||
private readonly ReadOnlyCollection<AuthorityIdentityProviderMetadata> passwordProviders;
|
||||
private readonly ReadOnlyCollection<AuthorityIdentityProviderMetadata> mfaProviders;
|
||||
private readonly ReadOnlyCollection<AuthorityIdentityProviderMetadata> clientProvisioningProviders;
|
||||
private readonly ReadOnlyCollection<AuthorityIdentityProviderMetadata> bootstrapProviders;
|
||||
private readonly ILogger<AuthorityIdentityProviderRegistry> logger;
|
||||
private volatile IReadOnlyDictionary<string, AuthorityIdentityProviderMetadata> providersByName;
|
||||
private volatile ReadOnlyCollection<AuthorityIdentityProviderMetadata> providers;
|
||||
private volatile ReadOnlyCollection<AuthorityIdentityProviderMetadata> passwordProviders;
|
||||
private volatile ReadOnlyCollection<AuthorityIdentityProviderMetadata> mfaProviders;
|
||||
private volatile ReadOnlyCollection<AuthorityIdentityProviderMetadata> clientProvisioningProviders;
|
||||
private volatile ReadOnlyCollection<AuthorityIdentityProviderMetadata> bootstrapProviders;
|
||||
private volatile AuthorityIdentityProviderCapabilities aggregateCapabilities;
|
||||
|
||||
public AuthorityIdentityProviderRegistry(
|
||||
IServiceProvider serviceProvider,
|
||||
ILogger<AuthorityIdentityProviderRegistry> logger)
|
||||
{
|
||||
this.serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider));
|
||||
logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
|
||||
// Initialise all volatile fields to empty defaults so Rebuild never
|
||||
// reads uninitialised state from another thread.
|
||||
providersByName = new Dictionary<string, AuthorityIdentityProviderMetadata>(StringComparer.OrdinalIgnoreCase);
|
||||
providers = new ReadOnlyCollection<AuthorityIdentityProviderMetadata>(Array.Empty<AuthorityIdentityProviderMetadata>());
|
||||
passwordProviders = new ReadOnlyCollection<AuthorityIdentityProviderMetadata>(Array.Empty<AuthorityIdentityProviderMetadata>());
|
||||
mfaProviders = new ReadOnlyCollection<AuthorityIdentityProviderMetadata>(Array.Empty<AuthorityIdentityProviderMetadata>());
|
||||
clientProvisioningProviders = new ReadOnlyCollection<AuthorityIdentityProviderMetadata>(Array.Empty<AuthorityIdentityProviderMetadata>());
|
||||
bootstrapProviders = new ReadOnlyCollection<AuthorityIdentityProviderMetadata>(Array.Empty<AuthorityIdentityProviderMetadata>());
|
||||
aggregateCapabilities = new AuthorityIdentityProviderCapabilities(false, false, false, false);
|
||||
|
||||
Rebuild();
|
||||
}
|
||||
|
||||
public IReadOnlyCollection<AuthorityIdentityProviderMetadata> Providers => providers;
|
||||
|
||||
public IReadOnlyCollection<AuthorityIdentityProviderMetadata> PasswordProviders => passwordProviders;
|
||||
|
||||
public IReadOnlyCollection<AuthorityIdentityProviderMetadata> MfaProviders => mfaProviders;
|
||||
|
||||
public IReadOnlyCollection<AuthorityIdentityProviderMetadata> ClientProvisioningProviders => clientProvisioningProviders;
|
||||
|
||||
public IReadOnlyCollection<AuthorityIdentityProviderMetadata> BootstrapProviders => bootstrapProviders;
|
||||
|
||||
public AuthorityIdentityProviderCapabilities AggregateCapabilities => aggregateCapabilities;
|
||||
|
||||
public bool TryGet(string name, [NotNullWhen(true)] out AuthorityIdentityProviderMetadata? metadata)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
metadata = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
return providersByName.TryGetValue(name, out metadata);
|
||||
}
|
||||
|
||||
public async ValueTask<AuthorityIdentityProviderHandle> AcquireAsync(string name, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!providersByName.TryGetValue(name, out var metadata))
|
||||
{
|
||||
throw new KeyNotFoundException($"Identity provider plugin '{name}' is not registered.");
|
||||
}
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var scope = serviceProvider.CreateAsyncScope();
|
||||
try
|
||||
{
|
||||
var provider = scope.ServiceProvider
|
||||
.GetServices<IIdentityProviderPlugin>()
|
||||
.FirstOrDefault(p => string.Equals(p.Name, metadata.Name, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (provider is null)
|
||||
{
|
||||
await scope.DisposeAsync().ConfigureAwait(false);
|
||||
throw new InvalidOperationException($"Identity provider plugin '{metadata.Name}' could not be resolved.");
|
||||
}
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
return new AuthorityIdentityProviderHandle(scope, metadata, provider);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await scope.DisposeAsync().ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Re-scans <see cref="IIdentityProviderPlugin"/> instances from the DI
|
||||
/// container and rebuilds the metadata and capability indexes. This is
|
||||
/// called during startup and when the plugin configuration is reloaded at
|
||||
/// runtime.
|
||||
/// </summary>
|
||||
internal void Rebuild()
|
||||
{
|
||||
using var scope = serviceProvider.CreateScope();
|
||||
var providerInstances = scope.ServiceProvider.GetServices<IIdentityProviderPlugin>();
|
||||
|
||||
@@ -87,72 +165,17 @@ internal sealed class AuthorityIdentityProviderRegistry : IAuthorityIdentityProv
|
||||
}
|
||||
}
|
||||
|
||||
// Volatile writes ensure visibility to concurrent readers.
|
||||
providersByName = dictionary;
|
||||
providers = new ReadOnlyCollection<AuthorityIdentityProviderMetadata>(uniqueProviders);
|
||||
passwordProviders = new ReadOnlyCollection<AuthorityIdentityProviderMetadata>(password);
|
||||
mfaProviders = new ReadOnlyCollection<AuthorityIdentityProviderMetadata>(mfa);
|
||||
clientProvisioningProviders = new ReadOnlyCollection<AuthorityIdentityProviderMetadata>(clientProvisioning);
|
||||
bootstrapProviders = new ReadOnlyCollection<AuthorityIdentityProviderMetadata>(bootstrap);
|
||||
|
||||
AggregateCapabilities = new AuthorityIdentityProviderCapabilities(
|
||||
SupportsPassword: passwordProviders.Count > 0,
|
||||
SupportsMfa: mfaProviders.Count > 0,
|
||||
SupportsClientProvisioning: clientProvisioningProviders.Count > 0,
|
||||
SupportsBootstrap: bootstrapProviders.Count > 0);
|
||||
}
|
||||
|
||||
public IReadOnlyCollection<AuthorityIdentityProviderMetadata> Providers => providers;
|
||||
|
||||
public IReadOnlyCollection<AuthorityIdentityProviderMetadata> PasswordProviders => passwordProviders;
|
||||
|
||||
public IReadOnlyCollection<AuthorityIdentityProviderMetadata> MfaProviders => mfaProviders;
|
||||
|
||||
public IReadOnlyCollection<AuthorityIdentityProviderMetadata> ClientProvisioningProviders => clientProvisioningProviders;
|
||||
|
||||
public IReadOnlyCollection<AuthorityIdentityProviderMetadata> BootstrapProviders => bootstrapProviders;
|
||||
|
||||
public AuthorityIdentityProviderCapabilities AggregateCapabilities { get; }
|
||||
|
||||
public bool TryGet(string name, [NotNullWhen(true)] out AuthorityIdentityProviderMetadata? metadata)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
metadata = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
return providersByName.TryGetValue(name, out metadata);
|
||||
}
|
||||
|
||||
public async ValueTask<AuthorityIdentityProviderHandle> AcquireAsync(string name, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!providersByName.TryGetValue(name, out var metadata))
|
||||
{
|
||||
throw new KeyNotFoundException($"Identity provider plugin '{name}' is not registered.");
|
||||
}
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var scope = serviceProvider.CreateAsyncScope();
|
||||
try
|
||||
{
|
||||
var provider = scope.ServiceProvider
|
||||
.GetServices<IIdentityProviderPlugin>()
|
||||
.FirstOrDefault(p => string.Equals(p.Name, metadata.Name, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (provider is null)
|
||||
{
|
||||
await scope.DisposeAsync().ConfigureAwait(false);
|
||||
throw new InvalidOperationException($"Identity provider plugin '{metadata.Name}' could not be resolved.");
|
||||
}
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
return new AuthorityIdentityProviderHandle(scope, metadata, provider);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await scope.DisposeAsync().ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
aggregateCapabilities = new AuthorityIdentityProviderCapabilities(
|
||||
SupportsPassword: password.Count > 0,
|
||||
SupportsMfa: mfa.Count > 0,
|
||||
SupportsClientProvisioning: clientProvisioning.Count > 0,
|
||||
SupportsBootstrap: bootstrap.Count > 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,16 +9,30 @@ namespace StellaOps.Authority;
|
||||
|
||||
internal sealed class AuthorityPluginRegistry : IAuthorityPluginRegistry
|
||||
{
|
||||
private readonly IReadOnlyDictionary<string, AuthorityPluginContext> registry;
|
||||
private volatile IReadOnlyDictionary<string, AuthorityPluginContext> registry;
|
||||
private volatile IReadOnlyCollection<AuthorityPluginContext> plugins;
|
||||
|
||||
public AuthorityPluginRegistry(IEnumerable<AuthorityPluginContext> contexts)
|
||||
{
|
||||
registry = contexts.ToDictionary(c => c.Manifest.Name, StringComparer.OrdinalIgnoreCase);
|
||||
Plugins = registry.Values.ToArray();
|
||||
var dict = contexts.ToDictionary(c => c.Manifest.Name, StringComparer.OrdinalIgnoreCase);
|
||||
registry = dict;
|
||||
plugins = dict.Values.ToArray();
|
||||
}
|
||||
|
||||
public IReadOnlyCollection<AuthorityPluginContext> Plugins { get; }
|
||||
public IReadOnlyCollection<AuthorityPluginContext> Plugins => plugins;
|
||||
|
||||
public bool TryGet(string name, [NotNullWhen(true)] out AuthorityPluginContext? context)
|
||||
=> registry.TryGetValue(name, out context);
|
||||
|
||||
/// <summary>
|
||||
/// Atomically replaces the plugin context set. Callers are responsible for
|
||||
/// ensuring that downstream registries (e.g. identity-provider registry) are
|
||||
/// rebuilt after this call.
|
||||
/// </summary>
|
||||
internal void Reload(IEnumerable<AuthorityPluginContext> contexts)
|
||||
{
|
||||
var dict = contexts.ToDictionary(c => c.Manifest.Name, StringComparer.OrdinalIgnoreCase);
|
||||
plugins = dict.Values.ToArray();
|
||||
registry = dict;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
@@ -1736,6 +1737,50 @@ if (authorityOptions.Bootstrap.Enabled)
|
||||
return Results.Problem("Failed to rotate ack token key.");
|
||||
}
|
||||
});
|
||||
|
||||
bootstrapGroup.MapPost("/plugins/reload", (
|
||||
IAuthorityPluginRegistry pluginRegistry,
|
||||
IAuthorityIdentityProviderRegistry identityProviderRegistry,
|
||||
IOptions<StellaOpsAuthorityOptions> optionsAccessor,
|
||||
IWebHostEnvironment environment,
|
||||
ILogger<AuthorityPluginRegistry> reloadLogger) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var opts = optionsAccessor.Value;
|
||||
var reloadedContexts = AuthorityPluginConfigurationLoader
|
||||
.Load(opts, environment.ContentRootPath)
|
||||
.ToArray();
|
||||
|
||||
if (pluginRegistry is AuthorityPluginRegistry reloadable)
|
||||
{
|
||||
reloadable.Reload(reloadedContexts);
|
||||
reloadLogger.LogInformation(
|
||||
"Plugin registry reloaded with {Count} context(s).",
|
||||
reloadedContexts.Length);
|
||||
}
|
||||
|
||||
if (identityProviderRegistry is AuthorityIdentityProviderRegistry idpReloadable)
|
||||
{
|
||||
idpReloadable.Rebuild();
|
||||
reloadLogger.LogInformation(
|
||||
"Identity provider registry rebuilt with {Count} provider(s).",
|
||||
idpReloadable.Providers.Count);
|
||||
}
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
reloaded = true,
|
||||
pluginContexts = reloadedContexts.Length,
|
||||
identityProviders = identityProviderRegistry.Providers.Count
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
reloadLogger.LogError(ex, "Plugin reload failed.");
|
||||
return Results.Problem("Plugin reload failed: " + ex.Message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
app.UseSerilogRequestLogging(options =>
|
||||
|
||||
Reference in New Issue
Block a user