From 6ba7ab216a18afc54a91c343f8b24b76b0a0f496 Mon Sep 17 00:00:00 2001 From: Adam Kadlec Date: Tue, 16 Jul 2024 11:26:24 +0200 Subject: [PATCH] Integrate Casbin authorizator (#6) --- .docs/en/index.md | 4 +- composer.json | 8 +- resources/model.conf | 14 + src/Constants.php | 2 + src/DI/SimpleAuthExtension.php | 125 +++++++-- src/Entities/Policies/Policy.php | 167 ++++++++++++ src/Entities/Tokens/Token.php | 7 +- src/Exceptions/Logical.php | 23 ++ src/Models/Policies/Manager.php | 93 +++++++ src/Models/Policies/Repository.php | 82 ++++++ .../Tokens/{TokensManager.php => Manager.php} | 36 ++- .../{TokenRepository.php => Repository.php} | 4 +- src/Queries/FindPolicies.php | 91 +++++++ src/Security/AnnotationChecker.php | 16 +- src/Security/User.php | 23 -- src/Subscribers/Policy.php | 116 ++++++++ src/Types/PolicyType.php | 33 +++ tests/cases/unit/BaseTestCase.php | 15 ++ tests/cases/unit/Casbin/DatabaseTest.php | 80 ++++++ tests/cases/unit/Casbin/database.neon | 26 ++ tests/cases/unit/DI/ExtensionTests.php | 3 + tests/cases/unit/DbTestCase.php | 250 ++++++++++++++++++ .../unit/Middleware/UserMiddlewareTest.php | 8 +- .../unit/Models/PoliciesRepositoryTest.php | 81 ++++++ ...itoryTest.php => ToknesRepositoryTest.php} | 6 +- tests/common.neon | 2 + tests/fixtures/Entities/TestPolicyEntity.php | 44 +++ tests/fixtures/Entities/TestRoleEntity.php | 24 ++ tests/fixtures/policy.csv | 8 + tests/sql/dummy.data.sql | 12 + tests/tools/ConnectionWrapper.php | 63 +++++ tools/phpcs.xml | 1 + 32 files changed, 1395 insertions(+), 72 deletions(-) create mode 100644 resources/model.conf create mode 100644 src/Entities/Policies/Policy.php create mode 100644 src/Exceptions/Logical.php create mode 100644 src/Models/Policies/Manager.php create mode 100644 src/Models/Policies/Repository.php rename src/Models/Tokens/{TokensManager.php => Manager.php} (58%) rename src/Models/Tokens/{TokenRepository.php => Repository.php} (98%) create mode 100644 src/Queries/FindPolicies.php create mode 100644 src/Subscribers/Policy.php create mode 100644 src/Types/PolicyType.php create mode 100644 tests/cases/unit/Casbin/DatabaseTest.php create mode 100644 tests/cases/unit/Casbin/database.neon create mode 100644 tests/cases/unit/DbTestCase.php create mode 100644 tests/cases/unit/Models/PoliciesRepositoryTest.php rename tests/cases/unit/Models/{RepositoryTest.php => ToknesRepositoryTest.php} (91%) create mode 100644 tests/fixtures/Entities/TestPolicyEntity.php create mode 100644 tests/fixtures/Entities/TestRoleEntity.php create mode 100644 tests/fixtures/policy.csv create mode 100644 tests/sql/dummy.data.sql create mode 100644 tests/tools/ConnectionWrapper.php diff --git a/.docs/en/index.md b/.docs/en/index.md index e4ad1ab..7b2ada5 100644 --- a/.docs/en/index.md +++ b/.docs/en/index.md @@ -159,8 +159,8 @@ class SomeSessionController { /** @var Security\User */ private Security\User $user; - /** @var Models\Tokens\TokensManager */ - private Models\Tokens\TokensManager $tokensManager; + /** @var Models\Tokens\Manager */ + private Models\Tokens\Manager $tokensManager; /** @var Security\TokenBuilder */ private Security\TokenBuilder $tokenBuilder; diff --git a/composer.json b/composer.json index ac7b352..b1f1cf1 100644 --- a/composer.json +++ b/composer.json @@ -40,6 +40,8 @@ "require" : { "php": ">=8.1.0", "ext-openssl": "*", + "casbin/casbin": "^3.23", + "casbin/dbal-adapter": "^2.4", "contributte/event-dispatcher": "^0.9", "cweagans/composer-patches": "^1.7", "fastybird/datetime-factory": "^0.6", @@ -86,7 +88,8 @@ "autoload-dev" : { "psr-4" : { "FastyBird\\SimpleAuth\\Tests\\Cases\\Unit\\" : "tests/cases/unit", - "FastyBird\\SimpleAuth\\Tests\\Fixtures\\" : "tests/fixtures" + "FastyBird\\SimpleAuth\\Tests\\Fixtures\\" : "tests/fixtures", + "FastyBird\\SimpleAuth\\Tests\\Tools\\": "tests/tools" } }, @@ -104,6 +107,9 @@ "patches" : { "nette/utils" : { "Bug: Offset check with null support" : "https://raw.githubusercontent.com/FastyBird/libraries-patches/master/nette.array.offsetCheck.diff" + }, + "nettrine/orm": { + "Enable connection overrides": "https://raw.githubusercontent.com/FastyBird/libraries-patches/master/nettrine-orm-src-managerregistry-php.patch" } } } diff --git a/resources/model.conf b/resources/model.conf new file mode 100644 index 0000000..9ca4b92 --- /dev/null +++ b/resources/model.conf @@ -0,0 +1,14 @@ +[request_definition] +r = sub, obj, act + +[policy_definition] +p = sub, obj, act + +[role_definition] +g = _, _ + +[policy_effect] +e = some(where (p.eft == allow)) + +[matchers] +m = g(r.sub, p.sub) && r.obj == p.obj && r.act == p.act diff --git a/src/Constants.php b/src/Constants.php index 208cb67..7e4e6d8 100644 --- a/src/Constants.php +++ b/src/Constants.php @@ -65,4 +65,6 @@ final class Constants public const ROLE_ADMINISTRATOR = 'administrator'; + public const USER_ANONYMOUS = 'guest'; + } diff --git a/src/DI/SimpleAuthExtension.php b/src/DI/SimpleAuthExtension.php index a182098..fd37d58 100644 --- a/src/DI/SimpleAuthExtension.php +++ b/src/DI/SimpleAuthExtension.php @@ -15,15 +15,17 @@ namespace FastyBird\SimpleAuth\DI; +use Casbin; +use CasbinAdapter; +use Doctrine\DBAL\Connection; use Doctrine\Persistence; use FastyBird\SimpleAuth; -use FastyBird\SimpleAuth\Entities; use FastyBird\SimpleAuth\Events; +use FastyBird\SimpleAuth\Exceptions; use FastyBird\SimpleAuth\Mapping; use FastyBird\SimpleAuth\Middleware; use FastyBird\SimpleAuth\Security; use FastyBird\SimpleAuth\Subscribers; -use IPub\DoctrineCrud; use Nette; use Nette\Application as NetteApplication; use Nette\DI; @@ -32,7 +34,8 @@ use stdClass; use Symfony\Contracts\EventDispatcher; use function assert; -use function ucfirst; +use function is_file; +use function is_string; use const DIRECTORY_SEPARATOR; /** @@ -69,6 +72,9 @@ public function getConfigSchema(): Schema\Schema 'mapping' => Schema\Expect::bool(false), 'models' => Schema\Expect::bool(false), ]), + 'casbin' => Schema\Expect::structure([ + 'database' => Schema\Expect::bool(false), + ]), 'nette' => Schema\Expect::structure([ 'application' => Schema\Expect::bool(false), ]), @@ -80,6 +86,24 @@ public function getConfigSchema(): Schema\Schema 'services' => Schema\Expect::structure([ 'identity' => Schema\Expect::bool(false), ]), + 'casbin' => Schema\Expect::structure([ + 'model' => Schema\Expect::string( + // phpcs:ignore SlevomatCodingStandard.Files.LineLength.LineTooLong + __DIR__ . DIRECTORY_SEPARATOR . '..' . DIRECTORY_SEPARATOR . '..' . DIRECTORY_SEPARATOR . 'resources' . DIRECTORY_SEPARATOR . 'model.conf', + ), + 'policy' => Schema\Expect::string(), + ]), + 'database' => Schema\Expect::anyOf( + Schema\Expect::null(), + Schema\Expect::structure([ + 'driver' => Schema\Expect::string('pdo_mysql'), + 'host' => Schema\Expect::string('127.0.0.1'), + 'port' => Schema\Expect::int(3_306), + 'database' => Schema\Expect::string('security'), + 'user' => Schema\Expect::string('root'), + 'password' => Schema\Expect::string(), + ]), + ), ]); } @@ -162,14 +186,19 @@ public function loadConfiguration(): void } if ($configuration->enable->doctrine->models) { - $builder->addDefinition($this->prefix('doctrine.tokenRepository'), new DI\Definitions\ServiceDefinition()) - ->setType(SimpleAuth\Models\Tokens\TokenRepository::class); + $builder->addDefinition($this->prefix('doctrine.tokensRepository'), new DI\Definitions\ServiceDefinition()) + ->setType(SimpleAuth\Models\Tokens\Repository::class); $builder->addDefinition($this->prefix('doctrine.tokensManager'), new DI\Definitions\ServiceDefinition()) - ->setType(SimpleAuth\Models\Tokens\TokensManager::class) - ->setArgument('entityCrud', '__placeholder__'); + ->setType(SimpleAuth\Models\Tokens\Manager::class); } + $builder->addDefinition($this->prefix('doctrine.policiesRepository'), new DI\Definitions\ServiceDefinition()) + ->setType(SimpleAuth\Models\Policies\Repository::class); + + $builder->addDefinition($this->prefix('doctrine.policiesManager'), new DI\Definitions\ServiceDefinition()) + ->setType(SimpleAuth\Models\Policies\Manager::class); + /** * Nette application extension */ @@ -182,6 +211,7 @@ public function loadConfiguration(): void /** * @throws DI\MissingServiceException + * @throws Exceptions\Logical */ public function beforeCompile(): void { @@ -258,32 +288,73 @@ public function beforeCompile(): void } } } - } - - /** - * @throws DI\MissingServiceException - */ - public function afterCompile(PhpGenerator\ClassType $class): void - { - $builder = $this->getContainerBuilder(); - $configuration = $this->getConfig(); - assert($configuration instanceof stdClass); /** - * Doctrine extension + * Casbin */ - if ($configuration->enable->doctrine->models) { - $entityFactoryServiceName = $builder->getByType(DoctrineCrud\Crud\IEntityCrudFactory::class, true); + if ($configuration->enable->casbin->database) { + $connectionServiceName = $builder->getByType(Connection::class); + + if ($connectionServiceName !== null) { + $connectionService = $builder->getDefinition($connectionServiceName); + + $adapter = $builder->addDefinition( + $this->prefix('casbin.adapter'), + new DI\Definitions\ServiceDefinition(), + ) + ->setType(CasbinAdapter\DBAL\Adapter::class) + ->setArguments([ + 'connection' => $connectionService, + ]) + ->addSetup('$policyTableName', ['fb_security_policies']); + } else { + $adapter = $builder->addDefinition( + $this->prefix('casbin.adapter'), + new DI\Definitions\ServiceDefinition(), + ) + ->setType(CasbinAdapter\DBAL\Adapter::class) + ->setArguments([ + 'connection' => [ + 'driver' => $configuration->database->driver, + 'host' => $configuration->database->host, + 'port' => $configuration->database->port, + 'dbname' => $configuration->database->database, + 'user' => $configuration->database->user, + 'password' => $configuration->database->password, + 'policy_table_name' => 'fb_security_policies', + ], + ]); + } - $tokensManagerService = $class->getMethod( - 'createService' . ucfirst($this->name) . '__doctrine__tokensManager', - ); - $tokensManagerService->setBody( - 'return new ' . SimpleAuth\Models\Tokens\TokensManager::class - . '($this->getService(\'' . $entityFactoryServiceName . '\')->create(\'' . Entities\Tokens\Token::class . '\'));', - ); + $builder->addDefinition($this->prefix('casbin.subscriber'), new DI\Definitions\ServiceDefinition()) + ->setType(Subscribers\Policy::class); + } else { + $policyFile = $configuration->casbin->policy; + + if (!is_string($policyFile) || !is_file($policyFile)) { + throw new Exceptions\Logical('Casbin policy file is not configured'); + } + + $adapter = $builder->addDefinition($this->prefix('casbin.adapter'), new DI\Definitions\ServiceDefinition()) + ->setType(Casbin\Persist\Adapters\FileAdapter::class) + ->setArguments([ + 'filePath' => $policyFile, + ]); + } + + $modelFile = $configuration->casbin->model; + + if (!is_string($modelFile) || !is_file($modelFile)) { + throw new Exceptions\Logical('Casbin model file is not configured'); } + + $builder->addDefinition($this->prefix('casbin.enforcer'), new DI\Definitions\ServiceDefinition()) + ->setType(Casbin\Enforcer::class) + ->setArguments([ + $modelFile, + $adapter, + ]); } } diff --git a/src/Entities/Policies/Policy.php b/src/Entities/Policies/Policy.php new file mode 100644 index 0000000..3d62c09 --- /dev/null +++ b/src/Entities/Policies/Policy.php @@ -0,0 +1,167 @@ + + * @package FastyBird:SimpleAuth! + * @subpackage Entities + * @since 1.0.0 + * + * @date 14.07.24 + */ + +namespace FastyBird\SimpleAuth\Entities\Policies; + +use Doctrine\ORM\Mapping as ORM; +use FastyBird\SimpleAuth\Types; +use IPub\DoctrineCrud; +use IPub\DoctrineCrud\Mapping\Attribute as IPubDoctrine; +use Ramsey\Uuid; + +#[ORM\Entity] +#[ORM\Table( + name: 'fb_security_policies', + indexes: [ + new ORM\Index(columns: ['p_type'], name: 'p_type_idx'), + ], + options: [ + 'collate' => 'utf8mb4_general_ci', + 'charset' => 'utf8mb4', + 'comment' => 'Casbin policies', + ], +)] +#[ORM\InheritanceType('SINGLE_TABLE')] +#[ORM\DiscriminatorColumn(name: 'policy_type', type: 'string', length: 100)] +#[ORM\MappedSuperclass] +class Policy implements DoctrineCrud\Entities\IEntity +{ + + #[ORM\Id] + #[ORM\Column(name: 'policy_id', type: Uuid\Doctrine\UuidBinaryType::NAME)] + #[ORM\CustomIdGenerator(class: Uuid\Doctrine\UuidGenerator::class)] + protected Uuid\UuidInterface $id; + + #[IPubDoctrine\Crud(required: true, writable: true)] + #[ORM\Column( + name: 'p_type', + type: 'string', + nullable: false, + enumType: Types\PolicyType::class, + )] + protected Types\PolicyType $type; + + #[IPubDoctrine\Crud(writable: true)] + #[ORM\Column(name: 'v0', type: 'string', length: 150, nullable: true, options: ['default' => null])] + protected string|null $v0 = null; + + #[IPubDoctrine\Crud(writable: true)] + #[ORM\Column(name: 'v1', type: 'string', length: 150, nullable: true, options: ['default' => null])] + protected string|null $v1 = null; + + #[IPubDoctrine\Crud(writable: true)] + #[ORM\Column(name: 'v2', type: 'string', length: 150, nullable: true, options: ['default' => null])] + protected string|null $v2 = null; + + #[IPubDoctrine\Crud(writable: true)] + #[ORM\Column(name: 'v3', type: 'string', length: 150, nullable: true, options: ['default' => null])] + protected string|null $v3 = null; + + #[IPubDoctrine\Crud(writable: true)] + #[ORM\Column(name: 'v4', type: 'string', length: 150, nullable: true, options: ['default' => null])] + protected string|null $v4 = null; + + #[IPubDoctrine\Crud(writable: true)] + #[ORM\Column(name: 'v5', type: 'string', length: 150, nullable: true, options: ['default' => null])] + protected string|null $v5 = null; + + public function __construct( + Types\PolicyType $type, + Uuid\UuidInterface|null $id = null, + ) + { + $this->id = $id ?? Uuid\Uuid::uuid4(); + + $this->type = $type; + + $this->v0 = null; + $this->v1 = null; + $this->v2 = null; + $this->v3 = null; + $this->v4 = null; + $this->v5 = null; + } + + public function getId(): Uuid\UuidInterface + { + return $this->id; + } + + public function getType(): string + { + return $this->type->value; + } + + public function getV0(): string|null + { + return $this->v0; + } + + public function setV0(string|null $v0): void + { + $this->v0 = $v0; + } + + public function getV1(): string|null + { + return $this->v1; + } + + public function setV1(string|null $v1): void + { + $this->v1 = $v1; + } + + public function getV2(): string|null + { + return $this->v2; + } + + public function setV2(string|null $v2): void + { + $this->v2 = $v2; + } + + public function getV3(): string|null + { + return $this->v3; + } + + public function setV3(string|null $v3): void + { + $this->v3 = $v3; + } + + public function getV4(): string|null + { + return $this->v4; + } + + public function setV4(string|null $v4): void + { + $this->v4 = $v4; + } + + public function getV5(): string|null + { + return $this->v5; + } + + public function setV5(string|null $v5): void + { + $this->v5 = $v5; + } + +} diff --git a/src/Entities/Tokens/Token.php b/src/Entities/Tokens/Token.php index cb8c6c4..a1862c4 100644 --- a/src/Entities/Tokens/Token.php +++ b/src/Entities/Tokens/Token.php @@ -35,7 +35,7 @@ ], )] #[ORM\InheritanceType('SINGLE_TABLE')] -#[ORM\DiscriminatorColumn(name: 'token_type', type: 'string', length: 20)] +#[ORM\DiscriminatorColumn(name: 'token_type', type: 'string', length: 100)] #[ORM\MappedSuperclass] abstract class Token implements DoctrineCrud\Entities\IEntity { @@ -78,6 +78,11 @@ public function __construct(string $token, Uuid\UuidInterface|null $id = null) $this->children = new Common\Collections\ArrayCollection(); } + public function getId(): Uuid\UuidInterface + { + return $this->id; + } + public function getParent(): self|null { return $this->parent; diff --git a/src/Exceptions/Logical.php b/src/Exceptions/Logical.php new file mode 100644 index 0000000..a8ed8df --- /dev/null +++ b/src/Exceptions/Logical.php @@ -0,0 +1,23 @@ + + * @package FastyBird:SimpleAuth! + * @subpackage Exceptions + * @since 0.1.0 + * + * @date 09.07.20 + */ + +namespace FastyBird\SimpleAuth\Exceptions; + +use LogicException; + +class Logical extends LogicException implements Exception +{ + +} diff --git a/src/Models/Policies/Manager.php b/src/Models/Policies/Manager.php new file mode 100644 index 0000000..c9d9009 --- /dev/null +++ b/src/Models/Policies/Manager.php @@ -0,0 +1,93 @@ + + * @package FastyBird:SimpleAuth! + * @subpackage Models + * @since 0.1.0 + * + * @date 15.07.24 + */ + +namespace FastyBird\SimpleAuth\Models\Policies; + +use FastyBird\SimpleAuth\Entities; +use FastyBird\SimpleAuth\Models; +use IPub\DoctrineCrud\Crud as DoctrineCrudCrud; +use IPub\DoctrineCrud\Exceptions as DoctrineCrudExceptions; +use Nette; +use Nette\Utils; +use function assert; + +/** + * Security tokens entities manager + * + * @package FastyBird:SimpleAuth! + * @subpackage Models + * @author Adam Kadlec + */ +class Manager +{ + + use Nette\SmartObject; + + /** @var DoctrineCrudCrud\IEntityCrud|null */ + private DoctrineCrudCrud\IEntityCrud|null $entityCrud = null; + + /** + * @param DoctrineCrudCrud\IEntityCrudFactory $entityCrudFactory + */ + public function __construct( + private readonly DoctrineCrudCrud\IEntityCrudFactory $entityCrudFactory, + ) + { + } + + public function create(Utils\ArrayHash $values): Entities\Policies\Policy + { + $entity = $this->getEntityCrud()->getEntityCreator()->create($values); + assert($entity instanceof Entities\Policies\Policy); + + return $entity; + } + + /** + * @throws DoctrineCrudExceptions\InvalidArgumentException + */ + public function update( + Entities\Policies\Policy $entity, + Utils\ArrayHash $values, + ): Entities\Policies\Policy + { + $entity = $this->getEntityCrud()->getEntityUpdater()->update($values, $entity); + assert($entity instanceof Entities\Policies\Policy); + + return $entity; + } + + /** + * @throws DoctrineCrudExceptions\InvalidArgumentException + */ + public function delete(Entities\Policies\Policy $entity): bool + { + // Delete entity from database + return $this->getEntityCrud()->getEntityDeleter()->delete($entity); + } + + /** + * @return DoctrineCrudCrud\IEntityCrud + */ + public function getEntityCrud(): DoctrineCrudCrud\IEntityCrud + { + if ($this->entityCrud === null) { + $this->entityCrud = $this->entityCrudFactory->create(Entities\Policies\Policy::class); + } + + return $this->entityCrud; + } + +} diff --git a/src/Models/Policies/Repository.php b/src/Models/Policies/Repository.php new file mode 100644 index 0000000..4cf4cd7 --- /dev/null +++ b/src/Models/Policies/Repository.php @@ -0,0 +1,82 @@ + + * @package FastyBird:SimpleAuth! + * @subpackage Models + * @since 0.1.0 + * + * @date 15.07.24 + */ + +namespace FastyBird\SimpleAuth\Models\Policies; + +use Doctrine\ORM; +use Doctrine\Persistence; +use FastyBird\SimpleAuth\Entities; +use FastyBird\SimpleAuth\Queries; +use IPub\DoctrineOrmQuery\Exceptions as DoctrineOrmQueryExceptions; +use Nette; + +/** + * Security token repository + * + * @package FastyBird:SimpleAuth! + * @subpackage Models + * @author Adam Kadlec + */ +final class Repository +{ + + use Nette\SmartObject; + + /** @var array> */ + private array $repository = []; + + public function __construct(private readonly Persistence\ManagerRegistry $managerRegistry) + { + } + + /** + * @template T of Entities\Policies\Policy + * + * @param Queries\FindPolicies $queryObject + * @param class-string $type + * + * @return T|null + * + * @throws DoctrineOrmQueryExceptions\InvalidStateException + * @throws DoctrineOrmQueryExceptions\QueryException + */ + public function findOneBy( + Queries\FindPolicies $queryObject, + string $type = Entities\Policies\Policy::class, + ): Entities\Policies\Policy|null + { + return $queryObject->fetchOne($this->getRepository($type)); + } + + /** + * @template T of Entities\Policies\Policy + * + * @param class-string $type + * + * @return ORM\EntityRepository + */ + private function getRepository(string $type): ORM\EntityRepository + { + if (!isset($this->repository[$type])) { + $this->repository[$type] = $this->managerRegistry->getRepository($type); + } + + /** @var ORM\EntityRepository $repository */ + $repository = $this->repository[$type]; + + return $repository; + } + +} diff --git a/src/Models/Tokens/TokensManager.php b/src/Models/Tokens/Manager.php similarity index 58% rename from src/Models/Tokens/TokensManager.php rename to src/Models/Tokens/Manager.php index 4ea1392..3117b8c 100644 --- a/src/Models/Tokens/TokensManager.php +++ b/src/Models/Tokens/Manager.php @@ -1,7 +1,7 @@ */ -class TokensManager +class Manager { use Nette\SmartObject; + /** @var DoctrineCrudCrud\IEntityCrud|null */ + private DoctrineCrudCrud\IEntityCrud|null $entityCrud = null; + /** - * @param Crud\IEntityCrud $entityCrud + * @param DoctrineCrudCrud\IEntityCrudFactory $entityCrudFactory */ - public function __construct(private Crud\IEntityCrud $entityCrud) + public function __construct( + private readonly DoctrineCrudCrud\IEntityCrudFactory $entityCrudFactory, + ) { - // Entity CRUD for handling entities } public function create(Utils\ArrayHash $values): Entities\Tokens\Token { - $entity = $this->entityCrud->getEntityCreator()->create($values); + $entity = $this->getEntityCrud()->getEntityCreator()->create($values); assert($entity instanceof Entities\Tokens\Token); return $entity; @@ -61,7 +63,7 @@ public function update( Utils\ArrayHash $values, ): Entities\Tokens\Token { - $entity = $this->entityCrud->getEntityUpdater()->update($values, $entity); + $entity = $this->getEntityCrud()->getEntityUpdater()->update($values, $entity); assert($entity instanceof Entities\Tokens\Token); return $entity; @@ -73,7 +75,19 @@ public function update( public function delete(Entities\Tokens\Token $entity): bool { // Delete entity from database - return $this->entityCrud->getEntityDeleter()->delete($entity); + return $this->getEntityCrud()->getEntityDeleter()->delete($entity); + } + + /** + * @return DoctrineCrudCrud\IEntityCrud + */ + public function getEntityCrud(): DoctrineCrudCrud\IEntityCrud + { + if ($this->entityCrud === null) { + $this->entityCrud = $this->entityCrudFactory->create(Entities\Tokens\Token::class); + } + + return $this->entityCrud; } } diff --git a/src/Models/Tokens/TokenRepository.php b/src/Models/Tokens/Repository.php similarity index 98% rename from src/Models/Tokens/TokenRepository.php rename to src/Models/Tokens/Repository.php index 1db26b9..838d730 100644 --- a/src/Models/Tokens/TokenRepository.php +++ b/src/Models/Tokens/Repository.php @@ -1,7 +1,7 @@ */ -final class TokenRepository +final class Repository { use Nette\SmartObject; diff --git a/src/Queries/FindPolicies.php b/src/Queries/FindPolicies.php new file mode 100644 index 0000000..56b59f1 --- /dev/null +++ b/src/Queries/FindPolicies.php @@ -0,0 +1,91 @@ + + * @package FastyBird:SimpleAuth! + * @subpackage Queries + * @since 0.1.0 + * + * @date 15.07.24 + */ + +namespace FastyBird\SimpleAuth\Queries; + +use Closure; +use Doctrine\ORM; +use FastyBird\SimpleAuth\Entities; +use IPub\DoctrineOrmQuery; +use Ramsey\Uuid; + +/** + * Find tokens entities query + * + * @template T of Entities\Policies\Policy + * @extends DoctrineOrmQuery\QueryObject + * + * @package FastyBird:SimpleAuth! + * @subpackage Queries + * @author Adam Kadlec + */ +class FindPolicies extends DoctrineOrmQuery\QueryObject +{ + + /** @var array */ + private array $filter = []; + + /** @var array */ + private array $select = []; + + public function byId(Uuid\UuidInterface $id): void + { + $this->filter[] = static function (ORM\QueryBuilder $qb) use ($id): void { + $qb->andWhere('p.id = :id')->setParameter('id', $id, Uuid\Doctrine\UuidBinaryType::NAME); + }; + } + + public function byType(string $type): void + { + $this->filter[] = static function (ORM\QueryBuilder $qb) use ($type): void { + $qb->andWhere('p.type = :type')->setParameter('type', $type); + }; + } + + /** + * @param ORM\EntityRepository $repository + */ + protected function doCreateQuery(ORM\EntityRepository $repository): ORM\QueryBuilder + { + return $this->createBasicDql($repository); + } + + /** + * @param ORM\EntityRepository $repository + */ + private function createBasicDql(ORM\EntityRepository $repository): ORM\QueryBuilder + { + $qb = $repository->createQueryBuilder('p'); + + foreach ($this->select as $modifier) { + $modifier($qb); + } + + foreach ($this->filter as $modifier) { + $modifier($qb); + } + + return $qb; + } + + /** + * @param ORM\EntityRepository $repository + */ + protected function doCreateCountQuery(ORM\EntityRepository $repository): ORM\QueryBuilder + { + return $this->createBasicDql($repository)->select('COUNT(p.id)'); + } + +} diff --git a/src/Security/AnnotationChecker.php b/src/Security/AnnotationChecker.php index 11b10dd..1331920 100644 --- a/src/Security/AnnotationChecker.php +++ b/src/Security/AnnotationChecker.php @@ -15,11 +15,14 @@ namespace FastyBird\SimpleAuth\Security; +use Casbin; +use FastyBird\SimpleAuth; use FastyBird\SimpleAuth\Exceptions; use ReflectionClass; use ReflectionException; use Reflector; use function array_key_exists; +use function assert; use function call_user_func; use function class_exists; use function count; @@ -46,6 +49,10 @@ class AnnotationChecker { + public function __construct(private readonly Casbin\Enforcer $enforcer) + { + } + /** * @param class-string $controllerClass * @@ -187,7 +194,14 @@ private function checkRoles(User $user, Reflector $element): bool continue; } - if (is_string($role) && $user->isInRole($role)) { + assert(is_string($role)); + + if ( + $this->enforcer->hasRoleForUser( + $user->getId()?->toString() ?? SimpleAuth\Constants::USER_ANONYMOUS, + $role, + ) + ) { return true; } } diff --git a/src/Security/User.php b/src/Security/User.php index be478a2..c757658 100644 --- a/src/Security/User.php +++ b/src/Security/User.php @@ -16,14 +16,12 @@ namespace FastyBird\SimpleAuth\Security; use Closure; -use FastyBird\SimpleAuth; use FastyBird\SimpleAuth\Exceptions; use FastyBird\SimpleAuth\Security; use Nette; use Nette\Utils; use Ramsey\Uuid; use function func_get_args; -use function in_array; /** * Application user @@ -106,25 +104,4 @@ public function isLoggedIn(): bool return $this->storage->isAuthenticated(); } - public function isInRole(string $role): bool - { - return in_array($role, $this->getRoles(), true); - } - - /** - * @return array - */ - public function getRoles(): array - { - if (!$this->isLoggedIn()) { - return [SimpleAuth\Constants::ROLE_ANONYMOUS]; - } - - $identity = $this->getIdentity(); - - return $identity !== null && $identity->getRoles() !== [] - ? $identity->getRoles() - : [SimpleAuth\Constants::ROLE_USER]; - } - } diff --git a/src/Subscribers/Policy.php b/src/Subscribers/Policy.php new file mode 100644 index 0000000..e4fb129 --- /dev/null +++ b/src/Subscribers/Policy.php @@ -0,0 +1,116 @@ + + * @package FastyBird:DevicesModule! + * @subpackage Subscribers + * @since 1.0.0 + * + * @date 22.03.20 + */ + +namespace FastyBird\SimpleAuth\Subscribers; + +use Casbin; +use Doctrine\Common; +use Doctrine\ORM; +use Doctrine\Persistence; +use FastyBird\SimpleAuth\Entities; +use Nette; +use function count; + +/** + * Casbin policy entity subscriber + * + * @package FastyBird:DevicesModule! + * @subpackage Subscribers + * + * @author Adam Kadlec + */ +final class Policy implements Common\EventSubscriber +{ + + use Nette\SmartObject; + + public function __construct( + private readonly ORM\EntityManagerInterface $entityManager, + private readonly Casbin\Enforcer $enforcer, + ) + { + } + + public function getSubscribedEvents(): array + { + return [ + 0 => ORM\Events::postPersist, + 1 => ORM\Events::postUpdate, + 2 => ORM\Events::postRemove, + ]; + } + + /** + * @param Persistence\Event\LifecycleEventArgs $eventArgs + */ + public function postPersist(Persistence\Event\LifecycleEventArgs $eventArgs): void + { + // onFlush was executed before, everything already initialized + $entity = $eventArgs->getObject(); + + // Check for valid entity + if (!$entity instanceof Entities\Policies\Policy) { + return; + } + + $this->enforcer->loadPolicy(); + } + + /** + * @param Persistence\Event\LifecycleEventArgs $eventArgs + */ + public function postUpdate(Persistence\Event\LifecycleEventArgs $eventArgs): void + { + $uow = $this->entityManager->getUnitOfWork(); + + // onFlush was executed before, everything already initialized + $entity = $eventArgs->getObject(); + + // Get changes => should be already computed here (is a listener) + $changeSet = $uow->getEntityChangeSet($entity); + + // If we have no changes left => don't create revision log + if (count($changeSet) === 0) { + return; + } + + // Check for valid entity + if ( + !$entity instanceof Entities\Policies\Policy + || $uow->isScheduledForDelete($entity) + ) { + return; + } + + $this->enforcer->loadPolicy(); + } + + /** + * @param Persistence\Event\LifecycleEventArgs $eventArgs + */ + public function postRemove(Persistence\Event\LifecycleEventArgs $eventArgs): void + { + // onFlush was executed before, everything already initialized + $entity = $eventArgs->getObject(); + + // Check for valid entity + if (!$entity instanceof Entities\Policies\Policy) { + return; + } + + $this->enforcer->loadPolicy(); + } + +} diff --git a/src/Types/PolicyType.php b/src/Types/PolicyType.php new file mode 100644 index 0000000..f2a92f0 --- /dev/null +++ b/src/Types/PolicyType.php @@ -0,0 +1,33 @@ + + * @package FastyBird:SimpleAuth! + * @subpackage Entities + * @since 0.1.0 + * + * @date 15.07.24 + */ + +namespace FastyBird\SimpleAuth\Types; + +/** + * Policy type types + * + * @package FastyBird:SimpleAuth! + * @subpackage Types + * + * @author Adam Kadlec + */ +enum PolicyType: string +{ + + case POLICY = 'p'; + + case ROLE = 'g'; + +} diff --git a/tests/cases/unit/BaseTestCase.php b/tests/cases/unit/BaseTestCase.php index 6d1a586..b6ee44b 100644 --- a/tests/cases/unit/BaseTestCase.php +++ b/tests/cases/unit/BaseTestCase.php @@ -11,6 +11,7 @@ use Nettrine; use PHPUnit\Framework\TestCase; use function file_exists; +use function in_array; use function md5; use function time; @@ -21,6 +22,9 @@ abstract class BaseTestCase extends TestCase protected Nettrine\ORM\EntityManagerDecorator $em; + /** @var array */ + protected array $neonFiles = []; + /** * @throws DI\MissingServiceException */ @@ -54,6 +58,10 @@ protected function createContainer(string|null $additionalConfig = null): Nette\ $config->addConfig(__DIR__ . '/../../common.neon'); + foreach ($this->neonFiles as $neonFile) { + $config->addConfig($neonFile); + } + if ($additionalConfig !== null && file_exists($additionalConfig)) { $config->addConfig($additionalConfig); } @@ -81,6 +89,13 @@ private function replaceContainerService(string $serviceName, object $service): $this->container->addService($serviceName, $service); } + protected function registerNeonConfigurationFile(string $file): void + { + if (!in_array($file, $this->neonFiles, true)) { + $this->neonFiles[] = $file; + } + } + /** * @throws ORM\Tools\ToolsException */ diff --git a/tests/cases/unit/Casbin/DatabaseTest.php b/tests/cases/unit/Casbin/DatabaseTest.php new file mode 100644 index 0000000..9a7fec7 --- /dev/null +++ b/tests/cases/unit/Casbin/DatabaseTest.php @@ -0,0 +1,80 @@ +registerNeonConfigurationFile(__DIR__ . '/database.neon'); + + parent::setUp(); + } + + /** + * @throws Casbin\Exceptions\CasbinException + * @throws DI\MissingServiceException + * @throws DoctrineOrmQueryExceptions\InvalidStateException + * @throws DoctrineOrmQueryExceptions\QueryException + */ + public function testPolicy(): void + { + $manager = $this->container->getByType(Models\Policies\Manager::class); + + $repository = $this->container->getByType(Models\Policies\Repository::class); + + $parentId = Uuid\Uuid::fromString('ff11f4fd-c06b-40a2-9a79-6dd3e3a10373'); + + $enforcer = $this->container->getByType(Casbin\Enforcer::class); + + self::assertSame( + ['visitor', 'administrator'], + $enforcer->getAllRoles(), + ); + + self::assertFalse($enforcer->enforce('2784d750-f085-4580-8525-4d622face83d', 'data2', 'read')); + self::assertTrue($enforcer->enforce('2784d750-f085-4580-8525-4d622face83d', 'data3', 'read')); + + $createdPolicy = $manager->create(Utils\ArrayHash::from([ + 'entity' => Fixtures\Entities\TestPolicyEntity::class, + 'parent' => $parentId, + 'type' => Types\PolicyType::POLICY, + 'v0' => '2784d750-f085-4580-8525-4d622face83d', + 'v1' => 'data2', + 'v2' => 'read', + ])); + + $findPolicy = new Queries\FindPolicies(); + $findPolicy->byId($createdPolicy->getId()); + + $foundPolicy = $repository->findOneBy($findPolicy); + + self::assertNotNull($foundPolicy); + + self::assertTrue($enforcer->enforce('2784d750-f085-4580-8525-4d622face83d', 'data2', 'read')); + + $enforcer->deletePermissionForUser('2784d750-f085-4580-8525-4d622face83d', 'data2', 'read'); + + $findPolicy = new Queries\FindPolicies(); + $findPolicy->byId($createdPolicy->getId()); + + $foundPolicy = $repository->findOneBy($findPolicy); + + self::assertNull($foundPolicy); + + self::assertFalse($enforcer->enforce('2784d750-f085-4580-8525-4d622face83d', 'data2', 'read')); + } + +} diff --git a/tests/cases/unit/Casbin/database.neon b/tests/cases/unit/Casbin/database.neon new file mode 100644 index 0000000..04fd836 --- /dev/null +++ b/tests/cases/unit/Casbin/database.neon @@ -0,0 +1,26 @@ +# +# DI configuration +# +# @license More in LICENSE.md +# @copyright https://www.fastybird.com +# @author Adam Kadlec +# @package FastyBird:SimpleAuth! +# @subpackage config +# @since 0.1.0 +# +# @date 15.07.24 + +fbSimpleAuth: + token: + issuer: fb_tester + enable: + middleware: true + doctrine: + mapping: true + models: true + casbin: + database: true + services: + identity: true + casbin: + policy: %appDir%tests/fixtures/policy.csv diff --git a/tests/cases/unit/DI/ExtensionTests.php b/tests/cases/unit/DI/ExtensionTests.php index a2010af..dc1bbb1 100644 --- a/tests/cases/unit/DI/ExtensionTests.php +++ b/tests/cases/unit/DI/ExtensionTests.php @@ -2,6 +2,7 @@ namespace FastyBird\SimpleAuth\Tests\Cases\Unit\DI; +use Casbin; use FastyBird\SimpleAuth; use FastyBird\SimpleAuth\Mapping; use FastyBird\SimpleAuth\Middleware; @@ -37,6 +38,8 @@ public function testServicesRegistration(): void self::assertNotNull($container->getByType(Security\IdentityFactory::class, false)); self::assertNotNull($container->getByType(Security\User::class, false)); self::assertNotNull($container->getByType(Security\UserStorage::class, false)); + + self::assertNotNull($container->getByType(Casbin\Enforcer::class, false)); } } diff --git a/tests/cases/unit/DbTestCase.php b/tests/cases/unit/DbTestCase.php new file mode 100644 index 0000000..75bdfd2 --- /dev/null +++ b/tests/cases/unit/DbTestCase.php @@ -0,0 +1,250 @@ + */ + protected array $sqlFiles = []; + + /** @var array */ + protected array $neonFiles = []; + + /** + * @throws Exceptions\InvalidArgument + * @throws Nette\DI\MissingServiceException + * @throws RuntimeException + */ + public function setUp(): void + { + $this->registerDatabaseSchemaFile(__DIR__ . '/../../sql/dummy.data.sql'); + + parent::setUp(); + + $this->container = $this->createContainer(); + $this->em = $this->container->getByType(NettrineORM\EntityManagerDecorator::class); + + $dateTimeFactory = $this->createMock(DateTimeFactory\Factory::class); + $dateTimeFactory + ->method('getNow') + ->willReturn(new DateTimeImmutable('2020-04-01T12:00:00+00:00')); + + $this->mockContainerService( + DateTimeFactory\Factory::class, + $dateTimeFactory, + ); + } + + /** + * @throws Exceptions\InvalidArgument + * @throws RuntimeException + */ + protected function createContainer(string|null $additionalConfig = null): Nette\DI\Container + { + $rootDir = __DIR__ . '/../../../'; + + $config = new Nette\Bootstrap\Configurator(); + $config->setTempDirectory($rootDir . '/var/tmp'); + + $config->addStaticParameters(['container' => ['class' => 'SystemContainer_' . md5((string) time())]]); + $config->addStaticParameters(['appDir' => $rootDir, 'wwwDir' => $rootDir]); + + $config->addConfig(__DIR__ . '/../../common.neon'); + + foreach ($this->neonFiles as $neonFile) { + $config->addConfig($neonFile); + } + + if ($additionalConfig !== null && file_exists($additionalConfig)) { + $config->addConfig($additionalConfig); + } + + SimpleAuth\DI\SimpleAuthExtension::register($config); + + $this->container = $config->createContainer(); + + $this->setupDatabase(); + + return $this->container; + } + + protected function mockContainerService( + string $serviceType, + object $serviceMock, + ): void + { + $foundServiceNames = $this->container->findByType($serviceType); + + foreach ($foundServiceNames as $serviceName) { + $this->replaceContainerService($serviceName, $serviceMock); + } + } + + private function replaceContainerService(string $serviceName, object $service): void + { + $this->container->removeService($serviceName); + $this->container->addService($serviceName, $service); + } + + protected function registerNeonConfigurationFile(string $file): void + { + if (!in_array($file, $this->neonFiles, true)) { + $this->neonFiles[] = $file; + } + } + + protected function registerDatabaseSchemaFile(string $file): void + { + if (!in_array($file, $this->sqlFiles, true)) { + $this->sqlFiles[] = $file; + } + } + + /** + * @throws Exceptions\InvalidArgument + * @throws Nette\DI\MissingServiceException + * @throws RuntimeException + */ + private function setupDatabase(): void + { + if (!$this->isDatabaseSetUp) { + $db = $this->getDb(); + + /** @var list> $metadata */ + $metadata = $this->getEntityManager()->getMetadataFactory()->getAllMetadata(); + $schemaTool = new ORM\Tools\SchemaTool($this->getEntityManager()); + + $schemas = $schemaTool->getCreateSchemaSql($metadata); + + foreach ($schemas as $sql) { + try { + $db->executeStatement($sql); + } catch (DBAL\Exception) { + throw new RuntimeException('Database schema could not be created'); + } + } + + foreach (array_reverse($this->sqlFiles) as $file) { + $this->loadFromFile($db, $file); + } + + $this->isDatabaseSetUp = true; + } + } + + /** + * @throws Nette\DI\MissingServiceException + * @throws RuntimeException + */ + protected function getDb(): DBAL\Connection + { + return $this->container->getByType(DBAL\Connection::class); + } + + /** + * @throws Nette\DI\MissingServiceException + * @throws RuntimeException + */ + protected function getEntityManager(): NettrineORM\EntityManagerDecorator + { + return $this->container->getByType(NettrineORM\EntityManagerDecorator::class); + } + + /** + * @throws Exceptions\InvalidArgument + */ + private function loadFromFile(DBAL\Connection $db, string $file): void + { + @set_time_limit(0); // intentionally @ + + $handle = @fopen($file, 'r'); // intentionally @ + + if ($handle === false) { + throw new Exceptions\InvalidArgument(sprintf('Cannot open file "%s".', $file)); + } + + $delimiter = ';'; + $sql = ''; + + while (!feof($handle)) { + $content = fgets($handle); + + if ($content !== false) { + $s = rtrim($content); + + if (substr($s, 0, 10) === 'DELIMITER ') { + $delimiter = substr($s, 10); + } elseif (substr($s, -strlen($delimiter)) === $delimiter) { + $sql .= substr($s, 0, -strlen($delimiter)); + + try { + $db->executeQuery($sql); + $sql = ''; + } catch (DBAL\Exception) { + // File could not be loaded + } + } else { + $sql .= $s . "\n"; + } + } + } + + if (trim($sql) !== '') { + try { + $db->executeQuery($sql); + } catch (DBAL\Exception) { + // File could not be loaded + } + } + + fclose($handle); + } + + /** + * @throws RuntimeException + */ + protected function tearDown(): void + { + $this->getDb()->close(); + + $this->isDatabaseSetUp = false; + + parent::tearDown(); + } + +} diff --git a/tests/cases/unit/Middleware/UserMiddlewareTest.php b/tests/cases/unit/Middleware/UserMiddlewareTest.php index e86ea78..b18a368 100644 --- a/tests/cases/unit/Middleware/UserMiddlewareTest.php +++ b/tests/cases/unit/Middleware/UserMiddlewareTest.php @@ -2,6 +2,7 @@ namespace FastyBird\SimpleAuth\Tests\Cases\Unit\Middleware; +use Casbin; use FastyBird\SimpleAuth; use FastyBird\SimpleAuth\Middleware; use FastyBird\SimpleAuth\Security; @@ -22,6 +23,8 @@ final class UserMiddlewareTest extends BaseTestCase */ public function testAllowedPermission(string $url, string $method, string $token, string $id): void { + $enforcer = $this->container->getByType(Casbin\Enforcer::class); + $router = $this->createRouter(); $request = new Psr7\ServerRequest( @@ -37,7 +40,10 @@ public function testAllowedPermission(string $url, string $method, string $token $user = $this->container->getByType(Security\User::class); self::assertSame($id, (string) $user->getId()); - self::assertSame([SimpleAuth\Constants::ROLE_ADMINISTRATOR], $user->getRoles()); + self::assertTrue($enforcer->hasRoleForUser( + $user->getId()?->toString() ?? SimpleAuth\Constants::USER_ANONYMOUS, + SimpleAuth\Constants::ROLE_ADMINISTRATOR, + )); } /** diff --git a/tests/cases/unit/Models/PoliciesRepositoryTest.php b/tests/cases/unit/Models/PoliciesRepositoryTest.php new file mode 100644 index 0000000..6d152de --- /dev/null +++ b/tests/cases/unit/Models/PoliciesRepositoryTest.php @@ -0,0 +1,81 @@ +generateDbSchema(); + + $manager = $this->container->getByType(Models\Policies\Manager::class); + + $repository = $this->container->getByType(Models\Policies\Repository::class); + + $parentId = Uuid\Uuid::fromString('ff11f4fd-c06b-40a2-9a79-6dd3e3a10373'); + + $policyEntity = $manager->create(Utils\ArrayHash::from([ + 'entity' => Fixtures\Entities\TestPolicyEntity::class, + 'parent' => $parentId, + 'type' => Types\PolicyType::POLICY, + 'v0' => '2784d750-f085-4580-8525-4d622face83d', + 'v1' => 'data1', + 'v2' => 'read', + ])); + + self::assertInstanceOf(Fixtures\Entities\TestPolicyEntity::class, $policyEntity); + self::assertSame($parentId->toString(), $policyEntity->getParent()?->toString()); + self::assertSame('read', $policyEntity->getV2()); + + $findPolicy = new Queries\FindPolicies(); + $findPolicy->byId($policyEntity->getId()); + + $foundPolicy = $repository->findOneBy($findPolicy); + + self::assertInstanceOf(Fixtures\Entities\TestPolicyEntity::class, $foundPolicy); + self::assertSame($parentId->toString(), $foundPolicy->getParent()?->toString()); + + $updatedPolicyEntity = $manager->update($policyEntity, Utils\ArrayHash::from([ + 'type' => 'p', + 'v0' => '2784d750-f085-4580-8525-4d622face83d', + 'v1' => 'data1', + 'v2' => 'write', + ])); + + self::assertInstanceOf(Fixtures\Entities\TestPolicyEntity::class, $updatedPolicyEntity); + self::assertSame('write', $policyEntity->getV2()); + + $result = $manager->delete($policyEntity); + + self::assertTrue($result); + + $findPolicy = new Queries\FindPolicies(); + $findPolicy->byId($policyEntity->getId()); + + $foundPolicy = $repository->findOneBy($findPolicy); + + self::assertNull($foundPolicy); + } + +} diff --git a/tests/cases/unit/Models/RepositoryTest.php b/tests/cases/unit/Models/ToknesRepositoryTest.php similarity index 91% rename from tests/cases/unit/Models/RepositoryTest.php rename to tests/cases/unit/Models/ToknesRepositoryTest.php index 2e245c4..d664b07 100644 --- a/tests/cases/unit/Models/RepositoryTest.php +++ b/tests/cases/unit/Models/ToknesRepositoryTest.php @@ -12,7 +12,7 @@ use Nette\DI; use Nette\Utils; -final class RepositoryTest extends BaseTestCase +final class ToknesRepositoryTest extends BaseTestCase { /** @@ -27,9 +27,9 @@ public function testTokenEntity(): void { $this->generateDbSchema(); - $manager = $this->container->getByType(Models\Tokens\TokensManager::class); + $manager = $this->container->getByType(Models\Tokens\Manager::class); - $repository = $this->container->getByType(Models\Tokens\TokenRepository::class); + $repository = $this->container->getByType(Models\Tokens\Repository::class); $tokenEntity = $manager->create(Utils\ArrayHash::from([ 'entity' => Fixtures\Entities\TestTokenEntity::class, diff --git a/tests/common.neon b/tests/common.neon index 03cf8a2..09d5cbd 100644 --- a/tests/common.neon +++ b/tests/common.neon @@ -67,3 +67,5 @@ fbSimpleAuth: models: true services: identity: true + casbin: + policy: %appDir%tests/fixtures/policy.csv diff --git a/tests/fixtures/Entities/TestPolicyEntity.php b/tests/fixtures/Entities/TestPolicyEntity.php new file mode 100644 index 0000000..2d47dec --- /dev/null +++ b/tests/fixtures/Entities/TestPolicyEntity.php @@ -0,0 +1,44 @@ + self::class, +])] +class TestPolicyEntity extends Entities\Policies\Policy +{ + + public const TYPE = 'test_policy'; + + #[IPubDoctrine\Crud(writable: true)] + #[ORM\Column( + name: 'policy_parent', + type: Uuid\Doctrine\UuidBinaryType::NAME, + nullable: true, + options: ['default' => null], + )] + protected Uuid\UuidInterface|null $parent = null; + + public function __construct(Uuid\UuidInterface|null $id = null) + { + parent::__construct(Types\PolicyType::POLICY, $id); + } + + public function setParent(Uuid\UuidInterface $parent): void + { + $this->parent = $parent; + } + + public function getParent(): Uuid\UuidInterface|null + { + return $this->parent; + } + +} diff --git a/tests/fixtures/Entities/TestRoleEntity.php b/tests/fixtures/Entities/TestRoleEntity.php new file mode 100644 index 0000000..373da7f --- /dev/null +++ b/tests/fixtures/Entities/TestRoleEntity.php @@ -0,0 +1,24 @@ + self::class, +])] +class TestRoleEntity extends Entities\Policies\Policy +{ + + public const TYPE = 'test_role'; + + public function __construct(Uuid\UuidInterface|null $id = null) + { + parent::__construct(Types\PolicyType::ROLE, $id); + } + +} diff --git a/tests/fixtures/policy.csv b/tests/fixtures/policy.csv new file mode 100644 index 0000000..58e166a --- /dev/null +++ b/tests/fixtures/policy.csv @@ -0,0 +1,8 @@ +p, 2784d750-f085-4580-8525-4d622face83d, data1, read +p, c450531d-0f10-4587-a0ce-42fb48a8a8ad, data2, write +p, visitor, data2, read +p, administrator, data2, read +p, administrator, data2, write + +g, 2784d750-f085-4580-8525-4d622face83d, visitor +g, 5785924c-75a8-42ae-9bdd-a6ce5edbadac, administrator diff --git a/tests/sql/dummy.data.sql b/tests/sql/dummy.data.sql new file mode 100644 index 0000000..9d3aaf9 --- /dev/null +++ b/tests/sql/dummy.data.sql @@ -0,0 +1,12 @@ +INSERT INTO fb_security_policies (policy_id, p_type, v0, v1, v2, v3, v4, v5, policy_parent, policy_type) VALUES +(X'272379D8835144B6AD8D73A0ABCB7F9C', 'p', '2784d750-f085-4580-8525-4d622face83d', 'data1', 'read', NULL, NULL, NULL, NULL, 'test_policy'), +(X'AB369E71ADA64D1AA5A8B6EE5CD58296', 'p', 'c450531d-0f10-4587-a0ce-42fb48a8a8ad', 'data2', 'write', NULL, NULL, NULL, NULL, 'test_policy'), +(X'89F4A14F7F78421699B8584AB9229F1C', 'p', 'visitor', 'data3', 'read', NULL, NULL, NULL, NULL, 'test_policy'), +(X'C74A16B167F44FFD812A9E5EC4BD5263', 'p', 'administrator', 'data1', 'read', NULL, NULL, NULL, NULL, 'test_policy'), +(X'D721529EDEC647C88035A3484070142B', 'p', 'administrator', 'data1', 'write', NULL, NULL, NULL, NULL, 'test_policy'), +(X'155534434564454DAF040DFEEF08AA96', 'p', 'administrator', 'data2', 'read', NULL, NULL, NULL, NULL, 'test_policy'), +(X'1D60090154E743EE8F5DA9E22663DDD7', 'p', 'administrator', 'data2', 'write', NULL, NULL, NULL, NULL, 'test_policy'), +(X'9d270db23bfc403f88cd27dbf49c9d49', 'p', 'administrator', 'data3', 'read', NULL, NULL, NULL, NULL, 'test_policy'), +(X'27e12426f3c54b6f9c86211d675be143', 'p', 'administrator', 'data3', 'write', NULL, NULL, NULL, NULL, 'test_policy'), +(X'5626E7A1C42C4A319B5D848E3CF0E82A', 'g', '2784d750-f085-4580-8525-4d622face83d', 'visitor', NULL, NULL, NULL, NULL, NULL, 'test_role'), +(X'9A91473298DC47F6BFD19D81CA9F8CB6', 'g', '5785924c-75a8-42ae-9bdd-a6ce5edbadac', 'administrator', NULL, NULL, NULL, NULL, NULL, 'test_role'); \ No newline at end of file diff --git a/tests/tools/ConnectionWrapper.php b/tests/tools/ConnectionWrapper.php new file mode 100644 index 0000000..56eec2c --- /dev/null +++ b/tests/tools/ConnectionWrapper.php @@ -0,0 +1,63 @@ + $params + * + * @throws DBAL\Exception + */ + public function __construct( + array $params, + Driver $driver, + Configuration|null $config = null, + EventManager|null $eventManager = null, + ) + { + $this->dbName = is_string(getenv('TEST_TOKEN')) + ? 'fb_test_' . getmypid() . md5((string) time()) . getenv('TEST_TOKEN') + : 'fb_test_' . getmypid() . md5((string) time()); + + unset($params['dbname']); + + parent::__construct($params, $driver, $config, $eventManager); + } + + public function connect(): bool + { + if (parent::connect()) { + $this->executeStatement(sprintf('DROP DATABASE IF EXISTS `%s`', $this->dbName)); + $this->executeStatement(sprintf('CREATE DATABASE `%s`', $this->dbName)); + $this->executeStatement(sprintf('USE `%s`', $this->dbName)); + + // drop on shutdown + register_shutdown_function( + function (): void { + $this->executeStatement(sprintf('DROP DATABASE IF EXISTS `%s`', $this->dbName)); + }, + ); + + return true; + } + + return false; + } + +} diff --git a/tools/phpcs.xml b/tools/phpcs.xml index 841f6e9..fd3e013 100644 --- a/tools/phpcs.xml +++ b/tools/phpcs.xml @@ -20,6 +20,7 @@ +