Skip to content

Keycloak setup for the operator

Two independent pieces have to exist in Keycloak before the operator is useful:

  1. An admin credential for the operator itself — so it can create groups and manage memberships via the Keycloak admin REST API.
  2. A Rancher OIDC client with a groups claim — so that the groups the operator creates actually translate into Rancher access when a user logs in. Without this, the operator happily creates groups and bindings that grant nobody anything.

scripts/keycloak-bootstrap.sh performs both idempotently. The sections below explain what it does and why, so you can also do it by hand in the admin console.


1. Operator admin credential

The operator authenticates with the password grant against admin-cli (keycloak_client.py). It does not support client credentials / service accounts — the client ID is hardcoded to admin-cli and there is no env var to override it. So you need a real user with a password.

Create a dedicated user (do not reuse the realm super-admin):

Field Value
Realm the realm holding the managed groups (→ KEYCLOAK_USER_REALM = KEYCLOAK_REALM), or master
Username e.g. rancher-operator
Password non-temporary — a temporary password makes the token request fail
Email required — set one (e.g. rancher-operator@noreply.invalid) and mark it verified
Required actions none

The email is not cosmetic. Keycloak's declarative user profile marks email required, so a user without one trips the VERIFY_PROFILE required action at authentication time and the password grant fails with "error_description": "Account is not fully set up" — even though the user's requiredActions list reads as empty in the admin API. This is the single most likely reason a freshly created operator user can't log in.

Required roles

Assign these client roles of the realm-management client in the target realm:

Role Needed for
manage-users create/delete groups, add/remove users from groups (Keycloak files group writes under the users permission set)
view-users read users, groups and group members (composite: includes query-users, query-groups)

That is the minimal set for every call the operator makes: GET /users/{id}, GET /users?username=&exact=true, GET /users?q=<attr>:<value>&exact=true (attribute-mode members, see §4), GET|POST /groups, POST /groups/{id}/children, DELETE /groups/{id}, GET /groups/{id}/members, PUT|DELETE /users/{id}/groups/{gid}. No manage-realm, no admin role.

If the user lives in master instead of the target realm, assign the same two roles from the <realm>-realm client in master (e.g. riigipilv-realm) rather than from realm-management.

Wiring it into the operator

1
2
3
4
5
--set config.keycloak.url=https://keycloak.example.com \
--set config.keycloak.realm=myrealm \
--set config.keycloak.userRealm=myrealm \
--set config.keycloak.username=rancher-operator \
--set config.keycloak.password='<password>'

KEYCLOAK_URL must be the base URL. The client builds /admin/realms/... and /realms/{userRealm}/protocol/openid-connect/token directly onto it — for Keycloak < 17 (legacy WildFly distribution) you must include the /auth suffix in the URL.

Verify

1
2
3
4
5
6
7
8
TOKEN=$(curl -s -d grant_type=password -d client_id=admin-cli \
  -d username=rancher-operator -d password='<password>' \
  https://keycloak.example.com/realms/myrealm/protocol/openid-connect/token \
  | jq -r .access_token)

# must return 200 with a group list, not 403
curl -s -o /dev/null -w '%{http_code}\n' -H "Authorization: Bearer $TOKEN" \
  https://keycloak.example.com/admin/realms/myrealm/groups

2. Rancher OIDC client (the part that makes groups mean something)

The operator writes bindings whose principal is:

1
keycloakoidc_group://<groupName>

Both binding scopes use that same principal format, so nothing here is scope-specific: project-scope spec.keycloak.roleBindings produce ProjectRoleTemplateBindings, and (since 0.4.0) cluster-scope spec.cluster.keycloak.roleBindings produce ClusterRoleTemplateBindings. Cluster-scope groups are created under the same Keycloak parent group as the project-scope ones.

Three things must line up for that principal to resolve at login time:

  1. Rancher must use the "Keycloak (OIDC)" auth provider — not Keycloak SAML, not the generic OIDC provider. The provider name is baked into the principal prefix (keycloakoidc_); a generic OIDC config produces oidc_group:// and the bindings will never match.
  2. The token must carry a groups claim.
  3. The claim must contain the bare group name, not the full path. The operator uses groupName alone, so a mapper with Full group path enabled emits /parent/child and nothing matches.

Client configuration

Setting Value
Client ID e.g. rancher
Client authentication On (confidential)
Standard flow Enabled
Direct access grants Optional
Valid redirect URIs https://<rancher-host>/verify-auth
Web origins https://<rancher-host>

Group membership mapper

Add a Group Membership mapper on the client (Client → Client scopes → rancher-dedicated → Add mapper → By configuration → Group Membership):

Setting Value
Name groups
Token Claim Name groups
Full group path Off ← critical
Add to ID token On
Add to access token On
Add to userinfo On

Keycloak ships a built-in groups client scope. It is not in the default scopes, and in several versions its mapper has Full group path set to true. Prefer a dedicated client mapper as above; if you use the built-in scope instead, open its mapper and turn full path off.

Rancher side

In Rancher: Users & Authentication → Auth Provider → Keycloak (OIDC)

Field Value
Client ID rancher
Client Secret from Keycloak → Client → Credentials
Issuer https://keycloak.example.com/realms/myrealm
Rancher URL https://<rancher-host>

Enable it before the operator reconciles its first CR — Rancher can reject a groupPrincipalId for an auth provider that is not configured.

Verify the claim end to end

1
2
3
4
5
# using the direct access grant on the rancher client (enable temporarily)
curl -s -d grant_type=password -d client_id=rancher -d client_secret='<secret>' \
  -d scope=openid -d username='<test-user>' -d password='<pw>' \
  https://keycloak.example.com/realms/myrealm/protocol/openid-connect/token \
  | jq -r .id_token | cut -d. -f2 | base64 -d 2>/dev/null | jq .groups

Expect bare names — ["p-abc-admin"], not ["/parent/p-abc-admin"].


3. Group naming constraints

  • Names must be unique realm-wide. get_group_by_name searches top-level groups and one level of subgroups and returns the first exact name match (keycloak_client.py:107-116), and Rancher principals are name-only. Two same-named groups under different parents will collide. Namespace them, e.g. <project-slug>-admin.
  • Only one level of nesting is resolvable. The CR's keycloak.parentGroupName → roleBindings[].groupName shape (parent → child) is exactly what the lookup supports. Deeper hierarchies created by hand won't be found.
  • The operator tags what it creates with the group attribute managed_by=rancher-keycloak-operator. Handy for auditing; it is not used for filtering.

4. Looking members up by a user attribute

Each CR member names a Keycloak user in one of three ways:

Member fields userIdentifier is
lookupByID: true (default) the Keycloak user UUID
lookupByID: false the Keycloak username
lookupAttribute: <name> the value of user attribute <name>; lookupByID is ignored

The attribute form exists for deployments where Waldur and Rancher authenticate against different Keycloaks, so neither the UUID nor necessarily the username is shared. The identifier both sides do share (typically a national civil code) is then sent by the site agent, and the Rancher-side Keycloak may hold it as a user attribute instead of the username:

1
2
3
members:
  - userIdentifier: "38001010001"
    lookupAttribute: civil_code

If the Rancher Keycloak stores the civil code as the username, use lookupByID: false instead — no attribute setup is needed.

What has to hold for attribute lookup to work:

  • Only administrators may write the attribute. Attribute lookup gives the grant to whoever holds the value, so a user who could edit the attribute in the Account Console, at registration or on the first-broker-login profile review could copy someone else's civil code into their own profile and receive that person's access. On Keycloak 24+ (declarative user profile always on), declare the attribute in Realm settings → User profile with edit permission admin only (view admin), or fill it from an identity-provider or LDAP mapper. Leaving it undeclared works only with Unmanaged attributes set to Only administrators can write (ADMIN_EDIT). Never use Enabled, which lets users edit it. The operator enforces this: before matching by an attribute it reads GET /users/profile. If users can edit the attribute, or the profile can't be read, it logs an error, adds nobody by that attribute, and stops keeping current members on the strength of it. The check is cached for five minutes, so fixing the realm needs no restart.
  • The attribute must be visible to the admin API. An attribute that is neither declared nor allowed as unmanaged is neither searchable with q= nor returned in group member listings, so every lookup silently finds nobody and the user is never added.
  • The operator credential needs view-users (already part of the minimal role set in §1); the search is GET /users?q=<name>:<value>.
  • Values must match exactly what the site agent sends: same attribute name, same value, no extra whitespace or prefix such as a country code. The operator re-checks every search hit against the returned attributes and keeps only exact matches, so a substring match in Keycloak's search can never resolve to the wrong user.
  • Values must be unique. Keycloak does not enforce uniqueness of attributes. If a value matches more than one user, the operator adds nobody and logs an error with the attribute name and match count (values are masked in logs, e.g. 3800…01).

Status syncedMembers echoes the attribute value for members matched by attribute (as it echoes the username for username-mode members), so the site agent can confirm each grant against what it submitted.

5. Cleanup semantics to be aware of

On CR deletion the operator removes members (by the UUIDs Keycloak lists for each group, whatever form the spec used) and deletes the CR's project groups. Cluster-scope groups and the per-cluster parent group are shared by every CR on the same cluster (one per ResourceProject of a Waldur Resource), so they stay while any other CR for that cluster exists. The last CR removes the cluster groups as well.

The parent group is deleted only if it has no child groups left. Keycloak deletes a group's subgroups along with it, so this check is what keeps one CR's deletion from taking its siblings' groups. It reads subGroupCount from GET /groups/{id}: Keycloak 26 leaves subGroups empty there and lists children only under /groups/{id}/children. Pre-existing groups adopted by name are deleted the same way as ones it created — don't point a CR at a group you also use for something else.