The Permission Is the Type
Or: how type-driven development made authorisation feel surprisingly simple
Authorisation is one of those problems every application has to solve, but it’s also one of the easiest places for logic to become scattered throughout a codebase.
Coming from a TypeScript background, I’m used to permission checks being runtime concerns. A user has a role, a string gets checked somewhere, and access is either granted or denied.
While working on a recent project, we built an authorisation system into our shared auth library. At first glance it looked like a lot of types and abstractions, but once the pieces came together it became clear just how much complexity the design removes from application code.
Rather than checking permissions throughout the application at runtime, permissions become part of the type system itself. That means many of the mistakes you’d normally discover while testing are caught by the compiler instead.
The Problem
Permission checks often start life looking something like this:
if (!user.roles.includes("manager")) {
throw new Error("Unauthorised");
}
The approach works, but the challenges appear as applications grow.
Permission checks end up spread across handlers, services and UI components. Role names become strings that can be mistyped. New functionality relies on developers remembering to add the correct checks in the correct places.
Over time, the model of what permissions exist and the enforcement of those permissions become separate concerns.
Modelling Roles and Permissions
The authorisation system is built around three concepts:
- Roles – who a user is
- Permissions – what a user can do
- Grant rules – how permissions are assigned
At a high level, it looks something like this:
enum AppRole {
Manager,
TeamA,
TeamB,
Superuser,
}
enum Permission {
ManageRecords,
AccessBulkUpload,
}
Roles are mapped from Microsoft Entra app roles, while permissions represent actions within the application.
The interesting part is that permissions also define how they can be granted.
Permission Composition
Some permissions are granted directly by a role:
ManageRecords => Manager
Others can be composed from existing permissions:
AccessBulkUpload =>
UploadTypeA OR UploadTypeB
Instead of permission logic being repeated throughout the application, the relationships are defined once and become the source of truth. If the rules change, there’s only one place to update them.
How the Grant Rule Resolution Works
What makes this particularly elegant is how the system resolves a permission check at runtime.
When you call user.can(&permission), it asks the permission for its GrantRule. If the rule is AnyRole, it checks directly whether the user holds one of those roles. If the rule is AnyPermission, it recursively calls can() on each of the listed permissions until one of them bottoms out at a role check.
fn can(&self, permission: &P) -> bool {
self.0.iter().any(A::is_superuser) || self.satisfies(permission.grant_rule())
}
fn satisfies(&self, grant_rule: GrantRule<A, P>) -> bool {
match grant_rule {
GrantRule::AnyRole(roles) => self.has_any_role(roles),
GrantRule::AnyPermission(permissions) => self.has_any_permission(permissions),
}
}
fn has_any_permission(&self, permissions: &[P]) -> bool {
permissions.iter().any(|permission| self.can(permission))
}
So checking AccessBulkUpload walks the permission tree. The system evaluates each permission, resolving any nested permissions until it reaches a role check. If any path succeeds, access is granted.
The whole traversal happens automatically just by defining the grant rules.
Another thing I liked about the design is the superuser behaviour. If any of the user’s roles satisfy is_superuser(), the entire check short-circuits to true without walking the tree at all.
Generating Authorised Actors
Once roles and permissions are defined, a macro generates strongly-typed actors for the application.
authorised_actor!(
AppRole,
RecordManager => Permission::ManageRecords
);
This creates a RecordManager type.
The important detail is that this type can only be extracted from a request if the user actually has the required permission.
If they don’t, extraction fails before the handler logic ever runs.
A Type As Proof
The generated actor type looks like this under the hood:
pub struct RecordManager(PhantomData<AuthenticatedUser<AppRole>>);
PhantomData is a zero-sized type. It takes up no memory at runtime and exists purely to tell the compiler that RecordManager is logically connected to AuthenticatedUser<AppRole>, even though it doesn’t actually hold one.
This matters because RecordManager can only be constructed in one place: inside the TryFrom implementation that checks the permission first. If the permission check fails, the type is never created. If it succeeds, you get a RecordManager — a compile-time token that proves the check already happened.
The upshot is that passing a RecordManager to a function is proof of authorisation.
Not a flag.
Not a boolean.
An actual type the compiler tracks for you.
If you forget to extract it, the code won’t compile. If you extract it and it fails, the error propagates before your handler runs. There’s no way to accidentally skip the check.
The Bit That Made It Click
This is where the approach really started to make sense to me.
At the call site, the permission check becomes:
let _actor: RecordManager = extract().await?;
That’s it.
No role strings.
No conditional logic.
No helper methods.
If extraction succeeds, the user is authorised.
If extraction fails, they aren’t.
The type itself becomes proof that authorisation has already happened.
Coming from a world where permission checks are usually explicit runtime logic, this felt like a completely different way of thinking about the problem.
Adding It To A New Application
When wiring this into a new application, the setup was surprisingly small.
The application needs:
- An
AppRoleenum for the Entra roles - A
Permissionenum for application actions - A set of
authorised_actor!()definitions
Everything else comes for free.
Permission composition, superuser behaviour and the request extraction logic are all handled by the shared framework.
The compiler also does a lot of the heavy lifting.
Miss a match arm when defining permissions? The compiler complains.
Reference a type that doesn’t exist? The compiler complains.
Forget to handle a new permission? The compiler helps guide you to the places that need updating.
Instead of relying on runtime testing to discover mistakes, many of them become compile-time problems.
Final Thoughts
Type-driven development was one of the things that drew me towards Rust in the first place.
I’d read plenty about using types to model business rules and push correctness into the compiler, but this was one of the clearest examples I’ve worked on of those ideas solving a real-world problem.
What I like about this approach is that it doesn’t just make the code cleaner. It changes where correctness comes from. Instead of relying on developers remembering to write permission checks, the type system becomes part of the solution itself.
By modelling permissions as types, the application moves away from remembering to perform permission checks and towards making unauthorised states difficult to represent in the first place.
It’s not just about writing cleaner code. It’s about using the type system to make correctness the default, and for something as important as authorisation, that feels like a pretty good trade.