Guides

Multi-role system and app feature permissions

This guide explains multi-role in MUDBASE: configuring roles for your project, how end-user JWTs interact with feature permissions, how to simulate access before you ship UI, and how presets and the permissions matrix fit together. Use the REST API (see multiRole tag), OpenAPI, or SDKs to automate the same operations.


1. Two audiences (do not confuse them)

AudienceAuthWhat they use
Project admins (dashboard, integrations)Dashboard JWT, API keysMulti-role admin APIs under /api/projects/{projectId}/… — configure roles, featurePermissions, collection rules. Not subject to app feature gates.
App end users (your customers)Project-scoped JWT with customRole (role slug)Product-facing APIs (messaging, data, storage, chat, …). May be checked by feature gates when the token clearly identifies an app user for that projectId.

Feature gates apply only for a recognized app end-user context:

  • The JWT includes customRole (slug, e.g. customer).
  • The JWT projectId matches the projectId in the request path.

If that is not true (console user, API key, or no project id on the token), feature gates do not add an extra denial; normal project access and role checks still apply.


2. Stored model (high level)

Each project has one MultiRoleFeature document (project is unique).

2.1 Top-level fields

FieldPurpose
isEnabledWhether multi-role behavior is active for the project.
defaultRoleSlug assigned when signup does not pick a role (often customer).
settingse.g. allowMultipleRoles, requireRoleSelection, autoAssignDefault, dataOwnerField (default owner field for row scope).
roles[]Role templates: slug, name, signup, collectionPermissions, featurePermissions, etc.

2.2 Per-role featurePermissions (app-facing toggles)

featurePermissions is a mixed object. It is not the same as dashboard RBAC for the builder.

Semantics (important):

  • Missing bucket or empty featurePermissionsno extra denial (legacy-friendly): the gate does not block for that resource.
  • If a resource bucket exists and is a non-empty object, then for the evaluated action, if any relevant key is explicitly false, the action is denied.

For messaging, the platform accepts legacy keys (email, sms, …) and new keys (send_email, read_history, …) as synonyms when evaluating the gate.

Other resources (for example integration, storage, functions) use the action names documented for that resource (e.g. execute, upload).

2.3 featurePermissions keys and the API contract

When you add (POST .../multi-role/roles) or update (PATCH .../multi-role/roles/:roleSlug) a role, use resource and action names that match what the API expects. Otherwise some flags may never affect a real endpoint (harmless), or you may omit keys that do gate traffic.

SourcePurpose
OpenAPIAppRoleFeaturePermissionsDocumented JSON shape and vocabulary for role bodies.
Simulate API (POST .../multi-role/simulate-permissions)Dry-run whether a role + HTTP method/path (or operationId) would be allowed.
API referenceOperation-level detail for each multi-role route.

Resource → actions (boolean keys under each resource object):

ResourceActions
messagingsend_email, send_sms, send_push, read_history, read_statsor legacy email, sms, push, history, stats
integrationread, create, update, delete, execute, test, export, read_usage
functionscreate, read, update, delete, execute, simulate
datacreate, read, update, delete
searchquery, suggestions, read_analytics
usageread
storageread, create, update, delete, upload
chatread, create, update, delete
realtimeread_analytics, read_active_users, presence, read_throughput, read_history
roleElevationrequest, status, documents
webhooksconfig_read, config_update, test_transformation

Built-in admin / user / viewer presets use this same vocabulary for featurePermissions bundles.

2.4 Per-collection permissions and dataScope (separate from feature gates)

collectionPermissions on each role, plus collection-level permissions, control which collections a role can touch and whether list/read/update are scoped to “own” rows (dataScope: "own") using the configured owner field (e.g. createdBy). That logic is separate from feature gates: see §2.3 for feature vs collection CRUD.


3. Runtime: how a gated request is decided

  1. The client calls a project-scoped endpoint with a valid token.
  2. The platform confirms project access (and normal API-key or JWT rules).
  3. If the caller is not an app end-user in the sense of §1 (no customRole / projectId match), no extra feature check is applied for gates.
  4. If the HTTP method and path match a mapped feature rule, the platform resolves { resource, action }. Unmapped routes are not denied by feature gates.
  5. The platform loads featurePermissions for (projectId, customRole) (results may be cached briefly).
  6. If the role’s permissions deny that resource/action, the API returns 403 with the stable body below. Otherwise the request continues to the handler.

3.1 Feature denied response (403)

{
  "success": false,
  "error": "feature_not_allowed",
  "resource": "messaging",
  "action": "send_email",
  "message": "Your role does not have permission to use messaging (send_email) for this project."
}

Use POST .../multi-role/simulate-permissions with the same method, path, and role slug to verify behavior before your users hit the endpoint.


4. Admin APIs (project management)

Base path: /api/projects/{projectId} (all paths below are appended to that prefix).

Auth: Dashboard JWT (builder / admin session) or API key with permission to manage the project (see Authentication and the multiRole operations in the API reference).

MethodPathPurpose
GET/permissions-matrixMatrix of collections × role actions + featurePermissions snapshot per role.
GET/multi-roleFull multi-role config.
PATCH/multi-role/settingsUpdate isEnabled, defaultRole, settings.
POST/multi-role/simulate-permissionsDry-run feature gate for a role + path or operationId.
POST/multi-role/roles/:roleSlug/apply-presetApply admin / user / viewer featurePermissions preset only.
PATCH/multi-role/roles/:roleSlug/toggleEnable/disable role.
PATCH/multi-role/roles/:roleSlugUpdate role (name, featurePermissions, collection permissions, etc.).
POST/multi-role/rolesAdd role.

Full URL example: GET /api/projects/685ad30be129932fbb7a1047/permissions-matrix.

Additional routes (per-collection role permissions, signup role lists, etc.) are documented under the multiRole tag in OpenAPI.


5. Full request/response examples

All examples use projectId in the URL. Send Authorization: Bearer <admin or API key token> (and any other headers your deployment requires). No request body for GET routes below.

§Endpoint
5.1GET .../multi-role
5.2PATCH .../multi-role/settings
5.3GET .../permissions-matrix
5.4PATCH .../multi-role/roles/:roleSlug/toggle
5.5PATCH .../multi-role/roles/:roleSlug
5.6POST .../multi-role/roles
5.7POST .../multi-role/simulate-permissions
5.8POST .../multi-role/roles/:roleSlug/apply-preset

5.1 GET /api/projects/{projectId}/multi-role

HTTP

GET /api/projects/685ad30be129932fbb7a1047/multi-role HTTP/1.1
Host: api.example.com
Authorization: Bearer <token>

Response (200)

{
  "success": true,
  "data": {
    "isEnabled": true,
    "defaultRole": "customer",
    "settings": {
      "allowMultipleRoles": false,
      "requireRoleSelection": true,
      "autoAssignDefault": true,
      "dataOwnerField": "createdBy"
    },
    "roles": [
      {
        "slug": "customer",
        "name": "Customer",
        "description": "Default app user role.",
        "isEnabled": true,
        "isCustom": true,
        "signupEndpoint": "customer",
        "requiresApproval": false,
        "requiresPayment": false,
        "requiresKYC": false,
        "defaultPermissions": [],
        "collectionPermissions": [],
        "featurePermissions": {
          "messaging": {
            "email": true,
            "sms": true,
            "push": false,
            "history": true,
            "stats": true
          },
          "integration": {
            "read": true,
            "execute": true
          }
        }
      }
    ]
  }
}

Error (500)

{
  "error": "Failed to get multi-role configuration"
}

5.2 PATCH /api/projects/{projectId}/multi-role/settings

HTTP

PATCH /api/projects/685ad30be129932fbb7a1047/multi-role/settings HTTP/1.1
Host: api.example.com
Authorization: Bearer <token>
Content-Type: application/json

Request body

{
  "isEnabled": true,
  "defaultRole": "customer",
  "settings": {
    "allowMultipleRoles": false,
    "requireRoleSelection": true,
    "autoAssignDefault": true,
    "dataOwnerField": "createdBy"
  }
}

All top-level fields are optional; send only what you want to change.

Response (200)

{
  "success": true,
  "message": "Multi-role settings updated",
  "data": {
    "isEnabled": true,
    "defaultRole": "customer",
    "settings": {
      "allowMultipleRoles": false,
      "requireRoleSelection": true,
      "autoAssignDefault": true,
      "dataOwnerField": "createdBy"
    },
    "roles": [
      {
        "slug": "customer",
        "name": "Customer",
        "description": "Default app user role.",
        "isEnabled": true,
        "isCustom": true,
        "signupEndpoint": "customer",
        "requiresApproval": false,
        "requiresPayment": false,
        "requiresKYC": false,
        "defaultPermissions": [],
        "collectionPermissions": [],
        "featurePermissions": {}
      }
    ]
  }
}

Error (500)

{
  "error": "Failed to update multi-role settings"
}

5.3 GET /api/projects/{projectId}/permissions-matrix

HTTP

GET /api/projects/685ad30be129932fbb7a1047/permissions-matrix HTTP/1.1
Host: api.example.com
Authorization: Bearer <token>

Response (200)

{
  "success": true,
  "data": {
    "collections": [
      {
        "id": "507f1f77bcf86cd799439011",
        "name": "Orders",
        "slug": "orders",
        "permissions": [
          {
            "role": "customer",
            "actions": ["read", "create"],
            "hasConditions": false,
            "dataScope": "own",
            "ownerField": "createdBy"
          }
        ],
        "stateMachine": null
      }
    ],
    "roles": [
      {
        "slug": "customer",
        "name": "Customer"
      }
    ],
    "features": [
      {
        "slug": "customer",
        "featurePermissions": {
          "messaging": {
            "email": true,
            "sms": true
          }
        }
      }
    ]
  }
}

Error (500)

{
  "error": "Failed to get permissions matrix"
}

5.4 PATCH /api/projects/{projectId}/multi-role/roles/{roleSlug}/toggle

HTTP

PATCH /api/projects/685ad30be129932fbb7a1047/multi-role/roles/customer/toggle HTTP/1.1
Host: api.example.com
Authorization: Bearer <token>
Content-Type: application/json

Request body

{
  "isEnabled": false
}

Response (200)

{
  "success": true,
  "message": "Role disabled",
  "data": {
    "slug": "customer",
    "name": "Customer",
    "description": "Default app user role.",
    "isEnabled": false,
    "isCustom": true,
    "signupEndpoint": "customer",
    "requiresApproval": false,
    "requiresPayment": false,
    "requiresKYC": false,
    "defaultPermissions": [],
    "collectionPermissions": [],
    "featurePermissions": {}
  }
}

If isEnabled is true, message is "Role enabled".

Error (500)

{
  "error": "Failed to toggle role"
}

5.5 PATCH /api/projects/{projectId}/multi-role/roles/{roleSlug}

Updates a single role. You may send partial fields (e.g. only name or only featurePermissions). defaultPermissions / collectionPermissions are normalized together when either is present.

featurePermissions: use resource/action keys from §2.3 and components/schemas/AppRoleFeaturePermissions in openapi.yaml (same shape as POST .../roles).

HTTP

PATCH /api/projects/685ad30be129932fbb7a1047/multi-role/roles/customer HTTP/1.1
Host: api.example.com
Authorization: Bearer <token>
Content-Type: application/json

Request body (example — partial update)

{
  "name": "Customer (updated)",
  "description": "B2C buyers",
  "featurePermissions": {
    "messaging": {
      "email": true,
      "sms": false,
      "push": false,
      "history": true,
      "stats": true
    }
  }
}

Request body (example — collection permissions + dataScope)

{
  "collectionPermissions": [
    {
      "collectionId": "507f1f77bcf86cd799439011",
      "collectionSlug": "orders",
      "actions": ["read", "create", "update"],
      "dataScope": "own",
      "ownerField": "createdBy",
      "conditions": {}
    }
  ]
}

Response (200)

{
  "success": true,
  "message": "Role updated",
  "data": {
    "slug": "customer",
    "name": "Customer (updated)",
    "description": "B2C buyers",
    "isEnabled": true,
    "isCustom": true,
    "signupEndpoint": "customer",
    "requiresApproval": false,
    "requiresPayment": false,
    "requiresKYC": false,
    "defaultPermissions": [],
    "collectionPermissions": [
      {
        "collectionId": "507f1f77bcf86cd799439011",
        "collectionSlug": "orders",
        "actions": ["read", "create", "update"],
        "conditions": {},
        "dataScope": "own",
        "ownerField": "createdBy"
      }
    ],
    "featurePermissions": {
      "messaging": {
        "email": true,
        "sms": false,
        "push": false,
        "history": true,
        "stats": true
      }
    }
  }
}

Error (404)

{
  "error": "Role not found"
}

Error (500)

{
  "error": "Failed to update role"
}

5.6 POST /api/projects/{projectId}/multi-role/roles

Creates a new app role. Required: slug, name, signupEndpoint.

featurePermissions: optional; if set, align with §2.3 and the OpenAPI schema AppRoleFeaturePermissions (spec).

HTTP

POST /api/projects/685ad30be129932fbb7a1047/multi-role/roles HTTP/1.1
Host: api.example.com
Authorization: Bearer <token>
Content-Type: application/json

Request body

{
  "slug": "vendor",
  "name": "Vendor",
  "description": "Seller role",
  "signupEndpoint": "vendor",
  "requiresApproval": true,
  "requiresPayment": false,
  "requiresKYC": false,
  "defaultPermissions": [],
  "collectionPermissions": [],
  "featurePermissions": {
    "messaging": {
      "email": true,
      "sms": true,
      "push": true,
      "history": true,
      "stats": true
    },
    "integration": {
      "read": true,
      "execute": true,
      "create": false
    },
    "storage": {
      "read": true,
      "upload": true,
      "delete": false
    }
  }
}

Response (201)

{
  "success": true,
  "message": "Custom role added",
  "data": {
    "slug": "vendor",
    "name": "Vendor",
    "description": "Seller role",
    "isEnabled": true,
    "isCustom": true,
    "signupEndpoint": "vendor",
    "requiresApproval": true,
    "requiresPayment": false,
    "requiresKYC": false,
    "defaultPermissions": [],
    "collectionPermissions": [],
    "featurePermissions": {
      "messaging": {
        "email": true,
        "sms": true,
        "push": true,
        "history": true,
        "stats": true
      },
      "integration": {
        "read": true,
        "execute": true,
        "create": false
      },
      "storage": {
        "read": true,
        "upload": true,
        "delete": false
      }
    }
  }
}

Error (400) — missing slug, name, or signupEndpoint

{
  "error": "Missing required fields",
  "required": ["slug", "name", "signupEndpoint"]
}

Error (500)

{
  "error": "Failed to add custom role"
}

5.7 POST /api/projects/{projectId}/multi-role/simulate-permissions

Simulates what the app feature gate would do for a given role slug and either:

  • operationId (from openapi.yaml), or
  • method + pathname (or path as alias for pathname).

HTTP

POST /api/projects/685ad30be129932fbb7a1047/multi-role/simulate-permissions HTTP/1.1
Host: api.example.com
Authorization: Bearer <token>
Content-Type: application/json

Request (by path)

{
  "role": "customer",
  "method": "POST",
  "pathname": "/api/messaging/projects/685ad30be129932fbb7a1047/messaging/email"
}

Request (by OpenAPI operationId)

{
  "role": "customer",
  "operationId": "sendEmail"
}

Response — mapped path, allowed

{
  "success": true,
  "allowed": true,
  "reason": "allowed",
  "evaluated": {
    "role": "customer",
    "pathname": "/api/messaging/projects/685ad30be129932fbb7a1047/messaging/email",
    "method": "POST",
    "resource": "messaging",
    "action": "send_email"
  }
}

Response — mapped path, denied by featurePermissions

{
  "success": true,
  "allowed": false,
  "reason": "feature_not_allowed",
  "evaluated": {
    "role": "viewer",
    "pathname": "/api/messaging/projects/685ad30be129932fbb7a1047/messaging/email",
    "method": "POST",
    "resource": "messaging",
    "action": "send_email"
  }
}

Response — no gate for this path (unmapped)

{
  "success": true,
  "allowed": true,
  "reason": "no_feature_gate_for_path",
  "evaluated": {
    "role": "customer",
    "pathname": "/api/projects/685ad30be129932fbb7a1047/some-unmapped-route",
    "method": "GET"
  }
}

Response — operationId not in server map

{
  "success": true,
  "allowed": true,
  "reason": "no_feature_gate_for_operation_id",
  "evaluated": {
    "role": "customer",
    "operationId": "registerUser"
  }
}

Error (400) — missing role or missing both path and operationId:

{
  "error": "role (or roleSlug) is required, plus either operationId or both method and pathname (or path)"
}

Error (404) — role slug not found on project:

{
  "error": "Role not found for this project"
}

Error (500)

{
  "error": "Simulation failed"
}

5.8 POST /api/projects/{projectId}/multi-role/roles/{roleSlug}/apply-preset

Applies only featurePermissions from a named bundle; does not change collection CRUD or dataScope.

HTTP

POST /api/projects/685ad30be129932fbb7a1047/multi-role/roles/customer/apply-preset HTTP/1.1
Host: api.example.com
Authorization: Bearer <token>
Content-Type: application/json

Request body

{
  "preset": "viewer"
}

Valid preset values: admin, user, viewer.

Response (200)

{
  "success": true,
  "message": "Applied Viewer preset",
  "data": {
    "slug": "customer",
    "name": "Customer",
    "description": "Default app user role.",
    "isEnabled": true,
    "isCustom": true,
    "signupEndpoint": "customer",
    "requiresApproval": false,
    "requiresPayment": false,
    "requiresKYC": false,
    "defaultPermissions": [],
    "collectionPermissions": [],
    "featurePermissions": {
      "messaging": {
        "email": false,
        "sms": false,
        "push": false,
        "history": true,
        "stats": true
      },
      "integration": {
        "create": false,
        "read": true,
        "update": false,
        "delete": false,
        "execute": false,
        "test": false,
        "export": true,
        "read_usage": true
      }
    }
  }
}

Error (400) — bad preset:

{
  "error": "Invalid preset",
  "valid": ["admin", "user", "viewer"]
}

Error (404) — role not found:

{
  "error": "Role not found"
}

Error (500)

{
  "error": "Failed to apply preset"
}

6. End-to-end workflow (simulation)

6.1 Narrative

  1. Provision multi-role for the project (GET /multi-role initializes if needed).
  2. Define roles (POST/PATCH roles) and collection permissions for CRUD and row scope.
  3. Set feature bundles either manually on featurePermissions or via apply-preset.
  4. Before changing the mobile/web app, call simulate-permissions with the same method + pathname (or operationId) your client will use, for each role slug you care about.
  5. Ship the app: end users authenticate and receive a JWT with projectId + customRole.
  6. On gated routes, the server enforces featurePermissions; if denied, the client receives feature_not_allowed (403).

6.2 Sequence (mermaid)

sequenceDiagram
  participant Admin as Dashboard (admin JWT)
  participant API as Admin API /api/projects/:id
  participant Store as Role configuration
  participant App as App user (project JWT)
  participant Endpoint as Product API

  Admin->>API: PATCH role featurePermissions / apply-preset
  API->>Store: Save roles[].featurePermissions
  Admin->>API: POST simulate-permissions (method+pathname)
  API->>Store: Load role by slug
  API-->>Admin: { allowed, reason, evaluated }

  App->>Endpoint: POST .../messaging/email (Bearer: project JWT)
  Endpoint->>Endpoint: Project access check
  Endpoint->>Endpoint: Feature permission check
  Endpoint->>Store: featurePermissions for (projectId, customRole)
  alt allowed
    Endpoint-->>App: 201 + body
  else denied
    Endpoint-->>App: 403 feature_not_allowed
  end

6.3 Concrete simulation walkthrough

  1. Goal: Confirm whether role viewer may call POST /api/messaging/projects/{projectId}/messaging/email.

  2. Configure viewer.featurePermissions so messaging.email is false (or apply viewer preset).

  3. Call simulate:

    POST /api/projects/685ad30be129932fbb7a1047/multi-role/simulate-permissions
    Content-Type: application/json
    
    {
      "role": "viewer",
      "method": "POST",
      "pathname": "/api/messaging/projects/685ad30be129932fbb7a1047/messaging/email"
    }
    
  4. Expect allowed: false, reason: "feature_not_allowed", evaluated.resource: "messaging", evaluated.action: "send_email".

  5. Optional: Same check via operationId (from OpenAPI):

    {
      "role": "viewer",
      "operationId": "sendEmail"
    }
    
  6. Runtime: An app user JWT with customRole: "viewer" calling that route receives 403 with error: "feature_not_allowed" when the role’s featurePermissions deny the action.


7. OpenAPI, SDKs, and tools

  • Contract: Download OpenAPI — see POST /api/projects/{projectId}/multi-role/simulate-permissions for examples and GET .../permissions-matrix for the matrix.
  • SDKs: Use generated clients from SDKs for typed calls to the same endpoints.
  • featurePermissions on add/update role: schema AppRoleFeaturePermissions in OpenAPI — align keys with §2.3 so gates behave as you expect.

POST /api/projects/{projectId}/multi-role/roles includes a fullTesting example: optional fields plus all featurePermissions resource buckets (messaging through webhooks) so you can copy from Swagger UI / Redoc and exercise permissions end-to-end. The same shape appears under 201 as the illustrative success body.