Roles & permissions
Concepts: Roles & permissions · Permission scopes.
Base path: /api/org/{slug}/roles (authenticated; org membership resolved by middleware).
| Method · Route | Purpose | Source |
|---|---|---|
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 |
Permission templates
Section titled “Permission templates”| Method · Route | Purpose | Source |
|---|---|---|
POST /{roleId}/permissions | Add 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.
Role assignment
Section titled “Role assignment”| Method · Route | Purpose | Source |
|---|---|---|
POST /{roleId}/members | Assign 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.
Scoped guests ✅ Built (2026-08-18)
Section titled “Scoped guests ✅ Built (2026-08-18)”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 key | Compiles to | Required variable |
|---|---|---|
PROJECT_GUEST | org.project.read:project=<projectId>, org.project.list:project=<projectId> | project |
MEETING_GUEST | org.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”.
Invite a guest scoped to one project
Section titled “Invite a guest scoped to one project”| Method · Route | Purpose | Source |
|---|---|---|
POST /api/org/{slug}/projects/{id}/invite | Invite 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 · Route | Purpose | Source |
|---|---|---|
GET /sod-report | Members currently holding a conflicting pair. Returns SeparationOfDutiesValidationReadModel (isValid, conflicts[]) | RoleEndpoints.cs:41 |
Forbidden pairs (SodConflictCatalog.Pairs):
| A | B |
|---|---|
org.permission.create | org.audit_event_log.delete |
org.role.assign | org.audit_event_log.delete |
org.reporting_instance.create | org.reporting_instance.approval_step.update |
org.member.invite | org.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.