Skip to content
AXOWLDevelopers
Quickstart

Roles & permissions

Concepts: Roles & permissions · Permission scopes.

Base path: /api/org/{slug}/roles. Every route needs a signed-in member of the organization; write routes also need the scope shown. A missing scope returns 403 with errorCode = "PERMISSION_DENIED".

Method · RouteScopePurpose
GET /—List roles. ?includeInactive=true includes deactivated roles.
GET /{roleId}—Role detail. 404 if the role is not in this organization.
POST /org.role.createCreate a role. Body: name, roleKey, description?, requiresApproval? (default false). Returns { roleId }.
PUT /{roleId}org.role.updateRename or re-describe a role. Body: name, description?.
DELETE /{roleId}org.role.deleteDelete a role.

POST / returns 403 with error = "UPGRADE_REQUIRED", feature = "roles" once the plan’s custom-role limit is reached, and 409 with error = "ROLE_DUPLICATE_KEY" when the roleKey is already used. A role that does not exist in this organization answers 404 with error = "ROLE_NOT_FOUND". The Owner role (ORG_OWNER) cannot be deleted: 403 with error = "ROLE_SYSTEM_PROTECTED". The fixed Roost roles cannot be renamed, changed or deleted: PUT, DELETE and the permission routes below return 403 with error = "ROLE_FIXED" for them.

Method · RouteScopePurpose
POST /{roleId}/permissionsorg.role.updateAdd a scope expression to a role. Body: templateExpression, riskLevel? (default 0), webhookUrl?. Returns { templateId }.
DELETE /{roleId}/permissions/{templateId}org.role.updateRemove a permission template.

The scope expression follows namespace.resource.action[:constraint,…]. A constraint is a key, one of the operators =, !=, <, <=, >, >=, and a value; the value may be a set (region={KR,JP}) or an assignment variable (project={project}), and <, <=, >, >= take a number. See Permission scopes. POST returns 400 when:

  • the expression is empty, has fewer than two dot-separated segments, or a segment contains anything other than lowercase letters, digits, -, _ and *;
  • * appears anywhere but the last segment, or ** is used;
  • a constraint after : has no operator, an empty key or value, a malformed set, or a non-numeric value after a numeric operator;
  • a constraint uses uses, ttl or exp (errorCode = "LIFECYCLE_NOT_SUPPORTED"). A role’s permissions do not expire or count uses; set expiresAt on the assignment, or grant the scope through an approval form;
  • the namespace is org, view, platform or user and the scope is not one Axowl defines (errorCode = "SCOPE_NOT_IN_LIBRARY"). Use your own namespace for your application’s permissions.

It returns 404 with error = "ROLE_NOT_FOUND" when the role is not in this organization, 403 with error = "UPGRADE_REQUIRED", feature = "permissions_per_role" at the plan’s per-role permission limit, and 403 with error = "PERMISSION_DENIED" when:

  • the expression covers an Owner-only scope (view.billing, org.org.billing_manage, Roost access) that the caller does not hold;
  • the caller is not an Owner or Admin and does not hold the expression themselves, constraints included. A wildcard counts as held only when every scope it covers is held.
Method · RouteScopePurpose
POST /{roleId}/membersorg.role.assignAssign the role to a member. Body: connectedIdId, variables? (string map), expiresAt?. Returns { connectedIdRoleId }.
DELETE /{roleId}/members/{connectedIdRoleId}org.role.assignRevoke the role from a member.

Assignment compiles the role’s templates into the member’s stored permissions and recomputes the member’s membership type.

  • The Owner role (ORG_OWNER) cannot be assigned or revoked here (403); transfer ownership instead.
  • A role that grants an Owner-only scope can be assigned only by someone who holds that scope, and the Roost Admin role only by the Owner (403, error = "OWNER_REQUIRED").
  • A separation-of-duties conflict or any other refused assignment returns 400 with the reason in error.

An external guest is often invited to one project or one meeting, not to the organization. Rather than minting a role per project, there is one preset role per kind and the target is a variable supplied at assignment time:

Role keyCompiles toRequired variable
PROJECT_GUESTorg.project.read:project=<projectId>, org.project.list:project=<projectId>project
MEETING_GUESTorg.meeting.read:meeting=<meetingId>, org.meeting.list:meeting=<meetingId>meeting

The variable travels as variables on role assignment (POST /{roleId}/members), or as the invitation’s scope metadata block when the guest is invited by email. It is written into the role’s template holes ({project} / {meeting}), so the stored permission already carries the constraint and every read is compared against it.

Assigning either role without its variable is refused: 400 with error = "VARIABLE_REQUIRED" and variable naming the missing key. Use ORG_GUEST when you want an unscoped read-only guest.

Project and meeting reads are narrowed only for callers who actually hold a constrained grant. Everyone else (including ordinary members, who hold no org.project.* scope at all) keeps the membership-based listing they had before. A guest requesting a project outside their grant gets 403; a meeting outside their grant returns 404, since the meeting reader cannot distinguish “not yours” from “not there”.

Method · RoutePurpose
POST /api/org/{slug}/projects/{id}/inviteInvite email (optional name) with role = "Guest" (→ PROJECT_GUEST, scoped to {id}) or "Freelancer" (→ ORG_FREELANCER, org-wide). Returns { invited: true }.

Requires org.project.update on the calling member. Returns 400 with error = "ROLE_NOT_FOUND" if the organization has no active role with that key — the invite is refused rather than silently widened to an org-level guest.

Assignment is refused when the resulting scope set would contain both halves of a forbidden pair. The check runs on the union of what the member already holds and what this grant compiles to, so splitting the two halves across two roles does not get around it.

  • Scopes are compared without their constraints: org.permission.create:app_group=… counts as org.permission.create.
  • A permission that has expired does not count as held.
  • A single role that carries both halves by itself is a deliberate role design: it is assigned, and the pair is recorded as auth.sod.conflict_detected. What is refused is a pair completed by combining a new grant with what the member already holds.
Method · RoutePurpose
GET /sod-reportMembers currently holding a conflicting pair. Needs org.role.read. Returns isValid, validatedAt and conflicts[], each with connectedId, conflictingPermissions[] and description.

Forbidden pairs:

AB
org.permission.createorg.audit_event_log.delete
org.role.assignorg.audit_event_log.delete

Enforced at both grant paths: role assignment and the grant made when an approval completes. A refusal emits auth.sod.assignment_blocked (AUT079); a single grant that carries both halves emits auth.sod.conflict_detected (AUT080). Both are sealed like any other event. GET /sod-report only reads and emits nothing.

Owner and Admin are exempt. Their wildcard expands to every concrete scope and would trip every pair, which would leave an organization unable to repair itself. Full-authority principals are governed by the sealed audit trail rather than by refusal.

A member who already holds both halves keeps them; they are refused only on a later grant that carries one half of the pair again. GET /sod-report is how you find them first.