* 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>
112 lines
3.9 KiB
C#
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);
|
|
}
|
|
}
|