R2 API Token Terraform Module

Now that R2 has bucket scoped tokens, I wanted to create a simple way to generate the S3 tokens that can be used with any S3 compatible service. I was playing around with terraform and created a simple module that will generate the tokens for you. The module is available from the Terraform registry here and you can view the source here. Before I break down the module, I want to go over the basics of how to use it.

Example Usage

The following example will return a token that has read and write access:

1module "r2-api-token" {
2  source  = "Cyb3r-Jak3/r2-api-token/cloudflare"
3  version = "1.0.0"
4  account_id = "your_account_id"
5  buckets = ["bucket-1", "bucket-2"]
6}

The following example will return a token that has read only access:

1module "r2-api-token" {
2  source  = "Cyb3r-Jak3/r2-api-token/cloudflare"
3  version = "3.0.0"
4  account_id = "your_account_id"
5  buckets = ["bucket-1", "bucket-2"]
6  bucket_write = false
7}

There are outputs from the module of id and secret that you can use with S3 compatible services. If you want to be able to get the raw token, you can use the following:

1  output "id" {
2    value = module.r2-api-token.id
3  }
4  output "secret" {
5    value = module.r2-api-token.secret
6  }

Module Breakdown

The main part of the module is:

 1terraform {
 2  required_version = ">= 1.2.0"
 3  required_providers {
 4    cloudflare = {
 5      source  = "cloudflare/cloudflare"
 6      version = ">= 4.13.0"
 7    }
 8  }
 9}
10
11data "cloudflare_api_token_permission_groups" "this" {}
12
13locals {
14  resources          = length(var.buckets) > 0 ? { for bucket in var.buckets : "com.cloudflare.edge.r2.bucket.${var.account_id}_default_${bucket}" => "*" } : { "com.cloudflare.edge.r2.bucket.*" = "*" }
15  token_bucket_names = length(var.buckets) > 0 ? "All-Buckets" : join(",", var.buckets)
16}
17
18resource "cloudflare_api_token" "token" {
19  name = var.token_name != "" ? var.token_name : "R2-${join(",", var.buckets)}-${var.bucket_read ? "Read" : ""}-${var.bucket_write ? "Write" : ""}"
20  policy {
21    permission_groups = compact([
22      var.bucket_read ? module.api-permissions.r2["Workers R2 Storage Bucket Item Read"] : null,
23      var.bucket_write ? module.api-permissions.r2["Workers R2 Storage Bucket Item Write"] : null,
24    ])
25    resources = { for bucket in var.buckets : "com.cloudflare.edge.r2.bucket.${var.account_id}_default_${bucket}" => "*" }
26  }
27  not_before = var.not_before != "" ? var.not_before : timestamp()
28  expires_on = var.expires_on != "" ? var.expires_on : null
29  condition {
30    request_ip {
31      in     = var.condition_ip_in
32      not_in = var.condition_ip_not_in
33    }
34  }
35}

Resource

 1resource "cloudflare_api_token" "token" {
 2  name = var.token_name != "" ? var.token_name : "R2-${local.token_bucket_names}-${var.bucket_read ? "Read" : ""}-${var.bucket_write ? "Write" : ""}"
 3  policy {
 4    permission_groups = compact([
 5      var.bucket_read ? data.cloudflare_api_token_permission_groups.this.r2["Workers R2 Storage Bucket Item Read"] : null,
 6      var.bucket_write ? data.cloudflare_api_token_permission_groups.this.r2["Workers R2 Storage Bucket Item Write"] : null,
 7    ])
 8    resources = local.resources
 9  }
10  not_before = var.not_before != "" ? var.not_before : null
11  expires_on = var.expires_on != "" ? var.expires_on : null
12  condition {
13    request_ip {
14      in     = var.condition_ip_in
15      not_in = var.condition_ip_not_in
16    }
17  }
18}

This is the main meat of the module. It will create the token with the following attributes:

  • name is either the name you pass in or it will be generated based on the buckets and permissions. So token with Read Write and a bucket of bucket-1 will be R2-bucket-1-Read-Write.
  • permissions groups lookup the ones specified for R2 and will only add the ones that are needed. So if you only want read access, it will only add the read permission. compact is used to remove any null values.
  • resources are generated based on the buckets you pass in. This is something that I had use dev tools to figure out the format of it. So if you pass in bucket-1 and bucket-2, it will generate the following resources:
    • com.cloudflare.edge.r2.bucket.${var.account_id}_default_bucket-1
    • com.cloudflare.edge.r2.bucket.${var.account_id}_default_bucket-2
  • not_before and expires_on are set to either the values you pass in or the current timestamp and null respectively.
  • condition allows you to choose the IPs that can use the token. You can either specify a list of IPs that can use the token or a list of IPs that can't use the token. If you don't specify either, then it will allow all IPs to use the token.
  • buckets is a list of buckets that you want to grant access to. If you don't specify any, then it will grant access to all buckets.

Inputs

Inputs

NameDescriptionTypeDefaultRequired
account_idCloudflare Account IDstringn/ayes
bucket_readIf true, grant read access to the bucket(s)booltrueno
bucket_writeIf true, grant write access to the bucket(s)booltrueno
bucketsList of R2 buckets to grant access to. If empty, all buckets will be granted access.list(string)[]no
condition_ip_inList of IP addresses or CIDR notation where the token may be used from. If not specified, the token will be valid for all IP addresses.list(string)[]no
condition_ip_not_inList of IP addresses or CIDR notation where the token should not be used from.list(string)[]no
expires_onThe expiration time on or after which the token MUST NOT be accepted for processing. If not specified, the token will not expire.string""no
not_beforeThe time before which the token MUST NOT be accepted for processing. If not specified, the token will be valid immediately.string""no
token_nameName of the API token.
If none given then the fomart is: R2-<comma separated names>-<Read if 'bucket-read'>-<Write if 'bucket-write'>
string""no