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

@@ -0,0 +1,53 @@
using System.Collections.Generic;
using System.Security.Claims;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Configuration;
using StellaOps.Authority.Plugins.Abstractions;
using StellaOps.Authority.Plugin.Standard;
using Xunit;
using StellaOps.TestKit;
namespace StellaOps.Authority.Plugin.Standard.Tests;
public class StandardClaimsEnricherTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task EnrichAsync_AddsRolesAndAttributes()
{
var manifest = new AuthorityPluginManifest(
"standard",
"standard",
true,
"StellaOps.Authority.Plugin.Standard",
"standard.dll",
new[] { AuthorityPluginCapabilities.Password },
new Dictionary<string, string?>(),
"standard.yaml");
var context = new AuthorityClaimsEnrichmentContext(
new AuthorityPluginContext(manifest, new ConfigurationBuilder().Build()),
new AuthorityUserDescriptor(
"subject-1",
"alice",
"Alice",
false,
new[] { "admin", "ops" },
new Dictionary<string, string?>
{
["region"] = "eu",
["team"] = "platform"
}),
client: null);
var identity = new ClaimsIdentity();
var enricher = new StandardClaimsEnricher();
await enricher.EnrichAsync(identity, context, CancellationToken.None);
Assert.Contains(identity.Claims, claim => claim.Type == ClaimTypes.Role && claim.Value == "admin");
Assert.Contains(identity.Claims, claim => claim.Type == ClaimTypes.Role && claim.Value == "ops");
Assert.Contains(identity.Claims, claim => claim.Type == "region" && claim.Value == "eu");
Assert.Contains(identity.Claims, claim => claim.Type == "team" && claim.Value == "platform");
}
}

View File

@@ -147,6 +147,40 @@ public class StandardClientProvisioningStoreTests
Assert.Equal("primary", binding.Label);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task DeleteAsync_RemovesClientAndWritesRevocation()
{
var store = new TrackingClientStore();
var revocations = new TrackingRevocationStore();
var clock = new FakeTimeProvider(DateTimeOffset.Parse("2025-12-29T12:30:00Z"));
var provisioning = new StandardClientProvisioningStore("standard", store, revocations, clock);
var registration = new AuthorityClientRegistration(
clientId: "delete-me",
confidential: false,
displayName: "Delete Me",
clientSecret: null,
allowedGrantTypes: new[] { "client_credentials" },
allowedScopes: new[] { "scopeA" });
await provisioning.CreateOrUpdateAsync(registration, CancellationToken.None);
var result = await provisioning.DeleteAsync("delete-me", CancellationToken.None);
Assert.True(result.Succeeded);
Assert.False(store.Documents.ContainsKey("delete-me"));
var revocation = Assert.Single(revocations.Upserts);
Assert.Equal("client", revocation.Category);
Assert.Equal("delete-me", revocation.RevocationId);
Assert.Equal("delete-me", revocation.ClientId);
Assert.Equal("operator_request", revocation.Reason);
Assert.Equal(clock.GetUtcNow(), revocation.RevokedAt);
Assert.Equal(clock.GetUtcNow(), revocation.EffectiveAt);
Assert.Equal("standard", revocation.Metadata["plugin"]);
}
private sealed class TrackingClientStore : IAuthorityClientStore
{
public Dictionary<string, AuthorityClientDocument> Documents { get; } = new(StringComparer.OrdinalIgnoreCase);
@@ -186,4 +220,13 @@ public class StandardClientProvisioningStoreTests
public ValueTask<IReadOnlyList<AuthorityRevocationDocument>> GetActiveAsync(DateTimeOffset asOf, CancellationToken cancellationToken, IClientSessionHandle? session = null)
=> ValueTask.FromResult<IReadOnlyList<AuthorityRevocationDocument>>(Array.Empty<AuthorityRevocationDocument>());
}
private sealed class FakeTimeProvider : TimeProvider
{
private readonly DateTimeOffset fixedNow;
public FakeTimeProvider(DateTimeOffset fixedNow) => this.fixedNow = fixedNow;
public override DateTimeOffset GetUtcNow() => fixedNow;
}
}

View File

@@ -0,0 +1,72 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Authority.Persistence.InMemory.Stores;
using StellaOps.Authority.Plugins.Abstractions;
using StellaOps.Authority.Plugin.Standard;
using StellaOps.Authority.Plugin.Standard.Security;
using StellaOps.Authority.Plugin.Standard.Storage;
using StellaOps.Cryptography;
using Xunit;
using StellaOps.TestKit;
namespace StellaOps.Authority.Plugin.Standard.Tests;
public class StandardIdentityProviderPluginTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task CheckHealthAsync_ReturnsHealthy()
{
var manifest = new AuthorityPluginManifest(
"standard",
"standard",
true,
"StellaOps.Authority.Plugin.Standard",
"standard.dll",
new[] { AuthorityPluginCapabilities.Password, AuthorityPluginCapabilities.Bootstrap, AuthorityPluginCapabilities.ClientProvisioning },
new Dictionary<string, string?>(),
"standard.yaml");
var context = new AuthorityPluginContext(manifest, new ConfigurationBuilder().Build());
var userRepository = new InMemoryUserRepository();
var options = new StandardPluginOptions();
var cryptoProvider = new DefaultCryptoProvider();
var auditLogger = new TestAuditLogger();
var store = new StandardUserCredentialStore(
"standard",
"tenant-1",
userRepository,
options,
new CryptoPasswordHasher(options, cryptoProvider),
auditLogger,
TimeProvider.System,
new FixedStandardIdGenerator(),
NullLogger<StandardUserCredentialStore>.Instance);
var clientStore = new InMemoryClientStore();
var revocationStore = new InMemoryRevocationStore();
var provisioning = new StandardClientProvisioningStore("standard", clientStore, revocationStore, TimeProvider.System);
var plugin = new StandardIdentityProviderPlugin(
context,
store,
provisioning,
new StandardClaimsEnricher(),
NullLogger<StandardIdentityProviderPlugin>.Instance);
var health = await plugin.CheckHealthAsync(CancellationToken.None);
Assert.Equal(AuthorityPluginHealthStatus.Healthy, health.Status);
}
private sealed class FixedStandardIdGenerator : IStandardIdGenerator
{
public Guid NewUserId() => Guid.Parse("00000000-0000-0000-0000-000000000201");
public string NewSubjectId() => "subject-201";
}
}

View File

@@ -0,0 +1,140 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Authority.Persistence.Postgres.Models;
using StellaOps.Authority.Persistence.Postgres.Repositories;
using StellaOps.Authority.Plugins.Abstractions;
using StellaOps.Authority.Plugin.Standard;
using StellaOps.Authority.Plugin.Standard.Bootstrap;
using StellaOps.Authority.Plugin.Standard.Security;
using StellaOps.Authority.Plugin.Standard.Storage;
using StellaOps.Cryptography;
using StellaOps.Cryptography.Audit;
using Xunit;
using StellaOps.TestKit;
namespace StellaOps.Authority.Plugin.Standard.Tests;
public class StandardPluginBootstrapperTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task StartAsync_DoesNotThrow_WhenBootstrapFails()
{
var services = new ServiceCollection();
services.AddOptions<StandardPluginOptions>("standard")
.Configure(options =>
{
options.BootstrapUser = new BootstrapUserOptions
{
Username = "bootstrap",
Password = "Password1!",
RequirePasswordReset = false
};
});
services.AddSingleton<IUserRepository>(new ThrowingUserRepository());
services.AddSingleton<IStandardCredentialAuditLogger, NullAuditLogger>();
services.AddSingleton<TimeProvider>(new FakeTimeProvider(DateTimeOffset.Parse("2025-12-29T13:00:00Z")));
services.AddSingleton<IStandardIdGenerator>(new FixedStandardIdGenerator());
services.AddSingleton<ICryptoProvider>(new DefaultCryptoProvider());
services.AddSingleton(sp =>
{
var optionsMonitor = sp.GetRequiredService<IOptionsMonitor<StandardPluginOptions>>();
var options = optionsMonitor.Get("standard");
var cryptoProvider = sp.GetRequiredService<ICryptoProvider>();
var auditLogger = sp.GetRequiredService<IStandardCredentialAuditLogger>();
return new StandardUserCredentialStore(
"standard",
"tenant-1",
sp.GetRequiredService<IUserRepository>(),
options,
new CryptoPasswordHasher(options, cryptoProvider),
auditLogger,
sp.GetRequiredService<TimeProvider>(),
sp.GetRequiredService<IStandardIdGenerator>(),
NullLogger<StandardUserCredentialStore>.Instance);
});
services.AddSingleton<StandardPluginBootstrapper>(sp =>
new StandardPluginBootstrapper("standard", sp.GetRequiredService<IServiceScopeFactory>(), NullLogger<StandardPluginBootstrapper>.Instance));
using var provider = services.BuildServiceProvider();
var bootstrapper = provider.GetRequiredService<StandardPluginBootstrapper>();
var exception = await Record.ExceptionAsync(() => bootstrapper.StartAsync(CancellationToken.None));
Assert.Null(exception);
}
private sealed class ThrowingUserRepository : IUserRepository
{
public Task<UserEntity> CreateAsync(UserEntity user, CancellationToken cancellationToken = default)
=> throw new InvalidOperationException("Simulated failure");
public Task<UserEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
=> throw new InvalidOperationException("Simulated failure");
public Task<UserEntity?> GetByUsernameAsync(string tenantId, string username, CancellationToken cancellationToken = default)
=> throw new InvalidOperationException("Simulated failure");
public Task<UserEntity?> GetBySubjectIdAsync(string tenantId, string subjectId, CancellationToken cancellationToken = default)
=> throw new InvalidOperationException("Simulated failure");
public Task<UserEntity?> GetByEmailAsync(string tenantId, string email, CancellationToken cancellationToken = default)
=> throw new InvalidOperationException("Simulated failure");
public Task<IReadOnlyList<UserEntity>> GetAllAsync(string tenantId, bool? enabled = null, int limit = 100, int offset = 0, CancellationToken cancellationToken = default)
=> throw new InvalidOperationException("Simulated failure");
public Task<bool> UpdateAsync(UserEntity user, CancellationToken cancellationToken = default)
=> throw new InvalidOperationException("Simulated failure");
public Task<bool> DeleteAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
=> throw new InvalidOperationException("Simulated failure");
public Task<bool> UpdatePasswordAsync(string tenantId, Guid userId, string passwordHash, string passwordSalt, CancellationToken cancellationToken = default)
=> throw new InvalidOperationException("Simulated failure");
public Task<int> RecordFailedLoginAsync(string tenantId, Guid userId, DateTimeOffset? lockUntil = null, CancellationToken cancellationToken = default)
=> throw new InvalidOperationException("Simulated failure");
public Task RecordSuccessfulLoginAsync(string tenantId, Guid userId, CancellationToken cancellationToken = default)
=> throw new InvalidOperationException("Simulated failure");
}
private sealed class NullAuditLogger : IStandardCredentialAuditLogger
{
public ValueTask RecordAsync(
string pluginName,
string normalizedUsername,
string? subjectId,
bool success,
AuthorityCredentialFailureCode? failureCode,
string? reason,
IReadOnlyList<AuthEventProperty>? properties,
CancellationToken cancellationToken)
=> ValueTask.CompletedTask;
}
private sealed class FakeTimeProvider : TimeProvider
{
private readonly DateTimeOffset fixedNow;
public FakeTimeProvider(DateTimeOffset fixedNow) => this.fixedNow = fixedNow;
public override DateTimeOffset GetUtcNow() => fixedNow;
}
private sealed class FixedStandardIdGenerator : IStandardIdGenerator
{
public Guid NewUserId() => Guid.Parse("00000000-0000-0000-0000-000000000301");
public string NewSubjectId() => "subject-301";
}
}

View File

@@ -104,6 +104,40 @@ public class StandardPluginOptionsTests
Assert.Equal(Path.GetFullPath(absolute), options.TokenSigning.KeyDirectory);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Normalize_TrimsTenantAndBootstrapValues()
{
var options = new StandardPluginOptions
{
TenantId = " Tenant-A ",
BootstrapUser = new BootstrapUserOptions
{
Username = " admin ",
Password = " "
}
};
options.Normalize(Path.Combine(Path.GetTempPath(), "config", "standard.yaml"));
Assert.Equal("tenant-a", options.TenantId);
Assert.Equal("admin", options.BootstrapUser?.Username);
Assert.Null(options.BootstrapUser?.Password);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Validate_Throws_WhenTokenSigningConfigured()
{
var options = new StandardPluginOptions
{
TokenSigning = { KeyDirectory = "/tmp/keys" }
};
var ex = Assert.Throws<InvalidOperationException>(() => options.Validate("standard"));
Assert.Contains("token signing", ex.Message, StringComparison.OrdinalIgnoreCase);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Validate_Throws_WhenPasswordHashingMemoryInvalid()

View File

@@ -208,7 +208,7 @@ public class StandardPluginRegistrarTests
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Register_NormalizesTokenSigningKeyDirectory()
public void Register_Throws_WhenTokenSigningKeyDirectoryConfigured()
{
var client = new InMemoryClient();
var database = client.GetDatabase("registrar-token-signing");
@@ -238,7 +238,6 @@ public class StandardPluginRegistrarTests
var pluginContext = new AuthorityPluginContext(manifest, configuration);
var services = StandardPluginRegistrarTestHelpers.CreateServiceCollection(database, configuration);
services.AddSingleton(TimeProvider.System);
var registrar = new StandardPluginRegistrar();
registrar.Register(new AuthorityPluginRegistrationContext(services, pluginContext, configuration));
@@ -246,10 +245,7 @@ public class StandardPluginRegistrarTests
using var provider = services.BuildServiceProvider();
var optionsMonitor = provider.GetRequiredService<IOptionsMonitor<StandardPluginOptions>>();
var options = optionsMonitor.Get("standard");
var expected = Path.GetFullPath(Path.Combine(configDir, "../keys"));
Assert.Equal(expected, options.TokenSigning.KeyDirectory);
Assert.Throws<InvalidOperationException>(() => optionsMonitor.Get("standard"));
}
finally
{

View File

@@ -23,6 +23,8 @@ public class StandardUserCredentialStoreTests : IAsyncLifetime
private readonly StandardPluginOptions options;
private readonly StandardUserCredentialStore store;
private readonly TestAuditLogger auditLogger;
private readonly FakeTimeProvider clock;
private readonly SequenceStandardIdGenerator idGenerator;
public StandardUserCredentialStoreTests()
{
@@ -53,6 +55,8 @@ public class StandardUserCredentialStoreTests : IAsyncLifetime
var cryptoProvider = new DefaultCryptoProvider();
auditLogger = new TestAuditLogger();
userRepository = new InMemoryUserRepository();
clock = new FakeTimeProvider(DateTimeOffset.Parse("2025-12-29T12:00:00Z"));
idGenerator = new SequenceStandardIdGenerator();
store = new StandardUserCredentialStore(
"standard",
"test-tenant",
@@ -60,6 +64,8 @@ public class StandardUserCredentialStoreTests : IAsyncLifetime
options,
new CryptoPasswordHasher(options, cryptoProvider),
auditLogger,
clock,
idGenerator,
NullLogger<StandardUserCredentialStore>.Instance);
}
@@ -155,7 +161,7 @@ public class StandardUserCredentialStoreTests : IAsyncLifetime
await userRepository.CreateAsync(new UserEntity
{
Id = Guid.NewGuid(),
Id = Guid.Parse("00000000-0000-0000-0000-000000000101"),
TenantId = "test-tenant",
Username = "legacy",
Email = "legacy@local",
@@ -188,6 +194,87 @@ public class StandardUserCredentialStoreTests : IAsyncLifetime
Assert.StartsWith("$argon2id$", updated!.PasswordHash, StringComparison.Ordinal);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task UpsertUserAsync_PreservesRolesAndAttributesOnUpdate()
{
var registration = new AuthorityUserRegistration(
"chris",
"Password1!",
"Chris",
null,
false,
new[] { "viewer" },
new Dictionary<string, string?>
{
["region"] = "eu"
});
var created = await store.UpsertUserAsync(registration, CancellationToken.None);
Assert.True(created.Succeeded);
var update = new AuthorityUserRegistration(
"chris",
password: null,
displayName: "Chris Updated",
email: null,
requirePasswordReset: true,
roles: new[] { "editor", "admin" },
attributes: new Dictionary<string, string?>
{
["region"] = "us",
["team"] = "platform"
});
var updated = await store.UpsertUserAsync(update, CancellationToken.None);
Assert.True(updated.Succeeded);
Assert.Contains("editor", updated.Value.Roles);
Assert.Contains("admin", updated.Value.Roles);
Assert.Equal("us", updated.Value.Attributes["region"]);
Assert.Equal("platform", updated.Value.Attributes["team"]);
Assert.True(updated.Value.RequiresPasswordReset);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task FindBySubjectAsync_ReturnsUserWhenSubjectMatches()
{
var registration = new AuthorityUserRegistration(
"dana",
"Password1!",
"Dana",
null,
false,
Array.Empty<string>(),
new Dictionary<string, string?>());
var created = await store.UpsertUserAsync(registration, CancellationToken.None);
Assert.True(created.Succeeded);
var found = await store.FindBySubjectAsync(created.Value.SubjectId, CancellationToken.None);
Assert.NotNull(found);
Assert.Equal("dana", found!.Username);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task UpsertUserAsync_RejectsWeakPasswords()
{
var registration = new AuthorityUserRegistration(
"erin",
"short",
"Erin",
null,
false,
Array.Empty<string>(),
new Dictionary<string, string?>());
var result = await store.UpsertUserAsync(registration, CancellationToken.None);
Assert.False(result.Succeeded);
Assert.Equal("password_policy_violation", result.ErrorCode);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task VerifyPasswordAsync_RecordsAudit_ForUnknownUser()
@@ -249,6 +336,34 @@ internal sealed class TestAuditLogger : IStandardCredentialAuditLogger
IReadOnlyList<AuthEventProperty> Properties);
}
internal sealed class FakeTimeProvider : TimeProvider
{
private readonly DateTimeOffset fixedNow;
public FakeTimeProvider(DateTimeOffset fixedNow) => this.fixedNow = fixedNow;
public override DateTimeOffset GetUtcNow() => fixedNow;
}
internal sealed class SequenceStandardIdGenerator : IStandardIdGenerator
{
private int userCounter;
private int subjectCounter;
public Guid NewUserId()
{
userCounter++;
var suffix = userCounter.ToString("D12", CultureInfo.InvariantCulture);
return Guid.Parse($"00000000-0000-0000-0000-{suffix}");
}
public string NewSubjectId()
{
subjectCounter++;
return $"subject-{subjectCounter}";
}
}

View File

@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Authority.Persistence.Postgres.Models;
@@ -70,6 +71,24 @@ internal sealed class InMemoryUserRepository : IUserRepository
return Task.FromResult<UserEntity?>(null);
}
public Task<UserEntity?> GetBySubjectIdAsync(string tenantId, string subjectId, CancellationToken cancellationToken = default)
{
foreach (var user in users.Values)
{
if (!string.Equals(user.TenantId, tenantId, StringComparison.Ordinal))
{
continue;
}
if (TryGetSubjectId(user.Metadata, out var stored) && string.Equals(stored, subjectId, StringComparison.Ordinal))
{
return Task.FromResult<UserEntity?>(user);
}
}
return Task.FromResult<UserEntity?>(null);
}
public Task<UserEntity?> GetByEmailAsync(string tenantId, string email, CancellationToken cancellationToken = default)
{
var key = GetEmailKey(tenantId, email);
@@ -278,4 +297,34 @@ internal sealed class InMemoryUserRepository : IUserRepository
private static string GetEmailKey(string tenantId, string email)
=> $"{tenantId}::{email}".ToLowerInvariant();
private static bool TryGetSubjectId(string? metadataJson, out string? subjectId)
{
subjectId = null;
if (string.IsNullOrWhiteSpace(metadataJson))
{
return false;
}
try
{
using var document = JsonDocument.Parse(metadataJson);
if (document.RootElement.ValueKind != JsonValueKind.Object)
{
return false;
}
if (document.RootElement.TryGetProperty("subjectId", out var subjectElement)
&& subjectElement.ValueKind == JsonValueKind.String)
{
subjectId = subjectElement.GetString();
return !string.IsNullOrWhiteSpace(subjectId);
}
}
catch
{
}
return false;
}
}