Files
computer-playbook/plugins/lookup/rbac_group_path.py
kevinveenbirkenbachandClaude Opus 4.8 4039313266 refactor(tasks): enforce and apply a single task-file numbering scheme
Add tests/lint/repository/test_task_file_numbering.py: numbered task
files use exactly two leading digits, a numbered *_core.yml must be
00_core.yml, per directory the numbers are unique/consecutive and start
at 01 (00 with a core file), numbered includes appear in ascending
order, and shared helpers live in tasks/utils/ instead of the numbered
chain.

Apply it across every role: rename NN_core.yml to 00_core.yml with a
consecutive down-shift of the siblings, move letter-suffix children into
a subdirectory named after their parent task, relocate helpers included
from more than one place into tasks/utils/, and update every reference
(include_tasks, path_absolute, .ansible-lint-ignore, requirement docs).
Legacy narration comments in the touched files are deleted; the upstream
xwiki.properties reference block opts out via nocheck. The unused
MAILU_IP6_PUBLIC var (only ever named in now-removed comments) is dropped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 20:01:31 +02:00

201 lines
7.6 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.
The Keycloak group tree behind these paths is materialised by a
per-application group-ldap-mapper
(roles/web-app-keycloak/tasks/update/05_per_app_mappers.yml), anchored at
`/<group_root>/<application_id>` with `group.name.ldap.attribute=description`.
"""
from ansible.errors import AnsibleError
from ansible.plugins.loader import lookup_loader
from ansible.plugins.lookup import LookupBase
_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, loader=None, templar=None):
apps = lookup_loader.get("applications", loader=loader, templar=templar).run(
[], variables=variables
)[0]
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)
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:
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'."
)
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,
loader=getattr(self, "_loader", None),
templar=getattr(self, "_templar", None),
)
scope, axis = _resolve_role_scope(app_cfg, application_id, role)
group_root = _get_rbac_group_name(variables)
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}"]