Files
dashboard-unifi/src/UnifiServiceProvider.php
jwed 462a1a3611 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>
2026-06-12 21:31:41 -04:00

126 lines
5.3 KiB
PHP

<?php
namespace Dashboard\Unifi;
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
{
public function register(): void
{
$this->mergeConfigFrom(__DIR__ . '/../config/unifi.php', 'unifi');
$this->app->singleton(Services\UnifiApiClient::class, function ($app) {
return new Services\UnifiApiClient();
});
}
public function boot(): void
{
$this->loadRoutesFrom(__DIR__ . '/routes/unifi.php');
$this->loadMigrationsFrom(__DIR__ . '/../database/migrations');
// 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.',
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 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;
$event->route->middleware(\Dashboard\Unifi\Http\Middleware\EnforceUnifiPageGrant::class);
});
if ($this->app->runningInConsole()) {
$this->commands([
Console\CheckWebhooks::class,
Console\CaptureSnapshots::class,
Console\CleanupSnapshots::class,
Console\RebootAllAps::class,
Console\RotatePasswords::class,
Console\SyncPpskSchedules::class,
]);
$this->publishes([
__DIR__ . '/../config/unifi.php' => config_path('unifi.php'),
], '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();
}
}