Roles & Permissions
Authorization = a minimum-role hierarchy × a per-org policy board. Both live in code once (packages/domain + convex/lib/functions.ts) and are enforced server-side on every function; frontend role gates are UX sugar.
The hierarchy
ROLE_RANK in the domain package — higher rank ⊇ lower rank's defaults:
| Role | Rank |
|---|---|
| owner | 150 |
| manager | 100 |
| supervisor | 75 |
| employee | 50 |
| volunteer | 20 |
| customer | 5 |
roleAtLeast(role, min) compares ranks. requireRole(ctx, min) throws typed FORBIDDEN below the bar.
The policy board
Each sensitive action has a default minimum role (POLICY_DEFAULTS), overridable per org in the rolePolicies table (Settings → Roles):
| Action | Default | Governs |
|---|---|---|
clients.manage | employee | create/edit/archive clients, start portal impersonation |
clients.view_vip | manager | see (and edit) the VIP flag |
pets.manage | employee | pets, medication needs, documents |
visits.manage | employee | book, edit, cancel, no-show |
visits.checkin | employee | check-in/out, care logging, desk signatures, open issues |
visits.checkin_override | manager | check in past unsigned agreements; resolve serious issues |
orders.manage | employee | create orders, record payments, fulfillment, dev cards |
orders.refund | manager | refunds (capped per payment) |
orders.refund_override | owner | refund payments older than 30 days |
products.manage | manager | catalog |
notes.manage | manager | post/edit/delete team notes |
messages.send | employee | outbound client messages |
staff.manage | owner | roster, invites, memberships, schedules |
reports.view | employee | all seven reports |
settings.manage | manager | every settings tab |
timeclock.approve | manager | approve/edit time entries |
Enforcement: requirePolicy(ctx, action) resolves the effective minimum (org override beats default) then requireRoles it. hasPolicy(ctx, action) is the soft variant used for data shaping — e.g. masking the VIP flag in list payloads rather than throwing.
The three v1-parity grants
The rewrite enforced three grants that in v1 were real permission toggles (commit c7cc36fc, a product decision):
clients.view_vip— VIP is masked inclients.list/listPage/getfor roles below the policy, andclients.updaterefuses to let those roles accidentally wipe a flag they can't see.orders.refund_override— refunds of payments older than 30 days require it; independently, refunds now cap against each payment's remaining refundable balance, so partial refunds can't re-spend the full amount.visits.checkin_override— checking in past unsigned required agreements, and resolving serious pet issues ("Resolve Serious Events" parity).
Deliberately not carried over: notes stay broadcast+ack (no per-note permissions), portal retail waits for post-cutover, and the volunteer role is being dropped (still present in the enum).
Self-access rules
Some functions blend policy with ownership:
timeclock.entries— employees/volunteers are forced to their own entries regardless of args.staff/[id]accepts the literal idme; self-access works withoutstaff.manage.- Portal functions scope everything to the caller's own client record; staff cross that line only via audit-logged impersonation (
asClientId+clients.manage).
Verification
The 27-case authz suite (convex/authz.test.ts) covers: staff-data access per role incl. a policy-board override changing the outcome, settings access, platform-owner checks, typed not-found for malformed and cross-tenant ids on every detail read, org resolution errors (unknown slug / bad token / non-member), and time-clock self-access. See Testing & Harness.