Edit Page

Multi-tenancy

RESTHeart Cloud
Prefer not to run it yourself? Ulabase is this, hosted, with sign-up, payments and an MCP server already on. Free to start. Get started →

A user can belong to multiple teams simultaneously, each with a different role.

Tip

9.6.0 breaking change: the team JWT claim and the POST /auth/switch-team response changed from a scalar id to { _id, role }. ACL filters using @user.team as the team id must migrate to @user.team._id. @user.team.role is now available for predicate-based gating (e.g. equals[@user.team.role, "owner"]). Clients reading the team claim must adapt: team._id replaces team.

Warning

9.12.0 breaking change: the tokens restheart-accounts issues carry no team claim any more. ACL rules read the active team through the @team variable, from the membership provider, on every login path: replace @user.team._id with @team._id and @user.team.role with @team.role in predicates, readFilter and mergeRequest. Clients that read the team from the JWT read it from GET /auth/teams or the POST /auth/switch-team response, or the deployment lists "@team" in jwtConfigProvider.account-properties-claims to have it in every token, POST /token included.

The active team and its role are read by ACL rules through @team; switching teams reissues the JWT without requiring a new login, and the response says the team.

Note

The membership model — field names, role vocabulary, storage collection — can be customised via the MembershipProvider SPI (9.4.1+). This page describes the built-in default behaviour.

Data model

Since 9.12.0 who belongs to a team, and as what, is kept in one place: the team’s members. The user document keeps only the id of its active team, which is the user’s own choice. The DefaultMembershipProvider stores team identifiers as MongoDB ObjectId values:

// teams collection
{
  "_id":       { "$oid": "64a1b2c3d4e5f6a7b8c9d0e1" },
  "name":      "Acme Corp",
  "createdBy": "alice@example.com",
  "createdAt": { "$date": "…" },
  "members": [
    { "userId": "alice@example.com", "role": "owner",  "joinedAt": { "$date": "…" } },  // membership role
    { "userId": "bob@example.com",   "role": "member", "joinedAt": { "$date": "…" } }
  ]
}

// users collection
{
  "_id":   "alice@example.com",
  "roles": ["user"],                                   // system ACL role (from default-role)
  "team":  { "_id": { "$oid": "64a1b2c3d4e5f6a7b8c9d0e1" } }   // the active team
}
Note
roles contains the system ACL role (e.g. user), not the membership role. The membership role (owner/member) is per-team and stored in the team’s members; @team.role reads it from there.

A membership exists only in the team’s members: a user listed nowhere in them cannot use the team, and has no active membership while that team is the active one. Before 9.12.0 the user document also carried a teams array and team.role; they are neither read nor written any more, and accountsConfig.migrate-memberships removes them from existing data (see Configuration). Custom providers can use a different identifier type — see Custom Membership Providers.

Adding a user to a second team

Inviting an existing user to a different team adds them to that team’s members without requiring account reactivation (the user is already active):

POST /auth/invite
Authorization: Bearer <owner-or-admin-token>
Content-Type: application/json

{ "email": "alice@example.com", "role": "member" }
  • If the user already belongs to the caller’s team → 409 Conflict.

  • If the user is new → standard invite flow (see Team Invitations).

  • If the user is active in another team → invitation sent; membership added when the user accepts; 201 Created.

List team memberships

GET /auth/teams — requires authentication.

Returns all teams the current user belongs to. The entry whose active field is true is the active team, the one @team resolves to.

GET /auth/teams
Authorization: Bearer <token>
[
  { "id": {"$oid": "64a1b2c3d4e5f6a7b8c9d0e1"}, "name": "Acme Corp",  "description": "Main workspace", "role": "owner",  "active": true  },
  { "id": {"$oid": "64a1b2c3d4e5f6a7b8c9d0e2"}, "name": "Other Corp", "role": "member", "active": false }
]

The description field is included when the team document has a non-null description. Omitted otherwise.

The id field is serialized as MongoDB extended JSON, preserving the native BSON type. For ObjectId team identifiers (the default) it is {"$oid": "…​"}. Custom providers that use string or integer team IDs will produce a plain JSON string or number.

Switch active team

POST /auth/switch-team — requires authentication.

Verifies that the user belongs to the requested team, makes it the active team, issues a new JWT and updates the auth cookie. The new JWT carries the user’s system roles from the database. It carries no team and no team role: ACL rules read them through @team. The browser (or client) does not need to re-enter credentials.

POST /auth/switch-team
Authorization: Bearer <token>
Content-Type: application/json

{ "teamId": {"$oid": "64a1b2c3d4e5f6a7b8c9d0e2"} }

The teamId value must be in MongoDB extended JSON format, matching the id field returned by GET /auth/teams.

On success (200 OK):

  • The response body carries the new active team as {_id, role}, under the key set by team-claim-name (default team).

  • The reissued JWT is delivered per the optional delivery query parameter — cookie (default; Set-Cookie carries the new JWT) or body (since 9.6.0; access_token/token_type/expires_in in the response body plus the Auth-Token header, no cookie).

{ "team": { "_id": {"$oid": "64a1b2c3d4e5f6a7b8c9d0e2"}, "role": "owner" } }

Error responses

Status Reason

400

Missing or blank teamId

401

Not authenticated

403

User does not belong to the requested team, or account not active

404

User document not found

Typical frontend flow

// 1. Load team list on app init
authService.getTeams()  // GET /auth/teams
  .subscribe(teams => this.teams.set(teams));

// 2. User picks a different team
authService.switchTeam(teamId)  // POST /auth/switch-team
  .subscribe(res => {
    this.activeTeam.set(res.team);  // { _id, role }
    router.navigate(['/time']);
  });

After switchTeam, read the new team and role from the switch-team response, or call GET /auth/teams and take the entry with active: true. GET /token does not help here: it returns the username and the system roles, and no team.

ACL considerations

No manual ACL entries are needed for /auth/teams or /auth/switch-team. Both endpoints register their own allow rules via ACLRegistry at startup. They are disabled when accountsConfig.membership-endpoints-enabled: false.

See Custom Membership Providers to replace the built-in storage strategy.