Skip to main content

Multi-tenancy

Ever Async runs in two shapes from one binary:

Self-hostedSaaS (app-async.ever.co)
Tenantsexactly one, implicit (DEFAULT_TENANT)many, isolated
Authlocal_trusted (none) or token / local usersoauth — SSO against Slack, Discord, GitHub, Google, Ever Gauzy
StorageSQLite by default — Postgres, MySQL or in-memory if you want themPostgres
Credentialsenv vars at boot, resolved through *_env⏳ planned — per-tenant, captured at OAuth install, encrypted at rest

A company self-hosting for itself sees none of the tenancy machinery: one implicit tenant, no login wall, nothing to configure. That is not a compatibility shim — it is the same code path with a tenant count of one.

What is built, and what is not

Everything on this page is implemented except per-tenant credentials and the bot installation flow. Concretely:

  • ✅ Tenant, Membership, WorkspaceBinding and a tenant column leading every primary key and index, across all four storage backends.
  • ✅ Tenant resolution at sign-in (binding → membership → fresh tenant), the dashboard's switcher, and POST /api/v1/auth/switch-tenant.
  • ✅ Event routing: a bound workspace decides the tenant an inbound message is recorded against, and a tenant id in a channel payload is ignored.
  • ⏳ Nothing writes a WorkspaceBinding yet. The row is read everywhere it matters, but the "Add to Slack" installation flow that would create one does not exist — auth-oauth implements the identity flow only, and says so. Until it lands, a binding has to be inserted by whatever provisions the tenant.
  • ⏳ Plugins are still built once, globally, from environment variables. There is no per-tenant credential store, and the Storage trait has no methods for one.

Why tenancy makes self-signup safe​

Ever Async's API is writable: it changes per-channel policy and the team charter. On a single-tenant install, a public signup form would let a stranger register and then change your settings — which is why signup was deliberately refused before tenancy existed.

With tenancy, a new signup lands in their own empty tenant and can only ever reach their own rows. The isolation is what makes open registration correct rather than reckless, so it must be enforced in the storage layer, not in the UI.

The isolation boundary​

Tenant is the root boundary. Everything user-visible hangs off it:

  • policies (tenant × channel × conversation)
  • the charter (one per tenant, not one global row)
  • events, nudges, digest counters
  • connected tools and their credentials
  • chat workspaces (a Slack team, a Discord guild)

A Principal therefore carries the tenant it is acting in, plus the set of tenants it may act in — a person can belong to several (contractor, agency, someone who signed up personally and was later invited to a company).

Two flows that are easy to confuse​

"Sign in with Slack" and "Add to Slack" are different OAuth flows and Ever Async needs both:

  1. Identity (Slack OpenID Connect, Discord OAuth2 identify, Ever Gauzy's authorization-code server) — proves who the human is, so they can open the dashboard. Yields a user id, a verified email, and for Slack and Ever Gauzy a workspace id. ✅ This is what auth-oauth implements. The scopes it requests are identity scopes only: none of them let Ever Async read or post messages.
  2. Installation (Slack OAuth v2 with bot scopes, Discord bot invite) — yields the bot token the channel plugin uses to read and post, plus a workspace or guild id. ⏳ Deliberately not implemented. [channels.slack] bot_token_env is how a bot token gets in today.

The installation is what would bind a chat workspace to a tenant. The identity flow already resolves a login against such a binding: someone signing in with Slack from team T123 lands in whichever tenant is bound to T123, with no tenant picker at all in the common case — and someone signing in with Ever Gauzy lands via their Gauzy tenantId the same way. Until the installation flow exists, the binding row has to come from somewhere else.

Keeping them separate also keeps the failure modes separate — an expired bot token breaks message handling but not login, and a revoked personal login does not uninstall the bot.

Credentials stop being environment variables — eventually​

Self-hosted, every secret is an env var named by a *_env key, resolved at boot. That model cannot survive multi-tenancy: tenant B's Slack token arrives at runtime when they click Add to Slack, long after the process started, and must never be visible to tenant A.

⏳ This is the main piece of SaaS that is still design, not code. The intended shape: per-tenant credentials stored encrypted at rest, and plugins instantiated per tenant rather than once globally. The plugin traits do not change — only who constructs them, and when, which is why it can land later without a redesign.

Today the CLI's assemble.rs builds one instance of each plugin from one config file at startup, for every tenant. A single-tenant self-host is therefore complete; a multi-tenant deployment shares one set of channel and connector credentials.

Picking a database​

Supported choices, one config line apart:

[storage]
driver = "sqlite" # default — one file, nothing to run
path = "everasync.db"

# driver = "postgres" # self-hosted or SaaS
# url_env = "EVERASYNC_DATABASE_URL"

# driver = "orm" # SQLite, Postgres or MySQL — the URL scheme picks
# url_env = "EVERASYNC_DATABASE_URL"

SQLite is the default and stays that way for self-hosting. It needs no server, no backup story beyond copying a file, and for one company its single-writer nature is a feature rather than a limit — it is what makes webhook de-duplication and /async off trivially consistent.

Self-hosters who want Postgres can have it. Teams that already run Postgres, want more than one replica, or want their existing backup and HA story to cover Ever Async simply point driver at it. Nothing else changes.

SaaS requires Postgres. A single-writer file cannot back a horizontally scaled deployment: two replicas on SQLite would each hold their own copy and silently break de-duplication. That is the one place the choice is made for you.

The Storage trait is what makes this a configuration decision rather than a fork — every backend implements the same interface, including the tenant column, so no application code knows which one it is talking to.

driver = "orm" is the same storage written once. The SQLite and Postgres backends are two hand-written copies of one schema, and two copies drift: the Postgres one creates idx_events_tenant_at and idx_pending_due, the SQLite one never got either. The SeaORM backend describes the tables in a single place, adds MySQL as a build feature rather than a third backend, and replaces the ad-hoc CREATE TABLE IF NOT EXISTS with versioned migrations.

It ships beside the hand-written backends rather than replacing them, and it adopts an existing database in place instead of demanding a fresh one — including a pre-tenancy everasync.db, whose rows all land in the default tenant. Both can be pointed at the same file, so switching to it is reversible.

Tenant switching​

A principal with more than one tenant gets a switcher. Switching re-issues the session bound to the new tenant rather than passing a tenant id from the client — a client-supplied tenant id is an authorization bug waiting to happen, because any caller could then simply ask for someone else's.