diff --git a/composer.json b/composer.json index 035d5e5..347b960 100644 --- a/composer.json +++ b/composer.json @@ -1,7 +1,7 @@ { "name": "dashboard/unifi", "description": "UniFi network management, WiFi stats, and captive portal authentication for the Dashboard platform", - "version": "1.12.1", + "version": "1.13.0", "type": "library", "license": "MIT", "autoload": { diff --git a/database/migrations/2026_06_12_000001_add_role_and_default_to_unifi_page_grants.php b/database/migrations/2026_06_12_000001_add_role_and_default_to_unifi_page_grants.php new file mode 100644 index 0000000..ddab1ad --- /dev/null +++ b/database/migrations/2026_06_12_000001_add_role_and_default_to_unifi_page_grants.php @@ -0,0 +1,62 @@ +boolean('can_view')->default(true)->after('grantee_id'); + }); + + // Belt and braces: make sure every pre-existing user/group row + // is an explicit allow grant. + DB::table('unifi_page_grants')->update(['can_view' => true]); + } + + // The unique index on (nav_item_id, grantee_type, grantee_id) + // already exists from the create migration + // ('unifi_page_grants_unique') and still applies — grantee_id is + // NULL for default rows, and MySQL treats NULLs as distinct, so + // app code enforces one default row per nav_item. + } + + public function down(): void + { + if (! Schema::hasTable('unifi_page_grants')) return; + + if (Schema::hasColumn('unifi_page_grants', 'can_view')) { + Schema::table('unifi_page_grants', function (Blueprint $table) { + $table->dropColumn('can_view'); + }); + } + + DB::table('unifi_page_grants')->whereIn('grantee_type', ['default', 'role'])->delete(); + + DB::statement("ALTER TABLE unifi_page_grants MODIFY grantee_type ENUM('user','group') NOT NULL"); + DB::statement("ALTER TABLE unifi_page_grants MODIFY grantee_id BIGINT UNSIGNED NOT NULL"); + } +}; diff --git a/src/Http/Controllers/UnifiPagesAccessController.php b/src/Http/Controllers/UnifiPagesAccessController.php index 4ab1dbc..27b8128 100644 --- a/src/Http/Controllers/UnifiPagesAccessController.php +++ b/src/Http/Controllers/UnifiPagesAccessController.php @@ -5,6 +5,7 @@ namespace Dashboard\Unifi\Http\Controllers; use App\Models\DashboardApp; use App\Models\Group; use App\Models\NavItem; +use App\Models\Role; use App\Models\User; use Dashboard\Unifi\Models\UnifiPageGrant; use Illuminate\Http\Request; @@ -21,7 +22,7 @@ class UnifiPagesAccessController extends Controller { $app = DashboardApp::where('slug', 'unifi')->first(); if (! $app) { - return response()->json(['pages' => [], 'users' => [], 'groups' => []]); + return response()->json(['pages' => [], 'users' => [], 'groups' => [], 'roles' => []]); } $pages = NavItem::where('app_id', $app->id) @@ -49,16 +50,27 @@ class UnifiPagesAccessController extends Controller ->orWhereIn('id', $grantedGroupIds); })->orderBy('name')->get(['id', 'name', 'is_super']); + // Roles: only ones with at least one grant — more added via searchRoles. + $grantedRoleIds = $grants->flatten(1)->where('grantee_type', 'role')->pluck('grantee_id')->unique(); + $roles = Role::whereIn('id', $grantedRoleIds)->orderBy('label')->get(['id', 'slug', 'label']); + return response()->json([ - 'pages' => $pages->map(fn ($p) => [ - 'id' => $p->id, - 'label' => $p->label, - 'route_name' => $p->route_name, - 'user_ids' => $grants->get($p->id, collect())->where('grantee_type', 'user')->pluck('grantee_id')->all(), - 'group_ids' => $grants->get($p->id, collect())->where('grantee_type', 'group')->pluck('grantee_id')->all(), - ])->values(), + 'pages' => $pages->map(function ($p) use ($grants) { + $pageGrants = $grants->get($p->id, collect()); + $defaultRow = $pageGrants->firstWhere('grantee_type', 'default'); + return [ + 'id' => $p->id, + 'label' => $p->label, + 'route_name' => $p->route_name, + 'default_allow' => (bool) ($defaultRow?->can_view ?? false), + 'user_ids' => $pageGrants->where('grantee_type', 'user')->where('can_view', true)->pluck('grantee_id')->values()->all(), + 'group_ids' => $pageGrants->where('grantee_type', 'group')->where('can_view', true)->pluck('grantee_id')->values()->all(), + 'role_ids' => $pageGrants->where('grantee_type', 'role')->where('can_view', true)->pluck('grantee_id')->values()->all(), + ]; + })->values(), 'users' => $users, 'groups' => $groups, + 'roles' => $roles, ]); } @@ -106,6 +118,26 @@ class UnifiPagesAccessController extends Controller return response()->json(['groups' => $groups]); } + /** + * Typeahead-style search for roles to add to the access matrix. + * An empty query returns every role (the role list is small). + */ + public function searchRoles(Request $request) + { + $q = trim((string) $request->query('q', '')); + if (strlen($q) < 1) { + return response()->json(['roles' => Role::orderBy('label')->get(['id', 'slug', 'label'])]); + } + + $roles = Role::where('label', 'like', '%' . $q . '%') + ->orWhere('slug', 'like', '%' . $q . '%') + ->orderBy('label') + ->limit(20) + ->get(['id', 'slug', 'label']); + + return response()->json(['roles' => $roles]); + } + public function update(Request $request, NavItem $navItem) { $app = DashboardApp::where('slug', 'unifi')->first(); @@ -114,28 +146,62 @@ class UnifiPagesAccessController extends Controller } $data = $request->validate([ - 'user_ids' => 'present|array', - 'user_ids.*' => 'integer|exists:users,id', - 'group_ids' => 'present|array', - 'group_ids.*' => 'integer|exists:groups,id', + 'default_allow' => 'boolean', + 'user_ids' => 'present|array', + 'user_ids.*' => 'integer|exists:users,id', + 'group_ids' => 'present|array', + 'group_ids.*' => 'integer|exists:groups,id', + 'role_ids' => 'array', + 'role_ids.*' => 'integer|exists:roles,id', ]); $grantedBy = $request->user()?->id; DB::transaction(function () use ($navItem, $data, $grantedBy) { - UnifiPageGrant::where('nav_item_id', $navItem->id)->delete(); + // Upsert the default row (one per nav_item). firstOrCreate can't + // match on grantee_id=NULL reliably in MySQL, so look it up first. + $default = UnifiPageGrant::where('nav_item_id', $navItem->id) + ->where('grantee_type', 'default') + ->first(); + if ($default) { + $default->update(['can_view' => (bool) ($data['default_allow'] ?? false)]); + } else { + UnifiPageGrant::create([ + 'nav_item_id' => $navItem->id, + 'grantee_type' => 'default', + 'grantee_id' => null, + 'can_view' => (bool) ($data['default_allow'] ?? false), + 'granted_by_user_id' => $grantedBy, + ]); + } - $rows = []; - $now = now(); - foreach ($data['user_ids'] as $uid) { - $rows[] = ['nav_item_id' => $navItem->id, 'grantee_type' => 'user', 'grantee_id' => $uid, 'granted_by_user_id' => $grantedBy, 'created_at' => $now, 'updated_at' => $now]; - } - foreach ($data['group_ids'] as $gid) { - $rows[] = ['nav_item_id' => $navItem->id, 'grantee_type' => 'group', 'grantee_id' => $gid, 'granted_by_user_id' => $grantedBy, 'created_at' => $now, 'updated_at' => $now]; - } - if ($rows) UnifiPageGrant::insert($rows); + $this->syncGrantsOfType($navItem->id, 'user', $data['user_ids'] ?? [], $grantedBy); + $this->syncGrantsOfType($navItem->id, 'group', $data['group_ids'] ?? [], $grantedBy); + $this->syncGrantsOfType($navItem->id, 'role', $data['role_ids'] ?? [], $grantedBy); }); return response()->json(['ok' => true]); } + + private function syncGrantsOfType(int $navItemId, string $type, array $ids, ?int $actorId): void + { + UnifiPageGrant::where('nav_item_id', $navItemId) + ->where('grantee_type', $type) + ->whereNotIn('grantee_id', $ids ?: [0]) + ->delete(); + + foreach ($ids as $id) { + UnifiPageGrant::updateOrCreate( + [ + 'nav_item_id' => $navItemId, + 'grantee_type' => $type, + 'grantee_id' => $id, + ], + [ + 'can_view' => true, + 'granted_by_user_id' => $actorId, + ], + ); + } + } } diff --git a/src/Http/Middleware/EnforceUnifiPageGrant.php b/src/Http/Middleware/EnforceUnifiPageGrant.php new file mode 100644 index 0000000..ce6297c --- /dev/null +++ b/src/Http/Middleware/EnforceUnifiPageGrant.php @@ -0,0 +1,51 @@ +user() is still null. + */ +class EnforceUnifiPageGrant +{ + public function handle(Request $request, Closure $next): Response + { + $user = $request->user(); + $routeName = $request->route()?->getName(); + + if (! $routeName || ! $user || $user->is_super_admin) { + return $next($request); + } + + try { + $item = NavItem::where('route_name', $routeName)->first(); + if ($item) { + // Permission holders keep access — grants extend, never revoke. + $holdsPermission = $item->required_permission + && UnifiServiceProvider::realPermissionKeysFor($user)->contains($item->required_permission); + + if (! $holdsPermission && ! UnifiPageGrant::userCanAccess($user, $item)) { + // 404 instead of 403 — don't leak that the page exists. + abort(404); + } + } + } catch (\Symfony\Component\HttpKernel\Exception\HttpException $e) { + throw $e; + } catch (\Throwable) { + // unifi_page_grants may not exist yet on first install — fail + // open in that narrow window (permission middleware still + // guards the route). + } + + return $next($request); + } +} diff --git a/src/Models/UnifiPageGrant.php b/src/Models/UnifiPageGrant.php index bd11110..d911ba5 100644 --- a/src/Models/UnifiPageGrant.php +++ b/src/Models/UnifiPageGrant.php @@ -13,11 +13,16 @@ class UnifiPageGrant extends Model protected $fillable = [ 'nav_item_id', - 'grantee_type', - 'grantee_id', + 'grantee_type', // default | user | group | role + 'grantee_id', // null for grantee_type=default + 'can_view', 'granted_by_user_id', ]; + protected $casts = [ + 'can_view' => 'boolean', + ]; + public function navItem(): BelongsTo { return $this->belongsTo(NavItem::class); @@ -29,30 +34,32 @@ class UnifiPageGrant extends Model } /** - * True iff $user is allowed to access $navItem under strict allowlist - * semantics: - * * super-admins (the model-level flag) always pass - * * otherwise the user must be a direct grantee, OR be in a group - * that is a grantee - * - * A page with NO grants saved is only visible to super-admins — - * the admin must explicitly authorize everyone else via the - * Access tab. + * Grant check with a per-page default fallback. Passes if the user + * matches an explicit user/group/role grant; otherwise falls back + * to the page's `default` row (deny by default — no row or + * can_view=false denies). Permission-based access is checked + * separately by the caller — grants extend, never revoke. */ public static function userCanAccess(User $user, NavItem $navItem): bool { if ($user->is_super_admin) return true; $groupIds = $user->groups()->pluck('groups.id'); + $roleIds = $user->roles()->pluck('roles.id'); - return static::where('nav_item_id', $navItem->id) - ->where(function ($q) use ($user, $groupIds) { - $q->where(function ($u) use ($user) { - $u->where('grantee_type', 'user')->where('grantee_id', $user->id); - })->orWhere(function ($g) use ($groupIds) { - $g->where('grantee_type', 'group')->whereIn('grantee_id', $groupIds); - }); + $hasExplicit = static::where('nav_item_id', $navItem->id) + ->where('can_view', true) + ->where(function ($q) use ($user, $groupIds, $roleIds) { + $q->where(fn ($u) => $u->where('grantee_type', 'user')->where('grantee_id', $user->id)) + ->orWhere(fn ($g) => $g->where('grantee_type', 'group')->whereIn('grantee_id', $groupIds)) + ->orWhere(fn ($r) => $r->where('grantee_type', 'role')->whereIn('grantee_id', $roleIds)); }) ->exists(); + + if ($hasExplicit) return true; + + return (bool) static::where('nav_item_id', $navItem->id) + ->where('grantee_type', 'default') + ->value('can_view'); } } diff --git a/src/UnifiServiceProvider.php b/src/UnifiServiceProvider.php index 2bbefc6..18cd102 100644 --- a/src/UnifiServiceProvider.php +++ b/src/UnifiServiceProvider.php @@ -2,7 +2,6 @@ namespace Dashboard\Unifi; -use App\Models\DashboardApp; use App\Models\NavItem; use Dashboard\Unifi\Models\UnifiPageGrant; use Illuminate\Routing\Events\RouteMatched; @@ -25,59 +24,34 @@ class UnifiServiceProvider extends ServiceProvider $this->loadRoutesFrom(__DIR__ . '/routes/unifi.php'); $this->loadMigrationsFrom(__DIR__ . '/../database/migrations'); - // Tell the shell's NavVisibilityRegistry which unifi nav items - // the user can see in the sidebar. Without this the sidebar - // would only follow legacy required_permission, hiding pages - // the user has been explicitly granted via the Access tab. + // Tell the shell's nav sidebar which unifi nav items the user + // can see: pages whose required_permission the user holds through + // groups, plus pages granted via the Settings → Access tab. The + // shell also folds these grants back into User::can()/allPermissions + // so the route-level permission middleware passes for grantees. try { app(\App\Support\NavVisibilityRegistry::class)->register( 'unifi.', - function (\App\Models\User $user) { - if (! \Illuminate\Support\Facades\Schema::hasTable('unifi_page_grants')) { - return collect(); - } - $groupIds = $user->groups()->pluck('groups.id'); - return UnifiPageGrant::query() - ->where(function ($q) use ($user, $groupIds) { - $q->where(fn ($u) => $u->where('grantee_type', 'user')->where('grantee_id', $user->id)) - ->orWhere(fn ($g) => $g->where('grantee_type', 'group')->whereIn('grantee_id', $groupIds)); - }) - ->pluck('nav_item_id'); - } + fn (\App\Models\User $user) => $this->visibleUnifiNavItemIdsFor($user), ); } catch (\Throwable) { // Shell may not have the registry yet (older shell version). // Sidebar will fall back to legacy permission filter. } - // Per-page access enforcement for unifi routes. If a unifi page has - // any UnifiPageGrant rows, only super-admins and granted users/ - // groups can hit it; otherwise (no grants) it's open per the existing - // permission middleware. Super-admins always bypass. + // Per-page enforcement for unifi pages. Settings stays on its + // permission:unifi.settings route middleware (the Access tab + // itself lives there and must not be able to lock itself out). + // The user-dependent check lives in middleware appended to the + // matched route: RouteMatched fires before the session/auth + // middleware run, so request->user() is null here and any check + // at this point silently fails open. Event::listen(RouteMatched::class, function (RouteMatched $event) { $routeName = $event->route->getName(); if (! $routeName || ! str_starts_with($routeName, 'unifi.')) return; + if (str_starts_with($routeName, 'unifi.settings')) return; - $user = $event->request->user(); - if (! $user || $user->is_super_admin) return; - - try { - $appId = DashboardApp::where('slug', 'unifi')->value('id'); - $item = NavItem::where('route_name', $routeName) - ->where('app_id', $appId) - ->first(); - if (! $item) return; - - if (! UnifiPageGrant::userCanAccess($user, $item)) { - // 404 instead of 403 — don't leak that the page - // exists. The Access tab is the only way in. - abort(404); - } - } catch (\Throwable) { - // unifi_page_grants table may not exist yet on a fresh - // install before this snap-in's migrations have run — - // fail open in that narrow window. - } + $event->route->middleware(\Dashboard\Unifi\Http\Middleware\EnforceUnifiPageGrant::class); }); if ($this->app->runningInConsole()) { @@ -94,4 +68,58 @@ class UnifiServiceProvider extends ServiceProvider ], 'unifi-config'); } } + + /** Collect every unifi nav_item_id this user is allowed to see. */ + protected function visibleUnifiNavItemIdsFor(\App\Models\User $user): \Illuminate\Support\Collection + { + $ids = collect(); + + // Pages whose required_permission the user already holds through + // groups keep showing, independent of the access-grant matrix. + $permittedIds = NavItem::where('route_name', 'like', 'unifi.%') + ->whereIn('required_permission', static::realPermissionKeysFor($user)) + ->pluck('id'); + $ids = $ids->merge($permittedIds); + + if (\Illuminate\Support\Facades\Schema::hasTable('unifi_page_grants')) { + $groupIds = $user->groups()->pluck('groups.id'); + $roleIds = $user->roles()->pluck('roles.id'); + + $grantedIds = UnifiPageGrant::query() + ->where('can_view', true) + ->where(function ($q) use ($user, $groupIds, $roleIds) { + $q->where(fn ($u) => $u->where('grantee_type', 'user')->where('grantee_id', $user->id)) + ->orWhere(fn ($g) => $g->where('grantee_type', 'group')->whereIn('grantee_id', $groupIds)) + ->orWhere(fn ($r) => $r->where('grantee_type', 'role')->whereIn('grantee_id', $roleIds)) + // Default-allow row makes the page visible to everyone. + ->orWhere('grantee_type', 'default'); + }) + ->pluck('nav_item_id'); + $ids = $ids->merge($grantedIds); + } + + return $ids->unique()->values(); + } + + /** + * Group + direct permissions only. Deliberately NOT allPermissions(): + * the shell folds page-grant permissions back into allPermissions/can, + * so using it here would turn a single granted page into every page + * carrying the same permission. + */ + public static function realPermissionKeysFor(\App\Models\User $user): \Illuminate\Support\Collection + { + if ($user->is_super_admin) { + return \App\Models\Permission::pluck('key'); + } + + $direct = $user->directPermissions()->get(); + + return \App\Models\Permission::whereHas('groups', fn ($q) => $q->whereIn('group_id', $user->groups()->pluck('groups.id'))) + ->pluck('key') + ->merge($direct->where('granted', true)->pluck('permission_key')) + ->diff($direct->where('granted', false)->pluck('permission_key')) + ->unique() + ->values(); + } } diff --git a/src/routes/unifi.php b/src/routes/unifi.php index 0b29cc2..e08e181 100644 --- a/src/routes/unifi.php +++ b/src/routes/unifi.php @@ -74,11 +74,13 @@ Route::middleware(['web', 'auth', 'app.access:unifi']) Route::post('/settings/sites', [UnifiSettingsController::class, 'fetchSites']) ->name('settings.sites'); // Page Access — super-admin only. Lists unifi pages and lets - // operators assign per-page user/group grants. + // operators assign per-page user/group/role grants plus the + // per-page "everyone else" default row (deny by default). Route::middleware('super.admin')->group(function () { Route::get('/settings/pages-access', [UnifiPagesAccessController::class, 'index']) ->name('settings.pages-access.index'); Route::get('/settings/pages-access/users/search', [UnifiPagesAccessController::class, 'searchUsers'])->name('settings.pages-access.users.search'); Route::get('/settings/pages-access/groups/search', [UnifiPagesAccessController::class, 'searchGroups'])->name('settings.pages-access.groups.search'); + Route::get('/settings/pages-access/roles/search', [UnifiPagesAccessController::class, 'searchRoles'])->name('settings.pages-access.roles.search'); Route::put('/settings/pages-access/{navItem}', [UnifiPagesAccessController::class, 'update']) ->name('settings.pages-access.update'); });