Skip to content

Roles and Permissions

TruePPM uses a 5-role per-project permission model stored in ProjectMembership and enforced on every API endpoint and WebSocket connection.

RoleOrdinalProject labelProgram labelDescription
Owner400Project AdminProgram AdminFull control. Manages members, can assign any role below Owner, deletes project.
Admin300Project ManagerProgram ManagerFull task and dependency edit, project settings, baseline creation.
Scheduler200Resource ManagerResource ManagerAssigns resources and edits dependencies. Cannot edit task content.
Member100Team MemberTeam MemberEdits own assigned tasks. Logs time.
Viewer1ViewerViewerRead-only. Can pull delta sync to mobile.

One ordinal, named for its container. The two label columns are not two role models. ProjectMembership and ProgramMembership share the same five roles and the same ordinals, so a Program Manager and a Project Manager hold the same rank — 300 — and differ only in what they hold it over. Only the top two tiers are renamed; 200, 100, and 1 read identically in both scopes. Program surfaces (GET /programs/{id}/members/ and the my_role_label field on GET /programs/{id}/) return the program wording, project surfaces return the project wording, and the numeric role field is the same value either way — integrate against the ordinal, never against the label.

The gaps are reserved slots, not arbitrary numbering. Ordinals are compared, never enumerated: a permission check asks “is this role at least a Member?”, so what matters is the ordering, and the space between two tiers is deliberate room to insert a role later without renumbering the ones that already exist.

Concretely, suppose you want an Auditor — someone who can read everything a Viewer can plus export history and baselines, but who must never edit a task. There is no OSS tier that fits: a Viewer is too narrow, a Member can write. An Auditor belongs between them, so it takes an ordinal in the 2–99 band. Every existing role >= Member write gate then excludes the Auditor automatically, by arithmetic, without a single one of those gates being modified or even knowing the role exists.

BandReserved for
0Permanently unused — see the note below
2–99Read-augmented roles above Viewer but below Member (the Auditor example)
101–199Contributor extensions above Member
201–299Resource-management extensions above Scheduler
301–399Project-lead extensions above Admin
401+Nothing. There is no role above Owner — that ceiling is part of the contract

The comparison contract. Two check styles appear in the code and they mean different things, which is what makes the bands safe:

  • role >= Owner/Admin/Scheduler/Member/Viewer — “at least this band”. A custom role inside the band does inherit the capability. This is how nearly every write gate is written, and it is why adding a role never requires touching them.
  • role == Owner (and the other exact-tier checks) — “specifically this OSS tier”. A custom role does not silently absorb these. Owner-only behavior such as the last-Owner guard stays Owner-only.

A note on the roles you actually get. TruePPM’s Community edition ships exactly the five roles in the table above — the bands describe an extension seam, not hidden functionality waiting to be unlocked. Custom roles are an Enterprise capability, and when Enterprise registers one it does so into these bands through the slot-registration pattern (ADR-0029), leaving the Community ordinals untouched.

Why Viewer is 1 and 0 is unused. The ordinal is part of the public API — it appears in membership payloads, invite responses, and MCP reads — and in JavaScript 0 is falsy, so a consumer writing role || DEFAULT would read a Viewer as absent and quietly grant them the default instead. Starting the ladder at 1 makes every role truthy and removes that failure mode. “No membership” is expressed as null, a distinct type, never as an ordinal. If you integrate against the numeric field, note that a Viewer will change from 0 to 1 in 0.4; see the changelog.

ActionOwnerAdminSchedulerMemberViewer
View project data✓✓✓✓✓
Pull delta sync✓✓✓✓✓
Connect WebSocket✓✓✓✓—
Edit own assigned tasks✓✓—✓—
Create/edit any task✓✓———
Create/edit dependencies✓✓✓——
Assign resources✓✓✓——
Edit project settings✓✓—¹——
Manage members✓————
Delete project✓————
Self-remove✓✓✓✓✓

¹ Scheduler may edit the methodology and estimation-mode settings only; all other project settings require Admin (field-level gate, ADR-0041).

² Read this row and Create/edit any task together — they cross, and that is deliberate. A Scheduler may draw dependencies and may not edit task content; a Member is the exact inverse. Neither capability implies the other, so on the Schedule a Scheduler gets drag-to-link and no row editing, while a Member gets row editing and no drag-to-link. If you want one person to do both, give them Project Manager (Admin).

The 5 roles are capability levels, not job titles. The same role may serve different personas depending on the team’s delivery method (waterfall, agile, or hybrid).

PersonaRecommended roleRationale
Executive Sponsor / COOViewerReads status and reports; no editing needed.
PMO DirectorViewerPortfolio-level visibility; project edits belong to the PM.
Project ManagerProject Manager (Admin)Full task/dependency edit, baseline management.
Product OwnerProject Manager (Admin)Backlog and sprint content authority requires the same write access as a PM.
Scrum Master / Agile Delivery LeadProject Manager (Admin)Opens/closes sprints, manages velocity, runs ceremonies — same capability tier as a PM.
Resource ManagerResource Manager (Scheduler)Assigns resources without touching task content or the schedule directly.
Team Member / ContributorTeam Member (Member)Edits their own assigned tasks and logs time.
Agile CoachViewerObserves team health signals; editing authority belongs to the team, not the coach.

Why Product Owner and Scrum Master map to Project Manager (Admin)

Section titled “Why Product Owner and Scrum Master map to Project Manager (Admin)”

Product Owners and Scrum Masters hold the same Project Manager (Admin) role as a traditional PM. This is intentional: the capabilities a PO or Scrum Master needs — author and groom the backlog, open and close sprints, manage velocity, run ceremonies — all require the same project-wide write access the Admin tier grants. There is no narrower tier that fits, because the access ordinal (Viewer 1 → Owner 400, in apps/access/models.py) measures how much a member can write, not which agile facet they hold.

Crucially, the guardrails a PO or Scrum Master cares about — sprint sovereignty and scope-change protection — are not enforced by the RBAC ordinal. They are enforced at the application layer: the sprint model rejects mid-sprint mutations without team notification (explicit, audited scope-injection approval), and velocity is never auto-exposed as a management gauge. A PM cannot silently add tasks to an active sprint regardless of their role, because the workflow — not the permission level — is the gate.

So the role tier and the agile facet are deliberately orthogonal. The access ordinal answers “can this person write to the project?”; the team facet answers “is this person the Product Owner or the Scrum Master for this team?”. The facet axis lives on TeamMembership (is_product_owner / is_scrum_master) as two independent booleans alongside the ordinal role — a member who is also PO, or an admin who is also Scrum Master — rather than as extra rungs on the role ladder (#927). Facet-gated behavior (for example, a Product Owner may edit but not delete EPIC/STORY items) resolves through that facet, not through a higher ordinal.

This means you do not need separate “Product Owner” or “Scrum Master” role slots. A project with a Scrum Master assigned Admin and a PM also assigned Admin has both respect the sprint boundary because the system enforces it uniformly — and the agile-specific authority each holds is carried by their team facet, independent of the role each was given.

Members are managed at /api/v1/projects/{project_id}/members/.

POST /api/v1/projects/{project_id}/members/
Authorization: Bearer <token>
{"user": "<user-id>", "role": 100}

Role escalation rule: you can only assign a role strictly below your own. An Owner (400) can assign up to Admin (300).

0.4 bounds user to accounts you can already reach, so adding somebody can never reveal an account you could not already see:

You areYou can add
A workspace Admin or OwnerAny active account
Anyone elseYourself, and anyone already on a project or program roster you belong to

Deactivated accounts are never addable, at any tier. A target outside your reach is refused with HTTP 400 and a user error — the same response a nonexistent id gets, so the endpoint cannot be used to test whether an account exists.

To bring in somebody nobody on your rosters has worked with, use a workspace invite instead. It is keyed on an email address you already hold, so it discloses nothing; once they accept, they are a workspace member and reachable the normal way.

user is not accepted on PATCH. A membership row’s account is fixed at creation — remove the member and add the other account instead.

PATCH /api/v1/projects/{project_id}/members/{membership_id}/
{"role": 200}
DELETE /api/v1/projects/{project_id}/members/{membership_id}/

Any member may remove themselves. An Owner may remove members with a role below their own.

A project must always have at least one Owner. Removing or demoting the last Owner returns HTTP 400. The check uses SELECT FOR UPDATE to prevent a concurrent-removal race condition.

When a user creates a project, they are automatically assigned the Owner role (labeled Project Admin) via ProjectViewSet.perform_create().

The 5-role ladder above is project- and program-scoped — it answers “what can this user do on this project?” A small set of install-wide surfaces sit on a separate, orthogonal axis: IsWorkspaceOperator, which checks user.is_superuser directly and does not consult ProjectMembership or ProgramMembership at all. A stored WorkspaceRole.ADMIN (or project/program Owner) grant does not pass this gate — only a Django superuser does, the kind created by create_admin, createsuperuser, or the Django admin.

This axis exists because a handful of endpoints expose install-wide operational state (tracebacks, retry/drop of any project’s background jobs, retention windows, telemetry export config) with no natural project scope and no in-app delegation path — there is no role a workspace Owner can grant for them. An admin reviewing “who can do what” against the 5-role matrix above will not see these surfaces unless they know to look for this second axis, so they are listed here explicitly:

  • Mail transport and notification transport configuration
  • Dead-letter queue — FailedTaskViewSet (list/retrieve/requeue/drop, across all projects)
  • Observability retention and telemetry export — System Health, the Prometheus metrics endpoints (/api/v1/health/{beat,dead-letter,email}/), retention policy read/write, retention impact and run history, and the telemetry test-export probe

See ADR-0034’s 2026-09-28 amendment for why these moved off Django’s is_staff/IsAdminUser (a similar-looking but distinct and previously-undocumented axis) onto IsWorkspaceOperator.

All querysets are scoped to projects the requesting user is a member of via ProjectScopedViewSet. Non-members receive an empty queryset rather than a 403, preventing information leakage about object existence.

WebSocket connections authenticate with a short-lived, single-use ticket (?ticket=<ticket>), minted via POST /api/v1/ws/ticket/ — so no JWT ever reaches a URL or an access log. The legacy ?token=<jwt> handshake is disabled by default and opt-in only via TRUEPPM_WS_LEGACY_TOKEN_AUTH_ENABLED (deprecated). Viewer (role=1) connections are rejected with close code 4003 — real-time push requires Team Member or above. See WebSocket connections.

Every task in the API response carries two read-only booleans for the requesting user: can_edit and can_delete. They are computed server-side from the same predicate the write-permission check enforces (can_user_edit_task), so a client never re-implements the rule and the declared capability can never drift from the enforced one. The values reflect the full per-task rule, including the assignee-own case (a Member may edit a task only when they are its assignee) and the Product Owner facet (a PO may edit — but not delete — EPIC/STORY items). can_delete differs from can_edit only for a Product Owner, who grooms stories but does not delete them.

These flags are advisory for clients (the server still authorizes every write — hiding a control is defense-in-depth, never the only gate). The web app gates the entire task detail drawer off can_edit: a user who cannot edit a task sees a fully read-only drawer with a “View only” indicator in the header, rather than controls that silently fail on submit. A future admin role-capability matrix view (GET /projects/{id}/role-capabilities/) will expose the full role × capability grid for compliance review.

can_author — may this user author the plan at all?

Section titled “can_author — may this user author the plan at all?”

GET /projects/{id}/ carries a third read-only boolean, can_author. Where can_edit and can_delete answer “may this user change this row”, can_author answers the project-level question that comes before it: is there an authoring mode to be in. It is computed from the same predicate the task-authoring endpoints enforce, so — like the per-task flags — the answer a client reads is the answer the server will give.

The rule is not “Team Member or above”. It is Team Member or above minus the resource-management band (200–299):

role >= Member and not (Scheduler <= role < Admin)

That exclusion is the whole point of the field, and it is why a client must read it rather than compare ordinals. A Resource Manager sits above Team Member on the ladder and is nonetheless refused task content — so the obvious role >= Member test gets exactly one role wrong, and gets it wrong in the direction that shows someone a control the server will refuse. The band form (rather than “is this role exactly Scheduler”) means an Enterprise custom role registered at 201–299 inherits the same exclusion instead of silently gaining authoring rights the tier beside it does not have.