├── 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 | Yii 4 | 5 |

Yii Role-Based Access Control

6 |
7 |

8 | 9 | [![Latest Stable Version](https://poser.pugx.org/yiisoft/rbac/v)](https://packagist.org/packages/yiisoft/rbac) 10 | [![Total Downloads](https://poser.pugx.org/yiisoft/rbac/downloads)](https://packagist.org/packages/yiisoft/rbac) 11 | [![Build status](https://github.com/yiisoft/rbac/actions/workflows/build.yml/badge.svg)](https://github.com/yiisoft/rbac/actions/workflows/build.yml) 12 | [![codecov](https://codecov.io/gh/yiisoft/rbac/graph/badge.svg?token=95SVWYEXO1)](https://codecov.io/gh/yiisoft/rbac) 13 | [![Mutation testing badge](https://img.shields.io/endpoint?style=flat&url=https%3A%2F%2Fbadge-api.stryker-mutator.io%2Fgithub.com%2Fyiisoft%2Frbac%2Fmaster)](https://dashboard.stryker-mutator.io/reports/github.com/yiisoft/rbac/master) 14 | [![static analysis](https://github.com/yiisoft/rbac/workflows/static%20analysis/badge.svg)](https://github.com/yiisoft/rbac/actions?query=workflow%3A%22static+analysis%22) 15 | [![type-coverage](https://shepherd.dev/github/yiisoft/rbac/coverage.svg)](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 | [![Open Collective](https://img.shields.io/badge/Open%20Collective-sponsor-7eadf1?logo=open%20collective&logoColor=7eadf1&labelColor=555555)](https://opencollective.com/yiisoft) 289 | 290 | ## Follow updates 291 | 292 | [![Official website](https://img.shields.io/badge/Powered_by-Yii_Framework-green.svg?style=flat)](https://www.yiiframework.com/) 293 | [![Twitter](https://img.shields.io/badge/twitter-follow-1DA1F2?logo=twitter&logoColor=1DA1F2&labelColor=555555?style=flat)](https://twitter.com/yiiframework) 294 | [![Telegram](https://img.shields.io/badge/telegram-join-1DA1F2?style=flat&logo=telegram)](https://t.me/yii3en) 295 | [![Facebook](https://img.shields.io/badge/facebook-join-1DA1F2?style=flat&logo=facebook&logoColor=ffffff)](https://www.facebook.com/groups/yiitalk) 296 | [![Slack](https://img.shields.io/badge/slack-join-1DA1F2?style=flat&logo=slack)](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 | --------------------------------------------------------------------------------