feat: support hierarchical permission wildcards#1327
Conversation
Add hierarchical wildcard matching for Shield permissions. - Support nested trailing wildcards like forum.posts.* - Support middle-segment wildcards like forum.*.create - Share wildcard matching between user and group permission checks - Document wildcard semantics and direct user wildcard assignment - Cover matcher behavior and public authorization paths Co-authored-by: bgeneto <bgeneto@duck.com> Co-authored-by: christianberkman <christianberkman@users.noreply.github.com> Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com>
michalsn
left a comment
There was a problem hiding this comment.
I would like to make wildcards simpler: trailing wildcards match children only, never the parent. So forum.posts.* would match forum.posts.create but NOT forum.posts.
The current "parent matching" feels like hidden privilege escalation. If someone sets up forum.posts.* thinking "all actions on posts", they probably don't expect it also grants the bare forum.posts scope - which could be used as a different kind of gate elsewhere in their app. Current behavior is non-intuitive to me.
|
@michalsn I agree, and I think this is the direction we should standardize on as a team. The overall goal of supporting deeper hierarchical wildcard permissions still seems valid, but I do not think trailing wildcards should also grant the parent scope itself. Allowing The clearer contract, in my view, is: 1- That keeps permission grants explicit, avoids hidden coupling between namespace-like labels and actual permission checks, and gives us simpler semantics to document and test consistently across both group-level and direct user permissions. So my preference would be to keep the feature, but adjust the trailing-wildcard semantics to follow this rule before merging. |
Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com>
There was a problem hiding this comment.
<?php
declare(strict_types=1);
/**
* This file is part of CodeIgniter Shield.
*
* (c) CodeIgniter Foundation <admin@codeigniter.com>
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace CodeIgniter\Shield\Authorization;
/**
* Matches permission grants against requested permission names for Shield authorization internals.
*/
final class PermissionMatcher
{
/**
* @param list<string> $grants
*/
public static function matches(string $permission, array $grants): bool
{
if (! self::isValid($permission)) {
return false;
}
// Cache the exploded permission to prevent redundant string parsing in loops
$permissionSegments = explode('.', $permission);
$permissionSegCount = count($permissionSegments);
foreach ($grants as $grant) {
// Exact match first
// If it matches exactly, it's inherently valid (since $permission is valid)
// This skips the isValid() overhead entirely for exact matches
if ($grant === $permission) {
return true;
}
if (! self::isValid($grant)) {
continue;
}
if (str_contains($grant, '*') && self::matchesWildcardGrant($grant, $permissionSegments, $permissionSegCount)) {
return true;
}
}
return false;
}
/**
* @param list<string> $permissionSegments
*/
private static function matchesWildcardGrant(string $grant, array $permissionSegments, int $permissionSegCount): bool
{
$grantSegments = explode('.', $grant);
$grantSegCount = count($grantSegments);
// Check trailing wildcard efficiently
if ($grantSegments[$grantSegCount - 1] === '*') {
array_pop($grantSegments);
$grantSegCount--; // Decrement count after pop
// Trailing wildcard matches children only, never the parent itself.
return $permissionSegCount > $grantSegCount
&& self::segmentsMatch($grantSegments, $permissionSegments, $grantSegCount);
}
if ($permissionSegCount !== $grantSegCount) {
return false;
}
return self::segmentsMatch($grantSegments, $permissionSegments, $grantSegCount);
}
/**
* @param list<string> $grantSegments
* @param list<string> $permissionSegments
*/
private static function segmentsMatch(array $grantSegments, array $permissionSegments, int $matchLimitCount): bool
{
// Using "for" with a strict limit instead of array_slice() overhead
for ($i = 0; $i < $matchLimitCount; $i++) {
if ($grantSegments[$i] !== '*' && $grantSegments[$i] !== $permissionSegments[$i]) {
return false;
}
}
return true;
}
private static function isValid(string $permission): bool
{
// Catch invalid starts right away before allocating an array.
if ($permission === '' || str_starts_with($permission, '*')) {
return false;
}
$segments = explode('.', $permission);
foreach ($segments as $segment) {
if ($segment === '' || ($segment !== '*' && str_contains($segment, '*'))) {
return false;
}
}
return true;
}
}I was playing around with the code to see if we could squeeze out a bit more performance (mostly by avoiding array_slice overhead and optimizing the explode logic). I ran a benchmark test with 100,000 iterations, and the results were actually quite promising:
Running Benchmark (100000 iterations)...
Original Code:
Time: 488.31 ms
Optimized Code:
Time: 395.79 ms
Performance Improvement: 18.95%@memleakd What are your thoughts on this?
This PR intends to finalize the hierarchical permissions work from #1253 and #1309, which had stalled for some time.
Shield already supports wildcard permissions, but the current behavior is limited when permissions are organized into deeper namespaces.
In larger, real-world applications, permissions often grow beyond two segments. The flat/single-level wildcard behavior makes those permission sets harder to express and maintain cleanly, often requiring custom workarounds.
This PR makes wildcard permissions work with deeper structures while keeping the existing
$user->can()and$group->can()APIs unchanged.A trailing wildcard now matches descendant permissions:
'forum.posts.*'matches
forum.posts.createandforum.posts.comments.delete, but notforum.posts.Wildcards can also be used in the middle of a permission, so
forum.*.creatematchesforum.posts.createandforum.comments.create, but notforum.posts.comments.create.I tried to address the concerns and review feedback from the earlier attempts:
Config\AuthGroups::$permissionsbefore assignment;*must be a complete segment;*cannot be the first segment;*does not grant everything;$user->can()/$group->can()paths.I’d appreciate any feedback. Hopefully this can help move this long-standing and requested feature forward.