Lion Cipher Lab
Prove tenant isolation in a multi-tenant SaaS -- nine layers, one harness, zero leaks
- Cloud Security
- Advanced
- ~135 min
- Free · no account required
Why it matters
Every SaaS company on earth tells its customers that their data is isolated from the other customers. Almost none of them can prove it, and the reason is structural rather than lazy: tenant isolation is not one control, it is eight or nine independent boundaries stacked on top of each other, and it takes exactly one of them to leak. Your database has a tenant_id column on every table and you feel good about it -- but the object store keys the files by a prefix, the response cache keys them by resource id, the search index is a second copy of the data with its own query path, the nightly…
What you'll learn
- enumerating the tenant boundary as a LIST OF LAYERS rather than a control: the relational store, object storage, the response cache, a derived search index, an async worker running as a privileged identity, an export/aggregate path, logs and metrics, error codes, and quota counters -- and internalising that a proof at one layer is evidence about exactly that layer and nothing else
- building an exhaustive isolation harness: N tenants x M actors x K resources x L layers, cross-producted, with an explicit CONFIDENTIALITY assertion (can A reach B) and an explicit AVAILABILITY assertion (can A still reach A) -- the second half that almost nobody writes, and the reason 'we fixed the leak' ships as an outage
- measuring a partial leak: an aggregate whose COUNT is computed before the tenant filter returns no rows and still leaks, and recovering 108 characters of six other tenants' data from 4,104 integer answers while zero rows of their data ever appear in a response body
- turning a 404-vs-403 status difference into an existence oracle, calibrating the decision rule on one known id, and scoring it: 6 of 6 real cross-tenant ids confirmed against 27 fabricated ones with 0 false positives and 0 bytes of body read
- why 'the id is an unguessable uuid4' is not an authorization control -- with the honest measurement that the attacker in this lab spent 0 bits of guessing because a share notification, an export and a log line all hand out ids legitimately
- the AND/OR precedence bug in raw SQL, read at the query-planner level: EXPLAIN QUERY PLAN shows the tenant-filtered version taking SEARCH USING INDEX and the unparenthesised version taking a full SCAN, which is the mechanical reason it silently widens instead of erroring
- why a cache defect survives CI: the same harness, the same code and the same probes give 18 leaks cold and 36 warm, because the authorization check on the slow path is perfect and never runs on a hit
- designing an ARCHITECTURAL control instead of nine patches: a mandatory tenant-scoped data accessor that cannot be constructed without a tenant and never accepts a whole SQL string, a tenant-scoped cache-key TYPE that turns the classic bug into a TypeError, and a deny-by-default resolver whose 'forbidden' and 'missing' are the same 404
- proving an architectural control actually holds by adding a NEW endpoint written by a developer in a hurry and re-running the harness -- 0 leaks through the scoped API, 36 through one raw-SQL escape hatch, which is the real measurement of whether a choke point is a choke point
- pricing the tenancy spectrum honestly -- shared schema, schema-per-tenant, database-per-tenant, account-per-tenant -- against isolation strength, operational cost, blast radius and noisy-neighbour risk, and knowing when the stronger model is genuinely required
Steps in this lab
- [0] What tenant isolation actually is, and why nobody can prove theirs
- [0a] Set up -- open a terminal and check Python (assume nothing)
- [0b] The isolation boundary, written out as nine layers
- [0c] Create tenantapp.py -- the multi-tenant application, in four builds
- [0d] The nine defects, one per layer -- read them before you run anything
- [0e] Create isoharness.py -- the actual deliverable
- [0f] Create runlab.py -- the driver that runs all fifteen experiments
- [1] The size of the question: how big is the isolation matrix?
- [2] The test suite you already have is green
- [3] The full harness against the vulnerable build (cold cache)
- [4] The same harness, the same build, a warm cache -- and why CI never catches this
- [5] The AND/OR precedence bug, read at the query-planner level
- [6] The aggregate oracle -- leak a number, recover a string
- [7] The 404-versus-403 existence oracle
- [8] IDOR on an opaque uuid -- 122 bits of entropy, 0 bits of guessing
- [8a] The three layers people forget: the worker, the logs and the counters
- [9] The architectural fix, part 1: a data accessor you cannot misuse
- [9a] The architectural fix, parts 2 and 3: a key TYPE and a deny-by-default resolver
- [9b] The static audit -- and the two reasons grep is not a control
- [10] The hardened build: same harness, same 648 probes
- [11] Control failure mode 1: the fix that scores zero leaks and breaks the product
- [12] Control failure mode 2: a harness that only tests reads
- [13] The real test of an architectural control: let a careless developer add an endpoint
- [14] Control failure mode 3: a fixture whose tenants are all the same shape
- [15] Where Postgres row-level security is the right answer -- and what it does not cover
- [16] The tenancy spectrum, priced honestly
- [17] Named failure modes of the CONTROLS, each with the number you measured
- [18] Wire it into CI -- the part that makes it a control instead of an exercise
- [19] When NOT to do this, and how a professional does it differently
- [20] Where to go next (all optional, none required)