Create an Assumable Identity for an AWS role

Procedural tutorial outlining how to create a Chainguard identity that can be assumed by an AWS role.

Chainguard’s assumable identities are identities that can be assumed by external applications or workflows in order to perform certain tasks that would otherwise have to be done by a human.

This procedural tutorial outlines how to create an identity using Terraform, and then create an AWS role that will assume the identity to interact with Chainguard resources. This can be used to authorize requests from AWS Lambda, ECS, EKS, or any other AWS service that supports IAM roles for service accounts.

Prerequisites

To complete this guide, you will need the following.

  • terraform installed on your local machine. Terraform is an open-source Infrastructure as Code tool which this guide will use to create various cloud resources. Follow the official Terraform documentation for instructions on installing the tool.
  • chainctl — the Chainguard command line interface tool — installed on your local machine. Follow our guide on How to Install chainctl to set this up.
  • An AWS account with the AWS CLI installed and configured. The Terraform provider for AWS uses credentials configured via the AWS CLI.
  • A recent version of Go to test the identity with AWS Lambda.

Creating Terraform Files

We will be using Terraform to create an identity for an AWS role to assume. This step outlines how to create the Terraform configuration files that, together, will produce such an identity.

These files are available in Chainguard’s GitHub Repository of Platform Examples.

To help explain each configuration file’s purpose, we will go over what they do one by one. First, though, create a directory to hold the Terraform configuration and navigate into it.

mkdir ~/aws-id && cd $_

This will help make it easier to clean up your system at the end of this guide.

main.tf

The first file, which we will call main.tf, will serve as the scaffolding for our Terraform infrastructure.

The file will consist of the following content.

terraform {
  required_providers {
    aws        = { source = "hashicorp/aws" }
    chainguard = { source = "chainguard-dev/chainguard" }
    ko         = { source = "ko-build/ko" }
  }
}

This is a fairly barebones Terraform configuration file, but we will define the rest of the resources in the other two files. In main.tf, we declare and initialize the Chainguard Terraform provider.

Next we’ll create lambda.tf to define the AWS resources to run the Lambda function, and chainguard.tf to define the Chainguard resources that the Lambda function will interact with.

lambda.tf

The lambda.tf describes the AWS role that a Lambda function will run as.

data "aws_iam_policy_document" "lambda" {
  statement {
    effect  = "Allow"
    actions = ["sts:AssumeRole"]
    principals {
      type        = "Service"
      identifiers = ["lambda.amazonaws.com"]
    }
  }
}

data "aws_iam_policy" "lambda" {
  name = "AWSLambdaBasicExecutionRole"
}

resource "aws_iam_role" "lambda" {
  name                = "aws-auth"
  assume_role_policy  = data.aws_iam_policy_document.lambda.json
  managed_policy_arns = [data.aws_iam_policy.lambda.arn]
}

This describes an AWS role that a Lambda function will run as. The Lambda function will assume this role, and then use the Chainguard identity to interact with Chainguard resources.

The lambda.tf file also creates an AWS Lambda function that will assume the identity you created in the previous section. This function will then use the identity to interact with Chainguard resources.

The final section defines a AWS lambda function implemented in Go that will assume the identity you created in the previous section:

resource "aws_lambda_function" "test_lambda" {
  filename      = "lambda_function_payload.zip"
  function_name = "lambda_function_name"
  role          = aws_iam_role.iam_for_lambda.arn
  handler       = "bootstrap"

  source_code_hash = data.archive_file.lambda.output_base64sha256

  runtime = "go1.x"
}

Check out this basic example for configuring AWS Lambda using Terraform, and the docs for deploying Go Lambda functions with .zip file archives for more information on how to configure the filename and source_code_hash fields.

Below we’ll go over some of the specific details from the example Go application.

After it’s deployed, when this function is invoked, it will assume the AWS role you created in the previous section. It will then be able to present credentials as that AWS role that will allow it to assume the Chainguard identity you created in the previous section, to view and manage Chainguard resources.

Next, you can create the chainguard.tf file.

chainguard.tf

chainguard.tf will create a few resources that will help us test out the identity.

resource "chainguard_group" "example-group" {
  name   	 = "example-group"
  description = <<EOF
    This organization simulates an end-user organization, which the AWS role identity
    can interact with via the identity in aws.tf.
  EOF
}

This section creates a Chainguard IAM organization named example-group, as well as a description of the organization. This will serve as some data for the identity to access when we test it out later on.

Then we’ll define a Chainguard identity that can be assumed by the AWS role created in lambda.tf above:

resource "chainguard_identity" "aws" {
  parent_id   = data.chainguard_group.example-group.id
  name        = "aws-auth-identity"
  description = "Identity for AWS Lambda"

  aws_identity {
    aws_account         = data.aws_caller_identity.current.account_id
    aws_user_id_pattern = "^AROA(.*):${local.lambda_name}$"

    // NB: This role will be assumed so can't use the role ARN directly. We must use the ARN of the assumed role
    aws_arn = "arn:aws:sts::${data.aws_caller_identity.current.account_id}:assumed-role/${aws_iam_role.lambda.name}/${local.lambda_name}"
  }
}

The most important part of this section is the aws_identity block. When the AWS role tries to assume this identity later on, it must present a token matching the aws_account, aws_user_id_pattern, and aws_arn specified here in order to do so.

The aws_user_id_pattern field configures the identity to be assumable only by the AWS role with the specified name, which is itself assumed by another execution role, which we’ll configure below. This role will be assumed so can’t use the role ARN directly in aws_arn; We must used the ARN of the assumed role.

The section after that looks up the viewer role.

data "chainguard_role" "viewer" {
  name = "viewer"
}

The final section grants this role to the identity on the example-group.

resource "chainguard_rolebinding" "view-stuff" {
  identity = chainguard_identity.aws.id
  group	= data.chainguard_group.example-group.id
  role 	= data.chainguard_role.viewer.items[0].id
}

After defining these resources, there are some other resources in the example directory that build and deploy a Lambda function that assumes the identity. We’ll describe that code in the next section.

After defining these resources, your Terraform configuration will be ready. Now you can run a few terraform commands to create the resources defined in your .tf files.

Creating Your Resources

First, run terraform init to initialize Terraform’s working directory.

terraform init

Then run terraform plan. This will produce a speculative execution plan that outlines what steps Terraform will take to create the resources defined in the files you set up in the last section.

terraform plan

Then apply the configuration.

terraform apply

Before going through with applying the Terraform configuration, this command will prompt you to confirm that you want it to do so. Enter yes to apply the configuration.

...

Plan: 8 to add, 0 to change, 0 to destroy.

Changes to Outputs:
  + aws-identity = (known after apply)

Do you want to perform these actions?
  Terraform will perform the actions described above.
  Only 'yes' will be accepted to approve.

  Enter a value:

After typing yes and pressing ENTER, the command will complete and will output an aws-identity value.

...

Apply complete! Resources: 8 added, 0 changed, 0 destroyed.

Outputs:

aws-identity = "<your identity>"

This is the identity’s UIDP (unique identity path), which you configured the chainguard.tf file to emit in the previous section. Note this value down, as you’ll need it to set up the AWS role you’ll use to test the identity. If you need to retrieve this UIDP later on, though, you can always run the following chainctl command to obtain a list of the UIDPs of all your existing identities.

chainctl iam identities ls

Testing the Identity

When the AWS Lambda function is invoked, first it needs to get its credentials, which assert that it is the AWS IAM role defined earlier.

The example code in Go does this with aws-sdk-go-v2:

// Get AWS credentials.
cfg, err := config.LoadDefaultConfig(ctx)
if err != nil {
  return "", fmt.Errorf("failed to load configuration, %w", err)
}
creds, err := cfg.Credentials.Retrieve(ctx)
if err != nil {
  return "", fmt.Errorf("failed to retrieve credentials, %w", err)
}

These credentials represent the AWS role assumed by the Lambda function ("aws-auth" defined above in lambda.tf).

You can use the Chainguard SDK for Go to generate a token that Chainguard understands, to authenticate the Lambda function as the Chainguard identity you created earlier, by its UIDP:

// Generate a token and exchange it for a Chainguard token.
awsTok, err := aws.GenerateToken(ctx, creds, env.Issuer, env.Identity)
if err != nil {
  return "", fmt.Errorf("generating AWS token: %w", err)
}
exch := sts.New(env.Issuer, env.APIEndpoint, sts.WithIdentity(env.Identity))
cgtok, err := exch.Exchange(ctx, awsTok)
if err != nil {
  return "", fmt.Errorf("exchanging token: %w", err)
}

The resulting token, cgtok, can be used to authenticate requests to Chainguard API calls:

// Use the token to list repos in the organization.
clients, err := registry.NewClients(ctx, env.APIEndpoint, cgtok)
if err != nil {
  return "", fmt.Errorf("creating clients: %w", err)
}
ls, err := clients.Registry().ListRepos(ctx, &registry.RepoFilter{
  Uidp: &common.UIDPFilter{
    ChildrenOf: env.Group,
  },
})

Removing Sample Resources

To remove the resources Terraform created, you can run the terraform destroy command.

terraform destroy

This will destroy the role-binding, and the identity created in this guide. However, you’ll need to destroy the example-group organization yourself with chainctl. It will also delete all the AWS resources defined earlier in chainguard.tf and lambda.tf.

chainctl iam organizations rm example-group

You can then remove the working directory to clean up your system.

rm -r ~/aws-id/

Following that, all of the example resources created in this guide will be removed from your system.

Learn more

For more information about how assumable identities work in Chainguard, check out our conceptual overview of assumable identities. Additionally, the Terraform documentation includes a section on recommended best practices which you can refer to if you’d like to build on this Terraform configuration for a production environment.

Last updated: 2024-05-09 08:48