PyBook 06: Tenancy
What this notebook proves: "org/project/user model, scoped API keys, the
authEndpoint mint. Done when: a cross-tenant isolation test passes:
one that actually tries to leak."
auradefi is multi-tenant by construction, not by a WHERE clause bolted
on later (rule #6). The shape is Vezgo's, and it is four nouns deep:
Organisation ──► Project (live | test) ──► EndUser ──► Connection
│ signing_secret (usr_…) (conn_…)
└► ApiKey (adk_live_… , scopes)
Everything in this book is pure and in-process, no network, no
database, no keys. Ids are made deterministic by injecting a scripted
entropy function, exactly as the isolation gate does, so every assertion
below is a literal.
from auradefi.clock import FrozenClock
from auradefi.tenancy.models import ConnectionKind, Environment
from auradefi.tenancy.store import TenancyStore
T0 = 1_767_225_600_000 # 2026-01-01T00:00:00Z
EXTERNAL_ID = "host-user-1" # the SAME opaque id in both tenants
DESCRIPTOR = "0xAbCd000000000000000000000000000000000001" # the SAME address
SECRET_A, SECRET_B = "aa" * 32, "bb" * 32
def scripted_entropy(ids, secrets):
"Width-keyed: n=8 pops an id, n=32 pops a signing secret."
id_queue, secret_queue = list(ids), list(secrets)
def entropy(n: int) -> str:
if n == 8:
return id_queue.pop(0)
if n == 32:
return secret_queue.pop(0)
return "7" * (2 * n)
return entropy
clock = FrozenClock(T0)
store = TenancyStore(scripted_entropy(["11" * 8, "a", "22" * 8, "b"], [SECRET_A, SECRET_B]))
org_a = store.create_organisation("Org A", clock)
proj_a = store.create_project(org_a.id, "tenant-a", Environment.LIVE, clock)
org_b = store.create_organisation("Org B", clock)
proj_b = store.create_project(org_b.id, "tenant-b", Environment.LIVE, clock)
assert (proj_a.id, proj_b.id) == ("proj_a", "proj_b")
assert proj_a.signing_secret == SECRET_A
assert proj_b.signing_secret == SECRET_B
assert proj_a.signing_secret != proj_b.signing_secret # THE isolation root
print(proj_a)
Project(id='proj_a', org_id='org_1111111111111111', name='tenant-a', environment=<Environment.LIVE: 'live'>, signing_secret='aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', created_at=1767225600000)
Ids are derived, not allocated
An EndUser id is usr_ + sha256("{project_id}|{external_user_id}")[:16]
and a Connection id folds in the normalised descriptor. Two consequences
that matter:
- get-or-create is free: the same host user id always resolves to the
same
usr_, with no lookup table;
- the same external id under two projects lands on two different
usr_
ids, because the project id is inside the hash. The tenants below are
deliberately confusable, identical external_user_id, identical
address, and still nothing coincides.
from auradefi.tenancy.models import connection_id, end_user_id
user_a = store.get_or_create_user(proj_a.id, EXTERNAL_ID, clock)
user_b = store.get_or_create_user(proj_b.id, EXTERNAL_ID, clock)
conn_a = store.create_connection(proj_a.id, user_a.id, ConnectionKind.ADDRESS, DESCRIPTOR, clock)
conn_b = store.create_connection(proj_b.id, user_b.id, ConnectionKind.ADDRESS, DESCRIPTOR, clock)
assert user_a.external_user_id == user_b.external_user_id == EXTERNAL_ID
assert conn_a.descriptor == conn_b.descriptor == DESCRIPTOR.lower() # canonical form
assert user_a.id != user_b.id and conn_a.id != conn_b.id
# Derived, so recomputable from first principles:
assert user_a.id == end_user_id(proj_a.id, EXTERNAL_ID)
assert conn_a.id == connection_id(proj_a.id, user_a.id, ConnectionKind.ADDRESS, DESCRIPTOR)
# ...and idempotent: a second get-or-create returns the same row.
assert store.get_or_create_user(proj_a.id, EXTERNAL_ID, clock) == user_a
print(f"tenant A: {user_a.id} {conn_a.id}")
print(f"tenant B: {user_b.id} {conn_b.id} <- same inputs, different tenant")
tenant A: usr_2b67bf34d2444625 conn_eaa947a5814c21e3
tenant B: usr_2414ea20b3d09c41 conn_3d9ab7d47cb3733d <- same inputs, different tenant
Scoped API keys: server-side only
A project holds adk_live_… / adk_test_… keys. The plaintext is shown
once at issue; the store keeps a hash and a prefix. Scopes are a
frozenset on the key, checked at every route.
from auradefi.errors import AuthError
from auradefi.tenancy.keys import ApiKeyStore, has_scope
from auradefi.tenancy.models import Scope
keys = ApiKeyStore(scripted_entropy(["cc" * 8, "dd" * 8], ["ee" * 32]))
key, plaintext = keys.issue(proj_a.id, Environment.LIVE, (Scope.USERS_ADMIN, Scope.ACCOUNTS_READ), clock)
assert plaintext.startswith("adk_live_") and len(plaintext) == 57
assert key.secret_hash != plaintext # only the hash is retained
assert has_scope(key, Scope.ACCOUNTS_READ)
assert not has_scope(key, Scope.ACCOUNTS_WRITE)
assert keys.authenticate(plaintext, clock).id == key.id
print("issued", plaintext[:14] + "…", "scopes:", sorted(str(s) for s in key.scopes))
keys.revoke(proj_a.id, key.id, clock) # tenant-gated (0.1.1 #25)
try:
keys.authenticate(plaintext, clock)
except AuthError as exc:
print("after revoke:", exc)
issued adk_live_77777… scopes: ['accounts:read', 'users:admin']
after revoke: api key failed authentication
The authEndpoint mint: the browser never sees the key
The Vezgo pattern, and the reason tenancy exists at all: the host's
backend presents its secret key plus one opaque user id, and gets back
a short-lived, project-signed, scope-limited user token that is safe to
hand to a browser. The token is an HS256 JWT signed with that project's
secret; every mint is audited with the key id and caller IP.
from auradefi.tenancy.audit import AuditLog
from auradefi.tenancy.tokens import RevocationSet, require_scope, verify_token
audit = AuditLog()
minting_key, _ = ApiKeyStore(scripted_entropy(["ab" * 8], ["cd" * 32])).issue(
proj_a.id, Environment.LIVE, (Scope.USERS_ADMIN,), clock
)
token = store.mint_user_token(
proj_a.id, EXTERNAL_ID, ["accounts:read"], 600_000,
"203.0.113.7", minting_key.id, clock, audit, jti="ab" * 16,
)
claims = verify_token(token, signing_secret=proj_a.signing_secret, clock=clock)
assert len(token.split(".")) == 3 # header.payload.signature
assert claims.project_id == proj_a.id
assert claims.external_user_id == EXTERNAL_ID
assert claims.scopes == ("accounts:read",)
assert claims.exp - claims.iat == 600_000 # ten minutes, ms epoch
require_scope(claims, "accounts:read")
print("claims:", claims)
(record,) = audit.entries(proj_a.id)
assert (record.seq, record.event, record.key_id, record.ip) == (1, "token.minted", minting_key.id, "203.0.113.7")
print("audited:", record)
revoked = RevocationSet()
revoked.revoke(claims.jti)
try:
verify_token(token, signing_secret=proj_a.signing_secret, clock=clock, revoked=revoked)
except AuthError as exc:
print("after revocation:", exc)
claims: TokenClaims(external_user_id='host-user-1', project_id='proj_a', scopes=('accounts:read',), iat=1767225600000, exp=1767226200000, jti='abababababababababababababababab')
audited: AuditRecord(seq=1, event='token.minted', project_id='proj_a', external_user_id='host-user-1', key_id='key_abababababababab', ip='203.0.113.7', at_ms=1767225600000, ip_source='unknown')
after revocation: token revoked
Isolation attempt 1: cryptographic
Each project has its own 64-hex signing secret. A token minted for
tenant A must be indistinguishable from garbage under tenant B's secret:
plain AuthError, never "expired" or "revoked". A distinguishable error
is an oracle an attacker can farm.
control = verify_token(token, signing_secret=proj_a.signing_secret, clock=clock)
assert control.project_id == proj_a.id # valid under its OWN project
try:
verify_token(token, signing_secret=proj_b.signing_secret, clock=clock)
except AuthError as exc:
assert type(exc) is AuthError # not a subclass: no information leaked
print("A's token under B's secret ->", type(exc).__name__, "-", exc)
else:
raise AssertionError("cross-tenant token verified: isolation is broken")
A's token under B's secret -> AuthError - token failed authentication
Isolation attempt 2: id smuggling
Present tenant A's real connection id to tenant B. It must read exactly
like an id that never existed: same exception class, and a message that
mentions neither A's project nor A's secret.
from auradefi.errors import NotFoundError
ABSENT = "conn_0000000000000000"
try:
store.get_connection(proj_b.id, conn_a.id)
except NotFoundError as cross:
smuggled = cross
try:
store.get_connection(proj_b.id, ABSENT)
except NotFoundError as missing:
absent = missing
assert type(smuggled) is type(absent) is NotFoundError
assert proj_a.id not in str(smuggled)
assert proj_a.signing_secret not in str(smuggled)
print("smuggled:", smuggled)
print("absent: ", absent)
# Enumeration gets nowhere either: disjoint user sets, no cross-project list.
ids_a = {user.id for user in store.users(proj_a.id)}
ids_b = {user.id for user in store.users(proj_b.id)}
assert ids_a and ids_b and ids_a.isdisjoint(ids_b)
print("A's users:", ids_a, "B's users:", ids_b)
smuggled: connection not found: 'conn_eaa947a5814c21e3'
absent: connection not found: 'conn_0000000000000000'
A's users: {'usr_2b67bf34d2444625'} B's users: {'usr_2414ea20b3d09c41'}
Isolation attempt 3: quota and audit
Rate limits are per project, in three windows (second / day / month),
and the counter is consumed before the work. Exhausting tenant A must
leave tenant B untouched, and a mint that was refused must leave no
audit row: a failed action that logs as if it happened is its own kind of
lie.
from auradefi.errors import QuotaExceededError
from auradefi.tenancy.quota import QuotaCounter, QuotaLimits
shared_audit = AuditLog()
quota = QuotaCounter(QuotaLimits(per_second=2, per_day=100, per_month=100), clock)
def mint(project_id, jti):
return store.mint_user_token(
project_id, EXTERNAL_ID, ["accounts:read"], 600_000,
"203.0.113.7", minting_key.id, clock, shared_audit, quota=quota, jti=jti,
)
mint(proj_a.id, "11" * 16)
mint(proj_a.id, "22" * 16)
try:
mint(proj_a.id, "33" * 16)
except QuotaExceededError as exc:
print("A's window is spent:", exc)
assert len(shared_audit.entries(proj_a.id)) == 2 # the refused mint is NOT audited
assert shared_audit.entries(proj_b.id) == () # B's trail is empty
b_token = mint(proj_b.id, "44" * 16) # B is unthrottled
assert verify_token(b_token, signing_secret=proj_b.signing_secret, clock=clock).project_id == proj_b.id
print("B mints fine; snapshot:", {k: v.remaining for k, v in quota.snapshot(proj_b.id).items()})
A's window is spent: quota exceeded in the 'second' window for 'proj_a': limit 2, window resets at 1767225601000
B mints fine; snapshot: {'second': 1, 'day': 99, 'month': 99}
The surface is the guarantee
TenancyStore's public surface is small and every tenant-data method
takes project_id first. The isolation gate asserts that set exactly, so
adding a method is a deliberate, reviewed act measured against the leak
tests, not something that slips in.
import inspect
public = {name for name in dir(store) if not name.startswith("_")}
assert public == {
"create_organisation", "create_project", "get_or_create_user", "users",
"create_connection", "get_connection", "connections", "mint_user_token",
}
for name in ("get_or_create_user", "users", "create_connection", "get_connection",
"connections", "mint_user_token"):
assert list(inspect.signature(getattr(store, name)).parameters)[0] == "project_id", name
print("project-scoped first argument on every tenant-data method:", sorted(public))
project-scoped first argument on every tenant-data method: ['connections', 'create_connection', 'create_organisation', 'create_project', 'get_connection', 'get_or_create_user', 'mint_user_token', 'users']
What is not here
- Tokens are HS256 with a per-project secret. There is no JWKS, no
asymmetric signing, and no refresh-token flow.
- The store is in-memory; persistence is the host's job (see
09_embedding), and no SQL tenancy backend ships.
- Quota windows are process-local counters, not a distributed limiter.
Next: 07_transactions: what actually moved.