# Identity, permissions & audit ## Two kinds of people Customers and staff are **separate tables and auth guards**. Different risk profile, different login methods, and a customer account can never be escalated into an admin by mistake. ### Customer `id, first_name, last_name, email (unique, verified_at), phone (E.164, verified_at?), password_hash?, marketing_opt_in (+ consent timestamp & source), locale, created_via (web | app | kiosk_claim | import), last_seen_at, archived_at`. - Guest checkout creates no account; an order can be **claimed** later by an account with the same verified email (and kiosk receipts can offer "save to account"). - GDPR: export + erase. Erase anonymises the customer but keeps orders (accounting). ### SocialAccount `id, customer_id, provider (apple | google | facebook?), provider_user_id, email, email_is_private_relay, linked_at`. Unique on (provider, provider_user_id). - **Apple** is mandatory on iOS if any other social login is offered (App Store rule 4.8). - **Google** web + Android + iOS. - Account linking: same verified email → link automatically; otherwise ask the user to confirm. - Apple private relay emails are supported (we never assume the email is real). ### CustomerDevice Push tokens per device (`platform, token, app_version, last_seen_at`) for "Your order is ready". ### StaffUser `id, name, email (unique), password_hash, mfa_secret?, mfa_enforced, status (invited | active | suspended), last_login_at, invited_by`. Optional "Sign in with Google" restricted to the client's Google Workspace domain. MFA (TOTP) mandatory for anyone with a global role. ## Permission system (RBAC, location-scoped) ``` StaffUser ──< RoleAssignment >── Role ──< role_permission >── Permission │ └── scope: global | location(id) ``` - **Permission**: a fixed code-defined key, seeded from code (not editable in UI): `orders.view`, `orders.update_status`, `orders.cancel`, `orders.refund`, `availability.manage`, `location.pause`, `location.settings`, `menu.view`, `menu.edit`, `menu.prices`, `menu.publish`, `customers.view`, `customers.export`, `customers.erase`, `staff.view`, `staff.manage`, `roles.manage`, `devices.manage`, `integrations.manage`, `branding.manage`, `loyalty.view`, `loyalty.adjust`, `reports.view`, `reports.finance`, `audit.view`, `settings.global`. - **Role**: named bundle of permissions, editable in admin (`is_system` roles can't be deleted). Optional per-permission **limits** in `role_permission.constraints` json (e.g. `orders.refund` max £20). - **RoleAssignment**: (staff_user, role, scope_type, scope_id). A person can be *Store Manager @ Noel* and *Shift Lead @ Brewer* at the same time. - Check: `can(user, 'orders.refund', location)` = any assignment whose role has the permission and whose scope is global or that location. Global-only permissions (`roles.manage`, `settings.global`) ignore location assignments. - Admin UI hides what you can't do, API enforces it (policies), and every denied attempt on a sensitive permission is audited. Default roles (seed): | Role | Scope | Gist | |---|---|---| | Owner | global | everything | | Operations Manager | global | all stores, menu, staff (not Owners), reports | | Store Manager | location | orders, refunds, availability, pause, store staff, store reports | | Shift Lead | location | orders, availability, refunds ≤ £20 | | Kitchen | location | view orders, update status, availability | | Marketing | global | menu content (no prices), customers view, promotions | | Support | global | orders view, cancel, refund ≤ £50, customers view | | Finance | global | reports.finance, refunds view, exports | Implementation note: spatie/laravel-permission with "teams" = location scope covers most of this; global scope = team null. Constraints json is ours. ## Audit log Goal: "who changed what, when, from where" for **every** write — menu prices, availability toggles, refunds, role changes, logins, device activations, integration config. ### AuditEvent | Field | Notes | |---|---| | id | ULID (time-ordered) | | occurred_at | | | actor_type | `staff`, `customer`, `device`, `integration` (e.g. Stream webhook), `system` (scheduler) | | actor_id, actor_label | label snapshot ("Anna K. (Store Manager @ Noel)") survives user deletion | | impersonator_id? | when support acts as a customer / staff | | action | dotted verb: `product.price_changed`, `availability.snoozed`, `order.refunded`, `role.assigned`, `auth.login_failed` | | subject_type, subject_id, subject_label | | | location_id? | lets store managers see their store's log only | | changes | json `{ "price": [1295, 1395] }` — only changed fields, secrets redacted | | context | json: reason, refund amount, related ids | | ip, user_agent, request_id, channel (`admin`, `api`, `kiosk`, `webhook`) | | | prev_hash, hash | **hash chain** → tamper-evident (any edited/deleted row breaks the chain) | Rules: - Written from **domain events** in the service layer (not just Eloquent observers) so bulk updates, queued jobs and webhooks are covered and actions have business meaning ("price_changed", not "updated"). - Append-only: DB role used by the app has `INSERT, SELECT` only on `audit_events`. - Async write through the queue with an outbox table, so a failed audit never blocks a sale but is never lost. - Retention: 7 years for financial actions (refunds), 2 years for the rest **[Q]**; customer PII in `changes` is masked on erase. - Admin UI: timeline with filters (actor, store, action, subject, date), diff view, "view subject history" from any entity page, CSV export (`audit.view` + `reports.finance`).