clojure -M:migrate up
The tenant library provides full multi-tenancy: PostgreSQL schema-per-tenant isolation,
membership management with roles, and a secure email invite flow.
Schema-per-tenant provisioning requires PostgreSQL. On SQLite and MySQL the
application refuses to boot with :wagoe/tenant configured, and on H2 too unless
:wagoe/tenant sets :allow-h2? true, as a test profile does. H2 then skips
provisioning (the provision route answers 501) and is fine for unit/integration tests.
|
PostgreSQL database (for production)
wagoe/tenant and wagoe/platform libraries on the classpath
User authentication middleware already configured (tenant middleware runs after auth)
Run database migrations before starting the server:
clojure -M:migrate up
This creates four tables in the public schema:
tenants, tenant_memberships, tenant_member_invites and tenant_audit_log.
Switch the module on under :active in resources/conf/<env>/config.edn:
:wagoe/tenant
{:enabled? true}
That brings in the tenant, membership and invite repositories and services,
their routes and the tenant middleware (:wagoe/tenant-db-schema,
:wagoe/tenant-service, :wagoe/membership-service,
:wagoe/invite-service, :wagoe/tenant-routes, :wagoe/membership-routes,
:wagoe/tenant-http-middleware, and the rest). There is nothing to wire by
hand.
You do not compose this stack by hand. The tenant module builds it in
:wagoe/tenant-http-middleware — tenant resolution first, then membership, each
contributed only if the service it needs is present — and the application hands
it to platform’s HTTP handler, with every module’s middleware, as :module-middleware:
:wagoe/http-handler
{:module-middleware [#ig/ref :wagoe/tenant-http-middleware]
;; … the rest of the handler's refs
}
Both keys are written for you when the modules are enabled — there is nothing to wire by hand.
Membership enrichment reads [:user :id], so something has to have
authenticated the request before it runs. That is the user module’s
:wagoe/user-http-middleware, which platform takes as :auth-middleware and
places outermost — ahead of the tenant middleware. It sets :user when the
request carries valid credentials and does nothing when it does not, so it is
safe on public routes; rejecting stays with the per-route guards in Step 3.
After the stack has run, the request map contains:
{:user {:id #uuid "..." :email "..."}
:tenant {:id #uuid "..." :slug "acme-corp" :status :active}
:tenant-membership {:id #uuid "..." :tenant-id #uuid "..." :user-id #uuid "..."
:role :admin :status :active}}
wrap-multi-tenant (and wrap-tenant-resolution underneath) tries three sources in order:
1. Subdomain acme-corp.myapp.com → slug = "acme-corp"
2. JWT claim token payload :tenant-slug or :tenant-id
3. HTTP header X-Tenant-Slug or X-Tenant-Id
(tm/wrap-multi-tenant handler tenant-service db-context
{:require-tenant? true ; return 404 when tenant cannot be resolved (default false)
:cache cache}) ; atom-based cache, TTL 1 hour (default: a new one)
Import the interceptors from the user library:
(require '[wagoe.user.shell.http-interceptors :as auth])
Paths below are relative: the ones under :api in your module’s contribution
are served at /api/v1/…, the ones under :web at /web/….
["/tenants/:tenant-id/dashboard"
{:get {:handler dashboard-handler
:interceptors [auth/require-authenticated
auth/require-tenant-member]}}]
["/tenants/:tenant-id/settings"
{:put {:handler update-settings-handler
:interceptors [auth/require-authenticated
(auth/require-tenant-role #{:admin})]}}]
["/tenants/:tenant-id/members"
{:post {:handler invite-member-handler
:interceptors [auth/require-authenticated
auth/require-tenant-admin]}}]
For server-rendered HTML routes, use require-web-tenant-admin.
It redirects to /web/login?return-to=<uri> for /web/* paths
instead of returning a bare JSON 403:
["/tenants/:tenant-id/settings"
{:get {:handler settings-page-handler
:interceptors [auth/require-authenticated
auth/require-web-tenant-admin]}}]
A freshly created tenant has no members.
Use bootstrap-open? to check (true while the tenant has no active admin), then create the first active membership directly:
(require '[wagoe.tenant.ports :as ports])
(defn on-tenant-created! [tenant user-id membership-service]
(when (ports/bootstrap-open? membership-service (:id tenant))
(ports/bootstrap-first-member membership-service (:id tenant) user-id :admin)))
curl -X POST http://localhost:3000/api/v1/tenants \
-H "Authorization: Bearer <admin-token>" \
-H "Content-Type: application/json" \
-d '{"name": "ACME Corp", "slug": "acme-corp"}'
Response:
{"id": "...", "slug": "acme-corp", "name": "ACME Corp", "status": "active",
"created-at": "2026-03-27T..."}
# Provision PostgreSQL schema (idempotent; creating a tenant already does this)
curl -X POST http://localhost:3000/api/v1/tenants/<id>/provision \
-H "Authorization: Bearer <admin-token>"
# Suspend (disables access, preserves data)
curl -X POST http://localhost:3000/api/v1/tenants/<id>/suspend \
-H "Authorization: Bearer <admin-token>"
# Re-activate
curl -X POST http://localhost:3000/api/v1/tenants/<id>/activate \
-H "Authorization: Bearer <admin-token>"
:active → Created; on PostgreSQL its schema is created with it
:suspended → Access disabled (data preserved)
:deleted → Soft delete; schema NOT auto-dropped
Slugs must be lowercase alphanumeric + hyphens, 2–56 characters, so the schema name (tenant_ + slug) fits PostgreSQL’s 63-byte identifier limit:
(tenant/valid-slug? "acme-corp") ;=> true
(tenant/valid-slug? "ACME") ;=> false ; uppercase
(tenant/valid-slug? "acme_corp") ;=> false ; underscore
;; Slug → PostgreSQL schema name (hyphens become underscores)
(tenant/slug->schema-name "acme-corp") ;=> "tenant_acme_corp"
curl -X POST http://localhost:3000/api/v1/tenants/<tenant-id>/memberships \
-H "Authorization: Bearer <admin-token>" \
-H "Content-Type: application/json" \
-d '{"userId": "<user-id>", "role": "member"}'
curl -X POST http://localhost:3000/api/v1/memberships/<membership-id>/accept \
-H "Authorization: Bearer <user-token>"
curl -X PUT http://localhost:3000/api/v1/tenants/<tenant-id>/memberships/<membership-id> \
-H "Authorization: Bearer <admin-token>" \
-H "Content-Type: application/json" \
-d '{"role": "admin"}'
curl -X DELETE http://localhost:3000/api/v1/tenants/<tenant-id>/memberships/<membership-id> \
-H "Authorization: Bearer <admin-token>"
Roles: :admin, :member, :viewer, :contractor
Membership lifecycle:
:invited ──accept──→ :active
:active ──suspend──→ :suspended
:active ──revoke──→ :revoked
:invited ──revoke──→ :revoked
For inviting users who do not yet have an account, or whose user ID you do not know, use the token-based external invite flow.
(require '[wagoe.tenant.ports :as ports])
;; Returns the invite with :invite-token, the raw token.
;; Send it to the user via email — it is NOT stored in the database.
;; :expires-at defaults to 7 days from now.
(let [invite (ports/invite-external-member invite-service
tenant-id "alice@example.com" :member
{:expires-at (.plus (java.time.Instant/now) (java.time.Duration/ofDays 7))})]
(email/send-invite! email-service (:email invite) (:invite-token invite)))
;; Phase 1 — validate (no mutations)
(let [invite (ports/load-external-invite-for-acceptance invite-service {:token raw-token})]
;; the pending invite; throws :not-found when there is none for the token,
;; :validation-error when it has expired
;; Phase 2 — atomic accept; creates membership in same transaction
;; Returns {:invite ... :membership ... :effects {:after-accept-tx result}}
(ports/accept-external-invite! invite-service
{:token raw-token
:accepted-by-user-id user-id
:hooks {:after-accept-tx (fn [{:keys [invite membership tx-context]}]
;; optional: run additional work in same transaction
)}}))
| The raw token is never stored. Only the SHA-256 hash is kept in the database. Always send the raw token to the user and discard it after. |
(require '[wagoe.tenant.shell.provisioning :as provisioning])
(provisioning/with-tenant-schema db-ctx "tenant_acme_corp"
(fn [tx]
;; Executes: SET search_path TO tenant_acme_corp, public
(jdbc/execute! tx ["SELECT * FROM orders"])))
Middleware order matters — wrap-tenant-membership must run after both user auth and tenant resolution.
Wrong order → :tenant-membership is always nil.
Re-inviting an existing member — throws {:type :conflict} because (tenant_id, user_id) is unique.
Check for an existing membership first.
Expired invites — load-external-invite-for-acceptance throws :validation-error for expired tokens.
Surface this to the user as a 400, not a 500.
H2 provisioning — provision-tenant! throws :not-supported on H2, and creating a tenant skips its schema. Tests that check schema isolation
must run against PostgreSQL.
require-tenant-member returns 403 — check that wrap-tenant-membership is in the middleware stack
and that the user has an :active membership (not :invited or :suspended).
# Full tenant test suite
clojure -M:test:test/pg :tenant
# Focused suites
clojure -M:test:test/pg :tenant --focus wagoe.tenant.core.membership-test
clojure -M:test:test/pg :tenant --focus wagoe.tenant.shell.membership-service-test
clojure -M:test:test/pg :tenant --focus wagoe.tenant.shell.invite-service-test
clojure -M:test:test/pg :tenant --focus wagoe.tenant.integration-test
See libs/tenant/AGENTS.md for the full library reference.
Can you improve this documentation? These fine people already did:
thijscreemers & Thijs CreemersEdit on GitHub
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |