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>
126 lines
5.3 KiB
PHP
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();
|
|
}
|
|
}
|