Roles & policies in code

On this page

Access control in dembrane is policy-based, not role-based. A role is a display label; the enforcement source of truth is a policy set. Enforcement code always asks “does this caller hold this policy?” - never “is this caller an admin?”. This page is the engineer’s view of echo/server/dembrane/policies.py and the modules around it. For the conceptual model (what each role can do, told plainly) see roles & permissions.

Important

The pattern is AWS-IAM-inspired: presets are hardcoded in policies.py; the database stores only custom_policies (extras beyond the preset). Effective policies = preset[role] + custom_policies. Enforcement code calls has_policy(...). If you find code branching on a raw role string for an access decision, that’s a bug - route it through a policy.

The presets

policies.py defines three preset dictionaries:

Each preset is an explicit allowlist. Anything not listed is implicitly denied. So external deliberately lacks workspace:view_usage, member:invite, report:publish, project:create, conversation:delete; observer deliberately lacks chat:use, report:generate, project:update - hitting that wall is the observer→external upgrade trigger.

The functions you’ll call

get_effective_policies(role, custom_policies=None, presets=WORKSPACE_ROLE_PRESETS) -> list[str]
has_policy(role, custom_policies, required, presets=..., workspace_tier=None) -> bool
meets_tier(current_tier, minimum_tier) -> bool

Tier gates ride along with the policy check

TIER_REQUIRED_FOR_POLICY maps policies to the minimum tier required:

Policy Minimum tier
workspace:export, project:share, workspace:set_private, project:set_private innovator
workspace:whitelabel, workspace:api_access, workspace:webhooks changemaker

Because has_policy enforces this when you pass workspace_tier, an endpoint usually only needs one call - the tier gate is not a separate check. Pass the workspace tier and the gate is automatic; omit it (e.g. in tests) to bypass.

Role hierarchy - escalation guard

ROLE_HIERARCHY orders the workspace roles for escalation prevention, not capability:

observer(0) < external(1) < member(2) < billing(3) < admin(4) < owner(5)

The invite endpoint (and any future role-change endpoint) uses this so a caller can only grant a role at or below their own level. It is not a capability ranking - billing sits above member here despite having no content access, because the number is about “what you’re allowed to hand out”, not “what you can do”. See ADR 0003.

Staff policies

STAFF_POLICIES is a finer grain than “any Directus administrator”: staff:can_set_tier, staff:can_set_visibility, staff:can_transfer. Today the staff gate is the JWT admin_access claim (see architecture); the named staff policies are wiring-in-progress reference for when a storage mechanism lands. Treat them as the future seams for splitting up staff power.

The admin panel API

The staff panel (/admin, AdminSettingsRoute) is server-gated: every route below re-checks admin_access, so it can’t be reached by guessing a URL. The staff guide covers what each action does in plain terms; this is the route map behind it.

Action Endpoint Extra staff policy
Usage & billing rollup GET /api/v2/admin/billing-rollup (?month_offset= for the 12-month lookback) -
At-risk inbox GET /api/v2/admin/at-risk -
Payments view GET /api/v2/admin/payments (actions in admin_managed.py) -
Change tier PATCH /api/v2/workspaces/{id}/tier staff:can_set_tier
Discount PATCH /api/v2/admin/workspaces/{id}/discount -
Grant reverse trial POST /api/v2/admin/billing-accounts/{id}/grant-trial -
Change admin POST /api/v2/admin/workspaces/{id}/change-admin -
Reset usage POST /api/v2/admin/workspaces/{id}/reset-usage (requires a reason) -
Partner toggle PATCH /api/v2/admin/orgs/{id}/partner -
Referral ledger GET /api/v2/admin/referral-ledger -
External-led orgs GET /api/v2/admin/external-led-orgs -
Set workspace visibility (workspace visibility change) staff:can_set_visibility
Transfer workspace (owner handoff) staff:can_transfer

Seats - seat_capacity.py

Seats are computed from membership rows, never stored as a count. The billable roles are:

_SEAT_ROLES = {"owner", "admin", "member", "billing", "external"}

observer is deliberately absent - it’s free. Key functions:

Membership inheritance - inheritance.py

A user’s effective workspace role is derived, not just read. inheritance.py folds together:

derive_workspace_role(...) produces the effective role; user_can_access(workspace_id, user_id) returns (role, source). When debugging “why can this person see this?”, trace inheritance.derive_workspace_rolepolicies.get_effective_policieshas_policy. Don’t stop at the membership table - the answer often lives in inheritance.

Write-time invariants

Some rules are enforced when data is written, not at read time:

How to add a capability (a new policy)

  1. Name it with the domain:verb convention (project:read, workspace:export, …).
  2. Add it to the relevant preset(s) in policies.py (WORKSPACE_ROLE_PRESETS and/or ORG_ROLE_PRESETS/PROJECT_ROLE_PRESETS) for every role that should have it. Remember: presets are allowlists - only the roles you list get it.
  3. Gate it by tier if needed by adding an entry to TIER_REQUIRED_FOR_POLICY. Then any has_policy(..., workspace_tier=...) call enforces it for free.
  4. Enforce it at the endpoint by calling has_policy(role, custom_policies, "your:policy", workspace_tier=...). Never branch on the role string.
  5. Reflect it in the matrix in roles & permissions so the docs and the capability table stay true.
  6. Frontend display is separate - echo/frontend/src/lib/roles.ts (displayRole, roleColor, ROLE_HIERARCHY) handles how roles render (observer & external render grey). Adding a policy doesn’t touch this; adding a role does.

How to add a role arm

Adding a whole role is heavier - it touches presets, the hierarchy, seats and inheritance:

  1. Add the role to the relevant *_ROLE_PRESETS with its allowlist.
  2. Add it to ROLE_HIERARCHY at the right rung (this controls who can grant it).
  3. Decide whether it consumes a seat - add to or omit from _SEAT_ROLES in seat_capacity.py.
  4. Teach inheritance.py how the role interacts with org membership and visibility (e.g. the external/observer “no org_membership” invariant).
  5. Wire the invite branches (api/v2/invites.py, _invite_helpers.py) and the frontend role display.
  6. Write the write-time invariants and document the upgrade/downgrade path.

Warning

Roles aren't free to add. The five-role collapse (matrix v1.1) plus observer was a deliberate simplification. Prefer a new policy on an existing role over a new role. Read ADR 0003, 0004 and 0005 before proposing one.

The ADRs that govern this

They live in echo/docs/adr/.


Related

Related

Comments