Developer Docs

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: true role 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
	}
}

slug is 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 null if 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_ROLES or DELETE_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:

  1. The CustomRole document is removed from the database
  2. All memberships referencing that customRoleId are updated: customRoleId is set to null
  3. Affected members immediately lose the extra permissions โ€” they revert to Base + Vertical only
  4. 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_SERVICE TCP 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

  • Organizations & Permissions โ€” Base roles (OWNER/ADMIN/MEMBER/VIEWER), vertical roles, and the PermissionsGuard that Custom RBAC extends
  • Teams & Sub-Groups โ€” Group members into named teams with maletIds scoping โ€” 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_TAGS permission 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