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 withaccountOwnerUIDset to the owner'suserUID. 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
PermissionMapmerged (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):assignedrestricts a member to their self-assigned leads (enforced for thecrmresource viacrmLeadScope.middleware.ts; theAgentsystem role usesassigned). Other resources treatassignedas reserved.
Helpers: emptyPermissionMap(), fullPermissionMap(), normalizePermissionMap(), mergePermissionMaps(...) (boolean-OR union), hasPermission(map, resource, action), hasCapability(...), hasGrant(...).
Data model
models/Role.tscollectionRole.roleUID(role_prefix),accountOwnerUID,orgUID,name(unique per owner, case-insensitive),description,isSystem,systemKey,permissions(normalized on write),scope.models/User.tsnew 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_TEMPLATESinrepositories/role.repository.ts):systemKeyName Summary Scope org_adminOrg Admin Full permission map (a trusted deputy; mirrors the owner's bypass). allmanagerManager View/create/edit across campaigns, agents, CRM, commerce, tickets, etc. (no delete). Holds sensitive capabilities ( crmexport + transfer,ticketsmanage) and all dashboard/CRM/marketing widgets. View-only onteamandbilling.allagentAgent Day-to-day operator: view/create on leads, tickets, campaigns; operational dashboard widgets only (no revenue/intelligence widgets); no export/transfer/delete. assignedviewerViewer Read-only across the core sections; dashboard overview card only. allSeeding is wired (idempotent, non-fatal) on the three real owner-creation paths (
auth.controllerself-signup,admin.controller.createCustomer,signup.service) and an operator backfill script exists atscripts/backfill-team-fields.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:
npx tsx src/scripts/backfill-team-fields.tsFeature gating (superadmin → org → customer)
Team management is a plan feature, team_management:
- SuperAdmin → Org: the org's
plan.featuresmust includeteam_management(set in the SuperAdmin org editor). - Org → Customer: the customer (account owner) must have
team_managementassigned (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:
| Principal | req.userId / req.userUID | req.actorUID | req.effectivePermissions | req.effectiveFeatures |
|---|---|---|---|---|
| Owner / admin / superadmin | own userUID | own userUID | full map | own features |
| Team member | owner's userUID | member's userUID | resolved from roles + override | owner'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:
requireFeatureAccessusesreq.effectiveFeaturesfor team members (not the member's emptyfeatures), so feature-gated routes (campaigns, agents, contacts, …) are reachable. RBACrequirePermissionthen restricts what the member can do. - Frontend:
/auth/meandloginreturneffectiveFeaturesfor members;useFeature.hasFeatureuses 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 get403withcode: "PERMISSION_DENIED". - Owner-only endpoints use
requireAccountOwner(team members get403 OWNER_ONLY). - Frontend:
usePermission()+<PermissionGate>hide/disable nav items and buttons.FeatureRouteaccepts aresourceprop and redirects members lackingview.
API
Mounted in app.ts: app.use("/account/team", teamRoutes) then app.use("/account", roleRoutes).
Roles (/account, owner-only)
| Method | Path | Description |
|---|---|---|
| GET | /account/permissions/catalog | Resource/action catalog for the matrix UI |
| GET | /account/roles | List roles (seeds the 4 system presets on first call) |
| POST | /account/roles | Create a custom role |
| PUT | /account/roles/:roleUID | Update a role |
| DELETE | /account/roles/:roleUID | Delete (system + assigned roles blocked) |
Team (/account/team, owner-only unless noted)
| Method | Path | Description |
|---|---|---|
| GET | /account/team | List members (with role names) |
| POST | /account/team/invite | Invite (`method: "link" |
| GET | /account/team/:memberUID | Member detail |
| PUT | /account/team/:memberUID | Update name / email / phone / roles / override / status |
| POST | /account/team/:memberUID/resend-invite | Resend set-password link |
| POST | /account/team/:memberUID/reset-password | Owner sets + emails a new password |
| DELETE | /account/team/:memberUID | Remove member |
| POST | /account/team/:memberUID/impersonate | Start impersonation (owner-only) |
| POST | /account/team/impersonate/stop | Stop impersonation (any auth) |
Invitation flows
- Link (default, secure): creates a
pendingmember with a random password, emails a/setup-password?token=...link (reuses the onboardingEmailTokenflow). The member sets their own password and the account activates. - Password: owner sets an initial password (validated ≥8, ≤128). The member is created
activewithisPasswordSet/emailVerifiedtrue 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_REGEXas 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""ornullclears it. - Frontend: the
EditMemberSheet(components/team/TeamManagementTab.tsx) exposes Name / Email / Phone inputs with the same client-side validation before callingteamService.update.TeamMember.phoneis 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.tsfields:sharedWithAll(bool) andsharedWithUIDs(member UIDs).- Access rules live in
utils/agentAccess.utils.ts:canActorAccessAgent(req, agent)owners/admins/superadmins always pass; team members pass only ifsharedWithAllOR theiractorUIDis insharedWithUIDs.agentVisibilityFilter(req)a Mongo$orfilter used to list only the agents a member may see ({}for full-access principals).
- Listing:
campaign.controller.getCampaignsusesagentRepository.findByUserVisible(userId, agentVisibilityFilter(req)), so a member's/agentspage only shows agents shared with them. - Per-agent guards:
agent.controllergates get/update/delete/clone/test withagentAccessible(req, agent). Campaign flows also guard:uploadCampaignandupdateCampaignAgentreject 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 genericupdate-agentpayload so they can only change via this endpoint. - Cleanup: removing a team member
$pulls their UID from all agents'sharedWithUIDs. - Frontend:
ShareAgentDialog(owner-only, requiresteam_management) in/agents— "share with everyone" toggle or per-member checkboxes; callsagentService.share.
Impersonation
POST /account/team/:memberUID/impersonate(owner-only) issues a short-lived (30m) access token whose payload carriesimpersonate: true+impersonatorUID. No refresh token is issued. Nested impersonation is blocked.verifyJwtrecognizes the token and acts as the member (identity + permissions + features, data scoped to the owner) while exposingreq.impersonatorUIDfor auditing. Start/stop are logged.- Frontend:
AuthContext.startImpersonationsaves the owner's session underimp_owner_*localStorage keys, swaps to the member session, re-hydrates via/auth/me(soeffectiveFeatures/effectivePermissionsload), 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.
Related changes
- Self-service password change:
POST /auth/change-password(JWT) validates the current password, rotates tokens (keeps the current device signed in, invalidates others), and targetsreq.actorUIDso a member changes their own password, not the owner's. - RBAC toast suppression: the frontend axios interceptor no longer auto-toasts expected
403RBAC 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:
validateFeatureArraynow 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.