Documentation menu

Tenant isolation

Telegram Tenant Isolation & IDOR Defenses

The mechanics behind the isolation claim: where the owner id comes from on each surface, the three refusals, the owner-zero trap and the tests.

Verified against the checkout built for v2.2.4 · updated 20 Oct 2026

On this page

Isolation is not a filter applied to a result set. It is the shape of every query: the owner is an argument of the read, so a row belonging to somebody else is not fetched and then hidden — it is never fetched. That distinction is what makes the guarantee hold under refactoring, because a handler that forgot the owner would fail to compile against the core method it needs.

The one rule

  1. A token arrives

    Authorization: Bearer <jwt>. Nothing else in the request is trusted to name a tenant — not a query parameter, not a body field, not a header.

    401 authentication required

  2. The core validates it

    The signature, the expiry and the account are checked over gRPC. The answer carries the numeric user id, the username and the administrator flag.

    401 invalid or expired token

  3. The identity is refused if it owns nothing

    An account that has not linked a Telegram id resolves to a synthetic negative id. Rather than serving the unattributed bucket, the middleware refuses it.

    403 account_not_linked

  4. The handler asks only for that owner

    Every core call is made with the verified id as the owner argument. The ownership predicate is part of the query, not a check applied to the result.

    404 not_found — for foreign and missing rows alike

The three refusals

Refusals, and the reason each one is shaped the way it is
SituationAnswerWhy this answer
No credential at all401 {"error":"authentication required"}The request never named a tenant, so there is nothing to scope by.
A bad or expired token401 {"error":"invalid or expired token"}Identical for a forged signature and an expired one: a client learns only that it must sign in again.
A valid token for an unlinked account403 {"error":"…","code":"account_not_linked"}Fail closed. Serving this caller would mean serving the unattributed bucket, which is the whole installation.
A row owned by somebody else404 {"error":"not_found"}Byte-identical to a row that does not exist, so ids cannot be probed for existence.
Unlinked account
HTTP/1.1 403 Forbidden

{
  "error": "this account must be linked to a Telegram id first",
  "code": "account_not_linked"
}
Foreign row — indistinguishable from missing
HTTP/1.1 404 Not Found

{
  "error": "not_found",
  "error_code": "not_found"
}

The owner-zero trap

The most dangerous value in a multi-tenant system is the one that means "no owner", because every code path that forgets to check it silently addresses everything. This platform has three separate guards against it, and the reason is worth stating plainly:

Three places where zero would have widened the scope instead of narrowing it
PathWhat owner 0 would have meantThe guard
Any authenticated readThe shared, unattributed bucket that no tenant should see.The authentication middleware refuses a non-positive id before any handler runs.
A backup exportThe entire installation, because an export with no owner is an export of everything.The same middleware refusal, plus the handler's own non-positive check.
AdministrationAn unreachable route set, not merely a hidden one.Mounted behind the admin requirement, and the core re-checks the role.

Surface by surface

Where identity comes from, per surface
SurfaceHow identity is establishedWhat it can reach
Telegram botThe Telegram client itself resolves the caller to a numeric id on every update.Only that account's tasks, sessions and history.
Web dashboardBearer token from the authentication routes.The tenant routes, scoped by the verified id.
Mobile appThe same authentication routes — there is no second, weaker sign-in path.The same tenant API.
REST integrationsBearer token; X-API-Key only when the operator has configured one for service-to-service automation.The tenant routes.
gRPC coreCalled by the Go API with the owner it verified; the core never trusts a caller-supplied owner for a tenant read.Whatever the API asked for, on behalf of the id it passed.
Session chat listsScoped from the token identity; a session that is not yours is a 404, and a session with no live client is a 503.The chat list of a session you own.

Administration is separate

Administration is not "the tenant API with more rows". The route set is mounted behind a gate that reads the verified administrator flag, and the core re-checks the role on the other side of gRPC. A stale or forged claim therefore fails twice: once where the route is mounted, and once where the work is done.

Gate one — routing

Requests to /api/admin/* reach a handler only if the identity carries the administrator flag. A non-administrator gets 403 admin access required, not an empty list.

Gate two — the core

The core re-verifies the role before performing an installation-wide operation, so the API is not the only thing standing between a forged token and every tenant's data.

Gate three — the log

Administrative actions are recorded with the verified actor, so "who disabled that account" has an answer that does not depend on the application's own memory.

What enforces it

The claim is only worth what fails when it is broken. These files exist in the repository and run in CI:

Tests that make the isolation claim checkable
Test fileWhat it pins down
api/internal/handlers/accountless_identity_test.goAn unlinked account is refused with 403 rather than served the unattributed bucket.
api/internal/handlers/session_chats_test.goA foreign session is a 404; a session with no live client is a 503 — two different facts, two different answers.
api/internal/handlers/admin_test.goThe administrator gate refuses a non-admin identity on every administrative route.
api/internal/handlers/action_refusal_test.goRefused actions return their reason on the wire instead of an empty success.
api/internal/handlers/query_bounds_test.goPagination bounds are clamped before narrowing, so an out-of-range limit cannot wrap.
core/tests/test_tenant_isolation.pyOwner scoping in the core: a read for one tenant cannot return another tenant's row.
core/tests/test_cross_language_isolation.pyThe Go API and the Python core agree on the owner they carry, so a boundary crossing cannot drop it.
core/tests/test_session_backup_security.pyA backup is encrypted and tenant-scoped before it is written.

Verify it yourself

Two tenants and one terminal are enough to see the behaviour. Create a task as tenant A, then ask for it as tenant B — the answer should be indistinguishable from asking for a task that never existed.

A five-minute isolation check
# as tenant A
A=$(curl -s -X POST https://your-host/api/auth/login \
  -H 'Content-Type: application/json' \
  -d '{"username":"alice","password":"…"}' | jq -r .token)

TASK=$(curl -s -X POST https://your-host/api/tasks \
  -H "Authorization: Bearer $A" \
  -H 'Content-Type: application/json' \
  -d '{"kind":"dlq.purge","payload":{}}' | jq -r .id)

# as tenant B — expect 404, exactly as for a random id
B=$(curl -s -X POST https://your-host/api/auth/login \
  -H 'Content-Type: application/json' \
  -d '{"username":"bob","password":"…"}' | jq -r .token)

curl -s -o /dev/null -w 'foreign:  %{http_code}\n' \
  -H "Authorization: Bearer $B" https://your-host/api/tasks/$TASK
curl -s -o /dev/null -w 'random:   %{http_code}\n' \
  -H "Authorization: Bearer $B" https://your-host/api/tasks/does-not-exist

# and without a token at all
curl -s -o /dev/null -w 'anonymous: %{http_code}\n' https://your-host/api/tasks/$TASK

# 200 for the owner is the fourth line of the proof
curl -s -o /dev/null -w 'owner:    %{http_code}\n' \
  -H "Authorization: Bearer $A" https://your-host/api/tasks/$TASK