This document explains how RBAC is implemented in this Medusa.
Overview
This project uses a custom RBAC module to control admin-side access. At a high level:Permissiondefines aresource + actionpairRoleis a named collection of permissions- Users are linked to roles through a
user_rolelink table - Access checks are done through middleware before protected admin routes run
- User permissions are cached in Redis
src/modules/rbac/service.ts.
Where the RBAC code lives
Core modulesrc/modules/rbac/index.tssrc/modules/rbac/service.tssrc/modules/rbac/models/role.tssrc/modules/rbac/models/permission.tssrc/modules/rbac/migrations/Migration20260421114704.ts
src/links/user-role.ts
src/api/admin/rbac-middleware.tssrc/api/middlewares.ts
src/api/admin/rbac/roles/route.tssrc/api/admin/rbac/roles/[id]/route.tssrc/api/admin/rbac/roles/[id]/permissions/route.tssrc/api/admin/rbac/users/[id]/roles/route.tssrc/api/admin/rbac/me/permissions/route.tssrc/api/admin/rbac/utils.ts
src/scripts/seed-rbac.tssrc/subscribers/user-created-rbac.ts
src/utils/cache.ts
Data model
Entity relationship diagram
Permission
Defined insrc/modules/rbac/models/permission.ts:4.
Fields: id, resource, action, description
Constraints:
resource + actionis unique for non-deleted rows —src/modules/rbac/models/permission.ts:12
Role
Defined insrc/modules/rbac/models/role.ts:4.
Fields: id, name, display_name, description, is_active, permissions
Constraints:
nameis unique for non-deleted rows —src/modules/rbac/models/role.ts:16- Inactive roles are ignored during permission resolution —
src/modules/rbac/service.ts:140
Relationships
Role ↔ Permission — many-to-many- Model relation:
src/modules/rbac/models/role.ts:10 - Pivot table
role_permission:src/modules/rbac/models/role.ts:12 - DB migration:
src/modules/rbac/migrations/Migration20260421114704.ts:19
- Defined in
src/links/user-role.ts:5 - Stored in table
user_role—src/links/user-role.ts:16
How permissions work
The permission format is built asresource.action — src/modules/rbac/service.ts:56.
Supported match patterns
Matching logic is implemented in
src/modules/rbac/service.ts:93.
Example matches
How user access is resolved
1. Fetch user roles
getUserRoles loads a user’s linked roles through a Medusa graph query — src/modules/rbac/service.ts:110.
- Only active roles are returned —
src/modules/rbac/service.ts:140 - Permissions can optionally be included —
src/modules/rbac/service.ts:125
2. Convert roles to permission strings
fetchUserPermissionsFromDb flattens all permissions from all active roles — src/modules/rbac/service.ts:159.
- Duplicate permissions are removed with
Set—src/modules/rbac/service.ts:181 - If any role is named
super_admin, the user gets immediately —src/modules/rbac/service.ts:171
3. Check a requested permission
checkPermission delegates to getUserPermissions and then evaluates match rules — src/modules/rbac/service.ts:184.
Caching and invalidation
User permissions are cached in Redis. Cache key format — defined insrc/utils/cache.ts:6
Read flow
- Reads current RBAC generation —
src/modules/rbac/service.ts:147 - Builds user cache key —
:148 - Returns cached permissions if present —
:149 - Falls back to DB if cache read/parsing fails —
:154 - TTL is 1 hour —
:152
Invalidation rules
Role-permission changes invalidate all users logically by bumping the generation number, without deleting every key one by one.
How routes are protected
RBAC route checks are handled by middleware insrc/api/admin/rbac-middleware.ts.
Request flow through middleware
Middleware helpersrequirePermission(resource, action)—src/api/admin/rbac-middleware.ts:80requireAnyPermission(...permissions)—src/api/admin/rbac-middleware.ts:108
auth_context (:18)
api_key/api-keyactors are automatically allowed —:37- Missing user ID returns
401—:41 - Actor type other than
userreturns403—:45 - Normal admin users are checked through
rbacService.checkPermission—:53
src/api/middlewares.ts:50
RBAC admin APIs
Roles list and create
src/api/admin/rbac/roles/route.ts
GET /admin/rbac/roles— supportslimit,offset,q; loads role permissions too (:23)POST /admin/rbac/roles— creates role; optionally assigns permission IDs (:51)
Single role
src/api/admin/rbac/roles/[id]/route.ts
GET /admin/rbac/roles/:idPOST /admin/rbac/roles/:id— updates role metadata; ifpermission_idsis provided, it replaces the role’s permissions (:42)DELETE /admin/rbac/roles/:id
Role permissions only
src/api/admin/rbac/roles/[id]/permissions/route.ts
POST /admin/rbac/roles/:id/permissions— replaces all permissions with provided list (:36)DELETE /admin/rbac/roles/:id/permissions— removes only the provided permission IDs (:54)
⚠️POST= full replace,DELETE= partial removal. Don’t confuse the two — see edge case #4.
User roles
src/api/admin/rbac/users/[id]/roles/route.ts
GET /admin/rbac/users/:id/rolesPOST /admin/rbac/users/:id/roles— syncs the user’s roles to exactly the providedrole_ids; removes missing roles and adds new ones (:41)DELETE /admin/rbac/users/:id/roles— removes only the listed roles
role_pricing_admin, it is automatically removed since it’s not in the list.
Current user permissions
src/api/admin/rbac/me/permissions/route.ts
GET /admin/rbac/me/permissions— returns bothpermissionsandroles. Useful for front-end capability checks.
Shared route utilities
src/api/admin/rbac/utils.ts
Seeding roles and permissions
The base RBAC setup is seeded fromsrc/scripts/seed-rbac.ts.
Permissions
Defined inPERMISSIONS — src/scripts/seed-rbac.ts:29
Resources: catalog, inventory, orders, customer.phone, customer.email, customer.address, customer.payment_meta, customer.internal_notes, promotions, pricing, analytics, access, exports, panel
Actions: view, manage, admin, run
Permission-group style roles
Defined inPERMISSION_GROUPS — :57
Examples: catalog_view, catalog_manage, inventory_admin, orders_manage_limited, customer_contact_access, access_manage, panel_admin
Legacy roles
Defined inLEGACY_ROLES — :198
super_adminall_viewer
Wildcard support during seeding
Script supports patterns:*, resource.*, *.action, exact resource.action. Pattern matching implemented in :226.
Existing user assignment during seed
⚠️ After seeding, the script assignssuper_adminandall_viewerto all existing users —src/scripts/seed-rbac.ts:349. Becausesuper_adminresolves to*, this gives every existing seeded user full access.
Default role assignment for new users
New users automatically receive theall_viewer role through subscriber src/subscribers/user-created-rbac.ts:8.
- Listens to
user.created—:40 - Fetches role
all_viewer—:16 - Assigns it to the new user —
:25 - If the role does not exist, the subscriber logs a warning and skips assignment
How to add a new permission
- Add the permission seed in
src/scripts/seed-rbac.ts:29 - Add that permission into the appropriate permission-group role in
:57 - Re-run the RBAC seed script
- Apply middleware to the route that should be protected
- If needed, expose the permission to the admin UI using
/admin/rbac/me/permissions
Example — adding comments.manage
Add to PERMISSIONS:
How to protect a new route
- Import
requirePermissionorrequireAnyPermissionfromsrc/api/admin/rbac-middleware.ts - Create a middleware instance in
src/api/middlewares.ts - Attach it to the desired admin route definition
Important behaviors and edge cases
super_adminis name-based — the special full-access behavior triggers only when a role is literally namedsuper_admin(src/modules/rbac/service.ts:171). It does not depend on assigned permissions. If a different role should behave the same way, code must be changed.- Inactive roles do not count — even if a user is linked to a role, it is ignored if
is_active === false(:140). - API keys bypass RBAC checks —
api_keyandapi-keyactors are allowed automatically (src/api/admin/rbac-middleware.ts:37). Keep this in mind when testing. - Role permission updates replace the relation set —
assignPermissionsToRoleupdates the role with a full permission list (src/modules/rbac/service.ts:244). Treat it as a replace operation, not an append. - Duplicate role assignment is tolerated — duplicate link errors return
falseinstead of throwing (:207). - Global cache invalidation happens on role-permission changes — when one role’s permissions change, all user permission caches become stale by generation bump (
:264). - Seed script currently grants broad access to all existing users — the seed script assigns both
super_adminandall_viewerto every existing user (src/scripts/seed-rbac.ts:349). Useful for bootstrap, but risky if the seed is run in an environment where granular access is expected.
Quick scenario check
Known gaps and developer notes
- Permission constants are not centralized — most route protection uses string literals in
src/api/middlewares.ts:50. Simple, but route strings and seeded permissions can drift apart. A future improvement would centralize permission definitions in a shared constant map. - Some protected resources appear outside the seed list —
src/api/middlewares.tsincludes permissions likemedia.manage(:56) andcomments.manage(:57), but these are not present in the seededPERMISSIONSlist (src/scripts/seed-rbac.ts:29). If these middleware checks are used, the required permissions/roles must exist through another flow or be added to the seed script. - Route attachment should always be verified —
src/api/middlewares.tsdefines permission middleware instances, but when adding new protections you should verify the middleware is actually attached to the intended route registration in that same file.
Quick reference
Main service methods
Main middleware helpers
Core tables
role · permission · role_permission · user_role
Special roles
super_admin→ full wildcard accessall_viewer→ default viewer role for new users
GET /admin/rbac/me/permissionsGET /admin/rbac/rolesPOST /admin/rbac/rolesGET /admin/rbac/roles/:idPOST /admin/rbac/roles/:idDELETE /admin/rbac/roles/:idPOST /admin/rbac/roles/:id/permissionsDELETE /admin/rbac/roles/:id/permissionsGET /admin/rbac/users/:id/rolesPOST /admin/rbac/users/:id/rolesDELETE /admin/rbac/users/:id/roles