save progress

This commit is contained in:
StellaOps Bot
2026-01-03 00:47:24 +02:00
parent 3f197814c5
commit ca578801fd
319 changed files with 32478 additions and 2202 deletions

View File

@@ -37,7 +37,14 @@ internal sealed class StandardPluginBootstrapper : IHostedService
}
logger.LogInformation("Standard Authority plugin '{PluginName}' ensuring bootstrap user.", pluginName);
await credentialStore.EnsureBootstrapUserAsync(options.BootstrapUser, cancellationToken).ConfigureAwait(false);
try
{
await credentialStore.EnsureBootstrapUserAsync(options.BootstrapUser, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
logger.LogError(ex, "Standard Authority plugin '{PluginName}' failed to ensure bootstrap user.", pluginName);
}
}
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;

View File

@@ -20,6 +20,8 @@ internal sealed class StandardPluginOptions
public void Normalize(string configPath)
{
TenantId = NormalizeTenantId(TenantId);
BootstrapUser?.Normalize();
TokenSigning.Normalize(configPath);
}
@@ -29,7 +31,16 @@ internal sealed class StandardPluginOptions
PasswordPolicy.Validate(pluginName);
Lockout.Validate(pluginName);
PasswordHashing.Validate();
if (!string.IsNullOrWhiteSpace(TokenSigning.KeyDirectory))
{
throw new InvalidOperationException(
$"Standard plugin '{pluginName}' does not support token signing keys. Remove tokenSigning.keyDirectory from the configuration.");
}
}
private static string? NormalizeTenantId(string? tenantId)
=> string.IsNullOrWhiteSpace(tenantId) ? null : tenantId.Trim().ToLowerInvariant();
}
internal sealed class BootstrapUserOptions
@@ -52,6 +63,15 @@ internal sealed class BootstrapUserOptions
throw new InvalidOperationException($"Standard plugin '{pluginName}' requires both bootstrapUser.username and bootstrapUser.password when configuring a bootstrap user.");
}
}
public void Normalize()
{
Username = string.IsNullOrWhiteSpace(Username) ? null : Username.Trim();
if (string.IsNullOrWhiteSpace(Password))
{
Password = null;
}
}
}
internal sealed class PasswordPolicyOptions

View File

@@ -46,6 +46,7 @@ internal sealed class StandardPluginRegistrar : IAuthorityPluginRegistrar
.ValidateOnStart();
context.Services.AddScoped<IStandardCredentialAuditLogger, StandardCredentialAuditLogger>();
context.Services.AddSingleton<IStandardIdGenerator, GuidStandardIdGenerator>();
context.Services.AddScoped(sp =>
{
@@ -57,6 +58,8 @@ internal sealed class StandardPluginRegistrar : IAuthorityPluginRegistrar
var loggerFactory = sp.GetRequiredService<ILoggerFactory>();
var registrarLogger = loggerFactory.CreateLogger<StandardPluginRegistrar>();
var auditLogger = sp.GetRequiredService<IStandardCredentialAuditLogger>();
var clock = sp.GetRequiredService<TimeProvider>();
var idGenerator = sp.GetRequiredService<IStandardIdGenerator>();
var baselinePolicy = new PasswordPolicyOptions();
if (pluginOptions.PasswordPolicy.IsWeakerThan(baselinePolicy))
@@ -86,6 +89,8 @@ internal sealed class StandardPluginRegistrar : IAuthorityPluginRegistrar
pluginOptions,
passwordHasher,
auditLogger,
clock,
idGenerator,
loggerFactory.CreateLogger<StandardUserCredentialStore>());
});

View File

@@ -5,7 +5,7 @@
<LangVersion>preview</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<IsAuthorityPlugin>true</IsAuthorityPlugin>
</PropertyGroup>
<ItemGroup>

View File

@@ -0,0 +1,17 @@
using System;
namespace StellaOps.Authority.Plugin.Standard.Storage;
internal interface IStandardIdGenerator
{
Guid NewUserId();
string NewSubjectId();
}
internal sealed class GuidStandardIdGenerator : IStandardIdGenerator
{
public Guid NewUserId() => Guid.NewGuid();
public string NewSubjectId() => Guid.NewGuid().ToString("N");
}

View File

@@ -20,6 +20,8 @@ internal sealed class StandardUserCredentialStore : IUserCredentialStore
private readonly StandardPluginOptions options;
private readonly IPasswordHasher passwordHasher;
private readonly IStandardCredentialAuditLogger auditLogger;
private readonly TimeProvider clock;
private readonly IStandardIdGenerator idGenerator;
private readonly ILogger<StandardUserCredentialStore> logger;
private readonly string pluginName;
private readonly string tenantId;
@@ -31,6 +33,8 @@ internal sealed class StandardUserCredentialStore : IUserCredentialStore
StandardPluginOptions options,
IPasswordHasher passwordHasher,
IStandardCredentialAuditLogger auditLogger,
TimeProvider clock,
IStandardIdGenerator idGenerator,
ILogger<StandardUserCredentialStore> logger)
{
this.pluginName = pluginName ?? throw new ArgumentNullException(nameof(pluginName));
@@ -39,6 +43,8 @@ internal sealed class StandardUserCredentialStore : IUserCredentialStore
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.clock = clock ?? throw new ArgumentNullException(nameof(clock));
this.idGenerator = idGenerator ?? throw new ArgumentNullException(nameof(idGenerator));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
@@ -74,9 +80,10 @@ internal sealed class StandardUserCredentialStore : IUserCredentialStore
var user = MapToDocument(userEntity);
if (options.Lockout.Enabled && userEntity.LockedUntil is { } lockoutEnd && lockoutEnd > DateTimeOffset.UtcNow)
var now = clock.GetUtcNow();
if (options.Lockout.Enabled && userEntity.LockedUntil is { } lockoutEnd && lockoutEnd > now)
{
var retryAfter = lockoutEnd - DateTimeOffset.UtcNow;
var retryAfter = lockoutEnd - now;
logger.LogWarning("Plugin {PluginName} denied access for {Username} due to lockout (retry after {RetryAfter}).", pluginName, normalized, retryAfter);
auditProperties.Add(new AuthEventProperty
{
@@ -154,8 +161,9 @@ internal sealed class StandardUserCredentialStore : IUserCredentialStore
? AuthorityCredentialFailureCode.LockedOut
: AuthorityCredentialFailureCode.InvalidCredentials;
TimeSpan? retry = updatedUser?.LockedUntil is { } lockoutTime && lockoutTime > DateTimeOffset.UtcNow
? lockoutTime - DateTimeOffset.UtcNow
var retryNow = clock.GetUtcNow();
TimeSpan? retry = updatedUser?.LockedUntil is { } lockoutTime && lockoutTime > retryNow
? lockoutTime - retryNow
: null;
auditProperties.Add(new AuthEventProperty
@@ -198,8 +206,6 @@ internal sealed class StandardUserCredentialStore : IUserCredentialStore
ArgumentNullException.ThrowIfNull(registration);
var normalized = NormalizeUsername(registration.Username);
var now = DateTimeOffset.UtcNow;
if (!string.IsNullOrEmpty(registration.Password))
{
var passwordValidation = ValidatePassword(registration.Password);
@@ -221,7 +227,7 @@ internal sealed class StandardUserCredentialStore : IUserCredentialStore
var metadata = new Dictionary<string, object?>
{
["subjectId"] = Guid.NewGuid().ToString("N"),
["subjectId"] = idGenerator.NewSubjectId(),
["roles"] = registration.Roles.ToList(),
["attributes"] = registration.Attributes,
["requirePasswordReset"] = registration.RequirePasswordReset
@@ -229,7 +235,7 @@ internal sealed class StandardUserCredentialStore : IUserCredentialStore
var newUser = new UserEntity
{
Id = Guid.NewGuid(),
Id = idGenerator.NewUserId(),
TenantId = tenantId,
Username = normalized,
Email = registration.Email ?? $"{normalized}@local",
@@ -301,17 +307,23 @@ internal sealed class StandardUserCredentialStore : IUserCredentialStore
return null;
}
// 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)
var user = await userRepository.GetBySubjectIdAsync(tenantId, subjectId, cancellationToken)
.ConfigureAwait(false);
foreach (var user in users)
if (user is not null)
{
var metadata = ParseMetadata(user.Metadata);
if (metadata.TryGetValue("subjectId", out var sid) && sid?.ToString() == subjectId)
return ToDescriptor(MapToDocument(user, metadata));
}
if (Guid.TryParse(subjectId, out var parsed))
{
var fallback = await userRepository.GetByIdAsync(tenantId, parsed, cancellationToken)
.ConfigureAwait(false);
if (fallback is not null)
{
return ToDescriptor(MapToDocument(user, metadata));
var metadata = ParseMetadata(fallback.Metadata);
return ToDescriptor(MapToDocument(fallback, metadata));
}
}
@@ -387,7 +399,7 @@ internal sealed class StandardUserCredentialStore : IUserCredentialStore
if (options.Lockout.Enabled && user.FailedLoginAttempts + 1 >= options.Lockout.MaxAttempts)
{
lockUntil = DateTimeOffset.UtcNow + options.Lockout.Window;
lockUntil = clock.GetUtcNow() + options.Lockout.Window;
}
await userRepository.RecordFailedLoginAsync(tenantId, user.Id, lockUntil, cancellationToken)
@@ -401,14 +413,12 @@ internal sealed class StandardUserCredentialStore : IUserCredentialStore
{
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();
var subjectId = metadata.TryGetValue("subjectId", out var sid) && !string.IsNullOrWhiteSpace(sid?.ToString())
? sid!.ToString()!
: entity.Id.ToString("N");
var roles = ReadRoles(metadata.TryGetValue("roles", out var r) ? r : null);
var attrs = ReadAttributes(metadata.TryGetValue("attributes", out var a) ? a : null);
var requireReset = ReadBoolean(metadata.TryGetValue("requirePasswordReset", out var rr) ? rr : null);
return new StandardUserDocument
{
@@ -421,7 +431,7 @@ internal sealed class StandardUserCredentialStore : IUserCredentialStore
Email = entity.Email,
RequirePasswordReset = requireReset,
Roles = roles,
Attributes = attrs!,
Attributes = attrs,
Lockout = new StandardLockoutState
{
FailedAttempts = entity.FailedLoginAttempts,
@@ -460,6 +470,97 @@ internal sealed class StandardUserCredentialStore : IUserCredentialStore
document.Roles,
document.Attributes);
private static List<string> ReadRoles(object? value)
{
if (value is null)
{
return new List<string>();
}
if (value is JsonElement element && element.ValueKind == JsonValueKind.Array)
{
return element.EnumerateArray()
.Select(entry => entry.GetString() ?? string.Empty)
.Where(entry => !string.IsNullOrWhiteSpace(entry))
.ToList();
}
if (value is IEnumerable<string> strings)
{
return strings.Where(static entry => !string.IsNullOrWhiteSpace(entry))
.Select(static entry => entry.Trim())
.ToList();
}
if (value is IEnumerable<object> values)
{
return values.Select(static entry => entry?.ToString() ?? string.Empty)
.Where(static entry => !string.IsNullOrWhiteSpace(entry))
.Select(static entry => entry.Trim())
.ToList();
}
if (value is string single && !string.IsNullOrWhiteSpace(single))
{
return new List<string> { single.Trim() };
}
return new List<string>();
}
private static Dictionary<string, string?> ReadAttributes(object? value)
{
if (value is null)
{
return new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
}
if (value is JsonElement element && element.ValueKind == JsonValueKind.Object)
{
return element.EnumerateObject()
.ToDictionary(
property => property.Name,
property => property.Value.ValueKind == JsonValueKind.Null ? null : property.Value.ToString(),
StringComparer.OrdinalIgnoreCase);
}
if (value is IReadOnlyDictionary<string, string?> stringMap)
{
return new Dictionary<string, string?>(stringMap, StringComparer.OrdinalIgnoreCase);
}
if (value is IReadOnlyDictionary<string, object?> objectMap)
{
var resolved = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
foreach (var pair in objectMap)
{
resolved[pair.Key] = pair.Value switch
{
null => null,
JsonElement json when json.ValueKind == JsonValueKind.Null => null,
JsonElement json => json.ToString(),
_ => pair.Value.ToString()
};
}
return resolved;
}
return new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
}
private static bool ReadBoolean(object? value)
{
return value switch
{
null => false,
bool flag => flag,
JsonElement json when json.ValueKind == JsonValueKind.True => true,
JsonElement json when json.ValueKind == JsonValueKind.False => false,
_ => false
};
}
private async ValueTask RecordAuditAsync(
string normalizedUsername,
string? subjectId,

View File

@@ -5,9 +5,9 @@ namespace StellaOps.Authority.Plugin.Standard.Storage;
internal sealed class StandardUserDocument
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid Id { get; set; }
public string SubjectId { get; set; } = Guid.NewGuid().ToString("N");
public string SubjectId { get; set; } = string.Empty;
public string Username { get; set; } = string.Empty;
@@ -27,9 +27,9 @@ internal sealed class StandardUserDocument
public StandardLockoutState Lockout { get; set; } = new();
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
public DateTimeOffset UpdatedAt { get; set; }
}
internal sealed class StandardLockoutState

View File

@@ -7,4 +7,4 @@ Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.
| --- | --- | --- |
| AUDIT-0096-M | DONE | Maintainability audit for StellaOps.Authority.Plugin.Standard. |
| AUDIT-0096-T | DONE | Test coverage audit for StellaOps.Authority.Plugin.Standard. |
| AUDIT-0096-A | TODO | Pending approval for changes. |
| AUDIT-0096-A | DONE | Pending approval for changes. |