Multi-tenant
Enterprise replaces CE’s single-fleet-per-process model with a three-level hierarchy:
Tenant ← billing boundary + audit isolation boundary
└── Organization ← RBAC boundary + SSO IdP boundary
└── Fleet ← already exists in CE; FleetSpec from the proxy manifest
Why three levels (not two)
- One paying account at “AcmeCorp” (Tenant) might run separate Organizations for “AcmeCorp / Prod” and “AcmeCorp / Staging” with different SSO IdPs and different fleet sets.
- A reseller / MSP runs one Tenant per end-customer, each with multiple Organizations underneath.
- The verifier’s hot path keys cryptographic isolation (per-tenant Merkle chains) at the Tenant level. RBAC checks (RBAC) cap cross-tenant queries at 403 well before the store layer reads.
Data model
The types live in the edssa-tenant crate; the panel mounts an
Arc<dyn TenantStore> in AppState.
#![allow(unused)]
fn main() {
pub struct Tenant {
pub id: TenantId, // ADR-004 format
pub display_name: String,
pub contact_email: Option<String>,
pub active: bool, // false → read-only freeze
}
pub struct Organization {
pub id: OrganizationId,
pub tenant_id: TenantId,
pub display_name: String,
pub sso_provider_ref: Option<String>, // matches an AuthProviderRegistry key
}
pub struct FleetRef {
pub fleet_id: FleetId,
pub organization_id: OrganizationId,
pub tenant_id: TenantId,
}
}
FleetRef carries only the hierarchy link — the actual
FleetSpec state (width_N, chaff_C, threshold_T,
seed_path, audit_tier, rate_limit_policy, …) stays in
edssa-proxy::manifest::FleetSpec. The two are joined at
operator-view-time by fleet_id.
Boot-time seeding
The panel reads EDSSA_PANEL_TENANT_SEED at boot. If unset →
empty store (the /tenants list view renders an empty-state
card). If set → JSON file with the shape:
{
"tenants": [
{
"id": "acme",
"display_name": "AcmeCorp",
"contact_email": "ops@acme.example",
"active": true
}
],
"organizations": [
{
"id": "acme-prod",
"tenant_id": "acme",
"display_name": "AcmeCorp / Prod",
"sso_provider_ref": "saml:acme-okta"
}
],
"fleets": [
{
"fleet_id": "c1b2-demo",
"organization_id": "acme-prod",
"tenant_id": "acme"
}
]
}
Field shapes follow edssa-tenant directly. Insert order
matters: tenants → orgs → fleets. A dangling org (referencing a
tenant that wasn’t declared first) fails boot with the offending
ID in the error message — no silent acceptance of inconsistent
seeds.
The in-memory store survives only for the process lifetime.
Cross-host panel deployments need a shared backend; the
Postgres-backed store lands behind a tenant-store-postgres
cargo feature in a customer-driven follow-up. The TenantStore
trait is the stable surface — switching backends doesn’t touch
panel handlers.
Per-fleet manifest binding
The proxy’s fleets.toml gains an optional tenant_id field per
fleet (and an optional manifest-wide default in [defaults]):
version = 1
[defaults]
tenant_id = "acme"
[[fleet]]
id = "c1b2-demo"
seed_path = "seeds/c1b2-demo.seed"
preset = "balanced"
# `tenant_id` inherits "acme" from defaults; overridable per-fleet:
# tenant_id = "acme-staging"
When set, the proxy:
- Stamps every emitted
AuditEventwith the tenant scope. - Initializes the Tier-4 Merkle aggregator with a domain-
separated genesis derived from
(tenant_id, fleet_id). - Writes transparency logs to
<root>/<date>/<tenant>/<fleet>.log.
SIGHUP-reload classifies tenant_id diffs as
unsupported_change (mid-flight retag would mix tenants inside
a Merkle anchor); operators must restart the proxy to rebind a
fleet to a different tenant.
Cryptographic isolation
Two tenants running fleets with the same fleet ID produce
distinct Merkle chains by construction. The Tier-4 aggregator
uses MerkleAggregator::with_tenant_scope(batch_size, tenant_id, fleet_id) for fresh chains; the genesis prev_root is derived
as:
SHA-256("edssa-audit-tenant-genesis-v1"
|| len(tenant_id)_le || tenant_id
|| len(fleet_id)_le || fleet_id)
Length-prefixed encoding prevents ("a", "bc") = ("ab", "c")
collisions. The domain tag prevents confusion with any other
tenant-scoped derivation. Operators verifying anchors offline can
re-derive the same genesis from edssa_audit::tenant_scoped_genesis.
Filesystem isolation
The daily TransparencyLogWriter::emit_day walks tenants via
AnchorStore::tenants(), then per-tenant fleets via
fleets_scoped(tenant), writing one file per (tenant, fleet)
pair at tenant_scoped_transparency_path:
<root>/YYYY-MM-DD/<fleet>.log ← legacy / single-tenant
<root>/YYYY-MM-DD/<tenant>/<fleet>.log ← Phase-9 tenant-scoped
The tenant boundary surfaces in the public URL: Caddy serves
https://edssa.io/transparency/<date>/<tenant>/<fleet>.log and
the per-tenant Caddy block enforces SSO + tenant scope on access.
Audit-store SQLite schema
The merkle_anchors table gains a tenant_id TEXT column on
first open (idempotent ALTER TABLE ADD COLUMN migration). Pre-
Phase-9 databases pick up the column with NULL on existing rows
— legacy callers (the panel + admin pre-multi-tenant) keep
reading the NULL-tenant rows they always wrote. New tenant-
scoped inserts land alongside; queries dispatched via IS
operator (NULL-safe equality) so latest_scoped(None, fleet)
returns the legacy rows and latest_scoped(Some("acme"), fleet)
returns the tenant-scoped ones — never cross-contaminated.
Per-user-tenant RBAC
Sessions issued via SSO carry the resolved (subject, tenant, org)
triple in the cookie. The session signer dispatches on field
count:
- Legacy (2-field
<user_id>|<exp>) — WebAuthn sessions from pre-Phase-9 / single-passkey deployments. No tenant scope; operator-admin view applies (all tenants visible). - Tenant-scoped (4-field
<subject>|<tenant>|<org>|<exp>) — every SSO-authenticated session.
Panel handlers calling current_session(headers, auth) get a
typed SessionPayload enum. Cross-tenant access at
/tenants/:id or /tenants/:id/orgs/:org returns 403, not
404 — operators see “user wrong tenant” distinct from “tenant
doesn’t exist” in logs + metrics.
The 403 fires before the store lookup runs (defence in
depth + no oracle behaviour around “did this tenant exist?”).
For the list view at /tenants, the handler filters to the
session’s tenant when scoped; legacy sessions see all tenants.
Panel routes
| Route | Behaviour |
|---|---|
GET /tenants | List configured tenants. Tenant-scoped sessions see only their own. |
GET /tenants/:tenant_id | Tenant detail + org list. 403 if session tenant differs. |
GET /tenants/:tenant_id/orgs/:org_id | Organisation detail + fleet list. Same 403 rule. |
All three live under the existing require_session middleware
(auth-gated; redirect to /login for unauthenticated browsers,
401 for API clients).
Errors
TenantStore returns typed variants:
TenantNotFound(TenantId)— 404-shaped at the handler.OrganizationNotFound { tenant, organization }— 404.FleetNotFound { tenant, organization, fleet }— 404.Duplicate(String)— boot-time insert collision (same tenant, same org id; or same fleet ID re-claimed within one tenant).CrossTenantAccess { requested, actual_tenant }— distinct fromDuplicatewhen a fleet ID collision crosses tenant boundaries. Logged at WARN so SOC 2 audit trails flag the attack-shape attempt.Backend(String)— storage layer error; opaque to callers.
Pen-test guarantees
The Phase-9 multi-tenant pen-test exit criterion is satisfied by three independent isolation layers:
- Cryptographic —
MerkleAggregator::with_tenant_scopeproduces distinct chains; cross-tenant Merkle confusion is mathematically blocked, not configuration-dependent. - Filesystem —
tenant_scoped_transparency_pathproduces distinct on-disk URLs; Caddy enforces SSO + tenant on access. - RBAC —
enforce_tenant_rbacreturns 403 for cross-tenant session/path mismatch before the store sees the query.
Each layer’s failure mode is distinct in logs and metrics, so a penetration test exercising one doesn’t accidentally pass another. The substrate-level guarantee is repeatable; the production-environment walk-through is the SOC 2 auditor’s deliverable.