├── tests
├── Support
│ ├── hierarchy.png
│ ├── FakeItemsStorage.php
│ ├── FakeAssignmentsStorage.php
│ ├── WannabeRule.php
│ ├── TrueRule.php
│ ├── FalseRule.php
│ ├── BanRule.php
│ ├── AdsRule.php
│ ├── GuestRule.php
│ ├── AuthorRule.php
│ ├── SubscriptionRule.php
│ └── hierarchy.graphml
├── ItemsStorageTest.php
├── AssignmentsStorageTest.php
├── ManagerTest.php
├── Exception
│ ├── DefaultRolesNotFoundExceptionTest.php
│ ├── RuleNotFoundExceptionTest.php
│ ├── ItemAlreadyExistsExceptionTest.php
│ └── RuleInterfaceNotImplementedExceptionTest.php
├── AssignmentTest.php
├── RuleContextTest.php
├── RoleTest.php
├── SimpleRuleFactoryTest.php
├── PermissionTest.php
├── ConfigTest.php
├── CompositeRuleTest.php
└── Common
│ ├── ManagerConfigurationTestTrait.php
│ ├── AssignmentsStorageTestTrait.php
│ └── ItemsStorageTestTrait.php
├── config
└── di.php
├── src
├── Role.php
├── Permission.php
├── Exception
│ ├── RuleNotFoundException.php
│ ├── ItemAlreadyExistsException.php
│ ├── RuleInterfaceNotImplementedException.php
│ └── DefaultRolesNotFoundException.php
├── RuleFactoryInterface.php
├── SimpleRuleFactory.php
├── RuleInterface.php
├── RuleContext.php
├── Assignment.php
├── CompositeRule.php
├── SimpleAssignmentsStorage.php
├── Item.php
├── AssignmentsStorageInterface.php
├── ItemsStorageInterface.php
├── ManagerInterface.php
├── SimpleItemsStorage.php
└── Manager.php
├── .php-cs-fixer.dist.php
├── rector.php
├── LICENSE.md
├── composer.json
├── CHANGELOG.md
└── README.md
/tests/Support/hierarchy.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/yiisoft/rbac/master/tests/Support/hierarchy.png
--------------------------------------------------------------------------------
/config/di.php:
--------------------------------------------------------------------------------
1 | Manager::class,
10 | ];
11 |
--------------------------------------------------------------------------------
/src/Role.php:
--------------------------------------------------------------------------------
1 | getParameterValue('viewed') !== true;
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/tests/Support/AdsRule.php:
--------------------------------------------------------------------------------
1 | getParameterValue('dayPeriod') !== 'night';
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/tests/Support/GuestRule.php:
--------------------------------------------------------------------------------
1 | getParameterValue('noGuestsModeOn') !== true;
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/tests/Support/AuthorRule.php:
--------------------------------------------------------------------------------
1 | getParameterValue('authorId') === $userId;
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/src/Exception/ItemAlreadyExistsException.php:
--------------------------------------------------------------------------------
1 | getName()}\" already exists.",
17 | $code,
18 | $previous,
19 | );
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/.php-cs-fixer.dist.php:
--------------------------------------------------------------------------------
1 | in([
10 | __DIR__ . '/src',
11 | __DIR__ . '/tests',
12 | ]);
13 |
14 | return (new Config())
15 | ->setParallelConfig(ParallelConfigFactory::detect())
16 | ->setRules([
17 | '@PER-CS3.0' => true,
18 | 'no_unused_imports' => true,
19 | 'ordered_class_elements' => true,
20 | 'class_attributes_separation' => ['elements' => ['method' => 'one']],
21 | ])
22 | ->setFinder($finder);
23 |
--------------------------------------------------------------------------------
/src/Exception/RuleInterfaceNotImplementedException.php:
--------------------------------------------------------------------------------
1 | true,
15 | '4' => false,
16 | ];
17 |
18 | public function execute(?string $userId, Item $item, RuleContext $context): bool
19 | {
20 | if ($userId === null || $context->getParameterValue('voidSubscription') === true) {
21 | return false;
22 | }
23 |
24 | return self::SUBSCRIPTION_MAP[$userId] ?? null === true;
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/tests/Exception/DefaultRolesNotFoundExceptionTest.php:
--------------------------------------------------------------------------------
1 | assertSame(0, $exception->getCode());
16 | }
17 |
18 | public function testReturnTypes(): void
19 | {
20 | $exception = new DefaultRolesNotFoundException('test');
21 | $this->assertIsString($exception->getName());
22 | $this->assertIsString($exception->getSolution());
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/src/RuleInterface.php:
--------------------------------------------------------------------------------
1 | parameters;
17 | }
18 |
19 | public function getParameterValue(string $name): mixed
20 | {
21 | return $this->parameters[$name] ?? null;
22 | }
23 |
24 | public function hasParameter(string $name): bool
25 | {
26 | return array_key_exists($name, $this->parameters);
27 | }
28 |
29 | public function createRule(string $name): RuleInterface
30 | {
31 | return $this->ruleFactory->create($name);
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/tests/AssignmentTest.php:
--------------------------------------------------------------------------------
1 | withItemName('test2');
16 |
17 | $this->assertNotSame($original, $new);
18 | }
19 |
20 | public function testGetAttributes(): void
21 | {
22 | $assignment = new Assignment(userId: '42', itemName: 'test1', createdAt: 1_642_029_084);
23 | $this->assertSame([
24 | 'item_name' => 'test1',
25 | 'user_id' => '42',
26 | 'created_at' => 1_642_029_084,
27 | ], $assignment->getAttributes());
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/rector.php:
--------------------------------------------------------------------------------
1 | withPaths([
14 | __DIR__ . '/src',
15 | __DIR__ . '/tests',
16 | ])
17 | ->withPhpSets(php81: true)
18 | ->withRules([
19 | InlineConstructorDefaultToPropertyRector::class,
20 | ])
21 | ->withSkip([
22 | ClosureToArrowFunctionRector::class,
23 | ReadOnlyPropertyRector::class,
24 | NullToStrictStringFuncCallArgRector::class,
25 | NewInInitializerRector::class,
26 | ]);
27 |
--------------------------------------------------------------------------------
/tests/Exception/RuleNotFoundExceptionTest.php:
--------------------------------------------------------------------------------
1 | assertSame('Rule "MyRule" not found.', $exception->getMessage());
18 | $this->assertSame(0, $exception->getCode());
19 | $this->assertNull($exception->getPrevious());
20 | }
21 |
22 | public function testCodeAndPreviousException(): void
23 | {
24 | $code = 212;
25 | $previousException = new Exception();
26 |
27 | $exception = new RuleNotFoundException('MyRule', $code, $previousException);
28 |
29 | $this->assertSame($code, $exception->getCode());
30 | $this->assertSame($previousException, $exception->getPrevious());
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/tests/Exception/ItemAlreadyExistsExceptionTest.php:
--------------------------------------------------------------------------------
1 | assertSame('Role or permission with name "reader" already exists.', $exception->getMessage());
19 | $this->assertSame(0, $exception->getCode());
20 | $this->assertNull($exception->getPrevious());
21 | }
22 |
23 | public function testCodeAndPreviousException(): void
24 | {
25 | $code = 212;
26 | $previousException = new Exception();
27 |
28 | $exception = new ItemAlreadyExistsException(new Role('reader'), $code, $previousException);
29 |
30 | $this->assertSame($code, $exception->getCode());
31 | $this->assertSame($previousException, $exception->getPrevious());
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/tests/Exception/RuleInterfaceNotImplementedExceptionTest.php:
--------------------------------------------------------------------------------
1 | assertSame(
19 | 'Rule "MyRule" must implement "' . RuleInterface::class . '".',
20 | $exception->getMessage(),
21 | );
22 | $this->assertSame(0, $exception->getCode());
23 | $this->assertNull($exception->getPrevious());
24 | }
25 |
26 | public function testCodeAndPreviousException(): void
27 | {
28 | $code = 212;
29 | $previousException = new Exception();
30 |
31 | $exception = new RuleInterfaceNotImplementedException('MyRule', $code, $previousException);
32 |
33 | $this->assertSame($code, $exception->getCode());
34 | $this->assertSame($previousException, $exception->getPrevious());
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/tests/RuleContextTest.php:
--------------------------------------------------------------------------------
1 | 1, 'b' => 2]);
17 | $this->assertSame(['a' => 1, 'b' => 2], $context->getParameters());
18 | }
19 |
20 | public function testGetParameterValue(): void
21 | {
22 | $context = new RuleContext(new SimpleRuleFactory(), ['a' => 1, 'b' => 2]);
23 | $this->assertSame(1, $context->getParameterValue('a'));
24 | $this->assertNull($context->getParameterValue('c'));
25 | }
26 |
27 | public function testHasParameter(): void
28 | {
29 | $context = new RuleContext(new SimpleRuleFactory(), ['a' => 1, 'b' => 2]);
30 | $this->assertTrue($context->hasParameter('a'));
31 | $this->assertFalse($context->hasParameter('c'));
32 | }
33 |
34 | public function testCreateRule(): void
35 | {
36 | $context = new RuleContext(new SimpleRuleFactory(), ['a' => 1, 'b' => 2]);
37 | $this->assertEquals(new TrueRule(), $context->createRule(TrueRule::class));
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/tests/RoleTest.php:
--------------------------------------------------------------------------------
1 | withName('test2');
18 | $new2 = $original->withDescription('new description');
19 | $new3 = $original->withUpdatedAt(1_642_029_084);
20 | $new4 = $original->withCreatedAt(1_642_029_084);
21 | $new5 = $original->withRuleName(TrueRule::class);
22 |
23 | $this->assertNotSame($original, $new1);
24 | $this->assertNotSame($original, $new2);
25 | $this->assertNotSame($original, $new3);
26 | $this->assertNotSame($original, $new4);
27 | $this->assertNotSame($original, $new5);
28 | }
29 |
30 | public function testDefaultAttributes(): void
31 | {
32 | $permission = new Role('test');
33 | $this->assertSame([
34 | 'name' => 'test',
35 | 'description' => '',
36 | 'rule_name' => null,
37 | 'type' => Item::TYPE_ROLE,
38 | 'updated_at' => null,
39 | 'created_at' => null,
40 | ], $permission->getAttributes());
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/tests/SimpleRuleFactoryTest.php:
--------------------------------------------------------------------------------
1 | assertEquals(new TrueRule(), (new SimpleRuleFactory())->create(TrueRule::class));
20 | }
21 |
22 | public function testCreateWithNonExistingRule(): void
23 | {
24 | $this->expectException(RuleNotFoundException::class);
25 | $this->expectExceptionMessage('Rule "non-existing-rule" not found.');
26 | (new SimpleRuleFactory())->create('non-existing-rule');
27 | }
28 |
29 | public function testCreateWithRuleMissingImplements(): void
30 | {
31 | $className = WannabeRule::class;
32 | $interfaceName = RuleInterface::class;
33 |
34 | $this->expectException(RuleInterfaceNotImplementedException::class);
35 | $this->expectExceptionMessage("Rule \"$className\" must implement \"$interfaceName\".");
36 | (new SimpleRuleFactory())->create($className);
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/tests/PermissionTest.php:
--------------------------------------------------------------------------------
1 | withName('test2');
18 | $new2 = $original->withDescription('new description');
19 | $new3 = $original->withUpdatedAt(1_642_029_084);
20 | $new4 = $original->withCreatedAt(1_642_029_084);
21 | $new5 = $original->withRuleName(TrueRule::class);
22 |
23 | $this->assertNotSame($original, $new1);
24 | $this->assertNotSame($original, $new2);
25 | $this->assertNotSame($original, $new3);
26 | $this->assertNotSame($original, $new4);
27 | $this->assertNotSame($original, $new5);
28 | }
29 |
30 | public function testDefaultAttributes(): void
31 | {
32 | $permission = new Permission('test');
33 | $this->assertSame([
34 | 'name' => 'test',
35 | 'description' => '',
36 | 'rule_name' => null,
37 | 'type' => Item::TYPE_PERMISSION,
38 | 'updated_at' => null,
39 | 'created_at' => null,
40 | ], $permission->getAttributes());
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/tests/ConfigTest.php:
--------------------------------------------------------------------------------
1 | createContainer();
23 |
24 | $manager = $container->get(ManagerInterface::class);
25 | $this->assertInstanceOf(Manager::class, $manager);
26 | }
27 |
28 | private function createContainer(): ContainerInterface
29 | {
30 | $definitions = $this->getContainerDefinitions();
31 | $definitions = array_merge($definitions, [
32 | ItemsStorageInterface::class => FakeItemsStorage::class,
33 | AssignmentsStorageInterface::class => FakeAssignmentsStorage::class,
34 | ]);
35 | $config = ContainerConfig::create()->withDefinitions($definitions);
36 |
37 | return new Container($config);
38 | }
39 |
40 | private function getContainerDefinitions(): array
41 | {
42 | return require dirname(__DIR__) . '/config/di.php';
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | Copyright © 2008 by Yii Software (https://www.yiiframework.com/)
2 | All rights reserved.
3 |
4 | Redistribution and use in source and binary forms, with or without
5 | modification, are permitted provided that the following conditions
6 | are met:
7 |
8 | * Redistributions of source code must retain the above copyright
9 | notice, this list of conditions and the following disclaimer.
10 | * Redistributions in binary form must reproduce the above copyright
11 | notice, this list of conditions and the following disclaimer in
12 | the documentation and/or other materials provided with the
13 | distribution.
14 | * Neither the name of Yii Software nor the names of its
15 | contributors may be used to endorse or promote products derived
16 | from this software without specific prior written permission.
17 |
18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
19 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
20 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
21 | FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
22 | COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
23 | INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
24 | BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
25 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
26 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
27 | LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
28 | ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
29 | POSSIBILITY OF SUCH DAMAGE.
30 |
--------------------------------------------------------------------------------
/src/Assignment.php:
--------------------------------------------------------------------------------
1 | userId;
28 | }
29 |
30 | public function getItemName(): string
31 | {
32 | return $this->itemName;
33 | }
34 |
35 | public function withItemName(string $roleName): self
36 | {
37 | $new = clone $this;
38 | $new->itemName = $roleName;
39 | return $new;
40 | }
41 |
42 | public function getCreatedAt(): int
43 | {
44 | return $this->createdAt;
45 | }
46 |
47 | /**
48 | * @return array Attribute values indexed by corresponding names.
49 | * @psalm-return RawAssignment
50 | */
51 | public function getAttributes(): array
52 | {
53 | return [
54 | 'item_name' => $this->getItemName(),
55 | 'user_id' => $this->getUserId(),
56 | 'created_at' => $this->getCreatedAt(),
57 | ];
58 | }
59 | }
60 |
--------------------------------------------------------------------------------
/tests/CompositeRuleTest.php:
--------------------------------------------------------------------------------
1 | [CompositeRule::AND, [], true],
22 | 'AND all true' => [CompositeRule::AND, [TrueRule::class, TrueRule::class], true],
23 | 'AND last false' => [CompositeRule::AND, [TrueRule::class, FalseRule::class], false],
24 |
25 | 'OR empty' => [CompositeRule::OR, [], true],
26 | 'OR all false' => [CompositeRule::OR, [FalseRule::class, FalseRule::class], false],
27 | 'OR last true' => [CompositeRule::OR, [FalseRule::class, TrueRule::class], true],
28 | ];
29 | }
30 |
31 | /**
32 | * @dataProvider dataCompositeRule
33 | */
34 | public function testCompositeRule(string $operator, array $rules, bool $expected): void
35 | {
36 | $rule = new CompositeRule($operator, $rules);
37 | $result = $rule->execute('user', new Permission('permission'), new RuleContext(new SimpleRuleFactory(), []));
38 | $this->assertSame($expected, $result);
39 | }
40 |
41 | public function testInvalidOperator(): void
42 | {
43 | $this->expectException(InvalidArgumentException::class);
44 | $this->expectExceptionMessage(
45 | 'Operator could be either '
46 | . CompositeRule::class
47 | . '::AND or '
48 | . CompositeRule::class
49 | . '::OR, "no_such_operation" given.',
50 | );
51 | new CompositeRule('no_such_operation', []);
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/src/CompositeRule.php:
--------------------------------------------------------------------------------
1 | ruleNames)) {
49 | return true;
50 | }
51 |
52 | foreach ($this->ruleNames as $ruleName) {
53 | $result = $context->createRule($ruleName)->execute($userId, $item, $context);
54 |
55 | if ($this->operator === self::AND && $result === false) {
56 | return false;
57 | }
58 |
59 | if ($this->operator === self::OR && $result === true) {
60 | return true;
61 | }
62 | }
63 |
64 | return $this->operator === self::AND;
65 | }
66 | }
67 |
--------------------------------------------------------------------------------
/composer.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "yiisoft/rbac",
3 | "type": "library",
4 | "description": "Yii Role-Based Access Control",
5 | "keywords": [
6 | "yii",
7 | "rbac"
8 | ],
9 | "homepage": "https://www.yiiframework.com/",
10 | "license": "BSD-3-Clause",
11 | "support": {
12 | "issues": "https://github.com/yiisoft/rbac/issues?state=open",
13 | "source": "https://github.com/yiisoft/rbac",
14 | "forum": "https://www.yiiframework.com/forum/",
15 | "wiki": "https://www.yiiframework.com/wiki/",
16 | "irc": "ircs://irc.libera.chat:6697/yii",
17 | "chat": "https://t.me/yii3en"
18 | },
19 | "funding": [
20 | {
21 | "type": "opencollective",
22 | "url": "https://opencollective.com/yiisoft"
23 | },
24 | {
25 | "type": "github",
26 | "url": "https://github.com/sponsors/yiisoft"
27 | }
28 | ],
29 | "require": {
30 | "php": "8.1 - 8.5",
31 | "yiisoft/access": "^2.0",
32 | "yiisoft/friendly-exception": "^1.1"
33 | },
34 | "require-dev": {
35 | "friendsofphp/php-cs-fixer": "^3.91",
36 | "maglnet/composer-require-checker": "^4.7.1",
37 | "phpunit/phpunit": "^10.5.45",
38 | "psr/clock": "^1.0",
39 | "rector/rector": "^2.0.10",
40 | "roave/infection-static-analysis-plugin": "^1.35",
41 | "spatie/phpunit-watcher": "^1.24",
42 | "vimeo/psalm": "^5.26.1 || ^6.9.2",
43 | "yiisoft/di": "^1.3"
44 | },
45 | "suggest": {
46 | "yiisoft/rbac-cycle-db": "For using Cycle as a storage",
47 | "yiisoft/rbac-db": "For using Yii Database as a storage",
48 | "yiisoft/rbac-php": "For using PHP files as a storage",
49 | "yiisoft/rbac-rules-container": "To create rules via Yii Factory",
50 | "psr/clock": "For using custom clock"
51 | },
52 | "autoload": {
53 | "psr-4": {
54 | "Yiisoft\\Rbac\\": "src"
55 | }
56 | },
57 | "autoload-dev": {
58 | "psr-4": {
59 | "Yiisoft\\Rbac\\Tests\\": "tests"
60 | }
61 | },
62 | "extra": {
63 | "config-plugin-options": {
64 | "source-directory": "config"
65 | },
66 | "config-plugin": {
67 | "di": "di.php"
68 | }
69 | },
70 | "config": {
71 | "sort-packages": true,
72 | "allow-plugins": {
73 | "infection/extension-installer": true,
74 | "composer/package-versions-deprecated": true
75 | }
76 | },
77 | "scripts": {
78 | "cs-fix": "php-cs-fixer fix",
79 | "test": "phpunit --testdox --no-interaction",
80 | "test-watch": "phpunit-watcher watch"
81 | }
82 | }
83 |
--------------------------------------------------------------------------------
/src/SimpleAssignmentsStorage.php:
--------------------------------------------------------------------------------
1 | >
20 | */
21 | protected array $assignments = [];
22 |
23 | public function getAll(): array
24 | {
25 | return $this->assignments;
26 | }
27 |
28 | public function getByUserId(string $userId): array
29 | {
30 | return $this->assignments[$userId] ?? [];
31 | }
32 |
33 | public function getByItemNames(array $itemNames): array
34 | {
35 | $result = [];
36 |
37 | foreach ($this->assignments as $assignments) {
38 | foreach ($assignments as $userAssignment) {
39 | if (in_array($userAssignment->getItemName(), $itemNames, true)) {
40 | $result[] = $userAssignment;
41 | }
42 | }
43 | }
44 |
45 | return $result;
46 | }
47 |
48 | public function get(string $itemName, string $userId): ?Assignment
49 | {
50 | return $this->getByUserId($userId)[$itemName] ?? null;
51 | }
52 |
53 | public function exists(string $itemName, string $userId): bool
54 | {
55 | return isset($this->getByUserId($userId)[$itemName]);
56 | }
57 |
58 | public function userHasItem(string $userId, array $itemNames): bool
59 | {
60 | $assignments = $this->getByUserId($userId);
61 | if (empty($assignments)) {
62 | return false;
63 | }
64 |
65 | foreach ($itemNames as $itemName) {
66 | if (array_key_exists($itemName, $assignments)) {
67 | return true;
68 | }
69 | }
70 |
71 | return false;
72 | }
73 |
74 | public function filterUserItemNames(string $userId, array $itemNames): array
75 | {
76 | $assignments = $this->getByUserId($userId);
77 | if (empty($assignments)) {
78 | return [];
79 | }
80 |
81 | $userItemNames = [];
82 | foreach ($itemNames as $itemName) {
83 | if (array_key_exists($itemName, $assignments)) {
84 | $userItemNames[] = $itemName;
85 | }
86 | }
87 |
88 | return $userItemNames;
89 | }
90 |
91 | public function add(Assignment $assignment): void
92 | {
93 | $this->assignments[$assignment->getUserId()][$assignment->getItemName()] = $assignment;
94 | }
95 |
96 | public function hasItem(string $name): bool
97 | {
98 | foreach ($this->getAll() as $assignmentInfo) {
99 | if (array_key_exists($name, $assignmentInfo)) {
100 | return true;
101 | }
102 | }
103 |
104 | return false;
105 | }
106 |
107 | public function renameItem(string $oldName, string $newName): void
108 | {
109 | if ($oldName === $newName) {
110 | return;
111 | }
112 |
113 | foreach ($this->assignments as &$assignments) {
114 | if (isset($assignments[$oldName])) {
115 | $assignments[$newName] = $assignments[$oldName]->withItemName($newName);
116 | unset($assignments[$oldName]);
117 | }
118 | }
119 | }
120 |
121 | public function remove(string $itemName, string $userId): void
122 | {
123 | unset($this->assignments[$userId][$itemName]);
124 | }
125 |
126 | public function removeByUserId(string $userId): void
127 | {
128 | $this->assignments[$userId] = [];
129 | }
130 |
131 | public function removeByItemName(string $itemName): void
132 | {
133 | foreach ($this->assignments as &$assignments) {
134 | unset($assignments[$itemName]);
135 | }
136 | }
137 |
138 | public function clear(): void
139 | {
140 | $this->assignments = [];
141 | }
142 | }
143 |
--------------------------------------------------------------------------------
/src/Item.php:
--------------------------------------------------------------------------------
1 | name;
59 | }
60 |
61 | /**
62 | * @return static
63 | */
64 | final public function withName(string $name): self
65 | {
66 | $new = clone $this;
67 | $new->name = $name;
68 | return $new;
69 | }
70 |
71 | /**
72 | * @return static
73 | */
74 | final public function withDescription(string $description): self
75 | {
76 | $new = clone $this;
77 | $new->description = $description;
78 | return $new;
79 | }
80 |
81 | final public function getDescription(): string
82 | {
83 | return $this->description;
84 | }
85 |
86 | /**
87 | * @return static
88 | */
89 | final public function withRuleName(?string $ruleName): self
90 | {
91 | $new = clone $this;
92 | $new->ruleName = $ruleName;
93 | return $new;
94 | }
95 |
96 | final public function getRuleName(): ?string
97 | {
98 | return $this->ruleName;
99 | }
100 |
101 | /**
102 | * @return static
103 | */
104 | final public function withCreatedAt(int $createdAt): self
105 | {
106 | $new = clone $this;
107 | $new->createdAt = $createdAt;
108 | return $new;
109 | }
110 |
111 | final public function getCreatedAt(): ?int
112 | {
113 | return $this->createdAt;
114 | }
115 |
116 | /**
117 | * @return static
118 | */
119 | final public function withUpdatedAt(int $updatedAt): self
120 | {
121 | $new = clone $this;
122 | $new->updatedAt = $updatedAt;
123 | return $new;
124 | }
125 |
126 | final public function getUpdatedAt(): ?int
127 | {
128 | return $this->updatedAt;
129 | }
130 |
131 | final public function hasCreatedAt(): bool
132 | {
133 | return $this->createdAt !== null;
134 | }
135 |
136 | final public function hasUpdatedAt(): bool
137 | {
138 | return $this->updatedAt !== null;
139 | }
140 |
141 | /**
142 | * @return array Attribute values indexed by corresponding names.
143 | * @psalm-return array{
144 | * name: string,
145 | * description: string,
146 | * rule_name: string|null,
147 | * type: string,
148 | * updated_at: int|null,
149 | * created_at: int|null,
150 | * }
151 | */
152 | final public function getAttributes(): array
153 | {
154 | return [
155 | 'name' => $this->getName(),
156 | 'description' => $this->getDescription(),
157 | 'rule_name' => $this->getRuleName(),
158 | 'type' => $this->getType(),
159 | 'updated_at' => $this->getUpdatedAt(),
160 | 'created_at' => $this->getCreatedAt(),
161 | ];
162 | }
163 | }
164 |
--------------------------------------------------------------------------------
/tests/Common/ManagerConfigurationTestTrait.php:
--------------------------------------------------------------------------------
1 | $itemsStorage ?? $this->createItemsStorage(),
30 | 'assignmentsStorage' => $assignmentsStorage ?? $this->createAssignmentsStorage(),
31 | 'clock' => $currentDateTime === null
32 | ? null
33 | : new class ($currentDateTime) implements ClockInterface {
34 | public function __construct(private readonly DateTimeImmutable $dateTime) {}
35 |
36 | public function now(): DateTimeImmutable
37 | {
38 | return $this->dateTime;
39 | }
40 | },
41 | ];
42 | if ($enableDirectPermissions !== null) {
43 | $arguments['enableDirectPermissions'] = $enableDirectPermissions;
44 | }
45 |
46 | if ($includeRolesInAccessChecks !== null) {
47 | $arguments['includeRolesInAccessChecks'] = $includeRolesInAccessChecks;
48 | }
49 |
50 | return new Manager(...$arguments);
51 | }
52 |
53 | protected function createItemsStorage(): ItemsStorageInterface
54 | {
55 | return new FakeItemsStorage();
56 | }
57 |
58 | protected function createAssignmentsStorage(): AssignmentsStorageInterface
59 | {
60 | return new FakeAssignmentsStorage();
61 | }
62 |
63 | protected function createFilledManager(
64 | ?ItemsStorageInterface $itemsStorage = null,
65 | ?AssignmentsStorageInterface $assignmentsStorage = null,
66 | ?bool $includeRolesInAccessChecks = null,
67 | ): ManagerInterface {
68 | $arguments = [
69 | $itemsStorage ?? $this->createItemsStorage(),
70 | $assignmentsStorage ?? $this->createAssignmentsStorage(),
71 | true,
72 | ];
73 | if ($includeRolesInAccessChecks !== null) {
74 | $arguments[] = $includeRolesInAccessChecks;
75 | }
76 |
77 | return $this
78 | ->createManager(...$arguments)
79 | ->addPermission(new Permission('Fast Metabolism'))
80 | ->addPermission(new Permission('createPost'))
81 | ->addPermission(new Permission('publishPost'))
82 | ->addPermission(new Permission('readPost'))
83 | ->addPermission(new Permission('deletePost'))
84 | ->addPermission((new Permission('updatePost'))->withRuleName(AuthorRule::class))
85 | ->addPermission(new Permission('updateAnyPost'))
86 | ->addRole(new Role('reader'))
87 | ->addRole(new Role('author'))
88 | ->addRole(new Role('admin'))
89 | ->addRole(new Role('myDefaultRole'))
90 | ->setDefaultRoleNames(['myDefaultRole'])
91 | ->addChild('reader', 'readPost')
92 | ->addChild('author', 'createPost')
93 | ->addChild('author', 'updatePost')
94 | ->addChild('author', 'reader')
95 | ->addChild('admin', 'author')
96 | ->addChild('admin', 'updateAnyPost')
97 | ->assign(itemName: 'Fast Metabolism', userId: 'reader A')
98 | ->assign(itemName: 'reader', userId: 'reader A')
99 | ->assign(itemName: 'author', userId: 'author B')
100 | ->assign(itemName: 'deletePost', userId: 'author B')
101 | ->assign(itemName: 'publishPost', userId: 'author B')
102 | ->assign(itemName: 'admin', userId: 'admin C');
103 | }
104 | }
105 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Yii Role-Based Access Control Change Log
2 |
3 | ## 2.1.3 under development
4 |
5 | - no changes in this release.
6 |
7 | ## 2.1.2 December 19, 2025
8 |
9 | - Enh #284: Allow using `yiisoft/access` version `^2.0` (@vjik)
10 |
11 | ## 2.1.1 December 11, 2025
12 |
13 | - Enh #282: Add PHP 8.5 support (@vjik)
14 |
15 | ## 2.1.0 April 04, 2025
16 |
17 | - New #275: Add optional `$clock` parameter to `Manager` constructor to get current time (@vjik)
18 | - Chg #276: Change PHP constraint in `composer.json` to `8.1 - 8.4` (@vjik)
19 | - Bug #264: Fix bug when default roles were not checked in `Manager::userHasPermission()` (@KovYu, @arogachev)
20 |
21 | ## 2.0.0 March 07, 2024
22 |
23 | - New #161: Add `ManagerInterface` (@arogachev)
24 | - New #230: Add `Assignment::getAttributes()` method (@arogachev)
25 | - Chg #134: Add `$createdAt` parameter to `ManagerInterface::assign()` (@arogachev)
26 | - Chg #134: Replace all parameters with single `$assignment` parameter in `AssignmentsStorageInterface::add()`
27 | (@arogachev)
28 | - Chg #161, #217: Raise PHP version to 8.1 (@arogachev)
29 | - Chg #161: Allow to reuse manager test code in related packages (@arogachev)
30 | - Chg #172: Make `$userId` parameter `nullable` in `RuleInterface::execute()` (@arogachev)
31 | - Chg #202: Rename `$permissionName` parameter to `$name` in `ManagerInterface::removePermission()` method (@arogachev)
32 | - Chg #203: Verify that every passed role name is a string in `Manager::setDefaultRoleNames()` (@arogachev)
33 | - Chg #203: Throw `RuntimeException` in the case with implicit guest and non-existing guest role in
34 | `Manager::userHasPermission()` (@arogachev)
35 | - Chg #259: Rename `$ruleContext` argument to `$context` in `RuleInterface::execute()` (@arogachev)
36 | - Enh #134: Improve handling and control of `Assignment::$createdAt` (@arogachev)
37 | - Enh #165, #203, #206, #208, #237: Add methods to `ItemsStorageInterface`: `roleExists()`, `getRolesByNames()`,
38 | `getPermissionsByNames()`, `getAllChildren()`, `getAllChildRoles()`, `getAllChildPermissions()`, `hasChild()`,
39 | `hasDirectChild()`, `getByNames()`, `getHierarchy()` (@arogachev)
40 | - Enh #165, #203: Add methods to `AssignmentsStorageInterface`: `getByItemNames()`, `exists()`, `userHasItem()`,
41 | `filterUserItemNames()` (@arogachev)
42 | - Enh #165, #206: Improve performance, including optimization of calls for getting child items within the loops
43 | (@arogachev)
44 | - Enh #165: Rename `DefaultRoleNotFoundException` to `DefaultRolesNotFoundException` and finalize it (@arogachev)
45 | - Enh #165: Rename `getChildren` method to `getDirectAchildren()` in `ItemsStorageInterface` (@arogachev)
46 | - Enh #202, #203: Add methods to `ManagerInterface`: `getRole()`, `getPermission()`, `hasChildren()`,
47 | `getItemsByUserId()` (@arogachev)
48 | - Enh #203: Add methods to `Manager`: `getGuestRoleName()`, `getGuestRole()` (@arogachev)
49 | - Enh #204: Add simple storages for items and assignments (@arogachev)
50 | - Enh #227: Use snake case for item attribute names (ease migration from Yii 2) (@arogachev)
51 | - Enh #245: Handle same names during renaming item in `AssignmentsStorage` (@arogachev)
52 | - Enh #248: Add `SimpleRuleFactory` (@arogachev)
53 | - Enh #251: Allow checking for user's roles in `ManagerInterface::userHasPermission()` (@arogachev)
54 | - Enh #252: Return `$this` instead of throwing "already assigned" exception in `Manager::assign()` (@arogachev)
55 | - Bug #172: Execute rule when checking permissions for guests (@arogachev)
56 | - Bug #175: Use rule factory for creating rule instances in `CompositeRule` (@arogachev)
57 | - Bug #178: Exclude parent role from `Manager::getAllChildRoles()` (@arogachev)
58 | - Bug #203: Execute rules for parent items and for guests in `Manager::userHasPermission()` (@arogachev)
59 | - Bug #203: Do not limit child items by only direct ones for guests in `Manager::userHasPermission()` (@arogachev)
60 | - Bug #203: Fix `Manager::getRolesByUserId()` to include child roles (@arogachev)
61 | - Bug #221: Exclude items with base names when getting children (@arogachev)
62 | - Bug #222: Adjust hierarchy when removing item (@arogachev)
63 | - Bug #223: Handle empty assignments in `Manager::getPermissionsByUserId()` (@arogachev)
64 | - Bug #260: Fix `Manager::userHasPermission()` to return `true` for the case when a user have access via at least one
65 | hierarchy branch (@arogachev)
66 |
67 | ## 1.0.2 April 20, 2023
68 |
69 | - Technical release, no code changes.
70 |
71 | ## 1.0.1 April 20, 2023
72 |
73 | - Enh #121: Throw friendly exception when getting non-existing default roles (@DplusG)
74 |
75 | ## 1.0.0 April 08, 2022
76 |
77 | - Initial release.
78 |
--------------------------------------------------------------------------------
/src/AssignmentsStorageInterface.php:
--------------------------------------------------------------------------------
1 | >
18 | */
19 | public function getAll(): array;
20 |
21 | /**
22 | * Returns all role or permission assignment information for the specified user.
23 | *
24 | * @param string $userId The user ID.
25 | *
26 | * @return Assignment[] The assignments. The array is indexed by the role or the permission names. An empty array
27 | * will be returned if there is no role or permission assigned to the user.
28 | * @psalm-return array
29 | */
30 | public function getByUserId(string $userId): array;
31 |
32 | /**
33 | * Returns all role or permission assignment information by the specified item names' list.
34 | *
35 | * @param string[] $itemNames List of item names.
36 | *
37 | * @return Assignment[] The assignments. An empty array will be returned if there are no users assigned to these
38 | * item names.
39 | * @psalm-return list
40 | */
41 | public function getByItemNames(array $itemNames): array;
42 |
43 | /**
44 | * Returns role or permission assignment for the specified item name that belongs to user with the specified ID.
45 | *
46 | * @param string $itemName Item name.
47 | * @param string $userId The user ID.
48 | *
49 | * @return Assignment|null Assignment or null if there is no role or permission assigned to the user.
50 | */
51 | public function get(string $itemName, string $userId): ?Assignment;
52 |
53 | /**
54 | * Whether assignment with a given item name and user id pair exists.
55 | *
56 | * @param string $itemName Item name.
57 | * @param string $userId User id.
58 | *
59 | * @return bool Whether assignment exists.
60 | */
61 | public function exists(string $itemName, string $userId): bool;
62 |
63 | /**
64 | * Whether at least one item from the given list is assigned to the user.
65 | *
66 | * @param string $userId User id.
67 | * @param string[] $itemNames List of item names.
68 | *
69 | * @return bool Whether at least one item from the given list is assigned to the user.
70 | */
71 | public function userHasItem(string $userId, array $itemNames): bool;
72 |
73 | /**
74 | * Filters item names leaving only the ones that are assigned to specific user.
75 | *
76 | * @param string $userId User id.
77 | * @param string[] $itemNames List of item names.
78 | *
79 | * @return string[] Filtered item names.
80 | */
81 | public function filterUserItemNames(string $userId, array $itemNames): array;
82 |
83 | /**
84 | * Adds assignment to the storage.
85 | *
86 | * @param Assignment $assignment Assignment instance.
87 | */
88 | public function add(Assignment $assignment): void;
89 |
90 | /**
91 | * Returns whether there is assignment for a named role or permission.
92 | *
93 | * @param string $name Name of the role or the permission.
94 | *
95 | * @return bool Whether there is assignment.
96 | */
97 | public function hasItem(string $name): bool;
98 |
99 | /**
100 | * Change the name of an item in assignments.
101 | *
102 | * @param string $oldName Old name of the role or the permission.
103 | * @param string $newName New name of the role or permission.
104 | */
105 | public function renameItem(string $oldName, string $newName): void;
106 |
107 | /**
108 | * Removes assignment of a role or a permission to the user with ID specified.
109 | *
110 | * @param string $itemName Name of a role or permission to remove assignment from.
111 | * @param string $userId The user ID.
112 | */
113 | public function remove(string $itemName, string $userId): void;
114 |
115 | /**
116 | * Removes all role or permission assignments for a user with ID specified.
117 | *
118 | * @param string $userId The user ID.
119 | */
120 | public function removeByUserId(string $userId): void;
121 |
122 | /**
123 | * Removes all assignments for role or permission.
124 | *
125 | * @param string $itemName Name of a role or permission to remove.
126 | */
127 | public function removeByItemName(string $itemName): void;
128 |
129 | /**
130 | * Removes all role and permission assignments.
131 | */
132 | public function clear(): void;
133 | }
134 |
--------------------------------------------------------------------------------
/src/ItemsStorageInterface.php:
--------------------------------------------------------------------------------
1 |
11 | * @psalm-type Hierarchy = array
14 | * }>
15 | */
16 | interface ItemsStorageInterface
17 | {
18 | /**
19 | * Removes all roles and permissions.
20 | */
21 | public function clear(): void;
22 |
23 | /**
24 | * Returns all roles and permissions in the system.
25 | *
26 | * @return array All roles and permissions in the system.
27 | * @psalm-return ItemsIndexedByName
28 | */
29 | public function getAll(): array;
30 |
31 | /**
32 | * Returns roles and permission by the given names' list.
33 | *
34 | * @param string[] $names List of role and/or permission names.
35 | *
36 | * @return array Array of role and permission instances indexed by their corresponding names.
37 | * @psalm-return ItemsIndexedByName
38 | */
39 | public function getByNames(array $names): array;
40 |
41 | /**
42 | * Returns the named role or permission.
43 | *
44 | * @param string $name The role or the permission name.
45 | *
46 | * @return Permission|Role|null The role or the permission corresponding to the specified name. `null` is returned
47 | * if there is no such item.
48 | */
49 | public function get(string $name): Permission|Role|null;
50 |
51 | /**
52 | * Whether a named role or permission exists.
53 | *
54 | * @param string $name The role or the permission name.
55 | *
56 | * @return bool Whether a named role or permission exists.
57 | */
58 | public function exists(string $name): bool;
59 |
60 | /**
61 | * Whether a named role exists.
62 | *
63 | * @param string $name The role name.
64 | *
65 | * @return bool Whether a named role exists.
66 | */
67 | public function roleExists(string $name): bool;
68 |
69 | /**
70 | * Adds the role or the permission to RBAC system.
71 | *
72 | * @param Permission|Role $item The role or the permission to add.
73 | */
74 | public function add(Permission|Role $item): void;
75 |
76 | /**
77 | * Updates the specified role or permission in the system.
78 | *
79 | * @param string $name The old name of the role or permission.
80 | * @param Permission|Role $item Modified role or permission.
81 | */
82 | public function update(string $name, Permission|Role $item): void;
83 |
84 | /**
85 | * Removes a role or permission from the RBAC system.
86 | *
87 | * @param string $name Name of a role or a permission to remove.
88 | */
89 | public function remove(string $name): void;
90 |
91 | /**
92 | * Returns all roles in the system.
93 | *
94 | * @return Role[] Array of role instances indexed by role names.
95 | * @psalm-return array
96 | */
97 | public function getRoles(): array;
98 |
99 | /**
100 | * Returns roles by the given names' list.
101 | *
102 | * @param string[] $names List of role names.
103 | *
104 | * @return Role[] Array of role instances indexed by role names.
105 | * @psalm-return array
106 | */
107 | public function getRolesByNames(array $names): array;
108 |
109 | /**
110 | * Returns the named role.
111 | *
112 | * @param string $name The role name.
113 | *
114 | * @return Role|null The role corresponding to the specified name. `null` is returned if no such role.
115 | */
116 | public function getRole(string $name): ?Role;
117 |
118 | /**
119 | * Removes all roles.
120 | * All parent child relations will be adjusted accordingly.
121 | */
122 | public function clearRoles(): void;
123 |
124 | /**
125 | * Returns all permissions in the system.
126 | *
127 | * @return Permission[] Array of permission instances indexed by permission names.
128 | * @psalm-return array
129 | */
130 | public function getPermissions(): array;
131 |
132 | /**
133 | * Returns permissions by the given names' list.
134 | *
135 | * @param string[] $names List of permission names.
136 | *
137 | * @return Permission[] Array of permission instances indexed by permission names.
138 | * @psalm-return array
139 | */
140 | public function getPermissionsByNames(array $names): array;
141 |
142 | /**
143 | * Returns the named permission.
144 | *
145 | * @param string $name The permission name.
146 | *
147 | * @return Permission|null The permission corresponding to the specified name. `null` is returned if there is no
148 | * such permission.
149 | */
150 | public function getPermission(string $name): ?Permission;
151 |
152 | /**
153 | * Removes all permissions.
154 | * All parent child relations will be adjusted accordingly.
155 | */
156 | public function clearPermissions(): void;
157 |
158 | /**
159 | * Returns the parent permissions and/or roles.
160 | *
161 | * @param string $name The child name.
162 | *
163 | * @return array The parent permissions and/or roles.
164 | * @psalm-return ItemsIndexedByName
165 | */
166 | public function getParents(string $name): array;
167 |
168 | /**
169 | * Returns the parents tree for a single item which additionally contains children for each parent (only among the
170 | * found items). The base item is included too, its children list is always empty.
171 | *
172 | * @param string $name The child name.
173 | *
174 | * @return array A mapping between parent names and according items with all their children (references to other
175 | * parents found).
176 | * @psalm-return Hierarchy
177 | */
178 | public function getHierarchy(string $name): array;
179 |
180 | /**
181 | * Returns direct child permissions and/or roles.
182 | *
183 | * @param string $name The parent name.
184 | *
185 | * @return array The child permissions and/or roles.
186 | * @psalm-return ItemsIndexedByName
187 | */
188 | public function getDirectChildren(string $name): array;
189 |
190 | /**
191 | * Returns all child permissions and/or roles.
192 | *
193 | * @param string|string[] $names The parent name / names.
194 | *
195 | * @return array The child permissions and/or roles.
196 | * @psalm-return ItemsIndexedByName
197 | */
198 | public function getAllChildren(string|array $names): array;
199 |
200 | /**
201 | * Returns all child roles.
202 | *
203 | * @param string|string[] $names The parent name / names.
204 | *
205 | * @return Role[] The child roles.
206 | * @psalm-return array
207 | */
208 | public function getAllChildRoles(string|array $names): array;
209 |
210 | /**
211 | * Returns all child permissions.
212 | *
213 | * @param string|string[] $names The parent name / names.
214 | *
215 | * @return Permission[] The child permissions.
216 | * @psalm-return array
217 | */
218 | public function getAllChildPermissions(string|array $names): array;
219 |
220 | /**
221 | * Returns whether named parent has children.
222 | *
223 | * @param string $name The parent name.
224 | *
225 | * @return bool Whether named parent has children.
226 | */
227 | public function hasChildren(string $name): bool;
228 |
229 | /**
230 | * Returns whether selected parent has a child with a given name.
231 | *
232 | * @param string $parentName The parent name.
233 | * @param string $childName The child name.
234 | *
235 | * @return bool Whether selected parent has a child with a given name.
236 | */
237 | public function hasChild(string $parentName, string $childName): bool;
238 |
239 | /**
240 | * Returns whether selected parent has a direct child with a given name.
241 | *
242 | * @param string $parentName The parent name.
243 | * @param string $childName The child name.
244 | *
245 | * @return bool Whether selected parent has a direct child with a given name.
246 | */
247 | public function hasDirectChild(string $parentName, string $childName): bool;
248 |
249 | /**
250 | * Adds a role or a permission as a child of another role or permission.
251 | *
252 | * @param string $parentName Name of the parent to add child to.
253 | * @param string $childName Name of the child to add.
254 | */
255 | public function addChild(string $parentName, string $childName): void;
256 |
257 | /**
258 | * Removes a child from its parent.
259 | * Note, the child role or permission is not deleted. Only the parent-child relationship is removed.
260 | *
261 | * @param string $parentName Name of the parent to remove the child from.
262 | * @param string $childName Name of the child to remove.
263 | */
264 | public function removeChild(string $parentName, string $childName): void;
265 |
266 | /**
267 | * Removed all children form their parent.
268 | * Note, the children roles or permissions are not deleted. Only the parent-child relationships are removed.
269 | *
270 | * @param string $parentName Name of the parent to remove children from.
271 | */
272 | public function removeChildren(string $parentName): void;
273 | }
274 |
--------------------------------------------------------------------------------
/src/ManagerInterface.php:
--------------------------------------------------------------------------------
1 |
137 | */
138 | public function getRolesByUserId(int|Stringable|string $userId): array;
139 |
140 | /**
141 | * Returns child roles of the role specified. Depth isn't limited.
142 | *
143 | * @param string $roleName Name of the role to get child roles for.
144 | *
145 | * @throws InvalidArgumentException If the role was not found by `$roleName`.
146 | *
147 | * @return Role[] Child roles. The array is indexed by the role names.
148 | * @psalm-return array
149 | */
150 | public function getChildRoles(string $roleName): array;
151 |
152 | /**
153 | * Returns all permissions that the specified role represents.
154 | *
155 | * @param string $roleName The role name.
156 | *
157 | * @return Permission[] All permissions that the role represents. The array is indexed by the permission names.
158 | * @psalm-return array
159 | */
160 | public function getPermissionsByRoleName(string $roleName): array;
161 |
162 | /**
163 | * Returns all permissions that the user has.
164 | *
165 | * @param int|string|Stringable $userId The user ID.
166 | *
167 | * @return Permission[] All permissions that the user has. The array is indexed by the permission names.
168 | * @psalm-return array
169 | */
170 | public function getPermissionsByUserId(int|Stringable|string $userId): array;
171 |
172 | /**
173 | * Returns all user IDs assigned to the role specified.
174 | *
175 | * @param string $roleName The role name.
176 | *
177 | * @return array Array of user ID strings.
178 | */
179 | public function getUserIdsByRoleName(string $roleName): array;
180 |
181 | /**
182 | * @throws ItemAlreadyExistsException
183 | */
184 | public function addRole(Role $role): self;
185 |
186 | /**
187 | * @param string $name The role name.
188 | */
189 | public function getRole(string $name): ?Role;
190 |
191 | /**
192 | * @param string $name The role name.
193 | * @param Role $role Role instance with updated data.
194 | */
195 | public function updateRole(string $name, Role $role): self;
196 |
197 | /**
198 | * @param string $name The role name.
199 | */
200 | public function removeRole(string $name): self;
201 |
202 | /**
203 | * @throws ItemAlreadyExistsException
204 | */
205 | public function addPermission(Permission $permission): self;
206 |
207 | /**
208 | * @param string $name The permission name.
209 | */
210 | public function getPermission(string $name): ?Permission;
211 |
212 | /**
213 | * @param string $name The permission name.
214 | */
215 | public function removePermission(string $name): self;
216 |
217 | /**
218 | * @param string $name The permission name.
219 | * @param Permission $permission Permission instance with updated data.
220 | */
221 | public function updatePermission(string $name, Permission $permission): self;
222 |
223 | /**
224 | * Set default role names.
225 | *
226 | * @param array|Closure $roleNames Either array of role names or a closure returning it.
227 | *
228 | * @throws InvalidArgumentException When role names is not a list of strings passed directly or resolved from a
229 | * closure.
230 | */
231 | public function setDefaultRoleNames(array|Closure $roleNames): self;
232 |
233 | /**
234 | * Returns default role names.
235 | *
236 | * @return string[] Default role names.
237 | */
238 | public function getDefaultRoleNames(): array;
239 |
240 | /**
241 | * Returns default roles.
242 | *
243 | * @throws DefaultRolesNotFoundException When at least 1 of the default roles was not found.
244 | * @return Role[] Default roles. The array is indexed by the role names.
245 | * @psalm-return array
246 | */
247 | public function getDefaultRoles(): array;
248 |
249 | /**
250 | * Set guest role name.
251 | *
252 | * @param string|null $name The guest role name.
253 | */
254 | public function setGuestRoleName(?string $name): self;
255 |
256 | /**
257 | * Get guest role name.
258 | *
259 | * @return string|null The guest role name or `null` if it was not set.
260 | */
261 | public function getGuestRoleName(): ?string;
262 |
263 | /**
264 | * Get a guest role.
265 | *
266 | * @throws InvalidArgumentException When a role was not found.
267 | * @return Role|null Guest role or `null` if the name was not set.
268 | */
269 | public function getGuestRole(): ?Role;
270 | }
271 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
Yii Role-Based Access Control
6 |
7 |
8 |
9 | [](https://packagist.org/packages/yiisoft/rbac)
10 | [](https://packagist.org/packages/yiisoft/rbac)
11 | [](https://github.com/yiisoft/rbac/actions/workflows/build.yml)
12 | [](https://codecov.io/gh/yiisoft/rbac)
13 | [](https://dashboard.stryker-mutator.io/reports/github.com/yiisoft/rbac/master)
14 | [](https://github.com/yiisoft/rbac/actions?query=workflow%3A%22static+analysis%22)
15 | [](https://shepherd.dev/github/yiisoft/rbac)
16 |
17 | This package provides [RBAC](https://en.wikipedia.org/wiki/Role-based_access_control) (Role-Based Access Control)
18 | library. It is used in [Yii Framework](https://yiiframework.com) but is usable separately as well.
19 |
20 | ## Features
21 |
22 | - Flexible RBAC hierarchy with roles, permissions, and rules.
23 | - Role inheritance.
24 | - Data could be passed to rules when checking access.
25 | - Multiple storage adapters.
26 | - Separate storages could be used for user-role assignments and role hierarchy.
27 | - API to manage RBAC hierarchy.
28 |
29 | ## Requirements
30 |
31 | - PHP 8.1 - 8.5.
32 |
33 | ## Installation
34 |
35 | The package could be installed with [Composer](https://getcomposer.org):
36 |
37 | ```shell
38 | composer require yiisoft/rbac
39 | ```
40 |
41 | One of the following storages could be installed as well:
42 |
43 | - [PHP storage](https://github.com/yiisoft/rbac-php) - PHP file storage;
44 | - [DB storage](https://github.com/yiisoft/rbac-db) - database storage based on [Yii DB](https://github.com/yiisoft/db);
45 | - [Cycle DB storage](https://github.com/yiisoft/rbac-cycle-db) - database storage based on
46 | [Cycle DBAL](https://github.com/cycle/database).
47 |
48 | Also, there is a rule factory implementation - [Rules Container](https://github.com/yiisoft/rbac-rules-container) (based
49 | on [Yii Factory](https://github.com/yiisoft/factory)).
50 |
51 | All these can be replaced with custom implementations.
52 |
53 | ## General usage
54 |
55 | ### Setting up manager
56 |
57 | First step when using RBAC is to configure an instance of `Manager`:
58 |
59 | ```php
60 | use Yiisoft\Rbac\AssignmentsStorageInterface;
61 | use Yiisoft\Rbac\ItemsStorageInterface;
62 | use Yiisoft\Rbac\RuleFactoryInterface;
63 |
64 | /**
65 | * @var ItemsStorageInterface $itemsStorage
66 | * @var AssignmentsStorageInterface $assignmentsStorage
67 | * @var RuleFactoryInterface $ruleFactory
68 | */
69 | $manager = new Manager($itemsStorage, $assignmentsStorage, $ruleFactory);
70 | ```
71 |
72 | It requires the following dependencies:
73 |
74 | - Items storage (hierarchy itself).
75 | - Assignments storage where user IDs are mapped to roles.
76 | - Rule factory. Creates a rule instance by a given name.
77 |
78 | While storages are required, rule factory is optional and, when omitted, `SimpleRuleFactory` will be used. For more
79 | advanced usage, such as resolving rules by aliases and passing arguments in rules constructor, install
80 | [Rules Container](https://github.com/yiisoft/rbac-rules-container) additionally or write your own implementation.
81 |
82 | A few tips for choosing storage backend:
83 |
84 | - Roles and permissions could usually be considered "semi-static," as they only change when you update your application
85 | code, so it may make sense to use PHP storage for it.
86 | - Assignments, on the other hand, could be considered "dynamic." They change more often: when creating a new user,
87 | or when updating a user role from within your application. So it may make sense to use database storage for assignments.
88 |
89 | ### Managing RBAC hierarchy
90 |
91 | Before being able to check for permissions, an RBAC hierarchy must be defined. Usually it is done via either console
92 | commands or migrations. Hierarchy consists of permissions, roles, and rules:
93 |
94 | - Permissions are granules of access such as "create a post" or "read a post."
95 | - A role is what is assigned to the user. The Role is granted one or more permissions. Typical roles are "manager" or
96 | "admin."
97 | - Rule is a PHP class that has given some data answers a single question "given the data has the user the permission
98 | asked for."
99 |
100 | To create a permission, use the following code:
101 |
102 | ```php
103 | use Yiisoft\Rbac\ManagerInterface;
104 | use Yiisoft\Rbac\Permission;
105 |
106 | /** @var ManagerInterface $manager */
107 | $manager->addPermission(new Permission('createPost'));
108 | $manager->addPermission(new Permission('readPost'));
109 | $manager->addPermission(new Permission('deletePost'));
110 | ```
111 |
112 | To add some roles:
113 |
114 | ```php
115 | use Yiisoft\Rbac\ManagerInterface;
116 | use Yiisoft\Rbac\Role;
117 |
118 | /** @var ManagerInterface $manager */
119 | $manager->addRole(new Role('author'));
120 | $manager->addRole(new Role('reader'));
121 | ```
122 |
123 | Next, we need to attach permissions to roles:
124 |
125 | ```php
126 | use Yiisoft\Rbac\ManagerInterface;
127 |
128 | /** @var ManagerInterface $manager */
129 | $manager->addChild('reader', 'readPost');
130 | $manager->addChild('author', 'createPost');
131 | $manager->addChild('author', 'deletePost');
132 | $manager->addChild('author', 'reader');
133 | ```
134 |
135 | Hierarchy for the example above:
136 |
137 | ```mermaid
138 | flowchart LR
139 | createPost:::permission ---> author:::role
140 | readPost:::permission --> reader:::role --> author:::role
141 | deletePost:::permission ---> author:::role
142 | classDef permission fill:#fc0,stroke:#000,color:#000
143 | classDef role fill:#9c0,stroke:#000,color:#000
144 | ```
145 |
146 | Sometimes, basic permissions are not enough. In this case, rules are helpful. Rules are PHP classes that could be
147 | added to permissions and roles:
148 |
149 | ```php
150 | use Yiisoft\Rbac\Item;
151 | use Yiisoft\Rbac\RuleContext;
152 | use Yiisoft\Rbac\RuleInterface;
153 |
154 | class ActionRule implements RuleInterface
155 | {
156 | public function execute(?string $userId, Item $item, RuleContext $context): bool;
157 | {
158 | return $context->getParameterValue('action') === 'home';
159 | }
160 | }
161 | ```
162 |
163 | With rule added, the role or permission is considered only when rule's `execute()` method returns `true`.
164 |
165 | The parameters are:
166 |
167 | - `$userId` is user id to check permission against;
168 | - `$item` is RBAC hierarchy item that rule is attached to;
169 | - `$context` is a rule context providing access to parameters.
170 |
171 | To use rules with `Manager`, specify their names with added permissions or roles:
172 |
173 | ```php
174 | use Yiisoft\Rbac\ManagerInterface;
175 | use Yiisoft\Rbac\Permission;
176 |
177 | /** @var ManagerInterface $manager */
178 | $manager->addPermission(
179 | (new Permission('viewList'))->withRuleName(ActionRule::class),
180 | );
181 |
182 | // or
183 |
184 | $manager->addRole(
185 | (new Role('NewYearMaintainer'))->withRuleName(NewYearOnlyRule::class)
186 | );
187 | ```
188 |
189 | The rule names `action_rule` and `new_year_only_rule` are resolved to `ActionRule` and `NewYearOnlyRule` class instances
190 | accordingly via rule factory.
191 |
192 | If you need to aggregate multiple rules at once, use composite rule:
193 |
194 | ```php
195 | use Yiisoft\Rbac\CompositeRule;
196 |
197 | // Fresh and owned
198 | $compositeRule = new CompositeRule(CompositeRule::AND, [FreshRule::class, OwnedRule::class]);
199 |
200 | // Fresh or owned
201 | $compositeRule = new CompositeRule(CompositeRule::OR, [FreshRule::class, OwnedRule::class]);
202 | ```
203 |
204 | ### Assigning roles to users
205 |
206 | To assign a certain role to a user with a given ID, use the following code:
207 |
208 | ```php
209 | use Yiisoft\Rbac\ManagerInterface;
210 |
211 | /** @var ManagerInterface $manager */
212 | $userId = 100;
213 | $manager->assign('author', $userId);
214 | ```
215 |
216 | It could be done in an admin panel, via console command, or it could be built into the application business logic
217 | itself.
218 |
219 | ### Check for permission
220 |
221 | To check for permission, obtain an instance of `Yiisoft\Access\AccessCheckerInterface` and use it:
222 |
223 | ```php
224 | use Psr\Http\Message\ResponseInterface;
225 | use Yiisoft\Access\AccessCheckerInterface;
226 |
227 | public function actionCreate(AccessCheckerInterface $accessChecker): ResponseInterface
228 | {
229 | $userId = getUserId();
230 |
231 | if ($accessChecker->userHasPermission($userId, 'createPost')) {
232 | // author has permission to create post
233 | }
234 | }
235 | ```
236 |
237 | Sometimes you need to add guest-only permission, which is not assigned to any user ID. In this case, you can specify a
238 | role which is assigned to guest user:
239 |
240 | ```php
241 | use Yiisoft\Access\AccessCheckerInterface;
242 | use Yiisoft\Rbac\Permission;
243 | use Yiisoft\Rbac\Role;
244 |
245 | /**
246 | * @var ManagerInterface $manager
247 | * @var AccessCheckerInterface $accessChecker
248 | */
249 | $manager->setGuestRoleName('guest');
250 | $manager->addPermission(new Permission('signup'));
251 | $manager->addRole(new Role('guest'));
252 | $manager->addChild('guest', 'signup');
253 |
254 | $guestId = null;
255 | if ($accessChecker->userHasPermission($guestId, 'signup')) {
256 | // Guest has "signup" permission.
257 | }
258 | ```
259 |
260 | If there is a rule involved, you may pass extra parameters:
261 |
262 | ```php
263 | use Yiisoft\Rbac\ManagerInterface;
264 |
265 | /** @var ManagerInterface $manager */
266 | $anotherUserId = 103;
267 | if (!$manager->userHasPermission($anotherUserId, 'viewList', ['action' => 'home'])) {
268 | echo 'reader hasn\'t "index" permission';
269 | }
270 | ```
271 |
272 | ## Documentation
273 |
274 | - [Internals](docs/internals.md)
275 |
276 | If you need help or have a question, the [Yii Forum](https://forum.yiiframework.com/c/yii-3-0/63) is a good place for that.
277 | You may also check out other [Yii Community Resources](https://www.yiiframework.com/community).
278 |
279 | ## License
280 |
281 | The Yii Role-Based Access Control is free software. It is released under the terms of the BSD License.
282 | Please see [`LICENSE`](./LICENSE.md) for more information.
283 |
284 | Maintained by [Yii Software](https://www.yiiframework.com/).
285 |
286 | ## Support the project
287 |
288 | [](https://opencollective.com/yiisoft)
289 |
290 | ## Follow updates
291 |
292 | [](https://www.yiiframework.com/)
293 | [](https://twitter.com/yiiframework)
294 | [](https://t.me/yii3en)
295 | [](https://www.facebook.com/groups/yiitalk)
296 | [](https://yiiframework.com/go/slack)
297 |
--------------------------------------------------------------------------------
/src/SimpleItemsStorage.php:
--------------------------------------------------------------------------------
1 |
30 | */
31 | protected array $children = [];
32 |
33 | public function getAll(): array
34 | {
35 | return $this->items;
36 | }
37 |
38 | public function getByNames(array $names): array
39 | {
40 | return array_filter(
41 | $this->getAll(),
42 | static fn(Item $item): bool => in_array($item->getName(), $names, strict: true),
43 | );
44 | }
45 |
46 | public function get(string $name): Permission|Role|null
47 | {
48 | return $this->items[$name] ?? null;
49 | }
50 |
51 | public function exists(string $name): bool
52 | {
53 | return array_key_exists($name, $this->items);
54 | }
55 |
56 | public function roleExists(string $name): bool
57 | {
58 | return isset($this->getItemsByType(Item::TYPE_ROLE)[$name]);
59 | }
60 |
61 | public function add(Permission|Role $item): void
62 | {
63 | $this->items[$item->getName()] = $item;
64 | }
65 |
66 | public function getRole(string $name): ?Role
67 | {
68 | return $this->getItemsByType(Item::TYPE_ROLE)[$name] ?? null;
69 | }
70 |
71 | public function getRoles(): array
72 | {
73 | return $this->getItemsByType(Item::TYPE_ROLE);
74 | }
75 |
76 | public function getRolesByNames(array $names): array
77 | {
78 | return array_filter(
79 | $this->getAll(),
80 | static function (Permission|Role $item) use ($names): bool {
81 | return $item instanceof Role && in_array($item->getName(), $names, strict: true);
82 | },
83 | );
84 | }
85 |
86 | public function getPermission(string $name): ?Permission
87 | {
88 | return $this->getItemsByType(Item::TYPE_PERMISSION)[$name] ?? null;
89 | }
90 |
91 | public function getPermissions(): array
92 | {
93 | return $this->getItemsByType(Item::TYPE_PERMISSION);
94 | }
95 |
96 | public function getPermissionsByNames(array $names): array
97 | {
98 | return array_filter(
99 | $this->getAll(),
100 | static function (Permission|Role $item) use ($names): bool {
101 | return $item instanceof Permission && in_array($item->getName(), $names, strict: true);
102 | },
103 | );
104 | }
105 |
106 | public function getParents(string $name): array
107 | {
108 | $result = [];
109 | $this->fillParentsRecursive($name, $result);
110 |
111 | return $result;
112 | }
113 |
114 | public function getHierarchy(string $name): array
115 | {
116 | if (!array_key_exists($name, $this->items)) {
117 | return [];
118 | }
119 |
120 | $result = [$name => ['item' => $this->items[$name], 'children' => []]];
121 | $this->fillHierarchyRecursive($name, $result);
122 |
123 | return $result;
124 | }
125 |
126 | public function getDirectChildren(string $name): array
127 | {
128 | return $this->children[$name] ?? [];
129 | }
130 |
131 | public function getAllChildren(string|array $names): array
132 | {
133 | $result = [];
134 | $this->getAllChildrenInternal($names, $result);
135 |
136 | return $result;
137 | }
138 |
139 | public function getAllChildRoles(string|array $names): array
140 | {
141 | $result = [];
142 | $this->getAllChildrenInternal($names, $result);
143 |
144 | return $this->filterRoles($result);
145 | }
146 |
147 | public function getAllChildPermissions(string|array $names): array
148 | {
149 | $result = [];
150 | $this->getAllChildrenInternal($names, $result);
151 |
152 | return $this->filterPermissions($result);
153 | }
154 |
155 | public function addChild(string $parentName, string $childName): void
156 | {
157 | $this->children[$parentName][$childName] = $this->items[$childName];
158 | }
159 |
160 | public function hasChildren(string $name): bool
161 | {
162 | return isset($this->children[$name]);
163 | }
164 |
165 | public function hasChild(string $parentName, string $childName): bool
166 | {
167 | if ($parentName === $childName) {
168 | return true;
169 | }
170 |
171 | $children = $this->getDirectChildren($parentName);
172 | if (empty($children)) {
173 | return false;
174 | }
175 |
176 | foreach ($children as $groupChild) {
177 | if ($this->hasChild($groupChild->getName(), $childName)) {
178 | return true;
179 | }
180 | }
181 |
182 | return false;
183 | }
184 |
185 | public function hasDirectChild(string $parentName, string $childName): bool
186 | {
187 | return isset($this->children[$parentName][$childName]);
188 | }
189 |
190 | public function removeChild(string $parentName, string $childName): void
191 | {
192 | unset($this->children[$parentName][$childName]);
193 | }
194 |
195 | public function removeChildren(string $parentName): void
196 | {
197 | unset($this->children[$parentName]);
198 | }
199 |
200 | public function remove(string $name): void
201 | {
202 | $this->clearChildrenFromItem($name);
203 | $this->removeItemByName($name);
204 | }
205 |
206 | public function update(string $name, Permission|Role $item): void
207 | {
208 | if ($item->getName() !== $name) {
209 | $this->updateItemName($name, $item);
210 | $this->removeItemByName($name);
211 | }
212 |
213 | $this->add($item);
214 | }
215 |
216 | public function clear(): void
217 | {
218 | $this->children = [];
219 | $this->items = [];
220 | }
221 |
222 | public function clearPermissions(): void
223 | {
224 | $this->removeItemsByType(Item::TYPE_PERMISSION);
225 | }
226 |
227 | public function clearRoles(): void
228 | {
229 | $this->removeItemsByType(Item::TYPE_ROLE);
230 | }
231 |
232 | private function updateItemName(string $name, Item $item): void
233 | {
234 | $this->updateChildrenForItemName($name, $item);
235 | }
236 |
237 | /**
238 | * @psalm-param Item::TYPE_* $type
239 | *
240 | * @psalm-return ($type is Item::TYPE_PERMISSION ? array : array)
241 | */
242 | private function getItemsByType(string $type): array
243 | {
244 | return array_filter(
245 | $this->getAll(),
246 | static fn(Permission|Role $item): bool => $item->getType() === $type,
247 | );
248 | }
249 |
250 | /**
251 | * @psalm-param Item::TYPE_* $type
252 | */
253 | private function removeItemsByType(string $type): void
254 | {
255 | foreach ($this->getItemsByType($type) as $item) {
256 | $this->remove($item->getName());
257 | }
258 | }
259 |
260 | private function clearChildrenFromItem(string $itemName): void
261 | {
262 | unset($this->children[$itemName]);
263 | }
264 |
265 | private function updateChildrenForItemName(string $name, Item $item): void
266 | {
267 | if ($this->hasChildren($name)) {
268 | $this->children[$item->getName()] = $this->children[$name];
269 | unset($this->children[$name]);
270 | }
271 |
272 | foreach ($this->children as &$children) {
273 | if (isset($children[$name])) {
274 | $children[$item->getName()] = $item;
275 | unset($children[$name]);
276 | }
277 | }
278 | }
279 |
280 | private function removeItemByName(string $name): void
281 | {
282 | unset($this->items[$name]);
283 |
284 | foreach ($this->children as &$children) {
285 | unset($children[$name]);
286 | }
287 | }
288 |
289 | /**
290 | * @psalm-param ItemsIndexedByName $result
291 | * @psalm-param-out ItemsIndexedByName $result
292 | */
293 | private function fillParentsRecursive(string $name, array &$result): void
294 | {
295 | foreach ($this->children as $parentName => $childItems) {
296 | foreach ($childItems as $childItem) {
297 | if ($childItem->getName() !== $name) {
298 | continue;
299 | }
300 |
301 | $parent = $this->get($parentName);
302 | if ($parent !== null) {
303 | /** @psalm-var ItemsIndexedByName $result Imported type in `psalm-param-out` is not resolved. */
304 | $result[$parentName] = $parent;
305 | }
306 |
307 | $this->fillParentsRecursive($parentName, $result);
308 | }
309 | }
310 | }
311 |
312 | /**
313 | * @psalm-param Hierarchy $result
314 | * @psalm-param-out Hierarchy $result
315 | *
316 | * @psalm-param ItemsIndexedByName $addedChildItems
317 | */
318 | private function fillHierarchyRecursive(string $name, array &$result, array $addedChildItems = []): void
319 | {
320 | foreach ($this->children as $parentName => $childItems) {
321 | foreach ($childItems as $childItem) {
322 | if ($childItem->getName() !== $name) {
323 | continue;
324 | }
325 |
326 | $parent = $this->get($parentName);
327 | if ($parent !== null) {
328 | /** @psalm-var Hierarchy $result Imported type in `psalm-param-out` is not resolved. */
329 | $result[$parentName]['item'] = $this->items[$parentName];
330 |
331 | $addedChildItems[$childItem->getName()] = $childItem;
332 | $result[$parentName]['children'] = $addedChildItems;
333 | }
334 |
335 | $this->fillHierarchyRecursive($parentName, $result, $addedChildItems);
336 | }
337 | }
338 | }
339 |
340 | /**
341 | * @param string|string[] $names
342 | *
343 | * @psalm-param ItemsIndexedByName $result
344 | * @psalm-param-out ItemsIndexedByName $result
345 | */
346 | private function getAllChildrenInternal(string|array $names, array &$result): void
347 | {
348 | $names = (array) $names;
349 | foreach ($names as $name) {
350 | $this->fillChildrenRecursive($name, $result, $names);
351 | }
352 | }
353 |
354 | /**
355 | * @psalm-param ItemsIndexedByName $result
356 | * @psalm-param-out ItemsIndexedByName $result
357 | */
358 | private function fillChildrenRecursive(string $name, array &$result, array $baseNames): void
359 | {
360 | $children = $this->children[$name] ?? [];
361 | foreach ($children as $childName => $_childItem) {
362 | if (in_array($childName, $baseNames, strict: true)) {
363 | continue;
364 | }
365 |
366 | $child = $this->get($childName);
367 | if ($child !== null) {
368 | /** @psalm-var ItemsIndexedByName $result Imported type in `psalm-param-out` is not resolved. */
369 | $result[$childName] = $child;
370 | }
371 |
372 | $this->fillChildrenRecursive($childName, $result, $baseNames);
373 | }
374 | }
375 |
376 | /**
377 | * @psalm-param ItemsIndexedByName $items
378 | *
379 | * @return Role[]
380 | * @psalm-return array
381 | */
382 | private function filterRoles(array $items): array
383 | {
384 | return array_filter($items, static fn(Permission|Role $item): bool => $item instanceof Role);
385 | }
386 |
387 | /**
388 | * @psalm-param ItemsIndexedByName $items
389 | *
390 | * @return Permission[]
391 | * @psalm-return array
392 | */
393 | private function filterPermissions(array $items): array
394 | {
395 | return array_filter($items, static fn(Permission|Role $item): bool => $item instanceof Permission);
396 | }
397 | }
398 |
--------------------------------------------------------------------------------
/tests/Common/AssignmentsStorageTestTrait.php:
--------------------------------------------------------------------------------
1 | populateItemsStorage();
24 | $this->populateAssignmentsStorage();
25 | }
26 |
27 | protected function tearDown(): void
28 | {
29 | $this->getItemsStorage()->clear();
30 | $this->getAssignmentsStorage()->clear();
31 | }
32 |
33 | public function testHasItem(): void
34 | {
35 | $storage = $this->getAssignmentsStorage();
36 |
37 | $this->assertTrue($storage->hasItem('Accountant'));
38 | }
39 |
40 | public function testRenameItem(): void
41 | {
42 | $testStorage = $this->getAssignmentsStorageForModificationAssertions();
43 | $actionStorage = $this->getAssignmentsStorage();
44 | $actionStorage->renameItem('Accountant', 'Senior accountant');
45 |
46 | $this->assertFalse($testStorage->hasItem('Accountant'));
47 | $this->assertTrue($testStorage->hasItem('Senior accountant'));
48 | }
49 |
50 | public function testRenameItemToSameName(): void
51 | {
52 | $testStorage = $this->getAssignmentsStorageForModificationAssertions();
53 | $actionStorage = $this->getAssignmentsStorage();
54 | $actionStorage->renameItem('Accountant', 'Accountant');
55 |
56 | $this->assertTrue($testStorage->hasItem('Accountant'));
57 | }
58 |
59 | public function testGetAll(): void
60 | {
61 | $storage = $this->getAssignmentsStorage();
62 | $all = $storage->getAll();
63 |
64 | $this->assertCount(3, $all);
65 | foreach ($all as $userId => $assignments) {
66 | foreach ($assignments as $name => $assignment) {
67 | $this->assertSame($userId, $assignment->getUserId());
68 | $this->assertSame($name, $assignment->getItemName());
69 | }
70 | }
71 | }
72 |
73 | public function testRemoveByItemName(): void
74 | {
75 | $testStorage = $this->getAssignmentsStorageForModificationAssertions();
76 | $actionStorage = $this->getAssignmentsStorage();
77 | $actionStorage->removeByItemName('Manager');
78 |
79 | $this->assertFalse($testStorage->hasItem('Manager'));
80 | $this->assertCount(2, $testStorage->getByUserId('jack'));
81 | $this->assertCount(3, $testStorage->getByUserId('john'));
82 | }
83 |
84 | public function testGetByUserId(): void
85 | {
86 | $storage = $this->getAssignmentsStorage();
87 | $assignments = $storage->getByUserId('john');
88 |
89 | $this->assertCount(3, $assignments);
90 |
91 | foreach ($assignments as $name => $assignment) {
92 | $this->assertSame($name, $assignment->getItemName());
93 | }
94 | }
95 |
96 | public static function dataGetByItemNames(): array
97 | {
98 | return [
99 | [[], []],
100 | [['Researcher'], [['Researcher', 'john']]],
101 | [['Researcher', 'Operator'], [['Researcher', 'john'], ['Operator', 'jack'], ['Operator', 'jeff']]],
102 | [['Researcher', 'jack'], [['Researcher', 'john']]],
103 | [['Researcher', 'non-existing'], [['Researcher', 'john']]],
104 | [['non-existing1', 'non-existing2'], []],
105 | ];
106 | }
107 |
108 | /**
109 | * @dataProvider dataGetByItemNames
110 | */
111 | public function testGetByItemNames(array $itemNames, array $expectedAssignments): void
112 | {
113 | $assignments = $this->getAssignmentsStorage()->getByItemNames($itemNames);
114 | $this->assertCount(count($expectedAssignments), $assignments);
115 |
116 | $assignmentFound = false;
117 | foreach ($assignments as $assignment) {
118 | foreach ($expectedAssignments as $expectedAssignment) {
119 | if (
120 | $assignment->getItemName() === $expectedAssignment[0]
121 | && $assignment->getUserId() === $expectedAssignment[1]
122 | ) {
123 | $assignmentFound = true;
124 | }
125 | }
126 | }
127 |
128 | if (!empty($expectedAssignments) && !$assignmentFound) {
129 | $this->fail('Assignment not found.');
130 | }
131 | }
132 |
133 | public function testRemoveByUserId(): void
134 | {
135 | $testStorage = $this->getAssignmentsStorageForModificationAssertions();
136 | $actionStorage = $this->getAssignmentsStorage();
137 | $actionStorage->removeByUserId('jack');
138 |
139 | $this->assertEmpty($testStorage->getByUserId('jack'));
140 | $this->assertNotEmpty($testStorage->getByUserId('john'));
141 | }
142 |
143 | public function testRemove(): void
144 | {
145 | $testStorage = $this->getAssignmentsStorageForModificationAssertions();
146 | $actionStorage = $this->getAssignmentsStorage();
147 | $actionStorage->remove(itemName: 'Accountant', userId: 'john');
148 |
149 | $this->assertEmpty($testStorage->get(itemName: 'Accountant', userId: 'john'));
150 | $this->assertNotEmpty($testStorage->getByUserId('john'));
151 | }
152 |
153 | public function testRemoveNonExisting(): void
154 | {
155 | $testStorage = $this->getAssignmentsStorageForModificationAssertions();
156 | $actionStorage = $this->getAssignmentsStorage();
157 | $count = count($actionStorage->getByUserId('john'));
158 | $actionStorage->remove(itemName: 'Operator', userId: 'john');
159 |
160 | $this->assertCount($count, $testStorage->getByUserId('john'));
161 | }
162 |
163 | public function testClear(): void
164 | {
165 | $testStorage = $this->getAssignmentsStorageForModificationAssertions();
166 | $actionStorage = $this->getAssignmentsStorage();
167 | $actionStorage->clear();
168 |
169 | $this->assertEmpty($testStorage->getAll());
170 | }
171 |
172 | public function testGet(): void
173 | {
174 | $storage = $this->getAssignmentsStorage();
175 | $assignment = $storage->get('Manager', 'jack');
176 |
177 | $this->assertNotNull($assignment);
178 | $this->assertSame('Manager', $assignment->getItemName());
179 | $this->assertSame('jack', $assignment->getUserId());
180 | $this->assertIsInt($assignment->getCreatedAt());
181 | }
182 |
183 | public function testGetNonExisting(): void
184 | {
185 | $this->assertNull($this->getAssignmentsStorage()->get('Researcher', 'jeff'));
186 | }
187 |
188 | public static function dataExists(): array
189 | {
190 | return [
191 | ['Manager', 'jack', true],
192 | ['jack', 'Manager', false],
193 | ['Manager', 'non-existing', false],
194 | ['non-existing', 'jack', false],
195 | ['non-existing1', 'non-existing2', false],
196 | ];
197 | }
198 |
199 | /**
200 | * @dataProvider dataExists
201 | */
202 | public function testExists(string $itemName, string $userId, bool $expectedExists): void
203 | {
204 | $this->assertSame($expectedExists, $this->getAssignmentsStorage()->exists($itemName, $userId));
205 | }
206 |
207 | public static function dataUserHasItem(): array
208 | {
209 | return [
210 | ['user without assignments', ['Researcher', 'Accountant'], false],
211 | ['john', ['Researcher', 'Accountant'], true],
212 | ['jeff', ['Researcher', 'Operator'], true],
213 | ['jeff', ['Researcher', 'non-existing'], false],
214 | ['jeff', ['non-existing', 'Operator'], true],
215 | ['jeff', ['non-existing1', 'non-existing2'], false],
216 | ['jeff', ['Researcher', 'Accountant'], false],
217 | ['jeff', [], false],
218 | ];
219 | }
220 |
221 | /**
222 | * @dataProvider dataUserHasItem
223 | */
224 | public function testUserHasItem(string $userId, array $itemNames, bool $expectedUserHasItem): void
225 | {
226 | $this->assertSame($expectedUserHasItem, $this->getAssignmentsStorage()->userHasItem($userId, $itemNames));
227 | }
228 |
229 | public static function dataFilterUserItemNames(): array
230 | {
231 | return [
232 | ['john', ['Researcher', 'Accountant'], ['Researcher', 'Accountant']],
233 | ['jeff', ['Researcher', 'Operator'], ['Operator']],
234 | ['jeff', ['Researcher', 'non-existing'], []],
235 | ['jeff', ['non-existing', 'Operator'], ['Operator']],
236 | ['jeff', ['non-existing1', 'non-existing2'], []],
237 | ['jeff', ['Researcher', 'Accountant'], []],
238 | ];
239 | }
240 |
241 | /**
242 | * @dataProvider dataFilterUserItemNames
243 | */
244 | public function testFilterUserItemNames(string $userId, array $itemNames, array $expectedUserItemNames): void
245 | {
246 | $this->assertEqualsCanonicalizing(
247 | $expectedUserItemNames,
248 | $this->getAssignmentsStorage()->filterUserItemNames($userId, $itemNames),
249 | );
250 | }
251 |
252 | public function testAddWithCurrentTimestamp(): void
253 | {
254 | $testStorage = $this->getAssignmentsStorageForModificationAssertions();
255 | $actionStorage = $this->getAssignmentsStorage();
256 | $actionStorage->add(new Assignment(userId: 'john', itemName: 'Operator', createdAt: 1_683_707_079));
257 |
258 | $this->assertEquals(
259 | new Assignment(userId: 'john', itemName: 'Operator', createdAt: 1_683_707_079),
260 | $testStorage->get(itemName: 'Operator', userId: 'john'),
261 | );
262 | }
263 |
264 | public function testAddWithPastTimestamp(): void
265 | {
266 | $testStorage = $this->getAssignmentsStorageForModificationAssertions();
267 | $actionStorage = $this->getAssignmentsStorage();
268 | $actionStorage->add(new Assignment(userId: 'john', itemName: 'Operator', createdAt: 1_694_508_008));
269 |
270 | $this->assertEquals(
271 | new Assignment(userId: 'john', itemName: 'Operator', createdAt: 1_694_508_008),
272 | $testStorage->get(itemName: 'Operator', userId: 'john'),
273 | );
274 | }
275 |
276 | protected function getFixtures(): array
277 | {
278 | $time = time();
279 | $items = [
280 | ['name' => 'Researcher', 'type' => Item::TYPE_ROLE],
281 | ['name' => 'Accountant', 'type' => Item::TYPE_ROLE],
282 | ['name' => 'Quality control specialist', 'type' => Item::TYPE_ROLE],
283 | ['name' => 'Operator', 'type' => Item::TYPE_ROLE],
284 | ['name' => 'Manager', 'type' => Item::TYPE_ROLE],
285 | ['name' => 'Support specialist', 'type' => Item::TYPE_ROLE],
286 | ['name' => 'Delete user', 'type' => Item::TYPE_PERMISSION],
287 | ];
288 | $items = array_map(
289 | static function (array $item) use ($time): array {
290 | $item['created_at'] = $time;
291 | $item['updated_at'] = $time;
292 |
293 | return $item;
294 | },
295 | $items,
296 | );
297 | $assignments = [
298 | ['item_name' => 'Researcher', 'user_id' => 'john'],
299 | ['item_name' => 'Accountant', 'user_id' => 'john'],
300 | ['item_name' => 'Quality control specialist', 'user_id' => 'john'],
301 | ['item_name' => 'Operator', 'user_id' => 'jack'],
302 | ['item_name' => 'Manager', 'user_id' => 'jack'],
303 | ['item_name' => 'Support specialist', 'user_id' => 'jack'],
304 | ['item_name' => 'Operator', 'user_id' => 'jeff'],
305 | ];
306 | $assignments = array_map(
307 | static function (array $item) use ($time): array {
308 | $item['created_at'] = $time;
309 |
310 | return $item;
311 | },
312 | $assignments,
313 | );
314 |
315 | return ['items' => $items, 'assignments' => $assignments];
316 | }
317 |
318 | protected function populateItemsStorage(): void
319 | {
320 | foreach ($this->getFixtures()['items'] as $itemData) {
321 | $name = $itemData['name'];
322 | $item = $itemData['type'] === Item::TYPE_PERMISSION ? new Permission($name) : new Role($name);
323 | $item = $item
324 | ->withCreatedAt($itemData['created_at'])
325 | ->withUpdatedAt($itemData['updated_at']);
326 | $this->getItemsStorage()->add($item);
327 | }
328 | }
329 |
330 | protected function populateAssignmentsStorage(): void
331 | {
332 | foreach ($this->getFixtures()['assignments'] as $assignmentData) {
333 | $this->getAssignmentsStorage()->add(
334 | new Assignment(
335 | userId: $assignmentData['user_id'],
336 | itemName: $assignmentData['item_name'],
337 | createdAt: time(),
338 | ),
339 | );
340 | }
341 | }
342 |
343 | protected function getItemsStorage(): ItemsStorageInterface
344 | {
345 | if ($this->itemsStorage === null) {
346 | $this->itemsStorage = $this->createItemsStorage();
347 | }
348 |
349 | return $this->itemsStorage;
350 | }
351 |
352 | protected function getAssignmentsStorage(): AssignmentsStorageInterface
353 | {
354 | if ($this->assignmentsStorage === null) {
355 | $this->assignmentsStorage = $this->createAssignmentsStorage();
356 | }
357 |
358 | return $this->assignmentsStorage;
359 | }
360 |
361 | protected function createItemsStorage(): ItemsStorageInterface
362 | {
363 | return new FakeItemsStorage();
364 | }
365 |
366 | protected function createAssignmentsStorage(): AssignmentsStorageInterface
367 | {
368 | return new FakeAssignmentsStorage();
369 | }
370 |
371 | protected function getAssignmentsStorageForModificationAssertions(): AssignmentsStorageInterface
372 | {
373 | return $this->getAssignmentsStorage();
374 | }
375 | }
376 |
--------------------------------------------------------------------------------
/src/Manager.php:
--------------------------------------------------------------------------------
1 | ruleFactory = $ruleFactory ?? new SimpleRuleFactory();
51 | }
52 |
53 | public function userHasPermission(
54 | int|string|Stringable|null $userId,
55 | string $permissionName,
56 | array $parameters = [],
57 | ): bool {
58 | $item = $this->itemsStorage->get($permissionName);
59 | if ($item === null) {
60 | return false;
61 | }
62 |
63 | if (!$this->includeRolesInAccessChecks && $item->getType() === Item::TYPE_ROLE) {
64 | return false;
65 | }
66 |
67 | if ($userId !== null) {
68 | $guestRole = null;
69 | } else {
70 | $guestRole = $this->getGuestRole();
71 | if ($guestRole === null) {
72 | return false;
73 | }
74 | }
75 |
76 | $hierarchy = $this->itemsStorage->getHierarchy($item->getName());
77 | $itemNames = array_map(static fn(array $treeItem): string => $treeItem['item']->getName(), $hierarchy);
78 | $userItemNames = $guestRole !== null
79 | ? [$guestRole->getName()]
80 | : $this->filterUserItemNames((string) $userId, $itemNames);
81 | $userItemNamesMap = [];
82 | foreach ($userItemNames as $userItemName) {
83 | $userItemNamesMap[$userItemName] = null;
84 | }
85 |
86 | foreach ($hierarchy as $data) {
87 | if (
88 | !array_key_exists($data['item']->getName(), $userItemNamesMap)
89 | || !$this->executeRule($userId === null ? $userId : (string) $userId, $data['item'], $parameters)
90 | ) {
91 | continue;
92 | }
93 |
94 | $hasPermission = true;
95 | foreach ($data['children'] as $childItem) {
96 | if (!$this->executeRule($userId === null ? $userId : (string) $userId, $childItem, $parameters)) {
97 | $hasPermission = false;
98 |
99 | /**
100 | * @infection-ignore-all Break_
101 | * Replacing with `continue` works as well, but there is no point in further checks, because at
102 | * least one failed rule execution means access is not granted via current iterated hierarchy
103 | * branch.
104 | */
105 | break;
106 | }
107 | }
108 |
109 | if ($hasPermission) {
110 | return true;
111 | }
112 | }
113 |
114 | return false;
115 | }
116 |
117 | public function canAddChild(string $parentName, string $childName): bool
118 | {
119 | try {
120 | $this->assertFutureChild($parentName, $childName);
121 | } catch (RuntimeException) {
122 | return false;
123 | }
124 |
125 | return true;
126 | }
127 |
128 | public function addChild(string $parentName, string $childName): self
129 | {
130 | $this->assertFutureChild($parentName, $childName);
131 | $this->itemsStorage->addChild($parentName, $childName);
132 |
133 | return $this;
134 | }
135 |
136 | public function removeChild(string $parentName, string $childName): self
137 | {
138 | $this->itemsStorage->removeChild($parentName, $childName);
139 |
140 | return $this;
141 | }
142 |
143 | public function removeChildren(string $parentName): self
144 | {
145 | $this->itemsStorage->removeChildren($parentName);
146 |
147 | return $this;
148 | }
149 |
150 | public function hasChild(string $parentName, string $childName): bool
151 | {
152 | return $this->itemsStorage->hasDirectChild($parentName, $childName);
153 | }
154 |
155 | public function hasChildren(string $parentName): bool
156 | {
157 | return $this->itemsStorage->hasChildren($parentName);
158 | }
159 |
160 | public function assign(string $itemName, int|Stringable|string $userId, ?int $createdAt = null): self
161 | {
162 | $userId = (string) $userId;
163 |
164 | $item = $this->itemsStorage->get($itemName);
165 | if ($item === null) {
166 | throw new InvalidArgumentException("There is no item named \"$itemName\".");
167 | }
168 |
169 | if (!$this->enableDirectPermissions && $item->getType() === Item::TYPE_PERMISSION) {
170 | throw new InvalidArgumentException(
171 | 'Assigning permissions directly is disabled. Prefer assigning roles only.',
172 | );
173 | }
174 |
175 | if ($this->assignmentsStorage->exists($itemName, $userId)) {
176 | return $this;
177 | }
178 |
179 | $assignment = new Assignment($userId, $itemName, $createdAt ?? $this->getCurrentTimestamp());
180 | $this->assignmentsStorage->add($assignment);
181 |
182 | return $this;
183 | }
184 |
185 | public function revoke(string $itemName, int|Stringable|string $userId): self
186 | {
187 | $this->assignmentsStorage->remove($itemName, (string) $userId);
188 |
189 | return $this;
190 | }
191 |
192 | public function revokeAll(int|Stringable|string $userId): self
193 | {
194 | $this->assignmentsStorage->removeByUserId((string) $userId);
195 |
196 | return $this;
197 | }
198 |
199 | public function getItemsByUserId(int|Stringable|string $userId): array
200 | {
201 | $userId = (string) $userId;
202 | $assignments = $this->assignmentsStorage->getByUserId($userId);
203 | $assignmentNames = array_keys($assignments);
204 |
205 | return array_merge(
206 | $this->getDefaultRoles(),
207 | $this->itemsStorage->getByNames($assignmentNames),
208 | $this->itemsStorage->getAllChildren($assignmentNames),
209 | );
210 | }
211 |
212 | public function getRolesByUserId(int|Stringable|string $userId): array
213 | {
214 | $userId = (string) $userId;
215 | $assignments = $this->assignmentsStorage->getByUserId($userId);
216 | $assignmentNames = array_keys($assignments);
217 |
218 | return array_merge(
219 | $this->getDefaultRoles(),
220 | $this->itemsStorage->getRolesByNames($assignmentNames),
221 | $this->itemsStorage->getAllChildRoles($assignmentNames),
222 | );
223 | }
224 |
225 | public function getChildRoles(string $roleName): array
226 | {
227 | if (!$this->itemsStorage->roleExists($roleName)) {
228 | throw new InvalidArgumentException("Role \"$roleName\" not found.");
229 | }
230 |
231 | return $this->itemsStorage->getAllChildRoles($roleName);
232 | }
233 |
234 | public function getPermissionsByRoleName(string $roleName): array
235 | {
236 | return $this->itemsStorage->getAllChildPermissions($roleName);
237 | }
238 |
239 | public function getPermissionsByUserId(int|Stringable|string $userId): array
240 | {
241 | $userId = (string) $userId;
242 | $assignments = $this->assignmentsStorage->getByUserId($userId);
243 | if (empty($assignments)) {
244 | return [];
245 | }
246 |
247 | $assignmentNames = array_keys($assignments);
248 |
249 | return array_merge(
250 | $this->itemsStorage->getPermissionsByNames($assignmentNames),
251 | $this->itemsStorage->getAllChildPermissions($assignmentNames),
252 | );
253 | }
254 |
255 | public function getUserIdsByRoleName(string $roleName): array
256 | {
257 | $roleNames = [$roleName, ...array_keys($this->itemsStorage->getParents($roleName))];
258 |
259 | return array_map(
260 | static fn(Assignment $assignment): string => $assignment->getUserId(),
261 | $this->assignmentsStorage->getByItemNames($roleNames),
262 | );
263 | }
264 |
265 | public function addRole(Role $role): self
266 | {
267 | $this->addItem($role);
268 | return $this;
269 | }
270 |
271 | public function getRole(string $name): ?Role
272 | {
273 | return $this->itemsStorage->getRole($name);
274 | }
275 |
276 | public function updateRole(string $name, Role $role): self
277 | {
278 | $this->assertItemNameForUpdate($role, $name);
279 |
280 | $this->itemsStorage->update($name, $role);
281 | $this->assignmentsStorage->renameItem($name, $role->getName());
282 |
283 | return $this;
284 | }
285 |
286 | public function removeRole(string $name): self
287 | {
288 | $this->removeItem($name);
289 | return $this;
290 | }
291 |
292 | public function addPermission(Permission $permission): self
293 | {
294 | $this->addItem($permission);
295 | return $this;
296 | }
297 |
298 | public function getPermission(string $name): ?Permission
299 | {
300 | return $this->itemsStorage->getPermission($name);
301 | }
302 |
303 | public function updatePermission(string $name, Permission $permission): self
304 | {
305 | $this->assertItemNameForUpdate($permission, $name);
306 |
307 | $this->itemsStorage->update($name, $permission);
308 | $this->assignmentsStorage->renameItem($name, $permission->getName());
309 |
310 | return $this;
311 | }
312 |
313 | public function removePermission(string $name): self
314 | {
315 | $this->removeItem($name);
316 | return $this;
317 | }
318 |
319 | public function setDefaultRoleNames(array|Closure $roleNames): self
320 | {
321 | $this->defaultRoleNames = $this->getDefaultRoleNamesForUpdate($roleNames);
322 |
323 | return $this;
324 | }
325 |
326 | public function getDefaultRoleNames(): array
327 | {
328 | return $this->defaultRoleNames;
329 | }
330 |
331 | public function getDefaultRoles(): array
332 | {
333 | return $this->filterStoredRoles($this->defaultRoleNames);
334 | }
335 |
336 | public function setGuestRoleName(?string $name): self
337 | {
338 | $this->guestRoleName = $name;
339 |
340 | return $this;
341 | }
342 |
343 | public function getGuestRoleName(): ?string
344 | {
345 | return $this->guestRoleName;
346 | }
347 |
348 | public function getGuestRole(): ?Role
349 | {
350 | if ($this->guestRoleName === null) {
351 | return null;
352 | }
353 |
354 | $role = $this->getRole($this->guestRoleName);
355 | if ($role === null) {
356 | throw new RuntimeException("Guest role with name \"$this->guestRoleName\" does not exist.");
357 | }
358 |
359 | return $role;
360 | }
361 |
362 | /**
363 | * Executes the rule associated with the specified role or permission.
364 | *
365 | * If the item does not specify a rule, this method will return `true`. Otherwise, it will
366 | * return the value of {@see RuleInterface::execute()}.
367 | *
368 | * @param string|null $userId The user ID. This should be a string representing the unique identifier of a user. For
369 | * guests the value is `null`.
370 | * @param Item $item The role or the permission that needs to execute its rule.
371 | * @param array $params Parameters passed to {@see AccessCheckerInterface::userHasPermission()} and will be passed
372 | * to the rule.
373 | *
374 | * @throws RuntimeException If the role or the permission has an invalid rule.
375 | * @return bool The return value of {@see RuleInterface::execute()}. If the role or the permission does not specify
376 | * a rule, `true` will be returned.
377 | */
378 | private function executeRule(?string $userId, Item $item, array $params): bool
379 | {
380 | if ($item->getRuleName() === null) {
381 | return true;
382 | }
383 |
384 | return $this->ruleFactory
385 | ->create($item->getRuleName())
386 | ->execute($userId, $item, new RuleContext($this->ruleFactory, $params));
387 | }
388 |
389 | /**
390 | * @throws ItemAlreadyExistsException
391 | */
392 | private function addItem(Permission|Role $item): void
393 | {
394 | if ($this->itemsStorage->exists($item->getName())) {
395 | throw new ItemAlreadyExistsException($item);
396 | }
397 |
398 | $time = $this->getCurrentTimestamp();
399 | if (!$item->hasCreatedAt()) {
400 | $item = $item->withCreatedAt($time);
401 | }
402 | if (!$item->hasUpdatedAt()) {
403 | $item = $item->withUpdatedAt($time);
404 | }
405 |
406 | $this->itemsStorage->add($item);
407 | }
408 |
409 | private function removeItem(string $name): void
410 | {
411 | if ($this->itemsStorage->exists($name)) {
412 | $this->itemsStorage->remove($name);
413 | $this->assignmentsStorage->removeByItemName($name);
414 | }
415 | }
416 |
417 | /**
418 | * @throws RuntimeException
419 | */
420 | private function assertFutureChild(string $parentName, string $childName): void
421 | {
422 | if ($parentName === $childName) {
423 | throw new RuntimeException("Cannot add \"$parentName\" as a child of itself.");
424 | }
425 |
426 | $parent = $this->itemsStorage->get($parentName);
427 | if ($parent === null) {
428 | throw new RuntimeException("Parent \"$parentName\" does not exist.");
429 | }
430 |
431 | $child = $this->itemsStorage->get($childName);
432 | if ($child === null) {
433 | throw new RuntimeException("Child \"$childName\" does not exist.");
434 | }
435 |
436 | if ($parent instanceof Permission && $child instanceof Role) {
437 | throw new RuntimeException(
438 | "Can not add \"$childName\" role as a child of \"$parentName\" permission.",
439 | );
440 | }
441 |
442 | if ($this->itemsStorage->hasDirectChild($parentName, $childName)) {
443 | throw new RuntimeException("The item \"$parentName\" already has a child \"$childName\".");
444 | }
445 |
446 | if ($this->itemsStorage->hasChild($childName, $parentName)) {
447 | throw new RuntimeException(
448 | "Cannot add \"$childName\" as a child of \"$parentName\". A loop has been detected.",
449 | );
450 | }
451 | }
452 |
453 | private function assertItemNameForUpdate(Item $item, string $name): void
454 | {
455 | if ($item->getName() === $name || !$this->itemsStorage->exists($item->getName())) {
456 | return;
457 | }
458 |
459 | throw new InvalidArgumentException(
460 | 'Unable to change the role or the permission name. '
461 | . "The name \"{$item->getName()}\" is already used by another role or permission.",
462 | );
463 | }
464 |
465 | /**
466 | * @throws InvalidArgumentException
467 | * @return string[]
468 | */
469 | private function getDefaultRoleNamesForUpdate(array|Closure $roleNames): array
470 | {
471 | if (is_array($roleNames)) {
472 | $this->assertDefaultRoleNamesListForUpdate($roleNames);
473 |
474 | return $roleNames;
475 | }
476 |
477 | $roleNames = $roleNames();
478 | if (!is_array($roleNames)) {
479 | throw new InvalidArgumentException('Default role names closure must return an array.');
480 | }
481 |
482 | $this->assertDefaultRoleNamesListForUpdate($roleNames);
483 |
484 | return $roleNames;
485 | }
486 |
487 | /**
488 | * @psalm-assert string[] $roleNames
489 | *
490 | * @throws InvalidArgumentException
491 | */
492 | private function assertDefaultRoleNamesListForUpdate(array $roleNames): void
493 | {
494 | foreach ($roleNames as $roleName) {
495 | if (!is_string($roleName)) {
496 | throw new InvalidArgumentException('Each role name must be a string.');
497 | }
498 | }
499 | }
500 |
501 | /**
502 | * @param string[] $roleNames
503 | *
504 | * @throws DefaultRolesNotFoundException
505 | * @return array
506 | */
507 | private function filterStoredRoles(array $roleNames): array
508 | {
509 | $storedRoles = $this->itemsStorage->getRolesByNames($roleNames);
510 | $missingRoles = array_diff($roleNames, array_keys($storedRoles));
511 | if (!empty($missingRoles)) {
512 | $missingRolesStr = '"' . implode('", "', $missingRoles) . '"';
513 |
514 | throw new DefaultRolesNotFoundException("The following default roles were not found: $missingRolesStr.");
515 | }
516 |
517 | return $storedRoles;
518 | }
519 |
520 | /**
521 | * Filters item names leaving only the ones that are assigned to specific user or assigned by default.
522 | *
523 | * @param string $userId User id.
524 | * @param string[] $itemNames List of item names.
525 | *
526 | * @return string[] Filtered item names.
527 | */
528 | private function filterUserItemNames(string $userId, array $itemNames): array
529 | {
530 | return array_merge(
531 | $this->assignmentsStorage->filterUserItemNames($userId, $itemNames),
532 | $this->defaultRoleNames,
533 | );
534 | }
535 |
536 | private function getCurrentTimestamp(): int
537 | {
538 | return $this->clock === null
539 | ? time()
540 | : $this->clock->now()->getTimestamp();
541 | }
542 | }
543 |
--------------------------------------------------------------------------------
/tests/Support/hierarchy.graphml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 | AuthorRule
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 | Fast Metabolism
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 | createPost
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 | readPost
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 | deletePost
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 | updatePost
79 |
80 |
81 |
82 |
83 |
84 |
85 |
86 |
87 |
88 |
89 | updateAnyPost
90 |
91 |
92 |
93 |
94 |
95 |
96 |
97 |
98 |
99 |
100 | withoutChildren
101 |
102 |
103 |
104 |
105 |
106 |
107 |
108 |
109 |
110 |
111 | reader
112 |
113 |
114 |
115 |
116 |
117 |
118 |
119 |
120 |
121 |
122 | author
123 |
124 |
125 |
126 |
127 |
128 |
129 |
130 |
131 |
132 |
133 | admin
134 |
135 |
136 |
137 |
138 |
139 |
140 |
141 |
142 |
143 |
144 | reader A
145 |
146 |
147 |
148 |
149 |
150 |
151 |
152 |
153 |
154 |
155 | author B
156 |
157 |
158 |
159 |
160 |
161 |
162 |
163 |
164 |
165 |
166 | admin C
167 |
168 |
169 |
170 |
171 |
172 |
173 |
174 |
175 |
176 |
177 | publishPost
178 |
179 |
180 |
181 |
182 |
183 |
184 |
185 |
186 |
187 |
188 |
189 |
190 |
191 |
192 |
193 |
194 |
195 |
196 |
197 |
198 |
199 |
200 |
201 |
202 |
203 |
204 |
205 |
206 |
207 |
208 |
209 |
210 |
211 |
212 |
213 |
214 |
215 |
216 |
217 |
218 |
219 |
220 |
221 |
222 |
223 |
224 |
225 |
226 |
227 |
228 |
229 |
230 |
231 |
232 |
233 |
234 |
235 |
236 |
237 |
238 |
239 |
240 |
241 |
242 |
243 |
244 |
245 |
246 |
247 |
248 |
249 |
250 |
251 |
252 |
253 |
254 |
255 |
256 |
257 |
258 |
259 |
260 |
261 |
262 |
263 |
264 |
265 |
266 |
267 |
268 |
269 |
270 |
271 |
272 |
273 |
274 |
275 |
276 |
277 |
278 |
279 |
280 |
281 |
282 |
283 |
284 |
285 |
286 |
287 |
288 |
289 |
290 |
291 |
292 |
293 |
294 |
295 |
296 |
297 |
298 |
299 |
300 |
301 |
302 |
303 |
304 |
305 |
306 |
307 |
308 |
309 |
310 |
311 |
312 |
313 |
314 |
315 |
316 |
317 |
318 |
319 |
--------------------------------------------------------------------------------
/tests/Common/ItemsStorageTestTrait.php:
--------------------------------------------------------------------------------
1 | populateItemsStorage();
29 | }
30 |
31 | protected function tearDown(): void
32 | {
33 | $this->getItemsStorage()->clear();
34 | }
35 |
36 | public static function dataUpdate(): array
37 | {
38 | return [
39 | 'present as parent in items children' => ['Parent 1', 'Super Admin', true],
40 | 'no children' => ['Parent 3', 'Super Admin', false],
41 | 'present as child in items children' => ['Child 1', 'Parent 1', true],
42 | ];
43 | }
44 |
45 | /**
46 | * @dataProvider dataUpdate
47 | */
48 | public function testUpdate(string $itemName, string $parentNameForChildrenCheck, bool $expectedHasChildren): void
49 | {
50 | $testStorage = $this->getItemsStorageForModificationAssertions();
51 | $actionStorage = $this->getItemsStorage();
52 |
53 | $item = $actionStorage->get($itemName);
54 | $this->assertNull($item->getRuleName());
55 |
56 | $item = $item
57 | ->withName('Super Admin')
58 | ->withRuleName(TrueRule::class);
59 | $actionStorage->update($itemName, $item);
60 |
61 | $this->assertNull($testStorage->get($itemName));
62 |
63 | $item = $testStorage->get('Super Admin');
64 | $this->assertNotNull($item);
65 |
66 | $this->assertSame('Super Admin', $item->getName());
67 | $this->assertSame(TrueRule::class, $item->getRuleName());
68 |
69 | $this->assertSame($expectedHasChildren, $testStorage->hasChildren($parentNameForChildrenCheck));
70 | }
71 |
72 | public function testGet(): void
73 | {
74 | $storage = $this->getItemsStorage();
75 | $item = $storage->get('Parent 3');
76 |
77 | $this->assertInstanceOf(Permission::class, $item);
78 | $this->assertSame(Item::TYPE_PERMISSION, $item->getType());
79 | $this->assertSame('Parent 3', $item->getName());
80 | }
81 |
82 | public function testGetWithNonExistingName(): void
83 | {
84 | $storage = $this->getItemsStorage();
85 | $this->assertNull($storage->get('Non-existing name'));
86 | }
87 |
88 | public static function dataExists(): array
89 | {
90 | return [
91 | ['Parent 1', true],
92 | ['Parent 2', true],
93 | ['Parent 3', true],
94 | ['Parent 100', false],
95 | ['Child 1', true],
96 | ['Child 2', true],
97 | ['Child 100', false],
98 | ];
99 | }
100 |
101 | /**
102 | * @dataProvider dataExists
103 | */
104 | public function testExists(string $name, bool $expectedExists): void
105 | {
106 | $storage = $this->getItemsStorage();
107 | $this->assertSame($expectedExists, $storage->exists($name));
108 | }
109 |
110 | public static function dataRoleExists(): array
111 | {
112 | return [
113 | ['posts.viewer', true],
114 | ['posts.view', false],
115 | ['non-existing', false],
116 | ];
117 | }
118 |
119 | /**
120 | * @dataProvider dataRoleExists
121 | */
122 | public function testRoleExists(string $name, bool $expectedRoleExists): void
123 | {
124 | $this->assertSame($expectedRoleExists, $this->getItemsStorage()->roleExists($name));
125 | }
126 |
127 | public function testGetPermission(): void
128 | {
129 | $storage = $this->getItemsStorage();
130 | $permission = $storage->getPermission('Child 1');
131 |
132 | $this->assertInstanceOf(Permission::class, $permission);
133 | $this->assertSame('Child 1', $permission->getName());
134 | }
135 |
136 | public function testAddChild(): void
137 | {
138 | $testStorage = $this->getItemsStorageForModificationAssertions();
139 | $actionStorage = $this->getItemsStorage();
140 | $actionStorage->addChild('Parent 2', 'Child 1');
141 |
142 | $children = $testStorage->getAllChildren('Parent 2');
143 | $this->assertCount(3, $children);
144 |
145 | foreach ($children as $name => $item) {
146 | $this->assertSame($name, $item->getName());
147 | }
148 | }
149 |
150 | public function testClear(): void
151 | {
152 | $testStorage = $this->getItemsStorageForModificationAssertions();
153 | $actionStorage = $this->getItemsStorage();
154 | $actionStorage->clear();
155 |
156 | $this->assertEmpty($testStorage->getAll());
157 | }
158 |
159 | public static function dataGetDirectChildren(): array
160 | {
161 | return [
162 | ['Parent 1', ['Child 1']],
163 | ['Parent 2', ['Child 2', 'Child 3']],
164 | ['posts.view', []],
165 | ['posts.create', []],
166 | ['posts.update', []],
167 | ['posts.delete', []],
168 | ['posts.viewer', ['posts.view']],
169 | ['posts.redactor', ['posts.viewer', 'posts.create', 'posts.update']],
170 | ['posts.admin', ['posts.redactor', 'posts.delete']],
171 | ['non-existing', []],
172 | ];
173 | }
174 |
175 | /**
176 | * @dataProvider dataGetDirectChildren
177 | */
178 | public function testGetDirectChildren(string $parentName, array $expectedChildren): void
179 | {
180 | $children = $this->getItemsStorage()->getDirectChildren($parentName);
181 | $this->assertChildren($children, $expectedChildren);
182 | }
183 |
184 | public static function dataGetAllChildren(): array
185 | {
186 | return [
187 | ['Parent 1', ['Child 1']],
188 | ['Parent 2', ['Child 2', 'Child 3']],
189 | ['posts.view', []],
190 | ['posts.create', []],
191 | ['posts.update', []],
192 | ['posts.delete', []],
193 | ['posts.viewer', ['posts.view']],
194 | ['posts.redactor', ['posts.viewer', 'posts.view', 'posts.create', 'posts.update']],
195 | [
196 | 'posts.admin',
197 | ['posts.redactor', 'posts.viewer', 'posts.view', 'posts.create', 'posts.update', 'posts.delete'],
198 | ],
199 | [['Parent 1', 'Parent 2'], ['Child 1', 'Child 2', 'Child 3']],
200 | [
201 | ['posts.viewer', 'posts.redactor', 'posts.admin'],
202 | ['posts.view', 'posts.create', 'posts.update', 'posts.delete'],
203 | ],
204 | [[], []],
205 | ['non-existing', []],
206 | ];
207 | }
208 |
209 | /**
210 | * @dataProvider dataGetAllChildren
211 | */
212 | public function testGetAllChildren(string|array $parentNames, array $expectedChildren): void
213 | {
214 | $children = $this->getItemsStorage()->getAllChildren($parentNames);
215 | $this->assertChildren($children, $expectedChildren);
216 | }
217 |
218 | public static function dataGetAllChildPermissions(): array
219 | {
220 | return [
221 | ['Parent 1', ['Child 1']],
222 | ['Parent 2', []],
223 | ['posts.view', []],
224 | ['posts.create', []],
225 | ['posts.update', []],
226 | ['posts.delete', []],
227 | ['posts.viewer', ['posts.view']],
228 | ['posts.redactor', ['posts.view', 'posts.create', 'posts.update']],
229 | ['posts.admin', ['posts.view', 'posts.create', 'posts.update', 'posts.delete']],
230 | [['Parent 1', 'Parent 5'], ['Child 1', 'Child 5']],
231 | [
232 | ['posts.viewer', 'posts.redactor', 'posts.admin'],
233 | ['posts.view', 'posts.create', 'posts.update', 'posts.delete'],
234 | ],
235 | [[], []],
236 | ['non-existing', []],
237 | ];
238 | }
239 |
240 | /**
241 | * @dataProvider dataGetAllChildPermissions
242 | */
243 | public function testGetAllChildPermissions(string|array $parentNames, array $expectedChildren): void
244 | {
245 | $children = $this->getItemsStorage()->getAllChildPermissions($parentNames);
246 | $this->assertChildren($children, $expectedChildren);
247 | }
248 |
249 | public static function dataGetAllChildRoles(): array
250 | {
251 | return [
252 | ['Parent 1', []],
253 | ['Parent 2', ['Child 2', 'Child 3']],
254 | ['posts.view', []],
255 | ['posts.create', []],
256 | ['posts.update', []],
257 | ['posts.delete', []],
258 | ['posts.viewer', []],
259 | ['posts.redactor', ['posts.viewer']],
260 | ['posts.admin', ['posts.redactor', 'posts.viewer']],
261 | [['Parent 2', 'Parent 4'], ['Child 2', 'Child 3', 'Child 4']],
262 | [['posts.viewer', 'posts.redactor', 'posts.admin'], []],
263 | [[], []],
264 | ['non-existing', []],
265 | ];
266 | }
267 |
268 | /**
269 | * @dataProvider dataGetAllChildRoles
270 | */
271 | public function testGetAllChildRoles(string|array $parentNames, array $expectedChildren): void
272 | {
273 | $children = $this->getItemsStorage()->getAllChildRoles($parentNames);
274 | $this->assertChildren($children, $expectedChildren);
275 | }
276 |
277 | public function testGetRoles(): void
278 | {
279 | $storage = $this->getItemsStorage();
280 | $roles = $storage->getRoles();
281 |
282 | $this->assertCount($this->initialRolesCount, $roles);
283 | $this->assertContainsOnlyInstancesOf(Role::class, $roles);
284 | }
285 |
286 | public static function dataGetRolesByNames(): array
287 | {
288 | return [
289 | [[], []],
290 | [['posts.viewer'], ['posts.viewer']],
291 | [['posts.viewer', 'posts.redactor'], ['posts.viewer', 'posts.redactor']],
292 | [['posts.viewer', 'posts.view'], ['posts.viewer']],
293 | [['posts.viewer', 'non-existing'], ['posts.viewer']],
294 | [['non-existing1', 'non-existing2'], []],
295 | ];
296 | }
297 |
298 | /**
299 | * @dataProvider dataGetRolesByNames
300 | */
301 | public function testGetRolesByNames(array $names, array $expectedRoleNames): void
302 | {
303 | $roles = $this->getItemsStorage()->getRolesByNames($names);
304 |
305 | $this->assertCount(count($expectedRoleNames), $roles);
306 | foreach ($roles as $roleName => $role) {
307 | $this->assertContains($roleName, $expectedRoleNames);
308 | $this->assertSame($roleName, $role->getName());
309 | }
310 | }
311 |
312 | public function testGetPermissions(): void
313 | {
314 | $storage = $this->getItemsStorage();
315 | $permissions = $storage->getPermissions();
316 |
317 | $this->assertCount($this->initialPermissionsCount, $permissions);
318 | $this->assertContainsOnlyInstancesOf(Permission::class, $permissions);
319 | }
320 |
321 | public static function dataGetPermissionsByNames(): array
322 | {
323 | return [
324 | [[], []],
325 | [['posts.view'], ['posts.view']],
326 | [['posts.create', 'posts.update'], ['posts.create', 'posts.update']],
327 | [['posts.create', 'posts.redactor'], ['posts.create']],
328 | [['posts.create', 'non-existing'], ['posts.create']],
329 | [['non-existing1', 'non-existing2'], []],
330 | ];
331 | }
332 |
333 | /**
334 | * @dataProvider dataGetPermissionsByNames
335 | */
336 | public function testGetPermissionsByNames(array $names, array $expectedPermissionNames): void
337 | {
338 | $permissions = $this->getItemsStorage()->getPermissionsByNames($names);
339 |
340 | $this->assertCount(count($expectedPermissionNames), $permissions);
341 | foreach ($permissions as $permissionName => $permission) {
342 | $this->assertContains($permissionName, $expectedPermissionNames);
343 | $this->assertSame($permissionName, $permission->getName());
344 | }
345 | }
346 |
347 | public static function dataRemove(): array
348 | {
349 | return [
350 | ['Parent 2'],
351 | ['non-existing'],
352 | ];
353 | }
354 |
355 | /**
356 | * @dataProvider dataRemove
357 | */
358 | public function testRemove(string $name): void
359 | {
360 | $testStorage = $this->getItemsStorageForModificationAssertions();
361 | $actionStorage = $this->getItemsStorage();
362 | $actionStorage->remove($name);
363 |
364 | $this->assertNull($testStorage->get($name));
365 | $this->assertNotEmpty($testStorage->getAll());
366 | $this->assertFalse($testStorage->hasChildren($name));
367 | }
368 |
369 | public static function dataGetParents(): array
370 | {
371 | return [
372 | ['Child 1', ['Parent 1']],
373 | ['Child 2', ['Parent 2']],
374 | ['posts.view', ['posts.admin', 'posts.redactor', 'posts.viewer']],
375 | ['posts.create', ['posts.admin', 'posts.redactor']],
376 | ['posts.update', ['posts.admin', 'posts.redactor']],
377 | ['posts.delete', ['posts.admin']],
378 | ['posts.viewer', ['posts.admin', 'posts.redactor']],
379 | ['posts.redactor', ['posts.admin']],
380 | ['posts.admin', []],
381 | ['non-existing', []],
382 | ];
383 | }
384 |
385 | /**
386 | * @dataProvider dataGetParents
387 | */
388 | public function testGetParents(string $childName, array $expectedParents): void
389 | {
390 | $storage = $this->getItemsStorage();
391 | $parents = $storage->getParents($childName);
392 |
393 | $this->assertCount(count($expectedParents), $parents);
394 | foreach ($parents as $parentName => $parent) {
395 | $this->assertContains($parentName, $expectedParents);
396 | $this->assertSame($parentName, $parent->getName());
397 | }
398 | }
399 |
400 | public static function dataGetHierarchy(): array
401 | {
402 | $createdAt = (new DateTime('2023-12-24 17:51:18'))->getTimestamp();
403 | $postsViewPermission = (new Permission('posts.view'))->withCreatedAt($createdAt)->withUpdatedAt($createdAt);
404 | $postsCreatePermission = (new Permission('posts.create'))->withCreatedAt($createdAt)->withUpdatedAt($createdAt);
405 | $postsDeletePermission = (new Permission('posts.delete'))->withCreatedAt($createdAt)->withUpdatedAt($createdAt);
406 | $postsViewerRole = (new Role('posts.viewer'))->withCreatedAt($createdAt)->withUpdatedAt($createdAt);
407 | $postsRedactorRole = (new Role('posts.redactor'))->withCreatedAt($createdAt)->withUpdatedAt($createdAt);
408 | $postsAdminRole = (new Role('posts.admin'))->withCreatedAt($createdAt)->withUpdatedAt($createdAt);
409 |
410 | return [
411 | [
412 | 'posts.view',
413 | [
414 | 'posts.view' => ['item' => $postsViewPermission, 'children' => []],
415 | 'posts.viewer' => ['item' => $postsViewerRole, 'children' => ['posts.view' => $postsViewPermission]],
416 | 'posts.redactor' => [
417 | 'item' => $postsRedactorRole,
418 | 'children' => ['posts.view' => $postsViewPermission, 'posts.viewer' => $postsViewerRole],
419 | ],
420 | 'posts.admin' => [
421 | 'item' => $postsAdminRole,
422 | 'children' => [
423 | 'posts.view' => $postsViewPermission,
424 | 'posts.viewer' => $postsViewerRole,
425 | 'posts.redactor' => $postsRedactorRole,
426 | ],
427 | ],
428 | ],
429 | ],
430 | [
431 | 'posts.create',
432 | [
433 | 'posts.create' => ['item' => $postsCreatePermission, 'children' => []],
434 | 'posts.redactor' => [
435 | 'item' => $postsRedactorRole,
436 | 'children' => ['posts.create' => $postsCreatePermission],
437 | ],
438 | 'posts.admin' => [
439 | 'item' => $postsAdminRole,
440 | 'children' => [
441 | 'posts.create' => $postsCreatePermission,
442 | 'posts.redactor' => $postsRedactorRole,
443 | ],
444 | ],
445 | ],
446 | ],
447 | [
448 | 'posts.delete',
449 | [
450 | 'posts.delete' => ['item' => $postsDeletePermission, 'children' => []],
451 | 'posts.admin' => [
452 | 'item' => $postsAdminRole,
453 | 'children' => [
454 | 'posts.delete' => $postsDeletePermission,
455 | ],
456 | ],
457 | ],
458 | ],
459 | [
460 | 'posts.viewer',
461 | [
462 | 'posts.viewer' => ['item' => $postsViewerRole, 'children' => []],
463 | 'posts.redactor' => [
464 | 'item' => $postsRedactorRole,
465 | 'children' => ['posts.viewer' => $postsViewerRole],
466 | ],
467 | 'posts.admin' => [
468 | 'item' => $postsAdminRole,
469 | 'children' => [
470 | 'posts.viewer' => $postsViewerRole,
471 | 'posts.redactor' => $postsRedactorRole,
472 | ],
473 | ],
474 | ],
475 | ],
476 | ['non-existing', []],
477 | ];
478 | }
479 |
480 | #[DataProvider('dataGetHierarchy')]
481 | public function testGetHierarchy(string $name, array $expectedHierarchy): void
482 | {
483 | $this->assertEquals($expectedHierarchy, $this->getItemsStorage()->getHierarchy($name));
484 | }
485 |
486 | public function testRemoveChildren(): void
487 | {
488 | $testStorage = $this->getItemsStorageForModificationAssertions();
489 | $actionStorage = $this->getItemsStorage();
490 | $actionStorage->removeChildren('Parent 2');
491 |
492 | $this->assertFalse($testStorage->hasChildren('Parent 2'));
493 | $this->assertTrue($testStorage->hasChildren('Parent 1'));
494 | }
495 |
496 | public function testRemoveChildrenNonExisting(): void
497 | {
498 | $testStorage = $this->getItemsStorageForModificationAssertions();
499 | $actionStorage = $this->getItemsStorage();
500 | $count = count($actionStorage->getAll());
501 | $actionStorage->removeChildren('non-existing');
502 |
503 | $this->assertCount($count, $testStorage->getAll());
504 | }
505 |
506 | public function testGetRole(): void
507 | {
508 | $storage = $this->getItemsStorage();
509 | $role = $storage->getRole('Parent 1');
510 |
511 | $this->assertNotEmpty($role);
512 | $this->assertInstanceOf(Role::class, $role);
513 | $this->assertSame('Parent 1', $role->getName());
514 | }
515 |
516 | public function testAddWithCurrentTimestamps(): void
517 | {
518 | $testStorage = $this->getItemsStorageForModificationAssertions();
519 |
520 | $time = 1_683_707_079;
521 | $newItem = (new Permission('Delete post'))->withCreatedAt($time)->withUpdatedAt($time);
522 |
523 | $actionStorage = $this->getItemsStorage();
524 | $actionStorage->add($newItem);
525 |
526 | $this->assertEquals(
527 | (new Permission('Delete post'))->withCreatedAt(1_683_707_079)->withUpdatedAt(1_683_707_079),
528 | $testStorage->get('Delete post'),
529 | );
530 | }
531 |
532 | public function testAddWithPastTimestamps(): void
533 | {
534 | $testStorage = $this->getItemsStorageForModificationAssertions();
535 | $time = 1_694_508_008;
536 | $newItem = (new Permission('Delete post'))->withCreatedAt($time)->withUpdatedAt($time);
537 |
538 | $actionStorage = $this->getItemsStorage();
539 | $actionStorage->add($newItem);
540 |
541 | $this->assertEquals(
542 | (new Permission('Delete post'))->withCreatedAt($time)->withUpdatedAt($time),
543 | $testStorage->get('Delete post'),
544 | );
545 | }
546 |
547 | public function testRemoveChild(): void
548 | {
549 | $testStorage = $this->getItemsStorageForModificationAssertions();
550 | $actionStorage = $this->getItemsStorage();
551 | $actionStorage->addChild('Parent 2', 'Child 1');
552 | $actionStorage->removeChild('Parent 2', 'Child 1');
553 |
554 | $children = $testStorage->getAllChildren('Parent 2');
555 | $this->assertNotEmpty($children);
556 | $this->assertArrayNotHasKey('Child 1', $children);
557 |
558 | $this->assertArrayHasKey('Child 1', $testStorage->getAllChildren('Parent 1'));
559 | }
560 |
561 | public function testRemoveChildNonExisting(): void
562 | {
563 | $testStorage = $this->getItemsStorageForModificationAssertions();
564 | $actionStorage = $this->getItemsStorage();
565 | $count = count($actionStorage->getAll());
566 | $actionStorage->removeChild('posts.viewer', 'non-existing');
567 |
568 | $this->assertSame(['posts.view'], array_keys($testStorage->getDirectChildren('posts.viewer')));
569 | $this->assertCount($count, $testStorage->getAll());
570 | }
571 |
572 | public function testGetAll(): void
573 | {
574 | $storage = $this->getItemsStorage();
575 | $this->assertCount($this->getItemsCount(), $storage->getAll());
576 | }
577 |
578 | public static function dataGetByNames(): array
579 | {
580 | return [
581 | [[], []],
582 | [['posts.viewer', 'posts.redactor'], ['posts.viewer', 'posts.redactor']],
583 | [['posts.create', 'posts.update'], ['posts.create', 'posts.update']],
584 | [['posts.viewer', 'posts.view'], ['posts.viewer', 'posts.view']],
585 | [['posts.viewer', 'posts.view', 'non-existing'], ['posts.viewer', 'posts.view']],
586 | [['non-existing1', 'non-existing2'], []],
587 | ];
588 | }
589 |
590 | /**
591 | * @dataProvider dataGetByNames
592 | */
593 | public function testGetByNames(array $names, array $expectedItemNames): void
594 | {
595 | $items = $this->getItemsStorage()->getByNames($names);
596 |
597 | $this->assertCount(count($expectedItemNames), $items);
598 | foreach ($items as $itemName => $item) {
599 | $this->assertContains($itemName, $expectedItemNames);
600 | $this->assertSame($itemName, $item->getName());
601 | }
602 | }
603 |
604 | public function testHasChildren(): void
605 | {
606 | $storage = $this->getItemsStorage();
607 |
608 | $this->assertTrue($storage->hasChildren('Parent 1'));
609 | $this->assertFalse($storage->hasChildren('Parent 3'));
610 | }
611 |
612 | public static function dataHasChild(): array
613 | {
614 | return [
615 | ['posts.viewer', 'posts.view', true],
616 | ['posts.viewer', 'posts.create', false],
617 | ['posts.viewer', 'posts.delete', false],
618 |
619 | ['posts.redactor', 'posts.create', true],
620 | ['posts.redactor', 'posts.view', true],
621 | ['posts.redactor', 'posts.viewer', true],
622 | ['posts.redactor', 'posts.delete', false],
623 |
624 | ['posts.admin', 'posts.delete', true],
625 | ['posts.admin', 'posts.create', true],
626 | ['posts.admin', 'posts.redactor', true],
627 | ['posts.admin', 'posts.view', true],
628 | ['posts.admin', 'posts.viewer', true],
629 |
630 | ['posts.viewer', 'posts.redactor', false],
631 | ['posts.viewer', 'posts.admin', false],
632 | ['posts.redactor', 'posts.admin', false],
633 | ['posts.viewer', 'non-existing', false],
634 | ['non-existing', 'posts.viewer', false],
635 | ['non-existing1', 'non-existing2', false],
636 | ];
637 | }
638 |
639 | /**
640 | * @dataProvider dataHasChild
641 | */
642 | public function testHasChild(string $parentName, string $childName, bool $expectedHasChild): void
643 | {
644 | $this->assertSame($expectedHasChild, $this->getItemsStorage()->hasChild($parentName, $childName));
645 | }
646 |
647 | public static function dataHasDirectChild(): array
648 | {
649 | return [
650 | ['posts.viewer', 'posts.view', true],
651 | ['posts.viewer', 'posts.create', false],
652 | ['posts.viewer', 'posts.delete', false],
653 |
654 | ['posts.redactor', 'posts.create', true],
655 | ['posts.redactor', 'posts.view', false],
656 | ['posts.redactor', 'posts.viewer', true],
657 | ['posts.redactor', 'posts.delete', false],
658 |
659 | ['posts.admin', 'posts.delete', true],
660 | ['posts.admin', 'posts.create', false],
661 | ['posts.admin', 'posts.redactor', true],
662 | ['posts.admin', 'posts.view', false],
663 | ['posts.admin', 'posts.viewer', false],
664 |
665 | ['posts.viewer', 'posts.redactor', false],
666 | ['posts.viewer', 'posts.admin', false],
667 | ['posts.redactor', 'posts.admin', false],
668 | ['posts.viewer', 'non-existing', false],
669 | ['non-existing', 'posts.viewer', false],
670 | ['non-existing1', 'non-existing2', false],
671 | ];
672 | }
673 |
674 | /**
675 | * @dataProvider dataHasDirectChild
676 | */
677 | public function testHasDirectChild(string $parentName, string $childName, bool $expectedHasDirectChild): void
678 | {
679 | $this->assertSame($expectedHasDirectChild, $this->getItemsStorage()->hasDirectChild($parentName, $childName));
680 | }
681 |
682 | public function testClearPermissions(): void
683 | {
684 | $testStorage = $this->getItemsStorageForModificationAssertions();
685 | $actionStorage = $this->getItemsStorage();
686 | $actionStorage->clearPermissions();
687 |
688 | $all = $testStorage->getAll();
689 | $this->assertNotEmpty($all);
690 | $this->assertContainsOnlyInstancesOf(Role::class, $all);
691 | }
692 |
693 | public function testClearRoles(): void
694 | {
695 | $testStorage = $this->getItemsStorageForModificationAssertions();
696 | $actionStorage = $this->getItemsStorage();
697 | $actionStorage->clearRoles();
698 |
699 | $all = $testStorage->getAll();
700 | $this->assertNotEmpty($all);
701 | $this->assertContainsOnlyInstancesOf(Permission::class, $testStorage->getAll());
702 |
703 | $this->assertTrue($testStorage->hasChildren('Parent 5'));
704 | }
705 |
706 | protected function getItemsStorage(): ItemsStorageInterface
707 | {
708 | if ($this->itemsStorage === null) {
709 | $this->itemsStorage = $this->createItemsStorage();
710 | }
711 |
712 | return $this->itemsStorage;
713 | }
714 |
715 | protected function createItemsStorage(): ItemsStorageInterface
716 | {
717 | return new FakeItemsStorage();
718 | }
719 |
720 | protected function getItemsStorageForModificationAssertions(): ItemsStorageInterface
721 | {
722 | return $this->getItemsStorage();
723 | }
724 |
725 | protected function getFixtures(): array
726 | {
727 | $itemsMap = [
728 | 'Parent 1' => Item::TYPE_ROLE,
729 | 'Parent 2' => Item::TYPE_ROLE,
730 |
731 | // Parent without children
732 | 'Parent 3' => Item::TYPE_PERMISSION,
733 |
734 | 'Parent 4' => Item::TYPE_PERMISSION,
735 | 'Parent 5' => Item::TYPE_PERMISSION,
736 |
737 | // Parent with multiple generations of children
738 | 'posts.admin' => Item::TYPE_ROLE,
739 | 'posts.redactor' => Item::TYPE_ROLE,
740 | 'posts.viewer' => Item::TYPE_ROLE,
741 |
742 | 'Child 1' => Item::TYPE_PERMISSION,
743 | 'Child 2' => Item::TYPE_ROLE,
744 | 'Child 3' => Item::TYPE_ROLE,
745 | 'Child 4' => Item::TYPE_ROLE,
746 | 'Child 5' => Item::TYPE_PERMISSION,
747 |
748 | // Children of multiple generations
749 | 'posts.view' => Item::TYPE_PERMISSION,
750 | 'posts.create' => Item::TYPE_PERMISSION,
751 | 'posts.update' => Item::TYPE_PERMISSION,
752 | 'posts.delete' => Item::TYPE_PERMISSION,
753 | ];
754 | $time = 1703440278;
755 |
756 | $items = [];
757 | foreach ($itemsMap as $name => $type) {
758 | $items[] = [
759 | 'name' => $name,
760 | 'type' => $type,
761 | 'created_at' => $time,
762 | 'updated_at' => $time,
763 | ];
764 | $type === Item::TYPE_ROLE ? $this->initialRolesCount++ : $this->initialPermissionsCount++;
765 | }
766 |
767 | $itemsChildren = [
768 | // Parent: role, child: permission
769 | ['parent' => 'Parent 1', 'child' => 'Child 1'],
770 | // Parent: role, child: role
771 | ['parent' => 'Parent 2', 'child' => 'Child 2'],
772 | ['parent' => 'Parent 2', 'child' => 'Child 3'],
773 | // Parent: permission, child: role
774 | ['parent' => 'Parent 4', 'child' => 'Child 4'],
775 | // Parent: permission, child: permission
776 | ['parent' => 'Parent 5', 'child' => 'Child 5'],
777 |
778 | // Multiple generations of children
779 | ['parent' => 'posts.admin', 'child' => 'posts.redactor'],
780 | ['parent' => 'posts.redactor', 'child' => 'posts.viewer'],
781 | ['parent' => 'posts.viewer', 'child' => 'posts.view'],
782 | ['parent' => 'posts.redactor', 'child' => 'posts.create'],
783 | ['parent' => 'posts.redactor', 'child' => 'posts.update'],
784 | ['parent' => 'posts.admin', 'child' => 'posts.delete'],
785 | ];
786 | foreach ($itemsChildren as $itemChild) {
787 | $parentItemType = $itemsMap[$itemChild['parent']];
788 | $childItemType = $itemsMap[$itemChild['child']];
789 |
790 | if ($parentItemType === Item::TYPE_ROLE && $childItemType === Item::TYPE_ROLE) {
791 | $this->initialBothRolesChildrenCount++;
792 | }
793 |
794 | if ($parentItemType === Item::TYPE_PERMISSION && $childItemType === Item::TYPE_PERMISSION) {
795 | $this->initialBothPermissionsChildrenCount++;
796 | }
797 |
798 | $this->initialItemsChildrenCount++;
799 | }
800 |
801 | return ['items' => $items, 'itemsChildren' => $itemsChildren];
802 | }
803 |
804 | protected function populateItemsStorage(): void
805 | {
806 | $storage = $this->getItemsStorage();
807 | $fixtures = $this->getFixtures();
808 | foreach ($fixtures['items'] as $itemData) {
809 | $name = $itemData['name'];
810 | $item = $itemData['type'] === Item::TYPE_PERMISSION ? new Permission($name) : new Role($name);
811 | $item = $item
812 | ->withCreatedAt($itemData['created_at'])
813 | ->withUpdatedAt($itemData['updated_at']);
814 | $storage->add($item);
815 | }
816 |
817 | foreach ($fixtures['itemsChildren'] as $itemChildData) {
818 | $storage->addChild($itemChildData['parent'], $itemChildData['child']);
819 | }
820 | }
821 |
822 | private function getItemsCount(): int
823 | {
824 | return $this->initialRolesCount + $this->initialPermissionsCount;
825 | }
826 |
827 | private function assertChildren(array $children, array $expectedChildren): void
828 | {
829 | $this->assertCount(count($expectedChildren), $children);
830 | foreach ($children as $childName => $child) {
831 | $this->assertContains($childName, $expectedChildren);
832 | $this->assertSame($childName, $child->getName());
833 | }
834 | }
835 | }
836 |
--------------------------------------------------------------------------------