mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2025-11-30 08:06:47 +00:00
Implement reserved username handling for users, LDAP and Keycloak
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
This commit is contained in:
4
Makefile
4
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)…"
|
||||
|
||||
@@ -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,
|
||||
|
||||
53
filter_plugins/reserved_users.py
Normal file
53
filter_plugins/reserved_users.py
Normal file
@@ -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,
|
||||
}
|
||||
@@ -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) }}"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }}"
|
||||
|
||||
|
||||
@@ -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')
|
||||
when: OPENLDAP_PROVISION_UPDATE | bool
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 }}"
|
||||
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 }}"
|
||||
@@ -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') }}"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 }}"
|
||||
|
||||
@@ -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 }}]
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
125
tests/unit/filter_plugins/test_reserved_users.py
Normal file
125
tests/unit/filter_plugins/test_reserved_users.py
Normal file
@@ -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()
|
||||
Reference in New Issue
Block a user