Multi-tenant control plane for an AI security product

Anvaya Platform

A test that has never failed constrains nothing. An isolation suite written against the schema it validates will pass on the first run and tell you nothing about whether your tenants are actually separated.

Role
Sole engineer. Tenancy model, authentication, authorization, HTTP API, dashboard, migrations, adversarial and mutation suites.
Shape
TypeScript monorepo across five packages, 27 migrations, tested against PostgreSQL 18 in process.
Links
Private repo

Architecture

before actionAPI keyscoped, not roledSessionArgon2id · TOTPRouterpermission gateTenantScopetype layerwithTenantSET LOCAL · app roleRLS forcedevery org_id tableAudit loginsert-only grant
TENANCY BOUNDARY

The problem

Multi-tenancy is the one architectural decision that cannot be deferred. Every feature built before the boundary exists has to be rewritten against it later, and the rewrite itself is when data crosses between customers. So isolation came first, before authentication, before any endpoint, before anything that would depend on it.

The harder problem is proving it. An isolation test suite is written by the same person who wrote the schema, against the same mental model. It passes immediately, and that first green run is worth very little: it demonstrates that the code matches its author's assumptions, not that the assumptions are correct.

The realistic production incident is not a missing policy. It is connecting to the database as an owner or a superuser, at which point every policy is present, every policy is correct, and every policy is inert, while every query keeps succeeding.

The approach

Three independent layers carry the boundary, chosen so that the failure modes do not overlap. The type layer catches an engineer who forgets the predicate. The client layer catches a pooled connection still carrying the previous request's tenant. The database layer catches everything above it being bypassed. Any one of them being wrong must not produce a leak.

To find out what the suite was actually worth, isolation was deliberately broken five ways and the result measured rather than predicted. Two guesses were wrong in the safe direction: dropping a policy fails closed instead of leaking, and omitting a write check falls back to the read predicate. One was wrong about the nature of the attack it enables.

What that exercise established is where review effort belongs. The leaks are not exotic. They are a permissive predicate someone wrote to silence a failing insert, a new table where the policy was never added, and connecting as the wrong role. So the questions worth asking in review are who do we connect as, and did anyone write true.

Decisions

Authorization is a build gate, not a convention

Every route declares the permission it requires, and a validator fails the build if any route declares neither a permission nor an explicit public marker. Both markers are required to expose an endpoint, so omitting authorization is never something that happens by not typing anything. The router is hand-rolled on the Node http module precisely to make that enforceable. Bolted onto a framework's decorators it degrades into a convention, and a convention is what the next endpoint forgets.

Keys carry scopes, not roles

An ingest-scoped API key authenticates successfully and can read nothing. This matters because of where keys actually leak from, which is the customer's own repository or a pasted issue. A role-bound key would carry that role's read access, turning a leaked ingest credential into an attacker who can read your incidents. A scoped one turns it into an attacker who can send you spans. The key prefix is registered with GitHub and GitLab secret scanning so a committed key is revoked before anyone finds it.

A measured deviation from the security blueprint

The blueprint specified Argon2id at 64 MiB and three iterations, which measured 1849 ms per login in pure JavaScript. That is not a security posture, it is an outage people route around. The OWASP parameters measured 387 ms and were chosen instead. Hashes are stored as PHC strings carrying their own parameters, so raising them later is transparent: an old hash verifies and is rehashed on the next successful login.

Cross-tenant access returns 404, never 403

A 403 confirms the resource exists, which lets an attacker map another customer's identifiers by probing. A test asserts that a foreign id and a genuinely nonexistent id produce byte-identical outcomes, so the denial itself leaks nothing.

The audit log is append-only by grant

The application role holds insert and select on the audit table and nothing else, verified by querying the privilege catalog rather than by code review. An audit log the application can rewrite proves nothing, and being evidence is its only job. Entries are written before the action, so an operation that crashes midway still records having been attempted.

Confidence is a required prop, not a style guide

In the dashboard, rendering a finding without its confidence band is a compile error. Type-level assertions fail the build if that ever stops being true, including one that fires if anyone adds a confidence override to the correlation callout, which would let a population-level correlation render as a fact about a single trace. A style guide saying always show confidence is a style guide someone eventually does not read.

Evidence

806
tests across 42 files, all passing
3
independent isolation layers
5
deliberate breaks, measured not predicted
387
ms per login, down from 1849

What it does not do

Isolation between tenants was complete before authorization within a tenant existed, and the gap was stated plainly at the time rather than discovered later. Permissions were seeded and queryable for a full phase before anything consulted them.

Tests run against PostgreSQL compiled to WebAssembly, which is real Postgres rather than an emulation, but it is not a managed production cluster. Connection pooling, retries and read replicas are a later phase.

Billing, the Python SDK and the cloud beta are not built. The phase table in the repository says so rather than implying breadth that does not exist.

Other systems