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:
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user