openshift adm group sync/prune (#125)

This commit is contained in:
abikouo
2022-01-20 17:23:30 +01:00
committed by GitHub
parent eb11821b3c
commit 0a1a647e37
23 changed files with 3150 additions and 0 deletions

View File

@@ -0,0 +1,466 @@
#!/usr/bin/env python
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
from __future__ import (absolute_import, division, print_function)
__metaclass__ = type
import traceback
from datetime import datetime
from ansible_collections.kubernetes.core.plugins.module_utils.common import (K8sAnsibleMixin, get_api_client)
from ansible.module_utils.parsing.convert_bool import boolean
from ansible.module_utils._text import to_native
from ansible.module_utils.basic import missing_required_lib
from ansible_collections.community.okd.plugins.module_utils.openshift_ldap import (
validate_ldap_sync_config,
ldap_split_host_port,
OpenshiftLDAPRFC2307,
OpenshiftLDAPActiveDirectory,
OpenshiftLDAPAugmentedActiveDirectory
)
try:
import ldap
HAS_PYTHON_LDAP = True
except ImportError as e:
HAS_PYTHON_LDAP = False
PYTHON_LDAP_ERROR = e
try:
from ansible_collections.kubernetes.core.plugins.module_utils.common import (
K8sAnsibleMixin,
get_api_client,
)
HAS_KUBERNETES_COLLECTION = True
except ImportError as e:
HAS_KUBERNETES_COLLECTION = False
k8s_collection_import_exception = e
K8S_COLLECTION_ERROR = traceback.format_exc()
try:
from kubernetes.dynamic.exceptions import DynamicApiError
except ImportError as e:
pass
LDAP_OPENSHIFT_HOST_LABEL = "openshift.io/ldap.host"
LDAP_OPENSHIFT_URL_ANNOTATION = "openshift.io/ldap.url"
LDAP_OPENSHIFT_UID_ANNOTATION = "openshift.io/ldap.uid"
LDAP_OPENSHIFT_SYNCTIME_ANNOTATION = "openshift.io/ldap.sync-time"
def connect_to_ldap(module, server_uri, bind_dn=None, bind_pw=None, insecure=True, ca_file=None):
if insecure:
ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_NEVER)
elif ca_file:
ldap.set_option(ldap.OPT_X_TLS_CACERTFILE, ca_file)
try:
connection = ldap.initialize(server_uri)
connection.set_option(ldap.OPT_REFERRALS, 0)
connection.simple_bind_s(bind_dn, bind_pw)
return connection
except ldap.LDAPError as e:
module.fail_json(msg="Cannot bind to the LDAP server '{0}' due to: {1}".format(server_uri, e))
def validate_group_annotation(definition, host_ip):
name = definition['metadata']['name']
# Validate LDAP URL Annotation
annotate_url = definition['metadata'].get('annotations', {}).get(LDAP_OPENSHIFT_URL_ANNOTATION)
if host_ip:
if not annotate_url:
return "group '{0}' marked as having been synced did not have an '{1}' annotation".format(name, LDAP_OPENSHIFT_URL_ANNOTATION)
elif annotate_url != host_ip:
return "group '{0}' was not synchronized from: '{1}'".format(name, host_ip)
# Validate LDAP UID Annotation
annotate_uid = definition['metadata']['annotations'].get(LDAP_OPENSHIFT_UID_ANNOTATION)
if not annotate_uid:
return "group '{0}' marked as having been synced did not have an '{1}' annotation".format(name, LDAP_OPENSHIFT_UID_ANNOTATION)
return None
class OpenshiftLDAPGroups(object):
kind = "Group"
version = "user.openshift.io/v1"
def __init__(self, module):
self.module = module
self.cache = {}
self.__group_api = None
@property
def k8s_group_api(self):
if not self.__group_api:
params = dict(
kind=self.kind,
api_version=self.version,
fail=True
)
self.__group_api = self.module.find_resource(**params)
return self.__group_api
def get_group_info(self, return_list=False, **kwargs):
params = dict(
kind=self.kind,
api_version=self.version,
)
params.update(kwargs)
result = self.module.kubernetes_facts(**params)
if len(result["resources"]) == 0:
return None
if len(result["resources"]) == 1 and not return_list:
return result["resources"][0]
else:
return result["resources"]
def list_groups(self):
allow_groups = self.module.params.get("allow_groups")
deny_groups = self.module.params.get("deny_groups")
name_mapping = self.module.config.get("groupUIDNameMapping")
if name_mapping and (allow_groups or deny_groups):
def _map_group_names(groups):
return [name_mapping.get(value, value) for value in groups]
allow_groups = _map_group_names(allow_groups)
deny_groups = _map_group_names(deny_groups)
host = self.module.host
netlocation = self.module.netlocation
groups = []
if allow_groups:
missing = []
for grp in allow_groups:
if grp in deny_groups:
continue
resource = self.get_group_info(name=grp)
if not resource:
missing.append(grp)
continue
groups.append(resource)
if missing:
self.module.fail_json(
msg="The following groups were not found: %s" % ''.join(missing)
)
else:
label_selector = "%s=%s" % (LDAP_OPENSHIFT_HOST_LABEL, host)
resources = self.get_group_info(label_selectors=[label_selector], return_list=True)
if not resources:
return None, "Unable to find Group matching label selector '%s'" % label_selector
groups = resources
if deny_groups:
groups = [item for item in groups if item["metadata"]["name"] not in deny_groups]
uids = []
for grp in groups:
err = validate_group_annotation(grp, netlocation)
if err and allow_groups:
# We raise an error for group part of the allow_group not matching LDAP sync criteria
return None, err
group_uid = grp['metadata']['annotations'].get(LDAP_OPENSHIFT_UID_ANNOTATION)
self.cache[group_uid] = grp
uids.append(group_uid)
return uids, None
def get_group_name_for_uid(self, group_uid):
if group_uid not in self.cache:
return None, "No mapping found for Group uid: %s" % group_uid
return self.cache[group_uid]["metadata"]["name"], None
def make_openshift_group(self, group_uid, group_name, usernames):
group = self.get_group_info(name=group_name)
if not group:
group = {
"apiVersion": "user.openshift.io/v1",
"kind": "Group",
"metadata": {
"name": group_name,
"labels": {
LDAP_OPENSHIFT_HOST_LABEL: self.module.host
},
"annotations": {
LDAP_OPENSHIFT_URL_ANNOTATION: self.module.netlocation,
LDAP_OPENSHIFT_UID_ANNOTATION: group_uid,
}
}
}
# Make sure we aren't taking over an OpenShift group that is already related to a different LDAP group
ldaphost_label = group["metadata"].get("labels", {}).get(LDAP_OPENSHIFT_HOST_LABEL)
if not ldaphost_label or ldaphost_label != self.module.host:
return None, "Group %s: %s label did not match sync host: wanted %s, got %s" % (
group_name, LDAP_OPENSHIFT_HOST_LABEL, self.module.host, ldaphost_label
)
ldapurl_annotation = group["metadata"].get("annotations", {}).get(LDAP_OPENSHIFT_URL_ANNOTATION)
if not ldapurl_annotation or ldapurl_annotation != self.module.netlocation:
return None, "Group %s: %s annotation did not match sync host: wanted %s, got %s" % (
group_name, LDAP_OPENSHIFT_URL_ANNOTATION, self.module.netlocation, ldapurl_annotation
)
ldapuid_annotation = group["metadata"].get("annotations", {}).get(LDAP_OPENSHIFT_UID_ANNOTATION)
if not ldapuid_annotation or ldapuid_annotation != group_uid:
return None, "Group %s: %s annotation did not match LDAP UID: wanted %s, got %s" % (
group_name, LDAP_OPENSHIFT_UID_ANNOTATION, group_uid, ldapuid_annotation
)
# Overwrite Group Users data
group["users"] = usernames
group["metadata"]["annotations"][LDAP_OPENSHIFT_SYNCTIME_ANNOTATION] = datetime.now().isoformat()
return group, None
def create_openshift_groups(self, groups: list):
diffs = []
results = []
changed = False
for definition in groups:
name = definition["metadata"]["name"]
existing = self.get_group_info(name=name)
if not self.module.check_mode:
try:
if existing:
method = 'patch'
definition = self.k8s_group_api.patch(definition).to_dict()
else:
method = 'create'
definition = self.k8s_group_api.create(definition).to_dict()
except DynamicApiError as exc:
self.module.fail_json(msg="Failed to %s Group '%s' due to: %s" % (method, name, exc.body))
except Exception as exc:
self.module.fail_json(msg="Failed to %s Group '%s' due to: %s" % (method, name, to_native(exc)))
equals = False
if existing:
equals, diff = self.module.diff_objects(existing, definition)
diffs.append(diff)
changed = changed or not equals
results.append(definition)
return results, diffs, changed
def delete_openshift_group(self, name: str):
result = dict(
kind=self.kind,
apiVersion=self.version,
metadata=dict(
name=name
)
)
if not self.module.check_mode:
try:
result = self.k8s_group_api.delete(name=name).to_dict()
except DynamicApiError as exc:
self.module.fail_json(msg="Failed to delete Group '{0}' due to: {1}".format(name, exc.body))
except Exception as exc:
self.module.fail_json(msg="Failed to delete Group '{0}' due to: {1}".format(name, to_native(exc)))
return result
class OpenshiftGroupsSync(K8sAnsibleMixin):
def __init__(self, module):
self.module = module
if not HAS_KUBERNETES_COLLECTION:
self.module.fail_json(
msg="The kubernetes.core collection must be installed",
exception=K8S_COLLECTION_ERROR,
error=to_native(k8s_collection_import_exception),
)
if not HAS_PYTHON_LDAP:
self.fail_json(
msg=missing_required_lib('python-ldap'), error=to_native(PYTHON_LDAP_ERROR)
)
super(OpenshiftGroupsSync, self).__init__(self.module)
self.params = self.module.params
self.check_mode = self.module.check_mode
self.client = get_api_client(self.module)
self.__k8s_group_api = None
self.__ldap_connection = None
self.host = None
self.port = None
self.netlocation = None
self.scheme = None
self.config = self.params.get("sync_config")
@property
def k8s_group_api(self):
if not self.__k8s_group_api:
params = dict(
kind="Group",
api_version="user.openshift.io/v1",
fail=True
)
self.__k8s_group_api = self.find_resource(**params)
return self.__k8s_group_api
@property
def hostIP(self):
return self.netlocation
@property
def connection(self):
if not self.__ldap_connection:
# Create connection object
params = dict(
module=self,
server_uri=self.config.get('url'),
bind_dn=self.config.get('bindDN'),
bind_pw=self.config.get('bindPassword'),
insecure=boolean(self.config.get('insecure')),
ca_file=self.config.get('ca')
)
self.__ldap_connection = connect_to_ldap(**params)
return self.__ldap_connection
def close_connection(self):
if self.__ldap_connection:
self.__ldap_connection.unbind_s()
self.__ldap_connection = None
def exit_json(self, **kwargs):
self.close_connection()
self.module.exit_json(**kwargs)
def fail_json(self, **kwargs):
self.close_connection()
self.module.fail_json(**kwargs)
def get_syncer(self):
syncer = None
if "rfc2307" in self.config:
syncer = OpenshiftLDAPRFC2307(self.config, self.connection)
elif "activeDirectory" in self.config:
syncer = OpenshiftLDAPActiveDirectory(self.config, self.connection)
elif "augmentedActiveDirectory" in self.config:
syncer = OpenshiftLDAPAugmentedActiveDirectory(self.config, self.connection)
else:
msg = "No schema-specific config was found, should be one of 'rfc2307', 'activeDirectory', 'augmentedActiveDirectory'"
self.fail_json(msg=msg)
return syncer
def synchronize(self):
sync_group_type = self.module.params.get("type")
groups_uids = []
ldap_openshift_group = OpenshiftLDAPGroups(module=self)
# Get Synchronize object
syncer = self.get_syncer()
# Determine what to sync : list groups
if sync_group_type == "openshift":
groups_uids, err = ldap_openshift_group.list_groups()
if err:
self.fail_json(msg="Failed to list openshift groups", errors=err)
else:
# List LDAP Group to synchronize
groups_uids = self.params.get("allow_groups")
if not groups_uids:
groups_uids, err = syncer.list_groups()
if err:
self.module.fail_json(msg=err)
deny_groups = self.params.get("deny_groups")
if deny_groups:
groups_uids = [uid for uid in groups_uids if uid not in deny_groups]
openshift_groups = []
for uid in groups_uids:
# Get membership data
member_entries, err = syncer.extract_members(uid)
if err:
self.fail_json(msg=err)
# Determine usernames for members entries
usernames = []
for entry in member_entries:
name, err = syncer.get_username_for_entry(entry)
if err:
self.exit_json(
msg="Unable to determine username for entry %s: %s" % (entry, err)
)
if isinstance(name, list):
usernames.extend(name)
else:
usernames.append(name)
# Get group name
if sync_group_type == "openshift":
group_name, err = ldap_openshift_group.get_group_name_for_uid(uid)
else:
group_name, err = syncer.get_group_name_for_uid(uid)
if err:
self.exit_json(msg=err)
# Make Openshift group
group, err = ldap_openshift_group.make_openshift_group(uid, group_name, usernames)
if err:
self.fail_json(msg=err)
openshift_groups.append(group)
# Create Openshift Groups
results, diffs, changed = ldap_openshift_group.create_openshift_groups(openshift_groups)
self.module.exit_json(changed=True, groups=results)
def prune(self):
ldap_openshift_group = OpenshiftLDAPGroups(module=self)
groups_uids, err = ldap_openshift_group.list_groups()
if err:
self.fail_json(msg="Failed to list openshift groups", errors=err)
# Get Synchronize object
syncer = self.get_syncer()
changed = False
groups = []
for uid in groups_uids:
# Check if LDAP group exist
exists, err = syncer.is_ldapgroup_exists(uid)
if err:
msg = "Error determining LDAP group existence for group %s: %s" % (uid, err)
self.module.fail_json(msg=msg)
if exists:
continue
# if the LDAP entry that was previously used to create the group doesn't exist, prune it
group_name, err = ldap_openshift_group.get_group_name_for_uid(uid)
if err:
self.module.fail_json(msg=err)
# Delete Group
result = ldap_openshift_group.delete_openshift_group(group_name)
groups.append(result)
changed = True
self.exit_json(changed=changed, groups=groups)
def execute_module(self):
# validate LDAP sync configuration
error = validate_ldap_sync_config(self.config)
if error:
self.fail_json(msg="Invalid LDAP Sync config: %s" % error)
# Split host/port
if self.config.get('url'):
result, error = ldap_split_host_port(self.config.get('url'))
if error:
self.fail_json(msg="Failed to parse url='{0}': {1}".format(self.config.get('url'), error))
self.netlocation, self.host, self.port = result["netlocation"], result["host"], result["port"]
self.scheme = result["scheme"]
if self.params.get('state') == 'present':
self.synchronize()
else:
self.prune()

View File

@@ -0,0 +1,777 @@
#!/usr/bin/env python
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
from __future__ import (absolute_import, division, print_function)
__metaclass__ = type
import os
import copy
from ansible.module_utils.parsing.convert_bool import boolean
from ansible.module_utils.six import iteritems
try:
import ldap
except ImportError as e:
pass
LDAP_SEARCH_OUT_OF_SCOPE_ERROR = "trying to search by DN for an entry that exists outside of the tree specified with the BaseDN for search"
def validate_ldap_sync_config(config):
# Validate url
url = config.get('url')
if not url:
return "url should be non empty attribute."
# Make sure bindDN and bindPassword are both set, or both unset
bind_dn = config.get('bindDN', "")
bind_password = config.get('bindPassword', "")
if (len(bind_dn) == 0) != (len(bind_password) == 0):
return "bindDN and bindPassword must both be specified, or both be empty."
insecure = boolean(config.get('insecure'))
ca_file = config.get('ca')
if insecure:
if url.startswith('ldaps://'):
return "Cannot use ldaps scheme with insecure=true."
if ca_file:
return "Cannot specify a ca with insecure=true."
elif ca_file and not os.path.isfile(ca_file):
return "could not read ca file: {0}.".format(ca_file)
nameMapping = config.get('groupUIDNameMapping', {})
for k, v in iteritems(nameMapping):
if len(k) == 0 or len(v) == 0:
return "groupUIDNameMapping has empty key or value"
schemas = []
schema_list = ('rfc2307', 'activeDirectory', 'augmentedActiveDirectory')
for schema in schema_list:
if schema in config:
schemas.append(schema)
if len(schemas) == 0:
return "No schema-specific config was provided, should be one of %s" % schema_list
if len(schemas) > 1:
return "Exactly one schema-specific config is required; found (%d) %s" % (len(schemas), ','.join(schemas))
if schemas[0] == 'rfc2307':
return validate_RFC2307(config.get("rfc2307"))
elif schemas[0] == 'activeDirectory':
return validate_ActiveDirectory(config.get("activeDirectory"))
elif schemas[0] == 'augmentedActiveDirectory':
return validate_AugmentedActiveDirectory(config.get("augmentedActiveDirectory"))
def validate_ldap_query(qry, isDNOnly=False):
# validate query scope
scope = qry.get('scope')
if scope and scope not in ("", "sub", "one", "base"):
return "invalid scope %s" % scope
# validate deref aliases
derefAlias = qry.get('derefAliases')
if derefAlias and derefAlias not in ("never", "search", "base", "always"):
return "not a valid LDAP alias dereferncing behavior: %s", derefAlias
# validate timeout
timeout = qry.get('timeout')
if timeout and float(timeout) < 0:
return "timeout must be equal to or greater than zero"
# Validate DN only
qry_filter = qry.get('filter', "")
if isDNOnly:
if len(qry_filter) > 0:
return 'cannot specify a filter when using "dn" as the UID attribute'
else:
# validate filter
if len(qry_filter) == 0 or qry_filter[0] != '(':
return "filter does not start with an '('"
return None
def validate_RFC2307(config):
qry = config.get('groupsQuery')
if not qry or not isinstance(qry, dict):
return "RFC2307: groupsQuery requires a dictionary"
error = validate_ldap_query(qry)
if not error:
return error
for field in ('groupUIDAttribute', 'groupNameAttributes', 'groupMembershipAttributes',
'userUIDAttribute', 'userNameAttributes'):
value = config.get(field)
if not value:
return "RFC2307: {0} is required.".format(field)
users_qry = config.get('usersQuery')
if not users_qry or not isinstance(users_qry, dict):
return "RFC2307: usersQuery requires a dictionary"
isUserDNOnly = (config.get('userUIDAttribute').strip() == 'dn')
return validate_ldap_query(users_qry, isDNOnly=isUserDNOnly)
def validate_ActiveDirectory(config, label="ActiveDirectory"):
users_qry = config.get('usersQuery')
if not users_qry or not isinstance(users_qry, dict):
return "{0}: usersQuery requires as dictionnary".format(label)
error = validate_ldap_query(users_qry)
if not error:
return error
for field in ('userNameAttributes', 'groupMembershipAttributes'):
value = config.get(field)
if not value:
return "{0}: {1} is required.".format(field, label)
return None
def validate_AugmentedActiveDirectory(config):
error = validate_ActiveDirectory(config, label="AugmentedActiveDirectory")
if not error:
return error
for field in ('groupUIDAttribute', 'groupNameAttributes'):
value = config.get(field)
if not value:
return "AugmentedActiveDirectory: {0} is required".format(field)
groups_qry = config.get('groupsQuery')
if not groups_qry or not isinstance(groups_qry, dict):
return "AugmentedActiveDirectory: groupsQuery requires as dictionnary."
isGroupDNOnly = (config.get('groupUIDAttribute').strip() == 'dn')
return validate_ldap_query(groups_qry, isDNOnly=isGroupDNOnly)
def determine_ldap_scope(scope):
if scope in ("", "sub"):
return ldap.SCOPE_SUBTREE
elif scope == 'base':
return ldap.SCOPE_BASE
elif scope == 'one':
return ldap.SCOPE_ONELEVEL
return None
def determine_deref_aliases(derefAlias):
mapping = {
"never": ldap.DEREF_NEVER,
"search": ldap.DEREF_SEARCHING,
"base": ldap.DEREF_FINDING,
"always": ldap.DEREF_ALWAYS,
}
result = None
if derefAlias in mapping:
result = mapping.get(derefAlias)
return result
def openshift_ldap_build_base_query(config):
qry = {}
if config.get('baseDN'):
qry['base'] = config.get('baseDN')
scope = determine_ldap_scope(config.get('scope'))
if scope:
qry['scope'] = scope
pageSize = config.get('pageSize')
if pageSize and int(pageSize) > 0:
qry['sizelimit'] = int(pageSize)
timeout = config.get('timeout')
if timeout and int(timeout) > 0:
qry['timeout'] = int(timeout)
filter = config.get('filter')
if filter:
qry['filterstr'] = filter
derefAlias = determine_deref_aliases(config.get('derefAliases'))
if derefAlias:
qry['derefAlias'] = derefAlias
return qry
def openshift_ldap_get_attribute_for_entry(entry, attribute):
attributes = [attribute]
if isinstance(attribute, list):
attributes = attribute
for k in attributes:
if k.lower() == 'dn':
return entry[0]
v = entry[1].get(k, None)
if v:
if isinstance(v, list):
result = []
for x in v:
if hasattr(x, 'decode'):
result.append(x.decode('utf-8'))
else:
result.append(x)
return result
else:
return v.decode('utf-8') if hasattr(v, 'decode') else v
return ""
def ldap_split_host_port(hostport):
"""
ldap_split_host_port splits a network address of the form "host:port",
"host%zone:port", "[host]:port" or "[host%zone]:port" into host or
host%zone and port.
"""
result = dict(
scheme=None, netlocation=None, host=None, port=None
)
if not hostport:
return result, None
# Extract Scheme
netlocation = hostport
scheme_l = "://"
if "://" in hostport:
idx = hostport.find(scheme_l)
result["scheme"] = hostport[:idx]
netlocation = hostport[idx + len(scheme_l):]
result["netlocation"] = netlocation
if netlocation[-1] == ']':
# ipv6 literal (with no port)
result["host"] = netlocation
v = netlocation.rsplit(":", 1)
if len(v) != 1:
try:
result["port"] = int(v[1])
except ValueError:
return None, "Invalid value specified for port: %s" % v[1]
result["host"] = v[0]
return result, None
def openshift_ldap_query_for_entries(connection, qry, unique_entry=True):
# set deref alias (TODO: need to set a default value to reset for each transaction)
derefAlias = qry.pop('derefAlias', None)
if derefAlias:
ldap.set_option(ldap.OPT_DEREF, derefAlias)
try:
result = connection.search_ext_s(**qry)
if not result or len(result) == 0:
return None, "Entry not found for base='{0}' and filter='{1}'".format(qry['base'], qry['filterstr'])
if len(result) > 1 and unique_entry:
if qry.get('scope') == ldap.SCOPE_BASE:
return None, "multiple entries found matching dn={0}: {1}".format(qry['base'], result)
else:
return None, "multiple entries found matching filter {0}: {1}".format(qry['filterstr'], result)
return result, None
except ldap.NO_SUCH_OBJECT:
return None, "search for entry with base dn='{0}' refers to a non-existent entry".format(qry['base'])
def openshift_equal_dn_objects(dn_obj, other_dn_obj):
if len(dn_obj) != len(other_dn_obj):
return False
for k, v in enumerate(dn_obj):
if len(v) != len(other_dn_obj[k]):
return False
for j, item in enumerate(v):
if not (item == other_dn_obj[k][j]):
return False
return True
def openshift_equal_dn(dn, other):
dn_obj = ldap.dn.str2dn(dn)
other_dn_obj = ldap.dn.str2dn(other)
return openshift_equal_dn_objects(dn_obj, other_dn_obj)
def openshift_ancestorof_dn(dn, other):
dn_obj = ldap.dn.str2dn(dn)
other_dn_obj = ldap.dn.str2dn(other)
if len(dn_obj) >= len(other_dn_obj):
return False
# Take the last attribute from the other DN to compare against
return openshift_equal_dn_objects(dn_obj, other_dn_obj[len(other_dn_obj) - len(dn_obj):])
class OpenshiftLDAPQueryOnAttribute(object):
def __init__(self, qry, attribute):
# qry retrieves entries from an LDAP server
self.qry = copy.deepcopy(qry)
# query_attributes is the attribute for a specific filter that, when conjoined with the common filter,
# retrieves the specific LDAP entry from the LDAP server. (e.g. "cn", when formatted with "aGroupName"
# and conjoined with "objectClass=groupOfNames", becomes (&(objectClass=groupOfNames)(cn=aGroupName))")
self.query_attribute = attribute
@staticmethod
def escape_filter(buffer):
"""
escapes from the provided LDAP filter string the special
characters in the set '(', ')', '*', \\ and those out of the range 0 < c < 0x80, as defined in RFC4515.
"""
output = []
hex_string = "0123456789abcdef"
for c in buffer:
if ord(c) > 0x7f or c in ('(', ')', '\\', '*') or c == 0:
first = ord(c) >> 4
second = ord(c) & 0xf
output += ['\\', hex_string[first], hex_string[second]]
else:
output.append(c)
return ''.join(output)
def build_request(self, ldapuid, attributes):
params = copy.deepcopy(self.qry)
if self.query_attribute.lower() == 'dn':
if ldapuid:
if not openshift_equal_dn(ldapuid, params['base']) and not openshift_ancestorof_dn(params['base'], ldapuid):
return None, LDAP_SEARCH_OUT_OF_SCOPE_ERROR
params['base'] = ldapuid
params['scope'] = ldap.SCOPE_BASE
# filter that returns all values
params['filterstr'] = "(objectClass=*)"
params['attrlist'] = attributes
else:
# Builds the query containing a filter that conjoins the common filter given
# in the configuration with the specific attribute filter for which the attribute value is given
specificFilter = "%s=%s" % (self.escape_filter(self.query_attribute), self.escape_filter(ldapuid))
qry_filter = params.get('filterstr', None)
if qry_filter:
params['filterstr'] = "(&%s(%s))" % (qry_filter, specificFilter)
params['attrlist'] = attributes
return params, None
def ldap_search(self, connection, ldapuid, required_attributes, unique_entry=True):
query, error = self.build_request(ldapuid, required_attributes)
if error:
return None, error
# set deref alias (TODO: need to set a default value to reset for each transaction)
derefAlias = query.pop('derefAlias', None)
if derefAlias:
ldap.set_option(ldap.OPT_DEREF, derefAlias)
try:
result = connection.search_ext_s(**query)
if not result or len(result) == 0:
return None, "Entry not found for base='{0}' and filter='{1}'".format(query['base'], query['filterstr'])
if unique_entry:
if len(result) > 1:
return None, "Multiple Entries found matching search criteria: %s (%s)" % (query, result)
result = result[0]
return result, None
except ldap.NO_SUCH_OBJECT:
return None, "Entry not found for base='{0}' and filter='{1}'".format(query['base'], query['filterstr'])
except Exception as err:
return None, "Request %s failed due to: %s" % (query, err)
class OpenshiftLDAPQuery(object):
def __init__(self, qry):
# Query retrieves entries from an LDAP server
self.qry = qry
def build_request(self, attributes):
params = copy.deepcopy(self.qry)
params['attrlist'] = attributes
return params
def ldap_search(self, connection, required_attributes):
query = self.build_request(required_attributes)
# set deref alias (TODO: need to set a default value to reset for each transaction)
derefAlias = query.pop('derefAlias', None)
if derefAlias:
ldap.set_option(ldap.OPT_DEREF, derefAlias)
try:
result = connection.search_ext_s(**query)
if not result or len(result) == 0:
return None, "Entry not found for base='{0}' and filter='{1}'".format(query['base'], query['filterstr'])
return result, None
except ldap.NO_SUCH_OBJECT:
return None, "search for entry with base dn='{0}' refers to a non-existent entry".format(query['base'])
class OpenshiftLDAPInterface(object):
def __init__(self, connection, groupQuery, groupNameAttributes, groupMembershipAttributes,
userQuery, userNameAttributes, config):
self.connection = connection
self.groupQuery = copy.deepcopy(groupQuery)
self.groupNameAttributes = groupNameAttributes
self.groupMembershipAttributes = groupMembershipAttributes
self.userQuery = copy.deepcopy(userQuery)
self.userNameAttributes = userNameAttributes
self.config = config
self.tolerate_not_found = boolean(config.get('tolerateMemberNotFoundErrors', False))
self.tolerate_out_of_scope = boolean(config.get('tolerateMemberOutOfScopeErrors', False))
self.required_group_attributes = [self.groupQuery.query_attribute]
for x in self.groupNameAttributes + self.groupMembershipAttributes:
if x not in self.required_group_attributes:
self.required_group_attributes.append(x)
self.required_user_attributes = [self.userQuery.query_attribute]
for x in self.userNameAttributes:
if x not in self.required_user_attributes:
self.required_user_attributes.append(x)
self.cached_groups = {}
self.cached_users = {}
def get_group_entry(self, uid):
"""
get_group_entry returns an LDAP group entry for the given group UID by searching the internal cache
of the LDAPInterface first, then sending an LDAP query if the cache did not contain the entry.
"""
if uid in self.cached_groups:
return self.cached_groups.get(uid), None
group, err = self.groupQuery.ldap_search(self.connection, uid, self.required_group_attributes)
if err:
return None, err
self.cached_groups[uid] = group
return group, None
def get_user_entry(self, uid):
"""
get_user_entry returns an LDAP group entry for the given user UID by searching the internal cache
of the LDAPInterface first, then sending an LDAP query if the cache did not contain the entry.
"""
if uid in self.cached_users:
return self.cached_users.get(uid), None
entry, err = self.userQuery.ldap_search(self.connection, uid, self.required_user_attributes)
if err:
return None, err
self.cached_users[uid] = entry
return entry, None
def exists(self, ldapuid):
group, error = self.get_group_entry(ldapuid)
return bool(group), error
def list_groups(self):
group_qry = copy.deepcopy(self.groupQuery.qry)
group_qry['attrlist'] = self.required_group_attributes
groups, err = openshift_ldap_query_for_entries(
connection=self.connection,
qry=group_qry,
unique_entry=False
)
if err:
return None, err
group_uids = []
for entry in groups:
uid = openshift_ldap_get_attribute_for_entry(entry, self.groupQuery.query_attribute)
if not uid:
return None, "Unable to find LDAP group uid for entry %s" % entry
self.cached_groups[uid] = entry
group_uids.append(uid)
return group_uids, None
def extract_members(self, uid):
"""
returns the LDAP member entries for a group specified with a ldapGroupUID
"""
# Get group entry from LDAP
group, err = self.get_group_entry(uid)
if err:
return None, err
# Extract member UIDs from group entry
member_uids = []
for attribute in self.groupMembershipAttributes:
member_uids += openshift_ldap_get_attribute_for_entry(group, attribute)
members = []
for user_uid in member_uids:
entry, err = self.get_user_entry(user_uid)
if err:
if self.tolerate_not_found and err.startswith("Entry not found"):
continue
elif err == LDAP_SEARCH_OUT_OF_SCOPE_ERROR:
continue
return None, err
members.append(entry)
return members, None
class OpenshiftLDAPRFC2307(object):
def __init__(self, config, ldap_connection):
self.config = config
self.ldap_interface = self.create_ldap_interface(ldap_connection)
def create_ldap_interface(self, connection):
segment = self.config.get("rfc2307")
groups_base_qry = openshift_ldap_build_base_query(segment['groupsQuery'])
users_base_qry = openshift_ldap_build_base_query(segment['usersQuery'])
groups_query = OpenshiftLDAPQueryOnAttribute(groups_base_qry, segment['groupUIDAttribute'])
users_query = OpenshiftLDAPQueryOnAttribute(users_base_qry, segment['userUIDAttribute'])
params = dict(
connection=connection,
groupQuery=groups_query,
groupNameAttributes=segment['groupNameAttributes'],
groupMembershipAttributes=segment['groupMembershipAttributes'],
userQuery=users_query,
userNameAttributes=segment['userNameAttributes'],
config=segment
)
return OpenshiftLDAPInterface(**params)
def get_username_for_entry(self, entry):
username = openshift_ldap_get_attribute_for_entry(entry, self.ldap_interface.userNameAttributes)
if not username:
return None, "The user entry (%s) does not map to a OpenShift User name with the given mapping" % entry
return username, None
def get_group_name_for_uid(self, uid):
# Get name from User defined mapping
groupuid_name_mapping = self.config.get("groupUIDNameMapping")
if groupuid_name_mapping and uid in groupuid_name_mapping:
return groupuid_name_mapping.get(uid), None
elif self.ldap_interface.groupNameAttributes:
group, err = self.ldap_interface.get_group_entry(uid)
if err:
return None, err
group_name = openshift_ldap_get_attribute_for_entry(group, self.ldap_interface.groupNameAttributes)
if not group_name:
error = "The group entry (%s) does not map to an OpenShift Group name with the given name attribute (%s)" % (
group, self.ldap_interface.groupNameAttributes
)
return None, error
if isinstance(group_name, list):
group_name = group_name[0]
return group_name, None
else:
return None, "No OpenShift Group name defined for LDAP group UID: %s" % uid
def is_ldapgroup_exists(self, uid):
group, err = self.ldap_interface.get_group_entry(uid)
if err:
if err == LDAP_SEARCH_OUT_OF_SCOPE_ERROR or err.startswith("Entry not found") or "non-existent entry" in err:
return False, None
return False, err
if group:
return True, None
return False, None
def list_groups(self):
return self.ldap_interface.list_groups()
def extract_members(self, uid):
return self.ldap_interface.extract_members(uid)
class OpenshiftLDAP_ADInterface(object):
def __init__(self, connection, user_query, group_member_attr, user_name_attr):
self.connection = connection
self.userQuery = user_query
self.groupMembershipAttributes = group_member_attr
self.userNameAttributes = user_name_attr
self.required_user_attributes = self.userNameAttributes or []
for attr in self.groupMembershipAttributes:
if attr not in self.required_user_attributes:
self.required_user_attributes.append(attr)
self.cache = {}
self.cache_populated = False
def is_entry_present(self, cache_item, entry):
for item in cache_item:
if item[0] == entry[0]:
return True
return False
def populate_cache(self):
if not self.cache_populated:
self.cache_populated = True
entries, err = self.userQuery.ldap_search(self.connection, self.required_user_attributes)
if err:
return err
for entry in entries:
for group_attr in self.groupMembershipAttributes:
uids = openshift_ldap_get_attribute_for_entry(entry, group_attr)
if not isinstance(uids, list):
uids = [uids]
for uid in uids:
if uid not in self.cache:
self.cache[uid] = []
if not self.is_entry_present(self.cache[uid], entry):
self.cache[uid].append(entry)
return None
def list_groups(self):
err = self.populate_cache()
if err:
return None, err
result = []
if self.cache:
result = self.cache.keys()
return result, None
def extract_members(self, uid):
# ExtractMembers returns the LDAP member entries for a group specified with a ldapGroupUID
# if we already have it cached, return the cached value
if uid in self.cache:
return self.cache[uid], None
# This happens in cases where we did not list out every group.
# In that case, we're going to be asked about specific groups.
users_in_group = []
for attr in self.groupMembershipAttributes:
query_on_attribute = OpenshiftLDAPQueryOnAttribute(self.userQuery.qry, attr)
entries, error = query_on_attribute.ldap_search(self.connection, uid, self.required_user_attributes, unique_entry=False)
if error and "not found" not in error:
return None, error
if not entries:
continue
for entry in entries:
if not self.is_entry_present(users_in_group, entry):
users_in_group.append(entry)
self.cache[uid] = users_in_group
return users_in_group, None
class OpenshiftLDAPActiveDirectory(object):
def __init__(self, config, ldap_connection):
self.config = config
self.ldap_interface = self.create_ldap_interface(ldap_connection)
def create_ldap_interface(self, connection):
segment = self.config.get("activeDirectory")
base_query = openshift_ldap_build_base_query(segment['usersQuery'])
user_query = OpenshiftLDAPQuery(base_query)
return OpenshiftLDAP_ADInterface(
connection=connection,
user_query=user_query,
group_member_attr=segment["groupMembershipAttributes"],
user_name_attr=segment["userNameAttributes"],
)
def get_username_for_entry(self, entry):
username = openshift_ldap_get_attribute_for_entry(entry, self.ldap_interface.userNameAttributes)
if not username:
return None, "The user entry (%s) does not map to a OpenShift User name with the given mapping" % entry
return username, None
def get_group_name_for_uid(self, uid):
return uid, None
def is_ldapgroup_exists(self, uid):
members, error = self.extract_members(uid)
if error:
return False, error
exists = members and len(members) > 0
return exists, None
def list_groups(self):
return self.ldap_interface.list_groups()
def extract_members(self, uid):
return self.ldap_interface.extract_members(uid)
class OpenshiftLDAP_AugmentedADInterface(OpenshiftLDAP_ADInterface):
def __init__(self, connection, user_query, group_member_attr, user_name_attr, group_qry, group_name_attr):
super(OpenshiftLDAP_AugmentedADInterface, self).__init__(
connection, user_query, group_member_attr, user_name_attr
)
self.groupQuery = copy.deepcopy(group_qry)
self.groupNameAttributes = group_name_attr
self.required_group_attributes = [self.groupQuery.query_attribute]
for x in self.groupNameAttributes:
if x not in self.required_group_attributes:
self.required_group_attributes.append(x)
self.cached_groups = {}
def get_group_entry(self, uid):
"""
get_group_entry returns an LDAP group entry for the given group UID by searching the internal cache
of the LDAPInterface first, then sending an LDAP query if the cache did not contain the entry.
"""
if uid in self.cached_groups:
return self.cached_groups.get(uid), None
group, err = self.groupQuery.ldap_search(self.connection, uid, self.required_group_attributes)
if err:
return None, err
self.cached_groups[uid] = group
return group, None
def exists(self, ldapuid):
# Get group members
members, error = self.extract_members(ldapuid)
if error:
return False, error
group_exists = bool(members)
# Check group Existence
entry, error = self.get_group_entry(ldapuid)
if error:
if "not found" in error:
return False, None
else:
return False, error
else:
return group_exists and bool(entry), None
class OpenshiftLDAPAugmentedActiveDirectory(OpenshiftLDAPRFC2307):
def __init__(self, config, ldap_connection):
self.config = config
self.ldap_interface = self.create_ldap_interface(ldap_connection)
def create_ldap_interface(self, connection):
segment = self.config.get("augmentedActiveDirectory")
user_base_query = openshift_ldap_build_base_query(segment['usersQuery'])
groups_base_qry = openshift_ldap_build_base_query(segment['groupsQuery'])
user_query = OpenshiftLDAPQuery(user_base_query)
groups_query = OpenshiftLDAPQueryOnAttribute(groups_base_qry, segment['groupUIDAttribute'])
return OpenshiftLDAP_AugmentedADInterface(
connection=connection,
user_query=user_query,
group_member_attr=segment["groupMembershipAttributes"],
user_name_attr=segment["userNameAttributes"],
group_qry=groups_query,
group_name_attr=segment["groupNameAttributes"]
)
def is_ldapgroup_exists(self, uid):
return self.ldap_interface.exists(uid)

View File

@@ -0,0 +1,226 @@
#!/usr/bin/python
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
from __future__ import absolute_import, division, print_function
__metaclass__ = type
DOCUMENTATION = r"""
module: openshift_adm_groups_sync
short_description: Sync OpenShift Groups with records from an external provider.
version_added: "2.1.0"
author:
- Aubin Bikouo (@abikouo)
description:
- In order to sync/prune OpenShift Group records with those from an external provider, determine which Groups you wish to sync
and where their records live.
- Analogous to `oc adm prune groups` and `oc adm group sync`.
- LDAP sync configuration file syntax can be found here
U(https://docs.openshift.com/container-platform/4.9/authentication/ldap-syncing.html).
- The bindPassword attribute of the LDAP sync configuration is expected to be a string,
please use ansible-vault encryption to secure this information.
extends_documentation_fragment:
- kubernetes.core.k8s_auth_options
options:
state:
description:
- Determines if the group should be sync when set to C(present) or pruned when set to C(absent).
type: str
default: present
choices: [ absent, present ]
type:
description:
- which groups allow and deny list entries refer to.
type: str
default: ldap
choices: [ ldap, openshift ]
sync_config:
description:
- Provide a valid YAML definition of an LDAP sync configuration.
type: dict
aliases:
- config
- src
required: True
deny_groups:
description:
- Denied groups, could be openshift group name or LDAP group dn value.
- When parameter C(type) is set to I(ldap) this should contains only LDAP group definition
like I(cn=developers,ou=groups,ou=rfc2307,dc=ansible,dc=redhat).
- The elements specified in this list will override the ones specified in C(allow_groups).
type: list
elements: str
allow_groups:
description:
- Allowed groups, could be openshift group name or LDAP group dn value.
- When parameter C(type) is set to I(ldap) this should contains only LDAP group definition
like I(cn=developers,ou=groups,ou=rfc2307,dc=ansible,dc=redhat).
type: list
elements: str
requirements:
- python >= 3.6
- kubernetes >= 12.0.0
- python-ldap
"""
EXAMPLES = r"""
# Prune all orphaned groups
- name: Prune all orphan groups
openshift_adm_groups_sync:
state: absent
src: "{{ lookup('file', '/path/to/ldap-sync-config.yaml') | from_yaml }}"
# Prune all orphaned groups from a list of specific groups specified in allow_groups
- name: Prune all orphan groups from a list of specific groups specified in allow_groups
openshift_adm_groups_sync:
state: absent
src: "{{ lookup('file', '/path/to/ldap-sync-config.yaml') | from_yaml }}"
allow_groups:
- cn=developers,ou=groups,ou=rfc2307,dc=ansible,dc=redhat
- cn=developers,ou=groups,ou=rfc2307,dc=ansible,dc=redhat
# Sync all groups from an LDAP server
- name: Sync all groups from an LDAP server
openshift_adm_groups_sync:
src:
kind: LDAPSyncConfig
apiVersion: v1
url: ldap://localhost:1390
insecure: true
bindDN: cn=admin,dc=example,dc=org
bindPassword: adminpassword
rfc2307:
groupsQuery:
baseDN: "cn=admins,ou=groups,dc=example,dc=org"
scope: sub
derefAliases: never
filter: (objectClass=*)
pageSize: 0
groupUIDAttribute: dn
groupNameAttributes: [ cn ]
groupMembershipAttributes: [ member ]
usersQuery:
baseDN: "ou=users,dc=example,dc=org"
scope: sub
derefAliases: never
pageSize: 0
userUIDAttribute: dn
userNameAttributes: [ mail ]
tolerateMemberNotFoundErrors: true
tolerateMemberOutOfScopeErrors: true
# Sync all groups except the ones from the deny_groups from an LDAP server
- name: Sync all groups from an LDAP server using deny_groups
openshift_adm_groups_sync:
src: "{{ lookup('file', '/path/to/ldap-sync-config.yaml') | from_yaml }}"
deny_groups:
- cn=developers,ou=groups,ou=rfc2307,dc=ansible,dc=redhat
- cn=developers,ou=groups,ou=rfc2307,dc=ansible,dc=redhat
# Sync all OpenShift Groups that have been synced previously with an LDAP server
- name: Sync all OpenShift Groups that have been synced previously with an LDAP server
openshift_adm_groups_sync:
src: "{{ lookup('file', '/path/to/ldap-sync-config.yaml') | from_yaml }}"
type: openshift
"""
RETURN = r"""
builds:
description:
- The groups that were created, updated or deleted
returned: success
type: list
elements: dict
sample: [
{
"apiVersion": "user.openshift.io/v1",
"kind": "Group",
"metadata": {
"annotations": {
"openshift.io/ldap.sync-time": "2021-12-17T12:20:28.125282",
"openshift.io/ldap.uid": "cn=developers,ou=groups,ou=rfc2307,dc=ansible,dc=redhat",
"openshift.io/ldap.url": "localhost:1390"
},
"creationTimestamp": "2021-12-17T11:09:49Z",
"labels": {
"openshift.io/ldap.host": "localhost"
},
"managedFields": [{
"apiVersion": "user.openshift.io/v1",
"fieldsType": "FieldsV1",
"fieldsV1": {
"f:metadata": {
"f:annotations": {
".": {},
"f:openshift.io/ldap.sync-time": {},
"f:openshift.io/ldap.uid": {},
"f:openshift.io/ldap.url": {}
},
"f:labels": {
".": {},
"f:openshift.io/ldap.host": {}
}
},
"f:users": {}
},
"manager": "OpenAPI-Generator",
"operation": "Update",
"time": "2021-12-17T11:09:49Z"
}],
"name": "developers",
"resourceVersion": "2014696",
"uid": "8dc211cb-1544-41e1-96b1-efffeed2d7d7"
},
"users": ["jordanbulls@ansible.org"]
}
]
"""
import copy
import traceback
from ansible.module_utils.basic import AnsibleModule
from ansible_collections.kubernetes.core.plugins.module_utils.args_common import AUTH_ARG_SPEC
def argument_spec():
args = copy.deepcopy(AUTH_ARG_SPEC)
args.update(
dict(
state=dict(type='str', choices=['absent', 'present'], default='present'),
type=dict(type='str', choices=['ldap', 'openshift'], default='ldap'),
sync_config=dict(type='dict', aliases=['config', 'src'], required=True),
deny_groups=dict(type='list', elements='str', default=[]),
allow_groups=dict(type='list', elements='str', default=[]),
)
)
return args
def main():
module = AnsibleModule(argument_spec=argument_spec(), supports_check_mode=True)
from ansible_collections.community.okd.plugins.module_utils.openshift_groups import (
OpenshiftGroupsSync
)
try:
openshift_groups = OpenshiftGroupsSync(module)
openshift_groups.execute_module()
except Exception as e:
module.fail_json(msg=str(e), exception=traceback.format_exc())
if __name__ == '__main__':
main()