Edit Page

Custom Membership Providers

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 →
Note

The MembershipProvider SPI is available starting from restheart-accounts 9.4.1. listTeamMembers, createTeam, updateTeam, and deleteTeam (and the TeamMember record) were added in 9.6.0. The interface and its types (Membership, TeamRef, TeamMember) are defined in restheart-commons (org.restheart.plugins.accounts), so custom implementations depend only on restheart-commons — not on restheart-accounts.

By default, restheart-accounts stores who belongs to a team in the team document (members), and the active team on the user document (team._id). 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

createInitialTeam(userId, teamName)

POST /auth/register, OAuth new-user path

isMember(userId, teamId)

POST /auth/invite — duplicate-membership guard

addMember(userId, teamId, role)

PATCH /auth/activate (new users), POST /auth/accept-invite (existing users), OAuth callback (both paths)

activeMembership(userId)

JWT issuance after login, verification, activation, OAuth callback

listMemberships(userId)

GET /auth/teams

setActiveMembership(userId, teamId)

POST /auth/switch-team, POST /auth/accept-invite (switches active org on acceptance)

removeMember(userId, teamId)

DELETE /auth/remove-member

updateMemberRole(userId, teamId, newRole)

PATCH /auth/member-role

listTeamMembers(teamId)

GET /auth/team/members

createTeam(userId, teamName)

POST /auth/teams

updateTeam(teamId, name, description)

PATCH /auth/team

deleteTeam(userId, teamId)

DELETE /auth/team

Tenant ID types

All SPI methods that receive or return a team 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), team IDs are serialized as MongoDB extended JSON:

  • An ObjectId team appears as {"$oid": "64a1b2c3…"} in GET /auth/teams response items.

  • The POST /auth/switch-team response carries the active team as { _id, role } — e.g. { "_id": {"$oid": "…​"}, "role": "owner" }.

  • The same document is the ACL variable @team, resolved from activeMembership for the caller, whatever this provider stores on the user document. Since 9.12.0 the tokens carry no team claim: a deployment that wants it lists "@team" in jwtConfigProvider.account-properties-claims and gets it on every path, POST /token included.

  • Clients must send the same format in the POST /auth/switch-team request 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:
  # The name of the ACL variable for the active team (@team), of the team key in the
  # POST /auth/switch-team response, and of the claim when "@team" is listed in
  # jwtConfigProvider/account-properties-claims. Default: "team".
  team-claim-name: team

  # 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/teams          POST   /auth/teams
  #   POST   /auth/switch-team
  #   GET    /auth/team/members
  #   PATCH  /auth/team           DELETE /auth/team
  # Useful when your custom provider exposes its own equivalent endpoints.
  # Default: true
  membership-endpoints-enabled: true

Ulabase example

accountsConfig:
  db: myapp
  team-claim-name: org          # the variable is @org, and the claim "org" when listed
  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, teamId, role) to set the user’s team. 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

pendingInviteToken

The invite token from the activation email — passed as-is for audit purposes

consentsAccepted

"true" when the user has already accepted T&C / Privacy Policy on the frontend before the OAuth redirect

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.TeamRef;

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 TeamRef 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 TeamRef(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 MembershipProvider does not need to be a RESTHeart plugin itself — it is a plain Java class instantiated by the Initializer.

  • The Initializer (the @RegisterPlugin class) must inject AccountsService and call registerMembershipProvider(this) inside init().

  • Only one custom provider can be active at a time; the last call to registerMembershipProvider wins.

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 TeamRef is what @team resolves to while this team is the active one.
     */
    TeamRef createInitialTeam(String userId, String teamName);

    /**
     * Returns true if the user is already a member of the given team.
     * Used by /auth/invite to prevent duplicate memberships.
     */
    boolean isMember(String userId, BsonValue teamId);

    /**
     * Adds a user to a team with the specified role (idempotent).
     * If the user has no active team yet, this should also set it.
     */
    void addMember(String userId, BsonValue teamId, 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/teams.
     */
    List<Membership> listMemberships(String userId);

    /**
     * Sets the given team as the user's active membership.
     * Used by POST /auth/switch-team.
     * Must throw IllegalArgumentException if the user is not a member.
     */
    void setActiveMembership(String userId, BsonValue teamId);

    /**
     * Removes a user from the given team.
     * Called by DELETE /auth/remove-member.
     * Must remove the entry from both the user-side and the team-side store.
     * If the team was the user's active team, the active-team field must be cleared.
     * No-op if the user is not a member.
     * Default: throws UnsupportedOperationException.
     */
    default void removeMember(String userId, BsonValue teamId) {
        throw new UnsupportedOperationException("removeMember not implemented by this provider");
    }

    /**
     * Updates the org-level role of a member within the given team.
     * 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 teamId, 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();
    }

    /**
     * Returns the members of the given team with display information denormalized
     * from each member's user document. Called by GET /auth/team/members.
     * Default: throws UnsupportedOperationException.
     */
    default List<TeamMember> listTeamMembers(BsonValue teamId) {
        throw new UnsupportedOperationException("listTeamMembers not implemented by this provider");
    }

    /**
     * Creates an additional team for an already-registered user, assigns them
     * the owner role, and sets it as their newly active membership. Unlike
     * createInitialTeam, this may be called any number of times for the same
     * user. Called by POST /auth/teams.
     * Default: throws UnsupportedOperationException.
     */
    default TeamRef createTeam(String userId, String teamName) {
        throw new UnsupportedOperationException("createTeam not implemented by this provider");
    }

    /**
     * Updates a team's own fields (name and/or description, partial update —
     * either parameter may be null to leave that field unchanged).
     * Called by PATCH /auth/team.
     * Default: throws UnsupportedOperationException.
     */
    default void updateTeam(BsonValue teamId, String name, String description) {
        throw new UnsupportedOperationException("updateTeam not implemented by this provider");
    }

    /**
     * Deletes a team, but only if it has no other members — implementations
     * must enforce this atomically, server-side (a client-side pre-check is a
     * race condition against a concurrent invite acceptance). On success, also
     * clears the caller's own membership entry and active-team pointer.
     * Called by DELETE /auth/team.
     * Returns false (does not throw) if the team still has other members, or
     * no longer exists.
     * Default: throws UnsupportedOperationException.
     */
    default boolean deleteTeam(String userId, BsonValue teamId) {
        throw new UnsupportedOperationException("deleteTeam not implemented by this provider");
    }
}

// Tenant reference returned by createInitialTeam/createTeam — id is the native BsonValue
public record TeamRef(BsonValue id, String displayName) {}

// Snapshot of a single membership — teamId is the native BsonValue
public record Membership(BsonValue teamId, String displayName, String role, boolean active) {}

// A team member with display info denormalized from their user document,
// returned by listTeamMembers — email is the member's identifier
public record TeamMember(String email, String name, String role, Instant joinedAt) {}

Built-in default provider

Since 9.12.0 DefaultMembershipProvider keeps who belongs to a team, and as what, in the team’s members only. The user document keeps the id of its active team, which is the user’s own choice, and nothing else about memberships. Team identifiers are MongoDB ObjectId values.

// teams collection — created by createInitialTeam / createTeam, written by addMember, removeMember, updateMemberRole
{
  "_id":       { "$oid": "64a1b2c3d4e5f6a7b8c9d0e1" },
  "name":      "Acme Corp",
  "createdBy": "alice@example.com",
  "createdAt": { "$date": "…" },
  "members":   [{ "userId": "alice@example.com", "role": "owner", "joinedAt": { "$date": "…" } }]
}

// users collection — only the active team
{
  "_id":  "alice@example.com",
  "team": { "_id": { "$oid": "64a1b2c3d4e5f6a7b8c9d0e1" } }
}
Operation MongoDB effect

createInitialTeam

Inserts a teams document with the user as its owner in members; $set team: { _id } on the user only if absent

createTeam

Same insert; $set team: { _id } on the user unconditionally, so the new team becomes active

isMember

Reads the team; true when its members name the user

addMember

$push to members when the user is not there yet (a user already there keeps the role it has); $set team: { _id } on the user only if absent

activeMembership

Reads the user’s team._id, then the team: the membership with the role its members say, or none when the user is not among them

listMemberships

One query on the teams collection, members.userId; active marks the user’s team._id

setActiveMembership

$set team: { _id } on the user, after checking the team’s members

removeMember

$pull from members; $unset team on the user if the removed team was the active one

updateMemberRole

Positional $set members.$.role on the team; what @team.role reads on the next request

listTeamMembers

Reads team.members[]; batch-fetches ($in) the corresponding users documents to denormalize display names from profile.name / profile.surname

updateTeam

Partial $set of name / description on the teams document

deleteTeam

Atomic findOneAndDelete on the teams document guarded by $expr: {$size: "$members"} ⇐ 1; on success, $unset team on the user if it was active

The teams collection has an index on members.userId, ensured by accountsInitializer.

Migrating from the pre-9.12.0 model

Before 9.12.0 the user document also carried teams: [{ id, role }], every team with the user’s role, and the active role in team.role: three copies of one fact, written with separate updates, that could disagree. They are neither read nor written any more. A user whose teams names a team whose members do not name them has no access to that team: the team decides.

Existing data is migrated once, at startup, with accountsConfig.migrate-memberships: true: every user document still carrying teams or team.role has them unset, a pre-9.6.0 bare team id becomes { _id }, and every membership the old teams listed that the team does not confirm is reported in the log, as a warning, never added. Turn the setting off afterwards; running it again finds nothing to do.

Warning

ACL rules reading @user.team.role or @user.team._id must move to @team.role and @team._id, which read the team’s members (see Variables). Clients and tools reading teams or team.role from the user document must read GET /auth/teams instead.

Deleting a user document, with the MongoDB REST API or by hand, does not take the user out of the teams that name it: a user registered again with the same id finds those memberships, and their roles, as they were. Remove the member from its teams first (DELETE /auth/remove-member, or a $pull on members), as before 9.12.0 the stale entries existed just the same, only hidden by the user document.

Backward compatibility

A 9.4 deployment upgraded to 9.4.1 with no configuration changes behaves identically:

  • DefaultMembershipProvider is used automatically.

  • team-claim-name defaults to "team" — the variable is @team.

  • Since 9.12.0 memberships live on the team only: see Migrating from the pre-9.12.0 model.

  • member-role-name defaults to "member" — role names for new invitees use "member".

  • membership-endpoints-enabled defaults to true — all endpoints remain active.

Note

A team id stored as a plain hex string (the 9.4 format) keeps working: the active team and the members are compared as BsonValue, without conversion. New teams get an ObjectId.

Note

If your existing ACL rules match the role "user" for invited team members, add member-role-name: user to your accountsConfig after upgrading.

See also: OAuth custom providers for the equivalent SPI for OAuth 2.0 providers.