How to set up CircleCI OIDC AWS access with Terraform (Part 1)
OpenID Connect #
The most secure way to authenticate CircleCI with AWS is by using OIDC. This removes the need to create and store long-lived, programmatic access credentials associated with an IAM User by instead configuring an OIDC identity provider within your AWS account. CircleCI can then assume an IAM Role with short-lived access credentials that will expire after a short period of time. Ideally this IAM Role should then conform to the PoLP (Principle of Least Privilege), i.e. granting the bare minimum permissions required in order to perform the task.
Terraform IaC #
The Terraform snippet below will create the OIDC Identity Provider. The circleci--organisation-id
will be in the format "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX" and is available from your CircleCI "Organisation Settings" screen.
variable "circleci--organisation-id" {
type = string
description = "CircleCI Organisation ID"
}
locals {
circleci_oidc_url = "https://oidc.circleci.com/org/${var.circleci--organisation-id}"
}
data "tls_certificate" "circleci" {
url = "${local.circleci_oidc_url}/.well-known/openid-configuration"
}
resource "aws_iam_openid_connect_provider" "circleci" {
url = local.circleci_oidc_url
client_id_list = [
var.circleci--organisation-id,
]
thumbprint_list = [
data.tls_certificate.circleci.certificates[0].sha1_fingerprint
]
}
With the provider in place, we can then create the IAM Role, which CircleCI will assume. In the example below I've included a circleci--project-id
variable, which will restrict this IAM Role even further to a single CircleCI Project (this variable matches the same format as the Organisation ID and is available from each CircleCI "Project Settings" screen). Alternatively you could opt to provide a wildcard here instead (e.g. org/${var.circleci--organisation-id}/project/*/user/*
), which would then permit any CircleCI Project within the given Organisation access to this IAM Role.
variable "circleci--project-id" {
type = string
description = "CircleCI Project ID"
}
data "aws_iam_policy_document" "circleci--assume-role" {
statement {
effect = "Allow"
actions = ["sts:AssumeRoleWithWebIdentity"]
principals {
type = "Federated"
identifiers = [aws_iam_openid_connect_provider.circleci.id]
}
condition {
test = "StringLike"
variable = "${aws_iam_openid_connect_provider.circleci.url}:sub"
values = [
"org/${var.circleci--organisation-id}/project/${var.circleci--project-id}/user/*"
]
}
}
}
resource "aws_iam_role" "circleci" {
name = "tf-circleci"
description = "Temporarily assumed by CircleCI"
assume_role_policy = data.aws_iam_policy_document.circleci--assume-role.json
}
Now that the base tf-circleci
IAM Role has been defined with the requisite Trust Policy to allow CircleCI to assume it, we can focus on which permissions to give it. In situations like this I prefer using reusable, standalone IAM Policy documents over inline policies. In this example I am giving the Role permission to update assets within an S3 bucket and invalidate a specific CloudFront Distribution.
data "aws_iam_policy_document" "circleci--deploy-website" {
statement {
actions = [
"s3:ListBucket",
]
resources = [
var.s3_bucket--arn
]
}
statement {
actions = [
"s3:DeleteObject",
"s3:GetObject",
"s3:PutObject",
]
resources = [
"${var.s3_bucket--arn}/*"
]
}
statement {
actions = [
"cloudfront:CreateInvalidation",
]
resources = [
var.cloudfront_dist--arn
]
}
}
resource "aws_iam_policy" "circleci--deploy-website" {
name = "tf-circleci--deploy-website"
policy = data.aws_iam_policy_document.circleci--deploy-website.json
}
resource "aws_iam_role_policy_attachment" "circleci--deploy-website" {
role = aws_iam_role.circleci.name
policy_arn = aws_iam_policy.circleci--deploy-website.arn
}
In Part 2 we'll cover how to setup CircleCI to assume this IAM Role using OIDC.`