mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-06 02:54:26 +00:00
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>
201 lines
7.6 KiB
Python
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}"]
|