diff --git a/README.md b/README.md index f291a54..1ebe5bc 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,76 @@ 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. + +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 +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..af9e45d 100644 --- a/modules/rancher/main.tf +++ b/modules/rancher/main.tf @@ -52,3 +52,106 @@ 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 + + # 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 +# 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..6713a26 100644 --- a/modules/rancher/variables.tf +++ b/modules/rancher/variables.tf @@ -32,3 +32,84 @@ 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 = {} + + # 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 +# 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 = {} +}