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/permission.py | |
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/permission.py')
-rw-r--r-- | ipaserver/plugins/permission.py | 1395 |
1 files changed, 1395 insertions, 0 deletions
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 |