Multi-tenancy
Ever Async runs in two shapes from one binary:
| Self-hosted | SaaS (app-async.ever.co) | |
|---|---|---|
| Tenants | exactly one, implicit (DEFAULT_TENANT) | many, isolated |
| Auth | local_trusted (none) or token / local users | oauth — SSO against Slack, Discord, GitHub, Google, Ever Gauzy |
| Storage | SQLite by default — Postgres, MySQL or in-memory if you want them | Postgres |
| Credentials | env 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.
Everything on this page is implemented except per-tenant credentials and the bot installation flow. Concretely:
- ✅
Tenant,Membership,WorkspaceBindingand atenantcolumn 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
WorkspaceBindingyet. The row is read everywhere it matters, but the "Add to Slack" installation flow that would create one does not exist —auth-oauthimplements 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
Storagetrait 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:
- 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 whatauth-oauthimplements. The scopes it requests are identity scopes only: none of them let Ever Async read or post messages. - 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_envis 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.