Permission
The model that controls who can see what and do what in Helm. Three layers stack: built-in screen list → role defaults → per-staff overrides.
The screen-visibility layer is built. Action-level permissions (e.g., "can refund" within Sales) are a slice-1 follow-on.
Layer 1: screens
The built-in list of top-level navigation tabs:
| screen_id | name | What it shows |
|---|---|---|
today | Today | The daily dashboard |
sales | Sales | The till (formerly Ring-Up) |
customers | Customers | Customer search and profile |
service | Service | Kanban + ticket detail |
inventory | Inventory | SKU + variant management |
trades | Trades | Trade-ins + consignments |
rentals | Rentals | Fleet + bookings |
orders | Orders | Online orders + POs |
reports | Reports | Financial + operational reports |
settings | Settings | Shop config + staff + integration creds |
Seeded once at deploy time via seed_permissions.sql. Idempotent.
Layer 2: roles + role_permissions
CREATE TABLE roles (
id INTEGER PRIMARY KEY,
name TEXT UNIQUE
);
CREATE TABLE role_permissions (
role_id INTEGER REFERENCES roles(id),
screen_id TEXT REFERENCES screens(id),
can_see INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (role_id, screen_id)
);
Defaults (illustrative; tune per shop):
| role | today | sales | customers | service | inventory | trades | rentals | orders | reports | settings |
|---|---|---|---|---|---|---|---|---|---|---|
| Sys Admin | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| Owner | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| Service Lead | ✓ | ✓ | ✓ | ✓ | ✓ | – | – | – | – | – |
| Mechanic | ✓ | – | ✓ | ✓ | ✓ | – | – | – | – | – |
| Sales | ✓ | ✓ | ✓ | – | ✓ | ✓ | ✓ | ✓ | – | – |
| Junior | ✓ | ✓ | ✓ | – | – | – | – | – | – | – |
Layer 3: staff_screen_permissions
CREATE TABLE staff_screen_permissions (
staff_id INTEGER REFERENCES staff(id),
screen_id TEXT REFERENCES screens(id),
can_see INTEGER NOT NULL,
PRIMARY KEY (staff_id, screen_id)
);
A row here for a specific (staff, screen) wins over the role default. NULL/missing → role default applies.
See ADR-0014: Per-staff permission overrides for the design rationale.
Resolution
The Worker computes the resolved permission on every request that needs gating:
SELECT COALESCE(
ssp.can_see,
rp.can_see,
0
) AS can_see
FROM staff s
LEFT JOIN role_permissions rp ON rp.role_id = s.role_id AND rp.screen_id = ?
LEFT JOIN staff_screen_permissions ssp ON ssp.staff_id = s.id AND ssp.screen_id = ?
WHERE s.id = ?
This is wrapped in a cheap helper called once per request, then cached for the request lifetime.
API authorization
API endpoints are gated by a map from URL pattern → required screen:
// Pseudo
const requireScreen = {
'/api/customers': 'customers',
'/api/tickets': 'service',
'/api/sales': 'sales',
'/api/inventory': 'inventory',
'/api/staff': 'settings', // staff management is in Settings
// ...
};
Middleware checks the resolved permission before invoking the handler.
Settings → Staff & Permissions specifically requires both screen:settings visibility AND role:owner OR sys_admin. Captured as additional middleware.
Action-level permissions (slice 1 follow-on)
Some actions within an accessible screen are more privileged. For example: anyone with Sales access can ring a sale; refunding requires Manager+. These will live in a screen_actions + role_action_permissions + staff_action_permissions triple.
Not built in v0.1; the screen-visibility layer is the foundation.
Manager override flow
For action-level permissions, a manager-override pattern:
- The user attempts the privileged action
- A modal appears: "Requires Manager approval"
- Manager enters their PIN; their permission is checked
- The action is permitted for this single change only
- An
audit_eventsrow records the override (action:permission.overridden, override_by_staff_id: manager's id)
In-situ editing surface
On the Staff & Permissions card in Settings (only visible to roles with settings access):
- Each staff row: checkboxes per screen (column header is screen name)
- Select/Deselect All buttons per staff row
- "Reset to role defaults" button per staff
- Role dropdown per staff (changing it doesn't clear per-staff overrides — overrides win regardless)
Migrated from AIM
AIM has its own role/permission model that does not map cleanly. The migration seeds Helm's default roles + permissions but does NOT carry forward AIM's specific staff permissions — each staff member's effective access is recomputed from their role defaults at first sign-in. The Sys Admin (owner) then customizes per-staff overrides as needed.