Optimized RBAC via LDAP

This commit is contained in:
Kevin Veen-Birkenbach 2025-07-04 08:03:27 +02:00
parent a9f55579a2
commit ee0561db72
No known key found for this signature in database
GPG Key ID: 44D8F11FD62F878E
25 changed files with 316 additions and 111 deletions

View File

@ -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,29 +52,30 @@ 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
# Determine UID and GID
if 'uid' in overrides:
uid = overrides['uid']
else:
uid = allocate_free_id()
uid = allocate_uid()
gid = overrides.get('gid', uid)
entry = {
@ -72,41 +91,37 @@ def build_users(defs, primary_domain, start_id, become_pwd):
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,

View File

@ -90,3 +90,8 @@ ldap:
users:
login: "(&{{ _ldap_filters_users_all }}({{_ldap_user_id}}=%{{_ldap_user_id}}))"
all: "{{ _ldap_filters_users_all }}"
rbac:
flavors:
# Valid values posixGroup, groupOfNames
- groupOfNames
# - posixGroup

View File

@ -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

0
roles/__init__.py Normal file
View File

View File

@ -4,4 +4,5 @@ users:
contact:
description: "General contact account"
username: "contact"
mailu_token_enabled: true
roles:
- mail-bot

View File

@ -25,3 +25,5 @@ csp:
domains:
aliases:
- "crm.{{ primary_domain }}"
email:
from_name: "Customer Relationship Management ({{ primary_domain }})"

View File

@ -1,5 +1,3 @@
application_id: "espocrm"
# EspoCRM uses MySQL/MariaDB
database_type: "mariadb"
email:
from_name: "Customer Relationship Management ({{ primary_domain }})"

View File

View File

@ -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"
```

View File

@ -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
}

View File

@ -52,4 +52,4 @@
listen:
- "Import data LDIF files"
- "Import all LDIF files"
loop: "{{ lookup('fileglob', role_path ~ '/templates/ldif/data/*.j2', wantlist=True) }}"
loop: "{{ query('fileglob', role_path ~ '/templates/ldif/data/*.j2') | sort }}"

View File

@ -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 }}"

View File

@ -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"

View File

@ -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 }}"

View File

@ -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 %}

View File

@ -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 %}

View File

@ -3,7 +3,9 @@ users:
username: "administrator"
bounce:
username: "bounce"
mailu_token_enabled: true
roles:
- mail-bot
newsletter:
username: "newsletter"
mailu_token_enabled: true
roles:
- mail-bot

View File

@ -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
when: "{{ 'mail-bot' in item.value.roles }}"

View File

@ -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:

View File

@ -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"

View File

@ -3,4 +3,5 @@ users:
username: "administrator"
no-reply:
username: "no-reply"
mailu_token_enabled: true
roles:
- mail-bot

View File

@ -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

View File

View File

@ -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()