Permissions matrix
Role by action, including the project-level roles.
docs/PERMISSIONS.md
Who may do what. Two systems that answer different questions — see Hierarchies.
Organization roles
internal below means owner, admin or member.
| Action | owner | admin | member | agency | client |
|---|---|---|---|---|---|
| See every project in the org | ✅ | ✅ | ✅ | ❌ | ❌ |
| See a project they are a member of | ✅ | ✅ | ✅ | ✅ | ✅ |
| Create a project | ✅ | ✅ | ✅ | ❌ | ❌ |
| Edit a project / set budgets | ✅ | ✅ | ✅ | project role | project role |
| Delete a project | ✅ | ✅ | ✅ | ❌ | ❌ |
| Manage project membership | ✅ | ✅ | ❌ | ❌ | ❌ |
| Change the plan / open the billing portal | ✅ | ✅ | ❌ | ❌ | ❌ |
| Start an agent run | ✅ | ✅ | ✅ | ❌ | ❌ |
| Approve an agent run | ✅ | ✅ | ✅ | ❌ | ❌ |
| Compose a post / propose a date for it | ✅ | ✅ | ✅ | project role | project role |
| Edit or withdraw their own proposal | ✅ | ✅ | ✅ | ✅ | ✅ |
| Edit somebody else's post | ✅ | ✅ | ✅ | ❌ | ❌ |
| Schedule a post (commit a time to the publisher) | ✅ | ✅ | ✅ | ❌ | ❌ |
| Publish now | ✅ | ✅ | ✅ | ❌ | ❌ |
| Log out of every session | own account | own account | own account | own account | own account |
Two of these are worth saying out loud:
A member cannot manage project membership. Inviting an outside contractor into a project is a tenancy decision, not a workflow one.
An agency cannot approve. An agency contributor can see a run on their project, read its proposal and its cost — and cannot sign it off. An agency does not approve its own agent's spend.
An external contributor proposes; it takes an internal role to schedule. A client or agency
user on a project can compose a post and put a suggested date on the calendar. What they cannot do
is commit that date: their post always lands draft, their date always lands on the post's
proposed_publish_at, and the publisher reads neither — it selects on a variant whose status is
scheduled, which is the one field an external role has no route to write. The suggestion becomes
a real schedule when an approval lands and scheduleApprovedPost promotes it.
That is what makes this safe to offer at all. Non-negotiable #3 says nothing reaches a third-party platform without a recorded approval; here that holds structurally rather than by convention, because the gate is the only path between what a client can write and what the publisher can read. Two consequences worth stating:
- The org role is only the first lock.
assertCanWriteProjectstill runs, so a client invited to a project as aviewercan read the calendar and propose nothing. Making them acontributoris the deliberate act that lets them contribute. - An external contributor cannot decide on their own submission, even when a
client_reviewstage names them as an approver. Propose-and-approve in two clicks would make the gate a formality for exactly the person it exists to check. This binds external roles only — an internal author who is also a named approver is an ordinary small-team arrangement and still works.
Project roles
Applied after the organization role has decided the person can see the project at all.
| Action | lead | contributor | viewer |
|---|---|---|---|
| Read the project, its budgets and its team | ✅ | ✅ | ✅ |
| Edit the project | ✅ | ✅ | ❌ |
| Set or clear a budget | ✅ | ✅ | ❌ |
| Approve anything | ❌ | ❌ | ❌ |
Approval is never a project role. It is an organization-level decision, because the person answering for the spend is not the person doing the work.
How refusals are shaped
| Situation | Response | Why |
|---|---|---|
| Not in your organization | 404 | 403 would confirm the id exists, letting a caller probe for real resources |
| In your org, but you are not a project member | 404 | Same reason. Read and write give the identical answer, so the write path cannot be used as an oracle either |
| A member of the project, but your project role is read-only | 403 | You already know it exists — hiding it now would just be confusing |
| Your organization role cannot do this at all | 403 | |
| Your plan does not include the feature | 402 | With the plan named and what to upgrade to |
The 404-vs-403 split is not stylistic. Getting it wrong turns every authorization check into a resource-enumeration endpoint.
E12 policy-based RBAC (in progress)
The static tables above are the current authoritative source of truth. Alongside them, E12
introduces a policy engine (apps/api/src/modules/authz/) that lets an organization define
custom roles and permission sets without changing route code:
Role— per-org role definition. Five built-in roles (owner/admin/member/agency/client) are seeded on org creation and cannot have their permissions edited (v1); their display name can be changed. Custom roles have no such restriction.Permission— a static catalogue of dotted keys (posts:read,billing:manage, …). Seeapps/api/src/modules/authz/permissions.tsfor the full list.UserRoleAssignment— one user may hold multiple roles; the effective permission set is the union of every role's keys, plus the wildcard*for the built-in owner.
The POST /billing/checkout endpoint is the first route migrated from a hard-coded role check
to requirePermission("billing:manage") as a demonstration. Every other role check is left in
place with a TODO(rbac) marker until a follow-up sweep migrates them one endpoint at a time.
The legacy user.role column is still populated on user creation for backward compatibility and
is NOT dropped in this release.