# Vertex WiFi — Roles & Permissions

> Deliverable 5 (authentication/authorisation). The complete permission catalogue
> and role matrix.

---

## 1. How it fits together

```
App\Enums\Permission  ──┐
                        ├──► RolePermissionSeeder ──► permissions / roles tables
App\Enums\UserRole    ──┘                                      │
                                                               ▼
                                          spatie/laravel-permission
                                                               │
                          ┌────────────────────────────────────┴───────────┐
                          ▼                                                ▼
                    Policies (server)                          UserResource → React
                    the actual control                         hides what you can't do
```

**The enums are the single source of truth.** `RolePermissionSeeder` syncs the
database from `Permission::cases()` and `UserRole::permissions()`, and it *removes*
permissions that no longer exist in the enum — so a revoked capability does not
linger on a role. Adding a permission is:

1. Add a case to `App\Enums\Permission`.
2. Add it to the roles that should have it in `App\Enums\UserRole::permissions()`.
3. `php artisan db:seed --class=RolePermissionSeeder`

The seeder is idempotent and safe on every deploy.

### The frontend is not security

`UserResource` ships the resolved permission list to React, and the UI hides
buttons the user cannot use. That is a **courtesy**. Every one of those actions is
checked again by a policy on the request itself, because anything in a browser can
be edited. The smoke test asserts this directly: a Staff token posting to
`/payments` gets 403 regardless of what the UI rendered.

---

## 2. The three roles

| Role | Intent | Permissions |
| --- | --- | --- |
| **Super Admin** | Full access, including user administration, settings and the audit log | 38 |
| **Admin** | Customer management, billing and reports | 32 |
| **Staff** | Customer encoding; read-only elsewhere | 11 |

Verified by the smoke test:

```
superadmin  role=Super Admin  perms=38  customers.delete=true   users.create=true   payments.create=true
admin       role=Admin        perms=32  customers.delete=true   users.create=false  payments.create=true
staff       role=Staff        perms=11  customers.delete=false  users.create=false  payments.create=false
```

### Deliberate exclusions

**Admin cannot manage users.** Creating accounts and assigning roles is a
privilege-escalation surface; it stays with Super Admin.

**Admin cannot change settings.** The SMS gateway credentials and billing defaults
sit behind `settings.update`. Admin holds `settings.view` so they can see the
configuration without being able to repoint the gateway.

**Admin cannot read the audit log.** This is separation of duties: an Admin should
not be able to review the trail that records their own actions. If you disagree for
your operation, add `Permission::ActivityLogsView` to the Admin list — it is one
line.

**Staff *can* update customers.** "Customer Encoding" implies being able to correct
your own typo. Drop `Permission::CustomersUpdate` from the Staff list to make the
role strictly append-only.

---

## 3. Full matrix

`●` granted · `·` not granted

| Permission | Super Admin | Admin | Staff |
| --- | :---: | :---: | :---: |
| `dashboard.view` | ● | ● | ● |
| **Customers** | | | |
| `customers.view` | ● | ● | ● |
| `customers.create` | ● | ● | ● |
| `customers.update` | ● | ● | ● |
| `customers.delete` | ● | ● | · |
| `customers.change_status` | ● | ● | · |
| `customers.view_credentials` | ● | ● | · |
| `customers.export` | ● | ● | · |
| **Plans** | | | |
| `plans.view` | ● | ● | ● |
| `plans.create` | ● | ● | · |
| `plans.update` | ● | ● | · |
| `plans.delete` | ● | ● | · |
| **Subscriptions** | | | |
| `subscriptions.view` | ● | ● | ● |
| `subscriptions.create` | ● | ● | · |
| `subscriptions.update` | ● | ● | · |
| **Invoices** | | | |
| `invoices.view` | ● | ● | ● |
| `invoices.create` | ● | ● | · |
| `invoices.update` | ● | ● | · |
| `invoices.void` | ● | ● | · |
| **Payments** | | | |
| `payments.view` | ● | ● | ● |
| `payments.create` | ● | ● | · |
| `payments.update` | ● | ● | · |
| `payments.void` | ● | ● | · |
| **Notes** | | | |
| `notes.view` | ● | ● | ● |
| `notes.create` | ● | ● | ● |
| `notes.delete` | ● | ● | · |
| **Reports** | | | |
| `reports.view` | ● | ● | · |
| `reports.export` | ● | ● | · |
| **SMS** | | | |
| `sms.view` | ● | ● | ● |
| `sms.send` | ● | ● | · |
| `sms.templates.manage` | ● | ● | · |
| **Settings** | | | |
| `settings.view` | ● | ● | · |
| `settings.update` | ● | · | · |
| **Users** | | | |
| `users.view` | ● | · | · |
| `users.create` | ● | · | · |
| `users.update` | ● | · | · |
| `users.delete` | ● | · | · |
| **Audit** | | | |
| `activity_logs.view` | ● | · | · |

---

## 4. Two permissions worth calling out

### `customers.view_credentials`

The PPPoE password is the one genuinely sensitive field on a subscriber record. It
is stored **encrypted** (not hashed — a technician must read it back to configure a
replacement router), hidden on the model, and re-added by `ConnectionResource`
*only* for a caller holding this permission.

A Staff-role encoder never receives it — not even in a payload the UI happens not
to render. What they do get is `has_pppoe_password: true`, so the profile page can
show "Set — you do not have permission to view it" instead of an empty field that
looks like missing data.

### `reports.export`

Separate from `reports.view` on purpose. Reading a figure on screen and walking out
with the entire subscriber list in a spreadsheet are different acts with different
risk. Staff hold neither; the smoke test confirms a Staff token gets 403 on an
export.

---

## 5. Policies do more than check a permission

Ten policies live in `app/Policies/`. Most map 1:1 onto a permission, but several
enforce rules a permission alone cannot express — these are the ones that matter:

### `UserPolicy`

| Guard | Why |
| --- | --- |
| Nobody may deactivate or delete **their own** account | A one-click self-lockout |
| Only a Super Admin may modify another **Super Admin** | Otherwise an Admin with `users.update` could demote their superior |
| The **last** Super Admin cannot be deleted or demoted | Guarantees the system always has someone who can administer it |
| Role assignment is Super Admin only | Checked in both the Form Request and the policy — it is the escalation surface |

### `PaymentPolicy`

A settled payment is a financial record. Editing it in place would silently rewrite
a receipt already in a subscriber's hands, so `update` is limited to unsettled rows
and anything else must be **voided and re-entered**, leaving both rows visible.
`delete` returns `false` unconditionally.

`void` returns an `Illuminate\Auth\Access\Response` rather than a bool, so an
already-voided payment produces *"Payment RCP-2026-00026 was already voided on
Aug 7, 2026"* instead of the misleading generic "This action is unauthorized" — the
user has the permission; there is simply nothing left to reverse.

### `InvoicePolicy`

`update` is refused once `amount_paid > 0`. `void` is refused for settled invoices —
void the payments first, so the cash trail stays explainable.

### `CustomerNotePolicy`

Authors may reword their own note for 30 minutes, and may always retract it. Beyond
that window it takes `notes.delete`. A note is a manual audit entry; freely
rewritable history is not an audit trail.

### `ActivityLogPolicy` / `SmsLogPolicy`

Every write method returns `false`. These tables are append-only evidence.
Retention is handled out-of-band by `vertex:prune-activity-logs`.

### `PlanPolicy`

`forceDelete` returns `false` always — historical invoices resolve their plan
through `withTrashed()`, so a purge would orphan them.

---

## 6. Authentication

**Sanctum bearer tokens**, not the cookie/SPA mode. The frontend deploys separately
from the API (often on a different host), and token auth avoids the SameSite, CSRF
and shared-domain constraints that make cookie mode brittle in that topology.

The trade-off is that the token sits in browser storage, so:

| Mitigation | Where |
| --- | --- |
| Finite lifetime (default 12h) | `config/sanctum.php` — Laravel's default is *never expire* |
| Expiry checked client-side on boot | `tokenStore.get()` — avoids a pointless 401 and a flash of the app shell |
| Revoked server-side on logout | `AuthController::logout` deletes the current token only |
| "Sign out everywhere" | `AuthController::logoutAll` |
| Revoked when an account is deactivated | `EnsureUserIsActive` middleware — runs on **every** request, because a token outlives the login that created it |
| Revoked on password change | Other sessions dropped; the current one survives |

### Login hardening

There is **no registration endpoint anywhere in this application.**

| Measure | Detail |
| --- | --- |
| No account enumeration | "These credentials do not match our records" for both wrong password and unknown email — and the password is hashed even when the user does not exist, so the timing does not leak either |
| Per-account throttle | 5 attempts per email+IP per minute (`LoginRequest`) |
| Per-source throttle | 10 attempts per IP per minute (named `login` limiter) |
| Deactivated accounts | Rejected at login *and* on every subsequent request |
| Breach checking | New passwords run through `Password::uncompromised()` |
| Strength | 10+ chars, mixed case, number, symbol |

Keying the per-account limiter on **email + IP** is deliberate: keying on IP alone
lets one attacker behind a shared NAT lock out an entire office, and keying on
email alone lets an attacker lock a known admin out of their own account.

---

## 7. Audit trail

`App\Traits\LogsActivity` writes an `activity_logs` row on every create, update and
delete of a Customer, Plan, Payment, Invoice, Subscription, User, Setting and
SmsTemplate, plus login, logout and password-change events.

Two things it deliberately does **not** do:

- **No full row snapshots.** `old_values`/`new_values` hold only the keys that
  actually changed. Full snapshots bloat the table without adding anything you can
  query for, and an update that touched only `updated_at` writes no row at all.
- **No secrets.** `password`, `remember_token` and `pppoe_password` are stripped
  globally. A credential sitting in a log table is a liability, not an audit trail.

Viewable at `/activity` (Super Admin), and per-subscriber on their profile.
