Authorization and Multi-Tenant Isolation
Authorization decides whether a principal may perform an action on a resource in the current context. It is a domain policy evaluated at every access path, not a boolean attached to authentication.
A useful authorization question has at least four parts:
Context can include tenant, resource state, authentication strength, time, network trust, or delegation. Checking only is_admin or only a token scope loses most of this information.
Core rules
Build around these rules:
- Deny by default.
- Check every request, including reads, exports, bulk actions, background jobs, and internal tools.
- Scope the database query as part of authorization when possible.
- Use stable permissions rather than scattering role-name checks through routers.
- Keep tenant identity server-derived and immutable within one operation.
- Treat object identifiers as locators, never as proof of access.
- Record high-value decisions without logging sensitive resource contents.
- Test negative cases more heavily than the happy path.
Authentication failure is normally 401 Unauthorized. An authenticated principal that lacks permission normally receives 403 Forbidden. For resources whose existence must not be disclosed, return the same tenant-scoped 404 Not Found for absent and inaccessible objects. Apply the policy consistently.
Model permissions, then assign roles
A role is a convenient bundle. A permission names an allowed business capability.
from enum import StrEnum
class Permission(StrEnum):
ORDER_READ = "order:read"
ORDER_CREATE = "order:create"
ORDER_REFUND = "order:refund"
MEMBER_INVITE = "member:invite"
MEMBER_ROLE_CHANGE = "member:role_change"
AUDIT_READ = "audit:read"
Endpoint and service policy should ask for order:refund, not for a role named manager. Product teams can change which roles grant that permission without rewriting application branches.
A tenant-aware RBAC schema might include:
users(id, ...)
tenants(id, ...)
roles(id, tenant_id, name, is_system_role, ...)
permissions(code, ...)
role_permissions(role_id, permission_code)
memberships(tenant_id, user_id, role_id, status, ...)
Use uniqueness and composite foreign keys to prevent a membership from referencing another tenant's custom role. System role templates can be copied or referenced through an explicit design, but a nullable tenant_id with ambiguous global behavior is easy to misuse.
Role hierarchy
Role inheritance reduces repetitive grants but makes effective access harder to reason about. A hierarchy must be acyclic, deterministic, and visible in administrative UI and audit output. It should not infer that a billing administrator is also a content administrator simply because one role sounds "higher."
Prefer a shallow set of role-to-permission assignments. Compute effective permissions in one policy component. If results are cached, membership and role changes need a bounded invalidation strategy.
OAuth scopes are not the whole policy
An OAuth scope describes delegated capability granted to a client/token. It can be one input to authorization. It does not prove that the subject belongs to the tenant, owns a resource, satisfies a state transition, or still has the underlying account permission.
FastAPI policy dependencies
A dependency can enforce coarse endpoint capability and return the already authenticated principal:
from collections.abc import Callable, Coroutine
from typing import Annotated, Any
from fastapi import Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
AuthorizationDependency = Callable[..., Coroutine[Any, Any, Principal]]
def require_permissions(*required: Permission) -> AuthorizationDependency:
async def dependency(
principal: Annotated[Principal, Depends(get_principal)],
session: Annotated[AsyncSession, Depends(get_session)],
) -> Principal:
granted = await authorization.effective_permissions(
session,
subject_id=principal.subject_id,
tenant_id=principal.tenant_id,
)
if not set(required).issubset(granted):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="insufficient permission",
)
return principal
return dependency
CanReadOrders = Annotated[
Principal,
Depends(require_permissions(Permission.ORDER_READ)),
]
This handles an endpoint-level permission. It does not replace resource-level policy. Avoid evaluating the same membership query in several nested dependencies; cache only within the request or through an invalidation-aware policy cache.
For a small codebase, an in-process policy service is usually clearer than a separate policy engine. A policy engine becomes useful when many services need one expressive policy language, centralized review, and decision logs. It adds availability, latency, policy rollout, and debugging concerns. Keep enforcement in every service even when decision logic is external.
Resource-level authorization
Broken object-level authorization occurs when an API accepts an object ID and performs an action without checking that the principal can access that object. UUIDs do not solve it.
Scope the lookup
from uuid import UUID
from fastapi import HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
async def get_tenant_order(
session: AsyncSession,
*,
tenant_id: UUID,
order_id: UUID,
) -> Order:
stmt = select(Order).where(
Order.tenant_id == tenant_id,
Order.id == order_id,
Order.deleted_at.is_(None),
)
order = (await session.scalars(stmt)).one_or_none()
if order is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="order not found",
)
return order
Filtering by both tenant and ID is safer than loading by global ID and checking later. It avoids accidental disclosure and reduces the chance a later refactor forgets the comparison.
For owner or collaborator policy, push the authorization predicate into SQL:
stmt = (
select(Document)
.outerjoin(
DocumentGrant,
(DocumentGrant.document_id == Document.id)
& (DocumentGrant.subject_id == principal.subject_id),
)
.where(
Document.tenant_id == principal.tenant_id,
Document.id == document_id,
(Document.owner_id == principal.subject_id)
| (DocumentGrant.can_edit.is_(True)),
)
)
For a collection endpoint, the same policy predicate must filter every returned row. Checking permission after fetching a page can produce short pages, wrong totals, data-dependent timing, and accidental serialization of forbidden rows.
Separate capability from state transition
Permission to refund orders does not mean every order can be refunded. The service must also enforce order ownership/tenant, payment state, refund limits, and separation-of-duty rules.
async def refund_order(
session: AsyncSession,
principal: Principal,
order_id: UUID,
) -> RefundResult:
async with session.begin():
order = await order_repository.lock_authorized_for_refund(
session,
principal=principal,
order_id=order_id,
)
if order.status != OrderStatus.PAID:
raise InvalidOrderStateError
if order.created_by == principal.subject_id and policy.requires_dual_control:
raise SeparationOfDutiesError
return await refunds.create(session, order=order, actor=principal)
When membership or resource state can change concurrently, check and mutate in one transaction at the isolation and locking level the invariant needs. A permission check performed minutes before a write is a time-of-check/time-of-use gap.
ACL, ABAC, and relationship-based policy
- RBAC assigns permission bundles to roles. It is understandable and works well for organizational capabilities.
- ACLs attach principals or groups to individual resources. They are precise but can become large and difficult to audit.
- Attribute-based access control evaluates subject, resource, action, and context attributes. It is expressive but policy interactions become harder to explain.
- Relationship-based access control derives permission from graph relationships such as owner, parent team, or shared folder. It suits collaboration products but needs careful consistency and traversal limits.
Most systems combine these. For example, RBAC grants document:read, tenant membership establishes the boundary, and an ownership or sharing relationship grants access to one document. Write down precedence, especially for explicit denies and inherited grants.
Multi-tenancy models
Multi-tenancy means one system serves multiple customer security boundaries. Tenant isolation includes data, caches, files, search, queues, logs, metrics, quotas, backups, and support tooling, not only SQL rows.
| Model | Isolation | Operational cost | Typical fit |
|---|---|---|---|
Shared database, shared schema, tenant_id per row |
Application and optional row-policy isolation | Lowest migration and connection overhead | Many small tenants with common schema |
| Shared database, schema per tenant | Namespace separation | Migrations and search-path management grow with tenants | Moderate tenant count with schema isolation requirements |
| Database per tenant | Stronger database boundary and independent restore | Connection pools, migrations, monitoring, and routing are expensive | Large or regulated tenants, data residency, custom lifecycle |
| Hybrid tiers | Matches cost to customer needs | Routing and feature parity become more complex | Broad customer size or compliance range |
No model eliminates application authorization. A database-per-tenant deployment can still route a request to the wrong database.
Tenant resolution
Resolve tenant context from authenticated, server-validated state. A subdomain, path value, or X-Tenant-ID header is a request hint, not proof of membership.
A safe switch flow is:
- Authenticate the subject.
- Receive the requested tenant identifier.
- Verify active membership and any client restrictions.
- create a new session context or short-lived token bound to that tenant.
- Audit the switch.
One operation should have one immutable tenant context. Avoid letting nested repository calls accept arbitrary tenant IDs from request models.
Validate hostnames against trusted configuration before subdomain-based resolution. Do not build password-reset links or issuer URLs from an untrusted Host or forwarded header. Configure proxy trust explicitly.
Shared-schema database design
Put tenant_id NOT NULL on every tenant-owned table, including join tables, idempotency records, audit rows, outbox events, and background-job state. Include it in common indexes and uniqueness constraints:
ALTER TABLE projects
ADD CONSTRAINT projects_tenant_id_id_uq UNIQUE (tenant_id, id);
ALTER TABLE tasks
ADD CONSTRAINT tasks_project_tenant_fk
FOREIGN KEY (tenant_id, project_id)
REFERENCES projects (tenant_id, id)
ON DELETE CASCADE;
CREATE UNIQUE INDEX projects_tenant_slug_uq
ON projects (tenant_id, slug)
WHERE deleted_at IS NULL;
The composite foreign key prevents a task in tenant A from pointing to a project in tenant B. The unique index allows two tenants to use the same slug while protecting uniqueness inside each tenant.
A repository base class that adds tenant filters can reduce repetition, but hidden query mutation is not proof of isolation. Raw SQL, joins, bulk updates, new repositories, and maintenance scripts can bypass it. Prefer explicit predicates plus database constraints and tests.
PostgreSQL row-level security
PostgreSQL row-level security (RLS) can add a database enforcement layer:
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
ALTER TABLE orders FORCE ROW LEVEL SECURITY;
CREATE POLICY orders_tenant_isolation ON orders
USING (tenant_id = nullif(current_setting('app.tenant_id', true), '')::uuid)
WITH CHECK (tenant_id = nullif(current_setting('app.tenant_id', true), '')::uuid);
Set context transaction-locally after beginning the transaction:
from sqlalchemy import text
async def set_tenant_context(
session: AsyncSession,
tenant_id: UUID,
) -> None:
await session.execute(
text("SELECT set_config('app.tenant_id', :tenant_id, true)"),
{"tenant_id": str(tenant_id)},
)
The third argument to set_config makes the value local to the transaction. This is critical with pooled connections. Set it at every transaction boundary before tenant data is accessed, and fail closed when it is absent.
RLS needs careful threat modeling and testing:
- table owners normally bypass RLS unless forced;
- roles with
BYPASSRLSbypass policies; - privileged migrations and maintenance jobs need separate, explicit roles;
- policy expressions affect query planning and performance;
- views, functions, foreign keys, backups, and replication need review;
- a connection-local setting can leak across requests if it is not transaction-local and reset;
- RLS does not isolate caches, files, search indexes, or downstream APIs.
Use a minimally privileged application database role. RLS is defense in depth, not a reason to remove tenant predicates from application queries.
Isolation beyond PostgreSQL
Caches
Every tenant-owned cache key includes tenant and authorization-relevant dimensions:
A globally keyed project:<id> cache can return another tenant's object if IDs collide or routing fails. Do not cache an authorization decision longer than the membership/role staleness policy. Invalidation events must themselves carry verified tenant context.
Object storage and uploads
Use server-generated object keys under tenant prefixes, but do not rely on a prefix alone. Record ownership in the database and authorize every upload completion, download, and signed-URL creation. Keep signed URLs short-lived and scope them to one object and operation. Storage credentials used by the API should be limited by bucket and action.
Search
Index tenant identity as a mandatory field and add it to every search filter in trusted server code. Search results are another list authorization path. Indexing delay means deleted or newly restricted content may remain discoverable; define deletion and permission-update propagation behavior.
Queues and jobs
A job payload carries an immutable tenant ID and actor/system context. The worker reloads current authorization if the action requires it. Do not let a user-controlled payload select arbitrary storage credentials or database routing. Dead-letter queues and retries must retain tenant metadata without exposing sensitive payloads.
Observability
Tenant identifiers are useful log and metric dimensions, but raw high-cardinality tenant IDs can overwhelm metric systems or expose customer information. Put tenant ID in access-controlled structured logs and traces; use bounded tiers or sampled dimensions for metrics. Never put access tokens or secrets in any of them.
Administrative and support access
Platform operators are a distinct security domain. Avoid a universal is_superuser branch scattered across code.
Elevated access should be:
- granted through a separate, narrowly scoped role;
- protected by strong and recent authentication;
- time-bound and approval-bound where risk warrants it;
- explicit about target tenant and reason;
- visible to the operator throughout the session;
- fully audited, with alerts for sensitive actions;
- unable to silently impersonate a customer identity.
For support impersonation, retain both the real operator and effective subject in the principal and audit record. Do not issue an indistinguishable customer token.
Background maintenance should use named system principals with purpose-specific permissions, not subject_id = NULL as a magic bypass.
Audit authorization decisions
High-value audit records commonly include:
- event time and request/correlation ID;
- real actor and effective actor;
- tenant, action, and resource type/ID;
- allow or deny decision and stable reason code;
- authentication method and recent-auth status;
- relevant role/policy version;
- source service and safe network/device context.
Audit logs must be access-controlled, integrity-protected, retained according to policy, and scrubbed of secrets and unnecessary personal data. An audit log that an application administrator can silently rewrite is weak evidence.
Common failure modes
Checking a role in the router only
Workers, GraphQL resolvers, internal calls, and future endpoints bypass it. Enforce core policy in the service or policy layer and use route dependencies for early rejection.
Loading by ID, then checking tenant
One missed check becomes cross-tenant access. Scope the lookup itself and return a non-revealing result.
Trusting tenant ID from a header
The client selects another tenant. Treat it only as a requested context and verify membership server-side.
Caching by resource ID only
Authorized data crosses tenants or viewer classes. Namespace and include authorization dimensions.
Long-lived permissions in JWTs
Removed users retain access until token expiry. Shorten tokens or consult revocable current state according to risk.
A role called admin means everything
Privilege grows invisibly and violates least privilege. Use explicit permissions and separate tenant, billing, security, and platform administration.
Bulk endpoint checks only the first object
Mixed authorized and unauthorized IDs leak or mutate data. Apply authorization to the whole SQL operation and define all-or-nothing or per-item results.
RLS connection context leaks
A pooled connection retains tenant A for tenant B. Use transaction-local context, transaction tests, and a fail-closed policy.
Testing authorization
Create a policy matrix before writing tests:
| Principal | Same tenant | Permission | Relationship | Expected |
|---|---|---|---|---|
| Anonymous | N/A | N/A | N/A | 401 |
| Member | Yes | Yes | Allowed | Success |
| Member | Yes | No | Allowed | 403 |
| Member | Yes | Yes | Not allowed | 404 or 403 by policy |
| Member | No | Yes in own tenant | Any | Non-revealing 404 |
| Disabled member | Yes | Formerly yes | Any | Deny |
| Support actor | Explicit elevation | Narrow grant | Target tenant | Success and audit |
Exercise every operation type: get, list, search, count, create with parent IDs, update, delete, bulk mutation, export, file access, and background processing.
Include tests that:
- substitute another tenant's UUID in every path and body identifier;
- combine same-tenant parent IDs with cross-tenant child IDs;
- verify pagination cursors cannot switch tenant or filter scope;
- change membership during a session and measure revocation behavior;
- attempt mass assignment of owner, tenant, and role fields;
- verify cache keys and invalidation events contain tenant context;
- run RLS checks using the actual minimally privileged application role;
- verify missing RLS context returns no rows and rejects writes;
- ensure support actions retain real actor identity in audits.
Static route tests are insufficient. Test repository/service entry points and worker handlers because HTTP is only one caller.
Interview discussion
RBAC versus ABAC?
RBAC is easier to explain and administer for stable job capabilities. ABAC handles context and resource attributes but creates more complex policy interactions. Mature systems often use RBAC for coarse capability and explicit resource or attribute checks for the final decision.
How do you prevent cross-tenant access?
Derive tenant context from authenticated membership, include tenant_id in every owned row and query, use composite constraints, namespace non-database systems, test adversarial substitutions, and optionally add PostgreSQL RLS under a non-bypass role.
Is a random UUID an authorization control?
No. It reduces easy enumeration but can leak through logs, links, analytics, or another endpoint. Every object access still requires authorization.
What is the risk of permissions in a JWT?
They remain valid until expiry even after membership changes, propagate sensitive structure, and can diverge across services. State the accepted staleness or perform a current-state check for sensitive actions.
When would you choose database-per-tenant?
When isolation, residency, independent backup/restore, or very large-tenant workload control justifies routing, pool, migration, and operational cost. It is an infrastructure boundary, not a replacement for authorization.