mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-08 11:46:35 +00:00
After10cb02461switched `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 of10cb02461intact: 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 exact10cb02461scenario: 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>
214 lines
8.3 KiB
Python
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}"]
|