The seven roles
Defined in server/lib/roles.js (server source of truth) and client/src/config/roles.js (sidebar / switcher labels). Every authenticated user has exactly one persistent role, stored in their GCS profile at tenants/{tid}/users/{email}/profile.json.
| Role | Rank | canManage | Responsibility |
|---|---|---|---|
| admin | 100 | ✓ | System administrator. Pinned via ADMIN_EMAIL in .env — never assignable through the API. LMS/SSO config, residency, model governance. |
| coord (Coordinator) | 80 | ✓ | Department/tenant-level oversight; manages researchers, auditors, instructors, TAs, students. Fairness, quality, calibration finalization. |
| researcher | 70 | ✗ | IRB-scoped corpus & xAPI access. Lateral observer — sanctioned by coord, not subordinate to instructors. |
| auditor | 70 | ✗ | External review of rubric integrity & scoring fairness. Read-only lateral observer; the only role that can approve curated rubrics & residency. |
| instructor | 60 | ✓ | Course owner. Authors rubrics, calibrates AES, releases grades. Manages own TAs & students. |
| ta (TA / Grader) | 40 | ✗ | Grades batches under instructor calibration. Cannot release scores (instructor co-sign required). |
| student | 20 | ✗ | Submits work, reads feedback, revises. Sees only their own submissions. |
Hierarchy
Two ranking axes: rank (strict numeric ordering — a role can only act on roles strictly below it) and canManage (a separate gate on whether the role has any management authority at all).
Researcher & auditor sit above instructor in rank so an instructor cannot demote them — but canManage:false keeps them out of the management chain entirely. They observe; they don't manage.
Three identity layers — never collapse
Per docs/role-capabilities.md §1.1, every authenticated session carries three identity fields. Each governs a distinct set of decisions; do not collapse them.
| Field | Source | What it gates |
|---|---|---|
isAdmin |
email === ADMIN_EMAIL (.env) |
Visibility of the role-switcher in the TopBar and permission to call POST /api/auth/switch-role. Nothing else. |
activeRole |
Persistent (GCS) for non-admins; mutable "view-as" for admin | Every capability check, sidebar visibility, server endpoint gate, UI conditional. |
canManage |
Derived from activeRole via roles.js |
User-management policy (canManageUser) only. Not a feature-capability gate. |
When the admin switches activeRole to student, they behave exactly as a student — no admin override, no leakage. The role-switcher UI is the one legitimate isAdmin reader anywhere in the codebase.
Two distinct gating systems
The codebase enforces permissions through two parallel layers. Don't confuse them.
4a · User management (vertical)
Used only for "who can change whose role". Lives in server/lib/roles.js:
canManageUser({ actorRole, isAdmin }, target, newRole)
canViewUser ({ actorRole, isAdmin }, target)
subordinatesOf(role, { isAdmin })
Rules: newRole !== 'admin' AND actor outranks both target's current role AND newRole. Admin bypasses rank checks but still cannot assign admin.
Enforced at: /api/admin/users router gate, setUserRole() in lib/users.js, and actorCapabilities() (which narrows the role-dropdown to what the actor may actually assign).
4b · Feature capability (horizontal)
Used for "can this role take this action". Currently implemented as simple role allow-lists, not a unified matrix:
// server/middleware/auth.js
requireRole('instructor', 'coord', 'admin')
requireAdmin
A recurring constant AUTHOR_ROLES = ['instructor', 'coord', 'admin'] appears across three routers.
There is a spec for a fine-grained capability matrix — see § 8.
Where role checks actually live
Permissions are enforced in four distinct layers. A request typically passes through several of them before producing a response.
| Layer | Mechanism | Files |
|---|---|---|
| Route access | roles: [...] array per item; requiresManage gate; inspector-admin bypass |
client/src/config/sections.js + canAccessRole() (used by App.jsx) |
| Sidebar visibility | sidebarFor: [...] (optional) narrows which roles get a top-level rail entry; sidebarFor: [] = drill-down-only |
client/src/config/sections.js + visibleForRole() |
| Server route guards | requireRole(...), requireAdmin, router-level canManageRole |
See full inventory in § 6 |
| In-handler conditionals | isInstructorish = role === 'instructor' || 'coord' || 'admin' || 'ta' — keys off activeRole only. |
routes/dashboard.js:127, routes/submissions.js:84,110 |
| Resource ownership | r.ownerId === user.uid || user.isAdmin — still uses sticky isAdmin; review per-call-site whether view-as-role faithfulness requires changing to isActingAsAdmin(). |
lib/rubrics.js, lib/assignments.js |
Server route guards · full inventory
Every Express route guard currently active in the server, in dependency order.
| Endpoint | Guard | Allowed roles |
|---|---|---|
POST /api/email/send | requireAdmin | admin |
PUT /api/i18n/locales/:lang.json | requireAdmin | admin |
POST /api/i18n/invalidate-cache | requireAdmin | admin |
POST /api/bugs/:id/resolve | requireAdmin | admin |
POST /api/bugs/clear | requireAdmin | admin |
GET /api/help/admin/help-analytics | requireAdmin | admin |
POST /api/rubrics | requireRole(AUTHOR_ROLES) | instructor coord admin |
POST /api/rubrics/suggest | requireRole(AUTHOR_ROLES) | instructor coord admin |
POST /api/rubrics/:rid/validate/{samples,baseline,perturb,save} | requireRole(AUTHOR_ROLES) | instructor coord admin |
POST /api/assignments | requireRole(AUTHOR_ROLES) | instructor coord admin |
/api/admin/* | router gate: isAdmin || canManageRole(activeRole) | admin coord instructor |
POST /api/auth/switch-role | inline req.user.isAdmin | admin |
everything else under /api/* | auth (any valid Bearer) | all authenticated |
The fine-grained capability matrix
The capability matrix in docs/role-capabilities.md defines 70+ tokens in feature:action form (e.g. rubric:publish, submission:override-score, fairness:approve) with a per-role allow / forbid / not-meaningful / constrained value. The supporting code does not yet exist:
- ✗
server/lib/capabilities.js— does not exist - ✗
<Capable cap="…">React wrapper — does not exist - ✗
requireCap()Express middleware — does not exist - ✗
GET /api/capabilitiesendpoint — does not exist
Today's substitute: the roles: [...] arrays in the sidebar plus requireRole(...AUTHOR_ROLES) in 3 routers. Migration is laid out as 7 phases (C0 → C7) in the spec.
Capability legend
✓ allowed ✗ forbidden — not meaningful ⚠ allowed with constraint
Sample area · Rubric (8 of 11 capabilities)
To give you the flavour — see docs/role-capabilities.md §3 for the full 8-area matrix (Rubric · Submission & grading · Calibration · Integrity · Pedagogy · Data & Research · Governance · Cross-cutting).
| Capability | student | ta | instructor | coord | researcher | auditor | admin |
|---|---|---|---|---|---|---|---|
rubric:view | ⚠ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
rubric:create | — | — | ✓ | ✓ | — | — | ✓ |
rubric:edit-draft | — | — | ⚠ | ✓ | — | — | ✓ |
rubric:publish | — | — | ⚠ | ✓ | — | — | ✓ |
rubric:fork | — | ✓ | ✓ | ✓ | — | — | ✓ |
rubric:promote-curated | — | — | — | ⚠ | — | ✗ | ⚠ |
rubric:approve-curated | — | — | — | — | — | ✓ | ✗ |
rubric:retire | — | — | — | ✓ | — | — | ✓ |
Notable: rubric:approve-curated is the only column where auditor has ✓ and admin has ✗ — the curated tier is gated on external review, not internal authority. rubric:promote-curated is a propose action; the approve must follow.
How a request flows through the gates
For an authenticated request (Bearer … header):
attachUser(middleware/auth.js) — verifies Bearer against the OAuth gateway (or dev-bearer table), reads the GCS profile, buildsreq.user = { uid, email, isAdmin, activeRole, storedRole, tenantRoles, tid }.auth— 401 if noreq.user. Mounted atapp.use('/api', auth), after the public routers.- Optional
requireRole(...) / requireAdmin— 403 ifactiveRolenot in the allowed set. - In-handler logic —
isInstructorishfiltering, ownership checks, scope filtering (e.g.listUsersForreturns only outranked users viacanViewUser). - xAPI emission — every state-change emits a statement; 403 emits
denied, 5xx emitserroredvialogBug.
Practical FAQ
Who can change a user's role?
admin (any), coord (anyone below), instructor (TAs and students of their courses). Never to admin. Enforced by canManageUser in lib/users.js:135.
Who can publish a rubric?
Spec: instructor (own course), coord (any), admin. Today: enforced only at POST /api/rubrics + /suggest via AUTHOR_ROLES. Course-scoping is not yet checked.
What's the difference between admin and coord?
admin is .env-pinned, gets the role-switcher, owns LMS/SSO/governance config. coord is the highest assignable role and runs day-to-day tenant management.
Why can't I assign someone the admin role?
It's pinned to ADMIN_EMAIL. subordinatesOf() excludes it; setUserRole() throws admin_role_not_assignable 403.
Who sees the Rubric Library?
All roles (per sidebar). Per spec matrix, students should only see rubrics attached to their submissions (constraint ⚠₁) — not yet enforced server-side.
What's "view-as"?
The role-switcher dropdown in the TopBar, visible only to the bootstrap admin. Switching activeRole makes the entire UI & server behave as that role would. The switcher itself is the only legitimate isAdmin consumer.
References
server/lib/roles.js— role hierarchy,canManageUser,canViewUser,subordinatesOf.server/lib/users.js— GCS profile read/write,setUserRole,actorCapabilities.server/middleware/auth.js—attachUser,auth,requireRole,requireAdmin.client/src/config/sections.js— sidebar visibility allow-lists.docs/auth-and-roles.md— user-management contract (the layer this page describes).docs/role-capabilities.md— feature-capability spec (the matrix that is not yet implemented).CLAUDE.md— auth gateway, bug LRS, GCS layout, architecture invariants.