- Published on
Put two tenants in your authorization tests
- Authors

- Name
- Mehdi Akiki
Article · Through the layers
The endpoint test creates a user, creates a record, and checks that the user can read it. Everything passes.
That test says the happy path works. It says nothing about whether the same user can read another customer's record.
For a service that separates customer data, I want two tenants in the fixture from the start. Give them similar-looking data. Make their external record IDs collide on purpose. The test should make a missing tenant condition hurt.
Cross the boundary through the real request path
Testing a database policy directly is useful, but the API can fail earlier. It might trust a tenant header, map an account to the wrong tenant, or return a shared cache entry before querying the database.
Set up one allowed request and one forbidden request using the same authenticated user. The request's target changes; the authenticated identity does not.
The complete Axum fixture uses authenticated test-token mappings and a real PostgreSQL table. The read handler binds the trusted tenant as part of its lookup:
async fn read_record(
State(pool): State<PgPool>,
headers: HeaderMap,
Path(id): Path<i32>,
) -> Result<Json<String>, StatusCode> {
let tenant = principal(&headers)?;
let value: Option<String> =
sqlx::query_scalar("SELECT name FROM records WHERE id = $1 AND tenant = $2")
.bind(id)
.bind(tenant)
.fetch_optional(&pool)
.await
.map_err(|_| StatusCode::SERVICE_UNAVAILABLE)?;
value.map(Json).ok_or(StatusCode::NOT_FOUND)
}
The exact denial response is a product choice. Some APIs use 403; others use 404 to avoid revealing whether the record exists. Make the contract explicit and check that sensitive data is absent from the body.
The write assertion matters. A handler can return a denial after it has already changed a row. Check the stored state using a separate, trusted fixture connection.
Reuse a connection between tenants
Tenant context often lives in application state or database settings. A connection pool makes accidental reuse worth testing.
Use a small pool and perform tenant A's request, then tenant B's request. Exercise failure and rollback as well as success. The second request must establish its own context rather than inherit whatever the first request left behind.
The Rust row-security example uses transaction-local context and a separate non-owner role. PostgreSQL documents who bypasses row security. Running every test as the table owner can give very comforting results for the wrong reason.
Keep privileged setup connections separate from the application pool. The test should not accidentally make its API calls using the credential that seeded the tables.
Add the paths that do not look like reads
Search, exports, bulk operations, background jobs, and attachment downloads also need the boundary. Start with the main read and write, then follow the places that use the same record identity.
OWASP recommends authorization checks on every request. For the fixture, that means a previous allowed action must not grant the next action access by accident.
Two tenants are not a complete security review. They are a cheap way to stop the most misleading happy-path test from being your only evidence. When the next feature adds a new route, reuse those competing records. Let the fixture keep asking the awkward question.
Follow the authenticated identity through all three routes
The fixture maps two fixed bearer tokens to tenant A and tenant B. It is a test identity registry, not a JWT implementation. An arbitrary tenant header never changes that mapping. Requests use Tower dispatch through the Axum router, so extraction, identity lookup, handler logic, and the database query are exercised together.
Alice can read her own record and list only private-a. Reading Bob's record returns 404 without private-b in the body. Trying to rename it also returns 404. An independent setup connection checks that Bob's stored name stayed unchanged. Bob's own request succeeds, and a request without a recognized token returns 401.
Both records deliberately share external-42. Their internal IDs differ so the test can target the other customer's record directly. Forging the tenant header in either direction must not change whose data the authenticated request can reach.
The application pool has one connection and uses the non-owner application login. Privileged setup only grants the required schema usage, select, and update rights. Requests alternate identities through that same small pool. This case uses bound tenant predicates rather than PostgreSQL row-security settings; the separate row-security guide tests transaction-local database context.
This distinction matters when interpreting a green check. The fixture proves the chosen routes and identity mapping. It does not prove a production token verifier, every export route, or every cache key. Add each alternate data path with the same competing records. The useful habit is to connect authorization to the actual request and independently observed stored result, including successful allowed work and failed cross-tenant work.