Custom Membership Providers
RESTHeart|
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
Writing a custom provider
1. Implement MembershipProvider
package com.example;
import org.restheart.accounts.spi.Membership;
import org.restheart.accounts.spi.MembershipProvider;
import org.restheart.accounts.spi.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.accounts.AccountsService;
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 AccountsService 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
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.