Skip to content

Commit 9b0382f

Browse files
jms200claude
andauthored
feat(comet_eks): byo_s3_irsa_roles — customer bring-your-own-S3 IAM (DND-1423) (#48)
* feat(comet_eks): add byo_s3_irsa_roles for customer bring-your-own-S3 (DND-1423) Add a reusable, opt-in `byo_s3_irsa_roles` map that provisions the IAM that accompanies a customer-supplied S3 bucket: one IRSA role + a scoped customer-managed policy + attachment per map entry. The trusted Kubernetes ServiceAccounts (the IRSA :sub condition) are an explicit input, so the trust list is codified and reviewable in PRs. This generalizes the BYO-S3 access role that was previously created out-of-band during onboarding (e.g. Zoox's ZooxS3Access), whose invisible trust list let a missing ServiceAccount silently break ClickHouse remote backups for >=7 days (DND-1413). It follows the existing Loki IRSA triplet exactly and reuses the upstream iam-role-for-service-accounts-eks module to build the web-identity trust. - Permissions are scoped to the supplied bucket ARN(s) by default (not s3:::*), so the feature is least-privilege from day one. - role_name_override / policy_name_override let an existing out-of-band role or policy be adopted in place via `terraform import` (stable ARN -> IRSA annotations keep working) instead of being recreated. - Empty map default -> no effect on any existing consumer. New MINOR (new feature family): release as v1.21.0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(comet_eks): validate byo_s3_irsa_roles inputs at plan time (DND-1423 PR review) Address Baz review on PR #48. The variable only length-checked its lists, so malformed inputs passed `terraform validate` and failed at apply (or silently produced broken IAM/IRSA): - bucket_arns: require an exact `arn:aws:s3:::<bucket>` per entry — reject wildcards (`arn:aws:s3:::*`, which would reintroduce the over-broad grant this feature exists to remove) and trailing `/*`/object keys (main.tf already appends `/*`, so a trailing glob yields `<bucket>/*/*`). - namespace_service_accounts: require `<namespace>:<sa-name>` — a malformed subject silently produces a trust condition no pod can satisfy. - policy_name_override: add IAM policy-name validation (1-128 chars) for parity with the existing role_name_override check. Verified the regexes accept the intended Zoox values and reject the bad cases; `terraform validate` passes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent cd6c9f6 commit 9b0382f

4 files changed

Lines changed: 172 additions & 0 deletions

File tree

‎main.tf‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,9 @@ module "comet_eks" {
327327
loki_s3_bucket_arn = var.enable_s3 && var.enable_loki_bucket ? module.comet_s3[0].comet_loki_bucket_arn : null
328328
loki_iam_role_name_override = var.loki_iam_role_name_override
329329

330+
# DND-1423: Bring-your-own-S3 IRSA roles (customer-supplied buckets)
331+
byo_s3_irsa_roles = var.byo_s3_irsa_roles
332+
330333
# CloudWatch Exporter IRSA for scraping AWS managed service metrics
331334
enable_cloudwatch_exporter = var.enable_cloudwatch_exporter
332335
cloudwatch_exporter_iam_role_name_override = var.cloudwatch_exporter_iam_role_name_override

‎modules/comet_eks/main.tf‎

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -828,6 +828,89 @@ module "loki_irsa_role" {
828828
)
829829
}
830830

831+
################################################################
832+
#### BYO-S3 IRSA Roles (customer-supplied bucket) - DND-1423 ###
833+
################################################################
834+
# One IRSA role + scoped customer-managed policy per byo_s3_irsa_roles entry.
835+
# Generalizes the out-of-band ZooxS3Access pattern: a set of ServiceAccounts
836+
# gets scoped access to a customer's own S3 bucket. The upstream IRSA module
837+
# builds the OIDC web-identity trust (:sub/:aud) from namespace_service_accounts.
838+
locals {
839+
# Default action set matches the live ClickHouse-backup S3 usage.
840+
byo_s3_default_actions = [
841+
"s3:GetObject",
842+
"s3:PutObject",
843+
"s3:DeleteObject",
844+
"s3:ListBucket",
845+
"s3:AbortMultipartUpload",
846+
"s3:ListMultipartUploadParts",
847+
"s3:ListBucketMultipartUploads",
848+
]
849+
}
850+
851+
data "aws_iam_policy_document" "byo_s3" {
852+
for_each = var.byo_s3_irsa_roles
853+
854+
statement {
855+
effect = "Allow"
856+
actions = coalesce(each.value.actions, local.byo_s3_default_actions)
857+
# Scoped to the supplied bucket(s) - bucket ARN (for ListBucket) + objects.
858+
resources = flatten([
859+
for arn in each.value.bucket_arns : [arn, "${arn}/*"]
860+
])
861+
}
862+
}
863+
864+
resource "aws_iam_policy" "byo_s3" {
865+
for_each = var.byo_s3_irsa_roles
866+
867+
# name when adopting an existing policy in place; name_prefix otherwise.
868+
name = each.value.policy_name_override
869+
name_prefix = each.value.policy_name_override == null ? "${var.environment}-byo-s3-${each.key}-" : null
870+
description = "BYO-S3 access for ${each.key} on ${var.environment} cluster (DND-1423)"
871+
policy = data.aws_iam_policy_document.byo_s3[each.key].json
872+
873+
tags = merge(
874+
var.common_tags,
875+
{
876+
Name = coalesce(each.value.policy_name_override, "${var.environment}-byo-s3-${each.key}")
877+
}
878+
)
879+
}
880+
881+
module "byo_s3_irsa_role" {
882+
source = "terraform-aws-modules/iam/aws//modules/iam-role-for-service-accounts-eks"
883+
version = "~> 5.39"
884+
885+
for_each = var.byo_s3_irsa_roles
886+
887+
role_name = coalesce(each.value.role_name_override, "${var.environment}-byo-s3-${each.key}")
888+
889+
role_policy_arns = {
890+
byo_s3 = aws_iam_policy.byo_s3[each.key].arn
891+
}
892+
893+
oidc_providers = {
894+
ex = {
895+
provider_arn = module.eks.oidc_provider_arn
896+
namespace_service_accounts = each.value.namespace_service_accounts
897+
}
898+
}
899+
900+
depends_on = [
901+
module.eks,
902+
aws_iam_policy.byo_s3
903+
]
904+
905+
tags = merge(
906+
var.common_tags,
907+
{
908+
Name = coalesce(each.value.role_name_override, "${var.environment}-byo-s3-${each.key}")
909+
Description = "IRSA role granting listed ServiceAccounts scoped access to a customer-supplied S3 bucket"
910+
}
911+
)
912+
}
913+
831914
##################################################
832915
#### CloudWatch Exporter IRSA Role and Policy ####
833916
##################################################

‎modules/comet_eks/variables.tf‎

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -412,6 +412,77 @@ variable "loki_s3_bucket_arn" {
412412
default = null
413413
}
414414

415+
# DND-1423: Bring-your-own-S3 IRSA roles. When a customer supplies their own S3
416+
# bucket (e.g. ClickHouse remote backups), the pods that touch it need an IAM
417+
# role assumable via IRSA and a policy scoped to that bucket. Each map entry
418+
# provisions one such role + customer-managed policy + attachment. This
419+
# generalizes roles previously created out-of-band during BYO-S3 onboarding
420+
# (e.g. Zoox's ZooxS3Access) so the trusted ServiceAccounts are codified and
421+
# reviewable. The map key is a short logical name used in resource naming.
422+
variable "byo_s3_irsa_roles" {
423+
description = "Map of bring-your-own-S3 IRSA roles. Each entry grants the listed Kubernetes ServiceAccounts (via IRSA web-identity) scoped access to a customer-supplied S3 bucket. Empty by default (feature off)."
424+
type = map(object({
425+
# ARNs of the customer-supplied bucket(s). Permissions are scoped to these
426+
# (bucket + bucket/*) - do NOT pass arn:aws:s3:::* here.
427+
bucket_arns = list(string)
428+
# ServiceAccounts allowed to assume the role, as "<namespace>:<sa-name>".
429+
namespace_service_accounts = list(string)
430+
# Optional S3 action set. Null => the ClickHouse-backup default action set.
431+
actions = optional(list(string))
432+
# Optional overrides to adopt an existing out-of-band role/policy in place
433+
# (e.g. role_name_override="ZooxS3Access") via terraform import without
434+
# recreating it (recreation would change the ARN and break IRSA annotations).
435+
role_name_override = optional(string)
436+
policy_name_override = optional(string)
437+
}))
438+
default = {}
439+
440+
validation {
441+
condition = alltrue([
442+
for k, v in var.byo_s3_irsa_roles :
443+
length(v.bucket_arns) > 0 && length(v.namespace_service_accounts) > 0
444+
])
445+
error_message = "Each byo_s3_irsa_roles entry must set at least one bucket_arn and one namespace_service_account."
446+
}
447+
# Each bucket_arn must be an exact bucket ARN: arn:aws:s3:::<bucket>. No globs
448+
# (arn:aws:s3:::* would reintroduce the over-broad grant this feature removes)
449+
# and no trailing /* (main.tf appends /* itself -> would yield <bucket>/*/*).
450+
validation {
451+
condition = alltrue(flatten([
452+
for k, v in var.byo_s3_irsa_roles : [
453+
for arn in v.bucket_arns : can(regex("^arn:aws:s3:::[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$", arn))
454+
]
455+
]))
456+
error_message = "byo_s3_irsa_roles[*].bucket_arns entries must be an exact bucket ARN 'arn:aws:s3:::<bucket>' (no wildcards, no trailing /*, no object key)."
457+
}
458+
# Each entry must be "<namespace>:<sa-name>" (the format the upstream IRSA
459+
# module expands into system:serviceaccount:<ns>:<sa>). A malformed subject
460+
# silently produces a trust condition no pod can satisfy.
461+
validation {
462+
condition = alltrue(flatten([
463+
for k, v in var.byo_s3_irsa_roles : [
464+
for s in v.namespace_service_accounts : can(regex("^[a-z0-9][a-z0-9.-]*:[a-z0-9][a-z0-9.-]*$", s))
465+
]
466+
]))
467+
error_message = "byo_s3_irsa_roles[*].namespace_service_accounts entries must be '<namespace>:<sa-name>' (both non-empty, lowercase DNS-safe, exactly one colon)."
468+
}
469+
validation {
470+
condition = alltrue([
471+
for k, v in var.byo_s3_irsa_roles :
472+
v.role_name_override == null ? true : can(regex("^[a-zA-Z0-9+=,.@_-]{1,64}$", v.role_name_override))
473+
])
474+
error_message = "byo_s3_irsa_roles[*].role_name_override must match ^[a-zA-Z0-9+=,.@_-]{1,64}$."
475+
}
476+
# IAM customer-managed policy name: 1-128 chars from [a-zA-Z0-9+=,.@_-].
477+
validation {
478+
condition = alltrue([
479+
for k, v in var.byo_s3_irsa_roles :
480+
v.policy_name_override == null ? true : can(regex("^[a-zA-Z0-9+=,.@_-]{1,128}$", v.policy_name_override))
481+
])
482+
error_message = "byo_s3_irsa_roles[*].policy_name_override must match ^[a-zA-Z0-9+=,.@_-]{1,128}$."
483+
}
484+
}
485+
415486
variable "enable_monitoring_setup" {
416487
description = "Enable monitoring namespace and Grafana credentials secret"
417488
type = bool

‎variables.tf‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,21 @@ variable "loki_iam_role_name_override" {
7777
}
7878
}
7979

80+
# DND-1423: Bring-your-own-S3 IRSA roles (see modules/comet_eks/variables.tf for
81+
# the full schema). Empty by default => feature off. Set an entry per customer
82+
# BYO bucket that pods must reach via IRSA (e.g. ClickHouse remote backups).
83+
variable "byo_s3_irsa_roles" {
84+
description = "Map of bring-your-own-S3 IRSA roles. Each entry grants listed Kubernetes ServiceAccounts (via IRSA) scoped access to a customer-supplied S3 bucket. Empty by default."
85+
type = map(object({
86+
bucket_arns = list(string)
87+
namespace_service_accounts = list(string)
88+
actions = optional(list(string))
89+
role_name_override = optional(string)
90+
policy_name_override = optional(string)
91+
}))
92+
default = {}
93+
}
94+
8095
variable "external_secrets_iam_role_name_override" {
8196
description = "Override the External Secrets IRSA role name. Null keeps the computed <environment>-external-secrets name."
8297
type = string

0 commit comments

Comments
 (0)