Skip to content

feat: support hierarchical permission wildcards#1327

Open
memleakd wants to merge 2 commits into
codeigniter4:developfrom
memleakd:feat/hierarchical-permission-wildcards
Open

feat: support hierarchical permission wildcards#1327
memleakd wants to merge 2 commits into
codeigniter4:developfrom
memleakd:feat/hierarchical-permission-wildcards

Conversation

@memleakd
Copy link
Copy Markdown
Contributor

@memleakd memleakd commented May 23, 2026

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.create and forum.posts.comments.delete, but not forum.posts.

Wildcards can also be used in the middle of a permission, so forum.*.create matches forum.posts.create and forum.comments.create, but not forum.posts.comments.create.

I tried to address the concerns and review feedback from the earlier attempts:

  • wildcard matching is shared instead of duplicated;
  • both group-level and direct user-level permissions use the same matcher;
  • direct user wildcard permissions still need to be listed in Config\AuthGroups::$permissions before assignment;
  • * must be a complete segment;
  • * cannot be the first segment;
  • standalone * does not grant everything;
  • trailing wildcards do not grant the parent permission itself;
  • tests cover the matcher itself and the public $user->can() / $group->can() paths.

I’d appreciate any feedback. Hopefully this can help move this long-standing and requested feature forward.

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>
Copy link
Copy Markdown
Member

@michalsn michalsn left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 michalsn requested a review from datamweb May 27, 2026 15:13
@datamweb
Copy link
Copy Markdown
Collaborator

@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 scope.* to match scope introduces implicit privilege expansion. In an authorization system, that is too broad a default because the granted access becomes wider than the permission string naturally suggests. If forum.posts is used elsewhere as its own gate, then assigning forum.posts.* may unintentionally grant that too.

The clearer contract, in my view, is:

1- scope => exact match only
2- scope.* => descendants only
3- never the parent itself

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.

@datamweb datamweb added the enhancement New feature or request label May 29, 2026
Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com>
@memleakd
Copy link
Copy Markdown
Contributor Author

memleakd commented May 29, 2026

Thanks for your reviews @michalsn, @datamweb

Just pushed an update. Trailing wildcards now match descendants only, never the parent scope itself. Tests and docs were updated to cover the new semantics.

Copy link
Copy Markdown
Member

@michalsn michalsn left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, this looks good!

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

<?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?

@memleakd
Copy link
Copy Markdown
Contributor Author

memleakd commented May 30, 2026

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, '.');
    }
}
  • Original: the first implementation in this PR
  • Your version: the optimization you suggested
  • Current: the version I pushed after building on the same idea

I used 100,000 iterations, same as your benchmark:

XDEBUG_MODE=off php -d opcache.enable_cli=0 permission-matcher-compare.php 100000

Here is one representative run:

Case Original Your version Current
Exact match first 34.14 ms 24.39 ms (-28.57%) 20.66 ms (-39.49%)
Exact match late 67.93 ms 58.55 ms (-13.80%) 25.39 ms (-62.62%)
Trailing wildcard 68.92 ms 61.57 ms (-10.67%) 38.70 ms (-43.84%)
Middle wildcard 58.60 ms 56.28 ms (-3.96%) 51.95 ms (-11.35%)
Many grants miss 139.78 ms 126.41 ms (-9.57%) 100.69 ms (-27.96%)
Many grants late wildcard 165.55 ms 141.61 ms (-14.46%) 99.29 ms (-40.02%)

I kept the direction you suggested, then adjusted it a bit further: the matcher now parses the requested permission once, avoids array_slice(), and has a small fast path for common literal trailing wildcards like admin.users.*.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants