From c7ef079c888ccd1b1bedba21037df796ae25b989 Mon Sep 17 00:00:00 2001 From: unkin-agent Date: Sun, 30 Aug 2026 00:48:29 +1000 Subject: [PATCH 1/2] Manage Rancher users, global roles, tokens and settings Extend the yaml-discovery config to four more rancher2 kinds so Rancher objects beyond the OIDC auth provider stop being unmanaged clickops. - Discover config/users, config/roles, config/tokens and config/settings in config/config.hcl and pass them through terragrunt.hcl - Add rancher2_user (password from Vault kv-v2) plus per-user rancher2_global_role_binding on "/" keys - Add rancher2_global_role with dynamic rules blocks - Add rancher2_token, documenting that the provider has no user selector - Add rancher2_setting and seed config/settings/server-url.yaml - Document every yaml schema in the README --- README.md | 70 +++++++++++++++ config/config.hcl | 20 +++++ config/roles/.gitkeep | 0 config/settings/server-url.yaml | 3 + config/tokens/.gitkeep | 0 config/users/.gitkeep | 0 .../terragrunt.hcl | 4 + modules/rancher/main.tf | 90 +++++++++++++++++++ modules/rancher/variables.tf | 59 ++++++++++++ 9 files changed, 246 insertions(+) create mode 100644 config/roles/.gitkeep create mode 100644 config/settings/server-url.yaml create mode 100644 config/tokens/.gitkeep create mode 100644 config/users/.gitkeep diff --git a/README.md b/README.md index f291a54..479bad9 100644 --- a/README.md +++ b/README.md @@ -7,9 +7,18 @@ provider. Mirrors the `terraform-authentik` pattern. ## Managed Resources - **Keycloak(OIDC) auth config** — Authentik OIDC login for Rancher. +- **Global role bindings** — Authentik group → Rancher global role. +- **Users** — Rancher-local users and their global roles. +- **Global roles** — custom Rancher global roles. +- **Tokens** — API tokens for the identity Terraform authenticates as. +- **Settings** — Rancher settings such as `server-url`. ## Configuration +Every kind is discovered from YAML by `config/config.hcl`: the file name is the +object's key/name and the file body holds its attributes. Adding a file is the +only step needed to manage a new object; an empty directory means an empty map. + `config/keycloakoidc.yaml` defines the auth provider. The OAuth client secret is read from Vault (kv-v2) — the same secret Authentik sets on its `rancher` provider — and is never committed. @@ -18,6 +27,67 @@ provider — and is never committed. roles are granted to users/groups separately. This avoids locking the admin out when the provider is first enabled. +### `config/global_role_bindings/.yaml` + +```yaml +global_role_id: admin +``` + +### `config/users/.yaml` + +```yaml +name: Some Person # optional display name, defaults to the file name +enabled: true # optional, default true +must_change_password: false # optional, default false +password_vault: # required — rancher2_user requires a password + mount: kv + path: service/rancher/users/someperson + key: password # optional, default "password" +global_role_bindings: # optional + - user +``` + +The password is read from Vault **at plan time**, so seed the kv-v2 secret +before adding the file, or the plan fails. + +### `config/roles/.yaml` + +```yaml +description: Read-only across all clusters +new_user_default: false # optional, default false +inherited_cluster_roles: # optional + - view +rules: # optional + - api_groups: ["management.cattle.io"] + resources: ["clusters"] + verbs: ["get", "list", "watch"] + # non_resource_urls and resource_names are also supported +``` + +### `config/tokens/.yaml` + +```yaml +description: CI token # optional, defaults to the file name +ttl: 7776000 # optional, seconds +renew: true # optional +cluster_id: c-m-abcdefgh # optional, scopes the token to one cluster +``` + +> **Caveat:** `rancher2_token` has no user selector — `user_id` is computed by +> the provider (14.1.1), not settable. Tokens declared here are minted for the +> identity the rancher2 provider authenticates as (the admin token Terraform +> runs with), **not** for users in `config/users/`. A token for another user has +> to be created by that user. Token values land in Terraform state. + +### `config/settings/.yaml` + +```yaml +value: https://rancher.k8s.syd1.au.unkin.net +``` + +Rancher ships defaults for its settings, so an entry takes over an existing +setting rather than creating a new one. + ## Usage ```sh diff --git a/config/config.hcl b/config/config.hcl index 683481a..a692d13 100644 --- a/config/config.hcl +++ b/config/config.hcl @@ -13,5 +13,25 @@ locals { trimsuffix(basename(file_path), ".yaml") => content if startswith(file_path, "global_role_bindings/") } + users = { + for file_path, content in local.all_configs : + trimsuffix(basename(file_path), ".yaml") => content + if startswith(file_path, "users/") + } + roles = { + for file_path, content in local.all_configs : + trimsuffix(basename(file_path), ".yaml") => content + if startswith(file_path, "roles/") + } + tokens = { + for file_path, content in local.all_configs : + trimsuffix(basename(file_path), ".yaml") => content + if startswith(file_path, "tokens/") + } + settings = { + for file_path, content in local.all_configs : + trimsuffix(basename(file_path), ".yaml") => content + if startswith(file_path, "settings/") + } } } diff --git a/config/roles/.gitkeep b/config/roles/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/config/settings/server-url.yaml b/config/settings/server-url.yaml new file mode 100644 index 0000000..b33ee88 --- /dev/null +++ b/config/settings/server-url.yaml @@ -0,0 +1,3 @@ +# Rancher's server-url setting. Must match the URL Rancher is reached on, +# otherwise newly registered cluster agents dial the wrong endpoint. +value: https://rancher.k8s.syd1.au.unkin.net diff --git a/config/tokens/.gitkeep b/config/tokens/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/config/users/.gitkeep b/config/users/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/environments/rancher.k8s.syd1.au.unkin.net/terragrunt.hcl b/environments/rancher.k8s.syd1.au.unkin.net/terragrunt.hcl index 7a75b1c..20ee9e2 100644 --- a/environments/rancher.k8s.syd1.au.unkin.net/terragrunt.hcl +++ b/environments/rancher.k8s.syd1.au.unkin.net/terragrunt.hcl @@ -19,4 +19,8 @@ terraform { inputs = { keycloakoidc = local.config.keycloakoidc global_role_bindings = local.config.global_role_bindings + users = local.config.users + global_roles = local.config.roles + tokens = local.config.tokens + settings = local.config.settings } diff --git a/modules/rancher/main.tf b/modules/rancher/main.tf index 93b861b..9002a32 100644 --- a/modules/rancher/main.tf +++ b/modules/rancher/main.tf @@ -52,3 +52,93 @@ resource "rancher2_global_role_binding" "group" { depends_on = [rancher2_auth_config_keycloak_oidc.this] } + +# Rancher-local users. rancher2_user makes `password` required, so each user's +# password comes from a Vault kv-v2 secret rather than the repo. Vault data +# sources resolve at plan time: seed the secret before adding the yaml. +data "vault_kv_secret_v2" "user_password" { + for_each = var.users + + mount = each.value.password_vault.mount + name = each.value.password_vault.path +} + +resource "rancher2_user" "this" { + for_each = var.users + + username = each.key + name = coalesce(each.value.name, each.key) + enabled = each.value.enabled + must_change_password = each.value.must_change_password + password = data.vault_kv_secret_v2.user_password[each.key].data[each.value.password_vault.key] +} + +# Flatten users -> their global roles into "/" keys so adding or +# removing one role never re-indexes the others. +locals { + user_global_role_bindings = merge([ + for username, user in var.users : { + for role in user.global_role_bindings : + "${username}/${role}" => { + username = username + global_role_id = role + } + } + ]...) +} + +resource "rancher2_global_role_binding" "user" { + for_each = local.user_global_role_bindings + + # Binding name must be an RFC 1123 label, so lowercase it and swap the key's + # "/" separator for "-". + name = "akuser-${lower(replace(each.key, "/", "-"))}" + global_role_id = each.value.global_role_id + user_id = rancher2_user.this[each.value.username].id +} + +# Custom global roles. Rules are optional: a role with none grants nothing on its +# own and is useful purely as a container for inherited_cluster_roles. +resource "rancher2_global_role" "this" { + for_each = var.global_roles + + name = each.key + description = each.value.description + new_user_default = each.value.new_user_default + inherited_cluster_roles = each.value.inherited_cluster_roles + + dynamic "rules" { + for_each = each.value.rules + content { + api_groups = rules.value.api_groups + resources = rules.value.resources + verbs = rules.value.verbs + non_resource_urls = rules.value.non_resource_urls + resource_names = rules.value.resource_names + } + } +} + +# CAVEAT: rancher2_token has no user selector — `user_id` is computed by the +# provider at 14.1.1, not settable. Every token declared here is minted for the +# identity the rancher2 provider authenticates as (the CI admin service account), +# NOT for any user in config/users/. There is no way to mint a token on another +# user's behalf through this provider; that has to be done by that user. +# The token/secret_key values land in Terraform state, so treat state as secret. +resource "rancher2_token" "this" { + for_each = var.tokens + + description = coalesce(each.value.description, each.key) + ttl = each.value.ttl + renew = each.value.renew + cluster_id = each.value.cluster_id +} + +# Rancher settings, e.g. server-url. Rancher ships defaults for these, so an +# entry here takes over an existing setting rather than creating a new one. +resource "rancher2_setting" "this" { + for_each = var.settings + + name = each.key + value = each.value.value +} diff --git a/modules/rancher/variables.tf b/modules/rancher/variables.tf index 19c56ac..fe359de 100644 --- a/modules/rancher/variables.tf +++ b/modules/rancher/variables.tf @@ -32,3 +32,62 @@ variable "global_role_bindings" { })) default = {} } + +# Rancher-local users (config/users/.yaml). Map key is the username. +# rancher2_user requires a password, so every entry must point at a Vault kv-v2 +# secret holding one; the value is read at plan time, so the secret must exist +# before a user yaml is added. +variable "users" { + type = map(object({ + name = optional(string, null) # display name, defaults to the username + enabled = optional(bool, true) + must_change_password = optional(bool, false) + password_vault = object({ + mount = string + path = string + key = optional(string, "password") + }) + # Global roles granted to this user, e.g. ["user", "admin"]. + global_role_bindings = optional(list(string), []) + })) + default = {} +} + +# Custom Rancher global roles (config/roles/.yaml). Map key is the role +# name, which is also the id other configs bind to via global_role_id. +variable "global_roles" { + type = map(object({ + description = optional(string, null) + new_user_default = optional(bool, false) + inherited_cluster_roles = optional(list(string), []) + rules = optional(list(object({ + api_groups = optional(list(string), []) + resources = optional(list(string), []) + verbs = optional(list(string), []) + non_resource_urls = optional(list(string), []) + resource_names = optional(list(string), []) + })), []) + })) + default = {} +} + +# API tokens (config/tokens/.yaml). Map key names the Terraform resource +# only. rancher2_token has no user selector (user_id is computed at 14.1.1), so +# every token here is minted for the identity the provider authenticates as. +variable "tokens" { + type = map(object({ + description = optional(string, null) # defaults to the map key + ttl = optional(number, null) # seconds; 0/null = provider default + renew = optional(bool, null) + cluster_id = optional(string, null) # scope to one cluster, null = global + })) + default = {} +} + +# Rancher settings (config/settings/.yaml). Map key is the setting name. +variable "settings" { + type = map(object({ + value = string + })) + default = {} +} -- 2.47.3 From 187e29d068bcbe26ab7c05e0607ad93a12179b1c Mon Sep 17 00:00:00 2001 From: unkin-agent Date: Sun, 30 Aug 2026 00:58:59 +1000 Subject: [PATCH 2/2] Validate user and role names instead of munging binding names Rancher requires global role binding names to be RFC 1123 labels. The per-user binding name lowercased the key and replaced only "/", so usernames containing ".", "_" or "@" still produced an invalid name and failed at apply, and hyphenated names could collide (foo-bar + baz vs foo + bar-baz) into one object. Add validation blocks on var.users requiring the username and every referenced role name to be RFC 1123 labels, so non-compliant input fails the plan with an actionable message. The binding name is then built from the two parts directly. A precondition rejects the remaining hyphen ambiguity at plan time rather than as a mid-apply conflict. Document the constraint in the users schema section of the README. The group binding path is unchanged. --- README.md | 9 +++++++++ modules/rancher/main.tf | 19 ++++++++++++++++--- modules/rancher/variables.tf | 22 ++++++++++++++++++++++ 3 files changed, 47 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 479bad9..1ebe5bc 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,15 @@ global_role_bindings: # optional The password is read from Vault **at plan time**, so seed the kv-v2 secret before adding the file, or the plan fails. +The file name (the username) and every entry in `global_role_bindings` must be +an RFC 1123 label — `^[a-z0-9]([a-z0-9-]*[a-z0-9])?$`, i.e. lowercase +alphanumerics and `-`, starting and ending alphanumeric. No `.`, `_`, `@` or +uppercase. Rancher names each binding `akuser--` and rejects +anything else, so non-compliant values fail the plan with an explicit error +instead of the apply. Names that are individually valid but collapse to the same +binding name (e.g. `foo-bar` + `baz` and `foo` + `bar-baz`) are rejected at plan +time too. + ### `config/roles/.yaml` ```yaml diff --git a/modules/rancher/main.tf b/modules/rancher/main.tf index 9002a32..af9e45d 100644 --- a/modules/rancher/main.tf +++ b/modules/rancher/main.tf @@ -90,11 +90,24 @@ locals { resource "rancher2_global_role_binding" "user" { for_each = local.user_global_role_bindings - # Binding name must be an RFC 1123 label, so lowercase it and swap the key's - # "/" separator for "-". - name = "akuser-${lower(replace(each.key, "/", "-"))}" + # Both parts are RFC 1123 labels already (var.users validates them), so the + # binding name needs no transformation. + name = "akuser-${each.value.username}-${each.value.global_role_id}" global_role_id = each.value.global_role_id user_id = rancher2_user.this[each.value.username].id + + lifecycle { + # Hyphens are legal inside both parts, so distinct bindings can still flatten + # to the same name (foo-bar + baz vs foo + bar-baz). Catch that at plan time + # rather than as a duplicate-object conflict mid-apply. + precondition { + condition = length(distinct([ + for binding in local.user_global_role_bindings : + "akuser-${binding.username}-${binding.global_role_id}" + ])) == length(local.user_global_role_bindings) + error_message = "Two user global role bindings collapse to the same Rancher binding name (akuser--). Rename one of the users or roles involved." + } + } } # Custom global roles. Rules are optional: a role with none grants nothing on its diff --git a/modules/rancher/variables.tf b/modules/rancher/variables.tf index fe359de..6713a26 100644 --- a/modules/rancher/variables.tf +++ b/modules/rancher/variables.tf @@ -51,6 +51,28 @@ variable "users" { global_role_bindings = optional(list(string), []) })) default = {} + + # Rancher names each global role binding as an RFC 1123 label and rejects + # anything else at apply time. The binding name is built from the username and + # the role name verbatim, so both must already be compliant — fail the plan + # with a clear message instead of munging the input and hoping. + validation { + condition = alltrue([ + for username in keys(var.users) : + can(regex("^[a-z0-9]([a-z0-9-]*[a-z0-9])?$", username)) + ]) + error_message = "Usernames must be RFC 1123 labels: lowercase alphanumerics and '-', starting and ending alphanumeric (no '.', '_', '@' or uppercase). Rename config/users/.yaml to a compliant username." + } + + validation { + condition = alltrue(flatten([ + for user in values(var.users) : [ + for role in user.global_role_bindings : + can(regex("^[a-z0-9]([a-z0-9-]*[a-z0-9])?$", role)) + ] + ])) + error_message = "Values in global_role_bindings must be RFC 1123 labels: lowercase alphanumerics and '-', starting and ending alphanumeric." + } } # Custom Rancher global roles (config/roles/.yaml). Map key is the role -- 2.47.3