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.
globalRoleis 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¶
- Add the DB enum value to
globalRoleEnumororgRoleEnuminbackend/src/db/schema/. - Add the
UserRoleenum entry inshared/src/types/index.ts. - Update
ROLE_LEVELin every component that usescanManageUser. - Update
getRolePermissionsinshared/src/types/index.ts. - Update
requireRole(...)calls on any affected routes. - Run
pnpm db:generate && pnpm db:migrate(DB enum changes require a migration). - Update seed data in
backend/scripts/seed.ts.