Permission Profiles
Permission Rule Profiles are reusable RBAC bundles you assign to memberships (or to API keys via Integrations). Each profile is an ordered list of rules; the middleware walks them last-match-wins on every authenticated request.
Find the admin at Admin → Permission Profiles. Route:
/admin/permission-profiles.
The rule grammar
A rule is one string. It can be one of:
| Form | What it matches |
|---|---|
* | Catch-all — matches every entity-op pair |
read:* / write:* | Op-only — matches every entity for that op |
<op>:<Entity> | Exact match. Wildcards on either side: *:Setup, read:*Sheet |
<HTTP>:/<path-glob> | HTTP-style. Inert against the in-process entity-op gate (returns false). Useful for documenting an intent that an external API gateway can mirror without affecting the in-process check. |
Each rule is prefixed with + (allow) or - (deny). For
example:
+ *
- write:Setup
+ read:Issue
Walks last-match-wins:
read:Lap→ matched by+ *→ allowwrite:Setup→ matched by- write:Setup→ denyread:Issue→ last match is+ read:Issue→ allow
If no rule matches, the default is allow. Owners and admins always pass.
The middleware
apps/api/src/middleware/permissions.ts is the in-process gate.
Routes call requirePermission("Setup", "write") (or use the
factory) and the middleware:
- Resolves the caller's profile via their membership
- Walks the rule list last-match-wins
- Returns 403 on deny, passes through on allow
Profile-to-membership lookups are cached in-process by
(accountId, userId). The cache key is invalidated on profile
mutation and on assignment changes.
The admin
Master/detail layout:
- Left — profile list (with a "Read Only" + "Full Access" pair seeded for every account)
- Right — rule editor with one row per rule (effect /
pattern), drag to reorder, an explanatory pane showing how the
current ruleset evaluates against a sample
(entity, op)pair
Assigning a profile
Profiles attach to memberships via
POST /memberships/:id/permission-profile. Surfaced in the Team
admin (/admin/team) — pick a profile from the dropdown next to
each member's row.
API keys can pin their own profile at create time on
POST /api-keys — that profile takes precedence over the key
owner's membership profile.
What you can do today
- Author profiles with the rule grammar above
- Assign profiles to memberships and API keys
- Use the seeded Read Only profile to validate the path
- 10/10 vitest cases passing on the matcher
What's coming
- Visual rule builder — today rules are typed strings; a guided builder is queued
- Per-entity-id rules — today the rules are entity-type scoped only
- Audit log — record every denied request with the matched rule for debugging