Skip to content

Team Management & RBAC ​

Reference for the team-member / role-based-access-control (RBAC) system that lets a customer account owner invite team members, assign granular permissions, and temporarily sign in as a member.

Concepts ​

  • Account owner a top-level role: "customer" user (accountOwnerUID = null, isAccountOwner = true). Owns all account data and manages the team. Has full access.
  • Team member a role: "customer" sub-user with accountOwnerUID set to the owner's userUID. Shares the owner's data scope; access is gated by RBAC permissions.
  • Role a reusable named permission set scoped to one owner. Members can hold multiple roles; effective permissions are the union.
  • Permission override an optional per-member PermissionMap merged (OR) on top of their roles for one-off grants.

Permission model ​

Source of truth: src/types/permissions.types.ts (mirrored on the frontend in app/src/types/permissions.ts).

  • Resources: campaigns, agents, analysis, contacts, numbers, analytics, tools, inbound, knowledge_base, chatbot, workflow, whatsapp_automation, byok, byon, billing, team, crm, tickets, commerce, notifications, automation, connecter.
  • Actions: view, create, edit, delete.
  • Capabilities (beyond CRUD, per RESOURCE_CAPABILITIES): crm → view_all, export, transfer; tickets → manage.
  • Grants (per RESOURCE_GRANTS): sub-section grants (e.g. crm → sub.contacts/sub.companies/sub.opportunities/sub.tasks/sub.segments/sub.marketing/sub.agentic; tickets → sub.settings/sub.routing/sub.sla/sub.integrations; commerce → sub.stores/sub.inventory/sub.orders/sub.transactions) and widget grants (dashboard, opportunities, marketing, segments widgets). A subset is DATA-enforced server-side (API_ENFORCED_WIDGETS) — the endpoint omits the widget's data for a team member who lacks the grant, not just hides it in the UI.
  • PermissionMap: Record<Resource, { view, create, edit, delete, ...capabilities, ...grants }>.
  • Scope (all | assigned): assigned restricts a member to their self-assigned leads (enforced for the crm resource via crmLeadScope.middleware.ts; the Agent system role uses assigned). Other resources treat assigned as reserved.

Helpers: emptyPermissionMap(), fullPermissionMap(), normalizePermissionMap(), mergePermissionMaps(...) (boolean-OR union), hasPermission(map, resource, action), hasCapability(...), hasGrant(...).

Data model ​

  • models/Role.ts collection Role. roleUID (role_ prefix), accountOwnerUID, orgUID, name (unique per owner, case-insensitive), description, isSystem, systemKey, permissions (normalized on write), scope.

  • models/User.ts new fields: accountOwnerUID, isAccountOwner, roleUIDs[], permissionsOverride.

  • System roles are seeded per owner on demand via roleRepository.ensureSystemRoles(ownerUID, orgUID) (idempotent). There are four presets (SYSTEM_ROLE_TEMPLATES in repositories/role.repository.ts):

    systemKeyNameSummaryScope
    org_adminOrg AdminFull permission map (a trusted deputy; mirrors the owner's bypass).all
    managerManagerView/create/edit across campaigns, agents, CRM, commerce, tickets, etc. (no delete). Holds sensitive capabilities (crm export + transfer, tickets manage) and all dashboard/CRM/marketing widgets. View-only on team and billing.all
    agentAgentDay-to-day operator: view/create on leads, tickets, campaigns; operational dashboard widgets only (no revenue/intelligence widgets); no export/transfer/delete.assigned
    viewerViewerRead-only across the core sections; dashboard overview card only.all

    Seeding is wired (idempotent, non-fatal) on the three real owner-creation paths (auth.controller self-signup, admin.controller.createCustomer, signup.service) and an operator backfill script exists at scripts/backfill-system-roles.ts (documented, not auto-run). Editing a system role mutates only that owner's copy (roles are owner-scoped), so an owner may tweak a preset without affecting other orgs.

Data migration (automatic) ​

The RBAC fields are backfilled onto existing users automatically on server startup server.ts calls migrateTeamFields() (services/team-migration.service.ts) which idempotently sets isAccountOwner=true, accountOwnerUID=null, roleUIDs=[], permissionsOverride=null only where missing. No manual step is required in production.

A manual runner is still available for one-off use against a specific DB:

bash
npx tsx src/scripts/backfill-team-fields.ts

Feature gating (superadmin → org → customer) ​

Team management is a plan feature, team_management:

  • SuperAdmin → Org: the org's plan.features must include team_management (set in the SuperAdmin org editor).
  • Org → Customer: the customer (account owner) must have team_management assigned (set in the Admin customer editor / signup defaults).

Enforced by requireFeatureAccess(...FEATURE_MAP.manageTeam) on the /account/roles and /account/team routes (superadmin/admin bypass; /account/team/impersonate/stop stays auth-only). The frontend hides the Team tab unless the owner has the feature (usePermission().isOwner && hasFeature('team_management')).

Max team members (org-configurable) ​

Organization.signupConfig.maxTeamMembers (default 5, range 0–500) caps how many members a customer account owner can create effectively "N members + 1 owner". Admins set it in Signup Configuration. inviteMember enforces it (403 TEAM_LIMIT_REACHED), and GET /account/team returns limits: { maxTeamMembers, used } so the UI shows "X of Y used" and disables Invite at capacity.

The core mechanism data-scope resolution in verifyJwt ​

All customer data is queried by req.userId (== req.userUID). verifyJwt (middleware/auth.middleware.ts) resolves this so team members transparently operate on the owner's data with no controller changes:

Principalreq.userId / req.userUIDreq.actorUIDreq.effectivePermissionsreq.effectiveFeatures
Owner / admin / superadminown userUIDown userUIDfull mapown features
Team memberowner's userUIDmember's userUIDresolved from roles + overrideowner's features

If a member's owner is missing or suspended, access is blocked (403). A suspended member is also blocked (existing status !== active check).

Feature inheritance (important) ​

A team member has no features of their own features belong to the account owner. verifyJwt resolves req.effectiveFeatures to the owner's feature set for members. Both layers honor this:

  • Backend: requireFeatureAccess uses req.effectiveFeatures for team members (not the member's empty features), so feature-gated routes (campaigns, agents, contacts, …) are reachable. RBAC requirePermission then restricts what the member can do.
  • Frontend: /auth/me and login return effectiveFeatures for members; useFeature.hasFeature uses it so the sidebar/gating match the owner's plan.

Without this, a member's data calls would 403 at the feature layer even though the sidebar looked correct (this was a real bug see "Feature inheritance" fix).

Enforcement ​

  • Backend: requirePermission(resource, action) (middleware/rbac.middleware.ts) is attached to customer-facing routes (GET→view, POST→create, PUT/PATCH→edit, DELETE→delete). Owners / admin / superadmin bypass; team members must hold the permission or get 403 with code: "PERMISSION_DENIED".
  • Owner-only endpoints use requireAccountOwner (team members get 403 OWNER_ONLY).
  • Frontend: usePermission() + <PermissionGate> hide/disable nav items and buttons. FeatureRoute accepts a resource prop and redirects members lacking view.

API ​

Mounted in app.ts: app.use("/account/team", teamRoutes) then app.use("/account", roleRoutes).

Roles (/account, owner-only) ​

MethodPathDescription
GET/account/permissions/catalogResource/action catalog for the matrix UI
GET/account/rolesList roles (seeds the 4 system presets on first call)
POST/account/rolesCreate a custom role
PUT/account/roles/:roleUIDUpdate a role
DELETE/account/roles/:roleUIDDelete (system + assigned roles blocked)

Team (/account/team, owner-only unless noted) ​

MethodPathDescription
GET/account/teamList members (with role names)
POST/account/team/inviteInvite (`method: "link"
GET/account/team/:memberUIDMember detail
PUT/account/team/:memberUIDUpdate name / email / phone / roles / override / status
POST/account/team/:memberUID/resend-inviteResend set-password link
POST/account/team/:memberUID/reset-passwordOwner sets + emails a new password
DELETE/account/team/:memberUIDRemove member
POST/account/team/:memberUID/impersonateStart impersonation (owner-only)
POST/account/team/impersonate/stopStop impersonation (any auth)

Invitation flows ​

  • Link (default, secure): creates a pending member with a random password, emails a /setup-password?token=... link (reuses the onboarding EmailToken flow). The member sets their own password and the account activates.
  • Password: owner sets an initial password (validated ≥8, ≤128). The member is created active with isPasswordSet/emailVerified true and the credentials are emailed.

Emails: sendTeamInviteEmail / sendTeamCredentialsEmail in services/email/email.actions.ts.

Editing a member's contact details ​

PUT /account/team/:memberUID (updateMember) accepts, in addition to roles / override / status, the member's name, email, and phone so an owner can correct or fill in contact details after invite (the invite requires a phone, but a legacy member may lack one). Every field is optional; only provided fields change.

  • Email: normalized (trimmed + lowercased) and validated with the same EMAIL_REGEX as invite. A duplicate guard rejects an email already registered to a different user (409); re-sending the member's own email is a no-op.
  • Phone: must be E.164 (+[1-9]\d{7,14}), matching invite. Passing "" or null clears it.
  • Frontend: the EditMemberSheet (components/team/TeamManagementTab.tsx) exposes Name / Email / Phone inputs with the same client-side validation before calling teamService.update. TeamMember.phone is surfaced on the list/detail payloads (serializeMember).

Agent sharing ​

By default a team member with agents:view would see all of the owner's agents (shared-account scope). Agent sharing narrows this so an owner shares specific agents with specific members.

  • models/Agent.ts fields: sharedWithAll (bool) and sharedWithUIDs (member UIDs).
  • Access rules live in utils/agentAccess.utils.ts:
    • canActorAccessAgent(req, agent) owners/admins/superadmins always pass; team members pass only if sharedWithAll OR their actorUID is in sharedWithUIDs.
    • agentVisibilityFilter(req) a Mongo $or filter used to list only the agents a member may see ({} for full-access principals).
  • Listing: campaign.controller.getCampaigns uses agentRepository.findByUserVisible(userId, agentVisibilityFilter(req)), so a member's /agents page only shows agents shared with them.
  • Per-agent guards: agent.controller gates get/update/delete/clone/test with agentAccessible(req, agent). Campaign flows also guard: uploadCampaign and updateCampaignAgent reject a member trying to use an unshared agent.
  • Sharing endpoint (owner-only): PUT /agents/:agentId/share{ sharedWithAll?, sharedWithUIDs? }. It validates the target UIDs belong to the owner's team. Sharing fields are stripped from the generic update-agent payload so they can only change via this endpoint.
  • Cleanup: removing a team member $pulls their UID from all agents' sharedWithUIDs.
  • Frontend: ShareAgentDialog (owner-only, requires team_management) in /agents — "share with everyone" toggle or per-member checkboxes; calls agentService.share.

Impersonation ​

  • POST /account/team/:memberUID/impersonate (owner-only) issues a short-lived (30m) access token whose payload carries impersonate: true + impersonatorUID. No refresh token is issued. Nested impersonation is blocked.
  • verifyJwt recognizes the token and acts as the member (identity + permissions + features, data scoped to the owner) while exposing req.impersonatorUID for auditing. Start/stop are logged.
  • Frontend: AuthContext.startImpersonation saves the owner's session under imp_owner_* localStorage keys, swaps to the member session, re-hydrates via /auth/me (so effectiveFeatures/effectivePermissions load), and shows a persistent banner. "Return to your account" (stopImpersonation) restores the owner session.
  • Entry points: the Team settings page (per-member "Sign in as member" action) and a drop-up menu on the sidebar profile (the ↻ button) that lists team members. Both are owner-only and require team_management.
  • Self-service password change: POST /auth/change-password (JWT) validates the current password, rotates tokens (keeps the current device signed in, invalidates others), and targets req.actorUID so a member changes their own password, not the owner's.
  • RBAC toast suppression: the frontend axios interceptor no longer auto-toasts expected 403 RBAC denials (code: PERMISSION_DENIED / OWNER_ONLY), so background calls to resources a member can't access stay silent (the UI already hides them).
  • Legacy feature sanitizing: validateFeatureArray now drops unknown/legacy feature keys instead of hard-failing, so saving orgs/customers with stale stored features works.

Testing ​

Backend (npx vitest --run):

  • permissions-catalog.test.ts, role-repository.test.ts, user-team-fields.test.ts, auth-rbac-middleware.test.ts, team-api.integration.test.ts, resource-permission-enforcement.test.ts, impersonation.integration.test.ts, change-password.integration.test.ts, feature-utils.test.ts, agent-sharing.test.ts, feature-access-team-member.test.ts.

Frontend (npx vitest --run in app/): hooks/__tests__/usePermission.test.ts.

Last updated: