Skip to main content

Building a Tenant Selector

This document is a follow-up to Multi-Tenant Access Control.

In an MSO Access Control model, a single practitioner often has access to multiple tenants (for example, multiple Organization clinics) within the same Medplum Project. After login, most applications need an answer to:

  • Which tenant context is the user working in right now?
  • When should the UI show a consolidated cross-tenant view vs. a tenant-scoped view?

This guide describes three common approaches and how to implement a tenant selector when you need one.

At a Glance

Use the decision tree to pick the option that fits your product, then jump to that section for implementation details.

Option 1: Strict IsolationOption 2: Single Account + SelectorOption 3: Unified View
API-level tenant isolationStrongest (one tenant per token)Allowed tenants enforced; UI scopes within themNone beyond the project
Consolidated cross-tenant viewsDifficultSupported (when UI omits the filter)Default
Tenant-scoped PHI screensYes (always)Yes (when UI applies the filter)No
Login / membership modelOne membership per tenant per userOne membership covering multiple tenantsOne membership covering all tenants
Tenant switchingRe-auth or saved-login swapUI control (no re-auth)N/A
Implementation complexityHighestMediumLowest

Key Concept: Allowed Tenants vs Active Tenant

  • Allowed tenants: The set of tenant references the user is permitted to access (enforced by ProjectMembership + AccessPolicy).
  • Active tenant: A single tenant reference that your application uses as the default filter for PHI (a UI/application concept).

ProjectMembership defines the allowed tenants (Org A, B, C). The application picks one as the active tenant (Org B above) and uses it as the default filter on PHI queries.

When you build a "tenant selector", you're choosing and persisting the active tenant.

Option 1: Strict Isolation (Separate Accounts / Separate Projects)

Goal: Hard isolation of PHI at the API level. At any given time, the "active tenant" is the only "allowed tenant" for the user.

What This Looks Like

  • The user has separate logins per tenant.
  • Each login/profile has a ProjectMembership that only includes access to one tenant.

Each login is bound to a single tenant. Switching tenants requires switching the active login.

note

In Medplum, a user/profile has a single ProjectMembership per Project (it is resolved by project + profile). If you want strict separation within the same project, you typically use separate user accounts (separate profiles) rather than "two memberships for one profile".

Switching the Active ProjectMembership

In Medplum, the "active membership" is not a client-side flag you can flip on an existing token. The OAuth login flow selects a membership, and the issued access token is bound to that membership.

Practically, "switching memberships" means one of:

  • Switching to another existing login (you already authenticated previously and have a saved token pair).
  • Starting a new login and selecting a different membership during the login handshake.

How Membership Selection Works in the Login Flow

  • If a user only has one membership, Medplum selects it automatically.
  • If a user has multiple memberships, the client displays a "choose profile" screen and posts the selected membership ID to POST /auth/profile.
  • After that, the client exchanges the login code for tokens; the token response includes the project and profile for the selected membership.

How Switching Works in the Medplum App / SDK

The Medplum App supports "Add another account" and project switching by storing multiple login states locally and switching the active one. In the TypeScript SDK this is done with:

  • medplum.getLogins() to list saved logins
  • medplum.setActiveLogin(login) to switch the active login (and therefore the active membership)

How to Configure a User with Multiple Memberships

You need to invite the user once per membership you want in the project (for example one membership per tenant in a strict-isolation MSO model). See the Invite User endpoint.

Parameterized access policy: put the policy and its substitution values on membership.access. Each entry is one ProjectMembership.access rule: a policy reference plus an optional parameter array. The name of each parameter must match your policy placeholders (for example %organization uses "name": "organization"). A single-tenant membership has one access entry and one parameter object.

forceNewMembership: set true when the user is already a member of this project but you need an additional ProjectMembership row (same email address). The first invite for that email in the project does not need it; the second and later ones do, or the server returns a conflict.

Example: invite a practitioner with one organization-scoped policy and a single organization parameter (replace ids with your AccessPolicy and Organization).

Creating the First ProjectMembership

// First membership invite for this user in this project.
// Do NOT set forceNewMembership here.
await medplum.post(`admin/projects/${projectId}/invite`, {
resourceType: 'Practitioner',
firstName: 'George',
lastName: 'Washington',
email: 'dr.gw@example.gov',
sendEmail: false,
membership: {
access: [
{
policy: { reference: 'AccessPolicy/mso-tenant-policy' },
parameter: [{ name: 'organization', valueReference: { reference: 'Organization/clinic-a' } }],
},
],
},
});

Creating an Additional ProjectMembership

// Second membership invite for the same user in the same project.
// This creates an additional ProjectMembership row with a different tenant parameter.
await medplum.post(`admin/projects/${projectId}/invite`, {
resourceType: 'Practitioner',
firstName: 'George',
lastName: 'Washington',
email: 'dr.gw@example.gov',
sendEmail: false,
forceNewMembership: true,
membership: {
access: [
{
policy: { reference: 'AccessPolicy/mso-tenant-policy' },
parameter: [{ name: 'organization', valueReference: { reference: 'Organization/clinic-b' } }],
},
],
},
});

Selector UX

Often resembles the Medplum App profile selector: users switch "logins" to switch tenant context.

Option 2: Single Account, Multi-Tenant Access + UI-Level Tenant Context

Goal: One login (ProjectMembership) can access one or multiple defined tenants, but the FE application chooses an "active tenant".

What This Looks Like

  • One ProjectMembership grants access to multiple tenant references (e.g., multiple Organization values).
  • API level access is still enforced to "allowed tenants" only.
  • Your UI applies a tenant-scoping to the "active tenant" by the search parameter _compartment, but can intentionally show consolidated views when needed.

One membership grants access to multiple tenants. The UI selector designates one as the active tenant; the user can switch it without re-authenticating.

How to Grant Access to Multiple Tenants

If your AccessPolicy uses %organization:

{
"resourceType": "ProjectMembership",
"access": [
{
"policy": { "reference": "AccessPolicy/mso-policy" },
"parameter": [
{ "name": "organization", "valueReference": { "reference": "Organization/clinic-a" } },
{ "name": "organization", "valueReference": { "reference": "Organization/clinic-b" } }
]
}
]
}

With that membership, an access policy criteria like Patient?_compartment=%organization can allow access to patients across both tenants.

Building the Selector

At login (or on first use), your app decides an "active tenant" and persists it. Common patterns:

  • URL: ?tenant=Organization/clinic-a (easy to deep-link, but must be validated server-side against allowed tenants).
  • Local storage/session: fast, but not shared across devices.
  • FHIR-backed preference: store the active tenant in UserConfiguration so it follows the user.

Persisting the Active Tenant in UserConfiguration

UserConfiguration supports per-user options. One common approach is to store a reference-valued option:

{
"resourceType": "UserConfiguration",
"option": [
{
"id": "activeTenant",
"valueReference": { "reference": "Organization/clinic-a" }
}
]
}

Then reference that UserConfiguration from the user's ProjectMembership (see User Configuration).

Enforcing the Active Tenant in Queries

Once you have an active tenant reference (e.g., Organization/clinic-a), apply it as a default filter for tenant-scoped screen queries.

Examples (MSO model using Organization as tenant):

  • Tenant-scoped PHI view: Patient?_compartment=Organization/clinic-a
  • Consolidated schedule view (cross-tenant): omit tenant filter (or intentionally include multiple tenants)

Option 3: Unified View (No Tenant Context)

Goal: Treat the project as the only boundary; the user always sees everything they're allowed to see.

The user sees a single consolidated view of all allowed tenants. There is no active-tenant concept and no UI selector.

When to Choose It

  • Your product model is truly unified (e.g., one clinical ops team across all clinics).
  • Your contracts and workflows do not require tenant-scoped PHI screens.

See Also