Skip to content

Permissions & Roles

Overview

The app uses a two-scope RBAC system. Roles are split between global (platform-level) and org-scoped (per-organization), stored in separate database columns. Understanding this split is essential before touching any user, organization, or protected data feature.


Role Scopes

Global Roles

Stored in user.globalRole (nullable). Only platform-level actors have this set.

Role DB value Level
Owner 'Owner' 15
Platform Admin 'PlatformAdmin' 10

Org-Scoped Roles

Stored in organizationUsers.roleInOrganization. A user can have different roles in different orgs.

Role DB value Level
Organization Admin 'OrganizationAdmin' 3
Manager 'Manager' 2
Coach 'Coach' 1
Teacher 'Teacher' 1

Frontend UserRole Enum

Used throughout the frontend (shared/src/types/index.ts):

export enum UserRole {
  OWNER = 'owner', // level 15
  PLATFORM_ADMIN = 'platform_admin', // level 10
  ORGANIZATION_ADMIN = 'organization_admin', // level 3
  MANAGER = 'manager', // level 2
  COACH = 'coach', // level 1
  TEACHER = 'teacher', // level 1
}

Hierarchy

A user can only act on users with a strictly lower level:

graph TD
    OW["πŸ‘‘ Owner<br/><code>globalRole = Owner</code><br/>Level 15"]
    PA["🌐 Platform Admin<br/><code>globalRole = PlatformAdmin</code><br/>Level 10"]
    OA["🏒 Organization Admin<br/><code>roleInOrganization = OrganizationAdmin</code><br/>Level 3"]
    MG["πŸ‘” Manager<br/><code>roleInOrganization = Manager</code><br/>Level 2"]
    CO["πŸŽ“ Coach<br/><code>roleInOrganization = Coach</code><br/>Level 1"]
    TE["πŸ“š Teacher<br/><code>roleInOrganization = Teacher</code><br/>Level 1"]

    OW -->|"can manage"| PA
    OW -->|"can manage"| OA
    OW -->|"can manage"| MG
    OW -->|"can manage"| CO
    OW -->|"can manage"| TE
    PA -->|"can manage"| OA
    PA -->|"can manage"| MG
    PA -->|"can manage"| CO
    PA -->|"can manage"| TE
    OA -->|"can manage"| MG
    OA -->|"can manage"| CO
    OA -->|"can manage"| TE
    MG -->|"can manage"| CO
    MG -->|"can manage"| TE

    style OW fill:#dc2626,color:#fff
    style PA fill:#7c3aed,color:#fff
    style OA fill:#0891b2,color:#fff
    style MG fill:#059669,color:#fff
    style CO fill:#d97706,color:#fff
    style TE fill:#d97706,color:#fff
  • Manager cannot view, edit, or delete OrgAdmins.
  • OrgAdmin cannot act on PlatformAdmin or Owner users.
  • Coach / Teacher have no user management capability.
  • A user can hold different roles in different organizations simultaneously (e.g. Manager in Acme, Teacher in StartUp Academy).

Frontend Routes by Role

Role Routes
owner /platform/*
platform_admin /platform/*
organization_admin /org-admin/*
manager /admin/*
coach /inicio, /libreria, /mensajes
teacher /inicio, /libreria, /mensajes

Multi-Org Role Membership

Org-scoped users can belong to multiple organizations with different roles in each. The User type carries an orgRoles array:

interface OrgRoleEntry {
  orgId: string;
  orgName: string;
  role: string; // PascalCase DB value, e.g. 'Manager'
}

interface User {
  // ...
  role: UserRole; // resolved role for the active org context
  orgRoles?: OrgRoleEntry[]; // all per-org assignments (populated in platform-admin views)
}

The GET /api/users (platform-admin path) and GET /api/users/:id both return orgRoles so UIs can show every org assignment at once.

Platform Admins have no org memberships. globalRole is the authority; they must never appear in org user lists.


Backend Runtime Context

After requireAuth + tenantMiddleware, every Hono handler has:

Key Type Description
c.get('user').globalRole GlobalRole \| null Only set for Owner / PlatformAdmin
c.get('orgId') string \| null Resolved from X-Organization-Id header or profile
c.get('orgRole') OrgRole \| null Caller's role in the active org

PlatformAdmin always gets orgId = null, orgRole = null β€” tenant middleware short-circuits for them.


Permission Matrix

Action Owner PlatformAdmin OrgAdmin Manager Coach Teacher
Create / manage Platform Admins βœ… ❌ ❌ ❌ ❌ ❌
Create organization βœ… βœ… ❌ ❌ ❌ ❌
List all organizations βœ… βœ… ❌ ❌ ❌ ❌
List org users βœ… βœ… βœ… βœ… (excl. OrgAdmins) ❌ ❌
View user by ID βœ… βœ… βœ… βœ… (excl. OrgAdmins) ❌ ❌
Edit user βœ… βœ… βœ… βœ… (excl. OrgAdmins) ❌ ❌
Disable user βœ… βœ… βœ… βœ… (excl. OrgAdmins) ❌ ❌
Manage org settings βœ… ❌ βœ… ❌ ❌ ❌
View own profile βœ… βœ… βœ… βœ… βœ… βœ…

Middleware Reference

import { requireRole } from '../../middleware/require-role.middleware.js';
import { GlobalRole, OrgRole } from '../../types/roles.js';

// Platform Admin only
import { requirePlatformAdmin } from '../../middleware/require-role.middleware.js';

// Platform Admin or Org Admin
import { requireOrgAdmin } from '../../middleware/require-role.middleware.js';

// Custom: platform admins + org admins + managers
requireRole(GlobalRole.PlatformAdmin, OrgRole.OrganizationAdmin, OrgRole.Manager);

Do NOT use requireOrgAdmin for Manager routes

requireOrgAdmin only allows PlatformAdmin + OrgAdmin. If a Manager also needs access, use requireRole(GlobalRole.PlatformAdmin, OrgRole.OrganizationAdmin, OrgRole.Manager) explicitly.


Backend Patterns

Exclude platform-level users from org queries

import { isNull, ne, and, eq } from 'drizzle-orm';

const conditions = [
  eq(organizationUsers.organizationId, orgId),
  isNull(user.globalRole), // ← ALWAYS: exclude PlatformAdmin / Administrator
];

// Additional ceiling for Manager callers
const callerOrgRole = c.get('orgRole');
if (callerOrgRole === OrgRole.Manager) {
  conditions.push(ne(organizationUsers.roleInOrganization, OrgRole.OrganizationAdmin));
}

Ceiling check on GET / PATCH a specific user

const callerGlobal = c.get('user').globalRole;
const callerOrgRole = c.get('orgRole');

const isAdminRequest =
  callerGlobal === GlobalRole.PlatformAdmin ||
  callerGlobal === GlobalRole.Administrator ||
  callerOrgRole === OrgRole.OrganizationAdmin ||
  callerOrgRole === OrgRole.Manager;

if (!isAdminRequest) return c.json({ error: 'Forbidden' }, 403);

// Manager ceiling: cannot read or edit an OrgAdmin
if (callerOrgRole === OrgRole.Manager && targetOrgRole === OrgRole.OrganizationAdmin) {
  return c.json({ error: 'Forbidden' }, 403);
}

Frontend Patterns

Always destructure role locally

// βœ… Correct β€” role is in scope
const MyScreen: React.FC = () => {
  const { role } = useAuth();
  // ...
};

// ❌ Wrong β€” role from a parent component is not in scope

Gating row actions

const ROLE_LEVEL: Record<string, number> = {
  owner: 15,
  platform_admin: 10,
  organization_admin: 3,
  manager: 2,
  coach: 1,
  teacher: 1,
};

const canManageUser = (targetRole: string) =>
  (ROLE_LEVEL[role ?? ''] ?? 0) > (ROLE_LEVEL[targetRole] ?? 0);

// In render:
{canManageUser(user.role) ? <RowActionsMenu items={...} /> : null}

Displaying role strings

Always use getRoleLabel() β€” never render raw enum or DB values to users.


Common Pitfalls

Mistake Consequence Fix
Omitting isNull(user.globalRole) in org JOIN Platform admins appear in org user lists Always add it to org-scoped queries
Using requireOrgAdmin for Manager routes Managers get 403 Use requireRole(...) with explicit role list
Referencing role without useAuth() in the component Runtime crash β†’ blank screen Add const { role } = useAuth() locally
Frontend-only hierarchy checks API directly bypassable Every mutation must have a backend ceiling check
Seeding platform admins as OrgAdmins They surface in org user lists Seed with globalRole only; no org membership
Rendering user.role raw Shows "OrganizationAdmin" instead of localized label Use getRoleLabel()

PR Checklist β€” Features Touching Users or Orgs

  • Route guarded by requireRole(...) with the correct role set
  • Org-scoped queries include isNull(user.globalRole)
  • GET/:id and PATCH/:id include Manager ceiling check
  • const { role } = useAuth() called locally in every component that gates on role
  • Row action menus gated with canManageUser(user.role)
  • All displayed role strings go through getRoleLabel()

Adding a New Role

  1. Add the DB enum value to globalRoleEnum or orgRoleEnum in backend/src/db/schema/.
  2. Add the UserRole enum entry in shared/src/types/index.ts.
  3. Update ROLE_LEVEL in every component that uses canManageUser.
  4. Update getRolePermissions in shared/src/types/index.ts.
  5. Update requireRole(...) calls on any affected routes.
  6. Run pnpm db:generate && pnpm db:migrate (DB enum changes require a migration).
  7. Update seed data in backend/scripts/seed.ts.