elsa-core/test/unit/Elsa.ExternalAuthentication.UnitTests/Foundational/ExternalAuthenticationOptionsValidatorTests.cs
Sipke Schoorstra 168a8c76f0
fix(auth): validate wildcard permission patterns and warn on deny-list stripping (#7997)
* fix(auth): validate wildcard permission patterns and warn on deny-list stripping

Permission.IsValidPattern rejects inert wildcard spellings (such as
"workflows*:delete") that parse but can never match. The grant boundary,
stored-permission, and external-authentication options validators reject them
at authoring time, and PermissionGrantValidator applies the same check to
incoming grants.

ExternalAuthenticationOptionsValidator now warns (never fails) when
DeniedPermissions is non-empty, because any non-empty deny list refuses every
wildcard grant that could reach a denied permission -- including the seeded
administrator role's "*". The validator takes an ILogger, and
AddExternalAuthenticationServices registers logging alongside its other
framework dependencies (TryAdd-based, so host logging configuration wins).
The operational consequence is recorded in the authorization-model migration
guide.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(auth): report subtree grants whose verb nothing under them supports

'workflows/*:frobnicate' reached a non-empty subtree and was therefore
treated as resolved, so the startup audit stayed silent about a grant
that cannot authorize anything. Require at least one reached descriptor
to support a concrete verb; verb wildcards keep the reach-only check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 11:45:54 +02:00

283 lines
11 KiB
C#

using Elsa.ExternalAuthentication.Models;
using Elsa.ExternalAuthentication.Options;
using Elsa.ExternalAuthentication.Validation;
using Microsoft.Extensions.Logging;
namespace Elsa.ExternalAuthentication.UnitTests.Foundational;
public class ExternalAuthenticationOptionsValidatorTests
{
private readonly CapturingLogger<ExternalAuthenticationOptionsValidator> _logger = new();
[Theory]
// The legacy spelling carries two colons, so it parses as nothing and would silently stop bounding anything.
[InlineData("external-authentication:connections:read")]
[InlineData("not a permission")]
[InlineData("workflows/definitions:")]
public void RejectsAGrantBoundaryEntryThatIsNotAWellFormedPermission(string permission)
{
var allowed = new ExternalAuthenticationOptions();
allowed.PermissionGrants.AllowedPermissions = [permission];
var denied = new ExternalAuthenticationOptions();
denied.PermissionGrants.DeniedPermissions = [permission];
var allowedResult = CreateValidator().Validate(null, allowed);
var deniedResult = CreateValidator().Validate(null, denied);
Assert.False(allowedResult.Succeeded);
Assert.Contains(allowedResult.Failures!, x => x.Contains("AllowedPermissions") && x.Contains("well-formed permission"));
Assert.False(deniedResult.Succeeded);
Assert.Contains(deniedResult.Failures!, x => x.Contains("DeniedPermissions") && x.Contains("well-formed permission"));
}
[Theory]
// These parse, so they slipped past well-formedness — yet the matcher never satisfies them. In a deny
// list that is a silent un-denying, exactly what boundary validation exists to prevent.
[InlineData("workflows*:delete")]
[InlineData("work*/foo:view")]
[InlineData("work*/definitions/*:view")]
[InlineData("workflows:del*")]
public void RejectsAGrantBoundaryEntryWithAWildcardTheMatcherNeverSatisfies(string permission)
{
var allowed = new ExternalAuthenticationOptions();
allowed.PermissionGrants.AllowedPermissions = [permission];
var denied = new ExternalAuthenticationOptions();
denied.PermissionGrants.DeniedPermissions = [permission];
var allowedResult = CreateValidator().Validate(null, allowed);
var deniedResult = CreateValidator().Validate(null, denied);
Assert.False(allowedResult.Succeeded);
Assert.Contains(allowedResult.Failures!, x => x.Contains("AllowedPermissions") && x.Contains("would match nothing"));
Assert.False(deniedResult.Succeeded);
Assert.Contains(deniedResult.Failures!, x => x.Contains("DeniedPermissions") && x.Contains("would match nothing"));
}
[Fact]
public void AcceptsAGrantBoundaryOfWildcardPatterns()
{
var options = new ExternalAuthenticationOptions();
options.PermissionGrants.AllowedPermissions = ["workflows/*:delete", "*"];
options.PermissionGrants.DeniedPermissions = ["workflows/definitions:*"];
var result = CreateValidator().Validate(null, options);
Assert.DoesNotContain(result.Failures ?? [], x => x.Contains("well-formed permission"));
}
/// <remarks>
/// A deny list is not a failure, but it costs a wildcard grant everything it could have conferred: deny
/// matches in both directions, so '*' satisfies every deny entry and an externally-authenticated superuser
/// silently loses it while local login keeps working. The warning names that at startup rather than leaving
/// it to be diagnosed from an issued token.
/// </remarks>
[Fact]
public void WarnsThatANonEmptyDenyListRefusesWildcardGrantsWhole()
{
var options = new ExternalAuthenticationOptions();
options.PermissionGrants.DeniedPermissions = ["workflows/*:delete"];
var result = CreateValidator().Validate(null, options);
Assert.True(result.Succeeded);
var warning = Assert.Single(_logger.Entries, x => x.Level == LogLevel.Warning);
Assert.Contains("DeniedPermissions", warning.Message);
Assert.Contains("refused entirely", warning.Message);
}
[Fact]
public void DoesNotWarnWhenNoPermissionsAreDenied()
{
var options = new ExternalAuthenticationOptions();
options.PermissionGrants.AllowedPermissions = ["workflows/*:delete"];
CreateValidator().Validate(null, options);
Assert.DoesNotContain(_logger.Entries, x => x.Level == LogLevel.Warning);
}
[Fact]
public void RejectsDuplicateInstalledAdapterTypes()
{
var result = CreateValidator([new StubAdapter("oidc"), new StubAdapter("oidc")]).Validate(null, new ExternalAuthenticationOptions());
Assert.False(result.Succeeded);
Assert.Contains(result.Failures!, x => x.Contains("registered more than once"));
}
[Fact]
public void RejectsPublicClientWithWildcardOriginAndSecret()
{
var options = new ExternalAuthenticationOptions
{
Clients =
[
new AuthenticationClient(
"studio",
"Studio",
AuthenticationClientType.Public,
new HashSet<Uri> { new("https://studio.example/callback") },
new HashSet<Uri>(),
new HashSet<string> { "https://*.example" },
new HashSet<string> { "/" },
new SecretBinding("configuration", "studio-secret"),
true)
]
};
var result = CreateValidator().Validate(null, options);
Assert.False(result.Succeeded);
Assert.Contains(result.Failures!, x => x.Contains("invalid allowed origin"));
Assert.Contains(result.Failures!, x => x.Contains("must not define a client secret"));
}
[Fact]
public void RejectsNonHostConfigurationConnection()
{
var options = new ExternalAuthenticationOptions
{
ConfigurationConnections =
[
RegistryTestData.Connection("tenant", "tenant-a", "contoso")
]
};
var result = CreateValidator().Validate(null, options);
Assert.False(result.Succeeded);
Assert.Contains(result.Failures!, x => x.Contains("must use the host scope"));
}
[Fact]
public void RejectsMultipleConfiguredPreferredConnections()
{
var options = new ExternalAuthenticationOptions
{
ConfigurationConnections =
[
RegistryTestData.Connection("first", "*", "first", isPreferred: true),
RegistryTestData.Connection("second", "*", "second", isPreferred: true)
]
};
var result = CreateValidator().Validate(null, options);
Assert.False(result.Succeeded);
Assert.Contains(result.Failures!, x => x.Contains("more than one preferred sign-in method"));
}
[Fact]
public void RejectsNonPositiveRateLimitRules()
{
var options = new ExternalAuthenticationOptions
{
RateLimits = new ExternalAuthenticationRateLimitOptions
{
Discovery = new RateLimitRule(0, TimeSpan.Zero)
}
};
var result = CreateValidator().Validate(null, options);
Assert.False(result.Succeeded);
Assert.Contains(result.Failures!, x => x.Contains("Discovery") && x.Contains("positive permit limit and window"));
}
[Theory]
[InlineData("http://elsa.example")]
[InlineData("https://elsa.example/?unexpected=true")]
public void RejectsUnsafeExternalCallbackBaseUri(string callbackBaseUri)
{
var options = new ExternalAuthenticationOptions
{
Redirects = new RedirectValidationOptions { ExternalCallbackBaseUri = new Uri(callbackBaseUri) }
};
var result = CreateValidator().Validate(null, options);
Assert.False(result.Succeeded);
Assert.Contains(result.Failures!, failure => failure.Contains("ExternalCallbackBaseUri"));
}
[Fact]
public void AllowsHttpLoopbackExternalCallbackBaseUriOnlyWhenDevelopmentModeIsEnabled()
{
var options = new ExternalAuthenticationOptions
{
Redirects = new RedirectValidationOptions
{
ExternalCallbackBaseUri = new Uri("http://127.0.0.1:5000"),
AllowDevelopmentLoopbackCallbacks = true
}
};
var result = CreateValidator().Validate(null, options);
Assert.True(result.Succeeded);
}
[Fact]
public void AcceptsExactPublicClientAndInstalledConfigurationSelections()
{
var options = new ExternalAuthenticationOptions
{
AllowedAdapterTypes = ["oidc"],
Clients =
[
new AuthenticationClient(
"studio",
"Studio",
AuthenticationClientType.Public,
new HashSet<Uri> { new("https://studio.example/callback") },
new HashSet<Uri> { new("https://studio.example/logout") },
new HashSet<string> { "https://studio.example" },
new HashSet<string> { "/" },
null,
true)
],
ConfigurationConnections = [RegistryTestData.Connection("connection", "*", "contoso")]
};
var result = CreateValidator().Validate(null, options);
Assert.True(result.Succeeded);
}
private ExternalAuthenticationOptionsValidator CreateValidator(IEnumerable<StubAdapter>? adapters = null)
{
var extensions = new ExternalAuthenticationExtensionOptions();
foreach (var adapter in adapters ?? [new StubAdapter("oidc")])
extensions.Registrations.Add(new(ExternalAuthenticationExtensionKind.Adapter, adapter.Type));
extensions.Registrations.Add(new(ExternalAuthenticationExtensionKind.UnlinkedIdentityPolicy, "reject"));
extensions.Registrations.Add(new(ExternalAuthenticationExtensionKind.UnlinkedIdentityPolicy, "create-user"));
extensions.Registrations.Add(new(ExternalAuthenticationExtensionKind.PermissionGrantSource, "elsa-roles"));
extensions.Registrations.Add(new(ExternalAuthenticationExtensionKind.PermissionGrantSource, "claim-mapping"));
extensions.Registrations.Add(new(ExternalAuthenticationExtensionKind.PermissionGrantSource, "group-mapping"));
extensions.Registrations.Add(new(ExternalAuthenticationExtensionKind.PermissionGrantSource, "claim-pass-through"));
return new(Microsoft.Extensions.Options.Options.Create(extensions), _logger);
}
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()
{
}
}
}