Audience: Developers and operators working on Meridian’s backend, frontend, or deployment. This doc describes the multi-tenant identity system added to support platform admins, cross-tenant event registration, and SSO across subdomains (e.g. rpi.meridian.study, tvcog.meridian.study).
Overview
The multi-tenant identity implementation provides:| Goal | Description |
|---|---|
| Platform admins | A small set of users with admin access on every tenant (school) without needing a local User in each tenant DB. |
| Multi-tenant users (light) | A user with a global identity can register for an event at another school as a “global guest” without a full account there. |
| Multi-tenant users (full) | A user can be a member of multiple schools with one identity; profile can be linked across tenants. |
| Single sign-on (SSO) | One login works across all subdomains (e.g. rpi.meridian.study, tvcog.meridian.study) via a shared cookie domain. |
| Strict per-tenant data | Tenant-specific data (users, events, orgs) stays in each tenant’s database; cross-tenant data lives only in the global DB where needed. |
Architecture
High-level flow
Per-request data flow
- Subdomain sets
req.schoolandreq.db(unchanged): derived fromHost(e.g.rpifromrpi.meridian.study). In development,localhostdefaults torpi. - JWT is verified; payload may include
globalUserId,tenantUserId,platformRoles,roles. - Middleware resolves
req.user.tenantUserIdfrom the global DB viaTenantMembership(globalUserId, req.school). For backward compatibility,req.user.userId = req.user.tenantUserIdso existing code usingreq.user.userIdandgetModels(req, 'User')keeps working. When there is no membership,userIdis null (guest on that tenant). - Admin routes use
requireAdmin: platform admin (from global DB or token) first, then tenantUser.roles(admin/root).
Tenant derivation (subdomain)
- Production: Tenant is the first segment of the host. Examples:
rpi.meridian.study→req.school = 'rpi',tvcog.meridian.study→req.school = 'tvcog'. - Development: If the host is
localhostor an IP address, the backend defaults toreq.school = 'rpi'so local testing works without subdomains.
Meridian/backend/app.js: the middleware that sets req.db and req.school reads req.headers.host, splits on ., and applies the localhost default when appropriate.
Global database
Purpose
A dedicated MongoDB database holds cross-tenant data only:- GlobalUser – one identity per person (email, name, picture, provider ids).
- PlatformRole – which global users are platform admins (or root).
- TenantMembership – which global user is linked to which tenant and which tenant User id.
- Session – refresh sessions keyed by
globalUserIdso token refresh works across tenants (SSO).
Connection
- File:
Meridian/backend/connectionsManager.js - Function:
connectToGlobalDatabase() - Config: Reads
MONGO_URI_PLATFORMorMONGO_URI_GLOBAL. If unset, derives a DB from the default tenant URI (e.g. same cluster, database namemeridian_platform). - Attach: In
app.js, the same middleware that setsreq.dbsetsreq.globalDb = await connectToGlobalDatabase()so every request has access.
Global schemas
Located underMeridian/backend/schemas/:
| Schema | Collection | Purpose | ||
|---|---|---|---|---|
| GlobalUser | global_users | One document per person. Fields: email (unique), name, picture, googleId, appleId, samlId, samlProvider, timestamps. Indexes on email, googleId, appleId, (samlId, samlProvider). | ||
| PlatformRole | platform_roles | One document per global user for platform-wide roles. Fields: globalUserId, roles (e.g. ['platform_admin']). Unique index on globalUserId. | ||
| TenantMembership | tenant_memberships | Links global user to a tenant and its User. Fields: globalUserId, tenantKey (e.g. 'rpi', 'tvcog'), tenantUserId (ObjectId, no ref to avoid cross-DB refs), status (`‘active' | 'invited' | 'left’). Compound unique on (globalUserId, tenantKey)`. |
| Session (global) | sessions | Refresh sessions for SSO. Fields: globalUserId, refreshToken, deviceInfo, userAgent, ipAddress, expiresAt, etc. Keyed by globalUserId so refresh works on any tenant. |
getGlobalModelService
- File:
Meridian/backend/services/getGlobalModelService.js - Usage:
const { GlobalUser, PlatformRole, TenantMembership, Session } = getGlobalModels(req, 'GlobalUser', 'PlatformRole', 'TenantMembership', 'Session'); - Rule: Use only in auth flows, session handling, and platform-admin resolution. Tenant routes continue to use
getModelService(req, ...)withreq.db.
JWT shape and auth
New token payload
globalUserId– always present for authenticated users.tenantUserId– present when the user has a TenantMembership for the tenant they logged in on; for other tenants, resolved in middleware from TenantMembership.platformRoles– optional array from PlatformRole (e.g.['platform_admin']) to avoid a DB hit on every request.roles– tenant User roles (for backward compatibility and existingauthorizeRoleswhen not using platform admin).
Backward compatibility
Legacy tokens that only haveuserId and roles are still accepted. Middleware sets req.user.userId = decoded.userId and req.user.roles = decoded.roles. globalUserId is not set for legacy tokens unless a separate backfill runs.
Login/register flow (create-or-find GlobalUser + TenantMembership)
After validating credentials (or OAuth/SAML):- Get or create GlobalUser by email (and optionally by
googleId/appleId/ SAML id). New GlobalUser: copyemail,name,picture, provider ids from the tenant User or OAuth profile. - Get or create TenantMembership for
(globalUserId, req.school). If creating, create or find the tenant User inreq.db, then setTenantMembership.tenantUserIdto that User’s_id. - Load PlatformRole for this
globalUserId(if any). - Issue JWT with
globalUserId,tenantUserId(for current school),platformRoles,roles(from tenant User). - Create Session in the global DB keyed by
globalUserIdso refresh works across tenants.
issueTokens(req, res, globalUser, tenantUser, platformRoles)) in authGlobalService.js is used so every code path that issues tokens is consistent.
Sessions and refresh (global)
- Session storage lives in the global DB. The
Sessioncollection hasglobalUserId,refreshToken, device/userAgent/ip,expiresAt. Refresh token payload:{ globalUserId, type: 'refresh' }. - On refresh: verify JWT, validate session in global DB, resolve
tenantUserIdforreq.schoolvia TenantMembership, then issue new access (and optionally refresh) token with the same shape.
Cookie domain for SSO
- In production, when setting
accessToken/refreshTokencookies, setdomain: '.meridian.study'so the cookie is sent to all subdomains. - In development, omit
domain(defaults to current host). - CORS: Production
corsOptions.origininapp.jsmust allow all intended subdomains (e.g.rpi.meridian.study,tvcog.meridian.study) so the frontend on any subdomain can send credentials and receive cookies.
Auth middleware (verifyToken)
- File:
Meridian/backend/middlewares/verifyToken.js - After verifying the JWT, if the payload has
globalUserId, the middleware resolvestenantUserIdfrom TenantMembership for(decoded.globalUserId, req.school)and sets:req.user.globalUserIdreq.user.tenantUserIdreq.user.userId = tenantUserId(so existing code usingreq.user.userIdworks)req.user.roles(from tenant User if found, else from token)req.user.platformRoles
- If the token is legacy (no
globalUserId), behavior is unchanged:req.user.userId = decoded.userId,req.user.roles = decoded.roles. - verifyTokenOptional: Same resolution when the token is valid; when the token is expired, the refresh path uses the global Session and TenantMembership, then sets
req.userwith the new shape.
Platform admins (requireAdmin)
Resolve admin status
- File:
Meridian/backend/middlewares/requireAdmin.js - Middleware:
requireAdmin(or “require platform or tenant admin”):- If
req.user.platformRolesincludesplatform_adminorroot→ allow. - Else if
req.user.globalUserIdexists, load PlatformRole from the global DB; if roles includeplatform_adminorroot→ allow. - Else if
req.user.userIdexists, load User fromreq.db; ifuser.rolesincludesadminorroot→ allow. - Else return 403.
- If
requireAdmin instead of authorizeRoles('admin', 'root') so platform admins have access on every tenant. Routes that should allow only tenant admins for certain actions can still use requireAdmin for the route and enforce tenant-specific rules in the handler if needed.
Platform admins API
UnderMeridian/backend/routes/adminRoutes.js:
| Method | Path | Description |
|---|---|---|
| GET | /admin/platform-admins | List GlobalUsers that have a PlatformRole with platform_admin. Returns { success: true, data: [{ globalUserId, email, name, picture }] }. Protected by requireAdmin. |
| POST | /admin/platform-admins | Add platform admin. Body: { email? } or { globalUserId? }. Look up GlobalUser by email or id; create or update PlatformRole with roles: ['platform_admin']. Protected by requireAdmin. |
| DELETE | /admin/platform-admins/:globalUserId | Remove platform_admin from that user’s PlatformRole. Protected by requireAdmin. |
Platform admins UI
- Frontend:
Meridian/frontend/src/pages/Admin/PlatformAdminsPage/PlatformAdminsPage.jsx– lists platform admins, “Add” by email, “Remove”. Wired under the Admin area (e.g. “Platform Admins” tab). - Visibility should be restricted to users who pass the backend
requireAdmincheck (platform or tenant admin).
Seeding the first platform admin(s)
- Env:
PLATFORM_ADMIN_EMAILS=admin@example.com(comma-separated if multiple). - Script: From
Meridian/backend/, runnode scripts/seedPlatformAdmins.js. It creates or finds GlobalUser by email and adds PlatformRole withplatform_admin. - Alternatively, create a migration that inserts GlobalUser + PlatformRole for a fixed email. Document in backend docs how to add the first platform admin.
Cross-tenant event registration
Event schema: global attendees
- File:
Meridian/backend/events/schemas/event.js(or Events-Backend equivalent) - Field:
globalAttendees– array of{ globalUserId, email, name, picture?, sourceTenant?, registeredAt, guestCount, checkedIn, checkedInAt, checkedInBy }. No ref to User in tenant DB. - Index: Ensure queries for “is this user already registered?” consider both
attendees.userIdandglobalAttendees.globalUserId.
RSVP route: global guest
- In POST RSVP (e.g.
POST /rsvp/:event_id): Ifreq.user.userIdexists, keep current behavior (add/update inevent.attendees). Else ifreq.user.globalUserIdexists, treat as global guest: checkevent.globalAttendeesbyglobalUserId, then append toglobalAttendeeswith data from GlobalUser and apply the same validation (capacity, deadline, form if any). FormResponse may storeglobalSubmittedBy(ObjectId) for global guests. - In DELETE RSVP (withdraw): Allow withdrawal if either
attendees.userId === req.user.userIdorglobalAttendees.globalUserId === req.user.globalUserId. - GET event/list: Include both
attendeesandglobalAttendees(or a merged view with a flag likeisGuest: true) so organizers see “Name (guest)” or “Name (from RPI)” as needed.
Join school (create tenant user + membership)
- Endpoint:
POST /auth/join-tenant(or/join-tenant). Protected byverifyToken. Expectsreq.user.globalUserId(user must be logged in with global identity). Body can be empty; tenant comes fromreq.school. - Logic: Look up TenantMembership for
(req.user.globalUserId, req.school). If already active, return success. Else: load GlobalUser; create a new User inreq.dbwith email, name, picture, provider ids from GlobalUser and default tenant fields (roles: ['user'], etc.); create TenantMembership withglobalUserId,tenantKey: req.school,tenantUserId: newUser._id,status: 'active'. Optionally issue a new token withtenantUserIdset. - Frontend: When validate-token returns
user: nullbutcommunities(or similar), show a “Join [School name]” CTA that calls this endpoint and then refreshes auth state.
Migration and backward compatibility
Legacy tokens
- Keep accepting the old JWT shape (
userId,rolesonly) inverifyToken.js; setreq.user.userId,req.user.roles. Optional: lazy backfill (on first request with legacy token, resolve or create GlobalUser + TenantMembership and setreq.user.globalUserIdfor that request only; optionally refresh token with new shape).
Existing users (one-time migration)
- Script:
Meridian/backend/scripts/migrateUsersToGlobalIdentity.js - Behavior: For each tenant key (e.g.
rpi,tvcog), load all Users from that tenant’s DB; for each User, get or create GlobalUser by email (and provider ids), then get or create TenantMembership for(globalUserId, tenantKey)withtenantUserId = User._id. Existing User documents are not changed. - Config: Edit
tenantKeysin the script to match your tenants; ensureMONGO_URI_PLATFORM(orMONGO_URI_GLOBAL) and tenant DB URIs are set. Run fromMeridian/backend/:node scripts/migrateUsersToGlobalIdentity.js.
Security and SOC2 notes
- Global DB: Restrict access (network and credentials); only the main app should connect. Use least privilege for platform admin management (only platform admins can add/remove).
- Audit: Log platform admin add/remove (who, when, by whom) in your existing audit or logging pipeline.
- Cookie:
httpOnly,securein production,sameSite: 'strict'(orlaxif cross-site links must preserve login). Domain.meridian.studyonly in production.
Setting up in staging / production
Use this checklist when enabling multi-tenant identity on a staging or production environment.1. Environment variables
Set these in your staging/production environment (e.g. in your host’s env or.env):
| Variable | Required | Description |
|---|---|---|
NODE_ENV | Yes | Set to production (or ensure it’s set for staging) so CORS and cookie domain use production behavior. |
MONGO_URI_PLATFORM or MONGO_URI_GLOBAL | Yes* | MongoDB URI for the global database (GlobalUser, PlatformRole, TenantMembership, Session). *If unset, the app derives a DB from MONGO_URI_RPI or DEFAULT_MONGO_URI by replacing the DB name with meridian_platform. For staging/prod, prefer setting an explicit URI. |
MONGO_URI_RPI | Yes (if using rpi) | MongoDB URI for the RPI tenant database. |
MONGO_URI_TVCOG | If using tvcog | MongoDB URI for the tvcog tenant database. |
JWT_SECRET | Yes | Same secret used for access tokens across all tenants. |
JWT_REFRESH_SECRET | Optional | Defaults to JWT_SECRET if unset. |
MONGODB_URI, DEFAULT_MONGO_URI, etc.) stay as you already use them. The global DB can be a new database on the same cluster (e.g. meridian_platform) or a separate cluster.
2. CORS and cookie domain
- Production (
NODE_ENV=production): The app allows originshttps://www.meridian.study,https://meridian.study,https://rpi.meridian.study,https://tvcog.meridian.studyand sets auth cookies withdomain: '.meridian.study'so SSO works across subdomains. - Staging: If your staging site uses the same parent domain (e.g.
rpi.staging.meridian.study), add those origins inMeridian/backend/app.jsin thecorsOrigin/corsOptions.originarrays, and set cookiedomainto.staging.meridian.studyin:Meridian/backend/routes/authRoutes.js(cookieOptions / clearOpts)Meridian/backend/middlewares/verifyToken.js(cookieOptions on refresh)Meridian/backend/services/authGlobalService.js(cookieOptions in issueTokens)Meridian/backend/routes/adminRoutes.js(impersonate cookieOpts) If staging uses a different domain entirely, use the same pattern (one parent domain for CORS and cookie domain).
3. Add new tenants (if needed)
InMeridian/backend/connectionsManager.js, extend getDbUriForSchool() so each subdomain has a URI:
MONGO_URI_STAGING) in your environment. Ensure CORS (and cookie domain if SSO is used) includes the new subdomain.
4. (Optional) Migrate existing users
If you already have Users in tenant DBs and want them to have global identity and SSO:- Set
MONGO_URI_PLATFORM(orMONGO_URI_GLOBAL) and all tenant URIs (MONGO_URI_RPI,MONGO_URI_TVCOG, etc.) in the environment. - In
Meridian/backend/scripts/migrateUsersToGlobalIdentity.js, settenantKeysto the list of tenant keys you use (e.g.['rpi', 'tvcog']). - From the backend directory, run:
- The script creates GlobalUser (by email) and TenantMembership per tenant User. Existing User documents are not modified. New logins will attach to these GlobalUsers.
5. Seed the first platform admins
Before or after deploy, create at least one platform admin:- Set
PLATFORM_ADMIN_EMAILSto a comma-separated list of emails (e.g.admin@meridian.study). These must be emails that exist (or will exist) as GlobalUser—e.g. after migration, or after the first login with that email. - From the backend directory, run:
Or ensure
MONGO_URI_PLATFORMis already in the environment and run: - Those users can then log in on any tenant and access admin routes (e.g. Platform Admins UI). You can also add/remove platform admins later via the Admin → Platform Admins UI (if you have at least one tenant or platform admin).
6. Deploy and verify
- Deploy the backend (with the env vars above). Ensure the app starts and can connect to both tenant DBs and the global DB.
- Open a tenant subdomain (e.g.
https://rpi.meridian.study), log in, and confirm auth works. - If SSO is configured, open another tenant (e.g.
https://tvcog.meridian.study) in the same browser and confirm you are still logged in (same user or no prompt to log in again, depending on membership). - As a platform admin, open Admin → Platform Admins and confirm you can list/add/remove platform admins.
- (Optional) Test global-guest event RSVP: log in on tenant A, open an event on tenant B (where you have no tenant User), and confirm you can register as a guest or use “Join school” if implemented.
7. Security checklist
- Use HTTPS in staging/production so cookies are sent with
secure: true. - Restrict global DB network and credentials to the app only; do not expose it to other services unless required.
- Audit: Ensure platform admin add/remove is logged (who, when, by whom) for SOC2 or compliance.
- Keep JWT_SECRET and JWT_REFRESH_SECRET strong and not committed to the repo.
Quick reference: key files
| Purpose | File(s) |
|---|---|
| App entry, CORS, DB middleware, route mounting | Meridian/backend/app.js |
| Per-tenant and global DB connections | Meridian/backend/connectionsManager.js |
| Request-scoped tenant models | Meridian/backend/services/getModelService.js |
| Global (cross-tenant) models | Meridian/backend/services/getGlobalModelService.js |
| Auth flows (get/create GlobalUser, TenantMembership, issueTokens) | Meridian/backend/services/authGlobalService.js |
| Session create/validate (global and legacy) | Meridian/backend/utilities/sessionUtils.js |
| JWT auth and tenant user resolution | Meridian/backend/middlewares/verifyToken.js |
| Platform or tenant admin check | Meridian/backend/middlewares/requireAdmin.js |
| Auth routes (login, register, refresh, join-tenant, validate-token) | Meridian/backend/routes/authRoutes.js |
| Platform admins API | Meridian/backend/routes/adminRoutes.js |
| SAML (then issue tokens via authGlobalService) | Meridian/backend/routes/samlRoutes.js |
| Event RSVP (global guest, globalAttendees) | Meridian/backend/events/routes/eventRoutes.js |
| Global schemas | Meridian/backend/schemas/globalUser.js, platformRole.js, tenantMembership.js, globalSession.js |
| Seed platform admins | Meridian/backend/scripts/seedPlatformAdmins.js |
| Migrate existing users to global identity | Meridian/backend/scripts/migrateUsersToGlobalIdentity.js |
| Platform Admins UI | Meridian/frontend/src/pages/Admin/PlatformAdminsPage/ |
Related docs
Backend Best Practices
Multi-tenancy, getModelService, getGlobalModelService, requireAdmin, and response shapes
Session Management
Multi-device sessions; sessions are stored in global DB when using global identity
SAML
SAML login flows; after success, tokens are issued via authGlobalService (global identity)
Atlas Backend
Org and event routes; admin routes use requireAdmin for platform admins