diff --git a/docs/Authorization.md b/docs/Authorization.md index b119f802..f8120d45 100644 --- a/docs/Authorization.md +++ b/docs/Authorization.md @@ -103,7 +103,7 @@ Example of a record from a valid roles table: 'modified' => '2010-01-07 03:36:33' ``` -The `alias` values should be slugged as `lowercase-dashed`. +The `alias` values should be slugged as `lowercase-dashed`. Multi words like `Super Admin` would be `super-admin` etc. > Please note that you do NOT need Configure based roles when using database @@ -171,7 +171,7 @@ If you need more control over the prefix map, or want to even customize the role 'authorizeByPrefix' => [ 'Admin => 'admin', 'Management' => ['mod', 'super-mod'], - 'PrefixThree' => ... + 'PrefixThree' => ... ], ``` @@ -242,7 +242,7 @@ view, edit = user * = * ; All roles can access all actions ``` ->**Note:** Prefixes are always `CamelCased`. The route inflects to the final casing if needed. +>**Note:** Prefixes are always `CamelCased`. The route inflects to the final casing if needed. Nested prefixes are joined using `/`, e.g. `MyAdmin/Nested`. Using only "granting" is recommended for security reasons. @@ -263,7 +263,7 @@ Make sure you are using each section key only once, though. The first definition ### Template with defaults See the `config/` folder and the default template for popular plugins. -You can copy out any default rules you want to use in your project. +You can copy out any default rules you want to use in your project. ## Adapters By default INI files and the IniAdapter will be used. @@ -400,6 +400,16 @@ This will then add any missing controller with `* = ...` for all actions and you Note: Use `'*'` as wildcard role if you just want to generate all possible controllers. Use with `-d -v` to just output the changes it would do to your ACL INI file. +## Add Command + +Add any role to any command and action: +``` +bin/cake tiny_auth_add {Controller} {Action} {roles, comma separated} +``` +It will skip if the roles are already present for this controller and action. + +Use with `-d -v` to just output the changes it would do to your ACL INI file. + ## Tips ### Use constants instead of magic strings diff --git a/src/Command/TinyAuthAddCommand.php b/src/Command/TinyAuthAddCommand.php new file mode 100644 index 00000000..ba224b0f --- /dev/null +++ b/src/Command/TinyAuthAddCommand.php @@ -0,0 +1,97 @@ +_getAdder(); + + $controller = $args->getArgument('controller'); + if ($controller === null) { + $controllerNames = $adder->controllers($args); + $io->out('Select a controller:'); + foreach ($controllerNames as $controllerName) { + $io->out(' - ' . $controllerName); + } + while (!$controller || !in_array($controller, $controllerNames, true)) { + $controller = $io->ask('Controller name'); + } + } + + $action = $args->getArgument('action') ?: '*'; + $roles = $args->getArgument('roles') ?: '*'; + $roles = array_map('trim', explode(',', $roles)); + $adder->addAcl($controller, $action, $roles, $args, $io); + $io->out('Controllers and ACL synced.'); + + return static::CODE_SUCCESS; + } + + /** + * @return \TinyAuth\Sync\Adder + */ + protected function _getAdder() { + return new Adder(); + } + + /** + * Gets the option parser instance and configures it. + * + * @param \Cake\Console\ConsoleOptionParser $parser The parser to build + * @return \Cake\Console\ConsoleOptionParser + */ + protected function buildOptionParser(ConsoleOptionParser $parser): ConsoleOptionParser { + $roles = $this->_getAvailableRoles(); + + $parser->setDescription( + 'Get the list of controllers and make sure, they are synced into the ACL file.', + )->addArgument('controller', [ + 'help' => 'Controller name (Plugin.Prefix/Name) without Controller suffix.', + 'required' => false, + ])->addArgument('action', [ + 'help' => 'Action name (camelCased or under_scored), defaults to `*` (all).', + 'required' => false, + ])->addArgument('roles', [ + 'help' => 'Role names, comma separated, e.g. `user,admin`, defaults to `*` (all).' . ($roles ? PHP_EOL . 'Available roles: ' . implode(', ', $roles) . '.' : ''), + 'required' => false, + ])->addOption('plugin', [ + 'short' => 'p', + 'help' => 'Plugin, use `all` to include all loaded plugins.', + 'default' => null, + ])->addOption('dry-run', [ + 'short' => 'd', + 'help' => 'Dry Run (only output, do not modify INI files).', + 'boolean' => true, + ]); + + return $parser; + } + + /** + * @return array + */ + protected function _getAvailableRoles() { + $roles = (new TinyAuth())->getAvailableRoles(); + + return array_keys($roles); + } + +} diff --git a/src/Command/TinyAuthSyncCommand.php b/src/Command/TinyAuthSyncCommand.php index 7abf5391..e7e80006 100644 --- a/src/Command/TinyAuthSyncCommand.php +++ b/src/Command/TinyAuthSyncCommand.php @@ -4,8 +4,6 @@ use Cake\Command\Command; use Cake\Console\Arguments; -use Cake\Console\CommandCollection; -use Cake\Console\CommandCollectionAwareInterface; use Cake\Console\ConsoleIo; use Cake\Console\ConsoleOptionParser; use TinyAuth\Sync\Syncer; @@ -14,22 +12,7 @@ /** * Auth and ACL helper */ -class TinyAuthSyncCommand extends Command implements CommandCollectionAwareInterface { - - /** - * The command collection to get help on. - * - * @var \Cake\Console\CommandCollection - */ - protected $commands; - - /** - * @param \Cake\Console\CommandCollection $commands The commands to use. - * @return void - */ - public function setCommandCollection(CommandCollection $commands): void { - $this->commands = $commands; - } +class TinyAuthSyncCommand extends Command { /** * Main function Prints out the list of shells. diff --git a/src/Sync/Adder.php b/src/Sync/Adder.php new file mode 100644 index 00000000..991345fa --- /dev/null +++ b/src/Sync/Adder.php @@ -0,0 +1,213 @@ + + */ + protected array $config; + + public function __construct() { + $defaults = [ + 'aclFile' => 'auth_acl.ini', + 'aclFilePath' => null, + ]; + $this->config = (array)Configure::read('TinyAuth') + $defaults; + } + + /** + * @var array|null + */ + protected $authAllow; + + /** + * @param string $controller + * @param string $action + * @param array $roles + * @param \Cake\Console\Arguments $args + * @param \Cake\Console\ConsoleIo $io + * + * @return void + */ + public function addAcl(string $controller, string $action, array $roles, Arguments $args, ConsoleIo $io) { + $path = $this->config['aclFilePath'] ?: ROOT . DS . 'config' . DS; + $file = $path . $this->config['aclFile']; + $content = Utility::parseFile($file); + + if (isset($content[$controller][$action]) || isset($content[$controller]['*'])) { + $mappedRoles = $content[$controller][$action] ?? $content[$controller]['*']; + if (strpos($mappedRoles, ',') !== false) { + $mappedRoles = array_map('trim', explode(',', $mappedRoles)); + } + $this->checkRoles($roles, (array)$mappedRoles, $io); + } + + $io->info('Add [' . $controller . '] ' . $action . ' = ' . implode(', ', $roles)); + $content[$controller][$action] = implode(', ', $roles); + + if ($args->getOption('dry-run')) { + $string = Utility::buildIniString($content); + + if ($args->getOption('verbose')) { + $io->info('=== ' . $this->config['aclFile'] . ' ==='); + $io->info($string); + $io->info('=== ' . $this->config['aclFile'] . ' end ==='); + } + + return; + } + + Utility::generateFile($file, $content); + } + + /** + * @param string|null $plugin + * @return array + */ + protected function _getControllers($plugin) { + if ($plugin === 'all') { + $plugins = Plugin::loaded(); + + $controllers = []; + foreach ($plugins as $plugin) { + $controllers = array_merge($controllers, $this->_getControllers($plugin)); + } + + return $controllers; + } + + $folders = App::classPath('Controller', $plugin); + + $controllers = []; + foreach ($folders as $folder) { + $controllers = array_merge($controllers, $this->_parseControllers($folder, $plugin)); + } + + return $controllers; + } + + /** + * @param string $folder Path + * @param string|null $plugin + * @param string|null $prefix + * + * @return array + */ + protected function _parseControllers($folder, $plugin, $prefix = null) { + $folderContent = (new Folder($folder))->read(Folder::SORT_NAME, true); + + $controllers = []; + foreach ($folderContent[1] as $file) { + $className = pathinfo($file, PATHINFO_FILENAME); + + if (!preg_match('#^(.+)Controller$#', $className, $matches)) { + continue; + } + $name = $matches[1]; + if ($matches[1] === 'App') { + continue; + } + + if ($this->_noAuthenticationNeeded($name, $plugin, $prefix)) { + continue; + } + + $controllers[] = ($plugin ? $plugin . '.' : '') . ($prefix ? $prefix . '/' : '') . $name; + } + + foreach ($folderContent[0] as $subFolder) { + $prefixes = (array)Configure::read('TinyAuth.prefixes') ?: null; + + if ($prefixes !== null && !in_array($subFolder, $prefixes, true)) { + continue; + } + + $controllers = array_merge($controllers, $this->_parseControllers($folder . $subFolder . DS, $plugin, $subFolder)); + } + + return $controllers; + } + + /** + * @param string $name + * @param string|null $plugin + * @param string|null $prefix + * @return bool + */ + protected function _noAuthenticationNeeded($name, $plugin, $prefix) { + if (!isset($this->authAllow)) { + $this->authAllow = $this->_parseAuthAllow(); + } + + $key = $name; + if (!isset($this->authAllow[$key])) { + return false; + } + + if ($this->authAllow[$key] === '*') { + return true; + } + + //TODO: specific actions? + return false; + } + + /** + * @return array + */ + protected function _parseAuthAllow() { + $defaults = [ + 'allowFilePath' => null, + 'allowFile' => 'auth_allow.ini', + ]; + $config = (array)Configure::read('TinyAuth') + $defaults; + + $path = $config['allowFilePath'] ?: ROOT . DS . 'config' . DS; + $file = $path . $config['allowFile']; + + return Utility::parseFile($file); + } + + /** + * @param \Cake\Console\Arguments $args + * + * @return array + */ + public function controllers(Arguments $args): array { + //$path = $this->config['aclFilePath'] ?: ROOT . DS . 'config' . DS; + //$file = $path . $this->config['aclFile']; + //$content = Utility::parseFile($file); + + $controllers = $this->_getControllers((string)$args->getOption('plugin') ?: null); + + return $controllers; + } + + /** + * @param array $roles + * @param array $mappedRoles + * @param \Cake\Console\ConsoleIo $io + * + * @return void + */ + protected function checkRoles(array $roles, array $mappedRoles, ConsoleIo $io): void { + foreach ($roles as $role) { + if (!in_array($role, $mappedRoles, true) && !in_array('*', $mappedRoles, true)) { + return; + } + } + + $io->abort('Already present. Aborting'); + } + +} diff --git a/tests/TestCase/Command/TinyAuthAddCommandTest.php b/tests/TestCase/Command/TinyAuthAddCommandTest.php new file mode 100644 index 00000000..e9582bbe --- /dev/null +++ b/tests/TestCase/Command/TinyAuthAddCommandTest.php @@ -0,0 +1,49 @@ + 1, + 'moderator' => 2, + 'admin' => 3, + ]); + + $this->setAppNamespace(); + } + + /** + * @return void + */ + public function testAdd() { + Configure::write('TinyAuth.aclFilePath', TESTS . 'test_files/subfolder/'); + Configure::write('TinyAuth.allowFilePath', TESTS . 'test_files/'); + + $folder = new Folder(); + $folder->copy('/tmp' . DS . 'src' . DS . 'Controller' . DS, ['from' => TESTS . 'test_app' . DS . 'Controller' . DS]); + + $this->exec('tiny_auth_add Some action foo,bar -d -v'); + + $this->assertExitCode(Command::CODE_SUCCESS); + $this->assertOutputContains('[Some]'); + $this->assertOutputContains('action = foo, bar'); + } + +} diff --git a/tests/TestCase/Command/TinyAuthSyncCommandTest.php b/tests/TestCase/Command/TinyAuthSyncCommandTest.php index 47989a6c..86281b3a 100644 --- a/tests/TestCase/Command/TinyAuthSyncCommandTest.php +++ b/tests/TestCase/Command/TinyAuthSyncCommandTest.php @@ -27,7 +27,6 @@ public function setUp(): void { ]); $this->setAppNamespace(); - //$this->useCommandRunner(); } /**