Role-Based Access Control (RBAC)
Technical reference for how roles, organization membership, and fine-grained scopes work in this codebase. Pair withapps/documentation/architecture/authentication.md for session login and identity.
This page explains how RBAC turns an organization member’s role into product-scoped AllScopes checks using Redis caching and ProductRoleScope mappings.
How it works
The runtime path is:hasScopes resolves the current membership, then canAccessScopes calls getScopesForOrganizationMembership (Redis cache or DB via ProductRoleScope), and finally it checks ScopeMatchMode.
Example: scope-gated resolver composition with hasScopes([...], ScopeMatchMode.EVERY):
Extending this system
- Add the new scope identifier to
AllScopesinapps/backend/src/utilities/rbac.ts:
- Seed product-role-scope mappings in
apps/backend/src/tools/rds/sequelize/seeds/20250503045559-createDefaultProducts.ts(example bulk insert intoproducts_roles_scopes):
- Protect the relevant GraphQL resolver with
hasScopes([AllScopes.YOUR_SCOPE], ScopeMatchMode.EVERY)inapps/backend/src/gql/mutations/:
- Verify end-to-end behavior with backend tests:
What not to do
File conventions
How roles are defined
This section shows how the code represents roles and what makes super admin special. Roles are declared as TypeScript enums inapps/backend/src/constants/roles.ts:
PublicRoles— values allowed for normal organization members (e.g. invitations, membershiprolecolumn).Roles.SUPER_ADMIN— not stored as an organization role; it is implied whenUser.is_super_adminis true (see below).
AllScopes in apps/backend/src/utilities/rbac.ts). Role-to-scope mapping comes from the database (ProductRoleScope → Scope) per subscription product.
How roles are assigned to users
This section explains how an organization member’s effective role is chosen for scope resolution.Organization membership (OrganizationMember.role)
- Default org creation assigns the creator as admin:
- Invitations set
rolefrominviteMemberinput (validated againstObject.values(PublicRoles)):apps/backend/src/gql/mutations/inviteMember.ts.
Super admin (User.is_super_admin)
- Global flag on the user row. When true, scope resolution treats the effective role as
Roles.SUPER_ADMIN:
Active organization context (User.current_membership)
getCurrentMembershipselects theOrganizationMemberwhoseidmatchesuser.current_membership:
switchOrganization mutation (apps/backend/src/gql/mutations/switchOrganization.ts), which updates current_membership when the user belongs to the target org.
How permissions are checked
This section explains the scope-resolution inputs and howhasScopes enforces them at runtime.
Scope enum
All permission strings live inAllScopes (apps/backend/src/utilities/rbac.ts), e.g. members:invite, view:pages:billing, etc.
Resolving scopes for the current membership
getScopesForOrganizationMembership:
- Loads the organization’s active subscription (filtered by
ACTIVE_STATUSES); if none active, returns[](no scopes). - Resolves the effective role for scope lookup:
organizationMember.user?.is_super_admin→Roles.SUPER_ADMIN- otherwise, use
organizationMember.role
- Uses Redis cache key
rbac:scopes:<productId>:<role.toLowerCase()>:v1with TTL 24 hours (CACHE_TTL), with a super-admin special case keyrbac:scopes:${Roles.SUPER_ADMIN}:v1(i.e.rbac:scopes:super_admin:v1). - On miss, loads
ProductRoleScoperows for(product_id, role)and maps toScope.name. - If the role is not in
PublicRoles,getRoleScopesFromProductIdAndRolereturns allAllScopesvalues (Object.values(AllScopes)).
canAccessScopes
ScopeMatchMode.ANY passes when at least one required scope is present; ScopeMatchMode.EVERY passes only when all required scopes are present.
Scope check flow (quick visual)
The diagram shows howhasScopes turns tenant membership into an “allow/deny” decision.
GraphQL middleware: hasScopes
Typical usage — load user + memberships, short-circuit for super admin, resolve current membership, then canAccessScopes:
Role-only middleware
isAdmin— current membership role must bePublicRoles.ADMIN(apps/backend/src/gql/middlewares/isAdmin.ts).isAdminOrSuperAdmin— super admin or org admin (apps/backend/src/gql/middlewares/isAdminOrSuperAdmin.ts).isSuperAdmin—context.user.is_super_adminmust be true; must run afterpopulateUserWithMemberships(see example below).
How to add a new role
This section shows how to introduce a new public role and ensure it grants the right scopes per product.- Extend
PublicRolesinapps/backend/src/constants/roles.ts(andRolesif it should exist at the enum level for non-public handling). - Validate invite input —
inviteMemberonly allowsObject.values(PublicRoles)(apps/backend/src/gql/mutations/inviteMember.ts); update that validation if the role should be invitable. - Data — insert rows in
ProductRoleScope(and relatedScoperecords) for each product that should grant that role’s permissions. Models:apps/backend/src/tools/rds/sequelize/models/productRoleScope.ts,apps/backend/src/tools/rds/sequelize/models/scope.ts. - Cache invalidation —
ProductRoleScope.afterSave/afterDestroyenqueue aQueueName.SCOPE_CACHE_RESETjob;scopeCacheResetConsumerclears the matching Redis entry viaclearCachedScopes. Redis TTL (24h) acts as a safety net, but the primary refresh path is event-driven.
- Cite:
How to add a new permission (scope)
This section shows how to add a new scope string and attach it to product-role mappings.- Add enum member to
AllScopesinapps/backend/src/utilities/rbac.ts. - Database — create or use a
scopesrow withnameequal to that string; link it viaproduct_role_scopesfor the relevantproduct_id+role. - Use it — protect resolvers with
hasScopes([AllScopes.YOUR_SCOPE], ScopeMatchMode.EVERY)(orANY). - Clear cache —
clearCachedScopes({ productId, role }, tools)fromapps/backend/src/utilities/rbac.tswhen you change mappings.
Code examples
Use these examples to see the intended middleware ordering for role-scoped and scope-scoped resolvers.Example: super-admin-only query
Example: scope-gated mutation (invite)
Example: org-level permission check in a utility
resetInvitation requires AllScopes.MEMBERS_INVITE via canAccessScopes (apps/backend/src/utilities/organization.ts).
Related models (Sequelize)
These tables back the pipeline from organization membership roles to product-scoped permissions.| Concern | Model / table direction | File |
|---|---|---|
| Membership + role | OrganizationMember | apps/backend/src/tools/rds/sequelize/models/organizationMember/index.ts |
| Product ↔ scope ↔ role | ProductRoleScope | apps/backend/src/tools/rds/sequelize/models/productRoleScope.ts |
| Scope catalog | Scope | apps/backend/src/tools/rds/sequelize/models/scope.ts |
Pitfalls
These are the highest-risk failure modes when wiring middleware or changing scope mappings.⚠️ Watch out: no active subscription meansgetScopesForOrganizationMembershipreturns an empty scope set ([]), sohasScopesrejects with 403. ⚠️ Watch out: cache invalidation is event-driven viaQueueName.SCOPE_CACHE_RESET(triggered byProductRoleScope.afterSave/afterDestroy); TTL is a fallback. ⚠️ Watch out:isSuperAdminreadscontext.user.is_super_adminand throws 403 when it is false, so ensurepopulateUserWithMembershipsruns before it (socontext.userexists).
- Cite:
-
Super admin bypass —
hasScopesallows super admins without evaluating org scopes;isAdminis membership-role specific (useisAdminOrSuperAdminwhen you want super-admin coverage too).
What’s next?
Billing is the primary consumer of these scope gates; seeapps/documentation/architecture/billing.md.