Manage Rancher users, global roles, tokens and settings #8
@@ -7,9 +7,18 @@ provider. Mirrors the `terraform-authentik` pattern.
|
|||||||
## Managed Resources
|
## Managed Resources
|
||||||
|
|
||||||
- **Keycloak(OIDC) auth config** — Authentik OIDC login for Rancher.
|
- **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
|
## 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
|
`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`
|
read from Vault (kv-v2) — the same secret Authentik sets on its `rancher`
|
||||||
provider — and is never committed.
|
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
|
roles are granted to users/groups separately. This avoids locking the admin out
|
||||||
when the provider is first enabled.
|
when the provider is first enabled.
|
||||||
|
|
||||||
|
### `config/global_role_bindings/<group>.yaml`
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
global_role_id: admin
|
||||||
|
```
|
||||||
|
|
||||||
|
### `config/users/<username>.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-<username>-<role>` 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/<name>.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/<name>.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/<name>.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
|
## Usage
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
|||||||
@@ -13,5 +13,25 @@ locals {
|
|||||||
trimsuffix(basename(file_path), ".yaml") => content
|
trimsuffix(basename(file_path), ".yaml") => content
|
||||||
if startswith(file_path, "global_role_bindings/")
|
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/")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -19,4 +19,8 @@ terraform {
|
|||||||
inputs = {
|
inputs = {
|
||||||
keycloakoidc = local.config.keycloakoidc
|
keycloakoidc = local.config.keycloakoidc
|
||||||
global_role_bindings = local.config.global_role_bindings
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,3 +52,106 @@ resource "rancher2_global_role_binding" "group" {
|
|||||||
|
|
||||||
depends_on = [rancher2_auth_config_keycloak_oidc.this]
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -32,3 +32,84 @@ variable "global_role_bindings" {
|
|||||||
}))
|
}))
|
||||||
default = {}
|
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 = {}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user