Custom RBAC
Define custom roles within your Organization to grant members granular permissions beyond their base role. Custom roles are fully additive โ they can only add permissions, never restrict them โ ensuring consistent security boundaries while giving Malet Owners precise control over who can do what.
How It Works
Every Organization member already has a base role (OWNER, ADMIN, MEMBER, VIEWER) and optionally a Vertical role (GUIDE, KITCHEN, PHOTOGRAPHER, etc.). Custom roles add a third layer:
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Permission Resolution โ
โ โ
โ Layer 1: BASE_ROLE_PERMISSIONS[MEMBER] โ
โ โ VIEW_ANALYTICS โ
โ โ
โ Layer 2: VERTICAL_ROLE_PERMISSIONS[KITCHEN] โ
โ โ VIEW_ORDERS, CREATE_ORDERS, UPDATE_ORDER_STATUS, ACCESS_KDS โ
โ โ
โ Layer 3: CustomRole.permissions โ
โ โ MANAGE_PRODUCTS, MANAGE_ORDERS โ
โ โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ Result: { VIEW_ANALYTICS, VIEW_ORDERS, CREATE_ORDERS, โ
โ UPDATE_ORDER_STATUS, ACCESS_KDS, MANAGE_PRODUCTS, โ
โ MANAGE_ORDERS } (deduplicated) โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Key design principle: Custom role permissions are additive-only. A MEMBER with a custom role always retains their base MEMBER permissions. The custom role can grant additional permissions but cannot revoke existing ones. This prevents a misconfigured custom role from accidentally locking someone out.
Entity Schema
CustomRole
| Field | Type | Description |
|---|---|---|
id |
ID! |
Unique identifier (nanoid) |
organizationId |
ID! |
Parent Organization |
name |
String! |
Human-readable name (1โ100 chars, e.g. "Inventory Manager") |
slug |
String! |
URL-friendly identifier (unique within org, auto-generated from name if omitted) |
description |
String |
Brief explanation of what this role is for (0โ500 chars) |
permissions |
[Permission!]! |
The permission set this role grants (minimum 1 required) |
isDefault |
Boolean! |
Auto-assign to new Org members? (at most one per Organization) |
createdBy |
ID! |
User who created this role |
createdAt |
DateTime! |
Creation timestamp |
updatedAt |
DateTime! |
Last update timestamp |
Indexes:
{ organizationId: 1 }โ Fast org-level lookups{ organizationId: 1, slug: 1 }โ Unique slug per Organization
Constraints:
- Maximum 50 custom roles per Organization โ prevents abuse and keeps permission resolution performant
- Slug must match
^[a-z0-9-]+$(lowercase alphanumeric + hyphens) - At most one
isDefault: truerole per Organization โ creating a new default automatically clears the previous one
Membership Extension
The Membership entity gains a new field:
| Field | Type | Description |
|---|---|---|
customRoleId |
String |
References the assigned CustomRole.id (null = no custom role) |
When querying a member's permissions via GraphQL, the resolved field automatically includes custom role permissions:
query {
myMemberships {
organizationId
role # Base role (MEMBER, ADMIN, etc.)
verticalRole # Optional vertical role (KITCHEN, GUIDE, etc.)
customRoleId # Optional custom role ID
permissions # Computed: Base + Vertical + Custom (deduplicated)
}
}
GraphQL Mutations
All mutations require the MANAGE_ROLES permission โ only Organization OWNER and ADMIN roles have this by default.
Create Custom Role
mutation CreateCustomRole($input: CreateCustomRoleInput!) {
createCustomRole(input: $input) {
id
organizationId
name
slug
description
permissions
isDefault
createdBy
createdAt
}
}
Variables:
{
"input": {
"orgId": "org-abc123",
"name": "Inventory Manager",
"slug": "inventory-manager",
"description": "Can manage products and view analytics",
"permissions": ["MANAGE_PRODUCTS", "VIEW_ANALYTICS"],
"isDefault": false
}
}
slugis optional โ if omitted, it's auto-generated from the name (e.g. "Inventory Manager" โinventory-manager). Must be unique within the Organization.
Update Custom Role
Partial updates are supported โ only include the fields you want to change.
mutation UpdateCustomRole($input: UpdateCustomRoleInput!) {
updateCustomRole(input: $input) {
id
name
description
permissions
isDefault
}
}
Variables:
{
"input": {
"orgId": "org-abc123",
"roleId": "role-xyz789",
"name": "Senior Inventory Manager",
"permissions": ["MANAGE_PRODUCTS", "MANAGE_ORDERS", "VIEW_ANALYTICS"]
}
}
If updating
permissions, at least one permission must remain. Empty permission arrays are rejected.
Delete Custom Role
Deletes the custom role and cascade-clears the customRoleId from all memberships that reference it. Members lose the custom role's extra permissions immediately but retain their base and vertical permissions.
mutation DeleteCustomRole($orgId: ID!, $roleId: ID!) {
deleteCustomRole(orgId: $orgId, roleId: $roleId) {
id
name
}
}
Assign Custom Role to Member
Assigns a custom role to an Organization member. The member must already exist in the Organization, and the custom role must belong to the same Organization.
mutation AssignCustomRole($input: AssignCustomRoleInput!) {
assignCustomRole(input: $input) {
userId
customRoleId
}
}
Variables:
{
"input": {
"orgId": "org-abc123",
"userId": "user-staff-001",
"roleId": "role-xyz789"
}
}
Each member can have at most one custom role at a time. Assigning a new role replaces the previous one.
Remove Custom Role from Member
Clears the customRoleId from a member's record. The member reverts to their base + vertical permissions only.
mutation {
removeCustomRoleFromMember(orgId: "org-abc123", userId: "user-staff-001") {
userId
customRoleId
}
}
GraphQL Queries
List Organization Custom Roles
Returns all custom roles for an Organization, sorted alphabetically by name.
query OrganizationCustomRoles($orgId: ID!) {
organizationCustomRoles(orgId: $orgId) {
id
name
slug
description
permissions
isDefault
createdBy
createdAt
}
}
Get Single Custom Role
query CustomRole($roleId: ID!) {
customRole(roleId: $roleId) {
id
name
slug
description
permissions
isDefault
createdBy
createdAt
updatedAt
}
}
Returns
nullif the role does not exist (no error thrown).
Permission Resolution Flow
When the PermissionsGuard evaluates access for a @RequirePermission-decorated resolver, it performs the following:
1. Extract userId from x-user-id header
2. Extract orgId from x-org-id header (or mutation input)
3. Look up Membership (userId + orgId)
4. If membership.customRoleId exists:
a. Look up CustomRole by id
b. Extract CustomRole.permissions
5. Compute effective permissions:
BASE_ROLE_PERMISSIONS[membership.role]
โช VERTICAL_ROLE_PERMISSIONS[membership.verticalRole]
โช CustomRole.permissions
โ Set (deduplicated)
6. Check: required permission โ effective set
7. Attach permissions + membership to GraphQL context
The same resolution logic runs in MembershipResolver.permissions() when you query a member's permissions via GraphQL โ ensuring consistency between authorization and display.
Available Permissions
Custom roles can grant any combination of the platform's Permission enum values:
| Category | Permissions |
|---|---|
| Common | VIEW_ANALYTICS, MANAGE_PRODUCTS, MANAGE_SERVICES, MANAGE_ORDERS |
| Administrative | MANAGE_MEMBERS, MANAGE_ROLES, MANAGE_TEAMS, UPDATE_ORG, DELETE_ORG, VIEW_AUDIT_LOGS |
| Tour Operator | VIEW_BOOKINGS, VIEW_MANIFESTS, UPDATE_STATUS, MANAGE_BOOKINGS, CONTACT_GUESTS |
| Restaurant | VIEW_ORDERS, CREATE_ORDERS, UPDATE_ORDER_STATUS, ACCESS_KDS |
| Photography | UPLOAD_IMAGES, MANAGE_GALLERIES, ACCESS_PROOFING |
| Author | EDIT_BLOGS, EDIT_PRODUCTS |
| Platform | MANAGE_SEARCH, MANAGE_USERS, MANAGE_ANALYTICS |
Security note: While a custom role can include administrative permissions like
MANAGE_ROLESorDELETE_ORG, this is intentional โ it allows Organization Owners to precisely delegate authority. The additive-only design ensures a custom role never restricts existing access.
Audit Events
All custom role lifecycle changes are logged via the Organization Audit Trail โ see Edit History Audit Trail for the broader field-level change tracking system.
| Event Type | Trigger | Metadata |
|---|---|---|
CUSTOM_ROLE_CREATED |
New custom role created | roleId, name, slug, permissions |
CUSTOM_ROLE_UPDATED |
Role name/description/permissions changed | roleId, changes |
CUSTOM_ROLE_DELETED |
Role deleted (cascade-clears memberships) | roleId, name, affectedMembers |
CUSTOM_ROLE_ASSIGNED |
Role assigned to a member | roleId, targetUserId |
CUSTOM_ROLE_REMOVED |
Role removed from a member | targetUserId, previousRoleId |
Permission Matrix
| OrgRole | Can create/manage custom roles? | Can be assigned a custom role? |
|---|---|---|
OWNER |
โ
(has MANAGE_ROLES) |
โ (rarely needed โ already has full access) |
ADMIN |
โ
(has MANAGE_ROLES) |
โ |
MEMBER |
โ | โ (primary use case) |
VIEWER |
โ | โ (can expand view-only access) |
Cascade Behavior
Deleting a Custom Role
When a custom role is deleted:
- The
CustomRoledocument is removed from the database - All memberships referencing that
customRoleIdare updated:customRoleIdis set tonull - Affected members immediately lose the extra permissions โ they revert to Base + Vertical only
- An audit event is recorded with the count of affected memberships
Deleting an Organization Member
When a member is removed from an Organization, their Membership record (including any customRoleId) is deleted. No cleanup of the CustomRole is needed โ the role continues to exist for other members.
Module Structure
apps/organizations/src/custom-role/
โโโ custom-role.entity.ts # CustomRole entity (Typegoose + GraphQL)
โโโ custom-role.service.ts # CRUD + assign/remove + audit logging
โโโ custom-role.resolver.ts # GraphQL mutations + queries
โโโ custom-role.service.spec.ts # 28 unit tests
โโโ custom-role.resolver.spec.ts # 10 unit tests
โโโ dto/
โโโ create-custom-role.input.ts # CreateCustomRoleInput
โโโ update-custom-role.input.ts # UpdateCustomRoleInput
โโโ assign-custom-role.input.ts # AssignCustomRoleInput
Error Handling
| Scenario | Error Type | Message |
|---|---|---|
| Duplicate slug in same org | BadRequestException |
"A custom role with this slug already exists in this organization." |
| Max roles exceeded (50) | BadRequestException |
"Maximum of 50 custom roles per organization reached." |
| Empty permissions on create | BadRequestException |
"At least one permission is required" |
| Empty permissions on update | BadRequestException |
"At least one permission is required" |
| Role not found | NotFoundException |
"Custom role not found" |
| Member not found (assign) | NotFoundException |
"Membership not found" |
| Cross-org role assign | NotFoundException |
"Custom role not found in this organization" |
| Member has no role (remove) | BadRequestException |
"Member does not have a custom role assigned" |
| Insufficient permissions | ForbiddenException |
"Permission denied: MANAGE_ROLES required" |
Example: Setting Up a Restaurant Org
Here's a practical workflow for a Malet Owner running a restaurant vertical:
# 1. Create a "Shift Manager" custom role
mutation {
createCustomRole(
input: {
orgId: "org-restaurant-01"
name: "Shift Manager"
description: "Can manage orders and view kitchen display"
permissions: [MANAGE_ORDERS, VIEW_ORDERS, ACCESS_KDS]
}
) {
id
slug # โ "shift-manager"
}
}
# 2. Create a "Content Specialist" custom role
mutation {
createCustomRole(
input: {
orgId: "org-restaurant-01"
name: "Content Specialist"
description: "Can update menu items and blog posts"
permissions: [MANAGE_PRODUCTS, EDIT_BLOGS]
isDefault: false
}
) {
id
slug # โ "content-specialist"
}
}
# 3. Assign "Shift Manager" to a MEMBER
mutation {
assignCustomRole(
input: { orgId: "org-restaurant-01", userId: "user-maria", roleId: "<shift-manager-id>" }
) {
userId
customRoleId
}
}
# 4. Query Maria's effective permissions
# (As Maria)
query {
myMemberships {
role # "MEMBER"
customRoleId # "<shift-manager-id>"
permissions # ["VIEW_ANALYTICS", "MANAGE_ORDERS", "VIEW_ORDERS", "ACCESS_KDS"]
}
}
Maria now has her base MEMBER permissions (VIEW_ANALYTICS) plus the Shift Manager permissions (MANAGE_ORDERS, VIEW_ORDERS, ACCESS_KDS) โ without needing to be promoted to ADMIN.
Testing
| Suite | Tests | Coverage |
|---|---|---|
custom-role.service.spec.ts |
28 | CRUD lifecycle, cascade-clear, slug generation, cap enforcement, audit logging |
custom-role.resolver.spec.ts |
10 | Auth gating, input validation, error propagation |
custom-roles.e2e-spec.ts |
24 | Full lifecycle: create โ assign โ permission resolution โ delete cascade |
Running Tests
# Unit tests only
npx jest --testPathPattern="custom-role" --no-coverage
# E2E tests only
npx jest --config apps/organizations/test/jest-e2e.json --testPathPattern=custom-roles --forceExit
# Full organizations suite (148 unit + 56 E2E)
npx jest --testPathPattern="organizations" --no-coverage
Note: E2E tests use in-memory MongoDB and mock the
ALERTS_SERVICETCP client since the Alerts service isn't running during test execution.
Frontend Implementation
The Custom RBAC UI lives in the Organization management dashboard at /orgs/[slug]/manage#roles. It replaces the previous placeholder with a fully functional role editor and inline assignment system.
Components
| Component | Purpose |
|---|---|
src/lib/components/rbac/CustomRbacPanel.svelte |
Self-contained panel: role CRUD, permission matrix editor, role list table |
src/lib/queries/customRoles.ts |
GraphQL operations, Permission type, PERMISSION_CATEGORIES constant |
Permission Matrix
The PERMISSION_CATEGORIES constant organizes 35 platform permissions into 8 visual groups. Each group renders as a collapsible section with:
- A category-level checkbox to toggle all permissions in the group
- Individual permission checkboxes in a responsive grid
- A selected count indicator
Role Assignment
Roles are assigned inline from the Members tab โ each member row has a "Custom Role" <select> dropdown that appears when custom roles exist. The dropdown calls assignCustomRole / removeCustomRoleFromMember mutations directly, triggering a full org reload to reflect the change.
Design Decisions
| Decision | Rationale |
|---|---|
| Inline assignment dropdown | Keeps UX contextual โ admins don't need to switch tabs to assign roles |
| Category toggle | Speeds up role creation for vertical-specific permissions (e.g., "select all Restaurant permissions") |
| Cap indicator (N/50) | Surfaces the MAX_CUSTOM_ROLES_PER_ORG constraint before hitting the backend error |
| Confirm on delete | Warns about cascade-clear effect on members |
Frontend Testing
# Unit tests โ permission matrix structure validation
npx vitest run --environment node tests/custom-rbac.test.ts
# E2E tests โ role list rendering, create flow
pnpm test -- tests/custom-rbac.spec.ts
Related
- Organizations & Permissions โ Base roles (OWNER/ADMIN/MEMBER/VIEWER), vertical roles, and the
PermissionsGuardthat Custom RBAC extends - Teams & Sub-Groups โ Group members into named teams with
maletIdsscoping โ Custom RBAC permissions layer on top of team membership - Corporate Identity (SAML & SCIM) โ Enterprise provisioning that creates members who can then be assigned custom roles
- Organization & Malet Management โ Frontend management UI for Organizations, Malets, domains, and billing
- Edit History Audit Trail โ Cross-entity field-level audit trail that tracks changes across the platform
- SIEM Event Streaming โ Stream audit events (including custom role lifecycle) to external SIEM platforms via HMAC-signed webhooks
- Tag Registry โ
MANAGE_TAGSpermission can be delegated via custom roles for tag administration - Subscription & Billing โ Subscription tiers restrict the maximum number of custom roles per organization and limit total members.
- Revenue Sharing & Payouts โ VIEW_PAYOUTS, MANAGE_PAYOUTS, VIEW_REVENUE, and MANAGE_COMMISSIONS permissions can be delegated via custom roles
- Permission Matrix Editor โ UI component architecture for the interactive checkbox matrix