* refactor(identity)!: retire the SecurityRoot policy in favour of endpoint permissions Completes T040. ADR 0010 already decided SecurityRoot was overloaded and that endpoints should be authorized by their own permissions; this removes the last of it. Roles/Create and Applications/Create carried Policies(SecurityRoot) alongside an existing RequirePermission, so the policy was redundant there and the line is simply dropped. Secrets/Hash carried only the policy. By default SecurityRoot resolved to RequireAuthenticatedUser(), so any signed-in caller could exercise the password hasher. It now declares identity/users:create, on the grounds that hashing a secret is a step in provisioning a credential. This is a tightening: callers who could hash before and hold no user-creation permission will now be refused. The policy, its two registration paths and the IdentityPolicyNames constant are removed. ConfigureAuthorizationOptions stays public and now defaults to a no-op so hosts that add their own policies are unaffected. BREAKING CHANGE: the SecurityRoot authorization policy and the IdentityPolicyNames class are removed. Hosts referencing either should rely on endpoint permissions, and use DefaultAdminUserFeature for initial bootstrap. Note: SecurityRoot was the only attachment point for LocalHostPermissionRequirement, so the localhost permission grant is now inert. The requirement type and the EnableLocalHostPermissionGrantForSecurityRoot toggles are left in place rather than deleted, but they no longer gate anything -- see the PR for why that path was already incoherent. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * refactor(identity)!: delete the localhost bootstrap grant and its machinery Follows the SecurityRoot removal in the previous commit. SecurityRoot was the only attachment point for LocalHostPermissionRequirement, so the localhost permission grant is now removed outright rather than left inert: LocalHostPermissionRequirement, LocalHostRequirement (already dead -- registered as a handler but consumed by no policy), LocalHostPermissionRequirementOptions and the two feature toggles all go. The grant was the weakest of the three bootstrap mechanisms Elsa already has. It trusted network position, which stops meaning anything behind a reverse proxy, inside a container, or across a port-forward; it granted unauthenticated access, so the bootstrap action carried no identity; it covered only localhost, so it did nothing for a deployed environment; and it could not perform its headline job, because it granted identity/users:create while POST /identity/users does not carry the policy that injected it. The replacements already exist and both work in deployed environments: UseDefaultAdmin(...) seeds an admin role and user at startup, idempotently, and UseAdminApiKey(...) accepts an out-of-band key. What the localhost grant did usefully provide was a hint that something needed configuring, so IdentityBootstrapDiagnostic replaces that: when the user store is empty and neither mechanism is configured, startup logs an error naming both, instead of every endpoint answering 403 with no explanation. BREAKING CHANGE: LocalHostRequirement, LocalHostPermissionRequirement, LocalHostPermissionRequirementOptions and the Enable/DisableLocalHostPermissionGrantForSecurityRoot toggles are removed. Use UseDefaultAdmin or UseAdminApiKey to bootstrap an instance. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(identity): scope the hash endpoint's documentation to users, and pin the declarations The hash endpoint's remarks said the callers that need it are "the ones standing up users and applications", while the endpoint requires identity/users:create alone. An application provisioner reading that would have been sent into a 403. The documentation was the part that was wrong. `POST /identity/applications` generates and hashes the client secret and the API key itself and returns both the plaintext and the hash, so identity/applications:create is already sufficient to create an application and the hash endpoint is not on that path at all. Say so, in the endpoint and in the migration guide, rather than widening a grant nobody needs. Adds EndpointPermissionTests over the three endpoints that carried the retired SecurityRoot policy: the two that only lost a redundant policy line must keep the permission they already declared, and Secrets/Hash must keep the one it gained. The coverage gate only asks whether an endpoint declares something, so either half could otherwise change unnoticed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(identity): state the hash endpoint's user-only scope in the summary, and complete the removal list Moves the user-only scoping into the endpoint's <summary>, which is the part that reaches the generated API description, rather than leaving it to a paragraph further down. The remark now says outright that no application-provisioning flow reaches this endpoint and none is documented to, with the reason: POST /identity/applications generates the client secret and the API key itself, hashes both, and returns each plaintext alongside its hash. The migration guide's removal list was partial — it named the requirements and the two toggles but not the handlers, the options type, the EnableLocalHostPermissionGrant property on either feature, or the already-obsolete DisableLocalHostRequirement() alias. A reader hitting a compile error on any of those would not have found it in the guide. It also now records that ConfigureAuthorizationOptions survives as a no-op default. Adds the store-failure case to IdentityBootstrapDiagnosticTests: the broad catch is load-bearing — an unmigrated database must not stop the host from starting — and nothing was holding it in place. Disposes the test service provider, and folds the repeated arrange blocks in DefaultAuthenticationFeatureTests into fields. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
118 lines
4.6 KiB
C#
118 lines
4.6 KiB
C#
using Elsa.Identity.Contracts;
|
|
using Elsa.Identity.Entities;
|
|
using Elsa.Identity.HostedServices;
|
|
using Elsa.Identity.Models;
|
|
using Elsa.Identity.Options;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.Logging;
|
|
using OptionsFactory = Microsoft.Extensions.Options.Options;
|
|
|
|
namespace Elsa.Identity.UnitTests.HostedServices;
|
|
|
|
public class IdentityBootstrapDiagnosticTests
|
|
{
|
|
[Fact]
|
|
public async Task ReportsAnInstanceNobodyCanSignInTo()
|
|
{
|
|
var logger = await StartAsync();
|
|
|
|
var error = Assert.Single(logger.Entries, x => x.Level == LogLevel.Error);
|
|
Assert.Contains("UseDefaultAdmin", error.Message);
|
|
Assert.Contains("UseAdminApiKey", error.Message);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task StaysQuietWhenAnAdministratorIsSeeded()
|
|
{
|
|
var logger = await StartAsync(adminUserName: "admin", adminPassword: "secret");
|
|
|
|
Assert.DoesNotContain(logger.Entries, x => x.Level == LogLevel.Error);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task StaysQuietWhenAnAdminApiKeyIsConfigured()
|
|
{
|
|
var logger = await StartAsync(apiKey: "an-api-key");
|
|
|
|
Assert.DoesNotContain(logger.Entries, x => x.Level == LogLevel.Error);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task StaysQuietWhenUsersAlreadyExist()
|
|
{
|
|
// The check is about an unusable instance, not about how it was bootstrapped: once anyone can sign in,
|
|
// an operator who configured nothing declaratively is making a deliberate choice.
|
|
var logger = await StartAsync(existingUser: new() { Id = "1", Name = "someone" });
|
|
|
|
Assert.DoesNotContain(logger.Entries, x => x.Level == LogLevel.Error);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SurvivesAStoreItCannotRead()
|
|
{
|
|
// The whole point of the broad catch: a store that cannot be read yet -- an unmigrated database, a
|
|
// connection that is not up -- must not be the reason the host fails to start. That failure surfaces
|
|
// on its own the moment a real request touches the store.
|
|
var logger = await StartAsync(storeFailure: new InvalidDataException("the database is not migrated"));
|
|
|
|
Assert.DoesNotContain(logger.Entries, x => x.Level == LogLevel.Error);
|
|
Assert.Single(logger.Entries, x => x.Level == LogLevel.Debug);
|
|
}
|
|
|
|
private static async Task<CapturingLogger<IdentityBootstrapDiagnostic>> StartAsync(
|
|
string adminUserName = "",
|
|
string adminPassword = "",
|
|
string apiKey = "",
|
|
User? existingUser = null,
|
|
Exception? storeFailure = null)
|
|
{
|
|
var services = new ServiceCollection();
|
|
services.AddSingleton<IUserStore>(new StubUserStore(existingUser, storeFailure));
|
|
var logger = new CapturingLogger<IdentityBootstrapDiagnostic>();
|
|
|
|
await using var serviceProvider = services.BuildServiceProvider();
|
|
|
|
var diagnostic = new IdentityBootstrapDiagnostic(
|
|
serviceProvider.GetRequiredService<IServiceScopeFactory>(),
|
|
OptionsFactory.Create(new DefaultAdminUserOptions { AdminUserName = adminUserName, AdminPassword = adminPassword }),
|
|
OptionsFactory.Create(new AdminApiKeyOptions { ApiKey = apiKey }),
|
|
logger);
|
|
|
|
await diagnostic.StartAsync(default);
|
|
return logger;
|
|
}
|
|
|
|
private sealed class StubUserStore(User? user, Exception? failure = null) : IUserStore
|
|
{
|
|
public Task SaveAsync(User user, CancellationToken cancellationToken = default) => Task.CompletedTask;
|
|
public Task DeleteAsync(UserFilter filter, CancellationToken cancellationToken = default) => Task.CompletedTask;
|
|
|
|
public Task<IEnumerable<User>> FindManyAsync(UserFilter filter, CancellationToken cancellationToken = default) =>
|
|
failure is not null ? Task.FromException<IEnumerable<User>>(failure) : Task.FromResult<IEnumerable<User>>(user is null ? [] : [user]);
|
|
|
|
public Task<User?> FindAsync(UserFilter filter, CancellationToken cancellationToken = default) => Task.FromResult(user);
|
|
}
|
|
|
|
private sealed class CapturingLogger<T> : ILogger<T>
|
|
{
|
|
public List<LogEntry> Entries { get; } = [];
|
|
|
|
public IDisposable BeginScope<TState>(TState state) where TState : notnull => NullScope.Instance;
|
|
public bool IsEnabled(LogLevel logLevel) => true;
|
|
|
|
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter) =>
|
|
Entries.Add(new(logLevel, formatter(state, exception)));
|
|
}
|
|
|
|
private sealed record LogEntry(LogLevel Level, string Message);
|
|
|
|
private sealed class NullScope : IDisposable
|
|
{
|
|
public static readonly NullScope Instance = new();
|
|
|
|
public void Dispose()
|
|
{
|
|
}
|
|
}
|
|
}
|