Skip to content

Roles & permissions

Concepts: Roles & permissions · Permission scopes.

Base path: /api/org/{slug}/roles (authenticated; org membership resolved by middleware).

Method · RoutePurposeSource
GET /List roles (?includeInactive)RoleEndpoints.cs:24
GET /{roleId}Get role detail:38
POST /Create role (Name, RoleKey, Description, RequiresApproval):52
PUT /{roleId}Update role:75
DELETE /{roleId}Delete role:98
Method · RoutePurposeSource
POST /{roleId}/permissionsAdd a scope expression to a role (base scope resolved server-side from the catalog):121
DELETE /{roleId}/permissions/{templateId}Remove a permission template:232

The scope expression follows namespace.resource.action[:key{op}value,…]. The axowl namespace is restricted to the resource whitelist (:161). See Permission scopes.

Method · RoutePurposeSource
POST /{roleId}/membersAssign role to a member (ConnectedIdId, optional Variables) → snapshots permissions:256
DELETE /{roleId}/members/{connectedIdRoleId}Revoke role from a member:279

Assignment runs SnapshotService.CreateSnapshotAsync and recomputes the member’s MembershipType via MembershipTierService.

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. SnapshotService injects it into the role’s template holes ({project} / {meeting}) so the stored permission already carries the constraint; ScopeGate then compares it against the row being read.

Assigning either role without its variable grants nothing — the unfilled hole becomes *, which matches no real id. 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 · RoutePurposeSource
POST /api/org/{slug}/projects/{id}/inviteInvite Email (optional Name) with Role = "Guest" (→ PROJECT_GUEST, scoped to {id}) or "Freelancer" (→ ORG_FREELANCER, org-wide)ProjectEndpoints.cs

Requires org.project.update on the calling member. Returns 400 ROLE_MISSING if the organization has no PROJECT_GUEST role row yet — the invite is refused rather than silently widened to an org-level guest.

Separation of duties ✅ Built (2026-07-28)

Section titled “Separation of duties ✅ Built (2026-07-28)”

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.

Method · RoutePurposeSource
GET /sod-reportMembers currently holding a conflicting pair. Returns SeparationOfDutiesValidationReadModel (isValid, conflicts[])RoleEndpoints.cs:41

Forbidden pairs (SodConflictCatalog.Pairs):

AB
org.permission.createorg.audit_event_log.delete
org.role.assignorg.audit_event_log.delete
org.reporting_instance.createorg.reporting_instance.approval_step.update
org.member.inviteorg.member.revoke

Enforced at both grant paths: role assignment (SnapshotService.CreateSnapshotAsync, after compile and before persist) and approval materialization (StampApprovalStepCommandHandler.MaterializeAsync). A refusal emits auth.sod.assignment_blocked (AUT079); a scan emits auth.sod.conflict_detected (AUT080) per finding. Both are sealed like any other event.

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.

Because the rules arrived after grants already existed, a member holding both halves keeps them and only fails on their next grant. GET /sod-report is how you find them first.