Files
computer-playbook/plugins/lookup/rbac_group_path.py
T
kevinveenbirkenbachandClaude Opus 4.7 6f703fb06d fix(rbac_group_path): read through merged-applications cache
After 10cb02461 switched `payload._resolve_variant_payloads` from
`get_variants` to `get_variant_overrides_only`, matrix-deploy bakes
only the raw `meta/variants.yml` slice into host_vars (typically just
`services:`). Lookups that read `variables['applications']` raw no
longer see content from `meta/rbac.yml`, `meta/server.yml`, etc.

`rbac_group_path` was the last lookup still going through the raw
host_vars read. For joomla (whose `meta/rbac.yml` declares the
`editor` role) the post-10cb02461 host_vars carried no `rbac` block
at all, the lookup walked an empty `rbac.roles` map, and the deploy
crashed with "rbac_group_path: role 'editor' is not declared under
applications[web-app-joomla].rbac.roles. Declared roles:
['administrator']" (job 77797371433 on run 26428080957).

Route `_get_application` through `get_merged_applications` so the
meta-merged view (services + server + rbac + volumes + schema
credentials + variant overrides) is restored. This mirrors the
existing pattern in plugins/lookup/{config,tls,nginx,domain}.py and
keeps the mirror-override intent of 10cb02461 intact: the bake step
still emits only the variant overrides; the merged cache assembles
the full view at lookup time.

Test updates:
- TestRbacGroupPathLookup.setUp patches get_merged_applications to
  read from variables['applications'] so the existing 14 tests stay
  hermetic without disk-reading roles/*/meta/*.
- New TestRbacGroupPathReadsMergedApplications regression case
  exercises the exact 10cb02461 scenario: host_vars only carry a
  variants.yml slice, the merged cache supplies the rbac block, the
  lookup returns the correct path.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 13:25:13 +02:00

214 lines
8.3 KiB
Python

"""Compute the canonical OIDC group path for an application role.
This lookup is the single sanctioned way to derive the Keycloak group
path that an application role maps to. It replaces the scattered
`[RBAC.GROUP.NAME, ...] | path_join` idiom so the per-application
OU hierarchy has one authoritative producer.
Usage:
"{{ lookup('rbac_group_path',
application_id='web-app-yourls',
role='administrator') }}"
-> "roles/web-app-yourls/administrator"
"{{ lookup('rbac_group_path',
application_id='web-app-wordpress',
role='editor',
tenant='blog.example') }}"
-> "roles/web-app-wordpress/blog.example/editor"
"{{ lookup('rbac_group_path',
application_id='web-app-wordpress',
role='network-administrator') }}"
-> "roles/web-app-wordpress/network-administrator"
Contract:
- `application_id` MUST refer to a role that declares an `rbac:` block
in its `meta/services.yml`. The role may be tenant-aware
(`rbac.tenancy.axis == "domain"`) or not (default).
- `role` MUST appear under `rbac.roles.<role>` or be the implicit
`administrator` that the role-list contract auto-adds.
- `tenant` MUST be passed for tenant-aware per-tenant roles, MUST NOT
be passed for global-scope roles or non-tenant apps.
- Every failure raises AnsibleError with an actionable message.
"""
from ansible.errors import AnsibleError
from ansible.plugins.lookup import LookupBase
from utils.cache.applications import get_merged_applications
try:
from ansible.utils.display import Display
except Exception: # pragma: no cover
Display = None
_IMPLICIT_ADMIN_ROLE = "administrator"
_TENANCY_AXIS_NONE = "none"
_TENANCY_AXIS_DOMAIN = "domain"
_DEFAULT_SCOPE = "per_tenant"
_SCOPE_GLOBAL = "global"
def _get_rbac_group_name(variables):
"""Resolve the top-level group container name (usually "roles")."""
rbac = variables.get("RBAC") or {}
group = (rbac.get("GROUP") or {}) if isinstance(rbac, dict) else {}
name = group.get("NAME") if isinstance(group, dict) else None
if not isinstance(name, str) or not name:
raise AnsibleError(
"rbac_group_path: RBAC.GROUP.NAME is not set in group_vars. "
"Expected a non-empty string such as 'roles'."
)
return name.strip("/")
def _get_application(variables, application_id, templar=None):
apps = get_merged_applications(variables=variables, templar=templar)
if not isinstance(apps, dict):
raise AnsibleError(
"rbac_group_path: 'applications' could not be merged. "
"Make sure the lookup runs after meta/*.yml topics have been "
"loaded."
)
app = apps.get(application_id)
if not isinstance(app, dict):
raise AnsibleError(
f"rbac_group_path: application_id '{application_id}' is not "
f"present in the merged applications dict."
)
return app
def _resolve_role_scope(app_cfg, application_id, role):
"""Return the effective scope for a given role.
For a tenant-aware app the default scope is 'per_tenant'; individual
roles can override with scope='global'. For a non-tenant app all
roles are effectively global regardless of their declaration.
"""
rbac = (app_cfg.get("rbac") or {}) if isinstance(app_cfg, dict) else {}
if not isinstance(rbac, dict):
raise AnsibleError(
f"rbac_group_path: application '{application_id}' has no valid "
f"'rbac' block in meta/services.yml."
)
roles = rbac.get("roles") or {}
role_cfg = roles.get(role)
# The implicit `administrator` role from the role-list contract is auto-added
# for every app with an rbac: block and is always valid.
if role != _IMPLICIT_ADMIN_ROLE and not isinstance(role_cfg, dict):
declared = sorted(roles.keys()) if isinstance(roles, dict) else []
raise AnsibleError(
f"rbac_group_path: role '{role}' is not declared under "
f"applications[{application_id}].rbac.roles. Declared roles: "
f"{[*declared, _IMPLICIT_ADMIN_ROLE]}."
)
tenancy = (rbac.get("tenancy") or {}) if isinstance(rbac, dict) else {}
axis = (
tenancy.get("axis", _TENANCY_AXIS_NONE)
if isinstance(tenancy, dict)
else _TENANCY_AXIS_NONE
)
if axis == _TENANCY_AXIS_NONE:
# In non-tenant apps the concept of scope collapses; treat every
# role as global for the purpose of path construction.
return _SCOPE_GLOBAL, axis
if axis != _TENANCY_AXIS_DOMAIN:
raise AnsibleError(
f"rbac_group_path: unsupported rbac.tenancy.axis "
f"'{axis}' on application '{application_id}'. Supported values "
f"are 'none' (default) and 'domain'."
)
# Tenant-aware path: consult per-role scope with default 'per_tenant'.
scope = _DEFAULT_SCOPE
if isinstance(role_cfg, dict):
scope = role_cfg.get("scope", _DEFAULT_SCOPE)
if scope not in (_DEFAULT_SCOPE, _SCOPE_GLOBAL):
raise AnsibleError(
f"rbac_group_path: unsupported scope '{scope}' on "
f"applications[{application_id}].rbac.roles.{role}. Supported "
f"values are 'per_tenant' (default) and 'global'."
)
return scope, axis
class LookupModule(LookupBase):
def run(self, terms, variables=None, **kwargs):
variables = variables or {}
if terms:
raise AnsibleError(
"rbac_group_path: positional arguments are not supported. "
"Use keyword arguments application_id=..., role=..., "
"tenant=... (optional)."
)
application_id = kwargs.get("application_id")
role = kwargs.get("role")
tenant = kwargs.get("tenant")
if not application_id or not isinstance(application_id, str):
raise AnsibleError(
"rbac_group_path: 'application_id' is required and must be "
"a non-empty string."
)
if not role or not isinstance(role, str):
raise AnsibleError(
"rbac_group_path: 'role' is required and must be a non-empty string."
)
app_cfg = _get_application(
variables,
application_id,
templar=getattr(self, "_templar", None),
)
scope, axis = _resolve_role_scope(app_cfg, application_id, role)
group_root = _get_rbac_group_name(variables)
# hierarchical OIDC claim paths that mirror the
# LDAP RBAC tree verbatim, with no redundant segments:
# /<group_root>/<application_id>/<role_name> # non-tenant / global
# /<group_root>/<application_id>/<tenant_id>/<role_name> # per-tenant
# The Keycloak group tree is materialised by a per-application
# group-ldap-mapper (see
# roles/web-app-keycloak/tasks/update/05c_per_app_mappers.yml),
# anchored at `/<group_root>/<application_id>` with
# `group.name.ldap.attribute=description`.
if scope == _SCOPE_GLOBAL:
if tenant:
raise AnsibleError(
f"rbac_group_path: role '{role}' on application "
f"'{application_id}' resolves to scope='global' but a "
f"tenant '{tenant}' was passed. Omit the tenant "
f"argument for global roles."
)
return [f"{group_root}/{application_id}/{role}"]
if axis != _TENANCY_AXIS_DOMAIN:
raise AnsibleError(
f"rbac_group_path: per_tenant scope on "
f"'{application_id}.{role}' requires "
f"rbac.tenancy.axis == 'domain' but got '{axis}'."
)
if not tenant or not isinstance(tenant, str):
raise AnsibleError(
f"rbac_group_path: role '{role}' on application "
f"'{application_id}' is tenant-scoped; a 'tenant' argument "
f"is required."
)
tenant_norm = tenant.strip().strip("/").lower()
if not tenant_norm:
raise AnsibleError(
"rbac_group_path: 'tenant' argument must be a non-empty "
"domain after normalisation."
)
return [f"{group_root}/{application_id}/{tenant_norm}/{role}"]