From a33f2885ff8c3ee12f90c92ea72e36800bb6d00b Mon Sep 17 00:00:00 2001 From: jwed Date: Sat, 23 May 2026 16:47:57 -0400 Subject: [PATCH] feat(access): per-page user/group grants, snap-in-local A snap-in-owned access mechanism. Adds: - unifi_page_grants table (nav_item_id, grantee_type, grantee_id) with cascadeOnDelete from nav_items so uninstalling the snap-in wipes its grant rows automatically - UnifiPageGrant model + ::userCanAccess(user, navItem) helper - UnifiPagesAccessController (index + update), super-admin only - RouteMatched listener in UnifiServiceProvider that 403s any unifi.* route if the matched nav_item has grants and the user isn't a super-admin / granted user / member of a granted group Semantics: a page with NO grants stays open per the existing permission middleware (no behaviour change). The moment grants are added, ONLY super-admins and listed users/groups can see/open the page. Super-admins always pass; their access can't be removed. v1.4.0. Co-Authored-By: Claude Opus 4.7 (1M context) --- composer.json | 2 +- ..._000001_create_unifi_page_grants_table.php | 38 +++++++++ .../UnifiPagesAccessController.php | 82 +++++++++++++++++++ src/Models/UnifiPageGrant.php | 56 +++++++++++++ src/UnifiServiceProvider.php | 33 ++++++++ src/routes/unifi.php | 8 ++ 6 files changed, 218 insertions(+), 1 deletion(-) create mode 100644 database/migrations/2026_05_23_000001_create_unifi_page_grants_table.php create mode 100644 src/Http/Controllers/UnifiPagesAccessController.php create mode 100644 src/Models/UnifiPageGrant.php diff --git a/composer.json b/composer.json index 7fe11c2..5aa4bd8 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.3.1", + "version": "1.4.0", "type": "library", "license": "MIT", "autoload": { diff --git a/database/migrations/2026_05_23_000001_create_unifi_page_grants_table.php b/database/migrations/2026_05_23_000001_create_unifi_page_grants_table.php new file mode 100644 index 0000000..0059bcc --- /dev/null +++ b/database/migrations/2026_05_23_000001_create_unifi_page_grants_table.php @@ -0,0 +1,38 @@ +id(); + $table->foreignId('nav_item_id')->constrained('nav_items')->cascadeOnDelete(); + $table->enum('grantee_type', ['user', 'group']); + $table->unsignedBigInteger('grantee_id'); + $table->foreignId('granted_by_user_id')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + + $table->unique(['nav_item_id', 'grantee_type', 'grantee_id'], 'unifi_page_grants_unique'); + $table->index(['grantee_type', 'grantee_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('unifi_page_grants'); + } +}; diff --git a/src/Http/Controllers/UnifiPagesAccessController.php b/src/Http/Controllers/UnifiPagesAccessController.php new file mode 100644 index 0000000..3268164 --- /dev/null +++ b/src/Http/Controllers/UnifiPagesAccessController.php @@ -0,0 +1,82 @@ +first(); + if (! $app) { + return response()->json(['pages' => [], 'users' => [], 'groups' => []]); + } + + $pages = NavItem::where('app_id', $app->id) + ->where('is_folder', false) + ->whereNotNull('route_name') + ->orderBy('sort_order') + ->get(['id', 'label', 'route_name']); + + $grants = UnifiPageGrant::whereIn('nav_item_id', $pages->pluck('id')) + ->get() + ->groupBy('nav_item_id'); + + 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(), + 'users' => User::orderBy('name')->get(['id', 'name', 'email']), + 'groups' => Group::orderBy('name')->get(['id', 'name', 'is_super']), + ]); + } + + public function update(Request $request, NavItem $navItem) + { + $app = DashboardApp::where('slug', 'unifi')->first(); + if (! $app || $navItem->app_id !== $app->id) { + return response()->json(['error' => 'Not a unifi page.'], 422); + } + + $data = $request->validate([ + 'user_ids' => 'present|array', + 'user_ids.*' => 'integer|exists:users,id', + 'group_ids' => 'present|array', + 'group_ids.*' => 'integer|exists:groups,id', + ]); + + $grantedBy = $request->user()?->id; + + DB::transaction(function () use ($navItem, $data, $grantedBy) { + UnifiPageGrant::where('nav_item_id', $navItem->id)->delete(); + + $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); + }); + + return response()->json(['ok' => true]); + } +} diff --git a/src/Models/UnifiPageGrant.php b/src/Models/UnifiPageGrant.php new file mode 100644 index 0000000..5e384e2 --- /dev/null +++ b/src/Models/UnifiPageGrant.php @@ -0,0 +1,56 @@ +belongsTo(NavItem::class); + } + + public function grantedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'granted_by_user_id'); + } + + /** + * True iff $user is allowed to access $navItem under this grant model. + * Super-admins always pass. + * If there are NO grants for the page, falls back to "open" (anyone + * who can reach the route can access — same as before grants existed). + */ + public static function userCanAccess(User $user, NavItem $navItem): bool + { + if ($user->is_super_admin) return true; + + $hasGrants = static::where('nav_item_id', $navItem->id)->exists(); + if (! $hasGrants) return true; + + $groupIds = $user->groups()->pluck('groups.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); + }); + }) + ->exists(); + } +} diff --git a/src/UnifiServiceProvider.php b/src/UnifiServiceProvider.php index 623896a..62c8edf 100644 --- a/src/UnifiServiceProvider.php +++ b/src/UnifiServiceProvider.php @@ -2,6 +2,11 @@ namespace Dashboard\Unifi; +use App\Models\DashboardApp; +use App\Models\NavItem; +use Dashboard\Unifi\Models\UnifiPageGrant; +use Illuminate\Routing\Events\RouteMatched; +use Illuminate\Support\Facades\Event; use Illuminate\Support\ServiceProvider; class UnifiServiceProvider extends ServiceProvider @@ -20,6 +25,34 @@ class UnifiServiceProvider extends ServiceProvider $this->loadRoutesFrom(__DIR__ . '/routes/unifi.php'); $this->loadMigrationsFrom(__DIR__ . '/../database/migrations'); + // 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. + Event::listen(RouteMatched::class, function (RouteMatched $event) { + $routeName = $event->route->getName(); + if (! $routeName || ! str_starts_with($routeName, 'unifi.')) 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)) { + abort(403, 'You do not have access to this page.'); + } + } 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. + } + }); + if ($this->app->runningInConsole()) { $this->commands([ Console\CheckWebhooks::class, diff --git a/src/routes/unifi.php b/src/routes/unifi.php index 079954b..b649e67 100644 --- a/src/routes/unifi.php +++ b/src/routes/unifi.php @@ -4,6 +4,7 @@ use Dashboard\Unifi\Http\Controllers\ClientController; use Dashboard\Unifi\Http\Controllers\DeviceController; use Dashboard\Unifi\Http\Controllers\PortalController; use Dashboard\Unifi\Http\Controllers\StatsController; +use Dashboard\Unifi\Http\Controllers\UnifiPagesAccessController; use Dashboard\Unifi\Http\Controllers\UnifiSettingsController; use Dashboard\Unifi\Http\Controllers\VlanGroupController; use Dashboard\Unifi\Http\Controllers\WebhookController; @@ -70,6 +71,13 @@ Route::middleware(['web', 'auth', 'app.access:unifi']) Route::post('/settings/test', [UnifiSettingsController::class, 'testConnection'])->name('settings.test'); 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. + Route::middleware('super.admin')->group(function () { + Route::get('/settings/pages-access', [UnifiPagesAccessController::class, 'index']) ->name('settings.pages-access.index'); + Route::put('/settings/pages-access/{navItem}', [UnifiPagesAccessController::class, 'update']) ->name('settings.pages-access.update'); + }); + // Webhooks Route::get('/webhooks', [WebhookController::class, 'index']) ->name('webhooks.index'); Route::post('/webhooks', [WebhookController::class, 'store']) ->name('webhooks.store');