From ee0561db72eaebae76e9bc66b4808eab17a8bc74 Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Fri, 4 Jul 2025 08:03:27 +0200 Subject: [PATCH] Optimized RBAC via LDAP --- cli/generate_users.py | 137 ++++++++++-------- group_vars/all/13_ldap.yml | 7 +- main.py | 6 +- roles/__init__.py | 0 roles/docker-espocrm/meta/users.yml | 5 +- roles/docker-espocrm/vars/configuration.yml | 4 +- roles/docker-espocrm/vars/main.yml | 4 +- roles/docker-ldap/__init__.py | 0 roles/docker-ldap/docs/Administration.md | 15 +- roles/docker-ldap/filter_plugins/__init__.py | 0 .../filter_plugins/build_ldap_role_entries.py | 64 ++++++++ roles/docker-ldap/handlers/main.yml | 2 +- roles/docker-ldap/tasks/add_user_objects.yml | 2 + roles/docker-ldap/tasks/create_ldif_files.yml | 8 +- roles/docker-ldap/tasks/main.yml | 4 + .../ldif/data/01_application_roles.ldif.j2 | 30 ---- .../templates/ldif/data/01_rbac.ldif.j2 | 23 +++ roles/docker-listmonk/meta/users.yml | 6 +- .../docker-mailu/tasks/create-mailu-user.yml | 4 +- roles/docker-mailu/tasks/main.yml | 1 - roles/docker-mailu/vars/configuration.yml | 4 + roles/docker-nextcloud/meta/users.yml | 3 +- .../docker-portfolio/templates/config.yaml.j2 | 2 +- tests/unit/roles/docker-ldap/__init__.py | 0 .../test_build_ldap_role_entries.py | 96 ++++++++++++ 25 files changed, 316 insertions(+), 111 deletions(-) create mode 100644 roles/__init__.py create mode 100644 roles/docker-ldap/__init__.py create mode 100644 roles/docker-ldap/filter_plugins/__init__.py create mode 100644 roles/docker-ldap/filter_plugins/build_ldap_role_entries.py delete mode 100644 roles/docker-ldap/templates/ldif/data/01_application_roles.ldif.j2 create mode 100644 roles/docker-ldap/templates/ldif/data/01_rbac.ldif.j2 create mode 100644 tests/unit/roles/docker-ldap/__init__.py create mode 100644 tests/unit/roles/docker-ldap/test_build_ldap_role_entries.py diff --git a/cli/generate_users.py b/cli/generate_users.py index 006a2370..b7ed7db4 100644 --- a/cli/generate_users.py +++ b/cli/generate_users.py @@ -7,26 +7,44 @@ import glob from collections import OrderedDict +def represent_str(dumper, data): + """ + Custom YAML string representer that forces double quotes around any string + containing a Jinja2 placeholder ({{ ... }}). + """ + if isinstance(data, str) and '{{' in data: + return dumper.represent_scalar( + 'tag:yaml.org,2002:str', + data, + style='"' + ) + return dumper.represent_scalar( + 'tag:yaml.org,2002:str', + data + ) + + def build_users(defs, primary_domain, start_id, become_pwd): """ - Build user entries with auto-incremented uid/gid, default username/email, and optional description. + Construct user entries with auto-incremented UID/GID, default username/email, + and optional description. Args: - defs (OrderedDict): Keys are user IDs, values are dicts with optional overrides. - primary_domain (str): e.g., 'example.com'. - start_id (int): Starting uid/gid (e.g., 1001). - become_pwd (str): Password string for all users. + defs (OrderedDict): Mapping of user keys to their override settings. + primary_domain (str): The primary domain for email addresses (e.g. 'example.com'). + start_id (int): Starting number for UID/GID allocation (e.g. 1001). + become_pwd (str): Default password string for users without an override. Returns: - OrderedDict: Merged user definitions with full fields. + OrderedDict: Complete user definitions with all required fields filled in. Raises: - ValueError: If duplicate override uids/gids or conflicts in generated values. + ValueError: If there are duplicate UIDs, usernames, or emails. """ users = OrderedDict() used_uids = set() - - # Pre-collect any provided uids/gids and check for duplicates + + # Collect any preset UIDs to avoid collisions for key, overrides in defs.items(): if 'uid' in overrides: uid = overrides['uid'] @@ -34,79 +52,76 @@ def build_users(defs, primary_domain, start_id, become_pwd): raise ValueError(f"Duplicate uid {uid} for user '{key}'") used_uids.add(uid) - next_free = start_id - def allocate_free_id(): - nonlocal next_free - # find next free id not in used_uids - while next_free in used_uids: - next_free += 1 - free = next_free - used_uids.add(free) - next_free += 1 - return free + next_uid = start_id + def allocate_uid(): + nonlocal next_uid + # Find the next free UID not already used + while next_uid in used_uids: + next_uid += 1 + free_uid = next_uid + used_uids.add(free_uid) + next_uid += 1 + return free_uid - # Build entries + # Build each user entry for key, overrides in defs.items(): username = overrides.get('username', key) email = overrides.get('email', f"{username}@{primary_domain}") description = overrides.get('description') - roles = overrides.get('roles',[]) - password = overrides.get('password',become_pwd) - # UID assignment + roles = overrides.get('roles', []) + password = overrides.get('password', become_pwd) + + # Determine UID and GID if 'uid' in overrides: uid = overrides['uid'] else: - uid = allocate_free_id() - gid = overrides.get('gid',uid) + uid = allocate_uid() + gid = overrides.get('gid', uid) entry = { 'username': username, - 'email': email, + 'email': email, 'password': password, - 'uid': uid, - 'gid': gid, - 'roles': roles + 'uid': uid, + 'gid': gid, + 'roles': roles } if description is not None: entry['description'] = description users[key] = entry - # Validate uniqueness of username, email, and gid + # Ensure uniqueness of usernames and emails seen_usernames = set() seen_emails = set() - seen_gids = set() + for key, entry in users.items(): un = entry['username'] em = entry['email'] - gd = entry['gid'] if un in seen_usernames: raise ValueError(f"Duplicate username '{un}' in merged users") if em in seen_emails: raise ValueError(f"Duplicate email '{em}' in merged users") seen_usernames.add(un) seen_emails.add(em) - seen_gids.add(gd) return users -def load_user_defs(roles_dir): +def load_user_defs(roles_directory): """ - Scan all roles/*/meta/users.yml files and extract 'users:' sections. - - Raises an exception if conflicting definitions are found. + Scan all roles/*/meta/users.yml files and merge any 'users:' sections. Args: - roles_dir (str): Path to the directory containing role subdirectories. + roles_directory (str): Path to the directory containing role subdirectories. Returns: - OrderedDict: Merged user definitions. + OrderedDict: Merged user definitions from all roles. Raises: - ValueError: On invalid format or conflicting field values. + ValueError: On invalid format or conflicting override values. """ - pattern = os.path.join(roles_dir, '*/meta/users.yml') + pattern = os.path.join(roles_directory, '*/meta/users.yml') files = sorted(glob.glob(pattern)) merged = OrderedDict() @@ -128,8 +143,7 @@ def load_user_defs(roles_dir): for field, value in overrides.items(): if field in existing and existing[field] != value: raise ValueError( - f"Conflict for user '{key}': field '{field}' has existing value " - f"'{existing[field]}', tried to set '{value}' in {filepath}" + f"Conflict for user '{key}': field '{field}' has existing value '{existing[field]}', tried to set '{value}' in {filepath}" ) existing.update(overrides) @@ -138,7 +152,7 @@ def load_user_defs(roles_dir): def dictify(data): """ - Recursively convert OrderedDict to regular dict before YAML dump. + Recursively convert OrderedDict to regular dict for YAML dumping. """ if isinstance(data, OrderedDict): return {k: dictify(v) for k, v in data.items()} @@ -151,7 +165,7 @@ def dictify(data): def parse_args(): parser = argparse.ArgumentParser( - description='Generate a users.yml by merging all roles/*/meta/users.yml users sections.' + description='Generate a users.yml by merging all roles/*/meta/users.yml definitions.' ) parser.add_argument( '--roles-dir', '-r', required=True, @@ -163,7 +177,7 @@ def parse_args(): ) parser.add_argument( '--start-id', '-s', type=int, default=1001, - help='Starting uid/gid number (default: 1001).' + help='Starting UID/GID number (default: 1001).' ) parser.add_argument( '--extra-users', '-e', @@ -179,42 +193,41 @@ def main(): become_pwd = '{{ lookup("password", "/dev/null length=42 chars=ascii_letters,digits") }}' try: - user_defs = load_user_defs(args.roles_dir) + definitions = load_user_defs(args.roles_dir) except ValueError as e: print(f"Error merging user definitions: {e}", file=sys.stderr) sys.exit(1) - # Add extra users if any + # Add extra users if specified if args.extra_users: for name in args.extra_users.split(','): - user = name.strip() - if not user: + user_key = name.strip() + if not user_key: continue - if user in user_defs: - print(f"Warning: extra user '{user}' already defined; skipping.", file=sys.stderr) + if user_key in definitions: + print(f"Warning: extra user '{user_key}' already defined; skipping.", file=sys.stderr) else: - user_defs[user] = {} + definitions[user_key] = {} try: users = build_users( - defs=user_defs, - primary_domain=primary_domain, - start_id=args.start_id, - become_pwd=become_pwd + definitions, + primary_domain, + args.start_id, + become_pwd ) except ValueError as e: print(f"Error building user entries: {e}", file=sys.stderr) sys.exit(1) + # Convert OrderedDict into plain dict for YAML default_users = {'default_users': users} plain_data = dictify(default_users) - # Ensure strings are represented without Python-specific tags - yaml.SafeDumper.add_representer( - str, - lambda dumper, data: dumper.represent_scalar('tag:yaml.org,2002:str', data) - ) + # Register custom string representer + yaml.SafeDumper.add_representer(str, represent_str) + # Dump the YAML file with open(args.output, 'w') as f: yaml.safe_dump( plain_data, diff --git a/group_vars/all/13_ldap.yml b/group_vars/all/13_ldap.yml index b235a8f2..cc9eced9 100644 --- a/group_vars/all/13_ldap.yml +++ b/group_vars/all/13_ldap.yml @@ -89,4 +89,9 @@ ldap: filters: users: login: "(&{{ _ldap_filters_users_all }}({{_ldap_user_id}}=%{{_ldap_user_id}}))" - all: "{{ _ldap_filters_users_all }}" \ No newline at end of file + all: "{{ _ldap_filters_users_all }}" + rbac: + flavors: + # Valid values posixGroup, groupOfNames + - groupOfNames + # - posixGroup diff --git a/main.py b/main.py index 286f3914..645bb1ab 100755 --- a/main.py +++ b/main.py @@ -147,9 +147,13 @@ if __name__ == "__main__": log_file = None if log_enabled: - log_file_path = os.path.join(script_dir, 'logfile.log') + log_dir = os.path.join(script_dir, 'logs') + os.makedirs(log_dir, exist_ok=True) + timestamp = datetime.now().strftime('%Y%m%dT%H%M%S') + log_file_path = os.path.join(log_dir, f'{timestamp}.log') log_file = open(log_file_path, 'a', encoding='utf-8') + try: if log_enabled: # Use a pseudo-terminal to preserve color formatting diff --git a/roles/__init__.py b/roles/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/roles/docker-espocrm/meta/users.yml b/roles/docker-espocrm/meta/users.yml index d6139aea..c5752dd6 100644 --- a/roles/docker-espocrm/meta/users.yml +++ b/roles/docker-espocrm/meta/users.yml @@ -1,7 +1,8 @@ users: administrator: - username: "administrator" + username: "administrator" contact: description: "General contact account" username: "contact" - mailu_token_enabled: true \ No newline at end of file + roles: + - mail-bot \ No newline at end of file diff --git a/roles/docker-espocrm/vars/configuration.yml b/roles/docker-espocrm/vars/configuration.yml index b90fcbcf..620275b7 100644 --- a/roles/docker-espocrm/vars/configuration.yml +++ b/roles/docker-espocrm/vars/configuration.yml @@ -24,4 +24,6 @@ csp: - https://s.espocrm.com/ domains: aliases: - - "crm.{{ primary_domain }}" \ No newline at end of file + - "crm.{{ primary_domain }}" +email: + from_name: "Customer Relationship Management ({{ primary_domain }})" \ No newline at end of file diff --git a/roles/docker-espocrm/vars/main.yml b/roles/docker-espocrm/vars/main.yml index bc2f317e..fdf94800 100644 --- a/roles/docker-espocrm/vars/main.yml +++ b/roles/docker-espocrm/vars/main.yml @@ -1,5 +1,3 @@ application_id: "espocrm" # EspoCRM uses MySQL/MariaDB -database_type: "mariadb" -email: - from_name: "Customer Relationship Management ({{ primary_domain }})" \ No newline at end of file +database_type: "mariadb" \ No newline at end of file diff --git a/roles/docker-ldap/__init__.py b/roles/docker-ldap/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/roles/docker-ldap/docs/Administration.md b/roles/docker-ldap/docs/Administration.md index 44140661..4d156225 100644 --- a/roles/docker-ldap/docs/Administration.md +++ b/roles/docker-ldap/docs/Administration.md @@ -46,10 +46,23 @@ docker exec -it ldap bash -c "ldapsearch -LLL -o ldif-wrap=no -x -D \"\$LDAP_ADM ### Delete Groups and Subgroup To delete the group inclusive all subgroups use: ```bash +docker exec -it ldap bash -c "ldapsearch -LLL -o ldif-wrap=no -x -D \"\$LDAP_ADMIN_DN\" -w \"\$LDAP_ADMIN_PASSWORD\" -b \"ou=applications,ou=groups,\$LDAP_ROOT\" dn | sed -n 's/^dn: //p' | tac | while read -r dn; do echo \"Deleting \$dn\"; ldapdelete -x -D \"\$LDAP_ADMIN_DN\" -w \"\$LDAP_ADMIN_PASSWORD\" \"\$dn\"; done" + +# Works docker exec -it ldap \ ldapdelete -x \ -D "$LDAP_ADMIN_DN" \ -w "$LDAP_ADMIN_PASSWORD" \ -r \ - "ou=groups,dc=veen,dc=world" + "ou=groups,$LDAP_ROOT" +``` + +## Import RBAC +```bash +docker exec -i ldap \ + ldapadd -x \ + -D "$LDAP_ADMIN_DN" \ + -w "$LDAP_ADMIN_PASSWORD" \ + -c \ + -f "/tmp/ldif/data/01_rbac_roles.ldif" ``` \ No newline at end of file diff --git a/roles/docker-ldap/filter_plugins/__init__.py b/roles/docker-ldap/filter_plugins/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/roles/docker-ldap/filter_plugins/build_ldap_role_entries.py b/roles/docker-ldap/filter_plugins/build_ldap_role_entries.py new file mode 100644 index 00000000..50210ada --- /dev/null +++ b/roles/docker-ldap/filter_plugins/build_ldap_role_entries.py @@ -0,0 +1,64 @@ +def build_ldap_role_entries(applications, users, ldap): + """ + Builds structured LDAP role entries using the global `ldap` configuration. + Supports objectClasses: posixGroup (adds gidNumber, memberUid), groupOfNames (adds member). + """ + + result = {} + + for application_id, application_config in applications.items(): + base_roles = application_config.get("rbac", {}).get("roles", {}) + roles = { + **base_roles, + "administrator": { + "description": "Has full administrative access: manage themes, plugins, settings, and users" + } + } + + group_id = application_config.get("group_id") + user_dn_base = ldap["dn"]["ou"]["users"] + ldap_user_attr = ldap["attributes"]["user_id"] + role_dn_base = ldap["dn"]["ou"]["roles"] + flavors = ldap.get("rbac", {}).get("flavors", []) + + for role_name, role_conf in roles.items(): + group_cn = f"{application_id}-{role_name}" + dn = f"cn={group_cn},{role_dn_base}" + + entry = { + "dn": dn, + "cn": group_cn, + "description": role_conf.get("description", ""), + "objectClass": ["top"] + flavors, + } + + # Initialize member lists + member_dns = [] + member_uids = [] + + for username, user_config in users.items(): + if role_name in user_config.get("roles", []): + user_dn = f"{ldap_user_attr}={username},{user_dn_base}" + member_dns.append(user_dn) + member_uids.append(username) + + # Add gidNumber for posixGroup + if "posixGroup" in flavors: + entry["gidNumber"] = group_id + if member_uids: + entry["memberUid"] = member_uids + + # Add members for groupOfNames + if "groupOfNames" in flavors and member_dns: + entry["member"] = member_dns + + result[dn] = entry + + return result + + +class FilterModule(object): + def filters(self): + return { + "build_ldap_role_entries": build_ldap_role_entries + } diff --git a/roles/docker-ldap/handlers/main.yml b/roles/docker-ldap/handlers/main.yml index 16292a45..b019970e 100644 --- a/roles/docker-ldap/handlers/main.yml +++ b/roles/docker-ldap/handlers/main.yml @@ -52,4 +52,4 @@ listen: - "Import data LDIF files" - "Import all LDIF files" - loop: "{{ lookup('fileglob', role_path ~ '/templates/ldif/data/*.j2', wantlist=True) }}" \ No newline at end of file + loop: "{{ query('fileglob', role_path ~ '/templates/ldif/data/*.j2') | sort }}" \ No newline at end of file diff --git a/roles/docker-ldap/tasks/add_user_objects.yml b/roles/docker-ldap/tasks/add_user_objects.yml index 459b053c..a8831ed2 100644 --- a/roles/docker-ldap/tasks/add_user_objects.yml +++ b/roles/docker-ldap/tasks/add_user_objects.yml @@ -21,6 +21,8 @@ attributes: objectClass: "{{ missing_auxiliary }}" state: present + async: 60 + poll: 0 loop: "{{ ldap_users_with_classes.results }}" loop_control: label: "{{ item.dn }}" diff --git a/roles/docker-ldap/tasks/create_ldif_files.yml b/roles/docker-ldap/tasks/create_ldif_files.yml index a1d4b498..5ae39b90 100644 --- a/roles/docker-ldap/tasks/create_ldif_files.yml +++ b/roles/docker-ldap/tasks/create_ldif_files.yml @@ -1,9 +1,11 @@ -# In own task file for easier looping - - name: "Create LDIF files at {{ ldif_host_path }}{{ folder }}" template: src: "{{ item }}" dest: "{{ ldif_host_path }}{{ folder }}/{{ item | basename | regex_replace('\\.j2$', '') }}" mode: '770' - loop: "{{ lookup('fileglob', role_path ~ '/templates/ldif/' ~ folder ~ '/*.j2', wantlist=True) }}" + loop: >- + {{ + lookup('fileglob', role_path ~ '/templates/ldif/' ~ folder ~ '/*.j2', wantlist=True) + | sort + }} notify: "Import {{ folder }} LDIF files" diff --git a/roles/docker-ldap/tasks/main.yml b/roles/docker-ldap/tasks/main.yml index ed43bd20..8b0db7dc 100644 --- a/roles/docker-ldap/tasks/main.yml +++ b/roles/docker-ldap/tasks/main.yml @@ -78,6 +78,8 @@ uidNumber: "{{ item.value.uid | int }}" gidNumber: "{{ item.value.gid | int }}" state: present # ↳ creates but never updates + async: 60 + poll: 0 loop: "{{ users | dict2items }}" loop_control: label: "{{ item.key }}" @@ -95,6 +97,8 @@ objectClass: "{{ ldap.user_objects.structural }}" mail: "{{ item.value.email }}" state: exact + async: 60 + poll: 0 loop: "{{ users | dict2items }}" loop_control: label: "{{ item.key }}" diff --git a/roles/docker-ldap/templates/ldif/data/01_application_roles.ldif.j2 b/roles/docker-ldap/templates/ldif/data/01_application_roles.ldif.j2 deleted file mode 100644 index 1c5e4cc0..00000000 --- a/roles/docker-ldap/templates/ldif/data/01_application_roles.ldif.j2 +++ /dev/null @@ -1,30 +0,0 @@ -{%- for application_id, application_config in applications.items() %} - {%- set base_roles = application_config.rbac.roles | default({}) %} - {%- set roles = base_roles | combine({ - 'administrator': { - 'description': 'Has full administrative access: manage themes, plugins, settings, and users' - } - }) - %} - - {%- for role_name, role_conf in roles.items() %} -dn: cn={{ application_id }}-{{ role_name }},{{ ldap.dn.ou.roles }} -objectClass: top -objectClass: organizationalRole -objectClass: posixGroup -gidNumber: {{ application_config['group_id'] }} -cn: {{ application_id }}-{{ role_name }} -description: {{ role_conf.description }} - - {%- for username, user_config in users.items() %} - {%- set user_roles = user_config.roles | default([]) %} - {%- if role_name in user_roles %} -dn: cn={{ application_id }}-{{ role_name }},{{ ldap.dn.ou.roles }} -changetype: modify -add: roleOccupant -roleOccupant: {{ ldap.attributes.user_id }}={{ username }},{{ ldap.dn.ou.users }} - - {%- endif %} - {%- endfor %} - {%- endfor %} -{%- endfor %} diff --git a/roles/docker-ldap/templates/ldif/data/01_rbac.ldif.j2 b/roles/docker-ldap/templates/ldif/data/01_rbac.ldif.j2 new file mode 100644 index 00000000..18b1e417 --- /dev/null +++ b/roles/docker-ldap/templates/ldif/data/01_rbac.ldif.j2 @@ -0,0 +1,23 @@ +{% for dn, entry in (applications | build_ldap_role_entries(users, ldap)).items() %} + +dn: {{ dn }} +{% for oc in entry.objectClass %} +objectClass: {{ oc }} +{% endfor %} +{% if entry.gidNumber is defined %} +gidNumber: {{ entry.gidNumber }} +{% endif %} +cn: {{ entry.cn }} +description: {{ entry.description }} +{% if entry.memberUid is defined %} +{% for uid in entry.memberUid %} +memberUid: {{ uid }} +{% endfor %} +{% endif %} +{% if entry.member is defined %} +{% for m in entry.member %} +member: {{ m }} +{% endfor %} +{% endif %} + +{% endfor %} diff --git a/roles/docker-listmonk/meta/users.yml b/roles/docker-listmonk/meta/users.yml index b8ee7b0a..bbe6133c 100644 --- a/roles/docker-listmonk/meta/users.yml +++ b/roles/docker-listmonk/meta/users.yml @@ -3,7 +3,9 @@ users: username: "administrator" bounce: username: "bounce" - mailu_token_enabled: true + roles: + - mail-bot newsletter: username: "newsletter" - mailu_token_enabled: true \ No newline at end of file + roles: + - mail-bot \ No newline at end of file diff --git a/roles/docker-mailu/tasks/create-mailu-user.yml b/roles/docker-mailu/tasks/create-mailu-user.yml index 06fe3b24..63a44641 100644 --- a/roles/docker-mailu/tasks/create-mailu-user.yml +++ b/roles/docker-mailu/tasks/create-mailu-user.yml @@ -12,6 +12,7 @@ "Duplicate entry" not in mailu_user_result.stderr ) changed_when: mailu_user_result.rc == 0 + when: "'mail-bot' in item.value.roles or 'administrator' in item.value.roles" - name: "Change password for user '{{ mailu_user_key }};{{ mailu_user_name }}@{{ mailu_domain }}'" command: > @@ -19,7 +20,8 @@ {{ mailu_user_name }} {{ mailu_domain }} '{{ mailu_password }}' args: chdir: "{{ mailu_compose_dir }}" + when: "'mail-bot' in item.value.roles or 'administrator' in item.value.roles" - name: "Create Mailu API Token for {{ mailu_user_name }}" include_tasks: create-mailu-token.yml - when: mailu_token_enabled \ No newline at end of file + when: "{{ 'mail-bot' in item.value.roles }}" \ No newline at end of file diff --git a/roles/docker-mailu/tasks/main.yml b/roles/docker-mailu/tasks/main.yml index b6710d62..830791f1 100644 --- a/roles/docker-mailu/tasks/main.yml +++ b/roles/docker-mailu/tasks/main.yml @@ -42,7 +42,6 @@ mailu_user_key: "{{ item.key }}" mailu_user_name: "{{ item.value.username }}" mailu_password: "{{ item.value.password }}" - mailu_token_enabled: "{{ item.value.mailu_token_enabled | default(false)}}" mailu_token_ip: "{{ item.value.ip | default('') }}" loop: "{{ users | dict2items }}" loop_control: diff --git a/roles/docker-mailu/vars/configuration.yml b/roles/docker-mailu/vars/configuration.yml index 71351e75..5ac9088d 100644 --- a/roles/docker-mailu/vars/configuration.yml +++ b/roles/docker-mailu/vars/configuration.yml @@ -22,4 +22,8 @@ csp: script-src: unsafe-inline: true unsafe-eval: true +rbac: + roles: + mail-bot: + description: "Has an token to send and recieve emails" \ No newline at end of file diff --git a/roles/docker-nextcloud/meta/users.yml b/roles/docker-nextcloud/meta/users.yml index 1223a0b5..fc7cdca1 100644 --- a/roles/docker-nextcloud/meta/users.yml +++ b/roles/docker-nextcloud/meta/users.yml @@ -3,4 +3,5 @@ users: username: "administrator" no-reply: username: "no-reply" - mailu_token_enabled: true \ No newline at end of file + roles: + - mail-bot \ No newline at end of file diff --git a/roles/docker-portfolio/templates/config.yaml.j2 b/roles/docker-portfolio/templates/config.yaml.j2 index 49c73984..b54aae93 100644 --- a/roles/docker-portfolio/templates/config.yaml.j2 +++ b/roles/docker-portfolio/templates/config.yaml.j2 @@ -46,7 +46,7 @@ accounts: iframe: {{ applications | is_feature_enabled('portfolio_iframe','pixelfed') }} {% endif %} {% if service_provider.contact.peertube is defined and service_provider.contact.peertube != "" %} - - name: Peertube + - name: Videos description: Discover {{ 'our' if service_provider.type == 'legal' else 'my' }} videos on Peertube. icon: class: fa-solid fa-video diff --git a/tests/unit/roles/docker-ldap/__init__.py b/tests/unit/roles/docker-ldap/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/roles/docker-ldap/test_build_ldap_role_entries.py b/tests/unit/roles/docker-ldap/test_build_ldap_role_entries.py new file mode 100644 index 00000000..92b7eff4 --- /dev/null +++ b/tests/unit/roles/docker-ldap/test_build_ldap_role_entries.py @@ -0,0 +1,96 @@ +import unittest +import sys +import os +import importlib.util + +# Dynamisch den Filter-Plugin Pfad hinzufügen +current_dir = os.path.dirname(__file__) +filter_plugin_path = os.path.abspath(os.path.join(current_dir, "../../../../roles/docker-ldap/filter_plugins")) + +# Modul dynamisch laden +spec = importlib.util.spec_from_file_location("build_ldap_role_entries", os.path.join(filter_plugin_path, "build_ldap_role_entries.py")) +ble_module = importlib.util.module_from_spec(spec) +spec.loader.exec_module(ble_module) + +build_ldap_role_entries = ble_module.build_ldap_role_entries + + +class TestBuildLdapRoleEntries(unittest.TestCase): + def setUp(self): + self.applications = { + "app1": { + "group_id": 10000, + "rbac": { + "roles": { + "editor": {"description": "Can edit content"}, + "viewer": {"description": "Can view content"} + } + } + } + } + + self.users = { + "alice": { + "roles": ["editor", "administrator"] + }, + "bob": { + "roles": ["viewer"] + }, + "carol": { + "roles": [] + } + } + + self.ldap = { + "dn": { + "ou": { + "users": "ou=users,dc=example,dc=org", + "roles": "ou=roles,dc=example,dc=org" + } + }, + "attributes": { + "user_id": "uid" + }, + "rbac": { + "flavors": ["posixGroup", "groupOfNames"] + } + } + + def test_entries_structure(self): + entries = build_ldap_role_entries(self.applications, self.users, self.ldap) + expected_dns = { + "cn=app1-editor,ou=roles,dc=example,dc=org", + "cn=app1-viewer,ou=roles,dc=example,dc=org", + "cn=app1-administrator,ou=roles,dc=example,dc=org" + } + self.assertEqual(set(entries.keys()), expected_dns) + + def test_posix_group_members(self): + entries = build_ldap_role_entries(self.applications, self.users, self.ldap) + editor = entries["cn=app1-editor,ou=roles,dc=example,dc=org"] + self.assertEqual(editor["gidNumber"], 10000) + self.assertIn("memberUid", editor) + self.assertIn("alice", editor["memberUid"]) + + def test_group_of_names_members(self): + entries = build_ldap_role_entries(self.applications, self.users, self.ldap) + viewer = entries["cn=app1-viewer,ou=roles,dc=example,dc=org"] + expected_dn = "uid=bob,ou=users,dc=example,dc=org" + self.assertIn("member", viewer) + self.assertIn(expected_dn, viewer["member"]) + + def test_administrator_auto_included(self): + entries = build_ldap_role_entries(self.applications, self.users, self.ldap) + admin = entries["cn=app1-administrator,ou=roles,dc=example,dc=org"] + self.assertEqual(admin["description"], "Has full administrative access: manage themes, plugins, settings, and users") + self.assertIn("alice", admin.get("memberUid", [])) + + def test_empty_roles_are_skipped(self): + entries = build_ldap_role_entries(self.applications, self.users, self.ldap) + for entry in entries.values(): + if entry["cn"].endswith("-viewer"): + self.assertNotIn("carol", entry.get("memberUid", [])) + + +if __name__ == "__main__": + unittest.main()