elsa-core/test/unit/Elsa.Api.Common.UnitTests/Authorization/PermissionTests.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

84 lines
3.7 KiB
C#

using Elsa.Authorization;
namespace Elsa.Api.Common.UnitTests.Authorization;
public class PermissionTests
{
[Theory]
[InlineData("workflows/definitions:view", "workflows/definitions", "view")]
[InlineData("secrets:write", "secrets", "write")]
[InlineData("workflows/*:view", "workflows/*", "view")]
[InlineData("workflows/definitions:*", "workflows/definitions", "*")]
[InlineData("*:*", "*", "*")]
[InlineData(" secrets:view ", "secrets", "view")]
public void ParsesWellFormedPermissions(string value, string resource, string verb)
{
Assert.True(Permission.TryParse(value, out var permission));
Assert.Equal(new Permission(resource, verb), permission);
Assert.Equal($"{resource}:{verb}", permission.ToString());
}
[Fact]
public void ABareWildcardNormalizesToTheWholeVocabulary()
{
// A parsing rule, not an evaluation special case: it is what lets a stored or seeded "*" keep
// authorizing across the vocabulary migration without a lock-out window.
Assert.True(Permission.TryParse("*", out var permission));
Assert.Equal(Permission.All, permission);
Assert.Equal("*:*", permission.ToString());
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("workflows/definitions")] // no verb
[InlineData(":view")] // no resource
[InlineData("workflows/definitions:")] // empty verb
[InlineData("workflows:definitions:view")] // verb may not contain the separator
[InlineData("workflows:defs/view")] // verb may not contain a path separator
[InlineData("workflows/definitions:view,create")] // a comma can never appear: persistence joins on it
public void RejectsMalformedPermissions(string? value)
{
Assert.False(Permission.TryParse(value, out _));
}
[Fact]
public void ParseThrowsOnMalformedInput()
{
Assert.Throws<FormatException>(() => Permission.Parse("workflows/definitions"));
}
[Theory]
[InlineData("*:*", true, true, false)]
[InlineData("workflows/*:view", false, false, true)]
[InlineData("workflows/definitions:*", false, true, false)]
[InlineData("workflows/definitions:view", false, false, false)]
public void ClassifiesWildcards(string value, bool resourceWildcard, bool verbWildcard, bool subtree)
{
var permission = Permission.Parse(value);
Assert.Equal(resourceWildcard, permission.IsResourceWildcard);
Assert.Equal(verbWildcard, permission.IsVerbWildcard);
Assert.Equal(subtree, permission.IsSubtree);
Assert.Equal(resourceWildcard || verbWildcard || subtree, permission.HasWildcard);
}
[Theory]
[InlineData("workflows/definitions:view", true)]
[InlineData("workflows/*:view", true)]
[InlineData("workflows/definitions:*", true)]
[InlineData("*:*", true)]
[InlineData("workflows*:delete", false)] // missing slash: not a subtree pattern
[InlineData("work*/foo:view", false)] // embedded wildcard mid-resource
[InlineData("work*/definitions/*:view", false)] // trailing '/*' does not redeem an embedded '*'
[InlineData("workflows/*/versions:view", false)] // '*' as a middle segment
[InlineData("workflows:del*", false)] // embedded wildcard in the verb
public void RecognizesWildcardsTheMatcherNeverSatisfies(string value, bool valid)
{
// Such strings parse — TryParse stays lenient for stored roles — but validation paths reject them,
// because a pattern that matches nothing in a deny list silently stops denying.
Assert.Equal(valid, Permission.Parse(value).IsValidPattern);
}
}