Merge remote-tracking branch 'origin/main' into codex/security-identity-secret-hashing

# Conflicts:
#	src/modules/Elsa.Identity/README.md
This commit is contained in:
Sipke Schoorstra 2026-05-21 00:49:15 +02:00
commit 8b9f005f1d
No known key found for this signature in database
GPG key ID: 5C10502B28A4268F
7 changed files with 330 additions and 19 deletions

View file

@ -0,0 +1,12 @@
namespace Elsa.Options;
/// <summary>
/// Options for the localhost permission requirement.
/// </summary>
public class LocalHostPermissionRequirementOptions
{
/// <summary>
/// Gets or sets whether localhost requests may satisfy the security-root permission requirement without other credentials.
/// </summary>
public bool EnableLocalHostPermissionGrant { get; set; }
}

View file

@ -1,14 +1,16 @@
using System.Security.Claims;
using Elsa.Extensions;
using Elsa.Options;
using JetBrains.Annotations;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Options;
namespace Elsa.Requirements;
/// <summary>
/// Add the "create:application" permission to the current user if the request is local.
/// Adds security-root bootstrap permissions to the current user when explicit localhost permission grants are enabled and the request is local.
/// </summary>
public class LocalHostPermissionRequirement : IAuthorizationRequirement
{
@ -18,32 +20,58 @@ public class LocalHostPermissionRequirement : IAuthorizationRequirement
[PublicAPI]
public class LocalHostPermissionRequirementHandler : AuthorizationHandler<LocalHostPermissionRequirement>
{
private static readonly string[] BootstrapPermissions =
[
"create:application",
"create:user",
"create:role"
];
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly IOptions<LocalHostPermissionRequirementOptions> _options;
/// <inheritdoc />
public LocalHostPermissionRequirementHandler(IHttpContextAccessor httpContextAccessor)
public LocalHostPermissionRequirementHandler(IHttpContextAccessor httpContextAccessor) : this(
httpContextAccessor,
Microsoft.Extensions.Options.Options.Create(new LocalHostPermissionRequirementOptions()))
{
}
/// <inheritdoc />
public LocalHostPermissionRequirementHandler(IHttpContextAccessor httpContextAccessor, IOptions<LocalHostPermissionRequirementOptions> options)
{
_httpContextAccessor = httpContextAccessor;
_options = options;
}
/// <inheritdoc />
protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, LocalHostPermissionRequirement requirement)
{
if (_httpContextAccessor.HttpContext?.Request.IsLocal() == false)
if (!_options.Value.EnableLocalHostPermissionGrant)
return Task.CompletedTask;
var currentIdentity = context.User.Identity;
if (currentIdentity?.IsAuthenticated == false)
if (_httpContextAccessor.HttpContext?.Request.IsLocal() != true)
return Task.CompletedTask;
if (context.User.Identities.Any(x => x.IsAuthenticated))
{
var identity = new ClaimsIdentity(JwtBearerDefaults.AuthenticationScheme);
identity.AddClaim(new Claim("permissions", "create:application"));
identity.AddClaim(new Claim("permissions", "create:user"));
identity.AddClaim(new Claim("permissions", "create:role"));
context.User.AddIdentity(identity);
if (HasBootstrapPermissions(context.User))
context.Succeed(requirement);
return Task.CompletedTask;
}
var identity = new ClaimsIdentity(JwtBearerDefaults.AuthenticationScheme);
identity.AddClaims(BootstrapPermissions.Select(permission => new Claim(PermissionNames.ClaimType, permission)));
context.User.AddIdentity(identity);
context.Succeed(requirement);
return Task.CompletedTask;
}
}
private static bool HasBootstrapPermissions(ClaimsPrincipal user)
{
var permissions = user.FindAll(PermissionNames.ClaimType).Select(x => x.Value).ToHashSet(StringComparer.Ordinal);
return permissions.Contains(PermissionNames.All) || BootstrapPermissions.All(permissions.Contains);
}
}

View file

@ -6,6 +6,7 @@ using Elsa.Features.Services;
using Elsa.Identity.Constants;
using Elsa.Identity.Options;
using Elsa.Identity.Providers;
using Elsa.Options;
using Elsa.Requirements;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.JwtBearer;
@ -22,17 +23,32 @@ public class DefaultAuthenticationFeature : FeatureBase
{
private const string MultiScheme = "Jwt-or-ApiKey";
private Func<AuthenticationBuilder, AuthenticationBuilder> _configureApiKeyAuthorization = builder => builder.AddApiKeyInAuthorizationHeader<DefaultApiKeyProvider>();
private Action<AuthorizationOptions>? _configureAuthorizationOptions;
/// <inheritdoc />
public DefaultAuthenticationFeature(IModule module) : base(module)
{
ConfigureAuthorizationOptions = ConfigureDefaultSecurityRootPolicy;
}
/// <summary>
/// Gets or sets the <see cref="ApiKeyProviderType"/>.
/// </summary>
public Type ApiKeyProviderType { get; set; } = typeof(DefaultApiKeyProvider);
public Action<AuthorizationOptions> ConfigureAuthorizationOptions { get; set; } = options => options.AddPolicy(IdentityPolicyNames.SecurityRoot, policy => policy.AddRequirements(new LocalHostPermissionRequirement()));
/// <summary>
/// Gets or sets the authorization options configuration.
/// </summary>
public Action<AuthorizationOptions> ConfigureAuthorizationOptions
{
get => _configureAuthorizationOptions ?? ConfigureDefaultSecurityRootPolicy;
set => _configureAuthorizationOptions = value ?? ConfigureDefaultSecurityRootPolicy;
}
/// <summary>
/// Gets or sets whether localhost requests may satisfy the security-root permission requirement without other credentials.
/// </summary>
public bool EnableLocalHostPermissionGrant { get; set; }
/// <summary>
/// Configures the API key provider type.
@ -79,23 +95,40 @@ public class DefaultAuthenticationFeature : FeatureBase
/// </summary>
/// <returns>The current <see cref="DefaultAuthenticationFeature"/>.</returns>
public DefaultAuthenticationFeature UseDevelopmentAdminApiKey() => UseAdminApiKey(AdminApiKeyProvider.DevelopmentApiKey);
/// <summary>
/// Disables the local host requirement for the security root policy.
/// This is useful when privileged identity bootstrap is handled through features such as <see cref="DefaultAdminUserFeature"/>.
/// Enables the legacy localhost permission grant for the security root policy.
/// </summary>
public DefaultAuthenticationFeature DisableLocalHostRequirement()
public DefaultAuthenticationFeature EnableLocalHostPermissionGrantForSecurityRoot()
{
ConfigureAuthorizationOptions = options => options.AddPolicy(IdentityPolicyNames.SecurityRoot, policy => policy.RequireAuthenticatedUser());
EnableLocalHostPermissionGrant = true;
return this;
}
/// <summary>
/// Disables the localhost permission grant for the security root policy.
/// This is useful when privileged identity bootstrap is handled through features such as <see cref="DefaultAdminUserFeature"/>.
/// </summary>
public DefaultAuthenticationFeature DisableLocalHostPermissionGrantForSecurityRoot()
{
EnableLocalHostPermissionGrant = false;
return this;
}
/// <summary>
/// Disables the legacy localhost permission grant for the security root policy.
/// This is useful when privileged identity bootstrap is handled through features such as <see cref="DefaultAdminUserFeature"/>.
/// </summary>
[Obsolete("Use DisableLocalHostPermissionGrantForSecurityRoot instead.")]
public DefaultAuthenticationFeature DisableLocalHostRequirement() => DisableLocalHostPermissionGrantForSecurityRoot();
/// <inheritdoc />
public override void Apply()
{
Services.ConfigureOptions<ConfigureJwtBearerOptions>();
Services.Configure<AdminApiKeyOptions>(_ => { });
Services.AddIdentityTokenOptionsValidation();
Services.Configure<LocalHostPermissionRequirementOptions>(options => options.EnableLocalHostPermissionGrant = EnableLocalHostPermissionGrant);
var authBuilder = Services
.AddAuthentication(MultiScheme)
@ -119,4 +152,22 @@ public class DefaultAuthenticationFeature : FeatureBase
Services.AddScoped<IApiKeyProvider>(sp => (IApiKeyProvider)sp.GetRequiredService(ApiKeyProviderType));
Services.AddAuthorization(ConfigureAuthorizationOptions);
}
private static void ConfigureAuthenticatedSecurityRootPolicy(AuthorizationOptions options)
{
options.AddPolicy(IdentityPolicyNames.SecurityRoot, policy => policy.RequireAuthenticatedUser());
}
private void ConfigureDefaultSecurityRootPolicy(AuthorizationOptions options)
{
if (EnableLocalHostPermissionGrant)
ConfigureLocalHostSecurityRootPolicy(options);
else
ConfigureAuthenticatedSecurityRootPolicy(options);
}
private static void ConfigureLocalHostSecurityRootPolicy(AuthorizationOptions options)
{
options.AddPolicy(IdentityPolicyNames.SecurityRoot, policy => policy.AddRequirements(new LocalHostPermissionRequirement()));
}
}

View file

@ -83,6 +83,7 @@ identity.UseDefaultAdmin("admin", "REPLACE_WITH_SECURE_BOOTSTRAP_PASSWORD", "adm
- Do not keep development defaults in production.
- Prefer environment variables or a secret manager for admin credentials.
- After first bootstrap, rotate credentials according to your security policy.
- Localhost requests no longer satisfy `SecurityRoot` by default. Legacy localhost bootstrap requires an explicit opt-in: call `EnableLocalHostPermissionGrantForSecurityRoot()` in code-first configuration or set `EnableLocalHostPermissionGrant` on the shell `DefaultAuthentication` feature; prefer `DefaultAdminUser` instead.
## Secret Hashing

View file

@ -4,6 +4,7 @@ using Elsa.Extensions;
using Elsa.Identity.Constants;
using Elsa.Identity.Options;
using Elsa.Identity.Providers;
using Elsa.Options;
using Elsa.PackageManifest.Generator.Hints;
using Elsa.Requirements;
using JetBrains.Annotations;
@ -53,6 +54,11 @@ public class DefaultAuthenticationFeature : IShellFeature
RestartRequired = true)]
public bool UseDevelopmentAdminApiKey { get; set; }
/// <summary>
/// Gets or sets whether localhost requests may satisfy the security-root permission requirement without other credentials.
/// </summary>
public bool EnableLocalHostPermissionGrant { get; set; }
public void ConfigureServices(IServiceCollection services)
{
var resolvedAdminApiKey = UseDevelopmentAdminApiKey ? AdminApiKeyProvider.DevelopmentApiKey : AdminApiKey;
@ -61,6 +67,7 @@ public class DefaultAuthenticationFeature : IShellFeature
services.ConfigureOptions<ConfigureJwtBearerOptions>();
services.AddIdentityTokenOptionsValidation();
services.Configure<LocalHostPermissionRequirementOptions>(options => options.EnableLocalHostPermissionGrant = EnableLocalHostPermissionGrant);
services.Configure<AdminApiKeyOptions>(options =>
{
options.ApiKey = resolvedAdminApiKey;
@ -93,7 +100,10 @@ public class DefaultAuthenticationFeature : IShellFeature
services.AddAuthorization(options =>
{
options.AddPolicy(IdentityPolicyNames.SecurityRoot, policy => policy.RequireAuthenticatedUser());
if (EnableLocalHostPermissionGrant)
options.AddPolicy(IdentityPolicyNames.SecurityRoot, policy => policy.AddRequirements(new LocalHostPermissionRequirement()));
else
options.AddPolicy(IdentityPolicyNames.SecurityRoot, policy => policy.RequireAuthenticatedUser());
});
}
}

View file

@ -0,0 +1,85 @@
using Elsa.Features.Services;
using Elsa.Identity.Features;
using Elsa.Requirements;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Authorization.Infrastructure;
using NSubstitute;
namespace Elsa.Identity.UnitTests.Features;
public class DefaultAuthenticationFeatureTests
{
[Fact]
public void DefaultSecurityRootPolicyRequiresAuthenticatedUser()
{
var feature = new DefaultAuthenticationFeature(Substitute.For<IModule>());
var options = new AuthorizationOptions();
feature.ConfigureAuthorizationOptions(options);
var policy = options.GetPolicy(IdentityPolicyNames.SecurityRoot);
Assert.NotNull(policy);
Assert.Contains(policy.Requirements, requirement => requirement is DenyAnonymousAuthorizationRequirement);
Assert.DoesNotContain(policy.Requirements, requirement => requirement is LocalHostPermissionRequirement);
}
[Fact]
public void EnableLocalHostPermissionGrantForSecurityRootConfiguresExplicitLocalhostPolicy()
{
var feature = new DefaultAuthenticationFeature(Substitute.For<IModule>());
var options = new AuthorizationOptions();
feature.EnableLocalHostPermissionGrantForSecurityRoot();
feature.ConfigureAuthorizationOptions(options);
var policy = options.GetPolicy(IdentityPolicyNames.SecurityRoot);
Assert.True(feature.EnableLocalHostPermissionGrant);
Assert.NotNull(policy);
Assert.Contains(policy.Requirements, requirement => requirement is LocalHostPermissionRequirement);
}
[Fact]
public void EnableLocalHostPermissionGrantDoesNotOverwriteCustomAuthorizationConfiguration()
{
var feature = new DefaultAuthenticationFeature(Substitute.For<IModule>());
feature.ConfigureAuthorizationOptions = options => options.AddPolicy("Custom", policy => policy.RequireAuthenticatedUser());
feature.EnableLocalHostPermissionGrantForSecurityRoot();
var options = new AuthorizationOptions();
feature.ConfigureAuthorizationOptions(options);
Assert.True(feature.EnableLocalHostPermissionGrant);
Assert.NotNull(options.GetPolicy("Custom"));
Assert.Null(options.GetPolicy(IdentityPolicyNames.SecurityRoot));
}
[Fact]
public void NullConfigureAuthorizationOptionsFallsBackToDefaultSecurityRootPolicy()
{
var feature = new DefaultAuthenticationFeature(Substitute.For<IModule>())
{
ConfigureAuthorizationOptions = null!
};
var options = new AuthorizationOptions();
feature.ConfigureAuthorizationOptions(options);
var policy = options.GetPolicy(IdentityPolicyNames.SecurityRoot);
Assert.NotNull(policy);
Assert.Contains(policy.Requirements, requirement => requirement is DenyAnonymousAuthorizationRequirement);
}
[Fact]
public void DisableLocalHostPermissionGrantForSecurityRootClearsOptInFlag()
{
var feature = new DefaultAuthenticationFeature(Substitute.For<IModule>());
feature.EnableLocalHostPermissionGrantForSecurityRoot();
feature.DisableLocalHostPermissionGrantForSecurityRoot();
Assert.False(feature.EnableLocalHostPermissionGrant);
}
}

View file

@ -0,0 +1,124 @@
using System.Net;
using System.Security.Claims;
using Elsa;
using Elsa.Options;
using Elsa.Requirements;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using OptionsFactory = Microsoft.Extensions.Options.Options;
namespace Elsa.Identity.UnitTests.Requirements;
public class LocalHostPermissionRequirementHandlerTests
{
[Fact]
public async Task DoesNotGrantPermissionsToLocalhostRequestsByDefault()
{
var context = await AuthorizeAsync(enableLocalHostPermissionGrant: false, isLocal: true);
Assert.False(context.HasSucceeded);
Assert.Empty(context.User.FindAll(PermissionNames.ClaimType));
}
[Fact]
public async Task GrantsPermissionsToLocalhostRequestsWhenExplicitlyEnabled()
{
var context = await AuthorizeAsync(enableLocalHostPermissionGrant: true, isLocal: true);
var permissions = context.User.FindAll(PermissionNames.ClaimType).Select(x => x.Value).ToList();
Assert.True(context.HasSucceeded);
Assert.Contains("create:application", permissions);
Assert.Contains("create:user", permissions);
Assert.Contains("create:role", permissions);
}
[Fact]
public async Task DoesNotGrantPermissionsToAuthenticatedLocalhostRequestsWhenExplicitlyEnabled()
{
var context = await AuthorizeAsync(enableLocalHostPermissionGrant: true, isLocal: true, isAuthenticated: true);
Assert.False(context.HasSucceeded);
Assert.True(context.User.Identity?.IsAuthenticated);
Assert.Empty(context.User.FindAll(PermissionNames.ClaimType));
}
[Fact]
public async Task SucceedsForAuthenticatedLocalhostRequestsWithExistingBootstrapPermissionsWhenExplicitlyEnabled()
{
var context = await AuthorizeAsync(enableLocalHostPermissionGrant: true, isLocal: true, isAuthenticated: true, permissions: BootstrapPermissions);
var permissions = context.User.FindAll(PermissionNames.ClaimType).Select(x => x.Value).ToList();
Assert.True(context.HasSucceeded);
Assert.True(context.User.Identity?.IsAuthenticated);
Assert.Equal(3, permissions.Count);
Assert.Contains("create:application", permissions);
Assert.Contains("create:user", permissions);
Assert.Contains("create:role", permissions);
}
[Fact]
public async Task DoesNotDuplicateBootstrapPermissionsAcrossMultipleEvaluations()
{
var requirement = new LocalHostPermissionRequirement();
var user = new ClaimsPrincipal(new ClaimsIdentity());
var authorizationContext = new AuthorizationHandlerContext(new[] { requirement }, user, null);
var handler = CreateHandler(enableLocalHostPermissionGrant: true, isLocal: true);
await handler.HandleAsync(authorizationContext);
await handler.HandleAsync(authorizationContext);
Assert.True(authorizationContext.HasSucceeded);
Assert.Equal(1, authorizationContext.User.Identities.Count(x => x.AuthenticationType == JwtBearerDefaults.AuthenticationScheme));
Assert.Equal(3, authorizationContext.User.FindAll(PermissionNames.ClaimType).Count());
}
[Fact]
public async Task DoesNotGrantPermissionsToRemoteRequestsWhenExplicitlyEnabled()
{
var context = await AuthorizeAsync(enableLocalHostPermissionGrant: true, isLocal: false);
Assert.False(context.HasSucceeded);
Assert.Empty(context.User.FindAll(PermissionNames.ClaimType));
}
private static readonly string[] BootstrapPermissions =
[
"create:application",
"create:user",
"create:role"
];
private static async Task<AuthorizationHandlerContext> AuthorizeAsync(bool enableLocalHostPermissionGrant, bool isLocal, bool isAuthenticated = false, params string[] permissions)
{
var requirement = new LocalHostPermissionRequirement();
var user = new ClaimsPrincipal(new ClaimsIdentity(isAuthenticated ? "Test" : null));
user.Identities.First().AddClaims(permissions.Select(x => new Claim(PermissionNames.ClaimType, x)));
var authorizationContext = new AuthorizationHandlerContext(new[] { requirement }, user, null);
var handler = CreateHandler(enableLocalHostPermissionGrant, isLocal);
await handler.HandleAsync(authorizationContext);
return authorizationContext;
}
private static LocalHostPermissionRequirementHandler CreateHandler(bool enableLocalHostPermissionGrant, bool isLocal)
{
var httpContext = CreateHttpContext(isLocal);
var httpContextAccessor = new HttpContextAccessor { HttpContext = httpContext };
var options = OptionsFactory.Create(new LocalHostPermissionRequirementOptions
{
EnableLocalHostPermissionGrant = enableLocalHostPermissionGrant
});
return new LocalHostPermissionRequirementHandler(httpContextAccessor, options);
}
private static HttpContext CreateHttpContext(bool isLocal)
{
var httpContext = new DefaultHttpContext();
httpContext.Connection.LocalIpAddress = IPAddress.Loopback;
httpContext.Connection.RemoteIpAddress = isLocal ? IPAddress.Loopback : IPAddress.Parse("10.0.0.1");
return httpContext;
}
}