Extract the csp, domains, and networks blocks out of roles/*/meta/server.yml
into dedicated meta files, each mapping to its own top-level applications key
via the recursive meta-walk:
csp: -> meta/csp.yml -> applications.<app>.csp
domains: -> meta/domains.yml -> applications.<app>.domains
networks: -> meta/networks.yml -> applications.<app>.networks
Residual nginx/proxy keys (status_codes, client_max_body_size, locations,
ip6_enabled, config_upstream_url) stay in server.yml; an emptied server.yml is
deleted. 98 server.yml -> 249 new files, 74 deleted.
- Consumers repointed applications.<app>.server.{csp,domains,networks} ->
applications.<app>.{csp,domains,networks} across templates, vars, tasks,
lookups and filters.
- Infra readers migrated: canonical_domains_map, application_domain_index,
build_ldap_role_entries, service-registry overlay discovery,
roles_with_service, and the file-based readers utils/domains/list.py +
utils/meta/scan.py (read the now-deleted server.yml directly).
- Detection + mapping: _META_TOPICS gains csp/domains/networks;
ROLE_FILE_META_{CSP,DOMAINS,NETWORKS} constants + ROLE_FILES entries.
- New self-expiring lint guard (test_server_meta_no_split_fields) fails if a
server.yml still carries a split field; auto-fails from 2026-12-28 as a
delete reminder.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
4.8 KiB
RBAC 🛂
This page describes the design of the Infinito.Nexus RBAC layer that provisions LDAP groups, synchronises them to Keycloak, and emits them as the OIDC groups claim.
For IAM fundamentals that span both providers, see common.md.
For the LDAP server layer, see ldap.md.
For the OIDC client layer, see oidc.md.
Operator-facing steps live in Administration RBAC.
LDAP layout contract 🌳
Every role that declares RBAC at the file root of its meta/rbac.yml MUST be provisioned with a per-application namespace under the container named by RBAC.GROUP.NAME (default roles). Each LDAP group entry is a direct child of that container; the application identifier (and the tenant identifier, if any) is encoded into the CN with hyphen separators:
cn=<application_id>-<role_name>,ou=roles,<LDAP_DN_BASE> # non-tenant / global role
cn=<application_id>-<tenant_id>-<role_name>,ou=roles,<LDAP_DN_BASE> # tenant-aware per-tenant
cn=<application_id>-<role_name>,ou=roles,<LDAP_DN_BASE> # tenant-aware scope=global
The producer build_ldap_role_entries also emits a per-application groupOfNames container cn=<application_id>,ou=roles,... whose member attribute references every role group of that application, so an LDAP-only consumer can still navigate the hierarchy. Keycloak does not rely on those containers; it reconstructs /roles/<application_id>/<role_name> via per-application mappers (see below).
rbac.tenancy schema 📐
rbac:
tenancy:
axis: "domain" # one of: "none" (default), "domain"
source: "domains.canonical" # Jinja path, only "domains.canonical" is implemented
roles:
<role_name>:
description: "..."
scope: "per_tenant" # one of: "per_tenant" (default), "global"
An application SHOULD opt in to axis: domain only when its authorisation model is genuinely per-tenant and the tenant axis is the canonical domain. Typical consumer: WordPress Multisite. Applications whose authorisation is one global user set (Nextcloud, Gitea, Keycloak itself) MUST keep axis: none.
rbac_group_path lookup plugin 🧭
rbac_group_path is the only sanctioned way to derive the OIDC group path for an application role. Consumers MUST call it:
# non-tenant app
"{{ lookup('rbac_group_path', application_id='web-app-yourls', role='administrator') }}"
# -> "roles/web-app-yourls/administrator"
# tenant-aware, per-tenant role
"{{ lookup('rbac_group_path', application_id='web-app-wordpress',
role='editor', tenant='blog.example') }}"
# -> "roles/web-app-wordpress/blog.example/editor"
# tenant-aware, scope=global role
"{{ lookup('rbac_group_path', application_id='web-app-wordpress',
role='network-administrator') }}"
# -> "roles/web-app-wordpress/network-administrator"
Inline [RBAC.GROUP.NAME, ...] | path_join MUST NOT be used: it scatters the path shape across dozens of files and makes future layout changes partial. Centralising the path in the plugin guarantees that a layout evolution stays a one-file change.
Keycloak group tree and the groups client scope 🎫
The Keycloak group tree is built by per-application group-ldap-mappers. Each deployed application gets one mapper anchored at /roles/<application_id> whose LDAP filter (&(objectClass=groupOfNames)(cn=<application_id>-*)) surfaces only that application's role groups. The mapper uses the LDAP description attribute as the visible Keycloak group name, so the resulting group paths carry no redundant prefix.
A shared mapper that imports every LDAP group with preserve.group.inheritance=true MUST NOT be used. Role names recur across applications (administrator, editor, ...) and Keycloak's GroupTreeResolver keys its lookup map on the group name, so the second administrator silently overwrites the first.
The groups client scope emits the OIDC groups claim with full.path=true, so consumers receive entries shaped like:
/roles/web-app-nextcloud/administrator
/roles/web-app-wordpress/<tenant>/<role>
/roles/web-app-wordpress/network-administrator
The scope MUST be a default client scope on every OIDC client that needs group-based RBAC.
Making it optional reintroduces a userinfo regression: when optional with include.in.token.scope=false, Keycloak drops the group-membership mapper from userinfo even when the scope is explicitly requested, so consumers receive no groups and fall back to subscriber.