auradefi 0.1.2
PyPI GitHub

How do I serve many customers from one deployment without leaking?

Run it

pip install auradefi
python examples/06_isolate_two_tenants.py

Multi-tenancy here is not a WHERE clause you must remember to write. The hierarchy is organisation -> project -> end user, and the tenant key is derived: usr_… is a hash over project_id | external_user_id. Two projects using the identical customer id, "user-1", say, cannot collide, because the project id is inside the hash.

This file sets up two projects that are as similar as possible, same customer id, same wallet address, and then attacks the boundary between them four ways:

1. replay project A's user token against project B  -> refused (signature)
2. use a token beyond the scopes it was minted with -> refused
3. use a token one millisecond after it expires     -> refused
4. read the other project's audit log               -> empty

Then it shows what a caller legitimately gets: scoped keys, a short-lived token, and three quota windows they can see the state of.

What it prints

customer 'user-1' in two projects:
  proj_42749aa1df279f12 -> usr_d3c786c7efccabe4
  proj_7bd1811b954d6ae0 -> usr_7513369f02bb5dd0
  the project id is INSIDE the hash, so the ids cannot collide

the same wallet connected in both: conn_a6bb35e03fab957b vs conn_07c3549376528419

key adk_live_9bea… authenticates to proj_42749aa1df279f12 with scopes ['accounts:read', 'users:admin']
  beta's key holds ['accounts:read']: it cannot mint a user token at all

alpha minted a token for user-1: scopes ('accounts:read',), ttl 600s
  replayed at beta: AuthError: token failed authentication
  used to write: ScopeError: missing required scope: accounts:write
  used 1 ms late: TokenExpiredError: token expired

audit: alpha has 1 entry (token.minted by key_0c4aa7f241680db4 from 203.0.113.7), beta has 0

alpha's 3rd request this second: QuotaExceededError: quota exceeded in the 'second' window for 'proj_42749aa1df279f12': limit 2, window resets at 1767225601000
  alpha's windows: day 998/1000 left, month 9998/10000 left, second 0/2 left
  beta's second:   1/2 left: one tenant cannot spend another's budget
  one second later alpha is servable again: 1/2 left

OK: derived tenant ids, project-signed tokens, scoped keys, per-project quota.

The whole file

06_isolate_two_tenants.py on GitHub: self-contained, offline, asserts its own output.

"""How do I serve many customers from one deployment without leaking?

    pip install auradefi
    python examples/06_isolate_two_tenants.py

Multi-tenancy here is not a `WHERE` clause you must remember to write. The
hierarchy is organisation -> project -> end user, and the tenant key is
*derived*: `usr_…` is a hash over `project_id | external_user_id`. Two
projects using the identical customer id, "user-1", say, cannot collide,
because the project id is inside the hash.

This file sets up two projects that are as similar as possible, same
customer id, same wallet address, and then attacks the boundary between
them four ways:

    1. replay project A's user token against project B  -> refused (signature)
    2. use a token beyond the scopes it was minted with -> refused
    3. use a token one millisecond after it expires     -> refused
    4. read the other project's audit log               -> empty

Then it shows what a caller legitimately gets: scoped keys, a short-lived
token, and three quota windows they can see the state of.
"""

from __future__ import annotations

from auradefi.clock import FrozenClock
from auradefi.errors import AuthError, QuotaExceededError, ScopeError
from auradefi.tenancy.audit import AuditLog
from auradefi.tenancy.keys import ApiKeyStore
from auradefi.tenancy.models import ConnectionKind, Environment, Scope, end_user_id
from auradefi.tenancy.quota import QuotaCounter, QuotaLimits
from auradefi.tenancy.store import TenancyStore
from auradefi.tenancy.tokens import require_scope, verify_token

CUSTOMER = "user-1"                 # the SAME id in both projects
WALLET = "0x1111111111111111111111111111111111111111"

clock = FrozenClock(1_767_225_600_000)
tenancy = TenancyStore()
keys = ApiKeyStore()
audit = AuditLog()

# ------------------------------------------------------ 1. two tenants
organisation = tenancy.create_organisation("Acme", clock)
alpha = tenancy.create_project(organisation.id, "alpha", Environment.LIVE, clock)
beta = tenancy.create_project(organisation.id, "beta", Environment.LIVE, clock)

alpha_user = tenancy.get_or_create_user(alpha.id, CUSTOMER, clock)
beta_user = tenancy.get_or_create_user(beta.id, CUSTOMER, clock)

# Get-or-create really is: the same external id gives the same row back.
assert tenancy.get_or_create_user(alpha.id, CUSTOMER, clock).id == alpha_user.id
# Same customer id, same everything else: different tenant, by derivation.
assert alpha_user.id != beta_user.id
assert alpha_user.id == end_user_id(alpha.id, CUSTOMER)
print(f"customer {CUSTOMER!r} in two projects:")
print(f"  {alpha.id} -> {alpha_user.id}")
print(f"  {beta.id} -> {beta_user.id}")
print("  the project id is INSIDE the hash, so the ids cannot collide")

# Both connect the same wallet. Both connections are real, and distinct.
alpha_connection = tenancy.create_connection(
    alpha.id, alpha_user.id, ConnectionKind.ADDRESS, WALLET, clock)
beta_connection = tenancy.create_connection(
    beta.id, beta_user.id, ConnectionKind.ADDRESS, WALLET, clock)
assert alpha_connection.id != beta_connection.id
print(f"\nthe same wallet connected in both: {alpha_connection.id} vs "
      f"{beta_connection.id}")

# ------------------------------------------ 2. keys are scoped, and per project
alpha_key, alpha_secret = keys.issue(
    alpha.id, Environment.LIVE, (Scope.USERS_ADMIN, Scope.ACCOUNTS_READ), clock)
beta_key, beta_secret = keys.issue(
    beta.id, Environment.LIVE, (Scope.ACCOUNTS_READ,), clock)

# The secret is shown once. Only its hash is stored, so a database dump is
# not a set of working credentials.
assert alpha_secret.startswith("adk_live_") and len(alpha_secret) == 57
assert alpha_secret not in repr(alpha_key)
authenticated = keys.authenticate(alpha_secret, clock)
assert authenticated.project_id == alpha.id
print(f"\nkey {alpha_secret[:13]}… authenticates to {authenticated.project_id} "
      f"with scopes {sorted(scope.value for scope in authenticated.scopes)}")

# Beta's key was never granted users:admin, so it cannot mint tokens even
# for its OWN users. Scope is checked, not assumed from possession.
beta_authenticated = keys.authenticate(beta_secret, clock)
assert Scope.USERS_ADMIN not in beta_authenticated.scopes
print(f"  beta's key holds {sorted(s.value for s in beta_authenticated.scopes)}: "
      "it cannot mint a user token at all")

# ------------------------------------------------ 3. tokens are project-signed
token = tenancy.mint_user_token(
    alpha.id, CUSTOMER, ["accounts:read"], ttl_ms=600_000,
    ip="203.0.113.7", key_id=alpha_key.id, clock=clock, audit=audit,
    ip_source="socket",
)
claims = verify_token(token, signing_secret=alpha.signing_secret, clock=clock)
assert (claims.project_id, claims.external_user_id) == (alpha.id, CUSTOMER)
print(f"\nalpha minted a token for {claims.external_user_id}: "
      f"scopes {claims.scopes}, ttl {(claims.exp - claims.iat) // 1000}s")

# ATTACK 1: replay alpha's token against beta's secret.
try:
    verify_token(token, signing_secret=beta.signing_secret, clock=clock)
    raise AssertionError("a cross-project token must never verify")
except AuthError as exc:
    print(f"  replayed at beta: {type(exc).__name__}: {exc}")

# ATTACK 2: use it beyond its scope.
try:
    require_scope(claims, "accounts:write")
    raise AssertionError("a scope not granted must never pass")
except ScopeError as exc:
    print(f"  used to write: {type(exc).__name__}: {exc}")

# ATTACK 3: use it after it expires. Time is a port, so this is testable.
expired_clock = FrozenClock(claims.exp + 1)
try:
    verify_token(token, signing_secret=alpha.signing_secret, clock=expired_clock)
    raise AssertionError("an expired token must never verify")
except AuthError as exc:
    print(f"  used 1 ms late: {type(exc).__name__}: {exc}")

# ATTACK 4: read the other project's audit trail. Every mint is recorded,
# under the project that did it, with the IP the SERVER observed: a caller
# cannot choose the address its own permanent audit row records.
(entry,) = audit.entries(alpha.id)
assert audit.entries(beta.id) == ()
assert (entry.event, entry.key_id, entry.ip) == ("token.minted", alpha_key.id, "203.0.113.7")
print(f"\naudit: alpha has {len(audit.entries(alpha.id))} entry "
      f"({entry.event} by {entry.key_id} from {entry.ip}), beta has "
      f"{len(audit.entries(beta.id))}")

# ------------------------------------------------------ 4. quota, per project
# Three windows at once. A project that burns its second does not touch its
# day, and beta is not slowed down by alpha at all.
quota = QuotaCounter(QuotaLimits(per_second=2, per_day=1_000, per_month=10_000), clock)
quota.hit(alpha.id)
quota.hit(alpha.id)
try:
    quota.hit(alpha.id)
    raise AssertionError("the third hit in one second must be refused")
except QuotaExceededError as exc:
    print(f"\nalpha's 3rd request this second: {type(exc).__name__}: {exc}")

quota.hit(beta.id)      # beta is unaffected by alpha's burst
snapshot = quota.snapshot(alpha.id)
print("  alpha's windows: " + ", ".join(
    f"{name} {window.remaining}/{window.limit} left"
    for name, window in sorted(snapshot.items())))
print(f"  beta's second:   {quota.snapshot(beta.id)['second'].remaining}/2 left: "
      "one tenant cannot spend another's budget")

clock.advance(1_000)    # a new second
quota.hit(alpha.id)
print(f"  one second later alpha is servable again: "
      f"{quota.snapshot(alpha.id)['second'].remaining}/2 left")

print("\nOK: derived tenant ids, project-signed tokens, scoped keys, "
      "per-project quota.")