Skip to content

Multi-Tenant Operator Guide (C-B / v1.9)

How to run one Cesivi farm that hosts many isolated tenants — each with its own webs, lists, and storage — behind tenant routing with a deny-by-default cross-tenant guard.

Default is single-tenant. Cesivi:MultiTenant:Enabled defaults to false. Every existing farm keeps byte-identical behaviour and pays zero multi-tenant cost. This guide is only for farms that opt in.


1. What multi-tenant mode changes

Aspect false (default) true (multi-tenant)
Tenants one implicit default tenant many, provisioned via /_farm/tenants
Request routing every host served identically host (then path-prefix) → tenant; unresolved host → 404
Storage keys Default\|\|site\|\|… tenant/Default\|\|site\|\|… (per-tenant namespace)
Cross-tenant reads n/a denied by default (a tenant can never see another's data)
Boot seeds the single default tenant seeds nothing; tenants are seeded on provision
Cost when unused none (the flag is read once at startup)

Internally this is enforced at two layers, both keyed by the resolved tenant: the storage decorator (TenantScopedStorageService, namespaces every storage key + GUID-index guard) and the cache decorator (TenantScopedCacheService, namespaces every cached site/web/list object). Both are pass-throughs in false mode.


2. HARD pre-condition — FarmAdmin claim provenance

Every /_farm/tenants route requires the CentralAdmin policy (RequireRole("FarmAdmin")). Before you enable multi-tenant mode in production you MUST ensure FarmAdmin is granted only by a trusted identity provider / authorization policy.

⚠️ The bundled demo MockAuthenticationHandler grants FarmAdmin to every non-noadmin user. That is fine for tests and demos but unsafe for a real farm — it would let any authenticated caller provision, suspend, or DELETE tenants (deleting a tenant purges its data). Wire FarmAdmin to a real IdP claim or group (see IDENTITY_PROVIDERS.md / MULTI-PROVIDER-IDP.md) before opting in.


3. Enabling the flag

appsettings.json (or environment):

{
  "Cesivi": {
    "MultiTenant": { "Enabled": true }
  }
}

The flag is read eagerly at startup. On a true-mode boot the server:

  1. does not seed a default tenant (there is none — every request must resolve to a provisioned tenant);
  2. lazily creates the farm registry under the reserved __farm__ namespace;
  3. is ready to provision tenants.

No SkipDefaultDataInit workaround is needed — true-mode boot is self-safe.


4. Provisioning a tenant

POST /_farm/tenants
Content-Type: application/json

{ "tenantId": "acme", "hosts": ["acme.cesivi.example"] }
  • tenantId — a DNS-label-safe slug (^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$). default and __farm__ are reserved (400).
  • hosts — host headers that route to this tenant. pathPrefix (e.g. /t/acme) is also supported.
  • storageBackendId / storageConfig (optional) — route this tenant to its own storage backend through the C-A registry. Omit to share the farm-default backend (still fully key-isolated).

On success (201) the tenant is provisioned and seeded: its root web-application, site collection, root web and the standard list set are created under its own namespace, so it serves requests immediately. If seeding fails the tenant is marked Deleting and a 500 is returned (no half-usable tenant is advertised as ready).

Responses: 201 created · 400 invalid/reserved slug · 409 already exists · 500 seed failed.

Verify

GET /_api/web/lists      Host: acme.cesivi.example      →  acme's seeded lists
GET /_api/web            Host: other.example            →  404 (unresolved host)

5. Lifecycle

State machine (single source: TenantLifecycle.IsLegal):

Provisioning → Active → Suspended ⇄ Active → Deleting(terminal)
Action Route Result Request behaviour after
Suspend POST /_farm/tenants/{id}/suspend Active → Suspended tenant requests → 403
Resume POST /_farm/tenants/{id}/resume Suspended → Active tenant requests → normal
Delete DELETE /_farm/tenants/{id} → Deleting and purges storage tenant requests → 503
List / Get GET /_farm/tenants[/{id}] summary/detail DTOs

Illegal transitions → 409. Provisioning503 (RetryAfter 30). Deleting is terminal.

Delete purges data. DELETE returns 202 and deletes every site collection under the tenant's namespace (scoped to that tenant only — farm and other-tenant data are never touched). If the purge fails the tenant stays Deleting for a reconcile retry (no false "done"). The registry descriptor is retained as a tombstone.

Secret-leak guard: list/detail responses never include storageConfig (it may hold credentials). Only the opaque storageBackendId selector is surfaced.


6. Isolation guarantees

  • Deny-by-default. In true mode a storage operation with no resolved tenant throws — never a silent fallback to default. An unresolvable host is 404, never served as some default tenant.
  • A-cannot-see-B. Storage keys, the web/list GUID indexes, and the object cache are all namespaced by tenant. Farm-seeded data under the bare Default namespace is invisible to every tenant.
  • __farm__ reserved namespace. The tenant registry persists under __farm__; a tenant-scoped request attempting to reach it is denied. Only farm-admin (context-less) paths read/write it.

These are covered by MultiTenantIsolationTests, PerTenantBackendIsolationTests, TenantScopedStorageCoverageTests (227-method sweep), and MultiTenantProvisionLifecycleTests.


7. Known limitations / deferred (no silent gaps)

Area Status
false→true re-key migration tool Not built. Design only — see RE-KEY-TOOL-DESIGN.md. Today, enable multi-tenant on a fresh farm and provision tenants; converting an existing single-tenant farm's data in place is the deferred tool.
Re-provision with a new backend Not exposed. Repointing a live tenant's storageBackendId without the re-key tool would orphan its data; safe only once that tool exists (or restricted to empty tenants).
Teardown depth DELETE purges the tenant's site collections (webs/lists/items/docs). It leaves web/list GUID-index orphan entries (path reads return empty; GUID reads resolve a missing path → empty) and does not sweep external BCS state or per-tenant search entries.
Per-tenant auth / search / quota Future C-B slices (promotion notes in DESIGN §7). Today auth/search/quota are farm-global.
Cluster + multi-tenant The farm-wide cache-invalidation callback removes by raw (un-prefixed) key; cross-node invalidation of tenant-scoped cache keys is a later cluster+MT concern. Single-node multi-tenant is correct.

8. Reference

C-B Phase-B.6 (PLAN-1715). Last updated 2026-06-06.