diff --git a/src/common/Elsa.Api.Common/Options/LocalHostPermissionRequirementOptions.cs b/src/common/Elsa.Api.Common/Options/LocalHostPermissionRequirementOptions.cs
new file mode 100644
index 000000000..0dda8b1cc
--- /dev/null
+++ b/src/common/Elsa.Api.Common/Options/LocalHostPermissionRequirementOptions.cs
@@ -0,0 +1,12 @@
+namespace Elsa.Options;
+
+///
+/// Options for the localhost permission requirement.
+///
+public class LocalHostPermissionRequirementOptions
+{
+ ///
+ /// Gets or sets whether localhost requests may satisfy the security-root permission requirement without other credentials.
+ ///
+ public bool EnableLocalHostPermissionGrant { get; set; }
+}
diff --git a/src/common/Elsa.Api.Common/Requirements/LocalHostPermissionRequirement.cs b/src/common/Elsa.Api.Common/Requirements/LocalHostPermissionRequirement.cs
index 7cf7f9906..883662d0e 100644
--- a/src/common/Elsa.Api.Common/Requirements/LocalHostPermissionRequirement.cs
+++ b/src/common/Elsa.Api.Common/Requirements/LocalHostPermissionRequirement.cs
@@ -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;
///
-/// 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.
///
public class LocalHostPermissionRequirement : IAuthorizationRequirement
{
@@ -18,32 +20,58 @@ public class LocalHostPermissionRequirement : IAuthorizationRequirement
[PublicAPI]
public class LocalHostPermissionRequirementHandler : AuthorizationHandler
{
+ private static readonly string[] BootstrapPermissions =
+ [
+ "create:application",
+ "create:user",
+ "create:role"
+ ];
+
private readonly IHttpContextAccessor _httpContextAccessor;
+ private readonly IOptions _options;
///
- public LocalHostPermissionRequirementHandler(IHttpContextAccessor httpContextAccessor)
+ public LocalHostPermissionRequirementHandler(IHttpContextAccessor httpContextAccessor) : this(
+ httpContextAccessor,
+ Microsoft.Extensions.Options.Options.Create(new LocalHostPermissionRequirementOptions()))
+ {
+ }
+
+ ///
+ public LocalHostPermissionRequirementHandler(IHttpContextAccessor httpContextAccessor, IOptions options)
{
_httpContextAccessor = httpContextAccessor;
+ _options = options;
}
///
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;
}
-}
\ No newline at end of file
+
+ 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);
+ }
+}
diff --git a/src/modules/Elsa.Identity/Features/DefaultAuthenticationFeature.cs b/src/modules/Elsa.Identity/Features/DefaultAuthenticationFeature.cs
index 6aadbafd8..0f19a1413 100644
--- a/src/modules/Elsa.Identity/Features/DefaultAuthenticationFeature.cs
+++ b/src/modules/Elsa.Identity/Features/DefaultAuthenticationFeature.cs
@@ -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 _configureApiKeyAuthorization = builder => builder.AddApiKeyInAuthorizationHeader();
+ private Action? _configureAuthorizationOptions;
///
public DefaultAuthenticationFeature(IModule module) : base(module)
{
+ ConfigureAuthorizationOptions = ConfigureDefaultSecurityRootPolicy;
}
///
/// Gets or sets the .
///
public Type ApiKeyProviderType { get; set; } = typeof(DefaultApiKeyProvider);
- public Action ConfigureAuthorizationOptions { get; set; } = options => options.AddPolicy(IdentityPolicyNames.SecurityRoot, policy => policy.AddRequirements(new LocalHostPermissionRequirement()));
+
+ ///
+ /// Gets or sets the authorization options configuration.
+ ///
+ public Action ConfigureAuthorizationOptions
+ {
+ get => _configureAuthorizationOptions ?? ConfigureDefaultSecurityRootPolicy;
+ set => _configureAuthorizationOptions = value ?? ConfigureDefaultSecurityRootPolicy;
+ }
+
+ ///
+ /// Gets or sets whether localhost requests may satisfy the security-root permission requirement without other credentials.
+ ///
+ public bool EnableLocalHostPermissionGrant { get; set; }
///
/// Configures the API key provider type.
@@ -79,23 +95,40 @@ public class DefaultAuthenticationFeature : FeatureBase
///
/// The current .
public DefaultAuthenticationFeature UseDevelopmentAdminApiKey() => UseAdminApiKey(AdminApiKeyProvider.DevelopmentApiKey);
-
+
///
- /// Disables the local host requirement for the security root policy.
- /// This is useful when privileged identity bootstrap is handled through features such as .
+ /// Enables the legacy localhost permission grant for the security root policy.
///
- public DefaultAuthenticationFeature DisableLocalHostRequirement()
+ public DefaultAuthenticationFeature EnableLocalHostPermissionGrantForSecurityRoot()
{
- ConfigureAuthorizationOptions = options => options.AddPolicy(IdentityPolicyNames.SecurityRoot, policy => policy.RequireAuthenticatedUser());
+ EnableLocalHostPermissionGrant = true;
return this;
}
+ ///
+ /// Disables the localhost permission grant for the security root policy.
+ /// This is useful when privileged identity bootstrap is handled through features such as .
+ ///
+ public DefaultAuthenticationFeature DisableLocalHostPermissionGrantForSecurityRoot()
+ {
+ EnableLocalHostPermissionGrant = false;
+ return this;
+ }
+
+ ///
+ /// Disables the legacy localhost permission grant for the security root policy.
+ /// This is useful when privileged identity bootstrap is handled through features such as .
+ ///
+ [Obsolete("Use DisableLocalHostPermissionGrantForSecurityRoot instead.")]
+ public DefaultAuthenticationFeature DisableLocalHostRequirement() => DisableLocalHostPermissionGrantForSecurityRoot();
+
///
public override void Apply()
{
Services.ConfigureOptions();
Services.Configure(_ => { });
Services.AddIdentityTokenOptionsValidation();
+ Services.Configure(options => options.EnableLocalHostPermissionGrant = EnableLocalHostPermissionGrant);
var authBuilder = Services
.AddAuthentication(MultiScheme)
@@ -119,4 +152,22 @@ public class DefaultAuthenticationFeature : FeatureBase
Services.AddScoped(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()));
+ }
}
diff --git a/src/modules/Elsa.Identity/README.md b/src/modules/Elsa.Identity/README.md
index 38f1590f3..6a9200923 100644
--- a/src/modules/Elsa.Identity/README.md
+++ b/src/modules/Elsa.Identity/README.md
@@ -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
diff --git a/src/modules/Elsa.Identity/ShellFeatures/DefaultAuthenticationFeature.cs b/src/modules/Elsa.Identity/ShellFeatures/DefaultAuthenticationFeature.cs
index 51f892a61..57d8ed1c1 100644
--- a/src/modules/Elsa.Identity/ShellFeatures/DefaultAuthenticationFeature.cs
+++ b/src/modules/Elsa.Identity/ShellFeatures/DefaultAuthenticationFeature.cs
@@ -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; }
+ ///
+ /// Gets or sets whether localhost requests may satisfy the security-root permission requirement without other credentials.
+ ///
+ 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();
services.AddIdentityTokenOptionsValidation();
+ services.Configure(options => options.EnableLocalHostPermissionGrant = EnableLocalHostPermissionGrant);
services.Configure(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());
});
}
}
diff --git a/test/unit/Elsa.Identity.UnitTests/Features/DefaultAuthenticationFeatureTests.cs b/test/unit/Elsa.Identity.UnitTests/Features/DefaultAuthenticationFeatureTests.cs
new file mode 100644
index 000000000..8568eeaef
--- /dev/null
+++ b/test/unit/Elsa.Identity.UnitTests/Features/DefaultAuthenticationFeatureTests.cs
@@ -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());
+ 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());
+ 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());
+ 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())
+ {
+ 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());
+
+ feature.EnableLocalHostPermissionGrantForSecurityRoot();
+ feature.DisableLocalHostPermissionGrantForSecurityRoot();
+
+ Assert.False(feature.EnableLocalHostPermissionGrant);
+ }
+}
diff --git a/test/unit/Elsa.Identity.UnitTests/Requirements/LocalHostPermissionRequirementHandlerTests.cs b/test/unit/Elsa.Identity.UnitTests/Requirements/LocalHostPermissionRequirementHandlerTests.cs
new file mode 100644
index 000000000..970387f02
--- /dev/null
+++ b/test/unit/Elsa.Identity.UnitTests/Requirements/LocalHostPermissionRequirementHandlerTests.cs
@@ -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 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;
+ }
+}