From 1736b717dc2918577ff7c11aa0ea7e2a909446e8 Mon Sep 17 00:00:00 2001 From: Michael Kaufmann Date: Sun, 28 Jun 2026 13:36:46 +0200 Subject: [PATCH] - Add two (dev/testing) commands to list tenants and users with info - Test API routes being defined correctly - Make Role/Permission tenant-scopable Signed-off-by: Michael Kaufmann --- .../core/src/Console/Commands/InstallCore.php | 4 - .../core/src/Console/Commands/ListTenants.php | 99 +++++ .../core/src/Console/Commands/ListUsers.php | 396 ++++++++++++++++++ .../Api/Role/RolePermissionController.php | 2 + .../Tenant/Role/RolePermissionController.php | 2 + .../src/Resources/Roles/Schemas/ShowRole.php | 196 +++++---- .../Tenants/Relations/Roles/RoleResource.php | 2 +- packages/core/src/Support/RoleAssignments.php | 39 ++ .../Feature/CoreApiRouteRegistrationTest.php | 133 ++++++ .../Feature/PlanResourceAuthorizationTest.php | 2 + .../RolePermissionAuthorizationTest.php | 28 ++ .../TenantRolePermissionAuthorizationTest.php | 45 ++ 12 files changed, 858 insertions(+), 90 deletions(-) create mode 100644 packages/core/src/Console/Commands/ListTenants.php create mode 100644 packages/core/src/Console/Commands/ListUsers.php create mode 100644 packages/core/tests/Feature/CoreApiRouteRegistrationTest.php diff --git a/packages/core/src/Console/Commands/InstallCore.php b/packages/core/src/Console/Commands/InstallCore.php index 860a6d8..dd3e2ae 100644 --- a/packages/core/src/Console/Commands/InstallCore.php +++ b/packages/core/src/Console/Commands/InstallCore.php @@ -2,10 +2,6 @@ namespace Froxlor\Core\Console\Commands; -use Froxlor\Core\Models\Plan; -use Froxlor\Core\Models\Role; -use Froxlor\Core\Models\Tenant; -use Froxlor\Core\Models\User; use Illuminate\Console\Command; use Illuminate\Support\Facades\Artisan; diff --git a/packages/core/src/Console/Commands/ListTenants.php b/packages/core/src/Console/Commands/ListTenants.php new file mode 100644 index 0000000..74dd0b1 --- /dev/null +++ b/packages/core/src/Console/Commands/ListTenants.php @@ -0,0 +1,99 @@ +withCount(['users', 'subTenants']) + ->orderBy('name') + ->get() + ->groupBy(fn(Tenant $tenant) => $tenant->parent_tenant_id ?? 'root'); + + $roots = $tenants->get('root', new Collection()); + + if ($roots->isEmpty()) { + $this->warn('No tenants found.'); + return self::SUCCESS; + } + + $roots->values()->each(function (Tenant $tenant, int $index) use ($roots, $tenants) { + $this->renderTenant( + tenant: $tenant, + groupedTenants: $tenants, + prefix: '', + isLast: $index === $roots->count() - 1, + isRoot: true, + ); + }); + + return self::SUCCESS; + } + + /** + * Render one tenant and its children using tree-style branch prefixes. + */ + private function renderTenant(Tenant $tenant, Collection $groupedTenants, string $prefix, bool $isLast, bool $isRoot = false): void + { + $branch = $isRoot + ? '' + : ($isLast ? '`-- ' : '|-- '); + + $this->line($prefix . $branch . $this->formatTenant($tenant)); + + $children = $groupedTenants->get($tenant->id, new Collection())->values(); + $childPrefix = $prefix . ($isRoot ? '' : ($isLast ? ' ' : '| ')); + + $children->each(function (Tenant $child, int $index) use ($children, $groupedTenants, $childPrefix) { + $this->renderTenant( + tenant: $child, + groupedTenants: $groupedTenants, + prefix: $childPrefix, + isLast: $index === $children->count() - 1, + ); + }); + } + + /** + * Format one tenant line for CLI output. + */ + private function formatTenant(Tenant $tenant): string + { + $line = $tenant->name; + + if ($this->option('ids')) { + $line .= ' (' . $tenant->id . ')'; + } + + if ($this->option('stats')) { + $line .= ' [' . $tenant->users_count . ' users, ' . $tenant->sub_tenants_count . ' children]'; + } + + return $line; + } +} diff --git a/packages/core/src/Console/Commands/ListUsers.php b/packages/core/src/Console/Commands/ListUsers.php new file mode 100644 index 0000000..81e0f2d --- /dev/null +++ b/packages/core/src/Console/Commands/ListUsers.php @@ -0,0 +1,396 @@ +option('details')) { + return $this->showUser((string)$identifier); + } + + $this->showUsers(); + return self::SUCCESS; + } + + /** + * Show all users with assignment counts. + */ + private function showUsers(): void + { + $users = User::query() + ->withCount(['tenants', 'roles']) + ->with('tenants:id') + ->orderBy('email') + ->get(); + + $this->table(['ID', 'Name', 'Email', 'Tenants', 'Roles', 'Plans'], $users->map(fn(User $user) => [ + $user->id, + $user->name, + $user->email, + $user->tenants_count, + $user->roles_count + $user->tenants->pluck('pivot.role_id')->filter()->unique()->count(), + $user->tenants->pluck('pivot.plan_id')->filter()->unique()->count(), + ])); + } + + /** + * Show one user and render tenant assignments as a tree. + */ + private function showUser(string $identifier): int + { + $user = $this->findUser($identifier); + + if (!$user) { + $this->error('User not found: ' . $identifier); + return self::FAILURE; + } + + $globalRoles = $user->roles()->with('permissions')->orderBy('name')->get(); + $tenantAssignments = TenantUser::query() + ->with([ + 'user', + 'tenant.plan.resources', + 'role.permissions', + 'plan.resources', + ]) + ->where('user_id', $user->id) + ->get() + ->keyBy('tenant_id'); + + $this->info($user->name . ' (' . $user->id . ')'); + $this->line($user->email); + $this->newLine(); + + $this->line('Global roles'); + if ($globalRoles->isEmpty()) { + $this->line('`-- none'); + } else { + $globalRoles->values()->each(function (Role $role, int $index) use ($globalRoles) { + $isLast = $index === $globalRoles->count() - 1; + $this->line(($isLast ? '`-- ' : '|-- ') . $this->formatRole($role)); + $this->renderRolePermissions($role, $isLast ? ' ' : '| '); + }); + } + $this->newLine(); + + $this->line('Tenant assignments'); + if ($tenantAssignments->isEmpty()) { + $this->line('`-- none'); + return self::SUCCESS; + } + + $this->renderTenantTree($tenantAssignments); + + return self::SUCCESS; + } + + /** + * Render assigned tenants in their hierarchy, including unassigned ancestors. + */ + private function renderTenantTree(Collection $tenantAssignments): void + { + $assignedTenants = $tenantAssignments->map->tenant; + $tenantIds = $this->tenantTreeIds($assignedTenants); + $tenants = Tenant::query() + ->with('plan.resources') + ->whereIn('id', $tenantIds) + ->orderBy('name') + ->get() + ->groupBy(fn(Tenant $tenant) => $tenant->parent_tenant_id ?? 'root'); + + $roots = $tenants->get('root', new Collection())->values(); + $roots->each(function (Tenant $tenant, int $index) use ($roots, $tenants, $tenantAssignments) { + $this->renderTenant( + tenant: $tenant, + groupedTenants: $tenants, + assignments: $tenantAssignments, + prefix: '', + isLast: $index === $roots->count() - 1, + isRoot: true, + ); + }); + } + + /** + * Render one tenant node and its assignment details. + */ + private function renderTenant( + Tenant $tenant, + Collection $groupedTenants, + Collection $assignments, + string $prefix, + bool $isLast, + bool $isRoot = false, + ): void { + $branch = $isRoot ? '' : ($isLast ? '`-- ' : '|-- '); + $assignment = $assignments->get($tenant->id); + $marker = $assignment ? '' : ' (ancestor)'; + + $this->line($prefix . $branch . $tenant->name . ' (' . $tenant->id . ')' . $marker); + + $childPrefix = $prefix . ($isRoot ? '' : ($isLast ? ' ' : '| ')); + $children = $groupedTenants->get($tenant->id, new Collection())->values(); + + if ($assignment) { + $detailPrefix = $childPrefix . ($children->isEmpty() ? ' ' : '| '); + $this->renderAssignmentDetails($assignment, $detailPrefix); + } + + $children->each(function (Tenant $child, int $index) use ($children, $groupedTenants, $assignments, $childPrefix) { + $this->renderTenant( + tenant: $child, + groupedTenants: $groupedTenants, + assignments: $assignments, + prefix: $childPrefix, + isLast: $index === $children->count() - 1, + ); + }); + } + + /** + * Render role, plan, and resource information for a tenant assignment. + */ + private function renderAssignmentDetails(TenantUser $assignment, string $prefix): void + { + $effectivePlan = $assignment->plan ?: $assignment->tenant->plan; + + $this->line($prefix . '|-- role: ' . $this->formatRole($assignment->role)); + $this->renderRolePermissions($assignment->role, $prefix . '| '); + $this->line($prefix . '|-- plan: ' . $this->formatPlan($effectivePlan, $assignment->plan === null)); + + $resources = $effectivePlan?->resources ?? new Collection(); + if ($resources->isEmpty()) { + $this->line($prefix . '`-- resources: none'); + return; + } + + $this->line($prefix . '`-- resources'); + $resources->values()->each(function (Resource $resource, int $index) use ($assignment, $resources, $prefix) { + $branch = $index === $resources->count() - 1 ? ' `-- ' : ' |-- '; + $this->line($prefix . $branch . $resource->key . ' (' . $resource->type . ', limit ' . $this->formatLimit((int)$resource->pivot->limit) . ', usage ' . $this->usageFor($resource, $assignment) . ')'); + $this->renderResourceInstances( + resource: $resource, + assignment: $assignment, + prefix: $prefix . ($index === $resources->count() - 1 ? ' ' : ' | '), + ); + }); + } + + /** + * Return all assigned tenant IDs plus their ancestors. + */ + private function tenantTreeIds(Collection $tenants): array + { + $ids = []; + + $tenants->each(function (Tenant $tenant) use (&$ids) { + $current = $tenant; + while ($current !== null) { + $ids[$current->id] = $current->id; + $current = $current->parentTenant; + } + }); + + return array_values($ids); + } + + /** + * Render role permissions when requested for user details. + */ + private function renderRolePermissions(?Role $role, string $prefix): void + { + if (!$this->option('permissiondetails') || !$role) { + return; + } + + $permissions = $role->permissions->sortBy('key')->values(); + + if ($permissions->isEmpty()) { + $this->line($prefix . '`-- permissions: none'); + return; + } + + $this->line($prefix . '`-- permissions'); + $permissions->each(function ($permission, int $index) use ($permissions, $prefix) { + $branch = $index === $permissions->count() - 1 ? ' `-- ' : ' |-- '; + $inheritable = (bool)($permission->pivot->inheritable ?? false) ? ', inheritable' : ''; + $this->line($prefix . $branch . $permission->key . ' (' . $permission->name . $inheritable . ')'); + }); + } + + /** + * Resolve a user by ULID, exact email, or exact display name. + */ + private function findUser(string $identifier): ?User + { + $user = User::query() + ->where('id', $identifier) + ->orWhere('email', $identifier) + ->orWhere('company_name', $identifier) + ->first(); + + if ($user) { + return $user; + } + + return User::query() + ->get() + ->first(fn(User $candidate) => $candidate->name === $identifier); + } + + /** + * Format a role with permission count. + */ + private function formatRole(?Role $role): string + { + if (!$role) { + return 'none'; + } + + return $role->name . ' (' . $role->id . ', ' . $role->permissions->count() . ' permissions)'; + } + + /** + * Format a plan line. + */ + private function formatPlan(?Plan $plan, bool $inherited): string + { + if (!$plan) { + return 'none'; + } + + return $plan->name . ' (' . $plan->id . ')' . ($inherited ? ' inherited from tenant' : ''); + } + + /** + * Convert stored plan limits to readable CLI output. + */ + private function formatLimit(int $limit): string + { + return $limit === -1 ? 'unlimited' : (string)$limit; + } + + /** + * Count current user usage for a plan resource within the tenant assignment. + */ + private function usageFor(Resource $resource, TenantUser $assignment): int + { + if ($resource->type === 'environment') { + return EnvUsage::query() + ->where('resource_key', $resource->key) + ->where('user_id', $assignment->user_id) + ->whereHas('environment', fn($query) => $query->where('tenant_id', $assignment->tenant_id)) + ->count(); + } + + return ResourceUsage::getUsage($assignment->tenant, $resource->model_type, $assignment->user); + } + + /** + * Render concrete usage records for a plan resource when requested. + */ + private function renderResourceInstances(Resource $resource, TenantUser $assignment, string $prefix): void + { + if (!$this->option('resourcedetails')) { + return; + } + + $usages = $this->usageRecordsFor($resource, $assignment); + + if ($usages->isEmpty()) { + $this->line($prefix . '`-- used instances: none'); + return; + } + + $this->line($prefix . '`-- used instances'); + $usages->values()->each(function ($usage, int $index) use ($usages, $prefix, $resource) { + $branch = $index === $usages->count() - 1 ? ' `-- ' : ' |-- '; + $this->line($prefix . $branch . $this->formatResourceInstance($resource, $usage->resource_id)); + }); + } + + /** + * Return usage records for a resource in this tenant assignment. + */ + private function usageRecordsFor(Resource $resource, TenantUser $assignment): Collection + { + if ($resource->type === 'environment') { + return EnvUsage::query() + ->where('resource_key', $resource->key) + ->where('user_id', $assignment->user_id) + ->whereHas('environment', fn($query) => $query->where('tenant_id', $assignment->tenant_id)) + ->orderBy('resource_id') + ->get(); + } + + return TenantUsage::query() + ->where('tenant_id', $assignment->tenant_id) + ->where('resource_key', $resource->key) + ->where('user_id', $assignment->user_id) + ->orderBy('resource_id') + ->get(); + } + + /** + * Format one used resource instance. + */ + private function formatResourceInstance(Resource $resource, string $resourceId): string + { + $modelClass = Relation::getMorphedModel($resource->key) + ?? (class_exists($resource->model_type) ? $resource->model_type : null); + + if (!$modelClass || !is_subclass_of($modelClass, Model::class)) { + return $resourceId; + } + + $model = $modelClass::query()->find($resourceId); + + if (!$model) { + return $resourceId . ' (missing)'; + } + + $label = $model->name + ?? $model->email + ?? $model->hostname + ?? $model->key + ?? class_basename($modelClass); + + return $label . ' (' . $resourceId . ')'; + } +} diff --git a/packages/core/src/Http/Controllers/Api/Role/RolePermissionController.php b/packages/core/src/Http/Controllers/Api/Role/RolePermissionController.php index 5ff8c80..8e0a780 100644 --- a/packages/core/src/Http/Controllers/Api/Role/RolePermissionController.php +++ b/packages/core/src/Http/Controllers/Api/Role/RolePermissionController.php @@ -65,6 +65,7 @@ public function store(Request $request, Role $role) $permission = Permission::findOrFail($data['permission_id']); abort_unless(RoleAssignments::canDelegate($request->user(), $permission->key), 403); + RoleAssignments::ensureNotAssignedToUser($role, $request->user()); $role->permissions()->syncWithoutDetaching([ $permission->id => ['inheritable' => $data['inheritable'] ?? false], @@ -88,6 +89,7 @@ public function destroy(Request $request, Role $role, Permission $permission) Gate::authorize('roleDelete', [$permission, $role]); abort_unless(RoleAssignments::canDelegate($request->user(), $permission->key), 403); + RoleAssignments::ensureNotAssignedToUser($role, $request->user()); if (!$role->permissions()->where('permissions.id', $permission->id)->exists()) { throw ValidationException::withMessages([ diff --git a/packages/core/src/Http/Controllers/Api/Tenant/Role/RolePermissionController.php b/packages/core/src/Http/Controllers/Api/Tenant/Role/RolePermissionController.php index 69c7131..35f6d69 100644 --- a/packages/core/src/Http/Controllers/Api/Tenant/Role/RolePermissionController.php +++ b/packages/core/src/Http/Controllers/Api/Tenant/Role/RolePermissionController.php @@ -62,6 +62,7 @@ public function store(Request $request, Tenant $tenant, Role $role) $permission = Permission::findOrFail($data['permission_id']); abort_unless(RoleAssignments::canDelegate($request->user(), $permission->key, $tenant), 403); + RoleAssignments::ensureNotAssignedToUser($role, $request->user(), $tenant); $role->permissions()->syncWithoutDetaching([ $permission->id => ['inheritable' => $data['inheritable'] ?? false], @@ -85,6 +86,7 @@ public function destroy(Request $request, Tenant $tenant, Role $role, Permission Gate::authorize('tenantRoleDelete', [$permission, $tenant, $role]); abort_unless(RoleAssignments::canDelegate($request->user(), $permission->key, $tenant), 403); + RoleAssignments::ensureNotAssignedToUser($role, $request->user(), $tenant); if (!$role->permissions()->where('permissions.id', $permission->id)->exists()) { throw ValidationException::withMessages([ diff --git a/packages/core/src/Resources/Roles/Schemas/ShowRole.php b/packages/core/src/Resources/Roles/Schemas/ShowRole.php index a6560e0..fbb8d21 100644 --- a/packages/core/src/Resources/Roles/Schemas/ShowRole.php +++ b/packages/core/src/Resources/Roles/Schemas/ShowRole.php @@ -3,105 +3,131 @@ namespace Froxlor\Core\Resources\Roles\Schemas; use Froxlor\Core\Models\Role; -use Froxlor\Core\Resources\Roles\Relations\Permissions\Tables\PermissionTable; +use Froxlor\Core\Models\Tenant; use Froxlor\Core\Resources\Users\Tables\UserTable; use Froxlor\UI\Schemas; class ShowRole { - public static function schema(Role $role): array + public static function schema(Role $role, ?Tenant $tenant = null): array { - return [ - Schemas\Components\Tabs::make('roles.show.tabs') - ->props(['role' => $role]) + $usesTenantRoleEndpoints = $tenant !== null && $role->tenant_id !== null; + + $permissionsRoute = !$usesTenantRoleEndpoints + ? route('api.roles.permissions.index', $role) + : route('api.tenants.roles.permissions.index', ['tenant' => $tenant, 'role' => $role]); + $permissionsStoreRoute = !$usesTenantRoleEndpoints + ? route('api.roles.permissions.store', $role) + : route('api.tenants.roles.permissions.store', ['tenant' => $tenant, 'role' => $role]); + $permissionsDestroyRoute = !$usesTenantRoleEndpoints + ? str_replace('__permission__', '{permission}', route('api.roles.permissions.destroy', [ + 'role' => $role, + 'permission' => '__permission__', + ])) + : str_replace('__permission__', '{permission}', route('api.tenants.roles.permissions.destroy', [ + 'tenant' => $tenant, + 'role' => $role, + 'permission' => '__permission__', + ])); + + $assignmentComponents = [ + Schemas\Components\Text::make('permission_count') + ->label(trans('froxlor-core::generic.permissions')) + ->default(fn(Role $role) => $role->permissions->count()), + ]; + + if ($tenant === null) { + $assignmentComponents = [ + Schemas\Components\Text::make('members_count') + ->label(trans('froxlor-core::generic.members_count')), + ...$assignmentComponents, + Schemas\Components\Text::make('user_names') + ->label(trans('froxlor-core::generic.users')) + ->default(fn(Role $role) => $role->users->pluck('name')->join(', ') ?: trans('froxlor-core::generic.none')), + ]; + } + + $tabs = [ + Schemas\Components\Tab::make('roles.show.tabs.details') + ->sort(1) + ->label(trans('froxlor-core::generic.details')) ->components([ - Schemas\Components\Tab::make('roles.show.tabs.details') - ->sort(1) - ->label(trans('froxlor-core::generic.details')) + Schemas\Schema::make('roles.details') ->components([ - Schemas\Schema::make('roles.details') + Schemas\Components\Group::make('roles.show.details.group_a') ->components([ - Schemas\Components\Group::make('roles.show.details.group_a') + Schemas\Components\Section::make('roles.show.details.main') + ->title(trans('froxlor-core::generic.details')) + ->description(trans('froxlor-core::generic.role')) ->components([ - Schemas\Components\Section::make('roles.show.details.main') - ->title(trans('froxlor-core::generic.details')) - ->description(trans('froxlor-core::generic.role')) - ->components([ - Schemas\Components\Text::make('name') - ->label(trans('froxlor-core::generic.name')), - - Schemas\Components\Text::make('description') - ->label(trans('froxlor-core::generic.description')), - ]), - - Schemas\Components\Section::make('roles.show.details.assignments') - ->title(trans('froxlor-core::generic.overview')) - ->description(trans('froxlor-core::generic.permissions')) - ->components([ - Schemas\Components\Text::make('members_count') - ->label(trans('froxlor-core::generic.members_count')), - - Schemas\Components\Text::make('permission_count') - ->label(trans('froxlor-core::generic.permissions')) - ->default(fn(Role $role) => $role->permissions->count()), - - Schemas\Components\Text::make('user_names') - ->label(trans('froxlor-core::generic.users')) - ->default(fn(Role $role) => $role->users->pluck('name')->join(', ') ?: trans('froxlor-core::generic.none')), - ]), - ]) - ->colSpan(2), - - Schemas\Components\Group::make('roles.show.details.group_b') - ->components([ - Schemas\Components\Section::make('roles.show.details.meta') - ->title(trans('froxlor-core::generic.title')) - ->description(trans('froxlor-core::generic.overview')) - ->components([ - Schemas\Components\Text::make('id') - ->label('ID'), - - Schemas\Components\Text::make('created_at') - ->label(trans('froxlor-core::generic.created_at')), - - Schemas\Components\Text::make('updated_at') - ->label(trans('froxlor-core::generic.updated_at')), - ]), + Schemas\Components\Text::make('name') + ->label(trans('froxlor-core::generic.name')), + + Schemas\Components\Text::make('description') + ->label(trans('froxlor-core::generic.description')), ]), + + Schemas\Components\Section::make('roles.show.details.assignments') + ->title(trans('froxlor-core::generic.overview')) + ->description(trans('froxlor-core::generic.permissions')) + ->components($assignmentComponents), ]) - ->cols(3), - ]), + ->colSpan(2), - Schemas\Components\Tab::make('roles.show.tabs.edit') - ->sort(2) - ->label(trans('froxlor-core::generic.edit')) - ->components([ - Schemas\Schema::make('roles.edit') - ->components(RoleForm::schema()) - ->cols(3), - ]), - - Schemas\Components\Tab::make('roles.show.tabs.permissions') - ->sort(100) - ->label(trans('froxlor-core::generic.permissions')) - ->components([ - Schemas\Components\Relation::make('permissions') - ->fetch(route('api.roles.permissions.index', $role)) - ->columns(PermissionTable::columns($role)) - ->actions(PermissionTable::actions($role)), - ]), - - Schemas\Components\Tab::make('roles.show.tabs.users') - ->sort(200) - ->label(trans('froxlor-core::generic.users')) - ->components([ - Schemas\Components\Relation::make('users') - ->fetch(route('api.roles.users.index', $role)) - ->intendedRoute('auth.users.show', ['user' => '{id}']) - ->columns(UserTable::columns()) - ->actions([]), - ]), + Schemas\Components\Group::make('roles.show.details.group_b') + ->components([ + Schemas\Components\Section::make('roles.show.details.meta') + ->title(trans('froxlor-core::generic.title')) + ->description(trans('froxlor-core::generic.overview')) + ->components([ + Schemas\Components\Text::make('id') + ->label('ID'), + + Schemas\Components\Text::make('created_at') + ->label(trans('froxlor-core::generic.created_at')), + + Schemas\Components\Text::make('updated_at') + ->label(trans('froxlor-core::generic.updated_at')), + ]), + ]), + ]) + ->cols(3), ]), + + Schemas\Components\Tab::make('roles.show.tabs.edit') + ->sort(2) + ->label(trans('froxlor-core::generic.edit')) + ->components([ + Schemas\Schema::make('roles.edit') + ->components(RoleForm::schema()) + ->cols(3), + ]), + + Schemas\Components\Tab::make('roles.show.tabs.permissions') + ->sort(100) + ->label(trans('froxlor-core::generic.permissions')) + ->components([ + /* todo permission matrix */ + ]), + ]; + + if ($tenant === null) { + $tabs[] = Schemas\Components\Tab::make('roles.show.tabs.users') + ->sort(200) + ->label(trans('froxlor-core::generic.users')) + ->components([ + Schemas\Components\Relation::make('users') + ->fetch(route('api.roles.users.index', $role)) + ->intendedRoute('auth.users.show', ['user' => '{id}']) + ->columns(UserTable::columns()) + ->actions([]), + ]); + } + + return [ + Schemas\Components\Tabs::make('roles.show.tabs') + ->props(['role' => $role]) + ->components($tabs), ]; } diff --git a/packages/core/src/Resources/Tenants/Relations/Roles/RoleResource.php b/packages/core/src/Resources/Tenants/Relations/Roles/RoleResource.php index ae77b14..9c3b9de 100644 --- a/packages/core/src/Resources/Tenants/Relations/Roles/RoleResource.php +++ b/packages/core/src/Resources/Tenants/Relations/Roles/RoleResource.php @@ -39,7 +39,7 @@ public function show(Tenant $tenant, Role $role): Schema ->fetch(route('api.tenants.roles.show', ['tenant' => $tenant, 'role' => $role])) ->push(route('api.tenants.roles.update', ['tenant' => $tenant, 'role' => $role]), 'PUT') ->intendedRoute('tenants.roles.index', ['tenant' => $tenant]) - ->components(ShowRole::columns($role)) + ->components(ShowRole::schema($role, $tenant)) ->actions($this->showActions($tenant, $role)); } diff --git a/packages/core/src/Support/RoleAssignments.php b/packages/core/src/Support/RoleAssignments.php index cbb39a4..3b4d8d9 100644 --- a/packages/core/src/Support/RoleAssignments.php +++ b/packages/core/src/Support/RoleAssignments.php @@ -109,6 +109,45 @@ public static function ensureNotAssigned(Role $role): void } } + /** + * Ensure the given role is not currently assigned to the user in the active scope. + * + * Permission changes on a user's own role can immediately remove the permission + * required to recover from the change, so role-permission mutations must reject + * those self-referential updates before touching the pivot table. + * + * @throws ValidationException + */ + public static function ensureNotAssignedToUser( + Role $role, + User $user, + ?Tenant $tenant = null, + ?Environment $environment = null, + ): void { + $assigned = match (true) { + $environment !== null => DB::table('environment_user') + ->where('environment_id', $environment->id) + ->where('user_id', $user->id) + ->where('role_id', $role->id) + ->exists(), + $tenant !== null => DB::table('tenant_user') + ->where('tenant_id', $tenant->id) + ->where('user_id', $user->id) + ->where('role_id', $role->id) + ->exists(), + default => DB::table('role_user') + ->where('user_id', $user->id) + ->where('role_id', $role->id) + ->exists(), + }; + + if ($assigned) { + throw ValidationException::withMessages([ + 'role' => 'You cannot change permissions on a role assigned to yourself.', + ]); + } + } + /** * Ensure the selected role is global or owned by the target tenant. * diff --git a/packages/core/tests/Feature/CoreApiRouteRegistrationTest.php b/packages/core/tests/Feature/CoreApiRouteRegistrationTest.php new file mode 100644 index 0000000..5286b53 --- /dev/null +++ b/packages/core/tests/Feature/CoreApiRouteRegistrationTest.php @@ -0,0 +1,133 @@ +getByName($name); + + $this->assertNotNull($route, 'Route [' . $name . '] is not registered.'); + $this->assertSame($uri, $route->uri(), 'Route [' . $name . '] URI changed.'); + $this->assertContains($method, $route->methods(), 'Route [' . $name . '] method changed.'); + $this->assertSame($controller . '@' . $action, $route->getActionName(), 'Route [' . $name . '] action changed.'); + } + + /** + * Current core API route contract. + * + * @return array + */ + public static function coreApiRoutes(): array + { + return [ + 'audit-log.index' => ['api.audit-log.index', 'GET', 'api/audit-log', Api\AuditLogController::class, 'index'], + + 'nodes.index' => ['api.nodes.index', 'GET', 'api/nodes', Api\NodeController::class, 'index'], + 'nodes.store' => ['api.nodes.store', 'POST', 'api/nodes', Api\NodeController::class, 'store'], + 'nodes.show' => ['api.nodes.show', 'GET', 'api/nodes/{node}', Api\NodeController::class, 'show'], + 'nodes.update' => ['api.nodes.update', 'PUT', 'api/nodes/{node}', Api\NodeController::class, 'update'], + 'nodes.destroy' => ['api.nodes.destroy', 'DELETE', 'api/nodes/{node}', Api\NodeController::class, 'destroy'], + + 'users.index' => ['api.users.index', 'GET', 'api/users', Api\UserController::class, 'index'], + 'users.store' => ['api.users.store', 'POST', 'api/users', Api\UserController::class, 'store'], + 'users.show' => ['api.users.show', 'GET', 'api/users/{user}', Api\UserController::class, 'show'], + 'users.update' => ['api.users.update', 'PUT', 'api/users/{user}', Api\UserController::class, 'update'], + 'users.destroy' => ['api.users.destroy', 'DELETE', 'api/users/{user}', Api\UserController::class, 'destroy'], + + 'api-keys.index' => ['api.api-keys.index', 'GET', 'api/api-keys', Api\ApiKeyController::class, 'index'], + 'api-keys.store' => ['api.api-keys.store', 'POST', 'api/api-keys', Api\ApiKeyController::class, 'store'], + 'api-keys.show' => ['api.api-keys.show', 'GET', 'api/api-keys/{api_key}', Api\ApiKeyController::class, 'show'], + 'api-keys.destroy' => ['api.api-keys.destroy', 'DELETE', 'api/api-keys/{api_key}', Api\ApiKeyController::class, 'destroy'], + + 'settings.index' => ['api.settings.index', 'GET', 'api/settings/{resource?}/{resource_id?}', Api\SettingsController::class, 'index'], + 'settings.store' => ['api.settings.store', 'POST', 'api/settings/{resource?}/{resource_id?}', Api\SettingsController::class, 'store'], + + 'tenants.index' => ['api.tenants.index', 'GET', 'api/tenants', Api\TenantController::class, 'index'], + 'tenants.store' => ['api.tenants.store', 'POST', 'api/tenants', Api\TenantController::class, 'store'], + 'tenants.show' => ['api.tenants.show', 'GET', 'api/tenants/{tenant}', Api\TenantController::class, 'show'], + 'tenants.update' => ['api.tenants.update', 'PUT', 'api/tenants/{tenant}', Api\TenantController::class, 'update'], + 'tenants.destroy' => ['api.tenants.destroy', 'DELETE', 'api/tenants/{tenant}', Api\TenantController::class, 'destroy'], + + 'tenants.audit-log.index' => ['api.tenants.audit-log.index', 'GET', 'api/tenants/{tenant}/audit-log', Api\Tenant\AuditLogController::class, 'index'], + + 'tenants.nodes.index' => ['api.tenants.nodes.index', 'GET', 'api/tenants/{tenant}/nodes', Api\Tenant\NodeController::class, 'index'], + 'tenants.nodes.store' => ['api.tenants.nodes.store', 'POST', 'api/tenants/{tenant}/nodes', Api\Tenant\NodeController::class, 'store'], + 'tenants.nodes.show' => ['api.tenants.nodes.show', 'GET', 'api/tenants/{tenant}/nodes/{node}', Api\Tenant\NodeController::class, 'show'], + 'tenants.nodes.update' => ['api.tenants.nodes.update', 'PUT', 'api/tenants/{tenant}/nodes/{node}', Api\Tenant\NodeController::class, 'update'], + 'tenants.nodes.destroy' => ['api.tenants.nodes.destroy', 'DELETE', 'api/tenants/{tenant}/nodes/{node}', Api\Tenant\NodeController::class, 'destroy'], + + 'tenants.environments.index' => ['api.tenants.environments.index', 'GET', 'api/tenants/{tenant}/environments', Api\Tenant\EnvironmentController::class, 'index'], + 'tenants.environments.store' => ['api.tenants.environments.store', 'POST', 'api/tenants/{tenant}/environments', Api\Tenant\EnvironmentController::class, 'store'], + 'tenants.environments.show' => ['api.tenants.environments.show', 'GET', 'api/tenants/{tenant}/environments/{environment}', Api\Tenant\EnvironmentController::class, 'show'], + 'tenants.environments.update' => ['api.tenants.environments.update', 'PUT', 'api/tenants/{tenant}/environments/{environment}', Api\Tenant\EnvironmentController::class, 'update'], + 'tenants.environments.destroy' => ['api.tenants.environments.destroy', 'DELETE', 'api/tenants/{tenant}/environments/{environment}', Api\Tenant\EnvironmentController::class, 'destroy'], + + 'tenants.environments.audit-log.index' => ['api.tenants.environments.audit-log.index', 'GET', 'api/tenants/{tenant}/environments/{environment}/audit-log', Api\Tenant\Environment\AuditLogController::class, 'index'], + + 'tenants.environments.users.index' => ['api.tenants.environments.users.index', 'GET', 'api/tenants/{tenant}/environments/{environment}/users', Api\Tenant\Environment\UserController::class, 'index'], + 'tenants.environments.users.store' => ['api.tenants.environments.users.store', 'POST', 'api/tenants/{tenant}/environments/{environment}/users', Api\Tenant\Environment\UserController::class, 'store'], + 'tenants.environments.users.show' => ['api.tenants.environments.users.show', 'GET', 'api/tenants/{tenant}/environments/{environment}/users/{user}', Api\Tenant\Environment\UserController::class, 'show'], + 'tenants.environments.users.update' => ['api.tenants.environments.users.update', 'PUT', 'api/tenants/{tenant}/environments/{environment}/users/{user}', Api\Tenant\Environment\UserController::class, 'update'], + 'tenants.environments.users.destroy' => ['api.tenants.environments.users.destroy', 'DELETE', 'api/tenants/{tenant}/environments/{environment}/users/{user}', Api\Tenant\Environment\UserController::class, 'destroy'], + + 'tenants.users.index' => ['api.tenants.users.index', 'GET', 'api/tenants/{tenant}/users', Api\Tenant\UserController::class, 'index'], + 'tenants.users.store' => ['api.tenants.users.store', 'POST', 'api/tenants/{tenant}/users', Api\Tenant\UserController::class, 'store'], + 'tenants.users.show' => ['api.tenants.users.show', 'GET', 'api/tenants/{tenant}/users/{user}', Api\Tenant\UserController::class, 'show'], + 'tenants.users.update' => ['api.tenants.users.update', 'PUT', 'api/tenants/{tenant}/users/{user}', Api\Tenant\UserController::class, 'update'], + 'tenants.users.destroy' => ['api.tenants.users.destroy', 'DELETE', 'api/tenants/{tenant}/users/{user}', Api\Tenant\UserController::class, 'destroy'], + + 'tenants.plans.index' => ['api.tenants.plans.index', 'GET', 'api/tenants/{tenant}/plans', Api\Tenant\PlanController::class, 'index'], + 'tenants.plans.store' => ['api.tenants.plans.store', 'POST', 'api/tenants/{tenant}/plans', Api\Tenant\PlanController::class, 'store'], + 'tenants.plans.show' => ['api.tenants.plans.show', 'GET', 'api/tenants/{tenant}/plans/{plan}', Api\Tenant\PlanController::class, 'show'], + 'tenants.plans.update' => ['api.tenants.plans.update', 'PUT', 'api/tenants/{tenant}/plans/{plan}', Api\Tenant\PlanController::class, 'update'], + 'tenants.plans.destroy' => ['api.tenants.plans.destroy', 'DELETE', 'api/tenants/{tenant}/plans/{plan}', Api\Tenant\PlanController::class, 'destroy'], + 'tenants.plans.resources.index' => ['api.tenants.plans.resources.index', 'GET', 'api/tenants/{tenant}/plans/{plan}/resources', Api\Tenant\Plan\PlanResourceController::class, 'index'], + 'tenants.plans.resources.store' => ['api.tenants.plans.resources.store', 'POST', 'api/tenants/{tenant}/plans/{plan}/resources', Api\Tenant\Plan\PlanResourceController::class, 'store'], + 'tenants.plans.resources.destroy' => ['api.tenants.plans.resources.destroy', 'DELETE', 'api/tenants/{tenant}/plans/{plan}/resources/{resource}', Api\Tenant\Plan\PlanResourceController::class, 'destroy'], + + 'tenants.roles.index' => ['api.tenants.roles.index', 'GET', 'api/tenants/{tenant}/roles', Api\Tenant\RoleController::class, 'index'], + 'tenants.roles.store' => ['api.tenants.roles.store', 'POST', 'api/tenants/{tenant}/roles', Api\Tenant\RoleController::class, 'store'], + 'tenants.roles.show' => ['api.tenants.roles.show', 'GET', 'api/tenants/{tenant}/roles/{role}', Api\Tenant\RoleController::class, 'show'], + 'tenants.roles.update' => ['api.tenants.roles.update', 'PUT', 'api/tenants/{tenant}/roles/{role}', Api\Tenant\RoleController::class, 'update'], + 'tenants.roles.destroy' => ['api.tenants.roles.destroy', 'DELETE', 'api/tenants/{tenant}/roles/{role}', Api\Tenant\RoleController::class, 'destroy'], + 'tenants.roles.permissions.index' => ['api.tenants.roles.permissions.index', 'GET', 'api/tenants/{tenant}/roles/{role}/permissions', Api\Tenant\Role\RolePermissionController::class, 'index'], + 'tenants.roles.permissions.store' => ['api.tenants.roles.permissions.store', 'POST', 'api/tenants/{tenant}/roles/{role}/permissions', Api\Tenant\Role\RolePermissionController::class, 'store'], + 'tenants.roles.permissions.destroy' => ['api.tenants.roles.permissions.destroy', 'DELETE', 'api/tenants/{tenant}/roles/{role}/permissions/{permission}', Api\Tenant\Role\RolePermissionController::class, 'destroy'], + + 'plans.resources.available' => ['api.plans.resources.available', 'GET', 'api/plans/resources', Api\ResourceController::class, 'index'], + 'plans.index' => ['api.plans.index', 'GET', 'api/plans', Api\PlanController::class, 'index'], + 'plans.store' => ['api.plans.store', 'POST', 'api/plans', Api\PlanController::class, 'store'], + 'plans.show' => ['api.plans.show', 'GET', 'api/plans/{plan}', Api\PlanController::class, 'show'], + 'plans.update' => ['api.plans.update', 'PUT', 'api/plans/{plan}', Api\PlanController::class, 'update'], + 'plans.destroy' => ['api.plans.destroy', 'DELETE', 'api/plans/{plan}', Api\PlanController::class, 'destroy'], + 'plans.resources.index' => ['api.plans.resources.index', 'GET', 'api/plans/{plan}/resources', Api\Plan\PlanResourceController::class, 'index'], + 'plans.resources.store' => ['api.plans.resources.store', 'POST', 'api/plans/{plan}/resources', Api\Plan\PlanResourceController::class, 'store'], + 'plans.resources.destroy' => ['api.plans.resources.destroy', 'DELETE', 'api/plans/{plan}/resources/{resource}', Api\Plan\PlanResourceController::class, 'destroy'], + 'plans.users.index' => ['api.plans.users.index', 'GET', 'api/plans/{plan}/users', Api\Plan\UserController::class, 'index'], + + 'roles.permissions.available' => ['api.roles.permissions.available', 'GET', 'api/roles/permissions', Api\PermissionController::class, 'index'], + 'roles.index' => ['api.roles.index', 'GET', 'api/roles', Api\RoleController::class, 'index'], + 'roles.store' => ['api.roles.store', 'POST', 'api/roles', Api\RoleController::class, 'store'], + 'roles.show' => ['api.roles.show', 'GET', 'api/roles/{role}', Api\RoleController::class, 'show'], + 'roles.update' => ['api.roles.update', 'PUT', 'api/roles/{role}', Api\RoleController::class, 'update'], + 'roles.destroy' => ['api.roles.destroy', 'DELETE', 'api/roles/{role}', Api\RoleController::class, 'destroy'], + 'roles.permissions.index' => ['api.roles.permissions.index', 'GET', 'api/roles/{role}/permissions', Api\Role\RolePermissionController::class, 'index'], + 'roles.permissions.store' => ['api.roles.permissions.store', 'POST', 'api/roles/{role}/permissions', Api\Role\RolePermissionController::class, 'store'], + 'roles.permissions.destroy' => ['api.roles.permissions.destroy', 'DELETE', 'api/roles/{role}/permissions/{permission}', Api\Role\RolePermissionController::class, 'destroy'], + 'roles.users.index' => ['api.roles.users.index', 'GET', 'api/roles/{role}/users', Api\Role\UserController::class, 'index'], + ]; + } +} diff --git a/packages/core/tests/Feature/PlanResourceAuthorizationTest.php b/packages/core/tests/Feature/PlanResourceAuthorizationTest.php index c3e1449..00af62a 100644 --- a/packages/core/tests/Feature/PlanResourceAuthorizationTest.php +++ b/packages/core/tests/Feature/PlanResourceAuthorizationTest.php @@ -136,6 +136,7 @@ public function test_assigned_global_plan_resources_can_change_when_usage_fits() $plan->resources()->attach($resource, ['limit' => 2]); Tenant::query()->create([ 'plan_id' => $plan->id, + 'parent_tenant_id' => Tenant::query()->root()->firstOrFail()->id, 'name' => 'Assigned Global Mutable Tenant ' . str()->ulid(), ]); @@ -166,6 +167,7 @@ public function test_assigned_global_plan_resource_limit_cannot_drop_below_usage $plan->resources()->attach($resource, ['limit' => 2]); $tenant = Tenant::query()->create([ 'plan_id' => $plan->id, + 'parent_tenant_id' => Tenant::query()->root()->firstOrFail()->id, 'name' => 'Assigned Global Usage Tenant ' . str()->ulid(), ]); diff --git a/packages/core/tests/Feature/RolePermissionAuthorizationTest.php b/packages/core/tests/Feature/RolePermissionAuthorizationTest.php index 264ef32..ef62f87 100644 --- a/packages/core/tests/Feature/RolePermissionAuthorizationTest.php +++ b/packages/core/tests/Feature/RolePermissionAuthorizationTest.php @@ -176,6 +176,34 @@ public function test_user_cannot_remove_permission_without_inheritable_delegatio ->assertForbidden(); } + public function test_user_cannot_remove_permission_from_own_global_role(): void + { + $user = User::query()->create([ + 'first_name' => 'Self', + 'last_name' => 'Locked', + 'email' => 'self-locked-global-' . str()->ulid() . '@froxlor.test', + 'password' => 'secret-password', + ]); + $role = Role::query()->create([ + 'name' => 'Self Locked Global Role ' . str()->ulid(), + ]); + $wildcardPermission = Permission::query()->where('key', '*')->firstOrFail(); + $permission = Permission::query()->where('key', 'users.index')->firstOrFail(); + + $role->permissions()->attach($wildcardPermission, ['inheritable' => true]); + $role->permissions()->attach($permission, ['inheritable' => true]); + $user->roles()->attach($role); + + $this->actingAs($user, 'sanctum') + ->deleteJson('/api/roles/' . $role->id . '/permissions/' . $permission->id) + ->assertUnprocessable() + ->assertJsonValidationErrors(['role']); + + $this->assertTrue( + $role->permissions()->where('permissions.id', $permission->id)->exists() + ); + } + public function test_detaching_unassigned_global_role_permission_returns_validation_error(): void { $user = User::query()->where('email', config('dev.email'))->firstOrFail(); diff --git a/packages/core/tests/Feature/TenantRolePermissionAuthorizationTest.php b/packages/core/tests/Feature/TenantRolePermissionAuthorizationTest.php index 9f4c3f0..7ea9b21 100644 --- a/packages/core/tests/Feature/TenantRolePermissionAuthorizationTest.php +++ b/packages/core/tests/Feature/TenantRolePermissionAuthorizationTest.php @@ -76,6 +76,21 @@ public function test_tenant_role_permission_index_lists_assigned_and_unassigned_ $this->assertFalse($unassigned['inheritable']); } + public function test_global_role_permissions_are_managed_through_global_routes(): void + { + $tenant = Tenant::query()->where('name', 'Froxlor')->firstOrFail(); + $user = User::query()->where('email', config('dev.email'))->firstOrFail(); + $role = Role::query()->whereNull('tenant_id')->where('name', 'Reseller')->firstOrFail(); + + $this->actingAs($user, 'sanctum') + ->getJson('/api/roles/' . $role->id . '/permissions') + ->assertOk(); + + $this->actingAs($user, 'sanctum') + ->getJson('/api/tenants/' . $tenant->id . '/roles/' . $role->id . '/permissions') + ->assertForbidden(); + } + public function test_tenant_admin_cannot_assign_non_delegable_permission_to_tenant_role(): void { $tenant = Tenant::query()->where('name', 'First customer')->firstOrFail(); @@ -94,6 +109,36 @@ public function test_tenant_admin_cannot_assign_non_delegable_permission_to_tena ->assertForbidden(); } + public function test_user_cannot_remove_permission_from_own_tenant_role(): void + { + $tenant = Tenant::query()->where('name', 'First customer')->firstOrFail(); + $user = User::query()->create([ + 'first_name' => 'Self', + 'last_name' => 'Tenant Locked', + 'email' => 'self-locked-tenant-' . str()->ulid() . '@froxlor.test', + 'password' => 'secret-password', + ]); + $role = Role::query()->create([ + 'tenant_id' => $tenant->id, + 'name' => 'Self Locked Tenant Role ' . str()->ulid(), + ]); + $wildcardPermission = Permission::query()->where('key', '*')->firstOrFail(); + $permission = Permission::query()->where('key', 'tenants.users.index')->firstOrFail(); + + $role->permissions()->attach($wildcardPermission, ['inheritable' => true]); + $role->permissions()->attach($permission, ['inheritable' => true]); + $user->tenants()->attach($tenant, ['role_id' => $role->id]); + + $this->actingAs($user, 'sanctum') + ->deleteJson('/api/tenants/' . $tenant->id . '/roles/' . $role->id . '/permissions/' . $permission->id) + ->assertUnprocessable() + ->assertJsonValidationErrors(['role']); + + $this->assertTrue( + $role->permissions()->where('permissions.id', $permission->id)->exists() + ); + } + public function test_tenant_role_permission_route_rejects_foreign_and_global_roles(): void { $tenant = Tenant::query()->where('name', 'First customer')->firstOrFail();