EmofyEmofy Developers
Auth & Tenancy

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 every resource:action. The guard calls canWithRoles(orgRole, additionalRoles, resource, action).
  • @RequireOrgRole(...)OR semantics; at least one listed role.

It also handles three special cases:

  • Super-admin bypassisSuperAdmin short-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)


     handler

Row-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.

Next

On this page