Optimized RBAC implementation

This commit is contained in:
Kevin Veen-Birkenbach 2025-07-03 22:51:42 +02:00
parent 1486862327
commit a93e1520d4
No known key found for this signature in database
GPG Key ID: 44D8F11FD62F878E
20 changed files with 106 additions and 68 deletions

View File

@ -50,6 +50,7 @@ def build_users(defs, primary_domain, start_id, become_pwd):
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',[])
password = overrides.get('password',become_pwd) password = overrides.get('password',become_pwd)
# UID assignment # UID assignment
if 'uid' in overrides: if 'uid' in overrides:
@ -63,12 +64,11 @@ def build_users(defs, primary_domain, start_id, become_pwd):
'email': email, 'email': email,
'password': password, 'password': password,
'uid': uid, 'uid': uid,
'gid': gid 'gid': gid,
'roles': roles
} }
if description is not None: if description is not None:
entry['description'] = description entry['description'] = description
if overrides.get('is_admin', False):
entry['is_admin'] = True
users[key] = entry users[key] = entry

View File

@ -35,16 +35,17 @@ ldap:
# Typically: “cn=admin,cn=config” # Typically: “cn=admin,cn=config”
configuration: "cn={{ applications.ldap.users.administrator.username }},cn=config" configuration: "cn={{ applications.ldap.users.administrator.username }},cn=config"
ou:
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
# Organizational Units (OUs) # Organizational Units (OUs)
# Pre-created containers in the data tree to organize entries. # Pre-created containers in the directory tree to logically separate entries:
# users: Where all person/posixAccount entries live. # users: Contains all user objects (person/posixAccount entries).
# groups: Where you define your application or business groups. # groups: Contains organizational or business groups (e.g., departments, teams).
# roles: A flat container for application-role entries (e.g. “cn=app1-user”). # roles: Contains application-specific RBAC roles
# (e.g., "cn=app1-user", "cn=yourls-admin").
users: "ou=users,{{ _ldap_dn_base }}" users: "ou=users,{{ _ldap_dn_base }}"
groups: "ou=groups,{{ _ldap_dn_base }}" groups: "ou=groups,{{ _ldap_dn_base }}"
application_roles: "ou=application_roles,{{ _ldap_dn_base }}" roles: "ou=roles,{{ _ldap_dn_base }}"
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
# Additional Notes # Additional Notes

View File

@ -166,7 +166,7 @@ run:
# LDAP additional configuration # LDAP additional configuration
- exec: rails r "SiteSetting.ldap_user_filter = '{{ ldap.filters.users.login }}'" - exec: rails r "SiteSetting.ldap_user_filter = '{{ ldap.filters.users.login }}'"
- exec: rails r "SiteSetting.ldap_group_base_dn = '{{ ldap.dn.groups }}'" - exec: rails r "SiteSetting.ldap_group_base_dn = '{{ ldap.dn.ou.groups }}'"
- exec: rails r "SiteSetting.ldap_group_member_check = 'memberUid'" - exec: rails r "SiteSetting.ldap_group_member_check = 'memberUid'"
- exec: rails r "SiteSetting.ldap_sync_period = 1" - exec: rails r "SiteSetting.ldap_sync_period = 1"

View File

@ -69,7 +69,7 @@ ESPOCRM_CONFIG_LDAP_PORT={{ ldap.server.port }}
ESPOCRM_CONFIG_LDAP_SECURITY={{ ldap.server.security }} ESPOCRM_CONFIG_LDAP_SECURITY={{ ldap.server.security }}
ESPOCRM_CONFIG_LDAP_USERNAME={{ ldap.dn.administrator.data }} ESPOCRM_CONFIG_LDAP_USERNAME={{ ldap.dn.administrator.data }}
ESPOCRM_CONFIG_LDAP_PASSWORD={{ ldap.bind_credential }} ESPOCRM_CONFIG_LDAP_PASSWORD={{ ldap.bind_credential }}
ESPOCRM_CONFIG_LDAP_BASE_DN={{ ldap.dn.users }} ESPOCRM_CONFIG_LDAP_BASE_DN={{ ldap.dn.ou.users }}
ESPOCRM_CONFIG_LDAP_USER_LOGIN_FILTER=(sAMAccountName=%USERNAME%) ESPOCRM_CONFIG_LDAP_USER_LOGIN_FILTER=(sAMAccountName=%USERNAME%)
{% endif %} {% endif %}

View File

@ -7,7 +7,7 @@ gitea_ldap_auth_args:
- '--security-protocol "{{ ldap.server.security | trim or "unencrypted" }}"' - '--security-protocol "{{ ldap.server.security | trim or "unencrypted" }}"'
- '--bind-dn "{{ ldap.dn.administrator.data }}"' - '--bind-dn "{{ ldap.dn.administrator.data }}"'
- '--bind-password "{{ ldap.bind_credential }}"' - '--bind-password "{{ ldap.bind_credential }}"'
- '--user-search-base "{{ ldap.dn.users }}"' - '--user-search-base "{{ ldap.dn.ou.users }}"'
- '--user-filter "(&(objectClass=inetOrgPerson)(uid=%s))"' - '--user-filter "(&(objectClass=inetOrgPerson)(uid=%s))"'
- '--username-attribute "{{ ldap.attributes.user_id }}"' - '--username-attribute "{{ ldap.attributes.user_id }}"'
- '--firstname-attribute "{{ ldap.attributes.firstname }}"' - '--firstname-attribute "{{ ldap.attributes.firstname }}"'

View File

@ -2045,7 +2045,7 @@
"false" "false"
], ],
"usersDn": [ "usersDn": [
"{{ldap.dn.users}}" "{{ldap.dn.ou.users}}"
], ],
"connectionPooling": [ "connectionPooling": [
"true" "true"

View File

@ -3,7 +3,7 @@
server_uri: "{{ ldap_server_uri }}" server_uri: "{{ ldap_server_uri }}"
bind_dn: "{{ ldap.dn.administrator.data }}" bind_dn: "{{ ldap.dn.administrator.data }}"
bind_pw: "{{ ldap.bind_credential }}" bind_pw: "{{ ldap.bind_credential }}"
dn: "{{ ldap.dn.users }}" dn: "{{ ldap.dn.ou.users }}"
scope: subordinate scope: subordinate
filter: "{{ ldap.filters.users.all }}" filter: "{{ ldap.filters.users.all }}"
attrs: attrs:

View File

@ -63,7 +63,7 @@
############################################################################### ###############################################################################
- name: Ensure LDAP users exist - name: Ensure LDAP users exist
community.general.ldap_entry: community.general.ldap_entry:
dn: "{{ ldap.attributes.user_id }}={{ item.key }},{{ ldap.dn.users }}" dn: "{{ ldap.attributes.user_id }}={{ item.key }},{{ ldap.dn.ou.users }}"
server_uri: "{{ ldap_server_uri }}" server_uri: "{{ ldap_server_uri }}"
bind_dn: "{{ ldap.dn.administrator.data }}" bind_dn: "{{ ldap.dn.administrator.data }}"
bind_pw: "{{ ldap.bind_credential }}" bind_pw: "{{ ldap.bind_credential }}"
@ -87,7 +87,7 @@
############################################################################### ###############################################################################
- name: Ensure required objectClass values and mail address are present - name: Ensure required objectClass values and mail address are present
community.general.ldap_attrs: community.general.ldap_attrs:
dn: "{{ ldap.attributes.user_id }}={{ item.key }},{{ ldap.dn.users }}" dn: "{{ ldap.attributes.user_id }}={{ item.key }},{{ ldap.dn.ou.users }}"
server_uri: "{{ ldap_server_uri }}" server_uri: "{{ ldap_server_uri }}"
bind_dn: "{{ ldap.dn.administrator.data }}" bind_dn: "{{ ldap.dn.administrator.data }}"
bind_pw: "{{ ldap.bind_credential }}" bind_pw: "{{ ldap.bind_credential }}"
@ -101,7 +101,7 @@
- name: "Ensure container for application roles exists" - name: "Ensure container for application roles exists"
community.general.ldap_entry: community.general.ldap_entry:
dn: "{{ ldap.dn.application_roles }}" dn: "{{ ldap.dn.ou.roles }}"
server_uri: "{{ ldap_server_uri }}" server_uri: "{{ ldap_server_uri }}"
bind_dn: "{{ ldap.dn.administrator.data }}" bind_dn: "{{ ldap.dn.administrator.data }}"
bind_pw: "{{ ldap.bind_credential }}" bind_pw: "{{ ldap.bind_credential }}"

View File

@ -1,42 +1,34 @@
{% for app, config in applications.items() %} {% for application_id, application_config in applications.items() %}
dn: cn={{ app }}-administrator,{{ldap.dn.application_roles}}
{# 1. Build up roles dict, defaulting to {} if rbac oder roles fehlt, then ensure administrator immer dabei ist #}
{% 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'
}
})
%}
{# 2. Emit role definitions #}
{% for role_name, role_conf in roles.items() %}
dn: cn={{ application_id }}-{{ role_name }},{{ ldap.dn.ou.roles }}
objectClass: top objectClass: top
objectClass: organizationalRole objectClass: organizationalRole
cn: {{ app }}-administrator cn: {{ application_id }}-{{ role_name }}
description: Administrator role for {{ app }} (automatically generated) description: {{ role_conf.description }}
dn: cn={{ app }}-user,{{ldap.dn.application_roles}} {# 3. Assign only if user has that role #}
objectClass: top {% for username, user_config in users.items() %}
objectClass: organizationalRole {% set user_roles = user_config.roles | default([]) %}
cn: {{ app }}-user {% if role_name in user_roles %}
description: Standard user role for {{ app }} (automatically generated) dn: cn={{ application_id }}-{{ role_name }},{{ ldap.dn.ou.roles }}
{% endfor %}
{% for username, user in users.items() %}
#######################################################################
# Assign {{ username }} to application user roles
#######################################################################
{% for app, config in applications.items() %}
# Assign {{ username }} to {{ app }}-users
dn: cn={{ app }}-user,{{ ldap.dn.application_roles }}
changetype: modify changetype: modify
add: roleOccupant add: roleOccupant
roleOccupant: {{ ldap.attributes.user_id }}={{ username }},{{ ldap.dn.users }} roleOccupant: {{ ldap.attributes.user_id }}={{ username }},{{ ldap.dn.ou.users }}
{% if users.is_admin | default(false) | bool %} {% endif %}
{% endfor %}
# Assign {{ username }} to {{ app }}-administrator {% endfor %}
dn: cn={{ app }}-administrator,{{ ldap.dn.application_roles }}
changetype: modify
add: roleOccupant
roleOccupant: {{ ldap.attributes.user_id }}={{ users.administrator.username }},{{ ldap.dn.users }}
{% endif %}
{% endfor %}
{% endfor %} {% endfor %}

View File

@ -32,7 +32,13 @@
mailu_domain: "{{ primary_domain }}" mailu_domain: "{{ primary_domain }}"
mailu_api_base_url: "http://127.0.0.1:8080/api/v1" mailu_api_base_url: "http://127.0.0.1:8080/api/v1"
mailu_global_api_token: "{{ applications.mailu.credentials.api_token }}" mailu_global_api_token: "{{ applications.mailu.credentials.api_token }}"
mailu_action: "{{ item.value.is_admin | default(false) | ternary('admin','user') }}" mailu_action: >-
{{
(
'administrator' in (item.value.get('roles', []))
)
| ternary('admin','user')
}}
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 }}"

View File

@ -1,6 +1,25 @@
# Routines to create the administrator account # Routines to create the administrator account
# @see https://chatgpt.com/share/67b9b12c-064c-800f-9354-8e42e6459764 # @see https://chatgpt.com/share/67b9b12c-064c-800f-9354-8e42e6459764
- name: Check health status of {{ item }} container
shell: |
cid=$(docker compose ps -q {{ item }})
docker inspect \
--format '{{ "{{.State.Health.Status}}" }}' \
$cid
args:
chdir: "{{ docker_compose.directories.instance }}"
register: healthcheck
retries: 60
delay: 5
until: healthcheck.stdout == "healthy"
loop:
- web
- streaming
- sidekiq
loop_control:
label: "{{ item }}"
- name: Remove line containing "- administrator" from config/settings.yml to allow creating administrator account - name: Remove line containing "- administrator" from config/settings.yml to allow creating administrator account
command: command:
cmd: "docker compose exec -u root web sed -i '/- administrator/d' config/settings.yml" cmd: "docker compose exec -u root web sed -i '/- administrator/d' config/settings.yml"

View File

@ -42,7 +42,7 @@ plugin_configuration:
- -
appid: "user_ldap" appid: "user_ldap"
configkey: "s01ldap_base_users" configkey: "s01ldap_base_users"
configvalue: "{{ldap.dn.users}}" configvalue: "{{ldap.dn.ou.users}}"
- -
appid: "user_ldap" appid: "user_ldap"

View File

@ -1,6 +1,5 @@
http_address = "0.0.0.0:4180" http_address = "0.0.0.0:4180"
cookie_secret = "{{ applications[oauth2_proxy_application_id].credentials.oauth2_proxy_cookie_secret }}" cookie_secret = "{{ applications[oauth2_proxy_application_id].credentials.oauth2_proxy_cookie_secret }}"
email_domains = "{{ primary_domain }}"
cookie_secure = "true" # True is necessary to force the cookie set via https cookie_secure = "true" # True is necessary to force the cookie set via https
upstreams = "http://{{ applications[oauth2_proxy_application_id].oauth2_proxy.application }}:{{ applications[oauth2_proxy_application_id].oauth2_proxy.port }}" upstreams = "http://{{ applications[oauth2_proxy_application_id].oauth2_proxy.application }}:{{ applications[oauth2_proxy_application_id].oauth2_proxy.port }}"
cookie_domains = ["{{ domains | get_domain(oauth2_proxy_application_id) }}", "{{ domains | get_domain('keycloak') }}"] # Required so cookie can be read on all subdomains. cookie_domains = ["{{ domains | get_domain(oauth2_proxy_application_id) }}", "{{ domains | get_domain('keycloak') }}"] # Required so cookie can be read on all subdomains.
@ -14,7 +13,11 @@ oidc_issuer_url = "{{ oidc.client.issuer_url }}"
provider = "oidc" provider = "oidc"
provider_display_name = "Keycloak" provider_display_name = "Keycloak"
# role restrictions {% if applications[oauth2_proxy_application_id].oauth2_proxy.allowed_groups is defined %}
#cookie_roles = "realm_access.roles" {# role based restrictions #}
#allowed_groups = "{{ applications[application_id].allowed_roles }}" # This is not correct here. needs to be placed in applications @todo move there when implementing scope = "openid email profile groups"
# @see https://chatgpt.com/share/67f42607-bf68-800f-b587-bd56fe9067b5 oidc_groups_claim = "realm_access.roles"
allowed_groups = {{ applications[oauth2_proxy_application_id].oauth2_proxy.allowed_groups | tojson }}
{% else %}
email_domains = "{{ primary_domain }}"
{% endif %}

View File

@ -4,7 +4,7 @@ openproject_ldap:
port: "{{ ldap.server.port }}" # LDAP server port (typically 389 or 636) port: "{{ ldap.server.port }}" # LDAP server port (typically 389 or 636)
account: "{{ ldap.dn.administrator.data }}" # Bind DN (used for authentication) account: "{{ ldap.dn.administrator.data }}" # Bind DN (used for authentication)
account_password: "{{ ldap.bind_credential }}" # Bind password account_password: "{{ ldap.bind_credential }}" # Bind password
base_dn: "{{ ldap.dn.users }}" # Base DN for user search base_dn: "{{ ldap.dn.ou.users }}" # Base DN for user search
attr_login: "{{ ldap.attributes.user_id }}" # LDAP attribute used for login attr_login: "{{ ldap.attributes.user_id }}" # LDAP attribute used for login
attr_firstname: "givenName" # LDAP attribute for first name attr_firstname: "givenName" # LDAP attribute for first name
attr_lastname: "{{ ldap.attributes.surname }}" # LDAP attribute for last name attr_lastname: "{{ ldap.attributes.surname }}" # LDAP attribute for last name

View File

@ -19,9 +19,9 @@ openproject_rails_settings:
openproject_filters: openproject_filters:
administrators: >- administrators: >-
{{ '(memberOf=cn=openproject-admins,' ~ ldap.dn.application_roles ~ ')' {{ '(memberOf=cn=openproject-admins,' ~ ldap.dn.ou.roles ~ ')'
if applications[application_id].ldap.filters.administrators else '' }} if applications[application_id].ldap.filters.administrators else '' }}
users: >- users: >-
{{ '(memberOf=cn=openproject-users,' ~ ldap.dn.application_roles ~ ')' {{ '(memberOf=cn=openproject-users,' ~ ldap.dn.ou.roles ~ ')'
if applications[application_id].ldap.filters.users else '' }} if applications[application_id].ldap.filters.users else '' }}

View File

@ -45,7 +45,7 @@
$s->ldap_server = "{{ ldap.server.uri }}"; $s->ldap_server = "{{ ldap.server.uri }}";
$s->ldap_port = {{ ldap.server.port }}; $s->ldap_port = {{ ldap.server.port }};
$s->ldap_uname = "{{ ldap.dn.administrator.data }}"; $s->ldap_uname = "{{ ldap.dn.administrator.data }}";
$s->ldap_basedn = "{{ ldap.dn.users }}"; $s->ldap_basedn = "{{ ldap.dn.ou.users }}";
$s->ldap_filter = "&(objectClass=inetOrgPerson)"; $s->ldap_filter = "&(objectClass=inetOrgPerson)";
$s->ldap_username_field = "{{ ldap.attributes.user_id }}"; $s->ldap_username_field = "{{ ldap.attributes.user_id }}";
$s->ldap_fname_field = "{{ ldap.attributes.firstname }}"; $s->ldap_fname_field = "{{ ldap.attributes.firstname }}";

View File

@ -39,3 +39,15 @@ csp:
domains: domains:
canonical: canonical:
- "blog.{{ primary_domain }}" - "blog.{{ primary_domain }}"
rbac:
roles:
subscriber:
description: "Can read posts and leave comments but cannot write or manage content"
author:
description: "Can write and manage own posts"
contributor:
description: "Can write and submit posts for review but cannot publish"
editor:
description: "Can publish and manage all posts, including those by other users"
administrator:
description: "Has full administrative access: manage themes, plugins, settings, and users"

View File

@ -5,3 +5,5 @@ YOURLS_DB_NAME: "{{database_name}}"
YOURLS_SITE: "{{ web_protocol }}://{{domains | get_domain(application_id)}}" YOURLS_SITE: "{{ web_protocol }}://{{domains | get_domain(application_id)}}"
YOURLS_USER: "{{applications.yourls.users.administrator.username}}" YOURLS_USER: "{{applications.yourls.users.administrator.username}}"
YOURLS_PASS: "{{applications[application_id].credentials.administrator_password}}" YOURLS_PASS: "{{applications[application_id].credentials.administrator_password}}"
# The following deactivates the login mask for admins, if the oauth2 proxy is activated
YOURLS_PRIVATE: "{{not (applications | is_feature_enabled('oauth2', application_id))}}"

View File

@ -2,6 +2,8 @@ version: "latest"
oauth2_proxy: oauth2_proxy:
application: "application" application: "application"
port: "80" port: "80"
allowed_groups:
- "yourls-administrator"
acl: acl:
blacklist: blacklist:
- "/admin/" # Protects the admin area - "/admin/" # Protects the admin area

View File

@ -6,4 +6,5 @@ users:
password: "{{ ansible_become_password }}" password: "{{ ansible_become_password }}"
uid: 1001 uid: 1001
gid: 1001 gid: 1001
is_admin: true roles:
- administrator