DEV Community

Cover image for Managing users’ permissions with Cedar policies in the AWS Lambda

Managing users’ permissions with Cedar policies in the AWS Lambda

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.

Image description

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
};
Enter fullscreen mode Exit fullscreen mode

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 every User (principal) for all ReportData (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
}

Enter fullscreen mode Exit fullscreen mode

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)
}

Enter fullscreen mode Exit fullscreen mode

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)
}
// ...
Enter fullscreen mode Exit fullscreen mode

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 my create_entity helper function

  • prepare Request to the Authorizer, and check the decision for the given input

Using inside Lambda Function

The flow of authorizing requests will be the following:

Image description

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)
}

Enter fullscreen mode Exit fullscreen mode
  • extract a token from the authorization header

  • get the user id from the jwt token using get_user_id function. There is an assumption that claims include username 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
Enter fullscreen mode Exit fullscreen mode

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(),
                },
            ]
// ...
Enter fullscreen mode Exit fullscreen mode

I expect to be able to get URL for the abc-123 report and be declined for others.

Image description

Image description

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),
    )?;
// ...
Enter fullscreen mode Exit fullscreen mode

As expected this time I am able to query data for abc-124 report

Image description

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

  1. Cedar is a powerful and expressive language. It allows you to define access control based on role, attributes, and relations.

  2. 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.

  3. Simpler solutions can still benefit from decoupling authorization logic from application code. Policies can be stored in S3 or any db.

  4. Authorization logic can be seamlessly integrated into Rust code using cedar-policy crate.

Top comments (0)