Keeping one club from ever seeing another club's data is a first-order security property rather than a feature, so Azimuth enforces it in two independent layers: every query filters on an org id resolved from the session and never from user input, and Postgres row-level security enforces the same boundary underneath. A bug in either layer alone therefore fails to become a data breach. Authorization on top of that is capability-based, and the capability grants are themselves per-organization data rather than hardcoded roles.
What it's built with.
How it works.
Isolated twice, on purpose
Azimuth is a single deployment serving independent organizations out of one database, which makes tenant isolation the property everything else rests on. The application layer scopes every query by an org id that is established at authentication and re-resolved on each request, deliberately never taken from a parameter, a header, or a form field, since any of those would let a caller ask for someone else's data by asking nicely.
Underneath that, row-level security applies the same rule at the database. The redundancy is the point. Application scoping is the layer that gets edited every day and therefore the layer most likely to acquire a bug, and RLS is the wall standing behind it that does not move when a query does.
Capabilities, not roles, and grants as data
Roles like dispatcher or instructor are names for bundles of capabilities, and nothing in the codebase tests a role directly. Every server action and every piece of UI asks whether the caller holds a specific capability, which keeps authorization decisions in one place and makes adding or re-scoping a role safe rather than an audit of the whole application.
The grants themselves are per-organization rows rather than constants, with the map in code acting only as the seed for a new club. Two clubs can run the same deployment with genuinely different ideas about what a dispatcher is allowed to do, and neither of them needs a deploy to change their mind. The capability set is also cut along operational lines rather than CRUD lines, so overriding a maintenance hold at dispatch is a distinct grant from ordinary dispatch access, because those are distinct amounts of trust.
The things I'm proudest of.
- ▹Every tenant-owned table carries an org_id and every query filters by the caller's, where that value is established at authentication time and re-resolved per request. It is never read from user input for the purpose of scoping, which is the mistake that turns a multi-tenant app into a shared one.
- ▹Row-level security enforces the same boundary a second time inside Postgres, so the isolation does not depend on the application layer being correct. Defense in depth here is not a slogan: it is the difference between a scoping bug and a breach.
- ▹89 fine-grained capabilities across roughly a dozen domains (members, dispatch, fleet, maintenance, billing, curriculum, checkrides, broadcast, admin) checked by both server actions and UI surfaces, so permission logic lives in one place instead of scattered role comparisons.
- ▹Capabilities are granted per organization through a role_permissions table and resolved per request onto the active user, with the in-code map serving only as the defaults used to seed a new org. A club can therefore re-scope what its own dispatchers or instructors may do without a code change.
- ▹Which roles a given role may hand out is a separate question with its own table, so a club can delegate enrolment without also delegating the ability to promote someone to admin.
- ▹Capabilities are drawn at the granularity of real decisions rather than CRUD. Filing a credential with no supporting document, overriding a maintenance hold or a weight-and-balance warning at dispatch, and hard-deleting an archived member are each their own capability, each documented with the operational reasoning for why it is separable.