From 7e2bef0b9f36a90902784be9363cbcb5ba4221b4 Mon Sep 17 00:00:00 2001 From: Martin Babinsky Date: Jun 13 2016 15:50:54 +0000 Subject: Server Roles: definitions of server roles and attributes This patch introduces classes which define the properties of server roles and attributes and their relationship to LDAP attributes representing the role/attribute. A brief documentation about defining and using roles is given at the beginning of the module. http://www.freeipa.org/page/V4/Server_Roles https://fedorahosted.org/freeipa/ticket/5181 Reviewed-By: Jan Cholasta Reviewed-By: Martin Basti Reviewed-By: Pavel Vomacka --- diff --git a/ipaserver/servroles.py b/ipaserver/servroles.py new file mode 100644 index 0000000..8628cd6 --- /dev/null +++ b/ipaserver/servroles.py @@ -0,0 +1,586 @@ +# +# Copyright (C) 2016 FreeIPA Contributors see COPYING for license +# + + +""" +This module contains the set of classes which abstract various bits and pieces +of information present in the LDAP tree about functionalities such as DNS +server, Active Directory trust controller etc. These properties come in two +distinct groups: + + server roles + this group represents a genral functionality provided by one or more + IPA servers, such as DNS server, certificate authority and such. In + this case there is a many-to-many mapping between the roles and the + masters which provide them. + + server attributes + these represent a functionality associated with the whole topology, + such as CA renewal master or DNSSec key master. + +See the corresponding design page (http://www.freeipa.org/page/V4/Server_Roles) +for more info. + +Both of these groups use `LDAPBasedProperty` class as a base. + +Server Roles +============ + +Server role objects are usually consuming information from the master's service +container (cn=FQDN,cn=masters,cn=ipa,cn=etc,$SUFFIX) are represented by +`ServiceBasedRole`class. To create an instance of such role, you only need to +specify role name and individual services comprising the role (more systemd +services may be enabled to provide some function): + +>>> example_role = ServiceBasedRole( +... "Example Role", +... component_services = ['SERVICE1', 'SERVICE2']) +>>> example_role.name +'Example Role' + +The role object can then be queried for the status of the role in the whole +topology or on a single master by using its `status` method. This method +returns a list of dictionaries akin to LDAP entries comprised from server name, +role name and role status (enabled if role is enabled, configured if the +service entries are present but not marked as enabled by 'enabledService' +config string, absent if the service entries are not present). + +Note that 'AD trust agent' role is based on membership of the master in the +'adtrust agents' sysaccount group and is thus an instance of different class +(`ADTrustBasedRole`). This role also does not have 'configured' status, since +the master is either member of the group ('enabled') or not ('absent') + +Server Attributes +================= + +Server attributes are implemented as instances of `ServerAttribute` class. The +attribute is defined by some flag set on 'ipaConfigString' attribute of some +service entry. To create your own server attribute, see the following example: + +>>> example_attribute = ServerAttribute("Example Attribute", example_role, +... "SERVICE1", "roleMaster") +>>> example_attribute.name +'Example Attribute' + +The FQDN of master with the attribute set can be requested using `get()` +method. The attribute master can be changed by the `set()` method +which accepts FQDN of a new master hosting the attribute. + +The available role/attribute instances are stored in +`role_instances`/`attribute_instances` tuples. +""" + +import abc +from collections import namedtuple, defaultdict + +from ldap import SCOPE_ONELEVEL +import six + +from ipalib import _, errors +from ipapython.dn import DN + + +if six.PY3: + unicode = str + + +ENABLED = u'enabled' +CONFIGURED = u'configured' +ABSENT = u'absent' + + +@six.add_metaclass(abc.ABCMeta) +class LDAPBasedProperty(object): + """ + base class for all master properties defined by LDAP content + :param attr_name: attribute name + :param name: user-friendly name of the property + :param attrs_list: list of attributes to retrieve during search, defaults + to all + """ + + def __init__(self, attr_name, name): + self.attr_name = attr_name + self.name = name + + +@six.add_metaclass(abc.ABCMeta) +class BaseServerRole(LDAPBasedProperty): + """ + Server role hierarchy apex. All other server role definition should either + inherit from it or at least provide the 'status' method for querying role + status + property + """ + + def create_role_status_dict(self, server, status): + """ + the output of `status()` method should be a list of dictionaries having + the following keys: + * role_servrole: name of role + * server_server: server FQDN + * status: role status on server + + this methods returns such a dict given server and role status + """ + return { + u'role_servrole': self.name, + u'server_server': server, + u'status': status} + + @abc.abstractmethod + def create_search_params(self, ldap, api_instance, server=None): + """ + create search base and filter + :param ldap: ldap connection + :param api_instance: API instance + :param server: server FQDN. if given, the method should generate + filter and search base matching only the status on this server + :returns: tuple of search base (a DN) and search filter + """ + pass + + @abc.abstractmethod + def get_result_from_entries(self, entries): + """ + Get role status from returned LDAP entries + + :param entries: LDAPEntry objects returned by `search()` + :returns: list of dicts generated by `create_role_status_dict()` + method + """ + pass + + def _fill_in_absent_masters(self, ldap2, api_instance, result): + """ + get all masters on which the role is absent + + :param ldap2: LDAP connection + :param api_instance: API instance + :param result: output of `get_result_from_entries` method + + :returns: list of masters on which the role is absent + """ + search_base = DN(api_instance.env.container_masters, + api_instance.env.basedn) + search_filter = '(objectclass=ipaConfigObject)' + attrs_list = ['cn'] + + all_masters = ldap2.get_entries( + search_base, + filter=search_filter, + scope=SCOPE_ONELEVEL, + attrs_list=attrs_list) + + all_master_cns = set(m['cn'][0] for m in all_masters) + enabled_configured_masters = set(r[u'server_server'] for r in result) + + absent_masters = all_master_cns.difference(enabled_configured_masters) + + return [self.create_role_status_dict(m, ABSENT) for m in + absent_masters] + + def status(self, api_instance, server=None, attrs_list=("*",)): + """ + probe and return status of the role either on single server or on the + whole topology + + :param api_instance: API instance + :param server: server FQDN. If given, only the status of the role on + this master will be returned + :returns: * 'enabled' if the role is enabled on the master + * 'configured' if it is not enabled but has + been configured by installer + * 'absent' otherwise + """ + ldap2 = api_instance.Backend.ldap2 + search_base, search_filter = self.create_search_params( + ldap2, api_instance, server=server) + + try: + entries = ldap2.get_entries( + search_base, + filter=search_filter, + attrs_list=attrs_list) + except errors.EmptyResult: + entries = [] + + if not entries and server is not None: + return [self.create_role_status_dict(server, ABSENT)] + + result = self.get_result_from_entries(entries) + + if server is None: + result.extend( + self._fill_in_absent_masters(ldap2, api_instance, result)) + + return sorted(result, key=lambda x: x[u'server_server']) + + +class ServerAttribute(LDAPBasedProperty): + """ + Class from which server attributes should be instantiated + + :param associated_role_name: name of a role which must be enabled + on the provider + :param associated_service_name: name of LDAP service on which the + attribute is set. Does not need to belong to the service entries + of associate role + :param ipa_config_string_value: value of `ipaConfigString` attribute + associated with the presence of server attribute + """ + + def __init__(self, attr_name, name, associated_role_name, + associated_service_name, + ipa_config_string_value): + super(ServerAttribute, self).__init__(attr_name, name) + + self.associated_role_name = associated_role_name + self.associated_service_name = associated_service_name + self.ipa_config_string_value = ipa_config_string_value + + @property + def associated_role(self): + for inst in role_instances: + if self.associated_role_name == inst.attr_name: + return inst + + raise NotImplementedError( + "{}: no valid associated role found".format(self.attr_name)) + + def create_search_filter(self, ldap): + """ + Create search filter which matches LDAP data corresponding to the + attribute + """ + svc_filter = ldap.make_filter_from_attr( + 'cn', self.associated_service_name) + + configstring_filter = ldap.make_filter_from_attr( + 'ipaConfigString', self.ipa_config_string_value) + return ldap.combine_filters( + [svc_filter, configstring_filter], rules=ldap.MATCH_ALL) + + def get(self, api_instance): + """ + get the master which has the attribute set + :param api_instance: API instance + :returns: master FQDN + """ + ldap2 = api_instance.Backend.ldap2 + search_base = DN(api_instance.env.container_masters, + api_instance.env.basedn) + + search_filter = self.create_search_filter(ldap2) + + try: + entries = ldap2.get_entries(search_base, filter=search_filter) + except errors.EmptyResult: + return + + master_cn = entries[0].dn[1]['cn'] + + associated_role_providers = set( + self._get_assoc_role_providers(api_instance)) + + if master_cn not in associated_role_providers: + raise errors.ValidationError( + name=self.name, + error=_("all masters must have %(role)s role enabled" % + {'role': self.associated_role.name}) + ) + + return master_cn + + def _get_master_dn(self, api_instance, server): + return DN(('cn', server), api_instance.env.container_masters, + api_instance.env.basedn) + + def _get_masters_service_entry(self, ldap, master_dn): + service_dn = DN(('cn', self.associated_service_name), master_dn) + return ldap.get_entry(service_dn) + + def _add_attribute_to_svc_entry(self, ldap, service_entry): + """ + add the server attribute to the entry of associated service + + :param ldap: LDAP connection object + :param service_entry: associated service entry + """ + ipa_config_string = service_entry.get('ipaConfigString', []) + + ipa_config_string.append(self.ipa_config_string_value) + + service_entry['ipaConfigString'] = ipa_config_string + ldap.update_entry(service_entry) + + def _remove_attribute_from_svc_entry(self, ldap, service_entry): + """ + remove the server attribute to the entry of associated service + + single ipaConfigString attribute is case-insensitive, we must handle + arbitrary case of target value + + :param ldap: LDAP connection object + :param service_entry: associated service entry + """ + ipa_config_string = service_entry.get('ipaConfigString', []) + + for value in ipa_config_string: + if value.lower() == self.ipa_config_string_value.lower(): + service_entry['ipaConfigString'].remove(value) + + ldap.update_entry(service_entry) + + def _get_assoc_role_providers(self, api_instance): + """ + get list of all servers on which the associated role is enabled + """ + return [ + r[u'server_server'] for r in self.associated_role.status( + api_instance) if r[u'status'] == ENABLED] + + def _remove(self, api_instance, master): + """ + remove attribute from the master + + :param api_instance: API instance + :param master: master FQDN + """ + + ldap = api_instance.Backend.ldap2 + + master_dn = self._get_master_dn(api_instance, master) + service_entry = self._get_masters_service_entry(ldap, master_dn) + self._remove_attribute_from_svc_entry(ldap, service_entry) + + def _add(self, api_instance, master): + """ + add attribute to the master + :param api_instance: API instance + :param master: master FQDN + + :raises: * errors.ValidationError if the associated role is not enabled + on the master + """ + + assoc_role_providers = self._get_assoc_role_providers(api_instance) + ldap = api_instance.Backend.ldap2 + + if master not in assoc_role_providers: + raise errors.ValidationError( + name=master, + error=_("must have %(role)s role enabled" % + {'role': self.associated_role.name}) + ) + + master_dn = self._get_master_dn(api_instance, master) + service_entry = self._get_masters_service_entry(ldap, master_dn) + self._add_attribute_to_svc_entry(ldap, service_entry) + + def set(self, api_instance, master): + """ + set the attribute on master + + :param api_instance: API instance + :param master: FQDN of the new master + + the attribute is automatically unset from previous master if present + + :raises: errors.EmptyModlist if the new masters is the same as + the original on + """ + old_master = self.get(api_instance) + + if old_master == master: + raise errors.EmptyModlist + + self._add(api_instance, master) + + if old_master is not None: + self._remove(api_instance, old_master) + + +_Service = namedtuple('Service', ['name', 'enabled']) + + +class ServiceBasedRole(BaseServerRole): + """ + class for all role instances whose status is defined by presence of one or + more entries in LDAP and/or their attributes + """ + + def __init__(self, attr_name, name, component_services): + super(ServiceBasedRole, self).__init__(attr_name, name) + + self.component_services = component_services + + def _validate_component_services(self, services): + svc_set = {s.name for s in services} + if svc_set != set(self.component_services): + raise ValueError( + "{}: Mismatch between component services and search result " + "(expected: {}, got: {})".format( + self.__class__.__name__, + ', '.join(sorted(self.component_services)), + ', '.join(sorted(s.name for s in services)))) + + def _get_service(self, entry): + entry_cn = entry['cn'][0] + + enabled = self._is_service_enabled(entry) + + return _Service(name=entry_cn, enabled=enabled) + + def _is_service_enabled(self, entry): + """ + determine whether the service is enabled based on the presence of + enabledService attribute in ipaConfigString attribute. + Since the attribute is case-insensitive, we must first lowercase its + values and do the comparison afterwards. + + :param entry: LDAPEntry of the service + :returns: True if the service entry is enabled, False otherwise + """ + enabled_value = 'enabledservice' + ipaconfigstring_values = set( + e.lower() for e in entry.get('ipaConfigString', [])) + + return enabled_value in ipaconfigstring_values + + def _get_services_by_masters(self, entries): + """ + given list of entries, return a dictionary keyed by master FQDNs which + contains list of service entries belonging to the master + """ + services_by_master = defaultdict(list) + for e in entries: + service = self._get_service(e) + master_cn = e.dn[1]['cn'] + + services_by_master[master_cn].append(service) + + return services_by_master + + def get_result_from_entries(self, entries): + result = [] + services_by_master = self._get_services_by_masters(entries) + for master, services in services_by_master.items(): + try: + self._validate_component_services(services) + except ValueError: + continue + + status = ( + ENABLED if all(s.enabled for s in services) else + CONFIGURED) + + result.append(self.create_role_status_dict(master, status)) + + return result + + def create_search_params(self, ldap, api_instance, server=None): + search_base = DN(api_instance.env.container_masters, + api_instance.env.basedn) + + search_filter = ldap.make_filter_from_attr( + 'cn', + self.component_services, + rules=ldap.MATCH_ANY, + exact=True + ) + + if server is not None: + search_base = DN(('cn', server), search_base) + + return search_base, search_filter + + def status(self, api_instance, server=None): + return super(ServiceBasedRole, self).status( + api_instance, server=server, attrs_list=('ipaConfigString', 'cn')) + + +class ADtrustBasedRole(BaseServerRole): + """ + Class which should instantiate roles besed on membership in 'adtrust agent' + sysaccount group. + """ + + def get_result_from_entries(self, entries): + result = [] + + for e in entries: + result.append( + self.create_role_status_dict(e['fqdn'][0], ENABLED) + ) + return result + + def create_search_params(self, ldap, api_instance, server=None): + search_base = DN( + api_instance.env.container_host, api_instance.env.basedn) + + search_filter = ldap.make_filter_from_attr( + "memberof", + DN(('cn', 'adtrust agents'), ('cn', 'sysaccounts'), + ('cn', 'etc'), api_instance.env.basedn) + ) + if server is not None: + server_filter = ldap.make_filter_from_attr( + 'fqdn', + server, + exact=True + ) + search_filter = ldap.combine_filters( + [search_filter, server_filter], + rules=ldap.MATCH_ALL + ) + + return search_base, search_filter + + +role_instances = ( + ADtrustBasedRole(u"ad_trust_agent_server", u"AD trust agent"), + ServiceBasedRole( + u"ad_trust_controller_server", + u"AD trust controller", + component_services=['ADTRUST'] + ), + ServiceBasedRole( + u"ca_server_server", + u"CA server", + component_services=['CA'] + ), + ServiceBasedRole( + u"dns_server_server", + u"DNS server", + component_services=['DNS', 'DNSKeySync'] + ), + ServiceBasedRole( + u"ipa_master_server", + u"IPA master", + component_services=['HTTP', 'KDC', 'KPASSWD'] + ), + ServiceBasedRole( + u"kra_server_server", + u"KRA server", + component_services=['KRA'] + ), +) + +attribute_instances = ( + ServerAttribute( + u"ca_renewal_master_server", + u"CA renewal master", + u"ca_server_server", + u"CA", + u"caRenewalMaster", + ), + ServerAttribute( + u"dnssec_key_master_server", + u"DNSSec key master", + u"dns_server_server", + u"DNSSEC", + u"dnssecKeyMaster", + ), +)