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

112 lines
3.9 KiB
C#

using Elsa.Permissions;
namespace Elsa.Api.Common.UnitTests.Permissions;
public class PermissionGrantValidatorTests
{
private sealed class Provider(params PermissionDescriptor[] descriptors) : IPermissionDescriptorProvider
{
public IEnumerable<PermissionDescriptor> GetDescriptors() => descriptors;
}
private static readonly PermissionGrantValidator Validator = new(
new DefaultPermissionDescriptorRegistry([
new Provider(
new("workflows/definitions", ["view", "write", "publish"], "Definitions", "", "Workflows"),
new("workflows/instances", ["view", "cancel"], "Instances", "", "Workflows"),
new("secrets", ["view", "write"], "Secrets", "", "Secrets"))
]));
[Theory]
[InlineData("workflows/definitions:view")]
[InlineData("workflows/definitions:publish")]
[InlineData("secrets:write")]
public void AcceptsConcreteGrantsTheCatalogKnows(string permission)
{
Assert.True(Validator.Validate([permission]).IsValid);
}
[Theory]
[InlineData("workflows/*:view")]
[InlineData("workflows/definitions:*")]
[InlineData("*:*")]
[InlineData("*")]
public void AcceptsWildcards(string permission)
{
// Wildcards are validated structurally. `workflows/*` matches no single descriptor and `*` is
// deliberately absent from every supported-verb list, so catalog validation would reject exactly
// the grants the hierarchy exists to make possible.
Assert.True(Validator.Validate([permission]).IsValid);
}
[Fact]
public void AcceptsAWildcardThatCurrentlyMatchesNothing()
{
// A grant naming a module that is not installed yet must survive: installing it later is what
// gives the grant meaning.
Assert.True(Validator.Validate(["not-installed/*:view"]).IsValid);
}
[Fact]
public void RejectsAConcreteResourceNoModuleRegisters()
{
var result = Validator.Validate(["invented/resource:view"]);
Assert.False(result.IsValid);
Assert.Contains("No module registers", result.Errors.Single().Reason);
}
[Fact]
public void RejectsAVerbTheResourceDoesNotSupport()
{
var result = Validator.Validate(["secrets:publish"]);
Assert.False(result.IsValid);
Assert.Contains("does not support the verb", result.Errors.Single().Reason);
Assert.Contains("view, write", result.Errors.Single().Reason);
}
[Theory]
[InlineData("workflows*:delete")]
[InlineData("work*/foo/*:view")]
[InlineData("workflows/*/instances:view")]
[InlineData("workflows/definitions:vi*w")]
public void RejectsWildcardsTheMatcherNeverSatisfies(string permission)
{
// These parse, but the matcher never satisfies them; accepting them would persist a grant
// that silently reaches nothing.
var result = Validator.Validate([permission]);
Assert.False(result.IsValid);
Assert.Contains("would match nothing", result.Errors.Single().Reason);
}
[Theory]
[InlineData("not a permission")]
[InlineData("workflows/definitions")]
[InlineData("workflows/definitions:view,create")]
public void RejectsMalformedPermissions(string permission)
{
var result = Validator.Validate([permission]);
Assert.False(result.IsValid);
Assert.Contains("well-formed", result.Errors.Single().Reason);
}
[Fact]
public void ReportsEveryOffendingGrantRatherThanTheFirst()
{
var result = Validator.Validate(["secrets:publish", "invented:view", "workflows/definitions:view"]);
Assert.Equal(2, result.Errors.Count);
}
[Fact]
public void TreatsNullAndEmptyEntriesAsNothingToValidate()
{
Assert.True(Validator.Validate(null).IsValid);
Assert.True(Validator.Validate([]).IsValid);
Assert.True(Validator.Validate(["", " "]).IsValid);
}
}