* 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>
283 lines
11 KiB
C#
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()
|
|
{
|
|
}
|
|
}
|
|
}
|