An authorization framework with compile-time enforcement.
Dacquiri turns authorization vulnerabilities into compile-time errors.
Dacquiri has two main concepts that govern how authorization policies are defined and applied.
dacquiri
relies on nightly + multiple unstable features to work.
The following unstable features will, at minimum, be required in your
application for it to work with dacquiri
.
#![feature(generic_associated_types)]
#![feature(adt_const_params)]
#![feature(generic_arg_infer)]
Additionally, you can add #![allow(incomplete_features)]
to ignore the inevitable unstable feature warnings.
Attributes are properties we prove about a Subject
(the entity we are applying the authorization check against).
Attributes are statements that are true about a particular subject. For example,
UserIsEnabled
may be an attribute defined for User
subjects that have their enabled
flag set to true
.
Some additional attributes you might define could answer the following:
-
Is this user's ID verified?
-
Is this user's account older than 30 days?
-
Is this user a member of a particular team?
The last attribute introduces us to the idea of resources. A Resource is the object a subject is attempting to acquire a particular attribute against. A common example of an attribute, with a resource, would be a
UserIsTeamMember
attribute. For this attribute, the subject isUser
and the resource isTeam
. This attribute would only be granted if theUser
was a member of the specifiedTeam
.While this a useful primitive, it wouldn't make much sense to check if a
User
was a member of aTeam
and then perform actions against a completely differentTeam
object. Therefore, attributes also remember which resource they were acquired against. This way, if necessary, you can access an attribute's associated resource.
We define attributes using the attribute
macro, 1 to 3 arguments, and an AttributeResult
return type.
use dacquiri::prelude::*;
#[attribute(UserIsEnabled)]
fn check_user_is_enabled(user: &User) -> AttributeResult<String> {
match user.enabled {
true => Ok(()),
false => Err(format!("User is not enabled."))
}
}
This will automatically generate an attribute with a User
as the subject and ()
as the resource.
If we have a resource we depend on, we can add it as the second argument to the function.
use dacquiri::prelude::*;
#[attribute(UserIsTeamMember)]
fn check_user_team(
user: &User,
team: &Team
) -> AttributeResult<String> {
match team.users.contains(&user.user_id) {
true => Ok(()),
false => Err(format!("User is not specified team."))
}
}
The generated UserIsTeamMember
attribute will have User
as the subject and Team
as the resource.
Sometimes, you may not have all of the required information to determine if a subject has a particular attribute
for a particular resource even if you already have that resource fetched. In these cases, you can specify an optional
third argument to provide context or assets required to access additional, required information.
Here's an example iteration on the previous attribute we defined where we fetch data, live, from a database.
use dacquiri::prelude::*;
#[attribute(UserIsTeamMember)]
async fn user_team_check(
user: &User,
team: &Team,
conn: &mut DatabaseConnection
) -> AttributeResult<String> {
let row_count = conn.count_query(
"select count(*) from memberships where uid = {} and tid = {}",
vec![user.user_id, team.team_id]
)
.await
.map_err(|_| format!("DB error."))?;
// if we have more than 1 records, we're on the team!
match row_count > 0 {
true => Ok(()),
false => Err(format!("User is not on the specified team."))
}
}
You should notice two things that are different about this particular attribute.
- We didn't have to make the context (3rd argument) an immutable reference. Attribute context's can be owned, immutable, or mutable references. This allows you to use any concrete type you wish here.
- You should also notice that this attribute function is
async
! Attributes support async and it's as simple as just adding the keyword to the function. All of the other work is handled automatically for you. We'll come back to attributes in a bit, but first let's talk about Entitlements.
Entitlements are traits, gated behind one or more attributes, that are automatically applied
to any subject that has acquired all of the prerequisite attributes at some point, in any order.
An example of a useful entitlement could be a VerifiedUser
entitlement which would require the following attributes:
UserIsEnabled
- Checks that the user's enabled flag is trueUserIsVerified
- Checks that the user's verified state isVerified::Success
Entitlements allow us to guard functionality behind a prerequisite set of attributes using default trait methods.
We start by defining a trait with the entitlement
macro.
#[entitlement(UserIsVerified, UserIsEnabled)]
pub trait VerifiedUser {
fn print_message(&self) {
println!("Hello, world!!");
}
}
This entitlement requires that a subject have both the UserIsVerified
and UserIsEnabled
attributes.
If a subject has acquired both attributes, VerifiedUser
will automatically be implemented on the subject.
To get access to the User
subject again, we use the get_subject
or
get_subject_mut
methods. Then we can access information
or make changes to our subject once again.
#[entitlement(UserIsVerified, UserIsEnabled)]
pub trait VerifiedUser {
fn change_name(&mut self, new_name: impl Into<String>) {
self.get_subject_mut().name = new_name.into();
}
}
We can create async methods here as well using #[async_trait]
like a normal trait.
#[async_trait]
#[entitlement(UserIsVerified, UserIsEnabled)]
pub trait VerifiedUser {
// set the account's enabled to false and consume the user
async fn disable_account(self, conn: &mut DatabaseConnection) {
let query = escape!(
"UPDATE users SET enabled = false WHERE uid = {};",
self.get_subject().user_id
);
conn.execute(query).await;
}
}
To acquire an attribute, we call one of the following on our subject.
try_grant
try_grant_async
try_grant_with_context
try_grant_with_context_async
try_grant_with_resource
try_grant_with_resource_async
try_grant_with_resource_and_context
try_grant_with_resource_and_context_async
For example, if we wanted to check if our User
was both enabled and a member of a Team
we could do the following.
We'll use the previous UserIsEnabled
and UserIsTeamMember
attribute definitions.
#[tokio::main]
async fn main() -> Result<(), String> {
let user: User = get_user();
let team: Team = get_team();
let mut conn: DatabaseConnection = get_database_conn();
let checked_user = user
.try_grant::<UserIsEnabled>()?
.try_grant_with_resource_and_context_async::<UserIsTeamMember, _>(team, &mut conn).await?;
}
Now that we know how to acquire an attribute for a subject, let's put the entitlement system to work by guarding a function with one or more entitlements.
We treat entitlements like regular traits and guard with your favorite trait-bound syntax.
Here's a longer, more complicated example, that demonstrates the value that dacquiri
provides
by guarding access to the leave_team
functionality to Users
until they have checked both
attributes required by the TeamMember
entitlement bound.
It does not matter the order that the try_grant_*
functions are called, that they are called
sequentially, or that they even happened in the same function.
#[tokio::main]
async fn main() -> Result<(), String> {
let user: User = get_user();
let team: Team = get_team();
let mut conn: DatabaseConnection = get_database_conn();
let mut checked_user = user
.try_grant::<UserIsEnabled>()?
.try_grant_with_resource_and_context_async::<UserIsTeamMember, _>(team, &mut conn).await?;
leave_my_account(&mut checked_user).await
}
async fn leave_my_team(user: impl TeamMember) -> Result<(), String> {
// you can't call `.leave_team()` if you're not
// a TeamMember (which requires UserIsEnabled and UserIsTeamMember)
user.leave_team().await
}
#[entitlement(UserIsEnabled, UserIsTeamMember)]
#[async_trait]
trait TeamMember {
// we capture self here because leaving the team
// means we're no longer a team member
async fn leave_team(
self,
conn: &mut DatabaseConection
) -> Result<(), String> {
let user = self.get_subject();
// we need to specify *which* attribute's resource we want
let team = self.get_resource::<UserIsTeamMember, _, _>();
let query = escape!(
"DELETE FROM members WHERE uid = {} AND tid = {};",
user.user_id,
team.team_id
);
conn
.execute(query)
.await
.map(|_| format!("DB error"))?;
Ok(())
}
}
The last topic that needs to be covered is about subjects. We mentioned them earlier; subjects are the
entities that we're administering an authorization policy against and applying access control.
We do need to denote subjects before we can start acquiring attributes on them.
Do mark a struct as a Subject
we mark them with #[derive(Subject))
use dacquiri::prelude::Subject;
#[derive(Subject)]
pub struct AuthenticatedUser {
username: String,
session_token: String,
enabled: bool
}
That's it!
Now you have a relatively good grasp on how dacquiri
works and how you can use it
to life authorization requirements into the type system.