From a723051d115c86dd776a3f92f90e311008c3655f Mon Sep 17 00:00:00 2001 From: Sebastian Krupinski Date: Fri, 24 Jul 2026 21:58:16 -0400 Subject: [PATCH] feat: console role management Signed-off-by: Sebastian Krupinski --- core/lib/Console/Role/RoleAssignCommand.php | 102 ++++++++++++++++ core/lib/Console/Role/RoleCreateCommand.php | 109 ++++++++++++++++++ core/lib/Console/Role/RoleDeleteCommand.php | 103 +++++++++++++++++ core/lib/Console/Role/RoleListCommand.php | 82 +++++++++++++ core/lib/Console/Role/RoleRevokeCommand.php | 95 +++++++++++++++ .../Console/Tenant/TenantCreateCommand.php | 53 ++++++++- core/lib/Module/Module.php | 5 + 7 files changed, 548 insertions(+), 1 deletion(-) create mode 100644 core/lib/Console/Role/RoleAssignCommand.php create mode 100644 core/lib/Console/Role/RoleCreateCommand.php create mode 100644 core/lib/Console/Role/RoleDeleteCommand.php create mode 100644 core/lib/Console/Role/RoleListCommand.php create mode 100644 core/lib/Console/Role/RoleRevokeCommand.php diff --git a/core/lib/Console/Role/RoleAssignCommand.php b/core/lib/Console/Role/RoleAssignCommand.php new file mode 100644 index 0000000..c45cad4 --- /dev/null +++ b/core/lib/Console/Role/RoleAssignCommand.php @@ -0,0 +1,102 @@ +addArgument('tenant', InputArgument::REQUIRED, 'Tenant identifier') + ->addArgument('identity', InputArgument::REQUIRED, 'User identity (email/username)') + ->addArgument('rid', InputArgument::REQUIRED, 'Role id to assign') + ->setHelp('This command assigns a role to a user account.') + ; + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $tenant = $input->getArgument('tenant'); + $identity = $input->getArgument('identity'); + $rid = $input->getArgument('rid'); + + $io->title('Assign Role'); + + try { + $user = $this->userStore->fetchByIdentity($tenant, $identity); + if (!$user) { + $io->error("User '{$identity}' not found in tenant '{$tenant}'."); + return Command::FAILURE; + } + + if (!$this->rolesStore->fetchByRid($tenant, $rid)) { + $io->error("Role '{$rid}' not found in tenant '{$tenant}'."); + return Command::FAILURE; + } + + $roles = (array)($user['roles'] ?? []); + if (in_array($rid, $roles, true)) { + $io->text("User '{$identity}' already has role '{$rid}'."); + return Command::SUCCESS; + } + + $roles[] = $rid; + + if (!$this->userStore->updateUser($tenant, $user['uid'], ['roles' => array_values($roles)])) { + $io->error("Failed to assign role '{$rid}' to '{$identity}'."); + return Command::FAILURE; + } + + $this->logger->info('Role assigned via console', [ + 'tenant' => $tenant, + 'identity' => $identity, + 'rid' => $rid, + 'command' => $this->getName(), + ]); + + $io->success("Role '{$rid}' assigned to '{$identity}' successfully!"); + + return Command::SUCCESS; + + } catch (\Throwable $e) { + $io->error('Failed to assign role: ' . $e->getMessage()); + $this->logger->error('Role assign failed', [ + 'tenant' => $tenant, + 'identity' => $identity, + 'rid' => $rid, + 'error' => $e->getMessage(), + ]); + return Command::FAILURE; + } + } +} diff --git a/core/lib/Console/Role/RoleCreateCommand.php b/core/lib/Console/Role/RoleCreateCommand.php new file mode 100644 index 0000000..0cb05c6 --- /dev/null +++ b/core/lib/Console/Role/RoleCreateCommand.php @@ -0,0 +1,109 @@ +addArgument('tenant', InputArgument::REQUIRED, 'Tenant identifier') + ->addArgument('label', InputArgument::REQUIRED, 'Display label for the role') + ->addOption('description', 'd', InputOption::VALUE_REQUIRED, 'Role description', '') + ->addOption('permission', 'p', InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Permission(s) to grant (repeatable, e.g. --permission "*")', []) + ->addOption('rid', null, InputOption::VALUE_REQUIRED, 'Explicit role id (defaults to a generated UUID)') + ->addOption('system', null, InputOption::VALUE_NONE, 'Mark the role as a protected system role (cannot be updated or deleted)') + ->setHelp('This command creates a new role in a tenant. Permissions are free-form strings matched by the runtime permission checker, including wildcard suffixes like "user_manager.role.*" or the full wildcard "*".') + ; + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $tenant = $input->getArgument('tenant'); + $label = $input->getArgument('label'); + $rid = $input->getOption('rid'); + + $io->title('Create Role'); + + try { + if (!$this->tenantService->fetchById($tenant)) { + $io->error("Tenant '{$tenant}' not found."); + return Command::FAILURE; + } + + if ($rid !== null && $this->rolesStore->fetchByRid($tenant, $rid)) { + $io->error("Role '{$rid}' already exists in tenant '{$tenant}'."); + return Command::FAILURE; + } + + $roleData = [ + 'label' => $label, + 'description' => $input->getOption('description') ?? '', + 'permissions' => $input->getOption('permission'), + 'system' => $input->getOption('system'), + ]; + + if ($rid !== null) { + $roleData['rid'] = $rid; + } + + $role = $this->rolesStore->createRole($tenant, $roleData); + + $this->logger->info('Role created via console', [ + 'tenant' => $tenant, + 'rid' => $role['rid'] ?? null, + 'command' => $this->getName(), + ]); + + $io->success("Role '{$label}' created successfully!"); + $io->definitionList( + ['Rid' => $role['rid'] ?? ''], + ['Label' => $role['label'] ?? ''], + ['System' => !empty($role['system']) ? 'yes' : 'no'], + ['Permissions' => implode(', ', (array)($role['permissions'] ?? []))], + ); + + return Command::SUCCESS; + + } catch (\Throwable $e) { + $io->error('Failed to create role: ' . $e->getMessage()); + $this->logger->error('Role create failed', [ + 'tenant' => $tenant, + 'label' => $label, + 'error' => $e->getMessage(), + ]); + return Command::FAILURE; + } + } +} diff --git a/core/lib/Console/Role/RoleDeleteCommand.php b/core/lib/Console/Role/RoleDeleteCommand.php new file mode 100644 index 0000000..5fea740 --- /dev/null +++ b/core/lib/Console/Role/RoleDeleteCommand.php @@ -0,0 +1,103 @@ +addArgument('tenant', InputArgument::REQUIRED, 'Tenant identifier') + ->addArgument('rid', InputArgument::REQUIRED, 'Role id') + ->addOption('force', 'f', InputOption::VALUE_NONE, 'Skip confirmation prompt') + ->setHelp('This command deletes a role from a tenant. System roles cannot be deleted, and roles still assigned to users cannot be deleted.') + ; + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $tenant = $input->getArgument('tenant'); + $rid = $input->getArgument('rid'); + $force = $input->getOption('force'); + + $io->title('Delete Role'); + + try { + $role = $this->rolesStore->fetchByRid($tenant, $rid); + + if (!$role) { + $io->error("Role '{$rid}' not found in tenant '{$tenant}'."); + return Command::FAILURE; + } + + if ($role['system'] ?? false) { + $io->error("Role '{$rid}' is a system role and cannot be deleted."); + return Command::FAILURE; + } + + $userCount = $this->rolesStore->countUsersInRole($tenant, $rid); + if ($userCount > 0) { + $io->error("Role '{$rid}' is assigned to {$userCount} user(s) and cannot be deleted."); + return Command::FAILURE; + } + + if (!$force && !$io->confirm("Are you sure you want to delete role '{$rid}' from tenant '{$tenant}'?", false)) { + $io->text('Delete cancelled.'); + return Command::SUCCESS; + } + + if (!$this->rolesStore->deleteRole($tenant, $rid)) { + $io->error("Failed to delete role '{$rid}'."); + return Command::FAILURE; + } + + $this->logger->info('Role deleted via console', [ + 'tenant' => $tenant, + 'rid' => $rid, + 'command' => $this->getName(), + ]); + + $io->success("Role '{$rid}' deleted successfully!"); + + return Command::SUCCESS; + + } catch (\Throwable $e) { + $io->error('Failed to delete role: ' . $e->getMessage()); + $this->logger->error('Role delete failed', [ + 'tenant' => $tenant, + 'rid' => $rid, + 'error' => $e->getMessage(), + ]); + return Command::FAILURE; + } + } +} diff --git a/core/lib/Console/Role/RoleListCommand.php b/core/lib/Console/Role/RoleListCommand.php new file mode 100644 index 0000000..e1bf7bd --- /dev/null +++ b/core/lib/Console/Role/RoleListCommand.php @@ -0,0 +1,82 @@ +addArgument('tenant', InputArgument::REQUIRED, 'Tenant identifier') + ->setHelp('This command lists all roles in a tenant.') + ; + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $tenant = $input->getArgument('tenant'); + + $io->title("Roles in tenant '{$tenant}'"); + + try { + if (!$this->tenantService->fetchById($tenant)) { + $io->error("Tenant '{$tenant}' not found."); + return Command::FAILURE; + } + + $roles = $this->rolesStore->listRoles($tenant); + + if (empty($roles)) { + $io->text('No roles found.'); + return Command::SUCCESS; + } + + $rows = []; + foreach ($roles as $role) { + $rows[] = [ + $role['rid'] ?? '', + $role['label'] ?? '', + !empty($role['system']) ? 'yes' : 'no', + implode(', ', (array)($role['permissions'] ?? [])), + ]; + } + + $io->table(['Rid', 'Label', 'System', 'Permissions'], $rows); + $io->text(sprintf('Total: %d role(s)', count($rows))); + + return Command::SUCCESS; + + } catch (\Throwable $e) { + $io->error('Failed to list roles: ' . $e->getMessage()); + return Command::FAILURE; + } + } +} diff --git a/core/lib/Console/Role/RoleRevokeCommand.php b/core/lib/Console/Role/RoleRevokeCommand.php new file mode 100644 index 0000000..552dd01 --- /dev/null +++ b/core/lib/Console/Role/RoleRevokeCommand.php @@ -0,0 +1,95 @@ +addArgument('tenant', InputArgument::REQUIRED, 'Tenant identifier') + ->addArgument('identity', InputArgument::REQUIRED, 'User identity (email/username)') + ->addArgument('rid', InputArgument::REQUIRED, 'Role id to revoke') + ->setHelp('This command revokes a role from a user account.') + ; + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $tenant = $input->getArgument('tenant'); + $identity = $input->getArgument('identity'); + $rid = $input->getArgument('rid'); + + $io->title('Revoke Role'); + + try { + $user = $this->userStore->fetchByIdentity($tenant, $identity); + if (!$user) { + $io->error("User '{$identity}' not found in tenant '{$tenant}'."); + return Command::FAILURE; + } + + $roles = (array)($user['roles'] ?? []); + if (!in_array($rid, $roles, true)) { + $io->text("User '{$identity}' does not have role '{$rid}'."); + return Command::SUCCESS; + } + + $roles = array_values(array_filter($roles, fn($r) => $r !== $rid)); + + if (!$this->userStore->updateUser($tenant, $user['uid'], ['roles' => $roles])) { + $io->error("Failed to revoke role '{$rid}' from '{$identity}'."); + return Command::FAILURE; + } + + $this->logger->info('Role revoked via console', [ + 'tenant' => $tenant, + 'identity' => $identity, + 'rid' => $rid, + 'command' => $this->getName(), + ]); + + $io->success("Role '{$rid}' revoked from '{$identity}' successfully!"); + + return Command::SUCCESS; + + } catch (\Throwable $e) { + $io->error('Failed to revoke role: ' . $e->getMessage()); + $this->logger->error('Role revoke failed', [ + 'tenant' => $tenant, + 'identity' => $identity, + 'rid' => $rid, + 'error' => $e->getMessage(), + ]); + return Command::FAILURE; + } + } +} diff --git a/core/lib/Console/Tenant/TenantCreateCommand.php b/core/lib/Console/Tenant/TenantCreateCommand.php index 280b817..aa8ca93 100644 --- a/core/lib/Console/Tenant/TenantCreateCommand.php +++ b/core/lib/Console/Tenant/TenantCreateCommand.php @@ -8,6 +8,8 @@ use KTXC\Models\Tenant\DomainCollection; use KTXC\Models\Tenant\TenantConfiguration; use KTXC\Models\Tenant\TenantObject; use KTXC\Service\TenantService; +use KTXC\Stores\UserAccountsStore; +use KTXC\Stores\UserRolesStore; use KTXF\Utile\UUID; use Psr\Log\LoggerInterface; use Symfony\Component\Console\Attribute\AsCommand; @@ -31,6 +33,8 @@ class TenantCreateCommand extends Command { public function __construct( private readonly TenantService $tenantService, + private readonly UserRolesStore $rolesStore, + private readonly UserAccountsStore $userStore, private readonly LoggerInterface $logger ) { parent::__construct(); @@ -44,7 +48,9 @@ class TenantCreateCommand extends Command ->addOption('description', 'd', InputOption::VALUE_REQUIRED, 'Tenant description') ->addOption('identifier', null, InputOption::VALUE_REQUIRED, 'Explicit tenant identifier (defaults to a generated UUID)') ->addOption('disabled', null, InputOption::VALUE_NONE, 'Create the tenant in a disabled state') - ->setHelp('This command creates a new tenant with one or more domains. The tenant identifier is generated automatically unless --identifier is provided.') + ->addOption('admin-identity', null, InputOption::VALUE_REQUIRED, 'Identity for the bootstrap admin user', 'admin') + ->addOption('no-admin-user', null, InputOption::VALUE_NONE, 'Do not create a bootstrap admin user (the admin role is still seeded)') + ->setHelp('This command creates a new tenant with one or more domains. The tenant identifier is generated automatically unless --identifier is provided. An "admin" role with full permissions is seeded automatically, along with a bootstrap admin user unless --no-admin-user is passed.') ; } @@ -106,6 +112,51 @@ class TenantCreateCommand extends Command ['Domains' => implode(', ', $domains)], ); + $role = $this->rolesStore->createRole($identifier, [ + 'rid' => 'admin', + 'label' => 'Administrator', + 'description' => 'Full access to all tenant features', + 'permissions' => ['*'], + 'system' => true, + ]); + + $this->logger->info('Default admin role seeded via console', [ + 'tenant' => $identifier, + 'rid' => $role['rid'] ?? null, + 'command' => $this->getName(), + ]); + + $io->text("Default role 'admin' seeded with full permissions."); + + if (!$input->getOption('no-admin-user')) { + $adminIdentity = $input->getOption('admin-identity'); + + if ($this->userStore->fetchByIdentity($identifier, $adminIdentity)) { + $io->warning("User '{$adminIdentity}' already exists in tenant '{$identifier}'; skipping admin user creation."); + } else { + $this->userStore->createUser($identifier, [ + 'identity' => $adminIdentity, + 'label' => 'Administrator', + 'enabled' => true, + 'roles' => ['admin'], + 'profile' => [], + 'settings' => [], + 'provider' => null, + 'provider_subject' => null, + 'provider_managed_fields' => [], + ]); + + $this->logger->info('Bootstrap admin user created via console', [ + 'tenant' => $identifier, + 'identity' => $adminIdentity, + 'command' => $this->getName(), + ]); + + $io->text("Admin user '{$adminIdentity}' created with the 'admin' role."); + $io->note("Set a credential for this account using the auth provider module you have installed, e.g.: php bin/console user:password {$identifier} {$adminIdentity}"); + } + } + return Command::SUCCESS; } catch (\Throwable $e) { diff --git a/core/lib/Module/Module.php b/core/lib/Module/Module.php index 668d650..8556c5a 100644 --- a/core/lib/Module/Module.php +++ b/core/lib/Module/Module.php @@ -112,6 +112,11 @@ class Module extends ModuleInstanceAbstract implements ModuleConsoleInterface, M \KTXC\Console\User\UserCreateCommand::class, \KTXC\Console\User\UserListCommand::class, \KTXC\Console\User\UserDeleteCommand::class, + \KTXC\Console\Role\RoleCreateCommand::class, + \KTXC\Console\Role\RoleListCommand::class, + \KTXC\Console\Role\RoleDeleteCommand::class, + \KTXC\Console\Role\RoleAssignCommand::class, + \KTXC\Console\Role\RoleRevokeCommand::class, ]; }