Tenant authorization: carry trusted scope into every lookup
Bind resource access to authenticated tenant membership instead of treating object identifiers as permission.
On this page
Key takeaways
- An object ID selects a candidate resource; it does not grant access.
- Apply trusted tenant scope in the query and authorize the requested action.
First principles
Authentication establishes who is making a request. Authorization decides whether that identity may perform this action on this resource in this context. A tenant identifier supplied in a URL or payload is not trusted merely because the caller is logged in. It is a requested scope that must be checked against membership or another authoritative policy. A globally unique resource ID prevents accidental identity collisions, but uniqueness says nothing about the caller's right to read it.
Represent the trusted context separately from untrusted input. The server can construct a principal after verifying the session and resolving tenant membership. Resource handlers then receive that principal rather than rebuilding authority from payload fields. This separation makes the trust boundary visible: request data selects what is being requested, while verified server state determines the limits within which it may be selected. Renaming a client-supplied field to trustedTenant does not make it trusted.
Put tenant scope into the resource query so unauthorized rows are never materialized as successful candidates. This is a useful guardrail, not a complete permission system. A member may be allowed to read a document but not approve it, or to edit only documents they own. Apply action-specific checks as well. For updates, include relevant scope and policy predicates in the mutation itself when concurrency could invalidate a separate earlier check.
Runnable example
Run this Python 3 program against an in-memory SQLite database. The principal is a fixed server-side test fixture, not something parsed from a request. All records are synthetic, and the example deliberately hides whether a document exists outside the caller's scope.
import sqlite3
from dataclasses import dataclass
@dataclass(frozen=True)
class Principal:
user_id: str
tenant_id: str
can_read: bool
database = sqlite3.connect(":memory:")
database.execute("""
CREATE TABLE documents (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL,
title TEXT NOT NULL
)
""")
database.executemany("INSERT INTO documents VALUES (?, ?, ?)", [
("doc-a", "tenant-a", "Budget draft"),
("doc-b", "tenant-b", "Private plan"),
])
def read_document(principal, document_id):
if not principal.can_read:
return 403, None
row = database.execute(
"SELECT title FROM documents WHERE id = ? AND tenant_id = ?",
(document_id, principal.tenant_id),
).fetchone()
return (200, row[0]) if row else (404, None)
member = Principal("user-a", "tenant-a", True)
assert read_document(member, "doc-a") == (200, "Budget draft")
assert read_document(member, "doc-b") == (404, None)
assert read_document(member, "missing") == (404, None)
assert read_document(Principal("user-a", "tenant-a", False), "doc-a") == (403, None)
print("same-tenant read allowed; cross-tenant read hidden")
Parameters prevent values from changing SQL structure, but parameterization alone is not authorization. The tenant predicate is the access boundary in this query. The explicit permission check distinguishes the ability to read documents from membership alone. Whether to use 403 or 404 for a denied action depends on the product's information-disclosure policy. Keep that policy consistent across list, detail, search, and export endpoints.
Contextual failure case
A detail endpoint checks tenant membership, but a download endpoint looks up files only by document ID because the link was generated from an authorized page. The developer assumes reaching the download link proves authorization. In reality, each request is a new trust decision. Links can be copied, guessed, cached, or remain accessible after membership changes. The download handler must independently enforce scope, or validate a narrowly scoped capability whose expiry and revocation behavior are deliberately chosen.
A subtler leak appears in caching. A handler performs the correct scoped query, then stores its response under a key containing only the resource ID. Another tenant's request reaches the cache before authorization and receives that response. Correct SQL cannot protect a response returned before SQL runs. Cache keys, background jobs, search indexes, and bulk exports all need the same ownership contract. Scope must follow the data through every path that can produce an observable result.
Verification
The example prints same-tenant read allowed; cross-tenant read hidden. Expand the test matrix to include a nonmember, a member without read permission, a valid reader, and a reader requesting a different tenant's document. Assert response bodies as well as status codes; an error response containing the hidden title is still a disclosure. Use payload tools only with synthetic records when checking response shapes and ensure metadata does not include other tenants' counts.
For an update endpoint, verify zero affected rows when scope or expected ownership does not match. Include cache-hit tests, not just database misses. If permissions are revoked during an active session, document how quickly revocation takes effect and test that policy. A long-lived permission claim and a fresh membership lookup have different latency and revocation properties, so neither should be chosen accidentally.
Exercise
Add document ownership and an edit permission. Permit readers to view same-tenant documents, but permit edits only when both the action permission and ownership policy pass. Write a parameterized update containing tenant and document predicates, then assert another tenant's row remains unchanged. Design an audit record that captures actor, action, resource identity, decision, and policy version without copying the confidential document body.
Safety and caveats
Do not rely on unguessable identifiers as the only defense. Avoid logging session tokens or complete denied resources. Database row-level policies can add defense in depth, but connection-pool session state must be set and cleared correctly. Administrative cross-tenant access deserves a distinct, audited path rather than a hidden bypass flag in ordinary handlers. Test permission changes and tenant deletion deliberately, because stale caches and orphaned records can retain access beyond the intended lifecycle.