From 26dfab147d4123112df36933c243fef48e972f46 Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Sat, 29 Nov 2025 17:40:45 +0100 Subject: [PATCH] Implement reserved username handling for users, LDAP and Keycloak MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add end-to-end support for reserved usernames and tighten CAPTCHA / Keycloak logic. Changes: - Makefile: rename EXTRA_USERS → RESERVED_USERNAMES and pass it as --reserved-usernames to the users defaults generator. - cli/build/defaults/users.py: propagate flag into generated users, add --reserved-usernames CLI option and mark listed accounts as reserved. - Add reserved_users filter plugin with and helpers for Ansible templates and tasks. - Add unit tests for reserved_users filters and the new reserved-usernames behaviour in the users defaults generator. - group_vars/all/00_general.yml: harden RECAPTCHA_ENABLED / HCAPTCHA_ENABLED checks with default('') and explicit > 0 length checks. - svc-db-openldap: introduce OPENLDAP_PROVISION_* flags, add OPENLDAP_PROVISION_RESERVED and OPERNLDAP_USERS to optionally exclude reserved users from provisioning. - svc-db-openldap templates/tasks: switch role/group LDIF and user import loops to use OPERNLDAP_USERS instead of the full users dict. - networks: assign dedicated subnet for web-app-roulette-wheel. - web-app-keycloak vars: compute KEYCLOAK_RESERVED_USERNAMES_LIST and KEYCLOAK_RESERVED_USERNAMES_REGEX from users | reserved_usernames. - web-app-keycloak user profile template: inject reserved-username regex into username validation pattern and improve error message, fix SSH public key attribute usage and add component name field. - web-app-keycloak update/_update.yml: strip subComponents from component payloads before update and disable async/poll for easier debugging. - web-app-keycloak tasks/main.yml: guard cleanup include with MODE_CLEANUP and keep reCAPTCHA update behind KEYCLOAK_RECAPTCHA_ENABLED. - user/users defaults: mark system/service accounts (root, daemon, mail, admin, webmaster, etc.) as reserved so they cannot be chosen as login names. - svc-prx-openresty vars: simplify OPENRESTY_CONTAINER lookup by dropping unused default parameter. - sys-ctl-rpr-btrfs-balancer: simplify main.yml by removing the extra block wrapper. - sys-daemon handlers: quote handler name for consistency. Context: change set discussed and refined in ChatGPT on 2025-11-29 (Infinito.Nexus reserved usernames & Keycloak user profile flow). See conversation: https://chatgpt.com/share/692b21f5-5d98-800f-8e15-1ded49deddc9 --- Makefile | 4 +- cli/build/defaults/users.py | 22 ++- filter_plugins/reserved_users.py | 53 ++++++++ group_vars/all/00_general.yml | 9 +- group_vars/all/09_networks.yml | 2 + roles/svc-db-openldap/config/main.yml | 15 ++- roles/svc-db-openldap/tasks/03_users.yml | 4 +- roles/svc-db-openldap/tasks/main.yml | 12 +- .../ldif/groups/01_rbac_roles.ldif.j2 | 2 +- roles/svc-db-openldap/vars/main.yml | 14 +- roles/svc-prx-openresty/vars/main.yml | 2 +- .../sys-ctl-rpr-btrfs-balancer/tasks/main.yml | 3 +- roles/sys-daemon/handlers/main.yml | 2 +- roles/user/users/main.yml | 42 ++++++ roles/web-app-keycloak/tasks/main.yml | 2 + .../web-app-keycloak/tasks/update/_update.yml | 17 ++- ...ak.userprofile.UserProfileProvider.json.j2 | 11 +- roles/web-app-keycloak/vars/main.yml | 4 + tests/unit/cli/build/defaults/test_users.py | 91 +++++++++++++ .../filter_plugins/test_reserved_users.py | 125 ++++++++++++++++++ 20 files changed, 400 insertions(+), 36 deletions(-) create mode 100644 filter_plugins/reserved_users.py create mode 100644 tests/unit/filter_plugins/test_reserved_users.py diff --git a/Makefile b/Makefile index 1b077c10..08d576a0 100644 --- a/Makefile +++ b/Makefile @@ -11,7 +11,7 @@ INCLUDE_GROUPS := $(shell python3 main.py meta categories invokable -s "-" --no- INCLUDES_OUT_DIR := ./tasks/groups # Compute extra users as before -EXTRA_USERS := $(shell \ +RESERVED_USERNAMES := $(shell \ find $(ROLES_DIR) -maxdepth 1 -type d -printf '%f\n' \ | sed -E 's/.*-//' \ | grep -E -x '[a-z0-9]+' \ @@ -50,7 +50,7 @@ messy-build: dockerignore python3 $(USERS_SCRIPT) \ --roles-dir $(ROLES_DIR) \ --output $(USERS_OUT) \ - --extra-users "$(EXTRA_USERS)" + --reserved-usernames "$(RESERVED_USERNAMES)" @echo "✅ Users defaults written to $(USERS_OUT)\n" @echo "🔧 Generating applications defaults → $(APPLICATIONS_OUT)…" diff --git a/cli/build/defaults/users.py b/cli/build/defaults/users.py index 1b66f0a9..cc7801d3 100644 --- a/cli/build/defaults/users.py +++ b/cli/build/defaults/users.py @@ -70,6 +70,7 @@ def build_users(defs, primary_domain, start_id, become_pwd): description = overrides.get('description') roles = overrides.get('roles', []) password = overrides.get('password', become_pwd) + reserved = overrides.get('reserved', False) # Determine UID and GID if 'uid' in overrides: @@ -89,6 +90,9 @@ def build_users(defs, primary_domain, start_id, become_pwd): if description is not None: entry['description'] = description + if reserved: + entry['reserved'] = reserved + users[key] = entry # Ensure uniqueness of usernames and emails @@ -180,8 +184,8 @@ def parse_args(): help='Starting UID/GID number (default: 1001).' ) parser.add_argument( - '--extra-users', '-e', - help='Comma-separated list of additional usernames to include.', + '--reserved-usernames', '-e', + help='Comma-separated list of usernames to reserve.', default=None ) return parser.parse_args() @@ -198,17 +202,21 @@ def main(): print(f"Error merging user definitions: {e}", file=sys.stderr) sys.exit(1) - # Add extra users if specified - if args.extra_users: - for name in args.extra_users.split(','): + # Add reserved/ users if specified + if args.reserved_usernames: + for name in args.reserved_usernames.split(','): user_key = name.strip() if not user_key: continue if user_key in definitions: - print(f"Warning: extra user '{user_key}' already defined; skipping.", file=sys.stderr) + print( + f"Warning: reserved user '{user_key}' already defined; skipping (not changing existing definition).", + file=sys.stderr + ) else: definitions[user_key] = {} - + # Mark user as reserved + definitions[user_key]["reserved"] = True try: users = build_users( definitions, diff --git a/filter_plugins/reserved_users.py b/filter_plugins/reserved_users.py new file mode 100644 index 00000000..6c4986be --- /dev/null +++ b/filter_plugins/reserved_users.py @@ -0,0 +1,53 @@ +from ansible.errors import AnsibleFilterError +import re + + +def reserved_usernames(users_dict): + """ + Return a list of usernames where reserved: true. + Usernames are regex-escaped to be safely embeddable. + """ + if not isinstance(users_dict, dict): + raise AnsibleFilterError("reserved_usernames expects a dictionary.") + + results = [] + + for _key, user in users_dict.items(): + if not isinstance(user, dict): + continue + if not user.get("reserved", False): + continue + username = user.get("username") + if username: + results.append(re.escape(str(username))) + + return results + + +def non_reserved_users(users_dict): + """ + Return a dict of users where reserved != true. + """ + if not isinstance(users_dict, dict): + raise AnsibleFilterError("non_reserved_users expects a dictionary.") + + results = {} + + for key, user in users_dict.items(): + if not isinstance(user, dict): + continue + if user.get("reserved", False): + continue + results[key] = user + + return results + + +class FilterModule(object): + """User filters for extracting reserved and non-reserved subsets.""" + + def filters(self): + return { + "reserved_usernames": reserved_usernames, + "non_reserved_users": non_reserved_users, + } diff --git a/group_vars/all/00_general.yml b/group_vars/all/00_general.yml index 84c3a53e..f945353c 100644 --- a/group_vars/all/00_general.yml +++ b/group_vars/all/00_general.yml @@ -98,5 +98,10 @@ CAPTCHA: KEY: "" SECRET: "" -RECAPTCHA_ENABLED: "{{ CAPTCHA.RECAPTCHA.KEY | default('') | length and CAPTCHA.RECAPTCHA.SECRET | default('') | length }}" -HCAPTCHA_ENABLED: "{{ CAPTCHA.HCAPTCHA.KEY | default('') | length and CAPTCHA.HCAPTCHA.SECRET | default('') | length }}" +RECAPTCHA_ENABLED: "{{ (CAPTCHA.RECAPTCHA.KEY | default('') | length > 0) + and + (CAPTCHA.RECAPTCHA.SECRET | default('') | length > 0) }}" + +HCAPTCHA_ENABLED: "{{ (CAPTCHA.HCAPTCHA.KEY | default('') | length > 0) + and + (CAPTCHA.HCAPTCHA.SECRET | default('') | length > 0) }}" diff --git a/group_vars/all/09_networks.yml b/group_vars/all/09_networks.yml index 940fc03b..1110e565 100644 --- a/group_vars/all/09_networks.yml +++ b/group_vars/all/09_networks.yml @@ -122,6 +122,8 @@ defaults_networks: subnet: 192.168.104.112/28 web-app-littlejs: subnet: 192.168.104.128/28 + web-app-roulette-wheel: + subnet: 192.168.104.144/28 # /24 Networks / 254 Usable Clients web-app-bigbluebutton: diff --git a/roles/svc-db-openldap/config/main.yml b/roles/svc-db-openldap/config/main.yml index cc5fed41..b54d7cd2 100644 --- a/roles/svc-db-openldap/config/main.yml +++ b/roles/svc-db-openldap/config/main.yml @@ -18,12 +18,13 @@ docker: data: "openldap_data" features: ldap: true -provisioning: +provision: # Here it's possible to define what should be imported and updated. # It doesn't make sense to let the import run everytime because its very time consuming - configuration: true # E.g. MemberOf and Hashed Password Configuration - credentials: true # Administrator Password - schemas: true # E.g. Nextcloud, Openssl - users: true # E.g. User, group and role entries - groups: true # Roles and Groups import - update: true # User Class updates + configuration: true # E.g. MemberOf and Hashed Password Configuration + credentials: true # Administrator Password + schemas: true # E.g. Nextcloud, Openssl + users: true # E.g. User, group and role entries + groups: true # Roles and Groups import + update: true # User Class updates + reserved: false # Reserved Users aren't provisioned diff --git a/roles/svc-db-openldap/tasks/03_users.yml b/roles/svc-db-openldap/tasks/03_users.yml index a3ed5c9b..e7204e4b 100644 --- a/roles/svc-db-openldap/tasks/03_users.yml +++ b/roles/svc-db-openldap/tasks/03_users.yml @@ -20,7 +20,7 @@ state: present # ↳ creates but never updates async: "{{ ASYNC_TIME if ASYNC_ENABLED | bool else omit }}" poll: "{{ ASYNC_POLL if ASYNC_ENABLED | bool else omit }}" - loop: "{{ users | dict2items }}" + loop: "{{ OPERNLDAP_USERS | dict2items }}" loop_control: label: "{{ item.key }}" @@ -39,7 +39,7 @@ state: exact async: "{{ ASYNC_TIME if ASYNC_ENABLED | bool else omit }}" poll: "{{ ASYNC_POLL if ASYNC_ENABLED | bool else omit }}" - loop: "{{ users | dict2items }}" + loop: "{{ OPERNLDAP_USERS | dict2items }}" loop_control: label: "{{ item.key }}" diff --git a/roles/svc-db-openldap/tasks/main.yml b/roles/svc-db-openldap/tasks/main.yml index 52720dd1..a987bad0 100644 --- a/roles/svc-db-openldap/tasks/main.yml +++ b/roles/svc-db-openldap/tasks/main.yml @@ -38,7 +38,7 @@ include_tasks: 01_credentials.yml when: - OPENLDAP_NETWORK_SWITCH_LOCAL | bool - - applications | get_app_conf(application_id, 'provisioning.credentials') + - OPENLDAP_PROVISION_CREDENTIALS | bool - name: "create directory {{ OPENLDAP_LDIF_PATH_HOST }}{{ item }}" file: @@ -53,7 +53,7 @@ - configuration loop_control: loop_var: folder - when: applications | get_app_conf(application_id, 'provisioning.configuration') + when: OPENLDAP_PROVISION_CONFIGURATION | bool - name: flush LDIF handlers meta: flush_handlers @@ -66,11 +66,11 @@ - name: "Include Schemas (if enabled)" include_tasks: 02_schemas.yml - when: applications | get_app_conf(application_id, 'provisioning.schemas') + when: OPENLDAP_PROVISION_SCHEMAS | bool - name: "Import LDAP Entries (if enabled)" include_tasks: 03_users.yml - when: applications | get_app_conf(application_id, 'provisioning.users') + when: OPENLDAP_PROVISION_USERS | bool - name: "Import LDIF Data (if enabled)" include_tasks: _ldifs_creation.yml @@ -78,10 +78,10 @@ - groups loop_control: loop_var: folder - when: applications | get_app_conf(application_id, 'provisioning.groups') + when: OPENLDAP_PROVISION_GROUPS | bool - meta: flush_handlers - name: "Add Objects to all users" include_tasks: 04_update.yml - when: applications | get_app_conf(application_id, 'provisioning.update') \ No newline at end of file + when: OPENLDAP_PROVISION_UPDATE | bool \ No newline at end of file diff --git a/roles/svc-db-openldap/templates/ldif/groups/01_rbac_roles.ldif.j2 b/roles/svc-db-openldap/templates/ldif/groups/01_rbac_roles.ldif.j2 index e56a0935..fa1e49e6 100644 --- a/roles/svc-db-openldap/templates/ldif/groups/01_rbac_roles.ldif.j2 +++ b/roles/svc-db-openldap/templates/ldif/groups/01_rbac_roles.ldif.j2 @@ -1,4 +1,4 @@ -{% for dn, entry in (applications | build_ldap_role_entries(users, LDAP)).items() %} +{% for dn, entry in (applications | build_ldap_role_entries(OPERNLDAP_USERS, LDAP)).items() %} dn: {{ dn }} {% for oc in entry.objectClass %} diff --git a/roles/svc-db-openldap/vars/main.yml b/roles/svc-db-openldap/vars/main.yml index 1c7be164..50237d84 100644 --- a/roles/svc-db-openldap/vars/main.yml +++ b/roles/svc-db-openldap/vars/main.yml @@ -24,4 +24,16 @@ OPENLDAP_NETWORK: "{{ applications | get_app_conf(application_id, # Network OPENLDAP_NETWORK_SWITCH_PUBLIC: "{{ applications | get_app_conf(application_id, 'network.public') }}" OPENLDAP_NETWORK_SWITCH_LOCAL: "{{ applications | get_app_conf(application_id, 'network.local') }}" -OPENLDAP_NETWORK_EXPOSE_LOCAL: "{{ OPENLDAP_NETWORK_SWITCH_PUBLIC | bool or OPENLDAP_NETWORK_SWITCH_LOCAL | bool }}" \ No newline at end of file +OPENLDAP_NETWORK_EXPOSE_LOCAL: "{{ OPENLDAP_NETWORK_SWITCH_PUBLIC | bool or OPENLDAP_NETWORK_SWITCH_LOCAL | bool }}" + +# Provision +OPENLDAP_PROVISION_CONFIGURATION: "{{ applications | get_app_conf(application_id, 'provision.configuration') }}" +OPENLDAP_PROVISION_CREDENTIALS: "{{ applications | get_app_conf(application_id, 'provision.credentials') }}" +OPENLDAP_PROVISION_SCHEMAS: "{{ applications | get_app_conf(application_id, 'provision.schemas') }}" +OPENLDAP_PROVISION_USERS: "{{ applications | get_app_conf(application_id, 'provision.users') }}" +OPENLDAP_PROVISION_GROUPS: "{{ applications | get_app_conf(application_id, 'provision.groups') }}" +OPENLDAP_PROVISION_UPDATE: "{{ applications | get_app_conf(application_id, 'provision.update') }}" +OPENLDAP_PROVISION_RESERVED: "{{ applications | get_app_conf(application_id, 'provision.reserved') }}" + +# Users to be processed by LDAP +OPERNLDAP_USERS: "{{ users if OPENLDAP_PROVISION_RESERVED else users | non_reserved_users }}" \ No newline at end of file diff --git a/roles/svc-prx-openresty/vars/main.yml b/roles/svc-prx-openresty/vars/main.yml index 1d906523..774589c9 100644 --- a/roles/svc-prx-openresty/vars/main.yml +++ b/roles/svc-prx-openresty/vars/main.yml @@ -7,4 +7,4 @@ database_type: "" # Openresty OPENRESTY_IMAGE: "openresty/openresty" OPENRESTY_VERSION: "alpine" -OPENRESTY_CONTAINER: "{{ applications | get_app_conf(application_id, 'docker.services.openresty.name', True) }}" +OPENRESTY_CONTAINER: "{{ applications | get_app_conf(application_id, 'docker.services.openresty.name') }}" diff --git a/roles/sys-ctl-rpr-btrfs-balancer/tasks/main.yml b/roles/sys-ctl-rpr-btrfs-balancer/tasks/main.yml index cdc7f450..672528c9 100644 --- a/roles/sys-ctl-rpr-btrfs-balancer/tasks/main.yml +++ b/roles/sys-ctl-rpr-btrfs-balancer/tasks/main.yml @@ -1,3 +1,2 @@ -- block: - - include_tasks: 01_core.yml +- include_tasks: 01_core.yml when: run_once_sys_ctl_rpr_btrfs_balancer is not defined diff --git a/roles/sys-daemon/handlers/main.yml b/roles/sys-daemon/handlers/main.yml index 72b3469a..addbe80b 100644 --- a/roles/sys-daemon/handlers/main.yml +++ b/roles/sys-daemon/handlers/main.yml @@ -34,7 +34,7 @@ - reload system daemon - reexec systemd manager -- name: reload system daemon +- name: "reload system daemon" ansible.builtin.systemd: daemon_reload: true become: true diff --git a/roles/user/users/main.yml b/roles/user/users/main.yml index b66cf700..30843526 100644 --- a/roles/user/users/main.yml +++ b/roles/user/users/main.yml @@ -3,127 +3,169 @@ users: sld: description: "Auto Generated Account to reserve the SLD" username: "{{ PRIMARY_DOMAIN.split('.')[0] }}" + reserved: true tld: description: "Auto Generated Account to reserve the TLD" username: "{{ PRIMARY_DOMAIN.split('.')[1] if (PRIMARY_DOMAIN is defined and (PRIMARY_DOMAIN.split('.') | length) > 1) else (PRIMARY_DOMAIN ~ '_tld ') }}" + reserved: true root: username: root uid: 0 gid: 0 description: "System superuser" + reserved: true daemon: username: daemon description: "Daemon processes owner" + reserved: true bin: username: bin description: "Owner of essential binaries" + reserved: true sys: username: sys description: "System files owner" + reserved: true sync: username: sync description: "Sync user for filesystem synchronization" + reserved: true games: username: games description: "Games and educational software owner" + reserved: true man: username: man description: "Manual pages viewer" + reserved: true lp: username: lp description: "Printer spooler" + reserved: true mail: username: mail description: "Mail system" + reserved: true news: username: news description: "Network news system" + reserved: true uucp: username: uucp description: "UUCP system" + reserved: true proxy: username: proxy description: "Proxy user" + reserved: true backup: username: backup description: "Backup operator" + reserved: true list: username: list description: "Mailing list manager" + reserved: true irc: username: irc description: "IRC services user" + reserved: true gnats: username: gnats description: "GNATS bug-reporting system" + reserved: true nobody: username: nobody description: "Unprivileged user" + reserved: true messagebus: username: messagebus description: "D-Bus message bus system" + reserved: true sshd: username: sshd description: "SSH daemon" + reserved: true rpc: username: rpc description: "Rpcbind daemon" + reserved: true ftp: username: ftp description: "FTP server" + reserved: true postfix: username: postfix description: "Postfix mail transfer agent" + reserved: true mysql: username: mysql description: "MySQL database server" + reserved: true mongodb: username: mongodb description: "MongoDB database server" + reserved: true admin: username: admin description: "Generic reserved username" + reserved: true administrator: username: administrator + reserved: true user: username: user description: "Generic reserved username" + reserved: true test: username: test description: "Generic reserved username" + reserved: true guest: username: guest description: "Generic reserved username" + reserved: true demo: username: demo description: "Generic reserved username" + reserved: true info: username: info description: "Generic reserved username" + reserved: true support: username: support description: "Generic reserved username" + reserved: true helpdesk: username: helpdesk description: "Generic reserved username" + reserved: true operator: username: operator description: "Generic reserved username" + reserved: true staff: username: staff description: "Generic reserved username" + reserved: true smtp: username: smtp description: "Generic reserved username" + reserved: true imap: username: imap description: "Generic reserved username" + reserved: true pop: username: pop description: "Generic reserved username" + reserved: true webmaster: username: webmaster description: "Generic reserved username" + reserved: true mailman: username: mailman description: "Generic reserved username" + reserved: true diff --git a/roles/web-app-keycloak/tasks/main.yml b/roles/web-app-keycloak/tasks/main.yml index bef8ef20..8ff1ad21 100644 --- a/roles/web-app-keycloak/tasks/main.yml +++ b/roles/web-app-keycloak/tasks/main.yml @@ -5,6 +5,7 @@ - name: "Load cleanup routine for '{{ application_id }}'" include_tasks: 02_cleanup.yml + when: MODE_CLEANUP | bool - name: "Load init routine for '{{ application_id }}'" include_tasks: 03_init.yml @@ -35,3 +36,4 @@ - name: "Load reCAPTCHA Update routines for '{{ application_id }}'" include_tasks: update/06_recaptcha.yml when: KEYCLOAK_RECAPTCHA_ENABLED | bool + diff --git a/roles/web-app-keycloak/tasks/update/_update.yml b/roles/web-app-keycloak/tasks/update/_update.yml index eb74c492..453dc70d 100644 --- a/roles/web-app-keycloak/tasks/update/_update.yml +++ b/roles/web-app-keycloak/tasks/update/_update.yml @@ -146,6 +146,19 @@ }} no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}" +- name: Drop unsupported fields for components (e.g. subComponents) + when: kc_object_kind == 'component' + set_fact: + desired_obj: >- + {{ + desired_obj + | dict2items + | rejectattr('key', 'equalto', 'subComponents') + | list + | items2dict + }} + no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}" + - name: Preserve immutable fields for client-scope when: kc_object_kind == 'client-scope' set_fact: @@ -181,5 +194,5 @@ {{ desired_obj | to_json }} JSON {%- endif %} - async: "{{ ASYNC_TIME if ASYNC_ENABLED | bool else omit }}" - poll: "{{ ASYNC_POLL if ASYNC_ENABLED | bool else omit }}" + #async: "{{ ASYNC_TIME if ASYNC_ENABLED | bool else omit }}" + #poll: "{{ ASYNC_POLL if ASYNC_ENABLED | bool else omit }}" diff --git a/roles/web-app-keycloak/templates/import/components/org.keycloak.userprofile.UserProfileProvider.json.j2 b/roles/web-app-keycloak/templates/import/components/org.keycloak.userprofile.UserProfileProvider.json.j2 index 213655cb..51073d09 100644 --- a/roles/web-app-keycloak/templates/import/components/org.keycloak.userprofile.UserProfileProvider.json.j2 +++ b/roles/web-app-keycloak/templates/import/components/org.keycloak.userprofile.UserProfileProvider.json.j2 @@ -3,7 +3,13 @@ { "name": "username", "displayName": "${username}", - "validations": {"length": {"min": 3, "max": 255}, "pattern": {"pattern": "^[a-z0-9]+$", "error-message": ""}}, + "validations": { + "length": { "min": 3, "max": 255 }, + "pattern": { + "pattern": "^(?!(?:" ~ KEYCLOAK_RESERVED_USERNAMES_REGEX | replace('\\', '\\\\') ~ ")$)[a-z0-9]+$", + "error-message": "Username is reserved or contains invalid characters. Only lowercase letters (a–z) and digits (0–9) are allowed." + } + }, "annotations": {}, "permissions": {"view": ["admin","user"], "edit": ["admin","user"]}, "multivalued": false @@ -33,7 +39,7 @@ "multivalued": false }, { - "name": "{{ LDAP.USER.ATTRIBUTES.SSH_PUBLIC_KEY }}", + "name": LDAP.USER.ATTRIBUTES.SSH_PUBLIC_KEY, "displayName": "SSH Public Key", "validations": {}, "annotations": {}, @@ -53,6 +59,7 @@ "org.keycloak.userprofile.UserProfileProvider": [ { "providerId": "declarative-user-profile", + "name": "declarative-user-profile", "subComponents": {}, "config": { "kc.user.profile.config": [{{ (user_profile | to_json) | to_json }}] diff --git a/roles/web-app-keycloak/vars/main.yml b/roles/web-app-keycloak/vars/main.yml index d01510cb..4f4dab6f 100644 --- a/roles/web-app-keycloak/vars/main.yml +++ b/roles/web-app-keycloak/vars/main.yml @@ -18,6 +18,10 @@ KEYCLOAK_DOMAIN: "{{ domains | get_domain('web-app-keycloak') KEYCLOAK_RBAC_GROUP_CLAIM: "{{ RBAC.GROUP.CLAIM }}" KEYCLOAK_RBAC_GROUP_NAME: "{{ RBAC.GROUP.NAME }}" +# Users +KEYCLOAK_RESERVED_USERNAMES_LIST: "{{ users | reserved_usernames }}" +KEYCLOAK_RESERVED_USERNAMES_REGEX: "{{ KEYCLOAK_RESERVED_USERNAMES_LIST | join('|') }}" + ## Health KEYCLOAK_HEALTH_ENABLED: true diff --git a/tests/unit/cli/build/defaults/test_users.py b/tests/unit/cli/build/defaults/test_users.py index 2f58502d..7cdcba9c 100644 --- a/tests/unit/cli/build/defaults/test_users.py +++ b/tests/unit/cli/build/defaults/test_users.py @@ -248,6 +248,97 @@ class TestGenerateUsers(unittest.TestCase): finally: shutil.rmtree(tmpdir) + def test_build_users_reserved_flag_propagated(self): + """ + Ensure that the 'reserved' flag from the definitions is copied + into the final user entries, and is not added for non-reserved users. + """ + defs = { + "admin": {"reserved": True}, + "bob": {}, + } + + build = users.build_users( + defs=defs, + primary_domain="example.com", + start_id=1001, + become_pwd="pw", + ) + + # Reserved user should carry the flag + self.assertIn("reserved", build["admin"]) + self.assertTrue(build["admin"]["reserved"]) + + # Non-reserved user should not have the flag at all + self.assertNotIn("reserved", build["bob"]) + + def test_cli_reserved_usernames_flag_sets_reserved_field(self): + """ + Verify that --reserved-usernames marks given usernames as reserved + in the generated YAML, and that existing definitions are preserved + (only 'reserved' is added). + """ + import tempfile + import subprocess + from pathlib import Path + + tmpdir = Path(tempfile.mkdtemp()) + try: + roles_dir = tmpdir / "roles" + roles_dir.mkdir() + + # Role with an existing user definition "admin" + (roles_dir / "role-base" / "users").mkdir(parents=True, exist_ok=True) + with open(roles_dir / "role-base" / "users" / "main.yml", "w") as f: + yaml.safe_dump( + { + "users": { + "admin": { + "email": "admin@ex", + "description": "Admin from role", + } + } + }, + f, + ) + + out_file = tmpdir / "users.yml" + script_path = Path(__file__).resolve().parents[5] / "cli" / "build" / "defaults" / "users.py" + + result = subprocess.run( + [ + "python3", + str(script_path), + "--roles-dir", + str(roles_dir), + "--output", + str(out_file), + "--reserved-usernames", + "admin,service", + ], + capture_output=True, + text=True, + ) + self.assertEqual(result.returncode, 0, msg=result.stderr) + self.assertTrue(out_file.exists(), "Output file was not created.") + + data = yaml.safe_load(out_file.read_text()) + self.assertIn("default_users", data) + users_map = data["default_users"] + + # "service" was created from the reserved list and must be reserved + self.assertIn("service", users_map) + self.assertTrue(users_map["service"].get("reserved", False)) + + # "admin" existed before; its fields must remain unchanged, + # but it must now be marked as reserved + self.assertIn("admin", users_map) + self.assertEqual(users_map["admin"]["email"], "admin@ex") + self.assertEqual(users_map["admin"]["description"], "Admin from role") + self.assertTrue(users_map["admin"].get("reserved", False)) + + finally: + shutil.rmtree(tmpdir) if __name__ == '__main__': unittest.main() diff --git a/tests/unit/filter_plugins/test_reserved_users.py b/tests/unit/filter_plugins/test_reserved_users.py new file mode 100644 index 00000000..5173259c --- /dev/null +++ b/tests/unit/filter_plugins/test_reserved_users.py @@ -0,0 +1,125 @@ +# tests/unit/filter_plugins/test_reserved_users.py + +import os +import sys +import unittest + +# Ensure that the filter_plugins directory is importable +CURRENT_DIR = os.path.dirname(__file__) +REPO_ROOT = os.path.abspath(os.path.join(CURRENT_DIR, "..", "..", "..")) +FILTER_PLUGINS_DIR = os.path.join(REPO_ROOT, "filter_plugins") + +if FILTER_PLUGINS_DIR not in sys.path: + sys.path.insert(0, FILTER_PLUGINS_DIR) + +import reserved_users # noqa: E402 +from reserved_users import reserved_usernames, non_reserved_users # noqa: E402 +from ansible.errors import AnsibleFilterError # type: ignore # noqa: E402 + + +class TestReservedUsersFilters(unittest.TestCase): + def setUp(self): + # Minimal sample user dict similar to your defaults + self.users = { + "admin": { + "username": "admin", + "reserved": True, + "uid": 1001, + }, + "backup": { + "username": "backup", + "reserved": True, + "uid": 1002, + }, + "kevin": { + "username": "kevin", + "reserved": False, + "uid": 2001, + }, + "service.user": { + "username": "service.user", + "reserved": True, + "uid": 3001, + }, + "no_username_field": { + "reserved": True, + "uid": 4001, + }, + "not_a_dict": "invalid", + } + + # -------- reserved_usernames tests -------- + + def test_reserved_usernames_requires_dict(self): + with self.assertRaises(AnsibleFilterError): + reserved_usernames(["not", "a", "dict"]) + + def test_reserved_usernames_returns_only_reserved(self): + result = reserved_usernames(self.users) + # Escaped regex strings + self.assertIn("admin", result) + self.assertIn("backup", result) + self.assertIn("service\\.user", result) + + # Non-reserved user must not be included + self.assertNotIn("kevin", result) + + def test_reserved_usernames_ignores_entries_without_username(self): + result = reserved_usernames(self.users) + # "no_username_field" has no username -> must not be present + # There is no raw 'no_username_field' username at all + for item in result: + self.assertNotIn("no_username_field", item) + + def test_reserved_usernames_escapes_special_chars(self): + result = reserved_usernames(self.users) + # service.user → service\.user + self.assertIn("service\\.user", result) + self.assertNotIn("service.user", result) + + def test_reserved_usernames_empty_dict(self): + result = reserved_usernames({}) + self.assertEqual(result, []) + + # -------- non_reserved_users tests -------- + + def test_non_reserved_users_requires_dict(self): + with self.assertRaises(AnsibleFilterError): + non_reserved_users("not-a-dict") + + def test_non_reserved_users_returns_only_non_reserved(self): + result = non_reserved_users(self.users) + # Must be a dict + self.assertIsInstance(result, dict) + + # Only "kevin" is non-reserved in our sample + self.assertIn("kevin", result) + self.assertNotIn("admin", result) + self.assertNotIn("backup", result) + self.assertNotIn("service.user", result) # key is "service.user" but reserved=True + + def test_non_reserved_users_ignores_non_dict_entries(self): + result = non_reserved_users(self.users) + # "not_a_dict" entry must be skipped + self.assertNotIn("not_a_dict", result) + + def test_non_reserved_users_empty_dict(self): + result = non_reserved_users({}) + self.assertEqual(result, {}) + + # -------- FilterModule registration tests -------- + + def test_filtermodule_registers_filters(self): + fm = reserved_users.FilterModule() + filters = fm.filters() + + self.assertIn("reserved_usernames", filters) + self.assertIn("non_reserved_users", filters) + + # Basic sanity: they must be callables + self.assertTrue(callable(filters["reserved_usernames"])) + self.assertTrue(callable(filters["non_reserved_users"])) + + +if __name__ == "__main__": + unittest.main()