mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2025-07-05 08:23:08 +02:00
Optimized RBAC via LDAP
This commit is contained in:
parent
a9f55579a2
commit
ee0561db72
@ -7,26 +7,44 @@ import glob
|
|||||||
from collections import OrderedDict
|
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):
|
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:
|
Args:
|
||||||
defs (OrderedDict): Keys are user IDs, values are dicts with optional overrides.
|
defs (OrderedDict): Mapping of user keys to their override settings.
|
||||||
primary_domain (str): e.g., 'example.com'.
|
primary_domain (str): The primary domain for email addresses (e.g. 'example.com').
|
||||||
start_id (int): Starting uid/gid (e.g., 1001).
|
start_id (int): Starting number for UID/GID allocation (e.g. 1001).
|
||||||
become_pwd (str): Password string for all users.
|
become_pwd (str): Default password string for users without an override.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
OrderedDict: Merged user definitions with full fields.
|
OrderedDict: Complete user definitions with all required fields filled in.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
ValueError: If duplicate override uids/gids or conflicts in generated values.
|
ValueError: If there are duplicate UIDs, usernames, or emails.
|
||||||
"""
|
"""
|
||||||
users = OrderedDict()
|
users = OrderedDict()
|
||||||
used_uids = set()
|
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():
|
for key, overrides in defs.items():
|
||||||
if 'uid' in overrides:
|
if 'uid' in overrides:
|
||||||
uid = overrides['uid']
|
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}'")
|
raise ValueError(f"Duplicate uid {uid} for user '{key}'")
|
||||||
used_uids.add(uid)
|
used_uids.add(uid)
|
||||||
|
|
||||||
next_free = start_id
|
next_uid = start_id
|
||||||
def allocate_free_id():
|
def allocate_uid():
|
||||||
nonlocal next_free
|
nonlocal next_uid
|
||||||
# find next free id not in used_uids
|
# Find the next free UID not already used
|
||||||
while next_free in used_uids:
|
while next_uid in used_uids:
|
||||||
next_free += 1
|
next_uid += 1
|
||||||
free = next_free
|
free_uid = next_uid
|
||||||
used_uids.add(free)
|
used_uids.add(free_uid)
|
||||||
next_free += 1
|
next_uid += 1
|
||||||
return free
|
return free_uid
|
||||||
|
|
||||||
# Build entries
|
# Build each user entry
|
||||||
for key, overrides in defs.items():
|
for key, overrides in defs.items():
|
||||||
username = overrides.get('username', key)
|
username = overrides.get('username', key)
|
||||||
email = overrides.get('email', f"{username}@{primary_domain}")
|
email = overrides.get('email', f"{username}@{primary_domain}")
|
||||||
description = overrides.get('description')
|
description = overrides.get('description')
|
||||||
roles = overrides.get('roles',[])
|
roles = overrides.get('roles', [])
|
||||||
password = overrides.get('password',become_pwd)
|
password = overrides.get('password', become_pwd)
|
||||||
# UID assignment
|
|
||||||
|
# Determine UID and GID
|
||||||
if 'uid' in overrides:
|
if 'uid' in overrides:
|
||||||
uid = overrides['uid']
|
uid = overrides['uid']
|
||||||
else:
|
else:
|
||||||
uid = allocate_free_id()
|
uid = allocate_uid()
|
||||||
gid = overrides.get('gid',uid)
|
gid = overrides.get('gid', uid)
|
||||||
|
|
||||||
entry = {
|
entry = {
|
||||||
'username': username,
|
'username': username,
|
||||||
'email': email,
|
'email': email,
|
||||||
'password': password,
|
'password': password,
|
||||||
'uid': uid,
|
'uid': uid,
|
||||||
'gid': gid,
|
'gid': gid,
|
||||||
'roles': roles
|
'roles': roles
|
||||||
}
|
}
|
||||||
if description is not None:
|
if description is not None:
|
||||||
entry['description'] = description
|
entry['description'] = description
|
||||||
|
|
||||||
users[key] = entry
|
users[key] = entry
|
||||||
|
|
||||||
# Validate uniqueness of username, email, and gid
|
# Ensure uniqueness of usernames and emails
|
||||||
seen_usernames = set()
|
seen_usernames = set()
|
||||||
seen_emails = set()
|
seen_emails = set()
|
||||||
seen_gids = set()
|
|
||||||
for key, entry in users.items():
|
for key, entry in users.items():
|
||||||
un = entry['username']
|
un = entry['username']
|
||||||
em = entry['email']
|
em = entry['email']
|
||||||
gd = entry['gid']
|
|
||||||
if un in seen_usernames:
|
if un in seen_usernames:
|
||||||
raise ValueError(f"Duplicate username '{un}' in merged users")
|
raise ValueError(f"Duplicate username '{un}' in merged users")
|
||||||
if em in seen_emails:
|
if em in seen_emails:
|
||||||
raise ValueError(f"Duplicate email '{em}' in merged users")
|
raise ValueError(f"Duplicate email '{em}' in merged users")
|
||||||
seen_usernames.add(un)
|
seen_usernames.add(un)
|
||||||
seen_emails.add(em)
|
seen_emails.add(em)
|
||||||
seen_gids.add(gd)
|
|
||||||
|
|
||||||
return users
|
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.
|
Scan all roles/*/meta/users.yml files and merge any 'users:' sections.
|
||||||
|
|
||||||
Raises an exception if conflicting definitions are found.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
roles_dir (str): Path to the directory containing role subdirectories.
|
roles_directory (str): Path to the directory containing role subdirectories.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
OrderedDict: Merged user definitions.
|
OrderedDict: Merged user definitions from all roles.
|
||||||
|
|
||||||
Raises:
|
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))
|
files = sorted(glob.glob(pattern))
|
||||||
merged = OrderedDict()
|
merged = OrderedDict()
|
||||||
|
|
||||||
@ -128,8 +143,7 @@ def load_user_defs(roles_dir):
|
|||||||
for field, value in overrides.items():
|
for field, value in overrides.items():
|
||||||
if field in existing and existing[field] != value:
|
if field in existing and existing[field] != value:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"Conflict for user '{key}': field '{field}' has existing value "
|
f"Conflict for user '{key}': field '{field}' has existing value '{existing[field]}', tried to set '{value}' in {filepath}"
|
||||||
f"'{existing[field]}', tried to set '{value}' in {filepath}"
|
|
||||||
)
|
)
|
||||||
existing.update(overrides)
|
existing.update(overrides)
|
||||||
|
|
||||||
@ -138,7 +152,7 @@ def load_user_defs(roles_dir):
|
|||||||
|
|
||||||
def dictify(data):
|
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):
|
if isinstance(data, OrderedDict):
|
||||||
return {k: dictify(v) for k, v in data.items()}
|
return {k: dictify(v) for k, v in data.items()}
|
||||||
@ -151,7 +165,7 @@ def dictify(data):
|
|||||||
|
|
||||||
def parse_args():
|
def parse_args():
|
||||||
parser = argparse.ArgumentParser(
|
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(
|
parser.add_argument(
|
||||||
'--roles-dir', '-r', required=True,
|
'--roles-dir', '-r', required=True,
|
||||||
@ -163,7 +177,7 @@ def parse_args():
|
|||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
'--start-id', '-s', type=int, default=1001,
|
'--start-id', '-s', type=int, default=1001,
|
||||||
help='Starting uid/gid number (default: 1001).'
|
help='Starting UID/GID number (default: 1001).'
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
'--extra-users', '-e',
|
'--extra-users', '-e',
|
||||||
@ -179,42 +193,41 @@ def main():
|
|||||||
become_pwd = '{{ lookup("password", "/dev/null length=42 chars=ascii_letters,digits") }}'
|
become_pwd = '{{ lookup("password", "/dev/null length=42 chars=ascii_letters,digits") }}'
|
||||||
|
|
||||||
try:
|
try:
|
||||||
user_defs = load_user_defs(args.roles_dir)
|
definitions = load_user_defs(args.roles_dir)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
print(f"Error merging user definitions: {e}", file=sys.stderr)
|
print(f"Error merging user definitions: {e}", file=sys.stderr)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
# Add extra users if any
|
# Add extra users if specified
|
||||||
if args.extra_users:
|
if args.extra_users:
|
||||||
for name in args.extra_users.split(','):
|
for name in args.extra_users.split(','):
|
||||||
user = name.strip()
|
user_key = name.strip()
|
||||||
if not user:
|
if not user_key:
|
||||||
continue
|
continue
|
||||||
if user in user_defs:
|
if user_key in definitions:
|
||||||
print(f"Warning: extra user '{user}' already defined; skipping.", file=sys.stderr)
|
print(f"Warning: extra user '{user_key}' already defined; skipping.", file=sys.stderr)
|
||||||
else:
|
else:
|
||||||
user_defs[user] = {}
|
definitions[user_key] = {}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
users = build_users(
|
users = build_users(
|
||||||
defs=user_defs,
|
definitions,
|
||||||
primary_domain=primary_domain,
|
primary_domain,
|
||||||
start_id=args.start_id,
|
args.start_id,
|
||||||
become_pwd=become_pwd
|
become_pwd
|
||||||
)
|
)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
print(f"Error building user entries: {e}", file=sys.stderr)
|
print(f"Error building user entries: {e}", file=sys.stderr)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Convert OrderedDict into plain dict for YAML
|
||||||
default_users = {'default_users': users}
|
default_users = {'default_users': users}
|
||||||
plain_data = dictify(default_users)
|
plain_data = dictify(default_users)
|
||||||
|
|
||||||
# Ensure strings are represented without Python-specific tags
|
# Register custom string representer
|
||||||
yaml.SafeDumper.add_representer(
|
yaml.SafeDumper.add_representer(str, represent_str)
|
||||||
str,
|
|
||||||
lambda dumper, data: dumper.represent_scalar('tag:yaml.org,2002:str', data)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
# Dump the YAML file
|
||||||
with open(args.output, 'w') as f:
|
with open(args.output, 'w') as f:
|
||||||
yaml.safe_dump(
|
yaml.safe_dump(
|
||||||
plain_data,
|
plain_data,
|
||||||
|
@ -90,3 +90,8 @@ ldap:
|
|||||||
users:
|
users:
|
||||||
login: "(&{{ _ldap_filters_users_all }}({{_ldap_user_id}}=%{{_ldap_user_id}}))"
|
login: "(&{{ _ldap_filters_users_all }}({{_ldap_user_id}}=%{{_ldap_user_id}}))"
|
||||||
all: "{{ _ldap_filters_users_all }}"
|
all: "{{ _ldap_filters_users_all }}"
|
||||||
|
rbac:
|
||||||
|
flavors:
|
||||||
|
# Valid values posixGroup, groupOfNames
|
||||||
|
- groupOfNames
|
||||||
|
# - posixGroup
|
||||||
|
6
main.py
6
main.py
@ -147,9 +147,13 @@ if __name__ == "__main__":
|
|||||||
|
|
||||||
log_file = None
|
log_file = None
|
||||||
if log_enabled:
|
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')
|
log_file = open(log_file_path, 'a', encoding='utf-8')
|
||||||
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if log_enabled:
|
if log_enabled:
|
||||||
# Use a pseudo-terminal to preserve color formatting
|
# Use a pseudo-terminal to preserve color formatting
|
||||||
|
0
roles/__init__.py
Normal file
0
roles/__init__.py
Normal file
@ -1,7 +1,8 @@
|
|||||||
users:
|
users:
|
||||||
administrator:
|
administrator:
|
||||||
username: "administrator"
|
username: "administrator"
|
||||||
contact:
|
contact:
|
||||||
description: "General contact account"
|
description: "General contact account"
|
||||||
username: "contact"
|
username: "contact"
|
||||||
mailu_token_enabled: true
|
roles:
|
||||||
|
- mail-bot
|
@ -25,3 +25,5 @@ csp:
|
|||||||
domains:
|
domains:
|
||||||
aliases:
|
aliases:
|
||||||
- "crm.{{ primary_domain }}"
|
- "crm.{{ primary_domain }}"
|
||||||
|
email:
|
||||||
|
from_name: "Customer Relationship Management ({{ primary_domain }})"
|
@ -1,5 +1,3 @@
|
|||||||
application_id: "espocrm"
|
application_id: "espocrm"
|
||||||
# EspoCRM uses MySQL/MariaDB
|
# EspoCRM uses MySQL/MariaDB
|
||||||
database_type: "mariadb"
|
database_type: "mariadb"
|
||||||
email:
|
|
||||||
from_name: "Customer Relationship Management ({{ primary_domain }})"
|
|
0
roles/docker-ldap/__init__.py
Normal file
0
roles/docker-ldap/__init__.py
Normal 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
|
### Delete Groups and Subgroup
|
||||||
To delete the group inclusive all subgroups use:
|
To delete the group inclusive all subgroups use:
|
||||||
```bash
|
```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 \
|
docker exec -it ldap \
|
||||||
ldapdelete -x \
|
ldapdelete -x \
|
||||||
-D "$LDAP_ADMIN_DN" \
|
-D "$LDAP_ADMIN_DN" \
|
||||||
-w "$LDAP_ADMIN_PASSWORD" \
|
-w "$LDAP_ADMIN_PASSWORD" \
|
||||||
-r \
|
-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"
|
||||||
```
|
```
|
0
roles/docker-ldap/filter_plugins/__init__.py
Normal file
0
roles/docker-ldap/filter_plugins/__init__.py
Normal file
64
roles/docker-ldap/filter_plugins/build_ldap_role_entries.py
Normal file
64
roles/docker-ldap/filter_plugins/build_ldap_role_entries.py
Normal 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
|
||||||
|
}
|
@ -52,4 +52,4 @@
|
|||||||
listen:
|
listen:
|
||||||
- "Import data LDIF files"
|
- "Import data LDIF files"
|
||||||
- "Import all 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 }}"
|
@ -21,6 +21,8 @@
|
|||||||
attributes:
|
attributes:
|
||||||
objectClass: "{{ missing_auxiliary }}"
|
objectClass: "{{ missing_auxiliary }}"
|
||||||
state: present
|
state: present
|
||||||
|
async: 60
|
||||||
|
poll: 0
|
||||||
loop: "{{ ldap_users_with_classes.results }}"
|
loop: "{{ ldap_users_with_classes.results }}"
|
||||||
loop_control:
|
loop_control:
|
||||||
label: "{{ item.dn }}"
|
label: "{{ item.dn }}"
|
||||||
|
@ -1,9 +1,11 @@
|
|||||||
# In own task file for easier looping
|
|
||||||
|
|
||||||
- name: "Create LDIF files at {{ ldif_host_path }}{{ folder }}"
|
- name: "Create LDIF files at {{ ldif_host_path }}{{ folder }}"
|
||||||
template:
|
template:
|
||||||
src: "{{ item }}"
|
src: "{{ item }}"
|
||||||
dest: "{{ ldif_host_path }}{{ folder }}/{{ item | basename | regex_replace('\\.j2$', '') }}"
|
dest: "{{ ldif_host_path }}{{ folder }}/{{ item | basename | regex_replace('\\.j2$', '') }}"
|
||||||
mode: '770'
|
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"
|
notify: "Import {{ folder }} LDIF files"
|
||||||
|
@ -78,6 +78,8 @@
|
|||||||
uidNumber: "{{ item.value.uid | int }}"
|
uidNumber: "{{ item.value.uid | int }}"
|
||||||
gidNumber: "{{ item.value.gid | int }}"
|
gidNumber: "{{ item.value.gid | int }}"
|
||||||
state: present # ↳ creates but never updates
|
state: present # ↳ creates but never updates
|
||||||
|
async: 60
|
||||||
|
poll: 0
|
||||||
loop: "{{ users | dict2items }}"
|
loop: "{{ users | dict2items }}"
|
||||||
loop_control:
|
loop_control:
|
||||||
label: "{{ item.key }}"
|
label: "{{ item.key }}"
|
||||||
@ -95,6 +97,8 @@
|
|||||||
objectClass: "{{ ldap.user_objects.structural }}"
|
objectClass: "{{ ldap.user_objects.structural }}"
|
||||||
mail: "{{ item.value.email }}"
|
mail: "{{ item.value.email }}"
|
||||||
state: exact
|
state: exact
|
||||||
|
async: 60
|
||||||
|
poll: 0
|
||||||
loop: "{{ users | dict2items }}"
|
loop: "{{ users | dict2items }}"
|
||||||
loop_control:
|
loop_control:
|
||||||
label: "{{ item.key }}"
|
label: "{{ item.key }}"
|
||||||
|
@ -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 %}
|
|
23
roles/docker-ldap/templates/ldif/data/01_rbac.ldif.j2
Normal file
23
roles/docker-ldap/templates/ldif/data/01_rbac.ldif.j2
Normal 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 %}
|
@ -3,7 +3,9 @@ users:
|
|||||||
username: "administrator"
|
username: "administrator"
|
||||||
bounce:
|
bounce:
|
||||||
username: "bounce"
|
username: "bounce"
|
||||||
mailu_token_enabled: true
|
roles:
|
||||||
|
- mail-bot
|
||||||
newsletter:
|
newsletter:
|
||||||
username: "newsletter"
|
username: "newsletter"
|
||||||
mailu_token_enabled: true
|
roles:
|
||||||
|
- mail-bot
|
@ -12,6 +12,7 @@
|
|||||||
"Duplicate entry" not in mailu_user_result.stderr
|
"Duplicate entry" not in mailu_user_result.stderr
|
||||||
)
|
)
|
||||||
changed_when: mailu_user_result.rc == 0
|
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 }}'"
|
- name: "Change password for user '{{ mailu_user_key }};{{ mailu_user_name }}@{{ mailu_domain }}'"
|
||||||
command: >
|
command: >
|
||||||
@ -19,7 +20,8 @@
|
|||||||
{{ mailu_user_name }} {{ mailu_domain }} '{{ mailu_password }}'
|
{{ mailu_user_name }} {{ mailu_domain }} '{{ mailu_password }}'
|
||||||
args:
|
args:
|
||||||
chdir: "{{ mailu_compose_dir }}"
|
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 }}"
|
- name: "Create Mailu API Token for {{ mailu_user_name }}"
|
||||||
include_tasks: create-mailu-token.yml
|
include_tasks: create-mailu-token.yml
|
||||||
when: mailu_token_enabled
|
when: "{{ 'mail-bot' in item.value.roles }}"
|
@ -42,7 +42,6 @@
|
|||||||
mailu_user_key: "{{ item.key }}"
|
mailu_user_key: "{{ item.key }}"
|
||||||
mailu_user_name: "{{ item.value.username }}"
|
mailu_user_name: "{{ item.value.username }}"
|
||||||
mailu_password: "{{ item.value.password }}"
|
mailu_password: "{{ item.value.password }}"
|
||||||
mailu_token_enabled: "{{ item.value.mailu_token_enabled | default(false)}}"
|
|
||||||
mailu_token_ip: "{{ item.value.ip | default('') }}"
|
mailu_token_ip: "{{ item.value.ip | default('') }}"
|
||||||
loop: "{{ users | dict2items }}"
|
loop: "{{ users | dict2items }}"
|
||||||
loop_control:
|
loop_control:
|
||||||
|
@ -22,4 +22,8 @@ csp:
|
|||||||
script-src:
|
script-src:
|
||||||
unsafe-inline: true
|
unsafe-inline: true
|
||||||
unsafe-eval: true
|
unsafe-eval: true
|
||||||
|
rbac:
|
||||||
|
roles:
|
||||||
|
mail-bot:
|
||||||
|
description: "Has an token to send and recieve emails"
|
||||||
|
|
@ -3,4 +3,5 @@ users:
|
|||||||
username: "administrator"
|
username: "administrator"
|
||||||
no-reply:
|
no-reply:
|
||||||
username: "no-reply"
|
username: "no-reply"
|
||||||
mailu_token_enabled: true
|
roles:
|
||||||
|
- mail-bot
|
@ -46,7 +46,7 @@ accounts:
|
|||||||
iframe: {{ applications | is_feature_enabled('portfolio_iframe','pixelfed') }}
|
iframe: {{ applications | is_feature_enabled('portfolio_iframe','pixelfed') }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if service_provider.contact.peertube is defined and service_provider.contact.peertube != "" %}
|
{% 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.
|
description: Discover {{ 'our' if service_provider.type == 'legal' else 'my' }} videos on Peertube.
|
||||||
icon:
|
icon:
|
||||||
class: fa-solid fa-video
|
class: fa-solid fa-video
|
||||||
|
0
tests/unit/roles/docker-ldap/__init__.py
Normal file
0
tests/unit/roles/docker-ldap/__init__.py
Normal file
96
tests/unit/roles/docker-ldap/test_build_ldap_role_entries.py
Normal file
96
tests/unit/roles/docker-ldap/test_build_ldap_role_entries.py
Normal 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()
|
Loading…
x
Reference in New Issue
Block a user