Authorization (RBAC)
The 9 roles, the resource/action permission model, and the two guards — RolesGuard and PermissionGuard — that enforce them.
Once a request is authenticated, the
backend decides what it is allowed to do. Authorization is role-based, with a
fine-grained permission layer on top, and the rules live in a single source of
truth: the @emofy/types package.
The 9 organization roles
Roles are hierarchical. A user holds one primary role plus any number of
additional roles; the effective permission set is their union. The
canonical role list — labels, descriptions, and ordinal rank — is generated
from the @emofy/types SSOT and cannot drift:
Organization roles reference
The 9 roles, generated from @emofy/types — code-bound and drift-gated.
The top role is owner; the lowest is guest. Actual permissions come from the
permission matrix, not the rank.
Retired role names
The names "workspaceManager" and "workspaceAdmin" were removed in the
PRD-254 rename and no longer exist. The current top roles are "owner" and
"manager". Referencing a retired name silently fails the check —
verify:docs-drift (docs) and verify:org-role-literals (code) both block
their reappearance.
Super-admin is not a role. It is an orthogonal boolean (isSuperAdmin) on
the user record, stamped into the JWT, used for platform staff. It bypasses the
role/permission guards entirely — see Multi-tenancy.
Permissions: resource × action
Beyond roles, the SSOT defines a matrix of resources (e.g. tasks,
grades, messages, billing, webhooks, members…) and actions (e.g.
create, read, update, delete, submit, manage, export,
rotate_secret…). Each role maps to a set of resource:action pairs, built with
Better Auth's createAccessControl.
This matrix is shared: the backend evaluates it server-side, and the account client uses the same definitions for UI gating, so the front and back ends never disagree about who can do what.
The two guards
The pipeline runs two authorization guards in sequence. Both are no-ops if the route carries no matching metadata, so you only pay for what you declare.
RolesGuard (legacy, role-level)
Reads the @Roles(...) decorator and checks whether the user's orgRole or any
additionalRoles match (OR semantics). Requesting super-admin checks the
isSuperAdmin flag.
@Roles("manager", "teacher") // caller needs manager OR teacher
@Get("reports")
getReports() { /* ... */ }PermissionGuard (modern, fine-grained)
The preferred path. It reads two decorators:
@RequireOrgRole("manager", "teacher") // OR — at least one role
@RequirePermission("tasks:create") // AND — every permission listed
@Post("tasks")
createTask() { /* ... */ }@RequirePermission(...)— AND semantics; the user must satisfy everyresource:action. The guard callscanWithRoles(orgRole, additionalRoles, resource, action).@RequireOrgRole(...)— OR semantics; at least one listed role.
It also handles three special cases:
- Super-admin bypass —
isSuperAdminshort-circuits to allow. - API-key scopes — when the request carries an API key instead of a user JWT, the guard checks the key's scopes rather than org roles, mapping permissions to OAuth-style scopes.
- User-scoped exceptions — a small set such as
notifications:*are user-scoped and bypass the org requirement.
Enforcement mode
A PERMISSION_ENFORCEMENT_MODE=log setting makes the permission guard log
denials but allow the request — an operator gate used to roll out new checks
safely before flipping them to hard enforcement.
Putting it together
authenticated request
│
▼
isSuperAdmin? ──yes──▶ allow (platform staff)
│ no
▼
@RequireOrgRole satisfied? (OR over roles)
│
▼
@RequirePermission satisfied? (AND over resource:action)
│ │
│ └─ API key? → check scopes instead
▼
@DataClass present? → RelationshipGuard + ConsentGuard (row-level)
│
▼
handlerRow-level scoping with @DataClass
RBAC decides what kind of action a role may take; it does not decide which
rows. For child data, a route is tagged with the @DataClass decorator
(common/decorators/data-class.decorator.ts), classifying it "C1"–"C4" per
ADR-022:
@DataClass("C2") // child-scoped, developmental data
@Get("children/:id/observations")
getObservations(@Param("id") id: string) { /* ... */ }The global RelationshipGuard reads that metadata and verifies the caller has
the required relationship to the subject (e.g. this guardian is linked to this
child); ConsentGuard verifies the necessary consent for higher-sensitivity
classes, failing closed when it is missing. Full detail in
Family & passport.