Photo by Dmitrii Pashutskii on Unsplash
You can define complex permission rules with human-readable policies and manage them without changing the application code.
Introduction
The blog post shows how to define and use Cedar policies in the Lambda Function. We will define policies using Cedar and use cesar-policy
crate to validate them in the Lambda Function code.
Scenario
We have a service for managing generated reports. Reports are stored as PDF files on the S3. Users can see general info about reports and download files using presigned URLs. While general data related to reports is available for all authenticated users, the presigned URLs can be generated only by the report owners.
What we are building
I create a REST API that exposes two endpoints: get-report-info
and get-report-url
. The first is available for all authenticated users, the second is only for reports' owners.
Architecture
The high-level picture of the overall solution could look like this.
The green rectangle highlights the part that will be implemented in this blog post.
Tech stack
IaC - AWS SAM
Application code - Rust
, cargo-lambda
Authorization logic - Cedar
, cedar-policy
The whole code is available in this repository
Cedar
Cedar is the language for defining authorization policies and making decisions based on them. The main idea is to decouple authorization logic from the business logic of the application.
The syntax of Cedar
is simple yet powerful. It allows for defining fine-grained access restrictions in a human-readable fashion. Let's see our example:
permit(
principal is User,
action == Action::"GetReportInfo",
resource == Resource::"ReportData"
);
permit(
principal is User,
action == Action::"SetReportInfo",
resource == Resource::"ReportData"
) when {
resource.owner_id == principal.id
};
permit(
principal is User,
action == Action::"GenerateS3Url",
resource == Resource::"S3Object"
) when {
resource.owner_id == principal.id
};
Even if this is the first time you see Cedar policy, you probably can reason about it.
There are 3 policies, for 3 different actions:
-
GetReportInfo
is available for everyUser
(principal) for allReportData
(resource) -
SetReportInfo
allows only users who are owners of the resource - the same goes for
GenerateS3Url
Authorization check
The policies alone don't do much. To be able to run authorization verification I need a few more elements.
Entities
The entities are principal, resource, and action. Authorization logic uses them to validate requests against policies set.
Context
Sometimes you would need to attach additional information that is not related to the specific user, like source IP, or time of request. The context is the right place to include this data.
Schema
I won't use schema validation, however, it is a powerful tool to guarantee policies' correctness. More about this topic can be found in documentation
Implementation
The authorization logic is decoupled from the application code, which means that we need to map the application logic to Cedar entities.
For my simple use case, I create a service in Rust to handle authorization and use it directly in my Lambda code. I could also build a Lambda Extension and use it from a Lambda Function written in any language.
Models
To make a decision, my authorization service would need to receive the user id, action type, resource type and resource owner id. I use a rich Rust's types system to describe expected input.
// authorization/models.rs
// input types
#[derive(Clone, Debug)]
pub struct UserId(pub String);
impl UserId {
pub fn as_str(&self) -> &str {
&self.0
}
}
#[derive(Clone, Debug)]
pub struct OwnerId(pub String);
impl OwnerId {
pub fn as_str(&self) -> &str {
&self.0
}
}
#[derive(Serialize, Deserialize, Clone)]
pub enum ActionVerb {
GetReportInfo,
SetReportInfo,
GenerateS3Url
}
impl fmt::Display for ActionVerb {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
let str = match self {
ActionVerb::GetReportInfo => "GetReportInfo",
ActionVerb::SetReportInfo => "SetReportInfo",
ActionVerb::GenerateS3Url => "GenerateS3Url"
};
write!(f, "{}", str)
}
}
#[derive(Serialize, Deserialize, Clone)]
pub enum ResourceType {
S3Object,
ReportData
}
impl fmt::Display for ResourceType {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
let str = match self {
ResourceType::S3Object => "S3Object",
ResourceType::ReportData => "ReportData"
};
write!(f, "{}", str)
}
}
// output
#[derive(Debug)]
pub enum AuthorizationDecision {
Allow,
Deny
}
UserId
and OwnerId
utilize a newtype pattern
. I also implement as_str
for both of them to make it easy to get a reference to their inner values.
ActionVerb
and ResourceType
are enums with implemented Display
to easily convert to_string
and print to the console if needed.
There are also internal types to help with creating cedar-policy
entities.
// models.rs
// ...
// internal types
#[derive(Clone)]
pub struct UserEntityInput {
pub user_id: UserId
}
impl UserEntityInput {
pub fn new(user_id: UserId) -> Self {
Self { user_id }
}
}
#[derive(Clone)]
pub struct ActionEntityInput {
pub verb: ActionVerb
}
impl ActionEntityInput {
pub fn new(verb: ActionVerb) -> Self {
Self { verb }
}
}
#[derive(Clone)]
pub struct ResourceEntityInput {
pub resource_type: ResourceType,
pub owner_id: OwnerId
}
impl ResourceEntityInput {
pub fn new(resource_type: ResourceType, owner_id: OwnerId) -> Self {
Self { resource_type, owner_id }
}
}
#[derive(Clone)]
pub enum CreateEntityInput {
User(UserEntityInput),
Action(ActionEntityInput),
Resource(ResourceEntityInput)
}
Authorize function
The main function of the authorization service takes input defined above, and returns decision.
// service.rs
//...
pub(crate) fn authorize(
user_id: UserId,
action_verb: ActionVerb,
resource_type: ResourceType,
owner_id: OwnerId,
) -> Result<AuthorizationDecision, Box<dyn Error>> {
let policy = get_policy_set()?;
let user_entity_input = UserEntityInput::new(user_id);
let action_entity_input = ActionEntityInput::new(action_verb);
let resource_entity_input = ResourceEntityInput::new(resource_type, owner_id);
let principal = create_entity(CreateEntityInput::User(user_entity_input))?;
let action = create_entity(CreateEntityInput::Action(action_entity_input))?;
let resource = create_entity(CreateEntityInput::Resource(resource_entity_input))?;
let request = Request::new(
principal.uid(),
action.uid(),
resource.uid(),
Context::empty(),
None,
)?;
let entities = Entities::from_entities([principal, action, resource], None)?;
let authorizer = Authorizer::new();
let answer = authorizer.is_authorized(&request, &policy, &entities);
let decision = match &answer.decision() {
cedar_policy::Decision::Allow => {
println!("Allowed: {:?}", &answer);
AuthorizationDecision::Allow
},
cedar_policy::Decision::Deny => {
println!("Deny: {:?}", &answer);
AuthorizationDecision::Deny
},
};
Ok(decision)
}
// ...
Let's go through it step by step:
fetch policies with
get_policy_set
function. In this example, policies are defined as a string directly in the code. In real life, policies can be stored in the S3, DynamoDB, or wherever.convert received function args to the internal type
CreateEntityInput
create
cedar-policy
entities using mycreate_entity
helper functionprepare
Request
to theAuthorizer
, and check the decision for the given input
Using inside Lambda Function
The flow of authorizing requests will be the following:
At this point, I will mock my reports service
and return reports from the static list. I would use DynamoDB or any other database here in the real scenario.
I've also made some assumptions regarding the Claims
shape in the JWT token. They would work if you use Cognito
but might not work with other services.
Get S3 URL hanlder
The authorization flow in the handler looks like this:
// handlers/get_report_s3url.rs
// ...
pub(crate) async fn function_handler(
event: Request,
reports_service: &ReportsService,
) -> Result<Response<Body>, Error> {
// get user id from auth token
let token = event
.headers()
.get(AUTHORIZATION)
.and_then(|header| header.to_str().ok())
.and_then(|token| token.strip_prefix("Bearer "))
.ok_or("Missing auth token")?;
let user_id = user_context::jwt::get_user_id(token)?;
// get report id
let report_id = event
.query_string_parameters_ref()
.and_then(|params| params.first("report"))
.ok_or("Missing report id")?;
// get report
let report = reports_service.get_report(report_id.to_string())
.ok_or("missing report with given id")?;
// authorize request
let decision = authorization::service::authorize(
UserId(user_id),
ActionVerb::GenerateS3Url,
ResourceType::S3Object,
OwnerId(report.owner_id),
)?;
// return response
let resp = match decision {
AuthorizationDecision::Allow => Response::builder()
.status(200)
.header("content-type", "text/html")
.body("Here you go, this is your dummy s3 url: dummy".into())
.map_err(Box::new)?,
AuthorizationDecision::Deny => Response::builder()
.status(404)
.header("content-type", "text/html")
.body("You are not authorized".into())
.map_err(Box::new)?,
};
Ok(resp)
}
extract a token from the authorization header
get the user id from the jwt token using
get_user_id
function. There is an assumption thatclaims
includeusername
field which is the user id. The important thing is that I didn't validate the token, because it was already validated on the API HTTP Gateway.get the report id from the query string and look for the report using
reports_service
use user id and owner from the report to get authorization decision
Test handler
In the root folder, I run the following command:
sam build && sam local start-api
Once the local container is up, I test the path using Bruno
. (I use the token from the already configured cognito user pool from another project, with username
in Claims
04781408-1081-706c-c3ac-3c618d5a379a). My dummy data in the ProjectsService
looks like this:
//...
reports: vec![
Report {
id: "abc-123".to_string(),
title: "dummy report".to_string(),
owner_id: "04781408-1081-706c-c3ac-3c618d5a379a".to_string(),
s3_key: "dummy/key/file.pdf".to_string(),
},
Report {
id: "abc-124".to_string(),
title: "dummy report 2".to_string(),
owner_id: "123".to_string(),
s3_key: "dummy/key/file.pdf".to_string(),
},
Report {
id: "abc-125".to_string(),
title: "dummy report 3".to_string(),
owner_id: "321".to_string(),
s3_key: "dummy/key/file.pdf".to_string(),
},
]
// ...
I expect to be able to get URL for the abc-123
report and be declined for others.
Looks good!
In the get_report_data
handler the flow is exactly the same, there is only different action and resource type defined
// handlers/get_report_data.rs
// ...
let decision = authorization::service::authorize(
UserId(user_id),
ActionVerb::GetReportInfo,
ResourceType::ReportData,
OwnerId(report.owner_id),
)?;
// ...
As expected this time I am able to query data for abc-124
report
It works 🎉 🎉 🎉
Summary
In this blog post, I created the authorization flow using the Cedar language to define policies and the cedar-policy
crate to run authorization logic in the Rust code.
Here are my thoughts after playing with the Cedar language and using it in the AWS Lambda with cedar-policy
crate
Cedar is a powerful and expressive language. It allows you to define access control based on role, attributes, and relations.
Cedar was created with complex systems in mind. It allows managing thousands of policies and complex access patterns, which require solving different problems, such as managing and updating policies.
Simpler solutions can still benefit from decoupling authorization logic from application code. Policies can be stored in S3 or any db.
Authorization logic can be seamlessly integrated into Rust code using
cedar-policy
crate.
Top comments (0)