Documentation menu
Getting started
New in v2.0
Feature deep dives
Reference
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
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
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
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
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
| Situation | Answer | Why this answer |
|---|---|---|
| No credential at all | 401 {"error":"authentication required"} | The request never named a tenant, so there is nothing to scope by. |
| A bad or expired token | 401 {"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 account | 403 {"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 else | 404 {"error":"not_found"} | Byte-identical to a row that does not exist, so ids cannot be probed for existence. |
HTTP/1.1 403 Forbidden
{
"error": "this account must be linked to a Telegram id first",
"code": "account_not_linked"
}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:
| Path | What owner 0 would have meant | The guard |
|---|---|---|
| Any authenticated read | The shared, unattributed bucket that no tenant should see. | The authentication middleware refuses a non-positive id before any handler runs. |
| A backup export | The 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. |
| Administration | An unreachable route set, not merely a hidden one. | Mounted behind the admin requirement, and the core re-checks the role. |
Surface by surface
| Surface | How identity is established | What it can reach |
|---|---|---|
| Telegram bot | The Telegram client itself resolves the caller to a numeric id on every update. | Only that account's tasks, sessions and history. |
| Web dashboard | Bearer token from the authentication routes. | The tenant routes, scoped by the verified id. |
| Mobile app | The same authentication routes — there is no second, weaker sign-in path. | The same tenant API. |
| REST integrations | Bearer token; X-API-Key only when the operator has configured one for service-to-service automation. | The tenant routes. |
| gRPC core | Called 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 lists | Scoped 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:
| Test file | What it pins down |
|---|---|
api/internal/handlers/accountless_identity_test.go | An unlinked account is refused with 403 rather than served the unattributed bucket. |
api/internal/handlers/session_chats_test.go | A 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.go | The administrator gate refuses a non-admin identity on every administrative route. |
api/internal/handlers/action_refusal_test.go | Refused actions return their reason on the wire instead of an empty success. |
api/internal/handlers/query_bounds_test.go | Pagination bounds are clamped before narrowing, so an out-of-range limit cannot wrap. |
core/tests/test_tenant_isolation.py | Owner scoping in the core: a read for one tenant cannot return another tenant's row. |
core/tests/test_cross_language_isolation.py | The 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.py | A 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.
# 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