Roles and policies in code

On this page

A role is a name on a membership row. What it allows is a set of policies, and code that guards an action asks for one policy by name. All of it lives in dembrane/platform/packages/access/src.

For what each role can do in plain words, read roles and permissions.

The files

File What it holds
policies.ts The workspace roles, every policy, the role presets (ROLE_POLICIES) and the tier gates (TIER_REQUIRED).
org.ts The organisation roles and the customer account policies.
resolve.ts Which role a person holds on a workspace or a project.
authorize.ts The Access class: the one way a service obtains access.
staff.ts The named staff permissions and their audit trail.
store.ts, drizzle.ts, memory.ts The rows a decision reads: an interface, the Drizzle implementation, and an in-memory twin for tests.

Workspace roles and their policies

The workspace roles are observer, external, member, billing, admin and owner. A stored viewer is a legacy name and reads as member. The dashboard shows owner as “Admin”.

The presets are built from four groups:

Group Policies
Read project:read, conversation:read, report:view
Contribute project:update, chat:use, report:generate
Manage Deleting, sharing and moving projects, making a project private, deleting reports, member:invite, member:manage, settings:manage, and the workspace features such as workspace:set_private, workspace:whitelabel and workspace:webhooks
Billing workspace:view_usage, workspace:view_invoices, workspace:update_payment, upgrade:request
Role Holds
observer Read
external Read, Contribute
member Read, Contribute, plus project:create, conversation:delete, report:publish, workspace:view_usage
billing Billing only. No project data at all, not even a private project shared with them.
admin Read, Contribute, Manage, Billing, plus project:create, conversation:delete, report:publish
owner Every policy

ROLE_POLICIES in policies.ts is the exact list. Read it before you rely on a detail.

A membership can also carry extra policies in its custom_policies column. Only known policy names count, so a stray value can never widen access. There is no interface for setting them.

The rank is not a permission list

ROLE_RANK orders the roles: observer 0, external 1, member 2, billing 3, admin 4, owner 5. It is used only to stop someone giving a role above their own. billing ranks above member and can do far less.

Tier gates

Some policies also need the workspace’s billing account to be on a tier. The tiers, in order, are free, innovator, changemaker and guardian.

Policy Needs at least
project:share, project:set_private, workspace:set_private Innovator
workspace:whitelabel, workspace:webhooks Changemaker

The table in code is TIER_REQUIRED. The tier is the tier of the billing account that pays for the workspace. It is not a column on the workspace.

How a check runs

For a workspace or a project, a service does not read a membership row itself. It calls the Access class, usually through projectFor or workspaceFor from @dembrane/http, and names the policy it needs. The call has this shape:

const access = await projectFor(deps.access, who, projectId, "report:generate");

Access then does three things:

  1. It resolves the caller’s role on the project or workspace. When there is none, it throws a not-found error, so it does not confirm that the resource exists.
  2. It checks the policy against the role’s preset and the membership’s extra policies. A missing policy is a 403 with the code access.forbidden.
  3. It checks the tier gate, if the policy has one. A tier that is too low is a 403 with the code billing.tier_required, and the error carries the required tier.

The helpers in @dembrane/http wrap this and set the refusal each surface of the API sends. When a workspace is below the tier a policy needs, projectFor reports the tier gate first, whatever the role, because that is the upgrade the dashboard offers.

How a role is resolved

resolveWorkspace and resolveProject in resolve.ts read only the store, so you can test them with the in-memory one.

For a workspace:

  1. A direct, unexpired membership always wins, even when the organisation role would give more.
  2. Otherwise the role is derived from the organisation role. An organisation owner is admin on every workspace of the organisation. An organisation admin is admin on workspaces that are open to the organisation. An organisation member gets a role only on workspaces that still carry a legacy inherit flag. An organisation billing role gives nothing.
  3. A person who was removed from a workspace is not let back in through their organisation role. They return by a new invite.

For a project:

  1. The caller needs a workspace role that holds project:read. That is why workspace billing gets no project data.
  2. A project visible to the workspace is open to that role.
  3. A private project is open to workspace admins and owners, and to others only with a share in project_membership. A share never crosses workspaces.

A user who has not finished onboarding has no app_user row and reaches no workspace.

Organisation roles

The organisation roles are member, billing, admin and owner. There is no organisation-level external or observer.

Organisation actions are checked by role name, with requireOrgRole in packages/tenancy/src/service/orgs.ts. The only organisation policies are the five for the customer account: account:read, account:sign, account:billing, account:tasks and account:support. The billing, admin and owner roles hold all five. A plain member holds none.

Guards worth knowing when you touch membership code:

Staff

Staff permissions are named, and every use is written to staff_audit_event before the action runs. requireStaff in staff.ts does both.

Staff support access describes this from the customer’s side.

Add a policy

  1. Add the name to POLICIES in policies.ts. owner holds it at once, because the owner preset is every policy.
  2. Add it to the group or the role presets that should hold it.
  3. Add it to TIER_REQUIRED if a tier gates it.
  4. Ask for it in the service, through projectFor or workspaceFor.
  5. Extend the tests in packages/access/test.
  6. Update roles and permissions in the same pull request.

Related pages

Comments