Clever Engineering Blog — Always a Student

Using IAM Roles with Session Policies for Least Privilege

By Alex Smolen on

At Clever, we lock down code access to customer data using AWS IAM roles with session policies.

In Clever’s microservice AWS architecture, each service has a unique IAM role with access to the AWS resources it needs: S3 buckets, DynamoDB tables, and so on. Our services are multi-tenant and customer data is separated via logical control (i.e. our code), so there’s a risk of “crossing the streams” with a difficult-to-spot coding error. We could use separate AWS accounts for each customer or other sharding strategies, but with thousands of customers, these approaches have scaling challenges.

You can use session policies to reduce your permissions when you assume a role to the intersection of declared policies and runtime policies. They’re a drop privilege-style operation, reducing the risk of a sensitive operation being subverted.

AWS blogged about an update allowing session policies to assume managed IAM roles, and configuring your identity broker to automatically restrict permissions to someone who has access to a role to a subset of those permissions so that administrators can “reduce the number of roles they need to create”. This use case didn’t make sense for us, but we started using session policies in a way we didn’t see documented elsewhere – to scope code permissions during sensitive operations.

Let’s say your application uses an IAM role to access an S3 bucket with customer data and you use a customer identifier to namespace the S3 files in the bucket:

s3://bucket-of-private-data/<customer-number>/the-goods.zip

Your code might look something like this:

// Initialize the s3 client
session := session.New()
s3Client := s3.New(session)
// Get the customer number from some authenticated context
customerNumber := getAuthenticatedUserCustomerNumber(req)

...

// Many layers down in the call stack, get the file
key := fmt.Sprintf("%s/the-goods.zip", customerNumber)
output, err := s3Client.GetObject(&s3.GetObjectInput{
Bucket: aws.String("bucket-of-private-data"),
Key: aws.String(key),
})

Your application would use an IAM role with a policy that allows read access to all files in the S3 bucket:

"Statement": [
{
"Sid": "AllowReadBucketOfPrivateData",
"Effect": "Allow",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::bucket-of-private-data/*"
}
]

This seems straightforward enough, but there’s a risk as complex code makes access control problems more difficult to spot. You may have several places in your code where you’re accessing customer-specific files in S3, and checking each of those for errors in how the S3 path is formed can be difficult and error-prone.

As a safety precaution, your code can reassume its role with a session policy restricting access to the authenticated customer’s data during the scope of the request, pass the credentials to the AWS client, and prevent coding mistakes from becoming access control vulnerabilities.

// Create privilege-reducing session policy
bucketWithPrefix := fmt.Sprintf("arn:aws:s3:::bucket-of-private-data/%s/*", customerNumber)
policy := `{
"Version": "2012-10-17",
"Statement": [{
"Sid": "AllowAllS3ActionsWithinPrefix",
"Effect": "Allow",
"Action": "s3:*",
"Resource": "` + bucketWithPrefix + `"
}]
}`

// Assume role with session policy and use credentials for S3 client
input := &sts.AssumeRoleInput{
RoleArn: aws.String(roleArn),
RoleSessionName: aws.String("customer-" + customerNumber),
Policy: aws.String(policy),
}
resp, err := stsService.AssumeRole(input)
creds := credentials.NewStaticCredentials(
*resp.Credentials.AccessKeyId,
*resp.Credentials.SecretAccessKey,
*resp.Credentials.SessionToken,
)
session := session.New()
s3Client := s3.New(session, aws.NewConfig().WithCredentials(creds))

// File access code looks the same, using the customer number and S3 client
key := fmt.Sprintf("%s/the-goods.zip", customerNumber)
output, err := s3Client.GetObject(&s3.GetObjectInput{
Bucket: aws.String("bucket-of-private-data"),
Key: aws.String(key),
})

// If the customer number is no longer the authenticated user, the code will fail with an AccessDenied error
customerNumberFromOutput := getCustomerNumberFromS3File(output.Body)
key := fmt.Sprintf("%s/the-goods.zip", customerNumberFromOuput)
output, err := s3Client.GetObject(&s3.GetObjectInput{
Bucket: aws.String("bucket-of-private-data"),
Key: aws.String(key),
})

One additional change you need is to give a policy to the role to assume itself:

"Statement": [
{
"Sid": "AllowReadBucketOfPrivateData",
"Effect": "Allow",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::bucket-of-private-data/*"
},
{
"Sid": "AllowSelfAssume",
"Effect": "Allow",
"Action": "sts:AssumeRole",
"Resource": "arn:aws:iam:accountnumber:role/RoleName"
}
]

We started using session policies and self-assuming roles at Clever for our SFTP service that transparently uses S3 as the file store (implemented pre-AWS SFTP). We’ve recently rolled it out more broadly in our data pipeline that uses S3 as the transient file store. Similar to unit tests or types, it works as a “double check” in your code, so that an isolated coding error doesn’t lead to a critical failure. You could apply this pattern beyond S3, and use it for any AWS resource that support IAM-based resource access control: DynamoDB, SQS, and so on.

See the Session Policy Example gist for a CloudFormation JSON template and Go file that demonstrate session policies in action.

Access control is hard to get right because it’s specific to each software system. Finding access control problems often means tediously reviewing sensitive invocations to ensure that the correct authenticated user context is preserved. By using session policies, you can enforce that AWS resource access is logically separated by customer in one place in your code.