14 Commits

Author SHA1 Message Date
dbebb6c964 Plan 1 of 2: the BYOD Wi-Fi access model
Six TDD tasks covering the back-end half of the approved spec: the live-authorization
quota scope, dropping network presence as an eviction trigger, the role allow-list
with `default` removed, the IP-derived device resolver, randomized-address
detection, and the reconciler.

Split from the UI deliberately. Plan 1 leaves the portal correct and safe while
still being configured through the existing mappings CRUD, so it is worth shipping
on its own; Plan 2 covers the settings screen, the denial-page coaching copy and
the Wi-Fi Devices page.

Three things the self-review caught and the plan now handles:

  · the resolver had no retry, so a device that had only just associated — and so
    had no IP in UniFi's table yet — would have been told it was not connected at
    the exact moment it was connecting;
  · nothing captured the device fingerprint, which the report-only device-type
    decision depends on. The reconciler now does it, and only ever fills in: an
    unfingerprinted client must not blank a type already recorded, or the answer
    is lost every time the device happens to be quiet;
  · one task's test needed a method a later task introduced, and the first draft
    said to leave it red. Under subagent-driven execution a fresh agent per task
    would have seen red and tried to fix it, so the test now uses the parameter
    flow that exists at that point and the later task migrates it.

Also records what NOT to do: do not schedule the reconciler (it calls
unauthorizeGuest against a live controller that nothing here has been tested
against), do not register the unifi app, and do not touch disconnectSession.
2026-08-21 12:56:23 -04:00
c745aa8b2c Spec: Wi-Fi access becomes an explicit allow-list, configured in settings
Two changes from the operator.

Session length and devices-per-role are set in the settings UI, on the existing
Portal settings page rather than a new one. Two presentation decisions that are
really correctness decisions:

  · the role is picked from a DROPDOWN of seeded roles, not typed. match_value is
    compared exactly, so "Student" or "studnet" matches nobody and the failure
    surfaces as a pupil with no Wi-Fi and nothing logged anywhere. A dropdown
    makes an unmatchable value impossible to enter.
  · session length is entered in DAYS with presets. session_minutes stays the
    stored unit, but a term is 172,800 minutes and nobody should type that — the
    same reasoning that presents cron schedules as presets instead of raw strings.

And access is now an explicit allow-list: no role added means no Wi-Fi. That makes
`default` an anti-feature, because a default row matches everybody and turns the
allow-list into a suggestion, so it is removed from the available match types.
resolveMapping() already returns null when nothing matches and null already
produces the "No WiFi access configured for your account." denial, so
deny-by-default is the behaviour as soon as the catch-all cannot be created.

Safe to do now specifically because unifi_vlan_mappings has ZERO rows in every
environment — nothing is revoked from anybody. The same change made after a school
had leaned on a catch-all would cut off every unmapped person at once.

VLAN stays required per row. It was not in the request, so it is called out in the
spec rather than quietly dropped: the network still has to know where to put the
device.
2026-08-21 12:48:47 -04:00
79a8360714 Design: harden the BYOD Wi-Fi captive portal
Brainstormed spec for registered guest/BYOD Wi-Fi. The headline finding is that
this is not a new feature: the portal, per-cohort device limits, per-cohort VLANs,
session length, self-service eviction and admin eviction all already exist here,
and CaptureSnapshots is already collecting device fingerprints. So the spec is
about what is broken and what is missing, not what to build from scratch.

Two defects in quota accounting, in opposite directions, both of which get worse
the longer session_minutes is — and long sessions are the whole point of "do not
make users re-do the portal every connection":

  · a slot is freed when isClientConnected() is false, so a phone that leaves the
    building frees its slot while UniFi still holds it authorized — the user then
    authorizes a second device and one of them is an orphan nothing tracks;
  · expires_at is written and never read, and no job expires sessions, so a row
    keeps consuming a slot long after UniFi stopped honouring it.

The fix is to count live AUTHORIZATIONS rather than present devices, and to let a
reconciler repair both directions.

Also specified: closing the MAC-ownership hole the original author documented and
declined to guess at, by deriving the MAC from the request's source IP and
verifying AP association instead of trusting ?mac=. That fails closed, which is
correct — and the spec is explicit that it can therefore fail closed for
EVERYONE if TrustProxies is wrong behind Traefik, so the resolver has to report
that case distinctly and it is the one thing to verify against real hardware
before release.

Device-type detection (keeping students off phones) is specified as report-only.
UniFi's fingerprint is derived from observed traffic and so is not available at
the moment of authorization, and unifi_client_snapshots has zero rows on dev, so
there is no evidence dev_cat is populated in this environment. Enforcing on
unvalidated data would lock out the wrong people. MAC OUI is rejected outright:
a randomized MAC has no real OUI, so it would fail on phones while working on the
laptops that should not be blocked.

No version bump: documentation only, no runtime change, and bumping would put the
repo ahead of prod for a file that cannot affect it.
2026-08-21 12:43:21 -04:00
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
11 changed files with 1968 additions and 401 deletions

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,288 @@
# Registered guest / BYOD Wi-Fi — hardening the captive portal
**Date:** 2026-08-21
**Repos:** `central-unifi` (most of it), `dashboard-shell` (tests, one Vue page)
**Status:** design approved, no implementation plan yet
---
## Why this document is short on new features
Nearly everything asked for already exists in `central-unifi` v1.13.4 and has for
some time. The request was "design registered guest Wi-Fi"; the finding is that
the feature is largely built, partly broken in ways that only show up under real
use, and **currently unreachable**. So this is a hardening spec, not a greenfield
one.
### What already works
| Requirement | Where it lives |
|---|---|
| Captive portal + Google auth | `PortalController::captiveCallback`, `/portal/wifi/callback`; the shell's OAuth has already authenticated the user by then |
| Per-cohort device limits | `unifi_vlan_mappings.max_devices` |
| Per-cohort VLAN | `unifi_vlan_mappings.vlan_id` |
| Session length | `unifi_vlan_mappings.session_minutes` (validated 1..525600) |
| Self-service eviction | `Unifi/PortalDenied` page lists the user's devices with `can_disconnect`, plus `unifi.portal.self-disconnect` |
| Admin eviction | `unifi.portal.sessions.disconnect` behind `app.access:unifi` + `permission:unifi.auth` |
| Device fingerprint capture | `CaptureSnapshots` already writes `dev_cat` and `os_name` per MAC into `unifi_client_snapshots` |
Eviction authorisation is already correct and is **not** being changed:
`disconnectSession()` does `kickClient` + `unauthorizeGuest` and authorises
owner-or-`unifi.auth` inside the action, so a student can free their own slot and
nobody else's.
### The answer to "must users re-do the portal every time?"
No, and the mechanism is already wired. `authorizeGuest($mac, $minutes)` sends
UniFi `cmd: authorize-guest` with a **minutes** value taken from
`session_minutes`. UniFi holds that MAC as authorized for the whole window and
does not redirect it again. Set the window to a term and a student signs in once
a term.
The portal is not the obstacle. **MAC randomization is** — see decision 2.
---
## Decisions
| # | Decision | Rationale |
|---|---|---|
| 1 | Harden and finish the existing portal rather than redesign | 80% exists and the schema shape is right; the defects are in accounting and trust, not structure |
| 2 | Detect a randomized MAC and coach the user; keep manual eviction | Manual choice is the stated requirement and already built; randomization is detectable, so the confusing case can be explained rather than hidden |
| 3 | Derive the MAC from the request's source IP and verify AP association; ignore `?mac=` | Closes the ownership hole by construction — the caller never gets to name a MAC |
| 4 | Add `role` as a `match_type` | Quotas were asked for per role; roles are the platform's own authority and need no external sync |
| 5 | One Wi-Fi Devices page grouped by person; no profile card | Matches the requirement exactly; keeps `/admin/users` as the one directory |
| 6 | Device type is **reported, not enforced**, for now | Whether `dev_cat` is populated in this environment is unverified; enforcing on unvalidated data can lock out the wrong people |
| 7 | **Explicit allow-list only** — no `default` mapping | No Wi-Fi unless an admin has added that role and set an amount. A `default` row silently grants everyone, which is the opposite of the requirement |
| 8 | Roles are chosen from a dropdown; session length entered in days | A free-text role slug that matches nothing fails silently — it presents as "this student has no Wi-Fi" with no error anywhere to explain it |
---
## A. What consumes a device slot
### The defect
A slot is currently freed when `isClientConnected($mac)` is false. That conflates
**connected right now** with **authorized**, and they are different facts. Two
leaks follow, in opposite directions, and both get worse the longer
`session_minutes` is — which is exactly the direction this design pushes it.
**Leak 1 — offline treated as unauthorized.** A phone leaves the building. Its
session row is marked inactive, freeing a slot, while UniFi still holds the MAC
authorized for the rest of its window. The user authorizes a second device. Two
devices are now on the network against a one-device quota, and one of them is an
orphan nothing tracks.
**Leak 2 — `expires_at` is written and never read.** No scheduled job expires
sessions. A row stays `is_active` long after UniFi stopped honouring it, so the
user is refused by slots that do not exist.
They compound: the same student can be simultaneously over-provisioned on the
network and locked out of the portal.
### The model
- A slot is consumed by a **live authorization**: `is_active AND expires_at > now()`.
Presence on the network is irrelevant to the count.
- A slot is freed by exactly three things: eviction (user or admin), expiry, or the
reconciler finding UniFi no longer holds it.
- `isClientConnected()` is **no longer an eviction trigger**. It becomes a display
value only — "connected" / "away" on the admin page.
- Quota resolution gains `role`. Precedence stays `sort_order`, so a mapping for
`staff` above one for `parent` gives a staff-parent 4 devices.
---
## B. Identifying the device
### Flow
1. UniFi redirects the device to `/portal/wifi/callback?ap=<ap_mac>&ssid=…&url=…`.
2. Google auth via the shell — unchanged.
3. Resolve the device **server-side**: match the request's client IP against
UniFi's live client table (`getActiveClients()`, on `ip`) to obtain the real
MAC, its `ap_mac`, and its authorization state.
4. Require that the resolved client's `ap_mac` equals the `ap` in the redirect —
the device must be on the AP that sent it here.
5. `?mac=` is **ignored**: not validated, not used. It is read for one purpose —
if present and disagreeing with the derived MAC, log it. That disagreement is
somebody attempting to authorize a device they do not own, and today nothing
would notice.
6. Check the locally-administered bit on the **derived** MAC. Record it on the
session; it is what triggers the coaching copy on the denial page.
This replaces the hole the original author documented and declined to guess at:
every identifying value was request-supplied, so any signed-in account could
authorize somebody else's MAC up to `max_devices`.
### Failure modes, deliberately closed
- **IP matches no client** → refuse.
- **IP resolves to the proxy** → refuse, and report it distinctly (see below).
- **AP mismatch** → refuse.
- **Client not in the table yet** (associated, pre-DHCP) → retry twice at ~750ms,
then show "still connecting, try again" rather than a hard error. Bounded on
purpose: this runs inside a web request, and a longer wait would hold a worker
per connecting device at exactly the moment a class of them arrives at once.
### The operational risk this creates
Failing closed means it can fail closed **for everyone**. If `TrustProxies` is
not correct for Traefik, every request resolves to the proxy, no client ever
matches, and nobody can get on the Wi-Fi — a worse outage than the vulnerability
being fixed.
Mitigation: the resolver distinguishes "no client matched this IP" from "this IP
is the proxy", and the second is surfaced as a health line on the Wi-Fi Devices
page. The failure becomes one glance to diagnose instead of a mystery. This is
also the single most important thing to verify against the live controller.
### MAC randomization
Normally stable per SSID, so it does not break the design. When it does rotate,
the device looks new: it consumes a slot and the user is asked to evict what
appears to be the same phone. With `max_devices: 1` this is the likeliest support
call.
Handling: when the derived MAC is randomized **and** the user is at limit, the
denial page leads with an explanation — the device is using a private Wi-Fi
address, which is why it looks new — followed by the iOS and Android steps to
turn it off for this network, then the normal eviction list. Detection is the
locally-administered bit; no guessing.
---
## C. Persistence, and the reconciler
`session_minutes` is the lever for "no portal on every connection". Default to a
term (~120 days) rather than a year, so lost or handed-down devices age out.
A scheduled reconciler (following `CaptureSnapshots` and the `unifi_cron_runs`
pattern) does three jobs:
1. **Expire** rows past `expires_at` — fixes leak 2.
2. **Reconcile both directions** — revoke UniFi authorizations with no live row
(the orphans leak 1 produced), and expire rows UniFi no longer holds.
3. **Capture device type** onto the session from the client record — the
report-only fingerprint data for decision 6.
Job 2 also heals controller-side loss: if UniFi forgets an authorization while our
row is still live and in-window (upgrade, restore), the reconciler re-authorizes
it. The user is not bounced to the portal for something that was not their doing.
Windows are **fixed, not sliding** — a device's authorization ends when it ends,
so "one term" means one term.
---
## C2. Settings: who gets Wi-Fi, and how much
Access is an **explicit allow-list**. A person gets Wi-Fi only if one of their
roles has been added here with an amount. No role added means no Wi-Fi, and that
is the intended answer rather than a gap to be filled by a catch-all.
Consequences, stated so nobody re-adds the hole later:
- **`default` is removed from the available match types.** A `default` row matches
everybody, so its existence makes the allow-list advisory. `resolveMapping()`
already returns null when nothing matches, and null already produces the
"No WiFi access configured for your account." denial — so deny-by-default is
the behaviour once the catch-all cannot be created.
- Safe to remove now: **there are zero rows in `unifi_vlan_mappings` in any
environment**, so nothing is being revoked from anyone. Doing this later, after
a school had leaned on a catch-all, would cut off every unmapped person at once.
- `email_domain`, `group` and `ou` stay available — they are narrow matches, not
catch-alls, and removing working options is not part of this.
### The settings screen
Extends the existing Portal settings page rather than adding another one. Per row:
| Field | Control | Why |
|---|---|---|
| Role | **dropdown of real seeded roles** | Free text is the defect. `match_value` is a string compared exactly, so `Student` or `studnet` matches nobody and the failure surfaces as a pupil with no Wi-Fi and nothing logged. The dropdown makes an unmatchable value impossible to enter. |
| Devices | number, 1..50 | The quota. Column is already bounded. |
| Session length | **days, with presets** (a week / a month / a term / a year) | `session_minutes` stays the stored unit, but a term is 172,800 minutes and nobody should type that. Same reasoning as presenting cron schedules as presets rather than raw strings. |
| VLAN | existing select | **Still required** — the network needs to know where to put the device, and this was not in the original request, so it is called out rather than quietly dropped. |
| Order | existing `sort_order` | Precedence for multi-role people: staff above parent gives a staff-parent 4. |
Roles already in the system and available to add: staff, student, parent,
oncall-staff, coach, alumni, applicant, board-member, contact, homeschool,
homeschool-parent, homeschool-student, open-house, tuition-payee, forms-payer.
## D. The Wi-Fi Devices page
New page under Unifi, gated on the existing `unifi.auth`, grouped by person.
- **Person row:** name, role, quota usage ("staff · 2 of 4").
- **Device rows:** hostname, device type (reported), MAC in `font-mono` as a
technical id, a "private address" marker when randomized, AP, SSID, authorized
at, expires at, and connected/away — display only.
- **Per-device disconnect** as a `.btn-icon-danger` row action behind the shared
confirm modal. Never a native `confirm()`.
- Search, plus a filter for over-quota people.
- The portal health line from section B.
Follows the house table and empty-state conventions in the shell's `CLAUDE.md`.
---
## E. Testing
### Covered here, with `Http::fake()` for the controller
- Both quota leaks, one regression test each: an offline device keeps its slot; an
expired row stops consuming one.
- `role` matching, and `sort_order` precedence including the staff-parent case.
- The reconciler in both directions, including the re-authorize-on-controller-loss
path.
- Randomized-MAC bit detection, both values.
- Resolver fail-closed paths: no client match, IP resolves to the proxy, AP
mismatch, client not yet in the table.
- That `?mac=` is ignored, and that a disagreement is logged.
- Authorization-not-connectivity: a slot survives the device going away.
Plus a browser pass over the denial page (including the randomized-MAC copy),
self-service eviction, and the admin page — UI behaviour changes, so the
before-push browser rule applies.
### NOT provable without the live controller
- The actual parameters UniFi's redirect sends.
- Whether `dev_cat` is populated for these clients — `unifi_client_snapshots` has
zero rows on dev, so there is no evidence either way.
- Whether `TrustProxies` yields the true client IP through Traefik.
These need a supervised session against real hardware, in the same spirit as the
Bosch cutover. The third is the one that gates release: it is the difference
between a closed hole and nobody getting online.
---
## F. Rollout
1. **Register the app.** Every route here 404s today, because `unifi` has no
`dashboard_apps` row and `app.access:unifi` aborts 404 when the lookup misses.
Nothing in this spec is reachable until that is resolved. Note `central-unifi`
declares `stability: stable`, so unlike On-Call this is a gap rather than a
decision — **and it is still an open question, not settled by this document.**
2. Verify the three hardware-dependent items above against the live controller.
3. Configure mappings: `role` → student (1 device) and staff (4 devices), with
`sort_order` set so multi-role people land deliberately.
4. Pilot with staff, then students.
---
## Explicitly out of scope
- **Enforcing** a no-phones rule. Decision 6 is report-only until the fingerprint
data is shown to be trustworthy. When it is, enforcement belongs in the
reconciler (authorize, then sweep), because UniFi's fingerprint is derived from
observed traffic and is not available at the moment of authorization.
- MAC OUI as a device-type signal. A randomized MAC has no real OUI, so this
would fail on phones — its intended target — while working on the school
laptops that should not be blocked.
- Blocking on the portal's User-Agent. Defeated by one "Request Desktop Site" tap.
- Tethering. A blocked phone can share a laptop's connection and vice versa; no
design here addresses that, and none is proposed.
- Any change to `disconnectSession`, which is already correct.

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',
'vlan_id' => 'required|integer|min:1|max:4094',
'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;
VlanMapping::create($data);
@@ -51,7 +55,11 @@ class PortalController extends Controller
'match_value' => 'required|string|max:255',
'vlan_id' => 'required|integer|min:1|max:4094',
'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);
return back()->with('success', 'Mapping updated.');
@@ -87,6 +95,35 @@ class PortalController extends Controller
// ── 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)
{
// 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);
}
// 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
$mapping = $this->resolveMapping($user);
if (! $mapping) {
@@ -161,9 +215,16 @@ class PortalController extends Controller
'expires_at' => now()->addMinutes($minutes),
]);
// Redirect to the original URL the user was trying to reach
$redirectUrl = $request->input('url', '/');
return redirect($redirectUrl);
// Redirect to the original URL the user was trying to reach.
//
// 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)

View File

@@ -1,207 +0,0 @@
<?php
namespace Dashboard\Unifi\Http\Controllers;
use App\Models\DashboardApp;
use App\Models\Group;
use App\Models\NavItem;
use App\Models\Role;
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' => [], 'roles' => []]);
}
$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']);
// Roles: only ones with at least one grant — more added via searchRoles.
$grantedRoleIds = $grants->flatten(1)->where('grantee_type', 'role')->pluck('grantee_id')->unique();
$roles = Role::whereIn('id', $grantedRoleIds)->orderBy('label')->get(['id', 'slug', 'label']);
return response()->json([
'pages' => $pages->map(function ($p) use ($grants) {
$pageGrants = $grants->get($p->id, collect());
$defaultRow = $pageGrants->firstWhere('grantee_type', 'default');
return [
'id' => $p->id,
'label' => $p->label,
'route_name' => $p->route_name,
'default_allow' => (bool) ($defaultRow?->can_view ?? false),
'user_ids' => $pageGrants->where('grantee_type', 'user')->where('can_view', true)->pluck('grantee_id')->values()->all(),
'group_ids' => $pageGrants->where('grantee_type', 'group')->where('can_view', true)->pluck('grantee_id')->values()->all(),
'role_ids' => $pageGrants->where('grantee_type', 'role')->where('can_view', true)->pluck('grantee_id')->values()->all(),
];
})->values(),
'users' => $users,
'groups' => $groups,
'roles' => $roles,
]);
}
/**
* 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]);
}
/**
* Typeahead-style search for roles to add to the access matrix.
* An empty query returns every role (the role list is small).
*/
public function searchRoles(Request $request)
{
$q = trim((string) $request->query('q', ''));
if (strlen($q) < 1) {
return response()->json(['roles' => Role::orderBy('label')->get(['id', 'slug', 'label'])]);
}
$roles = Role::where('label', 'like', '%' . $q . '%')
->orWhere('slug', 'like', '%' . $q . '%')
->orderBy('label')
->limit(20)
->get(['id', 'slug', 'label']);
return response()->json(['roles' => $roles]);
}
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([
'default_allow' => 'boolean',
'user_ids' => 'present|array',
'user_ids.*' => 'integer|exists:users,id',
'group_ids' => 'present|array',
'group_ids.*' => 'integer|exists:groups,id',
'role_ids' => 'array',
'role_ids.*' => 'integer|exists:roles,id',
]);
$grantedBy = $request->user()?->id;
DB::transaction(function () use ($navItem, $data, $grantedBy) {
// Upsert the default row (one per nav_item). firstOrCreate can't
// match on grantee_id=NULL reliably in MySQL, so look it up first.
$default = UnifiPageGrant::where('nav_item_id', $navItem->id)
->where('grantee_type', 'default')
->first();
if ($default) {
$default->update(['can_view' => (bool) ($data['default_allow'] ?? false)]);
} else {
UnifiPageGrant::create([
'nav_item_id' => $navItem->id,
'grantee_type' => 'default',
'grantee_id' => null,
'can_view' => (bool) ($data['default_allow'] ?? false),
'granted_by_user_id' => $grantedBy,
]);
}
$this->syncGrantsOfType($navItem->id, 'user', $data['user_ids'] ?? [], $grantedBy);
$this->syncGrantsOfType($navItem->id, 'group', $data['group_ids'] ?? [], $grantedBy);
$this->syncGrantsOfType($navItem->id, 'role', $data['role_ids'] ?? [], $grantedBy);
});
return response()->json(['ok' => true]);
}
private function syncGrantsOfType(int $navItemId, string $type, array $ids, ?int $actorId): void
{
UnifiPageGrant::where('nav_item_id', $navItemId)
->where('grantee_type', $type)
->whereNotIn('grantee_id', $ids ?: [0])
->delete();
foreach ($ids as $id) {
UnifiPageGrant::updateOrCreate(
[
'nav_item_id' => $navItemId,
'grantee_type' => $type,
'grantee_id' => $id,
],
[
'can_view' => true,
'granted_by_user_id' => $actorId,
],
);
}
}
}

View File

@@ -36,6 +36,9 @@ class UnifiSettingsController extends Controller
'ppskSchedulingEnabled' => (bool) Setting::get('unifi.ppsk_scheduling.enabled', false),
'apiEnabled' => (bool) Setting::get('unifi.api.enabled', false),
'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', ''),
]);
}
@@ -74,6 +77,9 @@ class UnifiSettingsController extends Controller
'rotation_wordlist' => 'nullable|string|max:20000',
'ppsk_scheduling_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, '/'));
@@ -101,6 +107,9 @@ class UnifiSettingsController extends Controller
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.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, '/')));

View File

@@ -1,51 +0,0 @@
<?php
namespace Dashboard\Unifi\Http\Middleware;
use App\Models\NavItem;
use Closure;
use Dashboard\Unifi\Models\UnifiPageGrant;
use Dashboard\Unifi\UnifiServiceProvider;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* Per-page access for unifi pages. Attached dynamically by the
* RouteMatched listener in UnifiServiceProvider — the check cannot
* live in the listener because RouteMatched fires before session/auth
* middleware, when request->user() is still null.
*/
class EnforceUnifiPageGrant
{
public function handle(Request $request, Closure $next): Response
{
$user = $request->user();
$routeName = $request->route()?->getName();
if (! $routeName || ! $user || $user->is_super_admin) {
return $next($request);
}
try {
$item = NavItem::where('route_name', $routeName)->first();
if ($item) {
// Permission holders keep access — grants extend, never revoke.
$holdsPermission = $item->required_permission
&& UnifiServiceProvider::realPermissionKeysFor($user)->contains($item->required_permission);
if (! $holdsPermission && ! UnifiPageGrant::userCanAccess($user, $item)) {
// 404 instead of 403 — don't leak that the page exists.
abort(404);
}
}
} catch (\Symfony\Component\HttpKernel\Exception\HttpException $e) {
throw $e;
} catch (\Throwable) {
// unifi_page_grants may not exist yet on first install — fail
// open in that narrow window (permission middleware still
// guards the route).
}
return $next($request);
}
}

View File

@@ -1,65 +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', // default | user | group | role
'grantee_id', // null for grantee_type=default
'can_view',
'granted_by_user_id',
];
protected $casts = [
'can_view' => 'boolean',
];
public function navItem(): BelongsTo
{
return $this->belongsTo(NavItem::class);
}
public function grantedBy(): BelongsTo
{
return $this->belongsTo(User::class, 'granted_by_user_id');
}
/**
* Grant check with a per-page default fallback. Passes if the user
* matches an explicit user/group/role grant; otherwise falls back
* to the page's `default` row (deny by default — no row or
* can_view=false denies). Permission-based access is checked
* separately by the caller — grants extend, never revoke.
*/
public static function userCanAccess(User $user, NavItem $navItem): bool
{
if ($user->is_super_admin) return true;
$groupIds = $user->groups()->pluck('groups.id');
$roleIds = $user->roles()->pluck('roles.id');
$hasExplicit = static::where('nav_item_id', $navItem->id)
->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));
})
->exists();
if ($hasExplicit) return true;
return (bool) static::where('nav_item_id', $navItem->id)
->where('grantee_type', 'default')
->value('can_view');
}
}

View File

@@ -3,9 +3,6 @@
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
@@ -24,36 +21,45 @@ class UnifiServiceProvider extends ServiceProvider
$this->loadRoutesFrom(__DIR__ . '/routes/unifi.php');
$this->loadMigrationsFrom(__DIR__ . '/../database/migrations');
// "/" home card: guest Wi-Fi network + current rotating password +
// 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',
'Guest Wi-Fi',
'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',
// So the permission screens can say "granted, but not switched
// on yet" rather than leaving a grantee staring at a home page
// with nothing new on it.
configured: fn () => \Dashboard\Unifi\Http\Controllers\GuestCardController::configured(),
);
} catch (\Throwable) {
// Older shell without the card registry — skip.
}
// 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,
@@ -69,38 +75,6 @@ class UnifiServiceProvider extends ServiceProvider
}
}
/** 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,

View File

@@ -75,17 +75,6 @@ Route::middleware(['web', 'auth', 'app.access:unifi'])
Route::post('/settings/test', [UnifiSettingsController::class, 'testConnection'])->name('settings.test');
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/role grants plus the
// per-page "everyone else" default row (deny by default).
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::get('/settings/pages-access/roles/search', [UnifiPagesAccessController::class, 'searchRoles'])->name('settings.pages-access.roles.search');
Route::put('/settings/pages-access/{navItem}', [UnifiPagesAccessController::class, 'update']) ->name('settings.pages-access.update');
});
// Cron logs — read-only history of scheduled-task runs.
Route::get('/settings/cron-logs', [UnifiCronLogsController::class, 'index'])->name('settings.cron-logs.index');
@@ -103,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) ──────────────────────────────────────────
// External integrations (signage, kiosks) hit these without session auth.
Route::prefix('api/unifi')->name('unifi.api.')->group(function () {
@@ -114,3 +112,21 @@ Route::prefix('api/unifi')->name('unifi.api.')->group(function () {
Route::middleware(['web', 'auth'])
->get('/portal/wifi/callback', [PortalController::class, 'captiveCallback'])
->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');