save progress
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>());
|
||||
});
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<LangVersion>preview</LangVersion>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<IsAuthorityPlugin>true</IsAuthorityPlugin>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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. |
|
||||
|
||||
Reference in New Issue
Block a user