feat(1.13.0): page grants on the standard admissions-style pattern
unifi_page_grants gains role + default grantee types and can_view (deny-by-default "Everyone else" row); enforcement moves from the RouteMatched listener — where request->user() is always null and the check silently failed open — into route-appended middleware with the permission-holder pass. Pages-access endpoints gain role search + default-row handling. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "dashboard/unifi",
|
"name": "dashboard/unifi",
|
||||||
"description": "UniFi network management, WiFi stats, and captive portal authentication for the Dashboard platform",
|
"description": "UniFi network management, WiFi stats, and captive portal authentication for the Dashboard platform",
|
||||||
"version": "1.12.1",
|
"version": "1.13.0",
|
||||||
"type": "library",
|
"type": "library",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"autoload": {
|
"autoload": {
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Brings unifi_page_grants to parity with adm_access_grants /
|
||||||
|
* directory_access_grants: widens grantee_type to include 'default' and
|
||||||
|
* 'role', allows a NULL grantee_id (used by the per-page default row),
|
||||||
|
* and adds a can_view column. The default row's can_view carries the
|
||||||
|
* deny/allow fallback for users not matched by a more specific grant
|
||||||
|
* (deny by default — no row means deny).
|
||||||
|
*
|
||||||
|
* Existing user/group rows keep working: adding can_view with a default
|
||||||
|
* of true backfills them as explicit allow grants.
|
||||||
|
*/
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
if (! Schema::hasTable('unifi_page_grants')) return;
|
||||||
|
|
||||||
|
// Widen the enum and allow NULL grantee_id (default rows). MySQL
|
||||||
|
// enum changes require raw DDL.
|
||||||
|
DB::statement("ALTER TABLE unifi_page_grants MODIFY grantee_type ENUM('default','user','group','role') NOT NULL");
|
||||||
|
DB::statement("ALTER TABLE unifi_page_grants MODIFY grantee_id BIGINT UNSIGNED NULL");
|
||||||
|
|
||||||
|
if (! Schema::hasColumn('unifi_page_grants', 'can_view')) {
|
||||||
|
Schema::table('unifi_page_grants', function (Blueprint $table) {
|
||||||
|
$table->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");
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -5,6 +5,7 @@ namespace Dashboard\Unifi\Http\Controllers;
|
|||||||
use App\Models\DashboardApp;
|
use App\Models\DashboardApp;
|
||||||
use App\Models\Group;
|
use App\Models\Group;
|
||||||
use App\Models\NavItem;
|
use App\Models\NavItem;
|
||||||
|
use App\Models\Role;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Dashboard\Unifi\Models\UnifiPageGrant;
|
use Dashboard\Unifi\Models\UnifiPageGrant;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
@@ -21,7 +22,7 @@ class UnifiPagesAccessController extends Controller
|
|||||||
{
|
{
|
||||||
$app = DashboardApp::where('slug', 'unifi')->first();
|
$app = DashboardApp::where('slug', 'unifi')->first();
|
||||||
if (! $app) {
|
if (! $app) {
|
||||||
return response()->json(['pages' => [], 'users' => [], 'groups' => []]);
|
return response()->json(['pages' => [], 'users' => [], 'groups' => [], 'roles' => []]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$pages = NavItem::where('app_id', $app->id)
|
$pages = NavItem::where('app_id', $app->id)
|
||||||
@@ -49,16 +50,27 @@ class UnifiPagesAccessController extends Controller
|
|||||||
->orWhereIn('id', $grantedGroupIds);
|
->orWhereIn('id', $grantedGroupIds);
|
||||||
})->orderBy('name')->get(['id', 'name', 'is_super']);
|
})->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([
|
return response()->json([
|
||||||
'pages' => $pages->map(fn ($p) => [
|
'pages' => $pages->map(function ($p) use ($grants) {
|
||||||
'id' => $p->id,
|
$pageGrants = $grants->get($p->id, collect());
|
||||||
'label' => $p->label,
|
$defaultRow = $pageGrants->firstWhere('grantee_type', 'default');
|
||||||
'route_name' => $p->route_name,
|
return [
|
||||||
'user_ids' => $grants->get($p->id, collect())->where('grantee_type', 'user')->pluck('grantee_id')->all(),
|
'id' => $p->id,
|
||||||
'group_ids' => $grants->get($p->id, collect())->where('grantee_type', 'group')->pluck('grantee_id')->all(),
|
'label' => $p->label,
|
||||||
])->values(),
|
'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,
|
'users' => $users,
|
||||||
'groups' => $groups,
|
'groups' => $groups,
|
||||||
|
'roles' => $roles,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,6 +118,26 @@ class UnifiPagesAccessController extends Controller
|
|||||||
return response()->json(['groups' => $groups]);
|
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)
|
public function update(Request $request, NavItem $navItem)
|
||||||
{
|
{
|
||||||
$app = DashboardApp::where('slug', 'unifi')->first();
|
$app = DashboardApp::where('slug', 'unifi')->first();
|
||||||
@@ -114,28 +146,62 @@ class UnifiPagesAccessController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
$data = $request->validate([
|
$data = $request->validate([
|
||||||
'user_ids' => 'present|array',
|
'default_allow' => 'boolean',
|
||||||
'user_ids.*' => 'integer|exists:users,id',
|
'user_ids' => 'present|array',
|
||||||
'group_ids' => 'present|array',
|
'user_ids.*' => 'integer|exists:users,id',
|
||||||
'group_ids.*' => 'integer|exists:groups,id',
|
'group_ids' => 'present|array',
|
||||||
|
'group_ids.*' => 'integer|exists:groups,id',
|
||||||
|
'role_ids' => 'array',
|
||||||
|
'role_ids.*' => 'integer|exists:roles,id',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$grantedBy = $request->user()?->id;
|
$grantedBy = $request->user()?->id;
|
||||||
|
|
||||||
DB::transaction(function () use ($navItem, $data, $grantedBy) {
|
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 = [];
|
$this->syncGrantsOfType($navItem->id, 'user', $data['user_ids'] ?? [], $grantedBy);
|
||||||
$now = now();
|
$this->syncGrantsOfType($navItem->id, 'group', $data['group_ids'] ?? [], $grantedBy);
|
||||||
foreach ($data['user_ids'] as $uid) {
|
$this->syncGrantsOfType($navItem->id, 'role', $data['role_ids'] ?? [], $grantedBy);
|
||||||
$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]);
|
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,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
51
src/Http/Middleware/EnforceUnifiPageGrant.php
Normal file
51
src/Http/Middleware/EnforceUnifiPageGrant.php
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Dashboard\Unifi\Http\Middleware;
|
||||||
|
|
||||||
|
use App\Models\NavItem;
|
||||||
|
use Closure;
|
||||||
|
use Dashboard\Unifi\Models\UnifiPageGrant;
|
||||||
|
use Dashboard\Unifi\UnifiServiceProvider;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-page access for unifi pages. Attached dynamically by the
|
||||||
|
* RouteMatched listener in UnifiServiceProvider — the check cannot
|
||||||
|
* live in the listener because RouteMatched fires before session/auth
|
||||||
|
* middleware, when request->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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,11 +13,16 @@ class UnifiPageGrant extends Model
|
|||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'nav_item_id',
|
'nav_item_id',
|
||||||
'grantee_type',
|
'grantee_type', // default | user | group | role
|
||||||
'grantee_id',
|
'grantee_id', // null for grantee_type=default
|
||||||
|
'can_view',
|
||||||
'granted_by_user_id',
|
'granted_by_user_id',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'can_view' => 'boolean',
|
||||||
|
];
|
||||||
|
|
||||||
public function navItem(): BelongsTo
|
public function navItem(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(NavItem::class);
|
return $this->belongsTo(NavItem::class);
|
||||||
@@ -29,30 +34,32 @@ class UnifiPageGrant extends Model
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* True iff $user is allowed to access $navItem under strict allowlist
|
* Grant check with a per-page default fallback. Passes if the user
|
||||||
* semantics:
|
* matches an explicit user/group/role grant; otherwise falls back
|
||||||
* * super-admins (the model-level flag) always pass
|
* to the page's `default` row (deny by default — no row or
|
||||||
* * otherwise the user must be a direct grantee, OR be in a group
|
* can_view=false denies). Permission-based access is checked
|
||||||
* that is a grantee
|
* separately by the caller — grants extend, never revoke.
|
||||||
*
|
|
||||||
* A page with NO grants saved is only visible to super-admins —
|
|
||||||
* the admin must explicitly authorize everyone else via the
|
|
||||||
* Access tab.
|
|
||||||
*/
|
*/
|
||||||
public static function userCanAccess(User $user, NavItem $navItem): bool
|
public static function userCanAccess(User $user, NavItem $navItem): bool
|
||||||
{
|
{
|
||||||
if ($user->is_super_admin) return true;
|
if ($user->is_super_admin) return true;
|
||||||
|
|
||||||
$groupIds = $user->groups()->pluck('groups.id');
|
$groupIds = $user->groups()->pluck('groups.id');
|
||||||
|
$roleIds = $user->roles()->pluck('roles.id');
|
||||||
|
|
||||||
return static::where('nav_item_id', $navItem->id)
|
$hasExplicit = static::where('nav_item_id', $navItem->id)
|
||||||
->where(function ($q) use ($user, $groupIds) {
|
->where('can_view', true)
|
||||||
$q->where(function ($u) use ($user) {
|
->where(function ($q) use ($user, $groupIds, $roleIds) {
|
||||||
$u->where('grantee_type', 'user')->where('grantee_id', $user->id);
|
$q->where(fn ($u) => $u->where('grantee_type', 'user')->where('grantee_id', $user->id))
|
||||||
})->orWhere(function ($g) use ($groupIds) {
|
->orWhere(fn ($g) => $g->where('grantee_type', 'group')->whereIn('grantee_id', $groupIds))
|
||||||
$g->where('grantee_type', 'group')->whereIn('grantee_id', $groupIds);
|
->orWhere(fn ($r) => $r->where('grantee_type', 'role')->whereIn('grantee_id', $roleIds));
|
||||||
});
|
|
||||||
})
|
})
|
||||||
->exists();
|
->exists();
|
||||||
|
|
||||||
|
if ($hasExplicit) return true;
|
||||||
|
|
||||||
|
return (bool) static::where('nav_item_id', $navItem->id)
|
||||||
|
->where('grantee_type', 'default')
|
||||||
|
->value('can_view');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
namespace Dashboard\Unifi;
|
namespace Dashboard\Unifi;
|
||||||
|
|
||||||
use App\Models\DashboardApp;
|
|
||||||
use App\Models\NavItem;
|
use App\Models\NavItem;
|
||||||
use Dashboard\Unifi\Models\UnifiPageGrant;
|
use Dashboard\Unifi\Models\UnifiPageGrant;
|
||||||
use Illuminate\Routing\Events\RouteMatched;
|
use Illuminate\Routing\Events\RouteMatched;
|
||||||
@@ -25,59 +24,34 @@ class UnifiServiceProvider extends ServiceProvider
|
|||||||
$this->loadRoutesFrom(__DIR__ . '/routes/unifi.php');
|
$this->loadRoutesFrom(__DIR__ . '/routes/unifi.php');
|
||||||
$this->loadMigrationsFrom(__DIR__ . '/../database/migrations');
|
$this->loadMigrationsFrom(__DIR__ . '/../database/migrations');
|
||||||
|
|
||||||
// Tell the shell's NavVisibilityRegistry which unifi nav items
|
// Tell the shell's nav sidebar which unifi nav items the user
|
||||||
// the user can see in the sidebar. Without this the sidebar
|
// can see: pages whose required_permission the user holds through
|
||||||
// would only follow legacy required_permission, hiding pages
|
// groups, plus pages granted via the Settings → Access tab. The
|
||||||
// the user has been explicitly granted via the Access tab.
|
// shell also folds these grants back into User::can()/allPermissions
|
||||||
|
// so the route-level permission middleware passes for grantees.
|
||||||
try {
|
try {
|
||||||
app(\App\Support\NavVisibilityRegistry::class)->register(
|
app(\App\Support\NavVisibilityRegistry::class)->register(
|
||||||
'unifi.',
|
'unifi.',
|
||||||
function (\App\Models\User $user) {
|
fn (\App\Models\User $user) => $this->visibleUnifiNavItemIdsFor($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');
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
} catch (\Throwable) {
|
} catch (\Throwable) {
|
||||||
// Shell may not have the registry yet (older shell version).
|
// Shell may not have the registry yet (older shell version).
|
||||||
// Sidebar will fall back to legacy permission filter.
|
// Sidebar will fall back to legacy permission filter.
|
||||||
}
|
}
|
||||||
|
|
||||||
// Per-page access enforcement for unifi routes. If a unifi page has
|
// Per-page enforcement for unifi pages. Settings stays on its
|
||||||
// any UnifiPageGrant rows, only super-admins and granted users/
|
// permission:unifi.settings route middleware (the Access tab
|
||||||
// groups can hit it; otherwise (no grants) it's open per the existing
|
// itself lives there and must not be able to lock itself out).
|
||||||
// permission middleware. Super-admins always bypass.
|
// 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) {
|
Event::listen(RouteMatched::class, function (RouteMatched $event) {
|
||||||
$routeName = $event->route->getName();
|
$routeName = $event->route->getName();
|
||||||
if (! $routeName || ! str_starts_with($routeName, 'unifi.')) return;
|
if (! $routeName || ! str_starts_with($routeName, 'unifi.')) return;
|
||||||
|
if (str_starts_with($routeName, 'unifi.settings')) return;
|
||||||
|
|
||||||
$user = $event->request->user();
|
$event->route->middleware(\Dashboard\Unifi\Http\Middleware\EnforceUnifiPageGrant::class);
|
||||||
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.
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if ($this->app->runningInConsole()) {
|
if ($this->app->runningInConsole()) {
|
||||||
@@ -94,4 +68,58 @@ class UnifiServiceProvider extends ServiceProvider
|
|||||||
], 'unifi-config');
|
], '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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,11 +74,13 @@ Route::middleware(['web', 'auth', 'app.access:unifi'])
|
|||||||
Route::post('/settings/sites', [UnifiSettingsController::class, 'fetchSites']) ->name('settings.sites');
|
Route::post('/settings/sites', [UnifiSettingsController::class, 'fetchSites']) ->name('settings.sites');
|
||||||
|
|
||||||
// Page Access — super-admin only. Lists unifi pages and lets
|
// 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::middleware('super.admin')->group(function () {
|
||||||
Route::get('/settings/pages-access', [UnifiPagesAccessController::class, 'index']) ->name('settings.pages-access.index');
|
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/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/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');
|
Route::put('/settings/pages-access/{navItem}', [UnifiPagesAccessController::class, 'update']) ->name('settings.pages-access.update');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user