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:Enableddefaults tofalse. 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
MockAuthenticationHandlergrantsFarmAdminto every non-noadminuser. 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). WireFarmAdminto a real IdP claim or group (seeIDENTITY_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:
- does not seed a
defaulttenant (there is none — every request must resolve to a provisioned tenant); - lazily creates the farm registry under the reserved
__farm__namespace; - 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])?$).defaultand__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. Provisioning → 503 (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 opaquestorageBackendIdselector is surfaced.
6. Isolation guarantees¶
- Deny-by-default. In
truemode a storage operation with no resolved tenant throws — never a silent fallback todefault. An unresolvable host is404, 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
Defaultnamespace 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¶
- Design:
_project/areas/multi-tenant/DESIGN.md(contracts, seam inventory, phasing). - Re-key tool design:
_project/areas/multi-tenant/RE-KEY-TOOL-DESIGN.md. - Identity /
FarmAdmin:IDENTITY_PROVIDERS.md,MULTI-PROVIDER-IDP.md.
C-B Phase-B.6 (PLAN-1715). Last updated 2026-06-06.