Merge pull request 'Manage Rancher users, global roles, tokens and settings' (#8) from benvin/users-roles-tokens-settings into main
ci/woodpecker/push/apply Pipeline was successful

Reviewed-on: #8
This commit was merged in pull request #8.
This commit is contained in:
2026-08-30 14:25:47 +10:00
9 changed files with 290 additions and 0 deletions
+103
View File
@@ -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 "<username>/<role>" 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-<username>-<role>). 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
}
+81
View File
@@ -32,3 +32,84 @@ variable "global_role_bindings" {
}))
default = {}
}
# Rancher-local users (config/users/<username>.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/<username>.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/<name>.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/<name>.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/<name>.yaml). Map key is the setting name.
variable "settings" {
type = map(object({
value = string
}))
default = {}
}