Status: Draft (design proposal — nothing here is implemented). This document defines the target architecture for operating OpenWA as a multi-tenant platform: tenant identity, access control, 2FA, per-tenant branding, isolation, quotas, and the migration path from today's single-operator model.
Goals
- One OpenWA deployment safely hosts many independent organizations (tenants), each with its own WhatsApp sessions, users, API keys, branding, plugins, and data — with no cross-tenant visibility.
- Enterprise-grade identity: named users with per-tenant roles and optional/enforced two-factor authentication (TOTP), alongside the existing API-key model for programmatic access.
- A migration path where today's single-tenant installations keep working unchanged (everything lands in a default tenant).
Non-goals (explicitly out of scope for the first phases)
- Billing, seat management, and subscription metering.
- Per-tenant database servers or per-tenant process isolation (see the isolation decision in 28.4).
- Public self-service tenant signup (tenants are provisioned by a platform admin).
OpenWA today is multi-session, not multi-tenant. What already exists and is reused by this design:
| Primitive | Where | Reuse |
|---|---|---|
| Many WhatsApp sessions per deployment, engine-isolated | session.service.ts |
Sessions become tenant-owned resources |
API keys with roles (ADMIN/OPERATOR/VIEWER) and an allowedSessions scope; list + stats already scope-filtered |
api-key.entity.ts:37, api-key.guard.ts:64-77, session.controller.ts:68-70 |
Evolves into tenant-scoped keys; the enforcement pattern (guard-level session resolution) is exactly where tenant checks plug in |
| Per-session plugin activation and per-session config overrides | sessionConfig in the plugin loader |
Per-tenant plugin policy on top |
All business data keyed by sessionId |
messages, audit_log, webhooks, templates | Adding tenantId alongside is mechanical |
| Per-request ALS actor stamping (audit attribution) | request-context.ts |
Audit gains tenantId at the same point |
The gaps (what makes it not multi-tenant today): no tenant entity, no named users or memberships, dashboard identity is the API key itself, all branding is global (engine device name, dashboard theme/logo/title), storage/backups/quota are global, and there is no tenant-scoped audit boundary.
- Platform admin — operates the deployment (today's
ADMINkey owner). Provisions tenants, never reads tenant message content by default (see 28.14). - Tenant — an organization. Owns sessions, users, API keys, plugins-activation, branding,
quotas, and a data partition. Identified by
tenantId(uuid) + a stableslug. - Membership — a named user's relationship to a tenant, with a tenant role
(
owner,admin,operator,viewer). One user may belong to many tenants. - Tenant role vs platform role — today's global
ApiKeyRolebecomes the platform role; the tenant role governs what a user/key may do inside one tenant. The full matrix is in 28.5.3.
Everything tenant-owned carries tenantId: sessions, API keys, users (via memberships), plugin
activations, webhook endpoints, templates, and every row derived from sessions (messages, audit,
search indexes are recomputed from the sessionId → tenantId mapping, so no per-row backfill of
message tables is required).
Chosen: shared database, tenantId on every tenant-owned row, enforced at the service/guard
boundary — the same boundary that already enforces allowedSessions today.
| Option | Verdict | Why |
|---|---|---|
Row-level (tenantId columns, shared schema) |
Chosen | Matches the existing sessionId pattern, zero-infra migration, per-tenant backup/filtering still possible, and PostgreSQL RLS remains a later hardening option |
| Schema-per-tenant (Postgres) | Later option | Real isolation at the DB layer, but migrations × N tenants and cross-tenant platform tables get awkward; revisit if a customer contract demands it |
| Database-per-tenant | Rejected for now | Operational complexity (migrations, pooling, backup orchestration) buys little at this scale |
Hard rule: no query returns rows without a tenant predicate. The guard resolves the tenant
context once per request (from key/user + route session) and the service layer composes with it —
mirroring how allowedSessions is enforced in ApiKeyGuard and SessionService.findAll today,
so there is one chokepoint to audit instead of a filter remembered per query.
New users (email, display name, password hash (argon2id), TOTP fields, status) and
memberships (userId, tenantId, tenantRole, createdAt). Login moves from "paste one API
key" to email+password (+2FA), while API keys remain first-class for programmatic access —
now owned by a tenant (tenantId + optional allowedSessions narrowing inside it) rather than
by the deployment.
2FA is included because the admin surface controls real WhatsApp sessions and message flows — shared API keys already outgrew "one strong secret is enough".
- Enrollment: standard TOTP secret (shown once as text + otpauth:// QR), activated only after one valid code; 10 single-use backup codes (hashed at rest) for device loss.
- Enforcement modes: per-user opt-in (default), tenant-enforced (tenant owner requires it for all memberships), platform-enforced (deployment-wide).
- Login flow: email+password → short-lived
preAuthToken→ TOTP/backup-code challenge → full session token.preAuthTokencan do nothing else and expires in 5 minutes. - Reset: tenant owner/platform admin can reset a member's 2FA (audit-logged); backup codes are single-use and regenerating them invalidates the previous set.
- API keys are not behind 2FA (they are secrets, not logins) — same model as GitHub/AWS.
| Capability | Platform admin | Tenant owner | Tenant admin | Tenant operator | Tenant viewer |
|---|---|---|---|---|---|
| Provision/suspend tenants | ✓ | – | – | – | – |
| Manage tenant members & roles | – | ✓ | ✓ | – | – |
| Manage tenant API keys | – | ✓ | ✓ | – | – |
| Create/delete sessions | – | ✓ | ✓ | – | – |
| Send messages, manage webhooks/templates | – | ✓ | ✓ | ✓ | – |
| View sessions, messages, logs | – | ✓ | ✓ | ✓ | ✓ |
| Configure tenant branding | – | ✓ | ✓ | – | – |
| Manage own 2FA | every named user | ||||
| Reset members' 2FA | ✓ | ✓ | – | – | – |
OIDC login (Google/Microsoft/any IdP) and SCIM provisioning are deliberately deferred: they only make sense once users/memberships exist, and the membership model here is designed to accept them without schema change.
- Linked-device identity: the engine's browser display name (the
BAILEYS_BROWSERtuple) resolves per session:session.config.browserName→tenant.branding.deviceName→ globalBAILEYS_BROWSER_NAMEenv →'OpenWA'. This supersedes the global-only env approach for white-label tenants while keeping a global fallback. - Dashboard:
tenant.branding(display name, logo URL, optional accent color within the existing token system) applied at login and in the shell; global defaults when absent. - Outbound identity: webhook
User-Agentand dashboard page titles take the tenant brand when present.
- Session/engine data:
SESSION_DATA_PATH/<tenantId>/<sessionId>(migration moves existing dirs under the default tenant). - Media/object storage: S3 prefix per tenant (or per-tenant bucket policy later); local storage mirrors the same layout.
- Backup/restore: per-tenant export (sessions + config + keys + branding, message tables optionally excluded) so a tenant can be moved or archived independently.
New tenant_quotas (maxSessions, maxUsers, maxApiKeys, maxMessagesPerDay, maxWebhookEndpoints,
maxPluginInstalls) with sensible unlimited defaults. The existing per-IP/per-key throttlers stay
as coarse guards; per-tenant counters are checked at the same chokepoints (session create, key
create, send path).
Installation stays platform-global (one code copy), activation and config stay per session,
governed by an optional tenant policy: tenant.pluginPolicy = { allow: [...], deny: [...] }
evaluated at install/enable time. Tenant secrets live only in that tenant's sessionConfig slice,
as today.
- Audit log gains
tenantId(stamped from the same ALS actor point asapiKeyIdtoday) and becomes tenant-filterable; platform admins see the deployment-wide stream. - Metrics (Prometheus) label session-scoped series with
tenantIdwhere cheap; dashboards can slice per tenant. - Logs keep the existing structured shape; the tenant id joins the per-request log context
alongside
requestId/actor.
POST/GET/PATCH/DELETE /api/platform/tenants(platform admin), with suspend/resume.GET/POST/PATCH/DELETE /api/tenants/:tenantId/members(tenant owner/admin).POST /api/auth/login(email+password),POST /api/auth/2fa/verify,POST /api/auth/2fa/enroll,POST /api/auth/2fa/activate,POST /api/auth/2fa/backup-codes,POST /api/auth/2fa/reset(privileged).PATCH /api/sessions/:idgains an optionaltenantId(assign on create/move by platform admin).- Existing tenant-scoped routes resolve the tenant from the key/user;
X-Tenant-Idheader is accepted only from platform admins (tenant switching without a second key). - Public config endpoint exposes the active tenant's branding for the login page.
- Login: email+password → optional TOTP challenge; tenant switcher in the sidebar for multi-tenant users; tenant admin pages (members, keys, branding, quotas); a 2FA section in the user menu (enroll, backup codes, reset for admins).
- Every list/view is implicitly tenant-scoped; platform admins get an explicit tenant picker.
- Create the default tenant and backfill: all existing sessions, API keys, plugin activations, and templates point at it; existing full-access API keys keep working untouched (they become keys of the default tenant).
- Ship behind
MULTITENANCY_ENABLED=true(default off): when off, the tenant layer is a no-op pass-through to the default tenant and behavior is byte-identical to today. - Dashboard gains user login as an additional method; API-key login stays supported indefinitely.
- Single chokepoint: tenant context resolved in the guard; services never accept a tenant id from the client except the platform-admin override (28.11).
- No platform-admin read-path to tenant content by default (support operations go through an explicit, audit-logged impersonation action).
- Required test matrix: cross-tenant read on every tenant-owned route (sessions, messages, stats, webhooks, templates, plugins, audit), membership/role enforcement, 2FA enrollment and challenge, backup-code single-use, per-tenant quota denial, branding fallback order, migration idempotency (second boot is a no-op).
| Phase | Contents | Sizing note |
|---|---|---|
| P0 — Foundations | Tenant entity + tenantId everywhere, default-tenant migration, guard chokepoint, tenant-scoped keys, cross-tenant test matrix, MULTITENANCY_ENABLED flag |
The platform everything hangs off; largest single block |
| P1 — Users & 2FA | users/memberships, email+password login, TOTP enroll/challenge/backup codes, tenant roles matrix, dashboard login + tenant switcher | Independent of P0's data model but layered on it |
| P2 — Branding, quotas, observability | per-tenant branding (device name, dashboard, UA), tenant quotas + counters, audit tenantId, per-tenant backup/export |
Mostly small, parallelizable pieces |
| P3 — Enterprise extras | OIDC/SSO, SCIM provisioning, schema-per-tenant evaluation, audit export, impersonation support flow | Only after P0–P2 prove out |
- Do platform admins ever get a legitimate read path into tenant message content, and if so under what audit/impersonation ceremony?
- Should tenant slug be immutable once sessions exist (webhook URLs may embed it later)?
- Is per-message-type or per-recipient rate limiting part of tenant quotas, or left to the existing anti-abuse layer?
- Does 2FA enforcement also cover API-key management actions (create/revoke), not just login?