diff --git a/.gitignore b/.gitignore index 7cecd762a..b8ce6566d 100644 --- a/.gitignore +++ b/.gitignore @@ -58,3 +58,7 @@ nix/docker/maria/mariadb_data/ nix/mariadb/ wings/ mariadb_data/ + + +# Ignore +ignored-markdown-notes/ diff --git a/Vagrantfile b/Vagrantfile index 7df12ca79..12be4e5bc 100644 --- a/Vagrantfile +++ b/Vagrantfile @@ -1,4 +1,3 @@ - BOX_DEFAULT = "bento/ubuntu-24.04" BOX_LIBVIRT = "generic/ubuntu2404" @@ -22,6 +21,10 @@ FORWARDED_PORTS = SPECIAL_PORTS + TEST_PORTS.to_a Vagrant.configure("2") do |config| config.vm.box = BOX_DEFAULT config.vm.hostname = "pyrodactyl-dev" + + # Add private network for NFS (required on macOS) + config.vm.network "private_network", type: "dhcp" + FORWARDED_PORTS.each do |p| config.vm.network "forwarded_port", guest: p, @@ -63,19 +66,12 @@ Vagrant.configure("2") do |config| lv.cpus = CPUS end - if Vagrant::Util::Platform.windows? - config.vm.synced_folder ".", "/home/vagrant/pyrodactyl", - type: "virtualbox", - owner: "vagrant", - group: "vagrant", - mount_options: ["dmode=775", "fmode=664"] - else - config.vm.synced_folder ".", "/home/vagrant/pyrodactyl", - type: "nfs", - nfs_version: 4, - nfs_udp: false, - mount_options: ["rw", "vers=4", "tcp", "fsc", "rsize=1048576", "wsize=1048576"] - end + # Use VirtualBox shared folders for maximum compatibility + config.vm.synced_folder ".", "/home/vagrant/pyrodactyl", + type: "virtualbox", + owner: "vagrant", + group: "vagrant", + mount_options: ["dmode=775", "fmode=664"] config.vm.provision "shell", path: "vagrant/provision.sh", @@ -87,4 +83,9 @@ Vagrant.configure("2") do |config| username: dev@pyro.host password: dev MSG + + # Increase boot timeout to 10 minutes for slow boots or first-time setup + # in seconds 300 = 5 minutes + config.vm.boot_timeout = 600 + end diff --git a/app/Console/Commands/User/ManageAdminPermissionsCommand.php b/app/Console/Commands/User/ManageAdminPermissionsCommand.php new file mode 100644 index 000000000..4ec6a2aaf --- /dev/null +++ b/app/Console/Commands/User/ManageAdminPermissionsCommand.php @@ -0,0 +1,310 @@ +argument('action'); + + return match ($action) { + 'grant' => $this->grantPermission(), + 'revoke' => $this->revokePermission(), + 'list' => $this->listPermissions(), + 'sync' => $this->syncPermissions(), + default => $this->invalidAction($action), + }; + } + + /** + * Grant permission(s) to a user. + */ + private function grantPermission(): int + { + $user = $this->getUser(); + if (!$user) { + return 1; + } + + if ($this->option('all')) { + $this->permissionService->grantAllPermissions($user); + $this->info("Granted all admin permissions to {$user->email}"); + return 0; + } + + $permission = $this->option('permission'); + if (!$permission) { + $permission = $this->selectPermission(); + if (!$permission) { + return 1; + } + } + + if (!in_array($permission, AdminPermission::allPermissions())) { + $this->error("Invalid permission: {$permission}"); + return 1; + } + + $this->permissionService->grantPermissions($user, [$permission]); + $this->info("Granted permission '{$permission}' to {$user->email}"); + + return 0; + } + + /** + * Revoke permission(s) from a user. + */ + private function revokePermission(): int + { + $user = $this->getUser(); + if (!$user) { + return 1; + } + + if ($this->option('all')) { + $this->permissionService->revokeAllPermissions($user); + $this->info("Revoked all admin permissions from {$user->email}"); + return 0; + } + + $permission = $this->option('permission'); + if (!$permission) { + $permission = $this->selectPermissionFromUser($user); + if (!$permission) { + return 1; + } + } + + $this->permissionService->revokePermissions($user, [$permission]); + $this->info("Revoked permission '{$permission}' from {$user->email}"); + + return 0; + } + + /** + * List permissions for a user. + */ + private function listPermissions(): int + { + if ($this->option('all')) { + return $this->listAllPermissions(); + } + + $user = $this->getUser(); + if (!$user) { + return 1; + } + + $this->info("Permissions for {$user->email}:"); + $this->line(''); + + if ($user->root_admin) { + $this->warn('This user is a ROOT ADMIN and has all permissions.'); + $this->line(''); + } + + $permissions = $user->getAdminPermissions(); + + if (empty($permissions)) { + $this->warn('This user has no admin permissions.'); + return 0; + } + + $grouped = AdminPermission::permissions(); + foreach ($grouped as $category) { + $categoryPerms = array_intersect(array_keys($category['permissions']), $permissions); + if (!empty($categoryPerms)) { + $this->line("{$category['name']}:"); + foreach ($categoryPerms as $perm) { + $this->line(" ✓ {$category['permissions'][$perm]} ({$perm})"); + } + $this->line(''); + } + } + + $this->info("Total: " . count($permissions) . " permission(s)"); + + return 0; + } + + /** + * List all available permissions. + */ + private function listAllPermissions(): int + { + $this->info('All Available Admin Permissions:'); + $this->line(''); + + $grouped = AdminPermission::permissions(); + foreach ($grouped as $category) { + $this->line("{$category['name']}:"); + foreach ($category['permissions'] as $perm => $description) { + $this->line(" • {$description}"); + $this->line(" {$perm}>"); + } + $this->line(''); + } + + return 0; + } + + /** + * Sync permissions (replace all permissions with specified ones). + */ + private function syncPermissions(): int + { + $user = $this->getUser(); + if (!$user) { + return 1; + } + + $this->warn('This will replace ALL current permissions for this user.'); + $this->info('Select permissions to grant (press enter when done):'); + + $permissions = []; + $available = AdminPermission::allPermissions(); + + foreach (AdminPermission::permissions() as $category) { + $this->line(''); + $this->line("{$category['name']}:"); + + foreach ($category['permissions'] as $perm => $description) { + if ($this->confirm("Grant: {$description}?", false)) { + $permissions[] = $perm; + } + } + } + + if (empty($permissions) && !$this->confirm('Remove all permissions from this user?', false)) { + $this->warn('Operation cancelled.'); + return 1; + } + + $this->permissionService->updatePermissions($user, $permissions); + $this->info("Updated permissions for {$user->email}"); + $this->info("Total permissions: " . count($permissions)); + + return 0; + } + + /** + * Get user by email or ID. + */ + private function getUser(): ?User + { + $identifier = $this->option('user'); + + if (!$identifier) { + $identifier = $this->ask('Enter user email or ID'); + } + + if (!$identifier) { + $this->error('User email or ID is required.'); + return null; + } + + $user = is_numeric($identifier) + ? User::find($identifier) + : User::where('email', $identifier)->first(); + + if (!$user) { + $this->error("User not found: {$identifier}"); + return null; + } + + return $user; + } + + /** + * Let user select a permission from all available. + */ + private function selectPermission(): ?string + { + $grouped = AdminPermission::permissions(); + $options = []; + + foreach ($grouped as $category) { + foreach ($category['permissions'] as $perm => $description) { + $options[] = $perm; + $this->line(sprintf('[%d] %s - %s', count($options) - 1, $category['name'], $description)); + } + } + + $choice = $this->ask('Select permission number'); + + if (!is_numeric($choice) || !isset($options[$choice])) { + $this->error('Invalid selection.'); + return null; + } + + return $options[$choice]; + } + + /** + * Let user select from permissions they currently have. + */ + private function selectPermissionFromUser(User $user): ?string + { + $permissions = $user->adminPermissions()->pluck('permission')->toArray(); + + if (empty($permissions)) { + $this->warn('User has no permissions to revoke.'); + return null; + } + + $this->info('Select permission to revoke:'); + foreach ($permissions as $index => $perm) { + $this->line("[{$index}] {$perm}"); + } + + $choice = $this->ask('Select permission number'); + + if (!is_numeric($choice) || !isset($permissions[$choice])) { + $this->error('Invalid selection.'); + return null; + } + + return $permissions[$choice]; + } + + /** + * Handle invalid action. + */ + private function invalidAction(string $action): int + { + $this->error("Invalid action: {$action}"); + $this->info('Valid actions are: grant, revoke, list, sync'); + return 1; + } +} diff --git a/app/Http/Controllers/Admin/AdminPermissionController.php b/app/Http/Controllers/Admin/AdminPermissionController.php new file mode 100644 index 000000000..9d095d54c --- /dev/null +++ b/app/Http/Controllers/Admin/AdminPermissionController.php @@ -0,0 +1,82 @@ +getAdminPermissions(); + + return $this->view->make('admin.users.permissions', [ + 'user' => $user, + 'permissions' => $permissions, + 'userPermissions' => $userPermissions, + ]); + } + + /** + * Update permissions for a user. + * + * @throws \Throwable + */ + public function update(AdminPermissionFormRequest $request, User $user): RedirectResponse + { + $permissions = $request->input('permissions', []); + + $this->permissionService->updatePermissions($user, $permissions); + + $this->alert->success($this->translator->get('admin/user.notices.permissions_updated'))->flash(); + + return redirect()->route('admin.users.permissions', $user->id); + } + + /** + * Get permissions as JSON (for API or AJAX requests). + */ + public function json(User $user): JsonResponse + { + return response()->json([ + 'permissions' => $user->getAdminPermissions(), + 'is_root_admin' => $user->root_admin, + ]); + } + + /** + * Get all available permissions as JSON. + */ + public function available(): JsonResponse + { + return response()->json([ + 'permissions' => AdminPermission::permissions(), + ]); + } +} diff --git a/app/Http/Controllers/Admin/Examples/ExamplePermissionController.php b/app/Http/Controllers/Admin/Examples/ExamplePermissionController.php new file mode 100644 index 000000000..76966a80f --- /dev/null +++ b/app/Http/Controllers/Admin/Examples/ExamplePermissionController.php @@ -0,0 +1,331 @@ +requireAdminPermission(AdminPermission::USER_READ); + + // If we get here, user has permission + $users = User::all(); + return view('admin.users.index', compact('users')); + } + + /** + * EXAMPLE 2: Using the trait helper method - Any of multiple permissions + * + * User needs at least ONE of the specified permissions. + */ + public function example2_anyPermission(): View + { + // User needs either read OR update permission + $this->requireAnyAdminPermission([ + AdminPermission::USER_READ, + AdminPermission::USER_UPDATE, + ]); + + $users = User::all(); + return view('admin.users.index', compact('users')); + } + + /** + * EXAMPLE 3: Using the trait helper method - All permissions required + * + * User must have ALL specified permissions. + */ + public function example3_allPermissions(): View + { + // User needs both read AND update permission + $this->requireAllAdminPermissions([ + AdminPermission::USER_READ, + AdminPermission::USER_UPDATE, + ]); + + $users = User::all(); + return view('admin.users.index', compact('users')); + } + + /** + * EXAMPLE 4: Using the authorize convenience method + * + * Quick method for standard CRUD operations. + */ + public function example4_authorizeMethod(): View + { + // Automatically checks for 'admin.users.read' permission + $this->authorize('users', 'read'); + + $users = User::all(); + return view('admin.users.index', compact('users')); + } + + /** + * EXAMPLE 5: Manual check with custom logic + * + * Use when you need custom behavior based on permissions. + */ + public function example5_manualCheck(): View + { + $user = auth()->user(); + + // Manual check with custom response + if (!$user->hasAdminPermission(AdminPermission::USER_READ)) { + // You can return custom response instead of throwing exception + return view('errors.no-permission')->with('message', 'You need user read permission'); + } + + $users = User::all(); + return view('admin.users.index', compact('users')); + } + + /** + * EXAMPLE 6: Conditional logic based on permissions + * + * Show different data or options based on what user can do. + */ + public function example6_conditionalLogic(): View + { + $user = auth()->user(); + + // Everyone with admin access can view this page + $this->requireAnyAdminPermission([AdminPermission::USER_READ]); + + // But we'll customize what they see + $users = User::all(); + $canCreate = $user->hasAdminPermission(AdminPermission::USER_CREATE); + $canUpdate = $user->hasAdminPermission(AdminPermission::USER_UPDATE); + $canDelete = $user->hasAdminPermission(AdminPermission::USER_DELETE); + + return view('admin.users.index', compact('users', 'canCreate', 'canUpdate', 'canDelete')); + } + + /** + * EXAMPLE 7: Different permissions for different actions + * + * A complex method that does different things based on request. + */ + public function example7_differentActions(Request $request): JsonResponse + { + $action = $request->input('action'); + + switch ($action) { + case 'list': + $this->requireAdminPermission(AdminPermission::USER_READ); + return response()->json(['users' => User::all()]); + + case 'create': + $this->requireAdminPermission(AdminPermission::USER_CREATE); + // Create logic here + return response()->json(['message' => 'User created']); + + case 'delete': + $this->requireAdminPermission(AdminPermission::USER_DELETE); + // Delete logic here + return response()->json(['message' => 'User deleted']); + + default: + return response()->json(['error' => 'Invalid action'], 400); + } + } + + /** + * EXAMPLE 8: Combining with Laravel's policy system + * + * You can use both the permission system and Laravel policies together. + */ + public function example8_withPolicy(User $user): View + { + // Check admin permission first + $this->requireAdminPermission(AdminPermission::USER_UPDATE); + + // Then use Laravel's authorization (from Policy) + $this->authorize('update', $user); + + return view('admin.users.edit', compact('user')); + } + + /** + * EXAMPLE 9: API endpoint with permission check + * + * Useful for API controllers. + */ + public function example9_apiEndpoint(): JsonResponse + { + $this->requireAdminPermission(AdminPermission::USER_READ); + + return response()->json([ + 'data' => User::all(), + 'meta' => [ + 'count' => User::count(), + ], + ]); + } + + /** + * EXAMPLE 10: Form submission with permission check + * + * Pattern for handling form submissions. + */ + public function example10_formSubmission(Request $request): RedirectResponse + { + $this->requireAdminPermission(AdminPermission::USER_CREATE); + + // Validate request + $validated = $request->validate([ + 'email' => 'required|email', + 'username' => 'required|string', + // ... other fields + ]); + + // Create user + $user = User::create($validated); + + return redirect() + ->route('admin.users.view', $user) + ->with('success', 'User created successfully'); + } + + /** + * EXAMPLE 11: Multiple permission checks in sequence + * + * When you need to check different permissions at different points. + */ + public function example11_sequentialChecks(User $user): View + { + // First check if they can view users at all + $this->requireAdminPermission(AdminPermission::USER_READ); + + // Load basic user data + $userData = $user->toArray(); + + // Check if they can see sensitive data + if (auth()->user()->hasAdminPermission(AdminPermission::USER_UPDATE)) { + // Include more sensitive information + $userData['last_login_ip'] = $user->last_login_ip; + $userData['sessions'] = $user->sessions; + } + + return view('admin.users.view', ['user' => $userData]); + } + + /** + * EXAMPLE 12: Graceful degradation + * + * Return different responses based on permissions without throwing errors. + */ + public function example12_gracefulDegradation(): View + { + $user = auth()->user(); + $data = []; + + // Try to get user data if they have permission + if ($user->hasAdminPermission(AdminPermission::USER_READ)) { + $data['users'] = User::all(); + } + + // Try to get server data if they have permission + if ($user->hasAdminPermission(AdminPermission::SERVER_READ)) { + $data['servers'] = \Pterodactyl\Models\Server::all(); + } + + // Try to get node data if they have permission + if ($user->hasAdminPermission(AdminPermission::NODE_READ)) { + $data['nodes'] = \Pterodactyl\Models\Node::all(); + } + + return view('admin.dashboard', $data); + } +} + +/** + * EXAMPLE 13: Route-level middleware (add to routes/admin.php) + * + * Apply permission checks at the route level instead of in controller: + * + * use Pterodactyl\Http\Middleware\RequireAdminPermission; + * use Pterodactyl\Models\AdminPermission; + * + * Route::get('/admin/users', [UserController::class, 'index']) + * ->middleware(RequireAdminPermission::class . ':' . AdminPermission::USER_READ); + * + * // Or with multiple permissions (user needs any one): + * Route::get('/admin/users', [UserController::class, 'index']) + * ->middleware(RequireAdminPermission::class . ':admin.users.read,admin.users.update'); + * + * // Or for a group: + * Route::middleware([RequireAdminPermission::class . ':' . AdminPermission::USER_READ]) + * ->group(function () { + * Route::get('/admin/users', [UserController::class, 'index']); + * Route::get('/admin/users/{user}', [UserController::class, 'show']); + * }); + */ + +/** + * EXAMPLE 14: Blade template usage (add to your views) + * + * {{-- Check single permission --}} + * @if(auth()->user()->hasAdminPermission(\Pterodactyl\Models\AdminPermission::USER_CREATE)) + * Create User + * @endif + * + * {{-- Check if root admin OR has permission --}} + * @if(auth()->user()->root_admin || auth()->user()->hasAdminPermission(\Pterodactyl\Models\AdminPermission::USER_DELETE)) + * Delete + * @endif + * + * {{-- Check if any kind of admin --}} + * @if(auth()->user()->isAdmin()) + * Admin Panel + * @endif + * + * {{-- Conditional menu items --}} + * @if(auth()->user()->hasAdminPermission(\Pterodactyl\Models\AdminPermission::USER_READ)) + * Users + * @endif + * + * @if(auth()->user()->hasAdminPermission(\Pterodactyl\Models\AdminPermission::SERVER_READ)) + * Servers + * @endif + */ + +/** + * BEST PRACTICES: + * + * 1. Use permission constants (AdminPermission::USER_READ) instead of strings + * 2. Check permissions as early as possible in your method + * 3. Use the trait helpers for consistency + * 4. Apply permissions at route level when the entire route needs protection + * 5. Use conditional logic when you want to customize based on permissions + * 6. Always test with users that DON'T have permissions + * 7. Root admins automatically pass all checks - this is by design + * 8. Consider UX - don't show buttons/links for actions users can't perform + * 9. Log permission denials if needed for security auditing + * 10. Document which permissions your new features require + */ diff --git a/app/Http/Middleware/AdminAuthenticate.php b/app/Http/Middleware/AdminAuthenticate.php index 2e61f6429..9284709e5 100644 --- a/app/Http/Middleware/AdminAuthenticate.php +++ b/app/Http/Middleware/AdminAuthenticate.php @@ -14,8 +14,15 @@ class AdminAuthenticate */ public function handle(Request $request, \Closure $next): mixed { - if (!$request->user() || !$request->user()->root_admin) { - throw new AccessDeniedHttpException(); + $user = $request->user(); + + if (!$user) { + throw new AccessDeniedHttpException('Authentication required.'); + } + + // Check if user is either a root admin or has any admin permissions + if (!$user->root_admin && !$user->isAdmin()) { + throw new AccessDeniedHttpException('Admin access required.'); } return $next($request); diff --git a/app/Http/Middleware/RequireAdminPermission.php b/app/Http/Middleware/RequireAdminPermission.php new file mode 100644 index 000000000..1ed5a9039 --- /dev/null +++ b/app/Http/Middleware/RequireAdminPermission.php @@ -0,0 +1,44 @@ +user(); + + if (!$user) { + throw new AccessDeniedHttpException('You must be logged in to access this resource.'); + } + + // Root admins have access to everything + if ($user->root_admin) { + return $next($request); + } + + // Check if user has any of the required permissions + if (empty($permissions)) { + // If no specific permissions specified, just check if they're any kind of admin + if ($user->isAdmin()) { + return $next($request); + } + } else { + // Check if user has any of the specified permissions + if ($user->hasAnyAdminPermission($permissions)) { + return $next($request); + } + } + + throw new AccessDeniedHttpException('You do not have permission to access this resource.'); + } +} diff --git a/app/Http/Requests/Admin/AdminPermissionFormRequest.php b/app/Http/Requests/Admin/AdminPermissionFormRequest.php new file mode 100644 index 000000000..d9ba4e611 --- /dev/null +++ b/app/Http/Requests/Admin/AdminPermissionFormRequest.php @@ -0,0 +1,29 @@ + 'sometimes|array', + 'permissions.*' => 'string|in:' . implode(',', AdminPermission::allPermissions()), + ]; + } + + /** + * Normalize the request data. + */ + public function normalize(): array + { + return [ + 'permissions' => $this->input('permissions', []), + ]; + } +} diff --git a/app/Models/AdminPermission.php b/app/Models/AdminPermission.php new file mode 100644 index 000000000..004225fc3 --- /dev/null +++ b/app/Models/AdminPermission.php @@ -0,0 +1,284 @@ + 'integer', + ]; + + /** + * Get all available admin permissions grouped by category. + */ + public static function permissions(): array + { + return [ + 'users' => [ + 'name' => 'User Management', + 'permissions' => [ + self::USER_READ => 'View users', + self::USER_CREATE => 'Create users', + self::USER_UPDATE => 'Update users', + self::USER_DELETE => 'Delete users', + ], + ], + 'servers' => [ + 'name' => 'Server Management', + 'permissions' => [ + self::SERVER_READ => 'View servers', + self::SERVER_CREATE => 'Create servers', + self::SERVER_UPDATE => 'Update servers', + self::SERVER_DELETE => 'Delete servers', + self::SERVER_VIEW_CONSOLE => 'Access server console', + ], + ], + 'nodes' => [ + 'name' => 'Node Management', + 'permissions' => [ + self::NODE_READ => 'View nodes', + self::NODE_CREATE => 'Create nodes', + self::NODE_UPDATE => 'Update nodes', + self::NODE_DELETE => 'Delete nodes', + ], + ], + 'locations' => [ + 'name' => 'Location Management', + 'permissions' => [ + self::LOCATION_READ => 'View locations', + self::LOCATION_CREATE => 'Create locations', + self::LOCATION_UPDATE => 'Update locations', + self::LOCATION_DELETE => 'Delete locations', + ], + ], + 'databases' => [ + 'name' => 'Database Management', + 'permissions' => [ + self::DATABASE_READ => 'View databases', + self::DATABASE_CREATE => 'Create databases', + self::DATABASE_UPDATE => 'Update databases', + self::DATABASE_DELETE => 'Delete databases', + ], + ], + 'nests' => [ + 'name' => 'Nest & Egg Management', + 'permissions' => [ + self::NEST_READ => 'View nests and eggs', + self::NEST_CREATE => 'Create nests and eggs', + self::NEST_UPDATE => 'Update nests and eggs', + self::NEST_DELETE => 'Delete nests and eggs', + ], + ], + 'mounts' => [ + 'name' => 'Mount Management', + 'permissions' => [ + self::MOUNT_READ => 'View mounts', + self::MOUNT_CREATE => 'Create mounts', + self::MOUNT_UPDATE => 'Update mounts', + self::MOUNT_DELETE => 'Delete mounts', + ], + ], + 'settings' => [ + 'name' => 'Settings Management', + 'permissions' => [ + self::SETTINGS_READ => 'View settings', + self::SETTINGS_UPDATE => 'Update settings', + ], + ], + 'api' => [ + 'name' => 'API Key Management', + 'permissions' => [ + self::API_KEYS_READ => 'View API keys', + self::API_KEYS_CREATE => 'Create API keys', + self::API_KEYS_DELETE => 'Delete API keys', + ], + ], + 'domains' => [ + 'name' => 'Domain Management', + 'permissions' => [ + self::DOMAIN_READ => 'View domains', + self::DOMAIN_UPDATE => 'Manage domains', + ], + ], + 'mail' => [ + 'name' => 'Mail Settings', + 'permissions' => [ + self::MAIL_READ => 'View mail settings', + self::MAIL_UPDATE => 'Update mail settings', + ], + ], + 'advanced' => [ + 'name' => 'Advanced Settings', + 'permissions' => [ + self::ADVANCED_READ => 'View advanced settings', + self::ADVANCED_UPDATE => 'Update advanced settings', + ], + ], + 'captcha' => [ + 'name' => 'Captcha Settings', + 'permissions' => [ + self::CAPTCHA_READ => 'View captcha settings', + self::CAPTCHA_UPDATE => 'Update captcha settings', + ], + ], + ]; + } + + /** + * Get a flat list of all permission keys. + */ + public static function allPermissions(): array + { + $permissions = []; + foreach (self::permissions() as $category) { + foreach ($category['permissions'] as $key => $name) { + $permissions[] = $key; + } + } + return $permissions; + } + + /** + * Validation rules. + */ + public static array $validationRules = [ + 'user_id' => 'required|integer|exists:users,id', + 'permission' => 'required|string', + ]; + + /** + * Get the user this permission belongs to. + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index e0a6d266a..4ddee4d2a 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -43,6 +43,8 @@ * @property \Illuminate\Support\Carbon|null $updated_at * @property \Illuminate\Database\Eloquent\Collection|\Pterodactyl\Models\ApiKey[] $apiKeys * @property int|null $api_keys_count + * @property \Illuminate\Database\Eloquent\Collection|\Pterodactyl\Models\AdminPermission[] $adminPermissions + * @property int|null $admin_permissions_count * @property string $name * @property \Illuminate\Notifications\DatabaseNotificationCollection|\Illuminate\Notifications\DatabaseNotification[] $notifications * @property int|null $notifications_count @@ -250,6 +252,75 @@ public function sshKeys(): HasMany return $this->hasMany(UserSSHKey::class); } + /** + * Returns all admin permissions for this user. + */ + public function adminPermissions(): HasMany + { + return $this->hasMany(AdminPermission::class); + } + + /** + * Check if the user has a specific admin permission. + */ + public function hasAdminPermission(string $permission): bool + { + // Root admins have all permissions + if ($this->root_admin) { + return true; + } + + return $this->adminPermissions()->where('permission', $permission)->exists(); + } + + /** + * Check if the user has any of the given admin permissions. + */ + public function hasAnyAdminPermission(array $permissions): bool + { + // Root admins have all permissions + if ($this->root_admin) { + return true; + } + + return $this->adminPermissions()->whereIn('permission', $permissions)->exists(); + } + + /** + * Check if the user has all of the given admin permissions. + */ + public function hasAllAdminPermissions(array $permissions): bool + { + // Root admins have all permissions + if ($this->root_admin) { + return true; + } + + $userPermissions = $this->adminPermissions()->pluck('permission')->toArray(); + return count(array_intersect($permissions, $userPermissions)) === count($permissions); + } + + /** + * Get all admin permission keys for this user. + */ + public function getAdminPermissions(): array + { + // Root admins have all permissions + if ($this->root_admin) { + return AdminPermission::allPermissions(); + } + + return $this->adminPermissions()->pluck('permission')->toArray(); + } + + /** + * Check if user is an admin (either root admin or has any admin permissions). + */ + public function isAdmin(): bool + { + return $this->root_admin || $this->adminPermissions()->exists(); + } + /** * Returns all the activity logs where this user is the subject — not to * be confused by activity logs where this user is the _actor_. diff --git a/app/Policies/AdminPolicy.php b/app/Policies/AdminPolicy.php new file mode 100644 index 000000000..92a3e1b0f --- /dev/null +++ b/app/Policies/AdminPolicy.php @@ -0,0 +1,132 @@ +root_admin || $user->hasAdminPermission(AdminPermission::USER_READ); + } + + /** + * Determine if the user can create users. + */ + public function createUsers(User $user): bool + { + return $user->root_admin || $user->hasAdminPermission(AdminPermission::USER_CREATE); + } + + /** + * Determine if the user can update users. + */ + public function updateUsers(User $user): bool + { + return $user->root_admin || $user->hasAdminPermission(AdminPermission::USER_UPDATE); + } + + /** + * Determine if the user can delete users. + */ + public function deleteUsers(User $user): bool + { + return $user->root_admin || $user->hasAdminPermission(AdminPermission::USER_DELETE); + } + + /** + * Determine if the user can view servers. + */ + public function viewServers(User $user): bool + { + return $user->root_admin || $user->hasAdminPermission(AdminPermission::SERVER_READ); + } + + /** + * Determine if the user can create servers. + */ + public function createServers(User $user): bool + { + return $user->root_admin || $user->hasAdminPermission(AdminPermission::SERVER_CREATE); + } + + /** + * Determine if the user can update servers. + */ + public function updateServers(User $user): bool + { + return $user->root_admin || $user->hasAdminPermission(AdminPermission::SERVER_UPDATE); + } + + /** + * Determine if the user can delete servers. + */ + public function deleteServers(User $user): bool + { + return $user->root_admin || $user->hasAdminPermission(AdminPermission::SERVER_DELETE); + } + + /** + * Determine if the user can view nodes. + */ + public function viewNodes(User $user): bool + { + return $user->root_admin || $user->hasAdminPermission(AdminPermission::NODE_READ); + } + + /** + * Determine if the user can create nodes. + */ + public function createNodes(User $user): bool + { + return $user->root_admin || $user->hasAdminPermission(AdminPermission::NODE_CREATE); + } + + /** + * Determine if the user can update nodes. + */ + public function updateNodes(User $user): bool + { + return $user->root_admin || $user->hasAdminPermission(AdminPermission::NODE_UPDATE); + } + + /** + * Determine if the user can delete nodes. + */ + public function deleteNodes(User $user): bool + { + return $user->root_admin || $user->hasAdminPermission(AdminPermission::NODE_DELETE); + } + + /** + * Determine if the user can view settings. + */ + public function viewSettings(User $user): bool + { + return $user->root_admin || $user->hasAdminPermission(AdminPermission::SETTINGS_READ); + } + + /** + * Determine if the user can update settings. + */ + public function updateSettings(User $user): bool + { + return $user->root_admin || $user->hasAdminPermission(AdminPermission::SETTINGS_UPDATE); + } + + /** + * General method to check any admin permission. + */ + public function hasPermission(User $user, string $permission): bool + { + return $user->root_admin || $user->hasAdminPermission($permission); + } +} diff --git a/app/Services/Users/AdminPermissionService.php b/app/Services/Users/AdminPermissionService.php new file mode 100644 index 000000000..9c9a1d3c3 --- /dev/null +++ b/app/Services/Users/AdminPermissionService.php @@ -0,0 +1,146 @@ +connection->transaction(function () use ($user, $permissions) { + // Delete all existing permissions + $user->adminPermissions()->delete(); + + // If user is root admin, don't add any specific permissions + // as they have access to everything + if ($user->root_admin) { + return; + } + + // Add new permissions + foreach ($permissions as $permission) { + $user->adminPermissions()->create([ + 'permission' => $permission, + ]); + } + }); + } + + /** + * Grant specific permissions to a user. + * + * @param User $user + * @param array $permissions Array of permission keys to grant + * @throws \Throwable + */ + public function grantPermissions(User $user, array $permissions): void + { + // Validate all permissions exist + $validPermissions = AdminPermission::allPermissions(); + $permissions = array_filter($permissions, function ($permission) use ($validPermissions) { + return in_array($permission, $validPermissions); + }); + + // If user is root admin, they already have all permissions + if ($user->root_admin) { + return; + } + + $this->connection->transaction(function () use ($user, $permissions) { + foreach ($permissions as $permission) { + // Check if permission already exists + if (!$user->adminPermissions()->where('permission', $permission)->exists()) { + $user->adminPermissions()->create([ + 'permission' => $permission, + ]); + } + } + }); + } + + /** + * Revoke specific permissions from a user. + * + * @param User $user + * @param array $permissions Array of permission keys to revoke + * @throws \Throwable + */ + public function revokePermissions(User $user, array $permissions): void + { + // Root admins can't have individual permissions revoked + if ($user->root_admin) { + return; + } + + $this->connection->transaction(function () use ($user, $permissions) { + $user->adminPermissions()->whereIn('permission', $permissions)->delete(); + }); + } + + /** + * Grant all permissions to a user. + * + * @param User $user + * @throws \Throwable + */ + public function grantAllPermissions(User $user): void + { + $this->grantPermissions($user, AdminPermission::allPermissions()); + } + + /** + * Revoke all permissions from a user. + * + * @param User $user + * @throws \Throwable + */ + public function revokeAllPermissions(User $user): void + { + $this->connection->transaction(function () use ($user) { + $user->adminPermissions()->delete(); + }); + } + + /** + * Copy permissions from one user to another. + * + * @param User $fromUser + * @param User $toUser + * @throws \Throwable + */ + public function copyPermissions(User $fromUser, User $toUser): void + { + if ($fromUser->root_admin) { + $permissions = AdminPermission::allPermissions(); + } else { + $permissions = $fromUser->adminPermissions()->pluck('permission')->toArray(); + } + + $this->updatePermissions($toUser, $permissions); + } +} diff --git a/app/Traits/Controllers/ChecksAdminPermissions.php b/app/Traits/Controllers/ChecksAdminPermissions.php new file mode 100644 index 000000000..fd1deb531 --- /dev/null +++ b/app/Traits/Controllers/ChecksAdminPermissions.php @@ -0,0 +1,72 @@ +user(); + + if (!$user || (!$user->root_admin && !$user->hasAdminPermission($permission))) { + throw new AccessDeniedHttpException( + 'You do not have permission to perform this action.' + ); + } + } + + /** + * Check if the current user has any of the specified admin permissions. + * + * @throws AccessDeniedHttpException + */ + protected function requireAnyAdminPermission(array $permissions): void + { + $user = request()->user(); + + if (!$user || (!$user->root_admin && !$user->hasAnyAdminPermission($permissions))) { + throw new AccessDeniedHttpException( + 'You do not have permission to perform this action.' + ); + } + } + + /** + * Check if the current user has all of the specified admin permissions. + * + * @throws AccessDeniedHttpException + */ + protected function requireAllAdminPermissions(array $permissions): void + { + $user = request()->user(); + + if (!$user || (!$user->root_admin && !$user->hasAllAdminPermissions($permissions))) { + throw new AccessDeniedHttpException( + 'You do not have all required permissions to perform this action.' + ); + } + } + + /** + * Check if the current user can perform an action (read, create, update, delete). + * This is a convenience method for common CRUD operations. + * + * @param string $resource The resource type (users, servers, nodes, etc.) + * @param string $action The action (read, create, update, delete) + * @throws AccessDeniedHttpException + */ + protected function authorize(string $resource, string $action): void + { + $permission = "admin.{$resource}.{$action}"; + $this->requireAdminPermission($permission); + } +} diff --git a/database/Seeders/AdminPermissionSeeder.php b/database/Seeders/AdminPermissionSeeder.php new file mode 100644 index 000000000..955bee40a --- /dev/null +++ b/database/Seeders/AdminPermissionSeeder.php @@ -0,0 +1,257 @@ +command->info('Admin Permission System Seeder'); + $this->command->info('============================='); + $this->command->newLine(); + + // Check if we should create example users + if ($this->command->confirm('Create example admin users with different permission sets?', true)) { + $this->createExampleUsers(); + } + + // Check if we should add permissions to existing users + if ($this->command->confirm('Add permissions to existing users?', false)) { + $this->addPermissionsToExistingUsers(); + } + + $this->command->newLine(); + $this->command->info('Seeding completed!'); + } + + /** + * Create example admin users with different permission sets. + */ + private function createExampleUsers(): void + { + $this->command->info('Creating example admin users...'); + + // Create a user manager admin (can only manage users) + $userManager = User::firstOrCreate( + ['email' => 'usermanager@example.com'], + [ + 'username' => 'usermanager', + 'name_first' => 'User', + 'name_last' => 'Manager', + 'password' => Hash::make('password'), + 'root_admin' => false, + ] + ); + + if ($userManager->wasRecentlyCreated) { + $userManager->adminPermissions()->createMany([ + ['permission' => AdminPermission::USER_READ], + ['permission' => AdminPermission::USER_CREATE], + ['permission' => AdminPermission::USER_UPDATE], + ['permission' => AdminPermission::USER_DELETE], + ]); + $this->command->info('✓ Created User Manager (usermanager@example.com / password)'); + } + + // Create a server manager admin (can only manage servers) + $serverManager = User::firstOrCreate( + ['email' => 'servermanager@example.com'], + [ + 'username' => 'servermanager', + 'name_first' => 'Server', + 'name_last' => 'Manager', + 'password' => Hash::make('password'), + 'root_admin' => false, + ] + ); + + if ($serverManager->wasRecentlyCreated) { + $serverManager->adminPermissions()->createMany([ + ['permission' => AdminPermission::SERVER_READ], + ['permission' => AdminPermission::SERVER_CREATE], + ['permission' => AdminPermission::SERVER_UPDATE], + ['permission' => AdminPermission::SERVER_DELETE], + ['permission' => AdminPermission::SERVER_VIEW_CONSOLE], + ]); + $this->command->info('✓ Created Server Manager (servermanager@example.com / password)'); + } + + // Create a read-only admin (can view everything but not modify) + $readOnly = User::firstOrCreate( + ['email' => 'readonly@example.com'], + [ + 'username' => 'readonly', + 'name_first' => 'Read', + 'name_last' => 'Only', + 'password' => Hash::make('password'), + 'root_admin' => false, + ] + ); + + if ($readOnly->wasRecentlyCreated) { + $readOnly->adminPermissions()->createMany([ + ['permission' => AdminPermission::USER_READ], + ['permission' => AdminPermission::SERVER_READ], + ['permission' => AdminPermission::NODE_READ], + ['permission' => AdminPermission::LOCATION_READ], + ['permission' => AdminPermission::DATABASE_READ], + ['permission' => AdminPermission::NEST_READ], + ['permission' => AdminPermission::MOUNT_READ], + ['permission' => AdminPermission::SETTINGS_READ], + ['permission' => AdminPermission::API_KEYS_READ], + ]); + $this->command->info('✓ Created Read-Only Admin (readonly@example.com / password)'); + } + + // Create a full admin with all permissions (but not root) + $fullAdmin = User::firstOrCreate( + ['email' => 'fulladmin@example.com'], + [ + 'username' => 'fulladmin', + 'name_first' => 'Full', + 'name_last' => 'Admin', + 'password' => Hash::make('password'), + 'root_admin' => false, + ] + ); + + if ($fullAdmin->wasRecentlyCreated) { + $permissions = AdminPermission::allPermissions(); + $permissionData = array_map(function ($permission) { + return ['permission' => $permission]; + }, $permissions); + $fullAdmin->adminPermissions()->createMany($permissionData); + $this->command->info('✓ Created Full Admin (fulladmin@example.com / password)'); + } + } + + /** + * Add permissions to existing users interactively. + */ + private function addPermissionsToExistingUsers(): void + { + $users = User::where('root_admin', false)->get(); + + if ($users->isEmpty()) { + $this->command->warn('No non-root admin users found.'); + return; + } + + $this->command->info('Select a user to add permissions to:'); + + foreach ($users as $index => $user) { + $permCount = $user->adminPermissions()->count(); + $this->command->line(sprintf( + '[%d] %s (%s) - %d permission(s)', + $index, + $user->username, + $user->email, + $permCount + )); + } + + $userIndex = $this->command->ask('Enter user number'); + + if (!isset($users[$userIndex])) { + $this->command->error('Invalid user selection.'); + return; + } + + $selectedUser = $users[$userIndex]; + + $this->command->info('Select permission category:'); + $this->command->line('[0] All Permissions'); + $this->command->line('[1] User Management'); + $this->command->line('[2] Server Management'); + $this->command->line('[3] Node Management'); + $this->command->line('[4] Location Management'); + $this->command->line('[5] Database Management'); + $this->command->line('[6] Nest & Egg Management'); + $this->command->line('[7] Mount Management'); + $this->command->line('[8] Settings Management'); + $this->command->line('[9] API Key Management'); + + $category = $this->command->ask('Enter category number'); + + $permissions = match ((int) $category) { + 0 => AdminPermission::allPermissions(), + 1 => [ + AdminPermission::USER_READ, + AdminPermission::USER_CREATE, + AdminPermission::USER_UPDATE, + AdminPermission::USER_DELETE, + ], + 2 => [ + AdminPermission::SERVER_READ, + AdminPermission::SERVER_CREATE, + AdminPermission::SERVER_UPDATE, + AdminPermission::SERVER_DELETE, + AdminPermission::SERVER_VIEW_CONSOLE, + ], + 3 => [ + AdminPermission::NODE_READ, + AdminPermission::NODE_CREATE, + AdminPermission::NODE_UPDATE, + AdminPermission::NODE_DELETE, + ], + 4 => [ + AdminPermission::LOCATION_READ, + AdminPermission::LOCATION_CREATE, + AdminPermission::LOCATION_UPDATE, + AdminPermission::LOCATION_DELETE, + ], + 5 => [ + AdminPermission::DATABASE_READ, + AdminPermission::DATABASE_CREATE, + AdminPermission::DATABASE_UPDATE, + AdminPermission::DATABASE_DELETE, + ], + 6 => [ + AdminPermission::NEST_READ, + AdminPermission::NEST_CREATE, + AdminPermission::NEST_UPDATE, + AdminPermission::NEST_DELETE, + ], + 7 => [ + AdminPermission::MOUNT_READ, + AdminPermission::MOUNT_CREATE, + AdminPermission::MOUNT_UPDATE, + AdminPermission::MOUNT_DELETE, + ], + 8 => [ + AdminPermission::SETTINGS_READ, + AdminPermission::SETTINGS_UPDATE, + ], + 9 => [ + AdminPermission::API_KEYS_READ, + AdminPermission::API_KEYS_CREATE, + AdminPermission::API_KEYS_DELETE, + ], + default => [], + }; + + if (empty($permissions)) { + $this->command->error('Invalid category selection.'); + return; + } + + foreach ($permissions as $permission) { + $selectedUser->adminPermissions()->firstOrCreate([ + 'permission' => $permission, + ]); + } + + $this->command->info("✓ Added {$category} permission(s) to {$selectedUser->username}"); + } +} diff --git a/database/migrations/2024_01_01_000000_create_admin_permissions_table.php b/database/migrations/2024_01_01_000000_create_admin_permissions_table.php new file mode 100644 index 000000000..2f32b90af --- /dev/null +++ b/database/migrations/2024_01_01_000000_create_admin_permissions_table.php @@ -0,0 +1,33 @@ +id(); + $table->unsignedInteger('user_id'); + $table->string('permission'); + $table->timestamps(); + + $table->foreign('user_id')->references('id')->on('users')->onDelete('cascade'); + $table->unique(['user_id', 'permission']); + $table->index('user_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('admin_permissions'); + } +} diff --git a/resources/lang/en/admin/user.php b/resources/lang/en/admin/user.php index 65e227806..8271c4757 100644 --- a/resources/lang/en/admin/user.php +++ b/resources/lang/en/admin/user.php @@ -7,5 +7,6 @@ 'notices' => [ 'account_created' => 'Account has been created successfully.', 'account_updated' => 'Account has been successfully updated.', + 'permissions_updated' => 'Admin permissions have been successfully updated.', ], ]; diff --git a/resources/views/admin/users/permissions.blade.php b/resources/views/admin/users/permissions.blade.php new file mode 100644 index 000000000..64d7d42e7 --- /dev/null +++ b/resources/views/admin/users/permissions.blade.php @@ -0,0 +1,113 @@ +@extends('layouts.admin') + +@section('title') + Admin Permissions: {{ $user->username }} +@endsection + +@section('content-header') + {{ $user->name_first }} {{ $user->name_last}}Admin Permissions + + Admin + Users + {{ $user->username }} + Permissions + +@endsection + +@section('content') + + + @if($user->root_admin) + + Root Administrator: This user has full administrative access to all features. + Individual permissions below are informational only and do not restrict access. + + @endif + + + + + Administrative Permissions + + + + Select the administrative permissions this user should have. + @if(!$user->root_admin) + Users with admin permissions can access the admin panel but only perform actions they have been granted permission for. + @endif + + + + @foreach($permissions as $categoryKey => $category) + + {{ $category['name'] }} + + @foreach($category['permissions'] as $permKey => $permName) + + + + root_admin ? 'disabled' : '' }} + > + {{ $permName }} + + + + @endforeach + + + + @endforeach + + @if($user->root_admin) + + Note: To modify permissions, first remove root admin status from this user on the + user details page. + + @endif + + + + + + + +@section('footer-scripts') + @parent + + + +@endsection +@endsection diff --git a/resources/views/admin/users/view.blade.php b/resources/views/admin/users/view.blade.php index f2a03329b..e27d9c3c3 100644 --- a/resources/views/admin/users/view.blade.php +++ b/resources/views/admin/users/view.blade.php @@ -98,6 +98,18 @@ Setting this to 'Yes' gives a user full administrative access. + @if($user->root_admin || $user->isAdmin()) + + + Manage Admin Permissions + + @if(!$user->root_admin && $user->adminPermissions->count() > 0) + + This user has {{ $user->adminPermissions->count() }} specific admin permission(s). + + @endif + + @endif diff --git a/routes/admin.php b/routes/admin.php index 6d8941850..c2fc63245 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -104,10 +104,13 @@ Route::get('/accounts.json', [Admin\UserController::class, 'json'])->name('admin.users.json'); Route::get('/new', [Admin\UserController::class, 'create'])->name('admin.users.new'); Route::get('/view/{user:id}', [Admin\UserController::class, 'view'])->name('admin.users.view'); + Route::get('/view/{user:id}/permissions', [Admin\AdminPermissionController::class, 'view'])->name('admin.users.permissions'); + Route::get('/view/{user:id}/permissions/json', [Admin\AdminPermissionController::class, 'json'])->name('admin.users.permissions.json'); Route::post('/new', [Admin\UserController::class, 'store']); Route::patch('/view/{user:id}', [Admin\UserController::class, 'update']); + Route::patch('/view/{user:id}/permissions', [Admin\AdminPermissionController::class, 'update'])->name('admin.users.permissions.update'); Route::delete('/view/{user:id}', [Admin\UserController::class, 'delete']); });
+ Select the administrative permissions this user should have. + @if(!$user->root_admin) + Users with admin permissions can access the admin panel but only perform actions they have been granted permission for. + @endif +
Setting this to 'Yes' gives a user full administrative access.
+ This user has {{ $user->adminPermissions->count() }} specific admin permission(s). +