Multi-tenant application: organization-scoped routes in Axum
Multi-tenancy sounds like a simple idea - reuse the same application across multiple organizations. Usually, you put the organization identifier somewhere in the URL, like a subdomain or a path parameter. You quickly realize that making sure that identifier is carried through and validated with every request is not as easy as it sounds. You have requests, queries, redirects, webhooks, forms, emails, and all kinds of checks that must never cross tenant boundaries.
Those kinds of problems I have been wrestling with recently through Featherboard, which is a self-hosted customer feedback management board written in Rust with Axum, Askama, HTMX, and SQLite.
Organization slug is the first parameter of every route
Featherboard constructs the path in the following way:
/{org-slug}/ideas
/{org-slug}/roadmap
/{org-slug}/settings/site
/{org-slug}/settings/user
The organization identifier is located in the /{org-slug} parameter. All routing is therefore evaluated in the context of that parameter.
This gives the tenant an explicit boundary, and every request is scoped to a specific organization, so instead of asking for /ideas/43, it is asking for idea 43 inside a particular organization.
This distinction is useful even if IDs are unique across the entire system, because unique integer IDs do not protect against cross-referencing resources.
Central place to extract organization info
Instead of extracting the organization from the slug in every request, Featherboard implemented a CurrentOrg request extractor. It takes the path, extracts the first parameter, looks up the organization in the database, and provides the organization struct to the request handler.
This method allows every handler to take CurrentOrg as a parameter, like so:
async fn some_handler(
current_org: CurrentOrg,
) -> Result<Response, AppError> {
let org_id = current_org.id;
// Rest of the function
}
This way, routing logic remains consistent across all endpoints, while invalid organization slugs are rejected with a 404 Not Found response.
Enforced organization scope
Organization scope is enforced in the resource query, not just through a route lookup. Let's look at a concrete example:
SELECT ...
FROM ideas
WHERE id = ? AND org_id = ?
The same rule applies to write operations:
UPDATE ideas
SET status = ?
WHERE id = ? AND org_id = ?
It would be more obvious to write the handler with just a simple WHERE id = ?, especially since the URL slug was validated in the CurrentOrg extractor, but that kind of validation is not enough. The SQL query must enforce that the record belongs to the same tenant.
The same thing applies to authorization. The admin check needs to include a SQL query to verify that the user_id and org_id match. If a user is an admin within one organization, it should not give them admin rights to all organizations.
Organization boundary was the most obvious in settings
We have two separate settings categories in Featherboard:
/{org-slug}/settings/site
/{org-slug}/settings/user
Site settings are only accessible by admins and contain settings affecting the entire organization, like configuration keys, billing information, branding, and such. User settings, on the other hand, contain information regarding an individual user, such as notification preferences, and should be accessible by regular users as well.
Both admins and regular users exist within one organization scope, but they have different authorization rules. Because of this routing structure, access to those settings is clearly scoped, and the handlers do not need to accept unrelated requests.
The same routing is implemented for form actions and redirects. If we blindly accept /settings/user/notification without the organization prefix, there is a real risk that settings would be saved for a user that belongs to a different organization. The same applies to redirects because navigating to /settings/site would land you in a different organization entirely.
Things that are less obvious
Routing is only one part of the system. To ensure multi-tenant support, the organization prefix had to be implemented in many places across the entire system, such as:
- HTML links
- Form actions
- Redirects
- Email links
- Stripe checkout
- Webhooks
- Tests and assertions
All of the above are quite fragile, because the compiler will not help you validate them. Rendering a website with invalid links still produces valid HTML, so writing proper unit tests and integration tests is absolutely essential.
Unit tests and integration tests
In order to ensure the stability of Featherboard, we need to verify that:
- An existing
org-slugreaches the correct handler. - Users can only access and modify their own settings.
- A malicious request cannot modify settings that belong to another organization.
- Redirects preserve the organization path.
- Links and forms reference URLs that belong to the same organization.
In Featherboard, besides unit tests located in the same module as the code, there are tests that verify the correct behavior of a real Axum router using test dependencies such as a SQLite database and a session store. Those kinds of tests catch different classes of bugs that a typical unit test would not catch, such as route nesting, session cookie handling, extractors, database migrations, writes, and state changes.
The tradeoffs
Path-based tenancy routing is not the only design choice. Another valid option is a subdomain model, e.g. acme.example.com. You could also resolve tenancy through a session token or use some other creative way to differentiate between organizations.
For Featherboard, I chose the path-based model because it makes hosting very simple and does not require wildcard DNS or complicated reverse proxy configuration. The downside is that every URL has to include the org-slug prefix.
The main takeaway from this is that once the organization identifier is in the path, it also needs to be part of the handler context, database design, authorization checks, validations, redirects, HTML templating, and so on.
Top comments (0)