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 3bcd4b802c..2ca42cf4b2 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 = concat( 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..340b1fc9a6 --- /dev/null +++ b/terraform/tailscale/lolice/oidc.tf @@ -0,0 +1,102 @@ +# 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 + 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" { + 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..8191b2cc6d 100644 --- a/terraform/tailscale/lolice/wif.tf +++ b/terraform/tailscale/lolice/wif.tf @@ -23,3 +23,34 @@ 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"] + + # 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, + aws_s3_bucket_policy.k8s_oidc_public_read, + ] +}