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.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.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 —
* 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
* you may see the card. Super admins pass, as everywhere else. Kept as the card
* gate rather than a permission so the widget stays grantable to non-unifi staff,
* which is the point of a home-page card.
* The endpoint enforces what the comment always claimed: you may poll it iff
* you may see the card. Still asked as the CARD gate — via the registry, which
* now answers from the card's permission (unifi.guest_wifi) instead of the
* 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)
{
$user = $request->user();
abort_unless($user, 403);
if (! $user->is_super_admin) {
$allowed = \App\Models\DashboardCardGrant::accessibleCardKeysFor($user)
->contains(self::CARD_KEY);
abort_unless($allowed, 403);
}
abort_unless(
app(\App\Support\DashboardCardRegistry::class)->visibleTo($user, self::CARD_KEY),
403,
);
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
{
if (! $user->can('unifi.guest_wifi')) {
return null;
}
return self::payload();
}

View File

@@ -22,9 +22,22 @@ class UnifiServiceProvider extends ServiceProvider
$this->loadMigrationsFrom(__DIR__ . '/../database/migrations');
// "/" home card: guest Wi-Fi network + current rotating password +
// QR (re-renders when the password changes). Grant-gated via
// Admin → Settings → Dashboard Cards; self-hides until enabled and
// a password has been rotated.
// QR (re-renders when the password changes). Self-hides until enabled
// and 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 {
app(\App\Support\DashboardCardRegistry::class)->register(
'unifi.guest_wifi',
@@ -32,6 +45,7 @@ class UnifiServiceProvider extends ServiceProvider
'UnifiGuestWifi',
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.',
permission: 'unifi.guest_wifi',
);
} catch (\Throwable) {
// 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
// grant-gated by the shell). Exempt from the unifi page-grant middleware
// because it's a home-page widget, not a unifi page. ──
// ── Guest Wi-Fi home-card poll. Authorises inside the controller against the
// card's own visibility (unifi.guest_wifi) — it returns a live credential, so
// `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'])
->get('/app/unifi/guest-card/data', [\Dashboard\Unifi\Http\Controllers\GuestCardController::class, 'data'])
->name('unifi.guest-card.data');