Files
unkin-agent 187e29d068
ci/woodpecker/pr/plan Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
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.
2026-08-30 00:58:59 +10:00

158 lines
6.4 KiB
Terraform

# Read the OAuth client secret from Vault so nothing sensitive is committed.
data "vault_kv_secret_v2" "keycloakoidc" {
count = var.keycloakoidc != null && var.keycloakoidc.client_secret_vault != null ? 1 : 0
mount = var.keycloakoidc.client_secret_vault.mount
name = var.keycloakoidc.client_secret_vault.path
}
# Read the internal PKI CA chain (intermediate + root) from Vault. Rancher makes
# its OIDC discovery call to https://identity.unkin.net server-side; without the
# internal CA it fails with x509 "certificate signed by unknown authority". The
# ca_chain field is public/non-sensitive. Sourced from Vault (not hardcoded) so
# the trust never goes stale on rotation.
data "vault_generic_secret" "internal_ca" {
count = var.keycloakoidc != null ? 1 : 0
path = "pki_int/cert/ca_chain"
}
# Rancher's Keycloak(OIDC) auth provider, pointed at Authentik. access_mode
# "unrestricted" means any authenticated Authentik user can log in; Rancher
# roles are granted to users/groups separately (avoids admin lockout on enable).
resource "rancher2_auth_config_keycloak_oidc" "this" {
count = var.keycloakoidc != null ? 1 : 0
rancher_url = var.keycloakoidc.rancher_url
client_id = var.keycloakoidc.client_id
client_secret = data.vault_kv_secret_v2.keycloakoidc[0].data["client_secret"]
issuer = var.keycloakoidc.issuer
auth_endpoint = var.keycloakoidc.auth_endpoint
scopes = var.keycloakoidc.scopes
groups_field = var.keycloakoidc.groups_field
access_mode = var.keycloakoidc.access_mode
enabled = var.keycloakoidc.enabled
# CA cert Rancher uses to trust the IdP's TLS during OIDC discovery. Defaults
# to the internal PKI chain from Vault; an explicit config value overrides it.
certificate = coalesce(var.keycloakoidc.certificate, data.vault_generic_secret.internal_ca[0].data["ca_chain"])
}
# Grant Rancher global roles to Authentik permission groups. The keycloak_oidc
# group principal id is keycloakoidc_group://<group name>. Members of a role
# group (e.g. akR-global-admin) inherit the permission group, so they receive
# the bound global role.
resource "rancher2_global_role_binding" "group" {
for_each = var.global_role_bindings
# Binding name must be an RFC 1123 label (lowercase); the group name may be
# mixed-case (akP-*), so lowercase it here. The principal id keeps the original
# case to match the group.
name = "akgroup-${lower(each.key)}"
global_role_id = each.value.global_role_id
group_principal_id = "keycloakoidc_group://${each.key}"
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
}