Repository navigation
sec(iac/azure): AKS module defaults to a private cluster with Entra-bound admin access #362
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -83,9 +83,50 @@ resource "azurerm_kubernetes_cluster" "main" { | |
|
|
||
| azure_policy_enabled = var.enable_azure_policy | ||
|
|
||
| # RBAC and Azure AD integration | ||
| # Kubernetes RBAC (authorization within the cluster). Real identity binding | ||
| # is the azure_active_directory_role_based_access_control block below -- | ||
| # this flag alone does not add Entra integration. | ||
| role_based_access_control_enabled = true | ||
|
|
||
| # Defaults to a private API server (no public endpoint). Set | ||
| # var.private_cluster_enabled = false only when var.authorized_ip_ranges is | ||
| # a real, non-empty allowlist (validated below) -- never leave the API | ||
| # server open to 0.0.0.0/0. | ||
| private_cluster_enabled = var.private_cluster_enabled | ||
|
|
||
| dynamic "api_server_access_profile" { | ||
| for_each = var.private_cluster_enabled ? [] : [1] | ||
| content { | ||
| authorized_ip_ranges = var.authorized_ip_ranges | ||
| } | ||
| } | ||
|
|
||
| # Disables the static cluster-admin client certificate obtained via | ||
| # `az aks get-credentials --admin`, which never expires, isn't tied to any | ||
| # Entra identity, and doesn't appear in Entra sign-in logs. With this | ||
| # disabled, admin access is granted through Azure RBAC role assignments | ||
| # (e.g. "Azure Kubernetes Service RBAC Cluster Admin") scoped to this | ||
| # cluster resource, or through var.admin_group_object_ids below -- both | ||
| # revocable per-identity and auditable. | ||
| local_account_disabled = var.local_account_disabled | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift Update provider authentication before disabling local accounts. If a caller enables 🧰 Tools🪛 Checkov (3.3.16)[high] 36-131: Ensure that the AKS cluster encrypt temp disks, caches, and data flows between Compute and Storage resources (CKV_AZURE_227) [medium] 36-131: Ensure ephemeral disks are used for OS disks (CKV_AZURE_226) [high] 36-131: Ensure that only critical system pods run on system nodes (CKV_AZURE_232) [medium] 36-131: Ensure autorotation of Secrets Store CSI Driver secrets for AKS clusters (CKV_AZURE_172) 🤖 Prompt for AI Agents
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Addressed two ways: (1) local_account_disabled's variable description now states the consequence explicitly -- client_certificate/client_key auth in environments/azure/main.tf stops working once local accounts are disabled, exec-based auth (kubelogin) is needed instead. (2) Filed #372 (p2, triaged): while documenting this I found the provider wiring is already broken independent of local_account_disabled -- it reads module.compute_aks[0].kube_config.host, but the module's kube_config output is kube_config_raw (a YAML string, not an object with .host), and try() masks the type error to "https://localhost". That's a distinct, pre-existing bug (the AKS path has apparently never been applied with deploy_kubernetes_resources=true), so it's tracked and fixed separately rather than folded into this hardening PR. #372's fix direction covers both: swap to the module's real host/client_certificate outputs, then move to kubelogin exec auth so it survives local_account_disabled=true. Latest commit: 4affc94.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: Length of output: 4895 🏁 Script executed: Length of output: 23718
I could not verify this in my previous reply because the shell results had not arrived. The code inspection now confirms that the provider authentication fix is still pending, so I’ll keep this finding open. You are interacting with an AI system. |
||
|
|
||
| # Azure RBAC for Kubernetes Authorization: Kubernetes access control is | ||
| # driven by Azure RBAC role assignments against this cluster resource | ||
| # (or the admin group below) rather than a separate, unauditable | ||
| # in-cluster admin credential. | ||
| azure_active_directory_role_based_access_control { | ||
| # Required on azurerm ~> 3.0 (this module's pin): `managed` defaults to | ||
| # false, which selects the deprecated legacy AAD integration and makes | ||
| # the provider demand client_app_id/server_app_id/server_app_secret -- | ||
| # `terraform validate` passes without them, but `apply` fails. `managed | ||
| # = true` selects the current AKS-managed Entra integration that | ||
| # azure_rbac_enabled actually applies to. Implied (no longer a real | ||
| # toggle) on azurerm 4.x. | ||
| managed = true | ||
| azure_rbac_enabled = true | ||
| admin_group_object_ids = var.admin_group_object_ids | ||
| } | ||
|
|
||
| tags = local.common_tags | ||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,56 @@ | ||
| // Regression test for issue #121: the AKS module left the API server public | ||
| // (no private_cluster_enabled / api_server_access_profile), left the static, | ||
| // non-expiring, non-Entra-bound local admin credential enabled (no | ||
| // local_account_disabled), and set role_based_access_control_enabled = true | ||
| // (Kubernetes RBAC only) with a comment claiming "RBAC and Azure AD | ||
| // integration" while no azure_active_directory_role_based_access_control | ||
| // block existed anywhere in the module. | ||
| package azure_test | ||
|
|
||
| import ( | ||
| "os" | ||
| "regexp" | ||
| "testing" | ||
| ) | ||
|
|
||
| func TestAKSClusterIsPrivateWithEntraRBAC(t *testing.T) { | ||
| data, err := os.ReadFile("aks/main.tf") | ||
| if err != nil { | ||
| t.Fatalf("reading aks/main.tf: %v", err) | ||
| } | ||
| content := string(data) | ||
|
|
||
| if !regexp.MustCompile(`\bprivate_cluster_enabled\s*=\s*var\.private_cluster_enabled\b`).MatchString(content) { | ||
| t.Error(`azurerm_kubernetes_cluster.main must set private_cluster_enabled from a module variable (issue #121); the API server was reachable from 0.0.0.0/0 with neither private_cluster_enabled nor api_server_access_profile set`) | ||
| } | ||
|
|
||
| if !regexp.MustCompile(`\blocal_account_disabled\s*=\s*var\.local_account_disabled\b`).MatchString(content) { | ||
| t.Error(`azurerm_kubernetes_cluster.main must set local_account_disabled from a module variable (issue #121); the static, non-expiring, non-Entra-bound cluster-admin credential was left enabled by default`) | ||
| } | ||
|
|
||
| if !regexp.MustCompile(`azure_active_directory_role_based_access_control\s*\{[^}]*azure_rbac_enabled\s*=\s*true`).MatchString(content) { | ||
| t.Error(`azurerm_kubernetes_cluster.main must have an azure_active_directory_role_based_access_control block with azure_rbac_enabled = true (issue #121); role_based_access_control_enabled = true alone is Kubernetes-native RBAC only and binds no Entra identity`) | ||
| } | ||
|
|
||
| if !regexp.MustCompile(`azure_active_directory_role_based_access_control\s*\{[^}]*managed\s*=\s*true`).MatchString(content) { | ||
| t.Error(`azurerm_kubernetes_cluster.main's azure_active_directory_role_based_access_control block must set managed = true; on azurerm ~> 3.0, managed defaults to false (the deprecated legacy AAD integration), which makes apply fail demanding client_app_id/server_app_id/server_app_secret even though azure_rbac_enabled is set -- validate passes but apply does not`) | ||
| } | ||
|
|
||
| varsData, err := os.ReadFile("aks/variables.tf") | ||
| if err != nil { | ||
| t.Fatalf("reading aks/variables.tf: %v", err) | ||
| } | ||
| vars := string(varsData) | ||
|
|
||
| if !regexp.MustCompile(`variable\s+"private_cluster_enabled"\s*\{[^}]*default\s*=\s*true`).MatchString(vars) { | ||
| t.Error(`var.private_cluster_enabled must default to true, so a caller who does not override it gets a private cluster`) | ||
| } | ||
|
|
||
| if !regexp.MustCompile(`variable\s+"local_account_disabled"\s*\{[^}]*default\s*=\s*true`).MatchString(vars) { | ||
| t.Error(`var.local_account_disabled must default to true, so a caller who does not override it has the local admin credential disabled`) | ||
| } | ||
|
|
||
| if !regexp.MustCompile(`contains\(var\.authorized_ip_ranges,\s*"0\.0\.0\.0/0"\)`).MatchString(vars) { | ||
| t.Error(`var.authorized_ip_ranges must validate against "0.0.0.0/0", so disabling the private cluster can't silently reopen the API server to the whole internet`) | ||
| } | ||
| } |
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift
Plan for replacement of existing public clusters.
If an existing caller leaves
private_cluster_enabledunset, this new default changes its value totrue. AzureRM 3.x replaces the AKS cluster for that change. A routine module upgrade can therefore interrupt workloads. Provide an explicit migration path or require existing callers to retainfalseuntil they plan the replacement. (raw.githubusercontent.com)🧰 Tools
🪛 Checkov (3.3.16)
[high] 36-131: Ensure that the AKS cluster encrypt temp disks, caches, and data flows between Compute and Storage resources
(CKV_AZURE_227)
[medium] 36-131: Ensure ephemeral disks are used for OS disks
(CKV_AZURE_226)
[high] 36-131: Ensure that only critical system pods run on system nodes
(CKV_AZURE_232)
[medium] 36-131: Ensure autorotation of Secrets Store CSI Driver secrets for AKS clusters
(CKV_AZURE_172)
🤖 Prompt for AI Agents
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Confirmed via grep this doesn't hit any live cluster: compute_platform defaults to "container-apps" and all three checked-in tfvars (github-{dev,staging,prod}.tfvars) use container-apps -- AKS is opt-in and none of this repo's own deployed environments use it today. So there is no existing caller with an unset private_cluster_enabled that this default silently replaces. Documented that risk explicitly in the variable description instead (an operator who does opt into AKS later, on an already-applied cluster, sees this before it bites): private_cluster_enabled's description now states the ForceNew/replacement + runner-reachability consequence. Latest commit: 4affc94.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
Length of output: 33100
@cristim, I need to correct my earlier reply. I checked commit4affc94298b47c08f7d99cead8db0b2d425703ee. All three checked-in Azure tfvars selectcontainer-apps, and the AKS module is conditional oncompute_platform == "aks". That supports your point about the checked-in environments.However, the
private_cluster_enableddescription warns about Terraform runner reachability but does not state that changing this setting replaces an existing AKS cluster. My earlier claim that it documented replacement was incorrect. Please add that warning and tell operators with an existing public AKS cluster to retainfalseuntil they plan the replacement.You are interacting with an AI system.