diff options
author | Jan Cholasta <jcholast@redhat.com> | 2016-04-28 10:30:05 +0200 |
---|---|---|
committer | Jan Cholasta <jcholast@redhat.com> | 2016-06-03 09:00:34 +0200 |
commit | 6e44557b601f769d23ee74555a72e8b5cc62c0c9 (patch) | |
tree | eedd3e054b0709341b9f58c190ea54f999f7d13a /ipaserver/plugins | |
parent | ec841e5d7ab29d08de294b3fa863a631cd50e30a (diff) | |
download | freeipa-6e44557b601f769d23ee74555a72e8b5cc62c0c9.tar.gz freeipa-6e44557b601f769d23ee74555a72e8b5cc62c0c9.tar.xz freeipa-6e44557b601f769d23ee74555a72e8b5cc62c0c9.zip |
ipalib: move server-side plugins to ipaserver
Move the remaining plugin code from ipalib.plugins to ipaserver.plugins.
Remove the now unused ipalib.plugins package.
https://fedorahosted.org/freeipa/ticket/4739
Reviewed-By: David Kupka <dkupka@redhat.com>
Diffstat (limited to 'ipaserver/plugins')
59 files changed, 34784 insertions, 6 deletions
diff --git a/ipaserver/plugins/aci.py b/ipaserver/plugins/aci.py new file mode 100644 index 000000000..01c929230 --- /dev/null +++ b/ipaserver/plugins/aci.py @@ -0,0 +1,986 @@ +# Authors: +# Rob Crittenden <rcritten@redhat.com> +# Pavel Zuna <pzuna@redhat.com> +# +# Copyright (C) 2009 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. +""" +Directory Server Access Control Instructions (ACIs) + +ACIs are used to allow or deny access to information. This module is +currently designed to allow, not deny, access. + +The aci commands are designed to grant permissions that allow updating +existing entries or adding or deleting new ones. The goal of the ACIs +that ship with IPA is to provide a set of low-level permissions that +grant access to special groups called taskgroups. These low-level +permissions can be combined into roles that grant broader access. These +roles are another type of group, roles. + +For example, if you have taskgroups that allow adding and modifying users you +could create a role, useradmin. You would assign users to the useradmin +role to allow them to do the operations defined by the taskgroups. + +You can create ACIs that delegate permission so users in group A can write +attributes on group B. + +The type option is a map that applies to all entries in the users, groups or +host location. It is primarily designed to be used when granting add +permissions (to write new entries). + +An ACI consists of three parts: +1. target +2. permissions +3. bind rules + +The target is a set of rules that define which LDAP objects are being +targeted. This can include a list of attributes, an area of that LDAP +tree or an LDAP filter. + +The targets include: +- attrs: list of attributes affected +- type: an object type (user, group, host, service, etc) +- memberof: members of a group +- targetgroup: grant access to modify a specific group. This is primarily + designed to enable users to add or remove members of a specific group. +- filter: A legal LDAP filter used to narrow the scope of the target. +- subtree: Used to apply a rule across an entire set of objects. For example, + to allow adding users you need to grant "add" permission to the subtree + ldap://uid=*,cn=users,cn=accounts,dc=example,dc=com. The subtree option + is a fail-safe for objects that may not be covered by the type option. + +The permissions define what the ACI is allowed to do, and are one or +more of: +1. write - write one or more attributes +2. read - read one or more attributes +3. add - add a new entry to the tree +4. delete - delete an existing entry +5. all - all permissions are granted + +Note the distinction between attributes and entries. The permissions are +independent, so being able to add a user does not mean that the user will +be editable. + +The bind rule defines who this ACI grants permissions to. The LDAP server +allows this to be any valid LDAP entry but we encourage the use of +taskgroups so that the rights can be easily shared through roles. + +For a more thorough description of access controls see +http://www.redhat.com/docs/manuals/dir-server/ag/8.0/Managing_Access_Control.html + +EXAMPLES: + +NOTE: ACIs are now added via the permission plugin. These examples are to +demonstrate how the various options work but this is done via the permission +command-line now (see last example). + + Add an ACI so that the group "secretaries" can update the address on any user: + ipa group-add --desc="Office secretaries" secretaries + ipa aci-add --attrs=streetAddress --memberof=ipausers --group=secretaries --permissions=write --prefix=none "Secretaries write addresses" + + Show the new ACI: + ipa aci-show --prefix=none "Secretaries write addresses" + + Add an ACI that allows members of the "addusers" permission to add new users: + ipa aci-add --type=user --permission=addusers --permissions=add --prefix=none "Add new users" + + Add an ACI that allows members of the editors manage members of the admins group: + ipa aci-add --permissions=write --attrs=member --targetgroup=admins --group=editors --prefix=none "Editors manage admins" + + Add an ACI that allows members of the admins group to manage the street and zip code of those in the editors group: + ipa aci-add --permissions=write --memberof=editors --group=admins --attrs=street --attrs=postalcode --prefix=none "admins edit the address of editors" + + Add an ACI that allows the admins group manage the street and zipcode of those who work for the boss: + ipa aci-add --permissions=write --group=admins --attrs=street --attrs=postalcode --filter="(manager=uid=boss,cn=users,cn=accounts,dc=example,dc=com)" --prefix=none "Edit the address of those who work for the boss" + + Add an entirely new kind of record to IPA that isn't covered by any of the --type options, creating a permission: + ipa permission-add --permissions=add --subtree="cn=*,cn=orange,cn=accounts,dc=example,dc=com" --desc="Add Orange Entries" add_orange + + +The show command shows the raw 389-ds ACI. + +IMPORTANT: When modifying the target attributes of an existing ACI you +must include all existing attributes as well. When doing an aci-mod the +targetattr REPLACES the current attributes, it does not add to them. + +""" +from copy import deepcopy + +import six + +from ipalib import api, crud, errors +from ipalib import Object +from ipalib import Flag, Str, StrEnum, DNParam +from ipalib.aci import ACI +from ipalib import output +from ipalib import _, ngettext +from ipalib.plugable import Registry +from .baseldap import gen_pkey_only_option, pkey_to_value +from ipapython.ipa_log_manager import root_logger +from ipapython.dn import DN + +if six.PY3: + unicode = str + +register = Registry() + +ACI_NAME_PREFIX_SEP = ":" + +_type_map = { + 'user': 'ldap:///' + str(DN(('uid', '*'), api.env.container_user, api.env.basedn)), + 'group': 'ldap:///' + str(DN(('cn', '*'), api.env.container_group, api.env.basedn)), + 'host': 'ldap:///' + str(DN(('fqdn', '*'), api.env.container_host, api.env.basedn)), + 'hostgroup': 'ldap:///' + str(DN(('cn', '*'), api.env.container_hostgroup, api.env.basedn)), + 'service': 'ldap:///' + str(DN(('krbprincipalname', '*'), api.env.container_service, api.env.basedn)), + 'netgroup': 'ldap:///' + str(DN(('ipauniqueid', '*'), api.env.container_netgroup, api.env.basedn)), + 'dnsrecord': 'ldap:///' + str(DN(('idnsname', '*'), api.env.container_dns, api.env.basedn)), +} + +_valid_permissions_values = [ + u'read', u'write', u'add', u'delete', u'all' +] + +_valid_prefix_values = ( + u'permission', u'delegation', u'selfservice', u'none' +) + +class ListOfACI(output.Output): + type = (list, tuple) + doc = _('A list of ACI values') + + def validate(self, cmd, entries): + assert isinstance(entries, self.type) + for (i, entry) in enumerate(entries): + if not isinstance(entry, unicode): + raise TypeError(output.emsg % + (cmd.name, self.__class__.__name__, + self.name, i, unicode, type(entry), entry) + ) + +aci_output = ( + output.Output('result', unicode, 'A string representing the ACI'), + output.value, + output.summary, +) + + +def _make_aci_name(aciprefix, aciname): + """ + Given a name and a prefix construct an ACI name. + """ + if aciprefix == u"none": + return aciname + + return aciprefix + ACI_NAME_PREFIX_SEP + aciname + +def _parse_aci_name(aciname): + """ + Parse the raw ACI name and return a tuple containing the ACI prefix + and the actual ACI name. + """ + aciparts = aciname.partition(ACI_NAME_PREFIX_SEP) + + if not aciparts[2]: # no prefix/name separator found + return (u"none",aciparts[0]) + + return (aciparts[0], aciparts[2]) + +def _group_from_memberof(memberof): + """ + Pull the group name out of a memberOf filter + """ + st = memberof.find('memberOf=') + if st == -1: + # We have a raw group name, use that + return api.Object['group'].get_dn(memberof) + en = memberof.find(')', st) + return memberof[st+9:en] + +def _make_aci(ldap, current, aciname, kw): + """ + Given a name and a set of keywords construct an ACI. + """ + # Do some quick and dirty validation. + checked_args=['type','filter','subtree','targetgroup','attrs','memberof'] + valid={} + for arg in checked_args: + if arg in kw: + valid[arg]=kw[arg] is not None + else: + valid[arg]=False + + if valid['type'] + valid['filter'] + valid['subtree'] + valid['targetgroup'] > 1: + raise errors.ValidationError(name='target', error=_('type, filter, subtree and targetgroup are mutually exclusive')) + + if 'aciprefix' not in kw: + raise errors.ValidationError(name='aciprefix', error=_('ACI prefix is required')) + + if sum(valid.values()) == 0: + raise errors.ValidationError(name='target', error=_('at least one of: type, filter, subtree, targetgroup, attrs or memberof are required')) + + if valid['filter'] + valid['memberof'] > 1: + raise errors.ValidationError(name='target', error=_('filter and memberof are mutually exclusive')) + + group = 'group' in kw + permission = 'permission' in kw + selfaci = 'selfaci' in kw and kw['selfaci'] == True + if group + permission + selfaci > 1: + raise errors.ValidationError(name='target', error=_('group, permission and self are mutually exclusive')) + elif group + permission + selfaci == 0: + raise errors.ValidationError(name='target', error=_('One of group, permission or self is required')) + + # Grab the dn of the group we're granting access to. This group may be a + # permission or a user group. + entry_attrs = [] + if permission: + # This will raise NotFound if the permission doesn't exist + try: + entry_attrs = api.Command['permission_show'](kw['permission'])['result'] + except errors.NotFound as e: + if 'test' in kw and not kw.get('test'): + raise e + else: + entry_attrs = { + 'dn': DN(('cn', kw['permission']), + api.env.container_permission, api.env.basedn), + } + elif group: + # Not so friendly with groups. This will raise + try: + group_dn = api.Object['group'].get_dn_if_exists(kw['group']) + entry_attrs = {'dn': group_dn} + except errors.NotFound: + raise errors.NotFound(reason=_("Group '%s' does not exist") % kw['group']) + + try: + a = ACI(current) + a.name = _make_aci_name(kw['aciprefix'], aciname) + a.permissions = kw['permissions'] + if 'selfaci' in kw and kw['selfaci']: + a.set_bindrule('userdn = "ldap:///self"') + else: + dn = entry_attrs['dn'] + a.set_bindrule('groupdn = "ldap:///%s"' % dn) + if valid['attrs']: + a.set_target_attr(kw['attrs']) + if valid['memberof']: + try: + api.Object['group'].get_dn_if_exists(kw['memberof']) + except errors.NotFound: + api.Object['group'].handle_not_found(kw['memberof']) + groupdn = _group_from_memberof(kw['memberof']) + a.set_target_filter('memberOf=%s' % groupdn) + if valid['filter']: + # Test the filter by performing a simple search on it. The + # filter is considered valid if either it returns some entries + # or it returns no entries, otherwise we let whatever exception + # happened be raised. + if kw['filter'] in ('', None, u''): + raise errors.BadSearchFilter(info=_('empty filter')) + try: + entries = ldap.find_entries(filter=kw['filter']) + except errors.NotFound: + pass + a.set_target_filter(kw['filter']) + if valid['type']: + target = _type_map[kw['type']] + a.set_target(target) + if valid['targetgroup']: + # Purposely no try here so we'll raise a NotFound + group_dn = api.Object['group'].get_dn_if_exists(kw['targetgroup']) + target = 'ldap:///%s' % group_dn + a.set_target(target) + if valid['subtree']: + # See if the subtree is a full URI + target = kw['subtree'] + if not target.startswith('ldap:///'): + target = 'ldap:///%s' % target + a.set_target(target) + except SyntaxError as e: + raise errors.ValidationError(name='target', error=_('Syntax Error: %(error)s') % dict(error=str(e))) + + return a + +def _aci_to_kw(ldap, a, test=False, pkey_only=False): + """Convert an ACI into its equivalent keywords. + + This is used for the modify operation so we can merge the + incoming kw and existing ACI and pass the result to + _make_aci(). + """ + kw = {} + kw['aciprefix'], kw['aciname'] = _parse_aci_name(a.name) + if pkey_only: + return kw + kw['permissions'] = tuple(a.permissions) + if 'targetattr' in a.target: + kw['attrs'] = tuple(unicode(e) + for e in a.target['targetattr']['expression']) + if 'targetfilter' in a.target: + target = a.target['targetfilter']['expression'] + if target.startswith('(memberOf=') or target.startswith('memberOf='): + (junk, memberof) = target.split('memberOf=', 1) + memberof = DN(memberof) + kw['memberof'] = memberof['cn'] + else: + kw['filter'] = unicode(target) + if 'target' in a.target: + target = a.target['target']['expression'] + found = False + for k in _type_map.keys(): + if _type_map[k] == target: + kw['type'] = unicode(k) + found = True + break + if not found: + if target.startswith('('): + kw['filter'] = unicode(target) + else: + # See if the target is a group. If so we set the + # targetgroup attr, otherwise we consider it a subtree + try: + targetdn = DN(target.replace('ldap:///','')) + except ValueError as e: + raise errors.ValidationError(name='subtree', error=_("invalid DN (%s)") % e.message) + if targetdn.endswith(DN(api.env.container_group, api.env.basedn)): + kw['targetgroup'] = targetdn[0]['cn'] + else: + kw['subtree'] = unicode(target) + + groupdn = a.bindrule['expression'] + groupdn = groupdn.replace('ldap:///','') + if groupdn == 'self': + kw['selfaci'] = True + elif groupdn == 'anyone': + pass + else: + groupdn = DN(groupdn) + if len(groupdn) and groupdn[0].attr == 'cn': + dn = DN() + entry = ldap.make_entry(dn) + try: + entry = ldap.get_entry(groupdn, ['cn']) + except errors.NotFound as e: + # FIXME, use real name here + if test: + dn = DN(('cn', 'test'), api.env.container_permission, + api.env.basedn) + entry = ldap.make_entry(dn, {'cn': [u'test']}) + if api.env.container_permission in entry.dn: + kw['permission'] = entry['cn'][0] + else: + if 'cn' in entry: + kw['group'] = entry['cn'][0] + + return kw + +def _convert_strings_to_acis(acistrs): + acis = [] + for a in acistrs: + try: + acis.append(ACI(a)) + except SyntaxError as e: + root_logger.warning("Failed to parse: %s" % a) + return acis + +def _find_aci_by_name(acis, aciprefix, aciname): + name = _make_aci_name(aciprefix, aciname).lower() + for a in acis: + if a.name.lower() == name: + return a + raise errors.NotFound(reason=_('ACI with name "%s" not found') % aciname) + + +def validate_permissions(ugettext, perm): + perm = perm.strip().lower() + if perm not in _valid_permissions_values: + return '"%s" is not a valid permission' % perm + + +def _normalize_permissions(perm): + valid_permissions = [] + perm = perm.strip().lower() + if perm not in valid_permissions: + valid_permissions.append(perm) + return ','.join(valid_permissions) + +_prefix_option = StrEnum('aciprefix', + cli_name='prefix', + label=_('ACI prefix'), + doc=_('Prefix used to distinguish ACI types ' \ + '(permission, delegation, selfservice, none)'), + values=_valid_prefix_values, + ) + + +@register() +class aci(Object): + """ + ACI object. + """ + NO_CLI = True + + label = _('ACIs') + + takes_params = ( + Str('aciname', + cli_name='name', + label=_('ACI name'), + primary_key=True, + flags=('virtual_attribute',), + ), + Str('permission?', + cli_name='permission', + label=_('Permission'), + doc=_('Permission ACI grants access to'), + flags=('virtual_attribute',), + ), + Str('group?', + cli_name='group', + label=_('User group'), + doc=_('User group ACI grants access to'), + flags=('virtual_attribute',), + ), + Str('permissions+', validate_permissions, + cli_name='permissions', + label=_('Permissions'), + doc=_('Permissions to grant' \ + '(read, write, add, delete, all)'), + normalizer=_normalize_permissions, + flags=('virtual_attribute',), + ), + Str('attrs*', + cli_name='attrs', + label=_('Attributes to which the permission applies'), + doc=_('Attributes'), + flags=('virtual_attribute',), + ), + StrEnum('type?', + cli_name='type', + label=_('Type'), + doc=_('type of IPA object (user, group, host, hostgroup, service, netgroup)'), + values=(u'user', u'group', u'host', u'service', u'hostgroup', u'netgroup', u'dnsrecord'), + flags=('virtual_attribute',), + ), + Str('memberof?', + cli_name='memberof', + label=_('Member of'), # FIXME: Does this label make sense? + doc=_('Member of a group'), + flags=('virtual_attribute',), + ), + Str('filter?', + cli_name='filter', + label=_('Filter'), + doc=_('Legal LDAP filter (e.g. ou=Engineering)'), + flags=('virtual_attribute',), + ), + Str('subtree?', + cli_name='subtree', + label=_('Subtree'), + doc=_('Subtree to apply ACI to'), + flags=('virtual_attribute',), + ), + Str('targetgroup?', + cli_name='targetgroup', + label=_('Target group'), + doc=_('Group to apply ACI to'), + flags=('virtual_attribute',), + ), + Flag('selfaci?', + cli_name='self', + label=_('Target your own entry (self)'), + doc=_('Apply ACI to your own entry (self)'), + flags=('virtual_attribute',), + ), + ) + + +@register() +class aci_add(crud.Create): + """ + Create new ACI. + """ + NO_CLI = True + msg_summary = _('Created ACI "%(value)s"') + + takes_options = ( + _prefix_option, + Flag('test?', + doc=_('Test the ACI syntax but don\'t write anything'), + default=False, + ), + ) + + def execute(self, aciname, **kw): + """ + Execute the aci-create operation. + + Returns the entry as it will be created in LDAP. + + :param aciname: The name of the ACI being added. + :param kw: Keyword arguments for the other LDAP attributes. + """ + assert 'aciname' not in kw + ldap = self.api.Backend.ldap2 + + newaci = _make_aci(ldap, None, aciname, kw) + + entry = ldap.get_entry(self.api.env.basedn, ['aci']) + + acis = _convert_strings_to_acis(entry.get('aci', [])) + for a in acis: + # FIXME: add check for permission_group = permission_group + if a.isequal(newaci) or newaci.name == a.name: + raise errors.DuplicateEntry() + + newaci_str = unicode(newaci) + entry.setdefault('aci', []).append(newaci_str) + + if not kw.get('test', False): + ldap.update_entry(entry) + + if kw.get('raw', False): + result = dict(aci=unicode(newaci_str)) + else: + result = _aci_to_kw(ldap, newaci, kw.get('test', False)) + return dict( + result=result, + value=pkey_to_value(aciname, kw), + ) + + +@register() +class aci_del(crud.Delete): + """ + Delete ACI. + """ + NO_CLI = True + has_output = output.standard_boolean + msg_summary = _('Deleted ACI "%(value)s"') + + takes_options = (_prefix_option,) + + def execute(self, aciname, aciprefix, **options): + """ + Execute the aci-delete operation. + + :param aciname: The name of the ACI being deleted. + :param aciprefix: The ACI prefix. + """ + ldap = self.api.Backend.ldap2 + + entry = ldap.get_entry(self.api.env.basedn, ['aci']) + + acistrs = entry.get('aci', []) + acis = _convert_strings_to_acis(acistrs) + aci = _find_aci_by_name(acis, aciprefix, aciname) + for a in acistrs: + candidate = ACI(a) + if aci.isequal(candidate): + acistrs.remove(a) + break + + entry['aci'] = acistrs + + ldap.update_entry(entry) + + return dict( + result=True, + value=pkey_to_value(aciname, options), + ) + + +@register() +class aci_mod(crud.Update): + """ + Modify ACI. + """ + NO_CLI = True + has_output_params = ( + Str('aci', + label=_('ACI'), + ), + ) + + takes_options = (_prefix_option,) + + internal_options = ['rename'] + + msg_summary = _('Modified ACI "%(value)s"') + + def execute(self, aciname, **kw): + aciprefix = kw['aciprefix'] + ldap = self.api.Backend.ldap2 + + entry = ldap.get_entry(self.api.env.basedn, ['aci']) + + acis = _convert_strings_to_acis(entry.get('aci', [])) + aci = _find_aci_by_name(acis, aciprefix, aciname) + + # The strategy here is to convert the ACI we're updating back into + # a series of keywords. Then we replace any keywords that have been + # updated and convert that back into an ACI and write it out. + oldkw = _aci_to_kw(ldap, aci) + newkw = deepcopy(oldkw) + if newkw.get('selfaci', False): + # selfaci is set in aci_to_kw to True only if the target is self + kw['selfaci'] = True + newkw.update(kw) + for acikw in (oldkw, newkw): + acikw.pop('aciname', None) + + # _make_aci is what is run in aci_add and validates the input. + # Do this before we delete the existing ACI. + newaci = _make_aci(ldap, None, aciname, newkw) + if aci.isequal(newaci): + raise errors.EmptyModlist() + + self.api.Command['aci_del'](aciname, aciprefix=aciprefix) + + try: + result = self.api.Command['aci_add'](aciname, **newkw)['result'] + except Exception as e: + # ACI could not be added, try to restore the old deleted ACI and + # report the ADD error back to user + try: + self.api.Command['aci_add'](aciname, **oldkw) + except Exception: + pass + raise e + + if kw.get('raw', False): + result = dict(aci=unicode(newaci)) + else: + result = _aci_to_kw(ldap, newaci) + return dict( + result=result, + value=pkey_to_value(aciname, kw), + ) + + +@register() +class aci_find(crud.Search): + """ + Search for ACIs. + + Returns a list of ACIs + + EXAMPLES: + + To find all ACIs that apply directly to members of the group ipausers: + ipa aci-find --memberof=ipausers + + To find all ACIs that grant add access: + ipa aci-find --permissions=add + + Note that the find command only looks for the given text in the set of + ACIs, it does not evaluate the ACIs to see if something would apply. + For example, searching on memberof=ipausers will find all ACIs that + have ipausers as a memberof. There may be other ACIs that apply to + members of that group indirectly. + """ + NO_CLI = True + msg_summary = ngettext('%(count)d ACI matched', '%(count)d ACIs matched', 0) + + takes_options = (_prefix_option.clone_rename("aciprefix?", required=False), + gen_pkey_only_option("name"),) + + def execute(self, term=None, **kw): + ldap = self.api.Backend.ldap2 + + entry = ldap.get_entry(self.api.env.basedn, ['aci']) + + acis = _convert_strings_to_acis(entry.get('aci', [])) + results = [] + + if term: + term = term.lower() + for a in acis: + if a.name.lower().find(term) != -1 and a not in results: + results.append(a) + acis = list(results) + else: + results = list(acis) + + if kw.get('aciname'): + for a in acis: + prefix, name = _parse_aci_name(a.name) + if name != kw['aciname']: + results.remove(a) + acis = list(results) + + if kw.get('aciprefix'): + for a in acis: + prefix, name = _parse_aci_name(a.name) + if prefix != kw['aciprefix']: + results.remove(a) + acis = list(results) + + if kw.get('attrs'): + for a in acis: + if not 'targetattr' in a.target: + results.remove(a) + continue + alist1 = sorted( + [t.lower() for t in a.target['targetattr']['expression']] + ) + alist2 = sorted([t.lower() for t in kw['attrs']]) + if len(set(alist1) & set(alist2)) != len(alist2): + results.remove(a) + acis = list(results) + + if kw.get('permission'): + try: + self.api.Command['permission_show']( + kw['permission'] + ) + except errors.NotFound: + pass + else: + for a in acis: + uri = 'ldap:///%s' % entry.dn + if a.bindrule['expression'] != uri: + results.remove(a) + acis = list(results) + + if kw.get('permissions'): + for a in acis: + alist1 = sorted(a.permissions) + alist2 = sorted(kw['permissions']) + if len(set(alist1) & set(alist2)) != len(alist2): + results.remove(a) + acis = list(results) + + if kw.get('memberof'): + try: + dn = _group_from_memberof(kw['memberof']) + except errors.NotFound: + pass + else: + memberof_filter = '(memberOf=%s)' % dn + for a in acis: + if 'targetfilter' in a.target: + targetfilter = a.target['targetfilter']['expression'] + if targetfilter != memberof_filter: + results.remove(a) + else: + results.remove(a) + + if kw.get('type'): + for a in acis: + if 'target' in a.target: + target = a.target['target']['expression'] + else: + results.remove(a) + continue + found = False + for k in _type_map.keys(): + if _type_map[k] == target and kw['type'] == k: + found = True + break + if not found: + try: + results.remove(a) + except ValueError: + pass + + if kw.get('selfaci', False) is True: + for a in acis: + if a.bindrule['expression'] != u'ldap:///self': + try: + results.remove(a) + except ValueError: + pass + + if kw.get('group'): + for a in acis: + groupdn = a.bindrule['expression'] + groupdn = DN(groupdn.replace('ldap:///','')) + try: + cn = groupdn[0]['cn'] + except (IndexError, KeyError): + cn = None + if cn is None or cn != kw['group']: + try: + results.remove(a) + except ValueError: + pass + + if kw.get('targetgroup'): + for a in acis: + found = False + if 'target' in a.target: + target = a.target['target']['expression'] + targetdn = DN(target.replace('ldap:///','')) + group_container_dn = DN(api.env.container_group, api.env.basedn) + if targetdn.endswith(group_container_dn): + try: + cn = targetdn[0]['cn'] + except (IndexError, KeyError): + cn = None + if cn == kw['targetgroup']: + found = True + if not found: + try: + results.remove(a) + except ValueError: + pass + + if kw.get('filter'): + if not kw['filter'].startswith('('): + kw['filter'] = unicode('('+kw['filter']+')') + for a in acis: + if 'targetfilter' not in a.target or\ + not a.target['targetfilter']['expression'] or\ + a.target['targetfilter']['expression'] != kw['filter']: + results.remove(a) + + if kw.get('subtree'): + for a in acis: + if 'target' in a.target: + target = a.target['target']['expression'] + else: + results.remove(a) + continue + if kw['subtree'].lower() != target.lower(): + try: + results.remove(a) + except ValueError: + pass + + acis = [] + for result in results: + if kw.get('raw', False): + aci = dict(aci=unicode(result)) + else: + aci = _aci_to_kw(ldap, result, + pkey_only=kw.get('pkey_only', False)) + acis.append(aci) + + return dict( + result=acis, + count=len(acis), + truncated=False, + ) + + +@register() +class aci_show(crud.Retrieve): + """ + Display a single ACI given an ACI name. + """ + NO_CLI = True + + has_output_params = ( + Str('aci', + label=_('ACI'), + ), + ) + + takes_options = ( + _prefix_option, + DNParam('location?', + label=_('Location of the ACI'), + ) + ) + + def execute(self, aciname, **kw): + """ + Execute the aci-show operation. + + Returns the entry + + :param uid: The login name of the user to retrieve. + :param kw: unused + """ + ldap = self.api.Backend.ldap2 + + dn = kw.get('location', self.api.env.basedn) + entry = ldap.get_entry(dn, ['aci']) + + acis = _convert_strings_to_acis(entry.get('aci', [])) + + aci = _find_aci_by_name(acis, kw['aciprefix'], aciname) + if kw.get('raw', False): + result = dict(aci=unicode(aci)) + else: + result = _aci_to_kw(ldap, aci) + return dict( + result=result, + value=pkey_to_value(aciname, kw), + ) + + +@register() +class aci_rename(crud.Update): + """ + Rename an ACI. + """ + NO_CLI = True + has_output_params = ( + Str('aci', + label=_('ACI'), + ), + ) + + takes_options = ( + _prefix_option, + Str('newname', + doc=_('New ACI name'), + ), + ) + + msg_summary = _('Renamed ACI to "%(value)s"') + + def execute(self, aciname, **kw): + ldap = self.api.Backend.ldap2 + + entry = ldap.get_entry(self.api.env.basedn, ['aci']) + + acis = _convert_strings_to_acis(entry.get('aci', [])) + aci = _find_aci_by_name(acis, kw['aciprefix'], aciname) + + for a in acis: + prefix, name = _parse_aci_name(a.name) + if _make_aci_name(prefix, kw['newname']) == a.name: + raise errors.DuplicateEntry() + + # The strategy here is to convert the ACI we're updating back into + # a series of keywords. Then we replace any keywords that have been + # updated and convert that back into an ACI and write it out. + newkw = _aci_to_kw(ldap, aci) + if 'selfaci' in newkw and newkw['selfaci'] == True: + # selfaci is set in aci_to_kw to True only if the target is self + kw['selfaci'] = True + if 'aciname' in newkw: + del newkw['aciname'] + + # _make_aci is what is run in aci_add and validates the input. + # Do this before we delete the existing ACI. + newaci = _make_aci(ldap, None, kw['newname'], newkw) + + self.api.Command['aci_del'](aciname, aciprefix=kw['aciprefix']) + + result = self.api.Command['aci_add'](kw['newname'], **newkw)['result'] + + if kw.get('raw', False): + result = dict(aci=unicode(newaci)) + else: + result = _aci_to_kw(ldap, newaci) + return dict( + result=result, + value=pkey_to_value(kw['newname'], kw), + ) diff --git a/ipaserver/plugins/automember.py b/ipaserver/plugins/automember.py new file mode 100644 index 000000000..89b9dfadc --- /dev/null +++ b/ipaserver/plugins/automember.py @@ -0,0 +1,802 @@ +# Authors: +# Jr Aquino <jr.aquino@citrix.com> +# +# Copyright (C) 2011 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. +import uuid +import time + +import ldap as _ldap +import six + +from ipalib import api, errors, Str, StrEnum, DNParam, Flag, _, ngettext +from ipalib import output, Command +from ipalib.plugable import Registry +from .baseldap import ( + pkey_to_value, + entry_to_dict, + LDAPObject, + LDAPCreate, + LDAPUpdate, + LDAPDelete, + LDAPSearch, + LDAPRetrieve) +from ipalib.request import context +from ipapython.dn import DN + +if six.PY3: + unicode = str + +__doc__ = _(""" +Auto Membership Rule. +""") + _(""" +Bring clarity to the membership of hosts and users by configuring inclusive +or exclusive regex patterns, you can automatically assign a new entries into +a group or hostgroup based upon attribute information. +""") + _(""" +A rule is directly associated with a group by name, so you cannot create +a rule without an accompanying group or hostgroup. +""") + _(""" +A condition is a regular expression used by 389-ds to match a new incoming +entry with an automember rule. If it matches an inclusive rule then the +entry is added to the appropriate group or hostgroup. +""") + _(""" +A default group or hostgroup could be specified for entries that do not +match any rule. In case of user entries this group will be a fallback group +because all users are by default members of group specified in IPA config. +""") + _(""" +The automember-rebuild command can be used to retroactively run automember rules +against existing entries, thus rebuilding their membership. +""") + _(""" +EXAMPLES: +""") + _(""" + Add the initial group or hostgroup: + ipa hostgroup-add --desc="Web Servers" webservers + ipa group-add --desc="Developers" devel +""") + _(""" + Add the initial rule: + ipa automember-add --type=hostgroup webservers + ipa automember-add --type=group devel +""") + _(""" + Add a condition to the rule: + ipa automember-add-condition --key=fqdn --type=hostgroup --inclusive-regex=^web[1-9]+\.example\.com webservers + ipa automember-add-condition --key=manager --type=group --inclusive-regex=^uid=mscott devel +""") + _(""" + Add an exclusive condition to the rule to prevent auto assignment: + ipa automember-add-condition --key=fqdn --type=hostgroup --exclusive-regex=^web5\.example\.com webservers +""") + _(""" + Add a host: + ipa host-add web1.example.com +""") + _(""" + Add a user: + ipa user-add --first=Tim --last=User --password tuser1 --manager=mscott +""") + _(""" + Verify automembership: + ipa hostgroup-show webservers + Host-group: webservers + Description: Web Servers + Member hosts: web1.example.com + + ipa group-show devel + Group name: devel + Description: Developers + GID: 1004200000 + Member users: tuser +""") + _(""" + Remove a condition from the rule: + ipa automember-remove-condition --key=fqdn --type=hostgroup --inclusive-regex=^web[1-9]+\.example\.com webservers +""") + _(""" + Modify the automember rule: + ipa automember-mod +""") + _(""" + Set the default (fallback) target group: + ipa automember-default-group-set --default-group=webservers --type=hostgroup + ipa automember-default-group-set --default-group=ipausers --type=group +""") + _(""" + Remove the default (fallback) target group: + ipa automember-default-group-remove --type=hostgroup + ipa automember-default-group-remove --type=group +""") + _(""" + Show the default (fallback) target group: + ipa automember-default-group-show --type=hostgroup + ipa automember-default-group-show --type=group +""") + _(""" + Find all of the automember rules: + ipa automember-find +""") + _(""" + Display a automember rule: + ipa automember-show --type=hostgroup webservers + ipa automember-show --type=group devel +""") + _(""" + Delete an automember rule: + ipa automember-del --type=hostgroup webservers + ipa automember-del --type=group devel +""") + _(""" + Rebuild membership for all users: + ipa automember-rebuild --type=group +""") + _(""" + Rebuild membership for all hosts: + ipa automember-rebuild --type=hostgroup +""") + _(""" + Rebuild membership for specified users: + ipa automember-rebuild --users=tuser1 --users=tuser2 +""") + _(""" + Rebuild membership for specified hosts: + ipa automember-rebuild --hosts=web1.example.com --hosts=web2.example.com +""") + +register = Registry() + +# Options used by Condition Add and Remove. +INCLUDE_RE = 'automemberinclusiveregex' +EXCLUDE_RE = 'automemberexclusiveregex' + +REBUILD_TASK_CONTAINER = DN(('cn', 'automember rebuild membership'), + ('cn', 'tasks'), + ('cn', 'config')) + + +regex_attrs = ( + Str('automemberinclusiveregex*', + cli_name='inclusive_regex', + label=_('Inclusive Regex'), + doc=_('Inclusive Regex'), + alwaysask=True, + ), + Str('automemberexclusiveregex*', + cli_name='exclusive_regex', + label=_('Exclusive Regex'), + doc=_('Exclusive Regex'), + alwaysask=True, + ), + Str('key', + label=_('Attribute Key'), + doc=_('Attribute to filter via regex. For example fqdn for a host, or manager for a user'), + flags=['no_create', 'no_update', 'no_search'] + ), +) + +group_type = ( + StrEnum('type', + label=_('Grouping Type'), + doc=_('Grouping to which the rule applies'), + values=(u'group', u'hostgroup', ), + ), +) + +automember_rule = ( + Str('cn', + cli_name='automember_rule', + label=_('Automember Rule'), + doc=_('Automember Rule'), + normalizer=lambda value: value.lower(), + ), +) + + +@register() +class automember(LDAPObject): + + """ + Bring automember to a hostgroup with an Auto Membership Rule. + """ + + container_dn = api.env.container_automember + + object_name = 'Automember rule' + object_name_plural = 'Automember rules' + object_class = ['top', 'automemberregexrule'] + permission_filter_objectclasses = ['automemberregexrule'] + default_attributes = [ + 'automemberinclusiveregex', 'automemberexclusiveregex', + 'cn', 'automembertargetgroup', 'description', 'automemberdefaultgroup' + ] + managed_permissions = { + 'System: Read Automember Definitions': { + 'non_object': True, + 'ipapermlocation': DN(container_dn, api.env.basedn), + 'ipapermtargetfilter': {'(objectclass=automemberdefinition)'}, + 'replaces_global_anonymous_aci': True, + 'ipapermright': {'read', 'search', 'compare'}, + 'ipapermdefaultattr': { + 'objectclass', 'cn', 'automemberscope', 'automemberfilter', + 'automembergroupingattr', 'automemberdefaultgroup', + 'automemberdisabled', + }, + 'default_privileges': {'Automember Readers', + 'Automember Task Administrator'}, + }, + 'System: Read Automember Rules': { + 'replaces_global_anonymous_aci': True, + 'ipapermright': {'read', 'search', 'compare'}, + 'ipapermdefaultattr': { + 'cn', 'objectclass', 'automembertargetgroup', 'description', + 'automemberexclusiveregex', 'automemberinclusiveregex', + }, + 'default_privileges': {'Automember Readers', + 'Automember Task Administrator'}, + }, + 'System: Read Automember Tasks': { + 'non_object': True, + 'ipapermlocation': DN('cn=tasks', 'cn=config'), + 'ipapermtarget': DN('cn=*', REBUILD_TASK_CONTAINER), + 'replaces_global_anonymous_aci': True, + 'ipapermright': {'read', 'search', 'compare'}, + 'ipapermdefaultattr': {'*'}, + 'default_privileges': {'Automember Task Administrator'}, + }, + } + + label = _('Auto Membership Rule') + + takes_params = ( + Str('description?', + cli_name='desc', + label=_('Description'), + doc=_('A description of this auto member rule'), + ), + Str('automemberdefaultgroup?', + cli_name='default_group', + label=_('Default (fallback) Group'), + doc=_('Default group for entries to land'), + flags=['no_create', 'no_update', 'no_search'] + ), + ) + + def dn_exists(self, otype, oname): + ldap = self.api.Backend.ldap2 + dn = self.api.Object[otype].get_dn(oname) + try: + entry = ldap.get_entry(dn, []) + except errors.NotFound: + raise errors.NotFound( + reason=_(u'%(otype)s "%(oname)s" not found') % + dict(otype=otype, oname=oname) + ) + return entry.dn + + def get_dn(self, *keys, **options): + if self.parent_object: + parent_dn = self.api.Object[self.parent_object].get_dn(*keys[:-1]) + else: + parent_dn = DN(self.container_dn, api.env.basedn) + grouptype = options['type'] + try: + ndn = DN(('cn', keys[-1]), ('cn', grouptype), parent_dn) + except IndexError: + ndn = DN(('cn', grouptype), parent_dn) + return ndn + + def check_attr(self, attr): + """ + Verify that the user supplied key is a valid attribute in the schema + """ + ldap = self.api.Backend.ldap2 + obj = ldap.schema.get_obj(_ldap.schema.AttributeType, attr) + if obj is not None: + return obj + else: + raise errors.NotFound(reason=_('%s is not a valid attribute.') % attr) + + +def automember_container_exists(ldap): + try: + ldap.get_entry(DN(api.env.container_automember, api.env.basedn), []) + except errors.NotFound: + return False + return True + + +@register() +class automember_add(LDAPCreate): + __doc__ = _(""" + Add an automember rule. + """) + takes_options = LDAPCreate.takes_options + group_type + takes_args = automember_rule + msg_summary = _('Added automember rule "%(value)s"') + + def pre_callback(self, ldap, dn, entry_attrs, *keys, **options): + assert isinstance(dn, DN) + + entry_attrs['cn'] = keys[-1] + if not automember_container_exists(self.api.Backend.ldap2): + raise errors.NotFound(reason=_('Auto Membership is not configured')) + entry_attrs['automembertargetgroup'] = self.obj.dn_exists(options['type'], keys[-1]) + return dn + + def execute(self, *keys, **options): + result = super(automember_add, self).execute(*keys, **options) + result['value'] = pkey_to_value(keys[-1], options) + return result + + +@register() +class automember_add_condition(LDAPUpdate): + __doc__ = _(""" + Add conditions to an automember rule. + """) + has_output_params = ( + Str('failed', + label=_('Failed to add'), + flags=['suppress_empty'], + ), + ) + + takes_options = regex_attrs + group_type + takes_args = automember_rule + msg_summary = _('Added condition(s) to "%(value)s"') + + # Prepare the output to expect failed results + has_output = ( + output.summary, + output.Entry('result'), + output.value, + output.Output('failed', + type=dict, + doc=_('Conditions that could not be added'), + ), + output.Output('completed', + type=int, + doc=_('Number of conditions added'), + ), + ) + + def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options): + assert isinstance(dn, DN) + # Check to see if the automember rule exists + try: + dn = ldap.get_entry(dn, []).dn + except errors.NotFound: + raise errors.NotFound(reason=_(u'Auto member rule: %s not found!') % keys[0]) + # Define container key + key = options['key'] + # Check to see if the attribute is valid + self.obj.check_attr(key) + + key = '%s=' % key + completed = 0 + failed = {'failed': {}} + + for attr in (INCLUDE_RE, EXCLUDE_RE): + failed['failed'][attr] = [] + if attr in options and options[attr]: + entry_attrs[attr] = [key + condition for condition in options[attr]] + completed += len(entry_attrs[attr]) + try: + old_entry = ldap.get_entry(dn, [attr]) + for regex in old_entry.keys(): + if not isinstance(entry_attrs[regex], (list, tuple)): + entry_attrs[regex] = [entry_attrs[regex]] + duplicate = set(old_entry[regex]) & set(entry_attrs[regex]) + if len(duplicate) > 0: + completed -= 1 + else: + entry_attrs[regex] = list(entry_attrs[regex]) + old_entry[regex] + except errors.NotFound: + failed['failed'][attr].append(regex) + + entry_attrs = entry_to_dict(entry_attrs, **options) + + # Set failed and completed to they can be harvested in the execute super + setattr(context, 'failed', failed) + setattr(context, 'completed', completed) + setattr(context, 'entry_attrs', entry_attrs) + + # Make sure to returned the failed results if there is nothing to remove + if completed == 0: + ldap.get_entry(dn, attrs_list) + raise errors.EmptyModlist + return dn + + def execute(self, *keys, **options): + __doc__ = _(""" + Override this so we can add completed and failed to the return result. + """) + try: + result = super(automember_add_condition, self).execute(*keys, **options) + except errors.EmptyModlist: + result = {'result': getattr(context, 'entry_attrs'), 'value': keys[-1]} + result['failed'] = getattr(context, 'failed') + result['completed'] = getattr(context, 'completed') + result['value'] = pkey_to_value(keys[-1], options) + return result + + +@register() +class automember_remove_condition(LDAPUpdate): + __doc__ = _(""" + Remove conditions from an automember rule. + """) + takes_options = regex_attrs + group_type + takes_args = automember_rule + msg_summary = _('Removed condition(s) from "%(value)s"') + + # Prepare the output to expect failed results + has_output = ( + output.summary, + output.Entry('result'), + output.value, + output.Output('failed', + type=dict, + doc=_('Conditions that could not be removed'), + ), + output.Output('completed', + type=int, + doc=_('Number of conditions removed'), + ), + ) + + def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options): + assert isinstance(dn, DN) + # Check to see if the automember rule exists + try: + ldap.get_entry(dn, []) + except errors.NotFound: + raise errors.NotFound(reason=_(u'Auto member rule: %s not found!') % keys[0]) + + # Define container key + type_attr_default = {'group': 'manager', 'hostgroup': 'fqdn'} + if 'key' in options: + key = options['key'] + else: + key = type_attr_default[options['type']] + + key = '%s=' % key + completed = 0 + failed = {'failed': {}} + + # Check to see if there are existing exclusive conditions present. + dn = ldap.get_entry(dn, [EXCLUDE_RE]).dn + + for attr in (INCLUDE_RE, EXCLUDE_RE): + failed['failed'][attr] = [] + if attr in options and options[attr]: + entry_attrs[attr] = [key + condition for condition in options[attr]] + entry_attrs_ = ldap.get_entry(dn, [attr]) + old_entry = entry_attrs_.get(attr, []) + for regex in entry_attrs[attr]: + if regex in old_entry: + old_entry.remove(regex) + completed += 1 + else: + failed['failed'][attr].append(regex) + entry_attrs[attr] = old_entry + + entry_attrs = entry_to_dict(entry_attrs, **options) + + # Set failed and completed to they can be harvested in the execute super + setattr(context, 'failed', failed) + setattr(context, 'completed', completed) + setattr(context, 'entry_attrs', entry_attrs) + + # Make sure to returned the failed results if there is nothing to remove + if completed == 0: + ldap.get_entry(dn, attrs_list) + raise errors.EmptyModlist + return dn + + def execute(self, *keys, **options): + __doc__ = _(""" + Override this so we can set completed and failed. + """) + try: + result = super(automember_remove_condition, self).execute(*keys, **options) + except errors.EmptyModlist: + result = {'result': getattr(context, 'entry_attrs'), 'value': keys[-1]} + result['failed'] = getattr(context, 'failed') + result['completed'] = getattr(context, 'completed') + result['value'] = pkey_to_value(keys[-1], options) + return result + + +@register() +class automember_mod(LDAPUpdate): + __doc__ = _(""" + Modify an automember rule. + """) + takes_args = automember_rule + takes_options = LDAPUpdate.takes_options + group_type + msg_summary = _('Modified automember rule "%(value)s"') + + def execute(self, *keys, **options): + result = super(automember_mod, self).execute(*keys, **options) + result['value'] = pkey_to_value(keys[-1], options) + return result + + +@register() +class automember_del(LDAPDelete): + __doc__ = _(""" + Delete an automember rule. + """) + takes_args = automember_rule + takes_options = group_type + msg_summary = _('Deleted automember rule "%(value)s"') + + def execute(self, *keys, **options): + result = super(automember_del, self).execute(*keys, **options) + result['value'] = pkey_to_value([keys[-1]], options) + return result + + +@register() +class automember_find(LDAPSearch): + __doc__ = _(""" + Search for automember rules. + """) + takes_options = group_type + has_output_params = LDAPSearch.has_output_params + automember_rule + regex_attrs + + msg_summary = ngettext( + '%(count)d rules matched', '%(count)d rules matched', 0 + ) + + def pre_callback(self, ldap, filters, attrs_list, base_dn, scope, *args, **options): + assert isinstance(base_dn, DN) + scope = ldap.SCOPE_SUBTREE + ndn = DN(('cn', options['type']), base_dn) + return (filters, ndn, scope) + + +@register() +class automember_show(LDAPRetrieve): + __doc__ = _(""" + Display information about an automember rule. + """) + takes_args = automember_rule + takes_options = group_type + has_output_params = LDAPRetrieve.has_output_params + regex_attrs + + def execute(self, *keys, **options): + result = super(automember_show, self).execute(*keys, **options) + result['value'] = pkey_to_value(keys[-1], options) + return result + + +@register() +class automember_default_group_set(LDAPUpdate): + __doc__ = _(""" + Set default (fallback) group for all unmatched entries. + """) + + takes_options = ( + Str('automemberdefaultgroup', + cli_name='default_group', + label=_('Default (fallback) Group'), + doc=_('Default (fallback) group for entries to land'), + flags=['no_create', 'no_update'] + ), + ) + group_type + msg_summary = _('Set default (fallback) group for automember "%(value)s"') + + def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options): + dn = DN(('cn', options['type']), api.env.container_automember, + api.env.basedn) + entry_attrs['automemberdefaultgroup'] = self.obj.dn_exists(options['type'], options['automemberdefaultgroup']) + return dn + + def execute(self, *keys, **options): + result = super(automember_default_group_set, self).execute(*keys, **options) + result['value'] = pkey_to_value(options['type'], options) + return result + + +@register() +class automember_default_group_remove(LDAPUpdate): + __doc__ = _(""" + Remove default (fallback) group for all unmatched entries. + """) + + takes_options = group_type + msg_summary = _('Removed default (fallback) group for automember "%(value)s"') + + def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options): + dn = DN(('cn', options['type']), api.env.container_automember, + api.env.basedn) + attr = 'automemberdefaultgroup' + + entry_attrs_ = ldap.get_entry(dn, [attr]) + + if attr not in entry_attrs_: + raise errors.NotFound(reason=_(u'No default (fallback) group set')) + else: + entry_attrs[attr] = [] + return entry_attrs_.dn + + def post_callback(self, ldap, dn, entry_attrs, *keys, **options): + assert isinstance(dn, DN) + if 'automemberdefaultgroup' not in entry_attrs: + entry_attrs['automemberdefaultgroup'] = unicode(_('No default (fallback) group set')) + return dn + + def execute(self, *keys, **options): + result = super(automember_default_group_remove, self).execute(*keys, **options) + result['value'] = pkey_to_value(options['type'], options) + return result + + +@register() +class automember_default_group_show(LDAPRetrieve): + __doc__ = _(""" + Display information about the default (fallback) automember groups. + """) + takes_options = group_type + + def pre_callback(self, ldap, dn, attrs_list, *keys, **options): + dn = DN(('cn', options['type']), api.env.container_automember, + api.env.basedn) + return dn + + def post_callback(self, ldap, dn, entry_attrs, *keys, **options): + assert isinstance(dn, DN) + if 'automemberdefaultgroup' not in entry_attrs: + entry_attrs['automemberdefaultgroup'] = unicode(_('No default (fallback) group set')) + return dn + + def execute(self, *keys, **options): + result = super(automember_default_group_show, self).execute(*keys, **options) + result['value'] = pkey_to_value(options['type'], options) + return result + + +@register() +class automember_rebuild(Command): + __doc__ = _('Rebuild auto membership.') + # TODO: Add a --dry-run option: + # https://fedorahosted.org/freeipa/ticket/3936 + takes_options = ( + group_type[0].clone( + required=False, + label=_('Rebuild membership for all members of a grouping') + ), + Str( + 'users*', + label=_('Users'), + doc=_('Rebuild membership for specified users'), + ), + Str( + 'hosts*', + label=_('Hosts'), + doc=_('Rebuild membership for specified hosts'), + ), + Flag( + 'no_wait?', + default=False, + label=_('No wait'), + doc=_("Don't wait for rebuilding membership"), + ), + ) + has_output = output.standard_entry + has_output_params = ( + DNParam( + 'dn', + label=_('Task DN'), + doc=_('DN of the started task'), + ), + ) + + def validate(self, **kw): + """ + Validation rules: + - at least one of 'type', 'users', 'hosts' is required + - 'users' and 'hosts' cannot be combined together + - if 'users' and 'type' are specified, 'type' must be 'group' + - if 'hosts' and 'type' are specified, 'type' must be 'hostgroup' + """ + super(automember_rebuild, self).validate(**kw) + users, hosts, gtype = kw.get('users'), kw.get('hosts'), kw.get('type') + + if not (gtype or users or hosts): + raise errors.MutuallyExclusiveError( + reason=_('at least one of options: type, users, hosts must be ' + 'specified') + ) + + if users and hosts: + raise errors.MutuallyExclusiveError( + reason=_("users and hosts cannot both be set") + ) + if gtype == 'group' and hosts: + raise errors.MutuallyExclusiveError( + reason=_("hosts cannot be set when type is 'group'") + ) + if gtype == 'hostgroup' and users: + raise errors.MutuallyExclusiveError( + reason=_("users cannot be set when type is 'hostgroup'") + ) + + def execute(self, *keys, **options): + ldap = self.api.Backend.ldap2 + cn = str(uuid.uuid4()) + + gtype = options.get('type') + if not gtype: + gtype = 'group' if options.get('users') else 'hostgroup' + + types = { + 'group': ( + 'user', + 'users', + DN(api.env.container_user, api.env.basedn) + ), + 'hostgroup': ( + 'host', + 'hosts', + DN(api.env.container_host, api.env.basedn) + ), + } + + obj_name, opt_name, basedn = types[gtype] + obj = self.api.Object[obj_name] + + names = options.get(opt_name) + if names: + for name in names: + try: + obj.get_dn_if_exists(name) + except errors.NotFound: + obj.handle_not_found(name) + search_filter = ldap.make_filter_from_attr( + obj.primary_key.name, + names, + rules=ldap.MATCH_ANY + ) + else: + search_filter = '(%s=*)' % obj.primary_key.name + + task_dn = DN(('cn', cn), REBUILD_TASK_CONTAINER) + + entry = ldap.make_entry( + task_dn, + objectclass=['top', 'extensibleObject'], + cn=[cn], + basedn=[basedn], + filter=[search_filter], + scope=['sub'], + ttl=[3600]) + ldap.add_entry(entry) + + summary = _('Automember rebuild membership task started') + result = {'dn': task_dn} + + if not options.get('no_wait'): + summary = _('Automember rebuild membership task completed') + result = {} + start_time = time.time() + + while True: + try: + task = ldap.get_entry(task_dn) + except errors.NotFound: + break + + if 'nstaskexitcode' in task: + if str(task.single_value['nstaskexitcode']) == '0': + summary=task.single_value['nstaskstatus'] + break + else: + raise errors.DatabaseError( + desc=task.single_value['nstaskstatus'], + info=_("Task DN = '%s'" % task_dn)) + time.sleep(1) + if time.time() > (start_time + 60): + raise errors.TaskTimeout(task=_('Automember'), task_dn=task_dn) + + return dict( + result=result, + summary=unicode(summary), + value=pkey_to_value(None, options)) diff --git a/ipaserver/plugins/automount.py b/ipaserver/plugins/automount.py new file mode 100644 index 000000000..c4cf2d6db --- /dev/null +++ b/ipaserver/plugins/automount.py @@ -0,0 +1,841 @@ +# Authors: +# Rob Crittenden <rcritten@redhat.com> +# Pavel Zuna <pzuna@redhat.com> +# +# Copyright (C) 2008 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +import six + +from ipalib import api, errors +from ipalib import Str, IA5Str +from ipalib.plugable import Registry +from .baseldap import ( + pkey_to_value, + LDAPObject, + LDAPCreate, + LDAPDelete, + LDAPQuery, + LDAPUpdate, + LDAPSearch, + LDAPRetrieve) +from ipalib import _, ngettext +from ipapython.dn import DN + +if six.PY3: + unicode = str + +__doc__ = _(""" +Automount + +Stores automount(8) configuration for autofs(8) in IPA. + +The base of an automount configuration is the configuration file auto.master. +This is also the base location in IPA. Multiple auto.master configurations +can be stored in separate locations. A location is implementation-specific +with the default being a location named 'default'. For example, you can have +locations by geographic region, by floor, by type, etc. + +Automount has three basic object types: locations, maps and keys. + +A location defines a set of maps anchored in auto.master. This allows you +to store multiple automount configurations. A location in itself isn't +very interesting, it is just a point to start a new automount map. + +A map is roughly equivalent to a discrete automount file and provides +storage for keys. + +A key is a mount point associated with a map. + +When a new location is created, two maps are automatically created for +it: auto.master and auto.direct. auto.master is the root map for all +automount maps for the location. auto.direct is the default map for +direct mounts and is mounted on /-. + +An automount map may contain a submount key. This key defines a mount +location within the map that references another map. This can be done +either using automountmap-add-indirect --parentmap or manually +with automountkey-add and setting info to "-type=autofs :<mapname>". + +EXAMPLES: + +Locations: + + Create a named location, "Baltimore": + ipa automountlocation-add baltimore + + Display the new location: + ipa automountlocation-show baltimore + + Find available locations: + ipa automountlocation-find + + Remove a named automount location: + ipa automountlocation-del baltimore + + Show what the automount maps would look like if they were in the filesystem: + ipa automountlocation-tofiles baltimore + + Import an existing configuration into a location: + ipa automountlocation-import baltimore /etc/auto.master + + The import will fail if any duplicate entries are found. For + continuous operation where errors are ignored, use the --continue + option. + +Maps: + + Create a new map, "auto.share": + ipa automountmap-add baltimore auto.share + + Display the new map: + ipa automountmap-show baltimore auto.share + + Find maps in the location baltimore: + ipa automountmap-find baltimore + + Create an indirect map with auto.share as a submount: + ipa automountmap-add-indirect baltimore --parentmap=auto.share --mount=sub auto.man + + This is equivalent to: + + ipa automountmap-add-indirect baltimore --mount=/man auto.man + ipa automountkey-add baltimore auto.man --key=sub --info="-fstype=autofs ldap:auto.share" + + Remove the auto.share map: + ipa automountmap-del baltimore auto.share + +Keys: + + Create a new key for the auto.share map in location baltimore. This ties + the map we previously created to auto.master: + ipa automountkey-add baltimore auto.master --key=/share --info=auto.share + + Create a new key for our auto.share map, an NFS mount for man pages: + ipa automountkey-add baltimore auto.share --key=man --info="-ro,soft,rsize=8192,wsize=8192 ipa.example.com:/shared/man" + + Find all keys for the auto.share map: + ipa automountkey-find baltimore auto.share + + Find all direct automount keys: + ipa automountkey-find baltimore --key=/- + + Remove the man key from the auto.share map: + ipa automountkey-del baltimore auto.share --key=man +""") + +""" +Developer notes: + +RFC 2707bis http://www.padl.com/~lukeh/rfc2307bis.txt + +A few notes on automount: +- The default parent when adding an indirect map is auto.master +- This uses the short format for automount maps instead of the + URL format. Support for ldap as a map source in nsswitch.conf was added + in autofs version 4.1.3-197. Any version prior to that is not expected + to work. +- An indirect key should not begin with / + +As an example, the following automount files: + +auto.master: +/- auto.direct +/mnt auto.mnt + +auto.mnt: +stuff -ro,soft,rsize=8192,wsize=8192 nfs.example.com:/vol/archive/stuff + +are equivalent to the following LDAP entries: + +# auto.master, automount, example.com +dn: automountmapname=auto.master,cn=automount,dc=example,dc=com +objectClass: automountMap +objectClass: top +automountMapName: auto.master + +# auto.direct, automount, example.com +dn: automountmapname=auto.direct,cn=automount,dc=example,dc=com +objectClass: automountMap +objectClass: top +automountMapName: auto.direct + +# /-, auto.master, automount, example.com +dn: automountkey=/-,automountmapname=auto.master,cn=automount,dc=example,dc=co + m +objectClass: automount +objectClass: top +automountKey: /- +automountInformation: auto.direct + +# auto.mnt, automount, example.com +dn: automountmapname=auto.mnt,cn=automount,dc=example,dc=com +objectClass: automountMap +objectClass: top +automountMapName: auto.mnt + +# /mnt, auto.master, automount, example.com +dn: automountkey=/mnt,automountmapname=auto.master,cn=automount,dc=example,dc= + com +objectClass: automount +objectClass: top +automountKey: /mnt +automountInformation: auto.mnt + +# stuff, auto.mnt, automount, example.com +dn: automountkey=stuff,automountmapname=auto.mnt,cn=automount,dc=example,dc=com +objectClass: automount +objectClass: top +automountKey: stuff +automountInformation: -ro,soft,rsize=8192,wsize=8192 nfs.example.com:/vol/arch + ive/stuff + +""" + +register = Registry() + +DIRECT_MAP_KEY = u'/-' + +@register() +class automountlocation(LDAPObject): + """ + Location container for automount maps. + """ + container_dn = api.env.container_automount + object_name = _('automount location') + object_name_plural = _('automount locations') + object_class = ['nscontainer'] + default_attributes = ['cn'] + label = _('Automount Locations') + label_singular = _('Automount Location') + permission_filter_objectclasses = ['nscontainer'] + managed_permissions = { + 'System: Read Automount Configuration': { + # Single permission for all automount-related entries + 'non_object': True, + 'ipapermlocation': DN(container_dn, api.env.basedn), + 'replaces_global_anonymous_aci': True, + 'ipapermbindruletype': 'anonymous', + 'ipapermright': {'read', 'search', 'compare'}, + 'ipapermdefaultattr': { + 'cn', 'objectclass', + 'automountinformation', 'automountkey', 'description', + 'automountmapname', 'description', + }, + }, + 'System: Add Automount Locations': { + 'ipapermright': {'add'}, + 'default_privileges': {'Automount Administrators'}, + }, + 'System: Remove Automount Locations': { + 'ipapermright': {'delete'}, + 'default_privileges': {'Automount Administrators'}, + }, + } + + takes_params = ( + Str('cn', + cli_name='location', + label=_('Location'), + doc=_('Automount location name.'), + primary_key=True, + ), + ) + + +@register() +class automountlocation_add(LDAPCreate): + __doc__ = _('Create a new automount location.') + + msg_summary = _('Added automount location "%(value)s"') + + def post_callback(self, ldap, dn, entry_attrs, *keys, **options): + assert isinstance(dn, DN) + # create auto.master for the new location + self.api.Command['automountmap_add'](keys[-1], u'auto.master') + + # add additional pre-created maps and keys + # IMPORTANT: add pre-created maps/keys to DEFAULT_MAPS/DEFAULT_KEYS + # so that they do not cause conflicts during import operation + self.api.Command['automountmap_add_indirect']( + keys[-1], u'auto.direct', key=DIRECT_MAP_KEY + ) + return dn + + +@register() +class automountlocation_del(LDAPDelete): + __doc__ = _('Delete an automount location.') + + msg_summary = _('Deleted automount location "%(value)s"') + + +@register() +class automountlocation_show(LDAPRetrieve): + __doc__ = _('Display an automount location.') + + +@register() +class automountlocation_find(LDAPSearch): + __doc__ = _('Search for an automount location.') + + msg_summary = ngettext( + '%(count)d automount location matched', + '%(count)d automount locations matched', 0 + ) + + +@register() +class automountlocation_tofiles(LDAPQuery): + __doc__ = _('Generate automount files for a specific location.') + + def execute(self, *args, **options): + self.api.Command['automountlocation_show'](args[0]) + + result = self.api.Command['automountkey_find'](args[0], u'auto.master') + maps = result['result'] + + # maps, truncated + # TODO: handle truncated results + # ?use ldap.find_entries instead of automountkey_find? + + keys = {} + mapnames = [u'auto.master'] + for m in maps: + info = m['automountinformation'][0] + mapnames.append(info) + key = info.split(None) + result = self.api.Command['automountkey_find'](args[0], key[0]) + keys[info] = result['result'] + # TODO: handle truncated results, same as above + + allmaps = self.api.Command['automountmap_find'](args[0])['result'] + orphanmaps = [] + for m in allmaps: + if m['automountmapname'][0] not in mapnames: + orphanmaps.append(m) + + orphankeys = [] + # Collect all the keys for the orphaned maps + for m in orphanmaps: + key = m['automountmapname'] + result = self.api.Command['automountkey_find'](args[0], key[0]) + orphankeys.append(result['result']) + + return dict(result=dict(maps=maps, keys=keys, + orphanmaps=orphanmaps, orphankeys=orphankeys)) + + +@register() +class automountmap(LDAPObject): + """ + Automount map object. + """ + parent_object = 'automountlocation' + container_dn = api.env.container_automount + object_name = _('automount map') + object_name_plural = _('automount maps') + object_class = ['automountmap'] + permission_filter_objectclasses = ['automountmap'] + default_attributes = ['automountmapname', 'description'] + + takes_params = ( + IA5Str('automountmapname', + cli_name='map', + label=_('Map'), + doc=_('Automount map name.'), + primary_key=True, + ), + Str('description?', + cli_name='desc', + label=_('Description'), + ), + ) + + managed_permissions = { + 'System: Add Automount Maps': { + 'ipapermright': {'add'}, + 'replaces': [ + '(target = "ldap:///automountmapname=*,cn=automount,$SUFFIX")(version 3.0;acl "permission:Add Automount maps";allow (add) groupdn = "ldap:///cn=Add Automount maps,cn=permissions,cn=pbac,$SUFFIX";)', + ], + 'default_privileges': {'Automount Administrators'}, + }, + 'System: Modify Automount Maps': { + 'ipapermright': {'write'}, + 'ipapermdefaultattr': {'automountmapname', 'description'}, + 'replaces': [ + '(targetattr = "automountmapname || description")(target = "ldap:///automountmapname=*,cn=automount,$SUFFIX")(version 3.0;acl "permission:Modify Automount maps";allow (write) groupdn = "ldap:///cn=Modify Automount maps,cn=permissions,cn=pbac,$SUFFIX";)', + ], + 'default_privileges': {'Automount Administrators'}, + }, + 'System: Remove Automount Maps': { + 'ipapermright': {'delete'}, + 'replaces': [ + '(target = "ldap:///automountmapname=*,cn=automount,$SUFFIX")(version 3.0;acl "permission:Remove Automount maps";allow (delete) groupdn = "ldap:///cn=Remove Automount maps,cn=permissions,cn=pbac,$SUFFIX";)', + ], + 'default_privileges': {'Automount Administrators'}, + }, + } + + label = _('Automount Maps') + label_singular = _('Automount Map') + + +@register() +class automountmap_add(LDAPCreate): + __doc__ = _('Create a new automount map.') + + msg_summary = _('Added automount map "%(value)s"') + + +@register() +class automountmap_del(LDAPDelete): + __doc__ = _('Delete an automount map.') + + msg_summary = _('Deleted automount map "%(value)s"') + + def post_callback(self, ldap, dn, *keys, **options): + assert isinstance(dn, DN) + # delete optional parental connection (direct maps may not have this) + try: + entry_attrs = ldap.find_entry_by_attr( + 'automountinformation', keys[0], 'automount', + base_dn=DN(self.obj.container_dn, api.env.basedn) + ) + ldap.delete_entry(entry_attrs) + except errors.NotFound: + pass + return True + + +@register() +class automountmap_mod(LDAPUpdate): + __doc__ = _('Modify an automount map.') + + msg_summary = _('Modified automount map "%(value)s"') + + +@register() +class automountmap_find(LDAPSearch): + __doc__ = _('Search for an automount map.') + + msg_summary = ngettext( + '%(count)d automount map matched', + '%(count)d automount maps matched', 0 + ) + + +@register() +class automountmap_show(LDAPRetrieve): + __doc__ = _('Display an automount map.') + + +@register() +class automountkey(LDAPObject): + __doc__ = _('Automount key object.') + + parent_object = 'automountmap' + container_dn = api.env.container_automount + object_name = _('automount key') + object_name_plural = _('automount keys') + object_class = ['automount'] + permission_filter_objectclasses = ['automount'] + default_attributes = [ + 'automountkey', 'automountinformation', 'description' + ] + rdn_is_primary_key = True + rdn_separator = ' ' + + takes_params = ( + IA5Str('automountkey', + cli_name='key', + label=_('Key'), + doc=_('Automount key name.'), + flags=('req_update',), + ), + IA5Str('automountinformation', + cli_name='info', + label=_('Mount information'), + ), + Str('description', + label=_('description'), + primary_key=True, + required=False, + flags=['no_create', 'no_update', 'no_search', 'no_output'], + exclude='webui', + ), + ) + + managed_permissions = { + 'System: Add Automount Keys': { + 'ipapermright': {'add'}, + 'replaces': [ + '(target = "ldap:///automountkey=*,automountmapname=*,cn=automount,$SUFFIX")(version 3.0;acl "permission:Add Automount keys";allow (add) groupdn = "ldap:///cn=Add Automount keys,cn=permissions,cn=pbac,$SUFFIX";)', + '(targetfilter = "(objectclass=automount)")(target = "ldap:///automountmapname=*,cn=automount,$SUFFIX")(version 3.0;acl "permission:Add Automount keys";allow (add) groupdn = "ldap:///cn=Add Automount keys,cn=permissions,cn=pbac,$SUFFIX";)', + ], + 'default_privileges': {'Automount Administrators'}, + }, + 'System: Modify Automount Keys': { + 'ipapermright': {'write'}, + 'ipapermdefaultattr': { + 'automountinformation', 'automountkey', 'description', + }, + 'replaces': [ + '(targetattr = "automountkey || automountinformation || description")(targetfilter = "(objectclass=automount)")(target = "ldap:///automountmapname=*,cn=automount,$SUFFIX")(version 3.0;acl "permission:Modify Automount keys";allow (write) groupdn = "ldap:///cn=Modify Automount keys,cn=permissions,cn=pbac,$SUFFIX";)', + ], + 'default_privileges': {'Automount Administrators'}, + }, + 'System: Remove Automount Keys': { + 'ipapermright': {'delete'}, + 'replaces': [ + '(target = "ldap:///automountkey=*,automountmapname=*,cn=automount,$SUFFIX")(version 3.0;acl "permission:Remove Automount keys";allow (delete) groupdn = "ldap:///cn=Remove Automount keys,cn=permissions,cn=pbac,$SUFFIX";)', + '(targetfilter = "(objectclass=automount)")(target = "ldap:///automountmapname=*,cn=automount,$SUFFIX")(version 3.0;acl "permission:Remove Automount keys";allow (delete) groupdn = "ldap:///cn=Remove Automount keys,cn=permissions,cn=pbac,$SUFFIX";)', + ], + 'default_privileges': {'Automount Administrators'}, + }, + } + + num_parents = 2 + label = _('Automount Keys') + label_singular = _('Automount Key') + already_exists_msg = _('The key,info pair must be unique. A key named %(key)s with info %(info)s already exists') + key_already_exists_msg = _('key named %(key)s already exists') + object_not_found_msg = _('The automount key %(key)s with info %(info)s does not exist') + + def get_dn(self, *keys, **kwargs): + # all commands except for create send pk in keys, too + # create cannot due to validation in frontend.py + ldap = self.backend + if len(keys) == self.num_parents: + try: + pkey = kwargs[self.primary_key.name] + except KeyError: + raise ValueError('Not enough keys and pkey not in kwargs') + parent_keys = keys + else: + pkey = keys[-1] + parent_keys = keys[:-1] + + parent_dn = self.api.Object[self.parent_object].get_dn(*parent_keys) + dn = self.backend.make_dn_from_attr( + self.primary_key.name, + pkey, + parent_dn + ) + # If we're doing an add then just return the dn we created, there + # is no need to check for it. + if kwargs.get('add_operation', False): + return dn + # We had an older mechanism where description consisted of + # 'automountkey automountinformation' so we could support multiple + # direct maps. This made showing keys nearly impossible since it + # required automountinfo to show, which if you had you didn't need + # to look at the key. We still support existing entries but now + # only create this type of dn when the key is /- + # + # First we look with the information given, then try to search for + # the right entry. + try: + dn = ldap.get_entry(dn, ['*']).dn + except errors.NotFound: + if kwargs.get('automountinformation', False): + sfilter = '(&(automountkey=%s)(automountinformation=%s))' % \ + (kwargs['automountkey'], kwargs['automountinformation']) + else: + sfilter = '(automountkey=%s)' % kwargs['automountkey'] + basedn = DN(('automountmapname', parent_keys[1]), + ('cn', parent_keys[0]), self.container_dn, + api.env.basedn) + attrs_list = ['*'] + entries = ldap.get_entries( + basedn, ldap.SCOPE_ONELEVEL, sfilter, attrs_list) + if len(entries) > 1: + raise errors.NotFound(reason=_('More than one entry with key %(key)s found, use --info to select specific entry.') % dict(key=pkey)) + dn = entries[0].dn + + return dn + + def handle_not_found(self, *keys): + pkey = keys[-1] + key = pkey.split(self.rdn_separator)[0] + info = self.rdn_separator.join(pkey.split(self.rdn_separator)[1:]) + raise errors.NotFound( + reason=self.object_not_found_msg % { + 'key': key, 'info': info, + } + ) + + def handle_duplicate_entry(self, *keys): + pkey = keys[-1] + key = pkey.split(self.rdn_separator)[0] + info = self.rdn_separator.join(pkey.split(self.rdn_separator)[1:]) + if info: + raise errors.DuplicateEntry( + message=self.already_exists_msg % { + 'key': key, 'info': info, + } + ) + else: + raise errors.DuplicateEntry( + message=self.key_already_exists_msg % { + 'key': key, + } + ) + + def get_pk(self, key, info=None): + if key == DIRECT_MAP_KEY and info: + return self.rdn_separator.join((key,info)) + else: + return key + + def check_key_uniqueness(self, location, map, **keykw): + info = None + key = keykw.get('automountkey') + if key is None: + return + + entries = self.methods.find(location, map, automountkey=key)['result'] + if len(entries) > 0: + if key == DIRECT_MAP_KEY: + info = keykw.get('automountinformation') + entries = self.methods.find(location, map, **keykw)['result'] + if len(entries) > 0: + self.handle_duplicate_entry(location, map, self.get_pk(key, info)) + else: return + self.handle_duplicate_entry(location, map, self.get_pk(key, info)) + + +@register() +class automountkey_add(LDAPCreate): + __doc__ = _('Create a new automount key.') + + msg_summary = _('Added automount key "%(value)s"') + + internal_options = ['description', 'add_operation'] + + def pre_callback(self, ldap, dn, entry_attrs, *keys, **options): + assert isinstance(dn, DN) + options.pop('add_operation', None) + options.pop('description', None) + self.obj.check_key_uniqueness(keys[-2], keys[-1], **options) + return dn + + def get_args(self): + for key in self.obj.get_ancestor_primary_keys(): + yield key + + def execute(self, *keys, **options): + key = options['automountkey'] + info = options.get('automountinformation', None) + options[self.obj.primary_key.name] = self.obj.get_pk(key, info) + options['add_operation'] = True + result = super(automountkey_add, self).execute(*keys, **options) + result['value'] = pkey_to_value(options['automountkey'], options) + return result + + +@register() +class automountmap_add_indirect(LDAPCreate): + __doc__ = _('Create a new indirect mount point.') + + msg_summary = _('Added automount indirect map "%(value)s"') + + takes_options = LDAPCreate.takes_options + ( + Str('key', + cli_name='mount', + label=_('Mount point'), + ), + Str('parentmap?', + cli_name='parentmap', + label=_('Parent map'), + doc=_('Name of parent automount map (default: auto.master).'), + default=u'auto.master', + autofill=True, + ), + ) + + def execute(self, *keys, **options): + parentmap = options.pop('parentmap', None) + key = options.pop('key') + result = self.api.Command['automountmap_add'](*keys, **options) + try: + if parentmap != u'auto.master': + if key.startswith('/'): + raise errors.ValidationError(name='mount', + error=_('mount point is relative to parent map, ' + 'cannot begin with /')) + location = keys[0] + map = keys[1] + options['automountinformation'] = map + + # Ensure the referenced map exists + self.api.Command['automountmap_show'](location, parentmap) + # Add a submount key + self.api.Command['automountkey_add']( + location, parentmap, automountkey=key, + automountinformation='-fstype=autofs ldap:%s' % map) + else: # adding to auto.master + # Ensure auto.master exists + self.api.Command['automountmap_show'](keys[0], parentmap) + self.api.Command['automountkey_add']( + keys[0], u'auto.master', automountkey=key, + automountinformation=keys[1]) + except Exception: + # The key exists, drop the map + self.api.Command['automountmap_del'](*keys) + raise + return result + + +@register() +class automountkey_del(LDAPDelete): + __doc__ = _('Delete an automount key.') + + msg_summary = _('Deleted automount key "%(value)s"') + + takes_options = LDAPDelete.takes_options + ( + IA5Str('automountkey', + cli_name='key', + label=_('Key'), + doc=_('Automount key name.'), + ), + IA5Str('automountinformation?', + cli_name='info', + label=_('Mount information'), + ), + ) + def get_options(self): + for option in super(automountkey_del, self).get_options(): + if option.name == 'continue': + # TODO: hide for now - remove in future major release + yield option.clone(exclude='webui', + flags=['no_option', 'no_output']) + else: + yield option + + def get_args(self): + for key in self.obj.get_ancestor_primary_keys(): + yield key + + def execute(self, *keys, **options): + keys += (self.obj.get_pk(options['automountkey'], + options.get('automountinformation', None)),) + options[self.obj.primary_key.name] = self.obj.get_pk( + options['automountkey'], + options.get('automountinformation', None)) + result = super(automountkey_del, self).execute(*keys, **options) + result['value'] = pkey_to_value([options['automountkey']], options) + return result + + +@register() +class automountkey_mod(LDAPUpdate): + __doc__ = _('Modify an automount key.') + + msg_summary = _('Modified automount key "%(value)s"') + + internal_options = ['newautomountkey'] + + takes_options = LDAPUpdate.takes_options + ( + IA5Str('newautomountinformation?', + cli_name='newinfo', + label=_('New mount information'), + ), + ) + + def get_args(self): + for key in self.obj.get_ancestor_primary_keys(): + yield key + + def pre_callback(self, ldap, dn, entry_attrs, *keys, **options): + assert isinstance(dn, DN) + if 'newautomountkey' in options: + entry_attrs['automountkey'] = options['newautomountkey'] + if 'newautomountinformation' in options: + entry_attrs['automountinformation'] = options['newautomountinformation'] + return dn + + def execute(self, *keys, **options): + ldap = self.api.Backend.ldap2 + key = options['automountkey'] + info = options.get('automountinformation', None) + keys += (self.obj.get_pk(key, info), ) + + # handle RDN changes + if 'rename' in options or 'newautomountinformation' in options: + new_key = options.get('rename', key) + new_info = options.get('newautomountinformation', info) + + if new_key == DIRECT_MAP_KEY and not new_info: + # automountinformation attribute of existing LDAP object needs + # to be retrieved so that RDN can be generated + dn = self.obj.get_dn(*keys, **options) + entry_attrs_ = ldap.get_entry(dn, ['automountinformation']) + new_info = entry_attrs_.get('automountinformation', [])[0] + + # automounkey attribute cannot be overwritten so that get_dn() + # still works right + options['newautomountkey'] = new_key + + new_rdn = self.obj.get_pk(new_key, new_info) + if new_rdn != keys[-1]: + options['rename'] = new_rdn + + result = super(automountkey_mod, self).execute(*keys, **options) + result['value'] = pkey_to_value(options['automountkey'], options) + return result + + +@register() +class automountkey_find(LDAPSearch): + __doc__ = _('Search for an automount key.') + + msg_summary = ngettext( + '%(count)d automount key matched', + '%(count)d automount keys matched', 0 + ) + + +@register() +class automountkey_show(LDAPRetrieve): + __doc__ = _('Display an automount key.') + + takes_options = LDAPRetrieve.takes_options + ( + IA5Str('automountkey', + cli_name='key', + label=_('Key'), + doc=_('Automount key name.'), + ), + IA5Str('automountinformation?', + cli_name='info', + label=_('Mount information'), + ), + ) + + def get_args(self): + for key in self.obj.get_ancestor_primary_keys(): + yield key + + def execute(self, *keys, **options): + keys += (self.obj.get_pk(options['automountkey'], + options.get('automountinformation', None)), ) + options[self.obj.primary_key.name] = self.obj.get_pk( + options['automountkey'], + options.get('automountinformation', None)) + + result = super(automountkey_show, self).execute(*keys, **options) + result['value'] = pkey_to_value(options['automountkey'], options) + return result diff --git a/ipaserver/plugins/baseldap.py b/ipaserver/plugins/baseldap.py new file mode 100644 index 000000000..bbd8ba146 --- /dev/null +++ b/ipaserver/plugins/baseldap.py @@ -0,0 +1,2397 @@ +# Authors: +# Pavel Zuna <pzuna@redhat.com> +# +# Copyright (C) 2009 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. +""" +Base classes for LDAP plugins. +""" + +import re +import time +from copy import deepcopy +import base64 + +import six + +from ipalib import api, crud, errors +from ipalib import Method, Object +from ipalib import Flag, Int, Str +from ipalib.cli import to_cli +from ipalib import output +from ipalib.text import _ +from ipalib.util import json_serialize, validate_hostname +from ipalib.capabilities import client_has_capability +from ipalib.messages import add_message, SearchResultTruncated +from ipapython.dn import DN +from ipapython.version import API_VERSION + +if six.PY3: + unicode = str + +DNA_MAGIC = -1 + +global_output_params = ( + Flag('has_password', + label=_('Password'), + ), + Str('member', + label=_('Failed members'), + ), + Str('member_user?', + label=_('Member users'), + ), + Str('member_group?', + label=_('Member groups'), + ), + Str('memberof_group?', + label=_('Member of groups'), + ), + Str('member_host?', + label=_('Member hosts'), + ), + Str('member_hostgroup?', + label=_('Member host-groups'), + ), + Str('memberof_hostgroup?', + label=_('Member of host-groups'), + ), + Str('memberof_permission?', + label=_('Permissions'), + ), + Str('memberof_privilege?', + label='Privileges', + ), + Str('memberof_role?', + label=_('Roles'), + ), + Str('memberof_sudocmdgroup?', + label=_('Sudo Command Groups'), + ), + Str('member_privilege?', + label='Granted to Privilege', + ), + Str('member_role?', + label=_('Granting privilege to roles'), + ), + Str('member_netgroup?', + label=_('Member netgroups'), + ), + Str('memberof_netgroup?', + label=_('Member of netgroups'), + ), + Str('member_service?', + label=_('Member services'), + ), + Str('member_servicegroup?', + label=_('Member service groups'), + ), + Str('memberof_servicegroup?', + label='Member of service groups', + ), + Str('member_hbacsvc?', + label=_('Member HBAC service'), + ), + Str('member_hbacsvcgroup?', + label=_('Member HBAC service groups'), + ), + Str('memberof_hbacsvcgroup?', + label='Member of HBAC service groups', + ), + Str('member_sudocmd?', + label='Member Sudo commands', + ), + Str('memberof_sudorule?', + label='Member of Sudo rule', + ), + Str('memberof_hbacrule?', + label='Member of HBAC rule', + ), + Str('memberindirect_user?', + label=_('Indirect Member users'), + ), + Str('memberindirect_group?', + label=_('Indirect Member groups'), + ), + Str('memberindirect_host?', + label=_('Indirect Member hosts'), + ), + Str('memberindirect_hostgroup?', + label=_('Indirect Member host-groups'), + ), + Str('memberindirect_role?', + label=_('Indirect Member of roles'), + ), + Str('memberindirect_permission?', + label=_('Indirect Member permissions'), + ), + Str('memberindirect_hbacsvc?', + label=_('Indirect Member HBAC service'), + ), + Str('memberindirect_hbacsvcgrp?', + label=_('Indirect Member HBAC service group'), + ), + Str('memberindirect_netgroup?', + label=_('Indirect Member netgroups'), + ), + Str('memberofindirect_group?', + label='Indirect Member of group', + ), + Str('memberofindirect_netgroup?', + label='Indirect Member of netgroup', + ), + Str('memberofindirect_hostgroup?', + label='Indirect Member of host-group', + ), + Str('memberofindirect_role?', + label='Indirect Member of role', + ), + Str('memberofindirect_sudorule?', + label='Indirect Member of Sudo rule', + ), + Str('memberofindirect_hbacrule?', + label='Indirect Member of HBAC rule', + ), + Str('sourcehost', + label=_('Failed source hosts/hostgroups'), + ), + Str('memberhost', + label=_('Failed hosts/hostgroups'), + ), + Str('memberuser', + label=_('Failed users/groups'), + ), + Str('memberservice', + label=_('Failed service/service groups'), + ), + Str('failed', + label=_('Failed to remove'), + flags=['suppress_empty'], + ), + Str('ipasudorunas', + label=_('Failed RunAs'), + ), + Str('ipasudorunasgroup', + label=_('Failed RunAsGroup'), + ), +) + + +def validate_add_attribute(ugettext, attr): + validate_attribute(ugettext, 'addattr', attr) + +def validate_set_attribute(ugettext, attr): + validate_attribute(ugettext, 'setattr', attr) + +def validate_del_attribute(ugettext, attr): + validate_attribute(ugettext, 'delattr', attr) + +def validate_attribute(ugettext, name, attr): + m = re.match("\s*(.*?)\s*=\s*(.*?)\s*$", attr) + if not m or len(m.groups()) != 2: + raise errors.ValidationError( + name=name, error=_('Invalid format. Should be name=value')) + +def get_effective_rights(ldap, dn, attrs=None): + assert isinstance(dn, DN) + if attrs is None: + attrs = ['*', 'nsaccountlock', 'cospriority'] + rights = ldap.get_effective_rights(dn, attrs) + rdict = {} + if 'attributelevelrights' in rights: + rights = rights['attributelevelrights'] + rights = rights[0].split(', ') + for r in rights: + (k,v) = r.split(':') + if v == 'none': + # the string "none" means "no rights found" + # see https://fedorahosted.org/freeipa/ticket/4359 + v = u'' + rdict[k.strip().lower()] = v + + return rdict + +def entry_from_entry(entry, newentry): + """ + Python is more or less pass-by-value except for immutable objects. So if + you pass in a dict to a function you are free to change members of that + dict but you can't create a new dict in the function and expect to replace + what was passed in. + + In some post-op plugins that is exactly what we want to do, so here is a + clumsy way around the problem. + """ + + # Wipe out the current data + for e in list(entry): + del entry[e] + + # Re-populate it with new wentry + for e in newentry.keys(): + entry[e] = newentry[e] + +def entry_to_dict(entry, **options): + if options.get('raw', False): + result = {} + for attr in entry: + if attr.lower() == 'attributelevelrights': + value = entry[attr] + elif entry.conn.get_attribute_type(attr) is bytes: + value = entry.raw[attr] + else: + value = list(entry.raw[attr]) + for (i, v) in enumerate(value): + try: + value[i] = v.decode('utf-8') + except UnicodeDecodeError: + pass + result[attr] = value + else: + result = dict((k.lower(), v) for (k, v) in entry.items()) + if options.get('all', False): + result['dn'] = entry.dn + return result + +def pkey_to_unicode(key): + if key is None: + key = [] + elif not isinstance(key, (tuple, list)): + key = [key] + key = u','.join(unicode(k) for k in key) + return key + +def pkey_to_value(key, options): + version = options.get('version', API_VERSION) + if client_has_capability(version, 'primary_key_types'): + return key + return pkey_to_unicode(key) + +def wait_for_value(ldap, dn, attr, value): + """ + 389-ds postoperation plugins are executed after the data has been + returned to a client. This means that plugins that add data in a + postop are not included in data returned to the user. + + The downside of waiting is that this increases the time of the + command. + + The updated entry is returned. + """ + # Loop a few times to give the postop-plugin a chance to complete + # Don't sleep for more than 6 seconds. + x = 0 + while x < 20: + # sleep first because the first search, even on a quiet system, + # almost always fails. + time.sleep(.3) + x = x + 1 + + # FIXME: put a try/except around here? I think it is probably better + # to just let the exception filter up to the caller. + entry_attrs = ldap.get_entry(dn, ['*']) + if attr in entry_attrs: + if isinstance(entry_attrs[attr], (list, tuple)): + values = [y.lower() for y in entry_attrs[attr]] + if value.lower() in values: + break + else: + if value.lower() == entry_attrs[attr].lower(): + break + + return entry_attrs + + +def validate_externalhost(ugettext, hostname): + try: + validate_hostname(hostname, check_fqdn=False, allow_underscore=True) + except ValueError as e: + return unicode(e) + + +external_host_param = Str('externalhost*', validate_externalhost, + label=_('External host'), + flags=['no_option'], +) + + +def add_external_pre_callback(membertype, ldap, dn, keys, options): + """ + Pre callback to validate external members. + + This should be called by a command pre callback directly. + + membertype is the type of member + """ + assert isinstance(dn, DN) + + # validate hostname with allowed underscore characters, non-fqdn + # hostnames are allowed + def validate_host(hostname): + validate_hostname(hostname, check_fqdn=False, allow_underscore=True) + + if options.get(membertype): + if membertype == 'host': + validator = validate_host + else: + param = api.Object[membertype].primary_key + + def validator(value): + value = param(value) + param.validate(value) + + for value in options[membertype]: + try: + validator(value) + except errors.ValidationError as e: + raise errors.ValidationError(name=membertype, error=e.error) + except ValueError as e: + raise errors.ValidationError(name=membertype, error=e) + return dn + + +def add_external_post_callback(ldap, dn, entry_attrs, failed, completed, + memberattr, membertype, externalattr, + normalize=True): + """ + Takes the following arguments: + failed - the list of failed entries, these are candidates for possible + external entries to add + completed - the number of successfully added entries so far + memberattr - the attribute name that IPA uses for membership natively + (e.g. memberhost) + membertype - the object type of the member (e.g. host) + externalattr - the attribute name that IPA uses to store the membership + of the entries that are not managed by IPA + (e.g externalhost) + + Returns the number of completed entries so far (the number of entries + handled by IPA incremented by the number of handled external entries) and + dn. + """ + assert isinstance(dn, DN) + + completed_external = 0 + + # Sift through the failures. We assume that these are all + # entries that aren't stored in IPA, aka external entries. + if memberattr in failed and membertype in failed[memberattr]: + entry_attrs_ = ldap.get_entry(dn, [externalattr]) + dn = entry_attrs_.dn + members = entry_attrs.get(memberattr, []) + external_entries = entry_attrs_.get(externalattr, []) + lc_external_entries = set(e.lower() for e in external_entries) + + failed_entries = [] + for entry in failed[memberattr][membertype]: + membername = entry[0].lower() + member_dn = api.Object[membertype].get_dn(membername) + assert isinstance(member_dn, DN) + + if (membername not in lc_external_entries and + member_dn not in members): + # Not an IPA entry, assume external + if normalize: + external_entries.append(membername) + else: + external_entries.append(entry[0]) + lc_external_entries.add(membername) + completed_external += 1 + elif (membername in lc_external_entries and + member_dn not in members): + # Already an external member, reset the error message + msg = unicode(errors.AlreadyGroupMember().message) + newerror = (entry[0], msg) + ind = failed[memberattr][membertype].index(entry) + failed[memberattr][membertype][ind] = newerror + failed_entries.append(membername) + else: + # Really a failure + failed_entries.append(membername) + + if completed_external: + entry_attrs_[externalattr] = external_entries + try: + ldap.update_entry(entry_attrs_) + except errors.EmptyModlist: + pass + failed[memberattr][membertype] = failed_entries + entry_attrs[externalattr] = external_entries + + return (completed + completed_external, dn) + + +def remove_external_post_callback(ldap, dn, entry_attrs, failed, completed, + memberattr, membertype, externalattr): + """ + Takes the following arguments: + failed - the list of failed entries, these are candidates for possible + external entries to remove + completed - the number of successfully removed entries so far + memberattr - the attribute name that IPA uses for membership natively + (e.g. memberhost) + membertype - the object type of the member (e.g. host) + externalattr - the attribute name that IPA uses to store the membership + of the entries that are not managed by IPA + (e.g externalhost) + + Returns the number of completed entries so far (the number of entries + handled by IPA incremented by the number of handled external entries) and + dn. + """ + + assert isinstance(dn, DN) + + # Run through the failures and gracefully remove any member defined + # as an external member. + completed_external = 0 + if memberattr in failed and membertype in failed[memberattr]: + entry_attrs_ = ldap.get_entry(dn, [externalattr]) + dn = entry_attrs_.dn + external_entries = entry_attrs_.get(externalattr, []) + failed_entries = [] + + for entry in failed[memberattr][membertype]: + membername = entry[0].lower() + if membername in external_entries or entry[0] in external_entries: + try: + external_entries.remove(membername) + except ValueError: + external_entries.remove(entry[0]) + completed_external += 1 + else: + msg = unicode(errors.NotGroupMember().message) + newerror = (entry[0], msg) + ind = failed[memberattr][membertype].index(entry) + failed[memberattr][membertype][ind] = newerror + failed_entries.append(membername) + + if completed_external: + entry_attrs_[externalattr] = external_entries + try: + ldap.update_entry(entry_attrs_) + except errors.EmptyModlist: + pass + failed[memberattr][membertype] = failed_entries + entry_attrs[externalattr] = external_entries + + return (completed + completed_external, dn) + + +def host_is_master(ldap, fqdn): + """ + Check to see if this host is a master. + + Raises an exception if a master, otherwise returns nothing. + """ + master_dn = DN(('cn', fqdn), ('cn', 'masters'), ('cn', 'ipa'), ('cn', 'etc'), api.env.basedn) + try: + ldap.get_entry(master_dn, ['objectclass']) + raise errors.ValidationError(name='hostname', error=_('An IPA master host cannot be deleted or disabled')) + except errors.NotFound: + # Good, not a master + return + + +def add_missing_object_class(ldap, objectclass, dn, entry_attrs=None, update=True): + """ + Add object class if missing into entry. Fetches entry if not passed. Updates + the entry by default. + + Returns the entry + """ + + if not entry_attrs: + entry_attrs = ldap.get_entry(dn, ['objectclass']) + if (objectclass.lower() not in (o.lower() for o in entry_attrs['objectclass'])): + entry_attrs['objectclass'].append(objectclass) + if update: + ldap.update_entry(entry_attrs) + return entry_attrs + + +class LDAPObject(Object): + """ + Object representing a LDAP entry. + """ + backend_name = 'ldap2' + + parent_object = '' + container_dn = '' + object_name = _('entry') + object_name_plural = _('entries') + object_class = [] + object_class_config = None + # If an objectclass is possible but not default in an entry. Needed for + # collecting attributes for ACI UI. + possible_objectclasses = [] + limit_object_classes = [] # Only attributes in these are allowed + disallow_object_classes = [] # Disallow attributes in these + permission_filter_objectclasses = None + search_attributes = [] + search_attributes_config = None + default_attributes = [] + search_display_attributes = [] # attributes displayed in LDAPSearch + hidden_attributes = ['objectclass', 'aci'] + # set rdn_attribute only if RDN attribute differs from primary key! + rdn_attribute = '' + uuid_attribute = '' + attribute_members = {} + rdn_is_primary_key = False # Do we need RDN change to do a rename? + password_attributes = [] + # Can bind as this entry (has userPassword or krbPrincipalKey) + bindable = False + relationships = { + # attribute: (label, inclusive param prefix, exclusive param prefix) + 'member': ('Member', '', 'no_'), + 'memberof': ('Member Of', 'in_', 'not_in_'), + 'memberindirect': ( + 'Indirect Member', None, 'no_indirect_' + ), + 'memberofindirect': ( + 'Indirect Member Of', None, 'not_in_indirect_' + ), + } + label = _('Entry') + label_singular = _('Entry') + managed_permissions = {} + + container_not_found_msg = _('container entry (%(container)s) not found') + parent_not_found_msg = _('%(parent)s: %(oname)s not found') + object_not_found_msg = _('%(pkey)s: %(oname)s not found') + already_exists_msg = _('%(oname)s with name "%(pkey)s" already exists') + + def get_dn(self, *keys, **kwargs): + if self.parent_object: + parent_dn = self.api.Object[self.parent_object].get_dn(*keys[:-1]) + else: + parent_dn = DN(self.container_dn, api.env.basedn) + if self.rdn_attribute: + try: + entry_attrs = self.backend.find_entry_by_attr( + self.primary_key.name, keys[-1], self.object_class, [''], + DN(self.container_dn, api.env.basedn) + ) + except errors.NotFound: + pass + else: + return entry_attrs.dn + if self.primary_key and keys[-1] is not None: + return self.backend.make_dn_from_attr( + self.primary_key.name, keys[-1], parent_dn + ) + assert isinstance(parent_dn, DN) + return parent_dn + + def get_dn_if_exists(self, *keys, **kwargs): + dn = self.get_dn(*keys, **kwargs) + entry = self.backend.get_entry(dn, ['']) + return entry.dn + + def get_primary_key_from_dn(self, dn): + assert isinstance(dn, DN) + try: + if self.rdn_attribute: + entry_attrs = self.backend.get_entry( + dn, [self.primary_key.name] + ) + try: + return entry_attrs[self.primary_key.name][0] + except (KeyError, IndexError): + return '' + except errors.NotFound: + pass + try: + return dn[self.primary_key.name] + except KeyError: + # The primary key is not in the DN. + # This shouldn't happen, but we don't want a "show" command to + # crash. + # Just return the entire DN, it's all we have if the entry + # doesn't exist + return unicode(dn) + + def get_ancestor_primary_keys(self): + if self.parent_object: + parent_obj = self.api.Object[self.parent_object] + for key in parent_obj.get_ancestor_primary_keys(): + yield key + if parent_obj.primary_key: + pkey = parent_obj.primary_key + yield pkey.clone_rename( + parent_obj.name + pkey.name, required=True, query=True, + cli_name=parent_obj.name, label=pkey.label + ) + + def has_objectclass(self, classes, objectclass): + oc = [x.lower() for x in classes] + return objectclass.lower() in oc + + def convert_attribute_members(self, entry_attrs, *keys, **options): + if options.get('raw', False): + return + + container_dns = {} + new_attrs = {} + + for attr in self.attribute_members: + try: + value = entry_attrs.raw[attr] + except KeyError: + continue + del entry_attrs[attr] + + for member in value: + memberdn = DN(member) + for ldap_obj_name in self.attribute_members[attr]: + ldap_obj = self.api.Object[ldap_obj_name] + try: + container_dn = container_dns[ldap_obj_name] + except KeyError: + container_dn = DN(ldap_obj.container_dn, api.env.basedn) + container_dns[ldap_obj_name] = container_dn + + if memberdn.endswith(container_dn): + new_value = ldap_obj.get_primary_key_from_dn(memberdn) + new_attr_name = '%s_%s' % (attr, ldap_obj.name) + try: + new_attr = new_attrs[new_attr_name] + except KeyError: + new_attr = entry_attrs.setdefault(new_attr_name, []) + new_attrs[new_attr_name] = new_attr + new_attr.append(new_value) + break + + def get_indirect_members(self, entry_attrs, attrs_list): + if 'memberindirect' in attrs_list: + self.get_memberindirect(entry_attrs) + if 'memberofindirect' in attrs_list: + self.get_memberofindirect(entry_attrs) + + def get_memberindirect(self, group_entry): + """ + Get indirect members + """ + + mo_filter = self.backend.make_filter({'memberof': group_entry.dn}) + filter = self.backend.combine_filters( + ('(member=*)', mo_filter), self.backend.MATCH_ALL) + try: + result = self.backend.get_entries( + self.api.env.basedn, + filter=filter, + attrs_list=['member'], + size_limit=-1, # paged search will get everything anyway + paged_search=True) + except errors.NotFound: + result = [] + + indirect = set() + for entry in result: + indirect.update(entry.raw.get('member', [])) + indirect.difference_update(group_entry.raw.get('member', [])) + + if indirect: + group_entry.raw['memberindirect'] = list(indirect) + + def get_memberofindirect(self, entry): + + dn = entry.dn + filter = self.backend.make_filter( + {'member': dn, 'memberuser': dn, 'memberhost': dn}) + try: + result = self.backend.get_entries( + self.api.env.basedn, + filter=filter, + attrs_list=['']) + except errors.NotFound: + result = [] + + direct = set() + indirect = set(entry.raw.get('memberof', [])) + for group_entry in result: + dn = str(group_entry.dn) + if dn in indirect: + indirect.remove(dn) + direct.add(dn) + + entry.raw['memberof'] = list(direct) + if indirect: + entry.raw['memberofindirect'] = list(indirect) + + def get_password_attributes(self, ldap, dn, entry_attrs): + """ + Search on the entry to determine if it has a password or + keytab set. + + A tuple is used to determine which attribute is set + in entry_attrs. The value is set to True/False whether a + given password type is set. + """ + for (pwattr, attr) in self.password_attributes: + search_filter = '(%s=*)' % pwattr + try: + (entries, truncated) = ldap.find_entries( + search_filter, [pwattr], dn, ldap.SCOPE_BASE + ) + entry_attrs[attr] = True + except errors.NotFound: + entry_attrs[attr] = False + + def handle_not_found(self, *keys): + pkey = '' + if self.primary_key: + pkey = keys[-1] + raise errors.NotFound( + reason=self.object_not_found_msg % { + 'pkey': pkey, 'oname': self.object_name, + } + ) + + def handle_duplicate_entry(self, *keys): + try: + pkey = keys[-1] + except IndexError: + pkey = '' + raise errors.DuplicateEntry( + message=self.already_exists_msg % { + 'pkey': pkey, 'oname': self.object_name, + } + ) + + # list of attributes we want exported to JSON + json_friendly_attributes = ( + 'parent_object', 'container_dn', 'object_name', 'object_name_plural', + 'object_class', 'object_class_config', 'default_attributes', 'label', 'label_singular', + 'hidden_attributes', 'uuid_attribute', 'attribute_members', 'name', + 'takes_params', 'rdn_attribute', 'bindable', 'relationships', + ) + + def __json__(self): + ldap = self.backend + json_dict = dict( + (a, json_serialize(getattr(self, a))) for a in self.json_friendly_attributes + ) + if self.primary_key: + json_dict['primary_key'] = self.primary_key.name + objectclasses = self.object_class + if self.object_class_config: + config = ldap.get_ipa_config() + objectclasses = config.get( + self.object_class_config, objectclasses + ) + objectclasses = objectclasses + self.possible_objectclasses + # Get list of available attributes for this object for use + # in the ACI UI. + attrs = self.api.Backend.ldap2.schema.attribute_types(objectclasses) + attrlist = [] + # Go through the MUST first + for (oid, attr) in attrs[0].items(): + attrlist.append(attr.names[0].lower()) + # And now the MAY + for (oid, attr) in attrs[1].items(): + attrlist.append(attr.names[0].lower()) + json_dict['aciattrs'] = attrlist + attrlist.sort() + json_dict['methods'] = [m for m in self.methods] + json_dict['can_have_permissions'] = bool( + self.permission_filter_objectclasses) + return json_dict + + +# addattr can cause parameters to have more than one value even if not defined +# as multivalue, make sure this isn't the case +def _check_single_value_attrs(params, entry_attrs): + for (a, v) in entry_attrs.items(): + if isinstance(v, (list, tuple)) and len(v) > 1: + if a in params and not params[a].multivalue: + raise errors.OnlyOneValueAllowed(attr=a) + +# setattr or --option='' can cause parameters to be empty that are otherwise +# required, make sure we enforce that. +def _check_empty_attrs(params, entry_attrs): + for (a, v) in entry_attrs.items(): + if v is None or (isinstance(v, six.string_types) and len(v) == 0): + if a in params and params[a].required: + raise errors.RequirementError(name=a) + + +def _check_limit_object_class(attributes, attrs, allow_only): + """ + If the set of objectclasses is limited enforce that only those + are updated in entry_attrs (plus dn) + + allow_only tells us what mode to check in: + + If True then we enforce that the attributes must be in the list of + allowed. + + If False then those attributes are not allowed. + """ + if len(attributes[0]) == 0 and len(attributes[1]) == 0: + return + limitattrs = deepcopy(attrs) + # Go through the MUST first + for (oid, attr) in attributes[0].items(): + if attr.names[0].lower() in limitattrs: + if not allow_only: + raise errors.ObjectclassViolation( + info=_('attribute "%(attribute)s" not allowed') % dict( + attribute=attr.names[0].lower())) + limitattrs.remove(attr.names[0].lower()) + # And now the MAY + for (oid, attr) in attributes[1].items(): + if attr.names[0].lower() in limitattrs: + if not allow_only: + raise errors.ObjectclassViolation( + info=_('attribute "%(attribute)s" not allowed') % dict( + attribute=attr.names[0].lower())) + limitattrs.remove(attr.names[0].lower()) + if len(limitattrs) > 0 and allow_only: + raise errors.ObjectclassViolation( + info=_('attribute "%(attribute)s" not allowed') % dict( + attribute=limitattrs[0])) + + +class BaseLDAPCommand(Method): + """ + Base class for Base LDAP Commands. + """ + setattr_option = Str('setattr*', validate_set_attribute, + cli_name='setattr', + doc=_("""Set an attribute to a name/value pair. Format is attr=value. +For multi-valued attributes, the command replaces the values already present."""), + exclude='webui', + ) + addattr_option = Str('addattr*', validate_add_attribute, + cli_name='addattr', + doc=_("""Add an attribute/value pair. Format is attr=value. The attribute +must be part of the schema."""), + exclude='webui', + ) + delattr_option = Str('delattr*', validate_del_attribute, + cli_name='delattr', + doc=_("""Delete an attribute/value pair. The option will be evaluated +last, after all sets and adds."""), + exclude='webui', + ) + + callback_types = Method.callback_types + ('pre', + 'post', + 'exc') + + def get_summary_default(self, output): + if 'value' in output: + output = dict(output) + output['value'] = pkey_to_unicode(output['value']) + return super(BaseLDAPCommand, self).get_summary_default(output) + + def _convert_2_dict(self, ldap, attrs): + """ + Convert a string in the form of name/value pairs into a dictionary. + + :param attrs: A list of name/value pair strings, in the "name=value" + format. May also be a single string, or None. + """ + + newdict = {} + if attrs is None: + attrs = [] + elif not type(attrs) in (list, tuple): + attrs = [attrs] + for a in attrs: + m = re.match("\s*(.*?)\s*=\s*(.*?)\s*$", a) + attr = str(m.group(1)).lower() + value = m.group(2) + if attr in self.obj.params and attr not in self.params: + # The attribute is managed by IPA, but it didn't get cloned + # to the command. This happens with no_update/no_create attrs. + raise errors.ValidationError( + name=attr, error=_('attribute is not configurable')) + if len(value) == 0: + # None means "delete this attribute" + value = None + + if attr in newdict: + if type(value) in (tuple,): + newdict[attr] += list(value) + else: + newdict[attr].append(value) + else: + if type(value) in (tuple,): + newdict[attr] = list(value) + else: + newdict[attr] = [value] + return newdict + + def process_attr_options(self, entry_attrs, dn, keys, options): + """ + Process all --setattr, --addattr, and --delattr options and add the + resulting value to the list of attributes. --setattr is processed first, + then --addattr and finally --delattr. + + When --setattr is not used then the original LDAP object is looked up + (of course, not when dn is None) and the changes are applied to old + object values. + + Attribute values deleted by --delattr may be deleted from attribute + values set or added by --setattr, --addattr. For example, the following + attributes will result in a NOOP: + + --addattr=attribute=foo --delattr=attribute=foo + + AttrValueNotFound exception may be raised when an attribute value was + not found either by --setattr and --addattr nor in existing LDAP object. + + :param entry_attrs: A list of attributes that will be updated + :param dn: dn of updated LDAP object or None if a new object is created + :param keys: List of command arguments + :param options: List of options + """ + + if all(k not in options for k in ("setattr", "addattr", "delattr")): + return + + ldap = self.obj.backend + + adddict = self._convert_2_dict(ldap, options.get('addattr', [])) + setdict = self._convert_2_dict(ldap, options.get('setattr', [])) + deldict = self._convert_2_dict(ldap, options.get('delattr', [])) + + setattrs = set(setdict) + addattrs = set(adddict) + delattrs = set(deldict) + + if dn is None: + direct_add = addattrs + direct_del = delattrs + needldapattrs = [] + else: + assert isinstance(dn, DN) + direct_add = setattrs & addattrs + direct_del = setattrs & delattrs + needldapattrs = list((addattrs | delattrs) - setattrs) + + for attr, val in setdict.items(): + entry_attrs[attr] = val + + for attr in direct_add: + try: + val = entry_attrs[attr] + except KeyError: + val = [] + else: + if not isinstance(val, (list, tuple)): + val = [val] + elif isinstance(val, tuple): + val = list(val) + val.extend(adddict[attr]) + entry_attrs[attr] = val + + for attr in direct_del: + for delval in deldict[attr]: + try: + entry_attrs[attr].remove(delval) + except ValueError: + raise errors.AttrValueNotFound(attr=attr, value=delval) + + if needldapattrs: + try: + old_entry = self._exc_wrapper(keys, options, ldap.get_entry)( + dn, needldapattrs + ) + except errors.NotFound: + self.obj.handle_not_found(*keys) + + # Provide a nice error message when user tries to delete an + # attribute that does not exist on the entry (and user is not + # adding it) + names = set(n.lower() for n in old_entry) + del_nonexisting = delattrs - (names | setattrs | addattrs) + if del_nonexisting: + raise errors.ValidationError(name=del_nonexisting.pop(), + error=_('No such attribute on this entry')) + + for attr in needldapattrs: + entry_attrs[attr] = old_entry.get(attr, []) + + if attr in addattrs: + entry_attrs[attr].extend(adddict.get(attr, [])) + + for delval in deldict.get(attr, []): + try: + entry_attrs[attr].remove(delval) + except ValueError: + if isinstance(delval, bytes): + # This is a Binary value, base64 encode it + delval = unicode(base64.b64encode(delval)) + raise errors.AttrValueNotFound(attr=attr, value=delval) + + # normalize all values + changedattrs = setattrs | addattrs | delattrs + for attr in changedattrs: + if attr in self.params and self.params[attr].attribute: + # convert single-value params to scalars + param = self.params[attr] + value = entry_attrs[attr] + if not param.multivalue: + if len(value) == 1: + value = value[0] + elif not value: + value = None + else: + raise errors.OnlyOneValueAllowed(attr=attr) + # validate, convert and encode params + try: + value = param(value) + param.validate(value) + except errors.ValidationError as err: + raise errors.ValidationError(name=attr, error=err.error) + except errors.ConversionError as err: + raise errors.ConversionError(name=attr, error=err.error) + if isinstance(value, tuple): + value = list(value) + entry_attrs[attr] = value + else: + # unknown attribute: remove duplicite and invalid values + entry_attrs[attr] = list(set([val for val in entry_attrs[attr] if val])) + if not entry_attrs[attr]: + entry_attrs[attr] = None + elif isinstance(entry_attrs[attr], (tuple, list)) and len(entry_attrs[attr]) == 1: + entry_attrs[attr] = entry_attrs[attr][0] + + @classmethod + def register_pre_callback(cls, callback, first=False): + """Shortcut for register_callback('pre', ...)""" + cls.register_callback('pre', callback, first) + + @classmethod + def register_post_callback(cls, callback, first=False): + """Shortcut for register_callback('post', ...)""" + cls.register_callback('post', callback, first) + + @classmethod + def register_exc_callback(cls, callback, first=False): + """Shortcut for register_callback('exc', ...)""" + cls.register_callback('exc', callback, first) + + def _exc_wrapper(self, keys, options, call_func): + """Function wrapper that automatically calls exception callbacks""" + def wrapped(*call_args, **call_kwargs): + # call call_func first + func = call_func + callbacks = list(self.get_callbacks('exc')) + while True: + try: + return func(*call_args, **call_kwargs) + except errors.ExecutionError as exc: + e = exc + if not callbacks: + raise + # call exc_callback in the next loop + callback = callbacks.pop(0) + def exc_func(*args, **kwargs): + return callback( + self, keys, options, e, call_func, *args, **kwargs) + func = exc_func + return wrapped + + def get_options(self): + for param in super(BaseLDAPCommand, self).get_options(): + yield param + if self.obj.attribute_members: + for o in self.has_output: + if isinstance(o, (output.Entry, output.ListOfEntries)): + yield Flag('no_members', + doc=_('Suppress processing of membership attributes.'), + exclude='webui', + flags={'no_output'}, + ) + break + +class LDAPCreate(BaseLDAPCommand, crud.Create): + """ + Create a new entry in LDAP. + """ + takes_options = (BaseLDAPCommand.setattr_option, BaseLDAPCommand.addattr_option) + + def get_args(self): + for key in self.obj.get_ancestor_primary_keys(): + yield key + for arg in super(LDAPCreate, self).get_args(): + yield arg + + has_output_params = global_output_params + + def execute(self, *keys, **options): + ldap = self.obj.backend + + dn = self.obj.get_dn(*keys, **options) + entry_attrs = ldap.make_entry( + dn, self.args_options_2_entry(*keys, **options)) + + self.process_attr_options(entry_attrs, None, keys, options) + + entry_attrs['objectclass'] = deepcopy(self.obj.object_class) + + if self.obj.object_class_config: + config = ldap.get_ipa_config() + entry_attrs['objectclass'] = config.get( + self.obj.object_class_config, entry_attrs['objectclass'] + ) + + if self.obj.uuid_attribute: + entry_attrs[self.obj.uuid_attribute] = 'autogenerate' + + if self.obj.rdn_attribute: + try: + dn_attr = dn[0].attr + except (IndexError, KeyError): + dn_attr = None + if dn_attr != self.obj.primary_key.name: + self.obj.handle_duplicate_entry(*keys) + entry_attrs.dn = ldap.make_dn( + entry_attrs, self.obj.rdn_attribute, + DN(self.obj.container_dn, api.env.basedn)) + + if options.get('all', False): + attrs_list = ['*'] + self.obj.default_attributes + else: + attrs_list = set(self.obj.default_attributes) + attrs_list.update(entry_attrs.keys()) + if options.get('no_members', False): + attrs_list.difference_update(self.obj.attribute_members) + attrs_list = list(attrs_list) + + for callback in self.get_callbacks('pre'): + entry_attrs.dn = callback( + self, ldap, entry_attrs.dn, entry_attrs, attrs_list, + *keys, **options) + + _check_single_value_attrs(self.params, entry_attrs) + _check_limit_object_class(self.api.Backend.ldap2.schema.attribute_types(self.obj.limit_object_classes), list(entry_attrs), allow_only=True) + _check_limit_object_class(self.api.Backend.ldap2.schema.attribute_types(self.obj.disallow_object_classes), list(entry_attrs), allow_only=False) + + try: + self._exc_wrapper(keys, options, ldap.add_entry)(entry_attrs) + except errors.NotFound: + parent = self.obj.parent_object + if parent: + raise errors.NotFound( + reason=self.obj.parent_not_found_msg % { + 'parent': keys[-2], + 'oname': self.api.Object[parent].object_name, + } + ) + raise errors.NotFound( + reason=self.obj.container_not_found_msg % { + 'container': self.obj.container_dn, + } + ) + except errors.DuplicateEntry: + self.obj.handle_duplicate_entry(*keys) + + try: + if self.obj.rdn_attribute: + # make sure objectclass is either set or None + if self.obj.object_class: + object_class = self.obj.object_class + else: + object_class = None + entry_attrs = self._exc_wrapper(keys, options, ldap.find_entry_by_attr)( + self.obj.primary_key.name, keys[-1], object_class, attrs_list, + DN(self.obj.container_dn, api.env.basedn) + ) + else: + entry_attrs = self._exc_wrapper(keys, options, ldap.get_entry)( + entry_attrs.dn, attrs_list) + except errors.NotFound: + self.obj.handle_not_found(*keys) + + self.obj.get_indirect_members(entry_attrs, attrs_list) + + for callback in self.get_callbacks('post'): + entry_attrs.dn = callback( + self, ldap, entry_attrs.dn, entry_attrs, *keys, **options) + + self.obj.convert_attribute_members(entry_attrs, *keys, **options) + + dn = entry_attrs.dn + entry_attrs = entry_to_dict(entry_attrs, **options) + entry_attrs['dn'] = dn + + if self.obj.primary_key: + pkey = keys[-1] + else: + pkey = None + + return dict(result=entry_attrs, value=pkey_to_value(pkey, options)) + + def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options): + assert isinstance(dn, DN) + return dn + + def post_callback(self, ldap, dn, entry_attrs, *keys, **options): + assert isinstance(dn, DN) + return dn + + def exc_callback(self, keys, options, exc, call_func, *call_args, **call_kwargs): + raise exc + + +class LDAPQuery(BaseLDAPCommand, crud.PKQuery): + """ + Base class for commands that need to retrieve an existing entry. + """ + def get_args(self): + for key in self.obj.get_ancestor_primary_keys(): + yield key + for arg in super(LDAPQuery, self).get_args(): + yield arg + + +class LDAPMultiQuery(LDAPQuery): + """ + Base class for commands that need to retrieve one or more existing entries. + """ + takes_options = ( + Flag('continue', + cli_name='continue', + doc=_('Continuous mode: Don\'t stop on errors.'), + ), + ) + + def get_args(self): + for arg in super(LDAPMultiQuery, self).get_args(): + if self.obj.primary_key and arg.name == self.obj.primary_key.name: + yield arg.clone(multivalue=True) + else: + yield arg + + +class LDAPRetrieve(LDAPQuery): + """ + Retrieve an LDAP entry. + """ + has_output = output.standard_entry + has_output_params = global_output_params + + takes_options = ( + Flag('rights', + label=_('Rights'), + doc=_('Display the access rights of this entry (requires --all). See ipa man page for details.'), + ), + ) + + def execute(self, *keys, **options): + ldap = self.obj.backend + + dn = self.obj.get_dn(*keys, **options) + assert isinstance(dn, DN) + + if options.get('all', False): + attrs_list = ['*'] + self.obj.default_attributes + else: + attrs_list = set(self.obj.default_attributes) + if options.get('no_members', False): + attrs_list.difference_update(self.obj.attribute_members) + attrs_list = list(attrs_list) + + for callback in self.get_callbacks('pre'): + dn = callback(self, ldap, dn, attrs_list, *keys, **options) + assert isinstance(dn, DN) + + try: + entry_attrs = self._exc_wrapper(keys, options, ldap.get_entry)( + dn, attrs_list + ) + except errors.NotFound: + self.obj.handle_not_found(*keys) + + self.obj.get_indirect_members(entry_attrs, attrs_list) + + if options.get('rights', False) and options.get('all', False): + entry_attrs['attributelevelrights'] = get_effective_rights( + ldap, entry_attrs.dn) + + for callback in self.get_callbacks('post'): + entry_attrs.dn = callback( + self, ldap, entry_attrs.dn, entry_attrs, *keys, **options) + + self.obj.convert_attribute_members(entry_attrs, *keys, **options) + + dn = entry_attrs.dn + entry_attrs = entry_to_dict(entry_attrs, **options) + entry_attrs['dn'] = dn + + if self.obj.primary_key: + pkey = keys[-1] + else: + pkey = None + + return dict(result=entry_attrs, value=pkey_to_value(pkey, options)) + + def pre_callback(self, ldap, dn, attrs_list, *keys, **options): + assert isinstance(dn, DN) + return dn + + def post_callback(self, ldap, dn, entry_attrs, *keys, **options): + assert isinstance(dn, DN) + return dn + + def exc_callback(self, keys, options, exc, call_func, *call_args, **call_kwargs): + raise exc + + +class LDAPUpdate(LDAPQuery, crud.Update): + """ + Update an LDAP entry. + """ + + takes_options = ( + BaseLDAPCommand.setattr_option, + BaseLDAPCommand.addattr_option, + BaseLDAPCommand.delattr_option, + Flag('rights', + label=_('Rights'), + doc=_('Display the access rights of this entry (requires --all). See ipa man page for details.'), + ), + ) + + has_output_params = global_output_params + + def _get_rename_option(self): + rdnparam = getattr(self.obj.params, self.obj.primary_key.name) + return rdnparam.clone_rename('rename', + cli_name='rename', required=False, label=_('Rename'), + doc=_('Rename the %(ldap_obj_name)s object') % dict( + ldap_obj_name=self.obj.object_name + ) + ) + + def get_options(self): + for option in super(LDAPUpdate, self).get_options(): + yield option + if self.obj.rdn_is_primary_key: + yield self._get_rename_option() + + def execute(self, *keys, **options): + ldap = self.obj.backend + + if len(options) == 2: # 'all' and 'raw' are always sent + raise errors.EmptyModlist() + + dn = self.obj.get_dn(*keys, **options) + entry_attrs = ldap.make_entry(dn, self.args_options_2_entry(**options)) + + self.process_attr_options(entry_attrs, dn, keys, options) + + if options.get('all', False): + attrs_list = ['*'] + self.obj.default_attributes + else: + attrs_list = set(self.obj.default_attributes) + attrs_list.update(entry_attrs.keys()) + if options.get('no_members', False): + attrs_list.difference_update(self.obj.attribute_members) + attrs_list = list(attrs_list) + + _check_single_value_attrs(self.params, entry_attrs) + _check_empty_attrs(self.obj.params, entry_attrs) + + for callback in self.get_callbacks('pre'): + entry_attrs.dn = callback( + self, ldap, entry_attrs.dn, entry_attrs, attrs_list, + *keys, **options) + + _check_limit_object_class(self.api.Backend.ldap2.schema.attribute_types(self.obj.limit_object_classes), list(entry_attrs), allow_only=True) + _check_limit_object_class(self.api.Backend.ldap2.schema.attribute_types(self.obj.disallow_object_classes), list(entry_attrs), allow_only=False) + + rdnupdate = False + try: + if self.obj.rdn_is_primary_key and 'rename' in options: + if not options['rename']: + raise errors.ValidationError(name='rename', error=u'can\'t be empty') + entry_attrs[self.obj.primary_key.name] = options['rename'] + + if self.obj.rdn_is_primary_key and self.obj.primary_key.name in entry_attrs: + try: + # RDN change + new_dn = DN((self.obj.primary_key.name, + entry_attrs[self.obj.primary_key.name]), + *entry_attrs.dn[1:]) + self._exc_wrapper(keys, options, ldap.move_entry)( + entry_attrs.dn, + new_dn) + + rdnkeys = keys[:-1] + (entry_attrs[self.obj.primary_key.name], ) + entry_attrs.dn = self.obj.get_dn(*rdnkeys) + options['rdnupdate'] = True + rdnupdate = True + except errors.EmptyModlist: + # Attempt to rename to the current name, ignore + pass + finally: + # Delete the primary_key from entry_attrs either way + del entry_attrs[self.obj.primary_key.name] + + # Exception callbacks will need to test for options['rdnupdate'] + # to decide what to do. An EmptyModlist in this context doesn't + # mean an error occurred, just that there were no other updates to + # perform. + update = self._exc_wrapper(keys, options, ldap.get_entry)( + entry_attrs.dn, list(entry_attrs)) + update.update(entry_attrs) + + self._exc_wrapper(keys, options, ldap.update_entry)(update) + except errors.EmptyModlist as e: + if not rdnupdate: + raise e + except errors.NotFound: + self.obj.handle_not_found(*keys) + + try: + entry_attrs = self._exc_wrapper(keys, options, ldap.get_entry)( + entry_attrs.dn, attrs_list) + except errors.NotFound: + raise errors.MidairCollision( + format=_('the entry was deleted while being modified') + ) + + self.obj.get_indirect_members(entry_attrs, attrs_list) + + if options.get('rights', False) and options.get('all', False): + entry_attrs['attributelevelrights'] = get_effective_rights( + ldap, entry_attrs.dn) + + for callback in self.get_callbacks('post'): + entry_attrs.dn = callback( + self, ldap, entry_attrs.dn, entry_attrs, *keys, **options) + + self.obj.convert_attribute_members(entry_attrs, *keys, **options) + + entry_attrs = entry_to_dict(entry_attrs, **options) + + if self.obj.primary_key: + pkey = keys[-1] + else: + pkey = None + + return dict(result=entry_attrs, value=pkey_to_value(pkey, options)) + + def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options): + assert isinstance(dn, DN) + return dn + + def post_callback(self, ldap, dn, entry_attrs, *keys, **options): + assert isinstance(dn, DN) + return dn + + def exc_callback(self, keys, options, exc, call_func, *call_args, **call_kwargs): + raise exc + + +class LDAPDelete(LDAPMultiQuery): + """ + Delete an LDAP entry and all of its direct subentries. + """ + has_output = output.standard_multi_delete + + has_output_params = global_output_params + + subtree_delete = True + + def execute(self, *keys, **options): + ldap = self.obj.backend + + def delete_entry(pkey): + nkeys = keys[:-1] + (pkey, ) + dn = self.obj.get_dn(*nkeys, **options) + assert isinstance(dn, DN) + + for callback in self.get_callbacks('pre'): + dn = callback(self, ldap, dn, *nkeys, **options) + assert isinstance(dn, DN) + + def delete_subtree(base_dn): + assert isinstance(base_dn, DN) + truncated = True + while truncated: + try: + (subentries, truncated) = ldap.find_entries( + None, [''], base_dn, ldap.SCOPE_ONELEVEL + ) + except errors.NotFound: + break + else: + for entry_attrs in subentries: + delete_subtree(entry_attrs.dn) + try: + self._exc_wrapper(nkeys, options, ldap.delete_entry)(base_dn) + except errors.NotFound: + self.obj.handle_not_found(*nkeys) + + try: + self._exc_wrapper(nkeys, options, ldap.delete_entry)(dn) + except errors.NotFound: + self.obj.handle_not_found(*nkeys) + except errors.NotAllowedOnNonLeaf: + if not self.subtree_delete: + raise + # this entry is not a leaf entry, delete all child nodes + delete_subtree(dn) + + for callback in self.get_callbacks('post'): + result = callback(self, ldap, dn, *nkeys, **options) + + return result + + if self.obj.primary_key and isinstance(keys[-1], (list, tuple)): + pkeyiter = keys[-1] + elif keys[-1] is not None: + pkeyiter = [keys[-1]] + else: + pkeyiter = [] + + deleted = [] + failed = [] + for pkey in pkeyiter: + try: + delete_entry(pkey) + except errors.ExecutionError: + if not options.get('continue', False): + raise + failed.append(pkey) + else: + deleted.append(pkey) + deleted = pkey_to_value(deleted, options) + failed = pkey_to_value(failed, options) + + return dict(result=dict(failed=failed), value=deleted) + + def pre_callback(self, ldap, dn, *keys, **options): + assert isinstance(dn, DN) + return dn + + def post_callback(self, ldap, dn, *keys, **options): + assert isinstance(dn, DN) + return True + + def exc_callback(self, keys, options, exc, call_func, *call_args, **call_kwargs): + raise exc + + +class LDAPModMember(LDAPQuery): + """ + Base class for member manipulation. + """ + member_attributes = ['member'] + member_param_doc = _('%s') + member_param_label = _('member %s') + member_count_out = ('%i member processed.', '%i members processed.') + + def get_options(self): + for option in super(LDAPModMember, self).get_options(): + yield option + for attr in self.member_attributes: + for ldap_obj_name in self.obj.attribute_members[attr]: + ldap_obj = self.api.Object[ldap_obj_name] + name = to_cli(ldap_obj_name) + doc = self.member_param_doc % ldap_obj.object_name_plural + label = self.member_param_label % ldap_obj.object_name + yield Str('%s*' % name, cli_name='%ss' % name, doc=doc, + label=label, alwaysask=True) + + def get_member_dns(self, **options): + dns = {} + failed = {} + for attr in self.member_attributes: + dns[attr] = {} + failed[attr] = {} + for ldap_obj_name in self.obj.attribute_members[attr]: + dns[attr][ldap_obj_name] = [] + failed[attr][ldap_obj_name] = [] + names = options.get(to_cli(ldap_obj_name), []) + if not names: + continue + for name in names: + if not name: + continue + ldap_obj = self.api.Object[ldap_obj_name] + try: + dns[attr][ldap_obj_name].append(ldap_obj.get_dn(name)) + except errors.PublicError as e: + failed[attr][ldap_obj_name].append((name, unicode(e))) + return (dns, failed) + + +class LDAPAddMember(LDAPModMember): + """ + Add other LDAP entries to members. + """ + member_param_doc = _('%s to add') + member_count_out = ('%i member added.', '%i members added.') + allow_same = False + + has_output = ( + output.Entry('result'), + output.Output('failed', + type=dict, + doc=_('Members that could not be added'), + ), + output.Output('completed', + type=int, + doc=_('Number of members added'), + ), + ) + + has_output_params = global_output_params + + def execute(self, *keys, **options): + ldap = self.obj.backend + + (member_dns, failed) = self.get_member_dns(**options) + + dn = self.obj.get_dn(*keys, **options) + assert isinstance(dn, DN) + + for callback in self.get_callbacks('pre'): + dn = callback(self, ldap, dn, member_dns, failed, *keys, **options) + assert isinstance(dn, DN) + + completed = 0 + for (attr, objs) in member_dns.items(): + for ldap_obj_name in objs: + for m_dn in member_dns[attr][ldap_obj_name]: + assert isinstance(m_dn, DN) + if not m_dn: + continue + try: + ldap.add_entry_to_group(m_dn, dn, attr, allow_same=self.allow_same) + except errors.PublicError as e: + ldap_obj = self.api.Object[ldap_obj_name] + failed[attr][ldap_obj_name].append(( + ldap_obj.get_primary_key_from_dn(m_dn), + unicode(e),) + ) + else: + completed += 1 + + if options.get('all', False): + attrs_list = ['*'] + self.obj.default_attributes + else: + attrs_list = set(self.obj.default_attributes) + attrs_list.update(member_dns.keys()) + if options.get('no_members', False): + attrs_list.difference_update(self.obj.attribute_members) + attrs_list = list(attrs_list) + + try: + entry_attrs = self._exc_wrapper(keys, options, ldap.get_entry)( + dn, attrs_list + ) + except errors.NotFound: + self.obj.handle_not_found(*keys) + + self.obj.get_indirect_members(entry_attrs, attrs_list) + + for callback in self.get_callbacks('post'): + (completed, entry_attrs.dn) = callback( + self, ldap, completed, failed, entry_attrs.dn, entry_attrs, + *keys, **options) + + self.obj.convert_attribute_members(entry_attrs, *keys, **options) + + dn = entry_attrs.dn + entry_attrs = entry_to_dict(entry_attrs, **options) + entry_attrs['dn'] = dn + + return dict( + completed=completed, + failed=failed, + result=entry_attrs, + ) + + def pre_callback(self, ldap, dn, found, not_found, *keys, **options): + assert isinstance(dn, DN) + return dn + + def post_callback(self, ldap, completed, failed, dn, entry_attrs, *keys, **options): + assert isinstance(dn, DN) + return (completed, dn) + + def exc_callback(self, keys, options, exc, call_func, *call_args, **call_kwargs): + raise exc + + +class LDAPRemoveMember(LDAPModMember): + """ + Remove LDAP entries from members. + """ + member_param_doc = _('%s to remove') + member_count_out = ('%i member removed.', '%i members removed.') + + has_output = ( + output.Entry('result'), + output.Output('failed', + type=dict, + doc=_('Members that could not be removed'), + ), + output.Output('completed', + type=int, + doc=_('Number of members removed'), + ), + ) + + has_output_params = global_output_params + + def execute(self, *keys, **options): + ldap = self.obj.backend + + (member_dns, failed) = self.get_member_dns(**options) + + dn = self.obj.get_dn(*keys, **options) + assert isinstance(dn, DN) + + for callback in self.get_callbacks('pre'): + dn = callback(self, ldap, dn, member_dns, failed, *keys, **options) + assert isinstance(dn, DN) + + completed = 0 + for (attr, objs) in member_dns.items(): + for ldap_obj_name, m_dns in objs.items(): + for m_dn in m_dns: + assert isinstance(m_dn, DN) + if not m_dn: + continue + try: + ldap.remove_entry_from_group(m_dn, dn, attr) + except errors.PublicError as e: + ldap_obj = self.api.Object[ldap_obj_name] + failed[attr][ldap_obj_name].append(( + ldap_obj.get_primary_key_from_dn(m_dn), + unicode(e),) + ) + else: + completed += 1 + + if options.get('all', False): + attrs_list = ['*'] + self.obj.default_attributes + else: + attrs_list = set(self.obj.default_attributes) + attrs_list.update(member_dns.keys()) + if options.get('no_members', False): + attrs_list.difference_update(self.obj.attribute_members) + attrs_list = list(attrs_list) + + # Give memberOf a chance to update entries + time.sleep(.3) + + try: + entry_attrs = self._exc_wrapper(keys, options, ldap.get_entry)( + dn, attrs_list + ) + except errors.NotFound: + self.obj.handle_not_found(*keys) + + self.obj.get_indirect_members(entry_attrs, attrs_list) + + for callback in self.get_callbacks('post'): + (completed, entry_attrs.dn) = callback( + self, ldap, completed, failed, entry_attrs.dn, entry_attrs, + *keys, **options) + + self.obj.convert_attribute_members(entry_attrs, *keys, **options) + + dn = entry_attrs.dn + entry_attrs = entry_to_dict(entry_attrs, **options) + entry_attrs['dn'] = dn + + return dict( + completed=completed, + failed=failed, + result=entry_attrs, + ) + + def pre_callback(self, ldap, dn, found, not_found, *keys, **options): + assert isinstance(dn, DN) + return dn + + def post_callback(self, ldap, completed, failed, dn, entry_attrs, *keys, **options): + assert isinstance(dn, DN) + return (completed, dn) + + def exc_callback(self, keys, options, exc, call_func, *call_args, **call_kwargs): + raise exc + + +def gen_pkey_only_option(cli_name): + return Flag('pkey_only?', + label=_('Primary key only'), + doc=_('Results should contain primary key attribute only ("%s")') \ + % to_cli(cli_name),) + +class LDAPSearch(BaseLDAPCommand, crud.Search): + """ + Retrieve all LDAP entries matching the given criteria. + """ + member_attributes = [] + member_param_incl_doc = _('Search for %(searched_object)s with these %(relationship)s %(ldap_object)s.') + member_param_excl_doc = _('Search for %(searched_object)s without these %(relationship)s %(ldap_object)s.') + + # LDAPSearch sorts all matched records in the end using their primary key + # as a key attribute + # Set the following attribute to False to turn sorting off + sort_result_entries = True + + takes_options = ( + Int('timelimit?', + label=_('Time Limit'), + doc=_('Time limit of search in seconds (0 is unlimited)'), + flags=['no_display'], + minvalue=0, + autofill=False, + ), + Int('sizelimit?', + label=_('Size Limit'), + doc=_('Maximum number of entries returned (0 is unlimited)'), + flags=['no_display'], + minvalue=0, + autofill=False, + ), + ) + + def get_args(self): + for key in self.obj.get_ancestor_primary_keys(): + yield key + for arg in super(LDAPSearch, self).get_args(): + yield arg + + def get_member_options(self, attr): + for ldap_obj_name in self.obj.attribute_members[attr]: + ldap_obj = self.api.Object[ldap_obj_name] + relationship = self.obj.relationships.get( + attr, ['member', '', 'no_'] + ) + doc = self.member_param_incl_doc % dict( + searched_object=self.obj.object_name_plural, + relationship=relationship[0].lower(), + ldap_object=ldap_obj.object_name_plural + ) + name = '%s%s' % (relationship[1], to_cli(ldap_obj_name)) + yield Str( + '%s*' % name, cli_name='%ss' % name, doc=doc, + label=ldap_obj.object_name + ) + doc = self.member_param_excl_doc % dict( + searched_object=self.obj.object_name_plural, + relationship=relationship[0].lower(), + ldap_object=ldap_obj.object_name_plural + ) + name = '%s%s' % (relationship[2], to_cli(ldap_obj_name)) + yield Str( + '%s*' % name, cli_name='%ss' % name, doc=doc, + label=ldap_obj.object_name + ) + + def get_options(self): + for option in super(LDAPSearch, self).get_options(): + if option.name == 'no_members': + # no_members are always true for find commands, do not + # show option in CLI but keep API compatibility + option = option.clone( + default=True, flags=option.flags | {"no_option"}) + yield option + if self.obj.primary_key and \ + 'no_output' not in self.obj.primary_key.flags: + yield gen_pkey_only_option(self.obj.primary_key.cli_name) + for attr in self.member_attributes: + for option in self.get_member_options(attr): + yield option + + def get_member_filter(self, ldap, **options): + filter = '' + for attr in self.member_attributes: + for ldap_obj_name in self.obj.attribute_members[attr]: + ldap_obj = self.api.Object[ldap_obj_name] + relationship = self.obj.relationships.get( + attr, ['member', '', 'no_'] + ) + # Handle positive (MATCH_ALL) and negative (MATCH_NONE) + # searches similarly + param_prefixes = relationship[1:] # e.g. ('in_', 'not_in_') + rules = ldap.MATCH_ALL, ldap.MATCH_NONE + for param_prefix, rule in zip(param_prefixes, rules): + param_name = '%s%s' % (param_prefix, to_cli(ldap_obj_name)) + if options.get(param_name): + dns = [] + for pkey in options[param_name]: + dns.append(ldap_obj.get_dn(pkey)) + flt = ldap.make_filter_from_attr(attr, dns, rule) + filter = ldap.combine_filters( + (filter, flt), ldap.MATCH_ALL + ) + return filter + + has_output_params = global_output_params + + def execute(self, *args, **options): + ldap = self.obj.backend + + index = tuple(self.args).index('criteria') + keys = args[:index] + try: + term = args[index] + except IndexError: + term = None + if self.obj.parent_object: + base_dn = self.api.Object[self.obj.parent_object].get_dn(*keys) + else: + base_dn = DN(self.obj.container_dn, api.env.basedn) + assert isinstance(base_dn, DN) + + search_kw = self.args_options_2_entry(**options) + + if self.obj.search_display_attributes: + defattrs = self.obj.search_display_attributes + else: + defattrs = self.obj.default_attributes + + if options.get('pkey_only', False): + attrs_list = [self.obj.primary_key.name] + elif options.get('all', False): + attrs_list = ['*'] + defattrs + else: + attrs_list = set(defattrs) + attrs_list.update(search_kw.keys()) + if options.get('no_members', False): + attrs_list.difference_update(self.obj.attribute_members) + attrs_list = list(attrs_list) + + if self.obj.search_attributes: + search_attrs = self.obj.search_attributes + else: + search_attrs = self.obj.default_attributes + if self.obj.search_attributes_config: + config = ldap.get_ipa_config() + config_attrs = config.get( + self.obj.search_attributes_config, []) + if len(config_attrs) == 1 and ( + isinstance(config_attrs[0], six.string_types)): + search_attrs = config_attrs[0].split(',') + + search_kw['objectclass'] = self.obj.object_class + attr_filter = ldap.make_filter(search_kw, rules=ldap.MATCH_ALL) + + search_kw = {} + for a in search_attrs: + search_kw[a] = term + term_filter = ldap.make_filter(search_kw, exact=False) + + member_filter = self.get_member_filter(ldap, **options) + + filter = ldap.combine_filters( + (term_filter, attr_filter, member_filter), rules=ldap.MATCH_ALL + ) + + scope = ldap.SCOPE_ONELEVEL + for callback in self.get_callbacks('pre'): + (filter, base_dn, scope) = callback( + self, ldap, filter, attrs_list, base_dn, scope, *args, **options) + assert isinstance(base_dn, DN) + + try: + (entries, truncated) = self._exc_wrapper(args, options, ldap.find_entries)( + filter, attrs_list, base_dn, scope, + time_limit=options.get('timelimit', None), + size_limit=options.get('sizelimit', None) + ) + except errors.EmptyResult: + (entries, truncated) = ([], False) + except errors.NotFound: + self.api.Object[self.obj.parent_object].handle_not_found(*keys) + + for callback in self.get_callbacks('post'): + truncated = callback(self, ldap, entries, truncated, *args, **options) + + if self.sort_result_entries: + if self.obj.primary_key: + def sort_key(x): + return self.obj.primary_key.sort_key( + x[self.obj.primary_key.name][0]) + entries.sort(key=sort_key) + + if not options.get('raw', False): + for e in entries: + self.obj.get_indirect_members(e, attrs_list) + self.obj.convert_attribute_members(e, *args, **options) + + for (i, e) in enumerate(entries): + entries[i] = entry_to_dict(e, **options) + entries[i]['dn'] = e.dn + + result = dict( + result=entries, + count=len(entries), + truncated=bool(truncated), + ) + + try: + ldap.handle_truncated_result(truncated) + except errors.LimitsExceeded as e: + add_message(options['version'], result, SearchResultTruncated( + reason=e)) + + return result + + def pre_callback(self, ldap, filters, attrs_list, base_dn, scope, *args, **options): + assert isinstance(base_dn, DN) + return (filters, base_dn, scope) + + def post_callback(self, ldap, entries, truncated, *args, **options): + return truncated + + def exc_callback(self, args, options, exc, call_func, *call_args, **call_kwargs): + raise exc + + +class LDAPModReverseMember(LDAPQuery): + """ + Base class for reverse member manipulation. + """ + reverse_attributes = ['member'] + reverse_param_doc = _('%s') + reverse_count_out = ('%i member processed.', '%i members processed.') + + has_output_params = global_output_params + + def get_options(self): + for option in super(LDAPModReverseMember, self).get_options(): + yield option + for attr in self.reverse_attributes: + for ldap_obj_name in self.obj.reverse_members[attr]: + ldap_obj = self.api.Object[ldap_obj_name] + name = to_cli(ldap_obj_name) + doc = self.reverse_param_doc % ldap_obj.object_name_plural + yield Str('%s*' % name, cli_name='%ss' % name, doc=doc, + label=ldap_obj.object_name, alwaysask=True) + + +class LDAPAddReverseMember(LDAPModReverseMember): + """ + Add other LDAP entries to members in reverse. + + The call looks like "add A to B" but in fact executes + add B to A to handle reverse membership. + """ + member_param_doc = _('%s to add') + member_count_out = ('%i member added.', '%i members added.') + + show_command = None + member_command = None + reverse_attr = None + member_attr = None + + has_output = ( + output.Entry('result'), + output.Output('failed', + type=dict, + doc=_('Members that could not be added'), + ), + output.Output('completed', + type=int, + doc=_('Number of members added'), + ), + ) + + has_output_params = global_output_params + + def execute(self, *keys, **options): + ldap = self.obj.backend + + # Ensure our target exists + result = self.api.Command[self.show_command](keys[-1])['result'] + dn = result['dn'] + assert isinstance(dn, DN) + + for callback in self.get_callbacks('pre'): + dn = callback(self, ldap, dn, *keys, **options) + assert isinstance(dn, DN) + + if options.get('all', False): + attrs_list = ['*'] + self.obj.default_attributes + else: + attrs_list = set(self.obj.default_attributes) + if options.get('no_members', False): + attrs_list.difference_update(self.obj.attribute_members) + attrs_list = list(attrs_list) + + completed = 0 + failed = {'member': {self.reverse_attr: []}} + for attr in options.get(self.reverse_attr) or []: + try: + options = {'%s' % self.member_attr: keys[-1]} + try: + result = self._exc_wrapper(keys, options, self.api.Command[self.member_command])(attr, **options) + if result['completed'] == 1: + completed = completed + 1 + else: + failed['member'][self.reverse_attr].append((attr, result['failed']['member'][self.member_attr][0][1])) + except errors.NotFound as e: + msg = str(e) + (attr, msg) = msg.split(':', 1) + failed['member'][self.reverse_attr].append((attr, unicode(msg.strip()))) + + except errors.PublicError as e: + failed['member'][self.reverse_attr].append((attr, unicode(e))) + + # Update the member data. + entry_attrs = ldap.get_entry(dn, ['*']) + self.obj.convert_attribute_members(entry_attrs, *keys, **options) + + for callback in self.get_callbacks('post'): + (completed, entry_attrs.dn) = callback( + self, ldap, completed, failed, entry_attrs.dn, entry_attrs, + *keys, **options) + + dn = entry_attrs.dn + entry_attrs = entry_to_dict(entry_attrs, **options) + entry_attrs['dn'] = dn + + return dict( + completed=completed, + failed=failed, + result=entry_attrs, + ) + + def pre_callback(self, ldap, dn, *keys, **options): + assert isinstance(dn, DN) + return dn + + def post_callback(self, ldap, completed, failed, dn, entry_attrs, *keys, **options): + assert isinstance(dn, DN) + return (completed, dn) + + def exc_callback(self, keys, options, exc, call_func, *call_args, **call_kwargs): + raise exc + + +class LDAPRemoveReverseMember(LDAPModReverseMember): + """ + Remove other LDAP entries from members in reverse. + + The call looks like "remove A from B" but in fact executes + remove B from A to handle reverse membership. + """ + member_param_doc = _('%s to remove') + member_count_out = ('%i member removed.', '%i members removed.') + + show_command = None + member_command = None + reverse_attr = None + member_attr = None + + has_output = ( + output.Entry('result'), + output.Output('failed', + type=dict, + doc=_('Members that could not be removed'), + ), + output.Output('completed', + type=int, + doc=_('Number of members removed'), + ), + ) + + has_output_params = global_output_params + + def execute(self, *keys, **options): + ldap = self.obj.backend + + # Ensure our target exists + result = self.api.Command[self.show_command](keys[-1])['result'] + dn = result['dn'] + assert isinstance(dn, DN) + + for callback in self.get_callbacks('pre'): + dn = callback(self, ldap, dn, *keys, **options) + assert isinstance(dn, DN) + + if options.get('all', False): + attrs_list = ['*'] + self.obj.default_attributes + else: + attrs_list = set(self.obj.default_attributes) + if options.get('no_members', False): + attrs_list.difference_update(self.obj.attribute_members) + attrs_list = list(attrs_list) + + completed = 0 + failed = {'member': {self.reverse_attr: []}} + for attr in options.get(self.reverse_attr) or []: + try: + options = {'%s' % self.member_attr: keys[-1]} + try: + result = self._exc_wrapper(keys, options, self.api.Command[self.member_command])(attr, **options) + if result['completed'] == 1: + completed = completed + 1 + else: + failed['member'][self.reverse_attr].append((attr, result['failed']['member'][self.member_attr][0][1])) + except errors.NotFound as e: + msg = str(e) + (attr, msg) = msg.split(':', 1) + failed['member'][self.reverse_attr].append((attr, unicode(msg.strip()))) + + except errors.PublicError as e: + failed['member'][self.reverse_attr].append((attr, unicode(e))) + + # Update the member data. + entry_attrs = ldap.get_entry(dn, ['*']) + self.obj.convert_attribute_members(entry_attrs, *keys, **options) + + for callback in self.get_callbacks('post'): + (completed, entry_attrs.dn) = callback( + self, ldap, completed, failed, entry_attrs.dn, entry_attrs, + *keys, **options) + + dn = entry_attrs.dn + entry_attrs = entry_to_dict(entry_attrs, **options) + entry_attrs['dn'] = dn + + return dict( + completed=completed, + failed=failed, + result=entry_attrs, + ) + + def pre_callback(self, ldap, dn, *keys, **options): + assert isinstance(dn, DN) + return dn + + def post_callback(self, ldap, completed, failed, dn, entry_attrs, *keys, **options): + assert isinstance(dn, DN) + return (completed, dn) + + def exc_callback(self, keys, options, exc, call_func, *call_args, **call_kwargs): + raise exc + + +class LDAPModAttribute(LDAPQuery): + + attribute = None + + has_output = output.standard_entry + + def get_options(self): + for option in super(LDAPModAttribute, self).get_options(): + yield option + + option = self.obj.params[self.attribute] + attribute = 'virtual_attribute' not in option.flags + yield option.clone(attribute=attribute, alwaysask=True) + + def _update_attrs(self, update, entry_attrs): + raise NotImplementedError("%s.update_attrs()", self.__class__.__name__) + + def execute(self, *keys, **options): + ldap = self.obj.backend + + dn = self.obj.get_dn(*keys, **options) + entry_attrs = ldap.make_entry(dn, self.args_options_2_entry(**options)) + + if options.get('all', False): + attrs_list = ['*', self.obj.primary_key.name] + else: + attrs_list = {self.obj.primary_key.name} + attrs_list.update(entry_attrs.keys()) + attrs_list = list(attrs_list) + + for callback in self.get_callbacks('pre'): + entry_attrs.dn = callback( + self, ldap, entry_attrs.dn, entry_attrs, attrs_list, + *keys, **options) + + try: + update = self._exc_wrapper(keys, options, ldap.get_entry)( + entry_attrs.dn, list(entry_attrs)) + self._update_attrs(update, entry_attrs) + + self._exc_wrapper(keys, options, ldap.update_entry)(update) + except errors.NotFound: + self.obj.handle_not_found(*keys) + + try: + entry_attrs = self._exc_wrapper(keys, options, ldap.get_entry)( + entry_attrs.dn, attrs_list) + except errors.NotFound: + raise errors.MidairCollision( + format=_('the entry was deleted while being modified') + ) + + for callback in self.get_callbacks('post'): + entry_attrs.dn = callback( + self, ldap, entry_attrs.dn, entry_attrs, *keys, **options) + + entry_attrs = entry_to_dict(entry_attrs, **options) + + if self.obj.primary_key: + pkey = keys[-1] + else: + pkey = None + + return dict(result=entry_attrs, value=pkey_to_value(pkey, options)) + + def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, + **options): + assert isinstance(dn, DN) + return dn + + def post_callback(self, ldap, dn, entry_attrs, *keys, **options): + assert isinstance(dn, DN) + return dn + + def exc_callback(self, keys, options, exc, call_func, *call_args, + **call_kwargs): + raise exc + + +class LDAPAddAttribute(LDAPModAttribute): + msg_summary = _('added attribute value to entry %(value)') + + def _update_attrs(self, update, entry_attrs): + for name, value in entry_attrs.items(): + old_value = set(update.get(name, [])) + value_to_add = set(value) + + if not old_value.isdisjoint(value_to_add): + raise errors.ExecutionError( + message=_('\'%s\' already contains one or more values' + % name) + ) + + update[name] = list(old_value | value_to_add) + + +class LDAPRemoveAttribute(LDAPModAttribute): + msg_summary = _('removed attribute values from entry %(value)') + + def _update_attrs(self, update, entry_attrs): + for name, value in entry_attrs.items(): + old_value = set(update.get(name, [])) + value_to_remove = set(value) + + if not value_to_remove.issubset(old_value): + raise errors.AttrValueNotFound( + attr=name, value=_("one or more values to remove")) + + update[name] = list(old_value - value_to_remove) diff --git a/ipaserver/plugins/baseuser.py b/ipaserver/plugins/baseuser.py new file mode 100644 index 000000000..bbea403d9 --- /dev/null +++ b/ipaserver/plugins/baseuser.py @@ -0,0 +1,663 @@ +# Authors: +# Thierry Bordaz <tbordaz@redhat.com> +# +# Copyright (C) 2014 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +import string + +import six + +from ipalib import api, errors +from ipalib import Flag, Int, Password, Str, Bool, StrEnum, DateTime, Bytes +from ipalib.plugable import Registry +from .baseldap import ( + DN, LDAPObject, LDAPCreate, LDAPUpdate, LDAPSearch, LDAPDelete, + LDAPRetrieve, LDAPAddMember, LDAPRemoveMember) +from .service import validate_certificate +from ipalib.request import context +from ipalib import _ +from ipapython.ipautil import ipa_generate_password +from ipapython.ipavalidate import Email +from ipalib.util import ( + normalize_sshpubkey, + validate_sshpubkey, + convert_sshpubkey_post, + remove_sshpubkey_from_output_post, + remove_sshpubkey_from_output_list_post, + add_sshpubkey_to_attrs_pre, +) + +if six.PY3: + unicode = str + +__doc__ = _(""" +Baseuser + +This contains common definitions for user/stageuser +""") + +register = Registry() + +NO_UPG_MAGIC = '__no_upg__' + +baseuser_output_params = ( + Flag('has_keytab', + label=_('Kerberos keys available'), + ), + Str('sshpubkeyfp*', + label=_('SSH public key fingerprint'), + ), + ) + +status_baseuser_output_params = ( + Str('server', + label=_('Server'), + ), + Str('krbloginfailedcount', + label=_('Failed logins'), + ), + Str('krblastsuccessfulauth', + label=_('Last successful authentication'), + ), + Str('krblastfailedauth', + label=_('Last failed authentication'), + ), + Str('now', + label=_('Time now'), + ), + ) + +UPG_DEFINITION_DN = DN(('cn', 'UPG Definition'), + ('cn', 'Definitions'), + ('cn', 'Managed Entries'), + ('cn', 'etc'), + api.env.basedn) + +# characters to be used for generating random user passwords +baseuser_pwdchars = string.digits + string.ascii_letters + '_,.@+-=' + +def validate_nsaccountlock(entry_attrs): + if 'nsaccountlock' in entry_attrs: + nsaccountlock = entry_attrs['nsaccountlock'] + if not isinstance(nsaccountlock, (bool, Bool)): + if not isinstance(nsaccountlock, six.string_types): + raise errors.OnlyOneValueAllowed(attr='nsaccountlock') + if nsaccountlock.lower() not in ('true', 'false'): + raise errors.ValidationError(name='nsaccountlock', + error=_('must be TRUE or FALSE')) + +def radius_dn2pk(api, entry_attrs): + cl = entry_attrs.get('ipatokenradiusconfiglink', None) + if cl: + pk = api.Object['radiusproxy'].get_primary_key_from_dn(cl[0]) + entry_attrs['ipatokenradiusconfiglink'] = [pk] + +def convert_nsaccountlock(entry_attrs): + if not 'nsaccountlock' in entry_attrs: + entry_attrs['nsaccountlock'] = False + else: + nsaccountlock = Bool('temp') + entry_attrs['nsaccountlock'] = nsaccountlock.convert(entry_attrs['nsaccountlock'][0]) + +def split_principal(principal): + """ + Split the principal into its components and do some basic validation. + + Automatically append our realm if it wasn't provided. + """ + realm = None + parts = principal.split('@') + user = parts[0].lower() + if len(parts) > 2: + raise errors.MalformedUserPrincipal(principal=principal) + + if len(parts) == 2: + realm = parts[1].upper() + # At some point we'll support multiple realms + if realm != api.env.realm: + raise errors.RealmMismatch() + else: + realm = api.env.realm + + return (user, realm) + +def validate_principal(ugettext, principal): + """ + All the real work is done in split_principal. + """ + (user, realm) = split_principal(principal) + return None + +def normalize_principal(principal): + """ + Ensure that the name in the principal is lower-case. The realm is + upper-case by convention but it isn't required. + + The principal is validated at this point. + """ + (user, realm) = split_principal(principal) + return unicode('%s@%s' % (user, realm)) + + + +def fix_addressbook_permission_bindrule(name, template, is_new, + anonymous_read_aci, + **other_options): + """Fix bind rule type for Read User Addressbook/IPA Attributes permission + + When upgrading from an old IPA that had the global read ACI, + or when installing the first replica with granular read permissions, + we need to keep allowing anonymous access to many user attributes. + This fixup_function changes the bind rule type accordingly. + """ + if is_new and anonymous_read_aci: + template['ipapermbindruletype'] = 'anonymous' + + + +class baseuser(LDAPObject): + """ + baseuser object. + """ + + stage_container_dn = api.env.container_stageuser + active_container_dn = api.env.container_user + delete_container_dn = api.env.container_deleteuser + object_class = ['posixaccount'] + object_class_config = 'ipauserobjectclasses' + possible_objectclasses = [ + 'meporiginentry', 'ipauserauthtypeclass', 'ipauser', + 'ipatokenradiusproxyuser' + ] + disallow_object_classes = ['krbticketpolicyaux'] + permission_filter_objectclasses = ['posixaccount'] + search_attributes_config = 'ipausersearchfields' + default_attributes = [ + 'uid', 'givenname', 'sn', 'homedirectory', 'loginshell', + 'uidnumber', 'gidnumber', 'mail', 'ou', + 'telephonenumber', 'title', 'memberof', 'nsaccountlock', + 'memberofindirect', 'ipauserauthtype', 'userclass', + 'ipatokenradiusconfiglink', 'ipatokenradiususername', + 'krbprincipalexpiration', 'usercertificate;binary', + ] + search_display_attributes = [ + 'uid', 'givenname', 'sn', 'homedirectory', 'loginshell', + 'mail', 'telephonenumber', 'title', 'nsaccountlock', + 'uidnumber', 'gidnumber', 'sshpubkeyfp', + ] + uuid_attribute = 'ipauniqueid' + attribute_members = { + 'manager': ['user'], + 'memberof': ['group', 'netgroup', 'role', 'hbacrule', 'sudorule'], + 'memberofindirect': ['group', 'netgroup', 'role', 'hbacrule', 'sudorule'], + } + rdn_is_primary_key = True + bindable = True + password_attributes = [('userpassword', 'has_password'), + ('krbprincipalkey', 'has_keytab')] + label = _('Users') + label_singular = _('User') + + takes_params = ( + Str('uid', + pattern='^[a-zA-Z0-9_.][a-zA-Z0-9_.-]{0,252}[a-zA-Z0-9_.$-]?$', + pattern_errmsg='may only include letters, numbers, _, -, . and $', + maxlength=255, + cli_name='login', + label=_('User login'), + primary_key=True, + default_from=lambda givenname, sn: givenname[0] + sn, + normalizer=lambda value: value.lower(), + ), + Str('givenname', + cli_name='first', + label=_('First name'), + ), + Str('sn', + cli_name='last', + label=_('Last name'), + ), + Str('cn', + label=_('Full name'), + default_from=lambda givenname, sn: '%s %s' % (givenname, sn), + autofill=True, + ), + Str('displayname?', + label=_('Display name'), + default_from=lambda givenname, sn: '%s %s' % (givenname, sn), + autofill=True, + ), + Str('initials?', + label=_('Initials'), + default_from=lambda givenname, sn: '%c%c' % (givenname[0], sn[0]), + autofill=True, + ), + Str('homedirectory?', + cli_name='homedir', + label=_('Home directory'), + ), + Str('gecos?', + label=_('GECOS'), + default_from=lambda givenname, sn: '%s %s' % (givenname, sn), + autofill=True, + ), + Str('loginshell?', + cli_name='shell', + label=_('Login shell'), + ), + Str('krbprincipalname?', validate_principal, + cli_name='principal', + label=_('Kerberos principal'), + default_from=lambda uid: '%s@%s' % (uid.lower(), api.env.realm), + autofill=True, + flags=['no_update'], + normalizer=lambda value: normalize_principal(value), + ), + DateTime('krbprincipalexpiration?', + cli_name='principal_expiration', + label=_('Kerberos principal expiration'), + ), + Str('mail*', + cli_name='email', + label=_('Email address'), + ), + Password('userpassword?', + cli_name='password', + label=_('Password'), + doc=_('Prompt to set the user password'), + # FIXME: This is temporary till bug is fixed causing updates to + # bomb out via the webUI. + exclude='webui', + ), + Flag('random?', + doc=_('Generate a random user password'), + flags=('no_search', 'virtual_attribute'), + default=False, + ), + Str('randompassword?', + label=_('Random password'), + flags=('no_create', 'no_update', 'no_search', 'virtual_attribute'), + ), + Int('uidnumber?', + cli_name='uid', + label=_('UID'), + doc=_('User ID Number (system will assign one if not provided)'), + minvalue=1, + ), + Int('gidnumber?', + label=_('GID'), + doc=_('Group ID Number'), + minvalue=1, + ), + Str('street?', + cli_name='street', + label=_('Street address'), + ), + Str('l?', + cli_name='city', + label=_('City'), + ), + Str('st?', + cli_name='state', + label=_('State/Province'), + ), + Str('postalcode?', + label=_('ZIP'), + ), + Str('telephonenumber*', + cli_name='phone', + label=_('Telephone Number') + ), + Str('mobile*', + label=_('Mobile Telephone Number') + ), + Str('pager*', + label=_('Pager Number') + ), + Str('facsimiletelephonenumber*', + cli_name='fax', + label=_('Fax Number'), + ), + Str('ou?', + cli_name='orgunit', + label=_('Org. Unit'), + ), + Str('title?', + label=_('Job Title'), + ), + # keep backward compatibility using single value manager option + Str('manager?', + label=_('Manager'), + ), + Str('carlicense*', + label=_('Car License'), + ), + Str('ipasshpubkey*', validate_sshpubkey, + cli_name='sshpubkey', + label=_('SSH public key'), + normalizer=normalize_sshpubkey, + flags=['no_search'], + ), + StrEnum('ipauserauthtype*', + cli_name='user_auth_type', + label=_('User authentication types'), + doc=_('Types of supported user authentication'), + values=(u'password', u'radius', u'otp'), + ), + Str('userclass*', + cli_name='class', + label=_('Class'), + doc=_('User category (semantics placed on this attribute are for ' + 'local interpretation)'), + ), + Str('ipatokenradiusconfiglink?', + cli_name='radius', + label=_('RADIUS proxy configuration'), + ), + Str('ipatokenradiususername?', + cli_name='radius_username', + label=_('RADIUS proxy username'), + ), + Str('departmentnumber*', + label=_('Department Number'), + ), + Str('employeenumber?', + label=_('Employee Number'), + ), + Str('employeetype?', + label=_('Employee Type'), + ), + Str('preferredlanguage?', + label=_('Preferred Language'), + pattern='^(([a-zA-Z]{1,8}(-[a-zA-Z]{1,8})?(;q\=((0(\.[0-9]{0,3})?)|(1(\.0{0,3})?)))?' \ + + '(\s*,\s*[a-zA-Z]{1,8}(-[a-zA-Z]{1,8})?(;q\=((0(\.[0-9]{0,3})?)|(1(\.0{0,3})?)))?)*)|(\*))$', + pattern_errmsg='must match RFC 2068 - 14.4, e.g., "da, en-gb;q=0.8, en;q=0.7"', + ), + Bytes('usercertificate*', validate_certificate, + cli_name='certificate', + label=_('Certificate'), + doc=_('Base-64 encoded user certificate'), + ), + ) + + def normalize_and_validate_email(self, email, config=None): + if not config: + config = self.backend.get_ipa_config() + + # check if default email domain should be added + defaultdomain = config.get('ipadefaultemaildomain', [None])[0] + if email: + norm_email = [] + if not isinstance(email, (list, tuple)): + email = [email] + for m in email: + if isinstance(m, six.string_types): + if '@' not in m and defaultdomain: + m = m + u'@' + defaultdomain + if not Email(m): + raise errors.ValidationError(name='email', error=_('invalid e-mail format: %(email)s') % dict(email=m)) + norm_email.append(m) + else: + if not Email(m): + raise errors.ValidationError(name='email', error=_('invalid e-mail format: %(email)s') % dict(email=m)) + norm_email.append(m) + return norm_email + + return email + + def normalize_manager(self, manager, container): + """ + Given a userid verify the user's existence (in the appropriate containter) and return the dn. + """ + if not manager: + return None + + if not isinstance(manager, list): + manager = [manager] + + try: + container_dn = DN(container, api.env.basedn) + for i, mgr in enumerate(manager): + if isinstance(mgr, DN) and mgr.endswith(container_dn): + continue + entry_attrs = self.backend.find_entry_by_attr( + self.primary_key.name, mgr, self.object_class, [''], + container_dn + ) + manager[i] = entry_attrs.dn + except errors.NotFound: + raise errors.NotFound(reason=_('manager %(manager)s not found') % dict(manager=mgr)) + + return manager + + def _user_status(self, user, container): + assert isinstance(user, DN) + return user.endswith(container) + + def active_user(self, user): + assert isinstance(user, DN) + return self._user_status(user, DN(self.active_container_dn, api.env.basedn)) + + def stage_user(self, user): + assert isinstance(user, DN) + return self._user_status(user, DN(self.stage_container_dn, api.env.basedn)) + + def delete_user(self, user): + assert isinstance(user, DN) + return self._user_status(user, DN(self.delete_container_dn, api.env.basedn)) + + def convert_usercertificate_pre(self, entry_attrs): + if 'usercertificate' in entry_attrs: + entry_attrs['usercertificate;binary'] = entry_attrs.pop( + 'usercertificate') + + def convert_usercertificate_post(self, entry_attrs, **options): + if 'usercertificate;binary' in entry_attrs: + entry_attrs['usercertificate'] = entry_attrs.pop( + 'usercertificate;binary') + + def convert_attribute_members(self, entry_attrs, *keys, **options): + super(baseuser, self).convert_attribute_members( + entry_attrs, *keys, **options) + + if options.get("raw", False): + return + + # due the backward compatibility, managers have to be returned in + # 'manager' attribute instead of 'manager_user' + try: + entry_attrs['failed_manager'] = entry_attrs.pop('manager') + except KeyError: + pass + + try: + entry_attrs['manager'] = entry_attrs.pop('manager_user') + except KeyError: + pass + + +class baseuser_add(LDAPCreate): + """ + Prototype command plugin to be implemented by real plugin + """ + def pre_common_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, + **options): + assert isinstance(dn, DN) + self.obj.convert_usercertificate_pre(entry_attrs) + + def post_common_callback(self, ldap, dn, entry_attrs, *keys, **options): + assert isinstance(dn, DN) + self.obj.convert_usercertificate_post(entry_attrs, **options) + self.obj.get_password_attributes(ldap, dn, entry_attrs) + convert_sshpubkey_post(entry_attrs) + radius_dn2pk(self.api, entry_attrs) + +class baseuser_del(LDAPDelete): + """ + Prototype command plugin to be implemented by real plugin + """ + +class baseuser_mod(LDAPUpdate): + """ + Prototype command plugin to be implemented by real plugin + """ + def check_namelength(self, ldap, **options): + if options.get('rename') is not None: + config = ldap.get_ipa_config() + if 'ipamaxusernamelength' in config: + if len(options['rename']) > int(config.get('ipamaxusernamelength')[0]): + raise errors.ValidationError( + name=self.obj.primary_key.cli_name, + error=_('can be at most %(len)d characters') % dict( + len = int(config.get('ipamaxusernamelength')[0]) + ) + ) + def check_mail(self, entry_attrs): + if 'mail' in entry_attrs: + entry_attrs['mail'] = self.obj.normalize_and_validate_email(entry_attrs['mail']) + + def check_manager(self, entry_attrs, container): + if 'manager' in entry_attrs: + entry_attrs['manager'] = self.obj.normalize_manager(entry_attrs['manager'], container) + + def check_userpassword(self, entry_attrs, **options): + if 'userpassword' not in entry_attrs and options.get('random'): + entry_attrs['userpassword'] = ipa_generate_password(baseuser_pwdchars) + # save the password so it can be displayed in post_callback + setattr(context, 'randompassword', entry_attrs['userpassword']) + + def check_objectclass(self, ldap, dn, entry_attrs): + if ('ipasshpubkey' in entry_attrs or 'ipauserauthtype' in entry_attrs + or 'userclass' in entry_attrs or 'ipatokenradiusconfiglink' in entry_attrs): + if 'objectclass' in entry_attrs: + obj_classes = entry_attrs['objectclass'] + else: + _entry_attrs = ldap.get_entry(dn, ['objectclass']) + obj_classes = entry_attrs['objectclass'] = _entry_attrs['objectclass'] + + # IMPORTANT: compare objectclasses as case insensitive + obj_classes = [o.lower() for o in obj_classes] + + if 'ipasshpubkey' in entry_attrs and 'ipasshuser' not in obj_classes: + entry_attrs['objectclass'].append('ipasshuser') + + if 'ipauserauthtype' in entry_attrs and 'ipauserauthtypeclass' not in obj_classes: + entry_attrs['objectclass'].append('ipauserauthtypeclass') + + if 'userclass' in entry_attrs and 'ipauser' not in obj_classes: + entry_attrs['objectclass'].append('ipauser') + + if 'ipatokenradiusconfiglink' in entry_attrs: + cl = entry_attrs['ipatokenradiusconfiglink'] + if cl: + if 'ipatokenradiusproxyuser' not in obj_classes: + entry_attrs['objectclass'].append('ipatokenradiusproxyuser') + + answer = self.api.Object['radiusproxy'].get_dn_if_exists(cl) + entry_attrs['ipatokenradiusconfiglink'] = answer + + def pre_common_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, + **options): + assert isinstance(dn, DN) + add_sshpubkey_to_attrs_pre(self.context, attrs_list) + + self.check_namelength(ldap, **options) + + self.check_mail(entry_attrs) + + self.check_manager(entry_attrs, self.obj.active_container_dn) + + self.check_userpassword(entry_attrs, **options) + + self.check_objectclass(ldap, dn, entry_attrs) + self.obj.convert_usercertificate_pre(entry_attrs) + + def post_common_callback(self, ldap, dn, entry_attrs, *keys, **options): + assert isinstance(dn, DN) + if options.get('random', False): + try: + entry_attrs['randompassword'] = unicode(getattr(context, 'randompassword')) + except AttributeError: + # if both randompassword and userpassword options were used + pass + convert_nsaccountlock(entry_attrs) + self.obj.get_password_attributes(ldap, dn, entry_attrs) + self.obj.convert_usercertificate_post(entry_attrs, **options) + convert_sshpubkey_post(entry_attrs) + remove_sshpubkey_from_output_post(self.context, entry_attrs) + radius_dn2pk(self.api, entry_attrs) + +class baseuser_find(LDAPSearch): + """ + Prototype command plugin to be implemented by real plugin + """ + def args_options_2_entry(self, *args, **options): + newoptions = {} + self.common_enhance_options(newoptions, **options) + options.update(newoptions) + + return super(baseuser_find, self).args_options_2_entry( + *args, **options) + + def common_enhance_options(self, newoptions, **options): + # assure the manager attr is a dn, not just a bare uid + manager = options.get('manager') + if manager is not None: + newoptions['manager'] = self.obj.normalize_manager(manager, self.obj.active_container_dn) + + # Ensure that the RADIUS config link is a dn, not just the name + cl = 'ipatokenradiusconfiglink' + if cl in options: + newoptions[cl] = self.api.Object['radiusproxy'].get_dn(options[cl]) + + def pre_common_callback(self, ldap, filters, attrs_list, base_dn, scope, + *args, **options): + add_sshpubkey_to_attrs_pre(self.context, attrs_list) + + def post_common_callback(self, ldap, entries, lockout=False, **options): + for attrs in entries: + self.obj.convert_usercertificate_post(attrs, **options) + if (lockout): + attrs['nsaccountlock'] = True + else: + convert_nsaccountlock(attrs) + convert_sshpubkey_post(attrs) + remove_sshpubkey_from_output_list_post(self.context, entries) + +class baseuser_show(LDAPRetrieve): + """ + Prototype command plugin to be implemented by real plugin + """ + def pre_common_callback(self, ldap, dn, attrs_list, *keys, **options): + assert isinstance(dn, DN) + add_sshpubkey_to_attrs_pre(self.context, attrs_list) + + def post_common_callback(self, ldap, dn, entry_attrs, *keys, **options): + assert isinstance(dn, DN) + self.obj.get_password_attributes(ldap, dn, entry_attrs) + self.obj.convert_usercertificate_post(entry_attrs, **options) + convert_sshpubkey_post(entry_attrs) + remove_sshpubkey_from_output_post(self.context, entry_attrs) + radius_dn2pk(self.api, entry_attrs) + + +class baseuser_add_manager(LDAPAddMember): + member_attributes = ['manager'] + + +class baseuser_remove_manager(LDAPRemoveMember): + member_attributes = ['manager'] diff --git a/ipaserver/plugins/batch.py b/ipaserver/plugins/batch.py new file mode 100644 index 000000000..84a650575 --- /dev/null +++ b/ipaserver/plugins/batch.py @@ -0,0 +1,143 @@ +# Authors: +# Adam Young <ayoung@redhat.com> +# Rob Crittenden <rcritten@redhat.com> +# +# Copyright (c) 2010 Red Hat +# See file 'copying' for use and warranty information +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +""" +Plugin to make multiple ipa calls via one remote procedure call + +To run this code in the lite-server + +curl -H "Content-Type:application/json" -H "Accept:application/json" -H "Accept-Language:en" --negotiate -u : --cacert /etc/ipa/ca.crt -d @batch_request.json -X POST http://localhost:8888/ipa/json + +where the contents of the file batch_request.json follow the below example + +{"method":"batch","params":[[ + {"method":"group_find","params":[[],{}]}, + {"method":"user_find","params":[[],{"whoami":"true","all":"true"}]}, + {"method":"user_show","params":[["admin"],{"all":true}]} + ],{}],"id":1} + +The format of the response is nested the same way. At the top you will see + "error": null, + "id": 1, + "result": { + "count": 3, + "results": [ + + +And then a nested response for each IPA command method sent in the request + +""" + +import six + +from ipalib import api, errors +from ipalib import Command +from ipalib.parameters import Str, Any +from ipalib.output import Output +from ipalib.text import _ +from ipalib.request import context +from ipalib.plugable import Registry +from ipapython.version import API_VERSION + +if six.PY3: + unicode = str + +register = Registry() + +@register() +class batch(Command): + NO_CLI = True + + takes_args = ( + Any('methods*', + doc=_('Nested Methods to execute'), + ), + ) + + take_options = ( + Str('version', + cli_name='version', + doc=_('Client version. Used to determine if server will accept request.'), + exclude='webui', + flags=['no_option', 'no_output'], + default=API_VERSION, + autofill=True, + ), + ) + + has_output = ( + Output('count', int, doc=''), + Output('results', (list, tuple), doc='') + ) + + def execute(self, methods=None, **options): + results = [] + for arg in (methods or []): + params = dict() + name = None + try: + if 'method' not in arg: + raise errors.RequirementError(name='method') + if 'params' not in arg: + raise errors.RequirementError(name='params') + name = arg['method'] + if name not in self.Command: + raise errors.CommandError(name=name) + a, kw = arg['params'] + newkw = dict((str(k), v) for k, v in kw.items()) + params = api.Command[name].args_options_2_params(*a, **newkw) + newkw.setdefault('version', options['version']) + + result = api.Command[name](*a, **newkw) + self.info( + '%s: batch: %s(%s): SUCCESS', + getattr(context, 'principal', 'UNKNOWN'), + name, + ', '.join(api.Command[name]._repr_iter(**params)) + ) + result['error']=None + except Exception as e: + if isinstance(e, errors.RequirementError) or \ + isinstance(e, errors.CommandError): + self.info( + '%s: batch: %s', + context.principal, # pylint: disable=no-member + e.__class__.__name__ + ) + else: + self.info( + '%s: batch: %s(%s): %s', + context.principal, name, # pylint: disable=no-member + ', '.join(api.Command[name]._repr_iter(**params)), + e.__class__.__name__ + ) + if isinstance(e, errors.PublicError): + reported_error = e + else: + reported_error = errors.InternalError() + result = dict( + error=reported_error.strerror, + error_code=reported_error.errno, + error_name=unicode(type(reported_error).__name__), + error_kw=reported_error.kw, + ) + results.append(result) + return dict(count=len(results) , results=results) + diff --git a/ipaserver/plugins/caacl.py b/ipaserver/plugins/caacl.py new file mode 100644 index 000000000..60eeb5a33 --- /dev/null +++ b/ipaserver/plugins/caacl.py @@ -0,0 +1,562 @@ +# +# Copyright (C) 2015 FreeIPA Contributors see COPYING for license +# + +import pyhbac + +from ipalib import api, errors, output +from ipalib import Bool, Str, StrEnum +from ipalib.plugable import Registry +from .baseldap import ( + LDAPObject, LDAPSearch, LDAPCreate, LDAPDelete, LDAPQuery, + LDAPUpdate, LDAPRetrieve, LDAPAddMember, LDAPRemoveMember, + global_output_params, pkey_to_value) +from .hbacrule import is_all +from .service import normalize_principal, split_any_principal +from ipalib import _, ngettext +from ipapython.dn import DN + + +__doc__ = _(""" +Manage CA ACL rules. + +This plugin is used to define rules governing which principals are +permitted to have certificates issued using a given certificate +profile. + +PROFILE ID SYNTAX: + +A Profile ID is a string without spaces or punctuation starting with a letter +and followed by a sequence of letters, digits or underscore ("_"). + +EXAMPLES: + + Create a CA ACL "test" that grants all users access to the + "UserCert" profile: + ipa caacl-add test --usercat=all + ipa caacl-add-profile test --certprofiles UserCert + + Display the properties of a named CA ACL: + ipa caacl-show test + + Create a CA ACL to let user "alice" use the "DNP3" profile: + ipa caacl-add-profile alice_dnp3 --certprofiles DNP3 + ipa caacl-add-user alice_dnp3 --user=alice + + Disable a CA ACL: + ipa caacl-disable test + + Remove a CA ACL: + ipa caacl-del test +""") + +register = Registry() + + +def _acl_make_request(principal_type, principal, ca_ref, profile_id): + """Construct HBAC request for the given principal, CA and profile""" + service, name, realm = split_any_principal(principal) + + req = pyhbac.HbacRequest() + req.targethost.name = ca_ref + req.service.name = profile_id + if principal_type == 'user': + req.user.name = name + elif principal_type == 'host': + req.user.name = name + elif principal_type == 'service': + req.user.name = normalize_principal(principal) + groups = [] + if principal_type == 'user': + user_obj = api.Command.user_show(name)['result'] + groups = user_obj.get('memberof_group', []) + groups += user_obj.get('memberofindirect_group', []) + elif principal_type == 'host': + host_obj = api.Command.host_show(name)['result'] + groups = host_obj.get('memberof_hostgroup', []) + groups += host_obj.get('memberofindirect_hostgroup', []) + req.user.groups = sorted(set(groups)) + return req + + +def _acl_make_rule(principal_type, obj): + """Turn CA ACL object into HBAC rule. + + ``principal_type`` + String in {'user', 'host', 'service'} + """ + rule = pyhbac.HbacRule(obj['cn'][0]) + rule.enabled = obj['ipaenabledflag'][0] + rule.srchosts.category = {pyhbac.HBAC_CATEGORY_ALL} + + # add CA(s) + # Hardcoded until caacl plugin arrives + rule.targethosts.category = {pyhbac.HBAC_CATEGORY_ALL} + #if 'ipacacategory' in obj and obj['ipacacategory'][0].lower() == 'all': + # rule.targethosts.category = {pyhbac.HBAC_CATEGORY_ALL} + #else: + # rule.targethosts.names = obj.get('ipacaaclcaref', []) + + # add profiles + if ('ipacertprofilecategory' in obj + and obj['ipacertprofilecategory'][0].lower() == 'all'): + rule.services.category = {pyhbac.HBAC_CATEGORY_ALL} + else: + attr = 'ipamembercertprofile_certprofile' + rule.services.names = obj.get(attr, []) + + # add principals and principal's groups + m = {'user': 'group', 'host': 'hostgroup', 'service': None} + category_attr = '{}category'.format(principal_type) + if category_attr in obj and obj[category_attr][0].lower() == 'all': + rule.users.category = {pyhbac.HBAC_CATEGORY_ALL} + else: + principal_attr = 'member{}_{}'.format(principal_type, principal_type) + rule.users.names = obj.get(principal_attr, []) + if m[principal_type] is not None: + group_attr = 'member{}_{}'.format(principal_type, m[principal_type]) + rule.users.groups = obj.get(group_attr, []) + + return rule + + +def acl_evaluate(principal_type, principal, ca_ref, profile_id): + req = _acl_make_request(principal_type, principal, ca_ref, profile_id) + acls = api.Command.caacl_find(no_members=False)['result'] + rules = [_acl_make_rule(principal_type, obj) for obj in acls] + return req.evaluate(rules) == pyhbac.HBAC_EVAL_ALLOW + + +@register() +class caacl(LDAPObject): + """ + CA ACL object. + """ + container_dn = api.env.container_caacl + object_name = _('CA ACL') + object_name_plural = _('CA ACLs') + object_class = ['ipaassociation', 'ipacaacl'] + permission_filter_objectclasses = ['ipacaacl'] + default_attributes = [ + 'cn', 'description', 'ipaenabledflag', + 'ipacacategory', 'ipamemberca', + 'ipacertprofilecategory', 'ipamembercertprofile', + 'usercategory', 'memberuser', + 'hostcategory', 'memberhost', + 'servicecategory', 'memberservice', + ] + uuid_attribute = 'ipauniqueid' + rdn_attribute = 'ipauniqueid' + attribute_members = { + 'memberuser': ['user', 'group'], + 'memberhost': ['host', 'hostgroup'], + 'memberservice': ['service'], + 'ipamembercertprofile': ['certprofile'], + } + managed_permissions = { + 'System: Read CA ACLs': { + 'replaces_global_anonymous_aci': True, + 'ipapermbindruletype': 'all', + 'ipapermright': {'read', 'search', 'compare'}, + 'ipapermdefaultattr': { + 'cn', 'description', 'ipaenabledflag', + 'ipacacategory', 'ipamemberca', + 'ipacertprofilecategory', 'ipamembercertprofile', + 'usercategory', 'memberuser', + 'hostcategory', 'memberhost', + 'servicecategory', 'memberservice', + 'ipauniqueid', + 'objectclass', 'member', + }, + }, + 'System: Add CA ACL': { + 'ipapermright': {'add'}, + 'replaces': [ + '(target = "ldap:///ipauniqueid=*,cn=caacls,cn=ca,$SUFFIX")(version 3.0;acl "permission:Add CA ACL";allow (add) groupdn = "ldap:///cn=Add CA ACL,cn=permissions,cn=pbac,$SUFFIX";)', + ], + 'default_privileges': {'CA Administrator'}, + }, + 'System: Delete CA ACL': { + 'ipapermright': {'delete'}, + 'replaces': [ + '(target = "ldap:///ipauniqueid=*,cn=caacls,cn=ca,$SUFFIX")(version 3.0;acl "permission:Delete CA ACL";allow (delete) groupdn = "ldap:///cn=Delete CA ACL,cn=permissions,cn=pbac,$SUFFIX";)', + ], + 'default_privileges': {'CA Administrator'}, + }, + 'System: Manage CA ACL Membership': { + 'ipapermright': {'write'}, + 'ipapermdefaultattr': { + 'ipacacategory', 'ipamemberca', + 'ipacertprofilecategory', 'ipamembercertprofile', + 'usercategory', 'memberuser', + 'hostcategory', 'memberhost', + 'servicecategory', 'memberservice' + }, + 'replaces': [ + '(targetattr = "ipamemberca || ipamembercertprofile || memberuser || memberservice || memberhost || ipacacategory || ipacertprofilecategory || usercategory || hostcategory || servicecategory")(target = "ldap:///ipauniqueid=*,cn=caacls,cn=ca,$SUFFIX")(version 3.0;acl "permission:Manage CA ACL membership";allow (write) groupdn = "ldap:///cn=Manage CA ACL membership,cn=permissions,cn=pbac,$SUFFIX";)', + ], + 'default_privileges': {'CA Administrator'}, + }, + 'System: Modify CA ACL': { + 'ipapermright': {'write'}, + 'ipapermdefaultattr': { + 'cn', 'description', 'ipaenabledflag', + }, + 'replaces': [ + '(targetattr = "cn || description || ipaenabledflag")(target = "ldap:///ipauniqueid=*,cn=caacls,cn=ca,$SUFFIX")(version 3.0;acl "permission:Modify CA ACL";allow (write) groupdn = "ldap:///cn=Modify CA ACL,cn=permissions,cn=pbac,$SUFFIX";)', + ], + 'default_privileges': {'CA Administrator'}, + }, + } + + label = _('CA ACLs') + label_singular = _('CA ACL') + + takes_params = ( + Str('cn', + cli_name='name', + label=_('ACL name'), + primary_key=True, + ), + Str('description?', + cli_name='desc', + label=_('Description'), + ), + Bool('ipaenabledflag?', + label=_('Enabled'), + flags=['no_option'], + ), + # Commented until subca plugin arrives + #StrEnum('ipacacategory?', + # cli_name='cacat', + # label=_('CA category'), + # doc=_('CA category the ACL applies to'), + # values=(u'all', ), + #), + StrEnum('ipacertprofilecategory?', + cli_name='profilecat', + label=_('Profile category'), + doc=_('Profile category the ACL applies to'), + values=(u'all', ), + ), + StrEnum('usercategory?', + cli_name='usercat', + label=_('User category'), + doc=_('User category the ACL applies to'), + values=(u'all', ), + ), + StrEnum('hostcategory?', + cli_name='hostcat', + label=_('Host category'), + doc=_('Host category the ACL applies to'), + values=(u'all', ), + ), + StrEnum('servicecategory?', + cli_name='servicecat', + label=_('Service category'), + doc=_('Service category the ACL applies to'), + values=(u'all', ), + ), + # Commented until subca plugin arrives + #Str('ipamemberca_subca?', + # label=_('CAs'), + # flags=['no_create', 'no_update', 'no_search'], + #), + Str('ipamembercertprofile_certprofile?', + label=_('Profiles'), + flags=['no_create', 'no_update', 'no_search'], + ), + Str('memberuser_user?', + label=_('Users'), + flags=['no_create', 'no_update', 'no_search'], + ), + Str('memberuser_group?', + label=_('User Groups'), + flags=['no_create', 'no_update', 'no_search'], + ), + Str('memberhost_host?', + label=_('Hosts'), + flags=['no_create', 'no_update', 'no_search'], + ), + Str('memberhost_hostgroup?', + label=_('Host Groups'), + flags=['no_create', 'no_update', 'no_search'], + ), + Str('memberservice_service?', + label=_('Services'), + flags=['no_create', 'no_update', 'no_search'], + ), + ) + + +@register() +class caacl_add(LDAPCreate): + __doc__ = _('Create a new CA ACL.') + + msg_summary = _('Added CA ACL "%(value)s"') + + def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options): + # CA ACLs are enabled by default + entry_attrs['ipaenabledflag'] = ['TRUE'] + return dn + + +@register() +class caacl_del(LDAPDelete): + __doc__ = _('Delete a CA ACL.') + + msg_summary = _('Deleted CA ACL "%(value)s"') + + def pre_callback(self, ldap, dn, *keys, **options): + if keys[0] == 'hosts_services_caIPAserviceCert': + raise errors.ProtectedEntryError( + label=_("CA ACL"), + key=keys[0], + reason=_("default CA ACL can be only disabled")) + return dn + + +@register() +class caacl_mod(LDAPUpdate): + __doc__ = _('Modify a CA ACL.') + + msg_summary = _('Modified CA ACL "%(value)s"') + + def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options): + assert isinstance(dn, DN) + try: + entry_attrs = ldap.get_entry(dn, attrs_list) + dn = entry_attrs.dn + except errors.NotFound: + self.obj.handle_not_found(*keys) + + # Commented until subca plugin arrives + #if is_all(options, 'ipacacategory') and 'ipamemberca' in entry_attrs: + # raise errors.MutuallyExclusiveError(reason=_( + # "CA category cannot be set to 'all' " + # "while there are allowed CAs")) + if (is_all(options, 'ipacertprofilecategory') + and 'ipamembercertprofile' in entry_attrs): + raise errors.MutuallyExclusiveError(reason=_( + "profile category cannot be set to 'all' " + "while there are allowed profiles")) + if is_all(options, 'usercategory') and 'memberuser' in entry_attrs: + raise errors.MutuallyExclusiveError(reason=_( + "user category cannot be set to 'all' " + "while there are allowed users")) + if is_all(options, 'hostcategory') and 'memberhost' in entry_attrs: + raise errors.MutuallyExclusiveError(reason=_( + "host category cannot be set to 'all' " + "while there are allowed hosts")) + if is_all(options, 'servicecategory') and 'memberservice' in entry_attrs: + raise errors.MutuallyExclusiveError(reason=_( + "service category cannot be set to 'all' " + "while there are allowed services")) + return dn + + +@register() +class caacl_find(LDAPSearch): + __doc__ = _('Search for CA ACLs.') + + msg_summary = ngettext( + '%(count)d CA ACL matched', '%(count)d CA ACLs matched', 0 + ) + + +@register() +class caacl_show(LDAPRetrieve): + __doc__ = _('Display the properties of a CA ACL.') + + +@register() +class caacl_enable(LDAPQuery): + __doc__ = _('Enable a CA ACL.') + + msg_summary = _('Enabled CA ACL "%(value)s"') + has_output = output.standard_value + + def execute(self, cn, **options): + ldap = self.obj.backend + + dn = self.obj.get_dn(cn) + try: + entry_attrs = ldap.get_entry(dn, ['ipaenabledflag']) + except errors.NotFound: + self.obj.handle_not_found(cn) + + entry_attrs['ipaenabledflag'] = ['TRUE'] + + try: + ldap.update_entry(entry_attrs) + except errors.EmptyModlist: + pass + + return dict( + result=True, + value=pkey_to_value(cn, options), + ) + + +@register() +class caacl_disable(LDAPQuery): + __doc__ = _('Disable a CA ACL.') + + msg_summary = _('Disabled CA ACL "%(value)s"') + has_output = output.standard_value + + def execute(self, cn, **options): + ldap = self.obj.backend + + dn = self.obj.get_dn(cn) + try: + entry_attrs = ldap.get_entry(dn, ['ipaenabledflag']) + except errors.NotFound: + self.obj.handle_not_found(cn) + + entry_attrs['ipaenabledflag'] = ['FALSE'] + + try: + ldap.update_entry(entry_attrs) + except errors.EmptyModlist: + pass + + return dict( + result=True, + value=pkey_to_value(cn, options), + ) + + +@register() +class caacl_add_user(LDAPAddMember): + __doc__ = _('Add users and groups to a CA ACL.') + + member_attributes = ['memberuser'] + member_count_out = ( + _('%i user or group added.'), + _('%i users or groups added.')) + + def pre_callback(self, ldap, dn, found, not_found, *keys, **options): + assert isinstance(dn, DN) + try: + entry_attrs = ldap.get_entry(dn, self.obj.default_attributes) + dn = entry_attrs.dn + except errors.NotFound: + self.obj.handle_not_found(*keys) + if is_all(entry_attrs, 'usercategory'): + raise errors.MutuallyExclusiveError( + reason=_("users cannot be added when user category='all'")) + return dn + + +@register() +class caacl_remove_user(LDAPRemoveMember): + __doc__ = _('Remove users and groups from a CA ACL.') + + member_attributes = ['memberuser'] + member_count_out = ( + _('%i user or group removed.'), + _('%i users or groups removed.')) + + +@register() +class caacl_add_host(LDAPAddMember): + __doc__ = _('Add target hosts and hostgroups to a CA ACL.') + + member_attributes = ['memberhost'] + member_count_out = ( + _('%i host or hostgroup added.'), + _('%i hosts or hostgroups added.')) + + def pre_callback(self, ldap, dn, found, not_found, *keys, **options): + assert isinstance(dn, DN) + try: + entry_attrs = ldap.get_entry(dn, self.obj.default_attributes) + dn = entry_attrs.dn + except errors.NotFound: + self.obj.handle_not_found(*keys) + if is_all(entry_attrs, 'hostcategory'): + raise errors.MutuallyExclusiveError( + reason=_("hosts cannot be added when host category='all'")) + return dn + + +@register() +class caacl_remove_host(LDAPRemoveMember): + __doc__ = _('Remove target hosts and hostgroups from a CA ACL.') + + member_attributes = ['memberhost'] + member_count_out = ( + _('%i host or hostgroup removed.'), + _('%i hosts or hostgroups removed.')) + + +@register() +class caacl_add_service(LDAPAddMember): + __doc__ = _('Add services to a CA ACL.') + + member_attributes = ['memberservice'] + member_count_out = (_('%i service added.'), _('%i services added.')) + + def pre_callback(self, ldap, dn, found, not_found, *keys, **options): + assert isinstance(dn, DN) + try: + entry_attrs = ldap.get_entry(dn, self.obj.default_attributes) + dn = entry_attrs.dn + except errors.NotFound: + self.obj.handle_not_found(*keys) + if is_all(entry_attrs, 'servicecategory'): + raise errors.MutuallyExclusiveError(reason=_( + "services cannot be added when service category='all'")) + return dn + + +@register() +class caacl_remove_service(LDAPRemoveMember): + __doc__ = _('Remove services from a CA ACL.') + + member_attributes = ['memberservice'] + member_count_out = (_('%i service removed.'), _('%i services removed.')) + + +caacl_output_params = global_output_params + ( + Str('ipamembercertprofile', + label=_('Failed profiles'), + ), + # Commented until caacl plugin arrives + #Str('ipamemberca', + # label=_('Failed CAs'), + #), +) + + +@register() +class caacl_add_profile(LDAPAddMember): + __doc__ = _('Add profiles to a CA ACL.') + + has_output_params = caacl_output_params + + member_attributes = ['ipamembercertprofile'] + member_count_out = (_('%i profile added.'), _('%i profiles added.')) + + def pre_callback(self, ldap, dn, found, not_found, *keys, **options): + assert isinstance(dn, DN) + try: + entry_attrs = ldap.get_entry(dn, self.obj.default_attributes) + dn = entry_attrs.dn + except errors.NotFound: + self.obj.handle_not_found(*keys) + if is_all(entry_attrs, 'ipacertprofilecategory'): + raise errors.MutuallyExclusiveError(reason=_( + "profiles cannot be added when profile category='all'")) + return dn + + +@register() +class caacl_remove_profile(LDAPRemoveMember): + __doc__ = _('Remove profiles from a CA ACL.') + + has_output_params = caacl_output_params + + member_attributes = ['ipamembercertprofile'] + member_count_out = (_('%i profile removed.'), _('%i profiles removed.')) diff --git a/ipaserver/plugins/cert.py b/ipaserver/plugins/cert.py new file mode 100644 index 000000000..cbb5382fb --- /dev/null +++ b/ipaserver/plugins/cert.py @@ -0,0 +1,835 @@ +# Authors: +# Andrew Wnuk <awnuk@redhat.com> +# Jason Gerard DeRose <jderose@redhat.com> +# John Dennis <jdennis@redhat.com> +# +# Copyright (C) 2009 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +import os +import time +import binascii + +from ipalib import Command, Str, Int, Flag +from ipalib import api +from ipalib import errors +from ipalib import pkcs10 +from ipalib import x509 +from ipalib import ngettext +from ipalib.plugable import Registry +from .virtual import VirtualCommand +from .baseldap import pkey_to_value +from .service import split_any_principal +from .certprofile import validate_profile_id +from .caacl import acl_evaluate +from ipalib.text import _ +from ipalib.request import context +from ipalib import output +from .service import validate_principal +from ipapython.dn import DN + +import six +import nss.nss as nss +from nss.error import NSPRError +from pyasn1.error import PyAsn1Error + +if six.PY3: + unicode = str + +__doc__ = _(""" +IPA certificate operations + +Implements a set of commands for managing server SSL certificates. + +Certificate requests exist in the form of a Certificate Signing Request (CSR) +in PEM format. + +The dogtag CA uses just the CN value of the CSR and forces the rest of the +subject to values configured in the server. + +A certificate is stored with a service principal and a service principal +needs a host. + +In order to request a certificate: + +* The host must exist +* The service must exist (or you use the --add option to automatically add it) + +SEARCHING: + +Certificates may be searched on by certificate subject, serial number, +revocation reason, validity dates and the issued date. + +When searching on dates the _from date does a >= search and the _to date +does a <= search. When combined these are done as an AND. + +Dates are treated as GMT to match the dates in the certificates. + +The date format is YYYY-mm-dd. + +EXAMPLES: + + Request a new certificate and add the principal: + ipa cert-request --add --principal=HTTP/lion.example.com example.csr + + Retrieve an existing certificate: + ipa cert-show 1032 + + Revoke a certificate (see RFC 5280 for reason details): + ipa cert-revoke --revocation-reason=6 1032 + + Remove a certificate from revocation hold status: + ipa cert-remove-hold 1032 + + Check the status of a signing request: + ipa cert-status 10 + + Search for certificates by hostname: + ipa cert-find --subject=ipaserver.example.com + + Search for revoked certificates by reason: + ipa cert-find --revocation-reason=5 + + Search for certificates based on issuance date + ipa cert-find --issuedon-from=2013-02-01 --issuedon-to=2013-02-07 + +IPA currently immediately issues (or declines) all certificate requests so +the status of a request is not normally useful. This is for future use +or the case where a CA does not immediately issue a certificate. + +The following revocation reasons are supported: + + * 0 - unspecified + * 1 - keyCompromise + * 2 - cACompromise + * 3 - affiliationChanged + * 4 - superseded + * 5 - cessationOfOperation + * 6 - certificateHold + * 8 - removeFromCRL + * 9 - privilegeWithdrawn + * 10 - aACompromise + +Note that reason code 7 is not used. See RFC 5280 for more details: + +http://www.ietf.org/rfc/rfc5280.txt + +""") + +USER, HOST, SERVICE = range(3) + +register = Registry() + +def validate_pkidate(ugettext, value): + """ + A date in the format of %Y-%m-%d + """ + try: + ts = time.strptime(value, '%Y-%m-%d') + except ValueError as e: + return str(e) + + return None + +def validate_csr(ugettext, csr): + """ + Ensure the CSR is base64-encoded and can be decoded by our PKCS#10 + parser. + """ + if api.env.context == 'cli': + # If we are passed in a pointer to a valid file on the client side + # escape and let the load_files() handle things + if csr and os.path.exists(csr): + return + try: + request = pkcs10.load_certificate_request(csr) + except (TypeError, binascii.Error) as e: + raise errors.Base64DecodeError(reason=str(e)) + except Exception as e: + raise errors.CertificateOperationError(error=_('Failure decoding Certificate Signing Request: %s') % e) + +def normalize_csr(csr): + """ + Strip any leading and trailing cruft around the BEGIN/END block + """ + end_len = 37 + s = csr.find('-----BEGIN NEW CERTIFICATE REQUEST-----') + if s == -1: + s = csr.find('-----BEGIN CERTIFICATE REQUEST-----') + e = csr.find('-----END NEW CERTIFICATE REQUEST-----') + if e == -1: + e = csr.find('-----END CERTIFICATE REQUEST-----') + if e != -1: + end_len = 33 + + if s > -1 and e > -1: + # We're normalizing here, not validating + csr = csr[s:e+end_len] + + return csr + +def _convert_serial_number(num): + """ + Convert a SN given in decimal or hexadecimal. + Returns the number or None if conversion fails. + """ + # plain decimal or hexa with radix prefix + try: + num = int(num, 0) + except ValueError: + try: + # hexa without prefix + num = int(num, 16) + except ValueError: + num = None + + return num + +def validate_serial_number(ugettext, num): + if _convert_serial_number(num) == None: + return u"Decimal or hexadecimal number is required for serial number" + return None + +def normalize_serial_number(num): + # It's been already validated + return unicode(_convert_serial_number(num)) + +def get_host_from_principal(principal): + """ + Given a principal with or without a realm return the + host portion. + """ + validate_principal(None, principal) + realm = principal.find('@') + slash = principal.find('/') + if realm == -1: + realm = len(principal) + hostname = principal[slash+1:realm] + + return hostname + +def ca_enabled_check(): + if not api.Command.ca_is_enabled()['result']: + raise errors.NotFound(reason=_('CA is not configured')) + +def caacl_check(principal_type, principal_string, ca, profile_id): + principal_type_map = {USER: 'user', HOST: 'host', SERVICE: 'service'} + if not acl_evaluate( + principal_type_map[principal_type], + principal_string, ca, profile_id): + raise errors.ACIError(info=_( + "Principal '%(principal)s' " + "is not permitted to use CA '%(ca)s' " + "with profile '%(profile_id)s' for certificate issuance." + ) % dict( + principal=principal_string, + ca=ca or '.', + profile_id=profile_id + ) + ) + +@register() +class cert_request(VirtualCommand): + __doc__ = _('Submit a certificate signing request.') + + takes_args = ( + Str( + 'csr', validate_csr, + label=_('CSR'), + cli_name='csr_file', + normalizer=normalize_csr, + noextrawhitespace=False, + ), + ) + operation="request certificate" + + takes_options = ( + Str('principal', + label=_('Principal'), + doc=_('Principal for this certificate (e.g. HTTP/test.example.com)'), + ), + Str('request_type', + default=u'pkcs10', + autofill=True, + ), + Flag('add', + doc=_("automatically add the principal if it doesn't exist"), + default=False, + autofill=True + ), + Str('profile_id?', validate_profile_id, + label=_("Profile ID"), + doc=_("Certificate Profile to use"), + ) + ) + + has_output_params = ( + Str('certificate', + label=_('Certificate'), + ), + Str('subject', + label=_('Subject'), + ), + Str('issuer', + label=_('Issuer'), + ), + Str('valid_not_before', + label=_('Not Before'), + ), + Str('valid_not_after', + label=_('Not After'), + ), + Str('md5_fingerprint', + label=_('Fingerprint (MD5)'), + ), + Str('sha1_fingerprint', + label=_('Fingerprint (SHA1)'), + ), + Str('serial_number', + label=_('Serial number'), + ), + Str('serial_number_hex', + label=_('Serial number (hex)'), + ), + ) + + has_output = ( + output.Output('result', + type=dict, + doc=_('Dictionary mapping variable name to value'), + ), + ) + + def execute(self, csr, **kw): + ca_enabled_check() + + ldap = self.api.Backend.ldap2 + add = kw.get('add') + request_type = kw.get('request_type') + profile_id = kw.get('profile_id', self.Backend.ra.DEFAULT_PROFILE) + ca = '.' # top-level CA hardcoded until subca plugin implemented + + """ + Access control is partially handled by the ACI titled + 'Hosts can modify service userCertificate'. This is for the case + where a machine binds using a host/ prinicpal. It can only do the + request if the target hostname is in the managedBy attribute which + is managed using the add/del member commands. + + Binding with a user principal one needs to be in the request_certs + taskgroup (directly or indirectly via role membership). + """ + + principal_string = kw.get('principal') + principal = split_any_principal(principal_string) + servicename, principal_name, realm = principal + if servicename is None: + principal_type = USER + elif servicename == 'host': + principal_type = HOST + else: + principal_type = SERVICE + + bind_principal = split_any_principal(getattr(context, 'principal')) + bind_service, bind_name, bind_realm = bind_principal + + if bind_service is None: + bind_principal_type = USER + elif bind_service == 'host': + bind_principal_type = HOST + else: + bind_principal_type = SERVICE + + if bind_principal != principal and bind_principal_type != HOST: + # Can the bound principal request certs for another principal? + self.check_access() + + try: + self.check_access("request certificate ignore caacl") + bypass_caacl = True + except errors.ACIError: + bypass_caacl = False + + if not bypass_caacl: + caacl_check(principal_type, principal_string, ca, profile_id) + + try: + subject = pkcs10.get_subject(csr) + extensions = pkcs10.get_extensions(csr) + subjectaltname = pkcs10.get_subjectaltname(csr) or () + except (NSPRError, PyAsn1Error, ValueError) as e: + raise errors.CertificateOperationError( + error=_("Failure decoding Certificate Signing Request: %s") % e) + + # self-service and host principals may bypass SAN permission check + if bind_principal != principal and bind_principal_type != HOST: + if '2.5.29.17' in extensions: + self.check_access('request certificate with subjectaltname') + + dn = None + principal_obj = None + # See if the service exists and punt if it doesn't and we aren't + # going to add it + try: + if principal_type == SERVICE: + principal_obj = api.Command['service_show'](principal_string, all=True) + elif principal_type == HOST: + principal_obj = api.Command['host_show'](principal_name, all=True) + elif principal_type == USER: + principal_obj = api.Command['user_show'](principal_name, all=True) + except errors.NotFound as e: + if principal_type == SERVICE and add: + principal_obj = api.Command['service_add'](principal_string, force=True) + else: + raise errors.NotFound( + reason=_("The principal for this request doesn't exist.")) + principal_obj = principal_obj['result'] + dn = principal_obj['dn'] + + # Ensure that the DN in the CSR matches the principal + cn = subject.common_name #pylint: disable=E1101 + if not cn: + raise errors.ValidationError(name='csr', + error=_("No Common Name was found in subject of request.")) + + if principal_type in (SERVICE, HOST): + if cn.lower() != principal_name.lower(): + raise errors.ACIError( + info=_("hostname in subject of request '%(cn)s' " + "does not match principal hostname '%(hostname)s'") + % dict(cn=cn, hostname=principal_name)) + elif principal_type == USER: + # check user name + if cn != principal_name: + raise errors.ValidationError( + name='csr', + error=_("DN commonName does not match user's login") + ) + + # check email address + mail = subject.email_address #pylint: disable=E1101 + if mail is not None and mail not in principal_obj.get('mail', []): + raise errors.ValidationError( + name='csr', + error=_( + "DN emailAddress does not match " + "any of user's email addresses") + ) + + # We got this far so the principal entry exists, can we write it? + if not ldap.can_write(dn, "usercertificate"): + raise errors.ACIError(info=_("Insufficient 'write' privilege " + "to the 'userCertificate' attribute of entry '%s'.") % dn) + + # Validate the subject alt name, if any + for name_type, name in subjectaltname: + if name_type == pkcs10.SAN_DNSNAME: + name = unicode(name) + alt_principal_obj = None + alt_principal_string = None + try: + if principal_type == HOST: + alt_principal_string = 'host/%s@%s' % (name, realm) + alt_principal_obj = api.Command['host_show'](name, all=True) + elif principal_type == SERVICE: + alt_principal_string = '%s/%s@%s' % (servicename, name, realm) + alt_principal_obj = api.Command['service_show']( + alt_principal_string, all=True) + elif principal_type == USER: + raise errors.ValidationError( + name='csr', + error=_("subject alt name type %s is forbidden " + "for user principals") % name_type + ) + except errors.NotFound: + # We don't want to issue any certificates referencing + # machines we don't know about. Nothing is stored in this + # host record related to this certificate. + raise errors.NotFound(reason=_('The service principal for ' + 'subject alt name %s in certificate request does not ' + 'exist') % name) + if alt_principal_obj is not None: + altdn = alt_principal_obj['result']['dn'] + if not ldap.can_write(altdn, "usercertificate"): + raise errors.ACIError(info=_( + "Insufficient privilege to create a certificate " + "with subject alt name '%s'.") % name) + if alt_principal_string is not None and not bypass_caacl: + caacl_check( + principal_type, alt_principal_string, ca, profile_id) + elif name_type in (pkcs10.SAN_OTHERNAME_KRB5PRINCIPALNAME, + pkcs10.SAN_OTHERNAME_UPN): + if split_any_principal(name) != principal: + raise errors.ACIError( + info=_("Principal '%s' in subject alt name does not " + "match requested principal") % name) + elif name_type == pkcs10.SAN_RFC822NAME: + if principal_type == USER: + if name not in principal_obj.get('mail', []): + raise errors.ValidationError( + name='csr', + error=_( + "RFC822Name does not match " + "any of user's email addresses") + ) + else: + raise errors.ValidationError( + name='csr', + error=_("subject alt name type %s is forbidden " + "for non-user principals") % name_type + ) + else: + raise errors.ACIError( + info=_("Subject alt name type %s is forbidden") % + name_type) + + # Request the certificate + result = self.Backend.ra.request_certificate( + csr, profile_id, request_type=request_type) + cert = x509.load_certificate(result['certificate']) + result['issuer'] = unicode(cert.issuer) + result['valid_not_before'] = unicode(cert.valid_not_before_str) + result['valid_not_after'] = unicode(cert.valid_not_after_str) + result['md5_fingerprint'] = unicode(nss.data_to_hex(nss.md5_digest(cert.der_data), 64)[0]) + result['sha1_fingerprint'] = unicode(nss.data_to_hex(nss.sha1_digest(cert.der_data), 64)[0]) + + # Success? Then add it to the principal's entry + # (unless the profile tells us not to) + profile = api.Command['certprofile_show'](profile_id) + store = profile['result']['ipacertprofilestoreissued'][0] == 'TRUE' + if store and 'certificate' in result: + cert = str(result.get('certificate')) + kwargs = dict(addattr=u'usercertificate={}'.format(cert)) + if principal_type == SERVICE: + api.Command['service_mod'](principal_string, **kwargs) + elif principal_type == HOST: + api.Command['host_mod'](principal_name, **kwargs) + elif principal_type == USER: + api.Command['user_mod'](principal_name, **kwargs) + + return dict( + result=result + ) + + + +@register() +class cert_status(VirtualCommand): + __doc__ = _('Check the status of a certificate signing request.') + + takes_args = ( + Str('request_id', + label=_('Request id'), + flags=['no_create', 'no_update', 'no_search'], + ), + ) + has_output_params = ( + Str('cert_request_status', + label=_('Request status'), + ), + ) + operation = "certificate status" + + + def execute(self, request_id, **kw): + ca_enabled_check() + self.check_access() + return dict( + result=self.Backend.ra.check_request_status(request_id) + ) + + + +_serial_number = Str('serial_number', + validate_serial_number, + label=_('Serial number'), + doc=_('Serial number in decimal or if prefixed with 0x in hexadecimal'), + normalizer=normalize_serial_number, +) + +@register() +class cert_show(VirtualCommand): + __doc__ = _('Retrieve an existing certificate.') + + takes_args = _serial_number + + has_output_params = ( + Str('certificate', + label=_('Certificate'), + ), + Str('subject', + label=_('Subject'), + ), + Str('issuer', + label=_('Issuer'), + ), + Str('valid_not_before', + label=_('Not Before'), + ), + Str('valid_not_after', + label=_('Not After'), + ), + Str('md5_fingerprint', + label=_('Fingerprint (MD5)'), + ), + Str('sha1_fingerprint', + label=_('Fingerprint (SHA1)'), + ), + Str('revocation_reason', + label=_('Revocation reason'), + ), + Str('serial_number_hex', + label=_('Serial number (hex)'), + ), + ) + + takes_options = ( + Str('out?', + label=_('Output filename'), + doc=_('File to store the certificate in.'), + exclude='webui', + ), + ) + + operation="retrieve certificate" + + def execute(self, serial_number, **options): + ca_enabled_check() + hostname = None + try: + self.check_access() + except errors.ACIError as acierr: + self.debug("Not granted by ACI to retrieve certificate, looking at principal") + bind_principal = getattr(context, 'principal') + if not bind_principal.startswith('host/'): + raise acierr + hostname = get_host_from_principal(bind_principal) + + result=self.Backend.ra.get_certificate(serial_number) + cert = x509.load_certificate(result['certificate']) + result['subject'] = unicode(cert.subject) + result['issuer'] = unicode(cert.issuer) + result['valid_not_before'] = unicode(cert.valid_not_before_str) + result['valid_not_after'] = unicode(cert.valid_not_after_str) + result['md5_fingerprint'] = unicode(nss.data_to_hex(nss.md5_digest(cert.der_data), 64)[0]) + result['sha1_fingerprint'] = unicode(nss.data_to_hex(nss.sha1_digest(cert.der_data), 64)[0]) + if hostname: + # If we have a hostname we want to verify that the subject + # of the certificate matches it, otherwise raise an error + if hostname != cert.subject.common_name: #pylint: disable=E1101 + raise acierr + + return dict(result=result) + + + + +@register() +class cert_revoke(VirtualCommand): + __doc__ = _('Revoke a certificate.') + + takes_args = _serial_number + + has_output_params = ( + Flag('revoked', + label=_('Revoked'), + ), + ) + operation = "revoke certificate" + + # FIXME: The default is 0. Is this really an Int param? + takes_options = ( + Int('revocation_reason', + label=_('Reason'), + doc=_('Reason for revoking the certificate (0-10). Type ' + '"ipa help cert" for revocation reason details. '), + minvalue=0, + maxvalue=10, + default=0, + autofill=True + ), + ) + + def execute(self, serial_number, **kw): + ca_enabled_check() + hostname = None + try: + self.check_access() + except errors.ACIError as acierr: + self.debug("Not granted by ACI to revoke certificate, looking at principal") + try: + # Let cert_show() handle verifying that the subject of the + # cert we're dealing with matches the hostname in the principal + result = api.Command['cert_show'](unicode(serial_number))['result'] + except errors.NotImplementedError: + pass + revocation_reason = kw['revocation_reason'] + if revocation_reason == 7: + raise errors.CertificateOperationError(error=_('7 is not a valid revocation reason')) + return dict( + result=self.Backend.ra.revoke_certificate( + serial_number, revocation_reason=revocation_reason) + ) + + + +@register() +class cert_remove_hold(VirtualCommand): + __doc__ = _('Take a revoked certificate off hold.') + + takes_args = _serial_number + + has_output_params = ( + Flag('unrevoked', + label=_('Unrevoked'), + ), + Str('error_string', + label=_('Error'), + ), + ) + operation = "certificate remove hold" + + def execute(self, serial_number, **kw): + ca_enabled_check() + self.check_access() + return dict( + result=self.Backend.ra.take_certificate_off_hold(serial_number) + ) + + + +@register() +class cert_find(Command): + __doc__ = _('Search for existing certificates.') + + takes_options = ( + Str('subject?', + label=_('Subject'), + doc=_('Subject'), + autofill=False, + ), + Int('revocation_reason?', + label=_('Reason'), + doc=_('Reason for revoking the certificate (0-10). Type ' + '"ipa help cert" for revocation reason details.'), + minvalue=0, + maxvalue=10, + autofill=False, + ), + Int('min_serial_number?', + doc=_("minimum serial number"), + autofill=False, + minvalue=0, + maxvalue=2147483647, + ), + Int('max_serial_number?', + doc=_("maximum serial number"), + autofill=False, + minvalue=0, + maxvalue=2147483647, + ), + Flag('exactly?', + doc=_('match the common name exactly'), + autofill=False, + ), + Str('validnotafter_from?', validate_pkidate, + doc=_('Valid not after from this date (YYYY-mm-dd)'), + autofill=False, + ), + Str('validnotafter_to?', validate_pkidate, + doc=_('Valid not after to this date (YYYY-mm-dd)'), + autofill=False, + ), + Str('validnotbefore_from?', validate_pkidate, + doc=_('Valid not before from this date (YYYY-mm-dd)'), + autofill=False, + ), + Str('validnotbefore_to?', validate_pkidate, + doc=_('Valid not before to this date (YYYY-mm-dd)'), + autofill=False, + ), + Str('issuedon_from?', validate_pkidate, + doc=_('Issued on from this date (YYYY-mm-dd)'), + autofill=False, + ), + Str('issuedon_to?', validate_pkidate, + doc=_('Issued on to this date (YYYY-mm-dd)'), + autofill=False, + ), + Str('revokedon_from?', validate_pkidate, + doc=_('Revoked on from this date (YYYY-mm-dd)'), + autofill=False, + ), + Str('revokedon_to?', validate_pkidate, + doc=_('Revoked on to this date (YYYY-mm-dd)'), + autofill=False, + ), + Int('sizelimit?', + label=_('Size Limit'), + doc=_('Maximum number of certs returned'), + flags=['no_display'], + minvalue=0, + default=100, + ), + ) + + has_output = output.standard_list_of_entries + has_output_params = ( + Str('serial_number_hex', + label=_('Serial number (hex)'), + ), + Str('serial_number', + label=_('Serial number'), + ), + Str('status', + label=_('Status'), + ), + ) + + msg_summary = ngettext( + '%(count)d certificate matched', '%(count)d certificates matched', 0 + ) + + def execute(self, **options): + ca_enabled_check() + ret = dict( + result=self.Backend.ra.find(options) + ) + ret['count'] = len(ret['result']) + ret['truncated'] = False + return ret + + +@register() +class ca_is_enabled(Command): + """ + Checks if any of the servers has the CA service enabled. + """ + NO_CLI = True + has_output = output.standard_value + + def execute(self, *args, **options): + base_dn = DN(('cn', 'masters'), ('cn', 'ipa'), ('cn', 'etc'), + self.api.env.basedn) + filter = '(&(objectClass=ipaConfigObject)(cn=CA))' + try: + self.api.Backend.ldap2.find_entries( + base_dn=base_dn, filter=filter, attrs_list=[]) + except errors.NotFound: + result = False + else: + result = True + return dict(result=result, value=pkey_to_value(None, options)) diff --git a/ipaserver/plugins/certprofile.py b/ipaserver/plugins/certprofile.py new file mode 100644 index 000000000..6f314e1a4 --- /dev/null +++ b/ipaserver/plugins/certprofile.py @@ -0,0 +1,335 @@ +# +# Copyright (C) 2015 FreeIPA Contributors see COPYING for license +# + +import re + +from ipalib import api, Bool, Str +from ipalib.plugable import Registry +from .baseldap import ( + LDAPObject, LDAPSearch, LDAPCreate, + LDAPDelete, LDAPUpdate, LDAPRetrieve) +from ipalib.request import context +from ipalib import ngettext +from ipalib.text import _ +from ipapython.dogtag import INCLUDED_PROFILES +from ipapython.version import API_VERSION + +from ipalib import errors + + +__doc__ = _(""" +Manage Certificate Profiles + +Certificate Profiles are used by Certificate Authority (CA) in the signing of +certificates to determine if a Certificate Signing Request (CSR) is acceptable, +and if so what features and extensions will be present on the certificate. + +The Certificate Profile format is the property-list format understood by the +Dogtag or Red Hat Certificate System CA. + +PROFILE ID SYNTAX: + +A Profile ID is a string without spaces or punctuation starting with a letter +and followed by a sequence of letters, digits or underscore ("_"). + +EXAMPLES: + + Import a profile that will not store issued certificates: + ipa certprofile-import ShortLivedUserCert \\ + --file UserCert.profile --desc "User Certificates" \\ + --store=false + + Delete a certificate profile: + ipa certprofile-del ShortLivedUserCert + + Show information about a profile: + ipa certprofile-show ShortLivedUserCert + + Save profile configuration to a file: + ipa certprofile-show caIPAserviceCert --out caIPAserviceCert.cfg + + Search for profiles that do not store certificates: + ipa certprofile-find --store=false + +PROFILE CONFIGURATION FORMAT: + +The profile configuration format is the raw property-list format +used by Dogtag Certificate System. The XML format is not supported. + +The following restrictions apply to profiles managed by FreeIPA: + +- When importing a profile the "profileId" field, if present, must + match the ID given on the command line. + +- The "classId" field must be set to "caEnrollImpl" + +- The "auth.instance_id" field must be set to "raCertAuth" + +- The "certReqInputImpl" input class and "certOutputImpl" output + class must be used. + +""") + + +register = Registry() + + +def ca_enabled_check(): + """Raise NotFound if CA is not enabled. + + This function is defined in multiple plugins to avoid circular imports + (cert depends on certprofile, so we cannot import cert here). + + """ + if not api.Command.ca_is_enabled()['result']: + raise errors.NotFound(reason=_('CA is not configured')) + + +profile_id_pattern = re.compile('^[a-zA-Z]\w*$') + + +def validate_profile_id(ugettext, value): + """Ensure profile ID matches form required by CA.""" + if profile_id_pattern.match(value) is None: + return _('invalid Profile ID') + else: + return None + + +@register() +class certprofile(LDAPObject): + """ + Certificate Profile object. + """ + container_dn = api.env.container_certprofile + object_name = _('Certificate Profile') + object_name_plural = _('Certificate Profiles') + object_class = ['ipacertprofile'] + default_attributes = [ + 'cn', 'description', 'ipacertprofilestoreissued' + ] + search_attributes = [ + 'cn', 'description', 'ipacertprofilestoreissued' + ] + label = _('Certificate Profiles') + label_singular = _('Certificate Profile') + + takes_params = ( + Str('cn', validate_profile_id, + primary_key=True, + cli_name='id', + label=_('Profile ID'), + doc=_('Profile ID for referring to this profile'), + ), + Str('description', + required=True, + cli_name='desc', + label=_('Profile description'), + doc=_('Brief description of this profile'), + ), + Bool('ipacertprofilestoreissued', + default=True, + cli_name='store', + label=_('Store issued certificates'), + doc=_('Whether to store certs issued using this profile'), + ), + ) + + permission_filter_objectclasses = ['ipacertprofile'] + managed_permissions = { + 'System: Read Certificate Profiles': { + 'replaces_global_anonymous_aci': True, + 'ipapermbindruletype': 'all', + 'ipapermright': {'read', 'search', 'compare'}, + 'ipapermdefaultattr': { + 'cn', + 'description', + 'ipacertprofilestoreissued', + 'objectclass', + }, + }, + 'System: Import Certificate Profile': { + 'ipapermright': {'add'}, + 'replaces': [ + '(target = "ldap:///cn=*,cn=certprofiles,cn=ca,$SUFFIX")(version 3.0;acl "permission:Import Certificate Profile";allow (add) groupdn = "ldap:///cn=Import Certificate Profile,cn=permissions,cn=pbac,$SUFFIX";)', + ], + 'default_privileges': {'CA Administrator'}, + }, + 'System: Delete Certificate Profile': { + 'ipapermright': {'delete'}, + 'replaces': [ + '(target = "ldap:///cn=*,cn=certprofiles,cn=ca,$SUFFIX")(version 3.0;acl "permission:Delete Certificate Profile";allow (delete) groupdn = "ldap:///cn=Delete Certificate Profile,cn=permissions,cn=pbac,$SUFFIX";)', + ], + 'default_privileges': {'CA Administrator'}, + }, + 'System: Modify Certificate Profile': { + 'ipapermright': {'write'}, + 'ipapermdefaultattr': { + 'cn', + 'description', + 'ipacertprofilestoreissued', + }, + 'replaces': [ + '(targetattr = "cn || description || ipacertprofilestoreissued")(target = "ldap:///cn=*,cn=certprofiles,cn=ca,$SUFFIX")(version 3.0;acl "permission:Modify Certificate Profile";allow (write) groupdn = "ldap:///cn=Modify Certificate Profile,cn=permissions,cn=pbac,$SUFFIX";)', + ], + 'default_privileges': {'CA Administrator'}, + }, + } + + + +@register() +class certprofile_find(LDAPSearch): + __doc__ = _("Search for Certificate Profiles.") + msg_summary = ngettext( + '%(count)d profile matched', '%(count)d profiles matched', 0 + ) + + def execute(self, *args, **kwargs): + ca_enabled_check() + return super(certprofile_find, self).execute(*args, **kwargs) + + +@register() +class certprofile_show(LDAPRetrieve): + __doc__ = _("Display the properties of a Certificate Profile.") + + has_output_params = LDAPRetrieve.has_output_params + ( + Str('config', + label=_('Profile configuration'), + ), + ) + + takes_options = LDAPRetrieve.takes_options + ( + Str('out?', + doc=_('Write profile configuration to file'), + ), + ) + + def execute(self, *keys, **options): + ca_enabled_check() + result = super(certprofile_show, self).execute(*keys, **options) + + if 'out' in options: + with self.api.Backend.ra_certprofile as profile_api: + result['result']['config'] = profile_api.read_profile(keys[0]) + + return result + + +@register() +class certprofile_import(LDAPCreate): + __doc__ = _("Import a Certificate Profile.") + msg_summary = _('Imported profile "%(value)s"') + takes_options = ( + Str( + 'file', + label=_('Filename of a raw profile. The XML format is not supported.'), + cli_name='file', + flags=('virtual_attribute',), + noextrawhitespace=False, + ), + ) + + PROFILE_ID_PATTERN = re.compile('^profileId=([a-zA-Z]\w*)', re.MULTILINE) + + def pre_callback(self, ldap, dn, entry, entry_attrs, *keys, **options): + ca_enabled_check() + context.profile = options['file'] + + match = self.PROFILE_ID_PATTERN.search(options['file']) + if match is None: + # no profileId found, use CLI value as profileId. + context.profile = u'profileId=%s\n%s' % (keys[0], context.profile) + elif keys[0] != match.group(1): + raise errors.ValidationError(name='file', + error=_("Profile ID '%(cli_value)s' does not match profile data '%(file_value)s'") + % {'cli_value': keys[0], 'file_value': match.group(1)} + ) + return dn + + + def post_callback(self, ldap, dn, entry_attrs, *keys, **options): + """Import the profile into Dogtag and enable it. + + If the operation fails, remove the LDAP entry. + """ + try: + with self.api.Backend.ra_certprofile as profile_api: + profile_api.create_profile(context.profile) + profile_api.enable_profile(keys[0]) + except: + # something went wrong ; delete entry + ldap.delete_entry(dn) + raise + + return dn + + +@register() +class certprofile_del(LDAPDelete): + __doc__ = _("Delete a Certificate Profile.") + msg_summary = _('Deleted profile "%(value)s"') + + def pre_callback(self, ldap, dn, *keys, **options): + ca_enabled_check() + + if keys[0] in [p.profile_id for p in INCLUDED_PROFILES]: + raise errors.ValidationError(name='profile_id', + error=_("Predefined profile '%(profile_id)s' cannot be deleted") + % {'profile_id': keys[0]} + ) + + return dn + + def post_callback(self, ldap, dn, *keys, **options): + with self.api.Backend.ra_certprofile as profile_api: + profile_api.disable_profile(keys[0]) + profile_api.delete_profile(keys[0]) + return dn + + +@register() +class certprofile_mod(LDAPUpdate): + __doc__ = _("Modify Certificate Profile configuration.") + msg_summary = _('Modified Certificate Profile "%(value)s"') + + takes_options = LDAPUpdate.takes_options + ( + Str( + 'file?', + label=_('File containing profile configuration'), + cli_name='file', + flags=('virtual_attribute',), + noextrawhitespace=False, + ), + ) + + def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options): + ca_enabled_check() + # Once a profile id is set it cannot be changed + if 'cn' in entry_attrs: + raise errors.ProtectedEntryError(label='certprofile', key=keys[0], + reason=_('Certificate profiles cannot be renamed')) + if 'file' in options: + with self.api.Backend.ra_certprofile as profile_api: + profile_api.disable_profile(keys[0]) + try: + profile_api.update_profile(keys[0], options['file']) + finally: + profile_api.enable_profile(keys[0]) + + return dn + + def execute(self, *keys, **options): + try: + return super(certprofile_mod, self).execute(*keys, **options) + except errors.EmptyModlist: + if 'file' in options: + # The profile data in Dogtag was updated. + # Do not fail; return result of certprofile-show instead + return self.api.Command.certprofile_show(keys[0], + version=API_VERSION) + else: + # This case is actually an error; re-raise + raise diff --git a/ipaserver/plugins/config.py b/ipaserver/plugins/config.py new file mode 100644 index 000000000..46a40ddf7 --- /dev/null +++ b/ipaserver/plugins/config.py @@ -0,0 +1,358 @@ +# Authors: +# Rob Crittenden <rcritten@redhat.com> +# Pavel Zuna <pzuna@redhat.com> +# +# Copyright (C) 2008 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +from ipalib import api +from ipalib import Bool, Int, Str, IA5Str, StrEnum, DNParam +from ipalib import errors +from ipalib.plugable import Registry +from .baseldap import ( + LDAPObject, + LDAPUpdate, + LDAPRetrieve) +from .selinuxusermap import validate_selinuxuser +from ipalib import _ +from ipapython.dn import DN + +# 389-ds attributes that should be skipped in attribute checks +OPERATIONAL_ATTRIBUTES = ('nsaccountlock', 'member', 'memberof', + 'memberindirect', 'memberofindirect',) + +__doc__ = _(""" +Server configuration + +Manage the default values that IPA uses and some of its tuning parameters. + +NOTES: + +The password notification value (--pwdexpnotify) is stored here so it will +be replicated. It is not currently used to notify users in advance of an +expiring password. + +Some attributes are read-only, provided only for information purposes. These +include: + +Certificate Subject base: the configured certificate subject base, + e.g. O=EXAMPLE.COM. This is configurable only at install time. +Password plug-in features: currently defines additional hashes that the + password will generate (there may be other conditions). + +When setting the order list for mapping SELinux users you may need to +quote the value so it isn't interpreted by the shell. + +EXAMPLES: + + Show basic server configuration: + ipa config-show + + Show all configuration options: + ipa config-show --all + + Change maximum username length to 99 characters: + ipa config-mod --maxusername=99 + + Increase default time and size limits for maximum IPA server search: + ipa config-mod --searchtimelimit=10 --searchrecordslimit=2000 + + Set default user e-mail domain: + ipa config-mod --emaildomain=example.com + + Enable migration mode to make "ipa migrate-ds" command operational: + ipa config-mod --enable-migration=TRUE + + Define SELinux user map order: + ipa config-mod --ipaselinuxusermaporder='guest_u:s0$xguest_u:s0$user_u:s0-s0:c0.c1023$staff_u:s0-s0:c0.c1023$unconfined_u:s0-s0:c0.c1023' +""") + +register = Registry() + +@register() +class config(LDAPObject): + """ + IPA configuration object + """ + object_name = _('configuration options') + default_attributes = [ + 'ipamaxusernamelength', 'ipahomesrootdir', 'ipadefaultloginshell', + 'ipadefaultprimarygroup', 'ipadefaultemaildomain', 'ipasearchtimelimit', + 'ipasearchrecordslimit', 'ipausersearchfields', 'ipagroupsearchfields', + 'ipamigrationenabled', 'ipacertificatesubjectbase', + 'ipapwdexpadvnotify', 'ipaselinuxusermaporder', + 'ipaselinuxusermapdefault', 'ipaconfigstring', 'ipakrbauthzdata', + 'ipauserauthtype' + ] + container_dn = DN(('cn', 'ipaconfig'), ('cn', 'etc')) + permission_filter_objectclasses = ['ipaguiconfig'] + managed_permissions = { + 'System: Read Global Configuration': { + 'replaces_global_anonymous_aci': True, + 'ipapermbindruletype': 'all', + 'ipapermright': {'read', 'search', 'compare'}, + 'ipapermdefaultattr': { + 'cn', 'objectclass', + 'ipacertificatesubjectbase', 'ipaconfigstring', + 'ipadefaultemaildomain', 'ipadefaultloginshell', + 'ipadefaultprimarygroup', 'ipagroupobjectclasses', + 'ipagroupsearchfields', 'ipahomesrootdir', + 'ipakrbauthzdata', 'ipamaxusernamelength', + 'ipamigrationenabled', 'ipapwdexpadvnotify', + 'ipaselinuxusermapdefault', 'ipaselinuxusermaporder', + 'ipasearchrecordslimit', 'ipasearchtimelimit', + 'ipauserauthtype', 'ipauserobjectclasses', + 'ipausersearchfields', 'ipacustomfields', + }, + }, + } + + label = _('Configuration') + label_singular = _('Configuration') + + takes_params = ( + Int('ipamaxusernamelength', + cli_name='maxusername', + label=_('Maximum username length'), + minvalue=1, + maxvalue=255, + ), + IA5Str('ipahomesrootdir', + cli_name='homedirectory', + label=_('Home directory base'), + doc=_('Default location of home directories'), + ), + Str('ipadefaultloginshell', + cli_name='defaultshell', + label=_('Default shell'), + doc=_('Default shell for new users'), + ), + Str('ipadefaultprimarygroup', + cli_name='defaultgroup', + label=_('Default users group'), + doc=_('Default group for new users'), + ), + Str('ipadefaultemaildomain?', + cli_name='emaildomain', + label=_('Default e-mail domain'), + doc=_('Default e-mail domain'), + ), + Int('ipasearchtimelimit', + cli_name='searchtimelimit', + label=_('Search time limit'), + doc=_('Maximum amount of time (seconds) for a search (-1 or 0 is unlimited)'), + minvalue=-1, + ), + Int('ipasearchrecordslimit', + cli_name='searchrecordslimit', + label=_('Search size limit'), + doc=_('Maximum number of records to search (-1 or 0 is unlimited)'), + minvalue=-1, + ), + IA5Str('ipausersearchfields', + cli_name='usersearch', + label=_('User search fields'), + doc=_('A comma-separated list of fields to search in when searching for users'), + ), + IA5Str('ipagroupsearchfields', + cli_name='groupsearch', + label='Group search fields', + doc=_('A comma-separated list of fields to search in when searching for groups'), + ), + Bool('ipamigrationenabled', + cli_name='enable_migration', + label=_('Enable migration mode'), + doc=_('Enable migration mode'), + ), + DNParam('ipacertificatesubjectbase', + cli_name='subject', + label=_('Certificate Subject base'), + doc=_('Base for certificate subjects (OU=Test,O=Example)'), + flags=['no_update'], + ), + Str('ipagroupobjectclasses+', + cli_name='groupobjectclasses', + label=_('Default group objectclasses'), + doc=_('Default group objectclasses (comma-separated list)'), + ), + Str('ipauserobjectclasses+', + cli_name='userobjectclasses', + label=_('Default user objectclasses'), + doc=_('Default user objectclasses (comma-separated list)'), + ), + Int('ipapwdexpadvnotify', + cli_name='pwdexpnotify', + label=_('Password Expiration Notification (days)'), + doc=_('Number of days\'s notice of impending password expiration'), + minvalue=0, + ), + StrEnum('ipaconfigstring*', + cli_name='ipaconfigstring', + label=_('Password plugin features'), + doc=_('Extra hashes to generate in password plug-in'), + values=(u'AllowNThash', + u'KDC:Disable Last Success', u'KDC:Disable Lockout', + u'KDC:Disable Default Preauth for SPNs'), + ), + Str('ipaselinuxusermaporder', + label=_('SELinux user map order'), + doc=_('Order in increasing priority of SELinux users, delimited by $'), + ), + Str('ipaselinuxusermapdefault?', + label=_('Default SELinux user'), + doc=_('Default SELinux user when no match is found in SELinux map rule'), + ), + StrEnum('ipakrbauthzdata*', + cli_name='pac_type', + label=_('Default PAC types'), + doc=_('Default types of PAC supported for services'), + values=(u'MS-PAC', u'PAD', u'nfs:NONE'), + ), + StrEnum('ipauserauthtype*', + cli_name='user_auth_type', + label=_('Default user authentication types'), + doc=_('Default types of supported user authentication'), + values=(u'password', u'radius', u'otp', u'disabled'), + ), + ) + + def get_dn(self, *keys, **kwargs): + return DN(('cn', 'ipaconfig'), ('cn', 'etc'), api.env.basedn) + + + +@register() +class config_mod(LDAPUpdate): + __doc__ = _('Modify configuration options.') + + def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options): + assert isinstance(dn, DN) + if 'ipadefaultprimarygroup' in entry_attrs: + group=entry_attrs['ipadefaultprimarygroup'] + try: + api.Object['group'].get_dn_if_exists(group) + except errors.NotFound: + raise errors.NotFound(message=_("The group doesn't exist")) + kw = {} + if 'ipausersearchfields' in entry_attrs: + kw['ipausersearchfields'] = 'ipauserobjectclasses' + if 'ipagroupsearchfields' in entry_attrs: + kw['ipagroupsearchfields'] = 'ipagroupobjectclasses' + if kw: + config = ldap.get_ipa_config(list(kw.values())) + for (k, v) in kw.items(): + allowed_attrs = ldap.get_allowed_attributes(config[v]) + fields = entry_attrs[k].split(',') + for a in fields: + a = a.strip() + a, tomato, olive = a.partition(';') + if a not in allowed_attrs: + raise errors.ValidationError( + name=k, error=_('attribute "%s" not allowed') % a + ) + + # Set ipasearchrecordslimit to -1 if 0 is used + if 'ipasearchrecordslimit' in entry_attrs: + if entry_attrs['ipasearchrecordslimit'] is 0: + entry_attrs['ipasearchrecordslimit'] = -1 + + # Set ipasearchtimelimit to -1 if 0 is used + if 'ipasearchtimelimit' in entry_attrs: + if entry_attrs['ipasearchtimelimit'] is 0: + entry_attrs['ipasearchtimelimit'] = -1 + + for (attr, obj) in (('ipauserobjectclasses', 'user'), + ('ipagroupobjectclasses', 'group')): + if attr in entry_attrs: + if not entry_attrs[attr]: + raise errors.ValidationError(name=attr, + error=_('May not be empty')) + objectclasses = list(set(entry_attrs[attr]).union( + self.api.Object[obj].possible_objectclasses)) + new_allowed_attrs = ldap.get_allowed_attributes(objectclasses, + raise_on_unknown=True) + checked_attrs = self.api.Object[obj].default_attributes + if self.api.Object[obj].uuid_attribute: + checked_attrs = checked_attrs + [self.api.Object[obj].uuid_attribute] + for obj_attr in checked_attrs: + obj_attr, tomato, olive = obj_attr.partition(';') + if obj_attr in OPERATIONAL_ATTRIBUTES: + continue + if obj_attr in self.api.Object[obj].params and \ + 'virtual_attribute' in \ + self.api.Object[obj].params[obj_attr].flags: + # skip virtual attributes + continue + if obj_attr not in new_allowed_attrs: + raise errors.ValidationError(name=attr, + error=_('%(obj)s default attribute %(attr)s would not be allowed!') \ + % dict(obj=obj, attr=obj_attr)) + + if ('ipaselinuxusermapdefault' in entry_attrs or + 'ipaselinuxusermaporder' in entry_attrs): + config = None + failedattr = 'ipaselinuxusermaporder' + + if 'ipaselinuxusermapdefault' in entry_attrs: + defaultuser = entry_attrs['ipaselinuxusermapdefault'] + failedattr = 'ipaselinuxusermapdefault' + + # validate the new default user first + if defaultuser is not None: + error_message = validate_selinuxuser(_, defaultuser) + + if error_message: + raise errors.ValidationError(name='ipaselinuxusermapdefault', + error=error_message) + + else: + config = ldap.get_ipa_config() + defaultuser = config.get('ipaselinuxusermapdefault', [None])[0] + + if 'ipaselinuxusermaporder' in entry_attrs: + order = entry_attrs['ipaselinuxusermaporder'] + userlist = order.split('$') + + # validate the new user order first + for user in userlist: + if not user: + raise errors.ValidationError(name='ipaselinuxusermaporder', + error=_('A list of SELinux users delimited by $ expected')) + + error_message = validate_selinuxuser(_, user) + if error_message: + error_message = _("SELinux user '%(user)s' is not " + "valid: %(error)s") % dict(user=user, + error=error_message) + raise errors.ValidationError(name='ipaselinuxusermaporder', + error=error_message) + else: + if not config: + config = ldap.get_ipa_config() + order = config['ipaselinuxusermaporder'] + userlist = order[0].split('$') + if defaultuser and defaultuser not in userlist: + raise errors.ValidationError(name=failedattr, + error=_('SELinux user map default user not in order list')) + + return dn + + + +@register() +class config_show(LDAPRetrieve): + __doc__ = _('Show the current configuration.') + diff --git a/ipaserver/plugins/delegation.py b/ipaserver/plugins/delegation.py new file mode 100644 index 000000000..0443f0e48 --- /dev/null +++ b/ipaserver/plugins/delegation.py @@ -0,0 +1,226 @@ +# Authors: +# Rob Crittenden <rcritten@redhat.com> +# Martin Kosek <mkosek@redhat.com> +# +# Copyright (C) 2010 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +from ipalib import _, ngettext +from ipalib import Str +from ipalib import api, crud +from ipalib import output +from ipalib import Object +from ipalib.plugable import Registry +from .baseldap import gen_pkey_only_option, pkey_to_value + +__doc__ = _(""" +Group to Group Delegation + +A permission enables fine-grained delegation of permissions. Access Control +Rules, or instructions (ACIs), grant permission to permissions to perform +given tasks such as adding a user, modifying a group, etc. + +Group to Group Delegations grants the members of one group to update a set +of attributes of members of another group. + +EXAMPLES: + + Add a delegation rule to allow managers to edit employee's addresses: + ipa delegation-add --attrs=street --group=managers --membergroup=employees "managers edit employees' street" + + When managing the list of attributes you need to include all attributes + in the list, including existing ones. Add postalCode to the list: + ipa delegation-mod --attrs=street --attrs=postalCode --group=managers --membergroup=employees "managers edit employees' street" + + Display our updated rule: + ipa delegation-show "managers edit employees' street" + + Delete a rule: + ipa delegation-del "managers edit employees' street" +""") + +register = Registry() + +ACI_PREFIX=u"delegation" + +output_params = ( + Str('aci', + label=_('ACI'), + ), +) + +@register() +class delegation(Object): + """ + Delegation object. + """ + + bindable = False + object_name = _('delegation') + object_name_plural = _('delegations') + label = _('Delegations') + label_singular = _('Delegation') + + takes_params = ( + Str('aciname', + cli_name='name', + label=_('Delegation name'), + doc=_('Delegation name'), + primary_key=True, + ), + Str('permissions*', + cli_name='permissions', + label=_('Permissions'), + doc=_('Permissions to grant (read, write). Default is write.'), + ), + Str('attrs+', + cli_name='attrs', + label=_('Attributes'), + doc=_('Attributes to which the delegation applies'), + normalizer=lambda value: value.lower(), + ), + Str('memberof', + cli_name='membergroup', + label=_('Member user group'), + doc=_('User group to apply delegation to'), + ), + Str('group', + cli_name='group', + label=_('User group'), + doc=_('User group ACI grants access to'), + ), + ) + + def __json__(self): + json_friendly_attributes = ( + 'label', 'label_singular', 'takes_params', 'bindable', 'name', + 'object_name', 'object_name_plural', + ) + json_dict = dict( + (a, getattr(self, a)) for a in json_friendly_attributes + ) + json_dict['primary_key'] = self.primary_key.name + + json_dict['methods'] = [m for m in self.methods] + return json_dict + + def postprocess_result(self, result): + try: + # do not include prefix in result + del result['aciprefix'] + except KeyError: + pass + + + +@register() +class delegation_add(crud.Create): + __doc__ = _('Add a new delegation.') + + msg_summary = _('Added delegation "%(value)s"') + has_output_params = output_params + + def execute(self, aciname, **kw): + if not 'permissions' in kw: + kw['permissions'] = (u'write',) + kw['aciprefix'] = ACI_PREFIX + result = api.Command['aci_add'](aciname, **kw)['result'] + self.obj.postprocess_result(result) + + return dict( + result=result, + value=pkey_to_value(aciname, kw), + ) + + + +@register() +class delegation_del(crud.Delete): + __doc__ = _('Delete a delegation.') + + has_output = output.standard_boolean + msg_summary = _('Deleted delegation "%(value)s"') + + def execute(self, aciname, **kw): + kw['aciprefix'] = ACI_PREFIX + result = api.Command['aci_del'](aciname, **kw) + self.obj.postprocess_result(result) + return dict( + result=True, + value=pkey_to_value(aciname, kw), + ) + + + +@register() +class delegation_mod(crud.Update): + __doc__ = _('Modify a delegation.') + + msg_summary = _('Modified delegation "%(value)s"') + has_output_params = output_params + + def execute(self, aciname, **kw): + kw['aciprefix'] = ACI_PREFIX + result = api.Command['aci_mod'](aciname, **kw)['result'] + self.obj.postprocess_result(result) + + return dict( + result=result, + value=pkey_to_value(aciname, kw), + ) + + + +@register() +class delegation_find(crud.Search): + __doc__ = _('Search for delegations.') + + msg_summary = ngettext( + '%(count)d delegation matched', '%(count)d delegations matched', 0 + ) + + takes_options = (gen_pkey_only_option("name"),) + has_output_params = output_params + + def execute(self, term=None, **kw): + kw['aciprefix'] = ACI_PREFIX + results = api.Command['aci_find'](term, **kw)['result'] + + for aci in results: + self.obj.postprocess_result(aci) + + return dict( + result=results, + count=len(results), + truncated=False, + ) + + + +@register() +class delegation_show(crud.Retrieve): + __doc__ = _('Display information about a delegation.') + + has_output_params = output_params + + def execute(self, aciname, **kw): + result = api.Command['aci_show'](aciname, aciprefix=ACI_PREFIX, **kw)['result'] + self.obj.postprocess_result(result) + return dict( + result=result, + value=pkey_to_value(aciname, kw), + ) + diff --git a/ipaserver/plugins/dns.py b/ipaserver/plugins/dns.py new file mode 100644 index 000000000..9cca07c6d --- /dev/null +++ b/ipaserver/plugins/dns.py @@ -0,0 +1,4396 @@ +# Authors: +# Martin Kosek <mkosek@redhat.com> +# Pavel Zuna <pzuna@redhat.com> +# +# Copyright (C) 2010 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +from __future__ import absolute_import + +import netaddr +import time +import re +import binascii +import encodings.idna + +import dns.name +import dns.exception +import dns.rdatatype +import dns.resolver +import six + +from ipalib.dns import (get_record_rrtype, + get_rrparam_from_part, + has_cli_options, + iterate_rrparams_by_parts, + record_name_format) +from ipalib.request import context +from ipalib import api, errors, output +from ipalib import Command +from ipalib.capabilities import ( + VERSION_WITHOUT_CAPABILITIES, + client_has_capability) +from ipalib.parameters import (Flag, Bool, Int, Decimal, Str, StrEnum, Any, + DNSNameParam) +from ipalib.plugable import Registry +from .baseldap import ( + pkey_to_value, + LDAPObject, + LDAPCreate, + LDAPUpdate, + LDAPSearch, + LDAPQuery, + LDAPDelete, + LDAPRetrieve) +from ipalib import _ +from ipalib import messages +from ipalib.util import (normalize_zonemgr, + get_dns_forward_zone_update_policy, + get_dns_reverse_zone_update_policy, + get_reverse_zone_default, REVERSE_DNS_ZONES, + normalize_zone, validate_dnssec_global_forwarder, + DNSSECSignatureMissingError, UnresolvableRecordError, + EDNS0UnsupportedError, DNSSECValidationError, + validate_dnssec_zone_forwarder_step1, + validate_dnssec_zone_forwarder_step2, + verify_host_resolvable) +from ipapython.dn import DN +from ipapython.ipautil import CheckedIPAddress +from ipapython.dnsutil import check_zone_overlap +from ipapython.dnsutil import DNSName +from ipapython.dnsutil import related_to_auto_empty_zone + +if six.PY3: + unicode = str + +__doc__ = _(""" +Domain Name System (DNS) +""") + _(""" +Manage DNS zone and resource records. +""") + _(""" +SUPPORTED ZONE TYPES + + * Master zone (dnszone-*), contains authoritative data. + * Forward zone (dnsforwardzone-*), forwards queries to configured forwarders + (a set of DNS servers). +""") + _(""" +USING STRUCTURED PER-TYPE OPTIONS +""") + _(""" +There are many structured DNS RR types where DNS data stored in LDAP server +is not just a scalar value, for example an IP address or a domain name, but +a data structure which may be often complex. A good example is a LOC record +[RFC1876] which consists of many mandatory and optional parts (degrees, +minutes, seconds of latitude and longitude, altitude or precision). +""") + _(""" +It may be difficult to manipulate such DNS records without making a mistake +and entering an invalid value. DNS module provides an abstraction over these +raw records and allows to manipulate each RR type with specific options. For +each supported RR type, DNS module provides a standard option to manipulate +a raw records with format --<rrtype>-rec, e.g. --mx-rec, and special options +for every part of the RR structure with format --<rrtype>-<partname>, e.g. +--mx-preference and --mx-exchanger. +""") + _(""" +When adding a record, either RR specific options or standard option for a raw +value can be used, they just should not be combined in one add operation. When +modifying an existing entry, new RR specific options can be used to change +one part of a DNS record, where the standard option for raw value is used +to specify the modified value. The following example demonstrates +a modification of MX record preference from 0 to 1 in a record without +modifying the exchanger: +ipa dnsrecord-mod --mx-rec="0 mx.example.com." --mx-preference=1 +""") + _(""" + +EXAMPLES: +""") + _(""" + Add new zone: + ipa dnszone-add example.com --admin-email=admin@example.com +""") + _(""" + Add system permission that can be used for per-zone privilege delegation: + ipa dnszone-add-permission example.com +""") + _(""" + Modify the zone to allow dynamic updates for hosts own records in realm EXAMPLE.COM: + ipa dnszone-mod example.com --dynamic-update=TRUE +""") + _(""" + This is the equivalent of: + ipa dnszone-mod example.com --dynamic-update=TRUE \\ + --update-policy="grant EXAMPLE.COM krb5-self * A; grant EXAMPLE.COM krb5-self * AAAA; grant EXAMPLE.COM krb5-self * SSHFP;" +""") + _(""" + Modify the zone to allow zone transfers for local network only: + ipa dnszone-mod example.com --allow-transfer=192.0.2.0/24 +""") + _(""" + Add new reverse zone specified by network IP address: + ipa dnszone-add --name-from-ip=192.0.2.0/24 +""") + _(""" + Add second nameserver for example.com: + ipa dnsrecord-add example.com @ --ns-rec=nameserver2.example.com +""") + _(""" + Add a mail server for example.com: + ipa dnsrecord-add example.com @ --mx-rec="10 mail1" +""") + _(""" + Add another record using MX record specific options: + ipa dnsrecord-add example.com @ --mx-preference=20 --mx-exchanger=mail2 +""") + _(""" + Add another record using interactive mode (started when dnsrecord-add, dnsrecord-mod, + or dnsrecord-del are executed with no options): + ipa dnsrecord-add example.com @ + Please choose a type of DNS resource record to be added + The most common types for this type of zone are: NS, MX, LOC + + DNS resource record type: MX + MX Preference: 30 + MX Exchanger: mail3 + Record name: example.com + MX record: 10 mail1, 20 mail2, 30 mail3 + NS record: nameserver.example.com., nameserver2.example.com. +""") + _(""" + Delete previously added nameserver from example.com: + ipa dnsrecord-del example.com @ --ns-rec=nameserver2.example.com. +""") + _(""" + Add LOC record for example.com: + ipa dnsrecord-add example.com @ --loc-rec="49 11 42.4 N 16 36 29.6 E 227.64m" +""") + _(""" + Add new A record for www.example.com. Create a reverse record in appropriate + reverse zone as well. In this case a PTR record "2" pointing to www.example.com + will be created in zone 2.0.192.in-addr.arpa. + ipa dnsrecord-add example.com www --a-rec=192.0.2.2 --a-create-reverse +""") + _(""" + Add new PTR record for www.example.com + ipa dnsrecord-add 2.0.192.in-addr.arpa. 2 --ptr-rec=www.example.com. +""") + _(""" + Add new SRV records for LDAP servers. Three quarters of the requests + should go to fast.example.com, one quarter to slow.example.com. If neither + is available, switch to backup.example.com. + ipa dnsrecord-add example.com _ldap._tcp --srv-rec="0 3 389 fast.example.com" + ipa dnsrecord-add example.com _ldap._tcp --srv-rec="0 1 389 slow.example.com" + ipa dnsrecord-add example.com _ldap._tcp --srv-rec="1 1 389 backup.example.com" +""") + _(""" + The interactive mode can be used for easy modification: + ipa dnsrecord-mod example.com _ldap._tcp + No option to modify specific record provided. + Current DNS record contents: + + SRV record: 0 3 389 fast.example.com, 0 1 389 slow.example.com, 1 1 389 backup.example.com + + Modify SRV record '0 3 389 fast.example.com'? Yes/No (default No): + Modify SRV record '0 1 389 slow.example.com'? Yes/No (default No): y + SRV Priority [0]: (keep the default value) + SRV Weight [1]: 2 (modified value) + SRV Port [389]: (keep the default value) + SRV Target [slow.example.com]: (keep the default value) + 1 SRV record skipped. Only one value per DNS record type can be modified at one time. + Record name: _ldap._tcp + SRV record: 0 3 389 fast.example.com, 1 1 389 backup.example.com, 0 2 389 slow.example.com +""") + _(""" + After this modification, three fifths of the requests should go to + fast.example.com and two fifths to slow.example.com. +""") + _(""" + An example of the interactive mode for dnsrecord-del command: + ipa dnsrecord-del example.com www + No option to delete specific record provided. + Delete all? Yes/No (default No): (do not delete all records) + Current DNS record contents: + + A record: 192.0.2.2, 192.0.2.3 + + Delete A record '192.0.2.2'? Yes/No (default No): + Delete A record '192.0.2.3'? Yes/No (default No): y + Record name: www + A record: 192.0.2.2 (A record 192.0.2.3 has been deleted) +""") + _(""" + Show zone example.com: + ipa dnszone-show example.com +""") + _(""" + Find zone with "example" in its domain name: + ipa dnszone-find example +""") + _(""" + Find records for resources with "www" in their name in zone example.com: + ipa dnsrecord-find example.com www +""") + _(""" + Find A records with value 192.0.2.2 in zone example.com + ipa dnsrecord-find example.com --a-rec=192.0.2.2 +""") + _(""" + Show records for resource www in zone example.com + ipa dnsrecord-show example.com www +""") + _(""" + Delegate zone sub.example to another nameserver: + ipa dnsrecord-add example.com ns.sub --a-rec=203.0.113.1 + ipa dnsrecord-add example.com sub --ns-rec=ns.sub.example.com. +""") + _(""" + Delete zone example.com with all resource records: + ipa dnszone-del example.com +""") + _(""" + If a global forwarder is configured, all queries for which this server is not + authoritative (e.g. sub.example.com) will be routed to the global forwarder. + Global forwarding configuration can be overridden per-zone. +""") + _(""" + Semantics of forwarding in IPA matches BIND semantics and depends on the type + of zone: + * Master zone: local BIND replies authoritatively to queries for data in + the given zone (including authoritative NXDOMAIN answers) and forwarding + affects only queries for names below zone cuts (NS records) of locally + served zones. + + * Forward zone: forward zone contains no authoritative data. BIND forwards + queries, which cannot be answered from its local cache, to configured + forwarders. +""") + _(""" + Semantics of the --forwarder-policy option: + * none - disable forwarding for the given zone. + * first - forward all queries to configured forwarders. If they fail, + do resolution using DNS root servers. + * only - forward all queries to configured forwarders and if they fail, + return failure. +""") + _(""" + Disable global forwarding for given sub-tree: + ipa dnszone-mod example.com --forward-policy=none +""") + _(""" + This configuration forwards all queries for names outside the example.com + sub-tree to global forwarders. Normal recursive resolution process is used + for names inside the example.com sub-tree (i.e. NS records are followed etc.). +""") + _(""" + Forward all requests for the zone external.example.com to another forwarder + using a "first" policy (it will send the queries to the selected forwarder + and if not answered it will use global root servers): + ipa dnsforwardzone-add external.example.com --forward-policy=first \\ + --forwarder=203.0.113.1 +""") + _(""" + Change forward-policy for external.example.com: + ipa dnsforwardzone-mod external.example.com --forward-policy=only +""") + _(""" + Show forward zone external.example.com: + ipa dnsforwardzone-show external.example.com +""") + _(""" + List all forward zones: + ipa dnsforwardzone-find +""") + _(""" + Delete forward zone external.example.com: + ipa dnsforwardzone-del external.example.com +""") + _(""" + Resolve a host name to see if it exists (will add default IPA domain + if one is not included): + ipa dns-resolve www.example.com + ipa dns-resolve www +""") + _(""" + +GLOBAL DNS CONFIGURATION +""") + _(""" +DNS configuration passed to command line install script is stored in a local +configuration file on each IPA server where DNS service is configured. These +local settings can be overridden with a common configuration stored in LDAP +server: +""") + _(""" + Show global DNS configuration: + ipa dnsconfig-show +""") + _(""" + Modify global DNS configuration and set a list of global forwarders: + ipa dnsconfig-mod --forwarder=203.0.113.113 +""") + +register = Registry() + +# supported resource record types +_record_types = ( + u'A', u'AAAA', u'A6', u'AFSDB', u'APL', u'CERT', u'CNAME', u'DHCID', u'DLV', + u'DNAME', u'DS', u'HIP', u'HINFO', u'IPSECKEY', u'KEY', u'KX', u'LOC', + u'MD', u'MINFO', u'MX', u'NAPTR', u'NS', u'NSEC', u'NXT', u'PTR', u'RRSIG', + u'RP', u'SIG', u'SPF', u'SRV', u'SSHFP', u'TLSA', u'TXT', +) + +# DNS zone record identificator +_dns_zone_record = DNSName.empty + +# attributes derived from record types +_record_attributes = [str(record_name_format % t.lower()) + for t in _record_types] + +# Deprecated +# supported DNS classes, IN = internet, rest is almost never used +_record_classes = (u'IN', u'CS', u'CH', u'HS') + +# IN record class +_IN = dns.rdataclass.IN + +# NS record type +_NS = dns.rdatatype.from_text('NS') + +_output_permissions = ( + output.summary, + output.Output('result', bool, _('True means the operation was successful')), + output.Output('value', unicode, _('Permission value')), +) + + +def _rname_validator(ugettext, zonemgr): + try: + DNSName(zonemgr) # test only if it is valid domain name + except (ValueError, dns.exception.SyntaxError) as e: + return unicode(e) + return None + +def _create_zone_serial(): + """ + Generate serial number for zones. bind-dyndb-ldap expects unix time in + to be used for SOA serial. + + SOA serial in a date format would also work, but it may be set to far + future when many DNS updates are done per day (more than 100). Unix + timestamp is more resilient to this issue. + """ + return int(time.time()) + +def _reverse_zone_name(netstr): + try: + netaddr.IPAddress(str(netstr)) + except (netaddr.AddrFormatError, ValueError): + pass + else: + # use more sensible default prefix than netaddr default + return unicode(get_reverse_zone_default(netstr)) + + net = netaddr.IPNetwork(netstr) + items = net.ip.reverse_dns.split('.') + if net.version == 4: + return u'.'.join(items[4 - net.prefixlen // 8:]) + elif net.version == 6: + return u'.'.join(items[32 - net.prefixlen // 4:]) + else: + return None + +def _validate_ipaddr(ugettext, ipaddr, ip_version=None): + try: + ip = netaddr.IPAddress(str(ipaddr), flags=netaddr.INET_PTON) + + if ip_version is not None: + if ip.version != ip_version: + return _('invalid IP address version (is %(value)d, must be %(required_value)d)!') \ + % dict(value=ip.version, required_value=ip_version) + except (netaddr.AddrFormatError, ValueError): + return _('invalid IP address format') + return None + +def _validate_ip4addr(ugettext, ipaddr): + return _validate_ipaddr(ugettext, ipaddr, 4) + +def _validate_ip6addr(ugettext, ipaddr): + return _validate_ipaddr(ugettext, ipaddr, 6) + +def _validate_ipnet(ugettext, ipnet): + try: + net = netaddr.IPNetwork(ipnet) + except (netaddr.AddrFormatError, ValueError, UnboundLocalError): + return _('invalid IP network format') + return None + +def _validate_bind_aci(ugettext, bind_acis): + if not bind_acis: + return + + bind_acis = bind_acis.split(';') + if bind_acis[-1]: + return _('each ACL element must be terminated with a semicolon') + else: + bind_acis.pop(-1) + + for bind_aci in bind_acis: + if bind_aci in ("any", "none", "localhost", "localnets"): + continue + + if bind_aci.startswith('!'): + bind_aci = bind_aci[1:] + + try: + ip = CheckedIPAddress(bind_aci, parse_netmask=True, + allow_network=True, allow_loopback=True) + except (netaddr.AddrFormatError, ValueError) as e: + return unicode(e) + except UnboundLocalError: + return _(u"invalid address format") + +def _normalize_bind_aci(bind_acis): + if not bind_acis: + return + bind_acis = bind_acis.split(';') + normalized = [] + for bind_aci in bind_acis: + if not bind_aci: + continue + if bind_aci in ("any", "none", "localhost", "localnets"): + normalized.append(bind_aci) + continue + + prefix = "" + if bind_aci.startswith('!'): + bind_aci = bind_aci[1:] + prefix = "!" + + try: + ip = CheckedIPAddress(bind_aci, parse_netmask=True, + allow_network=True, allow_loopback=True) + if '/' in bind_aci: # addr with netmask + netmask = "/%s" % ip.prefixlen + else: + netmask = "" + normalized.append(u"%s%s%s" % (prefix, str(ip), netmask)) + continue + except Exception: + normalized.append(bind_aci) + continue + + acis = u';'.join(normalized) + acis += u';' + return acis + +def _validate_bind_forwarder(ugettext, forwarder): + ip_address, sep, port = forwarder.partition(u' port ') + + ip_address_validation = _validate_ipaddr(ugettext, ip_address) + + if ip_address_validation is not None: + return ip_address_validation + + if sep: + try: + port = int(port) + if port < 0 or port > 65535: + raise ValueError() + except ValueError: + return _('%(port)s is not a valid port' % dict(port=port)) + + return None + +def _validate_nsec3param_record(ugettext, value): + _nsec3param_pattern = (r'^(?P<alg>\d+) (?P<flags>\d+) (?P<iter>\d+) ' + r'(?P<salt>([0-9a-fA-F]{2})+|-)$') + rec = re.compile(_nsec3param_pattern, flags=re.U) + result = rec.match(value) + + if result is None: + return _(u'expected format: <0-255> <0-255> <0-65535> ' + 'even-length_hexadecimal_digits_or_hyphen') + + alg = int(result.group('alg')) + flags = int(result.group('flags')) + iterations = int(result.group('iter')) + salt = result.group('salt') + + if alg > 255: + return _('algorithm value: allowed interval 0-255') + + if flags > 255: + return _('flags value: allowed interval 0-255') + + if iterations > 65535: + return _('iterations value: allowed interval 0-65535') + + if salt == u'-': + return None + + try: + binascii.a2b_hex(salt) + except TypeError as e: + return _('salt value: %(err)s') % {'err': e} + return None + + +def _hostname_validator(ugettext, value): + assert isinstance(value, DNSName) + if len(value.make_absolute().labels) < 3: + return _('invalid domain-name: not fully qualified') + + return None + +def _no_wildcard_validator(ugettext, value): + """Disallow usage of wildcards as RFC 4592 section 4 recommends + """ + assert isinstance(value, DNSName) + if value.is_wild(): + return _('should not be a wildcard domain name (RFC 4592 section 4)') + return None + +def is_forward_record(zone, str_address): + addr = netaddr.IPAddress(str_address) + if addr.version == 4: + result = api.Command['dnsrecord_find'](zone, arecord=str_address) + elif addr.version == 6: + result = api.Command['dnsrecord_find'](zone, aaaarecord=str_address) + else: + raise ValueError('Invalid address family') + + return result['count'] > 0 + +def add_forward_record(zone, name, str_address): + addr = netaddr.IPAddress(str_address) + try: + if addr.version == 4: + api.Command['dnsrecord_add'](zone, name, arecord=str_address) + elif addr.version == 6: + api.Command['dnsrecord_add'](zone, name, aaaarecord=str_address) + else: + raise ValueError('Invalid address family') + except errors.EmptyModlist: + pass # the entry already exists and matches + +def get_reverse_zone(ipaddr): + """ + resolve the reverse zone for IP address and see if it is managed by IPA + server + :param ipaddr: host IP address + :return: tuple containing name of the reverse zone and the name of the + record + """ + ip = netaddr.IPAddress(str(ipaddr)) + revdns = DNSName(unicode(ip.reverse_dns)) + revzone = DNSName(dns.resolver.zone_for_name(revdns)) + + try: + api.Command['dnszone_show'](revzone) + except errors.NotFound: + raise errors.NotFound( + reason=_( + 'DNS reverse zone %(revzone)s for IP address ' + '%(addr)s is not managed by this server') % dict( + addr=ipaddr, revzone=revzone) + ) + + revname = revdns.relativize(revzone) + + return revzone, revname + +def add_records_for_host_validation(option_name, host, domain, ip_addresses, check_forward=True, check_reverse=True): + assert isinstance(host, DNSName) + assert isinstance(domain, DNSName) + + try: + api.Command['dnszone_show'](domain)['result'] + except errors.NotFound: + raise errors.NotFound( + reason=_('DNS zone %(zone)s not found') % dict(zone=domain) + ) + if not isinstance(ip_addresses, (tuple, list)): + ip_addresses = [ip_addresses] + + for ip_address in ip_addresses: + try: + ip = CheckedIPAddress(ip_address, match_local=False) + except Exception as e: + raise errors.ValidationError(name=option_name, error=unicode(e)) + + if check_forward: + if is_forward_record(domain, unicode(ip)): + raise errors.DuplicateEntry( + message=_(u'IP address %(ip)s is already assigned in domain %(domain)s.')\ + % dict(ip=str(ip), domain=domain)) + + if check_reverse: + try: + # we prefer lookup of the IP through the reverse zone + revzone, revname = get_reverse_zone(ip) + reverse = api.Command['dnsrecord_find'](revzone, idnsname=revname) + if reverse['count'] > 0: + raise errors.DuplicateEntry( + message=_(u'Reverse record for IP address %(ip)s already exists in reverse zone %(zone)s.')\ + % dict(ip=str(ip), zone=revzone)) + except errors.NotFound: + pass + + +def add_records_for_host(host, domain, ip_addresses, add_forward=True, add_reverse=True): + assert isinstance(host, DNSName) + assert isinstance(domain, DNSName) + + if not isinstance(ip_addresses, (tuple, list)): + ip_addresses = [ip_addresses] + + for ip_address in ip_addresses: + ip = CheckedIPAddress(ip_address, match_local=False) + + if add_forward: + add_forward_record(domain, host, unicode(ip)) + + if add_reverse: + try: + revzone, revname = get_reverse_zone(ip) + addkw = {'ptrrecord': host.derelativize(domain).ToASCII()} + api.Command['dnsrecord_add'](revzone, revname, **addkw) + except errors.EmptyModlist: + # the entry already exists and matches + pass + +def _dns_name_to_string(value, raw=False): + if isinstance(value, unicode): + try: + value = DNSName(value) + except Exception: + return value + + assert isinstance(value, DNSName) + if raw: + return value.ToASCII() + else: + return unicode(value) + + +def _check_entry_objectclass(entry, objectclasses): + """ + Check if entry contains all objectclasses + """ + if not isinstance(objectclasses, (list, tuple)): + objectclasses = [objectclasses, ] + if not entry.get('objectclass'): + return False + entry_objectclasses = [o.lower() for o in entry['objectclass']] + for o in objectclasses: + if o not in entry_objectclasses: + return False + return True + + +def _check_DN_objectclass(ldap, dn, objectclasses): + try: + entry = ldap.get_entry(dn, [u'objectclass', ]) + except Exception: + return False + else: + return _check_entry_objectclass(entry, objectclasses) + + +class DNSRecord(Str): + # a list of parts that create the actual raw DNS record + parts = None + # an optional list of parameters used in record-specific operations + extra = None + supported = True + # supported RR types: https://fedorahosted.org/bind-dyndb-ldap/browser/doc/schema + + label_format = _("%s record") + part_label_format = "%s %s" + doc_format = _('Raw %s records') + option_group_format = _('%s Record') + see_rfc_msg = _("(see RFC %s for details)") + part_name_format = "%s_part_%s" + extra_name_format = "%s_extra_%s" + cli_name_format = "%s_%s" + format_error_msg = None + + kwargs = Str.kwargs + ( + ('validatedns', bool, True), + ('normalizedns', bool, True), + ) + + # should be replaced in subclasses + rrtype = None + rfc = None + + def __init__(self, name=None, *rules, **kw): + if self.rrtype not in _record_types: + raise ValueError("Unknown RR type: %s. Must be one of %s" % \ + (str(self.rrtype), ", ".join(_record_types))) + if not name: + name = "%s*" % (record_name_format % self.rrtype.lower()) + kw.setdefault('cli_name', '%s_rec' % self.rrtype.lower()) + kw.setdefault('label', self.label_format % self.rrtype) + kw.setdefault('doc', self.doc_format % self.rrtype) + kw.setdefault('option_group', self.option_group_format % self.rrtype) + + if not self.supported: + kw['flags'] = ('no_option',) + + super(DNSRecord, self).__init__(name, *rules, **kw) + + def _get_part_values(self, value): + values = value.split() + if len(values) != len(self.parts): + return None + return tuple(values) + + def _part_values_to_string(self, values, idna=True): + self._validate_parts(values) + parts = [] + for v in values: + if v is None: + continue + elif isinstance(v, DNSName) and idna: + v = v.ToASCII() + elif not isinstance(v, unicode): + v = unicode(v) + parts.append(v) + + return u" ".join(parts) + + def get_parts_from_kw(self, kw, raise_on_none=True): + part_names = tuple(self.part_name_format % (self.rrtype.lower(), part.name) \ + for part in self.parts) + vals = tuple(kw.get(part_name) for part_name in part_names) + + if all(val is None for val in vals): + return + + if raise_on_none: + for val_id,val in enumerate(vals): + if val is None and self.parts[val_id].required: + cli_name = self.cli_name_format % (self.rrtype.lower(), self.parts[val_id].name) + raise errors.ConversionError(name=self.name, + error=_("'%s' is a required part of DNS record") % cli_name) + + return vals + + def _validate_parts(self, parts): + if len(parts) != len(self.parts): + raise errors.ValidationError(name=self.name, + error=_("Invalid number of parts!")) + + def _convert_scalar(self, value, index=None): + if isinstance(value, (tuple, list)): + return self._part_values_to_string(value) + return super(DNSRecord, self)._convert_scalar(value) + + def normalize(self, value): + if self.normalizedns: + if isinstance(value, (tuple, list)): + value = tuple( + self._normalize_parts(v) for v in value \ + if v is not None + ) + elif value is not None: + value = (self._normalize_parts(value),) + + return super(DNSRecord, self).normalize(value) + + def _normalize_parts(self, value): + """ + Normalize a DNS record value using normalizers for its parts. + """ + if self.parts is None: + return value + try: + values = self._get_part_values(value) + if not values: + return value + + converted_values = [ part._convert_scalar(values[part_id]) \ + if values[part_id] is not None else None + for part_id, part in enumerate(self.parts) + ] + + new_values = [ part.normalize(converted_values[part_id]) \ + for part_id, part in enumerate(self.parts) ] + + value = self._convert_scalar(new_values) + except Exception: + # cannot normalize, rather return original value than fail + pass + return value + + def _rule_validatedns(self, _, value): + if not self.validatedns: + return + + if value is None: + return + + if not self.supported: + return _('DNS RR type "%s" is not supported by bind-dyndb-ldap plugin') \ + % self.rrtype + + if self.parts is None: + return + + # validate record format + values = self._get_part_values(value) + if not values: + if not self.format_error_msg: + part_names = [part.name.upper() for part in self.parts] + + if self.rfc: + see_rfc_msg = " " + self.see_rfc_msg % self.rfc + else: + see_rfc_msg = "" + return _('format must be specified as "%(format)s" %(rfcs)s') \ + % dict(format=" ".join(part_names), rfcs=see_rfc_msg) + else: + return self.format_error_msg + + # validate every part + for part_id, part in enumerate(self.parts): + val = part.normalize(values[part_id]) + val = part.convert(val) + part.validate(val) + return None + + def _convert_dnsrecord_part(self, part): + """ + All parts of DNSRecord need to be processed and modified before they + can be added to global DNS API. For example a prefix need to be added + before part name so that the name is unique in the global namespace. + """ + name = self.part_name_format % (self.rrtype.lower(), part.name) + cli_name = self.cli_name_format % (self.rrtype.lower(), part.name) + label = self.part_label_format % (self.rrtype, unicode(part.label)) + option_group = self.option_group_format % self.rrtype + flags = list(part.flags) + ['dnsrecord_part', 'virtual_attribute',] + if not part.required: + flags.append('dnsrecord_optional') + if not self.supported: + flags.append("no_option") + + return part.clone_rename(name, + cli_name=cli_name, + label=label, + required=False, + option_group=option_group, + flags=flags, + hint=self.name,) # name of parent RR param + + def _convert_dnsrecord_extra(self, extra): + """ + Parameters for special per-type behavior need to be processed in the + same way as record parts in _convert_dnsrecord_part(). + """ + name = self.extra_name_format % (self.rrtype.lower(), extra.name) + cli_name = self.cli_name_format % (self.rrtype.lower(), extra.name) + label = self.part_label_format % (self.rrtype, unicode(extra.label)) + option_group = self.option_group_format % self.rrtype + flags = list(extra.flags) + ['dnsrecord_extra', 'virtual_attribute',] + + return extra.clone_rename(name, + cli_name=cli_name, + label=label, + required=False, + option_group=option_group, + flags=flags, + hint=self.name,) # name of parent RR param + + def get_parts(self): + if self.parts is None: + return tuple() + + return tuple(self._convert_dnsrecord_part(part) for part in self.parts) + + def get_extra(self): + if self.extra is None: + return tuple() + + return tuple(self._convert_dnsrecord_extra(extra) for extra in self.extra) + + # callbacks for per-type special record behavior + def dnsrecord_add_pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options): + assert isinstance(dn, DN) + + def dnsrecord_add_post_callback(self, ldap, dn, entry_attrs, *keys, **options): + assert isinstance(dn, DN) + +class ForwardRecord(DNSRecord): + extra = ( + Flag('create_reverse?', + label=_('Create reverse'), + doc=_('Create reverse record for this IP Address'), + flags=['no_update'] + ), + ) + + def dnsrecord_add_pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options): + assert isinstance(dn, DN) + reverse_option = self._convert_dnsrecord_extra(self.extra[0]) + if options.get(reverse_option.name): + records = entry_attrs.get(self.name, []) + if not records: + # --<rrtype>-create-reverse is set, but there are not records + raise errors.RequirementError(name=self.name) + + for record in records: + add_records_for_host_validation(self.name, keys[-1], keys[-2], record, + check_forward=False, + check_reverse=True) + + setattr(context, '%s_reverse' % self.name, entry_attrs.get(self.name)) + + def dnsrecord_add_post_callback(self, ldap, dn, entry_attrs, *keys, **options): + assert isinstance(dn, DN) + rev_records = getattr(context, '%s_reverse' % self.name, []) + + if rev_records: + # make sure we don't run this post callback action again in nested + # commands, line adding PTR record in add_records_for_host + delattr(context, '%s_reverse' % self.name) + for record in rev_records: + try: + add_records_for_host(keys[-1], keys[-2], record, + add_forward=False, add_reverse=True) + except Exception as e: + raise errors.NonFatalError( + reason=_('Cannot create reverse record for "%(value)s": %(exc)s') \ + % dict(value=record, exc=unicode(e))) + +class UnsupportedDNSRecord(DNSRecord): + """ + Records which are not supported by IPA CLI, but we allow to show them if + LDAP contains these records. + """ + supported = False + + def _get_part_values(self, value): + return tuple() + + +class ARecord(ForwardRecord): + rrtype = 'A' + rfc = 1035 + parts = ( + Str('ip_address', + _validate_ip4addr, + label=_('IP Address'), + ), + ) + +class A6Record(DNSRecord): + rrtype = 'A6' + rfc = 3226 + parts = ( + Str('data', + label=_('Record data'), + ), + ) + + def _get_part_values(self, value): + # A6 RR type is obsolete and only a raw interface is provided + return (value,) + +class AAAARecord(ForwardRecord): + rrtype = 'AAAA' + rfc = 3596 + parts = ( + Str('ip_address', + _validate_ip6addr, + label=_('IP Address'), + ), + ) + +class AFSDBRecord(DNSRecord): + rrtype = 'AFSDB' + rfc = 1183 + parts = ( + Int('subtype?', + label=_('Subtype'), + minvalue=0, + maxvalue=65535, + ), + DNSNameParam('hostname', + label=_('Hostname'), + ), + ) + +class APLRecord(UnsupportedDNSRecord): + rrtype = 'APL' + rfc = 3123 + +class CERTRecord(DNSRecord): + rrtype = 'CERT' + rfc = 4398 + parts = ( + Int('type', + label=_('Certificate Type'), + minvalue=0, + maxvalue=65535, + ), + Int('key_tag', + label=_('Key Tag'), + minvalue=0, + maxvalue=65535, + ), + Int('algorithm', + label=_('Algorithm'), + minvalue=0, + maxvalue=255, + ), + Str('certificate_or_crl', + label=_('Certificate/CRL'), + ), + ) + +class CNAMERecord(DNSRecord): + rrtype = 'CNAME' + rfc = 1035 + parts = ( + DNSNameParam('hostname', + label=_('Hostname'), + doc=_('A hostname which this alias hostname points to'), + ), + ) + +class DHCIDRecord(UnsupportedDNSRecord): + rrtype = 'DHCID' + rfc = 4701 + +class DNAMERecord(DNSRecord): + rrtype = 'DNAME' + rfc = 2672 + parts = ( + DNSNameParam('target', + label=_('Target'), + ), + ) + + +class DSRecord(DNSRecord): + rrtype = 'DS' + rfc = 4034 + parts = ( + Int('key_tag', + label=_('Key Tag'), + minvalue=0, + maxvalue=65535, + ), + Int('algorithm', + label=_('Algorithm'), + minvalue=0, + maxvalue=255, + ), + Int('digest_type', + label=_('Digest Type'), + minvalue=0, + maxvalue=255, + ), + Str('digest', + label=_('Digest'), + pattern=r'^[0-9a-fA-F]+$', + pattern_errmsg=u'only hexadecimal digits are allowed' + ), + ) + + +class DLVRecord(DSRecord): + # must use same attributes as DSRecord + rrtype = 'DLV' + rfc = 4431 + + +class HINFORecord(UnsupportedDNSRecord): + rrtype = 'HINFO' + rfc = 1035 + + +class HIPRecord(UnsupportedDNSRecord): + rrtype = 'HIP' + rfc = 5205 + +class KEYRecord(UnsupportedDNSRecord): + # managed by BIND itself + rrtype = 'KEY' + rfc = 2535 + +class IPSECKEYRecord(UnsupportedDNSRecord): + rrtype = 'IPSECKEY' + rfc = 4025 + +class KXRecord(DNSRecord): + rrtype = 'KX' + rfc = 2230 + parts = ( + Int('preference', + label=_('Preference'), + doc=_('Preference given to this exchanger. Lower values are more preferred'), + minvalue=0, + maxvalue=65535, + ), + DNSNameParam('exchanger', + label=_('Exchanger'), + doc=_('A host willing to act as a key exchanger'), + ), + ) + +class LOCRecord(DNSRecord): + rrtype = 'LOC' + rfc = 1876 + parts = ( + Int('lat_deg', + label=_('Degrees Latitude'), + minvalue=0, + maxvalue=90, + ), + Int('lat_min?', + label=_('Minutes Latitude'), + minvalue=0, + maxvalue=59, + ), + Decimal('lat_sec?', + label=_('Seconds Latitude'), + minvalue='0.0', + maxvalue='59.999', + precision=3, + ), + StrEnum('lat_dir', + label=_('Direction Latitude'), + values=(u'N', u'S',), + ), + Int('lon_deg', + label=_('Degrees Longitude'), + minvalue=0, + maxvalue=180, + ), + Int('lon_min?', + label=_('Minutes Longitude'), + minvalue=0, + maxvalue=59, + ), + Decimal('lon_sec?', + label=_('Seconds Longitude'), + minvalue='0.0', + maxvalue='59.999', + precision=3, + ), + StrEnum('lon_dir', + label=_('Direction Longitude'), + values=(u'E', u'W',), + ), + Decimal('altitude', + label=_('Altitude'), + minvalue='-100000.00', + maxvalue='42849672.95', + precision=2, + ), + Decimal('size?', + label=_('Size'), + minvalue='0.0', + maxvalue='90000000.00', + precision=2, + ), + Decimal('h_precision?', + label=_('Horizontal Precision'), + minvalue='0.0', + maxvalue='90000000.00', + precision=2, + ), + Decimal('v_precision?', + label=_('Vertical Precision'), + minvalue='0.0', + maxvalue='90000000.00', + precision=2, + ), + ) + + format_error_msg = _("""format must be specified as + "d1 [m1 [s1]] {"N"|"S"} d2 [m2 [s2]] {"E"|"W"} alt["m"] [siz["m"] [hp["m"] [vp["m"]]]]" + where: + d1: [0 .. 90] (degrees latitude) + d2: [0 .. 180] (degrees longitude) + m1, m2: [0 .. 59] (minutes latitude/longitude) + s1, s2: [0 .. 59.999] (seconds latitude/longitude) + alt: [-100000.00 .. 42849672.95] BY .01 (altitude in meters) + siz, hp, vp: [0 .. 90000000.00] (size/precision in meters) + See RFC 1876 for details""") + + def _get_part_values(self, value): + regex = re.compile( + r'(?P<d1>\d{1,2}\s+)' + r'(?:(?P<m1>\d{1,2}\s+)' + r'(?P<s1>\d{1,2}(?:\.\d{1,3})?\s+)?)?' + r'(?P<dir1>[NS])\s+' + r'(?P<d2>\d{1,3}\s+)' + r'(?:(?P<m2>\d{1,2}\s+)' + r'(?P<s2>\d{1,2}(?:\.\d{1,3})?\s+)?)?' + r'(?P<dir2>[WE])\s+' + r'(?P<alt>-?\d{1,8}(?:\.\d{1,2})?)m?' + r'(?:\s+(?P<siz>\d{1,8}(?:\.\d{1,2})?)m?' + r'(?:\s+(?P<hp>\d{1,8}(?:\.\d{1,2})?)m?' + r'(?:\s+(?P<vp>\d{1,8}(?:\.\d{1,2})?)m?\s*)?)?)?$') + + m = regex.match(value) + + if m is None: + return None + + return tuple(x.strip() if x is not None else x for x in m.groups()) + + def _validate_parts(self, parts): + super(LOCRecord, self)._validate_parts(parts) + + # create part_name -> part_id map first + part_name_map = dict((part.name, part_id) \ + for part_id,part in enumerate(self.parts)) + + requirements = ( ('lat_sec', 'lat_min'), + ('lon_sec', 'lon_min'), + ('h_precision', 'size'), + ('v_precision', 'h_precision', 'size') ) + + for req in requirements: + target_part = req[0] + + if parts[part_name_map[target_part]] is not None: + required_parts = req[1:] + if any(parts[part_name_map[part]] is None for part in required_parts): + target_cli_name = self.cli_name_format % (self.rrtype.lower(), req[0]) + required_cli_names = [ self.cli_name_format % (self.rrtype.lower(), part) + for part in req[1:] ] + error = _("'%(required)s' must not be empty when '%(name)s' is set") % \ + dict(required=', '.join(required_cli_names), + name=target_cli_name) + raise errors.ValidationError(name=self.name, error=error) + + +class MDRecord(UnsupportedDNSRecord): + # obsoleted, use MX instead + rrtype = 'MD' + rfc = 1035 + + +class MINFORecord(UnsupportedDNSRecord): + rrtype = 'MINFO' + rfc = 1035 + + +class MXRecord(DNSRecord): + rrtype = 'MX' + rfc = 1035 + parts = ( + Int('preference', + label=_('Preference'), + doc=_('Preference given to this exchanger. Lower values are more preferred'), + minvalue=0, + maxvalue=65535, + ), + DNSNameParam('exchanger', + label=_('Exchanger'), + doc=_('A host willing to act as a mail exchanger'), + ), + ) + +class NSRecord(DNSRecord): + rrtype = 'NS' + rfc = 1035 + + parts = ( + DNSNameParam('hostname', + label=_('Hostname'), + ), + ) + +class NSECRecord(UnsupportedDNSRecord): + # managed by BIND itself + rrtype = 'NSEC' + rfc = 4034 + + +def _validate_naptr_flags(ugettext, flags): + allowed_flags = u'SAUP' + flags = flags.replace('"','').replace('\'','') + + for flag in flags: + if flag not in allowed_flags: + return _('flags must be one of "S", "A", "U", or "P"') + +class NAPTRRecord(DNSRecord): + rrtype = 'NAPTR' + rfc = 2915 + + parts = ( + Int('order', + label=_('Order'), + minvalue=0, + maxvalue=65535, + ), + Int('preference', + label=_('Preference'), + minvalue=0, + maxvalue=65535, + ), + Str('flags', + _validate_naptr_flags, + label=_('Flags'), + normalizer=lambda x:x.upper() + ), + Str('service', + label=_('Service'), + ), + Str('regexp', + label=_('Regular Expression'), + ), + Str('replacement', + label=_('Replacement'), + ), + ) + + +class NXTRecord(UnsupportedDNSRecord): + rrtype = 'NXT' + rfc = 2535 + + +class PTRRecord(DNSRecord): + rrtype = 'PTR' + rfc = 1035 + parts = ( + DNSNameParam('hostname', + #RFC 2317 section 5.2 -- can be relative + label=_('Hostname'), + doc=_('The hostname this reverse record points to'), + ), + ) + +class RPRecord(UnsupportedDNSRecord): + rrtype = 'RP' + rfc = 1183 + +class SRVRecord(DNSRecord): + rrtype = 'SRV' + rfc = 2782 + parts = ( + Int('priority', + label=_('Priority'), + minvalue=0, + maxvalue=65535, + ), + Int('weight', + label=_('Weight'), + minvalue=0, + maxvalue=65535, + ), + Int('port', + label=_('Port'), + minvalue=0, + maxvalue=65535, + ), + DNSNameParam('target', + label=_('Target'), + doc=_('The domain name of the target host or \'.\' if the service is decidedly not available at this domain'), + ), + ) + +def _sig_time_validator(ugettext, value): + time_format = "%Y%m%d%H%M%S" + try: + time.strptime(value, time_format) + except ValueError: + return _('the value does not follow "YYYYMMDDHHMMSS" time format') + + +class SIGRecord(UnsupportedDNSRecord): + # managed by BIND itself + rrtype = 'SIG' + rfc = 2535 + +class SPFRecord(UnsupportedDNSRecord): + rrtype = 'SPF' + rfc = 4408 + +class RRSIGRecord(UnsupportedDNSRecord): + # managed by BIND itself + rrtype = 'RRSIG' + rfc = 4034 + +class SSHFPRecord(DNSRecord): + rrtype = 'SSHFP' + rfc = 4255 + parts = ( + Int('algorithm', + label=_('Algorithm'), + minvalue=0, + maxvalue=255, + ), + Int('fp_type', + label=_('Fingerprint Type'), + minvalue=0, + maxvalue=255, + ), + Str('fingerprint', + label=_('Fingerprint'), + ), + ) + + def _get_part_values(self, value): + # fingerprint part can contain space in LDAP, return it as one part + values = value.split(None, 2) + if len(values) != len(self.parts): + return None + return tuple(values) + + +class TLSARecord(DNSRecord): + rrtype = 'TLSA' + rfc = 6698 + parts = ( + Int('cert_usage', + label=_('Certificate Usage'), + minvalue=0, + maxvalue=255, + ), + Int('selector', + label=_('Selector'), + minvalue=0, + maxvalue=255, + ), + Int('matching_type', + label=_('Matching Type'), + minvalue=0, + maxvalue=255, + ), + Str('cert_association_data', + label=_('Certificate Association Data'), + ), + ) + + +class TXTRecord(DNSRecord): + rrtype = 'TXT' + rfc = 1035 + parts = ( + Str('data', + label=_('Text Data'), + ), + ) + + def _get_part_values(self, value): + # ignore any space in TXT record + return (value,) + +_dns_records = ( + ARecord(), + AAAARecord(), + A6Record(), + AFSDBRecord(), + APLRecord(), + CERTRecord(), + CNAMERecord(), + DHCIDRecord(), + DLVRecord(), + DNAMERecord(), + DSRecord(), + HIPRecord(), + IPSECKEYRecord(), + KEYRecord(), + KXRecord(), + LOCRecord(), + MXRecord(), + NAPTRRecord(), + NSRecord(), + NSECRecord(), + PTRRecord(), + RRSIGRecord(), + RPRecord(), + SIGRecord(), + SPFRecord(), + SRVRecord(), + SSHFPRecord(), + TLSARecord(), + TXTRecord(), +) + +def __dns_record_options_iter(): + for opt in (Any('dnsrecords?', + label=_('Records'), + flags=['no_create', 'no_search', 'no_update'],), + Str('dnstype?', + label=_('Record type'), + flags=['no_create', 'no_search', 'no_update'],), + Str('dnsdata?', + label=_('Record data'), + flags=['no_create', 'no_search', 'no_update'],)): + # These 3 options are used in --structured format. They are defined + # rather in takes_params than has_output_params because of their + # order - they should be printed to CLI before any DNS part param + yield opt + for option in _dns_records: + yield option + + for part in option.get_parts(): + yield part + + for extra in option.get_extra(): + yield extra + +_dns_record_options = tuple(__dns_record_options_iter()) + + +def check_ns_rec_resolvable(zone, name, log): + assert isinstance(zone, DNSName) + assert isinstance(name, DNSName) + + if name.is_empty(): + name = zone.make_absolute() + elif not name.is_absolute(): + # this is a DNS name relative to the zone + name = name.derelativize(zone.make_absolute()) + try: + verify_host_resolvable(name) + except errors.DNSNotARecordError: + raise errors.NotFound( + reason=_('Nameserver \'%(host)s\' does not have a corresponding ' + 'A/AAAA record') % {'host': name} + ) + +def dns_container_exists(ldap): + try: + ldap.get_entry(DN(api.env.container_dns, api.env.basedn), []) + except errors.NotFound: + return False + return True + + +def dnssec_installed(ldap): + """ + * Method opendnssecinstance.get_dnssec_key_masters() CANNOT be used in the + dns plugin, or any plugin accessible for common users! * + Why?: The content of service container is not readable for common users. + + This method only try to find if a DNSSEC service container exists on any + replica. What means that DNSSEC key master is installed. + :param ldap: ldap connection + :return: True if DNSSEC was installed, otherwise False + """ + dn = DN(api.env.container_masters, api.env.basedn) + + filter_attrs = { + u'cn': u'DNSSEC', + u'objectclass': u'ipaConfigObject', + } + only_masters_f = ldap.make_filter(filter_attrs, rules=ldap.MATCH_ALL) + + try: + ldap.find_entries(filter=only_masters_f, base_dn=dn) + except errors.NotFound: + return False + return True + + +def default_zone_update_policy(zone): + if zone.is_reverse(): + return get_dns_reverse_zone_update_policy(api.env.realm, zone.ToASCII()) + else: + return get_dns_forward_zone_update_policy(api.env.realm) + +dnszone_output_params = ( + Str('managedby', + label=_('Managedby permission'), + ), +) + + +def _convert_to_idna(value): + """ + Function converts a unicode value to idna, without extra validation. + If conversion fails, None is returned + """ + assert isinstance(value, unicode) + + try: + idna_val = value + start_dot = u'' + end_dot = u'' + if idna_val.startswith(u'.'): + idna_val = idna_val[1:] + start_dot = u'.' + if idna_val.endswith(u'.'): + idna_val = idna_val[:-1] + end_dot = u'.' + idna_val = encodings.idna.nameprep(idna_val) + idna_val = re.split(r'(?<!\\)\.', idna_val) + idna_val = u'%s%s%s' % (start_dot, + u'.'.join(encodings.idna.ToASCII(x) + for x in idna_val), + end_dot) + return idna_val + except Exception: + pass + return None + + +def _create_idn_filter(cmd, ldap, term=None, **options): + if term: + #include idna values to search + term_idna = _convert_to_idna(term) + if term_idna and term != term_idna: + term = (term, term_idna) + + search_kw = {} + attr_extra_filters = [] + + for attr, value in cmd.args_options_2_entry(**options).items(): + if not isinstance(value, list): + value = [value] + for i, v in enumerate(value): + if isinstance(v, DNSName): + value[i] = v.ToASCII() + elif attr in map_names_to_records: + record = map_names_to_records[attr] + parts = record._get_part_values(v) + if parts is None: + value[i] = v + continue + try: + value[i] = record._part_values_to_string(parts) + except errors.ValidationError: + value[i] = v + + #create MATCH_ANY filter for multivalue + if len(value) > 1: + f = ldap.make_filter({attr: value}, rules=ldap.MATCH_ANY) + attr_extra_filters.append(f) + else: + search_kw[attr] = value + + if cmd.obj.search_attributes: + search_attrs = cmd.obj.search_attributes + else: + search_attrs = cmd.obj.default_attributes + if cmd.obj.search_attributes_config: + config = ldap.get_ipa_config() + config_attrs = config.get(cmd.obj.search_attributes_config, []) + if len(config_attrs) == 1 and (isinstance(config_attrs[0], + six.string_types)): + search_attrs = config_attrs[0].split(',') + + search_kw['objectclass'] = cmd.obj.object_class + attr_filter = ldap.make_filter(search_kw, rules=ldap.MATCH_ALL) + if attr_extra_filters: + #combine filter if there is any idna value + attr_extra_filters.append(attr_filter) + attr_filter = ldap.combine_filters(attr_extra_filters, + rules=ldap.MATCH_ALL) + + search_kw = {} + for a in search_attrs: + search_kw[a] = term + term_filter = ldap.make_filter(search_kw, exact=False) + + member_filter = cmd.get_member_filter(ldap, **options) + + filter = ldap.combine_filters( + (term_filter, attr_filter, member_filter), rules=ldap.MATCH_ALL + ) + return filter + + +map_names_to_records = {record_name_format % record.rrtype.lower(): record + for record in _dns_records if record.supported} + +def _records_idn_postprocess(record, **options): + for attr in record.keys(): + attr = attr.lower() + try: + param = map_names_to_records[attr] + except KeyError: + continue + if not isinstance(param, DNSRecord): + continue + + part_params = param.get_parts() + rrs = [] + for dnsvalue in record[attr]: + parts = param._get_part_values(dnsvalue) + if parts is None: + continue + parts = list(parts) + try: + for (i, p) in enumerate(parts): + if isinstance(part_params[i], DNSNameParam): + parts[i] = DNSName(p) + rrs.append(param._part_values_to_string(parts, + idna=options.get('raw', False))) + except (errors.ValidationError, errors.ConversionError): + rrs.append(dnsvalue) + record[attr] = rrs + +def _normalize_zone(zone): + if isinstance(zone, unicode): + # normalize only non-IDNA zones + try: + zone.encode('ascii') + except UnicodeError: + pass + else: + return zone.lower() + return zone + + +def _get_auth_zone_ldap(api, name): + """ + Find authoritative zone in LDAP for name. Only active zones are considered. + :param name: + :return: (zone, truncated) + zone: authoritative zone, or None if authoritative zone is not in LDAP + """ + assert isinstance(name, DNSName) + ldap = api.Backend.ldap2 + + # Create all possible parent zone names + search_name = name.make_absolute() + zone_names = [] + for i, name in enumerate(search_name): + zone_name_abs = DNSName(search_name[i:]).ToASCII() + zone_names.append(zone_name_abs) + # compatibility with IPA < 4.0, zone name can be relative + zone_names.append(zone_name_abs[:-1]) + + # Create filters + objectclass_filter = ldap.make_filter({'objectclass':'idnszone'}) + zonenames_filter = ldap.make_filter({'idnsname': zone_names}) + zoneactive_filter = ldap.make_filter({'idnsZoneActive': 'true'}) + complete_filter = ldap.combine_filters( + [objectclass_filter, zonenames_filter, zoneactive_filter], + rules=ldap.MATCH_ALL + ) + + try: + entries, truncated = ldap.find_entries( + filter=complete_filter, + attrs_list=['idnsname'], + base_dn=DN(api.env.container_dns, api.env.basedn), + scope=ldap.SCOPE_ONELEVEL + ) + except errors.NotFound: + return None, False + + # always use absolute zones + matched_auth_zones = [entry.single_value['idnsname'].make_absolute() + for entry in entries] + + # return longest match + return max(matched_auth_zones, key=len), truncated + + +def _get_longest_match_ns_delegation_ldap(api, zone, name): + """ + Searches for deepest delegation for name in LDAP zone. + + NOTE: NS record in zone apex is not considered as delegation. + It returns None if there is no delegation outside of zone apex. + + Example: + zone: example.com. + name: ns.sub.example.com. + + records: + extra.ns.sub.example.com. + sub.example.com. + example.com + + result: sub.example.com. + + :param zone: zone name + :param name: + :return: (match, truncated); + match: delegation name if success, or None if no delegation record exists + """ + assert isinstance(zone, DNSName) + assert isinstance(name, DNSName) + + ldap = api.Backend.ldap2 + + # get zone DN + zone_dn = api.Object.dnszone.get_dn(zone) + + if name.is_absolute(): + relative_record_name = name.relativize(zone.make_absolute()) + else: + relative_record_name = name + + # Name is zone apex + if relative_record_name.is_empty(): + return None, False + + # create list of possible record names + possible_record_names = [DNSName(relative_record_name[i:]).ToASCII() + for i in range(len(relative_record_name))] + + # search filters + name_filter = ldap.make_filter({'idnsname': [possible_record_names]}) + objectclass_filter = ldap.make_filter({'objectclass': 'idnsrecord'}) + complete_filter = ldap.combine_filters( + [name_filter, objectclass_filter], + rules=ldap.MATCH_ALL + ) + + try: + entries, truncated = ldap.find_entries( + filter=complete_filter, + attrs_list=['idnsname', 'nsrecord'], + base_dn=zone_dn, + scope=ldap.SCOPE_ONELEVEL + ) + except errors.NotFound: + return None, False + + matched_records = [] + + # test if entry contains NS records + for entry in entries: + if entry.get('nsrecord'): + matched_records.append(entry.single_value['idnsname']) + + if not matched_records: + return None, truncated + + # return longest match + return max(matched_records, key=len), truncated + + +def _find_subtree_forward_zones_ldap(api, name, child_zones_only=False): + """ + Search for forwardzone <name> and all child forwardzones + Filter: (|(*.<name>.)(<name>.)) + :param name: + :param child_zones_only: search only for child zones + :return: (list of zonenames, truncated), list is empty if no zone found + """ + assert isinstance(name, DNSName) + ldap = api.Backend.ldap2 + + # prepare for filter "*.<name>." + search_name = u".%s" % name.make_absolute().ToASCII() + + # we need to search zone with and without last dot, due compatibility + # with IPA < 4.0 + search_names = [search_name, search_name[:-1]] + + # Create filters + objectclass_filter = ldap.make_filter({'objectclass':'idnsforwardzone'}) + zonenames_filter = ldap.make_filter({'idnsname': search_names}, exact=False, + trailing_wildcard=False) + if not child_zones_only: + # find also zone with exact name + exact_name = name.make_absolute().ToASCII() + # we need to search zone with and without last dot, due compatibility + # with IPA < 4.0 + exact_names = [exact_name, exact_name[-1]] + exact_name_filter = ldap.make_filter({'idnsname': exact_names}) + zonenames_filter = ldap.combine_filters([zonenames_filter, + exact_name_filter]) + + zoneactive_filter = ldap.make_filter({'idnsZoneActive': 'true'}) + complete_filter = ldap.combine_filters( + [objectclass_filter, zonenames_filter, zoneactive_filter], + rules=ldap.MATCH_ALL + ) + + try: + entries, truncated = ldap.find_entries( + filter=complete_filter, + attrs_list=['idnsname'], + base_dn=DN(api.env.container_dns, api.env.basedn), + scope=ldap.SCOPE_ONELEVEL + ) + except errors.NotFound: + return [], False + + result = [entry.single_value['idnsname'].make_absolute() + for entry in entries] + + return result, truncated + + +def _get_zone_which_makes_fw_zone_ineffective(api, fwzonename): + """ + Check if forward zone is effective. + + If parent zone exists as authoritative zone, the forward zone will not + forward queries by default. It is necessary to delegate authority + to forward zone with a NS record. + + Example: + + Forward zone: sub.example.com + Zone: example.com + + Forwarding will not work, because the server thinks it is authoritative + for zone and will return NXDOMAIN + + Adding record: sub.example.com NS ns.sub.example.com. + will delegate authority, and IPA DNS server will forward DNS queries. + + :param fwzonename: forwardzone + :return: (zone, truncated) + zone: None if effective, name of authoritative zone otherwise + """ + assert isinstance(fwzonename, DNSName) + + auth_zone, truncated_zone = _get_auth_zone_ldap(api, fwzonename) + if not auth_zone: + return None, truncated_zone + + delegation_record_name, truncated_ns =\ + _get_longest_match_ns_delegation_ldap(api, auth_zone, fwzonename) + + truncated = truncated_ns or truncated_zone + + if delegation_record_name: + return None, truncated + + return auth_zone, truncated + + +def _add_warning_fw_zone_is_not_effective(api, result, fwzone, version): + """ + Adds warning message to result, if required + """ + authoritative_zone, truncated = \ + _get_zone_which_makes_fw_zone_ineffective(api, fwzone) + if authoritative_zone: + # forward zone is not effective and forwarding will not work + messages.add_message( + version, result, + messages.ForwardzoneIsNotEffectiveWarning( + fwzone=fwzone, authzone=authoritative_zone, + ns_rec=fwzone.relativize(authoritative_zone) + ) + ) + + +def _add_warning_fw_policy_conflict_aez(result, fwzone, **options): + """Warn if forwarding policy conflicts with an automatic empty zone.""" + fwd_policy = result['result'].get(u'idnsforwardpolicy', + dnsforwardzone.default_forward_policy) + if ( + fwd_policy != [u'only'] + and related_to_auto_empty_zone(DNSName(fwzone)) + ): + messages.add_message( + options['version'], result, + messages.DNSForwardPolicyConflictWithEmptyZone() + ) + + +class DNSZoneBase(LDAPObject): + """ + Base class for DNS Zone + """ + container_dn = api.env.container_dns + object_class = ['top'] + possible_objectclasses = ['ipadnszone'] + default_attributes = [ + 'idnsname', 'idnszoneactive', 'idnsforwarders', 'idnsforwardpolicy' + ] + + takes_params = ( + DNSNameParam('idnsname', + _no_wildcard_validator, # RFC 4592 section 4 + only_absolute=True, + cli_name='name', + label=_('Zone name'), + doc=_('Zone name (FQDN)'), + default_from=lambda name_from_ip: _reverse_zone_name(name_from_ip), + normalizer=_normalize_zone, + primary_key=True, + ), + Str('name_from_ip?', _validate_ipnet, + label=_('Reverse zone IP network'), + doc=_('IP network to create reverse zone name from'), + flags=('virtual_attribute',), + ), + Bool('idnszoneactive?', + cli_name='zone_active', + label=_('Active zone'), + doc=_('Is zone active?'), + flags=['no_create', 'no_update'], + attribute=True, + ), + Str('idnsforwarders*', + _validate_bind_forwarder, + cli_name='forwarder', + label=_('Zone forwarders'), + doc=_('Per-zone forwarders. A custom port can be specified ' + 'for each forwarder using a standard format "IP_ADDRESS port PORT"'), + ), + StrEnum('idnsforwardpolicy?', + cli_name='forward_policy', + label=_('Forward policy'), + doc=_('Per-zone conditional forwarding policy. Set to "none" to ' + 'disable forwarding to global forwarder for this zone. In ' + 'that case, conditional zone forwarders are disregarded.'), + values=(u'only', u'first', u'none'), + ), + + ) + + def get_dn(self, *keys, **options): + if not dns_container_exists(self.api.Backend.ldap2): + raise errors.NotFound(reason=_('DNS is not configured')) + + zone = keys[-1] + assert isinstance(zone, DNSName) + assert zone.is_absolute() + zone_a = zone.ToASCII() + + # special case when zone is the root zone ('.') + if zone == DNSName.root: + return super(DNSZoneBase, self).get_dn(zone_a, **options) + + # try first relative name, a new zone has to be added as absolute + # otherwise ObjectViolation is raised + zone_a = zone_a[:-1] + dn = super(DNSZoneBase, self).get_dn(zone_a, **options) + try: + self.backend.get_entry(dn, ['']) + except errors.NotFound: + zone_a = u"%s." % zone_a + dn = super(DNSZoneBase, self).get_dn(zone_a, **options) + + return dn + + def permission_name(self, zone): + assert isinstance(zone, DNSName) + return u"Manage DNS zone %s" % zone.ToASCII() + + def get_name_in_zone(self, zone, hostname): + """ + Get name of a record that is to be added to a new zone. I.e. when + we want to add record "ipa.lab.example.com" in a zone "example.com", + this function should return "ipa.lab". Returns None when record cannot + be added to a zone. Returns '@' when the hostname is the zone record. + """ + assert isinstance(zone, DNSName) + assert zone.is_absolute() + assert isinstance(hostname, DNSName) + + if not hostname.is_absolute(): + return hostname + + if hostname.is_subdomain(zone): + return hostname.relativize(zone) + + return None + + def _remove_permission(self, zone): + permission_name = self.permission_name(zone) + try: + self.api.Command['permission_del'](permission_name, force=True) + except errors.NotFound as e: + if zone == DNSName.root: # special case root zone + raise + # compatibility, older IPA versions which allows to create zone + # without absolute zone name + permission_name_rel = self.permission_name( + zone.relativize(DNSName.root) + ) + try: + self.api.Command['permission_del'](permission_name_rel, + force=True) + except errors.NotFound: + raise e # re-raise original exception + + def _make_zonename_absolute(self, entry_attrs, **options): + """ + Zone names can be relative in IPA < 4.0, make sure we always return + absolute zone name from ldap + """ + if options.get('raw'): + return + + if "idnsname" in entry_attrs: + entry_attrs.single_value['idnsname'] = ( + entry_attrs.single_value['idnsname'].make_absolute()) + + +class DNSZoneBase_add(LDAPCreate): + + takes_options = LDAPCreate.takes_options + ( + Flag('skip_overlap_check', + doc=_('Force DNS zone creation even if it will overlap with ' + 'an existing zone.') + ), + ) + + has_output_params = LDAPCreate.has_output_params + dnszone_output_params + + def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options): + assert isinstance(dn, DN) + + try: + entry = ldap.get_entry(dn) + except errors.NotFound: + pass + else: + if _check_entry_objectclass(entry, self.obj.object_class): + self.obj.handle_duplicate_entry(*keys) + else: + raise errors.DuplicateEntry( + message=_(u'Only one zone type is allowed per zone name') + ) + + entry_attrs['idnszoneactive'] = 'TRUE' + + if not options['skip_overlap_check']: + try: + check_zone_overlap(keys[-1]) + except ValueError as e: + raise errors.InvocationError(e.message) + + return dn + + +class DNSZoneBase_del(LDAPDelete): + + def pre_callback(self, ldap, dn, *nkeys, **options): + assert isinstance(dn, DN) + if not _check_DN_objectclass(ldap, dn, self.obj.object_class): + self.obj.handle_not_found(*nkeys) + return dn + + def post_callback(self, ldap, dn, *keys, **options): + try: + self.obj._remove_permission(keys[-1]) + except errors.NotFound: + pass + + return True + + +class DNSZoneBase_mod(LDAPUpdate): + has_output_params = LDAPUpdate.has_output_params + dnszone_output_params + + def post_callback(self, ldap, dn, entry_attrs, *keys, **options): + assert isinstance(dn, DN) + self.obj._make_zonename_absolute(entry_attrs, **options) + return dn + + +class DNSZoneBase_find(LDAPSearch): + __doc__ = _('Search for DNS zones (SOA records).') + + has_output_params = LDAPSearch.has_output_params + dnszone_output_params + + def args_options_2_params(self, *args, **options): + # FIXME: Check that name_from_ip is valid. This is necessary because + # custom validation rules, including _validate_ipnet, are not + # used when doing a search. Once we have a parameter type for + # IP network objects, this will no longer be necessary, as the + # parameter type will handle the validation itself (see + # <https://fedorahosted.org/freeipa/ticket/2266>). + if 'name_from_ip' in options: + self.obj.params['name_from_ip'](unicode(options['name_from_ip'])) + return super(DNSZoneBase_find, self).args_options_2_params(*args, **options) + + def args_options_2_entry(self, *args, **options): + if 'name_from_ip' in options: + if 'idnsname' not in options: + options['idnsname'] = self.obj.params['idnsname'].get_default(**options) + del options['name_from_ip'] + search_kw = super(DNSZoneBase_find, self).args_options_2_entry(*args, + **options) + name = search_kw.get('idnsname') + if name: + search_kw['idnsname'] = [name, name.relativize(DNSName.root)] + return search_kw + + def pre_callback(self, ldap, filter, attrs_list, base_dn, scope, *args, **options): + assert isinstance(base_dn, DN) + # Check if DNS container exists must be here for find methods + if not dns_container_exists(self.api.Backend.ldap2): + raise errors.NotFound(reason=_('DNS is not configured')) + filter = _create_idn_filter(self, ldap, *args, **options) + return (filter, base_dn, scope) + + def post_callback(self, ldap, entries, truncated, *args, **options): + for entry_attrs in entries: + self.obj._make_zonename_absolute(entry_attrs, **options) + return truncated + + +class DNSZoneBase_show(LDAPRetrieve): + has_output_params = LDAPRetrieve.has_output_params + dnszone_output_params + + def pre_callback(self, ldap, dn, attrs_list, *keys, **options): + assert isinstance(dn, DN) + if not _check_DN_objectclass(ldap, dn, self.obj.object_class): + self.obj.handle_not_found(*keys) + return dn + + def post_callback(self, ldap, dn, entry_attrs, *keys, **options): + assert isinstance(dn, DN) + self.obj._make_zonename_absolute(entry_attrs, **options) + return dn + + +class DNSZoneBase_disable(LDAPQuery): + has_output = output.standard_value + + def execute(self, *keys, **options): + ldap = self.obj.backend + + dn = self.obj.get_dn(*keys, **options) + try: + entry = ldap.get_entry(dn, ['idnszoneactive', 'objectclass']) + except errors.NotFound: + self.obj.handle_not_found(*keys) + + if not _check_entry_objectclass(entry, self.obj.object_class): + self.obj.handle_not_found(*keys) + + entry['idnszoneactive'] = ['FALSE'] + + try: + ldap.update_entry(entry) + except errors.EmptyModlist: + pass + + return dict(result=True, value=pkey_to_value(keys[-1], options)) + + +class DNSZoneBase_enable(LDAPQuery): + has_output = output.standard_value + + def execute(self, *keys, **options): + ldap = self.obj.backend + + dn = self.obj.get_dn(*keys, **options) + try: + entry = ldap.get_entry(dn, ['idnszoneactive', 'objectclass']) + except errors.NotFound: + self.obj.handle_not_found(*keys) + + if not _check_entry_objectclass(entry, self.obj.object_class): + self.obj.handle_not_found(*keys) + + entry['idnszoneactive'] = ['TRUE'] + + try: + ldap.update_entry(entry) + except errors.EmptyModlist: + pass + + return dict(result=True, value=pkey_to_value(keys[-1], options)) + + +class DNSZoneBase_add_permission(LDAPQuery): + has_output = _output_permissions + msg_summary = _('Added system permission "%(value)s"') + + def execute(self, *keys, **options): + ldap = self.obj.backend + dn = self.obj.get_dn(*keys, **options) + + try: + entry_attrs = ldap.get_entry(dn, ['objectclass']) + except errors.NotFound: + self.obj.handle_not_found(*keys) + else: + if not _check_entry_objectclass(entry_attrs, self.obj.object_class): + self.obj.handle_not_found(*keys) + + permission_name = self.obj.permission_name(keys[-1]) + + # compatibility with older IPA versions which allows relative zonenames + if keys[-1] != DNSName.root: # special case root zone + permission_name_rel = self.obj.permission_name( + keys[-1].relativize(DNSName.root) + ) + try: + self.api.Object['permission'].get_dn_if_exists( + permission_name_rel) + except errors.NotFound: + pass + else: + # permission exists without absolute domain name + raise errors.DuplicateEntry( + message=_('permission "%(value)s" already exists') % { + 'value': permission_name + } + ) + + permission = self.api.Command['permission_add_noaci'](permission_name, + ipapermissiontype=u'SYSTEM' + )['result'] + + dnszone_ocs = entry_attrs.get('objectclass') + if dnszone_ocs: + for oc in dnszone_ocs: + if oc.lower() == 'ipadnszone': + break + else: + dnszone_ocs.append('ipadnszone') + + entry_attrs['managedby'] = [permission['dn']] + ldap.update_entry(entry_attrs) + + return dict( + result=True, + value=pkey_to_value(permission_name, options), + ) + + +class DNSZoneBase_remove_permission(LDAPQuery): + has_output = _output_permissions + msg_summary = _('Removed system permission "%(value)s"') + + def execute(self, *keys, **options): + ldap = self.obj.backend + dn = self.obj.get_dn(*keys, **options) + try: + entry = ldap.get_entry(dn, ['managedby', 'objectclass']) + except errors.NotFound: + self.obj.handle_not_found(*keys) + else: + if not _check_entry_objectclass(entry, self.obj.object_class): + self.obj.handle_not_found(*keys) + + entry['managedby'] = None + + try: + ldap.update_entry(entry) + except errors.EmptyModlist: + # managedBy attribute is clean, lets make sure there is also no + # dangling DNS zone permission + pass + + permission_name = self.obj.permission_name(keys[-1]) + self.obj._remove_permission(keys[-1]) + + return dict( + result=True, + value=pkey_to_value(permission_name, options), + ) + + +@register() +class dnszone(DNSZoneBase): + """ + DNS Zone, container for resource records. + """ + object_name = _('DNS zone') + object_name_plural = _('DNS zones') + object_class = DNSZoneBase.object_class + ['idnsrecord', 'idnszone'] + default_attributes = DNSZoneBase.default_attributes + [ + 'idnssoamname', 'idnssoarname', 'idnssoaserial', 'idnssoarefresh', + 'idnssoaretry', 'idnssoaexpire', 'idnssoaminimum', 'idnsallowquery', + 'idnsallowtransfer', 'idnssecinlinesigning', + ] + _record_attributes + label = _('DNS Zones') + label_singular = _('DNS Zone') + + takes_params = DNSZoneBase.takes_params + ( + DNSNameParam('idnssoamname?', + cli_name='name_server', + label=_('Authoritative nameserver'), + doc=_('Authoritative nameserver domain name'), + default=None, # value will be added in precallback from ldap + ), + DNSNameParam('idnssoarname', + _rname_validator, + cli_name='admin_email', + label=_('Administrator e-mail address'), + doc=_('Administrator e-mail address'), + default=DNSName(u'hostmaster'), + normalizer=normalize_zonemgr, + autofill=True, + ), + Int('idnssoaserial', + cli_name='serial', + label=_('SOA serial'), + doc=_('SOA record serial number'), + minvalue=1, + maxvalue=4294967295, + default_from=_create_zone_serial, + autofill=True, + ), + Int('idnssoarefresh', + cli_name='refresh', + label=_('SOA refresh'), + doc=_('SOA record refresh time'), + minvalue=0, + maxvalue=2147483647, + default=3600, + autofill=True, + ), + Int('idnssoaretry', + cli_name='retry', + label=_('SOA retry'), + doc=_('SOA record retry time'), + minvalue=0, + maxvalue=2147483647, + default=900, + autofill=True, + ), + Int('idnssoaexpire', + cli_name='expire', + label=_('SOA expire'), + doc=_('SOA record expire time'), + default=1209600, + minvalue=0, + maxvalue=2147483647, + autofill=True, + ), + Int('idnssoaminimum', + cli_name='minimum', + label=_('SOA minimum'), + doc=_('How long should negative responses be cached'), + default=3600, + minvalue=0, + maxvalue=2147483647, + autofill=True, + ), + Int('dnsttl?', + cli_name='ttl', + label=_('Time to live'), + doc=_('Time to live for records at zone apex'), + minvalue=0, + maxvalue=2147483647, # see RFC 2181 + ), + StrEnum('dnsclass?', + # Deprecated + cli_name='class', + flags=['no_option'], + values=_record_classes, + ), + Str('idnsupdatepolicy?', + cli_name='update_policy', + label=_('BIND update policy'), + doc=_('BIND update policy'), + default_from=lambda idnsname: default_zone_update_policy(idnsname), + autofill=True + ), + Bool('idnsallowdynupdate?', + cli_name='dynamic_update', + label=_('Dynamic update'), + doc=_('Allow dynamic updates.'), + attribute=True, + default=False, + autofill=True + ), + Str('idnsallowquery?', + _validate_bind_aci, + normalizer=_normalize_bind_aci, + cli_name='allow_query', + label=_('Allow query'), + doc=_('Semicolon separated list of IP addresses or networks which are allowed to issue queries'), + default=u'any;', # anyone can issue queries by default + autofill=True, + ), + Str('idnsallowtransfer?', + _validate_bind_aci, + normalizer=_normalize_bind_aci, + cli_name='allow_transfer', + label=_('Allow transfer'), + doc=_('Semicolon separated list of IP addresses or networks which are allowed to transfer the zone'), + default=u'none;', # no one can issue queries by default + autofill=True, + ), + Bool('idnsallowsyncptr?', + cli_name='allow_sync_ptr', + label=_('Allow PTR sync'), + doc=_('Allow synchronization of forward (A, AAAA) and reverse (PTR) records in the zone'), + ), + Bool('idnssecinlinesigning?', + cli_name='dnssec', + default=False, + label=_('Allow in-line DNSSEC signing'), + doc=_('Allow inline DNSSEC signing of records in the zone'), + ), + Str('nsec3paramrecord?', + _validate_nsec3param_record, + cli_name='nsec3param_rec', + label=_('NSEC3PARAM record'), + doc=_('NSEC3PARAM record for zone in format: hash_algorithm flags iterations salt'), + pattern=r'^\d+ \d+ \d+ (([0-9a-fA-F]{2})+|-)$', + pattern_errmsg=(u'expected format: <0-255> <0-255> <0-65535> ' + 'even-length_hexadecimal_digits_or_hyphen'), + ), + ) + # Permissions will be apllied for forwardzones too + # Store permissions into api.env.basedn, dns container could not exists + managed_permissions = { + 'System: Add DNS Entries': { + 'non_object': True, + 'ipapermright': {'add'}, + 'ipapermlocation': api.env.basedn, + 'ipapermtarget': DN('idnsname=*', 'cn=dns', api.env.basedn), + 'replaces': [ + '(target = "ldap:///idnsname=*,cn=dns,$SUFFIX")(version 3.0;acl "permission:add dns entries";allow (add) groupdn = "ldap:///cn=add dns entries,cn=permissions,cn=pbac,$SUFFIX";)', + ], + 'default_privileges': {'DNS Administrators', 'DNS Servers'}, + }, + 'System: Read DNS Entries': { + 'non_object': True, + 'ipapermright': {'read', 'search', 'compare'}, + 'ipapermlocation': api.env.basedn, + 'ipapermtarget': DN('idnsname=*', 'cn=dns', api.env.basedn), + 'ipapermdefaultattr': { + 'objectclass', + 'a6record', 'aaaarecord', 'afsdbrecord', 'aplrecord', 'arecord', + 'certrecord', 'cn', 'cnamerecord', 'dhcidrecord', 'dlvrecord', + 'dnamerecord', 'dnsclass', 'dnsttl', 'dsrecord', + 'hinforecord', 'hiprecord', 'idnsallowdynupdate', + 'idnsallowquery', 'idnsallowsyncptr', 'idnsallowtransfer', + 'idnsforwarders', 'idnsforwardpolicy', 'idnsname', + 'idnssecinlinesigning', 'idnssoaexpire', 'idnssoaminimum', + 'idnssoamname', 'idnssoarefresh', 'idnssoaretry', + 'idnssoarname', 'idnssoaserial', 'idnsupdatepolicy', + 'idnszoneactive', 'ipseckeyrecord','keyrecord', 'kxrecord', + 'locrecord', 'managedby', 'mdrecord', 'minforecord', + 'mxrecord', 'naptrrecord', 'nsecrecord', 'nsec3paramrecord', + 'nsrecord', 'nxtrecord', 'ptrrecord', 'rprecord', 'rrsigrecord', + 'sigrecord', 'spfrecord', 'srvrecord', 'sshfprecord', + 'tlsarecord', 'txtrecord', 'unknownrecord', + }, + 'replaces_system': ['Read DNS Entries'], + 'default_privileges': {'DNS Administrators', 'DNS Servers'}, + }, + 'System: Remove DNS Entries': { + 'non_object': True, + 'ipapermright': {'delete'}, + 'ipapermlocation': api.env.basedn, + 'ipapermtarget': DN('idnsname=*', 'cn=dns', api.env.basedn), + 'replaces': [ + '(target = "ldap:///idnsname=*,cn=dns,$SUFFIX")(version 3.0;acl "permission:remove dns entries";allow (delete) groupdn = "ldap:///cn=remove dns entries,cn=permissions,cn=pbac,$SUFFIX";)', + ], + 'default_privileges': {'DNS Administrators', 'DNS Servers'}, + }, + 'System: Update DNS Entries': { + 'non_object': True, + 'ipapermright': {'write'}, + 'ipapermlocation': api.env.basedn, + 'ipapermtarget': DN('idnsname=*', 'cn=dns', api.env.basedn), + 'ipapermdefaultattr': { + 'a6record', 'aaaarecord', 'afsdbrecord', 'aplrecord', 'arecord', + 'certrecord', 'cn', 'cnamerecord', 'dhcidrecord', 'dlvrecord', + 'dnamerecord', 'dnsclass', 'dnsttl', 'dsrecord', + 'hinforecord', 'hiprecord', 'idnsallowdynupdate', + 'idnsallowquery', 'idnsallowsyncptr', 'idnsallowtransfer', + 'idnsforwarders', 'idnsforwardpolicy', 'idnsname', + 'idnssecinlinesigning', 'idnssoaexpire', 'idnssoaminimum', + 'idnssoamname', 'idnssoarefresh', 'idnssoaretry', + 'idnssoarname', 'idnssoaserial', 'idnsupdatepolicy', + 'idnszoneactive', 'ipseckeyrecord','keyrecord', 'kxrecord', + 'locrecord', 'managedby', 'mdrecord', 'minforecord', + 'mxrecord', 'naptrrecord', 'nsecrecord', 'nsec3paramrecord', + 'nsrecord', 'nxtrecord', 'ptrrecord', 'rprecord', 'rrsigrecord', + 'sigrecord', 'spfrecord', 'srvrecord', 'sshfprecord', + 'tlsarecord', 'txtrecord', 'unknownrecord', + }, + 'replaces': [ + '(targetattr = "idnsname || cn || idnsallowdynupdate || dnsttl || dnsclass || arecord || aaaarecord || a6record || nsrecord || cnamerecord || ptrrecord || srvrecord || txtrecord || mxrecord || mdrecord || hinforecord || minforecord || afsdbrecord || sigrecord || keyrecord || locrecord || nxtrecord || naptrrecord || kxrecord || certrecord || dnamerecord || dsrecord || sshfprecord || rrsigrecord || nsecrecord || idnsname || idnszoneactive || idnssoamname || idnssoarname || idnssoaserial || idnssoarefresh || idnssoaretry || idnssoaexpire || idnssoaminimum || idnsupdatepolicy")(target = "ldap:///idnsname=*,cn=dns,$SUFFIX")(version 3.0;acl "permission:update dns entries";allow (write) groupdn = "ldap:///cn=update dns entries,cn=permissions,cn=pbac,$SUFFIX";)', + '(targetattr = "idnsname || cn || idnsallowdynupdate || dnsttl || dnsclass || arecord || aaaarecord || a6record || nsrecord || cnamerecord || ptrrecord || srvrecord || txtrecord || mxrecord || mdrecord || hinforecord || minforecord || afsdbrecord || sigrecord || keyrecord || locrecord || nxtrecord || naptrrecord || kxrecord || certrecord || dnamerecord || dsrecord || sshfprecord || rrsigrecord || nsecrecord || idnsname || idnszoneactive || idnssoamname || idnssoarname || idnssoaserial || idnssoarefresh || idnssoaretry || idnssoaexpire || idnssoaminimum || idnsupdatepolicy || idnsallowquery || idnsallowtransfer || idnsallowsyncptr || idnsforwardpolicy || idnsforwarders")(target = "ldap:///idnsname=*,cn=dns,$SUFFIX")(version 3.0;acl "permission:update dns entries";allow (write) groupdn = "ldap:///cn=update dns entries,cn=permissions,cn=pbac,$SUFFIX";)', + '(targetattr = "idnsname || cn || idnsallowdynupdate || dnsttl || dnsclass || arecord || aaaarecord || a6record || nsrecord || cnamerecord || ptrrecord || srvrecord || txtrecord || mxrecord || mdrecord || hinforecord || minforecord || afsdbrecord || sigrecord || keyrecord || locrecord || nxtrecord || naptrrecord || kxrecord || certrecord || dnamerecord || dsrecord || sshfprecord || rrsigrecord || nsecrecord || idnsname || idnszoneactive || idnssoamname || idnssoarname || idnssoaserial || idnssoarefresh || idnssoaretry || idnssoaexpire || idnssoaminimum || idnsupdatepolicy || idnsallowquery || idnsallowtransfer || idnsallowsyncptr || idnsforwardpolicy || idnsforwarders || managedby")(target = "ldap:///idnsname=*,cn=dns,$SUFFIX")(version 3.0;acl "permission:update dns entries";allow (write) groupdn = "ldap:///cn=update dns entries,cn=permissions,cn=pbac,$SUFFIX";)', + ], + 'default_privileges': {'DNS Administrators', 'DNS Servers'}, + }, + 'System: Read DNSSEC metadata': { + 'non_object': True, + 'ipapermright': {'read', 'search', 'compare'}, + 'ipapermlocation': api.env.basedn, + 'ipapermtarget': DN('cn=dns', api.env.basedn), + 'ipapermtargetfilter': ['(objectclass=idnsSecKey)'], + 'ipapermdefaultattr': { + 'idnsSecAlgorithm', 'idnsSecKeyCreated', 'idnsSecKeyPublish', + 'idnsSecKeyActivate', 'idnsSecKeyInactive', 'idnsSecKeyDelete', + 'idnsSecKeyZone', 'idnsSecKeyRevoke', 'idnsSecKeySep', + 'idnsSecKeyRef', 'cn', 'objectclass', + }, + 'default_privileges': {'DNS Administrators'}, + }, + 'System: Manage DNSSEC metadata': { + 'non_object': True, + 'ipapermright': {'all'}, + 'ipapermlocation': api.env.basedn, + 'ipapermtarget': DN('cn=dns', api.env.basedn), + 'ipapermtargetfilter': ['(objectclass=idnsSecKey)'], + 'ipapermdefaultattr': { + 'idnsSecAlgorithm', 'idnsSecKeyCreated', 'idnsSecKeyPublish', + 'idnsSecKeyActivate', 'idnsSecKeyInactive', 'idnsSecKeyDelete', + 'idnsSecKeyZone', 'idnsSecKeyRevoke', 'idnsSecKeySep', + 'idnsSecKeyRef', 'cn', 'objectclass', + }, + 'default_privileges': {'DNS Servers'}, + }, + 'System: Manage DNSSEC keys': { + 'non_object': True, + 'ipapermright': {'all'}, + 'ipapermlocation': api.env.basedn, + 'ipapermtarget': DN('cn=keys', 'cn=sec', 'cn=dns', api.env.basedn), + 'ipapermdefaultattr': { + 'ipaPublicKey', 'ipaPrivateKey', 'ipaSecretKey', + 'ipaWrappingMech','ipaWrappingKey', + 'ipaSecretKeyRef', 'ipk11Private', 'ipk11Modifiable', 'ipk11Label', + 'ipk11Copyable', 'ipk11Destroyable', 'ipk11Trusted', + 'ipk11CheckValue', 'ipk11StartDate', 'ipk11EndDate', + 'ipk11UniqueId', 'ipk11PublicKeyInfo', 'ipk11Distrusted', + 'ipk11Subject', 'ipk11Id', 'ipk11Local', 'ipk11KeyType', + 'ipk11Derive', 'ipk11KeyGenMechanism', 'ipk11AllowedMechanisms', + 'ipk11Encrypt', 'ipk11Verify', 'ipk11VerifyRecover', 'ipk11Wrap', + 'ipk11WrapTemplate', 'ipk11Sensitive', 'ipk11Decrypt', + 'ipk11Sign', 'ipk11SignRecover', 'ipk11Unwrap', + 'ipk11Extractable', 'ipk11AlwaysSensitive', + 'ipk11NeverExtractable', 'ipk11WrapWithTrusted', + 'ipk11UnwrapTemplate', 'ipk11AlwaysAuthenticate', + 'objectclass', + }, + 'default_privileges': {'DNS Servers'}, + }, + } + + def _rr_zone_postprocess(self, record, **options): + #Decode IDN ACE form to Unicode, raw records are passed directly from LDAP + if options.get('raw', False): + return + _records_idn_postprocess(record, **options) + + def _warning_forwarding(self, result, **options): + if ('idnsforwarders' in result['result']): + messages.add_message(options.get('version', VERSION_WITHOUT_CAPABILITIES), + result, messages.ForwardersWarning()) + + def _warning_name_server_option(self, result, context, **options): + if getattr(context, 'show_warning_nameserver_option', False): + messages.add_message( + options['version'], + result, messages.OptionSemanticChangedWarning( + label=_(u"setting Authoritative nameserver"), + current_behavior=_(u"It is used only for setting the " + u"SOA MNAME attribute."), + hint=_(u"NS record(s) can be edited in zone apex - '@'. ") + ) + ) + + def _warning_fw_zone_is_not_effective(self, result, *keys, **options): + """ + Warning if any operation with zone causes, a child forward zone is + not effective + """ + zone = keys[-1] + affected_fw_zones, truncated = _find_subtree_forward_zones_ldap( + self.api, zone, child_zones_only=True) + if not affected_fw_zones: + return + + for fwzone in affected_fw_zones: + _add_warning_fw_zone_is_not_effective(self.api, result, fwzone, + options['version']) + + def _warning_dnssec_master_is_not_installed(self, result, **options): + dnssec_enabled = result['result'].get("idnssecinlinesigning", False) + if dnssec_enabled and not dnssec_installed(self.api.Backend.ldap2): + messages.add_message( + options['version'], + result, + messages.DNSSECMasterNotInstalled() + ) + + +@register() +class dnszone_add(DNSZoneBase_add): + __doc__ = _('Create new DNS zone (SOA record).') + + takes_options = DNSZoneBase_add.takes_options + ( + Flag('force', + doc=_('Force DNS zone creation even if nameserver is not ' + 'resolvable. (Deprecated)'), + ), + + Flag('skip_nameserver_check', + doc=_('Force DNS zone creation even if nameserver is not ' + 'resolvable.'), + ), + + # Deprecated + # ip-address option is not used anymore, we have to keep it + # due to compability with clients older than 4.1 + Str('ip_address?', + flags=['no_option', ] + ), + ) + + def _warning_deprecated_option(self, result, **options): + if 'ip_address' in options: + messages.add_message( + options['version'], + result, + messages.OptionDeprecatedWarning( + option='ip-address', + additional_info=u"Value will be ignored.") + ) + + def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options): + assert isinstance(dn, DN) + + if options.get('force'): + options['skip_nameserver_check'] = True + + dn = super(dnszone_add, self).pre_callback( + ldap, dn, entry_attrs, attrs_list, *keys, **options) + + nameservers = [normalize_zone(x) for x in + self.api.Object.dnsrecord.get_dns_masters()] + server = normalize_zone(api.env.host) + zone = keys[-1] + + if entry_attrs.get('idnssoamname'): + if zone.is_reverse() and not entry_attrs['idnssoamname'].is_absolute(): + raise errors.ValidationError( + name='name-server', + error=_("Nameserver for reverse zone cannot be a relative DNS name")) + + # verify if user specified server is resolvable + if not options['skip_nameserver_check']: + check_ns_rec_resolvable(keys[0], entry_attrs['idnssoamname'], + self.log) + # show warning about --name-server option + context.show_warning_nameserver_option = True + else: + # user didn't specify SOA mname + if server in nameservers: + # current ipa server is authoritative nameserver in SOA record + entry_attrs['idnssoamname'] = [server] + else: + # a first DNS capable server is authoritative nameserver in SOA record + entry_attrs['idnssoamname'] = [nameservers[0]] + + # all ipa DNS servers should be in NS zone record (as absolute domain name) + entry_attrs['nsrecord'] = nameservers + + return dn + + def execute(self, *keys, **options): + result = super(dnszone_add, self).execute(*keys, **options) + self._warning_deprecated_option(result, **options) + self.obj._warning_forwarding(result, **options) + self.obj._warning_name_server_option(result, context, **options) + self.obj._warning_fw_zone_is_not_effective(result, *keys, **options) + self.obj._warning_dnssec_master_is_not_installed(result, **options) + return result + + def post_callback(self, ldap, dn, entry_attrs, *keys, **options): + assert isinstance(dn, DN) + + # Add entry to realmdomains + # except for our own domain, forward zones, reverse zones and root zone + zone = keys[0] + + if (zone != DNSName(api.env.domain).make_absolute() and + not options.get('idnsforwarders') and + not zone.is_reverse() and + zone != DNSName.root): + try: + self.api.Command['realmdomains_mod'](add_domain=unicode(zone), + force=True) + except (errors.EmptyModlist, errors.ValidationError): + pass + + self.obj._rr_zone_postprocess(entry_attrs, **options) + return dn + + + +@register() +class dnszone_del(DNSZoneBase_del): + __doc__ = _('Delete DNS zone (SOA record).') + + msg_summary = _('Deleted DNS zone "%(value)s"') + + def execute(self, *keys, **options): + result = super(dnszone_del, self).execute(*keys, **options) + nkeys = keys[-1] # we can delete more zones + for key in nkeys: + self.obj._warning_fw_zone_is_not_effective(result, key, **options) + return result + + def post_callback(self, ldap, dn, *keys, **options): + super(dnszone_del, self).post_callback(ldap, dn, *keys, **options) + + # Delete entry from realmdomains + # except for our own domain, reverse zone, and root zone + zone = keys[0].make_absolute() + + if (zone != DNSName(api.env.domain).make_absolute() and + not zone.is_reverse() and zone != DNSName.root + ): + try: + self.api.Command['realmdomains_mod']( + del_domain=unicode(zone), force=True) + except (errors.AttrValueNotFound, errors.ValidationError): + pass + + return True + + + +@register() +class dnszone_mod(DNSZoneBase_mod): + __doc__ = _('Modify DNS zone (SOA record).') + + takes_options = DNSZoneBase_mod.takes_options + ( + Flag('force', + label=_('Force'), + doc=_('Force nameserver change even if nameserver not in DNS'), + ), + ) + + def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options): + if not _check_DN_objectclass(ldap, dn, self.obj.object_class): + self.obj.handle_not_found(*keys) + if 'idnssoamname' in entry_attrs: + nameserver = entry_attrs['idnssoamname'] + if nameserver: + if not nameserver.is_empty() and not options['force']: + check_ns_rec_resolvable(keys[0], nameserver, self.log) + context.show_warning_nameserver_option = True + else: + # empty value, this option is required by ldap + raise errors.ValidationError( + name='name_server', + error=_(u"is required")) + + return dn + + def execute(self, *keys, **options): + result = super(dnszone_mod, self).execute(*keys, **options) + self.obj._warning_forwarding(result, **options) + self.obj._warning_name_server_option(result, context, **options) + self.obj._warning_dnssec_master_is_not_installed(result, **options) + return result + + def post_callback(self, ldap, dn, entry_attrs, *keys, **options): + dn = super(dnszone_mod, self).post_callback(ldap, dn, entry_attrs, + *keys, **options) + self.obj._rr_zone_postprocess(entry_attrs, **options) + return dn + + +@register() +class dnszone_find(DNSZoneBase_find): + __doc__ = _('Search for DNS zones (SOA records).') + + takes_options = DNSZoneBase_find.takes_options + ( + Flag('forward_only', + label=_('Forward zones only'), + cli_name='forward_only', + doc=_('Search for forward zones only'), + ), + ) + + def pre_callback(self, ldap, filter, attrs_list, base_dn, scope, *args, **options): + assert isinstance(base_dn, DN) + + filter, base, dn = super(dnszone_find, self).pre_callback(ldap, filter, + attrs_list, base_dn, scope, *args, **options) + + if options.get('forward_only', False): + search_kw = {} + search_kw['idnsname'] = [revzone.ToASCII() for revzone in + REVERSE_DNS_ZONES.keys()] + rev_zone_filter = ldap.make_filter(search_kw, + rules=ldap.MATCH_NONE, + exact=False, + trailing_wildcard=False) + filter = ldap.combine_filters((rev_zone_filter, filter), + rules=ldap.MATCH_ALL) + + return (filter, base_dn, scope) + + def post_callback(self, ldap, entries, truncated, *args, **options): + truncated = super(dnszone_find, self).post_callback(ldap, entries, + truncated, *args, + **options) + for entry_attrs in entries: + self.obj._rr_zone_postprocess(entry_attrs, **options) + return truncated + + + +@register() +class dnszone_show(DNSZoneBase_show): + __doc__ = _('Display information about a DNS zone (SOA record).') + + def execute(self, *keys, **options): + result = super(dnszone_show, self).execute(*keys, **options) + self.obj._warning_forwarding(result, **options) + self.obj._warning_dnssec_master_is_not_installed(result, **options) + return result + + def post_callback(self, ldap, dn, entry_attrs, *keys, **options): + dn = super(dnszone_show, self).post_callback(ldap, dn, entry_attrs, + *keys, **options) + self.obj._rr_zone_postprocess(entry_attrs, **options) + return dn + + + +@register() +class dnszone_disable(DNSZoneBase_disable): + __doc__ = _('Disable DNS Zone.') + msg_summary = _('Disabled DNS zone "%(value)s"') + + def execute(self, *keys, **options): + result = super(dnszone_disable, self).execute(*keys, **options) + self.obj._warning_fw_zone_is_not_effective(result, *keys, **options) + return result + + +@register() +class dnszone_enable(DNSZoneBase_enable): + __doc__ = _('Enable DNS Zone.') + msg_summary = _('Enabled DNS zone "%(value)s"') + + def execute(self, *keys, **options): + result = super(dnszone_enable, self).execute(*keys, **options) + self.obj._warning_fw_zone_is_not_effective(result, *keys, **options) + return result + + +@register() +class dnszone_add_permission(DNSZoneBase_add_permission): + __doc__ = _('Add a permission for per-zone access delegation.') + + +@register() +class dnszone_remove_permission(DNSZoneBase_remove_permission): + __doc__ = _('Remove a permission for per-zone access delegation.') + + +@register() +class dnsrecord(LDAPObject): + """ + DNS record. + """ + parent_object = 'dnszone' + container_dn = api.env.container_dns + object_name = _('DNS resource record') + object_name_plural = _('DNS resource records') + object_class = ['top', 'idnsrecord'] + permission_filter_objectclasses = ['idnsrecord'] + default_attributes = ['idnsname'] + _record_attributes + rdn_is_primary_key = True + + label = _('DNS Resource Records') + label_singular = _('DNS Resource Record') + + takes_params = ( + DNSNameParam('idnsname', + cli_name='name', + label=_('Record name'), + doc=_('Record name'), + primary_key=True, + ), + Int('dnsttl?', + cli_name='ttl', + label=_('Time to live'), + doc=_('Time to live'), + ), + StrEnum('dnsclass?', + # Deprecated + cli_name='class', + flags=['no_option'], + values=_record_classes, + ), + ) + _dns_record_options + + structured_flag = Flag('structured', + label=_('Structured'), + doc=_('Parse all raw DNS records and return them in a structured way'), + ) + + def _dsrecord_pre_callback(self, ldap, dn, entry_attrs, *keys, **options): + assert isinstance(dn, DN) + dsrecords = entry_attrs.get('dsrecord') + if dsrecords and self.is_pkey_zone_record(*keys): + raise errors.ValidationError( + name='dsrecord', + error=unicode(_('DS record must not be in zone apex (RFC 4035 section 2.4)'))) + + def _nsrecord_pre_callback(self, ldap, dn, entry_attrs, *keys, **options): + assert isinstance(dn, DN) + nsrecords = entry_attrs.get('nsrecord') + if options.get('force', False) or nsrecords is None: + return + for nsrecord in nsrecords: + check_ns_rec_resolvable(keys[0], DNSName(nsrecord), self.log) + + def _idnsname_pre_callback(self, ldap, dn, entry_attrs, *keys, **options): + assert isinstance(dn, DN) + if keys[-1].is_absolute(): + if keys[-1].is_subdomain(keys[-2]): + entry_attrs['idnsname'] = [keys[-1].relativize(keys[-2])] + elif not self.is_pkey_zone_record(*keys): + raise errors.ValidationError(name='idnsname', + error=unicode(_('out-of-zone data: record name must ' + 'be a subdomain of the zone or a ' + 'relative name'))) + # dissallowed wildcard (RFC 4592 section 4) + no_wildcard_rtypes = ['DNAME', 'DS', 'NS'] + if (keys[-1].is_wild() and + any(entry_attrs.get(record_name_format % r.lower()) + for r in no_wildcard_rtypes) + ): + raise errors.ValidationError( + name='idnsname', + error=(_('owner of %(types)s records ' + 'should not be a wildcard domain name (RFC 4592 section 4)') % + {'types': ', '.join(no_wildcard_rtypes)} + ) + ) + + def _ptrrecord_pre_callback(self, ldap, dn, entry_attrs, *keys, **options): + assert isinstance(dn, DN) + ptrrecords = entry_attrs.get('ptrrecord') + if ptrrecords is None: + return + + zone = keys[-2] + if self.is_pkey_zone_record(*keys): + addr = _dns_zone_record + else: + addr = keys[-1] + + zone_len = 0 + for valid_zone in REVERSE_DNS_ZONES: + if zone.is_subdomain(valid_zone): + zone = zone.relativize(valid_zone) + zone_name = valid_zone + zone_len = REVERSE_DNS_ZONES[valid_zone] + + if not zone_len: + allowed_zones = ', '.join([unicode(revzone) for revzone in + REVERSE_DNS_ZONES.keys()]) + raise errors.ValidationError(name='ptrrecord', + error=unicode(_('Reverse zone for PTR record should be a sub-zone of one the following fully qualified domains: %s') % allowed_zones)) + + addr_len = len(addr.labels) + + # Classless zones (0/25.0.0.10.in-addr.arpa.) -> skip check + # zone has to be checked without reverse domain suffix (in-addr.arpa.) + for sign in ('/', '-'): + for name in (zone, addr): + for label in name.labels: + if sign in label: + return + + ip_addr_comp_count = addr_len + len(zone.labels) + if ip_addr_comp_count != zone_len: + raise errors.ValidationError(name='ptrrecord', + error=unicode(_('Reverse zone %(name)s requires exactly ' + '%(count)d IP address components, ' + '%(user_count)d given') + % dict(name=zone_name, + count=zone_len, + user_count=ip_addr_comp_count))) + + def run_precallback_validators(self, dn, entry_attrs, *keys, **options): + assert isinstance(dn, DN) + ldap = self.api.Backend.ldap2 + + for rtype in entry_attrs.keys(): + rtype_cb = getattr(self, '_%s_pre_callback' % rtype, None) + if rtype_cb: + rtype_cb(ldap, dn, entry_attrs, *keys, **options) + + def is_pkey_zone_record(self, *keys): + assert isinstance(keys[-1], DNSName) + assert isinstance(keys[-2], DNSName) + idnsname = keys[-1] + zonename = keys[-2] + if idnsname.is_empty() or idnsname == zonename: + return True + return False + + def check_zone(self, zone, **options): + """ + Check if zone exists and if is master zone + """ + parent_object = self.api.Object[self.parent_object] + dn = parent_object.get_dn(zone, **options) + ldap = self.api.Backend.ldap2 + try: + entry = ldap.get_entry(dn, ['objectclass']) + except errors.NotFound: + parent_object.handle_not_found(zone) + else: + # only master zones can contain records + if 'idnszone' not in [x.lower() for x in entry.get('objectclass', [])]: + raise errors.ValidationError( + name='dnszoneidnsname', + error=_(u'only master zones can contain records') + ) + return dn + + + def get_dn(self, *keys, **options): + if not dns_container_exists(self.api.Backend.ldap2): + raise errors.NotFound(reason=_('DNS is not configured')) + + dn = self.check_zone(keys[-2], **options) + + if self.is_pkey_zone_record(*keys): + return dn + + #Make RR name relative if possible + relative_name = keys[-1].relativize(keys[-2]).ToASCII() + keys = keys[:-1] + (relative_name,) + return super(dnsrecord, self).get_dn(*keys, **options) + + def attr_to_cli(self, attr): + cliname = get_record_rrtype(attr) + if not cliname: + cliname = attr + return cliname + + def get_dns_masters(self): + ldap = self.api.Backend.ldap2 + base_dn = DN(('cn', 'masters'), ('cn', 'ipa'), ('cn', 'etc'), self.api.env.basedn) + ldap_filter = '(&(objectClass=ipaConfigObject)(cn=DNS))' + dns_masters = [] + + try: + entries = ldap.find_entries(filter=ldap_filter, base_dn=base_dn)[0] + + for entry in entries: + try: + master = entry.dn[1]['cn'] + dns_masters.append(master) + except (IndexError, KeyError): + pass + except errors.NotFound: + return [] + + return dns_masters + + def get_record_entry_attrs(self, entry_attrs): + entry_attrs = entry_attrs.copy() + for attr in entry_attrs.keys(): + if attr not in self.params or self.params[attr].primary_key: + del entry_attrs[attr] + return entry_attrs + + def postprocess_record(self, record, **options): + if options.get('structured', False): + for attr in record.keys(): + # attributes in LDAPEntry may not be normalized + attr = attr.lower() + try: + param = self.params[attr] + except KeyError: + continue + + if not isinstance(param, DNSRecord): + continue + parts_params = param.get_parts() + + for dnsvalue in record[attr]: + dnsentry = { + u'dnstype' : unicode(param.rrtype), + u'dnsdata' : dnsvalue + } + values = param._get_part_values(dnsvalue) + if values is None: + continue + for val_id, val in enumerate(values): + if val is not None: + #decode IDN + if isinstance(parts_params[val_id], DNSNameParam): + dnsentry[parts_params[val_id].name] = \ + _dns_name_to_string(val, + options.get('raw', False)) + else: + dnsentry[parts_params[val_id].name] = val + record.setdefault('dnsrecords', []).append(dnsentry) + del record[attr] + + elif not options.get('raw', False): + #Decode IDN ACE form to Unicode, raw records are passed directly from LDAP + _records_idn_postprocess(record, **options) + + def updated_rrattrs(self, old_entry, entry_attrs): + """Returns updated RR attributes + """ + rrattrs = {} + if old_entry is not None: + old_rrattrs = dict((key, value) for key, value in old_entry.items() + if key in self.params and + isinstance(self.params[key], DNSRecord)) + rrattrs.update(old_rrattrs) + new_rrattrs = dict((key, value) for key, value in entry_attrs.items() + if key in self.params and + isinstance(self.params[key], DNSRecord)) + rrattrs.update(new_rrattrs) + return rrattrs + + def check_record_type_collisions(self, keys, rrattrs): + # Test that only allowed combination of record types was created + + # CNAME record validation + cnames = rrattrs.get('cnamerecord') + if cnames is not None: + if len(cnames) > 1: + raise errors.ValidationError(name='cnamerecord', + error=_('only one CNAME record is allowed per name ' + '(RFC 2136, section 1.1.5)')) + if any(rrvalue is not None + and rrattr != 'cnamerecord' + for rrattr, rrvalue in rrattrs.items()): + raise errors.ValidationError(name='cnamerecord', + error=_('CNAME record is not allowed to coexist ' + 'with any other record (RFC 1034, section 3.6.2)')) + + # DNAME record validation + dnames = rrattrs.get('dnamerecord') + if dnames is not None: + if len(dnames) > 1: + raise errors.ValidationError(name='dnamerecord', + error=_('only one DNAME record is allowed per name ' + '(RFC 6672, section 2.4)')) + # DNAME must not coexist with CNAME, but this is already checked earlier + + # NS record validation + # NS record can coexist only with A, AAAA, DS, and other NS records (except zone apex) + # RFC 2181 section 6.1, + allowed_records = ['AAAA', 'A', 'DS', 'NS'] + nsrecords = rrattrs.get('nsrecord') + if nsrecords and not self.is_pkey_zone_record(*keys): + for r_type in _record_types: + if (r_type not in allowed_records + and rrattrs.get(record_name_format % r_type.lower()) + ): + raise errors.ValidationError( + name='nsrecord', + error=_('NS record is not allowed to coexist with an ' + '%(type)s record except when located in a ' + 'zone root record (RFC 2181, section 6.1)') % + {'type': r_type}) + + def check_record_type_dependencies(self, keys, rrattrs): + # Test that all record type dependencies are satisfied + + # DS record validation + # DS record requires to coexists with NS record + dsrecords = rrattrs.get('dsrecord') + nsrecords = rrattrs.get('nsrecord') + # DS record cannot be in zone apex, checked in pre-callback validators + if dsrecords and not nsrecords: + raise errors.ValidationError( + name='dsrecord', + error=_('DS record requires to coexist with an ' + 'NS record (RFC 4592 section 4.6, RFC 4035 section 2.4)')) + + def _entry2rrsets(self, entry_attrs, dns_name, dns_domain): + '''Convert entry_attrs to a dictionary {rdtype: rrset}. + + :returns: + None if entry_attrs is None + {rdtype: None} if RRset of given type is empty + {rdtype: RRset} if RRset of given type is non-empty + ''' + ldap_rrsets = {} + + if not entry_attrs: + # all records were deleted => name should not exist in DNS + return None + + for attr, value in entry_attrs.items(): + rrtype = get_record_rrtype(attr) + if not rrtype: + continue + + rdtype = dns.rdatatype.from_text(rrtype) + if not value: + ldap_rrsets[rdtype] = None # RRset is empty + continue + + try: + # TTL here can be arbitrary value because it is ignored + # during comparison + ldap_rrset = dns.rrset.from_text( + dns_name, 86400, dns.rdataclass.IN, rdtype, + *[str(v) for v in value]) + + # make sure that all names are absolute so RRset + # comparison will work + for ldap_rr in ldap_rrset: + ldap_rr.choose_relativity(origin=dns_domain, + relativize=False) + ldap_rrsets[rdtype] = ldap_rrset + + except dns.exception.SyntaxError as e: + self.log.error('DNS syntax error: %s %s %s: %s', dns_name, + dns.rdatatype.to_text(rdtype), value, e) + raise + + return ldap_rrsets + + def wait_for_modified_attr(self, ldap_rrset, rdtype, dns_name): + '''Wait until DNS resolver returns up-to-date answer for given RRset + or until the maximum number of attempts is reached. + Number of attempts is controlled by self.api.env['wait_for_dns']. + + :param ldap_rrset: + None if given rdtype should not exist or + dns.rrset.RRset to match against data in DNS. + :param dns_name: FQDN to query + :type dns_name: dns.name.Name + :return: None if data in DNS and LDAP match + :raises errors.DNSDataMismatch: if data in DNS and LDAP doesn't match + :raises dns.exception.DNSException: if DNS resolution failed + ''' + resolver = dns.resolver.Resolver() + resolver.set_flags(0) # disable recursion (for NS RR checks) + max_attempts = int(self.api.env['wait_for_dns']) + warn_attempts = max_attempts // 2 + period = 1 # second + attempt = 0 + log_fn = self.log.debug + log_fn('querying DNS server: expecting answer {%s}', ldap_rrset) + wait_template = 'waiting for DNS answer {%s}: got {%s} (attempt %s); '\ + 'waiting %s seconds before next try' + + while attempt < max_attempts: + if attempt >= warn_attempts: + log_fn = self.log.warning + attempt += 1 + try: + dns_answer = resolver.query(dns_name, rdtype, + dns.rdataclass.IN, + raise_on_no_answer=False) + dns_rrset = None + if rdtype == _NS: + # NS records can be in Authority section (sometimes) + dns_rrset = dns_answer.response.get_rrset( + dns_answer.response.authority, dns_name, _IN, rdtype) + + if not dns_rrset: + # Look for NS and other data in Answer section + dns_rrset = dns_answer.rrset + + if dns_rrset == ldap_rrset: + log_fn('DNS answer matches expectations (attempt %s)', + attempt) + return + + log_msg = wait_template % (ldap_rrset, dns_answer.response, + attempt, period) + + except (dns.resolver.NXDOMAIN, + dns.resolver.YXDOMAIN, + dns.resolver.NoNameservers, + dns.resolver.Timeout) as e: + if attempt >= max_attempts: + raise + else: + log_msg = wait_template % (ldap_rrset, type(e), attempt, + period) + + log_fn(log_msg) + time.sleep(period) + + # Maximum number of attempts was reached + else: + raise errors.DNSDataMismatch(expected=ldap_rrset, got=dns_rrset) + + def wait_for_modified_attrs(self, entry_attrs, dns_name, dns_domain): + '''Wait until DNS resolver returns up-to-date answer for given entry + or until the maximum number of attempts is reached. + + :param entry_attrs: + None if the entry was deleted from LDAP or + LDAPEntry instance containing at least all modified attributes. + :param dns_name: FQDN + :type dns_name: dns.name.Name + :raises errors.DNSDataMismatch: if data in DNS and LDAP doesn't match + ''' + + # represent data in LDAP as dictionary rdtype => rrset + ldap_rrsets = self._entry2rrsets(entry_attrs, dns_name, dns_domain) + nxdomain = ldap_rrsets is None + if nxdomain: + # name should not exist => ask for A record and check result + ldap_rrsets = {dns.rdatatype.from_text('A'): None} + + for rdtype, ldap_rrset in ldap_rrsets.items(): + try: + self.wait_for_modified_attr(ldap_rrset, rdtype, dns_name) + + except dns.resolver.NXDOMAIN as e: + if nxdomain: + continue + else: + e = errors.DNSDataMismatch(expected=ldap_rrset, + got="NXDOMAIN") + self.log.error(e) + raise e + + except dns.resolver.NoNameservers as e: + # Do not raise exception if we have got SERVFAILs. + # Maybe the user has created an invalid zone intentionally. + self.log.warning('waiting for DNS answer {%s}: got {%s}; ' + 'ignoring', ldap_rrset, type(e)) + continue + + except dns.exception.DNSException as e: + err_desc = str(type(e)) + err_str = str(e) + if err_str: + err_desc += ": %s" % err_str + e = errors.DNSDataMismatch(expected=ldap_rrset, got=err_desc) + self.log.error(e) + raise e + + def wait_for_modified_entries(self, entries): + '''Call wait_for_modified_attrs for all entries in given dict. + + :param entries: + Dict {(dns_domain, dns_name): entry_for_wait_for_modified_attrs} + ''' + for entry_name, entry in entries.items(): + dns_domain = entry_name[0] + dns_name = entry_name[1].derelativize(dns_domain) + self.wait_for_modified_attrs(entry, dns_name, dns_domain) + + def warning_if_ns_change_cause_fwzone_ineffective(self, result, *keys, + **options): + """Detect if NS record change can make forward zones ineffective due + missing delegation. Run after parent's execute method. + """ + record_name_absolute = keys[-1] + zone = keys[-2] + + if not record_name_absolute.is_absolute(): + record_name_absolute = record_name_absolute.derelativize(zone) + + affected_fw_zones, truncated = _find_subtree_forward_zones_ldap( + self.api, record_name_absolute) + if not affected_fw_zones: + return + + for fwzone in affected_fw_zones: + _add_warning_fw_zone_is_not_effective(self.api, result, fwzone, + options['version']) + + def warning_suspicious_relative_name(self, result, *keys, **options): + """Detect if zone name is suffix of relative record name and warn. + + Zone name: test.zone. + Relative name: record.test.zone + """ + record_name = keys[-1] + zone = keys[-2] + if not record_name.is_absolute() and record_name.is_subdomain( + zone.relativize(DNSName.root)): + messages.add_message( + options['version'], + result, + messages.DNSSuspiciousRelativeName(record=record_name, + zone=zone, + fqdn=record_name + zone) + ) + + +@register() +class dnsrecord_split_parts(Command): + NO_CLI = True + + takes_args = ( + Str('name'), + Str('value'), + ) + + def execute(self, name, value, *args, **options): + result = self.api.Object.dnsrecord.params[name]._get_part_values(value) + return dict(result=result) + + +@register() +class dnsrecord_add(LDAPCreate): + __doc__ = _('Add new DNS resource record.') + + no_option_msg = 'No options to add a specific record provided.\n' \ + "Command help may be consulted for all supported record types." + takes_options = LDAPCreate.takes_options + ( + Flag('force', + label=_('Force'), + flags=['no_option', 'no_output'], + doc=_('force NS record creation even if its hostname is not in DNS'), + ), + dnsrecord.structured_flag, + ) + + def args_options_2_entry(self, *keys, **options): + has_cli_options(self, options, self.no_option_msg) + return super(dnsrecord_add, self).args_options_2_entry(*keys, **options) + + def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options): + assert isinstance(dn, DN) + precallback_attrs = [] + processed_attrs = [] + for option in options: + try: + param = self.params[option] + except KeyError: + continue + + rrparam = get_rrparam_from_part(self, option) + if rrparam is None: + continue + + if 'dnsrecord_part' in param.flags: + if rrparam.name in processed_attrs: + # this record was already entered + continue + if rrparam.name in entry_attrs: + # this record is entered both via parts and raw records + raise errors.ValidationError(name=param.cli_name or param.name, + error=_('Raw value of a DNS record was already set by "%(name)s" option') \ + % dict(name=rrparam.cli_name or rrparam.name)) + + parts = rrparam.get_parts_from_kw(options) + dnsvalue = [rrparam._convert_scalar(parts)] + entry_attrs[rrparam.name] = dnsvalue + processed_attrs.append(rrparam.name) + continue + + if 'dnsrecord_extra' in param.flags: + # do not run precallback for unset flags + if isinstance(param, Flag) and not options[option]: + continue + # extra option is passed, run per-type pre_callback for given RR type + precallback_attrs.append(rrparam.name) + + # Run pre_callback validators + self.obj.run_precallback_validators(dn, entry_attrs, *keys, **options) + + # run precallback also for all new RR type attributes in entry_attrs + for attr in entry_attrs.keys(): + try: + param = self.params[attr] + except KeyError: + continue + + if not isinstance(param, DNSRecord): + continue + precallback_attrs.append(attr) + + precallback_attrs = list(set(precallback_attrs)) + + for attr in precallback_attrs: + # run per-type + try: + param = self.params[attr] + except KeyError: + continue + param.dnsrecord_add_pre_callback(ldap, dn, entry_attrs, attrs_list, *keys, **options) + + # Store all new attrs so that DNSRecord post callback is called for + # new attributes only and not for all attributes in the LDAP entry + setattr(context, 'dnsrecord_precallback_attrs', precallback_attrs) + + # We always want to retrieve all DNS record attributes to test for + # record type collisions (#2601) + try: + old_entry = ldap.get_entry(dn, _record_attributes) + except errors.NotFound: + old_entry = None + else: + for attr in entry_attrs.keys(): + if attr not in _record_attributes: + continue + if entry_attrs[attr] is None: + entry_attrs[attr] = [] + if not isinstance(entry_attrs[attr], (tuple, list)): + vals = [entry_attrs[attr]] + else: + vals = list(entry_attrs[attr]) + entry_attrs[attr] = list(set(old_entry.get(attr, []) + vals)) + + rrattrs = self.obj.updated_rrattrs(old_entry, entry_attrs) + self.obj.check_record_type_dependencies(keys, rrattrs) + self.obj.check_record_type_collisions(keys, rrattrs) + context.dnsrecord_entry_mods = getattr(context, 'dnsrecord_entry_mods', + {}) + context.dnsrecord_entry_mods[(keys[0], keys[1])] = entry_attrs.copy() + + return dn + + def execute(self, *keys, **options): + result = super(dnsrecord_add, self).execute(*keys, **options) + self.obj.warning_suspicious_relative_name(result, *keys, **options) + return result + + def exc_callback(self, keys, options, exc, call_func, *call_args, **call_kwargs): + if call_func.__name__ == 'add_entry': + if isinstance(exc, errors.DuplicateEntry): + # A new record is being added to existing LDAP DNS object + # Update can be safely run as old record values has been + # already merged in pre_callback + ldap = self.obj.backend + entry_attrs = self.obj.get_record_entry_attrs(call_args[0]) + update = ldap.get_entry(entry_attrs.dn, list(entry_attrs)) + update.update(entry_attrs) + ldap.update_entry(update, **call_kwargs) + return + raise exc + + def post_callback(self, ldap, dn, entry_attrs, *keys, **options): + assert isinstance(dn, DN) + for attr in getattr(context, 'dnsrecord_precallback_attrs', []): + param = self.params[attr] + param.dnsrecord_add_post_callback(ldap, dn, entry_attrs, *keys, **options) + + if self.obj.is_pkey_zone_record(*keys): + entry_attrs[self.obj.primary_key.name] = [_dns_zone_record] + + self.obj.postprocess_record(entry_attrs, **options) + + if self.api.env['wait_for_dns']: + self.obj.wait_for_modified_entries(context.dnsrecord_entry_mods) + return dn + + + +@register() +class dnsrecord_mod(LDAPUpdate): + __doc__ = _('Modify a DNS resource record.') + + no_option_msg = 'No options to modify a specific record provided.' + + takes_options = LDAPUpdate.takes_options + ( + dnsrecord.structured_flag, + ) + + def args_options_2_entry(self, *keys, **options): + has_cli_options(self, options, self.no_option_msg, True) + return super(dnsrecord_mod, self).args_options_2_entry(*keys, **options) + + def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options): + assert isinstance(dn, DN) + if options.get('rename') and self.obj.is_pkey_zone_record(*keys): + # zone rename is not allowed + raise errors.ValidationError(name='rename', + error=_('DNS zone root record cannot be renamed')) + + # check if any attr should be updated using structured instead of replaced + # format is recordname : (old_value, new_parts) + updated_attrs = {} + for param in iterate_rrparams_by_parts(self, options, skip_extra=True): + parts = param.get_parts_from_kw(options, raise_on_none=False) + + if parts is None: + # old-style modification + continue + + old_value = entry_attrs.get(param.name) + if not old_value: + raise errors.RequirementError(name=param.name) + if isinstance(old_value, (tuple, list)): + if len(old_value) > 1: + raise errors.ValidationError(name=param.name, + error=_('DNS records can be only updated one at a time')) + old_value = old_value[0] + + updated_attrs[param.name] = (old_value, parts) + + # Run pre_callback validators + self.obj.run_precallback_validators(dn, entry_attrs, *keys, **options) + + # current entry is needed in case of per-dns-record-part updates and + # for record type collision check + try: + old_entry = ldap.get_entry(dn, _record_attributes) + except errors.NotFound: + self.obj.handle_not_found(*keys) + + if updated_attrs: + for attr in updated_attrs: + param = self.params[attr] + old_dnsvalue, new_parts = updated_attrs[attr] + + if old_dnsvalue not in old_entry.get(attr, []): + attr_name = unicode(param.label or param.name) + raise errors.AttrValueNotFound(attr=attr_name, + value=old_dnsvalue) + old_entry[attr].remove(old_dnsvalue) + + old_parts = param._get_part_values(old_dnsvalue) + modified_parts = tuple(part if part is not None else old_parts[part_id] \ + for part_id,part in enumerate(new_parts)) + + new_dnsvalue = [param._convert_scalar(modified_parts)] + entry_attrs[attr] = list(set(old_entry[attr] + new_dnsvalue)) + + rrattrs = self.obj.updated_rrattrs(old_entry, entry_attrs) + self.obj.check_record_type_dependencies(keys, rrattrs) + self.obj.check_record_type_collisions(keys, rrattrs) + + context.dnsrecord_entry_mods = getattr(context, 'dnsrecord_entry_mods', + {}) + context.dnsrecord_entry_mods[(keys[0], keys[1])] = entry_attrs.copy() + return dn + + def execute(self, *keys, **options): + result = super(dnsrecord_mod, self).execute(*keys, **options) + + # remove if empty + if not self.obj.is_pkey_zone_record(*keys): + rename = options.get('rename') + if rename is not None: + keys = keys[:-1] + (rename,) + dn = self.obj.get_dn(*keys, **options) + ldap = self.obj.backend + old_entry = ldap.get_entry(dn, _record_attributes) + + del_all = True + for attr in old_entry.keys(): + if old_entry[attr]: + del_all = False + break + + if del_all: + result = self.obj.methods.delentry(*keys, + version=options['version']) + + # we need to modify delete result to match mod output type + # only one value is expected, not a list + if client_has_capability(options['version'], 'primary_key_types'): + assert len(result['value']) == 1 + result['value'] = result['value'][0] + + # indicate that entry was deleted + context.dnsrecord_entry_mods[(keys[0], keys[1])] = None + + if self.api.env['wait_for_dns']: + self.obj.wait_for_modified_entries(context.dnsrecord_entry_mods) + if 'nsrecord' in options: + self.obj.warning_if_ns_change_cause_fwzone_ineffective(result, + *keys, + **options) + return result + + def post_callback(self, ldap, dn, entry_attrs, *keys, **options): + assert isinstance(dn, DN) + if self.obj.is_pkey_zone_record(*keys): + entry_attrs[self.obj.primary_key.name] = [_dns_zone_record] + + self.obj.postprocess_record(entry_attrs, **options) + return dn + + +@register() +class dnsrecord_delentry(LDAPDelete): + """ + Delete DNS record entry. + """ + msg_summary = _('Deleted record "%(value)s"') + NO_CLI = True + + + +@register() +class dnsrecord_del(LDAPUpdate): + __doc__ = _('Delete DNS resource record.') + + has_output = output.standard_multi_delete + + no_option_msg = _('Neither --del-all nor options to delete a specific record provided.\n'\ + "Command help may be consulted for all supported record types.") + + takes_options = ( + Flag('del_all', + default=False, + label=_('Delete all associated records'), + ), + dnsrecord.structured_flag, + ) + + def get_options(self): + for option in super(dnsrecord_del, self).get_options(): + if any(flag in option.flags for flag in \ + ('dnsrecord_part', 'dnsrecord_extra',)): + continue + elif option.name in ('rename', ): + # options only valid for dnsrecord-mod + continue + elif isinstance(option, DNSRecord): + yield option.clone(option_group=None) + continue + yield option + + def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options): + assert isinstance(dn, DN) + try: + old_entry = ldap.get_entry(dn, _record_attributes) + except errors.NotFound: + self.obj.handle_not_found(*keys) + + for attr in entry_attrs.keys(): + if attr not in _record_attributes: + continue + if not isinstance(entry_attrs[attr], (tuple, list)): + vals = [entry_attrs[attr]] + else: + vals = entry_attrs[attr] + + for val in vals: + try: + old_entry[attr].remove(val) + except (KeyError, ValueError): + try: + param = self.params[attr] + attr_name = unicode(param.label or param.name) + except Exception: + attr_name = attr + raise errors.AttrValueNotFound(attr=attr_name, value=val) + entry_attrs[attr] = list(set(old_entry[attr])) + + rrattrs = self.obj.updated_rrattrs(old_entry, entry_attrs) + self.obj.check_record_type_dependencies(keys, rrattrs) + + del_all = False + if not self.obj.is_pkey_zone_record(*keys): + record_found = False + for attr in old_entry.keys(): + if old_entry[attr]: + record_found = True + break + del_all = not record_found + + # set del_all flag in context + # when the flag is enabled, the entire DNS record object is deleted + # in a post callback + context.del_all = del_all + context.dnsrecord_entry_mods = getattr(context, 'dnsrecord_entry_mods', + {}) + context.dnsrecord_entry_mods[(keys[0], keys[1])] = entry_attrs.copy() + + return dn + + def execute(self, *keys, **options): + if options.get('del_all', False): + if self.obj.is_pkey_zone_record(*keys): + raise errors.ValidationError( + name='del_all', + error=_('Zone record \'%s\' cannot be deleted') \ + % _dns_zone_record + ) + result = self.obj.methods.delentry(*keys, + version=options['version']) + if self.api.env['wait_for_dns']: + entries = {(keys[0], keys[1]): None} + self.obj.wait_for_modified_entries(entries) + else: + result = super(dnsrecord_del, self).execute(*keys, **options) + result['value'] = pkey_to_value([keys[-1]], options) + + if getattr(context, 'del_all', False) and not \ + self.obj.is_pkey_zone_record(*keys): + result = self.obj.methods.delentry(*keys, + version=options['version']) + context.dnsrecord_entry_mods[(keys[0], keys[1])] = None + + if self.api.env['wait_for_dns']: + self.obj.wait_for_modified_entries(context.dnsrecord_entry_mods) + + if 'nsrecord' in options or options.get('del_all', False): + self.obj.warning_if_ns_change_cause_fwzone_ineffective(result, + *keys, + **options) + return result + + def post_callback(self, ldap, dn, entry_attrs, *keys, **options): + assert isinstance(dn, DN) + if self.obj.is_pkey_zone_record(*keys): + entry_attrs[self.obj.primary_key.name] = [_dns_zone_record] + self.obj.postprocess_record(entry_attrs, **options) + return dn + + def args_options_2_entry(self, *keys, **options): + has_cli_options(self, options, self.no_option_msg) + return super(dnsrecord_del, self).args_options_2_entry(*keys, **options) + + +@register() +class dnsrecord_show(LDAPRetrieve): + __doc__ = _('Display DNS resource.') + + takes_options = LDAPRetrieve.takes_options + ( + dnsrecord.structured_flag, + ) + + def post_callback(self, ldap, dn, entry_attrs, *keys, **options): + assert isinstance(dn, DN) + if self.obj.is_pkey_zone_record(*keys): + entry_attrs[self.obj.primary_key.name] = [_dns_zone_record] + self.obj.postprocess_record(entry_attrs, **options) + return dn + + + +@register() +class dnsrecord_find(LDAPSearch): + __doc__ = _('Search for DNS resources.') + + takes_options = LDAPSearch.takes_options + ( + dnsrecord.structured_flag, + ) + + def get_options(self): + for option in super(dnsrecord_find, self).get_options(): + if any(flag in option.flags for flag in \ + ('dnsrecord_part', 'dnsrecord_extra',)): + continue + elif isinstance(option, DNSRecord): + yield option.clone(option_group=None) + continue + yield option + + def pre_callback(self, ldap, filter, attrs_list, base_dn, scope, + dnszoneidnsname, *args, **options): + assert isinstance(base_dn, DN) + + # validate if zone is master zone + self.obj.check_zone(dnszoneidnsname, **options) + + filter = _create_idn_filter(self, ldap, *args, **options) + return (filter, base_dn, ldap.SCOPE_SUBTREE) + + def post_callback(self, ldap, entries, truncated, *args, **options): + if entries: + zone_obj = self.api.Object[self.obj.parent_object] + zone_dn = zone_obj.get_dn(args[0]) + if entries[0].dn == zone_dn: + entries[0][zone_obj.primary_key.name] = [_dns_zone_record] + for entry in entries: + self.obj.postprocess_record(entry, **options) + + return truncated + + +@register() +class dns_resolve(Command): + __doc__ = _('Resolve a host name in DNS. (Deprecated)') + + NO_CLI = True + + has_output = output.standard_value + msg_summary = _('Found \'%(value)s\'') + + takes_args = ( + Str('hostname', + label=_('Hostname (FQDN)'), + ), + ) + + def execute(self, *args, **options): + query=args[0] + + try: + verify_host_resolvable(query) + except errors.DNSNotARecordError: + raise errors.NotFound( + reason=_('Host \'%(host)s\' not found') % {'host': query} + ) + result = dict(result=True, value=query) + messages.add_message( + options['version'], result, + messages.CommandDeprecatedWarning( + command='dns-resolve', + additional_info='The command may return an unexpected result, ' + 'the resolution of the DNS domain is done on ' + 'a randomly chosen IPA server.' + ) + ) + return result + + +@register() +class dns_is_enabled(Command): + """ + Checks if any of the servers has the DNS service enabled. + """ + NO_CLI = True + has_output = output.standard_value + + base_dn = DN(('cn', 'masters'), ('cn', 'ipa'), ('cn', 'etc'), api.env.basedn) + filter = '(&(objectClass=ipaConfigObject)(cn=DNS))' + + def execute(self, *args, **options): + ldap = self.api.Backend.ldap2 + dns_enabled = False + + try: + ldap.find_entries(filter=self.filter, base_dn=self.base_dn) + dns_enabled = True + except errors.EmptyResult: + dns_enabled = False + + return dict(result=dns_enabled, value=pkey_to_value(None, options)) + + +@register() +class dnsconfig(LDAPObject): + """ + DNS global configuration object + """ + object_name = _('DNS configuration options') + default_attributes = [ + 'idnsforwardpolicy', 'idnsforwarders', 'idnsallowsyncptr' + ] + + label = _('DNS Global Configuration') + label_singular = _('DNS Global Configuration') + + takes_params = ( + Str('idnsforwarders*', + _validate_bind_forwarder, + cli_name='forwarder', + label=_('Global forwarders'), + doc=_('Global forwarders. A custom port can be specified for each ' + 'forwarder using a standard format "IP_ADDRESS port PORT"'), + ), + StrEnum('idnsforwardpolicy?', + cli_name='forward_policy', + label=_('Forward policy'), + doc=_('Global forwarding policy. Set to "none" to disable ' + 'any configured global forwarders.'), + values=(u'only', u'first', u'none'), + ), + Bool('idnsallowsyncptr?', + cli_name='allow_sync_ptr', + label=_('Allow PTR sync'), + doc=_('Allow synchronization of forward (A, AAAA) and reverse (PTR) records'), + ), + Int('idnszonerefresh?', + deprecated=True, + cli_name='zone_refresh', + label=_('Zone refresh interval'), + doc=_('An interval between regular polls of the name server for new DNS zones'), + minvalue=0, + flags={'no_option'}, + ), + Int('ipadnsversion?', # available only in installer/upgrade + label=_('IPA DNS version'), + ), + ) + managed_permissions = { + 'System: Write DNS Configuration': { + 'non_object': True, + 'ipapermright': {'write'}, + 'ipapermlocation': api.env.basedn, + 'ipapermtarget': DN('cn=dns', api.env.basedn), + 'ipapermtargetfilter': ['(objectclass=idnsConfigObject)'], + 'ipapermdefaultattr': { + 'idnsallowsyncptr', 'idnsforwarders', 'idnsforwardpolicy', + 'idnspersistentsearch', 'idnszonerefresh' + }, + 'replaces': [ + '(targetattr = "idnsforwardpolicy || idnsforwarders || idnsallowsyncptr || idnszonerefresh || idnspersistentsearch")(target = "ldap:///cn=dns,$SUFFIX")(version 3.0;acl "permission:Write DNS Configuration";allow (write) groupdn = "ldap:///cn=Write DNS Configuration,cn=permissions,cn=pbac,$SUFFIX";)', + ], + 'default_privileges': {'DNS Administrators', 'DNS Servers'}, + }, + 'System: Read DNS Configuration': { + 'non_object': True, + 'ipapermright': {'read'}, + 'ipapermlocation': api.env.basedn, + 'ipapermtarget': DN('cn=dns', api.env.basedn), + 'ipapermtargetfilter': ['(objectclass=idnsConfigObject)'], + 'ipapermdefaultattr': { + 'objectclass', + 'idnsallowsyncptr', 'idnsforwarders', 'idnsforwardpolicy', + 'idnspersistentsearch', 'idnszonerefresh', 'ipadnsversion' + }, + 'default_privileges': {'DNS Administrators', 'DNS Servers'}, + }, + } + + def get_dn(self, *keys, **kwargs): + if not dns_container_exists(self.api.Backend.ldap2): + raise errors.NotFound(reason=_('DNS is not configured')) + return DN(api.env.container_dns, api.env.basedn) + + def get_dnsconfig(self, ldap): + entry = ldap.get_entry(self.get_dn(), None) + + return entry + + def postprocess_result(self, result): + if not any(param in result['result'] for param in self.params): + result['summary'] = unicode(_('Global DNS configuration is empty')) + + +@register() +class dnsconfig_mod(LDAPUpdate): + __doc__ = _('Modify global DNS configuration.') + + def get_options(self): + """hide ipadnsversion outside of installer/upgrade""" + for option in super(dnsconfig_mod, self).get_options(): + if option.name == 'ipadnsversion': + option = option.clone(include=('installer', 'updates')) + yield option + + def execute(self, *keys, **options): + # test dnssec forwarders + forwarders = options.get('idnsforwarders') + + result = super(dnsconfig_mod, self).execute(*keys, **options) + self.obj.postprocess_result(result) + + # this check makes sense only when resulting forwarders are non-empty + if result['result'].get('idnsforwarders'): + fwzone = DNSName('.') + _add_warning_fw_policy_conflict_aez(result, fwzone, **options) + + if forwarders: + # forwarders were changed + for forwarder in forwarders: + try: + validate_dnssec_global_forwarder(forwarder, log=self.log) + except DNSSECSignatureMissingError as e: + messages.add_message( + options['version'], + result, messages.DNSServerDoesNotSupportDNSSECWarning( + server=forwarder, error=e, + ) + ) + except EDNS0UnsupportedError as e: + messages.add_message( + options['version'], + result, messages.DNSServerDoesNotSupportEDNS0Warning( + server=forwarder, error=e, + ) + ) + except UnresolvableRecordError as e: + messages.add_message( + options['version'], + result, messages.DNSServerValidationWarning( + server=forwarder, error=e + ) + ) + + return result + + + +@register() +class dnsconfig_show(LDAPRetrieve): + __doc__ = _('Show the current global DNS configuration.') + + def execute(self, *keys, **options): + result = super(dnsconfig_show, self).execute(*keys, **options) + self.obj.postprocess_result(result) + return result + + + +@register() +class dnsforwardzone(DNSZoneBase): + """ + DNS Forward zone, container for resource records. + """ + object_name = _('DNS forward zone') + object_name_plural = _('DNS forward zones') + object_class = DNSZoneBase.object_class + ['idnsforwardzone'] + label = _('DNS Forward Zones') + label_singular = _('DNS Forward Zone') + default_forward_policy = u'first' + + # managed_permissions: permissions was apllied in dnszone class, do NOT + # add them here, they should not be applied twice. + + def _warning_fw_zone_is_not_effective(self, result, *keys, **options): + fwzone = keys[-1] + _add_warning_fw_zone_is_not_effective(self.api, result, fwzone, + options['version']) + + def _warning_if_forwarders_do_not_work(self, result, new_zone, + *keys, **options): + fwzone = keys[-1] + forwarders = options.get('idnsforwarders', []) + any_forwarder_work = False + + for forwarder in forwarders: + try: + validate_dnssec_zone_forwarder_step1(forwarder, fwzone, + log=self.log) + except UnresolvableRecordError as e: + messages.add_message( + options['version'], + result, messages.DNSServerValidationWarning( + server=forwarder, error=e + ) + ) + except EDNS0UnsupportedError as e: + messages.add_message( + options['version'], + result, messages.DNSServerDoesNotSupportEDNS0Warning( + server=forwarder, error=e + ) + ) + else: + any_forwarder_work = True + + if not any_forwarder_work: + # do not test DNSSEC validation if there is no valid forwarder + return + + # resolve IP address of any DNS replica + # FIXME: https://fedorahosted.org/bind-dyndb-ldap/ticket/143 + # we currenly should to test all IPA DNS replica, because DNSSEC + # validation is configured just in named.conf per replica + + ipa_dns_masters = [normalize_zone(x) for x in + self.api.Object.dnsrecord.get_dns_masters()] + + if not ipa_dns_masters: + # something very bad happened, DNS is installed, but no IPA DNS + # servers available + self.log.error("No IPA DNS server can be found, but integrated DNS " + "is installed") + return + + ipa_dns_ip = None + for rdtype in (dns.rdatatype.A, dns.rdatatype.AAAA): + try: + ans = dns.resolver.query(ipa_dns_masters[0], rdtype) + except dns.exception.DNSException: + continue + else: + ipa_dns_ip = str(ans.rrset.items[0]) + break + + if not ipa_dns_ip: + self.log.error("Cannot resolve %s hostname", ipa_dns_masters[0]) + return + + # sleep a bit, adding new zone to BIND from LDAP may take a while + if new_zone: + time.sleep(5) + + # Test if IPA is able to receive replies from forwarders + try: + validate_dnssec_zone_forwarder_step2(ipa_dns_ip, fwzone, + log=self.log) + except DNSSECValidationError as e: + messages.add_message( + options['version'], + result, messages.DNSSECValidationFailingWarning(error=e) + ) + except UnresolvableRecordError as e: + messages.add_message( + options['version'], + result, messages.DNSServerValidationWarning( + server=ipa_dns_ip, error=e + ) + ) + + +@register() +class dnsforwardzone_add(DNSZoneBase_add): + __doc__ = _('Create new DNS forward zone.') + + def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options): + assert isinstance(dn, DN) + + dn = super(dnsforwardzone_add, self).pre_callback(ldap, dn, + entry_attrs, attrs_list, *keys, **options) + + if 'idnsforwardpolicy' not in entry_attrs: + entry_attrs['idnsforwardpolicy'] = self.obj.default_forward_policy + + if (not entry_attrs.get('idnsforwarders') and + entry_attrs['idnsforwardpolicy'] != u'none'): + raise errors.ValidationError(name=u'idnsforwarders', + error=_('Please specify forwarders.')) + + return dn + + def execute(self, *keys, **options): + fwzone = keys[-1] + result = super(dnsforwardzone_add, self).execute(*keys, **options) + self.obj._warning_fw_zone_is_not_effective(result, *keys, **options) + _add_warning_fw_policy_conflict_aez(result, fwzone, **options) + if options.get('idnsforwarders'): + self.obj._warning_if_forwarders_do_not_work( + result, True, *keys, **options) + return result + + +@register() +class dnsforwardzone_del(DNSZoneBase_del): + __doc__ = _('Delete DNS forward zone.') + + msg_summary = _('Deleted DNS forward zone "%(value)s"') + + +@register() +class dnsforwardzone_mod(DNSZoneBase_mod): + __doc__ = _('Modify DNS forward zone.') + + def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options): + try: + entry = ldap.get_entry(dn) + except errors.NotFound: + self.obj.handle_not_found(*keys) + + if not _check_entry_objectclass(entry, self.obj.object_class): + self.obj.handle_not_found(*keys) + + policy = self.obj.default_forward_policy + forwarders = [] + + if 'idnsforwarders' in entry_attrs: + forwarders = entry_attrs['idnsforwarders'] + elif 'idnsforwarders' in entry: + forwarders = entry['idnsforwarders'] + + if 'idnsforwardpolicy' in entry_attrs: + policy = entry_attrs['idnsforwardpolicy'] + elif 'idnsforwardpolicy' in entry: + policy = entry['idnsforwardpolicy'] + + if not forwarders and policy != u'none': + raise errors.ValidationError(name=u'idnsforwarders', + error=_('Please specify forwarders.')) + + return dn + + def execute(self, *keys, **options): + fwzone = keys[-1] + result = super(dnsforwardzone_mod, self).execute(*keys, **options) + _add_warning_fw_policy_conflict_aez(result, fwzone, **options) + if options.get('idnsforwarders'): + self.obj._warning_if_forwarders_do_not_work(result, False, *keys, + **options) + return result + +@register() +class dnsforwardzone_find(DNSZoneBase_find): + __doc__ = _('Search for DNS forward zones.') + + +@register() +class dnsforwardzone_show(DNSZoneBase_show): + __doc__ = _('Display information about a DNS forward zone.') + + has_output_params = LDAPRetrieve.has_output_params + dnszone_output_params + + +@register() +class dnsforwardzone_disable(DNSZoneBase_disable): + __doc__ = _('Disable DNS Forward Zone.') + msg_summary = _('Disabled DNS forward zone "%(value)s"') + + +@register() +class dnsforwardzone_enable(DNSZoneBase_enable): + __doc__ = _('Enable DNS Forward Zone.') + msg_summary = _('Enabled DNS forward zone "%(value)s"') + + def execute(self, *keys, **options): + result = super(dnsforwardzone_enable, self).execute(*keys, **options) + self.obj._warning_fw_zone_is_not_effective(result, *keys, **options) + return result + + +@register() +class dnsforwardzone_add_permission(DNSZoneBase_add_permission): + __doc__ = _('Add a permission for per-forward zone access delegation.') + + +@register() +class dnsforwardzone_remove_permission(DNSZoneBase_remove_permission): + __doc__ = _('Remove a permission for per-forward zone access delegation.') diff --git a/ipaserver/plugins/dogtag.py b/ipaserver/plugins/dogtag.py index 8836e70dc..197814c4d 100644 --- a/ipaserver/plugins/dogtag.py +++ b/ipaserver/plugins/dogtag.py @@ -244,19 +244,21 @@ import json from lxml import etree import time -import pki -from pki.client import PKIConnection -import pki.crypto as cryptoutil -from pki.kra import KRAClient import six from six.moves import urllib -from ipalib import Backend +from ipalib import Backend, api from ipapython.dn import DN import ipapython.cookie from ipapython import dogtag from ipapython import ipautil +if api.env.in_server: + import pki + from pki.client import PKIConnection + import pki.crypto as cryptoutil + from pki.kra import KRAClient + if six.PY3: unicode = str @@ -1269,7 +1271,7 @@ def select_any_master(ldap2, service='CA'): #------------------------------------------------------------------------------- -from ipalib import Registry, api, errors, SkipPluginModule +from ipalib import Registry, errors, SkipPluginModule if api.env.ra_plugin != 'dogtag': # In this case, abort loading this plugin module... raise SkipPluginModule(reason='dogtag not selected as RA plugin') diff --git a/ipaserver/plugins/domainlevel.py b/ipaserver/plugins/domainlevel.py new file mode 100644 index 000000000..23fa2a1b2 --- /dev/null +++ b/ipaserver/plugins/domainlevel.py @@ -0,0 +1,137 @@ +# +# Copyright (C) 2015 FreeIPA Contributors see COPYING for license +# + +from collections import namedtuple + +from ipalib import _ +from ipalib import Command +from ipalib import errors +from ipalib import output +from ipalib.parameters import Int +from ipalib.plugable import Registry + +from ipapython.dn import DN + + +__doc__ = _(""" +Raise the IPA Domain Level. +""") + +register = Registry() + +DomainLevelRange = namedtuple('DomainLevelRange', ['min', 'max']) + +domainlevel_output = ( + output.Output('result', int, _('Current domain level:')), +) + + +def get_domainlevel_dn(api): + domainlevel_dn = DN( + ('cn', 'Domain Level'), + ('cn', 'ipa'), + ('cn', 'etc'), + api.env.basedn + ) + + return domainlevel_dn + + +def get_domainlevel_range(master_entry): + try: + return DomainLevelRange( + int(master_entry['ipaMinDomainLevel'][0]), + int(master_entry['ipaMaxDomainLevel'][0]) + ) + except KeyError: + return DomainLevelRange(0, 0) + + +def get_master_entries(ldap, api): + """ + Returns list of LDAPEntries representing IPA masters. + """ + + container_masters = DN( + ('cn', 'masters'), + ('cn', 'ipa'), + ('cn', 'etc'), + api.env.basedn + ) + + masters, _ = ldap.find_entries( + filter="(cn=*)", + base_dn=container_masters, + scope=ldap.SCOPE_ONELEVEL, + paged_search=True, # we need to make sure to get all of them + ) + + return masters + + +@register() +class domainlevel_get(Command): + __doc__ = _('Query current Domain Level.') + + has_output = domainlevel_output + + def execute(self, *args, **options): + ldap = self.api.Backend.ldap2 + entry = ldap.get_entry( + get_domainlevel_dn(self.api), + ['ipaDomainLevel'] + ) + + return {'result': int(entry.single_value['ipaDomainLevel'])} + + +@register() +class domainlevel_set(Command): + __doc__ = _('Change current Domain Level.') + + has_output = domainlevel_output + + takes_args = ( + Int('ipadomainlevel', + cli_name='level', + label=_('Domain Level'), + minvalue=0, + ), + ) + + def execute(self, *args, **options): + """ + Checks all the IPA masters for supported domain level ranges. + + If the desired domain level is within the supported range of all + masters, it will be raised. + + Domain level cannot be lowered. + """ + + ldap = self.api.Backend.ldap2 + + current_entry = ldap.get_entry(get_domainlevel_dn(self.api)) + current_value = int(current_entry.single_value['ipadomainlevel']) + desired_value = int(args[0]) + + # Domain level cannot be lowered + if int(desired_value) < int(current_value): + message = _("Domain Level cannot be lowered.") + raise errors.InvalidDomainLevelError(reason=message) + + # Check if every master supports the desired level + for master in get_master_entries(ldap, self.api): + supported = get_domainlevel_range(master) + + if supported.min > desired_value or supported.max < desired_value: + message = _("Domain Level cannot be raised to {0}, server {1} " + "does not support it." + .format(desired_value, master['cn'][0])) + raise errors.InvalidDomainLevelError(reason=message) + + current_entry.single_value['ipaDomainLevel'] = desired_value + ldap.update_entry(current_entry) + + return {'result': int(current_entry.single_value['ipaDomainLevel'])} diff --git a/ipaserver/plugins/group.py b/ipaserver/plugins/group.py new file mode 100644 index 000000000..2b0c08050 --- /dev/null +++ b/ipaserver/plugins/group.py @@ -0,0 +1,690 @@ +# Authors: +# Rob Crittenden <rcritten@redhat.com> +# Pavel Zuna <pzuna@redhat.com> +# +# Copyright (C) 2009 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +import six + +from ipalib import api +from ipalib import Int, Str, Flag +from ipalib.plugable import Registry +from .baseldap import ( + add_external_post_callback, + pkey_to_value, + remove_external_post_callback, + LDAPObject, + LDAPCreate, + LDAPUpdate, + LDAPDelete, + LDAPSearch, + LDAPRetrieve, + LDAPAddMember, + LDAPRemoveMember, + LDAPQuery, +) +from .idviews import remove_ipaobject_overrides +from . import baseldap +from ipalib import _, ngettext +from ipalib import errors +from ipalib import output +from ipapython.dn import DN + +if six.PY3: + unicode = str + +if api.env.in_server and api.env.context in ['lite', 'server']: + try: + import ipaserver.dcerpc + _dcerpc_bindings_installed = True + except ImportError: + _dcerpc_bindings_installed = False + +__doc__ = _(""" +Groups of users + +Manage groups of users. By default, new groups are POSIX groups. You +can add the --nonposix option to the group-add command to mark a new group +as non-POSIX. You can use the --posix argument with the group-mod command +to convert a non-POSIX group into a POSIX group. POSIX groups cannot be +converted to non-POSIX groups. + +Every group must have a description. + +POSIX groups must have a Group ID (GID) number. Changing a GID is +supported but can have an impact on your file permissions. It is not necessary +to supply a GID when creating a group. IPA will generate one automatically +if it is not provided. + +EXAMPLES: + + Add a new group: + ipa group-add --desc='local administrators' localadmins + + Add a new non-POSIX group: + ipa group-add --nonposix --desc='remote administrators' remoteadmins + + Convert a non-POSIX group to posix: + ipa group-mod --posix remoteadmins + + Add a new POSIX group with a specific Group ID number: + ipa group-add --gid=500 --desc='unix admins' unixadmins + + Add a new POSIX group and let IPA assign a Group ID number: + ipa group-add --desc='printer admins' printeradmins + + Remove a group: + ipa group-del unixadmins + + To add the "remoteadmins" group to the "localadmins" group: + ipa group-add-member --groups=remoteadmins localadmins + + Add multiple users to the "localadmins" group: + ipa group-add-member --users=test1 --users=test2 localadmins + + Remove a user from the "localadmins" group: + ipa group-remove-member --users=test2 localadmins + + Display information about a named group. + ipa group-show localadmins + +External group membership is designed to allow users from trusted domains +to be mapped to local POSIX groups in order to actually use IPA resources. +External members should be added to groups that specifically created as +external and non-POSIX. Such group later should be included into one of POSIX +groups. + +An external group member is currently a Security Identifier (SID) as defined by +the trusted domain. When adding external group members, it is possible to +specify them in either SID, or DOM\\name, or name@domain format. IPA will attempt +to resolve passed name to SID with the use of Global Catalog of the trusted domain. + +Example: + +1. Create group for the trusted domain admins' mapping and their local POSIX group: + + ipa group-add --desc='<ad.domain> admins external map' ad_admins_external --external + ipa group-add --desc='<ad.domain> admins' ad_admins + +2. Add security identifier of Domain Admins of the <ad.domain> to the ad_admins_external + group: + + ipa group-add-member ad_admins_external --external 'AD\\Domain Admins' + +3. Allow members of ad_admins_external group to be associated with ad_admins POSIX group: + + ipa group-add-member ad_admins --groups ad_admins_external + +4. List members of external members of ad_admins_external group to see their SIDs: + + ipa group-show ad_admins_external +""") + +register = Registry() + +PROTECTED_GROUPS = (u'admins', u'trust admins', u'default smb group') + + +@register() +class group(LDAPObject): + """ + Group object. + """ + container_dn = api.env.container_group + object_name = _('group') + object_name_plural = _('groups') + object_class = ['ipausergroup'] + object_class_config = 'ipagroupobjectclasses' + possible_objectclasses = ['posixGroup', 'mepManagedEntry', 'ipaExternalGroup'] + permission_filter_objectclasses = ['posixgroup', 'ipausergroup'] + search_attributes_config = 'ipagroupsearchfields' + default_attributes = [ + 'cn', 'description', 'gidnumber', 'member', 'memberof', + 'memberindirect', 'memberofindirect', 'ipaexternalmember', + ] + uuid_attribute = 'ipauniqueid' + attribute_members = { + 'member': ['user', 'group'], + 'memberof': ['group', 'netgroup', 'role', 'hbacrule', 'sudorule'], + 'memberindirect': ['user', 'group'], + 'memberofindirect': ['group', 'netgroup', 'role', 'hbacrule', + 'sudorule'], + } + rdn_is_primary_key = True + managed_permissions = { + 'System: Read Groups': { + 'replaces_global_anonymous_aci': True, + 'ipapermbindruletype': 'anonymous', + 'ipapermright': {'read', 'search', 'compare'}, + 'ipapermdefaultattr': { + 'businesscategory', 'cn', 'description', 'gidnumber', + 'ipaexternalmember', 'ipauniqueid', 'mepmanagedby', 'o', + 'objectclass', 'ou', 'owner', 'seealso', + 'ipantsecurityidentifier' + }, + }, + 'System: Read Group Membership': { + 'replaces_global_anonymous_aci': True, + 'ipapermbindruletype': 'all', + 'ipapermright': {'read', 'search', 'compare'}, + 'ipapermdefaultattr': { + 'member', 'memberof', 'memberuid', 'memberuser', 'memberhost', + }, + }, + 'System: Add Groups': { + 'ipapermright': {'add'}, + 'replaces': [ + '(target = "ldap:///cn=*,cn=groups,cn=accounts,$SUFFIX")(version 3.0;acl "permission:Add Groups";allow (add) groupdn = "ldap:///cn=Add Groups,cn=permissions,cn=pbac,$SUFFIX";)', + ], + 'default_privileges': {'Group Administrators'}, + }, + 'System: Modify Group Membership': { + 'ipapermright': {'write'}, + 'ipapermtargetfilter': [ + '(objectclass=ipausergroup)', + '(!(cn=admins))', + ], + 'ipapermdefaultattr': {'member'}, + 'replaces': [ + '(targetattr = "member")(target = "ldap:///cn=*,cn=groups,cn=accounts,$SUFFIX")(version 3.0;acl "permission:Modify Group membership";allow (write) groupdn = "ldap:///cn=Modify Group membership,cn=permissions,cn=pbac,$SUFFIX";)', + '(targetfilter = "(!(cn=admins))")(targetattr = "member")(target = "ldap:///cn=*,cn=groups,cn=accounts,$SUFFIX")(version 3.0;acl "permission:Modify Group membership";allow (write) groupdn = "ldap:///cn=Modify Group membership,cn=permissions,cn=pbac,$SUFFIX";)', + ], + 'default_privileges': { + 'Group Administrators', 'Modify Group membership' + }, + }, + 'System: Modify Groups': { + 'ipapermright': {'write'}, + 'ipapermdefaultattr': { + 'cn', 'description', 'gidnumber', 'ipauniqueid', + 'mepmanagedby', 'objectclass' + }, + 'replaces': [ + '(targetattr = "cn || description || gidnumber || objectclass || mepmanagedby || ipauniqueid")(target = "ldap:///cn=*,cn=groups,cn=accounts,$SUFFIX")(version 3.0;acl "permission:Modify Groups";allow (write) groupdn = "ldap:///cn=Modify Groups,cn=permissions,cn=pbac,$SUFFIX";)', + ], + 'default_privileges': {'Group Administrators'}, + }, + 'System: Remove Groups': { + 'ipapermright': {'delete'}, + 'replaces': [ + '(target = "ldap:///cn=*,cn=groups,cn=accounts,$SUFFIX")(version 3.0;acl "permission:Remove Groups";allow (delete) groupdn = "ldap:///cn=Remove Groups,cn=permissions,cn=pbac,$SUFFIX";)', + ], + 'default_privileges': {'Group Administrators'}, + }, + 'System: Read Group Compat Tree': { + 'non_object': True, + 'ipapermbindruletype': 'anonymous', + 'ipapermlocation': api.env.basedn, + 'ipapermtarget': DN('cn=groups', 'cn=compat', api.env.basedn), + 'ipapermright': {'read', 'search', 'compare'}, + 'ipapermdefaultattr': { + 'objectclass', 'cn', 'memberuid', 'gidnumber', + }, + }, + 'System: Read Group Views Compat Tree': { + 'non_object': True, + 'ipapermbindruletype': 'anonymous', + 'ipapermlocation': api.env.basedn, + 'ipapermtarget': DN('cn=groups', 'cn=*', 'cn=views', 'cn=compat', api.env.basedn), + 'ipapermright': {'read', 'search', 'compare'}, + 'ipapermdefaultattr': { + 'objectclass', 'cn', 'memberuid', 'gidnumber', + }, + }, + } + + label = _('User Groups') + label_singular = _('User Group') + + takes_params = ( + Str('cn', + pattern='^[a-zA-Z0-9_.][a-zA-Z0-9_.-]{0,252}[a-zA-Z0-9_.$-]?$', + pattern_errmsg='may only include letters, numbers, _, -, . and $', + maxlength=255, + cli_name='group_name', + label=_('Group name'), + primary_key=True, + normalizer=lambda value: value.lower(), + ), + Str('description?', + cli_name='desc', + label=_('Description'), + doc=_('Group description'), + ), + Int('gidnumber?', + cli_name='gid', + label=_('GID'), + doc=_('GID (use this option to set it manually)'), + minvalue=1, + ), + ) + + +ipaexternalmember_param = Str('ipaexternalmember*', + cli_name='external', + label=_('External member'), + doc=_('Members of a trusted domain in DOM\\name or name@domain form'), + flags=['no_create', 'no_update', 'no_search'], + ) + + +@register() +class group_add(LDAPCreate): + __doc__ = _('Create a new group.') + + msg_summary = _('Added group "%(value)s"') + + takes_options = LDAPCreate.takes_options + ( + Flag('nonposix', + cli_name='nonposix', + doc=_('Create as a non-POSIX group'), + default=False, + ), + Flag('external', + cli_name='external', + doc=_('Allow adding external non-IPA members from trusted domains'), + default=False, + ), + ) + + def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options): + # As both 'external' and 'nonposix' options have default= set for + # them, they will always be present in options dict, thus we can + # safely reference the values + assert isinstance(dn, DN) + if options['external']: + entry_attrs['objectclass'].append('ipaexternalgroup') + if 'gidnumber' in options: + raise errors.MutuallyExclusiveError(reason=_('gid cannot be set for external group')) + elif not options['nonposix']: + entry_attrs['objectclass'].append('posixgroup') + if not 'gidnumber' in options: + entry_attrs['gidnumber'] = baseldap.DNA_MAGIC + return dn + + +@register() +class group_del(LDAPDelete): + __doc__ = _('Delete group.') + + msg_summary = _('Deleted group "%(value)s"') + + def pre_callback(self, ldap, dn, *keys, **options): + assert isinstance(dn, DN) + config = ldap.get_ipa_config() + def_primary_group = config.get('ipadefaultprimarygroup', '') + def_primary_group_dn = group_dn = self.obj.get_dn(def_primary_group) + if dn == def_primary_group_dn: + raise errors.DefaultGroupError() + group_attrs = self.obj.methods.show( + self.obj.get_primary_key_from_dn(dn), all=True + )['result'] + if keys[0] in PROTECTED_GROUPS: + raise errors.ProtectedEntryError(label=_(u'group'), key=keys[0], + reason=_(u'privileged group')) + if 'mepmanagedby' in group_attrs: + raise errors.ManagedGroupError() + + # Remove any ID overrides tied with this group + remove_ipaobject_overrides(ldap, self.obj.api, dn) + + return dn + + def post_callback(self, ldap, dn, *keys, **options): + assert isinstance(dn, DN) + try: + api.Command['pwpolicy_del'](keys[-1]) + except errors.NotFound: + pass + + return True + + +@register() +class group_mod(LDAPUpdate): + __doc__ = _('Modify a group.') + + msg_summary = _('Modified group "%(value)s"') + + takes_options = LDAPUpdate.takes_options + ( + Flag('posix', + cli_name='posix', + doc=_('change to a POSIX group'), + ), + Flag('external', + cli_name='external', + doc=_('change to support external non-IPA members from trusted domains'), + default=False, + ), + ) + + def pre_callback(self, ldap, dn, entry_attrs, *keys, **options): + assert isinstance(dn, DN) + + is_protected_group = keys[-1] in PROTECTED_GROUPS + + if 'rename' in options or 'cn' in entry_attrs: + if is_protected_group: + raise errors.ProtectedEntryError(label=u'group', key=keys[-1], + reason=u'Cannot be renamed') + + if ('posix' in options and options['posix']) or 'gidnumber' in options: + old_entry_attrs = ldap.get_entry(dn, ['objectclass']) + dn = old_entry_attrs.dn + if 'ipaexternalgroup' in old_entry_attrs['objectclass']: + raise errors.ExternalGroupViolation() + if 'posixgroup' in old_entry_attrs['objectclass']: + if options['posix']: + raise errors.AlreadyPosixGroup() + else: + old_entry_attrs['objectclass'].append('posixgroup') + entry_attrs['objectclass'] = old_entry_attrs['objectclass'] + if not 'gidnumber' in options: + entry_attrs['gidnumber'] = baseldap.DNA_MAGIC + + if options['external']: + if is_protected_group: + raise errors.ProtectedEntryError(label=u'group', key=keys[-1], + reason=u'Cannot support external non-IPA members') + old_entry_attrs = ldap.get_entry(dn, ['objectclass']) + dn = old_entry_attrs.dn + if 'posixgroup' in old_entry_attrs['objectclass']: + raise errors.PosixGroupViolation() + if 'ipaexternalgroup' in old_entry_attrs['objectclass']: + raise errors.AlreadyExternalGroup() + else: + old_entry_attrs['objectclass'].append('ipaexternalgroup') + entry_attrs['objectclass'] = old_entry_attrs['objectclass'] + + # Can't check for this in a validator because we lack context + if 'gidnumber' in options and options['gidnumber'] is None: + raise errors.RequirementError(name='gidnumber') + return dn + + def exc_callback(self, keys, options, exc, call_func, *call_args, **call_kwargs): + # Check again for GID requirement in case someone tried to clear it + # using --setattr. + if call_func.__name__ == 'update_entry': + if isinstance(exc, errors.ObjectclassViolation): + if 'gidNumber' in exc.message and 'posixGroup' in exc.message: + raise errors.RequirementError(name='gidnumber') + raise exc + + +@register() +class group_find(LDAPSearch): + __doc__ = _('Search for groups.') + + member_attributes = ['member', 'memberof'] + + msg_summary = ngettext( + '%(count)d group matched', '%(count)d groups matched', 0 + ) + + takes_options = LDAPSearch.takes_options + ( + Flag('private', + cli_name='private', + doc=_('search for private groups'), + ), + Flag('posix', + cli_name='posix', + doc=_('search for POSIX groups'), + ), + Flag('external', + cli_name='external', + doc=_('search for groups with support of external non-IPA members from trusted domains'), + ), + Flag('nonposix', + cli_name='nonposix', + doc=_('search for non-POSIX groups'), + ), + ) + + def pre_callback(self, ldap, filter, attrs_list, base_dn, scope, + criteria=None, **options): + assert isinstance(base_dn, DN) + + # filter groups by pseudo type + filters = [] + if options['posix']: + search_kw = {'objectclass': ['posixGroup']} + filters.append(ldap.make_filter(search_kw, rules=ldap.MATCH_ALL)) + if options['external']: + search_kw = {'objectclass': ['ipaExternalGroup']} + filters.append(ldap.make_filter(search_kw, rules=ldap.MATCH_ALL)) + if options['nonposix']: + search_kw = {'objectclass': ['posixGroup' , 'ipaExternalGroup']} + filters.append(ldap.make_filter(search_kw, rules=ldap.MATCH_NONE)) + + # if looking for private groups, we need to create a new search filter, + # because private groups have different object classes + if options['private']: + # filter based on options, oflt + search_kw = self.args_options_2_entry(**options) + search_kw['objectclass'] = ['posixGroup', 'mepManagedEntry'] + oflt = ldap.make_filter(search_kw, rules=ldap.MATCH_ALL) + + # filter based on 'criteria' argument + search_kw = {} + config = ldap.get_ipa_config() + attrs = config.get(self.obj.search_attributes_config, []) + if len(attrs) == 1 and isinstance(attrs[0], six.string_types): + search_attrs = attrs[0].split(',') + for a in search_attrs: + search_kw[a] = criteria + cflt = ldap.make_filter(search_kw, exact=False) + + filter = ldap.combine_filters((oflt, cflt), rules=ldap.MATCH_ALL) + elif filters: + filters.append(filter) + filter = ldap.combine_filters(filters, rules=ldap.MATCH_ALL) + return (filter, base_dn, scope) + + +@register() +class group_show(LDAPRetrieve): + __doc__ = _('Display information about a named group.') + has_output_params = LDAPRetrieve.has_output_params + (ipaexternalmember_param,) + def post_callback(self, ldap, dn, entry_attrs, *keys, **options): + assert isinstance(dn, DN) + if ('ipaexternalmember' in entry_attrs and + len(entry_attrs['ipaexternalmember']) > 0 and + 'trust_resolve' in self.Command and + not options.get('raw', False)): + sids = entry_attrs['ipaexternalmember'] + result = self.Command.trust_resolve(sids=sids) + for entry in result['result']: + try: + idx = sids.index(entry['sid'][0]) + sids[idx] = entry['name'][0] + except ValueError: + pass + return dn + + +@register() +class group_add_member(LDAPAddMember): + __doc__ = _('Add members to a group.') + + takes_options = (ipaexternalmember_param,) + + def post_callback(self, ldap, completed, failed, dn, entry_attrs, *keys, **options): + assert isinstance(dn, DN) + result = (completed, dn) + if 'ipaexternalmember' in options: + if not _dcerpc_bindings_installed: + raise errors.NotFound(reason=_('Cannot perform external member validation without ' + 'Samba 4 support installed. Make sure you have installed ' + 'server-trust-ad sub-package of IPA on the server')) + domain_validator = ipaserver.dcerpc.DomainValidator(self.api) + if not domain_validator.is_configured(): + raise errors.NotFound(reason=_('Cannot perform join operation without own domain configured. ' + 'Make sure you have run ipa-adtrust-install on the IPA server first')) + sids = [] + failed_sids = [] + for sid in options['ipaexternalmember']: + if domain_validator.is_trusted_sid_valid(sid): + sids.append(sid) + else: + try: + actual_sid = domain_validator.get_trusted_domain_object_sid(sid) + except errors.PublicError as e: + failed_sids.append((sid, e.strerror)) + else: + sids.append(actual_sid) + restore = [] + if 'member' in failed and 'group' in failed['member']: + restore = failed['member']['group'] + failed['member']['group'] = list((id, id) for id in sids) + result = add_external_post_callback(ldap, dn, entry_attrs, + failed=failed, + completed=completed, + memberattr='member', + membertype='group', + externalattr='ipaexternalmember', + normalize=False) + failed['member']['group'] += restore + failed_sids + return result + + +@register() +class group_remove_member(LDAPRemoveMember): + __doc__ = _('Remove members from a group.') + + takes_options = (ipaexternalmember_param,) + + def pre_callback(self, ldap, dn, found, not_found, *keys, **options): + assert isinstance(dn, DN) + if keys[0] in PROTECTED_GROUPS and 'user' in options: + protected_group_name = keys[0] + result = api.Command.group_show(protected_group_name) + users_left = set(result['result'].get('member_user', [])) + users_deleted = set(options['user']) + if users_left.issubset(users_deleted): + raise errors.LastMemberError(key=sorted(users_deleted)[0], + label=_(u'group'), container=protected_group_name) + return dn + + def post_callback(self, ldap, completed, failed, dn, entry_attrs, *keys, **options): + assert isinstance(dn, DN) + result = (completed, dn) + if 'ipaexternalmember' in options: + if not _dcerpc_bindings_installed: + raise errors.NotFound(reason=_('Cannot perform external member validation without ' + 'Samba 4 support installed. Make sure you have installed ' + 'server-trust-ad sub-package of IPA on the server')) + domain_validator = ipaserver.dcerpc.DomainValidator(self.api) + if not domain_validator.is_configured(): + raise errors.NotFound(reason=_('Cannot perform join operation without own domain configured. ' + 'Make sure you have run ipa-adtrust-install on the IPA server first')) + sids = [] + failed_sids = [] + for sid in options['ipaexternalmember']: + if domain_validator.is_trusted_sid_valid(sid): + sids.append(sid) + else: + try: + actual_sid = domain_validator.get_trusted_domain_object_sid(sid) + except errors.PublicError as e: + failed_sids.append((sid, unicode(e))) + else: + sids.append(actual_sid) + restore = [] + if 'member' in failed and 'group' in failed['member']: + restore = failed['member']['group'] + failed['member']['group'] = list((id, id) for id in sids) + result = remove_external_post_callback(ldap, dn, entry_attrs, + failed=failed, + completed=completed, + memberattr='member', + membertype='group', + externalattr='ipaexternalmember', + ) + failed['member']['group'] += restore + failed_sids + return result + + +@register() +class group_detach(LDAPQuery): + __doc__ = _('Detach a managed group from a user.') + + has_output = output.standard_value + msg_summary = _('Detached group "%(value)s" from user "%(value)s"') + + def execute(self, *keys, **options): + """ + This requires updating both the user and the group. We first need to + verify that both the user and group can be updated, then we go + about our work. We don't want a situation where only the user or + group can be modified and we're left in a bad state. + """ + ldap = self.obj.backend + + group_dn = self.obj.get_dn(*keys, **options) + user_dn = self.api.Object['user'].get_dn(*keys) + + try: + user_attrs = ldap.get_entry(user_dn) + except errors.NotFound: + self.obj.handle_not_found(*keys) + is_managed = self.obj.has_objectclass(user_attrs['objectclass'], 'mepmanagedentry') + if (not ldap.can_write(user_dn, "objectclass") or + not (ldap.can_write(user_dn, "mepManagedEntry")) and is_managed): + raise errors.ACIError(info=_('not allowed to modify user entries')) + + group_attrs = ldap.get_entry(group_dn) + is_managed = self.obj.has_objectclass(group_attrs['objectclass'], 'mepmanagedby') + if (not ldap.can_write(group_dn, "objectclass") or + not (ldap.can_write(group_dn, "mepManagedBy")) and is_managed): + raise errors.ACIError(info=_('not allowed to modify group entries')) + + objectclasses = user_attrs['objectclass'] + try: + i = objectclasses.index('mepOriginEntry') + del objectclasses[i] + user_attrs['mepManagedEntry'] = None + ldap.update_entry(user_attrs) + except ValueError: + # Somehow the user isn't managed, let it pass for now. We'll + # let the group throw "Not managed". + pass + + group_attrs = ldap.get_entry(group_dn) + objectclasses = group_attrs['objectclass'] + try: + i = objectclasses.index('mepManagedEntry') + except ValueError: + # this should never happen + raise errors.NotFound(reason=_('Not a managed group')) + del objectclasses[i] + + # Make sure the resulting group has the default group objectclasses + config = ldap.get_ipa_config() + def_objectclass = config.get( + self.obj.object_class_config, objectclasses + ) + objectclasses = list(set(def_objectclass + objectclasses)) + + group_attrs['mepManagedBy'] = None + group_attrs['objectclass'] = objectclasses + ldap.update_entry(group_attrs) + + return dict( + result=True, + value=pkey_to_value(keys[0], options), + ) + diff --git a/ipaserver/plugins/hbac.py b/ipaserver/plugins/hbac.py new file mode 100644 index 000000000..59defc1f2 --- /dev/null +++ b/ipaserver/plugins/hbac.py @@ -0,0 +1,7 @@ +# +# Copyright (C) 2016 FreeIPA Contributors see COPYING for license +# + +from ipalib.text import _ + +__doc__ = _('Host-based access control commands') diff --git a/ipaserver/plugins/hbacrule.py b/ipaserver/plugins/hbacrule.py new file mode 100644 index 000000000..7d3e4851a --- /dev/null +++ b/ipaserver/plugins/hbacrule.py @@ -0,0 +1,605 @@ +# Authors: +# Pavel Zuna <pzuna@redhat.com> +# +# Copyright (C) 2009 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +from ipalib import api, errors +from ipalib import AccessTime, Str, StrEnum, Bool +from ipalib.plugable import Registry +from .baseldap import ( + pkey_to_value, + external_host_param, + LDAPObject, + LDAPCreate, + LDAPDelete, + LDAPRetrieve, + LDAPUpdate, + LDAPSearch, + LDAPQuery, + LDAPAddMember, + LDAPRemoveMember) +from ipalib import _, ngettext +from ipalib import output +from ipapython.dn import DN + +__doc__ = _(""" +Host-based access control + +Control who can access what services on what hosts. You +can use HBAC to control which users or groups can +access a service, or group of services, on a target host. + +You can also specify a category of users and target hosts. +This is currently limited to "all", but might be expanded in the +future. + +Target hosts in HBAC rules must be hosts managed by IPA. + +The available services and groups of services are controlled by the +hbacsvc and hbacsvcgroup plug-ins respectively. + +EXAMPLES: + + Create a rule, "test1", that grants all users access to the host "server" from + anywhere: + ipa hbacrule-add --usercat=all test1 + ipa hbacrule-add-host --hosts=server.example.com test1 + + Display the properties of a named HBAC rule: + ipa hbacrule-show test1 + + Create a rule for a specific service. This lets the user john access + the sshd service on any machine from any machine: + ipa hbacrule-add --hostcat=all john_sshd + ipa hbacrule-add-user --users=john john_sshd + ipa hbacrule-add-service --hbacsvcs=sshd john_sshd + + Create a rule for a new service group. This lets the user john access + the FTP service on any machine from any machine: + ipa hbacsvcgroup-add ftpers + ipa hbacsvc-add sftp + ipa hbacsvcgroup-add-member --hbacsvcs=ftp --hbacsvcs=sftp ftpers + ipa hbacrule-add --hostcat=all john_ftp + ipa hbacrule-add-user --users=john john_ftp + ipa hbacrule-add-service --hbacsvcgroups=ftpers john_ftp + + Disable a named HBAC rule: + ipa hbacrule-disable test1 + + Remove a named HBAC rule: + ipa hbacrule-del allow_server +""") + +register = Registry() + +# AccessTime support is being removed for now. +# +# You can also control the times that the rule is active. +# +# The access time(s) of a host are cumulative and are not guaranteed to be +# applied in the order displayed. +# +# Specify that the rule "test1" be active every day between 0800 and 1400: +# ipa hbacrule-add-accesstime --time='periodic daily 0800-1400' test1 +# +# Specify that the rule "test1" be active once, from 10:32 until 10:33 on +# December 16, 2010: +# ipa hbacrule-add-accesstime --time='absolute 201012161032 ~ 201012161033' test1 + + +topic = 'hbac' + +def validate_type(ugettext, type): + if type.lower() == 'deny': + raise errors.ValidationError(name='type', error=_('The deny type has been deprecated.')) + +def is_all(options, attribute): + """ + See if options[attribute] is lower-case 'all' in a safe way. + """ + if attribute in options and options[attribute] is not None: + if type(options[attribute]) in (list, tuple): + value = options[attribute][0].lower() + else: + value = options[attribute].lower() + if value == 'all': + return True + else: + return False + + +@register() +class hbacrule(LDAPObject): + """ + HBAC object. + """ + container_dn = api.env.container_hbac + object_name = _('HBAC rule') + object_name_plural = _('HBAC rules') + object_class = ['ipaassociation', 'ipahbacrule'] + permission_filter_objectclasses = ['ipahbacrule'] + default_attributes = [ + 'cn', 'ipaenabledflag', + 'description', 'usercategory', 'hostcategory', + 'servicecategory', 'ipaenabledflag', + 'memberuser', 'sourcehost', 'memberhost', 'memberservice', + 'externalhost', + ] + uuid_attribute = 'ipauniqueid' + rdn_attribute = 'ipauniqueid' + attribute_members = { + 'memberuser': ['user', 'group'], + 'memberhost': ['host', 'hostgroup'], + 'sourcehost': ['host', 'hostgroup'], + 'memberservice': ['hbacsvc', 'hbacsvcgroup'], + } + managed_permissions = { + 'System: Read HBAC Rules': { + 'replaces_global_anonymous_aci': True, + 'ipapermbindruletype': 'all', + 'ipapermright': {'read', 'search', 'compare'}, + 'ipapermdefaultattr': { + 'accessruletype', 'accesstime', 'cn', 'description', + 'externalhost', 'hostcategory', 'ipaenabledflag', + 'ipauniqueid', 'memberhost', 'memberservice', 'memberuser', + 'servicecategory', 'sourcehost', 'sourcehostcategory', + 'usercategory', 'objectclass', 'member', + }, + }, + 'System: Add HBAC Rule': { + 'ipapermright': {'add'}, + 'replaces': [ + '(target = "ldap:///ipauniqueid=*,cn=hbac,$SUFFIX")(version 3.0;acl "permission:Add HBAC rule";allow (add) groupdn = "ldap:///cn=Add HBAC rule,cn=permissions,cn=pbac,$SUFFIX";)', + ], + 'default_privileges': {'HBAC Administrator'}, + }, + 'System: Delete HBAC Rule': { + 'ipapermright': {'delete'}, + 'replaces': [ + '(target = "ldap:///ipauniqueid=*,cn=hbac,$SUFFIX")(version 3.0;acl "permission:Delete HBAC rule";allow (delete) groupdn = "ldap:///cn=Delete HBAC rule,cn=permissions,cn=pbac,$SUFFIX";)', + ], + 'default_privileges': {'HBAC Administrator'}, + }, + 'System: Manage HBAC Rule Membership': { + 'ipapermright': {'write'}, + 'ipapermdefaultattr': { + 'externalhost', 'memberhost', 'memberservice', 'memberuser' + }, + 'replaces': [ + '(targetattr = "memberuser || externalhost || memberservice || memberhost")(target = "ldap:///ipauniqueid=*,cn=hbac,$SUFFIX")(version 3.0;acl "permission:Manage HBAC rule membership";allow (write) groupdn = "ldap:///cn=Manage HBAC rule membership,cn=permissions,cn=pbac,$SUFFIX";)', + ], + 'default_privileges': {'HBAC Administrator'}, + }, + 'System: Modify HBAC Rule': { + 'ipapermright': {'write'}, + 'ipapermdefaultattr': { + 'accessruletype', 'accesstime', 'cn', 'description', + 'hostcategory', 'ipaenabledflag', 'servicecategory', + 'sourcehost', 'sourcehostcategory', 'usercategory' + }, + 'replaces': [ + '(targetattr = "servicecategory || sourcehostcategory || cn || description || ipaenabledflag || accesstime || usercategory || hostcategory || accessruletype || sourcehost")(target = "ldap:///ipauniqueid=*,cn=hbac,$SUFFIX")(version 3.0;acl "permission:Modify HBAC rule";allow (write) groupdn = "ldap:///cn=Modify HBAC rule,cn=permissions,cn=pbac,$SUFFIX";)', + ], + 'default_privileges': {'HBAC Administrator'}, + }, + } + + label = _('HBAC Rules') + label_singular = _('HBAC Rule') + + takes_params = ( + Str('cn', + cli_name='name', + label=_('Rule name'), + primary_key=True, + ), + StrEnum('accessruletype', validate_type, + cli_name='type', + doc=_('Rule type (allow)'), + label=_('Rule type'), + values=(u'allow', u'deny'), + default=u'allow', + autofill=True, + exclude='webui', + flags=['no_option', 'no_output'], + ), + # FIXME: {user,host,service}categories should expand in the future + StrEnum('usercategory?', + cli_name='usercat', + label=_('User category'), + doc=_('User category the rule applies to'), + values=(u'all', ), + ), + StrEnum('hostcategory?', + cli_name='hostcat', + label=_('Host category'), + doc=_('Host category the rule applies to'), + values=(u'all', ), + ), + StrEnum('sourcehostcategory?', + deprecated=True, + cli_name='srchostcat', + label=_('Source host category'), + doc=_('Source host category the rule applies to'), + values=(u'all', ), + flags={'no_option'}, + ), + StrEnum('servicecategory?', + cli_name='servicecat', + label=_('Service category'), + doc=_('Service category the rule applies to'), + values=(u'all', ), + ), +# AccessTime('accesstime?', +# cli_name='time', +# label=_('Access time'), +# ), + Str('description?', + cli_name='desc', + label=_('Description'), + ), + Bool('ipaenabledflag?', + label=_('Enabled'), + flags=['no_option'], + ), + Str('memberuser_user?', + label=_('Users'), + flags=['no_create', 'no_update', 'no_search'], + ), + Str('memberuser_group?', + label=_('User Groups'), + flags=['no_create', 'no_update', 'no_search'], + ), + Str('memberhost_host?', + label=_('Hosts'), + flags=['no_create', 'no_update', 'no_search'], + ), + Str('memberhost_hostgroup?', + label=_('Host Groups'), + flags=['no_create', 'no_update', 'no_search'], + ), + Str('sourcehost_host?', + deprecated=True, + label=_('Source Hosts'), + flags=['no_create', 'no_update', 'no_search', 'no_option'], + ), + Str('sourcehost_hostgroup?', + deprecated=True, + label=_('Source Host Groups'), + flags=['no_create', 'no_update', 'no_search', 'no_option'], + ), + Str('memberservice_hbacsvc?', + label=_('Services'), + flags=['no_create', 'no_update', 'no_search'], + ), + Str('memberservice_hbacsvcgroup?', + label=_('Service Groups'), + flags=['no_create', 'no_update', 'no_search'], + ), + external_host_param, + ) + + + +@register() +class hbacrule_add(LDAPCreate): + __doc__ = _('Create a new HBAC rule.') + + msg_summary = _('Added HBAC rule "%(value)s"') + + def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options): + assert isinstance(dn, DN) + # HBAC rules are enabled by default + entry_attrs['ipaenabledflag'] = 'TRUE' + return dn + + + +@register() +class hbacrule_del(LDAPDelete): + __doc__ = _('Delete an HBAC rule.') + + msg_summary = _('Deleted HBAC rule "%(value)s"') + + def pre_callback(self, ldap, dn, *keys, **options): + assert isinstance(dn, DN) + kw = dict(seealso=keys[0]) + _entries = api.Command.selinuxusermap_find(None, **kw) + if _entries['count']: + raise errors.DependentEntry(key=keys[0], label=self.api.Object['selinuxusermap'].label_singular, dependent=_entries['result'][0]['cn'][0]) + + return dn + + + +@register() +class hbacrule_mod(LDAPUpdate): + __doc__ = _('Modify an HBAC rule.') + + msg_summary = _('Modified HBAC rule "%(value)s"') + + def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options): + assert isinstance(dn, DN) + try: + entry_attrs = ldap.get_entry(dn, attrs_list) + dn = entry_attrs.dn + except errors.NotFound: + self.obj.handle_not_found(*keys) + + if is_all(options, 'usercategory') and 'memberuser' in entry_attrs: + raise errors.MutuallyExclusiveError(reason=_("user category cannot be set to 'all' while there are allowed users")) + if is_all(options, 'hostcategory') and 'memberhost' in entry_attrs: + raise errors.MutuallyExclusiveError(reason=_("host category cannot be set to 'all' while there are allowed hosts")) + if is_all(options, 'servicecategory') and 'memberservice' in entry_attrs: + raise errors.MutuallyExclusiveError(reason=_("service category cannot be set to 'all' while there are allowed services")) + return dn + + + +@register() +class hbacrule_find(LDAPSearch): + __doc__ = _('Search for HBAC rules.') + + msg_summary = ngettext( + '%(count)d HBAC rule matched', '%(count)d HBAC rules matched', 0 + ) + + + +@register() +class hbacrule_show(LDAPRetrieve): + __doc__ = _('Display the properties of an HBAC rule.') + + + +@register() +class hbacrule_enable(LDAPQuery): + __doc__ = _('Enable an HBAC rule.') + + msg_summary = _('Enabled HBAC rule "%(value)s"') + has_output = output.standard_value + + def execute(self, cn, **options): + ldap = self.obj.backend + + dn = self.obj.get_dn(cn) + try: + entry_attrs = ldap.get_entry(dn, ['ipaenabledflag']) + except errors.NotFound: + self.obj.handle_not_found(cn) + + entry_attrs['ipaenabledflag'] = ['TRUE'] + + try: + ldap.update_entry(entry_attrs) + except errors.EmptyModlist: + pass + + return dict( + result=True, + value=pkey_to_value(cn, options), + ) + + + +@register() +class hbacrule_disable(LDAPQuery): + __doc__ = _('Disable an HBAC rule.') + + msg_summary = _('Disabled HBAC rule "%(value)s"') + has_output = output.standard_value + + def execute(self, cn, **options): + ldap = self.obj.backend + + dn = self.obj.get_dn(cn) + try: + entry_attrs = ldap.get_entry(dn, ['ipaenabledflag']) + except errors.NotFound: + self.obj.handle_not_found(cn) + + entry_attrs['ipaenabledflag'] = ['FALSE'] + + try: + ldap.update_entry(entry_attrs) + except errors.EmptyModlist: + pass + + return dict( + result=True, + value=pkey_to_value(cn, options), + ) + + +# @register() +class hbacrule_add_accesstime(LDAPQuery): + """ + Add an access time to an HBAC rule. + """ + + takes_options = ( + AccessTime('accesstime', + cli_name='time', + label=_('Access time'), + ), + ) + + def execute(self, cn, **options): + ldap = self.obj.backend + + dn = self.obj.get_dn(cn) + + entry_attrs = ldap.get_entry(dn, ['accesstime']) + entry_attrs.setdefault('accesstime', []).append( + options['accesstime'] + ) + try: + ldap.update_entry(entry_attrs) + except errors.EmptyModlist: + pass + except errors.NotFound: + self.obj.handle_not_found(cn) + + return dict(result=True) + + +# @register() +class hbacrule_remove_accesstime(LDAPQuery): + """ + Remove access time to HBAC rule. + """ + takes_options = ( + AccessTime('accesstime?', + cli_name='time', + label=_('Access time'), + ), + ) + + def execute(self, cn, **options): + ldap = self.obj.backend + + dn = self.obj.get_dn(cn) + + entry_attrs = ldap.get_entry(dn, ['accesstime']) + try: + entry_attrs.setdefault('accesstime', []).remove( + options['accesstime'] + ) + ldap.update_entry(entry_attrs) + except (ValueError, errors.EmptyModlist): + pass + except errors.NotFound: + self.obj.handle_not_found(cn) + + return dict(result=True) + + +@register() +class hbacrule_add_user(LDAPAddMember): + __doc__ = _('Add users and groups to an HBAC rule.') + + member_attributes = ['memberuser'] + member_count_out = ('%i object added.', '%i objects added.') + + def pre_callback(self, ldap, dn, found, not_found, *keys, **options): + assert isinstance(dn, DN) + try: + entry_attrs = ldap.get_entry(dn, self.obj.default_attributes) + dn = entry_attrs.dn + except errors.NotFound: + self.obj.handle_not_found(*keys) + if 'usercategory' in entry_attrs and \ + entry_attrs['usercategory'][0].lower() == 'all': + raise errors.MutuallyExclusiveError( + reason=_("users cannot be added when user category='all'")) + return dn + + + +@register() +class hbacrule_remove_user(LDAPRemoveMember): + __doc__ = _('Remove users and groups from an HBAC rule.') + + member_attributes = ['memberuser'] + member_count_out = ('%i object removed.', '%i objects removed.') + + + +@register() +class hbacrule_add_host(LDAPAddMember): + __doc__ = _('Add target hosts and hostgroups to an HBAC rule.') + + member_attributes = ['memberhost'] + member_count_out = ('%i object added.', '%i objects added.') + + def pre_callback(self, ldap, dn, found, not_found, *keys, **options): + assert isinstance(dn, DN) + try: + entry_attrs = ldap.get_entry(dn, self.obj.default_attributes) + dn = entry_attrs.dn + except errors.NotFound: + self.obj.handle_not_found(*keys) + if 'hostcategory' in entry_attrs and \ + entry_attrs['hostcategory'][0].lower() == 'all': + raise errors.MutuallyExclusiveError( + reason=_("hosts cannot be added when host category='all'")) + return dn + + + +@register() +class hbacrule_remove_host(LDAPRemoveMember): + __doc__ = _('Remove target hosts and hostgroups from an HBAC rule.') + + member_attributes = ['memberhost'] + member_count_out = ('%i object removed.', '%i objects removed.') + + + +@register() +class hbacrule_add_sourcehost(LDAPAddMember): + NO_CLI = True + + member_attributes = ['sourcehost'] + member_count_out = ('%i object added.', '%i objects added.') + + def validate(self, **kw): + raise errors.DeprecationError(name='hbacrule_add_sourcehost') + + + +@register() +class hbacrule_remove_sourcehost(LDAPRemoveMember): + NO_CLI = True + + member_attributes = ['sourcehost'] + member_count_out = ('%i object removed.', '%i objects removed.') + + def validate(self, **kw): + raise errors.DeprecationError(name='hbacrule_remove_sourcehost') + + + +@register() +class hbacrule_add_service(LDAPAddMember): + __doc__ = _('Add services to an HBAC rule.') + + member_attributes = ['memberservice'] + member_count_out = ('%i object added.', '%i objects added.') + + def pre_callback(self, ldap, dn, found, not_found, *keys, **options): + assert isinstance(dn, DN) + try: + entry_attrs = ldap.get_entry(dn, self.obj.default_attributes) + dn = entry_attrs.dn + except errors.NotFound: + self.obj.handle_not_found(*keys) + if 'servicecategory' in entry_attrs and \ + entry_attrs['servicecategory'][0].lower() == 'all': + raise errors.MutuallyExclusiveError(reason=_( + "services cannot be added when service category='all'")) + return dn + + + +@register() +class hbacrule_remove_service(LDAPRemoveMember): + __doc__ = _('Remove service and service groups from an HBAC rule.') + + member_attributes = ['memberservice'] + member_count_out = ('%i object removed.', '%i objects removed.') + diff --git a/ipaserver/plugins/hbacsvc.py b/ipaserver/plugins/hbacsvc.py new file mode 100644 index 000000000..43d641642 --- /dev/null +++ b/ipaserver/plugins/hbacsvc.py @@ -0,0 +1,152 @@ +# Authors: +# Rob Crittenden <rcritten@redhat.com> +# +# Copyright (C) 2010 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +from ipalib import api +from ipalib import Str +from ipalib.plugable import Registry +from .baseldap import LDAPObject, LDAPCreate, LDAPDelete +from .baseldap import LDAPUpdate, LDAPSearch, LDAPRetrieve + +from ipalib import _, ngettext + +__doc__ = _(""" +HBAC Services + +The PAM services that HBAC can control access to. The name used here +must match the service name that PAM is evaluating. + +EXAMPLES: + + Add a new HBAC service: + ipa hbacsvc-add tftp + + Modify an existing HBAC service: + ipa hbacsvc-mod --desc="TFTP service" tftp + + Search for HBAC services. This example will return two results, the FTP + service and the newly-added tftp service: + ipa hbacsvc-find ftp + + Delete an HBAC service: + ipa hbacsvc-del tftp + +""") + +register = Registry() + +topic = 'hbac' + +@register() +class hbacsvc(LDAPObject): + """ + HBAC Service object. + """ + container_dn = api.env.container_hbacservice + object_name = _('HBAC service') + object_name_plural = _('HBAC services') + object_class = [ 'ipaobject', 'ipahbacservice' ] + permission_filter_objectclasses = ['ipahbacservice'] + default_attributes = ['cn', 'description', 'memberof'] + uuid_attribute = 'ipauniqueid' + attribute_members = { + 'memberof': ['hbacsvcgroup'], + } + managed_permissions = { + 'System: Read HBAC Services': { + 'replaces_global_anonymous_aci': True, + 'ipapermbindruletype': 'all', + 'ipapermright': {'read', 'search', 'compare'}, + 'ipapermdefaultattr': { + 'cn', 'description', 'ipauniqueid', 'memberof', 'objectclass', + }, + }, + 'System: Add HBAC Services': { + 'ipapermright': {'add'}, + 'replaces': [ + '(target = "ldap:///cn=*,cn=hbacservices,cn=hbac,$SUFFIX")(version 3.0;acl "permission:Add HBAC services";allow (add) groupdn = "ldap:///cn=Add HBAC services,cn=permissions,cn=pbac,$SUFFIX";)', + ], + 'default_privileges': {'HBAC Administrator'}, + }, + 'System: Delete HBAC Services': { + 'ipapermright': {'delete'}, + 'replaces': [ + '(target = "ldap:///cn=*,cn=hbacservices,cn=hbac,$SUFFIX")(version 3.0;acl "permission:Delete HBAC services";allow (delete) groupdn = "ldap:///cn=Delete HBAC services,cn=permissions,cn=pbac,$SUFFIX";)', + ], + 'default_privileges': {'HBAC Administrator'}, + }, + } + + label = _('HBAC Services') + label_singular = _('HBAC Service') + + takes_params = ( + Str('cn', + cli_name='service', + label=_('Service name'), + doc=_('HBAC service'), + primary_key=True, + normalizer=lambda value: value.lower(), + ), + Str('description?', + cli_name='desc', + label=_('Description'), + doc=_('HBAC service description'), + ), + ) + + + +@register() +class hbacsvc_add(LDAPCreate): + __doc__ = _('Add a new HBAC service.') + + msg_summary = _('Added HBAC service "%(value)s"') + + + +@register() +class hbacsvc_del(LDAPDelete): + __doc__ = _('Delete an existing HBAC service.') + + msg_summary = _('Deleted HBAC service "%(value)s"') + + + +@register() +class hbacsvc_mod(LDAPUpdate): + __doc__ = _('Modify an HBAC service.') + + msg_summary = _('Modified HBAC service "%(value)s"') + + + +@register() +class hbacsvc_find(LDAPSearch): + __doc__ = _('Search for HBAC services.') + + msg_summary = ngettext( + '%(count)d HBAC service matched', '%(count)d HBAC services matched', 0 + ) + + + +@register() +class hbacsvc_show(LDAPRetrieve): + __doc__ = _('Display information about an HBAC service.') + diff --git a/ipaserver/plugins/hbacsvcgroup.py b/ipaserver/plugins/hbacsvcgroup.py new file mode 100644 index 000000000..41157efc6 --- /dev/null +++ b/ipaserver/plugins/hbacsvcgroup.py @@ -0,0 +1,176 @@ +# Authors: +# Rob Crittenden <rcritten@redhat.com> +# +# Copyright (C) 2010 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +from ipalib import api, Str +from ipalib.plugable import Registry +from .baseldap import ( + LDAPObject, + LDAPCreate, + LDAPUpdate, + LDAPRetrieve, + LDAPSearch, + LDAPDelete, + LDAPAddMember, + LDAPRemoveMember) +from ipalib import _, ngettext + +__doc__ = _(""" +HBAC Service Groups + +HBAC service groups can contain any number of individual services, +or "members". Every group must have a description. + +EXAMPLES: + + Add a new HBAC service group: + ipa hbacsvcgroup-add --desc="login services" login + + Add members to an HBAC service group: + ipa hbacsvcgroup-add-member --hbacsvcs=sshd --hbacsvcs=login login + + Display information about a named group: + ipa hbacsvcgroup-show login + + Delete an HBAC service group: + ipa hbacsvcgroup-del login +""") + +register = Registry() + +topic = 'hbac' + +@register() +class hbacsvcgroup(LDAPObject): + """ + HBAC service group object. + """ + container_dn = api.env.container_hbacservicegroup + object_name = _('HBAC service group') + object_name_plural = _('HBAC service groups') + object_class = ['ipaobject', 'ipahbacservicegroup'] + permission_filter_objectclasses = ['ipahbacservicegroup'] + default_attributes = [ 'cn', 'description', 'member' ] + uuid_attribute = 'ipauniqueid' + attribute_members = { + 'member': ['hbacsvc'], + } + managed_permissions = { + 'System: Read HBAC Service Groups': { + 'replaces_global_anonymous_aci': True, + 'ipapermbindruletype': 'all', + 'ipapermright': {'read', 'search', 'compare'}, + 'ipapermdefaultattr': { + 'businesscategory', 'cn', 'description', 'ipauniqueid', + 'member', 'o', 'objectclass', 'ou', 'owner', 'seealso', + 'memberuser', 'memberhost', + }, + }, + 'System: Add HBAC Service Groups': { + 'ipapermright': {'add'}, + 'replaces': [ + '(target = "ldap:///cn=*,cn=hbacservicegroups,cn=hbac,$SUFFIX")(version 3.0;acl "permission:Add HBAC service groups";allow (add) groupdn = "ldap:///cn=Add HBAC service groups,cn=permissions,cn=pbac,$SUFFIX";)', + ], + 'default_privileges': {'HBAC Administrator'}, + }, + 'System: Delete HBAC Service Groups': { + 'ipapermright': {'delete'}, + 'replaces': [ + '(target = "ldap:///cn=*,cn=hbacservicegroups,cn=hbac,$SUFFIX")(version 3.0;acl "permission:Delete HBAC service groups";allow (delete) groupdn = "ldap:///cn=Delete HBAC service groups,cn=permissions,cn=pbac,$SUFFIX";)', + ], + 'default_privileges': {'HBAC Administrator'}, + }, + 'System: Manage HBAC Service Group Membership': { + 'ipapermright': {'write'}, + 'ipapermdefaultattr': {'member'}, + 'replaces': [ + '(targetattr = "member")(target = "ldap:///cn=*,cn=hbacservicegroups,cn=hbac,$SUFFIX")(version 3.0;acl "permission:Manage HBAC service group membership";allow (write) groupdn = "ldap:///cn=Manage HBAC service group membership,cn=permissions,cn=pbac,$SUFFIX";)', + ], + 'default_privileges': {'HBAC Administrator'}, + }, + } + + label = _('HBAC Service Groups') + label_singular = _('HBAC Service Group') + + takes_params = ( + Str('cn', + cli_name='name', + label=_('Service group name'), + primary_key=True, + normalizer=lambda value: value.lower(), + ), + Str('description?', + cli_name='desc', + label=_('Description'), + doc=_('HBAC service group description'), + ), + ) + + + +@register() +class hbacsvcgroup_add(LDAPCreate): + __doc__ = _('Add a new HBAC service group.') + + msg_summary = _('Added HBAC service group "%(value)s"') + + + +@register() +class hbacsvcgroup_del(LDAPDelete): + __doc__ = _('Delete an HBAC service group.') + + msg_summary = _('Deleted HBAC service group "%(value)s"') + + + +@register() +class hbacsvcgroup_mod(LDAPUpdate): + __doc__ = _('Modify an HBAC service group.') + + msg_summary = _('Modified HBAC service group "%(value)s"') + + + +@register() +class hbacsvcgroup_find(LDAPSearch): + __doc__ = _('Search for an HBAC service group.') + + msg_summary = ngettext( + '%(count)d HBAC service group matched', '%(count)d HBAC service groups matched', 0 + ) + + + +@register() +class hbacsvcgroup_show(LDAPRetrieve): + __doc__ = _('Display information about an HBAC service group.') + + + +@register() +class hbacsvcgroup_add_member(LDAPAddMember): + __doc__ = _('Add members to an HBAC service group.') + + + +@register() +class hbacsvcgroup_remove_member(LDAPRemoveMember): + __doc__ = _('Remove members from an HBAC service group.') + diff --git a/ipaserver/plugins/hbactest.py b/ipaserver/plugins/hbactest.py new file mode 100644 index 000000000..90f3b561a --- /dev/null +++ b/ipaserver/plugins/hbactest.py @@ -0,0 +1,499 @@ +# Authors: +# Alexander Bokovoy <abokovoy@redhat.com> +# +# Copyright (C) 2011 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +from ipalib import api, errors, output, util +from ipalib import Command, Str, Flag, Int +from ipalib import _ +from ipapython.dn import DN +from ipalib.plugable import Registry +if api.env.in_server and api.env.context in ['lite', 'server']: + try: + import ipaserver.dcerpc + _dcerpc_bindings_installed = True + except ImportError: + _dcerpc_bindings_installed = False + +import pyhbac +import six + +if six.PY3: + unicode = str + +__doc__ = _(""" +Simulate use of Host-based access controls + +HBAC rules control who can access what services on what hosts. +You can use HBAC to control which users or groups can access a service, +or group of services, on a target host. + +Since applying HBAC rules implies use of a production environment, +this plugin aims to provide simulation of HBAC rules evaluation without +having access to the production environment. + + Test user coming to a service on a named host against + existing enabled rules. + + ipa hbactest --user= --host= --service= + [--rules=rules-list] [--nodetail] [--enabled] [--disabled] + [--sizelimit= ] + + --user, --host, and --service are mandatory, others are optional. + + If --rules is specified simulate enabling of the specified rules and test + the login of the user using only these rules. + + If --enabled is specified, all enabled HBAC rules will be added to simulation + + If --disabled is specified, all disabled HBAC rules will be added to simulation + + If --nodetail is specified, do not return information about rules matched/not matched. + + If both --rules and --enabled are specified, apply simulation to --rules _and_ + all IPA enabled rules. + + If no --rules specified, simulation is run against all IPA enabled rules. + By default there is a IPA-wide limit to number of entries fetched, you can change it + with --sizelimit option. + +EXAMPLES: + + 1. Use all enabled HBAC rules in IPA database to simulate: + $ ipa hbactest --user=a1a --host=bar --service=sshd + -------------------- + Access granted: True + -------------------- + Not matched rules: my-second-rule + Not matched rules: my-third-rule + Not matched rules: myrule + Matched rules: allow_all + + 2. Disable detailed summary of how rules were applied: + $ ipa hbactest --user=a1a --host=bar --service=sshd --nodetail + -------------------- + Access granted: True + -------------------- + + 3. Test explicitly specified HBAC rules: + $ ipa hbactest --user=a1a --host=bar --service=sshd \\ + --rules=myrule --rules=my-second-rule + --------------------- + Access granted: False + --------------------- + Not matched rules: my-second-rule + Not matched rules: myrule + + 4. Use all enabled HBAC rules in IPA database + explicitly specified rules: + $ ipa hbactest --user=a1a --host=bar --service=sshd \\ + --rules=myrule --rules=my-second-rule --enabled + -------------------- + Access granted: True + -------------------- + Not matched rules: my-second-rule + Not matched rules: my-third-rule + Not matched rules: myrule + Matched rules: allow_all + + 5. Test all disabled HBAC rules in IPA database: + $ ipa hbactest --user=a1a --host=bar --service=sshd --disabled + --------------------- + Access granted: False + --------------------- + Not matched rules: new-rule + + 6. Test all disabled HBAC rules in IPA database + explicitly specified rules: + $ ipa hbactest --user=a1a --host=bar --service=sshd \\ + --rules=myrule --rules=my-second-rule --disabled + --------------------- + Access granted: False + --------------------- + Not matched rules: my-second-rule + Not matched rules: my-third-rule + Not matched rules: myrule + + 7. Test all (enabled and disabled) HBAC rules in IPA database: + $ ipa hbactest --user=a1a --host=bar --service=sshd \\ + --enabled --disabled + -------------------- + Access granted: True + -------------------- + Not matched rules: my-second-rule + Not matched rules: my-third-rule + Not matched rules: myrule + Not matched rules: new-rule + Matched rules: allow_all + + +HBACTEST AND TRUSTED DOMAINS + +When an external trusted domain is configured in IPA, HBAC rules are also applied +on users accessing IPA resources from the trusted domain. Trusted domain users and +groups (and their SIDs) can be then assigned to external groups which can be +members of POSIX groups in IPA which can be used in HBAC rules and thus allowing +access to resources protected by the HBAC system. + +hbactest plugin is capable of testing access for both local IPA users and users +from the trusted domains, either by a fully qualified user name or by user SID. +Such user names need to have a trusted domain specified as a short name +(DOMAIN\Administrator) or with a user principal name (UPN), Administrator@ad.test. + +Please note that hbactest executed with a trusted domain user as --user parameter +can be only run by members of "trust admins" group. + +EXAMPLES: + + 1. Test if a user from a trusted domain specified by its shortname matches any + rule: + + $ ipa hbactest --user 'DOMAIN\Administrator' --host `hostname` --service sshd + -------------------- + Access granted: True + -------------------- + Matched rules: allow_all + Matched rules: can_login + + 2. Test if a user from a trusted domain specified by its domain name matches + any rule: + + $ ipa hbactest --user 'Administrator@domain.com' --host `hostname` --service sshd + -------------------- + Access granted: True + -------------------- + Matched rules: allow_all + Matched rules: can_login + + 3. Test if a user from a trusted domain specified by its SID matches any rule: + + $ ipa hbactest --user S-1-5-21-3035198329-144811719-1378114514-500 \\ + --host `hostname` --service sshd + -------------------- + Access granted: True + -------------------- + Matched rules: allow_all + Matched rules: can_login + + 4. Test if other user from a trusted domain specified by its SID matches any rule: + + $ ipa hbactest --user S-1-5-21-3035198329-144811719-1378114514-1203 \\ + --host `hostname` --service sshd + -------------------- + Access granted: True + -------------------- + Matched rules: allow_all + Not matched rules: can_login + + 5. Test if other user from a trusted domain specified by its shortname matches + any rule: + + $ ipa hbactest --user 'DOMAIN\Otheruser' --host `hostname` --service sshd + -------------------- + Access granted: True + -------------------- + Matched rules: allow_all + Not matched rules: can_login +""") + +register = Registry() + +def convert_to_ipa_rule(rule): + # convert a dict with a rule to an pyhbac rule + ipa_rule = pyhbac.HbacRule(rule['cn'][0]) + ipa_rule.enabled = rule['ipaenabledflag'][0] + # Following code attempts to process rule systematically + structure = \ + (('user', 'memberuser', 'user', 'group', ipa_rule.users), + ('host', 'memberhost', 'host', 'hostgroup', ipa_rule.targethosts), + ('sourcehost', 'sourcehost', 'host', 'hostgroup', ipa_rule.srchosts), + ('service', 'memberservice', 'hbacsvc', 'hbacsvcgroup', ipa_rule.services), + ) + for element in structure: + category = '%scategory' % (element[0]) + if (category in rule and rule[category][0] == u'all') or (element[0] == 'sourcehost'): + # rule applies to all elements + # sourcehost is always set to 'all' + element[4].category = set([pyhbac.HBAC_CATEGORY_ALL]) + else: + # rule is about specific entities + # Check if there are explicitly listed entities + attr_name = '%s_%s' % (element[1], element[2]) + if attr_name in rule: + element[4].names = rule[attr_name] + # Now add groups of entities if they are there + attr_name = '%s_%s' % (element[1], element[3]) + if attr_name in rule: + element[4].groups = rule[attr_name] + if 'externalhost' in rule: + ipa_rule.srchosts.names.extend(rule['externalhost']) #pylint: disable=E1101 + return ipa_rule + + +@register() +class hbactest(Command): + __doc__ = _('Simulate use of Host-based access controls') + + has_output = ( + output.summary, + output.Output('warning', (list, tuple, type(None)), _('Warning')), + output.Output('matched', (list, tuple, type(None)), _('Matched rules')), + output.Output('notmatched', (list, tuple, type(None)), _('Not matched rules')), + output.Output('error', (list, tuple, type(None)), _('Non-existent or invalid rules')), + output.Output('value', bool, _('Result of simulation'), ['no_display']), + ) + + takes_options = ( + Str('user', + cli_name='user', + label=_('User name'), + primary_key=True, + ), + Str('sourcehost?', + deprecated=True, + cli_name='srchost', + label=_('Source host'), + flags={'no_option'}, + ), + Str('targethost', + cli_name='host', + label=_('Target host'), + ), + Str('service', + cli_name='service', + label=_('Service'), + ), + Str('rules*', + cli_name='rules', + label=_('Rules to test. If not specified, --enabled is assumed'), + ), + Flag('nodetail?', + cli_name='nodetail', + label=_('Hide details which rules are matched, not matched, or invalid'), + ), + Flag('enabled?', + cli_name='enabled', + label=_('Include all enabled IPA rules into test [default]'), + ), + Flag('disabled?', + cli_name='disabled', + label=_('Include all disabled IPA rules into test'), + ), + Int('sizelimit?', + label=_('Size Limit'), + doc=_('Maximum number of rules to process when no --rules is specified'), + flags=['no_display'], + minvalue=0, + autofill=False, + ), + ) + + def canonicalize(self, host): + """ + Canonicalize the host name -- add default IPA domain if that is missing + """ + if host.find('.') == -1: + return u'%s.%s' % (host, self.env.domain) + return host + + def execute(self, *args, **options): + # First receive all needed information: + # 1. HBAC rules (whether enabled or disabled) + # 2. Required options are (user, target host, service) + # 3. Options: rules to test (--rules, --enabled, --disabled), request for detail output + rules = [] + + # Use all enabled IPA rules by default + all_enabled = True + all_disabled = False + + # We need a local copy of test rules in order find incorrect ones + testrules = {} + if 'rules' in options: + testrules = list(options['rules']) + # When explicit rules are provided, disable assumptions + all_enabled = False + all_disabled = False + + sizelimit = None + if 'sizelimit' in options: + sizelimit = int(options['sizelimit']) + + # Check if --disabled is specified, include all disabled IPA rules + if options['disabled']: + all_disabled = True + all_enabled = False + + # Finally, if enabled is specified implicitly, override above decisions + if options['enabled']: + all_enabled = True + + hbacset = [] + if len(testrules) == 0: + hbacset = self.api.Command.hbacrule_find( + sizelimit=sizelimit, no_members=False)['result'] + else: + for rule in testrules: + try: + hbacset.append(self.api.Command.hbacrule_show(rule)['result']) + except Exception: + pass + + # We have some rules, import them + # --enabled will import all enabled rules (default) + # --disabled will import all disabled rules + # --rules will implicitly add the rules from a rule list + for rule in hbacset: + ipa_rule = convert_to_ipa_rule(rule) + if ipa_rule.name in testrules: + ipa_rule.enabled = True + rules.append(ipa_rule) + testrules.remove(ipa_rule.name) + elif all_enabled and ipa_rule.enabled: + # Option --enabled forces to include all enabled IPA rules into test + rules.append(ipa_rule) + elif all_disabled and not ipa_rule.enabled: + # Option --disabled forces to include all disabled IPA rules into test + ipa_rule.enabled = True + rules.append(ipa_rule) + + # Check if there are unresolved rules left + if len(testrules) > 0: + # Error, unresolved rules are left in --rules + return {'summary' : unicode(_(u'Unresolved rules in --rules')), + 'error': testrules, 'matched': None, 'notmatched': None, + 'warning' : None, 'value' : False} + + # Rules are converted to pyhbac format, build request and then test it + request = pyhbac.HbacRequest() + + if options['user'] != u'all': + # check first if this is not a trusted domain user + if _dcerpc_bindings_installed: + is_valid_sid = ipaserver.dcerpc.is_sid_valid(options['user']) + else: + is_valid_sid = False + components = util.normalize_name(options['user']) + if is_valid_sid or 'domain' in components or 'flatname' in components: + # this is a trusted domain user + if not _dcerpc_bindings_installed: + raise errors.NotFound(reason=_( + 'Cannot perform external member validation without ' + 'Samba 4 support installed. Make sure you have installed ' + 'server-trust-ad sub-package of IPA on the server')) + domain_validator = ipaserver.dcerpc.DomainValidator(self.api) + if not domain_validator.is_configured(): + raise errors.NotFound(reason=_( + 'Cannot search in trusted domains without own domain configured. ' + 'Make sure you have run ipa-adtrust-install on the IPA server first')) + user_sid, group_sids = domain_validator.get_trusted_domain_user_and_groups(options['user']) + request.user.name = user_sid + + # Now search for all external groups that have this user or + # any of its groups in its external members. Found entires + # memberOf links will be then used to gather all groups where + # this group is assigned, including the nested ones + filter_sids = "(&(objectclass=ipaexternalgroup)(|(ipaExternalMember=%s)))" \ + % ")(ipaExternalMember=".join(group_sids + [user_sid]) + + ldap = self.api.Backend.ldap2 + group_container = DN(api.env.container_group, api.env.basedn) + try: + entries, truncated = ldap.find_entries(filter_sids, ['memberof'], group_container) + except errors.NotFound: + request.user.groups = [] + else: + groups = [] + for entry in entries: + memberof_dns = entry.get('memberof', []) + for memberof_dn in memberof_dns: + if memberof_dn.endswith(group_container): + groups.append(memberof_dn[0][0].value) + request.user.groups = sorted(set(groups)) + else: + # try searching for a local user + try: + request.user.name = options['user'] + search_result = self.api.Command.user_show(request.user.name)['result'] + groups = search_result['memberof_group'] + if 'memberofindirect_group' in search_result: + groups += search_result['memberofindirect_group'] + request.user.groups = sorted(set(groups)) + except Exception: + pass + + if options['service'] != u'all': + try: + request.service.name = options['service'] + service_result = self.api.Command.hbacsvc_show(request.service.name)['result'] + if 'memberof_hbacsvcgroup' in service_result: + request.service.groups = service_result['memberof_hbacsvcgroup'] + except Exception: + pass + + if options['targethost'] != u'all': + try: + request.targethost.name = self.canonicalize(options['targethost']) + tgthost_result = self.api.Command.host_show(request.targethost.name)['result'] + groups = tgthost_result['memberof_hostgroup'] + if 'memberofindirect_hostgroup' in tgthost_result: + groups += tgthost_result['memberofindirect_hostgroup'] + request.targethost.groups = sorted(set(groups)) + except Exception: + pass + + matched_rules = [] + notmatched_rules = [] + error_rules = [] + warning_rules = [] + + result = {'warning':None, 'matched':None, 'notmatched':None, 'error':None} + if not options['nodetail']: + # Validate runs rules one-by-one and reports failed ones + for ipa_rule in rules: + try: + res = request.evaluate([ipa_rule]) + if res == pyhbac.HBAC_EVAL_ALLOW: + matched_rules.append(ipa_rule.name) + if res == pyhbac.HBAC_EVAL_DENY: + notmatched_rules.append(ipa_rule.name) + except pyhbac.HbacError as e: + code, rule_name = e.args + if code == pyhbac.HBAC_EVAL_ERROR: + error_rules.append(rule_name) + self.log.info('Native IPA HBAC rule "%s" parsing error: %s' % \ + (rule_name, pyhbac.hbac_result_string(code))) + except (TypeError, IOError) as info: + self.log.error('Native IPA HBAC module error: %s' % info) + + access_granted = len(matched_rules) > 0 + else: + res = request.evaluate(rules) + access_granted = (res == pyhbac.HBAC_EVAL_ALLOW) + + result['summary'] = _('Access granted: %s') % (access_granted) + + + if len(matched_rules) > 0: + result['matched'] = matched_rules + if len(notmatched_rules) > 0: + result['notmatched'] = notmatched_rules + if len(error_rules) > 0: + result['error'] = error_rules + if len(warning_rules) > 0: + result['warning'] = warning_rules + + result['value'] = access_granted + return result diff --git a/ipaserver/plugins/host.py b/ipaserver/plugins/host.py new file mode 100644 index 000000000..709b78d5b --- /dev/null +++ b/ipaserver/plugins/host.py @@ -0,0 +1,1284 @@ +# Authors: +# Rob Crittenden <rcritten@redhat.com> +# Pavel Zuna <pzuna@redhat.com> +# +# Copyright (C) 2008 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +import string + +import six + +from ipalib import api, errors, util +from ipalib import messages +from ipalib import Str, Flag, Bytes +from ipalib.plugable import Registry +from .baseldap import (LDAPQuery, LDAPObject, LDAPCreate, + LDAPDelete, LDAPUpdate, LDAPSearch, + LDAPRetrieve, LDAPAddMember, + LDAPRemoveMember, host_is_master, + pkey_to_value, add_missing_object_class, + LDAPAddAttribute, LDAPRemoveAttribute) +from .service import (split_principal, validate_certificate, + set_certificate_attrs, ticket_flags_params, update_krbticketflags, + set_kerberos_attrs, rename_ipaallowedtoperform_from_ldap, + rename_ipaallowedtoperform_to_ldap, revoke_certs) +from .dns import (dns_container_exists, + add_records_for_host_validation, add_records_for_host, + get_reverse_zone) +from ipalib import _, ngettext +from ipalib import x509 +from ipalib import output +from ipalib.request import context +from ipalib.util import (normalize_sshpubkey, validate_sshpubkey_no_options, + convert_sshpubkey_post, validate_hostname, + add_sshpubkey_to_attrs_pre, + remove_sshpubkey_from_output_post, + remove_sshpubkey_from_output_list_post) +from ipapython.ipautil import ipa_generate_password, CheckedIPAddress +from ipapython.dnsutil import DNSName +from ipapython.ssh import SSHPublicKey +from ipapython.dn import DN +from functools import reduce + +if six.PY3: + unicode = str + +__doc__ = _(""" +Hosts/Machines + +A host represents a machine. It can be used in a number of contexts: +- service entries are associated with a host +- a host stores the host/ service principal +- a host can be used in Host-based Access Control (HBAC) rules +- every enrolled client generates a host entry +""") + _(""" +ENROLLMENT: + +There are three enrollment scenarios when enrolling a new client: + +1. You are enrolling as a full administrator. The host entry may exist + or not. A full administrator is a member of the hostadmin role + or the admins group. +2. You are enrolling as a limited administrator. The host must already + exist. A limited administrator is a member a role with the + Host Enrollment privilege. +3. The host has been created with a one-time password. +""") + _(""" +RE-ENROLLMENT: + +Host that has been enrolled at some point, and lost its configuration (e.g. VM +destroyed) can be re-enrolled. + +For more information, consult the manual pages for ipa-client-install. + +A host can optionally store information such as where it is located, +the OS that it runs, etc. +""") + _(""" +EXAMPLES: +""") + _(""" + Add a new host: + ipa host-add --location="3rd floor lab" --locality=Dallas test.example.com +""") + _(""" + Delete a host: + ipa host-del test.example.com +""") + _(""" + Add a new host with a one-time password: + ipa host-add --os='Fedora 12' --password=Secret123 test.example.com +""") + _(""" + Add a new host with a random one-time password: + ipa host-add --os='Fedora 12' --random test.example.com +""") + _(""" + Modify information about a host: + ipa host-mod --os='Fedora 12' test.example.com +""") + _(""" + Remove SSH public keys of a host and update DNS to reflect this change: + ipa host-mod --sshpubkey= --updatedns test.example.com +""") + _(""" + Disable the host Kerberos key, SSL certificate and all of its services: + ipa host-disable test.example.com +""") + _(""" + Add a host that can manage this host's keytab and certificate: + ipa host-add-managedby --hosts=test2 test +""") + _(""" + Allow user to create a keytab: + ipa host-allow-create-keytab test2 --users=tuser1 +""") + +register = Registry() + +# Characters to be used by random password generator +# The set was chosen to avoid the need for escaping the characters by user +host_pwd_chars = string.digits + string.ascii_letters + '_,.@+-=' + + +def remove_ptr_rec(ipaddr, host, domain): + """ + Remove PTR record of IP address (ipaddr) + :return: True if PTR record was removed, False if record was not found + """ + api.log.debug('deleting PTR record of ipaddr %s', ipaddr) + try: + revzone, revname = get_reverse_zone(ipaddr) + + # in case domain is in FQDN form with a trailing dot, we needn't add + # another one, in case it has no trailing dot, dnsrecord-del will + # normalize the entry + delkw = {'ptrrecord': "%s.%s" % (host, domain)} + + api.Command['dnsrecord_del'](revzone, revname, **delkw) + except errors.NotFound: + api.log.debug('PTR record of ipaddr %s not found', ipaddr) + return False + + return True + + +def update_sshfp_record(zone, record, entry_attrs): + if 'ipasshpubkey' not in entry_attrs: + return + + pubkeys = entry_attrs['ipasshpubkey'] or () + sshfps = [] + for pubkey in pubkeys: + try: + sshfp = SSHPublicKey(pubkey).fingerprint_dns_sha1() + except (ValueError, UnicodeDecodeError): + continue + if sshfp is not None: + sshfps.append(sshfp) + + try: + sshfp = SSHPublicKey(pubkey).fingerprint_dns_sha256() + except (ValueError, UnicodeDecodeError): + continue + if sshfp is not None: + sshfps.append(sshfp) + + try: + api.Command['dnsrecord_mod'](zone, record, sshfprecord=sshfps) + except errors.EmptyModlist: + pass + + +def convert_ipaassignedidview_post(entry_attrs, options): + """ + Converts the ID View DN to its name for the better looking output. + """ + + if 'ipaassignedidview' in entry_attrs and not options.get('raw'): + idview_name = entry_attrs.single_value['ipaassignedidview'][0].value + entry_attrs.single_value['ipaassignedidview'] = idview_name + + +host_output_params = ( + Flag('has_keytab', + label=_('Keytab'), + ), + Str('managedby_host', + label='Managed by', + ), + Str('managing_host', + label='Managing', + ), + Str('subject', + label=_('Subject'), + ), + Str('serial_number', + label=_('Serial Number'), + ), + Str('serial_number_hex', + label=_('Serial Number (hex)'), + ), + Str('issuer', + label=_('Issuer'), + ), + Str('valid_not_before', + label=_('Not Before'), + ), + Str('valid_not_after', + label=_('Not After'), + ), + Str('md5_fingerprint', + label=_('Fingerprint (MD5)'), + ), + Str('sha1_fingerprint', + label=_('Fingerprint (SHA1)'), + ), + Str('revocation_reason?', + label=_('Revocation reason'), + ), + Str('managedby', + label=_('Failed managedby'), + ), + Str('sshpubkeyfp*', + label=_('SSH public key fingerprint'), + ), + Str('ipaallowedtoperform_read_keys_user', + label=_('Users allowed to retrieve keytab'), + ), + Str('ipaallowedtoperform_read_keys_group', + label=_('Groups allowed to retrieve keytab'), + ), + Str('ipaallowedtoperform_read_keys_host', + label=_('Hosts allowed to retrieve keytab'), + ), + Str('ipaallowedtoperform_read_keys_hostgroup', + label=_('Host Groups allowed to retrieve keytab'), + ), + Str('ipaallowedtoperform_write_keys_user', + label=_('Users allowed to create keytab'), + ), + Str('ipaallowedtoperform_write_keys_group', + label=_('Groups allowed to create keytab'), + ), + Str('ipaallowedtoperform_write_keys_host', + label=_('Hosts allowed to create keytab'), + ), + Str('ipaallowedtoperform_write_keys_hostgroup', + label=_('Host Groups allowed to create keytab'), + ), + Str('ipaallowedtoperform_read_keys', + label=_('Failed allowed to retrieve keytab'), + ), + Str('ipaallowedtoperform_write_keys', + label=_('Failed allowed to create keytab'), + ), +) + + +def validate_ipaddr(ugettext, ipaddr): + """ + Verify that we have either an IPv4 or IPv6 address. + """ + try: + CheckedIPAddress(ipaddr, match_local=False) + except Exception as e: + return unicode(e) + return None + + +def normalize_hostname(hostname): + """Use common fqdn form without the trailing dot""" + if hostname.endswith(u'.'): + hostname = hostname[:-1] + hostname = hostname.lower() + return hostname + + +def _hostname_validator(ugettext, value): + try: + validate_hostname(value) + except ValueError as e: + return _('invalid domain-name: %s') % unicode(e) + + return None + + +@register() +class host(LDAPObject): + """ + Host object. + """ + container_dn = api.env.container_host + object_name = _('host') + object_name_plural = _('hosts') + object_class = ['ipaobject', 'nshost', 'ipahost', 'pkiuser', 'ipaservice'] + possible_objectclasses = ['ipaallowedoperations'] + permission_filter_objectclasses = ['ipahost'] + # object_class_config = 'ipahostobjectclasses' + search_attributes = [ + 'fqdn', 'description', 'l', 'nshostlocation', 'krbprincipalname', + 'nshardwareplatform', 'nsosversion', 'managedby', + ] + default_attributes = [ + 'fqdn', 'description', 'l', 'nshostlocation', 'krbprincipalname', + 'nshardwareplatform', 'nsosversion', 'usercertificate', 'memberof', + 'managedby', 'memberofindirect', 'macaddress', + 'userclass', 'ipaallowedtoperform', 'ipaassignedidview', + ] + uuid_attribute = 'ipauniqueid' + attribute_members = { + 'enrolledby': ['user'], + 'memberof': ['hostgroup', 'netgroup', 'role', 'hbacrule', 'sudorule'], + 'managedby': ['host'], + 'managing': ['host'], + 'memberofindirect': ['hostgroup', 'netgroup', 'role', 'hbacrule', + 'sudorule'], + 'ipaallowedtoperform_read_keys': ['user', 'group', 'host', 'hostgroup'], + 'ipaallowedtoperform_write_keys': ['user', 'group', 'host', 'hostgroup'], + } + bindable = True + relationships = { + 'memberof': ('Member Of', 'in_', 'not_in_'), + 'enrolledby': ('Enrolled by', 'enroll_by_', 'not_enroll_by_'), + 'managedby': ('Managed by', 'man_by_', 'not_man_by_'), + 'managing': ('Managing', 'man_', 'not_man_'), + 'ipaallowedtoperform_read_keys': ('Allow to retrieve keytab by', 'retrieve_keytab_by_', 'not_retrieve_keytab_by_'), + 'ipaallowedtoperform_write_keys': ('Allow to create keytab by', 'write_keytab_by_', 'not_write_keytab_by'), + } + password_attributes = [('userpassword', 'has_password'), + ('krbprincipalkey', 'has_keytab')] + managed_permissions = { + 'System: Read Hosts': { + 'replaces_global_anonymous_aci': True, + 'ipapermbindruletype': 'all', + 'ipapermright': {'read', 'search', 'compare'}, + 'ipapermdefaultattr': { + 'cn', 'description', 'fqdn', 'ipaclientversion', + 'ipakrbauthzdata', 'ipasshpubkey', 'ipauniqueid', + 'krbprincipalname', 'l', 'macaddress', 'nshardwareplatform', + 'nshostlocation', 'nsosversion', 'objectclass', + 'serverhostname', 'usercertificate', 'userclass', + 'enrolledby', 'managedby', 'ipaassignedidview', + 'krbprincipalname', 'krbcanonicalname', 'krbprincipalaliases', + 'krbprincipalexpiration', 'krbpasswordexpiration', + 'krblastpwdchange', + }, + }, + 'System: Read Host Membership': { + 'replaces_global_anonymous_aci': True, + 'ipapermbindruletype': 'all', + 'ipapermright': {'read', 'search', 'compare'}, + 'ipapermdefaultattr': { + 'memberof', + }, + }, + 'System: Add Hosts': { + 'ipapermright': {'add'}, + 'replaces': [ + '(target = "ldap:///fqdn=*,cn=computers,cn=accounts,$SUFFIX")(version 3.0;acl "permission:Add Hosts";allow (add) groupdn = "ldap:///cn=Add Hosts,cn=permissions,cn=pbac,$SUFFIX";)', + ], + 'default_privileges': {'Host Administrators'}, + }, + 'System: Add krbPrincipalName to a Host': { + # Allow an admin to enroll a host that has a one-time password. + # When a host is created with a password no krbPrincipalName is set. + # This will let it be added if the client ends up enrolling with + # an administrator instead. + 'ipapermright': {'write'}, + 'ipapermtargetfilter': [ + '(objectclass=ipahost)', + '(!(krbprincipalname=*))', + ], + 'ipapermdefaultattr': {'krbprincipalname'}, + 'replaces': [ + '(target = "ldap:///fqdn=*,cn=computers,cn=accounts,$SUFFIX")(targetfilter = "(!(krbprincipalname=*))")(targetattr = "krbprincipalname")(version 3.0;acl "permission:Add krbPrincipalName to a host"; allow (write) groupdn = "ldap:///cn=Add krbPrincipalName to a host,cn=permissions,cn=pbac,$SUFFIX";)', + ], + 'default_privileges': {'Host Administrators', 'Host Enrollment'}, + }, + 'System: Enroll a Host': { + 'ipapermright': {'write'}, + 'ipapermdefaultattr': {'objectclass', 'enrolledby'}, + 'replaces': [ + '(targetattr = "objectclass")(target = "ldap:///fqdn=*,cn=computers,cn=accounts,$SUFFIX")(version 3.0;acl "permission:Enroll a host";allow (write) groupdn = "ldap:///cn=Enroll a host,cn=permissions,cn=pbac,$SUFFIX";)', + '(targetattr = "enrolledby || objectclass")(target = "ldap:///fqdn=*,cn=computers,cn=accounts,$SUFFIX")(version 3.0;acl "permission:Enroll a host";allow (write) groupdn = "ldap:///cn=Enroll a host,cn=permissions,cn=pbac,$SUFFIX";)', + ], + 'default_privileges': {'Host Administrators', 'Host Enrollment'}, + }, + 'System: Manage Host SSH Public Keys': { + 'ipapermright': {'write'}, + 'ipapermdefaultattr': {'ipasshpubkey'}, + 'replaces': [ + '(targetattr = "ipasshpubkey")(target = "ldap:///fqdn=*,cn=computers,cn=accounts,$SUFFIX")(version 3.0;acl "permission:Manage Host SSH Public Keys";allow (write) groupdn = "ldap:///cn=Manage Host SSH Public Keys,cn=permissions,cn=pbac,$SUFFIX";)', + ], + 'default_privileges': {'Host Administrators'}, + }, + 'System: Manage Host Keytab': { + 'ipapermright': {'write'}, + 'ipapermtargetfilter': [ + '(objectclass=ipahost)', + '(!(memberOf=%s))' % DN('cn=ipaservers', + api.env.container_hostgroup, + api.env.basedn), + ], + 'ipapermdefaultattr': {'krblastpwdchange', 'krbprincipalkey'}, + 'replaces': [ + '(targetattr = "krbprincipalkey || krblastpwdchange")(target = "ldap:///fqdn=*,cn=computers,cn=accounts,$SUFFIX")(version 3.0;acl "permission:Manage host keytab";allow (write) groupdn = "ldap:///cn=Manage host keytab,cn=permissions,cn=pbac,$SUFFIX";)', + ], + 'default_privileges': {'Host Administrators', 'Host Enrollment'}, + }, + 'System: Manage Host Keytab Permissions': { + 'ipapermright': {'read', 'search', 'compare', 'write'}, + 'ipapermdefaultattr': { + 'ipaallowedtoperform;write_keys', + 'ipaallowedtoperform;read_keys', 'objectclass' + }, + 'default_privileges': {'Host Administrators'}, + }, + 'System: Modify Hosts': { + 'ipapermright': {'write'}, + 'ipapermdefaultattr': { + 'description', 'l', 'nshardwareplatform', 'nshostlocation', + 'nsosversion', 'macaddress', 'userclass', 'ipaassignedidview', + }, + 'replaces': [ + '(targetattr = "description || l || nshostlocation || nshardwareplatform || nsosversion")(target = "ldap:///fqdn=*,cn=computers,cn=accounts,$SUFFIX")(version 3.0;acl "permission:Modify Hosts";allow (write) groupdn = "ldap:///cn=Modify Hosts,cn=permissions,cn=pbac,$SUFFIX";)', + ], + 'default_privileges': {'Host Administrators'}, + }, + 'System: Remove Hosts': { + 'ipapermright': {'delete'}, + 'replaces': [ + '(target = "ldap:///fqdn=*,cn=computers,cn=accounts,$SUFFIX")(version 3.0;acl "permission:Remove Hosts";allow (delete) groupdn = "ldap:///cn=Remove Hosts,cn=permissions,cn=pbac,$SUFFIX";)', + ], + 'default_privileges': {'Host Administrators'}, + }, + 'System: Manage Host Certificates': { + 'ipapermbindruletype': 'permission', + 'ipapermright': {'write'}, + 'ipapermdefaultattr': {'usercertificate'}, + 'default_privileges': {'Host Administrators', 'Host Enrollment'}, + }, + 'System: Manage Host Enrollment Password': { + 'ipapermbindruletype': 'permission', + 'ipapermright': {'write'}, + 'ipapermdefaultattr': {'userpassword'}, + 'default_privileges': {'Host Administrators', 'Host Enrollment'}, + }, + 'System: Read Host Compat Tree': { + 'non_object': True, + 'ipapermbindruletype': 'anonymous', + 'ipapermlocation': api.env.basedn, + 'ipapermtarget': DN('cn=computers', 'cn=compat', api.env.basedn), + 'ipapermright': {'read', 'search', 'compare'}, + 'ipapermdefaultattr': { + 'objectclass', 'cn', 'macaddress', + }, + }, + } + + label = _('Hosts') + label_singular = _('Host') + + takes_params = ( + Str('fqdn', _hostname_validator, + cli_name='hostname', + label=_('Host name'), + primary_key=True, + normalizer=normalize_hostname, + ), + Str('description?', + cli_name='desc', + label=_('Description'), + doc=_('A description of this host'), + ), + Str('l?', + cli_name='locality', + label=_('Locality'), + doc=_('Host locality (e.g. "Baltimore, MD")'), + ), + Str('nshostlocation?', + cli_name='location', + label=_('Location'), + doc=_('Host location (e.g. "Lab 2")'), + ), + Str('nshardwareplatform?', + cli_name='platform', + label=_('Platform'), + doc=_('Host hardware platform (e.g. "Lenovo T61")'), + ), + Str('nsosversion?', + cli_name='os', + label=_('Operating system'), + doc=_('Host operating system and version (e.g. "Fedora 9")'), + ), + Str('userpassword?', + cli_name='password', + label=_('User password'), + doc=_('Password used in bulk enrollment'), + ), + Flag('random?', + doc=_('Generate a random password to be used in bulk enrollment'), + flags=('no_search', 'virtual_attribute'), + default=False, + ), + Str('randompassword?', + label=_('Random password'), + flags=('no_create', 'no_update', 'no_search', 'virtual_attribute'), + ), + Bytes('usercertificate*', validate_certificate, + cli_name='certificate', + label=_('Certificate'), + doc=_('Base-64 encoded host certificate'), + ), + Str('krbprincipalname?', + label=_('Principal name'), + flags=['no_create', 'no_update', 'no_search'], + ), + Str('macaddress*', + normalizer=lambda value: value.upper(), + pattern='^([a-fA-F0-9]{2}[:|\-]?){5}[a-fA-F0-9]{2}$', + pattern_errmsg=('Must be of the form HH:HH:HH:HH:HH:HH, where ' + 'each H is a hexadecimal character.'), + label=_('MAC address'), + doc=_('Hardware MAC address(es) on this host'), + ), + Str('ipasshpubkey*', validate_sshpubkey_no_options, + cli_name='sshpubkey', + label=_('SSH public key'), + normalizer=normalize_sshpubkey, + flags=['no_search'], + ), + Str('userclass*', + cli_name='class', + label=_('Class'), + doc=_('Host category (semantics placed on this attribute are for ' + 'local interpretation)'), + ), + Str('ipaassignedidview?', + label=_('Assigned ID View'), + flags=['no_option'], + ), + ) + ticket_flags_params + + def get_dn(self, *keys, **options): + hostname = keys[-1] + dn = super(host, self).get_dn(hostname, **options) + try: + self.backend.get_entry(dn, ['']) + except errors.NotFound: + try: + entry_attrs = self.backend.find_entry_by_attr( + 'serverhostname', hostname, self.object_class, [''], + DN(self.container_dn, api.env.basedn)) + dn = entry_attrs.dn + except errors.NotFound: + pass + return dn + + def get_managed_hosts(self, dn): + host_filter = 'managedBy=%s' % dn + host_attrs = ['fqdn'] + ldap = self.api.Backend.ldap2 + managed_hosts = [] + + try: + (hosts, truncated) = ldap.find_entries( + base_dn=DN(self.container_dn, api.env.basedn), + filter=host_filter, attrs_list=host_attrs) + + for host in hosts: + managed_hosts.append(host.dn) + except errors.NotFound: + return [] + + return managed_hosts + + def suppress_netgroup_memberof(self, ldap, entry_attrs): + """ + We don't want to show managed netgroups so remove them from the + memberofindirect list. + """ + ng_container = DN(api.env.container_netgroup, api.env.basedn) + for member in list(entry_attrs.get('memberofindirect', [])): + memberdn = DN(member) + if not memberdn.endswith(ng_container): + continue + + filter = ldap.make_filter({'objectclass': 'mepmanagedentry'}) + try: + ldap.get_entries(memberdn, ldap.SCOPE_BASE, filter, ['']) + except errors.NotFound: + pass + else: + entry_attrs['memberofindirect'].remove(member) + + +@register() +class host_add(LDAPCreate): + __doc__ = _('Add a new host.') + + has_output_params = LDAPCreate.has_output_params + host_output_params + msg_summary = _('Added host "%(value)s"') + member_attributes = ['managedby'] + takes_options = LDAPCreate.takes_options + ( + Flag('force', + label=_('Force'), + doc=_('force host name even if not in DNS'), + ), + Flag('no_reverse', + doc=_('skip reverse DNS detection'), + ), + Str('ip_address?', validate_ipaddr, + doc=_('Add the host to DNS with this IP address'), + label=_('IP Address'), + ), + ) + + def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options): + assert isinstance(dn, DN) + if options.get('ip_address') and dns_container_exists(ldap): + parts = keys[-1].split('.') + host = parts[0] + domain = unicode('.'.join(parts[1:])) + check_reverse = not options.get('no_reverse', False) + add_records_for_host_validation('ip_address', + DNSName(host), + DNSName(domain).make_absolute(), + options['ip_address'], + check_forward=True, + check_reverse=check_reverse) + if not options.get('force', False) and not 'ip_address' in options: + util.verify_host_resolvable(keys[-1]) + if 'locality' in entry_attrs: + entry_attrs['l'] = entry_attrs['locality'] + entry_attrs['cn'] = keys[-1] + entry_attrs['serverhostname'] = keys[-1].split('.', 1)[0] + if not entry_attrs.get('userpassword', False) and not options.get('random', False): + entry_attrs['krbprincipalname'] = 'host/%s@%s' % ( + keys[-1], self.api.env.realm + ) + if 'krbprincipalaux' not in entry_attrs['objectclass']: + entry_attrs['objectclass'].append('krbprincipalaux') + if 'krbprincipal' not in entry_attrs['objectclass']: + entry_attrs['objectclass'].append('krbprincipal') + else: + if 'krbprincipalaux' in entry_attrs['objectclass']: + entry_attrs['objectclass'].remove('krbprincipalaux') + if 'krbprincipal' in entry_attrs['objectclass']: + entry_attrs['objectclass'].remove('krbprincipal') + if options.get('random'): + entry_attrs['userpassword'] = ipa_generate_password(characters=host_pwd_chars) + # save the password so it can be displayed in post_callback + setattr(context, 'randompassword', entry_attrs['userpassword']) + certs = options.get('usercertificate', []) + certs_der = [x509.normalize_certificate(c) for c in certs] + for cert in certs_der: + x509.verify_cert_subject(ldap, keys[-1], cert) + entry_attrs['usercertificate'] = certs_der + entry_attrs['managedby'] = dn + entry_attrs['objectclass'].append('ieee802device') + entry_attrs['objectclass'].append('ipasshhost') + update_krbticketflags(ldap, entry_attrs, attrs_list, options, False) + if 'krbticketflags' in entry_attrs: + entry_attrs['objectclass'].append('krbticketpolicyaux') + return dn + + def post_callback(self, ldap, dn, entry_attrs, *keys, **options): + assert isinstance(dn, DN) + exc = None + if dns_container_exists(ldap): + try: + parts = keys[-1].split('.') + host = parts[0] + domain = unicode('.'.join(parts[1:])) + + if options.get('ip_address'): + add_reverse = not options.get('no_reverse', False) + + add_records_for_host(DNSName(host), + DNSName(domain).make_absolute(), + options['ip_address'], + add_forward=True, + add_reverse=add_reverse) + del options['ip_address'] + + update_sshfp_record(domain, unicode(parts[0]), entry_attrs) + except Exception as e: + exc = e + if options.get('random', False): + try: + entry_attrs['randompassword'] = unicode(getattr(context, 'randompassword')) + except AttributeError: + # On the off-chance some other extension deletes this from the + # context, don't crash. + pass + if exc: + raise errors.NonFatalError( + reason=_('The host was added but the DNS update failed with: %(exc)s') % dict(exc=exc) + ) + set_certificate_attrs(entry_attrs) + set_kerberos_attrs(entry_attrs, options) + rename_ipaallowedtoperform_from_ldap(entry_attrs, options) + + if options.get('all', False): + entry_attrs['managing'] = self.obj.get_managed_hosts(dn) + self.obj.get_password_attributes(ldap, dn, entry_attrs) + if entry_attrs['has_password']: + # If an OTP is set there is no keytab, at least not one + # fetched anywhere. + entry_attrs['has_keytab'] = False + + convert_sshpubkey_post(entry_attrs) + + return dn + + +@register() +class host_del(LDAPDelete): + __doc__ = _('Delete a host.') + + msg_summary = _('Deleted host "%(value)s"') + member_attributes = ['managedby'] + + takes_options = LDAPDelete.takes_options + ( + Flag('updatedns?', + doc=_('Remove A, AAAA, SSHFP and PTR records of the host(s) ' + 'managed by IPA DNS'), + default=False, + ), + ) + + def pre_callback(self, ldap, dn, *keys, **options): + assert isinstance(dn, DN) + # If we aren't given a fqdn, find it + if _hostname_validator(None, keys[-1]) is not None: + hostentry = api.Command['host_show'](keys[-1])['result'] + fqdn = hostentry['fqdn'][0] + else: + fqdn = keys[-1] + host_is_master(ldap, fqdn) + # Remove all service records for this host + truncated = True + while truncated: + try: + ret = api.Command['service_find'](fqdn) + truncated = ret['truncated'] + services = ret['result'] + except errors.NotFound: + break + else: + for entry_attrs in services: + principal = entry_attrs['krbprincipalname'][0] + (service, hostname, realm) = split_principal(principal) + if hostname.lower() == fqdn: + api.Command['service_del'](principal) + updatedns = options.get('updatedns', False) + if updatedns: + try: + updatedns = dns_container_exists(ldap) + except errors.NotFound: + updatedns = False + + if updatedns: + # Remove A, AAAA, SSHFP and PTR records of the host + parts = fqdn.split('.') + domain = unicode('.'.join(parts[1:])) + # Get all resources for this host + rec_removed = False + try: + record = api.Command['dnsrecord_show']( + domain, parts[0])['result'] + except errors.NotFound: + pass + else: + # remove PTR records first + for attr in ('arecord', 'aaaarecord'): + for val in record.get(attr, []): + rec_removed = ( + remove_ptr_rec(val, parts[0], domain) or + rec_removed + ) + try: + # remove all A, AAAA, SSHFP records of the host + api.Command['dnsrecord_mod']( + domain, + record['idnsname'][0], + arecord=[], + aaaarecord=[], + sshfprecord=[] + ) + except errors.EmptyModlist: + pass + else: + rec_removed = True + + if not rec_removed: + self.add_message( + messages.FailedToRemoveHostDNSRecords( + host=fqdn, + reason=_("No A, AAAA, SSHFP or PTR records found.") + ) + ) + + if self.api.Command.ca_is_enabled()['result']: + try: + entry_attrs = ldap.get_entry(dn, ['usercertificate']) + except errors.NotFound: + self.obj.handle_not_found(*keys) + + revoke_certs(entry_attrs.get('usercertificate', []), self.log) + + return dn + + +@register() +class host_mod(LDAPUpdate): + __doc__ = _('Modify information about a host.') + + has_output_params = LDAPUpdate.has_output_params + host_output_params + msg_summary = _('Modified host "%(value)s"') + member_attributes = ['managedby'] + + takes_options = LDAPUpdate.takes_options + ( + Str('krbprincipalname?', + cli_name='principalname', + label=_('Principal name'), + doc=_('Kerberos principal name for this host'), + attribute=True, + ), + Flag('updatedns?', + doc=_('Update DNS entries'), + default=False, + ), + ) + + def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options): + assert isinstance(dn, DN) + # Allow an existing OTP to be reset but don't allow a OTP to be + # added to an enrolled host. + if options.get('userpassword') or options.get('random'): + entry = {} + self.obj.get_password_attributes(ldap, dn, entry) + if not entry['has_password'] and entry['has_keytab']: + raise errors.ValidationError( + name='password', + error=_('Password cannot be set on enrolled host.')) + + # Once a principal name is set it cannot be changed + if 'cn' in entry_attrs: + raise errors.ACIError(info=_('cn is immutable')) + if 'locality' in entry_attrs: + entry_attrs['l'] = entry_attrs['locality'] + if 'krbprincipalname' in entry_attrs: + entry_attrs_old = ldap.get_entry( + dn, ['objectclass', 'krbprincipalname'] + ) + if 'krbprincipalname' in entry_attrs_old: + msg = 'Principal name already set, it is unchangeable.' + raise errors.ACIError(info=msg) + obj_classes = entry_attrs_old['objectclass'] + if 'krbprincipalaux' not in obj_classes: + obj_classes.append('krbprincipalaux') + entry_attrs['objectclass'] = obj_classes + + # verify certificates + certs = entry_attrs.get('usercertificate') or [] + certs_der = [x509.normalize_certificate(c) for c in certs] + for cert in certs_der: + x509.verify_cert_subject(ldap, keys[-1], cert) + + # revoke removed certificates + if certs and self.api.Command.ca_is_enabled()['result']: + try: + entry_attrs_old = ldap.get_entry(dn, ['usercertificate']) + except errors.NotFound: + self.obj.handle_not_found(*keys) + old_certs = entry_attrs_old.get('usercertificate', []) + old_certs_der = [x509.normalize_certificate(c) for c in old_certs] + removed_certs_der = set(old_certs_der) - set(certs_der) + revoke_certs(removed_certs_der, self.log) + + if certs: + entry_attrs['usercertificate'] = certs_der + + if options.get('random'): + entry_attrs['userpassword'] = ipa_generate_password(characters=host_pwd_chars) + setattr(context, 'randompassword', entry_attrs['userpassword']) + + if 'macaddress' in entry_attrs: + if 'objectclass' in entry_attrs: + obj_classes = entry_attrs['objectclass'] + else: + _entry_attrs = ldap.get_entry(dn, ['objectclass']) + obj_classes = _entry_attrs['objectclass'] + if 'ieee802device' not in obj_classes: + obj_classes.append('ieee802device') + entry_attrs['objectclass'] = obj_classes + + if options.get('updatedns', False) and dns_container_exists(ldap): + parts = keys[-1].split('.') + domain = unicode('.'.join(parts[1:])) + try: + result = api.Command['dnszone_show'](domain)['result'] + domain = result['idnsname'][0] + except errors.NotFound: + self.obj.handle_not_found(*keys) + update_sshfp_record(domain, unicode(parts[0]), entry_attrs) + + if 'ipasshpubkey' in entry_attrs: + if 'objectclass' in entry_attrs: + obj_classes = entry_attrs['objectclass'] + else: + _entry_attrs = ldap.get_entry(dn, ['objectclass']) + obj_classes = entry_attrs['objectclass'] = _entry_attrs['objectclass'] + if 'ipasshhost' not in obj_classes: + obj_classes.append('ipasshhost') + + update_krbticketflags(ldap, entry_attrs, attrs_list, options, True) + + if 'krbticketflags' in entry_attrs: + if 'objectclass' not in entry_attrs: + entry_attrs_old = ldap.get_entry(dn, ['objectclass']) + entry_attrs['objectclass'] = entry_attrs_old['objectclass'] + if 'krbticketpolicyaux' not in entry_attrs['objectclass']: + entry_attrs['objectclass'].append('krbticketpolicyaux') + + add_sshpubkey_to_attrs_pre(self.context, attrs_list) + + return dn + + def post_callback(self, ldap, dn, entry_attrs, *keys, **options): + assert isinstance(dn, DN) + if options.get('random', False): + entry_attrs['randompassword'] = unicode(getattr(context, 'randompassword')) + set_certificate_attrs(entry_attrs) + set_kerberos_attrs(entry_attrs, options) + rename_ipaallowedtoperform_from_ldap(entry_attrs, options) + self.obj.get_password_attributes(ldap, dn, entry_attrs) + if entry_attrs['has_password']: + # If an OTP is set there is no keytab, at least not one + # fetched anywhere. + entry_attrs['has_keytab'] = False + + if options.get('all', False): + entry_attrs['managing'] = self.obj.get_managed_hosts(dn) + + self.obj.suppress_netgroup_memberof(ldap, entry_attrs) + + convert_sshpubkey_post(entry_attrs) + remove_sshpubkey_from_output_post(self.context, entry_attrs) + convert_ipaassignedidview_post(entry_attrs, options) + + return dn + + +@register() +class host_find(LDAPSearch): + __doc__ = _('Search for hosts.') + + has_output_params = LDAPSearch.has_output_params + host_output_params + msg_summary = ngettext( + '%(count)d host matched', '%(count)d hosts matched', 0 + ) + member_attributes = ['memberof', 'enrolledby', 'managedby'] + + def get_options(self): + for option in super(host_find, self).get_options(): + yield option + # "managing" membership has to be added and processed separately + for option in self.get_member_options('managing'): + yield option + + def pre_callback(self, ldap, filter, attrs_list, base_dn, scope, *args, **options): + assert isinstance(base_dn, DN) + if 'locality' in attrs_list: + attrs_list.remove('locality') + attrs_list.append('l') + if 'man_host' in options or 'not_man_host' in options: + hosts = [] + if options.get('man_host') is not None: + for pkey in options.get('man_host', []): + dn = self.obj.get_dn(pkey) + try: + entry_attrs = ldap.get_entry(dn, ['managedby']) + except errors.NotFound: + self.obj.handle_not_found(pkey) + hosts.append(set(entry_attrs.get('managedby', ''))) + hosts = list(reduce(lambda s1, s2: s1 & s2, hosts)) + + if not hosts: + # There is no host managing _all_ hosts in --man-hosts + filter = ldap.combine_filters( + (filter, '(objectclass=disabled)'), ldap.MATCH_ALL + ) + + not_hosts = [] + if options.get('not_man_host') is not None: + for pkey in options.get('not_man_host', []): + dn = self.obj.get_dn(pkey) + try: + entry_attrs = ldap.get_entry(dn, ['managedby']) + except errors.NotFound: + self.obj.handle_not_found(pkey) + not_hosts += entry_attrs.get('managedby', []) + not_hosts = list(set(not_hosts)) + + for target_hosts, filter_op in ((hosts, ldap.MATCH_ANY), + (not_hosts, ldap.MATCH_NONE)): + hosts_avas = [DN(host)[0][0] for host in target_hosts] + hosts_filters = [ldap.make_filter_from_attr(ava.attr, ava.value) + for ava in hosts_avas] + hosts_filter = ldap.combine_filters(hosts_filters, filter_op) + + filter = ldap.combine_filters( + (filter, hosts_filter), ldap.MATCH_ALL + ) + + add_sshpubkey_to_attrs_pre(self.context, attrs_list) + + return (filter.replace('locality', 'l'), base_dn, scope) + + def post_callback(self, ldap, entries, truncated, *args, **options): + if options.get('pkey_only', False): + return truncated + for entry_attrs in entries: + set_certificate_attrs(entry_attrs) + set_kerberos_attrs(entry_attrs, options) + rename_ipaallowedtoperform_from_ldap(entry_attrs, options) + self.obj.suppress_netgroup_memberof(ldap, entry_attrs) + + if options.get('all', False): + entry_attrs['managing'] = self.obj.get_managed_hosts(entry_attrs.dn) + + convert_sshpubkey_post(entry_attrs) + remove_sshpubkey_from_output_post(self.context, entry_attrs) + convert_ipaassignedidview_post(entry_attrs, options) + + remove_sshpubkey_from_output_list_post(self.context, entries) + + return truncated + + +@register() +class host_show(LDAPRetrieve): + __doc__ = _('Display information about a host.') + + has_output_params = LDAPRetrieve.has_output_params + host_output_params + takes_options = LDAPRetrieve.takes_options + ( + Str('out?', + doc=_('file to store certificate in'), + ), + ) + + member_attributes = ['managedby'] + + def pre_callback(self, ldap, dn, attrs_list, *keys, **options): + assert isinstance(dn, DN) + add_sshpubkey_to_attrs_pre(self.context, attrs_list) + return dn + + def post_callback(self, ldap, dn, entry_attrs, *keys, **options): + assert isinstance(dn, DN) + self.obj.get_password_attributes(ldap, dn, entry_attrs) + if entry_attrs['has_password']: + # If an OTP is set there is no keytab, at least not one + # fetched anywhere. + entry_attrs['has_keytab'] = False + + set_certificate_attrs(entry_attrs) + set_kerberos_attrs(entry_attrs, options) + rename_ipaallowedtoperform_from_ldap(entry_attrs, options) + + if options.get('all', False): + entry_attrs['managing'] = self.obj.get_managed_hosts(dn) + + self.obj.suppress_netgroup_memberof(ldap, entry_attrs) + + convert_sshpubkey_post(entry_attrs) + remove_sshpubkey_from_output_post(self.context, entry_attrs) + convert_ipaassignedidview_post(entry_attrs, options) + + return dn + + +@register() +class host_disable(LDAPQuery): + __doc__ = _('Disable the Kerberos key, SSL certificate and all services of a host.') + + has_output = output.standard_value + msg_summary = _('Disabled host "%(value)s"') + + def execute(self, *keys, **options): + ldap = self.obj.backend + + # If we aren't given a fqdn, find it + if _hostname_validator(None, keys[-1]) is not None: + hostentry = api.Command['host_show'](keys[-1])['result'] + fqdn = hostentry['fqdn'][0] + else: + fqdn = keys[-1] + + host_is_master(ldap, fqdn) + + # See if we actually do anthing here, and if not raise an exception + done_work = False + + truncated = True + while truncated: + try: + ret = api.Command['service_find'](fqdn) + truncated = ret['truncated'] + services = ret['result'] + except errors.NotFound: + break + else: + for entry_attrs in services: + principal = entry_attrs['krbprincipalname'][0] + (service, hostname, realm) = split_principal(principal) + if hostname.lower() == fqdn: + try: + api.Command['service_disable'](principal) + done_work = True + except errors.AlreadyInactive: + pass + + dn = self.obj.get_dn(*keys, **options) + try: + entry_attrs = ldap.get_entry(dn, ['usercertificate']) + except errors.NotFound: + self.obj.handle_not_found(*keys) + if self.api.Command.ca_is_enabled()['result']: + certs = entry_attrs.get('usercertificate', []) + + if certs: + revoke_certs(certs, self.log) + # Remove the usercertificate altogether + entry_attrs['usercertificate'] = None + ldap.update_entry(entry_attrs) + done_work = True + + self.obj.get_password_attributes(ldap, dn, entry_attrs) + if entry_attrs['has_keytab']: + ldap.remove_principal_key(dn) + done_work = True + + if not done_work: + raise errors.AlreadyInactive() + + return dict( + result=True, + value=pkey_to_value(keys[0], options), + ) + + def post_callback(self, ldap, dn, entry_attrs, *keys, **options): + assert isinstance(dn, DN) + self.obj.suppress_netgroup_memberof(ldap, entry_attrs) + return dn + + +@register() +class host_add_managedby(LDAPAddMember): + __doc__ = _('Add hosts that can manage this host.') + + member_attributes = ['managedby'] + has_output_params = LDAPAddMember.has_output_params + host_output_params + allow_same = True + + def post_callback(self, ldap, completed, failed, dn, entry_attrs, *keys, **options): + assert isinstance(dn, DN) + self.obj.suppress_netgroup_memberof(ldap, entry_attrs) + return (completed, dn) + + +@register() +class host_remove_managedby(LDAPRemoveMember): + __doc__ = _('Remove hosts that can manage this host.') + + member_attributes = ['managedby'] + has_output_params = LDAPRemoveMember.has_output_params + host_output_params + + def post_callback(self, ldap, completed, failed, dn, entry_attrs, *keys, **options): + assert isinstance(dn, DN) + self.obj.suppress_netgroup_memberof(ldap, entry_attrs) + return (completed, dn) + + +@register() +class host_allow_retrieve_keytab(LDAPAddMember): + __doc__ = _('Allow users, groups, hosts or host groups to retrieve a keytab' + ' of this host.') + member_attributes = ['ipaallowedtoperform_read_keys'] + has_output_params = LDAPAddMember.has_output_params + host_output_params + + def pre_callback(self, ldap, dn, found, not_found, *keys, **options): + rename_ipaallowedtoperform_to_ldap(found) + rename_ipaallowedtoperform_to_ldap(not_found) + add_missing_object_class(ldap, u'ipaallowedoperations', dn) + return dn + + def post_callback(self, ldap, completed, failed, dn, entry_attrs, *keys, **options): + rename_ipaallowedtoperform_from_ldap(entry_attrs, options) + rename_ipaallowedtoperform_from_ldap(failed, options) + return (completed, dn) + + +@register() +class host_disallow_retrieve_keytab(LDAPRemoveMember): + __doc__ = _('Disallow users, groups, hosts or host groups to retrieve a ' + 'keytab of this host.') + member_attributes = ['ipaallowedtoperform_read_keys'] + has_output_params = LDAPRemoveMember.has_output_params + host_output_params + + def pre_callback(self, ldap, dn, found, not_found, *keys, **options): + rename_ipaallowedtoperform_to_ldap(found) + rename_ipaallowedtoperform_to_ldap(not_found) + return dn + + def post_callback(self, ldap, completed, failed, dn, entry_attrs, *keys, **options): + rename_ipaallowedtoperform_from_ldap(entry_attrs, options) + rename_ipaallowedtoperform_from_ldap(failed, options) + return (completed, dn) + + +@register() +class host_allow_create_keytab(LDAPAddMember): + __doc__ = _('Allow users, groups, hosts or host groups to create a keytab ' + 'of this host.') + member_attributes = ['ipaallowedtoperform_write_keys'] + has_output_params = LDAPAddMember.has_output_params + host_output_params + + def pre_callback(self, ldap, dn, found, not_found, *keys, **options): + rename_ipaallowedtoperform_to_ldap(found) + rename_ipaallowedtoperform_to_ldap(not_found) + add_missing_object_class(ldap, u'ipaallowedoperations', dn) + return dn + + def post_callback(self, ldap, completed, failed, dn, entry_attrs, *keys, **options): + rename_ipaallowedtoperform_from_ldap(entry_attrs, options) + rename_ipaallowedtoperform_from_ldap(failed, options) + return (completed, dn) + + +@register() +class host_disallow_create_keytab(LDAPRemoveMember): + __doc__ = _('Disallow users, groups, hosts or host groups to create a ' + 'keytab of this host.') + member_attributes = ['ipaallowedtoperform_write_keys'] + has_output_params = LDAPRemoveMember.has_output_params + host_output_params + + def pre_callback(self, ldap, dn, found, not_found, *keys, **options): + rename_ipaallowedtoperform_to_ldap(found) + rename_ipaallowedtoperform_to_ldap(not_found) + return dn + + def post_callback(self, ldap, completed, failed, dn, entry_attrs, *keys, **options): + rename_ipaallowedtoperform_from_ldap(entry_attrs, options) + rename_ipaallowedtoperform_from_ldap(failed, options) + return (completed, dn) + + +@register() +class host_add_cert(LDAPAddAttribute): + __doc__ = _('Add certificates to host entry') + msg_summary = _('Added certificates to host "%(value)s"') + attribute = 'usercertificate' + + +@register() +class host_remove_cert(LDAPRemoveAttribute): + __doc__ = _('Remove certificates from host entry') + msg_summary = _('Removed certificates from host "%(value)s"') + attribute = 'usercertificate' + + def post_callback(self, ldap, dn, entry_attrs, *keys, **options): + assert isinstance(dn, DN) + + if 'usercertificate' in options: + revoke_certs(options['usercertificate'], self.log) + + return dn diff --git a/ipaserver/plugins/hostgroup.py b/ipaserver/plugins/hostgroup.py new file mode 100644 index 000000000..dab354d9c --- /dev/null +++ b/ipaserver/plugins/hostgroup.py @@ -0,0 +1,316 @@ +# Authors: +# Rob Crittenden <rcritten@redhat.com> +# Pavel Zuna <pzuna@redhat.com> +# +# Copyright (C) 2009 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +import six + +from ipalib.plugable import Registry +from .baseldap import (LDAPObject, LDAPCreate, LDAPRetrieve, + LDAPDelete, LDAPUpdate, LDAPSearch, + LDAPAddMember, LDAPRemoveMember, + entry_from_entry, wait_for_value) +from ipalib import Str, api, _, ngettext, errors +from .netgroup import NETGROUP_PATTERN, NETGROUP_PATTERN_ERRMSG +from ipapython.dn import DN + +if six.PY3: + unicode = str + +__doc__ = _(""" +Groups of hosts. + +Manage groups of hosts. This is useful for applying access control to a +number of hosts by using Host-based Access Control. + +EXAMPLES: + + Add a new host group: + ipa hostgroup-add --desc="Baltimore hosts" baltimore + + Add another new host group: + ipa hostgroup-add --desc="Maryland hosts" maryland + + Add members to the hostgroup (using Bash brace expansion): + ipa hostgroup-add-member --hosts={box1,box2,box3} baltimore + + Add a hostgroup as a member of another hostgroup: + ipa hostgroup-add-member --hostgroups=baltimore maryland + + Remove a host from the hostgroup: + ipa hostgroup-remove-member --hosts=box2 baltimore + + Display a host group: + ipa hostgroup-show baltimore + + Delete a hostgroup: + ipa hostgroup-del baltimore +""") + + +def get_complete_hostgroup_member_list(hostgroup): + result = api.Command['hostgroup_show'](hostgroup)['result'] + direct = list(result.get('member_host', [])) + indirect = list(result.get('memberindirect_host', [])) + return direct + indirect + + +register = Registry() + +PROTECTED_HOSTGROUPS = (u'ipaservers',) + + +@register() +class hostgroup(LDAPObject): + """ + Hostgroup object. + """ + container_dn = api.env.container_hostgroup + object_name = _('host group') + object_name_plural = _('host groups') + object_class = ['ipaobject', 'ipahostgroup'] + permission_filter_objectclasses = ['ipahostgroup'] + search_attributes = ['cn', 'description', 'member', 'memberof'] + default_attributes = ['cn', 'description', 'member', 'memberof', + 'memberindirect', 'memberofindirect', + ] + uuid_attribute = 'ipauniqueid' + attribute_members = { + 'member': ['host', 'hostgroup'], + 'memberof': ['hostgroup', 'netgroup', 'hbacrule', 'sudorule'], + 'memberindirect': ['host', 'hostgroup'], + 'memberofindirect': ['hostgroup', 'hbacrule', 'sudorule'], + } + managed_permissions = { + 'System: Read Hostgroups': { + 'replaces_global_anonymous_aci': True, + 'ipapermbindruletype': 'all', + 'ipapermright': {'read', 'search', 'compare'}, + 'ipapermdefaultattr': { + 'businesscategory', 'cn', 'description', 'ipauniqueid', 'o', + 'objectclass', 'ou', 'owner', 'seealso', + }, + }, + 'System: Read Hostgroup Membership': { + 'replaces_global_anonymous_aci': True, + 'ipapermbindruletype': 'all', + 'ipapermright': {'read', 'search', 'compare'}, + 'ipapermdefaultattr': { + 'member', 'memberof', 'memberuser', 'memberhost', + }, + }, + 'System: Add Hostgroups': { + 'ipapermright': {'add'}, + 'replaces': [ + '(target = "ldap:///cn=*,cn=hostgroups,cn=accounts,$SUFFIX")(version 3.0;acl "permission:Add Hostgroups";allow (add) groupdn = "ldap:///cn=Add Hostgroups,cn=permissions,cn=pbac,$SUFFIX";)', + ], + 'default_privileges': {'Host Group Administrators'}, + }, + 'System: Modify Hostgroup Membership': { + 'ipapermright': {'write'}, + 'ipapermtargetfilter': [ + '(objectclass=ipahostgroup)', + '(!(cn=ipaservers))', + ], + 'ipapermdefaultattr': {'member'}, + 'replaces': [ + '(targetattr = "member")(target = "ldap:///cn=*,cn=hostgroups,cn=accounts,$SUFFIX")(version 3.0;acl "permission:Modify Hostgroup membership";allow (write) groupdn = "ldap:///cn=Modify Hostgroup membership,cn=permissions,cn=pbac,$SUFFIX";)', + ], + 'default_privileges': {'Host Group Administrators'}, + }, + 'System: Modify Hostgroups': { + 'ipapermright': {'write'}, + 'ipapermdefaultattr': {'cn', 'description'}, + 'replaces': [ + '(targetattr = "cn || description")(target = "ldap:///cn=*,cn=hostgroups,cn=accounts,$SUFFIX")(version 3.0; acl "permission:Modify Hostgroups";allow (write) groupdn = "ldap:///cn=Modify Hostgroups,cn=permissions,cn=pbac,$SUFFIX";)', + ], + 'default_privileges': {'Host Group Administrators'}, + }, + 'System: Remove Hostgroups': { + 'ipapermright': {'delete'}, + 'replaces': [ + '(target = "ldap:///cn=*,cn=hostgroups,cn=accounts,$SUFFIX")(version 3.0;acl "permission:Remove Hostgroups";allow (delete) groupdn = "ldap:///cn=Remove Hostgroups,cn=permissions,cn=pbac,$SUFFIX";)', + ], + 'default_privileges': {'Host Group Administrators'}, + }, + } + + label = _('Host Groups') + label_singular = _('Host Group') + + takes_params = ( + Str('cn', + pattern=NETGROUP_PATTERN, + pattern_errmsg=NETGROUP_PATTERN_ERRMSG, + cli_name='hostgroup_name', + label=_('Host-group'), + doc=_('Name of host-group'), + primary_key=True, + normalizer=lambda value: value.lower(), + ), + Str('description?', + cli_name='desc', + label=_('Description'), + doc=_('A description of this host-group'), + ), + ) + + def suppress_netgroup_memberof(self, ldap, dn, entry_attrs): + """ + We don't want to show managed netgroups so remove them from the + memberOf list. + """ + hgdn = DN(dn) + for member in list(entry_attrs.get('memberof', [])): + ngdn = DN(member) + if ngdn['cn'] != hgdn['cn']: + continue + + filter = ldap.make_filter({'objectclass': 'mepmanagedentry'}) + try: + ldap.get_entries(ngdn, ldap.SCOPE_BASE, filter, ['']) + except errors.NotFound: + pass + else: + entry_attrs['memberof'].remove(member) + + +@register() +class hostgroup_add(LDAPCreate): + __doc__ = _('Add a new hostgroup.') + + msg_summary = _('Added hostgroup "%(value)s"') + + def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options): + assert isinstance(dn, DN) + try: + # check duplicity with hostgroups first to provide proper error + api.Object['hostgroup'].get_dn_if_exists(keys[-1]) + self.obj.handle_duplicate_entry(*keys) + except errors.NotFound: + pass + + try: + # when enabled, a managed netgroup is created for every hostgroup + # make sure that the netgroup can be created + api.Object['netgroup'].get_dn_if_exists(keys[-1]) + raise errors.DuplicateEntry(message=unicode(_( + u'netgroup with name "%s" already exists. ' + u'Hostgroups and netgroups share a common namespace' + ) % keys[-1])) + except errors.NotFound: + pass + + return dn + + def post_callback(self, ldap, dn, entry_attrs, *keys, **options): + assert isinstance(dn, DN) + # Always wait for the associated netgroup to be created so we can + # be sure to ignore it in memberOf + newentry = wait_for_value(ldap, dn, 'objectclass', 'mepOriginEntry') + entry_from_entry(entry_attrs, newentry) + self.obj.suppress_netgroup_memberof(ldap, dn, entry_attrs) + + return dn + + +@register() +class hostgroup_del(LDAPDelete): + __doc__ = _('Delete a hostgroup.') + + msg_summary = _('Deleted hostgroup "%(value)s"') + + def pre_callback(self, ldap, dn, *keys, **options): + if keys[0] in PROTECTED_HOSTGROUPS: + raise errors.ProtectedEntryError(label=_(u'hostgroup'), + key=keys[0], + reason=_(u'privileged hostgroup')) + + return dn + + +@register() +class hostgroup_mod(LDAPUpdate): + __doc__ = _('Modify a hostgroup.') + + msg_summary = _('Modified hostgroup "%(value)s"') + + def post_callback(self, ldap, dn, entry_attrs, *keys, **options): + assert isinstance(dn, DN) + self.obj.suppress_netgroup_memberof(ldap, dn, entry_attrs) + return dn + + +@register() +class hostgroup_find(LDAPSearch): + __doc__ = _('Search for hostgroups.') + + member_attributes = ['member', 'memberof'] + msg_summary = ngettext( + '%(count)d hostgroup matched', '%(count)d hostgroups matched', 0 + ) + + def post_callback(self, ldap, entries, truncated, *args, **options): + if options.get('pkey_only', False): + return truncated + for entry in entries: + self.obj.suppress_netgroup_memberof(ldap, entry.dn, entry) + return truncated + + +@register() +class hostgroup_show(LDAPRetrieve): + __doc__ = _('Display information about a hostgroup.') + + def post_callback(self, ldap, dn, entry_attrs, *keys, **options): + assert isinstance(dn, DN) + self.obj.suppress_netgroup_memberof(ldap, dn, entry_attrs) + return dn + + +@register() +class hostgroup_add_member(LDAPAddMember): + __doc__ = _('Add members to a hostgroup.') + + def post_callback(self, ldap, completed, failed, dn, entry_attrs, *keys, **options): + assert isinstance(dn, DN) + self.obj.suppress_netgroup_memberof(ldap, dn, entry_attrs) + return (completed, dn) + + +@register() +class hostgroup_remove_member(LDAPRemoveMember): + __doc__ = _('Remove members from a hostgroup.') + + def pre_callback(self, ldap, dn, found, not_found, *keys, **options): + if keys[0] in PROTECTED_HOSTGROUPS and 'host' in options: + result = api.Command.hostgroup_show(keys[0]) + hosts_left = set(result['result'].get('member_host', [])) + hosts_deleted = set(options['host']) + if hosts_left.issubset(hosts_deleted): + raise errors.LastMemberError(key=sorted(hosts_deleted)[0], + label=_(u'hostgroup'), + container=keys[0]) + + return dn + + def post_callback(self, ldap, completed, failed, dn, entry_attrs, *keys, **options): + assert isinstance(dn, DN) + self.obj.suppress_netgroup_memberof(ldap, dn, entry_attrs) + return (completed, dn) + diff --git a/ipaserver/plugins/idrange.py b/ipaserver/plugins/idrange.py new file mode 100644 index 000000000..ccd67995e --- /dev/null +++ b/ipaserver/plugins/idrange.py @@ -0,0 +1,769 @@ +# Authors: +# Sumit Bose <sbose@redhat.com> +# +# Copyright (C) 2012 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +import six + +from ipalib.plugable import Registry +from .baseldap import (LDAPObject, LDAPCreate, LDAPDelete, + LDAPRetrieve, LDAPSearch, LDAPUpdate) +from ipalib import api, Int, Str, StrEnum, _, ngettext +from ipalib import errors +from ipapython.dn import DN + +if six.PY3: + unicode = str + +if api.env.in_server and api.env.context in ['lite', 'server']: + try: + import ipaserver.dcerpc + _dcerpc_bindings_installed = True + except ImportError: + _dcerpc_bindings_installed = False + +ID_RANGE_VS_DNA_WARNING = """======= +WARNING: + +DNA plugin in 389-ds will allocate IDs based on the ranges configured for the +local domain. Currently the DNA plugin *cannot* be reconfigured itself based +on the local ranges set via this family of commands. + +Manual configuration change has to be done in the DNA plugin configuration for +the new local range. Specifically, The dnaNextRange attribute of 'cn=Posix +IDs,cn=Distributed Numeric Assignment Plugin,cn=plugins,cn=config' has to be +modified to match the new range. +======= +""" + +__doc__ = _(""" +ID ranges + +Manage ID ranges used to map Posix IDs to SIDs and back. + +There are two type of ID ranges which are both handled by this utility: + + - the ID ranges of the local domain + - the ID ranges of trusted remote domains + +Both types have the following attributes in common: + + - base-id: the first ID of the Posix ID range + - range-size: the size of the range + +With those two attributes a range object can reserve the Posix IDs starting +with base-id up to but not including base-id+range-size exclusively. + +Additionally an ID range of the local domain may set + - rid-base: the first RID(*) of the corresponding RID range + - secondary-rid-base: first RID of the secondary RID range + +and an ID range of a trusted domain must set + - rid-base: the first RID of the corresponding RID range + - sid: domain SID of the trusted domain + + + +EXAMPLE: Add a new ID range for a trusted domain + +Since there might be more than one trusted domain the domain SID must be given +while creating the ID range. + + ipa idrange-add --base-id=1200000 --range-size=200000 --rid-base=0 \\ + --dom-sid=S-1-5-21-123-456-789 trusted_dom_range + +This ID range is then used by the IPA server and the SSSD IPA provider to +assign Posix UIDs to users from the trusted domain. + +If e.g a range for a trusted domain is configured with the following values: + base-id = 1200000 + range-size = 200000 + rid-base = 0 +the RIDs 0 to 199999 are mapped to the Posix ID from 1200000 to 13999999. So +RID 1000 <-> Posix ID 1201000 + + + +EXAMPLE: Add a new ID range for the local domain + +To create an ID range for the local domain it is not necessary to specify a +domain SID. But since it is possible that a user and a group can have the same +value as Posix ID a second RID interval is needed to handle conflicts. + + ipa idrange-add --base-id=1200000 --range-size=200000 --rid-base=1000 \\ + --secondary-rid-base=1000000 local_range + +The data from the ID ranges of the local domain are used by the IPA server +internally to assign SIDs to IPA users and groups. The SID will then be stored +in the user or group objects. + +If e.g. the ID range for the local domain is configured with the values from +the example above then a new user with the UID 1200007 will get the RID 1007. +If this RID is already used by a group the RID will be 1000007. This can only +happen if a user or a group object was created with a fixed ID because the +automatic assignment will not assign the same ID twice. Since there are only +users and groups sharing the same ID namespace it is sufficient to have only +one fallback range to handle conflicts. + +To find the Posix ID for a given RID from the local domain it has to be +checked first if the RID falls in the primary or secondary RID range and +the rid-base or the secondary-rid-base has to be subtracted, respectively, +and the base-id has to be added to get the Posix ID. + +Typically the creation of ID ranges happens behind the scenes and this CLI +must not be used at all. The ID range for the local domain will be created +during installation or upgrade from an older version. The ID range for a +trusted domain will be created together with the trust by 'ipa trust-add ...'. + +USE CASES: + + Add an ID range from a transitively trusted domain + + If the trusted domain (A) trusts another domain (B) as well and this trust + is transitive 'ipa trust-add domain-A' will only create a range for + domain A. The ID range for domain B must be added manually. + + Add an additional ID range for the local domain + + If the ID range of the local domain is exhausted, i.e. no new IDs can be + assigned to Posix users or groups by the DNA plugin, a new range has to be + created to allow new users and groups to be added. (Currently there is no + connection between this range CLI and the DNA plugin, but a future version + might be able to modify the configuration of the DNS plugin as well) + +In general it is not necessary to modify or delete ID ranges. If there is no +other way to achieve a certain configuration than to modify or delete an ID +range it should be done with great care. Because UIDs are stored in the file +system and are used for access control it might be possible that users are +allowed to access files of other users if an ID range got deleted and reused +for a different domain. + +(*) The RID is typically the last integer of a user or group SID which follows +the domain SID. E.g. if the domain SID is S-1-5-21-123-456-789 and a user from +this domain has the SID S-1-5-21-123-456-789-1010 then 1010 id the RID of the +user. RIDs are unique in a domain, 32bit values and are used for users and +groups. + +{0} +""".format(ID_RANGE_VS_DNA_WARNING)) + +register = Registry() + +@register() +class idrange(LDAPObject): + """ + Range object. + """ + + range_type = ('domain', 'ad', 'ipa') + container_dn = api.env.container_ranges + object_name = ('range') + object_name_plural = ('ranges') + object_class = ['ipaIDrange'] + permission_filter_objectclasses = ['ipaidrange'] + possible_objectclasses = ['ipadomainidrange', 'ipatrustedaddomainrange'] + default_attributes = ['cn', 'ipabaseid', 'ipaidrangesize', 'ipabaserid', + 'ipasecondarybaserid', 'ipanttrusteddomainsid', + 'iparangetype'] + managed_permissions = { + 'System: Read ID Ranges': { + 'replaces_global_anonymous_aci': True, + 'ipapermbindruletype': 'all', + 'ipapermright': {'read', 'search', 'compare'}, + 'ipapermdefaultattr': { + 'cn', 'objectclass', + 'ipabaseid', 'ipaidrangesize', 'iparangetype', + 'ipabaserid', 'ipasecondarybaserid', 'ipanttrusteddomainsid', + }, + }, + } + + label = _('ID Ranges') + label_singular = _('ID Range') + + # The commented range types are planned but not yet supported + range_types = { + u'ipa-local': unicode(_('local domain range')), + # u'ipa-ad-winsync': unicode(_('Active Directory winsync range')), + u'ipa-ad-trust': unicode(_('Active Directory domain range')), + u'ipa-ad-trust-posix': unicode(_('Active Directory trust range with ' + 'POSIX attributes')), + # u'ipa-ipa-trust': unicode(_('IPA trust range')), + } + + takes_params = ( + Str('cn', + cli_name='name', + label=_('Range name'), + primary_key=True, + ), + Int('ipabaseid', + cli_name='base_id', + label=_("First Posix ID of the range"), + ), + Int('ipaidrangesize', + cli_name='range_size', + label=_("Number of IDs in the range"), + ), + Int('ipabaserid?', + cli_name='rid_base', + label=_('First RID of the corresponding RID range'), + ), + Int('ipasecondarybaserid?', + cli_name='secondary_rid_base', + label=_('First RID of the secondary RID range'), + ), + Str('ipanttrusteddomainsid?', + cli_name='dom_sid', + flags=('no_update',), + label=_('Domain SID of the trusted domain'), + ), + Str('ipanttrusteddomainname?', + cli_name='dom_name', + flags=('no_search', 'virtual_attribute', 'no_update'), + label=_('Name of the trusted domain'), + ), + StrEnum('iparangetype?', + label=_('Range type'), + cli_name='type', + doc=(_('ID range type, one of {vals}' + .format(vals=', '.join(range_types.keys())))), + values=tuple(range_types.keys()), + flags=['no_update'], + ) + ) + + def handle_iparangetype(self, entry_attrs, options, keep_objectclass=False): + if not any((options.get('pkey_only', False), + options.get('raw', False))): + range_type = entry_attrs['iparangetype'][0] + entry_attrs['iparangetyperaw'] = [range_type] + entry_attrs['iparangetype'] = [self.range_types.get(range_type, None)] + + # Remove the objectclass + if not keep_objectclass: + if not options.get('all', False) or options.get('pkey_only', False): + entry_attrs.pop('objectclass', None) + + def handle_ipabaserid(self, entry_attrs, options): + if any((options.get('pkey_only', False), options.get('raw', False))): + return + if entry_attrs['iparangetype'][0] == u'ipa-ad-trust-posix': + entry_attrs.pop('ipabaserid', None) + + def check_ids_in_modified_range(self, old_base, old_size, new_base, + new_size): + if new_base is None and new_size is None: + # nothing to check + return + if new_base is None: + new_base = old_base + if new_size is None: + new_size = old_size + old_interval = (old_base, old_base + old_size - 1) + new_interval = (new_base, new_base + new_size - 1) + checked_intervals = [] + low_diff = new_interval[0] - old_interval[0] + if low_diff > 0: + checked_intervals.append((old_interval[0], + min(old_interval[1], new_interval[0] - 1))) + high_diff = old_interval[1] - new_interval[1] + if high_diff > 0: + checked_intervals.append((max(old_interval[0], new_interval[1] + 1), + old_interval[1])) + + if not checked_intervals: + # range is equal or covers the entire old range, nothing to check + return + + ldap = self.backend + id_filter_base = ["(objectclass=posixAccount)", + "(objectclass=posixGroup)", + "(objectclass=ipaIDObject)"] + id_filter_ids = [] + + for id_low, id_high in checked_intervals: + id_filter_ids.append("(&(uidNumber>=%(low)d)(uidNumber<=%(high)d))" + % dict(low=id_low, high=id_high)) + id_filter_ids.append("(&(gidNumber>=%(low)d)(gidNumber<=%(high)d))" + % dict(low=id_low, high=id_high)) + id_filter = ldap.combine_filters( + [ldap.combine_filters(id_filter_base, "|"), + ldap.combine_filters(id_filter_ids, "|")], + "&") + + try: + (objects, truncated) = ldap.find_entries(filter=id_filter, + attrs_list=['uid', 'cn'], + base_dn=DN(api.env.container_accounts, api.env.basedn)) + except errors.NotFound: + # no objects in this range found, allow the command + pass + else: + raise errors.ValidationError(name="ipabaseid,ipaidrangesize", + error=_('range modification leaving objects with ID out ' + 'of the defined range is not allowed')) + + def get_domain_validator(self): + if not _dcerpc_bindings_installed: + raise errors.NotFound(reason=_('Cannot perform SID validation ' + 'without Samba 4 support installed. Make sure you have ' + 'installed server-trust-ad sub-package of IPA on the server')) + + domain_validator = ipaserver.dcerpc.DomainValidator(self.api) + + if not domain_validator.is_configured(): + raise errors.NotFound(reason=_('Cross-realm trusts are not ' + 'configured. Make sure you have run ipa-adtrust-install ' + 'on the IPA server first')) + + return domain_validator + + def validate_trusted_domain_sid(self, sid): + + domain_validator = self.get_domain_validator() + + if not domain_validator.is_trusted_domain_sid_valid(sid): + raise errors.ValidationError(name='domain SID', + error=_('SID is not recognized as a valid SID for a ' + 'trusted domain')) + + def get_trusted_domain_sid_from_name(self, name): + """ Returns unicode string representation for given trusted domain name + or None if SID forthe given trusted domain name could not be found.""" + + domain_validator = self.get_domain_validator() + + sid = domain_validator.get_sid_from_domain_name(name) + + if sid is not None: + sid = unicode(sid) + + return sid + + # checks that primary and secondary rid ranges do not overlap + def are_rid_ranges_overlapping(self, rid_base, secondary_rid_base, size): + + # if any of these is None, the check does not apply + if any(attr is None for attr in (rid_base, secondary_rid_base, size)): + return False + + # sort the bases + if rid_base > secondary_rid_base: + rid_base, secondary_rid_base = secondary_rid_base, rid_base + + # rid_base is now <= secondary_rid_base, + # so the following check is sufficient + if rid_base + size <= secondary_rid_base: + return False + else: + return True + + +@register() +class idrange_add(LDAPCreate): + __doc__ = _(""" + Add new ID range. + + To add a new ID range you always have to specify + + --base-id + --range-size + + Additionally + + --rid-base + --secondary-rid-base + + may be given for a new ID range for the local domain while + + --rid-base + --dom-sid + + must be given to add a new range for a trusted AD domain. + +{0} +""".format(ID_RANGE_VS_DNA_WARNING)) + + msg_summary = _('Added ID range "%(value)s"') + + def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options): + assert isinstance(dn, DN) + + is_set = lambda x: (x in entry_attrs) and (entry_attrs[x] is not None) + + # This needs to stay in options since there is no + # ipanttrusteddomainname attribute in LDAP + if 'ipanttrusteddomainname' in options: + if is_set('ipanttrusteddomainsid'): + raise errors.ValidationError(name='ID Range setup', + error=_('Options dom-sid and dom-name ' + 'cannot be used together')) + + sid = self.obj.get_trusted_domain_sid_from_name( + options['ipanttrusteddomainname']) + + if sid is not None: + entry_attrs['ipanttrusteddomainsid'] = sid + else: + raise errors.ValidationError(name='ID Range setup', + error=_('SID for the specified trusted domain name could ' + 'not be found. Please specify the SID directly ' + 'using dom-sid option.')) + + # ipaNTTrustedDomainSID attribute set, this is AD Trusted domain range + if is_set('ipanttrusteddomainsid'): + entry_attrs['objectclass'].append('ipatrustedaddomainrange') + + # Default to ipa-ad-trust if no type set + if not is_set('iparangetype'): + entry_attrs['iparangetype'] = u'ipa-ad-trust' + + if entry_attrs['iparangetype'] == u'ipa-ad-trust': + if not is_set('ipabaserid'): + raise errors.ValidationError( + name='ID Range setup', + error=_('Options dom-sid/dom-name and rid-base must ' + 'be used together') + ) + elif entry_attrs['iparangetype'] == u'ipa-ad-trust-posix': + if is_set('ipabaserid') and entry_attrs['ipabaserid'] != 0: + raise errors.ValidationError( + name='ID Range setup', + error=_('Option rid-base must not be used when IPA ' + 'range type is ipa-ad-trust-posix') + ) + else: + entry_attrs['ipabaserid'] = 0 + else: + raise errors.ValidationError(name='ID Range setup', + error=_('IPA Range type must be one of ipa-ad-trust ' + 'or ipa-ad-trust-posix when SID of the trusted ' + 'domain is specified')) + + if is_set('ipasecondarybaserid'): + raise errors.ValidationError(name='ID Range setup', + error=_('Options dom-sid/dom-name and secondary-rid-base ' + 'cannot be used together')) + + # Validate SID as the one of trusted domains + self.obj.validate_trusted_domain_sid( + entry_attrs['ipanttrusteddomainsid']) + + # ipaNTTrustedDomainSID attribute not set, this is local domain range + else: + entry_attrs['objectclass'].append('ipadomainidrange') + + # Default to ipa-local if no type set + if 'iparangetype' not in entry_attrs: + entry_attrs['iparangetype'] = 'ipa-local' + + # TODO: can also be ipa-ad-winsync here? + if entry_attrs['iparangetype'] in (u'ipa-ad-trust', + u'ipa-ad-trust-posix'): + raise errors.ValidationError(name='ID Range setup', + error=_('IPA Range type must not be one of ipa-ad-trust ' + 'or ipa-ad-trust-posix when SID of the trusted ' + 'domain is not specified.')) + + # secondary base rid must be set if and only if base rid is set + if is_set('ipasecondarybaserid') != is_set('ipabaserid'): + raise errors.ValidationError(name='ID Range setup', + error=_('Options secondary-rid-base and rid-base must ' + 'be used together')) + + # and they must not overlap + if is_set('ipabaserid') and is_set('ipasecondarybaserid'): + if self.obj.are_rid_ranges_overlapping( + entry_attrs['ipabaserid'], + entry_attrs['ipasecondarybaserid'], + entry_attrs['ipaidrangesize']): + raise errors.ValidationError(name='ID Range setup', + error=_("Primary RID range and secondary RID range" + " cannot overlap")) + + # rid-base and secondary-rid-base must be set if + # ipa-adtrust-install has been run on the system + adtrust_is_enabled = api.Command['adtrust_is_enabled']()['result'] + + if adtrust_is_enabled and not ( + is_set('ipabaserid') and is_set('ipasecondarybaserid')): + raise errors.ValidationError( + name='ID Range setup', + error=_( + 'You must specify both rid-base and ' + 'secondary-rid-base options, because ' + 'ipa-adtrust-install has already been run.' + ) + ) + return dn + + def post_callback(self, ldap, dn, entry_attrs, *keys, **options): + assert isinstance(dn, DN) + self.obj.handle_ipabaserid(entry_attrs, options) + self.obj.handle_iparangetype(entry_attrs, options, + keep_objectclass=True) + return dn + + +@register() +class idrange_del(LDAPDelete): + __doc__ = _('Delete an ID range.') + + msg_summary = _('Deleted ID range "%(value)s"') + + def pre_callback(self, ldap, dn, *keys, **options): + try: + old_attrs = ldap.get_entry(dn, ['ipabaseid', + 'ipaidrangesize', + 'ipanttrusteddomainsid']) + except errors.NotFound: + self.obj.handle_not_found(*keys) + + # Check whether we leave any object with id in deleted range + old_base_id = int(old_attrs.get('ipabaseid', [0])[0]) + old_range_size = int(old_attrs.get('ipaidrangesize', [0])[0]) + self.obj.check_ids_in_modified_range( + old_base_id, old_range_size, 0, 0) + + # Check whether the range does not belong to the active trust + range_sid = old_attrs.get('ipanttrusteddomainsid') + + if range_sid is not None: + # Search for trusted domain with SID specified in the ID range entry + range_sid = range_sid[0] + domain_filter=('(&(objectclass=ipaNTTrustedDomain)' + '(ipanttrusteddomainsid=%s))' % range_sid) + + try: + (trust_domains, truncated) = ldap.find_entries( + base_dn=DN(api.env.container_trusts, api.env.basedn), + filter=domain_filter) + except errors.NotFound: + pass + else: + # If there's an entry, it means that there's active domain + # of a trust that this range belongs to, so raise a + # DependentEntry error + raise errors.DependentEntry( + label='Active Trust domain', + key=keys[0], + dependent=trust_domains[0].dn[0].value) + + + return dn + + +@register() +class idrange_find(LDAPSearch): + __doc__ = _('Search for ranges.') + + msg_summary = ngettext( + '%(count)d range matched', '%(count)d ranges matched', 0 + ) + + # Since all range types are stored within separate containers under + # 'cn=ranges,cn=etc' search can be done on a one-level scope + def pre_callback(self, ldap, filters, attrs_list, base_dn, scope, *args, + **options): + assert isinstance(base_dn, DN) + attrs_list.append('objectclass') + return (filters, base_dn, ldap.SCOPE_ONELEVEL) + + def post_callback(self, ldap, entries, truncated, *args, **options): + for entry in entries: + self.obj.handle_ipabaserid(entry, options) + self.obj.handle_iparangetype(entry, options) + return truncated + + +@register() +class idrange_show(LDAPRetrieve): + __doc__ = _('Display information about a range.') + + def pre_callback(self, ldap, dn, attrs_list, *keys, **options): + assert isinstance(dn, DN) + attrs_list.append('objectclass') + return dn + + def post_callback(self, ldap, dn, entry_attrs, *keys, **options): + assert isinstance(dn, DN) + self.obj.handle_ipabaserid(entry_attrs, options) + self.obj.handle_iparangetype(entry_attrs, options) + return dn + + +@register() +class idrange_mod(LDAPUpdate): + __doc__ = _("""Modify ID range. + +{0} +""".format(ID_RANGE_VS_DNA_WARNING)) + + msg_summary = _('Modified ID range "%(value)s"') + + takes_options = LDAPUpdate.takes_options + ( + Str( + 'ipanttrusteddomainsid?', + deprecated=True, + cli_name='dom_sid', + flags=('no_update', 'no_option'), + label=_('Domain SID of the trusted domain'), + autofill=False, + ), + Str( + 'ipanttrusteddomainname?', + deprecated=True, + cli_name='dom_name', + flags=('no_search', 'virtual_attribute', 'no_update', 'no_option'), + label=_('Name of the trusted domain'), + autofill=False, + ), + ) + + def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options): + assert isinstance(dn, DN) + attrs_list.append('objectclass') + + try: + old_attrs = ldap.get_entry(dn, ['*']) + except errors.NotFound: + self.obj.handle_not_found(*keys) + + if old_attrs['iparangetype'][0] == 'ipa-local': + raise errors.ExecutionError( + message=_('This command can not be used to change ID ' + 'allocation for local IPA domain. Run ' + '`ipa help idrange` for more information') + ) + + is_set = lambda x: (x in entry_attrs) and (entry_attrs[x] is not None) + in_updated_attrs = lambda x:\ + (x in entry_attrs and entry_attrs[x] is not None) or\ + (x not in entry_attrs and x in old_attrs + and old_attrs[x] is not None) + + # This needs to stay in options since there is no + # ipanttrusteddomainname attribute in LDAP + if 'ipanttrusteddomainname' in options: + if is_set('ipanttrusteddomainsid'): + raise errors.ValidationError(name='ID Range setup', + error=_('Options dom-sid and dom-name ' + 'cannot be used together')) + + sid = self.obj.get_trusted_domain_sid_from_name( + options['ipanttrusteddomainname']) + + # we translate the name into sid so further validation can rely + # on ipanttrusteddomainsid attribute only + if sid is not None: + entry_attrs['ipanttrusteddomainsid'] = sid + else: + raise errors.ValidationError(name='ID Range setup', + error=_('SID for the specified trusted domain name could ' + 'not be found. Please specify the SID directly ' + 'using dom-sid option.')) + + if in_updated_attrs('ipanttrusteddomainsid'): + if in_updated_attrs('ipasecondarybaserid'): + raise errors.ValidationError(name='ID Range setup', + error=_('Options dom-sid and secondary-rid-base cannot ' + 'be used together')) + range_type = old_attrs['iparangetype'][0] + if range_type == u'ipa-ad-trust': + if not in_updated_attrs('ipabaserid'): + raise errors.ValidationError( + name='ID Range setup', + error=_('Options dom-sid and rid-base must ' + 'be used together')) + elif (range_type == u'ipa-ad-trust-posix' and + 'ipabaserid' in entry_attrs): + if entry_attrs['ipabaserid'] is None: + entry_attrs['ipabaserid'] = 0 + elif entry_attrs['ipabaserid'] != 0: + raise errors.ValidationError( + name='ID Range setup', + error=_('Option rid-base must not be used when IPA ' + 'range type is ipa-ad-trust-posix') + ) + + if is_set('ipanttrusteddomainsid'): + # Validate SID as the one of trusted domains + # perform this check only if the attribute was changed + self.obj.validate_trusted_domain_sid( + entry_attrs['ipanttrusteddomainsid']) + + # Add trusted AD domain range object class, if it wasn't there + if not 'ipatrustedaddomainrange' in old_attrs['objectclass']: + entry_attrs['objectclass'].append('ipatrustedaddomainrange') + + else: + # secondary base rid must be set if and only if base rid is set + if in_updated_attrs('ipasecondarybaserid') !=\ + in_updated_attrs('ipabaserid'): + raise errors.ValidationError(name='ID Range setup', + error=_('Options secondary-rid-base and rid-base must ' + 'be used together')) + + # ensure that primary and secondary rid ranges do not overlap + if all(in_updated_attrs(base) + for base in ('ipabaserid', 'ipasecondarybaserid')): + + # make sure we are working with updated attributes + rid_range_attributes = ('ipabaserid', 'ipasecondarybaserid', + 'ipaidrangesize') + updated_values = dict() + + for attr in rid_range_attributes: + if is_set(attr): + updated_values[attr] = entry_attrs[attr] + else: + updated_values[attr] = int(old_attrs[attr][0]) + + if self.obj.are_rid_ranges_overlapping( + updated_values['ipabaserid'], + updated_values['ipasecondarybaserid'], + updated_values['ipaidrangesize']): + raise errors.ValidationError(name='ID Range setup', + error=_("Primary RID range and secondary RID range" + " cannot overlap")) + + # check whether ids are in modified range + old_base_id = int(old_attrs.get('ipabaseid', [0])[0]) + old_range_size = int(old_attrs.get('ipaidrangesize', [0])[0]) + new_base_id = entry_attrs.get('ipabaseid') + + if new_base_id is not None: + new_base_id = int(new_base_id) + + new_range_size = entry_attrs.get('ipaidrangesize') + + if new_range_size is not None: + new_range_size = int(new_range_size) + + self.obj.check_ids_in_modified_range(old_base_id, old_range_size, + new_base_id, new_range_size) + + return dn + + def post_callback(self, ldap, dn, entry_attrs, *keys, **options): + assert isinstance(dn, DN) + self.obj.handle_ipabaserid(entry_attrs, options) + self.obj.handle_iparangetype(entry_attrs, options) + return dn + + diff --git a/ipaserver/plugins/idviews.py b/ipaserver/plugins/idviews.py new file mode 100644 index 000000000..537f924ce --- /dev/null +++ b/ipaserver/plugins/idviews.py @@ -0,0 +1,1123 @@ +# Authors: +# Alexander Bokovoy <abokovoy@redhat.com> +# Tomas Babej <tbabej@redhat.com> +# +# Copyright (C) 2014 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. +import re + +import six + +from .baseldap import (LDAPQuery, LDAPObject, LDAPCreate, + LDAPDelete, LDAPUpdate, LDAPSearch, + LDAPAddAttribute, LDAPRemoveAttribute, + LDAPRetrieve, global_output_params) +from .hostgroup import get_complete_hostgroup_member_list +from .service import validate_certificate +from ipalib import api, Str, Int, Bytes, Flag, _, ngettext, errors, output +from ipalib.constants import IPA_ANCHOR_PREFIX, SID_ANCHOR_PREFIX +from ipalib.plugable import Registry +from ipalib.util import (normalize_sshpubkey, validate_sshpubkey, + convert_sshpubkey_post) + +from ipapython.dn import DN + +if six.PY3: + unicode = str + +_dcerpc_bindings_installed = False + +if api.env.in_server and api.env.context in ['lite', 'server']: + try: + import ipaserver.dcerpc + _dcerpc_bindings_installed = True + except ImportError: + pass + +__doc__ = _(""" +ID Views +Manage ID Views +IPA allows to override certain properties of users and groups per each host. +This functionality is primarily used to allow migration from older systems or +other Identity Management solutions. +""") + +register = Registry() + +protected_default_trust_view_error = errors.ProtectedEntryError( + label=_('ID View'), + key=u"Default Trust View", + reason=_('system ID View') +) + +fallback_to_ldap_option = Flag( + 'fallback_to_ldap?', + default=False, + label=_('Fallback to AD DC LDAP'), + doc=_("Allow falling back to AD DC LDAP when resolving AD " + "trusted objects. For two-way trusts only."), +) + +DEFAULT_TRUST_VIEW_NAME = "default trust view" + +ANCHOR_REGEX = re.compile( + r':IPA:.*:[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}' + r'|' + r':SID:S-[0-9\-]+' +) + + +@register() +class idview(LDAPObject): + """ + ID View object. + """ + + container_dn = api.env.container_views + object_name = _('ID View') + object_name_plural = _('ID Views') + object_class = ['ipaIDView', 'top'] + default_attributes = ['cn', 'description'] + rdn_is_primary_key = True + + label = _('ID Views') + label_singular = _('ID View') + + takes_params = ( + Str('cn', + cli_name='name', + label=_('ID View Name'), + primary_key=True, + ), + Str('description?', + cli_name='desc', + label=_('Description'), + ), + ) + + permission_filter_objectclasses = ['nsContainer'] + managed_permissions = { + 'System: Read ID Views': { + 'ipapermbindruletype': 'all', + 'ipapermright': {'read', 'search', 'compare'}, + 'ipapermdefaultattr': { + 'cn', 'description', 'objectClass', + }, + }, + } + + +@register() +class idview_add(LDAPCreate): + __doc__ = _('Add a new ID View.') + msg_summary = _('Added ID View "%(value)s"') + + +@register() +class idview_del(LDAPDelete): + __doc__ = _('Delete an ID View.') + msg_summary = _('Deleted ID View "%(value)s"') + + def pre_callback(self, ldap, dn, *keys, **options): + for key in keys: + if key.lower() == DEFAULT_TRUST_VIEW_NAME: + raise protected_default_trust_view_error + + return dn + + +@register() +class idview_mod(LDAPUpdate): + __doc__ = _('Modify an ID View.') + msg_summary = _('Modified an ID View "%(value)s"') + + def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options): + for key in keys: + if key.lower() == DEFAULT_TRUST_VIEW_NAME: + raise protected_default_trust_view_error + + return dn + + +@register() +class idview_find(LDAPSearch): + __doc__ = _('Search for an ID View.') + msg_summary = ngettext('%(count)d ID View matched', + '%(count)d ID Views matched', 0) + + +@register() +class idview_show(LDAPRetrieve): + __doc__ = _('Display information about an ID View.') + + takes_options = LDAPRetrieve.takes_options + ( + Flag('show_hosts?', + cli_name='show_hosts', + doc=_('Enumerate all the hosts the view applies to.'), + ), + ) + + has_output_params = global_output_params + ( + Str('useroverrides', + label=_('User object overrides'), + ), + Str('groupoverrides', + label=_('Group object overrides'), + ), + Str('appliedtohosts', + label=_('Hosts the view applies to') + ), + ) + + def show_id_overrides(self, dn, entry_attrs): + ldap = self.obj.backend + + for objectclass, obj_type in [('ipaUserOverride', 'user'), + ('ipaGroupOverride', 'group')]: + + # Attribute to store results is called (user|group)overrides + attr_name = obj_type + 'overrides' + + try: + (overrides, truncated) = ldap.find_entries( + filter="objectclass=%s" % objectclass, + attrs_list=['ipaanchoruuid'], + base_dn=dn, + scope=ldap.SCOPE_ONELEVEL, + paged_search=True) + + resolved_overrides = [] + for override in overrides: + anchor = override.single_value['ipaanchoruuid'] + + try: + name = resolve_anchor_to_object_name(ldap, obj_type, + anchor) + resolved_overrides.append(name) + + except (errors.NotFound, errors.ValidationError): + # Anchor could not be resolved, use raw + resolved_overrides.append(anchor) + + entry_attrs[attr_name] = resolved_overrides + + except errors.NotFound: + # No overrides found, nothing to do + pass + + def enumerate_hosts(self, dn, entry_attrs): + ldap = self.obj.backend + + filter_params = { + 'ipaAssignedIDView': dn, + 'objectClass': 'ipaHost', + } + + try: + (hosts, truncated) = ldap.find_entries( + filter=ldap.make_filter(filter_params, rules=ldap.MATCH_ALL), + attrs_list=['cn'], + base_dn=api.env.container_host + api.env.basedn, + scope=ldap.SCOPE_ONELEVEL, + paged_search=True) + + entry_attrs['appliedtohosts'] = [host.single_value['cn'] + for host in hosts] + except errors.NotFound: + pass + + def post_callback(self, ldap, dn, entry_attrs, *keys, **options): + self.show_id_overrides(dn, entry_attrs) + + # Enumerating hosts is a potentially expensive operation (uses paged + # search to list all the hosts the ID view applies to). Show the list + # of the hosts only if explicitly asked for (or asked for --all). + # Do not display with --raw, since this attribute does not exist in + # LDAP. + + if ((options.get('show_hosts') or options.get('all')) + and not options.get('raw')): + self.enumerate_hosts(dn, entry_attrs) + + return dn + + +class baseidview_apply(LDAPQuery): + """ + Base class for idview_apply and idview_unapply commands. + """ + + has_output_params = global_output_params + + def execute(self, *keys, **options): + view = keys[-1] if keys else None + ldap = self.obj.backend + + # Test if idview actually exists, if it does not, NotFound is raised + if not options.get('clear_view', False): + view_dn = self.api.Object['idview'].get_dn_if_exists(view) + assert isinstance(view_dn, DN) + + # Check that we're not applying the Default Trust View + if view.lower() == DEFAULT_TRUST_VIEW_NAME: + raise errors.ValidationError( + name=_('ID View'), + error=_('Default Trust View cannot be applied on hosts') + ) + + else: + # In case we are removing assigned view, we modify the host setting + # the ipaAssignedIDView to None + view_dn = None + + completed = 0 + succeeded = {'host': []} + failed = { + 'host': [], + 'hostgroup': [], + } + + # Make sure we ignore None passed via host or hostgroup, since it does + # not make sense + for key in ('host', 'hostgroup'): + if key in options and options[key] is None: + del options[key] + + # Generate a list of all hosts to apply the view to + hosts_to_apply = list(options.get('host', [])) + + for hostgroup in options.get('hostgroup', ()): + try: + hosts_to_apply += get_complete_hostgroup_member_list(hostgroup) + except errors.NotFound: + failed['hostgroup'].append((hostgroup, unicode(_("not found")))) + except errors.PublicError as e: + failed['hostgroup'].append((hostgroup, "%s : %s" % ( + e.__class__.__name__, str(e)))) + + for host in hosts_to_apply: + try: + host_dn = api.Object['host'].get_dn_if_exists(host) + + host_entry = ldap.get_entry(host_dn, + attrs_list=['ipaassignedidview']) + host_entry['ipaassignedidview'] = view_dn + + ldap.update_entry(host_entry) + + # If no exception was raised, view assigment went well + completed = completed + 1 + succeeded['host'].append(host) + except errors.EmptyModlist: + # If view was already applied, complain about it + failed['host'].append((host, + unicode(_("ID View already applied")))) + except errors.NotFound: + failed['host'].append((host, unicode(_("not found")))) + except errors.PublicError as e: + failed['host'].append((host, str(e))) + + # Wrap dictionary containing failures in another dictionary under key + # 'memberhost', since that is output parameter in global_output_params + # and thus we get nice output in the CLI + failed = {'memberhost': failed} + + # Sort the list of affected hosts + succeeded['host'].sort() + + # Note that we're returning the list of affected hosts even if they + # were passed via referencing a hostgroup. This is desired, since we + # want to stress the fact that view is applied on all the current + # member hosts of the hostgroup and not tied with the hostgroup itself. + + return dict( + summary=unicode(_(self.msg_summary % {'value': view})), + succeeded=succeeded, + completed=completed, + failed=failed, + ) + + +@register() +class idview_apply(baseidview_apply): + __doc__ = _('Applies ID View to specified hosts or current members of ' + 'specified hostgroups. If any other ID View is applied to ' + 'the host, it is overridden.') + + member_count_out = (_('ID View applied to %i host.'), + _('ID View applied to %i hosts.')) + + msg_summary = 'Applied ID View "%(value)s"' + + takes_options = ( + Str('host*', + cli_name='hosts', + doc=_('Hosts to apply the ID View to'), + label=_('hosts'), + ), + Str('hostgroup*', + cli_name='hostgroups', + doc=_('Hostgroups to whose hosts apply the ID View to. Please note ' + 'that view is not applied automatically to any hosts added ' + 'to the hostgroup after running the idview-apply command.'), + label=_('hostgroups'), + ), + ) + + has_output = ( + output.summary, + output.Output('succeeded', + type=dict, + doc=_('Hosts that this ID View was applied to.'), + ), + output.Output('failed', + type=dict, + doc=_('Hosts or hostgroups that this ID View could not be ' + 'applied to.'), + ), + output.Output('completed', + type=int, + doc=_('Number of hosts the ID View was applied to:'), + ), + ) + + +@register() +class idview_unapply(baseidview_apply): + __doc__ = _('Clears ID View from specified hosts or current members of ' + 'specified hostgroups.') + + member_count_out = (_('ID View cleared from %i host.'), + _('ID View cleared from %i hosts.')) + + msg_summary = 'Cleared ID Views' + + takes_options = ( + Str('host*', + cli_name='hosts', + doc=_('Hosts to clear (any) ID View from.'), + label=_('hosts'), + ), + Str('hostgroup*', + cli_name='hostgroups', + doc=_('Hostgroups whose hosts should have ID Views cleared. Note ' + 'that view is not cleared automatically from any host added ' + 'to the hostgroup after running idview-unapply command.'), + label=_('hostgroups'), + ), + ) + + has_output = ( + output.summary, + output.Output('succeeded', + type=dict, + doc=_('Hosts that ID View was cleared from.'), + ), + output.Output('failed', + type=dict, + doc=_('Hosts or hostgroups that ID View could not be cleared ' + 'from.'), + ), + output.Output('completed', + type=int, + doc=_('Number of hosts that had a ID View was unset:'), + ), + ) + + # Take no arguments, since ID View reference is not needed to clear + # the hosts + def get_args(self): + return () + + def execute(self, *keys, **options): + options['clear_view'] = True + return super(idview_unapply, self).execute(*keys, **options) + + +# ID overrides helper methods +def verify_trusted_domain_object_type(validator, desired_type, name_or_sid): + + object_type = validator.get_trusted_domain_object_type(name_or_sid) + + if object_type == desired_type: + # In case SSSD returns the same type as the type being + # searched, no problems here. + return True + + elif desired_type == 'user' and object_type == 'both': + # Type both denotes users with magic private groups. + # Overriding attributes for such users is OK. + return True + + elif desired_type == 'group' and object_type == 'both': + # However, overriding attributes for magic private groups + # does not make sense. One should override the GID of + # the user itself. + + raise errors.ConversionError( + name='identifier', + error=_('You are trying to reference a magic private group ' + 'which is not allowed to be overridden. ' + 'Try overriding the GID attribute of the ' + 'corresponding user instead.') + ) + + return False + + +def resolve_object_to_anchor(ldap, obj_type, obj, fallback_to_ldap): + """ + Resolves the user/group name to the anchor uuid: + - first it tries to find the object as user or group in IPA (depending + on the passed obj_type) + - if the IPA lookup failed, lookup object SID in the trusted domains + + Takes options: + ldap - the backend + obj_type - either 'user' or 'group' + obj - the name of the object, e.g 'admin' or 'testuser' + """ + + try: + entry = ldap.get_entry(api.Object[obj_type].get_dn(obj), + attrs_list=['ipaUniqueID', 'objectClass']) + + # First we check this is a valid object to override + # - for groups, it must have ipaUserGroup objectclass + # - for users, it must have posixAccount objectclass + + required_objectclass = { + 'user': 'posixaccount', + 'group': 'ipausergroup', + }[obj_type] + + if required_objectclass not in entry['objectclass']: + raise errors.ValidationError( + name=_('IPA object'), + error=_('system IPA objects (e.g system groups, user ' + 'private groups) cannot be overridden') + ) + + # The domain prefix, this will need to be reworked once we + # introduce IPA-IPA trusts + domain = api.env.domain + uuid = entry.single_value['ipaUniqueID'] + + return "%s%s:%s" % (IPA_ANCHOR_PREFIX, domain, uuid) + except errors.NotFound: + pass + + # If not successfull, try looking up the object in the trusted domain + try: + if _dcerpc_bindings_installed: + domain_validator = ipaserver.dcerpc.DomainValidator(api) + if domain_validator.is_configured(): + sid = domain_validator.get_trusted_domain_object_sid(obj, + fallback_to_ldap=fallback_to_ldap) + + # We need to verify that the object type is correct + type_correct = verify_trusted_domain_object_type( + domain_validator, obj_type, sid) + + if type_correct: + # There is no domain prefix since SID contains information + # about the domain + return SID_ANCHOR_PREFIX + sid + + except errors.ValidationError: + # Domain validator raises Validation Error if object name does not + # contain domain part (either NETBIOS\ prefix or @domain.name suffix) + pass + + # No acceptable object was found + api.Object[obj_type].handle_not_found(obj) + + +def resolve_anchor_to_object_name(ldap, obj_type, anchor): + """ + Resolves IPA Anchor UUID to the actual common object name (uid for users, + cn for groups). + + Takes options: + ldap - the backend + anchor - the anchor, e.g. + ':IPA:ipa.example.com:2cb604ea-39a5-11e4-a37e-001a4a22216f' + """ + + if anchor.startswith(IPA_ANCHOR_PREFIX): + + # Prepare search parameters + accounts_dn = DN(api.env.container_accounts, api.env.basedn) + + # Anchor of the form :IPA:<domain>:<uuid> + # Strip the IPA prefix and the domain prefix + uuid = anchor.rpartition(':')[-1].strip() + + # Set the object type-specific search attributes + objectclass, name_attr = { + 'user': ('posixaccount', 'uid'), + 'group': ('ipausergroup', 'cn'), + }[obj_type] + + entry = ldap.find_entry_by_attr(attr='ipaUniqueID', + value=uuid, + object_class=objectclass, + attrs_list=[name_attr], + base_dn=accounts_dn) + + # Return the name of the object, which is either cn for + # groups or uid for users + return entry.single_value[name_attr] + + elif anchor.startswith(SID_ANCHOR_PREFIX): + + # Parse the SID out from the anchor + sid = anchor[len(SID_ANCHOR_PREFIX):].strip() + + if _dcerpc_bindings_installed: + domain_validator = ipaserver.dcerpc.DomainValidator(api) + if domain_validator.is_configured(): + name = domain_validator.get_trusted_domain_object_from_sid(sid) + + # We need to verify that the object type is correct + type_correct = verify_trusted_domain_object_type( + domain_validator, obj_type, name) + + if type_correct: + return name + + # No acceptable object was found + raise errors.NotFound( + reason=_("Anchor '%(anchor)s' could not be resolved.") + % dict(anchor=anchor)) + + +def remove_ipaobject_overrides(ldap, api, dn): + """ + Removes all ID overrides for given object. This method is to be + consumed by -del commands of the given objects (users, groups). + """ + + entry = ldap.get_entry(dn, attrs_list=['ipaUniqueID']) + object_uuid = entry.single_value['ipaUniqueID'] + + override_filter = '(ipaanchoruuid=:IPA:{0}:{1})'.format(api.env.domain, + object_uuid) + try: + entries, truncated = ldap.find_entries( + override_filter, + base_dn=DN(api.env.container_views, api.env.basedn), + paged_search=True + ) + except errors.EmptyResult: + pass + else: + # In case we found something, delete it + for entry in entries: + ldap.delete_entry(entry) + + +# This is not registered on purpose, it's a base class for ID overrides +class baseidoverride(LDAPObject): + """ + Base ID override object. + """ + + parent_object = 'idview' + container_dn = api.env.container_views + + object_class = ['ipaOverrideAnchor', 'top'] + default_attributes = [ + 'description', 'ipaAnchorUUID', + ] + + takes_params = ( + Str('ipaanchoruuid', + cli_name='anchor', + primary_key=True, + label=_('Anchor to override'), + ), + Str('description?', + cli_name='desc', + label=_('Description'), + ), + ) + + override_object = None + + def get_dn(self, *keys, **options): + # If user passed raw anchor, do not try + # to translate it. + if ANCHOR_REGEX.match(keys[-1]): + anchor = keys[-1] + + # Otherwise, translate object into a + # legitimate object anchor. + else: + anchor = resolve_object_to_anchor( + self.backend, + self.override_object, + keys[-1], + fallback_to_ldap=options['fallback_to_ldap'] + ) + + keys = keys[:-1] + (anchor, ) + return super(baseidoverride, self).get_dn(*keys, **options) + + def set_anchoruuid_from_dn(self, dn, entry_attrs): + # TODO: Use entry_attrs.single_value once LDAPUpdate supports + # lists in primary key fields (baseldap.LDAPUpdate.execute) + entry_attrs['ipaanchoruuid'] = dn[0].value + + def convert_anchor_to_human_readable_form(self, entry_attrs, **options): + if not options.get('raw'): + anchor = entry_attrs.single_value['ipaanchoruuid'] + + if anchor: + try: + object_name = resolve_anchor_to_object_name( + self.backend, + self.override_object, + anchor + ) + entry_attrs.single_value['ipaanchoruuid'] = object_name + except errors.NotFound: + # If we were unable to resolve the anchor, + # keep it in the raw form + pass + except errors.ValidationError: + # Same as above, ValidationError may be raised when SIDs + # are attempted to be converted, but the domain is no + # longer trusted + pass + + def prohibit_ipa_users_in_default_view(self, dn, entry_attrs): + # Check if parent object is Default Trust View, if so, prohibit + # adding overrides for IPA objects + + if dn[1].value.lower() == DEFAULT_TRUST_VIEW_NAME: + if dn[0].value.startswith(IPA_ANCHOR_PREFIX): + raise errors.ValidationError( + name=_('ID View'), + error=_('Default Trust View cannot contain IPA users') + ) + +class baseidoverride_add(LDAPCreate): + __doc__ = _('Add a new ID override.') + msg_summary = _('Added ID override "%(value)s"') + + takes_options = LDAPCreate.takes_options + (fallback_to_ldap_option,) + + def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options): + self.obj.set_anchoruuid_from_dn(dn, entry_attrs) + self.obj.prohibit_ipa_users_in_default_view(dn, entry_attrs) + return dn + + def post_callback(self, ldap, dn, entry_attrs, *keys, **options): + self.obj.convert_anchor_to_human_readable_form(entry_attrs, **options) + return dn + + +class baseidoverride_del(LDAPDelete): + __doc__ = _('Delete an ID override.') + msg_summary = _('Deleted ID override "%(value)s"') + + takes_options = LDAPDelete.takes_options + (fallback_to_ldap_option,) + + def pre_callback(self, ldap, dn, *keys, **options): + assert isinstance(dn, DN) + + # Make sure the entry we're deleting has all the objectclasses + # this object requires + try: + entry = ldap.get_entry(dn, ['objectclass']) + except errors.NotFound: + self.obj.handle_not_found(*keys) + + required_object_classes = set(self.obj.object_class) + actual_object_classes = set(entry['objectclass']) + + # If not, treat it as a failed search + if not required_object_classes.issubset(actual_object_classes): + self.obj.handle_not_found(*keys) + + return dn + + +class baseidoverride_mod(LDAPUpdate): + __doc__ = _('Modify an ID override.') + msg_summary = _('Modified an ID override "%(value)s"') + + takes_options = LDAPUpdate.takes_options + (fallback_to_ldap_option,) + + def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options): + if 'rename' in options: + raise errors.ValidationError( + name=_('ID override'), + error=_('ID overrides cannot be renamed') + ) + + self.obj.prohibit_ipa_users_in_default_view(dn, entry_attrs) + return dn + + def post_callback(self, ldap, dn, entry_attrs, *keys, **options): + self.obj.convert_anchor_to_human_readable_form(entry_attrs, **options) + return dn + + +class baseidoverride_find(LDAPSearch): + __doc__ = _('Search for an ID override.') + msg_summary = ngettext('%(count)d ID override matched', + '%(count)d ID overrides matched', 0) + + takes_options = LDAPSearch.takes_options + (fallback_to_ldap_option,) + + def post_callback(self, ldap, entries, truncated, *args, **options): + for entry in entries: + self.obj.convert_anchor_to_human_readable_form(entry, **options) + return truncated + + +class baseidoverride_show(LDAPRetrieve): + __doc__ = _('Display information about an ID override.') + + takes_options = LDAPRetrieve.takes_options + (fallback_to_ldap_option,) + + def post_callback(self, ldap, dn, entry_attrs, *keys, **options): + self.obj.convert_anchor_to_human_readable_form(entry_attrs, **options) + return dn + + +@register() +class idoverrideuser(baseidoverride): + + object_name = _('User ID override') + object_name_plural = _('User ID overrides') + + label = _('User ID overrides') + label_singular = _('User ID override') + rdn_is_primary_key = True + + permission_filter_objectclasses = ['ipaUserOverride'] + managed_permissions = { + 'System: Read User ID Overrides': { + 'ipapermbindruletype': 'all', + 'ipapermright': {'read', 'search', 'compare'}, + 'ipapermdefaultattr': { + 'objectClass', 'ipaAnchorUUID', 'uidNumber', 'description', + 'homeDirectory', 'uid', 'ipaOriginalUid', 'loginShell', 'gecos', + 'gidNumber', 'ipaSshPubkey', 'usercertificate' + }, + }, + } + + object_class = baseidoverride.object_class + ['ipaUserOverride'] + possible_objectclasses = ['ipasshuser', 'ipaSshGroupOfPubKeys'] + default_attributes = baseidoverride.default_attributes + [ + 'homeDirectory', 'uidNumber', 'uid', 'ipaOriginalUid', 'loginShell', + 'ipaSshPubkey', 'gidNumber', 'gecos', 'usercertificate;binary', + ] + + search_display_attributes = baseidoverride.default_attributes + [ + 'homeDirectory', 'uidNumber', 'uid', 'ipaOriginalUid', 'loginShell', + 'ipaSshPubkey', 'gidNumber', 'gecos', + ] + + takes_params = baseidoverride.takes_params + ( + Str('uid?', + pattern='^[a-zA-Z0-9_.][a-zA-Z0-9_.-]{0,252}[a-zA-Z0-9_.$-]?$', + pattern_errmsg='may only include letters, numbers, _, -, . and $', + maxlength=255, + cli_name='login', + label=_('User login'), + normalizer=lambda value: value.lower(), + ), + Int('uidnumber?', + cli_name='uid', + label=_('UID'), + doc=_('User ID Number'), + minvalue=1, + ), + Str('gecos?', + label=_('GECOS'), + ), + Int('gidnumber?', + label=_('GID'), + doc=_('Group ID Number'), + minvalue=1, + ), + Str('homedirectory?', + cli_name='homedir', + label=_('Home directory'), + ), + Str('loginshell?', + cli_name='shell', + label=_('Login shell'), + ), + Str('ipaoriginaluid?', + flags=['no_option', 'no_output'] + ), + Str('ipasshpubkey*', validate_sshpubkey, + cli_name='sshpubkey', + label=_('SSH public key'), + normalizer=normalize_sshpubkey, + flags=['no_search'], + ), + Bytes('usercertificate*', validate_certificate, + cli_name='certificate', + label=_('Certificate'), + doc=_('Base-64 encoded user certificate'), + flags=['no_search',], + ), + ) + + override_object = 'user' + + def update_original_uid_reference(self, entry_attrs): + anchor = entry_attrs.single_value['ipaanchoruuid'] + try: + original_uid = resolve_anchor_to_object_name(self.backend, + self.override_object, + anchor) + entry_attrs['ipaOriginalUid'] = original_uid + + except (errors.NotFound, errors.ValidationError): + # Anchor could not be resolved, this means we had to specify the + # object to manipulate using a raw anchor value already, hence + # we have no way to update the original_uid + pass + + def convert_usercertificate_pre(self, entry_attrs): + if 'usercertificate' in entry_attrs: + entry_attrs['usercertificate;binary'] = entry_attrs.pop( + 'usercertificate') + + def convert_usercertificate_post(self, entry_attrs, **options): + if 'usercertificate;binary' in entry_attrs: + entry_attrs['usercertificate'] = entry_attrs.pop( + 'usercertificate;binary') + + + +@register() +class idoverridegroup(baseidoverride): + + object_name = _('Group ID override') + object_name_plural = _('Group ID overrides') + + label = _('Group ID overrides') + label_singular = _('Group ID override') + rdn_is_primary_key = True + + permission_filter_objectclasses = ['ipaGroupOverride'] + managed_permissions = { + 'System: Read Group ID Overrides': { + 'ipapermbindruletype': 'all', + 'ipapermright': {'read', 'search', 'compare'}, + 'ipapermdefaultattr': { + 'objectClass', 'ipaAnchorUUID', 'gidNumber', + 'description', 'cn', + }, + }, + } + + object_class = baseidoverride.object_class + ['ipaGroupOverride'] + default_attributes = baseidoverride.default_attributes + [ + 'gidNumber', 'cn', + ] + + takes_params = baseidoverride.takes_params + ( + Str('cn?', + pattern='^[a-zA-Z0-9_.][a-zA-Z0-9_.-]{0,252}[a-zA-Z0-9_.$-]?$', + pattern_errmsg='may only include letters, numbers, _, -, . and $', + maxlength=255, + cli_name='group_name', + label=_('Group name'), + normalizer=lambda value: value.lower(), + ), + Int('gidnumber?', + cli_name='gid', + label=_('GID'), + doc=_('Group ID Number'), + minvalue=1, + ), + ) + + override_object = 'group' + +@register() +class idoverrideuser_add_cert(LDAPAddAttribute): + __doc__ = _('Add one or more certificates to the idoverrideuser entry') + msg_summary = _('Added certificates to idoverrideuser "%(value)s"') + attribute = 'usercertificate' + + takes_options = LDAPAddAttribute.takes_options + (fallback_to_ldap_option,) + + def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, + **options): + dn = self.obj.get_dn(*keys, **options) + self.obj.convert_usercertificate_pre(entry_attrs) + + return dn + + def post_callback(self, ldap, dn, entry_attrs, *keys, **options): + assert isinstance(dn, DN) + self.obj.convert_usercertificate_post(entry_attrs, **options) + self.obj.convert_anchor_to_human_readable_form(entry_attrs, **options) + return dn + + +@register() +class idoverrideuser_remove_cert(LDAPRemoveAttribute): + __doc__ = _('Remove one or more certificates to the idoverrideuser entry') + msg_summary = _('Removed certificates from idoverrideuser "%(value)s"') + attribute = 'usercertificate' + + takes_options = LDAPRemoveAttribute.takes_options + (fallback_to_ldap_option,) + + def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, + **options): + dn = self.obj.get_dn(*keys, **options) + self.obj.convert_usercertificate_pre(entry_attrs) + + return dn + + def post_callback(self, ldap, dn, entry_attrs, *keys, **options): + assert isinstance(dn, DN) + self.obj.convert_usercertificate_post(entry_attrs, **options) + self.obj.convert_anchor_to_human_readable_form(entry_attrs, **options) + + return dn + + +@register() +class idoverrideuser_add(baseidoverride_add): + __doc__ = _('Add a new User ID override.') + msg_summary = _('Added User ID override "%(value)s"') + + def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options): + dn = super(idoverrideuser_add, self).pre_callback(ldap, dn, + entry_attrs, attrs_list, *keys, **options) + + entry_attrs['objectclass'].append('ipasshuser') + self.obj.convert_usercertificate_pre(entry_attrs) + + # Update the ipaOriginalUid + self.obj.update_original_uid_reference(entry_attrs) + return dn + + def post_callback(self, ldap, dn, entry_attrs, *keys, **options): + dn = super(idoverrideuser_add, self).post_callback(ldap, dn, + entry_attrs, *keys, **options) + convert_sshpubkey_post(entry_attrs) + self.obj.convert_usercertificate_post(entry_attrs, **options) + return dn + + + +@register() +class idoverrideuser_del(baseidoverride_del): + __doc__ = _('Delete an User ID override.') + msg_summary = _('Deleted User ID override "%(value)s"') + + +@register() +class idoverrideuser_mod(baseidoverride_mod): + __doc__ = _('Modify an User ID override.') + msg_summary = _('Modified an User ID override "%(value)s"') + + def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options): + dn = super(idoverrideuser_mod, self).pre_callback(ldap, dn, + entry_attrs, attrs_list, *keys, **options) + + # Update the ipaOriginalUid + self.obj.set_anchoruuid_from_dn(dn, entry_attrs) + self.obj.update_original_uid_reference(entry_attrs) + if 'objectclass' in entry_attrs: + obj_classes = entry_attrs['objectclass'] + else: + _entry_attrs = ldap.get_entry(dn, ['objectclass']) + obj_classes = entry_attrs['objectclass'] = _entry_attrs['objectclass'] + + if 'ipasshpubkey' in entry_attrs and 'ipasshuser' not in obj_classes: + obj_classes.append('ipasshuser') + + self.obj.convert_usercertificate_pre(entry_attrs) + return dn + + def post_callback(self, ldap, dn, entry_attrs, *keys, **options): + dn = super(idoverrideuser_mod, self).post_callback(ldap, dn, + entry_attrs, *keys, **options) + convert_sshpubkey_post(entry_attrs) + self.obj.convert_usercertificate_post(entry_attrs, **options) + return dn + + +@register() +class idoverrideuser_find(baseidoverride_find): + __doc__ = _('Search for an User ID override.') + msg_summary = ngettext('%(count)d User ID override matched', + '%(count)d User ID overrides matched', 0) + + def post_callback(self, ldap, entries, truncated, *args, **options): + truncated = super(idoverrideuser_find, self).post_callback( + ldap, entries, truncated, *args, **options) + for entry in entries: + convert_sshpubkey_post(entry) + self.obj.convert_usercertificate_post(entry, **options) + return truncated + + +@register() +class idoverrideuser_show(baseidoverride_show): + __doc__ = _('Display information about an User ID override.') + + def post_callback(self, ldap, dn, entry_attrs, *keys, **options): + dn = super(idoverrideuser_show, self).post_callback(ldap, dn, + entry_attrs, *keys, **options) + convert_sshpubkey_post(entry_attrs) + self.obj.convert_usercertificate_post(entry_attrs, **options) + return dn + + +@register() +class idoverridegroup_add(baseidoverride_add): + __doc__ = _('Add a new Group ID override.') + msg_summary = _('Added Group ID override "%(value)s"') + + +@register() +class idoverridegroup_del(baseidoverride_del): + __doc__ = _('Delete an Group ID override.') + msg_summary = _('Deleted Group ID override "%(value)s"') + + +@register() +class idoverridegroup_mod(baseidoverride_mod): + __doc__ = _('Modify an Group ID override.') + msg_summary = _('Modified an Group ID override "%(value)s"') + + +@register() +class idoverridegroup_find(baseidoverride_find): + __doc__ = _('Search for an Group ID override.') + msg_summary = ngettext('%(count)d Group ID override matched', + '%(count)d Group ID overrides matched', 0) + + +@register() +class idoverridegroup_show(baseidoverride_show): + __doc__ = _('Display information about an Group ID override.') diff --git a/ipaserver/plugins/internal.py b/ipaserver/plugins/internal.py new file mode 100644 index 000000000..99b0c04d1 --- /dev/null +++ b/ipaserver/plugins/internal.py @@ -0,0 +1,859 @@ +# Authors: +# Pavel Zuna <pzuna@redhat.com> +# Adam Young <ayoung@redhat.com> +# Endi S. Dewata <edewata@redhat.com> +# +# Copyright (c) 2010 Red Hat +# See file 'copying' for use and warranty information +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +""" +Plugins not accessible directly through the CLI, commands used internally +""" +from ipalib import Command +from ipalib import Str +from ipalib.output import Output +from ipalib.text import _ +from ipalib.util import json_serialize +from ipalib.plugable import Registry + +register = Registry() + +@register() +class json_metadata(Command): + """ + Export plugin meta-data for the webUI. + """ + NO_CLI = True + + + takes_args = ( + Str('objname?', + doc=_('Name of object to export'), + ), + Str('methodname?', + doc=_('Name of method to export'), + ), + ) + + takes_options = ( + Str('object?', + doc=_('Name of object to export'), + ), + Str('method?', + doc=_('Name of method to export'), + ), + Str('command?', + doc=_('Name of command to export'), + ), + ) + + has_output = ( + Output('objects', dict, doc=_('Dict of JSON encoded IPA Objects')), + Output('methods', dict, doc=_('Dict of JSON encoded IPA Methods')), + Output('commands', dict, doc=_('Dict of JSON encoded IPA Commands')), + ) + + def execute(self, objname=None, methodname=None, **options): + objects = dict() + methods = dict() + commands = dict() + + empty = True + + try: + if not objname: + objname = options['object'] + if objname in self.api.Object: + o = self.api.Object[objname] + objects = dict([(o.name, json_serialize(o))]) + elif objname == "all": + objects = dict( + (o.name, json_serialize(o)) for o in self.api.Object() + ) + empty = False + except KeyError: + pass + + try: + if not methodname: + methodname = options['method'] + if methodname in self.api.Method: + m = self.api.Method[methodname] + methods = dict([(m.name, json_serialize(m))]) + elif methodname == "all": + methods = dict( + (m.name, json_serialize(m)) for m in self.api.Method() + ) + empty = False + except KeyError: + pass + + try: + cmdname = options['command'] + if cmdname in self.api.Command: + c = self.api.Command[cmdname] + commands = dict([(c.name, json_serialize(c))]) + elif cmdname == "all": + commands = dict( + (c.name, json_serialize(c)) for c in self.api.Command() + ) + empty = False + except KeyError: + pass + + if empty: + objects = dict( + (o.name, json_serialize(o)) for o in self.api.Object() + ) + methods = dict( + (m.name, json_serialize(m)) for m in self.api.Method() + ) + commands = dict( + (c.name, json_serialize(c)) for c in self.api.Command() + ) + + retval = dict([ + ("objects", objects), + ("methods", methods), + ("commands", commands), + ]) + + return retval + + +@register() +class i18n_messages(Command): + NO_CLI = True + + messages = { + "ajax": { + "401": { + "message": _("Your session has expired. Please re-login."), + }, + }, + "actions": { + "apply": _("Apply"), + "automember_rebuild": _("Rebuild auto membership"), + "automember_rebuild_confirm": _("Are you sure you want to rebuild auto membership?"), + "automember_rebuild_success": _("Automember rebuild membership task completed"), + "confirm": _("Are you sure you want to proceed with the action?"), + "delete_confirm": _("Are you sure you want to delete ${object}?"), + "disable_confirm": _("Are you sure you want to disable ${object}?"), + "enable_confirm": _("Are you sure you want to enable ${object}?"), + "title": _("Actions"), + }, + "association": { + "add": { + "ipasudorunas": _("Add RunAs ${other_entity} into ${entity} ${primary_key}"), + "ipasudorunasgroup": _("Add RunAs Groups into ${entity} ${primary_key}"), + "managedby": _("Add ${other_entity} Managing ${entity} ${primary_key}"), + "member": _("Add ${other_entity} into ${entity} ${primary_key}"), + "memberallowcmd": _("Add Allow ${other_entity} into ${entity} ${primary_key}"), + "memberdenycmd": _("Add Deny ${other_entity} into ${entity} ${primary_key}"), + "memberof": _("Add ${entity} ${primary_key} into ${other_entity}"), + }, + "added": _("${count} item(s) added"), + "direct_membership": _("Direct Membership"), + "filter_placeholder": _("Filter available ${other_entity}"), + "indirect_membership": _("Indirect Membership"), + "no_entries": _("No entries."), + "paging": _("Showing ${start} to ${end} of ${total} entries."), + "remove": { + "ipasudorunas": _("Remove RunAs ${other_entity} from ${entity} ${primary_key}"), + "ipasudorunasgroup": _("Remove RunAs Groups from ${entity} ${primary_key}"), + "managedby": _("Remove ${other_entity} Managing ${entity} ${primary_key}"), + "member": _("Remove ${other_entity} from ${entity} ${primary_key}"), + "memberallowcmd": _("Remove Allow ${other_entity} from ${entity} ${primary_key}"), + "memberdenycmd": _("Remove Deny ${other_entity} from ${entity} ${primary_key}"), + "memberof": _("Remove ${entity} ${primary_key} from ${other_entity}"), + }, + "removed": _("${count} item(s) removed"), + "show_results": _("Show Results"), + }, + "authtype": { + "config_tooltip": _("<p>Implicit method (password) will be used if no method is chosen.</p><p><strong>Password + Two-factor:</strong> LDAP and Kerberos allow authentication with either one of the authentication types but Kerberos uses pre-authentication method which requires to use armor ccache.</p><p><strong>RADIUS with another type:</strong> Kerberos always use RADIUS, but LDAP never does. LDAP only recognize the password and two-factor authentication options.</p>"), + "type_otp": _("Two factor authentication (password + OTP)"), + "type_password": _("Password"), + "type_radius": _("Radius"), + "type_disabled": _("Disable per-user override"), + "user_tooltip": _("<p>Per-user setting, overwrites the global setting if any option is checked.</p><p><strong>Password + Two-factor:</strong> LDAP and Kerberos allow authentication with either one of the authentication types but Kerberos uses pre-authentication method which requires to use armor ccache.</p><p><strong>RADIUS with another type:</strong> Kerberos always use RADIUS, but LDAP never does. LDAP only recognize the password and two-factor authentication options.</p>"), + }, + "buttons": { + "about": _("About"), + "activate": _("Activate"), + "add": _("Add"), + "add_and_add_another": _("Add and Add Another"), + "add_and_close": _("Add and Close"), + "add_and_edit": _("Add and Edit"), + "add_many": _("Add Many"), + "apply": _("Apply"), + "back": _("Back"), + "cancel": _("Cancel"), + "close": _("Close"), + "disable": _("Disable"), + "edit": _("Edit"), + "enable": _("Enable"), + "filter": _("Filter"), + "find": _("Find"), + "get": _("Get"), + "hide": _("Hide"), + "issue": _("Issue"), + "ok": _("OK"), + "refresh": _("Refresh"), + "refresh_title": _("Reload current settings from the server."), + "remove": _("Delete"), + "reset": _("Reset"), + "reset_password_and_login": _("Reset Password and Login"), + "restore": _("Restore"), + "retry": _("Retry"), + "revert": _("Revert"), + "revert_title": ("Undo all unsaved changes."), + "revoke": _("Revoke"), + "save": _("Save"), + "set": _("Set"), + "show": _("Show"), + "unapply": ("Un-apply"), + "update": _("Update"), + "view": _("View"), + }, + "details": { + "collapse_all": _("Collapse All"), + "expand_all": _("Expand All"), + "general": _("General"), + "identity": _("Identity Settings"), + "settings": _("${entity} ${primary_key} Settings"), + "to_top": _("Back to Top"), + "updated": _("${entity} ${primary_key} updated"), + }, + "dialogs": { + "add_confirmation": _("${entity} successfully added"), + "add_title": _("Add ${entity}"), + "available": _("Available"), + "batch_error_message": _("Some operations failed."), + "batch_error_title": _("Operations Error"), + "confirmation": _("Confirmation"), + "dirty_message": _("This page has unsaved changes. Please save or revert."), + "dirty_title": _("Unsaved Changes"), + "edit_title": _("Edit ${entity}"), + "hide_details": _("Hide details"), + "about_title": _("About"), + "about_message": _("${product}, version: ${version}"), + "prospective": _("Prospective"), + "redirection": _("Redirection"), + "remove_empty": _("Select entries to be removed."), + "remove_title": _("Remove ${entity}"), + "result": _("Result"), + "show_details": _("Show details"), + "success": _("Success"), + "validation_title": _("Validation error"), + "validation_message": _("Input form contains invalid or missing values."), + }, + "error_report": { + "options": _("Please try the following options:"), + "problem_persists": _("If the problem persists please contact the system administrator."), + "refresh": _("Refresh the page."), + "reload": _("Reload the browser."), + "main_page": _("Return to the main page and retry the operation"), + "title": _("An error has occurred (${error})"), + }, + "errors": { + "error": _("Error"), + "http_error": _("HTTP Error"), + "internal_error": _("Internal Error"), + "ipa_error": _("IPA Error"), + "no_response": _("No response"), + "unknown_error": _("Unknown Error"), + "url": _("URL"), + }, + "facet_groups": { + "managedby": _("${primary_key} is managed by:"), + "member": _("${primary_key} members:"), + "memberof": _("${primary_key} is a member of:"), + }, + "facets": { + "details": _("Settings"), + "search": _("Search"), + }, + "false": _("False"), + "keytab": { + "add_create": _("Allow ${other_entity} to create keytab of ${primary_key}"), + "add_retrive": _("Allow ${other_entity} to retrieve keytab of ${primary_key}"), + "allowed_to_create": _("Allowed to create keytab"), + "allowed_to_retrieve": _("Allowed to retrieve keytab"), + "remove_create": _("Disallow ${other_entity} to create keytab of ${primary_key}"), + "remove_retrieve": _("Disallow ${other_entity} to retrieve keytab of ${primary_key}"), + }, + "krbauthzdata": { + "inherited": _("Inherited from server configuration"), + "mspac": _("MS-PAC"), + "override": _("Override inherited settings"), + "pad": _("PAD"), + }, + "login": { + "form_auth": _("<i class=\"fa fa-info-circle\"></i> To login with <strong>username and password</strong>, enter them in the corresponding fields, then click Login."), + "header": _("Logged In As"), + "krb_auth_msg": _("<i class=\"fa fa-info-circle\"></i> To login with <strong>Kerberos</strong>, please make sure you have valid tickets (obtainable via kinit) and <a href='http://${host}/ipa/config/unauthorized.html'>configured</a> the browser correctly, then click Login."), + "login": _("Login"), + "logout": _("Logout"), + "logout_error": _("Logout error"), + "password": _("Password"), + "sync_otp_token": _("Sync OTP Token"), + "username": _("Username"), + }, + "measurement_units": { + "number_of_passwords": _("number of passwords"), + "seconds": _("seconds"), + }, + "objects": { + "aci": { + "attribute": _("Attribute"), + }, + "automember": { + "add_condition": _("Add Condition into ${pkey}"), + "add_rule": _("Add Rule"), + "attribute": _("Attribute"), + "default_host_group": _("Default host group"), + "default_user_group": _("Default user group"), + "exclusive": _("Exclusive"), + "expression": _("Expression"), + "hostgrouprule": _("Host group rule"), + "hostgrouprules": _("Host group rules"), + "inclusive": _("Inclusive"), + "usergrouprule": _("User group rule"), + "usergrouprules": _("User group rules"), + }, + "automountkey": { + }, + "automountlocation": { + "identity": _("Automount Location Settings") + }, + "automountmap": { + "map_type": _("Map Type"), + "direct": _("Direct"), + "indirect": _("Indirect"), + }, + "caacl": { + "any_host": _("Any Host"), + "any_service": _("Any Service"), + "any_profile": _("Any Profile"), + "anyone": _("Anyone"), + "ipaenabledflag": _("Rule status"), + "profile": _("Profiles"), + "specified_hosts": _("Specified Hosts and Groups"), + "specified_profiles": _("Specified Profiles"), + "specified_services": _("Specified Services and Groups"), + "specified_users": _("Specified Users and Groups"), + "who": _("Permitted to have certificates issued"), + }, + "cert": { + "aa_compromise": _("AA Compromise"), + "add_principal": _("Add principal"), + "affiliation_changed": _("Affiliation Changed"), + "ca_compromise": _("CA Compromise"), + "certificate": _("Certificate"), + "certificates": _("Certificates"), + "certificate_hold": _("Certificate Hold"), + "cessation_of_operation": _("Cessation of Operation"), + "common_name": _("Common Name"), + "expires_on": _("Expires On"), + "find_issuedon_from": _("Issued on from"), + "find_issuedon_to": _("Issued on to"), + "find_max_serial_number": _("Maximum serial number"), + "find_min_serial_number": _("Minimum serial number"), + "find_revocation_reason": _("Revocation reason"), + "find_revokedon_from": _("Revoked on from"), + "find_revokedon_to": _("Revoked on to"), + "find_subject": _("Subject"), + "find_validnotafter_from": _("Valid not after from"), + "find_validnotafter_to": _("Valid not after to"), + "find_validnotbefore_from": _("Valid not before from"), + "find_validnotbefore_to": _("Valid not before to"), + "fingerprints": _("Fingerprints"), + "get_certificate": _("Get Certificate"), + "issue_certificate": _("Issue New Certificate for ${entity} ${primary_key}"), + "issue_certificate_generic": _("Issue New Certificate"), + "issued_by": _("Issued By"), + "issued_on": _("Issued On"), + "issued_to": _("Issued To"), + "key_compromise": _("Key Compromise"), + "md5_fingerprint": _("MD5 Fingerprint"), + "missing": _("No Valid Certificate"), + "new_certificate": _("New Certificate"), + "note": _("Note"), + "organization": _("Organization"), + "organizational_unit": _("Organizational Unit"), + "present": _("${count} certificate(s) present"), + "privilege_withdrawn": _("Privilege Withdrawn"), + "reason": _("Reason for Revocation"), + "remove_from_crl": _("Remove from CRL"), + "request_message": _("<ol> <li>Create a certificate database or use an existing one. To create a new database:<br/> <code># certutil -N -d <database path></code> </li> <li>Create a CSR with subject <em>CN=<${cn_name}>,O=<realm></em>, for example:<br/> <code># certutil -R -d <database path> -a -g <key size> -s 'CN=${cn},O=${realm}'</code> </li> <li> Copy and paste the CSR (from <em>-----BEGIN NEW CERTIFICATE REQUEST-----</em> to <em>-----END NEW CERTIFICATE REQUEST-----</em>) into the text area below: </li> </ol>"), + "requested": _("Certificate requested"), + "restore_certificate": _("Restore Certificate for ${entity} ${primary_key}"), + "restore_certificate_simple": _("Restore Certificate"), + "restore_confirmation": _("To confirm your intention to restore this certificate, click the \"Restore\" button."), + "restored": _("Certificate restored"), + "revocation_reason": _("Revocation reason"), + "revoke_certificate": _("Revoke Certificate for ${entity} ${primary_key}"), + "revoke_certificate_simple": _("Revoke Certificate"), + "revoke_confirmation": _("To confirm your intention to revoke this certificate, select a reason from the pull-down list, and click the \"Revoke\" button."), + "revoked": _("Certificate Revoked"), + "serial_number": _("Serial Number"), + "serial_number_hex": _("Serial Number (hex)"), + "sha1_fingerprint": _("SHA1 Fingerprint"), + "status": _("Status"), + "superseded": _("Superseded"), + "unspecified": _("Unspecified"), + "valid": _("Valid Certificate Present"), + "validity": _("Validity"), + "view_certificate": _("Certificate for ${entity} ${primary_key}"), + "view_certificate_btn": _("View Certificate"), + }, + "config": { + "group": _("Group Options"), + "search": _("Search Options"), + "selinux": _("SELinux Options"), + "service": _("Service Options"), + "user": _("User Options"), + }, + "delegation": { + }, + "dnsconfig": { + "forward_first": _("Forward first"), + "forward_none": _("Forwarding disabled"), + "forward_only": _("Forward only"), + "options": _("Options"), + }, + "dnsrecord": { + "data": _("Data"), + "deleted_no_data": _("DNS record was deleted because it contained no data."), + "other": _("Other Record Types"), + "ptr_redir_address_err": _("Address not valid, can't redirect"), + "ptr_redir_create": _("Create dns record"), + "ptr_redir_creating": _("Creating record."), + "ptr_redir_creating_err": _("Record creation failed."), + "ptr_redir_record": _("Checking if record exists."), + "ptr_redir_record_err": _("Record not found."), + "ptr_redir_title": _("Redirection to PTR record"), + "ptr_redir_zone": _("Zone found: ${zone}"), + "ptr_redir_zone_err": _("Target reverse zone not found."), + "ptr_redir_zones": _("Fetching DNS zones."), + "ptr_redir_zones_err": _("An error occurred while fetching dns zones."), + "redirection_dnszone": _("You will be redirected to DNS Zone."), + "standard": _("Standard Record Types"), + "title": _("Records for DNS Zone"), + "type": _("Record Type"), + }, + "dnszone": { + "identity": _("DNS Zone Settings"), + "add_permission":_("Add Permission"), + "add_permission_confirm":_("Are you sure you want to add permission for DNS Zone ${object}?"), + "remove_permission": _("Remove Permission"), + "remove_permission_confirm": _("Are you sure you want to remove permission for DNS Zone ${object}?"), + "skip_dns_check": _("Skip DNS check"), + "skip_overlap_check": _("Skip overlap check"), + "soamname_change_message": _("Do you want to check if new authoritative nameserver address is in DNS"), + "soamname_change_title": _("Authoritative nameserver change"), + }, + "domainlevel": { + "label": _("Domain Level"), + "label_singular": _("Domain Level"), + "ipadomainlevel": _("Level"), + "set": _("Set Domain Level"), + }, + "group": { + "details": _("Group Settings"), + "external": _("External"), + "make_external": _("Change to external group"), + "make_posix": _("Change to POSIX group"), + "normal": _("Normal"), + "posix": _("POSIX"), + "type": _("Group Type"), + }, + "hbacrule": { + "any_host": _("Any Host"), + "any_service": _("Any Service"), + "anyone": _("Anyone"), + "host": _("Accessing"), + "ipaenabledflag": _("Rule status"), + "service": _("Via Service"), + "specified_hosts": _("Specified Hosts and Groups"), + "specified_services": _("Specified Services and Groups"), + "specified_users": _("Specified Users and Groups"), + "user": _("Who"), + }, + "hbacsvc": { + }, + "hbacsvcgroup": { + "services": _("Services"), + }, + "hbactest": { + "access_denied": _("Access Denied"), + "access_granted": _("Access Granted"), + "include_disabled": _("Include Disabled"), + "include_enabled": _("Include Enabled"), + "label": _("HBAC Test"), + "matched": _("Matched"), + "missing_values": _("Missing values: "), + "new_test": _("New Test"), + "rules": _("Rules"), + "run_test": _("Run Test"), + "specify_external": _("Specify external ${entity}"), + "unmatched": _("Unmatched"), + }, + "host": { + "certificate": _("Host Certificate"), + "cn": _("Host Name"), + "delete_key_unprovision": _("Delete Key, Unprovision"), + "details": _("Host Settings"), + "enrolled": _("Enrolled"), + "enrollment": _("Enrollment"), + "fqdn": _("Fully Qualified Host Name"), + "generate_otp": _("Generate OTP"), + "generated_otp": _("Generated OTP"), + "keytab": _("Kerberos Key"), + "keytab_missing": _("Kerberos Key Not Present"), + "keytab_present": _("Kerberos Key Present, Host Provisioned"), + "password": _("One-Time-Password"), + "password_missing": _("One-Time-Password Not Present"), + "password_present": _("One-Time-Password Present"), + "password_reset_button": _("Reset OTP"), + "password_reset_title": _("Reset One-Time-Password"), + "password_set_button": _("Set OTP"), + "password_set_success": _("OTP set"), + "password_set_title": _("Set One-Time-Password"), + "status": _("Status"), + "unprovision": _("Unprovision"), + "unprovision_confirmation": _("Are you sure you want to unprovision this host?"), + "unprovision_title": _("Unprovisioning ${entity}"), + "unprovisioned": _("Host unprovisioned"), + }, + "hostgroup": { + "identity": _("Host Group Settings"), + }, + "idoverrideuser": { + "anchor_label": _("User to override"), + "anchor_tooltip": _("Enter trusted or IPA user login. Note: search doesn't list users from trusted domains."), + "anchor_tooltip_ad": _("Enter trusted user login."), + }, + "idoverridegroup": { + "anchor_label": _("Group to override"), + "anchor_tooltip": _("Enter trusted or IPA group name. Note: search doesn't list groups from trusted domains."), + "anchor_tooltip_ad": _("Enter trusted group name."), + }, + "idview": { + "appliesto_tab": _("${primary_key} applies to:"), + "appliedtohosts": _("Applied to hosts"), + "appliedtohosts_title": _("Applied to hosts"), + "apply_hostgroups": _("Apply to host groups"), + "apply_hostgroups_title": _("Apply ID View ${primary_key} on hosts of ${entity}"), + "apply_hosts": _("Apply to hosts"), + "apply_hosts_title": _("Apply ID view ${primary_key} on ${entity}"), + "ipaassignedidview": _("Assigned ID View"), + "overrides_tab": _("${primary_key} overrides:"), + "unapply_hostgroups": _("Un-apply from host groups"), + "unapply_hostgroups_all_title": _("Un-apply ID Views from hosts of hostgroups"), + "unapply_hostgroups_title": _("Un-apply ID View ${primary_key} from hosts of ${entity}"), + "unapply_hosts": _("Un-apply"), + "unapply_hosts_all": _("Un-apply from hosts"), + "unapply_hosts_all_title": _("Un-apply ID Views from hosts"), + "unapply_hosts_confirm": _("Are you sure you want to un-apply ID view from selected entries?"), + "unapply_hosts_title": _("Un-apply ID View ${primary_key} from hosts"), + }, + "krbtpolicy": { + "identity": _("Kerberos Ticket Policy"), + }, + "netgroup": { + "any_host": _("Any Host"), + "anyone": _("Anyone"), + "external": _("External"), + "host": _("Host"), + "hostgroups": _("Host Groups"), + "hosts": _("Hosts"), + "identity": _("Netgroup Settings"), + "specified_hosts": _("Specified Hosts and Groups"), + "specified_users": _("Specified Users and Groups"), + "user": _("User"), + "usergroups": _("User Groups"), + "users": _("Users"), + }, + "otptoken": { + "add_token": _("Add OTP Token"), + "app_link": _("You can use <a href=\"${link}\" target=\"_blank\">FreeOTP<a/> as a software OTP token application."), + "config_title": _("Configure your token"), + "config_instructions": _("Configure your token by scanning the QR code below. Click on the QR code if you see this on the device you want to configure."), + "details": _("OTP Token Settings"), + "disable": _("Disable token"), + "enable": _("Enable token"), + "show_qr": _("Show QR code"), + "show_uri": _("Show configuration uri"), + "type_hotp": _("Counter-based (HOTP)"), + "type_totp": _("Time-based (TOTP)"), + }, + "permission": { + "add_custom_attr": _("Add custom attribute"), + "attribute": _("Attribute"), + "filter": _("Filter"), + "identity": _("Permission settings"), + "managed": _("Attribute breakdown"), + "target": _("Target"), + }, + "privilege": { + "identity": _("Privilege Settings"), + }, + "pwpolicy": { + "identity": _("Password Policy"), + }, + "idrange": { + "details": _("Range Settings"), + "ipabaseid": _("Base ID"), + "ipabaserid": _("Primary RID base"), + "ipaidrangesize": _("Range size"), + "ipanttrusteddomainsid": _("Domain SID"), + "ipasecondarybaserid": _("Secondary RID base"), + "type": _("Range type"), + "type_ad": _("Active Directory domain"), + "type_ad_posix": _("Active Directory domain with POSIX attributes"), + "type_detect": _("Detect"), + "type_local": _("Local domain"), + "type_ipa": _("IPA trust"), + "type_winsync": _("Active Directory winsync"), + }, + "radiusproxy": { + "details": _("RADIUS Proxy Server Settings"), + }, + "realmdomains": { + "identity": _("Realm Domains"), + "check_dns": _("Check DNS"), + "check_dns_confirmation": _("Do you also want to perform DNS check?"), + "force_update": _("Force Update"), + }, + "role": { + "identity": _("Role Settings"), + }, + "selfservice": { + }, + "selinuxusermap": { + "any_host": _("Any Host"), + "anyone": _("Anyone"), + "host": _("Host"), + "specified_hosts": _("Specified Hosts and Groups"), + "specified_users": _("Specified Users and Groups"), + "user": _("User"), + }, + "service": { + "certificate": _("Service Certificate"), + "delete_key_unprovision": _("Delete Key, Unprovision"), + "details": _("Service Settings"), + "host": _("Host Name"), + "missing": _("Kerberos Key Not Present"), + "provisioning": _("Provisioning"), + "service": _("Service"), + "status": _("Status"), + "unprovision": _("Unprovision"), + "unprovision_confirmation": _("Are you sure you want to unprovision this service?"), + "unprovision_title": _("Unprovisioning ${entity}"), + "unprovisioned": _("Service unprovisioned"), + "valid": _("Kerberos Key Present, Service Provisioned"), + }, + "sshkeystore": { + "keys": _("SSH public keys"), + "set_dialog_help": _("SSH public key:"), + "set_dialog_title": _("Set SSH key"), + "show_set_key": _("Show/Set key"), + "status_mod_ns": _("Modified: key not set"), + "status_mod_s": _("Modified"), + "status_new_ns": _("New: key not set"), + "status_new_s": _("New: key set"), + }, + "stageuser": { + "activate_confirm": _("Are you sure you want to activate selected users?"), + "activate_one_confirm": _("Are you sure you want to activate ${object}?"), + "activate_success": _("${count} user(s) activated"), + "label": _("Stage users"), + "preserved_label": _("Preserved users"), + "undel_confirm": _("Are you sure you want to restore selected users?"), + "undel_success": _("${count} user(s) restored"), + "user_categories": _("User categories"), + }, + "sudocmd": { + "groups": _("Groups"), + }, + "sudocmdgroup": { + "commands": _("Commands"), + }, + "sudorule": { + "allow": _("Allow"), + "any_command": _("Any Command"), + "any_group": _("Any Group"), + "any_host": _("Any Host"), + "anyone": _("Anyone"), + "command": _("Run Commands"), + "deny": _("Deny"), + "external": _("External"), + "host": _("Access this host"), + "ipaenabledflag": _("Rule status"), + "option_added": _("Option added"), + "option_removed": _("${count} option(s) removed"), + "options": _("Options"), + "runas": _("As Whom"), + "specified_commands": _("Specified Commands and Groups"), + "specified_groups": _("Specified Groups"), + "specified_hosts": _("Specified Hosts and Groups"), + "specified_users": _("Specified Users and Groups"), + "user": _("Who"), + }, + "topology": { + "segment_details": _("Segment details"), + "replication_config": _("Replication configuration"), + "insufficient_domain_level" : _("Managed topology requires minimal domain level ${domainlevel}"), + }, + "trust": { + "account": _("Account"), + "admin_account": _("Administrative account"), + "blacklists": _("SID blacklists"), + "details": _("Trust Settings"), + "domain": _("Domain"), + "establish_using": _("Establish using"), + "fetch_domains": _("Fetch domains"), + "ipantflatname": _("Domain NetBIOS name"), + "ipanttrusteddomainsid": _("Domain Security Identifier"), + "preshared_password": _("Pre-shared password"), + "trustdirection": _("Trust direction"), + "truststatus": _("Trust status"), + "trusttype": _("Trust type"), + }, + "trustconfig": { + "options": _("Options"), + }, + "user": { + "account": _("Account Settings"), + "account_status": _("Account Status"), + "activeuser_label": _("Active users"), + "contact": _("Contact Settings"), + "delete_mode": _("Delete mode"), + "employee": _("Employee Information"), + "error_changing_status": _("Error changing account status"), + "krbpasswordexpiration": _("Password expiration"), + "mailing": _("Mailing Address"), + "misc": _("Misc. Information"), + "mode_delete": _("delete"), + "mode_preserve": _("preserve"), + "noprivate": _("No private group"), + "status_confirmation": _("Are you sure you want to ${action} the user?<br/>The change will take effect immediately."), + "status_link": _("Click to ${action}"), + "unlock": _("Unlock"), + "unlock_confirm": _("Are you sure you want to unlock user ${object}?"), + }, + }, + "password": { + "current_password": _("Current Password"), + "current_password_required": _("Current password is required"), + "expires_in": _("Your password expires in ${days} days."), + "first_otp": _("First OTP"), + "invalid_password": _("The password or username you entered is incorrect."), + "new_password": _("New Password"), + "new_password_required": _("New password is required"), + "otp": _("OTP"), + "otp_info": _("<i class=\"fa fa-info-circle\"></i> <strong>One-Time-Password(OTP):</strong> Generate new OTP code for each OTP field."), + "otp_long": _("One-Time-Password"), + "otp_sync_fail": _("Token synchronization failed"), + "otp_sync_invalid": _("The username, password or token codes are not correct"), + "otp_sync_success":_("Token was synchronized"), + "password": _("Password"), + "password_and_otp": _("Password or Password+One-Time-Password"), + "password_change_complete": _("Password change complete"), + "password_must_match": _("Passwords must match"), + "reset_failure": _("Password reset was not successful."), + "reset_password": _("Reset Password"), + "reset_password_sentence": _("Reset your password."), + "second_otp": _("Second OTP"), + "token_id": _("Token ID"), + "verify_password": _("Verify Password"), + }, + "search": { + "delete_confirm": _("Are you sure you want to delete selected entries?"), + "deleted": _("${count} item(s) deleted"), + "disable_confirm": _("Are you sure you want to disable selected entries?"), + "disabled": _("${count} item(s) disabled"), + "enable_confirm": _("Are you sure you want to enable selected entries?"), + "enabled": _("${count} item(s) enabled"), + "partial_delete": _("Some entries were not deleted"), + "placeholder": _("Search"), + "quick_links": _("Quick Links"), + "select_all": _("Select All"), + "truncated": _("Query returned more results than the configured size limit. Displaying the first ${counter} results."), + "unselect_all": _("Unselect All"), + }, + "status": { + "disable": _("Disable"), + "disabled": _("Disabled"), + "enable": _("Enable"), + "enabled": _("Enabled"), + "label": _("Status"), + "working": _("Working"), + }, + "tabs": { + "audit": _("Audit"), + "authentication": _("Authentication"), + "automember": _("Automember"), + "automount": _("Automount"), + "cert": _("Certificates"), + "dns": _("DNS"), + "hbac": _("Host Based Access Control"), + "identity": _("Identity"), + "ipaserver": _("IPA Server"), + "network_services": _("Network Services"), + "policy": _("Policy"), + "role": _("Role Based Access Control"), + "sudo": _("Sudo"), + "topology": _("Topology"), + "trust": _("Trusts"), + }, + "true": _("True"), + "widget": { + "first": _("First"), + "last": _("Last"), + "next": _("Next"), + "page": _("Page"), + "prev": _("Prev"), + "undo": _("Undo"), + "undo_title": _("Undo this change."), + "undo_all": _("Undo All"), + "undo_all_title": _("Undo all changes in this field."), + "validation": { + "error": _("Text does not match field pattern"), + "datetime": _("Must be an UTC date/time value (e.g., \"2014-01-20 17:58:01Z\")"), + "decimal": _("Must be a decimal number"), + "format": _("Format error"), + "integer": _("Must be an integer"), + "ip_address": _('Not a valid IP address'), + "ip_v4_address": _('Not a valid IPv4 address'), + "ip_v6_address": _('Not a valid IPv6 address'), + "max_value": _("Maximum value is ${value}"), + "min_value": _("Minimum value is ${value}"), + "net_address": _("Not a valid network address (examples: 2001:db8::/64, 192.0.2.0/24)"), + "parse": _("Parse error"), + "port": _("'${port}' is not a valid port"), + "required": _("Required field"), + "unsupported": _("Unsupported value"), + }, + }, + } + has_output = ( + Output('texts', dict, doc=_('Dict of I18N messages')), + ) + def execute(self, **options): + return dict(texts=json_serialize(self.messages)) diff --git a/ipaserver/plugins/join.py b/ipaserver/plugins/join.py index 0f877b4d1..efec4226a 100644 --- a/ipaserver/plugins/join.py +++ b/ipaserver/plugins/join.py @@ -49,6 +49,8 @@ def validate_host(ugettext, cn): class join(Command): """Join an IPA domain""" + NO_CLI = True + takes_args = ( Str('cn', validate_host, diff --git a/ipaserver/plugins/krbtpolicy.py b/ipaserver/plugins/krbtpolicy.py new file mode 100644 index 000000000..7cf587661 --- /dev/null +++ b/ipaserver/plugins/krbtpolicy.py @@ -0,0 +1,243 @@ +# Authors: +# Pavel Zuna <pzuna@redhat.com> +# +# Copyright (C) 2010 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +from ipalib import api, errors, output, _ +from ipalib import Int, Str +from . import baseldap +from .baseldap import entry_to_dict, pkey_to_value +from ipalib.plugable import Registry +from ipapython.dn import DN + +__doc__ = _(""" +Kerberos ticket policy + +There is a single Kerberos ticket policy. This policy defines the +maximum ticket lifetime and the maximum renewal age, the period during +which the ticket is renewable. + +You can also create a per-user ticket policy by specifying the user login. + +For changes to the global policy to take effect, restarting the KDC service +is required, which can be achieved using: + +service krb5kdc restart + +Changes to per-user policies take effect immediately for newly requested +tickets (e.g. when the user next runs kinit). + +EXAMPLES: + + Display the current Kerberos ticket policy: + ipa krbtpolicy-show + + Reset the policy to the default: + ipa krbtpolicy-reset + + Modify the policy to 8 hours max life, 1-day max renewal: + ipa krbtpolicy-mod --maxlife=28800 --maxrenew=86400 + + Display effective Kerberos ticket policy for user 'admin': + ipa krbtpolicy-show admin + + Reset per-user policy for user 'admin': + ipa krbtpolicy-reset admin + + Modify per-user policy for user 'admin': + ipa krbtpolicy-mod admin --maxlife=3600 +""") + +register = Registry() + +# FIXME: load this from a config file? +_default_values = { + 'krbmaxticketlife': 86400, + 'krbmaxrenewableage': 604800, +} + + +@register() +class krbtpolicy(baseldap.LDAPObject): + """ + Kerberos Ticket Policy object + """ + container_dn = DN(('cn', api.env.realm), ('cn', 'kerberos')) + object_name = _('kerberos ticket policy settings') + default_attributes = ['krbmaxticketlife', 'krbmaxrenewableage'] + limit_object_classes = ['krbticketpolicyaux'] + # permission_filter_objectclasses is deliberately missing, + # so it is not possible to create a permission of `--type krbtpolicy`. + # This is because we need two permissions to cover both global and per-user + # policies. + managed_permissions = { + 'System: Read Default Kerberos Ticket Policy': { + 'non_object': True, + 'replaces_global_anonymous_aci': True, + 'ipapermtargetfilter': ['(objectclass=krbticketpolicyaux)'], + 'ipapermlocation': DN(container_dn, api.env.basedn), + 'ipapermright': {'read', 'search', 'compare'}, + 'ipapermdefaultattr': { + 'krbdefaultencsalttypes', 'krbmaxrenewableage', + 'krbmaxticketlife', 'krbsupportedencsalttypes', + 'objectclass', + }, + 'default_privileges': { + 'Kerberos Ticket Policy Readers', + }, + }, + 'System: Read User Kerberos Ticket Policy': { + 'non_object': True, + 'replaces_global_anonymous_aci': True, + 'ipapermlocation': DN(api.env.container_user, api.env.basedn), + 'ipapermtargetfilter': ['(objectclass=krbticketpolicyaux)'], + 'ipapermright': {'read', 'search', 'compare'}, + 'ipapermdefaultattr': { + 'krbmaxrenewableage', 'krbmaxticketlife', + }, + 'default_privileges': { + 'Kerberos Ticket Policy Readers', + }, + }, + } + + label = _('Kerberos Ticket Policy') + label_singular = _('Kerberos Ticket Policy') + + takes_params = ( + Str('uid?', + cli_name='user', + label=_('User name'), + doc=_('Manage ticket policy for specific user'), + primary_key=True, + ), + Int('krbmaxticketlife?', + cli_name='maxlife', + label=_('Max life'), + doc=_('Maximum ticket life (seconds)'), + minvalue=1, + ), + Int('krbmaxrenewableage?', + cli_name='maxrenew', + label=_('Max renew'), + doc=_('Maximum renewable age (seconds)'), + minvalue=1, + ), + ) + + def get_dn(self, *keys, **kwargs): + if keys[-1] is not None: + return self.api.Object.user.get_dn(*keys, **kwargs) + return DN(self.container_dn, api.env.basedn) + + +@register() +class krbtpolicy_mod(baseldap.LDAPUpdate): + __doc__ = _('Modify Kerberos ticket policy.') + + def execute(self, uid=None, **options): + return super(krbtpolicy_mod, self).execute(uid, **options) + + def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options): + assert isinstance(dn, DN) + # disable all flag + # ticket policies are attached to objects with unrelated attributes + if options.get('all'): + options['all'] = False + return dn + + +@register() +class krbtpolicy_show(baseldap.LDAPRetrieve): + __doc__ = _('Display the current Kerberos ticket policy.') + + def execute(self, uid=None, **options): + return super(krbtpolicy_show, self).execute(uid, **options) + + def pre_callback(self, ldap, dn, attrs_list, *keys, **options): + assert isinstance(dn, DN) + # disable all flag + # ticket policies are attached to objects with unrelated attributes + if options.get('all'): + options['all'] = False + return dn + + def post_callback(self, ldap, dn, entry, *keys, **options): + default_entry = None + rights = None + for attrname in self.obj.default_attributes: + if attrname not in entry: + if keys[-1] is not None: + # User entry doesn't override the attribute. + # Check if this is caused by insufficient read rights + if rights is None: + rights = baseldap.get_effective_rights( + ldap, dn, self.obj.default_attributes) + if 'r' not in rights.get(attrname.lower(), ''): + raise errors.ACIError( + info=_('Ticket policy for %s could not be read') % + keys[-1]) + # Fallback to the default + if default_entry is None: + try: + default_dn = self.obj.get_dn(None) + default_entry = ldap.get_entry(default_dn) + except errors.NotFound: + default_entry = {} + if attrname in default_entry: + entry[attrname] = default_entry[attrname] + if attrname not in entry: + raise errors.ACIError( + info=_('Default ticket policy could not be read')) + return dn + + +@register() +class krbtpolicy_reset(baseldap.LDAPQuery): + __doc__ = _('Reset Kerberos ticket policy to the default values.') + + has_output = output.standard_entry + + def execute(self, uid=None, **options): + ldap = self.obj.backend + + dn = self.obj.get_dn(uid, **options) + + def_values = {} + # if reseting policy for a user - just his values + if uid is not None: + for a in self.obj.default_attributes: + def_values[a] = None + # if reseting global policy - set values to default + else: + def_values = _default_values + + entry = ldap.get_entry(dn, def_values.keys()) + entry.update(def_values) + try: + ldap.update_entry(entry) + except errors.EmptyModlist: + pass + + if uid is not None: + # policy for user was deleted, retrieve global policy + dn = self.obj.get_dn(None) + entry_attrs = ldap.get_entry(dn, self.obj.default_attributes) + + entry_attrs = entry_to_dict(entry_attrs, **options) + + return dict(result=entry_attrs, value=pkey_to_value(uid, options)) diff --git a/ipaserver/plugins/migration.py b/ipaserver/plugins/migration.py new file mode 100644 index 000000000..7f634a7cc --- /dev/null +++ b/ipaserver/plugins/migration.py @@ -0,0 +1,920 @@ +# Authors: +# Pavel Zuna <pzuna@redhat.com> +# +# Copyright (C) 2009 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +import re +from ldap import MOD_ADD +from ldap import SCOPE_BASE, SCOPE_ONELEVEL, SCOPE_SUBTREE + +import six + +from ipalib import api, errors, output +from ipalib import Command, Password, Str, Flag, StrEnum, DNParam, Bool +from ipalib.cli import to_cli +from ipalib.plugable import Registry +from .user import NO_UPG_MAGIC +if api.env.in_server and api.env.context in ['lite', 'server']: + try: + from ipaserver.plugins.ldap2 import ldap2 + except Exception as e: + raise e +from ipalib import _ +from ipapython.dn import DN +from ipapython.ipautil import write_tmp_file +import datetime +from ipaplatform.paths import paths + +if six.PY3: + unicode = str + +__doc__ = _(""" +Migration to IPA + +Migrate users and groups from an LDAP server to IPA. + +This performs an LDAP query against the remote server searching for +users and groups in a container. In order to migrate passwords you need +to bind as a user that can read the userPassword attribute on the remote +server. This is generally restricted to high-level admins such as +cn=Directory Manager in 389-ds (this is the default bind user). + +The default user container is ou=People. + +The default group container is ou=Groups. + +Users and groups that already exist on the IPA server are skipped. + +Two LDAP schemas define how group members are stored: RFC2307 and +RFC2307bis. RFC2307bis uses member and uniquemember to specify group +members, RFC2307 uses memberUid. The default schema is RFC2307bis. + +The schema compat feature allows IPA to reformat data for systems that +do not support RFC2307bis. It is recommended that this feature is disabled +during migration to reduce system overhead. It can be re-enabled after +migration. To migrate with it enabled use the "--with-compat" option. + +Migrated users do not have Kerberos credentials, they have only their +LDAP password. To complete the migration process, users need to go +to http://ipa.example.com/ipa/migration and authenticate using their +LDAP password in order to generate their Kerberos credentials. + +Migration is disabled by default. Use the command ipa config-mod to +enable it: + + ipa config-mod --enable-migration=TRUE + +If a base DN is not provided with --basedn then IPA will use either +the value of defaultNamingContext if it is set or the first value +in namingContexts set in the root of the remote LDAP server. + +Users are added as members to the default user group. This can be a +time-intensive task so during migration this is done in a batch +mode for every 100 users. As a result there will be a window in which +users will be added to IPA but will not be members of the default +user group. + +EXAMPLES: + + The simplest migration, accepting all defaults: + ipa migrate-ds ldap://ds.example.com:389 + + Specify the user and group container. This can be used to migrate user + and group data from an IPA v1 server: + ipa migrate-ds --user-container='cn=users,cn=accounts' \\ + --group-container='cn=groups,cn=accounts' \\ + ldap://ds.example.com:389 + + Since IPA v2 server already contain predefined groups that may collide with + groups in migrated (IPA v1) server (for example admins, ipausers), users + having colliding group as their primary group may happen to belong to + an unknown group on new IPA v2 server. + Use --group-overwrite-gid option to overwrite GID of already existing groups + to prevent this issue: + ipa migrate-ds --group-overwrite-gid \\ + --user-container='cn=users,cn=accounts' \\ + --group-container='cn=groups,cn=accounts' \\ + ldap://ds.example.com:389 + + Migrated users or groups may have object class and accompanied attributes + unknown to the IPA v2 server. These object classes and attributes may be + left out of the migration process: + ipa migrate-ds --user-container='cn=users,cn=accounts' \\ + --group-container='cn=groups,cn=accounts' \\ + --user-ignore-objectclass=radiusprofile \\ + --user-ignore-attribute=radiusgroupname \\ + ldap://ds.example.com:389 + +LOGGING + +Migration will log warnings and errors to the Apache error log. This +file should be evaluated post-migration to correct or investigate any +issues that were discovered. + +For every 100 users migrated an info-level message will be displayed to +give the current progress and duration to make it possible to track +the progress of migration. + +If the log level is debug, either by setting debug = True in +/etc/ipa/default.conf or /etc/ipa/server.conf, then an entry will be printed +for each user added plus a summary when the default user group is +updated. +""") + +register = Registry() + +# USER MIGRATION CALLBACKS AND VARS + +_krb_err_msg = _('Kerberos principal %s already exists. Use \'ipa user-mod\' to set it manually.') +_krb_failed_msg = _('Unable to determine if Kerberos principal %s already exists. Use \'ipa user-mod\' to set it manually.') +_grp_err_msg = _('Failed to add user to the default group. Use \'ipa group-add-member\' to add manually.') +_ref_err_msg = _('Migration of LDAP search reference is not supported.') +_dn_err_msg = _('Malformed DN') + +_supported_schemas = (u'RFC2307bis', u'RFC2307') + +# search scopes for users and groups when migrating +_supported_scopes = {u'base': SCOPE_BASE, u'onelevel': SCOPE_ONELEVEL, u'subtree': SCOPE_SUBTREE} +_default_scope = u'onelevel' + + +def _pre_migrate_user(ldap, pkey, dn, entry_attrs, failed, config, ctx, **kwargs): + assert isinstance(dn, DN) + attr_blacklist = ['krbprincipalkey','memberofindirect','memberindirect'] + attr_blacklist.extend(kwargs.get('attr_blacklist', [])) + ds_ldap = ctx['ds_ldap'] + has_upg = ctx['has_upg'] + search_bases = kwargs.get('search_bases', None) + valid_gids = kwargs['valid_gids'] + invalid_gids = kwargs['invalid_gids'] + + if 'gidnumber' not in entry_attrs: + raise errors.NotFound(reason=_('%(user)s is not a POSIX user') % dict(user=pkey)) + else: + # See if the gidNumber at least points to a valid group on the remote + # server. + if entry_attrs['gidnumber'][0] in invalid_gids: + api.log.warning('GID number %s of migrated user %s does not point to a known group.' \ + % (entry_attrs['gidnumber'][0], pkey)) + elif entry_attrs['gidnumber'][0] not in valid_gids: + try: + remote_entry = ds_ldap.find_entry_by_attr( + 'gidnumber', entry_attrs['gidnumber'][0], 'posixgroup', + [''], search_bases['group'] + ) + valid_gids.add(entry_attrs['gidnumber'][0]) + except errors.NotFound: + api.log.warning('GID number %s of migrated user %s does not point to a known group.' \ + % (entry_attrs['gidnumber'][0], pkey)) + invalid_gids.add(entry_attrs['gidnumber'][0]) + except errors.SingleMatchExpected as e: + # GID number matched more groups, this should not happen + api.log.warning('GID number %s of migrated user %s should match 1 group, but it matched %d groups' \ + % (entry_attrs['gidnumber'][0], pkey, e.found)) + except errors.LimitsExceeded as e: + api.log.warning('Search limit exceeded searching for GID %s' % entry_attrs['gidnumber'][0]) + + # We don't want to create a UPG so set the magic value in description + # to let the DS plugin know. + entry_attrs.setdefault('description', []) + entry_attrs['description'].append(NO_UPG_MAGIC) + + # fill in required attributes by IPA + entry_attrs['ipauniqueid'] = 'autogenerate' + if 'homedirectory' not in entry_attrs: + homes_root = config.get('ipahomesrootdir', (paths.HOME_DIR, ))[0] + home_dir = '%s/%s' % (homes_root, pkey) + home_dir = home_dir.replace('//', '/').rstrip('/') + entry_attrs['homedirectory'] = home_dir + + if 'loginshell' not in entry_attrs: + default_shell = config.get('ipadefaultloginshell', [paths.SH])[0] + entry_attrs.setdefault('loginshell', default_shell) + + # do not migrate all attributes + for attr in attr_blacklist: + entry_attrs.pop(attr, None) + + # do not migrate all object classes + if 'objectclass' in entry_attrs: + for object_class in kwargs.get('oc_blacklist', []): + try: + entry_attrs['objectclass'].remove(object_class) + except ValueError: # object class not present + pass + + # generate a principal name and check if it isn't already taken + principal = u'%s@%s' % (pkey, api.env.realm) + try: + ldap.find_entry_by_attr( + 'krbprincipalname', principal, 'krbprincipalaux', [''], + DN(api.env.container_user, api.env.basedn) + ) + except errors.NotFound: + entry_attrs['krbprincipalname'] = principal + except errors.LimitsExceeded: + failed[pkey] = unicode(_krb_failed_msg % principal) + else: + failed[pkey] = unicode(_krb_err_msg % principal) + + # Fix any attributes with DN syntax that point to entries in the old + # tree + + for attr in entry_attrs.keys(): + if ldap.has_dn_syntax(attr): + for ind, value in enumerate(entry_attrs[attr]): + if not isinstance(value, DN): + # value is not DN instance, the automatic encoding may have + # failed due to missing schema or the remote attribute type OID was + # not detected as DN type. Try to work this around + api.log.debug('%s: value %s of type %s in attribute %s is not a DN' + ', convert it', pkey, value, type(value), attr) + try: + value = DN(value) + except ValueError as e: + api.log.warning('%s: skipping normalization of value %s of type %s ' + 'in attribute %s which could not be converted to DN: %s', + pkey, value, type(value), attr, e) + continue + try: + remote_entry = ds_ldap.get_entry(value, [api.Object.user.primary_key.name, api.Object.group.primary_key.name]) + except errors.NotFound: + api.log.warning('%s: attribute %s refers to non-existent entry %s' % (pkey, attr, value)) + continue + if value.endswith(search_bases['user']): + primary_key = api.Object.user.primary_key.name + container = api.env.container_user + elif value.endswith(search_bases['group']): + primary_key = api.Object.group.primary_key.name + container = api.env.container_group + else: + api.log.warning('%s: value %s in attribute %s does not belong into any known container' % (pkey, value, attr)) + continue + + if not remote_entry.get(primary_key): + api.log.warning('%s: there is no primary key %s to migrate for %s' % (pkey, primary_key, attr)) + continue + + api.log.debug('converting DN value %s for %s in %s' % (value, attr, dn)) + rdnval = remote_entry[primary_key][0].lower() + entry_attrs[attr][ind] = DN((primary_key, rdnval), container, api.env.basedn) + + return dn + + +def _post_migrate_user(ldap, pkey, dn, entry_attrs, failed, config, ctx): + assert isinstance(dn, DN) + + if 'def_group_dn' in ctx: + _update_default_group(ldap, ctx, False) + + if 'description' in entry_attrs and NO_UPG_MAGIC in entry_attrs['description']: + entry_attrs['description'].remove(NO_UPG_MAGIC) + try: + update_attrs = ldap.get_entry(dn, ['description']) + update_attrs['description'] = entry_attrs['description'] + ldap.update_entry(update_attrs) + except (errors.EmptyModlist, errors.NotFound): + pass + +def _update_default_group(ldap, ctx, force): + migrate_cnt = ctx['migrate_cnt'] + group_dn = ctx['def_group_dn'] + + # Purposely let this fire when migrate_cnt == 0 so on re-running migration + # it can catch any users migrated but not added to the default group. + if force or migrate_cnt % 100 == 0: + s = datetime.datetime.now() + searchfilter = "(&(objectclass=posixAccount)(!(memberof=%s)))" % group_dn + try: + (result, truncated) = ldap.find_entries(searchfilter, + [''], DN(api.env.container_user, api.env.basedn), + scope=ldap.SCOPE_SUBTREE, time_limit=-1, size_limit=-1) + except errors.NotFound: + api.log.debug('All users have default group set') + return + + member_dns = [m.dn for m in result] + modlist = [(MOD_ADD, 'member', ldap.encode(member_dns))] + try: + with ldap.error_handler(): + ldap.conn.modify_s(str(group_dn), modlist) + except errors.DatabaseError as e: + api.log.error('Adding new members to default group failed: %s \n' + 'members: %s', e, ','.join(member_dns)) + + e = datetime.datetime.now() + d = e - s + mode = " (forced)" if force else "" + api.log.info('Adding %d users to group%s duration %s', + len(member_dns), mode, d) + +# GROUP MIGRATION CALLBACKS AND VARS + +def _pre_migrate_group(ldap, pkey, dn, entry_attrs, failed, config, ctx, **kwargs): + + def convert_members_rfc2307bis(member_attr, search_bases, overwrite=False): + """ + Convert DNs in member attributes to work in IPA. + """ + new_members = [] + entry_attrs.setdefault(member_attr, []) + for m in entry_attrs[member_attr]: + try: + m = DN(m) + except ValueError as e: + # This should be impossible unless the remote server + # doesn't enforce syntax checking. + api.log.error('Malformed DN %s: %s' % (m, e)) + continue + try: + rdnval = m[0].value + except IndexError: + api.log.error('Malformed DN %s has no RDN?' % m) + continue + + if m.endswith(search_bases['user']): + api.log.debug('migrating %s user %s', member_attr, m) + m = DN((api.Object.user.primary_key.name, rdnval), + api.env.container_user, api.env.basedn) + elif m.endswith(search_bases['group']): + api.log.debug('migrating %s group %s', member_attr, m) + m = DN((api.Object.group.primary_key.name, rdnval), + api.env.container_group, api.env.basedn) + else: + api.log.error('entry %s does not belong into any known container' % m) + continue + + new_members.append(m) + + del entry_attrs[member_attr] + if overwrite: + entry_attrs['member'] = [] + entry_attrs['member'] += new_members + + def convert_members_rfc2307(member_attr): + """ + Convert usernames in member attributes to work in IPA. + """ + new_members = [] + entry_attrs.setdefault(member_attr, []) + for m in entry_attrs[member_attr]: + memberdn = DN((api.Object.user.primary_key.name, m), + api.env.container_user, api.env.basedn) + new_members.append(memberdn) + entry_attrs['member'] = new_members + + assert isinstance(dn, DN) + attr_blacklist = ['memberofindirect','memberindirect'] + attr_blacklist.extend(kwargs.get('attr_blacklist', [])) + + schema = kwargs.get('schema', None) + entry_attrs['ipauniqueid'] = 'autogenerate' + if schema == 'RFC2307bis': + search_bases = kwargs.get('search_bases', None) + if not search_bases: + raise ValueError('Search bases not specified') + + convert_members_rfc2307bis('member', search_bases, overwrite=True) + convert_members_rfc2307bis('uniquemember', search_bases) + elif schema == 'RFC2307': + convert_members_rfc2307('memberuid') + else: + raise ValueError('Schema %s not supported' % schema) + + # do not migrate all attributes + for attr in attr_blacklist: + entry_attrs.pop(attr, None) + + # do not migrate all object classes + if 'objectclass' in entry_attrs: + for object_class in kwargs.get('oc_blacklist', []): + try: + entry_attrs['objectclass'].remove(object_class) + except ValueError: # object class not present + pass + + return dn + + +def _group_exc_callback(ldap, dn, entry_attrs, exc, options): + assert isinstance(dn, DN) + if isinstance(exc, errors.DuplicateEntry): + if options.get('groupoverwritegid', False) and \ + entry_attrs.get('gidnumber') is not None: + try: + new_entry_attrs = ldap.get_entry(dn, ['gidnumber']) + new_entry_attrs['gidnumber'] = entry_attrs['gidnumber'] + ldap.update_entry(new_entry_attrs) + except errors.EmptyModlist: + # no change to the GID + pass + # mark as success + return + elif not options.get('groupoverwritegid', False) and \ + entry_attrs.get('gidnumber') is not None: + msg = unicode(exc) + # add information about possibility to overwrite GID + msg = msg + unicode(_('. Check GID of the existing group. ' \ + 'Use --group-overwrite-gid option to overwrite the GID')) + raise errors.DuplicateEntry(message=msg) + + raise exc + +# DS MIGRATION PLUGIN + +def construct_filter(template, oc_list): + oc_subfilter = ''.join([ '(objectclass=%s)' % oc for oc in oc_list]) + return template % oc_subfilter + +def validate_ldapuri(ugettext, ldapuri): + m = re.match('^ldaps?://[-\w\.]+(:\d+)?$', ldapuri) + if not m: + err_msg = _('Invalid LDAP URI.') + raise errors.ValidationError(name='ldap_uri', error=err_msg) + + +@register() +class migrate_ds(Command): + __doc__ = _('Migrate users and groups from DS to IPA.') + + migrate_objects = { + # OBJECT_NAME: (search_filter, pre_callback, post_callback) + # + # OBJECT_NAME - is the name of an LDAPObject subclass + # search_filter - is the filter to retrieve objects from DS + # pre_callback - is called for each object just after it was + # retrieved from DS and before being added to IPA + # post_callback - is called for each object after it was added to IPA + # exc_callback - is called when adding entry to IPA raises an exception + # + # {pre, post}_callback parameters: + # ldap - ldap2 instance connected to IPA + # pkey - primary key value of the object (uid for users, etc.) + # dn - dn of the object as it (will be/is) stored in IPA + # entry_attrs - attributes of the object + # failed - a list of so-far failed objects + # config - IPA config entry attributes + # ctx - object context, used to pass data between callbacks + # + # If pre_callback return value evaluates to False, migration + # of the current object is aborted. + 'user': { + 'filter_template' : '(&(|%s)(uid=*))', + 'oc_option' : 'userobjectclass', + 'oc_blacklist_option' : 'userignoreobjectclass', + 'attr_blacklist_option' : 'userignoreattribute', + 'pre_callback' : _pre_migrate_user, + 'post_callback' : _post_migrate_user, + 'exc_callback' : None + }, + 'group': { + 'filter_template' : '(&(|%s)(cn=*))', + 'oc_option' : 'groupobjectclass', + 'oc_blacklist_option' : 'groupignoreobjectclass', + 'attr_blacklist_option' : 'groupignoreattribute', + 'pre_callback' : _pre_migrate_group, + 'post_callback' : None, + 'exc_callback' : _group_exc_callback, + }, + } + migrate_order = ('user', 'group') + + takes_args = ( + Str('ldapuri', validate_ldapuri, + cli_name='ldap_uri', + label=_('LDAP URI'), + doc=_('LDAP URI of DS server to migrate from'), + ), + Password('bindpw', + cli_name='password', + label=_('Password'), + confirm=False, + doc=_('bind password'), + ), + ) + + takes_options = ( + DNParam('binddn?', + cli_name='bind_dn', + label=_('Bind DN'), + default=DN(('cn', 'directory manager')), + autofill=True, + ), + DNParam('usercontainer', + cli_name='user_container', + label=_('User container'), + doc=_('DN of container for users in DS relative to base DN'), + default=DN(('ou', 'people')), + autofill=True, + ), + DNParam('groupcontainer', + cli_name='group_container', + label=_('Group container'), + doc=_('DN of container for groups in DS relative to base DN'), + default=DN(('ou', 'groups')), + autofill=True, + ), + Str('userobjectclass+', + cli_name='user_objectclass', + label=_('User object class'), + doc=_('Objectclasses used to search for user entries in DS'), + default=(u'person',), + autofill=True, + ), + Str('groupobjectclass+', + cli_name='group_objectclass', + label=_('Group object class'), + doc=_('Objectclasses used to search for group entries in DS'), + default=(u'groupOfUniqueNames', u'groupOfNames'), + autofill=True, + ), + Str('userignoreobjectclass*', + cli_name='user_ignore_objectclass', + label=_('Ignore user object class'), + doc=_('Objectclasses to be ignored for user entries in DS'), + default=tuple(), + autofill=True, + ), + Str('userignoreattribute*', + cli_name='user_ignore_attribute', + label=_('Ignore user attribute'), + doc=_('Attributes to be ignored for user entries in DS'), + default=tuple(), + autofill=True, + ), + Str('groupignoreobjectclass*', + cli_name='group_ignore_objectclass', + label=_('Ignore group object class'), + doc=_('Objectclasses to be ignored for group entries in DS'), + default=tuple(), + autofill=True, + ), + Str('groupignoreattribute*', + cli_name='group_ignore_attribute', + label=_('Ignore group attribute'), + doc=_('Attributes to be ignored for group entries in DS'), + default=tuple(), + autofill=True, + ), + Flag('groupoverwritegid', + cli_name='group_overwrite_gid', + label=_('Overwrite GID'), + doc=_('When migrating a group already existing in IPA domain overwrite the '\ + 'group GID and report as success'), + ), + StrEnum('schema?', + cli_name='schema', + label=_('LDAP schema'), + doc=_('The schema used on the LDAP server. Supported values are RFC2307 and RFC2307bis. The default is RFC2307bis'), + values=_supported_schemas, + default=_supported_schemas[0], + autofill=True, + ), + Flag('continue?', + label=_('Continue'), + doc=_('Continuous operation mode. Errors are reported but the process continues'), + default=False, + ), + DNParam('basedn?', + cli_name='base_dn', + label=_('Base DN'), + doc=_('Base DN on remote LDAP server'), + ), + Flag('compat?', + cli_name='with_compat', + label=_('Ignore compat plugin'), + doc=_('Allows migration despite the usage of compat plugin'), + default=False, + ), + Str('cacertfile?', + cli_name='ca_cert_file', + label=_('CA certificate'), + doc=_('Load CA certificate of LDAP server from FILE'), + default=None, + noextrawhitespace=False, + ), + Bool('use_def_group?', + cli_name='use_default_group', + label=_('Add to default group'), + doc=_('Add migrated users without a group to a default group ' + '(default: true)'), + default=True, + autofill=True, + ), + StrEnum('scope', + cli_name='scope', + label=_('Search scope'), + doc=_('LDAP search scope for users and groups: base, onelevel, or ' + 'subtree. Defaults to onelevel'), + values=tuple(_supported_scopes.keys()), + default=_default_scope, + autofill=True, + ), + ) + + has_output = ( + output.Output('result', + type=dict, + doc=_('Lists of objects migrated; categorized by type.'), + ), + output.Output('failed', + type=dict, + doc=_('Lists of objects that could not be migrated; categorized by type.'), + ), + output.Output('enabled', + type=bool, + doc=_('False if migration mode was disabled.'), + ), + output.Output('compat', + type=bool, + doc=_('False if migration fails because the compatibility plug-in is enabled.'), + ), + ) + + exclude_doc = _('%s to exclude from migration') + + truncated_err_msg = _('''\ +search results for objects to be migrated +have been truncated by the server; +migration process might be incomplete\n''') + + def get_options(self): + """ + Call get_options of the baseclass and add "exclude" options + for each type of object being migrated. + """ + for option in super(migrate_ds, self).get_options(): + yield option + for ldap_obj_name in self.migrate_objects: + ldap_obj = self.api.Object[ldap_obj_name] + name = 'exclude_%ss' % to_cli(ldap_obj_name) + doc = self.exclude_doc % ldap_obj.object_name_plural + yield Str( + '%s*' % name, cli_name=name, doc=doc, default=tuple(), + autofill=True + ) + + def normalize_options(self, options): + """ + Convert all "exclude" option values to lower-case. + + Also, empty List parameters are converted to None, but the migration + plugin doesn't like that - convert back to empty lists. + """ + names = ['userobjectclass', 'groupobjectclass', + 'userignoreobjectclass', 'userignoreattribute', + 'groupignoreobjectclass', 'groupignoreattribute'] + names.extend('exclude_%ss' % to_cli(n) for n in self.migrate_objects) + for name in names: + if options[name]: + options[name] = tuple( + v.lower() for v in options[name] + ) + else: + options[name] = tuple() + + def _get_search_bases(self, options, ds_base_dn, migrate_order): + search_bases = dict() + for ldap_obj_name in migrate_order: + container = options.get('%scontainer' % to_cli(ldap_obj_name)) + if container: + # Don't append base dn if user already appended it in the container dn + if container.endswith(ds_base_dn): + search_base = container + else: + search_base = DN(container, ds_base_dn) + else: + search_base = ds_base_dn + search_bases[ldap_obj_name] = search_base + return search_bases + + def migrate(self, ldap, config, ds_ldap, ds_base_dn, options): + """ + Migrate objects from DS to LDAP. + """ + assert isinstance(ds_base_dn, DN) + migrated = {} # {'OBJ': ['PKEY1', 'PKEY2', ...], ...} + failed = {} # {'OBJ': {'PKEY1': 'Failed 'cos blabla', ...}, ...} + search_bases = self._get_search_bases(options, ds_base_dn, self.migrate_order) + migration_start = datetime.datetime.now() + + scope = _supported_scopes[options.get('scope')] + + for ldap_obj_name in self.migrate_order: + ldap_obj = self.api.Object[ldap_obj_name] + + template = self.migrate_objects[ldap_obj_name]['filter_template'] + oc_list = options[to_cli(self.migrate_objects[ldap_obj_name]['oc_option'])] + search_filter = construct_filter(template, oc_list) + + exclude = options['exclude_%ss' % to_cli(ldap_obj_name)] + context = dict(ds_ldap = ds_ldap) + + migrated[ldap_obj_name] = [] + failed[ldap_obj_name] = {} + + try: + entries, truncated = ds_ldap.find_entries( + search_filter, ['*'], search_bases[ldap_obj_name], + scope, + time_limit=0, size_limit=-1, + search_refs=True # migrated DS may contain search references + ) + except errors.NotFound: + if not options.get('continue',False): + raise errors.NotFound( + reason=_('%(container)s LDAP search did not return any result ' + '(search base: %(search_base)s, ' + 'objectclass: %(objectclass)s)') + % {'container': ldap_obj_name, + 'search_base': search_bases[ldap_obj_name], + 'objectclass': ', '.join(oc_list)} + ) + else: + truncated = False + entries = [] + if truncated: + self.log.error( + '%s: %s' % ( + ldap_obj.name, self.truncated_err_msg + ) + ) + + blacklists = {} + for blacklist in ('oc_blacklist', 'attr_blacklist'): + blacklist_option = self.migrate_objects[ldap_obj_name][blacklist+'_option'] + if blacklist_option is not None: + blacklists[blacklist] = options.get(blacklist_option, tuple()) + else: + blacklists[blacklist] = tuple() + + # get default primary group for new users + if 'def_group_dn' not in context and options.get('use_def_group'): + def_group = config.get('ipadefaultprimarygroup') + context['def_group_dn'] = api.Object.group.get_dn(def_group) + try: + ldap.get_entry(context['def_group_dn'], ['gidnumber', 'cn']) + except errors.NotFound: + error_msg = _('Default group for new users not found') + raise errors.NotFound(reason=error_msg) + + context['has_upg'] = ldap.has_upg() + + valid_gids = set() + invalid_gids = set() + migrate_cnt = 0 + context['migrate_cnt'] = 0 + for entry_attrs in entries: + context['migrate_cnt'] = migrate_cnt + s = datetime.datetime.now() + + ava = entry_attrs.dn[0][0] + if ava.attr == ldap_obj.primary_key.name: + # In case if pkey attribute is in the migrated object DN + # and the original LDAP is multivalued, make sure that + # we pick the correct value (the unique one stored in DN) + pkey = ava.value.lower() + else: + pkey = entry_attrs[ldap_obj.primary_key.name][0].lower() + + if pkey in exclude: + continue + + entry_attrs.dn = ldap_obj.get_dn(pkey) + entry_attrs['objectclass'] = list( + set( + config.get( + ldap_obj.object_class_config, ldap_obj.object_class + ) + [o.lower() for o in entry_attrs['objectclass']] + ) + ) + entry_attrs[ldap_obj.primary_key.name][0] = entry_attrs[ldap_obj.primary_key.name][0].lower() + + callback = self.migrate_objects[ldap_obj_name]['pre_callback'] + if callable(callback): + try: + entry_attrs.dn = callback( + ldap, pkey, entry_attrs.dn, entry_attrs, + failed[ldap_obj_name], config, context, + schema=options['schema'], + search_bases=search_bases, + valid_gids=valid_gids, + invalid_gids=invalid_gids, + **blacklists + ) + if not entry_attrs.dn: + continue + except errors.NotFound as e: + failed[ldap_obj_name][pkey] = unicode(e.reason) + continue + + try: + ldap.add_entry(entry_attrs) + except errors.ExecutionError as e: + callback = self.migrate_objects[ldap_obj_name]['exc_callback'] + if callable(callback): + try: + callback( + ldap, entry_attrs.dn, entry_attrs, e, options) + except errors.ExecutionError as e: + failed[ldap_obj_name][pkey] = unicode(e) + continue + else: + failed[ldap_obj_name][pkey] = unicode(e) + continue + + migrated[ldap_obj_name].append(pkey) + + callback = self.migrate_objects[ldap_obj_name]['post_callback'] + if callable(callback): + callback( + ldap, pkey, entry_attrs.dn, entry_attrs, + failed[ldap_obj_name], config, context) + e = datetime.datetime.now() + d = e - s + total_dur = e - migration_start + migrate_cnt += 1 + if migrate_cnt > 0 and migrate_cnt % 100 == 0: + api.log.info("%d %ss migrated. %s elapsed." % (migrate_cnt, ldap_obj_name, total_dur)) + api.log.debug("%d %ss migrated, duration: %s (total %s)" % (migrate_cnt, ldap_obj_name, d, total_dur)) + + if 'def_group_dn' in context: + _update_default_group(ldap, context, True) + + return (migrated, failed) + + def execute(self, ldapuri, bindpw, **options): + ldap = self.api.Backend.ldap2 + self.normalize_options(options) + config = ldap.get_ipa_config() + + ds_base_dn = options.get('basedn') + if ds_base_dn is not None: + assert isinstance(ds_base_dn, DN) + + # check if migration mode is enabled + if config.get('ipamigrationenabled', ('FALSE', ))[0] == 'FALSE': + return dict(result={}, failed={}, enabled=False, compat=True) + + # connect to DS + ds_ldap = ldap2(self.api, ldap_uri=ldapuri) + + cacert = None + if options.get('cacertfile') is not None: + #store CA cert into file + tmp_ca_cert_f = write_tmp_file(options['cacertfile']) + cacert = tmp_ca_cert_f.name + + #start TLS connection + ds_ldap.connect(bind_dn=options['binddn'], bind_pw=bindpw, + tls_cacertfile=cacert) + + tmp_ca_cert_f.close() + else: + ds_ldap.connect(bind_dn=options['binddn'], bind_pw=bindpw) + + #check whether the compat plugin is enabled + if not options.get('compat'): + try: + ldap.get_entry(DN(('cn', 'compat'), (api.env.basedn))) + return dict(result={}, failed={}, enabled=True, compat=False) + except errors.NotFound: + pass + + if not ds_base_dn: + # retrieve base DN from remote LDAP server + entries, truncated = ds_ldap.find_entries( + '', ['namingcontexts', 'defaultnamingcontext'], DN(''), + ds_ldap.SCOPE_BASE, size_limit=-1, time_limit=0, + ) + if 'defaultnamingcontext' in entries[0]: + ds_base_dn = DN(entries[0]['defaultnamingcontext'][0]) + assert isinstance(ds_base_dn, DN) + else: + try: + ds_base_dn = DN(entries[0]['namingcontexts'][0]) + assert isinstance(ds_base_dn, DN) + except (IndexError, KeyError) as e: + raise Exception(str(e)) + + # migrate! + (migrated, failed) = self.migrate( + ldap, config, ds_ldap, ds_base_dn, options + ) + + return dict(result=migrated, failed=failed, enabled=True, compat=True) diff --git a/ipaserver/plugins/misc.py b/ipaserver/plugins/misc.py new file mode 100644 index 000000000..0628bb19b --- /dev/null +++ b/ipaserver/plugins/misc.py @@ -0,0 +1,138 @@ +# Authors: +# Jason Gerard DeRose <jderose@redhat.com> +# +# Copyright (C) 2008 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +import re +from ipalib import LocalOrRemote, _, ngettext +from ipalib.output import Output, summary +from ipalib import Flag +from ipalib.plugable import Registry + +__doc__ = _(""" +Misc plug-ins +""") + +register = Registry() + +# FIXME: We should not let env return anything in_server +# when mode == 'production'. This would allow an attacker to see the +# configuration of the server, potentially revealing compromising +# information. However, it's damn handy for testing/debugging. + + +@register() +class env(LocalOrRemote): + __doc__ = _('Show environment variables.') + + msg_summary = _('%(count)d variables') + + takes_args = ( + 'variables*', + ) + + takes_options = LocalOrRemote.takes_options + ( + Flag('all', + cli_name='all', + doc=_('retrieve and print all attributes from the server. Affects command output.'), + exclude='webui', + flags=['no_option', 'no_output'], + default=True, + ), + ) + + has_output = ( + Output('result', + type=dict, + doc=_('Dictionary mapping variable name to value'), + ), + Output('total', + type=int, + doc=_('Total number of variables env (>= count)'), + flags=['no_display'], + ), + Output('count', + type=int, + doc=_('Number of variables returned (<= total)'), + flags=['no_display'], + ), + summary, + ) + + def __find_keys(self, variables): + keys = set() + for query in variables: + if '*' in query: + pat = re.compile(query.replace('*', '.*') + '$') + for key in self.env: + if pat.match(key): + keys.add(key) + elif query in self.env: + keys.add(query) + return keys + + def execute(self, variables=None, **options): + if variables is None: + keys = self.env + else: + keys = self.__find_keys(variables) + ret = dict( + result=dict( + (key, self.env[key]) for key in keys + ), + count=len(keys), + total=len(self.env), + ) + if len(keys) > 1: + ret['summary'] = self.msg_summary % ret + else: + ret['summary'] = None + return ret + + + +@register() +class plugins(LocalOrRemote): + __doc__ = _('Show all loaded plugins.') + + msg_summary = ngettext( + '%(count)d plugin loaded', '%(count)d plugins loaded', 0 + ) + + takes_options = LocalOrRemote.takes_options + ( + Flag('all', + cli_name='all', + doc=_('retrieve and print all attributes from the server. Affects command output.'), + exclude='webui', + flags=['no_option', 'no_output'], + default=True, + ), + ) + + has_output = ( + Output('result', dict, 'Dictionary mapping plugin names to bases'), + Output('count', + type=int, + doc=_('Number of plugins loaded'), + ), + summary, + ) + + def execute(self, **options): + return dict( + result=dict(self.api.plugins), + ) diff --git a/ipaserver/plugins/netgroup.py b/ipaserver/plugins/netgroup.py new file mode 100644 index 000000000..f76a0ba3a --- /dev/null +++ b/ipaserver/plugins/netgroup.py @@ -0,0 +1,387 @@ +# Authors: +# Rob Crittenden <rcritten@redhat.com> +# Pavel Zuna <pzuna@redhat.com> +# +# Copyright (C) 2009 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +import six + +from ipalib import api, errors +from ipalib import Str, StrEnum, Flag +from ipalib.plugable import Registry +from .baseldap import ( + external_host_param, + add_external_pre_callback, + add_external_post_callback, + remove_external_post_callback, + LDAPObject, + LDAPCreate, + LDAPDelete, + LDAPUpdate, + LDAPSearch, + LDAPRetrieve, + LDAPAddMember, + LDAPRemoveMember) +from ipalib import _, ngettext +from .hbacrule import is_all +from ipapython.dn import DN + +if six.PY3: + unicode = str + +__doc__ = _(""" +Netgroups + +A netgroup is a group used for permission checking. It can contain both +user and host values. + +EXAMPLES: + + Add a new netgroup: + ipa netgroup-add --desc="NFS admins" admins + + Add members to the netgroup: + ipa netgroup-add-member --users=tuser1 --users=tuser2 admins + + Remove a member from the netgroup: + ipa netgroup-remove-member --users=tuser2 admins + + Display information about a netgroup: + ipa netgroup-show admins + + Delete a netgroup: + ipa netgroup-del admins +""") + +register = Registry() + +NETGROUP_PATTERN='^[a-zA-Z0-9_.][a-zA-Z0-9_.-]*$' +NETGROUP_PATTERN_ERRMSG='may only include letters, numbers, _, -, and .' + +# according to most common use cases the netgroup pattern should fit +# also the nisdomain pattern +NISDOMAIN_PATTERN=NETGROUP_PATTERN +NISDOMAIN_PATTERN_ERRMSG=NETGROUP_PATTERN_ERRMSG + +output_params = ( + Str('memberuser_user?', + label='Member User', + ), + Str('memberuser_group?', + label='Member Group', + ), + Str('memberhost_host?', + label=_('Member Host'), + ), + Str('memberhost_hostgroup?', + label='Member Hostgroup', + ), + ) + + +@register() +class netgroup(LDAPObject): + """ + Netgroup object. + """ + container_dn = api.env.container_netgroup + object_name = _('netgroup') + object_name_plural = _('netgroups') + object_class = ['ipaobject', 'ipaassociation', 'ipanisnetgroup'] + permission_filter_objectclasses = ['ipanisnetgroup'] + search_attributes = [ + 'cn', 'description', 'memberof', 'externalhost', 'nisdomainname', + 'memberuser', 'memberhost', 'member', 'usercategory', 'hostcategory', + ] + default_attributes = [ + 'cn', 'description', 'memberof', 'externalhost', 'nisdomainname', + 'memberuser', 'memberhost', 'member', 'memberindirect', + 'usercategory', 'hostcategory', + ] + uuid_attribute = 'ipauniqueid' + rdn_attribute = 'ipauniqueid' + attribute_members = { + 'member': ['netgroup'], + 'memberof': ['netgroup'], + 'memberindirect': ['netgroup'], + 'memberuser': ['user', 'group'], + 'memberhost': ['host', 'hostgroup'], + } + relationships = { + 'member': ('Member', '', 'no_'), + 'memberof': ('Member Of', 'in_', 'not_in_'), + 'memberindirect': ( + 'Indirect Member', None, 'no_indirect_' + ), + 'memberuser': ('Member', '', 'no_'), + 'memberhost': ('Member', '', 'no_'), + } + managed_permissions = { + 'System: Read Netgroups': { + 'replaces_global_anonymous_aci': True, + 'ipapermbindruletype': 'all', + 'ipapermright': {'read', 'search', 'compare'}, + 'ipapermdefaultattr': { + 'cn', 'description', 'hostcategory', 'ipaenabledflag', + 'ipauniqueid', 'nisdomainname', 'usercategory', 'objectclass', + }, + }, + 'System: Read Netgroup Membership': { + 'replaces_global_anonymous_aci': True, + 'ipapermbindruletype': 'all', + 'ipapermright': {'read', 'search', 'compare'}, + 'ipapermdefaultattr': { + 'externalhost', 'member', 'memberof', 'memberuser', + 'memberhost', 'objectclass', + }, + }, + 'System: Add Netgroups': { + 'ipapermright': {'add'}, + 'replaces': [ + '(target = "ldap:///ipauniqueid=*,cn=ng,cn=alt,$SUFFIX")(version 3.0;acl "permission:Add netgroups";allow (add) groupdn = "ldap:///cn=Add netgroups,cn=permissions,cn=pbac,$SUFFIX";)', + ], + 'default_privileges': {'Netgroups Administrators'}, + }, + 'System: Modify Netgroup Membership': { + 'ipapermright': {'write'}, + 'ipapermdefaultattr': { + 'externalhost', 'member', 'memberhost', 'memberuser' + }, + 'replaces': [ + '(targetattr = "memberhost || externalhost || memberuser || member")(target = "ldap:///ipauniqueid=*,cn=ng,cn=alt,$SUFFIX")(version 3.0;acl "permission:Modify netgroup membership";allow (write) groupdn = "ldap:///cn=Modify netgroup membership,cn=permissions,cn=pbac,$SUFFIX";)', + ], + 'default_privileges': {'Netgroups Administrators'}, + }, + 'System: Modify Netgroups': { + 'ipapermright': {'write'}, + 'ipapermdefaultattr': {'description'}, + 'replaces': [ + '(targetattr = "description")(target = "ldap:///ipauniqueid=*,cn=ng,cn=alt,$SUFFIX")(version 3.0; acl "permission:Modify netgroups";allow (write) groupdn = "ldap:///cn=Modify netgroups,cn=permissions,cn=pbac,$SUFFIX";)', + ], + 'default_privileges': {'Netgroups Administrators'}, + }, + 'System: Remove Netgroups': { + 'ipapermright': {'delete'}, + 'replaces': [ + '(target = "ldap:///ipauniqueid=*,cn=ng,cn=alt,$SUFFIX")(version 3.0;acl "permission:Remove netgroups";allow (delete) groupdn = "ldap:///cn=Remove netgroups,cn=permissions,cn=pbac,$SUFFIX";)', + ], + 'default_privileges': {'Netgroups Administrators'}, + }, + 'System: Read Netgroup Compat Tree': { + 'non_object': True, + 'ipapermbindruletype': 'anonymous', + 'ipapermlocation': api.env.basedn, + 'ipapermtarget': DN('cn=ng', 'cn=compat', api.env.basedn), + 'ipapermright': {'read', 'search', 'compare'}, + 'ipapermdefaultattr': { + 'objectclass', 'cn', 'membernisnetgroup', 'nisnetgrouptriple', + }, + }, + } + + label = _('Netgroups') + label_singular = _('Netgroup') + + takes_params = ( + Str('cn', + pattern=NETGROUP_PATTERN, + pattern_errmsg=NETGROUP_PATTERN_ERRMSG, + cli_name='name', + label=_('Netgroup name'), + primary_key=True, + normalizer=lambda value: value.lower(), + ), + Str('description?', + cli_name='desc', + label=_('Description'), + doc=_('Netgroup description'), + ), + Str('nisdomainname?', + pattern=NISDOMAIN_PATTERN, + pattern_errmsg=NISDOMAIN_PATTERN_ERRMSG, + cli_name='nisdomain', + label=_('NIS domain name'), + ), + Str('ipauniqueid?', + cli_name='uuid', + label='IPA unique ID', + doc=_('IPA unique ID'), + flags=['no_create', 'no_update'], + ), + StrEnum('usercategory?', + cli_name='usercat', + label=_('User category'), + doc=_('User category the rule applies to'), + values=(u'all', ), + ), + StrEnum('hostcategory?', + cli_name='hostcat', + label=_('Host category'), + doc=_('Host category the rule applies to'), + values=(u'all', ), + ), + external_host_param, + ) + + +@register() +class netgroup_add(LDAPCreate): + __doc__ = _('Add a new netgroup.') + + has_output_params = LDAPCreate.has_output_params + output_params + msg_summary = _('Added netgroup "%(value)s"') + + msg_collision = _(u'hostgroup with name "%s" already exists. ' \ + u'Hostgroups and netgroups share a common namespace') + + def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options): + assert isinstance(dn, DN) + entry_attrs.setdefault('nisdomainname', self.api.env.domain) + + try: + test_dn = self.obj.get_dn(keys[-1]) + netgroup = ldap.get_entry(test_dn, ['objectclass']) + if 'mepManagedEntry' in netgroup.get('objectclass', []): + raise errors.DuplicateEntry(message=unicode(self.msg_collision % keys[-1])) + else: + self.obj.handle_duplicate_entry(*keys) + except errors.NotFound: + pass + + try: + # when enabled, a managed netgroup is created for every hostgroup + # make sure that we don't create a collision if the plugin is + # (temporarily) disabled + api.Object['hostgroup'].get_dn_if_exists(keys[-1]) + raise errors.DuplicateEntry(message=unicode(self.msg_collision % keys[-1])) + except errors.NotFound: + pass + + return dn + + +@register() +class netgroup_del(LDAPDelete): + __doc__ = _('Delete a netgroup.') + + msg_summary = _('Deleted netgroup "%(value)s"') + + + +@register() +class netgroup_mod(LDAPUpdate): + __doc__ = _('Modify a netgroup.') + + has_output_params = LDAPUpdate.has_output_params + output_params + msg_summary = _('Modified netgroup "%(value)s"') + + def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options): + assert isinstance(dn, DN) + try: + entry_attrs = ldap.get_entry(dn, attrs_list) + dn = entry_attrs.dn + except errors.NotFound: + self.obj.handle_not_found(*keys) + if is_all(options, 'usercategory') and 'memberuser' in entry_attrs: + raise errors.MutuallyExclusiveError(reason=_("user category cannot be set to 'all' while there are allowed users")) + if is_all(options, 'hostcategory') and 'memberhost' in entry_attrs: + raise errors.MutuallyExclusiveError(reason=_("host category cannot be set to 'all' while there are allowed hosts")) + return dn + + +@register() +class netgroup_find(LDAPSearch): + __doc__ = _('Search for a netgroup.') + + member_attributes = ['member', 'memberuser', 'memberhost', 'memberof'] + has_output_params = LDAPSearch.has_output_params + output_params + msg_summary = ngettext( + '%(count)d netgroup matched', '%(count)d netgroups matched', 0 + ) + + takes_options = LDAPSearch.takes_options + ( + Flag('private', + exclude='webui', + flags=['no_option', 'no_output'], + ), + Flag('managed', + cli_name='managed', + doc=_('search for managed groups'), + default_from=lambda private: private, + ), + ) + + def pre_callback(self, ldap, filter, attrs_list, base_dn, scope, *args, **options): + assert isinstance(base_dn, DN) + # Do not display private mepManagedEntry netgroups by default + # If looking for managed groups, we need to omit the negation search filter + + search_kw = {} + search_kw['objectclass'] = ['mepManagedEntry'] + if not options['managed']: + local_filter = ldap.make_filter(search_kw, rules=ldap.MATCH_NONE) + else: + local_filter = ldap.make_filter(search_kw, rules=ldap.MATCH_ALL) + filter = ldap.combine_filters((local_filter, filter), rules=ldap.MATCH_ALL) + return (filter, base_dn, scope) + + +@register() +class netgroup_show(LDAPRetrieve): + __doc__ = _('Display information about a netgroup.') + + has_output_params = LDAPRetrieve.has_output_params + output_params + + +@register() +class netgroup_add_member(LDAPAddMember): + __doc__ = _('Add members to a netgroup.') + + member_attributes = ['memberuser', 'memberhost', 'member'] + has_output_params = LDAPAddMember.has_output_params + output_params + + def pre_callback(self, ldap, dn, found, not_found, *keys, **options): + assert isinstance(dn, DN) + return add_external_pre_callback('host', ldap, dn, keys, options) + + def post_callback(self, ldap, completed, failed, dn, entry_attrs, + *keys, **options): + assert isinstance(dn, DN) + return add_external_post_callback(ldap, dn, entry_attrs, + failed=failed, + completed=completed, + memberattr='memberhost', + membertype='host', + externalattr='externalhost') + + +@register() +class netgroup_remove_member(LDAPRemoveMember): + __doc__ = _('Remove members from a netgroup.') + + member_attributes = ['memberuser', 'memberhost', 'member'] + has_output_params = LDAPRemoveMember.has_output_params + output_params + + def post_callback(self, ldap, completed, failed, dn, entry_attrs, + *keys, **options): + assert isinstance(dn, DN) + return remove_external_post_callback(ldap, dn, entry_attrs, + failed=failed, + completed=completed, + memberattr='memberhost', + membertype='host', + externalattr='externalhost') diff --git a/ipaserver/plugins/otp.py b/ipaserver/plugins/otp.py new file mode 100644 index 000000000..306c87388 --- /dev/null +++ b/ipaserver/plugins/otp.py @@ -0,0 +1,7 @@ +# +# Copyright (C) 2016 FreeIPA Contributors see COPYING for license +# + +from ipalib.text import _ + +__doc__ = _('One time password commands') diff --git a/ipaserver/plugins/otpconfig.py b/ipaserver/plugins/otpconfig.py new file mode 100644 index 000000000..c7710468f --- /dev/null +++ b/ipaserver/plugins/otpconfig.py @@ -0,0 +1,121 @@ +# Authors: +# Nathaniel McCallum <npmccallum@redhat.com> +# +# Copyright (C) 2014 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +from ipalib import _, api, Int +from ipalib.plugable import Registry +from .baseldap import DN, LDAPObject, LDAPUpdate, LDAPRetrieve + +__doc__ = _(""" +OTP configuration + +Manage the default values that IPA uses for OTP tokens. + +EXAMPLES: + + Show basic OTP configuration: + ipa otpconfig-show + + Show all OTP configuration options: + ipa otpconfig-show --all + + Change maximum TOTP authentication window to 10 minutes: + ipa otpconfig-mod --totp-auth-window=600 + + Change maximum TOTP synchronization window to 12 hours: + ipa otpconfig-mod --totp-sync-window=43200 + + Change maximum HOTP authentication window to 5: + ipa hotpconfig-mod --hotp-auth-window=5 + + Change maximum HOTP synchronization window to 50: + ipa hotpconfig-mod --hotp-sync-window=50 +""") + +register = Registry() + +topic = 'otp' + + +@register() +class otpconfig(LDAPObject): + object_name = _('OTP configuration options') + default_attributes = [ + 'ipatokentotpauthwindow', + 'ipatokentotpsyncwindow', + 'ipatokenhotpauthwindow', + 'ipatokenhotpsyncwindow', + ] + + container_dn = DN(('cn', 'otp'), ('cn', 'etc')) + permission_filter_objectclasses = ['ipatokenotpconfig'] + managed_permissions = { + 'System: Read OTP Configuration': { + 'replaces_global_anonymous_aci': True, + 'ipapermbindruletype': 'all', + 'ipapermright': {'read', 'search', 'compare'}, + 'ipapermdefaultattr': { + 'ipatokentotpauthwindow', 'ipatokentotpsyncwindow', + 'ipatokenhotpauthwindow', 'ipatokenhotpsyncwindow', + 'cn', + }, + }, + } + + label = _('OTP Configuration') + label_singular = _('OTP Configuration') + + takes_params = ( + Int('ipatokentotpauthwindow', + cli_name='totp_auth_window', + label=_('TOTP authentication Window'), + doc=_('TOTP authentication time variance (seconds)'), + minvalue=5, + ), + Int('ipatokentotpsyncwindow', + cli_name='totp_sync_window', + label=_('TOTP Synchronization Window'), + doc=_('TOTP synchronization time variance (seconds)'), + minvalue=5, + ), + Int('ipatokenhotpauthwindow', + cli_name='hotp_auth_window', + label=_('HOTP Authentication Window'), + doc=_('HOTP authentication skip-ahead'), + minvalue=1, + ), + Int('ipatokenhotpsyncwindow', + cli_name='hotp_sync_window', + label=_('HOTP Synchronization Window'), + doc=_('HOTP synchronization skip-ahead'), + minvalue=1, + ), + ) + + def get_dn(self, *keys, **kwargs): + return self.container_dn + api.env.basedn + + +@register() +class otpconfig_mod(LDAPUpdate): + __doc__ = _('Modify OTP configuration options.') + + +@register() +class otpconfig_show(LDAPRetrieve): + __doc__ = _('Show the current OTP configuration.') diff --git a/ipaserver/plugins/otptoken.py b/ipaserver/plugins/otptoken.py new file mode 100644 index 000000000..fda05ce0b --- /dev/null +++ b/ipaserver/plugins/otptoken.py @@ -0,0 +1,464 @@ +# Authors: +# Nathaniel McCallum <npmccallum@redhat.com> +# +# Copyright (C) 2013 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +from .baseldap import LDAPObject, LDAPAddMember, LDAPRemoveMember +from .baseldap import LDAPCreate, LDAPDelete, LDAPUpdate, LDAPSearch, LDAPRetrieve +from ipalib import api, Int, Str, Bool, DateTime, Flag, Bytes, IntEnum, StrEnum, _, ngettext +from ipalib.plugable import Registry +from ipalib.errors import ( + PasswordMismatch, + ConversionError, + NotFound, + ValidationError) +from ipalib.request import context +from ipapython.dn import DN + +import base64 +import uuid +import os + +import six +from six.moves import urllib + +if six.PY3: + unicode = str + +__doc__ = _(""" +OTP Tokens +""") + _(""" +Manage OTP tokens. +""") + _(""" +IPA supports the use of OTP tokens for multi-factor authentication. This +code enables the management of OTP tokens. +""") + _(""" +EXAMPLES: +""") + _(""" + Add a new token: + ipa otptoken-add --type=totp --owner=jdoe --desc="My soft token" +""") + _(""" + Examine the token: + ipa otptoken-show a93db710-a31a-4639-8647-f15b2c70b78a +""") + _(""" + Change the vendor: + ipa otptoken-mod a93db710-a31a-4639-8647-f15b2c70b78a --vendor="Red Hat" +""") + _(""" + Delete a token: + ipa otptoken-del a93db710-a31a-4639-8647-f15b2c70b78a +""") + +register = Registry() + +topic = 'otp' + +TOKEN_TYPES = { + u'totp': ['ipatokentotpclockoffset', 'ipatokentotptimestep'], + u'hotp': ['ipatokenhotpcounter'] +} + +# NOTE: For maximum compatibility, KEY_LENGTH % 5 == 0 +KEY_LENGTH = 20 + +class OTPTokenKey(Bytes): + """A binary password type specified in base32.""" + + password = True + + kwargs = Bytes.kwargs + ( + ('confirm', bool, True), + ) + + def _convert_scalar(self, value, index=None): + if isinstance(value, (tuple, list)) and len(value) == 2: + (p1, p2) = value + if p1 != p2: + raise PasswordMismatch(name=self.name) + value = p1 + + if isinstance(value, unicode): + try: + value = base64.b32decode(value, True) + except TypeError as e: + raise ConversionError(name=self.name, error=str(e)) + + return super(OTPTokenKey, self)._convert_scalar(value) + +def _convert_owner(userobj, entry_attrs, options): + if 'ipatokenowner' in entry_attrs and not options.get('raw', False): + entry_attrs['ipatokenowner'] = [userobj.get_primary_key_from_dn(o) + for o in entry_attrs['ipatokenowner']] + +def _normalize_owner(userobj, entry_attrs): + owner = entry_attrs.get('ipatokenowner', None) + if owner: + try: + entry_attrs['ipatokenowner'] = userobj._normalize_manager(owner)[0] + except NotFound: + userobj.handle_not_found(owner) + +def _check_interval(not_before, not_after): + if not_before and not_after: + return not_before <= not_after + return True + +def _set_token_type(entry_attrs, **options): + klasses = [x.lower() for x in entry_attrs.get('objectclass', [])] + for ttype in TOKEN_TYPES.keys(): + cls = 'ipatoken' + ttype + if cls.lower() in klasses: + entry_attrs['type'] = ttype.upper() + + if not options.get('all', False) or options.get('pkey_only', False): + entry_attrs.pop('objectclass', None) + +@register() +class otptoken(LDAPObject): + """ + OTP Token object. + """ + container_dn = api.env.container_otp + object_name = _('OTP token') + object_name_plural = _('OTP tokens') + object_class = ['ipatoken'] + possible_objectclasses = ['ipatokentotp', 'ipatokenhotp'] + default_attributes = [ + 'ipatokenuniqueid', 'description', 'ipatokenowner', + 'ipatokendisabled', 'ipatokennotbefore', 'ipatokennotafter', + 'ipatokenvendor', 'ipatokenmodel', 'ipatokenserial', 'managedby' + ] + attribute_members = { + 'managedby': ['user'], + } + relationships = { + 'managedby': ('Managed by', 'man_by_', 'not_man_by_'), + } + rdn_is_primary_key = True + + label = _('OTP Tokens') + label_singular = _('OTP Token') + + takes_params = ( + Str('ipatokenuniqueid', + cli_name='id', + label=_('Unique ID'), + primary_key=True, + flags=('optional_create'), + ), + StrEnum('type?', + label=_('Type'), + doc=_('Type of the token'), + default=u'totp', + autofill=True, + values=tuple(list(TOKEN_TYPES) + [x.upper() for x in TOKEN_TYPES]), + flags=('virtual_attribute', 'no_update'), + ), + Str('description?', + cli_name='desc', + label=_('Description'), + doc=_('Token description (informational only)'), + ), + Str('ipatokenowner?', + cli_name='owner', + label=_('Owner'), + doc=_('Assigned user of the token (default: self)'), + ), + Str('managedby_user?', + label=_('Manager'), + doc=_('Assigned manager of the token (default: self)'), + flags=['no_create', 'no_update', 'no_search'], + ), + Bool('ipatokendisabled?', + cli_name='disabled', + label=_('Disabled'), + doc=_('Mark the token as disabled (default: false)') + ), + DateTime('ipatokennotbefore?', + cli_name='not_before', + label=_('Validity start'), + doc=_('First date/time the token can be used'), + ), + DateTime('ipatokennotafter?', + cli_name='not_after', + label=_('Validity end'), + doc=_('Last date/time the token can be used'), + ), + Str('ipatokenvendor?', + cli_name='vendor', + label=_('Vendor'), + doc=_('Token vendor name (informational only)'), + ), + Str('ipatokenmodel?', + cli_name='model', + label=_('Model'), + doc=_('Token model (informational only)'), + ), + Str('ipatokenserial?', + cli_name='serial', + label=_('Serial'), + doc=_('Token serial (informational only)'), + ), + OTPTokenKey('ipatokenotpkey?', + cli_name='key', + label=_('Key'), + doc=_('Token secret (Base32; default: random)'), + default_from=lambda: os.urandom(KEY_LENGTH), + autofill=True, + flags=('no_display', 'no_update', 'no_search'), + ), + StrEnum('ipatokenotpalgorithm?', + cli_name='algo', + label=_('Algorithm'), + doc=_('Token hash algorithm'), + default=u'sha1', + autofill=True, + flags=('no_update'), + values=(u'sha1', u'sha256', u'sha384', u'sha512'), + ), + IntEnum('ipatokenotpdigits?', + cli_name='digits', + label=_('Digits'), + doc=_('Number of digits each token code will have'), + values=(6, 8), + default=6, + autofill=True, + flags=('no_update'), + ), + Int('ipatokentotpclockoffset?', + cli_name='offset', + label=_('Clock offset'), + doc=_('TOTP token / FreeIPA server time difference'), + default=0, + autofill=True, + flags=('no_update'), + ), + Int('ipatokentotptimestep?', + cli_name='interval', + label=_('Clock interval'), + doc=_('Length of TOTP token code validity'), + default=30, + autofill=True, + minvalue=5, + flags=('no_update'), + ), + Int('ipatokenhotpcounter?', + cli_name='counter', + label=_('Counter'), + doc=_('Initial counter for the HOTP token'), + default=0, + autofill=True, + minvalue=0, + flags=('no_update'), + ), + ) + + +@register() +class otptoken_add(LDAPCreate): + __doc__ = _('Add a new OTP token.') + msg_summary = _('Added OTP token "%(value)s"') + + takes_options = LDAPCreate.takes_options + ( + Flag('qrcode?', label=_('(deprecated)'), flags=('no_option')), + Flag('no_qrcode', label=_('Do not display QR code'), default=False), + ) + + has_output_params = LDAPCreate.has_output_params + ( + Str('uri?', label=_('URI')), + ) + + def execute(self, ipatokenuniqueid=None, **options): + return super(otptoken_add, self).execute(ipatokenuniqueid, **options) + + def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options): + # Fill in a default UUID when not specified. + if entry_attrs.get('ipatokenuniqueid', None) is None: + entry_attrs['ipatokenuniqueid'] = str(uuid.uuid4()) + dn = DN("ipatokenuniqueid=%s" % entry_attrs['ipatokenuniqueid'], dn) + + if not _check_interval(options.get('ipatokennotbefore', None), + options.get('ipatokennotafter', None)): + raise ValidationError(name='not_after', + error='is before the validity start') + + # Set the object class and defaults for specific token types + options['type'] = options['type'].lower() + entry_attrs['objectclass'] = otptoken.object_class + ['ipatoken' + options['type']] + for ttype, tattrs in TOKEN_TYPES.items(): + if ttype != options['type']: + for tattr in tattrs: + if tattr in entry_attrs: + del entry_attrs[tattr] + + # If owner was not specified, default to the person adding this token. + # If managedby was not specified, attempt a sensible default. + if 'ipatokenowner' not in entry_attrs or 'managedby' not in entry_attrs: + result = self.api.Command.user_find( + whoami=True, no_members=False)['result'] + if result: + cur_uid = result[0]['uid'][0] + prev_uid = entry_attrs.setdefault('ipatokenowner', cur_uid) + if cur_uid == prev_uid: + entry_attrs.setdefault('managedby', result[0]['dn']) + + # Resolve the owner's dn + _normalize_owner(self.api.Object.user, entry_attrs) + + # Get the issuer for the URI + owner = entry_attrs.get('ipatokenowner', None) + issuer = api.env.realm + if owner is not None: + try: + issuer = ldap.get_entry(owner, ['krbprincipalname'])['krbprincipalname'][0] + except (NotFound, IndexError): + pass + + # Build the URI parameters + args = {} + args['issuer'] = issuer + args['secret'] = base64.b32encode(entry_attrs['ipatokenotpkey']) + args['digits'] = entry_attrs['ipatokenotpdigits'] + args['algorithm'] = entry_attrs['ipatokenotpalgorithm'].upper() + if options['type'] == 'totp': + args['period'] = entry_attrs['ipatokentotptimestep'] + elif options['type'] == 'hotp': + args['counter'] = entry_attrs['ipatokenhotpcounter'] + + # Build the URI + label = urllib.parse.quote(entry_attrs['ipatokenuniqueid']) + parameters = urllib.parse.urlencode(args) + uri = u'otpauth://%s/%s:%s?%s' % (options['type'], issuer, label, parameters) + setattr(context, 'uri', uri) + + attrs_list.append("objectclass") + return dn + + def post_callback(self, ldap, dn, entry_attrs, *keys, **options): + entry_attrs['uri'] = getattr(context, 'uri') + _set_token_type(entry_attrs, **options) + _convert_owner(self.api.Object.user, entry_attrs, options) + return super(otptoken_add, self).post_callback(ldap, dn, entry_attrs, *keys, **options) + + +@register() +class otptoken_del(LDAPDelete): + __doc__ = _('Delete an OTP token.') + msg_summary = _('Deleted OTP token "%(value)s"') + + +@register() +class otptoken_mod(LDAPUpdate): + __doc__ = _('Modify a OTP token.') + msg_summary = _('Modified OTP token "%(value)s"') + + def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options): + notafter_set = True + notbefore = options.get('ipatokennotbefore', None) + notafter = options.get('ipatokennotafter', None) + # notbefore xor notafter, exactly one of them is not None + if bool(notbefore) ^ bool(notafter): + result = self.api.Command.otptoken_show(keys[-1])['result'] + if notbefore is None: + notbefore = result.get('ipatokennotbefore', [None])[0] + if notafter is None: + notafter_set = False + notafter = result.get('ipatokennotafter', [None])[0] + + if not _check_interval(notbefore, notafter): + if notafter_set: + raise ValidationError(name='not_after', + error='is before the validity start') + else: + raise ValidationError(name='not_before', + error='is after the validity end') + _normalize_owner(self.api.Object.user, entry_attrs) + + # ticket #4681: if the owner of the token is changed and the + # user also manages this token, then we should automatically + # set the 'managedby' attribute to the new owner + if 'ipatokenowner' in entry_attrs and 'managedby' not in entry_attrs: + new_owner = entry_attrs.get('ipatokenowner', None) + prev_entry = ldap.get_entry(dn, attrs_list=['ipatokenowner', + 'managedby']) + prev_owner = prev_entry.get('ipatokenowner', None) + prev_managedby = prev_entry.get('managedby', None) + + if (new_owner != prev_owner) and (prev_owner == prev_managedby): + entry_attrs.setdefault('managedby', new_owner) + + attrs_list.append("objectclass") + return dn + + def post_callback(self, ldap, dn, entry_attrs, *keys, **options): + _set_token_type(entry_attrs, **options) + _convert_owner(self.api.Object.user, entry_attrs, options) + return super(otptoken_mod, self).post_callback(ldap, dn, entry_attrs, *keys, **options) + + +@register() +class otptoken_find(LDAPSearch): + __doc__ = _('Search for OTP token.') + msg_summary = ngettext('%(count)d OTP token matched', '%(count)d OTP tokens matched', 0) + + def pre_callback(self, ldap, filters, attrs_list, *args, **kwargs): + # This is a hack, but there is no other way to + # replace the objectClass when searching + type = kwargs.get('type', '') + if type not in TOKEN_TYPES: + type = '' + filters = filters.replace("(objectclass=ipatoken)", + "(objectclass=ipatoken%s)" % type) + + attrs_list.append("objectclass") + return super(otptoken_find, self).pre_callback(ldap, filters, attrs_list, *args, **kwargs) + + def args_options_2_entry(self, *args, **options): + entry = super(otptoken_find, self).args_options_2_entry(*args, **options) + _normalize_owner(self.api.Object.user, entry) + return entry + + def post_callback(self, ldap, entries, truncated, *args, **options): + for entry in entries: + _set_token_type(entry, **options) + _convert_owner(self.api.Object.user, entry, options) + return super(otptoken_find, self).post_callback(ldap, entries, truncated, *args, **options) + + +@register() +class otptoken_show(LDAPRetrieve): + __doc__ = _('Display information about an OTP token.') + + def pre_callback(self, ldap, dn, attrs_list, *keys, **options): + attrs_list.append("objectclass") + return super(otptoken_show, self).pre_callback(ldap, dn, attrs_list, *keys, **options) + + def post_callback(self, ldap, dn, entry_attrs, *keys, **options): + _set_token_type(entry_attrs, **options) + _convert_owner(self.api.Object.user, entry_attrs, options) + return super(otptoken_show, self).post_callback(ldap, dn, entry_attrs, *keys, **options) + +@register() +class otptoken_add_managedby(LDAPAddMember): + __doc__ = _('Add users that can manage this token.') + + member_attributes = ['managedby'] + +@register() +class otptoken_remove_managedby(LDAPRemoveMember): + __doc__ = _('Remove users that can manage this token.') + + member_attributes = ['managedby'] diff --git a/ipaserver/plugins/passwd.py b/ipaserver/plugins/passwd.py new file mode 100644 index 000000000..c4e220815 --- /dev/null +++ b/ipaserver/plugins/passwd.py @@ -0,0 +1,139 @@ +# Authors: +# Rob Crittenden <rcritten@redhat.com> +# +# Copyright (C) 2008 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +from ipalib import api, errors, krb_utils +from ipalib import Command +from ipalib import Str, Password +from ipalib import _ +from ipalib import output +from ipalib.plugable import Registry +from .baseuser import validate_principal, normalize_principal +from ipalib.request import context +from ipapython.dn import DN + +__doc__ = _(""" +Set a user's password + +If someone other than a user changes that user's password (e.g., Helpdesk +resets it) then the password will need to be changed the first time it +is used. This is so the end-user is the only one who knows the password. + +The IPA password policy controls how often a password may be changed, +what strength requirements exist, and the length of the password history. + +EXAMPLES: + + To reset your own password: + ipa passwd + + To change another user's password: + ipa passwd tuser1 +""") + +register = Registry() + +# We only need to prompt for the current password when changing a password +# for yourself, but the parameter is still required +MAGIC_VALUE = u'CHANGING_PASSWORD_FOR_ANOTHER_USER' + +def get_current_password(principal): + """ + If the user is changing their own password then return None so the + current password is prompted for, otherwise return a fixed value to + be ignored later. + """ + current_principal = krb_utils.get_principal() + if current_principal == normalize_principal(principal): + return None + else: + return MAGIC_VALUE + +@register() +class passwd(Command): + __doc__ = _("Set a user's password.") + + takes_args = ( + Str('principal', validate_principal, + cli_name='user', + label=_('User name'), + primary_key=True, + autofill=True, + default_from=lambda: krb_utils.get_principal(), + normalizer=lambda value: normalize_principal(value), + ), + Password('password', + label=_('New Password'), + ), + Password('current_password', + label=_('Current Password'), + confirm=False, + default_from=lambda principal: get_current_password(principal), + autofill=True, + sortorder=-1, + ), + ) + + takes_options = ( + Password('otp?', + label=_('OTP'), + doc=_('One Time Password'), + confirm=False, + ), + ) + + has_output = output.standard_value + msg_summary = _('Changed password for "%(value)s"') + + def execute(self, principal, password, current_password, **options): + """ + Execute the passwd operation. + + The dn should not be passed as a keyword argument as it is constructed + by this method. + + Returns the entry + + :param principal: The login name or principal of the user + :param password: the new password + :param current_password: the existing password, if applicable + """ + ldap = self.api.Backend.ldap2 + + entry_attrs = ldap.find_entry_by_attr( + 'krbprincipalname', principal, 'posixaccount', [''], + DN(api.env.container_user, api.env.basedn) + ) + + if principal == getattr(context, 'principal') and \ + current_password == MAGIC_VALUE: + # No cheating + self.log.warning('User attempted to change password using magic value') + raise errors.ACIError(info=_('Invalid credentials')) + + if current_password == MAGIC_VALUE: + ldap.modify_password(entry_attrs.dn, password) + else: + otp = options.get('otp') + ldap.modify_password(entry_attrs.dn, password, current_password, otp) + + return dict( + result=True, + value=principal, + ) + diff --git a/ipaserver/plugins/permission.py b/ipaserver/plugins/permission.py new file mode 100644 index 000000000..9f19358da --- /dev/null +++ b/ipaserver/plugins/permission.py @@ -0,0 +1,1395 @@ +# Authors: +# Petr Viktorin <pviktori@redhat.com> +# +# Copyright (C) 2013 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +import re +import traceback + +import six + +from . import baseldap +from .privilege import validate_permission_to_privilege +from ipalib import errors +from ipalib.parameters import Str, StrEnum, DNParam, Flag +from ipalib import api, _, ngettext +from ipalib.plugable import Registry +from ipalib.capabilities import client_has_capability +from ipalib.aci import ACI +from ipapython.dn import DN +from ipalib.request import context + +if six.PY3: + unicode = str + +__doc__ = _(""" +Permissions +""") + _(""" +A permission enables fine-grained delegation of rights. A permission is +a human-readable wrapper around a 389-ds Access Control Rule, +or instruction (ACI). +A permission grants the right to perform a specific task such as adding a +user, modifying a group, etc. +""") + _(""" +A permission may not contain other permissions. +""") + _(""" +* A permission grants access to read, write, add, delete, read, search, + or compare. +* A privilege combines similar permissions (for example all the permissions + needed to add a user). +* A role grants a set of privileges to users, groups, hosts or hostgroups. +""") + _(""" +A permission is made up of a number of different parts: + +1. The name of the permission. +2. The target of the permission. +3. The rights granted by the permission. +""") + _(""" +Rights define what operations are allowed, and may be one or more +of the following: +1. write - write one or more attributes +2. read - read one or more attributes +3. search - search on one or more attributes +4. compare - compare one or more attributes +5. add - add a new entry to the tree +6. delete - delete an existing entry +7. all - all permissions are granted +""") + _(""" +Note the distinction between attributes and entries. The permissions are +independent, so being able to add a user does not mean that the user will +be editable. +""") + _(""" +There are a number of allowed targets: +1. subtree: a DN; the permission applies to the subtree under this DN +2. target filter: an LDAP filter +3. target: DN with possible wildcards, specifies entries permission applies to +""") + _(""" +Additionally, there are the following convenience options. +Setting one of these options will set the corresponding attribute(s). +1. type: a type of object (user, group, etc); sets subtree and target filter. +2. memberof: apply to members of a group; sets target filter +3. targetgroup: grant access to modify a specific group (such as granting + the rights to manage group membership); sets target. +""") + _(""" +Managed permissions +""") + _(""" +Permissions that come with IPA by default can be so-called "managed" +permissions. These have a default set of attributes they apply to, +but the administrator can add/remove individual attributes to/from the set. +""") + _(""" +Deleting or renaming a managed permission, as well as changing its target, +is not allowed. +""") + _(""" +EXAMPLES: +""") + _(""" + Add a permission that grants the creation of users: + ipa permission-add --type=user --permissions=add "Add Users" +""") + _(""" + Add a permission that grants the ability to manage group membership: + ipa permission-add --attrs=member --permissions=write --type=group "Manage Group Members" +""") + +register = Registry() + +_DEPRECATED_OPTION_ALIASES = { + 'permissions': 'ipapermright', + 'filter': 'extratargetfilter', + 'subtree': 'ipapermlocation', +} + +KNOWN_FLAGS = {'SYSTEM', 'V2', 'MANAGED'} + +output_params = ( + Str('aci', + label=_('ACI'), + ), +) + + +def strip_ldap_prefix(uri): + prefix = 'ldap:///' + if not uri.startswith(prefix): + raise ValueError('%r does not start with %r' % (uri, prefix)) + return uri[len(prefix):] + + +def prevalidate_filter(ugettext, value): + if not value.startswith('(') or not value.endswith(')'): + return _('must be enclosed in parentheses') + + +class DNOrURL(DNParam): + """DN parameter that allows, and strips, a "ldap:///" prefix on input + + Used for ``subtree`` to maintain backward compatibility. + """ + + def _convert_scalar(self, value, index=None): + if isinstance(value, six.string_types) and value.startswith('ldap:///'): + value = strip_ldap_prefix(value) + return super(DNOrURL, self)._convert_scalar(value) + + +def validate_type(ugettext, typestr): + try: + obj = api.Object[typestr] + except KeyError: + return _('"%s" is not an object type') % typestr + if not getattr(obj, 'permission_filter_objectclasses', None): + return _('"%s" is not a valid permission type') % typestr + + +def _disallow_colon(option): + """Given a "cn" option, return a new "cn" option with ':' disallowed + + Used in permission-add and for --rename in permission-mod to prevent user + from creating new permissions with ":" in the name. + """ + return option.clone( + pattern='^[-_ a-zA-Z0-9.]+$', + pattern_errmsg="May only contain letters, numbers, -, _, ., and space", + ) + + +@register() +class permission(baseldap.LDAPObject): + """ + Permission object. + """ + container_dn = api.env.container_permission + object_name = _('permission') + object_name_plural = _('permissions') + # For use the complete object_class list, including 'top', so + # the updater doesn't try to delete 'top' every time. + object_class = ['top', 'groupofnames', 'ipapermission', 'ipapermissionv2'] + permission_filter_objectclasses = ['ipapermission'] + default_attributes = ['cn', 'member', 'memberof', + 'memberindirect', 'ipapermissiontype', 'objectclass', + 'ipapermdefaultattr', 'ipapermincludedattr', 'ipapermexcludedattr', + 'ipapermbindruletype', 'ipapermlocation', 'ipapermright', + 'ipapermtargetfilter', 'ipapermtarget' + ] + attribute_members = { + 'member': ['privilege'], + 'memberindirect': ['role'], + } + rdn_is_primary_key = True + managed_permissions = { + 'System: Read Permissions': { + 'replaces_global_anonymous_aci': True, + 'ipapermright': {'read', 'search', 'compare'}, + 'ipapermdefaultattr': { + 'businesscategory', 'cn', 'description', 'ipapermissiontype', + 'o', 'objectclass', 'ou', 'owner', 'seealso', + 'ipapermdefaultattr', 'ipapermincludedattr', + 'ipapermexcludedattr', 'ipapermbindruletype', 'ipapermtarget', + 'ipapermlocation', 'ipapermright', 'ipapermtargetfilter', + 'member', 'memberof', 'memberuser', 'memberhost', + }, + 'default_privileges': {'RBAC Readers'}, + }, + 'System: Read ACIs': { + # Readable ACIs are needed for reading legacy permissions. + 'non_object': True, + 'ipapermlocation': api.env.basedn, + 'replaces_global_anonymous_aci': True, + 'ipapermright': {'read', 'search', 'compare'}, + 'ipapermdefaultattr': {'aci'}, + 'default_privileges': {'RBAC Readers'}, + }, + 'System: Modify Privilege Membership': { + 'ipapermright': {'write'}, + 'ipapermdefaultattr': {'member'}, + 'replaces': [ + '(targetattr = "member")(target = "ldap:///cn=*,cn=permissions,cn=pbac,$SUFFIX")(version 3.0;acl "permission:Modify privilege membership";allow (write) groupdn = "ldap:///cn=Modify privilege membership,cn=permissions,cn=pbac,$SUFFIX";)', + ], + 'default_privileges': {'Delegation Administrator'}, + }, + } + + label = _('Permissions') + label_singular = _('Permission') + + takes_params = ( + Str('cn', + cli_name='name', + label=_('Permission name'), + primary_key=True, + pattern='^[-_ a-zA-Z0-9.:/]+$', + pattern_errmsg="May only contain letters, numbers, " + "-, _, ., :, /, and space", + ), + StrEnum( + 'ipapermright*', + cli_name='right', + deprecated_cli_aliases={'permissions'}, + label=_('Granted rights'), + doc=_('Rights to grant ' + '(read, search, compare, write, add, delete, all)'), + values=(u'read', u'search', u'compare', + u'write', u'add', u'delete', u'all'), + flags={'ask_create'}, + ), + Str('attrs*', + label=_('Effective attributes'), + doc=_('All attributes to which the permission applies'), + flags={'virtual_attribute', 'allow_mod_for_managed_permission'}, + ), + Str('ipapermincludedattr*', + cli_name='includedattrs', + label=_('Included attributes'), + doc=_('User-specified attributes to which the permission applies'), + flags={'no_create', 'allow_mod_for_managed_permission'}, + ), + Str('ipapermexcludedattr*', + cli_name='excludedattrs', + label=_('Excluded attributes'), + doc=_('User-specified attributes to which the permission ' + 'explicitly does not apply'), + flags={'no_create', 'allow_mod_for_managed_permission'}, + ), + Str('ipapermdefaultattr*', + cli_name='defaultattrs', + label=_('Default attributes'), + doc=_('Attributes to which the permission applies by default'), + flags={'no_create', 'no_update'}, + ), + StrEnum( + 'ipapermbindruletype', + cli_name='bindtype', + label=_('Bind rule type'), + doc=_('Bind rule type'), + autofill=True, + values=(u'permission', u'all', u'anonymous'), + default=u'permission', + flags={'allow_mod_for_managed_permission'}, + ), + DNOrURL( + 'ipapermlocation?', + cli_name='subtree', + label=_('Subtree'), + doc=_('Subtree to apply permissions to'), + flags={'ask_create'}, + ), + Str( + 'extratargetfilter*', prevalidate_filter, + cli_name='filter', + label=_('Extra target filter'), + doc=_('Extra target filter'), + flags={'virtual_attribute'}, + ), + Str( + 'ipapermtargetfilter*', prevalidate_filter, + cli_name='rawfilter', + label=_('Raw target filter'), + doc=_('All target filters, including those implied by ' + 'type and memberof'), + ), + + DNParam( + 'ipapermtarget?', + cli_name='target', + label=_('Target DN'), + doc=_('Optional DN to apply the permission to ' + '(must be in the subtree, but may not yet exist)'), + ), + + DNParam( + 'ipapermtargetto?', + cli_name='targetto', + label=_('Target DN subtree'), + doc=_('Optional DN subtree where an entry can be moved to ' + '(must be in the subtree, but may not yet exist)'), + ), + + DNParam( + 'ipapermtargetfrom?', + cli_name='targetfrom', + label=_('Origin DN subtree'), + doc=_('Optional DN subtree from where an entry can be moved ' + '(must be in the subtree, but may not yet exist)'), + ), + + Str('memberof*', + label=_('Member of group'), # FIXME: Does this label make sense? + doc=_('Target members of a group (sets memberOf targetfilter)'), + flags={'ask_create', 'virtual_attribute'}, + ), + Str('targetgroup?', + label=_('Target group'), + doc=_('User group to apply permissions to (sets target)'), + flags={'ask_create', 'virtual_attribute'}, + ), + Str( + 'type?', validate_type, + label=_('Type'), + doc=_('Type of IPA object ' + '(sets subtree and objectClass targetfilter)'), + flags={'ask_create', 'virtual_attribute'}, + ), + ) + tuple( + Str(old_name + '*', + doc=_('Deprecated; use %s' % new_name), + flags={'no_option', 'virtual_attribute'}) + for old_name, new_name in _DEPRECATED_OPTION_ALIASES.items() + ) + + def reject_system(self, entry): + """Raise if permission entry has unknown flags, or is a SYSTEM perm""" + flags = entry.get('ipapermissiontype', []) + for flag in flags: + if flag not in KNOWN_FLAGS: + raise errors.ACIError( + info=_('Permission with unknown flag %s may not be ' + 'modified or removed') % flag) + if list(flags) == [u'SYSTEM']: + raise errors.ACIError( + info=_('A SYSTEM permission may not be modified or removed')) + + def _get_filter_attr_info(self, entry): + """Get information on filter-related virtual attributes + + Returns a dict with this information: + 'implicit_targetfilters': targetfilters implied by memberof and type + 'memberof': list of names of groups from memberof + 'type': the type + """ + ipapermtargetfilter = entry.get('ipapermtargetfilter', []) + ipapermlocation = entry.single_value.get('ipapermlocation') + + implicit_targetfilters = set() + result = {'implicit_targetfilters': implicit_targetfilters} + + # memberof + memberof = [] + for targetfilter in ipapermtargetfilter: + match = re.match('^\(memberof=(.*)\)$', targetfilter, re.I) + if match: + try: + dn = DN(match.group(1)) + except ValueError: + # Malformed DN; e.g. (memberof=*) + continue + groups_dn = DN(self.api.Object.group.container_dn, + self.api.env.basedn) + if dn[1:] == groups_dn[:] and dn[0].attr == 'cn': + memberof.append(dn[0].value) + implicit_targetfilters.add(match.group(0)) + if memberof: + result['memberof'] = memberof + + # type + if ipapermtargetfilter and ipapermlocation: + for obj in self.api.Object(): + filt = self.make_type_filter(obj) + if not filt: + continue + + wantdn = DN(obj.container_dn, self.api.env.basedn) + if DN(ipapermlocation) != wantdn: + continue + + if filt in ipapermtargetfilter: + result['type'] = [unicode(obj.name)] + implicit_targetfilters.add(filt) + break + + return result + + def postprocess_result(self, entry, options): + """Update a permission entry for output (in place) + + :param entry: The entry to update + :param options: + Command options. Contains keys such as ``raw``, ``all``, + ``pkey_only``, ``version``. + """ + old_client = not client_has_capability( + options['version'], 'permissions2') + + if not options.get('raw') and not options.get('pkey_only'): + ipapermtargetfilter = entry.get('ipapermtargetfilter', []) + ipapermtarget = entry.single_value.get('ipapermtarget') + + # targetgroup + if ipapermtarget: + dn = DN(ipapermtarget) + if (dn[1:] == DN(self.api.Object.group.container_dn, + self.api.env.basedn)[:] and + dn[0].attr == 'cn' and dn[0].value != '*'): + entry.single_value['targetgroup'] = dn[0].value + + filter_attr_info = self._get_filter_attr_info(entry) + if 'type' in filter_attr_info: + entry['type'] = filter_attr_info['type'] + if 'memberof' in filter_attr_info: + entry['memberof'] = filter_attr_info['memberof'] + if 'implicit_targetfilters' in filter_attr_info: + extratargetfilter = sorted( + set(ipapermtargetfilter) - + filter_attr_info['implicit_targetfilters']) + if extratargetfilter: + entry['extratargetfilter'] = extratargetfilter + + # old output names + if old_client: + for old_name, new_name in _DEPRECATED_OPTION_ALIASES.items(): + if new_name in entry: + entry[old_name] = entry[new_name] + del entry[new_name] + + rights = entry.get('attributelevelrights') + if rights: + if 'ipapermtarget' in rights: + rights['targetgroup'] = rights['ipapermtarget'] + if 'ipapermtargetfilter' in rights: + rights['memberof'] = rights['ipapermtargetfilter'] + + type_rights = set(rights['ipapermtargetfilter']) + location_rights = set(rights.get('ipapermlocation', '')) + type_rights.intersection_update(location_rights) + rights['type'] = ''.join(sorted( + type_rights, key=rights['ipapermtargetfilter'].index)) + + if 'ipapermincludedattr' in rights: + rights['attrs'] = ''.join(sorted( + set(rights['ipapermincludedattr']) & + set(rights.get('ipapermexcludedattr', '')), + key=rights['ipapermincludedattr'].index)) + + if old_client: + for old_name, new_name in _DEPRECATED_OPTION_ALIASES.items(): + if new_name in entry: + rights[old_name] = rights[new_name] + del rights[new_name] + + if options.get('raw'): + # Retreive the ACI from LDAP to ensure we get the real thing + try: + acientry, acistring = self._get_aci_entry_and_string(entry) + except errors.NotFound: + if list(entry.get('ipapermissiontype')) == ['SYSTEM']: + # SYSTEM permissions don't have normal ACIs + pass + else: + raise + else: + entry.single_value['aci'] = acistring + else: + effective_attrs = self.get_effective_attrs(entry) + if effective_attrs: + entry['attrs'] = effective_attrs + if (not options.get('all') and + not entry.get('ipapermexcludedattr') and + not entry.get('ipapermdefaultattr')): + entry.pop('ipapermincludedattr', None) + + if old_client: + # Legacy clients expect some attributes as a single value + for attr in 'type', 'targetgroup', 'aci': + if attr in entry: + entry[attr] = entry.single_value[attr] + # memberof was also single-valued, but not any more + if entry.get('memberof'): + joined_value = u', '.join(str(m) for m in entry['memberof']) + entry['memberof'] = joined_value + if 'subtree' in entry: + # Legacy clients expect subtree as a URL + dn = entry.single_value['subtree'] + entry['subtree'] = u'ldap:///%s' % dn + if 'filter' in entry: + # Legacy clients expect filter without parentheses + new_filter = [] + for flt in entry['filter']: + assert flt[0] == '(' and flt[-1] == ')' + new_filter.append(flt[1:-1]) + entry['filter'] = new_filter + + if not options['raw'] and not options['all']: + # Don't return the raw target filter by default + entry.pop('ipapermtargetfilter', None) + + def get_effective_attrs(self, entry): + attrs = set(entry.get('ipapermdefaultattr', ())) + attrs.update(entry.get('ipapermincludedattr', ())) + if ('read' in entry.get('ipapermright', ()) and + 'objectclass' in (x.lower() for x in attrs)): + # Add special-cased operational attributes + # We want to allow reading these whenever reading the objectclass + # is allowed. + # (But they can still be excluded explicitly, at least in managed + # permissions). + attrs.update((u'entryusn', u'createtimestamp', u'modifytimestamp')) + attrs.difference_update(entry.get('ipapermexcludedattr', ())) + return sorted(attrs) + + def make_aci(self, entry): + """Make an ACI string from the given permission entry""" + + aci_parts = [] + name = entry.single_value['cn'] + + # targetattr + attrs = self.get_effective_attrs(entry) + if attrs: + aci_parts.append("(targetattr = \"%s\")" % ' || '.join(attrs)) + + # target + ipapermtarget = entry.single_value.get('ipapermtarget') + if ipapermtarget: + aci_parts.append("(target = \"%s\")" % + 'ldap:///%s' % ipapermtarget) + + # target_to + ipapermtargetto = entry.single_value.get('ipapermtargetto') + if ipapermtargetto: + aci_parts.append("(target_to = \"%s\")" % + 'ldap:///%s' % ipapermtargetto) + + # target_from + ipapermtargetfrom = entry.single_value.get('ipapermtargetfrom') + if ipapermtargetfrom: + aci_parts.append("(target_from = \"%s\")" % + 'ldap:///%s' % ipapermtargetfrom) + + # targetfilter + ipapermtargetfilter = entry.get('ipapermtargetfilter') + if ipapermtargetfilter: + assert all(f.startswith('(') and f.endswith(')') + for f in ipapermtargetfilter) + if len(ipapermtargetfilter) == 1: + filter = ipapermtargetfilter[0] + else: + filter = '(&%s)' % ''.join(sorted(ipapermtargetfilter)) + aci_parts.append("(targetfilter = \"%s\")" % filter) + + # version, name, rights, bind rule + ipapermbindruletype = entry.single_value.get('ipapermbindruletype', + 'permission') + if ipapermbindruletype == 'permission': + dn = DN(('cn', name), self.container_dn, self.api.env.basedn) + bindrule = 'groupdn = "ldap:///%s"' % dn + elif ipapermbindruletype == 'all': + bindrule = 'userdn = "ldap:///all"' + elif ipapermbindruletype == 'anonymous': + bindrule = 'userdn = "ldap:///anyone"' + else: + raise ValueError(ipapermbindruletype) + + aci_parts.append('(version 3.0;acl "permission:%s";allow (%s) %s;)' % ( + name, ','.join(sorted(entry['ipapermright'])), bindrule)) + + return ''.join(aci_parts) + + def add_aci(self, permission_entry): + """Add the ACI coresponding to the given permission entry""" + ldap = self.api.Backend.ldap2 + acistring = self.make_aci(permission_entry) + location = permission_entry.single_value.get('ipapermlocation', + self.api.env.basedn) + + self.log.debug('Adding ACI %r to %s' % (acistring, location)) + try: + entry = ldap.get_entry(location, ['aci']) + except errors.NotFound: + raise errors.NotFound(reason=_('Entry %s not found') % location) + entry.setdefault('aci', []).append(acistring) + ldap.update_entry(entry) + + def remove_aci(self, permission_entry): + """Remove the ACI corresponding to the given permission entry + + :return: tuple: + - entry + - removed ACI string, or None if none existed previously + """ + return self._replace_aci(permission_entry) + + def update_aci(self, permission_entry, old_name=None): + """Update the ACI corresponding to the given permission entry + + :return: tuple: + - entry + - removed ACI string, or None if none existed previously + """ + new_acistring = self.make_aci(permission_entry) + return self._replace_aci(permission_entry, old_name, new_acistring) + + def _replace_aci(self, permission_entry, old_name=None, new_acistring=None): + """Replace ACI corresponding to permission_entry + + :param old_name: the old name of the permission, if different from new + :param new_acistring: new ACI string; if None the ACI is just deleted + :return: tuple: + - entry + - removed ACI string, or None if none existed previously + """ + ldap = self.api.Backend.ldap2 + acientry, acistring = self._get_aci_entry_and_string( + permission_entry, old_name, notfound_ok=True) + + # (pylint thinks `acientry` is just a dict, but it's an LDAPEntry) + acidn = acientry.dn # pylint: disable=E1103 + + if acistring is not None: + self.log.debug('Removing ACI %r from %s' % (acistring, acidn)) + acientry['aci'].remove(acistring) + if new_acistring: + self.log.debug('Adding ACI %r to %s' % (new_acistring, acidn)) + acientry.setdefault('aci', []).append(new_acistring) + try: + ldap.update_entry(acientry) + except errors.EmptyModlist: + self.log.debug('No changes to ACI') + return acientry, acistring + + def _get_aci_entry_and_string(self, permission_entry, name=None, + notfound_ok=False, cached_acientry=None): + """Get the entry and ACI corresponding to the permission entry + + :param name: The name of the permission, or None for the cn + :param notfound_ok: + If true, (acientry, None) will be returned on missing ACI, rather + than raising exception + :param cached_acientry: See upgrade_permission() + """ + ldap = self.api.Backend.ldap2 + if name is None: + name = permission_entry.single_value['cn'] + location = permission_entry.single_value.get('ipapermlocation', + self.api.env.basedn) + wanted_aciname = 'permission:%s' % name + + if (cached_acientry and + cached_acientry.dn == location and + 'aci' in cached_acientry): + acientry = cached_acientry + else: + try: + acientry = ldap.get_entry(location, ['aci']) + except errors.NotFound: + acientry = ldap.make_entry(location) + acis = acientry.get('aci', ()) + for acistring in acis: + try: + aci = ACI(acistring) + except SyntaxError as e: + self.log.warning('Unparseable ACI %s: %s (at %s)', + acistring, e, location) + continue + if aci.name == wanted_aciname: + return acientry, acistring + else: + if notfound_ok: + return acientry, None + raise errors.NotFound( + reason=_('The ACI for permission %(name)s was not found ' + 'in %(dn)s ') % {'name': name, 'dn': location}) + + def upgrade_permission(self, entry, target_entry=None, + output_only=False, cached_acientry=None): + """Upgrade the given permission entry to V2, in-place + + The entry is only upgraded if it is a plain old-style permission, + that is, it has no flags set. + + :param target_entry: + If given, ``target_entry`` is filled from information taken + from the ACI corresponding to ``entry``. + If None, ``entry`` itself is filled + :param output_only: + If true, the flags & objectclass are not updated to V2. + Used for the -find and -show commands. + :param cached_acientry: + Optional pre-retreived entry that contains the existing ACI. + If it is None or its DN does not match the location DN, + cached_acientry is ignored and the entry is retreived from LDAP. + """ + if entry.get('ipapermissiontype'): + # Only convert old-style, non-SYSTEM permissions -- i.e. no flags + return + base, acistring = self._get_aci_entry_and_string( + entry, cached_acientry=cached_acientry) + + if not target_entry: + target_entry = entry + + # The DN of old permissions is always basedn + # (pylint thinks `base` is just a dict, but it's an LDAPEntry) + assert base.dn == self.api.env.basedn, base # pylint: disable=E1103 + + aci = ACI(acistring) + + if 'target' in aci.target: + target_entry.single_value['ipapermtarget'] = DN(strip_ldap_prefix( + aci.target['target']['expression'])) + if 'targetfilter' in aci.target: + target_entry.single_value['ipapermtargetfilter'] = unicode( + aci.target['targetfilter']['expression']) + if aci.bindrule['expression'] == 'ldap:///all': + target_entry.single_value['ipapermbindruletype'] = u'all' + elif aci.bindrule['expression'] == 'ldap:///anyone': + target_entry.single_value['ipapermbindruletype'] = u'anonymous' + else: + target_entry.single_value['ipapermbindruletype'] = u'permission' + target_entry['ipapermright'] = aci.permissions + if 'targetattr' in aci.target: + target_entry['ipapermincludedattr'] = [ + unicode(a) for a in aci.target['targetattr']['expression']] + + if not output_only: + target_entry['ipapermissiontype'] = ['SYSTEM', 'V2'] + if 'ipapermissionv2' not in entry['objectclass']: + target_entry['objectclass'] = list(entry['objectclass']) + [ + u'ipapermissionv2'] + + target_entry['ipapermlocation'] = [self.api.env.basedn] + + # Make sure we're not losing *any info* by the upgrade + new_acistring = self.make_aci(target_entry) + if not ACI(new_acistring).isequal(aci): + raise ValueError('Cannot convert ACI, %r != %r' % (new_acistring, + acistring)) + + def make_type_filter(self, obj): + """Make a filter for a --type based permission from an Object""" + objectclasses = getattr(obj, 'permission_filter_objectclasses', None) + if not objectclasses: + return None + filters = [u'(objectclass=%s)' % o for o in objectclasses] + if len(filters) == 1: + return filters[0] + else: + return '(|%s)' % ''.join(sorted(filters)) + + def preprocess_options(self, options, + return_filter_ops=False, + merge_targetfilter=False): + """Preprocess options (in-place) + + :param options: A dictionary of options + :param return_filter_ops: + If false, assumes there is no pre-existing entry; + additional values of ipapermtargetfilter are added to options. + If true, a dictionary of operations on ipapermtargetfilter is + returned. + These operations must be performed after the existing entry + is retrieved. + The dict has the following keys: + - remove: list of regular expression objects; + implicit values that match any of them should be removed + - add: list of values to be added, after any removals + :merge_targetfilter: + If true, the extratargetfilter is copied into ipapermtargetfilter. + """ + + if 'extratargetfilter' in options: + if 'ipapermtargetfilter' in options: + raise errors.ValidationError( + name='ipapermtargetfilter', + error=_('cannot specify full target filter ' + 'and extra target filter simultaneously')) + if merge_targetfilter: + options['ipapermtargetfilter'] = options['extratargetfilter'] + + filter_ops = {'add': [], 'remove': []} + + if options.get('subtree'): + if isinstance(options['subtree'], (list, tuple)): + [options['subtree']] = options['subtree'] + try: + options['subtree'] = strip_ldap_prefix(options['subtree']) + except ValueError: + raise errors.ValidationError( + name='subtree', + error='does not start with "ldap:///"') + + # Handle old options + for old_name, new_name in _DEPRECATED_OPTION_ALIASES.items(): + if old_name in options: + if client_has_capability(options['version'], 'permissions2'): + raise errors.ValidationError( + name=old_name, + error=_('option was renamed; use %s') % new_name) + if new_name in options: + raise errors.ValidationError( + name=old_name, + error=(_('Cannot use %(old_name)s with %(new_name)s') % + {'old_name': old_name, 'new_name': new_name})) + options[new_name] = options[old_name] + del options[old_name] + + # memberof + if 'memberof' in options: + filter_ops['remove'].append(re.compile(r'\(memberOf=.*\)', re.I)) + memberof = options.pop('memberof') + for group in (memberof or ()): + try: + groupdn = self.api.Object.group.get_dn_if_exists(group) + except errors.NotFound: + raise errors.NotFound( + reason=_('%s: group not found') % group) + filter_ops['add'].append(u'(memberOf=%s)' % groupdn) + + # targetgroup + if 'targetgroup' in options: + targetgroup = options.pop('targetgroup') + if targetgroup: + if 'ipapermtarget' in options: + raise errors.ValidationError( + name='ipapermtarget', + error=_('target and targetgroup are mutually exclusive')) + try: + groupdn = self.api.Object.group.get_dn_if_exists(targetgroup) + except errors.NotFound: + raise errors.NotFound( + reason=_('%s: group not found') % targetgroup) + options['ipapermtarget'] = groupdn + else: + if 'ipapermtarget' not in options: + options['ipapermtarget'] = None + + # type + if 'type' in options: + objtype = options.pop('type') + filter_ops['remove'].append(re.compile(r'\(objectclass=.*\)', re.I)) + filter_ops['remove'].append(re.compile( + r'\(\|(\(objectclass=[^(]*\))+\)', re.I)) + if objtype: + if 'ipapermlocation' in options: + raise errors.ValidationError( + name='ipapermlocation', + error=_('subtree and type are mutually exclusive')) + obj = self.api.Object[objtype.lower()] + filt = self.make_type_filter(obj) + if not filt: + raise errors.ValidationError( + _('"%s" is not a valid permission type') % objtype) + filter_ops['add'].append(filt) + container_dn = DN(obj.container_dn, self.api.env.basedn) + options['ipapermlocation'] = container_dn + else: + if 'ipapermlocation' not in options: + options['ipapermlocation'] = None + + if return_filter_ops: + return filter_ops + elif filter_ops['add']: + options['ipapermtargetfilter'] = list(options.get( + 'ipapermtargetfilter') or []) + filter_ops['add'] + + def validate_permission(self, entry): + ldap = self.Backend.ldap2 + + # Rough filter validation by a search + if entry.get('ipapermtargetfilter'): + try: + ldap.find_entries( + filter=ldap.combine_filters(entry['ipapermtargetfilter'], + rules='&'), + base_dn=self.env.basedn, + scope=ldap.SCOPE_BASE, + size_limit=1) + except errors.NotFound: + pass + except errors.BadSearchFilter: + raise errors.ValidationError( + name='ipapermtargetfilter', + error=_('Bad search filter')) + + # Ensure location exists + if entry.get('ipapermlocation'): + location = DN(entry.single_value['ipapermlocation']) + try: + ldap.get_entry(location, attrs_list=[]) + except errors.NotFound: + raise errors.ValidationError( + name='ipapermlocation', + error=_('Entry %s does not exist') % location) + + # Ensure there's something in the ACI's filter + needed_attrs = ( + 'ipapermtarget', 'ipapermtargetfilter', + 'ipapermincludedattr', 'ipapermexcludedattr', 'ipapermdefaultattr') + if not any(v for a in needed_attrs for v in (entry.get(a) or ())): + raise errors.ValidationError( + name='target', + error=_('there must be at least one target entry specifier ' + '(e.g. target, targetfilter, attrs)')) + + # Ensure there's a right + if not entry.get('ipapermright'): + raise errors.RequirementError(name='ipapermright') + + +@register() +class permission_add_noaci(baseldap.LDAPCreate): + __doc__ = _('Add a system permission without an ACI (internal command)') + + msg_summary = _('Added permission "%(value)s"') + NO_CLI = True + has_output_params = baseldap.LDAPCreate.has_output_params + output_params + + takes_options = ( + Str('ipapermissiontype+', + label=_('Permission flags'), + ), + ) + + def get_options(self): + perm_options = set(o.name for o in self.obj.takes_params) + for option in super(permission_add_noaci, self).get_options(): + # From new options, only cn & ipapermissiontype are supported + if option.name in ['ipapermissiontype']: + yield option.clone() + # Other options such as raw, version are supported + elif option.name not in perm_options: + yield option.clone() + + def pre_callback(self, ldap, dn, entry, attrs_list, *keys, **options): + entry['ipapermissiontype'] = list(options['ipapermissiontype']) + entry['objectclass'] = [oc for oc in entry['objectclass'] + if oc.lower() != 'ipapermissionv2'] + return dn + + +@register() +class permission_add(baseldap.LDAPCreate): + __doc__ = _('Add a new permission.') + + msg_summary = _('Added permission "%(value)s"') + has_output_params = baseldap.LDAPCreate.has_output_params + output_params + + # Need to override execute so that processed options apply to + # the whole command, not just the callbacks + def execute(self, *keys, **options): + self.obj.preprocess_options(options, merge_targetfilter=True) + return super(permission_add, self).execute(*keys, **options) + + def get_args(self): + for arg in super(permission_add, self).get_args(): + if arg.name == 'cn': + yield _disallow_colon(arg) + else: + yield arg + + def pre_callback(self, ldap, dn, entry, attrs_list, *keys, **options): + entry['ipapermissiontype'] = ['SYSTEM', 'V2'] + entry['cn'] = list(keys) + if not entry.get('ipapermlocation'): + entry.setdefault('ipapermlocation', [api.env.basedn]) + + if 'attrs' in options: + if 'ipapermincludedattr' in options: + raise errors.ValidationError( + name='attrs', + error=_('attrs and included attributes are ' + 'mutually exclusive')) + entry['ipapermincludedattr'] = list(options.pop('attrs') or ()) + + self.obj.validate_permission(entry) + return dn + + def post_callback(self, ldap, dn, entry, *keys, **options): + try: + self.obj.add_aci(entry) + except Exception as e: + # Adding the ACI failed. + # We want to be 100% sure the ACI is not there, so try to + # remove it. (This is a no-op if the ACI was not added.) + self.obj.remove_aci(entry) + # Remove the entry. + # The permission entry serves as a "lock" tho prevent + # permission-add commands started at the same time from + # interfering. As long as the entry is there, the other + # permission-add will fail with DuplicateEntry. + # So deleting entry ("releasing the lock") must be the last + # thing we do here. + try: + self.api.Backend['ldap2'].delete_entry(entry) + except errors.NotFound: + pass + if isinstance(e, errors.NotFound): + # add_aci may raise NotFound if the subtree is only virtual + # like cn=compat,SUFFIX and thus passes the LDAP get entry test + location = DN(entry.single_value['ipapermlocation']) + raise errors.ValidationError( + name='ipapermlocation', + error=_('Cannot store permission ACI to %s') % location) + # Re-raise original exception + raise + self.obj.postprocess_result(entry, options) + return dn + + +@register() +class permission_del(baseldap.LDAPDelete): + __doc__ = _('Delete a permission.') + + msg_summary = _('Deleted permission "%(value)s"') + + takes_options = baseldap.LDAPDelete.takes_options + ( + Flag('force', + label=_('Force'), + flags={'no_option', 'no_output'}, + doc=_('force delete of SYSTEM permissions'), + ), + ) + + def pre_callback(self, ldap, dn, *keys, **options): + try: + entry = ldap.get_entry(dn, attrs_list=self.obj.default_attributes) + except errors.NotFound: + self.obj.handle_not_found(*keys) + + if not options.get('force'): + self.obj.reject_system(entry) + if entry.get('ipapermdefaultattr'): + raise errors.ACIError( + info=_('cannot delete managed permissions')) + + try: + self.obj.remove_aci(entry) + except errors.NotFound: + errors.NotFound( + reason=_('ACI of permission %s was not found') % keys[0]) + + return dn + + +@register() +class permission_mod(baseldap.LDAPUpdate): + __doc__ = _('Modify a permission.') + + msg_summary = _('Modified permission "%(value)s"') + has_output_params = baseldap.LDAPUpdate.has_output_params + output_params + + def execute(self, *keys, **options): + context.filter_ops = self.obj.preprocess_options( + options, return_filter_ops=True) + return super(permission_mod, self).execute(*keys, **options) + + def get_options(self): + for opt in super(permission_mod, self).get_options(): + if opt.name == 'rename': + yield _disallow_colon(opt) + else: + yield opt + + def pre_callback(self, ldap, dn, entry, attrs_list, *keys, **options): + if 'rename' in options and not options['rename']: + raise errors.ValidationError(name='rename', + error='New name can not be empty') + + try: + attrs_list = self.obj.default_attributes + old_entry = ldap.get_entry(dn, attrs_list=attrs_list) + except errors.NotFound: + self.obj.handle_not_found(*keys) + + self.obj.reject_system(old_entry) + self.obj.upgrade_permission(old_entry) + + if 'MANAGED' in old_entry.get('ipapermissiontype', ()): + for option_name in sorted(options): + if option_name == 'rename': + raise errors.ValidationError( + name=option_name, + error=_('cannot rename managed permissions')) + option = self.options[option_name] + allow_mod = 'allow_mod_for_managed_permission' in option.flags + if (option.attribute and not allow_mod or + option_name == 'extratargetfilter'): + raise errors.ValidationError( + name=option_name, + error=_('not modifiable on managed permissions')) + if context.filter_ops.get('add'): + raise errors.ValidationError( + name='ipapermtargetfilter', + error=_('not modifiable on managed permissions')) + else: + if options.get('ipapermexcludedattr'): + # prevent setting excluded attributes on normal permissions + # (but do allow deleting them all) + raise errors.ValidationError( + name='ipapermexcludedattr', + error=_('only available on managed permissions')) + + if 'attrs' in options: + if any(a in options for a in ('ipapermincludedattr', + 'ipapermexcludedattr')): + raise errors.ValidationError( + name='attrs', + error=_('attrs and included/excluded attributes are ' + 'mutually exclusive')) + attrs = set(options.pop('attrs') or ()) + defaults = set(old_entry.get('ipapermdefaultattr', ())) + entry['ipapermincludedattr'] = list(attrs - defaults) + entry['ipapermexcludedattr'] = list(defaults - attrs) + + # Check setting bindtype for an assigned permission + if options.get('ipapermbindruletype') and old_entry.get('member'): + raise errors.ValidationError( + name='ipapermbindruletype', + error=_('cannot set bindtype for a permission that is ' + 'assigned to a privilege')) + + # Since `entry` only contains the attributes we are currently changing, + # it cannot be used directly to generate an ACI. + # First we need to copy the original data into it. + for key, value in old_entry.items(): + if (key not in options and + key != 'cn' and + key not in self.obj.attribute_members): + entry.setdefault(key, value) + + # For extratargetfilter, add it to the implicit filters + # to get the full target filter + if 'extratargetfilter' in options: + filter_attr_info = self.obj._get_filter_attr_info(entry) + entry['ipapermtargetfilter'] = ( + list(options['extratargetfilter'] or []) + + list(filter_attr_info['implicit_targetfilters'])) + + filter_ops = context.filter_ops + old_filter_attr_info = self.obj._get_filter_attr_info(old_entry) + old_implicit_filters = old_filter_attr_info['implicit_targetfilters'] + removes = filter_ops.get('remove', []) + new_filters = set( + filt for filt in (entry.get('ipapermtargetfilter') or []) + if filt not in old_implicit_filters or + not any(rem.match(filt) for rem in removes)) + new_filters.update(filter_ops.get('add', [])) + new_filters.update(options.get('ipapermtargetfilter') or []) + entry['ipapermtargetfilter'] = list(new_filters) + + if not entry.get('ipapermlocation'): + entry['ipapermlocation'] = [self.api.env.basedn] + + self.obj.validate_permission(entry) + + old_location = old_entry.single_value.get('ipapermlocation', + self.api.env.basedn) + if old_location == options.get('ipapermlocation', old_location): + context.permision_moving_aci = False + else: + context.permision_moving_aci = True + try: + context.old_aci_info = self.obj.remove_aci(old_entry) + except errors.NotFound as e: + self.log.error('permission ACI not found: %s' % e) + + # To pass data to postcallback, we currently need to use the context + context.old_entry = old_entry + + return dn + + def exc_callback(self, keys, options, exc, call_func, *call_args, **call_kwargs): + if call_func.__name__ == 'update_entry': + self._revert_aci() + raise exc + + def _revert_aci(self): + old_aci_info = getattr(context, 'old_aci_info', None) + if old_aci_info: + # Try to roll back the old ACI + entry, old_aci_string = old_aci_info + if old_aci_string: + self.log.warning('Reverting ACI on %s to %s' % (entry.dn, + old_aci_string)) + entry['aci'].append(old_aci_string) + self.Backend.ldap2.update_entry(entry) + + def post_callback(self, ldap, dn, entry, *keys, **options): + old_entry = context.old_entry + + try: + if context.permision_moving_aci: + self.obj.add_aci(entry) + else: + self.obj.update_aci(entry, old_entry.single_value['cn']) + except Exception: + # Don't revert attribute which doesn't exist in LDAP + entry.pop('attributelevelrights', None) + + self.log.error('Error updating ACI: %s' % traceback.format_exc()) + self.log.warning('Reverting entry') + old_entry.reset_modlist(entry) + ldap.update_entry(old_entry) + self._revert_aci() + raise + self.obj.postprocess_result(entry, options) + entry['dn'] = entry.dn + return dn + + +@register() +class permission_find(baseldap.LDAPSearch): + __doc__ = _('Search for permissions.') + + msg_summary = ngettext( + '%(count)d permission matched', '%(count)d permissions matched', 0) + has_output_params = baseldap.LDAPSearch.has_output_params + output_params + + def execute(self, *keys, **options): + self.obj.preprocess_options(options, merge_targetfilter=True) + return super(permission_find, self).execute(*keys, **options) + + def pre_callback(self, ldap, filters, attrs_list, base_dn, scope, + *args, **options): + if 'attrs' in options and 'ipapermincludedattr' in options: + raise errors.ValidationError( + name='attrs', + error=_('attrs and included/excluded attributes are ' + 'mutually exclusive')) + + if options.get('attrs'): + # Effective attributes: + # each attr must be in either default or included, + # but not in excluded + filters = ldap.combine_filters( + [filters] + [ + '(&' + '(|' + '(ipapermdefaultattr=%(attr)s)' + '(ipapermincludedattr=%(attr)s))' + '(!(ipapermexcludedattr=%(attr)s)))' % {'attr': attr} + for attr in options['attrs'] + ], + ldap.MATCH_ALL, + ) + + return filters, base_dn, scope + + def post_callback(self, ldap, entries, truncated, *args, **options): + if 'attrs' in options: + options['ipapermincludedattr'] = options['attrs'] + + attribute_options = [o for o in options + if (o in self.options and + self.options[o].attribute)] + + if not options.get('pkey_only'): + for entry in entries: + # Old-style permissions might have matched (e.g. by name) + self.obj.upgrade_permission(entry, output_only=True) + + if not truncated: + if 'sizelimit' in options: + max_entries = options['sizelimit'] + else: + max_entries = self.api.Backend.ldap2.size_limit + + filters = ['(objectclass=ipaPermission)', + '(!(ipaPermissionType=V2))'] + if 'name' in options: + filters.append(ldap.make_filter_from_attr('cn', + options['name'], + exact=False)) + attrs_list = list(self.obj.default_attributes) + attrs_list += list(self.obj.attribute_members) + if options.get('all'): + attrs_list.append('*') + try: + legacy_entries = ldap.get_entries( + base_dn=DN(self.obj.container_dn, self.api.env.basedn), + filter=ldap.combine_filters(filters, rules=ldap.MATCH_ALL), + attrs_list=attrs_list) + # Retrieve the root entry (with all legacy ACIs) at once + root_entry = ldap.get_entry(DN(api.env.basedn), ['aci']) + except errors.NotFound: + legacy_entries = () + cached_root_entry = None + self.log.debug('potential legacy entries: %s', len(legacy_entries)) + nonlegacy_names = {e.single_value['cn'] for e in entries} + for entry in legacy_entries: + if entry.single_value['cn'] in nonlegacy_names: + continue + if max_entries > 0 and len(entries) > max_entries: + # We've over the limit, pop the last entry and set + # truncated flag + # (this is easier to do than checking before adding + # the entry to results) + # (max_entries <= 0 means unlimited) + entries.pop() + truncated = True + break + self.obj.upgrade_permission(entry, output_only=True, + cached_acientry=root_entry) + # If all given options match, include the entry + # Do a case-insensitive match, on any value if multi-valued + for opt in attribute_options: + optval = options[opt] + if not isinstance(optval, (tuple, list)): + optval = [optval] + value = entry.get(opt) + if not value: + break + if not all(any(str(ov).lower() in str(v).lower() + for v in value) for ov in optval): + break + else: + # Each search term must be present in some + # attribute value + for arg in args: + if arg: + arg = arg.lower() + if not any(arg in str(value).lower() + for values in entry.values() + for value in values): + break + else: + entries.append(entry) + + for entry in entries: + if options.get('pkey_only'): + for opt_name in list(entry): + if opt_name != self.obj.primary_key.name: + del entry[opt_name] + else: + self.obj.postprocess_result(entry, options) + + return truncated + + +@register() +class permission_show(baseldap.LDAPRetrieve): + __doc__ = _('Display information about a permission.') + has_output_params = baseldap.LDAPRetrieve.has_output_params + output_params + + def post_callback(self, ldap, dn, entry, *keys, **options): + self.obj.upgrade_permission(entry, output_only=True) + self.obj.postprocess_result(entry, options) + return dn + + +@register() +class permission_add_member(baseldap.LDAPAddMember): + """Add members to a permission.""" + NO_CLI = True + + def pre_callback(self, ldap, dn, member_dns, failed, *keys, **options): + # We can only add permissions with bind rule type set to + # "permission" (or old-style permissions) + validate_permission_to_privilege(self.api, keys[-1]) + return dn + + +@register() +class permission_remove_member(baseldap.LDAPRemoveMember): + """Remove members from a permission.""" + NO_CLI = True diff --git a/ipaserver/plugins/ping.py b/ipaserver/plugins/ping.py new file mode 100644 index 000000000..6a514125c --- /dev/null +++ b/ipaserver/plugins/ping.py @@ -0,0 +1,70 @@ +# Authors: +# Rob Crittenden <rcritten@redhat.com> +# +# Copyright (C) 2010 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +from ipalib import Command +from ipalib import output +from ipalib import _ +from ipalib.plugable import Registry +from ipapython.version import VERSION, API_VERSION + +__doc__ = _(""" +Ping the remote IPA server to ensure it is running. + +The ping command sends an echo request to an IPA server. The server +returns its version information. This is used by an IPA client +to confirm that the server is available and accepting requests. + +The server from xmlrpc_uri in /etc/ipa/default.conf is contacted first. +If it does not respond then the client will contact any servers defined +by ldap SRV records in DNS. + +EXAMPLES: + + Ping an IPA server: + ipa ping + ------------------------------------------ + IPA server version 2.1.9. API version 2.20 + ------------------------------------------ + + Ping an IPA server verbosely: + ipa -v ping + ipa: INFO: trying https://ipa.example.com/ipa/xml + ipa: INFO: Forwarding 'ping' to server 'https://ipa.example.com/ipa/xml' + ----------------------------------------------------- + IPA server version 2.1.9. API version 2.20 + ----------------------------------------------------- +""") + +register = Registry() + + +@register() +class ping(Command): + __doc__ = _('Ping a remote server.') + + has_output = ( + output.summary, + ) + + def execute(self, **options): + """ + A possible enhancement would be to take an argument and echo it + back but a fixed value works for now. + """ + return dict(summary=u'IPA server version %s. API version %s' % (VERSION, API_VERSION)) diff --git a/ipaserver/plugins/pkinit.py b/ipaserver/plugins/pkinit.py new file mode 100644 index 000000000..9aa101063 --- /dev/null +++ b/ipaserver/plugins/pkinit.py @@ -0,0 +1,105 @@ +# Authors: +# Simo Sorce <ssorce@redhat.com> +# +# Copyright (C) 2010 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +from ipalib import api, errors +from ipalib import Str +from ipalib import Object, Command +from ipalib import _ +from ipalib.plugable import Registry +from ipapython.dn import DN + +__doc__ = _(""" +Kerberos pkinit options + +Enable or disable anonymous pkinit using the principal +WELLKNOWN/ANONYMOUS@REALM. The server must have been installed with +pkinit support. + +EXAMPLES: + + Enable anonymous pkinit: + ipa pkinit-anonymous enable + + Disable anonymous pkinit: + ipa pkinit-anonymous disable + +For more information on anonymous pkinit see: + +http://k5wiki.kerberos.org/wiki/Projects/Anonymous_pkinit +""") + +register = Registry() + +@register() +class pkinit(Object): + """ + PKINIT Options + """ + object_name = _('pkinit') + + label=_('PKINIT') + + +def valid_arg(ugettext, action): + """ + Accepts only Enable/Disable. + """ + a = action.lower() + if a != 'enable' and a != 'disable': + raise errors.ValidationError( + name='action', + error=_('Unknown command %s') % action + ) + +@register() +class pkinit_anonymous(Command): + __doc__ = _('Enable or Disable Anonymous PKINIT.') + + princ_name = 'WELLKNOWN/ANONYMOUS@%s' % api.env.realm + default_dn = DN(('krbprincipalname', princ_name), ('cn', api.env.realm), ('cn', 'kerberos'), api.env.basedn) + + takes_args = ( + Str('action', valid_arg), + ) + + def execute(self, action, **options): + ldap = self.api.Backend.ldap2 + set_lock = False + lock = None + + entry_attrs = ldap.get_entry(self.default_dn, ['nsaccountlock']) + + if 'nsaccountlock' in entry_attrs: + lock = entry_attrs['nsaccountlock'][0].lower() + + if action.lower() == 'enable': + if lock == 'true': + set_lock = True + lock = None + elif action.lower() == 'disable': + if lock != 'true': + set_lock = True + lock = 'TRUE' + + if set_lock: + entry_attrs['nsaccountlock'] = lock + ldap.update_entry(entry_attrs) + + return dict(result=True) + diff --git a/ipaserver/plugins/privilege.py b/ipaserver/plugins/privilege.py new file mode 100644 index 000000000..b46807c3f --- /dev/null +++ b/ipaserver/plugins/privilege.py @@ -0,0 +1,251 @@ +# Authors: +# Rob Crittenden <rcritten@redhat.com> +# +# Copyright (C) 2010 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +from .baseldap import ( + LDAPObject, + LDAPCreate, + LDAPDelete, + LDAPUpdate, + LDAPSearch, + LDAPRetrieve, + LDAPAddMember, + LDAPRemoveMember, + LDAPAddReverseMember, + LDAPRemoveReverseMember) +from ipalib import api, _, ngettext, errors +from ipalib.plugable import Registry +from ipalib import Str +from ipalib import output +from ipapython.dn import DN + +__doc__ = _(""" +Privileges + +A privilege combines permissions into a logical task. A permission provides +the rights to do a single task. There are some IPA operations that require +multiple permissions to succeed. A privilege is where permissions are +combined in order to perform a specific task. + +For example, adding a user requires the following permissions: + * Creating a new user entry + * Resetting a user password + * Adding the new user to the default IPA users group + +Combining these three low-level tasks into a higher level task in the +form of a privilege named "Add User" makes it easier to manage Roles. + +A privilege may not contain other privileges. + +See role and permission for additional information. +""") + +register = Registry() + + +def validate_permission_to_privilege(api, permission): + ldap = api.Backend.ldap2 + ldapfilter = ldap.combine_filters(rules='&', filters=[ + '(objectClass=ipaPermissionV2)', '(!(ipaPermBindRuleType=permission))', + ldap.make_filter_from_attr('cn', permission, rules='|')]) + try: + entries, truncated = ldap.find_entries( + filter=ldapfilter, + attrs_list=['cn', 'ipapermbindruletype'], + base_dn=DN(api.env.container_permission, api.env.basedn), + size_limit=1) + except errors.NotFound: + pass + else: + entry = entries[0] + message = _('cannot add permission "%(perm)s" with bindtype ' + '"%(bindtype)s" to a privilege') + raise errors.ValidationError( + name='permission', + error=message % { + 'perm': entry.single_value['cn'], + 'bindtype': entry.single_value.get( + 'ipapermbindruletype', 'permission')}) + + +@register() +class privilege(LDAPObject): + """ + Privilege object. + """ + container_dn = api.env.container_privilege + object_name = _('privilege') + object_name_plural = _('privileges') + object_class = ['nestedgroup', 'groupofnames'] + permission_filter_objectclasses = ['groupofnames'] + default_attributes = ['cn', 'description', 'member', 'memberof'] + attribute_members = { + 'member': ['role'], + 'memberof': ['permission'], + } + reverse_members = { + 'member': ['permission'], + } + rdn_is_primary_key = True + managed_permissions = { + 'System: Read Privileges': { + 'replaces_global_anonymous_aci': True, + 'ipapermright': {'read', 'search', 'compare'}, + 'ipapermdefaultattr': { + 'businesscategory', 'cn', 'description', 'member', 'memberof', + 'o', 'objectclass', 'ou', 'owner', 'seealso', 'memberuser', + 'memberhost', + }, + 'default_privileges': {'RBAC Readers'}, + }, + 'System: Add Privileges': { + 'ipapermright': {'add'}, + 'default_privileges': {'Delegation Administrator'}, + }, + 'System: Modify Privileges': { + 'ipapermright': {'write'}, + 'ipapermdefaultattr': { + 'businesscategory', 'cn', 'description', 'o', 'ou', 'owner', + 'seealso', + }, + 'default_privileges': {'Delegation Administrator'}, + }, + 'System: Remove Privileges': { + 'ipapermright': {'delete'}, + 'default_privileges': {'Delegation Administrator'}, + }, + } + + label = _('Privileges') + label_singular = _('Privilege') + + takes_params = ( + Str('cn', + cli_name='name', + label=_('Privilege name'), + primary_key=True, + ), + Str('description?', + cli_name='desc', + label=_('Description'), + doc=_('Privilege description'), + ), + ) + + +@register() +class privilege_add(LDAPCreate): + __doc__ = _('Add a new privilege.') + + msg_summary = _('Added privilege "%(value)s"') + + +@register() +class privilege_del(LDAPDelete): + __doc__ = _('Delete a privilege.') + + msg_summary = _('Deleted privilege "%(value)s"') + + +@register() +class privilege_mod(LDAPUpdate): + __doc__ = _('Modify a privilege.') + + msg_summary = _('Modified privilege "%(value)s"') + + +@register() +class privilege_find(LDAPSearch): + __doc__ = _('Search for privileges.') + + msg_summary = ngettext( + '%(count)d privilege matched', '%(count)d privileges matched', 0 + ) + + +@register() +class privilege_show(LDAPRetrieve): + __doc__ = _('Display information about a privilege.') + + +@register() +class privilege_add_member(LDAPAddMember): + __doc__ = _('Add members to a privilege.') + + NO_CLI=True + + +@register() +class privilege_remove_member(LDAPRemoveMember): + """ + Remove members from a privilege + """ + NO_CLI=True + + +@register() +class privilege_add_permission(LDAPAddReverseMember): + __doc__ = _('Add permissions to a privilege.') + + show_command = 'privilege_show' + member_command = 'permission_add_member' + reverse_attr = 'permission' + member_attr = 'privilege' + + has_output = ( + output.Entry('result'), + output.Output('failed', + type=dict, + doc=_('Members that could not be added'), + ), + output.Output('completed', + type=int, + doc=_('Number of permissions added'), + ), + ) + + def pre_callback(self, ldap, dn, *keys, **options): + if options.get('permission'): + # We can only add permissions with bind rule type set to + # "permission" (or old-style permissions) + validate_permission_to_privilege(self.api, options['permission']) + return dn + + +@register() +class privilege_remove_permission(LDAPRemoveReverseMember): + __doc__ = _('Remove permissions from a privilege.') + + show_command = 'privilege_show' + member_command = 'permission_remove_member' + reverse_attr = 'permission' + member_attr = 'privilege' + + permission_count_out = ('%i permission removed.', '%i permissions removed.') + + has_output = ( + output.Entry('result'), + output.Output('failed', + type=dict, + doc=_('Members that could not be added'), + ), + output.Output('completed', + type=int, + doc=_('Number of permissions removed'), + ), + ) diff --git a/ipaserver/plugins/pwpolicy.py b/ipaserver/plugins/pwpolicy.py new file mode 100644 index 000000000..5a2202aa0 --- /dev/null +++ b/ipaserver/plugins/pwpolicy.py @@ -0,0 +1,611 @@ +# Authors: +# Pavel Zuna <pzuna@redhat.com> +# Martin Kosek <mkosek@redhat.com> +# +# Copyright (C) 2010 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +from ipalib import api +from ipalib import Int, Str, DNParam +from ipalib import errors +from .baseldap import ( + LDAPObject, + LDAPCreate, + LDAPDelete, + LDAPUpdate, + LDAPRetrieve, + LDAPSearch) +from ipalib import _ +from ipalib.plugable import Registry +from ipalib.request import context +from ipapython.ipautil import run +from ipapython.dn import DN +from distutils import version + +import six + +if six.PY3: + unicode = str + +__doc__ = _(""" +Password policy + +A password policy sets limitations on IPA passwords, including maximum +lifetime, minimum lifetime, the number of passwords to save in +history, the number of character classes required (for stronger passwords) +and the minimum password length. + +By default there is a single, global policy for all users. You can also +create a password policy to apply to a group. Each user is only subject +to one password policy, either the group policy or the global policy. A +group policy stands alone; it is not a super-set of the global policy plus +custom settings. + +Each group password policy requires a unique priority setting. If a user +is in multiple groups that have password policies, this priority determines +which password policy is applied. A lower value indicates a higher priority +policy. + +Group password policies are automatically removed when the groups they +are associated with are removed. + +EXAMPLES: + + Modify the global policy: + ipa pwpolicy-mod --minlength=10 + + Add a new group password policy: + ipa pwpolicy-add --maxlife=90 --minlife=1 --history=10 --minclasses=3 --minlength=8 --priority=10 localadmins + + Display the global password policy: + ipa pwpolicy-show + + Display a group password policy: + ipa pwpolicy-show localadmins + + Display the policy that would be applied to a given user: + ipa pwpolicy-show --user=tuser1 + + Modify a group password policy: + ipa pwpolicy-mod --minclasses=2 localadmins +""") + +register = Registry() + +@register() +class cosentry(LDAPObject): + """ + Class of Service object used for linking policies with groups + """ + NO_CLI = True + + container_dn = DN(('cn', 'costemplates'), api.env.container_accounts) + object_class = ['top', 'costemplate', 'extensibleobject', 'krbcontainer'] + permission_filter_objectclasses = ['costemplate'] + default_attributes = ['cn', 'cospriority', 'krbpwdpolicyreference'] + managed_permissions = { + 'System: Read Group Password Policy costemplate': { + 'replaces_global_anonymous_aci': True, + 'ipapermright': {'read', 'search', 'compare'}, + 'ipapermdefaultattr': { + 'cn', 'cospriority', 'krbpwdpolicyreference', 'objectclass', + }, + 'default_privileges': { + 'Password Policy Readers', + 'Password Policy Administrator', + }, + }, + 'System: Add Group Password Policy costemplate': { + 'ipapermright': {'add'}, + 'replaces': [ + '(target = "ldap:///cn=*,cn=costemplates,cn=accounts,$SUFFIX")(version 3.0;acl "permission:Add Group Password Policy costemplate";allow (add) groupdn = "ldap:///cn=Add Group Password Policy costemplate,cn=permissions,cn=pbac,$SUFFIX";)', + ], + 'default_privileges': {'Password Policy Administrator'}, + }, + 'System: Delete Group Password Policy costemplate': { + 'ipapermright': {'delete'}, + 'replaces': [ + '(target = "ldap:///cn=*,cn=costemplates,cn=accounts,$SUFFIX")(version 3.0;acl "permission:Delete Group Password Policy costemplate";allow (delete) groupdn = "ldap:///cn=Delete Group Password Policy costemplate,cn=permissions,cn=pbac,$SUFFIX";)', + ], + 'default_privileges': {'Password Policy Administrator'}, + }, + 'System: Modify Group Password Policy costemplate': { + 'ipapermright': {'write'}, + 'ipapermdefaultattr': {'cospriority'}, + 'replaces': [ + '(targetattr = "cospriority")(target = "ldap:///cn=*,cn=costemplates,cn=accounts,$SUFFIX")(version 3.0;acl "permission:Modify Group Password Policy costemplate";allow (write) groupdn = "ldap:///cn=Modify Group Password Policy costemplate,cn=permissions,cn=pbac,$SUFFIX";)', + ], + 'default_privileges': {'Password Policy Administrator'}, + }, + } + + takes_params = ( + Str('cn', primary_key=True), + DNParam('krbpwdpolicyreference'), + Int('cospriority', minvalue=0), + ) + + priority_not_unique_msg = _( + 'priority must be a unique value (%(prio)d already used by %(gname)s)' + ) + + def get_dn(self, *keys, **options): + group_dn = self.api.Object.group.get_dn(keys[-1]) + return self.backend.make_dn_from_attr( + 'cn', group_dn, DN(self.container_dn, api.env.basedn) + ) + + def check_priority_uniqueness(self, *keys, **options): + if options.get('cospriority') is not None: + entries = self.methods.find( + cospriority=options['cospriority'] + )['result'] + if len(entries) > 0: + group_name = self.api.Object.group.get_primary_key_from_dn( + DN(entries[0]['cn'][0])) + raise errors.ValidationError( + name='priority', + error=self.priority_not_unique_msg % { + 'prio': options['cospriority'], + 'gname': group_name, + } + ) + + +@register() +class cosentry_add(LDAPCreate): + NO_CLI = True + + def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options): + assert isinstance(dn, DN) + + # check for existence of the group + group_dn = self.api.Object.group.get_dn(keys[-1]) + try: + result = ldap.get_entry(group_dn, ['objectclass']) + except errors.NotFound: + self.api.Object.group.handle_not_found(keys[-1]) + + oc = [x.lower() for x in result['objectclass']] + if 'mepmanagedentry' in oc: + raise errors.ManagedPolicyError() + self.obj.check_priority_uniqueness(*keys, **options) + del entry_attrs['cn'] + return dn + + +@register() +class cosentry_del(LDAPDelete): + NO_CLI = True + + +@register() +class cosentry_mod(LDAPUpdate): + NO_CLI = True + + def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options): + assert isinstance(dn, DN) + new_cospriority = options.get('cospriority') + if new_cospriority is not None: + cos_entry = self.api.Command.cosentry_show(keys[-1])['result'] + old_cospriority = int(cos_entry['cospriority'][0]) + + # check uniqueness only when the new priority differs + if old_cospriority != new_cospriority: + self.obj.check_priority_uniqueness(*keys, **options) + return dn + + +@register() +class cosentry_show(LDAPRetrieve): + NO_CLI = True + + +@register() +class cosentry_find(LDAPSearch): + NO_CLI = True + + +global_policy_name = 'global_policy' +global_policy_dn = DN(('cn', global_policy_name), ('cn', api.env.realm), ('cn', 'kerberos'), api.env.basedn) + +@register() +class pwpolicy(LDAPObject): + """ + Password Policy object + """ + container_dn = DN(('cn', api.env.realm), ('cn', 'kerberos')) + object_name = _('password policy') + object_name_plural = _('password policies') + object_class = ['top', 'nscontainer', 'krbpwdpolicy'] + permission_filter_objectclasses = ['krbpwdpolicy'] + default_attributes = [ + 'cn', 'cospriority', 'krbmaxpwdlife', 'krbminpwdlife', + 'krbpwdhistorylength', 'krbpwdmindiffchars', 'krbpwdminlength', + 'krbpwdmaxfailure', 'krbpwdfailurecountinterval', + 'krbpwdlockoutduration', + ] + managed_permissions = { + 'System: Read Group Password Policy': { + 'replaces_global_anonymous_aci': True, + 'ipapermbindruletype': 'permission', + 'ipapermright': {'read', 'search', 'compare'}, + 'ipapermdefaultattr': { + 'cn', 'cospriority', 'krbmaxpwdlife', 'krbminpwdlife', + 'krbpwdfailurecountinterval', 'krbpwdhistorylength', + 'krbpwdlockoutduration', 'krbpwdmaxfailure', + 'krbpwdmindiffchars', 'krbpwdminlength', 'objectclass', + }, + 'default_privileges': { + 'Password Policy Readers', + 'Password Policy Administrator', + }, + }, + 'System: Add Group Password Policy': { + 'ipapermright': {'add'}, + 'replaces': [ + '(target = "ldap:///cn=*,cn=$REALM,cn=kerberos,$SUFFIX")(version 3.0;acl "permission:Add Group Password Policy";allow (add) groupdn = "ldap:///cn=Add Group Password Policy,cn=permissions,cn=pbac,$SUFFIX";)', + ], + 'default_privileges': {'Password Policy Administrator'}, + }, + 'System: Delete Group Password Policy': { + 'ipapermright': {'delete'}, + 'replaces': [ + '(target = "ldap:///cn=*,cn=$REALM,cn=kerberos,$SUFFIX")(version 3.0;acl "permission:Delete Group Password Policy";allow (delete) groupdn = "ldap:///cn=Delete Group Password Policy,cn=permissions,cn=pbac,$SUFFIX";)', + ], + 'default_privileges': {'Password Policy Administrator'}, + }, + 'System: Modify Group Password Policy': { + 'ipapermright': {'write'}, + 'ipapermdefaultattr': { + 'krbmaxpwdlife', 'krbminpwdlife', 'krbpwdfailurecountinterval', + 'krbpwdhistorylength', 'krbpwdlockoutduration', + 'krbpwdmaxfailure', 'krbpwdmindiffchars', 'krbpwdminlength' + }, + 'replaces': [ + '(targetattr = "krbmaxpwdlife || krbminpwdlife || krbpwdhistorylength || krbpwdmindiffchars || krbpwdminlength || krbpwdmaxfailure || krbpwdfailurecountinterval || krbpwdlockoutduration")(target = "ldap:///cn=*,cn=$REALM,cn=kerberos,$SUFFIX")(version 3.0;acl "permission:Modify Group Password Policy";allow (write) groupdn = "ldap:///cn=Modify Group Password Policy,cn=permissions,cn=pbac,$SUFFIX";)', + ], + 'default_privileges': {'Password Policy Administrator'}, + }, + } + + MIN_KRB5KDC_WITH_LOCKOUT = "1.8" + has_lockout = False + lockout_params = () + + result = run(['klist', '-V'], raiseonerr=False, capture_output=True) + if result.returncode == 0: + verstr = result.output.split()[-1] + ver = version.LooseVersion(verstr) + min = version.LooseVersion(MIN_KRB5KDC_WITH_LOCKOUT) + if ver >= min: + has_lockout = True + + if has_lockout: + lockout_params = ( + Int('krbpwdmaxfailure?', + cli_name='maxfail', + label=_('Max failures'), + doc=_('Consecutive failures before lockout'), + minvalue=0, + ), + Int('krbpwdfailurecountinterval?', + cli_name='failinterval', + label=_('Failure reset interval'), + doc=_('Period after which failure count will be reset (seconds)'), + minvalue=0, + ), + Int('krbpwdlockoutduration?', + cli_name='lockouttime', + label=_('Lockout duration'), + doc=_('Period for which lockout is enforced (seconds)'), + minvalue=0, + ), + ) + + label = _('Password Policies') + label_singular = _('Password Policy') + + takes_params = ( + Str('cn?', + cli_name='group', + label=_('Group'), + doc=_('Manage password policy for specific group'), + primary_key=True, + ), + Int('krbmaxpwdlife?', + cli_name='maxlife', + label=_('Max lifetime (days)'), + doc=_('Maximum password lifetime (in days)'), + minvalue=0, + maxvalue=20000, # a little over 54 years + ), + Int('krbminpwdlife?', + cli_name='minlife', + label=_('Min lifetime (hours)'), + doc=_('Minimum password lifetime (in hours)'), + minvalue=0, + ), + Int('krbpwdhistorylength?', + cli_name='history', + label=_('History size'), + doc=_('Password history size'), + minvalue=0, + ), + Int('krbpwdmindiffchars?', + cli_name='minclasses', + label=_('Character classes'), + doc=_('Minimum number of character classes'), + minvalue=0, + maxvalue=5, + ), + Int('krbpwdminlength?', + cli_name='minlength', + label=_('Min length'), + doc=_('Minimum length of password'), + minvalue=0, + ), + Int('cospriority', + cli_name='priority', + label=_('Priority'), + doc=_('Priority of the policy (higher number means lower priority'), + minvalue=0, + flags=('virtual_attribute',), + ), + ) + lockout_params + + def get_dn(self, *keys, **options): + if keys[-1] is not None: + return self.backend.make_dn_from_attr( + self.primary_key.name, keys[-1], + DN(self.container_dn, api.env.basedn) + ) + return global_policy_dn + + def convert_time_for_output(self, entry_attrs, **options): + # Convert seconds to hours and days for displaying to user + if not options.get('raw', False): + if 'krbmaxpwdlife' in entry_attrs: + entry_attrs['krbmaxpwdlife'][0] = unicode( + int(entry_attrs['krbmaxpwdlife'][0]) // 86400 + ) + if 'krbminpwdlife' in entry_attrs: + entry_attrs['krbminpwdlife'][0] = unicode( + int(entry_attrs['krbminpwdlife'][0]) // 3600 + ) + + def convert_time_on_input(self, entry_attrs): + # Convert hours and days to seconds for writing to LDAP + if 'krbmaxpwdlife' in entry_attrs and entry_attrs['krbmaxpwdlife']: + entry_attrs['krbmaxpwdlife'] = entry_attrs['krbmaxpwdlife'] * 86400 + if 'krbminpwdlife' in entry_attrs and entry_attrs['krbminpwdlife']: + entry_attrs['krbminpwdlife'] = entry_attrs['krbminpwdlife'] * 3600 + + def validate_lifetime(self, entry_attrs, add=False, *keys): + """ + Ensure that the maximum lifetime is greater than the minimum. + If there is no minimum lifetime set then don't return an error. + """ + maxlife=entry_attrs.get('krbmaxpwdlife', None) + minlife=entry_attrs.get('krbminpwdlife', None) + existing_entry = {} + if not add: # then read existing entry + existing_entry = self.api.Command.pwpolicy_show(keys[-1], + all=True, + )['result'] + if minlife is None and 'krbminpwdlife' in existing_entry: + minlife = int(existing_entry['krbminpwdlife'][0]) * 3600 + if maxlife is None and 'krbmaxpwdlife' in existing_entry: + maxlife = int(existing_entry['krbmaxpwdlife'][0]) * 86400 + + if maxlife is not None and minlife is not None: + if minlife > maxlife: + raise errors.ValidationError( + name='maxlife', + error=_('Maximum password life must be greater than minimum.'), + ) + + def add_cospriority(self, entry, pwpolicy_name, rights=True): + if pwpolicy_name and pwpolicy_name != global_policy_name: + cos_entry = self.api.Command.cosentry_show( + pwpolicy_name, + rights=rights, all=rights + )['result'] + if cos_entry.get('cospriority') is not None: + entry['cospriority'] = cos_entry['cospriority'] + if rights: + entry['attributelevelrights']['cospriority'] = \ + cos_entry['attributelevelrights']['cospriority'] + + +@register() +class pwpolicy_add(LDAPCreate): + __doc__ = _('Add a new group password policy.') + + def get_args(self): + yield self.obj.primary_key.clone(attribute=True, required=True) + + def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options): + assert isinstance(dn, DN) + self.obj.convert_time_on_input(entry_attrs) + self.obj.validate_lifetime(entry_attrs, True) + self.api.Command.cosentry_add( + keys[-1], krbpwdpolicyreference=dn, + cospriority=options.get('cospriority') + ) + return dn + + def post_callback(self, ldap, dn, entry_attrs, *keys, **options): + assert isinstance(dn, DN) + self.log.info('%r' % entry_attrs) + # attribute rights are not allowed for pwpolicy_add + self.obj.add_cospriority(entry_attrs, keys[-1], rights=False) + self.obj.convert_time_for_output(entry_attrs, **options) + return dn + + +@register() +class pwpolicy_del(LDAPDelete): + __doc__ = _('Delete a group password policy.') + + def get_args(self): + yield self.obj.primary_key.clone( + attribute=True, required=True, multivalue=True + ) + + def pre_callback(self, ldap, dn, *keys, **options): + assert isinstance(dn, DN) + if dn == global_policy_dn: + raise errors.ValidationError( + name='group', + error=_('cannot delete global password policy') + ) + return dn + + def post_callback(self, ldap, dn, *keys, **options): + assert isinstance(dn, DN) + try: + self.api.Command.cosentry_del(keys[-1]) + except errors.NotFound: + pass + return True + + +@register() +class pwpolicy_mod(LDAPUpdate): + __doc__ = _('Modify a group password policy.') + + def execute(self, cn=None, **options): + return super(pwpolicy_mod, self).execute(cn, **options) + + def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options): + assert isinstance(dn, DN) + self.obj.convert_time_on_input(entry_attrs) + self.obj.validate_lifetime(entry_attrs, False, *keys) + setattr(context, 'cosupdate', False) + if options.get('cospriority') is not None: + if keys[-1] is None: + raise errors.ValidationError( + name='priority', + error=_('priority cannot be set on global policy') + ) + try: + self.api.Command.cosentry_mod( + keys[-1], cospriority=options['cospriority'] + ) + except errors.EmptyModlist as e: + if len(entry_attrs) == 1: # cospriority only was passed + raise e + else: + setattr(context, 'cosupdate', True) + return dn + + def post_callback(self, ldap, dn, entry_attrs, *keys, **options): + assert isinstance(dn, DN) + rights = options.get('all', False) and options.get('rights', False) + self.obj.add_cospriority(entry_attrs, keys[-1], rights) + self.obj.convert_time_for_output(entry_attrs, **options) + return dn + + def exc_callback(self, keys, options, exc, call_func, *call_args, **call_kwargs): + if call_func.__name__ == 'update_entry': + if isinstance(exc, errors.EmptyModlist): + entry_attrs = call_args[0] + cosupdate = getattr(context, 'cosupdate') + if not entry_attrs or cosupdate: + return + raise exc + + +@register() +class pwpolicy_show(LDAPRetrieve): + __doc__ = _('Display information about password policy.') + + takes_options = LDAPRetrieve.takes_options + ( + Str('user?', + label=_('User'), + doc=_('Display effective policy for a specific user'), + ), + ) + + def execute(self, cn=None, **options): + return super(pwpolicy_show, self).execute(cn, **options) + + def pre_callback(self, ldap, dn, attrs_list, *keys, **options): + assert isinstance(dn, DN) + if options.get('user') is not None: + user_entry = self.api.Command.user_show( + options['user'], all=True + )['result'] + if 'krbpwdpolicyreference' in user_entry: + return user_entry.get('krbpwdpolicyreference', [dn])[0] + return dn + + def post_callback(self, ldap, dn, entry_attrs, *keys, **options): + assert isinstance(dn, DN) + rights = options.get('all', False) and options.get('rights', False) + self.obj.add_cospriority(entry_attrs, keys[-1], rights) + self.obj.convert_time_for_output(entry_attrs, **options) + return dn + + +@register() +class pwpolicy_find(LDAPSearch): + __doc__ = _('Search for group password policies.') + + # this command does custom sorting in post_callback + sort_result_entries = False + + def priority_sort_key(self, entry): + """Key for sorting password policies + + returns a pair: (is_global, priority) + """ + # global policy will be always last in the output + if entry['cn'][0] == global_policy_name: + return True, 0 + else: + # policies with higher priority (lower number) will be at the + # beginning of the list + try: + cospriority = int(entry['cospriority'][0]) + except KeyError: + # if cospriority is not present in the entry, rather return 0 + # than crash + cospriority = 0 + return False, cospriority + + def post_callback(self, ldap, entries, truncated, *args, **options): + for e in entries: + # When pkey_only flag is on, entries should contain only a cn. + # Add a cospriority attribute that will be used for sorting. + # Attribute rights are not allowed for pwpolicy_find. + self.obj.add_cospriority(e, e['cn'][0], rights=False) + + self.obj.convert_time_for_output(e, **options) + + # do custom entry sorting by its cospriority + entries.sort(key=self.priority_sort_key) + + if options.get('pkey_only', False): + # remove cospriority that was used for sorting + for e in entries: + try: + del e['cospriority'] + except KeyError: + pass + + return truncated diff --git a/ipaserver/plugins/radiusproxy.py b/ipaserver/plugins/radiusproxy.py new file mode 100644 index 000000000..44d87b9ae --- /dev/null +++ b/ipaserver/plugins/radiusproxy.py @@ -0,0 +1,175 @@ +# Authors: +# Nathaniel McCallum <npmccallum@redhat.com> +# +# Copyright (C) 2013 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +from .baseldap import ( + LDAPObject, + LDAPCreate, + LDAPDelete, + LDAPUpdate, + LDAPSearch, + LDAPRetrieve) +from ipalib import api, Str, Int, Password, _, ngettext +from ipalib import errors +from ipalib.plugable import Registry +from ipalib.util import validate_hostname, validate_ipaddr +from ipalib.errors import ValidationError +import re + +__doc__ = _(""" +RADIUS Proxy Servers +""") + _(""" +Manage RADIUS Proxy Servers. +""") + _(""" +IPA supports the use of an external RADIUS proxy server for krb5 OTP +authentications. This permits a great deal of flexibility when +integrating with third-party authentication services. +""") + _(""" +EXAMPLES: +""") + _(""" + Add a new server: + ipa radiusproxy-add MyRADIUS --server=radius.example.com:1812 +""") + _(""" + Find all servers whose entries include the string "example.com": + ipa radiusproxy-find example.com +""") + _(""" + Examine the configuration: + ipa radiusproxy-show MyRADIUS +""") + _(""" + Change the secret: + ipa radiusproxy-mod MyRADIUS --secret +""") + _(""" + Delete a configuration: + ipa radiusproxy-del MyRADIUS +""") + +register = Registry() + +LDAP_ATTRIBUTE = re.compile("^[a-zA-Z][a-zA-Z0-9-]*$") +def validate_attributename(ugettext, attr): + if not LDAP_ATTRIBUTE.match(attr): + raise ValidationError(name="ipatokenusermapattribute", + error=_('invalid attribute name')) + +def validate_radiusserver(ugettext, server): + split = server.rsplit(':', 1) + server = split[0] + if len(split) == 2: + try: + port = int(split[1]) + if (port < 0 or port > 65535): + raise ValueError() + except ValueError: + raise ValidationError(name="ipatokenradiusserver", + error=_('invalid port number')) + + if validate_ipaddr(server): + return + + try: + validate_hostname(server, check_fqdn=True, allow_underscore=True) + except ValueError as e: + raise errors.ValidationError(name="ipatokenradiusserver", + error=str(e)) + + +@register() +class radiusproxy(LDAPObject): + """ + RADIUS Server object. + """ + container_dn = api.env.container_radiusproxy + object_name = _('RADIUS proxy server') + object_name_plural = _('RADIUS proxy servers') + object_class = ['ipatokenradiusconfiguration'] + default_attributes = ['cn', 'description', 'ipatokenradiusserver', + 'ipatokenradiustimeout', 'ipatokenradiusretries', 'ipatokenusermapattribute' + ] + search_attributes = ['cn', 'description', 'ipatokenradiusserver'] + rdn_is_primary_key = True + label = _('RADIUS Servers') + label_singular = _('RADIUS Server') + + takes_params = ( + Str('cn', + cli_name='name', + label=_('RADIUS proxy server name'), + primary_key=True, + ), + Str('description?', + cli_name='desc', + label=_('Description'), + doc=_('A description of this RADIUS proxy server'), + ), + Str('ipatokenradiusserver+', validate_radiusserver, + cli_name='server', + label=_('Server'), + doc=_('The hostname or IP (with or without port)'), + ), + Password('ipatokenradiussecret', + cli_name='secret', + label=_('Secret'), + doc=_('The secret used to encrypt data'), + confirm=True, + flags=['no_option'], + ), + Int('ipatokenradiustimeout?', + cli_name='timeout', + label=_('Timeout'), + doc=_('The total timeout across all retries (in seconds)'), + minvalue=1, + ), + Int('ipatokenradiusretries?', + cli_name='retries', + label=_('Retries'), + doc=_('The number of times to retry authentication'), + minvalue=0, + maxvalue=10, + ), + Str('ipatokenusermapattribute?', validate_attributename, + cli_name='userattr', + label=_('User attribute'), + doc=_('The username attribute on the user object'), + ), + ) + +@register() +class radiusproxy_add(LDAPCreate): + __doc__ = _('Add a new RADIUS proxy server.') + msg_summary = _('Added RADIUS proxy server "%(value)s"') + +@register() +class radiusproxy_del(LDAPDelete): + __doc__ = _('Delete a RADIUS proxy server.') + msg_summary = _('Deleted RADIUS proxy server "%(value)s"') + +@register() +class radiusproxy_mod(LDAPUpdate): + __doc__ = _('Modify a RADIUS proxy server.') + msg_summary = _('Modified RADIUS proxy server "%(value)s"') + +@register() +class radiusproxy_find(LDAPSearch): + __doc__ = _('Search for RADIUS proxy servers.') + msg_summary = ngettext( + '%(count)d RADIUS proxy server matched', '%(count)d RADIUS proxy servers matched', 0 + ) + +@register() +class radiusproxy_show(LDAPRetrieve): + __doc__ = _('Display information about a RADIUS proxy server.') diff --git a/ipaserver/plugins/realmdomains.py b/ipaserver/plugins/realmdomains.py new file mode 100644 index 000000000..3f8561091 --- /dev/null +++ b/ipaserver/plugins/realmdomains.py @@ -0,0 +1,340 @@ +# Authors: +# Ana Krivokapic <akrivoka@redhat.com> +# +# Copyright (C) 2013 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +import six + +from ipalib import api, errors, messages +from ipalib import Str, Flag +from ipalib import _ +from ipalib.plugable import Registry +from .baseldap import LDAPObject, LDAPUpdate, LDAPRetrieve +from ipalib.util import has_soa_or_ns_record, validate_domain_name +from ipalib.util import detect_dns_zone_realm_type +from ipapython.dn import DN +from ipapython.ipautil import get_domain_name + +if six.PY3: + unicode = str + +__doc__ = _(""" +Realm domains + +Manage the list of domains associated with IPA realm. + +EXAMPLES: + + Display the current list of realm domains: + ipa realmdomains-show + + Replace the list of realm domains: + ipa realmdomains-mod --domain=example.com + ipa realmdomains-mod --domain={example1.com,example2.com,example3.com} + + Add a domain to the list of realm domains: + ipa realmdomains-mod --add-domain=newdomain.com + + Delete a domain from the list of realm domains: + ipa realmdomains-mod --del-domain=olddomain.com +""") + +register = Registry() + +def _domain_name_normalizer(d): + return d.lower().rstrip('.') + +def _domain_name_validator(ugettext, value): + try: + validate_domain_name(value, allow_slash=False) + except ValueError as e: + return unicode(e) + + +@register() +class realmdomains(LDAPObject): + """ + List of domains associated with IPA realm. + """ + container_dn = api.env.container_realm_domains + permission_filter_objectclasses = ['domainrelatedobject'] + object_name = _('Realm domains') + search_attributes = ['associateddomain'] + default_attributes = ['associateddomain'] + managed_permissions = { + 'System: Read Realm Domains': { + 'replaces_global_anonymous_aci': True, + 'ipapermbindruletype': 'all', + 'ipapermright': {'read', 'search', 'compare'}, + 'ipapermdefaultattr': { + 'objectclass', 'cn', 'associateddomain', + }, + }, + 'System: Modify Realm Domains': { + 'ipapermbindruletype': 'permission', + 'ipapermright': {'write'}, + 'ipapermdefaultattr': { + 'associatedDomain', + }, + 'default_privileges': {'DNS Administrators'}, + }, + } + + label = _('Realm Domains') + label_singular = _('Realm Domains') + + takes_params = ( + Str('associateddomain+', + _domain_name_validator, + normalizer=_domain_name_normalizer, + cli_name='domain', + label=_('Domain'), + ), + Str('add_domain?', + _domain_name_validator, + normalizer=_domain_name_normalizer, + cli_name='add_domain', + label=_('Add domain'), + ), + Str('del_domain?', + _domain_name_validator, + normalizer=_domain_name_normalizer, + cli_name='del_domain', + label=_('Delete domain'), + ), + ) + + + +@register() +class realmdomains_mod(LDAPUpdate): + __doc__ = _('Modify realm domains.') + + takes_options = LDAPUpdate.takes_options + ( + Flag('force', + label=_('Force'), + doc=_('Force adding domain even if not in DNS'), + ), + ) + + def validate_domains(self, domains, force): + """ + Validates the list of domains as candidates for additions to the + realmdomains list. + + Requirements: + - Each domain has SOA or NS record + - Each domain belongs to the current realm + """ + + # Unless forced, check that each domain has SOA or NS records + if not force: + invalid_domains = [ + d for d in domains + if not has_soa_or_ns_record(d) + ] + + if invalid_domains: + raise errors.ValidationError( + name='domain', + error= _( + "DNS zone for each realmdomain must contain " + "SOA or NS records. No records found for: %s" + ) % ','.join(invalid_domains) + ) + + # Check realm alliegence for each domain + domains_with_realm = [ + (domain, detect_dns_zone_realm_type(self.api, domain)) + for domain in domains + ] + + foreign_domains = [ + domain for domain, realm in domains_with_realm + if realm == 'foreign' + ] + + unknown_domains = [ + domain for domain, realm in domains_with_realm + if realm == 'unknown' + ] + + # If there are any foreing realm domains, bail out + if foreign_domains: + raise errors.ValidationError( + name='domain', + error=_( + 'The following domains do not belong ' + 'to this realm: %(domains)s' + ) % dict(domains=','.join(foreign_domains)) + ) + + # If there are any unknown domains, error out, + # asking for _kerberos TXT records + + # Note: This can be forced, since realmdomains-mod + # is called from dnszone-add where we know that + # the domain being added belongs to our realm + if not force and unknown_domains: + raise errors.ValidationError( + name='domain', + error=_( + 'The realm of the following domains could ' + 'not be detected: %(domains)s. If these are ' + 'domains that belong to the this realm, please ' + 'create a _kerberos TXT record containing "%(realm)s" ' + 'in each of them.' + ) % dict(domains=','.join(unknown_domains), + realm=self.api.env.realm) + ) + + def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options): + assert isinstance(dn, DN) + associateddomain = entry_attrs.get('associateddomain') + add_domain = entry_attrs.get('add_domain') + del_domain = entry_attrs.get('del_domain') + force = options.get('force') + + current_domain = get_domain_name() + + # User specified the list of domains explicitly + if associateddomain: + if add_domain or del_domain: + raise errors.MutuallyExclusiveError( + reason=_( + "The --domain option cannot be used together " + "with --add-domain or --del-domain. Use --domain " + "to specify the whole realm domain list explicitly, " + "to add/remove individual domains, use " + "--add-domain/del-domain.") + ) + + # Make sure our domain is included in the list + if current_domain not in associateddomain: + raise errors.ValidationError( + name='realmdomain list', + error=_("IPA server domain cannot be omitted") + ) + + # Validate that each domain satisfies the requirements + # for realmdomain + self.validate_domains(domains=associateddomain, force=force) + + return dn + + # If --add-domain or --del-domain options were provided, read + # the curent list from LDAP, modify it, and write the changes back + domains = ldap.get_entry(dn)['associateddomain'] + + if add_domain: + self.validate_domains(domains=[add_domain], force=force) + del entry_attrs['add_domain'] + domains.append(add_domain) + + if del_domain: + if del_domain == current_domain: + raise errors.ValidationError( + name='del_domain', + error=_("IPA server domain cannot be deleted") + ) + del entry_attrs['del_domain'] + + try: + domains.remove(del_domain) + except ValueError: + raise errors.AttrValueNotFound( + attr='associateddomain', + value=del_domain + ) + + entry_attrs['associateddomain'] = domains + return dn + + def execute(self, *keys, **options): + dn = self.obj.get_dn(*keys, **options) + ldap = self.obj.backend + + domains_old = set(ldap.get_entry(dn)['associateddomain']) + result = super(realmdomains_mod, self).execute(*keys, **options) + domains_new = set(ldap.get_entry(dn)['associateddomain']) + + domains_added = domains_new - domains_old + domains_deleted = domains_old - domains_new + + # Add a _kerberos TXT record for zones that correspond with + # domains which were added + for domain in domains_added: + + # Skip our own domain + if domain == api.env.domain: + continue + + try: + self.api.Command['dnsrecord_add']( + unicode(domain), + u'_kerberos', + txtrecord=api.env.realm + ) + except (errors.EmptyModlist, errors.NotFound, + errors.ValidationError) as error: + + # If creation of the _kerberos TXT record failed, prompt + # for manual intervention + messages.add_message( + options['version'], + result, + messages.KerberosTXTRecordCreationFailure( + domain=domain, + error=unicode(error), + realm=self.api.env.realm + ) + ) + + # Delete _kerberos TXT record from zones that correspond with + # domains which were deleted + for domain in domains_deleted: + + # Skip our own domain + if domain == api.env.domain: + continue + + try: + self.api.Command['dnsrecord_del']( + unicode(domain), + u'_kerberos', + txtrecord=api.env.realm + ) + except (errors.AttrValueNotFound, errors.NotFound, + errors.ValidationError) as error: + # If deletion of the _kerberos TXT record failed, prompt + # for manual intervention + messages.add_message( + options['version'], + result, + messages.KerberosTXTRecordDeletionFailure( + domain=domain, error=unicode(error) + ) + ) + + return result + + + +@register() +class realmdomains_show(LDAPRetrieve): + __doc__ = _('Display the list of realm domains.') + diff --git a/ipaserver/plugins/role.py b/ipaserver/plugins/role.py new file mode 100644 index 000000000..f4f0c98d9 --- /dev/null +++ b/ipaserver/plugins/role.py @@ -0,0 +1,252 @@ +# Authors: +# Rob Crittenden <rcritten@redhat.com> +# Pavel Zuna <pzuna@redhat.com> +# +# Copyright (C) 2009 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +from ipalib.plugable import Registry +from .baseldap import ( + LDAPObject, + LDAPCreate, + LDAPDelete, + LDAPUpdate, + LDAPSearch, + LDAPRetrieve, + LDAPAddMember, + LDAPRemoveMember, + LDAPAddReverseMember, + LDAPRemoveReverseMember) +from ipalib import api, Str, _, ngettext +from ipalib import output + +__doc__ = _(""" +Roles + +A role is used for fine-grained delegation. A permission grants the ability +to perform given low-level tasks (add a user, modify a group, etc.). A +privilege combines one or more permissions into a higher-level abstraction +such as useradmin. A useradmin would be able to add, delete and modify users. + +Privileges are assigned to Roles. + +Users, groups, hosts and hostgroups may be members of a Role. + +Roles can not contain other roles. + +EXAMPLES: + + Add a new role: + ipa role-add --desc="Junior-level admin" junioradmin + + Add some privileges to this role: + ipa role-add-privilege --privileges=addusers junioradmin + ipa role-add-privilege --privileges=change_password junioradmin + ipa role-add-privilege --privileges=add_user_to_default_group junioradmin + + Add a group of users to this role: + ipa group-add --desc="User admins" useradmins + ipa role-add-member --groups=useradmins junioradmin + + Display information about a role: + ipa role-show junioradmin + + The result of this is that any users in the group 'junioradmin' can + add users, reset passwords or add a user to the default IPA user group. +""") + +register = Registry() + +@register() +class role(LDAPObject): + """ + Role object. + """ + container_dn = api.env.container_rolegroup + object_name = _('role') + object_name_plural = _('roles') + object_class = ['groupofnames', 'nestedgroup'] + permission_filter_objectclasses = ['groupofnames'] + default_attributes = ['cn', 'description', 'member', 'memberof'] + # Role could have a lot of indirect members, but they are not in + # attribute_members therefore they don't have to be in default_attributes + # 'memberindirect', 'memberofindirect', + + attribute_members = { + 'member': ['user', 'group', 'host', 'hostgroup', 'service'], + 'memberof': ['privilege'], + } + reverse_members = { + 'member': ['privilege'], + } + rdn_is_primary_key = True + managed_permissions = { + 'System: Read Roles': { + 'replaces_global_anonymous_aci': True, + 'ipapermright': {'read', 'search', 'compare'}, + 'ipapermdefaultattr': { + 'businesscategory', 'cn', 'description', 'member', 'memberof', + 'o', 'objectclass', 'ou', 'owner', 'seealso', 'memberuser', + 'memberhost', + }, + 'default_privileges': {'RBAC Readers'}, + }, + 'System: Add Roles': { + 'ipapermright': {'add'}, + 'replaces': [ + '(target = "ldap:///cn=*,cn=roles,cn=accounts,$SUFFIX")(version 3.0;acl "permission:Add Roles";allow (add) groupdn = "ldap:///cn=Add Roles,cn=permissions,cn=pbac,$SUFFIX";)', + ], + 'default_privileges': {'Delegation Administrator'}, + }, + 'System: Modify Role Membership': { + 'ipapermright': {'write'}, + 'ipapermdefaultattr': {'member'}, + 'replaces': [ + '(targetattr = "member")(target = "ldap:///cn=*,cn=roles,cn=accounts,$SUFFIX")(version 3.0;acl "permission:Modify Role membership";allow (write) groupdn = "ldap:///cn=Modify Role membership,cn=permissions,cn=pbac,$SUFFIX";)', + ], + 'default_privileges': {'Delegation Administrator'}, + }, + 'System: Modify Roles': { + 'ipapermright': {'write'}, + 'ipapermdefaultattr': {'cn', 'description'}, + 'replaces': [ + '(targetattr = "cn || description")(target = "ldap:///cn=*,cn=roles,cn=accounts,$SUFFIX")(version 3.0; acl "permission:Modify Roles";allow (write) groupdn = "ldap:///cn=Modify Roles,cn=permissions,cn=pbac,$SUFFIX";)', + ], + 'default_privileges': {'Delegation Administrator'}, + }, + 'System: Remove Roles': { + 'ipapermright': {'delete'}, + 'replaces': [ + '(target = "ldap:///cn=*,cn=roles,cn=accounts,$SUFFIX")(version 3.0;acl "permission:Remove Roles";allow (delete) groupdn = "ldap:///cn=Remove Roles,cn=permissions,cn=pbac,$SUFFIX";)', + ], + 'default_privileges': {'Delegation Administrator'}, + }, + } + + label = _('Roles') + label_singular = _('Role') + + takes_params = ( + Str('cn', + cli_name='name', + label=_('Role name'), + primary_key=True, + ), + Str('description?', + cli_name='desc', + label=_('Description'), + doc=_('A description of this role-group'), + ), + ) + + + +@register() +class role_add(LDAPCreate): + __doc__ = _('Add a new role.') + + msg_summary = _('Added role "%(value)s"') + + + +@register() +class role_del(LDAPDelete): + __doc__ = _('Delete a role.') + + msg_summary = _('Deleted role "%(value)s"') + + + +@register() +class role_mod(LDAPUpdate): + __doc__ = _('Modify a role.') + + msg_summary = _('Modified role "%(value)s"') + + + +@register() +class role_find(LDAPSearch): + __doc__ = _('Search for roles.') + + msg_summary = ngettext( + '%(count)d role matched', '%(count)d roles matched', 0 + ) + + + +@register() +class role_show(LDAPRetrieve): + __doc__ = _('Display information about a role.') + + + +@register() +class role_add_member(LDAPAddMember): + __doc__ = _('Add members to a role.') + + + +@register() +class role_remove_member(LDAPRemoveMember): + __doc__ = _('Remove members from a role.') + + + +@register() +class role_add_privilege(LDAPAddReverseMember): + __doc__ = _('Add privileges to a role.') + + show_command = 'role_show' + member_command = 'privilege_add_member' + reverse_attr = 'privilege' + member_attr = 'role' + + has_output = ( + output.Entry('result'), + output.Output('failed', + type=dict, + doc=_('Members that could not be added'), + ), + output.Output('completed', + type=int, + doc=_('Number of privileges added'), + ), + ) + + + +@register() +class role_remove_privilege(LDAPRemoveReverseMember): + __doc__ = _('Remove privileges from a role.') + + show_command = 'role_show' + member_command = 'privilege_remove_member' + reverse_attr = 'privilege' + member_attr = 'role' + + has_output = ( + output.Entry('result'), + output.Output('failed', + type=dict, + doc=_('Members that could not be added'), + ), + output.Output('completed', + type=int, + doc=_('Number of privileges removed'), + ), + ) + diff --git a/ipaserver/plugins/schema.py b/ipaserver/plugins/schema.py new file mode 100644 index 000000000..8bc230350 --- /dev/null +++ b/ipaserver/plugins/schema.py @@ -0,0 +1,660 @@ +# +# Copyright (C) 2016 FreeIPA Contributors see COPYING for license +# + +import importlib +import itertools +import sys + +import six + +from ipalib import errors +from ipalib.crud import PKQuery, Retrieve, Search +from ipalib.frontend import Command, Method, Object +from ipalib.output import Entry, ListOfEntries, ListOfPrimaryKeys, PrimaryKey +from ipalib.parameters import Any, Bool, Flag, Int, Str +from ipalib.plugable import Registry +from ipalib.text import _ +from ipapython.version import API_VERSION + +__doc__ = _(""" +API Schema +""") + _(""" +Provides API introspection capabilities. +""") + _(""" +EXAMPLES: +""") + _(""" + Show user-find details: + ipa command-show user-find +""") + _(""" + Find user-find parameters: + ipa param-find user-find +""") + +if six.PY3: + unicode = str + +register = Registry() + + +class BaseMetaObject(Object): + takes_params = ( + Str( + 'name', + label=_("Name"), + primary_key=True, + normalizer=lambda name: name.replace(u'-', u'_'), + flags={'no_search'}, + ), + Str( + 'doc?', + label=_("Documentation"), + flags={'no_search'}, + ), + ) + + def _get_obj(self, obj, **kwargs): + raise NotImplementedError() + + def _retrieve(self, *args, **kwargs): + raise NotImplementedError() + + def retrieve(self, *args, **kwargs): + obj = self._retrieve(*args, **kwargs) + obj = self._get_obj(obj, **kwargs) + return obj + + def _search(self, *args, **kwargs): + raise NotImplementedError() + + def _split_search_args(self, criteria=None): + return [], criteria + + def search(self, *args, **kwargs): + args, criteria = self._split_search_args(*args) + + result = self._search(*args, **kwargs) + result = (self._get_obj(r, **kwargs) for r in result) + + if criteria: + criteria = criteria.lower() + result = (r for r in result + if (criteria in r['name'].lower() or + criteria in r.get('doc', u'').lower())) + + if not kwargs.get('all', False) and kwargs.get('pkey_only', False): + result = ({'name': r['name']} for r in result) + + return result + + +class BaseMetaRetrieve(Retrieve): + def execute(self, *args, **options): + obj = self.obj.retrieve(*args, **options) + return dict(result=obj, value=args[-1]) + + +class BaseMetaSearch(Search): + def get_options(self): + for option in super(BaseMetaSearch, self).get_options(): + yield option + + yield Flag( + 'pkey_only?', + label=_("Primary key only"), + doc=_("Results should contain primary key attribute only " + "(\"%s\")") % 'name', + ) + + def execute(self, criteria=None, **options): + result = list(self.obj.search(criteria, **options)) + return dict(result=result, count=len(result), truncated=False) + + +class MetaObject(BaseMetaObject): + takes_params = BaseMetaObject.takes_params + ( + Str( + 'topic_topic?', + label=_("Help topic"), + flags={'no_search'}, + ), + ) + + +class MetaRetrieve(BaseMetaRetrieve): + pass + + +class MetaSearch(BaseMetaSearch): + pass + + +@register() +class command(MetaObject): + takes_params = BaseMetaObject.takes_params + ( + Str( + 'args_param*', + label=_("Arguments"), + flags={'no_search'}, + ), + Str( + 'options_param*', + label=_("Options"), + flags={'no_search'}, + ), + Str( + 'output_params_param*', + label=_("Output parameters"), + flags={'no_search'}, + ), + Bool( + 'no_cli?', + label=_("Exclude from CLI"), + flags={'no_search'}, + ), + ) + + def _get_obj(self, command, **kwargs): + obj = dict() + obj['name'] = unicode(command.name) + + if command.doc: + obj['doc'] = unicode(command.doc) + + if command.topic: + try: + topic = self.api.Object.topic.retrieve(unicode(command.topic)) + except errors.NotFound: + pass + else: + obj['topic_topic'] = topic['name'] + + if command.NO_CLI: + obj['no_cli'] = True + + if len(command.args): + obj['args_param'] = tuple(unicode(n) for n in command.args) + + if len(command.options): + obj['options_param'] = tuple( + unicode(n) for n in command.options if n != 'version') + + if len(command.output_params): + obj['output_params_param'] = tuple( + unicode(n) for n in command.output_params + if n not in command.params) + + return obj + + def _retrieve(self, name, **kwargs): + try: + return self.api.Command[name] + except KeyError: + raise errors.NotFound( + reason=_("%(pkey)s: %(oname)s not found") % { + 'pkey': name, 'oname': self.name, + } + ) + + def _search(self, **kwargs): + return self.api.Command() + + +@register() +class command_show(MetaRetrieve): + __doc__ = _("Display information about a command.") + + +@register() +class command_find(MetaSearch): + __doc__ = _("Search for commands.") + + +@register() +class command_defaults(PKQuery): + NO_CLI = True + + takes_options = ( + Str('params*'), + Any('kw?'), + ) + + def execute(self, name, **options): + command = self.api.Command[name] + + params = options.get('params', []) + + kw = options.get('kw', {}) + if not isinstance(kw, dict): + raise errors.ConversionError(name=name, + error=_("must be a dictionary")) + + result = command.get_default(**kw) + result = {n: v for n, v in result.items() if n in params} + + return dict(result=result) + + +@register() +class topic_(MetaObject): + name = 'topic' + + def __init__(self, api): + super(topic_, self).__init__(api) + self.__topics = None + + def __get_topics(self): + if self.__topics is None: + topics = {} + object.__setattr__(self, '_topic___topics', topics) + + for command in self.api.Command(): + topic_name = command.topic + + while topic_name is not None and topic_name not in topics: + topic = topics[topic_name] = {'name': topic_name} + + for package in self.api.packages: + module_name = '.'.join((package.__name__, topic_name)) + try: + module = sys.modules[module_name] + except KeyError: + try: + module = importlib.import_module(module_name) + except ImportError: + continue + + if module.__doc__ is not None: + topic['doc'] = unicode(module.__doc__).strip() + + try: + topic_name = module.topic + except AttributeError: + topic_name = None + else: + topic['topic_topic'] = topic_name + + return self.__topics + + def _get_obj(self, topic, **kwargs): + return topic + + def _retrieve(self, name, **kwargs): + try: + return self.__get_topics()[name] + except KeyError: + raise errors.NotFound( + reason=_("%(pkey)s: %(oname)s not found") % { + 'pkey': name, 'oname': self.name, + } + ) + + def _search(self, **kwargs): + return self.__get_topics().values() + + +@register() +class topic_show(MetaRetrieve): + __doc__ = _("Display information about a help topic.") + + +@register() +class topic_find(MetaSearch): + __doc__ = _("Search for help topics.") + + +class BaseParam(BaseMetaObject): + takes_params = BaseMetaObject.takes_params + ( + Str( + 'type?', + label=_("Type"), + flags={'no_search'}, + ), + Bool( + 'required?', + label=_("Required"), + flags={'no_search'}, + ), + Bool( + 'multivalue?', + label=_("Multi-value"), + flags={'no_search'}, + ), + ) + + def _split_search_args(self, commandname, criteria=None): + return [commandname], criteria + + +class BaseParamMethod(Method): + def get_args(self): + parent = self.api.Object.command + parent_key = parent.primary_key + yield parent_key.clone_rename( + parent.name + parent_key.name, + cli_name=parent.name, + label=parent_key.label, + required=True, + query=True, + ) + + for arg in super(BaseParamMethod, self).get_args(): + yield arg + + +class BaseParamRetrieve(BaseParamMethod, BaseMetaRetrieve): + pass + + +class BaseParamSearch(BaseParamMethod, BaseMetaSearch): + pass + + +@register() +class param(BaseParam): + takes_params = BaseParam.takes_params + ( + Bool( + 'alwaysask?', + label=_("Always ask"), + flags={'no_search'}, + ), + Bool( + 'autofill?', + label=_("Autofill"), + flags={'no_search'}, + ), + Str( + 'cli_metavar?', + label=_("CLI metavar"), + flags={'no_search'}, + ), + Str( + 'cli_name?', + label=_("CLI name"), + flags={'no_search'}, + ), + Bool( + 'confirm', + label=_("Confirm (password)"), + flags={'no_search'}, + ), + Str( + 'default*', + label=_("Default"), + flags={'no_search'}, + ), + Str( + 'default_from_param*', + label=_("Default from"), + flags={'no_search'}, + ), + Str( + 'deprecated_cli_aliases*', + label=_("Deprecated CLI aliases"), + flags={'no_search'}, + ), + Str( + 'exclude*', + label=_("Exclude from"), + flags={'no_search'}, + ), + Str( + 'hint?', + label=_("Hint"), + flags={'no_search'}, + ), + Str( + 'include*', + label=_("Include in"), + flags={'no_search'}, + ), + Str( + 'label?', + label=_("Label"), + flags={'no_search'}, + ), + Bool( + 'no_convert?', + label=_("Convert on server"), + flags={'no_search'}, + ), + Str( + 'option_group?', + label=_("Option group"), + flags={'no_search'}, + ), + Int( + 'sortorder?', + label=_("Sort order"), + flags={'no_search'}, + ), + Bool( + 'dnsrecord_extra?', + label=_("Extra field (DNS record)"), + flags={'no_search'}, + ), + Bool( + 'dnsrecord_part?', + label=_("Part (DNS record)"), + flags={'no_search'}, + ), + Bool( + 'no_option?', + label=_("No option"), + flags={'no_search'}, + ), + Bool( + 'suppress_empty?', + label=_("Suppress empty"), + flags={'no_search'}, + ), + Bool( + 'sensitive?', + label=_("Sensitive"), + flags={'no_search'}, + ), + ) + + def _get_obj(self, param, **kwargs): + obj = dict() + obj['name'] = unicode(param.name) + + if param.type is unicode: + obj['type'] = u'str' + elif param.type is bytes: + obj['type'] = u'bytes' + elif param.type is not None: + obj['type'] = unicode(param.type.__name__) + + if not param.required: + obj['required'] = False + if param.multivalue: + obj['multivalue'] = True + if param.password: + obj['sensitive'] = True + + for key, value in param._Param__clonekw.items(): + if key in ('alwaysask', + 'autofill', + 'confirm', + 'sortorder'): + obj[key] = value + elif key in ('cli_metavar', + 'cli_name', + 'doc', + 'hint', + 'label', + 'option_group'): + obj[key] = unicode(value) + elif key == 'default': + if param.multivalue: + obj[key] = [unicode(v) for v in value] + else: + obj[key] = [unicode(value)] + elif key == 'default_from': + obj['default_from_param'] = list(unicode(k) + for k in value.keys) + elif key in ('deprecated_cli_aliases', + 'exclude', + 'include'): + obj[key] = list(unicode(v) for v in value) + elif key in ('exponential', + 'normalizer', + 'only_absolute', + 'precision'): + obj['no_convert'] = True + + for flag in (param.flags or []): + if flag in ('dnsrecord_extra', + 'dnsrecord_part', + 'no_option', + 'suppress_empty'): + obj[flag] = True + + return obj + + def _retrieve(self, commandname, name, **kwargs): + command = self.api.Command[commandname] + + if name != 'version': + try: + return command.params[name] + except KeyError: + try: + return command.output_params[name] + except KeyError: + pass + + raise errors.NotFound( + reason=_("%(pkey)s: %(oname)s not found") % { + 'pkey': name, 'oname': self.name, + } + ) + + def _search(self, commandname, **kwargs): + command = self.api.Command[commandname] + + result = itertools.chain( + (p for p in command.params() if p.name != 'version'), + (p for p in command.output_params() + if p.name not in command.params)) + + return result + + +@register() +class param_show(BaseParamRetrieve): + __doc__ = _("Display information about a command parameter.") + + +@register() +class param_find(BaseParamSearch): + __doc__ = _("Search command parameters.") + + +@register() +class output(BaseParam): + takes_params = BaseParam.takes_params + ( + Bool( + 'no_display?', + label=_("Do not display"), + flags={'no_search'}, + ), + ) + + def _get_obj(self, command_output, **kwargs): + command, output = command_output + required = True + multivalue = False + + if isinstance(output, (Entry, ListOfEntries)): + type_type = dict + multivalue = isinstance(output, ListOfEntries) + elif isinstance(output, (PrimaryKey, ListOfPrimaryKeys)): + if getattr(command, 'obj', None) and command.obj.primary_key: + type_type = command.obj.primary_key.type + else: + type_type = type(None) + multivalue = isinstance(output, ListOfPrimaryKeys) + elif isinstance(output.type, tuple): + if tuple in output.type or list in output.type: + type_type = None + multivalue = True + else: + type_type = output.type[0] + required = type(None) not in output.type + else: + type_type = output.type + + obj = dict() + obj['name'] = unicode(output.name) + + if type_type is unicode: + obj['type'] = u'str' + elif type_type is bytes: + obj['type'] = u'bytes' + elif type_type is not None: + obj['type'] = unicode(type_type.__name__) + + if not required: + obj['required'] = False + + if multivalue: + obj['multivalue'] = True + + if 'doc' in output.__dict__: + obj['doc'] = unicode(output.doc) + + if 'flags' in output.__dict__: + if 'no_display' in output.flags: + obj['no_display'] = True + + return obj + + def _retrieve(self, commandname, name, **kwargs): + command = self.api.Command[commandname] + try: + return (command, command.output[name]) + except KeyError: + raise errors.NotFound( + reason=_("%(pkey)s: %(oname)s not found") % { + 'pkey': name, 'oname': self.name, + } + ) + + def _search(self, commandname, **kwargs): + command = self.api.Command[commandname] + return ((command, output) for output in command.output()) + + +@register() +class output_show(BaseParamRetrieve): + __doc__ = _("Display information about a command output.") + + +@register() +class output_find(BaseParamSearch): + __doc__ = _("Search for command outputs.") + + +@register() +class schema(Command): + NO_CLI = True + + def execute(self, *args, **kwargs): + commands = list(self.api.Object.command.search(**kwargs)) + for command in commands: + name = command['name'] + command['params'] = list( + self.api.Object.param.search(name, **kwargs)) + command['output'] = list( + self.api.Object.output.search(name, **kwargs)) + + topics = list(self.api.Object.topic.search(**kwargs)) + + schema = dict() + schema['version'] = API_VERSION + schema['commands'] = commands + schema['topics'] = topics + + return dict(result=schema) diff --git a/ipaserver/plugins/selfservice.py b/ipaserver/plugins/selfservice.py new file mode 100644 index 000000000..4ff6ac744 --- /dev/null +++ b/ipaserver/plugins/selfservice.py @@ -0,0 +1,224 @@ +# Authors: +# Rob Crittenden <rcritten@redhat.com> +# +# Copyright (C) 2010 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +from ipalib import _, ngettext +from ipalib import Str +from ipalib import api, crud, errors +from ipalib import output +from ipalib import Object +from ipalib.plugable import Registry +from .baseldap import gen_pkey_only_option, pkey_to_value + +__doc__ = _(""" +Self-service Permissions + +A permission enables fine-grained delegation of permissions. Access Control +Rules, or instructions (ACIs), grant permission to permissions to perform +given tasks such as adding a user, modifying a group, etc. + +A Self-service permission defines what an object can change in its own entry. + + +EXAMPLES: + + Add a self-service rule to allow users to manage their address (using Bash + brace expansion): + ipa selfservice-add --permissions=write --attrs={street,postalCode,l,c,st} "Users manage their own address" + + When managing the list of attributes you need to include all attributes + in the list, including existing ones. + Add telephoneNumber to the list (using Bash brace expansion): + ipa selfservice-mod --attrs={street,postalCode,l,c,st,telephoneNumber} "Users manage their own address" + + Display our updated rule: + ipa selfservice-show "Users manage their own address" + + Delete a rule: + ipa selfservice-del "Users manage their own address" +""") + +register = Registry() + +ACI_PREFIX=u"selfservice" + +output_params = ( + Str('aci', + label=_('ACI'), + ), +) + + +@register() +class selfservice(Object): + """ + Selfservice object. + """ + + bindable = False + object_name = _('self service permission') + object_name_plural = _('self service permissions') + label = _('Self Service Permissions') + label_singular = _('Self Service Permission') + + takes_params = ( + Str('aciname', + cli_name='name', + label=_('Self-service name'), + doc=_('Self-service name'), + primary_key=True, + pattern='^[-_ a-zA-Z0-9]+$', + pattern_errmsg="May only contain letters, numbers, -, _, and space", + ), + Str('permissions*', + cli_name='permissions', + label=_('Permissions'), + doc=_('Permissions to grant (read, write). Default is write.'), + ), + Str('attrs+', + cli_name='attrs', + label=_('Attributes'), + doc=_('Attributes to which the permission applies.'), + normalizer=lambda value: value.lower(), + ), + ) + + def __json__(self): + json_friendly_attributes = ( + 'label', 'label_singular', 'takes_params', 'bindable', 'name', + 'object_name', 'object_name_plural', + ) + json_dict = dict( + (a, getattr(self, a)) for a in json_friendly_attributes + ) + json_dict['primary_key'] = self.primary_key.name + json_dict['methods'] = [m for m in self.methods] + return json_dict + + def postprocess_result(self, result): + try: + # do not include prefix in result + del result['aciprefix'] + except KeyError: + pass + + + +@register() +class selfservice_add(crud.Create): + __doc__ = _('Add a new self-service permission.') + + msg_summary = _('Added selfservice "%(value)s"') + has_output_params = output_params + + def execute(self, aciname, **kw): + if not 'permissions' in kw: + kw['permissions'] = (u'write',) + kw['selfaci'] = True + kw['aciprefix'] = ACI_PREFIX + result = api.Command['aci_add'](aciname, **kw)['result'] + self.obj.postprocess_result(result) + + return dict( + result=result, + value=pkey_to_value(aciname, kw), + ) + + + +@register() +class selfservice_del(crud.Delete): + __doc__ = _('Delete a self-service permission.') + + has_output = output.standard_boolean + msg_summary = _('Deleted selfservice "%(value)s"') + + def execute(self, aciname, **kw): + result = api.Command['aci_del'](aciname, aciprefix=ACI_PREFIX) + self.obj.postprocess_result(result) + + return dict( + result=True, + value=pkey_to_value(aciname, kw), + ) + + + +@register() +class selfservice_mod(crud.Update): + __doc__ = _('Modify a self-service permission.') + + msg_summary = _('Modified selfservice "%(value)s"') + has_output_params = output_params + + def execute(self, aciname, **kw): + if 'attrs' in kw and kw['attrs'] is None: + raise errors.RequirementError(name='attrs') + + kw['aciprefix'] = ACI_PREFIX + result = api.Command['aci_mod'](aciname, **kw)['result'] + self.obj.postprocess_result(result) + + return dict( + result=result, + value=pkey_to_value(aciname, kw), + ) + + + +@register() +class selfservice_find(crud.Search): + __doc__ = _('Search for a self-service permission.') + + msg_summary = ngettext( + '%(count)d selfservice matched', '%(count)d selfservices matched', 0 + ) + + takes_options = (gen_pkey_only_option("name"),) + has_output_params = output_params + + def execute(self, term=None, **kw): + kw['selfaci'] = True + kw['aciprefix'] = ACI_PREFIX + result = api.Command['aci_find'](term, **kw)['result'] + + for aci in result: + self.obj.postprocess_result(aci) + + return dict( + result=result, + count=len(result), + truncated=False, + ) + + + +@register() +class selfservice_show(crud.Retrieve): + __doc__ = _('Display information about a self-service permission.') + + has_output_params = output_params + + def execute(self, aciname, **kw): + result = api.Command['aci_show'](aciname, aciprefix=ACI_PREFIX, **kw)['result'] + self.obj.postprocess_result(result) + return dict( + result=result, + value=pkey_to_value(aciname, kw), + ) + diff --git a/ipaserver/plugins/selinuxusermap.py b/ipaserver/plugins/selinuxusermap.py new file mode 100644 index 000000000..8f660d089 --- /dev/null +++ b/ipaserver/plugins/selinuxusermap.py @@ -0,0 +1,569 @@ +# Authors: +# Rob Crittenden <rcritten@redhat.com> +# +# Copyright (C) 2011 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +import re + +from ipalib import api, errors +from ipalib import Str, StrEnum, Bool +from ipalib.plugable import Registry +from .baseldap import ( + pkey_to_value, + LDAPObject, + LDAPCreate, + LDAPDelete, + LDAPUpdate, + LDAPSearch, + LDAPRetrieve, + LDAPQuery, + LDAPAddMember, + LDAPRemoveMember) +from ipalib import _, ngettext +from ipalib import output +from .hbacrule import is_all +from ipapython.dn import DN + +__doc__ = _(""" +SELinux User Mapping + +Map IPA users to SELinux users by host. + +Hosts, hostgroups, users and groups can be either defined within +the rule or it may point to an existing HBAC rule. When using +--hbacrule option to selinuxusermap-find an exact match is made on the +HBAC rule name, so only one or zero entries will be returned. + +EXAMPLES: + + Create a rule, "test1", that sets all users to xguest_u:s0 on the host "server": + ipa selinuxusermap-add --usercat=all --selinuxuser=xguest_u:s0 test1 + ipa selinuxusermap-add-host --hosts=server.example.com test1 + + Create a rule, "test2", that sets all users to guest_u:s0 and uses an existing HBAC rule for users and hosts: + ipa selinuxusermap-add --usercat=all --hbacrule=webserver --selinuxuser=guest_u:s0 test2 + + Display the properties of a rule: + ipa selinuxusermap-show test2 + + Create a rule for a specific user. This sets the SELinux context for + user john to unconfined_u:s0-s0:c0.c1023 on any machine: + ipa selinuxusermap-add --hostcat=all --selinuxuser=unconfined_u:s0-s0:c0.c1023 john_unconfined + ipa selinuxusermap-add-user --users=john john_unconfined + + Disable a rule: + ipa selinuxusermap-disable test1 + + Enable a rule: + ipa selinuxusermap-enable test1 + + Find a rule referencing a specific HBAC rule: + ipa selinuxusermap-find --hbacrule=allow_some + + Remove a rule: + ipa selinuxusermap-del john_unconfined + +SEEALSO: + + The list controlling the order in which the SELinux user map is applied + and the default SELinux user are available in the config-show command. +""") + +register = Registry() + +notboth_err = _('HBAC rule and local members cannot both be set') + + +def validate_selinuxuser(ugettext, user): + """ + An SELinux user has 3 components: user:MLS:MCS. user and MLS are required. + user traditionally ends with _u but this is not mandatory. + The regex is ^[a-zA-Z][a-zA-Z_]* + + The MLS part can only be: + Level: s[0-15](-s[0-15]) + + Then MCS could be c[0-1023].c[0-1023] and/or c[0-1023]-c[0-c0123] + Meaning + s0 s0-s1 s0-s15:c0.c1023 s0-s1:c0,c2,c15.c26 s0-s0:c0.c1023 + + Returns a message on invalid, returns nothing on valid. + """ + regex_name = re.compile(r'^[a-zA-Z][a-zA-Z_]*$') + regex_mls = re.compile(r'^s[0-9][1-5]{0,1}(-s[0-9][1-5]{0,1}){0,1}$') + regex_mcs = re.compile(r'^c(\d+)([.,-]c(\d+))*?$') + + # If we add in ::: we don't have to check to see if some values are + # empty + (name, mls, mcs, ignore) = (user + ':::').split(':', 3) + + if not regex_name.match(name): + return _('Invalid SELinux user name, only a-Z and _ are allowed') + if not mls or not regex_mls.match(mls): + return _('Invalid MLS value, must match s[0-15](-s[0-15])') + m = regex_mcs.match(mcs) + if mcs and (not m or (m.group(3) and (int(m.group(3)) > 1023))): + return _('Invalid MCS value, must match c[0-1023].c[0-1023] ' + 'and/or c[0-1023]-c[0-c0123]') + + return None + + +def validate_selinuxuser_inlist(ldap, user): + """ + Ensure the user is in the list of allowed SELinux users. + + Returns nothing if the user is found, raises an exception otherwise. + """ + config = ldap.get_ipa_config() + item = config.get('ipaselinuxusermaporder', []) + if len(item) != 1: + raise errors.NotFound(reason=_('SELinux user map list not ' + 'found in configuration')) + userlist = item[0].split('$') + if user not in userlist: + raise errors.NotFound( + reason=_('SELinux user %(user)s not found in ' + 'ordering list (in config)') % dict(user=user)) + + return + + +@register() +class selinuxusermap(LDAPObject): + """ + SELinux User Map object. + """ + container_dn = api.env.container_selinux + object_name = _('SELinux User Map rule') + object_name_plural = _('SELinux User Map rules') + object_class = ['ipaassociation', 'ipaselinuxusermap'] + permission_filter_objectclasses = ['ipaselinuxusermap'] + default_attributes = [ + 'cn', 'ipaenabledflag', + 'description', 'usercategory', 'hostcategory', + 'ipaenabledflag', 'memberuser', 'memberhost', + 'seealso', 'ipaselinuxuser', + ] + uuid_attribute = 'ipauniqueid' + rdn_attribute = 'ipauniqueid' + attribute_members = { + 'memberuser': ['user', 'group'], + 'memberhost': ['host', 'hostgroup'], + } + managed_permissions = { + 'System: Read SELinux User Maps': { + 'replaces_global_anonymous_aci': True, + 'ipapermbindruletype': 'all', + 'ipapermright': {'read', 'search', 'compare'}, + 'ipapermdefaultattr': { + 'accesstime', 'cn', 'description', 'hostcategory', + 'ipaenabledflag', 'ipaselinuxuser', 'ipauniqueid', + 'memberhost', 'memberuser', 'seealso', 'usercategory', + 'objectclass', 'member', + }, + }, + 'System: Add SELinux User Maps': { + 'ipapermright': {'add'}, + 'replaces': [ + '(target = "ldap:///ipauniqueid=*,cn=usermap,cn=selinux,$SUFFIX")(version 3.0;acl "permission:Add SELinux User Maps";allow (add) groupdn = "ldap:///cn=Add SELinux User Maps,cn=permissions,cn=pbac,$SUFFIX";)', + ], + 'default_privileges': {'SELinux User Map Administrators'}, + }, + 'System: Modify SELinux User Maps': { + 'ipapermright': {'write'}, + 'ipapermdefaultattr': { + 'cn', 'ipaenabledflag', 'ipaselinuxuser', 'memberhost', + 'memberuser', 'seealso' + }, + 'replaces': [ + '(targetattr = "cn || memberuser || memberhost || seealso || ipaselinuxuser || ipaenabledflag")(target = "ldap:///ipauniqueid=*,cn=usermap,cn=selinux,$SUFFIX")(version 3.0;acl "permission:Modify SELinux User Maps";allow (write) groupdn = "ldap:///cn=Modify SELinux User Maps,cn=permissions,cn=pbac,$SUFFIX";)', + ], + 'default_privileges': {'SELinux User Map Administrators'}, + }, + 'System: Remove SELinux User Maps': { + 'ipapermright': {'delete'}, + 'replaces': [ + '(target = "ldap:///ipauniqueid=*,cn=usermap,cn=selinux,$SUFFIX")(version 3.0;acl "permission:Remove SELinux User Maps";allow (delete) groupdn = "ldap:///cn=Remove SELinux User Maps,cn=permissions,cn=pbac,$SUFFIX";)', + ], + 'default_privileges': {'SELinux User Map Administrators'}, + }, + } + + # These maps will not show as members of other entries + + label = _('SELinux User Maps') + label_singular = _('SELinux User Map') + + takes_params = ( + Str('cn', + cli_name='name', + label=_('Rule name'), + primary_key=True, + ), + Str('ipaselinuxuser', validate_selinuxuser, + cli_name='selinuxuser', + label=_('SELinux User'), + ), + Str('seealso?', + cli_name='hbacrule', + label=_('HBAC Rule'), + doc=_('HBAC Rule that defines the users, groups and hostgroups'), + ), + StrEnum('usercategory?', + cli_name='usercat', + label=_('User category'), + doc=_('User category the rule applies to'), + values=(u'all', ), + ), + StrEnum('hostcategory?', + cli_name='hostcat', + label=_('Host category'), + doc=_('Host category the rule applies to'), + values=(u'all', ), + ), + Str('description?', + cli_name='desc', + label=_('Description'), + ), + Bool('ipaenabledflag?', + label=_('Enabled'), + flags=['no_option'], + ), + Str('memberuser_user?', + label=_('Users'), + flags=['no_create', 'no_update', 'no_search'], + ), + Str('memberuser_group?', + label=_('User Groups'), + flags=['no_create', 'no_update', 'no_search'], + ), + Str('memberhost_host?', + label=_('Hosts'), + flags=['no_create', 'no_update', 'no_search'], + ), + Str('memberhost_hostgroup?', + label=_('Host Groups'), + flags=['no_create', 'no_update', 'no_search'], + ), + ) + + def _normalize_seealso(self, seealso): + """ + Given a HBAC rule name verify its existence and return the dn. + """ + if not seealso: + return None + + try: + dn = DN(seealso) + return str(dn) + except ValueError: + try: + entry_attrs = self.backend.find_entry_by_attr( + self.api.Object['hbacrule'].primary_key.name, + seealso, + self.api.Object['hbacrule'].object_class, + [''], + DN(self.api.Object['hbacrule'].container_dn, api.env.basedn)) + seealso = entry_attrs.dn + except errors.NotFound: + raise errors.NotFound(reason=_('HBAC rule %(rule)s not found') % dict(rule=seealso)) + + return seealso + + def _convert_seealso(self, ldap, entry_attrs, **options): + """ + Convert an HBAC rule dn into a name + """ + if options.get('raw', False): + return + + if 'seealso' in entry_attrs: + hbac_attrs = ldap.get_entry(entry_attrs['seealso'][0], ['cn']) + entry_attrs['seealso'] = hbac_attrs['cn'][0] + + + +@register() +class selinuxusermap_add(LDAPCreate): + __doc__ = _('Create a new SELinux User Map.') + + msg_summary = _('Added SELinux User Map "%(value)s"') + + def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options): + assert isinstance(dn, DN) + # rules are enabled by default + entry_attrs['ipaenabledflag'] = 'TRUE' + validate_selinuxuser_inlist(ldap, entry_attrs['ipaselinuxuser']) + + # hbacrule is not allowed when usercat or hostcat is set + is_to_be_set = lambda x: x in entry_attrs and entry_attrs[x] != None + + are_local_members_to_be_set = any(is_to_be_set(attr) + for attr in ('usercategory', + 'hostcategory')) + + is_hbacrule_to_be_set = is_to_be_set('seealso') + + if is_hbacrule_to_be_set and are_local_members_to_be_set: + raise errors.MutuallyExclusiveError(reason=notboth_err) + + if is_hbacrule_to_be_set: + entry_attrs['seealso'] = self.obj._normalize_seealso(entry_attrs['seealso']) + + return dn + + def post_callback(self, ldap, dn, entry_attrs, *keys, **options): + assert isinstance(dn, DN) + self.obj._convert_seealso(ldap, entry_attrs, **options) + + return dn + + + +@register() +class selinuxusermap_del(LDAPDelete): + __doc__ = _('Delete a SELinux User Map.') + + msg_summary = _('Deleted SELinux User Map "%(value)s"') + + + +@register() +class selinuxusermap_mod(LDAPUpdate): + __doc__ = _('Modify a SELinux User Map.') + + msg_summary = _('Modified SELinux User Map "%(value)s"') + + def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options): + assert isinstance(dn, DN) + try: + _entry_attrs = ldap.get_entry(dn, attrs_list) + except errors.NotFound: + self.obj.handle_not_found(*keys) + + is_to_be_deleted = lambda x: (x in _entry_attrs and x in entry_attrs) and \ + entry_attrs[x] == None + + # makes sure the local members and hbacrule is not set at the same time + # memberuser or memberhost could have been set using --setattr + is_to_be_set = lambda x: ((x in _entry_attrs and _entry_attrs[x] != None) or \ + (x in entry_attrs and entry_attrs[x] != None)) and \ + not is_to_be_deleted(x) + + are_local_members_to_be_set = any(is_to_be_set(attr) + for attr in ('usercategory', + 'hostcategory', + 'memberuser', + 'memberhost')) + + is_hbacrule_to_be_set = is_to_be_set('seealso') + + # this can disable all modifications if hbacrule and local members were + # set at the same time bypassing this commad, e.g. using ldapmodify + if are_local_members_to_be_set and is_hbacrule_to_be_set: + raise errors.MutuallyExclusiveError(reason=notboth_err) + + if is_all(entry_attrs, 'usercategory') and 'memberuser' in entry_attrs: + raise errors.MutuallyExclusiveError(reason="user category " + "cannot be set to 'all' while there are allowed users") + if is_all(entry_attrs, 'hostcategory') and 'memberhost' in entry_attrs: + raise errors.MutuallyExclusiveError(reason="host category " + "cannot be set to 'all' while there are allowed hosts") + + if 'ipaselinuxuser' in entry_attrs: + validate_selinuxuser_inlist(ldap, entry_attrs['ipaselinuxuser']) + + if 'seealso' in entry_attrs: + entry_attrs['seealso'] = self.obj._normalize_seealso(entry_attrs['seealso']) + return dn + + def post_callback(self, ldap, dn, entry_attrs, *keys, **options): + assert isinstance(dn, DN) + self.obj._convert_seealso(ldap, entry_attrs, **options) + return dn + + + +@register() +class selinuxusermap_find(LDAPSearch): + __doc__ = _('Search for SELinux User Maps.') + + msg_summary = ngettext( + '%(count)d SELinux User Map matched', '%(count)d SELinux User Maps matched', 0 + ) + + def execute(self, *args, **options): + # If searching on hbacrule we need to find the uuid to search on + if options.get('seealso'): + hbacrule = options['seealso'] + + try: + hbac = api.Command['hbacrule_show'](hbacrule, +all=True)['result'] + dn = hbac['dn'] + except errors.NotFound: + return dict(count=0, result=[], truncated=False) + options['seealso'] = dn + + return super(selinuxusermap_find, self).execute(*args, **options) + + def post_callback(self, ldap, entries, truncated, *args, **options): + if options.get('pkey_only', False): + return truncated + for attrs in entries: + self.obj._convert_seealso(ldap, attrs, **options) + return truncated + + + +@register() +class selinuxusermap_show(LDAPRetrieve): + __doc__ = _('Display the properties of a SELinux User Map rule.') + + def post_callback(self, ldap, dn, entry_attrs, *keys, **options): + assert isinstance(dn, DN) + self.obj._convert_seealso(ldap, entry_attrs, **options) + return dn + + + +@register() +class selinuxusermap_enable(LDAPQuery): + __doc__ = _('Enable an SELinux User Map rule.') + + msg_summary = _('Enabled SELinux User Map "%(value)s"') + has_output = output.standard_value + + def execute(self, cn, **options): + ldap = self.obj.backend + + dn = self.obj.get_dn(cn) + try: + entry_attrs = ldap.get_entry(dn, ['ipaenabledflag']) + except errors.NotFound: + self.obj.handle_not_found(cn) + + entry_attrs['ipaenabledflag'] = ['TRUE'] + + try: + ldap.update_entry(entry_attrs) + except errors.EmptyModlist: + raise errors.AlreadyActive() + + return dict( + result=True, + value=pkey_to_value(cn, options), + ) + + + +@register() +class selinuxusermap_disable(LDAPQuery): + __doc__ = _('Disable an SELinux User Map rule.') + + msg_summary = _('Disabled SELinux User Map "%(value)s"') + has_output = output.standard_value + + def execute(self, cn, **options): + ldap = self.obj.backend + + dn = self.obj.get_dn(cn) + try: + entry_attrs = ldap.get_entry(dn, ['ipaenabledflag']) + except errors.NotFound: + self.obj.handle_not_found(cn) + + entry_attrs['ipaenabledflag'] = ['FALSE'] + + try: + ldap.update_entry(entry_attrs) + except errors.EmptyModlist: + raise errors.AlreadyInactive() + + return dict( + result=True, + value=pkey_to_value(cn, options), + ) + + + +@register() +class selinuxusermap_add_user(LDAPAddMember): + __doc__ = _('Add users and groups to an SELinux User Map rule.') + + member_attributes = ['memberuser'] + member_count_out = ('%i object added.', '%i objects added.') + + def pre_callback(self, ldap, dn, found, not_found, *keys, **options): + assert isinstance(dn, DN) + try: + entry_attrs = ldap.get_entry(dn, self.obj.default_attributes) + dn = entry_attrs.dn + except errors.NotFound: + self.obj.handle_not_found(*keys) + if 'usercategory' in entry_attrs and \ + entry_attrs['usercategory'][0].lower() == 'all': + raise errors.MutuallyExclusiveError( + reason=_("users cannot be added when user category='all'")) + if 'seealso' in entry_attrs: + raise errors.MutuallyExclusiveError(reason=notboth_err) + return dn + + + +@register() +class selinuxusermap_remove_user(LDAPRemoveMember): + __doc__ = _('Remove users and groups from an SELinux User Map rule.') + + member_attributes = ['memberuser'] + member_count_out = ('%i object removed.', '%i objects removed.') + + + +@register() +class selinuxusermap_add_host(LDAPAddMember): + __doc__ = _('Add target hosts and hostgroups to an SELinux User Map rule.') + + member_attributes = ['memberhost'] + member_count_out = ('%i object added.', '%i objects added.') + + def pre_callback(self, ldap, dn, found, not_found, *keys, **options): + assert isinstance(dn, DN) + try: + entry_attrs = ldap.get_entry(dn, self.obj.default_attributes) + dn = entry_attrs.dn + except errors.NotFound: + self.obj.handle_not_found(*keys) + if 'hostcategory' in entry_attrs and \ + entry_attrs['hostcategory'][0].lower() == 'all': + raise errors.MutuallyExclusiveError( + reason=_("hosts cannot be added when host category='all'")) + if 'seealso' in entry_attrs: + raise errors.MutuallyExclusiveError(reason=notboth_err) + return dn + + + +@register() +class selinuxusermap_remove_host(LDAPRemoveMember): + __doc__ = _('Remove target hosts and hostgroups from an SELinux User Map rule.') + + member_attributes = ['memberhost'] + member_count_out = ('%i object removed.', '%i objects removed.') + diff --git a/ipaserver/plugins/server.py b/ipaserver/plugins/server.py new file mode 100644 index 000000000..6faaf8ec5 --- /dev/null +++ b/ipaserver/plugins/server.py @@ -0,0 +1,260 @@ +# +# Copyright (C) 2015 FreeIPA Contributors see COPYING for license +# + +import dbus +import dbus.mainloop.glib + +from ipalib import api, crud, errors, messages +from ipalib import Int, Str +from ipalib.plugable import Registry +from .baseldap import ( + LDAPSearch, + LDAPRetrieve, + LDAPDelete, + LDAPObject) +from ipalib.request import context +from ipalib import _, ngettext +from ipalib import output + +__doc__ = _(""" +IPA servers +""") + _(""" +Get information about installed IPA servers. +""") + _(""" +EXAMPLES: +""") + _(""" + Find all servers: + ipa server-find +""") + _(""" + Show specific server: + ipa server-show ipa.example.com +""") + +register = Registry() + + +@register() +class server(LDAPObject): + """ + IPA server + """ + container_dn = api.env.container_masters + object_name = _('server') + object_name_plural = _('servers') + object_class = ['top'] + search_attributes = ['cn'] + default_attributes = [ + 'cn', 'iparepltopomanagedsuffix', 'ipamindomainlevel', + 'ipamaxdomainlevel' + ] + label = _('IPA Servers') + label_singular = _('IPA Server') + attribute_members = { + 'iparepltopomanagedsuffix': ['topologysuffix'], + } + relationships = { + 'iparepltopomanagedsuffix': ('Managed', '', 'no_'), + } + takes_params = ( + Str( + 'cn', + cli_name='name', + primary_key=True, + label=_('Server name'), + doc=_('IPA server hostname'), + ), + Str( + 'iparepltopomanagedsuffix*', + flags={'no_create', 'no_update', 'no_search'}, + ), + Str( + 'iparepltopomanagedsuffix_topologysuffix*', + label=_('Managed suffixes'), + flags={'virtual_attribute', 'no_create', 'no_update', 'no_search'}, + ), + Int( + 'ipamindomainlevel', + cli_name='minlevel', + label=_('Min domain level'), + doc=_('Minimum domain level'), + flags={'no_create', 'no_update'}, + ), + Int( + 'ipamaxdomainlevel', + cli_name='maxlevel', + label=_('Max domain level'), + doc=_('Maximum domain level'), + flags={'no_create', 'no_update'}, + ), + ) + + def _get_suffixes(self): + suffixes = self.api.Command.topologysuffix_find( + all=True, raw=True, + )['result'] + suffixes = [(s['iparepltopoconfroot'][0], s['dn']) for s in suffixes] + return suffixes + + def _apply_suffixes(self, entry, suffixes): + # change suffix DNs to topologysuffix entry DNs + # this fixes LDAPObject.convert_attribute_members() for suffixes + suffixes = dict(suffixes) + if 'iparepltopomanagedsuffix' in entry: + entry['iparepltopomanagedsuffix'] = [ + suffixes.get(m, m) for m in entry['iparepltopomanagedsuffix'] + ] + + +@register() +class server_find(LDAPSearch): + __doc__ = _('Search for IPA servers.') + + msg_summary = ngettext( + '%(count)d IPA server matched', + '%(count)d IPA servers matched', 0 + ) + member_attributes = ['iparepltopomanagedsuffix'] + + def get_options(self): + for option in super(server_find, self).get_options(): + if option.name == 'topologysuffix': + option = option.clone(cli_name='topologysuffixes') + elif option.name == 'no_topologysuffix': + option = option.clone(cli_name='no_topologysuffixes') + yield option + + def get_member_filter(self, ldap, **options): + options.pop('topologysuffix', None) + options.pop('no_topologysuffix', None) + + return super(server_find, self).get_member_filter(ldap, **options) + + def pre_callback(self, ldap, filters, attrs_list, base_dn, scope, + *args, **options): + included = options.get('topologysuffix') + excluded = options.get('no_topologysuffix') + + if included or excluded: + topologysuffix = self.api.Object.topologysuffix + suffixes = self.obj._get_suffixes() + suffixes = {s[1]: s[0] for s in suffixes} + + if included: + included = [topologysuffix.get_dn(pk) for pk in included] + try: + included = [suffixes[dn] for dn in included] + except KeyError: + # force empty result + filter = '(!(objectclass=*))' + else: + filter = ldap.make_filter_from_attr( + 'iparepltopomanagedsuffix', included, ldap.MATCH_ALL + ) + filters = ldap.combine_filters( + (filters, filter), ldap.MATCH_ALL + ) + + if excluded: + excluded = [topologysuffix.get_dn(pk) for pk in excluded] + excluded = [suffixes[dn] for dn in excluded if dn in suffixes] + filter = ldap.make_filter_from_attr( + 'iparepltopomanagedsuffix', excluded, ldap.MATCH_NONE + ) + filters = ldap.combine_filters( + (filters, filter), ldap.MATCH_ALL + ) + + return (filters, base_dn, scope) + + def post_callback(self, ldap, entries, truncated, *args, **options): + if not options.get('raw', False): + suffixes = self.obj._get_suffixes() + for entry in entries: + self.obj._apply_suffixes(entry, suffixes) + + return truncated + + +@register() +class server_show(LDAPRetrieve): + __doc__ = _('Show IPA server.') + + def post_callback(self, ldap, dn, entry, *keys, **options): + if not options.get('raw', False): + suffixes = self.obj._get_suffixes() + self.obj._apply_suffixes(entry, suffixes) + + return dn + + +@register() +class server_del(LDAPDelete): + __doc__ = _('Delete IPA server.') + NO_CLI = True + msg_summary = _('Deleted IPA server "%(value)s"') + + +@register() +class server_conncheck(crud.PKQuery): + __doc__ = _("Check connection to remote IPA server.") + + NO_CLI = True + + takes_args = ( + Str( + 'remote_cn', + cli_name='remote_name', + label=_('Remote server name'), + doc=_('Remote IPA server hostname'), + ), + ) + + has_output = output.standard_value + + def execute(self, *keys, **options): + # the server must be the local host + if keys[-2] != api.env.host: + raise errors.ValidationError( + name='cn', error=_("must be \"%s\"") % api.env.host) + + # the server entry must exist + try: + self.obj.get_dn_if_exists(*keys[:-1]) + except errors.NotFound: + self.obj.handle_not_found(keys[-2]) + + # the user must have the Replication Administrators privilege + privilege = u'Replication Administrators' + privilege_dn = self.api.Object.privilege.get_dn(privilege) + ldap = self.obj.backend + filter = ldap.make_filter({ + 'krbprincipalname': context.principal, # pylint: disable=no-member + 'memberof': privilege_dn}, + rules=ldap.MATCH_ALL) + try: + ldap.find_entries(base_dn=self.api.env.basedn, filter=filter) + except errors.NotFound: + raise errors.ACIError( + info=_("not allowed to perform server connection check")) + + dbus.mainloop.glib.DBusGMainLoop(set_as_default=True) + + bus = dbus.SystemBus() + obj = bus.get_object('org.freeipa.server', '/', + follow_name_owner_changes=True) + server = dbus.Interface(obj, 'org.freeipa.server') + + ret, stdout, stderr = server.conncheck(keys[-1]) + + result = dict( + result=(ret == 0), + value=keys[-2], + ) + + for line in stdout.splitlines(): + messages.add_message(options['version'], + result, + messages.ExternalCommandOutput(line=line)) + + return result diff --git a/ipaserver/plugins/service.py b/ipaserver/plugins/service.py new file mode 100644 index 000000000..7e3735583 --- /dev/null +++ b/ipaserver/plugins/service.py @@ -0,0 +1,889 @@ +# Authors: +# Jason Gerard DeRose <jderose@redhat.com> +# Rob Crittenden <rcritten@redhat.com> +# Pavel Zuna <pzuna@redhat.com> +# +# Copyright (C) 2008 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +import six + +from ipalib import api, errors +from ipalib import Bytes, StrEnum, Bool, Str, Flag +from ipalib.plugable import Registry +from .baseldap import ( + host_is_master, + add_missing_object_class, + pkey_to_value, + LDAPObject, + LDAPCreate, + LDAPDelete, + LDAPUpdate, + LDAPSearch, + LDAPRetrieve, + LDAPAddMember, + LDAPRemoveMember, + LDAPQuery, + LDAPAddAttribute, + LDAPRemoveAttribute) +from ipalib import x509 +from ipalib import _, ngettext +from ipalib import util +from ipalib import output +from ipapython.dn import DN + +import nss.nss as nss + + +if six.PY3: + unicode = str + +__doc__ = _(""" +Services + +A IPA service represents a service that runs on a host. The IPA service +record can store a Kerberos principal, an SSL certificate, or both. + +An IPA service can be managed directly from a machine, provided that +machine has been given the correct permission. This is true even for +machines other than the one the service is associated with. For example, +requesting an SSL certificate using the host service principal credentials +of the host. To manage a service using host credentials you need to +kinit as the host: + + # kinit -kt /etc/krb5.keytab host/ipa.example.com@EXAMPLE.COM + +Adding an IPA service allows the associated service to request an SSL +certificate or keytab, but this is performed as a separate step; they +are not produced as a result of adding the service. + +Only the public aspect of a certificate is stored in a service record; +the private key is not stored. + +EXAMPLES: + + Add a new IPA service: + ipa service-add HTTP/web.example.com + + Allow a host to manage an IPA service certificate: + ipa service-add-host --hosts=web.example.com HTTP/web.example.com + ipa role-add-member --hosts=web.example.com certadmin + + Override a default list of supported PAC types for the service: + ipa service-mod HTTP/web.example.com --pac-type=MS-PAC + + A typical use case where overriding the PAC type is needed is NFS. + Currently the related code in the Linux kernel can only handle Kerberos + tickets up to a maximal size. Since the PAC data can become quite large it + is recommended to set --pac-type=NONE for NFS services. + + Delete an IPA service: + ipa service-del HTTP/web.example.com + + Find all IPA services associated with a host: + ipa service-find web.example.com + + Find all HTTP services: + ipa service-find HTTP + + Disable the service Kerberos key and SSL certificate: + ipa service-disable HTTP/web.example.com + + Request a certificate for an IPA service: + ipa cert-request --principal=HTTP/web.example.com example.csr +""") + _(""" + Allow user to create a keytab: + ipa service-allow-create-keytab HTTP/web.example.com --users=tuser1 +""") + _(""" + Generate and retrieve a keytab for an IPA service: + ipa-getkeytab -s ipa.example.com -p HTTP/web.example.com -k /etc/httpd/httpd.keytab + +""") + +register = Registry() + +output_params = ( + Flag('has_keytab', + label=_('Keytab'), + ), + Str('managedby_host', + label='Managed by', + ), + Str('subject', + label=_('Subject'), + ), + Str('serial_number', + label=_('Serial Number'), + ), + Str('serial_number_hex', + label=_('Serial Number (hex)'), + ), + Str('issuer', + label=_('Issuer'), + ), + Str('valid_not_before', + label=_('Not Before'), + ), + Str('valid_not_after', + label=_('Not After'), + ), + Str('md5_fingerprint', + label=_('Fingerprint (MD5)'), + ), + Str('sha1_fingerprint', + label=_('Fingerprint (SHA1)'), + ), + Str('revocation_reason?', + label=_('Revocation reason'), + ), + Str('ipaallowedtoperform_read_keys_user', + label=_('Users allowed to retrieve keytab'), + ), + Str('ipaallowedtoperform_read_keys_group', + label=_('Groups allowed to retrieve keytab'), + ), + Str('ipaallowedtoperform_read_keys_host', + label=_('Hosts allowed to retrieve keytab'), + ), + Str('ipaallowedtoperform_read_keys_hostgroup', + label=_('Host Groups allowed to retrieve keytab'), + ), + Str('ipaallowedtoperform_write_keys_user', + label=_('Users allowed to create keytab'), + ), + Str('ipaallowedtoperform_write_keys_group', + label=_('Groups allowed to create keytab'), + ), + Str('ipaallowedtoperform_write_keys_host', + label=_('Hosts allowed to create keytab'), + ), + Str('ipaallowedtoperform_write_keys_hostgroup', + label=_('Host Groups allowed to create keytab'), + ), + Str('ipaallowedtoperform_read_keys', + label=_('Failed allowed to retrieve keytab'), + ), + Str('ipaallowedtoperform_write_keys', + label=_('Failed allowed to create keytab'), + ), +) + +ticket_flags_params = ( + Bool('ipakrbrequirespreauth?', + cli_name='requires_pre_auth', + label=_('Requires pre-authentication'), + doc=_('Pre-authentication is required for the service'), + flags=['virtual_attribute', 'no_search'], + ), + Bool('ipakrbokasdelegate?', + cli_name='ok_as_delegate', + label=_('Trusted for delegation'), + doc=_('Client credentials may be delegated to the service'), + flags=['virtual_attribute', 'no_search'], + ), +) + +_ticket_flags_map = { + 'ipakrbrequirespreauth': 0x00000080, + 'ipakrbokasdelegate': 0x00100000, +} + +_ticket_flags_default = _ticket_flags_map['ipakrbrequirespreauth'] + +def split_any_principal(principal): + service = hostname = realm = None + + # Break down the principal into its component parts, which may or + # may not include the realm. + sp = principal.split('/') + name_and_realm = None + if len(sp) > 2: + raise errors.MalformedServicePrincipal(reason=_('unable to determine service')) + elif len(sp) == 2: + service = sp[0] + if len(service) == 0: + raise errors.MalformedServicePrincipal(reason=_('blank service')) + name_and_realm = sp[1] + else: + name_and_realm = sp[0] + + sr = name_and_realm.split('@') + if len(sr) > 2: + raise errors.MalformedServicePrincipal( + reason=_('unable to determine realm')) + + hostname = sr[0].lower() + if len(sr) == 2: + realm = sr[1].upper() + # At some point we'll support multiple realms + if realm != api.env.realm: + raise errors.RealmMismatch() + else: + realm = api.env.realm + + # Note that realm may be None. + return service, hostname, realm + +def split_principal(principal): + service, name, realm = split_any_principal(principal) + if service is None: + raise errors.MalformedServicePrincipal(reason=_('missing service')) + return service, name, realm + +def validate_principal(ugettext, principal): + (service, hostname, principal) = split_principal(principal) + return None + +def normalize_principal(principal): + # The principal is already validated when it gets here + (service, hostname, realm) = split_principal(principal) + # Put the principal back together again + principal = '%s/%s@%s' % (service, hostname, realm) + return unicode(principal) + +def validate_certificate(ugettext, cert): + """ + Check whether the certificate is properly encoded to DER + """ + if api.env.in_server: + x509.validate_certificate(cert, datatype=x509.DER) + + +def revoke_certs(certs, logger=None): + """ + revoke the certificates removed from host/service entry + """ + for cert in certs: + try: + cert = x509.normalize_certificate(cert) + except errors.CertificateFormatError as e: + if logger is not None: + logger.info("Problem decoding certificate: %s" % e) + + serial = unicode(x509.get_serial_number(cert, x509.DER)) + + try: + result = api.Command['cert_show'](unicode(serial))['result'] + except errors.CertificateOperationError: + continue + if 'revocation_reason' in result: + continue + if x509.normalize_certificate(result['certificate']) != cert: + continue + + try: + api.Command['cert_revoke'](unicode(serial), + revocation_reason=4) + except errors.NotImplementedError: + # some CA's might not implement revoke + pass + + + +def set_certificate_attrs(entry_attrs): + """ + Set individual attributes from some values from a certificate. + + entry_attrs is a dict of an entry + + returns nothing + """ + if not 'usercertificate' in entry_attrs: + return + if type(entry_attrs['usercertificate']) in (list, tuple): + cert = entry_attrs['usercertificate'][0] + else: + cert = entry_attrs['usercertificate'] + cert = x509.normalize_certificate(cert) + cert = x509.load_certificate(cert, datatype=x509.DER) + entry_attrs['subject'] = unicode(cert.subject) + entry_attrs['serial_number'] = unicode(cert.serial_number) + entry_attrs['serial_number_hex'] = u'0x%X' % cert.serial_number + entry_attrs['issuer'] = unicode(cert.issuer) + entry_attrs['valid_not_before'] = unicode(cert.valid_not_before_str) + entry_attrs['valid_not_after'] = unicode(cert.valid_not_after_str) + entry_attrs['md5_fingerprint'] = unicode(nss.data_to_hex(nss.md5_digest(cert.der_data), 64)[0]) + entry_attrs['sha1_fingerprint'] = unicode(nss.data_to_hex(nss.sha1_digest(cert.der_data), 64)[0]) + +def check_required_principal(ldap, hostname, service): + """ + Raise an error if the host of this prinicipal is an IPA master and one + of the principals required for proper execution. + """ + try: + host_is_master(ldap, hostname) + except errors.ValidationError as e: + service_types = ['HTTP', 'ldap', 'DNS', 'dogtagldap'] + if service in service_types: + raise errors.ValidationError(name='principal', error=_('This principal is required by the IPA master')) + +def update_krbticketflags(ldap, entry_attrs, attrs_list, options, existing): + add = remove = 0 + + for (name, value) in _ticket_flags_map.items(): + if name not in options: + continue + if options[name]: + add |= value + else: + remove |= value + + if not add and not remove: + return + + if 'krbticketflags' not in entry_attrs and existing: + old_entry_attrs = ldap.get_entry(entry_attrs.dn, ['krbticketflags']) + else: + old_entry_attrs = entry_attrs + + try: + ticket_flags = old_entry_attrs.single_value['krbticketflags'] + ticket_flags = int(ticket_flags) + except (KeyError, ValueError): + ticket_flags = _ticket_flags_default + + ticket_flags |= add + ticket_flags &= ~remove + + entry_attrs['krbticketflags'] = [ticket_flags] + attrs_list.append('krbticketflags') + +def set_kerberos_attrs(entry_attrs, options): + if options.get('raw', False): + return + + try: + ticket_flags = entry_attrs.single_value.get('krbticketflags', + _ticket_flags_default) + ticket_flags = int(ticket_flags) + except ValueError: + return + + all_opt = options.get('all', False) + + for (name, value) in _ticket_flags_map.items(): + if name in options or all_opt: + entry_attrs[name] = bool(ticket_flags & value) + +def rename_ipaallowedtoperform_from_ldap(entry_attrs, options): + if options.get('raw', False): + return + + for subtype in ('read_keys', 'write_keys'): + name = 'ipaallowedtoperform;%s' % subtype + if name in entry_attrs: + new_name = 'ipaallowedtoperform_%s' % subtype + entry_attrs[new_name] = entry_attrs.pop(name) + +def rename_ipaallowedtoperform_to_ldap(entry_attrs): + for subtype in ('read_keys', 'write_keys'): + name = 'ipaallowedtoperform_%s' % subtype + if name in entry_attrs: + new_name = 'ipaallowedtoperform;%s' % subtype + entry_attrs[new_name] = entry_attrs.pop(name) + +@register() +class service(LDAPObject): + """ + Service object. + """ + container_dn = api.env.container_service + object_name = _('service') + object_name_plural = _('services') + object_class = [ + 'krbprincipal', 'krbprincipalaux', 'krbticketpolicyaux', 'ipaobject', + 'ipaservice', 'pkiuser' + ] + possible_objectclasses = ['ipakrbprincipal', 'ipaallowedoperations'] + permission_filter_objectclasses = ['ipaservice'] + search_attributes = ['krbprincipalname', 'managedby', 'ipakrbauthzdata'] + default_attributes = ['krbprincipalname', 'usercertificate', 'managedby', + 'ipakrbauthzdata', 'memberof', 'ipaallowedtoperform', 'krbprincipalauthind'] + uuid_attribute = 'ipauniqueid' + attribute_members = { + 'managedby': ['host'], + 'memberof': ['role'], + 'ipaallowedtoperform_read_keys': ['user', 'group', 'host', 'hostgroup'], + 'ipaallowedtoperform_write_keys': ['user', 'group', 'host', 'hostgroup'], + } + bindable = True + relationships = { + 'managedby': ('Managed by', 'man_by_', 'not_man_by_'), + 'ipaallowedtoperform_read_keys': ('Allow to retrieve keytab by', 'retrieve_keytab_by_', 'not_retrieve_keytab_by_'), + 'ipaallowedtoperform_write_keys': ('Allow to create keytab by', 'write_keytab_by_', 'not_write_keytab_by'), + } + password_attributes = [('krbprincipalkey', 'has_keytab')] + managed_permissions = { + 'System: Read Services': { + 'replaces_global_anonymous_aci': True, + 'ipapermbindruletype': 'all', + 'ipapermright': {'read', 'search', 'compare'}, + 'ipapermdefaultattr': { + 'objectclass', + 'ipauniqueid', 'managedby', 'memberof', 'usercertificate', + 'krbprincipalname', 'krbcanonicalname', 'krbprincipalaliases', + 'krbprincipalexpiration', 'krbpasswordexpiration', + 'krblastpwdchange', 'ipakrbauthzdata', 'ipakrbprincipalalias', + 'krbobjectreferences', + }, + }, + 'System: Add Services': { + 'ipapermright': {'add'}, + 'replaces': [ + '(target = "ldap:///krbprincipalname=*,cn=services,cn=accounts,$SUFFIX")(version 3.0;acl "permission:Add Services";allow (add) groupdn = "ldap:///cn=Add Services,cn=permissions,cn=pbac,$SUFFIX";)', + ], + 'default_privileges': {'Service Administrators'}, + }, + 'System: Manage Service Keytab': { + 'ipapermright': {'write'}, + 'ipapermdefaultattr': {'krblastpwdchange', 'krbprincipalkey'}, + 'replaces': [ + '(targetattr = "krbprincipalkey || krblastpwdchange")(target = "ldap:///krbprincipalname=*,cn=services,cn=accounts,$SUFFIX")(version 3.0;acl "permission:Manage service keytab";allow (write) groupdn = "ldap:///cn=Manage service keytab,cn=permissions,cn=pbac,$SUFFIX";)', + ], + 'default_privileges': {'Service Administrators', 'Host Administrators'}, + }, + 'System: Manage Service Keytab Permissions': { + 'ipapermright': {'read', 'search', 'compare', 'write'}, + 'ipapermdefaultattr': { + 'ipaallowedtoperform;write_keys', + 'ipaallowedtoperform;read_keys', 'objectclass' + }, + 'default_privileges': {'Service Administrators', 'Host Administrators'}, + }, + 'System: Modify Services': { + 'ipapermright': {'write'}, + 'ipapermdefaultattr': {'usercertificate'}, + 'replaces': [ + '(targetattr = "usercertificate")(target = "ldap:///krbprincipalname=*,cn=services,cn=accounts,$SUFFIX")(version 3.0;acl "permission:Modify Services";allow (write) groupdn = "ldap:///cn=Modify Services,cn=permissions,cn=pbac,$SUFFIX";)', + ], + 'default_privileges': {'Service Administrators'}, + }, + 'System: Remove Services': { + 'ipapermright': {'delete'}, + 'replaces': [ + '(target = "ldap:///krbprincipalname=*,cn=services,cn=accounts,$SUFFIX")(version 3.0;acl "permission:Remove Services";allow (delete) groupdn = "ldap:///cn=Remove Services,cn=permissions,cn=pbac,$SUFFIX";)', + ], + 'default_privileges': {'Service Administrators'}, + }, + } + + label = _('Services') + label_singular = _('Service') + + takes_params = ( + Str('krbprincipalname', validate_principal, + cli_name='principal', + label=_('Principal'), + doc=_('Service principal'), + primary_key=True, + normalizer=lambda value: normalize_principal(value), + ), + Bytes('usercertificate*', validate_certificate, + cli_name='certificate', + label=_('Certificate'), + doc=_('Base-64 encoded service certificate'), + flags=['no_search',], + ), + StrEnum('ipakrbauthzdata*', + cli_name='pac_type', + label=_('PAC type'), + doc=_("Override default list of supported PAC types." + " Use 'NONE' to disable PAC support for this service," + " e.g. this might be necessary for NFS services."), + values=(u'MS-PAC', u'PAD', u'NONE'), + ), + Str('krbprincipalauthind*', + cli_name='auth_ind', + label=_('Authentication Indicators'), + doc=_("Defines a whitelist for Authentication Indicators." + " Use 'otp' to allow OTP-based 2FA authentications." + " Use 'radius' to allow RADIUS-based 2FA authentications." + " Other values may be used for custom configurations."), + ), + ) + ticket_flags_params + + def validate_ipakrbauthzdata(self, entry): + new_value = entry.get('ipakrbauthzdata', []) + + if not new_value: + return + + if not isinstance(new_value, (list, tuple)): + new_value = set([new_value]) + else: + new_value = set(new_value) + + if u'NONE' in new_value and len(new_value) > 1: + raise errors.ValidationError(name='ipakrbauthzdata', + error=_('NONE value cannot be combined with other PAC types')) + + def get_dn(self, *keys, **kwargs): + keys = (normalize_principal(k) for k in keys) + return super(service, self).get_dn(*keys, **kwargs) + + +@register() +class service_add(LDAPCreate): + __doc__ = _('Add a new IPA service.') + + msg_summary = _('Added service "%(value)s"') + member_attributes = ['managedby'] + has_output_params = LDAPCreate.has_output_params + output_params + takes_options = LDAPCreate.takes_options + ( + Flag('force', + label=_('Force'), + doc=_('force principal name even if not in DNS'), + ), + ) + + def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options): + assert isinstance(dn, DN) + (service, hostname, realm) = split_principal(keys[-1]) + if service.lower() == 'host' and not options['force']: + raise errors.HostService() + + try: + hostresult = api.Command['host_show'](hostname)['result'] + except errors.NotFound: + raise errors.NotFound( + reason=_("The host '%s' does not exist to add a service to.") % + hostname) + + self.obj.validate_ipakrbauthzdata(entry_attrs) + + certs = options.get('usercertificate', []) + certs_der = [x509.normalize_certificate(c) for c in certs] + for dercert in certs_der: + x509.verify_cert_subject(ldap, hostname, dercert) + entry_attrs['usercertificate'] = certs_der + + if not options.get('force', False): + # We know the host exists if we've gotten this far but we + # really want to discourage creating services for hosts that + # don't exist in DNS. + util.verify_host_resolvable(hostname) + if not 'managedby' in entry_attrs: + entry_attrs['managedby'] = hostresult['dn'] + + # Enforce ipaKrbPrincipalAlias to aid case-insensitive searches + # as krbPrincipalName/krbCanonicalName are case-sensitive in Kerberos + # schema + entry_attrs['ipakrbprincipalalias'] = keys[-1] + + # Objectclass ipakrbprincipal providing ipakrbprincipalalias is not in + # in a list of default objectclasses, add it manually + entry_attrs['objectclass'].append('ipakrbprincipal') + + update_krbticketflags(ldap, entry_attrs, attrs_list, options, False) + + return dn + + def post_callback(self, ldap, dn, entry_attrs, *keys, **options): + set_kerberos_attrs(entry_attrs, options) + rename_ipaallowedtoperform_from_ldap(entry_attrs, options) + return dn + + + +@register() +class service_del(LDAPDelete): + __doc__ = _('Delete an IPA service.') + + msg_summary = _('Deleted service "%(value)s"') + member_attributes = ['managedby'] + def pre_callback(self, ldap, dn, *keys, **options): + assert isinstance(dn, DN) + # In the case of services we don't want IPA master services to be + # deleted. This is a limited few though. If the user has their own + # custom services allow them to manage them. + (service, hostname, realm) = split_principal(keys[-1]) + check_required_principal(ldap, hostname, service) + if self.api.Command.ca_is_enabled()['result']: + try: + entry_attrs = ldap.get_entry(dn, ['usercertificate']) + except errors.NotFound: + self.obj.handle_not_found(*keys) + revoke_certs(entry_attrs.get('usercertificate', []), self.log) + + return dn + + + +@register() +class service_mod(LDAPUpdate): + __doc__ = _('Modify an existing IPA service.') + + msg_summary = _('Modified service "%(value)s"') + takes_options = LDAPUpdate.takes_options + has_output_params = LDAPUpdate.has_output_params + output_params + + member_attributes = ['managedby'] + + def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options): + assert isinstance(dn, DN) + + self.obj.validate_ipakrbauthzdata(entry_attrs) + + (service, hostname, realm) = split_principal(keys[-1]) + + # verify certificates + certs = entry_attrs.get('usercertificate') or [] + certs_der = [x509.normalize_certificate(c) for c in certs] + for dercert in certs_der: + x509.verify_cert_subject(ldap, hostname, dercert) + # revoke removed certificates + if certs and self.api.Command.ca_is_enabled()['result']: + try: + entry_attrs_old = ldap.get_entry(dn, ['usercertificate']) + except errors.NotFound: + self.obj.handle_not_found(*keys) + old_certs = entry_attrs_old.get('usercertificate', []) + old_certs_der = [x509.normalize_certificate(c) for c in old_certs] + removed_certs_der = set(old_certs_der) - set(certs_der) + revoke_certs(removed_certs_der, self.log) + + if certs: + entry_attrs['usercertificate'] = certs_der + + update_krbticketflags(ldap, entry_attrs, attrs_list, options, True) + + return dn + + def post_callback(self, ldap, dn, entry_attrs, *keys, **options): + assert isinstance(dn, DN) + set_certificate_attrs(entry_attrs) + set_kerberos_attrs(entry_attrs, options) + rename_ipaallowedtoperform_from_ldap(entry_attrs, options) + return dn + + + +@register() +class service_find(LDAPSearch): + __doc__ = _('Search for IPA services.') + + msg_summary = ngettext( + '%(count)d service matched', '%(count)d services matched', 0 + ) + member_attributes = ['managedby'] + takes_options = LDAPSearch.takes_options + has_output_params = LDAPSearch.has_output_params + output_params + + def pre_callback(self, ldap, filter, attrs_list, base_dn, scope, *args, **options): + assert isinstance(base_dn, DN) + # lisp style! + custom_filter = '(&(objectclass=ipaService)' \ + '(!(objectClass=posixAccount))' \ + '(!(|(krbprincipalname=kadmin/*)' \ + '(krbprincipalname=K/M@*)' \ + '(krbprincipalname=krbtgt/*))' \ + ')' \ + ')' + return ( + ldap.combine_filters((custom_filter, filter), rules=ldap.MATCH_ALL), + base_dn, scope + ) + + def post_callback(self, ldap, entries, truncated, *args, **options): + if options.get('pkey_only', False): + return truncated + for entry_attrs in entries: + self.obj.get_password_attributes(ldap, entry_attrs.dn, entry_attrs) + set_certificate_attrs(entry_attrs) + set_kerberos_attrs(entry_attrs, options) + rename_ipaallowedtoperform_from_ldap(entry_attrs, options) + return truncated + + + +@register() +class service_show(LDAPRetrieve): + __doc__ = _('Display information about an IPA service.') + + member_attributes = ['managedby'] + takes_options = LDAPRetrieve.takes_options + ( + Str('out?', + doc=_('file to store certificate in'), + ), + ) + has_output_params = LDAPRetrieve.has_output_params + output_params + + def post_callback(self, ldap, dn, entry_attrs, *keys, **options): + assert isinstance(dn, DN) + self.obj.get_password_attributes(ldap, dn, entry_attrs) + + set_certificate_attrs(entry_attrs) + set_kerberos_attrs(entry_attrs, options) + rename_ipaallowedtoperform_from_ldap(entry_attrs, options) + + return dn + + +@register() +class service_add_host(LDAPAddMember): + __doc__ = _('Add hosts that can manage this service.') + + member_attributes = ['managedby'] + has_output_params = LDAPAddMember.has_output_params + output_params + + + +@register() +class service_remove_host(LDAPRemoveMember): + __doc__ = _('Remove hosts that can manage this service.') + + member_attributes = ['managedby'] + has_output_params = LDAPRemoveMember.has_output_params + output_params + + +@register() +class service_allow_retrieve_keytab(LDAPAddMember): + __doc__ = _('Allow users, groups, hosts or host groups to retrieve a keytab' + ' of this service.') + member_attributes = ['ipaallowedtoperform_read_keys'] + has_output_params = LDAPAddMember.has_output_params + output_params + + def pre_callback(self, ldap, dn, found, not_found, *keys, **options): + rename_ipaallowedtoperform_to_ldap(found) + rename_ipaallowedtoperform_to_ldap(not_found) + add_missing_object_class(ldap, u'ipaallowedoperations', dn) + return dn + + def post_callback(self, ldap, completed, failed, dn, entry_attrs, *keys, **options): + rename_ipaallowedtoperform_from_ldap(entry_attrs, options) + rename_ipaallowedtoperform_from_ldap(failed, options) + return (completed, dn) + + +@register() +class service_disallow_retrieve_keytab(LDAPRemoveMember): + __doc__ = _('Disallow users, groups, hosts or host groups to retrieve a ' + 'keytab of this service.') + member_attributes = ['ipaallowedtoperform_read_keys'] + has_output_params = LDAPRemoveMember.has_output_params + output_params + + def pre_callback(self, ldap, dn, found, not_found, *keys, **options): + rename_ipaallowedtoperform_to_ldap(found) + rename_ipaallowedtoperform_to_ldap(not_found) + return dn + + def post_callback(self, ldap, completed, failed, dn, entry_attrs, *keys, **options): + rename_ipaallowedtoperform_from_ldap(entry_attrs, options) + rename_ipaallowedtoperform_from_ldap(failed, options) + return (completed, dn) + + +@register() +class service_allow_create_keytab(LDAPAddMember): + __doc__ = _('Allow users, groups, hosts or host groups to create a keytab ' + 'of this service.') + member_attributes = ['ipaallowedtoperform_write_keys'] + has_output_params = LDAPAddMember.has_output_params + output_params + + def pre_callback(self, ldap, dn, found, not_found, *keys, **options): + rename_ipaallowedtoperform_to_ldap(found) + rename_ipaallowedtoperform_to_ldap(not_found) + add_missing_object_class(ldap, u'ipaallowedoperations', dn) + return dn + + def post_callback(self, ldap, completed, failed, dn, entry_attrs, *keys, **options): + rename_ipaallowedtoperform_from_ldap(entry_attrs, options) + rename_ipaallowedtoperform_from_ldap(failed, options) + return (completed, dn) + + +@register() +class service_disallow_create_keytab(LDAPRemoveMember): + __doc__ = _('Disallow users, groups, hosts or host groups to create a ' + 'keytab of this service.') + member_attributes = ['ipaallowedtoperform_write_keys'] + has_output_params = LDAPRemoveMember.has_output_params + output_params + + def pre_callback(self, ldap, dn, found, not_found, *keys, **options): + rename_ipaallowedtoperform_to_ldap(found) + rename_ipaallowedtoperform_to_ldap(not_found) + return dn + + def post_callback(self, ldap, completed, failed, dn, entry_attrs, *keys, **options): + rename_ipaallowedtoperform_from_ldap(entry_attrs, options) + rename_ipaallowedtoperform_from_ldap(failed, options) + return (completed, dn) + + +@register() +class service_disable(LDAPQuery): + __doc__ = _('Disable the Kerberos key and SSL certificate of a service.') + + has_output = output.standard_value + msg_summary = _('Disabled service "%(value)s"') + has_output_params = LDAPQuery.has_output_params + output_params + + def execute(self, *keys, **options): + ldap = self.obj.backend + + dn = self.obj.get_dn(*keys, **options) + entry_attrs = ldap.get_entry(dn, ['usercertificate']) + + (service, hostname, realm) = split_principal(keys[-1]) + check_required_principal(ldap, hostname, service) + + # See if we do any work at all here and if not raise an exception + done_work = False + + if self.api.Command.ca_is_enabled()['result']: + certs = entry_attrs.get('usercertificate', []) + + if len(certs) > 0: + revoke_certs(certs, self.log) + # Remove the usercertificate altogether + entry_attrs['usercertificate'] = None + ldap.update_entry(entry_attrs) + done_work = True + + self.obj.get_password_attributes(ldap, dn, entry_attrs) + if entry_attrs['has_keytab']: + ldap.remove_principal_key(dn) + done_work = True + + if not done_work: + raise errors.AlreadyInactive() + + return dict( + result=True, + value=pkey_to_value(keys[0], options), + ) + + +@register() +class service_add_cert(LDAPAddAttribute): + __doc__ = _('Add new certificates to a service') + msg_summary = _('Added certificates to service principal "%(value)s"') + attribute = 'usercertificate' + + +@register() +class service_remove_cert(LDAPRemoveAttribute): + __doc__ = _('Remove certificates from a service') + msg_summary = _('Removed certificates from service principal "%(value)s"') + attribute = 'usercertificate' + + def post_callback(self, ldap, dn, entry_attrs, *keys, **options): + assert isinstance(dn, DN) + + if 'usercertificate' in options: + revoke_certs(options['usercertificate'], self.log) + + return dn diff --git a/ipaserver/plugins/servicedelegation.py b/ipaserver/plugins/servicedelegation.py new file mode 100644 index 000000000..958c3b739 --- /dev/null +++ b/ipaserver/plugins/servicedelegation.py @@ -0,0 +1,550 @@ +# +# Copyright (C) 2015 FreeIPA Contributors see COPYING for license +# + +import six + +from ipalib import api +from ipalib import Str +from ipalib.plugable import Registry +from .baseldap import ( + LDAPObject, + LDAPAddMember, + LDAPRemoveMember, + LDAPCreate, + LDAPDelete, + LDAPSearch, + LDAPRetrieve) +from .service import normalize_principal +from ipalib import _, ngettext +from ipalib import errors +from ipapython.dn import DN + +if six.PY3: + unicode = str + +__doc__ = _(""" +Service Constrained Delegation + +Manage rules to allow constrained delegation of credentials so +that a service can impersonate a user when communicating with another +service without requiring the user to actually forward their TGT. +This makes for a much better method of delegating credentials as it +prevents exposure of the short term secret of the user. + +The naming convention is to append the word "target" or "targets" to +a matching rule name. This is not mandatory but helps conceptually +to associate rules and targets. + +A rule consists of two things: + - A list of targets the rule applies to + - A list of memberPrincipals that are allowed to delegate for + those targets + +A target consists of a list of principals that can be delegated. + +In English, a rule says that this principal can delegate as this +list of principals, as defined by these targets. + +EXAMPLES: + + Add a new constrained delegation rule: + ipa servicedelegationrule-add ftp-delegation + + Add a new constrained delegation target: + ipa servicedelegationtarget-add ftp-delegation-target + + Add a principal to the rule: + ipa servicedelegationrule-add-member --principals=ftp/ipa.example.com \ + ftp-delegation + + Add our target to the rule: + ipa servicedelegationrule-add-target \ + --servicedelegationtargets=ftp-delegation-target ftp-delegation + + Add a principal to the target: + ipa servicedelegationtarget-add-member --principals=ldap/ipa.example.com \ + ftp-delegation-target + + Display information about a named delegation rule and target: + ipa servicedelegationrule_show ftp-delegation + ipa servicedelegationtarget_show ftp-delegation-target + + Remove a constrained delegation: + ipa servicedelegationrule-del ftp-delegation-target + ipa servicedelegationtarget-del ftp-delegation + +In this example the ftp service can get a TGT for the ldap service on +the bound user's behalf. + +It is strongly discouraged to modify the delegations that ship with +IPA, ipa-http-delegation and its targets ipa-cifs-delegation-targets and +ipa-ldap-delegation-targets. Incorrect changes can remove the ability +to delegate, causing the framework to stop functioning. +""") + +register = Registry() + +PROTECTED_CONSTRAINT_RULES = ( + u'ipa-http-delegation', +) + +PROTECTED_CONSTRAINT_TARGETS = ( + u'ipa-cifs-delegation-targets', + u'ipa-ldap-delegation-targets', + +) + + +output_params = ( + Str( + 'ipaallowedtarget_servicedelegationtarget', + label=_('Allowed Target'), + ), + Str( + 'ipaallowedtoimpersonate', + label=_('Allowed to Impersonate'), + ), + Str( + 'memberprincipal', + label=_('Member principals'), + ), + Str( + 'failed_memberprincipal', + label=_('Failed members'), + ), + Str( + 'ipaallowedtarget', + label=_('Failed targets'), + ), +) + + +class servicedelegation(LDAPObject): + """ + Service Constrained Delegation base object. + + This jams a couple of concepts into a single plugin because the + data is all stored in one place. There is a "rule" which has the + objectclass ipakrb5delegationacl. This is the entry that controls + the delegation. Other entries that lack this objectclass are + targets and define what services can be impersonated. + """ + container_dn = api.env.container_s4u2proxy + object_class = ['groupofprincipals', 'top'] + + managed_permissions = { + 'System: Read Service Delegations': { + 'ipapermbindruletype': 'permission', + 'ipapermright': {'read', 'search', 'compare'}, + 'ipapermtargetfilter': {'(objectclass=groupofprincipals)'}, + 'ipapermdefaultattr': { + 'cn', 'objectclass', 'memberprincipal', + 'ipaallowedtarget', + }, + 'default_privileges': {'Service Administrators'}, + }, + 'System: Add Service Delegations': { + 'ipapermright': {'add'}, + 'ipapermtargetfilter': {'(objectclass=groupofprincipals)'}, + 'default_privileges': {'Service Administrators'}, + }, + 'System: Remove Service Delegations': { + 'ipapermright': {'delete'}, + 'ipapermtargetfilter': {'(objectclass=groupofprincipals)'}, + 'default_privileges': {'Service Administrators'}, + }, + 'System: Modify Service Delegation Membership': { + 'ipapermright': {'write'}, + 'ipapermtargetfilter': {'(objectclass=groupofprincipals)'}, + 'ipapermdefaultattr': {'memberprincipal', 'ipaallowedtarget'}, + 'default_privileges': {'Service Administrators'}, + }, + } + + rdn_is_primary_key = True + + takes_params = ( + Str( + 'cn', + pattern='^[a-zA-Z0-9_.][a-zA-Z0-9_ .-]{0,253}[a-zA-Z0-9_.-]?$', + pattern_errmsg='may only include letters, numbers, _, -, ., ' + 'and a space inside', + maxlength=255, + cli_name='delegation_name', + label=_('Delegation name'), + primary_key=True, + ), + ) + + +class servicedelegation_add_member(LDAPAddMember): + __doc__ = _('Add target to a named service delegation.') + member_attrs = ['memberprincipal'] + member_attributes = [] + member_names = {} + principal_attr = 'memberprincipal' + principal_failedattr = 'failed_memberprincipal' + + has_output_params = LDAPAddMember.has_output_params + output_params + + def get_options(self): + for option in super(servicedelegation_add_member, self).get_options(): + yield option + for attr in self.member_attrs: + name = self.member_names[attr] + doc = self.member_param_doc % name + yield Str('%s*' % name, cli_name='%ss' % name, doc=doc, + label=_('member %s') % name, alwaysask=True) + + def get_member_dns(self, **options): + """ + There are no member_dns to return. memberPrincipal needs + special handling since it is just a principal, not a + full dn. + """ + return dict(), dict() + + def post_callback(self, ldap, completed, failed, dn, entry_attrs, + *keys, **options): + """ + Add memberPrincipal values. This is done afterward because it isn't + a DN and the LDAPAddMember method explicitly only handles DNs. + + A separate fake attribute name is used for failed members. This is + a reverse of the way this is typically handled in the *Member + routines, where a successful addition will be represented as + member/memberof_<attribute>. In this case, because memberPrincipal + isn't a DN, I'm doing the reverse, and creating a fake failed + attribute instead. + """ + ldap = self.obj.backend + members = [] + failed[self.principal_failedattr] = {} + failed[self.principal_failedattr][self.principal_attr] = [] + names = options.get(self.member_names[self.principal_attr], []) + ldap_obj = self.api.Object['service'] + if names: + for name in names: + if not name: + continue + name = normalize_principal(name) + obj_dn = ldap_obj.get_dn(name) + try: + ldap.get_entry(obj_dn, ['krbprincipalname']) + except errors.NotFound as e: + failed[self.principal_failedattr][ + self.principal_attr].append((name, unicode(e))) + continue + try: + if name not in entry_attrs.get(self.principal_attr, []): + members.append(name) + else: + raise errors.AlreadyGroupMember() + except errors.PublicError as e: + failed[self.principal_failedattr][ + self.principal_attr].append((name, unicode(e))) + else: + completed += 1 + + if members: + value = entry_attrs.setdefault(self.principal_attr, []) + value.extend(members) + + try: + ldap.update_entry(entry_attrs) + except errors.EmptyModlist: + pass + + return completed, dn + + +class servicedelegation_remove_member(LDAPRemoveMember): + __doc__ = _('Remove member from a named service delegation.') + + member_attrs = ['memberprincipal'] + member_attributes = [] + member_names = {} + principal_attr = 'memberprincipal' + principal_failedattr = 'failed_memberprincipal' + + has_output_params = LDAPRemoveMember.has_output_params + output_params + + def get_options(self): + for option in super( + servicedelegation_remove_member, self).get_options(): + yield option + for attr in self.member_attrs: + name = self.member_names[attr] + doc = self.member_param_doc % name + yield Str('%s*' % name, cli_name='%ss' % name, doc=doc, + label=_('member %s') % name, alwaysask=True) + + def get_member_dns(self, **options): + """ + Need to ignore memberPrincipal for now and handle the difference + in objectclass between a rule and a target. + """ + dns = {} + failed = {} + for attr in self.member_attrs: + dns[attr] = {} + if attr.lower() == 'memberprincipal': + # This will be handled later. memberprincipal isn't a + # DN so will blow up in assertions in baseldap. + continue + failed[attr] = {} + for ldap_obj_name in self.obj.attribute_members[attr]: + dns[attr][ldap_obj_name] = [] + failed[attr][ldap_obj_name] = [] + names = options.get(self.member_names[attr], []) + if not names: + continue + for name in names: + if not name: + continue + ldap_obj = self.api.Object[ldap_obj_name] + try: + dns[attr][ldap_obj_name].append(ldap_obj.get_dn(name)) + except errors.PublicError as e: + failed[attr][ldap_obj_name].append((name, unicode(e))) + return dns, failed + + def post_callback(self, ldap, completed, failed, dn, entry_attrs, + *keys, **options): + """ + Remove memberPrincipal values. This is done afterward because it + isn't a DN and the LDAPAddMember method explicitly only handles DNs. + + See servicedelegation_add_member() for an explanation of what + failedattr is. + """ + ldap = self.obj.backend + failed[self.principal_failedattr] = {} + failed[self.principal_failedattr][self.principal_attr] = [] + names = options.get(self.member_names[self.principal_attr], []) + if names: + for name in names: + if not name: + continue + name = normalize_principal(name) + try: + if name in entry_attrs.get(self.principal_attr, []): + entry_attrs[self.principal_attr].remove(name) + else: + raise errors.NotGroupMember() + except errors.PublicError as e: + failed[self.principal_failedattr][ + self.principal_attr].append((name, unicode(e))) + else: + completed += 1 + + try: + ldap.update_entry(entry_attrs) + except errors.EmptyModlist: + pass + + return completed, dn + + +@register() +class servicedelegationrule(servicedelegation): + """ + A service delegation rule. This is the ACL that controls + what can be delegated to whom. + """ + object_name = _('service delegation rule') + object_name_plural = _('service delegation rules') + object_class = ['ipakrb5delegationacl', 'groupofprincipals', 'top'] + default_attributes = [ + 'cn', 'memberprincipal', 'ipaallowedtarget', + 'ipaallowedtoimpersonate', + ] + attribute_members = { + # memberprincipal is not listed because it isn't a DN + 'ipaallowedtarget': ['servicedelegationtarget'], + } + + label = _('Service delegation rules') + label_singular = _('Service delegation rule') + + +@register() +class servicedelegationrule_add(LDAPCreate): + __doc__ = _('Create a new service delegation rule.') + + msg_summary = _('Added service delegation rule "%(value)s"') + + +@register() +class servicedelegationrule_del(LDAPDelete): + __doc__ = _('Delete service delegation.') + + msg_summary = _('Deleted service delegation "%(value)s"') + + def pre_callback(self, ldap, dn, *keys, **options): + assert isinstance(dn, DN) + if keys[0] in PROTECTED_CONSTRAINT_RULES: + raise errors.ProtectedEntryError( + label=_(u'service delegation rule'), + key=keys[0], + reason=_(u'privileged service delegation rule') + ) + return dn + + +@register() +class servicedelegationrule_find(LDAPSearch): + __doc__ = _('Search for service delegations rule.') + + has_output_params = LDAPSearch.has_output_params + output_params + + msg_summary = ngettext( + '%(count)d service delegation rule matched', + '%(count)d service delegation rules matched', 0 + ) + + +@register() +class servicedelegationrule_show(LDAPRetrieve): + __doc__ = _('Display information about a named service delegation rule.') + + has_output_params = LDAPRetrieve.has_output_params + output_params + + +@register() +class servicedelegationrule_add_member(servicedelegation_add_member): + __doc__ = _('Add member to a named service delegation rule.') + + member_names = { + 'memberprincipal': 'principal', + } + + +@register() +class servicedelegationrule_remove_member(servicedelegation_remove_member): + __doc__ = _('Remove member from a named service delegation rule.') + member_names = { + 'memberprincipal': 'principal', + } + + +@register() +class servicedelegationrule_add_target(LDAPAddMember): + __doc__ = _('Add target to a named service delegation rule.') + + member_attributes = ['ipaallowedtarget'] + attribute_members = { + 'ipaallowedtarget': ['servicedelegationtarget'], + } + has_output_params = LDAPAddMember.has_output_params + output_params + + +@register() +class servicedelegationrule_remove_target(LDAPRemoveMember): + __doc__ = _('Remove target from a named service delegation rule.') + member_attributes = ['ipaallowedtarget'] + attribute_members = { + 'ipaallowedtarget': ['servicedelegationtarget'], + } + has_output_params = LDAPRemoveMember.has_output_params + output_params + + +@register() +class servicedelegationtarget(servicedelegation): + object_name = _('service delegation target') + object_name_plural = _('service delegation targets') + object_class = ['groupofprincipals', 'top'] + default_attributes = [ + 'cn', 'memberprincipal', + ] + attribute_members = {} + + label = _('Service delegation targets') + label_singular = _('Service delegation target') + + +@register() +class servicedelegationtarget_add(LDAPCreate): + __doc__ = _('Create a new service delegation target.') + + msg_summary = _('Added service delegation target "%(value)s"') + + +@register() +class servicedelegationtarget_del(LDAPDelete): + __doc__ = _('Delete service delegation target.') + + msg_summary = _('Deleted service delegation target "%(value)s"') + + def pre_callback(self, ldap, dn, *keys, **options): + assert isinstance(dn, DN) + if keys[0] in PROTECTED_CONSTRAINT_TARGETS: + raise errors.ProtectedEntryError( + label=_(u'service delegation target'), + key=keys[0], + reason=_(u'privileged service delegation target') + ) + return dn + + +@register() +class servicedelegationtarget_find(LDAPSearch): + __doc__ = _('Search for service delegation target.') + + has_output_params = LDAPSearch.has_output_params + output_params + + msg_summary = ngettext( + '%(count)d service delegation target matched', + '%(count)d service delegation targets matched', 0 + ) + + def pre_callback(self, ldap, filters, attrs_list, base_dn, scope, + term=None, **options): + """ + Exclude rules from the search output. A target contains a subset + of a rule objectclass. + """ + search_kw = self.args_options_2_entry(**options) + search_kw['objectclass'] = self.obj.object_class + attr_filter = ldap.make_filter(search_kw, rules=ldap.MATCH_ALL) + rule_kw = {'objectclass': 'ipakrb5delegationacl'} + target_filter = ldap.make_filter(rule_kw, rules=ldap.MATCH_NONE) + attr_filter = ldap.combine_filters( + (target_filter, attr_filter), rules=ldap.MATCH_ALL + ) + + search_kw = {} + for a in self.obj.default_attributes: + search_kw[a] = term + + term_filter = ldap.make_filter(search_kw, exact=False) + + sfilter = ldap.combine_filters( + (term_filter, attr_filter), rules=ldap.MATCH_ALL + ) + return sfilter, base_dn, ldap.SCOPE_ONELEVEL + + +@register() +class servicedelegationtarget_show(LDAPRetrieve): + __doc__ = _('Display information about a named service delegation target.') + + has_output_params = LDAPRetrieve.has_output_params + output_params + + +@register() +class servicedelegationtarget_add_member(servicedelegation_add_member): + __doc__ = _('Add member to a named service delegation target.') + + member_names = { + 'memberprincipal': 'principal', + } + + +@register() +class servicedelegationtarget_remove_member(servicedelegation_remove_member): + __doc__ = _('Remove member from a named service delegation target.') + member_names = { + 'memberprincipal': 'principal', + } diff --git a/ipaserver/plugins/session.py b/ipaserver/plugins/session.py new file mode 100644 index 000000000..b03b6b410 --- /dev/null +++ b/ipaserver/plugins/session.py @@ -0,0 +1,33 @@ +# +# Copyright (C) 2015 FreeIPA Contributors see COPYING for license +# + +from ipalib import api, Command +from ipalib.request import context +from ipalib.plugable import Registry + +if api.env.in_server: + from ipalib.session import session_mgr + +register = Registry() + + +@register() +class session_logout(Command): + ''' + RPC command used to log the current user out of their session. + ''' + NO_CLI = True + + def execute(self, *args, **options): + session_data = getattr(context, 'session_data', None) + if session_data is None: + self.debug('session logout command: no session_data found') + else: + session_id = session_data.get('session_id') + self.debug('session logout command: session_id=%s', session_id) + + # Notifiy registered listeners + session_mgr.auth_mgr.logout(session_data) + + return dict(result=None) diff --git a/ipaserver/plugins/stageuser.py b/ipaserver/plugins/stageuser.py new file mode 100644 index 000000000..86b1935f3 --- /dev/null +++ b/ipaserver/plugins/stageuser.py @@ -0,0 +1,745 @@ +# Authors: +# Thierry Bordaz <tbordaz@redhat.com> +# +# Copyright (C) 2014 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +import posixpath +from copy import deepcopy + +import six + +from ipalib import api, errors +from ipalib import Bool +from ipalib.plugable import Registry +from .baseldap import ( + LDAPCreate, + LDAPQuery, + DN) +from . import baseldap +from .baseuser import ( + baseuser, + baseuser_add, + baseuser_del, + baseuser_mod, + baseuser_find, + baseuser_show, + NO_UPG_MAGIC, + baseuser_pwdchars, + baseuser_output_params, + status_baseuser_output_params, + baseuser_add_manager, + baseuser_remove_manager) +from ipalib.request import context +from ipalib import _, ngettext +from ipalib import output +from ipaplatform.paths import paths +from ipapython.ipautil import ipa_generate_password +from ipalib.capabilities import client_has_capability + +if six.PY3: + unicode = str + +__doc__ = _(""" +Stageusers + +Manage stage user entries. + +Stage user entries are directly under the container: "cn=stage users, +cn=accounts, cn=provisioning, SUFFIX". +Users can not authenticate with those entries (even if the entries +contain credentials). Those entries are only candidate to become Active entries. + +Active user entries are Posix users directly under the container: "cn=accounts, SUFFIX". +Users can authenticate with Active entries, at the condition they have +credentials. + +Deleted user entries are Posix users directly under the container: "cn=deleted users, +cn=accounts, cn=provisioning, SUFFIX". +Users can not authenticate with those entries, even if the entries contain credentials. + +The stage user container contains entries: + - created by 'stageuser-add' commands that are Posix users, + - created by external provisioning system. + +A valid stage user entry MUST have: + - entry RDN is 'uid', + - ipaUniqueID is 'autogenerate'. + +IPA supports a wide range of username formats, but you need to be aware of any +restrictions that may apply to your particular environment. For example, +usernames that start with a digit or usernames that exceed a certain length +may cause problems for some UNIX systems. +Use 'ipa config-mod' to change the username format allowed by IPA tools. + + +EXAMPLES: + + Add a new stageuser: + ipa stageuser-add --first=Tim --last=User --password tuser1 + + Add a stageuser from the deleted users container: + ipa stageuser-add --first=Tim --last=User --from-delete tuser1 + +""") + +register = Registry() + + +stageuser_output_params = baseuser_output_params + +status_output_params = status_baseuser_output_params + +@register() +class stageuser(baseuser): + """ + Stage User object + A Stage user is not an Active user and can not be used to bind with. + Stage container is: cn=staged users,cn=accounts,cn=provisioning,SUFFIX + Stage entry conforms the schema + Stage entry RDN attribute is 'uid' + Stage entry are disabled (nsAccountLock: True) through cos + """ + + container_dn = baseuser.stage_container_dn + label = _('Stage Users') + label_singular = _('Stage User') + object_name = _('stage user') + object_name_plural = _('stage users') + managed_permissions = { + # + # Stage container + # + # Allowed to create stage user + 'System: Add Stage User': { + 'ipapermlocation': DN(baseuser.stage_container_dn, api.env.basedn), + 'ipapermbindruletype': 'permission', + 'ipapermtarget': DN('uid=*', baseuser.stage_container_dn, api.env.basedn), + 'ipapermtargetfilter': {'(objectclass=*)'}, + 'ipapermright': {'add'}, + 'ipapermdefaultattr': {'*'}, + 'default_privileges': {'Stage User Administrators', 'Stage User Provisioning'}, + }, + # Allow to read kerberos/password + 'System: Read Stage User password': { + 'ipapermlocation': DN(baseuser.stage_container_dn, api.env.basedn), + 'ipapermbindruletype': 'permission', + 'ipapermtarget': DN('uid=*', baseuser.stage_container_dn, api.env.basedn), + 'ipapermtargetfilter': {'(objectclass=*)'}, + 'ipapermright': {'read', 'search', 'compare'}, + 'ipapermdefaultattr': { + 'userPassword', 'krbPrincipalKey', + }, + 'default_privileges': {'Stage User Administrators'}, + }, + # Allow to update stage user + 'System: Modify Stage User': { + 'ipapermlocation': DN(baseuser.stage_container_dn, api.env.basedn), + 'ipapermbindruletype': 'permission', + 'ipapermtarget': DN('uid=*', baseuser.stage_container_dn, api.env.basedn), + 'ipapermtargetfilter': {'(objectclass=*)'}, + 'ipapermright': {'write'}, + 'ipapermdefaultattr': {'*'}, + 'default_privileges': {'Stage User Administrators'}, + }, + # Allow to delete stage user + 'System: Remove Stage User': { + 'ipapermlocation': DN(baseuser.stage_container_dn, api.env.basedn), + 'ipapermbindruletype': 'permission', + 'ipapermtarget': DN('uid=*', baseuser.stage_container_dn, api.env.basedn), + 'ipapermtargetfilter': {'(objectclass=*)'}, + 'ipapermright': {'delete'}, + 'ipapermdefaultattr': {'*'}, + 'default_privileges': {'Stage User Administrators'}, + }, + # Allow to read any attributes of stage users + 'System: Read Stage Users': { + 'ipapermlocation': DN(baseuser.stage_container_dn, api.env.basedn), + 'ipapermbindruletype': 'permission', + 'ipapermtarget': DN('uid=*', baseuser.stage_container_dn, api.env.basedn), + 'ipapermtargetfilter': {'(objectclass=*)'}, + 'ipapermright': {'read', 'search', 'compare'}, + 'ipapermdefaultattr': {'*'}, + 'default_privileges': {'Stage User Administrators'}, + }, + # + # Preserve container + # + # Allow to read Preserved User + 'System: Read Preserved Users': { + 'ipapermlocation': DN(baseuser.delete_container_dn, api.env.basedn), + 'ipapermbindruletype': 'permission', + 'ipapermtarget': DN('uid=*', baseuser.delete_container_dn, api.env.basedn), + 'ipapermtargetfilter': {'(objectclass=posixaccount)'}, + 'ipapermright': {'read', 'search', 'compare'}, + 'ipapermdefaultattr': {'*'}, + 'default_privileges': {'Stage User Administrators'}, + }, + # Allow to update Preserved User + 'System: Modify Preserved Users': { + 'ipapermlocation': DN(baseuser.delete_container_dn, api.env.basedn), + 'ipapermbindruletype': 'permission', + 'ipapermtarget': DN('uid=*', baseuser.delete_container_dn, api.env.basedn), + 'ipapermtargetfilter': {'(objectclass=posixaccount)'}, + 'ipapermright': {'write'}, + 'ipapermdefaultattr': {'*'}, + 'default_privileges': {'Stage User Administrators'}, + }, + # Allow to reset Preserved User password + 'System: Reset Preserved User password': { + 'ipapermlocation': DN(baseuser.delete_container_dn, api.env.basedn), + 'ipapermbindruletype': 'permission', + 'ipapermtarget': DN('uid=*', baseuser.delete_container_dn, api.env.basedn), + 'ipapermtargetfilter': {'(objectclass=posixaccount)'}, + 'ipapermright': {'read', 'search', 'write'}, + 'ipapermdefaultattr': { + 'userPassword', 'krbPrincipalKey','krbPasswordExpiration','krbLastPwdChange' + }, + 'default_privileges': {'Stage User Administrators'}, + }, + # Allow to delete preserved user + 'System: Remove preserved User': { + 'ipapermlocation': DN(baseuser.delete_container_dn, api.env.basedn), + 'ipapermbindruletype': 'permission', + 'ipapermtarget': DN('uid=*', baseuser.delete_container_dn, api.env.basedn), + 'ipapermtargetfilter': {'(objectclass=*)'}, + 'ipapermright': {'delete'}, + 'ipapermdefaultattr': {'*'}, + 'default_privileges': {'Stage User Administrators'}, + }, + # + # Active container + # + # Stage user administrators need write right on RDN when + # the active user is deleted (preserved) + 'System: Modify User RDN': { + 'ipapermlocation': DN(baseuser.active_container_dn, api.env.basedn), + 'ipapermbindruletype': 'permission', + 'ipapermtarget': DN('uid=*', baseuser.active_container_dn, api.env.basedn), + 'ipapermtargetfilter': {'(objectclass=posixaccount)'}, + 'ipapermright': {'write'}, + 'ipapermdefaultattr': {'uid'}, + 'default_privileges': {'Stage User Administrators'}, + }, + # + # Cross containers autorization + # + # Allow to move active user to preserve container (user-del --preserve) + # Note: targetfilter is the target parent container + 'System: Preserve User': { + 'ipapermlocation': DN(api.env.basedn), + 'ipapermbindruletype': 'permission', + 'ipapermtargetfrom': DN(baseuser.active_container_dn, api.env.basedn), + 'ipapermtargetto': DN(baseuser.delete_container_dn, api.env.basedn), + 'ipapermtargetfilter': {'(objectclass=nsContainer)'}, + 'ipapermright': {'moddn'}, + 'default_privileges': {'Stage User Administrators'}, + }, + # Allow to move preserved user to active container (user-undel) + # Note: targetfilter is the target parent container + 'System: Undelete User': { + 'ipapermlocation': DN(api.env.basedn), + 'ipapermbindruletype': 'permission', + 'ipapermtargetfrom': DN(baseuser.delete_container_dn, api.env.basedn), + 'ipapermtargetto': DN(baseuser.active_container_dn, api.env.basedn), + 'ipapermtargetfilter': {'(objectclass=nsContainer)'}, + 'ipapermright': {'moddn'}, + 'default_privileges': {'Stage User Administrators'}, + }, + } + +@register() +class stageuser_add(baseuser_add): + __doc__ = _('Add a new stage user.') + + msg_summary = _('Added stage user "%(value)s"') + + has_output_params = baseuser_add.has_output_params + stageuser_output_params + + takes_options = LDAPCreate.takes_options + ( + Bool( + 'from_delete?', + deprecated=True, + doc=_('Create Stage user in from a delete user'), + cli_name='from_delete', + flags={'no_option'}, + ), + ) + + def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options): + assert isinstance(dn, DN) + + # then givenname and sn are required attributes + if 'givenname' not in entry_attrs: + raise errors.RequirementError(name='givenname', error=_('givenname is required')) + + if 'sn' not in entry_attrs: + raise errors.RequirementError(name='sn', error=_('sn is required')) + + # we don't want an user private group to be created for this user + # add NO_UPG_MAGIC description attribute to let the DS plugin know + entry_attrs.setdefault('description', []) + entry_attrs['description'].append(NO_UPG_MAGIC) + + # uidNumber/gidNumber + entry_attrs.setdefault('uidnumber', baseldap.DNA_MAGIC) + entry_attrs.setdefault('gidnumber', baseldap.DNA_MAGIC) + + if not client_has_capability( + options['version'], 'optional_uid_params'): + # https://fedorahosted.org/freeipa/ticket/2886 + # Old clients say 999 (OLD_DNA_MAGIC) when they really mean + # "assign a value dynamically". + OLD_DNA_MAGIC = 999 + if entry_attrs.get('uidnumber') == OLD_DNA_MAGIC: + entry_attrs['uidnumber'] = baseldap.DNA_MAGIC + if entry_attrs.get('gidnumber') == OLD_DNA_MAGIC: + entry_attrs['gidnumber'] = baseldap.DNA_MAGIC + + + # Check the lenght of the RDN (uid) value + config = ldap.get_ipa_config() + if 'ipamaxusernamelength' in config: + if len(keys[-1]) > int(config.get('ipamaxusernamelength')[0]): + raise errors.ValidationError( + name=self.obj.primary_key.cli_name, + error=_('can be at most %(len)d characters') % dict( + len = int(config.get('ipamaxusernamelength')[0]) + ) + ) + default_shell = config.get('ipadefaultloginshell', [paths.SH])[0] + entry_attrs.setdefault('loginshell', default_shell) + # hack so we can request separate first and last name in CLI + full_name = '%s %s' % (entry_attrs['givenname'], entry_attrs['sn']) + entry_attrs.setdefault('cn', full_name) + + # Homedirectory + # (order is : option, placeholder (TBD), CLI default value (here in config)) + if 'homedirectory' not in entry_attrs: + # get home's root directory from config + homes_root = config.get('ipahomesrootdir', [paths.HOME_DIR])[0] + # build user's home directory based on his uid + entry_attrs['homedirectory'] = posixpath.join(homes_root, keys[-1]) + + # Kerberos principal + entry_attrs.setdefault('krbprincipalname', '%s@%s' % (entry_attrs['uid'], api.env.realm)) + + + # If requested, generate a userpassword + if 'userpassword' not in entry_attrs and options.get('random'): + entry_attrs['userpassword'] = ipa_generate_password(baseuser_pwdchars) + # save the password so it can be displayed in post_callback + setattr(context, 'randompassword', entry_attrs['userpassword']) + + # Check the email or create it + if 'mail' in entry_attrs: + entry_attrs['mail'] = self.obj.normalize_and_validate_email(entry_attrs['mail'], config) + else: + # No e-mail passed in. If we have a default e-mail domain set + # then we'll add it automatically. + defaultdomain = config.get('ipadefaultemaildomain', [None])[0] + if defaultdomain: + entry_attrs['mail'] = self.obj.normalize_and_validate_email(keys[-1], config) + + # If the manager is defined, check it is a ACTIVE user to validate it + if 'manager' in entry_attrs: + entry_attrs['manager'] = self.obj.normalize_manager(entry_attrs['manager'], self.obj.active_container_dn) + + if ('objectclass' in entry_attrs + and 'userclass' in entry_attrs + and 'ipauser' not in entry_attrs['objectclass']): + entry_attrs['objectclass'].append('ipauser') + + if 'ipatokenradiusconfiglink' in entry_attrs: + cl = entry_attrs['ipatokenradiusconfiglink'] + if cl: + if 'objectclass' not in entry_attrs: + _entry = ldap.get_entry(dn, ['objectclass']) + entry_attrs['objectclass'] = _entry['objectclass'] + + if 'ipatokenradiusproxyuser' not in entry_attrs['objectclass']: + entry_attrs['objectclass'].append('ipatokenradiusproxyuser') + + answer = self.api.Object['radiusproxy'].get_dn_if_exists(cl) + entry_attrs['ipatokenradiusconfiglink'] = answer + + self.pre_common_callback(ldap, dn, entry_attrs, attrs_list, *keys, + **options) + + return dn + + def post_callback(self, ldap, dn, entry_attrs, *keys, **options): + assert isinstance(dn, DN) + config = ldap.get_ipa_config() + + # Fetch the entry again to update memberof, mep data, etc updated + # at the end of the transaction. + newentry = ldap.get_entry(dn, ['*']) + entry_attrs.update(newentry) + + if options.get('random', False): + try: + entry_attrs['randompassword'] = unicode(getattr(context, 'randompassword')) + except AttributeError: + # if both randompassword and userpassword options were used + pass + + self.post_common_callback(ldap, dn, entry_attrs, *keys, **options) + return dn + +@register() +class stageuser_del(baseuser_del): + __doc__ = _('Delete a stage user.') + + msg_summary = _('Deleted stage user "%(value)s"') + +@register() +class stageuser_mod(baseuser_mod): + __doc__ = _('Modify a stage user.') + + msg_summary = _('Modified stage user "%(value)s"') + + has_output_params = baseuser_mod.has_output_params + stageuser_output_params + + def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options): + self.pre_common_callback(ldap, dn, entry_attrs, attrs_list, *keys, + **options) + # Make sure it is not possible to authenticate with a Stage user account + if 'nsaccountlock' in entry_attrs: + del entry_attrs['nsaccountlock'] + return dn + + def post_callback(self, ldap, dn, entry_attrs, *keys, **options): + self.post_common_callback(ldap, dn, entry_attrs, **options) + if 'nsaccountlock' in entry_attrs: + del entry_attrs['nsaccountlock'] + return dn + +@register() +class stageuser_find(baseuser_find): + __doc__ = _('Search for stage users.') + + member_attributes = ['memberof'] + has_output_params = baseuser_find.has_output_params + stageuser_output_params + + def pre_callback(self, ldap, filter, attrs_list, base_dn, scope, *keys, **options): + assert isinstance(base_dn, DN) + self.pre_common_callback(ldap, filter, attrs_list, base_dn, scope, + *keys, **options) + + container_filter = "(objectclass=posixaccount)" + # provisioning system can create non posixaccount stage user + # but then they have to create inetOrgPerson stage user + stagefilter = filter.replace(container_filter, + "(|%s(objectclass=inetOrgPerson))" % container_filter) + self.log.debug("stageuser_find: pre_callback new filter=%s " % (stagefilter)) + return (stagefilter, base_dn, scope) + + def post_callback(self, ldap, entries, truncated, *args, **options): + if options.get('pkey_only', False): + return truncated + self.post_common_callback(ldap, entries, lockout=True, **options) + return truncated + + msg_summary = ngettext( + '%(count)d user matched', '%(count)d users matched', 0 + ) + +@register() +class stageuser_show(baseuser_show): + __doc__ = _('Display information about a stage user.') + + has_output_params = baseuser_show.has_output_params + stageuser_output_params + + def pre_callback(self, ldap, dn, attrs_list, *keys, **options): + assert isinstance(dn, DN) + self.pre_common_callback(ldap, dn, attrs_list, *keys, **options) + return dn + + def post_callback(self, ldap, dn, entry_attrs, *keys, **options): + entry_attrs['nsaccountlock'] = True + self.post_common_callback(ldap, dn, entry_attrs, *keys, **options) + return dn + + +@register() +class stageuser_activate(LDAPQuery): + __doc__ = _('Activate a stage user.') + + msg_summary = _('Activate a stage user "%(value)s"') + + preserved_DN_syntax_attrs = ('manager', 'managedby', 'secretary') + + searched_operational_attributes = ['uidNumber', 'gidNumber', 'nsAccountLock', 'ipauniqueid'] + + has_output = output.standard_entry + has_output_params = LDAPQuery.has_output_params + stageuser_output_params + + def _check_validy(self, dn, entry): + if dn[0].attr != 'uid': + raise errors.ValidationError( + name=self.obj.primary_key.cli_name, + error=_('Entry RDN is not \'uid\''), + ) + for attr in ('cn', 'sn', 'uid'): + if attr not in entry: + raise errors.ValidationError( + name=self.obj.primary_key.cli_name, + error=_('Entry has no \'%(attribute)s\'') % dict(attribute=attr), + ) + + def _build_new_entry(self, ldap, dn, entry_from, entry_to): + config = ldap.get_ipa_config() + + if 'uidnumber' not in entry_from: + entry_to['uidnumber'] = baseldap.DNA_MAGIC + if 'gidnumber' not in entry_from: + entry_to['gidnumber'] = baseldap.DNA_MAGIC + if 'homedirectory' not in entry_from: + # get home's root directory from config + homes_root = config.get('ipahomesrootdir', [paths.HOME_DIR])[0] + # build user's home directory based on his uid + entry_to['homedirectory'] = posixpath.join(homes_root, dn[0].value) + if 'ipamaxusernamelength' in config: + if len(dn[0].value) > int(config.get('ipamaxusernamelength')[0]): + raise errors.ValidationError( + name=self.obj.primary_key.cli_name, + error=_('can be at most %(len)d characters') % dict( + len = int(config.get('ipamaxusernamelength')[0]) + ) + ) + if 'loginshell' not in entry_from: + default_shell = config.get('ipadefaultloginshell', [paths.SH])[0] + if default_shell: + entry_to.setdefault('loginshell', default_shell) + + if 'givenname' not in entry_from: + entry_to['givenname'] = entry_from['cn'][0].split()[0] + + if 'krbprincipalname' not in entry_from: + entry_to['krbprincipalname'] = '%s@%s' % (entry_from['uid'][0], api.env.realm) + + def __dict_new_entry(self, *args, **options): + ldap = self.obj.backend + + entry_attrs = self.args_options_2_entry(*args, **options) + entry_attrs = ldap.make_entry(DN(), entry_attrs) + + self.process_attr_options(entry_attrs, None, args, options) + + entry_attrs['objectclass'] = deepcopy(self.obj.object_class) + + if self.obj.object_class_config: + config = ldap.get_ipa_config() + entry_attrs['objectclass'] = config.get( + self.obj.object_class_config, entry_attrs['objectclass'] + ) + + return(entry_attrs) + + def __merge_values(self, args, options, entry_from, entry_to, attr): + ''' + This routine merges the values of attr taken from entry_from, into entry_to. + If attr is a syntax DN attribute, it is replaced by an empty value. It is a preferable solution + compare to skiping it because the final entry may no longer conform the schema. + An exception of this is for a limited set of syntax DN attribute that we want to + preserved (defined in preserved_DN_syntax_attrs) + see http://www.freeipa.org/page/V3/User_Life-Cycle_Management#Adjustment_of_DN_syntax_attributes + ''' + if not attr in entry_to: + if isinstance(entry_from[attr], (list, tuple)): + # attr is multi value attribute + entry_to[attr] = [] + else: + # attr single valued attribute + entry_to[attr] = None + + # At this point entry_to contains for all resulting attributes + # either a list (possibly empty) or a value (possibly None) + + for value in entry_from[attr]: + # merge all the values from->to + v = self.__value_2_add(args, options, attr, value) + if (isinstance(v, str) and v in ('', None)) or \ + (isinstance(v, unicode) and v in (u'', None)): + try: + v.decode('utf-8') + self.log.debug("merge: %s:%r wiped" % (attr, v)) + except Exception: + self.log.debug("merge %s: [no_print %s]" % (attr, v.__class__.__name__)) + if isinstance(entry_to[attr], (list, tuple)): + # multi value attribute + if v not in entry_to[attr]: + # it may has been added before in the loop + # so add it only if it not present + entry_to[attr].append(v) + else: + # single value attribute + # keep the value defined in staging + entry_to[attr] = v + else: + try: + v.decode('utf-8') + self.log.debug("Add: %s:%r" % (attr, v)) + except Exception: + self.log.debug("Add %s: [no_print %s]" % (attr, v.__class__.__name__)) + + if isinstance(entry_to[attr], (list, tuple)): + # multi value attribute + if attr.lower() == 'objectclass': + entry_to[attr] = [oc.lower() for oc in entry_to[attr]] + value = value.lower() + if value not in entry_to[attr]: + entry_to[attr].append(value) + else: + if value not in entry_to[attr]: + entry_to[attr].append(value) + else: + # single value attribute + if value: + entry_to[attr] = value + + def __value_2_add(self, args, options, attr, value): + ''' + If the attribute is NOT syntax DN it returns its value. + Else it checks if the value can be preserved. + To be preserved: + - attribute must be in preserved_DN_syntax_attrs + - value must be an active user DN (in Active container) + - the active user entry exists + ''' + ldap = self.obj.backend + + if ldap.has_dn_syntax(attr): + if attr.lower() in self.preserved_DN_syntax_attrs: + # we are about to add a DN syntax value + # Check this is a valid DN + if not isinstance(value, DN): + return u'' + + if not self.obj.active_user(value): + return u'' + + # Check that this value is a Active user + try: + entry_attrs = self._exc_wrapper(args, options, ldap.get_entry)(value, ['dn']) + return value + except errors.NotFound: + return u'' + else: + return u'' + else: + return value + + def execute(self, *args, **options): + + ldap = self.obj.backend + + staging_dn = self.obj.get_dn(*args, **options) + assert isinstance(staging_dn, DN) + + # retrieve the current entry + try: + entry_attrs = self._exc_wrapper(args, options, ldap.get_entry)( + staging_dn, ['*'] + ) + except errors.NotFound: + self.obj.handle_not_found(*args) + entry_attrs = dict((k.lower(), v) for (k, v) in entry_attrs.items()) + + # Check it does not exist an active entry with the same RDN + active_dn = DN(staging_dn[0], api.env.container_user, api.env.basedn) + try: + test_entry_attrs = self._exc_wrapper(args, options, ldap.get_entry)( + active_dn, ['dn'] + ) + assert isinstance(staging_dn, DN) + raise errors.DuplicateEntry( + message=_('active user with name "%(user)s" already exists') % + dict(user=args[-1])) + except errors.NotFound: + pass + + # Check the original entry is valid + self._check_validy(staging_dn, entry_attrs) + + # Time to build the new entry + result_entry = {'dn' : active_dn} + new_entry_attrs = self.__dict_new_entry() + for (attr, values) in entry_attrs.items(): + self.__merge_values(args, options, entry_attrs, new_entry_attrs, attr) + result_entry[attr] = values + + # Allow Managed entry plugin to do its work + if 'description' in new_entry_attrs and NO_UPG_MAGIC in new_entry_attrs['description']: + new_entry_attrs['description'].remove(NO_UPG_MAGIC) + if result_entry['description'] == NO_UPG_MAGIC: + del result_entry['description'] + + for (k, v) in new_entry_attrs.items(): + self.log.debug("new entry: k=%r and v=%r)" % (k, v)) + + self._build_new_entry(ldap, staging_dn, entry_attrs, new_entry_attrs) + + # Add the Active entry + entry = ldap.make_entry(active_dn, new_entry_attrs) + self._exc_wrapper(args, options, ldap.add_entry)(entry) + + # Now delete the Staging entry + try: + self._exc_wrapper(args, options, ldap.delete_entry)(staging_dn) + except: + try: + self.log.error("Fail to delete the Staging user after activating it %s " % (staging_dn)) + self._exc_wrapper(args, options, ldap.delete_entry)(active_dn) + except Exception: + self.log.error("Fail to cleanup activation. The user remains active %s" % (active_dn)) + raise + + # add the user we just created into the default primary group + config = ldap.get_ipa_config() + def_primary_group = config.get('ipadefaultprimarygroup') + group_dn = self.api.Object['group'].get_dn(def_primary_group) + + # if the user is already a member of default primary group, + # do not raise error + # this can happen if automember rule or default group is set + try: + ldap.add_entry_to_group(active_dn, group_dn) + except errors.AlreadyGroupMember: + pass + + # Now retrieve the activated entry + result = self.api.Command.user_show( + args[-1], + all=options.get('all', False), + raw=options.get('raw', False), + version=options.get('version'), + ) + result['summary'] = unicode( + _('Stage user %s activated' % staging_dn[0].value)) + + return result + + +@register() +class stageuser_add_manager(baseuser_add_manager): + __doc__ = _("Add a manager to the stage user entry") + + +@register() +class stageuser_remove_manager(baseuser_remove_manager): + __doc__ = _("Remove a manager to the stage user entry") diff --git a/ipaserver/plugins/sudo.py b/ipaserver/plugins/sudo.py new file mode 100644 index 000000000..eb1f49ff9 --- /dev/null +++ b/ipaserver/plugins/sudo.py @@ -0,0 +1,7 @@ +# +# Copyright (C) 2016 FreeIPA Contributors see COPYING for license +# + +from ipalib.text import _ + +__doc__ = _('commands for controlling sudo configuration') diff --git a/ipaserver/plugins/sudocmd.py b/ipaserver/plugins/sudocmd.py new file mode 100644 index 000000000..e3ae33a84 --- /dev/null +++ b/ipaserver/plugins/sudocmd.py @@ -0,0 +1,203 @@ +# Authors: +# Jr Aquino <jr.aquino@citrixonline.com> +# +# Copyright (C) 2010 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +from ipalib import api, errors +from ipalib import Str +from ipalib.plugable import Registry +from .baseldap import ( + LDAPObject, + LDAPCreate, + LDAPDelete, + LDAPUpdate, + LDAPSearch, + LDAPRetrieve) +from ipalib import _, ngettext +from ipapython.dn import DN + +__doc__ = _(""" +Sudo Commands + +Commands used as building blocks for sudo + +EXAMPLES: + + Create a new command + ipa sudocmd-add --desc='For reading log files' /usr/bin/less + + Remove a command + ipa sudocmd-del /usr/bin/less + +""") + +register = Registry() + +topic = 'sudo' + +@register() +class sudocmd(LDAPObject): + """ + Sudo Command object. + """ + container_dn = api.env.container_sudocmd + object_name = _('sudo command') + object_name_plural = _('sudo commands') + object_class = ['ipaobject', 'ipasudocmd'] + permission_filter_objectclasses = ['ipasudocmd'] + # object_class_config = 'ipahostobjectclasses' + search_attributes = [ + 'sudocmd', 'description', + ] + default_attributes = [ + 'sudocmd', 'description', 'memberof', + ] + attribute_members = { + 'memberof': ['sudocmdgroup'], + } + uuid_attribute = 'ipauniqueid' + rdn_attribute = 'ipauniqueid' + managed_permissions = { + 'System: Read Sudo Commands': { + 'replaces_global_anonymous_aci': True, + 'ipapermbindruletype': 'all', + 'ipapermright': {'read', 'search', 'compare'}, + 'ipapermdefaultattr': { + 'description', 'ipauniqueid', 'memberof', 'objectclass', + 'sudocmd', + }, + }, + 'System: Add Sudo Command': { + 'ipapermright': {'add'}, + 'replaces': [ + '(target = "ldap:///sudocmd=*,cn=sudocmds,cn=sudo,$SUFFIX")(version 3.0;acl "permission:Add Sudo command";allow (add) groupdn = "ldap:///cn=Add Sudo command,cn=permissions,cn=pbac,$SUFFIX";)', + '(targetfilter = "(objectclass=ipasudocmd)")(target = "ldap:///cn=sudocmds,cn=sudo,$SUFFIX")(version 3.0;acl "permission:Add Sudo command";allow (add) groupdn = "ldap:///cn=Add Sudo command,cn=permissions,cn=pbac,$SUFFIX";)', + ], + 'default_privileges': {'Sudo Administrator'}, + }, + 'System: Delete Sudo Command': { + 'ipapermright': {'delete'}, + 'replaces': [ + '(target = "ldap:///sudocmd=*,cn=sudocmds,cn=sudo,$SUFFIX")(version 3.0;acl "permission:Delete Sudo command";allow (delete) groupdn = "ldap:///cn=Delete Sudo command,cn=permissions,cn=pbac,$SUFFIX";)', + '(targetfilter = "(objectclass=ipasudocmd)")(target = "ldap:///cn=sudocmds,cn=sudo,$SUFFIX")(version 3.0;acl "permission:Delete Sudo command";allow (delete) groupdn = "ldap:///cn=Delete Sudo command,cn=permissions,cn=pbac,$SUFFIX";)', + ], + 'default_privileges': {'Sudo Administrator'}, + }, + 'System: Modify Sudo Command': { + 'ipapermright': {'write'}, + 'ipapermdefaultattr': {'description'}, + 'replaces': [ + '(targetattr = "description")(target = "ldap:///sudocmd=*,cn=sudocmds,cn=sudo,$SUFFIX")(version 3.0;acl "permission:Modify Sudo command";allow (write) groupdn = "ldap:///cn=Modify Sudo command,cn=permissions,cn=pbac,$SUFFIX";)', + '(targetfilter = "(objectclass=ipasudocmd)")(targetattr = "description")(target = "ldap:///cn=sudocmds,cn=sudo,$SUFFIX")(version 3.0;acl "permission:Modify Sudo command";allow (write) groupdn = "ldap:///cn=Modify Sudo command,cn=permissions,cn=pbac,$SUFFIX";)', + ], + 'default_privileges': {'Sudo Administrator'}, + }, + } + + label = _('Sudo Commands') + label_singular = _('Sudo Command') + + takes_params = ( + Str('sudocmd', + cli_name='command', + label=_('Sudo Command'), + primary_key=True, + ), + Str('description?', + cli_name='desc', + label=_('Description'), + doc=_('A description of this command'), + ), + ) + + def get_dn(self, *keys, **options): + if keys[-1].endswith('.'): + keys[-1] = keys[-1][:-1] + dn = super(sudocmd, self).get_dn(*keys, **options) + try: + self.backend.get_entry(dn, ['']) + except errors.NotFound: + try: + entry_attrs = self.backend.find_entry_by_attr( + 'sudocmd', keys[-1], self.object_class, [''], + DN(self.container_dn, api.env.basedn)) + dn = entry_attrs.dn + except errors.NotFound: + pass + return dn + + +@register() +class sudocmd_add(LDAPCreate): + __doc__ = _('Create new Sudo Command.') + + msg_summary = _('Added Sudo Command "%(value)s"') + + +@register() +class sudocmd_del(LDAPDelete): + __doc__ = _('Delete Sudo Command.') + + msg_summary = _('Deleted Sudo Command "%(value)s"') + + def pre_callback(self, ldap, dn, *keys, **options): + filters = [ + ldap.make_filter_from_attr(attr, dn) + for attr in ('memberallowcmd', 'memberdenycmd')] + filter = ldap.combine_filters(filters, ldap.MATCH_ANY) + filter = ldap.combine_filters( + (filter, ldap.make_filter_from_attr('objectClass', 'ipasudorule')), + ldap.MATCH_ALL) + dependent_sudorules = [] + try: + entries, truncated = ldap.find_entries( + filter, ['cn'], + base_dn=DN(api.env.container_sudorule, api.env.basedn)) + except errors.NotFound: + pass + else: + for entry_attrs in entries: + [cn] = entry_attrs['cn'] + dependent_sudorules.append(cn) + + if dependent_sudorules: + raise errors.DependentEntry( + key=keys[0], label='sudorule', + dependent=', '.join(dependent_sudorules)) + return dn + + +@register() +class sudocmd_mod(LDAPUpdate): + __doc__ = _('Modify Sudo Command.') + + msg_summary = _('Modified Sudo Command "%(value)s"') + + +@register() +class sudocmd_find(LDAPSearch): + __doc__ = _('Search for Sudo Commands.') + + msg_summary = ngettext( + '%(count)d Sudo Command matched', '%(count)d Sudo Commands matched', 0 + ) + + +@register() +class sudocmd_show(LDAPRetrieve): + __doc__ = _('Display Sudo Command.') + diff --git a/ipaserver/plugins/sudocmdgroup.py b/ipaserver/plugins/sudocmdgroup.py new file mode 100644 index 000000000..9e8c016fd --- /dev/null +++ b/ipaserver/plugins/sudocmdgroup.py @@ -0,0 +1,195 @@ +# Authors: +# Jr Aquino <jr.aquino@citrixonline.com> +# +# Copyright (C) 2010 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +from ipalib import api +from ipalib import Str +from ipalib.plugable import Registry +from .baseldap import ( + LDAPObject, + LDAPCreate, + LDAPDelete, + LDAPUpdate, + LDAPSearch, + LDAPRetrieve, + LDAPAddMember, + LDAPRemoveMember) +from ipalib import _, ngettext + +__doc__ = _(""" +Groups of Sudo Commands + +Manage groups of Sudo Commands. + +EXAMPLES: + + Add a new Sudo Command Group: + ipa sudocmdgroup-add --desc='administrators commands' admincmds + + Remove a Sudo Command Group: + ipa sudocmdgroup-del admincmds + + Manage Sudo Command Group membership, commands: + ipa sudocmdgroup-add-member --sudocmds=/usr/bin/less --sudocmds=/usr/bin/vim admincmds + + Manage Sudo Command Group membership, commands: + ipa sudocmdgroup-remove-member --sudocmds=/usr/bin/less admincmds + + Show a Sudo Command Group: + ipa sudocmdgroup-show admincmds +""") + +register = Registry() + +topic = 'sudo' + +@register() +class sudocmdgroup(LDAPObject): + """ + Sudo Command Group object. + """ + container_dn = api.env.container_sudocmdgroup + object_name = _('sudo command group') + object_name_plural = _('sudo command groups') + object_class = ['ipaobject', 'ipasudocmdgrp'] + permission_filter_objectclasses = ['ipasudocmdgrp'] + default_attributes = [ + 'cn', 'description', 'member', + ] + uuid_attribute = 'ipauniqueid' + attribute_members = { + 'member': ['sudocmd'], + } + managed_permissions = { + 'System: Read Sudo Command Groups': { + 'replaces_global_anonymous_aci': True, + 'ipapermbindruletype': 'all', + 'ipapermright': {'read', 'search', 'compare'}, + 'ipapermdefaultattr': { + 'businesscategory', 'cn', 'description', 'ipauniqueid', + 'member', 'o', 'objectclass', 'ou', 'owner', 'seealso', + 'memberuser', 'memberhost', + }, + }, + 'System: Add Sudo Command Group': { + 'ipapermright': {'add'}, + 'replaces': [ + '(target = "ldap:///cn=*,cn=sudocmdgroups,cn=sudo,$SUFFIX")(version 3.0;acl "permission:Add Sudo command group";allow (add) groupdn = "ldap:///cn=Add Sudo command group,cn=permissions,cn=pbac,$SUFFIX";)', + ], + 'default_privileges': {'Sudo Administrator'}, + }, + 'System: Delete Sudo Command Group': { + 'ipapermright': {'delete'}, + 'replaces': [ + '(target = "ldap:///cn=*,cn=sudocmdgroups,cn=sudo,$SUFFIX")(version 3.0;acl "permission:Delete Sudo command group";allow (delete) groupdn = "ldap:///cn=Delete Sudo command group,cn=permissions,cn=pbac,$SUFFIX";)', + ], + 'default_privileges': {'Sudo Administrator'}, + }, + 'System: Modify Sudo Command Group': { + 'ipapermright': {'write'}, + 'ipapermdefaultattr': {'description'}, + 'default_privileges': {'Sudo Administrator'}, + }, + 'System: Manage Sudo Command Group Membership': { + 'ipapermright': {'write'}, + 'ipapermdefaultattr': {'member'}, + 'replaces': [ + '(targetattr = "member")(target = "ldap:///cn=*,cn=sudocmdgroups,cn=sudo,$SUFFIX")(version 3.0;acl "permission:Manage Sudo command group membership";allow (write) groupdn = "ldap:///cn=Manage Sudo command group membership,cn=permissions,cn=pbac,$SUFFIX";)', + ], + 'default_privileges': {'Sudo Administrator'}, + }, + } + + label = _('Sudo Command Groups') + label_singular = _('Sudo Command Group') + + takes_params = ( + Str('cn', + cli_name='sudocmdgroup_name', + label=_('Sudo Command Group'), + primary_key=True, + normalizer=lambda value: value.lower(), + ), + Str('description?', + cli_name='desc', + label=_('Description'), + doc=_('Group description'), + ), + Str('membercmd_sudocmd?', + label=_('Commands'), + flags=['no_create', 'no_update', 'no_search'], + ), + Str('membercmd_sudocmdgroup?', + label=_('Sudo Command Groups'), + flags=['no_create', 'no_update', 'no_search'], + ), + ) + + + +@register() +class sudocmdgroup_add(LDAPCreate): + __doc__ = _('Create new Sudo Command Group.') + + msg_summary = _('Added Sudo Command Group "%(value)s"') + + + +@register() +class sudocmdgroup_del(LDAPDelete): + __doc__ = _('Delete Sudo Command Group.') + + msg_summary = _('Deleted Sudo Command Group "%(value)s"') + + + +@register() +class sudocmdgroup_mod(LDAPUpdate): + __doc__ = _('Modify Sudo Command Group.') + + msg_summary = _('Modified Sudo Command Group "%(value)s"') + + + +@register() +class sudocmdgroup_find(LDAPSearch): + __doc__ = _('Search for Sudo Command Groups.') + + msg_summary = ngettext( + '%(count)d Sudo Command Group matched', + '%(count)d Sudo Command Groups matched', 0 + ) + + + +@register() +class sudocmdgroup_show(LDAPRetrieve): + __doc__ = _('Display Sudo Command Group.') + + + +@register() +class sudocmdgroup_add_member(LDAPAddMember): + __doc__ = _('Add members to Sudo Command Group.') + + + +@register() +class sudocmdgroup_remove_member(LDAPRemoveMember): + __doc__ = _('Remove members from Sudo Command Group.') + diff --git a/ipaserver/plugins/sudorule.py b/ipaserver/plugins/sudorule.py new file mode 100644 index 000000000..15d03c659 --- /dev/null +++ b/ipaserver/plugins/sudorule.py @@ -0,0 +1,998 @@ +# Authors: +# Jr Aquino <jr.aquino@citrixonline.com> +# +# Copyright (C) 2010-2014 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +import netaddr +import six + +from ipalib import api, errors +from ipalib import Str, StrEnum, Bool, Int +from ipalib.plugable import Registry +from .baseldap import (LDAPObject, LDAPCreate, LDAPDelete, + LDAPUpdate, LDAPSearch, LDAPRetrieve, + LDAPQuery, LDAPAddMember, LDAPRemoveMember, + add_external_pre_callback, + add_external_post_callback, + remove_external_post_callback, + output, entry_to_dict, pkey_to_value, + external_host_param) +from .hbacrule import is_all +from ipalib import _, ngettext +from ipalib.util import validate_hostmask +from ipapython.dn import DN + +if six.PY3: + unicode = str + +__doc__ = _(""" +Sudo Rules +""") + _(""" +Sudo (su "do") allows a system administrator to delegate authority to +give certain users (or groups of users) the ability to run some (or all) +commands as root or another user while providing an audit trail of the +commands and their arguments. +""") + _(""" +FreeIPA provides a means to configure the various aspects of Sudo: + Users: The user(s)/group(s) allowed to invoke Sudo. + Hosts: The host(s)/hostgroup(s) which the user is allowed to to invoke Sudo. + Allow Command: The specific command(s) permitted to be run via Sudo. + Deny Command: The specific command(s) prohibited to be run via Sudo. + RunAsUser: The user(s) or group(s) of users whose rights Sudo will be invoked with. + RunAsGroup: The group(s) whose gid rights Sudo will be invoked with. + Options: The various Sudoers Options that can modify Sudo's behavior. +""") + _(""" +An order can be added to a sudorule to control the order in which they +are evaluated (if the client supports it). This order is an integer and +must be unique. +""") + _(""" +FreeIPA provides a designated binddn to use with Sudo located at: +uid=sudo,cn=sysaccounts,cn=etc,dc=example,dc=com +""") + _(""" +To enable the binddn run the following command to set the password: +LDAPTLS_CACERT=/etc/ipa/ca.crt /usr/bin/ldappasswd -S -W \ +-h ipa.example.com -ZZ -D "cn=Directory Manager" \ +uid=sudo,cn=sysaccounts,cn=etc,dc=example,dc=com +""") + _(""" +EXAMPLES: +""") + _(""" + Create a new rule: + ipa sudorule-add readfiles +""") + _(""" + Add sudo command object and add it as allowed command in the rule: + ipa sudocmd-add /usr/bin/less + ipa sudorule-add-allow-command readfiles --sudocmds /usr/bin/less +""") + _(""" + Add a host to the rule: + ipa sudorule-add-host readfiles --hosts server.example.com +""") + _(""" + Add a user to the rule: + ipa sudorule-add-user readfiles --users jsmith +""") + _(""" + Add a special Sudo rule for default Sudo server configuration: + ipa sudorule-add defaults +""") + _(""" + Set a default Sudo option: + ipa sudorule-add-option defaults --sudooption '!authenticate' +""") + +register = Registry() + +topic = 'sudo' + + +def deprecated(attribute): + raise errors.ValidationError( + name=attribute, + error=_('this option has been deprecated.')) + + +hostmask_membership_param = Str('hostmask?', validate_hostmask, + label=_('host masks of allowed hosts'), + flags=['no_create', 'no_update', 'no_search'], + multivalue=True, + ) + +def validate_externaluser(ugettext, value): + deprecated('externaluser') + + +def validate_runasextuser(ugettext, value): + deprecated('runasexternaluser') + + +def validate_runasextgroup(ugettext, value): + deprecated('runasexternalgroup') + + +@register() +class sudorule(LDAPObject): + """ + Sudo Rule object. + """ + container_dn = api.env.container_sudorule + object_name = _('sudo rule') + object_name_plural = _('sudo rules') + object_class = ['ipaassociation', 'ipasudorule'] + permission_filter_objectclasses = ['ipasudorule'] + default_attributes = [ + 'cn', 'ipaenabledflag', 'externaluser', + 'description', 'usercategory', 'hostcategory', + 'cmdcategory', 'memberuser', 'memberhost', + 'memberallowcmd', 'memberdenycmd', 'ipasudoopt', + 'ipasudorunas', 'ipasudorunasgroup', + 'ipasudorunasusercategory', 'ipasudorunasgroupcategory', + 'sudoorder', 'hostmask', 'externalhost', 'ipasudorunasextusergroup', + 'ipasudorunasextgroup', 'ipasudorunasextuser' + ] + uuid_attribute = 'ipauniqueid' + rdn_attribute = 'ipauniqueid' + attribute_members = { + 'memberuser': ['user', 'group'], + 'memberhost': ['host', 'hostgroup'], + 'memberallowcmd': ['sudocmd', 'sudocmdgroup'], + 'memberdenycmd': ['sudocmd', 'sudocmdgroup'], + 'ipasudorunas': ['user', 'group'], + 'ipasudorunasgroup': ['group'], + } + managed_permissions = { + 'System: Read Sudo Rules': { + 'replaces_global_anonymous_aci': True, + 'ipapermbindruletype': 'all', + 'ipapermright': {'read', 'search', 'compare'}, + 'ipapermdefaultattr': { + 'cmdcategory', 'cn', 'description', 'externalhost', + 'externaluser', 'hostcategory', 'hostmask', 'ipaenabledflag', + 'ipasudoopt', 'ipasudorunas', 'ipasudorunasextgroup', + 'ipasudorunasextuser', 'ipasudorunasextusergroup', + 'ipasudorunasgroup', + 'ipasudorunasgroupcategory', 'ipasudorunasusercategory', + 'ipauniqueid', 'memberallowcmd', 'memberdenycmd', + 'memberhost', 'memberuser', 'sudonotafter', 'sudonotbefore', + 'sudoorder', 'usercategory', 'objectclass', 'member', + }, + }, + 'System: Read Sudoers compat tree': { + 'non_object': True, + 'ipapermlocation': api.env.basedn, + 'ipapermtarget': DN('ou=sudoers', api.env.basedn), + 'ipapermbindruletype': 'anonymous', + 'ipapermright': {'read', 'search', 'compare'}, + 'ipapermdefaultattr': { + 'objectclass', 'cn', 'ou', + 'sudouser', 'sudohost', 'sudocommand', 'sudorunas', + 'sudorunasuser', 'sudorunasgroup', 'sudooption', + 'sudonotbefore', 'sudonotafter', 'sudoorder', 'description', + }, + }, + 'System: Add Sudo rule': { + 'ipapermright': {'add'}, + 'replaces': [ + '(target = "ldap:///ipauniqueid=*,cn=sudorules,cn=sudo,$SUFFIX")(version 3.0;acl "permission:Add Sudo rule";allow (add) groupdn = "ldap:///cn=Add Sudo rule,cn=permissions,cn=pbac,$SUFFIX";)', + ], + 'default_privileges': {'Sudo Administrator'}, + }, + 'System: Delete Sudo rule': { + 'ipapermright': {'delete'}, + 'replaces': [ + '(target = "ldap:///ipauniqueid=*,cn=sudorules,cn=sudo,$SUFFIX")(version 3.0;acl "permission:Delete Sudo rule";allow (delete) groupdn = "ldap:///cn=Delete Sudo rule,cn=permissions,cn=pbac,$SUFFIX";)', + ], + 'default_privileges': {'Sudo Administrator'}, + }, + 'System: Modify Sudo rule': { + 'ipapermright': {'write'}, + 'ipapermdefaultattr': { + 'description', 'ipaenabledflag', 'usercategory', + 'hostcategory', 'cmdcategory', 'ipasudorunasusercategory', + 'ipasudorunasgroupcategory', 'externaluser', + 'ipasudorunasextusergroup', + 'ipasudorunasextuser', 'ipasudorunasextgroup', 'memberdenycmd', + 'memberallowcmd', 'memberuser', 'memberhost', 'externalhost', + 'sudonotafter', 'hostmask', 'sudoorder', 'sudonotbefore', + 'ipasudorunas', 'externalhost', 'ipasudorunasgroup', + 'ipasudoopt', 'memberhost', + }, + 'replaces': [ + '(targetattr = "description || ipaenabledflag || usercategory || hostcategory || cmdcategory || ipasudorunasusercategory || ipasudorunasgroupcategory || externaluser || ipasudorunasextuser || ipasudorunasextgroup || memberdenycmd || memberallowcmd || memberuser")(target = "ldap:///ipauniqueid=*,cn=sudorules,cn=sudo,$SUFFIX")(version 3.0;acl "permission:Modify Sudo rule";allow (write) groupdn = "ldap:///cn=Modify Sudo rule,cn=permissions,cn=pbac,$SUFFIX";)', + ], + 'default_privileges': {'Sudo Administrator'}, + }, + } + + label = _('Sudo Rules') + label_singular = _('Sudo Rule') + + takes_params = ( + Str('cn', + cli_name='sudorule_name', + label=_('Rule name'), + primary_key=True, + ), + Str('description?', + cli_name='desc', + label=_('Description'), + ), + Bool('ipaenabledflag?', + label=_('Enabled'), + flags=['no_option'], + ), + StrEnum('usercategory?', + cli_name='usercat', + label=_('User category'), + doc=_('User category the rule applies to'), + values=(u'all', ), + ), + StrEnum('hostcategory?', + cli_name='hostcat', + label=_('Host category'), + doc=_('Host category the rule applies to'), + values=(u'all', ), + ), + StrEnum('cmdcategory?', + cli_name='cmdcat', + label=_('Command category'), + doc=_('Command category the rule applies to'), + values=(u'all', ), + ), + StrEnum('ipasudorunasusercategory?', + cli_name='runasusercat', + label=_('RunAs User category'), + doc=_('RunAs User category the rule applies to'), + values=(u'all', ), + ), + StrEnum('ipasudorunasgroupcategory?', + cli_name='runasgroupcat', + label=_('RunAs Group category'), + doc=_('RunAs Group category the rule applies to'), + values=(u'all', ), + ), + Int('sudoorder?', + cli_name='order', + label=_('Sudo order'), + doc=_('integer to order the Sudo rules'), + default=0, + minvalue=0, + ), + Str('memberuser_user?', + label=_('Users'), + flags=['no_create', 'no_update', 'no_search'], + ), + Str('memberuser_group?', + label=_('User Groups'), + flags=['no_create', 'no_update', 'no_search'], + ), + Str('externaluser?', validate_externaluser, + cli_name='externaluser', + label=_('External User'), + doc=_('External User the rule applies to (sudorule-find only)'), + ), + Str('memberhost_host?', + label=_('Hosts'), + flags=['no_create', 'no_update', 'no_search'], + ), + Str('memberhost_hostgroup?', + label=_('Host Groups'), + flags=['no_create', 'no_update', 'no_search'], + ), + Str('hostmask', validate_hostmask, + normalizer=lambda x: unicode(netaddr.IPNetwork(x).cidr), + label=_('Host Masks'), + flags=['no_create', 'no_update', 'no_search'], + multivalue=True, + ), + external_host_param, + Str('memberallowcmd_sudocmd?', + label=_('Sudo Allow Commands'), + flags=['no_create', 'no_update', 'no_search'], + ), + Str('memberdenycmd_sudocmd?', + label=_('Sudo Deny Commands'), + flags=['no_create', 'no_update', 'no_search'], + ), + Str('memberallowcmd_sudocmdgroup?', + label=_('Sudo Allow Command Groups'), + flags=['no_create', 'no_update', 'no_search'], + ), + Str('memberdenycmd_sudocmdgroup?', + label=_('Sudo Deny Command Groups'), + flags=['no_create', 'no_update', 'no_search'], + ), + Str('ipasudorunas_user?', + label=_('RunAs Users'), + doc=_('Run as a user'), + flags=['no_create', 'no_update', 'no_search'], + ), + Str('ipasudorunas_group?', + label=_('Groups of RunAs Users'), + doc=_('Run as any user within a specified group'), + flags=['no_create', 'no_update', 'no_search'], + ), + Str('ipasudorunasextuser?', validate_runasextuser, + cli_name='runasexternaluser', + label=_('RunAs External User'), + doc=_('External User the commands can run as (sudorule-find only)'), + ), + Str('ipasudorunasextusergroup?', + cli_name='runasexternalusergroup', + label=_('External Groups of RunAs Users'), + doc=_('External Groups of users that the command can run as'), + flags=['no_create', 'no_update', 'no_search'], + ), + Str('ipasudorunasgroup_group?', + label=_('RunAs Groups'), + doc=_('Run with the gid of a specified POSIX group'), + flags=['no_create', 'no_update', 'no_search'], + ), + Str('ipasudorunasextgroup?', validate_runasextgroup, + cli_name='runasexternalgroup', + label=_('RunAs External Group'), + doc=_('External Group the commands can run as (sudorule-find only)'), + ), + Str('ipasudoopt?', + label=_('Sudo Option'), + flags=['no_create', 'no_update', 'no_search'], + ), + ) + + order_not_unique_msg = _( + 'order must be a unique value (%(order)d already used by %(rule)s)' + ) + + def check_order_uniqueness(self, *keys, **options): + if options.get('sudoorder') is not None: + entries = self.methods.find( + sudoorder=options['sudoorder'] + )['result'] + + if len(entries) > 0: + rule_name = entries[0]['cn'][0] + raise errors.ValidationError( + name='order', + error=self.order_not_unique_msg % { + 'order': options['sudoorder'], + 'rule': rule_name, + } + ) + + +@register() +class sudorule_add(LDAPCreate): + __doc__ = _('Create new Sudo Rule.') + + def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options): + assert isinstance(dn, DN) + self.obj.check_order_uniqueness(*keys, **options) + # Sudo Rules are enabled by default + entry_attrs['ipaenabledflag'] = 'TRUE' + return dn + + msg_summary = _('Added Sudo Rule "%(value)s"') + + +@register() +class sudorule_del(LDAPDelete): + __doc__ = _('Delete Sudo Rule.') + + msg_summary = _('Deleted Sudo Rule "%(value)s"') + + +@register() +class sudorule_mod(LDAPUpdate): + __doc__ = _('Modify Sudo Rule.') + + msg_summary = _('Modified Sudo Rule "%(value)s"') + + def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options): + assert isinstance(dn, DN) + + if 'sudoorder' in options: + new_order = options.get('sudoorder') + old_entry = self.api.Command.sudorule_show(keys[-1])['result'] + if 'sudoorder' in old_entry: + old_order = int(old_entry['sudoorder'][0]) + if old_order != new_order: + self.obj.check_order_uniqueness(*keys, **options) + else: + self.obj.check_order_uniqueness(*keys, **options) + + try: + _entry_attrs = ldap.get_entry(dn, self.obj.default_attributes) + except errors.NotFound: + self.obj.handle_not_found(*keys) + + error = _("%(type)s category cannot be set to 'all' " + "while there are allowed %(objects)s") + + category_info = [( + 'usercategory', + ['memberuser', 'externaluser'], + error % {'type': _('user'), 'objects': _('users')} + ), + ( + 'hostcategory', + ['memberhost', 'externalhost', 'hostmask'], + error % {'type': _('host'), 'objects': _('hosts')} + ), + ( + 'cmdcategory', + ['memberallowcmd'], + error % {'type': _('command'), 'objects': _('commands')} + ), + ( + 'ipasudorunasusercategory', + ['ipasudorunas', 'ipasudorunasextuser', + 'ipasudorunasextusergroup'], + error % {'type': _('runAs user'), 'objects': _('runAs users')} + ), + ( + 'ipasudorunasgroupcategory', + ['ipasudorunasgroup', 'ipasudorunasextgroup'], + error % {'type': _('group runAs'), 'objects': _('runAs groups')} + ), + ] + + + # Enforce the checks for all the categories + for category, member_attrs, error in category_info: + any_member_attrs_set = any(attr in _entry_attrs + for attr in member_attrs) + + if is_all(options, category) and any_member_attrs_set: + raise errors.MutuallyExclusiveError(reason=error) + + return dn + + +@register() +class sudorule_find(LDAPSearch): + __doc__ = _('Search for Sudo Rule.') + + msg_summary = ngettext( + '%(count)d Sudo Rule matched', '%(count)d Sudo Rules matched', 0 + ) + + +@register() +class sudorule_show(LDAPRetrieve): + __doc__ = _('Display Sudo Rule.') + + +@register() +class sudorule_enable(LDAPQuery): + __doc__ = _('Enable a Sudo Rule.') + + def execute(self, cn, **options): + ldap = self.obj.backend + + dn = self.obj.get_dn(cn) + try: + entry_attrs = ldap.get_entry(dn, ['ipaenabledflag']) + except errors.NotFound: + self.obj.handle_not_found(cn) + + entry_attrs['ipaenabledflag'] = ['TRUE'] + + try: + ldap.update_entry(entry_attrs) + except errors.EmptyModlist: + pass + + return dict(result=True) + + +@register() +class sudorule_disable(LDAPQuery): + __doc__ = _('Disable a Sudo Rule.') + + def execute(self, cn, **options): + ldap = self.obj.backend + + dn = self.obj.get_dn(cn) + try: + entry_attrs = ldap.get_entry(dn, ['ipaenabledflag']) + except errors.NotFound: + self.obj.handle_not_found(cn) + + entry_attrs['ipaenabledflag'] = ['FALSE'] + + try: + ldap.update_entry(entry_attrs) + except errors.EmptyModlist: + pass + + return dict(result=True) + + +@register() +class sudorule_add_allow_command(LDAPAddMember): + __doc__ = _('Add commands and sudo command groups affected by Sudo Rule.') + + member_attributes = ['memberallowcmd'] + member_count_out = ('%i object added.', '%i objects added.') + + def pre_callback(self, ldap, dn, found, not_found, *keys, **options): + assert isinstance(dn, DN) + + try: + _entry_attrs = ldap.get_entry(dn, self.obj.default_attributes) + except errors.NotFound: + self.obj.handle_not_found(*keys) + + if is_all(_entry_attrs, 'cmdcategory'): + raise errors.MutuallyExclusiveError( + reason=_("commands cannot be added when command " + "category='all'")) + + return dn + + +@register() +class sudorule_remove_allow_command(LDAPRemoveMember): + __doc__ = _('Remove commands and sudo command groups affected by Sudo Rule.') + + member_attributes = ['memberallowcmd'] + member_count_out = ('%i object removed.', '%i objects removed.') + + +@register() +class sudorule_add_deny_command(LDAPAddMember): + __doc__ = _('Add commands and sudo command groups affected by Sudo Rule.') + + member_attributes = ['memberdenycmd'] + member_count_out = ('%i object added.', '%i objects added.') + + def pre_callback(self, ldap, dn, found, not_found, *keys, **options): + assert isinstance(dn, DN) + return dn + + +@register() +class sudorule_remove_deny_command(LDAPRemoveMember): + __doc__ = _('Remove commands and sudo command groups affected by Sudo Rule.') + + member_attributes = ['memberdenycmd'] + member_count_out = ('%i object removed.', '%i objects removed.') + + +@register() +class sudorule_add_user(LDAPAddMember): + __doc__ = _('Add users and groups affected by Sudo Rule.') + + member_attributes = ['memberuser'] + member_count_out = ('%i object added.', '%i objects added.') + + def pre_callback(self, ldap, dn, found, not_found, *keys, **options): + assert isinstance(dn, DN) + + try: + _entry_attrs = ldap.get_entry(dn, self.obj.default_attributes) + except errors.NotFound: + self.obj.handle_not_found(*keys) + + if is_all(_entry_attrs, 'usercategory'): + raise errors.MutuallyExclusiveError( + reason=_("users cannot be added when user category='all'")) + + return add_external_pre_callback('user', ldap, dn, keys, options) + + def post_callback(self, ldap, completed, failed, dn, entry_attrs, + *keys, **options): + assert isinstance(dn, DN) + return add_external_post_callback(ldap, dn, entry_attrs, + failed=failed, + completed=completed, + memberattr='memberuser', + membertype='user', + externalattr='externaluser') + + +@register() +class sudorule_remove_user(LDAPRemoveMember): + __doc__ = _('Remove users and groups affected by Sudo Rule.') + + member_attributes = ['memberuser'] + member_count_out = ('%i object removed.', '%i objects removed.') + + def post_callback(self, ldap, completed, failed, dn, entry_attrs, + *keys, **options): + assert isinstance(dn, DN) + return remove_external_post_callback(ldap, dn, entry_attrs, + failed=failed, + completed=completed, + memberattr='memberuser', + membertype='user', + externalattr='externaluser') + + +@register() +class sudorule_add_host(LDAPAddMember): + __doc__ = _('Add hosts and hostgroups affected by Sudo Rule.') + + member_attributes = ['memberhost'] + member_count_out = ('%i object added.', '%i objects added.') + + def get_options(self): + for option in super(sudorule_add_host, self).get_options(): + yield option + yield hostmask_membership_param + + def pre_callback(self, ldap, dn, found, not_found, *keys, **options): + assert isinstance(dn, DN) + try: + _entry_attrs = ldap.get_entry(dn, self.obj.default_attributes) + except errors.NotFound: + self.obj.handle_not_found(*keys) + + if is_all(_entry_attrs, 'hostcategory'): + raise errors.MutuallyExclusiveError( + reason=_("hosts cannot be added when host category='all'")) + + return add_external_pre_callback('host', ldap, dn, keys, options) + + def post_callback(self, ldap, completed, failed, dn, entry_attrs, + *keys, **options): + assert isinstance(dn, DN) + try: + _entry_attrs = ldap.get_entry(dn, self.obj.default_attributes) + except errors.NotFound: + self.obj.handle_not_found(*keys) + + if 'hostmask' in options: + norm = lambda x: unicode(netaddr.IPNetwork(x).cidr) + + old_masks = set(norm(m) for m in _entry_attrs.get('hostmask', [])) + new_masks = set(norm(m) for m in options['hostmask']) + + num_added = len(new_masks - old_masks) + + if num_added: + entry_attrs['hostmask'] = list(old_masks | new_masks) + try: + ldap.update_entry(entry_attrs) + except errors.EmptyModlist: + pass + completed = completed + num_added + + return add_external_post_callback(ldap, dn, entry_attrs, + failed=failed, + completed=completed, + memberattr='memberhost', + membertype='host', + externalattr='externalhost') + + +@register() +class sudorule_remove_host(LDAPRemoveMember): + __doc__ = _('Remove hosts and hostgroups affected by Sudo Rule.') + + member_attributes = ['memberhost'] + member_count_out = ('%i object removed.', '%i objects removed.') + + def get_options(self): + for option in super(sudorule_remove_host, self).get_options(): + yield option + yield hostmask_membership_param + + def post_callback(self, ldap, completed, failed, dn, entry_attrs, + *keys, **options): + assert isinstance(dn, DN) + + try: + _entry_attrs = ldap.get_entry(dn, self.obj.default_attributes) + except errors.NotFound: + self.obj.handle_not_found(*keys) + + if 'hostmask' in options: + def norm(x): + return unicode(netaddr.IPNetwork(x).cidr) + + old_masks = set(norm(m) for m in _entry_attrs.get('hostmask', [])) + removed_masks = set(norm(m) for m in options['hostmask']) + + num_added = len(removed_masks & old_masks) + + if num_added: + entry_attrs['hostmask'] = list(old_masks - removed_masks) + try: + ldap.update_entry(entry_attrs) + except errors.EmptyModlist: + pass + completed = completed + num_added + + return remove_external_post_callback(ldap, dn, entry_attrs, + failed=failed, + completed=completed, + memberattr='memberhost', + membertype='host', + externalattr='externalhost') + + +@register() +class sudorule_add_runasuser(LDAPAddMember): + __doc__ = _('Add users and groups for Sudo to execute as.') + + member_attributes = ['ipasudorunas'] + member_count_out = ('%i object added.', '%i objects added.') + + def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options): + assert isinstance(dn, DN) + + def check_validity(runas): + v = unicode(runas) + if v.upper() == u'ALL': + return False + return True + + try: + _entry_attrs = ldap.get_entry(dn, self.obj.default_attributes) + except errors.NotFound: + self.obj.handle_not_found(*keys) + + if any((is_all(_entry_attrs, 'ipasudorunasusercategory'), + is_all(_entry_attrs, 'ipasudorunasgroupcategory'))): + + raise errors.MutuallyExclusiveError( + reason=_("users cannot be added when runAs user or runAs " + "group category='all'")) + + if 'user' in options: + for name in options['user']: + if not check_validity(name): + raise errors.ValidationError(name='runas-user', + error=unicode(_("RunAsUser does not accept " + "'%(name)s' as a user name")) % + dict(name=name)) + + if 'group' in options: + for name in options['group']: + if not check_validity(name): + raise errors.ValidationError(name='runas-user', + error=unicode(_("RunAsUser does not accept " + "'%(name)s' as a group name")) % + dict(name=name)) + + return add_external_pre_callback('user', ldap, dn, keys, options) + + def post_callback(self, ldap, completed, failed, dn, entry_attrs, + *keys, **options): + assert isinstance(dn, DN) + + # Since external_post_callback returns the total number of completed + # entries yet (that is, any external users it added plus the value of + # passed variable 'completed', we need to pass 0 as completed, + # so that the entries added by the framework are not counted twice + # (once in each call of add_external_post_callback) + + (completed_ex_users, dn) = add_external_post_callback(ldap, dn, + entry_attrs, + failed=failed, + completed=0, + memberattr='ipasudorunas', + membertype='user', + externalattr='ipasudorunasextuser', + ) + + (completed_ex_groups, dn) = add_external_post_callback(ldap, dn, + entry_attrs=entry_attrs, + failed=failed, + completed=0, + memberattr='ipasudorunas', + membertype='group', + externalattr='ipasudorunasextusergroup', + ) + + return (completed + completed_ex_users + completed_ex_groups, dn) + + +@register() +class sudorule_remove_runasuser(LDAPRemoveMember): + __doc__ = _('Remove users and groups for Sudo to execute as.') + + member_attributes = ['ipasudorunas'] + member_count_out = ('%i object removed.', '%i objects removed.') + + def post_callback(self, ldap, completed, failed, dn, entry_attrs, + *keys, **options): + assert isinstance(dn, DN) + + # Since external_post_callback returns the total number of completed + # entries yet (that is, any external users it added plus the value of + # passed variable 'completed', we need to pass 0 as completed, + # so that the entries added by the framework are not counted twice + # (once in each call of remove_external_post_callback) + + (completed_ex_users, dn) = remove_external_post_callback(ldap, dn, + entry_attrs=entry_attrs, + failed=failed, + completed=0, + memberattr='ipasudorunas', + membertype='user', + externalattr='ipasudorunasextuser', + ) + + (completed_ex_groups, dn) = remove_external_post_callback(ldap, dn, + entry_attrs=entry_attrs, + failed=failed, + completed=0, + memberattr='ipasudorunas', + membertype='group', + externalattr='ipasudorunasextusergroup', + ) + + return (completed + completed_ex_users + completed_ex_groups, dn) + + +@register() +class sudorule_add_runasgroup(LDAPAddMember): + __doc__ = _('Add group for Sudo to execute as.') + + member_attributes = ['ipasudorunasgroup'] + member_count_out = ('%i object added.', '%i objects added.') + + def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options): + assert isinstance(dn, DN) + + def check_validity(runas): + v = unicode(runas) + if v.upper() == u'ALL': + return False + return True + + try: + _entry_attrs = ldap.get_entry(dn, self.obj.default_attributes) + except errors.NotFound: + self.obj.handle_not_found(*keys) + if is_all(_entry_attrs, 'ipasudorunasusercategory') or \ + is_all(_entry_attrs, 'ipasudorunasgroupcategory'): + raise errors.MutuallyExclusiveError( + reason=_("users cannot be added when runAs user or runAs " + "group category='all'")) + + if 'group' in options: + for name in options['group']: + if not check_validity(name): + raise errors.ValidationError(name='runas-group', + error=unicode(_("RunAsGroup does not accept " + "'%(name)s' as a group name")) % + dict(name=name)) + + return add_external_pre_callback('group', ldap, dn, keys, options) + + def post_callback(self, ldap, completed, failed, dn, entry_attrs, + *keys, **options): + assert isinstance(dn, DN) + return add_external_post_callback(ldap, dn, entry_attrs, + failed=failed, + completed=completed, + memberattr='ipasudorunasgroup', + membertype='group', + externalattr='ipasudorunasextgroup', + ) + + +@register() +class sudorule_remove_runasgroup(LDAPRemoveMember): + __doc__ = _('Remove group for Sudo to execute as.') + + member_attributes = ['ipasudorunasgroup'] + member_count_out = ('%i object removed.', '%i objects removed.') + + def post_callback(self, ldap, completed, failed, dn, entry_attrs, + *keys, **options): + assert isinstance(dn, DN) + return remove_external_post_callback(ldap, dn, entry_attrs, + failed=failed, + completed=completed, + memberattr='ipasudorunasgroup', + membertype='group', + externalattr='ipasudorunasextgroup', + ) + + +@register() +class sudorule_add_option(LDAPQuery): + __doc__ = _('Add an option to the Sudo Rule.') + + has_output = output.standard_entry + takes_options = ( + Str('ipasudoopt', + cli_name='sudooption', + label=_('Sudo Option'), + ), + ) + + def execute(self, cn, **options): + ldap = self.obj.backend + + dn = self.obj.get_dn(cn) + + if not options['ipasudoopt'].strip(): + raise errors.EmptyModlist() + entry_attrs = ldap.get_entry(dn, ['ipasudoopt']) + + try: + if options['ipasudoopt'] not in entry_attrs['ipasudoopt']: + entry_attrs.setdefault('ipasudoopt', []).append( + options['ipasudoopt']) + else: + raise errors.DuplicateEntry + except KeyError: + entry_attrs.setdefault('ipasudoopt', []).append( + options['ipasudoopt']) + try: + ldap.update_entry(entry_attrs) + except errors.EmptyModlist: + pass + except errors.NotFound: + self.obj.handle_not_found(cn) + + attrs_list = self.obj.default_attributes + entry_attrs = ldap.get_entry(dn, attrs_list) + + entry_attrs = entry_to_dict(entry_attrs, **options) + + return dict(result=entry_attrs, value=pkey_to_value(cn, options)) + + +@register() +class sudorule_remove_option(LDAPQuery): + __doc__ = _('Remove an option from Sudo Rule.') + + has_output = output.standard_entry + takes_options = ( + Str('ipasudoopt', + cli_name='sudooption', + label=_('Sudo Option'), + ), + ) + + def execute(self, cn, **options): + ldap = self.obj.backend + + dn = self.obj.get_dn(cn) + + if not options['ipasudoopt'].strip(): + raise errors.EmptyModlist() + + entry_attrs = ldap.get_entry(dn, ['ipasudoopt']) + + try: + if options['ipasudoopt'] in entry_attrs['ipasudoopt']: + entry_attrs.setdefault('ipasudoopt', []).remove( + options['ipasudoopt']) + ldap.update_entry(entry_attrs) + else: + raise errors.AttrValueNotFound( + attr='ipasudoopt', + value=options['ipasudoopt'] + ) + except ValueError: + pass + except KeyError: + raise errors.AttrValueNotFound( + attr='ipasudoopt', + value=options['ipasudoopt'] + ) + except errors.NotFound: + self.obj.handle_not_found(cn) + + attrs_list = self.obj.default_attributes + entry_attrs = ldap.get_entry(dn, attrs_list) + + entry_attrs = entry_to_dict(entry_attrs, **options) + + return dict(result=entry_attrs, value=pkey_to_value(cn, options)) diff --git a/ipaserver/plugins/topology.py b/ipaserver/plugins/topology.py new file mode 100644 index 000000000..a6e638479 --- /dev/null +++ b/ipaserver/plugins/topology.py @@ -0,0 +1,503 @@ +# +# Copyright (C) 2015 FreeIPA Contributors see COPYING for license +# + +import six + +from ipalib import api, errors +from ipalib import Int, Str, StrEnum, Flag, DNParam +from ipalib.plugable import Registry +from .baseldap import ( + LDAPObject, LDAPSearch, LDAPCreate, LDAPDelete, LDAPUpdate, LDAPQuery, + LDAPRetrieve) +from ipalib import _, ngettext +from ipalib import output +from ipalib.constants import DOMAIN_LEVEL_1 +from ipalib.util import create_topology_graph, get_topology_connection_errors +from ipapython.dn import DN + +if six.PY3: + unicode = str + +__doc__ = _(""" +Topology + +Management of a replication topology at domain level 1. +""") + _(""" +IPA server's data is stored in LDAP server in two suffixes: +* domain suffix, e.g., 'dc=example,dc=com', contains all domain related data +* ca suffix, 'o=ipaca', is present only on server with CA installed. It + contains data for Certificate Server component +""") + _(""" +Data stored on IPA servers is replicated to other IPA servers. The way it is +replicated is defined by replication agreements. Replication agreements needs +to be set for both suffixes separately. On domain level 0 they are managed +using ipa-replica-manage and ipa-csreplica-manage tools. With domain level 1 +they are managed centrally using `ipa topology*` commands. +""") + _(""" +Agreements are represented by topology segments. By default topology segment +represents 2 replication agreements - one for each direction, e.g., A to B and +B to A. Creation of unidirectional segments is not allowed. +""") + _(""" +To verify that no server is disconnected in the topology of the given suffix, +use: + ipa topologysuffix-verify $suffix +""") + _(""" + +Examples: + Find all IPA servers: + ipa server-find +""") + _(""" + Find all suffixes: + ipa topologysuffix-find +""") + _(""" + Add topology segment to 'domain' suffix: + ipa topologysegment-add domain --left IPA_SERVER_A --right IPA_SERVER_B +""") + _(""" + Add topology segment to 'ca' suffix: + ipa topologysegment-add ca --left IPA_SERVER_A --right IPA_SERVER_B +""") + _(""" + List all topology segments in 'domain' suffix: + ipa topologysegment-find domain +""") + _(""" + List all topology segments in 'ca' suffix: + ipa topologysegment-find ca +""") + _(""" + Delete topology segment in 'domain' suffix: + ipa topologysegment-del domain segment_name +""") + _(""" + Delete topology segment in 'ca' suffix: + ipa topologysegment-del ca segment_name +""") + _(""" + Verify topology of 'domain' suffix: + ipa topologysuffix-verify domain +""") + _(""" + Verify topology of 'ca' suffix: + ipa topologysuffix-verify ca +""") + +register = Registry() + + +def validate_domain_level(api): + current = int(api.Command.domainlevel_get()['result']) + if current < DOMAIN_LEVEL_1: + raise errors.InvalidDomainLevelError( + reason=_('Topology management requires minimum domain level {0} ' + .format(DOMAIN_LEVEL_1)) + ) + + +@register() +class topologysegment(LDAPObject): + """ + Topology segment. + """ + parent_object = 'topologysuffix' + container_dn = api.env.container_topology + object_name = _('segment') + object_name_plural = _('segments') + object_class = ['iparepltoposegment'] + default_attributes = [ + 'cn', + 'ipaReplTopoSegmentdirection', 'ipaReplTopoSegmentrightNode', + 'ipaReplTopoSegmentLeftNode', 'nsds5replicastripattrs', + 'nsds5replicatedattributelist', 'nsds5replicatedattributelisttotal', + 'nsds5replicatimeout', 'nsds5replicaenabled' + ] + search_display_attributes = [ + 'cn', 'ipaReplTopoSegmentdirection', 'ipaReplTopoSegmentrightNode', + 'ipaReplTopoSegmentLeftNode' + ] + + label = _('Topology Segments') + label_singular = _('Topology Segment') + + takes_params = ( + Str( + 'cn', + maxlength=255, + cli_name='name', + primary_key=True, + label=_('Segment name'), + default_from=lambda iparepltoposegmentleftnode, iparepltoposegmentrightnode: + '%s-to-%s' % (iparepltoposegmentleftnode, iparepltoposegmentrightnode), + normalizer=lambda value: value.lower(), + doc=_('Arbitrary string identifying the segment'), + ), + Str( + 'iparepltoposegmentleftnode', + pattern='^[a-zA-Z0-9.][a-zA-Z0-9.-]{0,252}[a-zA-Z0-9.$-]?$', + pattern_errmsg='may only include letters, numbers, -, . and $', + maxlength=255, + cli_name='leftnode', + label=_('Left node'), + normalizer=lambda value: value.lower(), + doc=_('Left replication node - an IPA server'), + flags={'no_update'}, + ), + Str( + 'iparepltoposegmentrightnode', + pattern='^[a-zA-Z0-9.][a-zA-Z0-9.-]{0,252}[a-zA-Z0-9.$-]?$', + pattern_errmsg='may only include letters, numbers, -, . and $', + maxlength=255, + cli_name='rightnode', + label=_('Right node'), + normalizer=lambda value: value.lower(), + doc=_('Right replication node - an IPA server'), + flags={'no_update'}, + ), + StrEnum( + 'iparepltoposegmentdirection', + cli_name='direction', + label=_('Connectivity'), + values=(u'both', u'left-right', u'right-left'), + default=u'both', + autofill=True, + doc=_('Direction of replication between left and right replication ' + 'node'), + flags={'no_option', 'no_update'}, + ), + Str( + 'nsds5replicastripattrs?', + cli_name='stripattrs', + label=_('Attributes to strip'), + normalizer=lambda value: value.lower(), + doc=_('A space separated list of attributes which are removed from ' + 'replication updates.') + ), + Str( + 'nsds5replicatedattributelist?', + cli_name='replattrs', + label='Attributes to replicate', + doc=_('Attributes that are not replicated to a consumer server ' + 'during a fractional update. E.g., `(objectclass=*) ' + '$ EXCLUDE accountlockout memberof'), + ), + Str( + 'nsds5replicatedattributelisttotal?', + cli_name='replattrstotal', + label=_('Attributes for total update'), + doc=_('Attributes that are not replicated to a consumer server ' + 'during a total update. E.g. (objectclass=*) $ EXCLUDE ' + 'accountlockout'), + ), + Int( + 'nsds5replicatimeout?', + cli_name='timeout', + label=_('Session timeout'), + minvalue=0, + doc=_('Number of seconds outbound LDAP operations waits for a ' + 'response from the remote replica before timing out and ' + 'failing'), + ), + StrEnum( + 'nsds5replicaenabled?', + cli_name='enabled', + label=_('Replication agreement enabled'), + doc=_('Whether a replication agreement is active, meaning whether ' + 'replication is occurring per that agreement'), + values=(u'on', u'off'), + flags={'no_option'}, + ), + ) + + def validate_nodes(self, ldap, dn, entry_attrs): + leftnode = entry_attrs.get('iparepltoposegmentleftnode') + rightnode = entry_attrs.get('iparepltoposegmentrightnode') + + if not leftnode and not rightnode: + return # nothing to check + + # check if nodes are IPA servers + masters = self.api.Command.server_find( + '', sizelimit=0, no_members=False)['result'] + m_hostnames = [master['cn'][0].lower() for master in masters] + + if leftnode and leftnode not in m_hostnames: + raise errors.ValidationError( + name='leftnode', + error=_('left node is not a topology node: %(leftnode)s') % + dict(leftnode=leftnode) + ) + + if rightnode and rightnode not in m_hostnames: + raise errors.ValidationError( + name='rightnode', + error=_('right node is not a topology node: %(rightnode)s') % + dict(rightnode=rightnode) + ) + + # prevent creation of reflexive relation + key = 'leftnode' + if not leftnode or not rightnode: # get missing end + _entry_attrs = ldap.get_entry(dn, ['*']) + if not leftnode: + key = 'rightnode' + leftnode = _entry_attrs['iparepltoposegmentleftnode'][0] + else: + rightnode = _entry_attrs['iparepltoposegmentrightnode'][0] + + if leftnode == rightnode: + raise errors.ValidationError( + name=key, + error=_('left node and right node must not be the same') + ) + + +@register() +class topologysegment_find(LDAPSearch): + __doc__ = _('Search for topology segments.') + + msg_summary = ngettext( + '%(count)d segment matched', + '%(count)d segments matched', 0 + ) + + +@register() +class topologysegment_add(LDAPCreate): + __doc__ = _('Add a new segment.') + + msg_summary = _('Added segment "%(value)s"') + + def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options): + assert isinstance(dn, DN) + validate_domain_level(self.api) + self.obj.validate_nodes(ldap, dn, entry_attrs) + return dn + + +@register() +class topologysegment_del(LDAPDelete): + __doc__ = _('Delete a segment.') + + msg_summary = _('Deleted segment "%(value)s"') + + def pre_callback(self, ldap, dn, *keys, **options): + assert isinstance(dn, DN) + validate_domain_level(self.api) + return dn + + +@register() +class topologysegment_mod(LDAPUpdate): + __doc__ = _('Modify a segment.') + + msg_summary = _('Modified segment "%(value)s"') + + def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options): + assert isinstance(dn, DN) + validate_domain_level(self.api) + self.obj.validate_nodes(ldap, dn, entry_attrs) + return dn + + +@register() +class topologysegment_reinitialize(LDAPQuery): + __doc__ = _('Request a full re-initialization of the node ' + 'retrieving data from the other node.') + + has_output = output.standard_value + msg_summary = _('%(value)s') + + takes_options = ( + Flag( + 'left?', + doc=_('Initialize left node'), + default=False, + ), + Flag( + 'right?', + doc=_('Initialize right node'), + default=False, + ), + Flag( + 'stop?', + doc=_('Stop already started refresh of chosen node(s)'), + default=False, + ), + ) + + def execute(self, *keys, **options): + dn = self.obj.get_dn(*keys, **options) + validate_domain_level(self.api) + + entry = self.obj.backend.get_entry( + dn, [ + 'nsds5beginreplicarefresh;left', + 'nsds5beginreplicarefresh;right' + ]) + + left = options.get('left') + right = options.get('right') + stop = options.get('stop') + + if not left and not right: + raise errors.OptionError( + _('left or right node has to be specified') + ) + + if left and right: + raise errors.OptionError( + _('only one node can be specified') + ) + + action = u'start' + msg = _('Replication refresh for segment: "%(pkey)s" requested.') + if stop: + action = u'stop' + msg = _('Stopping of replication refresh for segment: "' + '%(pkey)s" requested.') + + # left and right are swapped because internally it's a push not + # pull operation + if right: + entry['nsds5beginreplicarefresh;left'] = [action] + if left: + entry['nsds5beginreplicarefresh;right'] = [action] + + self.obj.backend.update_entry(entry) + + msg = msg % {'pkey': keys[-1]} + return dict( + result=True, + value=msg, + ) + + +@register() +class topologysegment_show(LDAPRetrieve): + __doc__ = _('Display a segment.') + + +@register() +class topologysuffix(LDAPObject): + """ + Suffix managed by the topology plugin. + """ + container_dn = api.env.container_topology + object_name = _('suffix') + object_name_plural = _('suffixes') + object_class = ['iparepltopoconf'] + default_attributes = ['cn', 'ipaReplTopoConfRoot'] + search_display_attributes = ['cn', 'ipaReplTopoConfRoot'] + label = _('Topology suffixes') + label_singular = _('Topology suffix') + + takes_params = ( + Str( + 'cn', + cli_name='name', + primary_key=True, + label=_('Suffix name'), + ), + DNParam( + 'iparepltopoconfroot', + cli_name='suffix_dn', + label=_('Managed LDAP suffix DN'), + ), + ) + + +@register() +class topologysuffix_find(LDAPSearch): + __doc__ = _('Search for topology suffixes.') + + msg_summary = ngettext( + '%(count)d topology suffix matched', + '%(count)d topology suffixes matched', 0 + ) + + +@register() +class topologysuffix_del(LDAPDelete): + __doc__ = _('Delete a topology suffix.') + + NO_CLI = True + + msg_summary = _('Deleted topology suffix "%(value)s"') + + def pre_callback(self, ldap, dn, *keys, **options): + assert isinstance(dn, DN) + validate_domain_level(self.api) + return dn + + +@register() +class topologysuffix_add(LDAPCreate): + __doc__ = _('Add a new topology suffix to be managed.') + + NO_CLI = True + + msg_summary = _('Added topology suffix "%(value)s"') + + def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options): + assert isinstance(dn, DN) + validate_domain_level(self.api) + return dn + + +@register() +class topologysuffix_mod(LDAPUpdate): + __doc__ = _('Modify a topology suffix.') + + NO_CLI = True + + msg_summary = _('Modified topology suffix "%(value)s"') + + def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options): + assert isinstance(dn, DN) + validate_domain_level(self.api) + return dn + + +@register() +class topologysuffix_show(LDAPRetrieve): + __doc__ = _('Show managed suffix.') + + +@register() +class topologysuffix_verify(LDAPQuery): + __doc__ = _(''' +Verify replication topology for suffix. + +Checks done: + 1. check if a topology is not disconnected. In other words if there are + replication paths between all servers. + 2. check if servers don't have more than the recommended number of + replication agreements +''') + + def execute(self, *keys, **options): + + validate_domain_level(self.api) + + masters = self.api.Command.server_find( + '', sizelimit=0, no_members=False)['result'] + segments = self.api.Command.topologysegment_find( + keys[0], sizelimit=0)['result'] + graph = create_topology_graph(masters, segments) + master_cns = [m['cn'][0] for m in masters] + master_cns.sort() + + # check if each master can contact others + connect_errors = get_topology_connection_errors(graph) + + # check if suggested maximum number of agreements per replica + max_agmts_errors = [] + for m in master_cns: + # chosen direction doesn't matter much given that 'both' is the + # only allowed direction + suppliers = graph.get_tails(m) + if len(suppliers) > self.api.env.recommended_max_agmts: + max_agmts_errors.append((m, suppliers)) + + return dict( + result={ + 'in_order': not connect_errors and not max_agmts_errors, + 'connect_errors': connect_errors, + 'max_agmts_errors': max_agmts_errors, + 'max_agmts': self.api.env.recommended_max_agmts + }, + ) diff --git a/ipaserver/plugins/trust.py b/ipaserver/plugins/trust.py new file mode 100644 index 000000000..ee0ab5d10 --- /dev/null +++ b/ipaserver/plugins/trust.py @@ -0,0 +1,1725 @@ +# Authors: +# Alexander Bokovoy <abokovoy@redhat.com> +# Martin Kosek <mkosek@redhat.com> +# +# Copyright (C) 2011 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +import six + +from ipalib.messages import ( + add_message, + BrokenTrust) +from ipalib.plugable import Registry +from .baseldap import ( + pkey_to_value, + entry_to_dict, + LDAPCreate, + LDAPDelete, + LDAPUpdate, + LDAPSearch, + LDAPRetrieve, + LDAPObject, + LDAPQuery) +from .dns import dns_container_exists +from ipapython.dn import DN +from ipapython.ipautil import realm_to_suffix +from ipapython.ipa_log_manager import root_logger +from ipalib import api, Str, StrEnum, Password, Bool, _, ngettext, Int, Flag +from ipalib import Command +from ipalib import errors +from ipalib import output +from ldap import SCOPE_SUBTREE +from time import sleep + +if six.PY3: + unicode = str + +try: + import pysss_murmur #pylint: disable=F0401 + _murmur_installed = True +except Exception as e: + _murmur_installed = False + +try: + import pysss_nss_idmap #pylint: disable=F0401 + _nss_idmap_installed = True +except Exception as e: + _nss_idmap_installed = False + +if api.env.in_server and api.env.context in ['lite', 'server']: + try: + import ipaserver.dcerpc #pylint: disable=F0401 + from ipaserver.dcerpc import TRUST_ONEWAY, TRUST_BIDIRECTIONAL + import dbus + import dbus.mainloop.glib + _bindings_installed = True + except ImportError: + _bindings_installed = False + +__doc__ = _(""" +Cross-realm trusts + +Manage trust relationship between IPA and Active Directory domains. + +In order to allow users from a remote domain to access resources in IPA +domain, trust relationship needs to be established. Currently IPA supports +only trusts between IPA and Active Directory domains under control of Windows +Server 2008 or later, with functional level 2008 or later. + +Please note that DNS on both IPA and Active Directory domain sides should be +configured properly to discover each other. Trust relationship relies on +ability to discover special resources in the other domain via DNS records. + +Examples: + +1. Establish cross-realm trust with Active Directory using AD administrator + credentials: + + ipa trust-add --type=ad <ad.domain> --admin <AD domain administrator> --password + +2. List all existing trust relationships: + + ipa trust-find + +3. Show details of the specific trust relationship: + + ipa trust-show <ad.domain> + +4. Delete existing trust relationship: + + ipa trust-del <ad.domain> + +Once trust relationship is established, remote users will need to be mapped +to local POSIX groups in order to actually use IPA resources. The mapping should +be done via use of external membership of non-POSIX group and then this group +should be included into one of local POSIX groups. + +Example: + +1. Create group for the trusted domain admins' mapping and their local POSIX group: + + ipa group-add --desc='<ad.domain> admins external map' ad_admins_external --external + ipa group-add --desc='<ad.domain> admins' ad_admins + +2. Add security identifier of Domain Admins of the <ad.domain> to the ad_admins_external + group: + + ipa group-add-member ad_admins_external --external 'AD\\Domain Admins' + +3. Allow members of ad_admins_external group to be associated with ad_admins POSIX group: + + ipa group-add-member ad_admins --groups ad_admins_external + +4. List members of external members of ad_admins_external group to see their SIDs: + + ipa group-show ad_admins_external + + +GLOBAL TRUST CONFIGURATION + +When IPA AD trust subpackage is installed and ipa-adtrust-install is run, +a local domain configuration (SID, GUID, NetBIOS name) is generated. These +identifiers are then used when communicating with a trusted domain of the +particular type. + +1. Show global trust configuration for Active Directory type of trusts: + + ipa trustconfig-show --type ad + +2. Modify global configuration for all trusts of Active Directory type and set + a different fallback primary group (fallback primary group GID is used as + a primary user GID if user authenticating to IPA domain does not have any other + primary GID already set): + + ipa trustconfig-mod --type ad --fallback-primary-group "alternative AD group" + +3. Change primary fallback group back to default hidden group (any group with + posixGroup object class is allowed): + + ipa trustconfig-mod --type ad --fallback-primary-group "Default SMB Group" +""") + +register = Registry() + +trust_output_params = ( + Str('trustdirection', + label=_('Trust direction')), + Str('trusttype', + label=_('Trust type')), + Str('truststatus', + label=_('Trust status')), +) + +_trust_type_dict = {1 : _('Non-Active Directory domain'), + 2 : _('Active Directory domain'), + 3 : _('RFC4120-compliant Kerberos realm')} +_trust_direction_dict = {1 : _('Trusting forest'), + 2 : _('Trusted forest'), + 3 : _('Two-way trust')} +_trust_status_dict = {True : _('Established and verified'), + False : _('Waiting for confirmation by remote side')} +_trust_type_dict_unknown = _('Unknown') + +_trust_type_option = StrEnum('trust_type', + cli_name='type', + label=_('Trust type (ad for Active Directory, default)'), + values=(u'ad',), + default=u'ad', + autofill=True, + ) + +DEFAULT_RANGE_SIZE = 200000 + +DBUS_IFACE_TRUST = 'com.redhat.idm.trust' + +CRED_STYLE_SAMBA = 1 +CRED_STYLE_KERBEROS = 2 + +def trust_type_string(level): + """ + Returns a string representing a type of the trust. The original field is an enum: + LSA_TRUST_TYPE_DOWNLEVEL = 0x00000001, + LSA_TRUST_TYPE_UPLEVEL = 0x00000002, + LSA_TRUST_TYPE_MIT = 0x00000003 + """ + string = _trust_type_dict.get(int(level), _trust_type_dict_unknown) + return unicode(string) + +def trust_direction_string(level): + """ + Returns a string representing a direction of the trust. The original field is a bitmask taking two bits in use + LSA_TRUST_DIRECTION_INBOUND = 0x00000001, + LSA_TRUST_DIRECTION_OUTBOUND = 0x00000002 + """ + string = _trust_direction_dict.get(int(level), _trust_type_dict_unknown) + return unicode(string) + +def trust_status_string(level): + string = _trust_status_dict.get(level, _trust_type_dict_unknown) + return unicode(string) + +def make_trust_dn(env, trust_type, dn): + assert isinstance(dn, DN) + if trust_type: + container_dn = DN(('cn', trust_type), env.container_trusts, env.basedn) + return DN(dn, container_dn) + return dn + +def find_adtrust_masters(ldap, api): + """ + Returns a list of names of IPA servers with ADTRUST component configured. + """ + + try: + entries, truncated = ldap.find_entries( + "cn=ADTRUST", + base_dn=api.env.container_masters + api.env.basedn + ) + except errors.NotFound: + entries = [] + + return [entry.dn[1].value for entry in entries] + +def verify_samba_component_presence(ldap, api): + """ + Verifies that Samba is installed and configured on this particular master. + If Samba is not available, provide a heplful hint with the list of masters + capable of running the commands. + """ + + adtrust_present = api.Command['adtrust_is_enabled']()['result'] + + hint = _( + ' Alternatively, following servers are capable of running this ' + 'command: %(masters)s' + ) + + def raise_missing_component_error(message): + masters_with_adtrust = find_adtrust_masters(ldap, api) + + # If there are any masters capable of running Samba requiring commands + # let's advertise them directly + if masters_with_adtrust: + message += hint % dict(masters=', '.join(masters_with_adtrust)) + + raise errors.NotFound( + name=_('AD Trust setup'), + reason=message, + ) + + # We're ok in this case, bail out + if adtrust_present and _bindings_installed: + return + + # First check for packages missing + elif not _bindings_installed: + error_message=_( + 'Cannot perform the selected command without Samba 4 support ' + 'installed. Make sure you have installed server-trust-ad ' + 'sub-package of IPA.' + ) + + raise_missing_component_error(error_message) + + # Packages present, but ADTRUST instance is not configured + elif not adtrust_present: + error_message=_( + 'Cannot perform the selected command without Samba 4 instance ' + 'configured on this machine. Make sure you have run ' + 'ipa-adtrust-install on this server.' + ) + + raise_missing_component_error(error_message) + + +def generate_creds(trustinstance, style, **options): + """ + Generate string representing credentials using trust instance + Input: + trustinstance -- ipaserver.dcerpc.TrustInstance object + style -- style of credentials + CRED_STYLE_SAMBA -- for using with Samba bindings + CRED_STYLE_KERBEROS -- for obtaining Kerberos ticket + **options -- options with realm_admin and realm_passwd keys + + Result: + a string representing credentials with first % separating username and password + None is returned if realm_passwd key returns nothing from options + """ + creds = None + password = options.get('realm_passwd', None) + if password: + admin_name = options.get('realm_admin') + sp = [] + sep = '@' + if style == CRED_STYLE_SAMBA: + sep = "\\" + sp = admin_name.split(sep) + if len(sp) == 1: + sp.insert(0, trustinstance.remote_domain.info['name']) + elif style == CRED_STYLE_KERBEROS: + sp = admin_name.split('\\') + if len(sp) > 1: + sp = [sp[1]] + else: + sp = admin_name.split(sep) + if len(sp) == 1: + sp.append(trustinstance.remote_domain.info['dns_forest'].upper()) + creds = u"{name}%{password}".format(name=sep.join(sp), + password=password) + return creds + +def add_range(myapi, trustinstance, range_name, dom_sid, *keys, **options): + """ + First, we try to derive the parameters of the ID range based on the + information contained in the Active Directory. + + If that was not successful, we go for our usual defaults (random base, + range size 200 000, ipa-ad-trust range type). + + Any of these can be overridden by passing appropriate CLI options + to the trust-add command. + """ + + range_size = None + range_type = None + base_id = None + + # First, get information about ID space from AD + # However, we skip this step if other than ipa-ad-trust-posix + # range type is enforced + + if options.get('range_type', None) in (None, u'ipa-ad-trust-posix'): + + # Get the base dn + domain = keys[-1] + basedn = realm_to_suffix(domain) + + # Search for information contained in + # CN=ypservers,CN=ypServ30,CN=RpcServices,CN=System + info_filter = '(objectClass=msSFU30DomainInfo)' + info_dn = DN('CN=ypservers,CN=ypServ30,CN=RpcServices,CN=System')\ + + basedn + + # Get the domain validator + domain_validator = ipaserver.dcerpc.DomainValidator(myapi) + if not domain_validator.is_configured(): + raise errors.NotFound( + reason=_('Cannot search in trusted domains without own ' + 'domain configured. Make sure you have run ' + 'ipa-adtrust-install on the IPA server first')) + + creds = None + if trustinstance: + # Re-use AD administrator credentials if they were provided + creds = generate_creds(trustinstance, style=CRED_STYLE_KERBEROS, **options) + if creds: + domain_validator._admin_creds = creds + # KDC might not get refreshed data at the first time, + # retry several times + for retry in range(10): + info_list = domain_validator.search_in_dc(domain, + info_filter, + None, + SCOPE_SUBTREE, + basedn=info_dn, + quiet=True) + + if info_list: + info = info_list[0] + break + else: + sleep(2) + + required_msSFU_attrs = ['msSFU30MaxUidNumber', 'msSFU30OrderNumber'] + + if not info_list: + # We were unable to gain UNIX specific info from the AD + root_logger.debug("Unable to gain POSIX info from the AD") + else: + if all(attr in info for attr in required_msSFU_attrs): + root_logger.debug("Able to gain POSIX info from the AD") + range_type = u'ipa-ad-trust-posix' + + max_uid = info.get('msSFU30MaxUidNumber') + max_gid = info.get('msSFU30MaxGidNumber', None) + max_id = int(max(max_uid, max_gid)[0]) + + base_id = int(info.get('msSFU30OrderNumber')[0]) + range_size = (1 + (max_id - base_id) // DEFAULT_RANGE_SIZE)\ + * DEFAULT_RANGE_SIZE + + # Second, options given via the CLI options take precedence to discovery + if options.get('range_type', None): + range_type = options.get('range_type', None) + elif not range_type: + range_type = u'ipa-ad-trust' + + if options.get('range_size', None): + range_size = options.get('range_size', None) + elif not range_size: + range_size = DEFAULT_RANGE_SIZE + + if options.get('base_id', None): + base_id = options.get('base_id', None) + elif not base_id: + # Generate random base_id if not discovered nor given via CLI + base_id = DEFAULT_RANGE_SIZE + ( + pysss_murmur.murmurhash3( + dom_sid, + len(dom_sid), 0xdeadbeef + ) % 10000 + ) * DEFAULT_RANGE_SIZE + + # Finally, add new ID range + myapi.Command['idrange_add'](range_name, + ipabaseid=base_id, + ipaidrangesize=range_size, + ipabaserid=0, + iparangetype=range_type, + ipanttrusteddomainsid=dom_sid) + + # Return the values that were generated inside this function + return range_type, range_size, base_id + +def fetch_trusted_domains_over_dbus(myapi, log, forest_name): + if not _bindings_installed: + return + # Calling oddjobd-activated service via DBus has some quirks: + # - Oddjobd registers multiple canonical names on the same address + # - python-dbus only follows name owner changes when mainloop is in use + # See https://fedorahosted.org/oddjob/ticket/2 for details + dbus.mainloop.glib.DBusGMainLoop(set_as_default=True) + try: + _ret = 0 + _stdout = '' + _stderr = '' + bus = dbus.SystemBus() + intf = bus.get_object(DBUS_IFACE_TRUST,"/", follow_name_owner_changes=True) + fetch_domains_method = intf.get_dbus_method('fetch_domains', dbus_interface=DBUS_IFACE_TRUST) + (_ret, _stdout, _stderr) = fetch_domains_method(forest_name) + except dbus.DBusException as e: + log.error('Failed to call %(iface)s.fetch_domains helper.' + 'DBus exception is %(exc)s.' % dict(iface=DBUS_IFACE_TRUST, exc=str(e))) + if _ret != 0: + log.error('Helper was called for forest %(forest)s, return code is %(ret)d' % dict(forest=forest_name, ret=_ret)) + log.error('Standard output from the helper:\n%s---\n' % (_stdout)) + log.error('Error output from the helper:\n%s--\n' % (_stderr)) + raise errors.ServerCommandError(server=myapi.env.host, + error=_('Fetching domains from trusted forest failed. ' + 'See details in the error_log')) + return + +@register() +class trust(LDAPObject): + """ + Trust object. + """ + trust_types = ('ad', 'ipa') + container_dn = api.env.container_trusts + object_name = _('trust') + object_name_plural = _('trusts') + object_class = ['ipaNTTrustedDomain'] + default_attributes = ['cn', 'ipantflatname', 'ipanttrusteddomainsid', + 'ipanttrusttype', 'ipanttrustattributes', 'ipanttrustdirection', + 'ipanttrustpartner', 'ipanttrustforesttrustinfo', + 'ipanttrustposixoffset', 'ipantsupportedencryptiontypes' ] + search_display_attributes = ['cn', 'ipantflatname', + 'ipanttrusteddomainsid', 'ipanttrusttype'] + managed_permissions = { + 'System: Read Trust Information': { + # Allow reading of attributes needed for SSSD subdomains support + 'non_object': True, + 'ipapermlocation': DN(container_dn, api.env.basedn), + 'replaces_global_anonymous_aci': True, + 'ipapermbindruletype': 'all', + 'ipapermright': {'read', 'search', 'compare'}, + 'ipapermdefaultattr': { + 'cn', 'objectclass', + 'ipantflatname', 'ipantsecurityidentifier', + 'ipanttrusteddomainsid', 'ipanttrustpartner', + 'ipantsidblacklistincoming', 'ipantsidblacklistoutgoing', + 'ipanttrustdirection' + }, + }, + + 'System: Read system trust accounts': { + 'non_object': True, + 'ipapermlocation': DN(container_dn, api.env.basedn), + 'replaces_global_anonymous_aci': True, + 'ipapermright': {'read', 'search', 'compare'}, + 'ipapermdefaultattr': { + 'uidnumber', 'gidnumber', 'krbprincipalname' + }, + 'default_privileges': {'ADTrust Agents'}, + }, + } + + label = _('Trusts') + label_singular = _('Trust') + + takes_params = ( + Str('cn', + cli_name='realm', + label=_('Realm name'), + primary_key=True, + ), + Str('ipantflatname', + cli_name='flat_name', + label=_('Domain NetBIOS name'), + flags=['no_create', 'no_update']), + Str('ipanttrusteddomainsid', + cli_name='sid', + label=_('Domain Security Identifier'), + flags=['no_create', 'no_update']), + Str('ipantsidblacklistincoming*', + cli_name='sid_blacklist_incoming', + label=_('SID blacklist incoming'), + flags=['no_create']), + Str('ipantsidblacklistoutgoing*', + cli_name='sid_blacklist_outgoing', + label=_('SID blacklist outgoing'), + flags=['no_create']), + ) + + def validate_sid_blacklists(self, entry_attrs): + if not _bindings_installed: + # SID validator is not available, return + # Even if invalid SID gets in the trust entry, it won't crash + # the validation process as it is translated to SID S-0-0 + return + for attr in ('ipantsidblacklistincoming', 'ipantsidblacklistoutgoing'): + values = entry_attrs.get(attr) + if not values: + continue + for value in values: + if not ipaserver.dcerpc.is_sid_valid(value): + raise errors.ValidationError(name=attr, + error=_("invalid SID: %(value)s") % dict(value=value)) + + def get_dn(self, *keys, **kwargs): + trust_type = kwargs.get('trust_type') + + sdn = [('cn', x) for x in keys] + sdn.reverse() + + if trust_type is None: + ldap = self.backend + trustfilter = ldap.make_filter({ + 'objectclass': ['ipaNTTrustedDomain'], + 'cn': [keys[-1]]}, + rules=ldap.MATCH_ALL + ) + + # more type of objects can be located in subtree (for example + # cross-realm principals). we need this attr do detect trust + # entries + trustfilter = ldap.combine_filters( + (trustfilter, "ipaNTTrustPartner=*"), + rules=ldap.MATCH_ALL + ) + + try: + result = ldap.get_entries( + DN(self.container_dn, self.env.basedn), + ldap.SCOPE_SUBTREE, trustfilter, [''] + ) + except errors.NotFound: + self.handle_not_found(keys[-1]) + + if len(result) > 1: + raise errors.OnlyOneValueAllowed(attr='trust domain') + + return result[0].dn + + return make_trust_dn(self.env, trust_type, DN(*sdn)) + + def warning_if_ad_trust_dom_have_missing_SID(self, result, **options): + """Due bug https://fedorahosted.org/freeipa/ticket/5665 there might be + AD trust domain without generated SID, warn user about it. + """ + ldap = self.api.Backend.ldap2 + + try: + entries, truncated = ldap.find_entries( + base_dn=DN(self.api.env.container_adtrusts, + self.api.env.basedn), + scope=ldap.SCOPE_ONELEVEL, + attrs_list=['cn'], + filter='(&(ipaNTTrustPartner=*)' + '(!(ipaNTSecurityIdentifier=*)))', + ) + except errors.NotFound: + pass + else: + for entry in entries: + add_message( + options['version'], + result, + BrokenTrust(domain=entry.single_value['cn']) + ) + + +@register() +class trust_add(LDAPCreate): + __doc__ = _(''' +Add new trust to use. + +This command establishes trust relationship to another domain +which becomes 'trusted'. As result, users of the trusted domain +may access resources of this domain. + +Only trusts to Active Directory domains are supported right now. + +The command can be safely run multiple times against the same domain, +this will cause change to trust relationship credentials on both +sides. + ''') + + range_types = { + u'ipa-ad-trust': unicode(_('Active Directory domain range')), + u'ipa-ad-trust-posix': unicode(_('Active Directory trust range with ' + 'POSIX attributes')), + } + + takes_options = LDAPCreate.takes_options + ( + _trust_type_option, + Str('realm_admin?', + cli_name='admin', + label=_("Active Directory domain administrator"), + ), + Password('realm_passwd?', + cli_name='password', + label=_("Active Directory domain administrator's password"), + confirm=False, + ), + Str('realm_server?', + cli_name='server', + label=_('Domain controller for the Active Directory domain (optional)'), + ), + Password('trust_secret?', + cli_name='trust_secret', + label=_('Shared secret for the trust'), + confirm=False, + ), + Int('base_id?', + cli_name='base_id', + label=_('First Posix ID of the range reserved for the trusted domain'), + ), + Int('range_size?', + cli_name='range_size', + label=_('Size of the ID range reserved for the trusted domain'), + ), + StrEnum('range_type?', + label=_('Range type'), + cli_name='range_type', + doc=(_('Type of trusted domain ID range, one of {vals}' + .format(vals=', '.join(range_types.keys())))), + values=tuple(range_types.keys()), + ), + Bool('bidirectional?', + label=_('Two-way trust'), + cli_name='two_way', + doc=(_('Establish bi-directional trust. By default trust is inbound one-way only.')), + default=False, + ), + ) + + msg_summary = _('Added Active Directory trust for realm "%(value)s"') + msg_summary_existing = _('Re-established trust to domain "%(value)s"') + has_output_params = LDAPCreate.has_output_params + trust_output_params + + def execute(self, *keys, **options): + ldap = self.obj.backend + + verify_samba_component_presence(ldap, self.api) + + full_join = self.validate_options(*keys, **options) + old_range, range_name, dom_sid = self.validate_range(*keys, **options) + result = self.execute_ad(full_join, *keys, **options) + + if not old_range: + # Store the created range type, since for POSIX trusts no + # ranges for the subdomains should be added, POSIX attributes + # provide a global mapping across all subdomains + (created_range_type, _, _) = add_range(self.api, self.trustinstance, + range_name, dom_sid, + *keys, **options) + else: + created_range_type = old_range['result']['iparangetype'][0] + + trust_filter = "cn=%s" % result['value'] + (trusts, truncated) = ldap.find_entries( + base_dn=DN(self.api.env.container_trusts, self.api.env.basedn), + filter=trust_filter) + + result['result'] = entry_to_dict(trusts[0], **options) + + # Fetch topology of the trust forest -- we need always to do it + # for AD trusts, regardless of the type of idranges associated with it + # Note that add_new_domains_from_trust will add needed ranges for + # the algorithmic ID mapping case. + if (options.get('trust_type') == u'ad' and + options.get('trust_secret') is None): + if options.get('bidirectional') == True: + # Bidirectional trust allows us to use cross-realm TGT, so we can + # run the call under original user's credentials + res = fetch_domains_from_trust(self.api, self.trustinstance, + result['result'], **options) + domains = add_new_domains_from_trust(self.api, self.trustinstance, + result['result'], res, **options) + else: + # One-way trust is more complex. We don't have cross-realm TGT + # and cannot use IPA principals to authenticate against AD. + # Instead, we have to use our trusted domain object's (TDO) + # account in AD. Access to the credentials is limited and IPA + # framework cannot access it directly. Instead, we call out to + # oddjobd-activated higher privilege process that will use TDO + # object credentials to authenticate to AD with Kerberos, + # run DCE RPC calls to do discovery and will call + # add_new_domains_from_trust() on its own. + fetch_trusted_domains_over_dbus(self.api, self.log, result['value']) + + # Format the output into human-readable values + result['result']['trusttype'] = [trust_type_string( + result['result']['ipanttrusttype'][0])] + result['result']['trustdirection'] = [trust_direction_string( + result['result']['ipanttrustdirection'][0])] + result['result']['truststatus'] = [trust_status_string( + result['verified'])] + + del result['verified'] + result['result'].pop('ipanttrustauthoutgoing', None) + result['result'].pop('ipanttrustauthincoming', None) + + return result + + def validate_options(self, *keys, **options): + trusted_realm_domain = keys[-1] + + if not _murmur_installed and 'base_id' not in options: + raise errors.ValidationError( + name=_('missing base_id'), + error=_( + 'pysss_murmur is not available on the server ' + 'and no base-id is given.' + ) + ) + + if 'trust_type' not in options: + raise errors.RequirementError(name='trust_type') + + if options['trust_type'] != u'ad': + raise errors.ValidationError( + name=_('trust type'), + error=_('only "ad" is supported') + ) + + # Detect IPA-AD domain clash + if self.api.env.domain.lower() == trusted_realm_domain.lower(): + raise errors.ValidationError( + name=_('domain'), + error=_('Cannot establish a trust to AD deployed in the same ' + 'domain as IPA. Such setup is not supported.') + ) + + # If domain name and realm does not match, IPA server is not be able + # to establish trust with Active Directory. + + realm_not_matching_domain = (self.api.env.domain.upper() != self.api.env.realm) + + if options['trust_type'] == u'ad' and realm_not_matching_domain: + raise errors.ValidationError( + name=_('Realm-domain mismatch'), + error=_('To establish trust with Active Directory, the ' + 'domain name and the realm name of the IPA server ' + 'must match') + ) + + self.trustinstance = ipaserver.dcerpc.TrustDomainJoins(self.api) + if not self.trustinstance.configured: + raise errors.NotFound( + name=_('AD Trust setup'), + reason=_( + 'Cannot perform join operation without own domain ' + 'configured. Make sure you have run ipa-adtrust-install ' + 'on the IPA server first' + ) + ) + + # Obtain a list of IPA realm domains + result = self.api.Command.realmdomains_show()['result'] + realm_domains = result['associateddomain'] + + # Do not allow the AD's trusted realm domain in the list + # of our realm domains + if trusted_realm_domain.lower() in realm_domains: + raise errors.ValidationError( + name=_('AD Trust setup'), + error=_( + 'Trusted domain %(domain)s is included among ' + 'IPA realm domains. It needs to be removed ' + 'prior to establishing the trust. See the ' + '"ipa realmdomains-mod --del-domain" command.' + ) % dict(domain=trusted_realm_domain) + ) + + self.realm_server = options.get('realm_server') + self.realm_admin = options.get('realm_admin') + self.realm_passwd = options.get('realm_passwd') + + if self.realm_admin: + names = self.realm_admin.split('@') + + if len(names) > 1: + # realm admin name is in UPN format, user@realm, check that + # realm is the same as the one that we are attempting to trust + if trusted_realm_domain.lower() != names[-1].lower(): + raise errors.ValidationError( + name=_('AD Trust setup'), + error=_( + 'Trusted domain and administrator account use ' + 'different realms' + ) + ) + self.realm_admin = names[0] + + if not self.realm_passwd: + raise errors.ValidationError( + name=_('AD Trust setup'), + error=_('Realm administrator password should be specified') + ) + return True + + return False + + def validate_range(self, *keys, **options): + # If a range for this trusted domain already exists, + # '--base-id' or '--range-size' options should not be specified + range_name = keys[-1].upper() + '_id_range' + range_type = options.get('range_type') + + try: + old_range = self.api.Command['idrange_show'](range_name, raw=True) + except errors.NotFound: + old_range = None + + if options.get('trust_type') == u'ad': + if range_type and range_type not in (u'ipa-ad-trust', + u'ipa-ad-trust-posix'): + raise errors.ValidationError( + name=_('id range type'), + error=_( + 'Only the ipa-ad-trust and ipa-ad-trust-posix are ' + 'allowed values for --range-type when adding an AD ' + 'trust.' + )) + + base_id = options.get('base_id') + range_size = options.get('range_size') + + if old_range and (base_id or range_size): + raise errors.ValidationError( + name=_('id range'), + error=_( + 'An id range already exists for this trust. ' + 'You should either delete the old range, or ' + 'exclude --base-id/--range-size options from the command.' + ) + ) + + # If a range for this trusted domain already exists, + # domain SID must also match + self.trustinstance.populate_remote_domain( + keys[-1], + self.realm_server, + self.realm_admin, + self.realm_passwd + ) + dom_sid = self.trustinstance.remote_domain.info['sid'] + + if old_range: + old_dom_sid = old_range['result']['ipanttrusteddomainsid'][0] + old_range_type = old_range['result']['iparangetype'][0] + + if old_dom_sid != dom_sid: + raise errors.ValidationError( + name=_('range exists'), + error=_( + 'ID range with the same name but different domain SID ' + 'already exists. The ID range for the new trusted ' + 'domain must be created manually.' + ) + ) + + if range_type and range_type != old_range_type: + raise errors.ValidationError(name=_('range type change'), + error=_('ID range for the trusted domain already exists, ' + 'but it has a different type. Please remove the ' + 'old range manually, or do not enforce type ' + 'via --range-type option.')) + + return old_range, range_name, dom_sid + + def execute_ad(self, full_join, *keys, **options): + # Join domain using full credentials and with random trustdom + # secret (will be generated by the join method) + + # First see if the trust is already in place + # Force retrieval of the trust object by not passing trust_type + try: + dn = self.obj.get_dn(keys[-1]) + except errors.NotFound: + dn = None + + trust_type = TRUST_ONEWAY + if options.get('bidirectional', False): + trust_type = TRUST_BIDIRECTIONAL + # 1. Full access to the remote domain. Use admin credentials and + # generate random trustdom password to do work on both sides + if full_join: + try: + result = self.trustinstance.join_ad_full_credentials( + keys[-1], + self.realm_server, + self.realm_admin, + self.realm_passwd, + trust_type + ) + except errors.NotFound: + error_message=_("Unable to resolve domain controller for '%s' domain. ") % (keys[-1]) + instructions=[] + if dns_container_exists(self.obj.backend): + try: + dns_zone = self.api.Command.dnszone_show(keys[-1])['result'] + if ('idnsforwardpolicy' in dns_zone) and dns_zone['idnsforwardpolicy'][0] == u'only': + instructions.append(_("Forward policy is defined for it in IPA DNS, " + "perhaps forwarder points to incorrect host?")) + except (errors.NotFound, KeyError) as e: + instructions.append(_("IPA manages DNS, please verify " + "your DNS configuration and " + "make sure that service records " + "of the '%(domain)s' domain can " + "be resolved. Examples how to " + "configure DNS with CLI commands " + "or the Web UI can be found in " + "the documentation. " ) % + dict(domain=keys[-1])) + else: + instructions.append(_("Since IPA does not manage DNS records, ensure DNS " + "is configured to resolve '%(domain)s' domain from " + "IPA hosts and back.") % dict(domain=keys[-1])) + raise errors.NotFound(reason=error_message, instructions=instructions) + + if result is None: + raise errors.ValidationError(name=_('AD Trust setup'), + error=_('Unable to verify write permissions to the AD')) + + ret = dict( + value=pkey_to_value( + self.trustinstance.remote_domain.info['dns_domain'], + options), + verified=result['verified'] + ) + if dn: + ret['summary'] = self.msg_summary_existing % ret + return ret + + # 2. We don't have access to the remote domain and trustdom password + # is provided. Do the work on our side and inform what to do on remote + # side. + if options.get('trust_secret'): + result = self.trustinstance.join_ad_ipa_half( + keys[-1], + self.realm_server, + options['trust_secret'], + trust_type + ) + ret = dict( + value=pkey_to_value( + self.trustinstance.remote_domain.info['dns_domain'], + options), + verified=result['verified'] + ) + if dn: + ret['summary'] = self.msg_summary_existing % ret + return ret + else: + raise errors.ValidationError( + name=_('AD Trust setup'), + error=_('Not enough arguments specified to perform trust ' + 'setup')) + +@register() +class trust_del(LDAPDelete): + __doc__ = _('Delete a trust.') + + msg_summary = _('Deleted trust "%(value)s"') + +@register() +class trust_mod(LDAPUpdate): + __doc__ = _(""" + Modify a trust (for future use). + + Currently only the default option to modify the LDAP attributes is + available. More specific options will be added in coming releases. + """) + + msg_summary = _('Modified trust "%(value)s" ' + '(change will be effective in 60 seconds)') + + def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options): + assert isinstance(dn, DN) + + self.obj.validate_sid_blacklists(entry_attrs) + + return dn + +@register() +class trust_find(LDAPSearch): + __doc__ = _('Search for trusts.') + has_output_params = LDAPSearch.has_output_params + trust_output_params +\ + (Str('ipanttrusttype'),) + + msg_summary = ngettext( + '%(count)d trust matched', '%(count)d trusts matched', 0 + ) + + # Since all trusts types are stored within separate containers under 'cn=trusts', + # search needs to be done on a sub-tree scope + def pre_callback(self, ldap, filters, attrs_list, base_dn, scope, *args, **options): + # list only trust, not trust domains + trust_filter = '(ipaNTTrustPartner=*)' + filter = ldap.combine_filters((filters, trust_filter), rules=ldap.MATCH_ALL) + return (filter, base_dn, ldap.SCOPE_SUBTREE) + + def execute(self, *args, **options): + result = super(trust_find, self).execute(*args, **options) + + self.obj.warning_if_ad_trust_dom_have_missing_SID(result, **options) + + return result + + def post_callback(self, ldap, entries, truncated, *args, **options): + if options.get('pkey_only', False): + return truncated + + for attrs in entries: + # Translate ipanttrusttype to trusttype if --raw not used + trust_type = attrs.get('ipanttrusttype', [None])[0] + if not options.get('raw', False) and trust_type is not None: + attrs['trusttype'] = trust_type_string(attrs['ipanttrusttype'][0]) + del attrs['ipanttrusttype'] + + return truncated + +@register() +class trust_show(LDAPRetrieve): + __doc__ = _('Display information about a trust.') + has_output_params = LDAPRetrieve.has_output_params + trust_output_params +\ + (Str('ipanttrusttype'), Str('ipanttrustdirection')) + + def execute(self, *keys, **options): + result = super(trust_show, self).execute(*keys, **options) + + self.obj.warning_if_ad_trust_dom_have_missing_SID(result, **options) + + return result + + def post_callback(self, ldap, dn, entry_attrs, *keys, **options): + + assert isinstance(dn, DN) + # Translate ipanttrusttype to trusttype + # and ipanttrustdirection to trustdirection + # if --raw not used + + if not options.get('raw', False): + trust_type = entry_attrs.get('ipanttrusttype', [None])[0] + if trust_type is not None: + entry_attrs['trusttype'] = trust_type_string(trust_type) + del entry_attrs['ipanttrusttype'] + + dir_str = entry_attrs.get('ipanttrustdirection', [None])[0] + if dir_str is not None: + entry_attrs['trustdirection'] = [trust_direction_string(dir_str)] + del entry_attrs['ipanttrustdirection'] + + return dn + + +_trustconfig_dn = { + u'ad': DN(('cn', api.env.domain), api.env.container_cifsdomains, api.env.basedn), +} + + +@register() +class trustconfig(LDAPObject): + """ + Trusts global configuration object + """ + object_name = _('trust configuration') + default_attributes = [ + 'cn', 'ipantsecurityidentifier', 'ipantflatname', 'ipantdomainguid', + 'ipantfallbackprimarygroup', + ] + + label = _('Global Trust Configuration') + label_singular = _('Global Trust Configuration') + + takes_params = ( + Str('cn', + label=_('Domain'), + flags=['no_update'], + ), + Str('ipantsecurityidentifier', + label=_('Security Identifier'), + flags=['no_update'], + ), + Str('ipantflatname', + label=_('NetBIOS name'), + flags=['no_update'], + ), + Str('ipantdomainguid', + label=_('Domain GUID'), + flags=['no_update'], + ), + Str('ipantfallbackprimarygroup', + cli_name='fallback_primary_group', + label=_('Fallback primary group'), + ), + ) + + def get_dn(self, *keys, **kwargs): + trust_type = kwargs.get('trust_type') + if trust_type is None: + raise errors.RequirementError(name='trust_type') + try: + return _trustconfig_dn[kwargs['trust_type']] + except KeyError: + raise errors.ValidationError(name='trust_type', + error=_("unsupported trust type")) + + def _normalize_groupdn(self, entry_attrs): + """ + Checks that group with given name/DN exists and updates the entry_attrs + """ + if 'ipantfallbackprimarygroup' not in entry_attrs: + return + + group = entry_attrs['ipantfallbackprimarygroup'] + if isinstance(group, (list, tuple)): + group = group[0] + + if group is None: + return + + try: + dn = DN(group) + # group is in a form of a DN + try: + self.backend.get_entry(dn) + except errors.NotFound: + self.api.Object['group'].handle_not_found(group) + # DN is valid, we can just return + return + except ValueError: + # The search is performed for groups with "posixgroup" objectclass + # and not "ipausergroup" so that it can also match groups like + # "Default SMB Group" which does not have this objectclass. + try: + group_entry = self.backend.find_entry_by_attr( + self.api.Object['group'].primary_key.name, + group, + ['posixgroup'], + [''], + DN(self.api.env.container_group, self.api.env.basedn)) + except errors.NotFound: + self.api.Object['group'].handle_not_found(group) + else: + entry_attrs['ipantfallbackprimarygroup'] = [group_entry.dn] + + def _convert_groupdn(self, entry_attrs, options): + """ + Convert an group dn into a name. As we use CN as user RDN, its value + can be extracted from the DN without further LDAP queries. + """ + if options.get('raw', False): + return + + try: + groupdn = entry_attrs['ipantfallbackprimarygroup'][0] + except (IndexError, KeyError): + groupdn = None + + if groupdn is None: + return + assert isinstance(groupdn, DN) + + entry_attrs['ipantfallbackprimarygroup'] = [groupdn[0][0].value] + + +@register() +class trustconfig_mod(LDAPUpdate): + __doc__ = _('Modify global trust configuration.') + + takes_options = LDAPUpdate.takes_options + (_trust_type_option,) + msg_summary = _('Modified "%(value)s" trust configuration') + + def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options): + self.obj._normalize_groupdn(entry_attrs) + return dn + + def execute(self, *keys, **options): + result = super(trustconfig_mod, self).execute(*keys, **options) + result['value'] = pkey_to_value(options['trust_type'], options) + return result + + def post_callback(self, ldap, dn, entry_attrs, *keys, **options): + self.obj._convert_groupdn(entry_attrs, options) + return dn + + + +@register() +class trustconfig_show(LDAPRetrieve): + __doc__ = _('Show global trust configuration.') + + takes_options = LDAPRetrieve.takes_options + (_trust_type_option,) + + def execute(self, *keys, **options): + result = super(trustconfig_show, self).execute(*keys, **options) + result['value'] = pkey_to_value(options['trust_type'], options) + return result + + def post_callback(self, ldap, dn, entry_attrs, *keys, **options): + self.obj._convert_groupdn(entry_attrs, options) + return dn + + +if _nss_idmap_installed: + _idmap_type_dict = { + pysss_nss_idmap.ID_USER : 'user', + pysss_nss_idmap.ID_GROUP : 'group', + pysss_nss_idmap.ID_BOTH : 'both', + } + def idmap_type_string(level): + string = _idmap_type_dict.get(int(level), 'unknown') + return unicode(string) + +@register() +class trust_resolve(Command): + NO_CLI = True + __doc__ = _('Resolve security identifiers of users and groups in trusted domains') + + takes_options = ( + Str('sids+', + label = _('Security Identifiers (SIDs)'), + ), + ) + + has_output_params = ( + Str('name', label= _('Name')), + Str('sid', label= _('SID')), + ) + + has_output = ( + output.ListOfEntries('result'), + ) + + def execute(self, *keys, **options): + result = list() + if not _nss_idmap_installed: + return dict(result=result) + try: + sids = [str(x) for x in options['sids']] + xlate = pysss_nss_idmap.getnamebysid(sids) + for sid in xlate: + entry = dict() + entry['sid'] = [unicode(sid)] + entry['name'] = [unicode(xlate[sid][pysss_nss_idmap.NAME_KEY])] + entry['type'] = [idmap_type_string(xlate[sid][pysss_nss_idmap.TYPE_KEY])] + result.append(entry) + except ValueError as e: + pass + + return dict(result=result) + + + +@register() +class adtrust_is_enabled(Command): + NO_CLI = True + + __doc__ = _('Determine whether ipa-adtrust-install has been run on this ' + 'system') + + def execute(self, *keys, **options): + ldap = self.api.Backend.ldap2 + adtrust_dn = DN( + ('cn', 'ADTRUST'), + ('cn', self.api.env.host), + ('cn', 'masters'), + ('cn', 'ipa'), + ('cn', 'etc'), + self.api.env.basedn + ) + + try: + ldap.get_entry(adtrust_dn) + except errors.NotFound: + return dict(result=False) + + return dict(result=True) + + + +@register() +class compat_is_enabled(Command): + NO_CLI = True + + __doc__ = _('Determine whether Schema Compatibility plugin is configured ' + 'to serve trusted domain users and groups') + + def execute(self, *keys, **options): + ldap = self.api.Backend.ldap2 + users_dn = DN( + ('cn', 'users'), + ('cn', 'Schema Compatibility'), + ('cn', 'plugins'), + ('cn', 'config') + ) + groups_dn = DN( + ('cn', 'groups'), + ('cn', 'Schema Compatibility'), + ('cn', 'plugins'), + ('cn', 'config') + ) + + try: + users_entry = ldap.get_entry(users_dn) + except errors.NotFound: + return dict(result=False) + + attr = users_entry.get('schema-compat-lookup-nsswitch') + if not attr or 'user' not in attr: + return dict(result=False) + + try: + groups_entry = ldap.get_entry(groups_dn) + except errors.NotFound: + return dict(result=False) + + attr = groups_entry.get('schema-compat-lookup-nsswitch') + if not attr or 'group' not in attr: + return dict(result=False) + + return dict(result=True) + + + +@register() +class sidgen_was_run(Command): + """ + This command tries to determine whether the sidgen task was run during + ipa-adtrust-install. It does that by simply checking the "editors" group + for the presence of the ipaNTSecurityIdentifier attribute - if the + attribute is present, the sidgen task was run. + + Since this command relies on the existence of the "editors" group, it will + fail loudly in case this group does not exist. + """ + NO_CLI = True + + __doc__ = _('Determine whether ipa-adtrust-install has been run with ' + 'sidgen task') + + def execute(self, *keys, **options): + ldap = self.api.Backend.ldap2 + editors_dn = DN( + ('cn', 'editors'), + ('cn', 'groups'), + ('cn', 'accounts'), + api.env.basedn + ) + + try: + editors_entry = ldap.get_entry(editors_dn) + except errors.NotFound: + raise errors.NotFound( + name=_('sidgen_was_run'), + reason=_( + 'This command relies on the existence of the "editors" ' + 'group, but this group was not found.' + ) + ) + + attr = editors_entry.get('ipaNTSecurityIdentifier') + if not attr: + return dict(result=False) + + return dict(result=True) + + +@register() +class trustdomain(LDAPObject): + """ + Object representing a domain of the AD trust. + """ + parent_object = 'trust' + trust_type_idx = {'2':u'ad'} + object_name = _('trust domain') + object_name_plural = _('trust domains') + object_class = ['ipaNTTrustedDomain'] + default_attributes = ['cn', 'ipantflatname', 'ipanttrusteddomainsid', 'ipanttrustpartner'] + search_display_attributes = ['cn', 'ipantflatname', 'ipanttrusteddomainsid', ] + + label = _('Trusted domains') + label_singular = _('Trusted domain') + + takes_params = ( + Str('cn', + label=_('Domain name'), + cli_name='domain', + primary_key=True + ), + Str('ipantflatname?', + cli_name='flat_name', + label=_('Domain NetBIOS name'), + ), + Str('ipanttrusteddomainsid?', + cli_name='sid', + label=_('Domain Security Identifier'), + ), + Str('ipanttrustpartner?', + label=_('Trusted domain partner'), + flags=['no_display', 'no_option'], + ), + ) + + # LDAPObject.get_dn() only passes all but last element of keys and no kwargs + # to the parent object's get_dn() no matter what you pass to it. Make own get_dn() + # as we really need all elements to construct proper dn. + def get_dn(self, *keys, **kwargs): + sdn = [('cn', x) for x in keys] + sdn.reverse() + trust_type = kwargs.get('trust_type') + if not trust_type: + trust_type=u'ad' + + dn=make_trust_dn(self.env, trust_type, DN(*sdn)) + return dn + +@register() +class trustdomain_find(LDAPSearch): + __doc__ = _('Search domains of the trust') + + has_output_params = LDAPSearch.has_output_params + ( + Flag('domain_enabled', label= _('Domain enabled')), + ) + def pre_callback(self, ldap, filters, attrs_list, base_dn, scope, *args, **options): + return (filters, base_dn, ldap.SCOPE_SUBTREE) + + def post_callback(self, ldap, entries, truncated, *args, **options): + if options.get('pkey_only', False): + return truncated + trust_dn = self.obj.get_dn(args[0], trust_type=u'ad') + trust_entry = ldap.get_entry(trust_dn) + for entry in entries: + sid = entry['ipanttrusteddomainsid'][0] + + blacklist = trust_entry.get('ipantsidblacklistincoming') + if blacklist is None: + continue + + if sid in blacklist: + entry['domain_enabled'] = [False] + else: + entry['domain_enabled'] = [True] + return truncated + + + +@register() +class trustdomain_mod(LDAPUpdate): + __doc__ = _('Modify trustdomain of the trust') + + NO_CLI = True + takes_options = LDAPUpdate.takes_options + (_trust_type_option,) + +@register() +class trustdomain_add(LDAPCreate): + __doc__ = _('Allow access from the trusted domain') + NO_CLI = True + + takes_options = LDAPCreate.takes_options + (_trust_type_option,) + def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options): + if 'ipanttrustpartner' in options: + entry_attrs['ipanttrustpartner'] = [options['ipanttrustpartner']] + return dn + +@register() +class trustdomain_del(LDAPDelete): + __doc__ = _('Remove infromation about the domain associated with the trust.') + + msg_summary = _('Removed information about the trusted domain "%(value)s"') + + def execute(self, *keys, **options): + ldap = self.api.Backend.ldap2 + verify_samba_component_presence(ldap, self.api) + + # Note that pre-/post- callback handling for LDAPDelete is causing pre_callback + # to always receive empty keys. We need to catch the case when root domain is being deleted + + for domain in keys[1]: + # Fetch the trust to verify that the entered domain is trusted + self.api.Command.trust_show(domain) + + if keys[0].lower() == domain: + raise errors.ValidationError(name='domain', + error=_("cannot delete root domain of the trust, " + "use trust-del to delete the trust itself")) + try: + res = self.api.Command.trustdomain_enable(keys[0], domain) + except errors.AlreadyActive: + pass + + result = super(trustdomain_del, self).execute(*keys, **options) + result['value'] = pkey_to_value(keys[1], options) + return result + + +def fetch_domains_from_trust(myapi, trustinstance, trust_entry, **options): + trust_name = trust_entry['cn'][0] + # We want to use Kerberos if we have admin credentials even with SMB calls + # as eventually use of NTLMSSP will be deprecated for trusted domain operations + # If admin credentials are missing, 'creds' will be None and fetch_domains + # will use HTTP/ipa.master@IPA.REALM principal, e.g. Kerberos authentication + # as well. + creds = generate_creds(trustinstance, style=CRED_STYLE_KERBEROS, **options) + server = options.get('realm_server', None) + domains = ipaserver.dcerpc.fetch_domains(myapi, + trustinstance.local_flatname, + trust_name, creds=creds, server=server) + return domains + +def add_new_domains_from_trust(myapi, trustinstance, trust_entry, domains, **options): + result = [] + if not domains: + return result + + trust_name = trust_entry['cn'][0] + # trust range must exist by the time add_new_domains_from_trust is called + range_name = trust_name.upper() + '_id_range' + old_range = myapi.Command.idrange_show(range_name, raw=True)['result'] + idrange_type = old_range['iparangetype'][0] + + for dom in domains: + dom['trust_type'] = u'ad' + try: + name = dom['cn'] + del dom['cn'] + if 'all' in options: + dom['all'] = options['all'] + if 'raw' in options: + dom['raw'] = options['raw'] + + res = myapi.Command.trustdomain_add(trust_name, name, **dom) + result.append(res['result']) + + if idrange_type != u'ipa-ad-trust-posix': + range_name = name.upper() + '_id_range' + dom['range_type'] = u'ipa-ad-trust' + add_range(myapi, trustinstance, range_name, dom['ipanttrusteddomainsid'], + trust_name, name, **dom) + except errors.DuplicateEntry: + # Ignore updating duplicate entries + pass + return result + +@register() +class trust_fetch_domains(LDAPRetrieve): + __doc__ = _('Refresh list of the domains associated with the trust') + + has_output = output.standard_list_of_entries + takes_options = LDAPRetrieve.takes_options + ( + Str('realm_server?', + cli_name='server', + label=_('Domain controller for the Active Directory domain (optional)'), + ), + ) + + def execute(self, *keys, **options): + ldap = self.api.Backend.ldap2 + verify_samba_component_presence(ldap, self.api) + + trust = self.api.Command.trust_show(keys[0], raw=True)['result'] + + result = dict() + result['result'] = [] + result['count'] = 0 + result['truncated'] = False + + # For one-way trust fetch over DBus. we don't get the list in this case. + if int(trust['ipanttrustdirection'][0]) != TRUST_BIDIRECTIONAL: + fetch_trusted_domains_over_dbus(self.api, self.log, keys[0]) + result['summary'] = unicode(_('List of trust domains successfully refreshed. Use trustdomain-find command to list them.')) + return result + + trustinstance = ipaserver.dcerpc.TrustDomainJoins(self.api) + if not trustinstance.configured: + raise errors.NotFound( + name=_('AD Trust setup'), + reason=_( + 'Cannot perform join operation without own domain ' + 'configured. Make sure you have run ipa-adtrust-install ' + 'on the IPA server first' + ) + ) + res = fetch_domains_from_trust(self.api, trustinstance, trust, **options) + domains = add_new_domains_from_trust(self.api, trustinstance, trust, res, **options) + + if len(domains) > 0: + result['summary'] = unicode(_('List of trust domains successfully refreshed')) + else: + result['summary'] = unicode(_('No new trust domains were found')) + + result['result'] = domains + result['count'] = len(domains) + return result + + +@register() +class trustdomain_enable(LDAPQuery): + __doc__ = _('Allow use of IPA resources by the domain of the trust') + + has_output = output.standard_value + msg_summary = _('Enabled trust domain "%(value)s"') + + def execute(self, *keys, **options): + ldap = self.api.Backend.ldap2 + verify_samba_component_presence(ldap, self.api) + + if keys[0].lower() == keys[1].lower(): + raise errors.ValidationError(name='domain', + error=_("Root domain of the trust is always enabled for the existing trust")) + try: + trust_dn = self.obj.get_dn(keys[0], trust_type=u'ad') + trust_entry = ldap.get_entry(trust_dn) + except errors.NotFound: + self.api.Object[self.obj.parent_object].handle_not_found(keys[0]) + + dn = self.obj.get_dn(keys[0], keys[1], trust_type=u'ad') + try: + entry = ldap.get_entry(dn) + sid = entry['ipanttrusteddomainsid'][0] + if sid in trust_entry['ipantsidblacklistincoming']: + trust_entry['ipantsidblacklistincoming'].remove(sid) + ldap.update_entry(trust_entry) + # Force MS-PAC cache re-initialization on KDC side + domval = ipaserver.dcerpc.DomainValidator(self.api) + (ccache_name, principal) = domval.kinit_as_http(keys[0]) + else: + raise errors.AlreadyActive() + except errors.NotFound: + self.obj.handle_not_found(*keys) + + return dict( + result=True, + value=pkey_to_value(keys[1], options), + ) + + +@register() +class trustdomain_disable(LDAPQuery): + __doc__ = _('Disable use of IPA resources by the domain of the trust') + + has_output = output.standard_value + msg_summary = _('Disabled trust domain "%(value)s"') + + def execute(self, *keys, **options): + ldap = self.api.Backend.ldap2 + verify_samba_component_presence(ldap, self.api) + + if keys[0].lower() == keys[1].lower(): + raise errors.ValidationError(name='domain', + error=_("cannot disable root domain of the trust, use trust-del to delete the trust itself")) + try: + trust_dn = self.obj.get_dn(keys[0], trust_type=u'ad') + trust_entry = ldap.get_entry(trust_dn) + except errors.NotFound: + self.api.Object[self.obj.parent_object].handle_not_found(keys[0]) + + dn = self.obj.get_dn(keys[0], keys[1], trust_type=u'ad') + try: + entry = ldap.get_entry(dn) + sid = entry['ipanttrusteddomainsid'][0] + if not (sid in trust_entry['ipantsidblacklistincoming']): + trust_entry['ipantsidblacklistincoming'].append(sid) + ldap.update_entry(trust_entry) + # Force MS-PAC cache re-initialization on KDC side + domval = ipaserver.dcerpc.DomainValidator(self.api) + (ccache_name, principal) = domval.kinit_as_http(keys[0]) + else: + raise errors.AlreadyInactive() + except errors.NotFound: + self.obj.handle_not_found(*keys) + + return dict( + result=True, + value=pkey_to_value(keys[1], options), + ) + diff --git a/ipaserver/plugins/user.py b/ipaserver/plugins/user.py new file mode 100644 index 000000000..adc59fcba --- /dev/null +++ b/ipaserver/plugins/user.py @@ -0,0 +1,1151 @@ +# Authors: +# Jason Gerard DeRose <jderose@redhat.com> +# Pavel Zuna <pzuna@redhat.com> +# +# Copyright (C) 2008 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +import time +from time import gmtime, strftime +import posixpath +import os + +import six + +from ipalib import api +from ipalib import errors +from ipalib import Bool, Flag, Str +from .baseuser import ( + baseuser, + baseuser_add, + baseuser_del, + baseuser_mod, + baseuser_find, + baseuser_show, + NO_UPG_MAGIC, + UPG_DEFINITION_DN, + baseuser_output_params, + status_baseuser_output_params, + baseuser_pwdchars, + validate_nsaccountlock, + convert_nsaccountlock, + fix_addressbook_permission_bindrule, + baseuser_add_manager, + baseuser_remove_manager) +from .idviews import remove_ipaobject_overrides +from ipalib.plugable import Registry +from .baseldap import ( + pkey_to_value, + LDAPCreate, + LDAPSearch, + LDAPQuery, + LDAPMultiQuery, + LDAPAddAttribute, + LDAPRemoveAttribute) +from . import baseldap +from ipalib.request import context +from ipalib import _, ngettext +from ipalib import output +from ipaplatform.paths import paths +from ipapython.dn import DN +from ipapython.ipautil import ipa_generate_password +from ipalib.capabilities import client_has_capability + +if api.env.in_server: + from ipaserver.plugins.ldap2 import ldap2 + +if six.PY3: + unicode = str + +__doc__ = _(""" +Users + +Manage user entries. All users are POSIX users. + +IPA supports a wide range of username formats, but you need to be aware of any +restrictions that may apply to your particular environment. For example, +usernames that start with a digit or usernames that exceed a certain length +may cause problems for some UNIX systems. +Use 'ipa config-mod' to change the username format allowed by IPA tools. + +Disabling a user account prevents that user from obtaining new Kerberos +credentials. It does not invalidate any credentials that have already +been issued. + +Password management is not a part of this module. For more information +about this topic please see: ipa help passwd + +Account lockout on password failure happens per IPA master. The user-status +command can be used to identify which master the user is locked out on. +It is on that master the administrator must unlock the user. + +EXAMPLES: + + Add a new user: + ipa user-add --first=Tim --last=User --password tuser1 + + Find all users whose entries include the string "Tim": + ipa user-find Tim + + Find all users with "Tim" as the first name: + ipa user-find --first=Tim + + Disable a user account: + ipa user-disable tuser1 + + Enable a user account: + ipa user-enable tuser1 + + Delete a user: + ipa user-del tuser1 +""") + +register = Registry() + + +user_output_params = baseuser_output_params + +status_output_params = status_baseuser_output_params + + +def check_protected_member(user, protected_group_name=u'admins'): + ''' + Ensure the last enabled member of a protected group cannot be deleted or + disabled by raising LastMemberError. + ''' + + # Get all users in the protected group + result = api.Command.user_find(in_group=protected_group_name) + + # Build list of users in the protected group who are enabled + result = result['result'] + enabled_users = [entry['uid'][0] for entry in result if not entry['nsaccountlock']] + + # If the user is the last enabled user raise LastMemberError exception + if enabled_users == [user]: + raise errors.LastMemberError(key=user, label=_(u'group'), + container=protected_group_name) + +@register() +class user(baseuser): + """ + User object. + """ + + container_dn = baseuser.active_container_dn + label = _('Users') + label_singular = _('User') + object_name = _('user') + object_name_plural = _('users') + managed_permissions = { + 'System: Read User Standard Attributes': { + 'replaces_global_anonymous_aci': True, + 'ipapermbindruletype': 'anonymous', + 'ipapermright': {'read', 'search', 'compare'}, + 'ipapermdefaultattr': { + 'objectclass', 'cn', 'sn', 'description', 'title', 'uid', + 'displayname', 'givenname', 'initials', 'manager', 'gecos', + 'gidnumber', 'homedirectory', 'loginshell', 'uidnumber', + 'ipantsecurityidentifier' + }, + }, + 'System: Read User Addressbook Attributes': { + 'replaces_global_anonymous_aci': True, + 'ipapermbindruletype': 'all', + 'ipapermright': {'read', 'search', 'compare'}, + 'ipapermdefaultattr': { + 'seealso', 'telephonenumber', + 'facsimiletelephonenumber', 'l', 'ou', 'st', 'postalcode', 'street', + 'destinationindicator', 'internationalisdnnumber', + 'physicaldeliveryofficename', 'postaladdress', 'postofficebox', + 'preferreddeliverymethod', 'registeredaddress', + 'teletexterminalidentifier', 'telexnumber', 'x121address', + 'carlicense', 'departmentnumber', 'employeenumber', + 'employeetype', 'preferredlanguage', 'mail', 'mobile', 'pager', + 'audio', 'businesscategory', 'homephone', 'homepostaladdress', + 'jpegphoto', 'labeleduri', 'o', 'photo', 'roomnumber', + 'secretary', 'usercertificate', + 'usersmimecertificate', 'x500uniqueidentifier', + 'inetuserhttpurl', 'inetuserstatus', + }, + 'fixup_function': fix_addressbook_permission_bindrule, + }, + 'System: Read User IPA Attributes': { + 'replaces_global_anonymous_aci': True, + 'ipapermbindruletype': 'all', + 'ipapermright': {'read', 'search', 'compare'}, + 'ipapermdefaultattr': { + 'ipauniqueid', 'ipasshpubkey', 'ipauserauthtype', 'userclass', + }, + 'fixup_function': fix_addressbook_permission_bindrule, + }, + 'System: Read User Kerberos Attributes': { + 'replaces_global_anonymous_aci': True, + 'ipapermbindruletype': 'all', + 'ipapermright': {'read', 'search', 'compare'}, + 'ipapermdefaultattr': { + 'krbprincipalname', 'krbcanonicalname', 'krbprincipalaliases', + 'krbprincipalexpiration', 'krbpasswordexpiration', + 'krblastpwdchange', 'nsaccountlock', 'krbprincipaltype', + }, + }, + 'System: Read User Kerberos Login Attributes': { + 'replaces_global_anonymous_aci': True, + 'ipapermright': {'read', 'search', 'compare'}, + 'ipapermdefaultattr': { + 'krblastsuccessfulauth', 'krblastfailedauth', + 'krblastpwdchange', 'krblastadminunlock', + 'krbloginfailedcount', 'krbpwdpolicyreference', + 'krbticketpolicyreference', 'krbupenabled', + }, + 'default_privileges': {'User Administrators'}, + }, + 'System: Read User Membership': { + 'replaces_global_anonymous_aci': True, + 'ipapermbindruletype': 'all', + 'ipapermright': {'read', 'search', 'compare'}, + 'ipapermdefaultattr': { + 'memberof', + }, + }, + 'System: Read UPG Definition': { + # Required for adding users + 'replaces_global_anonymous_aci': True, + 'non_object': True, + 'ipapermlocation': UPG_DEFINITION_DN, + 'ipapermtarget': UPG_DEFINITION_DN, + 'ipapermright': {'read', 'search', 'compare'}, + 'ipapermdefaultattr': {'*'}, + 'default_privileges': {'User Administrators'}, + }, + 'System: Add Users': { + 'ipapermright': {'add'}, + 'replaces': [ + '(target = "ldap:///uid=*,cn=users,cn=accounts,$SUFFIX")(version 3.0;acl "permission:Add Users";allow (add) groupdn = "ldap:///cn=Add Users,cn=permissions,cn=pbac,$SUFFIX";)', + ], + 'default_privileges': {'User Administrators'}, + }, + 'System: Add User to default group': { + 'non_object': True, + 'ipapermright': {'write'}, + 'ipapermlocation': DN(api.env.container_group, api.env.basedn), + 'ipapermtarget': DN('cn=ipausers', api.env.container_group, + api.env.basedn), + 'ipapermdefaultattr': {'member'}, + 'replaces': [ + '(targetattr = "member")(target = "ldap:///cn=ipausers,cn=groups,cn=accounts,$SUFFIX")(version 3.0;acl "permission:Add user to default group";allow (write) groupdn = "ldap:///cn=Add user to default group,cn=permissions,cn=pbac,$SUFFIX";)', + ], + 'default_privileges': {'User Administrators'}, + }, + 'System: Change User password': { + 'ipapermright': {'write'}, + 'ipapermtargetfilter': [ + '(objectclass=posixaccount)', + '(!(memberOf=%s))' % DN('cn=admins', + api.env.container_group, + api.env.basedn), + ], + 'ipapermdefaultattr': { + 'krbprincipalkey', 'passwordhistory', 'sambalmpassword', + 'sambantpassword', 'userpassword' + }, + 'replaces': [ + '(target = "ldap:///uid=*,cn=users,cn=accounts,$SUFFIX")(targetattr = "userpassword || krbprincipalkey || sambalmpassword || sambantpassword || passwordhistory")(version 3.0;acl "permission:Change a user password";allow (write) groupdn = "ldap:///cn=Change a user password,cn=permissions,cn=pbac,$SUFFIX";)', + '(targetfilter = "(!(memberOf=cn=admins,cn=groups,cn=accounts,$SUFFIX))")(target = "ldap:///uid=*,cn=users,cn=accounts,$SUFFIX")(targetattr = "userpassword || krbprincipalkey || sambalmpassword || sambantpassword || passwordhistory")(version 3.0;acl "permission:Change a user password";allow (write) groupdn = "ldap:///cn=Change a user password,cn=permissions,cn=pbac,$SUFFIX";)', + '(targetattr = "userPassword || krbPrincipalKey || sambaLMPassword || sambaNTPassword || passwordHistory")(version 3.0; acl "Windows PassSync service can write passwords"; allow (write) userdn="ldap:///uid=passsync,cn=sysaccounts,cn=etc,$SUFFIX";)', + ], + 'default_privileges': { + 'User Administrators', + 'Modify Users and Reset passwords', + 'PassSync Service', + }, + }, + 'System: Manage User SSH Public Keys': { + 'ipapermright': {'write'}, + 'ipapermdefaultattr': {'ipasshpubkey'}, + 'replaces': [ + '(targetattr = "ipasshpubkey")(target = "ldap:///uid=*,cn=users,cn=accounts,$SUFFIX")(version 3.0;acl "permission:Manage User SSH Public Keys";allow (write) groupdn = "ldap:///cn=Manage User SSH Public Keys,cn=permissions,cn=pbac,$SUFFIX";)', + ], + 'default_privileges': {'User Administrators'}, + }, + 'System: Manage User Certificates': { + 'ipapermright': {'write'}, + 'ipapermdefaultattr': {'usercertificate'}, + 'default_privileges': { + 'User Administrators', + 'Modify Users and Reset passwords', + }, + }, + 'System: Modify Users': { + 'ipapermright': {'write'}, + 'ipapermdefaultattr': { + 'businesscategory', 'carlicense', 'cn', 'departmentnumber', + 'description', 'displayname', 'employeetype', + 'employeenumber', 'facsimiletelephonenumber', + 'gecos', 'givenname', 'homephone', 'inetuserhttpurl', + 'initials', 'l', 'labeleduri', 'loginshell', 'manager', 'mail', + 'mepmanagedentry', 'mobile', 'objectclass', 'ou', 'pager', + 'postalcode', 'roomnumber', 'secretary', 'seealso', 'sn', 'st', + 'street', 'telephonenumber', 'title', 'userclass', + 'preferredlanguage', + }, + 'replaces': [ + '(targetattr = "givenname || sn || cn || displayname || title || initials || loginshell || gecos || homephone || mobile || pager || facsimiletelephonenumber || telephonenumber || street || roomnumber || l || st || postalcode || manager || secretary || description || carlicense || labeleduri || inetuserhttpurl || seealso || employeetype || businesscategory || ou || mepmanagedentry || objectclass")(target = "ldap:///uid=*,cn=users,cn=accounts,$SUFFIX")(version 3.0;acl "permission:Modify Users";allow (write) groupdn = "ldap:///cn=Modify Users,cn=permissions,cn=pbac,$SUFFIX";)', + ], + 'default_privileges': { + 'User Administrators', + 'Modify Users and Reset passwords', + }, + }, + 'System: Remove Users': { + 'ipapermright': {'delete'}, + 'replaces': [ + '(target = "ldap:///uid=*,cn=users,cn=accounts,$SUFFIX")(version 3.0;acl "permission:Remove Users";allow (delete) groupdn = "ldap:///cn=Remove Users,cn=permissions,cn=pbac,$SUFFIX";)', + ], + 'default_privileges': {'User Administrators'}, + }, + 'System: Unlock User': { + 'ipapermright': {'write'}, + 'ipapermdefaultattr': { + 'krblastadminunlock', 'krbloginfailedcount', 'nsaccountlock', + }, + 'replaces': [ + '(targetattr = "krbLastAdminUnlock || krbLoginFailedCount")(target = "ldap:///uid=*,cn=users,cn=accounts,$SUFFIX")(version 3.0;acl "permission:Unlock user accounts";allow (write) groupdn = "ldap:///cn=Unlock user accounts,cn=permissions,cn=pbac,$SUFFIX";)', + ], + 'default_privileges': {'User Administrators'}, + }, + 'System: Read User Compat Tree': { + 'non_object': True, + 'ipapermbindruletype': 'anonymous', + 'ipapermlocation': api.env.basedn, + 'ipapermtarget': DN('cn=users', 'cn=compat', api.env.basedn), + 'ipapermright': {'read', 'search', 'compare'}, + 'ipapermdefaultattr': { + 'objectclass', 'uid', 'cn', 'gecos', 'gidnumber', 'uidnumber', + 'homedirectory', 'loginshell', + }, + }, + 'System: Read User Views Compat Tree': { + 'non_object': True, + 'ipapermbindruletype': 'anonymous', + 'ipapermlocation': api.env.basedn, + 'ipapermtarget': DN('cn=users', 'cn=*', 'cn=views', 'cn=compat', api.env.basedn), + 'ipapermright': {'read', 'search', 'compare'}, + 'ipapermdefaultattr': { + 'objectclass', 'uid', 'cn', 'gecos', 'gidnumber', 'uidnumber', + 'homedirectory', 'loginshell', + }, + }, + 'System: Read User NT Attributes': { + 'ipapermbindruletype': 'permission', + 'ipapermright': {'read', 'search', 'compare'}, + 'ipapermdefaultattr': { + 'ntuserdomainid', 'ntuniqueid', 'ntuseracctexpires', + 'ntusercodepage', 'ntuserdeleteaccount', 'ntuserlastlogoff', + 'ntuserlastlogon', + }, + 'default_privileges': {'PassSync Service'}, + }, + } + + takes_params = baseuser.takes_params + ( + Bool('nsaccountlock?', + label=_('Account disabled'), + flags=['no_option'], + ), + Bool('preserved?', + label=_('Preserved user'), + default=False, + flags=['virtual_attribute', 'no_create', 'no_update'], + ), + ) + + def get_either_dn(self, *keys, **options): + ''' + Returns the DN of a user + The user can be active (active container) or delete (delete container) + If the user does not exist, returns the Active user DN + ''' + ldap = self.backend + # Check that this value is a Active user + try: + active_dn = self.get_dn(*keys, **options) + ldap.get_entry(active_dn, ['dn']) + + # The Active user exists + dn = active_dn + except errors.NotFound: + # Check that this value is a Delete user + delete_dn = DN(active_dn[0], self.delete_container_dn, api.env.basedn) + try: + ldap.get_entry(delete_dn, ['dn']) + + # The Delete user exists + dn = delete_dn + except errors.NotFound: + # The user is neither Active/Delete -> returns that Active DN + dn = active_dn + + return dn + + def _normalize_manager(self, manager): + """ + Given a userid verify the user's existence and return the dn. + """ + return super(user, self).normalize_manager(manager, self.active_container_dn) + + def get_preserved_attribute(self, entry, options): + if options.get('raw', False): + return + delete_container_dn = DN(self.delete_container_dn, api.env.basedn) + if entry.dn.endswith(delete_container_dn): + entry['preserved'] = True + elif options.get('all', False): + entry['preserved'] = False + + +@register() +class user_add(baseuser_add): + __doc__ = _('Add a new user.') + + msg_summary = _('Added user "%(value)s"') + + has_output_params = baseuser_add.has_output_params + user_output_params + + takes_options = LDAPCreate.takes_options + ( + Flag('noprivate', + cli_name='noprivate', + doc=_('Don\'t create user private group'), + ), + ) + + def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options): + dn = self.obj.get_either_dn(*keys, **options) + if not options.get('noprivate', False): + try: + # The Managed Entries plugin will allow a user to be created + # even if a group has a duplicate name. This would leave a user + # without a private group. Check for both the group and the user. + self.api.Object['group'].get_dn_if_exists(keys[-1]) + try: + self.api.Command['user_show'](keys[-1]) + self.obj.handle_duplicate_entry(*keys) + except errors.NotFound: + raise errors.ManagedGroupExistsError(group=keys[-1]) + except errors.NotFound: + pass + else: + # we don't want an user private group to be created for this user + # add NO_UPG_MAGIC description attribute to let the DS plugin know + entry_attrs.setdefault('description', []) + entry_attrs['description'].append(NO_UPG_MAGIC) + + entry_attrs.setdefault('uidnumber', baseldap.DNA_MAGIC) + + if not client_has_capability( + options['version'], 'optional_uid_params'): + # https://fedorahosted.org/freeipa/ticket/2886 + # Old clients say 999 (OLD_DNA_MAGIC) when they really mean + # "assign a value dynamically". + OLD_DNA_MAGIC = 999 + if entry_attrs.get('uidnumber') == OLD_DNA_MAGIC: + entry_attrs['uidnumber'] = baseldap.DNA_MAGIC + if entry_attrs.get('gidnumber') == OLD_DNA_MAGIC: + entry_attrs['gidnumber'] = baseldap.DNA_MAGIC + + validate_nsaccountlock(entry_attrs) + config = ldap.get_ipa_config() + if 'ipamaxusernamelength' in config: + if len(keys[-1]) > int(config.get('ipamaxusernamelength')[0]): + raise errors.ValidationError( + name=self.obj.primary_key.cli_name, + error=_('can be at most %(len)d characters') % dict( + len = int(config.get('ipamaxusernamelength')[0]) + ) + ) + default_shell = config.get('ipadefaultloginshell', [paths.SH])[0] + entry_attrs.setdefault('loginshell', default_shell) + # hack so we can request separate first and last name in CLI + full_name = '%s %s' % (entry_attrs['givenname'], entry_attrs['sn']) + entry_attrs.setdefault('cn', full_name) + if 'homedirectory' not in entry_attrs: + # get home's root directory from config + homes_root = config.get('ipahomesrootdir', [paths.HOME_DIR])[0] + # build user's home directory based on his uid + entry_attrs['homedirectory'] = posixpath.join(homes_root, keys[-1]) + entry_attrs.setdefault('krbprincipalname', '%s@%s' % (entry_attrs['uid'], api.env.realm)) + + if entry_attrs.get('gidnumber') is None: + # gidNumber wasn't specified explicity, find out what it should be + if not options.get('noprivate', False) and ldap.has_upg(): + # User Private Groups - uidNumber == gidNumber + entry_attrs['gidnumber'] = entry_attrs['uidnumber'] + else: + # we're adding new users to a default group, get its gidNumber + # get default group name from config + def_primary_group = config.get('ipadefaultprimarygroup') + group_dn = self.api.Object['group'].get_dn(def_primary_group) + try: + group_attrs = ldap.get_entry(group_dn, ['gidnumber']) + except errors.NotFound: + error_msg = _('Default group for new users not found') + raise errors.NotFound(reason=error_msg) + if 'gidnumber' not in group_attrs: + error_msg = _('Default group for new users is not POSIX') + raise errors.NotFound(reason=error_msg) + entry_attrs['gidnumber'] = group_attrs['gidnumber'] + + if 'userpassword' not in entry_attrs and options.get('random'): + entry_attrs['userpassword'] = ipa_generate_password(baseuser_pwdchars) + # save the password so it can be displayed in post_callback + setattr(context, 'randompassword', entry_attrs['userpassword']) + + if 'mail' in entry_attrs: + entry_attrs['mail'] = self.obj.normalize_and_validate_email(entry_attrs['mail'], config) + else: + # No e-mail passed in. If we have a default e-mail domain set + # then we'll add it automatically. + defaultdomain = config.get('ipadefaultemaildomain', [None])[0] + if defaultdomain: + entry_attrs['mail'] = self.obj.normalize_and_validate_email(keys[-1], config) + + if 'manager' in entry_attrs: + entry_attrs['manager'] = self.obj.normalize_manager(entry_attrs['manager'], self.obj.active_container_dn) + + if 'userclass' in entry_attrs and \ + 'ipauser' not in entry_attrs['objectclass']: + entry_attrs['objectclass'].append('ipauser') + + if 'ipauserauthtype' in entry_attrs and \ + 'ipauserauthtypeclass' not in entry_attrs['objectclass']: + entry_attrs['objectclass'].append('ipauserauthtypeclass') + + rcl = entry_attrs.get('ipatokenradiusconfiglink', None) + if rcl: + if 'ipatokenradiusproxyuser' not in entry_attrs['objectclass']: + entry_attrs['objectclass'].append('ipatokenradiusproxyuser') + + answer = self.api.Object['radiusproxy'].get_dn_if_exists(rcl) + entry_attrs['ipatokenradiusconfiglink'] = answer + + self.pre_common_callback(ldap, dn, entry_attrs, attrs_list, *keys, + **options) + + return dn + + def post_callback(self, ldap, dn, entry_attrs, *keys, **options): + assert isinstance(dn, DN) + config = ldap.get_ipa_config() + # add the user we just created into the default primary group + def_primary_group = config.get('ipadefaultprimarygroup') + group_dn = self.api.Object['group'].get_dn(def_primary_group) + + # if the user is already a member of default primary group, + # do not raise error + # this can happen if automember rule or default group is set + try: + ldap.add_entry_to_group(dn, group_dn) + except errors.AlreadyGroupMember: + pass + + # delete description attribute NO_UPG_MAGIC if present + if options.get('noprivate', False): + if not options.get('all', False): + desc_attr = ldap.get_entry(dn, ['description']) + entry_attrs.update(desc_attr) + if 'description' in entry_attrs and NO_UPG_MAGIC in entry_attrs['description']: + entry_attrs['description'].remove(NO_UPG_MAGIC) + kw = {'setattr': unicode('description=%s' % ','.join(entry_attrs['description']))} + try: + self.api.Command['user_mod'](keys[-1], **kw) + except (errors.EmptyModlist, errors.NotFound): + pass + + # Fetch the entry again to update memberof, mep data, etc updated + # at the end of the transaction. + newentry = ldap.get_entry(dn, ['*']) + entry_attrs.update(newentry) + + if options.get('random', False): + try: + entry_attrs['randompassword'] = unicode(getattr(context, 'randompassword')) + except AttributeError: + # if both randompassword and userpassword options were used + pass + + self.obj.get_preserved_attribute(entry_attrs, options) + + self.post_common_callback(ldap, dn, entry_attrs, *keys, **options) + + return dn + + +@register() +class user_del(baseuser_del): + __doc__ = _('Delete a user.') + + msg_summary = _('Deleted user "%(value)s"') + + takes_options = baseuser_del.takes_options + ( + Bool('preserve?', + exclude='cli', + ), + ) + + def _preserve_user(self, pkey, delete_container, **options): + assert isinstance(delete_container, DN) + + dn = self.obj.get_either_dn(pkey, **options) + delete_dn = DN(dn[0], delete_container) + ldap = self.obj.backend + self.log.debug("preserve move %s -> %s" % (dn, delete_dn)) + + if dn.endswith(delete_container): + raise errors.ExecutionError( + _('%s: user is already preserved' % pkey) + ) + # Check that this value is a Active user + try: + original_entry_attrs = self._exc_wrapper( + pkey, options, ldap.get_entry)(dn, ['dn']) + except errors.NotFound: + self.obj.handle_not_found(pkey) + + for callback in self.get_callbacks('pre'): + dn = callback(self, ldap, dn, pkey, **options) + assert isinstance(dn, DN) + + # start to move the entry to Delete container + self._exc_wrapper(pkey, options, ldap.move_entry)(dn, delete_dn, + del_old=True) + + # Then clear the credential attributes + attrs_to_clear = ['krbPrincipalKey', 'krbLastPwdChange', + 'krbPasswordExpiration', 'userPassword'] + + entry_attrs = self._exc_wrapper(pkey, options, ldap.get_entry)( + delete_dn, attrs_to_clear) + + clearedCredential = False + for attr in attrs_to_clear: + if attr.lower() in entry_attrs: + del entry_attrs[attr] + clearedCredential = True + if clearedCredential: + self._exc_wrapper(pkey, options, ldap.update_entry)(entry_attrs) + + # Then restore some original entry attributes + attrs_to_restore = ['secretary', 'managedby', 'manager', 'ipauniqueid', + 'uidnumber', 'gidnumber', 'passwordHistory'] + + entry_attrs = self._exc_wrapper( + pkey, options, ldap.get_entry)(delete_dn, attrs_to_restore) + + restoreAttr = False + for attr in attrs_to_restore: + if ((attr.lower() in original_entry_attrs) and + not (attr.lower() in entry_attrs)): + restoreAttr = True + entry_attrs[attr.lower()] = original_entry_attrs[attr.lower()] + if restoreAttr: + self._exc_wrapper(pkey, options, ldap.update_entry)(entry_attrs) + + def pre_callback(self, ldap, dn, *keys, **options): + dn = self.obj.get_either_dn(*keys, **options) + + # For User life Cycle: user-del is a common plugin + # command to delete active user (active container) and + # delete user (delete container). + # If the target entry is a Delete entry, skip the orphaning/removal + # of OTP tokens. + check_protected_member(keys[-1]) + + if not options.get('preserve', False): + # Remove any ID overrides tied with this user + try: + remove_ipaobject_overrides(self.obj.backend, self.obj.api, dn) + except errors.NotFound: + self.obj.handle_not_found(*keys) + + if dn.endswith(DN(self.obj.delete_container_dn, api.env.basedn)): + return dn + + # Delete all tokens owned and managed by this user. + # Orphan all tokens owned but not managed by this user. + owner = self.api.Object.user.get_primary_key_from_dn(dn) + results = self.api.Command.otptoken_find( + ipatokenowner=owner, no_members=False)['result'] + for token in results: + orphan = not [x for x in token.get('managedby_user', []) if x == owner] + token = self.api.Object.otptoken.get_primary_key_from_dn(token['dn']) + if orphan: + self.api.Command.otptoken_mod(token, ipatokenowner=None) + else: + self.api.Command.otptoken_del(token) + + return dn + + def execute(self, *keys, **options): + + # We are going to permanent delete or the user is already in the delete container. + delete_container = DN(self.obj.delete_container_dn, self.api.env.basedn) + + # The user to delete is active and there is no 'no_preserve' option + if options.get('preserve', False): + failed = [] + preserved = [] + for pkey in keys[-1]: + try: + self._preserve_user(pkey, delete_container, **options) + preserved.append(pkey_to_value(pkey, options)) + except Exception: + if not options.get('continue', False): + raise + failed.append(pkey_to_value(pkey, options)) + + val = dict(result=dict(failed=failed), value=preserved) + return val + else: + return super(user_del, self).execute(*keys, **options) + + +@register() +class user_mod(baseuser_mod): + __doc__ = _('Modify a user.') + + msg_summary = _('Modified user "%(value)s"') + + has_output_params = baseuser_mod.has_output_params + user_output_params + + def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options): + dn = self.obj.get_either_dn(*keys, **options) + self.pre_common_callback(ldap, dn, entry_attrs, attrs_list, *keys, + **options) + validate_nsaccountlock(entry_attrs) + return dn + + def post_callback(self, ldap, dn, entry_attrs, *keys, **options): + self.post_common_callback(ldap, dn, entry_attrs, *keys, **options) + self.obj.get_preserved_attribute(entry_attrs, options) + return dn + + +@register() +class user_find(baseuser_find): + __doc__ = _('Search for users.') + + member_attributes = ['memberof'] + has_output_params = baseuser_find.has_output_params + user_output_params + + msg_summary = ngettext( + '%(count)d user matched', '%(count)d users matched', 0 + ) + + takes_options = LDAPSearch.takes_options + ( + Flag('whoami', + label=_('Self'), + doc=_('Display user record for current Kerberos principal'), + ), + ) + + def pre_callback(self, ldap, filter, attrs_list, base_dn, scope, *keys, **options): + assert isinstance(base_dn, DN) + self.pre_common_callback(ldap, filter, attrs_list, base_dn, scope, + *keys, **options) + + if options.get('whoami'): + return ("(&(objectclass=posixaccount)(krbprincipalname=%s))"%\ + getattr(context, 'principal'), base_dn, scope) + + preserved = options.get('preserved', False) + if preserved is None: + base_dn = self.api.env.basedn + scope = ldap.SCOPE_SUBTREE + elif preserved: + base_dn = DN(self.obj.delete_container_dn, self.api.env.basedn) + else: + base_dn = DN(self.obj.active_container_dn, self.api.env.basedn) + + return (filter, base_dn, scope) + + def post_callback(self, ldap, entries, truncated, *args, **options): + if options.get('pkey_only', False): + return truncated + + if options.get('preserved', False) is None: + base_dns = ( + DN(self.obj.active_container_dn, self.api.env.basedn), + DN(self.obj.delete_container_dn, self.api.env.basedn), + ) + entries[:] = [e for e in entries + if any(e.dn.endswith(bd) for bd in base_dns)] + + self.post_common_callback(ldap, entries, lockout=False, **options) + for entry in entries: + self.obj.get_preserved_attribute(entry, options) + + return truncated + + +@register() +class user_show(baseuser_show): + __doc__ = _('Display information about a user.') + + has_output_params = baseuser_show.has_output_params + user_output_params + takes_options = baseuser_show.takes_options + ( + Str('out?', + doc=_('file to store certificate in'), + ), + ) + + def pre_callback(self, ldap, dn, attrs_list, *keys, **options): + dn = self.obj.get_either_dn(*keys, **options) + self.pre_common_callback(ldap, dn, attrs_list, *keys, **options) + return dn + + def post_callback(self, ldap, dn, entry_attrs, *keys, **options): + convert_nsaccountlock(entry_attrs) + self.post_common_callback(ldap, dn, entry_attrs, *keys, **options) + self.obj.get_preserved_attribute(entry_attrs, options) + return dn + + +@register() +class user_undel(LDAPQuery): + __doc__ = _('Undelete a delete user account.') + + has_output = output.standard_value + msg_summary = _('Undeleted user account "%(value)s"') + + def execute(self, *keys, **options): + ldap = self.obj.backend + + # First check that the user exists and is a delete one + delete_dn = self.obj.get_either_dn(*keys, **options) + try: + entry_attrs = self._exc_wrapper(keys, options, ldap.get_entry)(delete_dn) + except errors.NotFound: + self.obj.handle_not_found(*keys) + if delete_dn.endswith(DN(self.obj.active_container_dn, + api.env.basedn)): + raise errors.InvocationError( + message=_('user "%s" is already active') % keys[-1]) + + active_dn = DN(delete_dn[0], self.obj.active_container_dn, api.env.basedn) + + # start to move the entry to the Active container + self._exc_wrapper(keys, options, ldap.move_entry)(delete_dn, active_dn, del_old=True) + + # add the user we just undelete into the default primary group + config = ldap.get_ipa_config() + def_primary_group = config.get('ipadefaultprimarygroup') + group_dn = self.api.Object['group'].get_dn(def_primary_group) + + # if the user is already a member of default primary group, + # do not raise error + # this can happen if automember rule or default group is set + try: + ldap.add_entry_to_group(active_dn, group_dn) + except errors.AlreadyGroupMember: + pass + + return dict( + result=True, + value=pkey_to_value(keys[0], options), + ) + + +@register() +class user_stage(LDAPMultiQuery): + __doc__ = _('Move deleted user into staged area') + + has_output = output.standard_multi_delete + msg_summary = _('Staged user account "%(value)s"') + + def execute(self, *keys, **options): + staged = [] + failed = [] + + for key in keys[-1]: + single_keys = keys[:-1] + (key,) + multi_keys = keys[:-1] + ((key,),) + + user = self.api.Command.user_show(*single_keys, all=True)['result'] + new_options = {} + for param in self.api.Command.stageuser_add.options(): + try: + value = user[param.name] + except KeyError: + continue + if param.multivalue and not isinstance(value, (list, tuple)): + value = [value] + elif not param.multivalue and isinstance(value, (list, tuple)): + value = value[0] + new_options[param.name] = value + + try: + self.api.Command.stageuser_add(*single_keys, **new_options) + try: + self.api.Command.user_del(*multi_keys, preserve=False) + except errors.ExecutionError: + self.api.Command.stageuser_del(*multi_keys) + raise + except errors.ExecutionError: + if not options['continue']: + raise + failed.append(key) + else: + staged.append(key) + + return dict( + result=dict( + failed=pkey_to_value(failed, options), + ), + value=pkey_to_value(staged, options), + ) + + +@register() +class user_disable(LDAPQuery): + __doc__ = _('Disable a user account.') + + has_output = output.standard_value + msg_summary = _('Disabled user account "%(value)s"') + + def execute(self, *keys, **options): + ldap = self.obj.backend + + check_protected_member(keys[-1]) + + dn = self.obj.get_either_dn(*keys, **options) + ldap.deactivate_entry(dn) + + return dict( + result=True, + value=pkey_to_value(keys[0], options), + ) + + +@register() +class user_enable(LDAPQuery): + __doc__ = _('Enable a user account.') + + has_output = output.standard_value + has_output_params = LDAPQuery.has_output_params + user_output_params + msg_summary = _('Enabled user account "%(value)s"') + + def execute(self, *keys, **options): + ldap = self.obj.backend + + dn = self.obj.get_either_dn(*keys, **options) + + ldap.activate_entry(dn) + + return dict( + result=True, + value=pkey_to_value(keys[0], options), + ) + + +@register() +class user_unlock(LDAPQuery): + __doc__ = _(""" + Unlock a user account + + An account may become locked if the password is entered incorrectly too + many times within a specific time period as controlled by password + policy. A locked account is a temporary condition and may be unlocked by + an administrator.""") + + has_output = output.standard_value + msg_summary = _('Unlocked account "%(value)s"') + + def execute(self, *keys, **options): + dn = self.obj.get_either_dn(*keys, **options) + entry = self.obj.backend.get_entry( + dn, ['krbLastAdminUnlock', 'krbLoginFailedCount']) + + entry['krbLastAdminUnlock'] = [strftime("%Y%m%d%H%M%SZ", gmtime())] + entry['krbLoginFailedCount'] = ['0'] + + self.obj.backend.update_entry(entry) + + return dict( + result=True, + value=pkey_to_value(keys[0], options), + ) + + +@register() +class user_status(LDAPQuery): + __doc__ = _(""" + Lockout status of a user account + + An account may become locked if the password is entered incorrectly too + many times within a specific time period as controlled by password + policy. A locked account is a temporary condition and may be unlocked by + an administrator. + + This connects to each IPA master and displays the lockout status on + each one. + + To determine whether an account is locked on a given server you need + to compare the number of failed logins and the time of the last failure. + For an account to be locked it must exceed the maxfail failures within + the failinterval duration as specified in the password policy associated + with the user. + + The failed login counter is modified only when a user attempts a log in + so it is possible that an account may appear locked but the last failed + login attempt is older than the lockouttime of the password policy. This + means that the user may attempt a login again. """) + + has_output = output.standard_list_of_entries + has_output_params = LDAPSearch.has_output_params + status_output_params + + def execute(self, *keys, **options): + ldap = self.obj.backend + dn = self.obj.get_either_dn(*keys, **options) + attr_list = ['krbloginfailedcount', 'krblastsuccessfulauth', 'krblastfailedauth', 'nsaccountlock'] + + disabled = False + masters = [] + # Get list of masters + try: + (masters, truncated) = ldap.find_entries( + None, ['*'], DN(('cn', 'masters'), ('cn', 'ipa'), ('cn', 'etc'), api.env.basedn), + ldap.SCOPE_ONELEVEL + ) + except errors.NotFound: + # If this happens we have some pretty serious problems + self.error('No IPA masters found!') + + entries = [] + count = 0 + for master in masters: + host = master['cn'][0] + if host == api.env.host: + other_ldap = self.obj.backend + else: + other_ldap = ldap2(self.api, ldap_uri='ldap://%s' % host) + try: + other_ldap.connect(ccache=os.environ['KRB5CCNAME']) + except Exception as e: + self.error("user_status: Connecting to %s failed with %s" % (host, str(e))) + newresult = {'dn': dn} + newresult['server'] = _("%(host)s failed: %(error)s") % dict(host=host, error=str(e)) + entries.append(newresult) + count += 1 + continue + try: + entry = other_ldap.get_entry(dn, attr_list) + newresult = {'dn': dn} + for attr in ['krblastsuccessfulauth', 'krblastfailedauth']: + newresult[attr] = entry.get(attr, [u'N/A']) + newresult['krbloginfailedcount'] = entry.get('krbloginfailedcount', u'0') + if not options.get('raw', False): + for attr in ['krblastsuccessfulauth', 'krblastfailedauth']: + try: + if newresult[attr][0] == u'N/A': + continue + newtime = time.strptime(newresult[attr][0], '%Y%m%d%H%M%SZ') + newresult[attr][0] = unicode(time.strftime('%Y-%m-%dT%H:%M:%SZ', newtime)) + except Exception as e: + self.debug("time conversion failed with %s" % str(e)) + newresult['server'] = host + if options.get('raw', False): + time_format = '%Y%m%d%H%M%SZ' + else: + time_format = '%Y-%m-%dT%H:%M:%SZ' + newresult['now'] = unicode(strftime(time_format, gmtime())) + convert_nsaccountlock(entry) + if 'nsaccountlock' in entry: + disabled = entry['nsaccountlock'] + self.obj.get_preserved_attribute(entry, options) + entries.append(newresult) + count += 1 + except errors.NotFound: + self.obj.handle_not_found(*keys) + except Exception as e: + self.error("user_status: Retrieving status for %s failed with %s" % (dn, str(e))) + newresult = {'dn': dn} + newresult['server'] = _("%(host)s failed") % dict(host=host) + entries.append(newresult) + count += 1 + + if host != api.env.host: + other_ldap.disconnect() + + return dict(result=entries, + count=count, + truncated=False, + summary=unicode(_('Account disabled: %(disabled)s' % + dict(disabled=disabled))), + ) + + +@register() +class user_add_cert(LDAPAddAttribute): + __doc__ = _('Add one or more certificates to the user entry') + msg_summary = _('Added certificates to user "%(value)s"') + attribute = 'usercertificate' + + def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, + **options): + dn = self.obj.get_either_dn(*keys, **options) + + self.obj.convert_usercertificate_pre(entry_attrs) + + return dn + + def post_callback(self, ldap, dn, entry_attrs, *keys, **options): + assert isinstance(dn, DN) + + self.obj.convert_usercertificate_post(entry_attrs, **options) + + return dn + + +@register() +class user_remove_cert(LDAPRemoveAttribute): + __doc__ = _('Remove one or more certificates to the user entry') + msg_summary = _('Removed certificates from user "%(value)s"') + attribute = 'usercertificate' + + def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, + **options): + dn = self.obj.get_either_dn(*keys, **options) + + self.obj.convert_usercertificate_pre(entry_attrs) + + return dn + + def post_callback(self, ldap, dn, entry_attrs, *keys, **options): + assert isinstance(dn, DN) + + self.obj.convert_usercertificate_post(entry_attrs, **options) + + return dn + + +@register() +class user_add_manager(baseuser_add_manager): + __doc__ = _("Add a manager to the user entry") + + +@register() +class user_remove_manager(baseuser_remove_manager): + __doc__ = _("Remove a manager to the user entry") diff --git a/ipaserver/plugins/vault.py b/ipaserver/plugins/vault.py new file mode 100644 index 000000000..05db63cdc --- /dev/null +++ b/ipaserver/plugins/vault.py @@ -0,0 +1,1215 @@ +# Authors: +# Endi S. Dewata <edewata@redhat.com> +# +# Copyright (C) 2015 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +from ipalib.frontend import Command, Object +from ipalib import api, errors +from ipalib import Bytes, Flag, Str, StrEnum +from ipalib import output +from ipalib.crud import PKQuery, Retrieve +from ipalib.plugable import Registry +from .baseldap import LDAPObject, LDAPCreate, LDAPDelete,\ + LDAPSearch, LDAPUpdate, LDAPRetrieve, LDAPAddMember, LDAPRemoveMember,\ + LDAPModMember, pkey_to_value +from ipalib.request import context +from .baseuser import split_principal +from .service import normalize_principal +from ipalib import _, ngettext +from ipapython.dn import DN + +if api.env.in_server: + import pki.account + import pki.key + +__doc__ = _(""" +Vaults +""") + _(""" +Manage vaults. +""") + _(""" +Vault is a secure place to store a secret. +""") + _(""" +Based on the ownership there are three vault categories: +* user/private vault +* service vault +* shared vault +""") + _(""" +User vaults are vaults owned used by a particular user. Private +vaults are vaults owned the current user. Service vaults are +vaults owned by a service. Shared vaults are owned by the admin +but they can be used by other users or services. +""") + _(""" +Based on the security mechanism there are three types of +vaults: +* standard vault +* symmetric vault +* asymmetric vault +""") + _(""" +Standard vault uses a secure mechanism to transport and +store the secret. The secret can only be retrieved by users +that have access to the vault. +""") + _(""" +Symmetric vault is similar to the standard vault, but it +pre-encrypts the secret using a password before transport. +The secret can only be retrieved using the same password. +""") + _(""" +Asymmetric vault is similar to the standard vault, but it +pre-encrypts the secret using a public key before transport. +The secret can only be retrieved using the private key. +""") + _(""" +EXAMPLES: +""") + _(""" + List vaults: + ipa vault-find + [--user <user>|--service <service>|--shared] +""") + _(""" + Add a standard vault: + ipa vault-add <name> + [--user <user>|--service <service>|--shared] + --type standard +""") + _(""" + Add a symmetric vault: + ipa vault-add <name> + [--user <user>|--service <service>|--shared] + --type symmetric --password-file password.txt +""") + _(""" + Add an asymmetric vault: + ipa vault-add <name> + [--user <user>|--service <service>|--shared] + --type asymmetric --public-key-file public.pem +""") + _(""" + Show a vault: + ipa vault-show <name> + [--user <user>|--service <service>|--shared] +""") + _(""" + Modify vault description: + ipa vault-mod <name> + [--user <user>|--service <service>|--shared] + --desc <description> +""") + _(""" + Modify vault type: + ipa vault-mod <name> + [--user <user>|--service <service>|--shared] + --type <type> + [old password/private key] + [new password/public key] +""") + _(""" + Modify symmetric vault password: + ipa vault-mod <name> + [--user <user>|--service <service>|--shared] + --change-password + ipa vault-mod <name> + [--user <user>|--service <service>|--shared] + --old-password <old password> + --new-password <new password> + ipa vault-mod <name> + [--user <user>|--service <service>|--shared] + --old-password-file <old password file> + --new-password-file <new password file> +""") + _(""" + Modify asymmetric vault keys: + ipa vault-mod <name> + [--user <user>|--service <service>|--shared] + --private-key-file <old private key file> + --public-key-file <new public key file> +""") + _(""" + Delete a vault: + ipa vault-del <name> + [--user <user>|--service <service>|--shared] +""") + _(""" + Display vault configuration: + ipa vaultconfig-show +""") + _(""" + Archive data into standard vault: + ipa vault-archive <name> + [--user <user>|--service <service>|--shared] + --in <input file> +""") + _(""" + Archive data into symmetric vault: + ipa vault-archive <name> + [--user <user>|--service <service>|--shared] + --in <input file> + --password-file password.txt +""") + _(""" + Archive data into asymmetric vault: + ipa vault-archive <name> + [--user <user>|--service <service>|--shared] + --in <input file> +""") + _(""" + Retrieve data from standard vault: + ipa vault-retrieve <name> + [--user <user>|--service <service>|--shared] + --out <output file> +""") + _(""" + Retrieve data from symmetric vault: + ipa vault-retrieve <name> + [--user <user>|--service <service>|--shared] + --out <output file> + --password-file password.txt +""") + _(""" + Retrieve data from asymmetric vault: + ipa vault-retrieve <name> + [--user <user>|--service <service>|--shared] + --out <output file> --private-key-file private.pem +""") + _(""" + Add vault owners: + ipa vault-add-owner <name> + [--user <user>|--service <service>|--shared] + [--users <users>] [--groups <groups>] [--services <services>] +""") + _(""" + Delete vault owners: + ipa vault-remove-owner <name> + [--user <user>|--service <service>|--shared] + [--users <users>] [--groups <groups>] [--services <services>] +""") + _(""" + Add vault members: + ipa vault-add-member <name> + [--user <user>|--service <service>|--shared] + [--users <users>] [--groups <groups>] [--services <services>] +""") + _(""" + Delete vault members: + ipa vault-remove-member <name> + [--user <user>|--service <service>|--shared] + [--users <users>] [--groups <groups>] [--services <services>] +""") + + +register = Registry() + +vault_options = ( + Str( + 'service?', + doc=_('Service name of the service vault'), + normalizer=normalize_principal, + ), + Flag( + 'shared?', + doc=_('Shared vault'), + ), + Str( + 'username?', + cli_name='user', + doc=_('Username of the user vault'), + ), +) + + +class VaultModMember(LDAPModMember): + def get_options(self): + for param in super(VaultModMember, self).get_options(): + if param.name == 'service' and param not in vault_options: + param = param.clone_rename('services') + yield param + + def get_member_dns(self, **options): + if 'services' in options: + options['service'] = options.pop('services') + else: + options.pop('service', None) + return super(VaultModMember, self).get_member_dns(**options) + + def post_callback(self, ldap, completed, failed, dn, entry_attrs, *keys, **options): + for fail in failed.itervalues(): + fail['services'] = fail.pop('service', []) + self.obj.get_container_attribute(entry_attrs, options) + return completed, dn + + +@register() +class vaultcontainer(LDAPObject): + __doc__ = _(""" + Vault Container object. + """) + + container_dn = api.env.container_vault + + object_name = _('vaultcontainer') + object_name_plural = _('vaultcontainers') + object_class = ['ipaVaultContainer'] + permission_filter_objectclasses = ['ipaVaultContainer'] + + attribute_members = { + 'owner': ['user', 'group', 'service'], + } + + label = _('Vault Containers') + label_singular = _('Vault Container') + + managed_permissions = { + 'System: Read Vault Containers': { + 'ipapermlocation': api.env.basedn, + 'ipapermtarget': DN(api.env.container_vault, api.env.basedn), + 'ipapermright': {'read', 'search', 'compare'}, + 'ipapermdefaultattr': { + 'objectclass', 'cn', 'description', 'owner', + }, + 'default_privileges': {'Vault Administrators'}, + }, + 'System: Add Vault Containers': { + 'ipapermlocation': api.env.basedn, + 'ipapermtarget': DN(api.env.container_vault, api.env.basedn), + 'ipapermright': {'add'}, + 'default_privileges': {'Vault Administrators'}, + }, + 'System: Delete Vault Containers': { + 'ipapermlocation': api.env.basedn, + 'ipapermtarget': DN(api.env.container_vault, api.env.basedn), + 'ipapermright': {'delete'}, + 'default_privileges': {'Vault Administrators'}, + }, + 'System: Modify Vault Containers': { + 'ipapermlocation': api.env.basedn, + 'ipapermtarget': DN(api.env.container_vault, api.env.basedn), + 'ipapermright': {'write'}, + 'ipapermdefaultattr': { + 'objectclass', 'cn', 'description', + }, + 'default_privileges': {'Vault Administrators'}, + }, + 'System: Manage Vault Container Ownership': { + 'ipapermlocation': api.env.basedn, + 'ipapermtarget': DN(api.env.container_vault, api.env.basedn), + 'ipapermright': {'write'}, + 'ipapermdefaultattr': { + 'owner', + }, + 'default_privileges': {'Vault Administrators'}, + }, + } + + takes_params = ( + Str( + 'owner_user?', + label=_('Owner users'), + ), + Str( + 'owner_group?', + label=_('Owner groups'), + ), + Str( + 'owner_service?', + label=_('Owner services'), + ), + Str( + 'owner?', + label=_('Failed owners'), + ), + Str( + 'service?', + label=_('Vault service'), + flags={'virtual_attribute'}, + ), + Flag( + 'shared?', + label=_('Shared vault'), + flags={'virtual_attribute'}, + ), + Str( + 'username?', + label=_('Vault user'), + flags={'virtual_attribute'}, + ), + ) + + def get_dn(self, *keys, **options): + """ + Generates vault DN from parameters. + """ + service = options.get('service') + shared = options.get('shared') + user = options.get('username') + + count = (bool(service) + bool(shared) + bool(user)) + if count > 1: + raise errors.MutuallyExclusiveError( + reason=_('Service, shared and user options ' + + 'cannot be specified simultaneously')) + + parent_dn = super(vaultcontainer, self).get_dn(*keys, **options) + + if not count: + principal = getattr(context, 'principal') + + if principal.startswith('host/'): + raise errors.NotImplementedError( + reason=_('Host is not supported')) + + (name, realm) = split_principal(principal) + if '/' in name: + service = principal + else: + user = name + + if service: + dn = DN(('cn', service), ('cn', 'services'), parent_dn) + elif shared: + dn = DN(('cn', 'shared'), parent_dn) + elif user: + dn = DN(('cn', user), ('cn', 'users'), parent_dn) + else: + raise RuntimeError + + return dn + + def get_container_attribute(self, entry, options): + if options.get('raw', False): + return + container_dn = DN(self.container_dn, self.api.env.basedn) + if entry.dn.endswith(DN(('cn', 'services'), container_dn)): + entry['service'] = entry.dn[0]['cn'] + elif entry.dn.endswith(DN(('cn', 'shared'), container_dn)): + entry['shared'] = True + elif entry.dn.endswith(DN(('cn', 'users'), container_dn)): + entry['username'] = entry.dn[0]['cn'] + + +@register() +class vaultcontainer_show(LDAPRetrieve): + __doc__ = _('Display information about a vault container.') + + takes_options = LDAPRetrieve.takes_options + vault_options + + has_output_params = LDAPRetrieve.has_output_params + + def pre_callback(self, ldap, dn, attrs_list, *keys, **options): + assert isinstance(dn, DN) + + if not self.api.Command.kra_is_enabled()['result']: + raise errors.InvocationError( + format=_('KRA service is not enabled')) + + return dn + + def post_callback(self, ldap, dn, entry_attrs, *keys, **options): + self.obj.get_container_attribute(entry_attrs, options) + return dn + + +@register() +class vaultcontainer_del(LDAPDelete): + __doc__ = _('Delete a vault container.') + + takes_options = LDAPDelete.takes_options + vault_options + + msg_summary = _('Deleted vault container') + + subtree_delete = False + + def pre_callback(self, ldap, dn, *keys, **options): + assert isinstance(dn, DN) + + if not self.api.Command.kra_is_enabled()['result']: + raise errors.InvocationError( + format=_('KRA service is not enabled')) + + return dn + + def execute(self, *keys, **options): + keys = keys + (u'',) + return super(vaultcontainer_del, self).execute(*keys, **options) + + +@register() +class vaultcontainer_add_owner(VaultModMember, LDAPAddMember): + __doc__ = _('Add owners to a vault container.') + + takes_options = LDAPAddMember.takes_options + vault_options + + member_attributes = ['owner'] + member_param_label = _('owner %s') + member_count_out = ('%i owner added.', '%i owners added.') + + has_output = ( + output.Entry('result'), + output.Output( + 'failed', + type=dict, + doc=_('Owners that could not be added'), + ), + output.Output( + 'completed', + type=int, + doc=_('Number of owners added'), + ), + ) + + +@register() +class vaultcontainer_remove_owner(VaultModMember, LDAPRemoveMember): + __doc__ = _('Remove owners from a vault container.') + + takes_options = LDAPRemoveMember.takes_options + vault_options + + member_attributes = ['owner'] + member_param_label = _('owner %s') + member_count_out = ('%i owner removed.', '%i owners removed.') + + has_output = ( + output.Entry('result'), + output.Output( + 'failed', + type=dict, + doc=_('Owners that could not be removed'), + ), + output.Output( + 'completed', + type=int, + doc=_('Number of owners removed'), + ), + ) + + +@register() +class vault(LDAPObject): + __doc__ = _(""" + Vault object. + """) + + container_dn = api.env.container_vault + + object_name = _('vault') + object_name_plural = _('vaults') + + object_class = ['ipaVault'] + permission_filter_objectclasses = ['ipaVault'] + default_attributes = [ + 'cn', + 'description', + 'ipavaulttype', + 'ipavaultsalt', + 'ipavaultpublickey', + 'owner', + 'member', + ] + search_display_attributes = [ + 'cn', + 'description', + 'ipavaulttype', + ] + attribute_members = { + 'owner': ['user', 'group', 'service'], + 'member': ['user', 'group', 'service'], + } + + label = _('Vaults') + label_singular = _('Vault') + + managed_permissions = { + 'System: Read Vaults': { + 'ipapermlocation': api.env.basedn, + 'ipapermtarget': DN(api.env.container_vault, api.env.basedn), + 'ipapermright': {'read', 'search', 'compare'}, + 'ipapermdefaultattr': { + 'objectclass', 'cn', 'description', 'ipavaulttype', + 'ipavaultsalt', 'ipavaultpublickey', 'owner', 'member', + 'memberuser', 'memberhost', + }, + 'default_privileges': {'Vault Administrators'}, + }, + 'System: Add Vaults': { + 'ipapermlocation': api.env.basedn, + 'ipapermtarget': DN(api.env.container_vault, api.env.basedn), + 'ipapermright': {'add'}, + 'default_privileges': {'Vault Administrators'}, + }, + 'System: Delete Vaults': { + 'ipapermlocation': api.env.basedn, + 'ipapermtarget': DN(api.env.container_vault, api.env.basedn), + 'ipapermright': {'delete'}, + 'default_privileges': {'Vault Administrators'}, + }, + 'System: Modify Vaults': { + 'ipapermlocation': api.env.basedn, + 'ipapermtarget': DN(api.env.container_vault, api.env.basedn), + 'ipapermright': {'write'}, + 'ipapermdefaultattr': { + 'objectclass', 'cn', 'description', 'ipavaulttype', + 'ipavaultsalt', 'ipavaultpublickey', + }, + 'default_privileges': {'Vault Administrators'}, + }, + 'System: Manage Vault Ownership': { + 'ipapermlocation': api.env.basedn, + 'ipapermtarget': DN(api.env.container_vault, api.env.basedn), + 'ipapermright': {'write'}, + 'ipapermdefaultattr': { + 'owner', + }, + 'default_privileges': {'Vault Administrators'}, + }, + 'System: Manage Vault Membership': { + 'ipapermlocation': api.env.basedn, + 'ipapermtarget': DN(api.env.container_vault, api.env.basedn), + 'ipapermright': {'write'}, + 'ipapermdefaultattr': { + 'member', + }, + 'default_privileges': {'Vault Administrators'}, + }, + } + + takes_params = ( + Str( + 'cn', + cli_name='name', + label=_('Vault name'), + primary_key=True, + pattern='^[a-zA-Z0-9_.-]+$', + pattern_errmsg='may only include letters, numbers, _, ., and -', + maxlength=255, + ), + Str( + 'description?', + cli_name='desc', + label=_('Description'), + doc=_('Vault description'), + ), + StrEnum( + 'ipavaulttype?', + cli_name='type', + label=_('Type'), + doc=_('Vault type'), + values=(u'standard', u'symmetric', u'asymmetric', ), + default=u'symmetric', + autofill=True, + ), + Bytes( + 'ipavaultsalt?', + cli_name='salt', + label=_('Salt'), + doc=_('Vault salt'), + flags=['no_search'], + ), + Bytes( + 'ipavaultpublickey?', + cli_name='public_key', + label=_('Public key'), + doc=_('Vault public key'), + flags=['no_search'], + ), + Str( + 'owner_user?', + label=_('Owner users'), + flags=['no_create', 'no_update', 'no_search'], + ), + Str( + 'owner_group?', + label=_('Owner groups'), + flags=['no_create', 'no_update', 'no_search'], + ), + Str( + 'owner_service?', + label=_('Owner services'), + flags=['no_create', 'no_update', 'no_search'], + ), + Str( + 'owner?', + label=_('Failed owners'), + flags=['no_create', 'no_update', 'no_search'], + ), + Str( + 'service?', + label=_('Vault service'), + flags={'virtual_attribute', 'no_create', 'no_update', 'no_search'}, + ), + Flag( + 'shared?', + label=_('Shared vault'), + flags={'virtual_attribute', 'no_create', 'no_update', 'no_search'}, + ), + Str( + 'username?', + label=_('Vault user'), + flags={'virtual_attribute', 'no_create', 'no_update', 'no_search'}, + ), + ) + + def get_dn(self, *keys, **options): + """ + Generates vault DN from parameters. + """ + service = options.get('service') + shared = options.get('shared') + user = options.get('username') + + count = (bool(service) + bool(shared) + bool(user)) + if count > 1: + raise errors.MutuallyExclusiveError( + reason=_('Service, shared, and user options ' + + 'cannot be specified simultaneously')) + + # TODO: create container_dn after object initialization then reuse it + container_dn = DN(self.container_dn, self.api.env.basedn) + + dn = super(vault, self).get_dn(*keys, **options) + assert dn.endswith(container_dn) + rdns = DN(*dn[:-len(container_dn)]) + + if not count: + principal = getattr(context, 'principal') + + if principal.startswith('host/'): + raise errors.NotImplementedError( + reason=_('Host is not supported')) + + (name, realm) = split_principal(principal) + if '/' in name: + service = principal + else: + user = name + + if service: + parent_dn = DN(('cn', service), ('cn', 'services'), container_dn) + elif shared: + parent_dn = DN(('cn', 'shared'), container_dn) + elif user: + parent_dn = DN(('cn', user), ('cn', 'users'), container_dn) + else: + raise RuntimeError + + return DN(rdns, parent_dn) + + def create_container(self, dn, owner_dn): + """ + Creates vault container and its parents. + """ + + # TODO: create container_dn after object initialization then reuse it + container_dn = DN(self.container_dn, self.api.env.basedn) + + entries = [] + + while dn: + assert dn.endswith(container_dn) + + rdn = dn[0] + entry = self.backend.make_entry( + dn, + { + 'objectclass': ['ipaVaultContainer'], + 'cn': rdn['cn'], + 'owner': [owner_dn], + }) + + # if entry can be added, return + try: + self.backend.add_entry(entry) + break + + except errors.NotFound: + pass + + # otherwise, create parent entry first + dn = DN(*dn[1:]) + entries.insert(0, entry) + + # then create the entries again + for entry in entries: + self.backend.add_entry(entry) + + def get_key_id(self, dn): + """ + Generates a client key ID to archive/retrieve data in KRA. + """ + + # TODO: create container_dn after object initialization then reuse it + container_dn = DN(self.container_dn, self.api.env.basedn) + + # make sure the DN is a vault DN + if not dn.endswith(container_dn, 1): + raise ValueError('Invalid vault DN: %s' % dn) + + # construct the vault ID from the bottom up + id = u'' + for rdn in dn[:-len(container_dn)]: + name = rdn['cn'] + id = u'/' + name + id + + return 'ipa:' + id + + def get_container_attribute(self, entry, options): + if options.get('raw', False): + return + container_dn = DN(self.container_dn, self.api.env.basedn) + if entry.dn.endswith(DN(('cn', 'services'), container_dn)): + entry['service'] = entry.dn[1]['cn'] + elif entry.dn.endswith(DN(('cn', 'shared'), container_dn)): + entry['shared'] = True + elif entry.dn.endswith(DN(('cn', 'users'), container_dn)): + entry['username'] = entry.dn[1]['cn'] + + +@register() +class vault_add_internal(LDAPCreate): + + NO_CLI = True + + takes_options = LDAPCreate.takes_options + vault_options + + msg_summary = _('Added vault "%(value)s"') + + def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, + **options): + assert isinstance(dn, DN) + + if not self.api.Command.kra_is_enabled()['result']: + raise errors.InvocationError( + format=_('KRA service is not enabled')) + + principal = getattr(context, 'principal') + (name, realm) = split_principal(principal) + if '/' in name: + owner_dn = self.api.Object.service.get_dn(name) + else: + owner_dn = self.api.Object.user.get_dn(name) + + parent_dn = DN(*dn[1:]) + + try: + self.obj.create_container(parent_dn, owner_dn) + except errors.DuplicateEntry as e: + pass + + # vault should be owned by the creator + entry_attrs['owner'] = owner_dn + + return dn + + def post_callback(self, ldap, dn, entry, *keys, **options): + self.obj.get_container_attribute(entry, options) + return dn + + +@register() +class vault_del(LDAPDelete): + __doc__ = _('Delete a vault.') + + takes_options = LDAPDelete.takes_options + vault_options + + msg_summary = _('Deleted vault "%(value)s"') + + def pre_callback(self, ldap, dn, *keys, **options): + assert isinstance(dn, DN) + + if not self.api.Command.kra_is_enabled()['result']: + raise errors.InvocationError( + format=_('KRA service is not enabled')) + + return dn + + def post_callback(self, ldap, dn, *args, **options): + assert isinstance(dn, DN) + + kra_client = self.api.Backend.kra.get_client() + + kra_account = pki.account.AccountClient(kra_client.connection) + kra_account.login() + + client_key_id = self.obj.get_key_id(dn) + + # deactivate vault record in KRA + response = kra_client.keys.list_keys( + client_key_id, pki.key.KeyClient.KEY_STATUS_ACTIVE) + + for key_info in response.key_infos: + kra_client.keys.modify_key_status( + key_info.get_key_id(), + pki.key.KeyClient.KEY_STATUS_INACTIVE) + + kra_account.logout() + + return True + + +@register() +class vault_find(LDAPSearch): + __doc__ = _('Search for vaults.') + + takes_options = LDAPSearch.takes_options + vault_options + ( + Flag( + 'services?', + doc=_('List all service vaults'), + ), + Flag( + 'users?', + doc=_('List all user vaults'), + ), + ) + + has_output_params = LDAPSearch.has_output_params + + msg_summary = ngettext( + '%(count)d vault matched', + '%(count)d vaults matched', + 0, + ) + + def pre_callback(self, ldap, filter, attrs_list, base_dn, scope, *args, + **options): + assert isinstance(base_dn, DN) + + if not self.api.Command.kra_is_enabled()['result']: + raise errors.InvocationError( + format=_('KRA service is not enabled')) + + if options.get('users') or options.get('services'): + mutex = ['service', 'services', 'shared', 'username', 'users'] + count = sum(bool(options.get(option)) for option in mutex) + if count > 1: + raise errors.MutuallyExclusiveError( + reason=_('Service(s), shared, and user(s) options ' + + 'cannot be specified simultaneously')) + + scope = ldap.SCOPE_SUBTREE + container_dn = DN(self.obj.container_dn, + self.api.env.basedn) + + if options.get('services'): + base_dn = DN(('cn', 'services'), container_dn) + else: + base_dn = DN(('cn', 'users'), container_dn) + else: + base_dn = self.obj.get_dn(None, **options) + + return filter, base_dn, scope + + def post_callback(self, ldap, entries, truncated, *args, **options): + for entry in entries: + self.obj.get_container_attribute(entry, options) + return truncated + + def exc_callback(self, args, options, exc, call_func, *call_args, + **call_kwargs): + if call_func.__name__ == 'find_entries': + if isinstance(exc, errors.NotFound): + # ignore missing containers since they will be created + # automatically on vault creation. + raise errors.EmptyResult(reason=str(exc)) + + raise exc + + +@register() +class vault_mod_internal(LDAPUpdate): + + NO_CLI = True + + takes_options = LDAPUpdate.takes_options + vault_options + + msg_summary = _('Modified vault "%(value)s"') + + def pre_callback(self, ldap, dn, entry_attrs, attrs_list, + *keys, **options): + + assert isinstance(dn, DN) + + if not self.api.Command.kra_is_enabled()['result']: + raise errors.InvocationError( + format=_('KRA service is not enabled')) + + return dn + + def post_callback(self, ldap, dn, entry_attrs, *keys, **options): + self.obj.get_container_attribute(entry_attrs, options) + return dn + + +@register() +class vault_show(LDAPRetrieve): + __doc__ = _('Display information about a vault.') + + takes_options = LDAPRetrieve.takes_options + vault_options + + has_output_params = LDAPRetrieve.has_output_params + + def pre_callback(self, ldap, dn, attrs_list, *keys, **options): + assert isinstance(dn, DN) + + if not self.api.Command.kra_is_enabled()['result']: + raise errors.InvocationError( + format=_('KRA service is not enabled')) + + return dn + + def post_callback(self, ldap, dn, entry_attrs, *keys, **options): + self.obj.get_container_attribute(entry_attrs, options) + return dn + + +@register() +class vaultconfig(Object): + __doc__ = _('Vault configuration') + + takes_params = ( + Bytes( + 'transport_cert', + label=_('Transport Certificate'), + ), + ) + + +@register() +class vaultconfig_show(Retrieve): + __doc__ = _('Show vault configuration.') + + takes_options = ( + Str( + 'transport_out?', + doc=_('Output file to store the transport certificate'), + ), + ) + + def execute(self, *args, **options): + + if not self.api.Command.kra_is_enabled()['result']: + raise errors.InvocationError( + format=_('KRA service is not enabled')) + + kra_client = self.api.Backend.kra.get_client() + transport_cert = kra_client.system_certs.get_transport_cert() + return { + 'result': { + 'transport_cert': transport_cert.binary + }, + 'value': None, + } + + +@register() +class vault_archive_internal(PKQuery): + + NO_CLI = True + + takes_options = vault_options + ( + Bytes( + 'session_key', + doc=_('Session key wrapped with transport certificate'), + ), + Bytes( + 'vault_data', + doc=_('Vault data encrypted with session key'), + ), + Bytes( + 'nonce', + doc=_('Nonce'), + ), + ) + + has_output = output.standard_entry + + msg_summary = _('Archived data into vault "%(value)s"') + + def execute(self, *args, **options): + + if not self.api.Command.kra_is_enabled()['result']: + raise errors.InvocationError( + format=_('KRA service is not enabled')) + + wrapped_vault_data = options.pop('vault_data') + nonce = options.pop('nonce') + wrapped_session_key = options.pop('session_key') + + # retrieve vault info + vault = self.api.Command.vault_show(*args, **options)['result'] + + # connect to KRA + kra_client = self.api.Backend.kra.get_client() + + kra_account = pki.account.AccountClient(kra_client.connection) + kra_account.login() + + client_key_id = self.obj.get_key_id(vault['dn']) + + # deactivate existing vault record in KRA + response = kra_client.keys.list_keys( + client_key_id, + pki.key.KeyClient.KEY_STATUS_ACTIVE) + + for key_info in response.key_infos: + kra_client.keys.modify_key_status( + key_info.get_key_id(), + pki.key.KeyClient.KEY_STATUS_INACTIVE) + + # forward wrapped data to KRA + kra_client.keys.archive_encrypted_data( + client_key_id, + pki.key.KeyClient.PASS_PHRASE_TYPE, + wrapped_vault_data, + wrapped_session_key, + None, + nonce, + ) + + kra_account.logout() + + response = { + 'value': args[-1], + 'result': {}, + } + + response['summary'] = self.msg_summary % response + + return response + + +@register() +class vault_retrieve_internal(PKQuery): + + NO_CLI = True + + takes_options = vault_options + ( + Bytes( + 'session_key', + doc=_('Session key wrapped with transport certificate'), + ), + ) + + has_output = output.standard_entry + + msg_summary = _('Retrieved data from vault "%(value)s"') + + def execute(self, *args, **options): + + if not self.api.Command.kra_is_enabled()['result']: + raise errors.InvocationError( + format=_('KRA service is not enabled')) + + wrapped_session_key = options.pop('session_key') + + # retrieve vault info + vault = self.api.Command.vault_show(*args, **options)['result'] + + # connect to KRA + kra_client = self.api.Backend.kra.get_client() + + kra_account = pki.account.AccountClient(kra_client.connection) + kra_account.login() + + client_key_id = self.obj.get_key_id(vault['dn']) + + # find vault record in KRA + response = kra_client.keys.list_keys( + client_key_id, + pki.key.KeyClient.KEY_STATUS_ACTIVE) + + if not len(response.key_infos): + raise errors.NotFound(reason=_('No archived data.')) + + key_info = response.key_infos[0] + + # retrieve encrypted data from KRA + key = kra_client.keys.retrieve_key( + key_info.get_key_id(), + wrapped_session_key) + + kra_account.logout() + + response = { + 'value': args[-1], + 'result': { + 'vault_data': key.encrypted_data, + 'nonce': key.nonce_data, + }, + } + + response['summary'] = self.msg_summary % response + + return response + + +@register() +class vault_add_owner(VaultModMember, LDAPAddMember): + __doc__ = _('Add owners to a vault.') + + takes_options = LDAPAddMember.takes_options + vault_options + + member_attributes = ['owner'] + member_param_label = _('owner %s') + member_count_out = ('%i owner added.', '%i owners added.') + + has_output = ( + output.Entry('result'), + output.Output( + 'failed', + type=dict, + doc=_('Owners that could not be added'), + ), + output.Output( + 'completed', + type=int, + doc=_('Number of owners added'), + ), + ) + + +@register() +class vault_remove_owner(VaultModMember, LDAPRemoveMember): + __doc__ = _('Remove owners from a vault.') + + takes_options = LDAPRemoveMember.takes_options + vault_options + + member_attributes = ['owner'] + member_param_label = _('owner %s') + member_count_out = ('%i owner removed.', '%i owners removed.') + + has_output = ( + output.Entry('result'), + output.Output( + 'failed', + type=dict, + doc=_('Owners that could not be removed'), + ), + output.Output( + 'completed', + type=int, + doc=_('Number of owners removed'), + ), + ) + + +@register() +class vault_add_member(VaultModMember, LDAPAddMember): + __doc__ = _('Add members to a vault.') + + takes_options = LDAPAddMember.takes_options + vault_options + + +@register() +class vault_remove_member(VaultModMember, LDAPRemoveMember): + __doc__ = _('Remove members from a vault.') + + takes_options = LDAPRemoveMember.takes_options + vault_options + + +@register() +class kra_is_enabled(Command): + NO_CLI = True + + has_output = output.standard_value + + def execute(self, *args, **options): + base_dn = DN(('cn', 'masters'), ('cn', 'ipa'), ('cn', 'etc'), + self.api.env.basedn) + filter = '(&(objectClass=ipaConfigObject)(cn=KRA))' + try: + self.api.Backend.ldap2.find_entries( + base_dn=base_dn, filter=filter, attrs_list=[]) + except errors.NotFound: + result = False + else: + result = True + return dict(result=result, value=pkey_to_value(None, options)) diff --git a/ipaserver/plugins/virtual.py b/ipaserver/plugins/virtual.py new file mode 100644 index 000000000..2ba69f651 --- /dev/null +++ b/ipaserver/plugins/virtual.py @@ -0,0 +1,68 @@ +# Authors: +# Rob Crittenden <rcritten@redhat.com> +# +# Copyright (C) 2009 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +""" +Base classes for non-LDAP backend plugins. +""" +from ipalib import Command +from ipalib import errors +from ipapython.dn import DN +from ipalib.text import _ + +class VirtualCommand(Command): + """ + A command that doesn't use the LDAP backend but wants to use the + LDAP access control system to make authorization decisions. + + The class variable operation is the commonName attribute of the + entry to be tested against. + + In advance, you need to create an entry of the form: + cn=<operation>, api.env.container_virtual, api.env.basedn + + Ex. + cn=request certificate, cn=virtual operations,cn=etc, dc=example, dc=com + """ + operation = None + + def check_access(self, operation=None): + """ + Perform an LDAP query to determine authorization. + + This should be executed before any actual work is done. + """ + if self.operation is None and operation is None: + raise errors.ACIError(info=_('operation not defined')) + + if operation is None: + operation = self.operation + + ldap = self.api.Backend.ldap2 + self.log.debug("IPA: virtual verify %s" % operation) + + operationdn = DN(('cn', operation), self.api.env.container_virtual, self.api.env.basedn) + + try: + if not ldap.can_write(operationdn, "objectclass"): + raise errors.ACIError( + info=_('not allowed to perform operation: %s') % operation) + except errors.NotFound: + raise errors.ACIError(info=_('No such virtual command')) + + return True |