* 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>
93 lines
4.6 KiB
C#
93 lines
4.6 KiB
C#
using System.Reflection;
|
|
using System.Runtime.CompilerServices;
|
|
using Elsa.Authorization;
|
|
using Elsa.Identity.Permissions;
|
|
using Elsa.Identity.Services;
|
|
using Elsa.Permissions;
|
|
using FastEndpoints;
|
|
|
|
namespace Elsa.Identity.UnitTests.Authorization;
|
|
|
|
/// <summary>
|
|
/// Pins what the three endpoints that used to carry the <c>SecurityRoot</c> policy require now that the
|
|
/// policy is gone, and checks that what they require is something the Identity catalog advertises.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Two of the three (<c>Roles/Create</c>, <c>Applications/Create</c>) only lost a redundant policy line and
|
|
/// must keep the permission they already declared; <c>Secrets/Hash</c> gained one where it previously had
|
|
/// nothing but "any authenticated caller". The coverage gate only asks whether an endpoint declares
|
|
/// <em>something</em>, so without these rows either half of that could silently change.
|
|
/// </remarks>
|
|
public class EndpointPermissionTests
|
|
{
|
|
private static readonly Assembly Module = typeof(RoleAuthorizationService).Assembly;
|
|
|
|
public static TheoryData<string, string, string> Declarations => new()
|
|
{
|
|
{ "Elsa.Identity.Endpoints.Secrets.Hash.Hash", IdentityPermissions.Users, CoreVerbs.Create },
|
|
{ "Elsa.Identity.Endpoints.Roles.Create.Create", IdentityPermissions.Roles, CoreVerbs.Create },
|
|
{ "Elsa.Identity.Endpoints.Applications.Create.Create", IdentityPermissions.Applications, CoreVerbs.Create }
|
|
};
|
|
|
|
[Theory]
|
|
[MemberData(nameof(Declarations))]
|
|
public void EndpointDeclaresItsExpectedPermission(string endpointTypeName, string resource, string verb) =>
|
|
Assert.Equal(new Permission(resource, verb), Declare(endpointTypeName));
|
|
|
|
[Theory]
|
|
[MemberData(nameof(Declarations))]
|
|
public void EveryDeclaredPermissionIsAdvertisedByTheCatalog(string endpointTypeName, string resource, string verb)
|
|
{
|
|
var declared = Declare(endpointTypeName);
|
|
var descriptor = new IdentityPermissionsDescriptorProvider().GetDescriptors().SingleOrDefault(x => x.Resource == declared.Resource);
|
|
|
|
Assert.True(descriptor is not null, $"{endpointTypeName} requires resource '{declared.Resource}', which the module contributes no descriptor for, so it cannot be granted through the role editor.");
|
|
Assert.True(descriptor!.Supports(declared.Verb), $"{endpointTypeName} requires '{declared}', but '{declared.Resource}' advertises only [{string.Join(", ", descriptor.SupportedVerbs)}].");
|
|
Assert.Equal(new Permission(resource, verb), declared);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Runs one endpoint's <c>Configure()</c> and returns what it recorded. The requirement is attached as an
|
|
/// inline policy, which cannot be read back off the definition, so the registry is the only way to observe
|
|
/// a declaration without booting a host. The instance skips its constructor because <c>Configure()</c>
|
|
/// touches none of the injected services, and substituting them would make the rows depend on which
|
|
/// dependencies happen to be interfaces.
|
|
/// </summary>
|
|
private static Permission Declare(string endpointTypeName)
|
|
{
|
|
var endpointType = Module.GetType(endpointTypeName, true)!;
|
|
var endpoint = RuntimeHelpers.GetUninitializedObject(endpointType);
|
|
var (requestType, responseType) = DtoTypes(endpointType);
|
|
|
|
endpointType.GetProperty("Definition", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)!
|
|
.SetValue(endpoint, new EndpointDefinition(endpointType, requestType, responseType));
|
|
endpointType.GetMethod("Configure")!.Invoke(endpoint, null);
|
|
|
|
var permission = EndpointPermissionRegistry.Find(endpointType);
|
|
|
|
Assert.True(permission.HasValue, $"{endpointTypeName} declares no permission.");
|
|
return permission!.Value;
|
|
}
|
|
|
|
private static (Type Request, Type Response) DtoTypes(Type endpointType)
|
|
{
|
|
for (var type = endpointType.BaseType; type is not null; type = type.BaseType)
|
|
{
|
|
if (!type.IsGenericType)
|
|
continue;
|
|
|
|
var definition = type.GetGenericTypeDefinition();
|
|
var arguments = type.GetGenericArguments();
|
|
|
|
if (definition == typeof(Elsa.Abstractions.ElsaEndpoint<,>))
|
|
return (arguments[0], arguments[1]);
|
|
if (definition == typeof(Elsa.Abstractions.ElsaEndpointWithoutRequest<>))
|
|
return (typeof(EmptyRequest), arguments[0]);
|
|
if (definition == typeof(Elsa.Abstractions.ElsaEndpoint<>))
|
|
return (arguments[0], typeof(object));
|
|
}
|
|
|
|
throw new InvalidOperationException($"Unsupported endpoint type '{endpointType.FullName}'.");
|
|
}
|
|
}
|