From d23e61e9be472269b8e40e2790ae9a4b2446f7fc Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Thu, 21 May 2026 00:40:57 +0200 Subject: [PATCH] [codex] Require opt-in for localhost authorization grants (#7498) * Require opt-in for localhost auth grant * Preserve custom authorization configuration * Address localhost auth review feedback * Address localhost bootstrap review comments --- .../LocalHostPermissionRequirementOptions.cs | 12 ++ .../LocalHostPermissionRequirement.cs | 52 ++++++-- .../Features/DefaultAuthenticationFeature.cs | 63 ++++++++- src/modules/Elsa.Identity/README.md | 1 + .../DefaultAuthenticationFeature.cs | 12 +- .../DefaultAuthenticationFeatureTests.cs | 85 ++++++++++++ ...alHostPermissionRequirementHandlerTests.cs | 124 ++++++++++++++++++ 7 files changed, 330 insertions(+), 19 deletions(-) create mode 100644 src/common/Elsa.Api.Common/Options/LocalHostPermissionRequirementOptions.cs create mode 100644 test/unit/Elsa.Identity.UnitTests/Features/DefaultAuthenticationFeatureTests.cs create mode 100644 test/unit/Elsa.Identity.UnitTests/Requirements/LocalHostPermissionRequirementHandlerTests.cs 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 5f1c793ff..481894504 100644 --- a/src/modules/Elsa.Identity/README.md +++ b/src/modules/Elsa.Identity/README.md @@ -83,3 +83,4 @@ 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. 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; + } +}