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.
northwind) with the billing link that hangs off it, and the Organizations beneath it, each carrying its own SSO provider and its own set of Fleets. The SSO column reads WebAuthn fallback when an organisation has no IdP registered — the boundary is per-organisation, so one tenant can run an IdP for production and passkeys for staging. Captured against the local reference stack; the tenant is fictional.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.