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?
|
Thanks for digging into this and putting together the benchmark idea @datamweb. I played with it a bit more locally and compared the implementations: Click to see the optimized matcher class<?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
{
$permissionSegments = self::splitIfValid($permission);
if ($permissionSegments === null) {
return false;
}
$permissionSegmentCount = count($permissionSegments);
foreach ($grants as $grant) {
if ($grant === $permission) {
return true;
}
$wildcardPosition = strpos($grant, '*');
if ($wildcardPosition === false) {
continue;
}
if (
$wildcardPosition > 0
&& $wildcardPosition === strlen($grant) - 1
&& $grant[$wildcardPosition - 1] === '.'
) {
$prefix = substr($grant, 0, $wildcardPosition - 1);
if (self::isLiteral($prefix) && str_starts_with($permission, $prefix . '.')) {
return true;
}
continue;
}
$grantSegments = self::splitIfValid($grant);
if ($grantSegments === null) {
continue;
}
if (self::matchesWildcardGrant($grantSegments, $permissionSegments, $permissionSegmentCount)) {
return true;
}
}
return false;
}
/**
* @param list<string> $grantSegments
* @param list<string> $permissionSegments
*/
private static function matchesWildcardGrant(array $grantSegments, array $permissionSegments, int $permissionSegmentCount): bool
{
$grantSegmentCount = count($grantSegments);
if ($grantSegments[$grantSegmentCount - 1] === '*') {
$grantSegmentCount--;
return $permissionSegmentCount > $grantSegmentCount
&& self::segmentsMatch($grantSegments, $permissionSegments, $grantSegmentCount);
}
return $permissionSegmentCount === $grantSegmentCount
&& self::segmentsMatch($grantSegments, $permissionSegments, $grantSegmentCount);
}
/**
* @param list<string> $grantSegments
* @param list<string> $permissionSegments
*/
private static function segmentsMatch(array $grantSegments, array $permissionSegments, int $segmentCount): bool
{
for ($index = 0; $index < $segmentCount; $index++) {
if ($grantSegments[$index] !== '*' && $grantSegments[$index] !== $permissionSegments[$index]) {
return false;
}
}
return true;
}
/**
* @return list<string>|null
*/
private static function splitIfValid(string $permission): ?array
{
if ($permission === '' || str_starts_with($permission, '*')) {
return null;
}
$segments = explode('.', $permission);
foreach ($segments as $segment) {
if ($segment === '' || ($segment !== '*' && str_contains($segment, '*'))) {
return null;
}
}
return $segments;
}
private static function isLiteral(string $permission): bool
{
return $permission !== ''
&& ! str_contains($permission, '*')
&& ! str_contains($permission, '..')
&& ! str_starts_with($permission, '.')
&& ! str_ends_with($permission, '.');
}
}
I used 100,000 iterations, same as your benchmark: XDEBUG_MODE=off php -d opcache.enable_cli=0 permission-matcher-compare.php 100000Here is one representative run:
I kept the direction you suggested, then adjusted it a bit further: the matcher now parses the requested permission once, avoids I also tried simplifying the class more, but those versions were slower in the trailing wildcard path, so I kept this as the best balance I could find between readability and performance. I have not committed this yet, so I’m happy to go either way. If the team thinks the performance gains are worth the slightly larger matcher, I can commit this version here. I'd appreciate your thoughts on the tradeoff. |
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.