# Module 1 — Authentication, Roles & Permissions

> **KB version:** 2.0 · **Code baseline:** `b71c156` · **Last reviewed:** 2026-10-26
> **Epic:** EP-TBD · **Status:** Pending PO review
> **Pending PO review:**
> - Platform-token-only item push routes: the four `items/mobile/*` and `items/cc/*` push/simulate endpoints carry no role or permission middleware, so any valid platform token can write the whole catalog.
> - Ingredient-category CRUD gate: all five endpoints (read included) demand the full `ingredients.read` + `create` + `update` + `delete` set, so a read-only ingredient role is locked out of even listing categories.
> **Related flags:** TBD (requires As-Built Spec cross-reference, out of scope this pass) · **Open decisions:** TBD (requires As-Built Spec OD register, out of scope this pass)

---

## Module Overview
- **Purpose:** Control who can access the Dashboard, the POS, and the platform (machine-to-machine) API.
- **Business Goal:** Segregate head-office staff, branch staff, kitchen staff, and external platforms; enforce single-session tokens per user.
- **Main Actors:** `superadmin`, `admin` (head office); `cashier`, `chief` (branch); `Platform` (external system, e.g. Asparagus Mobile App); Guest (unauthenticated POS client before login).
- **Dependencies:** Spatie roles/permissions package, custom token tables (`oauth_user_tokens`, `platform_tokens`), custom middleware (`auth_api`, `guest_api`, `api.role`, `api.permission`, `auth.platform`).
- **Entry Points:**
  - `POST /api/v1/auth/dashboard/login`
  - `POST /api/v1/auth/pos/login`
  - `POST /api/v1/auth/logout`
  - `POST /api/v1/platform/login`, `POST /api/v1/platform/refresh`, `GET /api/v1/platform/me`

## Business Workflow
1. A user submits email + password to the dashboard or POS login endpoint.
2. The system checks the user exists and the password is correct.
3. The user's **first assigned role** is checked against the allowed roles for that surface:
   - Dashboard login allows only `superadmin` or `admin`.
   - POS login allows only `cashier` or `chief`.
4. If the role does not match, the login is rejected with HTTP 403 ("You are not authorized to login here") — even with correct credentials.
5. On success, **all previous tokens of that user are deleted** (single active session), and a new opaque 80-character token is issued, stored as a SHA-256 hash, **valid for exactly 1 day**.
6. Every subsequent request carries `Authorization: Bearer <token>`. Middleware resolves the token → user → **the user's branch is attached to the request** (this drives branch scoping everywhere else).
7. Role middleware (`api.role:cashier|chief`) and permission middleware (`api.permission:order.create`) gate each route. Permission checks use **all permissions of the user's first role**.
8. Logout deletes all the user's tokens and invalidates the session.

**Platform (machine) authentication:**
1. Platform submits `username` + `password` to `/platform/login`.
2. On success all still-active platform tokens are **revoked** (not deleted) and a new 80-char token is issued (SHA-256 hashed, 1-day expiry).
3. `/platform/refresh` rotates the token: the presented valid token is revoked and a fresh one is returned. A **legacy fallback** exists: if the presented value is an old-style encrypted token ID, it is accepted once, deleted, and replaced with a new-style token.
4. On platform-authenticated requests, `Auth` is set to the platform's **related user** (a seeded cashier account, e.g. "Asparagus Mobile Cashier"), so platform orders are attributed to that cashier and that cashier's branch rules apply.

**Guest access:** The pre-login POS screen fetches the branch/user list via `guest_api` middleware — a static shared key (`app.guest_key`, hardcoded in `config/app.php`) that the frontend sends encrypted; the backend decrypts and compares.

## Business Rules
- Dashboard is restricted to roles `superadmin`, `admin`.
- POS is restricted to roles `cashier`, `chief`.
- A user has effectively **one role** (only `roles[0]` is ever checked).
- Tokens expire after **1 day** (users and platforms alike).
- Logging in **kills all other sessions** of the same user (tokens deleted). Platform login kills other platform sessions (tokens revoked).
- Password minimum length: 8 characters (login validation and user create/update).
- Dashboard login requires the email to already exist in `users` (validation `exists:users,email`); POS login does not pre-check existence.
- Permission matrix (from `config/permission.php`):
  - **superadmin / admin:** full create/read/update/delete on user, item, payment_types, category, order (incl. change_status), promo_code, dsr, reports, ingredients, settings, order_types.
  - **cashier:** read-only on payment_types, promo_code, item, category, order_types; full CRUD + change_status on order; full CRUD on dsr.
  - **chief:** read-only on payment_types, promo_code, item, category, order_types; full CRUD + change_status on order. **No DSR access.**
- Permission names are `<model>.<action>` (e.g. `order.change_status`); actions are `create, read, update, delete, change_status`.
- The two gates read their lists differently: `api.role:a|b` passes when the user's first role is **any one** of the listed roles, while `api.permission:x|y` passes only when the user's role holds **every** permission in the list (the middleware loops the list and 403s on the first one missing).
- Route-level gate for ingredient categories: all five endpoints — `GET dashboard/ingredient-categories/list`, `GET .../view/{id}`, `POST .../create`, `PUT .../update`, `POST .../delete` — share one controller-wide gate of `api.role:admin|superadmin` plus `api.permission:ingredients.read|ingredients.create|ingredients.update|ingredients.delete`. There is no per-verb split: reading a category demands the same four permissions as deleting one. The ingredient endpoints themselves (`dashboard/ingredients/*`) carry the identical combined gate.
- The platform item-push routes are gated by a valid platform token alone. Each of `POST /v1/pos/items/mobile/check-mapper-slugs`, `POST /v1/pos/items/mobile/items-creation`, `POST /v1/pos/items/cc/item-creation` and `POST /v1/pos/items/cc/item-creation-simulate` sits in a route group whose only middleware is `cors` + `auth.platform`, and the backing controller actions (`CheckMapperSlugs`, `ItemCreationPlatform`, `ItemCreationSimulateCC`) are explicitly excluded from the controller's `api.role:cashier|chief` + `api.permission:item.read` middleware. No role and no permission is checked on them, and the platform's identity is not checked either: every authenticated platform may push or simulate items across the whole catalog, whichever platform it is. The acting user recorded for `created_by` is still the platform's `related_user`.
- Failed token lookup, malformed header, or missing header always answers HTTP 401 with code `auth-001`.
- Platform request identification: the `me` endpoint returns the platform record (password hidden).

## Hidden Rules
- **If a user is given two roles, only the first is honored** — the second role's permissions are invisible to the system.
- **Correct credentials + wrong surface = hard 403**, and (side effect) the just-created session is immediately logged out server-side.
- Because login deletes prior tokens, **two devices cannot share one cashier account simultaneously**; the second login silently kicks the first device.
- The guest key is a **hardcoded constant in source control** — anyone with repo access can mint guest tokens.
- Platform tokens are *revoked* (audit trail retained); user tokens are *deleted* (no audit trail).
- `AuthApiMiddleware` sets `request->branch` from the user's branch — **all branch-scoped behavior downstream implicitly trusts the user record's `branch_id`**, never a client-provided branch.
- User "delete" is a **hard delete** (the `SoftDeletes` trait is imported into the model but never used).
- Superadmin users are invisible in the dashboard user list and user detail endpoints (filtered out).
- New users are always created `is_active = 1`; there is **no code path that deactivates a user or checks `is_active`**.
- `CustomRoleMiddleware`/`CustomPermissionMiddleware` resolve the acting user via `$request->user()` (Laravel's real authenticated-user accessor), so `api.role`/`api.permission` do enforce on platform-authenticated routes as well — the platform's `related_user` is the identity they check. That enforcement only reaches routes that actually carry the middleware, and the item-push routes do not: `items/mobile/check-mapper-slugs`, `items/mobile/items-creation`, `items/cc/item-creation` and `items/cc/item-creation-simulate` are excluded from it, so no role or permission gate applies to them.
- **Any platform credential is effectively a catalog-write credential.** Because the item-push routes check nothing beyond `auth.platform`, a leaked or over-shared platform username/password — or a platform provisioned for an unrelated integration — can create, overwrite and simulate menu items for every branch's catalog. The blast radius of a platform token is therefore much wider than the "one platform impersonates one cashier" model suggests.
- The combined ingredient-category gate is an **all-four-permissions** check, not an any-one check: a role holding only `ingredients.read` is refused on *every* ingredient-category endpoint, listing and viewing included. Today only `admin` and `superadmin` reach these routes and `config/permission.php` grants both the full `create,read,update,delete` set, so the gate passes in practice — but introducing any read-only ingredient role would lock it out of reading categories rather than merely out of writing them.

## Decision Tree
```
LOGIN (dashboard or POS):
IF email not found (dashboard: fails validation 400) → reject
ELSE IF password wrong → 400/401 "Invalid credential"
ELSE IF user.roles[0].name NOT IN allowed-roles-for-surface
     → Auth::logout(); 403 "You are not authorized to login here"
ELSE
     → delete all existing tokens of user
     → create token (expiry = now + 1 day)
     → return token + user

AUTHENTICATED REQUEST:
IF Authorization header missing/malformed → 401
ELSE IF token hash not found OR revoked OR expired → 401
ELSE IF user of token no longer exists → 401
ELSE attach user + user.branch to request; continue
   IF route has api.role and user.roles[0].name not in role list → 403
   ELSE IF route has api.permission and any required permission missing from role → 403
   ELSE proceed to controller

PLATFORM REFRESH:
IF presented token valid & unrevoked & unexpired
     → revoke it, issue new token
ELSE IF value decrypts to a legacy token ID that exists
     → delete legacy token, issue new token
ELSE → 401 "Invalid or expired token"
```

## Database Understanding
- `users` — staff members; `branch_id` ties each staff member to exactly one branch. This link is *the* mechanism that scopes POS activity to a branch.
- `oauth_user_tokens` (`Token` model) — opaque bearer tokens for staff; exists so tokens can be expired/limited per-user independent of Laravel Sanctum (Sanctum is installed but effectively unused for these APIs).
- `platforms` — registered external systems (name, username, password, slug, related_user). The **slug** binds a platform to config in `config/services.php` (e.g. `asparagus_mobile_app`), used for callbacks.
- `platform_tokens` — bearer tokens for platforms, with `revoked_at` audit.
- Spatie tables (`roles`, `permissions`, `role_has_permissions`, `model_has_roles`) — role/permission matrix seeded from `config/permission.php`.
- `platforms.related_user` → `users.id`: **every platform impersonates a staff user**, so downstream logic (branch scoping, created_by) works without special-casing platforms.

## State Machine
Token lifecycle: `issued → (expired after 1 day | deleted-on-new-login | deleted-on-logout)`. Platform tokens: `issued → revoked (on new login/refresh) | expired`.
No other states in this module.

## APIs
| Endpoint | Purpose | Input | Output | Validation | Errors | Side effects |
|---|---|---|---|---|---|---|
| `POST /v1/auth/dashboard/login` | Head-office login | email, password | token + user | email required & exists; password ≥ 8 | 400 invalid; 403 wrong role | Deletes prior tokens |
| `POST /v1/auth/pos/login` | Branch staff login | email, password | token + user | email required; password ≥ 8 | 401 invalid; 403 wrong role | Deletes prior tokens |
| `POST /v1/auth/logout` | End session | token (header) | success msg | must be authenticated | 500 on error | Deletes tokens, invalidates session |
| `POST /v1/platform/login` | Platform login | username, password | token + platform | username exists in platforms | 400 validation; 401 invalid | Revokes prior active tokens |
| `POST /v1/platform/refresh` | Rotate platform token | Authorization header | new token | — | 400 no token; 401 invalid | Revokes/deletes presented token |
| `GET /v1/platform/me` | Identify platform | Authorization header | platform record | auth.platform | 401 | none |
| `GET /v1/pos/branches/listWithUsers` | Pre-login branch/user picker | guest key | branches (id, name, display_name) + users | guest_api key check | 401 | none |

## Edge Cases
- Login while another device is active: the other device's next request gets 401 with no explanation.
- Token presented exactly at expiry instant: `expire_date > now()` — an expired-by-a-second token fails.
- Platform legacy token replay: works exactly once (token row deleted after first refresh).
- A user whose role was removed after login: role middleware will throw (`roles[0]` on empty collection → exception → likely 500), effectively locking them out.
- `AuthApiMiddleware` wraps everything in try/catch returning 401 — any internal error during auth **masquerades as "Not authorized"**.

## Assumptions
- One user = one branch = one role.
- Platforms are few and provisioned manually via seeder (`PlatformSeeder`), not via any admin UI/API.
- The frontend knows how to encrypt the guest key with the app key (shared Laravel APP_KEY).

## TODOs / Technical Debt (business impact)
- `is_active` on users is written but never enforced → no way to suspend staff without deleting them.
- `ResponseMessageEnum::list()` references a misspelled undefined constant (`UNAUTHRAIZED`) → calling it fatals; localized message lists are effectively broken.
- Guest key hardcoded in the repo → rotate & move to env.
- Sanctum installed and `auth:sanctum` route exists (`GET /api/user`) but is orphaned from the real token system.
- Commented-out `last_used_at` updates → no session-activity telemetry.

## Open Questions
- Is single-session-per-user intentional policy or a side effect? (It prevents a cashier and a manager sharing an account — likely intentional but undocumented.)
- Should `chief` really have `order.create/update/delete` (config grants it) when routes only expose kitchen status change to chiefs?
- Is the ingredient-category gate meant to be per-verb (read for list/view, create/update/delete for the writes) rather than one all-four check on every endpoint? Equivalently: should a read-only ingredient permission grant — or at least not block — ingredient-category reads?
- Are the platform item-push endpoints intended to be open to every registered platform, or should they be restricted to the specific platform(s) that own the catalog source (by slug, role, or an `item.create`-style permission on the platform's `related_user`)?

---

