diff --git a/src/Command/User/UpgradePermissionsSchemaCommand.php b/src/Command/User/UpgradePermissionsSchemaCommand.php new file mode 100644 index 00000000..a4a02e00 --- /dev/null +++ b/src/Command/User/UpgradePermissionsSchemaCommand.php @@ -0,0 +1,122 @@ +. + */ + +namespace App\Command\User; + +use App\Entity\UserSystem\Group; +use App\Entity\UserSystem\PermissionData; +use App\Entity\UserSystem\User; +use App\Services\UserSystem\PermissionSchemaUpdater; +use Doctrine\ORM\EntityManagerInterface; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; + +final class UpgradePermissionsSchemaCommand extends Command +{ + protected static $defaultName = 'partdb:users:upgrade-permissions-schema'; + protected static $defaultDescription = '(Manually) upgrades the permissions schema of all users to the latest version.'; + + private PermissionSchemaUpdater $permissionSchemaUpdater; + private EntityManagerInterface $em; + + public function __construct(PermissionSchemaUpdater $permissionSchemaUpdater, EntityManagerInterface $entityManager) + { + parent::__construct(self::$defaultName); + + $this->permissionSchemaUpdater = $permissionSchemaUpdater; + $this->em = $entityManager; + } + + protected function configure(): void + { + $this + ->setDescription(self::$defaultDescription) + ; + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + $io->info('Target schema version number: '. PermissionData::CURRENT_SCHEMA_VERSION); + + //Retrieve all users and groups + $users = $this->em->getRepository(User::class)->findAll(); + $groups = $this->em->getRepository(Group::class)->findAll(); + + //Check which users and groups need an update + $groups_to_upgrade = []; + $users_to_upgrade = []; + foreach ($groups as $group) { + if ($this->permissionSchemaUpdater->isSchemaUpdateNeeded($group)) { + $groups_to_upgrade[] = $group; + } + } + foreach ($users as $user) { + if ($this->permissionSchemaUpdater->isSchemaUpdateNeeded($user)) { + $users_to_upgrade[] = $user; + } + } + + $io->info('Found '. count($groups_to_upgrade) .' groups and '. count($users_to_upgrade) .' users that need an update.'); + if (empty($groups_to_upgrade) && empty($users_to_upgrade)) { + $io->success('All users and group permissions schemas are up-to-date. No update needed.'); + + return 0; + } + + //List all users and groups that need an update + $io->section('Groups that need an update:'); + $io->listing(array_map(function (Group $group) { + return $group->getName() . ' (ID: '. $group->getID() .', Current version: ' . $group->getPermissions()->getSchemaVersion() . ')'; + }, $groups_to_upgrade)); + + $io->section('Users that need an update:'); + $io->listing(array_map(function (User $user) { + return $user->getUsername() . ' (ID: '. $user->getID() .', Current version: ' . $user->getPermissions()->getSchemaVersion() . ')'; + }, $users_to_upgrade)); + + if(!$io->confirm('Continue with the update?', false)) { + $io->warning('Update aborted.'); + return 0; + } + + //Update all users and groups + foreach ($groups_to_upgrade as $group) { + $io->writeln('Updating group '. $group->getName() .' (ID: '. $group->getID() .') to schema version '. PermissionData::CURRENT_SCHEMA_VERSION .'...', OutputInterface::VERBOSITY_VERBOSE); + $this->permissionSchemaUpdater->upgradeSchema($group); + } + foreach ($users_to_upgrade as $user) { + $io->writeln('Updating user '. $user->getUsername() .' (ID: '. $user->getID() .') to schema version '. PermissionData::CURRENT_SCHEMA_VERSION .'...', OutputInterface::VERBOSITY_VERBOSE); + $this->permissionSchemaUpdater->upgradeSchema($user); + } + + //Write changes to database + $this->em->flush(); + + $io->success('All users and groups have been updated to the latest permissions schema version.'); + + return Command::SUCCESS; + } +} diff --git a/src/Entity/UserSystem/PermissionData.php b/src/Entity/UserSystem/PermissionData.php index b5881a14..30fa48be 100644 --- a/src/Entity/UserSystem/PermissionData.php +++ b/src/Entity/UserSystem/PermissionData.php @@ -37,6 +37,11 @@ final class PermissionData implements \JsonSerializable public const ALLOW = true; public const DISALLOW = false; + /** + * The current schema version of the permission data + */ + public const CURRENT_SCHEMA_VERSION = 1; + /** * @var array This array contains the permission values for each permission * This array contains the permission values for each permission, in the form of: @@ -45,7 +50,10 @@ final class PermissionData implements \JsonSerializable * ] * @ORM\Column(type="json", name="data", options={"default": "[]"}) */ - protected ?array $data = []; + protected ?array $data = [ + //$ prefixed entries are used for metadata + '$ver' => self::CURRENT_SCHEMA_VERSION, //The schema version of the permission data + ]; /** * Creates a new Permission Data Instance using the given data. @@ -54,6 +62,11 @@ final class PermissionData implements \JsonSerializable public function __construct(array $data = []) { $this->data = $data; + + //If the passed data did not contain a schema version, we set it to the current version + if (!isset($this->data['$ver'])) { + $this->data['$ver'] = self::CURRENT_SCHEMA_VERSION; + } } /** @@ -64,6 +77,11 @@ final class PermissionData implements \JsonSerializable */ public function isPermissionSet(string $permission, string $operation): bool { + //We cannot access metadata via normal permission data + if (strpos($permission, '$') !== false) { + return false; + } + return isset($this->data[$permission][$operation]); } @@ -155,4 +173,29 @@ final class PermissionData implements \JsonSerializable return $ret; } + + /** + * Returns the schema version of the permission data. + * @return int The schema version of the permission data + */ + public function getSchemaVersion(): int + { + return $this->data['$ver'] ?? 0; + } + + /** + * Sets the schema version of this permission data + * @param int $new_version + * @return $this + */ + public function setSchemaVersion(int $new_version): self + { + if ($new_version < 0) { + throw new \InvalidArgumentException('The schema version must be a positive integer'); + } + + $this->data['$ver'] = $new_version; + return $this; + } + } \ No newline at end of file diff --git a/src/Services/UserSystem/PermissionSchemaUpdater.php b/src/Services/UserSystem/PermissionSchemaUpdater.php new file mode 100644 index 00000000..fabad3db --- /dev/null +++ b/src/Services/UserSystem/PermissionSchemaUpdater.php @@ -0,0 +1,95 @@ +. + */ + +namespace App\Services\UserSystem; + +use App\Entity\UserSystem\PermissionData; +use App\Security\Interfaces\HasPermissionsInterface; + +class PermissionSchemaUpdater +{ + /** + * Check if the given user/group needs an update of its permission schema. + * @param HasPermissionsInterface $holder + * @return bool True if the permission schema needs an update, false otherwise. + */ + public function isSchemaUpdateNeeded(HasPermissionsInterface $holder): bool + { + $perm_data = $holder->getPermissions(); + + if ($perm_data->getSchemaVersion() < PermissionData::CURRENT_SCHEMA_VERSION) { + return true; + } + + return false; + } + + /** + * Upgrades the permission schema of the given user/group to the chosen version + * @param HasPermissionsInterface $holder + * @param int $target_version + * @return bool True, if an upgrade was done, false if it was not needed. + */ + public function upgradeSchema(HasPermissionsInterface $holder, int $target_version = PermissionData::CURRENT_SCHEMA_VERSION): bool + { + if ($target_version > PermissionData::CURRENT_SCHEMA_VERSION) { + throw new \InvalidArgumentException('The target version is higher than the maximum possible schema version!'); + } + + //Check if we need to do an update, if not, return false + if ($target_version <= $holder->getPermissions()->getSchemaVersion()) { + return false; + } + + //Do the update + for ($n = $holder->getPermissions()->getSchemaVersion(); $n < $target_version; ++$n) { + $reflectionClass = new \ReflectionClass(self::class); + try { + $method = $reflectionClass->getMethod('upgradeSchemaToVersion'.($n + 1)); + $method->invoke($this, $holder); + } catch (\ReflectionException $e) { + throw new \RuntimeException('Could not find update method for schema version '.($n + 1)); + } + + //Bump the schema version + $holder->getPermissions()->setSchemaVersion($n + 1); + } + + //When we end up here, we have done an upgrade and we can return true + return true; + } + + private function upgradeSchemaToVersion1(HasPermissionsInterface $holder): void + { + //Use the part edit permission to set the preset value for the new part stock permission + if ( + !$holder->getPermissions()->isPermissionSet('parts_stock', 'withdraw') + && !$holder->getPermissions()->isPermissionSet('parts_stock', 'add') + && !$holder->getPermissions()->isPermissionSet('parts_stock', 'move') + ) { //Only do migration if the permission was not set already + + $new_value = $holder->getPermissions()->getPermissionValue('parts', 'edit'); + + $holder->getPermissions()->setPermissionValue('parts_stock', 'withdraw', $new_value); + $holder->getPermissions()->setPermissionValue('parts_stock', 'add', $new_value); + $holder->getPermissions()->setPermissionValue('parts_stock', 'move', $new_value); + } + } +} \ No newline at end of file diff --git a/tests/Entity/UserSystem/PermissionDataTest.php b/tests/Entity/UserSystem/PermissionDataTest.php index 131d31b6..681c5082 100644 --- a/tests/Entity/UserSystem/PermissionDataTest.php +++ b/tests/Entity/UserSystem/PermissionDataTest.php @@ -146,4 +146,16 @@ class PermissionDataTest extends TestCase $this->assertFalse($data->isPermissionSet('perm1', 'op2')); $this->assertFalse($data->isPermissionSet('perm1', 'op3')); } + + public function testGetSchemaVersion() + { + $data = new PermissionData(); + + //By default the schema version must be the CURRENT_SCHEMA_VERSION + $this->assertEquals(PermissionData::CURRENT_SCHEMA_VERSION, $data->getSchemaVersion()); + + //Ensure that the schema version can be set + $data->setSchemaVersion(12345); + $this->assertEquals(12345, $data->getSchemaVersion()); + } } diff --git a/tests/Services/UserSystem/PermissionSchemaUpdaterTest.php b/tests/Services/UserSystem/PermissionSchemaUpdaterTest.php new file mode 100644 index 00000000..3ed48cf5 --- /dev/null +++ b/tests/Services/UserSystem/PermissionSchemaUpdaterTest.php @@ -0,0 +1,100 @@ +. + */ + +namespace App\Tests\Services\UserSystem; + +use App\Entity\UserSystem\PermissionData; +use App\Security\Interfaces\HasPermissionsInterface; +use App\Services\UserSystem\PermissionSchemaUpdater; +use PHPUnit\Framework\TestCase; +use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; + +class TestPermissionHolder implements HasPermissionsInterface +{ + private PermissionData $perm_data; + + public function __construct(PermissionData $perm_data) + { + $this->perm_data = $perm_data; + } + + public function getPermissions(): PermissionData + { + return $this->perm_data; + } +} + +class PermissionSchemaUpdaterTest extends WebTestCase +{ + /** + * @var PermissionSchemaUpdater + */ + protected $service; + + public function setUp(): void + { + parent::setUp(); + self::bootKernel(); + + $this->service = self::$container->get(PermissionSchemaUpdater::class); + } + + public function testIsSchemaUpdateNeeded() + { + $perm_data = new PermissionData(); + $perm_data->setSchemaVersion(0); + $user = new TestPermissionHolder($perm_data); + + //With schema version 0, an update should be needed + self::assertTrue($this->service->isSchemaUpdateNeeded($user)); + + //With a very high scheme number no update should be needed + $perm_data->setSchemaVersion(123456); + self::assertFalse($this->service->isSchemaUpdateNeeded($user)); + } + + public function testUpgradeSchema() + { + $perm_data = new PermissionData(); + $perm_data->setSchemaVersion(0); + $user = new TestPermissionHolder($perm_data); + + //With schema version 0, an update should be done and the schema version should be updated + self::assertTrue($this->service->upgradeSchema($user)); + self::assertEquals(PermissionData::CURRENT_SCHEMA_VERSION, $user->getPermissions()->getSchemaVersion()); + + //If we redo it with the same schema version, no update should be done + self::assertFalse($this->service->upgradeSchema($user)); + } + + public function testUpgradeSchemaToVersion1() + { + $perm_data = new PermissionData(); + $perm_data->setSchemaVersion(0); + $perm_data->setPermissionValue('parts', 'edit', PermissionData::ALLOW); + $user = new TestPermissionHolder($perm_data); + + //Do an upgrade and afterwards the move, add, and withdraw permissions should be set to ALLOW + self::assertTrue($this->service->upgradeSchema($user, 1)); + self::assertEquals(PermissionData::ALLOW, $user->getPermissions()->getPermissionValue('parts_stock', 'move')); + self::assertEquals(PermissionData::ALLOW, $user->getPermissions()->getPermissionValue('parts_stock', 'add')); + self::assertEquals(PermissionData::ALLOW, $user->getPermissions()->getPermissionValue('parts_stock', 'withdraw')); + } +}