Liking cljdoc? Tell your friends :D

Multi-Tenancy

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. H2 and SQLite skip provisioning with a warning and are fine for unit/integration tests.

Prerequisites

  • PostgreSQL database (for production)

  • boundary/tenant and boundary/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 three tables in the public schema: tenants, tenant_memberships, and tenant_member_invites.

Step 1 — Configure Integrant

Add the tenant components to your config.edn:

;; Initialise DB tables (run once on startup)
:boundary/tenant-db-schema
{:db-context #ig/ref :boundary/db-context
 :logger     #ig/ref :boundary/logger}

;; Tenant CRUD
:boundary/tenant-repository
{:db-context     #ig/ref :boundary/db-context
 :logger         #ig/ref :boundary/logger
 :error-reporter #ig/ref :boundary/error-reporter}

:boundary/tenant-service
{:tenant-repository #ig/ref :boundary/tenant-repository
 :logger            #ig/ref :boundary/logger
 :metrics-emitter   #ig/ref :boundary/metrics-emitter
 :error-reporter    #ig/ref :boundary/error-reporter}

;; Membership management
:boundary/membership-repository
{:db-context     #ig/ref :boundary/db-context
 :logger         #ig/ref :boundary/logger
 :error-reporter #ig/ref :boundary/error-reporter}

:boundary/membership-service
{:repository      #ig/ref :boundary/membership-repository
 :logger          #ig/ref :boundary/logger
 :metrics-emitter #ig/ref :boundary/metrics-emitter
 :error-reporter  #ig/ref :boundary/error-reporter}

;; Email invite flow
:boundary/invite-repository
{:db-context     #ig/ref :boundary/db-context
 :logger         #ig/ref :boundary/logger
 :error-reporter #ig/ref :boundary/error-reporter}

:boundary/invite-service
{:repository            #ig/ref :boundary/invite-repository
 :membership-repository #ig/ref :boundary/membership-repository
 :logger                #ig/ref :boundary/logger
 :metrics-emitter       #ig/ref :boundary/metrics-emitter
 :error-reporter        #ig/ref :boundary/error-reporter}

;; Routes (merged into top-level router)
:boundary/tenant-routes
{:tenant-service #ig/ref :boundary/tenant-service
 :db-context     #ig/ref :boundary/db-context}

:boundary/membership-routes
{:service #ig/ref :boundary/membership-service}

Step 2 — Add middleware to your Ring stack

Three middlewares must be composed in the correct order. Each one depends on data set by the previous.

(require '[boundary.platform.shell.interfaces.http.tenant-middleware :as tm]
         '[boundary.tenant.shell.membership-middleware :refer [wrap-tenant-membership]])

(defn build-handler [routes system]
  (-> (create-router routes)
      ;; 3. Enrich request with :tenant-membership (needs :user + :tenant)
      (wrap-tenant-membership (:boundary/membership-service system))
      ;; 2. Resolve :tenant from subdomain / JWT / header; set search_path
      (tm/wrap-multi-tenant (:boundary/tenant-service system)
                            {:require-tenant? true
                             :cache (tm/create-tenant-cache)})
      ;; 1. Authenticate user; sets :user on request
      (wrap-user-authentication (:boundary/user-service system))))

After this middleware stack the request map contains:

{:user             {:id #uuid "..." :email "..." :role :user}
 :tenant           {:id #uuid "..." :slug "acme-corp" :status :provisioned}
 :tenant-membership {:id #uuid "..." :tenant-id #uuid "..." :user-id #uuid "..."
                     :role :admin :status :active}}

Tenant resolution strategies

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

Options

(tm/wrap-multi-tenant handler tenant-service
  {:require-tenant? true    ; return 404 when tenant cannot be resolved (default false)
   :cache           cache}) ; atom-based cache, TTL 1 hour (omit to disable caching)

Step 3 — Protect routes with interceptors

Import the interceptors from the user library:

(require '[boundary.user.shell.http-interceptors :as auth])

Require active membership (any role)

{:path    "/api/tenants/:tenant-id/dashboard"
 :methods {:get {:handler   dashboard-handler
                 :interceptors [auth/require-authenticated
                                auth/require-tenant-member]}}}

Require a specific role

{:path    "/api/tenants/:tenant-id/settings"
 :methods {:put {:handler   update-settings-handler
                 :interceptors [auth/require-authenticated
                                (auth/require-tenant-role #{:admin})]}}}

Require tenant admin (shorthand)

{:path    "/api/tenants/:tenant-id/members"
 :methods {:post {:handler   invite-member-handler
                  :interceptors [auth/require-authenticated
                                 auth/require-tenant-admin]}}}

Web routes — HTML redirect instead of JSON 403

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:

{:path    "/web/tenants/:tenant-id/settings"
 :methods {:get {:handler   settings-page-handler
                 :interceptors [auth/require-authenticated
                                auth/require-web-tenant-admin]}}}

Step 4 — Bootstrap the first admin member

A freshly created tenant has no members. Use bootstrap-open? to check, then create the first active membership directly:

(require '[boundary.tenant.core.membership :as m]
         '[boundary.tenant.ports :as ports])

(defn on-tenant-created! [tenant user-id membership-service]
  (when (ports/bootstrap-open? membership-service (:id tenant))
    (ports/create-active-membership! membership-service
      {:tenant-id (:id tenant)
       :user-id   user-id
       :role      :admin})))

Tenant CRUD

Create a tenant

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",
 "createdAt": "2026-03-27T..."}

Lifecycle operations

# Provision PostgreSQL schema
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>"

Tenant lifecycle states

:active       → Created, schema not yet provisioned
:provisioned  → PostgreSQL schema created and ready
:suspended    → Access disabled (data preserved)
:deleted      → Soft delete; schema NOT auto-dropped

Slug rules

Slugs must be lowercase alphanumeric + hyphens, 2–100 characters:

(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"

Membership management

Invite a user

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"}'

Accept an invitation

curl -X POST http://localhost:3000/api/v1/memberships/<membership-id>/accept \
  -H "Authorization: Bearer <user-token>"

Update role or status

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"}'

Revoke membership

curl -X DELETE http://localhost:3000/api/v1/tenants/<tenant-id>/memberships/<membership-id> \
  -H "Authorization: Bearer <admin-token>"

Available roles and statuses

Roles: :admin, :member, :viewer, :contractor

Membership lifecycle:
  :invited  ──accept──→  :active
  :active   ──suspend──→ :suspended
  :active   ──revoke──→  :revoked
  :invited  ──revoke──→  :revoked

Email invite flow

For inviting users who don’t have an account yet (or whose user ID you don’t know), use the token-based external invite flow.

Create an invite

(require '[boundary.tenant.ports :as ports])

;; Returns {:invite {...} :token "<raw-token>"}
;; Send :token to the user via email — it is NOT stored in the database
(let [{:keys [invite token]}
      (ports/create-external-invite! invite-service
        {:tenant-id  tenant-id
         :email      "alice@example.com"
         :role       :member
         :expires-in (java.time.Duration/ofDays 7)})]
  (email/send-invite! email-service (:email invite) token))

Accept an invite (two-phase)

;; Phase 1 — validate (no mutations)
(let [result (ports/load-external-invite-for-acceptance invite-service raw-token)]
  ;; result = {:invite {...} :tenant {...}}
  ;; throws ex-info {:type :validation-error} when expired or already used

  ;; Phase 2 — atomic accept; creates membership in same transaction
  (ports/accept-external-invite! invite-service raw-token user-id
    {:after-accept-tx (fn [tx invite membership]
                        ;; 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.

Executing queries in tenant schema context

(require '[boundary.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"])))

Common pitfalls

  • Middleware order matterswrap-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 invitesload-external-invite-for-acceptance throws :validation-error for expired tokens. Surface this to the user as a 400, not a 500.

  • H2 provisioningprovision-tenant! silently skips on H2. 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).

Testing

# Full tenant test suite
clojure -M:test:db/h2 :tenant

# Focused suites
clojure -M:test:db/h2 --focus boundary.tenant.core.membership-test
clojure -M:test:db/h2 --focus boundary.tenant.shell.membership-service-test
clojure -M:test:db/h2 --focus boundary.tenant.shell.invite-service-test
clojure -M:test:db/h2 --focus boundary.tenant.integration-test

See tenant AGENTS.md for the full library reference.

Can you improve this documentation?Edit on GitHub

cljdoc builds & hosts documentation for Clojure/Script libraries

Keyboard shortcuts
Ctrl+kJump to recent docs
Move to previous article
Move to next article
Ctrl+/Jump to the search field
× close