Fix live evidence and registry auth contracts

This commit is contained in:
master
2026-03-08 22:54:36 +02:00
parent 6efed23647
commit 4f445ad951
24 changed files with 1404 additions and 1576 deletions

View File

@@ -668,6 +668,11 @@ public static class StellaOpsScopes
/// </summary>
public const string IntegrationOperate = "integration:operate";
/// <summary>
/// Scope granting administrative access to registry plan and audit surfaces.
/// </summary>
public const string RegistryAdmin = "registry.admin";
private static readonly IReadOnlyList<string> AllScopes = BuildAllScopes();
private static readonly HashSet<string> KnownScopes = new(AllScopes, StringComparer.OrdinalIgnoreCase);

View File

@@ -36,14 +36,24 @@ internal sealed class StandardPluginBootstrapper : IHostedService
{
using var scope = scopeFactory.CreateScope();
var optionsMonitor = scope.ServiceProvider.GetRequiredService<IOptionsMonitor<StandardPluginOptions>>();
var credentialStore = scope.ServiceProvider.GetRequiredService<StandardUserCredentialStore>();
var options = optionsMonitor.Get(pluginName);
var tenantId = options.TenantId ?? DefaultTenantId;
try
{
await EnsureBootstrapClientsAsync(scope.ServiceProvider, tenantId, options.BootstrapClients, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
logger.LogError(ex, "Standard Authority plugin '{PluginName}' failed to ensure bootstrap clients.", pluginName);
}
if (options.BootstrapUser is null || !options.BootstrapUser.IsConfigured)
{
return;
}
var credentialStore = scope.ServiceProvider.GetRequiredService<StandardUserCredentialStore>();
logger.LogInformation("Standard Authority plugin '{PluginName}' ensuring bootstrap user.", pluginName);
try
{
@@ -54,7 +64,6 @@ internal sealed class StandardPluginBootstrapper : IHostedService
logger.LogError(ex, "Standard Authority plugin '{PluginName}' failed to ensure bootstrap user.", pluginName);
}
var tenantId = options.TenantId ?? DefaultTenantId;
var bootstrapRoles = options.BootstrapUser.Roles ?? new[] { "admin" };
try
@@ -171,5 +180,38 @@ internal sealed class StandardPluginBootstrapper : IHostedService
}
}
private async Task EnsureBootstrapClientsAsync(
IServiceProvider services,
string tenantId,
IReadOnlyCollection<BootstrapClientOptions> bootstrapClients,
CancellationToken cancellationToken)
{
if (bootstrapClients.Count == 0)
{
return;
}
var clientProvisioningStore = services.GetRequiredService<StandardClientProvisioningStore>();
foreach (var bootstrapClient in bootstrapClients)
{
var registration = bootstrapClient.ToRegistration(tenantId);
var result = await clientProvisioningStore.CreateOrUpdateAsync(registration, cancellationToken).ConfigureAwait(false);
if (!result.Succeeded)
{
logger.LogWarning(
"Standard Authority plugin '{PluginName}' failed to ensure bootstrap client '{ClientId}': {Message}",
pluginName,
registration.ClientId,
result.Message ?? result.ErrorCode ?? "unknown_error");
continue;
}
logger.LogInformation(
"Standard Authority plugin '{PluginName}' ensured bootstrap client '{ClientId}'.",
pluginName,
registration.ClientId);
}
}
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}

View File

@@ -1,7 +1,10 @@
using StellaOps.Auth.Abstractions;
using StellaOps.Authority.Plugins.Abstractions;
using StellaOps.Cryptography;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
namespace StellaOps.Authority.Plugin.Standard;
@@ -11,6 +14,8 @@ internal sealed class StandardPluginOptions
public BootstrapUserOptions? BootstrapUser { get; set; }
public BootstrapClientOptions[] BootstrapClients { get; set; } = Array.Empty<BootstrapClientOptions>();
public PasswordPolicyOptions PasswordPolicy { get; set; } = new();
public LockoutOptions Lockout { get; set; } = new();
@@ -23,12 +28,25 @@ internal sealed class StandardPluginOptions
{
TenantId = NormalizeTenantId(TenantId);
BootstrapUser?.Normalize();
BootstrapClients = BootstrapClients ?? Array.Empty<BootstrapClientOptions>();
foreach (var client in BootstrapClients)
{
client.Normalize();
}
TokenSigning.Normalize(configPath);
}
public void Validate(string pluginName)
{
BootstrapUser?.Validate(pluginName);
foreach (var client in BootstrapClients)
{
client.Validate(pluginName);
}
PasswordPolicy.Validate(pluginName);
Lockout.Validate(pluginName);
PasswordHashing.Validate();
@@ -44,6 +62,231 @@ internal sealed class StandardPluginOptions
=> string.IsNullOrWhiteSpace(tenantId) ? null : tenantId.Trim().ToLowerInvariant();
}
internal sealed class BootstrapClientOptions
{
public string? ClientId { get; set; }
public string? DisplayName { get; set; }
public bool Confidential { get; set; }
public string? ClientSecret { get; set; }
public string? AllowedGrantTypes { get; set; }
public string? AllowedScopes { get; set; }
public string? AllowedAudiences { get; set; }
public string? RedirectUris { get; set; }
public string? PostLogoutRedirectUris { get; set; }
public string? TenantId { get; set; }
public string? Project { get; set; }
public string? SenderConstraint { get; set; }
public bool Enabled { get; set; } = true;
public bool RequirePkce { get; set; } = true;
public bool AllowPlainTextPkce { get; set; }
public void Normalize()
{
ClientId = NormalizeOptional(ClientId);
DisplayName = NormalizeOptional(DisplayName);
ClientSecret = NormalizeSecret(ClientSecret);
AllowedGrantTypes = NormalizeJoinedValues(AllowedGrantTypes);
AllowedScopes = NormalizeJoinedScopes(AllowedScopes);
AllowedAudiences = NormalizeJoinedValues(AllowedAudiences);
RedirectUris = NormalizeJoinedValues(RedirectUris);
PostLogoutRedirectUris = NormalizeJoinedValues(PostLogoutRedirectUris);
TenantId = NormalizeTenantId(TenantId);
Project = NormalizeProject(Project);
SenderConstraint = NormalizeSenderConstraint(SenderConstraint);
}
public void Validate(string pluginName)
{
if (string.IsNullOrWhiteSpace(ClientId))
{
throw new InvalidOperationException(
$"Standard plugin '{pluginName}' requires bootstrapClients.clientId.");
}
if (Confidential && string.IsNullOrWhiteSpace(ClientSecret))
{
throw new InvalidOperationException(
$"Standard plugin '{pluginName}' bootstrap client '{ClientId}' requires clientSecret when confidential=true.");
}
var grantTypes = SplitValues(AllowedGrantTypes);
if (grantTypes.Length == 0)
{
throw new InvalidOperationException(
$"Standard plugin '{pluginName}' bootstrap client '{ClientId}' requires at least one allowed grant type.");
}
var scopes = SplitValues(AllowedScopes);
if (scopes.Length == 0)
{
throw new InvalidOperationException(
$"Standard plugin '{pluginName}' bootstrap client '{ClientId}' requires at least one allowed scope.");
}
var redirectUris = ParseUris(RedirectUris, pluginName, ClientId!, "redirectUris");
_ = ParseUris(PostLogoutRedirectUris, pluginName, ClientId!, "postLogoutRedirectUris");
if (grantTypes.Contains("authorization_code", StringComparer.OrdinalIgnoreCase) && redirectUris.Count == 0)
{
throw new InvalidOperationException(
$"Standard plugin '{pluginName}' bootstrap client '{ClientId}' requires at least one redirect URI for authorization_code.");
}
if (AllowPlainTextPkce && !RequirePkce)
{
throw new InvalidOperationException(
$"Standard plugin '{pluginName}' bootstrap client '{ClientId}' cannot allow plain-text PKCE when PKCE is disabled.");
}
if (!string.IsNullOrWhiteSpace(SenderConstraint) &&
!string.Equals(SenderConstraint, "dpop", StringComparison.Ordinal) &&
!string.Equals(SenderConstraint, "mtls", StringComparison.Ordinal))
{
throw new InvalidOperationException(
$"Standard plugin '{pluginName}' bootstrap client '{ClientId}' must use senderConstraint 'dpop' or 'mtls' when configured.");
}
}
public AuthorityClientRegistration ToRegistration(string defaultTenantId)
{
var properties = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase)
{
[StandardClientMetadataKeys.Enabled] = Enabled ? "true" : "false",
[StandardClientMetadataKeys.RequirePkce] = RequirePkce ? "true" : "false",
[StandardClientMetadataKeys.AllowPlainTextPkce] = AllowPlainTextPkce ? "true" : "false",
};
if (!string.IsNullOrWhiteSpace(SenderConstraint))
{
properties[AuthorityClientMetadataKeys.SenderConstraint] = SenderConstraint;
}
return new AuthorityClientRegistration(
clientId: ClientId!,
confidential: Confidential,
displayName: DisplayName ?? ClientId,
clientSecret: ClientSecret,
allowedGrantTypes: SplitValues(AllowedGrantTypes),
allowedScopes: SplitValues(AllowedScopes),
allowedAudiences: SplitValues(AllowedAudiences),
redirectUris: ParseUris(RedirectUris, pluginName: null, ClientId!, "redirectUris"),
postLogoutRedirectUris: ParseUris(PostLogoutRedirectUris, pluginName: null, ClientId!, "postLogoutRedirectUris"),
tenant: TenantId ?? defaultTenantId,
project: Project ?? StellaOpsTenancyDefaults.AnyProject,
properties: properties);
}
private static string? NormalizeOptional(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
private static string? NormalizeSecret(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
private static string? NormalizeTenantId(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim().ToLowerInvariant();
private static string? NormalizeProject(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim().ToLowerInvariant();
private static string? NormalizeSenderConstraint(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
return value.Trim().ToLowerInvariant();
}
private static string? NormalizeJoinedValues(string? raw)
{
var values = SplitValues(raw);
if (values.Length == 0)
{
return null;
}
return string.Join(" ", values.OrderBy(static value => value, StringComparer.Ordinal));
}
private static string? NormalizeJoinedScopes(string? raw)
{
var values = SplitValues(raw)
.Select(StellaOpsScopes.Normalize)
.Where(static value => value is not null)
.Select(static value => value!)
.Distinct(StringComparer.Ordinal)
.OrderBy(static value => value, StringComparer.Ordinal)
.ToArray();
return values.Length == 0 ? null : string.Join(" ", values);
}
private static string[] SplitValues(string? raw)
{
if (string.IsNullOrWhiteSpace(raw))
{
return Array.Empty<string>();
}
return raw
.Split([' ', ',', ';', '\t', '\r', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Where(static value => !string.IsNullOrWhiteSpace(value))
.Distinct(StringComparer.Ordinal)
.ToArray();
}
private static IReadOnlyCollection<Uri> ParseUris(string? raw, string? pluginName, string clientId, string propertyName)
{
var values = SplitValues(raw);
if (values.Length == 0)
{
return Array.Empty<Uri>();
}
var uris = new List<Uri>(values.Length);
foreach (var value in values)
{
if (!Uri.TryCreate(value, UriKind.Absolute, out var uri))
{
if (!string.IsNullOrWhiteSpace(pluginName))
{
throw new InvalidOperationException(
$"Standard plugin '{pluginName}' bootstrap client '{clientId}' requires absolute URIs in {propertyName}.");
}
throw new InvalidOperationException(
$"Bootstrap client '{clientId}' requires absolute URIs in {propertyName}.");
}
uris.Add(uri);
}
return uris;
}
}
internal static class StandardClientMetadataKeys
{
public const string Enabled = "enabled";
public const string RequirePkce = "requirePkce";
public const string AllowPlainTextPkce = "allowPlainTextPkce";
}
internal sealed class BootstrapUserOptions
{
public string? Username { get; set; }

View File

@@ -1,4 +1,4 @@
using StellaOps.Auth.Abstractions;
using StellaOps.Authority.Persistence.Documents;
using StellaOps.Authority.Persistence.InMemory.Stores;
using StellaOps.Authority.Plugins.Abstractions;
@@ -40,78 +40,7 @@ internal sealed class StandardClientProvisioningStore : IClientProvisioningStore
var document = await clientStore.FindByClientIdAsync(registration.ClientId, cancellationToken).ConfigureAwait(false)
?? new AuthorityClientDocument { ClientId = registration.ClientId, CreatedAt = clock.GetUtcNow() };
document.Plugin = pluginName;
document.ClientType = registration.Confidential ? "confidential" : "public";
document.DisplayName = registration.DisplayName;
document.SecretHash = registration.Confidential && registration.ClientSecret is not null
? AuthoritySecretHasher.ComputeHash(registration.ClientSecret)
: null;
document.UpdatedAt = clock.GetUtcNow();
document.RedirectUris = registration.RedirectUris.Select(static uri => uri.ToString()).ToList();
document.PostLogoutRedirectUris = registration.PostLogoutRedirectUris.Select(static uri => uri.ToString()).ToList();
document.Properties[AuthorityClientMetadataKeys.AllowedGrantTypes] = JoinValues(registration.AllowedGrantTypes);
document.Properties[AuthorityClientMetadataKeys.AllowedScopes] = JoinValues(registration.AllowedScopes);
document.Properties[AuthorityClientMetadataKeys.Audiences] = JoinValues(registration.AllowedAudiences);
document.Properties[AuthorityClientMetadataKeys.RedirectUris] = string.Join(" ", document.RedirectUris);
document.Properties[AuthorityClientMetadataKeys.PostLogoutRedirectUris] = string.Join(" ", document.PostLogoutRedirectUris);
if (registration.CertificateBindings is not null)
{
var now = clock.GetUtcNow();
document.CertificateBindings = registration.CertificateBindings
.Select(binding => MapCertificateBinding(binding, now))
.OrderBy(binding => binding.Thumbprint, StringComparer.Ordinal)
.ToList();
}
foreach (var (key, value) in registration.Properties)
{
document.Properties[key] = value;
}
var normalizedTenant = NormalizeTenant(registration.Tenant);
var normalizedTenants = NormalizeTenants(
registration.Properties.TryGetValue(AuthorityClientMetadataKeys.Tenants, out var tenantAssignments) ? tenantAssignments : null,
normalizedTenant);
if (normalizedTenants.Count > 0)
{
document.Properties[AuthorityClientMetadataKeys.Tenants] = string.Join(" ", normalizedTenants);
}
else
{
document.Properties.Remove(AuthorityClientMetadataKeys.Tenants);
}
if (normalizedTenant is not null)
{
document.Properties[AuthorityClientMetadataKeys.Tenant] = normalizedTenant;
}
else if (normalizedTenants.Count == 1)
{
document.Properties[AuthorityClientMetadataKeys.Tenant] = normalizedTenants[0];
}
else
{
document.Properties.Remove(AuthorityClientMetadataKeys.Tenant);
}
if (registration.Properties.TryGetValue(AuthorityClientMetadataKeys.SenderConstraint, out var senderConstraintRaw))
{
var normalizedConstraint = NormalizeSenderConstraint(senderConstraintRaw);
if (normalizedConstraint is not null)
{
document.SenderConstraint = normalizedConstraint;
document.Properties[AuthorityClientMetadataKeys.SenderConstraint] = normalizedConstraint;
}
else
{
document.SenderConstraint = null;
document.Properties.Remove(AuthorityClientMetadataKeys.SenderConstraint);
}
}
ApplyRegistration(document, registration);
await clientStore.UpsertAsync(document, cancellationToken).ConfigureAwait(false);
await revocationStore.RemoveAsync("client", registration.ClientId, cancellationToken).ConfigureAwait(false);
@@ -163,6 +92,86 @@ internal sealed class StandardClientProvisioningStore : IClientProvisioningStore
return AuthorityPluginOperationResult.Success();
}
private void ApplyRegistration(AuthorityClientDocument document, AuthorityClientRegistration registration)
{
document.Plugin = pluginName;
document.ClientType = registration.Confidential ? "confidential" : "public";
document.DisplayName = registration.DisplayName;
document.ClientSecret = registration.Confidential ? registration.ClientSecret : null;
document.SecretHash = registration.Confidential && registration.ClientSecret is not null
? AuthoritySecretHasher.ComputeHash(registration.ClientSecret)
: null;
document.Enabled = ParseBoolean(registration.Properties, StandardClientMetadataKeys.Enabled) ?? true;
document.Disabled = !document.Enabled;
document.RequireClientSecret = registration.Confidential;
document.RequirePkce = ParseBoolean(registration.Properties, StandardClientMetadataKeys.RequirePkce) ?? document.RequirePkce;
document.AllowPlainTextPkce = ParseBoolean(registration.Properties, StandardClientMetadataKeys.AllowPlainTextPkce) ?? document.AllowPlainTextPkce;
document.UpdatedAt = clock.GetUtcNow();
document.RedirectUris = registration.RedirectUris.Select(static uri => uri.ToString()).ToList();
document.PostLogoutRedirectUris = registration.PostLogoutRedirectUris.Select(static uri => uri.ToString()).ToList();
document.AllowedGrantTypes = registration.AllowedGrantTypes.OrderBy(static value => value, StringComparer.Ordinal).ToList();
document.AllowedScopes = registration.AllowedScopes.OrderBy(static value => value, StringComparer.Ordinal).ToList();
document.Properties[AuthorityClientMetadataKeys.AllowedGrantTypes] = JoinValues(registration.AllowedGrantTypes);
document.Properties[AuthorityClientMetadataKeys.AllowedScopes] = JoinValues(registration.AllowedScopes);
document.Properties[AuthorityClientMetadataKeys.Audiences] = JoinValues(registration.AllowedAudiences);
document.Properties[AuthorityClientMetadataKeys.RedirectUris] = string.Join(" ", document.RedirectUris);
document.Properties[AuthorityClientMetadataKeys.PostLogoutRedirectUris] = string.Join(" ", document.PostLogoutRedirectUris);
document.Properties[AuthorityClientMetadataKeys.Project] = registration.Project ?? StellaOpsTenancyDefaults.AnyProject;
foreach (var (key, value) in registration.Properties)
{
document.Properties[key] = value;
}
var normalizedTenant = NormalizeTenant(registration.Tenant);
var normalizedTenants = NormalizeTenants(
registration.Properties.TryGetValue(AuthorityClientMetadataKeys.Tenants, out var tenantAssignments) ? tenantAssignments : null,
normalizedTenant);
if (normalizedTenants.Count > 0)
{
document.Properties[AuthorityClientMetadataKeys.Tenants] = string.Join(" ", normalizedTenants);
}
else
{
document.Properties.Remove(AuthorityClientMetadataKeys.Tenants);
}
if (normalizedTenant is not null)
{
document.Properties[AuthorityClientMetadataKeys.Tenant] = normalizedTenant;
}
else if (normalizedTenants.Count == 1)
{
document.Properties[AuthorityClientMetadataKeys.Tenant] = normalizedTenants[0];
}
else
{
document.Properties.Remove(AuthorityClientMetadataKeys.Tenant);
}
if (registration.Properties.TryGetValue(AuthorityClientMetadataKeys.SenderConstraint, out var senderConstraintRaw))
{
var normalizedConstraint = NormalizeSenderConstraint(senderConstraintRaw);
if (normalizedConstraint is not null)
{
document.SenderConstraint = normalizedConstraint;
document.Properties[AuthorityClientMetadataKeys.SenderConstraint] = normalizedConstraint;
}
else
{
document.SenderConstraint = null;
document.Properties.Remove(AuthorityClientMetadataKeys.SenderConstraint);
}
}
document.CertificateBindings = registration.CertificateBindings.Count == 0
? new List<AuthorityClientCertificateBinding>()
: registration.CertificateBindings.Select(binding => MapCertificateBinding(binding, clock.GetUtcNow())).ToList();
}
private static AuthorityClientDescriptor ToDescriptor(AuthorityClientDocument document)
{
var allowedGrantTypes = Split(document.Properties, AuthorityClientMetadataKeys.AllowedGrantTypes);
@@ -250,6 +259,16 @@ internal sealed class StandardClientProvisioningStore : IClientProvisioningStore
.ToArray();
}
private static bool? ParseBoolean(IReadOnlyDictionary<string, string?> properties, string key)
{
if (!properties.TryGetValue(key, out var value) || string.IsNullOrWhiteSpace(value))
{
return null;
}
return bool.TryParse(value, out var parsed) ? parsed : null;
}
private static AuthorityClientCertificateBinding MapCertificateBinding(
AuthorityClientCertificateBindingRegistration registration,
DateTimeOffset now)

View File

@@ -100,7 +100,7 @@ VALUES
'ui.preferences.read', 'ui.preferences.write',
'doctor:run', 'doctor:admin',
'ops.health',
'integration:read', 'integration:write', 'integration:operate',
'integration:read', 'integration:write', 'integration:operate', 'registry.admin',
'advisory-ai:view', 'advisory-ai:operate',
'timeline:read', 'timeline:write'],
ARRAY['authorization_code', 'refresh_token'],