mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2025-08-15 08:30:46 +02:00
Added auto setting for redirect urls for keycloak clients. Element and Synapse still need to be mapped
This commit is contained in:
parent
0746acedfd
commit
6e8ae793e3
1
Todo.md
1
Todo.md
@ -1,3 +1,4 @@
|
|||||||
# Todos
|
# Todos
|
||||||
- Implement multi language
|
- Implement multi language
|
||||||
- Implement rbac administration interface
|
- Implement rbac administration interface
|
||||||
|
- Implement ``MASK_CREDENTIALS_IN_LOGS`` for all sensible tasks
|
@ -1,27 +1,11 @@
|
|||||||
#!/usr/bin/python
|
#!/usr/bin/python
|
||||||
import os
|
import sys, os
|
||||||
import sys
|
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
|
||||||
from ansible.errors import AnsibleFilterError
|
from module_utils.get_url import get_url
|
||||||
|
|
||||||
class FilterModule(object):
|
class FilterModule(object):
|
||||||
|
''' Infinito.Nexus application config extraction filters '''
|
||||||
def filters(self):
|
def filters(self):
|
||||||
return {'get_url': self.get_url}
|
return {
|
||||||
|
'get_url': get_url,
|
||||||
def get_url(self, domains, application_id, protocol):
|
}
|
||||||
# 1) module_util-Verzeichnis in den Pfad aufnehmen
|
|
||||||
plugin_dir = os.path.dirname(__file__)
|
|
||||||
project_root = os.path.dirname(plugin_dir)
|
|
||||||
module_utils = os.path.join(project_root, 'module_utils')
|
|
||||||
if module_utils not in sys.path:
|
|
||||||
sys.path.append(module_utils)
|
|
||||||
|
|
||||||
# 2) jetzt domain_utils importieren
|
|
||||||
try:
|
|
||||||
from domain_utils import get_domain
|
|
||||||
except ImportError as e:
|
|
||||||
raise AnsibleFilterError(f"could not import domain_utils: {e}")
|
|
||||||
|
|
||||||
# 3) Validierung und Aufruf
|
|
||||||
if not isinstance(protocol, str):
|
|
||||||
raise AnsibleFilterError("Protocol must be a string")
|
|
||||||
return f"{protocol}://{ get_domain(domains, application_id) }"
|
|
||||||
|
@ -1,5 +1,10 @@
|
|||||||
INFINITO_ENVIRONMENT: "production" # Possible values: production, development
|
INFINITO_ENVIRONMENT: "production" # Possible values: production, development
|
||||||
|
|
||||||
|
# If true, sensitive credentials will be masked or hidden from all Ansible task logs
|
||||||
|
# Recommendet to set to true
|
||||||
|
# @todo needs to be implemented everywhere
|
||||||
|
MASK_CREDENTIALS_IN_LOGS: true
|
||||||
|
|
||||||
HOST_CURRENCY: "EUR"
|
HOST_CURRENCY: "EUR"
|
||||||
HOST_TIMEZONE: "UTC"
|
HOST_TIMEZONE: "UTC"
|
||||||
|
|
||||||
|
18
module_utils/get_url.py
Normal file
18
module_utils/get_url.py
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
from ansible.errors import AnsibleFilterError
|
||||||
|
import sys, os
|
||||||
|
|
||||||
|
def get_url(domains, application_id, protocol):
|
||||||
|
plugin_dir = os.path.dirname(__file__)
|
||||||
|
project_root = os.path.dirname(plugin_dir)
|
||||||
|
module_utils = os.path.join(project_root, 'module_utils')
|
||||||
|
if module_utils not in sys.path:
|
||||||
|
sys.path.append(module_utils)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from domain_utils import get_domain
|
||||||
|
except ImportError as e:
|
||||||
|
raise AnsibleFilterError(f"could not import domain_utils: {e}")
|
||||||
|
|
||||||
|
if not isinstance(protocol, str):
|
||||||
|
raise AnsibleFilterError("Protocol must be a string")
|
||||||
|
return f"{protocol}://{ get_domain(domains, application_id) }"
|
@ -1,4 +1,4 @@
|
|||||||
import_realm: True # If True realm will be imported. If false skip.
|
import_realm: True # If True realm will be imported. If false skip.
|
||||||
features:
|
features:
|
||||||
matomo: true
|
matomo: true
|
||||||
css: true
|
css: true
|
||||||
@ -6,7 +6,12 @@ features:
|
|||||||
ldap: true
|
ldap: true
|
||||||
central_database: true
|
central_database: true
|
||||||
recaptcha: true
|
recaptcha: true
|
||||||
logout: true
|
|
||||||
|
# Doesn't make sense to activate logout page for keycloak, because the logout page
|
||||||
|
# anyhow should be included via iframe in keycloak.
|
||||||
|
# The JS is also messing with the keycloak config fields
|
||||||
|
# @todo optimize the JS
|
||||||
|
logout: false
|
||||||
server:
|
server:
|
||||||
csp:
|
csp:
|
||||||
flags:
|
flags:
|
||||||
|
0
roles/web-app-keycloak/filter_plugins/__init__.py
Normal file
0
roles/web-app-keycloak/filter_plugins/__init__.py
Normal file
82
roles/web-app-keycloak/filter_plugins/redirect_uris.py
Normal file
82
roles/web-app-keycloak/filter_plugins/redirect_uris.py
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
# roles/web-app-keycloak/filter_plugins/redirect_uris.py
|
||||||
|
from __future__ import annotations
|
||||||
|
import os, sys
|
||||||
|
from typing import Iterable, Sequence
|
||||||
|
from ansible.errors import AnsibleFilterError
|
||||||
|
|
||||||
|
# --- Locate project root that contains `module_utils/` dynamically (up to 5 levels) ---
|
||||||
|
def _ensure_module_utils_on_path():
|
||||||
|
here = os.path.dirname(__file__)
|
||||||
|
for depth in range(1, 6):
|
||||||
|
candidate = os.path.abspath(os.path.join(here, *(['..'] * depth)))
|
||||||
|
if os.path.isdir(os.path.join(candidate, 'module_utils')):
|
||||||
|
if candidate not in sys.path:
|
||||||
|
sys.path.insert(0, candidate)
|
||||||
|
return
|
||||||
|
# If not found, imports below will raise a clear error
|
||||||
|
_ensure_module_utils_on_path()
|
||||||
|
|
||||||
|
# Import your existing helpers
|
||||||
|
from module_utils.config_utils import get_app_conf, AppConfigKeyError, ConfigEntryNotSetError
|
||||||
|
from module_utils.get_url import get_url # returns "<protocol>://<domain>"
|
||||||
|
|
||||||
|
def _stable_dedup(items: Sequence[str]) -> list[str]:
|
||||||
|
seen = set()
|
||||||
|
out: list[str] = []
|
||||||
|
for x in items:
|
||||||
|
if x not in seen:
|
||||||
|
seen.add(x)
|
||||||
|
out.append(x)
|
||||||
|
return out
|
||||||
|
|
||||||
|
def redirect_uris(domains: dict,
|
||||||
|
applications: dict,
|
||||||
|
web_protocol: str = "https",
|
||||||
|
wildcard: str = "/*",
|
||||||
|
features: Iterable[str] = ("features.oauth2", "features.oidc"),
|
||||||
|
dedup: bool = True) -> list[str]:
|
||||||
|
"""
|
||||||
|
Build redirect URIs using:
|
||||||
|
- get_app_conf(applications, app_id, dotted_key, default) for feature gating
|
||||||
|
- get_url(domains_subset, app_id, web_protocol) to form "<proto>://<domain>"
|
||||||
|
|
||||||
|
For domain lists, we call get_url() once per domain by passing a minimal
|
||||||
|
per-app subset like {app_id: "example.org"} to preserve your original
|
||||||
|
'one entry per domain' behavior.
|
||||||
|
"""
|
||||||
|
if not isinstance(domains, dict):
|
||||||
|
raise AnsibleFilterError("redirect_uris: 'domains' must be a dict mapping app_id -> domain or list of domains")
|
||||||
|
|
||||||
|
uris: list[str] = []
|
||||||
|
|
||||||
|
for app_id, domain_value in domains.items():
|
||||||
|
# Feature check via get_app_conf
|
||||||
|
try:
|
||||||
|
has_feature = any(bool(get_app_conf(applications, app_id, f, False)) for f in features)
|
||||||
|
except (AppConfigKeyError, ConfigEntryNotSetError):
|
||||||
|
has_feature = False
|
||||||
|
|
||||||
|
if not has_feature:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Normalize to iterable of domains
|
||||||
|
doms = [domain_value] if isinstance(domain_value, str) else list(domain_value or [])
|
||||||
|
|
||||||
|
for d in doms:
|
||||||
|
# Use get_url() to produce "<proto>://<domain>"
|
||||||
|
# Pass a minimal per-app mapping so get_domain() resolves to 'd'
|
||||||
|
try:
|
||||||
|
url = get_url({app_id: d}, app_id, web_protocol)
|
||||||
|
except Exception as e:
|
||||||
|
raise AnsibleFilterError(f"redirect_uris: get_url failed for app '{app_id}' with domain '{d}': {e}")
|
||||||
|
uris.append(f"{url}{wildcard}")
|
||||||
|
|
||||||
|
return _stable_dedup(uris) if dedup else uris
|
||||||
|
|
||||||
|
|
||||||
|
class FilterModule(object):
|
||||||
|
"""Infinito.Nexus redirect URI builder (uses get_app_conf + get_url)"""
|
||||||
|
def filters(self):
|
||||||
|
return {
|
||||||
|
"redirect_uris": redirect_uris,
|
||||||
|
}
|
130
roles/web-app-keycloak/tasks/02_update_client_redirects.yml
Normal file
130
roles/web-app-keycloak/tasks/02_update_client_redirects.yml
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
---
|
||||||
|
# Update redirectUris/webOrigins per kcadm.sh — no defaults used.
|
||||||
|
|
||||||
|
# ── REQUIRED VARS (must be provided by caller) ───────────────────────────────
|
||||||
|
# - WEB_PROTOCOL e.g. "https"
|
||||||
|
# - keycloak_realm target realm name
|
||||||
|
# - keycloak_server_host_url e.g. "http://127.0.0.1:8080"
|
||||||
|
# - keycloak_server_internal_url e.g. "http://127.0.0.1:8080"
|
||||||
|
# - keycloak_kcadm_path e.g. "docker exec -i keycloak /opt/keycloak/bin/kcadm.sh"
|
||||||
|
# - keycloak_master_api_user_name
|
||||||
|
# - keycloak_master_api_user_password
|
||||||
|
# - keycloak_client_id clientId to update (e.g. same as realm or an app client)
|
||||||
|
# - domains your domain map
|
||||||
|
# - applications your applications map
|
||||||
|
|
||||||
|
- name: "Assert required variables are present (no defaults allowed)"
|
||||||
|
assert:
|
||||||
|
that:
|
||||||
|
- WEB_PROTOCOL is defined
|
||||||
|
- keycloak_realm is defined
|
||||||
|
- keycloak_server_host_url is defined
|
||||||
|
- keycloak_server_internal_url is defined
|
||||||
|
- keycloak_kcadm_path is defined
|
||||||
|
- keycloak_master_api_user_name is defined
|
||||||
|
- keycloak_master_api_user_password is defined
|
||||||
|
- keycloak_client_id is defined
|
||||||
|
- keycloak_redirect_features is defined
|
||||||
|
- domains is defined
|
||||||
|
- applications is defined
|
||||||
|
fail_msg: "Missing required variable(s). Provide all vars listed at the top of 10_update_client_redirects.yml."
|
||||||
|
|
||||||
|
# 0) Wait & login
|
||||||
|
- name: "Wait until Keycloak is reachable at {{ keycloak_server_host_url }}"
|
||||||
|
uri:
|
||||||
|
url: "{{ keycloak_server_host_url }}/realms/master"
|
||||||
|
method: GET
|
||||||
|
status_code: 200
|
||||||
|
validate_certs: false
|
||||||
|
register: kc_up
|
||||||
|
retries: 30
|
||||||
|
delay: 5
|
||||||
|
until: kc_up.status == 200
|
||||||
|
|
||||||
|
- name: "kcadm login"
|
||||||
|
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||||
|
shell: >
|
||||||
|
{{ keycloak_kcadm_path }} config credentials
|
||||||
|
--server {{ keycloak_server_internal_url }}
|
||||||
|
--realm master
|
||||||
|
--user {{ keycloak_master_api_user_name }}
|
||||||
|
--password {{ keycloak_master_api_user_password }}
|
||||||
|
changed_when: false
|
||||||
|
|
||||||
|
# 1) Build desired sets (NO defaults)
|
||||||
|
- name: "Build desired redirect URIs from config via filter"
|
||||||
|
set_fact:
|
||||||
|
kc_redirect_uris: >-
|
||||||
|
{{ domains | redirect_uris(applications, WEB_PROTOCOL, '/*', keycloak_redirect_features, True) }}
|
||||||
|
|
||||||
|
- name: Build desired web origins (scheme://host[:port])
|
||||||
|
set_fact:
|
||||||
|
kc_web_origins: >-
|
||||||
|
{{ kc_redirect_uris
|
||||||
|
| map('regex_replace','/\\*$','')
|
||||||
|
| map('regex_search','^(https?://[^/]+)')
|
||||||
|
| select('string')
|
||||||
|
| list | unique }}
|
||||||
|
|
||||||
|
#- name: "Build post.logout.redirect.uris value ('+' plus explicit URIs without /*)"
|
||||||
|
# set_fact:
|
||||||
|
# kc_desired_post_logout_uris: >-
|
||||||
|
# {{ (['+'] + (kc_redirect_uris | map('regex_replace','/\\*$','') | list)) | join('\n') }}
|
||||||
|
|
||||||
|
# 2) Resolve client id (strictly by provided clientId, no fallback)
|
||||||
|
- name: "Resolve client internal id for {{ keycloak_client_id }}"
|
||||||
|
shell: >
|
||||||
|
{{ keycloak_kcadm_path }} get clients
|
||||||
|
-r {{ keycloak_realm }}
|
||||||
|
--query 'clientId={{ keycloak_client_id }}'
|
||||||
|
--fields id --format json | jq -r '.[0].id'
|
||||||
|
register: kc_client
|
||||||
|
changed_when: false
|
||||||
|
|
||||||
|
- name: "Fail if client not found"
|
||||||
|
assert:
|
||||||
|
that: kc_client.stdout is match('^[0-9a-f-]+$')
|
||||||
|
fail_msg: "Client '{{ keycloak_client_id }}' not found in realm '{{ keycloak_realm }}'."
|
||||||
|
|
||||||
|
# 3) Read current config (assume keys exist; we don't use defaults)
|
||||||
|
- name: "Read current client configuration"
|
||||||
|
shell: >
|
||||||
|
{{ keycloak_kcadm_path }} get clients/{{ kc_client.stdout }}
|
||||||
|
-r {{ keycloak_realm }} --format json
|
||||||
|
register: kc_client_obj
|
||||||
|
changed_when: false
|
||||||
|
|
||||||
|
- name: "Normalize current vs desired for comparison"
|
||||||
|
set_fact:
|
||||||
|
kc_current_redirect_uris: "{{ (kc_client_obj.stdout | from_json).redirectUris | sort }}"
|
||||||
|
kc_current_web_origins: "{{ (kc_client_obj.stdout | from_json).webOrigins | sort }}"
|
||||||
|
kc_current_logout_uris: >-
|
||||||
|
{{
|
||||||
|
(
|
||||||
|
(kc_client_obj.stdout | from_json).attributes['post.logout.redirect.uris']
|
||||||
|
if 'post.logout.redirect.uris' in (kc_client_obj.stdout | from_json).attributes
|
||||||
|
else ''
|
||||||
|
)
|
||||||
|
| regex_replace('\r','')
|
||||||
|
| split('\n')
|
||||||
|
| reject('equalto','')
|
||||||
|
| list | sort
|
||||||
|
}}
|
||||||
|
kc_desired_redirect_uris: "{{ kc_redirect_uris | sort }}"
|
||||||
|
kc_desired_web_origins: "{{ kc_web_origins | sort }}"
|
||||||
|
kc_desired_post_logout_uris: "+"
|
||||||
|
kc_desired_post_logout_uris_list: >-
|
||||||
|
{{ "+" | split('\n') | reject('equalto','') | list | sort }}
|
||||||
|
|
||||||
|
# 4) Update only when changed
|
||||||
|
- name: "Update redirectUris, webOrigins, post.logout.redirect.uris"
|
||||||
|
shell: >
|
||||||
|
{{ keycloak_kcadm_path }} update clients/{{ kc_client.stdout }}
|
||||||
|
-r {{ keycloak_realm }}
|
||||||
|
-s 'redirectUris={{ kc_redirect_uris | to_json }}'
|
||||||
|
-s 'webOrigins={{ kc_web_origins | to_json }}'
|
||||||
|
-s 'attributes."post.logout.redirect.uris"={{ kc_desired_post_logout_uris | to_json }}'
|
||||||
|
when: kc_current_redirect_uris != kc_desired_redirect_uris
|
||||||
|
or kc_current_web_origins != kc_desired_web_origins
|
||||||
|
or kc_current_logout_uris != kc_desired_post_logout_uris_list
|
||||||
|
|
@ -16,8 +16,8 @@
|
|||||||
{{ keycloak_kcadm_path }} config credentials \
|
{{ keycloak_kcadm_path }} config credentials \
|
||||||
--server {{ keycloak_server_internal_url }} \
|
--server {{ keycloak_server_internal_url }} \
|
||||||
--realm master \
|
--realm master \
|
||||||
--user {{ keycloak_administrator_username }} \
|
--user {{ keycloak_master_api_user_name }} \
|
||||||
--password {{ keycloak_administrator_password }}
|
--password {{ keycloak_master_api_user_password }}
|
||||||
|
|
||||||
- name: Retrieve LDAP component ID
|
- name: Retrieve LDAP component ID
|
||||||
shell: |
|
shell: |
|
||||||
@ -37,6 +37,6 @@
|
|||||||
{{ keycloak_kcadm_path }} update components/{{ ldap_component.stdout }} \
|
{{ keycloak_kcadm_path }} update components/{{ ldap_component.stdout }} \
|
||||||
-r {{ keycloak_realm }} \
|
-r {{ keycloak_realm }} \
|
||||||
-s 'config.bindCredential=["{{ new_bind_password }}"]'
|
-s 'config.bindCredential=["{{ new_bind_password }}"]'
|
||||||
no_log: true
|
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||||
register: update_bind
|
register: update_bind
|
||||||
changed_when: update_bind.rc == 0
|
changed_when: update_bind.rc == 0
|
@ -15,8 +15,8 @@
|
|||||||
{{ keycloak_kcadm_path }} config credentials \
|
{{ keycloak_kcadm_path }} config credentials \
|
||||||
--server {{ keycloak_server_internal_url }} \
|
--server {{ keycloak_server_internal_url }} \
|
||||||
--realm master \
|
--realm master \
|
||||||
--user {{ keycloak_administrator_username }} \
|
--user {{ keycloak_master_api_user_name }} \
|
||||||
--password {{ keycloak_administrator_password }}
|
--password {{ keycloak_master_api_user_password }}
|
||||||
|
|
||||||
# LDAP Source
|
# LDAP Source
|
||||||
- name: Get ID of LDAP storage provider
|
- name: Get ID of LDAP storage provider
|
||||||
@ -66,8 +66,8 @@
|
|||||||
{{ keycloak_kcadm_path }} config credentials \
|
{{ keycloak_kcadm_path }} config credentials \
|
||||||
--server {{ keycloak_server_internal_url }} \
|
--server {{ keycloak_server_internal_url }} \
|
||||||
--realm master \
|
--realm master \
|
||||||
--user {{ keycloak_administrator_username }} \
|
--user {{ keycloak_master_api_user_name }} \
|
||||||
--password {{ keycloak_administrator_password }}
|
--password {{ keycloak_master_api_user_password }}
|
||||||
|
|
||||||
- name: Render user-profile JSON for SSH key
|
- name: Render user-profile JSON for SSH key
|
||||||
template:
|
template:
|
3
roles/web-app-keycloak/tasks/Todo.md
Normal file
3
roles/web-app-keycloak/tasks/Todo.md
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
# Todos
|
||||||
|
- Include 03_update-ldap-bind.yml
|
||||||
|
- Include 04_ssh_public_key.yml
|
@ -1,11 +1,14 @@
|
|||||||
---
|
---
|
||||||
- name: "create import files for {{application_id}}"
|
#- name: "create import files for {{application_id}}"
|
||||||
include_tasks: 01_import.yml
|
# include_tasks: 01_import.yml
|
||||||
|
#
|
||||||
|
#- name: "load docker, db and proxy for {{application_id}}"
|
||||||
|
# include_role:
|
||||||
|
# name: cmp-db-docker-proxy
|
||||||
|
|
||||||
- name: "load docker, db and proxy for {{application_id}}"
|
- name: "Apply client redirects without realm import"
|
||||||
include_role:
|
include_tasks: 02_update_client_redirects.yml
|
||||||
name: cmp-db-docker-proxy
|
|
||||||
|
|
||||||
# Deactivated temporary. Import now via realm.yml
|
# Deactivated temporary. Import now via realm.yml
|
||||||
#- name: Implement SSH Public Key Attribut
|
#- name: Implement SSH Public Key Attribut
|
||||||
# include_tasks: attributes/ssh_public_key.yml
|
# include_tasks: 03_ssh_public_key.yml
|
@ -833,20 +833,8 @@
|
|||||||
"alwaysDisplayInConsole": false,
|
"alwaysDisplayInConsole": false,
|
||||||
"clientAuthenticatorType": "desktop-secret",
|
"clientAuthenticatorType": "desktop-secret",
|
||||||
"secret": "{{oidc.client.secret}}",
|
"secret": "{{oidc.client.secret}}",
|
||||||
{%- set redirect_uris = [] %}
|
{# The following line should be covered by 02_update_client_redirects.yml #}
|
||||||
{%- for domain_application_id, domain in domains.items() %}
|
"redirectUris": {{ domains | redirect_uris(applications, WEB_PROTOCOL) | tojson }},
|
||||||
{%- if applications | get_app_conf(domain_application_id, 'features.oauth2', False) or applications | get_app_conf(domain_application_id, 'features.oidc', False) %}
|
|
||||||
{%- if domain is string %}
|
|
||||||
{%- set _ = redirect_uris.append(WEB_PROTOCOL ~ '://' ~ domain ~ '/*') %}
|
|
||||||
{%- else %}
|
|
||||||
{%- for d in domain %}
|
|
||||||
{%- set _ = redirect_uris.append(WEB_PROTOCOL ~ '://' ~ d ~ '/*') %}
|
|
||||||
{%- endfor %}
|
|
||||||
{%- endif %}
|
|
||||||
{%- endif %}
|
|
||||||
{%- endfor %}
|
|
||||||
|
|
||||||
"redirectUris": {{ redirect_uris | tojson }},
|
|
||||||
"webOrigins": [
|
"webOrigins": [
|
||||||
"{{ WEB_PROTOCOL }}://*.{{primary_domain}}"
|
"{{ WEB_PROTOCOL }}://*.{{primary_domain}}"
|
||||||
],
|
],
|
||||||
|
@ -6,9 +6,9 @@ database_type: "postgres"
|
|||||||
keycloak_container: "{{ applications | get_app_conf(application_id, 'docker.services.keycloak.name', True) }}" # Name of the keycloack docker container
|
keycloak_container: "{{ applications | get_app_conf(application_id, 'docker.services.keycloak.name', True) }}" # Name of the keycloack docker container
|
||||||
keycloak_docker_import_directory: "/opt/keycloak/data/import/" # Directory in which keycloack import files are placed in the running docker container
|
keycloak_docker_import_directory: "/opt/keycloak/data/import/" # Directory in which keycloack import files are placed in the running docker container
|
||||||
keycloak_realm: "{{ primary_domain}}" # This is the name of the default realm which is used by the applications
|
keycloak_realm: "{{ primary_domain}}" # This is the name of the default realm which is used by the applications
|
||||||
keycloak_administrator: "{{ applications | get_app_conf(application_id, 'users.administrator', True) }}" # Master Administrator
|
keycloak_master_api_user: "{{ applications | get_app_conf(application_id, 'users.administrator', True) }}" # Master Administrator
|
||||||
keycloak_administrator_username: "{{ keycloak_administrator.username }}" # Master Administrator Username
|
keycloak_master_api_user_name: "{{ keycloak_master_api_user.username }}" # Master Administrator Username
|
||||||
keycloak_administrator_password: "{{ keycloak_administrator.password }}" # Master Administrator Password
|
keycloak_master_api_user_password: "{{ keycloak_master_api_user.password }}" # Master Administrator Password
|
||||||
keycloak_kcadm_path: "docker exec -i {{ keycloak_container }} /opt/keycloak/bin/kcadm.sh" # Init script for keycloak
|
keycloak_kcadm_path: "docker exec -i {{ keycloak_container }} /opt/keycloak/bin/kcadm.sh" # Init script for keycloak
|
||||||
keycloak_server_internal_url: "http://127.0.0.1:8080"
|
keycloak_server_internal_url: "http://127.0.0.1:8080"
|
||||||
keycloak_server_host: "127.0.0.1:{{ ports.localhost.http[application_id] }}"
|
keycloak_server_host: "127.0.0.1:{{ ports.localhost.http[application_id] }}"
|
||||||
@ -17,6 +17,8 @@ keycloak_image: "{{ applications | get_app_conf(application_id
|
|||||||
keycloak_version: "{{ applications | get_app_conf(application_id, 'docker.services.keycloak.version', True) }}" # Keyloak docker version
|
keycloak_version: "{{ applications | get_app_conf(application_id, 'docker.services.keycloak.version', True) }}" # Keyloak docker version
|
||||||
keycloak_import_realm: "{{ applications | get_app_conf(application_id, 'import_realm', True, True) }}" # Activate realm import
|
keycloak_import_realm: "{{ applications | get_app_conf(application_id, 'import_realm', True, True) }}" # Activate realm import
|
||||||
keycloak_debug_enabled: "{{ enable_debug }}"
|
keycloak_debug_enabled: "{{ enable_debug }}"
|
||||||
|
keycloak_redirect_features: ["features.oauth2","features.oidc"]
|
||||||
|
keycloak_client_id: "{{ oidc.client.id }}"
|
||||||
|
|
||||||
# Docker
|
# Docker
|
||||||
docker_compose_flush_handlers: true # Remember to copy realm import before flushg when set to true
|
docker_compose_flush_handlers: true # Remember to copy realm import before flushg when set to true
|
0
tests/unit/roles/web-app-keycloak/__init__.py
Normal file
0
tests/unit/roles/web-app-keycloak/__init__.py
Normal file
@ -0,0 +1,159 @@
|
|||||||
|
# tests/unit/roles/web-app-keycloak/filter_plugins/test_redirect_uris.py
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
import unittest
|
||||||
|
import importlib.util
|
||||||
|
|
||||||
|
PLUGIN_REL_PATH = os.path.join("roles", "web-app-keycloak", "filter_plugins", "redirect_uris.py")
|
||||||
|
|
||||||
|
|
||||||
|
def _find_repo_root_containing(rel_path, max_depth=8):
|
||||||
|
"""Walk upwards from this test file to find the repo root that contains rel_path."""
|
||||||
|
here = os.path.dirname(__file__)
|
||||||
|
cur = here
|
||||||
|
for _ in range(max_depth):
|
||||||
|
candidate = os.path.join(cur, rel_path)
|
||||||
|
if os.path.isfile(candidate):
|
||||||
|
return cur
|
||||||
|
parent = os.path.dirname(cur)
|
||||||
|
if parent == cur:
|
||||||
|
break
|
||||||
|
cur = parent
|
||||||
|
raise FileNotFoundError(f"Could not find {rel_path} upwards from {here}")
|
||||||
|
|
||||||
|
|
||||||
|
def _load_module_from_path(name, file_path):
|
||||||
|
spec = importlib.util.spec_from_file_location(name, file_path)
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
assert spec and spec.loader, f"Cannot load spec for {file_path}"
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
return module
|
||||||
|
|
||||||
|
|
||||||
|
class RedirectUrisTest(unittest.TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
# Create stub package: module_utils, with config_utils and get_url submodules.
|
||||||
|
mu = types.ModuleType("module_utils")
|
||||||
|
mu_config = types.ModuleType("module_utils.config_utils")
|
||||||
|
mu_geturl = types.ModuleType("module_utils.get_url")
|
||||||
|
|
||||||
|
# Define stub exceptions
|
||||||
|
class AppConfigKeyError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class ConfigEntryNotSetError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Define a practical get_app_conf that understands dotted keys
|
||||||
|
def get_app_conf(applications, app_id, dotted, default=None):
|
||||||
|
data = applications.get(app_id, {})
|
||||||
|
cur = data
|
||||||
|
for part in dotted.split("."):
|
||||||
|
if isinstance(cur, dict) and part in cur:
|
||||||
|
cur = cur[part]
|
||||||
|
else:
|
||||||
|
return default
|
||||||
|
return cur
|
||||||
|
|
||||||
|
# Define a simple get_url matching your module_utils/get_url contract
|
||||||
|
# get_url(domains, application_id, protocol) -> "<protocol>://<domain>"
|
||||||
|
def get_url(domains, application_id, protocol):
|
||||||
|
domain = domains[application_id]
|
||||||
|
return f"{protocol}://{domain}"
|
||||||
|
|
||||||
|
# Attach to stub modules
|
||||||
|
mu_config.get_app_conf = staticmethod(get_app_conf)
|
||||||
|
mu_config.AppConfigKeyError = AppConfigKeyError
|
||||||
|
mu_config.ConfigEntryNotSetError = ConfigEntryNotSetError
|
||||||
|
|
||||||
|
mu_geturl.get_url = staticmethod(get_url)
|
||||||
|
|
||||||
|
# Register in sys.modules so plugin imports succeed
|
||||||
|
sys.modules["module_utils"] = mu
|
||||||
|
sys.modules["module_utils.config_utils"] = mu_config
|
||||||
|
sys.modules["module_utils.get_url"] = mu_geturl
|
||||||
|
|
||||||
|
# Load the plugin by path
|
||||||
|
repo_root = _find_repo_root_containing(PLUGIN_REL_PATH)
|
||||||
|
plugin_path = os.path.join(repo_root, PLUGIN_REL_PATH)
|
||||||
|
cls.plugin = _load_module_from_path("test_target.redirect_uris", plugin_path)
|
||||||
|
|
||||||
|
# Keep originals for per-test monkeypatching
|
||||||
|
cls._orig_get_app_conf = cls.plugin.get_app_conf
|
||||||
|
cls._orig_get_url = cls.plugin.get_url
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
# Restore plugin functions if a test monkeypatched them
|
||||||
|
self.plugin.get_app_conf = self._orig_get_app_conf
|
||||||
|
self.plugin.get_url = self._orig_get_url
|
||||||
|
|
||||||
|
def test_single_domain_oauth2_enabled(self):
|
||||||
|
domains = {"app1": "example.org"}
|
||||||
|
applications = {"app1": {"features": {"oauth2": True}}}
|
||||||
|
result = self.plugin.redirect_uris(domains, applications, web_protocol="https")
|
||||||
|
self.assertEqual(result, ["https://example.org/*"])
|
||||||
|
|
||||||
|
def test_multiple_domains_oidc_enabled(self):
|
||||||
|
domains = {"appX": ["a.example.org", "b.example.org"]}
|
||||||
|
applications = {"appX": {"features": {"oidc": True}}}
|
||||||
|
result = self.plugin.redirect_uris(domains, applications, web_protocol="https")
|
||||||
|
self.assertCountEqual(result, ["https://a.example.org/*", "https://b.example.org/*"])
|
||||||
|
|
||||||
|
def test_feature_missing_is_skipped(self):
|
||||||
|
domains = {"app1": "example.org"}
|
||||||
|
applications = {"app1": {"features": {"oauth2": False, "oidc": False}}}
|
||||||
|
result = self.plugin.redirect_uris(domains, applications)
|
||||||
|
self.assertEqual(result, [])
|
||||||
|
|
||||||
|
def test_protocol_and_wildcard_customization(self):
|
||||||
|
domains = {"app1": "x.test"}
|
||||||
|
applications = {"app1": {"features": {"oauth2": True}}}
|
||||||
|
result = self.plugin.redirect_uris(domains, applications, web_protocol="http", wildcard="/cb")
|
||||||
|
self.assertEqual(result, ["http://x.test/cb"])
|
||||||
|
|
||||||
|
def test_dedup_default_true(self):
|
||||||
|
domains = {"app1": ["dup.test", "dup.test", "other.test"]}
|
||||||
|
applications = {"app1": {"features": {"oidc": True}}}
|
||||||
|
result = self.plugin.redirect_uris(domains, applications)
|
||||||
|
self.assertEqual(result, ["https://dup.test/*", "https://other.test/*"])
|
||||||
|
|
||||||
|
def test_dedup_false_keeps_duplicates(self):
|
||||||
|
domains = {"app1": ["dup.test", "dup.test"]}
|
||||||
|
applications = {"app1": {"features": {"oidc": True}}}
|
||||||
|
result = self.plugin.redirect_uris(domains, applications, dedup=False)
|
||||||
|
self.assertEqual(result, ["https://dup.test/*", "https://dup.test/*"])
|
||||||
|
|
||||||
|
def test_invalid_domains_type_raises(self):
|
||||||
|
with self.assertRaises(self.plugin.AnsibleFilterError):
|
||||||
|
self.plugin.redirect_uris(["not-a-dict"], {}) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
def test_get_url_failure_is_wrapped(self):
|
||||||
|
# Make get_url raise an arbitrary error; plugin should re-raise AnsibleFilterError
|
||||||
|
def boom(*args, **kwargs):
|
||||||
|
raise RuntimeError("boom")
|
||||||
|
self.plugin.get_url = boom
|
||||||
|
|
||||||
|
domains = {"app1": "example.org"}
|
||||||
|
applications = {"app1": {"features": {"oauth2": True}}}
|
||||||
|
|
||||||
|
with self.assertRaises(self.plugin.AnsibleFilterError) as ctx:
|
||||||
|
self.plugin.redirect_uris(domains, applications)
|
||||||
|
self.assertIn("get_url failed", str(ctx.exception))
|
||||||
|
|
||||||
|
def test_get_app_conf_exception_is_handled_as_no_feature(self):
|
||||||
|
# Make get_app_conf raise AppConfigKeyError; plugin should treat as not enabled and skip
|
||||||
|
def raising_get_app_conf(*args, **kwargs):
|
||||||
|
raise self.plugin.AppConfigKeyError("missing key")
|
||||||
|
self.plugin.get_app_conf = raising_get_app_conf
|
||||||
|
|
||||||
|
domains = {"app1": "example.org"}
|
||||||
|
applications = {"app1": {"features": {"oauth2": True}}} # value won't be read due to exception
|
||||||
|
|
||||||
|
result = self.plugin.redirect_uris(domains, applications)
|
||||||
|
self.assertEqual(result, [])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
Loading…
x
Reference in New Issue
Block a user