Multi-tenancy
RESTHeart CloudA user can belong to multiple teams simultaneously, each with a different role.
|
Tip
|
9.6.0 breaking change: the |
|
Warning
|
9.12.0 breaking change: the tokens restheart-accounts issues carry no |
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 |
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 byteam-claim-name(defaultteam). -
The reissued JWT is delivered per the optional
deliveryquery parameter —cookie(default;Set-Cookiecarries the new JWT) orbody(since 9.6.0;access_token/token_type/expires_inin the response body plus theAuth-Tokenheader, no cookie).
{ "team": { "_id": {"$oid": "64a1b2c3d4e5f6a7b8c9d0e2"}, "role": "owner" } }
Error responses
| Status | Reason |
|---|---|
|
Missing or blank |
|
Not authenticated |
|
User does not belong to the requested team, or account not active |
|
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.