Gate the guest Wi-Fi card on its own key, and gate its poll endpoint properly

The home card used to be grant-gated by the shell's card table, which left the
poll endpoint behind `auth` alone — and that endpoint returns a LIVE CREDENTIAL:
the guest network's current rotating password and its join QR code. "Any logged-in
user" was never an adequate gate for that; the card table was doing the work, and
the card table is gone.

New key rather than reuse: unifi.guest_wifi exists precisely so front-desk staff
can be given the guest password WITHOUT any other network access. Reusing
unifi.settings would have meant handing out controller configuration to get a
Wi-Fi password read.

Authorised inside the controller rather than by route middleware, so the endpoint
stays outside the unifi page-permission groups — it is a home-page widget, not a
unifi page.

Nobody holds the new key until it is granted, so the card is invisible until then.
That is the intended direction: capability is assigned deliberately.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDA3fQbsa3gaHCFoV1Q8Gg
This commit is contained in:
2026-07-30 12:15:05 -04:00
parent c93f73ce02
commit 186c1eaf2b
4 changed files with 47 additions and 18 deletions

View File

@@ -35,7 +35,8 @@
{ "key": "unifi.stats", "label": "View Network Stats", "description": "View WiFi dashboards, AP stats, and client lists" }, { "key": "unifi.stats", "label": "View Network Stats", "description": "View WiFi dashboards, AP stats, and client lists" },
{ "key": "unifi.manage", "label": "Manage Network", "description": "Reboot APs, manage SSIDs, change WiFi passwords" }, { "key": "unifi.manage", "label": "Manage Network", "description": "Reboot APs, manage SSIDs, change WiFi passwords" },
{ "key": "unifi.auth", "label": "Manage Portal Auth", "description": "Configure captive portal, VLAN mappings, MAC allowlist" }, { "key": "unifi.auth", "label": "Manage Portal Auth", "description": "Configure captive portal, VLAN mappings, MAC allowlist" },
{ "key": "unifi.settings", "label": "Network Settings", "description": "Configure UniFi controller connection" } { "key": "unifi.settings", "label": "Network Settings", "description": "Configure UniFi controller connection" },
{ "key": "unifi.guest_wifi", "label": "View Guest Wi-Fi Password", "description": "See the guest network name, its current rotating password, and the join QR code on the home page. Grants no other network access." }
] ]
} }
}, },

View File

@@ -57,29 +57,41 @@ class GuestCardController extends Controller
* unchecked, so any signed-in account — every student and parent included — * unchecked, so any signed-in account — every student and parent included —
* could fetch the live guest Wi-Fi password by requesting the URL directly. * could fetch the live guest Wi-Fi password by requesting the URL directly.
* *
* The endpoint now enforces what the comment always claimed: you may poll it iff * The endpoint enforces what the comment always claimed: you may poll it iff
* you may see the card. Super admins pass, as everywhere else. Kept as the card * you may see the card. Still asked as the CARD gate — via the registry, which
* gate rather than a permission so the widget stays grantable to non-unifi staff, * now answers from the card's permission (unifi.guest_wifi) instead of the
* which is the point of a home-page card. * retired dashboard_card_grants table. Asking the registry rather than naming
* the key here is deliberate: the endpoint and the home page cannot drift.
* Super admins pass, as everywhere else (keysFor handles that).
*/ */
public function data(Request $request) public function data(Request $request)
{ {
$user = $request->user(); $user = $request->user();
abort_unless($user, 403); abort_unless($user, 403);
if (! $user->is_super_admin) { abort_unless(
$allowed = \App\Models\DashboardCardGrant::accessibleCardKeysFor($user) app(\App\Support\DashboardCardRegistry::class)->visibleTo($user, self::CARD_KEY),
->contains(self::CARD_KEY); 403,
);
abort_unless($allowed, 403);
}
return response()->json(self::payload() ?? ['title' => null]); return response()->json(self::payload() ?? ['title' => null]);
} }
/** Card registry resolver. */ /**
* Card registry resolver.
*
* Re-checks the permission the registry already gated on, the way bosch's and
* signinout's resolvers do. Redundant on the home-page path and deliberately
* kept: renderFor() renders whatever key list it is handed, and the payload
* here is a live credential — the one card where "the caller asked the right
* question" is not a good enough reason to hand it over.
*/
public static function resolve(User $user): ?array public static function resolve(User $user): ?array
{ {
if (! $user->can('unifi.guest_wifi')) {
return null;
}
return self::payload(); return self::payload();
} }

View File

@@ -22,9 +22,22 @@ class UnifiServiceProvider extends ServiceProvider
$this->loadMigrationsFrom(__DIR__ . '/../database/migrations'); $this->loadMigrationsFrom(__DIR__ . '/../database/migrations');
// "/" home card: guest Wi-Fi network + current rotating password + // "/" home card: guest Wi-Fi network + current rotating password +
// QR (re-renders when the password changes). Grant-gated via // QR (re-renders when the password changes). Self-hides until enabled
// Admin → Settings → Dashboard Cards; self-hides until enabled and // and a password has been rotated.
// a password has been rotated. //
// The ONE card in the platform that earned a NEW permission key rather
// than reusing one. It cannot be universal — the payload is a live
// credential, and publishing it to every student and parent is the exact
// hole GuestCardController::data was written to close. And no existing
// unifi key fits: unifi.stats is the network dashboards, unifi.settings is
// the controller connection, and handing the front desk either one to let
// them read a guest password would widen access, not reuse it. So the
// granularity goes in the catalogue (same move as the papercut/inventory
// split), and the key is grantable to non-unifi staff — which is the whole
// point of a home-page card.
//
// Declared in composer.json extra.dashboard.permissions, so the catalogue
// row appears when unifi is installed/updated — NOT seeded to any group.
try { try {
app(\App\Support\DashboardCardRegistry::class)->register( app(\App\Support\DashboardCardRegistry::class)->register(
'unifi.guest_wifi', 'unifi.guest_wifi',
@@ -32,6 +45,7 @@ class UnifiServiceProvider extends ServiceProvider
'UnifiGuestWifi', 'UnifiGuestWifi',
fn (\App\Models\User $user) => \Dashboard\Unifi\Http\Controllers\GuestCardController::resolve($user), fn (\App\Models\User $user) => \Dashboard\Unifi\Http\Controllers\GuestCardController::resolve($user),
'The guest network name, current password, and a join QR code — updates automatically when the password rotates.', 'The guest network name, current password, and a join QR code — updates automatically when the password rotates.',
permission: 'unifi.guest_wifi',
); );
} catch (\Throwable) { } catch (\Throwable) {
// Older shell without the card registry — skip. // Older shell without the card registry — skip.

View File

@@ -92,9 +92,11 @@ Route::middleware(['web', 'auth', 'app.access:unifi'])
}); });
}); });
// ── Guest Wi-Fi home-card poll (any logged-in user; card visibility is // ── Guest Wi-Fi home-card poll. Authorises inside the controller against the
// grant-gated by the shell). Exempt from the unifi page-grant middleware // card's own visibility (unifi.guest_wifi) — it returns a live credential, so
// because it's a home-page widget, not a unifi page. ── // `auth` alone is not a gate. Deliberately outside the permission groups above
// because it's a home-page widget, not a unifi page: unifi.guest_wifi is
// grantable to front-desk staff without any other network access. ──
Route::middleware(['web', 'auth']) Route::middleware(['web', 'auth'])
->get('/app/unifi/guest-card/data', [\Dashboard\Unifi\Http\Controllers\GuestCardController::class, 'data']) ->get('/app/unifi/guest-card/data', [\Dashboard\Unifi\Http\Controllers\GuestCardController::class, 'data'])
->name('unifi.guest-card.data'); ->name('unifi.guest-card.data');