18 Commits

Author SHA1 Message Date
6287054a2a Guest Wi-Fi card can say whether it is set up
configured() answers the three settings payload() needs as one question, so
the permission screens can say "granted, but not switched on yet" instead of
leaving a grantee staring at an unchanged home page.

The operator granted this key to two groups in production and saw nothing
(2026-08-17); the card had simply never been enabled and had no SSID. The
card's own behaviour is unchanged — it still self-hides, like every other
card — the sentence is said where somebody is deciding who gets the key.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDA3fQbsa3gaHCFoV1Q8Gg
2026-08-17 18:27:40 -04:00
b0287f1b10 Bound session_minutes to its column so an oversized value 422s (#5)
`unifi_vlan_mappings.session_minutes` is `unsignedInteger` (ceiling 4294967295)
but the mapping validator only said `integer|min:1`. Anything larger reached the
INSERT and raised SQLSTATE 22003, so the request died as an unhandled 500 where
a validation error belongs — the same shape just fixed in central-oncall#7.
Adds `max:525600` (a year of minutes) to both storeMapping and updateMapping.

Found while fixing #5, which reported a *silent* rejection rather than a crash.
The VLAN ID validation the issue was actually about was already correct on the
server (`min:1|max:4094` on all three forms); what was missing was any way to
see it, and that half of the fix is in the shell's Unifi/Portal.vue. Sibling
numeric fields were checked at the same time: `vlan_id` (unsignedSmallInteger,
bounded 1–4094) and `max_devices` (unsignedTinyInteger, bounded 1–50) were
already safe against their columns; `session_minutes` was the only one that
could overflow.

Version 1.13.3 -> 1.13.4.

Test: dashboard-shell tests/Feature/Unifi/VlanGroupValidationTest.php

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-01 17:05:14 -04:00
186c1eaf2b 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
2026-07-30 12:15:05 -04:00
c93f73ce02 Remove the page-access matrix: routes, controller methods, model
The last of the hand-rolled Access tabs. Its UI went in the previous commit; this
removes what sat behind it — the five settings/pages-access routes, the controller
methods serving them, the grant model, and the orphaned nav-visibility resolver that
read it. The table itself is dropped by a shell migration.

Nothing consulted any of it. Page access is a permission held through a group.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDA3fQbsa3gaHCFoV1Q8Gg
2026-07-29 14:31:07 -04:00
cac47b74bd A2: retire the per-app grant layer (nav resolver + page-grant middleware)
Removes this app's NavVisibilityRegistry resolver and, where it had one, its
page-grant middleware. Sidebar visibility and route access now come from the
same place as everything else: a permission, held through a group.

Nav visibility already had a better answer than the resolvers. NavItem::
visibleTo() applies required_permission to any route prefix that is NOT
registered — central-notifications documents exactly that and is why it never
registered one — and HandleInertiaRequests then applies a safety net reading
the TARGET route's own `permission:` middleware. The resolvers were overriding
both with a lookup against a grant table that is now empty, so a permission
holder could open a page while its sidebar link stayed hidden. That
inconsistency is what this removes.

The middleware only ever NARROWED routes that already carry `permission:`,
with one exception worth naming: payments' legacy rule had NO permission
bypass, so a payments.view holder was denied the ledger it gates. Deleting it
fixes that rather than changing it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDA3fQbsa3gaHCFoV1Q8Gg
2026-07-28 21:58:09 -04:00
7c6bf99201 Delete the bespoke page-grant middleware (A2)
This app attached an Enforce*PageGrant middleware at runtime via a
RouteMatched listener, consulting its own private *_page_grants table to
decide whether the request could proceed.

Safe to remove because the middleware only ever NARROWED routes that
already carry a `permission:` gate. Three independent audits agreed on
that for all eleven apps: no route relied on it as its sole
authorization, so deleting it cannot widen access — the permission the
route already declares still decides.

Two cases were not simply narrowing, and both were handled first:

- unifi's guest-Wi-Fi card poll was `auth`-only and leaked the live guest
  password to any signed-in account. Closed in 9cbc8e6 before this, so
  this commit does not reopen it.
- google's middleware had NO permission-holder bypass, so it could deny a
  legitimate google.manage holder. Deleting it restores their access.

What is deliberately NOT done here: the grant tables, models and the fold
in User::pageGrantedPermissions() all remain, so a grant row can still ADD
access. It can no longer 404 anybody. The fold goes last, together with
the characterisation tests that pin it.

Verified: pages in all four de-gated apps return 200, the affected suites
pass, and dashboard:doctor's advisories drop from 18 to 7.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDA3fQbsa3gaHCFoV1Q8Gg
2026-07-28 19:19:39 -04:00
bc3f415647 SECURITY: the captive-portal callback admitted arbitrary MACs and redirected anywhere
Found while triaging every authenticated route that carries no authorisation
middleware. Of 91 such routes this was the ONLY one that proved nothing: 35 scope to
$request->user(), 51 check ownership in the controller, 4 carry a credential in the URL.

TWO DEFECTS.

1. Arbitrary device admission. Everything identifying the device is request-supplied —
   $clientMac = $request->input('mac', $request->input('id', '')) — and this endpoint
   WRITES network state: authorizeGuest() puts a MAC on the mapped VLAN and a
   PortalSession row is created. So any signed-in account, a student or a parent
   included, could call
       GET /portal/wifi/callback?mac=<a MAC they do not own>&url=…
   and admit that device, up to the mapping's max_devices. No signature, no nonce, no
   check that the MAC was even present on the network.

   Fixed HALFWAY, on purpose. A well-formed MAC is now required, which stops the
   parameter smuggling junk into authorizeGuest and into the session row — but it
   cannot prove the caller owns the device, and the comment says so rather than
   implying the hole is closed.

   The likely proper fix is already in this codebase: UnifiApiClient::isClientConnected()
   exists and is used a few lines below for stale-session cleanup, so requiring the MAC
   to be currently associated would bind it. I did NOT add it, because if a pre-auth
   guest reports as not-connected that check breaks WiFi onboarding for the entire
   school, and there is no hardware here to test against. It needs one deliberate test
   on real APs. The alternative is having UniFi sign the redirect.

2. Open redirect. `return redirect($request->input('url', '/'))`, unvalidated, on an
   authenticated route — a phishing primitive serving from this school's own domain.

   A captive portal exists to forward people to an ARBITRARY external site they were
   already trying to reach, so an allowlist is not available. safeRedirectTarget()
   instead accepts only absolute http/https URLs and site-relative paths, rejecting
   javascript:, data: and protocol-relative "//evil.example" values that redirect()
   would otherwise follow off-site. A rejected value falls back to '/' rather than
   erroring: the guest is already online by then, and a bad `url` should not look like
   a failed login.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDA3fQbsa3gaHCFoV1Q8Gg
2026-07-28 16:32:32 -04:00
9cbc8e682a SECURITY: the guest Wi-Fi card poll had no authorization at all
GET /app/unifi/guest-card/data carried `web,auth` and nothing else, and
GuestCardController::data() checked nobody. So ANY signed-in account could fetch the
live guest Wi-Fi password by requesting the URL — on this dev database that is 663
students and 418 parents, none of whom can see the card in the UI.

The route comment asserted the protection that did not exist:

    // ── Guest Wi-Fi home-card poll (any logged-in user; card visibility is
    // grant-gated by the shell). …

Card visibility IS grant-gated, but that gate is PRESENTATION only — DashboardController
decides which cards to render for a user; it has never constrained the endpoints those
cards poll. So the comment described the card, and the endpoint was open.

Now enforced server-side: you may poll it iff you may see the card
(DashboardCardGrant::accessibleCardKeysFor), with the usual super-admin pass. Kept as
the card gate rather than a permission so the widget stays grantable to non-unifi staff,
which is the whole point of a home-page card.

Verified: a non-super user holding only the MyDocuments card is refused for
unifi.guest_wifi, and an explicit grant admits the same user.

Found while auditing whether the bespoke page-grant middleware could be deleted. This
route sits inside that middleware's prefix match, so deleting it would have removed the
last non-auth check on the endpoint — it needed closing first either way.

Two things nearby that are NOT fixed here and want their own look:
  • unifi.portal.callback (`auth` only, no permission) checks only that somebody is
    logged in.
  • unifi.api.wifi.current-password is token-protected via hash_equals, which is fine,
    but it is the same secret behind a different door — worth confirming that token's
    rotation story.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDA3fQbsa3gaHCFoV1Q8Gg
2026-07-28 15:03:53 -04:00
f4631d1da0 Make snap-in Inertia page assertions real, and fix the page it found missing
config/inertia.php listed only the shell's resource_path('js/Pages'), so a strict
`component('Admissions/Guest/Inquiry')` failed "page component file does not
exist" for every snap-in page. The whole suite worked around it with
`component('X', false)` — and that flag SKIPS the existence check, so those
assertions had stopped verifying the component existed at all. Exactly the bug
class the comment at the top of that file describes.

paths now derives the installed snap-ins' roots from the packages/ symlinks:

    'paths' => array_merge(
        [resource_path('js/Pages')],
        glob(base_path('packages/*/resources/js/Pages'), GLOB_ONLYDIR) ?: [],
    ),

Derived, not hardcoded: packages/ holds one symlink per INSTALLED snap-in, so an
uninstalled app simply vanishes from the array and a newly installed one is
covered automatically. Shell path stays first, matching app.js's resolve order.
`ensure_pages_exist` stays false in the production block — this only affects tests.

49 assertions across 37 files tightened to strict. Zero failed, which is only
meaningful because the mechanism was mutation-checked: renaming a snap-in page
makes the corresponding test fail with "page component file does not exist"
(verified on two different package roots).

THE FINDING. A sweep of all 261 literal Inertia::render() targets turned up
`Unifi/PortalDenied`, which HAS NEVER EXISTED in git history. PortalController
renders it on two live branches of captiveCallback — no VLAN mapping for the
user, and over the device limit — on a route reachable by any authenticated user
redirected from the captive portal (GET /portal/wifi/callback, ['web','auth'], no
app gate). So a student who could not get on the WiFi received a client-side
Inertia resolve error instead of an explanation. No test covered the path, which
is why the suite never noticed.

Fixed properly rather than stubbed:
 - New Unifi/PortalDenied.vue, on GuestLayout (the audience is someone on a phone
   trying to join the WiFi, not an operator in the dashboard). Device-limit case
   lists the user's devices with a Disconnect action; the no-mapping case says who
   to contact instead of offering a dead-end button. Uses confirmDialog, never
   window.confirm.
 - A SECOND defect in the same feature: the page passes `can_disconnect: true`,
   but the only disconnect route sat behind `app.access:unifi` +
   `permission:unifi.auth`, so a student told "disconnect a device" was refused by
   middleware BEFORE disconnectSession()'s own owner check could allow it. Added
   `unifi.portal.self-disconnect` under `['web','auth']`, pointing at the same
   action. Safe because that method already authorises owner-or-unifi.auth itself,
   and a test pins that a stranger still gets 403. The admin route is untouched
   rather than loosened.

Also left alone deliberately: `Admin/Tables` is rendered by
CustomFieldsController::index() but has no GET route (the UI moved to a tab on
/admin/settings), so it is unreachable leftover rather than a wrong name.
And dashboard-expenses/dashboard-ticketing carry stale duplicate copies of pages
that actually live in and are served from the shell; nothing is served only from a
snap-in copy today, but a page added ONLY to those repos would now pass the test
while 404ing in the browser.

Tests: 4 new (PortalDeniedTest, strict component assertions on purpose), 623
green across the touched areas. doctor exit 0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 17:42:20 -04:00
Claude
9413cf9ab9 Declare tier/stability in the manifest
Ship-dark and the dashboard:doctor deploy gate only protect apps that DECLARE
what they are; this app was falling through to defaults. Pure metadata, additive
only, no version bump (repo convention: manifest-only changes do not bump).

bosch is BETA deliberately: production last deployed 2026-07-16 and this app was
built on the 19th, so the next prod deploy is its FIRST install — it talks to a
real alarm panel and its supervised cutover day has not happened. Ship-dark means
it lands disabled until an operator turns it on. signinout is beta for the same
reason (planned, scaffold only). Everything long-live in prod is stable, because
wrongly tagging a live app beta could dark it, while stable never disables
anything.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 19:26:06 -04:00
070001ffff Guest Wi-Fi dashboard card (network + rotating password + join QR)
New home card 'unifi.guest_wifi': shows the guest SSID, the current
rotated password (unifi.password_rotation.last_password), and a WPA
join QR built from the WIFI: string. Polls app/unifi/guest-card/data
(auth) every 60s so it updates the moment the rotation engine changes
the password — the QR regenerates client-side to match. Self-hides
until enabled with an SSID and a rotated password; grant-gated via
Admin -> Settings -> Dashboard Cards. New settings (WiFi rotation
section): enable + network name + card title.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 19:37:44 -04:00
71807c8815 UniFi: settings tabs are deep-linkable path routes (/settings/{tab})
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jh8RnYXrC8E6z79LWs8ggd
2026-06-21 20:19:05 -04:00
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
ee27bee716 UniFi: degrade wan-status & client-ap-traffic to 200 'unavailable' when controller unreachable
Instead of returning HTTP 500 when the UniFi controller can't be reached, these JSON
data feeds now return 200 with an 'available: false' / status 'unavailable' payload so the
dashboard widgets show a friendly unavailable state and the WAN poll stops silently failing.
2026-06-11 17:38:24 -04:00
429cd44ac5 fix: register unifi pages with shell NavVisibilityRegistry; v1.12.1
The Access tab persists user/group grants in unifi_page_grants and the
existing RouteMatched listener honors them on the request path, but
NavItem::visibleTo() only consulted the page's required_permission —
so a granted user never saw the menu entry to reach the page. Register
the unifi.* prefix with the shell's NavVisibilityRegistry so the
sidebar lists exactly the pages the grant table allows.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-05 12:12:58 -04:00
274f210337 release: 1.12.0 — rolls up 1.11.1 (reboot suppression hardening)
Bundled stable cut for prod. Contents since 1.11.0:

* fix(reboot): suppress webhook alerts during a fleet reboot
  regardless of cache driver. RebootAllAps now stamps a Setting
  (unifi.reboot_suppression_until) for 20 minutes at the start of a
  fleet reboot, and WebhookCheckService consults that Setting in
  checkDeviceTransition + checkReboot in addition to the existing
  per-MAC cache keys. Database-backed Setting always crosses
  container/process/cache-driver boundaries so the suppression can
  no longer be defeated by config differences.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 22:09:08 -04:00
c0f12ce931 fix(reboot): suppress webhook alerts during a fleet reboot, regardless of cache driver
Existing Cache::has('unifi:planned_reboot:{mac}') per-MAC suppression
relies on the cache driver being shared across the scheduler and the
snapshot-capture containers. In environments where the cache is
backed by something process-local (or where the keys expire before a
slow reboot completes), webhook alerts fire even though the dashboard
itself initiated the reboots.

RebootAllAps now also stamps a single Setting
(unifi.reboot_suppression_until) at the start of a fleet reboot,
covering a 20-minute window. WebhookCheckService checks this Setting
in addition to the per-MAC cache key, in checkDeviceTransition and
checkReboot. Setting is database-backed so it's always visible across
containers regardless of cache configuration.

v1.11.1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 22:07:58 -04:00
dd4e0ca564 release: 1.11.0 — rolls up the 1.10.1/1.10.2/1.10.3/1.10.4 patches
Bundled stable cut for prod. Contents since 1.10.0:

* fix(banded ssid): treat "PPSK not on this band" as a quiet
  info-level skip rather than a failure (1.10.1).

* fix(ppsk sync): the WiFi modal's ingest sync now matches by NAME
  within a wlan before falling back to held-by-passphrase, and
  salvages rotate_password / schedule from held tombstones into the
  active row before pruning them. Prevents the modal from
  accumulating phantom "held" duplicates after every rotation and
  keeps the rotate flag on the row that's actually live (1.10.2).

* feat(grouped wifi): PPSK updates (both rotation and the manual
  modal edit) now follow user-defined SSID groups from the WiFi
  Networks page first, falling back to same-SSID-name detection.
  Lets the operator pair WLANs whose SSIDs have different names
  (e.g. "VCS Guest" and "VCS Guest 5G") (1.10.3).

* fix(name resolution): on this controller, embedded PPSKs don't
  carry a name field — the human "GUEST" label is the *network's*
  name and entries reference it via networkconf_id. updateEmbeddedPpsk
  and verifyEmbeddedPpsk now resolve name → networkconf_id and match
  on that, with entry-name and current-passphrase as fallbacks for
  other controller variants (1.10.4).

* feat(verify): after every rotation, each affected WLAN is
  re-fetched and the new passphrase is checked at the named network.
  Anything that didn't actually propagate (mismatch, fetch failure)
  shows up as a failed PPSK in the cron run details (1.10.4).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 21:01:22 -04:00
11 changed files with 406 additions and 252 deletions

View File

@@ -1,7 +1,7 @@
{ {
"name": "dashboard/unifi", "name": "dashboard/unifi",
"description": "UniFi network management, WiFi stats, and captive portal authentication for the Dashboard platform", "description": "UniFi network management, WiFi stats, and captive portal authentication for the Dashboard platform",
"version": "1.10.4", "version": "1.13.4",
"type": "library", "type": "library",
"license": "MIT", "license": "MIT",
"autoload": { "autoload": {
@@ -16,6 +16,8 @@
] ]
}, },
"dashboard": { "dashboard": {
"tier": "optional",
"stability": "stable",
"nav_folder": { "nav_folder": {
"label": "Unifi Network", "label": "Unifi Network",
"icon": "wifi", "icon": "wifi",
@@ -33,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

@@ -0,0 +1,62 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* Brings unifi_page_grants to parity with adm_access_grants /
* directory_access_grants: widens grantee_type to include 'default' and
* 'role', allows a NULL grantee_id (used by the per-page default row),
* and adds a can_view column. The default row's can_view carries the
* deny/allow fallback for users not matched by a more specific grant
* (deny by default — no row means deny).
*
* Existing user/group rows keep working: adding can_view with a default
* of true backfills them as explicit allow grants.
*/
return new class extends Migration
{
public function up(): void
{
if (! Schema::hasTable('unifi_page_grants')) return;
// Widen the enum and allow NULL grantee_id (default rows). MySQL
// enum changes require raw DDL.
DB::statement("ALTER TABLE unifi_page_grants MODIFY grantee_type ENUM('default','user','group','role') NOT NULL");
DB::statement("ALTER TABLE unifi_page_grants MODIFY grantee_id BIGINT UNSIGNED NULL");
if (! Schema::hasColumn('unifi_page_grants', 'can_view')) {
Schema::table('unifi_page_grants', function (Blueprint $table) {
$table->boolean('can_view')->default(true)->after('grantee_id');
});
// Belt and braces: make sure every pre-existing user/group row
// is an explicit allow grant.
DB::table('unifi_page_grants')->update(['can_view' => true]);
}
// The unique index on (nav_item_id, grantee_type, grantee_id)
// already exists from the create migration
// ('unifi_page_grants_unique') and still applies — grantee_id is
// NULL for default rows, and MySQL treats NULLs as distinct, so
// app code enforces one default row per nav_item.
}
public function down(): void
{
if (! Schema::hasTable('unifi_page_grants')) return;
if (Schema::hasColumn('unifi_page_grants', 'can_view')) {
Schema::table('unifi_page_grants', function (Blueprint $table) {
$table->dropColumn('can_view');
});
}
DB::table('unifi_page_grants')->whereIn('grantee_type', ['default', 'role'])->delete();
DB::statement("ALTER TABLE unifi_page_grants MODIFY grantee_type ENUM('user','group') NOT NULL");
DB::statement("ALTER TABLE unifi_page_grants MODIFY grantee_id BIGINT UNSIGNED NOT NULL");
}
};

View File

@@ -0,0 +1,124 @@
<?php
namespace Dashboard\Unifi\Http\Controllers;
use App\Models\Setting;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
/**
* The "Guest Wi-Fi" dashboard home card: network name, the current
* rotating password, and a QR code that re-generates when the password
* changes. The password is the rotation engine's last-applied value
* (unifi.password_rotation.last_password), so the card tracks whatever
* unifi:rotate-passwords sets. The card polls data() so it updates
* without a page reload.
*/
class GuestCardController extends Controller
{
/**
* Is this card set up enough to ever appear?
*
* The three things payload() needs, asked as one question, so the permission
* screens can say "granted, but not configured yet" instead of leaving
* somebody staring at a home page wondering what they got wrong. The
* operator granted this key to two groups in production and saw nothing
* (2026-08-17); the card was simply never switched on.
*
* Note the third clause: last_password is written ONLY by
* unifi:rotate-passwords, so a school that sets a static guest password by
* hand and never rotates has no card and no obvious reason why.
*/
public static function configured(): bool
{
return (bool) Setting::get('unifi.guest_card.enabled')
&& trim((string) Setting::get('unifi.guest_card.ssid', '')) !== ''
&& filled(Setting::get('unifi.password_rotation.last_password'));
}
/** Payload shared by the card resolver and the poll endpoint. */
public static function payload(): ?array
{
if (! Setting::get('unifi.guest_card.enabled')) {
return null;
}
$password = Setting::get('unifi.password_rotation.last_password');
$ssid = trim((string) Setting::get('unifi.guest_card.ssid', ''));
if (! $password || $ssid === '') {
return null; // nothing meaningful to show yet
}
$freq = Setting::get('unifi.password_rotation.frequency', 'weekly');
return [
'title' => trim((string) Setting::get('unifi.guest_card.title', '')) ?: 'Guest Wi-Fi',
'ssid' => $ssid,
'password' => $password,
'rotated_at' => Setting::get('unifi.password_rotation.last_rotated_at'),
// WPA/WPA2 join string a phone camera understands. Special
// chars in SSID/password must be backslash-escaped per the
// Wi-Fi QR spec.
'qr' => self::wifiQrString($ssid, $password),
'frequency' => in_array($freq, ['daily', 'weekly'], true) ? $freq : 'weekly',
];
}
/** The dashboard card this endpoint feeds — must match UnifiServiceProvider. */
private const CARD_KEY = 'unifi.guest_wifi';
/**
* SECURITY: authorize against the card's own visibility.
*
* This was `web,auth` only, and the route comment claimed "card visibility is
* grant-gated by the shell" — but that gate is PRESENTATION only
* (DashboardController decides which cards to render). The endpoint itself was
* 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 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);
abort_unless(
app(\App\Support\DashboardCardRegistry::class)->visibleTo($user, self::CARD_KEY),
403,
);
return response()->json(self::payload() ?? ['title' => null]);
}
/**
* 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();
}
private static function wifiQrString(string $ssid, string $password): string
{
$esc = fn (string $v) => preg_replace('/([\\\\;,:"])/', '\\\\$1', $v);
return sprintf('WIFI:T:WPA;S:%s;P:%s;;', $esc($ssid), $esc($password));
}
}

View File

@@ -36,7 +36,11 @@ class PortalController extends Controller
'match_value' => 'required|string|max:255', 'match_value' => 'required|string|max:255',
'vlan_id' => 'required|integer|min:1|max:4094', 'vlan_id' => 'required|integer|min:1|max:4094',
'max_devices' => 'required|integer|min:1|max:50', 'max_devices' => 'required|integer|min:1|max:50',
'session_minutes' => 'required|integer|min:1', // Bounded by the column that stores it: unifi_vlan_mappings.session_minutes
// is unsignedInteger, so an unbounded value reached the INSERT and raised
// SQLSTATE 22003 — a 500 where a validation error belongs (central-unifi#5).
// 525600 minutes is a year, far more session than anyone will ever grant.
'session_minutes' => 'required|integer|min:1|max:525600',
]); ]);
$data['sort_order'] = VlanMapping::max('sort_order') + 1; $data['sort_order'] = VlanMapping::max('sort_order') + 1;
VlanMapping::create($data); VlanMapping::create($data);
@@ -51,7 +55,11 @@ class PortalController extends Controller
'match_value' => 'required|string|max:255', 'match_value' => 'required|string|max:255',
'vlan_id' => 'required|integer|min:1|max:4094', 'vlan_id' => 'required|integer|min:1|max:4094',
'max_devices' => 'required|integer|min:1|max:50', 'max_devices' => 'required|integer|min:1|max:50',
'session_minutes' => 'required|integer|min:1', // Bounded by the column that stores it: unifi_vlan_mappings.session_minutes
// is unsignedInteger, so an unbounded value reached the INSERT and raised
// SQLSTATE 22003 — a 500 where a validation error belongs (central-unifi#5).
// 525600 minutes is a year, far more session than anyone will ever grant.
'session_minutes' => 'required|integer|min:1|max:525600',
]); ]);
$mapping->update($data); $mapping->update($data);
return back()->with('success', 'Mapping updated.'); return back()->with('success', 'Mapping updated.');
@@ -87,6 +95,35 @@ class PortalController extends Controller
// ── Captive portal callback (called by UniFi external portal redirect) ─── // ── Captive portal callback (called by UniFi external portal redirect) ───
/**
* A redirect target that cannot be turned into a phishing or script payload.
*
* Accepts only absolute http/https URLs and site-relative paths. Rejects
* javascript:/data:/vbscript: schemes, and protocol-relative "//host" values which
* redirect() would happily send off-site. Anything rejected falls back to the
* dashboard rather than erroring — the guest is already online at this point, so a
* bad `url` parameter should not look like a failed login.
*/
private function safeRedirectTarget(?string $url): string
{
$url = trim((string) $url);
if ($url === '' || str_starts_with($url, '//')) {
return '/';
}
// Site-relative is always fine.
if (str_starts_with($url, '/')) {
return $url;
}
$scheme = strtolower((string) parse_url($url, PHP_URL_SCHEME));
return in_array($scheme, ['http', 'https'], true) && parse_url($url, PHP_URL_HOST)
? $url
: '/';
}
public function captiveCallback(Request $request, UnifiApiClient $unifi) public function captiveCallback(Request $request, UnifiApiClient $unifi)
{ {
// UniFi redirects guest to this URL with ?id=<ap_mac>&ap=<ap_mac>&t=<timestamp>&url=<original_url>&ssid=<ssid> // UniFi redirects guest to this URL with ?id=<ap_mac>&ap=<ap_mac>&t=<timestamp>&url=<original_url>&ssid=<ssid>
@@ -104,6 +141,23 @@ class PortalController extends Controller
return response('Missing MAC address', 400); return response('Missing MAC address', 400);
} }
// SECURITY: everything identifying the device is request-supplied, and this
// endpoint WRITES network state (authorizeGuest puts a MAC on the mapped VLAN).
// Before this check any signed-in account — a student, a parent — could call
// GET /portal/wifi/callback?mac=<someone else's MAC>&url=…
// and admit a device they do not own, up to max_devices.
//
// A well-formed MAC is the floor, not a fix: it stops the parameter being used
// to smuggle junk into authorizeGuest and into the PortalSession row, but it
// cannot prove the caller owns the device. Properly binding it needs the AP
// handshake — UniFi can sign the redirect, or we ask the controller whether this
// MAC is CURRENTLY associated to $apMac before admitting it. Both need testing
// against real hardware, so they are deliberately not guessed at here.
$clientMac = str_replace('-', ':', $clientMac);
if (! preg_match('/^([0-9a-f]{2}:){5}[0-9a-f]{2}$/', $clientMac)) {
return response('Malformed MAC address', 400);
}
// Find the VLAN mapping for this user's group/OU // Find the VLAN mapping for this user's group/OU
$mapping = $this->resolveMapping($user); $mapping = $this->resolveMapping($user);
if (! $mapping) { if (! $mapping) {
@@ -161,9 +215,16 @@ class PortalController extends Controller
'expires_at' => now()->addMinutes($minutes), 'expires_at' => now()->addMinutes($minutes),
]); ]);
// Redirect to the original URL the user was trying to reach // Redirect to the original URL the user was trying to reach.
$redirectUrl = $request->input('url', '/'); //
return redirect($redirectUrl); // SECURITY: this was `redirect($request->input('url', '/'))` — an unvalidated
// open redirect on an authenticated route, i.e. a phishing primitive wearing
// this school's domain. The whole point of a captive portal is to send someone
// to an ARBITRARY external site they were already trying to reach, so an
// allowlist is not available; what is available is refusing anything that is
// not a plain http(s) URL, which blocks javascript:, data: and protocol-relative
// //evil.example payloads.
return redirect($this->safeRedirectTarget($request->input('url')));
} }
public function disconnectSession(Request $request, PortalSession $session, UnifiApiClient $unifi) public function disconnectSession(Request $request, PortalSession $session, UnifiApiClient $unifi)

View File

@@ -36,6 +36,7 @@ class StatsController extends Controller
if ($wanIp && str_starts_with($wanIp, '127.')) $wanIp = $gw['connect_request_ip'] ?? null; if ($wanIp && str_starts_with($wanIp, '127.')) $wanIp = $gw['connect_request_ip'] ?? null;
return response()->json([ return response()->json([
'available' => true,
'status' => $wan['status'] ?? 'unknown', 'status' => $wan['status'] ?? 'unknown',
'tx_rate' => $wan['tx_bytes-r'] ?? 0, 'tx_rate' => $wan['tx_bytes-r'] ?? 0,
'rx_rate' => $wan['rx_bytes-r'] ?? 0, 'rx_rate' => $wan['rx_bytes-r'] ?? 0,
@@ -44,7 +45,19 @@ class StatsController extends Controller
'latency' => $wan['latency'] ?? $gw['wan1']['latency'] ?? null, 'latency' => $wan['latency'] ?? $gw['wan1']['latency'] ?? null,
]); ]);
} catch (\Throwable $e) { } catch (\Throwable $e) {
return response()->json(['status' => 'error'], 500); // The UniFi controller is unreachable. Degrade gracefully: return 200 with a
// friendly "unavailable" payload so the dashboard widget shows an unavailable
// state instead of the poll silently failing on a 500.
return response()->json([
'available' => false,
'status' => 'unavailable',
'message' => 'UniFi controller is unreachable.',
'tx_rate' => 0,
'rx_rate' => 0,
'isp' => null,
'wan_ip' => null,
'latency' => null,
]);
} }
} }
@@ -296,12 +309,21 @@ class StatsController extends Controller
->values(); ->values();
return response()->json([ return response()->json([
'available' => true,
'labels' => $series['labels'], 'labels' => $series['labels'],
'traffic_rx' => $rx, 'traffic_rx' => $rx,
'traffic_tx' => $tx, 'traffic_tx' => $tx,
]); ]);
} catch (\Throwable $e) { } catch (\Throwable $e) {
return response()->json(['error' => $e->getMessage()], 500); // UniFi controller unreachable — degrade gracefully (200 with empty series)
// so the chart shows an empty/unavailable state rather than erroring on a 500.
return response()->json([
'available' => false,
'message' => 'UniFi controller is unreachable.',
'labels' => [],
'traffic_rx' => [],
'traffic_tx' => [],
]);
} }
} }

View File

@@ -1,141 +0,0 @@
<?php
namespace Dashboard\Unifi\Http\Controllers;
use App\Models\DashboardApp;
use App\Models\Group;
use App\Models\NavItem;
use App\Models\User;
use Dashboard\Unifi\Models\UnifiPageGrant;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Support\Facades\DB;
/**
* Super-admin-only endpoints for managing per-page access on unifi
* pages. Pages here = nav_items where app_id = unifi's DashboardApp row.
*/
class UnifiPagesAccessController extends Controller
{
public function index()
{
$app = DashboardApp::where('slug', 'unifi')->first();
if (! $app) {
return response()->json(['pages' => [], 'users' => [], 'groups' => []]);
}
$pages = NavItem::where('app_id', $app->id)
->where('is_folder', false)
->whereNotNull('route_name')
->orderBy('sort_order')
->get(['id', 'label', 'route_name']);
$grants = UnifiPageGrant::whereIn('nav_item_id', $pages->pluck('id'))
->get()
->groupBy('nav_item_id');
// Only return users that ALREADY have grants. The full users list
// can be enormous (thousands of rows); the operator adds more via
// the searchUsers endpoint as needed.
$grantedUserIds = $grants->flatten(1)->where('grantee_type', 'user')->pluck('grantee_id')->unique();
$users = User::whereIn('id', $grantedUserIds)->orderBy('name')->get(['id', 'name', 'email']);
// Groups: always include super-admin groups (locked-on across all
// pages) plus any group with at least one grant. Other groups are
// added via searchGroups.
$grantedGroupIds = $grants->flatten(1)->where('grantee_type', 'group')->pluck('grantee_id')->unique();
$groups = Group::where(function ($q) use ($grantedGroupIds) {
$q->where('is_super', true)
->orWhereIn('id', $grantedGroupIds);
})->orderBy('name')->get(['id', 'name', 'is_super']);
return response()->json([
'pages' => $pages->map(fn ($p) => [
'id' => $p->id,
'label' => $p->label,
'route_name' => $p->route_name,
'user_ids' => $grants->get($p->id, collect())->where('grantee_type', 'user')->pluck('grantee_id')->all(),
'group_ids' => $grants->get($p->id, collect())->where('grantee_type', 'group')->pluck('grantee_id')->all(),
])->values(),
'users' => $users,
'groups' => $groups,
]);
}
/**
* Typeahead-style search for users to add to the access matrix.
* Returns up to 20 matches against name or email. Empty query returns
* an empty array — caller must enter at least 2 chars.
*/
public function searchUsers(Request $request)
{
$q = trim((string) $request->query('q', ''));
if (strlen($q) < 2) {
return response()->json(['users' => []]);
}
$users = User::where(function ($w) use ($q) {
$w->where('name', 'like', '%' . $q . '%')
->orWhere('email', 'like', '%' . $q . '%');
})
->orderBy('name')
->limit(20)
->get(['id', 'name', 'email']);
return response()->json(['users' => $users]);
}
/**
* Typeahead-style search for groups to add to the access matrix.
* Excludes super-admin groups (they're already in the matrix and
* locked-on across every page).
*/
public function searchGroups(Request $request)
{
$q = trim((string) $request->query('q', ''));
if (strlen($q) < 2) {
return response()->json(['groups' => []]);
}
$groups = Group::where('name', 'like', '%' . $q . '%')
->where(function ($w) { $w->where('is_super', false)->orWhereNull('is_super'); })
->orderBy('name')
->limit(20)
->get(['id', 'name', 'is_super']);
return response()->json(['groups' => $groups]);
}
public function update(Request $request, NavItem $navItem)
{
$app = DashboardApp::where('slug', 'unifi')->first();
if (! $app || $navItem->app_id !== $app->id) {
return response()->json(['error' => 'Not a unifi page.'], 422);
}
$data = $request->validate([
'user_ids' => 'present|array',
'user_ids.*' => 'integer|exists:users,id',
'group_ids' => 'present|array',
'group_ids.*' => 'integer|exists:groups,id',
]);
$grantedBy = $request->user()?->id;
DB::transaction(function () use ($navItem, $data, $grantedBy) {
UnifiPageGrant::where('nav_item_id', $navItem->id)->delete();
$rows = [];
$now = now();
foreach ($data['user_ids'] as $uid) {
$rows[] = ['nav_item_id' => $navItem->id, 'grantee_type' => 'user', 'grantee_id' => $uid, 'granted_by_user_id' => $grantedBy, 'created_at' => $now, 'updated_at' => $now];
}
foreach ($data['group_ids'] as $gid) {
$rows[] = ['nav_item_id' => $navItem->id, 'grantee_type' => 'group', 'grantee_id' => $gid, 'granted_by_user_id' => $grantedBy, 'created_at' => $now, 'updated_at' => $now];
}
if ($rows) UnifiPageGrant::insert($rows);
});
return response()->json(['ok' => true]);
}
}

View File

@@ -10,9 +10,10 @@ use Inertia\Inertia;
class UnifiSettingsController extends Controller class UnifiSettingsController extends Controller
{ {
public function edit() public function edit(?string $tab = null)
{ {
return Inertia::render('Unifi/Settings', [ return Inertia::render('Unifi/Settings', [
'activeTab' => $tab,
'controllerUrl' => Setting::get('unifi.controller_url', ''), 'controllerUrl' => Setting::get('unifi.controller_url', ''),
'hasApiKey' => (bool) Setting::get('unifi.api_key'), 'hasApiKey' => (bool) Setting::get('unifi.api_key'),
'site' => Setting::get('unifi.site', 'default'), 'site' => Setting::get('unifi.site', 'default'),
@@ -35,6 +36,9 @@ class UnifiSettingsController extends Controller
'ppskSchedulingEnabled' => (bool) Setting::get('unifi.ppsk_scheduling.enabled', false), 'ppskSchedulingEnabled' => (bool) Setting::get('unifi.ppsk_scheduling.enabled', false),
'apiEnabled' => (bool) Setting::get('unifi.api.enabled', false), 'apiEnabled' => (bool) Setting::get('unifi.api.enabled', false),
'apiToken' => Setting::get('unifi.api_token', null), 'apiToken' => Setting::get('unifi.api_token', null),
'guestCardEnabled' => (bool) Setting::get('unifi.guest_card.enabled', false),
'guestCardSsid' => Setting::get('unifi.guest_card.ssid', ''),
'guestCardTitle' => Setting::get('unifi.guest_card.title', ''),
]); ]);
} }
@@ -73,6 +77,9 @@ class UnifiSettingsController extends Controller
'rotation_wordlist' => 'nullable|string|max:20000', 'rotation_wordlist' => 'nullable|string|max:20000',
'ppsk_scheduling_enabled' => 'boolean', 'ppsk_scheduling_enabled' => 'boolean',
'api_enabled' => 'boolean', 'api_enabled' => 'boolean',
'guest_card_enabled' => 'boolean',
'guest_card_ssid' => 'nullable|string|max:100',
'guest_card_title' => 'nullable|string|max:100',
]); ]);
Setting::set('unifi.controller_url', rtrim($request->controller_url, '/')); Setting::set('unifi.controller_url', rtrim($request->controller_url, '/'));
@@ -100,6 +107,9 @@ class UnifiSettingsController extends Controller
Setting::set('unifi.password_rotation.wordlist', $request->input('rotation_wordlist', '')); Setting::set('unifi.password_rotation.wordlist', $request->input('rotation_wordlist', ''));
Setting::set('unifi.ppsk_scheduling.enabled', $request->boolean('ppsk_scheduling_enabled') ? '1' : ''); Setting::set('unifi.ppsk_scheduling.enabled', $request->boolean('ppsk_scheduling_enabled') ? '1' : '');
Setting::set('unifi.api.enabled', $request->boolean('api_enabled') ? '1' : ''); Setting::set('unifi.api.enabled', $request->boolean('api_enabled') ? '1' : '');
Setting::set('unifi.guest_card.enabled', $request->boolean('guest_card_enabled') ? '1' : '');
Setting::set('unifi.guest_card.ssid', trim((string) $request->input('guest_card_ssid', '')));
Setting::set('unifi.guest_card.title', trim((string) $request->input('guest_card_title', '')));
\Illuminate\Support\Facades\Cache::forget('unifi:api_prefix:' . md5(rtrim($request->controller_url, '/'))); \Illuminate\Support\Facades\Cache::forget('unifi:api_prefix:' . md5(rtrim($request->controller_url, '/')));

View File

@@ -1,58 +0,0 @@
<?php
namespace Dashboard\Unifi\Models;
use App\Models\NavItem;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class UnifiPageGrant extends Model
{
protected $table = 'unifi_page_grants';
protected $fillable = [
'nav_item_id',
'grantee_type',
'grantee_id',
'granted_by_user_id',
];
public function navItem(): BelongsTo
{
return $this->belongsTo(NavItem::class);
}
public function grantedBy(): BelongsTo
{
return $this->belongsTo(User::class, 'granted_by_user_id');
}
/**
* True iff $user is allowed to access $navItem under strict allowlist
* semantics:
* * super-admins (the model-level flag) always pass
* * otherwise the user must be a direct grantee, OR be in a group
* that is a grantee
*
* A page with NO grants saved is only visible to super-admins —
* the admin must explicitly authorize everyone else via the
* Access tab.
*/
public static function userCanAccess(User $user, NavItem $navItem): bool
{
if ($user->is_super_admin) return true;
$groupIds = $user->groups()->pluck('groups.id');
return static::where('nav_item_id', $navItem->id)
->where(function ($q) use ($user, $groupIds) {
$q->where(function ($u) use ($user) {
$u->where('grantee_type', 'user')->where('grantee_id', $user->id);
})->orWhere(function ($g) use ($groupIds) {
$g->where('grantee_type', 'group')->whereIn('grantee_id', $groupIds);
});
})
->exists();
}
}

View File

@@ -181,7 +181,11 @@ class WebhookCheckService
$prev = DeviceState::where('device_mac', $mac)->first(); $prev = DeviceState::where('device_mac', $mac)->first();
if (! $prev) continue; if (! $prev) continue;
// Skip planned reboots — these are intentional, not alerts // Skip planned reboots — these are intentional, not alerts.
// Two layers: a global suppression window set by RebootAllAps
// (Setting, survives any cache driver), plus the per-MAC
// cache keys for finer granularity.
if ($this->inGlobalRebootSuppression()) continue;
if (Cache::has('unifi:planned_reboot:' . strtolower($mac))) continue; if (Cache::has('unifi:planned_reboot:' . strtolower($mac))) continue;
if ($comingOnline) { if ($comingOnline) {
@@ -509,6 +513,8 @@ class WebhookCheckService
private function checkReboot($aps, array $filter): array private function checkReboot($aps, array $filter): array
{ {
$alerts = []; $alerts = [];
if ($this->inGlobalRebootSuppression()) return $alerts;
foreach ($aps as $ap) { foreach ($aps as $ap) {
if (! empty($filter) && ! in_array($ap['mac'], $filter)) continue; if (! empty($filter) && ! in_array($ap['mac'], $filter)) continue;
if (Cache::has('unifi:planned_reboot:' . strtolower($ap['mac']))) continue; if (Cache::has('unifi:planned_reboot:' . strtolower($ap['mac']))) continue;
@@ -584,6 +590,24 @@ class WebhookCheckService
return self::buildPlatformPayload($url, $message, $fullPayload); return self::buildPlatformPayload($url, $message, $fullPayload);
} }
/**
* Is a fleet reboot in progress right now? RebootAllAps stamps a
* suppression-until timestamp as a Setting; while that timestamp
* is in the future, we skip all device-offline / device-online /
* unexpected-reboot alerts to avoid flooding webhooks during the
* known maintenance window.
*/
private function inGlobalRebootSuppression(): bool
{
$until = \App\Models\Setting::get('unifi.reboot_suppression_until');
if (! $until) return false;
try {
return \Carbon\Carbon::parse($until)->isFuture();
} catch (\Throwable) {
return false;
}
}
/** /**
* Public/static helper so the test-webhook endpoint produces the * Public/static helper so the test-webhook endpoint produces the
* same per-platform payload shape that real events do. * same per-platform payload shape that real events do.

View File

@@ -2,11 +2,7 @@
namespace Dashboard\Unifi; namespace Dashboard\Unifi;
use App\Models\DashboardApp;
use App\Models\NavItem; use App\Models\NavItem;
use Dashboard\Unifi\Models\UnifiPageGrant;
use Illuminate\Routing\Events\RouteMatched;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
class UnifiServiceProvider extends ServiceProvider class UnifiServiceProvider extends ServiceProvider
@@ -25,36 +21,45 @@ class UnifiServiceProvider extends ServiceProvider
$this->loadRoutesFrom(__DIR__ . '/routes/unifi.php'); $this->loadRoutesFrom(__DIR__ . '/routes/unifi.php');
$this->loadMigrationsFrom(__DIR__ . '/../database/migrations'); $this->loadMigrationsFrom(__DIR__ . '/../database/migrations');
// Per-page access enforcement for unifi routes. If a unifi page has // "/" home card: guest Wi-Fi network + current rotating password +
// any UnifiPageGrant rows, only super-admins and granted users/ // QR (re-renders when the password changes). Self-hides until enabled
// groups can hit it; otherwise (no grants) it's open per the existing // and a password has been rotated.
// permission middleware. Super-admins always bypass. //
Event::listen(RouteMatched::class, function (RouteMatched $event) { // The ONE card in the platform that earned a NEW permission key rather
$routeName = $event->route->getName(); // than reusing one. It cannot be universal — the payload is a live
if (! $routeName || ! str_starts_with($routeName, 'unifi.')) return; // credential, and publishing it to every student and parent is the exact
// hole GuestCardController::data was written to close. And no existing
$user = $event->request->user(); // unifi key fits: unifi.stats is the network dashboards, unifi.settings is
if (! $user || $user->is_super_admin) return; // 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 {
$appId = DashboardApp::where('slug', 'unifi')->value('id'); app(\App\Support\DashboardCardRegistry::class)->register(
$item = NavItem::where('route_name', $routeName) 'unifi.guest_wifi',
->where('app_id', $appId) 'Guest Wi-Fi',
->first(); 'UnifiGuestWifi',
if (! $item) return; 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.',
if (! UnifiPageGrant::userCanAccess($user, $item)) { permission: 'unifi.guest_wifi',
// 404 instead of 403 — don't leak that the page // So the permission screens can say "granted, but not switched
// exists. The Access tab is the only way in. // on yet" rather than leaving a grantee staring at a home page
abort(404); // with nothing new on it.
} configured: fn () => \Dashboard\Unifi\Http\Controllers\GuestCardController::configured(),
);
} catch (\Throwable) { } catch (\Throwable) {
// unifi_page_grants table may not exist yet on a fresh // Older shell without the card registry — skip.
// install before this snap-in's migrations have run —
// fail open in that narrow window.
} }
});
// 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.
if ($this->app->runningInConsole()) { if ($this->app->runningInConsole()) {
$this->commands([ $this->commands([
Console\CheckWebhooks::class, Console\CheckWebhooks::class,
@@ -69,4 +74,26 @@ class UnifiServiceProvider extends ServiceProvider
], 'unifi-config'); ], 'unifi-config');
} }
} }
/**
* 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();
}
} }

View File

@@ -69,19 +69,12 @@ Route::middleware(['web', 'auth', 'app.access:unifi'])
// ── Settings ───────────────────────────────────────────────────────── // ── Settings ─────────────────────────────────────────────────────────
Route::middleware('permission:unifi.settings')->group(function () { Route::middleware('permission:unifi.settings')->group(function () {
Route::get('/settings', [UnifiSettingsController::class, 'edit']) ->name('settings'); Route::get('/settings', [UnifiSettingsController::class, 'edit']) ->name('settings');
Route::get('/settings/{tab}', [UnifiSettingsController::class, 'edit'])
->where('tab', 'connection|tasks|logs|access')->name('settings.tab');
Route::post('/settings', [UnifiSettingsController::class, 'update']) ->name('settings.update'); Route::post('/settings', [UnifiSettingsController::class, 'update']) ->name('settings.update');
Route::post('/settings/test', [UnifiSettingsController::class, 'testConnection'])->name('settings.test'); Route::post('/settings/test', [UnifiSettingsController::class, 'testConnection'])->name('settings.test');
Route::post('/settings/sites', [UnifiSettingsController::class, 'fetchSites']) ->name('settings.sites'); Route::post('/settings/sites', [UnifiSettingsController::class, 'fetchSites']) ->name('settings.sites');
// Page Access — super-admin only. Lists unifi pages and lets
// operators assign per-page user/group grants.
Route::middleware('super.admin')->group(function () {
Route::get('/settings/pages-access', [UnifiPagesAccessController::class, 'index']) ->name('settings.pages-access.index');
Route::get('/settings/pages-access/users/search', [UnifiPagesAccessController::class, 'searchUsers'])->name('settings.pages-access.users.search');
Route::get('/settings/pages-access/groups/search', [UnifiPagesAccessController::class, 'searchGroups'])->name('settings.pages-access.groups.search');
Route::put('/settings/pages-access/{navItem}', [UnifiPagesAccessController::class, 'update']) ->name('settings.pages-access.update');
});
// Cron logs — read-only history of scheduled-task runs. // Cron logs — read-only history of scheduled-task runs.
Route::get('/settings/cron-logs', [UnifiCronLogsController::class, 'index'])->name('settings.cron-logs.index'); Route::get('/settings/cron-logs', [UnifiCronLogsController::class, 'index'])->name('settings.cron-logs.index');
@@ -99,6 +92,15 @@ Route::middleware(['web', 'auth', 'app.access:unifi'])
}); });
}); });
// ── 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');
// ── Public API (token-protected) ────────────────────────────────────────── // ── Public API (token-protected) ──────────────────────────────────────────
// External integrations (signage, kiosks) hit these without session auth. // External integrations (signage, kiosks) hit these without session auth.
Route::prefix('api/unifi')->name('unifi.api.')->group(function () { Route::prefix('api/unifi')->name('unifi.api.')->group(function () {
@@ -110,3 +112,21 @@ Route::prefix('api/unifi')->name('unifi.api.')->group(function () {
Route::middleware(['web', 'auth']) Route::middleware(['web', 'auth'])
->get('/portal/wifi/callback', [PortalController::class, 'captiveCallback']) ->get('/portal/wifi/callback', [PortalController::class, 'captiveCallback'])
->name('unifi.portal.callback'); ->name('unifi.portal.callback');
/*
* Let a user disconnect their OWN device from the captive-portal denial page.
*
* The admin route for this (unifi.portal.sessions.disconnect) sits behind
* `app.access:unifi` + `permission:unifi.auth`, so a student who is told "you
* already have 3 devices connected, disconnect one" was blocked by middleware
* before reaching the action — the page's `can_disconnect: true` was a promise
* the routing could not keep.
*
* Safe to expose with only `auth`: disconnectSession() already authorises
* owner-or-unifi.auth itself (`abort_if($session->user_id !== auth()->id() && !
* auth()->user()->can('unifi.auth'), 403)`), so a non-owner still gets 403. The
* admin route is left exactly as it was rather than loosened.
*/
Route::middleware(['web', 'auth'])
->post('/portal/wifi/sessions/{session}/disconnect', [PortalController::class, 'disconnectSession'])
->name('unifi.portal.self-disconnect');