Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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.
The operator panel's tenant detail page for Northwind Mutual: tenant id northwind, a contact address, active status, a link through to billing, and an Organisations table listing aegis-platform with its display name, an SSO provider column reading 'WebAuthn fallback', and a count of five fleets.
All three levels on one page: the Tenant (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 AuditEvent with 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

RouteBehaviour
GET /tenantsList configured tenants. Tenant-scoped sessions see only their own.
GET /tenants/:tenant_idTenant detail + org list. 403 if session tenant differs.
GET /tenants/:tenant_id/orgs/:org_idOrganisation 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 from Duplicate when 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:

  1. CryptographicMerkleAggregator::with_tenant_scope produces distinct chains; cross-tenant Merkle confusion is mathematically blocked, not configuration-dependent.
  2. Filesystemtenant_scoped_transparency_path produces distinct on-disk URLs; Caddy enforces SSO + tenant on access.
  3. RBACenforce_tenant_rbac returns 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.