From 6293e469a6d2aa6886850ac8c179505517957956 Mon Sep 17 00:00:00 2001 From: Sean Doughty Date: Fri, 15 May 2026 10:20:43 -0400 Subject: [PATCH 1/4] add oidc support for AWS access --- README.md | 65 +++++++++++-------- go.mod | 4 +- pkg/env/env.go | 16 ++++- terraform/main.tf | 23 ++++--- terraform/modules/aws/iam.tf | 50 +++++++++++--- terraform/modules/aws/s3.tf | 2 +- terraform/modules/aws/variables.tf | 16 ++++- terraform/modules/render-audit-logs/render.tf | 18 ++++- .../modules/render-audit-logs/variables.tf | 8 +-- terraform/variables.tf | 14 +++- 10 files changed, 157 insertions(+), 59 deletions(-) diff --git a/README.md b/README.md index ceaab2a..a3462a0 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,13 @@ Supports both workspace-level and organization-level (Enterprise) audit logs. - An Owner of the Oranization (Enterprise Plan) - Render Owner ID (`tea-xxx`) — workspace where the Cron Job will be deployed - [Terraform](https://www.terraform.io/downloads) >= 1.0 -- AWS account with permissions to create S3 buckets and IAM users +- AWS account with permissions to create S3 buckets and IAM roles + +## AWS Authentication + +The Cron Job authenticates to AWS via [Render OIDC](https://render.com/docs/oidc) (currently in alpha): it exchanges a short-lived token for AWS credentials by assuming an IAM role. No long-lived secrets are stored. Render publishes a per-workspace OIDC issuer at `https://oidc.render.com/`. + +The Go application also supports long-lived `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` credentials as a fallback (if `AWS_ROLE_ARN` is unset. ## Quick Start @@ -33,7 +39,7 @@ cd render-auditlogs/terraform ### 2. Configure authentication -Set up authentication for both providers: +Set up authentication for both providers for the Terraform providers: ```bash # AWS - use one of these methods: @@ -57,35 +63,37 @@ terraform apply \ -var='render_workspace_ids=["tea-xxxxx", "tea-yyyyy"]' ``` -For Enterprise customers with organization-level audit logs: +This creates an IAM OIDC provider for `https://oidc.render.com/` (if one does not already exist) and an IAM role the Cron Job assumes at runtime. + +If you already have the OIDC provider registered in AWS add: ```bash -terraform apply \ - -var="aws_s3_bucket_name=your-audit-logs-bucket" \ - -var="render_api_key=${RENDER_API_KEY}" \ - -var="render_organization_id=org-xxxxx" \ - -var='render_workspace_ids=["tea-xxxxx", "tea-yyyyy"]' + -var="aws_oidc_provider_arn=arn:aws:iam::123456789012:oidc-provider/oidc.render.com/tea-xxxxx" ``` +For Enterprise customers with organization-level audit logs, add `-var="render_organization_id=org-xxxxx"`. + ## Terraform Variables -| Variable | Required | Default | Description | -| --------------------------- | -------- | ---------------------------- | ------------------------------------------------------ | -| `aws_s3_bucket_name` | Yes | - | Name of the S3 bucket to create for storing audit logs | -| `render_api_key` | Yes | - | Render API key for accessing audit logs | -| `render_workspace_ids` | No | `[]` | List of workspace IDs to fetch audit logs from | -| `render_organization_id` | No | `""` | Organization ID for Enterprise audit logs | -| `aws_iam_user_name` | No | `render-audit-log-processor` | Name of the IAM user created for S3 access | -| `aws_s3_bucket_key_enabled` | No | `false` | Enable S3 bucket key to reduce KMS calls | -| `aws_s3_kms_key_id` | No | `""` | ARN for KMS key to use for encryption | -| `aws_s3_use_kms` | No | `false` | Use KMS for encryption (instead of SSE-S3) | -| `render_cronjob_name` | No | `render-auditlogs` | Name of the Render Cron Job | -| `render_cronjob_schedule` | No | `1/15 * * * *` | Cron schedule (default: every 15 minutes) | -| `render_cronjob_plan` | No | `starter` | Render plan for the Cron Job | -| `render_cronjob_region` | No | `oregon` | Region to deploy the Cron Job | -| `render_project_name` | No | `audit-logs` | Name of the Render project | - -*Note*: If you use a KMS key, confirm that the AWS IAM User is setup with the User Permissions for the key. +| Variable | Required | Default | Description | +| --------------------------- | -------- | ---------------------------- | -------------------------------------------------------------------------------------------------- | +| `aws_s3_bucket_name` | Yes | - | Name of the S3 bucket to create for storing audit logs | +| `render_api_key` | Yes | - | Render API key for accessing audit logs | +| `render_deployment_workspace_id` | Yes | - | Render workspace ID (`tea-xxx`) where the Cron Job is deployed; used to build the OIDC issuer URL `oidc.render.com/` | +| `render_workspace_ids` | No | `[]` | List of workspace IDs to fetch audit logs from | +| `render_organization_id` | No | `""` | Organization ID for Enterprise audit logs | +| `aws_oidc_provider_arn` | No | `""` | ARN of an existing AWS IAM OIDC provider; if empty, one is created | +| `aws_iam_role_name` | No | `render-audit-log-processor` | Name of the IAM role the Cron Job assumes | +| `aws_s3_bucket_key_enabled` | No | `false` | Enable S3 bucket key to reduce KMS calls | +| `aws_s3_kms_key_id` | No | `""` | ARN for KMS key to use for encryption | +| `aws_s3_use_kms` | No | `false` | Use KMS for encryption (instead of SSE-S3) | +| `render_cronjob_name` | No | `render-auditlogs` | Name of the Render Cron Job | +| `render_cronjob_schedule` | No | `1/15 * * * *` | Cron schedule (default: every 15 minutes) | +| `render_cronjob_plan` | No | `starter` | Render plan for the Cron Job | +| `render_cronjob_region` | No | `oregon` | Region to deploy the Cron Job | +| `render_project_name` | No | `audit-logs` | Name of the Render project | + +*Note*: If you use a KMS key, confirm that the IAM role is set up with User Permissions for the key. Example: ``` @@ -97,7 +105,7 @@ Example: "Sid": "Allow use of the key", "Effect": "Allow", "Principal": { - "AWS": "arn:aws:iam::12345:user/render-audit-log-processor" + "AWS": "arn:aws:iam::123456789012:role/render-audit-log-processor" }, "Action": [ "kms:Encrypt", @@ -119,7 +127,8 @@ The Terraform configuration creates: **AWS Resources:** - S3 bucket (versioned, encrypted, public access blocked) -- IAM user with S3 write permissions +- IAM role with S3 write permissions and an OIDC trust policy scoped to this Cron Job's service ID +- IAM OIDC provider for `oidc.render.com/` (skipped when `aws_oidc_provider_arn` is set) **Render Resources:** @@ -147,6 +156,8 @@ S3_KMS_KEY_ID=arn:aws:kms:us-west-2:123456789012:key/your-key-id # Optional S3_BUCKET_KEY_ENABLED=true # Optional ``` +When `AWS_ROLE_ARN` is set, the application assumes that role via web-identity federation. When it is empty, the AWS SDK's default credential chain picks up `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` (or your local AWS profile). + 2. Run the application: ```bash diff --git a/go.mod b/go.mod index 7630103..f9cda43 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,9 @@ go 1.25.0 require ( github.com/aws/aws-sdk-go-v2 v1.39.6 github.com/aws/aws-sdk-go-v2/config v1.31.20 + github.com/aws/aws-sdk-go-v2/credentials v1.18.24 github.com/aws/aws-sdk-go-v2/service/s3 v1.90.2 + github.com/aws/aws-sdk-go-v2/service/sts v1.40.2 github.com/google/uuid v1.6.0 github.com/joho/godotenv v1.5.1 github.com/kelseyhightower/envconfig v1.4.0 @@ -14,7 +16,6 @@ require ( require ( github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.3 // indirect - github.com/aws/aws-sdk-go-v2/credentials v1.18.24 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.13 // indirect github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.13 // indirect github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.13 // indirect @@ -26,7 +27,6 @@ require ( github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.13 // indirect github.com/aws/aws-sdk-go-v2/service/sso v1.30.3 // indirect github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.7 // indirect - github.com/aws/aws-sdk-go-v2/service/sts v1.40.2 // indirect github.com/aws/smithy-go v1.23.2 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect diff --git a/pkg/env/env.go b/pkg/env/env.go index c613a45..72a997f 100644 --- a/pkg/env/env.go +++ b/pkg/env/env.go @@ -6,6 +6,8 @@ import ( "github.com/aws/aws-sdk-go-v2/aws" awsconfig "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/credentials/stscreds" + "github.com/aws/aws-sdk-go-v2/service/sts" "github.com/joho/godotenv" "github.com/kelseyhightower/envconfig" @@ -20,10 +22,12 @@ type Config struct { S3KMSKeyID string `required:"false" split_words:"true"` S3UseKMS bool `required:"false" split_words:"true"` RenderAPIKey string `required:"true" split_words:"true"` - AWSAccessKeyID string `required:"true" split_words:"true"` - AWSSecretAccessKey string `required:"true" split_words:"true"` AWSRegion string `required:"true" split_words:"true"` + // If AWSRoleARN is empty, the AWS SDK falls back to access keys from the environment + AWSRoleARN string `required:"false" split_words:"true"` + AWSWebIdentityTokenFile string `required:"false" split_words:"true" default:"/var/lib/render/oidc/aws.jwt"` + AWSConfig aws.Config } @@ -45,6 +49,14 @@ func LoadConfig(ctx context.Context, config *Config) error { return err } + if config.AWSRoleARN != "" { + awscfg.Credentials = aws.NewCredentialsCache(stscreds.NewWebIdentityRoleProvider( + sts.NewFromConfig(awscfg), + config.AWSRoleARN, + stscreds.IdentityTokenFile(config.AWSWebIdentityTokenFile), + )) + } + config.AWSConfig = awscfg return nil diff --git a/terraform/main.tf b/terraform/main.tf index 930dccd..644c427 100644 --- a/terraform/main.tf +++ b/terraform/main.tf @@ -1,17 +1,11 @@ -module "aws" { - source = "./modules/aws" - - aws_s3_bucket_name = var.aws_s3_bucket_name - aws_iam_user_name = var.aws_iam_user_name - aws_s3_use_kms = var.aws_s3_use_kms -} +data "aws_caller_identity" "current" {} module "render" { source = "./modules/render-audit-logs" - aws_access_key = module.aws.aws_access_key - aws_secret_access_key = module.aws.aws_secret_access_key + aws_account_id = data.aws_caller_identity.current.account_id + aws_iam_role_name = var.aws_iam_role_name aws_s3_bucket_name = var.aws_s3_bucket_name aws_s3_bucket_key_enabled = var.aws_s3_bucket_key_enabled aws_s3_kms_key_id = var.aws_s3_kms_key_id @@ -26,3 +20,14 @@ module "render" { render_cronjob_plan = var.render_cronjob_plan render_cronjob_schedule = var.render_cronjob_schedule } + +module "aws" { + source = "./modules/aws" + + aws_s3_bucket_name = var.aws_s3_bucket_name + aws_iam_role_name = var.aws_iam_role_name + aws_s3_use_kms = var.aws_s3_use_kms + render_deployment_workspace_id = var.render_deployment_workspace_id + aws_oidc_provider_arn = var.aws_oidc_provider_arn + render_cron_job_service_id = module.render.cron_job_service_id +} diff --git a/terraform/modules/aws/iam.tf b/terraform/modules/aws/iam.tf index b918af7..7a83af1 100644 --- a/terraform/modules/aws/iam.tf +++ b/terraform/modules/aws/iam.tf @@ -1,18 +1,48 @@ -resource "aws_iam_user" "log_processor" { - name = var.aws_iam_user_name +locals { + issuer_host = "oidc.render.com" + issuer_url = "https://${local.issuer_host}/${var.render_deployment_workspace_id}" + create_oidc_provider = var.aws_oidc_provider_arn == "" + oidc_provider_arn = var.aws_oidc_provider_arn != "" ? var.aws_oidc_provider_arn : aws_iam_openid_connect_provider.render[0].arn } -resource "aws_iam_access_key" "log_processor" { - user = aws_iam_user.log_processor.name +resource "aws_iam_openid_connect_provider" "render" { + count = local.create_oidc_provider ? 1 : 0 + + url = local.issuer_url + client_id_list = ["sts.amazonaws.com"] + thumbprint_list = [] +} + +data "aws_iam_policy_document" "assume_role_with_oidc" { + statement { + effect = "Allow" + actions = ["sts:AssumeRoleWithWebIdentity"] + + principals { + type = "Federated" + identifiers = [local.oidc_provider_arn] + } + + condition { + test = "StringEquals" + variable = "${local.issuer_host}/${var.render_deployment_workspace_id}:aud" + values = ["sts.amazonaws.com"] + } + + condition { + test = "StringLike" + variable = "${local.issuer_host}/${var.render_deployment_workspace_id}:sub" + values = ["workspace:${var.render_deployment_workspace_id}:env:*:service:${var.render_cron_job_service_id}"] + } + } } -output "aws_access_key" { - value = aws_iam_access_key.log_processor.id - sensitive = true +resource "aws_iam_role" "log_processor" { + name = var.aws_iam_role_name + assume_role_policy = data.aws_iam_policy_document.assume_role_with_oidc.json } -output "aws_secret_access_key" { - value = aws_iam_access_key.log_processor.secret - sensitive = true +output "role_arn" { + value = aws_iam_role.log_processor.arn } diff --git a/terraform/modules/aws/s3.tf b/terraform/modules/aws/s3.tf index daa9805..a8356b7 100644 --- a/terraform/modules/aws/s3.tf +++ b/terraform/modules/aws/s3.tf @@ -31,7 +31,7 @@ resource "aws_s3_bucket_policy" "render_audit_logs" { Sid = "AllowAuditLogUpload", Effect = "Allow", Principal = { - AWS = "arn:aws:iam::${data.aws_caller_identity.current.account_id}:user/render-audit-log-processor" + AWS = aws_iam_role.log_processor.arn }, Action = [ "s3:ListBucket", diff --git a/terraform/modules/aws/variables.tf b/terraform/modules/aws/variables.tf index 7c3bc48..c58e5c9 100644 --- a/terraform/modules/aws/variables.tf +++ b/terraform/modules/aws/variables.tf @@ -3,7 +3,7 @@ variable "aws_s3_bucket_name" { default = "" } -variable "aws_iam_user_name" { +variable "aws_iam_role_name" { type = string default = "render-audit-log-processor" } @@ -12,3 +12,17 @@ variable "aws_s3_use_kms" { type = bool default = false } + +variable "render_deployment_workspace_id" { + type = string +} + +variable "aws_oidc_provider_arn" { + type = string + default = "" +} + +variable "render_cron_job_service_id" { + type = string + description = "Render Cron Job service ID (crn-xxx); pins the OIDC trust policy sub claim." +} diff --git a/terraform/modules/render-audit-logs/render.tf b/terraform/modules/render-audit-logs/render.tf index 3bd63e1..d10b880 100644 --- a/terraform/modules/render-audit-logs/render.tf +++ b/terraform/modules/render-audit-logs/render.tf @@ -1,4 +1,11 @@ +locals { + # Computed from inputs (no reference to aws_iam_role) so the cron job does not + # depend on the role resource; the role's trust policy can then reference this + # cron job's service ID without creating a cycle. + computed_aws_role_arn = "arn:aws:iam::${var.aws_account_id}:role/${var.aws_iam_role_name}" +} + resource "render_cron_job" "render-audit-logs" { name = var.render_cronjob_name plan = var.render_cronjob_plan @@ -20,8 +27,11 @@ resource "render_cron_job" "render-audit-logs" { env_vars = { "LOCAL" = { value = "false" }, - "AWS_ACCESS_KEY_ID" = {value = var.aws_access_key}, - "AWS_SECRET_ACCESS_KEY" = {value = var.aws_secret_access_key} + "AWS_ROLE_ARN" = { value = local.computed_aws_role_arn } + # To use an IAM user access key instead of OIDC, create the user manually, + # set these in the Render dashboard, and clear AWS_ROLE_ARN: + # "AWS_ACCESS_KEY_ID" = { value = "..." } + # "AWS_SECRET_ACCESS_KEY" = { value = "..." } "AWS_REGION" = {value = var.aws_region} "ORGANIZATION_ID" = {value = var.render_organization_id} "WORKSPACE_IDS" = { value = join(",", var.render_workspace_ids) } @@ -42,3 +52,7 @@ resource "render_project" "audit-logs" { }, } } + +output "cron_job_service_id" { + value = render_cron_job.render-audit-logs.id +} diff --git a/terraform/modules/render-audit-logs/variables.tf b/terraform/modules/render-audit-logs/variables.tf index fc63e4a..af3e237 100644 --- a/terraform/modules/render-audit-logs/variables.tf +++ b/terraform/modules/render-audit-logs/variables.tf @@ -17,14 +17,14 @@ variable "aws_s3_use_kms" { default = false } -variable "aws_access_key" { +variable "aws_account_id" { type = string - sensitive = true + description = "AWS account ID; used to construct the IAM role ARN the cron job assumes via OIDC" } -variable "aws_secret_access_key" { +variable "aws_iam_role_name" { type = string - sensitive = true + default = "render-audit-log-processor" } variable "aws_region" { diff --git a/terraform/variables.tf b/terraform/variables.tf index 25bdcdd..f637081 100644 --- a/terraform/variables.tf +++ b/terraform/variables.tf @@ -17,9 +17,21 @@ variable "aws_s3_use_kms" { default = false } -variable "aws_iam_user_name" { +variable "aws_iam_role_name" { type = string default = "render-audit-log-processor" + description = "Name of the IAM role the Cron Job assumes via OIDC" +} + +variable "render_deployment_workspace_id" { + type = string + description = "Render workspace ID (tea-xxx) where the Cron Job is deployed; used to build the OIDC issuer URL (oidc.render.com/)" +} + +variable "aws_oidc_provider_arn" { + type = string + default = "" + description = "ARN of an existing AWS IAM OIDC provider for Render. If empty, one is created." } variable "render_api_key" { From 434a9ff0064b6a44fd4a044aee0766fe57abb267 Mon Sep 17 00:00:00 2001 From: Sean Doughty Date: Fri, 15 May 2026 10:36:58 -0400 Subject: [PATCH 2/4] fixup --- terraform/modules/aws/iam.tf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/terraform/modules/aws/iam.tf b/terraform/modules/aws/iam.tf index 7a83af1..350753c 100644 --- a/terraform/modules/aws/iam.tf +++ b/terraform/modules/aws/iam.tf @@ -33,7 +33,7 @@ data "aws_iam_policy_document" "assume_role_with_oidc" { condition { test = "StringLike" variable = "${local.issuer_host}/${var.render_deployment_workspace_id}:sub" - values = ["workspace:${var.render_deployment_workspace_id}:env:*:service:${var.render_cron_job_service_id}"] + values = ["workspace:${var.render_deployment_workspace_id}:environment:*:service:${var.render_cron_job_service_id}"] } } } From 737daa8a02730da1dfe881a95973c057dbb4e9eb Mon Sep 17 00:00:00 2001 From: Sean Doughty Date: Fri, 15 May 2026 11:07:44 -0400 Subject: [PATCH 3/4] tidy ups --- terraform/modules/render-audit-logs/render.tf | 3 --- terraform/modules/render-audit-logs/variables.tf | 1 - 2 files changed, 4 deletions(-) diff --git a/terraform/modules/render-audit-logs/render.tf b/terraform/modules/render-audit-logs/render.tf index d10b880..0f6eefb 100644 --- a/terraform/modules/render-audit-logs/render.tf +++ b/terraform/modules/render-audit-logs/render.tf @@ -1,8 +1,5 @@ locals { - # Computed from inputs (no reference to aws_iam_role) so the cron job does not - # depend on the role resource; the role's trust policy can then reference this - # cron job's service ID without creating a cycle. computed_aws_role_arn = "arn:aws:iam::${var.aws_account_id}:role/${var.aws_iam_role_name}" } diff --git a/terraform/modules/render-audit-logs/variables.tf b/terraform/modules/render-audit-logs/variables.tf index af3e237..62bfdaf 100644 --- a/terraform/modules/render-audit-logs/variables.tf +++ b/terraform/modules/render-audit-logs/variables.tf @@ -19,7 +19,6 @@ variable "aws_s3_use_kms" { variable "aws_account_id" { type = string - description = "AWS account ID; used to construct the IAM role ARN the cron job assumes via OIDC" } variable "aws_iam_role_name" { From 4d0e73b7b3d6716939bfc0b051d735400921685f Mon Sep 17 00:00:00 2001 From: Sean Doughty Date: Fri, 15 May 2026 14:47:09 -0400 Subject: [PATCH 4/4] pr feedback --- pkg/env/env.go | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/pkg/env/env.go b/pkg/env/env.go index 72a997f..d447171 100644 --- a/pkg/env/env.go +++ b/pkg/env/env.go @@ -24,9 +24,11 @@ type Config struct { RenderAPIKey string `required:"true" split_words:"true"` AWSRegion string `required:"true" split_words:"true"` - // If AWSRoleARN is empty, the AWS SDK falls back to access keys from the environment - AWSRoleARN string `required:"false" split_words:"true"` - AWSWebIdentityTokenFile string `required:"false" split_words:"true" default:"/var/lib/render/oidc/aws.jwt"` + // If AWSWebIdentityTokenFile is set, OIDC is used. Otherwise the AWS SDK's + // default credential chain falls back to AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY. + AWSRoleARN string `required:"false" split_words:"true"` + // variable is auto-set by Render when OIDC is enabled. + AWSWebIdentityTokenFile string `required:"false" split_words:"true"` AWSConfig aws.Config } @@ -49,7 +51,8 @@ func LoadConfig(ctx context.Context, config *Config) error { return err } - if config.AWSRoleARN != "" { + if config.AWSWebIdentityTokenFile != "" { + logger.FromContext(ctx).Info("Using OIDC authentication") awscfg.Credentials = aws.NewCredentialsCache(stscreds.NewWebIdentityRoleProvider( sts.NewFromConfig(awscfg), config.AWSRoleARN,