From 3d6aa856b4a07d737fdd67126464a715fa72b62d Mon Sep 17 00:00:00 2001 From: root Date: Wed, 24 Dec 2025 19:22:20 -0500 Subject: [PATCH] implemented operation based permissions --- core/lib/Controllers/InitController.php | 48 ++++++- core/lib/Controllers/ModuleController.php | 14 +- .../lib/Controllers/UserProfileController.php | 14 +- .../Controllers/UserSettingsController.php | 14 +- core/lib/Http/Middleware/RouterMiddleware.php | 14 +- core/lib/Module/Module.php | 101 ++++++++++++++ core/lib/Module/ModuleManager.php | 97 +++++++++++++- core/lib/Module/ModuleObject.php | 5 + core/lib/Routing/Route.php | 1 + core/lib/Routing/Router.php | 1 + .../Authorization/PermissionChecker.php | 124 ++++++++++++++++++ core/lib/SessionIdentity.php | 38 +++++- core/src/composables/useUser.ts | 19 ++- core/src/stores/userStore.ts | 63 +++++++++ core/src/types/authenticationTypes.ts | 1 + core/src/views/PrivateLayout.vue | 7 + shared/lib/Module/ModuleInstanceAbstract.php | 24 ++++ .../Routing/Attributes/AuthenticatedRoute.php | 10 ++ 18 files changed, 578 insertions(+), 17 deletions(-) create mode 100644 core/lib/Module/Module.php create mode 100644 core/lib/Security/Authorization/PermissionChecker.php diff --git a/core/lib/Controllers/InitController.php b/core/lib/Controllers/InitController.php index a876647..4df88ff 100644 --- a/core/lib/Controllers/InitController.php +++ b/core/lib/Controllers/InitController.php @@ -4,6 +4,7 @@ namespace KTXC\Controllers; use KTXC\Http\Response\JsonResponse; use KTXC\Module\ModuleManager; +use KTXC\Security\Authorization\PermissionChecker; use KTXC\Service\UserService; use KTXC\SessionIdentity; use KTXF\Controller\ControllerAbstract; @@ -17,20 +18,33 @@ class InitController extends ControllerAbstract private readonly SessionIdentity $userIdentity, private readonly ModuleManager $moduleManager, private readonly UserService $userService, + private readonly PermissionChecker $permissionChecker, ) {} - #[AuthenticatedRoute('/init', name: 'init', methods: ['GET'])] + #[AuthenticatedRoute( + '/init', + name: 'init', + methods: ['GET'] + )] public function index(): JsonResponse { $configuration = []; - // modules + // modules - filter by permissions $configuration['modules'] = []; foreach ($this->moduleManager->list() as $module) { if (!method_exists($module, 'bootUi')) { continue; } - $configuration['modules'][$module->handle()] = $module->bootUi(); + + // Check if user has permission to view this module + // Allow access if user has: {module_handle}, {module_handle}.*, or * permission + $handle = $module->handle(); + if (!$this->hasModuleViewPermission($handle)) { + continue; + } + + $configuration['modules'][$handle] = $module->bootUi(); } // tenant @@ -46,7 +60,8 @@ class InitController extends ControllerAbstract 'identifier' => $this->userIdentity->identifier(), 'identity' => $this->userIdentity->identity()->getIdentity(), 'label' => $this->userIdentity->label(), - 'permissions' => [], // TODO: Implement permissions + 'roles' => $this->userIdentity->identity()->getRoles(), + 'permissions' => $this->userIdentity->identity()->getPermissions(), ], 'profile' => $this->userService->getEditableFields($this->userIdentity->identifier()), 'settings' => $this->userService->fetchSettings(), @@ -56,4 +71,29 @@ class InitController extends ControllerAbstract } + /** + * Check if user has permission to view a module + * + * Checks for the following permissions (in order): + * 1. {module_handle} - module access permission + * 2. {module_handle}.* - wildcard for the module + * 3. * - global wildcard + * + * @param string $moduleHandle The module handle to check + * @return bool + */ + private function hasModuleViewPermission(string $moduleHandle): bool + { + // Core module is always accessible to authenticated users + if ($moduleHandle === 'core') { + return true; + } + + // Check for specific module permission or wildcard permissions + return $this->permissionChecker->canAny([ + "{$moduleHandle}", + "{$moduleHandle}.*", + ]); + } + } diff --git a/core/lib/Controllers/ModuleController.php b/core/lib/Controllers/ModuleController.php index 8291c48..140d329 100644 --- a/core/lib/Controllers/ModuleController.php +++ b/core/lib/Controllers/ModuleController.php @@ -13,7 +13,12 @@ class ModuleController extends ControllerAbstract private readonly ModuleManager $moduleManager ) { } - #[AuthenticatedRoute('/modules/list', name: 'modules.index', methods: ['GET'])] + #[AuthenticatedRoute( + '/modules/list', + name: 'modules.index', + methods: ['GET'], + permissions: ['module_manager.modules.view'] + )] public function index(): JsonResponse { $modules = $this->moduleManager->list(false); @@ -21,7 +26,12 @@ class ModuleController extends ControllerAbstract return new JsonResponse(['modules' => $modules]); } - #[AuthenticatedRoute('/modules/manage', name: 'modules.manage', methods: ['POST'])] + #[AuthenticatedRoute( + '/modules/manage', + name: 'modules.manage', + methods: ['POST'], + permissions: ['module_manager.modules.manage'] + )] public function manage(string $handle, string $action): JsonResponse { // Verify module exists diff --git a/core/lib/Controllers/UserProfileController.php b/core/lib/Controllers/UserProfileController.php index 3996a45..cff9a00 100644 --- a/core/lib/Controllers/UserProfileController.php +++ b/core/lib/Controllers/UserProfileController.php @@ -22,7 +22,12 @@ class UserProfileController extends ControllerAbstract * * @return JsonResponse Profile data with editability metadata */ - #[AuthenticatedRoute('/user/profile', name: 'user.profile.read', methods: ['GET'])] + #[AuthenticatedRoute( + '/user/profile', + name: 'user.profile.read', + methods: ['GET'], + permissions: ['user.profile.read'] + )] public function read(): JsonResponse { $userId = $this->userIdentity->identifier(); @@ -50,7 +55,12 @@ class UserProfileController extends ControllerAbstract * * @return JsonResponse Updated profile data */ - #[AuthenticatedRoute('/user/profile', name: 'user.profile.update', methods: ['PUT', 'PATCH'])] + #[AuthenticatedRoute( + '/user/profile', + name: 'user.profile.update', + methods: ['PUT', 'PATCH'], + permissions: ['user.profile.update'] + )] public function update(array $data): JsonResponse { $userId = $this->userIdentity->identifier(); diff --git a/core/lib/Controllers/UserSettingsController.php b/core/lib/Controllers/UserSettingsController.php index 8b121d0..349dd8b 100644 --- a/core/lib/Controllers/UserSettingsController.php +++ b/core/lib/Controllers/UserSettingsController.php @@ -23,7 +23,12 @@ class UserSettingsController extends ControllerAbstract * * @return JsonResponse Settings data as key-value pairs */ - #[AuthenticatedRoute('/user/settings', name: 'user.settings.read', methods: ['GET'])] + #[AuthenticatedRoute( + '/user/settings', + name: 'user.settings.read', + methods: ['GET'], + permissions: ['user.settings.read'] + )] public function read(): JsonResponse { // Fetch all settings (no filter) @@ -48,7 +53,12 @@ class UserSettingsController extends ControllerAbstract * * @return JsonResponse Updated settings data */ - #[AuthenticatedRoute('/user/settings', name: 'user.settings.update', methods: ['PUT', 'PATCH'])] + #[AuthenticatedRoute( + '/user/settings', + name: 'user.settings.update', + methods: ['PUT', 'PATCH'], + permissions: ['user.settings.update'] + )] public function update(array $data): JsonResponse { $this->userService->storeSettings($data); diff --git a/core/lib/Http/Middleware/RouterMiddleware.php b/core/lib/Http/Middleware/RouterMiddleware.php index 109bc8d..410cb34 100644 --- a/core/lib/Http/Middleware/RouterMiddleware.php +++ b/core/lib/Http/Middleware/RouterMiddleware.php @@ -7,6 +7,7 @@ use KTXC\Http\Response\Response; use KTXC\Routing\Router; use KTXC\Routing\Route; use KTXC\SessionIdentity; +use KTXC\Security\Authorization\PermissionChecker; /** * Router middleware @@ -16,7 +17,8 @@ class RouterMiddleware implements MiddlewareInterface { public function __construct( private readonly Router $router, - private readonly SessionIdentity $sessionIdentity + private readonly SessionIdentity $sessionIdentity, + private readonly PermissionChecker $permissionChecker ) {} public function process(Request $request, RequestHandlerInterface $handler): Response @@ -37,6 +39,16 @@ class RouterMiddleware implements MiddlewareInterface ); } + // Check permissions (if any specified) + if ($match->authenticated && !empty($match->permissions)) { + if (!$this->permissionChecker->canAny($match->permissions)) { + return new Response( + Response::$statusTexts[Response::HTTP_FORBIDDEN], + Response::HTTP_FORBIDDEN + ); + } + } + // Dispatch to the controller $response = $this->router->dispatch($match, $request); diff --git a/core/lib/Module/Module.php b/core/lib/Module/Module.php new file mode 100644 index 0000000..ec30f84 --- /dev/null +++ b/core/lib/Module/Module.php @@ -0,0 +1,101 @@ + [ + 'label' => 'Read Own Profile', + 'description' => 'View own user profile information', + 'group' => 'User Profile' + ], + 'user.profile.update' => [ + 'label' => 'Update Own Profile', + 'description' => 'Edit own user profile information', + 'group' => 'User Profile' + ], + 'user.settings.read' => [ + 'label' => 'Read Own Settings', + 'description' => 'View own user settings', + 'group' => 'User Settings' + ], + 'user.settings.update' => [ + 'label' => 'Update Own Settings', + 'description' => 'Edit own user settings', + 'group' => 'User Settings' + ], + + // Module Management + 'module_manager.modules.view' => [ + 'label' => 'View Modules', + 'description' => 'View list of installed and available modules', + 'group' => 'Module Management' + ], + 'module_manager.modules.manage' => [ + 'label' => 'Manage Modules', + 'description' => 'Install, uninstall, enable, and disable modules', + 'group' => 'Module Management' + ], + 'module_manager.modules.*' => [ + 'label' => 'Full Module Management', + 'description' => 'All module management operations', + 'group' => 'Module Management' + ], + + // System Administration + 'system.admin' => [ + 'label' => 'System Administrator', + 'description' => 'Full system access (superuser)', + 'group' => 'System Administration' + ], + '*' => [ + 'label' => 'All Permissions', + 'description' => 'Grants access to all features and operations', + 'group' => 'System Administration' + ], + ]; + } + + public function bootUi(): ?array + { + return null; + } +} diff --git a/core/lib/Module/ModuleManager.php b/core/lib/Module/ModuleManager.php index c1d0b62..bf9c490 100644 --- a/core/lib/Module/ModuleManager.php +++ b/core/lib/Module/ModuleManager.php @@ -35,7 +35,14 @@ class ModuleManager */ public function list(bool $installedOnly = true, $enabledOnly = true): ModuleCollection { - $modules = New ModuleCollection(); + $modules = New ModuleCollection(); + + // Always include core module + $coreModule = $this->coreModule(); + if ($coreModule) { + $modules['core'] = new ModuleObject($coreModule, null); + } + // load all modules from store $entries = $this->repository->list(); foreach ($entries as $entry) { @@ -497,4 +504,92 @@ class ModuleManager return null; } + /** + * Get all available permissions from all modules + * + * @return array Grouped permissions with metadata + */ + public function availablePermissions(): array + { + $permissions = []; + + foreach ($this->list() as $module) { + $modulePermissions = $module->permissions(); + + foreach ($modulePermissions as $permission => $meta) { + $permissions[$permission] = array_merge($meta, [ + 'module' => $module->handle() + ]); + } + } + + // Group by category + $grouped = []; + foreach ($permissions as $permission => $meta) { + $group = $meta['group'] ?? 'Other'; + + if (!isset($grouped[$group])) { + $grouped[$group] = []; + } + + $grouped[$group][$permission] = $meta; + } + + // Sort groups alphabetically + ksort($grouped); + + return $grouped; + } + + /** + * Validate if a permission exists + */ + public function permissionExists(string $permission): bool + { + foreach ($this->list() as $module) { + $modulePermissions = $module->permissions(); + + // Exact match + if (isset($modulePermissions[$permission])) { + return true; + } + + // Wildcard match (e.g., user_manager.users.create matches user_manager.users.*) + foreach (array_keys($modulePermissions) as $registered) { + if (str_ends_with($registered, '.*')) { + $prefix = substr($registered, 0, -2); + if (str_starts_with($permission, $prefix . '.')) { + return true; + } + } + } + } + + return false; + } + /** + * Get the core module instance + */ + private function coreModule(): ?\KTXF\Module\ModuleInstanceInterface + { + if (isset($this->moduleInstances['core'])) { + return $this->moduleInstances['core']; + } + + try { + $coreModuleClass = \KTXC\Module\Module::class; + if (!class_exists($coreModuleClass)) { + return null; + } + + $instance = $this->container->get($coreModuleClass); + $this->moduleInstances['core'] = $instance; + return $instance; + } catch (\Throwable $e) { + $this->logger->error('Failed to load core module', [ + 'error' => $e->getMessage() + ]); + return null; + } + } } diff --git a/core/lib/Module/ModuleObject.php b/core/lib/Module/ModuleObject.php index 56e3fdb..37dd68f 100644 --- a/core/lib/Module/ModuleObject.php +++ b/core/lib/Module/ModuleObject.php @@ -158,4 +158,9 @@ class ModuleObject implements JsonSerializable return $this->instance?->bootUi() ?? null; } + public function permissions(): array + { + return $this->instance?->permissions() ?? []; + } + } diff --git a/core/lib/Routing/Route.php b/core/lib/Routing/Route.php index bfa96e3..877aeae 100644 --- a/core/lib/Routing/Route.php +++ b/core/lib/Routing/Route.php @@ -18,6 +18,7 @@ class Route public readonly string $className, public readonly string $classMethodName, public readonly array $classMethodParameters = [], + public readonly array $permissions = [], ) {} public function withParams(array $params): self diff --git a/core/lib/Routing/Router.php b/core/lib/Routing/Router.php index 0948b90..7bdddfc 100644 --- a/core/lib/Routing/Router.php +++ b/core/lib/Routing/Router.php @@ -109,6 +109,7 @@ class Router className: $reflectionClass->getName(), classMethodName: $reflectionMethod->getName(), classMethodParameters: $reflectionMethod->getParameters(), + permissions: $route instanceof AuthenticatedRoute ? $route->permissions : [], ); } } diff --git a/core/lib/Security/Authorization/PermissionChecker.php b/core/lib/Security/Authorization/PermissionChecker.php new file mode 100644 index 0000000..690b0f0 --- /dev/null +++ b/core/lib/Security/Authorization/PermissionChecker.php @@ -0,0 +1,124 @@ +sessionIdentity->identity(); + + if (!$identity) { + return false; + } + + // Get user permissions from identity + $userPermissions = $identity->getPermissions() ?? []; + + // Super admin bypass - check for admin role + $roles = $identity->getRoles() ?? []; + if (in_array('admin', $roles) || in_array('system.admin', $roles)) { + return true; + } + + // Exact match + if (in_array($permission, $userPermissions)) { + return true; + } + + // Wildcard match: user_manager.users.* allows user_manager.users.create + foreach ($userPermissions as $userPerm) { + if (str_ends_with($userPerm, '.*')) { + $prefix = substr($userPerm, 0, -2); + if (str_starts_with($permission, $prefix . '.')) { + return true; + } + } + } + + // Full wildcard: * grants all permissions + if (in_array('*', $userPermissions)) { + return true; + } + + return false; + } + + /** + * Check if user has ANY of the permissions (OR logic) + * + * @param array $permissions Array of permissions to check + * @param mixed $resource Optional resource for resource-based permissions + * @return bool + */ + public function canAny(array $permissions, mixed $resource = null): bool + { + if (empty($permissions)) { + return true; // No permissions required + } + + foreach ($permissions as $permission) { + if ($this->can($permission, $resource)) { + return true; + } + } + + return false; + } + + /** + * Check if user has ALL permissions (AND logic) + * + * @param array $permissions Array of permissions to check + * @param mixed $resource Optional resource for resource-based permissions + * @return bool + */ + public function canAll(array $permissions, mixed $resource = null): bool + { + if (empty($permissions)) { + return true; // No permissions required + } + + foreach ($permissions as $permission) { + if (!$this->can($permission, $resource)) { + return false; + } + } + + return true; + } + + /** + * Get all permissions for the current user + * + * @return array + */ + public function getUserPermissions(): array + { + $identity = $this->sessionIdentity->identity(); + + if (!$identity) { + return []; + } + + return $identity->getPermissions() ?? []; + } +} diff --git a/core/lib/SessionIdentity.php b/core/lib/SessionIdentity.php index deb5e3f..63b7374 100644 --- a/core/lib/SessionIdentity.php +++ b/core/lib/SessionIdentity.php @@ -51,14 +51,44 @@ class SessionIdentity public function permissions(): array { - $permissions = $this->identityData?->getPermissions() ?? []; - $permissions[] = 'ROLE_USER'; - return array_unique($permissions); + return $this->identityData?->getPermissions() ?? []; + } + + public function roles(): array + { + return $this->identityData?->getRoles() ?? []; } public function hasPermission(string $permission): bool { - return in_array($permission, $this->permissions()); + $permissions = $this->permissions(); + + // Exact match + if (in_array($permission, $permissions)) { + return true; + } + + // Wildcard match + foreach ($permissions as $userPerm) { + if (str_ends_with($userPerm, '.*')) { + $prefix = substr($userPerm, 0, -2); + if (str_starts_with($permission, $prefix . '.')) { + return true; + } + } + } + + // Full wildcard + if (in_array('*', $permissions)) { + return true; + } + + return false; + } + + public function hasRole(string $role): bool + { + return in_array($role, $this->roles()); } } diff --git a/core/src/composables/useUser.ts b/core/src/composables/useUser.ts index 86f927a..6af289a 100644 --- a/core/src/composables/useUser.ts +++ b/core/src/composables/useUser.ts @@ -16,10 +16,23 @@ export function useUser() { const identifier = computed(() => store.identifier); const identity = computed(() => store.identity); const label = computed(() => store.label); + const roles = computed(() => store.roles); const permissions = computed(() => store.permissions); const hasPermission = (permission: string): boolean => { - return store.permissions.includes(permission); + return store.hasPermission(permission); + }; + + const hasAnyPermission = (perms: string[]): boolean => { + return store.hasAnyPermission(perms); + }; + + const hasAllPermissions = (perms: string[]): boolean => { + return store.hasAllPermissions(perms); + }; + + const hasRole = (role: string): boolean => { + return store.hasRole(role); }; const logout = async (): Promise => { @@ -66,8 +79,12 @@ export function useUser() { identifier, identity, label, + roles, permissions, hasPermission, + hasAnyPermission, + hasAllPermissions, + hasRole, logout, // Profile diff --git a/core/src/stores/userStore.ts b/core/src/stores/userStore.ts index b8234aa..d7311a1 100644 --- a/core/src/stores/userStore.ts +++ b/core/src/stores/userStore.ts @@ -41,6 +41,7 @@ export const useUserStore = defineStore('userStore', () => { const identifier = computed(() => auth.value?.identifier ?? null); const identity = computed(() => auth.value?.identity ?? null); const label = computed(() => auth.value?.label ?? null); + const roles = computed(() => auth.value?.roles ?? []); const permissions = computed(() => auth.value?.permissions ?? []); // ========================================================================= @@ -148,6 +149,61 @@ export const useUserStore = defineStore('userStore', () => { }); } + // ========================================================================= + // Permission Checking + // ========================================================================= + + /** + * Check if user has a specific permission + * Supports wildcards: user_manager.users.* matches all user actions + */ + function hasPermission(permission: string): boolean { + const userPermissions = permissions.value; + + // Exact match + if (userPermissions.includes(permission)) { + return true; + } + + // Wildcard match + for (const userPerm of userPermissions) { + if (userPerm.endsWith('.*')) { + const prefix = userPerm.slice(0, -2); + if (permission.startsWith(prefix + '.')) { + return true; + } + } + } + + // Full wildcard + if (userPermissions.includes('*')) { + return true; + } + + return false; + } + + /** + * Check if user has ANY of the permissions (OR logic) + */ + function hasAnyPermission(perms: string[]): boolean { + return perms.some(p => hasPermission(p)); + } + + /** + * Check if user has ALL permissions (AND logic) + */ + function hasAllPermissions(perms: string[]): boolean { + return perms.every(p => hasPermission(p)); + } + + /** + * Check if user has a specific role + */ + function hasRole(role: string): boolean { + return roles.value.includes(role); + } + // ========================================================================= // Initialize from /init endpoint // ========================================================================= @@ -180,6 +236,7 @@ export const useUserStore = defineStore('userStore', () => { identifier, identity, label, + roles, permissions, // Profile getters @@ -206,6 +263,12 @@ export const useUserStore = defineStore('userStore', () => { getSetting, setSetting, + // Permission actions + hasPermission, + hasAnyPermission, + hasAllPermissions, + hasRole, + // Init init, }; diff --git a/core/src/types/authenticationTypes.ts b/core/src/types/authenticationTypes.ts index 066cd58..2da9cb8 100644 --- a/core/src/types/authenticationTypes.ts +++ b/core/src/types/authenticationTypes.ts @@ -15,6 +15,7 @@ export interface AuthenticatedUser { identifier: string; identity: string; label: string; + roles?: string[]; permissions?: string[]; } diff --git a/core/src/views/PrivateLayout.vue b/core/src/views/PrivateLayout.vue index d9022da..7237b4c 100644 --- a/core/src/views/PrivateLayout.vue +++ b/core/src/views/PrivateLayout.vue @@ -19,3 +19,10 @@ const layoutStore = useLayoutStore(); + + diff --git a/shared/lib/Module/ModuleInstanceAbstract.php b/shared/lib/Module/ModuleInstanceAbstract.php index 4d3907d..010413e 100644 --- a/shared/lib/Module/ModuleInstanceAbstract.php +++ b/shared/lib/Module/ModuleInstanceAbstract.php @@ -46,4 +46,28 @@ abstract class ModuleInstanceAbstract implements ModuleInstanceInterface // Override in specific modules if needed } + /** + * Permissions provided by this module + * + * @return array Permission definitions with metadata + * + * @example + * return [ + * 'user_manager.users.view' => [ + * 'label' => 'View Users', + * 'description' => 'View user list and details', + * 'group' => 'User Management' + * ], + * 'user_manager.users.*' => [ + * 'label' => 'Full User Management', + * 'description' => 'All user management permissions', + * 'group' => 'User Management' + * ] + * ]; + */ + public function permissions(): array + { + return []; + } + } diff --git a/shared/lib/Routing/Attributes/AuthenticatedRoute.php b/shared/lib/Routing/Attributes/AuthenticatedRoute.php index 1f37e42..ac4dde6 100644 --- a/shared/lib/Routing/Attributes/AuthenticatedRoute.php +++ b/shared/lib/Routing/Attributes/AuthenticatedRoute.php @@ -7,6 +7,16 @@ use Attribute; #[Attribute(Attribute::TARGET_METHOD | Attribute::TARGET_CLASS)] class AuthenticatedRoute { + /** + * @param string $path Route path + * @param string $name Route name + * @param array $methods HTTP methods + * @param array $permissions Required permissions (OR logic - user needs ANY) + * Examples: + * - ['user.profile.edit'] - exact permission + * - ['user_manager.users.*'] - wildcard permission + * - ['user.edit.own', 'user.edit.any'] - multiple options + */ public function __construct( public readonly string $path, public readonly string $name,