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.

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.