Optimized RBAC via LDAP

This commit is contained in:
2025-07-04 08:03:27 +02:00
parent a9f55579a2
commit ee0561db72
25 changed files with 316 additions and 111 deletions

0
roles/__init__.py Normal file
View File

View File

@@ -1,7 +1,8 @@
users:
administrator:
username: "administrator"
username: "administrator"
contact:
description: "General contact account"
username: "contact"
mailu_token_enabled: true
roles:
- mail-bot

View File

@@ -24,4 +24,6 @@ csp:
- https://s.espocrm.com/
domains:
aliases:
- "crm.{{ primary_domain }}"
- "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 }})"
database_type: "mariadb"

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