Files
computer-playbook/tests/unit/cli/build/defaults/test_users.py
Kevin Veen-Birkenbach 26dfab147d 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
2025-11-29 17:40:45 +01:00

345 lines
12 KiB
Python

import os
import sys
import unittest
import tempfile
import shutil
import yaml
from collections import OrderedDict
# Add cli/ to import path
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../../..", "cli/build/defaults/")))
import users
class TestGenerateUsers(unittest.TestCase):
def test_build_users_auto_increment_and_overrides(self):
defs = {
'alice': {},
'bob': {'uid': 2000, 'email': 'bob@custom.com', 'description': 'Custom user'},
'carol': {}
}
build = users.build_users(
defs=defs,
primary_domain='example.com',
start_id=1001,
become_pwd='pw'
)
# alice should get uid/gid 1001
self.assertEqual(build['alice']['uid'], 1001)
self.assertEqual(build['alice']['gid'], 1001)
self.assertEqual(build['alice']['email'], 'alice@example.com')
# bob overrides
self.assertEqual(build['bob']['uid'], 2000)
self.assertEqual(build['bob']['gid'], 2000)
self.assertEqual(build['bob']['email'], 'bob@custom.com')
self.assertIn('description', build['bob'])
# carol should get next free id = 1002
self.assertEqual(build['carol']['uid'], 1002)
self.assertEqual(build['carol']['gid'], 1002)
def test_build_users_default_lookup_password(self):
"""
When no 'password' override is provided,
the become_pwd lookup template string must be used as the password.
"""
defs = {'frank': {}}
lookup_template = '{{ lookup("password", "/dev/null length=42 chars=ascii_letters,digits") }}'
build = users.build_users(
defs=defs,
primary_domain='example.com',
start_id=1001,
become_pwd=lookup_template
)
self.assertEqual(
build['frank']['password'],
lookup_template,
"The lookup template string was not correctly applied as the default password"
)
def test_build_users_override_password(self):
"""
When a 'password' override is provided,
that custom password must be used instead of become_pwd.
"""
defs = {'eva': {'password': 'custompw'}}
lookup_template = '{{ lookup("password", "/dev/null length=42 chars=ascii_letters,digits") }}'
build = users.build_users(
defs=defs,
primary_domain='example.com',
start_id=1001,
become_pwd=lookup_template
)
self.assertEqual(
build['eva']['password'],
'custompw',
"The override password was not correctly applied"
)
def test_build_users_duplicate_override_uid(self):
defs = {
'u1': {'uid': 1001},
'u2': {'uid': 1001}
}
with self.assertRaises(ValueError):
users.build_users(defs, 'ex.com', 1001, 'pw')
def test_build_users_shared_gid_allowed(self):
# Allow two users to share the same GID when one overrides gid and the other uses that as uid
defs = {
'a': {'uid': 1500},
'b': {'gid': 1500}
}
build = users.build_users(defs, 'ex.com', 1500, 'pw')
# Both should have gid 1500
self.assertEqual(build['a']['gid'], 1500)
self.assertEqual(build['b']['gid'], 1500)
def test_build_users_duplicate_username_email(self):
defs = {
'u1': {'username': 'same', 'email': 'same@ex.com'},
'u2': {'username': 'same'}
}
# second user with same username should raise
with self.assertRaises(ValueError):
users.build_users(defs, 'ex.com', 1001, 'pw')
def test_dictify_converts_ordereddict(self):
od = users.OrderedDict([('a', 1), ('b', {'c': 2})])
result = users.dictify(OrderedDict(od))
self.assertIsInstance(result, dict)
self.assertEqual(result, {'a': 1, 'b': {'c': 2}})
def test_load_user_defs_and_conflict(self):
# create temp roles structure
tmp = tempfile.mkdtemp()
try:
os.makedirs(os.path.join(tmp, 'role1/users'))
os.makedirs(os.path.join(tmp, 'role2/users'))
# role1 defines user x
with open(os.path.join(tmp, 'role1/users/main.yml'), 'w') as f:
yaml.safe_dump({'users': {'x': {'email': 'x@a'}}}, f)
# role2 defines same user x with same value
with open(os.path.join(tmp, 'role2/users/main.yml'), 'w') as f:
yaml.safe_dump({'users': {'x': {'email': 'x@a'}}}, f)
defs = users.load_user_defs(tmp)
self.assertIn('x', defs)
# now conflict definition
with open(os.path.join(tmp, 'role2/users/main.yml'), 'w') as f:
yaml.safe_dump({'users': {'x': {'email': 'x@b'}}}, f)
with self.assertRaises(ValueError):
users.load_user_defs(tmp)
finally:
shutil.rmtree(tmp)
def test_cli_users_sorted_by_key(self):
"""
Ensure that default_users keys are written in alphabetical order.
"""
import tempfile
import subprocess
from pathlib import Path
tmpdir = Path(tempfile.mkdtemp())
try:
roles_dir = tmpdir / "roles"
roles_dir.mkdir()
# Create multiple roles with users in unsorted order
for role, users_map in [
("role-zeta", {"zeta": {"email": "z@ex"}}),
("role-alpha", {"alpha": {"email": "a@ex"}}),
("role-mu", {"mu": {"email": "m@ex"}}),
("role-beta", {"beta": {"email": "b@ex"}}),
]:
(roles_dir / role / "users").mkdir(parents=True, exist_ok=True)
with open(roles_dir / role / "users" / "main.yml", "w") as f:
yaml.safe_dump({"users": users_map}, f)
out_file = tmpdir / "users.yml"
# Resolve script path like in other tests (relative to repo root)
script_path = Path(__file__).resolve().parents[5] / "cli" / "build" / "defaults" / "users.py"
# Run generator
result = subprocess.run(
["python3", str(script_path),
"--roles-dir", str(roles_dir),
"--output", str(out_file)],
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"]
keys_in_file = list(users_map.keys())
# Expect alphabetical order
self.assertEqual(
keys_in_file, sorted(keys_in_file),
msg=f"Users are not sorted alphabetically: {keys_in_file}"
)
# Sanity: all expected keys present
for k in ["alpha", "beta", "mu", "zeta"]:
self.assertIn(k, users_map)
finally:
shutil.rmtree(tmpdir)
def test_cli_users_sorting_stable_across_runs(self):
"""
Running the generator multiple times yields identical content (stable sort).
"""
import tempfile
import subprocess
from pathlib import Path
tmpdir = Path(tempfile.mkdtemp())
try:
roles_dir = tmpdir / "roles"
roles_dir.mkdir()
# Unsorted creation order on purpose
cases = [
("role-d", {"duser": {"email": "d@ex"}}),
("role-a", {"auser": {"email": "a@ex"}}),
("role-c", {"cuser": {"email": "c@ex"}}),
("role-b", {"buser": {"email": "b@ex"}}),
]
for role, users_map in cases:
(roles_dir / role / "users").mkdir(parents=True, exist_ok=True)
with open(roles_dir / role / "users" / "main.yml", "w") as f:
yaml.safe_dump({"users": users_map}, f)
out_file = tmpdir / "users.yml"
script_path = Path(__file__).resolve().parents[5] / "cli" / "build" / "defaults" / "users.py"
# First run
r1 = subprocess.run(
["python3", str(script_path),
"--roles-dir", str(roles_dir),
"--output", str(out_file)],
capture_output=True, text=True
)
self.assertEqual(r1.returncode, 0, msg=r1.stderr)
content1 = out_file.read_text()
# Touch dirs to shuffle filesystem mtimes
for p in roles_dir.iterdir():
os.utime(p, None)
# Second run
r2 = subprocess.run(
["python3", str(script_path),
"--roles-dir", str(roles_dir),
"--output", str(out_file)],
capture_output=True, text=True
)
self.assertEqual(r2.returncode, 0, msg=r2.stderr)
content2 = out_file.read_text()
self.assertEqual(
content1, content2,
msg="Output differs between runs; user sorting should be stable."
)
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()