Custom Membership Providers
RESTHeart Cloud|
Note
|
The |
By default, restheart-accounts stores team membership directly on the user document using the tenant / tenants schema.
For applications that already have a domain-specific membership model — different field names, a separate collection, additional metadata such as joinedAt, billing references, or a different role vocabulary — the MembershipProvider SPI lets you plug in your own storage strategy without forking the plugin.
How it works
Every membership read/write inside restheart-accounts is routed through a single MembershipProvider instance.
The built-in DefaultMembershipProvider is used automatically; custom implementations replace it at startup via AccountsService.
| SPI method | Called by |
|---|---|
|
|
|
|
|
|
|
JWT issuance after login, verification, activation, OAuth callback |
|
|
|
|
|
|
|
|
Tenant ID types
All SPI methods that receive or return a tenant identifier use org.bson.BsonValue rather than String.
This means the identifier is stored and passed around in its native MongoDB type — typically BsonObjectId, but the SPI does not impose any constraint.
A custom provider is free to use BsonInt32, BsonString, or any other type its data model requires.
At HTTP boundaries (request bodies, response bodies), tenant IDs are serialized as MongoDB extended JSON:
-
An ObjectId tenant appears as
{"$oid": "64a1b2c3…"}inGET /auth/tenantsandPOST /auth/switch-tenantresponses. -
Clients must send the same format in the
POST /auth/switch-tenantrequest body.
This is handled automatically by BsonUtils.parse (input) and BsonUtils.toJson (output) — no custom conversion helpers are needed in providers.
Configuration
Three keys are available under accountsConfig (all optional; defaults preserve 9.4 behaviour):
accountsConfig:
# JWT claim name used to carry the active tenant identifier.
# Default: "tenant". Change to e.g. "org" if your domain uses organisations.
tenant-claim-name: tenant
# Role name assigned to regular (non-admin) team members.
# Default: "member". Set to "user" if your existing ACL rules use that label.
member-role-name: member
# Set to false to disable the built-in membership management endpoints:
# POST /auth/invite
# POST /auth/resend-invite
# GET /auth/tenants
# POST /auth/switch-tenant
# Useful when your custom provider exposes its own equivalent endpoints.
# Default: true
membership-endpoints-enabled: true
RESTHeart Cloud example
accountsConfig:
db: myapp
tenant-claim-name: org # JWT carries "org" instead of "tenant"
member-role-name: user # keep existing ACL rules
membership-endpoints-enabled: false # /orgs-mgmt/* handles invitations
OAuth activation for invited users
When an invited user (one with roles: ["$unauthenticated"]) authenticates via OAuth, the callback invokes
MembershipProvider.activateViaOAuth() instead of issuing a JWT that would be blocked by the ACL.
Before activateViaOAuth() is called, the OAuth callback resolves the pending invitation from auth_invitations using the pendingInviteToken stored in the CSRF state, and calls addMember(userId, orgId, role) to set the user’s tenant.
The DefaultMembershipProvider implementation of activateViaOAuth then assigns the system ACL role from accountsConfig.default-role, clears the inviteToken field, and returns the user’s membership for JWT issuance.
Custom providers can override this behaviour (or disable it by returning Optional.empty()).
Carrying invite context through the OAuth redirect
The OAuth initiation endpoint accepts two optional query parameters that are stored in the CSRF state token (TTL 10 min) and retrieved on callback:
| Parameter | Description |
|---|---|
|
The invite token from the activation email — passed as-is for audit purposes |
|
|
GET /auth/oauth/authorize/google
?pendingInviteToken=abc123
&consentsAccepted=true
When consentsAccepted=true is present, the callback builds a ConsentRecord from
accountsConfig.terms-version / privacy-version and the client IP, then passes it
to activateViaOAuth(userId, consents).
Both parameters are stored in the oauth_codes MongoDB collection alongside the CSRF state
(TTL 10 min, persisted across pod restarts).
activateViaOAuth method reference
// package org.restheart.plugins.accounts (in restheart-commons)
/**
* Called when an OAuth-authenticated user has status "invited".
* Return a non-empty Optional to activate the user and issue a JWT.
* Return Optional.empty() to fall back to the default behaviour
* (JWT with status:"invited" — blocked by ACL rules).
*/
default Optional<Membership> activateViaOAuth(String userId, ConsentRecord consents) {
return Optional.empty(); // deny by default (preserves 9.4.1 behaviour)
}
The ConsentRecord value object:
// package org.restheart.plugins.accounts (in restheart-commons)
public record ConsentRecord(String termsVersion, String privacyVersion,
String ip, Instant acceptedAt) {}
consents is null when consentsAccepted=true was not present in the OAuth state.
Writing a custom provider
1. Implement MembershipProvider
The example below maps the restheart-accounts membership model onto an orgs collection
where each org document has an ObjectId _id.
Note how BsonValue is used throughout — no forced conversion to String.
package com.example;
import org.bson.BsonObjectId;
import org.bson.BsonValue;
import org.restheart.plugins.accounts.Membership;
import org.restheart.plugins.accounts.MembershipProvider;
import org.restheart.plugins.accounts.TenantRef;
import java.util.List;
import java.util.Optional;
public class OrgMembershipProvider implements MembershipProvider {
// Receive your own DB client, config, etc. via constructor (from the Initializer below)
@Override
public TenantRef createInitialTeam(String userId, String orgName) {
// Create the org document; _id is a MongoDB ObjectId
var orgId = new BsonObjectId();
myOrgCollection.insertOrg(orgId, orgName, userId);
myUserCollection.setActiveOrg(userId, orgId);
return new TenantRef(orgId, orgName); // BsonObjectId, not String
}
@Override
public boolean isMember(String userId, BsonValue orgId) {
return myOrgCollection.hasMember(orgId, userId);
}
@Override
public void addMember(String userId, BsonValue orgId, String role) {
myOrgCollection.addMember(orgId, userId, role);
// Set orgId as active if the user has no active org yet
myUserCollection.setActiveOrgIfAbsent(userId, orgId);
}
@Override
public Optional<Membership> activeMembership(String userId) {
var user = myUserCollection.find(userId);
if (user == null || user.org() == null) return Optional.empty();
var orgId = user.org(); // BsonObjectId from the user document
var org = myOrgCollection.find(orgId);
var role = org.memberRole(userId);
return Optional.of(new Membership(orgId, org.name(), role, true));
}
@Override
public List<Membership> listMemberships(String userId) {
var user = myUserCollection.find(userId);
if (user == null) return List.of();
var activeOrg = user.org();
return user.orgs().stream()
.map(orgId -> {
var org = myOrgCollection.find(orgId);
var role = org.memberRole(userId);
return new Membership(orgId, org.name(), role, orgId.equals(activeOrg));
})
.toList();
}
@Override
public void setActiveMembership(String userId, BsonValue orgId) {
if (!isMember(userId, orgId))
throw new IllegalArgumentException("User is not a member of org " + orgId);
myUserCollection.setActiveOrg(userId, orgId);
}
}
2. Register via an Initializer
package com.example;
import org.restheart.plugins.accounts.MembershipProviderRegistry;
import org.restheart.plugins.Initializer;
import org.restheart.plugins.Inject;
import org.restheart.plugins.RegisterPlugin;
@RegisterPlugin(name = "orgMembershipProvider", description = "Custom org membership provider")
public class OrgMembershipInitializer implements Initializer {
@Inject("accountsService")
private MembershipProviderRegistry accountsService;
@Override
public void init() {
accountsService.registerMembershipProvider(new OrgMembershipProvider(/* … */));
}
}
Place the compiled JAR in RESTHeart’s plugins/ directory alongside restheart-accounts.jar.
The provider is activated before the server starts accepting requests.
Key rules:
-
The class implementing
MembershipProviderdoes not need to be a RESTHeart plugin itself — it is a plain Java class instantiated by theInitializer. -
The
Initializer(the@RegisterPluginclass) must injectAccountsServiceand callregisterMembershipProvider(this)insideinit(). -
Only one custom provider can be active at a time; the last call to
registerMembershipProviderwins.
SPI reference
// package org.restheart.plugins.accounts (in restheart-commons)
public interface MembershipProvider {
/**
* Creates an initial team for a newly registered user and assigns them
* the owner role. Called during /auth/register and the OAuth new-user path.
*
* The user document already exists when this is called.
* The returned TenantRef is embedded as the JWT tenant claim.
*/
TenantRef createInitialTeam(String userId, String teamName);
/**
* Returns true if the user is already a member of the given tenant.
* Used by /auth/invite to prevent duplicate memberships.
*/
boolean isMember(String userId, BsonValue tenantId);
/**
* Adds a user to a tenant with the specified role (idempotent).
* If the user has no active tenant yet, this should also set it.
*/
void addMember(String userId, BsonValue tenantId, String role);
/**
* Returns the user's currently active membership, or empty if none.
* Consumed by every JWT issuance path.
*/
Optional<Membership> activeMembership(String userId);
/**
* Returns all memberships for the user.
* Used by GET /auth/tenants.
*/
List<Membership> listMemberships(String userId);
/**
* Sets the given tenant as the user's active membership.
* Used by POST /auth/switch-tenant.
* Must throw IllegalArgumentException if the user is not a member.
*/
void setActiveMembership(String userId, BsonValue tenantId);
/**
* Removes a user from the given tenant.
* Called by DELETE /auth/remove-member.
* Must remove the entry from both the user-side and the team-side store.
* If the tenant was the user's active tenant, the active-tenant field must be cleared.
* No-op if the user is not a member.
* Default: throws UnsupportedOperationException.
*/
default void removeMember(String userId, BsonValue tenantId) {
throw new UnsupportedOperationException("removeMember not implemented by this provider");
}
/**
* Updates the org-level role of a member within the given tenant.
* Called by PATCH /auth/member-role.
* Must update the role on both the user-side and the team-side store.
* No-op if the user is not a member.
* Default: throws UnsupportedOperationException.
*/
default void updateMemberRole(String userId, BsonValue tenantId, String newRole) {
throw new UnsupportedOperationException("updateMemberRole not implemented by this provider");
}
/**
* Called when an OAuth-authenticated user has status "invited".
* Return Optional.empty() to deny (default, preserves 9.4.1 behaviour).
*/
default Optional<Membership> activateViaOAuth(String userId, ConsentRecord consents) {
return Optional.empty();
}
}
// Tenant reference returned by createInitialTeam — id is the native BsonValue
public record TenantRef(BsonValue id, String displayName) {}
// Snapshot of a single membership — tenantId is the native BsonValue
public record Membership(BsonValue tenantId, String displayName, String role, boolean active) {}
Built-in default provider
DefaultMembershipProvider preserves the tenant / tenants schema and stores tenant identifiers as MongoDB ObjectId values.
// users collection — schema managed by DefaultMembershipProvider
{
"_id": "alice@example.com",
"tenant": { "$oid": "64a1b2c3d4e5f6a7b8c9d0e1" }, // active tenant (ObjectId)
"tenants": [
{ "id": { "$oid": "64a1b2c3d4e5f6a7b8c9d0e1" }, "role": "owner" },
{ "id": { "$oid": "64a1b2c3d4e5f6a7b8c9d0e2" }, "role": "member" }
]
}
// teams collection — created by createInitialTeam / addMember
{
"_id": { "$oid": "64a1b2c3d4e5f6a7b8c9d0e1" },
"name": "Acme Corp",
"createdBy": "alice@example.com",
"createdAt": { "$date": "…" },
"members": [{ "userId": "alice@example.com", "role": "owner", "joinedAt": { "$date": "…" } }]
}
| Operation | MongoDB effect |
|---|---|
|
Inserts a |
|
Reads |
|
|
|
Reads the |
|
Reads the |
|
|
|
|
|
Positional |
Backward compatibility
A 9.4 deployment upgraded to 9.4.1 with no configuration changes behaves identically:
-
DefaultMembershipProvideris used automatically. -
tenant-claim-namedefaults to"tenant"— JWT payload is unchanged. -
member-role-namedefaults to"member"— role names for new invitees use"member". -
membership-endpoints-enableddefaults totrue— all endpoints remain active.
|
Note
|
If your existing user documents store |
|
Note
|
If your existing ACL rules match the role |
See also: OAuth custom providers for the equivalent SPI for OAuth 2.0 providers.