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 |
|
|
|
|
Configuration
Three new 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 status: "invited") authenticates via OAuth, the callback invokes
MembershipProvider.activateViaOAuth() instead of issuing a JWT that would be blocked by the ACL.
The DefaultMembershipProvider implementation activates the user immediately:
it sets status to "active", clears the inviteToken field, and returns the user’s pre-assigned 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
package com.example;
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, add userId as owner, update the user document
var orgId = myOrgCollection.insertOrg(orgName, userId);
myUserCollection.setActiveOrg(userId, orgId);
return new TenantRef(orgId, orgName);
}
@Override
public boolean isMember(String userId, String orgId) {
return myOrgCollection.hasMember(orgId, userId);
}
@Override
public void addMember(String userId, String 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 org = myOrgCollection.find(user.org());
var role = org.memberRole(userId);
return Optional.of(new Membership(user.org(), 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, String 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.MembershipProvider;
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 MembershipProviderRegistry (not AccountsService) — available in restheart-commons only
@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, String 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, String 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, String tenantId);
}
public record TenantRef(String id, String displayName) {}
public record Membership(String tenantId, String displayName, String role, boolean active) {}
Built-in default provider
DefaultMembershipProvider preserves the 9.4 tenant / tenants schema.
No configuration changes are needed for existing deployments.
// users collection — schema managed by DefaultMembershipProvider
{
"_id": "alice@example.com",
"tenant": "abc123", // active tenant ID
"tenants": [
{ "id": "abc123", "role": "owner" },
{ "id": "def456", "role": "member" }
]
}
// teams collection — created by createInitialTeam / addMember
{
"_id": ObjectId("…"),
"name": "Acme Corp",
"createdBy": "alice@example.com",
"createdAt": ISODate("…"),
"members": [{ "userId": "alice@example.com", "role": "owner", "joinedAt": ISODate("…") }]
}
| Operation | MongoDB effect |
|---|---|
|
Inserts a |
|
Reads |
|
|
|
Reads the |
|
Reads the |
|
|
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 ACL rules match the role |
See also: OAuth custom providers for the equivalent SPI for OAuth 2.0 providers.