Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions ansible/roles/kubernetes_components/defaults/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: ""
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions terraform/tailscale/lolice/acl.tf
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
28 changes: 28 additions & 0 deletions terraform/tailscale/lolice/oauth.tf
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
102 changes: 102 additions & 0 deletions terraform/tailscale/lolice/oidc.tf
Original file line number Diff line number Diff line change
@@ -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]
}
}
15 changes: 15 additions & 0 deletions terraform/tailscale/lolice/outputs.tf
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
13 changes: 13 additions & 0 deletions terraform/tailscale/lolice/variables.tf
Original file line number Diff line number Diff line change
Expand Up @@ -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\":[]}"
}
31 changes: 31 additions & 0 deletions terraform/tailscale/lolice/wif.tf
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Comment on lines +50 to +53

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Add public-read policy dependency before WIF creation

tailscale_federated_identity.k8s_operator currently waits for the two S3 objects but not for aws_s3_bucket_policy.k8s_oidc_public_read, so on a fresh apply Terraform can start WIF creation while the issuer documents are still private and Tailscale’s issuer validation gets a 403. This makes applies flaky/failing in the exact bootstrap path this commit introduces; include the bucket policy in depends_on to guarantee the issuer URL is publicly readable before creating the federated identity.

Useful? React with 👍 / 👎.

aws_s3_bucket_policy.k8s_oidc_public_read,
]
}
Loading