From f9da18215c16f65bc90b472fa4df661765d2b1ef Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Mar 2026 17:52:23 +0000 Subject: [PATCH 1/4] feat: tailscale-operator WIF(beta) - S3 OIDC, WIF credential, kubeadm config --- .../kubernetes_components/defaults/main.yml | 5 + .../templates/kubeadm-config.yaml.j2 | 4 + terraform/tailscale/lolice/acl.tf | 1 + terraform/tailscale/lolice/oauth.tf | 28 ++++++ terraform/tailscale/lolice/oidc.tf | 99 +++++++++++++++++++ terraform/tailscale/lolice/outputs.tf | 15 +++ terraform/tailscale/lolice/variables.tf | 13 +++ terraform/tailscale/lolice/wif.tf | 23 +++++ 8 files changed, 188 insertions(+) create mode 100644 terraform/tailscale/lolice/oidc.tf diff --git a/ansible/roles/kubernetes_components/defaults/main.yml b/ansible/roles/kubernetes_components/defaults/main.yml index 13455888e1..05454bdb48 100644 --- a/ansible/roles/kubernetes_components/defaults/main.yml +++ b/ansible/roles/kubernetes_components/defaults/main.yml @@ -58,3 +58,8 @@ sysctl_config: # Increased from default 100m CPU / 100Mi memory to accommodate ARM processor characteristics etcd_resource_requests_cpu: "200m" etcd_resource_requests_memory: "200Mi" + +# OIDC issuer URL for Workload Identity Federation (Tailscale WIF). +# Set this to the S3-hosted OIDC discovery URL to enable projected SA tokens. +# Leave empty to skip (default kubeadm SA issuer is used). +k8s_oidc_issuer_url: "" diff --git a/ansible/roles/kubernetes_components/templates/kubeadm-config.yaml.j2 b/ansible/roles/kubernetes_components/templates/kubeadm-config.yaml.j2 index 6b546d83f4..37f093c7f5 100644 --- a/ansible/roles/kubernetes_components/templates/kubeadm-config.yaml.j2 +++ b/ansible/roles/kubernetes_components/templates/kubeadm-config.yaml.j2 @@ -32,6 +32,10 @@ apiServer: {% if cluster_vip is defined and cluster_vip != "" %} - "{{ cluster_vip }}" {% endif %} +{% if k8s_oidc_issuer_url is defined and k8s_oidc_issuer_url != "" %} + extraArgs: + service-account-issuer: "{{ k8s_oidc_issuer_url }}" +{% endif %} --- apiVersion: kubeadm.k8s.io/v1beta4 kind: InitConfiguration diff --git a/terraform/tailscale/lolice/acl.tf b/terraform/tailscale/lolice/acl.tf index 6455c95d76..363932b04e 100644 --- a/terraform/tailscale/lolice/acl.tf +++ b/terraform/tailscale/lolice/acl.tf @@ -6,6 +6,7 @@ resource "tailscale_acl" "this" { "tag:ci" = ["autogroup:admin"] "tag:subnet-router" = ["autogroup:admin"] "tag:k8s-operator" = ["autogroup:admin"] + "tag:k8s" = ["tag:k8s-operator"] } acls = var.argocd_service_cluster_ip != "" ? [ diff --git a/terraform/tailscale/lolice/oauth.tf b/terraform/tailscale/lolice/oauth.tf index 4a943e1f40..b784b986cf 100644 --- a/terraform/tailscale/lolice/oauth.tf +++ b/terraform/tailscale/lolice/oauth.tf @@ -45,3 +45,31 @@ resource "aws_ssm_parameter" "operator_oauth_client_secret" { ignore_changes = [value] } } + +# ── Workload Identity Federation credentials ──────────────────────── +# These are NOT secrets (public identifiers), but stored in SSM for +# consistent management and to allow lolice ExternalSecret to pull them. + +resource "aws_ssm_parameter" "operator_wif_client_id" { + name = "/lolice/tailscale/operator-wif-client-id" + description = "Tailscale WIF client ID for the Kubernetes Operator (not a secret)" + type = "String" + value = tailscale_federated_identity.k8s_operator.id + + tags = { + Project = "lolice" + Purpose = "tailscale-k8s-operator-wif" + } +} + +resource "aws_ssm_parameter" "operator_wif_audience" { + name = "/lolice/tailscale/operator-wif-audience" + description = "Tailscale WIF audience for the Kubernetes Operator (not a secret)" + type = "String" + value = tailscale_federated_identity.k8s_operator.audience + + tags = { + Project = "lolice" + Purpose = "tailscale-k8s-operator-wif" + } +} diff --git a/terraform/tailscale/lolice/oidc.tf b/terraform/tailscale/lolice/oidc.tf new file mode 100644 index 0000000000..06e8174cf5 --- /dev/null +++ b/terraform/tailscale/lolice/oidc.tf @@ -0,0 +1,99 @@ +# S3-hosted OIDC Discovery for the lolice kubeadm cluster. +# +# Tailscale Workload Identity Federation requires the OIDC issuer to be +# publicly accessible. Since the lolice API server lives on a private +# network, we publish the two required discovery documents in an S3 +# bucket with public-read access. +# +# After the first apply, upload the real JWKS by extracting the SA +# signing public key from a control-plane node: +# +# kubectl get --raw /openid/v1/jwks > /tmp/jwks.json +# aws s3 cp /tmp/jwks.json s3://lolice-k8s-oidc/openid/v1/jwks \ +# --content-type application/json +# +# Then update the variable k8s_sa_jwks_json with the real content so +# that future applies do not overwrite it. + +# ── S3 bucket ──────────────────────────────────────────────────────── + +resource "aws_s3_bucket" "k8s_oidc" { + bucket = "lolice-k8s-oidc" +} + +resource "aws_s3_bucket_public_access_block" "k8s_oidc" { + bucket = aws_s3_bucket.k8s_oidc.id + + # Allow public read via bucket policy (required for OIDC discovery). + block_public_acls = true + block_public_policy = false + ignore_public_acls = true + restrict_public_buckets = false +} + +resource "aws_s3_bucket_policy" "k8s_oidc_public_read" { + bucket = aws_s3_bucket.k8s_oidc.id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Sid = "AllowPublicReadOIDC" + Effect = "Allow" + Principal = "*" + Action = "s3:GetObject" + Resource = [ + "${aws_s3_bucket.k8s_oidc.arn}/.well-known/openid-configuration", + "${aws_s3_bucket.k8s_oidc.arn}/openid/v1/jwks", + ] + }, + ] + }) + + depends_on = [aws_s3_bucket_public_access_block.k8s_oidc] +} + +resource "aws_s3_bucket_server_side_encryption_configuration" "k8s_oidc" { + bucket = aws_s3_bucket.k8s_oidc.id + + rule { + apply_server_side_encryption_by_default { + sse_algorithm = "AES256" + } + } +} + +# ── OIDC Discovery document ───────────────────────────────────────── + +locals { + oidc_issuer_url = "https://${aws_s3_bucket.k8s_oidc.bucket_regional_domain_name}" +} + +resource "aws_s3_object" "oidc_discovery" { + bucket = aws_s3_bucket.k8s_oidc.id + key = ".well-known/openid-configuration" + content_type = "application/json" + + content = jsonencode({ + issuer = local.oidc_issuer_url + jwks_uri = "${local.oidc_issuer_url}/openid/v1/jwks" + response_types_supported = ["id_token"] + subject_types_supported = ["public"] + id_token_signing_alg_values_supported = ["RS256"] + }) +} + +# ── JWKS document ──────────────────────────────────────────────────── +# The initial content is a placeholder. Replace with the real JWKS +# extracted from the cluster (see header comment). + +resource "aws_s3_object" "oidc_jwks" { + bucket = aws_s3_bucket.k8s_oidc.id + key = "openid/v1/jwks" + content_type = "application/json" + content = var.k8s_sa_jwks_json + + lifecycle { + ignore_changes = [content] + } +} diff --git a/terraform/tailscale/lolice/outputs.tf b/terraform/tailscale/lolice/outputs.tf index 95d4d238f8..309f96d5b8 100644 --- a/terraform/tailscale/lolice/outputs.tf +++ b/terraform/tailscale/lolice/outputs.tf @@ -7,3 +7,18 @@ output "subnet_router_auth_key_id" { description = "The ID of the auth key for the subnet router." value = tailscale_tailnet_key.subnet_router.id } + +output "k8s_operator_wif_client_id" { + description = "The client ID of the WIF federated identity for the K8s Operator." + value = tailscale_federated_identity.k8s_operator.id +} + +output "k8s_operator_wif_audience" { + description = "The audience value for the K8s Operator WIF credential." + value = tailscale_federated_identity.k8s_operator.audience +} + +output "k8s_oidc_issuer_url" { + description = "The public OIDC issuer URL (S3-hosted) for the lolice cluster." + value = local.oidc_issuer_url +} diff --git a/terraform/tailscale/lolice/variables.tf b/terraform/tailscale/lolice/variables.tf index 319ab8c2e9..c4919f8dfe 100644 --- a/terraform/tailscale/lolice/variables.tf +++ b/terraform/tailscale/lolice/variables.tf @@ -15,3 +15,16 @@ variable "argocd_diff_workflow_name" { type = string default = "ArgoCD Diff Check" } + +# ── Workload Identity Federation for K8s Operator ─────────────────── + +variable "k8s_sa_jwks_json" { + description = <<-EOT + JWKS JSON containing the Kubernetes service-account signing public + key. Extract from a control-plane node with: + kubectl get --raw /openid/v1/jwks + Leave as the default placeholder until the cluster is configured. + EOT + type = string + default = "{\"keys\":[]}" +} diff --git a/terraform/tailscale/lolice/wif.tf b/terraform/tailscale/lolice/wif.tf index 1af75a612c..c083d244e5 100644 --- a/terraform/tailscale/lolice/wif.tf +++ b/terraform/tailscale/lolice/wif.tf @@ -23,3 +23,26 @@ resource "tailscale_federated_identity" "github_actions_argocd_diff" { # ACL must be applied first so that tag:ci is recognised. depends_on = [tailscale_acl.this] } + +# ── Kubernetes Operator WIF Trust Credential ───────────────────────── +# Allows the tailscale-operator Pod to authenticate to the tailnet +# using a projected ServiceAccount OIDC token (no client secret needed). +# The OIDC issuer is the S3-hosted discovery endpoint for the lolice +# kubeadm cluster (see oidc.tf). +resource "tailscale_federated_identity" "k8s_operator" { + description = "lolice k8s-operator WIF" + + # OIDC issuer: S3-hosted discovery for the kubeadm cluster + issuer = local.oidc_issuer_url + + # Subject: the operator ServiceAccount in the tailscale-operator namespace + subject = "system:serviceaccount:tailscale-operator:operator" + + # Scopes required by the Kubernetes Operator + scopes = ["auth_keys", "devices:core", "services"] + + # Tag assigned to the operator node + tags = ["tag:k8s-operator"] + + depends_on = [tailscale_acl.this] +} From 30028b1819a9cb041654c1246e9f6505c0549f37 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Mar 2026 17:55:05 +0000 Subject: [PATCH 2/4] fix: add S3 OIDC dependency to k8s_operator WIF resource --- terraform/tailscale/lolice/wif.tf | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/terraform/tailscale/lolice/wif.tf b/terraform/tailscale/lolice/wif.tf index c083d244e5..803ec27620 100644 --- a/terraform/tailscale/lolice/wif.tf +++ b/terraform/tailscale/lolice/wif.tf @@ -44,5 +44,12 @@ resource "tailscale_federated_identity" "k8s_operator" { # Tag assigned to the operator node tags = ["tag:k8s-operator"] - depends_on = [tailscale_acl.this] + # ACL must exist so tag:k8s-operator is recognised, and the S3 + # OIDC discovery docs must be published before Tailscale validates + # the issuer URL. + depends_on = [ + tailscale_acl.this, + aws_s3_object.oidc_discovery, + aws_s3_object.oidc_jwks, + ] } From 1281c16b111cfef30b9fdeefbce3e92ed9aaa4da Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Mar 2026 18:14:42 +0000 Subject: [PATCH 3/4] fix: add trivy ignores for public OIDC bucket, add bucket policy dep --- terraform/tailscale/lolice/oidc.tf | 11 +++++++---- terraform/tailscale/lolice/wif.tf | 1 + 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/terraform/tailscale/lolice/oidc.tf b/terraform/tailscale/lolice/oidc.tf index 06e8174cf5..878bdd48f6 100644 --- a/terraform/tailscale/lolice/oidc.tf +++ b/terraform/tailscale/lolice/oidc.tf @@ -25,10 +25,13 @@ resource "aws_s3_bucket_public_access_block" "k8s_oidc" { bucket = aws_s3_bucket.k8s_oidc.id # Allow public read via bucket policy (required for OIDC discovery). - block_public_acls = true - block_public_policy = false - ignore_public_acls = true - restrict_public_buckets = false + block_public_acls = true + ignore_public_acls = true + + # Public policy and buckets must be allowed so Tailscale can fetch the + # OIDC discovery documents without authentication. + block_public_policy = false #trivy:ignore:AVD-AWS-0087 -- intentional: OIDC discovery must be public + restrict_public_buckets = false #trivy:ignore:AVD-AWS-0093 -- intentional: OIDC discovery must be public } resource "aws_s3_bucket_policy" "k8s_oidc_public_read" { diff --git a/terraform/tailscale/lolice/wif.tf b/terraform/tailscale/lolice/wif.tf index 803ec27620..8191b2cc6d 100644 --- a/terraform/tailscale/lolice/wif.tf +++ b/terraform/tailscale/lolice/wif.tf @@ -51,5 +51,6 @@ resource "tailscale_federated_identity" "k8s_operator" { tailscale_acl.this, aws_s3_object.oidc_discovery, aws_s3_object.oidc_jwks, + aws_s3_bucket_policy.k8s_oidc_public_read, ] } From 3c2a24535812e8ca5aec6c51cb8cc970d9999e06 Mon Sep 17 00:00:00 2001 From: "boxp-tfaction[bot]" <162872338+boxp-tfaction[bot]@users.noreply.github.com> Date: Mon, 2 Mar 2026 18:16:47 +0000 Subject: [PATCH 4/4] style: terraform fmt -recursive --- terraform/tailscale/lolice/oidc.tf | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/terraform/tailscale/lolice/oidc.tf b/terraform/tailscale/lolice/oidc.tf index 878bdd48f6..340b1fc9a6 100644 --- a/terraform/tailscale/lolice/oidc.tf +++ b/terraform/tailscale/lolice/oidc.tf @@ -78,10 +78,10 @@ resource "aws_s3_object" "oidc_discovery" { content_type = "application/json" content = jsonencode({ - issuer = local.oidc_issuer_url - jwks_uri = "${local.oidc_issuer_url}/openid/v1/jwks" - response_types_supported = ["id_token"] - subject_types_supported = ["public"] + issuer = local.oidc_issuer_url + jwks_uri = "${local.oidc_issuer_url}/openid/v1/jwks" + response_types_supported = ["id_token"] + subject_types_supported = ["public"] id_token_signing_alg_values_supported = ["RS256"] }) }