# 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 logging
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"
""")

logger = logging.getLogger(__name__)

register = Registry()

_DEPRECATED_OPTION_ALIASES = {
    'permissions': 'ipapermright',
    'filter': 'extratargetfilter',
    'subtree': 'ipapermlocation',
}

KNOWN_FLAGS = {'SYSTEM', 'V2', 'MANAGED'}


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",
    )


_ipapermissiontype_param = Str(
    'ipapermissiontype+',
    label=_('Permission flags'),
    flags={'no_create', 'no_update', 'no_search'},
)


@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'],
    }
    allow_rename = 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',
            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'),
            # force server-side conversion
            normalizer=lambda x: x,
            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()
    ) + (
        _ipapermissiontype_param,
        Str('aci',
            label=_('ACI'),
            flags={'virtual_attribute', 'no_create', 'no_update', 'no_search'},
        ),
    )

    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)

        logger.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:
            logger.debug('Removing ACI %r from %s', acistring, acidn)
            acientry['aci'].remove(acistring)
        if new_acistring:
            logger.debug('Adding ACI %r to %s', new_acistring, acidn)
            acientry.setdefault('aci', []).append(new_acistring)
        try:
            ldap.update_entry(acientry)
        except errors.EmptyModlist:
            logger.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:
                logger.warning('Unparseable ACI %s: %s (at %s)',
                               acistring, e, location)
                continue
            if aci.name == wanted_aciname:
                return acientry, acistring

        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

    takes_options = (
        _ipapermissiontype_param,
    )

    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"')

    # 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:
            raise 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:
            raise 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"')

    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:
            raise 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:
                logger.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:
                logger.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)

            logger.error('Error updating ACI: %s', traceback.format_exc())
            logger.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)

    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

            if max_entries > 0:
                # should we get more entries than current sizelimit, fail
                assert len(entries) <= max_entries

            filters = ['(objectclass=ipaPermission)',
                       '(!(ipaPermissionType=V2))']
            if 'name' in options:
                filters.append(ldap.make_filter_from_attr('cn',
                                                          options['name'],
                                                          exact=False))
            index = tuple(self.args).index('criteria')
            try:
                term = args[index]
                filters.append(self.get_term_filter(ldap, term))
            except IndexError:
                term = None

            attrs_list = list(self.obj.default_attributes)
            attrs_list += list(self.obj.attribute_members)
            if options.get('all'):
                attrs_list.append('*')
            try:
                legacy_entries, truncated = ldap.find_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, size_limit=max_entries)
                # 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 = ()
            logger.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
                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:
                        if max_entries > 0 and len(entries) == max_entries:
                            # We've reached the limit, set truncated flag
                            # (max_entries <= 0 means unlimited)
                            truncated = True
                            break
                        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.')

    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
