summaryrefslogtreecommitdiffstats
path: root/ipaserver
diff options
context:
space:
mode:
authorJan Cholasta <jcholast@redhat.com>2016-04-28 10:30:05 +0200
committerJan Cholasta <jcholast@redhat.com>2016-06-03 09:00:34 +0200
commit6e44557b601f769d23ee74555a72e8b5cc62c0c9 (patch)
treeeedd3e054b0709341b9f58c190ea54f999f7d13a /ipaserver
parentec841e5d7ab29d08de294b3fa863a631cd50e30a (diff)
downloadfreeipa-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')
-rw-r--r--ipaserver/install/plugins/dns.py2
-rw-r--r--ipaserver/install/plugins/update_managed_permissions.py4
-rw-r--r--ipaserver/plugins/aci.py986
-rw-r--r--ipaserver/plugins/automember.py802
-rw-r--r--ipaserver/plugins/automount.py841
-rw-r--r--ipaserver/plugins/baseldap.py2397
-rw-r--r--ipaserver/plugins/baseuser.py663
-rw-r--r--ipaserver/plugins/batch.py143
-rw-r--r--ipaserver/plugins/caacl.py562
-rw-r--r--ipaserver/plugins/cert.py835
-rw-r--r--ipaserver/plugins/certprofile.py335
-rw-r--r--ipaserver/plugins/config.py358
-rw-r--r--ipaserver/plugins/delegation.py226
-rw-r--r--ipaserver/plugins/dns.py4396
-rw-r--r--ipaserver/plugins/dogtag.py14
-rw-r--r--ipaserver/plugins/domainlevel.py137
-rw-r--r--ipaserver/plugins/group.py690
-rw-r--r--ipaserver/plugins/hbac.py7
-rw-r--r--ipaserver/plugins/hbacrule.py605
-rw-r--r--ipaserver/plugins/hbacsvc.py152
-rw-r--r--ipaserver/plugins/hbacsvcgroup.py176
-rw-r--r--ipaserver/plugins/hbactest.py499
-rw-r--r--ipaserver/plugins/host.py1284
-rw-r--r--ipaserver/plugins/hostgroup.py316
-rw-r--r--ipaserver/plugins/idrange.py769
-rw-r--r--ipaserver/plugins/idviews.py1123
-rw-r--r--ipaserver/plugins/internal.py859
-rw-r--r--ipaserver/plugins/join.py2
-rw-r--r--ipaserver/plugins/krbtpolicy.py243
-rw-r--r--ipaserver/plugins/migration.py920
-rw-r--r--ipaserver/plugins/misc.py138
-rw-r--r--ipaserver/plugins/netgroup.py387
-rw-r--r--ipaserver/plugins/otp.py7
-rw-r--r--ipaserver/plugins/otpconfig.py121
-rw-r--r--ipaserver/plugins/otptoken.py464
-rw-r--r--ipaserver/plugins/passwd.py139
-rw-r--r--ipaserver/plugins/permission.py1395
-rw-r--r--ipaserver/plugins/ping.py70
-rw-r--r--ipaserver/plugins/pkinit.py105
-rw-r--r--ipaserver/plugins/privilege.py251
-rw-r--r--ipaserver/plugins/pwpolicy.py611
-rw-r--r--ipaserver/plugins/radiusproxy.py175
-rw-r--r--ipaserver/plugins/realmdomains.py340
-rw-r--r--ipaserver/plugins/role.py252
-rw-r--r--ipaserver/plugins/schema.py660
-rw-r--r--ipaserver/plugins/selfservice.py224
-rw-r--r--ipaserver/plugins/selinuxusermap.py569
-rw-r--r--ipaserver/plugins/server.py260
-rw-r--r--ipaserver/plugins/service.py889
-rw-r--r--ipaserver/plugins/servicedelegation.py550
-rw-r--r--ipaserver/plugins/session.py33
-rw-r--r--ipaserver/plugins/stageuser.py745
-rw-r--r--ipaserver/plugins/sudo.py7
-rw-r--r--ipaserver/plugins/sudocmd.py203
-rw-r--r--ipaserver/plugins/sudocmdgroup.py195
-rw-r--r--ipaserver/plugins/sudorule.py998
-rw-r--r--ipaserver/plugins/topology.py503
-rw-r--r--ipaserver/plugins/trust.py1725
-rw-r--r--ipaserver/plugins/user.py1151
-rw-r--r--ipaserver/plugins/vault.py1215
-rw-r--r--ipaserver/plugins/virtual.py68
61 files changed, 34787 insertions, 9 deletions
diff --git a/ipaserver/install/plugins/dns.py b/ipaserver/install/plugins/dns.py
index d2a9bd8f8..2399264c2 100644
--- a/ipaserver/install/plugins/dns.py
+++ b/ipaserver/install/plugins/dns.py
@@ -27,8 +27,8 @@ from ipalib import Registry, errors, util
from ipalib import Updater
from ipapython.dn import DN
from ipapython import dnsutil
-from ipalib.plugins.dns import dns_container_exists
from ipapython.ipa_log_manager import root_logger
+from ipaserver.plugins.dns import dns_container_exists
register = Registry()
diff --git a/ipaserver/install/plugins/update_managed_permissions.py b/ipaserver/install/plugins/update_managed_permissions.py
index 36ac5cca8..5b3722742 100644
--- a/ipaserver/install/plugins/update_managed_permissions.py
+++ b/ipaserver/install/plugins/update_managed_permissions.py
@@ -88,11 +88,11 @@ import six
from ipalib import api, errors
from ipapython.dn import DN
from ipalib.plugable import Registry
-from ipalib.plugins import aci
-from ipalib.plugins.permission import permission, permission_del
from ipalib.aci import ACI
from ipalib import Updater
from ipapython import ipautil
+from ipaserver.plugins import aci
+from ipaserver.plugins.permission import permission, permission_del
if six.PY3:
unicode = str
diff --git a/ipaserver/plugins/aci.py b/ipaserver/plugins/aci.py
new file mode 100644
index 000000000..01c929230
--- /dev/null
+++ b/ipaserver/plugins/aci.py
@@ -0,0 +1,986 @@
+# Authors:
+# Rob Crittenden <rcritten@redhat.com>
+# Pavel Zuna <pzuna@redhat.com>
+#
+# Copyright (C) 2009 Red Hat
+# see file 'COPYING' for use and warranty information
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+"""
+Directory Server Access Control Instructions (ACIs)
+
+ACIs are used to allow or deny access to information. This module is
+currently designed to allow, not deny, access.
+
+The aci commands are designed to grant permissions that allow updating
+existing entries or adding or deleting new ones. The goal of the ACIs
+that ship with IPA is to provide a set of low-level permissions that
+grant access to special groups called taskgroups. These low-level
+permissions can be combined into roles that grant broader access. These
+roles are another type of group, roles.
+
+For example, if you have taskgroups that allow adding and modifying users you
+could create a role, useradmin. You would assign users to the useradmin
+role to allow them to do the operations defined by the taskgroups.
+
+You can create ACIs that delegate permission so users in group A can write
+attributes on group B.
+
+The type option is a map that applies to all entries in the users, groups or
+host location. It is primarily designed to be used when granting add
+permissions (to write new entries).
+
+An ACI consists of three parts:
+1. target
+2. permissions
+3. bind rules
+
+The target is a set of rules that define which LDAP objects are being
+targeted. This can include a list of attributes, an area of that LDAP
+tree or an LDAP filter.
+
+The targets include:
+- attrs: list of attributes affected
+- type: an object type (user, group, host, service, etc)
+- memberof: members of a group
+- targetgroup: grant access to modify a specific group. This is primarily
+ designed to enable users to add or remove members of a specific group.
+- filter: A legal LDAP filter used to narrow the scope of the target.
+- subtree: Used to apply a rule across an entire set of objects. For example,
+ to allow adding users you need to grant "add" permission to the subtree
+ ldap://uid=*,cn=users,cn=accounts,dc=example,dc=com. The subtree option
+ is a fail-safe for objects that may not be covered by the type option.
+
+The permissions define what the ACI is allowed to do, and are one or
+more of:
+1. write - write one or more attributes
+2. read - read one or more attributes
+3. add - add a new entry to the tree
+4. delete - delete an existing entry
+5. all - all permissions are granted
+
+Note the distinction between attributes and entries. The permissions are
+independent, so being able to add a user does not mean that the user will
+be editable.
+
+The bind rule defines who this ACI grants permissions to. The LDAP server
+allows this to be any valid LDAP entry but we encourage the use of
+taskgroups so that the rights can be easily shared through roles.
+
+For a more thorough description of access controls see
+http://www.redhat.com/docs/manuals/dir-server/ag/8.0/Managing_Access_Control.html
+
+EXAMPLES:
+
+NOTE: ACIs are now added via the permission plugin. These examples are to
+demonstrate how the various options work but this is done via the permission
+command-line now (see last example).
+
+ Add an ACI so that the group "secretaries" can update the address on any user:
+ ipa group-add --desc="Office secretaries" secretaries
+ ipa aci-add --attrs=streetAddress --memberof=ipausers --group=secretaries --permissions=write --prefix=none "Secretaries write addresses"
+
+ Show the new ACI:
+ ipa aci-show --prefix=none "Secretaries write addresses"
+
+ Add an ACI that allows members of the "addusers" permission to add new users:
+ ipa aci-add --type=user --permission=addusers --permissions=add --prefix=none "Add new users"
+
+ Add an ACI that allows members of the editors manage members of the admins group:
+ ipa aci-add --permissions=write --attrs=member --targetgroup=admins --group=editors --prefix=none "Editors manage admins"
+
+ Add an ACI that allows members of the admins group to manage the street and zip code of those in the editors group:
+ ipa aci-add --permissions=write --memberof=editors --group=admins --attrs=street --attrs=postalcode --prefix=none "admins edit the address of editors"
+
+ Add an ACI that allows the admins group manage the street and zipcode of those who work for the boss:
+ ipa aci-add --permissions=write --group=admins --attrs=street --attrs=postalcode --filter="(manager=uid=boss,cn=users,cn=accounts,dc=example,dc=com)" --prefix=none "Edit the address of those who work for the boss"
+
+ Add an entirely new kind of record to IPA that isn't covered by any of the --type options, creating a permission:
+ ipa permission-add --permissions=add --subtree="cn=*,cn=orange,cn=accounts,dc=example,dc=com" --desc="Add Orange Entries" add_orange
+
+
+The show command shows the raw 389-ds ACI.
+
+IMPORTANT: When modifying the target attributes of an existing ACI you
+must include all existing attributes as well. When doing an aci-mod the
+targetattr REPLACES the current attributes, it does not add to them.
+
+"""
+from copy import deepcopy
+
+import six
+
+from ipalib import api, crud, errors
+from ipalib import Object
+from ipalib import Flag, Str, StrEnum, DNParam
+from ipalib.aci import ACI
+from ipalib import output
+from ipalib import _, ngettext
+from ipalib.plugable import Registry
+from .baseldap import gen_pkey_only_option, pkey_to_value
+from ipapython.ipa_log_manager import root_logger
+from ipapython.dn import DN
+
+if six.PY3:
+ unicode = str
+
+register = Registry()
+
+ACI_NAME_PREFIX_SEP = ":"
+
+_type_map = {
+ 'user': 'ldap:///' + str(DN(('uid', '*'), api.env.container_user, api.env.basedn)),
+ 'group': 'ldap:///' + str(DN(('cn', '*'), api.env.container_group, api.env.basedn)),
+ 'host': 'ldap:///' + str(DN(('fqdn', '*'), api.env.container_host, api.env.basedn)),
+ 'hostgroup': 'ldap:///' + str(DN(('cn', '*'), api.env.container_hostgroup, api.env.basedn)),
+ 'service': 'ldap:///' + str(DN(('krbprincipalname', '*'), api.env.container_service, api.env.basedn)),
+ 'netgroup': 'ldap:///' + str(DN(('ipauniqueid', '*'), api.env.container_netgroup, api.env.basedn)),
+ 'dnsrecord': 'ldap:///' + str(DN(('idnsname', '*'), api.env.container_dns, api.env.basedn)),
+}
+
+_valid_permissions_values = [
+ u'read', u'write', u'add', u'delete', u'all'
+]
+
+_valid_prefix_values = (
+ u'permission', u'delegation', u'selfservice', u'none'
+)
+
+class ListOfACI(output.Output):
+ type = (list, tuple)
+ doc = _('A list of ACI values')
+
+ def validate(self, cmd, entries):
+ assert isinstance(entries, self.type)
+ for (i, entry) in enumerate(entries):
+ if not isinstance(entry, unicode):
+ raise TypeError(output.emsg %
+ (cmd.name, self.__class__.__name__,
+ self.name, i, unicode, type(entry), entry)
+ )
+
+aci_output = (
+ output.Output('result', unicode, 'A string representing the ACI'),
+ output.value,
+ output.summary,
+)
+
+
+def _make_aci_name(aciprefix, aciname):
+ """
+ Given a name and a prefix construct an ACI name.
+ """
+ if aciprefix == u"none":
+ return aciname
+
+ return aciprefix + ACI_NAME_PREFIX_SEP + aciname
+
+def _parse_aci_name(aciname):
+ """
+ Parse the raw ACI name and return a tuple containing the ACI prefix
+ and the actual ACI name.
+ """
+ aciparts = aciname.partition(ACI_NAME_PREFIX_SEP)
+
+ if not aciparts[2]: # no prefix/name separator found
+ return (u"none",aciparts[0])
+
+ return (aciparts[0], aciparts[2])
+
+def _group_from_memberof(memberof):
+ """
+ Pull the group name out of a memberOf filter
+ """
+ st = memberof.find('memberOf=')
+ if st == -1:
+ # We have a raw group name, use that
+ return api.Object['group'].get_dn(memberof)
+ en = memberof.find(')', st)
+ return memberof[st+9:en]
+
+def _make_aci(ldap, current, aciname, kw):
+ """
+ Given a name and a set of keywords construct an ACI.
+ """
+ # Do some quick and dirty validation.
+ checked_args=['type','filter','subtree','targetgroup','attrs','memberof']
+ valid={}
+ for arg in checked_args:
+ if arg in kw:
+ valid[arg]=kw[arg] is not None
+ else:
+ valid[arg]=False
+
+ if valid['type'] + valid['filter'] + valid['subtree'] + valid['targetgroup'] > 1:
+ raise errors.ValidationError(name='target', error=_('type, filter, subtree and targetgroup are mutually exclusive'))
+
+ if 'aciprefix' not in kw:
+ raise errors.ValidationError(name='aciprefix', error=_('ACI prefix is required'))
+
+ if sum(valid.values()) == 0:
+ raise errors.ValidationError(name='target', error=_('at least one of: type, filter, subtree, targetgroup, attrs or memberof are required'))
+
+ if valid['filter'] + valid['memberof'] > 1:
+ raise errors.ValidationError(name='target', error=_('filter and memberof are mutually exclusive'))
+
+ group = 'group' in kw
+ permission = 'permission' in kw
+ selfaci = 'selfaci' in kw and kw['selfaci'] == True
+ if group + permission + selfaci > 1:
+ raise errors.ValidationError(name='target', error=_('group, permission and self are mutually exclusive'))
+ elif group + permission + selfaci == 0:
+ raise errors.ValidationError(name='target', error=_('One of group, permission or self is required'))
+
+ # Grab the dn of the group we're granting access to. This group may be a
+ # permission or a user group.
+ entry_attrs = []
+ if permission:
+ # This will raise NotFound if the permission doesn't exist
+ try:
+ entry_attrs = api.Command['permission_show'](kw['permission'])['result']
+ except errors.NotFound as e:
+ if 'test' in kw and not kw.get('test'):
+ raise e
+ else:
+ entry_attrs = {
+ 'dn': DN(('cn', kw['permission']),
+ api.env.container_permission, api.env.basedn),
+ }
+ elif group:
+ # Not so friendly with groups. This will raise
+ try:
+ group_dn = api.Object['group'].get_dn_if_exists(kw['group'])
+ entry_attrs = {'dn': group_dn}
+ except errors.NotFound:
+ raise errors.NotFound(reason=_("Group '%s' does not exist") % kw['group'])
+
+ try:
+ a = ACI(current)
+ a.name = _make_aci_name(kw['aciprefix'], aciname)
+ a.permissions = kw['permissions']
+ if 'selfaci' in kw and kw['selfaci']:
+ a.set_bindrule('userdn = "ldap:///self"')
+ else:
+ dn = entry_attrs['dn']
+ a.set_bindrule('groupdn = "ldap:///%s"' % dn)
+ if valid['attrs']:
+ a.set_target_attr(kw['attrs'])
+ if valid['memberof']:
+ try:
+ api.Object['group'].get_dn_if_exists(kw['memberof'])
+ except errors.NotFound:
+ api.Object['group'].handle_not_found(kw['memberof'])
+ groupdn = _group_from_memberof(kw['memberof'])
+ a.set_target_filter('memberOf=%s' % groupdn)
+ if valid['filter']:
+ # Test the filter by performing a simple search on it. The
+ # filter is considered valid if either it returns some entries
+ # or it returns no entries, otherwise we let whatever exception
+ # happened be raised.
+ if kw['filter'] in ('', None, u''):
+ raise errors.BadSearchFilter(info=_('empty filter'))
+ try:
+ entries = ldap.find_entries(filter=kw['filter'])
+ except errors.NotFound:
+ pass
+ a.set_target_filter(kw['filter'])
+ if valid['type']:
+ target = _type_map[kw['type']]
+ a.set_target(target)
+ if valid['targetgroup']:
+ # Purposely no try here so we'll raise a NotFound
+ group_dn = api.Object['group'].get_dn_if_exists(kw['targetgroup'])
+ target = 'ldap:///%s' % group_dn
+ a.set_target(target)
+ if valid['subtree']:
+ # See if the subtree is a full URI
+ target = kw['subtree']
+ if not target.startswith('ldap:///'):
+ target = 'ldap:///%s' % target
+ a.set_target(target)
+ except SyntaxError as e:
+ raise errors.ValidationError(name='target', error=_('Syntax Error: %(error)s') % dict(error=str(e)))
+
+ return a
+
+def _aci_to_kw(ldap, a, test=False, pkey_only=False):
+ """Convert an ACI into its equivalent keywords.
+
+ This is used for the modify operation so we can merge the
+ incoming kw and existing ACI and pass the result to
+ _make_aci().
+ """
+ kw = {}
+ kw['aciprefix'], kw['aciname'] = _parse_aci_name(a.name)
+ if pkey_only:
+ return kw
+ kw['permissions'] = tuple(a.permissions)
+ if 'targetattr' in a.target:
+ kw['attrs'] = tuple(unicode(e)
+ for e in a.target['targetattr']['expression'])
+ if 'targetfilter' in a.target:
+ target = a.target['targetfilter']['expression']
+ if target.startswith('(memberOf=') or target.startswith('memberOf='):
+ (junk, memberof) = target.split('memberOf=', 1)
+ memberof = DN(memberof)
+ kw['memberof'] = memberof['cn']
+ else:
+ kw['filter'] = unicode(target)
+ if 'target' in a.target:
+ target = a.target['target']['expression']
+ found = False
+ for k in _type_map.keys():
+ if _type_map[k] == target:
+ kw['type'] = unicode(k)
+ found = True
+ break
+ if not found:
+ if target.startswith('('):
+ kw['filter'] = unicode(target)
+ else:
+ # See if the target is a group. If so we set the
+ # targetgroup attr, otherwise we consider it a subtree
+ try:
+ targetdn = DN(target.replace('ldap:///',''))
+ except ValueError as e:
+ raise errors.ValidationError(name='subtree', error=_("invalid DN (%s)") % e.message)
+ if targetdn.endswith(DN(api.env.container_group, api.env.basedn)):
+ kw['targetgroup'] = targetdn[0]['cn']
+ else:
+ kw['subtree'] = unicode(target)
+
+ groupdn = a.bindrule['expression']
+ groupdn = groupdn.replace('ldap:///','')
+ if groupdn == 'self':
+ kw['selfaci'] = True
+ elif groupdn == 'anyone':
+ pass
+ else:
+ groupdn = DN(groupdn)
+ if len(groupdn) and groupdn[0].attr == 'cn':
+ dn = DN()
+ entry = ldap.make_entry(dn)
+ try:
+ entry = ldap.get_entry(groupdn, ['cn'])
+ except errors.NotFound as e:
+ # FIXME, use real name here
+ if test:
+ dn = DN(('cn', 'test'), api.env.container_permission,
+ api.env.basedn)
+ entry = ldap.make_entry(dn, {'cn': [u'test']})
+ if api.env.container_permission in entry.dn:
+ kw['permission'] = entry['cn'][0]
+ else:
+ if 'cn' in entry:
+ kw['group'] = entry['cn'][0]
+
+ return kw
+
+def _convert_strings_to_acis(acistrs):
+ acis = []
+ for a in acistrs:
+ try:
+ acis.append(ACI(a))
+ except SyntaxError as e:
+ root_logger.warning("Failed to parse: %s" % a)
+ return acis
+
+def _find_aci_by_name(acis, aciprefix, aciname):
+ name = _make_aci_name(aciprefix, aciname).lower()
+ for a in acis:
+ if a.name.lower() == name:
+ return a
+ raise errors.NotFound(reason=_('ACI with name "%s" not found') % aciname)
+
+
+def validate_permissions(ugettext, perm):
+ perm = perm.strip().lower()
+ if perm not in _valid_permissions_values:
+ return '"%s" is not a valid permission' % perm
+
+
+def _normalize_permissions(perm):
+ valid_permissions = []
+ perm = perm.strip().lower()
+ if perm not in valid_permissions:
+ valid_permissions.append(perm)
+ return ','.join(valid_permissions)
+
+_prefix_option = StrEnum('aciprefix',
+ cli_name='prefix',
+ label=_('ACI prefix'),
+ doc=_('Prefix used to distinguish ACI types ' \
+ '(permission, delegation, selfservice, none)'),
+ values=_valid_prefix_values,
+ )
+
+
+@register()
+class aci(Object):
+ """
+ ACI object.
+ """
+ NO_CLI = True
+
+ label = _('ACIs')
+
+ takes_params = (
+ Str('aciname',
+ cli_name='name',
+ label=_('ACI name'),
+ primary_key=True,
+ flags=('virtual_attribute',),
+ ),
+ Str('permission?',
+ cli_name='permission',
+ label=_('Permission'),
+ doc=_('Permission ACI grants access to'),
+ flags=('virtual_attribute',),
+ ),
+ Str('group?',
+ cli_name='group',
+ label=_('User group'),
+ doc=_('User group ACI grants access to'),
+ flags=('virtual_attribute',),
+ ),
+ Str('permissions+', validate_permissions,
+ cli_name='permissions',
+ label=_('Permissions'),
+ doc=_('Permissions to grant' \
+ '(read, write, add, delete, all)'),
+ normalizer=_normalize_permissions,
+ flags=('virtual_attribute',),
+ ),
+ Str('attrs*',
+ cli_name='attrs',
+ label=_('Attributes to which the permission applies'),
+ doc=_('Attributes'),
+ flags=('virtual_attribute',),
+ ),
+ StrEnum('type?',
+ cli_name='type',
+ label=_('Type'),
+ doc=_('type of IPA object (user, group, host, hostgroup, service, netgroup)'),
+ values=(u'user', u'group', u'host', u'service', u'hostgroup', u'netgroup', u'dnsrecord'),
+ flags=('virtual_attribute',),
+ ),
+ Str('memberof?',
+ cli_name='memberof',
+ label=_('Member of'), # FIXME: Does this label make sense?
+ doc=_('Member of a group'),
+ flags=('virtual_attribute',),
+ ),
+ Str('filter?',
+ cli_name='filter',
+ label=_('Filter'),
+ doc=_('Legal LDAP filter (e.g. ou=Engineering)'),
+ flags=('virtual_attribute',),
+ ),
+ Str('subtree?',
+ cli_name='subtree',
+ label=_('Subtree'),
+ doc=_('Subtree to apply ACI to'),
+ flags=('virtual_attribute',),
+ ),
+ Str('targetgroup?',
+ cli_name='targetgroup',
+ label=_('Target group'),
+ doc=_('Group to apply ACI to'),
+ flags=('virtual_attribute',),
+ ),
+ Flag('selfaci?',
+ cli_name='self',
+ label=_('Target your own entry (self)'),
+ doc=_('Apply ACI to your own entry (self)'),
+ flags=('virtual_attribute',),
+ ),
+ )
+
+
+@register()
+class aci_add(crud.Create):
+ """
+ Create new ACI.
+ """
+ NO_CLI = True
+ msg_summary = _('Created ACI "%(value)s"')
+
+ takes_options = (
+ _prefix_option,
+ Flag('test?',
+ doc=_('Test the ACI syntax but don\'t write anything'),
+ default=False,
+ ),
+ )
+
+ def execute(self, aciname, **kw):
+ """
+ Execute the aci-create operation.
+
+ Returns the entry as it will be created in LDAP.
+
+ :param aciname: The name of the ACI being added.
+ :param kw: Keyword arguments for the other LDAP attributes.
+ """
+ assert 'aciname' not in kw
+ ldap = self.api.Backend.ldap2
+
+ newaci = _make_aci(ldap, None, aciname, kw)
+
+ entry = ldap.get_entry(self.api.env.basedn, ['aci'])
+
+ acis = _convert_strings_to_acis(entry.get('aci', []))
+ for a in acis:
+ # FIXME: add check for permission_group = permission_group
+ if a.isequal(newaci) or newaci.name == a.name:
+ raise errors.DuplicateEntry()
+
+ newaci_str = unicode(newaci)
+ entry.setdefault('aci', []).append(newaci_str)
+
+ if not kw.get('test', False):
+ ldap.update_entry(entry)
+
+ if kw.get('raw', False):
+ result = dict(aci=unicode(newaci_str))
+ else:
+ result = _aci_to_kw(ldap, newaci, kw.get('test', False))
+ return dict(
+ result=result,
+ value=pkey_to_value(aciname, kw),
+ )
+
+
+@register()
+class aci_del(crud.Delete):
+ """
+ Delete ACI.
+ """
+ NO_CLI = True
+ has_output = output.standard_boolean
+ msg_summary = _('Deleted ACI "%(value)s"')
+
+ takes_options = (_prefix_option,)
+
+ def execute(self, aciname, aciprefix, **options):
+ """
+ Execute the aci-delete operation.
+
+ :param aciname: The name of the ACI being deleted.
+ :param aciprefix: The ACI prefix.
+ """
+ ldap = self.api.Backend.ldap2
+
+ entry = ldap.get_entry(self.api.env.basedn, ['aci'])
+
+ acistrs = entry.get('aci', [])
+ acis = _convert_strings_to_acis(acistrs)
+ aci = _find_aci_by_name(acis, aciprefix, aciname)
+ for a in acistrs:
+ candidate = ACI(a)
+ if aci.isequal(candidate):
+ acistrs.remove(a)
+ break
+
+ entry['aci'] = acistrs
+
+ ldap.update_entry(entry)
+
+ return dict(
+ result=True,
+ value=pkey_to_value(aciname, options),
+ )
+
+
+@register()
+class aci_mod(crud.Update):
+ """
+ Modify ACI.
+ """
+ NO_CLI = True
+ has_output_params = (
+ Str('aci',
+ label=_('ACI'),
+ ),
+ )
+
+ takes_options = (_prefix_option,)
+
+ internal_options = ['rename']
+
+ msg_summary = _('Modified ACI "%(value)s"')
+
+ def execute(self, aciname, **kw):
+ aciprefix = kw['aciprefix']
+ ldap = self.api.Backend.ldap2
+
+ entry = ldap.get_entry(self.api.env.basedn, ['aci'])
+
+ acis = _convert_strings_to_acis(entry.get('aci', []))
+ aci = _find_aci_by_name(acis, aciprefix, aciname)
+
+ # The strategy here is to convert the ACI we're updating back into
+ # a series of keywords. Then we replace any keywords that have been
+ # updated and convert that back into an ACI and write it out.
+ oldkw = _aci_to_kw(ldap, aci)
+ newkw = deepcopy(oldkw)
+ if newkw.get('selfaci', False):
+ # selfaci is set in aci_to_kw to True only if the target is self
+ kw['selfaci'] = True
+ newkw.update(kw)
+ for acikw in (oldkw, newkw):
+ acikw.pop('aciname', None)
+
+ # _make_aci is what is run in aci_add and validates the input.
+ # Do this before we delete the existing ACI.
+ newaci = _make_aci(ldap, None, aciname, newkw)
+ if aci.isequal(newaci):
+ raise errors.EmptyModlist()
+
+ self.api.Command['aci_del'](aciname, aciprefix=aciprefix)
+
+ try:
+ result = self.api.Command['aci_add'](aciname, **newkw)['result']
+ except Exception as e:
+ # ACI could not be added, try to restore the old deleted ACI and
+ # report the ADD error back to user
+ try:
+ self.api.Command['aci_add'](aciname, **oldkw)
+ except Exception:
+ pass
+ raise e
+
+ if kw.get('raw', False):
+ result = dict(aci=unicode(newaci))
+ else:
+ result = _aci_to_kw(ldap, newaci)
+ return dict(
+ result=result,
+ value=pkey_to_value(aciname, kw),
+ )
+
+
+@register()
+class aci_find(crud.Search):
+ """
+ Search for ACIs.
+
+ Returns a list of ACIs
+
+ EXAMPLES:
+
+ To find all ACIs that apply directly to members of the group ipausers:
+ ipa aci-find --memberof=ipausers
+
+ To find all ACIs that grant add access:
+ ipa aci-find --permissions=add
+
+ Note that the find command only looks for the given text in the set of
+ ACIs, it does not evaluate the ACIs to see if something would apply.
+ For example, searching on memberof=ipausers will find all ACIs that
+ have ipausers as a memberof. There may be other ACIs that apply to
+ members of that group indirectly.
+ """
+ NO_CLI = True
+ msg_summary = ngettext('%(count)d ACI matched', '%(count)d ACIs matched', 0)
+
+ takes_options = (_prefix_option.clone_rename("aciprefix?", required=False),
+ gen_pkey_only_option("name"),)
+
+ def execute(self, term=None, **kw):
+ ldap = self.api.Backend.ldap2
+
+ entry = ldap.get_entry(self.api.env.basedn, ['aci'])
+
+ acis = _convert_strings_to_acis(entry.get('aci', []))
+ results = []
+
+ if term:
+ term = term.lower()
+ for a in acis:
+ if a.name.lower().find(term) != -1 and a not in results:
+ results.append(a)
+ acis = list(results)
+ else:
+ results = list(acis)
+
+ if kw.get('aciname'):
+ for a in acis:
+ prefix, name = _parse_aci_name(a.name)
+ if name != kw['aciname']:
+ results.remove(a)
+ acis = list(results)
+
+ if kw.get('aciprefix'):
+ for a in acis:
+ prefix, name = _parse_aci_name(a.name)
+ if prefix != kw['aciprefix']:
+ results.remove(a)
+ acis = list(results)
+
+ if kw.get('attrs'):
+ for a in acis:
+ if not 'targetattr' in a.target:
+ results.remove(a)
+ continue
+ alist1 = sorted(
+ [t.lower() for t in a.target['targetattr']['expression']]
+ )
+ alist2 = sorted([t.lower() for t in kw['attrs']])
+ if len(set(alist1) & set(alist2)) != len(alist2):
+ results.remove(a)
+ acis = list(results)
+
+ if kw.get('permission'):
+ try:
+ self.api.Command['permission_show'](
+ kw['permission']
+ )
+ except errors.NotFound:
+ pass
+ else:
+ for a in acis:
+ uri = 'ldap:///%s' % entry.dn
+ if a.bindrule['expression'] != uri:
+ results.remove(a)
+ acis = list(results)
+
+ if kw.get('permissions'):
+ for a in acis:
+ alist1 = sorted(a.permissions)
+ alist2 = sorted(kw['permissions'])
+ if len(set(alist1) & set(alist2)) != len(alist2):
+ results.remove(a)
+ acis = list(results)
+
+ if kw.get('memberof'):
+ try:
+ dn = _group_from_memberof(kw['memberof'])
+ except errors.NotFound:
+ pass
+ else:
+ memberof_filter = '(memberOf=%s)' % dn
+ for a in acis:
+ if 'targetfilter' in a.target:
+ targetfilter = a.target['targetfilter']['expression']
+ if targetfilter != memberof_filter:
+ results.remove(a)
+ else:
+ results.remove(a)
+
+ if kw.get('type'):
+ for a in acis:
+ if 'target' in a.target:
+ target = a.target['target']['expression']
+ else:
+ results.remove(a)
+ continue
+ found = False
+ for k in _type_map.keys():
+ if _type_map[k] == target and kw['type'] == k:
+ found = True
+ break
+ if not found:
+ try:
+ results.remove(a)
+ except ValueError:
+ pass
+
+ if kw.get('selfaci', False) is True:
+ for a in acis:
+ if a.bindrule['expression'] != u'ldap:///self':
+ try:
+ results.remove(a)
+ except ValueError:
+ pass
+
+ if kw.get('group'):
+ for a in acis:
+ groupdn = a.bindrule['expression']
+ groupdn = DN(groupdn.replace('ldap:///',''))
+ try:
+ cn = groupdn[0]['cn']
+ except (IndexError, KeyError):
+ cn = None
+ if cn is None or cn != kw['group']:
+ try:
+ results.remove(a)
+ except ValueError:
+ pass
+
+ if kw.get('targetgroup'):
+ for a in acis:
+ found = False
+ if 'target' in a.target:
+ target = a.target['target']['expression']
+ targetdn = DN(target.replace('ldap:///',''))
+ group_container_dn = DN(api.env.container_group, api.env.basedn)
+ if targetdn.endswith(group_container_dn):
+ try:
+ cn = targetdn[0]['cn']
+ except (IndexError, KeyError):
+ cn = None
+ if cn == kw['targetgroup']:
+ found = True
+ if not found:
+ try:
+ results.remove(a)
+ except ValueError:
+ pass
+
+ if kw.get('filter'):
+ if not kw['filter'].startswith('('):
+ kw['filter'] = unicode('('+kw['filter']+')')
+ for a in acis:
+ if 'targetfilter' not in a.target or\
+ not a.target['targetfilter']['expression'] or\
+ a.target['targetfilter']['expression'] != kw['filter']:
+ results.remove(a)
+
+ if kw.get('subtree'):
+ for a in acis:
+ if 'target' in a.target:
+ target = a.target['target']['expression']
+ else:
+ results.remove(a)
+ continue
+ if kw['subtree'].lower() != target.lower():
+ try:
+ results.remove(a)
+ except ValueError:
+ pass
+
+ acis = []
+ for result in results:
+ if kw.get('raw', False):
+ aci = dict(aci=unicode(result))
+ else:
+ aci = _aci_to_kw(ldap, result,
+ pkey_only=kw.get('pkey_only', False))
+ acis.append(aci)
+
+ return dict(
+ result=acis,
+ count=len(acis),
+ truncated=False,
+ )
+
+
+@register()
+class aci_show(crud.Retrieve):
+ """
+ Display a single ACI given an ACI name.
+ """
+ NO_CLI = True
+
+ has_output_params = (
+ Str('aci',
+ label=_('ACI'),
+ ),
+ )
+
+ takes_options = (
+ _prefix_option,
+ DNParam('location?',
+ label=_('Location of the ACI'),
+ )
+ )
+
+ def execute(self, aciname, **kw):
+ """
+ Execute the aci-show operation.
+
+ Returns the entry
+
+ :param uid: The login name of the user to retrieve.
+ :param kw: unused
+ """
+ ldap = self.api.Backend.ldap2
+
+ dn = kw.get('location', self.api.env.basedn)
+ entry = ldap.get_entry(dn, ['aci'])
+
+ acis = _convert_strings_to_acis(entry.get('aci', []))
+
+ aci = _find_aci_by_name(acis, kw['aciprefix'], aciname)
+ if kw.get('raw', False):
+ result = dict(aci=unicode(aci))
+ else:
+ result = _aci_to_kw(ldap, aci)
+ return dict(
+ result=result,
+ value=pkey_to_value(aciname, kw),
+ )
+
+
+@register()
+class aci_rename(crud.Update):
+ """
+ Rename an ACI.
+ """
+ NO_CLI = True
+ has_output_params = (
+ Str('aci',
+ label=_('ACI'),
+ ),
+ )
+
+ takes_options = (
+ _prefix_option,
+ Str('newname',
+ doc=_('New ACI name'),
+ ),
+ )
+
+ msg_summary = _('Renamed ACI to "%(value)s"')
+
+ def execute(self, aciname, **kw):
+ ldap = self.api.Backend.ldap2
+
+ entry = ldap.get_entry(self.api.env.basedn, ['aci'])
+
+ acis = _convert_strings_to_acis(entry.get('aci', []))
+ aci = _find_aci_by_name(acis, kw['aciprefix'], aciname)
+
+ for a in acis:
+ prefix, name = _parse_aci_name(a.name)
+ if _make_aci_name(prefix, kw['newname']) == a.name:
+ raise errors.DuplicateEntry()
+
+ # The strategy here is to convert the ACI we're updating back into
+ # a series of keywords. Then we replace any keywords that have been
+ # updated and convert that back into an ACI and write it out.
+ newkw = _aci_to_kw(ldap, aci)
+ if 'selfaci' in newkw and newkw['selfaci'] == True:
+ # selfaci is set in aci_to_kw to True only if the target is self
+ kw['selfaci'] = True
+ if 'aciname' in newkw:
+ del newkw['aciname']
+
+ # _make_aci is what is run in aci_add and validates the input.
+ # Do this before we delete the existing ACI.
+ newaci = _make_aci(ldap, None, kw['newname'], newkw)
+
+ self.api.Command['aci_del'](aciname, aciprefix=kw['aciprefix'])
+
+ result = self.api.Command['aci_add'](kw['newname'], **newkw)['result']
+
+ if kw.get('raw', False):
+ result = dict(aci=unicode(newaci))
+ else:
+ result = _aci_to_kw(ldap, newaci)
+ return dict(
+ result=result,
+ value=pkey_to_value(kw['newname'], kw),
+ )
diff --git a/ipaserver/plugins/automember.py b/ipaserver/plugins/automember.py
new file mode 100644
index 000000000..89b9dfadc
--- /dev/null
+++ b/ipaserver/plugins/automember.py
@@ -0,0 +1,802 @@
+# Authors:
+# Jr Aquino <jr.aquino@citrix.com>
+#
+# Copyright (C) 2011 Red Hat
+# see file 'COPYING' for use and warranty information
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+import uuid
+import time
+
+import ldap as _ldap
+import six
+
+from ipalib import api, errors, Str, StrEnum, DNParam, Flag, _, ngettext
+from ipalib import output, Command
+from ipalib.plugable import Registry
+from .baseldap import (
+ pkey_to_value,
+ entry_to_dict,
+ LDAPObject,
+ LDAPCreate,
+ LDAPUpdate,
+ LDAPDelete,
+ LDAPSearch,
+ LDAPRetrieve)
+from ipalib.request import context
+from ipapython.dn import DN
+
+if six.PY3:
+ unicode = str
+
+__doc__ = _("""
+Auto Membership Rule.
+""") + _("""
+Bring clarity to the membership of hosts and users by configuring inclusive
+or exclusive regex patterns, you can automatically assign a new entries into
+a group or hostgroup based upon attribute information.
+""") + _("""
+A rule is directly associated with a group by name, so you cannot create
+a rule without an accompanying group or hostgroup.
+""") + _("""
+A condition is a regular expression used by 389-ds to match a new incoming
+entry with an automember rule. If it matches an inclusive rule then the
+entry is added to the appropriate group or hostgroup.
+""") + _("""
+A default group or hostgroup could be specified for entries that do not
+match any rule. In case of user entries this group will be a fallback group
+because all users are by default members of group specified in IPA config.
+""") + _("""
+The automember-rebuild command can be used to retroactively run automember rules
+against existing entries, thus rebuilding their membership.
+""") + _("""
+EXAMPLES:
+""") + _("""
+ Add the initial group or hostgroup:
+ ipa hostgroup-add --desc="Web Servers" webservers
+ ipa group-add --desc="Developers" devel
+""") + _("""
+ Add the initial rule:
+ ipa automember-add --type=hostgroup webservers
+ ipa automember-add --type=group devel
+""") + _("""
+ Add a condition to the rule:
+ ipa automember-add-condition --key=fqdn --type=hostgroup --inclusive-regex=^web[1-9]+\.example\.com webservers
+ ipa automember-add-condition --key=manager --type=group --inclusive-regex=^uid=mscott devel
+""") + _("""
+ Add an exclusive condition to the rule to prevent auto assignment:
+ ipa automember-add-condition --key=fqdn --type=hostgroup --exclusive-regex=^web5\.example\.com webservers
+""") + _("""
+ Add a host:
+ ipa host-add web1.example.com
+""") + _("""
+ Add a user:
+ ipa user-add --first=Tim --last=User --password tuser1 --manager=mscott
+""") + _("""
+ Verify automembership:
+ ipa hostgroup-show webservers
+ Host-group: webservers
+ Description: Web Servers
+ Member hosts: web1.example.com
+
+ ipa group-show devel
+ Group name: devel
+ Description: Developers
+ GID: 1004200000
+ Member users: tuser
+""") + _("""
+ Remove a condition from the rule:
+ ipa automember-remove-condition --key=fqdn --type=hostgroup --inclusive-regex=^web[1-9]+\.example\.com webservers
+""") + _("""
+ Modify the automember rule:
+ ipa automember-mod
+""") + _("""
+ Set the default (fallback) target group:
+ ipa automember-default-group-set --default-group=webservers --type=hostgroup
+ ipa automember-default-group-set --default-group=ipausers --type=group
+""") + _("""
+ Remove the default (fallback) target group:
+ ipa automember-default-group-remove --type=hostgroup
+ ipa automember-default-group-remove --type=group
+""") + _("""
+ Show the default (fallback) target group:
+ ipa automember-default-group-show --type=hostgroup
+ ipa automember-default-group-show --type=group
+""") + _("""
+ Find all of the automember rules:
+ ipa automember-find
+""") + _("""
+ Display a automember rule:
+ ipa automember-show --type=hostgroup webservers
+ ipa automember-show --type=group devel
+""") + _("""
+ Delete an automember rule:
+ ipa automember-del --type=hostgroup webservers
+ ipa automember-del --type=group devel
+""") + _("""
+ Rebuild membership for all users:
+ ipa automember-rebuild --type=group
+""") + _("""
+ Rebuild membership for all hosts:
+ ipa automember-rebuild --type=hostgroup
+""") + _("""
+ Rebuild membership for specified users:
+ ipa automember-rebuild --users=tuser1 --users=tuser2
+""") + _("""
+ Rebuild membership for specified hosts:
+ ipa automember-rebuild --hosts=web1.example.com --hosts=web2.example.com
+""")
+
+register = Registry()
+
+# Options used by Condition Add and Remove.
+INCLUDE_RE = 'automemberinclusiveregex'
+EXCLUDE_RE = 'automemberexclusiveregex'
+
+REBUILD_TASK_CONTAINER = DN(('cn', 'automember rebuild membership'),
+ ('cn', 'tasks'),
+ ('cn', 'config'))
+
+
+regex_attrs = (
+ Str('automemberinclusiveregex*',
+ cli_name='inclusive_regex',
+ label=_('Inclusive Regex'),
+ doc=_('Inclusive Regex'),
+ alwaysask=True,
+ ),
+ Str('automemberexclusiveregex*',
+ cli_name='exclusive_regex',
+ label=_('Exclusive Regex'),
+ doc=_('Exclusive Regex'),
+ alwaysask=True,
+ ),
+ Str('key',
+ label=_('Attribute Key'),
+ doc=_('Attribute to filter via regex. For example fqdn for a host, or manager for a user'),
+ flags=['no_create', 'no_update', 'no_search']
+ ),
+)
+
+group_type = (
+ StrEnum('type',
+ label=_('Grouping Type'),
+ doc=_('Grouping to which the rule applies'),
+ values=(u'group', u'hostgroup', ),
+ ),
+)
+
+automember_rule = (
+ Str('cn',
+ cli_name='automember_rule',
+ label=_('Automember Rule'),
+ doc=_('Automember Rule'),
+ normalizer=lambda value: value.lower(),
+ ),
+)
+
+
+@register()
+class automember(LDAPObject):
+
+ """
+ Bring automember to a hostgroup with an Auto Membership Rule.
+ """
+
+ container_dn = api.env.container_automember
+
+ object_name = 'Automember rule'
+ object_name_plural = 'Automember rules'
+ object_class = ['top', 'automemberregexrule']
+ permission_filter_objectclasses = ['automemberregexrule']
+ default_attributes = [
+ 'automemberinclusiveregex', 'automemberexclusiveregex',
+ 'cn', 'automembertargetgroup', 'description', 'automemberdefaultgroup'
+ ]
+ managed_permissions = {
+ 'System: Read Automember Definitions': {
+ 'non_object': True,
+ 'ipapermlocation': DN(container_dn, api.env.basedn),
+ 'ipapermtargetfilter': {'(objectclass=automemberdefinition)'},
+ 'replaces_global_anonymous_aci': True,
+ 'ipapermright': {'read', 'search', 'compare'},
+ 'ipapermdefaultattr': {
+ 'objectclass', 'cn', 'automemberscope', 'automemberfilter',
+ 'automembergroupingattr', 'automemberdefaultgroup',
+ 'automemberdisabled',
+ },
+ 'default_privileges': {'Automember Readers',
+ 'Automember Task Administrator'},
+ },
+ 'System: Read Automember Rules': {
+ 'replaces_global_anonymous_aci': True,
+ 'ipapermright': {'read', 'search', 'compare'},
+ 'ipapermdefaultattr': {
+ 'cn', 'objectclass', 'automembertargetgroup', 'description',
+ 'automemberexclusiveregex', 'automemberinclusiveregex',
+ },
+ 'default_privileges': {'Automember Readers',
+ 'Automember Task Administrator'},
+ },
+ 'System: Read Automember Tasks': {
+ 'non_object': True,
+ 'ipapermlocation': DN('cn=tasks', 'cn=config'),
+ 'ipapermtarget': DN('cn=*', REBUILD_TASK_CONTAINER),
+ 'replaces_global_anonymous_aci': True,
+ 'ipapermright': {'read', 'search', 'compare'},
+ 'ipapermdefaultattr': {'*'},
+ 'default_privileges': {'Automember Task Administrator'},
+ },
+ }
+
+ label = _('Auto Membership Rule')
+
+ takes_params = (
+ Str('description?',
+ cli_name='desc',
+ label=_('Description'),
+ doc=_('A description of this auto member rule'),
+ ),
+ Str('automemberdefaultgroup?',
+ cli_name='default_group',
+ label=_('Default (fallback) Group'),
+ doc=_('Default group for entries to land'),
+ flags=['no_create', 'no_update', 'no_search']
+ ),
+ )
+
+ def dn_exists(self, otype, oname):
+ ldap = self.api.Backend.ldap2
+ dn = self.api.Object[otype].get_dn(oname)
+ try:
+ entry = ldap.get_entry(dn, [])
+ except errors.NotFound:
+ raise errors.NotFound(
+ reason=_(u'%(otype)s "%(oname)s" not found') %
+ dict(otype=otype, oname=oname)
+ )
+ return entry.dn
+
+ def get_dn(self, *keys, **options):
+ if self.parent_object:
+ parent_dn = self.api.Object[self.parent_object].get_dn(*keys[:-1])
+ else:
+ parent_dn = DN(self.container_dn, api.env.basedn)
+ grouptype = options['type']
+ try:
+ ndn = DN(('cn', keys[-1]), ('cn', grouptype), parent_dn)
+ except IndexError:
+ ndn = DN(('cn', grouptype), parent_dn)
+ return ndn
+
+ def check_attr(self, attr):
+ """
+ Verify that the user supplied key is a valid attribute in the schema
+ """
+ ldap = self.api.Backend.ldap2
+ obj = ldap.schema.get_obj(_ldap.schema.AttributeType, attr)
+ if obj is not None:
+ return obj
+ else:
+ raise errors.NotFound(reason=_('%s is not a valid attribute.') % attr)
+
+
+def automember_container_exists(ldap):
+ try:
+ ldap.get_entry(DN(api.env.container_automember, api.env.basedn), [])
+ except errors.NotFound:
+ return False
+ return True
+
+
+@register()
+class automember_add(LDAPCreate):
+ __doc__ = _("""
+ Add an automember rule.
+ """)
+ takes_options = LDAPCreate.takes_options + group_type
+ takes_args = automember_rule
+ msg_summary = _('Added automember rule "%(value)s"')
+
+ def pre_callback(self, ldap, dn, entry_attrs, *keys, **options):
+ assert isinstance(dn, DN)
+
+ entry_attrs['cn'] = keys[-1]
+ if not automember_container_exists(self.api.Backend.ldap2):
+ raise errors.NotFound(reason=_('Auto Membership is not configured'))
+ entry_attrs['automembertargetgroup'] = self.obj.dn_exists(options['type'], keys[-1])
+ return dn
+
+ def execute(self, *keys, **options):
+ result = super(automember_add, self).execute(*keys, **options)
+ result['value'] = pkey_to_value(keys[-1], options)
+ return result
+
+
+@register()
+class automember_add_condition(LDAPUpdate):
+ __doc__ = _("""
+ Add conditions to an automember rule.
+ """)
+ has_output_params = (
+ Str('failed',
+ label=_('Failed to add'),
+ flags=['suppress_empty'],
+ ),
+ )
+
+ takes_options = regex_attrs + group_type
+ takes_args = automember_rule
+ msg_summary = _('Added condition(s) to "%(value)s"')
+
+ # Prepare the output to expect failed results
+ has_output = (
+ output.summary,
+ output.Entry('result'),
+ output.value,
+ output.Output('failed',
+ type=dict,
+ doc=_('Conditions that could not be added'),
+ ),
+ output.Output('completed',
+ type=int,
+ doc=_('Number of conditions added'),
+ ),
+ )
+
+ def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options):
+ assert isinstance(dn, DN)
+ # Check to see if the automember rule exists
+ try:
+ dn = ldap.get_entry(dn, []).dn
+ except errors.NotFound:
+ raise errors.NotFound(reason=_(u'Auto member rule: %s not found!') % keys[0])
+ # Define container key
+ key = options['key']
+ # Check to see if the attribute is valid
+ self.obj.check_attr(key)
+
+ key = '%s=' % key
+ completed = 0
+ failed = {'failed': {}}
+
+ for attr in (INCLUDE_RE, EXCLUDE_RE):
+ failed['failed'][attr] = []
+ if attr in options and options[attr]:
+ entry_attrs[attr] = [key + condition for condition in options[attr]]
+ completed += len(entry_attrs[attr])
+ try:
+ old_entry = ldap.get_entry(dn, [attr])
+ for regex in old_entry.keys():
+ if not isinstance(entry_attrs[regex], (list, tuple)):
+ entry_attrs[regex] = [entry_attrs[regex]]
+ duplicate = set(old_entry[regex]) & set(entry_attrs[regex])
+ if len(duplicate) > 0:
+ completed -= 1
+ else:
+ entry_attrs[regex] = list(entry_attrs[regex]) + old_entry[regex]
+ except errors.NotFound:
+ failed['failed'][attr].append(regex)
+
+ entry_attrs = entry_to_dict(entry_attrs, **options)
+
+ # Set failed and completed to they can be harvested in the execute super
+ setattr(context, 'failed', failed)
+ setattr(context, 'completed', completed)
+ setattr(context, 'entry_attrs', entry_attrs)
+
+ # Make sure to returned the failed results if there is nothing to remove
+ if completed == 0:
+ ldap.get_entry(dn, attrs_list)
+ raise errors.EmptyModlist
+ return dn
+
+ def execute(self, *keys, **options):
+ __doc__ = _("""
+ Override this so we can add completed and failed to the return result.
+ """)
+ try:
+ result = super(automember_add_condition, self).execute(*keys, **options)
+ except errors.EmptyModlist:
+ result = {'result': getattr(context, 'entry_attrs'), 'value': keys[-1]}
+ result['failed'] = getattr(context, 'failed')
+ result['completed'] = getattr(context, 'completed')
+ result['value'] = pkey_to_value(keys[-1], options)
+ return result
+
+
+@register()
+class automember_remove_condition(LDAPUpdate):
+ __doc__ = _("""
+ Remove conditions from an automember rule.
+ """)
+ takes_options = regex_attrs + group_type
+ takes_args = automember_rule
+ msg_summary = _('Removed condition(s) from "%(value)s"')
+
+ # Prepare the output to expect failed results
+ has_output = (
+ output.summary,
+ output.Entry('result'),
+ output.value,
+ output.Output('failed',
+ type=dict,
+ doc=_('Conditions that could not be removed'),
+ ),
+ output.Output('completed',
+ type=int,
+ doc=_('Number of conditions removed'),
+ ),
+ )
+
+ def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options):
+ assert isinstance(dn, DN)
+ # Check to see if the automember rule exists
+ try:
+ ldap.get_entry(dn, [])
+ except errors.NotFound:
+ raise errors.NotFound(reason=_(u'Auto member rule: %s not found!') % keys[0])
+
+ # Define container key
+ type_attr_default = {'group': 'manager', 'hostgroup': 'fqdn'}
+ if 'key' in options:
+ key = options['key']
+ else:
+ key = type_attr_default[options['type']]
+
+ key = '%s=' % key
+ completed = 0
+ failed = {'failed': {}}
+
+ # Check to see if there are existing exclusive conditions present.
+ dn = ldap.get_entry(dn, [EXCLUDE_RE]).dn
+
+ for attr in (INCLUDE_RE, EXCLUDE_RE):
+ failed['failed'][attr] = []
+ if attr in options and options[attr]:
+ entry_attrs[attr] = [key + condition for condition in options[attr]]
+ entry_attrs_ = ldap.get_entry(dn, [attr])
+ old_entry = entry_attrs_.get(attr, [])
+ for regex in entry_attrs[attr]:
+ if regex in old_entry:
+ old_entry.remove(regex)
+ completed += 1
+ else:
+ failed['failed'][attr].append(regex)
+ entry_attrs[attr] = old_entry
+
+ entry_attrs = entry_to_dict(entry_attrs, **options)
+
+ # Set failed and completed to they can be harvested in the execute super
+ setattr(context, 'failed', failed)
+ setattr(context, 'completed', completed)
+ setattr(context, 'entry_attrs', entry_attrs)
+
+ # Make sure to returned the failed results if there is nothing to remove
+ if completed == 0:
+ ldap.get_entry(dn, attrs_list)
+ raise errors.EmptyModlist
+ return dn
+
+ def execute(self, *keys, **options):
+ __doc__ = _("""
+ Override this so we can set completed and failed.
+ """)
+ try:
+ result = super(automember_remove_condition, self).execute(*keys, **options)
+ except errors.EmptyModlist:
+ result = {'result': getattr(context, 'entry_attrs'), 'value': keys[-1]}
+ result['failed'] = getattr(context, 'failed')
+ result['completed'] = getattr(context, 'completed')
+ result['value'] = pkey_to_value(keys[-1], options)
+ return result
+
+
+@register()
+class automember_mod(LDAPUpdate):
+ __doc__ = _("""
+ Modify an automember rule.
+ """)
+ takes_args = automember_rule
+ takes_options = LDAPUpdate.takes_options + group_type
+ msg_summary = _('Modified automember rule "%(value)s"')
+
+ def execute(self, *keys, **options):
+ result = super(automember_mod, self).execute(*keys, **options)
+ result['value'] = pkey_to_value(keys[-1], options)
+ return result
+
+
+@register()
+class automember_del(LDAPDelete):
+ __doc__ = _("""
+ Delete an automember rule.
+ """)
+ takes_args = automember_rule
+ takes_options = group_type
+ msg_summary = _('Deleted automember rule "%(value)s"')
+
+ def execute(self, *keys, **options):
+ result = super(automember_del, self).execute(*keys, **options)
+ result['value'] = pkey_to_value([keys[-1]], options)
+ return result
+
+
+@register()
+class automember_find(LDAPSearch):
+ __doc__ = _("""
+ Search for automember rules.
+ """)
+ takes_options = group_type
+ has_output_params = LDAPSearch.has_output_params + automember_rule + regex_attrs
+
+ msg_summary = ngettext(
+ '%(count)d rules matched', '%(count)d rules matched', 0
+ )
+
+ def pre_callback(self, ldap, filters, attrs_list, base_dn, scope, *args, **options):
+ assert isinstance(base_dn, DN)
+ scope = ldap.SCOPE_SUBTREE
+ ndn = DN(('cn', options['type']), base_dn)
+ return (filters, ndn, scope)
+
+
+@register()
+class automember_show(LDAPRetrieve):
+ __doc__ = _("""
+ Display information about an automember rule.
+ """)
+ takes_args = automember_rule
+ takes_options = group_type
+ has_output_params = LDAPRetrieve.has_output_params + regex_attrs
+
+ def execute(self, *keys, **options):
+ result = super(automember_show, self).execute(*keys, **options)
+ result['value'] = pkey_to_value(keys[-1], options)
+ return result
+
+
+@register()
+class automember_default_group_set(LDAPUpdate):
+ __doc__ = _("""
+ Set default (fallback) group for all unmatched entries.
+ """)
+
+ takes_options = (
+ Str('automemberdefaultgroup',
+ cli_name='default_group',
+ label=_('Default (fallback) Group'),
+ doc=_('Default (fallback) group for entries to land'),
+ flags=['no_create', 'no_update']
+ ),
+ ) + group_type
+ msg_summary = _('Set default (fallback) group for automember "%(value)s"')
+
+ def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options):
+ dn = DN(('cn', options['type']), api.env.container_automember,
+ api.env.basedn)
+ entry_attrs['automemberdefaultgroup'] = self.obj.dn_exists(options['type'], options['automemberdefaultgroup'])
+ return dn
+
+ def execute(self, *keys, **options):
+ result = super(automember_default_group_set, self).execute(*keys, **options)
+ result['value'] = pkey_to_value(options['type'], options)
+ return result
+
+
+@register()
+class automember_default_group_remove(LDAPUpdate):
+ __doc__ = _("""
+ Remove default (fallback) group for all unmatched entries.
+ """)
+
+ takes_options = group_type
+ msg_summary = _('Removed default (fallback) group for automember "%(value)s"')
+
+ def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options):
+ dn = DN(('cn', options['type']), api.env.container_automember,
+ api.env.basedn)
+ attr = 'automemberdefaultgroup'
+
+ entry_attrs_ = ldap.get_entry(dn, [attr])
+
+ if attr not in entry_attrs_:
+ raise errors.NotFound(reason=_(u'No default (fallback) group set'))
+ else:
+ entry_attrs[attr] = []
+ return entry_attrs_.dn
+
+ def post_callback(self, ldap, dn, entry_attrs, *keys, **options):
+ assert isinstance(dn, DN)
+ if 'automemberdefaultgroup' not in entry_attrs:
+ entry_attrs['automemberdefaultgroup'] = unicode(_('No default (fallback) group set'))
+ return dn
+
+ def execute(self, *keys, **options):
+ result = super(automember_default_group_remove, self).execute(*keys, **options)
+ result['value'] = pkey_to_value(options['type'], options)
+ return result
+
+
+@register()
+class automember_default_group_show(LDAPRetrieve):
+ __doc__ = _("""
+ Display information about the default (fallback) automember groups.
+ """)
+ takes_options = group_type
+
+ def pre_callback(self, ldap, dn, attrs_list, *keys, **options):
+ dn = DN(('cn', options['type']), api.env.container_automember,
+ api.env.basedn)
+ return dn
+
+ def post_callback(self, ldap, dn, entry_attrs, *keys, **options):
+ assert isinstance(dn, DN)
+ if 'automemberdefaultgroup' not in entry_attrs:
+ entry_attrs['automemberdefaultgroup'] = unicode(_('No default (fallback) group set'))
+ return dn
+
+ def execute(self, *keys, **options):
+ result = super(automember_default_group_show, self).execute(*keys, **options)
+ result['value'] = pkey_to_value(options['type'], options)
+ return result
+
+
+@register()
+class automember_rebuild(Command):
+ __doc__ = _('Rebuild auto membership.')
+ # TODO: Add a --dry-run option:
+ # https://fedorahosted.org/freeipa/ticket/3936
+ takes_options = (
+ group_type[0].clone(
+ required=False,
+ label=_('Rebuild membership for all members of a grouping')
+ ),
+ Str(
+ 'users*',
+ label=_('Users'),
+ doc=_('Rebuild membership for specified users'),
+ ),
+ Str(
+ 'hosts*',
+ label=_('Hosts'),
+ doc=_('Rebuild membership for specified hosts'),
+ ),
+ Flag(
+ 'no_wait?',
+ default=False,
+ label=_('No wait'),
+ doc=_("Don't wait for rebuilding membership"),
+ ),
+ )
+ has_output = output.standard_entry
+ has_output_params = (
+ DNParam(
+ 'dn',
+ label=_('Task DN'),
+ doc=_('DN of the started task'),
+ ),
+ )
+
+ def validate(self, **kw):
+ """
+ Validation rules:
+ - at least one of 'type', 'users', 'hosts' is required
+ - 'users' and 'hosts' cannot be combined together
+ - if 'users' and 'type' are specified, 'type' must be 'group'
+ - if 'hosts' and 'type' are specified, 'type' must be 'hostgroup'
+ """
+ super(automember_rebuild, self).validate(**kw)
+ users, hosts, gtype = kw.get('users'), kw.get('hosts'), kw.get('type')
+
+ if not (gtype or users or hosts):
+ raise errors.MutuallyExclusiveError(
+ reason=_('at least one of options: type, users, hosts must be '
+ 'specified')
+ )
+
+ if users and hosts:
+ raise errors.MutuallyExclusiveError(
+ reason=_("users and hosts cannot both be set")
+ )
+ if gtype == 'group' and hosts:
+ raise errors.MutuallyExclusiveError(
+ reason=_("hosts cannot be set when type is 'group'")
+ )
+ if gtype == 'hostgroup' and users:
+ raise errors.MutuallyExclusiveError(
+ reason=_("users cannot be set when type is 'hostgroup'")
+ )
+
+ def execute(self, *keys, **options):
+ ldap = self.api.Backend.ldap2
+ cn = str(uuid.uuid4())
+
+ gtype = options.get('type')
+ if not gtype:
+ gtype = 'group' if options.get('users') else 'hostgroup'
+
+ types = {
+ 'group': (
+ 'user',
+ 'users',
+ DN(api.env.container_user, api.env.basedn)
+ ),
+ 'hostgroup': (
+ 'host',
+ 'hosts',
+ DN(api.env.container_host, api.env.basedn)
+ ),
+ }
+
+ obj_name, opt_name, basedn = types[gtype]
+ obj = self.api.Object[obj_name]
+
+ names = options.get(opt_name)
+ if names:
+ for name in names:
+ try:
+ obj.get_dn_if_exists(name)
+ except errors.NotFound:
+ obj.handle_not_found(name)
+ search_filter = ldap.make_filter_from_attr(
+ obj.primary_key.name,
+ names,
+ rules=ldap.MATCH_ANY
+ )
+ else:
+ search_filter = '(%s=*)' % obj.primary_key.name
+
+ task_dn = DN(('cn', cn), REBUILD_TASK_CONTAINER)
+
+ entry = ldap.make_entry(
+ task_dn,
+ objectclass=['top', 'extensibleObject'],
+ cn=[cn],
+ basedn=[basedn],
+ filter=[search_filter],
+ scope=['sub'],
+ ttl=[3600])
+ ldap.add_entry(entry)
+
+ summary = _('Automember rebuild membership task started')
+ result = {'dn': task_dn}
+
+ if not options.get('no_wait'):
+ summary = _('Automember rebuild membership task completed')
+ result = {}
+ start_time = time.time()
+
+ while True:
+ try:
+ task = ldap.get_entry(task_dn)
+ except errors.NotFound:
+ break
+
+ if 'nstaskexitcode' in task:
+ if str(task.single_value['nstaskexitcode']) == '0':
+ summary=task.single_value['nstaskstatus']
+ break
+ else:
+ raise errors.DatabaseError(
+ desc=task.single_value['nstaskstatus'],
+ info=_("Task DN = '%s'" % task_dn))
+ time.sleep(1)
+ if time.time() > (start_time + 60):
+ raise errors.TaskTimeout(task=_('Automember'), task_dn=task_dn)
+
+ return dict(
+ result=result,
+ summary=unicode(summary),
+ value=pkey_to_value(None, options))
diff --git a/ipaserver/plugins/automount.py b/ipaserver/plugins/automount.py
new file mode 100644
index 000000000..c4cf2d6db
--- /dev/null
+++ b/ipaserver/plugins/automount.py
@@ -0,0 +1,841 @@
+# Authors:
+# Rob Crittenden <rcritten@redhat.com>
+# Pavel Zuna <pzuna@redhat.com>
+#
+# Copyright (C) 2008 Red Hat
+# see file 'COPYING' for use and warranty information
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+import six
+
+from ipalib import api, errors
+from ipalib import Str, IA5Str
+from ipalib.plugable import Registry
+from .baseldap import (
+ pkey_to_value,
+ LDAPObject,
+ LDAPCreate,
+ LDAPDelete,
+ LDAPQuery,
+ LDAPUpdate,
+ LDAPSearch,
+ LDAPRetrieve)
+from ipalib import _, ngettext
+from ipapython.dn import DN
+
+if six.PY3:
+ unicode = str
+
+__doc__ = _("""
+Automount
+
+Stores automount(8) configuration for autofs(8) in IPA.
+
+The base of an automount configuration is the configuration file auto.master.
+This is also the base location in IPA. Multiple auto.master configurations
+can be stored in separate locations. A location is implementation-specific
+with the default being a location named 'default'. For example, you can have
+locations by geographic region, by floor, by type, etc.
+
+Automount has three basic object types: locations, maps and keys.
+
+A location defines a set of maps anchored in auto.master. This allows you
+to store multiple automount configurations. A location in itself isn't
+very interesting, it is just a point to start a new automount map.
+
+A map is roughly equivalent to a discrete automount file and provides
+storage for keys.
+
+A key is a mount point associated with a map.
+
+When a new location is created, two maps are automatically created for
+it: auto.master and auto.direct. auto.master is the root map for all
+automount maps for the location. auto.direct is the default map for
+direct mounts and is mounted on /-.
+
+An automount map may contain a submount key. This key defines a mount
+location within the map that references another map. This can be done
+either using automountmap-add-indirect --parentmap or manually
+with automountkey-add and setting info to "-type=autofs :<mapname>".
+
+EXAMPLES:
+
+Locations:
+
+ Create a named location, "Baltimore":
+ ipa automountlocation-add baltimore
+
+ Display the new location:
+ ipa automountlocation-show baltimore
+
+ Find available locations:
+ ipa automountlocation-find
+
+ Remove a named automount location:
+ ipa automountlocation-del baltimore
+
+ Show what the automount maps would look like if they were in the filesystem:
+ ipa automountlocation-tofiles baltimore
+
+ Import an existing configuration into a location:
+ ipa automountlocation-import baltimore /etc/auto.master
+
+ The import will fail if any duplicate entries are found. For
+ continuous operation where errors are ignored, use the --continue
+ option.
+
+Maps:
+
+ Create a new map, "auto.share":
+ ipa automountmap-add baltimore auto.share
+
+ Display the new map:
+ ipa automountmap-show baltimore auto.share
+
+ Find maps in the location baltimore:
+ ipa automountmap-find baltimore
+
+ Create an indirect map with auto.share as a submount:
+ ipa automountmap-add-indirect baltimore --parentmap=auto.share --mount=sub auto.man
+
+ This is equivalent to:
+
+ ipa automountmap-add-indirect baltimore --mount=/man auto.man
+ ipa automountkey-add baltimore auto.man --key=sub --info="-fstype=autofs ldap:auto.share"
+
+ Remove the auto.share map:
+ ipa automountmap-del baltimore auto.share
+
+Keys:
+
+ Create a new key for the auto.share map in location baltimore. This ties
+ the map we previously created to auto.master:
+ ipa automountkey-add baltimore auto.master --key=/share --info=auto.share
+
+ Create a new key for our auto.share map, an NFS mount for man pages:
+ ipa automountkey-add baltimore auto.share --key=man --info="-ro,soft,rsize=8192,wsize=8192 ipa.example.com:/shared/man"
+
+ Find all keys for the auto.share map:
+ ipa automountkey-find baltimore auto.share
+
+ Find all direct automount keys:
+ ipa automountkey-find baltimore --key=/-
+
+ Remove the man key from the auto.share map:
+ ipa automountkey-del baltimore auto.share --key=man
+""")
+
+"""
+Developer notes:
+
+RFC 2707bis http://www.padl.com/~lukeh/rfc2307bis.txt
+
+A few notes on automount:
+- The default parent when adding an indirect map is auto.master
+- This uses the short format for automount maps instead of the
+ URL format. Support for ldap as a map source in nsswitch.conf was added
+ in autofs version 4.1.3-197. Any version prior to that is not expected
+ to work.
+- An indirect key should not begin with /
+
+As an example, the following automount files:
+
+auto.master:
+/- auto.direct
+/mnt auto.mnt
+
+auto.mnt:
+stuff -ro,soft,rsize=8192,wsize=8192 nfs.example.com:/vol/archive/stuff
+
+are equivalent to the following LDAP entries:
+
+# auto.master, automount, example.com
+dn: automountmapname=auto.master,cn=automount,dc=example,dc=com
+objectClass: automountMap
+objectClass: top
+automountMapName: auto.master
+
+# auto.direct, automount, example.com
+dn: automountmapname=auto.direct,cn=automount,dc=example,dc=com
+objectClass: automountMap
+objectClass: top
+automountMapName: auto.direct
+
+# /-, auto.master, automount, example.com
+dn: automountkey=/-,automountmapname=auto.master,cn=automount,dc=example,dc=co
+ m
+objectClass: automount
+objectClass: top
+automountKey: /-
+automountInformation: auto.direct
+
+# auto.mnt, automount, example.com
+dn: automountmapname=auto.mnt,cn=automount,dc=example,dc=com
+objectClass: automountMap
+objectClass: top
+automountMapName: auto.mnt
+
+# /mnt, auto.master, automount, example.com
+dn: automountkey=/mnt,automountmapname=auto.master,cn=automount,dc=example,dc=
+ com
+objectClass: automount
+objectClass: top
+automountKey: /mnt
+automountInformation: auto.mnt
+
+# stuff, auto.mnt, automount, example.com
+dn: automountkey=stuff,automountmapname=auto.mnt,cn=automount,dc=example,dc=com
+objectClass: automount
+objectClass: top
+automountKey: stuff
+automountInformation: -ro,soft,rsize=8192,wsize=8192 nfs.example.com:/vol/arch
+ ive/stuff
+
+"""
+
+register = Registry()
+
+DIRECT_MAP_KEY = u'/-'
+
+@register()
+class automountlocation(LDAPObject):
+ """
+ Location container for automount maps.
+ """
+ container_dn = api.env.container_automount
+ object_name = _('automount location')
+ object_name_plural = _('automount locations')
+ object_class = ['nscontainer']
+ default_attributes = ['cn']
+ label = _('Automount Locations')
+ label_singular = _('Automount Location')
+ permission_filter_objectclasses = ['nscontainer']
+ managed_permissions = {
+ 'System: Read Automount Configuration': {
+ # Single permission for all automount-related entries
+ 'non_object': True,
+ 'ipapermlocation': DN(container_dn, api.env.basedn),
+ 'replaces_global_anonymous_aci': True,
+ 'ipapermbindruletype': 'anonymous',
+ 'ipapermright': {'read', 'search', 'compare'},
+ 'ipapermdefaultattr': {
+ 'cn', 'objectclass',
+ 'automountinformation', 'automountkey', 'description',
+ 'automountmapname', 'description',
+ },
+ },
+ 'System: Add Automount Locations': {
+ 'ipapermright': {'add'},
+ 'default_privileges': {'Automount Administrators'},
+ },
+ 'System: Remove Automount Locations': {
+ 'ipapermright': {'delete'},
+ 'default_privileges': {'Automount Administrators'},
+ },
+ }
+
+ takes_params = (
+ Str('cn',
+ cli_name='location',
+ label=_('Location'),
+ doc=_('Automount location name.'),
+ primary_key=True,
+ ),
+ )
+
+
+@register()
+class automountlocation_add(LDAPCreate):
+ __doc__ = _('Create a new automount location.')
+
+ msg_summary = _('Added automount location "%(value)s"')
+
+ def post_callback(self, ldap, dn, entry_attrs, *keys, **options):
+ assert isinstance(dn, DN)
+ # create auto.master for the new location
+ self.api.Command['automountmap_add'](keys[-1], u'auto.master')
+
+ # add additional pre-created maps and keys
+ # IMPORTANT: add pre-created maps/keys to DEFAULT_MAPS/DEFAULT_KEYS
+ # so that they do not cause conflicts during import operation
+ self.api.Command['automountmap_add_indirect'](
+ keys[-1], u'auto.direct', key=DIRECT_MAP_KEY
+ )
+ return dn
+
+
+@register()
+class automountlocation_del(LDAPDelete):
+ __doc__ = _('Delete an automount location.')
+
+ msg_summary = _('Deleted automount location "%(value)s"')
+
+
+@register()
+class automountlocation_show(LDAPRetrieve):
+ __doc__ = _('Display an automount location.')
+
+
+@register()
+class automountlocation_find(LDAPSearch):
+ __doc__ = _('Search for an automount location.')
+
+ msg_summary = ngettext(
+ '%(count)d automount location matched',
+ '%(count)d automount locations matched', 0
+ )
+
+
+@register()
+class automountlocation_tofiles(LDAPQuery):
+ __doc__ = _('Generate automount files for a specific location.')
+
+ def execute(self, *args, **options):
+ self.api.Command['automountlocation_show'](args[0])
+
+ result = self.api.Command['automountkey_find'](args[0], u'auto.master')
+ maps = result['result']
+
+ # maps, truncated
+ # TODO: handle truncated results
+ # ?use ldap.find_entries instead of automountkey_find?
+
+ keys = {}
+ mapnames = [u'auto.master']
+ for m in maps:
+ info = m['automountinformation'][0]
+ mapnames.append(info)
+ key = info.split(None)
+ result = self.api.Command['automountkey_find'](args[0], key[0])
+ keys[info] = result['result']
+ # TODO: handle truncated results, same as above
+
+ allmaps = self.api.Command['automountmap_find'](args[0])['result']
+ orphanmaps = []
+ for m in allmaps:
+ if m['automountmapname'][0] not in mapnames:
+ orphanmaps.append(m)
+
+ orphankeys = []
+ # Collect all the keys for the orphaned maps
+ for m in orphanmaps:
+ key = m['automountmapname']
+ result = self.api.Command['automountkey_find'](args[0], key[0])
+ orphankeys.append(result['result'])
+
+ return dict(result=dict(maps=maps, keys=keys,
+ orphanmaps=orphanmaps, orphankeys=orphankeys))
+
+
+@register()
+class automountmap(LDAPObject):
+ """
+ Automount map object.
+ """
+ parent_object = 'automountlocation'
+ container_dn = api.env.container_automount
+ object_name = _('automount map')
+ object_name_plural = _('automount maps')
+ object_class = ['automountmap']
+ permission_filter_objectclasses = ['automountmap']
+ default_attributes = ['automountmapname', 'description']
+
+ takes_params = (
+ IA5Str('automountmapname',
+ cli_name='map',
+ label=_('Map'),
+ doc=_('Automount map name.'),
+ primary_key=True,
+ ),
+ Str('description?',
+ cli_name='desc',
+ label=_('Description'),
+ ),
+ )
+
+ managed_permissions = {
+ 'System: Add Automount Maps': {
+ 'ipapermright': {'add'},
+ 'replaces': [
+ '(target = "ldap:///automountmapname=*,cn=automount,$SUFFIX")(version 3.0;acl "permission:Add Automount maps";allow (add) groupdn = "ldap:///cn=Add Automount maps,cn=permissions,cn=pbac,$SUFFIX";)',
+ ],
+ 'default_privileges': {'Automount Administrators'},
+ },
+ 'System: Modify Automount Maps': {
+ 'ipapermright': {'write'},
+ 'ipapermdefaultattr': {'automountmapname', 'description'},
+ 'replaces': [
+ '(targetattr = "automountmapname || description")(target = "ldap:///automountmapname=*,cn=automount,$SUFFIX")(version 3.0;acl "permission:Modify Automount maps";allow (write) groupdn = "ldap:///cn=Modify Automount maps,cn=permissions,cn=pbac,$SUFFIX";)',
+ ],
+ 'default_privileges': {'Automount Administrators'},
+ },
+ 'System: Remove Automount Maps': {
+ 'ipapermright': {'delete'},
+ 'replaces': [
+ '(target = "ldap:///automountmapname=*,cn=automount,$SUFFIX")(version 3.0;acl "permission:Remove Automount maps";allow (delete) groupdn = "ldap:///cn=Remove Automount maps,cn=permissions,cn=pbac,$SUFFIX";)',
+ ],
+ 'default_privileges': {'Automount Administrators'},
+ },
+ }
+
+ label = _('Automount Maps')
+ label_singular = _('Automount Map')
+
+
+@register()
+class automountmap_add(LDAPCreate):
+ __doc__ = _('Create a new automount map.')
+
+ msg_summary = _('Added automount map "%(value)s"')
+
+
+@register()
+class automountmap_del(LDAPDelete):
+ __doc__ = _('Delete an automount map.')
+
+ msg_summary = _('Deleted automount map "%(value)s"')
+
+ def post_callback(self, ldap, dn, *keys, **options):
+ assert isinstance(dn, DN)
+ # delete optional parental connection (direct maps may not have this)
+ try:
+ entry_attrs = ldap.find_entry_by_attr(
+ 'automountinformation', keys[0], 'automount',
+ base_dn=DN(self.obj.container_dn, api.env.basedn)
+ )
+ ldap.delete_entry(entry_attrs)
+ except errors.NotFound:
+ pass
+ return True
+
+
+@register()
+class automountmap_mod(LDAPUpdate):
+ __doc__ = _('Modify an automount map.')
+
+ msg_summary = _('Modified automount map "%(value)s"')
+
+
+@register()
+class automountmap_find(LDAPSearch):
+ __doc__ = _('Search for an automount map.')
+
+ msg_summary = ngettext(
+ '%(count)d automount map matched',
+ '%(count)d automount maps matched', 0
+ )
+
+
+@register()
+class automountmap_show(LDAPRetrieve):
+ __doc__ = _('Display an automount map.')
+
+
+@register()
+class automountkey(LDAPObject):
+ __doc__ = _('Automount key object.')
+
+ parent_object = 'automountmap'
+ container_dn = api.env.container_automount
+ object_name = _('automount key')
+ object_name_plural = _('automount keys')
+ object_class = ['automount']
+ permission_filter_objectclasses = ['automount']
+ default_attributes = [
+ 'automountkey', 'automountinformation', 'description'
+ ]
+ rdn_is_primary_key = True
+ rdn_separator = ' '
+
+ takes_params = (
+ IA5Str('automountkey',
+ cli_name='key',
+ label=_('Key'),
+ doc=_('Automount key name.'),
+ flags=('req_update',),
+ ),
+ IA5Str('automountinformation',
+ cli_name='info',
+ label=_('Mount information'),
+ ),
+ Str('description',
+ label=_('description'),
+ primary_key=True,
+ required=False,
+ flags=['no_create', 'no_update', 'no_search', 'no_output'],
+ exclude='webui',
+ ),
+ )
+
+ managed_permissions = {
+ 'System: Add Automount Keys': {
+ 'ipapermright': {'add'},
+ 'replaces': [
+ '(target = "ldap:///automountkey=*,automountmapname=*,cn=automount,$SUFFIX")(version 3.0;acl "permission:Add Automount keys";allow (add) groupdn = "ldap:///cn=Add Automount keys,cn=permissions,cn=pbac,$SUFFIX";)',
+ '(targetfilter = "(objectclass=automount)")(target = "ldap:///automountmapname=*,cn=automount,$SUFFIX")(version 3.0;acl "permission:Add Automount keys";allow (add) groupdn = "ldap:///cn=Add Automount keys,cn=permissions,cn=pbac,$SUFFIX";)',
+ ],
+ 'default_privileges': {'Automount Administrators'},
+ },
+ 'System: Modify Automount Keys': {
+ 'ipapermright': {'write'},
+ 'ipapermdefaultattr': {
+ 'automountinformation', 'automountkey', 'description',
+ },
+ 'replaces': [
+ '(targetattr = "automountkey || automountinformation || description")(targetfilter = "(objectclass=automount)")(target = "ldap:///automountmapname=*,cn=automount,$SUFFIX")(version 3.0;acl "permission:Modify Automount keys";allow (write) groupdn = "ldap:///cn=Modify Automount keys,cn=permissions,cn=pbac,$SUFFIX";)',
+ ],
+ 'default_privileges': {'Automount Administrators'},
+ },
+ 'System: Remove Automount Keys': {
+ 'ipapermright': {'delete'},
+ 'replaces': [
+ '(target = "ldap:///automountkey=*,automountmapname=*,cn=automount,$SUFFIX")(version 3.0;acl "permission:Remove Automount keys";allow (delete) groupdn = "ldap:///cn=Remove Automount keys,cn=permissions,cn=pbac,$SUFFIX";)',
+ '(targetfilter = "(objectclass=automount)")(target = "ldap:///automountmapname=*,cn=automount,$SUFFIX")(version 3.0;acl "permission:Remove Automount keys";allow (delete) groupdn = "ldap:///cn=Remove Automount keys,cn=permissions,cn=pbac,$SUFFIX";)',
+ ],
+ 'default_privileges': {'Automount Administrators'},
+ },
+ }
+
+ num_parents = 2
+ label = _('Automount Keys')
+ label_singular = _('Automount Key')
+ already_exists_msg = _('The key,info pair must be unique. A key named %(key)s with info %(info)s already exists')
+ key_already_exists_msg = _('key named %(key)s already exists')
+ object_not_found_msg = _('The automount key %(key)s with info %(info)s does not exist')
+
+ def get_dn(self, *keys, **kwargs):
+ # all commands except for create send pk in keys, too
+ # create cannot due to validation in frontend.py
+ ldap = self.backend
+ if len(keys) == self.num_parents:
+ try:
+ pkey = kwargs[self.primary_key.name]
+ except KeyError:
+ raise ValueError('Not enough keys and pkey not in kwargs')
+ parent_keys = keys
+ else:
+ pkey = keys[-1]
+ parent_keys = keys[:-1]
+
+ parent_dn = self.api.Object[self.parent_object].get_dn(*parent_keys)
+ dn = self.backend.make_dn_from_attr(
+ self.primary_key.name,
+ pkey,
+ parent_dn
+ )
+ # If we're doing an add then just return the dn we created, there
+ # is no need to check for it.
+ if kwargs.get('add_operation', False):
+ return dn
+ # We had an older mechanism where description consisted of
+ # 'automountkey automountinformation' so we could support multiple
+ # direct maps. This made showing keys nearly impossible since it
+ # required automountinfo to show, which if you had you didn't need
+ # to look at the key. We still support existing entries but now
+ # only create this type of dn when the key is /-
+ #
+ # First we look with the information given, then try to search for
+ # the right entry.
+ try:
+ dn = ldap.get_entry(dn, ['*']).dn
+ except errors.NotFound:
+ if kwargs.get('automountinformation', False):
+ sfilter = '(&(automountkey=%s)(automountinformation=%s))' % \
+ (kwargs['automountkey'], kwargs['automountinformation'])
+ else:
+ sfilter = '(automountkey=%s)' % kwargs['automountkey']
+ basedn = DN(('automountmapname', parent_keys[1]),
+ ('cn', parent_keys[0]), self.container_dn,
+ api.env.basedn)
+ attrs_list = ['*']
+ entries = ldap.get_entries(
+ basedn, ldap.SCOPE_ONELEVEL, sfilter, attrs_list)
+ if len(entries) > 1:
+ raise errors.NotFound(reason=_('More than one entry with key %(key)s found, use --info to select specific entry.') % dict(key=pkey))
+ dn = entries[0].dn
+
+ return dn
+
+ def handle_not_found(self, *keys):
+ pkey = keys[-1]
+ key = pkey.split(self.rdn_separator)[0]
+ info = self.rdn_separator.join(pkey.split(self.rdn_separator)[1:])
+ raise errors.NotFound(
+ reason=self.object_not_found_msg % {
+ 'key': key, 'info': info,
+ }
+ )
+
+ def handle_duplicate_entry(self, *keys):
+ pkey = keys[-1]
+ key = pkey.split(self.rdn_separator)[0]
+ info = self.rdn_separator.join(pkey.split(self.rdn_separator)[1:])
+ if info:
+ raise errors.DuplicateEntry(
+ message=self.already_exists_msg % {
+ 'key': key, 'info': info,
+ }
+ )
+ else:
+ raise errors.DuplicateEntry(
+ message=self.key_already_exists_msg % {
+ 'key': key,
+ }
+ )
+
+ def get_pk(self, key, info=None):
+ if key == DIRECT_MAP_KEY and info:
+ return self.rdn_separator.join((key,info))
+ else:
+ return key
+
+ def check_key_uniqueness(self, location, map, **keykw):
+ info = None
+ key = keykw.get('automountkey')
+ if key is None:
+ return
+
+ entries = self.methods.find(location, map, automountkey=key)['result']
+ if len(entries) > 0:
+ if key == DIRECT_MAP_KEY:
+ info = keykw.get('automountinformation')
+ entries = self.methods.find(location, map, **keykw)['result']
+ if len(entries) > 0:
+ self.handle_duplicate_entry(location, map, self.get_pk(key, info))
+ else: return
+ self.handle_duplicate_entry(location, map, self.get_pk(key, info))
+
+
+@register()
+class automountkey_add(LDAPCreate):
+ __doc__ = _('Create a new automount key.')
+
+ msg_summary = _('Added automount key "%(value)s"')
+
+ internal_options = ['description', 'add_operation']
+
+ def pre_callback(self, ldap, dn, entry_attrs, *keys, **options):
+ assert isinstance(dn, DN)
+ options.pop('add_operation', None)
+ options.pop('description', None)
+ self.obj.check_key_uniqueness(keys[-2], keys[-1], **options)
+ return dn
+
+ def get_args(self):
+ for key in self.obj.get_ancestor_primary_keys():
+ yield key
+
+ def execute(self, *keys, **options):
+ key = options['automountkey']
+ info = options.get('automountinformation', None)
+ options[self.obj.primary_key.name] = self.obj.get_pk(key, info)
+ options['add_operation'] = True
+ result = super(automountkey_add, self).execute(*keys, **options)
+ result['value'] = pkey_to_value(options['automountkey'], options)
+ return result
+
+
+@register()
+class automountmap_add_indirect(LDAPCreate):
+ __doc__ = _('Create a new indirect mount point.')
+
+ msg_summary = _('Added automount indirect map "%(value)s"')
+
+ takes_options = LDAPCreate.takes_options + (
+ Str('key',
+ cli_name='mount',
+ label=_('Mount point'),
+ ),
+ Str('parentmap?',
+ cli_name='parentmap',
+ label=_('Parent map'),
+ doc=_('Name of parent automount map (default: auto.master).'),
+ default=u'auto.master',
+ autofill=True,
+ ),
+ )
+
+ def execute(self, *keys, **options):
+ parentmap = options.pop('parentmap', None)
+ key = options.pop('key')
+ result = self.api.Command['automountmap_add'](*keys, **options)
+ try:
+ if parentmap != u'auto.master':
+ if key.startswith('/'):
+ raise errors.ValidationError(name='mount',
+ error=_('mount point is relative to parent map, '
+ 'cannot begin with /'))
+ location = keys[0]
+ map = keys[1]
+ options['automountinformation'] = map
+
+ # Ensure the referenced map exists
+ self.api.Command['automountmap_show'](location, parentmap)
+ # Add a submount key
+ self.api.Command['automountkey_add'](
+ location, parentmap, automountkey=key,
+ automountinformation='-fstype=autofs ldap:%s' % map)
+ else: # adding to auto.master
+ # Ensure auto.master exists
+ self.api.Command['automountmap_show'](keys[0], parentmap)
+ self.api.Command['automountkey_add'](
+ keys[0], u'auto.master', automountkey=key,
+ automountinformation=keys[1])
+ except Exception:
+ # The key exists, drop the map
+ self.api.Command['automountmap_del'](*keys)
+ raise
+ return result
+
+
+@register()
+class automountkey_del(LDAPDelete):
+ __doc__ = _('Delete an automount key.')
+
+ msg_summary = _('Deleted automount key "%(value)s"')
+
+ takes_options = LDAPDelete.takes_options + (
+ IA5Str('automountkey',
+ cli_name='key',
+ label=_('Key'),
+ doc=_('Automount key name.'),
+ ),
+ IA5Str('automountinformation?',
+ cli_name='info',
+ label=_('Mount information'),
+ ),
+ )
+ def get_options(self):
+ for option in super(automountkey_del, self).get_options():
+ if option.name == 'continue':
+ # TODO: hide for now - remove in future major release
+ yield option.clone(exclude='webui',
+ flags=['no_option', 'no_output'])
+ else:
+ yield option
+
+ def get_args(self):
+ for key in self.obj.get_ancestor_primary_keys():
+ yield key
+
+ def execute(self, *keys, **options):
+ keys += (self.obj.get_pk(options['automountkey'],
+ options.get('automountinformation', None)),)
+ options[self.obj.primary_key.name] = self.obj.get_pk(
+ options['automountkey'],
+ options.get('automountinformation', None))
+ result = super(automountkey_del, self).execute(*keys, **options)
+ result['value'] = pkey_to_value([options['automountkey']], options)
+ return result
+
+
+@register()
+class automountkey_mod(LDAPUpdate):
+ __doc__ = _('Modify an automount key.')
+
+ msg_summary = _('Modified automount key "%(value)s"')
+
+ internal_options = ['newautomountkey']
+
+ takes_options = LDAPUpdate.takes_options + (
+ IA5Str('newautomountinformation?',
+ cli_name='newinfo',
+ label=_('New mount information'),
+ ),
+ )
+
+ def get_args(self):
+ for key in self.obj.get_ancestor_primary_keys():
+ yield key
+
+ def pre_callback(self, ldap, dn, entry_attrs, *keys, **options):
+ assert isinstance(dn, DN)
+ if 'newautomountkey' in options:
+ entry_attrs['automountkey'] = options['newautomountkey']
+ if 'newautomountinformation' in options:
+ entry_attrs['automountinformation'] = options['newautomountinformation']
+ return dn
+
+ def execute(self, *keys, **options):
+ ldap = self.api.Backend.ldap2
+ key = options['automountkey']
+ info = options.get('automountinformation', None)
+ keys += (self.obj.get_pk(key, info), )
+
+ # handle RDN changes
+ if 'rename' in options or 'newautomountinformation' in options:
+ new_key = options.get('rename', key)
+ new_info = options.get('newautomountinformation', info)
+
+ if new_key == DIRECT_MAP_KEY and not new_info:
+ # automountinformation attribute of existing LDAP object needs
+ # to be retrieved so that RDN can be generated
+ dn = self.obj.get_dn(*keys, **options)
+ entry_attrs_ = ldap.get_entry(dn, ['automountinformation'])
+ new_info = entry_attrs_.get('automountinformation', [])[0]
+
+ # automounkey attribute cannot be overwritten so that get_dn()
+ # still works right
+ options['newautomountkey'] = new_key
+
+ new_rdn = self.obj.get_pk(new_key, new_info)
+ if new_rdn != keys[-1]:
+ options['rename'] = new_rdn
+
+ result = super(automountkey_mod, self).execute(*keys, **options)
+ result['value'] = pkey_to_value(options['automountkey'], options)
+ return result
+
+
+@register()
+class automountkey_find(LDAPSearch):
+ __doc__ = _('Search for an automount key.')
+
+ msg_summary = ngettext(
+ '%(count)d automount key matched',
+ '%(count)d automount keys matched', 0
+ )
+
+
+@register()
+class automountkey_show(LDAPRetrieve):
+ __doc__ = _('Display an automount key.')
+
+ takes_options = LDAPRetrieve.takes_options + (
+ IA5Str('automountkey',
+ cli_name='key',
+ label=_('Key'),
+ doc=_('Automount key name.'),
+ ),
+ IA5Str('automountinformation?',
+ cli_name='info',
+ label=_('Mount information'),
+ ),
+ )
+
+ def get_args(self):
+ for key in self.obj.get_ancestor_primary_keys():
+ yield key
+
+ def execute(self, *keys, **options):
+ keys += (self.obj.get_pk(options['automountkey'],
+ options.get('automountinformation', None)), )
+ options[self.obj.primary_key.name] = self.obj.get_pk(
+ options['automountkey'],
+ options.get('automountinformation', None))
+
+ result = super(automountkey_show, self).execute(*keys, **options)
+ result['value'] = pkey_to_value(options['automountkey'], options)
+ return result
diff --git a/ipaserver/plugins/baseldap.py b/ipaserver/plugins/baseldap.py
new file mode 100644
index 000000000..bbd8ba146
--- /dev/null
+++ b/ipaserver/plugins/baseldap.py
@@ -0,0 +1,2397 @@
+# Authors:
+# Pavel Zuna <pzuna@redhat.com>
+#
+# Copyright (C) 2009 Red Hat
+# see file 'COPYING' for use and warranty information
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+"""
+Base classes for LDAP plugins.
+"""
+
+import re
+import time
+from copy import deepcopy
+import base64
+
+import six
+
+from ipalib import api, crud, errors
+from ipalib import Method, Object
+from ipalib import Flag, Int, Str
+from ipalib.cli import to_cli
+from ipalib import output
+from ipalib.text import _
+from ipalib.util import json_serialize, validate_hostname
+from ipalib.capabilities import client_has_capability
+from ipalib.messages import add_message, SearchResultTruncated
+from ipapython.dn import DN
+from ipapython.version import API_VERSION
+
+if six.PY3:
+ unicode = str
+
+DNA_MAGIC = -1
+
+global_output_params = (
+ Flag('has_password',
+ label=_('Password'),
+ ),
+ Str('member',
+ label=_('Failed members'),
+ ),
+ Str('member_user?',
+ label=_('Member users'),
+ ),
+ Str('member_group?',
+ label=_('Member groups'),
+ ),
+ Str('memberof_group?',
+ label=_('Member of groups'),
+ ),
+ Str('member_host?',
+ label=_('Member hosts'),
+ ),
+ Str('member_hostgroup?',
+ label=_('Member host-groups'),
+ ),
+ Str('memberof_hostgroup?',
+ label=_('Member of host-groups'),
+ ),
+ Str('memberof_permission?',
+ label=_('Permissions'),
+ ),
+ Str('memberof_privilege?',
+ label='Privileges',
+ ),
+ Str('memberof_role?',
+ label=_('Roles'),
+ ),
+ Str('memberof_sudocmdgroup?',
+ label=_('Sudo Command Groups'),
+ ),
+ Str('member_privilege?',
+ label='Granted to Privilege',
+ ),
+ Str('member_role?',
+ label=_('Granting privilege to roles'),
+ ),
+ Str('member_netgroup?',
+ label=_('Member netgroups'),
+ ),
+ Str('memberof_netgroup?',
+ label=_('Member of netgroups'),
+ ),
+ Str('member_service?',
+ label=_('Member services'),
+ ),
+ Str('member_servicegroup?',
+ label=_('Member service groups'),
+ ),
+ Str('memberof_servicegroup?',
+ label='Member of service groups',
+ ),
+ Str('member_hbacsvc?',
+ label=_('Member HBAC service'),
+ ),
+ Str('member_hbacsvcgroup?',
+ label=_('Member HBAC service groups'),
+ ),
+ Str('memberof_hbacsvcgroup?',
+ label='Member of HBAC service groups',
+ ),
+ Str('member_sudocmd?',
+ label='Member Sudo commands',
+ ),
+ Str('memberof_sudorule?',
+ label='Member of Sudo rule',
+ ),
+ Str('memberof_hbacrule?',
+ label='Member of HBAC rule',
+ ),
+ Str('memberindirect_user?',
+ label=_('Indirect Member users'),
+ ),
+ Str('memberindirect_group?',
+ label=_('Indirect Member groups'),
+ ),
+ Str('memberindirect_host?',
+ label=_('Indirect Member hosts'),
+ ),
+ Str('memberindirect_hostgroup?',
+ label=_('Indirect Member host-groups'),
+ ),
+ Str('memberindirect_role?',
+ label=_('Indirect Member of roles'),
+ ),
+ Str('memberindirect_permission?',
+ label=_('Indirect Member permissions'),
+ ),
+ Str('memberindirect_hbacsvc?',
+ label=_('Indirect Member HBAC service'),
+ ),
+ Str('memberindirect_hbacsvcgrp?',
+ label=_('Indirect Member HBAC service group'),
+ ),
+ Str('memberindirect_netgroup?',
+ label=_('Indirect Member netgroups'),
+ ),
+ Str('memberofindirect_group?',
+ label='Indirect Member of group',
+ ),
+ Str('memberofindirect_netgroup?',
+ label='Indirect Member of netgroup',
+ ),
+ Str('memberofindirect_hostgroup?',
+ label='Indirect Member of host-group',
+ ),
+ Str('memberofindirect_role?',
+ label='Indirect Member of role',
+ ),
+ Str('memberofindirect_sudorule?',
+ label='Indirect Member of Sudo rule',
+ ),
+ Str('memberofindirect_hbacrule?',
+ label='Indirect Member of HBAC rule',
+ ),
+ Str('sourcehost',
+ label=_('Failed source hosts/hostgroups'),
+ ),
+ Str('memberhost',
+ label=_('Failed hosts/hostgroups'),
+ ),
+ Str('memberuser',
+ label=_('Failed users/groups'),
+ ),
+ Str('memberservice',
+ label=_('Failed service/service groups'),
+ ),
+ Str('failed',
+ label=_('Failed to remove'),
+ flags=['suppress_empty'],
+ ),
+ Str('ipasudorunas',
+ label=_('Failed RunAs'),
+ ),
+ Str('ipasudorunasgroup',
+ label=_('Failed RunAsGroup'),
+ ),
+)
+
+
+def validate_add_attribute(ugettext, attr):
+ validate_attribute(ugettext, 'addattr', attr)
+
+def validate_set_attribute(ugettext, attr):
+ validate_attribute(ugettext, 'setattr', attr)
+
+def validate_del_attribute(ugettext, attr):
+ validate_attribute(ugettext, 'delattr', attr)
+
+def validate_attribute(ugettext, name, attr):
+ m = re.match("\s*(.*?)\s*=\s*(.*?)\s*$", attr)
+ if not m or len(m.groups()) != 2:
+ raise errors.ValidationError(
+ name=name, error=_('Invalid format. Should be name=value'))
+
+def get_effective_rights(ldap, dn, attrs=None):
+ assert isinstance(dn, DN)
+ if attrs is None:
+ attrs = ['*', 'nsaccountlock', 'cospriority']
+ rights = ldap.get_effective_rights(dn, attrs)
+ rdict = {}
+ if 'attributelevelrights' in rights:
+ rights = rights['attributelevelrights']
+ rights = rights[0].split(', ')
+ for r in rights:
+ (k,v) = r.split(':')
+ if v == 'none':
+ # the string "none" means "no rights found"
+ # see https://fedorahosted.org/freeipa/ticket/4359
+ v = u''
+ rdict[k.strip().lower()] = v
+
+ return rdict
+
+def entry_from_entry(entry, newentry):
+ """
+ Python is more or less pass-by-value except for immutable objects. So if
+ you pass in a dict to a function you are free to change members of that
+ dict but you can't create a new dict in the function and expect to replace
+ what was passed in.
+
+ In some post-op plugins that is exactly what we want to do, so here is a
+ clumsy way around the problem.
+ """
+
+ # Wipe out the current data
+ for e in list(entry):
+ del entry[e]
+
+ # Re-populate it with new wentry
+ for e in newentry.keys():
+ entry[e] = newentry[e]
+
+def entry_to_dict(entry, **options):
+ if options.get('raw', False):
+ result = {}
+ for attr in entry:
+ if attr.lower() == 'attributelevelrights':
+ value = entry[attr]
+ elif entry.conn.get_attribute_type(attr) is bytes:
+ value = entry.raw[attr]
+ else:
+ value = list(entry.raw[attr])
+ for (i, v) in enumerate(value):
+ try:
+ value[i] = v.decode('utf-8')
+ except UnicodeDecodeError:
+ pass
+ result[attr] = value
+ else:
+ result = dict((k.lower(), v) for (k, v) in entry.items())
+ if options.get('all', False):
+ result['dn'] = entry.dn
+ return result
+
+def pkey_to_unicode(key):
+ if key is None:
+ key = []
+ elif not isinstance(key, (tuple, list)):
+ key = [key]
+ key = u','.join(unicode(k) for k in key)
+ return key
+
+def pkey_to_value(key, options):
+ version = options.get('version', API_VERSION)
+ if client_has_capability(version, 'primary_key_types'):
+ return key
+ return pkey_to_unicode(key)
+
+def wait_for_value(ldap, dn, attr, value):
+ """
+ 389-ds postoperation plugins are executed after the data has been
+ returned to a client. This means that plugins that add data in a
+ postop are not included in data returned to the user.
+
+ The downside of waiting is that this increases the time of the
+ command.
+
+ The updated entry is returned.
+ """
+ # Loop a few times to give the postop-plugin a chance to complete
+ # Don't sleep for more than 6 seconds.
+ x = 0
+ while x < 20:
+ # sleep first because the first search, even on a quiet system,
+ # almost always fails.
+ time.sleep(.3)
+ x = x + 1
+
+ # FIXME: put a try/except around here? I think it is probably better
+ # to just let the exception filter up to the caller.
+ entry_attrs = ldap.get_entry(dn, ['*'])
+ if attr in entry_attrs:
+ if isinstance(entry_attrs[attr], (list, tuple)):
+ values = [y.lower() for y in entry_attrs[attr]]
+ if value.lower() in values:
+ break
+ else:
+ if value.lower() == entry_attrs[attr].lower():
+ break
+
+ return entry_attrs
+
+
+def validate_externalhost(ugettext, hostname):
+ try:
+ validate_hostname(hostname, check_fqdn=False, allow_underscore=True)
+ except ValueError as e:
+ return unicode(e)
+
+
+external_host_param = Str('externalhost*', validate_externalhost,
+ label=_('External host'),
+ flags=['no_option'],
+)
+
+
+def add_external_pre_callback(membertype, ldap, dn, keys, options):
+ """
+ Pre callback to validate external members.
+
+ This should be called by a command pre callback directly.
+
+ membertype is the type of member
+ """
+ assert isinstance(dn, DN)
+
+ # validate hostname with allowed underscore characters, non-fqdn
+ # hostnames are allowed
+ def validate_host(hostname):
+ validate_hostname(hostname, check_fqdn=False, allow_underscore=True)
+
+ if options.get(membertype):
+ if membertype == 'host':
+ validator = validate_host
+ else:
+ param = api.Object[membertype].primary_key
+
+ def validator(value):
+ value = param(value)
+ param.validate(value)
+
+ for value in options[membertype]:
+ try:
+ validator(value)
+ except errors.ValidationError as e:
+ raise errors.ValidationError(name=membertype, error=e.error)
+ except ValueError as e:
+ raise errors.ValidationError(name=membertype, error=e)
+ return dn
+
+
+def add_external_post_callback(ldap, dn, entry_attrs, failed, completed,
+ memberattr, membertype, externalattr,
+ normalize=True):
+ """
+ Takes the following arguments:
+ failed - the list of failed entries, these are candidates for possible
+ external entries to add
+ completed - the number of successfully added entries so far
+ memberattr - the attribute name that IPA uses for membership natively
+ (e.g. memberhost)
+ membertype - the object type of the member (e.g. host)
+ externalattr - the attribute name that IPA uses to store the membership
+ of the entries that are not managed by IPA
+ (e.g externalhost)
+
+ Returns the number of completed entries so far (the number of entries
+ handled by IPA incremented by the number of handled external entries) and
+ dn.
+ """
+ assert isinstance(dn, DN)
+
+ completed_external = 0
+
+ # Sift through the failures. We assume that these are all
+ # entries that aren't stored in IPA, aka external entries.
+ if memberattr in failed and membertype in failed[memberattr]:
+ entry_attrs_ = ldap.get_entry(dn, [externalattr])
+ dn = entry_attrs_.dn
+ members = entry_attrs.get(memberattr, [])
+ external_entries = entry_attrs_.get(externalattr, [])
+ lc_external_entries = set(e.lower() for e in external_entries)
+
+ failed_entries = []
+ for entry in failed[memberattr][membertype]:
+ membername = entry[0].lower()
+ member_dn = api.Object[membertype].get_dn(membername)
+ assert isinstance(member_dn, DN)
+
+ if (membername not in lc_external_entries and
+ member_dn not in members):
+ # Not an IPA entry, assume external
+ if normalize:
+ external_entries.append(membername)
+ else:
+ external_entries.append(entry[0])
+ lc_external_entries.add(membername)
+ completed_external += 1
+ elif (membername in lc_external_entries and
+ member_dn not in members):
+ # Already an external member, reset the error message
+ msg = unicode(errors.AlreadyGroupMember().message)
+ newerror = (entry[0], msg)
+ ind = failed[memberattr][membertype].index(entry)
+ failed[memberattr][membertype][ind] = newerror
+ failed_entries.append(membername)
+ else:
+ # Really a failure
+ failed_entries.append(membername)
+
+ if completed_external:
+ entry_attrs_[externalattr] = external_entries
+ try:
+ ldap.update_entry(entry_attrs_)
+ except errors.EmptyModlist:
+ pass
+ failed[memberattr][membertype] = failed_entries
+ entry_attrs[externalattr] = external_entries
+
+ return (completed + completed_external, dn)
+
+
+def remove_external_post_callback(ldap, dn, entry_attrs, failed, completed,
+ memberattr, membertype, externalattr):
+ """
+ Takes the following arguments:
+ failed - the list of failed entries, these are candidates for possible
+ external entries to remove
+ completed - the number of successfully removed entries so far
+ memberattr - the attribute name that IPA uses for membership natively
+ (e.g. memberhost)
+ membertype - the object type of the member (e.g. host)
+ externalattr - the attribute name that IPA uses to store the membership
+ of the entries that are not managed by IPA
+ (e.g externalhost)
+
+ Returns the number of completed entries so far (the number of entries
+ handled by IPA incremented by the number of handled external entries) and
+ dn.
+ """
+
+ assert isinstance(dn, DN)
+
+ # Run through the failures and gracefully remove any member defined
+ # as an external member.
+ completed_external = 0
+ if memberattr in failed and membertype in failed[memberattr]:
+ entry_attrs_ = ldap.get_entry(dn, [externalattr])
+ dn = entry_attrs_.dn
+ external_entries = entry_attrs_.get(externalattr, [])
+ failed_entries = []
+
+ for entry in failed[memberattr][membertype]:
+ membername = entry[0].lower()
+ if membername in external_entries or entry[0] in external_entries:
+ try:
+ external_entries.remove(membername)
+ except ValueError:
+ external_entries.remove(entry[0])
+ completed_external += 1
+ else:
+ msg = unicode(errors.NotGroupMember().message)
+ newerror = (entry[0], msg)
+ ind = failed[memberattr][membertype].index(entry)
+ failed[memberattr][membertype][ind] = newerror
+ failed_entries.append(membername)
+
+ if completed_external:
+ entry_attrs_[externalattr] = external_entries
+ try:
+ ldap.update_entry(entry_attrs_)
+ except errors.EmptyModlist:
+ pass
+ failed[memberattr][membertype] = failed_entries
+ entry_attrs[externalattr] = external_entries
+
+ return (completed + completed_external, dn)
+
+
+def host_is_master(ldap, fqdn):
+ """
+ Check to see if this host is a master.
+
+ Raises an exception if a master, otherwise returns nothing.
+ """
+ master_dn = DN(('cn', fqdn), ('cn', 'masters'), ('cn', 'ipa'), ('cn', 'etc'), api.env.basedn)
+ try:
+ ldap.get_entry(master_dn, ['objectclass'])
+ raise errors.ValidationError(name='hostname', error=_('An IPA master host cannot be deleted or disabled'))
+ except errors.NotFound:
+ # Good, not a master
+ return
+
+
+def add_missing_object_class(ldap, objectclass, dn, entry_attrs=None, update=True):
+ """
+ Add object class if missing into entry. Fetches entry if not passed. Updates
+ the entry by default.
+
+ Returns the entry
+ """
+
+ if not entry_attrs:
+ entry_attrs = ldap.get_entry(dn, ['objectclass'])
+ if (objectclass.lower() not in (o.lower() for o in entry_attrs['objectclass'])):
+ entry_attrs['objectclass'].append(objectclass)
+ if update:
+ ldap.update_entry(entry_attrs)
+ return entry_attrs
+
+
+class LDAPObject(Object):
+ """
+ Object representing a LDAP entry.
+ """
+ backend_name = 'ldap2'
+
+ parent_object = ''
+ container_dn = ''
+ object_name = _('entry')
+ object_name_plural = _('entries')
+ object_class = []
+ object_class_config = None
+ # If an objectclass is possible but not default in an entry. Needed for
+ # collecting attributes for ACI UI.
+ possible_objectclasses = []
+ limit_object_classes = [] # Only attributes in these are allowed
+ disallow_object_classes = [] # Disallow attributes in these
+ permission_filter_objectclasses = None
+ search_attributes = []
+ search_attributes_config = None
+ default_attributes = []
+ search_display_attributes = [] # attributes displayed in LDAPSearch
+ hidden_attributes = ['objectclass', 'aci']
+ # set rdn_attribute only if RDN attribute differs from primary key!
+ rdn_attribute = ''
+ uuid_attribute = ''
+ attribute_members = {}
+ rdn_is_primary_key = False # Do we need RDN change to do a rename?
+ password_attributes = []
+ # Can bind as this entry (has userPassword or krbPrincipalKey)
+ bindable = False
+ relationships = {
+ # attribute: (label, inclusive param prefix, exclusive param prefix)
+ 'member': ('Member', '', 'no_'),
+ 'memberof': ('Member Of', 'in_', 'not_in_'),
+ 'memberindirect': (
+ 'Indirect Member', None, 'no_indirect_'
+ ),
+ 'memberofindirect': (
+ 'Indirect Member Of', None, 'not_in_indirect_'
+ ),
+ }
+ label = _('Entry')
+ label_singular = _('Entry')
+ managed_permissions = {}
+
+ container_not_found_msg = _('container entry (%(container)s) not found')
+ parent_not_found_msg = _('%(parent)s: %(oname)s not found')
+ object_not_found_msg = _('%(pkey)s: %(oname)s not found')
+ already_exists_msg = _('%(oname)s with name "%(pkey)s" already exists')
+
+ def get_dn(self, *keys, **kwargs):
+ if self.parent_object:
+ parent_dn = self.api.Object[self.parent_object].get_dn(*keys[:-1])
+ else:
+ parent_dn = DN(self.container_dn, api.env.basedn)
+ if self.rdn_attribute:
+ try:
+ entry_attrs = self.backend.find_entry_by_attr(
+ self.primary_key.name, keys[-1], self.object_class, [''],
+ DN(self.container_dn, api.env.basedn)
+ )
+ except errors.NotFound:
+ pass
+ else:
+ return entry_attrs.dn
+ if self.primary_key and keys[-1] is not None:
+ return self.backend.make_dn_from_attr(
+ self.primary_key.name, keys[-1], parent_dn
+ )
+ assert isinstance(parent_dn, DN)
+ return parent_dn
+
+ def get_dn_if_exists(self, *keys, **kwargs):
+ dn = self.get_dn(*keys, **kwargs)
+ entry = self.backend.get_entry(dn, [''])
+ return entry.dn
+
+ def get_primary_key_from_dn(self, dn):
+ assert isinstance(dn, DN)
+ try:
+ if self.rdn_attribute:
+ entry_attrs = self.backend.get_entry(
+ dn, [self.primary_key.name]
+ )
+ try:
+ return entry_attrs[self.primary_key.name][0]
+ except (KeyError, IndexError):
+ return ''
+ except errors.NotFound:
+ pass
+ try:
+ return dn[self.primary_key.name]
+ except KeyError:
+ # The primary key is not in the DN.
+ # This shouldn't happen, but we don't want a "show" command to
+ # crash.
+ # Just return the entire DN, it's all we have if the entry
+ # doesn't exist
+ return unicode(dn)
+
+ def get_ancestor_primary_keys(self):
+ if self.parent_object:
+ parent_obj = self.api.Object[self.parent_object]
+ for key in parent_obj.get_ancestor_primary_keys():
+ yield key
+ if parent_obj.primary_key:
+ pkey = parent_obj.primary_key
+ yield pkey.clone_rename(
+ parent_obj.name + pkey.name, required=True, query=True,
+ cli_name=parent_obj.name, label=pkey.label
+ )
+
+ def has_objectclass(self, classes, objectclass):
+ oc = [x.lower() for x in classes]
+ return objectclass.lower() in oc
+
+ def convert_attribute_members(self, entry_attrs, *keys, **options):
+ if options.get('raw', False):
+ return
+
+ container_dns = {}
+ new_attrs = {}
+
+ for attr in self.attribute_members:
+ try:
+ value = entry_attrs.raw[attr]
+ except KeyError:
+ continue
+ del entry_attrs[attr]
+
+ for member in value:
+ memberdn = DN(member)
+ for ldap_obj_name in self.attribute_members[attr]:
+ ldap_obj = self.api.Object[ldap_obj_name]
+ try:
+ container_dn = container_dns[ldap_obj_name]
+ except KeyError:
+ container_dn = DN(ldap_obj.container_dn, api.env.basedn)
+ container_dns[ldap_obj_name] = container_dn
+
+ if memberdn.endswith(container_dn):
+ new_value = ldap_obj.get_primary_key_from_dn(memberdn)
+ new_attr_name = '%s_%s' % (attr, ldap_obj.name)
+ try:
+ new_attr = new_attrs[new_attr_name]
+ except KeyError:
+ new_attr = entry_attrs.setdefault(new_attr_name, [])
+ new_attrs[new_attr_name] = new_attr
+ new_attr.append(new_value)
+ break
+
+ def get_indirect_members(self, entry_attrs, attrs_list):
+ if 'memberindirect' in attrs_list:
+ self.get_memberindirect(entry_attrs)
+ if 'memberofindirect' in attrs_list:
+ self.get_memberofindirect(entry_attrs)
+
+ def get_memberindirect(self, group_entry):
+ """
+ Get indirect members
+ """
+
+ mo_filter = self.backend.make_filter({'memberof': group_entry.dn})
+ filter = self.backend.combine_filters(
+ ('(member=*)', mo_filter), self.backend.MATCH_ALL)
+ try:
+ result = self.backend.get_entries(
+ self.api.env.basedn,
+ filter=filter,
+ attrs_list=['member'],
+ size_limit=-1, # paged search will get everything anyway
+ paged_search=True)
+ except errors.NotFound:
+ result = []
+
+ indirect = set()
+ for entry in result:
+ indirect.update(entry.raw.get('member', []))
+ indirect.difference_update(group_entry.raw.get('member', []))
+
+ if indirect:
+ group_entry.raw['memberindirect'] = list(indirect)
+
+ def get_memberofindirect(self, entry):
+
+ dn = entry.dn
+ filter = self.backend.make_filter(
+ {'member': dn, 'memberuser': dn, 'memberhost': dn})
+ try:
+ result = self.backend.get_entries(
+ self.api.env.basedn,
+ filter=filter,
+ attrs_list=[''])
+ except errors.NotFound:
+ result = []
+
+ direct = set()
+ indirect = set(entry.raw.get('memberof', []))
+ for group_entry in result:
+ dn = str(group_entry.dn)
+ if dn in indirect:
+ indirect.remove(dn)
+ direct.add(dn)
+
+ entry.raw['memberof'] = list(direct)
+ if indirect:
+ entry.raw['memberofindirect'] = list(indirect)
+
+ def get_password_attributes(self, ldap, dn, entry_attrs):
+ """
+ Search on the entry to determine if it has a password or
+ keytab set.
+
+ A tuple is used to determine which attribute is set
+ in entry_attrs. The value is set to True/False whether a
+ given password type is set.
+ """
+ for (pwattr, attr) in self.password_attributes:
+ search_filter = '(%s=*)' % pwattr
+ try:
+ (entries, truncated) = ldap.find_entries(
+ search_filter, [pwattr], dn, ldap.SCOPE_BASE
+ )
+ entry_attrs[attr] = True
+ except errors.NotFound:
+ entry_attrs[attr] = False
+
+ def handle_not_found(self, *keys):
+ pkey = ''
+ if self.primary_key:
+ pkey = keys[-1]
+ raise errors.NotFound(
+ reason=self.object_not_found_msg % {
+ 'pkey': pkey, 'oname': self.object_name,
+ }
+ )
+
+ def handle_duplicate_entry(self, *keys):
+ try:
+ pkey = keys[-1]
+ except IndexError:
+ pkey = ''
+ raise errors.DuplicateEntry(
+ message=self.already_exists_msg % {
+ 'pkey': pkey, 'oname': self.object_name,
+ }
+ )
+
+ # list of attributes we want exported to JSON
+ json_friendly_attributes = (
+ 'parent_object', 'container_dn', 'object_name', 'object_name_plural',
+ 'object_class', 'object_class_config', 'default_attributes', 'label', 'label_singular',
+ 'hidden_attributes', 'uuid_attribute', 'attribute_members', 'name',
+ 'takes_params', 'rdn_attribute', 'bindable', 'relationships',
+ )
+
+ def __json__(self):
+ ldap = self.backend
+ json_dict = dict(
+ (a, json_serialize(getattr(self, a))) for a in self.json_friendly_attributes
+ )
+ if self.primary_key:
+ json_dict['primary_key'] = self.primary_key.name
+ objectclasses = self.object_class
+ if self.object_class_config:
+ config = ldap.get_ipa_config()
+ objectclasses = config.get(
+ self.object_class_config, objectclasses
+ )
+ objectclasses = objectclasses + self.possible_objectclasses
+ # Get list of available attributes for this object for use
+ # in the ACI UI.
+ attrs = self.api.Backend.ldap2.schema.attribute_types(objectclasses)
+ attrlist = []
+ # Go through the MUST first
+ for (oid, attr) in attrs[0].items():
+ attrlist.append(attr.names[0].lower())
+ # And now the MAY
+ for (oid, attr) in attrs[1].items():
+ attrlist.append(attr.names[0].lower())
+ json_dict['aciattrs'] = attrlist
+ attrlist.sort()
+ json_dict['methods'] = [m for m in self.methods]
+ json_dict['can_have_permissions'] = bool(
+ self.permission_filter_objectclasses)
+ return json_dict
+
+
+# addattr can cause parameters to have more than one value even if not defined
+# as multivalue, make sure this isn't the case
+def _check_single_value_attrs(params, entry_attrs):
+ for (a, v) in entry_attrs.items():
+ if isinstance(v, (list, tuple)) and len(v) > 1:
+ if a in params and not params[a].multivalue:
+ raise errors.OnlyOneValueAllowed(attr=a)
+
+# setattr or --option='' can cause parameters to be empty that are otherwise
+# required, make sure we enforce that.
+def _check_empty_attrs(params, entry_attrs):
+ for (a, v) in entry_attrs.items():
+ if v is None or (isinstance(v, six.string_types) and len(v) == 0):
+ if a in params and params[a].required:
+ raise errors.RequirementError(name=a)
+
+
+def _check_limit_object_class(attributes, attrs, allow_only):
+ """
+ If the set of objectclasses is limited enforce that only those
+ are updated in entry_attrs (plus dn)
+
+ allow_only tells us what mode to check in:
+
+ If True then we enforce that the attributes must be in the list of
+ allowed.
+
+ If False then those attributes are not allowed.
+ """
+ if len(attributes[0]) == 0 and len(attributes[1]) == 0:
+ return
+ limitattrs = deepcopy(attrs)
+ # Go through the MUST first
+ for (oid, attr) in attributes[0].items():
+ if attr.names[0].lower() in limitattrs:
+ if not allow_only:
+ raise errors.ObjectclassViolation(
+ info=_('attribute "%(attribute)s" not allowed') % dict(
+ attribute=attr.names[0].lower()))
+ limitattrs.remove(attr.names[0].lower())
+ # And now the MAY
+ for (oid, attr) in attributes[1].items():
+ if attr.names[0].lower() in limitattrs:
+ if not allow_only:
+ raise errors.ObjectclassViolation(
+ info=_('attribute "%(attribute)s" not allowed') % dict(
+ attribute=attr.names[0].lower()))
+ limitattrs.remove(attr.names[0].lower())
+ if len(limitattrs) > 0 and allow_only:
+ raise errors.ObjectclassViolation(
+ info=_('attribute "%(attribute)s" not allowed') % dict(
+ attribute=limitattrs[0]))
+
+
+class BaseLDAPCommand(Method):
+ """
+ Base class for Base LDAP Commands.
+ """
+ setattr_option = Str('setattr*', validate_set_attribute,
+ cli_name='setattr',
+ doc=_("""Set an attribute to a name/value pair. Format is attr=value.
+For multi-valued attributes, the command replaces the values already present."""),
+ exclude='webui',
+ )
+ addattr_option = Str('addattr*', validate_add_attribute,
+ cli_name='addattr',
+ doc=_("""Add an attribute/value pair. Format is attr=value. The attribute
+must be part of the schema."""),
+ exclude='webui',
+ )
+ delattr_option = Str('delattr*', validate_del_attribute,
+ cli_name='delattr',
+ doc=_("""Delete an attribute/value pair. The option will be evaluated
+last, after all sets and adds."""),
+ exclude='webui',
+ )
+
+ callback_types = Method.callback_types + ('pre',
+ 'post',
+ 'exc')
+
+ def get_summary_default(self, output):
+ if 'value' in output:
+ output = dict(output)
+ output['value'] = pkey_to_unicode(output['value'])
+ return super(BaseLDAPCommand, self).get_summary_default(output)
+
+ def _convert_2_dict(self, ldap, attrs):
+ """
+ Convert a string in the form of name/value pairs into a dictionary.
+
+ :param attrs: A list of name/value pair strings, in the "name=value"
+ format. May also be a single string, or None.
+ """
+
+ newdict = {}
+ if attrs is None:
+ attrs = []
+ elif not type(attrs) in (list, tuple):
+ attrs = [attrs]
+ for a in attrs:
+ m = re.match("\s*(.*?)\s*=\s*(.*?)\s*$", a)
+ attr = str(m.group(1)).lower()
+ value = m.group(2)
+ if attr in self.obj.params and attr not in self.params:
+ # The attribute is managed by IPA, but it didn't get cloned
+ # to the command. This happens with no_update/no_create attrs.
+ raise errors.ValidationError(
+ name=attr, error=_('attribute is not configurable'))
+ if len(value) == 0:
+ # None means "delete this attribute"
+ value = None
+
+ if attr in newdict:
+ if type(value) in (tuple,):
+ newdict[attr] += list(value)
+ else:
+ newdict[attr].append(value)
+ else:
+ if type(value) in (tuple,):
+ newdict[attr] = list(value)
+ else:
+ newdict[attr] = [value]
+ return newdict
+
+ def process_attr_options(self, entry_attrs, dn, keys, options):
+ """
+ Process all --setattr, --addattr, and --delattr options and add the
+ resulting value to the list of attributes. --setattr is processed first,
+ then --addattr and finally --delattr.
+
+ When --setattr is not used then the original LDAP object is looked up
+ (of course, not when dn is None) and the changes are applied to old
+ object values.
+
+ Attribute values deleted by --delattr may be deleted from attribute
+ values set or added by --setattr, --addattr. For example, the following
+ attributes will result in a NOOP:
+
+ --addattr=attribute=foo --delattr=attribute=foo
+
+ AttrValueNotFound exception may be raised when an attribute value was
+ not found either by --setattr and --addattr nor in existing LDAP object.
+
+ :param entry_attrs: A list of attributes that will be updated
+ :param dn: dn of updated LDAP object or None if a new object is created
+ :param keys: List of command arguments
+ :param options: List of options
+ """
+
+ if all(k not in options for k in ("setattr", "addattr", "delattr")):
+ return
+
+ ldap = self.obj.backend
+
+ adddict = self._convert_2_dict(ldap, options.get('addattr', []))
+ setdict = self._convert_2_dict(ldap, options.get('setattr', []))
+ deldict = self._convert_2_dict(ldap, options.get('delattr', []))
+
+ setattrs = set(setdict)
+ addattrs = set(adddict)
+ delattrs = set(deldict)
+
+ if dn is None:
+ direct_add = addattrs
+ direct_del = delattrs
+ needldapattrs = []
+ else:
+ assert isinstance(dn, DN)
+ direct_add = setattrs & addattrs
+ direct_del = setattrs & delattrs
+ needldapattrs = list((addattrs | delattrs) - setattrs)
+
+ for attr, val in setdict.items():
+ entry_attrs[attr] = val
+
+ for attr in direct_add:
+ try:
+ val = entry_attrs[attr]
+ except KeyError:
+ val = []
+ else:
+ if not isinstance(val, (list, tuple)):
+ val = [val]
+ elif isinstance(val, tuple):
+ val = list(val)
+ val.extend(adddict[attr])
+ entry_attrs[attr] = val
+
+ for attr in direct_del:
+ for delval in deldict[attr]:
+ try:
+ entry_attrs[attr].remove(delval)
+ except ValueError:
+ raise errors.AttrValueNotFound(attr=attr, value=delval)
+
+ if needldapattrs:
+ try:
+ old_entry = self._exc_wrapper(keys, options, ldap.get_entry)(
+ dn, needldapattrs
+ )
+ except errors.NotFound:
+ self.obj.handle_not_found(*keys)
+
+ # Provide a nice error message when user tries to delete an
+ # attribute that does not exist on the entry (and user is not
+ # adding it)
+ names = set(n.lower() for n in old_entry)
+ del_nonexisting = delattrs - (names | setattrs | addattrs)
+ if del_nonexisting:
+ raise errors.ValidationError(name=del_nonexisting.pop(),
+ error=_('No such attribute on this entry'))
+
+ for attr in needldapattrs:
+ entry_attrs[attr] = old_entry.get(attr, [])
+
+ if attr in addattrs:
+ entry_attrs[attr].extend(adddict.get(attr, []))
+
+ for delval in deldict.get(attr, []):
+ try:
+ entry_attrs[attr].remove(delval)
+ except ValueError:
+ if isinstance(delval, bytes):
+ # This is a Binary value, base64 encode it
+ delval = unicode(base64.b64encode(delval))
+ raise errors.AttrValueNotFound(attr=attr, value=delval)
+
+ # normalize all values
+ changedattrs = setattrs | addattrs | delattrs
+ for attr in changedattrs:
+ if attr in self.params and self.params[attr].attribute:
+ # convert single-value params to scalars
+ param = self.params[attr]
+ value = entry_attrs[attr]
+ if not param.multivalue:
+ if len(value) == 1:
+ value = value[0]
+ elif not value:
+ value = None
+ else:
+ raise errors.OnlyOneValueAllowed(attr=attr)
+ # validate, convert and encode params
+ try:
+ value = param(value)
+ param.validate(value)
+ except errors.ValidationError as err:
+ raise errors.ValidationError(name=attr, error=err.error)
+ except errors.ConversionError as err:
+ raise errors.ConversionError(name=attr, error=err.error)
+ if isinstance(value, tuple):
+ value = list(value)
+ entry_attrs[attr] = value
+ else:
+ # unknown attribute: remove duplicite and invalid values
+ entry_attrs[attr] = list(set([val for val in entry_attrs[attr] if val]))
+ if not entry_attrs[attr]:
+ entry_attrs[attr] = None
+ elif isinstance(entry_attrs[attr], (tuple, list)) and len(entry_attrs[attr]) == 1:
+ entry_attrs[attr] = entry_attrs[attr][0]
+
+ @classmethod
+ def register_pre_callback(cls, callback, first=False):
+ """Shortcut for register_callback('pre', ...)"""
+ cls.register_callback('pre', callback, first)
+
+ @classmethod
+ def register_post_callback(cls, callback, first=False):
+ """Shortcut for register_callback('post', ...)"""
+ cls.register_callback('post', callback, first)
+
+ @classmethod
+ def register_exc_callback(cls, callback, first=False):
+ """Shortcut for register_callback('exc', ...)"""
+ cls.register_callback('exc', callback, first)
+
+ def _exc_wrapper(self, keys, options, call_func):
+ """Function wrapper that automatically calls exception callbacks"""
+ def wrapped(*call_args, **call_kwargs):
+ # call call_func first
+ func = call_func
+ callbacks = list(self.get_callbacks('exc'))
+ while True:
+ try:
+ return func(*call_args, **call_kwargs)
+ except errors.ExecutionError as exc:
+ e = exc
+ if not callbacks:
+ raise
+ # call exc_callback in the next loop
+ callback = callbacks.pop(0)
+ def exc_func(*args, **kwargs):
+ return callback(
+ self, keys, options, e, call_func, *args, **kwargs)
+ func = exc_func
+ return wrapped
+
+ def get_options(self):
+ for param in super(BaseLDAPCommand, self).get_options():
+ yield param
+ if self.obj.attribute_members:
+ for o in self.has_output:
+ if isinstance(o, (output.Entry, output.ListOfEntries)):
+ yield Flag('no_members',
+ doc=_('Suppress processing of membership attributes.'),
+ exclude='webui',
+ flags={'no_output'},
+ )
+ break
+
+class LDAPCreate(BaseLDAPCommand, crud.Create):
+ """
+ Create a new entry in LDAP.
+ """
+ takes_options = (BaseLDAPCommand.setattr_option, BaseLDAPCommand.addattr_option)
+
+ def get_args(self):
+ for key in self.obj.get_ancestor_primary_keys():
+ yield key
+ for arg in super(LDAPCreate, self).get_args():
+ yield arg
+
+ has_output_params = global_output_params
+
+ def execute(self, *keys, **options):
+ ldap = self.obj.backend
+
+ dn = self.obj.get_dn(*keys, **options)
+ entry_attrs = ldap.make_entry(
+ dn, self.args_options_2_entry(*keys, **options))
+
+ self.process_attr_options(entry_attrs, None, keys, options)
+
+ entry_attrs['objectclass'] = deepcopy(self.obj.object_class)
+
+ if self.obj.object_class_config:
+ config = ldap.get_ipa_config()
+ entry_attrs['objectclass'] = config.get(
+ self.obj.object_class_config, entry_attrs['objectclass']
+ )
+
+ if self.obj.uuid_attribute:
+ entry_attrs[self.obj.uuid_attribute] = 'autogenerate'
+
+ if self.obj.rdn_attribute:
+ try:
+ dn_attr = dn[0].attr
+ except (IndexError, KeyError):
+ dn_attr = None
+ if dn_attr != self.obj.primary_key.name:
+ self.obj.handle_duplicate_entry(*keys)
+ entry_attrs.dn = ldap.make_dn(
+ entry_attrs, self.obj.rdn_attribute,
+ DN(self.obj.container_dn, api.env.basedn))
+
+ if options.get('all', False):
+ attrs_list = ['*'] + self.obj.default_attributes
+ else:
+ attrs_list = set(self.obj.default_attributes)
+ attrs_list.update(entry_attrs.keys())
+ if options.get('no_members', False):
+ attrs_list.difference_update(self.obj.attribute_members)
+ attrs_list = list(attrs_list)
+
+ for callback in self.get_callbacks('pre'):
+ entry_attrs.dn = callback(
+ self, ldap, entry_attrs.dn, entry_attrs, attrs_list,
+ *keys, **options)
+
+ _check_single_value_attrs(self.params, entry_attrs)
+ _check_limit_object_class(self.api.Backend.ldap2.schema.attribute_types(self.obj.limit_object_classes), list(entry_attrs), allow_only=True)
+ _check_limit_object_class(self.api.Backend.ldap2.schema.attribute_types(self.obj.disallow_object_classes), list(entry_attrs), allow_only=False)
+
+ try:
+ self._exc_wrapper(keys, options, ldap.add_entry)(entry_attrs)
+ except errors.NotFound:
+ parent = self.obj.parent_object
+ if parent:
+ raise errors.NotFound(
+ reason=self.obj.parent_not_found_msg % {
+ 'parent': keys[-2],
+ 'oname': self.api.Object[parent].object_name,
+ }
+ )
+ raise errors.NotFound(
+ reason=self.obj.container_not_found_msg % {
+ 'container': self.obj.container_dn,
+ }
+ )
+ except errors.DuplicateEntry:
+ self.obj.handle_duplicate_entry(*keys)
+
+ try:
+ if self.obj.rdn_attribute:
+ # make sure objectclass is either set or None
+ if self.obj.object_class:
+ object_class = self.obj.object_class
+ else:
+ object_class = None
+ entry_attrs = self._exc_wrapper(keys, options, ldap.find_entry_by_attr)(
+ self.obj.primary_key.name, keys[-1], object_class, attrs_list,
+ DN(self.obj.container_dn, api.env.basedn)
+ )
+ else:
+ entry_attrs = self._exc_wrapper(keys, options, ldap.get_entry)(
+ entry_attrs.dn, attrs_list)
+ except errors.NotFound:
+ self.obj.handle_not_found(*keys)
+
+ self.obj.get_indirect_members(entry_attrs, attrs_list)
+
+ for callback in self.get_callbacks('post'):
+ entry_attrs.dn = callback(
+ self, ldap, entry_attrs.dn, entry_attrs, *keys, **options)
+
+ self.obj.convert_attribute_members(entry_attrs, *keys, **options)
+
+ dn = entry_attrs.dn
+ entry_attrs = entry_to_dict(entry_attrs, **options)
+ entry_attrs['dn'] = dn
+
+ if self.obj.primary_key:
+ pkey = keys[-1]
+ else:
+ pkey = None
+
+ return dict(result=entry_attrs, value=pkey_to_value(pkey, options))
+
+ def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options):
+ assert isinstance(dn, DN)
+ return dn
+
+ def post_callback(self, ldap, dn, entry_attrs, *keys, **options):
+ assert isinstance(dn, DN)
+ return dn
+
+ def exc_callback(self, keys, options, exc, call_func, *call_args, **call_kwargs):
+ raise exc
+
+
+class LDAPQuery(BaseLDAPCommand, crud.PKQuery):
+ """
+ Base class for commands that need to retrieve an existing entry.
+ """
+ def get_args(self):
+ for key in self.obj.get_ancestor_primary_keys():
+ yield key
+ for arg in super(LDAPQuery, self).get_args():
+ yield arg
+
+
+class LDAPMultiQuery(LDAPQuery):
+ """
+ Base class for commands that need to retrieve one or more existing entries.
+ """
+ takes_options = (
+ Flag('continue',
+ cli_name='continue',
+ doc=_('Continuous mode: Don\'t stop on errors.'),
+ ),
+ )
+
+ def get_args(self):
+ for arg in super(LDAPMultiQuery, self).get_args():
+ if self.obj.primary_key and arg.name == self.obj.primary_key.name:
+ yield arg.clone(multivalue=True)
+ else:
+ yield arg
+
+
+class LDAPRetrieve(LDAPQuery):
+ """
+ Retrieve an LDAP entry.
+ """
+ has_output = output.standard_entry
+ has_output_params = global_output_params
+
+ takes_options = (
+ Flag('rights',
+ label=_('Rights'),
+ doc=_('Display the access rights of this entry (requires --all). See ipa man page for details.'),
+ ),
+ )
+
+ def execute(self, *keys, **options):
+ ldap = self.obj.backend
+
+ dn = self.obj.get_dn(*keys, **options)
+ assert isinstance(dn, DN)
+
+ if options.get('all', False):
+ attrs_list = ['*'] + self.obj.default_attributes
+ else:
+ attrs_list = set(self.obj.default_attributes)
+ if options.get('no_members', False):
+ attrs_list.difference_update(self.obj.attribute_members)
+ attrs_list = list(attrs_list)
+
+ for callback in self.get_callbacks('pre'):
+ dn = callback(self, ldap, dn, attrs_list, *keys, **options)
+ assert isinstance(dn, DN)
+
+ try:
+ entry_attrs = self._exc_wrapper(keys, options, ldap.get_entry)(
+ dn, attrs_list
+ )
+ except errors.NotFound:
+ self.obj.handle_not_found(*keys)
+
+ self.obj.get_indirect_members(entry_attrs, attrs_list)
+
+ if options.get('rights', False) and options.get('all', False):
+ entry_attrs['attributelevelrights'] = get_effective_rights(
+ ldap, entry_attrs.dn)
+
+ for callback in self.get_callbacks('post'):
+ entry_attrs.dn = callback(
+ self, ldap, entry_attrs.dn, entry_attrs, *keys, **options)
+
+ self.obj.convert_attribute_members(entry_attrs, *keys, **options)
+
+ dn = entry_attrs.dn
+ entry_attrs = entry_to_dict(entry_attrs, **options)
+ entry_attrs['dn'] = dn
+
+ if self.obj.primary_key:
+ pkey = keys[-1]
+ else:
+ pkey = None
+
+ return dict(result=entry_attrs, value=pkey_to_value(pkey, options))
+
+ def pre_callback(self, ldap, dn, attrs_list, *keys, **options):
+ assert isinstance(dn, DN)
+ return dn
+
+ def post_callback(self, ldap, dn, entry_attrs, *keys, **options):
+ assert isinstance(dn, DN)
+ return dn
+
+ def exc_callback(self, keys, options, exc, call_func, *call_args, **call_kwargs):
+ raise exc
+
+
+class LDAPUpdate(LDAPQuery, crud.Update):
+ """
+ Update an LDAP entry.
+ """
+
+ takes_options = (
+ BaseLDAPCommand.setattr_option,
+ BaseLDAPCommand.addattr_option,
+ BaseLDAPCommand.delattr_option,
+ Flag('rights',
+ label=_('Rights'),
+ doc=_('Display the access rights of this entry (requires --all). See ipa man page for details.'),
+ ),
+ )
+
+ has_output_params = global_output_params
+
+ def _get_rename_option(self):
+ rdnparam = getattr(self.obj.params, self.obj.primary_key.name)
+ return rdnparam.clone_rename('rename',
+ cli_name='rename', required=False, label=_('Rename'),
+ doc=_('Rename the %(ldap_obj_name)s object') % dict(
+ ldap_obj_name=self.obj.object_name
+ )
+ )
+
+ def get_options(self):
+ for option in super(LDAPUpdate, self).get_options():
+ yield option
+ if self.obj.rdn_is_primary_key:
+ yield self._get_rename_option()
+
+ def execute(self, *keys, **options):
+ ldap = self.obj.backend
+
+ if len(options) == 2: # 'all' and 'raw' are always sent
+ raise errors.EmptyModlist()
+
+ dn = self.obj.get_dn(*keys, **options)
+ entry_attrs = ldap.make_entry(dn, self.args_options_2_entry(**options))
+
+ self.process_attr_options(entry_attrs, dn, keys, options)
+
+ if options.get('all', False):
+ attrs_list = ['*'] + self.obj.default_attributes
+ else:
+ attrs_list = set(self.obj.default_attributes)
+ attrs_list.update(entry_attrs.keys())
+ if options.get('no_members', False):
+ attrs_list.difference_update(self.obj.attribute_members)
+ attrs_list = list(attrs_list)
+
+ _check_single_value_attrs(self.params, entry_attrs)
+ _check_empty_attrs(self.obj.params, entry_attrs)
+
+ for callback in self.get_callbacks('pre'):
+ entry_attrs.dn = callback(
+ self, ldap, entry_attrs.dn, entry_attrs, attrs_list,
+ *keys, **options)
+
+ _check_limit_object_class(self.api.Backend.ldap2.schema.attribute_types(self.obj.limit_object_classes), list(entry_attrs), allow_only=True)
+ _check_limit_object_class(self.api.Backend.ldap2.schema.attribute_types(self.obj.disallow_object_classes), list(entry_attrs), allow_only=False)
+
+ rdnupdate = False
+ try:
+ if self.obj.rdn_is_primary_key and 'rename' in options:
+ if not options['rename']:
+ raise errors.ValidationError(name='rename', error=u'can\'t be empty')
+ entry_attrs[self.obj.primary_key.name] = options['rename']
+
+ if self.obj.rdn_is_primary_key and self.obj.primary_key.name in entry_attrs:
+ try:
+ # RDN change
+ new_dn = DN((self.obj.primary_key.name,
+ entry_attrs[self.obj.primary_key.name]),
+ *entry_attrs.dn[1:])
+ self._exc_wrapper(keys, options, ldap.move_entry)(
+ entry_attrs.dn,
+ new_dn)
+
+ rdnkeys = keys[:-1] + (entry_attrs[self.obj.primary_key.name], )
+ entry_attrs.dn = self.obj.get_dn(*rdnkeys)
+ options['rdnupdate'] = True
+ rdnupdate = True
+ except errors.EmptyModlist:
+ # Attempt to rename to the current name, ignore
+ pass
+ finally:
+ # Delete the primary_key from entry_attrs either way
+ del entry_attrs[self.obj.primary_key.name]
+
+ # Exception callbacks will need to test for options['rdnupdate']
+ # to decide what to do. An EmptyModlist in this context doesn't
+ # mean an error occurred, just that there were no other updates to
+ # perform.
+ update = self._exc_wrapper(keys, options, ldap.get_entry)(
+ entry_attrs.dn, list(entry_attrs))
+ update.update(entry_attrs)
+
+ self._exc_wrapper(keys, options, ldap.update_entry)(update)
+ except errors.EmptyModlist as e:
+ if not rdnupdate:
+ raise e
+ except errors.NotFound:
+ self.obj.handle_not_found(*keys)
+
+ try:
+ entry_attrs = self._exc_wrapper(keys, options, ldap.get_entry)(
+ entry_attrs.dn, attrs_list)
+ except errors.NotFound:
+ raise errors.MidairCollision(
+ format=_('the entry was deleted while being modified')
+ )
+
+ self.obj.get_indirect_members(entry_attrs, attrs_list)
+
+ if options.get('rights', False) and options.get('all', False):
+ entry_attrs['attributelevelrights'] = get_effective_rights(
+ ldap, entry_attrs.dn)
+
+ for callback in self.get_callbacks('post'):
+ entry_attrs.dn = callback(
+ self, ldap, entry_attrs.dn, entry_attrs, *keys, **options)
+
+ self.obj.convert_attribute_members(entry_attrs, *keys, **options)
+
+ entry_attrs = entry_to_dict(entry_attrs, **options)
+
+ if self.obj.primary_key:
+ pkey = keys[-1]
+ else:
+ pkey = None
+
+ return dict(result=entry_attrs, value=pkey_to_value(pkey, options))
+
+ def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options):
+ assert isinstance(dn, DN)
+ return dn
+
+ def post_callback(self, ldap, dn, entry_attrs, *keys, **options):
+ assert isinstance(dn, DN)
+ return dn
+
+ def exc_callback(self, keys, options, exc, call_func, *call_args, **call_kwargs):
+ raise exc
+
+
+class LDAPDelete(LDAPMultiQuery):
+ """
+ Delete an LDAP entry and all of its direct subentries.
+ """
+ has_output = output.standard_multi_delete
+
+ has_output_params = global_output_params
+
+ subtree_delete = True
+
+ def execute(self, *keys, **options):
+ ldap = self.obj.backend
+
+ def delete_entry(pkey):
+ nkeys = keys[:-1] + (pkey, )
+ dn = self.obj.get_dn(*nkeys, **options)
+ assert isinstance(dn, DN)
+
+ for callback in self.get_callbacks('pre'):
+ dn = callback(self, ldap, dn, *nkeys, **options)
+ assert isinstance(dn, DN)
+
+ def delete_subtree(base_dn):
+ assert isinstance(base_dn, DN)
+ truncated = True
+ while truncated:
+ try:
+ (subentries, truncated) = ldap.find_entries(
+ None, [''], base_dn, ldap.SCOPE_ONELEVEL
+ )
+ except errors.NotFound:
+ break
+ else:
+ for entry_attrs in subentries:
+ delete_subtree(entry_attrs.dn)
+ try:
+ self._exc_wrapper(nkeys, options, ldap.delete_entry)(base_dn)
+ except errors.NotFound:
+ self.obj.handle_not_found(*nkeys)
+
+ try:
+ self._exc_wrapper(nkeys, options, ldap.delete_entry)(dn)
+ except errors.NotFound:
+ self.obj.handle_not_found(*nkeys)
+ except errors.NotAllowedOnNonLeaf:
+ if not self.subtree_delete:
+ raise
+ # this entry is not a leaf entry, delete all child nodes
+ delete_subtree(dn)
+
+ for callback in self.get_callbacks('post'):
+ result = callback(self, ldap, dn, *nkeys, **options)
+
+ return result
+
+ if self.obj.primary_key and isinstance(keys[-1], (list, tuple)):
+ pkeyiter = keys[-1]
+ elif keys[-1] is not None:
+ pkeyiter = [keys[-1]]
+ else:
+ pkeyiter = []
+
+ deleted = []
+ failed = []
+ for pkey in pkeyiter:
+ try:
+ delete_entry(pkey)
+ except errors.ExecutionError:
+ if not options.get('continue', False):
+ raise
+ failed.append(pkey)
+ else:
+ deleted.append(pkey)
+ deleted = pkey_to_value(deleted, options)
+ failed = pkey_to_value(failed, options)
+
+ return dict(result=dict(failed=failed), value=deleted)
+
+ def pre_callback(self, ldap, dn, *keys, **options):
+ assert isinstance(dn, DN)
+ return dn
+
+ def post_callback(self, ldap, dn, *keys, **options):
+ assert isinstance(dn, DN)
+ return True
+
+ def exc_callback(self, keys, options, exc, call_func, *call_args, **call_kwargs):
+ raise exc
+
+
+class LDAPModMember(LDAPQuery):
+ """
+ Base class for member manipulation.
+ """
+ member_attributes = ['member']
+ member_param_doc = _('%s')
+ member_param_label = _('member %s')
+ member_count_out = ('%i member processed.', '%i members processed.')
+
+ def get_options(self):
+ for option in super(LDAPModMember, self).get_options():
+ yield option
+ for attr in self.member_attributes:
+ for ldap_obj_name in self.obj.attribute_members[attr]:
+ ldap_obj = self.api.Object[ldap_obj_name]
+ name = to_cli(ldap_obj_name)
+ doc = self.member_param_doc % ldap_obj.object_name_plural
+ label = self.member_param_label % ldap_obj.object_name
+ yield Str('%s*' % name, cli_name='%ss' % name, doc=doc,
+ label=label, alwaysask=True)
+
+ def get_member_dns(self, **options):
+ dns = {}
+ failed = {}
+ for attr in self.member_attributes:
+ dns[attr] = {}
+ failed[attr] = {}
+ for ldap_obj_name in self.obj.attribute_members[attr]:
+ dns[attr][ldap_obj_name] = []
+ failed[attr][ldap_obj_name] = []
+ names = options.get(to_cli(ldap_obj_name), [])
+ if not names:
+ continue
+ for name in names:
+ if not name:
+ continue
+ ldap_obj = self.api.Object[ldap_obj_name]
+ try:
+ dns[attr][ldap_obj_name].append(ldap_obj.get_dn(name))
+ except errors.PublicError as e:
+ failed[attr][ldap_obj_name].append((name, unicode(e)))
+ return (dns, failed)
+
+
+class LDAPAddMember(LDAPModMember):
+ """
+ Add other LDAP entries to members.
+ """
+ member_param_doc = _('%s to add')
+ member_count_out = ('%i member added.', '%i members added.')
+ allow_same = False
+
+ has_output = (
+ output.Entry('result'),
+ output.Output('failed',
+ type=dict,
+ doc=_('Members that could not be added'),
+ ),
+ output.Output('completed',
+ type=int,
+ doc=_('Number of members added'),
+ ),
+ )
+
+ has_output_params = global_output_params
+
+ def execute(self, *keys, **options):
+ ldap = self.obj.backend
+
+ (member_dns, failed) = self.get_member_dns(**options)
+
+ dn = self.obj.get_dn(*keys, **options)
+ assert isinstance(dn, DN)
+
+ for callback in self.get_callbacks('pre'):
+ dn = callback(self, ldap, dn, member_dns, failed, *keys, **options)
+ assert isinstance(dn, DN)
+
+ completed = 0
+ for (attr, objs) in member_dns.items():
+ for ldap_obj_name in objs:
+ for m_dn in member_dns[attr][ldap_obj_name]:
+ assert isinstance(m_dn, DN)
+ if not m_dn:
+ continue
+ try:
+ ldap.add_entry_to_group(m_dn, dn, attr, allow_same=self.allow_same)
+ except errors.PublicError as e:
+ ldap_obj = self.api.Object[ldap_obj_name]
+ failed[attr][ldap_obj_name].append((
+ ldap_obj.get_primary_key_from_dn(m_dn),
+ unicode(e),)
+ )
+ else:
+ completed += 1
+
+ if options.get('all', False):
+ attrs_list = ['*'] + self.obj.default_attributes
+ else:
+ attrs_list = set(self.obj.default_attributes)
+ attrs_list.update(member_dns.keys())
+ if options.get('no_members', False):
+ attrs_list.difference_update(self.obj.attribute_members)
+ attrs_list = list(attrs_list)
+
+ try:
+ entry_attrs = self._exc_wrapper(keys, options, ldap.get_entry)(
+ dn, attrs_list
+ )
+ except errors.NotFound:
+ self.obj.handle_not_found(*keys)
+
+ self.obj.get_indirect_members(entry_attrs, attrs_list)
+
+ for callback in self.get_callbacks('post'):
+ (completed, entry_attrs.dn) = callback(
+ self, ldap, completed, failed, entry_attrs.dn, entry_attrs,
+ *keys, **options)
+
+ self.obj.convert_attribute_members(entry_attrs, *keys, **options)
+
+ dn = entry_attrs.dn
+ entry_attrs = entry_to_dict(entry_attrs, **options)
+ entry_attrs['dn'] = dn
+
+ return dict(
+ completed=completed,
+ failed=failed,
+ result=entry_attrs,
+ )
+
+ def pre_callback(self, ldap, dn, found, not_found, *keys, **options):
+ assert isinstance(dn, DN)
+ return dn
+
+ def post_callback(self, ldap, completed, failed, dn, entry_attrs, *keys, **options):
+ assert isinstance(dn, DN)
+ return (completed, dn)
+
+ def exc_callback(self, keys, options, exc, call_func, *call_args, **call_kwargs):
+ raise exc
+
+
+class LDAPRemoveMember(LDAPModMember):
+ """
+ Remove LDAP entries from members.
+ """
+ member_param_doc = _('%s to remove')
+ member_count_out = ('%i member removed.', '%i members removed.')
+
+ has_output = (
+ output.Entry('result'),
+ output.Output('failed',
+ type=dict,
+ doc=_('Members that could not be removed'),
+ ),
+ output.Output('completed',
+ type=int,
+ doc=_('Number of members removed'),
+ ),
+ )
+
+ has_output_params = global_output_params
+
+ def execute(self, *keys, **options):
+ ldap = self.obj.backend
+
+ (member_dns, failed) = self.get_member_dns(**options)
+
+ dn = self.obj.get_dn(*keys, **options)
+ assert isinstance(dn, DN)
+
+ for callback in self.get_callbacks('pre'):
+ dn = callback(self, ldap, dn, member_dns, failed, *keys, **options)
+ assert isinstance(dn, DN)
+
+ completed = 0
+ for (attr, objs) in member_dns.items():
+ for ldap_obj_name, m_dns in objs.items():
+ for m_dn in m_dns:
+ assert isinstance(m_dn, DN)
+ if not m_dn:
+ continue
+ try:
+ ldap.remove_entry_from_group(m_dn, dn, attr)
+ except errors.PublicError as e:
+ ldap_obj = self.api.Object[ldap_obj_name]
+ failed[attr][ldap_obj_name].append((
+ ldap_obj.get_primary_key_from_dn(m_dn),
+ unicode(e),)
+ )
+ else:
+ completed += 1
+
+ if options.get('all', False):
+ attrs_list = ['*'] + self.obj.default_attributes
+ else:
+ attrs_list = set(self.obj.default_attributes)
+ attrs_list.update(member_dns.keys())
+ if options.get('no_members', False):
+ attrs_list.difference_update(self.obj.attribute_members)
+ attrs_list = list(attrs_list)
+
+ # Give memberOf a chance to update entries
+ time.sleep(.3)
+
+ try:
+ entry_attrs = self._exc_wrapper(keys, options, ldap.get_entry)(
+ dn, attrs_list
+ )
+ except errors.NotFound:
+ self.obj.handle_not_found(*keys)
+
+ self.obj.get_indirect_members(entry_attrs, attrs_list)
+
+ for callback in self.get_callbacks('post'):
+ (completed, entry_attrs.dn) = callback(
+ self, ldap, completed, failed, entry_attrs.dn, entry_attrs,
+ *keys, **options)
+
+ self.obj.convert_attribute_members(entry_attrs, *keys, **options)
+
+ dn = entry_attrs.dn
+ entry_attrs = entry_to_dict(entry_attrs, **options)
+ entry_attrs['dn'] = dn
+
+ return dict(
+ completed=completed,
+ failed=failed,
+ result=entry_attrs,
+ )
+
+ def pre_callback(self, ldap, dn, found, not_found, *keys, **options):
+ assert isinstance(dn, DN)
+ return dn
+
+ def post_callback(self, ldap, completed, failed, dn, entry_attrs, *keys, **options):
+ assert isinstance(dn, DN)
+ return (completed, dn)
+
+ def exc_callback(self, keys, options, exc, call_func, *call_args, **call_kwargs):
+ raise exc
+
+
+def gen_pkey_only_option(cli_name):
+ return Flag('pkey_only?',
+ label=_('Primary key only'),
+ doc=_('Results should contain primary key attribute only ("%s")') \
+ % to_cli(cli_name),)
+
+class LDAPSearch(BaseLDAPCommand, crud.Search):
+ """
+ Retrieve all LDAP entries matching the given criteria.
+ """
+ member_attributes = []
+ member_param_incl_doc = _('Search for %(searched_object)s with these %(relationship)s %(ldap_object)s.')
+ member_param_excl_doc = _('Search for %(searched_object)s without these %(relationship)s %(ldap_object)s.')
+
+ # LDAPSearch sorts all matched records in the end using their primary key
+ # as a key attribute
+ # Set the following attribute to False to turn sorting off
+ sort_result_entries = True
+
+ takes_options = (
+ Int('timelimit?',
+ label=_('Time Limit'),
+ doc=_('Time limit of search in seconds (0 is unlimited)'),
+ flags=['no_display'],
+ minvalue=0,
+ autofill=False,
+ ),
+ Int('sizelimit?',
+ label=_('Size Limit'),
+ doc=_('Maximum number of entries returned (0 is unlimited)'),
+ flags=['no_display'],
+ minvalue=0,
+ autofill=False,
+ ),
+ )
+
+ def get_args(self):
+ for key in self.obj.get_ancestor_primary_keys():
+ yield key
+ for arg in super(LDAPSearch, self).get_args():
+ yield arg
+
+ def get_member_options(self, attr):
+ for ldap_obj_name in self.obj.attribute_members[attr]:
+ ldap_obj = self.api.Object[ldap_obj_name]
+ relationship = self.obj.relationships.get(
+ attr, ['member', '', 'no_']
+ )
+ doc = self.member_param_incl_doc % dict(
+ searched_object=self.obj.object_name_plural,
+ relationship=relationship[0].lower(),
+ ldap_object=ldap_obj.object_name_plural
+ )
+ name = '%s%s' % (relationship[1], to_cli(ldap_obj_name))
+ yield Str(
+ '%s*' % name, cli_name='%ss' % name, doc=doc,
+ label=ldap_obj.object_name
+ )
+ doc = self.member_param_excl_doc % dict(
+ searched_object=self.obj.object_name_plural,
+ relationship=relationship[0].lower(),
+ ldap_object=ldap_obj.object_name_plural
+ )
+ name = '%s%s' % (relationship[2], to_cli(ldap_obj_name))
+ yield Str(
+ '%s*' % name, cli_name='%ss' % name, doc=doc,
+ label=ldap_obj.object_name
+ )
+
+ def get_options(self):
+ for option in super(LDAPSearch, self).get_options():
+ if option.name == 'no_members':
+ # no_members are always true for find commands, do not
+ # show option in CLI but keep API compatibility
+ option = option.clone(
+ default=True, flags=option.flags | {"no_option"})
+ yield option
+ if self.obj.primary_key and \
+ 'no_output' not in self.obj.primary_key.flags:
+ yield gen_pkey_only_option(self.obj.primary_key.cli_name)
+ for attr in self.member_attributes:
+ for option in self.get_member_options(attr):
+ yield option
+
+ def get_member_filter(self, ldap, **options):
+ filter = ''
+ for attr in self.member_attributes:
+ for ldap_obj_name in self.obj.attribute_members[attr]:
+ ldap_obj = self.api.Object[ldap_obj_name]
+ relationship = self.obj.relationships.get(
+ attr, ['member', '', 'no_']
+ )
+ # Handle positive (MATCH_ALL) and negative (MATCH_NONE)
+ # searches similarly
+ param_prefixes = relationship[1:] # e.g. ('in_', 'not_in_')
+ rules = ldap.MATCH_ALL, ldap.MATCH_NONE
+ for param_prefix, rule in zip(param_prefixes, rules):
+ param_name = '%s%s' % (param_prefix, to_cli(ldap_obj_name))
+ if options.get(param_name):
+ dns = []
+ for pkey in options[param_name]:
+ dns.append(ldap_obj.get_dn(pkey))
+ flt = ldap.make_filter_from_attr(attr, dns, rule)
+ filter = ldap.combine_filters(
+ (filter, flt), ldap.MATCH_ALL
+ )
+ return filter
+
+ has_output_params = global_output_params
+
+ def execute(self, *args, **options):
+ ldap = self.obj.backend
+
+ index = tuple(self.args).index('criteria')
+ keys = args[:index]
+ try:
+ term = args[index]
+ except IndexError:
+ term = None
+ if self.obj.parent_object:
+ base_dn = self.api.Object[self.obj.parent_object].get_dn(*keys)
+ else:
+ base_dn = DN(self.obj.container_dn, api.env.basedn)
+ assert isinstance(base_dn, DN)
+
+ search_kw = self.args_options_2_entry(**options)
+
+ if self.obj.search_display_attributes:
+ defattrs = self.obj.search_display_attributes
+ else:
+ defattrs = self.obj.default_attributes
+
+ if options.get('pkey_only', False):
+ attrs_list = [self.obj.primary_key.name]
+ elif options.get('all', False):
+ attrs_list = ['*'] + defattrs
+ else:
+ attrs_list = set(defattrs)
+ attrs_list.update(search_kw.keys())
+ if options.get('no_members', False):
+ attrs_list.difference_update(self.obj.attribute_members)
+ attrs_list = list(attrs_list)
+
+ if self.obj.search_attributes:
+ search_attrs = self.obj.search_attributes
+ else:
+ search_attrs = self.obj.default_attributes
+ if self.obj.search_attributes_config:
+ config = ldap.get_ipa_config()
+ config_attrs = config.get(
+ self.obj.search_attributes_config, [])
+ if len(config_attrs) == 1 and (
+ isinstance(config_attrs[0], six.string_types)):
+ search_attrs = config_attrs[0].split(',')
+
+ search_kw['objectclass'] = self.obj.object_class
+ attr_filter = ldap.make_filter(search_kw, rules=ldap.MATCH_ALL)
+
+ search_kw = {}
+ for a in search_attrs:
+ search_kw[a] = term
+ term_filter = ldap.make_filter(search_kw, exact=False)
+
+ member_filter = self.get_member_filter(ldap, **options)
+
+ filter = ldap.combine_filters(
+ (term_filter, attr_filter, member_filter), rules=ldap.MATCH_ALL
+ )
+
+ scope = ldap.SCOPE_ONELEVEL
+ for callback in self.get_callbacks('pre'):
+ (filter, base_dn, scope) = callback(
+ self, ldap, filter, attrs_list, base_dn, scope, *args, **options)
+ assert isinstance(base_dn, DN)
+
+ try:
+ (entries, truncated) = self._exc_wrapper(args, options, ldap.find_entries)(
+ filter, attrs_list, base_dn, scope,
+ time_limit=options.get('timelimit', None),
+ size_limit=options.get('sizelimit', None)
+ )
+ except errors.EmptyResult:
+ (entries, truncated) = ([], False)
+ except errors.NotFound:
+ self.api.Object[self.obj.parent_object].handle_not_found(*keys)
+
+ for callback in self.get_callbacks('post'):
+ truncated = callback(self, ldap, entries, truncated, *args, **options)
+
+ if self.sort_result_entries:
+ if self.obj.primary_key:
+ def sort_key(x):
+ return self.obj.primary_key.sort_key(
+ x[self.obj.primary_key.name][0])
+ entries.sort(key=sort_key)
+
+ if not options.get('raw', False):
+ for e in entries:
+ self.obj.get_indirect_members(e, attrs_list)
+ self.obj.convert_attribute_members(e, *args, **options)
+
+ for (i, e) in enumerate(entries):
+ entries[i] = entry_to_dict(e, **options)
+ entries[i]['dn'] = e.dn
+
+ result = dict(
+ result=entries,
+ count=len(entries),
+ truncated=bool(truncated),
+ )
+
+ try:
+ ldap.handle_truncated_result(truncated)
+ except errors.LimitsExceeded as e:
+ add_message(options['version'], result, SearchResultTruncated(
+ reason=e))
+
+ return result
+
+ def pre_callback(self, ldap, filters, attrs_list, base_dn, scope, *args, **options):
+ assert isinstance(base_dn, DN)
+ return (filters, base_dn, scope)
+
+ def post_callback(self, ldap, entries, truncated, *args, **options):
+ return truncated
+
+ def exc_callback(self, args, options, exc, call_func, *call_args, **call_kwargs):
+ raise exc
+
+
+class LDAPModReverseMember(LDAPQuery):
+ """
+ Base class for reverse member manipulation.
+ """
+ reverse_attributes = ['member']
+ reverse_param_doc = _('%s')
+ reverse_count_out = ('%i member processed.', '%i members processed.')
+
+ has_output_params = global_output_params
+
+ def get_options(self):
+ for option in super(LDAPModReverseMember, self).get_options():
+ yield option
+ for attr in self.reverse_attributes:
+ for ldap_obj_name in self.obj.reverse_members[attr]:
+ ldap_obj = self.api.Object[ldap_obj_name]
+ name = to_cli(ldap_obj_name)
+ doc = self.reverse_param_doc % ldap_obj.object_name_plural
+ yield Str('%s*' % name, cli_name='%ss' % name, doc=doc,
+ label=ldap_obj.object_name, alwaysask=True)
+
+
+class LDAPAddReverseMember(LDAPModReverseMember):
+ """
+ Add other LDAP entries to members in reverse.
+
+ The call looks like "add A to B" but in fact executes
+ add B to A to handle reverse membership.
+ """
+ member_param_doc = _('%s to add')
+ member_count_out = ('%i member added.', '%i members added.')
+
+ show_command = None
+ member_command = None
+ reverse_attr = None
+ member_attr = None
+
+ has_output = (
+ output.Entry('result'),
+ output.Output('failed',
+ type=dict,
+ doc=_('Members that could not be added'),
+ ),
+ output.Output('completed',
+ type=int,
+ doc=_('Number of members added'),
+ ),
+ )
+
+ has_output_params = global_output_params
+
+ def execute(self, *keys, **options):
+ ldap = self.obj.backend
+
+ # Ensure our target exists
+ result = self.api.Command[self.show_command](keys[-1])['result']
+ dn = result['dn']
+ assert isinstance(dn, DN)
+
+ for callback in self.get_callbacks('pre'):
+ dn = callback(self, ldap, dn, *keys, **options)
+ assert isinstance(dn, DN)
+
+ if options.get('all', False):
+ attrs_list = ['*'] + self.obj.default_attributes
+ else:
+ attrs_list = set(self.obj.default_attributes)
+ if options.get('no_members', False):
+ attrs_list.difference_update(self.obj.attribute_members)
+ attrs_list = list(attrs_list)
+
+ completed = 0
+ failed = {'member': {self.reverse_attr: []}}
+ for attr in options.get(self.reverse_attr) or []:
+ try:
+ options = {'%s' % self.member_attr: keys[-1]}
+ try:
+ result = self._exc_wrapper(keys, options, self.api.Command[self.member_command])(attr, **options)
+ if result['completed'] == 1:
+ completed = completed + 1
+ else:
+ failed['member'][self.reverse_attr].append((attr, result['failed']['member'][self.member_attr][0][1]))
+ except errors.NotFound as e:
+ msg = str(e)
+ (attr, msg) = msg.split(':', 1)
+ failed['member'][self.reverse_attr].append((attr, unicode(msg.strip())))
+
+ except errors.PublicError as e:
+ failed['member'][self.reverse_attr].append((attr, unicode(e)))
+
+ # Update the member data.
+ entry_attrs = ldap.get_entry(dn, ['*'])
+ self.obj.convert_attribute_members(entry_attrs, *keys, **options)
+
+ for callback in self.get_callbacks('post'):
+ (completed, entry_attrs.dn) = callback(
+ self, ldap, completed, failed, entry_attrs.dn, entry_attrs,
+ *keys, **options)
+
+ dn = entry_attrs.dn
+ entry_attrs = entry_to_dict(entry_attrs, **options)
+ entry_attrs['dn'] = dn
+
+ return dict(
+ completed=completed,
+ failed=failed,
+ result=entry_attrs,
+ )
+
+ def pre_callback(self, ldap, dn, *keys, **options):
+ assert isinstance(dn, DN)
+ return dn
+
+ def post_callback(self, ldap, completed, failed, dn, entry_attrs, *keys, **options):
+ assert isinstance(dn, DN)
+ return (completed, dn)
+
+ def exc_callback(self, keys, options, exc, call_func, *call_args, **call_kwargs):
+ raise exc
+
+
+class LDAPRemoveReverseMember(LDAPModReverseMember):
+ """
+ Remove other LDAP entries from members in reverse.
+
+ The call looks like "remove A from B" but in fact executes
+ remove B from A to handle reverse membership.
+ """
+ member_param_doc = _('%s to remove')
+ member_count_out = ('%i member removed.', '%i members removed.')
+
+ show_command = None
+ member_command = None
+ reverse_attr = None
+ member_attr = None
+
+ has_output = (
+ output.Entry('result'),
+ output.Output('failed',
+ type=dict,
+ doc=_('Members that could not be removed'),
+ ),
+ output.Output('completed',
+ type=int,
+ doc=_('Number of members removed'),
+ ),
+ )
+
+ has_output_params = global_output_params
+
+ def execute(self, *keys, **options):
+ ldap = self.obj.backend
+
+ # Ensure our target exists
+ result = self.api.Command[self.show_command](keys[-1])['result']
+ dn = result['dn']
+ assert isinstance(dn, DN)
+
+ for callback in self.get_callbacks('pre'):
+ dn = callback(self, ldap, dn, *keys, **options)
+ assert isinstance(dn, DN)
+
+ if options.get('all', False):
+ attrs_list = ['*'] + self.obj.default_attributes
+ else:
+ attrs_list = set(self.obj.default_attributes)
+ if options.get('no_members', False):
+ attrs_list.difference_update(self.obj.attribute_members)
+ attrs_list = list(attrs_list)
+
+ completed = 0
+ failed = {'member': {self.reverse_attr: []}}
+ for attr in options.get(self.reverse_attr) or []:
+ try:
+ options = {'%s' % self.member_attr: keys[-1]}
+ try:
+ result = self._exc_wrapper(keys, options, self.api.Command[self.member_command])(attr, **options)
+ if result['completed'] == 1:
+ completed = completed + 1
+ else:
+ failed['member'][self.reverse_attr].append((attr, result['failed']['member'][self.member_attr][0][1]))
+ except errors.NotFound as e:
+ msg = str(e)
+ (attr, msg) = msg.split(':', 1)
+ failed['member'][self.reverse_attr].append((attr, unicode(msg.strip())))
+
+ except errors.PublicError as e:
+ failed['member'][self.reverse_attr].append((attr, unicode(e)))
+
+ # Update the member data.
+ entry_attrs = ldap.get_entry(dn, ['*'])
+ self.obj.convert_attribute_members(entry_attrs, *keys, **options)
+
+ for callback in self.get_callbacks('post'):
+ (completed, entry_attrs.dn) = callback(
+ self, ldap, completed, failed, entry_attrs.dn, entry_attrs,
+ *keys, **options)
+
+ dn = entry_attrs.dn
+ entry_attrs = entry_to_dict(entry_attrs, **options)
+ entry_attrs['dn'] = dn
+
+ return dict(
+ completed=completed,
+ failed=failed,
+ result=entry_attrs,
+ )
+
+ def pre_callback(self, ldap, dn, *keys, **options):
+ assert isinstance(dn, DN)
+ return dn
+
+ def post_callback(self, ldap, completed, failed, dn, entry_attrs, *keys, **options):
+ assert isinstance(dn, DN)
+ return (completed, dn)
+
+ def exc_callback(self, keys, options, exc, call_func, *call_args, **call_kwargs):
+ raise exc
+
+
+class LDAPModAttribute(LDAPQuery):
+
+ attribute = None
+
+ has_output = output.standard_entry
+
+ def get_options(self):
+ for option in super(LDAPModAttribute, self).get_options():
+ yield option
+
+ option = self.obj.params[self.attribute]
+ attribute = 'virtual_attribute' not in option.flags
+ yield option.clone(attribute=attribute, alwaysask=True)
+
+ def _update_attrs(self, update, entry_attrs):
+ raise NotImplementedError("%s.update_attrs()", self.__class__.__name__)
+
+ def execute(self, *keys, **options):
+ ldap = self.obj.backend
+
+ dn = self.obj.get_dn(*keys, **options)
+ entry_attrs = ldap.make_entry(dn, self.args_options_2_entry(**options))
+
+ if options.get('all', False):
+ attrs_list = ['*', self.obj.primary_key.name]
+ else:
+ attrs_list = {self.obj.primary_key.name}
+ attrs_list.update(entry_attrs.keys())
+ attrs_list = list(attrs_list)
+
+ for callback in self.get_callbacks('pre'):
+ entry_attrs.dn = callback(
+ self, ldap, entry_attrs.dn, entry_attrs, attrs_list,
+ *keys, **options)
+
+ try:
+ update = self._exc_wrapper(keys, options, ldap.get_entry)(
+ entry_attrs.dn, list(entry_attrs))
+ self._update_attrs(update, entry_attrs)
+
+ self._exc_wrapper(keys, options, ldap.update_entry)(update)
+ except errors.NotFound:
+ self.obj.handle_not_found(*keys)
+
+ try:
+ entry_attrs = self._exc_wrapper(keys, options, ldap.get_entry)(
+ entry_attrs.dn, attrs_list)
+ except errors.NotFound:
+ raise errors.MidairCollision(
+ format=_('the entry was deleted while being modified')
+ )
+
+ for callback in self.get_callbacks('post'):
+ entry_attrs.dn = callback(
+ self, ldap, entry_attrs.dn, entry_attrs, *keys, **options)
+
+ entry_attrs = entry_to_dict(entry_attrs, **options)
+
+ if self.obj.primary_key:
+ pkey = keys[-1]
+ else:
+ pkey = None
+
+ return dict(result=entry_attrs, value=pkey_to_value(pkey, options))
+
+ def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys,
+ **options):
+ assert isinstance(dn, DN)
+ return dn
+
+ def post_callback(self, ldap, dn, entry_attrs, *keys, **options):
+ assert isinstance(dn, DN)
+ return dn
+
+ def exc_callback(self, keys, options, exc, call_func, *call_args,
+ **call_kwargs):
+ raise exc
+
+
+class LDAPAddAttribute(LDAPModAttribute):
+ msg_summary = _('added attribute value to entry %(value)')
+
+ def _update_attrs(self, update, entry_attrs):
+ for name, value in entry_attrs.items():
+ old_value = set(update.get(name, []))
+ value_to_add = set(value)
+
+ if not old_value.isdisjoint(value_to_add):
+ raise errors.ExecutionError(
+ message=_('\'%s\' already contains one or more values'
+ % name)
+ )
+
+ update[name] = list(old_value | value_to_add)
+
+
+class LDAPRemoveAttribute(LDAPModAttribute):
+ msg_summary = _('removed attribute values from entry %(value)')
+
+ def _update_attrs(self, update, entry_attrs):
+ for name, value in entry_attrs.items():
+ old_value = set(update.get(name, []))
+ value_to_remove = set(value)
+
+ if not value_to_remove.issubset(old_value):
+ raise errors.AttrValueNotFound(
+ attr=name, value=_("one or more values to remove"))
+
+ update[name] = list(old_value - value_to_remove)
diff --git a/ipaserver/plugins/baseuser.py b/ipaserver/plugins/baseuser.py
new file mode 100644
index 000000000..bbea403d9
--- /dev/null
+++ b/ipaserver/plugins/baseuser.py
@@ -0,0 +1,663 @@
+# Authors:
+# Thierry Bordaz <tbordaz@redhat.com>
+#
+# Copyright (C) 2014 Red Hat
+# see file 'COPYING' for use and warranty information
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+import string
+
+import six
+
+from ipalib import api, errors
+from ipalib import Flag, Int, Password, Str, Bool, StrEnum, DateTime, Bytes
+from ipalib.plugable import Registry
+from .baseldap import (
+ DN, LDAPObject, LDAPCreate, LDAPUpdate, LDAPSearch, LDAPDelete,
+ LDAPRetrieve, LDAPAddMember, LDAPRemoveMember)
+from .service import validate_certificate
+from ipalib.request import context
+from ipalib import _
+from ipapython.ipautil import ipa_generate_password
+from ipapython.ipavalidate import Email
+from ipalib.util import (
+ normalize_sshpubkey,
+ validate_sshpubkey,
+ convert_sshpubkey_post,
+ remove_sshpubkey_from_output_post,
+ remove_sshpubkey_from_output_list_post,
+ add_sshpubkey_to_attrs_pre,
+)
+
+if six.PY3:
+ unicode = str
+
+__doc__ = _("""
+Baseuser
+
+This contains common definitions for user/stageuser
+""")
+
+register = Registry()
+
+NO_UPG_MAGIC = '__no_upg__'
+
+baseuser_output_params = (
+ Flag('has_keytab',
+ label=_('Kerberos keys available'),
+ ),
+ Str('sshpubkeyfp*',
+ label=_('SSH public key fingerprint'),
+ ),
+ )
+
+status_baseuser_output_params = (
+ Str('server',
+ label=_('Server'),
+ ),
+ Str('krbloginfailedcount',
+ label=_('Failed logins'),
+ ),
+ Str('krblastsuccessfulauth',
+ label=_('Last successful authentication'),
+ ),
+ Str('krblastfailedauth',
+ label=_('Last failed authentication'),
+ ),
+ Str('now',
+ label=_('Time now'),
+ ),
+ )
+
+UPG_DEFINITION_DN = DN(('cn', 'UPG Definition'),
+ ('cn', 'Definitions'),
+ ('cn', 'Managed Entries'),
+ ('cn', 'etc'),
+ api.env.basedn)
+
+# characters to be used for generating random user passwords
+baseuser_pwdchars = string.digits + string.ascii_letters + '_,.@+-='
+
+def validate_nsaccountlock(entry_attrs):
+ if 'nsaccountlock' in entry_attrs:
+ nsaccountlock = entry_attrs['nsaccountlock']
+ if not isinstance(nsaccountlock, (bool, Bool)):
+ if not isinstance(nsaccountlock, six.string_types):
+ raise errors.OnlyOneValueAllowed(attr='nsaccountlock')
+ if nsaccountlock.lower() not in ('true', 'false'):
+ raise errors.ValidationError(name='nsaccountlock',
+ error=_('must be TRUE or FALSE'))
+
+def radius_dn2pk(api, entry_attrs):
+ cl = entry_attrs.get('ipatokenradiusconfiglink', None)
+ if cl:
+ pk = api.Object['radiusproxy'].get_primary_key_from_dn(cl[0])
+ entry_attrs['ipatokenradiusconfiglink'] = [pk]
+
+def convert_nsaccountlock(entry_attrs):
+ if not 'nsaccountlock' in entry_attrs:
+ entry_attrs['nsaccountlock'] = False
+ else:
+ nsaccountlock = Bool('temp')
+ entry_attrs['nsaccountlock'] = nsaccountlock.convert(entry_attrs['nsaccountlock'][0])
+
+def split_principal(principal):
+ """
+ Split the principal into its components and do some basic validation.
+
+ Automatically append our realm if it wasn't provided.
+ """
+ realm = None
+ parts = principal.split('@')
+ user = parts[0].lower()
+ if len(parts) > 2:
+ raise errors.MalformedUserPrincipal(principal=principal)
+
+ if len(parts) == 2:
+ realm = parts[1].upper()
+ # At some point we'll support multiple realms
+ if realm != api.env.realm:
+ raise errors.RealmMismatch()
+ else:
+ realm = api.env.realm
+
+ return (user, realm)
+
+def validate_principal(ugettext, principal):
+ """
+ All the real work is done in split_principal.
+ """
+ (user, realm) = split_principal(principal)
+ return None
+
+def normalize_principal(principal):
+ """
+ Ensure that the name in the principal is lower-case. The realm is
+ upper-case by convention but it isn't required.
+
+ The principal is validated at this point.
+ """
+ (user, realm) = split_principal(principal)
+ return unicode('%s@%s' % (user, realm))
+
+
+
+def fix_addressbook_permission_bindrule(name, template, is_new,
+ anonymous_read_aci,
+ **other_options):
+ """Fix bind rule type for Read User Addressbook/IPA Attributes permission
+
+ When upgrading from an old IPA that had the global read ACI,
+ or when installing the first replica with granular read permissions,
+ we need to keep allowing anonymous access to many user attributes.
+ This fixup_function changes the bind rule type accordingly.
+ """
+ if is_new and anonymous_read_aci:
+ template['ipapermbindruletype'] = 'anonymous'
+
+
+
+class baseuser(LDAPObject):
+ """
+ baseuser object.
+ """
+
+ stage_container_dn = api.env.container_stageuser
+ active_container_dn = api.env.container_user
+ delete_container_dn = api.env.container_deleteuser
+ object_class = ['posixaccount']
+ object_class_config = 'ipauserobjectclasses'
+ possible_objectclasses = [
+ 'meporiginentry', 'ipauserauthtypeclass', 'ipauser',
+ 'ipatokenradiusproxyuser'
+ ]
+ disallow_object_classes = ['krbticketpolicyaux']
+ permission_filter_objectclasses = ['posixaccount']
+ search_attributes_config = 'ipausersearchfields'
+ default_attributes = [
+ 'uid', 'givenname', 'sn', 'homedirectory', 'loginshell',
+ 'uidnumber', 'gidnumber', 'mail', 'ou',
+ 'telephonenumber', 'title', 'memberof', 'nsaccountlock',
+ 'memberofindirect', 'ipauserauthtype', 'userclass',
+ 'ipatokenradiusconfiglink', 'ipatokenradiususername',
+ 'krbprincipalexpiration', 'usercertificate;binary',
+ ]
+ search_display_attributes = [
+ 'uid', 'givenname', 'sn', 'homedirectory', 'loginshell',
+ 'mail', 'telephonenumber', 'title', 'nsaccountlock',
+ 'uidnumber', 'gidnumber', 'sshpubkeyfp',
+ ]
+ uuid_attribute = 'ipauniqueid'
+ attribute_members = {
+ 'manager': ['user'],
+ 'memberof': ['group', 'netgroup', 'role', 'hbacrule', 'sudorule'],
+ 'memberofindirect': ['group', 'netgroup', 'role', 'hbacrule', 'sudorule'],
+ }
+ rdn_is_primary_key = True
+ bindable = True
+ password_attributes = [('userpassword', 'has_password'),
+ ('krbprincipalkey', 'has_keytab')]
+ label = _('Users')
+ label_singular = _('User')
+
+ takes_params = (
+ Str('uid',
+ pattern='^[a-zA-Z0-9_.][a-zA-Z0-9_.-]{0,252}[a-zA-Z0-9_.$-]?$',
+ pattern_errmsg='may only include letters, numbers, _, -, . and $',
+ maxlength=255,
+ cli_name='login',
+ label=_('User login'),
+ primary_key=True,
+ default_from=lambda givenname, sn: givenname[0] + sn,
+ normalizer=lambda value: value.lower(),
+ ),
+ Str('givenname',
+ cli_name='first',
+ label=_('First name'),
+ ),
+ Str('sn',
+ cli_name='last',
+ label=_('Last name'),
+ ),
+ Str('cn',
+ label=_('Full name'),
+ default_from=lambda givenname, sn: '%s %s' % (givenname, sn),
+ autofill=True,
+ ),
+ Str('displayname?',
+ label=_('Display name'),
+ default_from=lambda givenname, sn: '%s %s' % (givenname, sn),
+ autofill=True,
+ ),
+ Str('initials?',
+ label=_('Initials'),
+ default_from=lambda givenname, sn: '%c%c' % (givenname[0], sn[0]),
+ autofill=True,
+ ),
+ Str('homedirectory?',
+ cli_name='homedir',
+ label=_('Home directory'),
+ ),
+ Str('gecos?',
+ label=_('GECOS'),
+ default_from=lambda givenname, sn: '%s %s' % (givenname, sn),
+ autofill=True,
+ ),
+ Str('loginshell?',
+ cli_name='shell',
+ label=_('Login shell'),
+ ),
+ Str('krbprincipalname?', validate_principal,
+ cli_name='principal',
+ label=_('Kerberos principal'),
+ default_from=lambda uid: '%s@%s' % (uid.lower(), api.env.realm),
+ autofill=True,
+ flags=['no_update'],
+ normalizer=lambda value: normalize_principal(value),
+ ),
+ DateTime('krbprincipalexpiration?',
+ cli_name='principal_expiration',
+ label=_('Kerberos principal expiration'),
+ ),
+ Str('mail*',
+ cli_name='email',
+ label=_('Email address'),
+ ),
+ Password('userpassword?',
+ cli_name='password',
+ label=_('Password'),
+ doc=_('Prompt to set the user password'),
+ # FIXME: This is temporary till bug is fixed causing updates to
+ # bomb out via the webUI.
+ exclude='webui',
+ ),
+ Flag('random?',
+ doc=_('Generate a random user password'),
+ flags=('no_search', 'virtual_attribute'),
+ default=False,
+ ),
+ Str('randompassword?',
+ label=_('Random password'),
+ flags=('no_create', 'no_update', 'no_search', 'virtual_attribute'),
+ ),
+ Int('uidnumber?',
+ cli_name='uid',
+ label=_('UID'),
+ doc=_('User ID Number (system will assign one if not provided)'),
+ minvalue=1,
+ ),
+ Int('gidnumber?',
+ label=_('GID'),
+ doc=_('Group ID Number'),
+ minvalue=1,
+ ),
+ Str('street?',
+ cli_name='street',
+ label=_('Street address'),
+ ),
+ Str('l?',
+ cli_name='city',
+ label=_('City'),
+ ),
+ Str('st?',
+ cli_name='state',
+ label=_('State/Province'),
+ ),
+ Str('postalcode?',
+ label=_('ZIP'),
+ ),
+ Str('telephonenumber*',
+ cli_name='phone',
+ label=_('Telephone Number')
+ ),
+ Str('mobile*',
+ label=_('Mobile Telephone Number')
+ ),
+ Str('pager*',
+ label=_('Pager Number')
+ ),
+ Str('facsimiletelephonenumber*',
+ cli_name='fax',
+ label=_('Fax Number'),
+ ),
+ Str('ou?',
+ cli_name='orgunit',
+ label=_('Org. Unit'),
+ ),
+ Str('title?',
+ label=_('Job Title'),
+ ),
+ # keep backward compatibility using single value manager option
+ Str('manager?',
+ label=_('Manager'),
+ ),
+ Str('carlicense*',
+ label=_('Car License'),
+ ),
+ Str('ipasshpubkey*', validate_sshpubkey,
+ cli_name='sshpubkey',
+ label=_('SSH public key'),
+ normalizer=normalize_sshpubkey,
+ flags=['no_search'],
+ ),
+ StrEnum('ipauserauthtype*',
+ cli_name='user_auth_type',
+ label=_('User authentication types'),
+ doc=_('Types of supported user authentication'),
+ values=(u'password', u'radius', u'otp'),
+ ),
+ Str('userclass*',
+ cli_name='class',
+ label=_('Class'),
+ doc=_('User category (semantics placed on this attribute are for '
+ 'local interpretation)'),
+ ),
+ Str('ipatokenradiusconfiglink?',
+ cli_name='radius',
+ label=_('RADIUS proxy configuration'),
+ ),
+ Str('ipatokenradiususername?',
+ cli_name='radius_username',
+ label=_('RADIUS proxy username'),
+ ),
+ Str('departmentnumber*',
+ label=_('Department Number'),
+ ),
+ Str('employeenumber?',
+ label=_('Employee Number'),
+ ),
+ Str('employeetype?',
+ label=_('Employee Type'),
+ ),
+ Str('preferredlanguage?',
+ label=_('Preferred Language'),
+ pattern='^(([a-zA-Z]{1,8}(-[a-zA-Z]{1,8})?(;q\=((0(\.[0-9]{0,3})?)|(1(\.0{0,3})?)))?' \
+ + '(\s*,\s*[a-zA-Z]{1,8}(-[a-zA-Z]{1,8})?(;q\=((0(\.[0-9]{0,3})?)|(1(\.0{0,3})?)))?)*)|(\*))$',
+ pattern_errmsg='must match RFC 2068 - 14.4, e.g., "da, en-gb;q=0.8, en;q=0.7"',
+ ),
+ Bytes('usercertificate*', validate_certificate,
+ cli_name='certificate',
+ label=_('Certificate'),
+ doc=_('Base-64 encoded user certificate'),
+ ),
+ )
+
+ def normalize_and_validate_email(self, email, config=None):
+ if not config:
+ config = self.backend.get_ipa_config()
+
+ # check if default email domain should be added
+ defaultdomain = config.get('ipadefaultemaildomain', [None])[0]
+ if email:
+ norm_email = []
+ if not isinstance(email, (list, tuple)):
+ email = [email]
+ for m in email:
+ if isinstance(m, six.string_types):
+ if '@' not in m and defaultdomain:
+ m = m + u'@' + defaultdomain
+ if not Email(m):
+ raise errors.ValidationError(name='email', error=_('invalid e-mail format: %(email)s') % dict(email=m))
+ norm_email.append(m)
+ else:
+ if not Email(m):
+ raise errors.ValidationError(name='email', error=_('invalid e-mail format: %(email)s') % dict(email=m))
+ norm_email.append(m)
+ return norm_email
+
+ return email
+
+ def normalize_manager(self, manager, container):
+ """
+ Given a userid verify the user's existence (in the appropriate containter) and return the dn.
+ """
+ if not manager:
+ return None
+
+ if not isinstance(manager, list):
+ manager = [manager]
+
+ try:
+ container_dn = DN(container, api.env.basedn)
+ for i, mgr in enumerate(manager):
+ if isinstance(mgr, DN) and mgr.endswith(container_dn):
+ continue
+ entry_attrs = self.backend.find_entry_by_attr(
+ self.primary_key.name, mgr, self.object_class, [''],
+ container_dn
+ )
+ manager[i] = entry_attrs.dn
+ except errors.NotFound:
+ raise errors.NotFound(reason=_('manager %(manager)s not found') % dict(manager=mgr))
+
+ return manager
+
+ def _user_status(self, user, container):
+ assert isinstance(user, DN)
+ return user.endswith(container)
+
+ def active_user(self, user):
+ assert isinstance(user, DN)
+ return self._user_status(user, DN(self.active_container_dn, api.env.basedn))
+
+ def stage_user(self, user):
+ assert isinstance(user, DN)
+ return self._user_status(user, DN(self.stage_container_dn, api.env.basedn))
+
+ def delete_user(self, user):
+ assert isinstance(user, DN)
+ return self._user_status(user, DN(self.delete_container_dn, api.env.basedn))
+
+ def convert_usercertificate_pre(self, entry_attrs):
+ if 'usercertificate' in entry_attrs:
+ entry_attrs['usercertificate;binary'] = entry_attrs.pop(
+ 'usercertificate')
+
+ def convert_usercertificate_post(self, entry_attrs, **options):
+ if 'usercertificate;binary' in entry_attrs:
+ entry_attrs['usercertificate'] = entry_attrs.pop(
+ 'usercertificate;binary')
+
+ def convert_attribute_members(self, entry_attrs, *keys, **options):
+ super(baseuser, self).convert_attribute_members(
+ entry_attrs, *keys, **options)
+
+ if options.get("raw", False):
+ return
+
+ # due the backward compatibility, managers have to be returned in
+ # 'manager' attribute instead of 'manager_user'
+ try:
+ entry_attrs['failed_manager'] = entry_attrs.pop('manager')
+ except KeyError:
+ pass
+
+ try:
+ entry_attrs['manager'] = entry_attrs.pop('manager_user')
+ except KeyError:
+ pass
+
+
+class baseuser_add(LDAPCreate):
+ """
+ Prototype command plugin to be implemented by real plugin
+ """
+ def pre_common_callback(self, ldap, dn, entry_attrs, attrs_list, *keys,
+ **options):
+ assert isinstance(dn, DN)
+ self.obj.convert_usercertificate_pre(entry_attrs)
+
+ def post_common_callback(self, ldap, dn, entry_attrs, *keys, **options):
+ assert isinstance(dn, DN)
+ self.obj.convert_usercertificate_post(entry_attrs, **options)
+ self.obj.get_password_attributes(ldap, dn, entry_attrs)
+ convert_sshpubkey_post(entry_attrs)
+ radius_dn2pk(self.api, entry_attrs)
+
+class baseuser_del(LDAPDelete):
+ """
+ Prototype command plugin to be implemented by real plugin
+ """
+
+class baseuser_mod(LDAPUpdate):
+ """
+ Prototype command plugin to be implemented by real plugin
+ """
+ def check_namelength(self, ldap, **options):
+ if options.get('rename') is not None:
+ config = ldap.get_ipa_config()
+ if 'ipamaxusernamelength' in config:
+ if len(options['rename']) > int(config.get('ipamaxusernamelength')[0]):
+ raise errors.ValidationError(
+ name=self.obj.primary_key.cli_name,
+ error=_('can be at most %(len)d characters') % dict(
+ len = int(config.get('ipamaxusernamelength')[0])
+ )
+ )
+ def check_mail(self, entry_attrs):
+ if 'mail' in entry_attrs:
+ entry_attrs['mail'] = self.obj.normalize_and_validate_email(entry_attrs['mail'])
+
+ def check_manager(self, entry_attrs, container):
+ if 'manager' in entry_attrs:
+ entry_attrs['manager'] = self.obj.normalize_manager(entry_attrs['manager'], container)
+
+ def check_userpassword(self, entry_attrs, **options):
+ if 'userpassword' not in entry_attrs and options.get('random'):
+ entry_attrs['userpassword'] = ipa_generate_password(baseuser_pwdchars)
+ # save the password so it can be displayed in post_callback
+ setattr(context, 'randompassword', entry_attrs['userpassword'])
+
+ def check_objectclass(self, ldap, dn, entry_attrs):
+ if ('ipasshpubkey' in entry_attrs or 'ipauserauthtype' in entry_attrs
+ or 'userclass' in entry_attrs or 'ipatokenradiusconfiglink' in entry_attrs):
+ if 'objectclass' in entry_attrs:
+ obj_classes = entry_attrs['objectclass']
+ else:
+ _entry_attrs = ldap.get_entry(dn, ['objectclass'])
+ obj_classes = entry_attrs['objectclass'] = _entry_attrs['objectclass']
+
+ # IMPORTANT: compare objectclasses as case insensitive
+ obj_classes = [o.lower() for o in obj_classes]
+
+ if 'ipasshpubkey' in entry_attrs and 'ipasshuser' not in obj_classes:
+ entry_attrs['objectclass'].append('ipasshuser')
+
+ if 'ipauserauthtype' in entry_attrs and 'ipauserauthtypeclass' not in obj_classes:
+ entry_attrs['objectclass'].append('ipauserauthtypeclass')
+
+ if 'userclass' in entry_attrs and 'ipauser' not in obj_classes:
+ entry_attrs['objectclass'].append('ipauser')
+
+ if 'ipatokenradiusconfiglink' in entry_attrs:
+ cl = entry_attrs['ipatokenradiusconfiglink']
+ if cl:
+ if 'ipatokenradiusproxyuser' not in obj_classes:
+ entry_attrs['objectclass'].append('ipatokenradiusproxyuser')
+
+ answer = self.api.Object['radiusproxy'].get_dn_if_exists(cl)
+ entry_attrs['ipatokenradiusconfiglink'] = answer
+
+ def pre_common_callback(self, ldap, dn, entry_attrs, attrs_list, *keys,
+ **options):
+ assert isinstance(dn, DN)
+ add_sshpubkey_to_attrs_pre(self.context, attrs_list)
+
+ self.check_namelength(ldap, **options)
+
+ self.check_mail(entry_attrs)
+
+ self.check_manager(entry_attrs, self.obj.active_container_dn)
+
+ self.check_userpassword(entry_attrs, **options)
+
+ self.check_objectclass(ldap, dn, entry_attrs)
+ self.obj.convert_usercertificate_pre(entry_attrs)
+
+ def post_common_callback(self, ldap, dn, entry_attrs, *keys, **options):
+ assert isinstance(dn, DN)
+ if options.get('random', False):
+ try:
+ entry_attrs['randompassword'] = unicode(getattr(context, 'randompassword'))
+ except AttributeError:
+ # if both randompassword and userpassword options were used
+ pass
+ convert_nsaccountlock(entry_attrs)
+ self.obj.get_password_attributes(ldap, dn, entry_attrs)
+ self.obj.convert_usercertificate_post(entry_attrs, **options)
+ convert_sshpubkey_post(entry_attrs)
+ remove_sshpubkey_from_output_post(self.context, entry_attrs)
+ radius_dn2pk(self.api, entry_attrs)
+
+class baseuser_find(LDAPSearch):
+ """
+ Prototype command plugin to be implemented by real plugin
+ """
+ def args_options_2_entry(self, *args, **options):
+ newoptions = {}
+ self.common_enhance_options(newoptions, **options)
+ options.update(newoptions)
+
+ return super(baseuser_find, self).args_options_2_entry(
+ *args, **options)
+
+ def common_enhance_options(self, newoptions, **options):
+ # assure the manager attr is a dn, not just a bare uid
+ manager = options.get('manager')
+ if manager is not None:
+ newoptions['manager'] = self.obj.normalize_manager(manager, self.obj.active_container_dn)
+
+ # Ensure that the RADIUS config link is a dn, not just the name
+ cl = 'ipatokenradiusconfiglink'
+ if cl in options:
+ newoptions[cl] = self.api.Object['radiusproxy'].get_dn(options[cl])
+
+ def pre_common_callback(self, ldap, filters, attrs_list, base_dn, scope,
+ *args, **options):
+ add_sshpubkey_to_attrs_pre(self.context, attrs_list)
+
+ def post_common_callback(self, ldap, entries, lockout=False, **options):
+ for attrs in entries:
+ self.obj.convert_usercertificate_post(attrs, **options)
+ if (lockout):
+ attrs['nsaccountlock'] = True
+ else:
+ convert_nsaccountlock(attrs)
+ convert_sshpubkey_post(attrs)
+ remove_sshpubkey_from_output_list_post(self.context, entries)
+
+class baseuser_show(LDAPRetrieve):
+ """
+ Prototype command plugin to be implemented by real plugin
+ """
+ def pre_common_callback(self, ldap, dn, attrs_list, *keys, **options):
+ assert isinstance(dn, DN)
+ add_sshpubkey_to_attrs_pre(self.context, attrs_list)
+
+ def post_common_callback(self, ldap, dn, entry_attrs, *keys, **options):
+ assert isinstance(dn, DN)
+ self.obj.get_password_attributes(ldap, dn, entry_attrs)
+ self.obj.convert_usercertificate_post(entry_attrs, **options)
+ convert_sshpubkey_post(entry_attrs)
+ remove_sshpubkey_from_output_post(self.context, entry_attrs)
+ radius_dn2pk(self.api, entry_attrs)
+
+
+class baseuser_add_manager(LDAPAddMember):
+ member_attributes = ['manager']
+
+
+class baseuser_remove_manager(LDAPRemoveMember):
+ member_attributes = ['manager']
diff --git a/ipaserver/plugins/batch.py b/ipaserver/plugins/batch.py
new file mode 100644
index 000000000..84a650575
--- /dev/null
+++ b/ipaserver/plugins/batch.py
@@ -0,0 +1,143 @@
+# Authors:
+# Adam Young <ayoung@redhat.com>
+# Rob Crittenden <rcritten@redhat.com>
+#
+# Copyright (c) 2010 Red Hat
+# See file 'copying' for use and warranty information
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+"""
+Plugin to make multiple ipa calls via one remote procedure call
+
+To run this code in the lite-server
+
+curl -H "Content-Type:application/json" -H "Accept:application/json" -H "Accept-Language:en" --negotiate -u : --cacert /etc/ipa/ca.crt -d @batch_request.json -X POST http://localhost:8888/ipa/json
+
+where the contents of the file batch_request.json follow the below example
+
+{"method":"batch","params":[[
+ {"method":"group_find","params":[[],{}]},
+ {"method":"user_find","params":[[],{"whoami":"true","all":"true"}]},
+ {"method":"user_show","params":[["admin"],{"all":true}]}
+ ],{}],"id":1}
+
+The format of the response is nested the same way. At the top you will see
+ "error": null,
+ "id": 1,
+ "result": {
+ "count": 3,
+ "results": [
+
+
+And then a nested response for each IPA command method sent in the request
+
+"""
+
+import six
+
+from ipalib import api, errors
+from ipalib import Command
+from ipalib.parameters import Str, Any
+from ipalib.output import Output
+from ipalib.text import _
+from ipalib.request import context
+from ipalib.plugable import Registry
+from ipapython.version import API_VERSION
+
+if six.PY3:
+ unicode = str
+
+register = Registry()
+
+@register()
+class batch(Command):
+ NO_CLI = True
+
+ takes_args = (
+ Any('methods*',
+ doc=_('Nested Methods to execute'),
+ ),
+ )
+
+ take_options = (
+ Str('version',
+ cli_name='version',
+ doc=_('Client version. Used to determine if server will accept request.'),
+ exclude='webui',
+ flags=['no_option', 'no_output'],
+ default=API_VERSION,
+ autofill=True,
+ ),
+ )
+
+ has_output = (
+ Output('count', int, doc=''),
+ Output('results', (list, tuple), doc='')
+ )
+
+ def execute(self, methods=None, **options):
+ results = []
+ for arg in (methods or []):
+ params = dict()
+ name = None
+ try:
+ if 'method' not in arg:
+ raise errors.RequirementError(name='method')
+ if 'params' not in arg:
+ raise errors.RequirementError(name='params')
+ name = arg['method']
+ if name not in self.Command:
+ raise errors.CommandError(name=name)
+ a, kw = arg['params']
+ newkw = dict((str(k), v) for k, v in kw.items())
+ params = api.Command[name].args_options_2_params(*a, **newkw)
+ newkw.setdefault('version', options['version'])
+
+ result = api.Command[name](*a, **newkw)
+ self.info(
+ '%s: batch: %s(%s): SUCCESS',
+ getattr(context, 'principal', 'UNKNOWN'),
+ name,
+ ', '.join(api.Command[name]._repr_iter(**params))
+ )
+ result['error']=None
+ except Exception as e:
+ if isinstance(e, errors.RequirementError) or \
+ isinstance(e, errors.CommandError):
+ self.info(
+ '%s: batch: %s',
+ context.principal, # pylint: disable=no-member
+ e.__class__.__name__
+ )
+ else:
+ self.info(
+ '%s: batch: %s(%s): %s',
+ context.principal, name, # pylint: disable=no-member
+ ', '.join(api.Command[name]._repr_iter(**params)),
+ e.__class__.__name__
+ )
+ if isinstance(e, errors.PublicError):
+ reported_error = e
+ else:
+ reported_error = errors.InternalError()
+ result = dict(
+ error=reported_error.strerror,
+ error_code=reported_error.errno,
+ error_name=unicode(type(reported_error).__name__),
+ error_kw=reported_error.kw,
+ )
+ results.append(result)
+ return dict(count=len(results) , results=results)
+
diff --git a/ipaserver/plugins/caacl.py b/ipaserver/plugins/caacl.py
new file mode 100644
index 000000000..60eeb5a33
--- /dev/null
+++ b/ipaserver/plugins/caacl.py
@@ -0,0 +1,562 @@
+#
+# Copyright (C) 2015 FreeIPA Contributors see COPYING for license
+#
+
+import pyhbac
+
+from ipalib import api, errors, output
+from ipalib import Bool, Str, StrEnum
+from ipalib.plugable import Registry
+from .baseldap import (
+ LDAPObject, LDAPSearch, LDAPCreate, LDAPDelete, LDAPQuery,
+ LDAPUpdate, LDAPRetrieve, LDAPAddMember, LDAPRemoveMember,
+ global_output_params, pkey_to_value)
+from .hbacrule import is_all
+from .service import normalize_principal, split_any_principal
+from ipalib import _, ngettext
+from ipapython.dn import DN
+
+
+__doc__ = _("""
+Manage CA ACL rules.
+
+This plugin is used to define rules governing which principals are
+permitted to have certificates issued using a given certificate
+profile.
+
+PROFILE ID SYNTAX:
+
+A Profile ID is a string without spaces or punctuation starting with a letter
+and followed by a sequence of letters, digits or underscore ("_").
+
+EXAMPLES:
+
+ Create a CA ACL "test" that grants all users access to the
+ "UserCert" profile:
+ ipa caacl-add test --usercat=all
+ ipa caacl-add-profile test --certprofiles UserCert
+
+ Display the properties of a named CA ACL:
+ ipa caacl-show test
+
+ Create a CA ACL to let user "alice" use the "DNP3" profile:
+ ipa caacl-add-profile alice_dnp3 --certprofiles DNP3
+ ipa caacl-add-user alice_dnp3 --user=alice
+
+ Disable a CA ACL:
+ ipa caacl-disable test
+
+ Remove a CA ACL:
+ ipa caacl-del test
+""")
+
+register = Registry()
+
+
+def _acl_make_request(principal_type, principal, ca_ref, profile_id):
+ """Construct HBAC request for the given principal, CA and profile"""
+ service, name, realm = split_any_principal(principal)
+
+ req = pyhbac.HbacRequest()
+ req.targethost.name = ca_ref
+ req.service.name = profile_id
+ if principal_type == 'user':
+ req.user.name = name
+ elif principal_type == 'host':
+ req.user.name = name
+ elif principal_type == 'service':
+ req.user.name = normalize_principal(principal)
+ groups = []
+ if principal_type == 'user':
+ user_obj = api.Command.user_show(name)['result']
+ groups = user_obj.get('memberof_group', [])
+ groups += user_obj.get('memberofindirect_group', [])
+ elif principal_type == 'host':
+ host_obj = api.Command.host_show(name)['result']
+ groups = host_obj.get('memberof_hostgroup', [])
+ groups += host_obj.get('memberofindirect_hostgroup', [])
+ req.user.groups = sorted(set(groups))
+ return req
+
+
+def _acl_make_rule(principal_type, obj):
+ """Turn CA ACL object into HBAC rule.
+
+ ``principal_type``
+ String in {'user', 'host', 'service'}
+ """
+ rule = pyhbac.HbacRule(obj['cn'][0])
+ rule.enabled = obj['ipaenabledflag'][0]
+ rule.srchosts.category = {pyhbac.HBAC_CATEGORY_ALL}
+
+ # add CA(s)
+ # Hardcoded until caacl plugin arrives
+ rule.targethosts.category = {pyhbac.HBAC_CATEGORY_ALL}
+ #if 'ipacacategory' in obj and obj['ipacacategory'][0].lower() == 'all':
+ # rule.targethosts.category = {pyhbac.HBAC_CATEGORY_ALL}
+ #else:
+ # rule.targethosts.names = obj.get('ipacaaclcaref', [])
+
+ # add profiles
+ if ('ipacertprofilecategory' in obj
+ and obj['ipacertprofilecategory'][0].lower() == 'all'):
+ rule.services.category = {pyhbac.HBAC_CATEGORY_ALL}
+ else:
+ attr = 'ipamembercertprofile_certprofile'
+ rule.services.names = obj.get(attr, [])
+
+ # add principals and principal's groups
+ m = {'user': 'group', 'host': 'hostgroup', 'service': None}
+ category_attr = '{}category'.format(principal_type)
+ if category_attr in obj and obj[category_attr][0].lower() == 'all':
+ rule.users.category = {pyhbac.HBAC_CATEGORY_ALL}
+ else:
+ principal_attr = 'member{}_{}'.format(principal_type, principal_type)
+ rule.users.names = obj.get(principal_attr, [])
+ if m[principal_type] is not None:
+ group_attr = 'member{}_{}'.format(principal_type, m[principal_type])
+ rule.users.groups = obj.get(group_attr, [])
+
+ return rule
+
+
+def acl_evaluate(principal_type, principal, ca_ref, profile_id):
+ req = _acl_make_request(principal_type, principal, ca_ref, profile_id)
+ acls = api.Command.caacl_find(no_members=False)['result']
+ rules = [_acl_make_rule(principal_type, obj) for obj in acls]
+ return req.evaluate(rules) == pyhbac.HBAC_EVAL_ALLOW
+
+
+@register()
+class caacl(LDAPObject):
+ """
+ CA ACL object.
+ """
+ container_dn = api.env.container_caacl
+ object_name = _('CA ACL')
+ object_name_plural = _('CA ACLs')
+ object_class = ['ipaassociation', 'ipacaacl']
+ permission_filter_objectclasses = ['ipacaacl']
+ default_attributes = [
+ 'cn', 'description', 'ipaenabledflag',
+ 'ipacacategory', 'ipamemberca',
+ 'ipacertprofilecategory', 'ipamembercertprofile',
+ 'usercategory', 'memberuser',
+ 'hostcategory', 'memberhost',
+ 'servicecategory', 'memberservice',
+ ]
+ uuid_attribute = 'ipauniqueid'
+ rdn_attribute = 'ipauniqueid'
+ attribute_members = {
+ 'memberuser': ['user', 'group'],
+ 'memberhost': ['host', 'hostgroup'],
+ 'memberservice': ['service'],
+ 'ipamembercertprofile': ['certprofile'],
+ }
+ managed_permissions = {
+ 'System: Read CA ACLs': {
+ 'replaces_global_anonymous_aci': True,
+ 'ipapermbindruletype': 'all',
+ 'ipapermright': {'read', 'search', 'compare'},
+ 'ipapermdefaultattr': {
+ 'cn', 'description', 'ipaenabledflag',
+ 'ipacacategory', 'ipamemberca',
+ 'ipacertprofilecategory', 'ipamembercertprofile',
+ 'usercategory', 'memberuser',
+ 'hostcategory', 'memberhost',
+ 'servicecategory', 'memberservice',
+ 'ipauniqueid',
+ 'objectclass', 'member',
+ },
+ },
+ 'System: Add CA ACL': {
+ 'ipapermright': {'add'},
+ 'replaces': [
+ '(target = "ldap:///ipauniqueid=*,cn=caacls,cn=ca,$SUFFIX")(version 3.0;acl "permission:Add CA ACL";allow (add) groupdn = "ldap:///cn=Add CA ACL,cn=permissions,cn=pbac,$SUFFIX";)',
+ ],
+ 'default_privileges': {'CA Administrator'},
+ },
+ 'System: Delete CA ACL': {
+ 'ipapermright': {'delete'},
+ 'replaces': [
+ '(target = "ldap:///ipauniqueid=*,cn=caacls,cn=ca,$SUFFIX")(version 3.0;acl "permission:Delete CA ACL";allow (delete) groupdn = "ldap:///cn=Delete CA ACL,cn=permissions,cn=pbac,$SUFFIX";)',
+ ],
+ 'default_privileges': {'CA Administrator'},
+ },
+ 'System: Manage CA ACL Membership': {
+ 'ipapermright': {'write'},
+ 'ipapermdefaultattr': {
+ 'ipacacategory', 'ipamemberca',
+ 'ipacertprofilecategory', 'ipamembercertprofile',
+ 'usercategory', 'memberuser',
+ 'hostcategory', 'memberhost',
+ 'servicecategory', 'memberservice'
+ },
+ 'replaces': [
+ '(targetattr = "ipamemberca || ipamembercertprofile || memberuser || memberservice || memberhost || ipacacategory || ipacertprofilecategory || usercategory || hostcategory || servicecategory")(target = "ldap:///ipauniqueid=*,cn=caacls,cn=ca,$SUFFIX")(version 3.0;acl "permission:Manage CA ACL membership";allow (write) groupdn = "ldap:///cn=Manage CA ACL membership,cn=permissions,cn=pbac,$SUFFIX";)',
+ ],
+ 'default_privileges': {'CA Administrator'},
+ },
+ 'System: Modify CA ACL': {
+ 'ipapermright': {'write'},
+ 'ipapermdefaultattr': {
+ 'cn', 'description', 'ipaenabledflag',
+ },
+ 'replaces': [
+ '(targetattr = "cn || description || ipaenabledflag")(target = "ldap:///ipauniqueid=*,cn=caacls,cn=ca,$SUFFIX")(version 3.0;acl "permission:Modify CA ACL";allow (write) groupdn = "ldap:///cn=Modify CA ACL,cn=permissions,cn=pbac,$SUFFIX";)',
+ ],
+ 'default_privileges': {'CA Administrator'},
+ },
+ }
+
+ label = _('CA ACLs')
+ label_singular = _('CA ACL')
+
+ takes_params = (
+ Str('cn',
+ cli_name='name',
+ label=_('ACL name'),
+ primary_key=True,
+ ),
+ Str('description?',
+ cli_name='desc',
+ label=_('Description'),
+ ),
+ Bool('ipaenabledflag?',
+ label=_('Enabled'),
+ flags=['no_option'],
+ ),
+ # Commented until subca plugin arrives
+ #StrEnum('ipacacategory?',
+ # cli_name='cacat',
+ # label=_('CA category'),
+ # doc=_('CA category the ACL applies to'),
+ # values=(u'all', ),
+ #),
+ StrEnum('ipacertprofilecategory?',
+ cli_name='profilecat',
+ label=_('Profile category'),
+ doc=_('Profile category the ACL applies to'),
+ values=(u'all', ),
+ ),
+ StrEnum('usercategory?',
+ cli_name='usercat',
+ label=_('User category'),
+ doc=_('User category the ACL applies to'),
+ values=(u'all', ),
+ ),
+ StrEnum('hostcategory?',
+ cli_name='hostcat',
+ label=_('Host category'),
+ doc=_('Host category the ACL applies to'),
+ values=(u'all', ),
+ ),
+ StrEnum('servicecategory?',
+ cli_name='servicecat',
+ label=_('Service category'),
+ doc=_('Service category the ACL applies to'),
+ values=(u'all', ),
+ ),
+ # Commented until subca plugin arrives
+ #Str('ipamemberca_subca?',
+ # label=_('CAs'),
+ # flags=['no_create', 'no_update', 'no_search'],
+ #),
+ Str('ipamembercertprofile_certprofile?',
+ label=_('Profiles'),
+ flags=['no_create', 'no_update', 'no_search'],
+ ),
+ Str('memberuser_user?',
+ label=_('Users'),
+ flags=['no_create', 'no_update', 'no_search'],
+ ),
+ Str('memberuser_group?',
+ label=_('User Groups'),
+ flags=['no_create', 'no_update', 'no_search'],
+ ),
+ Str('memberhost_host?',
+ label=_('Hosts'),
+ flags=['no_create', 'no_update', 'no_search'],
+ ),
+ Str('memberhost_hostgroup?',
+ label=_('Host Groups'),
+ flags=['no_create', 'no_update', 'no_search'],
+ ),
+ Str('memberservice_service?',
+ label=_('Services'),
+ flags=['no_create', 'no_update', 'no_search'],
+ ),
+ )
+
+
+@register()
+class caacl_add(LDAPCreate):
+ __doc__ = _('Create a new CA ACL.')
+
+ msg_summary = _('Added CA ACL "%(value)s"')
+
+ def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options):
+ # CA ACLs are enabled by default
+ entry_attrs['ipaenabledflag'] = ['TRUE']
+ return dn
+
+
+@register()
+class caacl_del(LDAPDelete):
+ __doc__ = _('Delete a CA ACL.')
+
+ msg_summary = _('Deleted CA ACL "%(value)s"')
+
+ def pre_callback(self, ldap, dn, *keys, **options):
+ if keys[0] == 'hosts_services_caIPAserviceCert':
+ raise errors.ProtectedEntryError(
+ label=_("CA ACL"),
+ key=keys[0],
+ reason=_("default CA ACL can be only disabled"))
+ return dn
+
+
+@register()
+class caacl_mod(LDAPUpdate):
+ __doc__ = _('Modify a CA ACL.')
+
+ msg_summary = _('Modified CA ACL "%(value)s"')
+
+ def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options):
+ assert isinstance(dn, DN)
+ try:
+ entry_attrs = ldap.get_entry(dn, attrs_list)
+ dn = entry_attrs.dn
+ except errors.NotFound:
+ self.obj.handle_not_found(*keys)
+
+ # Commented until subca plugin arrives
+ #if is_all(options, 'ipacacategory') and 'ipamemberca' in entry_attrs:
+ # raise errors.MutuallyExclusiveError(reason=_(
+ # "CA category cannot be set to 'all' "
+ # "while there are allowed CAs"))
+ if (is_all(options, 'ipacertprofilecategory')
+ and 'ipamembercertprofile' in entry_attrs):
+ raise errors.MutuallyExclusiveError(reason=_(
+ "profile category cannot be set to 'all' "
+ "while there are allowed profiles"))
+ if is_all(options, 'usercategory') and 'memberuser' in entry_attrs:
+ raise errors.MutuallyExclusiveError(reason=_(
+ "user category cannot be set to 'all' "
+ "while there are allowed users"))
+ if is_all(options, 'hostcategory') and 'memberhost' in entry_attrs:
+ raise errors.MutuallyExclusiveError(reason=_(
+ "host category cannot be set to 'all' "
+ "while there are allowed hosts"))
+ if is_all(options, 'servicecategory') and 'memberservice' in entry_attrs:
+ raise errors.MutuallyExclusiveError(reason=_(
+ "service category cannot be set to 'all' "
+ "while there are allowed services"))
+ return dn
+
+
+@register()
+class caacl_find(LDAPSearch):
+ __doc__ = _('Search for CA ACLs.')
+
+ msg_summary = ngettext(
+ '%(count)d CA ACL matched', '%(count)d CA ACLs matched', 0
+ )
+
+
+@register()
+class caacl_show(LDAPRetrieve):
+ __doc__ = _('Display the properties of a CA ACL.')
+
+
+@register()
+class caacl_enable(LDAPQuery):
+ __doc__ = _('Enable a CA ACL.')
+
+ msg_summary = _('Enabled CA ACL "%(value)s"')
+ has_output = output.standard_value
+
+ def execute(self, cn, **options):
+ ldap = self.obj.backend
+
+ dn = self.obj.get_dn(cn)
+ try:
+ entry_attrs = ldap.get_entry(dn, ['ipaenabledflag'])
+ except errors.NotFound:
+ self.obj.handle_not_found(cn)
+
+ entry_attrs['ipaenabledflag'] = ['TRUE']
+
+ try:
+ ldap.update_entry(entry_attrs)
+ except errors.EmptyModlist:
+ pass
+
+ return dict(
+ result=True,
+ value=pkey_to_value(cn, options),
+ )
+
+
+@register()
+class caacl_disable(LDAPQuery):
+ __doc__ = _('Disable a CA ACL.')
+
+ msg_summary = _('Disabled CA ACL "%(value)s"')
+ has_output = output.standard_value
+
+ def execute(self, cn, **options):
+ ldap = self.obj.backend
+
+ dn = self.obj.get_dn(cn)
+ try:
+ entry_attrs = ldap.get_entry(dn, ['ipaenabledflag'])
+ except errors.NotFound:
+ self.obj.handle_not_found(cn)
+
+ entry_attrs['ipaenabledflag'] = ['FALSE']
+
+ try:
+ ldap.update_entry(entry_attrs)
+ except errors.EmptyModlist:
+ pass
+
+ return dict(
+ result=True,
+ value=pkey_to_value(cn, options),
+ )
+
+
+@register()
+class caacl_add_user(LDAPAddMember):
+ __doc__ = _('Add users and groups to a CA ACL.')
+
+ member_attributes = ['memberuser']
+ member_count_out = (
+ _('%i user or group added.'),
+ _('%i users or groups added.'))
+
+ def pre_callback(self, ldap, dn, found, not_found, *keys, **options):
+ assert isinstance(dn, DN)
+ try:
+ entry_attrs = ldap.get_entry(dn, self.obj.default_attributes)
+ dn = entry_attrs.dn
+ except errors.NotFound:
+ self.obj.handle_not_found(*keys)
+ if is_all(entry_attrs, 'usercategory'):
+ raise errors.MutuallyExclusiveError(
+ reason=_("users cannot be added when user category='all'"))
+ return dn
+
+
+@register()
+class caacl_remove_user(LDAPRemoveMember):
+ __doc__ = _('Remove users and groups from a CA ACL.')
+
+ member_attributes = ['memberuser']
+ member_count_out = (
+ _('%i user or group removed.'),
+ _('%i users or groups removed.'))
+
+
+@register()
+class caacl_add_host(LDAPAddMember):
+ __doc__ = _('Add target hosts and hostgroups to a CA ACL.')
+
+ member_attributes = ['memberhost']
+ member_count_out = (
+ _('%i host or hostgroup added.'),
+ _('%i hosts or hostgroups added.'))
+
+ def pre_callback(self, ldap, dn, found, not_found, *keys, **options):
+ assert isinstance(dn, DN)
+ try:
+ entry_attrs = ldap.get_entry(dn, self.obj.default_attributes)
+ dn = entry_attrs.dn
+ except errors.NotFound:
+ self.obj.handle_not_found(*keys)
+ if is_all(entry_attrs, 'hostcategory'):
+ raise errors.MutuallyExclusiveError(
+ reason=_("hosts cannot be added when host category='all'"))
+ return dn
+
+
+@register()
+class caacl_remove_host(LDAPRemoveMember):
+ __doc__ = _('Remove target hosts and hostgroups from a CA ACL.')
+
+ member_attributes = ['memberhost']
+ member_count_out = (
+ _('%i host or hostgroup removed.'),
+ _('%i hosts or hostgroups removed.'))
+
+
+@register()
+class caacl_add_service(LDAPAddMember):
+ __doc__ = _('Add services to a CA ACL.')
+
+ member_attributes = ['memberservice']
+ member_count_out = (_('%i service added.'), _('%i services added.'))
+
+ def pre_callback(self, ldap, dn, found, not_found, *keys, **options):
+ assert isinstance(dn, DN)
+ try:
+ entry_attrs = ldap.get_entry(dn, self.obj.default_attributes)
+ dn = entry_attrs.dn
+ except errors.NotFound:
+ self.obj.handle_not_found(*keys)
+ if is_all(entry_attrs, 'servicecategory'):
+ raise errors.MutuallyExclusiveError(reason=_(
+ "services cannot be added when service category='all'"))
+ return dn
+
+
+@register()
+class caacl_remove_service(LDAPRemoveMember):
+ __doc__ = _('Remove services from a CA ACL.')
+
+ member_attributes = ['memberservice']
+ member_count_out = (_('%i service removed.'), _('%i services removed.'))
+
+
+caacl_output_params = global_output_params + (
+ Str('ipamembercertprofile',
+ label=_('Failed profiles'),
+ ),
+ # Commented until caacl plugin arrives
+ #Str('ipamemberca',
+ # label=_('Failed CAs'),
+ #),
+)
+
+
+@register()
+class caacl_add_profile(LDAPAddMember):
+ __doc__ = _('Add profiles to a CA ACL.')
+
+ has_output_params = caacl_output_params
+
+ member_attributes = ['ipamembercertprofile']
+ member_count_out = (_('%i profile added.'), _('%i profiles added.'))
+
+ def pre_callback(self, ldap, dn, found, not_found, *keys, **options):
+ assert isinstance(dn, DN)
+ try:
+ entry_attrs = ldap.get_entry(dn, self.obj.default_attributes)
+ dn = entry_attrs.dn
+ except errors.NotFound:
+ self.obj.handle_not_found(*keys)
+ if is_all(entry_attrs, 'ipacertprofilecategory'):
+ raise errors.MutuallyExclusiveError(reason=_(
+ "profiles cannot be added when profile category='all'"))
+ return dn
+
+
+@register()
+class caacl_remove_profile(LDAPRemoveMember):
+ __doc__ = _('Remove profiles from a CA ACL.')
+
+ has_output_params = caacl_output_params
+
+ member_attributes = ['ipamembercertprofile']
+ member_count_out = (_('%i profile removed.'), _('%i profiles removed.'))
diff --git a/ipaserver/plugins/cert.py b/ipaserver/plugins/cert.py
new file mode 100644
index 000000000..cbb5382fb
--- /dev/null
+++ b/ipaserver/plugins/cert.py
@@ -0,0 +1,835 @@
+# Authors:
+# Andrew Wnuk <awnuk@redhat.com>
+# Jason Gerard DeRose <jderose@redhat.com>
+# John Dennis <jdennis@redhat.com>
+#
+# Copyright (C) 2009 Red Hat
+# see file 'COPYING' for use and warranty information
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+import os
+import time
+import binascii
+
+from ipalib import Command, Str, Int, Flag
+from ipalib import api
+from ipalib import errors
+from ipalib import pkcs10
+from ipalib import x509
+from ipalib import ngettext
+from ipalib.plugable import Registry
+from .virtual import VirtualCommand
+from .baseldap import pkey_to_value
+from .service import split_any_principal
+from .certprofile import validate_profile_id
+from .caacl import acl_evaluate
+from ipalib.text import _
+from ipalib.request import context
+from ipalib import output
+from .service import validate_principal
+from ipapython.dn import DN
+
+import six
+import nss.nss as nss
+from nss.error import NSPRError
+from pyasn1.error import PyAsn1Error
+
+if six.PY3:
+ unicode = str
+
+__doc__ = _("""
+IPA certificate operations
+
+Implements a set of commands for managing server SSL certificates.
+
+Certificate requests exist in the form of a Certificate Signing Request (CSR)
+in PEM format.
+
+The dogtag CA uses just the CN value of the CSR and forces the rest of the
+subject to values configured in the server.
+
+A certificate is stored with a service principal and a service principal
+needs a host.
+
+In order to request a certificate:
+
+* The host must exist
+* The service must exist (or you use the --add option to automatically add it)
+
+SEARCHING:
+
+Certificates may be searched on by certificate subject, serial number,
+revocation reason, validity dates and the issued date.
+
+When searching on dates the _from date does a >= search and the _to date
+does a <= search. When combined these are done as an AND.
+
+Dates are treated as GMT to match the dates in the certificates.
+
+The date format is YYYY-mm-dd.
+
+EXAMPLES:
+
+ Request a new certificate and add the principal:
+ ipa cert-request --add --principal=HTTP/lion.example.com example.csr
+
+ Retrieve an existing certificate:
+ ipa cert-show 1032
+
+ Revoke a certificate (see RFC 5280 for reason details):
+ ipa cert-revoke --revocation-reason=6 1032
+
+ Remove a certificate from revocation hold status:
+ ipa cert-remove-hold 1032
+
+ Check the status of a signing request:
+ ipa cert-status 10
+
+ Search for certificates by hostname:
+ ipa cert-find --subject=ipaserver.example.com
+
+ Search for revoked certificates by reason:
+ ipa cert-find --revocation-reason=5
+
+ Search for certificates based on issuance date
+ ipa cert-find --issuedon-from=2013-02-01 --issuedon-to=2013-02-07
+
+IPA currently immediately issues (or declines) all certificate requests so
+the status of a request is not normally useful. This is for future use
+or the case where a CA does not immediately issue a certificate.
+
+The following revocation reasons are supported:
+
+ * 0 - unspecified
+ * 1 - keyCompromise
+ * 2 - cACompromise
+ * 3 - affiliationChanged
+ * 4 - superseded
+ * 5 - cessationOfOperation
+ * 6 - certificateHold
+ * 8 - removeFromCRL
+ * 9 - privilegeWithdrawn
+ * 10 - aACompromise
+
+Note that reason code 7 is not used. See RFC 5280 for more details:
+
+http://www.ietf.org/rfc/rfc5280.txt
+
+""")
+
+USER, HOST, SERVICE = range(3)
+
+register = Registry()
+
+def validate_pkidate(ugettext, value):
+ """
+ A date in the format of %Y-%m-%d
+ """
+ try:
+ ts = time.strptime(value, '%Y-%m-%d')
+ except ValueError as e:
+ return str(e)
+
+ return None
+
+def validate_csr(ugettext, csr):
+ """
+ Ensure the CSR is base64-encoded and can be decoded by our PKCS#10
+ parser.
+ """
+ if api.env.context == 'cli':
+ # If we are passed in a pointer to a valid file on the client side
+ # escape and let the load_files() handle things
+ if csr and os.path.exists(csr):
+ return
+ try:
+ request = pkcs10.load_certificate_request(csr)
+ except (TypeError, binascii.Error) as e:
+ raise errors.Base64DecodeError(reason=str(e))
+ except Exception as e:
+ raise errors.CertificateOperationError(error=_('Failure decoding Certificate Signing Request: %s') % e)
+
+def normalize_csr(csr):
+ """
+ Strip any leading and trailing cruft around the BEGIN/END block
+ """
+ end_len = 37
+ s = csr.find('-----BEGIN NEW CERTIFICATE REQUEST-----')
+ if s == -1:
+ s = csr.find('-----BEGIN CERTIFICATE REQUEST-----')
+ e = csr.find('-----END NEW CERTIFICATE REQUEST-----')
+ if e == -1:
+ e = csr.find('-----END CERTIFICATE REQUEST-----')
+ if e != -1:
+ end_len = 33
+
+ if s > -1 and e > -1:
+ # We're normalizing here, not validating
+ csr = csr[s:e+end_len]
+
+ return csr
+
+def _convert_serial_number(num):
+ """
+ Convert a SN given in decimal or hexadecimal.
+ Returns the number or None if conversion fails.
+ """
+ # plain decimal or hexa with radix prefix
+ try:
+ num = int(num, 0)
+ except ValueError:
+ try:
+ # hexa without prefix
+ num = int(num, 16)
+ except ValueError:
+ num = None
+
+ return num
+
+def validate_serial_number(ugettext, num):
+ if _convert_serial_number(num) == None:
+ return u"Decimal or hexadecimal number is required for serial number"
+ return None
+
+def normalize_serial_number(num):
+ # It's been already validated
+ return unicode(_convert_serial_number(num))
+
+def get_host_from_principal(principal):
+ """
+ Given a principal with or without a realm return the
+ host portion.
+ """
+ validate_principal(None, principal)
+ realm = principal.find('@')
+ slash = principal.find('/')
+ if realm == -1:
+ realm = len(principal)
+ hostname = principal[slash+1:realm]
+
+ return hostname
+
+def ca_enabled_check():
+ if not api.Command.ca_is_enabled()['result']:
+ raise errors.NotFound(reason=_('CA is not configured'))
+
+def caacl_check(principal_type, principal_string, ca, profile_id):
+ principal_type_map = {USER: 'user', HOST: 'host', SERVICE: 'service'}
+ if not acl_evaluate(
+ principal_type_map[principal_type],
+ principal_string, ca, profile_id):
+ raise errors.ACIError(info=_(
+ "Principal '%(principal)s' "
+ "is not permitted to use CA '%(ca)s' "
+ "with profile '%(profile_id)s' for certificate issuance."
+ ) % dict(
+ principal=principal_string,
+ ca=ca or '.',
+ profile_id=profile_id
+ )
+ )
+
+@register()
+class cert_request(VirtualCommand):
+ __doc__ = _('Submit a certificate signing request.')
+
+ takes_args = (
+ Str(
+ 'csr', validate_csr,
+ label=_('CSR'),
+ cli_name='csr_file',
+ normalizer=normalize_csr,
+ noextrawhitespace=False,
+ ),
+ )
+ operation="request certificate"
+
+ takes_options = (
+ Str('principal',
+ label=_('Principal'),
+ doc=_('Principal for this certificate (e.g. HTTP/test.example.com)'),
+ ),
+ Str('request_type',
+ default=u'pkcs10',
+ autofill=True,
+ ),
+ Flag('add',
+ doc=_("automatically add the principal if it doesn't exist"),
+ default=False,
+ autofill=True
+ ),
+ Str('profile_id?', validate_profile_id,
+ label=_("Profile ID"),
+ doc=_("Certificate Profile to use"),
+ )
+ )
+
+ has_output_params = (
+ Str('certificate',
+ label=_('Certificate'),
+ ),
+ Str('subject',
+ label=_('Subject'),
+ ),
+ Str('issuer',
+ label=_('Issuer'),
+ ),
+ Str('valid_not_before',
+ label=_('Not Before'),
+ ),
+ Str('valid_not_after',
+ label=_('Not After'),
+ ),
+ Str('md5_fingerprint',
+ label=_('Fingerprint (MD5)'),
+ ),
+ Str('sha1_fingerprint',
+ label=_('Fingerprint (SHA1)'),
+ ),
+ Str('serial_number',
+ label=_('Serial number'),
+ ),
+ Str('serial_number_hex',
+ label=_('Serial number (hex)'),
+ ),
+ )
+
+ has_output = (
+ output.Output('result',
+ type=dict,
+ doc=_('Dictionary mapping variable name to value'),
+ ),
+ )
+
+ def execute(self, csr, **kw):
+ ca_enabled_check()
+
+ ldap = self.api.Backend.ldap2
+ add = kw.get('add')
+ request_type = kw.get('request_type')
+ profile_id = kw.get('profile_id', self.Backend.ra.DEFAULT_PROFILE)
+ ca = '.' # top-level CA hardcoded until subca plugin implemented
+
+ """
+ Access control is partially handled by the ACI titled
+ 'Hosts can modify service userCertificate'. This is for the case
+ where a machine binds using a host/ prinicpal. It can only do the
+ request if the target hostname is in the managedBy attribute which
+ is managed using the add/del member commands.
+
+ Binding with a user principal one needs to be in the request_certs
+ taskgroup (directly or indirectly via role membership).
+ """
+
+ principal_string = kw.get('principal')
+ principal = split_any_principal(principal_string)
+ servicename, principal_name, realm = principal
+ if servicename is None:
+ principal_type = USER
+ elif servicename == 'host':
+ principal_type = HOST
+ else:
+ principal_type = SERVICE
+
+ bind_principal = split_any_principal(getattr(context, 'principal'))
+ bind_service, bind_name, bind_realm = bind_principal
+
+ if bind_service is None:
+ bind_principal_type = USER
+ elif bind_service == 'host':
+ bind_principal_type = HOST
+ else:
+ bind_principal_type = SERVICE
+
+ if bind_principal != principal and bind_principal_type != HOST:
+ # Can the bound principal request certs for another principal?
+ self.check_access()
+
+ try:
+ self.check_access("request certificate ignore caacl")
+ bypass_caacl = True
+ except errors.ACIError:
+ bypass_caacl = False
+
+ if not bypass_caacl:
+ caacl_check(principal_type, principal_string, ca, profile_id)
+
+ try:
+ subject = pkcs10.get_subject(csr)
+ extensions = pkcs10.get_extensions(csr)
+ subjectaltname = pkcs10.get_subjectaltname(csr) or ()
+ except (NSPRError, PyAsn1Error, ValueError) as e:
+ raise errors.CertificateOperationError(
+ error=_("Failure decoding Certificate Signing Request: %s") % e)
+
+ # self-service and host principals may bypass SAN permission check
+ if bind_principal != principal and bind_principal_type != HOST:
+ if '2.5.29.17' in extensions:
+ self.check_access('request certificate with subjectaltname')
+
+ dn = None
+ principal_obj = None
+ # See if the service exists and punt if it doesn't and we aren't
+ # going to add it
+ try:
+ if principal_type == SERVICE:
+ principal_obj = api.Command['service_show'](principal_string, all=True)
+ elif principal_type == HOST:
+ principal_obj = api.Command['host_show'](principal_name, all=True)
+ elif principal_type == USER:
+ principal_obj = api.Command['user_show'](principal_name, all=True)
+ except errors.NotFound as e:
+ if principal_type == SERVICE and add:
+ principal_obj = api.Command['service_add'](principal_string, force=True)
+ else:
+ raise errors.NotFound(
+ reason=_("The principal for this request doesn't exist."))
+ principal_obj = principal_obj['result']
+ dn = principal_obj['dn']
+
+ # Ensure that the DN in the CSR matches the principal
+ cn = subject.common_name #pylint: disable=E1101
+ if not cn:
+ raise errors.ValidationError(name='csr',
+ error=_("No Common Name was found in subject of request."))
+
+ if principal_type in (SERVICE, HOST):
+ if cn.lower() != principal_name.lower():
+ raise errors.ACIError(
+ info=_("hostname in subject of request '%(cn)s' "
+ "does not match principal hostname '%(hostname)s'")
+ % dict(cn=cn, hostname=principal_name))
+ elif principal_type == USER:
+ # check user name
+ if cn != principal_name:
+ raise errors.ValidationError(
+ name='csr',
+ error=_("DN commonName does not match user's login")
+ )
+
+ # check email address
+ mail = subject.email_address #pylint: disable=E1101
+ if mail is not None and mail not in principal_obj.get('mail', []):
+ raise errors.ValidationError(
+ name='csr',
+ error=_(
+ "DN emailAddress does not match "
+ "any of user's email addresses")
+ )
+
+ # We got this far so the principal entry exists, can we write it?
+ if not ldap.can_write(dn, "usercertificate"):
+ raise errors.ACIError(info=_("Insufficient 'write' privilege "
+ "to the 'userCertificate' attribute of entry '%s'.") % dn)
+
+ # Validate the subject alt name, if any
+ for name_type, name in subjectaltname:
+ if name_type == pkcs10.SAN_DNSNAME:
+ name = unicode(name)
+ alt_principal_obj = None
+ alt_principal_string = None
+ try:
+ if principal_type == HOST:
+ alt_principal_string = 'host/%s@%s' % (name, realm)
+ alt_principal_obj = api.Command['host_show'](name, all=True)
+ elif principal_type == SERVICE:
+ alt_principal_string = '%s/%s@%s' % (servicename, name, realm)
+ alt_principal_obj = api.Command['service_show'](
+ alt_principal_string, all=True)
+ elif principal_type == USER:
+ raise errors.ValidationError(
+ name='csr',
+ error=_("subject alt name type %s is forbidden "
+ "for user principals") % name_type
+ )
+ except errors.NotFound:
+ # We don't want to issue any certificates referencing
+ # machines we don't know about. Nothing is stored in this
+ # host record related to this certificate.
+ raise errors.NotFound(reason=_('The service principal for '
+ 'subject alt name %s in certificate request does not '
+ 'exist') % name)
+ if alt_principal_obj is not None:
+ altdn = alt_principal_obj['result']['dn']
+ if not ldap.can_write(altdn, "usercertificate"):
+ raise errors.ACIError(info=_(
+ "Insufficient privilege to create a certificate "
+ "with subject alt name '%s'.") % name)
+ if alt_principal_string is not None and not bypass_caacl:
+ caacl_check(
+ principal_type, alt_principal_string, ca, profile_id)
+ elif name_type in (pkcs10.SAN_OTHERNAME_KRB5PRINCIPALNAME,
+ pkcs10.SAN_OTHERNAME_UPN):
+ if split_any_principal(name) != principal:
+ raise errors.ACIError(
+ info=_("Principal '%s' in subject alt name does not "
+ "match requested principal") % name)
+ elif name_type == pkcs10.SAN_RFC822NAME:
+ if principal_type == USER:
+ if name not in principal_obj.get('mail', []):
+ raise errors.ValidationError(
+ name='csr',
+ error=_(
+ "RFC822Name does not match "
+ "any of user's email addresses")
+ )
+ else:
+ raise errors.ValidationError(
+ name='csr',
+ error=_("subject alt name type %s is forbidden "
+ "for non-user principals") % name_type
+ )
+ else:
+ raise errors.ACIError(
+ info=_("Subject alt name type %s is forbidden") %
+ name_type)
+
+ # Request the certificate
+ result = self.Backend.ra.request_certificate(
+ csr, profile_id, request_type=request_type)
+ cert = x509.load_certificate(result['certificate'])
+ result['issuer'] = unicode(cert.issuer)
+ result['valid_not_before'] = unicode(cert.valid_not_before_str)
+ result['valid_not_after'] = unicode(cert.valid_not_after_str)
+ result['md5_fingerprint'] = unicode(nss.data_to_hex(nss.md5_digest(cert.der_data), 64)[0])
+ result['sha1_fingerprint'] = unicode(nss.data_to_hex(nss.sha1_digest(cert.der_data), 64)[0])
+
+ # Success? Then add it to the principal's entry
+ # (unless the profile tells us not to)
+ profile = api.Command['certprofile_show'](profile_id)
+ store = profile['result']['ipacertprofilestoreissued'][0] == 'TRUE'
+ if store and 'certificate' in result:
+ cert = str(result.get('certificate'))
+ kwargs = dict(addattr=u'usercertificate={}'.format(cert))
+ if principal_type == SERVICE:
+ api.Command['service_mod'](principal_string, **kwargs)
+ elif principal_type == HOST:
+ api.Command['host_mod'](principal_name, **kwargs)
+ elif principal_type == USER:
+ api.Command['user_mod'](principal_name, **kwargs)
+
+ return dict(
+ result=result
+ )
+
+
+
+@register()
+class cert_status(VirtualCommand):
+ __doc__ = _('Check the status of a certificate signing request.')
+
+ takes_args = (
+ Str('request_id',
+ label=_('Request id'),
+ flags=['no_create', 'no_update', 'no_search'],
+ ),
+ )
+ has_output_params = (
+ Str('cert_request_status',
+ label=_('Request status'),
+ ),
+ )
+ operation = "certificate status"
+
+
+ def execute(self, request_id, **kw):
+ ca_enabled_check()
+ self.check_access()
+ return dict(
+ result=self.Backend.ra.check_request_status(request_id)
+ )
+
+
+
+_serial_number = Str('serial_number',
+ validate_serial_number,
+ label=_('Serial number'),
+ doc=_('Serial number in decimal or if prefixed with 0x in hexadecimal'),
+ normalizer=normalize_serial_number,
+)
+
+@register()
+class cert_show(VirtualCommand):
+ __doc__ = _('Retrieve an existing certificate.')
+
+ takes_args = _serial_number
+
+ has_output_params = (
+ Str('certificate',
+ label=_('Certificate'),
+ ),
+ Str('subject',
+ label=_('Subject'),
+ ),
+ Str('issuer',
+ label=_('Issuer'),
+ ),
+ Str('valid_not_before',
+ label=_('Not Before'),
+ ),
+ Str('valid_not_after',
+ label=_('Not After'),
+ ),
+ Str('md5_fingerprint',
+ label=_('Fingerprint (MD5)'),
+ ),
+ Str('sha1_fingerprint',
+ label=_('Fingerprint (SHA1)'),
+ ),
+ Str('revocation_reason',
+ label=_('Revocation reason'),
+ ),
+ Str('serial_number_hex',
+ label=_('Serial number (hex)'),
+ ),
+ )
+
+ takes_options = (
+ Str('out?',
+ label=_('Output filename'),
+ doc=_('File to store the certificate in.'),
+ exclude='webui',
+ ),
+ )
+
+ operation="retrieve certificate"
+
+ def execute(self, serial_number, **options):
+ ca_enabled_check()
+ hostname = None
+ try:
+ self.check_access()
+ except errors.ACIError as acierr:
+ self.debug("Not granted by ACI to retrieve certificate, looking at principal")
+ bind_principal = getattr(context, 'principal')
+ if not bind_principal.startswith('host/'):
+ raise acierr
+ hostname = get_host_from_principal(bind_principal)
+
+ result=self.Backend.ra.get_certificate(serial_number)
+ cert = x509.load_certificate(result['certificate'])
+ result['subject'] = unicode(cert.subject)
+ result['issuer'] = unicode(cert.issuer)
+ result['valid_not_before'] = unicode(cert.valid_not_before_str)
+ result['valid_not_after'] = unicode(cert.valid_not_after_str)
+ result['md5_fingerprint'] = unicode(nss.data_to_hex(nss.md5_digest(cert.der_data), 64)[0])
+ result['sha1_fingerprint'] = unicode(nss.data_to_hex(nss.sha1_digest(cert.der_data), 64)[0])
+ if hostname:
+ # If we have a hostname we want to verify that the subject
+ # of the certificate matches it, otherwise raise an error
+ if hostname != cert.subject.common_name: #pylint: disable=E1101
+ raise acierr
+
+ return dict(result=result)
+
+
+
+
+@register()
+class cert_revoke(VirtualCommand):
+ __doc__ = _('Revoke a certificate.')
+
+ takes_args = _serial_number
+
+ has_output_params = (
+ Flag('revoked',
+ label=_('Revoked'),
+ ),
+ )
+ operation = "revoke certificate"
+
+ # FIXME: The default is 0. Is this really an Int param?
+ takes_options = (
+ Int('revocation_reason',
+ label=_('Reason'),
+ doc=_('Reason for revoking the certificate (0-10). Type '
+ '"ipa help cert" for revocation reason details. '),
+ minvalue=0,
+ maxvalue=10,
+ default=0,
+ autofill=True
+ ),
+ )
+
+ def execute(self, serial_number, **kw):
+ ca_enabled_check()
+ hostname = None
+ try:
+ self.check_access()
+ except errors.ACIError as acierr:
+ self.debug("Not granted by ACI to revoke certificate, looking at principal")
+ try:
+ # Let cert_show() handle verifying that the subject of the
+ # cert we're dealing with matches the hostname in the principal
+ result = api.Command['cert_show'](unicode(serial_number))['result']
+ except errors.NotImplementedError:
+ pass
+ revocation_reason = kw['revocation_reason']
+ if revocation_reason == 7:
+ raise errors.CertificateOperationError(error=_('7 is not a valid revocation reason'))
+ return dict(
+ result=self.Backend.ra.revoke_certificate(
+ serial_number, revocation_reason=revocation_reason)
+ )
+
+
+
+@register()
+class cert_remove_hold(VirtualCommand):
+ __doc__ = _('Take a revoked certificate off hold.')
+
+ takes_args = _serial_number
+
+ has_output_params = (
+ Flag('unrevoked',
+ label=_('Unrevoked'),
+ ),
+ Str('error_string',
+ label=_('Error'),
+ ),
+ )
+ operation = "certificate remove hold"
+
+ def execute(self, serial_number, **kw):
+ ca_enabled_check()
+ self.check_access()
+ return dict(
+ result=self.Backend.ra.take_certificate_off_hold(serial_number)
+ )
+
+
+
+@register()
+class cert_find(Command):
+ __doc__ = _('Search for existing certificates.')
+
+ takes_options = (
+ Str('subject?',
+ label=_('Subject'),
+ doc=_('Subject'),
+ autofill=False,
+ ),
+ Int('revocation_reason?',
+ label=_('Reason'),
+ doc=_('Reason for revoking the certificate (0-10). Type '
+ '"ipa help cert" for revocation reason details.'),
+ minvalue=0,
+ maxvalue=10,
+ autofill=False,
+ ),
+ Int('min_serial_number?',
+ doc=_("minimum serial number"),
+ autofill=False,
+ minvalue=0,
+ maxvalue=2147483647,
+ ),
+ Int('max_serial_number?',
+ doc=_("maximum serial number"),
+ autofill=False,
+ minvalue=0,
+ maxvalue=2147483647,
+ ),
+ Flag('exactly?',
+ doc=_('match the common name exactly'),
+ autofill=False,
+ ),
+ Str('validnotafter_from?', validate_pkidate,
+ doc=_('Valid not after from this date (YYYY-mm-dd)'),
+ autofill=False,
+ ),
+ Str('validnotafter_to?', validate_pkidate,
+ doc=_('Valid not after to this date (YYYY-mm-dd)'),
+ autofill=False,
+ ),
+ Str('validnotbefore_from?', validate_pkidate,
+ doc=_('Valid not before from this date (YYYY-mm-dd)'),
+ autofill=False,
+ ),
+ Str('validnotbefore_to?', validate_pkidate,
+ doc=_('Valid not before to this date (YYYY-mm-dd)'),
+ autofill=False,
+ ),
+ Str('issuedon_from?', validate_pkidate,
+ doc=_('Issued on from this date (YYYY-mm-dd)'),
+ autofill=False,
+ ),
+ Str('issuedon_to?', validate_pkidate,
+ doc=_('Issued on to this date (YYYY-mm-dd)'),
+ autofill=False,
+ ),
+ Str('revokedon_from?', validate_pkidate,
+ doc=_('Revoked on from this date (YYYY-mm-dd)'),
+ autofill=False,
+ ),
+ Str('revokedon_to?', validate_pkidate,
+ doc=_('Revoked on to this date (YYYY-mm-dd)'),
+ autofill=False,
+ ),
+ Int('sizelimit?',
+ label=_('Size Limit'),
+ doc=_('Maximum number of certs returned'),
+ flags=['no_display'],
+ minvalue=0,
+ default=100,
+ ),
+ )
+
+ has_output = output.standard_list_of_entries
+ has_output_params = (
+ Str('serial_number_hex',
+ label=_('Serial number (hex)'),
+ ),
+ Str('serial_number',
+ label=_('Serial number'),
+ ),
+ Str('status',
+ label=_('Status'),
+ ),
+ )
+
+ msg_summary = ngettext(
+ '%(count)d certificate matched', '%(count)d certificates matched', 0
+ )
+
+ def execute(self, **options):
+ ca_enabled_check()
+ ret = dict(
+ result=self.Backend.ra.find(options)
+ )
+ ret['count'] = len(ret['result'])
+ ret['truncated'] = False
+ return ret
+
+
+@register()
+class ca_is_enabled(Command):
+ """
+ Checks if any of the servers has the CA service enabled.
+ """
+ NO_CLI = True
+ has_output = output.standard_value
+
+ def execute(self, *args, **options):
+ base_dn = DN(('cn', 'masters'), ('cn', 'ipa'), ('cn', 'etc'),
+ self.api.env.basedn)
+ filter = '(&(objectClass=ipaConfigObject)(cn=CA))'
+ try:
+ self.api.Backend.ldap2.find_entries(
+ base_dn=base_dn, filter=filter, attrs_list=[])
+ except errors.NotFound:
+ result = False
+ else:
+ result = True
+ return dict(result=result, value=pkey_to_value(None, options))
diff --git a/ipaserver/plugins/certprofile.py b/ipaserver/plugins/certprofile.py
new file mode 100644
index 000000000..6f314e1a4
--- /dev/null
+++ b/ipaserver/plugins/certprofile.py
@@ -0,0 +1,335 @@
+#
+# Copyright (C) 2015 FreeIPA Contributors see COPYING for license
+#
+
+import re
+
+from ipalib import api, Bool, Str
+from ipalib.plugable import Registry
+from .baseldap import (
+ LDAPObject, LDAPSearch, LDAPCreate,
+ LDAPDelete, LDAPUpdate, LDAPRetrieve)
+from ipalib.request import context
+from ipalib import ngettext
+from ipalib.text import _
+from ipapython.dogtag import INCLUDED_PROFILES
+from ipapython.version import API_VERSION
+
+from ipalib import errors
+
+
+__doc__ = _("""
+Manage Certificate Profiles
+
+Certificate Profiles are used by Certificate Authority (CA) in the signing of
+certificates to determine if a Certificate Signing Request (CSR) is acceptable,
+and if so what features and extensions will be present on the certificate.
+
+The Certificate Profile format is the property-list format understood by the
+Dogtag or Red Hat Certificate System CA.
+
+PROFILE ID SYNTAX:
+
+A Profile ID is a string without spaces or punctuation starting with a letter
+and followed by a sequence of letters, digits or underscore ("_").
+
+EXAMPLES:
+
+ Import a profile that will not store issued certificates:
+ ipa certprofile-import ShortLivedUserCert \\
+ --file UserCert.profile --desc "User Certificates" \\
+ --store=false
+
+ Delete a certificate profile:
+ ipa certprofile-del ShortLivedUserCert
+
+ Show information about a profile:
+ ipa certprofile-show ShortLivedUserCert
+
+ Save profile configuration to a file:
+ ipa certprofile-show caIPAserviceCert --out caIPAserviceCert.cfg
+
+ Search for profiles that do not store certificates:
+ ipa certprofile-find --store=false
+
+PROFILE CONFIGURATION FORMAT:
+
+The profile configuration format is the raw property-list format
+used by Dogtag Certificate System. The XML format is not supported.
+
+The following restrictions apply to profiles managed by FreeIPA:
+
+- When importing a profile the "profileId" field, if present, must
+ match the ID given on the command line.
+
+- The "classId" field must be set to "caEnrollImpl"
+
+- The "auth.instance_id" field must be set to "raCertAuth"
+
+- The "certReqInputImpl" input class and "certOutputImpl" output
+ class must be used.
+
+""")
+
+
+register = Registry()
+
+
+def ca_enabled_check():
+ """Raise NotFound if CA is not enabled.
+
+ This function is defined in multiple plugins to avoid circular imports
+ (cert depends on certprofile, so we cannot import cert here).
+
+ """
+ if not api.Command.ca_is_enabled()['result']:
+ raise errors.NotFound(reason=_('CA is not configured'))
+
+
+profile_id_pattern = re.compile('^[a-zA-Z]\w*$')
+
+
+def validate_profile_id(ugettext, value):
+ """Ensure profile ID matches form required by CA."""
+ if profile_id_pattern.match(value) is None:
+ return _('invalid Profile ID')
+ else:
+ return None
+
+
+@register()
+class certprofile(LDAPObject):
+ """
+ Certificate Profile object.
+ """
+ container_dn = api.env.container_certprofile
+ object_name = _('Certificate Profile')
+ object_name_plural = _('Certificate Profiles')
+ object_class = ['ipacertprofile']
+ default_attributes = [
+ 'cn', 'description', 'ipacertprofilestoreissued'
+ ]
+ search_attributes = [
+ 'cn', 'description', 'ipacertprofilestoreissued'
+ ]
+ label = _('Certificate Profiles')
+ label_singular = _('Certificate Profile')
+
+ takes_params = (
+ Str('cn', validate_profile_id,
+ primary_key=True,
+ cli_name='id',
+ label=_('Profile ID'),
+ doc=_('Profile ID for referring to this profile'),
+ ),
+ Str('description',
+ required=True,
+ cli_name='desc',
+ label=_('Profile description'),
+ doc=_('Brief description of this profile'),
+ ),
+ Bool('ipacertprofilestoreissued',
+ default=True,
+ cli_name='store',
+ label=_('Store issued certificates'),
+ doc=_('Whether to store certs issued using this profile'),
+ ),
+ )
+
+ permission_filter_objectclasses = ['ipacertprofile']
+ managed_permissions = {
+ 'System: Read Certificate Profiles': {
+ 'replaces_global_anonymous_aci': True,
+ 'ipapermbindruletype': 'all',
+ 'ipapermright': {'read', 'search', 'compare'},
+ 'ipapermdefaultattr': {
+ 'cn',
+ 'description',
+ 'ipacertprofilestoreissued',
+ 'objectclass',
+ },
+ },
+ 'System: Import Certificate Profile': {
+ 'ipapermright': {'add'},
+ 'replaces': [
+ '(target = "ldap:///cn=*,cn=certprofiles,cn=ca,$SUFFIX")(version 3.0;acl "permission:Import Certificate Profile";allow (add) groupdn = "ldap:///cn=Import Certificate Profile,cn=permissions,cn=pbac,$SUFFIX";)',
+ ],
+ 'default_privileges': {'CA Administrator'},
+ },
+ 'System: Delete Certificate Profile': {
+ 'ipapermright': {'delete'},
+ 'replaces': [
+ '(target = "ldap:///cn=*,cn=certprofiles,cn=ca,$SUFFIX")(version 3.0;acl "permission:Delete Certificate Profile";allow (delete) groupdn = "ldap:///cn=Delete Certificate Profile,cn=permissions,cn=pbac,$SUFFIX";)',
+ ],
+ 'default_privileges': {'CA Administrator'},
+ },
+ 'System: Modify Certificate Profile': {
+ 'ipapermright': {'write'},
+ 'ipapermdefaultattr': {
+ 'cn',
+ 'description',
+ 'ipacertprofilestoreissued',
+ },
+ 'replaces': [
+ '(targetattr = "cn || description || ipacertprofilestoreissued")(target = "ldap:///cn=*,cn=certprofiles,cn=ca,$SUFFIX")(version 3.0;acl "permission:Modify Certificate Profile";allow (write) groupdn = "ldap:///cn=Modify Certificate Profile,cn=permissions,cn=pbac,$SUFFIX";)',
+ ],
+ 'default_privileges': {'CA Administrator'},
+ },
+ }
+
+
+
+@register()
+class certprofile_find(LDAPSearch):
+ __doc__ = _("Search for Certificate Profiles.")
+ msg_summary = ngettext(
+ '%(count)d profile matched', '%(count)d profiles matched', 0
+ )
+
+ def execute(self, *args, **kwargs):
+ ca_enabled_check()
+ return super(certprofile_find, self).execute(*args, **kwargs)
+
+
+@register()
+class certprofile_show(LDAPRetrieve):
+ __doc__ = _("Display the properties of a Certificate Profile.")
+
+ has_output_params = LDAPRetrieve.has_output_params + (
+ Str('config',
+ label=_('Profile configuration'),
+ ),
+ )
+
+ takes_options = LDAPRetrieve.takes_options + (
+ Str('out?',
+ doc=_('Write profile configuration to file'),
+ ),
+ )
+
+ def execute(self, *keys, **options):
+ ca_enabled_check()
+ result = super(certprofile_show, self).execute(*keys, **options)
+
+ if 'out' in options:
+ with self.api.Backend.ra_certprofile as profile_api:
+ result['result']['config'] = profile_api.read_profile(keys[0])
+
+ return result
+
+
+@register()
+class certprofile_import(LDAPCreate):
+ __doc__ = _("Import a Certificate Profile.")
+ msg_summary = _('Imported profile "%(value)s"')
+ takes_options = (
+ Str(
+ 'file',
+ label=_('Filename of a raw profile. The XML format is not supported.'),
+ cli_name='file',
+ flags=('virtual_attribute',),
+ noextrawhitespace=False,
+ ),
+ )
+
+ PROFILE_ID_PATTERN = re.compile('^profileId=([a-zA-Z]\w*)', re.MULTILINE)
+
+ def pre_callback(self, ldap, dn, entry, entry_attrs, *keys, **options):
+ ca_enabled_check()
+ context.profile = options['file']
+
+ match = self.PROFILE_ID_PATTERN.search(options['file'])
+ if match is None:
+ # no profileId found, use CLI value as profileId.
+ context.profile = u'profileId=%s\n%s' % (keys[0], context.profile)
+ elif keys[0] != match.group(1):
+ raise errors.ValidationError(name='file',
+ error=_("Profile ID '%(cli_value)s' does not match profile data '%(file_value)s'")
+ % {'cli_value': keys[0], 'file_value': match.group(1)}
+ )
+ return dn
+
+
+ def post_callback(self, ldap, dn, entry_attrs, *keys, **options):
+ """Import the profile into Dogtag and enable it.
+
+ If the operation fails, remove the LDAP entry.
+ """
+ try:
+ with self.api.Backend.ra_certprofile as profile_api:
+ profile_api.create_profile(context.profile)
+ profile_api.enable_profile(keys[0])
+ except:
+ # something went wrong ; delete entry
+ ldap.delete_entry(dn)
+ raise
+
+ return dn
+
+
+@register()
+class certprofile_del(LDAPDelete):
+ __doc__ = _("Delete a Certificate Profile.")
+ msg_summary = _('Deleted profile "%(value)s"')
+
+ def pre_callback(self, ldap, dn, *keys, **options):
+ ca_enabled_check()
+
+ if keys[0] in [p.profile_id for p in INCLUDED_PROFILES]:
+ raise errors.ValidationError(name='profile_id',
+ error=_("Predefined profile '%(profile_id)s' cannot be deleted")
+ % {'profile_id': keys[0]}
+ )
+
+ return dn
+
+ def post_callback(self, ldap, dn, *keys, **options):
+ with self.api.Backend.ra_certprofile as profile_api:
+ profile_api.disable_profile(keys[0])
+ profile_api.delete_profile(keys[0])
+ return dn
+
+
+@register()
+class certprofile_mod(LDAPUpdate):
+ __doc__ = _("Modify Certificate Profile configuration.")
+ msg_summary = _('Modified Certificate Profile "%(value)s"')
+
+ takes_options = LDAPUpdate.takes_options + (
+ Str(
+ 'file?',
+ label=_('File containing profile configuration'),
+ cli_name='file',
+ flags=('virtual_attribute',),
+ noextrawhitespace=False,
+ ),
+ )
+
+ def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options):
+ ca_enabled_check()
+ # Once a profile id is set it cannot be changed
+ if 'cn' in entry_attrs:
+ raise errors.ProtectedEntryError(label='certprofile', key=keys[0],
+ reason=_('Certificate profiles cannot be renamed'))
+ if 'file' in options:
+ with self.api.Backend.ra_certprofile as profile_api:
+ profile_api.disable_profile(keys[0])
+ try:
+ profile_api.update_profile(keys[0], options['file'])
+ finally:
+ profile_api.enable_profile(keys[0])
+
+ return dn
+
+ def execute(self, *keys, **options):
+ try:
+ return super(certprofile_mod, self).execute(*keys, **options)
+ except errors.EmptyModlist:
+ if 'file' in options:
+ # The profile data in Dogtag was updated.
+ # Do not fail; return result of certprofile-show instead
+ return self.api.Command.certprofile_show(keys[0],
+ version=API_VERSION)
+ else:
+ # This case is actually an error; re-raise
+ raise
diff --git a/ipaserver/plugins/config.py b/ipaserver/plugins/config.py
new file mode 100644
index 000000000..46a40ddf7
--- /dev/null
+++ b/ipaserver/plugins/config.py
@@ -0,0 +1,358 @@
+# Authors:
+# Rob Crittenden <rcritten@redhat.com>
+# Pavel Zuna <pzuna@redhat.com>
+#
+# Copyright (C) 2008 Red Hat
+# see file 'COPYING' for use and warranty information
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+from ipalib import api
+from ipalib import Bool, Int, Str, IA5Str, StrEnum, DNParam
+from ipalib import errors
+from ipalib.plugable import Registry
+from .baseldap import (
+ LDAPObject,
+ LDAPUpdate,
+ LDAPRetrieve)
+from .selinuxusermap import validate_selinuxuser
+from ipalib import _
+from ipapython.dn import DN
+
+# 389-ds attributes that should be skipped in attribute checks
+OPERATIONAL_ATTRIBUTES = ('nsaccountlock', 'member', 'memberof',
+ 'memberindirect', 'memberofindirect',)
+
+__doc__ = _("""
+Server configuration
+
+Manage the default values that IPA uses and some of its tuning parameters.
+
+NOTES:
+
+The password notification value (--pwdexpnotify) is stored here so it will
+be replicated. It is not currently used to notify users in advance of an
+expiring password.
+
+Some attributes are read-only, provided only for information purposes. These
+include:
+
+Certificate Subject base: the configured certificate subject base,
+ e.g. O=EXAMPLE.COM. This is configurable only at install time.
+Password plug-in features: currently defines additional hashes that the
+ password will generate (there may be other conditions).
+
+When setting the order list for mapping SELinux users you may need to
+quote the value so it isn't interpreted by the shell.
+
+EXAMPLES:
+
+ Show basic server configuration:
+ ipa config-show
+
+ Show all configuration options:
+ ipa config-show --all
+
+ Change maximum username length to 99 characters:
+ ipa config-mod --maxusername=99
+
+ Increase default time and size limits for maximum IPA server search:
+ ipa config-mod --searchtimelimit=10 --searchrecordslimit=2000
+
+ Set default user e-mail domain:
+ ipa config-mod --emaildomain=example.com
+
+ Enable migration mode to make "ipa migrate-ds" command operational:
+ ipa config-mod --enable-migration=TRUE
+
+ Define SELinux user map order:
+ ipa config-mod --ipaselinuxusermaporder='guest_u:s0$xguest_u:s0$user_u:s0-s0:c0.c1023$staff_u:s0-s0:c0.c1023$unconfined_u:s0-s0:c0.c1023'
+""")
+
+register = Registry()
+
+@register()
+class config(LDAPObject):
+ """
+ IPA configuration object
+ """
+ object_name = _('configuration options')
+ default_attributes = [
+ 'ipamaxusernamelength', 'ipahomesrootdir', 'ipadefaultloginshell',
+ 'ipadefaultprimarygroup', 'ipadefaultemaildomain', 'ipasearchtimelimit',
+ 'ipasearchrecordslimit', 'ipausersearchfields', 'ipagroupsearchfields',
+ 'ipamigrationenabled', 'ipacertificatesubjectbase',
+ 'ipapwdexpadvnotify', 'ipaselinuxusermaporder',
+ 'ipaselinuxusermapdefault', 'ipaconfigstring', 'ipakrbauthzdata',
+ 'ipauserauthtype'
+ ]
+ container_dn = DN(('cn', 'ipaconfig'), ('cn', 'etc'))
+ permission_filter_objectclasses = ['ipaguiconfig']
+ managed_permissions = {
+ 'System: Read Global Configuration': {
+ 'replaces_global_anonymous_aci': True,
+ 'ipapermbindruletype': 'all',
+ 'ipapermright': {'read', 'search', 'compare'},
+ 'ipapermdefaultattr': {
+ 'cn', 'objectclass',
+ 'ipacertificatesubjectbase', 'ipaconfigstring',
+ 'ipadefaultemaildomain', 'ipadefaultloginshell',
+ 'ipadefaultprimarygroup', 'ipagroupobjectclasses',
+ 'ipagroupsearchfields', 'ipahomesrootdir',
+ 'ipakrbauthzdata', 'ipamaxusernamelength',
+ 'ipamigrationenabled', 'ipapwdexpadvnotify',
+ 'ipaselinuxusermapdefault', 'ipaselinuxusermaporder',
+ 'ipasearchrecordslimit', 'ipasearchtimelimit',
+ 'ipauserauthtype', 'ipauserobjectclasses',
+ 'ipausersearchfields', 'ipacustomfields',
+ },
+ },
+ }
+
+ label = _('Configuration')
+ label_singular = _('Configuration')
+
+ takes_params = (
+ Int('ipamaxusernamelength',
+ cli_name='maxusername',
+ label=_('Maximum username length'),
+ minvalue=1,
+ maxvalue=255,
+ ),
+ IA5Str('ipahomesrootdir',
+ cli_name='homedirectory',
+ label=_('Home directory base'),
+ doc=_('Default location of home directories'),
+ ),
+ Str('ipadefaultloginshell',
+ cli_name='defaultshell',
+ label=_('Default shell'),
+ doc=_('Default shell for new users'),
+ ),
+ Str('ipadefaultprimarygroup',
+ cli_name='defaultgroup',
+ label=_('Default users group'),
+ doc=_('Default group for new users'),
+ ),
+ Str('ipadefaultemaildomain?',
+ cli_name='emaildomain',
+ label=_('Default e-mail domain'),
+ doc=_('Default e-mail domain'),
+ ),
+ Int('ipasearchtimelimit',
+ cli_name='searchtimelimit',
+ label=_('Search time limit'),
+ doc=_('Maximum amount of time (seconds) for a search (-1 or 0 is unlimited)'),
+ minvalue=-1,
+ ),
+ Int('ipasearchrecordslimit',
+ cli_name='searchrecordslimit',
+ label=_('Search size limit'),
+ doc=_('Maximum number of records to search (-1 or 0 is unlimited)'),
+ minvalue=-1,
+ ),
+ IA5Str('ipausersearchfields',
+ cli_name='usersearch',
+ label=_('User search fields'),
+ doc=_('A comma-separated list of fields to search in when searching for users'),
+ ),
+ IA5Str('ipagroupsearchfields',
+ cli_name='groupsearch',
+ label='Group search fields',
+ doc=_('A comma-separated list of fields to search in when searching for groups'),
+ ),
+ Bool('ipamigrationenabled',
+ cli_name='enable_migration',
+ label=_('Enable migration mode'),
+ doc=_('Enable migration mode'),
+ ),
+ DNParam('ipacertificatesubjectbase',
+ cli_name='subject',
+ label=_('Certificate Subject base'),
+ doc=_('Base for certificate subjects (OU=Test,O=Example)'),
+ flags=['no_update'],
+ ),
+ Str('ipagroupobjectclasses+',
+ cli_name='groupobjectclasses',
+ label=_('Default group objectclasses'),
+ doc=_('Default group objectclasses (comma-separated list)'),
+ ),
+ Str('ipauserobjectclasses+',
+ cli_name='userobjectclasses',
+ label=_('Default user objectclasses'),
+ doc=_('Default user objectclasses (comma-separated list)'),
+ ),
+ Int('ipapwdexpadvnotify',
+ cli_name='pwdexpnotify',
+ label=_('Password Expiration Notification (days)'),
+ doc=_('Number of days\'s notice of impending password expiration'),
+ minvalue=0,
+ ),
+ StrEnum('ipaconfigstring*',
+ cli_name='ipaconfigstring',
+ label=_('Password plugin features'),
+ doc=_('Extra hashes to generate in password plug-in'),
+ values=(u'AllowNThash',
+ u'KDC:Disable Last Success', u'KDC:Disable Lockout',
+ u'KDC:Disable Default Preauth for SPNs'),
+ ),
+ Str('ipaselinuxusermaporder',
+ label=_('SELinux user map order'),
+ doc=_('Order in increasing priority of SELinux users, delimited by $'),
+ ),
+ Str('ipaselinuxusermapdefault?',
+ label=_('Default SELinux user'),
+ doc=_('Default SELinux user when no match is found in SELinux map rule'),
+ ),
+ StrEnum('ipakrbauthzdata*',
+ cli_name='pac_type',
+ label=_('Default PAC types'),
+ doc=_('Default types of PAC supported for services'),
+ values=(u'MS-PAC', u'PAD', u'nfs:NONE'),
+ ),
+ StrEnum('ipauserauthtype*',
+ cli_name='user_auth_type',
+ label=_('Default user authentication types'),
+ doc=_('Default types of supported user authentication'),
+ values=(u'password', u'radius', u'otp', u'disabled'),
+ ),
+ )
+
+ def get_dn(self, *keys, **kwargs):
+ return DN(('cn', 'ipaconfig'), ('cn', 'etc'), api.env.basedn)
+
+
+
+@register()
+class config_mod(LDAPUpdate):
+ __doc__ = _('Modify configuration options.')
+
+ def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options):
+ assert isinstance(dn, DN)
+ if 'ipadefaultprimarygroup' in entry_attrs:
+ group=entry_attrs['ipadefaultprimarygroup']
+ try:
+ api.Object['group'].get_dn_if_exists(group)
+ except errors.NotFound:
+ raise errors.NotFound(message=_("The group doesn't exist"))
+ kw = {}
+ if 'ipausersearchfields' in entry_attrs:
+ kw['ipausersearchfields'] = 'ipauserobjectclasses'
+ if 'ipagroupsearchfields' in entry_attrs:
+ kw['ipagroupsearchfields'] = 'ipagroupobjectclasses'
+ if kw:
+ config = ldap.get_ipa_config(list(kw.values()))
+ for (k, v) in kw.items():
+ allowed_attrs = ldap.get_allowed_attributes(config[v])
+ fields = entry_attrs[k].split(',')
+ for a in fields:
+ a = a.strip()
+ a, tomato, olive = a.partition(';')
+ if a not in allowed_attrs:
+ raise errors.ValidationError(
+ name=k, error=_('attribute "%s" not allowed') % a
+ )
+
+ # Set ipasearchrecordslimit to -1 if 0 is used
+ if 'ipasearchrecordslimit' in entry_attrs:
+ if entry_attrs['ipasearchrecordslimit'] is 0:
+ entry_attrs['ipasearchrecordslimit'] = -1
+
+ # Set ipasearchtimelimit to -1 if 0 is used
+ if 'ipasearchtimelimit' in entry_attrs:
+ if entry_attrs['ipasearchtimelimit'] is 0:
+ entry_attrs['ipasearchtimelimit'] = -1
+
+ for (attr, obj) in (('ipauserobjectclasses', 'user'),
+ ('ipagroupobjectclasses', 'group')):
+ if attr in entry_attrs:
+ if not entry_attrs[attr]:
+ raise errors.ValidationError(name=attr,
+ error=_('May not be empty'))
+ objectclasses = list(set(entry_attrs[attr]).union(
+ self.api.Object[obj].possible_objectclasses))
+ new_allowed_attrs = ldap.get_allowed_attributes(objectclasses,
+ raise_on_unknown=True)
+ checked_attrs = self.api.Object[obj].default_attributes
+ if self.api.Object[obj].uuid_attribute:
+ checked_attrs = checked_attrs + [self.api.Object[obj].uuid_attribute]
+ for obj_attr in checked_attrs:
+ obj_attr, tomato, olive = obj_attr.partition(';')
+ if obj_attr in OPERATIONAL_ATTRIBUTES:
+ continue
+ if obj_attr in self.api.Object[obj].params and \
+ 'virtual_attribute' in \
+ self.api.Object[obj].params[obj_attr].flags:
+ # skip virtual attributes
+ continue
+ if obj_attr not in new_allowed_attrs:
+ raise errors.ValidationError(name=attr,
+ error=_('%(obj)s default attribute %(attr)s would not be allowed!') \
+ % dict(obj=obj, attr=obj_attr))
+
+ if ('ipaselinuxusermapdefault' in entry_attrs or
+ 'ipaselinuxusermaporder' in entry_attrs):
+ config = None
+ failedattr = 'ipaselinuxusermaporder'
+
+ if 'ipaselinuxusermapdefault' in entry_attrs:
+ defaultuser = entry_attrs['ipaselinuxusermapdefault']
+ failedattr = 'ipaselinuxusermapdefault'
+
+ # validate the new default user first
+ if defaultuser is not None:
+ error_message = validate_selinuxuser(_, defaultuser)
+
+ if error_message:
+ raise errors.ValidationError(name='ipaselinuxusermapdefault',
+ error=error_message)
+
+ else:
+ config = ldap.get_ipa_config()
+ defaultuser = config.get('ipaselinuxusermapdefault', [None])[0]
+
+ if 'ipaselinuxusermaporder' in entry_attrs:
+ order = entry_attrs['ipaselinuxusermaporder']
+ userlist = order.split('$')
+
+ # validate the new user order first
+ for user in userlist:
+ if not user:
+ raise errors.ValidationError(name='ipaselinuxusermaporder',
+ error=_('A list of SELinux users delimited by $ expected'))
+
+ error_message = validate_selinuxuser(_, user)
+ if error_message:
+ error_message = _("SELinux user '%(user)s' is not "
+ "valid: %(error)s") % dict(user=user,
+ error=error_message)
+ raise errors.ValidationError(name='ipaselinuxusermaporder',
+ error=error_message)
+ else:
+ if not config:
+ config = ldap.get_ipa_config()
+ order = config['ipaselinuxusermaporder']
+ userlist = order[0].split('$')
+ if defaultuser and defaultuser not in userlist:
+ raise errors.ValidationError(name=failedattr,
+ error=_('SELinux user map default user not in order list'))
+
+ return dn
+
+
+
+@register()
+class config_show(LDAPRetrieve):
+ __doc__ = _('Show the current configuration.')
+
diff --git a/ipaserver/plugins/delegation.py b/ipaserver/plugins/delegation.py
new file mode 100644
index 000000000..0443f0e48
--- /dev/null
+++ b/ipaserver/plugins/delegation.py
@@ -0,0 +1,226 @@
+# Authors:
+# Rob Crittenden <rcritten@redhat.com>
+# Martin Kosek <mkosek@redhat.com>
+#
+# Copyright (C) 2010 Red Hat
+# see file 'COPYING' for use and warranty information
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+from ipalib import _, ngettext
+from ipalib import Str
+from ipalib import api, crud
+from ipalib import output
+from ipalib import Object
+from ipalib.plugable import Registry
+from .baseldap import gen_pkey_only_option, pkey_to_value
+
+__doc__ = _("""
+Group to Group Delegation
+
+A permission enables fine-grained delegation of permissions. Access Control
+Rules, or instructions (ACIs), grant permission to permissions to perform
+given tasks such as adding a user, modifying a group, etc.
+
+Group to Group Delegations grants the members of one group to update a set
+of attributes of members of another group.
+
+EXAMPLES:
+
+ Add a delegation rule to allow managers to edit employee's addresses:
+ ipa delegation-add --attrs=street --group=managers --membergroup=employees "managers edit employees' street"
+
+ When managing the list of attributes you need to include all attributes
+ in the list, including existing ones. Add postalCode to the list:
+ ipa delegation-mod --attrs=street --attrs=postalCode --group=managers --membergroup=employees "managers edit employees' street"
+
+ Display our updated rule:
+ ipa delegation-show "managers edit employees' street"
+
+ Delete a rule:
+ ipa delegation-del "managers edit employees' street"
+""")
+
+register = Registry()
+
+ACI_PREFIX=u"delegation"
+
+output_params = (
+ Str('aci',
+ label=_('ACI'),
+ ),
+)
+
+@register()
+class delegation(Object):
+ """
+ Delegation object.
+ """
+
+ bindable = False
+ object_name = _('delegation')
+ object_name_plural = _('delegations')
+ label = _('Delegations')
+ label_singular = _('Delegation')
+
+ takes_params = (
+ Str('aciname',
+ cli_name='name',
+ label=_('Delegation name'),
+ doc=_('Delegation name'),
+ primary_key=True,
+ ),
+ Str('permissions*',
+ cli_name='permissions',
+ label=_('Permissions'),
+ doc=_('Permissions to grant (read, write). Default is write.'),
+ ),
+ Str('attrs+',
+ cli_name='attrs',
+ label=_('Attributes'),
+ doc=_('Attributes to which the delegation applies'),
+ normalizer=lambda value: value.lower(),
+ ),
+ Str('memberof',
+ cli_name='membergroup',
+ label=_('Member user group'),
+ doc=_('User group to apply delegation to'),
+ ),
+ Str('group',
+ cli_name='group',
+ label=_('User group'),
+ doc=_('User group ACI grants access to'),
+ ),
+ )
+
+ def __json__(self):
+ json_friendly_attributes = (
+ 'label', 'label_singular', 'takes_params', 'bindable', 'name',
+ 'object_name', 'object_name_plural',
+ )
+ json_dict = dict(
+ (a, getattr(self, a)) for a in json_friendly_attributes
+ )
+ json_dict['primary_key'] = self.primary_key.name
+
+ json_dict['methods'] = [m for m in self.methods]
+ return json_dict
+
+ def postprocess_result(self, result):
+ try:
+ # do not include prefix in result
+ del result['aciprefix']
+ except KeyError:
+ pass
+
+
+
+@register()
+class delegation_add(crud.Create):
+ __doc__ = _('Add a new delegation.')
+
+ msg_summary = _('Added delegation "%(value)s"')
+ has_output_params = output_params
+
+ def execute(self, aciname, **kw):
+ if not 'permissions' in kw:
+ kw['permissions'] = (u'write',)
+ kw['aciprefix'] = ACI_PREFIX
+ result = api.Command['aci_add'](aciname, **kw)['result']
+ self.obj.postprocess_result(result)
+
+ return dict(
+ result=result,
+ value=pkey_to_value(aciname, kw),
+ )
+
+
+
+@register()
+class delegation_del(crud.Delete):
+ __doc__ = _('Delete a delegation.')
+
+ has_output = output.standard_boolean
+ msg_summary = _('Deleted delegation "%(value)s"')
+
+ def execute(self, aciname, **kw):
+ kw['aciprefix'] = ACI_PREFIX
+ result = api.Command['aci_del'](aciname, **kw)
+ self.obj.postprocess_result(result)
+ return dict(
+ result=True,
+ value=pkey_to_value(aciname, kw),
+ )
+
+
+
+@register()
+class delegation_mod(crud.Update):
+ __doc__ = _('Modify a delegation.')
+
+ msg_summary = _('Modified delegation "%(value)s"')
+ has_output_params = output_params
+
+ def execute(self, aciname, **kw):
+ kw['aciprefix'] = ACI_PREFIX
+ result = api.Command['aci_mod'](aciname, **kw)['result']
+ self.obj.postprocess_result(result)
+
+ return dict(
+ result=result,
+ value=pkey_to_value(aciname, kw),
+ )
+
+
+
+@register()
+class delegation_find(crud.Search):
+ __doc__ = _('Search for delegations.')
+
+ msg_summary = ngettext(
+ '%(count)d delegation matched', '%(count)d delegations matched', 0
+ )
+
+ takes_options = (gen_pkey_only_option("name"),)
+ has_output_params = output_params
+
+ def execute(self, term=None, **kw):
+ kw['aciprefix'] = ACI_PREFIX
+ results = api.Command['aci_find'](term, **kw)['result']
+
+ for aci in results:
+ self.obj.postprocess_result(aci)
+
+ return dict(
+ result=results,
+ count=len(results),
+ truncated=False,
+ )
+
+
+
+@register()
+class delegation_show(crud.Retrieve):
+ __doc__ = _('Display information about a delegation.')
+
+ has_output_params = output_params
+
+ def execute(self, aciname, **kw):
+ result = api.Command['aci_show'](aciname, aciprefix=ACI_PREFIX, **kw)['result']
+ self.obj.postprocess_result(result)
+ return dict(
+ result=result,
+ value=pkey_to_value(aciname, kw),
+ )
+
diff --git a/ipaserver/plugins/dns.py b/ipaserver/plugins/dns.py
new file mode 100644
index 000000000..9cca07c6d
--- /dev/null
+++ b/ipaserver/plugins/dns.py
@@ -0,0 +1,4396 @@
+# Authors:
+# Martin Kosek <mkosek@redhat.com>
+# Pavel Zuna <pzuna@redhat.com>
+#
+# Copyright (C) 2010 Red Hat
+# see file 'COPYING' for use and warranty information
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+from __future__ import absolute_import
+
+import netaddr
+import time
+import re
+import binascii
+import encodings.idna
+
+import dns.name
+import dns.exception
+import dns.rdatatype
+import dns.resolver
+import six
+
+from ipalib.dns import (get_record_rrtype,
+ get_rrparam_from_part,
+ has_cli_options,
+ iterate_rrparams_by_parts,
+ record_name_format)
+from ipalib.request import context
+from ipalib import api, errors, output
+from ipalib import Command
+from ipalib.capabilities import (
+ VERSION_WITHOUT_CAPABILITIES,
+ client_has_capability)
+from ipalib.parameters import (Flag, Bool, Int, Decimal, Str, StrEnum, Any,
+ DNSNameParam)
+from ipalib.plugable import Registry
+from .baseldap import (
+ pkey_to_value,
+ LDAPObject,
+ LDAPCreate,
+ LDAPUpdate,
+ LDAPSearch,
+ LDAPQuery,
+ LDAPDelete,
+ LDAPRetrieve)
+from ipalib import _
+from ipalib import messages
+from ipalib.util import (normalize_zonemgr,
+ get_dns_forward_zone_update_policy,
+ get_dns_reverse_zone_update_policy,
+ get_reverse_zone_default, REVERSE_DNS_ZONES,
+ normalize_zone, validate_dnssec_global_forwarder,
+ DNSSECSignatureMissingError, UnresolvableRecordError,
+ EDNS0UnsupportedError, DNSSECValidationError,
+ validate_dnssec_zone_forwarder_step1,
+ validate_dnssec_zone_forwarder_step2,
+ verify_host_resolvable)
+from ipapython.dn import DN
+from ipapython.ipautil import CheckedIPAddress
+from ipapython.dnsutil import check_zone_overlap
+from ipapython.dnsutil import DNSName
+from ipapython.dnsutil import related_to_auto_empty_zone
+
+if six.PY3:
+ unicode = str
+
+__doc__ = _("""
+Domain Name System (DNS)
+""") + _("""
+Manage DNS zone and resource records.
+""") + _("""
+SUPPORTED ZONE TYPES
+
+ * Master zone (dnszone-*), contains authoritative data.
+ * Forward zone (dnsforwardzone-*), forwards queries to configured forwarders
+ (a set of DNS servers).
+""") + _("""
+USING STRUCTURED PER-TYPE OPTIONS
+""") + _("""
+There are many structured DNS RR types where DNS data stored in LDAP server
+is not just a scalar value, for example an IP address or a domain name, but
+a data structure which may be often complex. A good example is a LOC record
+[RFC1876] which consists of many mandatory and optional parts (degrees,
+minutes, seconds of latitude and longitude, altitude or precision).
+""") + _("""
+It may be difficult to manipulate such DNS records without making a mistake
+and entering an invalid value. DNS module provides an abstraction over these
+raw records and allows to manipulate each RR type with specific options. For
+each supported RR type, DNS module provides a standard option to manipulate
+a raw records with format --<rrtype>-rec, e.g. --mx-rec, and special options
+for every part of the RR structure with format --<rrtype>-<partname>, e.g.
+--mx-preference and --mx-exchanger.
+""") + _("""
+When adding a record, either RR specific options or standard option for a raw
+value can be used, they just should not be combined in one add operation. When
+modifying an existing entry, new RR specific options can be used to change
+one part of a DNS record, where the standard option for raw value is used
+to specify the modified value. The following example demonstrates
+a modification of MX record preference from 0 to 1 in a record without
+modifying the exchanger:
+ipa dnsrecord-mod --mx-rec="0 mx.example.com." --mx-preference=1
+""") + _("""
+
+EXAMPLES:
+""") + _("""
+ Add new zone:
+ ipa dnszone-add example.com --admin-email=admin@example.com
+""") + _("""
+ Add system permission that can be used for per-zone privilege delegation:
+ ipa dnszone-add-permission example.com
+""") + _("""
+ Modify the zone to allow dynamic updates for hosts own records in realm EXAMPLE.COM:
+ ipa dnszone-mod example.com --dynamic-update=TRUE
+""") + _("""
+ This is the equivalent of:
+ ipa dnszone-mod example.com --dynamic-update=TRUE \\
+ --update-policy="grant EXAMPLE.COM krb5-self * A; grant EXAMPLE.COM krb5-self * AAAA; grant EXAMPLE.COM krb5-self * SSHFP;"
+""") + _("""
+ Modify the zone to allow zone transfers for local network only:
+ ipa dnszone-mod example.com --allow-transfer=192.0.2.0/24
+""") + _("""
+ Add new reverse zone specified by network IP address:
+ ipa dnszone-add --name-from-ip=192.0.2.0/24
+""") + _("""
+ Add second nameserver for example.com:
+ ipa dnsrecord-add example.com @ --ns-rec=nameserver2.example.com
+""") + _("""
+ Add a mail server for example.com:
+ ipa dnsrecord-add example.com @ --mx-rec="10 mail1"
+""") + _("""
+ Add another record using MX record specific options:
+ ipa dnsrecord-add example.com @ --mx-preference=20 --mx-exchanger=mail2
+""") + _("""
+ Add another record using interactive mode (started when dnsrecord-add, dnsrecord-mod,
+ or dnsrecord-del are executed with no options):
+ ipa dnsrecord-add example.com @
+ Please choose a type of DNS resource record to be added
+ The most common types for this type of zone are: NS, MX, LOC
+
+ DNS resource record type: MX
+ MX Preference: 30
+ MX Exchanger: mail3
+ Record name: example.com
+ MX record: 10 mail1, 20 mail2, 30 mail3
+ NS record: nameserver.example.com., nameserver2.example.com.
+""") + _("""
+ Delete previously added nameserver from example.com:
+ ipa dnsrecord-del example.com @ --ns-rec=nameserver2.example.com.
+""") + _("""
+ Add LOC record for example.com:
+ ipa dnsrecord-add example.com @ --loc-rec="49 11 42.4 N 16 36 29.6 E 227.64m"
+""") + _("""
+ Add new A record for www.example.com. Create a reverse record in appropriate
+ reverse zone as well. In this case a PTR record "2" pointing to www.example.com
+ will be created in zone 2.0.192.in-addr.arpa.
+ ipa dnsrecord-add example.com www --a-rec=192.0.2.2 --a-create-reverse
+""") + _("""
+ Add new PTR record for www.example.com
+ ipa dnsrecord-add 2.0.192.in-addr.arpa. 2 --ptr-rec=www.example.com.
+""") + _("""
+ Add new SRV records for LDAP servers. Three quarters of the requests
+ should go to fast.example.com, one quarter to slow.example.com. If neither
+ is available, switch to backup.example.com.
+ ipa dnsrecord-add example.com _ldap._tcp --srv-rec="0 3 389 fast.example.com"
+ ipa dnsrecord-add example.com _ldap._tcp --srv-rec="0 1 389 slow.example.com"
+ ipa dnsrecord-add example.com _ldap._tcp --srv-rec="1 1 389 backup.example.com"
+""") + _("""
+ The interactive mode can be used for easy modification:
+ ipa dnsrecord-mod example.com _ldap._tcp
+ No option to modify specific record provided.
+ Current DNS record contents:
+
+ SRV record: 0 3 389 fast.example.com, 0 1 389 slow.example.com, 1 1 389 backup.example.com
+
+ Modify SRV record '0 3 389 fast.example.com'? Yes/No (default No):
+ Modify SRV record '0 1 389 slow.example.com'? Yes/No (default No): y
+ SRV Priority [0]: (keep the default value)
+ SRV Weight [1]: 2 (modified value)
+ SRV Port [389]: (keep the default value)
+ SRV Target [slow.example.com]: (keep the default value)
+ 1 SRV record skipped. Only one value per DNS record type can be modified at one time.
+ Record name: _ldap._tcp
+ SRV record: 0 3 389 fast.example.com, 1 1 389 backup.example.com, 0 2 389 slow.example.com
+""") + _("""
+ After this modification, three fifths of the requests should go to
+ fast.example.com and two fifths to slow.example.com.
+""") + _("""
+ An example of the interactive mode for dnsrecord-del command:
+ ipa dnsrecord-del example.com www
+ No option to delete specific record provided.
+ Delete all? Yes/No (default No): (do not delete all records)
+ Current DNS record contents:
+
+ A record: 192.0.2.2, 192.0.2.3
+
+ Delete A record '192.0.2.2'? Yes/No (default No):
+ Delete A record '192.0.2.3'? Yes/No (default No): y
+ Record name: www
+ A record: 192.0.2.2 (A record 192.0.2.3 has been deleted)
+""") + _("""
+ Show zone example.com:
+ ipa dnszone-show example.com
+""") + _("""
+ Find zone with "example" in its domain name:
+ ipa dnszone-find example
+""") + _("""
+ Find records for resources with "www" in their name in zone example.com:
+ ipa dnsrecord-find example.com www
+""") + _("""
+ Find A records with value 192.0.2.2 in zone example.com
+ ipa dnsrecord-find example.com --a-rec=192.0.2.2
+""") + _("""
+ Show records for resource www in zone example.com
+ ipa dnsrecord-show example.com www
+""") + _("""
+ Delegate zone sub.example to another nameserver:
+ ipa dnsrecord-add example.com ns.sub --a-rec=203.0.113.1
+ ipa dnsrecord-add example.com sub --ns-rec=ns.sub.example.com.
+""") + _("""
+ Delete zone example.com with all resource records:
+ ipa dnszone-del example.com
+""") + _("""
+ If a global forwarder is configured, all queries for which this server is not
+ authoritative (e.g. sub.example.com) will be routed to the global forwarder.
+ Global forwarding configuration can be overridden per-zone.
+""") + _("""
+ Semantics of forwarding in IPA matches BIND semantics and depends on the type
+ of zone:
+ * Master zone: local BIND replies authoritatively to queries for data in
+ the given zone (including authoritative NXDOMAIN answers) and forwarding
+ affects only queries for names below zone cuts (NS records) of locally
+ served zones.
+
+ * Forward zone: forward zone contains no authoritative data. BIND forwards
+ queries, which cannot be answered from its local cache, to configured
+ forwarders.
+""") + _("""
+ Semantics of the --forwarder-policy option:
+ * none - disable forwarding for the given zone.
+ * first - forward all queries to configured forwarders. If they fail,
+ do resolution using DNS root servers.
+ * only - forward all queries to configured forwarders and if they fail,
+ return failure.
+""") + _("""
+ Disable global forwarding for given sub-tree:
+ ipa dnszone-mod example.com --forward-policy=none
+""") + _("""
+ This configuration forwards all queries for names outside the example.com
+ sub-tree to global forwarders. Normal recursive resolution process is used
+ for names inside the example.com sub-tree (i.e. NS records are followed etc.).
+""") + _("""
+ Forward all requests for the zone external.example.com to another forwarder
+ using a "first" policy (it will send the queries to the selected forwarder
+ and if not answered it will use global root servers):
+ ipa dnsforwardzone-add external.example.com --forward-policy=first \\
+ --forwarder=203.0.113.1
+""") + _("""
+ Change forward-policy for external.example.com:
+ ipa dnsforwardzone-mod external.example.com --forward-policy=only
+""") + _("""
+ Show forward zone external.example.com:
+ ipa dnsforwardzone-show external.example.com
+""") + _("""
+ List all forward zones:
+ ipa dnsforwardzone-find
+""") + _("""
+ Delete forward zone external.example.com:
+ ipa dnsforwardzone-del external.example.com
+""") + _("""
+ Resolve a host name to see if it exists (will add default IPA domain
+ if one is not included):
+ ipa dns-resolve www.example.com
+ ipa dns-resolve www
+""") + _("""
+
+GLOBAL DNS CONFIGURATION
+""") + _("""
+DNS configuration passed to command line install script is stored in a local
+configuration file on each IPA server where DNS service is configured. These
+local settings can be overridden with a common configuration stored in LDAP
+server:
+""") + _("""
+ Show global DNS configuration:
+ ipa dnsconfig-show
+""") + _("""
+ Modify global DNS configuration and set a list of global forwarders:
+ ipa dnsconfig-mod --forwarder=203.0.113.113
+""")
+
+register = Registry()
+
+# supported resource record types
+_record_types = (
+ u'A', u'AAAA', u'A6', u'AFSDB', u'APL', u'CERT', u'CNAME', u'DHCID', u'DLV',
+ u'DNAME', u'DS', u'HIP', u'HINFO', u'IPSECKEY', u'KEY', u'KX', u'LOC',
+ u'MD', u'MINFO', u'MX', u'NAPTR', u'NS', u'NSEC', u'NXT', u'PTR', u'RRSIG',
+ u'RP', u'SIG', u'SPF', u'SRV', u'SSHFP', u'TLSA', u'TXT',
+)
+
+# DNS zone record identificator
+_dns_zone_record = DNSName.empty
+
+# attributes derived from record types
+_record_attributes = [str(record_name_format % t.lower())
+ for t in _record_types]
+
+# Deprecated
+# supported DNS classes, IN = internet, rest is almost never used
+_record_classes = (u'IN', u'CS', u'CH', u'HS')
+
+# IN record class
+_IN = dns.rdataclass.IN
+
+# NS record type
+_NS = dns.rdatatype.from_text('NS')
+
+_output_permissions = (
+ output.summary,
+ output.Output('result', bool, _('True means the operation was successful')),
+ output.Output('value', unicode, _('Permission value')),
+)
+
+
+def _rname_validator(ugettext, zonemgr):
+ try:
+ DNSName(zonemgr) # test only if it is valid domain name
+ except (ValueError, dns.exception.SyntaxError) as e:
+ return unicode(e)
+ return None
+
+def _create_zone_serial():
+ """
+ Generate serial number for zones. bind-dyndb-ldap expects unix time in
+ to be used for SOA serial.
+
+ SOA serial in a date format would also work, but it may be set to far
+ future when many DNS updates are done per day (more than 100). Unix
+ timestamp is more resilient to this issue.
+ """
+ return int(time.time())
+
+def _reverse_zone_name(netstr):
+ try:
+ netaddr.IPAddress(str(netstr))
+ except (netaddr.AddrFormatError, ValueError):
+ pass
+ else:
+ # use more sensible default prefix than netaddr default
+ return unicode(get_reverse_zone_default(netstr))
+
+ net = netaddr.IPNetwork(netstr)
+ items = net.ip.reverse_dns.split('.')
+ if net.version == 4:
+ return u'.'.join(items[4 - net.prefixlen // 8:])
+ elif net.version == 6:
+ return u'.'.join(items[32 - net.prefixlen // 4:])
+ else:
+ return None
+
+def _validate_ipaddr(ugettext, ipaddr, ip_version=None):
+ try:
+ ip = netaddr.IPAddress(str(ipaddr), flags=netaddr.INET_PTON)
+
+ if ip_version is not None:
+ if ip.version != ip_version:
+ return _('invalid IP address version (is %(value)d, must be %(required_value)d)!') \
+ % dict(value=ip.version, required_value=ip_version)
+ except (netaddr.AddrFormatError, ValueError):
+ return _('invalid IP address format')
+ return None
+
+def _validate_ip4addr(ugettext, ipaddr):
+ return _validate_ipaddr(ugettext, ipaddr, 4)
+
+def _validate_ip6addr(ugettext, ipaddr):
+ return _validate_ipaddr(ugettext, ipaddr, 6)
+
+def _validate_ipnet(ugettext, ipnet):
+ try:
+ net = netaddr.IPNetwork(ipnet)
+ except (netaddr.AddrFormatError, ValueError, UnboundLocalError):
+ return _('invalid IP network format')
+ return None
+
+def _validate_bind_aci(ugettext, bind_acis):
+ if not bind_acis:
+ return
+
+ bind_acis = bind_acis.split(';')
+ if bind_acis[-1]:
+ return _('each ACL element must be terminated with a semicolon')
+ else:
+ bind_acis.pop(-1)
+
+ for bind_aci in bind_acis:
+ if bind_aci in ("any", "none", "localhost", "localnets"):
+ continue
+
+ if bind_aci.startswith('!'):
+ bind_aci = bind_aci[1:]
+
+ try:
+ ip = CheckedIPAddress(bind_aci, parse_netmask=True,
+ allow_network=True, allow_loopback=True)
+ except (netaddr.AddrFormatError, ValueError) as e:
+ return unicode(e)
+ except UnboundLocalError:
+ return _(u"invalid address format")
+
+def _normalize_bind_aci(bind_acis):
+ if not bind_acis:
+ return
+ bind_acis = bind_acis.split(';')
+ normalized = []
+ for bind_aci in bind_acis:
+ if not bind_aci:
+ continue
+ if bind_aci in ("any", "none", "localhost", "localnets"):
+ normalized.append(bind_aci)
+ continue
+
+ prefix = ""
+ if bind_aci.startswith('!'):
+ bind_aci = bind_aci[1:]
+ prefix = "!"
+
+ try:
+ ip = CheckedIPAddress(bind_aci, parse_netmask=True,
+ allow_network=True, allow_loopback=True)
+ if '/' in bind_aci: # addr with netmask
+ netmask = "/%s" % ip.prefixlen
+ else:
+ netmask = ""
+ normalized.append(u"%s%s%s" % (prefix, str(ip), netmask))
+ continue
+ except Exception:
+ normalized.append(bind_aci)
+ continue
+
+ acis = u';'.join(normalized)
+ acis += u';'
+ return acis
+
+def _validate_bind_forwarder(ugettext, forwarder):
+ ip_address, sep, port = forwarder.partition(u' port ')
+
+ ip_address_validation = _validate_ipaddr(ugettext, ip_address)
+
+ if ip_address_validation is not None:
+ return ip_address_validation
+
+ if sep:
+ try:
+ port = int(port)
+ if port < 0 or port > 65535:
+ raise ValueError()
+ except ValueError:
+ return _('%(port)s is not a valid port' % dict(port=port))
+
+ return None
+
+def _validate_nsec3param_record(ugettext, value):
+ _nsec3param_pattern = (r'^(?P<alg>\d+) (?P<flags>\d+) (?P<iter>\d+) '
+ r'(?P<salt>([0-9a-fA-F]{2})+|-)$')
+ rec = re.compile(_nsec3param_pattern, flags=re.U)
+ result = rec.match(value)
+
+ if result is None:
+ return _(u'expected format: <0-255> <0-255> <0-65535> '
+ 'even-length_hexadecimal_digits_or_hyphen')
+
+ alg = int(result.group('alg'))
+ flags = int(result.group('flags'))
+ iterations = int(result.group('iter'))
+ salt = result.group('salt')
+
+ if alg > 255:
+ return _('algorithm value: allowed interval 0-255')
+
+ if flags > 255:
+ return _('flags value: allowed interval 0-255')
+
+ if iterations > 65535:
+ return _('iterations value: allowed interval 0-65535')
+
+ if salt == u'-':
+ return None
+
+ try:
+ binascii.a2b_hex(salt)
+ except TypeError as e:
+ return _('salt value: %(err)s') % {'err': e}
+ return None
+
+
+def _hostname_validator(ugettext, value):
+ assert isinstance(value, DNSName)
+ if len(value.make_absolute().labels) < 3:
+ return _('invalid domain-name: not fully qualified')
+
+ return None
+
+def _no_wildcard_validator(ugettext, value):
+ """Disallow usage of wildcards as RFC 4592 section 4 recommends
+ """
+ assert isinstance(value, DNSName)
+ if value.is_wild():
+ return _('should not be a wildcard domain name (RFC 4592 section 4)')
+ return None
+
+def is_forward_record(zone, str_address):
+ addr = netaddr.IPAddress(str_address)
+ if addr.version == 4:
+ result = api.Command['dnsrecord_find'](zone, arecord=str_address)
+ elif addr.version == 6:
+ result = api.Command['dnsrecord_find'](zone, aaaarecord=str_address)
+ else:
+ raise ValueError('Invalid address family')
+
+ return result['count'] > 0
+
+def add_forward_record(zone, name, str_address):
+ addr = netaddr.IPAddress(str_address)
+ try:
+ if addr.version == 4:
+ api.Command['dnsrecord_add'](zone, name, arecord=str_address)
+ elif addr.version == 6:
+ api.Command['dnsrecord_add'](zone, name, aaaarecord=str_address)
+ else:
+ raise ValueError('Invalid address family')
+ except errors.EmptyModlist:
+ pass # the entry already exists and matches
+
+def get_reverse_zone(ipaddr):
+ """
+ resolve the reverse zone for IP address and see if it is managed by IPA
+ server
+ :param ipaddr: host IP address
+ :return: tuple containing name of the reverse zone and the name of the
+ record
+ """
+ ip = netaddr.IPAddress(str(ipaddr))
+ revdns = DNSName(unicode(ip.reverse_dns))
+ revzone = DNSName(dns.resolver.zone_for_name(revdns))
+
+ try:
+ api.Command['dnszone_show'](revzone)
+ except errors.NotFound:
+ raise errors.NotFound(
+ reason=_(
+ 'DNS reverse zone %(revzone)s for IP address '
+ '%(addr)s is not managed by this server') % dict(
+ addr=ipaddr, revzone=revzone)
+ )
+
+ revname = revdns.relativize(revzone)
+
+ return revzone, revname
+
+def add_records_for_host_validation(option_name, host, domain, ip_addresses, check_forward=True, check_reverse=True):
+ assert isinstance(host, DNSName)
+ assert isinstance(domain, DNSName)
+
+ try:
+ api.Command['dnszone_show'](domain)['result']
+ except errors.NotFound:
+ raise errors.NotFound(
+ reason=_('DNS zone %(zone)s not found') % dict(zone=domain)
+ )
+ if not isinstance(ip_addresses, (tuple, list)):
+ ip_addresses = [ip_addresses]
+
+ for ip_address in ip_addresses:
+ try:
+ ip = CheckedIPAddress(ip_address, match_local=False)
+ except Exception as e:
+ raise errors.ValidationError(name=option_name, error=unicode(e))
+
+ if check_forward:
+ if is_forward_record(domain, unicode(ip)):
+ raise errors.DuplicateEntry(
+ message=_(u'IP address %(ip)s is already assigned in domain %(domain)s.')\
+ % dict(ip=str(ip), domain=domain))
+
+ if check_reverse:
+ try:
+ # we prefer lookup of the IP through the reverse zone
+ revzone, revname = get_reverse_zone(ip)
+ reverse = api.Command['dnsrecord_find'](revzone, idnsname=revname)
+ if reverse['count'] > 0:
+ raise errors.DuplicateEntry(
+ message=_(u'Reverse record for IP address %(ip)s already exists in reverse zone %(zone)s.')\
+ % dict(ip=str(ip), zone=revzone))
+ except errors.NotFound:
+ pass
+
+
+def add_records_for_host(host, domain, ip_addresses, add_forward=True, add_reverse=True):
+ assert isinstance(host, DNSName)
+ assert isinstance(domain, DNSName)
+
+ if not isinstance(ip_addresses, (tuple, list)):
+ ip_addresses = [ip_addresses]
+
+ for ip_address in ip_addresses:
+ ip = CheckedIPAddress(ip_address, match_local=False)
+
+ if add_forward:
+ add_forward_record(domain, host, unicode(ip))
+
+ if add_reverse:
+ try:
+ revzone, revname = get_reverse_zone(ip)
+ addkw = {'ptrrecord': host.derelativize(domain).ToASCII()}
+ api.Command['dnsrecord_add'](revzone, revname, **addkw)
+ except errors.EmptyModlist:
+ # the entry already exists and matches
+ pass
+
+def _dns_name_to_string(value, raw=False):
+ if isinstance(value, unicode):
+ try:
+ value = DNSName(value)
+ except Exception:
+ return value
+
+ assert isinstance(value, DNSName)
+ if raw:
+ return value.ToASCII()
+ else:
+ return unicode(value)
+
+
+def _check_entry_objectclass(entry, objectclasses):
+ """
+ Check if entry contains all objectclasses
+ """
+ if not isinstance(objectclasses, (list, tuple)):
+ objectclasses = [objectclasses, ]
+ if not entry.get('objectclass'):
+ return False
+ entry_objectclasses = [o.lower() for o in entry['objectclass']]
+ for o in objectclasses:
+ if o not in entry_objectclasses:
+ return False
+ return True
+
+
+def _check_DN_objectclass(ldap, dn, objectclasses):
+ try:
+ entry = ldap.get_entry(dn, [u'objectclass', ])
+ except Exception:
+ return False
+ else:
+ return _check_entry_objectclass(entry, objectclasses)
+
+
+class DNSRecord(Str):
+ # a list of parts that create the actual raw DNS record
+ parts = None
+ # an optional list of parameters used in record-specific operations
+ extra = None
+ supported = True
+ # supported RR types: https://fedorahosted.org/bind-dyndb-ldap/browser/doc/schema
+
+ label_format = _("%s record")
+ part_label_format = "%s %s"
+ doc_format = _('Raw %s records')
+ option_group_format = _('%s Record')
+ see_rfc_msg = _("(see RFC %s for details)")
+ part_name_format = "%s_part_%s"
+ extra_name_format = "%s_extra_%s"
+ cli_name_format = "%s_%s"
+ format_error_msg = None
+
+ kwargs = Str.kwargs + (
+ ('validatedns', bool, True),
+ ('normalizedns', bool, True),
+ )
+
+ # should be replaced in subclasses
+ rrtype = None
+ rfc = None
+
+ def __init__(self, name=None, *rules, **kw):
+ if self.rrtype not in _record_types:
+ raise ValueError("Unknown RR type: %s. Must be one of %s" % \
+ (str(self.rrtype), ", ".join(_record_types)))
+ if not name:
+ name = "%s*" % (record_name_format % self.rrtype.lower())
+ kw.setdefault('cli_name', '%s_rec' % self.rrtype.lower())
+ kw.setdefault('label', self.label_format % self.rrtype)
+ kw.setdefault('doc', self.doc_format % self.rrtype)
+ kw.setdefault('option_group', self.option_group_format % self.rrtype)
+
+ if not self.supported:
+ kw['flags'] = ('no_option',)
+
+ super(DNSRecord, self).__init__(name, *rules, **kw)
+
+ def _get_part_values(self, value):
+ values = value.split()
+ if len(values) != len(self.parts):
+ return None
+ return tuple(values)
+
+ def _part_values_to_string(self, values, idna=True):
+ self._validate_parts(values)
+ parts = []
+ for v in values:
+ if v is None:
+ continue
+ elif isinstance(v, DNSName) and idna:
+ v = v.ToASCII()
+ elif not isinstance(v, unicode):
+ v = unicode(v)
+ parts.append(v)
+
+ return u" ".join(parts)
+
+ def get_parts_from_kw(self, kw, raise_on_none=True):
+ part_names = tuple(self.part_name_format % (self.rrtype.lower(), part.name) \
+ for part in self.parts)
+ vals = tuple(kw.get(part_name) for part_name in part_names)
+
+ if all(val is None for val in vals):
+ return
+
+ if raise_on_none:
+ for val_id,val in enumerate(vals):
+ if val is None and self.parts[val_id].required:
+ cli_name = self.cli_name_format % (self.rrtype.lower(), self.parts[val_id].name)
+ raise errors.ConversionError(name=self.name,
+ error=_("'%s' is a required part of DNS record") % cli_name)
+
+ return vals
+
+ def _validate_parts(self, parts):
+ if len(parts) != len(self.parts):
+ raise errors.ValidationError(name=self.name,
+ error=_("Invalid number of parts!"))
+
+ def _convert_scalar(self, value, index=None):
+ if isinstance(value, (tuple, list)):
+ return self._part_values_to_string(value)
+ return super(DNSRecord, self)._convert_scalar(value)
+
+ def normalize(self, value):
+ if self.normalizedns:
+ if isinstance(value, (tuple, list)):
+ value = tuple(
+ self._normalize_parts(v) for v in value \
+ if v is not None
+ )
+ elif value is not None:
+ value = (self._normalize_parts(value),)
+
+ return super(DNSRecord, self).normalize(value)
+
+ def _normalize_parts(self, value):
+ """
+ Normalize a DNS record value using normalizers for its parts.
+ """
+ if self.parts is None:
+ return value
+ try:
+ values = self._get_part_values(value)
+ if not values:
+ return value
+
+ converted_values = [ part._convert_scalar(values[part_id]) \
+ if values[part_id] is not None else None
+ for part_id, part in enumerate(self.parts)
+ ]
+
+ new_values = [ part.normalize(converted_values[part_id]) \
+ for part_id, part in enumerate(self.parts) ]
+
+ value = self._convert_scalar(new_values)
+ except Exception:
+ # cannot normalize, rather return original value than fail
+ pass
+ return value
+
+ def _rule_validatedns(self, _, value):
+ if not self.validatedns:
+ return
+
+ if value is None:
+ return
+
+ if not self.supported:
+ return _('DNS RR type "%s" is not supported by bind-dyndb-ldap plugin') \
+ % self.rrtype
+
+ if self.parts is None:
+ return
+
+ # validate record format
+ values = self._get_part_values(value)
+ if not values:
+ if not self.format_error_msg:
+ part_names = [part.name.upper() for part in self.parts]
+
+ if self.rfc:
+ see_rfc_msg = " " + self.see_rfc_msg % self.rfc
+ else:
+ see_rfc_msg = ""
+ return _('format must be specified as "%(format)s" %(rfcs)s') \
+ % dict(format=" ".join(part_names), rfcs=see_rfc_msg)
+ else:
+ return self.format_error_msg
+
+ # validate every part
+ for part_id, part in enumerate(self.parts):
+ val = part.normalize(values[part_id])
+ val = part.convert(val)
+ part.validate(val)
+ return None
+
+ def _convert_dnsrecord_part(self, part):
+ """
+ All parts of DNSRecord need to be processed and modified before they
+ can be added to global DNS API. For example a prefix need to be added
+ before part name so that the name is unique in the global namespace.
+ """
+ name = self.part_name_format % (self.rrtype.lower(), part.name)
+ cli_name = self.cli_name_format % (self.rrtype.lower(), part.name)
+ label = self.part_label_format % (self.rrtype, unicode(part.label))
+ option_group = self.option_group_format % self.rrtype
+ flags = list(part.flags) + ['dnsrecord_part', 'virtual_attribute',]
+ if not part.required:
+ flags.append('dnsrecord_optional')
+ if not self.supported:
+ flags.append("no_option")
+
+ return part.clone_rename(name,
+ cli_name=cli_name,
+ label=label,
+ required=False,
+ option_group=option_group,
+ flags=flags,
+ hint=self.name,) # name of parent RR param
+
+ def _convert_dnsrecord_extra(self, extra):
+ """
+ Parameters for special per-type behavior need to be processed in the
+ same way as record parts in _convert_dnsrecord_part().
+ """
+ name = self.extra_name_format % (self.rrtype.lower(), extra.name)
+ cli_name = self.cli_name_format % (self.rrtype.lower(), extra.name)
+ label = self.part_label_format % (self.rrtype, unicode(extra.label))
+ option_group = self.option_group_format % self.rrtype
+ flags = list(extra.flags) + ['dnsrecord_extra', 'virtual_attribute',]
+
+ return extra.clone_rename(name,
+ cli_name=cli_name,
+ label=label,
+ required=False,
+ option_group=option_group,
+ flags=flags,
+ hint=self.name,) # name of parent RR param
+
+ def get_parts(self):
+ if self.parts is None:
+ return tuple()
+
+ return tuple(self._convert_dnsrecord_part(part) for part in self.parts)
+
+ def get_extra(self):
+ if self.extra is None:
+ return tuple()
+
+ return tuple(self._convert_dnsrecord_extra(extra) for extra in self.extra)
+
+ # callbacks for per-type special record behavior
+ def dnsrecord_add_pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options):
+ assert isinstance(dn, DN)
+
+ def dnsrecord_add_post_callback(self, ldap, dn, entry_attrs, *keys, **options):
+ assert isinstance(dn, DN)
+
+class ForwardRecord(DNSRecord):
+ extra = (
+ Flag('create_reverse?',
+ label=_('Create reverse'),
+ doc=_('Create reverse record for this IP Address'),
+ flags=['no_update']
+ ),
+ )
+
+ def dnsrecord_add_pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options):
+ assert isinstance(dn, DN)
+ reverse_option = self._convert_dnsrecord_extra(self.extra[0])
+ if options.get(reverse_option.name):
+ records = entry_attrs.get(self.name, [])
+ if not records:
+ # --<rrtype>-create-reverse is set, but there are not records
+ raise errors.RequirementError(name=self.name)
+
+ for record in records:
+ add_records_for_host_validation(self.name, keys[-1], keys[-2], record,
+ check_forward=False,
+ check_reverse=True)
+
+ setattr(context, '%s_reverse' % self.name, entry_attrs.get(self.name))
+
+ def dnsrecord_add_post_callback(self, ldap, dn, entry_attrs, *keys, **options):
+ assert isinstance(dn, DN)
+ rev_records = getattr(context, '%s_reverse' % self.name, [])
+
+ if rev_records:
+ # make sure we don't run this post callback action again in nested
+ # commands, line adding PTR record in add_records_for_host
+ delattr(context, '%s_reverse' % self.name)
+ for record in rev_records:
+ try:
+ add_records_for_host(keys[-1], keys[-2], record,
+ add_forward=False, add_reverse=True)
+ except Exception as e:
+ raise errors.NonFatalError(
+ reason=_('Cannot create reverse record for "%(value)s": %(exc)s') \
+ % dict(value=record, exc=unicode(e)))
+
+class UnsupportedDNSRecord(DNSRecord):
+ """
+ Records which are not supported by IPA CLI, but we allow to show them if
+ LDAP contains these records.
+ """
+ supported = False
+
+ def _get_part_values(self, value):
+ return tuple()
+
+
+class ARecord(ForwardRecord):
+ rrtype = 'A'
+ rfc = 1035
+ parts = (
+ Str('ip_address',
+ _validate_ip4addr,
+ label=_('IP Address'),
+ ),
+ )
+
+class A6Record(DNSRecord):
+ rrtype = 'A6'
+ rfc = 3226
+ parts = (
+ Str('data',
+ label=_('Record data'),
+ ),
+ )
+
+ def _get_part_values(self, value):
+ # A6 RR type is obsolete and only a raw interface is provided
+ return (value,)
+
+class AAAARecord(ForwardRecord):
+ rrtype = 'AAAA'
+ rfc = 3596
+ parts = (
+ Str('ip_address',
+ _validate_ip6addr,
+ label=_('IP Address'),
+ ),
+ )
+
+class AFSDBRecord(DNSRecord):
+ rrtype = 'AFSDB'
+ rfc = 1183
+ parts = (
+ Int('subtype?',
+ label=_('Subtype'),
+ minvalue=0,
+ maxvalue=65535,
+ ),
+ DNSNameParam('hostname',
+ label=_('Hostname'),
+ ),
+ )
+
+class APLRecord(UnsupportedDNSRecord):
+ rrtype = 'APL'
+ rfc = 3123
+
+class CERTRecord(DNSRecord):
+ rrtype = 'CERT'
+ rfc = 4398
+ parts = (
+ Int('type',
+ label=_('Certificate Type'),
+ minvalue=0,
+ maxvalue=65535,
+ ),
+ Int('key_tag',
+ label=_('Key Tag'),
+ minvalue=0,
+ maxvalue=65535,
+ ),
+ Int('algorithm',
+ label=_('Algorithm'),
+ minvalue=0,
+ maxvalue=255,
+ ),
+ Str('certificate_or_crl',
+ label=_('Certificate/CRL'),
+ ),
+ )
+
+class CNAMERecord(DNSRecord):
+ rrtype = 'CNAME'
+ rfc = 1035
+ parts = (
+ DNSNameParam('hostname',
+ label=_('Hostname'),
+ doc=_('A hostname which this alias hostname points to'),
+ ),
+ )
+
+class DHCIDRecord(UnsupportedDNSRecord):
+ rrtype = 'DHCID'
+ rfc = 4701
+
+class DNAMERecord(DNSRecord):
+ rrtype = 'DNAME'
+ rfc = 2672
+ parts = (
+ DNSNameParam('target',
+ label=_('Target'),
+ ),
+ )
+
+
+class DSRecord(DNSRecord):
+ rrtype = 'DS'
+ rfc = 4034
+ parts = (
+ Int('key_tag',
+ label=_('Key Tag'),
+ minvalue=0,
+ maxvalue=65535,
+ ),
+ Int('algorithm',
+ label=_('Algorithm'),
+ minvalue=0,
+ maxvalue=255,
+ ),
+ Int('digest_type',
+ label=_('Digest Type'),
+ minvalue=0,
+ maxvalue=255,
+ ),
+ Str('digest',
+ label=_('Digest'),
+ pattern=r'^[0-9a-fA-F]+$',
+ pattern_errmsg=u'only hexadecimal digits are allowed'
+ ),
+ )
+
+
+class DLVRecord(DSRecord):
+ # must use same attributes as DSRecord
+ rrtype = 'DLV'
+ rfc = 4431
+
+
+class HINFORecord(UnsupportedDNSRecord):
+ rrtype = 'HINFO'
+ rfc = 1035
+
+
+class HIPRecord(UnsupportedDNSRecord):
+ rrtype = 'HIP'
+ rfc = 5205
+
+class KEYRecord(UnsupportedDNSRecord):
+ # managed by BIND itself
+ rrtype = 'KEY'
+ rfc = 2535
+
+class IPSECKEYRecord(UnsupportedDNSRecord):
+ rrtype = 'IPSECKEY'
+ rfc = 4025
+
+class KXRecord(DNSRecord):
+ rrtype = 'KX'
+ rfc = 2230
+ parts = (
+ Int('preference',
+ label=_('Preference'),
+ doc=_('Preference given to this exchanger. Lower values are more preferred'),
+ minvalue=0,
+ maxvalue=65535,
+ ),
+ DNSNameParam('exchanger',
+ label=_('Exchanger'),
+ doc=_('A host willing to act as a key exchanger'),
+ ),
+ )
+
+class LOCRecord(DNSRecord):
+ rrtype = 'LOC'
+ rfc = 1876
+ parts = (
+ Int('lat_deg',
+ label=_('Degrees Latitude'),
+ minvalue=0,
+ maxvalue=90,
+ ),
+ Int('lat_min?',
+ label=_('Minutes Latitude'),
+ minvalue=0,
+ maxvalue=59,
+ ),
+ Decimal('lat_sec?',
+ label=_('Seconds Latitude'),
+ minvalue='0.0',
+ maxvalue='59.999',
+ precision=3,
+ ),
+ StrEnum('lat_dir',
+ label=_('Direction Latitude'),
+ values=(u'N', u'S',),
+ ),
+ Int('lon_deg',
+ label=_('Degrees Longitude'),
+ minvalue=0,
+ maxvalue=180,
+ ),
+ Int('lon_min?',
+ label=_('Minutes Longitude'),
+ minvalue=0,
+ maxvalue=59,
+ ),
+ Decimal('lon_sec?',
+ label=_('Seconds Longitude'),
+ minvalue='0.0',
+ maxvalue='59.999',
+ precision=3,
+ ),
+ StrEnum('lon_dir',
+ label=_('Direction Longitude'),
+ values=(u'E', u'W',),
+ ),
+ Decimal('altitude',
+ label=_('Altitude'),
+ minvalue='-100000.00',
+ maxvalue='42849672.95',
+ precision=2,
+ ),
+ Decimal('size?',
+ label=_('Size'),
+ minvalue='0.0',
+ maxvalue='90000000.00',
+ precision=2,
+ ),
+ Decimal('h_precision?',
+ label=_('Horizontal Precision'),
+ minvalue='0.0',
+ maxvalue='90000000.00',
+ precision=2,
+ ),
+ Decimal('v_precision?',
+ label=_('Vertical Precision'),
+ minvalue='0.0',
+ maxvalue='90000000.00',
+ precision=2,
+ ),
+ )
+
+ format_error_msg = _("""format must be specified as
+ "d1 [m1 [s1]] {"N"|"S"} d2 [m2 [s2]] {"E"|"W"} alt["m"] [siz["m"] [hp["m"] [vp["m"]]]]"
+ where:
+ d1: [0 .. 90] (degrees latitude)
+ d2: [0 .. 180] (degrees longitude)
+ m1, m2: [0 .. 59] (minutes latitude/longitude)
+ s1, s2: [0 .. 59.999] (seconds latitude/longitude)
+ alt: [-100000.00 .. 42849672.95] BY .01 (altitude in meters)
+ siz, hp, vp: [0 .. 90000000.00] (size/precision in meters)
+ See RFC 1876 for details""")
+
+ def _get_part_values(self, value):
+ regex = re.compile(
+ r'(?P<d1>\d{1,2}\s+)'
+ r'(?:(?P<m1>\d{1,2}\s+)'
+ r'(?P<s1>\d{1,2}(?:\.\d{1,3})?\s+)?)?'
+ r'(?P<dir1>[NS])\s+'
+ r'(?P<d2>\d{1,3}\s+)'
+ r'(?:(?P<m2>\d{1,2}\s+)'
+ r'(?P<s2>\d{1,2}(?:\.\d{1,3})?\s+)?)?'
+ r'(?P<dir2>[WE])\s+'
+ r'(?P<alt>-?\d{1,8}(?:\.\d{1,2})?)m?'
+ r'(?:\s+(?P<siz>\d{1,8}(?:\.\d{1,2})?)m?'
+ r'(?:\s+(?P<hp>\d{1,8}(?:\.\d{1,2})?)m?'
+ r'(?:\s+(?P<vp>\d{1,8}(?:\.\d{1,2})?)m?\s*)?)?)?$')
+
+ m = regex.match(value)
+
+ if m is None:
+ return None
+
+ return tuple(x.strip() if x is not None else x for x in m.groups())
+
+ def _validate_parts(self, parts):
+ super(LOCRecord, self)._validate_parts(parts)
+
+ # create part_name -> part_id map first
+ part_name_map = dict((part.name, part_id) \
+ for part_id,part in enumerate(self.parts))
+
+ requirements = ( ('lat_sec', 'lat_min'),
+ ('lon_sec', 'lon_min'),
+ ('h_precision', 'size'),
+ ('v_precision', 'h_precision', 'size') )
+
+ for req in requirements:
+ target_part = req[0]
+
+ if parts[part_name_map[target_part]] is not None:
+ required_parts = req[1:]
+ if any(parts[part_name_map[part]] is None for part in required_parts):
+ target_cli_name = self.cli_name_format % (self.rrtype.lower(), req[0])
+ required_cli_names = [ self.cli_name_format % (self.rrtype.lower(), part)
+ for part in req[1:] ]
+ error = _("'%(required)s' must not be empty when '%(name)s' is set") % \
+ dict(required=', '.join(required_cli_names),
+ name=target_cli_name)
+ raise errors.ValidationError(name=self.name, error=error)
+
+
+class MDRecord(UnsupportedDNSRecord):
+ # obsoleted, use MX instead
+ rrtype = 'MD'
+ rfc = 1035
+
+
+class MINFORecord(UnsupportedDNSRecord):
+ rrtype = 'MINFO'
+ rfc = 1035
+
+
+class MXRecord(DNSRecord):
+ rrtype = 'MX'
+ rfc = 1035
+ parts = (
+ Int('preference',
+ label=_('Preference'),
+ doc=_('Preference given to this exchanger. Lower values are more preferred'),
+ minvalue=0,
+ maxvalue=65535,
+ ),
+ DNSNameParam('exchanger',
+ label=_('Exchanger'),
+ doc=_('A host willing to act as a mail exchanger'),
+ ),
+ )
+
+class NSRecord(DNSRecord):
+ rrtype = 'NS'
+ rfc = 1035
+
+ parts = (
+ DNSNameParam('hostname',
+ label=_('Hostname'),
+ ),
+ )
+
+class NSECRecord(UnsupportedDNSRecord):
+ # managed by BIND itself
+ rrtype = 'NSEC'
+ rfc = 4034
+
+
+def _validate_naptr_flags(ugettext, flags):
+ allowed_flags = u'SAUP'
+ flags = flags.replace('"','').replace('\'','')
+
+ for flag in flags:
+ if flag not in allowed_flags:
+ return _('flags must be one of "S", "A", "U", or "P"')
+
+class NAPTRRecord(DNSRecord):
+ rrtype = 'NAPTR'
+ rfc = 2915
+
+ parts = (
+ Int('order',
+ label=_('Order'),
+ minvalue=0,
+ maxvalue=65535,
+ ),
+ Int('preference',
+ label=_('Preference'),
+ minvalue=0,
+ maxvalue=65535,
+ ),
+ Str('flags',
+ _validate_naptr_flags,
+ label=_('Flags'),
+ normalizer=lambda x:x.upper()
+ ),
+ Str('service',
+ label=_('Service'),
+ ),
+ Str('regexp',
+ label=_('Regular Expression'),
+ ),
+ Str('replacement',
+ label=_('Replacement'),
+ ),
+ )
+
+
+class NXTRecord(UnsupportedDNSRecord):
+ rrtype = 'NXT'
+ rfc = 2535
+
+
+class PTRRecord(DNSRecord):
+ rrtype = 'PTR'
+ rfc = 1035
+ parts = (
+ DNSNameParam('hostname',
+ #RFC 2317 section 5.2 -- can be relative
+ label=_('Hostname'),
+ doc=_('The hostname this reverse record points to'),
+ ),
+ )
+
+class RPRecord(UnsupportedDNSRecord):
+ rrtype = 'RP'
+ rfc = 1183
+
+class SRVRecord(DNSRecord):
+ rrtype = 'SRV'
+ rfc = 2782
+ parts = (
+ Int('priority',
+ label=_('Priority'),
+ minvalue=0,
+ maxvalue=65535,
+ ),
+ Int('weight',
+ label=_('Weight'),
+ minvalue=0,
+ maxvalue=65535,
+ ),
+ Int('port',
+ label=_('Port'),
+ minvalue=0,
+ maxvalue=65535,
+ ),
+ DNSNameParam('target',
+ label=_('Target'),
+ doc=_('The domain name of the target host or \'.\' if the service is decidedly not available at this domain'),
+ ),
+ )
+
+def _sig_time_validator(ugettext, value):
+ time_format = "%Y%m%d%H%M%S"
+ try:
+ time.strptime(value, time_format)
+ except ValueError:
+ return _('the value does not follow "YYYYMMDDHHMMSS" time format')
+
+
+class SIGRecord(UnsupportedDNSRecord):
+ # managed by BIND itself
+ rrtype = 'SIG'
+ rfc = 2535
+
+class SPFRecord(UnsupportedDNSRecord):
+ rrtype = 'SPF'
+ rfc = 4408
+
+class RRSIGRecord(UnsupportedDNSRecord):
+ # managed by BIND itself
+ rrtype = 'RRSIG'
+ rfc = 4034
+
+class SSHFPRecord(DNSRecord):
+ rrtype = 'SSHFP'
+ rfc = 4255
+ parts = (
+ Int('algorithm',
+ label=_('Algorithm'),
+ minvalue=0,
+ maxvalue=255,
+ ),
+ Int('fp_type',
+ label=_('Fingerprint Type'),
+ minvalue=0,
+ maxvalue=255,
+ ),
+ Str('fingerprint',
+ label=_('Fingerprint'),
+ ),
+ )
+
+ def _get_part_values(self, value):
+ # fingerprint part can contain space in LDAP, return it as one part
+ values = value.split(None, 2)
+ if len(values) != len(self.parts):
+ return None
+ return tuple(values)
+
+
+class TLSARecord(DNSRecord):
+ rrtype = 'TLSA'
+ rfc = 6698
+ parts = (
+ Int('cert_usage',
+ label=_('Certificate Usage'),
+ minvalue=0,
+ maxvalue=255,
+ ),
+ Int('selector',
+ label=_('Selector'),
+ minvalue=0,
+ maxvalue=255,
+ ),
+ Int('matching_type',
+ label=_('Matching Type'),
+ minvalue=0,
+ maxvalue=255,
+ ),
+ Str('cert_association_data',
+ label=_('Certificate Association Data'),
+ ),
+ )
+
+
+class TXTRecord(DNSRecord):
+ rrtype = 'TXT'
+ rfc = 1035
+ parts = (
+ Str('data',
+ label=_('Text Data'),
+ ),
+ )
+
+ def _get_part_values(self, value):
+ # ignore any space in TXT record
+ return (value,)
+
+_dns_records = (
+ ARecord(),
+ AAAARecord(),
+ A6Record(),
+ AFSDBRecord(),
+ APLRecord(),
+ CERTRecord(),
+ CNAMERecord(),
+ DHCIDRecord(),
+ DLVRecord(),
+ DNAMERecord(),
+ DSRecord(),
+ HIPRecord(),
+ IPSECKEYRecord(),
+ KEYRecord(),
+ KXRecord(),
+ LOCRecord(),
+ MXRecord(),
+ NAPTRRecord(),
+ NSRecord(),
+ NSECRecord(),
+ PTRRecord(),
+ RRSIGRecord(),
+ RPRecord(),
+ SIGRecord(),
+ SPFRecord(),
+ SRVRecord(),
+ SSHFPRecord(),
+ TLSARecord(),
+ TXTRecord(),
+)
+
+def __dns_record_options_iter():
+ for opt in (Any('dnsrecords?',
+ label=_('Records'),
+ flags=['no_create', 'no_search', 'no_update'],),
+ Str('dnstype?',
+ label=_('Record type'),
+ flags=['no_create', 'no_search', 'no_update'],),
+ Str('dnsdata?',
+ label=_('Record data'),
+ flags=['no_create', 'no_search', 'no_update'],)):
+ # These 3 options are used in --structured format. They are defined
+ # rather in takes_params than has_output_params because of their
+ # order - they should be printed to CLI before any DNS part param
+ yield opt
+ for option in _dns_records:
+ yield option
+
+ for part in option.get_parts():
+ yield part
+
+ for extra in option.get_extra():
+ yield extra
+
+_dns_record_options = tuple(__dns_record_options_iter())
+
+
+def check_ns_rec_resolvable(zone, name, log):
+ assert isinstance(zone, DNSName)
+ assert isinstance(name, DNSName)
+
+ if name.is_empty():
+ name = zone.make_absolute()
+ elif not name.is_absolute():
+ # this is a DNS name relative to the zone
+ name = name.derelativize(zone.make_absolute())
+ try:
+ verify_host_resolvable(name)
+ except errors.DNSNotARecordError:
+ raise errors.NotFound(
+ reason=_('Nameserver \'%(host)s\' does not have a corresponding '
+ 'A/AAAA record') % {'host': name}
+ )
+
+def dns_container_exists(ldap):
+ try:
+ ldap.get_entry(DN(api.env.container_dns, api.env.basedn), [])
+ except errors.NotFound:
+ return False
+ return True
+
+
+def dnssec_installed(ldap):
+ """
+ * Method opendnssecinstance.get_dnssec_key_masters() CANNOT be used in the
+ dns plugin, or any plugin accessible for common users! *
+ Why?: The content of service container is not readable for common users.
+
+ This method only try to find if a DNSSEC service container exists on any
+ replica. What means that DNSSEC key master is installed.
+ :param ldap: ldap connection
+ :return: True if DNSSEC was installed, otherwise False
+ """
+ dn = DN(api.env.container_masters, api.env.basedn)
+
+ filter_attrs = {
+ u'cn': u'DNSSEC',
+ u'objectclass': u'ipaConfigObject',
+ }
+ only_masters_f = ldap.make_filter(filter_attrs, rules=ldap.MATCH_ALL)
+
+ try:
+ ldap.find_entries(filter=only_masters_f, base_dn=dn)
+ except errors.NotFound:
+ return False
+ return True
+
+
+def default_zone_update_policy(zone):
+ if zone.is_reverse():
+ return get_dns_reverse_zone_update_policy(api.env.realm, zone.ToASCII())
+ else:
+ return get_dns_forward_zone_update_policy(api.env.realm)
+
+dnszone_output_params = (
+ Str('managedby',
+ label=_('Managedby permission'),
+ ),
+)
+
+
+def _convert_to_idna(value):
+ """
+ Function converts a unicode value to idna, without extra validation.
+ If conversion fails, None is returned
+ """
+ assert isinstance(value, unicode)
+
+ try:
+ idna_val = value
+ start_dot = u''
+ end_dot = u''
+ if idna_val.startswith(u'.'):
+ idna_val = idna_val[1:]
+ start_dot = u'.'
+ if idna_val.endswith(u'.'):
+ idna_val = idna_val[:-1]
+ end_dot = u'.'
+ idna_val = encodings.idna.nameprep(idna_val)
+ idna_val = re.split(r'(?<!\\)\.', idna_val)
+ idna_val = u'%s%s%s' % (start_dot,
+ u'.'.join(encodings.idna.ToASCII(x)
+ for x in idna_val),
+ end_dot)
+ return idna_val
+ except Exception:
+ pass
+ return None
+
+
+def _create_idn_filter(cmd, ldap, term=None, **options):
+ if term:
+ #include idna values to search
+ term_idna = _convert_to_idna(term)
+ if term_idna and term != term_idna:
+ term = (term, term_idna)
+
+ search_kw = {}
+ attr_extra_filters = []
+
+ for attr, value in cmd.args_options_2_entry(**options).items():
+ if not isinstance(value, list):
+ value = [value]
+ for i, v in enumerate(value):
+ if isinstance(v, DNSName):
+ value[i] = v.ToASCII()
+ elif attr in map_names_to_records:
+ record = map_names_to_records[attr]
+ parts = record._get_part_values(v)
+ if parts is None:
+ value[i] = v
+ continue
+ try:
+ value[i] = record._part_values_to_string(parts)
+ except errors.ValidationError:
+ value[i] = v
+
+ #create MATCH_ANY filter for multivalue
+ if len(value) > 1:
+ f = ldap.make_filter({attr: value}, rules=ldap.MATCH_ANY)
+ attr_extra_filters.append(f)
+ else:
+ search_kw[attr] = value
+
+ if cmd.obj.search_attributes:
+ search_attrs = cmd.obj.search_attributes
+ else:
+ search_attrs = cmd.obj.default_attributes
+ if cmd.obj.search_attributes_config:
+ config = ldap.get_ipa_config()
+ config_attrs = config.get(cmd.obj.search_attributes_config, [])
+ if len(config_attrs) == 1 and (isinstance(config_attrs[0],
+ six.string_types)):
+ search_attrs = config_attrs[0].split(',')
+
+ search_kw['objectclass'] = cmd.obj.object_class
+ attr_filter = ldap.make_filter(search_kw, rules=ldap.MATCH_ALL)
+ if attr_extra_filters:
+ #combine filter if there is any idna value
+ attr_extra_filters.append(attr_filter)
+ attr_filter = ldap.combine_filters(attr_extra_filters,
+ rules=ldap.MATCH_ALL)
+
+ search_kw = {}
+ for a in search_attrs:
+ search_kw[a] = term
+ term_filter = ldap.make_filter(search_kw, exact=False)
+
+ member_filter = cmd.get_member_filter(ldap, **options)
+
+ filter = ldap.combine_filters(
+ (term_filter, attr_filter, member_filter), rules=ldap.MATCH_ALL
+ )
+ return filter
+
+
+map_names_to_records = {record_name_format % record.rrtype.lower(): record
+ for record in _dns_records if record.supported}
+
+def _records_idn_postprocess(record, **options):
+ for attr in record.keys():
+ attr = attr.lower()
+ try:
+ param = map_names_to_records[attr]
+ except KeyError:
+ continue
+ if not isinstance(param, DNSRecord):
+ continue
+
+ part_params = param.get_parts()
+ rrs = []
+ for dnsvalue in record[attr]:
+ parts = param._get_part_values(dnsvalue)
+ if parts is None:
+ continue
+ parts = list(parts)
+ try:
+ for (i, p) in enumerate(parts):
+ if isinstance(part_params[i], DNSNameParam):
+ parts[i] = DNSName(p)
+ rrs.append(param._part_values_to_string(parts,
+ idna=options.get('raw', False)))
+ except (errors.ValidationError, errors.ConversionError):
+ rrs.append(dnsvalue)
+ record[attr] = rrs
+
+def _normalize_zone(zone):
+ if isinstance(zone, unicode):
+ # normalize only non-IDNA zones
+ try:
+ zone.encode('ascii')
+ except UnicodeError:
+ pass
+ else:
+ return zone.lower()
+ return zone
+
+
+def _get_auth_zone_ldap(api, name):
+ """
+ Find authoritative zone in LDAP for name. Only active zones are considered.
+ :param name:
+ :return: (zone, truncated)
+ zone: authoritative zone, or None if authoritative zone is not in LDAP
+ """
+ assert isinstance(name, DNSName)
+ ldap = api.Backend.ldap2
+
+ # Create all possible parent zone names
+ search_name = name.make_absolute()
+ zone_names = []
+ for i, name in enumerate(search_name):
+ zone_name_abs = DNSName(search_name[i:]).ToASCII()
+ zone_names.append(zone_name_abs)
+ # compatibility with IPA < 4.0, zone name can be relative
+ zone_names.append(zone_name_abs[:-1])
+
+ # Create filters
+ objectclass_filter = ldap.make_filter({'objectclass':'idnszone'})
+ zonenames_filter = ldap.make_filter({'idnsname': zone_names})
+ zoneactive_filter = ldap.make_filter({'idnsZoneActive': 'true'})
+ complete_filter = ldap.combine_filters(
+ [objectclass_filter, zonenames_filter, zoneactive_filter],
+ rules=ldap.MATCH_ALL
+ )
+
+ try:
+ entries, truncated = ldap.find_entries(
+ filter=complete_filter,
+ attrs_list=['idnsname'],
+ base_dn=DN(api.env.container_dns, api.env.basedn),
+ scope=ldap.SCOPE_ONELEVEL
+ )
+ except errors.NotFound:
+ return None, False
+
+ # always use absolute zones
+ matched_auth_zones = [entry.single_value['idnsname'].make_absolute()
+ for entry in entries]
+
+ # return longest match
+ return max(matched_auth_zones, key=len), truncated
+
+
+def _get_longest_match_ns_delegation_ldap(api, zone, name):
+ """
+ Searches for deepest delegation for name in LDAP zone.
+
+ NOTE: NS record in zone apex is not considered as delegation.
+ It returns None if there is no delegation outside of zone apex.
+
+ Example:
+ zone: example.com.
+ name: ns.sub.example.com.
+
+ records:
+ extra.ns.sub.example.com.
+ sub.example.com.
+ example.com
+
+ result: sub.example.com.
+
+ :param zone: zone name
+ :param name:
+ :return: (match, truncated);
+ match: delegation name if success, or None if no delegation record exists
+ """
+ assert isinstance(zone, DNSName)
+ assert isinstance(name, DNSName)
+
+ ldap = api.Backend.ldap2
+
+ # get zone DN
+ zone_dn = api.Object.dnszone.get_dn(zone)
+
+ if name.is_absolute():
+ relative_record_name = name.relativize(zone.make_absolute())
+ else:
+ relative_record_name = name
+
+ # Name is zone apex
+ if relative_record_name.is_empty():
+ return None, False
+
+ # create list of possible record names
+ possible_record_names = [DNSName(relative_record_name[i:]).ToASCII()
+ for i in range(len(relative_record_name))]
+
+ # search filters
+ name_filter = ldap.make_filter({'idnsname': [possible_record_names]})
+ objectclass_filter = ldap.make_filter({'objectclass': 'idnsrecord'})
+ complete_filter = ldap.combine_filters(
+ [name_filter, objectclass_filter],
+ rules=ldap.MATCH_ALL
+ )
+
+ try:
+ entries, truncated = ldap.find_entries(
+ filter=complete_filter,
+ attrs_list=['idnsname', 'nsrecord'],
+ base_dn=zone_dn,
+ scope=ldap.SCOPE_ONELEVEL
+ )
+ except errors.NotFound:
+ return None, False
+
+ matched_records = []
+
+ # test if entry contains NS records
+ for entry in entries:
+ if entry.get('nsrecord'):
+ matched_records.append(entry.single_value['idnsname'])
+
+ if not matched_records:
+ return None, truncated
+
+ # return longest match
+ return max(matched_records, key=len), truncated
+
+
+def _find_subtree_forward_zones_ldap(api, name, child_zones_only=False):
+ """
+ Search for forwardzone <name> and all child forwardzones
+ Filter: (|(*.<name>.)(<name>.))
+ :param name:
+ :param child_zones_only: search only for child zones
+ :return: (list of zonenames, truncated), list is empty if no zone found
+ """
+ assert isinstance(name, DNSName)
+ ldap = api.Backend.ldap2
+
+ # prepare for filter "*.<name>."
+ search_name = u".%s" % name.make_absolute().ToASCII()
+
+ # we need to search zone with and without last dot, due compatibility
+ # with IPA < 4.0
+ search_names = [search_name, search_name[:-1]]
+
+ # Create filters
+ objectclass_filter = ldap.make_filter({'objectclass':'idnsforwardzone'})
+ zonenames_filter = ldap.make_filter({'idnsname': search_names}, exact=False,
+ trailing_wildcard=False)
+ if not child_zones_only:
+ # find also zone with exact name
+ exact_name = name.make_absolute().ToASCII()
+ # we need to search zone with and without last dot, due compatibility
+ # with IPA < 4.0
+ exact_names = [exact_name, exact_name[-1]]
+ exact_name_filter = ldap.make_filter({'idnsname': exact_names})
+ zonenames_filter = ldap.combine_filters([zonenames_filter,
+ exact_name_filter])
+
+ zoneactive_filter = ldap.make_filter({'idnsZoneActive': 'true'})
+ complete_filter = ldap.combine_filters(
+ [objectclass_filter, zonenames_filter, zoneactive_filter],
+ rules=ldap.MATCH_ALL
+ )
+
+ try:
+ entries, truncated = ldap.find_entries(
+ filter=complete_filter,
+ attrs_list=['idnsname'],
+ base_dn=DN(api.env.container_dns, api.env.basedn),
+ scope=ldap.SCOPE_ONELEVEL
+ )
+ except errors.NotFound:
+ return [], False
+
+ result = [entry.single_value['idnsname'].make_absolute()
+ for entry in entries]
+
+ return result, truncated
+
+
+def _get_zone_which_makes_fw_zone_ineffective(api, fwzonename):
+ """
+ Check if forward zone is effective.
+
+ If parent zone exists as authoritative zone, the forward zone will not
+ forward queries by default. It is necessary to delegate authority
+ to forward zone with a NS record.
+
+ Example:
+
+ Forward zone: sub.example.com
+ Zone: example.com
+
+ Forwarding will not work, because the server thinks it is authoritative
+ for zone and will return NXDOMAIN
+
+ Adding record: sub.example.com NS ns.sub.example.com.
+ will delegate authority, and IPA DNS server will forward DNS queries.
+
+ :param fwzonename: forwardzone
+ :return: (zone, truncated)
+ zone: None if effective, name of authoritative zone otherwise
+ """
+ assert isinstance(fwzonename, DNSName)
+
+ auth_zone, truncated_zone = _get_auth_zone_ldap(api, fwzonename)
+ if not auth_zone:
+ return None, truncated_zone
+
+ delegation_record_name, truncated_ns =\
+ _get_longest_match_ns_delegation_ldap(api, auth_zone, fwzonename)
+
+ truncated = truncated_ns or truncated_zone
+
+ if delegation_record_name:
+ return None, truncated
+
+ return auth_zone, truncated
+
+
+def _add_warning_fw_zone_is_not_effective(api, result, fwzone, version):
+ """
+ Adds warning message to result, if required
+ """
+ authoritative_zone, truncated = \
+ _get_zone_which_makes_fw_zone_ineffective(api, fwzone)
+ if authoritative_zone:
+ # forward zone is not effective and forwarding will not work
+ messages.add_message(
+ version, result,
+ messages.ForwardzoneIsNotEffectiveWarning(
+ fwzone=fwzone, authzone=authoritative_zone,
+ ns_rec=fwzone.relativize(authoritative_zone)
+ )
+ )
+
+
+def _add_warning_fw_policy_conflict_aez(result, fwzone, **options):
+ """Warn if forwarding policy conflicts with an automatic empty zone."""
+ fwd_policy = result['result'].get(u'idnsforwardpolicy',
+ dnsforwardzone.default_forward_policy)
+ if (
+ fwd_policy != [u'only']
+ and related_to_auto_empty_zone(DNSName(fwzone))
+ ):
+ messages.add_message(
+ options['version'], result,
+ messages.DNSForwardPolicyConflictWithEmptyZone()
+ )
+
+
+class DNSZoneBase(LDAPObject):
+ """
+ Base class for DNS Zone
+ """
+ container_dn = api.env.container_dns
+ object_class = ['top']
+ possible_objectclasses = ['ipadnszone']
+ default_attributes = [
+ 'idnsname', 'idnszoneactive', 'idnsforwarders', 'idnsforwardpolicy'
+ ]
+
+ takes_params = (
+ DNSNameParam('idnsname',
+ _no_wildcard_validator, # RFC 4592 section 4
+ only_absolute=True,
+ cli_name='name',
+ label=_('Zone name'),
+ doc=_('Zone name (FQDN)'),
+ default_from=lambda name_from_ip: _reverse_zone_name(name_from_ip),
+ normalizer=_normalize_zone,
+ primary_key=True,
+ ),
+ Str('name_from_ip?', _validate_ipnet,
+ label=_('Reverse zone IP network'),
+ doc=_('IP network to create reverse zone name from'),
+ flags=('virtual_attribute',),
+ ),
+ Bool('idnszoneactive?',
+ cli_name='zone_active',
+ label=_('Active zone'),
+ doc=_('Is zone active?'),
+ flags=['no_create', 'no_update'],
+ attribute=True,
+ ),
+ Str('idnsforwarders*',
+ _validate_bind_forwarder,
+ cli_name='forwarder',
+ label=_('Zone forwarders'),
+ doc=_('Per-zone forwarders. A custom port can be specified '
+ 'for each forwarder using a standard format "IP_ADDRESS port PORT"'),
+ ),
+ StrEnum('idnsforwardpolicy?',
+ cli_name='forward_policy',
+ label=_('Forward policy'),
+ doc=_('Per-zone conditional forwarding policy. Set to "none" to '
+ 'disable forwarding to global forwarder for this zone. In '
+ 'that case, conditional zone forwarders are disregarded.'),
+ values=(u'only', u'first', u'none'),
+ ),
+
+ )
+
+ def get_dn(self, *keys, **options):
+ if not dns_container_exists(self.api.Backend.ldap2):
+ raise errors.NotFound(reason=_('DNS is not configured'))
+
+ zone = keys[-1]
+ assert isinstance(zone, DNSName)
+ assert zone.is_absolute()
+ zone_a = zone.ToASCII()
+
+ # special case when zone is the root zone ('.')
+ if zone == DNSName.root:
+ return super(DNSZoneBase, self).get_dn(zone_a, **options)
+
+ # try first relative name, a new zone has to be added as absolute
+ # otherwise ObjectViolation is raised
+ zone_a = zone_a[:-1]
+ dn = super(DNSZoneBase, self).get_dn(zone_a, **options)
+ try:
+ self.backend.get_entry(dn, [''])
+ except errors.NotFound:
+ zone_a = u"%s." % zone_a
+ dn = super(DNSZoneBase, self).get_dn(zone_a, **options)
+
+ return dn
+
+ def permission_name(self, zone):
+ assert isinstance(zone, DNSName)
+ return u"Manage DNS zone %s" % zone.ToASCII()
+
+ def get_name_in_zone(self, zone, hostname):
+ """
+ Get name of a record that is to be added to a new zone. I.e. when
+ we want to add record "ipa.lab.example.com" in a zone "example.com",
+ this function should return "ipa.lab". Returns None when record cannot
+ be added to a zone. Returns '@' when the hostname is the zone record.
+ """
+ assert isinstance(zone, DNSName)
+ assert zone.is_absolute()
+ assert isinstance(hostname, DNSName)
+
+ if not hostname.is_absolute():
+ return hostname
+
+ if hostname.is_subdomain(zone):
+ return hostname.relativize(zone)
+
+ return None
+
+ def _remove_permission(self, zone):
+ permission_name = self.permission_name(zone)
+ try:
+ self.api.Command['permission_del'](permission_name, force=True)
+ except errors.NotFound as e:
+ if zone == DNSName.root: # special case root zone
+ raise
+ # compatibility, older IPA versions which allows to create zone
+ # without absolute zone name
+ permission_name_rel = self.permission_name(
+ zone.relativize(DNSName.root)
+ )
+ try:
+ self.api.Command['permission_del'](permission_name_rel,
+ force=True)
+ except errors.NotFound:
+ raise e # re-raise original exception
+
+ def _make_zonename_absolute(self, entry_attrs, **options):
+ """
+ Zone names can be relative in IPA < 4.0, make sure we always return
+ absolute zone name from ldap
+ """
+ if options.get('raw'):
+ return
+
+ if "idnsname" in entry_attrs:
+ entry_attrs.single_value['idnsname'] = (
+ entry_attrs.single_value['idnsname'].make_absolute())
+
+
+class DNSZoneBase_add(LDAPCreate):
+
+ takes_options = LDAPCreate.takes_options + (
+ Flag('skip_overlap_check',
+ doc=_('Force DNS zone creation even if it will overlap with '
+ 'an existing zone.')
+ ),
+ )
+
+ has_output_params = LDAPCreate.has_output_params + dnszone_output_params
+
+ def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options):
+ assert isinstance(dn, DN)
+
+ try:
+ entry = ldap.get_entry(dn)
+ except errors.NotFound:
+ pass
+ else:
+ if _check_entry_objectclass(entry, self.obj.object_class):
+ self.obj.handle_duplicate_entry(*keys)
+ else:
+ raise errors.DuplicateEntry(
+ message=_(u'Only one zone type is allowed per zone name')
+ )
+
+ entry_attrs['idnszoneactive'] = 'TRUE'
+
+ if not options['skip_overlap_check']:
+ try:
+ check_zone_overlap(keys[-1])
+ except ValueError as e:
+ raise errors.InvocationError(e.message)
+
+ return dn
+
+
+class DNSZoneBase_del(LDAPDelete):
+
+ def pre_callback(self, ldap, dn, *nkeys, **options):
+ assert isinstance(dn, DN)
+ if not _check_DN_objectclass(ldap, dn, self.obj.object_class):
+ self.obj.handle_not_found(*nkeys)
+ return dn
+
+ def post_callback(self, ldap, dn, *keys, **options):
+ try:
+ self.obj._remove_permission(keys[-1])
+ except errors.NotFound:
+ pass
+
+ return True
+
+
+class DNSZoneBase_mod(LDAPUpdate):
+ has_output_params = LDAPUpdate.has_output_params + dnszone_output_params
+
+ def post_callback(self, ldap, dn, entry_attrs, *keys, **options):
+ assert isinstance(dn, DN)
+ self.obj._make_zonename_absolute(entry_attrs, **options)
+ return dn
+
+
+class DNSZoneBase_find(LDAPSearch):
+ __doc__ = _('Search for DNS zones (SOA records).')
+
+ has_output_params = LDAPSearch.has_output_params + dnszone_output_params
+
+ def args_options_2_params(self, *args, **options):
+ # FIXME: Check that name_from_ip is valid. This is necessary because
+ # custom validation rules, including _validate_ipnet, are not
+ # used when doing a search. Once we have a parameter type for
+ # IP network objects, this will no longer be necessary, as the
+ # parameter type will handle the validation itself (see
+ # <https://fedorahosted.org/freeipa/ticket/2266>).
+ if 'name_from_ip' in options:
+ self.obj.params['name_from_ip'](unicode(options['name_from_ip']))
+ return super(DNSZoneBase_find, self).args_options_2_params(*args, **options)
+
+ def args_options_2_entry(self, *args, **options):
+ if 'name_from_ip' in options:
+ if 'idnsname' not in options:
+ options['idnsname'] = self.obj.params['idnsname'].get_default(**options)
+ del options['name_from_ip']
+ search_kw = super(DNSZoneBase_find, self).args_options_2_entry(*args,
+ **options)
+ name = search_kw.get('idnsname')
+ if name:
+ search_kw['idnsname'] = [name, name.relativize(DNSName.root)]
+ return search_kw
+
+ def pre_callback(self, ldap, filter, attrs_list, base_dn, scope, *args, **options):
+ assert isinstance(base_dn, DN)
+ # Check if DNS container exists must be here for find methods
+ if not dns_container_exists(self.api.Backend.ldap2):
+ raise errors.NotFound(reason=_('DNS is not configured'))
+ filter = _create_idn_filter(self, ldap, *args, **options)
+ return (filter, base_dn, scope)
+
+ def post_callback(self, ldap, entries, truncated, *args, **options):
+ for entry_attrs in entries:
+ self.obj._make_zonename_absolute(entry_attrs, **options)
+ return truncated
+
+
+class DNSZoneBase_show(LDAPRetrieve):
+ has_output_params = LDAPRetrieve.has_output_params + dnszone_output_params
+
+ def pre_callback(self, ldap, dn, attrs_list, *keys, **options):
+ assert isinstance(dn, DN)
+ if not _check_DN_objectclass(ldap, dn, self.obj.object_class):
+ self.obj.handle_not_found(*keys)
+ return dn
+
+ def post_callback(self, ldap, dn, entry_attrs, *keys, **options):
+ assert isinstance(dn, DN)
+ self.obj._make_zonename_absolute(entry_attrs, **options)
+ return dn
+
+
+class DNSZoneBase_disable(LDAPQuery):
+ has_output = output.standard_value
+
+ def execute(self, *keys, **options):
+ ldap = self.obj.backend
+
+ dn = self.obj.get_dn(*keys, **options)
+ try:
+ entry = ldap.get_entry(dn, ['idnszoneactive', 'objectclass'])
+ except errors.NotFound:
+ self.obj.handle_not_found(*keys)
+
+ if not _check_entry_objectclass(entry, self.obj.object_class):
+ self.obj.handle_not_found(*keys)
+
+ entry['idnszoneactive'] = ['FALSE']
+
+ try:
+ ldap.update_entry(entry)
+ except errors.EmptyModlist:
+ pass
+
+ return dict(result=True, value=pkey_to_value(keys[-1], options))
+
+
+class DNSZoneBase_enable(LDAPQuery):
+ has_output = output.standard_value
+
+ def execute(self, *keys, **options):
+ ldap = self.obj.backend
+
+ dn = self.obj.get_dn(*keys, **options)
+ try:
+ entry = ldap.get_entry(dn, ['idnszoneactive', 'objectclass'])
+ except errors.NotFound:
+ self.obj.handle_not_found(*keys)
+
+ if not _check_entry_objectclass(entry, self.obj.object_class):
+ self.obj.handle_not_found(*keys)
+
+ entry['idnszoneactive'] = ['TRUE']
+
+ try:
+ ldap.update_entry(entry)
+ except errors.EmptyModlist:
+ pass
+
+ return dict(result=True, value=pkey_to_value(keys[-1], options))
+
+
+class DNSZoneBase_add_permission(LDAPQuery):
+ has_output = _output_permissions
+ msg_summary = _('Added system permission "%(value)s"')
+
+ def execute(self, *keys, **options):
+ ldap = self.obj.backend
+ dn = self.obj.get_dn(*keys, **options)
+
+ try:
+ entry_attrs = ldap.get_entry(dn, ['objectclass'])
+ except errors.NotFound:
+ self.obj.handle_not_found(*keys)
+ else:
+ if not _check_entry_objectclass(entry_attrs, self.obj.object_class):
+ self.obj.handle_not_found(*keys)
+
+ permission_name = self.obj.permission_name(keys[-1])
+
+ # compatibility with older IPA versions which allows relative zonenames
+ if keys[-1] != DNSName.root: # special case root zone
+ permission_name_rel = self.obj.permission_name(
+ keys[-1].relativize(DNSName.root)
+ )
+ try:
+ self.api.Object['permission'].get_dn_if_exists(
+ permission_name_rel)
+ except errors.NotFound:
+ pass
+ else:
+ # permission exists without absolute domain name
+ raise errors.DuplicateEntry(
+ message=_('permission "%(value)s" already exists') % {
+ 'value': permission_name
+ }
+ )
+
+ permission = self.api.Command['permission_add_noaci'](permission_name,
+ ipapermissiontype=u'SYSTEM'
+ )['result']
+
+ dnszone_ocs = entry_attrs.get('objectclass')
+ if dnszone_ocs:
+ for oc in dnszone_ocs:
+ if oc.lower() == 'ipadnszone':
+ break
+ else:
+ dnszone_ocs.append('ipadnszone')
+
+ entry_attrs['managedby'] = [permission['dn']]
+ ldap.update_entry(entry_attrs)
+
+ return dict(
+ result=True,
+ value=pkey_to_value(permission_name, options),
+ )
+
+
+class DNSZoneBase_remove_permission(LDAPQuery):
+ has_output = _output_permissions
+ msg_summary = _('Removed system permission "%(value)s"')
+
+ def execute(self, *keys, **options):
+ ldap = self.obj.backend
+ dn = self.obj.get_dn(*keys, **options)
+ try:
+ entry = ldap.get_entry(dn, ['managedby', 'objectclass'])
+ except errors.NotFound:
+ self.obj.handle_not_found(*keys)
+ else:
+ if not _check_entry_objectclass(entry, self.obj.object_class):
+ self.obj.handle_not_found(*keys)
+
+ entry['managedby'] = None
+
+ try:
+ ldap.update_entry(entry)
+ except errors.EmptyModlist:
+ # managedBy attribute is clean, lets make sure there is also no
+ # dangling DNS zone permission
+ pass
+
+ permission_name = self.obj.permission_name(keys[-1])
+ self.obj._remove_permission(keys[-1])
+
+ return dict(
+ result=True,
+ value=pkey_to_value(permission_name, options),
+ )
+
+
+@register()
+class dnszone(DNSZoneBase):
+ """
+ DNS Zone, container for resource records.
+ """
+ object_name = _('DNS zone')
+ object_name_plural = _('DNS zones')
+ object_class = DNSZoneBase.object_class + ['idnsrecord', 'idnszone']
+ default_attributes = DNSZoneBase.default_attributes + [
+ 'idnssoamname', 'idnssoarname', 'idnssoaserial', 'idnssoarefresh',
+ 'idnssoaretry', 'idnssoaexpire', 'idnssoaminimum', 'idnsallowquery',
+ 'idnsallowtransfer', 'idnssecinlinesigning',
+ ] + _record_attributes
+ label = _('DNS Zones')
+ label_singular = _('DNS Zone')
+
+ takes_params = DNSZoneBase.takes_params + (
+ DNSNameParam('idnssoamname?',
+ cli_name='name_server',
+ label=_('Authoritative nameserver'),
+ doc=_('Authoritative nameserver domain name'),
+ default=None, # value will be added in precallback from ldap
+ ),
+ DNSNameParam('idnssoarname',
+ _rname_validator,
+ cli_name='admin_email',
+ label=_('Administrator e-mail address'),
+ doc=_('Administrator e-mail address'),
+ default=DNSName(u'hostmaster'),
+ normalizer=normalize_zonemgr,
+ autofill=True,
+ ),
+ Int('idnssoaserial',
+ cli_name='serial',
+ label=_('SOA serial'),
+ doc=_('SOA record serial number'),
+ minvalue=1,
+ maxvalue=4294967295,
+ default_from=_create_zone_serial,
+ autofill=True,
+ ),
+ Int('idnssoarefresh',
+ cli_name='refresh',
+ label=_('SOA refresh'),
+ doc=_('SOA record refresh time'),
+ minvalue=0,
+ maxvalue=2147483647,
+ default=3600,
+ autofill=True,
+ ),
+ Int('idnssoaretry',
+ cli_name='retry',
+ label=_('SOA retry'),
+ doc=_('SOA record retry time'),
+ minvalue=0,
+ maxvalue=2147483647,
+ default=900,
+ autofill=True,
+ ),
+ Int('idnssoaexpire',
+ cli_name='expire',
+ label=_('SOA expire'),
+ doc=_('SOA record expire time'),
+ default=1209600,
+ minvalue=0,
+ maxvalue=2147483647,
+ autofill=True,
+ ),
+ Int('idnssoaminimum',
+ cli_name='minimum',
+ label=_('SOA minimum'),
+ doc=_('How long should negative responses be cached'),
+ default=3600,
+ minvalue=0,
+ maxvalue=2147483647,
+ autofill=True,
+ ),
+ Int('dnsttl?',
+ cli_name='ttl',
+ label=_('Time to live'),
+ doc=_('Time to live for records at zone apex'),
+ minvalue=0,
+ maxvalue=2147483647, # see RFC 2181
+ ),
+ StrEnum('dnsclass?',
+ # Deprecated
+ cli_name='class',
+ flags=['no_option'],
+ values=_record_classes,
+ ),
+ Str('idnsupdatepolicy?',
+ cli_name='update_policy',
+ label=_('BIND update policy'),
+ doc=_('BIND update policy'),
+ default_from=lambda idnsname: default_zone_update_policy(idnsname),
+ autofill=True
+ ),
+ Bool('idnsallowdynupdate?',
+ cli_name='dynamic_update',
+ label=_('Dynamic update'),
+ doc=_('Allow dynamic updates.'),
+ attribute=True,
+ default=False,
+ autofill=True
+ ),
+ Str('idnsallowquery?',
+ _validate_bind_aci,
+ normalizer=_normalize_bind_aci,
+ cli_name='allow_query',
+ label=_('Allow query'),
+ doc=_('Semicolon separated list of IP addresses or networks which are allowed to issue queries'),
+ default=u'any;', # anyone can issue queries by default
+ autofill=True,
+ ),
+ Str('idnsallowtransfer?',
+ _validate_bind_aci,
+ normalizer=_normalize_bind_aci,
+ cli_name='allow_transfer',
+ label=_('Allow transfer'),
+ doc=_('Semicolon separated list of IP addresses or networks which are allowed to transfer the zone'),
+ default=u'none;', # no one can issue queries by default
+ autofill=True,
+ ),
+ Bool('idnsallowsyncptr?',
+ cli_name='allow_sync_ptr',
+ label=_('Allow PTR sync'),
+ doc=_('Allow synchronization of forward (A, AAAA) and reverse (PTR) records in the zone'),
+ ),
+ Bool('idnssecinlinesigning?',
+ cli_name='dnssec',
+ default=False,
+ label=_('Allow in-line DNSSEC signing'),
+ doc=_('Allow inline DNSSEC signing of records in the zone'),
+ ),
+ Str('nsec3paramrecord?',
+ _validate_nsec3param_record,
+ cli_name='nsec3param_rec',
+ label=_('NSEC3PARAM record'),
+ doc=_('NSEC3PARAM record for zone in format: hash_algorithm flags iterations salt'),
+ pattern=r'^\d+ \d+ \d+ (([0-9a-fA-F]{2})+|-)$',
+ pattern_errmsg=(u'expected format: <0-255> <0-255> <0-65535> '
+ 'even-length_hexadecimal_digits_or_hyphen'),
+ ),
+ )
+ # Permissions will be apllied for forwardzones too
+ # Store permissions into api.env.basedn, dns container could not exists
+ managed_permissions = {
+ 'System: Add DNS Entries': {
+ 'non_object': True,
+ 'ipapermright': {'add'},
+ 'ipapermlocation': api.env.basedn,
+ 'ipapermtarget': DN('idnsname=*', 'cn=dns', api.env.basedn),
+ 'replaces': [
+ '(target = "ldap:///idnsname=*,cn=dns,$SUFFIX")(version 3.0;acl "permission:add dns entries";allow (add) groupdn = "ldap:///cn=add dns entries,cn=permissions,cn=pbac,$SUFFIX";)',
+ ],
+ 'default_privileges': {'DNS Administrators', 'DNS Servers'},
+ },
+ 'System: Read DNS Entries': {
+ 'non_object': True,
+ 'ipapermright': {'read', 'search', 'compare'},
+ 'ipapermlocation': api.env.basedn,
+ 'ipapermtarget': DN('idnsname=*', 'cn=dns', api.env.basedn),
+ 'ipapermdefaultattr': {
+ 'objectclass',
+ 'a6record', 'aaaarecord', 'afsdbrecord', 'aplrecord', 'arecord',
+ 'certrecord', 'cn', 'cnamerecord', 'dhcidrecord', 'dlvrecord',
+ 'dnamerecord', 'dnsclass', 'dnsttl', 'dsrecord',
+ 'hinforecord', 'hiprecord', 'idnsallowdynupdate',
+ 'idnsallowquery', 'idnsallowsyncptr', 'idnsallowtransfer',
+ 'idnsforwarders', 'idnsforwardpolicy', 'idnsname',
+ 'idnssecinlinesigning', 'idnssoaexpire', 'idnssoaminimum',
+ 'idnssoamname', 'idnssoarefresh', 'idnssoaretry',
+ 'idnssoarname', 'idnssoaserial', 'idnsupdatepolicy',
+ 'idnszoneactive', 'ipseckeyrecord','keyrecord', 'kxrecord',
+ 'locrecord', 'managedby', 'mdrecord', 'minforecord',
+ 'mxrecord', 'naptrrecord', 'nsecrecord', 'nsec3paramrecord',
+ 'nsrecord', 'nxtrecord', 'ptrrecord', 'rprecord', 'rrsigrecord',
+ 'sigrecord', 'spfrecord', 'srvrecord', 'sshfprecord',
+ 'tlsarecord', 'txtrecord', 'unknownrecord',
+ },
+ 'replaces_system': ['Read DNS Entries'],
+ 'default_privileges': {'DNS Administrators', 'DNS Servers'},
+ },
+ 'System: Remove DNS Entries': {
+ 'non_object': True,
+ 'ipapermright': {'delete'},
+ 'ipapermlocation': api.env.basedn,
+ 'ipapermtarget': DN('idnsname=*', 'cn=dns', api.env.basedn),
+ 'replaces': [
+ '(target = "ldap:///idnsname=*,cn=dns,$SUFFIX")(version 3.0;acl "permission:remove dns entries";allow (delete) groupdn = "ldap:///cn=remove dns entries,cn=permissions,cn=pbac,$SUFFIX";)',
+ ],
+ 'default_privileges': {'DNS Administrators', 'DNS Servers'},
+ },
+ 'System: Update DNS Entries': {
+ 'non_object': True,
+ 'ipapermright': {'write'},
+ 'ipapermlocation': api.env.basedn,
+ 'ipapermtarget': DN('idnsname=*', 'cn=dns', api.env.basedn),
+ 'ipapermdefaultattr': {
+ 'a6record', 'aaaarecord', 'afsdbrecord', 'aplrecord', 'arecord',
+ 'certrecord', 'cn', 'cnamerecord', 'dhcidrecord', 'dlvrecord',
+ 'dnamerecord', 'dnsclass', 'dnsttl', 'dsrecord',
+ 'hinforecord', 'hiprecord', 'idnsallowdynupdate',
+ 'idnsallowquery', 'idnsallowsyncptr', 'idnsallowtransfer',
+ 'idnsforwarders', 'idnsforwardpolicy', 'idnsname',
+ 'idnssecinlinesigning', 'idnssoaexpire', 'idnssoaminimum',
+ 'idnssoamname', 'idnssoarefresh', 'idnssoaretry',
+ 'idnssoarname', 'idnssoaserial', 'idnsupdatepolicy',
+ 'idnszoneactive', 'ipseckeyrecord','keyrecord', 'kxrecord',
+ 'locrecord', 'managedby', 'mdrecord', 'minforecord',
+ 'mxrecord', 'naptrrecord', 'nsecrecord', 'nsec3paramrecord',
+ 'nsrecord', 'nxtrecord', 'ptrrecord', 'rprecord', 'rrsigrecord',
+ 'sigrecord', 'spfrecord', 'srvrecord', 'sshfprecord',
+ 'tlsarecord', 'txtrecord', 'unknownrecord',
+ },
+ 'replaces': [
+ '(targetattr = "idnsname || cn || idnsallowdynupdate || dnsttl || dnsclass || arecord || aaaarecord || a6record || nsrecord || cnamerecord || ptrrecord || srvrecord || txtrecord || mxrecord || mdrecord || hinforecord || minforecord || afsdbrecord || sigrecord || keyrecord || locrecord || nxtrecord || naptrrecord || kxrecord || certrecord || dnamerecord || dsrecord || sshfprecord || rrsigrecord || nsecrecord || idnsname || idnszoneactive || idnssoamname || idnssoarname || idnssoaserial || idnssoarefresh || idnssoaretry || idnssoaexpire || idnssoaminimum || idnsupdatepolicy")(target = "ldap:///idnsname=*,cn=dns,$SUFFIX")(version 3.0;acl "permission:update dns entries";allow (write) groupdn = "ldap:///cn=update dns entries,cn=permissions,cn=pbac,$SUFFIX";)',
+ '(targetattr = "idnsname || cn || idnsallowdynupdate || dnsttl || dnsclass || arecord || aaaarecord || a6record || nsrecord || cnamerecord || ptrrecord || srvrecord || txtrecord || mxrecord || mdrecord || hinforecord || minforecord || afsdbrecord || sigrecord || keyrecord || locrecord || nxtrecord || naptrrecord || kxrecord || certrecord || dnamerecord || dsrecord || sshfprecord || rrsigrecord || nsecrecord || idnsname || idnszoneactive || idnssoamname || idnssoarname || idnssoaserial || idnssoarefresh || idnssoaretry || idnssoaexpire || idnssoaminimum || idnsupdatepolicy || idnsallowquery || idnsallowtransfer || idnsallowsyncptr || idnsforwardpolicy || idnsforwarders")(target = "ldap:///idnsname=*,cn=dns,$SUFFIX")(version 3.0;acl "permission:update dns entries";allow (write) groupdn = "ldap:///cn=update dns entries,cn=permissions,cn=pbac,$SUFFIX";)',
+ '(targetattr = "idnsname || cn || idnsallowdynupdate || dnsttl || dnsclass || arecord || aaaarecord || a6record || nsrecord || cnamerecord || ptrrecord || srvrecord || txtrecord || mxrecord || mdrecord || hinforecord || minforecord || afsdbrecord || sigrecord || keyrecord || locrecord || nxtrecord || naptrrecord || kxrecord || certrecord || dnamerecord || dsrecord || sshfprecord || rrsigrecord || nsecrecord || idnsname || idnszoneactive || idnssoamname || idnssoarname || idnssoaserial || idnssoarefresh || idnssoaretry || idnssoaexpire || idnssoaminimum || idnsupdatepolicy || idnsallowquery || idnsallowtransfer || idnsallowsyncptr || idnsforwardpolicy || idnsforwarders || managedby")(target = "ldap:///idnsname=*,cn=dns,$SUFFIX")(version 3.0;acl "permission:update dns entries";allow (write) groupdn = "ldap:///cn=update dns entries,cn=permissions,cn=pbac,$SUFFIX";)',
+ ],
+ 'default_privileges': {'DNS Administrators', 'DNS Servers'},
+ },
+ 'System: Read DNSSEC metadata': {
+ 'non_object': True,
+ 'ipapermright': {'read', 'search', 'compare'},
+ 'ipapermlocation': api.env.basedn,
+ 'ipapermtarget': DN('cn=dns', api.env.basedn),
+ 'ipapermtargetfilter': ['(objectclass=idnsSecKey)'],
+ 'ipapermdefaultattr': {
+ 'idnsSecAlgorithm', 'idnsSecKeyCreated', 'idnsSecKeyPublish',
+ 'idnsSecKeyActivate', 'idnsSecKeyInactive', 'idnsSecKeyDelete',
+ 'idnsSecKeyZone', 'idnsSecKeyRevoke', 'idnsSecKeySep',
+ 'idnsSecKeyRef', 'cn', 'objectclass',
+ },
+ 'default_privileges': {'DNS Administrators'},
+ },
+ 'System: Manage DNSSEC metadata': {
+ 'non_object': True,
+ 'ipapermright': {'all'},
+ 'ipapermlocation': api.env.basedn,
+ 'ipapermtarget': DN('cn=dns', api.env.basedn),
+ 'ipapermtargetfilter': ['(objectclass=idnsSecKey)'],
+ 'ipapermdefaultattr': {
+ 'idnsSecAlgorithm', 'idnsSecKeyCreated', 'idnsSecKeyPublish',
+ 'idnsSecKeyActivate', 'idnsSecKeyInactive', 'idnsSecKeyDelete',
+ 'idnsSecKeyZone', 'idnsSecKeyRevoke', 'idnsSecKeySep',
+ 'idnsSecKeyRef', 'cn', 'objectclass',
+ },
+ 'default_privileges': {'DNS Servers'},
+ },
+ 'System: Manage DNSSEC keys': {
+ 'non_object': True,
+ 'ipapermright': {'all'},
+ 'ipapermlocation': api.env.basedn,
+ 'ipapermtarget': DN('cn=keys', 'cn=sec', 'cn=dns', api.env.basedn),
+ 'ipapermdefaultattr': {
+ 'ipaPublicKey', 'ipaPrivateKey', 'ipaSecretKey',
+ 'ipaWrappingMech','ipaWrappingKey',
+ 'ipaSecretKeyRef', 'ipk11Private', 'ipk11Modifiable', 'ipk11Label',
+ 'ipk11Copyable', 'ipk11Destroyable', 'ipk11Trusted',
+ 'ipk11CheckValue', 'ipk11StartDate', 'ipk11EndDate',
+ 'ipk11UniqueId', 'ipk11PublicKeyInfo', 'ipk11Distrusted',
+ 'ipk11Subject', 'ipk11Id', 'ipk11Local', 'ipk11KeyType',
+ 'ipk11Derive', 'ipk11KeyGenMechanism', 'ipk11AllowedMechanisms',
+ 'ipk11Encrypt', 'ipk11Verify', 'ipk11VerifyRecover', 'ipk11Wrap',
+ 'ipk11WrapTemplate', 'ipk11Sensitive', 'ipk11Decrypt',
+ 'ipk11Sign', 'ipk11SignRecover', 'ipk11Unwrap',
+ 'ipk11Extractable', 'ipk11AlwaysSensitive',
+ 'ipk11NeverExtractable', 'ipk11WrapWithTrusted',
+ 'ipk11UnwrapTemplate', 'ipk11AlwaysAuthenticate',
+ 'objectclass',
+ },
+ 'default_privileges': {'DNS Servers'},
+ },
+ }
+
+ def _rr_zone_postprocess(self, record, **options):
+ #Decode IDN ACE form to Unicode, raw records are passed directly from LDAP
+ if options.get('raw', False):
+ return
+ _records_idn_postprocess(record, **options)
+
+ def _warning_forwarding(self, result, **options):
+ if ('idnsforwarders' in result['result']):
+ messages.add_message(options.get('version', VERSION_WITHOUT_CAPABILITIES),
+ result, messages.ForwardersWarning())
+
+ def _warning_name_server_option(self, result, context, **options):
+ if getattr(context, 'show_warning_nameserver_option', False):
+ messages.add_message(
+ options['version'],
+ result, messages.OptionSemanticChangedWarning(
+ label=_(u"setting Authoritative nameserver"),
+ current_behavior=_(u"It is used only for setting the "
+ u"SOA MNAME attribute."),
+ hint=_(u"NS record(s) can be edited in zone apex - '@'. ")
+ )
+ )
+
+ def _warning_fw_zone_is_not_effective(self, result, *keys, **options):
+ """
+ Warning if any operation with zone causes, a child forward zone is
+ not effective
+ """
+ zone = keys[-1]
+ affected_fw_zones, truncated = _find_subtree_forward_zones_ldap(
+ self.api, zone, child_zones_only=True)
+ if not affected_fw_zones:
+ return
+
+ for fwzone in affected_fw_zones:
+ _add_warning_fw_zone_is_not_effective(self.api, result, fwzone,
+ options['version'])
+
+ def _warning_dnssec_master_is_not_installed(self, result, **options):
+ dnssec_enabled = result['result'].get("idnssecinlinesigning", False)
+ if dnssec_enabled and not dnssec_installed(self.api.Backend.ldap2):
+ messages.add_message(
+ options['version'],
+ result,
+ messages.DNSSECMasterNotInstalled()
+ )
+
+
+@register()
+class dnszone_add(DNSZoneBase_add):
+ __doc__ = _('Create new DNS zone (SOA record).')
+
+ takes_options = DNSZoneBase_add.takes_options + (
+ Flag('force',
+ doc=_('Force DNS zone creation even if nameserver is not '
+ 'resolvable. (Deprecated)'),
+ ),
+
+ Flag('skip_nameserver_check',
+ doc=_('Force DNS zone creation even if nameserver is not '
+ 'resolvable.'),
+ ),
+
+ # Deprecated
+ # ip-address option is not used anymore, we have to keep it
+ # due to compability with clients older than 4.1
+ Str('ip_address?',
+ flags=['no_option', ]
+ ),
+ )
+
+ def _warning_deprecated_option(self, result, **options):
+ if 'ip_address' in options:
+ messages.add_message(
+ options['version'],
+ result,
+ messages.OptionDeprecatedWarning(
+ option='ip-address',
+ additional_info=u"Value will be ignored.")
+ )
+
+ def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options):
+ assert isinstance(dn, DN)
+
+ if options.get('force'):
+ options['skip_nameserver_check'] = True
+
+ dn = super(dnszone_add, self).pre_callback(
+ ldap, dn, entry_attrs, attrs_list, *keys, **options)
+
+ nameservers = [normalize_zone(x) for x in
+ self.api.Object.dnsrecord.get_dns_masters()]
+ server = normalize_zone(api.env.host)
+ zone = keys[-1]
+
+ if entry_attrs.get('idnssoamname'):
+ if zone.is_reverse() and not entry_attrs['idnssoamname'].is_absolute():
+ raise errors.ValidationError(
+ name='name-server',
+ error=_("Nameserver for reverse zone cannot be a relative DNS name"))
+
+ # verify if user specified server is resolvable
+ if not options['skip_nameserver_check']:
+ check_ns_rec_resolvable(keys[0], entry_attrs['idnssoamname'],
+ self.log)
+ # show warning about --name-server option
+ context.show_warning_nameserver_option = True
+ else:
+ # user didn't specify SOA mname
+ if server in nameservers:
+ # current ipa server is authoritative nameserver in SOA record
+ entry_attrs['idnssoamname'] = [server]
+ else:
+ # a first DNS capable server is authoritative nameserver in SOA record
+ entry_attrs['idnssoamname'] = [nameservers[0]]
+
+ # all ipa DNS servers should be in NS zone record (as absolute domain name)
+ entry_attrs['nsrecord'] = nameservers
+
+ return dn
+
+ def execute(self, *keys, **options):
+ result = super(dnszone_add, self).execute(*keys, **options)
+ self._warning_deprecated_option(result, **options)
+ self.obj._warning_forwarding(result, **options)
+ self.obj._warning_name_server_option(result, context, **options)
+ self.obj._warning_fw_zone_is_not_effective(result, *keys, **options)
+ self.obj._warning_dnssec_master_is_not_installed(result, **options)
+ return result
+
+ def post_callback(self, ldap, dn, entry_attrs, *keys, **options):
+ assert isinstance(dn, DN)
+
+ # Add entry to realmdomains
+ # except for our own domain, forward zones, reverse zones and root zone
+ zone = keys[0]
+
+ if (zone != DNSName(api.env.domain).make_absolute() and
+ not options.get('idnsforwarders') and
+ not zone.is_reverse() and
+ zone != DNSName.root):
+ try:
+ self.api.Command['realmdomains_mod'](add_domain=unicode(zone),
+ force=True)
+ except (errors.EmptyModlist, errors.ValidationError):
+ pass
+
+ self.obj._rr_zone_postprocess(entry_attrs, **options)
+ return dn
+
+
+
+@register()
+class dnszone_del(DNSZoneBase_del):
+ __doc__ = _('Delete DNS zone (SOA record).')
+
+ msg_summary = _('Deleted DNS zone "%(value)s"')
+
+ def execute(self, *keys, **options):
+ result = super(dnszone_del, self).execute(*keys, **options)
+ nkeys = keys[-1] # we can delete more zones
+ for key in nkeys:
+ self.obj._warning_fw_zone_is_not_effective(result, key, **options)
+ return result
+
+ def post_callback(self, ldap, dn, *keys, **options):
+ super(dnszone_del, self).post_callback(ldap, dn, *keys, **options)
+
+ # Delete entry from realmdomains
+ # except for our own domain, reverse zone, and root zone
+ zone = keys[0].make_absolute()
+
+ if (zone != DNSName(api.env.domain).make_absolute() and
+ not zone.is_reverse() and zone != DNSName.root
+ ):
+ try:
+ self.api.Command['realmdomains_mod'](
+ del_domain=unicode(zone), force=True)
+ except (errors.AttrValueNotFound, errors.ValidationError):
+ pass
+
+ return True
+
+
+
+@register()
+class dnszone_mod(DNSZoneBase_mod):
+ __doc__ = _('Modify DNS zone (SOA record).')
+
+ takes_options = DNSZoneBase_mod.takes_options + (
+ Flag('force',
+ label=_('Force'),
+ doc=_('Force nameserver change even if nameserver not in DNS'),
+ ),
+ )
+
+ def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options):
+ if not _check_DN_objectclass(ldap, dn, self.obj.object_class):
+ self.obj.handle_not_found(*keys)
+ if 'idnssoamname' in entry_attrs:
+ nameserver = entry_attrs['idnssoamname']
+ if nameserver:
+ if not nameserver.is_empty() and not options['force']:
+ check_ns_rec_resolvable(keys[0], nameserver, self.log)
+ context.show_warning_nameserver_option = True
+ else:
+ # empty value, this option is required by ldap
+ raise errors.ValidationError(
+ name='name_server',
+ error=_(u"is required"))
+
+ return dn
+
+ def execute(self, *keys, **options):
+ result = super(dnszone_mod, self).execute(*keys, **options)
+ self.obj._warning_forwarding(result, **options)
+ self.obj._warning_name_server_option(result, context, **options)
+ self.obj._warning_dnssec_master_is_not_installed(result, **options)
+ return result
+
+ def post_callback(self, ldap, dn, entry_attrs, *keys, **options):
+ dn = super(dnszone_mod, self).post_callback(ldap, dn, entry_attrs,
+ *keys, **options)
+ self.obj._rr_zone_postprocess(entry_attrs, **options)
+ return dn
+
+
+@register()
+class dnszone_find(DNSZoneBase_find):
+ __doc__ = _('Search for DNS zones (SOA records).')
+
+ takes_options = DNSZoneBase_find.takes_options + (
+ Flag('forward_only',
+ label=_('Forward zones only'),
+ cli_name='forward_only',
+ doc=_('Search for forward zones only'),
+ ),
+ )
+
+ def pre_callback(self, ldap, filter, attrs_list, base_dn, scope, *args, **options):
+ assert isinstance(base_dn, DN)
+
+ filter, base, dn = super(dnszone_find, self).pre_callback(ldap, filter,
+ attrs_list, base_dn, scope, *args, **options)
+
+ if options.get('forward_only', False):
+ search_kw = {}
+ search_kw['idnsname'] = [revzone.ToASCII() for revzone in
+ REVERSE_DNS_ZONES.keys()]
+ rev_zone_filter = ldap.make_filter(search_kw,
+ rules=ldap.MATCH_NONE,
+ exact=False,
+ trailing_wildcard=False)
+ filter = ldap.combine_filters((rev_zone_filter, filter),
+ rules=ldap.MATCH_ALL)
+
+ return (filter, base_dn, scope)
+
+ def post_callback(self, ldap, entries, truncated, *args, **options):
+ truncated = super(dnszone_find, self).post_callback(ldap, entries,
+ truncated, *args,
+ **options)
+ for entry_attrs in entries:
+ self.obj._rr_zone_postprocess(entry_attrs, **options)
+ return truncated
+
+
+
+@register()
+class dnszone_show(DNSZoneBase_show):
+ __doc__ = _('Display information about a DNS zone (SOA record).')
+
+ def execute(self, *keys, **options):
+ result = super(dnszone_show, self).execute(*keys, **options)
+ self.obj._warning_forwarding(result, **options)
+ self.obj._warning_dnssec_master_is_not_installed(result, **options)
+ return result
+
+ def post_callback(self, ldap, dn, entry_attrs, *keys, **options):
+ dn = super(dnszone_show, self).post_callback(ldap, dn, entry_attrs,
+ *keys, **options)
+ self.obj._rr_zone_postprocess(entry_attrs, **options)
+ return dn
+
+
+
+@register()
+class dnszone_disable(DNSZoneBase_disable):
+ __doc__ = _('Disable DNS Zone.')
+ msg_summary = _('Disabled DNS zone "%(value)s"')
+
+ def execute(self, *keys, **options):
+ result = super(dnszone_disable, self).execute(*keys, **options)
+ self.obj._warning_fw_zone_is_not_effective(result, *keys, **options)
+ return result
+
+
+@register()
+class dnszone_enable(DNSZoneBase_enable):
+ __doc__ = _('Enable DNS Zone.')
+ msg_summary = _('Enabled DNS zone "%(value)s"')
+
+ def execute(self, *keys, **options):
+ result = super(dnszone_enable, self).execute(*keys, **options)
+ self.obj._warning_fw_zone_is_not_effective(result, *keys, **options)
+ return result
+
+
+@register()
+class dnszone_add_permission(DNSZoneBase_add_permission):
+ __doc__ = _('Add a permission for per-zone access delegation.')
+
+
+@register()
+class dnszone_remove_permission(DNSZoneBase_remove_permission):
+ __doc__ = _('Remove a permission for per-zone access delegation.')
+
+
+@register()
+class dnsrecord(LDAPObject):
+ """
+ DNS record.
+ """
+ parent_object = 'dnszone'
+ container_dn = api.env.container_dns
+ object_name = _('DNS resource record')
+ object_name_plural = _('DNS resource records')
+ object_class = ['top', 'idnsrecord']
+ permission_filter_objectclasses = ['idnsrecord']
+ default_attributes = ['idnsname'] + _record_attributes
+ rdn_is_primary_key = True
+
+ label = _('DNS Resource Records')
+ label_singular = _('DNS Resource Record')
+
+ takes_params = (
+ DNSNameParam('idnsname',
+ cli_name='name',
+ label=_('Record name'),
+ doc=_('Record name'),
+ primary_key=True,
+ ),
+ Int('dnsttl?',
+ cli_name='ttl',
+ label=_('Time to live'),
+ doc=_('Time to live'),
+ ),
+ StrEnum('dnsclass?',
+ # Deprecated
+ cli_name='class',
+ flags=['no_option'],
+ values=_record_classes,
+ ),
+ ) + _dns_record_options
+
+ structured_flag = Flag('structured',
+ label=_('Structured'),
+ doc=_('Parse all raw DNS records and return them in a structured way'),
+ )
+
+ def _dsrecord_pre_callback(self, ldap, dn, entry_attrs, *keys, **options):
+ assert isinstance(dn, DN)
+ dsrecords = entry_attrs.get('dsrecord')
+ if dsrecords and self.is_pkey_zone_record(*keys):
+ raise errors.ValidationError(
+ name='dsrecord',
+ error=unicode(_('DS record must not be in zone apex (RFC 4035 section 2.4)')))
+
+ def _nsrecord_pre_callback(self, ldap, dn, entry_attrs, *keys, **options):
+ assert isinstance(dn, DN)
+ nsrecords = entry_attrs.get('nsrecord')
+ if options.get('force', False) or nsrecords is None:
+ return
+ for nsrecord in nsrecords:
+ check_ns_rec_resolvable(keys[0], DNSName(nsrecord), self.log)
+
+ def _idnsname_pre_callback(self, ldap, dn, entry_attrs, *keys, **options):
+ assert isinstance(dn, DN)
+ if keys[-1].is_absolute():
+ if keys[-1].is_subdomain(keys[-2]):
+ entry_attrs['idnsname'] = [keys[-1].relativize(keys[-2])]
+ elif not self.is_pkey_zone_record(*keys):
+ raise errors.ValidationError(name='idnsname',
+ error=unicode(_('out-of-zone data: record name must '
+ 'be a subdomain of the zone or a '
+ 'relative name')))
+ # dissallowed wildcard (RFC 4592 section 4)
+ no_wildcard_rtypes = ['DNAME', 'DS', 'NS']
+ if (keys[-1].is_wild() and
+ any(entry_attrs.get(record_name_format % r.lower())
+ for r in no_wildcard_rtypes)
+ ):
+ raise errors.ValidationError(
+ name='idnsname',
+ error=(_('owner of %(types)s records '
+ 'should not be a wildcard domain name (RFC 4592 section 4)') %
+ {'types': ', '.join(no_wildcard_rtypes)}
+ )
+ )
+
+ def _ptrrecord_pre_callback(self, ldap, dn, entry_attrs, *keys, **options):
+ assert isinstance(dn, DN)
+ ptrrecords = entry_attrs.get('ptrrecord')
+ if ptrrecords is None:
+ return
+
+ zone = keys[-2]
+ if self.is_pkey_zone_record(*keys):
+ addr = _dns_zone_record
+ else:
+ addr = keys[-1]
+
+ zone_len = 0
+ for valid_zone in REVERSE_DNS_ZONES:
+ if zone.is_subdomain(valid_zone):
+ zone = zone.relativize(valid_zone)
+ zone_name = valid_zone
+ zone_len = REVERSE_DNS_ZONES[valid_zone]
+
+ if not zone_len:
+ allowed_zones = ', '.join([unicode(revzone) for revzone in
+ REVERSE_DNS_ZONES.keys()])
+ raise errors.ValidationError(name='ptrrecord',
+ error=unicode(_('Reverse zone for PTR record should be a sub-zone of one the following fully qualified domains: %s') % allowed_zones))
+
+ addr_len = len(addr.labels)
+
+ # Classless zones (0/25.0.0.10.in-addr.arpa.) -> skip check
+ # zone has to be checked without reverse domain suffix (in-addr.arpa.)
+ for sign in ('/', '-'):
+ for name in (zone, addr):
+ for label in name.labels:
+ if sign in label:
+ return
+
+ ip_addr_comp_count = addr_len + len(zone.labels)
+ if ip_addr_comp_count != zone_len:
+ raise errors.ValidationError(name='ptrrecord',
+ error=unicode(_('Reverse zone %(name)s requires exactly '
+ '%(count)d IP address components, '
+ '%(user_count)d given')
+ % dict(name=zone_name,
+ count=zone_len,
+ user_count=ip_addr_comp_count)))
+
+ def run_precallback_validators(self, dn, entry_attrs, *keys, **options):
+ assert isinstance(dn, DN)
+ ldap = self.api.Backend.ldap2
+
+ for rtype in entry_attrs.keys():
+ rtype_cb = getattr(self, '_%s_pre_callback' % rtype, None)
+ if rtype_cb:
+ rtype_cb(ldap, dn, entry_attrs, *keys, **options)
+
+ def is_pkey_zone_record(self, *keys):
+ assert isinstance(keys[-1], DNSName)
+ assert isinstance(keys[-2], DNSName)
+ idnsname = keys[-1]
+ zonename = keys[-2]
+ if idnsname.is_empty() or idnsname == zonename:
+ return True
+ return False
+
+ def check_zone(self, zone, **options):
+ """
+ Check if zone exists and if is master zone
+ """
+ parent_object = self.api.Object[self.parent_object]
+ dn = parent_object.get_dn(zone, **options)
+ ldap = self.api.Backend.ldap2
+ try:
+ entry = ldap.get_entry(dn, ['objectclass'])
+ except errors.NotFound:
+ parent_object.handle_not_found(zone)
+ else:
+ # only master zones can contain records
+ if 'idnszone' not in [x.lower() for x in entry.get('objectclass', [])]:
+ raise errors.ValidationError(
+ name='dnszoneidnsname',
+ error=_(u'only master zones can contain records')
+ )
+ return dn
+
+
+ def get_dn(self, *keys, **options):
+ if not dns_container_exists(self.api.Backend.ldap2):
+ raise errors.NotFound(reason=_('DNS is not configured'))
+
+ dn = self.check_zone(keys[-2], **options)
+
+ if self.is_pkey_zone_record(*keys):
+ return dn
+
+ #Make RR name relative if possible
+ relative_name = keys[-1].relativize(keys[-2]).ToASCII()
+ keys = keys[:-1] + (relative_name,)
+ return super(dnsrecord, self).get_dn(*keys, **options)
+
+ def attr_to_cli(self, attr):
+ cliname = get_record_rrtype(attr)
+ if not cliname:
+ cliname = attr
+ return cliname
+
+ def get_dns_masters(self):
+ ldap = self.api.Backend.ldap2
+ base_dn = DN(('cn', 'masters'), ('cn', 'ipa'), ('cn', 'etc'), self.api.env.basedn)
+ ldap_filter = '(&(objectClass=ipaConfigObject)(cn=DNS))'
+ dns_masters = []
+
+ try:
+ entries = ldap.find_entries(filter=ldap_filter, base_dn=base_dn)[0]
+
+ for entry in entries:
+ try:
+ master = entry.dn[1]['cn']
+ dns_masters.append(master)
+ except (IndexError, KeyError):
+ pass
+ except errors.NotFound:
+ return []
+
+ return dns_masters
+
+ def get_record_entry_attrs(self, entry_attrs):
+ entry_attrs = entry_attrs.copy()
+ for attr in entry_attrs.keys():
+ if attr not in self.params or self.params[attr].primary_key:
+ del entry_attrs[attr]
+ return entry_attrs
+
+ def postprocess_record(self, record, **options):
+ if options.get('structured', False):
+ for attr in record.keys():
+ # attributes in LDAPEntry may not be normalized
+ attr = attr.lower()
+ try:
+ param = self.params[attr]
+ except KeyError:
+ continue
+
+ if not isinstance(param, DNSRecord):
+ continue
+ parts_params = param.get_parts()
+
+ for dnsvalue in record[attr]:
+ dnsentry = {
+ u'dnstype' : unicode(param.rrtype),
+ u'dnsdata' : dnsvalue
+ }
+ values = param._get_part_values(dnsvalue)
+ if values is None:
+ continue
+ for val_id, val in enumerate(values):
+ if val is not None:
+ #decode IDN
+ if isinstance(parts_params[val_id], DNSNameParam):
+ dnsentry[parts_params[val_id].name] = \
+ _dns_name_to_string(val,
+ options.get('raw', False))
+ else:
+ dnsentry[parts_params[val_id].name] = val
+ record.setdefault('dnsrecords', []).append(dnsentry)
+ del record[attr]
+
+ elif not options.get('raw', False):
+ #Decode IDN ACE form to Unicode, raw records are passed directly from LDAP
+ _records_idn_postprocess(record, **options)
+
+ def updated_rrattrs(self, old_entry, entry_attrs):
+ """Returns updated RR attributes
+ """
+ rrattrs = {}
+ if old_entry is not None:
+ old_rrattrs = dict((key, value) for key, value in old_entry.items()
+ if key in self.params and
+ isinstance(self.params[key], DNSRecord))
+ rrattrs.update(old_rrattrs)
+ new_rrattrs = dict((key, value) for key, value in entry_attrs.items()
+ if key in self.params and
+ isinstance(self.params[key], DNSRecord))
+ rrattrs.update(new_rrattrs)
+ return rrattrs
+
+ def check_record_type_collisions(self, keys, rrattrs):
+ # Test that only allowed combination of record types was created
+
+ # CNAME record validation
+ cnames = rrattrs.get('cnamerecord')
+ if cnames is not None:
+ if len(cnames) > 1:
+ raise errors.ValidationError(name='cnamerecord',
+ error=_('only one CNAME record is allowed per name '
+ '(RFC 2136, section 1.1.5)'))
+ if any(rrvalue is not None
+ and rrattr != 'cnamerecord'
+ for rrattr, rrvalue in rrattrs.items()):
+ raise errors.ValidationError(name='cnamerecord',
+ error=_('CNAME record is not allowed to coexist '
+ 'with any other record (RFC 1034, section 3.6.2)'))
+
+ # DNAME record validation
+ dnames = rrattrs.get('dnamerecord')
+ if dnames is not None:
+ if len(dnames) > 1:
+ raise errors.ValidationError(name='dnamerecord',
+ error=_('only one DNAME record is allowed per name '
+ '(RFC 6672, section 2.4)'))
+ # DNAME must not coexist with CNAME, but this is already checked earlier
+
+ # NS record validation
+ # NS record can coexist only with A, AAAA, DS, and other NS records (except zone apex)
+ # RFC 2181 section 6.1,
+ allowed_records = ['AAAA', 'A', 'DS', 'NS']
+ nsrecords = rrattrs.get('nsrecord')
+ if nsrecords and not self.is_pkey_zone_record(*keys):
+ for r_type in _record_types:
+ if (r_type not in allowed_records
+ and rrattrs.get(record_name_format % r_type.lower())
+ ):
+ raise errors.ValidationError(
+ name='nsrecord',
+ error=_('NS record is not allowed to coexist with an '
+ '%(type)s record except when located in a '
+ 'zone root record (RFC 2181, section 6.1)') %
+ {'type': r_type})
+
+ def check_record_type_dependencies(self, keys, rrattrs):
+ # Test that all record type dependencies are satisfied
+
+ # DS record validation
+ # DS record requires to coexists with NS record
+ dsrecords = rrattrs.get('dsrecord')
+ nsrecords = rrattrs.get('nsrecord')
+ # DS record cannot be in zone apex, checked in pre-callback validators
+ if dsrecords and not nsrecords:
+ raise errors.ValidationError(
+ name='dsrecord',
+ error=_('DS record requires to coexist with an '
+ 'NS record (RFC 4592 section 4.6, RFC 4035 section 2.4)'))
+
+ def _entry2rrsets(self, entry_attrs, dns_name, dns_domain):
+ '''Convert entry_attrs to a dictionary {rdtype: rrset}.
+
+ :returns:
+ None if entry_attrs is None
+ {rdtype: None} if RRset of given type is empty
+ {rdtype: RRset} if RRset of given type is non-empty
+ '''
+ ldap_rrsets = {}
+
+ if not entry_attrs:
+ # all records were deleted => name should not exist in DNS
+ return None
+
+ for attr, value in entry_attrs.items():
+ rrtype = get_record_rrtype(attr)
+ if not rrtype:
+ continue
+
+ rdtype = dns.rdatatype.from_text(rrtype)
+ if not value:
+ ldap_rrsets[rdtype] = None # RRset is empty
+ continue
+
+ try:
+ # TTL here can be arbitrary value because it is ignored
+ # during comparison
+ ldap_rrset = dns.rrset.from_text(
+ dns_name, 86400, dns.rdataclass.IN, rdtype,
+ *[str(v) for v in value])
+
+ # make sure that all names are absolute so RRset
+ # comparison will work
+ for ldap_rr in ldap_rrset:
+ ldap_rr.choose_relativity(origin=dns_domain,
+ relativize=False)
+ ldap_rrsets[rdtype] = ldap_rrset
+
+ except dns.exception.SyntaxError as e:
+ self.log.error('DNS syntax error: %s %s %s: %s', dns_name,
+ dns.rdatatype.to_text(rdtype), value, e)
+ raise
+
+ return ldap_rrsets
+
+ def wait_for_modified_attr(self, ldap_rrset, rdtype, dns_name):
+ '''Wait until DNS resolver returns up-to-date answer for given RRset
+ or until the maximum number of attempts is reached.
+ Number of attempts is controlled by self.api.env['wait_for_dns'].
+
+ :param ldap_rrset:
+ None if given rdtype should not exist or
+ dns.rrset.RRset to match against data in DNS.
+ :param dns_name: FQDN to query
+ :type dns_name: dns.name.Name
+ :return: None if data in DNS and LDAP match
+ :raises errors.DNSDataMismatch: if data in DNS and LDAP doesn't match
+ :raises dns.exception.DNSException: if DNS resolution failed
+ '''
+ resolver = dns.resolver.Resolver()
+ resolver.set_flags(0) # disable recursion (for NS RR checks)
+ max_attempts = int(self.api.env['wait_for_dns'])
+ warn_attempts = max_attempts // 2
+ period = 1 # second
+ attempt = 0
+ log_fn = self.log.debug
+ log_fn('querying DNS server: expecting answer {%s}', ldap_rrset)
+ wait_template = 'waiting for DNS answer {%s}: got {%s} (attempt %s); '\
+ 'waiting %s seconds before next try'
+
+ while attempt < max_attempts:
+ if attempt >= warn_attempts:
+ log_fn = self.log.warning
+ attempt += 1
+ try:
+ dns_answer = resolver.query(dns_name, rdtype,
+ dns.rdataclass.IN,
+ raise_on_no_answer=False)
+ dns_rrset = None
+ if rdtype == _NS:
+ # NS records can be in Authority section (sometimes)
+ dns_rrset = dns_answer.response.get_rrset(
+ dns_answer.response.authority, dns_name, _IN, rdtype)
+
+ if not dns_rrset:
+ # Look for NS and other data in Answer section
+ dns_rrset = dns_answer.rrset
+
+ if dns_rrset == ldap_rrset:
+ log_fn('DNS answer matches expectations (attempt %s)',
+ attempt)
+ return
+
+ log_msg = wait_template % (ldap_rrset, dns_answer.response,
+ attempt, period)
+
+ except (dns.resolver.NXDOMAIN,
+ dns.resolver.YXDOMAIN,
+ dns.resolver.NoNameservers,
+ dns.resolver.Timeout) as e:
+ if attempt >= max_attempts:
+ raise
+ else:
+ log_msg = wait_template % (ldap_rrset, type(e), attempt,
+ period)
+
+ log_fn(log_msg)
+ time.sleep(period)
+
+ # Maximum number of attempts was reached
+ else:
+ raise errors.DNSDataMismatch(expected=ldap_rrset, got=dns_rrset)
+
+ def wait_for_modified_attrs(self, entry_attrs, dns_name, dns_domain):
+ '''Wait until DNS resolver returns up-to-date answer for given entry
+ or until the maximum number of attempts is reached.
+
+ :param entry_attrs:
+ None if the entry was deleted from LDAP or
+ LDAPEntry instance containing at least all modified attributes.
+ :param dns_name: FQDN
+ :type dns_name: dns.name.Name
+ :raises errors.DNSDataMismatch: if data in DNS and LDAP doesn't match
+ '''
+
+ # represent data in LDAP as dictionary rdtype => rrset
+ ldap_rrsets = self._entry2rrsets(entry_attrs, dns_name, dns_domain)
+ nxdomain = ldap_rrsets is None
+ if nxdomain:
+ # name should not exist => ask for A record and check result
+ ldap_rrsets = {dns.rdatatype.from_text('A'): None}
+
+ for rdtype, ldap_rrset in ldap_rrsets.items():
+ try:
+ self.wait_for_modified_attr(ldap_rrset, rdtype, dns_name)
+
+ except dns.resolver.NXDOMAIN as e:
+ if nxdomain:
+ continue
+ else:
+ e = errors.DNSDataMismatch(expected=ldap_rrset,
+ got="NXDOMAIN")
+ self.log.error(e)
+ raise e
+
+ except dns.resolver.NoNameservers as e:
+ # Do not raise exception if we have got SERVFAILs.
+ # Maybe the user has created an invalid zone intentionally.
+ self.log.warning('waiting for DNS answer {%s}: got {%s}; '
+ 'ignoring', ldap_rrset, type(e))
+ continue
+
+ except dns.exception.DNSException as e:
+ err_desc = str(type(e))
+ err_str = str(e)
+ if err_str:
+ err_desc += ": %s" % err_str
+ e = errors.DNSDataMismatch(expected=ldap_rrset, got=err_desc)
+ self.log.error(e)
+ raise e
+
+ def wait_for_modified_entries(self, entries):
+ '''Call wait_for_modified_attrs for all entries in given dict.
+
+ :param entries:
+ Dict {(dns_domain, dns_name): entry_for_wait_for_modified_attrs}
+ '''
+ for entry_name, entry in entries.items():
+ dns_domain = entry_name[0]
+ dns_name = entry_name[1].derelativize(dns_domain)
+ self.wait_for_modified_attrs(entry, dns_name, dns_domain)
+
+ def warning_if_ns_change_cause_fwzone_ineffective(self, result, *keys,
+ **options):
+ """Detect if NS record change can make forward zones ineffective due
+ missing delegation. Run after parent's execute method.
+ """
+ record_name_absolute = keys[-1]
+ zone = keys[-2]
+
+ if not record_name_absolute.is_absolute():
+ record_name_absolute = record_name_absolute.derelativize(zone)
+
+ affected_fw_zones, truncated = _find_subtree_forward_zones_ldap(
+ self.api, record_name_absolute)
+ if not affected_fw_zones:
+ return
+
+ for fwzone in affected_fw_zones:
+ _add_warning_fw_zone_is_not_effective(self.api, result, fwzone,
+ options['version'])
+
+ def warning_suspicious_relative_name(self, result, *keys, **options):
+ """Detect if zone name is suffix of relative record name and warn.
+
+ Zone name: test.zone.
+ Relative name: record.test.zone
+ """
+ record_name = keys[-1]
+ zone = keys[-2]
+ if not record_name.is_absolute() and record_name.is_subdomain(
+ zone.relativize(DNSName.root)):
+ messages.add_message(
+ options['version'],
+ result,
+ messages.DNSSuspiciousRelativeName(record=record_name,
+ zone=zone,
+ fqdn=record_name + zone)
+ )
+
+
+@register()
+class dnsrecord_split_parts(Command):
+ NO_CLI = True
+
+ takes_args = (
+ Str('name'),
+ Str('value'),
+ )
+
+ def execute(self, name, value, *args, **options):
+ result = self.api.Object.dnsrecord.params[name]._get_part_values(value)
+ return dict(result=result)
+
+
+@register()
+class dnsrecord_add(LDAPCreate):
+ __doc__ = _('Add new DNS resource record.')
+
+ no_option_msg = 'No options to add a specific record provided.\n' \
+ "Command help may be consulted for all supported record types."
+ takes_options = LDAPCreate.takes_options + (
+ Flag('force',
+ label=_('Force'),
+ flags=['no_option', 'no_output'],
+ doc=_('force NS record creation even if its hostname is not in DNS'),
+ ),
+ dnsrecord.structured_flag,
+ )
+
+ def args_options_2_entry(self, *keys, **options):
+ has_cli_options(self, options, self.no_option_msg)
+ return super(dnsrecord_add, self).args_options_2_entry(*keys, **options)
+
+ def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options):
+ assert isinstance(dn, DN)
+ precallback_attrs = []
+ processed_attrs = []
+ for option in options:
+ try:
+ param = self.params[option]
+ except KeyError:
+ continue
+
+ rrparam = get_rrparam_from_part(self, option)
+ if rrparam is None:
+ continue
+
+ if 'dnsrecord_part' in param.flags:
+ if rrparam.name in processed_attrs:
+ # this record was already entered
+ continue
+ if rrparam.name in entry_attrs:
+ # this record is entered both via parts and raw records
+ raise errors.ValidationError(name=param.cli_name or param.name,
+ error=_('Raw value of a DNS record was already set by "%(name)s" option') \
+ % dict(name=rrparam.cli_name or rrparam.name))
+
+ parts = rrparam.get_parts_from_kw(options)
+ dnsvalue = [rrparam._convert_scalar(parts)]
+ entry_attrs[rrparam.name] = dnsvalue
+ processed_attrs.append(rrparam.name)
+ continue
+
+ if 'dnsrecord_extra' in param.flags:
+ # do not run precallback for unset flags
+ if isinstance(param, Flag) and not options[option]:
+ continue
+ # extra option is passed, run per-type pre_callback for given RR type
+ precallback_attrs.append(rrparam.name)
+
+ # Run pre_callback validators
+ self.obj.run_precallback_validators(dn, entry_attrs, *keys, **options)
+
+ # run precallback also for all new RR type attributes in entry_attrs
+ for attr in entry_attrs.keys():
+ try:
+ param = self.params[attr]
+ except KeyError:
+ continue
+
+ if not isinstance(param, DNSRecord):
+ continue
+ precallback_attrs.append(attr)
+
+ precallback_attrs = list(set(precallback_attrs))
+
+ for attr in precallback_attrs:
+ # run per-type
+ try:
+ param = self.params[attr]
+ except KeyError:
+ continue
+ param.dnsrecord_add_pre_callback(ldap, dn, entry_attrs, attrs_list, *keys, **options)
+
+ # Store all new attrs so that DNSRecord post callback is called for
+ # new attributes only and not for all attributes in the LDAP entry
+ setattr(context, 'dnsrecord_precallback_attrs', precallback_attrs)
+
+ # We always want to retrieve all DNS record attributes to test for
+ # record type collisions (#2601)
+ try:
+ old_entry = ldap.get_entry(dn, _record_attributes)
+ except errors.NotFound:
+ old_entry = None
+ else:
+ for attr in entry_attrs.keys():
+ if attr not in _record_attributes:
+ continue
+ if entry_attrs[attr] is None:
+ entry_attrs[attr] = []
+ if not isinstance(entry_attrs[attr], (tuple, list)):
+ vals = [entry_attrs[attr]]
+ else:
+ vals = list(entry_attrs[attr])
+ entry_attrs[attr] = list(set(old_entry.get(attr, []) + vals))
+
+ rrattrs = self.obj.updated_rrattrs(old_entry, entry_attrs)
+ self.obj.check_record_type_dependencies(keys, rrattrs)
+ self.obj.check_record_type_collisions(keys, rrattrs)
+ context.dnsrecord_entry_mods = getattr(context, 'dnsrecord_entry_mods',
+ {})
+ context.dnsrecord_entry_mods[(keys[0], keys[1])] = entry_attrs.copy()
+
+ return dn
+
+ def execute(self, *keys, **options):
+ result = super(dnsrecord_add, self).execute(*keys, **options)
+ self.obj.warning_suspicious_relative_name(result, *keys, **options)
+ return result
+
+ def exc_callback(self, keys, options, exc, call_func, *call_args, **call_kwargs):
+ if call_func.__name__ == 'add_entry':
+ if isinstance(exc, errors.DuplicateEntry):
+ # A new record is being added to existing LDAP DNS object
+ # Update can be safely run as old record values has been
+ # already merged in pre_callback
+ ldap = self.obj.backend
+ entry_attrs = self.obj.get_record_entry_attrs(call_args[0])
+ update = ldap.get_entry(entry_attrs.dn, list(entry_attrs))
+ update.update(entry_attrs)
+ ldap.update_entry(update, **call_kwargs)
+ return
+ raise exc
+
+ def post_callback(self, ldap, dn, entry_attrs, *keys, **options):
+ assert isinstance(dn, DN)
+ for attr in getattr(context, 'dnsrecord_precallback_attrs', []):
+ param = self.params[attr]
+ param.dnsrecord_add_post_callback(ldap, dn, entry_attrs, *keys, **options)
+
+ if self.obj.is_pkey_zone_record(*keys):
+ entry_attrs[self.obj.primary_key.name] = [_dns_zone_record]
+
+ self.obj.postprocess_record(entry_attrs, **options)
+
+ if self.api.env['wait_for_dns']:
+ self.obj.wait_for_modified_entries(context.dnsrecord_entry_mods)
+ return dn
+
+
+
+@register()
+class dnsrecord_mod(LDAPUpdate):
+ __doc__ = _('Modify a DNS resource record.')
+
+ no_option_msg = 'No options to modify a specific record provided.'
+
+ takes_options = LDAPUpdate.takes_options + (
+ dnsrecord.structured_flag,
+ )
+
+ def args_options_2_entry(self, *keys, **options):
+ has_cli_options(self, options, self.no_option_msg, True)
+ return super(dnsrecord_mod, self).args_options_2_entry(*keys, **options)
+
+ def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options):
+ assert isinstance(dn, DN)
+ if options.get('rename') and self.obj.is_pkey_zone_record(*keys):
+ # zone rename is not allowed
+ raise errors.ValidationError(name='rename',
+ error=_('DNS zone root record cannot be renamed'))
+
+ # check if any attr should be updated using structured instead of replaced
+ # format is recordname : (old_value, new_parts)
+ updated_attrs = {}
+ for param in iterate_rrparams_by_parts(self, options, skip_extra=True):
+ parts = param.get_parts_from_kw(options, raise_on_none=False)
+
+ if parts is None:
+ # old-style modification
+ continue
+
+ old_value = entry_attrs.get(param.name)
+ if not old_value:
+ raise errors.RequirementError(name=param.name)
+ if isinstance(old_value, (tuple, list)):
+ if len(old_value) > 1:
+ raise errors.ValidationError(name=param.name,
+ error=_('DNS records can be only updated one at a time'))
+ old_value = old_value[0]
+
+ updated_attrs[param.name] = (old_value, parts)
+
+ # Run pre_callback validators
+ self.obj.run_precallback_validators(dn, entry_attrs, *keys, **options)
+
+ # current entry is needed in case of per-dns-record-part updates and
+ # for record type collision check
+ try:
+ old_entry = ldap.get_entry(dn, _record_attributes)
+ except errors.NotFound:
+ self.obj.handle_not_found(*keys)
+
+ if updated_attrs:
+ for attr in updated_attrs:
+ param = self.params[attr]
+ old_dnsvalue, new_parts = updated_attrs[attr]
+
+ if old_dnsvalue not in old_entry.get(attr, []):
+ attr_name = unicode(param.label or param.name)
+ raise errors.AttrValueNotFound(attr=attr_name,
+ value=old_dnsvalue)
+ old_entry[attr].remove(old_dnsvalue)
+
+ old_parts = param._get_part_values(old_dnsvalue)
+ modified_parts = tuple(part if part is not None else old_parts[part_id] \
+ for part_id,part in enumerate(new_parts))
+
+ new_dnsvalue = [param._convert_scalar(modified_parts)]
+ entry_attrs[attr] = list(set(old_entry[attr] + new_dnsvalue))
+
+ rrattrs = self.obj.updated_rrattrs(old_entry, entry_attrs)
+ self.obj.check_record_type_dependencies(keys, rrattrs)
+ self.obj.check_record_type_collisions(keys, rrattrs)
+
+ context.dnsrecord_entry_mods = getattr(context, 'dnsrecord_entry_mods',
+ {})
+ context.dnsrecord_entry_mods[(keys[0], keys[1])] = entry_attrs.copy()
+ return dn
+
+ def execute(self, *keys, **options):
+ result = super(dnsrecord_mod, self).execute(*keys, **options)
+
+ # remove if empty
+ if not self.obj.is_pkey_zone_record(*keys):
+ rename = options.get('rename')
+ if rename is not None:
+ keys = keys[:-1] + (rename,)
+ dn = self.obj.get_dn(*keys, **options)
+ ldap = self.obj.backend
+ old_entry = ldap.get_entry(dn, _record_attributes)
+
+ del_all = True
+ for attr in old_entry.keys():
+ if old_entry[attr]:
+ del_all = False
+ break
+
+ if del_all:
+ result = self.obj.methods.delentry(*keys,
+ version=options['version'])
+
+ # we need to modify delete result to match mod output type
+ # only one value is expected, not a list
+ if client_has_capability(options['version'], 'primary_key_types'):
+ assert len(result['value']) == 1
+ result['value'] = result['value'][0]
+
+ # indicate that entry was deleted
+ context.dnsrecord_entry_mods[(keys[0], keys[1])] = None
+
+ if self.api.env['wait_for_dns']:
+ self.obj.wait_for_modified_entries(context.dnsrecord_entry_mods)
+ if 'nsrecord' in options:
+ self.obj.warning_if_ns_change_cause_fwzone_ineffective(result,
+ *keys,
+ **options)
+ return result
+
+ def post_callback(self, ldap, dn, entry_attrs, *keys, **options):
+ assert isinstance(dn, DN)
+ if self.obj.is_pkey_zone_record(*keys):
+ entry_attrs[self.obj.primary_key.name] = [_dns_zone_record]
+
+ self.obj.postprocess_record(entry_attrs, **options)
+ return dn
+
+
+@register()
+class dnsrecord_delentry(LDAPDelete):
+ """
+ Delete DNS record entry.
+ """
+ msg_summary = _('Deleted record "%(value)s"')
+ NO_CLI = True
+
+
+
+@register()
+class dnsrecord_del(LDAPUpdate):
+ __doc__ = _('Delete DNS resource record.')
+
+ has_output = output.standard_multi_delete
+
+ no_option_msg = _('Neither --del-all nor options to delete a specific record provided.\n'\
+ "Command help may be consulted for all supported record types.")
+
+ takes_options = (
+ Flag('del_all',
+ default=False,
+ label=_('Delete all associated records'),
+ ),
+ dnsrecord.structured_flag,
+ )
+
+ def get_options(self):
+ for option in super(dnsrecord_del, self).get_options():
+ if any(flag in option.flags for flag in \
+ ('dnsrecord_part', 'dnsrecord_extra',)):
+ continue
+ elif option.name in ('rename', ):
+ # options only valid for dnsrecord-mod
+ continue
+ elif isinstance(option, DNSRecord):
+ yield option.clone(option_group=None)
+ continue
+ yield option
+
+ def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options):
+ assert isinstance(dn, DN)
+ try:
+ old_entry = ldap.get_entry(dn, _record_attributes)
+ except errors.NotFound:
+ self.obj.handle_not_found(*keys)
+
+ for attr in entry_attrs.keys():
+ if attr not in _record_attributes:
+ continue
+ if not isinstance(entry_attrs[attr], (tuple, list)):
+ vals = [entry_attrs[attr]]
+ else:
+ vals = entry_attrs[attr]
+
+ for val in vals:
+ try:
+ old_entry[attr].remove(val)
+ except (KeyError, ValueError):
+ try:
+ param = self.params[attr]
+ attr_name = unicode(param.label or param.name)
+ except Exception:
+ attr_name = attr
+ raise errors.AttrValueNotFound(attr=attr_name, value=val)
+ entry_attrs[attr] = list(set(old_entry[attr]))
+
+ rrattrs = self.obj.updated_rrattrs(old_entry, entry_attrs)
+ self.obj.check_record_type_dependencies(keys, rrattrs)
+
+ del_all = False
+ if not self.obj.is_pkey_zone_record(*keys):
+ record_found = False
+ for attr in old_entry.keys():
+ if old_entry[attr]:
+ record_found = True
+ break
+ del_all = not record_found
+
+ # set del_all flag in context
+ # when the flag is enabled, the entire DNS record object is deleted
+ # in a post callback
+ context.del_all = del_all
+ context.dnsrecord_entry_mods = getattr(context, 'dnsrecord_entry_mods',
+ {})
+ context.dnsrecord_entry_mods[(keys[0], keys[1])] = entry_attrs.copy()
+
+ return dn
+
+ def execute(self, *keys, **options):
+ if options.get('del_all', False):
+ if self.obj.is_pkey_zone_record(*keys):
+ raise errors.ValidationError(
+ name='del_all',
+ error=_('Zone record \'%s\' cannot be deleted') \
+ % _dns_zone_record
+ )
+ result = self.obj.methods.delentry(*keys,
+ version=options['version'])
+ if self.api.env['wait_for_dns']:
+ entries = {(keys[0], keys[1]): None}
+ self.obj.wait_for_modified_entries(entries)
+ else:
+ result = super(dnsrecord_del, self).execute(*keys, **options)
+ result['value'] = pkey_to_value([keys[-1]], options)
+
+ if getattr(context, 'del_all', False) and not \
+ self.obj.is_pkey_zone_record(*keys):
+ result = self.obj.methods.delentry(*keys,
+ version=options['version'])
+ context.dnsrecord_entry_mods[(keys[0], keys[1])] = None
+
+ if self.api.env['wait_for_dns']:
+ self.obj.wait_for_modified_entries(context.dnsrecord_entry_mods)
+
+ if 'nsrecord' in options or options.get('del_all', False):
+ self.obj.warning_if_ns_change_cause_fwzone_ineffective(result,
+ *keys,
+ **options)
+ return result
+
+ def post_callback(self, ldap, dn, entry_attrs, *keys, **options):
+ assert isinstance(dn, DN)
+ if self.obj.is_pkey_zone_record(*keys):
+ entry_attrs[self.obj.primary_key.name] = [_dns_zone_record]
+ self.obj.postprocess_record(entry_attrs, **options)
+ return dn
+
+ def args_options_2_entry(self, *keys, **options):
+ has_cli_options(self, options, self.no_option_msg)
+ return super(dnsrecord_del, self).args_options_2_entry(*keys, **options)
+
+
+@register()
+class dnsrecord_show(LDAPRetrieve):
+ __doc__ = _('Display DNS resource.')
+
+ takes_options = LDAPRetrieve.takes_options + (
+ dnsrecord.structured_flag,
+ )
+
+ def post_callback(self, ldap, dn, entry_attrs, *keys, **options):
+ assert isinstance(dn, DN)
+ if self.obj.is_pkey_zone_record(*keys):
+ entry_attrs[self.obj.primary_key.name] = [_dns_zone_record]
+ self.obj.postprocess_record(entry_attrs, **options)
+ return dn
+
+
+
+@register()
+class dnsrecord_find(LDAPSearch):
+ __doc__ = _('Search for DNS resources.')
+
+ takes_options = LDAPSearch.takes_options + (
+ dnsrecord.structured_flag,
+ )
+
+ def get_options(self):
+ for option in super(dnsrecord_find, self).get_options():
+ if any(flag in option.flags for flag in \
+ ('dnsrecord_part', 'dnsrecord_extra',)):
+ continue
+ elif isinstance(option, DNSRecord):
+ yield option.clone(option_group=None)
+ continue
+ yield option
+
+ def pre_callback(self, ldap, filter, attrs_list, base_dn, scope,
+ dnszoneidnsname, *args, **options):
+ assert isinstance(base_dn, DN)
+
+ # validate if zone is master zone
+ self.obj.check_zone(dnszoneidnsname, **options)
+
+ filter = _create_idn_filter(self, ldap, *args, **options)
+ return (filter, base_dn, ldap.SCOPE_SUBTREE)
+
+ def post_callback(self, ldap, entries, truncated, *args, **options):
+ if entries:
+ zone_obj = self.api.Object[self.obj.parent_object]
+ zone_dn = zone_obj.get_dn(args[0])
+ if entries[0].dn == zone_dn:
+ entries[0][zone_obj.primary_key.name] = [_dns_zone_record]
+ for entry in entries:
+ self.obj.postprocess_record(entry, **options)
+
+ return truncated
+
+
+@register()
+class dns_resolve(Command):
+ __doc__ = _('Resolve a host name in DNS. (Deprecated)')
+
+ NO_CLI = True
+
+ has_output = output.standard_value
+ msg_summary = _('Found \'%(value)s\'')
+
+ takes_args = (
+ Str('hostname',
+ label=_('Hostname (FQDN)'),
+ ),
+ )
+
+ def execute(self, *args, **options):
+ query=args[0]
+
+ try:
+ verify_host_resolvable(query)
+ except errors.DNSNotARecordError:
+ raise errors.NotFound(
+ reason=_('Host \'%(host)s\' not found') % {'host': query}
+ )
+ result = dict(result=True, value=query)
+ messages.add_message(
+ options['version'], result,
+ messages.CommandDeprecatedWarning(
+ command='dns-resolve',
+ additional_info='The command may return an unexpected result, '
+ 'the resolution of the DNS domain is done on '
+ 'a randomly chosen IPA server.'
+ )
+ )
+ return result
+
+
+@register()
+class dns_is_enabled(Command):
+ """
+ Checks if any of the servers has the DNS service enabled.
+ """
+ NO_CLI = True
+ has_output = output.standard_value
+
+ base_dn = DN(('cn', 'masters'), ('cn', 'ipa'), ('cn', 'etc'), api.env.basedn)
+ filter = '(&(objectClass=ipaConfigObject)(cn=DNS))'
+
+ def execute(self, *args, **options):
+ ldap = self.api.Backend.ldap2
+ dns_enabled = False
+
+ try:
+ ldap.find_entries(filter=self.filter, base_dn=self.base_dn)
+ dns_enabled = True
+ except errors.EmptyResult:
+ dns_enabled = False
+
+ return dict(result=dns_enabled, value=pkey_to_value(None, options))
+
+
+@register()
+class dnsconfig(LDAPObject):
+ """
+ DNS global configuration object
+ """
+ object_name = _('DNS configuration options')
+ default_attributes = [
+ 'idnsforwardpolicy', 'idnsforwarders', 'idnsallowsyncptr'
+ ]
+
+ label = _('DNS Global Configuration')
+ label_singular = _('DNS Global Configuration')
+
+ takes_params = (
+ Str('idnsforwarders*',
+ _validate_bind_forwarder,
+ cli_name='forwarder',
+ label=_('Global forwarders'),
+ doc=_('Global forwarders. A custom port can be specified for each '
+ 'forwarder using a standard format "IP_ADDRESS port PORT"'),
+ ),
+ StrEnum('idnsforwardpolicy?',
+ cli_name='forward_policy',
+ label=_('Forward policy'),
+ doc=_('Global forwarding policy. Set to "none" to disable '
+ 'any configured global forwarders.'),
+ values=(u'only', u'first', u'none'),
+ ),
+ Bool('idnsallowsyncptr?',
+ cli_name='allow_sync_ptr',
+ label=_('Allow PTR sync'),
+ doc=_('Allow synchronization of forward (A, AAAA) and reverse (PTR) records'),
+ ),
+ Int('idnszonerefresh?',
+ deprecated=True,
+ cli_name='zone_refresh',
+ label=_('Zone refresh interval'),
+ doc=_('An interval between regular polls of the name server for new DNS zones'),
+ minvalue=0,
+ flags={'no_option'},
+ ),
+ Int('ipadnsversion?', # available only in installer/upgrade
+ label=_('IPA DNS version'),
+ ),
+ )
+ managed_permissions = {
+ 'System: Write DNS Configuration': {
+ 'non_object': True,
+ 'ipapermright': {'write'},
+ 'ipapermlocation': api.env.basedn,
+ 'ipapermtarget': DN('cn=dns', api.env.basedn),
+ 'ipapermtargetfilter': ['(objectclass=idnsConfigObject)'],
+ 'ipapermdefaultattr': {
+ 'idnsallowsyncptr', 'idnsforwarders', 'idnsforwardpolicy',
+ 'idnspersistentsearch', 'idnszonerefresh'
+ },
+ 'replaces': [
+ '(targetattr = "idnsforwardpolicy || idnsforwarders || idnsallowsyncptr || idnszonerefresh || idnspersistentsearch")(target = "ldap:///cn=dns,$SUFFIX")(version 3.0;acl "permission:Write DNS Configuration";allow (write) groupdn = "ldap:///cn=Write DNS Configuration,cn=permissions,cn=pbac,$SUFFIX";)',
+ ],
+ 'default_privileges': {'DNS Administrators', 'DNS Servers'},
+ },
+ 'System: Read DNS Configuration': {
+ 'non_object': True,
+ 'ipapermright': {'read'},
+ 'ipapermlocation': api.env.basedn,
+ 'ipapermtarget': DN('cn=dns', api.env.basedn),
+ 'ipapermtargetfilter': ['(objectclass=idnsConfigObject)'],
+ 'ipapermdefaultattr': {
+ 'objectclass',
+ 'idnsallowsyncptr', 'idnsforwarders', 'idnsforwardpolicy',
+ 'idnspersistentsearch', 'idnszonerefresh', 'ipadnsversion'
+ },
+ 'default_privileges': {'DNS Administrators', 'DNS Servers'},
+ },
+ }
+
+ def get_dn(self, *keys, **kwargs):
+ if not dns_container_exists(self.api.Backend.ldap2):
+ raise errors.NotFound(reason=_('DNS is not configured'))
+ return DN(api.env.container_dns, api.env.basedn)
+
+ def get_dnsconfig(self, ldap):
+ entry = ldap.get_entry(self.get_dn(), None)
+
+ return entry
+
+ def postprocess_result(self, result):
+ if not any(param in result['result'] for param in self.params):
+ result['summary'] = unicode(_('Global DNS configuration is empty'))
+
+
+@register()
+class dnsconfig_mod(LDAPUpdate):
+ __doc__ = _('Modify global DNS configuration.')
+
+ def get_options(self):
+ """hide ipadnsversion outside of installer/upgrade"""
+ for option in super(dnsconfig_mod, self).get_options():
+ if option.name == 'ipadnsversion':
+ option = option.clone(include=('installer', 'updates'))
+ yield option
+
+ def execute(self, *keys, **options):
+ # test dnssec forwarders
+ forwarders = options.get('idnsforwarders')
+
+ result = super(dnsconfig_mod, self).execute(*keys, **options)
+ self.obj.postprocess_result(result)
+
+ # this check makes sense only when resulting forwarders are non-empty
+ if result['result'].get('idnsforwarders'):
+ fwzone = DNSName('.')
+ _add_warning_fw_policy_conflict_aez(result, fwzone, **options)
+
+ if forwarders:
+ # forwarders were changed
+ for forwarder in forwarders:
+ try:
+ validate_dnssec_global_forwarder(forwarder, log=self.log)
+ except DNSSECSignatureMissingError as e:
+ messages.add_message(
+ options['version'],
+ result, messages.DNSServerDoesNotSupportDNSSECWarning(
+ server=forwarder, error=e,
+ )
+ )
+ except EDNS0UnsupportedError as e:
+ messages.add_message(
+ options['version'],
+ result, messages.DNSServerDoesNotSupportEDNS0Warning(
+ server=forwarder, error=e,
+ )
+ )
+ except UnresolvableRecordError as e:
+ messages.add_message(
+ options['version'],
+ result, messages.DNSServerValidationWarning(
+ server=forwarder, error=e
+ )
+ )
+
+ return result
+
+
+
+@register()
+class dnsconfig_show(LDAPRetrieve):
+ __doc__ = _('Show the current global DNS configuration.')
+
+ def execute(self, *keys, **options):
+ result = super(dnsconfig_show, self).execute(*keys, **options)
+ self.obj.postprocess_result(result)
+ return result
+
+
+
+@register()
+class dnsforwardzone(DNSZoneBase):
+ """
+ DNS Forward zone, container for resource records.
+ """
+ object_name = _('DNS forward zone')
+ object_name_plural = _('DNS forward zones')
+ object_class = DNSZoneBase.object_class + ['idnsforwardzone']
+ label = _('DNS Forward Zones')
+ label_singular = _('DNS Forward Zone')
+ default_forward_policy = u'first'
+
+ # managed_permissions: permissions was apllied in dnszone class, do NOT
+ # add them here, they should not be applied twice.
+
+ def _warning_fw_zone_is_not_effective(self, result, *keys, **options):
+ fwzone = keys[-1]
+ _add_warning_fw_zone_is_not_effective(self.api, result, fwzone,
+ options['version'])
+
+ def _warning_if_forwarders_do_not_work(self, result, new_zone,
+ *keys, **options):
+ fwzone = keys[-1]
+ forwarders = options.get('idnsforwarders', [])
+ any_forwarder_work = False
+
+ for forwarder in forwarders:
+ try:
+ validate_dnssec_zone_forwarder_step1(forwarder, fwzone,
+ log=self.log)
+ except UnresolvableRecordError as e:
+ messages.add_message(
+ options['version'],
+ result, messages.DNSServerValidationWarning(
+ server=forwarder, error=e
+ )
+ )
+ except EDNS0UnsupportedError as e:
+ messages.add_message(
+ options['version'],
+ result, messages.DNSServerDoesNotSupportEDNS0Warning(
+ server=forwarder, error=e
+ )
+ )
+ else:
+ any_forwarder_work = True
+
+ if not any_forwarder_work:
+ # do not test DNSSEC validation if there is no valid forwarder
+ return
+
+ # resolve IP address of any DNS replica
+ # FIXME: https://fedorahosted.org/bind-dyndb-ldap/ticket/143
+ # we currenly should to test all IPA DNS replica, because DNSSEC
+ # validation is configured just in named.conf per replica
+
+ ipa_dns_masters = [normalize_zone(x) for x in
+ self.api.Object.dnsrecord.get_dns_masters()]
+
+ if not ipa_dns_masters:
+ # something very bad happened, DNS is installed, but no IPA DNS
+ # servers available
+ self.log.error("No IPA DNS server can be found, but integrated DNS "
+ "is installed")
+ return
+
+ ipa_dns_ip = None
+ for rdtype in (dns.rdatatype.A, dns.rdatatype.AAAA):
+ try:
+ ans = dns.resolver.query(ipa_dns_masters[0], rdtype)
+ except dns.exception.DNSException:
+ continue
+ else:
+ ipa_dns_ip = str(ans.rrset.items[0])
+ break
+
+ if not ipa_dns_ip:
+ self.log.error("Cannot resolve %s hostname", ipa_dns_masters[0])
+ return
+
+ # sleep a bit, adding new zone to BIND from LDAP may take a while
+ if new_zone:
+ time.sleep(5)
+
+ # Test if IPA is able to receive replies from forwarders
+ try:
+ validate_dnssec_zone_forwarder_step2(ipa_dns_ip, fwzone,
+ log=self.log)
+ except DNSSECValidationError as e:
+ messages.add_message(
+ options['version'],
+ result, messages.DNSSECValidationFailingWarning(error=e)
+ )
+ except UnresolvableRecordError as e:
+ messages.add_message(
+ options['version'],
+ result, messages.DNSServerValidationWarning(
+ server=ipa_dns_ip, error=e
+ )
+ )
+
+
+@register()
+class dnsforwardzone_add(DNSZoneBase_add):
+ __doc__ = _('Create new DNS forward zone.')
+
+ def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options):
+ assert isinstance(dn, DN)
+
+ dn = super(dnsforwardzone_add, self).pre_callback(ldap, dn,
+ entry_attrs, attrs_list, *keys, **options)
+
+ if 'idnsforwardpolicy' not in entry_attrs:
+ entry_attrs['idnsforwardpolicy'] = self.obj.default_forward_policy
+
+ if (not entry_attrs.get('idnsforwarders') and
+ entry_attrs['idnsforwardpolicy'] != u'none'):
+ raise errors.ValidationError(name=u'idnsforwarders',
+ error=_('Please specify forwarders.'))
+
+ return dn
+
+ def execute(self, *keys, **options):
+ fwzone = keys[-1]
+ result = super(dnsforwardzone_add, self).execute(*keys, **options)
+ self.obj._warning_fw_zone_is_not_effective(result, *keys, **options)
+ _add_warning_fw_policy_conflict_aez(result, fwzone, **options)
+ if options.get('idnsforwarders'):
+ self.obj._warning_if_forwarders_do_not_work(
+ result, True, *keys, **options)
+ return result
+
+
+@register()
+class dnsforwardzone_del(DNSZoneBase_del):
+ __doc__ = _('Delete DNS forward zone.')
+
+ msg_summary = _('Deleted DNS forward zone "%(value)s"')
+
+
+@register()
+class dnsforwardzone_mod(DNSZoneBase_mod):
+ __doc__ = _('Modify DNS forward zone.')
+
+ def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options):
+ try:
+ entry = ldap.get_entry(dn)
+ except errors.NotFound:
+ self.obj.handle_not_found(*keys)
+
+ if not _check_entry_objectclass(entry, self.obj.object_class):
+ self.obj.handle_not_found(*keys)
+
+ policy = self.obj.default_forward_policy
+ forwarders = []
+
+ if 'idnsforwarders' in entry_attrs:
+ forwarders = entry_attrs['idnsforwarders']
+ elif 'idnsforwarders' in entry:
+ forwarders = entry['idnsforwarders']
+
+ if 'idnsforwardpolicy' in entry_attrs:
+ policy = entry_attrs['idnsforwardpolicy']
+ elif 'idnsforwardpolicy' in entry:
+ policy = entry['idnsforwardpolicy']
+
+ if not forwarders and policy != u'none':
+ raise errors.ValidationError(name=u'idnsforwarders',
+ error=_('Please specify forwarders.'))
+
+ return dn
+
+ def execute(self, *keys, **options):
+ fwzone = keys[-1]
+ result = super(dnsforwardzone_mod, self).execute(*keys, **options)
+ _add_warning_fw_policy_conflict_aez(result, fwzone, **options)
+ if options.get('idnsforwarders'):
+ self.obj._warning_if_forwarders_do_not_work(result, False, *keys,
+ **options)
+ return result
+
+@register()
+class dnsforwardzone_find(DNSZoneBase_find):
+ __doc__ = _('Search for DNS forward zones.')
+
+
+@register()
+class dnsforwardzone_show(DNSZoneBase_show):
+ __doc__ = _('Display information about a DNS forward zone.')
+
+ has_output_params = LDAPRetrieve.has_output_params + dnszone_output_params
+
+
+@register()
+class dnsforwardzone_disable(DNSZoneBase_disable):
+ __doc__ = _('Disable DNS Forward Zone.')
+ msg_summary = _('Disabled DNS forward zone "%(value)s"')
+
+
+@register()
+class dnsforwardzone_enable(DNSZoneBase_enable):
+ __doc__ = _('Enable DNS Forward Zone.')
+ msg_summary = _('Enabled DNS forward zone "%(value)s"')
+
+ def execute(self, *keys, **options):
+ result = super(dnsforwardzone_enable, self).execute(*keys, **options)
+ self.obj._warning_fw_zone_is_not_effective(result, *keys, **options)
+ return result
+
+
+@register()
+class dnsforwardzone_add_permission(DNSZoneBase_add_permission):
+ __doc__ = _('Add a permission for per-forward zone access delegation.')
+
+
+@register()
+class dnsforwardzone_remove_permission(DNSZoneBase_remove_permission):
+ __doc__ = _('Remove a permission for per-forward zone access delegation.')
diff --git a/ipaserver/plugins/dogtag.py b/ipaserver/plugins/dogtag.py
index 8836e70dc..197814c4d 100644
--- a/ipaserver/plugins/dogtag.py
+++ b/ipaserver/plugins/dogtag.py
@@ -244,19 +244,21 @@ import json
from lxml import etree
import time
-import pki
-from pki.client import PKIConnection
-import pki.crypto as cryptoutil
-from pki.kra import KRAClient
import six
from six.moves import urllib
-from ipalib import Backend
+from ipalib import Backend, api
from ipapython.dn import DN
import ipapython.cookie
from ipapython import dogtag
from ipapython import ipautil
+if api.env.in_server:
+ import pki
+ from pki.client import PKIConnection
+ import pki.crypto as cryptoutil
+ from pki.kra import KRAClient
+
if six.PY3:
unicode = str
@@ -1269,7 +1271,7 @@ def select_any_master(ldap2, service='CA'):
#-------------------------------------------------------------------------------
-from ipalib import Registry, api, errors, SkipPluginModule
+from ipalib import Registry, errors, SkipPluginModule
if api.env.ra_plugin != 'dogtag':
# In this case, abort loading this plugin module...
raise SkipPluginModule(reason='dogtag not selected as RA plugin')
diff --git a/ipaserver/plugins/domainlevel.py b/ipaserver/plugins/domainlevel.py
new file mode 100644
index 000000000..23fa2a1b2
--- /dev/null
+++ b/ipaserver/plugins/domainlevel.py
@@ -0,0 +1,137 @@
+#
+# Copyright (C) 2015 FreeIPA Contributors see COPYING for license
+#
+
+from collections import namedtuple
+
+from ipalib import _
+from ipalib import Command
+from ipalib import errors
+from ipalib import output
+from ipalib.parameters import Int
+from ipalib.plugable import Registry
+
+from ipapython.dn import DN
+
+
+__doc__ = _("""
+Raise the IPA Domain Level.
+""")
+
+register = Registry()
+
+DomainLevelRange = namedtuple('DomainLevelRange', ['min', 'max'])
+
+domainlevel_output = (
+ output.Output('result', int, _('Current domain level:')),
+)
+
+
+def get_domainlevel_dn(api):
+ domainlevel_dn = DN(
+ ('cn', 'Domain Level'),
+ ('cn', 'ipa'),
+ ('cn', 'etc'),
+ api.env.basedn
+ )
+
+ return domainlevel_dn
+
+
+def get_domainlevel_range(master_entry):
+ try:
+ return DomainLevelRange(
+ int(master_entry['ipaMinDomainLevel'][0]),
+ int(master_entry['ipaMaxDomainLevel'][0])
+ )
+ except KeyError:
+ return DomainLevelRange(0, 0)
+
+
+def get_master_entries(ldap, api):
+ """
+ Returns list of LDAPEntries representing IPA masters.
+ """
+
+ container_masters = DN(
+ ('cn', 'masters'),
+ ('cn', 'ipa'),
+ ('cn', 'etc'),
+ api.env.basedn
+ )
+
+ masters, _ = ldap.find_entries(
+ filter="(cn=*)",
+ base_dn=container_masters,
+ scope=ldap.SCOPE_ONELEVEL,
+ paged_search=True, # we need to make sure to get all of them
+ )
+
+ return masters
+
+
+@register()
+class domainlevel_get(Command):
+ __doc__ = _('Query current Domain Level.')
+
+ has_output = domainlevel_output
+
+ def execute(self, *args, **options):
+ ldap = self.api.Backend.ldap2
+ entry = ldap.get_entry(
+ get_domainlevel_dn(self.api),
+ ['ipaDomainLevel']
+ )
+
+ return {'result': int(entry.single_value['ipaDomainLevel'])}
+
+
+@register()
+class domainlevel_set(Command):
+ __doc__ = _('Change current Domain Level.')
+
+ has_output = domainlevel_output
+
+ takes_args = (
+ Int('ipadomainlevel',
+ cli_name='level',
+ label=_('Domain Level'),
+ minvalue=0,
+ ),
+ )
+
+ def execute(self, *args, **options):
+ """
+ Checks all the IPA masters for supported domain level ranges.
+
+ If the desired domain level is within the supported range of all
+ masters, it will be raised.
+
+ Domain level cannot be lowered.
+ """
+
+ ldap = self.api.Backend.ldap2
+
+ current_entry = ldap.get_entry(get_domainlevel_dn(self.api))
+ current_value = int(current_entry.single_value['ipadomainlevel'])
+ desired_value = int(args[0])
+
+ # Domain level cannot be lowered
+ if int(desired_value) < int(current_value):
+ message = _("Domain Level cannot be lowered.")
+ raise errors.InvalidDomainLevelError(reason=message)
+
+ # Check if every master supports the desired level
+ for master in get_master_entries(ldap, self.api):
+ supported = get_domainlevel_range(master)
+
+ if supported.min > desired_value or supported.max < desired_value:
+ message = _("Domain Level cannot be raised to {0}, server {1} "
+ "does not support it."
+ .format(desired_value, master['cn'][0]))
+ raise errors.InvalidDomainLevelError(reason=message)
+
+ current_entry.single_value['ipaDomainLevel'] = desired_value
+ ldap.update_entry(current_entry)
+
+ return {'result': int(current_entry.single_value['ipaDomainLevel'])}
diff --git a/ipaserver/plugins/group.py b/ipaserver/plugins/group.py
new file mode 100644
index 000000000..2b0c08050
--- /dev/null
+++ b/ipaserver/plugins/group.py
@@ -0,0 +1,690 @@
+# Authors:
+# Rob Crittenden <rcritten@redhat.com>
+# Pavel Zuna <pzuna@redhat.com>
+#
+# Copyright (C) 2009 Red Hat
+# see file 'COPYING' for use and warranty information
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+import six
+
+from ipalib import api
+from ipalib import Int, Str, Flag
+from ipalib.plugable import Registry
+from .baseldap import (
+ add_external_post_callback,
+ pkey_to_value,
+ remove_external_post_callback,
+ LDAPObject,
+ LDAPCreate,
+ LDAPUpdate,
+ LDAPDelete,
+ LDAPSearch,
+ LDAPRetrieve,
+ LDAPAddMember,
+ LDAPRemoveMember,
+ LDAPQuery,
+)
+from .idviews import remove_ipaobject_overrides
+from . import baseldap
+from ipalib import _, ngettext
+from ipalib import errors
+from ipalib import output
+from ipapython.dn import DN
+
+if six.PY3:
+ unicode = str
+
+if api.env.in_server and api.env.context in ['lite', 'server']:
+ try:
+ import ipaserver.dcerpc
+ _dcerpc_bindings_installed = True
+ except ImportError:
+ _dcerpc_bindings_installed = False
+
+__doc__ = _("""
+Groups of users
+
+Manage groups of users. By default, new groups are POSIX groups. You
+can add the --nonposix option to the group-add command to mark a new group
+as non-POSIX. You can use the --posix argument with the group-mod command
+to convert a non-POSIX group into a POSIX group. POSIX groups cannot be
+converted to non-POSIX groups.
+
+Every group must have a description.
+
+POSIX groups must have a Group ID (GID) number. Changing a GID is
+supported but can have an impact on your file permissions. It is not necessary
+to supply a GID when creating a group. IPA will generate one automatically
+if it is not provided.
+
+EXAMPLES:
+
+ Add a new group:
+ ipa group-add --desc='local administrators' localadmins
+
+ Add a new non-POSIX group:
+ ipa group-add --nonposix --desc='remote administrators' remoteadmins
+
+ Convert a non-POSIX group to posix:
+ ipa group-mod --posix remoteadmins
+
+ Add a new POSIX group with a specific Group ID number:
+ ipa group-add --gid=500 --desc='unix admins' unixadmins
+
+ Add a new POSIX group and let IPA assign a Group ID number:
+ ipa group-add --desc='printer admins' printeradmins
+
+ Remove a group:
+ ipa group-del unixadmins
+
+ To add the "remoteadmins" group to the "localadmins" group:
+ ipa group-add-member --groups=remoteadmins localadmins
+
+ Add multiple users to the "localadmins" group:
+ ipa group-add-member --users=test1 --users=test2 localadmins
+
+ Remove a user from the "localadmins" group:
+ ipa group-remove-member --users=test2 localadmins
+
+ Display information about a named group.
+ ipa group-show localadmins
+
+External group membership is designed to allow users from trusted domains
+to be mapped to local POSIX groups in order to actually use IPA resources.
+External members should be added to groups that specifically created as
+external and non-POSIX. Such group later should be included into one of POSIX
+groups.
+
+An external group member is currently a Security Identifier (SID) as defined by
+the trusted domain. When adding external group members, it is possible to
+specify them in either SID, or DOM\\name, or name@domain format. IPA will attempt
+to resolve passed name to SID with the use of Global Catalog of the trusted domain.
+
+Example:
+
+1. Create group for the trusted domain admins' mapping and their local POSIX group:
+
+ ipa group-add --desc='<ad.domain> admins external map' ad_admins_external --external
+ ipa group-add --desc='<ad.domain> admins' ad_admins
+
+2. Add security identifier of Domain Admins of the <ad.domain> to the ad_admins_external
+ group:
+
+ ipa group-add-member ad_admins_external --external 'AD\\Domain Admins'
+
+3. Allow members of ad_admins_external group to be associated with ad_admins POSIX group:
+
+ ipa group-add-member ad_admins --groups ad_admins_external
+
+4. List members of external members of ad_admins_external group to see their SIDs:
+
+ ipa group-show ad_admins_external
+""")
+
+register = Registry()
+
+PROTECTED_GROUPS = (u'admins', u'trust admins', u'default smb group')
+
+
+@register()
+class group(LDAPObject):
+ """
+ Group object.
+ """
+ container_dn = api.env.container_group
+ object_name = _('group')
+ object_name_plural = _('groups')
+ object_class = ['ipausergroup']
+ object_class_config = 'ipagroupobjectclasses'
+ possible_objectclasses = ['posixGroup', 'mepManagedEntry', 'ipaExternalGroup']
+ permission_filter_objectclasses = ['posixgroup', 'ipausergroup']
+ search_attributes_config = 'ipagroupsearchfields'
+ default_attributes = [
+ 'cn', 'description', 'gidnumber', 'member', 'memberof',
+ 'memberindirect', 'memberofindirect', 'ipaexternalmember',
+ ]
+ uuid_attribute = 'ipauniqueid'
+ attribute_members = {
+ 'member': ['user', 'group'],
+ 'memberof': ['group', 'netgroup', 'role', 'hbacrule', 'sudorule'],
+ 'memberindirect': ['user', 'group'],
+ 'memberofindirect': ['group', 'netgroup', 'role', 'hbacrule',
+ 'sudorule'],
+ }
+ rdn_is_primary_key = True
+ managed_permissions = {
+ 'System: Read Groups': {
+ 'replaces_global_anonymous_aci': True,
+ 'ipapermbindruletype': 'anonymous',
+ 'ipapermright': {'read', 'search', 'compare'},
+ 'ipapermdefaultattr': {
+ 'businesscategory', 'cn', 'description', 'gidnumber',
+ 'ipaexternalmember', 'ipauniqueid', 'mepmanagedby', 'o',
+ 'objectclass', 'ou', 'owner', 'seealso',
+ 'ipantsecurityidentifier'
+ },
+ },
+ 'System: Read Group Membership': {
+ 'replaces_global_anonymous_aci': True,
+ 'ipapermbindruletype': 'all',
+ 'ipapermright': {'read', 'search', 'compare'},
+ 'ipapermdefaultattr': {
+ 'member', 'memberof', 'memberuid', 'memberuser', 'memberhost',
+ },
+ },
+ 'System: Add Groups': {
+ 'ipapermright': {'add'},
+ 'replaces': [
+ '(target = "ldap:///cn=*,cn=groups,cn=accounts,$SUFFIX")(version 3.0;acl "permission:Add Groups";allow (add) groupdn = "ldap:///cn=Add Groups,cn=permissions,cn=pbac,$SUFFIX";)',
+ ],
+ 'default_privileges': {'Group Administrators'},
+ },
+ 'System: Modify Group Membership': {
+ 'ipapermright': {'write'},
+ 'ipapermtargetfilter': [
+ '(objectclass=ipausergroup)',
+ '(!(cn=admins))',
+ ],
+ 'ipapermdefaultattr': {'member'},
+ 'replaces': [
+ '(targetattr = "member")(target = "ldap:///cn=*,cn=groups,cn=accounts,$SUFFIX")(version 3.0;acl "permission:Modify Group membership";allow (write) groupdn = "ldap:///cn=Modify Group membership,cn=permissions,cn=pbac,$SUFFIX";)',
+ '(targetfilter = "(!(cn=admins))")(targetattr = "member")(target = "ldap:///cn=*,cn=groups,cn=accounts,$SUFFIX")(version 3.0;acl "permission:Modify Group membership";allow (write) groupdn = "ldap:///cn=Modify Group membership,cn=permissions,cn=pbac,$SUFFIX";)',
+ ],
+ 'default_privileges': {
+ 'Group Administrators', 'Modify Group membership'
+ },
+ },
+ 'System: Modify Groups': {
+ 'ipapermright': {'write'},
+ 'ipapermdefaultattr': {
+ 'cn', 'description', 'gidnumber', 'ipauniqueid',
+ 'mepmanagedby', 'objectclass'
+ },
+ 'replaces': [
+ '(targetattr = "cn || description || gidnumber || objectclass || mepmanagedby || ipauniqueid")(target = "ldap:///cn=*,cn=groups,cn=accounts,$SUFFIX")(version 3.0;acl "permission:Modify Groups";allow (write) groupdn = "ldap:///cn=Modify Groups,cn=permissions,cn=pbac,$SUFFIX";)',
+ ],
+ 'default_privileges': {'Group Administrators'},
+ },
+ 'System: Remove Groups': {
+ 'ipapermright': {'delete'},
+ 'replaces': [
+ '(target = "ldap:///cn=*,cn=groups,cn=accounts,$SUFFIX")(version 3.0;acl "permission:Remove Groups";allow (delete) groupdn = "ldap:///cn=Remove Groups,cn=permissions,cn=pbac,$SUFFIX";)',
+ ],
+ 'default_privileges': {'Group Administrators'},
+ },
+ 'System: Read Group Compat Tree': {
+ 'non_object': True,
+ 'ipapermbindruletype': 'anonymous',
+ 'ipapermlocation': api.env.basedn,
+ 'ipapermtarget': DN('cn=groups', 'cn=compat', api.env.basedn),
+ 'ipapermright': {'read', 'search', 'compare'},
+ 'ipapermdefaultattr': {
+ 'objectclass', 'cn', 'memberuid', 'gidnumber',
+ },
+ },
+ 'System: Read Group Views Compat Tree': {
+ 'non_object': True,
+ 'ipapermbindruletype': 'anonymous',
+ 'ipapermlocation': api.env.basedn,
+ 'ipapermtarget': DN('cn=groups', 'cn=*', 'cn=views', 'cn=compat', api.env.basedn),
+ 'ipapermright': {'read', 'search', 'compare'},
+ 'ipapermdefaultattr': {
+ 'objectclass', 'cn', 'memberuid', 'gidnumber',
+ },
+ },
+ }
+
+ label = _('User Groups')
+ label_singular = _('User Group')
+
+ takes_params = (
+ Str('cn',
+ pattern='^[a-zA-Z0-9_.][a-zA-Z0-9_.-]{0,252}[a-zA-Z0-9_.$-]?$',
+ pattern_errmsg='may only include letters, numbers, _, -, . and $',
+ maxlength=255,
+ cli_name='group_name',
+ label=_('Group name'),
+ primary_key=True,
+ normalizer=lambda value: value.lower(),
+ ),
+ Str('description?',
+ cli_name='desc',
+ label=_('Description'),
+ doc=_('Group description'),
+ ),
+ Int('gidnumber?',
+ cli_name='gid',
+ label=_('GID'),
+ doc=_('GID (use this option to set it manually)'),
+ minvalue=1,
+ ),
+ )
+
+
+ipaexternalmember_param = Str('ipaexternalmember*',
+ cli_name='external',
+ label=_('External member'),
+ doc=_('Members of a trusted domain in DOM\\name or name@domain form'),
+ flags=['no_create', 'no_update', 'no_search'],
+ )
+
+
+@register()
+class group_add(LDAPCreate):
+ __doc__ = _('Create a new group.')
+
+ msg_summary = _('Added group "%(value)s"')
+
+ takes_options = LDAPCreate.takes_options + (
+ Flag('nonposix',
+ cli_name='nonposix',
+ doc=_('Create as a non-POSIX group'),
+ default=False,
+ ),
+ Flag('external',
+ cli_name='external',
+ doc=_('Allow adding external non-IPA members from trusted domains'),
+ default=False,
+ ),
+ )
+
+ def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options):
+ # As both 'external' and 'nonposix' options have default= set for
+ # them, they will always be present in options dict, thus we can
+ # safely reference the values
+ assert isinstance(dn, DN)
+ if options['external']:
+ entry_attrs['objectclass'].append('ipaexternalgroup')
+ if 'gidnumber' in options:
+ raise errors.MutuallyExclusiveError(reason=_('gid cannot be set for external group'))
+ elif not options['nonposix']:
+ entry_attrs['objectclass'].append('posixgroup')
+ if not 'gidnumber' in options:
+ entry_attrs['gidnumber'] = baseldap.DNA_MAGIC
+ return dn
+
+
+@register()
+class group_del(LDAPDelete):
+ __doc__ = _('Delete group.')
+
+ msg_summary = _('Deleted group "%(value)s"')
+
+ def pre_callback(self, ldap, dn, *keys, **options):
+ assert isinstance(dn, DN)
+ config = ldap.get_ipa_config()
+ def_primary_group = config.get('ipadefaultprimarygroup', '')
+ def_primary_group_dn = group_dn = self.obj.get_dn(def_primary_group)
+ if dn == def_primary_group_dn:
+ raise errors.DefaultGroupError()
+ group_attrs = self.obj.methods.show(
+ self.obj.get_primary_key_from_dn(dn), all=True
+ )['result']
+ if keys[0] in PROTECTED_GROUPS:
+ raise errors.ProtectedEntryError(label=_(u'group'), key=keys[0],
+ reason=_(u'privileged group'))
+ if 'mepmanagedby' in group_attrs:
+ raise errors.ManagedGroupError()
+
+ # Remove any ID overrides tied with this group
+ remove_ipaobject_overrides(ldap, self.obj.api, dn)
+
+ return dn
+
+ def post_callback(self, ldap, dn, *keys, **options):
+ assert isinstance(dn, DN)
+ try:
+ api.Command['pwpolicy_del'](keys[-1])
+ except errors.NotFound:
+ pass
+
+ return True
+
+
+@register()
+class group_mod(LDAPUpdate):
+ __doc__ = _('Modify a group.')
+
+ msg_summary = _('Modified group "%(value)s"')
+
+ takes_options = LDAPUpdate.takes_options + (
+ Flag('posix',
+ cli_name='posix',
+ doc=_('change to a POSIX group'),
+ ),
+ Flag('external',
+ cli_name='external',
+ doc=_('change to support external non-IPA members from trusted domains'),
+ default=False,
+ ),
+ )
+
+ def pre_callback(self, ldap, dn, entry_attrs, *keys, **options):
+ assert isinstance(dn, DN)
+
+ is_protected_group = keys[-1] in PROTECTED_GROUPS
+
+ if 'rename' in options or 'cn' in entry_attrs:
+ if is_protected_group:
+ raise errors.ProtectedEntryError(label=u'group', key=keys[-1],
+ reason=u'Cannot be renamed')
+
+ if ('posix' in options and options['posix']) or 'gidnumber' in options:
+ old_entry_attrs = ldap.get_entry(dn, ['objectclass'])
+ dn = old_entry_attrs.dn
+ if 'ipaexternalgroup' in old_entry_attrs['objectclass']:
+ raise errors.ExternalGroupViolation()
+ if 'posixgroup' in old_entry_attrs['objectclass']:
+ if options['posix']:
+ raise errors.AlreadyPosixGroup()
+ else:
+ old_entry_attrs['objectclass'].append('posixgroup')
+ entry_attrs['objectclass'] = old_entry_attrs['objectclass']
+ if not 'gidnumber' in options:
+ entry_attrs['gidnumber'] = baseldap.DNA_MAGIC
+
+ if options['external']:
+ if is_protected_group:
+ raise errors.ProtectedEntryError(label=u'group', key=keys[-1],
+ reason=u'Cannot support external non-IPA members')
+ old_entry_attrs = ldap.get_entry(dn, ['objectclass'])
+ dn = old_entry_attrs.dn
+ if 'posixgroup' in old_entry_attrs['objectclass']:
+ raise errors.PosixGroupViolation()
+ if 'ipaexternalgroup' in old_entry_attrs['objectclass']:
+ raise errors.AlreadyExternalGroup()
+ else:
+ old_entry_attrs['objectclass'].append('ipaexternalgroup')
+ entry_attrs['objectclass'] = old_entry_attrs['objectclass']
+
+ # Can't check for this in a validator because we lack context
+ if 'gidnumber' in options and options['gidnumber'] is None:
+ raise errors.RequirementError(name='gidnumber')
+ return dn
+
+ def exc_callback(self, keys, options, exc, call_func, *call_args, **call_kwargs):
+ # Check again for GID requirement in case someone tried to clear it
+ # using --setattr.
+ if call_func.__name__ == 'update_entry':
+ if isinstance(exc, errors.ObjectclassViolation):
+ if 'gidNumber' in exc.message and 'posixGroup' in exc.message:
+ raise errors.RequirementError(name='gidnumber')
+ raise exc
+
+
+@register()
+class group_find(LDAPSearch):
+ __doc__ = _('Search for groups.')
+
+ member_attributes = ['member', 'memberof']
+
+ msg_summary = ngettext(
+ '%(count)d group matched', '%(count)d groups matched', 0
+ )
+
+ takes_options = LDAPSearch.takes_options + (
+ Flag('private',
+ cli_name='private',
+ doc=_('search for private groups'),
+ ),
+ Flag('posix',
+ cli_name='posix',
+ doc=_('search for POSIX groups'),
+ ),
+ Flag('external',
+ cli_name='external',
+ doc=_('search for groups with support of external non-IPA members from trusted domains'),
+ ),
+ Flag('nonposix',
+ cli_name='nonposix',
+ doc=_('search for non-POSIX groups'),
+ ),
+ )
+
+ def pre_callback(self, ldap, filter, attrs_list, base_dn, scope,
+ criteria=None, **options):
+ assert isinstance(base_dn, DN)
+
+ # filter groups by pseudo type
+ filters = []
+ if options['posix']:
+ search_kw = {'objectclass': ['posixGroup']}
+ filters.append(ldap.make_filter(search_kw, rules=ldap.MATCH_ALL))
+ if options['external']:
+ search_kw = {'objectclass': ['ipaExternalGroup']}
+ filters.append(ldap.make_filter(search_kw, rules=ldap.MATCH_ALL))
+ if options['nonposix']:
+ search_kw = {'objectclass': ['posixGroup' , 'ipaExternalGroup']}
+ filters.append(ldap.make_filter(search_kw, rules=ldap.MATCH_NONE))
+
+ # if looking for private groups, we need to create a new search filter,
+ # because private groups have different object classes
+ if options['private']:
+ # filter based on options, oflt
+ search_kw = self.args_options_2_entry(**options)
+ search_kw['objectclass'] = ['posixGroup', 'mepManagedEntry']
+ oflt = ldap.make_filter(search_kw, rules=ldap.MATCH_ALL)
+
+ # filter based on 'criteria' argument
+ search_kw = {}
+ config = ldap.get_ipa_config()
+ attrs = config.get(self.obj.search_attributes_config, [])
+ if len(attrs) == 1 and isinstance(attrs[0], six.string_types):
+ search_attrs = attrs[0].split(',')
+ for a in search_attrs:
+ search_kw[a] = criteria
+ cflt = ldap.make_filter(search_kw, exact=False)
+
+ filter = ldap.combine_filters((oflt, cflt), rules=ldap.MATCH_ALL)
+ elif filters:
+ filters.append(filter)
+ filter = ldap.combine_filters(filters, rules=ldap.MATCH_ALL)
+ return (filter, base_dn, scope)
+
+
+@register()
+class group_show(LDAPRetrieve):
+ __doc__ = _('Display information about a named group.')
+ has_output_params = LDAPRetrieve.has_output_params + (ipaexternalmember_param,)
+ def post_callback(self, ldap, dn, entry_attrs, *keys, **options):
+ assert isinstance(dn, DN)
+ if ('ipaexternalmember' in entry_attrs and
+ len(entry_attrs['ipaexternalmember']) > 0 and
+ 'trust_resolve' in self.Command and
+ not options.get('raw', False)):
+ sids = entry_attrs['ipaexternalmember']
+ result = self.Command.trust_resolve(sids=sids)
+ for entry in result['result']:
+ try:
+ idx = sids.index(entry['sid'][0])
+ sids[idx] = entry['name'][0]
+ except ValueError:
+ pass
+ return dn
+
+
+@register()
+class group_add_member(LDAPAddMember):
+ __doc__ = _('Add members to a group.')
+
+ takes_options = (ipaexternalmember_param,)
+
+ def post_callback(self, ldap, completed, failed, dn, entry_attrs, *keys, **options):
+ assert isinstance(dn, DN)
+ result = (completed, dn)
+ if 'ipaexternalmember' in options:
+ if not _dcerpc_bindings_installed:
+ raise errors.NotFound(reason=_('Cannot perform external member validation without '
+ 'Samba 4 support installed. Make sure you have installed '
+ 'server-trust-ad sub-package of IPA on the server'))
+ domain_validator = ipaserver.dcerpc.DomainValidator(self.api)
+ if not domain_validator.is_configured():
+ raise errors.NotFound(reason=_('Cannot perform join operation without own domain configured. '
+ 'Make sure you have run ipa-adtrust-install on the IPA server first'))
+ sids = []
+ failed_sids = []
+ for sid in options['ipaexternalmember']:
+ if domain_validator.is_trusted_sid_valid(sid):
+ sids.append(sid)
+ else:
+ try:
+ actual_sid = domain_validator.get_trusted_domain_object_sid(sid)
+ except errors.PublicError as e:
+ failed_sids.append((sid, e.strerror))
+ else:
+ sids.append(actual_sid)
+ restore = []
+ if 'member' in failed and 'group' in failed['member']:
+ restore = failed['member']['group']
+ failed['member']['group'] = list((id, id) for id in sids)
+ result = add_external_post_callback(ldap, dn, entry_attrs,
+ failed=failed,
+ completed=completed,
+ memberattr='member',
+ membertype='group',
+ externalattr='ipaexternalmember',
+ normalize=False)
+ failed['member']['group'] += restore + failed_sids
+ return result
+
+
+@register()
+class group_remove_member(LDAPRemoveMember):
+ __doc__ = _('Remove members from a group.')
+
+ takes_options = (ipaexternalmember_param,)
+
+ def pre_callback(self, ldap, dn, found, not_found, *keys, **options):
+ assert isinstance(dn, DN)
+ if keys[0] in PROTECTED_GROUPS and 'user' in options:
+ protected_group_name = keys[0]
+ result = api.Command.group_show(protected_group_name)
+ users_left = set(result['result'].get('member_user', []))
+ users_deleted = set(options['user'])
+ if users_left.issubset(users_deleted):
+ raise errors.LastMemberError(key=sorted(users_deleted)[0],
+ label=_(u'group'), container=protected_group_name)
+ return dn
+
+ def post_callback(self, ldap, completed, failed, dn, entry_attrs, *keys, **options):
+ assert isinstance(dn, DN)
+ result = (completed, dn)
+ if 'ipaexternalmember' in options:
+ if not _dcerpc_bindings_installed:
+ raise errors.NotFound(reason=_('Cannot perform external member validation without '
+ 'Samba 4 support installed. Make sure you have installed '
+ 'server-trust-ad sub-package of IPA on the server'))
+ domain_validator = ipaserver.dcerpc.DomainValidator(self.api)
+ if not domain_validator.is_configured():
+ raise errors.NotFound(reason=_('Cannot perform join operation without own domain configured. '
+ 'Make sure you have run ipa-adtrust-install on the IPA server first'))
+ sids = []
+ failed_sids = []
+ for sid in options['ipaexternalmember']:
+ if domain_validator.is_trusted_sid_valid(sid):
+ sids.append(sid)
+ else:
+ try:
+ actual_sid = domain_validator.get_trusted_domain_object_sid(sid)
+ except errors.PublicError as e:
+ failed_sids.append((sid, unicode(e)))
+ else:
+ sids.append(actual_sid)
+ restore = []
+ if 'member' in failed and 'group' in failed['member']:
+ restore = failed['member']['group']
+ failed['member']['group'] = list((id, id) for id in sids)
+ result = remove_external_post_callback(ldap, dn, entry_attrs,
+ failed=failed,
+ completed=completed,
+ memberattr='member',
+ membertype='group',
+ externalattr='ipaexternalmember',
+ )
+ failed['member']['group'] += restore + failed_sids
+ return result
+
+
+@register()
+class group_detach(LDAPQuery):
+ __doc__ = _('Detach a managed group from a user.')
+
+ has_output = output.standard_value
+ msg_summary = _('Detached group "%(value)s" from user "%(value)s"')
+
+ def execute(self, *keys, **options):
+ """
+ This requires updating both the user and the group. We first need to
+ verify that both the user and group can be updated, then we go
+ about our work. We don't want a situation where only the user or
+ group can be modified and we're left in a bad state.
+ """
+ ldap = self.obj.backend
+
+ group_dn = self.obj.get_dn(*keys, **options)
+ user_dn = self.api.Object['user'].get_dn(*keys)
+
+ try:
+ user_attrs = ldap.get_entry(user_dn)
+ except errors.NotFound:
+ self.obj.handle_not_found(*keys)
+ is_managed = self.obj.has_objectclass(user_attrs['objectclass'], 'mepmanagedentry')
+ if (not ldap.can_write(user_dn, "objectclass") or
+ not (ldap.can_write(user_dn, "mepManagedEntry")) and is_managed):
+ raise errors.ACIError(info=_('not allowed to modify user entries'))
+
+ group_attrs = ldap.get_entry(group_dn)
+ is_managed = self.obj.has_objectclass(group_attrs['objectclass'], 'mepmanagedby')
+ if (not ldap.can_write(group_dn, "objectclass") or
+ not (ldap.can_write(group_dn, "mepManagedBy")) and is_managed):
+ raise errors.ACIError(info=_('not allowed to modify group entries'))
+
+ objectclasses = user_attrs['objectclass']
+ try:
+ i = objectclasses.index('mepOriginEntry')
+ del objectclasses[i]
+ user_attrs['mepManagedEntry'] = None
+ ldap.update_entry(user_attrs)
+ except ValueError:
+ # Somehow the user isn't managed, let it pass for now. We'll
+ # let the group throw "Not managed".
+ pass
+
+ group_attrs = ldap.get_entry(group_dn)
+ objectclasses = group_attrs['objectclass']
+ try:
+ i = objectclasses.index('mepManagedEntry')
+ except ValueError:
+ # this should never happen
+ raise errors.NotFound(reason=_('Not a managed group'))
+ del objectclasses[i]
+
+ # Make sure the resulting group has the default group objectclasses
+ config = ldap.get_ipa_config()
+ def_objectclass = config.get(
+ self.obj.object_class_config, objectclasses
+ )
+ objectclasses = list(set(def_objectclass + objectclasses))
+
+ group_attrs['mepManagedBy'] = None
+ group_attrs['objectclass'] = objectclasses
+ ldap.update_entry(group_attrs)
+
+ return dict(
+ result=True,
+ value=pkey_to_value(keys[0], options),
+ )
+
diff --git a/ipaserver/plugins/hbac.py b/ipaserver/plugins/hbac.py
new file mode 100644
index 000000000..59defc1f2
--- /dev/null
+++ b/ipaserver/plugins/hbac.py
@@ -0,0 +1,7 @@
+#
+# Copyright (C) 2016 FreeIPA Contributors see COPYING for license
+#
+
+from ipalib.text import _
+
+__doc__ = _('Host-based access control commands')
diff --git a/ipaserver/plugins/hbacrule.py b/ipaserver/plugins/hbacrule.py
new file mode 100644
index 000000000..7d3e4851a
--- /dev/null
+++ b/ipaserver/plugins/hbacrule.py
@@ -0,0 +1,605 @@
+# Authors:
+# Pavel Zuna <pzuna@redhat.com>
+#
+# Copyright (C) 2009 Red Hat
+# see file 'COPYING' for use and warranty information
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+from ipalib import api, errors
+from ipalib import AccessTime, Str, StrEnum, Bool
+from ipalib.plugable import Registry
+from .baseldap import (
+ pkey_to_value,
+ external_host_param,
+ LDAPObject,
+ LDAPCreate,
+ LDAPDelete,
+ LDAPRetrieve,
+ LDAPUpdate,
+ LDAPSearch,
+ LDAPQuery,
+ LDAPAddMember,
+ LDAPRemoveMember)
+from ipalib import _, ngettext
+from ipalib import output
+from ipapython.dn import DN
+
+__doc__ = _("""
+Host-based access control
+
+Control who can access what services on what hosts. You
+can use HBAC to control which users or groups can
+access a service, or group of services, on a target host.
+
+You can also specify a category of users and target hosts.
+This is currently limited to "all", but might be expanded in the
+future.
+
+Target hosts in HBAC rules must be hosts managed by IPA.
+
+The available services and groups of services are controlled by the
+hbacsvc and hbacsvcgroup plug-ins respectively.
+
+EXAMPLES:
+
+ Create a rule, "test1", that grants all users access to the host "server" from
+ anywhere:
+ ipa hbacrule-add --usercat=all test1
+ ipa hbacrule-add-host --hosts=server.example.com test1
+
+ Display the properties of a named HBAC rule:
+ ipa hbacrule-show test1
+
+ Create a rule for a specific service. This lets the user john access
+ the sshd service on any machine from any machine:
+ ipa hbacrule-add --hostcat=all john_sshd
+ ipa hbacrule-add-user --users=john john_sshd
+ ipa hbacrule-add-service --hbacsvcs=sshd john_sshd
+
+ Create a rule for a new service group. This lets the user john access
+ the FTP service on any machine from any machine:
+ ipa hbacsvcgroup-add ftpers
+ ipa hbacsvc-add sftp
+ ipa hbacsvcgroup-add-member --hbacsvcs=ftp --hbacsvcs=sftp ftpers
+ ipa hbacrule-add --hostcat=all john_ftp
+ ipa hbacrule-add-user --users=john john_ftp
+ ipa hbacrule-add-service --hbacsvcgroups=ftpers john_ftp
+
+ Disable a named HBAC rule:
+ ipa hbacrule-disable test1
+
+ Remove a named HBAC rule:
+ ipa hbacrule-del allow_server
+""")
+
+register = Registry()
+
+# AccessTime support is being removed for now.
+#
+# You can also control the times that the rule is active.
+#
+# The access time(s) of a host are cumulative and are not guaranteed to be
+# applied in the order displayed.
+#
+# Specify that the rule "test1" be active every day between 0800 and 1400:
+# ipa hbacrule-add-accesstime --time='periodic daily 0800-1400' test1
+#
+# Specify that the rule "test1" be active once, from 10:32 until 10:33 on
+# December 16, 2010:
+# ipa hbacrule-add-accesstime --time='absolute 201012161032 ~ 201012161033' test1
+
+
+topic = 'hbac'
+
+def validate_type(ugettext, type):
+ if type.lower() == 'deny':
+ raise errors.ValidationError(name='type', error=_('The deny type has been deprecated.'))
+
+def is_all(options, attribute):
+ """
+ See if options[attribute] is lower-case 'all' in a safe way.
+ """
+ if attribute in options and options[attribute] is not None:
+ if type(options[attribute]) in (list, tuple):
+ value = options[attribute][0].lower()
+ else:
+ value = options[attribute].lower()
+ if value == 'all':
+ return True
+ else:
+ return False
+
+
+@register()
+class hbacrule(LDAPObject):
+ """
+ HBAC object.
+ """
+ container_dn = api.env.container_hbac
+ object_name = _('HBAC rule')
+ object_name_plural = _('HBAC rules')
+ object_class = ['ipaassociation', 'ipahbacrule']
+ permission_filter_objectclasses = ['ipahbacrule']
+ default_attributes = [
+ 'cn', 'ipaenabledflag',
+ 'description', 'usercategory', 'hostcategory',
+ 'servicecategory', 'ipaenabledflag',
+ 'memberuser', 'sourcehost', 'memberhost', 'memberservice',
+ 'externalhost',
+ ]
+ uuid_attribute = 'ipauniqueid'
+ rdn_attribute = 'ipauniqueid'
+ attribute_members = {
+ 'memberuser': ['user', 'group'],
+ 'memberhost': ['host', 'hostgroup'],
+ 'sourcehost': ['host', 'hostgroup'],
+ 'memberservice': ['hbacsvc', 'hbacsvcgroup'],
+ }
+ managed_permissions = {
+ 'System: Read HBAC Rules': {
+ 'replaces_global_anonymous_aci': True,
+ 'ipapermbindruletype': 'all',
+ 'ipapermright': {'read', 'search', 'compare'},
+ 'ipapermdefaultattr': {
+ 'accessruletype', 'accesstime', 'cn', 'description',
+ 'externalhost', 'hostcategory', 'ipaenabledflag',
+ 'ipauniqueid', 'memberhost', 'memberservice', 'memberuser',
+ 'servicecategory', 'sourcehost', 'sourcehostcategory',
+ 'usercategory', 'objectclass', 'member',
+ },
+ },
+ 'System: Add HBAC Rule': {
+ 'ipapermright': {'add'},
+ 'replaces': [
+ '(target = "ldap:///ipauniqueid=*,cn=hbac,$SUFFIX")(version 3.0;acl "permission:Add HBAC rule";allow (add) groupdn = "ldap:///cn=Add HBAC rule,cn=permissions,cn=pbac,$SUFFIX";)',
+ ],
+ 'default_privileges': {'HBAC Administrator'},
+ },
+ 'System: Delete HBAC Rule': {
+ 'ipapermright': {'delete'},
+ 'replaces': [
+ '(target = "ldap:///ipauniqueid=*,cn=hbac,$SUFFIX")(version 3.0;acl "permission:Delete HBAC rule";allow (delete) groupdn = "ldap:///cn=Delete HBAC rule,cn=permissions,cn=pbac,$SUFFIX";)',
+ ],
+ 'default_privileges': {'HBAC Administrator'},
+ },
+ 'System: Manage HBAC Rule Membership': {
+ 'ipapermright': {'write'},
+ 'ipapermdefaultattr': {
+ 'externalhost', 'memberhost', 'memberservice', 'memberuser'
+ },
+ 'replaces': [
+ '(targetattr = "memberuser || externalhost || memberservice || memberhost")(target = "ldap:///ipauniqueid=*,cn=hbac,$SUFFIX")(version 3.0;acl "permission:Manage HBAC rule membership";allow (write) groupdn = "ldap:///cn=Manage HBAC rule membership,cn=permissions,cn=pbac,$SUFFIX";)',
+ ],
+ 'default_privileges': {'HBAC Administrator'},
+ },
+ 'System: Modify HBAC Rule': {
+ 'ipapermright': {'write'},
+ 'ipapermdefaultattr': {
+ 'accessruletype', 'accesstime', 'cn', 'description',
+ 'hostcategory', 'ipaenabledflag', 'servicecategory',
+ 'sourcehost', 'sourcehostcategory', 'usercategory'
+ },
+ 'replaces': [
+ '(targetattr = "servicecategory || sourcehostcategory || cn || description || ipaenabledflag || accesstime || usercategory || hostcategory || accessruletype || sourcehost")(target = "ldap:///ipauniqueid=*,cn=hbac,$SUFFIX")(version 3.0;acl "permission:Modify HBAC rule";allow (write) groupdn = "ldap:///cn=Modify HBAC rule,cn=permissions,cn=pbac,$SUFFIX";)',
+ ],
+ 'default_privileges': {'HBAC Administrator'},
+ },
+ }
+
+ label = _('HBAC Rules')
+ label_singular = _('HBAC Rule')
+
+ takes_params = (
+ Str('cn',
+ cli_name='name',
+ label=_('Rule name'),
+ primary_key=True,
+ ),
+ StrEnum('accessruletype', validate_type,
+ cli_name='type',
+ doc=_('Rule type (allow)'),
+ label=_('Rule type'),
+ values=(u'allow', u'deny'),
+ default=u'allow',
+ autofill=True,
+ exclude='webui',
+ flags=['no_option', 'no_output'],
+ ),
+ # FIXME: {user,host,service}categories should expand in the future
+ StrEnum('usercategory?',
+ cli_name='usercat',
+ label=_('User category'),
+ doc=_('User category the rule applies to'),
+ values=(u'all', ),
+ ),
+ StrEnum('hostcategory?',
+ cli_name='hostcat',
+ label=_('Host category'),
+ doc=_('Host category the rule applies to'),
+ values=(u'all', ),
+ ),
+ StrEnum('sourcehostcategory?',
+ deprecated=True,
+ cli_name='srchostcat',
+ label=_('Source host category'),
+ doc=_('Source host category the rule applies to'),
+ values=(u'all', ),
+ flags={'no_option'},
+ ),
+ StrEnum('servicecategory?',
+ cli_name='servicecat',
+ label=_('Service category'),
+ doc=_('Service category the rule applies to'),
+ values=(u'all', ),
+ ),
+# AccessTime('accesstime?',
+# cli_name='time',
+# label=_('Access time'),
+# ),
+ Str('description?',
+ cli_name='desc',
+ label=_('Description'),
+ ),
+ Bool('ipaenabledflag?',
+ label=_('Enabled'),
+ flags=['no_option'],
+ ),
+ Str('memberuser_user?',
+ label=_('Users'),
+ flags=['no_create', 'no_update', 'no_search'],
+ ),
+ Str('memberuser_group?',
+ label=_('User Groups'),
+ flags=['no_create', 'no_update', 'no_search'],
+ ),
+ Str('memberhost_host?',
+ label=_('Hosts'),
+ flags=['no_create', 'no_update', 'no_search'],
+ ),
+ Str('memberhost_hostgroup?',
+ label=_('Host Groups'),
+ flags=['no_create', 'no_update', 'no_search'],
+ ),
+ Str('sourcehost_host?',
+ deprecated=True,
+ label=_('Source Hosts'),
+ flags=['no_create', 'no_update', 'no_search', 'no_option'],
+ ),
+ Str('sourcehost_hostgroup?',
+ deprecated=True,
+ label=_('Source Host Groups'),
+ flags=['no_create', 'no_update', 'no_search', 'no_option'],
+ ),
+ Str('memberservice_hbacsvc?',
+ label=_('Services'),
+ flags=['no_create', 'no_update', 'no_search'],
+ ),
+ Str('memberservice_hbacsvcgroup?',
+ label=_('Service Groups'),
+ flags=['no_create', 'no_update', 'no_search'],
+ ),
+ external_host_param,
+ )
+
+
+
+@register()
+class hbacrule_add(LDAPCreate):
+ __doc__ = _('Create a new HBAC rule.')
+
+ msg_summary = _('Added HBAC rule "%(value)s"')
+
+ def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options):
+ assert isinstance(dn, DN)
+ # HBAC rules are enabled by default
+ entry_attrs['ipaenabledflag'] = 'TRUE'
+ return dn
+
+
+
+@register()
+class hbacrule_del(LDAPDelete):
+ __doc__ = _('Delete an HBAC rule.')
+
+ msg_summary = _('Deleted HBAC rule "%(value)s"')
+
+ def pre_callback(self, ldap, dn, *keys, **options):
+ assert isinstance(dn, DN)
+ kw = dict(seealso=keys[0])
+ _entries = api.Command.selinuxusermap_find(None, **kw)
+ if _entries['count']:
+ raise errors.DependentEntry(key=keys[0], label=self.api.Object['selinuxusermap'].label_singular, dependent=_entries['result'][0]['cn'][0])
+
+ return dn
+
+
+
+@register()
+class hbacrule_mod(LDAPUpdate):
+ __doc__ = _('Modify an HBAC rule.')
+
+ msg_summary = _('Modified HBAC rule "%(value)s"')
+
+ def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options):
+ assert isinstance(dn, DN)
+ try:
+ entry_attrs = ldap.get_entry(dn, attrs_list)
+ dn = entry_attrs.dn
+ except errors.NotFound:
+ self.obj.handle_not_found(*keys)
+
+ if is_all(options, 'usercategory') and 'memberuser' in entry_attrs:
+ raise errors.MutuallyExclusiveError(reason=_("user category cannot be set to 'all' while there are allowed users"))
+ if is_all(options, 'hostcategory') and 'memberhost' in entry_attrs:
+ raise errors.MutuallyExclusiveError(reason=_("host category cannot be set to 'all' while there are allowed hosts"))
+ if is_all(options, 'servicecategory') and 'memberservice' in entry_attrs:
+ raise errors.MutuallyExclusiveError(reason=_("service category cannot be set to 'all' while there are allowed services"))
+ return dn
+
+
+
+@register()
+class hbacrule_find(LDAPSearch):
+ __doc__ = _('Search for HBAC rules.')
+
+ msg_summary = ngettext(
+ '%(count)d HBAC rule matched', '%(count)d HBAC rules matched', 0
+ )
+
+
+
+@register()
+class hbacrule_show(LDAPRetrieve):
+ __doc__ = _('Display the properties of an HBAC rule.')
+
+
+
+@register()
+class hbacrule_enable(LDAPQuery):
+ __doc__ = _('Enable an HBAC rule.')
+
+ msg_summary = _('Enabled HBAC rule "%(value)s"')
+ has_output = output.standard_value
+
+ def execute(self, cn, **options):
+ ldap = self.obj.backend
+
+ dn = self.obj.get_dn(cn)
+ try:
+ entry_attrs = ldap.get_entry(dn, ['ipaenabledflag'])
+ except errors.NotFound:
+ self.obj.handle_not_found(cn)
+
+ entry_attrs['ipaenabledflag'] = ['TRUE']
+
+ try:
+ ldap.update_entry(entry_attrs)
+ except errors.EmptyModlist:
+ pass
+
+ return dict(
+ result=True,
+ value=pkey_to_value(cn, options),
+ )
+
+
+
+@register()
+class hbacrule_disable(LDAPQuery):
+ __doc__ = _('Disable an HBAC rule.')
+
+ msg_summary = _('Disabled HBAC rule "%(value)s"')
+ has_output = output.standard_value
+
+ def execute(self, cn, **options):
+ ldap = self.obj.backend
+
+ dn = self.obj.get_dn(cn)
+ try:
+ entry_attrs = ldap.get_entry(dn, ['ipaenabledflag'])
+ except errors.NotFound:
+ self.obj.handle_not_found(cn)
+
+ entry_attrs['ipaenabledflag'] = ['FALSE']
+
+ try:
+ ldap.update_entry(entry_attrs)
+ except errors.EmptyModlist:
+ pass
+
+ return dict(
+ result=True,
+ value=pkey_to_value(cn, options),
+ )
+
+
+# @register()
+class hbacrule_add_accesstime(LDAPQuery):
+ """
+ Add an access time to an HBAC rule.
+ """
+
+ takes_options = (
+ AccessTime('accesstime',
+ cli_name='time',
+ label=_('Access time'),
+ ),
+ )
+
+ def execute(self, cn, **options):
+ ldap = self.obj.backend
+
+ dn = self.obj.get_dn(cn)
+
+ entry_attrs = ldap.get_entry(dn, ['accesstime'])
+ entry_attrs.setdefault('accesstime', []).append(
+ options['accesstime']
+ )
+ try:
+ ldap.update_entry(entry_attrs)
+ except errors.EmptyModlist:
+ pass
+ except errors.NotFound:
+ self.obj.handle_not_found(cn)
+
+ return dict(result=True)
+
+
+# @register()
+class hbacrule_remove_accesstime(LDAPQuery):
+ """
+ Remove access time to HBAC rule.
+ """
+ takes_options = (
+ AccessTime('accesstime?',
+ cli_name='time',
+ label=_('Access time'),
+ ),
+ )
+
+ def execute(self, cn, **options):
+ ldap = self.obj.backend
+
+ dn = self.obj.get_dn(cn)
+
+ entry_attrs = ldap.get_entry(dn, ['accesstime'])
+ try:
+ entry_attrs.setdefault('accesstime', []).remove(
+ options['accesstime']
+ )
+ ldap.update_entry(entry_attrs)
+ except (ValueError, errors.EmptyModlist):
+ pass
+ except errors.NotFound:
+ self.obj.handle_not_found(cn)
+
+ return dict(result=True)
+
+
+@register()
+class hbacrule_add_user(LDAPAddMember):
+ __doc__ = _('Add users and groups to an HBAC rule.')
+
+ member_attributes = ['memberuser']
+ member_count_out = ('%i object added.', '%i objects added.')
+
+ def pre_callback(self, ldap, dn, found, not_found, *keys, **options):
+ assert isinstance(dn, DN)
+ try:
+ entry_attrs = ldap.get_entry(dn, self.obj.default_attributes)
+ dn = entry_attrs.dn
+ except errors.NotFound:
+ self.obj.handle_not_found(*keys)
+ if 'usercategory' in entry_attrs and \
+ entry_attrs['usercategory'][0].lower() == 'all':
+ raise errors.MutuallyExclusiveError(
+ reason=_("users cannot be added when user category='all'"))
+ return dn
+
+
+
+@register()
+class hbacrule_remove_user(LDAPRemoveMember):
+ __doc__ = _('Remove users and groups from an HBAC rule.')
+
+ member_attributes = ['memberuser']
+ member_count_out = ('%i object removed.', '%i objects removed.')
+
+
+
+@register()
+class hbacrule_add_host(LDAPAddMember):
+ __doc__ = _('Add target hosts and hostgroups to an HBAC rule.')
+
+ member_attributes = ['memberhost']
+ member_count_out = ('%i object added.', '%i objects added.')
+
+ def pre_callback(self, ldap, dn, found, not_found, *keys, **options):
+ assert isinstance(dn, DN)
+ try:
+ entry_attrs = ldap.get_entry(dn, self.obj.default_attributes)
+ dn = entry_attrs.dn
+ except errors.NotFound:
+ self.obj.handle_not_found(*keys)
+ if 'hostcategory' in entry_attrs and \
+ entry_attrs['hostcategory'][0].lower() == 'all':
+ raise errors.MutuallyExclusiveError(
+ reason=_("hosts cannot be added when host category='all'"))
+ return dn
+
+
+
+@register()
+class hbacrule_remove_host(LDAPRemoveMember):
+ __doc__ = _('Remove target hosts and hostgroups from an HBAC rule.')
+
+ member_attributes = ['memberhost']
+ member_count_out = ('%i object removed.', '%i objects removed.')
+
+
+
+@register()
+class hbacrule_add_sourcehost(LDAPAddMember):
+ NO_CLI = True
+
+ member_attributes = ['sourcehost']
+ member_count_out = ('%i object added.', '%i objects added.')
+
+ def validate(self, **kw):
+ raise errors.DeprecationError(name='hbacrule_add_sourcehost')
+
+
+
+@register()
+class hbacrule_remove_sourcehost(LDAPRemoveMember):
+ NO_CLI = True
+
+ member_attributes = ['sourcehost']
+ member_count_out = ('%i object removed.', '%i objects removed.')
+
+ def validate(self, **kw):
+ raise errors.DeprecationError(name='hbacrule_remove_sourcehost')
+
+
+
+@register()
+class hbacrule_add_service(LDAPAddMember):
+ __doc__ = _('Add services to an HBAC rule.')
+
+ member_attributes = ['memberservice']
+ member_count_out = ('%i object added.', '%i objects added.')
+
+ def pre_callback(self, ldap, dn, found, not_found, *keys, **options):
+ assert isinstance(dn, DN)
+ try:
+ entry_attrs = ldap.get_entry(dn, self.obj.default_attributes)
+ dn = entry_attrs.dn
+ except errors.NotFound:
+ self.obj.handle_not_found(*keys)
+ if 'servicecategory' in entry_attrs and \
+ entry_attrs['servicecategory'][0].lower() == 'all':
+ raise errors.MutuallyExclusiveError(reason=_(
+ "services cannot be added when service category='all'"))
+ return dn
+
+
+
+@register()
+class hbacrule_remove_service(LDAPRemoveMember):
+ __doc__ = _('Remove service and service groups from an HBAC rule.')
+
+ member_attributes = ['memberservice']
+ member_count_out = ('%i object removed.', '%i objects removed.')
+
diff --git a/ipaserver/plugins/hbacsvc.py b/ipaserver/plugins/hbacsvc.py
new file mode 100644
index 000000000..43d641642
--- /dev/null
+++ b/ipaserver/plugins/hbacsvc.py
@@ -0,0 +1,152 @@
+# Authors:
+# Rob Crittenden <rcritten@redhat.com>
+#
+# Copyright (C) 2010 Red Hat
+# see file 'COPYING' for use and warranty information
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+from ipalib import api
+from ipalib import Str
+from ipalib.plugable import Registry
+from .baseldap import LDAPObject, LDAPCreate, LDAPDelete
+from .baseldap import LDAPUpdate, LDAPSearch, LDAPRetrieve
+
+from ipalib import _, ngettext
+
+__doc__ = _("""
+HBAC Services
+
+The PAM services that HBAC can control access to. The name used here
+must match the service name that PAM is evaluating.
+
+EXAMPLES:
+
+ Add a new HBAC service:
+ ipa hbacsvc-add tftp
+
+ Modify an existing HBAC service:
+ ipa hbacsvc-mod --desc="TFTP service" tftp
+
+ Search for HBAC services. This example will return two results, the FTP
+ service and the newly-added tftp service:
+ ipa hbacsvc-find ftp
+
+ Delete an HBAC service:
+ ipa hbacsvc-del tftp
+
+""")
+
+register = Registry()
+
+topic = 'hbac'
+
+@register()
+class hbacsvc(LDAPObject):
+ """
+ HBAC Service object.
+ """
+ container_dn = api.env.container_hbacservice
+ object_name = _('HBAC service')
+ object_name_plural = _('HBAC services')
+ object_class = [ 'ipaobject', 'ipahbacservice' ]
+ permission_filter_objectclasses = ['ipahbacservice']
+ default_attributes = ['cn', 'description', 'memberof']
+ uuid_attribute = 'ipauniqueid'
+ attribute_members = {
+ 'memberof': ['hbacsvcgroup'],
+ }
+ managed_permissions = {
+ 'System: Read HBAC Services': {
+ 'replaces_global_anonymous_aci': True,
+ 'ipapermbindruletype': 'all',
+ 'ipapermright': {'read', 'search', 'compare'},
+ 'ipapermdefaultattr': {
+ 'cn', 'description', 'ipauniqueid', 'memberof', 'objectclass',
+ },
+ },
+ 'System: Add HBAC Services': {
+ 'ipapermright': {'add'},
+ 'replaces': [
+ '(target = "ldap:///cn=*,cn=hbacservices,cn=hbac,$SUFFIX")(version 3.0;acl "permission:Add HBAC services";allow (add) groupdn = "ldap:///cn=Add HBAC services,cn=permissions,cn=pbac,$SUFFIX";)',
+ ],
+ 'default_privileges': {'HBAC Administrator'},
+ },
+ 'System: Delete HBAC Services': {
+ 'ipapermright': {'delete'},
+ 'replaces': [
+ '(target = "ldap:///cn=*,cn=hbacservices,cn=hbac,$SUFFIX")(version 3.0;acl "permission:Delete HBAC services";allow (delete) groupdn = "ldap:///cn=Delete HBAC services,cn=permissions,cn=pbac,$SUFFIX";)',
+ ],
+ 'default_privileges': {'HBAC Administrator'},
+ },
+ }
+
+ label = _('HBAC Services')
+ label_singular = _('HBAC Service')
+
+ takes_params = (
+ Str('cn',
+ cli_name='service',
+ label=_('Service name'),
+ doc=_('HBAC service'),
+ primary_key=True,
+ normalizer=lambda value: value.lower(),
+ ),
+ Str('description?',
+ cli_name='desc',
+ label=_('Description'),
+ doc=_('HBAC service description'),
+ ),
+ )
+
+
+
+@register()
+class hbacsvc_add(LDAPCreate):
+ __doc__ = _('Add a new HBAC service.')
+
+ msg_summary = _('Added HBAC service "%(value)s"')
+
+
+
+@register()
+class hbacsvc_del(LDAPDelete):
+ __doc__ = _('Delete an existing HBAC service.')
+
+ msg_summary = _('Deleted HBAC service "%(value)s"')
+
+
+
+@register()
+class hbacsvc_mod(LDAPUpdate):
+ __doc__ = _('Modify an HBAC service.')
+
+ msg_summary = _('Modified HBAC service "%(value)s"')
+
+
+
+@register()
+class hbacsvc_find(LDAPSearch):
+ __doc__ = _('Search for HBAC services.')
+
+ msg_summary = ngettext(
+ '%(count)d HBAC service matched', '%(count)d HBAC services matched', 0
+ )
+
+
+
+@register()
+class hbacsvc_show(LDAPRetrieve):
+ __doc__ = _('Display information about an HBAC service.')
+
diff --git a/ipaserver/plugins/hbacsvcgroup.py b/ipaserver/plugins/hbacsvcgroup.py
new file mode 100644
index 000000000..41157efc6
--- /dev/null
+++ b/ipaserver/plugins/hbacsvcgroup.py
@@ -0,0 +1,176 @@
+# Authors:
+# Rob Crittenden <rcritten@redhat.com>
+#
+# Copyright (C) 2010 Red Hat
+# see file 'COPYING' for use and warranty information
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+from ipalib import api, Str
+from ipalib.plugable import Registry
+from .baseldap import (
+ LDAPObject,
+ LDAPCreate,
+ LDAPUpdate,
+ LDAPRetrieve,
+ LDAPSearch,
+ LDAPDelete,
+ LDAPAddMember,
+ LDAPRemoveMember)
+from ipalib import _, ngettext
+
+__doc__ = _("""
+HBAC Service Groups
+
+HBAC service groups can contain any number of individual services,
+or "members". Every group must have a description.
+
+EXAMPLES:
+
+ Add a new HBAC service group:
+ ipa hbacsvcgroup-add --desc="login services" login
+
+ Add members to an HBAC service group:
+ ipa hbacsvcgroup-add-member --hbacsvcs=sshd --hbacsvcs=login login
+
+ Display information about a named group:
+ ipa hbacsvcgroup-show login
+
+ Delete an HBAC service group:
+ ipa hbacsvcgroup-del login
+""")
+
+register = Registry()
+
+topic = 'hbac'
+
+@register()
+class hbacsvcgroup(LDAPObject):
+ """
+ HBAC service group object.
+ """
+ container_dn = api.env.container_hbacservicegroup
+ object_name = _('HBAC service group')
+ object_name_plural = _('HBAC service groups')
+ object_class = ['ipaobject', 'ipahbacservicegroup']
+ permission_filter_objectclasses = ['ipahbacservicegroup']
+ default_attributes = [ 'cn', 'description', 'member' ]
+ uuid_attribute = 'ipauniqueid'
+ attribute_members = {
+ 'member': ['hbacsvc'],
+ }
+ managed_permissions = {
+ 'System: Read HBAC Service Groups': {
+ 'replaces_global_anonymous_aci': True,
+ 'ipapermbindruletype': 'all',
+ 'ipapermright': {'read', 'search', 'compare'},
+ 'ipapermdefaultattr': {
+ 'businesscategory', 'cn', 'description', 'ipauniqueid',
+ 'member', 'o', 'objectclass', 'ou', 'owner', 'seealso',
+ 'memberuser', 'memberhost',
+ },
+ },
+ 'System: Add HBAC Service Groups': {
+ 'ipapermright': {'add'},
+ 'replaces': [
+ '(target = "ldap:///cn=*,cn=hbacservicegroups,cn=hbac,$SUFFIX")(version 3.0;acl "permission:Add HBAC service groups";allow (add) groupdn = "ldap:///cn=Add HBAC service groups,cn=permissions,cn=pbac,$SUFFIX";)',
+ ],
+ 'default_privileges': {'HBAC Administrator'},
+ },
+ 'System: Delete HBAC Service Groups': {
+ 'ipapermright': {'delete'},
+ 'replaces': [
+ '(target = "ldap:///cn=*,cn=hbacservicegroups,cn=hbac,$SUFFIX")(version 3.0;acl "permission:Delete HBAC service groups";allow (delete) groupdn = "ldap:///cn=Delete HBAC service groups,cn=permissions,cn=pbac,$SUFFIX";)',
+ ],
+ 'default_privileges': {'HBAC Administrator'},
+ },
+ 'System: Manage HBAC Service Group Membership': {
+ 'ipapermright': {'write'},
+ 'ipapermdefaultattr': {'member'},
+ 'replaces': [
+ '(targetattr = "member")(target = "ldap:///cn=*,cn=hbacservicegroups,cn=hbac,$SUFFIX")(version 3.0;acl "permission:Manage HBAC service group membership";allow (write) groupdn = "ldap:///cn=Manage HBAC service group membership,cn=permissions,cn=pbac,$SUFFIX";)',
+ ],
+ 'default_privileges': {'HBAC Administrator'},
+ },
+ }
+
+ label = _('HBAC Service Groups')
+ label_singular = _('HBAC Service Group')
+
+ takes_params = (
+ Str('cn',
+ cli_name='name',
+ label=_('Service group name'),
+ primary_key=True,
+ normalizer=lambda value: value.lower(),
+ ),
+ Str('description?',
+ cli_name='desc',
+ label=_('Description'),
+ doc=_('HBAC service group description'),
+ ),
+ )
+
+
+
+@register()
+class hbacsvcgroup_add(LDAPCreate):
+ __doc__ = _('Add a new HBAC service group.')
+
+ msg_summary = _('Added HBAC service group "%(value)s"')
+
+
+
+@register()
+class hbacsvcgroup_del(LDAPDelete):
+ __doc__ = _('Delete an HBAC service group.')
+
+ msg_summary = _('Deleted HBAC service group "%(value)s"')
+
+
+
+@register()
+class hbacsvcgroup_mod(LDAPUpdate):
+ __doc__ = _('Modify an HBAC service group.')
+
+ msg_summary = _('Modified HBAC service group "%(value)s"')
+
+
+
+@register()
+class hbacsvcgroup_find(LDAPSearch):
+ __doc__ = _('Search for an HBAC service group.')
+
+ msg_summary = ngettext(
+ '%(count)d HBAC service group matched', '%(count)d HBAC service groups matched', 0
+ )
+
+
+
+@register()
+class hbacsvcgroup_show(LDAPRetrieve):
+ __doc__ = _('Display information about an HBAC service group.')
+
+
+
+@register()
+class hbacsvcgroup_add_member(LDAPAddMember):
+ __doc__ = _('Add members to an HBAC service group.')
+
+
+
+@register()
+class hbacsvcgroup_remove_member(LDAPRemoveMember):
+ __doc__ = _('Remove members from an HBAC service group.')
+
diff --git a/ipaserver/plugins/hbactest.py b/ipaserver/plugins/hbactest.py
new file mode 100644
index 000000000..90f3b561a
--- /dev/null
+++ b/ipaserver/plugins/hbactest.py
@@ -0,0 +1,499 @@
+# Authors:
+# Alexander Bokovoy <abokovoy@redhat.com>
+#
+# Copyright (C) 2011 Red Hat
+# see file 'COPYING' for use and warranty information
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+from ipalib import api, errors, output, util
+from ipalib import Command, Str, Flag, Int
+from ipalib import _
+from ipapython.dn import DN
+from ipalib.plugable import Registry
+if api.env.in_server and api.env.context in ['lite', 'server']:
+ try:
+ import ipaserver.dcerpc
+ _dcerpc_bindings_installed = True
+ except ImportError:
+ _dcerpc_bindings_installed = False
+
+import pyhbac
+import six
+
+if six.PY3:
+ unicode = str
+
+__doc__ = _("""
+Simulate use of Host-based access controls
+
+HBAC rules control who can access what services on what hosts.
+You can use HBAC to control which users or groups can access a service,
+or group of services, on a target host.
+
+Since applying HBAC rules implies use of a production environment,
+this plugin aims to provide simulation of HBAC rules evaluation without
+having access to the production environment.
+
+ Test user coming to a service on a named host against
+ existing enabled rules.
+
+ ipa hbactest --user= --host= --service=
+ [--rules=rules-list] [--nodetail] [--enabled] [--disabled]
+ [--sizelimit= ]
+
+ --user, --host, and --service are mandatory, others are optional.
+
+ If --rules is specified simulate enabling of the specified rules and test
+ the login of the user using only these rules.
+
+ If --enabled is specified, all enabled HBAC rules will be added to simulation
+
+ If --disabled is specified, all disabled HBAC rules will be added to simulation
+
+ If --nodetail is specified, do not return information about rules matched/not matched.
+
+ If both --rules and --enabled are specified, apply simulation to --rules _and_
+ all IPA enabled rules.
+
+ If no --rules specified, simulation is run against all IPA enabled rules.
+ By default there is a IPA-wide limit to number of entries fetched, you can change it
+ with --sizelimit option.
+
+EXAMPLES:
+
+ 1. Use all enabled HBAC rules in IPA database to simulate:
+ $ ipa hbactest --user=a1a --host=bar --service=sshd
+ --------------------
+ Access granted: True
+ --------------------
+ Not matched rules: my-second-rule
+ Not matched rules: my-third-rule
+ Not matched rules: myrule
+ Matched rules: allow_all
+
+ 2. Disable detailed summary of how rules were applied:
+ $ ipa hbactest --user=a1a --host=bar --service=sshd --nodetail
+ --------------------
+ Access granted: True
+ --------------------
+
+ 3. Test explicitly specified HBAC rules:
+ $ ipa hbactest --user=a1a --host=bar --service=sshd \\
+ --rules=myrule --rules=my-second-rule
+ ---------------------
+ Access granted: False
+ ---------------------
+ Not matched rules: my-second-rule
+ Not matched rules: myrule
+
+ 4. Use all enabled HBAC rules in IPA database + explicitly specified rules:
+ $ ipa hbactest --user=a1a --host=bar --service=sshd \\
+ --rules=myrule --rules=my-second-rule --enabled
+ --------------------
+ Access granted: True
+ --------------------
+ Not matched rules: my-second-rule
+ Not matched rules: my-third-rule
+ Not matched rules: myrule
+ Matched rules: allow_all
+
+ 5. Test all disabled HBAC rules in IPA database:
+ $ ipa hbactest --user=a1a --host=bar --service=sshd --disabled
+ ---------------------
+ Access granted: False
+ ---------------------
+ Not matched rules: new-rule
+
+ 6. Test all disabled HBAC rules in IPA database + explicitly specified rules:
+ $ ipa hbactest --user=a1a --host=bar --service=sshd \\
+ --rules=myrule --rules=my-second-rule --disabled
+ ---------------------
+ Access granted: False
+ ---------------------
+ Not matched rules: my-second-rule
+ Not matched rules: my-third-rule
+ Not matched rules: myrule
+
+ 7. Test all (enabled and disabled) HBAC rules in IPA database:
+ $ ipa hbactest --user=a1a --host=bar --service=sshd \\
+ --enabled --disabled
+ --------------------
+ Access granted: True
+ --------------------
+ Not matched rules: my-second-rule
+ Not matched rules: my-third-rule
+ Not matched rules: myrule
+ Not matched rules: new-rule
+ Matched rules: allow_all
+
+
+HBACTEST AND TRUSTED DOMAINS
+
+When an external trusted domain is configured in IPA, HBAC rules are also applied
+on users accessing IPA resources from the trusted domain. Trusted domain users and
+groups (and their SIDs) can be then assigned to external groups which can be
+members of POSIX groups in IPA which can be used in HBAC rules and thus allowing
+access to resources protected by the HBAC system.
+
+hbactest plugin is capable of testing access for both local IPA users and users
+from the trusted domains, either by a fully qualified user name or by user SID.
+Such user names need to have a trusted domain specified as a short name
+(DOMAIN\Administrator) or with a user principal name (UPN), Administrator@ad.test.
+
+Please note that hbactest executed with a trusted domain user as --user parameter
+can be only run by members of "trust admins" group.
+
+EXAMPLES:
+
+ 1. Test if a user from a trusted domain specified by its shortname matches any
+ rule:
+
+ $ ipa hbactest --user 'DOMAIN\Administrator' --host `hostname` --service sshd
+ --------------------
+ Access granted: True
+ --------------------
+ Matched rules: allow_all
+ Matched rules: can_login
+
+ 2. Test if a user from a trusted domain specified by its domain name matches
+ any rule:
+
+ $ ipa hbactest --user 'Administrator@domain.com' --host `hostname` --service sshd
+ --------------------
+ Access granted: True
+ --------------------
+ Matched rules: allow_all
+ Matched rules: can_login
+
+ 3. Test if a user from a trusted domain specified by its SID matches any rule:
+
+ $ ipa hbactest --user S-1-5-21-3035198329-144811719-1378114514-500 \\
+ --host `hostname` --service sshd
+ --------------------
+ Access granted: True
+ --------------------
+ Matched rules: allow_all
+ Matched rules: can_login
+
+ 4. Test if other user from a trusted domain specified by its SID matches any rule:
+
+ $ ipa hbactest --user S-1-5-21-3035198329-144811719-1378114514-1203 \\
+ --host `hostname` --service sshd
+ --------------------
+ Access granted: True
+ --------------------
+ Matched rules: allow_all
+ Not matched rules: can_login
+
+ 5. Test if other user from a trusted domain specified by its shortname matches
+ any rule:
+
+ $ ipa hbactest --user 'DOMAIN\Otheruser' --host `hostname` --service sshd
+ --------------------
+ Access granted: True
+ --------------------
+ Matched rules: allow_all
+ Not matched rules: can_login
+""")
+
+register = Registry()
+
+def convert_to_ipa_rule(rule):
+ # convert a dict with a rule to an pyhbac rule
+ ipa_rule = pyhbac.HbacRule(rule['cn'][0])
+ ipa_rule.enabled = rule['ipaenabledflag'][0]
+ # Following code attempts to process rule systematically
+ structure = \
+ (('user', 'memberuser', 'user', 'group', ipa_rule.users),
+ ('host', 'memberhost', 'host', 'hostgroup', ipa_rule.targethosts),
+ ('sourcehost', 'sourcehost', 'host', 'hostgroup', ipa_rule.srchosts),
+ ('service', 'memberservice', 'hbacsvc', 'hbacsvcgroup', ipa_rule.services),
+ )
+ for element in structure:
+ category = '%scategory' % (element[0])
+ if (category in rule and rule[category][0] == u'all') or (element[0] == 'sourcehost'):
+ # rule applies to all elements
+ # sourcehost is always set to 'all'
+ element[4].category = set([pyhbac.HBAC_CATEGORY_ALL])
+ else:
+ # rule is about specific entities
+ # Check if there are explicitly listed entities
+ attr_name = '%s_%s' % (element[1], element[2])
+ if attr_name in rule:
+ element[4].names = rule[attr_name]
+ # Now add groups of entities if they are there
+ attr_name = '%s_%s' % (element[1], element[3])
+ if attr_name in rule:
+ element[4].groups = rule[attr_name]
+ if 'externalhost' in rule:
+ ipa_rule.srchosts.names.extend(rule['externalhost']) #pylint: disable=E1101
+ return ipa_rule
+
+
+@register()
+class hbactest(Command):
+ __doc__ = _('Simulate use of Host-based access controls')
+
+ has_output = (
+ output.summary,
+ output.Output('warning', (list, tuple, type(None)), _('Warning')),
+ output.Output('matched', (list, tuple, type(None)), _('Matched rules')),
+ output.Output('notmatched', (list, tuple, type(None)), _('Not matched rules')),
+ output.Output('error', (list, tuple, type(None)), _('Non-existent or invalid rules')),
+ output.Output('value', bool, _('Result of simulation'), ['no_display']),
+ )
+
+ takes_options = (
+ Str('user',
+ cli_name='user',
+ label=_('User name'),
+ primary_key=True,
+ ),
+ Str('sourcehost?',
+ deprecated=True,
+ cli_name='srchost',
+ label=_('Source host'),
+ flags={'no_option'},
+ ),
+ Str('targethost',
+ cli_name='host',
+ label=_('Target host'),
+ ),
+ Str('service',
+ cli_name='service',
+ label=_('Service'),
+ ),
+ Str('rules*',
+ cli_name='rules',
+ label=_('Rules to test. If not specified, --enabled is assumed'),
+ ),
+ Flag('nodetail?',
+ cli_name='nodetail',
+ label=_('Hide details which rules are matched, not matched, or invalid'),
+ ),
+ Flag('enabled?',
+ cli_name='enabled',
+ label=_('Include all enabled IPA rules into test [default]'),
+ ),
+ Flag('disabled?',
+ cli_name='disabled',
+ label=_('Include all disabled IPA rules into test'),
+ ),
+ Int('sizelimit?',
+ label=_('Size Limit'),
+ doc=_('Maximum number of rules to process when no --rules is specified'),
+ flags=['no_display'],
+ minvalue=0,
+ autofill=False,
+ ),
+ )
+
+ def canonicalize(self, host):
+ """
+ Canonicalize the host name -- add default IPA domain if that is missing
+ """
+ if host.find('.') == -1:
+ return u'%s.%s' % (host, self.env.domain)
+ return host
+
+ def execute(self, *args, **options):
+ # First receive all needed information:
+ # 1. HBAC rules (whether enabled or disabled)
+ # 2. Required options are (user, target host, service)
+ # 3. Options: rules to test (--rules, --enabled, --disabled), request for detail output
+ rules = []
+
+ # Use all enabled IPA rules by default
+ all_enabled = True
+ all_disabled = False
+
+ # We need a local copy of test rules in order find incorrect ones
+ testrules = {}
+ if 'rules' in options:
+ testrules = list(options['rules'])
+ # When explicit rules are provided, disable assumptions
+ all_enabled = False
+ all_disabled = False
+
+ sizelimit = None
+ if 'sizelimit' in options:
+ sizelimit = int(options['sizelimit'])
+
+ # Check if --disabled is specified, include all disabled IPA rules
+ if options['disabled']:
+ all_disabled = True
+ all_enabled = False
+
+ # Finally, if enabled is specified implicitly, override above decisions
+ if options['enabled']:
+ all_enabled = True
+
+ hbacset = []
+ if len(testrules) == 0:
+ hbacset = self.api.Command.hbacrule_find(
+ sizelimit=sizelimit, no_members=False)['result']
+ else:
+ for rule in testrules:
+ try:
+ hbacset.append(self.api.Command.hbacrule_show(rule)['result'])
+ except Exception:
+ pass
+
+ # We have some rules, import them
+ # --enabled will import all enabled rules (default)
+ # --disabled will import all disabled rules
+ # --rules will implicitly add the rules from a rule list
+ for rule in hbacset:
+ ipa_rule = convert_to_ipa_rule(rule)
+ if ipa_rule.name in testrules:
+ ipa_rule.enabled = True
+ rules.append(ipa_rule)
+ testrules.remove(ipa_rule.name)
+ elif all_enabled and ipa_rule.enabled:
+ # Option --enabled forces to include all enabled IPA rules into test
+ rules.append(ipa_rule)
+ elif all_disabled and not ipa_rule.enabled:
+ # Option --disabled forces to include all disabled IPA rules into test
+ ipa_rule.enabled = True
+ rules.append(ipa_rule)
+
+ # Check if there are unresolved rules left
+ if len(testrules) > 0:
+ # Error, unresolved rules are left in --rules
+ return {'summary' : unicode(_(u'Unresolved rules in --rules')),
+ 'error': testrules, 'matched': None, 'notmatched': None,
+ 'warning' : None, 'value' : False}
+
+ # Rules are converted to pyhbac format, build request and then test it
+ request = pyhbac.HbacRequest()
+
+ if options['user'] != u'all':
+ # check first if this is not a trusted domain user
+ if _dcerpc_bindings_installed:
+ is_valid_sid = ipaserver.dcerpc.is_sid_valid(options['user'])
+ else:
+ is_valid_sid = False
+ components = util.normalize_name(options['user'])
+ if is_valid_sid or 'domain' in components or 'flatname' in components:
+ # this is a trusted domain user
+ if not _dcerpc_bindings_installed:
+ raise errors.NotFound(reason=_(
+ 'Cannot perform external member validation without '
+ 'Samba 4 support installed. Make sure you have installed '
+ 'server-trust-ad sub-package of IPA on the server'))
+ domain_validator = ipaserver.dcerpc.DomainValidator(self.api)
+ if not domain_validator.is_configured():
+ raise errors.NotFound(reason=_(
+ 'Cannot search in trusted domains without own domain configured. '
+ 'Make sure you have run ipa-adtrust-install on the IPA server first'))
+ user_sid, group_sids = domain_validator.get_trusted_domain_user_and_groups(options['user'])
+ request.user.name = user_sid
+
+ # Now search for all external groups that have this user or
+ # any of its groups in its external members. Found entires
+ # memberOf links will be then used to gather all groups where
+ # this group is assigned, including the nested ones
+ filter_sids = "(&(objectclass=ipaexternalgroup)(|(ipaExternalMember=%s)))" \
+ % ")(ipaExternalMember=".join(group_sids + [user_sid])
+
+ ldap = self.api.Backend.ldap2
+ group_container = DN(api.env.container_group, api.env.basedn)
+ try:
+ entries, truncated = ldap.find_entries(filter_sids, ['memberof'], group_container)
+ except errors.NotFound:
+ request.user.groups = []
+ else:
+ groups = []
+ for entry in entries:
+ memberof_dns = entry.get('memberof', [])
+ for memberof_dn in memberof_dns:
+ if memberof_dn.endswith(group_container):
+ groups.append(memberof_dn[0][0].value)
+ request.user.groups = sorted(set(groups))
+ else:
+ # try searching for a local user
+ try:
+ request.user.name = options['user']
+ search_result = self.api.Command.user_show(request.user.name)['result']
+ groups = search_result['memberof_group']
+ if 'memberofindirect_group' in search_result:
+ groups += search_result['memberofindirect_group']
+ request.user.groups = sorted(set(groups))
+ except Exception:
+ pass
+
+ if options['service'] != u'all':
+ try:
+ request.service.name = options['service']
+ service_result = self.api.Command.hbacsvc_show(request.service.name)['result']
+ if 'memberof_hbacsvcgroup' in service_result:
+ request.service.groups = service_result['memberof_hbacsvcgroup']
+ except Exception:
+ pass
+
+ if options['targethost'] != u'all':
+ try:
+ request.targethost.name = self.canonicalize(options['targethost'])
+ tgthost_result = self.api.Command.host_show(request.targethost.name)['result']
+ groups = tgthost_result['memberof_hostgroup']
+ if 'memberofindirect_hostgroup' in tgthost_result:
+ groups += tgthost_result['memberofindirect_hostgroup']
+ request.targethost.groups = sorted(set(groups))
+ except Exception:
+ pass
+
+ matched_rules = []
+ notmatched_rules = []
+ error_rules = []
+ warning_rules = []
+
+ result = {'warning':None, 'matched':None, 'notmatched':None, 'error':None}
+ if not options['nodetail']:
+ # Validate runs rules one-by-one and reports failed ones
+ for ipa_rule in rules:
+ try:
+ res = request.evaluate([ipa_rule])
+ if res == pyhbac.HBAC_EVAL_ALLOW:
+ matched_rules.append(ipa_rule.name)
+ if res == pyhbac.HBAC_EVAL_DENY:
+ notmatched_rules.append(ipa_rule.name)
+ except pyhbac.HbacError as e:
+ code, rule_name = e.args
+ if code == pyhbac.HBAC_EVAL_ERROR:
+ error_rules.append(rule_name)
+ self.log.info('Native IPA HBAC rule "%s" parsing error: %s' % \
+ (rule_name, pyhbac.hbac_result_string(code)))
+ except (TypeError, IOError) as info:
+ self.log.error('Native IPA HBAC module error: %s' % info)
+
+ access_granted = len(matched_rules) > 0
+ else:
+ res = request.evaluate(rules)
+ access_granted = (res == pyhbac.HBAC_EVAL_ALLOW)
+
+ result['summary'] = _('Access granted: %s') % (access_granted)
+
+
+ if len(matched_rules) > 0:
+ result['matched'] = matched_rules
+ if len(notmatched_rules) > 0:
+ result['notmatched'] = notmatched_rules
+ if len(error_rules) > 0:
+ result['error'] = error_rules
+ if len(warning_rules) > 0:
+ result['warning'] = warning_rules
+
+ result['value'] = access_granted
+ return result
diff --git a/ipaserver/plugins/host.py b/ipaserver/plugins/host.py
new file mode 100644
index 000000000..709b78d5b
--- /dev/null
+++ b/ipaserver/plugins/host.py
@@ -0,0 +1,1284 @@
+# Authors:
+# Rob Crittenden <rcritten@redhat.com>
+# Pavel Zuna <pzuna@redhat.com>
+#
+# Copyright (C) 2008 Red Hat
+# see file 'COPYING' for use and warranty information
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+import string
+
+import six
+
+from ipalib import api, errors, util
+from ipalib import messages
+from ipalib import Str, Flag, Bytes
+from ipalib.plugable import Registry
+from .baseldap import (LDAPQuery, LDAPObject, LDAPCreate,
+ LDAPDelete, LDAPUpdate, LDAPSearch,
+ LDAPRetrieve, LDAPAddMember,
+ LDAPRemoveMember, host_is_master,
+ pkey_to_value, add_missing_object_class,
+ LDAPAddAttribute, LDAPRemoveAttribute)
+from .service import (split_principal, validate_certificate,
+ set_certificate_attrs, ticket_flags_params, update_krbticketflags,
+ set_kerberos_attrs, rename_ipaallowedtoperform_from_ldap,
+ rename_ipaallowedtoperform_to_ldap, revoke_certs)
+from .dns import (dns_container_exists,
+ add_records_for_host_validation, add_records_for_host,
+ get_reverse_zone)
+from ipalib import _, ngettext
+from ipalib import x509
+from ipalib import output
+from ipalib.request import context
+from ipalib.util import (normalize_sshpubkey, validate_sshpubkey_no_options,
+ convert_sshpubkey_post, validate_hostname,
+ add_sshpubkey_to_attrs_pre,
+ remove_sshpubkey_from_output_post,
+ remove_sshpubkey_from_output_list_post)
+from ipapython.ipautil import ipa_generate_password, CheckedIPAddress
+from ipapython.dnsutil import DNSName
+from ipapython.ssh import SSHPublicKey
+from ipapython.dn import DN
+from functools import reduce
+
+if six.PY3:
+ unicode = str
+
+__doc__ = _("""
+Hosts/Machines
+
+A host represents a machine. It can be used in a number of contexts:
+- service entries are associated with a host
+- a host stores the host/ service principal
+- a host can be used in Host-based Access Control (HBAC) rules
+- every enrolled client generates a host entry
+""") + _("""
+ENROLLMENT:
+
+There are three enrollment scenarios when enrolling a new client:
+
+1. You are enrolling as a full administrator. The host entry may exist
+ or not. A full administrator is a member of the hostadmin role
+ or the admins group.
+2. You are enrolling as a limited administrator. The host must already
+ exist. A limited administrator is a member a role with the
+ Host Enrollment privilege.
+3. The host has been created with a one-time password.
+""") + _("""
+RE-ENROLLMENT:
+
+Host that has been enrolled at some point, and lost its configuration (e.g. VM
+destroyed) can be re-enrolled.
+
+For more information, consult the manual pages for ipa-client-install.
+
+A host can optionally store information such as where it is located,
+the OS that it runs, etc.
+""") + _("""
+EXAMPLES:
+""") + _("""
+ Add a new host:
+ ipa host-add --location="3rd floor lab" --locality=Dallas test.example.com
+""") + _("""
+ Delete a host:
+ ipa host-del test.example.com
+""") + _("""
+ Add a new host with a one-time password:
+ ipa host-add --os='Fedora 12' --password=Secret123 test.example.com
+""") + _("""
+ Add a new host with a random one-time password:
+ ipa host-add --os='Fedora 12' --random test.example.com
+""") + _("""
+ Modify information about a host:
+ ipa host-mod --os='Fedora 12' test.example.com
+""") + _("""
+ Remove SSH public keys of a host and update DNS to reflect this change:
+ ipa host-mod --sshpubkey= --updatedns test.example.com
+""") + _("""
+ Disable the host Kerberos key, SSL certificate and all of its services:
+ ipa host-disable test.example.com
+""") + _("""
+ Add a host that can manage this host's keytab and certificate:
+ ipa host-add-managedby --hosts=test2 test
+""") + _("""
+ Allow user to create a keytab:
+ ipa host-allow-create-keytab test2 --users=tuser1
+""")
+
+register = Registry()
+
+# Characters to be used by random password generator
+# The set was chosen to avoid the need for escaping the characters by user
+host_pwd_chars = string.digits + string.ascii_letters + '_,.@+-='
+
+
+def remove_ptr_rec(ipaddr, host, domain):
+ """
+ Remove PTR record of IP address (ipaddr)
+ :return: True if PTR record was removed, False if record was not found
+ """
+ api.log.debug('deleting PTR record of ipaddr %s', ipaddr)
+ try:
+ revzone, revname = get_reverse_zone(ipaddr)
+
+ # in case domain is in FQDN form with a trailing dot, we needn't add
+ # another one, in case it has no trailing dot, dnsrecord-del will
+ # normalize the entry
+ delkw = {'ptrrecord': "%s.%s" % (host, domain)}
+
+ api.Command['dnsrecord_del'](revzone, revname, **delkw)
+ except errors.NotFound:
+ api.log.debug('PTR record of ipaddr %s not found', ipaddr)
+ return False
+
+ return True
+
+
+def update_sshfp_record(zone, record, entry_attrs):
+ if 'ipasshpubkey' not in entry_attrs:
+ return
+
+ pubkeys = entry_attrs['ipasshpubkey'] or ()
+ sshfps = []
+ for pubkey in pubkeys:
+ try:
+ sshfp = SSHPublicKey(pubkey).fingerprint_dns_sha1()
+ except (ValueError, UnicodeDecodeError):
+ continue
+ if sshfp is not None:
+ sshfps.append(sshfp)
+
+ try:
+ sshfp = SSHPublicKey(pubkey).fingerprint_dns_sha256()
+ except (ValueError, UnicodeDecodeError):
+ continue
+ if sshfp is not None:
+ sshfps.append(sshfp)
+
+ try:
+ api.Command['dnsrecord_mod'](zone, record, sshfprecord=sshfps)
+ except errors.EmptyModlist:
+ pass
+
+
+def convert_ipaassignedidview_post(entry_attrs, options):
+ """
+ Converts the ID View DN to its name for the better looking output.
+ """
+
+ if 'ipaassignedidview' in entry_attrs and not options.get('raw'):
+ idview_name = entry_attrs.single_value['ipaassignedidview'][0].value
+ entry_attrs.single_value['ipaassignedidview'] = idview_name
+
+
+host_output_params = (
+ Flag('has_keytab',
+ label=_('Keytab'),
+ ),
+ Str('managedby_host',
+ label='Managed by',
+ ),
+ Str('managing_host',
+ label='Managing',
+ ),
+ Str('subject',
+ label=_('Subject'),
+ ),
+ Str('serial_number',
+ label=_('Serial Number'),
+ ),
+ Str('serial_number_hex',
+ label=_('Serial Number (hex)'),
+ ),
+ Str('issuer',
+ label=_('Issuer'),
+ ),
+ Str('valid_not_before',
+ label=_('Not Before'),
+ ),
+ Str('valid_not_after',
+ label=_('Not After'),
+ ),
+ Str('md5_fingerprint',
+ label=_('Fingerprint (MD5)'),
+ ),
+ Str('sha1_fingerprint',
+ label=_('Fingerprint (SHA1)'),
+ ),
+ Str('revocation_reason?',
+ label=_('Revocation reason'),
+ ),
+ Str('managedby',
+ label=_('Failed managedby'),
+ ),
+ Str('sshpubkeyfp*',
+ label=_('SSH public key fingerprint'),
+ ),
+ Str('ipaallowedtoperform_read_keys_user',
+ label=_('Users allowed to retrieve keytab'),
+ ),
+ Str('ipaallowedtoperform_read_keys_group',
+ label=_('Groups allowed to retrieve keytab'),
+ ),
+ Str('ipaallowedtoperform_read_keys_host',
+ label=_('Hosts allowed to retrieve keytab'),
+ ),
+ Str('ipaallowedtoperform_read_keys_hostgroup',
+ label=_('Host Groups allowed to retrieve keytab'),
+ ),
+ Str('ipaallowedtoperform_write_keys_user',
+ label=_('Users allowed to create keytab'),
+ ),
+ Str('ipaallowedtoperform_write_keys_group',
+ label=_('Groups allowed to create keytab'),
+ ),
+ Str('ipaallowedtoperform_write_keys_host',
+ label=_('Hosts allowed to create keytab'),
+ ),
+ Str('ipaallowedtoperform_write_keys_hostgroup',
+ label=_('Host Groups allowed to create keytab'),
+ ),
+ Str('ipaallowedtoperform_read_keys',
+ label=_('Failed allowed to retrieve keytab'),
+ ),
+ Str('ipaallowedtoperform_write_keys',
+ label=_('Failed allowed to create keytab'),
+ ),
+)
+
+
+def validate_ipaddr(ugettext, ipaddr):
+ """
+ Verify that we have either an IPv4 or IPv6 address.
+ """
+ try:
+ CheckedIPAddress(ipaddr, match_local=False)
+ except Exception as e:
+ return unicode(e)
+ return None
+
+
+def normalize_hostname(hostname):
+ """Use common fqdn form without the trailing dot"""
+ if hostname.endswith(u'.'):
+ hostname = hostname[:-1]
+ hostname = hostname.lower()
+ return hostname
+
+
+def _hostname_validator(ugettext, value):
+ try:
+ validate_hostname(value)
+ except ValueError as e:
+ return _('invalid domain-name: %s') % unicode(e)
+
+ return None
+
+
+@register()
+class host(LDAPObject):
+ """
+ Host object.
+ """
+ container_dn = api.env.container_host
+ object_name = _('host')
+ object_name_plural = _('hosts')
+ object_class = ['ipaobject', 'nshost', 'ipahost', 'pkiuser', 'ipaservice']
+ possible_objectclasses = ['ipaallowedoperations']
+ permission_filter_objectclasses = ['ipahost']
+ # object_class_config = 'ipahostobjectclasses'
+ search_attributes = [
+ 'fqdn', 'description', 'l', 'nshostlocation', 'krbprincipalname',
+ 'nshardwareplatform', 'nsosversion', 'managedby',
+ ]
+ default_attributes = [
+ 'fqdn', 'description', 'l', 'nshostlocation', 'krbprincipalname',
+ 'nshardwareplatform', 'nsosversion', 'usercertificate', 'memberof',
+ 'managedby', 'memberofindirect', 'macaddress',
+ 'userclass', 'ipaallowedtoperform', 'ipaassignedidview',
+ ]
+ uuid_attribute = 'ipauniqueid'
+ attribute_members = {
+ 'enrolledby': ['user'],
+ 'memberof': ['hostgroup', 'netgroup', 'role', 'hbacrule', 'sudorule'],
+ 'managedby': ['host'],
+ 'managing': ['host'],
+ 'memberofindirect': ['hostgroup', 'netgroup', 'role', 'hbacrule',
+ 'sudorule'],
+ 'ipaallowedtoperform_read_keys': ['user', 'group', 'host', 'hostgroup'],
+ 'ipaallowedtoperform_write_keys': ['user', 'group', 'host', 'hostgroup'],
+ }
+ bindable = True
+ relationships = {
+ 'memberof': ('Member Of', 'in_', 'not_in_'),
+ 'enrolledby': ('Enrolled by', 'enroll_by_', 'not_enroll_by_'),
+ 'managedby': ('Managed by', 'man_by_', 'not_man_by_'),
+ 'managing': ('Managing', 'man_', 'not_man_'),
+ 'ipaallowedtoperform_read_keys': ('Allow to retrieve keytab by', 'retrieve_keytab_by_', 'not_retrieve_keytab_by_'),
+ 'ipaallowedtoperform_write_keys': ('Allow to create keytab by', 'write_keytab_by_', 'not_write_keytab_by'),
+ }
+ password_attributes = [('userpassword', 'has_password'),
+ ('krbprincipalkey', 'has_keytab')]
+ managed_permissions = {
+ 'System: Read Hosts': {
+ 'replaces_global_anonymous_aci': True,
+ 'ipapermbindruletype': 'all',
+ 'ipapermright': {'read', 'search', 'compare'},
+ 'ipapermdefaultattr': {
+ 'cn', 'description', 'fqdn', 'ipaclientversion',
+ 'ipakrbauthzdata', 'ipasshpubkey', 'ipauniqueid',
+ 'krbprincipalname', 'l', 'macaddress', 'nshardwareplatform',
+ 'nshostlocation', 'nsosversion', 'objectclass',
+ 'serverhostname', 'usercertificate', 'userclass',
+ 'enrolledby', 'managedby', 'ipaassignedidview',
+ 'krbprincipalname', 'krbcanonicalname', 'krbprincipalaliases',
+ 'krbprincipalexpiration', 'krbpasswordexpiration',
+ 'krblastpwdchange',
+ },
+ },
+ 'System: Read Host Membership': {
+ 'replaces_global_anonymous_aci': True,
+ 'ipapermbindruletype': 'all',
+ 'ipapermright': {'read', 'search', 'compare'},
+ 'ipapermdefaultattr': {
+ 'memberof',
+ },
+ },
+ 'System: Add Hosts': {
+ 'ipapermright': {'add'},
+ 'replaces': [
+ '(target = "ldap:///fqdn=*,cn=computers,cn=accounts,$SUFFIX")(version 3.0;acl "permission:Add Hosts";allow (add) groupdn = "ldap:///cn=Add Hosts,cn=permissions,cn=pbac,$SUFFIX";)',
+ ],
+ 'default_privileges': {'Host Administrators'},
+ },
+ 'System: Add krbPrincipalName to a Host': {
+ # Allow an admin to enroll a host that has a one-time password.
+ # When a host is created with a password no krbPrincipalName is set.
+ # This will let it be added if the client ends up enrolling with
+ # an administrator instead.
+ 'ipapermright': {'write'},
+ 'ipapermtargetfilter': [
+ '(objectclass=ipahost)',
+ '(!(krbprincipalname=*))',
+ ],
+ 'ipapermdefaultattr': {'krbprincipalname'},
+ 'replaces': [
+ '(target = "ldap:///fqdn=*,cn=computers,cn=accounts,$SUFFIX")(targetfilter = "(!(krbprincipalname=*))")(targetattr = "krbprincipalname")(version 3.0;acl "permission:Add krbPrincipalName to a host"; allow (write) groupdn = "ldap:///cn=Add krbPrincipalName to a host,cn=permissions,cn=pbac,$SUFFIX";)',
+ ],
+ 'default_privileges': {'Host Administrators', 'Host Enrollment'},
+ },
+ 'System: Enroll a Host': {
+ 'ipapermright': {'write'},
+ 'ipapermdefaultattr': {'objectclass', 'enrolledby'},
+ 'replaces': [
+ '(targetattr = "objectclass")(target = "ldap:///fqdn=*,cn=computers,cn=accounts,$SUFFIX")(version 3.0;acl "permission:Enroll a host";allow (write) groupdn = "ldap:///cn=Enroll a host,cn=permissions,cn=pbac,$SUFFIX";)',
+ '(targetattr = "enrolledby || objectclass")(target = "ldap:///fqdn=*,cn=computers,cn=accounts,$SUFFIX")(version 3.0;acl "permission:Enroll a host";allow (write) groupdn = "ldap:///cn=Enroll a host,cn=permissions,cn=pbac,$SUFFIX";)',
+ ],
+ 'default_privileges': {'Host Administrators', 'Host Enrollment'},
+ },
+ 'System: Manage Host SSH Public Keys': {
+ 'ipapermright': {'write'},
+ 'ipapermdefaultattr': {'ipasshpubkey'},
+ 'replaces': [
+ '(targetattr = "ipasshpubkey")(target = "ldap:///fqdn=*,cn=computers,cn=accounts,$SUFFIX")(version 3.0;acl "permission:Manage Host SSH Public Keys";allow (write) groupdn = "ldap:///cn=Manage Host SSH Public Keys,cn=permissions,cn=pbac,$SUFFIX";)',
+ ],
+ 'default_privileges': {'Host Administrators'},
+ },
+ 'System: Manage Host Keytab': {
+ 'ipapermright': {'write'},
+ 'ipapermtargetfilter': [
+ '(objectclass=ipahost)',
+ '(!(memberOf=%s))' % DN('cn=ipaservers',
+ api.env.container_hostgroup,
+ api.env.basedn),
+ ],
+ 'ipapermdefaultattr': {'krblastpwdchange', 'krbprincipalkey'},
+ 'replaces': [
+ '(targetattr = "krbprincipalkey || krblastpwdchange")(target = "ldap:///fqdn=*,cn=computers,cn=accounts,$SUFFIX")(version 3.0;acl "permission:Manage host keytab";allow (write) groupdn = "ldap:///cn=Manage host keytab,cn=permissions,cn=pbac,$SUFFIX";)',
+ ],
+ 'default_privileges': {'Host Administrators', 'Host Enrollment'},
+ },
+ 'System: Manage Host Keytab Permissions': {
+ 'ipapermright': {'read', 'search', 'compare', 'write'},
+ 'ipapermdefaultattr': {
+ 'ipaallowedtoperform;write_keys',
+ 'ipaallowedtoperform;read_keys', 'objectclass'
+ },
+ 'default_privileges': {'Host Administrators'},
+ },
+ 'System: Modify Hosts': {
+ 'ipapermright': {'write'},
+ 'ipapermdefaultattr': {
+ 'description', 'l', 'nshardwareplatform', 'nshostlocation',
+ 'nsosversion', 'macaddress', 'userclass', 'ipaassignedidview',
+ },
+ 'replaces': [
+ '(targetattr = "description || l || nshostlocation || nshardwareplatform || nsosversion")(target = "ldap:///fqdn=*,cn=computers,cn=accounts,$SUFFIX")(version 3.0;acl "permission:Modify Hosts";allow (write) groupdn = "ldap:///cn=Modify Hosts,cn=permissions,cn=pbac,$SUFFIX";)',
+ ],
+ 'default_privileges': {'Host Administrators'},
+ },
+ 'System: Remove Hosts': {
+ 'ipapermright': {'delete'},
+ 'replaces': [
+ '(target = "ldap:///fqdn=*,cn=computers,cn=accounts,$SUFFIX")(version 3.0;acl "permission:Remove Hosts";allow (delete) groupdn = "ldap:///cn=Remove Hosts,cn=permissions,cn=pbac,$SUFFIX";)',
+ ],
+ 'default_privileges': {'Host Administrators'},
+ },
+ 'System: Manage Host Certificates': {
+ 'ipapermbindruletype': 'permission',
+ 'ipapermright': {'write'},
+ 'ipapermdefaultattr': {'usercertificate'},
+ 'default_privileges': {'Host Administrators', 'Host Enrollment'},
+ },
+ 'System: Manage Host Enrollment Password': {
+ 'ipapermbindruletype': 'permission',
+ 'ipapermright': {'write'},
+ 'ipapermdefaultattr': {'userpassword'},
+ 'default_privileges': {'Host Administrators', 'Host Enrollment'},
+ },
+ 'System: Read Host Compat Tree': {
+ 'non_object': True,
+ 'ipapermbindruletype': 'anonymous',
+ 'ipapermlocation': api.env.basedn,
+ 'ipapermtarget': DN('cn=computers', 'cn=compat', api.env.basedn),
+ 'ipapermright': {'read', 'search', 'compare'},
+ 'ipapermdefaultattr': {
+ 'objectclass', 'cn', 'macaddress',
+ },
+ },
+ }
+
+ label = _('Hosts')
+ label_singular = _('Host')
+
+ takes_params = (
+ Str('fqdn', _hostname_validator,
+ cli_name='hostname',
+ label=_('Host name'),
+ primary_key=True,
+ normalizer=normalize_hostname,
+ ),
+ Str('description?',
+ cli_name='desc',
+ label=_('Description'),
+ doc=_('A description of this host'),
+ ),
+ Str('l?',
+ cli_name='locality',
+ label=_('Locality'),
+ doc=_('Host locality (e.g. "Baltimore, MD")'),
+ ),
+ Str('nshostlocation?',
+ cli_name='location',
+ label=_('Location'),
+ doc=_('Host location (e.g. "Lab 2")'),
+ ),
+ Str('nshardwareplatform?',
+ cli_name='platform',
+ label=_('Platform'),
+ doc=_('Host hardware platform (e.g. "Lenovo T61")'),
+ ),
+ Str('nsosversion?',
+ cli_name='os',
+ label=_('Operating system'),
+ doc=_('Host operating system and version (e.g. "Fedora 9")'),
+ ),
+ Str('userpassword?',
+ cli_name='password',
+ label=_('User password'),
+ doc=_('Password used in bulk enrollment'),
+ ),
+ Flag('random?',
+ doc=_('Generate a random password to be used in bulk enrollment'),
+ flags=('no_search', 'virtual_attribute'),
+ default=False,
+ ),
+ Str('randompassword?',
+ label=_('Random password'),
+ flags=('no_create', 'no_update', 'no_search', 'virtual_attribute'),
+ ),
+ Bytes('usercertificate*', validate_certificate,
+ cli_name='certificate',
+ label=_('Certificate'),
+ doc=_('Base-64 encoded host certificate'),
+ ),
+ Str('krbprincipalname?',
+ label=_('Principal name'),
+ flags=['no_create', 'no_update', 'no_search'],
+ ),
+ Str('macaddress*',
+ normalizer=lambda value: value.upper(),
+ pattern='^([a-fA-F0-9]{2}[:|\-]?){5}[a-fA-F0-9]{2}$',
+ pattern_errmsg=('Must be of the form HH:HH:HH:HH:HH:HH, where '
+ 'each H is a hexadecimal character.'),
+ label=_('MAC address'),
+ doc=_('Hardware MAC address(es) on this host'),
+ ),
+ Str('ipasshpubkey*', validate_sshpubkey_no_options,
+ cli_name='sshpubkey',
+ label=_('SSH public key'),
+ normalizer=normalize_sshpubkey,
+ flags=['no_search'],
+ ),
+ Str('userclass*',
+ cli_name='class',
+ label=_('Class'),
+ doc=_('Host category (semantics placed on this attribute are for '
+ 'local interpretation)'),
+ ),
+ Str('ipaassignedidview?',
+ label=_('Assigned ID View'),
+ flags=['no_option'],
+ ),
+ ) + ticket_flags_params
+
+ def get_dn(self, *keys, **options):
+ hostname = keys[-1]
+ dn = super(host, self).get_dn(hostname, **options)
+ try:
+ self.backend.get_entry(dn, [''])
+ except errors.NotFound:
+ try:
+ entry_attrs = self.backend.find_entry_by_attr(
+ 'serverhostname', hostname, self.object_class, [''],
+ DN(self.container_dn, api.env.basedn))
+ dn = entry_attrs.dn
+ except errors.NotFound:
+ pass
+ return dn
+
+ def get_managed_hosts(self, dn):
+ host_filter = 'managedBy=%s' % dn
+ host_attrs = ['fqdn']
+ ldap = self.api.Backend.ldap2
+ managed_hosts = []
+
+ try:
+ (hosts, truncated) = ldap.find_entries(
+ base_dn=DN(self.container_dn, api.env.basedn),
+ filter=host_filter, attrs_list=host_attrs)
+
+ for host in hosts:
+ managed_hosts.append(host.dn)
+ except errors.NotFound:
+ return []
+
+ return managed_hosts
+
+ def suppress_netgroup_memberof(self, ldap, entry_attrs):
+ """
+ We don't want to show managed netgroups so remove them from the
+ memberofindirect list.
+ """
+ ng_container = DN(api.env.container_netgroup, api.env.basedn)
+ for member in list(entry_attrs.get('memberofindirect', [])):
+ memberdn = DN(member)
+ if not memberdn.endswith(ng_container):
+ continue
+
+ filter = ldap.make_filter({'objectclass': 'mepmanagedentry'})
+ try:
+ ldap.get_entries(memberdn, ldap.SCOPE_BASE, filter, [''])
+ except errors.NotFound:
+ pass
+ else:
+ entry_attrs['memberofindirect'].remove(member)
+
+
+@register()
+class host_add(LDAPCreate):
+ __doc__ = _('Add a new host.')
+
+ has_output_params = LDAPCreate.has_output_params + host_output_params
+ msg_summary = _('Added host "%(value)s"')
+ member_attributes = ['managedby']
+ takes_options = LDAPCreate.takes_options + (
+ Flag('force',
+ label=_('Force'),
+ doc=_('force host name even if not in DNS'),
+ ),
+ Flag('no_reverse',
+ doc=_('skip reverse DNS detection'),
+ ),
+ Str('ip_address?', validate_ipaddr,
+ doc=_('Add the host to DNS with this IP address'),
+ label=_('IP Address'),
+ ),
+ )
+
+ def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options):
+ assert isinstance(dn, DN)
+ if options.get('ip_address') and dns_container_exists(ldap):
+ parts = keys[-1].split('.')
+ host = parts[0]
+ domain = unicode('.'.join(parts[1:]))
+ check_reverse = not options.get('no_reverse', False)
+ add_records_for_host_validation('ip_address',
+ DNSName(host),
+ DNSName(domain).make_absolute(),
+ options['ip_address'],
+ check_forward=True,
+ check_reverse=check_reverse)
+ if not options.get('force', False) and not 'ip_address' in options:
+ util.verify_host_resolvable(keys[-1])
+ if 'locality' in entry_attrs:
+ entry_attrs['l'] = entry_attrs['locality']
+ entry_attrs['cn'] = keys[-1]
+ entry_attrs['serverhostname'] = keys[-1].split('.', 1)[0]
+ if not entry_attrs.get('userpassword', False) and not options.get('random', False):
+ entry_attrs['krbprincipalname'] = 'host/%s@%s' % (
+ keys[-1], self.api.env.realm
+ )
+ if 'krbprincipalaux' not in entry_attrs['objectclass']:
+ entry_attrs['objectclass'].append('krbprincipalaux')
+ if 'krbprincipal' not in entry_attrs['objectclass']:
+ entry_attrs['objectclass'].append('krbprincipal')
+ else:
+ if 'krbprincipalaux' in entry_attrs['objectclass']:
+ entry_attrs['objectclass'].remove('krbprincipalaux')
+ if 'krbprincipal' in entry_attrs['objectclass']:
+ entry_attrs['objectclass'].remove('krbprincipal')
+ if options.get('random'):
+ entry_attrs['userpassword'] = ipa_generate_password(characters=host_pwd_chars)
+ # save the password so it can be displayed in post_callback
+ setattr(context, 'randompassword', entry_attrs['userpassword'])
+ certs = options.get('usercertificate', [])
+ certs_der = [x509.normalize_certificate(c) for c in certs]
+ for cert in certs_der:
+ x509.verify_cert_subject(ldap, keys[-1], cert)
+ entry_attrs['usercertificate'] = certs_der
+ entry_attrs['managedby'] = dn
+ entry_attrs['objectclass'].append('ieee802device')
+ entry_attrs['objectclass'].append('ipasshhost')
+ update_krbticketflags(ldap, entry_attrs, attrs_list, options, False)
+ if 'krbticketflags' in entry_attrs:
+ entry_attrs['objectclass'].append('krbticketpolicyaux')
+ return dn
+
+ def post_callback(self, ldap, dn, entry_attrs, *keys, **options):
+ assert isinstance(dn, DN)
+ exc = None
+ if dns_container_exists(ldap):
+ try:
+ parts = keys[-1].split('.')
+ host = parts[0]
+ domain = unicode('.'.join(parts[1:]))
+
+ if options.get('ip_address'):
+ add_reverse = not options.get('no_reverse', False)
+
+ add_records_for_host(DNSName(host),
+ DNSName(domain).make_absolute(),
+ options['ip_address'],
+ add_forward=True,
+ add_reverse=add_reverse)
+ del options['ip_address']
+
+ update_sshfp_record(domain, unicode(parts[0]), entry_attrs)
+ except Exception as e:
+ exc = e
+ if options.get('random', False):
+ try:
+ entry_attrs['randompassword'] = unicode(getattr(context, 'randompassword'))
+ except AttributeError:
+ # On the off-chance some other extension deletes this from the
+ # context, don't crash.
+ pass
+ if exc:
+ raise errors.NonFatalError(
+ reason=_('The host was added but the DNS update failed with: %(exc)s') % dict(exc=exc)
+ )
+ set_certificate_attrs(entry_attrs)
+ set_kerberos_attrs(entry_attrs, options)
+ rename_ipaallowedtoperform_from_ldap(entry_attrs, options)
+
+ if options.get('all', False):
+ entry_attrs['managing'] = self.obj.get_managed_hosts(dn)
+ self.obj.get_password_attributes(ldap, dn, entry_attrs)
+ if entry_attrs['has_password']:
+ # If an OTP is set there is no keytab, at least not one
+ # fetched anywhere.
+ entry_attrs['has_keytab'] = False
+
+ convert_sshpubkey_post(entry_attrs)
+
+ return dn
+
+
+@register()
+class host_del(LDAPDelete):
+ __doc__ = _('Delete a host.')
+
+ msg_summary = _('Deleted host "%(value)s"')
+ member_attributes = ['managedby']
+
+ takes_options = LDAPDelete.takes_options + (
+ Flag('updatedns?',
+ doc=_('Remove A, AAAA, SSHFP and PTR records of the host(s) '
+ 'managed by IPA DNS'),
+ default=False,
+ ),
+ )
+
+ def pre_callback(self, ldap, dn, *keys, **options):
+ assert isinstance(dn, DN)
+ # If we aren't given a fqdn, find it
+ if _hostname_validator(None, keys[-1]) is not None:
+ hostentry = api.Command['host_show'](keys[-1])['result']
+ fqdn = hostentry['fqdn'][0]
+ else:
+ fqdn = keys[-1]
+ host_is_master(ldap, fqdn)
+ # Remove all service records for this host
+ truncated = True
+ while truncated:
+ try:
+ ret = api.Command['service_find'](fqdn)
+ truncated = ret['truncated']
+ services = ret['result']
+ except errors.NotFound:
+ break
+ else:
+ for entry_attrs in services:
+ principal = entry_attrs['krbprincipalname'][0]
+ (service, hostname, realm) = split_principal(principal)
+ if hostname.lower() == fqdn:
+ api.Command['service_del'](principal)
+ updatedns = options.get('updatedns', False)
+ if updatedns:
+ try:
+ updatedns = dns_container_exists(ldap)
+ except errors.NotFound:
+ updatedns = False
+
+ if updatedns:
+ # Remove A, AAAA, SSHFP and PTR records of the host
+ parts = fqdn.split('.')
+ domain = unicode('.'.join(parts[1:]))
+ # Get all resources for this host
+ rec_removed = False
+ try:
+ record = api.Command['dnsrecord_show'](
+ domain, parts[0])['result']
+ except errors.NotFound:
+ pass
+ else:
+ # remove PTR records first
+ for attr in ('arecord', 'aaaarecord'):
+ for val in record.get(attr, []):
+ rec_removed = (
+ remove_ptr_rec(val, parts[0], domain) or
+ rec_removed
+ )
+ try:
+ # remove all A, AAAA, SSHFP records of the host
+ api.Command['dnsrecord_mod'](
+ domain,
+ record['idnsname'][0],
+ arecord=[],
+ aaaarecord=[],
+ sshfprecord=[]
+ )
+ except errors.EmptyModlist:
+ pass
+ else:
+ rec_removed = True
+
+ if not rec_removed:
+ self.add_message(
+ messages.FailedToRemoveHostDNSRecords(
+ host=fqdn,
+ reason=_("No A, AAAA, SSHFP or PTR records found.")
+ )
+ )
+
+ if self.api.Command.ca_is_enabled()['result']:
+ try:
+ entry_attrs = ldap.get_entry(dn, ['usercertificate'])
+ except errors.NotFound:
+ self.obj.handle_not_found(*keys)
+
+ revoke_certs(entry_attrs.get('usercertificate', []), self.log)
+
+ return dn
+
+
+@register()
+class host_mod(LDAPUpdate):
+ __doc__ = _('Modify information about a host.')
+
+ has_output_params = LDAPUpdate.has_output_params + host_output_params
+ msg_summary = _('Modified host "%(value)s"')
+ member_attributes = ['managedby']
+
+ takes_options = LDAPUpdate.takes_options + (
+ Str('krbprincipalname?',
+ cli_name='principalname',
+ label=_('Principal name'),
+ doc=_('Kerberos principal name for this host'),
+ attribute=True,
+ ),
+ Flag('updatedns?',
+ doc=_('Update DNS entries'),
+ default=False,
+ ),
+ )
+
+ def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options):
+ assert isinstance(dn, DN)
+ # Allow an existing OTP to be reset but don't allow a OTP to be
+ # added to an enrolled host.
+ if options.get('userpassword') or options.get('random'):
+ entry = {}
+ self.obj.get_password_attributes(ldap, dn, entry)
+ if not entry['has_password'] and entry['has_keytab']:
+ raise errors.ValidationError(
+ name='password',
+ error=_('Password cannot be set on enrolled host.'))
+
+ # Once a principal name is set it cannot be changed
+ if 'cn' in entry_attrs:
+ raise errors.ACIError(info=_('cn is immutable'))
+ if 'locality' in entry_attrs:
+ entry_attrs['l'] = entry_attrs['locality']
+ if 'krbprincipalname' in entry_attrs:
+ entry_attrs_old = ldap.get_entry(
+ dn, ['objectclass', 'krbprincipalname']
+ )
+ if 'krbprincipalname' in entry_attrs_old:
+ msg = 'Principal name already set, it is unchangeable.'
+ raise errors.ACIError(info=msg)
+ obj_classes = entry_attrs_old['objectclass']
+ if 'krbprincipalaux' not in obj_classes:
+ obj_classes.append('krbprincipalaux')
+ entry_attrs['objectclass'] = obj_classes
+
+ # verify certificates
+ certs = entry_attrs.get('usercertificate') or []
+ certs_der = [x509.normalize_certificate(c) for c in certs]
+ for cert in certs_der:
+ x509.verify_cert_subject(ldap, keys[-1], cert)
+
+ # revoke removed certificates
+ if certs and self.api.Command.ca_is_enabled()['result']:
+ try:
+ entry_attrs_old = ldap.get_entry(dn, ['usercertificate'])
+ except errors.NotFound:
+ self.obj.handle_not_found(*keys)
+ old_certs = entry_attrs_old.get('usercertificate', [])
+ old_certs_der = [x509.normalize_certificate(c) for c in old_certs]
+ removed_certs_der = set(old_certs_der) - set(certs_der)
+ revoke_certs(removed_certs_der, self.log)
+
+ if certs:
+ entry_attrs['usercertificate'] = certs_der
+
+ if options.get('random'):
+ entry_attrs['userpassword'] = ipa_generate_password(characters=host_pwd_chars)
+ setattr(context, 'randompassword', entry_attrs['userpassword'])
+
+ if 'macaddress' in entry_attrs:
+ if 'objectclass' in entry_attrs:
+ obj_classes = entry_attrs['objectclass']
+ else:
+ _entry_attrs = ldap.get_entry(dn, ['objectclass'])
+ obj_classes = _entry_attrs['objectclass']
+ if 'ieee802device' not in obj_classes:
+ obj_classes.append('ieee802device')
+ entry_attrs['objectclass'] = obj_classes
+
+ if options.get('updatedns', False) and dns_container_exists(ldap):
+ parts = keys[-1].split('.')
+ domain = unicode('.'.join(parts[1:]))
+ try:
+ result = api.Command['dnszone_show'](domain)['result']
+ domain = result['idnsname'][0]
+ except errors.NotFound:
+ self.obj.handle_not_found(*keys)
+ update_sshfp_record(domain, unicode(parts[0]), entry_attrs)
+
+ if 'ipasshpubkey' in entry_attrs:
+ if 'objectclass' in entry_attrs:
+ obj_classes = entry_attrs['objectclass']
+ else:
+ _entry_attrs = ldap.get_entry(dn, ['objectclass'])
+ obj_classes = entry_attrs['objectclass'] = _entry_attrs['objectclass']
+ if 'ipasshhost' not in obj_classes:
+ obj_classes.append('ipasshhost')
+
+ update_krbticketflags(ldap, entry_attrs, attrs_list, options, True)
+
+ if 'krbticketflags' in entry_attrs:
+ if 'objectclass' not in entry_attrs:
+ entry_attrs_old = ldap.get_entry(dn, ['objectclass'])
+ entry_attrs['objectclass'] = entry_attrs_old['objectclass']
+ if 'krbticketpolicyaux' not in entry_attrs['objectclass']:
+ entry_attrs['objectclass'].append('krbticketpolicyaux')
+
+ add_sshpubkey_to_attrs_pre(self.context, attrs_list)
+
+ return dn
+
+ def post_callback(self, ldap, dn, entry_attrs, *keys, **options):
+ assert isinstance(dn, DN)
+ if options.get('random', False):
+ entry_attrs['randompassword'] = unicode(getattr(context, 'randompassword'))
+ set_certificate_attrs(entry_attrs)
+ set_kerberos_attrs(entry_attrs, options)
+ rename_ipaallowedtoperform_from_ldap(entry_attrs, options)
+ self.obj.get_password_attributes(ldap, dn, entry_attrs)
+ if entry_attrs['has_password']:
+ # If an OTP is set there is no keytab, at least not one
+ # fetched anywhere.
+ entry_attrs['has_keytab'] = False
+
+ if options.get('all', False):
+ entry_attrs['managing'] = self.obj.get_managed_hosts(dn)
+
+ self.obj.suppress_netgroup_memberof(ldap, entry_attrs)
+
+ convert_sshpubkey_post(entry_attrs)
+ remove_sshpubkey_from_output_post(self.context, entry_attrs)
+ convert_ipaassignedidview_post(entry_attrs, options)
+
+ return dn
+
+
+@register()
+class host_find(LDAPSearch):
+ __doc__ = _('Search for hosts.')
+
+ has_output_params = LDAPSearch.has_output_params + host_output_params
+ msg_summary = ngettext(
+ '%(count)d host matched', '%(count)d hosts matched', 0
+ )
+ member_attributes = ['memberof', 'enrolledby', 'managedby']
+
+ def get_options(self):
+ for option in super(host_find, self).get_options():
+ yield option
+ # "managing" membership has to be added and processed separately
+ for option in self.get_member_options('managing'):
+ yield option
+
+ def pre_callback(self, ldap, filter, attrs_list, base_dn, scope, *args, **options):
+ assert isinstance(base_dn, DN)
+ if 'locality' in attrs_list:
+ attrs_list.remove('locality')
+ attrs_list.append('l')
+ if 'man_host' in options or 'not_man_host' in options:
+ hosts = []
+ if options.get('man_host') is not None:
+ for pkey in options.get('man_host', []):
+ dn = self.obj.get_dn(pkey)
+ try:
+ entry_attrs = ldap.get_entry(dn, ['managedby'])
+ except errors.NotFound:
+ self.obj.handle_not_found(pkey)
+ hosts.append(set(entry_attrs.get('managedby', '')))
+ hosts = list(reduce(lambda s1, s2: s1 & s2, hosts))
+
+ if not hosts:
+ # There is no host managing _all_ hosts in --man-hosts
+ filter = ldap.combine_filters(
+ (filter, '(objectclass=disabled)'), ldap.MATCH_ALL
+ )
+
+ not_hosts = []
+ if options.get('not_man_host') is not None:
+ for pkey in options.get('not_man_host', []):
+ dn = self.obj.get_dn(pkey)
+ try:
+ entry_attrs = ldap.get_entry(dn, ['managedby'])
+ except errors.NotFound:
+ self.obj.handle_not_found(pkey)
+ not_hosts += entry_attrs.get('managedby', [])
+ not_hosts = list(set(not_hosts))
+
+ for target_hosts, filter_op in ((hosts, ldap.MATCH_ANY),
+ (not_hosts, ldap.MATCH_NONE)):
+ hosts_avas = [DN(host)[0][0] for host in target_hosts]
+ hosts_filters = [ldap.make_filter_from_attr(ava.attr, ava.value)
+ for ava in hosts_avas]
+ hosts_filter = ldap.combine_filters(hosts_filters, filter_op)
+
+ filter = ldap.combine_filters(
+ (filter, hosts_filter), ldap.MATCH_ALL
+ )
+
+ add_sshpubkey_to_attrs_pre(self.context, attrs_list)
+
+ return (filter.replace('locality', 'l'), base_dn, scope)
+
+ def post_callback(self, ldap, entries, truncated, *args, **options):
+ if options.get('pkey_only', False):
+ return truncated
+ for entry_attrs in entries:
+ set_certificate_attrs(entry_attrs)
+ set_kerberos_attrs(entry_attrs, options)
+ rename_ipaallowedtoperform_from_ldap(entry_attrs, options)
+ self.obj.suppress_netgroup_memberof(ldap, entry_attrs)
+
+ if options.get('all', False):
+ entry_attrs['managing'] = self.obj.get_managed_hosts(entry_attrs.dn)
+
+ convert_sshpubkey_post(entry_attrs)
+ remove_sshpubkey_from_output_post(self.context, entry_attrs)
+ convert_ipaassignedidview_post(entry_attrs, options)
+
+ remove_sshpubkey_from_output_list_post(self.context, entries)
+
+ return truncated
+
+
+@register()
+class host_show(LDAPRetrieve):
+ __doc__ = _('Display information about a host.')
+
+ has_output_params = LDAPRetrieve.has_output_params + host_output_params
+ takes_options = LDAPRetrieve.takes_options + (
+ Str('out?',
+ doc=_('file to store certificate in'),
+ ),
+ )
+
+ member_attributes = ['managedby']
+
+ def pre_callback(self, ldap, dn, attrs_list, *keys, **options):
+ assert isinstance(dn, DN)
+ add_sshpubkey_to_attrs_pre(self.context, attrs_list)
+ return dn
+
+ def post_callback(self, ldap, dn, entry_attrs, *keys, **options):
+ assert isinstance(dn, DN)
+ self.obj.get_password_attributes(ldap, dn, entry_attrs)
+ if entry_attrs['has_password']:
+ # If an OTP is set there is no keytab, at least not one
+ # fetched anywhere.
+ entry_attrs['has_keytab'] = False
+
+ set_certificate_attrs(entry_attrs)
+ set_kerberos_attrs(entry_attrs, options)
+ rename_ipaallowedtoperform_from_ldap(entry_attrs, options)
+
+ if options.get('all', False):
+ entry_attrs['managing'] = self.obj.get_managed_hosts(dn)
+
+ self.obj.suppress_netgroup_memberof(ldap, entry_attrs)
+
+ convert_sshpubkey_post(entry_attrs)
+ remove_sshpubkey_from_output_post(self.context, entry_attrs)
+ convert_ipaassignedidview_post(entry_attrs, options)
+
+ return dn
+
+
+@register()
+class host_disable(LDAPQuery):
+ __doc__ = _('Disable the Kerberos key, SSL certificate and all services of a host.')
+
+ has_output = output.standard_value
+ msg_summary = _('Disabled host "%(value)s"')
+
+ def execute(self, *keys, **options):
+ ldap = self.obj.backend
+
+ # If we aren't given a fqdn, find it
+ if _hostname_validator(None, keys[-1]) is not None:
+ hostentry = api.Command['host_show'](keys[-1])['result']
+ fqdn = hostentry['fqdn'][0]
+ else:
+ fqdn = keys[-1]
+
+ host_is_master(ldap, fqdn)
+
+ # See if we actually do anthing here, and if not raise an exception
+ done_work = False
+
+ truncated = True
+ while truncated:
+ try:
+ ret = api.Command['service_find'](fqdn)
+ truncated = ret['truncated']
+ services = ret['result']
+ except errors.NotFound:
+ break
+ else:
+ for entry_attrs in services:
+ principal = entry_attrs['krbprincipalname'][0]
+ (service, hostname, realm) = split_principal(principal)
+ if hostname.lower() == fqdn:
+ try:
+ api.Command['service_disable'](principal)
+ done_work = True
+ except errors.AlreadyInactive:
+ pass
+
+ dn = self.obj.get_dn(*keys, **options)
+ try:
+ entry_attrs = ldap.get_entry(dn, ['usercertificate'])
+ except errors.NotFound:
+ self.obj.handle_not_found(*keys)
+ if self.api.Command.ca_is_enabled()['result']:
+ certs = entry_attrs.get('usercertificate', [])
+
+ if certs:
+ revoke_certs(certs, self.log)
+ # Remove the usercertificate altogether
+ entry_attrs['usercertificate'] = None
+ ldap.update_entry(entry_attrs)
+ done_work = True
+
+ self.obj.get_password_attributes(ldap, dn, entry_attrs)
+ if entry_attrs['has_keytab']:
+ ldap.remove_principal_key(dn)
+ done_work = True
+
+ if not done_work:
+ raise errors.AlreadyInactive()
+
+ return dict(
+ result=True,
+ value=pkey_to_value(keys[0], options),
+ )
+
+ def post_callback(self, ldap, dn, entry_attrs, *keys, **options):
+ assert isinstance(dn, DN)
+ self.obj.suppress_netgroup_memberof(ldap, entry_attrs)
+ return dn
+
+
+@register()
+class host_add_managedby(LDAPAddMember):
+ __doc__ = _('Add hosts that can manage this host.')
+
+ member_attributes = ['managedby']
+ has_output_params = LDAPAddMember.has_output_params + host_output_params
+ allow_same = True
+
+ def post_callback(self, ldap, completed, failed, dn, entry_attrs, *keys, **options):
+ assert isinstance(dn, DN)
+ self.obj.suppress_netgroup_memberof(ldap, entry_attrs)
+ return (completed, dn)
+
+
+@register()
+class host_remove_managedby(LDAPRemoveMember):
+ __doc__ = _('Remove hosts that can manage this host.')
+
+ member_attributes = ['managedby']
+ has_output_params = LDAPRemoveMember.has_output_params + host_output_params
+
+ def post_callback(self, ldap, completed, failed, dn, entry_attrs, *keys, **options):
+ assert isinstance(dn, DN)
+ self.obj.suppress_netgroup_memberof(ldap, entry_attrs)
+ return (completed, dn)
+
+
+@register()
+class host_allow_retrieve_keytab(LDAPAddMember):
+ __doc__ = _('Allow users, groups, hosts or host groups to retrieve a keytab'
+ ' of this host.')
+ member_attributes = ['ipaallowedtoperform_read_keys']
+ has_output_params = LDAPAddMember.has_output_params + host_output_params
+
+ def pre_callback(self, ldap, dn, found, not_found, *keys, **options):
+ rename_ipaallowedtoperform_to_ldap(found)
+ rename_ipaallowedtoperform_to_ldap(not_found)
+ add_missing_object_class(ldap, u'ipaallowedoperations', dn)
+ return dn
+
+ def post_callback(self, ldap, completed, failed, dn, entry_attrs, *keys, **options):
+ rename_ipaallowedtoperform_from_ldap(entry_attrs, options)
+ rename_ipaallowedtoperform_from_ldap(failed, options)
+ return (completed, dn)
+
+
+@register()
+class host_disallow_retrieve_keytab(LDAPRemoveMember):
+ __doc__ = _('Disallow users, groups, hosts or host groups to retrieve a '
+ 'keytab of this host.')
+ member_attributes = ['ipaallowedtoperform_read_keys']
+ has_output_params = LDAPRemoveMember.has_output_params + host_output_params
+
+ def pre_callback(self, ldap, dn, found, not_found, *keys, **options):
+ rename_ipaallowedtoperform_to_ldap(found)
+ rename_ipaallowedtoperform_to_ldap(not_found)
+ return dn
+
+ def post_callback(self, ldap, completed, failed, dn, entry_attrs, *keys, **options):
+ rename_ipaallowedtoperform_from_ldap(entry_attrs, options)
+ rename_ipaallowedtoperform_from_ldap(failed, options)
+ return (completed, dn)
+
+
+@register()
+class host_allow_create_keytab(LDAPAddMember):
+ __doc__ = _('Allow users, groups, hosts or host groups to create a keytab '
+ 'of this host.')
+ member_attributes = ['ipaallowedtoperform_write_keys']
+ has_output_params = LDAPAddMember.has_output_params + host_output_params
+
+ def pre_callback(self, ldap, dn, found, not_found, *keys, **options):
+ rename_ipaallowedtoperform_to_ldap(found)
+ rename_ipaallowedtoperform_to_ldap(not_found)
+ add_missing_object_class(ldap, u'ipaallowedoperations', dn)
+ return dn
+
+ def post_callback(self, ldap, completed, failed, dn, entry_attrs, *keys, **options):
+ rename_ipaallowedtoperform_from_ldap(entry_attrs, options)
+ rename_ipaallowedtoperform_from_ldap(failed, options)
+ return (completed, dn)
+
+
+@register()
+class host_disallow_create_keytab(LDAPRemoveMember):
+ __doc__ = _('Disallow users, groups, hosts or host groups to create a '
+ 'keytab of this host.')
+ member_attributes = ['ipaallowedtoperform_write_keys']
+ has_output_params = LDAPRemoveMember.has_output_params + host_output_params
+
+ def pre_callback(self, ldap, dn, found, not_found, *keys, **options):
+ rename_ipaallowedtoperform_to_ldap(found)
+ rename_ipaallowedtoperform_to_ldap(not_found)
+ return dn
+
+ def post_callback(self, ldap, completed, failed, dn, entry_attrs, *keys, **options):
+ rename_ipaallowedtoperform_from_ldap(entry_attrs, options)
+ rename_ipaallowedtoperform_from_ldap(failed, options)
+ return (completed, dn)
+
+
+@register()
+class host_add_cert(LDAPAddAttribute):
+ __doc__ = _('Add certificates to host entry')
+ msg_summary = _('Added certificates to host "%(value)s"')
+ attribute = 'usercertificate'
+
+
+@register()
+class host_remove_cert(LDAPRemoveAttribute):
+ __doc__ = _('Remove certificates from host entry')
+ msg_summary = _('Removed certificates from host "%(value)s"')
+ attribute = 'usercertificate'
+
+ def post_callback(self, ldap, dn, entry_attrs, *keys, **options):
+ assert isinstance(dn, DN)
+
+ if 'usercertificate' in options:
+ revoke_certs(options['usercertificate'], self.log)
+
+ return dn
diff --git a/ipaserver/plugins/hostgroup.py b/ipaserver/plugins/hostgroup.py
new file mode 100644
index 000000000..dab354d9c
--- /dev/null
+++ b/ipaserver/plugins/hostgroup.py
@@ -0,0 +1,316 @@
+# Authors:
+# Rob Crittenden <rcritten@redhat.com>
+# Pavel Zuna <pzuna@redhat.com>
+#
+# Copyright (C) 2009 Red Hat
+# see file 'COPYING' for use and warranty information
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+import six
+
+from ipalib.plugable import Registry
+from .baseldap import (LDAPObject, LDAPCreate, LDAPRetrieve,
+ LDAPDelete, LDAPUpdate, LDAPSearch,
+ LDAPAddMember, LDAPRemoveMember,
+ entry_from_entry, wait_for_value)
+from ipalib import Str, api, _, ngettext, errors
+from .netgroup import NETGROUP_PATTERN, NETGROUP_PATTERN_ERRMSG
+from ipapython.dn import DN
+
+if six.PY3:
+ unicode = str
+
+__doc__ = _("""
+Groups of hosts.
+
+Manage groups of hosts. This is useful for applying access control to a
+number of hosts by using Host-based Access Control.
+
+EXAMPLES:
+
+ Add a new host group:
+ ipa hostgroup-add --desc="Baltimore hosts" baltimore
+
+ Add another new host group:
+ ipa hostgroup-add --desc="Maryland hosts" maryland
+
+ Add members to the hostgroup (using Bash brace expansion):
+ ipa hostgroup-add-member --hosts={box1,box2,box3} baltimore
+
+ Add a hostgroup as a member of another hostgroup:
+ ipa hostgroup-add-member --hostgroups=baltimore maryland
+
+ Remove a host from the hostgroup:
+ ipa hostgroup-remove-member --hosts=box2 baltimore
+
+ Display a host group:
+ ipa hostgroup-show baltimore
+
+ Delete a hostgroup:
+ ipa hostgroup-del baltimore
+""")
+
+
+def get_complete_hostgroup_member_list(hostgroup):
+ result = api.Command['hostgroup_show'](hostgroup)['result']
+ direct = list(result.get('member_host', []))
+ indirect = list(result.get('memberindirect_host', []))
+ return direct + indirect
+
+
+register = Registry()
+
+PROTECTED_HOSTGROUPS = (u'ipaservers',)
+
+
+@register()
+class hostgroup(LDAPObject):
+ """
+ Hostgroup object.
+ """
+ container_dn = api.env.container_hostgroup
+ object_name = _('host group')
+ object_name_plural = _('host groups')
+ object_class = ['ipaobject', 'ipahostgroup']
+ permission_filter_objectclasses = ['ipahostgroup']
+ search_attributes = ['cn', 'description', 'member', 'memberof']
+ default_attributes = ['cn', 'description', 'member', 'memberof',
+ 'memberindirect', 'memberofindirect',
+ ]
+ uuid_attribute = 'ipauniqueid'
+ attribute_members = {
+ 'member': ['host', 'hostgroup'],
+ 'memberof': ['hostgroup', 'netgroup', 'hbacrule', 'sudorule'],
+ 'memberindirect': ['host', 'hostgroup'],
+ 'memberofindirect': ['hostgroup', 'hbacrule', 'sudorule'],
+ }
+ managed_permissions = {
+ 'System: Read Hostgroups': {
+ 'replaces_global_anonymous_aci': True,
+ 'ipapermbindruletype': 'all',
+ 'ipapermright': {'read', 'search', 'compare'},
+ 'ipapermdefaultattr': {
+ 'businesscategory', 'cn', 'description', 'ipauniqueid', 'o',
+ 'objectclass', 'ou', 'owner', 'seealso',
+ },
+ },
+ 'System: Read Hostgroup Membership': {
+ 'replaces_global_anonymous_aci': True,
+ 'ipapermbindruletype': 'all',
+ 'ipapermright': {'read', 'search', 'compare'},
+ 'ipapermdefaultattr': {
+ 'member', 'memberof', 'memberuser', 'memberhost',
+ },
+ },
+ 'System: Add Hostgroups': {
+ 'ipapermright': {'add'},
+ 'replaces': [
+ '(target = "ldap:///cn=*,cn=hostgroups,cn=accounts,$SUFFIX")(version 3.0;acl "permission:Add Hostgroups";allow (add) groupdn = "ldap:///cn=Add Hostgroups,cn=permissions,cn=pbac,$SUFFIX";)',
+ ],
+ 'default_privileges': {'Host Group Administrators'},
+ },
+ 'System: Modify Hostgroup Membership': {
+ 'ipapermright': {'write'},
+ 'ipapermtargetfilter': [
+ '(objectclass=ipahostgroup)',
+ '(!(cn=ipaservers))',
+ ],
+ 'ipapermdefaultattr': {'member'},
+ 'replaces': [
+ '(targetattr = "member")(target = "ldap:///cn=*,cn=hostgroups,cn=accounts,$SUFFIX")(version 3.0;acl "permission:Modify Hostgroup membership";allow (write) groupdn = "ldap:///cn=Modify Hostgroup membership,cn=permissions,cn=pbac,$SUFFIX";)',
+ ],
+ 'default_privileges': {'Host Group Administrators'},
+ },
+ 'System: Modify Hostgroups': {
+ 'ipapermright': {'write'},
+ 'ipapermdefaultattr': {'cn', 'description'},
+ 'replaces': [
+ '(targetattr = "cn || description")(target = "ldap:///cn=*,cn=hostgroups,cn=accounts,$SUFFIX")(version 3.0; acl "permission:Modify Hostgroups";allow (write) groupdn = "ldap:///cn=Modify Hostgroups,cn=permissions,cn=pbac,$SUFFIX";)',
+ ],
+ 'default_privileges': {'Host Group Administrators'},
+ },
+ 'System: Remove Hostgroups': {
+ 'ipapermright': {'delete'},
+ 'replaces': [
+ '(target = "ldap:///cn=*,cn=hostgroups,cn=accounts,$SUFFIX")(version 3.0;acl "permission:Remove Hostgroups";allow (delete) groupdn = "ldap:///cn=Remove Hostgroups,cn=permissions,cn=pbac,$SUFFIX";)',
+ ],
+ 'default_privileges': {'Host Group Administrators'},
+ },
+ }
+
+ label = _('Host Groups')
+ label_singular = _('Host Group')
+
+ takes_params = (
+ Str('cn',
+ pattern=NETGROUP_PATTERN,
+ pattern_errmsg=NETGROUP_PATTERN_ERRMSG,
+ cli_name='hostgroup_name',
+ label=_('Host-group'),
+ doc=_('Name of host-group'),
+ primary_key=True,
+ normalizer=lambda value: value.lower(),
+ ),
+ Str('description?',
+ cli_name='desc',
+ label=_('Description'),
+ doc=_('A description of this host-group'),
+ ),
+ )
+
+ def suppress_netgroup_memberof(self, ldap, dn, entry_attrs):
+ """
+ We don't want to show managed netgroups so remove them from the
+ memberOf list.
+ """
+ hgdn = DN(dn)
+ for member in list(entry_attrs.get('memberof', [])):
+ ngdn = DN(member)
+ if ngdn['cn'] != hgdn['cn']:
+ continue
+
+ filter = ldap.make_filter({'objectclass': 'mepmanagedentry'})
+ try:
+ ldap.get_entries(ngdn, ldap.SCOPE_BASE, filter, [''])
+ except errors.NotFound:
+ pass
+ else:
+ entry_attrs['memberof'].remove(member)
+
+
+@register()
+class hostgroup_add(LDAPCreate):
+ __doc__ = _('Add a new hostgroup.')
+
+ msg_summary = _('Added hostgroup "%(value)s"')
+
+ def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options):
+ assert isinstance(dn, DN)
+ try:
+ # check duplicity with hostgroups first to provide proper error
+ api.Object['hostgroup'].get_dn_if_exists(keys[-1])
+ self.obj.handle_duplicate_entry(*keys)
+ except errors.NotFound:
+ pass
+
+ try:
+ # when enabled, a managed netgroup is created for every hostgroup
+ # make sure that the netgroup can be created
+ api.Object['netgroup'].get_dn_if_exists(keys[-1])
+ raise errors.DuplicateEntry(message=unicode(_(
+ u'netgroup with name "%s" already exists. '
+ u'Hostgroups and netgroups share a common namespace'
+ ) % keys[-1]))
+ except errors.NotFound:
+ pass
+
+ return dn
+
+ def post_callback(self, ldap, dn, entry_attrs, *keys, **options):
+ assert isinstance(dn, DN)
+ # Always wait for the associated netgroup to be created so we can
+ # be sure to ignore it in memberOf
+ newentry = wait_for_value(ldap, dn, 'objectclass', 'mepOriginEntry')
+ entry_from_entry(entry_attrs, newentry)
+ self.obj.suppress_netgroup_memberof(ldap, dn, entry_attrs)
+
+ return dn
+
+
+@register()
+class hostgroup_del(LDAPDelete):
+ __doc__ = _('Delete a hostgroup.')
+
+ msg_summary = _('Deleted hostgroup "%(value)s"')
+
+ def pre_callback(self, ldap, dn, *keys, **options):
+ if keys[0] in PROTECTED_HOSTGROUPS:
+ raise errors.ProtectedEntryError(label=_(u'hostgroup'),
+ key=keys[0],
+ reason=_(u'privileged hostgroup'))
+
+ return dn
+
+
+@register()
+class hostgroup_mod(LDAPUpdate):
+ __doc__ = _('Modify a hostgroup.')
+
+ msg_summary = _('Modified hostgroup "%(value)s"')
+
+ def post_callback(self, ldap, dn, entry_attrs, *keys, **options):
+ assert isinstance(dn, DN)
+ self.obj.suppress_netgroup_memberof(ldap, dn, entry_attrs)
+ return dn
+
+
+@register()
+class hostgroup_find(LDAPSearch):
+ __doc__ = _('Search for hostgroups.')
+
+ member_attributes = ['member', 'memberof']
+ msg_summary = ngettext(
+ '%(count)d hostgroup matched', '%(count)d hostgroups matched', 0
+ )
+
+ def post_callback(self, ldap, entries, truncated, *args, **options):
+ if options.get('pkey_only', False):
+ return truncated
+ for entry in entries:
+ self.obj.suppress_netgroup_memberof(ldap, entry.dn, entry)
+ return truncated
+
+
+@register()
+class hostgroup_show(LDAPRetrieve):
+ __doc__ = _('Display information about a hostgroup.')
+
+ def post_callback(self, ldap, dn, entry_attrs, *keys, **options):
+ assert isinstance(dn, DN)
+ self.obj.suppress_netgroup_memberof(ldap, dn, entry_attrs)
+ return dn
+
+
+@register()
+class hostgroup_add_member(LDAPAddMember):
+ __doc__ = _('Add members to a hostgroup.')
+
+ def post_callback(self, ldap, completed, failed, dn, entry_attrs, *keys, **options):
+ assert isinstance(dn, DN)
+ self.obj.suppress_netgroup_memberof(ldap, dn, entry_attrs)
+ return (completed, dn)
+
+
+@register()
+class hostgroup_remove_member(LDAPRemoveMember):
+ __doc__ = _('Remove members from a hostgroup.')
+
+ def pre_callback(self, ldap, dn, found, not_found, *keys, **options):
+ if keys[0] in PROTECTED_HOSTGROUPS and 'host' in options:
+ result = api.Command.hostgroup_show(keys[0])
+ hosts_left = set(result['result'].get('member_host', []))
+ hosts_deleted = set(options['host'])
+ if hosts_left.issubset(hosts_deleted):
+ raise errors.LastMemberError(key=sorted(hosts_deleted)[0],
+ label=_(u'hostgroup'),
+ container=keys[0])
+
+ return dn
+
+ def post_callback(self, ldap, completed, failed, dn, entry_attrs, *keys, **options):
+ assert isinstance(dn, DN)
+ self.obj.suppress_netgroup_memberof(ldap, dn, entry_attrs)
+ return (completed, dn)
+
diff --git a/ipaserver/plugins/idrange.py b/ipaserver/plugins/idrange.py
new file mode 100644
index 000000000..ccd67995e
--- /dev/null
+++ b/ipaserver/plugins/idrange.py
@@ -0,0 +1,769 @@
+# Authors:
+# Sumit Bose <sbose@redhat.com>
+#
+# Copyright (C) 2012 Red Hat
+# see file 'COPYING' for use and warranty information
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+import six
+
+from ipalib.plugable import Registry
+from .baseldap import (LDAPObject, LDAPCreate, LDAPDelete,
+ LDAPRetrieve, LDAPSearch, LDAPUpdate)
+from ipalib import api, Int, Str, StrEnum, _, ngettext
+from ipalib import errors
+from ipapython.dn import DN
+
+if six.PY3:
+ unicode = str
+
+if api.env.in_server and api.env.context in ['lite', 'server']:
+ try:
+ import ipaserver.dcerpc
+ _dcerpc_bindings_installed = True
+ except ImportError:
+ _dcerpc_bindings_installed = False
+
+ID_RANGE_VS_DNA_WARNING = """=======
+WARNING:
+
+DNA plugin in 389-ds will allocate IDs based on the ranges configured for the
+local domain. Currently the DNA plugin *cannot* be reconfigured itself based
+on the local ranges set via this family of commands.
+
+Manual configuration change has to be done in the DNA plugin configuration for
+the new local range. Specifically, The dnaNextRange attribute of 'cn=Posix
+IDs,cn=Distributed Numeric Assignment Plugin,cn=plugins,cn=config' has to be
+modified to match the new range.
+=======
+"""
+
+__doc__ = _("""
+ID ranges
+
+Manage ID ranges used to map Posix IDs to SIDs and back.
+
+There are two type of ID ranges which are both handled by this utility:
+
+ - the ID ranges of the local domain
+ - the ID ranges of trusted remote domains
+
+Both types have the following attributes in common:
+
+ - base-id: the first ID of the Posix ID range
+ - range-size: the size of the range
+
+With those two attributes a range object can reserve the Posix IDs starting
+with base-id up to but not including base-id+range-size exclusively.
+
+Additionally an ID range of the local domain may set
+ - rid-base: the first RID(*) of the corresponding RID range
+ - secondary-rid-base: first RID of the secondary RID range
+
+and an ID range of a trusted domain must set
+ - rid-base: the first RID of the corresponding RID range
+ - sid: domain SID of the trusted domain
+
+
+
+EXAMPLE: Add a new ID range for a trusted domain
+
+Since there might be more than one trusted domain the domain SID must be given
+while creating the ID range.
+
+ ipa idrange-add --base-id=1200000 --range-size=200000 --rid-base=0 \\
+ --dom-sid=S-1-5-21-123-456-789 trusted_dom_range
+
+This ID range is then used by the IPA server and the SSSD IPA provider to
+assign Posix UIDs to users from the trusted domain.
+
+If e.g a range for a trusted domain is configured with the following values:
+ base-id = 1200000
+ range-size = 200000
+ rid-base = 0
+the RIDs 0 to 199999 are mapped to the Posix ID from 1200000 to 13999999. So
+RID 1000 <-> Posix ID 1201000
+
+
+
+EXAMPLE: Add a new ID range for the local domain
+
+To create an ID range for the local domain it is not necessary to specify a
+domain SID. But since it is possible that a user and a group can have the same
+value as Posix ID a second RID interval is needed to handle conflicts.
+
+ ipa idrange-add --base-id=1200000 --range-size=200000 --rid-base=1000 \\
+ --secondary-rid-base=1000000 local_range
+
+The data from the ID ranges of the local domain are used by the IPA server
+internally to assign SIDs to IPA users and groups. The SID will then be stored
+in the user or group objects.
+
+If e.g. the ID range for the local domain is configured with the values from
+the example above then a new user with the UID 1200007 will get the RID 1007.
+If this RID is already used by a group the RID will be 1000007. This can only
+happen if a user or a group object was created with a fixed ID because the
+automatic assignment will not assign the same ID twice. Since there are only
+users and groups sharing the same ID namespace it is sufficient to have only
+one fallback range to handle conflicts.
+
+To find the Posix ID for a given RID from the local domain it has to be
+checked first if the RID falls in the primary or secondary RID range and
+the rid-base or the secondary-rid-base has to be subtracted, respectively,
+and the base-id has to be added to get the Posix ID.
+
+Typically the creation of ID ranges happens behind the scenes and this CLI
+must not be used at all. The ID range for the local domain will be created
+during installation or upgrade from an older version. The ID range for a
+trusted domain will be created together with the trust by 'ipa trust-add ...'.
+
+USE CASES:
+
+ Add an ID range from a transitively trusted domain
+
+ If the trusted domain (A) trusts another domain (B) as well and this trust
+ is transitive 'ipa trust-add domain-A' will only create a range for
+ domain A. The ID range for domain B must be added manually.
+
+ Add an additional ID range for the local domain
+
+ If the ID range of the local domain is exhausted, i.e. no new IDs can be
+ assigned to Posix users or groups by the DNA plugin, a new range has to be
+ created to allow new users and groups to be added. (Currently there is no
+ connection between this range CLI and the DNA plugin, but a future version
+ might be able to modify the configuration of the DNS plugin as well)
+
+In general it is not necessary to modify or delete ID ranges. If there is no
+other way to achieve a certain configuration than to modify or delete an ID
+range it should be done with great care. Because UIDs are stored in the file
+system and are used for access control it might be possible that users are
+allowed to access files of other users if an ID range got deleted and reused
+for a different domain.
+
+(*) The RID is typically the last integer of a user or group SID which follows
+the domain SID. E.g. if the domain SID is S-1-5-21-123-456-789 and a user from
+this domain has the SID S-1-5-21-123-456-789-1010 then 1010 id the RID of the
+user. RIDs are unique in a domain, 32bit values and are used for users and
+groups.
+
+{0}
+""".format(ID_RANGE_VS_DNA_WARNING))
+
+register = Registry()
+
+@register()
+class idrange(LDAPObject):
+ """
+ Range object.
+ """
+
+ range_type = ('domain', 'ad', 'ipa')
+ container_dn = api.env.container_ranges
+ object_name = ('range')
+ object_name_plural = ('ranges')
+ object_class = ['ipaIDrange']
+ permission_filter_objectclasses = ['ipaidrange']
+ possible_objectclasses = ['ipadomainidrange', 'ipatrustedaddomainrange']
+ default_attributes = ['cn', 'ipabaseid', 'ipaidrangesize', 'ipabaserid',
+ 'ipasecondarybaserid', 'ipanttrusteddomainsid',
+ 'iparangetype']
+ managed_permissions = {
+ 'System: Read ID Ranges': {
+ 'replaces_global_anonymous_aci': True,
+ 'ipapermbindruletype': 'all',
+ 'ipapermright': {'read', 'search', 'compare'},
+ 'ipapermdefaultattr': {
+ 'cn', 'objectclass',
+ 'ipabaseid', 'ipaidrangesize', 'iparangetype',
+ 'ipabaserid', 'ipasecondarybaserid', 'ipanttrusteddomainsid',
+ },
+ },
+ }
+
+ label = _('ID Ranges')
+ label_singular = _('ID Range')
+
+ # The commented range types are planned but not yet supported
+ range_types = {
+ u'ipa-local': unicode(_('local domain range')),
+ # u'ipa-ad-winsync': unicode(_('Active Directory winsync range')),
+ u'ipa-ad-trust': unicode(_('Active Directory domain range')),
+ u'ipa-ad-trust-posix': unicode(_('Active Directory trust range with '
+ 'POSIX attributes')),
+ # u'ipa-ipa-trust': unicode(_('IPA trust range')),
+ }
+
+ takes_params = (
+ Str('cn',
+ cli_name='name',
+ label=_('Range name'),
+ primary_key=True,
+ ),
+ Int('ipabaseid',
+ cli_name='base_id',
+ label=_("First Posix ID of the range"),
+ ),
+ Int('ipaidrangesize',
+ cli_name='range_size',
+ label=_("Number of IDs in the range"),
+ ),
+ Int('ipabaserid?',
+ cli_name='rid_base',
+ label=_('First RID of the corresponding RID range'),
+ ),
+ Int('ipasecondarybaserid?',
+ cli_name='secondary_rid_base',
+ label=_('First RID of the secondary RID range'),
+ ),
+ Str('ipanttrusteddomainsid?',
+ cli_name='dom_sid',
+ flags=('no_update',),
+ label=_('Domain SID of the trusted domain'),
+ ),
+ Str('ipanttrusteddomainname?',
+ cli_name='dom_name',
+ flags=('no_search', 'virtual_attribute', 'no_update'),
+ label=_('Name of the trusted domain'),
+ ),
+ StrEnum('iparangetype?',
+ label=_('Range type'),
+ cli_name='type',
+ doc=(_('ID range type, one of {vals}'
+ .format(vals=', '.join(range_types.keys())))),
+ values=tuple(range_types.keys()),
+ flags=['no_update'],
+ )
+ )
+
+ def handle_iparangetype(self, entry_attrs, options, keep_objectclass=False):
+ if not any((options.get('pkey_only', False),
+ options.get('raw', False))):
+ range_type = entry_attrs['iparangetype'][0]
+ entry_attrs['iparangetyperaw'] = [range_type]
+ entry_attrs['iparangetype'] = [self.range_types.get(range_type, None)]
+
+ # Remove the objectclass
+ if not keep_objectclass:
+ if not options.get('all', False) or options.get('pkey_only', False):
+ entry_attrs.pop('objectclass', None)
+
+ def handle_ipabaserid(self, entry_attrs, options):
+ if any((options.get('pkey_only', False), options.get('raw', False))):
+ return
+ if entry_attrs['iparangetype'][0] == u'ipa-ad-trust-posix':
+ entry_attrs.pop('ipabaserid', None)
+
+ def check_ids_in_modified_range(self, old_base, old_size, new_base,
+ new_size):
+ if new_base is None and new_size is None:
+ # nothing to check
+ return
+ if new_base is None:
+ new_base = old_base
+ if new_size is None:
+ new_size = old_size
+ old_interval = (old_base, old_base + old_size - 1)
+ new_interval = (new_base, new_base + new_size - 1)
+ checked_intervals = []
+ low_diff = new_interval[0] - old_interval[0]
+ if low_diff > 0:
+ checked_intervals.append((old_interval[0],
+ min(old_interval[1], new_interval[0] - 1)))
+ high_diff = old_interval[1] - new_interval[1]
+ if high_diff > 0:
+ checked_intervals.append((max(old_interval[0], new_interval[1] + 1),
+ old_interval[1]))
+
+ if not checked_intervals:
+ # range is equal or covers the entire old range, nothing to check
+ return
+
+ ldap = self.backend
+ id_filter_base = ["(objectclass=posixAccount)",
+ "(objectclass=posixGroup)",
+ "(objectclass=ipaIDObject)"]
+ id_filter_ids = []
+
+ for id_low, id_high in checked_intervals:
+ id_filter_ids.append("(&(uidNumber>=%(low)d)(uidNumber<=%(high)d))"
+ % dict(low=id_low, high=id_high))
+ id_filter_ids.append("(&(gidNumber>=%(low)d)(gidNumber<=%(high)d))"
+ % dict(low=id_low, high=id_high))
+ id_filter = ldap.combine_filters(
+ [ldap.combine_filters(id_filter_base, "|"),
+ ldap.combine_filters(id_filter_ids, "|")],
+ "&")
+
+ try:
+ (objects, truncated) = ldap.find_entries(filter=id_filter,
+ attrs_list=['uid', 'cn'],
+ base_dn=DN(api.env.container_accounts, api.env.basedn))
+ except errors.NotFound:
+ # no objects in this range found, allow the command
+ pass
+ else:
+ raise errors.ValidationError(name="ipabaseid,ipaidrangesize",
+ error=_('range modification leaving objects with ID out '
+ 'of the defined range is not allowed'))
+
+ def get_domain_validator(self):
+ if not _dcerpc_bindings_installed:
+ raise errors.NotFound(reason=_('Cannot perform SID validation '
+ 'without Samba 4 support installed. Make sure you have '
+ 'installed server-trust-ad sub-package of IPA on the server'))
+
+ domain_validator = ipaserver.dcerpc.DomainValidator(self.api)
+
+ if not domain_validator.is_configured():
+ raise errors.NotFound(reason=_('Cross-realm trusts are not '
+ 'configured. Make sure you have run ipa-adtrust-install '
+ 'on the IPA server first'))
+
+ return domain_validator
+
+ def validate_trusted_domain_sid(self, sid):
+
+ domain_validator = self.get_domain_validator()
+
+ if not domain_validator.is_trusted_domain_sid_valid(sid):
+ raise errors.ValidationError(name='domain SID',
+ error=_('SID is not recognized as a valid SID for a '
+ 'trusted domain'))
+
+ def get_trusted_domain_sid_from_name(self, name):
+ """ Returns unicode string representation for given trusted domain name
+ or None if SID forthe given trusted domain name could not be found."""
+
+ domain_validator = self.get_domain_validator()
+
+ sid = domain_validator.get_sid_from_domain_name(name)
+
+ if sid is not None:
+ sid = unicode(sid)
+
+ return sid
+
+ # checks that primary and secondary rid ranges do not overlap
+ def are_rid_ranges_overlapping(self, rid_base, secondary_rid_base, size):
+
+ # if any of these is None, the check does not apply
+ if any(attr is None for attr in (rid_base, secondary_rid_base, size)):
+ return False
+
+ # sort the bases
+ if rid_base > secondary_rid_base:
+ rid_base, secondary_rid_base = secondary_rid_base, rid_base
+
+ # rid_base is now <= secondary_rid_base,
+ # so the following check is sufficient
+ if rid_base + size <= secondary_rid_base:
+ return False
+ else:
+ return True
+
+
+@register()
+class idrange_add(LDAPCreate):
+ __doc__ = _("""
+ Add new ID range.
+
+ To add a new ID range you always have to specify
+
+ --base-id
+ --range-size
+
+ Additionally
+
+ --rid-base
+ --secondary-rid-base
+
+ may be given for a new ID range for the local domain while
+
+ --rid-base
+ --dom-sid
+
+ must be given to add a new range for a trusted AD domain.
+
+{0}
+""".format(ID_RANGE_VS_DNA_WARNING))
+
+ msg_summary = _('Added ID range "%(value)s"')
+
+ def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options):
+ assert isinstance(dn, DN)
+
+ is_set = lambda x: (x in entry_attrs) and (entry_attrs[x] is not None)
+
+ # This needs to stay in options since there is no
+ # ipanttrusteddomainname attribute in LDAP
+ if 'ipanttrusteddomainname' in options:
+ if is_set('ipanttrusteddomainsid'):
+ raise errors.ValidationError(name='ID Range setup',
+ error=_('Options dom-sid and dom-name '
+ 'cannot be used together'))
+
+ sid = self.obj.get_trusted_domain_sid_from_name(
+ options['ipanttrusteddomainname'])
+
+ if sid is not None:
+ entry_attrs['ipanttrusteddomainsid'] = sid
+ else:
+ raise errors.ValidationError(name='ID Range setup',
+ error=_('SID for the specified trusted domain name could '
+ 'not be found. Please specify the SID directly '
+ 'using dom-sid option.'))
+
+ # ipaNTTrustedDomainSID attribute set, this is AD Trusted domain range
+ if is_set('ipanttrusteddomainsid'):
+ entry_attrs['objectclass'].append('ipatrustedaddomainrange')
+
+ # Default to ipa-ad-trust if no type set
+ if not is_set('iparangetype'):
+ entry_attrs['iparangetype'] = u'ipa-ad-trust'
+
+ if entry_attrs['iparangetype'] == u'ipa-ad-trust':
+ if not is_set('ipabaserid'):
+ raise errors.ValidationError(
+ name='ID Range setup',
+ error=_('Options dom-sid/dom-name and rid-base must '
+ 'be used together')
+ )
+ elif entry_attrs['iparangetype'] == u'ipa-ad-trust-posix':
+ if is_set('ipabaserid') and entry_attrs['ipabaserid'] != 0:
+ raise errors.ValidationError(
+ name='ID Range setup',
+ error=_('Option rid-base must not be used when IPA '
+ 'range type is ipa-ad-trust-posix')
+ )
+ else:
+ entry_attrs['ipabaserid'] = 0
+ else:
+ raise errors.ValidationError(name='ID Range setup',
+ error=_('IPA Range type must be one of ipa-ad-trust '
+ 'or ipa-ad-trust-posix when SID of the trusted '
+ 'domain is specified'))
+
+ if is_set('ipasecondarybaserid'):
+ raise errors.ValidationError(name='ID Range setup',
+ error=_('Options dom-sid/dom-name and secondary-rid-base '
+ 'cannot be used together'))
+
+ # Validate SID as the one of trusted domains
+ self.obj.validate_trusted_domain_sid(
+ entry_attrs['ipanttrusteddomainsid'])
+
+ # ipaNTTrustedDomainSID attribute not set, this is local domain range
+ else:
+ entry_attrs['objectclass'].append('ipadomainidrange')
+
+ # Default to ipa-local if no type set
+ if 'iparangetype' not in entry_attrs:
+ entry_attrs['iparangetype'] = 'ipa-local'
+
+ # TODO: can also be ipa-ad-winsync here?
+ if entry_attrs['iparangetype'] in (u'ipa-ad-trust',
+ u'ipa-ad-trust-posix'):
+ raise errors.ValidationError(name='ID Range setup',
+ error=_('IPA Range type must not be one of ipa-ad-trust '
+ 'or ipa-ad-trust-posix when SID of the trusted '
+ 'domain is not specified.'))
+
+ # secondary base rid must be set if and only if base rid is set
+ if is_set('ipasecondarybaserid') != is_set('ipabaserid'):
+ raise errors.ValidationError(name='ID Range setup',
+ error=_('Options secondary-rid-base and rid-base must '
+ 'be used together'))
+
+ # and they must not overlap
+ if is_set('ipabaserid') and is_set('ipasecondarybaserid'):
+ if self.obj.are_rid_ranges_overlapping(
+ entry_attrs['ipabaserid'],
+ entry_attrs['ipasecondarybaserid'],
+ entry_attrs['ipaidrangesize']):
+ raise errors.ValidationError(name='ID Range setup',
+ error=_("Primary RID range and secondary RID range"
+ " cannot overlap"))
+
+ # rid-base and secondary-rid-base must be set if
+ # ipa-adtrust-install has been run on the system
+ adtrust_is_enabled = api.Command['adtrust_is_enabled']()['result']
+
+ if adtrust_is_enabled and not (
+ is_set('ipabaserid') and is_set('ipasecondarybaserid')):
+ raise errors.ValidationError(
+ name='ID Range setup',
+ error=_(
+ 'You must specify both rid-base and '
+ 'secondary-rid-base options, because '
+ 'ipa-adtrust-install has already been run.'
+ )
+ )
+ return dn
+
+ def post_callback(self, ldap, dn, entry_attrs, *keys, **options):
+ assert isinstance(dn, DN)
+ self.obj.handle_ipabaserid(entry_attrs, options)
+ self.obj.handle_iparangetype(entry_attrs, options,
+ keep_objectclass=True)
+ return dn
+
+
+@register()
+class idrange_del(LDAPDelete):
+ __doc__ = _('Delete an ID range.')
+
+ msg_summary = _('Deleted ID range "%(value)s"')
+
+ def pre_callback(self, ldap, dn, *keys, **options):
+ try:
+ old_attrs = ldap.get_entry(dn, ['ipabaseid',
+ 'ipaidrangesize',
+ 'ipanttrusteddomainsid'])
+ except errors.NotFound:
+ self.obj.handle_not_found(*keys)
+
+ # Check whether we leave any object with id in deleted range
+ old_base_id = int(old_attrs.get('ipabaseid', [0])[0])
+ old_range_size = int(old_attrs.get('ipaidrangesize', [0])[0])
+ self.obj.check_ids_in_modified_range(
+ old_base_id, old_range_size, 0, 0)
+
+ # Check whether the range does not belong to the active trust
+ range_sid = old_attrs.get('ipanttrusteddomainsid')
+
+ if range_sid is not None:
+ # Search for trusted domain with SID specified in the ID range entry
+ range_sid = range_sid[0]
+ domain_filter=('(&(objectclass=ipaNTTrustedDomain)'
+ '(ipanttrusteddomainsid=%s))' % range_sid)
+
+ try:
+ (trust_domains, truncated) = ldap.find_entries(
+ base_dn=DN(api.env.container_trusts, api.env.basedn),
+ filter=domain_filter)
+ except errors.NotFound:
+ pass
+ else:
+ # If there's an entry, it means that there's active domain
+ # of a trust that this range belongs to, so raise a
+ # DependentEntry error
+ raise errors.DependentEntry(
+ label='Active Trust domain',
+ key=keys[0],
+ dependent=trust_domains[0].dn[0].value)
+
+
+ return dn
+
+
+@register()
+class idrange_find(LDAPSearch):
+ __doc__ = _('Search for ranges.')
+
+ msg_summary = ngettext(
+ '%(count)d range matched', '%(count)d ranges matched', 0
+ )
+
+ # Since all range types are stored within separate containers under
+ # 'cn=ranges,cn=etc' search can be done on a one-level scope
+ def pre_callback(self, ldap, filters, attrs_list, base_dn, scope, *args,
+ **options):
+ assert isinstance(base_dn, DN)
+ attrs_list.append('objectclass')
+ return (filters, base_dn, ldap.SCOPE_ONELEVEL)
+
+ def post_callback(self, ldap, entries, truncated, *args, **options):
+ for entry in entries:
+ self.obj.handle_ipabaserid(entry, options)
+ self.obj.handle_iparangetype(entry, options)
+ return truncated
+
+
+@register()
+class idrange_show(LDAPRetrieve):
+ __doc__ = _('Display information about a range.')
+
+ def pre_callback(self, ldap, dn, attrs_list, *keys, **options):
+ assert isinstance(dn, DN)
+ attrs_list.append('objectclass')
+ return dn
+
+ def post_callback(self, ldap, dn, entry_attrs, *keys, **options):
+ assert isinstance(dn, DN)
+ self.obj.handle_ipabaserid(entry_attrs, options)
+ self.obj.handle_iparangetype(entry_attrs, options)
+ return dn
+
+
+@register()
+class idrange_mod(LDAPUpdate):
+ __doc__ = _("""Modify ID range.
+
+{0}
+""".format(ID_RANGE_VS_DNA_WARNING))
+
+ msg_summary = _('Modified ID range "%(value)s"')
+
+ takes_options = LDAPUpdate.takes_options + (
+ Str(
+ 'ipanttrusteddomainsid?',
+ deprecated=True,
+ cli_name='dom_sid',
+ flags=('no_update', 'no_option'),
+ label=_('Domain SID of the trusted domain'),
+ autofill=False,
+ ),
+ Str(
+ 'ipanttrusteddomainname?',
+ deprecated=True,
+ cli_name='dom_name',
+ flags=('no_search', 'virtual_attribute', 'no_update', 'no_option'),
+ label=_('Name of the trusted domain'),
+ autofill=False,
+ ),
+ )
+
+ def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options):
+ assert isinstance(dn, DN)
+ attrs_list.append('objectclass')
+
+ try:
+ old_attrs = ldap.get_entry(dn, ['*'])
+ except errors.NotFound:
+ self.obj.handle_not_found(*keys)
+
+ if old_attrs['iparangetype'][0] == 'ipa-local':
+ raise errors.ExecutionError(
+ message=_('This command can not be used to change ID '
+ 'allocation for local IPA domain. Run '
+ '`ipa help idrange` for more information')
+ )
+
+ is_set = lambda x: (x in entry_attrs) and (entry_attrs[x] is not None)
+ in_updated_attrs = lambda x:\
+ (x in entry_attrs and entry_attrs[x] is not None) or\
+ (x not in entry_attrs and x in old_attrs
+ and old_attrs[x] is not None)
+
+ # This needs to stay in options since there is no
+ # ipanttrusteddomainname attribute in LDAP
+ if 'ipanttrusteddomainname' in options:
+ if is_set('ipanttrusteddomainsid'):
+ raise errors.ValidationError(name='ID Range setup',
+ error=_('Options dom-sid and dom-name '
+ 'cannot be used together'))
+
+ sid = self.obj.get_trusted_domain_sid_from_name(
+ options['ipanttrusteddomainname'])
+
+ # we translate the name into sid so further validation can rely
+ # on ipanttrusteddomainsid attribute only
+ if sid is not None:
+ entry_attrs['ipanttrusteddomainsid'] = sid
+ else:
+ raise errors.ValidationError(name='ID Range setup',
+ error=_('SID for the specified trusted domain name could '
+ 'not be found. Please specify the SID directly '
+ 'using dom-sid option.'))
+
+ if in_updated_attrs('ipanttrusteddomainsid'):
+ if in_updated_attrs('ipasecondarybaserid'):
+ raise errors.ValidationError(name='ID Range setup',
+ error=_('Options dom-sid and secondary-rid-base cannot '
+ 'be used together'))
+ range_type = old_attrs['iparangetype'][0]
+ if range_type == u'ipa-ad-trust':
+ if not in_updated_attrs('ipabaserid'):
+ raise errors.ValidationError(
+ name='ID Range setup',
+ error=_('Options dom-sid and rid-base must '
+ 'be used together'))
+ elif (range_type == u'ipa-ad-trust-posix' and
+ 'ipabaserid' in entry_attrs):
+ if entry_attrs['ipabaserid'] is None:
+ entry_attrs['ipabaserid'] = 0
+ elif entry_attrs['ipabaserid'] != 0:
+ raise errors.ValidationError(
+ name='ID Range setup',
+ error=_('Option rid-base must not be used when IPA '
+ 'range type is ipa-ad-trust-posix')
+ )
+
+ if is_set('ipanttrusteddomainsid'):
+ # Validate SID as the one of trusted domains
+ # perform this check only if the attribute was changed
+ self.obj.validate_trusted_domain_sid(
+ entry_attrs['ipanttrusteddomainsid'])
+
+ # Add trusted AD domain range object class, if it wasn't there
+ if not 'ipatrustedaddomainrange' in old_attrs['objectclass']:
+ entry_attrs['objectclass'].append('ipatrustedaddomainrange')
+
+ else:
+ # secondary base rid must be set if and only if base rid is set
+ if in_updated_attrs('ipasecondarybaserid') !=\
+ in_updated_attrs('ipabaserid'):
+ raise errors.ValidationError(name='ID Range setup',
+ error=_('Options secondary-rid-base and rid-base must '
+ 'be used together'))
+
+ # ensure that primary and secondary rid ranges do not overlap
+ if all(in_updated_attrs(base)
+ for base in ('ipabaserid', 'ipasecondarybaserid')):
+
+ # make sure we are working with updated attributes
+ rid_range_attributes = ('ipabaserid', 'ipasecondarybaserid',
+ 'ipaidrangesize')
+ updated_values = dict()
+
+ for attr in rid_range_attributes:
+ if is_set(attr):
+ updated_values[attr] = entry_attrs[attr]
+ else:
+ updated_values[attr] = int(old_attrs[attr][0])
+
+ if self.obj.are_rid_ranges_overlapping(
+ updated_values['ipabaserid'],
+ updated_values['ipasecondarybaserid'],
+ updated_values['ipaidrangesize']):
+ raise errors.ValidationError(name='ID Range setup',
+ error=_("Primary RID range and secondary RID range"
+ " cannot overlap"))
+
+ # check whether ids are in modified range
+ old_base_id = int(old_attrs.get('ipabaseid', [0])[0])
+ old_range_size = int(old_attrs.get('ipaidrangesize', [0])[0])
+ new_base_id = entry_attrs.get('ipabaseid')
+
+ if new_base_id is not None:
+ new_base_id = int(new_base_id)
+
+ new_range_size = entry_attrs.get('ipaidrangesize')
+
+ if new_range_size is not None:
+ new_range_size = int(new_range_size)
+
+ self.obj.check_ids_in_modified_range(old_base_id, old_range_size,
+ new_base_id, new_range_size)
+
+ return dn
+
+ def post_callback(self, ldap, dn, entry_attrs, *keys, **options):
+ assert isinstance(dn, DN)
+ self.obj.handle_ipabaserid(entry_attrs, options)
+ self.obj.handle_iparangetype(entry_attrs, options)
+ return dn
+
+
diff --git a/ipaserver/plugins/idviews.py b/ipaserver/plugins/idviews.py
new file mode 100644
index 000000000..537f924ce
--- /dev/null
+++ b/ipaserver/plugins/idviews.py
@@ -0,0 +1,1123 @@
+# Authors:
+# Alexander Bokovoy <abokovoy@redhat.com>
+# Tomas Babej <tbabej@redhat.com>
+#
+# Copyright (C) 2014 Red Hat
+# see file 'COPYING' for use and warranty information
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+import re
+
+import six
+
+from .baseldap import (LDAPQuery, LDAPObject, LDAPCreate,
+ LDAPDelete, LDAPUpdate, LDAPSearch,
+ LDAPAddAttribute, LDAPRemoveAttribute,
+ LDAPRetrieve, global_output_params)
+from .hostgroup import get_complete_hostgroup_member_list
+from .service import validate_certificate
+from ipalib import api, Str, Int, Bytes, Flag, _, ngettext, errors, output
+from ipalib.constants import IPA_ANCHOR_PREFIX, SID_ANCHOR_PREFIX
+from ipalib.plugable import Registry
+from ipalib.util import (normalize_sshpubkey, validate_sshpubkey,
+ convert_sshpubkey_post)
+
+from ipapython.dn import DN
+
+if six.PY3:
+ unicode = str
+
+_dcerpc_bindings_installed = False
+
+if api.env.in_server and api.env.context in ['lite', 'server']:
+ try:
+ import ipaserver.dcerpc
+ _dcerpc_bindings_installed = True
+ except ImportError:
+ pass
+
+__doc__ = _("""
+ID Views
+Manage ID Views
+IPA allows to override certain properties of users and groups per each host.
+This functionality is primarily used to allow migration from older systems or
+other Identity Management solutions.
+""")
+
+register = Registry()
+
+protected_default_trust_view_error = errors.ProtectedEntryError(
+ label=_('ID View'),
+ key=u"Default Trust View",
+ reason=_('system ID View')
+)
+
+fallback_to_ldap_option = Flag(
+ 'fallback_to_ldap?',
+ default=False,
+ label=_('Fallback to AD DC LDAP'),
+ doc=_("Allow falling back to AD DC LDAP when resolving AD "
+ "trusted objects. For two-way trusts only."),
+)
+
+DEFAULT_TRUST_VIEW_NAME = "default trust view"
+
+ANCHOR_REGEX = re.compile(
+ r':IPA:.*:[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}'
+ r'|'
+ r':SID:S-[0-9\-]+'
+)
+
+
+@register()
+class idview(LDAPObject):
+ """
+ ID View object.
+ """
+
+ container_dn = api.env.container_views
+ object_name = _('ID View')
+ object_name_plural = _('ID Views')
+ object_class = ['ipaIDView', 'top']
+ default_attributes = ['cn', 'description']
+ rdn_is_primary_key = True
+
+ label = _('ID Views')
+ label_singular = _('ID View')
+
+ takes_params = (
+ Str('cn',
+ cli_name='name',
+ label=_('ID View Name'),
+ primary_key=True,
+ ),
+ Str('description?',
+ cli_name='desc',
+ label=_('Description'),
+ ),
+ )
+
+ permission_filter_objectclasses = ['nsContainer']
+ managed_permissions = {
+ 'System: Read ID Views': {
+ 'ipapermbindruletype': 'all',
+ 'ipapermright': {'read', 'search', 'compare'},
+ 'ipapermdefaultattr': {
+ 'cn', 'description', 'objectClass',
+ },
+ },
+ }
+
+
+@register()
+class idview_add(LDAPCreate):
+ __doc__ = _('Add a new ID View.')
+ msg_summary = _('Added ID View "%(value)s"')
+
+
+@register()
+class idview_del(LDAPDelete):
+ __doc__ = _('Delete an ID View.')
+ msg_summary = _('Deleted ID View "%(value)s"')
+
+ def pre_callback(self, ldap, dn, *keys, **options):
+ for key in keys:
+ if key.lower() == DEFAULT_TRUST_VIEW_NAME:
+ raise protected_default_trust_view_error
+
+ return dn
+
+
+@register()
+class idview_mod(LDAPUpdate):
+ __doc__ = _('Modify an ID View.')
+ msg_summary = _('Modified an ID View "%(value)s"')
+
+ def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options):
+ for key in keys:
+ if key.lower() == DEFAULT_TRUST_VIEW_NAME:
+ raise protected_default_trust_view_error
+
+ return dn
+
+
+@register()
+class idview_find(LDAPSearch):
+ __doc__ = _('Search for an ID View.')
+ msg_summary = ngettext('%(count)d ID View matched',
+ '%(count)d ID Views matched', 0)
+
+
+@register()
+class idview_show(LDAPRetrieve):
+ __doc__ = _('Display information about an ID View.')
+
+ takes_options = LDAPRetrieve.takes_options + (
+ Flag('show_hosts?',
+ cli_name='show_hosts',
+ doc=_('Enumerate all the hosts the view applies to.'),
+ ),
+ )
+
+ has_output_params = global_output_params + (
+ Str('useroverrides',
+ label=_('User object overrides'),
+ ),
+ Str('groupoverrides',
+ label=_('Group object overrides'),
+ ),
+ Str('appliedtohosts',
+ label=_('Hosts the view applies to')
+ ),
+ )
+
+ def show_id_overrides(self, dn, entry_attrs):
+ ldap = self.obj.backend
+
+ for objectclass, obj_type in [('ipaUserOverride', 'user'),
+ ('ipaGroupOverride', 'group')]:
+
+ # Attribute to store results is called (user|group)overrides
+ attr_name = obj_type + 'overrides'
+
+ try:
+ (overrides, truncated) = ldap.find_entries(
+ filter="objectclass=%s" % objectclass,
+ attrs_list=['ipaanchoruuid'],
+ base_dn=dn,
+ scope=ldap.SCOPE_ONELEVEL,
+ paged_search=True)
+
+ resolved_overrides = []
+ for override in overrides:
+ anchor = override.single_value['ipaanchoruuid']
+
+ try:
+ name = resolve_anchor_to_object_name(ldap, obj_type,
+ anchor)
+ resolved_overrides.append(name)
+
+ except (errors.NotFound, errors.ValidationError):
+ # Anchor could not be resolved, use raw
+ resolved_overrides.append(anchor)
+
+ entry_attrs[attr_name] = resolved_overrides
+
+ except errors.NotFound:
+ # No overrides found, nothing to do
+ pass
+
+ def enumerate_hosts(self, dn, entry_attrs):
+ ldap = self.obj.backend
+
+ filter_params = {
+ 'ipaAssignedIDView': dn,
+ 'objectClass': 'ipaHost',
+ }
+
+ try:
+ (hosts, truncated) = ldap.find_entries(
+ filter=ldap.make_filter(filter_params, rules=ldap.MATCH_ALL),
+ attrs_list=['cn'],
+ base_dn=api.env.container_host + api.env.basedn,
+ scope=ldap.SCOPE_ONELEVEL,
+ paged_search=True)
+
+ entry_attrs['appliedtohosts'] = [host.single_value['cn']
+ for host in hosts]
+ except errors.NotFound:
+ pass
+
+ def post_callback(self, ldap, dn, entry_attrs, *keys, **options):
+ self.show_id_overrides(dn, entry_attrs)
+
+ # Enumerating hosts is a potentially expensive operation (uses paged
+ # search to list all the hosts the ID view applies to). Show the list
+ # of the hosts only if explicitly asked for (or asked for --all).
+ # Do not display with --raw, since this attribute does not exist in
+ # LDAP.
+
+ if ((options.get('show_hosts') or options.get('all'))
+ and not options.get('raw')):
+ self.enumerate_hosts(dn, entry_attrs)
+
+ return dn
+
+
+class baseidview_apply(LDAPQuery):
+ """
+ Base class for idview_apply and idview_unapply commands.
+ """
+
+ has_output_params = global_output_params
+
+ def execute(self, *keys, **options):
+ view = keys[-1] if keys else None
+ ldap = self.obj.backend
+
+ # Test if idview actually exists, if it does not, NotFound is raised
+ if not options.get('clear_view', False):
+ view_dn = self.api.Object['idview'].get_dn_if_exists(view)
+ assert isinstance(view_dn, DN)
+
+ # Check that we're not applying the Default Trust View
+ if view.lower() == DEFAULT_TRUST_VIEW_NAME:
+ raise errors.ValidationError(
+ name=_('ID View'),
+ error=_('Default Trust View cannot be applied on hosts')
+ )
+
+ else:
+ # In case we are removing assigned view, we modify the host setting
+ # the ipaAssignedIDView to None
+ view_dn = None
+
+ completed = 0
+ succeeded = {'host': []}
+ failed = {
+ 'host': [],
+ 'hostgroup': [],
+ }
+
+ # Make sure we ignore None passed via host or hostgroup, since it does
+ # not make sense
+ for key in ('host', 'hostgroup'):
+ if key in options and options[key] is None:
+ del options[key]
+
+ # Generate a list of all hosts to apply the view to
+ hosts_to_apply = list(options.get('host', []))
+
+ for hostgroup in options.get('hostgroup', ()):
+ try:
+ hosts_to_apply += get_complete_hostgroup_member_list(hostgroup)
+ except errors.NotFound:
+ failed['hostgroup'].append((hostgroup, unicode(_("not found"))))
+ except errors.PublicError as e:
+ failed['hostgroup'].append((hostgroup, "%s : %s" % (
+ e.__class__.__name__, str(e))))
+
+ for host in hosts_to_apply:
+ try:
+ host_dn = api.Object['host'].get_dn_if_exists(host)
+
+ host_entry = ldap.get_entry(host_dn,
+ attrs_list=['ipaassignedidview'])
+ host_entry['ipaassignedidview'] = view_dn
+
+ ldap.update_entry(host_entry)
+
+ # If no exception was raised, view assigment went well
+ completed = completed + 1
+ succeeded['host'].append(host)
+ except errors.EmptyModlist:
+ # If view was already applied, complain about it
+ failed['host'].append((host,
+ unicode(_("ID View already applied"))))
+ except errors.NotFound:
+ failed['host'].append((host, unicode(_("not found"))))
+ except errors.PublicError as e:
+ failed['host'].append((host, str(e)))
+
+ # Wrap dictionary containing failures in another dictionary under key
+ # 'memberhost', since that is output parameter in global_output_params
+ # and thus we get nice output in the CLI
+ failed = {'memberhost': failed}
+
+ # Sort the list of affected hosts
+ succeeded['host'].sort()
+
+ # Note that we're returning the list of affected hosts even if they
+ # were passed via referencing a hostgroup. This is desired, since we
+ # want to stress the fact that view is applied on all the current
+ # member hosts of the hostgroup and not tied with the hostgroup itself.
+
+ return dict(
+ summary=unicode(_(self.msg_summary % {'value': view})),
+ succeeded=succeeded,
+ completed=completed,
+ failed=failed,
+ )
+
+
+@register()
+class idview_apply(baseidview_apply):
+ __doc__ = _('Applies ID View to specified hosts or current members of '
+ 'specified hostgroups. If any other ID View is applied to '
+ 'the host, it is overridden.')
+
+ member_count_out = (_('ID View applied to %i host.'),
+ _('ID View applied to %i hosts.'))
+
+ msg_summary = 'Applied ID View "%(value)s"'
+
+ takes_options = (
+ Str('host*',
+ cli_name='hosts',
+ doc=_('Hosts to apply the ID View to'),
+ label=_('hosts'),
+ ),
+ Str('hostgroup*',
+ cli_name='hostgroups',
+ doc=_('Hostgroups to whose hosts apply the ID View to. Please note '
+ 'that view is not applied automatically to any hosts added '
+ 'to the hostgroup after running the idview-apply command.'),
+ label=_('hostgroups'),
+ ),
+ )
+
+ has_output = (
+ output.summary,
+ output.Output('succeeded',
+ type=dict,
+ doc=_('Hosts that this ID View was applied to.'),
+ ),
+ output.Output('failed',
+ type=dict,
+ doc=_('Hosts or hostgroups that this ID View could not be '
+ 'applied to.'),
+ ),
+ output.Output('completed',
+ type=int,
+ doc=_('Number of hosts the ID View was applied to:'),
+ ),
+ )
+
+
+@register()
+class idview_unapply(baseidview_apply):
+ __doc__ = _('Clears ID View from specified hosts or current members of '
+ 'specified hostgroups.')
+
+ member_count_out = (_('ID View cleared from %i host.'),
+ _('ID View cleared from %i hosts.'))
+
+ msg_summary = 'Cleared ID Views'
+
+ takes_options = (
+ Str('host*',
+ cli_name='hosts',
+ doc=_('Hosts to clear (any) ID View from.'),
+ label=_('hosts'),
+ ),
+ Str('hostgroup*',
+ cli_name='hostgroups',
+ doc=_('Hostgroups whose hosts should have ID Views cleared. Note '
+ 'that view is not cleared automatically from any host added '
+ 'to the hostgroup after running idview-unapply command.'),
+ label=_('hostgroups'),
+ ),
+ )
+
+ has_output = (
+ output.summary,
+ output.Output('succeeded',
+ type=dict,
+ doc=_('Hosts that ID View was cleared from.'),
+ ),
+ output.Output('failed',
+ type=dict,
+ doc=_('Hosts or hostgroups that ID View could not be cleared '
+ 'from.'),
+ ),
+ output.Output('completed',
+ type=int,
+ doc=_('Number of hosts that had a ID View was unset:'),
+ ),
+ )
+
+ # Take no arguments, since ID View reference is not needed to clear
+ # the hosts
+ def get_args(self):
+ return ()
+
+ def execute(self, *keys, **options):
+ options['clear_view'] = True
+ return super(idview_unapply, self).execute(*keys, **options)
+
+
+# ID overrides helper methods
+def verify_trusted_domain_object_type(validator, desired_type, name_or_sid):
+
+ object_type = validator.get_trusted_domain_object_type(name_or_sid)
+
+ if object_type == desired_type:
+ # In case SSSD returns the same type as the type being
+ # searched, no problems here.
+ return True
+
+ elif desired_type == 'user' and object_type == 'both':
+ # Type both denotes users with magic private groups.
+ # Overriding attributes for such users is OK.
+ return True
+
+ elif desired_type == 'group' and object_type == 'both':
+ # However, overriding attributes for magic private groups
+ # does not make sense. One should override the GID of
+ # the user itself.
+
+ raise errors.ConversionError(
+ name='identifier',
+ error=_('You are trying to reference a magic private group '
+ 'which is not allowed to be overridden. '
+ 'Try overriding the GID attribute of the '
+ 'corresponding user instead.')
+ )
+
+ return False
+
+
+def resolve_object_to_anchor(ldap, obj_type, obj, fallback_to_ldap):
+ """
+ Resolves the user/group name to the anchor uuid:
+ - first it tries to find the object as user or group in IPA (depending
+ on the passed obj_type)
+ - if the IPA lookup failed, lookup object SID in the trusted domains
+
+ Takes options:
+ ldap - the backend
+ obj_type - either 'user' or 'group'
+ obj - the name of the object, e.g 'admin' or 'testuser'
+ """
+
+ try:
+ entry = ldap.get_entry(api.Object[obj_type].get_dn(obj),
+ attrs_list=['ipaUniqueID', 'objectClass'])
+
+ # First we check this is a valid object to override
+ # - for groups, it must have ipaUserGroup objectclass
+ # - for users, it must have posixAccount objectclass
+
+ required_objectclass = {
+ 'user': 'posixaccount',
+ 'group': 'ipausergroup',
+ }[obj_type]
+
+ if required_objectclass not in entry['objectclass']:
+ raise errors.ValidationError(
+ name=_('IPA object'),
+ error=_('system IPA objects (e.g system groups, user '
+ 'private groups) cannot be overridden')
+ )
+
+ # The domain prefix, this will need to be reworked once we
+ # introduce IPA-IPA trusts
+ domain = api.env.domain
+ uuid = entry.single_value['ipaUniqueID']
+
+ return "%s%s:%s" % (IPA_ANCHOR_PREFIX, domain, uuid)
+ except errors.NotFound:
+ pass
+
+ # If not successfull, try looking up the object in the trusted domain
+ try:
+ if _dcerpc_bindings_installed:
+ domain_validator = ipaserver.dcerpc.DomainValidator(api)
+ if domain_validator.is_configured():
+ sid = domain_validator.get_trusted_domain_object_sid(obj,
+ fallback_to_ldap=fallback_to_ldap)
+
+ # We need to verify that the object type is correct
+ type_correct = verify_trusted_domain_object_type(
+ domain_validator, obj_type, sid)
+
+ if type_correct:
+ # There is no domain prefix since SID contains information
+ # about the domain
+ return SID_ANCHOR_PREFIX + sid
+
+ except errors.ValidationError:
+ # Domain validator raises Validation Error if object name does not
+ # contain domain part (either NETBIOS\ prefix or @domain.name suffix)
+ pass
+
+ # No acceptable object was found
+ api.Object[obj_type].handle_not_found(obj)
+
+
+def resolve_anchor_to_object_name(ldap, obj_type, anchor):
+ """
+ Resolves IPA Anchor UUID to the actual common object name (uid for users,
+ cn for groups).
+
+ Takes options:
+ ldap - the backend
+ anchor - the anchor, e.g.
+ ':IPA:ipa.example.com:2cb604ea-39a5-11e4-a37e-001a4a22216f'
+ """
+
+ if anchor.startswith(IPA_ANCHOR_PREFIX):
+
+ # Prepare search parameters
+ accounts_dn = DN(api.env.container_accounts, api.env.basedn)
+
+ # Anchor of the form :IPA:<domain>:<uuid>
+ # Strip the IPA prefix and the domain prefix
+ uuid = anchor.rpartition(':')[-1].strip()
+
+ # Set the object type-specific search attributes
+ objectclass, name_attr = {
+ 'user': ('posixaccount', 'uid'),
+ 'group': ('ipausergroup', 'cn'),
+ }[obj_type]
+
+ entry = ldap.find_entry_by_attr(attr='ipaUniqueID',
+ value=uuid,
+ object_class=objectclass,
+ attrs_list=[name_attr],
+ base_dn=accounts_dn)
+
+ # Return the name of the object, which is either cn for
+ # groups or uid for users
+ return entry.single_value[name_attr]
+
+ elif anchor.startswith(SID_ANCHOR_PREFIX):
+
+ # Parse the SID out from the anchor
+ sid = anchor[len(SID_ANCHOR_PREFIX):].strip()
+
+ if _dcerpc_bindings_installed:
+ domain_validator = ipaserver.dcerpc.DomainValidator(api)
+ if domain_validator.is_configured():
+ name = domain_validator.get_trusted_domain_object_from_sid(sid)
+
+ # We need to verify that the object type is correct
+ type_correct = verify_trusted_domain_object_type(
+ domain_validator, obj_type, name)
+
+ if type_correct:
+ return name
+
+ # No acceptable object was found
+ raise errors.NotFound(
+ reason=_("Anchor '%(anchor)s' could not be resolved.")
+ % dict(anchor=anchor))
+
+
+def remove_ipaobject_overrides(ldap, api, dn):
+ """
+ Removes all ID overrides for given object. This method is to be
+ consumed by -del commands of the given objects (users, groups).
+ """
+
+ entry = ldap.get_entry(dn, attrs_list=['ipaUniqueID'])
+ object_uuid = entry.single_value['ipaUniqueID']
+
+ override_filter = '(ipaanchoruuid=:IPA:{0}:{1})'.format(api.env.domain,
+ object_uuid)
+ try:
+ entries, truncated = ldap.find_entries(
+ override_filter,
+ base_dn=DN(api.env.container_views, api.env.basedn),
+ paged_search=True
+ )
+ except errors.EmptyResult:
+ pass
+ else:
+ # In case we found something, delete it
+ for entry in entries:
+ ldap.delete_entry(entry)
+
+
+# This is not registered on purpose, it's a base class for ID overrides
+class baseidoverride(LDAPObject):
+ """
+ Base ID override object.
+ """
+
+ parent_object = 'idview'
+ container_dn = api.env.container_views
+
+ object_class = ['ipaOverrideAnchor', 'top']
+ default_attributes = [
+ 'description', 'ipaAnchorUUID',
+ ]
+
+ takes_params = (
+ Str('ipaanchoruuid',
+ cli_name='anchor',
+ primary_key=True,
+ label=_('Anchor to override'),
+ ),
+ Str('description?',
+ cli_name='desc',
+ label=_('Description'),
+ ),
+ )
+
+ override_object = None
+
+ def get_dn(self, *keys, **options):
+ # If user passed raw anchor, do not try
+ # to translate it.
+ if ANCHOR_REGEX.match(keys[-1]):
+ anchor = keys[-1]
+
+ # Otherwise, translate object into a
+ # legitimate object anchor.
+ else:
+ anchor = resolve_object_to_anchor(
+ self.backend,
+ self.override_object,
+ keys[-1],
+ fallback_to_ldap=options['fallback_to_ldap']
+ )
+
+ keys = keys[:-1] + (anchor, )
+ return super(baseidoverride, self).get_dn(*keys, **options)
+
+ def set_anchoruuid_from_dn(self, dn, entry_attrs):
+ # TODO: Use entry_attrs.single_value once LDAPUpdate supports
+ # lists in primary key fields (baseldap.LDAPUpdate.execute)
+ entry_attrs['ipaanchoruuid'] = dn[0].value
+
+ def convert_anchor_to_human_readable_form(self, entry_attrs, **options):
+ if not options.get('raw'):
+ anchor = entry_attrs.single_value['ipaanchoruuid']
+
+ if anchor:
+ try:
+ object_name = resolve_anchor_to_object_name(
+ self.backend,
+ self.override_object,
+ anchor
+ )
+ entry_attrs.single_value['ipaanchoruuid'] = object_name
+ except errors.NotFound:
+ # If we were unable to resolve the anchor,
+ # keep it in the raw form
+ pass
+ except errors.ValidationError:
+ # Same as above, ValidationError may be raised when SIDs
+ # are attempted to be converted, but the domain is no
+ # longer trusted
+ pass
+
+ def prohibit_ipa_users_in_default_view(self, dn, entry_attrs):
+ # Check if parent object is Default Trust View, if so, prohibit
+ # adding overrides for IPA objects
+
+ if dn[1].value.lower() == DEFAULT_TRUST_VIEW_NAME:
+ if dn[0].value.startswith(IPA_ANCHOR_PREFIX):
+ raise errors.ValidationError(
+ name=_('ID View'),
+ error=_('Default Trust View cannot contain IPA users')
+ )
+
+class baseidoverride_add(LDAPCreate):
+ __doc__ = _('Add a new ID override.')
+ msg_summary = _('Added ID override "%(value)s"')
+
+ takes_options = LDAPCreate.takes_options + (fallback_to_ldap_option,)
+
+ def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options):
+ self.obj.set_anchoruuid_from_dn(dn, entry_attrs)
+ self.obj.prohibit_ipa_users_in_default_view(dn, entry_attrs)
+ return dn
+
+ def post_callback(self, ldap, dn, entry_attrs, *keys, **options):
+ self.obj.convert_anchor_to_human_readable_form(entry_attrs, **options)
+ return dn
+
+
+class baseidoverride_del(LDAPDelete):
+ __doc__ = _('Delete an ID override.')
+ msg_summary = _('Deleted ID override "%(value)s"')
+
+ takes_options = LDAPDelete.takes_options + (fallback_to_ldap_option,)
+
+ def pre_callback(self, ldap, dn, *keys, **options):
+ assert isinstance(dn, DN)
+
+ # Make sure the entry we're deleting has all the objectclasses
+ # this object requires
+ try:
+ entry = ldap.get_entry(dn, ['objectclass'])
+ except errors.NotFound:
+ self.obj.handle_not_found(*keys)
+
+ required_object_classes = set(self.obj.object_class)
+ actual_object_classes = set(entry['objectclass'])
+
+ # If not, treat it as a failed search
+ if not required_object_classes.issubset(actual_object_classes):
+ self.obj.handle_not_found(*keys)
+
+ return dn
+
+
+class baseidoverride_mod(LDAPUpdate):
+ __doc__ = _('Modify an ID override.')
+ msg_summary = _('Modified an ID override "%(value)s"')
+
+ takes_options = LDAPUpdate.takes_options + (fallback_to_ldap_option,)
+
+ def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options):
+ if 'rename' in options:
+ raise errors.ValidationError(
+ name=_('ID override'),
+ error=_('ID overrides cannot be renamed')
+ )
+
+ self.obj.prohibit_ipa_users_in_default_view(dn, entry_attrs)
+ return dn
+
+ def post_callback(self, ldap, dn, entry_attrs, *keys, **options):
+ self.obj.convert_anchor_to_human_readable_form(entry_attrs, **options)
+ return dn
+
+
+class baseidoverride_find(LDAPSearch):
+ __doc__ = _('Search for an ID override.')
+ msg_summary = ngettext('%(count)d ID override matched',
+ '%(count)d ID overrides matched', 0)
+
+ takes_options = LDAPSearch.takes_options + (fallback_to_ldap_option,)
+
+ def post_callback(self, ldap, entries, truncated, *args, **options):
+ for entry in entries:
+ self.obj.convert_anchor_to_human_readable_form(entry, **options)
+ return truncated
+
+
+class baseidoverride_show(LDAPRetrieve):
+ __doc__ = _('Display information about an ID override.')
+
+ takes_options = LDAPRetrieve.takes_options + (fallback_to_ldap_option,)
+
+ def post_callback(self, ldap, dn, entry_attrs, *keys, **options):
+ self.obj.convert_anchor_to_human_readable_form(entry_attrs, **options)
+ return dn
+
+
+@register()
+class idoverrideuser(baseidoverride):
+
+ object_name = _('User ID override')
+ object_name_plural = _('User ID overrides')
+
+ label = _('User ID overrides')
+ label_singular = _('User ID override')
+ rdn_is_primary_key = True
+
+ permission_filter_objectclasses = ['ipaUserOverride']
+ managed_permissions = {
+ 'System: Read User ID Overrides': {
+ 'ipapermbindruletype': 'all',
+ 'ipapermright': {'read', 'search', 'compare'},
+ 'ipapermdefaultattr': {
+ 'objectClass', 'ipaAnchorUUID', 'uidNumber', 'description',
+ 'homeDirectory', 'uid', 'ipaOriginalUid', 'loginShell', 'gecos',
+ 'gidNumber', 'ipaSshPubkey', 'usercertificate'
+ },
+ },
+ }
+
+ object_class = baseidoverride.object_class + ['ipaUserOverride']
+ possible_objectclasses = ['ipasshuser', 'ipaSshGroupOfPubKeys']
+ default_attributes = baseidoverride.default_attributes + [
+ 'homeDirectory', 'uidNumber', 'uid', 'ipaOriginalUid', 'loginShell',
+ 'ipaSshPubkey', 'gidNumber', 'gecos', 'usercertificate;binary',
+ ]
+
+ search_display_attributes = baseidoverride.default_attributes + [
+ 'homeDirectory', 'uidNumber', 'uid', 'ipaOriginalUid', 'loginShell',
+ 'ipaSshPubkey', 'gidNumber', 'gecos',
+ ]
+
+ takes_params = baseidoverride.takes_params + (
+ Str('uid?',
+ pattern='^[a-zA-Z0-9_.][a-zA-Z0-9_.-]{0,252}[a-zA-Z0-9_.$-]?$',
+ pattern_errmsg='may only include letters, numbers, _, -, . and $',
+ maxlength=255,
+ cli_name='login',
+ label=_('User login'),
+ normalizer=lambda value: value.lower(),
+ ),
+ Int('uidnumber?',
+ cli_name='uid',
+ label=_('UID'),
+ doc=_('User ID Number'),
+ minvalue=1,
+ ),
+ Str('gecos?',
+ label=_('GECOS'),
+ ),
+ Int('gidnumber?',
+ label=_('GID'),
+ doc=_('Group ID Number'),
+ minvalue=1,
+ ),
+ Str('homedirectory?',
+ cli_name='homedir',
+ label=_('Home directory'),
+ ),
+ Str('loginshell?',
+ cli_name='shell',
+ label=_('Login shell'),
+ ),
+ Str('ipaoriginaluid?',
+ flags=['no_option', 'no_output']
+ ),
+ Str('ipasshpubkey*', validate_sshpubkey,
+ cli_name='sshpubkey',
+ label=_('SSH public key'),
+ normalizer=normalize_sshpubkey,
+ flags=['no_search'],
+ ),
+ Bytes('usercertificate*', validate_certificate,
+ cli_name='certificate',
+ label=_('Certificate'),
+ doc=_('Base-64 encoded user certificate'),
+ flags=['no_search',],
+ ),
+ )
+
+ override_object = 'user'
+
+ def update_original_uid_reference(self, entry_attrs):
+ anchor = entry_attrs.single_value['ipaanchoruuid']
+ try:
+ original_uid = resolve_anchor_to_object_name(self.backend,
+ self.override_object,
+ anchor)
+ entry_attrs['ipaOriginalUid'] = original_uid
+
+ except (errors.NotFound, errors.ValidationError):
+ # Anchor could not be resolved, this means we had to specify the
+ # object to manipulate using a raw anchor value already, hence
+ # we have no way to update the original_uid
+ pass
+
+ def convert_usercertificate_pre(self, entry_attrs):
+ if 'usercertificate' in entry_attrs:
+ entry_attrs['usercertificate;binary'] = entry_attrs.pop(
+ 'usercertificate')
+
+ def convert_usercertificate_post(self, entry_attrs, **options):
+ if 'usercertificate;binary' in entry_attrs:
+ entry_attrs['usercertificate'] = entry_attrs.pop(
+ 'usercertificate;binary')
+
+
+
+@register()
+class idoverridegroup(baseidoverride):
+
+ object_name = _('Group ID override')
+ object_name_plural = _('Group ID overrides')
+
+ label = _('Group ID overrides')
+ label_singular = _('Group ID override')
+ rdn_is_primary_key = True
+
+ permission_filter_objectclasses = ['ipaGroupOverride']
+ managed_permissions = {
+ 'System: Read Group ID Overrides': {
+ 'ipapermbindruletype': 'all',
+ 'ipapermright': {'read', 'search', 'compare'},
+ 'ipapermdefaultattr': {
+ 'objectClass', 'ipaAnchorUUID', 'gidNumber',
+ 'description', 'cn',
+ },
+ },
+ }
+
+ object_class = baseidoverride.object_class + ['ipaGroupOverride']
+ default_attributes = baseidoverride.default_attributes + [
+ 'gidNumber', 'cn',
+ ]
+
+ takes_params = baseidoverride.takes_params + (
+ Str('cn?',
+ pattern='^[a-zA-Z0-9_.][a-zA-Z0-9_.-]{0,252}[a-zA-Z0-9_.$-]?$',
+ pattern_errmsg='may only include letters, numbers, _, -, . and $',
+ maxlength=255,
+ cli_name='group_name',
+ label=_('Group name'),
+ normalizer=lambda value: value.lower(),
+ ),
+ Int('gidnumber?',
+ cli_name='gid',
+ label=_('GID'),
+ doc=_('Group ID Number'),
+ minvalue=1,
+ ),
+ )
+
+ override_object = 'group'
+
+@register()
+class idoverrideuser_add_cert(LDAPAddAttribute):
+ __doc__ = _('Add one or more certificates to the idoverrideuser entry')
+ msg_summary = _('Added certificates to idoverrideuser "%(value)s"')
+ attribute = 'usercertificate'
+
+ takes_options = LDAPAddAttribute.takes_options + (fallback_to_ldap_option,)
+
+ def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys,
+ **options):
+ dn = self.obj.get_dn(*keys, **options)
+ self.obj.convert_usercertificate_pre(entry_attrs)
+
+ return dn
+
+ def post_callback(self, ldap, dn, entry_attrs, *keys, **options):
+ assert isinstance(dn, DN)
+ self.obj.convert_usercertificate_post(entry_attrs, **options)
+ self.obj.convert_anchor_to_human_readable_form(entry_attrs, **options)
+ return dn
+
+
+@register()
+class idoverrideuser_remove_cert(LDAPRemoveAttribute):
+ __doc__ = _('Remove one or more certificates to the idoverrideuser entry')
+ msg_summary = _('Removed certificates from idoverrideuser "%(value)s"')
+ attribute = 'usercertificate'
+
+ takes_options = LDAPRemoveAttribute.takes_options + (fallback_to_ldap_option,)
+
+ def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys,
+ **options):
+ dn = self.obj.get_dn(*keys, **options)
+ self.obj.convert_usercertificate_pre(entry_attrs)
+
+ return dn
+
+ def post_callback(self, ldap, dn, entry_attrs, *keys, **options):
+ assert isinstance(dn, DN)
+ self.obj.convert_usercertificate_post(entry_attrs, **options)
+ self.obj.convert_anchor_to_human_readable_form(entry_attrs, **options)
+
+ return dn
+
+
+@register()
+class idoverrideuser_add(baseidoverride_add):
+ __doc__ = _('Add a new User ID override.')
+ msg_summary = _('Added User ID override "%(value)s"')
+
+ def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options):
+ dn = super(idoverrideuser_add, self).pre_callback(ldap, dn,
+ entry_attrs, attrs_list, *keys, **options)
+
+ entry_attrs['objectclass'].append('ipasshuser')
+ self.obj.convert_usercertificate_pre(entry_attrs)
+
+ # Update the ipaOriginalUid
+ self.obj.update_original_uid_reference(entry_attrs)
+ return dn
+
+ def post_callback(self, ldap, dn, entry_attrs, *keys, **options):
+ dn = super(idoverrideuser_add, self).post_callback(ldap, dn,
+ entry_attrs, *keys, **options)
+ convert_sshpubkey_post(entry_attrs)
+ self.obj.convert_usercertificate_post(entry_attrs, **options)
+ return dn
+
+
+
+@register()
+class idoverrideuser_del(baseidoverride_del):
+ __doc__ = _('Delete an User ID override.')
+ msg_summary = _('Deleted User ID override "%(value)s"')
+
+
+@register()
+class idoverrideuser_mod(baseidoverride_mod):
+ __doc__ = _('Modify an User ID override.')
+ msg_summary = _('Modified an User ID override "%(value)s"')
+
+ def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options):
+ dn = super(idoverrideuser_mod, self).pre_callback(ldap, dn,
+ entry_attrs, attrs_list, *keys, **options)
+
+ # Update the ipaOriginalUid
+ self.obj.set_anchoruuid_from_dn(dn, entry_attrs)
+ self.obj.update_original_uid_reference(entry_attrs)
+ if 'objectclass' in entry_attrs:
+ obj_classes = entry_attrs['objectclass']
+ else:
+ _entry_attrs = ldap.get_entry(dn, ['objectclass'])
+ obj_classes = entry_attrs['objectclass'] = _entry_attrs['objectclass']
+
+ if 'ipasshpubkey' in entry_attrs and 'ipasshuser' not in obj_classes:
+ obj_classes.append('ipasshuser')
+
+ self.obj.convert_usercertificate_pre(entry_attrs)
+ return dn
+
+ def post_callback(self, ldap, dn, entry_attrs, *keys, **options):
+ dn = super(idoverrideuser_mod, self).post_callback(ldap, dn,
+ entry_attrs, *keys, **options)
+ convert_sshpubkey_post(entry_attrs)
+ self.obj.convert_usercertificate_post(entry_attrs, **options)
+ return dn
+
+
+@register()
+class idoverrideuser_find(baseidoverride_find):
+ __doc__ = _('Search for an User ID override.')
+ msg_summary = ngettext('%(count)d User ID override matched',
+ '%(count)d User ID overrides matched', 0)
+
+ def post_callback(self, ldap, entries, truncated, *args, **options):
+ truncated = super(idoverrideuser_find, self).post_callback(
+ ldap, entries, truncated, *args, **options)
+ for entry in entries:
+ convert_sshpubkey_post(entry)
+ self.obj.convert_usercertificate_post(entry, **options)
+ return truncated
+
+
+@register()
+class idoverrideuser_show(baseidoverride_show):
+ __doc__ = _('Display information about an User ID override.')
+
+ def post_callback(self, ldap, dn, entry_attrs, *keys, **options):
+ dn = super(idoverrideuser_show, self).post_callback(ldap, dn,
+ entry_attrs, *keys, **options)
+ convert_sshpubkey_post(entry_attrs)
+ self.obj.convert_usercertificate_post(entry_attrs, **options)
+ return dn
+
+
+@register()
+class idoverridegroup_add(baseidoverride_add):
+ __doc__ = _('Add a new Group ID override.')
+ msg_summary = _('Added Group ID override "%(value)s"')
+
+
+@register()
+class idoverridegroup_del(baseidoverride_del):
+ __doc__ = _('Delete an Group ID override.')
+ msg_summary = _('Deleted Group ID override "%(value)s"')
+
+
+@register()
+class idoverridegroup_mod(baseidoverride_mod):
+ __doc__ = _('Modify an Group ID override.')
+ msg_summary = _('Modified an Group ID override "%(value)s"')
+
+
+@register()
+class idoverridegroup_find(baseidoverride_find):
+ __doc__ = _('Search for an Group ID override.')
+ msg_summary = ngettext('%(count)d Group ID override matched',
+ '%(count)d Group ID overrides matched', 0)
+
+
+@register()
+class idoverridegroup_show(baseidoverride_show):
+ __doc__ = _('Display information about an Group ID override.')
diff --git a/ipaserver/plugins/internal.py b/ipaserver/plugins/internal.py
new file mode 100644
index 000000000..99b0c04d1
--- /dev/null
+++ b/ipaserver/plugins/internal.py
@@ -0,0 +1,859 @@
+# Authors:
+# Pavel Zuna <pzuna@redhat.com>
+# Adam Young <ayoung@redhat.com>
+# Endi S. Dewata <edewata@redhat.com>
+#
+# Copyright (c) 2010 Red Hat
+# See file 'copying' for use and warranty information
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+"""
+Plugins not accessible directly through the CLI, commands used internally
+"""
+from ipalib import Command
+from ipalib import Str
+from ipalib.output import Output
+from ipalib.text import _
+from ipalib.util import json_serialize
+from ipalib.plugable import Registry
+
+register = Registry()
+
+@register()
+class json_metadata(Command):
+ """
+ Export plugin meta-data for the webUI.
+ """
+ NO_CLI = True
+
+
+ takes_args = (
+ Str('objname?',
+ doc=_('Name of object to export'),
+ ),
+ Str('methodname?',
+ doc=_('Name of method to export'),
+ ),
+ )
+
+ takes_options = (
+ Str('object?',
+ doc=_('Name of object to export'),
+ ),
+ Str('method?',
+ doc=_('Name of method to export'),
+ ),
+ Str('command?',
+ doc=_('Name of command to export'),
+ ),
+ )
+
+ has_output = (
+ Output('objects', dict, doc=_('Dict of JSON encoded IPA Objects')),
+ Output('methods', dict, doc=_('Dict of JSON encoded IPA Methods')),
+ Output('commands', dict, doc=_('Dict of JSON encoded IPA Commands')),
+ )
+
+ def execute(self, objname=None, methodname=None, **options):
+ objects = dict()
+ methods = dict()
+ commands = dict()
+
+ empty = True
+
+ try:
+ if not objname:
+ objname = options['object']
+ if objname in self.api.Object:
+ o = self.api.Object[objname]
+ objects = dict([(o.name, json_serialize(o))])
+ elif objname == "all":
+ objects = dict(
+ (o.name, json_serialize(o)) for o in self.api.Object()
+ )
+ empty = False
+ except KeyError:
+ pass
+
+ try:
+ if not methodname:
+ methodname = options['method']
+ if methodname in self.api.Method:
+ m = self.api.Method[methodname]
+ methods = dict([(m.name, json_serialize(m))])
+ elif methodname == "all":
+ methods = dict(
+ (m.name, json_serialize(m)) for m in self.api.Method()
+ )
+ empty = False
+ except KeyError:
+ pass
+
+ try:
+ cmdname = options['command']
+ if cmdname in self.api.Command:
+ c = self.api.Command[cmdname]
+ commands = dict([(c.name, json_serialize(c))])
+ elif cmdname == "all":
+ commands = dict(
+ (c.name, json_serialize(c)) for c in self.api.Command()
+ )
+ empty = False
+ except KeyError:
+ pass
+
+ if empty:
+ objects = dict(
+ (o.name, json_serialize(o)) for o in self.api.Object()
+ )
+ methods = dict(
+ (m.name, json_serialize(m)) for m in self.api.Method()
+ )
+ commands = dict(
+ (c.name, json_serialize(c)) for c in self.api.Command()
+ )
+
+ retval = dict([
+ ("objects", objects),
+ ("methods", methods),
+ ("commands", commands),
+ ])
+
+ return retval
+
+
+@register()
+class i18n_messages(Command):
+ NO_CLI = True
+
+ messages = {
+ "ajax": {
+ "401": {
+ "message": _("Your session has expired. Please re-login."),
+ },
+ },
+ "actions": {
+ "apply": _("Apply"),
+ "automember_rebuild": _("Rebuild auto membership"),
+ "automember_rebuild_confirm": _("Are you sure you want to rebuild auto membership?"),
+ "automember_rebuild_success": _("Automember rebuild membership task completed"),
+ "confirm": _("Are you sure you want to proceed with the action?"),
+ "delete_confirm": _("Are you sure you want to delete ${object}?"),
+ "disable_confirm": _("Are you sure you want to disable ${object}?"),
+ "enable_confirm": _("Are you sure you want to enable ${object}?"),
+ "title": _("Actions"),
+ },
+ "association": {
+ "add": {
+ "ipasudorunas": _("Add RunAs ${other_entity} into ${entity} ${primary_key}"),
+ "ipasudorunasgroup": _("Add RunAs Groups into ${entity} ${primary_key}"),
+ "managedby": _("Add ${other_entity} Managing ${entity} ${primary_key}"),
+ "member": _("Add ${other_entity} into ${entity} ${primary_key}"),
+ "memberallowcmd": _("Add Allow ${other_entity} into ${entity} ${primary_key}"),
+ "memberdenycmd": _("Add Deny ${other_entity} into ${entity} ${primary_key}"),
+ "memberof": _("Add ${entity} ${primary_key} into ${other_entity}"),
+ },
+ "added": _("${count} item(s) added"),
+ "direct_membership": _("Direct Membership"),
+ "filter_placeholder": _("Filter available ${other_entity}"),
+ "indirect_membership": _("Indirect Membership"),
+ "no_entries": _("No entries."),
+ "paging": _("Showing ${start} to ${end} of ${total} entries."),
+ "remove": {
+ "ipasudorunas": _("Remove RunAs ${other_entity} from ${entity} ${primary_key}"),
+ "ipasudorunasgroup": _("Remove RunAs Groups from ${entity} ${primary_key}"),
+ "managedby": _("Remove ${other_entity} Managing ${entity} ${primary_key}"),
+ "member": _("Remove ${other_entity} from ${entity} ${primary_key}"),
+ "memberallowcmd": _("Remove Allow ${other_entity} from ${entity} ${primary_key}"),
+ "memberdenycmd": _("Remove Deny ${other_entity} from ${entity} ${primary_key}"),
+ "memberof": _("Remove ${entity} ${primary_key} from ${other_entity}"),
+ },
+ "removed": _("${count} item(s) removed"),
+ "show_results": _("Show Results"),
+ },
+ "authtype": {
+ "config_tooltip": _("<p>Implicit method (password) will be used if no method is chosen.</p><p><strong>Password + Two-factor:</strong> LDAP and Kerberos allow authentication with either one of the authentication types but Kerberos uses pre-authentication method which requires to use armor ccache.</p><p><strong>RADIUS with another type:</strong> Kerberos always use RADIUS, but LDAP never does. LDAP only recognize the password and two-factor authentication options.</p>"),
+ "type_otp": _("Two factor authentication (password + OTP)"),
+ "type_password": _("Password"),
+ "type_radius": _("Radius"),
+ "type_disabled": _("Disable per-user override"),
+ "user_tooltip": _("<p>Per-user setting, overwrites the global setting if any option is checked.</p><p><strong>Password + Two-factor:</strong> LDAP and Kerberos allow authentication with either one of the authentication types but Kerberos uses pre-authentication method which requires to use armor ccache.</p><p><strong>RADIUS with another type:</strong> Kerberos always use RADIUS, but LDAP never does. LDAP only recognize the password and two-factor authentication options.</p>"),
+ },
+ "buttons": {
+ "about": _("About"),
+ "activate": _("Activate"),
+ "add": _("Add"),
+ "add_and_add_another": _("Add and Add Another"),
+ "add_and_close": _("Add and Close"),
+ "add_and_edit": _("Add and Edit"),
+ "add_many": _("Add Many"),
+ "apply": _("Apply"),
+ "back": _("Back"),
+ "cancel": _("Cancel"),
+ "close": _("Close"),
+ "disable": _("Disable"),
+ "edit": _("Edit"),
+ "enable": _("Enable"),
+ "filter": _("Filter"),
+ "find": _("Find"),
+ "get": _("Get"),
+ "hide": _("Hide"),
+ "issue": _("Issue"),
+ "ok": _("OK"),
+ "refresh": _("Refresh"),
+ "refresh_title": _("Reload current settings from the server."),
+ "remove": _("Delete"),
+ "reset": _("Reset"),
+ "reset_password_and_login": _("Reset Password and Login"),
+ "restore": _("Restore"),
+ "retry": _("Retry"),
+ "revert": _("Revert"),
+ "revert_title": ("Undo all unsaved changes."),
+ "revoke": _("Revoke"),
+ "save": _("Save"),
+ "set": _("Set"),
+ "show": _("Show"),
+ "unapply": ("Un-apply"),
+ "update": _("Update"),
+ "view": _("View"),
+ },
+ "details": {
+ "collapse_all": _("Collapse All"),
+ "expand_all": _("Expand All"),
+ "general": _("General"),
+ "identity": _("Identity Settings"),
+ "settings": _("${entity} ${primary_key} Settings"),
+ "to_top": _("Back to Top"),
+ "updated": _("${entity} ${primary_key} updated"),
+ },
+ "dialogs": {
+ "add_confirmation": _("${entity} successfully added"),
+ "add_title": _("Add ${entity}"),
+ "available": _("Available"),
+ "batch_error_message": _("Some operations failed."),
+ "batch_error_title": _("Operations Error"),
+ "confirmation": _("Confirmation"),
+ "dirty_message": _("This page has unsaved changes. Please save or revert."),
+ "dirty_title": _("Unsaved Changes"),
+ "edit_title": _("Edit ${entity}"),
+ "hide_details": _("Hide details"),
+ "about_title": _("About"),
+ "about_message": _("${product}, version: ${version}"),
+ "prospective": _("Prospective"),
+ "redirection": _("Redirection"),
+ "remove_empty": _("Select entries to be removed."),
+ "remove_title": _("Remove ${entity}"),
+ "result": _("Result"),
+ "show_details": _("Show details"),
+ "success": _("Success"),
+ "validation_title": _("Validation error"),
+ "validation_message": _("Input form contains invalid or missing values."),
+ },
+ "error_report": {
+ "options": _("Please try the following options:"),
+ "problem_persists": _("If the problem persists please contact the system administrator."),
+ "refresh": _("Refresh the page."),
+ "reload": _("Reload the browser."),
+ "main_page": _("Return to the main page and retry the operation"),
+ "title": _("An error has occurred (${error})"),
+ },
+ "errors": {
+ "error": _("Error"),
+ "http_error": _("HTTP Error"),
+ "internal_error": _("Internal Error"),
+ "ipa_error": _("IPA Error"),
+ "no_response": _("No response"),
+ "unknown_error": _("Unknown Error"),
+ "url": _("URL"),
+ },
+ "facet_groups": {
+ "managedby": _("${primary_key} is managed by:"),
+ "member": _("${primary_key} members:"),
+ "memberof": _("${primary_key} is a member of:"),
+ },
+ "facets": {
+ "details": _("Settings"),
+ "search": _("Search"),
+ },
+ "false": _("False"),
+ "keytab": {
+ "add_create": _("Allow ${other_entity} to create keytab of ${primary_key}"),
+ "add_retrive": _("Allow ${other_entity} to retrieve keytab of ${primary_key}"),
+ "allowed_to_create": _("Allowed to create keytab"),
+ "allowed_to_retrieve": _("Allowed to retrieve keytab"),
+ "remove_create": _("Disallow ${other_entity} to create keytab of ${primary_key}"),
+ "remove_retrieve": _("Disallow ${other_entity} to retrieve keytab of ${primary_key}"),
+ },
+ "krbauthzdata": {
+ "inherited": _("Inherited from server configuration"),
+ "mspac": _("MS-PAC"),
+ "override": _("Override inherited settings"),
+ "pad": _("PAD"),
+ },
+ "login": {
+ "form_auth": _("<i class=\"fa fa-info-circle\"></i> To login with <strong>username and password</strong>, enter them in the corresponding fields, then click Login."),
+ "header": _("Logged In As"),
+ "krb_auth_msg": _("<i class=\"fa fa-info-circle\"></i> To login with <strong>Kerberos</strong>, please make sure you have valid tickets (obtainable via kinit) and <a href='http://${host}/ipa/config/unauthorized.html'>configured</a> the browser correctly, then click Login."),
+ "login": _("Login"),
+ "logout": _("Logout"),
+ "logout_error": _("Logout error"),
+ "password": _("Password"),
+ "sync_otp_token": _("Sync OTP Token"),
+ "username": _("Username"),
+ },
+ "measurement_units": {
+ "number_of_passwords": _("number of passwords"),
+ "seconds": _("seconds"),
+ },
+ "objects": {
+ "aci": {
+ "attribute": _("Attribute"),
+ },
+ "automember": {
+ "add_condition": _("Add Condition into ${pkey}"),
+ "add_rule": _("Add Rule"),
+ "attribute": _("Attribute"),
+ "default_host_group": _("Default host group"),
+ "default_user_group": _("Default user group"),
+ "exclusive": _("Exclusive"),
+ "expression": _("Expression"),
+ "hostgrouprule": _("Host group rule"),
+ "hostgrouprules": _("Host group rules"),
+ "inclusive": _("Inclusive"),
+ "usergrouprule": _("User group rule"),
+ "usergrouprules": _("User group rules"),
+ },
+ "automountkey": {
+ },
+ "automountlocation": {
+ "identity": _("Automount Location Settings")
+ },
+ "automountmap": {
+ "map_type": _("Map Type"),
+ "direct": _("Direct"),
+ "indirect": _("Indirect"),
+ },
+ "caacl": {
+ "any_host": _("Any Host"),
+ "any_service": _("Any Service"),
+ "any_profile": _("Any Profile"),
+ "anyone": _("Anyone"),
+ "ipaenabledflag": _("Rule status"),
+ "profile": _("Profiles"),
+ "specified_hosts": _("Specified Hosts and Groups"),
+ "specified_profiles": _("Specified Profiles"),
+ "specified_services": _("Specified Services and Groups"),
+ "specified_users": _("Specified Users and Groups"),
+ "who": _("Permitted to have certificates issued"),
+ },
+ "cert": {
+ "aa_compromise": _("AA Compromise"),
+ "add_principal": _("Add principal"),
+ "affiliation_changed": _("Affiliation Changed"),
+ "ca_compromise": _("CA Compromise"),
+ "certificate": _("Certificate"),
+ "certificates": _("Certificates"),
+ "certificate_hold": _("Certificate Hold"),
+ "cessation_of_operation": _("Cessation of Operation"),
+ "common_name": _("Common Name"),
+ "expires_on": _("Expires On"),
+ "find_issuedon_from": _("Issued on from"),
+ "find_issuedon_to": _("Issued on to"),
+ "find_max_serial_number": _("Maximum serial number"),
+ "find_min_serial_number": _("Minimum serial number"),
+ "find_revocation_reason": _("Revocation reason"),
+ "find_revokedon_from": _("Revoked on from"),
+ "find_revokedon_to": _("Revoked on to"),
+ "find_subject": _("Subject"),
+ "find_validnotafter_from": _("Valid not after from"),
+ "find_validnotafter_to": _("Valid not after to"),
+ "find_validnotbefore_from": _("Valid not before from"),
+ "find_validnotbefore_to": _("Valid not before to"),
+ "fingerprints": _("Fingerprints"),
+ "get_certificate": _("Get Certificate"),
+ "issue_certificate": _("Issue New Certificate for ${entity} ${primary_key}"),
+ "issue_certificate_generic": _("Issue New Certificate"),
+ "issued_by": _("Issued By"),
+ "issued_on": _("Issued On"),
+ "issued_to": _("Issued To"),
+ "key_compromise": _("Key Compromise"),
+ "md5_fingerprint": _("MD5 Fingerprint"),
+ "missing": _("No Valid Certificate"),
+ "new_certificate": _("New Certificate"),
+ "note": _("Note"),
+ "organization": _("Organization"),
+ "organizational_unit": _("Organizational Unit"),
+ "present": _("${count} certificate(s) present"),
+ "privilege_withdrawn": _("Privilege Withdrawn"),
+ "reason": _("Reason for Revocation"),
+ "remove_from_crl": _("Remove from CRL"),
+ "request_message": _("<ol> <li>Create a certificate database or use an existing one. To create a new database:<br/> <code># certutil -N -d &lt;database path&gt;</code> </li> <li>Create a CSR with subject <em>CN=&lt;${cn_name}&gt;,O=&lt;realm&gt;</em>, for example:<br/> <code># certutil -R -d &lt;database path&gt; -a -g &lt;key size&gt; -s 'CN=${cn},O=${realm}'</code> </li> <li> Copy and paste the CSR (from <em>-----BEGIN NEW CERTIFICATE REQUEST-----</em> to <em>-----END NEW CERTIFICATE REQUEST-----</em>) into the text area below: </li> </ol>"),
+ "requested": _("Certificate requested"),
+ "restore_certificate": _("Restore Certificate for ${entity} ${primary_key}"),
+ "restore_certificate_simple": _("Restore Certificate"),
+ "restore_confirmation": _("To confirm your intention to restore this certificate, click the \"Restore\" button."),
+ "restored": _("Certificate restored"),
+ "revocation_reason": _("Revocation reason"),
+ "revoke_certificate": _("Revoke Certificate for ${entity} ${primary_key}"),
+ "revoke_certificate_simple": _("Revoke Certificate"),
+ "revoke_confirmation": _("To confirm your intention to revoke this certificate, select a reason from the pull-down list, and click the \"Revoke\" button."),
+ "revoked": _("Certificate Revoked"),
+ "serial_number": _("Serial Number"),
+ "serial_number_hex": _("Serial Number (hex)"),
+ "sha1_fingerprint": _("SHA1 Fingerprint"),
+ "status": _("Status"),
+ "superseded": _("Superseded"),
+ "unspecified": _("Unspecified"),
+ "valid": _("Valid Certificate Present"),
+ "validity": _("Validity"),
+ "view_certificate": _("Certificate for ${entity} ${primary_key}"),
+ "view_certificate_btn": _("View Certificate"),
+ },
+ "config": {
+ "group": _("Group Options"),
+ "search": _("Search Options"),
+ "selinux": _("SELinux Options"),
+ "service": _("Service Options"),
+ "user": _("User Options"),
+ },
+ "delegation": {
+ },
+ "dnsconfig": {
+ "forward_first": _("Forward first"),
+ "forward_none": _("Forwarding disabled"),
+ "forward_only": _("Forward only"),
+ "options": _("Options"),
+ },
+ "dnsrecord": {
+ "data": _("Data"),
+ "deleted_no_data": _("DNS record was deleted because it contained no data."),
+ "other": _("Other Record Types"),
+ "ptr_redir_address_err": _("Address not valid, can't redirect"),
+ "ptr_redir_create": _("Create dns record"),
+ "ptr_redir_creating": _("Creating record."),
+ "ptr_redir_creating_err": _("Record creation failed."),
+ "ptr_redir_record": _("Checking if record exists."),
+ "ptr_redir_record_err": _("Record not found."),
+ "ptr_redir_title": _("Redirection to PTR record"),
+ "ptr_redir_zone": _("Zone found: ${zone}"),
+ "ptr_redir_zone_err": _("Target reverse zone not found."),
+ "ptr_redir_zones": _("Fetching DNS zones."),
+ "ptr_redir_zones_err": _("An error occurred while fetching dns zones."),
+ "redirection_dnszone": _("You will be redirected to DNS Zone."),
+ "standard": _("Standard Record Types"),
+ "title": _("Records for DNS Zone"),
+ "type": _("Record Type"),
+ },
+ "dnszone": {
+ "identity": _("DNS Zone Settings"),
+ "add_permission":_("Add Permission"),
+ "add_permission_confirm":_("Are you sure you want to add permission for DNS Zone ${object}?"),
+ "remove_permission": _("Remove Permission"),
+ "remove_permission_confirm": _("Are you sure you want to remove permission for DNS Zone ${object}?"),
+ "skip_dns_check": _("Skip DNS check"),
+ "skip_overlap_check": _("Skip overlap check"),
+ "soamname_change_message": _("Do you want to check if new authoritative nameserver address is in DNS"),
+ "soamname_change_title": _("Authoritative nameserver change"),
+ },
+ "domainlevel": {
+ "label": _("Domain Level"),
+ "label_singular": _("Domain Level"),
+ "ipadomainlevel": _("Level"),
+ "set": _("Set Domain Level"),
+ },
+ "group": {
+ "details": _("Group Settings"),
+ "external": _("External"),
+ "make_external": _("Change to external group"),
+ "make_posix": _("Change to POSIX group"),
+ "normal": _("Normal"),
+ "posix": _("POSIX"),
+ "type": _("Group Type"),
+ },
+ "hbacrule": {
+ "any_host": _("Any Host"),
+ "any_service": _("Any Service"),
+ "anyone": _("Anyone"),
+ "host": _("Accessing"),
+ "ipaenabledflag": _("Rule status"),
+ "service": _("Via Service"),
+ "specified_hosts": _("Specified Hosts and Groups"),
+ "specified_services": _("Specified Services and Groups"),
+ "specified_users": _("Specified Users and Groups"),
+ "user": _("Who"),
+ },
+ "hbacsvc": {
+ },
+ "hbacsvcgroup": {
+ "services": _("Services"),
+ },
+ "hbactest": {
+ "access_denied": _("Access Denied"),
+ "access_granted": _("Access Granted"),
+ "include_disabled": _("Include Disabled"),
+ "include_enabled": _("Include Enabled"),
+ "label": _("HBAC Test"),
+ "matched": _("Matched"),
+ "missing_values": _("Missing values: "),
+ "new_test": _("New Test"),
+ "rules": _("Rules"),
+ "run_test": _("Run Test"),
+ "specify_external": _("Specify external ${entity}"),
+ "unmatched": _("Unmatched"),
+ },
+ "host": {
+ "certificate": _("Host Certificate"),
+ "cn": _("Host Name"),
+ "delete_key_unprovision": _("Delete Key, Unprovision"),
+ "details": _("Host Settings"),
+ "enrolled": _("Enrolled"),
+ "enrollment": _("Enrollment"),
+ "fqdn": _("Fully Qualified Host Name"),
+ "generate_otp": _("Generate OTP"),
+ "generated_otp": _("Generated OTP"),
+ "keytab": _("Kerberos Key"),
+ "keytab_missing": _("Kerberos Key Not Present"),
+ "keytab_present": _("Kerberos Key Present, Host Provisioned"),
+ "password": _("One-Time-Password"),
+ "password_missing": _("One-Time-Password Not Present"),
+ "password_present": _("One-Time-Password Present"),
+ "password_reset_button": _("Reset OTP"),
+ "password_reset_title": _("Reset One-Time-Password"),
+ "password_set_button": _("Set OTP"),
+ "password_set_success": _("OTP set"),
+ "password_set_title": _("Set One-Time-Password"),
+ "status": _("Status"),
+ "unprovision": _("Unprovision"),
+ "unprovision_confirmation": _("Are you sure you want to unprovision this host?"),
+ "unprovision_title": _("Unprovisioning ${entity}"),
+ "unprovisioned": _("Host unprovisioned"),
+ },
+ "hostgroup": {
+ "identity": _("Host Group Settings"),
+ },
+ "idoverrideuser": {
+ "anchor_label": _("User to override"),
+ "anchor_tooltip": _("Enter trusted or IPA user login. Note: search doesn't list users from trusted domains."),
+ "anchor_tooltip_ad": _("Enter trusted user login."),
+ },
+ "idoverridegroup": {
+ "anchor_label": _("Group to override"),
+ "anchor_tooltip": _("Enter trusted or IPA group name. Note: search doesn't list groups from trusted domains."),
+ "anchor_tooltip_ad": _("Enter trusted group name."),
+ },
+ "idview": {
+ "appliesto_tab": _("${primary_key} applies to:"),
+ "appliedtohosts": _("Applied to hosts"),
+ "appliedtohosts_title": _("Applied to hosts"),
+ "apply_hostgroups": _("Apply to host groups"),
+ "apply_hostgroups_title": _("Apply ID View ${primary_key} on hosts of ${entity}"),
+ "apply_hosts": _("Apply to hosts"),
+ "apply_hosts_title": _("Apply ID view ${primary_key} on ${entity}"),
+ "ipaassignedidview": _("Assigned ID View"),
+ "overrides_tab": _("${primary_key} overrides:"),
+ "unapply_hostgroups": _("Un-apply from host groups"),
+ "unapply_hostgroups_all_title": _("Un-apply ID Views from hosts of hostgroups"),
+ "unapply_hostgroups_title": _("Un-apply ID View ${primary_key} from hosts of ${entity}"),
+ "unapply_hosts": _("Un-apply"),
+ "unapply_hosts_all": _("Un-apply from hosts"),
+ "unapply_hosts_all_title": _("Un-apply ID Views from hosts"),
+ "unapply_hosts_confirm": _("Are you sure you want to un-apply ID view from selected entries?"),
+ "unapply_hosts_title": _("Un-apply ID View ${primary_key} from hosts"),
+ },
+ "krbtpolicy": {
+ "identity": _("Kerberos Ticket Policy"),
+ },
+ "netgroup": {
+ "any_host": _("Any Host"),
+ "anyone": _("Anyone"),
+ "external": _("External"),
+ "host": _("Host"),
+ "hostgroups": _("Host Groups"),
+ "hosts": _("Hosts"),
+ "identity": _("Netgroup Settings"),
+ "specified_hosts": _("Specified Hosts and Groups"),
+ "specified_users": _("Specified Users and Groups"),
+ "user": _("User"),
+ "usergroups": _("User Groups"),
+ "users": _("Users"),
+ },
+ "otptoken": {
+ "add_token": _("Add OTP Token"),
+ "app_link": _("You can use <a href=\"${link}\" target=\"_blank\">FreeOTP<a/> as a software OTP token application."),
+ "config_title": _("Configure your token"),
+ "config_instructions": _("Configure your token by scanning the QR code below. Click on the QR code if you see this on the device you want to configure."),
+ "details": _("OTP Token Settings"),
+ "disable": _("Disable token"),
+ "enable": _("Enable token"),
+ "show_qr": _("Show QR code"),
+ "show_uri": _("Show configuration uri"),
+ "type_hotp": _("Counter-based (HOTP)"),
+ "type_totp": _("Time-based (TOTP)"),
+ },
+ "permission": {
+ "add_custom_attr": _("Add custom attribute"),
+ "attribute": _("Attribute"),
+ "filter": _("Filter"),
+ "identity": _("Permission settings"),
+ "managed": _("Attribute breakdown"),
+ "target": _("Target"),
+ },
+ "privilege": {
+ "identity": _("Privilege Settings"),
+ },
+ "pwpolicy": {
+ "identity": _("Password Policy"),
+ },
+ "idrange": {
+ "details": _("Range Settings"),
+ "ipabaseid": _("Base ID"),
+ "ipabaserid": _("Primary RID base"),
+ "ipaidrangesize": _("Range size"),
+ "ipanttrusteddomainsid": _("Domain SID"),
+ "ipasecondarybaserid": _("Secondary RID base"),
+ "type": _("Range type"),
+ "type_ad": _("Active Directory domain"),
+ "type_ad_posix": _("Active Directory domain with POSIX attributes"),
+ "type_detect": _("Detect"),
+ "type_local": _("Local domain"),
+ "type_ipa": _("IPA trust"),
+ "type_winsync": _("Active Directory winsync"),
+ },
+ "radiusproxy": {
+ "details": _("RADIUS Proxy Server Settings"),
+ },
+ "realmdomains": {
+ "identity": _("Realm Domains"),
+ "check_dns": _("Check DNS"),
+ "check_dns_confirmation": _("Do you also want to perform DNS check?"),
+ "force_update": _("Force Update"),
+ },
+ "role": {
+ "identity": _("Role Settings"),
+ },
+ "selfservice": {
+ },
+ "selinuxusermap": {
+ "any_host": _("Any Host"),
+ "anyone": _("Anyone"),
+ "host": _("Host"),
+ "specified_hosts": _("Specified Hosts and Groups"),
+ "specified_users": _("Specified Users and Groups"),
+ "user": _("User"),
+ },
+ "service": {
+ "certificate": _("Service Certificate"),
+ "delete_key_unprovision": _("Delete Key, Unprovision"),
+ "details": _("Service Settings"),
+ "host": _("Host Name"),
+ "missing": _("Kerberos Key Not Present"),
+ "provisioning": _("Provisioning"),
+ "service": _("Service"),
+ "status": _("Status"),
+ "unprovision": _("Unprovision"),
+ "unprovision_confirmation": _("Are you sure you want to unprovision this service?"),
+ "unprovision_title": _("Unprovisioning ${entity}"),
+ "unprovisioned": _("Service unprovisioned"),
+ "valid": _("Kerberos Key Present, Service Provisioned"),
+ },
+ "sshkeystore": {
+ "keys": _("SSH public keys"),
+ "set_dialog_help": _("SSH public key:"),
+ "set_dialog_title": _("Set SSH key"),
+ "show_set_key": _("Show/Set key"),
+ "status_mod_ns": _("Modified: key not set"),
+ "status_mod_s": _("Modified"),
+ "status_new_ns": _("New: key not set"),
+ "status_new_s": _("New: key set"),
+ },
+ "stageuser": {
+ "activate_confirm": _("Are you sure you want to activate selected users?"),
+ "activate_one_confirm": _("Are you sure you want to activate ${object}?"),
+ "activate_success": _("${count} user(s) activated"),
+ "label": _("Stage users"),
+ "preserved_label": _("Preserved users"),
+ "undel_confirm": _("Are you sure you want to restore selected users?"),
+ "undel_success": _("${count} user(s) restored"),
+ "user_categories": _("User categories"),
+ },
+ "sudocmd": {
+ "groups": _("Groups"),
+ },
+ "sudocmdgroup": {
+ "commands": _("Commands"),
+ },
+ "sudorule": {
+ "allow": _("Allow"),
+ "any_command": _("Any Command"),
+ "any_group": _("Any Group"),
+ "any_host": _("Any Host"),
+ "anyone": _("Anyone"),
+ "command": _("Run Commands"),
+ "deny": _("Deny"),
+ "external": _("External"),
+ "host": _("Access this host"),
+ "ipaenabledflag": _("Rule status"),
+ "option_added": _("Option added"),
+ "option_removed": _("${count} option(s) removed"),
+ "options": _("Options"),
+ "runas": _("As Whom"),
+ "specified_commands": _("Specified Commands and Groups"),
+ "specified_groups": _("Specified Groups"),
+ "specified_hosts": _("Specified Hosts and Groups"),
+ "specified_users": _("Specified Users and Groups"),
+ "user": _("Who"),
+ },
+ "topology": {
+ "segment_details": _("Segment details"),
+ "replication_config": _("Replication configuration"),
+ "insufficient_domain_level" : _("Managed topology requires minimal domain level ${domainlevel}"),
+ },
+ "trust": {
+ "account": _("Account"),
+ "admin_account": _("Administrative account"),
+ "blacklists": _("SID blacklists"),
+ "details": _("Trust Settings"),
+ "domain": _("Domain"),
+ "establish_using": _("Establish using"),
+ "fetch_domains": _("Fetch domains"),
+ "ipantflatname": _("Domain NetBIOS name"),
+ "ipanttrusteddomainsid": _("Domain Security Identifier"),
+ "preshared_password": _("Pre-shared password"),
+ "trustdirection": _("Trust direction"),
+ "truststatus": _("Trust status"),
+ "trusttype": _("Trust type"),
+ },
+ "trustconfig": {
+ "options": _("Options"),
+ },
+ "user": {
+ "account": _("Account Settings"),
+ "account_status": _("Account Status"),
+ "activeuser_label": _("Active users"),
+ "contact": _("Contact Settings"),
+ "delete_mode": _("Delete mode"),
+ "employee": _("Employee Information"),
+ "error_changing_status": _("Error changing account status"),
+ "krbpasswordexpiration": _("Password expiration"),
+ "mailing": _("Mailing Address"),
+ "misc": _("Misc. Information"),
+ "mode_delete": _("delete"),
+ "mode_preserve": _("preserve"),
+ "noprivate": _("No private group"),
+ "status_confirmation": _("Are you sure you want to ${action} the user?<br/>The change will take effect immediately."),
+ "status_link": _("Click to ${action}"),
+ "unlock": _("Unlock"),
+ "unlock_confirm": _("Are you sure you want to unlock user ${object}?"),
+ },
+ },
+ "password": {
+ "current_password": _("Current Password"),
+ "current_password_required": _("Current password is required"),
+ "expires_in": _("Your password expires in ${days} days."),
+ "first_otp": _("First OTP"),
+ "invalid_password": _("The password or username you entered is incorrect."),
+ "new_password": _("New Password"),
+ "new_password_required": _("New password is required"),
+ "otp": _("OTP"),
+ "otp_info": _("<i class=\"fa fa-info-circle\"></i> <strong>One-Time-Password(OTP):</strong> Generate new OTP code for each OTP field."),
+ "otp_long": _("One-Time-Password"),
+ "otp_sync_fail": _("Token synchronization failed"),
+ "otp_sync_invalid": _("The username, password or token codes are not correct"),
+ "otp_sync_success":_("Token was synchronized"),
+ "password": _("Password"),
+ "password_and_otp": _("Password or Password+One-Time-Password"),
+ "password_change_complete": _("Password change complete"),
+ "password_must_match": _("Passwords must match"),
+ "reset_failure": _("Password reset was not successful."),
+ "reset_password": _("Reset Password"),
+ "reset_password_sentence": _("Reset your password."),
+ "second_otp": _("Second OTP"),
+ "token_id": _("Token ID"),
+ "verify_password": _("Verify Password"),
+ },
+ "search": {
+ "delete_confirm": _("Are you sure you want to delete selected entries?"),
+ "deleted": _("${count} item(s) deleted"),
+ "disable_confirm": _("Are you sure you want to disable selected entries?"),
+ "disabled": _("${count} item(s) disabled"),
+ "enable_confirm": _("Are you sure you want to enable selected entries?"),
+ "enabled": _("${count} item(s) enabled"),
+ "partial_delete": _("Some entries were not deleted"),
+ "placeholder": _("Search"),
+ "quick_links": _("Quick Links"),
+ "select_all": _("Select All"),
+ "truncated": _("Query returned more results than the configured size limit. Displaying the first ${counter} results."),
+ "unselect_all": _("Unselect All"),
+ },
+ "status": {
+ "disable": _("Disable"),
+ "disabled": _("Disabled"),
+ "enable": _("Enable"),
+ "enabled": _("Enabled"),
+ "label": _("Status"),
+ "working": _("Working"),
+ },
+ "tabs": {
+ "audit": _("Audit"),
+ "authentication": _("Authentication"),
+ "automember": _("Automember"),
+ "automount": _("Automount"),
+ "cert": _("Certificates"),
+ "dns": _("DNS"),
+ "hbac": _("Host Based Access Control"),
+ "identity": _("Identity"),
+ "ipaserver": _("IPA Server"),
+ "network_services": _("Network Services"),
+ "policy": _("Policy"),
+ "role": _("Role Based Access Control"),
+ "sudo": _("Sudo"),
+ "topology": _("Topology"),
+ "trust": _("Trusts"),
+ },
+ "true": _("True"),
+ "widget": {
+ "first": _("First"),
+ "last": _("Last"),
+ "next": _("Next"),
+ "page": _("Page"),
+ "prev": _("Prev"),
+ "undo": _("Undo"),
+ "undo_title": _("Undo this change."),
+ "undo_all": _("Undo All"),
+ "undo_all_title": _("Undo all changes in this field."),
+ "validation": {
+ "error": _("Text does not match field pattern"),
+ "datetime": _("Must be an UTC date/time value (e.g., \"2014-01-20 17:58:01Z\")"),
+ "decimal": _("Must be a decimal number"),
+ "format": _("Format error"),
+ "integer": _("Must be an integer"),
+ "ip_address": _('Not a valid IP address'),
+ "ip_v4_address": _('Not a valid IPv4 address'),
+ "ip_v6_address": _('Not a valid IPv6 address'),
+ "max_value": _("Maximum value is ${value}"),
+ "min_value": _("Minimum value is ${value}"),
+ "net_address": _("Not a valid network address (examples: 2001:db8::/64, 192.0.2.0/24)"),
+ "parse": _("Parse error"),
+ "port": _("'${port}' is not a valid port"),
+ "required": _("Required field"),
+ "unsupported": _("Unsupported value"),
+ },
+ },
+ }
+ has_output = (
+ Output('texts', dict, doc=_('Dict of I18N messages')),
+ )
+ def execute(self, **options):
+ return dict(texts=json_serialize(self.messages))
diff --git a/ipaserver/plugins/join.py b/ipaserver/plugins/join.py
index 0f877b4d1..efec4226a 100644
--- a/ipaserver/plugins/join.py
+++ b/ipaserver/plugins/join.py
@@ -49,6 +49,8 @@ def validate_host(ugettext, cn):
class join(Command):
"""Join an IPA domain"""
+ NO_CLI = True
+
takes_args = (
Str('cn',
validate_host,
diff --git a/ipaserver/plugins/krbtpolicy.py b/ipaserver/plugins/krbtpolicy.py
new file mode 100644
index 000000000..7cf587661
--- /dev/null
+++ b/ipaserver/plugins/krbtpolicy.py
@@ -0,0 +1,243 @@
+# Authors:
+# Pavel Zuna <pzuna@redhat.com>
+#
+# Copyright (C) 2010 Red Hat
+# see file 'COPYING' for use and warranty information
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+from ipalib import api, errors, output, _
+from ipalib import Int, Str
+from . import baseldap
+from .baseldap import entry_to_dict, pkey_to_value
+from ipalib.plugable import Registry
+from ipapython.dn import DN
+
+__doc__ = _("""
+Kerberos ticket policy
+
+There is a single Kerberos ticket policy. This policy defines the
+maximum ticket lifetime and the maximum renewal age, the period during
+which the ticket is renewable.
+
+You can also create a per-user ticket policy by specifying the user login.
+
+For changes to the global policy to take effect, restarting the KDC service
+is required, which can be achieved using:
+
+service krb5kdc restart
+
+Changes to per-user policies take effect immediately for newly requested
+tickets (e.g. when the user next runs kinit).
+
+EXAMPLES:
+
+ Display the current Kerberos ticket policy:
+ ipa krbtpolicy-show
+
+ Reset the policy to the default:
+ ipa krbtpolicy-reset
+
+ Modify the policy to 8 hours max life, 1-day max renewal:
+ ipa krbtpolicy-mod --maxlife=28800 --maxrenew=86400
+
+ Display effective Kerberos ticket policy for user 'admin':
+ ipa krbtpolicy-show admin
+
+ Reset per-user policy for user 'admin':
+ ipa krbtpolicy-reset admin
+
+ Modify per-user policy for user 'admin':
+ ipa krbtpolicy-mod admin --maxlife=3600
+""")
+
+register = Registry()
+
+# FIXME: load this from a config file?
+_default_values = {
+ 'krbmaxticketlife': 86400,
+ 'krbmaxrenewableage': 604800,
+}
+
+
+@register()
+class krbtpolicy(baseldap.LDAPObject):
+ """
+ Kerberos Ticket Policy object
+ """
+ container_dn = DN(('cn', api.env.realm), ('cn', 'kerberos'))
+ object_name = _('kerberos ticket policy settings')
+ default_attributes = ['krbmaxticketlife', 'krbmaxrenewableage']
+ limit_object_classes = ['krbticketpolicyaux']
+ # permission_filter_objectclasses is deliberately missing,
+ # so it is not possible to create a permission of `--type krbtpolicy`.
+ # This is because we need two permissions to cover both global and per-user
+ # policies.
+ managed_permissions = {
+ 'System: Read Default Kerberos Ticket Policy': {
+ 'non_object': True,
+ 'replaces_global_anonymous_aci': True,
+ 'ipapermtargetfilter': ['(objectclass=krbticketpolicyaux)'],
+ 'ipapermlocation': DN(container_dn, api.env.basedn),
+ 'ipapermright': {'read', 'search', 'compare'},
+ 'ipapermdefaultattr': {
+ 'krbdefaultencsalttypes', 'krbmaxrenewableage',
+ 'krbmaxticketlife', 'krbsupportedencsalttypes',
+ 'objectclass',
+ },
+ 'default_privileges': {
+ 'Kerberos Ticket Policy Readers',
+ },
+ },
+ 'System: Read User Kerberos Ticket Policy': {
+ 'non_object': True,
+ 'replaces_global_anonymous_aci': True,
+ 'ipapermlocation': DN(api.env.container_user, api.env.basedn),
+ 'ipapermtargetfilter': ['(objectclass=krbticketpolicyaux)'],
+ 'ipapermright': {'read', 'search', 'compare'},
+ 'ipapermdefaultattr': {
+ 'krbmaxrenewableage', 'krbmaxticketlife',
+ },
+ 'default_privileges': {
+ 'Kerberos Ticket Policy Readers',
+ },
+ },
+ }
+
+ label = _('Kerberos Ticket Policy')
+ label_singular = _('Kerberos Ticket Policy')
+
+ takes_params = (
+ Str('uid?',
+ cli_name='user',
+ label=_('User name'),
+ doc=_('Manage ticket policy for specific user'),
+ primary_key=True,
+ ),
+ Int('krbmaxticketlife?',
+ cli_name='maxlife',
+ label=_('Max life'),
+ doc=_('Maximum ticket life (seconds)'),
+ minvalue=1,
+ ),
+ Int('krbmaxrenewableage?',
+ cli_name='maxrenew',
+ label=_('Max renew'),
+ doc=_('Maximum renewable age (seconds)'),
+ minvalue=1,
+ ),
+ )
+
+ def get_dn(self, *keys, **kwargs):
+ if keys[-1] is not None:
+ return self.api.Object.user.get_dn(*keys, **kwargs)
+ return DN(self.container_dn, api.env.basedn)
+
+
+@register()
+class krbtpolicy_mod(baseldap.LDAPUpdate):
+ __doc__ = _('Modify Kerberos ticket policy.')
+
+ def execute(self, uid=None, **options):
+ return super(krbtpolicy_mod, self).execute(uid, **options)
+
+ def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options):
+ assert isinstance(dn, DN)
+ # disable all flag
+ # ticket policies are attached to objects with unrelated attributes
+ if options.get('all'):
+ options['all'] = False
+ return dn
+
+
+@register()
+class krbtpolicy_show(baseldap.LDAPRetrieve):
+ __doc__ = _('Display the current Kerberos ticket policy.')
+
+ def execute(self, uid=None, **options):
+ return super(krbtpolicy_show, self).execute(uid, **options)
+
+ def pre_callback(self, ldap, dn, attrs_list, *keys, **options):
+ assert isinstance(dn, DN)
+ # disable all flag
+ # ticket policies are attached to objects with unrelated attributes
+ if options.get('all'):
+ options['all'] = False
+ return dn
+
+ def post_callback(self, ldap, dn, entry, *keys, **options):
+ default_entry = None
+ rights = None
+ for attrname in self.obj.default_attributes:
+ if attrname not in entry:
+ if keys[-1] is not None:
+ # User entry doesn't override the attribute.
+ # Check if this is caused by insufficient read rights
+ if rights is None:
+ rights = baseldap.get_effective_rights(
+ ldap, dn, self.obj.default_attributes)
+ if 'r' not in rights.get(attrname.lower(), ''):
+ raise errors.ACIError(
+ info=_('Ticket policy for %s could not be read') %
+ keys[-1])
+ # Fallback to the default
+ if default_entry is None:
+ try:
+ default_dn = self.obj.get_dn(None)
+ default_entry = ldap.get_entry(default_dn)
+ except errors.NotFound:
+ default_entry = {}
+ if attrname in default_entry:
+ entry[attrname] = default_entry[attrname]
+ if attrname not in entry:
+ raise errors.ACIError(
+ info=_('Default ticket policy could not be read'))
+ return dn
+
+
+@register()
+class krbtpolicy_reset(baseldap.LDAPQuery):
+ __doc__ = _('Reset Kerberos ticket policy to the default values.')
+
+ has_output = output.standard_entry
+
+ def execute(self, uid=None, **options):
+ ldap = self.obj.backend
+
+ dn = self.obj.get_dn(uid, **options)
+
+ def_values = {}
+ # if reseting policy for a user - just his values
+ if uid is not None:
+ for a in self.obj.default_attributes:
+ def_values[a] = None
+ # if reseting global policy - set values to default
+ else:
+ def_values = _default_values
+
+ entry = ldap.get_entry(dn, def_values.keys())
+ entry.update(def_values)
+ try:
+ ldap.update_entry(entry)
+ except errors.EmptyModlist:
+ pass
+
+ if uid is not None:
+ # policy for user was deleted, retrieve global policy
+ dn = self.obj.get_dn(None)
+ entry_attrs = ldap.get_entry(dn, self.obj.default_attributes)
+
+ entry_attrs = entry_to_dict(entry_attrs, **options)
+
+ return dict(result=entry_attrs, value=pkey_to_value(uid, options))
diff --git a/ipaserver/plugins/migration.py b/ipaserver/plugins/migration.py
new file mode 100644
index 000000000..7f634a7cc
--- /dev/null
+++ b/ipaserver/plugins/migration.py
@@ -0,0 +1,920 @@
+# Authors:
+# Pavel Zuna <pzuna@redhat.com>
+#
+# Copyright (C) 2009 Red Hat
+# see file 'COPYING' for use and warranty information
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+import re
+from ldap import MOD_ADD
+from ldap import SCOPE_BASE, SCOPE_ONELEVEL, SCOPE_SUBTREE
+
+import six
+
+from ipalib import api, errors, output
+from ipalib import Command, Password, Str, Flag, StrEnum, DNParam, Bool
+from ipalib.cli import to_cli
+from ipalib.plugable import Registry
+from .user import NO_UPG_MAGIC
+if api.env.in_server and api.env.context in ['lite', 'server']:
+ try:
+ from ipaserver.plugins.ldap2 import ldap2
+ except Exception as e:
+ raise e
+from ipalib import _
+from ipapython.dn import DN
+from ipapython.ipautil import write_tmp_file
+import datetime
+from ipaplatform.paths import paths
+
+if six.PY3:
+ unicode = str
+
+__doc__ = _("""
+Migration to IPA
+
+Migrate users and groups from an LDAP server to IPA.
+
+This performs an LDAP query against the remote server searching for
+users and groups in a container. In order to migrate passwords you need
+to bind as a user that can read the userPassword attribute on the remote
+server. This is generally restricted to high-level admins such as
+cn=Directory Manager in 389-ds (this is the default bind user).
+
+The default user container is ou=People.
+
+The default group container is ou=Groups.
+
+Users and groups that already exist on the IPA server are skipped.
+
+Two LDAP schemas define how group members are stored: RFC2307 and
+RFC2307bis. RFC2307bis uses member and uniquemember to specify group
+members, RFC2307 uses memberUid. The default schema is RFC2307bis.
+
+The schema compat feature allows IPA to reformat data for systems that
+do not support RFC2307bis. It is recommended that this feature is disabled
+during migration to reduce system overhead. It can be re-enabled after
+migration. To migrate with it enabled use the "--with-compat" option.
+
+Migrated users do not have Kerberos credentials, they have only their
+LDAP password. To complete the migration process, users need to go
+to http://ipa.example.com/ipa/migration and authenticate using their
+LDAP password in order to generate their Kerberos credentials.
+
+Migration is disabled by default. Use the command ipa config-mod to
+enable it:
+
+ ipa config-mod --enable-migration=TRUE
+
+If a base DN is not provided with --basedn then IPA will use either
+the value of defaultNamingContext if it is set or the first value
+in namingContexts set in the root of the remote LDAP server.
+
+Users are added as members to the default user group. This can be a
+time-intensive task so during migration this is done in a batch
+mode for every 100 users. As a result there will be a window in which
+users will be added to IPA but will not be members of the default
+user group.
+
+EXAMPLES:
+
+ The simplest migration, accepting all defaults:
+ ipa migrate-ds ldap://ds.example.com:389
+
+ Specify the user and group container. This can be used to migrate user
+ and group data from an IPA v1 server:
+ ipa migrate-ds --user-container='cn=users,cn=accounts' \\
+ --group-container='cn=groups,cn=accounts' \\
+ ldap://ds.example.com:389
+
+ Since IPA v2 server already contain predefined groups that may collide with
+ groups in migrated (IPA v1) server (for example admins, ipausers), users
+ having colliding group as their primary group may happen to belong to
+ an unknown group on new IPA v2 server.
+ Use --group-overwrite-gid option to overwrite GID of already existing groups
+ to prevent this issue:
+ ipa migrate-ds --group-overwrite-gid \\
+ --user-container='cn=users,cn=accounts' \\
+ --group-container='cn=groups,cn=accounts' \\
+ ldap://ds.example.com:389
+
+ Migrated users or groups may have object class and accompanied attributes
+ unknown to the IPA v2 server. These object classes and attributes may be
+ left out of the migration process:
+ ipa migrate-ds --user-container='cn=users,cn=accounts' \\
+ --group-container='cn=groups,cn=accounts' \\
+ --user-ignore-objectclass=radiusprofile \\
+ --user-ignore-attribute=radiusgroupname \\
+ ldap://ds.example.com:389
+
+LOGGING
+
+Migration will log warnings and errors to the Apache error log. This
+file should be evaluated post-migration to correct or investigate any
+issues that were discovered.
+
+For every 100 users migrated an info-level message will be displayed to
+give the current progress and duration to make it possible to track
+the progress of migration.
+
+If the log level is debug, either by setting debug = True in
+/etc/ipa/default.conf or /etc/ipa/server.conf, then an entry will be printed
+for each user added plus a summary when the default user group is
+updated.
+""")
+
+register = Registry()
+
+# USER MIGRATION CALLBACKS AND VARS
+
+_krb_err_msg = _('Kerberos principal %s already exists. Use \'ipa user-mod\' to set it manually.')
+_krb_failed_msg = _('Unable to determine if Kerberos principal %s already exists. Use \'ipa user-mod\' to set it manually.')
+_grp_err_msg = _('Failed to add user to the default group. Use \'ipa group-add-member\' to add manually.')
+_ref_err_msg = _('Migration of LDAP search reference is not supported.')
+_dn_err_msg = _('Malformed DN')
+
+_supported_schemas = (u'RFC2307bis', u'RFC2307')
+
+# search scopes for users and groups when migrating
+_supported_scopes = {u'base': SCOPE_BASE, u'onelevel': SCOPE_ONELEVEL, u'subtree': SCOPE_SUBTREE}
+_default_scope = u'onelevel'
+
+
+def _pre_migrate_user(ldap, pkey, dn, entry_attrs, failed, config, ctx, **kwargs):
+ assert isinstance(dn, DN)
+ attr_blacklist = ['krbprincipalkey','memberofindirect','memberindirect']
+ attr_blacklist.extend(kwargs.get('attr_blacklist', []))
+ ds_ldap = ctx['ds_ldap']
+ has_upg = ctx['has_upg']
+ search_bases = kwargs.get('search_bases', None)
+ valid_gids = kwargs['valid_gids']
+ invalid_gids = kwargs['invalid_gids']
+
+ if 'gidnumber' not in entry_attrs:
+ raise errors.NotFound(reason=_('%(user)s is not a POSIX user') % dict(user=pkey))
+ else:
+ # See if the gidNumber at least points to a valid group on the remote
+ # server.
+ if entry_attrs['gidnumber'][0] in invalid_gids:
+ api.log.warning('GID number %s of migrated user %s does not point to a known group.' \
+ % (entry_attrs['gidnumber'][0], pkey))
+ elif entry_attrs['gidnumber'][0] not in valid_gids:
+ try:
+ remote_entry = ds_ldap.find_entry_by_attr(
+ 'gidnumber', entry_attrs['gidnumber'][0], 'posixgroup',
+ [''], search_bases['group']
+ )
+ valid_gids.add(entry_attrs['gidnumber'][0])
+ except errors.NotFound:
+ api.log.warning('GID number %s of migrated user %s does not point to a known group.' \
+ % (entry_attrs['gidnumber'][0], pkey))
+ invalid_gids.add(entry_attrs['gidnumber'][0])
+ except errors.SingleMatchExpected as e:
+ # GID number matched more groups, this should not happen
+ api.log.warning('GID number %s of migrated user %s should match 1 group, but it matched %d groups' \
+ % (entry_attrs['gidnumber'][0], pkey, e.found))
+ except errors.LimitsExceeded as e:
+ api.log.warning('Search limit exceeded searching for GID %s' % entry_attrs['gidnumber'][0])
+
+ # We don't want to create a UPG so set the magic value in description
+ # to let the DS plugin know.
+ entry_attrs.setdefault('description', [])
+ entry_attrs['description'].append(NO_UPG_MAGIC)
+
+ # fill in required attributes by IPA
+ entry_attrs['ipauniqueid'] = 'autogenerate'
+ if 'homedirectory' not in entry_attrs:
+ homes_root = config.get('ipahomesrootdir', (paths.HOME_DIR, ))[0]
+ home_dir = '%s/%s' % (homes_root, pkey)
+ home_dir = home_dir.replace('//', '/').rstrip('/')
+ entry_attrs['homedirectory'] = home_dir
+
+ if 'loginshell' not in entry_attrs:
+ default_shell = config.get('ipadefaultloginshell', [paths.SH])[0]
+ entry_attrs.setdefault('loginshell', default_shell)
+
+ # do not migrate all attributes
+ for attr in attr_blacklist:
+ entry_attrs.pop(attr, None)
+
+ # do not migrate all object classes
+ if 'objectclass' in entry_attrs:
+ for object_class in kwargs.get('oc_blacklist', []):
+ try:
+ entry_attrs['objectclass'].remove(object_class)
+ except ValueError: # object class not present
+ pass
+
+ # generate a principal name and check if it isn't already taken
+ principal = u'%s@%s' % (pkey, api.env.realm)
+ try:
+ ldap.find_entry_by_attr(
+ 'krbprincipalname', principal, 'krbprincipalaux', [''],
+ DN(api.env.container_user, api.env.basedn)
+ )
+ except errors.NotFound:
+ entry_attrs['krbprincipalname'] = principal
+ except errors.LimitsExceeded:
+ failed[pkey] = unicode(_krb_failed_msg % principal)
+ else:
+ failed[pkey] = unicode(_krb_err_msg % principal)
+
+ # Fix any attributes with DN syntax that point to entries in the old
+ # tree
+
+ for attr in entry_attrs.keys():
+ if ldap.has_dn_syntax(attr):
+ for ind, value in enumerate(entry_attrs[attr]):
+ if not isinstance(value, DN):
+ # value is not DN instance, the automatic encoding may have
+ # failed due to missing schema or the remote attribute type OID was
+ # not detected as DN type. Try to work this around
+ api.log.debug('%s: value %s of type %s in attribute %s is not a DN'
+ ', convert it', pkey, value, type(value), attr)
+ try:
+ value = DN(value)
+ except ValueError as e:
+ api.log.warning('%s: skipping normalization of value %s of type %s '
+ 'in attribute %s which could not be converted to DN: %s',
+ pkey, value, type(value), attr, e)
+ continue
+ try:
+ remote_entry = ds_ldap.get_entry(value, [api.Object.user.primary_key.name, api.Object.group.primary_key.name])
+ except errors.NotFound:
+ api.log.warning('%s: attribute %s refers to non-existent entry %s' % (pkey, attr, value))
+ continue
+ if value.endswith(search_bases['user']):
+ primary_key = api.Object.user.primary_key.name
+ container = api.env.container_user
+ elif value.endswith(search_bases['group']):
+ primary_key = api.Object.group.primary_key.name
+ container = api.env.container_group
+ else:
+ api.log.warning('%s: value %s in attribute %s does not belong into any known container' % (pkey, value, attr))
+ continue
+
+ if not remote_entry.get(primary_key):
+ api.log.warning('%s: there is no primary key %s to migrate for %s' % (pkey, primary_key, attr))
+ continue
+
+ api.log.debug('converting DN value %s for %s in %s' % (value, attr, dn))
+ rdnval = remote_entry[primary_key][0].lower()
+ entry_attrs[attr][ind] = DN((primary_key, rdnval), container, api.env.basedn)
+
+ return dn
+
+
+def _post_migrate_user(ldap, pkey, dn, entry_attrs, failed, config, ctx):
+ assert isinstance(dn, DN)
+
+ if 'def_group_dn' in ctx:
+ _update_default_group(ldap, ctx, False)
+
+ if 'description' in entry_attrs and NO_UPG_MAGIC in entry_attrs['description']:
+ entry_attrs['description'].remove(NO_UPG_MAGIC)
+ try:
+ update_attrs = ldap.get_entry(dn, ['description'])
+ update_attrs['description'] = entry_attrs['description']
+ ldap.update_entry(update_attrs)
+ except (errors.EmptyModlist, errors.NotFound):
+ pass
+
+def _update_default_group(ldap, ctx, force):
+ migrate_cnt = ctx['migrate_cnt']
+ group_dn = ctx['def_group_dn']
+
+ # Purposely let this fire when migrate_cnt == 0 so on re-running migration
+ # it can catch any users migrated but not added to the default group.
+ if force or migrate_cnt % 100 == 0:
+ s = datetime.datetime.now()
+ searchfilter = "(&(objectclass=posixAccount)(!(memberof=%s)))" % group_dn
+ try:
+ (result, truncated) = ldap.find_entries(searchfilter,
+ [''], DN(api.env.container_user, api.env.basedn),
+ scope=ldap.SCOPE_SUBTREE, time_limit=-1, size_limit=-1)
+ except errors.NotFound:
+ api.log.debug('All users have default group set')
+ return
+
+ member_dns = [m.dn for m in result]
+ modlist = [(MOD_ADD, 'member', ldap.encode(member_dns))]
+ try:
+ with ldap.error_handler():
+ ldap.conn.modify_s(str(group_dn), modlist)
+ except errors.DatabaseError as e:
+ api.log.error('Adding new members to default group failed: %s \n'
+ 'members: %s', e, ','.join(member_dns))
+
+ e = datetime.datetime.now()
+ d = e - s
+ mode = " (forced)" if force else ""
+ api.log.info('Adding %d users to group%s duration %s',
+ len(member_dns), mode, d)
+
+# GROUP MIGRATION CALLBACKS AND VARS
+
+def _pre_migrate_group(ldap, pkey, dn, entry_attrs, failed, config, ctx, **kwargs):
+
+ def convert_members_rfc2307bis(member_attr, search_bases, overwrite=False):
+ """
+ Convert DNs in member attributes to work in IPA.
+ """
+ new_members = []
+ entry_attrs.setdefault(member_attr, [])
+ for m in entry_attrs[member_attr]:
+ try:
+ m = DN(m)
+ except ValueError as e:
+ # This should be impossible unless the remote server
+ # doesn't enforce syntax checking.
+ api.log.error('Malformed DN %s: %s' % (m, e))
+ continue
+ try:
+ rdnval = m[0].value
+ except IndexError:
+ api.log.error('Malformed DN %s has no RDN?' % m)
+ continue
+
+ if m.endswith(search_bases['user']):
+ api.log.debug('migrating %s user %s', member_attr, m)
+ m = DN((api.Object.user.primary_key.name, rdnval),
+ api.env.container_user, api.env.basedn)
+ elif m.endswith(search_bases['group']):
+ api.log.debug('migrating %s group %s', member_attr, m)
+ m = DN((api.Object.group.primary_key.name, rdnval),
+ api.env.container_group, api.env.basedn)
+ else:
+ api.log.error('entry %s does not belong into any known container' % m)
+ continue
+
+ new_members.append(m)
+
+ del entry_attrs[member_attr]
+ if overwrite:
+ entry_attrs['member'] = []
+ entry_attrs['member'] += new_members
+
+ def convert_members_rfc2307(member_attr):
+ """
+ Convert usernames in member attributes to work in IPA.
+ """
+ new_members = []
+ entry_attrs.setdefault(member_attr, [])
+ for m in entry_attrs[member_attr]:
+ memberdn = DN((api.Object.user.primary_key.name, m),
+ api.env.container_user, api.env.basedn)
+ new_members.append(memberdn)
+ entry_attrs['member'] = new_members
+
+ assert isinstance(dn, DN)
+ attr_blacklist = ['memberofindirect','memberindirect']
+ attr_blacklist.extend(kwargs.get('attr_blacklist', []))
+
+ schema = kwargs.get('schema', None)
+ entry_attrs['ipauniqueid'] = 'autogenerate'
+ if schema == 'RFC2307bis':
+ search_bases = kwargs.get('search_bases', None)
+ if not search_bases:
+ raise ValueError('Search bases not specified')
+
+ convert_members_rfc2307bis('member', search_bases, overwrite=True)
+ convert_members_rfc2307bis('uniquemember', search_bases)
+ elif schema == 'RFC2307':
+ convert_members_rfc2307('memberuid')
+ else:
+ raise ValueError('Schema %s not supported' % schema)
+
+ # do not migrate all attributes
+ for attr in attr_blacklist:
+ entry_attrs.pop(attr, None)
+
+ # do not migrate all object classes
+ if 'objectclass' in entry_attrs:
+ for object_class in kwargs.get('oc_blacklist', []):
+ try:
+ entry_attrs['objectclass'].remove(object_class)
+ except ValueError: # object class not present
+ pass
+
+ return dn
+
+
+def _group_exc_callback(ldap, dn, entry_attrs, exc, options):
+ assert isinstance(dn, DN)
+ if isinstance(exc, errors.DuplicateEntry):
+ if options.get('groupoverwritegid', False) and \
+ entry_attrs.get('gidnumber') is not None:
+ try:
+ new_entry_attrs = ldap.get_entry(dn, ['gidnumber'])
+ new_entry_attrs['gidnumber'] = entry_attrs['gidnumber']
+ ldap.update_entry(new_entry_attrs)
+ except errors.EmptyModlist:
+ # no change to the GID
+ pass
+ # mark as success
+ return
+ elif not options.get('groupoverwritegid', False) and \
+ entry_attrs.get('gidnumber') is not None:
+ msg = unicode(exc)
+ # add information about possibility to overwrite GID
+ msg = msg + unicode(_('. Check GID of the existing group. ' \
+ 'Use --group-overwrite-gid option to overwrite the GID'))
+ raise errors.DuplicateEntry(message=msg)
+
+ raise exc
+
+# DS MIGRATION PLUGIN
+
+def construct_filter(template, oc_list):
+ oc_subfilter = ''.join([ '(objectclass=%s)' % oc for oc in oc_list])
+ return template % oc_subfilter
+
+def validate_ldapuri(ugettext, ldapuri):
+ m = re.match('^ldaps?://[-\w\.]+(:\d+)?$', ldapuri)
+ if not m:
+ err_msg = _('Invalid LDAP URI.')
+ raise errors.ValidationError(name='ldap_uri', error=err_msg)
+
+
+@register()
+class migrate_ds(Command):
+ __doc__ = _('Migrate users and groups from DS to IPA.')
+
+ migrate_objects = {
+ # OBJECT_NAME: (search_filter, pre_callback, post_callback)
+ #
+ # OBJECT_NAME - is the name of an LDAPObject subclass
+ # search_filter - is the filter to retrieve objects from DS
+ # pre_callback - is called for each object just after it was
+ # retrieved from DS and before being added to IPA
+ # post_callback - is called for each object after it was added to IPA
+ # exc_callback - is called when adding entry to IPA raises an exception
+ #
+ # {pre, post}_callback parameters:
+ # ldap - ldap2 instance connected to IPA
+ # pkey - primary key value of the object (uid for users, etc.)
+ # dn - dn of the object as it (will be/is) stored in IPA
+ # entry_attrs - attributes of the object
+ # failed - a list of so-far failed objects
+ # config - IPA config entry attributes
+ # ctx - object context, used to pass data between callbacks
+ #
+ # If pre_callback return value evaluates to False, migration
+ # of the current object is aborted.
+ 'user': {
+ 'filter_template' : '(&(|%s)(uid=*))',
+ 'oc_option' : 'userobjectclass',
+ 'oc_blacklist_option' : 'userignoreobjectclass',
+ 'attr_blacklist_option' : 'userignoreattribute',
+ 'pre_callback' : _pre_migrate_user,
+ 'post_callback' : _post_migrate_user,
+ 'exc_callback' : None
+ },
+ 'group': {
+ 'filter_template' : '(&(|%s)(cn=*))',
+ 'oc_option' : 'groupobjectclass',
+ 'oc_blacklist_option' : 'groupignoreobjectclass',
+ 'attr_blacklist_option' : 'groupignoreattribute',
+ 'pre_callback' : _pre_migrate_group,
+ 'post_callback' : None,
+ 'exc_callback' : _group_exc_callback,
+ },
+ }
+ migrate_order = ('user', 'group')
+
+ takes_args = (
+ Str('ldapuri', validate_ldapuri,
+ cli_name='ldap_uri',
+ label=_('LDAP URI'),
+ doc=_('LDAP URI of DS server to migrate from'),
+ ),
+ Password('bindpw',
+ cli_name='password',
+ label=_('Password'),
+ confirm=False,
+ doc=_('bind password'),
+ ),
+ )
+
+ takes_options = (
+ DNParam('binddn?',
+ cli_name='bind_dn',
+ label=_('Bind DN'),
+ default=DN(('cn', 'directory manager')),
+ autofill=True,
+ ),
+ DNParam('usercontainer',
+ cli_name='user_container',
+ label=_('User container'),
+ doc=_('DN of container for users in DS relative to base DN'),
+ default=DN(('ou', 'people')),
+ autofill=True,
+ ),
+ DNParam('groupcontainer',
+ cli_name='group_container',
+ label=_('Group container'),
+ doc=_('DN of container for groups in DS relative to base DN'),
+ default=DN(('ou', 'groups')),
+ autofill=True,
+ ),
+ Str('userobjectclass+',
+ cli_name='user_objectclass',
+ label=_('User object class'),
+ doc=_('Objectclasses used to search for user entries in DS'),
+ default=(u'person',),
+ autofill=True,
+ ),
+ Str('groupobjectclass+',
+ cli_name='group_objectclass',
+ label=_('Group object class'),
+ doc=_('Objectclasses used to search for group entries in DS'),
+ default=(u'groupOfUniqueNames', u'groupOfNames'),
+ autofill=True,
+ ),
+ Str('userignoreobjectclass*',
+ cli_name='user_ignore_objectclass',
+ label=_('Ignore user object class'),
+ doc=_('Objectclasses to be ignored for user entries in DS'),
+ default=tuple(),
+ autofill=True,
+ ),
+ Str('userignoreattribute*',
+ cli_name='user_ignore_attribute',
+ label=_('Ignore user attribute'),
+ doc=_('Attributes to be ignored for user entries in DS'),
+ default=tuple(),
+ autofill=True,
+ ),
+ Str('groupignoreobjectclass*',
+ cli_name='group_ignore_objectclass',
+ label=_('Ignore group object class'),
+ doc=_('Objectclasses to be ignored for group entries in DS'),
+ default=tuple(),
+ autofill=True,
+ ),
+ Str('groupignoreattribute*',
+ cli_name='group_ignore_attribute',
+ label=_('Ignore group attribute'),
+ doc=_('Attributes to be ignored for group entries in DS'),
+ default=tuple(),
+ autofill=True,
+ ),
+ Flag('groupoverwritegid',
+ cli_name='group_overwrite_gid',
+ label=_('Overwrite GID'),
+ doc=_('When migrating a group already existing in IPA domain overwrite the '\
+ 'group GID and report as success'),
+ ),
+ StrEnum('schema?',
+ cli_name='schema',
+ label=_('LDAP schema'),
+ doc=_('The schema used on the LDAP server. Supported values are RFC2307 and RFC2307bis. The default is RFC2307bis'),
+ values=_supported_schemas,
+ default=_supported_schemas[0],
+ autofill=True,
+ ),
+ Flag('continue?',
+ label=_('Continue'),
+ doc=_('Continuous operation mode. Errors are reported but the process continues'),
+ default=False,
+ ),
+ DNParam('basedn?',
+ cli_name='base_dn',
+ label=_('Base DN'),
+ doc=_('Base DN on remote LDAP server'),
+ ),
+ Flag('compat?',
+ cli_name='with_compat',
+ label=_('Ignore compat plugin'),
+ doc=_('Allows migration despite the usage of compat plugin'),
+ default=False,
+ ),
+ Str('cacertfile?',
+ cli_name='ca_cert_file',
+ label=_('CA certificate'),
+ doc=_('Load CA certificate of LDAP server from FILE'),
+ default=None,
+ noextrawhitespace=False,
+ ),
+ Bool('use_def_group?',
+ cli_name='use_default_group',
+ label=_('Add to default group'),
+ doc=_('Add migrated users without a group to a default group '
+ '(default: true)'),
+ default=True,
+ autofill=True,
+ ),
+ StrEnum('scope',
+ cli_name='scope',
+ label=_('Search scope'),
+ doc=_('LDAP search scope for users and groups: base, onelevel, or '
+ 'subtree. Defaults to onelevel'),
+ values=tuple(_supported_scopes.keys()),
+ default=_default_scope,
+ autofill=True,
+ ),
+ )
+
+ has_output = (
+ output.Output('result',
+ type=dict,
+ doc=_('Lists of objects migrated; categorized by type.'),
+ ),
+ output.Output('failed',
+ type=dict,
+ doc=_('Lists of objects that could not be migrated; categorized by type.'),
+ ),
+ output.Output('enabled',
+ type=bool,
+ doc=_('False if migration mode was disabled.'),
+ ),
+ output.Output('compat',
+ type=bool,
+ doc=_('False if migration fails because the compatibility plug-in is enabled.'),
+ ),
+ )
+
+ exclude_doc = _('%s to exclude from migration')
+
+ truncated_err_msg = _('''\
+search results for objects to be migrated
+have been truncated by the server;
+migration process might be incomplete\n''')
+
+ def get_options(self):
+ """
+ Call get_options of the baseclass and add "exclude" options
+ for each type of object being migrated.
+ """
+ for option in super(migrate_ds, self).get_options():
+ yield option
+ for ldap_obj_name in self.migrate_objects:
+ ldap_obj = self.api.Object[ldap_obj_name]
+ name = 'exclude_%ss' % to_cli(ldap_obj_name)
+ doc = self.exclude_doc % ldap_obj.object_name_plural
+ yield Str(
+ '%s*' % name, cli_name=name, doc=doc, default=tuple(),
+ autofill=True
+ )
+
+ def normalize_options(self, options):
+ """
+ Convert all "exclude" option values to lower-case.
+
+ Also, empty List parameters are converted to None, but the migration
+ plugin doesn't like that - convert back to empty lists.
+ """
+ names = ['userobjectclass', 'groupobjectclass',
+ 'userignoreobjectclass', 'userignoreattribute',
+ 'groupignoreobjectclass', 'groupignoreattribute']
+ names.extend('exclude_%ss' % to_cli(n) for n in self.migrate_objects)
+ for name in names:
+ if options[name]:
+ options[name] = tuple(
+ v.lower() for v in options[name]
+ )
+ else:
+ options[name] = tuple()
+
+ def _get_search_bases(self, options, ds_base_dn, migrate_order):
+ search_bases = dict()
+ for ldap_obj_name in migrate_order:
+ container = options.get('%scontainer' % to_cli(ldap_obj_name))
+ if container:
+ # Don't append base dn if user already appended it in the container dn
+ if container.endswith(ds_base_dn):
+ search_base = container
+ else:
+ search_base = DN(container, ds_base_dn)
+ else:
+ search_base = ds_base_dn
+ search_bases[ldap_obj_name] = search_base
+ return search_bases
+
+ def migrate(self, ldap, config, ds_ldap, ds_base_dn, options):
+ """
+ Migrate objects from DS to LDAP.
+ """
+ assert isinstance(ds_base_dn, DN)
+ migrated = {} # {'OBJ': ['PKEY1', 'PKEY2', ...], ...}
+ failed = {} # {'OBJ': {'PKEY1': 'Failed 'cos blabla', ...}, ...}
+ search_bases = self._get_search_bases(options, ds_base_dn, self.migrate_order)
+ migration_start = datetime.datetime.now()
+
+ scope = _supported_scopes[options.get('scope')]
+
+ for ldap_obj_name in self.migrate_order:
+ ldap_obj = self.api.Object[ldap_obj_name]
+
+ template = self.migrate_objects[ldap_obj_name]['filter_template']
+ oc_list = options[to_cli(self.migrate_objects[ldap_obj_name]['oc_option'])]
+ search_filter = construct_filter(template, oc_list)
+
+ exclude = options['exclude_%ss' % to_cli(ldap_obj_name)]
+ context = dict(ds_ldap = ds_ldap)
+
+ migrated[ldap_obj_name] = []
+ failed[ldap_obj_name] = {}
+
+ try:
+ entries, truncated = ds_ldap.find_entries(
+ search_filter, ['*'], search_bases[ldap_obj_name],
+ scope,
+ time_limit=0, size_limit=-1,
+ search_refs=True # migrated DS may contain search references
+ )
+ except errors.NotFound:
+ if not options.get('continue',False):
+ raise errors.NotFound(
+ reason=_('%(container)s LDAP search did not return any result '
+ '(search base: %(search_base)s, '
+ 'objectclass: %(objectclass)s)')
+ % {'container': ldap_obj_name,
+ 'search_base': search_bases[ldap_obj_name],
+ 'objectclass': ', '.join(oc_list)}
+ )
+ else:
+ truncated = False
+ entries = []
+ if truncated:
+ self.log.error(
+ '%s: %s' % (
+ ldap_obj.name, self.truncated_err_msg
+ )
+ )
+
+ blacklists = {}
+ for blacklist in ('oc_blacklist', 'attr_blacklist'):
+ blacklist_option = self.migrate_objects[ldap_obj_name][blacklist+'_option']
+ if blacklist_option is not None:
+ blacklists[blacklist] = options.get(blacklist_option, tuple())
+ else:
+ blacklists[blacklist] = tuple()
+
+ # get default primary group for new users
+ if 'def_group_dn' not in context and options.get('use_def_group'):
+ def_group = config.get('ipadefaultprimarygroup')
+ context['def_group_dn'] = api.Object.group.get_dn(def_group)
+ try:
+ ldap.get_entry(context['def_group_dn'], ['gidnumber', 'cn'])
+ except errors.NotFound:
+ error_msg = _('Default group for new users not found')
+ raise errors.NotFound(reason=error_msg)
+
+ context['has_upg'] = ldap.has_upg()
+
+ valid_gids = set()
+ invalid_gids = set()
+ migrate_cnt = 0
+ context['migrate_cnt'] = 0
+ for entry_attrs in entries:
+ context['migrate_cnt'] = migrate_cnt
+ s = datetime.datetime.now()
+
+ ava = entry_attrs.dn[0][0]
+ if ava.attr == ldap_obj.primary_key.name:
+ # In case if pkey attribute is in the migrated object DN
+ # and the original LDAP is multivalued, make sure that
+ # we pick the correct value (the unique one stored in DN)
+ pkey = ava.value.lower()
+ else:
+ pkey = entry_attrs[ldap_obj.primary_key.name][0].lower()
+
+ if pkey in exclude:
+ continue
+
+ entry_attrs.dn = ldap_obj.get_dn(pkey)
+ entry_attrs['objectclass'] = list(
+ set(
+ config.get(
+ ldap_obj.object_class_config, ldap_obj.object_class
+ ) + [o.lower() for o in entry_attrs['objectclass']]
+ )
+ )
+ entry_attrs[ldap_obj.primary_key.name][0] = entry_attrs[ldap_obj.primary_key.name][0].lower()
+
+ callback = self.migrate_objects[ldap_obj_name]['pre_callback']
+ if callable(callback):
+ try:
+ entry_attrs.dn = callback(
+ ldap, pkey, entry_attrs.dn, entry_attrs,
+ failed[ldap_obj_name], config, context,
+ schema=options['schema'],
+ search_bases=search_bases,
+ valid_gids=valid_gids,
+ invalid_gids=invalid_gids,
+ **blacklists
+ )
+ if not entry_attrs.dn:
+ continue
+ except errors.NotFound as e:
+ failed[ldap_obj_name][pkey] = unicode(e.reason)
+ continue
+
+ try:
+ ldap.add_entry(entry_attrs)
+ except errors.ExecutionError as e:
+ callback = self.migrate_objects[ldap_obj_name]['exc_callback']
+ if callable(callback):
+ try:
+ callback(
+ ldap, entry_attrs.dn, entry_attrs, e, options)
+ except errors.ExecutionError as e:
+ failed[ldap_obj_name][pkey] = unicode(e)
+ continue
+ else:
+ failed[ldap_obj_name][pkey] = unicode(e)
+ continue
+
+ migrated[ldap_obj_name].append(pkey)
+
+ callback = self.migrate_objects[ldap_obj_name]['post_callback']
+ if callable(callback):
+ callback(
+ ldap, pkey, entry_attrs.dn, entry_attrs,
+ failed[ldap_obj_name], config, context)
+ e = datetime.datetime.now()
+ d = e - s
+ total_dur = e - migration_start
+ migrate_cnt += 1
+ if migrate_cnt > 0 and migrate_cnt % 100 == 0:
+ api.log.info("%d %ss migrated. %s elapsed." % (migrate_cnt, ldap_obj_name, total_dur))
+ api.log.debug("%d %ss migrated, duration: %s (total %s)" % (migrate_cnt, ldap_obj_name, d, total_dur))
+
+ if 'def_group_dn' in context:
+ _update_default_group(ldap, context, True)
+
+ return (migrated, failed)
+
+ def execute(self, ldapuri, bindpw, **options):
+ ldap = self.api.Backend.ldap2
+ self.normalize_options(options)
+ config = ldap.get_ipa_config()
+
+ ds_base_dn = options.get('basedn')
+ if ds_base_dn is not None:
+ assert isinstance(ds_base_dn, DN)
+
+ # check if migration mode is enabled
+ if config.get('ipamigrationenabled', ('FALSE', ))[0] == 'FALSE':
+ return dict(result={}, failed={}, enabled=False, compat=True)
+
+ # connect to DS
+ ds_ldap = ldap2(self.api, ldap_uri=ldapuri)
+
+ cacert = None
+ if options.get('cacertfile') is not None:
+ #store CA cert into file
+ tmp_ca_cert_f = write_tmp_file(options['cacertfile'])
+ cacert = tmp_ca_cert_f.name
+
+ #start TLS connection
+ ds_ldap.connect(bind_dn=options['binddn'], bind_pw=bindpw,
+ tls_cacertfile=cacert)
+
+ tmp_ca_cert_f.close()
+ else:
+ ds_ldap.connect(bind_dn=options['binddn'], bind_pw=bindpw)
+
+ #check whether the compat plugin is enabled
+ if not options.get('compat'):
+ try:
+ ldap.get_entry(DN(('cn', 'compat'), (api.env.basedn)))
+ return dict(result={}, failed={}, enabled=True, compat=False)
+ except errors.NotFound:
+ pass
+
+ if not ds_base_dn:
+ # retrieve base DN from remote LDAP server
+ entries, truncated = ds_ldap.find_entries(
+ '', ['namingcontexts', 'defaultnamingcontext'], DN(''),
+ ds_ldap.SCOPE_BASE, size_limit=-1, time_limit=0,
+ )
+ if 'defaultnamingcontext' in entries[0]:
+ ds_base_dn = DN(entries[0]['defaultnamingcontext'][0])
+ assert isinstance(ds_base_dn, DN)
+ else:
+ try:
+ ds_base_dn = DN(entries[0]['namingcontexts'][0])
+ assert isinstance(ds_base_dn, DN)
+ except (IndexError, KeyError) as e:
+ raise Exception(str(e))
+
+ # migrate!
+ (migrated, failed) = self.migrate(
+ ldap, config, ds_ldap, ds_base_dn, options
+ )
+
+ return dict(result=migrated, failed=failed, enabled=True, compat=True)
diff --git a/ipaserver/plugins/misc.py b/ipaserver/plugins/misc.py
new file mode 100644
index 000000000..0628bb19b
--- /dev/null
+++ b/ipaserver/plugins/misc.py
@@ -0,0 +1,138 @@
+# Authors:
+# Jason Gerard DeRose <jderose@redhat.com>
+#
+# Copyright (C) 2008 Red Hat
+# see file 'COPYING' for use and warranty information
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+import re
+from ipalib import LocalOrRemote, _, ngettext
+from ipalib.output import Output, summary
+from ipalib import Flag
+from ipalib.plugable import Registry
+
+__doc__ = _("""
+Misc plug-ins
+""")
+
+register = Registry()
+
+# FIXME: We should not let env return anything in_server
+# when mode == 'production'. This would allow an attacker to see the
+# configuration of the server, potentially revealing compromising
+# information. However, it's damn handy for testing/debugging.
+
+
+@register()
+class env(LocalOrRemote):
+ __doc__ = _('Show environment variables.')
+
+ msg_summary = _('%(count)d variables')
+
+ takes_args = (
+ 'variables*',
+ )
+
+ takes_options = LocalOrRemote.takes_options + (
+ Flag('all',
+ cli_name='all',
+ doc=_('retrieve and print all attributes from the server. Affects command output.'),
+ exclude='webui',
+ flags=['no_option', 'no_output'],
+ default=True,
+ ),
+ )
+
+ has_output = (
+ Output('result',
+ type=dict,
+ doc=_('Dictionary mapping variable name to value'),
+ ),
+ Output('total',
+ type=int,
+ doc=_('Total number of variables env (>= count)'),
+ flags=['no_display'],
+ ),
+ Output('count',
+ type=int,
+ doc=_('Number of variables returned (<= total)'),
+ flags=['no_display'],
+ ),
+ summary,
+ )
+
+ def __find_keys(self, variables):
+ keys = set()
+ for query in variables:
+ if '*' in query:
+ pat = re.compile(query.replace('*', '.*') + '$')
+ for key in self.env:
+ if pat.match(key):
+ keys.add(key)
+ elif query in self.env:
+ keys.add(query)
+ return keys
+
+ def execute(self, variables=None, **options):
+ if variables is None:
+ keys = self.env
+ else:
+ keys = self.__find_keys(variables)
+ ret = dict(
+ result=dict(
+ (key, self.env[key]) for key in keys
+ ),
+ count=len(keys),
+ total=len(self.env),
+ )
+ if len(keys) > 1:
+ ret['summary'] = self.msg_summary % ret
+ else:
+ ret['summary'] = None
+ return ret
+
+
+
+@register()
+class plugins(LocalOrRemote):
+ __doc__ = _('Show all loaded plugins.')
+
+ msg_summary = ngettext(
+ '%(count)d plugin loaded', '%(count)d plugins loaded', 0
+ )
+
+ takes_options = LocalOrRemote.takes_options + (
+ Flag('all',
+ cli_name='all',
+ doc=_('retrieve and print all attributes from the server. Affects command output.'),
+ exclude='webui',
+ flags=['no_option', 'no_output'],
+ default=True,
+ ),
+ )
+
+ has_output = (
+ Output('result', dict, 'Dictionary mapping plugin names to bases'),
+ Output('count',
+ type=int,
+ doc=_('Number of plugins loaded'),
+ ),
+ summary,
+ )
+
+ def execute(self, **options):
+ return dict(
+ result=dict(self.api.plugins),
+ )
diff --git a/ipaserver/plugins/netgroup.py b/ipaserver/plugins/netgroup.py
new file mode 100644
index 000000000..f76a0ba3a
--- /dev/null
+++ b/ipaserver/plugins/netgroup.py
@@ -0,0 +1,387 @@
+# Authors:
+# Rob Crittenden <rcritten@redhat.com>
+# Pavel Zuna <pzuna@redhat.com>
+#
+# Copyright (C) 2009 Red Hat
+# see file 'COPYING' for use and warranty information
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+import six
+
+from ipalib import api, errors
+from ipalib import Str, StrEnum, Flag
+from ipalib.plugable import Registry
+from .baseldap import (
+ external_host_param,
+ add_external_pre_callback,
+ add_external_post_callback,
+ remove_external_post_callback,
+ LDAPObject,
+ LDAPCreate,
+ LDAPDelete,
+ LDAPUpdate,
+ LDAPSearch,
+ LDAPRetrieve,
+ LDAPAddMember,
+ LDAPRemoveMember)
+from ipalib import _, ngettext
+from .hbacrule import is_all
+from ipapython.dn import DN
+
+if six.PY3:
+ unicode = str
+
+__doc__ = _("""
+Netgroups
+
+A netgroup is a group used for permission checking. It can contain both
+user and host values.
+
+EXAMPLES:
+
+ Add a new netgroup:
+ ipa netgroup-add --desc="NFS admins" admins
+
+ Add members to the netgroup:
+ ipa netgroup-add-member --users=tuser1 --users=tuser2 admins
+
+ Remove a member from the netgroup:
+ ipa netgroup-remove-member --users=tuser2 admins
+
+ Display information about a netgroup:
+ ipa netgroup-show admins
+
+ Delete a netgroup:
+ ipa netgroup-del admins
+""")
+
+register = Registry()
+
+NETGROUP_PATTERN='^[a-zA-Z0-9_.][a-zA-Z0-9_.-]*$'
+NETGROUP_PATTERN_ERRMSG='may only include letters, numbers, _, -, and .'
+
+# according to most common use cases the netgroup pattern should fit
+# also the nisdomain pattern
+NISDOMAIN_PATTERN=NETGROUP_PATTERN
+NISDOMAIN_PATTERN_ERRMSG=NETGROUP_PATTERN_ERRMSG
+
+output_params = (
+ Str('memberuser_user?',
+ label='Member User',
+ ),
+ Str('memberuser_group?',
+ label='Member Group',
+ ),
+ Str('memberhost_host?',
+ label=_('Member Host'),
+ ),
+ Str('memberhost_hostgroup?',
+ label='Member Hostgroup',
+ ),
+ )
+
+
+@register()
+class netgroup(LDAPObject):
+ """
+ Netgroup object.
+ """
+ container_dn = api.env.container_netgroup
+ object_name = _('netgroup')
+ object_name_plural = _('netgroups')
+ object_class = ['ipaobject', 'ipaassociation', 'ipanisnetgroup']
+ permission_filter_objectclasses = ['ipanisnetgroup']
+ search_attributes = [
+ 'cn', 'description', 'memberof', 'externalhost', 'nisdomainname',
+ 'memberuser', 'memberhost', 'member', 'usercategory', 'hostcategory',
+ ]
+ default_attributes = [
+ 'cn', 'description', 'memberof', 'externalhost', 'nisdomainname',
+ 'memberuser', 'memberhost', 'member', 'memberindirect',
+ 'usercategory', 'hostcategory',
+ ]
+ uuid_attribute = 'ipauniqueid'
+ rdn_attribute = 'ipauniqueid'
+ attribute_members = {
+ 'member': ['netgroup'],
+ 'memberof': ['netgroup'],
+ 'memberindirect': ['netgroup'],
+ 'memberuser': ['user', 'group'],
+ 'memberhost': ['host', 'hostgroup'],
+ }
+ relationships = {
+ 'member': ('Member', '', 'no_'),
+ 'memberof': ('Member Of', 'in_', 'not_in_'),
+ 'memberindirect': (
+ 'Indirect Member', None, 'no_indirect_'
+ ),
+ 'memberuser': ('Member', '', 'no_'),
+ 'memberhost': ('Member', '', 'no_'),
+ }
+ managed_permissions = {
+ 'System: Read Netgroups': {
+ 'replaces_global_anonymous_aci': True,
+ 'ipapermbindruletype': 'all',
+ 'ipapermright': {'read', 'search', 'compare'},
+ 'ipapermdefaultattr': {
+ 'cn', 'description', 'hostcategory', 'ipaenabledflag',
+ 'ipauniqueid', 'nisdomainname', 'usercategory', 'objectclass',
+ },
+ },
+ 'System: Read Netgroup Membership': {
+ 'replaces_global_anonymous_aci': True,
+ 'ipapermbindruletype': 'all',
+ 'ipapermright': {'read', 'search', 'compare'},
+ 'ipapermdefaultattr': {
+ 'externalhost', 'member', 'memberof', 'memberuser',
+ 'memberhost', 'objectclass',
+ },
+ },
+ 'System: Add Netgroups': {
+ 'ipapermright': {'add'},
+ 'replaces': [
+ '(target = "ldap:///ipauniqueid=*,cn=ng,cn=alt,$SUFFIX")(version 3.0;acl "permission:Add netgroups";allow (add) groupdn = "ldap:///cn=Add netgroups,cn=permissions,cn=pbac,$SUFFIX";)',
+ ],
+ 'default_privileges': {'Netgroups Administrators'},
+ },
+ 'System: Modify Netgroup Membership': {
+ 'ipapermright': {'write'},
+ 'ipapermdefaultattr': {
+ 'externalhost', 'member', 'memberhost', 'memberuser'
+ },
+ 'replaces': [
+ '(targetattr = "memberhost || externalhost || memberuser || member")(target = "ldap:///ipauniqueid=*,cn=ng,cn=alt,$SUFFIX")(version 3.0;acl "permission:Modify netgroup membership";allow (write) groupdn = "ldap:///cn=Modify netgroup membership,cn=permissions,cn=pbac,$SUFFIX";)',
+ ],
+ 'default_privileges': {'Netgroups Administrators'},
+ },
+ 'System: Modify Netgroups': {
+ 'ipapermright': {'write'},
+ 'ipapermdefaultattr': {'description'},
+ 'replaces': [
+ '(targetattr = "description")(target = "ldap:///ipauniqueid=*,cn=ng,cn=alt,$SUFFIX")(version 3.0; acl "permission:Modify netgroups";allow (write) groupdn = "ldap:///cn=Modify netgroups,cn=permissions,cn=pbac,$SUFFIX";)',
+ ],
+ 'default_privileges': {'Netgroups Administrators'},
+ },
+ 'System: Remove Netgroups': {
+ 'ipapermright': {'delete'},
+ 'replaces': [
+ '(target = "ldap:///ipauniqueid=*,cn=ng,cn=alt,$SUFFIX")(version 3.0;acl "permission:Remove netgroups";allow (delete) groupdn = "ldap:///cn=Remove netgroups,cn=permissions,cn=pbac,$SUFFIX";)',
+ ],
+ 'default_privileges': {'Netgroups Administrators'},
+ },
+ 'System: Read Netgroup Compat Tree': {
+ 'non_object': True,
+ 'ipapermbindruletype': 'anonymous',
+ 'ipapermlocation': api.env.basedn,
+ 'ipapermtarget': DN('cn=ng', 'cn=compat', api.env.basedn),
+ 'ipapermright': {'read', 'search', 'compare'},
+ 'ipapermdefaultattr': {
+ 'objectclass', 'cn', 'membernisnetgroup', 'nisnetgrouptriple',
+ },
+ },
+ }
+
+ label = _('Netgroups')
+ label_singular = _('Netgroup')
+
+ takes_params = (
+ Str('cn',
+ pattern=NETGROUP_PATTERN,
+ pattern_errmsg=NETGROUP_PATTERN_ERRMSG,
+ cli_name='name',
+ label=_('Netgroup name'),
+ primary_key=True,
+ normalizer=lambda value: value.lower(),
+ ),
+ Str('description?',
+ cli_name='desc',
+ label=_('Description'),
+ doc=_('Netgroup description'),
+ ),
+ Str('nisdomainname?',
+ pattern=NISDOMAIN_PATTERN,
+ pattern_errmsg=NISDOMAIN_PATTERN_ERRMSG,
+ cli_name='nisdomain',
+ label=_('NIS domain name'),
+ ),
+ Str('ipauniqueid?',
+ cli_name='uuid',
+ label='IPA unique ID',
+ doc=_('IPA unique ID'),
+ flags=['no_create', 'no_update'],
+ ),
+ StrEnum('usercategory?',
+ cli_name='usercat',
+ label=_('User category'),
+ doc=_('User category the rule applies to'),
+ values=(u'all', ),
+ ),
+ StrEnum('hostcategory?',
+ cli_name='hostcat',
+ label=_('Host category'),
+ doc=_('Host category the rule applies to'),
+ values=(u'all', ),
+ ),
+ external_host_param,
+ )
+
+
+@register()
+class netgroup_add(LDAPCreate):
+ __doc__ = _('Add a new netgroup.')
+
+ has_output_params = LDAPCreate.has_output_params + output_params
+ msg_summary = _('Added netgroup "%(value)s"')
+
+ msg_collision = _(u'hostgroup with name "%s" already exists. ' \
+ u'Hostgroups and netgroups share a common namespace')
+
+ def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options):
+ assert isinstance(dn, DN)
+ entry_attrs.setdefault('nisdomainname', self.api.env.domain)
+
+ try:
+ test_dn = self.obj.get_dn(keys[-1])
+ netgroup = ldap.get_entry(test_dn, ['objectclass'])
+ if 'mepManagedEntry' in netgroup.get('objectclass', []):
+ raise errors.DuplicateEntry(message=unicode(self.msg_collision % keys[-1]))
+ else:
+ self.obj.handle_duplicate_entry(*keys)
+ except errors.NotFound:
+ pass
+
+ try:
+ # when enabled, a managed netgroup is created for every hostgroup
+ # make sure that we don't create a collision if the plugin is
+ # (temporarily) disabled
+ api.Object['hostgroup'].get_dn_if_exists(keys[-1])
+ raise errors.DuplicateEntry(message=unicode(self.msg_collision % keys[-1]))
+ except errors.NotFound:
+ pass
+
+ return dn
+
+
+@register()
+class netgroup_del(LDAPDelete):
+ __doc__ = _('Delete a netgroup.')
+
+ msg_summary = _('Deleted netgroup "%(value)s"')
+
+
+
+@register()
+class netgroup_mod(LDAPUpdate):
+ __doc__ = _('Modify a netgroup.')
+
+ has_output_params = LDAPUpdate.has_output_params + output_params
+ msg_summary = _('Modified netgroup "%(value)s"')
+
+ def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options):
+ assert isinstance(dn, DN)
+ try:
+ entry_attrs = ldap.get_entry(dn, attrs_list)
+ dn = entry_attrs.dn
+ except errors.NotFound:
+ self.obj.handle_not_found(*keys)
+ if is_all(options, 'usercategory') and 'memberuser' in entry_attrs:
+ raise errors.MutuallyExclusiveError(reason=_("user category cannot be set to 'all' while there are allowed users"))
+ if is_all(options, 'hostcategory') and 'memberhost' in entry_attrs:
+ raise errors.MutuallyExclusiveError(reason=_("host category cannot be set to 'all' while there are allowed hosts"))
+ return dn
+
+
+@register()
+class netgroup_find(LDAPSearch):
+ __doc__ = _('Search for a netgroup.')
+
+ member_attributes = ['member', 'memberuser', 'memberhost', 'memberof']
+ has_output_params = LDAPSearch.has_output_params + output_params
+ msg_summary = ngettext(
+ '%(count)d netgroup matched', '%(count)d netgroups matched', 0
+ )
+
+ takes_options = LDAPSearch.takes_options + (
+ Flag('private',
+ exclude='webui',
+ flags=['no_option', 'no_output'],
+ ),
+ Flag('managed',
+ cli_name='managed',
+ doc=_('search for managed groups'),
+ default_from=lambda private: private,
+ ),
+ )
+
+ def pre_callback(self, ldap, filter, attrs_list, base_dn, scope, *args, **options):
+ assert isinstance(base_dn, DN)
+ # Do not display private mepManagedEntry netgroups by default
+ # If looking for managed groups, we need to omit the negation search filter
+
+ search_kw = {}
+ search_kw['objectclass'] = ['mepManagedEntry']
+ if not options['managed']:
+ local_filter = ldap.make_filter(search_kw, rules=ldap.MATCH_NONE)
+ else:
+ local_filter = ldap.make_filter(search_kw, rules=ldap.MATCH_ALL)
+ filter = ldap.combine_filters((local_filter, filter), rules=ldap.MATCH_ALL)
+ return (filter, base_dn, scope)
+
+
+@register()
+class netgroup_show(LDAPRetrieve):
+ __doc__ = _('Display information about a netgroup.')
+
+ has_output_params = LDAPRetrieve.has_output_params + output_params
+
+
+@register()
+class netgroup_add_member(LDAPAddMember):
+ __doc__ = _('Add members to a netgroup.')
+
+ member_attributes = ['memberuser', 'memberhost', 'member']
+ has_output_params = LDAPAddMember.has_output_params + output_params
+
+ def pre_callback(self, ldap, dn, found, not_found, *keys, **options):
+ assert isinstance(dn, DN)
+ return add_external_pre_callback('host', ldap, dn, keys, options)
+
+ def post_callback(self, ldap, completed, failed, dn, entry_attrs,
+ *keys, **options):
+ assert isinstance(dn, DN)
+ return add_external_post_callback(ldap, dn, entry_attrs,
+ failed=failed,
+ completed=completed,
+ memberattr='memberhost',
+ membertype='host',
+ externalattr='externalhost')
+
+
+@register()
+class netgroup_remove_member(LDAPRemoveMember):
+ __doc__ = _('Remove members from a netgroup.')
+
+ member_attributes = ['memberuser', 'memberhost', 'member']
+ has_output_params = LDAPRemoveMember.has_output_params + output_params
+
+ def post_callback(self, ldap, completed, failed, dn, entry_attrs,
+ *keys, **options):
+ assert isinstance(dn, DN)
+ return remove_external_post_callback(ldap, dn, entry_attrs,
+ failed=failed,
+ completed=completed,
+ memberattr='memberhost',
+ membertype='host',
+ externalattr='externalhost')
diff --git a/ipaserver/plugins/otp.py b/ipaserver/plugins/otp.py
new file mode 100644
index 000000000..306c87388
--- /dev/null
+++ b/ipaserver/plugins/otp.py
@@ -0,0 +1,7 @@
+#
+# Copyright (C) 2016 FreeIPA Contributors see COPYING for license
+#
+
+from ipalib.text import _
+
+__doc__ = _('One time password commands')
diff --git a/ipaserver/plugins/otpconfig.py b/ipaserver/plugins/otpconfig.py
new file mode 100644
index 000000000..c7710468f
--- /dev/null
+++ b/ipaserver/plugins/otpconfig.py
@@ -0,0 +1,121 @@
+# Authors:
+# Nathaniel McCallum <npmccallum@redhat.com>
+#
+# Copyright (C) 2014 Red Hat
+# see file 'COPYING' for use and warranty information
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+from ipalib import _, api, Int
+from ipalib.plugable import Registry
+from .baseldap import DN, LDAPObject, LDAPUpdate, LDAPRetrieve
+
+__doc__ = _("""
+OTP configuration
+
+Manage the default values that IPA uses for OTP tokens.
+
+EXAMPLES:
+
+ Show basic OTP configuration:
+ ipa otpconfig-show
+
+ Show all OTP configuration options:
+ ipa otpconfig-show --all
+
+ Change maximum TOTP authentication window to 10 minutes:
+ ipa otpconfig-mod --totp-auth-window=600
+
+ Change maximum TOTP synchronization window to 12 hours:
+ ipa otpconfig-mod --totp-sync-window=43200
+
+ Change maximum HOTP authentication window to 5:
+ ipa hotpconfig-mod --hotp-auth-window=5
+
+ Change maximum HOTP synchronization window to 50:
+ ipa hotpconfig-mod --hotp-sync-window=50
+""")
+
+register = Registry()
+
+topic = 'otp'
+
+
+@register()
+class otpconfig(LDAPObject):
+ object_name = _('OTP configuration options')
+ default_attributes = [
+ 'ipatokentotpauthwindow',
+ 'ipatokentotpsyncwindow',
+ 'ipatokenhotpauthwindow',
+ 'ipatokenhotpsyncwindow',
+ ]
+
+ container_dn = DN(('cn', 'otp'), ('cn', 'etc'))
+ permission_filter_objectclasses = ['ipatokenotpconfig']
+ managed_permissions = {
+ 'System: Read OTP Configuration': {
+ 'replaces_global_anonymous_aci': True,
+ 'ipapermbindruletype': 'all',
+ 'ipapermright': {'read', 'search', 'compare'},
+ 'ipapermdefaultattr': {
+ 'ipatokentotpauthwindow', 'ipatokentotpsyncwindow',
+ 'ipatokenhotpauthwindow', 'ipatokenhotpsyncwindow',
+ 'cn',
+ },
+ },
+ }
+
+ label = _('OTP Configuration')
+ label_singular = _('OTP Configuration')
+
+ takes_params = (
+ Int('ipatokentotpauthwindow',
+ cli_name='totp_auth_window',
+ label=_('TOTP authentication Window'),
+ doc=_('TOTP authentication time variance (seconds)'),
+ minvalue=5,
+ ),
+ Int('ipatokentotpsyncwindow',
+ cli_name='totp_sync_window',
+ label=_('TOTP Synchronization Window'),
+ doc=_('TOTP synchronization time variance (seconds)'),
+ minvalue=5,
+ ),
+ Int('ipatokenhotpauthwindow',
+ cli_name='hotp_auth_window',
+ label=_('HOTP Authentication Window'),
+ doc=_('HOTP authentication skip-ahead'),
+ minvalue=1,
+ ),
+ Int('ipatokenhotpsyncwindow',
+ cli_name='hotp_sync_window',
+ label=_('HOTP Synchronization Window'),
+ doc=_('HOTP synchronization skip-ahead'),
+ minvalue=1,
+ ),
+ )
+
+ def get_dn(self, *keys, **kwargs):
+ return self.container_dn + api.env.basedn
+
+
+@register()
+class otpconfig_mod(LDAPUpdate):
+ __doc__ = _('Modify OTP configuration options.')
+
+
+@register()
+class otpconfig_show(LDAPRetrieve):
+ __doc__ = _('Show the current OTP configuration.')
diff --git a/ipaserver/plugins/otptoken.py b/ipaserver/plugins/otptoken.py
new file mode 100644
index 000000000..fda05ce0b
--- /dev/null
+++ b/ipaserver/plugins/otptoken.py
@@ -0,0 +1,464 @@
+# Authors:
+# Nathaniel McCallum <npmccallum@redhat.com>
+#
+# Copyright (C) 2013 Red Hat
+# see file 'COPYING' for use and warranty information
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+from .baseldap import LDAPObject, LDAPAddMember, LDAPRemoveMember
+from .baseldap import LDAPCreate, LDAPDelete, LDAPUpdate, LDAPSearch, LDAPRetrieve
+from ipalib import api, Int, Str, Bool, DateTime, Flag, Bytes, IntEnum, StrEnum, _, ngettext
+from ipalib.plugable import Registry
+from ipalib.errors import (
+ PasswordMismatch,
+ ConversionError,
+ NotFound,
+ ValidationError)
+from ipalib.request import context
+from ipapython.dn import DN
+
+import base64
+import uuid
+import os
+
+import six
+from six.moves import urllib
+
+if six.PY3:
+ unicode = str
+
+__doc__ = _("""
+OTP Tokens
+""") + _("""
+Manage OTP tokens.
+""") + _("""
+IPA supports the use of OTP tokens for multi-factor authentication. This
+code enables the management of OTP tokens.
+""") + _("""
+EXAMPLES:
+""") + _("""
+ Add a new token:
+ ipa otptoken-add --type=totp --owner=jdoe --desc="My soft token"
+""") + _("""
+ Examine the token:
+ ipa otptoken-show a93db710-a31a-4639-8647-f15b2c70b78a
+""") + _("""
+ Change the vendor:
+ ipa otptoken-mod a93db710-a31a-4639-8647-f15b2c70b78a --vendor="Red Hat"
+""") + _("""
+ Delete a token:
+ ipa otptoken-del a93db710-a31a-4639-8647-f15b2c70b78a
+""")
+
+register = Registry()
+
+topic = 'otp'
+
+TOKEN_TYPES = {
+ u'totp': ['ipatokentotpclockoffset', 'ipatokentotptimestep'],
+ u'hotp': ['ipatokenhotpcounter']
+}
+
+# NOTE: For maximum compatibility, KEY_LENGTH % 5 == 0
+KEY_LENGTH = 20
+
+class OTPTokenKey(Bytes):
+ """A binary password type specified in base32."""
+
+ password = True
+
+ kwargs = Bytes.kwargs + (
+ ('confirm', bool, True),
+ )
+
+ def _convert_scalar(self, value, index=None):
+ if isinstance(value, (tuple, list)) and len(value) == 2:
+ (p1, p2) = value
+ if p1 != p2:
+ raise PasswordMismatch(name=self.name)
+ value = p1
+
+ if isinstance(value, unicode):
+ try:
+ value = base64.b32decode(value, True)
+ except TypeError as e:
+ raise ConversionError(name=self.name, error=str(e))
+
+ return super(OTPTokenKey, self)._convert_scalar(value)
+
+def _convert_owner(userobj, entry_attrs, options):
+ if 'ipatokenowner' in entry_attrs and not options.get('raw', False):
+ entry_attrs['ipatokenowner'] = [userobj.get_primary_key_from_dn(o)
+ for o in entry_attrs['ipatokenowner']]
+
+def _normalize_owner(userobj, entry_attrs):
+ owner = entry_attrs.get('ipatokenowner', None)
+ if owner:
+ try:
+ entry_attrs['ipatokenowner'] = userobj._normalize_manager(owner)[0]
+ except NotFound:
+ userobj.handle_not_found(owner)
+
+def _check_interval(not_before, not_after):
+ if not_before and not_after:
+ return not_before <= not_after
+ return True
+
+def _set_token_type(entry_attrs, **options):
+ klasses = [x.lower() for x in entry_attrs.get('objectclass', [])]
+ for ttype in TOKEN_TYPES.keys():
+ cls = 'ipatoken' + ttype
+ if cls.lower() in klasses:
+ entry_attrs['type'] = ttype.upper()
+
+ if not options.get('all', False) or options.get('pkey_only', False):
+ entry_attrs.pop('objectclass', None)
+
+@register()
+class otptoken(LDAPObject):
+ """
+ OTP Token object.
+ """
+ container_dn = api.env.container_otp
+ object_name = _('OTP token')
+ object_name_plural = _('OTP tokens')
+ object_class = ['ipatoken']
+ possible_objectclasses = ['ipatokentotp', 'ipatokenhotp']
+ default_attributes = [
+ 'ipatokenuniqueid', 'description', 'ipatokenowner',
+ 'ipatokendisabled', 'ipatokennotbefore', 'ipatokennotafter',
+ 'ipatokenvendor', 'ipatokenmodel', 'ipatokenserial', 'managedby'
+ ]
+ attribute_members = {
+ 'managedby': ['user'],
+ }
+ relationships = {
+ 'managedby': ('Managed by', 'man_by_', 'not_man_by_'),
+ }
+ rdn_is_primary_key = True
+
+ label = _('OTP Tokens')
+ label_singular = _('OTP Token')
+
+ takes_params = (
+ Str('ipatokenuniqueid',
+ cli_name='id',
+ label=_('Unique ID'),
+ primary_key=True,
+ flags=('optional_create'),
+ ),
+ StrEnum('type?',
+ label=_('Type'),
+ doc=_('Type of the token'),
+ default=u'totp',
+ autofill=True,
+ values=tuple(list(TOKEN_TYPES) + [x.upper() for x in TOKEN_TYPES]),
+ flags=('virtual_attribute', 'no_update'),
+ ),
+ Str('description?',
+ cli_name='desc',
+ label=_('Description'),
+ doc=_('Token description (informational only)'),
+ ),
+ Str('ipatokenowner?',
+ cli_name='owner',
+ label=_('Owner'),
+ doc=_('Assigned user of the token (default: self)'),
+ ),
+ Str('managedby_user?',
+ label=_('Manager'),
+ doc=_('Assigned manager of the token (default: self)'),
+ flags=['no_create', 'no_update', 'no_search'],
+ ),
+ Bool('ipatokendisabled?',
+ cli_name='disabled',
+ label=_('Disabled'),
+ doc=_('Mark the token as disabled (default: false)')
+ ),
+ DateTime('ipatokennotbefore?',
+ cli_name='not_before',
+ label=_('Validity start'),
+ doc=_('First date/time the token can be used'),
+ ),
+ DateTime('ipatokennotafter?',
+ cli_name='not_after',
+ label=_('Validity end'),
+ doc=_('Last date/time the token can be used'),
+ ),
+ Str('ipatokenvendor?',
+ cli_name='vendor',
+ label=_('Vendor'),
+ doc=_('Token vendor name (informational only)'),
+ ),
+ Str('ipatokenmodel?',
+ cli_name='model',
+ label=_('Model'),
+ doc=_('Token model (informational only)'),
+ ),
+ Str('ipatokenserial?',
+ cli_name='serial',
+ label=_('Serial'),
+ doc=_('Token serial (informational only)'),
+ ),
+ OTPTokenKey('ipatokenotpkey?',
+ cli_name='key',
+ label=_('Key'),
+ doc=_('Token secret (Base32; default: random)'),
+ default_from=lambda: os.urandom(KEY_LENGTH),
+ autofill=True,
+ flags=('no_display', 'no_update', 'no_search'),
+ ),
+ StrEnum('ipatokenotpalgorithm?',
+ cli_name='algo',
+ label=_('Algorithm'),
+ doc=_('Token hash algorithm'),
+ default=u'sha1',
+ autofill=True,
+ flags=('no_update'),
+ values=(u'sha1', u'sha256', u'sha384', u'sha512'),
+ ),
+ IntEnum('ipatokenotpdigits?',
+ cli_name='digits',
+ label=_('Digits'),
+ doc=_('Number of digits each token code will have'),
+ values=(6, 8),
+ default=6,
+ autofill=True,
+ flags=('no_update'),
+ ),
+ Int('ipatokentotpclockoffset?',
+ cli_name='offset',
+ label=_('Clock offset'),
+ doc=_('TOTP token / FreeIPA server time difference'),
+ default=0,
+ autofill=True,
+ flags=('no_update'),
+ ),
+ Int('ipatokentotptimestep?',
+ cli_name='interval',
+ label=_('Clock interval'),
+ doc=_('Length of TOTP token code validity'),
+ default=30,
+ autofill=True,
+ minvalue=5,
+ flags=('no_update'),
+ ),
+ Int('ipatokenhotpcounter?',
+ cli_name='counter',
+ label=_('Counter'),
+ doc=_('Initial counter for the HOTP token'),
+ default=0,
+ autofill=True,
+ minvalue=0,
+ flags=('no_update'),
+ ),
+ )
+
+
+@register()
+class otptoken_add(LDAPCreate):
+ __doc__ = _('Add a new OTP token.')
+ msg_summary = _('Added OTP token "%(value)s"')
+
+ takes_options = LDAPCreate.takes_options + (
+ Flag('qrcode?', label=_('(deprecated)'), flags=('no_option')),
+ Flag('no_qrcode', label=_('Do not display QR code'), default=False),
+ )
+
+ has_output_params = LDAPCreate.has_output_params + (
+ Str('uri?', label=_('URI')),
+ )
+
+ def execute(self, ipatokenuniqueid=None, **options):
+ return super(otptoken_add, self).execute(ipatokenuniqueid, **options)
+
+ def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options):
+ # Fill in a default UUID when not specified.
+ if entry_attrs.get('ipatokenuniqueid', None) is None:
+ entry_attrs['ipatokenuniqueid'] = str(uuid.uuid4())
+ dn = DN("ipatokenuniqueid=%s" % entry_attrs['ipatokenuniqueid'], dn)
+
+ if not _check_interval(options.get('ipatokennotbefore', None),
+ options.get('ipatokennotafter', None)):
+ raise ValidationError(name='not_after',
+ error='is before the validity start')
+
+ # Set the object class and defaults for specific token types
+ options['type'] = options['type'].lower()
+ entry_attrs['objectclass'] = otptoken.object_class + ['ipatoken' + options['type']]
+ for ttype, tattrs in TOKEN_TYPES.items():
+ if ttype != options['type']:
+ for tattr in tattrs:
+ if tattr in entry_attrs:
+ del entry_attrs[tattr]
+
+ # If owner was not specified, default to the person adding this token.
+ # If managedby was not specified, attempt a sensible default.
+ if 'ipatokenowner' not in entry_attrs or 'managedby' not in entry_attrs:
+ result = self.api.Command.user_find(
+ whoami=True, no_members=False)['result']
+ if result:
+ cur_uid = result[0]['uid'][0]
+ prev_uid = entry_attrs.setdefault('ipatokenowner', cur_uid)
+ if cur_uid == prev_uid:
+ entry_attrs.setdefault('managedby', result[0]['dn'])
+
+ # Resolve the owner's dn
+ _normalize_owner(self.api.Object.user, entry_attrs)
+
+ # Get the issuer for the URI
+ owner = entry_attrs.get('ipatokenowner', None)
+ issuer = api.env.realm
+ if owner is not None:
+ try:
+ issuer = ldap.get_entry(owner, ['krbprincipalname'])['krbprincipalname'][0]
+ except (NotFound, IndexError):
+ pass
+
+ # Build the URI parameters
+ args = {}
+ args['issuer'] = issuer
+ args['secret'] = base64.b32encode(entry_attrs['ipatokenotpkey'])
+ args['digits'] = entry_attrs['ipatokenotpdigits']
+ args['algorithm'] = entry_attrs['ipatokenotpalgorithm'].upper()
+ if options['type'] == 'totp':
+ args['period'] = entry_attrs['ipatokentotptimestep']
+ elif options['type'] == 'hotp':
+ args['counter'] = entry_attrs['ipatokenhotpcounter']
+
+ # Build the URI
+ label = urllib.parse.quote(entry_attrs['ipatokenuniqueid'])
+ parameters = urllib.parse.urlencode(args)
+ uri = u'otpauth://%s/%s:%s?%s' % (options['type'], issuer, label, parameters)
+ setattr(context, 'uri', uri)
+
+ attrs_list.append("objectclass")
+ return dn
+
+ def post_callback(self, ldap, dn, entry_attrs, *keys, **options):
+ entry_attrs['uri'] = getattr(context, 'uri')
+ _set_token_type(entry_attrs, **options)
+ _convert_owner(self.api.Object.user, entry_attrs, options)
+ return super(otptoken_add, self).post_callback(ldap, dn, entry_attrs, *keys, **options)
+
+
+@register()
+class otptoken_del(LDAPDelete):
+ __doc__ = _('Delete an OTP token.')
+ msg_summary = _('Deleted OTP token "%(value)s"')
+
+
+@register()
+class otptoken_mod(LDAPUpdate):
+ __doc__ = _('Modify a OTP token.')
+ msg_summary = _('Modified OTP token "%(value)s"')
+
+ def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options):
+ notafter_set = True
+ notbefore = options.get('ipatokennotbefore', None)
+ notafter = options.get('ipatokennotafter', None)
+ # notbefore xor notafter, exactly one of them is not None
+ if bool(notbefore) ^ bool(notafter):
+ result = self.api.Command.otptoken_show(keys[-1])['result']
+ if notbefore is None:
+ notbefore = result.get('ipatokennotbefore', [None])[0]
+ if notafter is None:
+ notafter_set = False
+ notafter = result.get('ipatokennotafter', [None])[0]
+
+ if not _check_interval(notbefore, notafter):
+ if notafter_set:
+ raise ValidationError(name='not_after',
+ error='is before the validity start')
+ else:
+ raise ValidationError(name='not_before',
+ error='is after the validity end')
+ _normalize_owner(self.api.Object.user, entry_attrs)
+
+ # ticket #4681: if the owner of the token is changed and the
+ # user also manages this token, then we should automatically
+ # set the 'managedby' attribute to the new owner
+ if 'ipatokenowner' in entry_attrs and 'managedby' not in entry_attrs:
+ new_owner = entry_attrs.get('ipatokenowner', None)
+ prev_entry = ldap.get_entry(dn, attrs_list=['ipatokenowner',
+ 'managedby'])
+ prev_owner = prev_entry.get('ipatokenowner', None)
+ prev_managedby = prev_entry.get('managedby', None)
+
+ if (new_owner != prev_owner) and (prev_owner == prev_managedby):
+ entry_attrs.setdefault('managedby', new_owner)
+
+ attrs_list.append("objectclass")
+ return dn
+
+ def post_callback(self, ldap, dn, entry_attrs, *keys, **options):
+ _set_token_type(entry_attrs, **options)
+ _convert_owner(self.api.Object.user, entry_attrs, options)
+ return super(otptoken_mod, self).post_callback(ldap, dn, entry_attrs, *keys, **options)
+
+
+@register()
+class otptoken_find(LDAPSearch):
+ __doc__ = _('Search for OTP token.')
+ msg_summary = ngettext('%(count)d OTP token matched', '%(count)d OTP tokens matched', 0)
+
+ def pre_callback(self, ldap, filters, attrs_list, *args, **kwargs):
+ # This is a hack, but there is no other way to
+ # replace the objectClass when searching
+ type = kwargs.get('type', '')
+ if type not in TOKEN_TYPES:
+ type = ''
+ filters = filters.replace("(objectclass=ipatoken)",
+ "(objectclass=ipatoken%s)" % type)
+
+ attrs_list.append("objectclass")
+ return super(otptoken_find, self).pre_callback(ldap, filters, attrs_list, *args, **kwargs)
+
+ def args_options_2_entry(self, *args, **options):
+ entry = super(otptoken_find, self).args_options_2_entry(*args, **options)
+ _normalize_owner(self.api.Object.user, entry)
+ return entry
+
+ def post_callback(self, ldap, entries, truncated, *args, **options):
+ for entry in entries:
+ _set_token_type(entry, **options)
+ _convert_owner(self.api.Object.user, entry, options)
+ return super(otptoken_find, self).post_callback(ldap, entries, truncated, *args, **options)
+
+
+@register()
+class otptoken_show(LDAPRetrieve):
+ __doc__ = _('Display information about an OTP token.')
+
+ def pre_callback(self, ldap, dn, attrs_list, *keys, **options):
+ attrs_list.append("objectclass")
+ return super(otptoken_show, self).pre_callback(ldap, dn, attrs_list, *keys, **options)
+
+ def post_callback(self, ldap, dn, entry_attrs, *keys, **options):
+ _set_token_type(entry_attrs, **options)
+ _convert_owner(self.api.Object.user, entry_attrs, options)
+ return super(otptoken_show, self).post_callback(ldap, dn, entry_attrs, *keys, **options)
+
+@register()
+class otptoken_add_managedby(LDAPAddMember):
+ __doc__ = _('Add users that can manage this token.')
+
+ member_attributes = ['managedby']
+
+@register()
+class otptoken_remove_managedby(LDAPRemoveMember):
+ __doc__ = _('Remove users that can manage this token.')
+
+ member_attributes = ['managedby']
diff --git a/ipaserver/plugins/passwd.py b/ipaserver/plugins/passwd.py
new file mode 100644
index 000000000..c4e220815
--- /dev/null
+++ b/ipaserver/plugins/passwd.py
@@ -0,0 +1,139 @@
+# Authors:
+# Rob Crittenden <rcritten@redhat.com>
+#
+# Copyright (C) 2008 Red Hat
+# see file 'COPYING' for use and warranty information
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+from ipalib import api, errors, krb_utils
+from ipalib import Command
+from ipalib import Str, Password
+from ipalib import _
+from ipalib import output
+from ipalib.plugable import Registry
+from .baseuser import validate_principal, normalize_principal
+from ipalib.request import context
+from ipapython.dn import DN
+
+__doc__ = _("""
+Set a user's password
+
+If someone other than a user changes that user's password (e.g., Helpdesk
+resets it) then the password will need to be changed the first time it
+is used. This is so the end-user is the only one who knows the password.
+
+The IPA password policy controls how often a password may be changed,
+what strength requirements exist, and the length of the password history.
+
+EXAMPLES:
+
+ To reset your own password:
+ ipa passwd
+
+ To change another user's password:
+ ipa passwd tuser1
+""")
+
+register = Registry()
+
+# We only need to prompt for the current password when changing a password
+# for yourself, but the parameter is still required
+MAGIC_VALUE = u'CHANGING_PASSWORD_FOR_ANOTHER_USER'
+
+def get_current_password(principal):
+ """
+ If the user is changing their own password then return None so the
+ current password is prompted for, otherwise return a fixed value to
+ be ignored later.
+ """
+ current_principal = krb_utils.get_principal()
+ if current_principal == normalize_principal(principal):
+ return None
+ else:
+ return MAGIC_VALUE
+
+@register()
+class passwd(Command):
+ __doc__ = _("Set a user's password.")
+
+ takes_args = (
+ Str('principal', validate_principal,
+ cli_name='user',
+ label=_('User name'),
+ primary_key=True,
+ autofill=True,
+ default_from=lambda: krb_utils.get_principal(),
+ normalizer=lambda value: normalize_principal(value),
+ ),
+ Password('password',
+ label=_('New Password'),
+ ),
+ Password('current_password',
+ label=_('Current Password'),
+ confirm=False,
+ default_from=lambda principal: get_current_password(principal),
+ autofill=True,
+ sortorder=-1,
+ ),
+ )
+
+ takes_options = (
+ Password('otp?',
+ label=_('OTP'),
+ doc=_('One Time Password'),
+ confirm=False,
+ ),
+ )
+
+ has_output = output.standard_value
+ msg_summary = _('Changed password for "%(value)s"')
+
+ def execute(self, principal, password, current_password, **options):
+ """
+ Execute the passwd operation.
+
+ The dn should not be passed as a keyword argument as it is constructed
+ by this method.
+
+ Returns the entry
+
+ :param principal: The login name or principal of the user
+ :param password: the new password
+ :param current_password: the existing password, if applicable
+ """
+ ldap = self.api.Backend.ldap2
+
+ entry_attrs = ldap.find_entry_by_attr(
+ 'krbprincipalname', principal, 'posixaccount', [''],
+ DN(api.env.container_user, api.env.basedn)
+ )
+
+ if principal == getattr(context, 'principal') and \
+ current_password == MAGIC_VALUE:
+ # No cheating
+ self.log.warning('User attempted to change password using magic value')
+ raise errors.ACIError(info=_('Invalid credentials'))
+
+ if current_password == MAGIC_VALUE:
+ ldap.modify_password(entry_attrs.dn, password)
+ else:
+ otp = options.get('otp')
+ ldap.modify_password(entry_attrs.dn, password, current_password, otp)
+
+ return dict(
+ result=True,
+ value=principal,
+ )
+
diff --git a/ipaserver/plugins/permission.py b/ipaserver/plugins/permission.py
new file mode 100644
index 000000000..9f19358da
--- /dev/null
+++ b/ipaserver/plugins/permission.py
@@ -0,0 +1,1395 @@
+# Authors:
+# Petr Viktorin <pviktori@redhat.com>
+#
+# Copyright (C) 2013 Red Hat
+# see file 'COPYING' for use and warranty information
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+import re
+import traceback
+
+import six
+
+from . import baseldap
+from .privilege import validate_permission_to_privilege
+from ipalib import errors
+from ipalib.parameters import Str, StrEnum, DNParam, Flag
+from ipalib import api, _, ngettext
+from ipalib.plugable import Registry
+from ipalib.capabilities import client_has_capability
+from ipalib.aci import ACI
+from ipapython.dn import DN
+from ipalib.request import context
+
+if six.PY3:
+ unicode = str
+
+__doc__ = _("""
+Permissions
+""") + _("""
+A permission enables fine-grained delegation of rights. A permission is
+a human-readable wrapper around a 389-ds Access Control Rule,
+or instruction (ACI).
+A permission grants the right to perform a specific task such as adding a
+user, modifying a group, etc.
+""") + _("""
+A permission may not contain other permissions.
+""") + _("""
+* A permission grants access to read, write, add, delete, read, search,
+ or compare.
+* A privilege combines similar permissions (for example all the permissions
+ needed to add a user).
+* A role grants a set of privileges to users, groups, hosts or hostgroups.
+""") + _("""
+A permission is made up of a number of different parts:
+
+1. The name of the permission.
+2. The target of the permission.
+3. The rights granted by the permission.
+""") + _("""
+Rights define what operations are allowed, and may be one or more
+of the following:
+1. write - write one or more attributes
+2. read - read one or more attributes
+3. search - search on one or more attributes
+4. compare - compare one or more attributes
+5. add - add a new entry to the tree
+6. delete - delete an existing entry
+7. all - all permissions are granted
+""") + _("""
+Note the distinction between attributes and entries. The permissions are
+independent, so being able to add a user does not mean that the user will
+be editable.
+""") + _("""
+There are a number of allowed targets:
+1. subtree: a DN; the permission applies to the subtree under this DN
+2. target filter: an LDAP filter
+3. target: DN with possible wildcards, specifies entries permission applies to
+""") + _("""
+Additionally, there are the following convenience options.
+Setting one of these options will set the corresponding attribute(s).
+1. type: a type of object (user, group, etc); sets subtree and target filter.
+2. memberof: apply to members of a group; sets target filter
+3. targetgroup: grant access to modify a specific group (such as granting
+ the rights to manage group membership); sets target.
+""") + _("""
+Managed permissions
+""") + _("""
+Permissions that come with IPA by default can be so-called "managed"
+permissions. These have a default set of attributes they apply to,
+but the administrator can add/remove individual attributes to/from the set.
+""") + _("""
+Deleting or renaming a managed permission, as well as changing its target,
+is not allowed.
+""") + _("""
+EXAMPLES:
+""") + _("""
+ Add a permission that grants the creation of users:
+ ipa permission-add --type=user --permissions=add "Add Users"
+""") + _("""
+ Add a permission that grants the ability to manage group membership:
+ ipa permission-add --attrs=member --permissions=write --type=group "Manage Group Members"
+""")
+
+register = Registry()
+
+_DEPRECATED_OPTION_ALIASES = {
+ 'permissions': 'ipapermright',
+ 'filter': 'extratargetfilter',
+ 'subtree': 'ipapermlocation',
+}
+
+KNOWN_FLAGS = {'SYSTEM', 'V2', 'MANAGED'}
+
+output_params = (
+ Str('aci',
+ label=_('ACI'),
+ ),
+)
+
+
+def strip_ldap_prefix(uri):
+ prefix = 'ldap:///'
+ if not uri.startswith(prefix):
+ raise ValueError('%r does not start with %r' % (uri, prefix))
+ return uri[len(prefix):]
+
+
+def prevalidate_filter(ugettext, value):
+ if not value.startswith('(') or not value.endswith(')'):
+ return _('must be enclosed in parentheses')
+
+
+class DNOrURL(DNParam):
+ """DN parameter that allows, and strips, a "ldap:///" prefix on input
+
+ Used for ``subtree`` to maintain backward compatibility.
+ """
+
+ def _convert_scalar(self, value, index=None):
+ if isinstance(value, six.string_types) and value.startswith('ldap:///'):
+ value = strip_ldap_prefix(value)
+ return super(DNOrURL, self)._convert_scalar(value)
+
+
+def validate_type(ugettext, typestr):
+ try:
+ obj = api.Object[typestr]
+ except KeyError:
+ return _('"%s" is not an object type') % typestr
+ if not getattr(obj, 'permission_filter_objectclasses', None):
+ return _('"%s" is not a valid permission type') % typestr
+
+
+def _disallow_colon(option):
+ """Given a "cn" option, return a new "cn" option with ':' disallowed
+
+ Used in permission-add and for --rename in permission-mod to prevent user
+ from creating new permissions with ":" in the name.
+ """
+ return option.clone(
+ pattern='^[-_ a-zA-Z0-9.]+$',
+ pattern_errmsg="May only contain letters, numbers, -, _, ., and space",
+ )
+
+
+@register()
+class permission(baseldap.LDAPObject):
+ """
+ Permission object.
+ """
+ container_dn = api.env.container_permission
+ object_name = _('permission')
+ object_name_plural = _('permissions')
+ # For use the complete object_class list, including 'top', so
+ # the updater doesn't try to delete 'top' every time.
+ object_class = ['top', 'groupofnames', 'ipapermission', 'ipapermissionv2']
+ permission_filter_objectclasses = ['ipapermission']
+ default_attributes = ['cn', 'member', 'memberof',
+ 'memberindirect', 'ipapermissiontype', 'objectclass',
+ 'ipapermdefaultattr', 'ipapermincludedattr', 'ipapermexcludedattr',
+ 'ipapermbindruletype', 'ipapermlocation', 'ipapermright',
+ 'ipapermtargetfilter', 'ipapermtarget'
+ ]
+ attribute_members = {
+ 'member': ['privilege'],
+ 'memberindirect': ['role'],
+ }
+ rdn_is_primary_key = True
+ managed_permissions = {
+ 'System: Read Permissions': {
+ 'replaces_global_anonymous_aci': True,
+ 'ipapermright': {'read', 'search', 'compare'},
+ 'ipapermdefaultattr': {
+ 'businesscategory', 'cn', 'description', 'ipapermissiontype',
+ 'o', 'objectclass', 'ou', 'owner', 'seealso',
+ 'ipapermdefaultattr', 'ipapermincludedattr',
+ 'ipapermexcludedattr', 'ipapermbindruletype', 'ipapermtarget',
+ 'ipapermlocation', 'ipapermright', 'ipapermtargetfilter',
+ 'member', 'memberof', 'memberuser', 'memberhost',
+ },
+ 'default_privileges': {'RBAC Readers'},
+ },
+ 'System: Read ACIs': {
+ # Readable ACIs are needed for reading legacy permissions.
+ 'non_object': True,
+ 'ipapermlocation': api.env.basedn,
+ 'replaces_global_anonymous_aci': True,
+ 'ipapermright': {'read', 'search', 'compare'},
+ 'ipapermdefaultattr': {'aci'},
+ 'default_privileges': {'RBAC Readers'},
+ },
+ 'System: Modify Privilege Membership': {
+ 'ipapermright': {'write'},
+ 'ipapermdefaultattr': {'member'},
+ 'replaces': [
+ '(targetattr = "member")(target = "ldap:///cn=*,cn=permissions,cn=pbac,$SUFFIX")(version 3.0;acl "permission:Modify privilege membership";allow (write) groupdn = "ldap:///cn=Modify privilege membership,cn=permissions,cn=pbac,$SUFFIX";)',
+ ],
+ 'default_privileges': {'Delegation Administrator'},
+ },
+ }
+
+ label = _('Permissions')
+ label_singular = _('Permission')
+
+ takes_params = (
+ Str('cn',
+ cli_name='name',
+ label=_('Permission name'),
+ primary_key=True,
+ pattern='^[-_ a-zA-Z0-9.:/]+$',
+ pattern_errmsg="May only contain letters, numbers, "
+ "-, _, ., :, /, and space",
+ ),
+ StrEnum(
+ 'ipapermright*',
+ cli_name='right',
+ deprecated_cli_aliases={'permissions'},
+ label=_('Granted rights'),
+ doc=_('Rights to grant '
+ '(read, search, compare, write, add, delete, all)'),
+ values=(u'read', u'search', u'compare',
+ u'write', u'add', u'delete', u'all'),
+ flags={'ask_create'},
+ ),
+ Str('attrs*',
+ label=_('Effective attributes'),
+ doc=_('All attributes to which the permission applies'),
+ flags={'virtual_attribute', 'allow_mod_for_managed_permission'},
+ ),
+ Str('ipapermincludedattr*',
+ cli_name='includedattrs',
+ label=_('Included attributes'),
+ doc=_('User-specified attributes to which the permission applies'),
+ flags={'no_create', 'allow_mod_for_managed_permission'},
+ ),
+ Str('ipapermexcludedattr*',
+ cli_name='excludedattrs',
+ label=_('Excluded attributes'),
+ doc=_('User-specified attributes to which the permission '
+ 'explicitly does not apply'),
+ flags={'no_create', 'allow_mod_for_managed_permission'},
+ ),
+ Str('ipapermdefaultattr*',
+ cli_name='defaultattrs',
+ label=_('Default attributes'),
+ doc=_('Attributes to which the permission applies by default'),
+ flags={'no_create', 'no_update'},
+ ),
+ StrEnum(
+ 'ipapermbindruletype',
+ cli_name='bindtype',
+ label=_('Bind rule type'),
+ doc=_('Bind rule type'),
+ autofill=True,
+ values=(u'permission', u'all', u'anonymous'),
+ default=u'permission',
+ flags={'allow_mod_for_managed_permission'},
+ ),
+ DNOrURL(
+ 'ipapermlocation?',
+ cli_name='subtree',
+ label=_('Subtree'),
+ doc=_('Subtree to apply permissions to'),
+ flags={'ask_create'},
+ ),
+ Str(
+ 'extratargetfilter*', prevalidate_filter,
+ cli_name='filter',
+ label=_('Extra target filter'),
+ doc=_('Extra target filter'),
+ flags={'virtual_attribute'},
+ ),
+ Str(
+ 'ipapermtargetfilter*', prevalidate_filter,
+ cli_name='rawfilter',
+ label=_('Raw target filter'),
+ doc=_('All target filters, including those implied by '
+ 'type and memberof'),
+ ),
+
+ DNParam(
+ 'ipapermtarget?',
+ cli_name='target',
+ label=_('Target DN'),
+ doc=_('Optional DN to apply the permission to '
+ '(must be in the subtree, but may not yet exist)'),
+ ),
+
+ DNParam(
+ 'ipapermtargetto?',
+ cli_name='targetto',
+ label=_('Target DN subtree'),
+ doc=_('Optional DN subtree where an entry can be moved to '
+ '(must be in the subtree, but may not yet exist)'),
+ ),
+
+ DNParam(
+ 'ipapermtargetfrom?',
+ cli_name='targetfrom',
+ label=_('Origin DN subtree'),
+ doc=_('Optional DN subtree from where an entry can be moved '
+ '(must be in the subtree, but may not yet exist)'),
+ ),
+
+ Str('memberof*',
+ label=_('Member of group'), # FIXME: Does this label make sense?
+ doc=_('Target members of a group (sets memberOf targetfilter)'),
+ flags={'ask_create', 'virtual_attribute'},
+ ),
+ Str('targetgroup?',
+ label=_('Target group'),
+ doc=_('User group to apply permissions to (sets target)'),
+ flags={'ask_create', 'virtual_attribute'},
+ ),
+ Str(
+ 'type?', validate_type,
+ label=_('Type'),
+ doc=_('Type of IPA object '
+ '(sets subtree and objectClass targetfilter)'),
+ flags={'ask_create', 'virtual_attribute'},
+ ),
+ ) + tuple(
+ Str(old_name + '*',
+ doc=_('Deprecated; use %s' % new_name),
+ flags={'no_option', 'virtual_attribute'})
+ for old_name, new_name in _DEPRECATED_OPTION_ALIASES.items()
+ )
+
+ def reject_system(self, entry):
+ """Raise if permission entry has unknown flags, or is a SYSTEM perm"""
+ flags = entry.get('ipapermissiontype', [])
+ for flag in flags:
+ if flag not in KNOWN_FLAGS:
+ raise errors.ACIError(
+ info=_('Permission with unknown flag %s may not be '
+ 'modified or removed') % flag)
+ if list(flags) == [u'SYSTEM']:
+ raise errors.ACIError(
+ info=_('A SYSTEM permission may not be modified or removed'))
+
+ def _get_filter_attr_info(self, entry):
+ """Get information on filter-related virtual attributes
+
+ Returns a dict with this information:
+ 'implicit_targetfilters': targetfilters implied by memberof and type
+ 'memberof': list of names of groups from memberof
+ 'type': the type
+ """
+ ipapermtargetfilter = entry.get('ipapermtargetfilter', [])
+ ipapermlocation = entry.single_value.get('ipapermlocation')
+
+ implicit_targetfilters = set()
+ result = {'implicit_targetfilters': implicit_targetfilters}
+
+ # memberof
+ memberof = []
+ for targetfilter in ipapermtargetfilter:
+ match = re.match('^\(memberof=(.*)\)$', targetfilter, re.I)
+ if match:
+ try:
+ dn = DN(match.group(1))
+ except ValueError:
+ # Malformed DN; e.g. (memberof=*)
+ continue
+ groups_dn = DN(self.api.Object.group.container_dn,
+ self.api.env.basedn)
+ if dn[1:] == groups_dn[:] and dn[0].attr == 'cn':
+ memberof.append(dn[0].value)
+ implicit_targetfilters.add(match.group(0))
+ if memberof:
+ result['memberof'] = memberof
+
+ # type
+ if ipapermtargetfilter and ipapermlocation:
+ for obj in self.api.Object():
+ filt = self.make_type_filter(obj)
+ if not filt:
+ continue
+
+ wantdn = DN(obj.container_dn, self.api.env.basedn)
+ if DN(ipapermlocation) != wantdn:
+ continue
+
+ if filt in ipapermtargetfilter:
+ result['type'] = [unicode(obj.name)]
+ implicit_targetfilters.add(filt)
+ break
+
+ return result
+
+ def postprocess_result(self, entry, options):
+ """Update a permission entry for output (in place)
+
+ :param entry: The entry to update
+ :param options:
+ Command options. Contains keys such as ``raw``, ``all``,
+ ``pkey_only``, ``version``.
+ """
+ old_client = not client_has_capability(
+ options['version'], 'permissions2')
+
+ if not options.get('raw') and not options.get('pkey_only'):
+ ipapermtargetfilter = entry.get('ipapermtargetfilter', [])
+ ipapermtarget = entry.single_value.get('ipapermtarget')
+
+ # targetgroup
+ if ipapermtarget:
+ dn = DN(ipapermtarget)
+ if (dn[1:] == DN(self.api.Object.group.container_dn,
+ self.api.env.basedn)[:] and
+ dn[0].attr == 'cn' and dn[0].value != '*'):
+ entry.single_value['targetgroup'] = dn[0].value
+
+ filter_attr_info = self._get_filter_attr_info(entry)
+ if 'type' in filter_attr_info:
+ entry['type'] = filter_attr_info['type']
+ if 'memberof' in filter_attr_info:
+ entry['memberof'] = filter_attr_info['memberof']
+ if 'implicit_targetfilters' in filter_attr_info:
+ extratargetfilter = sorted(
+ set(ipapermtargetfilter) -
+ filter_attr_info['implicit_targetfilters'])
+ if extratargetfilter:
+ entry['extratargetfilter'] = extratargetfilter
+
+ # old output names
+ if old_client:
+ for old_name, new_name in _DEPRECATED_OPTION_ALIASES.items():
+ if new_name in entry:
+ entry[old_name] = entry[new_name]
+ del entry[new_name]
+
+ rights = entry.get('attributelevelrights')
+ if rights:
+ if 'ipapermtarget' in rights:
+ rights['targetgroup'] = rights['ipapermtarget']
+ if 'ipapermtargetfilter' in rights:
+ rights['memberof'] = rights['ipapermtargetfilter']
+
+ type_rights = set(rights['ipapermtargetfilter'])
+ location_rights = set(rights.get('ipapermlocation', ''))
+ type_rights.intersection_update(location_rights)
+ rights['type'] = ''.join(sorted(
+ type_rights, key=rights['ipapermtargetfilter'].index))
+
+ if 'ipapermincludedattr' in rights:
+ rights['attrs'] = ''.join(sorted(
+ set(rights['ipapermincludedattr']) &
+ set(rights.get('ipapermexcludedattr', '')),
+ key=rights['ipapermincludedattr'].index))
+
+ if old_client:
+ for old_name, new_name in _DEPRECATED_OPTION_ALIASES.items():
+ if new_name in entry:
+ rights[old_name] = rights[new_name]
+ del rights[new_name]
+
+ if options.get('raw'):
+ # Retreive the ACI from LDAP to ensure we get the real thing
+ try:
+ acientry, acistring = self._get_aci_entry_and_string(entry)
+ except errors.NotFound:
+ if list(entry.get('ipapermissiontype')) == ['SYSTEM']:
+ # SYSTEM permissions don't have normal ACIs
+ pass
+ else:
+ raise
+ else:
+ entry.single_value['aci'] = acistring
+ else:
+ effective_attrs = self.get_effective_attrs(entry)
+ if effective_attrs:
+ entry['attrs'] = effective_attrs
+ if (not options.get('all') and
+ not entry.get('ipapermexcludedattr') and
+ not entry.get('ipapermdefaultattr')):
+ entry.pop('ipapermincludedattr', None)
+
+ if old_client:
+ # Legacy clients expect some attributes as a single value
+ for attr in 'type', 'targetgroup', 'aci':
+ if attr in entry:
+ entry[attr] = entry.single_value[attr]
+ # memberof was also single-valued, but not any more
+ if entry.get('memberof'):
+ joined_value = u', '.join(str(m) for m in entry['memberof'])
+ entry['memberof'] = joined_value
+ if 'subtree' in entry:
+ # Legacy clients expect subtree as a URL
+ dn = entry.single_value['subtree']
+ entry['subtree'] = u'ldap:///%s' % dn
+ if 'filter' in entry:
+ # Legacy clients expect filter without parentheses
+ new_filter = []
+ for flt in entry['filter']:
+ assert flt[0] == '(' and flt[-1] == ')'
+ new_filter.append(flt[1:-1])
+ entry['filter'] = new_filter
+
+ if not options['raw'] and not options['all']:
+ # Don't return the raw target filter by default
+ entry.pop('ipapermtargetfilter', None)
+
+ def get_effective_attrs(self, entry):
+ attrs = set(entry.get('ipapermdefaultattr', ()))
+ attrs.update(entry.get('ipapermincludedattr', ()))
+ if ('read' in entry.get('ipapermright', ()) and
+ 'objectclass' in (x.lower() for x in attrs)):
+ # Add special-cased operational attributes
+ # We want to allow reading these whenever reading the objectclass
+ # is allowed.
+ # (But they can still be excluded explicitly, at least in managed
+ # permissions).
+ attrs.update((u'entryusn', u'createtimestamp', u'modifytimestamp'))
+ attrs.difference_update(entry.get('ipapermexcludedattr', ()))
+ return sorted(attrs)
+
+ def make_aci(self, entry):
+ """Make an ACI string from the given permission entry"""
+
+ aci_parts = []
+ name = entry.single_value['cn']
+
+ # targetattr
+ attrs = self.get_effective_attrs(entry)
+ if attrs:
+ aci_parts.append("(targetattr = \"%s\")" % ' || '.join(attrs))
+
+ # target
+ ipapermtarget = entry.single_value.get('ipapermtarget')
+ if ipapermtarget:
+ aci_parts.append("(target = \"%s\")" %
+ 'ldap:///%s' % ipapermtarget)
+
+ # target_to
+ ipapermtargetto = entry.single_value.get('ipapermtargetto')
+ if ipapermtargetto:
+ aci_parts.append("(target_to = \"%s\")" %
+ 'ldap:///%s' % ipapermtargetto)
+
+ # target_from
+ ipapermtargetfrom = entry.single_value.get('ipapermtargetfrom')
+ if ipapermtargetfrom:
+ aci_parts.append("(target_from = \"%s\")" %
+ 'ldap:///%s' % ipapermtargetfrom)
+
+ # targetfilter
+ ipapermtargetfilter = entry.get('ipapermtargetfilter')
+ if ipapermtargetfilter:
+ assert all(f.startswith('(') and f.endswith(')')
+ for f in ipapermtargetfilter)
+ if len(ipapermtargetfilter) == 1:
+ filter = ipapermtargetfilter[0]
+ else:
+ filter = '(&%s)' % ''.join(sorted(ipapermtargetfilter))
+ aci_parts.append("(targetfilter = \"%s\")" % filter)
+
+ # version, name, rights, bind rule
+ ipapermbindruletype = entry.single_value.get('ipapermbindruletype',
+ 'permission')
+ if ipapermbindruletype == 'permission':
+ dn = DN(('cn', name), self.container_dn, self.api.env.basedn)
+ bindrule = 'groupdn = "ldap:///%s"' % dn
+ elif ipapermbindruletype == 'all':
+ bindrule = 'userdn = "ldap:///all"'
+ elif ipapermbindruletype == 'anonymous':
+ bindrule = 'userdn = "ldap:///anyone"'
+ else:
+ raise ValueError(ipapermbindruletype)
+
+ aci_parts.append('(version 3.0;acl "permission:%s";allow (%s) %s;)' % (
+ name, ','.join(sorted(entry['ipapermright'])), bindrule))
+
+ return ''.join(aci_parts)
+
+ def add_aci(self, permission_entry):
+ """Add the ACI coresponding to the given permission entry"""
+ ldap = self.api.Backend.ldap2
+ acistring = self.make_aci(permission_entry)
+ location = permission_entry.single_value.get('ipapermlocation',
+ self.api.env.basedn)
+
+ self.log.debug('Adding ACI %r to %s' % (acistring, location))
+ try:
+ entry = ldap.get_entry(location, ['aci'])
+ except errors.NotFound:
+ raise errors.NotFound(reason=_('Entry %s not found') % location)
+ entry.setdefault('aci', []).append(acistring)
+ ldap.update_entry(entry)
+
+ def remove_aci(self, permission_entry):
+ """Remove the ACI corresponding to the given permission entry
+
+ :return: tuple:
+ - entry
+ - removed ACI string, or None if none existed previously
+ """
+ return self._replace_aci(permission_entry)
+
+ def update_aci(self, permission_entry, old_name=None):
+ """Update the ACI corresponding to the given permission entry
+
+ :return: tuple:
+ - entry
+ - removed ACI string, or None if none existed previously
+ """
+ new_acistring = self.make_aci(permission_entry)
+ return self._replace_aci(permission_entry, old_name, new_acistring)
+
+ def _replace_aci(self, permission_entry, old_name=None, new_acistring=None):
+ """Replace ACI corresponding to permission_entry
+
+ :param old_name: the old name of the permission, if different from new
+ :param new_acistring: new ACI string; if None the ACI is just deleted
+ :return: tuple:
+ - entry
+ - removed ACI string, or None if none existed previously
+ """
+ ldap = self.api.Backend.ldap2
+ acientry, acistring = self._get_aci_entry_and_string(
+ permission_entry, old_name, notfound_ok=True)
+
+ # (pylint thinks `acientry` is just a dict, but it's an LDAPEntry)
+ acidn = acientry.dn # pylint: disable=E1103
+
+ if acistring is not None:
+ self.log.debug('Removing ACI %r from %s' % (acistring, acidn))
+ acientry['aci'].remove(acistring)
+ if new_acistring:
+ self.log.debug('Adding ACI %r to %s' % (new_acistring, acidn))
+ acientry.setdefault('aci', []).append(new_acistring)
+ try:
+ ldap.update_entry(acientry)
+ except errors.EmptyModlist:
+ self.log.debug('No changes to ACI')
+ return acientry, acistring
+
+ def _get_aci_entry_and_string(self, permission_entry, name=None,
+ notfound_ok=False, cached_acientry=None):
+ """Get the entry and ACI corresponding to the permission entry
+
+ :param name: The name of the permission, or None for the cn
+ :param notfound_ok:
+ If true, (acientry, None) will be returned on missing ACI, rather
+ than raising exception
+ :param cached_acientry: See upgrade_permission()
+ """
+ ldap = self.api.Backend.ldap2
+ if name is None:
+ name = permission_entry.single_value['cn']
+ location = permission_entry.single_value.get('ipapermlocation',
+ self.api.env.basedn)
+ wanted_aciname = 'permission:%s' % name
+
+ if (cached_acientry and
+ cached_acientry.dn == location and
+ 'aci' in cached_acientry):
+ acientry = cached_acientry
+ else:
+ try:
+ acientry = ldap.get_entry(location, ['aci'])
+ except errors.NotFound:
+ acientry = ldap.make_entry(location)
+ acis = acientry.get('aci', ())
+ for acistring in acis:
+ try:
+ aci = ACI(acistring)
+ except SyntaxError as e:
+ self.log.warning('Unparseable ACI %s: %s (at %s)',
+ acistring, e, location)
+ continue
+ if aci.name == wanted_aciname:
+ return acientry, acistring
+ else:
+ if notfound_ok:
+ return acientry, None
+ raise errors.NotFound(
+ reason=_('The ACI for permission %(name)s was not found '
+ 'in %(dn)s ') % {'name': name, 'dn': location})
+
+ def upgrade_permission(self, entry, target_entry=None,
+ output_only=False, cached_acientry=None):
+ """Upgrade the given permission entry to V2, in-place
+
+ The entry is only upgraded if it is a plain old-style permission,
+ that is, it has no flags set.
+
+ :param target_entry:
+ If given, ``target_entry`` is filled from information taken
+ from the ACI corresponding to ``entry``.
+ If None, ``entry`` itself is filled
+ :param output_only:
+ If true, the flags & objectclass are not updated to V2.
+ Used for the -find and -show commands.
+ :param cached_acientry:
+ Optional pre-retreived entry that contains the existing ACI.
+ If it is None or its DN does not match the location DN,
+ cached_acientry is ignored and the entry is retreived from LDAP.
+ """
+ if entry.get('ipapermissiontype'):
+ # Only convert old-style, non-SYSTEM permissions -- i.e. no flags
+ return
+ base, acistring = self._get_aci_entry_and_string(
+ entry, cached_acientry=cached_acientry)
+
+ if not target_entry:
+ target_entry = entry
+
+ # The DN of old permissions is always basedn
+ # (pylint thinks `base` is just a dict, but it's an LDAPEntry)
+ assert base.dn == self.api.env.basedn, base # pylint: disable=E1103
+
+ aci = ACI(acistring)
+
+ if 'target' in aci.target:
+ target_entry.single_value['ipapermtarget'] = DN(strip_ldap_prefix(
+ aci.target['target']['expression']))
+ if 'targetfilter' in aci.target:
+ target_entry.single_value['ipapermtargetfilter'] = unicode(
+ aci.target['targetfilter']['expression'])
+ if aci.bindrule['expression'] == 'ldap:///all':
+ target_entry.single_value['ipapermbindruletype'] = u'all'
+ elif aci.bindrule['expression'] == 'ldap:///anyone':
+ target_entry.single_value['ipapermbindruletype'] = u'anonymous'
+ else:
+ target_entry.single_value['ipapermbindruletype'] = u'permission'
+ target_entry['ipapermright'] = aci.permissions
+ if 'targetattr' in aci.target:
+ target_entry['ipapermincludedattr'] = [
+ unicode(a) for a in aci.target['targetattr']['expression']]
+
+ if not output_only:
+ target_entry['ipapermissiontype'] = ['SYSTEM', 'V2']
+ if 'ipapermissionv2' not in entry['objectclass']:
+ target_entry['objectclass'] = list(entry['objectclass']) + [
+ u'ipapermissionv2']
+
+ target_entry['ipapermlocation'] = [self.api.env.basedn]
+
+ # Make sure we're not losing *any info* by the upgrade
+ new_acistring = self.make_aci(target_entry)
+ if not ACI(new_acistring).isequal(aci):
+ raise ValueError('Cannot convert ACI, %r != %r' % (new_acistring,
+ acistring))
+
+ def make_type_filter(self, obj):
+ """Make a filter for a --type based permission from an Object"""
+ objectclasses = getattr(obj, 'permission_filter_objectclasses', None)
+ if not objectclasses:
+ return None
+ filters = [u'(objectclass=%s)' % o for o in objectclasses]
+ if len(filters) == 1:
+ return filters[0]
+ else:
+ return '(|%s)' % ''.join(sorted(filters))
+
+ def preprocess_options(self, options,
+ return_filter_ops=False,
+ merge_targetfilter=False):
+ """Preprocess options (in-place)
+
+ :param options: A dictionary of options
+ :param return_filter_ops:
+ If false, assumes there is no pre-existing entry;
+ additional values of ipapermtargetfilter are added to options.
+ If true, a dictionary of operations on ipapermtargetfilter is
+ returned.
+ These operations must be performed after the existing entry
+ is retrieved.
+ The dict has the following keys:
+ - remove: list of regular expression objects;
+ implicit values that match any of them should be removed
+ - add: list of values to be added, after any removals
+ :merge_targetfilter:
+ If true, the extratargetfilter is copied into ipapermtargetfilter.
+ """
+
+ if 'extratargetfilter' in options:
+ if 'ipapermtargetfilter' in options:
+ raise errors.ValidationError(
+ name='ipapermtargetfilter',
+ error=_('cannot specify full target filter '
+ 'and extra target filter simultaneously'))
+ if merge_targetfilter:
+ options['ipapermtargetfilter'] = options['extratargetfilter']
+
+ filter_ops = {'add': [], 'remove': []}
+
+ if options.get('subtree'):
+ if isinstance(options['subtree'], (list, tuple)):
+ [options['subtree']] = options['subtree']
+ try:
+ options['subtree'] = strip_ldap_prefix(options['subtree'])
+ except ValueError:
+ raise errors.ValidationError(
+ name='subtree',
+ error='does not start with "ldap:///"')
+
+ # Handle old options
+ for old_name, new_name in _DEPRECATED_OPTION_ALIASES.items():
+ if old_name in options:
+ if client_has_capability(options['version'], 'permissions2'):
+ raise errors.ValidationError(
+ name=old_name,
+ error=_('option was renamed; use %s') % new_name)
+ if new_name in options:
+ raise errors.ValidationError(
+ name=old_name,
+ error=(_('Cannot use %(old_name)s with %(new_name)s') %
+ {'old_name': old_name, 'new_name': new_name}))
+ options[new_name] = options[old_name]
+ del options[old_name]
+
+ # memberof
+ if 'memberof' in options:
+ filter_ops['remove'].append(re.compile(r'\(memberOf=.*\)', re.I))
+ memberof = options.pop('memberof')
+ for group in (memberof or ()):
+ try:
+ groupdn = self.api.Object.group.get_dn_if_exists(group)
+ except errors.NotFound:
+ raise errors.NotFound(
+ reason=_('%s: group not found') % group)
+ filter_ops['add'].append(u'(memberOf=%s)' % groupdn)
+
+ # targetgroup
+ if 'targetgroup' in options:
+ targetgroup = options.pop('targetgroup')
+ if targetgroup:
+ if 'ipapermtarget' in options:
+ raise errors.ValidationError(
+ name='ipapermtarget',
+ error=_('target and targetgroup are mutually exclusive'))
+ try:
+ groupdn = self.api.Object.group.get_dn_if_exists(targetgroup)
+ except errors.NotFound:
+ raise errors.NotFound(
+ reason=_('%s: group not found') % targetgroup)
+ options['ipapermtarget'] = groupdn
+ else:
+ if 'ipapermtarget' not in options:
+ options['ipapermtarget'] = None
+
+ # type
+ if 'type' in options:
+ objtype = options.pop('type')
+ filter_ops['remove'].append(re.compile(r'\(objectclass=.*\)', re.I))
+ filter_ops['remove'].append(re.compile(
+ r'\(\|(\(objectclass=[^(]*\))+\)', re.I))
+ if objtype:
+ if 'ipapermlocation' in options:
+ raise errors.ValidationError(
+ name='ipapermlocation',
+ error=_('subtree and type are mutually exclusive'))
+ obj = self.api.Object[objtype.lower()]
+ filt = self.make_type_filter(obj)
+ if not filt:
+ raise errors.ValidationError(
+ _('"%s" is not a valid permission type') % objtype)
+ filter_ops['add'].append(filt)
+ container_dn = DN(obj.container_dn, self.api.env.basedn)
+ options['ipapermlocation'] = container_dn
+ else:
+ if 'ipapermlocation' not in options:
+ options['ipapermlocation'] = None
+
+ if return_filter_ops:
+ return filter_ops
+ elif filter_ops['add']:
+ options['ipapermtargetfilter'] = list(options.get(
+ 'ipapermtargetfilter') or []) + filter_ops['add']
+
+ def validate_permission(self, entry):
+ ldap = self.Backend.ldap2
+
+ # Rough filter validation by a search
+ if entry.get('ipapermtargetfilter'):
+ try:
+ ldap.find_entries(
+ filter=ldap.combine_filters(entry['ipapermtargetfilter'],
+ rules='&'),
+ base_dn=self.env.basedn,
+ scope=ldap.SCOPE_BASE,
+ size_limit=1)
+ except errors.NotFound:
+ pass
+ except errors.BadSearchFilter:
+ raise errors.ValidationError(
+ name='ipapermtargetfilter',
+ error=_('Bad search filter'))
+
+ # Ensure location exists
+ if entry.get('ipapermlocation'):
+ location = DN(entry.single_value['ipapermlocation'])
+ try:
+ ldap.get_entry(location, attrs_list=[])
+ except errors.NotFound:
+ raise errors.ValidationError(
+ name='ipapermlocation',
+ error=_('Entry %s does not exist') % location)
+
+ # Ensure there's something in the ACI's filter
+ needed_attrs = (
+ 'ipapermtarget', 'ipapermtargetfilter',
+ 'ipapermincludedattr', 'ipapermexcludedattr', 'ipapermdefaultattr')
+ if not any(v for a in needed_attrs for v in (entry.get(a) or ())):
+ raise errors.ValidationError(
+ name='target',
+ error=_('there must be at least one target entry specifier '
+ '(e.g. target, targetfilter, attrs)'))
+
+ # Ensure there's a right
+ if not entry.get('ipapermright'):
+ raise errors.RequirementError(name='ipapermright')
+
+
+@register()
+class permission_add_noaci(baseldap.LDAPCreate):
+ __doc__ = _('Add a system permission without an ACI (internal command)')
+
+ msg_summary = _('Added permission "%(value)s"')
+ NO_CLI = True
+ has_output_params = baseldap.LDAPCreate.has_output_params + output_params
+
+ takes_options = (
+ Str('ipapermissiontype+',
+ label=_('Permission flags'),
+ ),
+ )
+
+ def get_options(self):
+ perm_options = set(o.name for o in self.obj.takes_params)
+ for option in super(permission_add_noaci, self).get_options():
+ # From new options, only cn & ipapermissiontype are supported
+ if option.name in ['ipapermissiontype']:
+ yield option.clone()
+ # Other options such as raw, version are supported
+ elif option.name not in perm_options:
+ yield option.clone()
+
+ def pre_callback(self, ldap, dn, entry, attrs_list, *keys, **options):
+ entry['ipapermissiontype'] = list(options['ipapermissiontype'])
+ entry['objectclass'] = [oc for oc in entry['objectclass']
+ if oc.lower() != 'ipapermissionv2']
+ return dn
+
+
+@register()
+class permission_add(baseldap.LDAPCreate):
+ __doc__ = _('Add a new permission.')
+
+ msg_summary = _('Added permission "%(value)s"')
+ has_output_params = baseldap.LDAPCreate.has_output_params + output_params
+
+ # Need to override execute so that processed options apply to
+ # the whole command, not just the callbacks
+ def execute(self, *keys, **options):
+ self.obj.preprocess_options(options, merge_targetfilter=True)
+ return super(permission_add, self).execute(*keys, **options)
+
+ def get_args(self):
+ for arg in super(permission_add, self).get_args():
+ if arg.name == 'cn':
+ yield _disallow_colon(arg)
+ else:
+ yield arg
+
+ def pre_callback(self, ldap, dn, entry, attrs_list, *keys, **options):
+ entry['ipapermissiontype'] = ['SYSTEM', 'V2']
+ entry['cn'] = list(keys)
+ if not entry.get('ipapermlocation'):
+ entry.setdefault('ipapermlocation', [api.env.basedn])
+
+ if 'attrs' in options:
+ if 'ipapermincludedattr' in options:
+ raise errors.ValidationError(
+ name='attrs',
+ error=_('attrs and included attributes are '
+ 'mutually exclusive'))
+ entry['ipapermincludedattr'] = list(options.pop('attrs') or ())
+
+ self.obj.validate_permission(entry)
+ return dn
+
+ def post_callback(self, ldap, dn, entry, *keys, **options):
+ try:
+ self.obj.add_aci(entry)
+ except Exception as e:
+ # Adding the ACI failed.
+ # We want to be 100% sure the ACI is not there, so try to
+ # remove it. (This is a no-op if the ACI was not added.)
+ self.obj.remove_aci(entry)
+ # Remove the entry.
+ # The permission entry serves as a "lock" tho prevent
+ # permission-add commands started at the same time from
+ # interfering. As long as the entry is there, the other
+ # permission-add will fail with DuplicateEntry.
+ # So deleting entry ("releasing the lock") must be the last
+ # thing we do here.
+ try:
+ self.api.Backend['ldap2'].delete_entry(entry)
+ except errors.NotFound:
+ pass
+ if isinstance(e, errors.NotFound):
+ # add_aci may raise NotFound if the subtree is only virtual
+ # like cn=compat,SUFFIX and thus passes the LDAP get entry test
+ location = DN(entry.single_value['ipapermlocation'])
+ raise errors.ValidationError(
+ name='ipapermlocation',
+ error=_('Cannot store permission ACI to %s') % location)
+ # Re-raise original exception
+ raise
+ self.obj.postprocess_result(entry, options)
+ return dn
+
+
+@register()
+class permission_del(baseldap.LDAPDelete):
+ __doc__ = _('Delete a permission.')
+
+ msg_summary = _('Deleted permission "%(value)s"')
+
+ takes_options = baseldap.LDAPDelete.takes_options + (
+ Flag('force',
+ label=_('Force'),
+ flags={'no_option', 'no_output'},
+ doc=_('force delete of SYSTEM permissions'),
+ ),
+ )
+
+ def pre_callback(self, ldap, dn, *keys, **options):
+ try:
+ entry = ldap.get_entry(dn, attrs_list=self.obj.default_attributes)
+ except errors.NotFound:
+ self.obj.handle_not_found(*keys)
+
+ if not options.get('force'):
+ self.obj.reject_system(entry)
+ if entry.get('ipapermdefaultattr'):
+ raise errors.ACIError(
+ info=_('cannot delete managed permissions'))
+
+ try:
+ self.obj.remove_aci(entry)
+ except errors.NotFound:
+ errors.NotFound(
+ reason=_('ACI of permission %s was not found') % keys[0])
+
+ return dn
+
+
+@register()
+class permission_mod(baseldap.LDAPUpdate):
+ __doc__ = _('Modify a permission.')
+
+ msg_summary = _('Modified permission "%(value)s"')
+ has_output_params = baseldap.LDAPUpdate.has_output_params + output_params
+
+ def execute(self, *keys, **options):
+ context.filter_ops = self.obj.preprocess_options(
+ options, return_filter_ops=True)
+ return super(permission_mod, self).execute(*keys, **options)
+
+ def get_options(self):
+ for opt in super(permission_mod, self).get_options():
+ if opt.name == 'rename':
+ yield _disallow_colon(opt)
+ else:
+ yield opt
+
+ def pre_callback(self, ldap, dn, entry, attrs_list, *keys, **options):
+ if 'rename' in options and not options['rename']:
+ raise errors.ValidationError(name='rename',
+ error='New name can not be empty')
+
+ try:
+ attrs_list = self.obj.default_attributes
+ old_entry = ldap.get_entry(dn, attrs_list=attrs_list)
+ except errors.NotFound:
+ self.obj.handle_not_found(*keys)
+
+ self.obj.reject_system(old_entry)
+ self.obj.upgrade_permission(old_entry)
+
+ if 'MANAGED' in old_entry.get('ipapermissiontype', ()):
+ for option_name in sorted(options):
+ if option_name == 'rename':
+ raise errors.ValidationError(
+ name=option_name,
+ error=_('cannot rename managed permissions'))
+ option = self.options[option_name]
+ allow_mod = 'allow_mod_for_managed_permission' in option.flags
+ if (option.attribute and not allow_mod or
+ option_name == 'extratargetfilter'):
+ raise errors.ValidationError(
+ name=option_name,
+ error=_('not modifiable on managed permissions'))
+ if context.filter_ops.get('add'):
+ raise errors.ValidationError(
+ name='ipapermtargetfilter',
+ error=_('not modifiable on managed permissions'))
+ else:
+ if options.get('ipapermexcludedattr'):
+ # prevent setting excluded attributes on normal permissions
+ # (but do allow deleting them all)
+ raise errors.ValidationError(
+ name='ipapermexcludedattr',
+ error=_('only available on managed permissions'))
+
+ if 'attrs' in options:
+ if any(a in options for a in ('ipapermincludedattr',
+ 'ipapermexcludedattr')):
+ raise errors.ValidationError(
+ name='attrs',
+ error=_('attrs and included/excluded attributes are '
+ 'mutually exclusive'))
+ attrs = set(options.pop('attrs') or ())
+ defaults = set(old_entry.get('ipapermdefaultattr', ()))
+ entry['ipapermincludedattr'] = list(attrs - defaults)
+ entry['ipapermexcludedattr'] = list(defaults - attrs)
+
+ # Check setting bindtype for an assigned permission
+ if options.get('ipapermbindruletype') and old_entry.get('member'):
+ raise errors.ValidationError(
+ name='ipapermbindruletype',
+ error=_('cannot set bindtype for a permission that is '
+ 'assigned to a privilege'))
+
+ # Since `entry` only contains the attributes we are currently changing,
+ # it cannot be used directly to generate an ACI.
+ # First we need to copy the original data into it.
+ for key, value in old_entry.items():
+ if (key not in options and
+ key != 'cn' and
+ key not in self.obj.attribute_members):
+ entry.setdefault(key, value)
+
+ # For extratargetfilter, add it to the implicit filters
+ # to get the full target filter
+ if 'extratargetfilter' in options:
+ filter_attr_info = self.obj._get_filter_attr_info(entry)
+ entry['ipapermtargetfilter'] = (
+ list(options['extratargetfilter'] or []) +
+ list(filter_attr_info['implicit_targetfilters']))
+
+ filter_ops = context.filter_ops
+ old_filter_attr_info = self.obj._get_filter_attr_info(old_entry)
+ old_implicit_filters = old_filter_attr_info['implicit_targetfilters']
+ removes = filter_ops.get('remove', [])
+ new_filters = set(
+ filt for filt in (entry.get('ipapermtargetfilter') or [])
+ if filt not in old_implicit_filters or
+ not any(rem.match(filt) for rem in removes))
+ new_filters.update(filter_ops.get('add', []))
+ new_filters.update(options.get('ipapermtargetfilter') or [])
+ entry['ipapermtargetfilter'] = list(new_filters)
+
+ if not entry.get('ipapermlocation'):
+ entry['ipapermlocation'] = [self.api.env.basedn]
+
+ self.obj.validate_permission(entry)
+
+ old_location = old_entry.single_value.get('ipapermlocation',
+ self.api.env.basedn)
+ if old_location == options.get('ipapermlocation', old_location):
+ context.permision_moving_aci = False
+ else:
+ context.permision_moving_aci = True
+ try:
+ context.old_aci_info = self.obj.remove_aci(old_entry)
+ except errors.NotFound as e:
+ self.log.error('permission ACI not found: %s' % e)
+
+ # To pass data to postcallback, we currently need to use the context
+ context.old_entry = old_entry
+
+ return dn
+
+ def exc_callback(self, keys, options, exc, call_func, *call_args, **call_kwargs):
+ if call_func.__name__ == 'update_entry':
+ self._revert_aci()
+ raise exc
+
+ def _revert_aci(self):
+ old_aci_info = getattr(context, 'old_aci_info', None)
+ if old_aci_info:
+ # Try to roll back the old ACI
+ entry, old_aci_string = old_aci_info
+ if old_aci_string:
+ self.log.warning('Reverting ACI on %s to %s' % (entry.dn,
+ old_aci_string))
+ entry['aci'].append(old_aci_string)
+ self.Backend.ldap2.update_entry(entry)
+
+ def post_callback(self, ldap, dn, entry, *keys, **options):
+ old_entry = context.old_entry
+
+ try:
+ if context.permision_moving_aci:
+ self.obj.add_aci(entry)
+ else:
+ self.obj.update_aci(entry, old_entry.single_value['cn'])
+ except Exception:
+ # Don't revert attribute which doesn't exist in LDAP
+ entry.pop('attributelevelrights', None)
+
+ self.log.error('Error updating ACI: %s' % traceback.format_exc())
+ self.log.warning('Reverting entry')
+ old_entry.reset_modlist(entry)
+ ldap.update_entry(old_entry)
+ self._revert_aci()
+ raise
+ self.obj.postprocess_result(entry, options)
+ entry['dn'] = entry.dn
+ return dn
+
+
+@register()
+class permission_find(baseldap.LDAPSearch):
+ __doc__ = _('Search for permissions.')
+
+ msg_summary = ngettext(
+ '%(count)d permission matched', '%(count)d permissions matched', 0)
+ has_output_params = baseldap.LDAPSearch.has_output_params + output_params
+
+ def execute(self, *keys, **options):
+ self.obj.preprocess_options(options, merge_targetfilter=True)
+ return super(permission_find, self).execute(*keys, **options)
+
+ def pre_callback(self, ldap, filters, attrs_list, base_dn, scope,
+ *args, **options):
+ if 'attrs' in options and 'ipapermincludedattr' in options:
+ raise errors.ValidationError(
+ name='attrs',
+ error=_('attrs and included/excluded attributes are '
+ 'mutually exclusive'))
+
+ if options.get('attrs'):
+ # Effective attributes:
+ # each attr must be in either default or included,
+ # but not in excluded
+ filters = ldap.combine_filters(
+ [filters] + [
+ '(&'
+ '(|'
+ '(ipapermdefaultattr=%(attr)s)'
+ '(ipapermincludedattr=%(attr)s))'
+ '(!(ipapermexcludedattr=%(attr)s)))' % {'attr': attr}
+ for attr in options['attrs']
+ ],
+ ldap.MATCH_ALL,
+ )
+
+ return filters, base_dn, scope
+
+ def post_callback(self, ldap, entries, truncated, *args, **options):
+ if 'attrs' in options:
+ options['ipapermincludedattr'] = options['attrs']
+
+ attribute_options = [o for o in options
+ if (o in self.options and
+ self.options[o].attribute)]
+
+ if not options.get('pkey_only'):
+ for entry in entries:
+ # Old-style permissions might have matched (e.g. by name)
+ self.obj.upgrade_permission(entry, output_only=True)
+
+ if not truncated:
+ if 'sizelimit' in options:
+ max_entries = options['sizelimit']
+ else:
+ max_entries = self.api.Backend.ldap2.size_limit
+
+ filters = ['(objectclass=ipaPermission)',
+ '(!(ipaPermissionType=V2))']
+ if 'name' in options:
+ filters.append(ldap.make_filter_from_attr('cn',
+ options['name'],
+ exact=False))
+ attrs_list = list(self.obj.default_attributes)
+ attrs_list += list(self.obj.attribute_members)
+ if options.get('all'):
+ attrs_list.append('*')
+ try:
+ legacy_entries = ldap.get_entries(
+ base_dn=DN(self.obj.container_dn, self.api.env.basedn),
+ filter=ldap.combine_filters(filters, rules=ldap.MATCH_ALL),
+ attrs_list=attrs_list)
+ # Retrieve the root entry (with all legacy ACIs) at once
+ root_entry = ldap.get_entry(DN(api.env.basedn), ['aci'])
+ except errors.NotFound:
+ legacy_entries = ()
+ cached_root_entry = None
+ self.log.debug('potential legacy entries: %s', len(legacy_entries))
+ nonlegacy_names = {e.single_value['cn'] for e in entries}
+ for entry in legacy_entries:
+ if entry.single_value['cn'] in nonlegacy_names:
+ continue
+ if max_entries > 0 and len(entries) > max_entries:
+ # We've over the limit, pop the last entry and set
+ # truncated flag
+ # (this is easier to do than checking before adding
+ # the entry to results)
+ # (max_entries <= 0 means unlimited)
+ entries.pop()
+ truncated = True
+ break
+ self.obj.upgrade_permission(entry, output_only=True,
+ cached_acientry=root_entry)
+ # If all given options match, include the entry
+ # Do a case-insensitive match, on any value if multi-valued
+ for opt in attribute_options:
+ optval = options[opt]
+ if not isinstance(optval, (tuple, list)):
+ optval = [optval]
+ value = entry.get(opt)
+ if not value:
+ break
+ if not all(any(str(ov).lower() in str(v).lower()
+ for v in value) for ov in optval):
+ break
+ else:
+ # Each search term must be present in some
+ # attribute value
+ for arg in args:
+ if arg:
+ arg = arg.lower()
+ if not any(arg in str(value).lower()
+ for values in entry.values()
+ for value in values):
+ break
+ else:
+ entries.append(entry)
+
+ for entry in entries:
+ if options.get('pkey_only'):
+ for opt_name in list(entry):
+ if opt_name != self.obj.primary_key.name:
+ del entry[opt_name]
+ else:
+ self.obj.postprocess_result(entry, options)
+
+ return truncated
+
+
+@register()
+class permission_show(baseldap.LDAPRetrieve):
+ __doc__ = _('Display information about a permission.')
+ has_output_params = baseldap.LDAPRetrieve.has_output_params + output_params
+
+ def post_callback(self, ldap, dn, entry, *keys, **options):
+ self.obj.upgrade_permission(entry, output_only=True)
+ self.obj.postprocess_result(entry, options)
+ return dn
+
+
+@register()
+class permission_add_member(baseldap.LDAPAddMember):
+ """Add members to a permission."""
+ NO_CLI = True
+
+ def pre_callback(self, ldap, dn, member_dns, failed, *keys, **options):
+ # We can only add permissions with bind rule type set to
+ # "permission" (or old-style permissions)
+ validate_permission_to_privilege(self.api, keys[-1])
+ return dn
+
+
+@register()
+class permission_remove_member(baseldap.LDAPRemoveMember):
+ """Remove members from a permission."""
+ NO_CLI = True
diff --git a/ipaserver/plugins/ping.py b/ipaserver/plugins/ping.py
new file mode 100644
index 000000000..6a514125c
--- /dev/null
+++ b/ipaserver/plugins/ping.py
@@ -0,0 +1,70 @@
+# Authors:
+# Rob Crittenden <rcritten@redhat.com>
+#
+# Copyright (C) 2010 Red Hat
+# see file 'COPYING' for use and warranty information
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+from ipalib import Command
+from ipalib import output
+from ipalib import _
+from ipalib.plugable import Registry
+from ipapython.version import VERSION, API_VERSION
+
+__doc__ = _("""
+Ping the remote IPA server to ensure it is running.
+
+The ping command sends an echo request to an IPA server. The server
+returns its version information. This is used by an IPA client
+to confirm that the server is available and accepting requests.
+
+The server from xmlrpc_uri in /etc/ipa/default.conf is contacted first.
+If it does not respond then the client will contact any servers defined
+by ldap SRV records in DNS.
+
+EXAMPLES:
+
+ Ping an IPA server:
+ ipa ping
+ ------------------------------------------
+ IPA server version 2.1.9. API version 2.20
+ ------------------------------------------
+
+ Ping an IPA server verbosely:
+ ipa -v ping
+ ipa: INFO: trying https://ipa.example.com/ipa/xml
+ ipa: INFO: Forwarding 'ping' to server 'https://ipa.example.com/ipa/xml'
+ -----------------------------------------------------
+ IPA server version 2.1.9. API version 2.20
+ -----------------------------------------------------
+""")
+
+register = Registry()
+
+
+@register()
+class ping(Command):
+ __doc__ = _('Ping a remote server.')
+
+ has_output = (
+ output.summary,
+ )
+
+ def execute(self, **options):
+ """
+ A possible enhancement would be to take an argument and echo it
+ back but a fixed value works for now.
+ """
+ return dict(summary=u'IPA server version %s. API version %s' % (VERSION, API_VERSION))
diff --git a/ipaserver/plugins/pkinit.py b/ipaserver/plugins/pkinit.py
new file mode 100644
index 000000000..9aa101063
--- /dev/null
+++ b/ipaserver/plugins/pkinit.py
@@ -0,0 +1,105 @@
+# Authors:
+# Simo Sorce <ssorce@redhat.com>
+#
+# Copyright (C) 2010 Red Hat
+# see file 'COPYING' for use and warranty information
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+from ipalib import api, errors
+from ipalib import Str
+from ipalib import Object, Command
+from ipalib import _
+from ipalib.plugable import Registry
+from ipapython.dn import DN
+
+__doc__ = _("""
+Kerberos pkinit options
+
+Enable or disable anonymous pkinit using the principal
+WELLKNOWN/ANONYMOUS@REALM. The server must have been installed with
+pkinit support.
+
+EXAMPLES:
+
+ Enable anonymous pkinit:
+ ipa pkinit-anonymous enable
+
+ Disable anonymous pkinit:
+ ipa pkinit-anonymous disable
+
+For more information on anonymous pkinit see:
+
+http://k5wiki.kerberos.org/wiki/Projects/Anonymous_pkinit
+""")
+
+register = Registry()
+
+@register()
+class pkinit(Object):
+ """
+ PKINIT Options
+ """
+ object_name = _('pkinit')
+
+ label=_('PKINIT')
+
+
+def valid_arg(ugettext, action):
+ """
+ Accepts only Enable/Disable.
+ """
+ a = action.lower()
+ if a != 'enable' and a != 'disable':
+ raise errors.ValidationError(
+ name='action',
+ error=_('Unknown command %s') % action
+ )
+
+@register()
+class pkinit_anonymous(Command):
+ __doc__ = _('Enable or Disable Anonymous PKINIT.')
+
+ princ_name = 'WELLKNOWN/ANONYMOUS@%s' % api.env.realm
+ default_dn = DN(('krbprincipalname', princ_name), ('cn', api.env.realm), ('cn', 'kerberos'), api.env.basedn)
+
+ takes_args = (
+ Str('action', valid_arg),
+ )
+
+ def execute(self, action, **options):
+ ldap = self.api.Backend.ldap2
+ set_lock = False
+ lock = None
+
+ entry_attrs = ldap.get_entry(self.default_dn, ['nsaccountlock'])
+
+ if 'nsaccountlock' in entry_attrs:
+ lock = entry_attrs['nsaccountlock'][0].lower()
+
+ if action.lower() == 'enable':
+ if lock == 'true':
+ set_lock = True
+ lock = None
+ elif action.lower() == 'disable':
+ if lock != 'true':
+ set_lock = True
+ lock = 'TRUE'
+
+ if set_lock:
+ entry_attrs['nsaccountlock'] = lock
+ ldap.update_entry(entry_attrs)
+
+ return dict(result=True)
+
diff --git a/ipaserver/plugins/privilege.py b/ipaserver/plugins/privilege.py
new file mode 100644
index 000000000..b46807c3f
--- /dev/null
+++ b/ipaserver/plugins/privilege.py
@@ -0,0 +1,251 @@
+# Authors:
+# Rob Crittenden <rcritten@redhat.com>
+#
+# Copyright (C) 2010 Red Hat
+# see file 'COPYING' for use and warranty information
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+from .baseldap import (
+ LDAPObject,
+ LDAPCreate,
+ LDAPDelete,
+ LDAPUpdate,
+ LDAPSearch,
+ LDAPRetrieve,
+ LDAPAddMember,
+ LDAPRemoveMember,
+ LDAPAddReverseMember,
+ LDAPRemoveReverseMember)
+from ipalib import api, _, ngettext, errors
+from ipalib.plugable import Registry
+from ipalib import Str
+from ipalib import output
+from ipapython.dn import DN
+
+__doc__ = _("""
+Privileges
+
+A privilege combines permissions into a logical task. A permission provides
+the rights to do a single task. There are some IPA operations that require
+multiple permissions to succeed. A privilege is where permissions are
+combined in order to perform a specific task.
+
+For example, adding a user requires the following permissions:
+ * Creating a new user entry
+ * Resetting a user password
+ * Adding the new user to the default IPA users group
+
+Combining these three low-level tasks into a higher level task in the
+form of a privilege named "Add User" makes it easier to manage Roles.
+
+A privilege may not contain other privileges.
+
+See role and permission for additional information.
+""")
+
+register = Registry()
+
+
+def validate_permission_to_privilege(api, permission):
+ ldap = api.Backend.ldap2
+ ldapfilter = ldap.combine_filters(rules='&', filters=[
+ '(objectClass=ipaPermissionV2)', '(!(ipaPermBindRuleType=permission))',
+ ldap.make_filter_from_attr('cn', permission, rules='|')])
+ try:
+ entries, truncated = ldap.find_entries(
+ filter=ldapfilter,
+ attrs_list=['cn', 'ipapermbindruletype'],
+ base_dn=DN(api.env.container_permission, api.env.basedn),
+ size_limit=1)
+ except errors.NotFound:
+ pass
+ else:
+ entry = entries[0]
+ message = _('cannot add permission "%(perm)s" with bindtype '
+ '"%(bindtype)s" to a privilege')
+ raise errors.ValidationError(
+ name='permission',
+ error=message % {
+ 'perm': entry.single_value['cn'],
+ 'bindtype': entry.single_value.get(
+ 'ipapermbindruletype', 'permission')})
+
+
+@register()
+class privilege(LDAPObject):
+ """
+ Privilege object.
+ """
+ container_dn = api.env.container_privilege
+ object_name = _('privilege')
+ object_name_plural = _('privileges')
+ object_class = ['nestedgroup', 'groupofnames']
+ permission_filter_objectclasses = ['groupofnames']
+ default_attributes = ['cn', 'description', 'member', 'memberof']
+ attribute_members = {
+ 'member': ['role'],
+ 'memberof': ['permission'],
+ }
+ reverse_members = {
+ 'member': ['permission'],
+ }
+ rdn_is_primary_key = True
+ managed_permissions = {
+ 'System: Read Privileges': {
+ 'replaces_global_anonymous_aci': True,
+ 'ipapermright': {'read', 'search', 'compare'},
+ 'ipapermdefaultattr': {
+ 'businesscategory', 'cn', 'description', 'member', 'memberof',
+ 'o', 'objectclass', 'ou', 'owner', 'seealso', 'memberuser',
+ 'memberhost',
+ },
+ 'default_privileges': {'RBAC Readers'},
+ },
+ 'System: Add Privileges': {
+ 'ipapermright': {'add'},
+ 'default_privileges': {'Delegation Administrator'},
+ },
+ 'System: Modify Privileges': {
+ 'ipapermright': {'write'},
+ 'ipapermdefaultattr': {
+ 'businesscategory', 'cn', 'description', 'o', 'ou', 'owner',
+ 'seealso',
+ },
+ 'default_privileges': {'Delegation Administrator'},
+ },
+ 'System: Remove Privileges': {
+ 'ipapermright': {'delete'},
+ 'default_privileges': {'Delegation Administrator'},
+ },
+ }
+
+ label = _('Privileges')
+ label_singular = _('Privilege')
+
+ takes_params = (
+ Str('cn',
+ cli_name='name',
+ label=_('Privilege name'),
+ primary_key=True,
+ ),
+ Str('description?',
+ cli_name='desc',
+ label=_('Description'),
+ doc=_('Privilege description'),
+ ),
+ )
+
+
+@register()
+class privilege_add(LDAPCreate):
+ __doc__ = _('Add a new privilege.')
+
+ msg_summary = _('Added privilege "%(value)s"')
+
+
+@register()
+class privilege_del(LDAPDelete):
+ __doc__ = _('Delete a privilege.')
+
+ msg_summary = _('Deleted privilege "%(value)s"')
+
+
+@register()
+class privilege_mod(LDAPUpdate):
+ __doc__ = _('Modify a privilege.')
+
+ msg_summary = _('Modified privilege "%(value)s"')
+
+
+@register()
+class privilege_find(LDAPSearch):
+ __doc__ = _('Search for privileges.')
+
+ msg_summary = ngettext(
+ '%(count)d privilege matched', '%(count)d privileges matched', 0
+ )
+
+
+@register()
+class privilege_show(LDAPRetrieve):
+ __doc__ = _('Display information about a privilege.')
+
+
+@register()
+class privilege_add_member(LDAPAddMember):
+ __doc__ = _('Add members to a privilege.')
+
+ NO_CLI=True
+
+
+@register()
+class privilege_remove_member(LDAPRemoveMember):
+ """
+ Remove members from a privilege
+ """
+ NO_CLI=True
+
+
+@register()
+class privilege_add_permission(LDAPAddReverseMember):
+ __doc__ = _('Add permissions to a privilege.')
+
+ show_command = 'privilege_show'
+ member_command = 'permission_add_member'
+ reverse_attr = 'permission'
+ member_attr = 'privilege'
+
+ has_output = (
+ output.Entry('result'),
+ output.Output('failed',
+ type=dict,
+ doc=_('Members that could not be added'),
+ ),
+ output.Output('completed',
+ type=int,
+ doc=_('Number of permissions added'),
+ ),
+ )
+
+ def pre_callback(self, ldap, dn, *keys, **options):
+ if options.get('permission'):
+ # We can only add permissions with bind rule type set to
+ # "permission" (or old-style permissions)
+ validate_permission_to_privilege(self.api, options['permission'])
+ return dn
+
+
+@register()
+class privilege_remove_permission(LDAPRemoveReverseMember):
+ __doc__ = _('Remove permissions from a privilege.')
+
+ show_command = 'privilege_show'
+ member_command = 'permission_remove_member'
+ reverse_attr = 'permission'
+ member_attr = 'privilege'
+
+ permission_count_out = ('%i permission removed.', '%i permissions removed.')
+
+ has_output = (
+ output.Entry('result'),
+ output.Output('failed',
+ type=dict,
+ doc=_('Members that could not be added'),
+ ),
+ output.Output('completed',
+ type=int,
+ doc=_('Number of permissions removed'),
+ ),
+ )
diff --git a/ipaserver/plugins/pwpolicy.py b/ipaserver/plugins/pwpolicy.py
new file mode 100644
index 000000000..5a2202aa0
--- /dev/null
+++ b/ipaserver/plugins/pwpolicy.py
@@ -0,0 +1,611 @@
+# Authors:
+# Pavel Zuna <pzuna@redhat.com>
+# Martin Kosek <mkosek@redhat.com>
+#
+# Copyright (C) 2010 Red Hat
+# see file 'COPYING' for use and warranty information
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+from ipalib import api
+from ipalib import Int, Str, DNParam
+from ipalib import errors
+from .baseldap import (
+ LDAPObject,
+ LDAPCreate,
+ LDAPDelete,
+ LDAPUpdate,
+ LDAPRetrieve,
+ LDAPSearch)
+from ipalib import _
+from ipalib.plugable import Registry
+from ipalib.request import context
+from ipapython.ipautil import run
+from ipapython.dn import DN
+from distutils import version
+
+import six
+
+if six.PY3:
+ unicode = str
+
+__doc__ = _("""
+Password policy
+
+A password policy sets limitations on IPA passwords, including maximum
+lifetime, minimum lifetime, the number of passwords to save in
+history, the number of character classes required (for stronger passwords)
+and the minimum password length.
+
+By default there is a single, global policy for all users. You can also
+create a password policy to apply to a group. Each user is only subject
+to one password policy, either the group policy or the global policy. A
+group policy stands alone; it is not a super-set of the global policy plus
+custom settings.
+
+Each group password policy requires a unique priority setting. If a user
+is in multiple groups that have password policies, this priority determines
+which password policy is applied. A lower value indicates a higher priority
+policy.
+
+Group password policies are automatically removed when the groups they
+are associated with are removed.
+
+EXAMPLES:
+
+ Modify the global policy:
+ ipa pwpolicy-mod --minlength=10
+
+ Add a new group password policy:
+ ipa pwpolicy-add --maxlife=90 --minlife=1 --history=10 --minclasses=3 --minlength=8 --priority=10 localadmins
+
+ Display the global password policy:
+ ipa pwpolicy-show
+
+ Display a group password policy:
+ ipa pwpolicy-show localadmins
+
+ Display the policy that would be applied to a given user:
+ ipa pwpolicy-show --user=tuser1
+
+ Modify a group password policy:
+ ipa pwpolicy-mod --minclasses=2 localadmins
+""")
+
+register = Registry()
+
+@register()
+class cosentry(LDAPObject):
+ """
+ Class of Service object used for linking policies with groups
+ """
+ NO_CLI = True
+
+ container_dn = DN(('cn', 'costemplates'), api.env.container_accounts)
+ object_class = ['top', 'costemplate', 'extensibleobject', 'krbcontainer']
+ permission_filter_objectclasses = ['costemplate']
+ default_attributes = ['cn', 'cospriority', 'krbpwdpolicyreference']
+ managed_permissions = {
+ 'System: Read Group Password Policy costemplate': {
+ 'replaces_global_anonymous_aci': True,
+ 'ipapermright': {'read', 'search', 'compare'},
+ 'ipapermdefaultattr': {
+ 'cn', 'cospriority', 'krbpwdpolicyreference', 'objectclass',
+ },
+ 'default_privileges': {
+ 'Password Policy Readers',
+ 'Password Policy Administrator',
+ },
+ },
+ 'System: Add Group Password Policy costemplate': {
+ 'ipapermright': {'add'},
+ 'replaces': [
+ '(target = "ldap:///cn=*,cn=costemplates,cn=accounts,$SUFFIX")(version 3.0;acl "permission:Add Group Password Policy costemplate";allow (add) groupdn = "ldap:///cn=Add Group Password Policy costemplate,cn=permissions,cn=pbac,$SUFFIX";)',
+ ],
+ 'default_privileges': {'Password Policy Administrator'},
+ },
+ 'System: Delete Group Password Policy costemplate': {
+ 'ipapermright': {'delete'},
+ 'replaces': [
+ '(target = "ldap:///cn=*,cn=costemplates,cn=accounts,$SUFFIX")(version 3.0;acl "permission:Delete Group Password Policy costemplate";allow (delete) groupdn = "ldap:///cn=Delete Group Password Policy costemplate,cn=permissions,cn=pbac,$SUFFIX";)',
+ ],
+ 'default_privileges': {'Password Policy Administrator'},
+ },
+ 'System: Modify Group Password Policy costemplate': {
+ 'ipapermright': {'write'},
+ 'ipapermdefaultattr': {'cospriority'},
+ 'replaces': [
+ '(targetattr = "cospriority")(target = "ldap:///cn=*,cn=costemplates,cn=accounts,$SUFFIX")(version 3.0;acl "permission:Modify Group Password Policy costemplate";allow (write) groupdn = "ldap:///cn=Modify Group Password Policy costemplate,cn=permissions,cn=pbac,$SUFFIX";)',
+ ],
+ 'default_privileges': {'Password Policy Administrator'},
+ },
+ }
+
+ takes_params = (
+ Str('cn', primary_key=True),
+ DNParam('krbpwdpolicyreference'),
+ Int('cospriority', minvalue=0),
+ )
+
+ priority_not_unique_msg = _(
+ 'priority must be a unique value (%(prio)d already used by %(gname)s)'
+ )
+
+ def get_dn(self, *keys, **options):
+ group_dn = self.api.Object.group.get_dn(keys[-1])
+ return self.backend.make_dn_from_attr(
+ 'cn', group_dn, DN(self.container_dn, api.env.basedn)
+ )
+
+ def check_priority_uniqueness(self, *keys, **options):
+ if options.get('cospriority') is not None:
+ entries = self.methods.find(
+ cospriority=options['cospriority']
+ )['result']
+ if len(entries) > 0:
+ group_name = self.api.Object.group.get_primary_key_from_dn(
+ DN(entries[0]['cn'][0]))
+ raise errors.ValidationError(
+ name='priority',
+ error=self.priority_not_unique_msg % {
+ 'prio': options['cospriority'],
+ 'gname': group_name,
+ }
+ )
+
+
+@register()
+class cosentry_add(LDAPCreate):
+ NO_CLI = True
+
+ def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options):
+ assert isinstance(dn, DN)
+
+ # check for existence of the group
+ group_dn = self.api.Object.group.get_dn(keys[-1])
+ try:
+ result = ldap.get_entry(group_dn, ['objectclass'])
+ except errors.NotFound:
+ self.api.Object.group.handle_not_found(keys[-1])
+
+ oc = [x.lower() for x in result['objectclass']]
+ if 'mepmanagedentry' in oc:
+ raise errors.ManagedPolicyError()
+ self.obj.check_priority_uniqueness(*keys, **options)
+ del entry_attrs['cn']
+ return dn
+
+
+@register()
+class cosentry_del(LDAPDelete):
+ NO_CLI = True
+
+
+@register()
+class cosentry_mod(LDAPUpdate):
+ NO_CLI = True
+
+ def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options):
+ assert isinstance(dn, DN)
+ new_cospriority = options.get('cospriority')
+ if new_cospriority is not None:
+ cos_entry = self.api.Command.cosentry_show(keys[-1])['result']
+ old_cospriority = int(cos_entry['cospriority'][0])
+
+ # check uniqueness only when the new priority differs
+ if old_cospriority != new_cospriority:
+ self.obj.check_priority_uniqueness(*keys, **options)
+ return dn
+
+
+@register()
+class cosentry_show(LDAPRetrieve):
+ NO_CLI = True
+
+
+@register()
+class cosentry_find(LDAPSearch):
+ NO_CLI = True
+
+
+global_policy_name = 'global_policy'
+global_policy_dn = DN(('cn', global_policy_name), ('cn', api.env.realm), ('cn', 'kerberos'), api.env.basedn)
+
+@register()
+class pwpolicy(LDAPObject):
+ """
+ Password Policy object
+ """
+ container_dn = DN(('cn', api.env.realm), ('cn', 'kerberos'))
+ object_name = _('password policy')
+ object_name_plural = _('password policies')
+ object_class = ['top', 'nscontainer', 'krbpwdpolicy']
+ permission_filter_objectclasses = ['krbpwdpolicy']
+ default_attributes = [
+ 'cn', 'cospriority', 'krbmaxpwdlife', 'krbminpwdlife',
+ 'krbpwdhistorylength', 'krbpwdmindiffchars', 'krbpwdminlength',
+ 'krbpwdmaxfailure', 'krbpwdfailurecountinterval',
+ 'krbpwdlockoutduration',
+ ]
+ managed_permissions = {
+ 'System: Read Group Password Policy': {
+ 'replaces_global_anonymous_aci': True,
+ 'ipapermbindruletype': 'permission',
+ 'ipapermright': {'read', 'search', 'compare'},
+ 'ipapermdefaultattr': {
+ 'cn', 'cospriority', 'krbmaxpwdlife', 'krbminpwdlife',
+ 'krbpwdfailurecountinterval', 'krbpwdhistorylength',
+ 'krbpwdlockoutduration', 'krbpwdmaxfailure',
+ 'krbpwdmindiffchars', 'krbpwdminlength', 'objectclass',
+ },
+ 'default_privileges': {
+ 'Password Policy Readers',
+ 'Password Policy Administrator',
+ },
+ },
+ 'System: Add Group Password Policy': {
+ 'ipapermright': {'add'},
+ 'replaces': [
+ '(target = "ldap:///cn=*,cn=$REALM,cn=kerberos,$SUFFIX")(version 3.0;acl "permission:Add Group Password Policy";allow (add) groupdn = "ldap:///cn=Add Group Password Policy,cn=permissions,cn=pbac,$SUFFIX";)',
+ ],
+ 'default_privileges': {'Password Policy Administrator'},
+ },
+ 'System: Delete Group Password Policy': {
+ 'ipapermright': {'delete'},
+ 'replaces': [
+ '(target = "ldap:///cn=*,cn=$REALM,cn=kerberos,$SUFFIX")(version 3.0;acl "permission:Delete Group Password Policy";allow (delete) groupdn = "ldap:///cn=Delete Group Password Policy,cn=permissions,cn=pbac,$SUFFIX";)',
+ ],
+ 'default_privileges': {'Password Policy Administrator'},
+ },
+ 'System: Modify Group Password Policy': {
+ 'ipapermright': {'write'},
+ 'ipapermdefaultattr': {
+ 'krbmaxpwdlife', 'krbminpwdlife', 'krbpwdfailurecountinterval',
+ 'krbpwdhistorylength', 'krbpwdlockoutduration',
+ 'krbpwdmaxfailure', 'krbpwdmindiffchars', 'krbpwdminlength'
+ },
+ 'replaces': [
+ '(targetattr = "krbmaxpwdlife || krbminpwdlife || krbpwdhistorylength || krbpwdmindiffchars || krbpwdminlength || krbpwdmaxfailure || krbpwdfailurecountinterval || krbpwdlockoutduration")(target = "ldap:///cn=*,cn=$REALM,cn=kerberos,$SUFFIX")(version 3.0;acl "permission:Modify Group Password Policy";allow (write) groupdn = "ldap:///cn=Modify Group Password Policy,cn=permissions,cn=pbac,$SUFFIX";)',
+ ],
+ 'default_privileges': {'Password Policy Administrator'},
+ },
+ }
+
+ MIN_KRB5KDC_WITH_LOCKOUT = "1.8"
+ has_lockout = False
+ lockout_params = ()
+
+ result = run(['klist', '-V'], raiseonerr=False, capture_output=True)
+ if result.returncode == 0:
+ verstr = result.output.split()[-1]
+ ver = version.LooseVersion(verstr)
+ min = version.LooseVersion(MIN_KRB5KDC_WITH_LOCKOUT)
+ if ver >= min:
+ has_lockout = True
+
+ if has_lockout:
+ lockout_params = (
+ Int('krbpwdmaxfailure?',
+ cli_name='maxfail',
+ label=_('Max failures'),
+ doc=_('Consecutive failures before lockout'),
+ minvalue=0,
+ ),
+ Int('krbpwdfailurecountinterval?',
+ cli_name='failinterval',
+ label=_('Failure reset interval'),
+ doc=_('Period after which failure count will be reset (seconds)'),
+ minvalue=0,
+ ),
+ Int('krbpwdlockoutduration?',
+ cli_name='lockouttime',
+ label=_('Lockout duration'),
+ doc=_('Period for which lockout is enforced (seconds)'),
+ minvalue=0,
+ ),
+ )
+
+ label = _('Password Policies')
+ label_singular = _('Password Policy')
+
+ takes_params = (
+ Str('cn?',
+ cli_name='group',
+ label=_('Group'),
+ doc=_('Manage password policy for specific group'),
+ primary_key=True,
+ ),
+ Int('krbmaxpwdlife?',
+ cli_name='maxlife',
+ label=_('Max lifetime (days)'),
+ doc=_('Maximum password lifetime (in days)'),
+ minvalue=0,
+ maxvalue=20000, # a little over 54 years
+ ),
+ Int('krbminpwdlife?',
+ cli_name='minlife',
+ label=_('Min lifetime (hours)'),
+ doc=_('Minimum password lifetime (in hours)'),
+ minvalue=0,
+ ),
+ Int('krbpwdhistorylength?',
+ cli_name='history',
+ label=_('History size'),
+ doc=_('Password history size'),
+ minvalue=0,
+ ),
+ Int('krbpwdmindiffchars?',
+ cli_name='minclasses',
+ label=_('Character classes'),
+ doc=_('Minimum number of character classes'),
+ minvalue=0,
+ maxvalue=5,
+ ),
+ Int('krbpwdminlength?',
+ cli_name='minlength',
+ label=_('Min length'),
+ doc=_('Minimum length of password'),
+ minvalue=0,
+ ),
+ Int('cospriority',
+ cli_name='priority',
+ label=_('Priority'),
+ doc=_('Priority of the policy (higher number means lower priority'),
+ minvalue=0,
+ flags=('virtual_attribute',),
+ ),
+ ) + lockout_params
+
+ def get_dn(self, *keys, **options):
+ if keys[-1] is not None:
+ return self.backend.make_dn_from_attr(
+ self.primary_key.name, keys[-1],
+ DN(self.container_dn, api.env.basedn)
+ )
+ return global_policy_dn
+
+ def convert_time_for_output(self, entry_attrs, **options):
+ # Convert seconds to hours and days for displaying to user
+ if not options.get('raw', False):
+ if 'krbmaxpwdlife' in entry_attrs:
+ entry_attrs['krbmaxpwdlife'][0] = unicode(
+ int(entry_attrs['krbmaxpwdlife'][0]) // 86400
+ )
+ if 'krbminpwdlife' in entry_attrs:
+ entry_attrs['krbminpwdlife'][0] = unicode(
+ int(entry_attrs['krbminpwdlife'][0]) // 3600
+ )
+
+ def convert_time_on_input(self, entry_attrs):
+ # Convert hours and days to seconds for writing to LDAP
+ if 'krbmaxpwdlife' in entry_attrs and entry_attrs['krbmaxpwdlife']:
+ entry_attrs['krbmaxpwdlife'] = entry_attrs['krbmaxpwdlife'] * 86400
+ if 'krbminpwdlife' in entry_attrs and entry_attrs['krbminpwdlife']:
+ entry_attrs['krbminpwdlife'] = entry_attrs['krbminpwdlife'] * 3600
+
+ def validate_lifetime(self, entry_attrs, add=False, *keys):
+ """
+ Ensure that the maximum lifetime is greater than the minimum.
+ If there is no minimum lifetime set then don't return an error.
+ """
+ maxlife=entry_attrs.get('krbmaxpwdlife', None)
+ minlife=entry_attrs.get('krbminpwdlife', None)
+ existing_entry = {}
+ if not add: # then read existing entry
+ existing_entry = self.api.Command.pwpolicy_show(keys[-1],
+ all=True,
+ )['result']
+ if minlife is None and 'krbminpwdlife' in existing_entry:
+ minlife = int(existing_entry['krbminpwdlife'][0]) * 3600
+ if maxlife is None and 'krbmaxpwdlife' in existing_entry:
+ maxlife = int(existing_entry['krbmaxpwdlife'][0]) * 86400
+
+ if maxlife is not None and minlife is not None:
+ if minlife > maxlife:
+ raise errors.ValidationError(
+ name='maxlife',
+ error=_('Maximum password life must be greater than minimum.'),
+ )
+
+ def add_cospriority(self, entry, pwpolicy_name, rights=True):
+ if pwpolicy_name and pwpolicy_name != global_policy_name:
+ cos_entry = self.api.Command.cosentry_show(
+ pwpolicy_name,
+ rights=rights, all=rights
+ )['result']
+ if cos_entry.get('cospriority') is not None:
+ entry['cospriority'] = cos_entry['cospriority']
+ if rights:
+ entry['attributelevelrights']['cospriority'] = \
+ cos_entry['attributelevelrights']['cospriority']
+
+
+@register()
+class pwpolicy_add(LDAPCreate):
+ __doc__ = _('Add a new group password policy.')
+
+ def get_args(self):
+ yield self.obj.primary_key.clone(attribute=True, required=True)
+
+ def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options):
+ assert isinstance(dn, DN)
+ self.obj.convert_time_on_input(entry_attrs)
+ self.obj.validate_lifetime(entry_attrs, True)
+ self.api.Command.cosentry_add(
+ keys[-1], krbpwdpolicyreference=dn,
+ cospriority=options.get('cospriority')
+ )
+ return dn
+
+ def post_callback(self, ldap, dn, entry_attrs, *keys, **options):
+ assert isinstance(dn, DN)
+ self.log.info('%r' % entry_attrs)
+ # attribute rights are not allowed for pwpolicy_add
+ self.obj.add_cospriority(entry_attrs, keys[-1], rights=False)
+ self.obj.convert_time_for_output(entry_attrs, **options)
+ return dn
+
+
+@register()
+class pwpolicy_del(LDAPDelete):
+ __doc__ = _('Delete a group password policy.')
+
+ def get_args(self):
+ yield self.obj.primary_key.clone(
+ attribute=True, required=True, multivalue=True
+ )
+
+ def pre_callback(self, ldap, dn, *keys, **options):
+ assert isinstance(dn, DN)
+ if dn == global_policy_dn:
+ raise errors.ValidationError(
+ name='group',
+ error=_('cannot delete global password policy')
+ )
+ return dn
+
+ def post_callback(self, ldap, dn, *keys, **options):
+ assert isinstance(dn, DN)
+ try:
+ self.api.Command.cosentry_del(keys[-1])
+ except errors.NotFound:
+ pass
+ return True
+
+
+@register()
+class pwpolicy_mod(LDAPUpdate):
+ __doc__ = _('Modify a group password policy.')
+
+ def execute(self, cn=None, **options):
+ return super(pwpolicy_mod, self).execute(cn, **options)
+
+ def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options):
+ assert isinstance(dn, DN)
+ self.obj.convert_time_on_input(entry_attrs)
+ self.obj.validate_lifetime(entry_attrs, False, *keys)
+ setattr(context, 'cosupdate', False)
+ if options.get('cospriority') is not None:
+ if keys[-1] is None:
+ raise errors.ValidationError(
+ name='priority',
+ error=_('priority cannot be set on global policy')
+ )
+ try:
+ self.api.Command.cosentry_mod(
+ keys[-1], cospriority=options['cospriority']
+ )
+ except errors.EmptyModlist as e:
+ if len(entry_attrs) == 1: # cospriority only was passed
+ raise e
+ else:
+ setattr(context, 'cosupdate', True)
+ return dn
+
+ def post_callback(self, ldap, dn, entry_attrs, *keys, **options):
+ assert isinstance(dn, DN)
+ rights = options.get('all', False) and options.get('rights', False)
+ self.obj.add_cospriority(entry_attrs, keys[-1], rights)
+ self.obj.convert_time_for_output(entry_attrs, **options)
+ return dn
+
+ def exc_callback(self, keys, options, exc, call_func, *call_args, **call_kwargs):
+ if call_func.__name__ == 'update_entry':
+ if isinstance(exc, errors.EmptyModlist):
+ entry_attrs = call_args[0]
+ cosupdate = getattr(context, 'cosupdate')
+ if not entry_attrs or cosupdate:
+ return
+ raise exc
+
+
+@register()
+class pwpolicy_show(LDAPRetrieve):
+ __doc__ = _('Display information about password policy.')
+
+ takes_options = LDAPRetrieve.takes_options + (
+ Str('user?',
+ label=_('User'),
+ doc=_('Display effective policy for a specific user'),
+ ),
+ )
+
+ def execute(self, cn=None, **options):
+ return super(pwpolicy_show, self).execute(cn, **options)
+
+ def pre_callback(self, ldap, dn, attrs_list, *keys, **options):
+ assert isinstance(dn, DN)
+ if options.get('user') is not None:
+ user_entry = self.api.Command.user_show(
+ options['user'], all=True
+ )['result']
+ if 'krbpwdpolicyreference' in user_entry:
+ return user_entry.get('krbpwdpolicyreference', [dn])[0]
+ return dn
+
+ def post_callback(self, ldap, dn, entry_attrs, *keys, **options):
+ assert isinstance(dn, DN)
+ rights = options.get('all', False) and options.get('rights', False)
+ self.obj.add_cospriority(entry_attrs, keys[-1], rights)
+ self.obj.convert_time_for_output(entry_attrs, **options)
+ return dn
+
+
+@register()
+class pwpolicy_find(LDAPSearch):
+ __doc__ = _('Search for group password policies.')
+
+ # this command does custom sorting in post_callback
+ sort_result_entries = False
+
+ def priority_sort_key(self, entry):
+ """Key for sorting password policies
+
+ returns a pair: (is_global, priority)
+ """
+ # global policy will be always last in the output
+ if entry['cn'][0] == global_policy_name:
+ return True, 0
+ else:
+ # policies with higher priority (lower number) will be at the
+ # beginning of the list
+ try:
+ cospriority = int(entry['cospriority'][0])
+ except KeyError:
+ # if cospriority is not present in the entry, rather return 0
+ # than crash
+ cospriority = 0
+ return False, cospriority
+
+ def post_callback(self, ldap, entries, truncated, *args, **options):
+ for e in entries:
+ # When pkey_only flag is on, entries should contain only a cn.
+ # Add a cospriority attribute that will be used for sorting.
+ # Attribute rights are not allowed for pwpolicy_find.
+ self.obj.add_cospriority(e, e['cn'][0], rights=False)
+
+ self.obj.convert_time_for_output(e, **options)
+
+ # do custom entry sorting by its cospriority
+ entries.sort(key=self.priority_sort_key)
+
+ if options.get('pkey_only', False):
+ # remove cospriority that was used for sorting
+ for e in entries:
+ try:
+ del e['cospriority']
+ except KeyError:
+ pass
+
+ return truncated
diff --git a/ipaserver/plugins/radiusproxy.py b/ipaserver/plugins/radiusproxy.py
new file mode 100644
index 000000000..44d87b9ae
--- /dev/null
+++ b/ipaserver/plugins/radiusproxy.py
@@ -0,0 +1,175 @@
+# Authors:
+# Nathaniel McCallum <npmccallum@redhat.com>
+#
+# Copyright (C) 2013 Red Hat
+# see file 'COPYING' for use and warranty information
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+from .baseldap import (
+ LDAPObject,
+ LDAPCreate,
+ LDAPDelete,
+ LDAPUpdate,
+ LDAPSearch,
+ LDAPRetrieve)
+from ipalib import api, Str, Int, Password, _, ngettext
+from ipalib import errors
+from ipalib.plugable import Registry
+from ipalib.util import validate_hostname, validate_ipaddr
+from ipalib.errors import ValidationError
+import re
+
+__doc__ = _("""
+RADIUS Proxy Servers
+""") + _("""
+Manage RADIUS Proxy Servers.
+""") + _("""
+IPA supports the use of an external RADIUS proxy server for krb5 OTP
+authentications. This permits a great deal of flexibility when
+integrating with third-party authentication services.
+""") + _("""
+EXAMPLES:
+""") + _("""
+ Add a new server:
+ ipa radiusproxy-add MyRADIUS --server=radius.example.com:1812
+""") + _("""
+ Find all servers whose entries include the string "example.com":
+ ipa radiusproxy-find example.com
+""") + _("""
+ Examine the configuration:
+ ipa radiusproxy-show MyRADIUS
+""") + _("""
+ Change the secret:
+ ipa radiusproxy-mod MyRADIUS --secret
+""") + _("""
+ Delete a configuration:
+ ipa radiusproxy-del MyRADIUS
+""")
+
+register = Registry()
+
+LDAP_ATTRIBUTE = re.compile("^[a-zA-Z][a-zA-Z0-9-]*$")
+def validate_attributename(ugettext, attr):
+ if not LDAP_ATTRIBUTE.match(attr):
+ raise ValidationError(name="ipatokenusermapattribute",
+ error=_('invalid attribute name'))
+
+def validate_radiusserver(ugettext, server):
+ split = server.rsplit(':', 1)
+ server = split[0]
+ if len(split) == 2:
+ try:
+ port = int(split[1])
+ if (port < 0 or port > 65535):
+ raise ValueError()
+ except ValueError:
+ raise ValidationError(name="ipatokenradiusserver",
+ error=_('invalid port number'))
+
+ if validate_ipaddr(server):
+ return
+
+ try:
+ validate_hostname(server, check_fqdn=True, allow_underscore=True)
+ except ValueError as e:
+ raise errors.ValidationError(name="ipatokenradiusserver",
+ error=str(e))
+
+
+@register()
+class radiusproxy(LDAPObject):
+ """
+ RADIUS Server object.
+ """
+ container_dn = api.env.container_radiusproxy
+ object_name = _('RADIUS proxy server')
+ object_name_plural = _('RADIUS proxy servers')
+ object_class = ['ipatokenradiusconfiguration']
+ default_attributes = ['cn', 'description', 'ipatokenradiusserver',
+ 'ipatokenradiustimeout', 'ipatokenradiusretries', 'ipatokenusermapattribute'
+ ]
+ search_attributes = ['cn', 'description', 'ipatokenradiusserver']
+ rdn_is_primary_key = True
+ label = _('RADIUS Servers')
+ label_singular = _('RADIUS Server')
+
+ takes_params = (
+ Str('cn',
+ cli_name='name',
+ label=_('RADIUS proxy server name'),
+ primary_key=True,
+ ),
+ Str('description?',
+ cli_name='desc',
+ label=_('Description'),
+ doc=_('A description of this RADIUS proxy server'),
+ ),
+ Str('ipatokenradiusserver+', validate_radiusserver,
+ cli_name='server',
+ label=_('Server'),
+ doc=_('The hostname or IP (with or without port)'),
+ ),
+ Password('ipatokenradiussecret',
+ cli_name='secret',
+ label=_('Secret'),
+ doc=_('The secret used to encrypt data'),
+ confirm=True,
+ flags=['no_option'],
+ ),
+ Int('ipatokenradiustimeout?',
+ cli_name='timeout',
+ label=_('Timeout'),
+ doc=_('The total timeout across all retries (in seconds)'),
+ minvalue=1,
+ ),
+ Int('ipatokenradiusretries?',
+ cli_name='retries',
+ label=_('Retries'),
+ doc=_('The number of times to retry authentication'),
+ minvalue=0,
+ maxvalue=10,
+ ),
+ Str('ipatokenusermapattribute?', validate_attributename,
+ cli_name='userattr',
+ label=_('User attribute'),
+ doc=_('The username attribute on the user object'),
+ ),
+ )
+
+@register()
+class radiusproxy_add(LDAPCreate):
+ __doc__ = _('Add a new RADIUS proxy server.')
+ msg_summary = _('Added RADIUS proxy server "%(value)s"')
+
+@register()
+class radiusproxy_del(LDAPDelete):
+ __doc__ = _('Delete a RADIUS proxy server.')
+ msg_summary = _('Deleted RADIUS proxy server "%(value)s"')
+
+@register()
+class radiusproxy_mod(LDAPUpdate):
+ __doc__ = _('Modify a RADIUS proxy server.')
+ msg_summary = _('Modified RADIUS proxy server "%(value)s"')
+
+@register()
+class radiusproxy_find(LDAPSearch):
+ __doc__ = _('Search for RADIUS proxy servers.')
+ msg_summary = ngettext(
+ '%(count)d RADIUS proxy server matched', '%(count)d RADIUS proxy servers matched', 0
+ )
+
+@register()
+class radiusproxy_show(LDAPRetrieve):
+ __doc__ = _('Display information about a RADIUS proxy server.')
diff --git a/ipaserver/plugins/realmdomains.py b/ipaserver/plugins/realmdomains.py
new file mode 100644
index 000000000..3f8561091
--- /dev/null
+++ b/ipaserver/plugins/realmdomains.py
@@ -0,0 +1,340 @@
+# Authors:
+# Ana Krivokapic <akrivoka@redhat.com>
+#
+# Copyright (C) 2013 Red Hat
+# see file 'COPYING' for use and warranty information
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+import six
+
+from ipalib import api, errors, messages
+from ipalib import Str, Flag
+from ipalib import _
+from ipalib.plugable import Registry
+from .baseldap import LDAPObject, LDAPUpdate, LDAPRetrieve
+from ipalib.util import has_soa_or_ns_record, validate_domain_name
+from ipalib.util import detect_dns_zone_realm_type
+from ipapython.dn import DN
+from ipapython.ipautil import get_domain_name
+
+if six.PY3:
+ unicode = str
+
+__doc__ = _("""
+Realm domains
+
+Manage the list of domains associated with IPA realm.
+
+EXAMPLES:
+
+ Display the current list of realm domains:
+ ipa realmdomains-show
+
+ Replace the list of realm domains:
+ ipa realmdomains-mod --domain=example.com
+ ipa realmdomains-mod --domain={example1.com,example2.com,example3.com}
+
+ Add a domain to the list of realm domains:
+ ipa realmdomains-mod --add-domain=newdomain.com
+
+ Delete a domain from the list of realm domains:
+ ipa realmdomains-mod --del-domain=olddomain.com
+""")
+
+register = Registry()
+
+def _domain_name_normalizer(d):
+ return d.lower().rstrip('.')
+
+def _domain_name_validator(ugettext, value):
+ try:
+ validate_domain_name(value, allow_slash=False)
+ except ValueError as e:
+ return unicode(e)
+
+
+@register()
+class realmdomains(LDAPObject):
+ """
+ List of domains associated with IPA realm.
+ """
+ container_dn = api.env.container_realm_domains
+ permission_filter_objectclasses = ['domainrelatedobject']
+ object_name = _('Realm domains')
+ search_attributes = ['associateddomain']
+ default_attributes = ['associateddomain']
+ managed_permissions = {
+ 'System: Read Realm Domains': {
+ 'replaces_global_anonymous_aci': True,
+ 'ipapermbindruletype': 'all',
+ 'ipapermright': {'read', 'search', 'compare'},
+ 'ipapermdefaultattr': {
+ 'objectclass', 'cn', 'associateddomain',
+ },
+ },
+ 'System: Modify Realm Domains': {
+ 'ipapermbindruletype': 'permission',
+ 'ipapermright': {'write'},
+ 'ipapermdefaultattr': {
+ 'associatedDomain',
+ },
+ 'default_privileges': {'DNS Administrators'},
+ },
+ }
+
+ label = _('Realm Domains')
+ label_singular = _('Realm Domains')
+
+ takes_params = (
+ Str('associateddomain+',
+ _domain_name_validator,
+ normalizer=_domain_name_normalizer,
+ cli_name='domain',
+ label=_('Domain'),
+ ),
+ Str('add_domain?',
+ _domain_name_validator,
+ normalizer=_domain_name_normalizer,
+ cli_name='add_domain',
+ label=_('Add domain'),
+ ),
+ Str('del_domain?',
+ _domain_name_validator,
+ normalizer=_domain_name_normalizer,
+ cli_name='del_domain',
+ label=_('Delete domain'),
+ ),
+ )
+
+
+
+@register()
+class realmdomains_mod(LDAPUpdate):
+ __doc__ = _('Modify realm domains.')
+
+ takes_options = LDAPUpdate.takes_options + (
+ Flag('force',
+ label=_('Force'),
+ doc=_('Force adding domain even if not in DNS'),
+ ),
+ )
+
+ def validate_domains(self, domains, force):
+ """
+ Validates the list of domains as candidates for additions to the
+ realmdomains list.
+
+ Requirements:
+ - Each domain has SOA or NS record
+ - Each domain belongs to the current realm
+ """
+
+ # Unless forced, check that each domain has SOA or NS records
+ if not force:
+ invalid_domains = [
+ d for d in domains
+ if not has_soa_or_ns_record(d)
+ ]
+
+ if invalid_domains:
+ raise errors.ValidationError(
+ name='domain',
+ error= _(
+ "DNS zone for each realmdomain must contain "
+ "SOA or NS records. No records found for: %s"
+ ) % ','.join(invalid_domains)
+ )
+
+ # Check realm alliegence for each domain
+ domains_with_realm = [
+ (domain, detect_dns_zone_realm_type(self.api, domain))
+ for domain in domains
+ ]
+
+ foreign_domains = [
+ domain for domain, realm in domains_with_realm
+ if realm == 'foreign'
+ ]
+
+ unknown_domains = [
+ domain for domain, realm in domains_with_realm
+ if realm == 'unknown'
+ ]
+
+ # If there are any foreing realm domains, bail out
+ if foreign_domains:
+ raise errors.ValidationError(
+ name='domain',
+ error=_(
+ 'The following domains do not belong '
+ 'to this realm: %(domains)s'
+ ) % dict(domains=','.join(foreign_domains))
+ )
+
+ # If there are any unknown domains, error out,
+ # asking for _kerberos TXT records
+
+ # Note: This can be forced, since realmdomains-mod
+ # is called from dnszone-add where we know that
+ # the domain being added belongs to our realm
+ if not force and unknown_domains:
+ raise errors.ValidationError(
+ name='domain',
+ error=_(
+ 'The realm of the following domains could '
+ 'not be detected: %(domains)s. If these are '
+ 'domains that belong to the this realm, please '
+ 'create a _kerberos TXT record containing "%(realm)s" '
+ 'in each of them.'
+ ) % dict(domains=','.join(unknown_domains),
+ realm=self.api.env.realm)
+ )
+
+ def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options):
+ assert isinstance(dn, DN)
+ associateddomain = entry_attrs.get('associateddomain')
+ add_domain = entry_attrs.get('add_domain')
+ del_domain = entry_attrs.get('del_domain')
+ force = options.get('force')
+
+ current_domain = get_domain_name()
+
+ # User specified the list of domains explicitly
+ if associateddomain:
+ if add_domain or del_domain:
+ raise errors.MutuallyExclusiveError(
+ reason=_(
+ "The --domain option cannot be used together "
+ "with --add-domain or --del-domain. Use --domain "
+ "to specify the whole realm domain list explicitly, "
+ "to add/remove individual domains, use "
+ "--add-domain/del-domain.")
+ )
+
+ # Make sure our domain is included in the list
+ if current_domain not in associateddomain:
+ raise errors.ValidationError(
+ name='realmdomain list',
+ error=_("IPA server domain cannot be omitted")
+ )
+
+ # Validate that each domain satisfies the requirements
+ # for realmdomain
+ self.validate_domains(domains=associateddomain, force=force)
+
+ return dn
+
+ # If --add-domain or --del-domain options were provided, read
+ # the curent list from LDAP, modify it, and write the changes back
+ domains = ldap.get_entry(dn)['associateddomain']
+
+ if add_domain:
+ self.validate_domains(domains=[add_domain], force=force)
+ del entry_attrs['add_domain']
+ domains.append(add_domain)
+
+ if del_domain:
+ if del_domain == current_domain:
+ raise errors.ValidationError(
+ name='del_domain',
+ error=_("IPA server domain cannot be deleted")
+ )
+ del entry_attrs['del_domain']
+
+ try:
+ domains.remove(del_domain)
+ except ValueError:
+ raise errors.AttrValueNotFound(
+ attr='associateddomain',
+ value=del_domain
+ )
+
+ entry_attrs['associateddomain'] = domains
+ return dn
+
+ def execute(self, *keys, **options):
+ dn = self.obj.get_dn(*keys, **options)
+ ldap = self.obj.backend
+
+ domains_old = set(ldap.get_entry(dn)['associateddomain'])
+ result = super(realmdomains_mod, self).execute(*keys, **options)
+ domains_new = set(ldap.get_entry(dn)['associateddomain'])
+
+ domains_added = domains_new - domains_old
+ domains_deleted = domains_old - domains_new
+
+ # Add a _kerberos TXT record for zones that correspond with
+ # domains which were added
+ for domain in domains_added:
+
+ # Skip our own domain
+ if domain == api.env.domain:
+ continue
+
+ try:
+ self.api.Command['dnsrecord_add'](
+ unicode(domain),
+ u'_kerberos',
+ txtrecord=api.env.realm
+ )
+ except (errors.EmptyModlist, errors.NotFound,
+ errors.ValidationError) as error:
+
+ # If creation of the _kerberos TXT record failed, prompt
+ # for manual intervention
+ messages.add_message(
+ options['version'],
+ result,
+ messages.KerberosTXTRecordCreationFailure(
+ domain=domain,
+ error=unicode(error),
+ realm=self.api.env.realm
+ )
+ )
+
+ # Delete _kerberos TXT record from zones that correspond with
+ # domains which were deleted
+ for domain in domains_deleted:
+
+ # Skip our own domain
+ if domain == api.env.domain:
+ continue
+
+ try:
+ self.api.Command['dnsrecord_del'](
+ unicode(domain),
+ u'_kerberos',
+ txtrecord=api.env.realm
+ )
+ except (errors.AttrValueNotFound, errors.NotFound,
+ errors.ValidationError) as error:
+ # If deletion of the _kerberos TXT record failed, prompt
+ # for manual intervention
+ messages.add_message(
+ options['version'],
+ result,
+ messages.KerberosTXTRecordDeletionFailure(
+ domain=domain, error=unicode(error)
+ )
+ )
+
+ return result
+
+
+
+@register()
+class realmdomains_show(LDAPRetrieve):
+ __doc__ = _('Display the list of realm domains.')
+
diff --git a/ipaserver/plugins/role.py b/ipaserver/plugins/role.py
new file mode 100644
index 000000000..f4f0c98d9
--- /dev/null
+++ b/ipaserver/plugins/role.py
@@ -0,0 +1,252 @@
+# Authors:
+# Rob Crittenden <rcritten@redhat.com>
+# Pavel Zuna <pzuna@redhat.com>
+#
+# Copyright (C) 2009 Red Hat
+# see file 'COPYING' for use and warranty information
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+from ipalib.plugable import Registry
+from .baseldap import (
+ LDAPObject,
+ LDAPCreate,
+ LDAPDelete,
+ LDAPUpdate,
+ LDAPSearch,
+ LDAPRetrieve,
+ LDAPAddMember,
+ LDAPRemoveMember,
+ LDAPAddReverseMember,
+ LDAPRemoveReverseMember)
+from ipalib import api, Str, _, ngettext
+from ipalib import output
+
+__doc__ = _("""
+Roles
+
+A role is used for fine-grained delegation. A permission grants the ability
+to perform given low-level tasks (add a user, modify a group, etc.). A
+privilege combines one or more permissions into a higher-level abstraction
+such as useradmin. A useradmin would be able to add, delete and modify users.
+
+Privileges are assigned to Roles.
+
+Users, groups, hosts and hostgroups may be members of a Role.
+
+Roles can not contain other roles.
+
+EXAMPLES:
+
+ Add a new role:
+ ipa role-add --desc="Junior-level admin" junioradmin
+
+ Add some privileges to this role:
+ ipa role-add-privilege --privileges=addusers junioradmin
+ ipa role-add-privilege --privileges=change_password junioradmin
+ ipa role-add-privilege --privileges=add_user_to_default_group junioradmin
+
+ Add a group of users to this role:
+ ipa group-add --desc="User admins" useradmins
+ ipa role-add-member --groups=useradmins junioradmin
+
+ Display information about a role:
+ ipa role-show junioradmin
+
+ The result of this is that any users in the group 'junioradmin' can
+ add users, reset passwords or add a user to the default IPA user group.
+""")
+
+register = Registry()
+
+@register()
+class role(LDAPObject):
+ """
+ Role object.
+ """
+ container_dn = api.env.container_rolegroup
+ object_name = _('role')
+ object_name_plural = _('roles')
+ object_class = ['groupofnames', 'nestedgroup']
+ permission_filter_objectclasses = ['groupofnames']
+ default_attributes = ['cn', 'description', 'member', 'memberof']
+ # Role could have a lot of indirect members, but they are not in
+ # attribute_members therefore they don't have to be in default_attributes
+ # 'memberindirect', 'memberofindirect',
+
+ attribute_members = {
+ 'member': ['user', 'group', 'host', 'hostgroup', 'service'],
+ 'memberof': ['privilege'],
+ }
+ reverse_members = {
+ 'member': ['privilege'],
+ }
+ rdn_is_primary_key = True
+ managed_permissions = {
+ 'System: Read Roles': {
+ 'replaces_global_anonymous_aci': True,
+ 'ipapermright': {'read', 'search', 'compare'},
+ 'ipapermdefaultattr': {
+ 'businesscategory', 'cn', 'description', 'member', 'memberof',
+ 'o', 'objectclass', 'ou', 'owner', 'seealso', 'memberuser',
+ 'memberhost',
+ },
+ 'default_privileges': {'RBAC Readers'},
+ },
+ 'System: Add Roles': {
+ 'ipapermright': {'add'},
+ 'replaces': [
+ '(target = "ldap:///cn=*,cn=roles,cn=accounts,$SUFFIX")(version 3.0;acl "permission:Add Roles";allow (add) groupdn = "ldap:///cn=Add Roles,cn=permissions,cn=pbac,$SUFFIX";)',
+ ],
+ 'default_privileges': {'Delegation Administrator'},
+ },
+ 'System: Modify Role Membership': {
+ 'ipapermright': {'write'},
+ 'ipapermdefaultattr': {'member'},
+ 'replaces': [
+ '(targetattr = "member")(target = "ldap:///cn=*,cn=roles,cn=accounts,$SUFFIX")(version 3.0;acl "permission:Modify Role membership";allow (write) groupdn = "ldap:///cn=Modify Role membership,cn=permissions,cn=pbac,$SUFFIX";)',
+ ],
+ 'default_privileges': {'Delegation Administrator'},
+ },
+ 'System: Modify Roles': {
+ 'ipapermright': {'write'},
+ 'ipapermdefaultattr': {'cn', 'description'},
+ 'replaces': [
+ '(targetattr = "cn || description")(target = "ldap:///cn=*,cn=roles,cn=accounts,$SUFFIX")(version 3.0; acl "permission:Modify Roles";allow (write) groupdn = "ldap:///cn=Modify Roles,cn=permissions,cn=pbac,$SUFFIX";)',
+ ],
+ 'default_privileges': {'Delegation Administrator'},
+ },
+ 'System: Remove Roles': {
+ 'ipapermright': {'delete'},
+ 'replaces': [
+ '(target = "ldap:///cn=*,cn=roles,cn=accounts,$SUFFIX")(version 3.0;acl "permission:Remove Roles";allow (delete) groupdn = "ldap:///cn=Remove Roles,cn=permissions,cn=pbac,$SUFFIX";)',
+ ],
+ 'default_privileges': {'Delegation Administrator'},
+ },
+ }
+
+ label = _('Roles')
+ label_singular = _('Role')
+
+ takes_params = (
+ Str('cn',
+ cli_name='name',
+ label=_('Role name'),
+ primary_key=True,
+ ),
+ Str('description?',
+ cli_name='desc',
+ label=_('Description'),
+ doc=_('A description of this role-group'),
+ ),
+ )
+
+
+
+@register()
+class role_add(LDAPCreate):
+ __doc__ = _('Add a new role.')
+
+ msg_summary = _('Added role "%(value)s"')
+
+
+
+@register()
+class role_del(LDAPDelete):
+ __doc__ = _('Delete a role.')
+
+ msg_summary = _('Deleted role "%(value)s"')
+
+
+
+@register()
+class role_mod(LDAPUpdate):
+ __doc__ = _('Modify a role.')
+
+ msg_summary = _('Modified role "%(value)s"')
+
+
+
+@register()
+class role_find(LDAPSearch):
+ __doc__ = _('Search for roles.')
+
+ msg_summary = ngettext(
+ '%(count)d role matched', '%(count)d roles matched', 0
+ )
+
+
+
+@register()
+class role_show(LDAPRetrieve):
+ __doc__ = _('Display information about a role.')
+
+
+
+@register()
+class role_add_member(LDAPAddMember):
+ __doc__ = _('Add members to a role.')
+
+
+
+@register()
+class role_remove_member(LDAPRemoveMember):
+ __doc__ = _('Remove members from a role.')
+
+
+
+@register()
+class role_add_privilege(LDAPAddReverseMember):
+ __doc__ = _('Add privileges to a role.')
+
+ show_command = 'role_show'
+ member_command = 'privilege_add_member'
+ reverse_attr = 'privilege'
+ member_attr = 'role'
+
+ has_output = (
+ output.Entry('result'),
+ output.Output('failed',
+ type=dict,
+ doc=_('Members that could not be added'),
+ ),
+ output.Output('completed',
+ type=int,
+ doc=_('Number of privileges added'),
+ ),
+ )
+
+
+
+@register()
+class role_remove_privilege(LDAPRemoveReverseMember):
+ __doc__ = _('Remove privileges from a role.')
+
+ show_command = 'role_show'
+ member_command = 'privilege_remove_member'
+ reverse_attr = 'privilege'
+ member_attr = 'role'
+
+ has_output = (
+ output.Entry('result'),
+ output.Output('failed',
+ type=dict,
+ doc=_('Members that could not be added'),
+ ),
+ output.Output('completed',
+ type=int,
+ doc=_('Number of privileges removed'),
+ ),
+ )
+
diff --git a/ipaserver/plugins/schema.py b/ipaserver/plugins/schema.py
new file mode 100644
index 000000000..8bc230350
--- /dev/null
+++ b/ipaserver/plugins/schema.py
@@ -0,0 +1,660 @@
+#
+# Copyright (C) 2016 FreeIPA Contributors see COPYING for license
+#
+
+import importlib
+import itertools
+import sys
+
+import six
+
+from ipalib import errors
+from ipalib.crud import PKQuery, Retrieve, Search
+from ipalib.frontend import Command, Method, Object
+from ipalib.output import Entry, ListOfEntries, ListOfPrimaryKeys, PrimaryKey
+from ipalib.parameters import Any, Bool, Flag, Int, Str
+from ipalib.plugable import Registry
+from ipalib.text import _
+from ipapython.version import API_VERSION
+
+__doc__ = _("""
+API Schema
+""") + _("""
+Provides API introspection capabilities.
+""") + _("""
+EXAMPLES:
+""") + _("""
+ Show user-find details:
+ ipa command-show user-find
+""") + _("""
+ Find user-find parameters:
+ ipa param-find user-find
+""")
+
+if six.PY3:
+ unicode = str
+
+register = Registry()
+
+
+class BaseMetaObject(Object):
+ takes_params = (
+ Str(
+ 'name',
+ label=_("Name"),
+ primary_key=True,
+ normalizer=lambda name: name.replace(u'-', u'_'),
+ flags={'no_search'},
+ ),
+ Str(
+ 'doc?',
+ label=_("Documentation"),
+ flags={'no_search'},
+ ),
+ )
+
+ def _get_obj(self, obj, **kwargs):
+ raise NotImplementedError()
+
+ def _retrieve(self, *args, **kwargs):
+ raise NotImplementedError()
+
+ def retrieve(self, *args, **kwargs):
+ obj = self._retrieve(*args, **kwargs)
+ obj = self._get_obj(obj, **kwargs)
+ return obj
+
+ def _search(self, *args, **kwargs):
+ raise NotImplementedError()
+
+ def _split_search_args(self, criteria=None):
+ return [], criteria
+
+ def search(self, *args, **kwargs):
+ args, criteria = self._split_search_args(*args)
+
+ result = self._search(*args, **kwargs)
+ result = (self._get_obj(r, **kwargs) for r in result)
+
+ if criteria:
+ criteria = criteria.lower()
+ result = (r for r in result
+ if (criteria in r['name'].lower() or
+ criteria in r.get('doc', u'').lower()))
+
+ if not kwargs.get('all', False) and kwargs.get('pkey_only', False):
+ result = ({'name': r['name']} for r in result)
+
+ return result
+
+
+class BaseMetaRetrieve(Retrieve):
+ def execute(self, *args, **options):
+ obj = self.obj.retrieve(*args, **options)
+ return dict(result=obj, value=args[-1])
+
+
+class BaseMetaSearch(Search):
+ def get_options(self):
+ for option in super(BaseMetaSearch, self).get_options():
+ yield option
+
+ yield Flag(
+ 'pkey_only?',
+ label=_("Primary key only"),
+ doc=_("Results should contain primary key attribute only "
+ "(\"%s\")") % 'name',
+ )
+
+ def execute(self, criteria=None, **options):
+ result = list(self.obj.search(criteria, **options))
+ return dict(result=result, count=len(result), truncated=False)
+
+
+class MetaObject(BaseMetaObject):
+ takes_params = BaseMetaObject.takes_params + (
+ Str(
+ 'topic_topic?',
+ label=_("Help topic"),
+ flags={'no_search'},
+ ),
+ )
+
+
+class MetaRetrieve(BaseMetaRetrieve):
+ pass
+
+
+class MetaSearch(BaseMetaSearch):
+ pass
+
+
+@register()
+class command(MetaObject):
+ takes_params = BaseMetaObject.takes_params + (
+ Str(
+ 'args_param*',
+ label=_("Arguments"),
+ flags={'no_search'},
+ ),
+ Str(
+ 'options_param*',
+ label=_("Options"),
+ flags={'no_search'},
+ ),
+ Str(
+ 'output_params_param*',
+ label=_("Output parameters"),
+ flags={'no_search'},
+ ),
+ Bool(
+ 'no_cli?',
+ label=_("Exclude from CLI"),
+ flags={'no_search'},
+ ),
+ )
+
+ def _get_obj(self, command, **kwargs):
+ obj = dict()
+ obj['name'] = unicode(command.name)
+
+ if command.doc:
+ obj['doc'] = unicode(command.doc)
+
+ if command.topic:
+ try:
+ topic = self.api.Object.topic.retrieve(unicode(command.topic))
+ except errors.NotFound:
+ pass
+ else:
+ obj['topic_topic'] = topic['name']
+
+ if command.NO_CLI:
+ obj['no_cli'] = True
+
+ if len(command.args):
+ obj['args_param'] = tuple(unicode(n) for n in command.args)
+
+ if len(command.options):
+ obj['options_param'] = tuple(
+ unicode(n) for n in command.options if n != 'version')
+
+ if len(command.output_params):
+ obj['output_params_param'] = tuple(
+ unicode(n) for n in command.output_params
+ if n not in command.params)
+
+ return obj
+
+ def _retrieve(self, name, **kwargs):
+ try:
+ return self.api.Command[name]
+ except KeyError:
+ raise errors.NotFound(
+ reason=_("%(pkey)s: %(oname)s not found") % {
+ 'pkey': name, 'oname': self.name,
+ }
+ )
+
+ def _search(self, **kwargs):
+ return self.api.Command()
+
+
+@register()
+class command_show(MetaRetrieve):
+ __doc__ = _("Display information about a command.")
+
+
+@register()
+class command_find(MetaSearch):
+ __doc__ = _("Search for commands.")
+
+
+@register()
+class command_defaults(PKQuery):
+ NO_CLI = True
+
+ takes_options = (
+ Str('params*'),
+ Any('kw?'),
+ )
+
+ def execute(self, name, **options):
+ command = self.api.Command[name]
+
+ params = options.get('params', [])
+
+ kw = options.get('kw', {})
+ if not isinstance(kw, dict):
+ raise errors.ConversionError(name=name,
+ error=_("must be a dictionary"))
+
+ result = command.get_default(**kw)
+ result = {n: v for n, v in result.items() if n in params}
+
+ return dict(result=result)
+
+
+@register()
+class topic_(MetaObject):
+ name = 'topic'
+
+ def __init__(self, api):
+ super(topic_, self).__init__(api)
+ self.__topics = None
+
+ def __get_topics(self):
+ if self.__topics is None:
+ topics = {}
+ object.__setattr__(self, '_topic___topics', topics)
+
+ for command in self.api.Command():
+ topic_name = command.topic
+
+ while topic_name is not None and topic_name not in topics:
+ topic = topics[topic_name] = {'name': topic_name}
+
+ for package in self.api.packages:
+ module_name = '.'.join((package.__name__, topic_name))
+ try:
+ module = sys.modules[module_name]
+ except KeyError:
+ try:
+ module = importlib.import_module(module_name)
+ except ImportError:
+ continue
+
+ if module.__doc__ is not None:
+ topic['doc'] = unicode(module.__doc__).strip()
+
+ try:
+ topic_name = module.topic
+ except AttributeError:
+ topic_name = None
+ else:
+ topic['topic_topic'] = topic_name
+
+ return self.__topics
+
+ def _get_obj(self, topic, **kwargs):
+ return topic
+
+ def _retrieve(self, name, **kwargs):
+ try:
+ return self.__get_topics()[name]
+ except KeyError:
+ raise errors.NotFound(
+ reason=_("%(pkey)s: %(oname)s not found") % {
+ 'pkey': name, 'oname': self.name,
+ }
+ )
+
+ def _search(self, **kwargs):
+ return self.__get_topics().values()
+
+
+@register()
+class topic_show(MetaRetrieve):
+ __doc__ = _("Display information about a help topic.")
+
+
+@register()
+class topic_find(MetaSearch):
+ __doc__ = _("Search for help topics.")
+
+
+class BaseParam(BaseMetaObject):
+ takes_params = BaseMetaObject.takes_params + (
+ Str(
+ 'type?',
+ label=_("Type"),
+ flags={'no_search'},
+ ),
+ Bool(
+ 'required?',
+ label=_("Required"),
+ flags={'no_search'},
+ ),
+ Bool(
+ 'multivalue?',
+ label=_("Multi-value"),
+ flags={'no_search'},
+ ),
+ )
+
+ def _split_search_args(self, commandname, criteria=None):
+ return [commandname], criteria
+
+
+class BaseParamMethod(Method):
+ def get_args(self):
+ parent = self.api.Object.command
+ parent_key = parent.primary_key
+ yield parent_key.clone_rename(
+ parent.name + parent_key.name,
+ cli_name=parent.name,
+ label=parent_key.label,
+ required=True,
+ query=True,
+ )
+
+ for arg in super(BaseParamMethod, self).get_args():
+ yield arg
+
+
+class BaseParamRetrieve(BaseParamMethod, BaseMetaRetrieve):
+ pass
+
+
+class BaseParamSearch(BaseParamMethod, BaseMetaSearch):
+ pass
+
+
+@register()
+class param(BaseParam):
+ takes_params = BaseParam.takes_params + (
+ Bool(
+ 'alwaysask?',
+ label=_("Always ask"),
+ flags={'no_search'},
+ ),
+ Bool(
+ 'autofill?',
+ label=_("Autofill"),
+ flags={'no_search'},
+ ),
+ Str(
+ 'cli_metavar?',
+ label=_("CLI metavar"),
+ flags={'no_search'},
+ ),
+ Str(
+ 'cli_name?',
+ label=_("CLI name"),
+ flags={'no_search'},
+ ),
+ Bool(
+ 'confirm',
+ label=_("Confirm (password)"),
+ flags={'no_search'},
+ ),
+ Str(
+ 'default*',
+ label=_("Default"),
+ flags={'no_search'},
+ ),
+ Str(
+ 'default_from_param*',
+ label=_("Default from"),
+ flags={'no_search'},
+ ),
+ Str(
+ 'deprecated_cli_aliases*',
+ label=_("Deprecated CLI aliases"),
+ flags={'no_search'},
+ ),
+ Str(
+ 'exclude*',
+ label=_("Exclude from"),
+ flags={'no_search'},
+ ),
+ Str(
+ 'hint?',
+ label=_("Hint"),
+ flags={'no_search'},
+ ),
+ Str(
+ 'include*',
+ label=_("Include in"),
+ flags={'no_search'},
+ ),
+ Str(
+ 'label?',
+ label=_("Label"),
+ flags={'no_search'},
+ ),
+ Bool(
+ 'no_convert?',
+ label=_("Convert on server"),
+ flags={'no_search'},
+ ),
+ Str(
+ 'option_group?',
+ label=_("Option group"),
+ flags={'no_search'},
+ ),
+ Int(
+ 'sortorder?',
+ label=_("Sort order"),
+ flags={'no_search'},
+ ),
+ Bool(
+ 'dnsrecord_extra?',
+ label=_("Extra field (DNS record)"),
+ flags={'no_search'},
+ ),
+ Bool(
+ 'dnsrecord_part?',
+ label=_("Part (DNS record)"),
+ flags={'no_search'},
+ ),
+ Bool(
+ 'no_option?',
+ label=_("No option"),
+ flags={'no_search'},
+ ),
+ Bool(
+ 'suppress_empty?',
+ label=_("Suppress empty"),
+ flags={'no_search'},
+ ),
+ Bool(
+ 'sensitive?',
+ label=_("Sensitive"),
+ flags={'no_search'},
+ ),
+ )
+
+ def _get_obj(self, param, **kwargs):
+ obj = dict()
+ obj['name'] = unicode(param.name)
+
+ if param.type is unicode:
+ obj['type'] = u'str'
+ elif param.type is bytes:
+ obj['type'] = u'bytes'
+ elif param.type is not None:
+ obj['type'] = unicode(param.type.__name__)
+
+ if not param.required:
+ obj['required'] = False
+ if param.multivalue:
+ obj['multivalue'] = True
+ if param.password:
+ obj['sensitive'] = True
+
+ for key, value in param._Param__clonekw.items():
+ if key in ('alwaysask',
+ 'autofill',
+ 'confirm',
+ 'sortorder'):
+ obj[key] = value
+ elif key in ('cli_metavar',
+ 'cli_name',
+ 'doc',
+ 'hint',
+ 'label',
+ 'option_group'):
+ obj[key] = unicode(value)
+ elif key == 'default':
+ if param.multivalue:
+ obj[key] = [unicode(v) for v in value]
+ else:
+ obj[key] = [unicode(value)]
+ elif key == 'default_from':
+ obj['default_from_param'] = list(unicode(k)
+ for k in value.keys)
+ elif key in ('deprecated_cli_aliases',
+ 'exclude',
+ 'include'):
+ obj[key] = list(unicode(v) for v in value)
+ elif key in ('exponential',
+ 'normalizer',
+ 'only_absolute',
+ 'precision'):
+ obj['no_convert'] = True
+
+ for flag in (param.flags or []):
+ if flag in ('dnsrecord_extra',
+ 'dnsrecord_part',
+ 'no_option',
+ 'suppress_empty'):
+ obj[flag] = True
+
+ return obj
+
+ def _retrieve(self, commandname, name, **kwargs):
+ command = self.api.Command[commandname]
+
+ if name != 'version':
+ try:
+ return command.params[name]
+ except KeyError:
+ try:
+ return command.output_params[name]
+ except KeyError:
+ pass
+
+ raise errors.NotFound(
+ reason=_("%(pkey)s: %(oname)s not found") % {
+ 'pkey': name, 'oname': self.name,
+ }
+ )
+
+ def _search(self, commandname, **kwargs):
+ command = self.api.Command[commandname]
+
+ result = itertools.chain(
+ (p for p in command.params() if p.name != 'version'),
+ (p for p in command.output_params()
+ if p.name not in command.params))
+
+ return result
+
+
+@register()
+class param_show(BaseParamRetrieve):
+ __doc__ = _("Display information about a command parameter.")
+
+
+@register()
+class param_find(BaseParamSearch):
+ __doc__ = _("Search command parameters.")
+
+
+@register()
+class output(BaseParam):
+ takes_params = BaseParam.takes_params + (
+ Bool(
+ 'no_display?',
+ label=_("Do not display"),
+ flags={'no_search'},
+ ),
+ )
+
+ def _get_obj(self, command_output, **kwargs):
+ command, output = command_output
+ required = True
+ multivalue = False
+
+ if isinstance(output, (Entry, ListOfEntries)):
+ type_type = dict
+ multivalue = isinstance(output, ListOfEntries)
+ elif isinstance(output, (PrimaryKey, ListOfPrimaryKeys)):
+ if getattr(command, 'obj', None) and command.obj.primary_key:
+ type_type = command.obj.primary_key.type
+ else:
+ type_type = type(None)
+ multivalue = isinstance(output, ListOfPrimaryKeys)
+ elif isinstance(output.type, tuple):
+ if tuple in output.type or list in output.type:
+ type_type = None
+ multivalue = True
+ else:
+ type_type = output.type[0]
+ required = type(None) not in output.type
+ else:
+ type_type = output.type
+
+ obj = dict()
+ obj['name'] = unicode(output.name)
+
+ if type_type is unicode:
+ obj['type'] = u'str'
+ elif type_type is bytes:
+ obj['type'] = u'bytes'
+ elif type_type is not None:
+ obj['type'] = unicode(type_type.__name__)
+
+ if not required:
+ obj['required'] = False
+
+ if multivalue:
+ obj['multivalue'] = True
+
+ if 'doc' in output.__dict__:
+ obj['doc'] = unicode(output.doc)
+
+ if 'flags' in output.__dict__:
+ if 'no_display' in output.flags:
+ obj['no_display'] = True
+
+ return obj
+
+ def _retrieve(self, commandname, name, **kwargs):
+ command = self.api.Command[commandname]
+ try:
+ return (command, command.output[name])
+ except KeyError:
+ raise errors.NotFound(
+ reason=_("%(pkey)s: %(oname)s not found") % {
+ 'pkey': name, 'oname': self.name,
+ }
+ )
+
+ def _search(self, commandname, **kwargs):
+ command = self.api.Command[commandname]
+ return ((command, output) for output in command.output())
+
+
+@register()
+class output_show(BaseParamRetrieve):
+ __doc__ = _("Display information about a command output.")
+
+
+@register()
+class output_find(BaseParamSearch):
+ __doc__ = _("Search for command outputs.")
+
+
+@register()
+class schema(Command):
+ NO_CLI = True
+
+ def execute(self, *args, **kwargs):
+ commands = list(self.api.Object.command.search(**kwargs))
+ for command in commands:
+ name = command['name']
+ command['params'] = list(
+ self.api.Object.param.search(name, **kwargs))
+ command['output'] = list(
+ self.api.Object.output.search(name, **kwargs))
+
+ topics = list(self.api.Object.topic.search(**kwargs))
+
+ schema = dict()
+ schema['version'] = API_VERSION
+ schema['commands'] = commands
+ schema['topics'] = topics
+
+ return dict(result=schema)
diff --git a/ipaserver/plugins/selfservice.py b/ipaserver/plugins/selfservice.py
new file mode 100644
index 000000000..4ff6ac744
--- /dev/null
+++ b/ipaserver/plugins/selfservice.py
@@ -0,0 +1,224 @@
+# Authors:
+# Rob Crittenden <rcritten@redhat.com>
+#
+# Copyright (C) 2010 Red Hat
+# see file 'COPYING' for use and warranty information
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+from ipalib import _, ngettext
+from ipalib import Str
+from ipalib import api, crud, errors
+from ipalib import output
+from ipalib import Object
+from ipalib.plugable import Registry
+from .baseldap import gen_pkey_only_option, pkey_to_value
+
+__doc__ = _("""
+Self-service Permissions
+
+A permission enables fine-grained delegation of permissions. Access Control
+Rules, or instructions (ACIs), grant permission to permissions to perform
+given tasks such as adding a user, modifying a group, etc.
+
+A Self-service permission defines what an object can change in its own entry.
+
+
+EXAMPLES:
+
+ Add a self-service rule to allow users to manage their address (using Bash
+ brace expansion):
+ ipa selfservice-add --permissions=write --attrs={street,postalCode,l,c,st} "Users manage their own address"
+
+ When managing the list of attributes you need to include all attributes
+ in the list, including existing ones.
+ Add telephoneNumber to the list (using Bash brace expansion):
+ ipa selfservice-mod --attrs={street,postalCode,l,c,st,telephoneNumber} "Users manage their own address"
+
+ Display our updated rule:
+ ipa selfservice-show "Users manage their own address"
+
+ Delete a rule:
+ ipa selfservice-del "Users manage their own address"
+""")
+
+register = Registry()
+
+ACI_PREFIX=u"selfservice"
+
+output_params = (
+ Str('aci',
+ label=_('ACI'),
+ ),
+)
+
+
+@register()
+class selfservice(Object):
+ """
+ Selfservice object.
+ """
+
+ bindable = False
+ object_name = _('self service permission')
+ object_name_plural = _('self service permissions')
+ label = _('Self Service Permissions')
+ label_singular = _('Self Service Permission')
+
+ takes_params = (
+ Str('aciname',
+ cli_name='name',
+ label=_('Self-service name'),
+ doc=_('Self-service name'),
+ primary_key=True,
+ pattern='^[-_ a-zA-Z0-9]+$',
+ pattern_errmsg="May only contain letters, numbers, -, _, and space",
+ ),
+ Str('permissions*',
+ cli_name='permissions',
+ label=_('Permissions'),
+ doc=_('Permissions to grant (read, write). Default is write.'),
+ ),
+ Str('attrs+',
+ cli_name='attrs',
+ label=_('Attributes'),
+ doc=_('Attributes to which the permission applies.'),
+ normalizer=lambda value: value.lower(),
+ ),
+ )
+
+ def __json__(self):
+ json_friendly_attributes = (
+ 'label', 'label_singular', 'takes_params', 'bindable', 'name',
+ 'object_name', 'object_name_plural',
+ )
+ json_dict = dict(
+ (a, getattr(self, a)) for a in json_friendly_attributes
+ )
+ json_dict['primary_key'] = self.primary_key.name
+ json_dict['methods'] = [m for m in self.methods]
+ return json_dict
+
+ def postprocess_result(self, result):
+ try:
+ # do not include prefix in result
+ del result['aciprefix']
+ except KeyError:
+ pass
+
+
+
+@register()
+class selfservice_add(crud.Create):
+ __doc__ = _('Add a new self-service permission.')
+
+ msg_summary = _('Added selfservice "%(value)s"')
+ has_output_params = output_params
+
+ def execute(self, aciname, **kw):
+ if not 'permissions' in kw:
+ kw['permissions'] = (u'write',)
+ kw['selfaci'] = True
+ kw['aciprefix'] = ACI_PREFIX
+ result = api.Command['aci_add'](aciname, **kw)['result']
+ self.obj.postprocess_result(result)
+
+ return dict(
+ result=result,
+ value=pkey_to_value(aciname, kw),
+ )
+
+
+
+@register()
+class selfservice_del(crud.Delete):
+ __doc__ = _('Delete a self-service permission.')
+
+ has_output = output.standard_boolean
+ msg_summary = _('Deleted selfservice "%(value)s"')
+
+ def execute(self, aciname, **kw):
+ result = api.Command['aci_del'](aciname, aciprefix=ACI_PREFIX)
+ self.obj.postprocess_result(result)
+
+ return dict(
+ result=True,
+ value=pkey_to_value(aciname, kw),
+ )
+
+
+
+@register()
+class selfservice_mod(crud.Update):
+ __doc__ = _('Modify a self-service permission.')
+
+ msg_summary = _('Modified selfservice "%(value)s"')
+ has_output_params = output_params
+
+ def execute(self, aciname, **kw):
+ if 'attrs' in kw and kw['attrs'] is None:
+ raise errors.RequirementError(name='attrs')
+
+ kw['aciprefix'] = ACI_PREFIX
+ result = api.Command['aci_mod'](aciname, **kw)['result']
+ self.obj.postprocess_result(result)
+
+ return dict(
+ result=result,
+ value=pkey_to_value(aciname, kw),
+ )
+
+
+
+@register()
+class selfservice_find(crud.Search):
+ __doc__ = _('Search for a self-service permission.')
+
+ msg_summary = ngettext(
+ '%(count)d selfservice matched', '%(count)d selfservices matched', 0
+ )
+
+ takes_options = (gen_pkey_only_option("name"),)
+ has_output_params = output_params
+
+ def execute(self, term=None, **kw):
+ kw['selfaci'] = True
+ kw['aciprefix'] = ACI_PREFIX
+ result = api.Command['aci_find'](term, **kw)['result']
+
+ for aci in result:
+ self.obj.postprocess_result(aci)
+
+ return dict(
+ result=result,
+ count=len(result),
+ truncated=False,
+ )
+
+
+
+@register()
+class selfservice_show(crud.Retrieve):
+ __doc__ = _('Display information about a self-service permission.')
+
+ has_output_params = output_params
+
+ def execute(self, aciname, **kw):
+ result = api.Command['aci_show'](aciname, aciprefix=ACI_PREFIX, **kw)['result']
+ self.obj.postprocess_result(result)
+ return dict(
+ result=result,
+ value=pkey_to_value(aciname, kw),
+ )
+
diff --git a/ipaserver/plugins/selinuxusermap.py b/ipaserver/plugins/selinuxusermap.py
new file mode 100644
index 000000000..8f660d089
--- /dev/null
+++ b/ipaserver/plugins/selinuxusermap.py
@@ -0,0 +1,569 @@
+# Authors:
+# Rob Crittenden <rcritten@redhat.com>
+#
+# Copyright (C) 2011 Red Hat
+# see file 'COPYING' for use and warranty information
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+import re
+
+from ipalib import api, errors
+from ipalib import Str, StrEnum, Bool
+from ipalib.plugable import Registry
+from .baseldap import (
+ pkey_to_value,
+ LDAPObject,
+ LDAPCreate,
+ LDAPDelete,
+ LDAPUpdate,
+ LDAPSearch,
+ LDAPRetrieve,
+ LDAPQuery,
+ LDAPAddMember,
+ LDAPRemoveMember)
+from ipalib import _, ngettext
+from ipalib import output
+from .hbacrule import is_all
+from ipapython.dn import DN
+
+__doc__ = _("""
+SELinux User Mapping
+
+Map IPA users to SELinux users by host.
+
+Hosts, hostgroups, users and groups can be either defined within
+the rule or it may point to an existing HBAC rule. When using
+--hbacrule option to selinuxusermap-find an exact match is made on the
+HBAC rule name, so only one or zero entries will be returned.
+
+EXAMPLES:
+
+ Create a rule, "test1", that sets all users to xguest_u:s0 on the host "server":
+ ipa selinuxusermap-add --usercat=all --selinuxuser=xguest_u:s0 test1
+ ipa selinuxusermap-add-host --hosts=server.example.com test1
+
+ Create a rule, "test2", that sets all users to guest_u:s0 and uses an existing HBAC rule for users and hosts:
+ ipa selinuxusermap-add --usercat=all --hbacrule=webserver --selinuxuser=guest_u:s0 test2
+
+ Display the properties of a rule:
+ ipa selinuxusermap-show test2
+
+ Create a rule for a specific user. This sets the SELinux context for
+ user john to unconfined_u:s0-s0:c0.c1023 on any machine:
+ ipa selinuxusermap-add --hostcat=all --selinuxuser=unconfined_u:s0-s0:c0.c1023 john_unconfined
+ ipa selinuxusermap-add-user --users=john john_unconfined
+
+ Disable a rule:
+ ipa selinuxusermap-disable test1
+
+ Enable a rule:
+ ipa selinuxusermap-enable test1
+
+ Find a rule referencing a specific HBAC rule:
+ ipa selinuxusermap-find --hbacrule=allow_some
+
+ Remove a rule:
+ ipa selinuxusermap-del john_unconfined
+
+SEEALSO:
+
+ The list controlling the order in which the SELinux user map is applied
+ and the default SELinux user are available in the config-show command.
+""")
+
+register = Registry()
+
+notboth_err = _('HBAC rule and local members cannot both be set')
+
+
+def validate_selinuxuser(ugettext, user):
+ """
+ An SELinux user has 3 components: user:MLS:MCS. user and MLS are required.
+ user traditionally ends with _u but this is not mandatory.
+ The regex is ^[a-zA-Z][a-zA-Z_]*
+
+ The MLS part can only be:
+ Level: s[0-15](-s[0-15])
+
+ Then MCS could be c[0-1023].c[0-1023] and/or c[0-1023]-c[0-c0123]
+ Meaning
+ s0 s0-s1 s0-s15:c0.c1023 s0-s1:c0,c2,c15.c26 s0-s0:c0.c1023
+
+ Returns a message on invalid, returns nothing on valid.
+ """
+ regex_name = re.compile(r'^[a-zA-Z][a-zA-Z_]*$')
+ regex_mls = re.compile(r'^s[0-9][1-5]{0,1}(-s[0-9][1-5]{0,1}){0,1}$')
+ regex_mcs = re.compile(r'^c(\d+)([.,-]c(\d+))*?$')
+
+ # If we add in ::: we don't have to check to see if some values are
+ # empty
+ (name, mls, mcs, ignore) = (user + ':::').split(':', 3)
+
+ if not regex_name.match(name):
+ return _('Invalid SELinux user name, only a-Z and _ are allowed')
+ if not mls or not regex_mls.match(mls):
+ return _('Invalid MLS value, must match s[0-15](-s[0-15])')
+ m = regex_mcs.match(mcs)
+ if mcs and (not m or (m.group(3) and (int(m.group(3)) > 1023))):
+ return _('Invalid MCS value, must match c[0-1023].c[0-1023] '
+ 'and/or c[0-1023]-c[0-c0123]')
+
+ return None
+
+
+def validate_selinuxuser_inlist(ldap, user):
+ """
+ Ensure the user is in the list of allowed SELinux users.
+
+ Returns nothing if the user is found, raises an exception otherwise.
+ """
+ config = ldap.get_ipa_config()
+ item = config.get('ipaselinuxusermaporder', [])
+ if len(item) != 1:
+ raise errors.NotFound(reason=_('SELinux user map list not '
+ 'found in configuration'))
+ userlist = item[0].split('$')
+ if user not in userlist:
+ raise errors.NotFound(
+ reason=_('SELinux user %(user)s not found in '
+ 'ordering list (in config)') % dict(user=user))
+
+ return
+
+
+@register()
+class selinuxusermap(LDAPObject):
+ """
+ SELinux User Map object.
+ """
+ container_dn = api.env.container_selinux
+ object_name = _('SELinux User Map rule')
+ object_name_plural = _('SELinux User Map rules')
+ object_class = ['ipaassociation', 'ipaselinuxusermap']
+ permission_filter_objectclasses = ['ipaselinuxusermap']
+ default_attributes = [
+ 'cn', 'ipaenabledflag',
+ 'description', 'usercategory', 'hostcategory',
+ 'ipaenabledflag', 'memberuser', 'memberhost',
+ 'seealso', 'ipaselinuxuser',
+ ]
+ uuid_attribute = 'ipauniqueid'
+ rdn_attribute = 'ipauniqueid'
+ attribute_members = {
+ 'memberuser': ['user', 'group'],
+ 'memberhost': ['host', 'hostgroup'],
+ }
+ managed_permissions = {
+ 'System: Read SELinux User Maps': {
+ 'replaces_global_anonymous_aci': True,
+ 'ipapermbindruletype': 'all',
+ 'ipapermright': {'read', 'search', 'compare'},
+ 'ipapermdefaultattr': {
+ 'accesstime', 'cn', 'description', 'hostcategory',
+ 'ipaenabledflag', 'ipaselinuxuser', 'ipauniqueid',
+ 'memberhost', 'memberuser', 'seealso', 'usercategory',
+ 'objectclass', 'member',
+ },
+ },
+ 'System: Add SELinux User Maps': {
+ 'ipapermright': {'add'},
+ 'replaces': [
+ '(target = "ldap:///ipauniqueid=*,cn=usermap,cn=selinux,$SUFFIX")(version 3.0;acl "permission:Add SELinux User Maps";allow (add) groupdn = "ldap:///cn=Add SELinux User Maps,cn=permissions,cn=pbac,$SUFFIX";)',
+ ],
+ 'default_privileges': {'SELinux User Map Administrators'},
+ },
+ 'System: Modify SELinux User Maps': {
+ 'ipapermright': {'write'},
+ 'ipapermdefaultattr': {
+ 'cn', 'ipaenabledflag', 'ipaselinuxuser', 'memberhost',
+ 'memberuser', 'seealso'
+ },
+ 'replaces': [
+ '(targetattr = "cn || memberuser || memberhost || seealso || ipaselinuxuser || ipaenabledflag")(target = "ldap:///ipauniqueid=*,cn=usermap,cn=selinux,$SUFFIX")(version 3.0;acl "permission:Modify SELinux User Maps";allow (write) groupdn = "ldap:///cn=Modify SELinux User Maps,cn=permissions,cn=pbac,$SUFFIX";)',
+ ],
+ 'default_privileges': {'SELinux User Map Administrators'},
+ },
+ 'System: Remove SELinux User Maps': {
+ 'ipapermright': {'delete'},
+ 'replaces': [
+ '(target = "ldap:///ipauniqueid=*,cn=usermap,cn=selinux,$SUFFIX")(version 3.0;acl "permission:Remove SELinux User Maps";allow (delete) groupdn = "ldap:///cn=Remove SELinux User Maps,cn=permissions,cn=pbac,$SUFFIX";)',
+ ],
+ 'default_privileges': {'SELinux User Map Administrators'},
+ },
+ }
+
+ # These maps will not show as members of other entries
+
+ label = _('SELinux User Maps')
+ label_singular = _('SELinux User Map')
+
+ takes_params = (
+ Str('cn',
+ cli_name='name',
+ label=_('Rule name'),
+ primary_key=True,
+ ),
+ Str('ipaselinuxuser', validate_selinuxuser,
+ cli_name='selinuxuser',
+ label=_('SELinux User'),
+ ),
+ Str('seealso?',
+ cli_name='hbacrule',
+ label=_('HBAC Rule'),
+ doc=_('HBAC Rule that defines the users, groups and hostgroups'),
+ ),
+ StrEnum('usercategory?',
+ cli_name='usercat',
+ label=_('User category'),
+ doc=_('User category the rule applies to'),
+ values=(u'all', ),
+ ),
+ StrEnum('hostcategory?',
+ cli_name='hostcat',
+ label=_('Host category'),
+ doc=_('Host category the rule applies to'),
+ values=(u'all', ),
+ ),
+ Str('description?',
+ cli_name='desc',
+ label=_('Description'),
+ ),
+ Bool('ipaenabledflag?',
+ label=_('Enabled'),
+ flags=['no_option'],
+ ),
+ Str('memberuser_user?',
+ label=_('Users'),
+ flags=['no_create', 'no_update', 'no_search'],
+ ),
+ Str('memberuser_group?',
+ label=_('User Groups'),
+ flags=['no_create', 'no_update', 'no_search'],
+ ),
+ Str('memberhost_host?',
+ label=_('Hosts'),
+ flags=['no_create', 'no_update', 'no_search'],
+ ),
+ Str('memberhost_hostgroup?',
+ label=_('Host Groups'),
+ flags=['no_create', 'no_update', 'no_search'],
+ ),
+ )
+
+ def _normalize_seealso(self, seealso):
+ """
+ Given a HBAC rule name verify its existence and return the dn.
+ """
+ if not seealso:
+ return None
+
+ try:
+ dn = DN(seealso)
+ return str(dn)
+ except ValueError:
+ try:
+ entry_attrs = self.backend.find_entry_by_attr(
+ self.api.Object['hbacrule'].primary_key.name,
+ seealso,
+ self.api.Object['hbacrule'].object_class,
+ [''],
+ DN(self.api.Object['hbacrule'].container_dn, api.env.basedn))
+ seealso = entry_attrs.dn
+ except errors.NotFound:
+ raise errors.NotFound(reason=_('HBAC rule %(rule)s not found') % dict(rule=seealso))
+
+ return seealso
+
+ def _convert_seealso(self, ldap, entry_attrs, **options):
+ """
+ Convert an HBAC rule dn into a name
+ """
+ if options.get('raw', False):
+ return
+
+ if 'seealso' in entry_attrs:
+ hbac_attrs = ldap.get_entry(entry_attrs['seealso'][0], ['cn'])
+ entry_attrs['seealso'] = hbac_attrs['cn'][0]
+
+
+
+@register()
+class selinuxusermap_add(LDAPCreate):
+ __doc__ = _('Create a new SELinux User Map.')
+
+ msg_summary = _('Added SELinux User Map "%(value)s"')
+
+ def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options):
+ assert isinstance(dn, DN)
+ # rules are enabled by default
+ entry_attrs['ipaenabledflag'] = 'TRUE'
+ validate_selinuxuser_inlist(ldap, entry_attrs['ipaselinuxuser'])
+
+ # hbacrule is not allowed when usercat or hostcat is set
+ is_to_be_set = lambda x: x in entry_attrs and entry_attrs[x] != None
+
+ are_local_members_to_be_set = any(is_to_be_set(attr)
+ for attr in ('usercategory',
+ 'hostcategory'))
+
+ is_hbacrule_to_be_set = is_to_be_set('seealso')
+
+ if is_hbacrule_to_be_set and are_local_members_to_be_set:
+ raise errors.MutuallyExclusiveError(reason=notboth_err)
+
+ if is_hbacrule_to_be_set:
+ entry_attrs['seealso'] = self.obj._normalize_seealso(entry_attrs['seealso'])
+
+ return dn
+
+ def post_callback(self, ldap, dn, entry_attrs, *keys, **options):
+ assert isinstance(dn, DN)
+ self.obj._convert_seealso(ldap, entry_attrs, **options)
+
+ return dn
+
+
+
+@register()
+class selinuxusermap_del(LDAPDelete):
+ __doc__ = _('Delete a SELinux User Map.')
+
+ msg_summary = _('Deleted SELinux User Map "%(value)s"')
+
+
+
+@register()
+class selinuxusermap_mod(LDAPUpdate):
+ __doc__ = _('Modify a SELinux User Map.')
+
+ msg_summary = _('Modified SELinux User Map "%(value)s"')
+
+ def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options):
+ assert isinstance(dn, DN)
+ try:
+ _entry_attrs = ldap.get_entry(dn, attrs_list)
+ except errors.NotFound:
+ self.obj.handle_not_found(*keys)
+
+ is_to_be_deleted = lambda x: (x in _entry_attrs and x in entry_attrs) and \
+ entry_attrs[x] == None
+
+ # makes sure the local members and hbacrule is not set at the same time
+ # memberuser or memberhost could have been set using --setattr
+ is_to_be_set = lambda x: ((x in _entry_attrs and _entry_attrs[x] != None) or \
+ (x in entry_attrs and entry_attrs[x] != None)) and \
+ not is_to_be_deleted(x)
+
+ are_local_members_to_be_set = any(is_to_be_set(attr)
+ for attr in ('usercategory',
+ 'hostcategory',
+ 'memberuser',
+ 'memberhost'))
+
+ is_hbacrule_to_be_set = is_to_be_set('seealso')
+
+ # this can disable all modifications if hbacrule and local members were
+ # set at the same time bypassing this commad, e.g. using ldapmodify
+ if are_local_members_to_be_set and is_hbacrule_to_be_set:
+ raise errors.MutuallyExclusiveError(reason=notboth_err)
+
+ if is_all(entry_attrs, 'usercategory') and 'memberuser' in entry_attrs:
+ raise errors.MutuallyExclusiveError(reason="user category "
+ "cannot be set to 'all' while there are allowed users")
+ if is_all(entry_attrs, 'hostcategory') and 'memberhost' in entry_attrs:
+ raise errors.MutuallyExclusiveError(reason="host category "
+ "cannot be set to 'all' while there are allowed hosts")
+
+ if 'ipaselinuxuser' in entry_attrs:
+ validate_selinuxuser_inlist(ldap, entry_attrs['ipaselinuxuser'])
+
+ if 'seealso' in entry_attrs:
+ entry_attrs['seealso'] = self.obj._normalize_seealso(entry_attrs['seealso'])
+ return dn
+
+ def post_callback(self, ldap, dn, entry_attrs, *keys, **options):
+ assert isinstance(dn, DN)
+ self.obj._convert_seealso(ldap, entry_attrs, **options)
+ return dn
+
+
+
+@register()
+class selinuxusermap_find(LDAPSearch):
+ __doc__ = _('Search for SELinux User Maps.')
+
+ msg_summary = ngettext(
+ '%(count)d SELinux User Map matched', '%(count)d SELinux User Maps matched', 0
+ )
+
+ def execute(self, *args, **options):
+ # If searching on hbacrule we need to find the uuid to search on
+ if options.get('seealso'):
+ hbacrule = options['seealso']
+
+ try:
+ hbac = api.Command['hbacrule_show'](hbacrule,
+all=True)['result']
+ dn = hbac['dn']
+ except errors.NotFound:
+ return dict(count=0, result=[], truncated=False)
+ options['seealso'] = dn
+
+ return super(selinuxusermap_find, self).execute(*args, **options)
+
+ def post_callback(self, ldap, entries, truncated, *args, **options):
+ if options.get('pkey_only', False):
+ return truncated
+ for attrs in entries:
+ self.obj._convert_seealso(ldap, attrs, **options)
+ return truncated
+
+
+
+@register()
+class selinuxusermap_show(LDAPRetrieve):
+ __doc__ = _('Display the properties of a SELinux User Map rule.')
+
+ def post_callback(self, ldap, dn, entry_attrs, *keys, **options):
+ assert isinstance(dn, DN)
+ self.obj._convert_seealso(ldap, entry_attrs, **options)
+ return dn
+
+
+
+@register()
+class selinuxusermap_enable(LDAPQuery):
+ __doc__ = _('Enable an SELinux User Map rule.')
+
+ msg_summary = _('Enabled SELinux User Map "%(value)s"')
+ has_output = output.standard_value
+
+ def execute(self, cn, **options):
+ ldap = self.obj.backend
+
+ dn = self.obj.get_dn(cn)
+ try:
+ entry_attrs = ldap.get_entry(dn, ['ipaenabledflag'])
+ except errors.NotFound:
+ self.obj.handle_not_found(cn)
+
+ entry_attrs['ipaenabledflag'] = ['TRUE']
+
+ try:
+ ldap.update_entry(entry_attrs)
+ except errors.EmptyModlist:
+ raise errors.AlreadyActive()
+
+ return dict(
+ result=True,
+ value=pkey_to_value(cn, options),
+ )
+
+
+
+@register()
+class selinuxusermap_disable(LDAPQuery):
+ __doc__ = _('Disable an SELinux User Map rule.')
+
+ msg_summary = _('Disabled SELinux User Map "%(value)s"')
+ has_output = output.standard_value
+
+ def execute(self, cn, **options):
+ ldap = self.obj.backend
+
+ dn = self.obj.get_dn(cn)
+ try:
+ entry_attrs = ldap.get_entry(dn, ['ipaenabledflag'])
+ except errors.NotFound:
+ self.obj.handle_not_found(cn)
+
+ entry_attrs['ipaenabledflag'] = ['FALSE']
+
+ try:
+ ldap.update_entry(entry_attrs)
+ except errors.EmptyModlist:
+ raise errors.AlreadyInactive()
+
+ return dict(
+ result=True,
+ value=pkey_to_value(cn, options),
+ )
+
+
+
+@register()
+class selinuxusermap_add_user(LDAPAddMember):
+ __doc__ = _('Add users and groups to an SELinux User Map rule.')
+
+ member_attributes = ['memberuser']
+ member_count_out = ('%i object added.', '%i objects added.')
+
+ def pre_callback(self, ldap, dn, found, not_found, *keys, **options):
+ assert isinstance(dn, DN)
+ try:
+ entry_attrs = ldap.get_entry(dn, self.obj.default_attributes)
+ dn = entry_attrs.dn
+ except errors.NotFound:
+ self.obj.handle_not_found(*keys)
+ if 'usercategory' in entry_attrs and \
+ entry_attrs['usercategory'][0].lower() == 'all':
+ raise errors.MutuallyExclusiveError(
+ reason=_("users cannot be added when user category='all'"))
+ if 'seealso' in entry_attrs:
+ raise errors.MutuallyExclusiveError(reason=notboth_err)
+ return dn
+
+
+
+@register()
+class selinuxusermap_remove_user(LDAPRemoveMember):
+ __doc__ = _('Remove users and groups from an SELinux User Map rule.')
+
+ member_attributes = ['memberuser']
+ member_count_out = ('%i object removed.', '%i objects removed.')
+
+
+
+@register()
+class selinuxusermap_add_host(LDAPAddMember):
+ __doc__ = _('Add target hosts and hostgroups to an SELinux User Map rule.')
+
+ member_attributes = ['memberhost']
+ member_count_out = ('%i object added.', '%i objects added.')
+
+ def pre_callback(self, ldap, dn, found, not_found, *keys, **options):
+ assert isinstance(dn, DN)
+ try:
+ entry_attrs = ldap.get_entry(dn, self.obj.default_attributes)
+ dn = entry_attrs.dn
+ except errors.NotFound:
+ self.obj.handle_not_found(*keys)
+ if 'hostcategory' in entry_attrs and \
+ entry_attrs['hostcategory'][0].lower() == 'all':
+ raise errors.MutuallyExclusiveError(
+ reason=_("hosts cannot be added when host category='all'"))
+ if 'seealso' in entry_attrs:
+ raise errors.MutuallyExclusiveError(reason=notboth_err)
+ return dn
+
+
+
+@register()
+class selinuxusermap_remove_host(LDAPRemoveMember):
+ __doc__ = _('Remove target hosts and hostgroups from an SELinux User Map rule.')
+
+ member_attributes = ['memberhost']
+ member_count_out = ('%i object removed.', '%i objects removed.')
+
diff --git a/ipaserver/plugins/server.py b/ipaserver/plugins/server.py
new file mode 100644
index 000000000..6faaf8ec5
--- /dev/null
+++ b/ipaserver/plugins/server.py
@@ -0,0 +1,260 @@
+#
+# Copyright (C) 2015 FreeIPA Contributors see COPYING for license
+#
+
+import dbus
+import dbus.mainloop.glib
+
+from ipalib import api, crud, errors, messages
+from ipalib import Int, Str
+from ipalib.plugable import Registry
+from .baseldap import (
+ LDAPSearch,
+ LDAPRetrieve,
+ LDAPDelete,
+ LDAPObject)
+from ipalib.request import context
+from ipalib import _, ngettext
+from ipalib import output
+
+__doc__ = _("""
+IPA servers
+""") + _("""
+Get information about installed IPA servers.
+""") + _("""
+EXAMPLES:
+""") + _("""
+ Find all servers:
+ ipa server-find
+""") + _("""
+ Show specific server:
+ ipa server-show ipa.example.com
+""")
+
+register = Registry()
+
+
+@register()
+class server(LDAPObject):
+ """
+ IPA server
+ """
+ container_dn = api.env.container_masters
+ object_name = _('server')
+ object_name_plural = _('servers')
+ object_class = ['top']
+ search_attributes = ['cn']
+ default_attributes = [
+ 'cn', 'iparepltopomanagedsuffix', 'ipamindomainlevel',
+ 'ipamaxdomainlevel'
+ ]
+ label = _('IPA Servers')
+ label_singular = _('IPA Server')
+ attribute_members = {
+ 'iparepltopomanagedsuffix': ['topologysuffix'],
+ }
+ relationships = {
+ 'iparepltopomanagedsuffix': ('Managed', '', 'no_'),
+ }
+ takes_params = (
+ Str(
+ 'cn',
+ cli_name='name',
+ primary_key=True,
+ label=_('Server name'),
+ doc=_('IPA server hostname'),
+ ),
+ Str(
+ 'iparepltopomanagedsuffix*',
+ flags={'no_create', 'no_update', 'no_search'},
+ ),
+ Str(
+ 'iparepltopomanagedsuffix_topologysuffix*',
+ label=_('Managed suffixes'),
+ flags={'virtual_attribute', 'no_create', 'no_update', 'no_search'},
+ ),
+ Int(
+ 'ipamindomainlevel',
+ cli_name='minlevel',
+ label=_('Min domain level'),
+ doc=_('Minimum domain level'),
+ flags={'no_create', 'no_update'},
+ ),
+ Int(
+ 'ipamaxdomainlevel',
+ cli_name='maxlevel',
+ label=_('Max domain level'),
+ doc=_('Maximum domain level'),
+ flags={'no_create', 'no_update'},
+ ),
+ )
+
+ def _get_suffixes(self):
+ suffixes = self.api.Command.topologysuffix_find(
+ all=True, raw=True,
+ )['result']
+ suffixes = [(s['iparepltopoconfroot'][0], s['dn']) for s in suffixes]
+ return suffixes
+
+ def _apply_suffixes(self, entry, suffixes):
+ # change suffix DNs to topologysuffix entry DNs
+ # this fixes LDAPObject.convert_attribute_members() for suffixes
+ suffixes = dict(suffixes)
+ if 'iparepltopomanagedsuffix' in entry:
+ entry['iparepltopomanagedsuffix'] = [
+ suffixes.get(m, m) for m in entry['iparepltopomanagedsuffix']
+ ]
+
+
+@register()
+class server_find(LDAPSearch):
+ __doc__ = _('Search for IPA servers.')
+
+ msg_summary = ngettext(
+ '%(count)d IPA server matched',
+ '%(count)d IPA servers matched', 0
+ )
+ member_attributes = ['iparepltopomanagedsuffix']
+
+ def get_options(self):
+ for option in super(server_find, self).get_options():
+ if option.name == 'topologysuffix':
+ option = option.clone(cli_name='topologysuffixes')
+ elif option.name == 'no_topologysuffix':
+ option = option.clone(cli_name='no_topologysuffixes')
+ yield option
+
+ def get_member_filter(self, ldap, **options):
+ options.pop('topologysuffix', None)
+ options.pop('no_topologysuffix', None)
+
+ return super(server_find, self).get_member_filter(ldap, **options)
+
+ def pre_callback(self, ldap, filters, attrs_list, base_dn, scope,
+ *args, **options):
+ included = options.get('topologysuffix')
+ excluded = options.get('no_topologysuffix')
+
+ if included or excluded:
+ topologysuffix = self.api.Object.topologysuffix
+ suffixes = self.obj._get_suffixes()
+ suffixes = {s[1]: s[0] for s in suffixes}
+
+ if included:
+ included = [topologysuffix.get_dn(pk) for pk in included]
+ try:
+ included = [suffixes[dn] for dn in included]
+ except KeyError:
+ # force empty result
+ filter = '(!(objectclass=*))'
+ else:
+ filter = ldap.make_filter_from_attr(
+ 'iparepltopomanagedsuffix', included, ldap.MATCH_ALL
+ )
+ filters = ldap.combine_filters(
+ (filters, filter), ldap.MATCH_ALL
+ )
+
+ if excluded:
+ excluded = [topologysuffix.get_dn(pk) for pk in excluded]
+ excluded = [suffixes[dn] for dn in excluded if dn in suffixes]
+ filter = ldap.make_filter_from_attr(
+ 'iparepltopomanagedsuffix', excluded, ldap.MATCH_NONE
+ )
+ filters = ldap.combine_filters(
+ (filters, filter), ldap.MATCH_ALL
+ )
+
+ return (filters, base_dn, scope)
+
+ def post_callback(self, ldap, entries, truncated, *args, **options):
+ if not options.get('raw', False):
+ suffixes = self.obj._get_suffixes()
+ for entry in entries:
+ self.obj._apply_suffixes(entry, suffixes)
+
+ return truncated
+
+
+@register()
+class server_show(LDAPRetrieve):
+ __doc__ = _('Show IPA server.')
+
+ def post_callback(self, ldap, dn, entry, *keys, **options):
+ if not options.get('raw', False):
+ suffixes = self.obj._get_suffixes()
+ self.obj._apply_suffixes(entry, suffixes)
+
+ return dn
+
+
+@register()
+class server_del(LDAPDelete):
+ __doc__ = _('Delete IPA server.')
+ NO_CLI = True
+ msg_summary = _('Deleted IPA server "%(value)s"')
+
+
+@register()
+class server_conncheck(crud.PKQuery):
+ __doc__ = _("Check connection to remote IPA server.")
+
+ NO_CLI = True
+
+ takes_args = (
+ Str(
+ 'remote_cn',
+ cli_name='remote_name',
+ label=_('Remote server name'),
+ doc=_('Remote IPA server hostname'),
+ ),
+ )
+
+ has_output = output.standard_value
+
+ def execute(self, *keys, **options):
+ # the server must be the local host
+ if keys[-2] != api.env.host:
+ raise errors.ValidationError(
+ name='cn', error=_("must be \"%s\"") % api.env.host)
+
+ # the server entry must exist
+ try:
+ self.obj.get_dn_if_exists(*keys[:-1])
+ except errors.NotFound:
+ self.obj.handle_not_found(keys[-2])
+
+ # the user must have the Replication Administrators privilege
+ privilege = u'Replication Administrators'
+ privilege_dn = self.api.Object.privilege.get_dn(privilege)
+ ldap = self.obj.backend
+ filter = ldap.make_filter({
+ 'krbprincipalname': context.principal, # pylint: disable=no-member
+ 'memberof': privilege_dn},
+ rules=ldap.MATCH_ALL)
+ try:
+ ldap.find_entries(base_dn=self.api.env.basedn, filter=filter)
+ except errors.NotFound:
+ raise errors.ACIError(
+ info=_("not allowed to perform server connection check"))
+
+ dbus.mainloop.glib.DBusGMainLoop(set_as_default=True)
+
+ bus = dbus.SystemBus()
+ obj = bus.get_object('org.freeipa.server', '/',
+ follow_name_owner_changes=True)
+ server = dbus.Interface(obj, 'org.freeipa.server')
+
+ ret, stdout, stderr = server.conncheck(keys[-1])
+
+ result = dict(
+ result=(ret == 0),
+ value=keys[-2],
+ )
+
+ for line in stdout.splitlines():
+ messages.add_message(options['version'],
+ result,
+ messages.ExternalCommandOutput(line=line))
+
+ return result
diff --git a/ipaserver/plugins/service.py b/ipaserver/plugins/service.py
new file mode 100644
index 000000000..7e3735583
--- /dev/null
+++ b/ipaserver/plugins/service.py
@@ -0,0 +1,889 @@
+# Authors:
+# Jason Gerard DeRose <jderose@redhat.com>
+# Rob Crittenden <rcritten@redhat.com>
+# Pavel Zuna <pzuna@redhat.com>
+#
+# Copyright (C) 2008 Red Hat
+# see file 'COPYING' for use and warranty information
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+import six
+
+from ipalib import api, errors
+from ipalib import Bytes, StrEnum, Bool, Str, Flag
+from ipalib.plugable import Registry
+from .baseldap import (
+ host_is_master,
+ add_missing_object_class,
+ pkey_to_value,
+ LDAPObject,
+ LDAPCreate,
+ LDAPDelete,
+ LDAPUpdate,
+ LDAPSearch,
+ LDAPRetrieve,
+ LDAPAddMember,
+ LDAPRemoveMember,
+ LDAPQuery,
+ LDAPAddAttribute,
+ LDAPRemoveAttribute)
+from ipalib import x509
+from ipalib import _, ngettext
+from ipalib import util
+from ipalib import output
+from ipapython.dn import DN
+
+import nss.nss as nss
+
+
+if six.PY3:
+ unicode = str
+
+__doc__ = _("""
+Services
+
+A IPA service represents a service that runs on a host. The IPA service
+record can store a Kerberos principal, an SSL certificate, or both.
+
+An IPA service can be managed directly from a machine, provided that
+machine has been given the correct permission. This is true even for
+machines other than the one the service is associated with. For example,
+requesting an SSL certificate using the host service principal credentials
+of the host. To manage a service using host credentials you need to
+kinit as the host:
+
+ # kinit -kt /etc/krb5.keytab host/ipa.example.com@EXAMPLE.COM
+
+Adding an IPA service allows the associated service to request an SSL
+certificate or keytab, but this is performed as a separate step; they
+are not produced as a result of adding the service.
+
+Only the public aspect of a certificate is stored in a service record;
+the private key is not stored.
+
+EXAMPLES:
+
+ Add a new IPA service:
+ ipa service-add HTTP/web.example.com
+
+ Allow a host to manage an IPA service certificate:
+ ipa service-add-host --hosts=web.example.com HTTP/web.example.com
+ ipa role-add-member --hosts=web.example.com certadmin
+
+ Override a default list of supported PAC types for the service:
+ ipa service-mod HTTP/web.example.com --pac-type=MS-PAC
+
+ A typical use case where overriding the PAC type is needed is NFS.
+ Currently the related code in the Linux kernel can only handle Kerberos
+ tickets up to a maximal size. Since the PAC data can become quite large it
+ is recommended to set --pac-type=NONE for NFS services.
+
+ Delete an IPA service:
+ ipa service-del HTTP/web.example.com
+
+ Find all IPA services associated with a host:
+ ipa service-find web.example.com
+
+ Find all HTTP services:
+ ipa service-find HTTP
+
+ Disable the service Kerberos key and SSL certificate:
+ ipa service-disable HTTP/web.example.com
+
+ Request a certificate for an IPA service:
+ ipa cert-request --principal=HTTP/web.example.com example.csr
+""") + _("""
+ Allow user to create a keytab:
+ ipa service-allow-create-keytab HTTP/web.example.com --users=tuser1
+""") + _("""
+ Generate and retrieve a keytab for an IPA service:
+ ipa-getkeytab -s ipa.example.com -p HTTP/web.example.com -k /etc/httpd/httpd.keytab
+
+""")
+
+register = Registry()
+
+output_params = (
+ Flag('has_keytab',
+ label=_('Keytab'),
+ ),
+ Str('managedby_host',
+ label='Managed by',
+ ),
+ Str('subject',
+ label=_('Subject'),
+ ),
+ Str('serial_number',
+ label=_('Serial Number'),
+ ),
+ Str('serial_number_hex',
+ label=_('Serial Number (hex)'),
+ ),
+ Str('issuer',
+ label=_('Issuer'),
+ ),
+ Str('valid_not_before',
+ label=_('Not Before'),
+ ),
+ Str('valid_not_after',
+ label=_('Not After'),
+ ),
+ Str('md5_fingerprint',
+ label=_('Fingerprint (MD5)'),
+ ),
+ Str('sha1_fingerprint',
+ label=_('Fingerprint (SHA1)'),
+ ),
+ Str('revocation_reason?',
+ label=_('Revocation reason'),
+ ),
+ Str('ipaallowedtoperform_read_keys_user',
+ label=_('Users allowed to retrieve keytab'),
+ ),
+ Str('ipaallowedtoperform_read_keys_group',
+ label=_('Groups allowed to retrieve keytab'),
+ ),
+ Str('ipaallowedtoperform_read_keys_host',
+ label=_('Hosts allowed to retrieve keytab'),
+ ),
+ Str('ipaallowedtoperform_read_keys_hostgroup',
+ label=_('Host Groups allowed to retrieve keytab'),
+ ),
+ Str('ipaallowedtoperform_write_keys_user',
+ label=_('Users allowed to create keytab'),
+ ),
+ Str('ipaallowedtoperform_write_keys_group',
+ label=_('Groups allowed to create keytab'),
+ ),
+ Str('ipaallowedtoperform_write_keys_host',
+ label=_('Hosts allowed to create keytab'),
+ ),
+ Str('ipaallowedtoperform_write_keys_hostgroup',
+ label=_('Host Groups allowed to create keytab'),
+ ),
+ Str('ipaallowedtoperform_read_keys',
+ label=_('Failed allowed to retrieve keytab'),
+ ),
+ Str('ipaallowedtoperform_write_keys',
+ label=_('Failed allowed to create keytab'),
+ ),
+)
+
+ticket_flags_params = (
+ Bool('ipakrbrequirespreauth?',
+ cli_name='requires_pre_auth',
+ label=_('Requires pre-authentication'),
+ doc=_('Pre-authentication is required for the service'),
+ flags=['virtual_attribute', 'no_search'],
+ ),
+ Bool('ipakrbokasdelegate?',
+ cli_name='ok_as_delegate',
+ label=_('Trusted for delegation'),
+ doc=_('Client credentials may be delegated to the service'),
+ flags=['virtual_attribute', 'no_search'],
+ ),
+)
+
+_ticket_flags_map = {
+ 'ipakrbrequirespreauth': 0x00000080,
+ 'ipakrbokasdelegate': 0x00100000,
+}
+
+_ticket_flags_default = _ticket_flags_map['ipakrbrequirespreauth']
+
+def split_any_principal(principal):
+ service = hostname = realm = None
+
+ # Break down the principal into its component parts, which may or
+ # may not include the realm.
+ sp = principal.split('/')
+ name_and_realm = None
+ if len(sp) > 2:
+ raise errors.MalformedServicePrincipal(reason=_('unable to determine service'))
+ elif len(sp) == 2:
+ service = sp[0]
+ if len(service) == 0:
+ raise errors.MalformedServicePrincipal(reason=_('blank service'))
+ name_and_realm = sp[1]
+ else:
+ name_and_realm = sp[0]
+
+ sr = name_and_realm.split('@')
+ if len(sr) > 2:
+ raise errors.MalformedServicePrincipal(
+ reason=_('unable to determine realm'))
+
+ hostname = sr[0].lower()
+ if len(sr) == 2:
+ realm = sr[1].upper()
+ # At some point we'll support multiple realms
+ if realm != api.env.realm:
+ raise errors.RealmMismatch()
+ else:
+ realm = api.env.realm
+
+ # Note that realm may be None.
+ return service, hostname, realm
+
+def split_principal(principal):
+ service, name, realm = split_any_principal(principal)
+ if service is None:
+ raise errors.MalformedServicePrincipal(reason=_('missing service'))
+ return service, name, realm
+
+def validate_principal(ugettext, principal):
+ (service, hostname, principal) = split_principal(principal)
+ return None
+
+def normalize_principal(principal):
+ # The principal is already validated when it gets here
+ (service, hostname, realm) = split_principal(principal)
+ # Put the principal back together again
+ principal = '%s/%s@%s' % (service, hostname, realm)
+ return unicode(principal)
+
+def validate_certificate(ugettext, cert):
+ """
+ Check whether the certificate is properly encoded to DER
+ """
+ if api.env.in_server:
+ x509.validate_certificate(cert, datatype=x509.DER)
+
+
+def revoke_certs(certs, logger=None):
+ """
+ revoke the certificates removed from host/service entry
+ """
+ for cert in certs:
+ try:
+ cert = x509.normalize_certificate(cert)
+ except errors.CertificateFormatError as e:
+ if logger is not None:
+ logger.info("Problem decoding certificate: %s" % e)
+
+ serial = unicode(x509.get_serial_number(cert, x509.DER))
+
+ try:
+ result = api.Command['cert_show'](unicode(serial))['result']
+ except errors.CertificateOperationError:
+ continue
+ if 'revocation_reason' in result:
+ continue
+ if x509.normalize_certificate(result['certificate']) != cert:
+ continue
+
+ try:
+ api.Command['cert_revoke'](unicode(serial),
+ revocation_reason=4)
+ except errors.NotImplementedError:
+ # some CA's might not implement revoke
+ pass
+
+
+
+def set_certificate_attrs(entry_attrs):
+ """
+ Set individual attributes from some values from a certificate.
+
+ entry_attrs is a dict of an entry
+
+ returns nothing
+ """
+ if not 'usercertificate' in entry_attrs:
+ return
+ if type(entry_attrs['usercertificate']) in (list, tuple):
+ cert = entry_attrs['usercertificate'][0]
+ else:
+ cert = entry_attrs['usercertificate']
+ cert = x509.normalize_certificate(cert)
+ cert = x509.load_certificate(cert, datatype=x509.DER)
+ entry_attrs['subject'] = unicode(cert.subject)
+ entry_attrs['serial_number'] = unicode(cert.serial_number)
+ entry_attrs['serial_number_hex'] = u'0x%X' % cert.serial_number
+ entry_attrs['issuer'] = unicode(cert.issuer)
+ entry_attrs['valid_not_before'] = unicode(cert.valid_not_before_str)
+ entry_attrs['valid_not_after'] = unicode(cert.valid_not_after_str)
+ entry_attrs['md5_fingerprint'] = unicode(nss.data_to_hex(nss.md5_digest(cert.der_data), 64)[0])
+ entry_attrs['sha1_fingerprint'] = unicode(nss.data_to_hex(nss.sha1_digest(cert.der_data), 64)[0])
+
+def check_required_principal(ldap, hostname, service):
+ """
+ Raise an error if the host of this prinicipal is an IPA master and one
+ of the principals required for proper execution.
+ """
+ try:
+ host_is_master(ldap, hostname)
+ except errors.ValidationError as e:
+ service_types = ['HTTP', 'ldap', 'DNS', 'dogtagldap']
+ if service in service_types:
+ raise errors.ValidationError(name='principal', error=_('This principal is required by the IPA master'))
+
+def update_krbticketflags(ldap, entry_attrs, attrs_list, options, existing):
+ add = remove = 0
+
+ for (name, value) in _ticket_flags_map.items():
+ if name not in options:
+ continue
+ if options[name]:
+ add |= value
+ else:
+ remove |= value
+
+ if not add and not remove:
+ return
+
+ if 'krbticketflags' not in entry_attrs and existing:
+ old_entry_attrs = ldap.get_entry(entry_attrs.dn, ['krbticketflags'])
+ else:
+ old_entry_attrs = entry_attrs
+
+ try:
+ ticket_flags = old_entry_attrs.single_value['krbticketflags']
+ ticket_flags = int(ticket_flags)
+ except (KeyError, ValueError):
+ ticket_flags = _ticket_flags_default
+
+ ticket_flags |= add
+ ticket_flags &= ~remove
+
+ entry_attrs['krbticketflags'] = [ticket_flags]
+ attrs_list.append('krbticketflags')
+
+def set_kerberos_attrs(entry_attrs, options):
+ if options.get('raw', False):
+ return
+
+ try:
+ ticket_flags = entry_attrs.single_value.get('krbticketflags',
+ _ticket_flags_default)
+ ticket_flags = int(ticket_flags)
+ except ValueError:
+ return
+
+ all_opt = options.get('all', False)
+
+ for (name, value) in _ticket_flags_map.items():
+ if name in options or all_opt:
+ entry_attrs[name] = bool(ticket_flags & value)
+
+def rename_ipaallowedtoperform_from_ldap(entry_attrs, options):
+ if options.get('raw', False):
+ return
+
+ for subtype in ('read_keys', 'write_keys'):
+ name = 'ipaallowedtoperform;%s' % subtype
+ if name in entry_attrs:
+ new_name = 'ipaallowedtoperform_%s' % subtype
+ entry_attrs[new_name] = entry_attrs.pop(name)
+
+def rename_ipaallowedtoperform_to_ldap(entry_attrs):
+ for subtype in ('read_keys', 'write_keys'):
+ name = 'ipaallowedtoperform_%s' % subtype
+ if name in entry_attrs:
+ new_name = 'ipaallowedtoperform;%s' % subtype
+ entry_attrs[new_name] = entry_attrs.pop(name)
+
+@register()
+class service(LDAPObject):
+ """
+ Service object.
+ """
+ container_dn = api.env.container_service
+ object_name = _('service')
+ object_name_plural = _('services')
+ object_class = [
+ 'krbprincipal', 'krbprincipalaux', 'krbticketpolicyaux', 'ipaobject',
+ 'ipaservice', 'pkiuser'
+ ]
+ possible_objectclasses = ['ipakrbprincipal', 'ipaallowedoperations']
+ permission_filter_objectclasses = ['ipaservice']
+ search_attributes = ['krbprincipalname', 'managedby', 'ipakrbauthzdata']
+ default_attributes = ['krbprincipalname', 'usercertificate', 'managedby',
+ 'ipakrbauthzdata', 'memberof', 'ipaallowedtoperform', 'krbprincipalauthind']
+ uuid_attribute = 'ipauniqueid'
+ attribute_members = {
+ 'managedby': ['host'],
+ 'memberof': ['role'],
+ 'ipaallowedtoperform_read_keys': ['user', 'group', 'host', 'hostgroup'],
+ 'ipaallowedtoperform_write_keys': ['user', 'group', 'host', 'hostgroup'],
+ }
+ bindable = True
+ relationships = {
+ 'managedby': ('Managed by', 'man_by_', 'not_man_by_'),
+ 'ipaallowedtoperform_read_keys': ('Allow to retrieve keytab by', 'retrieve_keytab_by_', 'not_retrieve_keytab_by_'),
+ 'ipaallowedtoperform_write_keys': ('Allow to create keytab by', 'write_keytab_by_', 'not_write_keytab_by'),
+ }
+ password_attributes = [('krbprincipalkey', 'has_keytab')]
+ managed_permissions = {
+ 'System: Read Services': {
+ 'replaces_global_anonymous_aci': True,
+ 'ipapermbindruletype': 'all',
+ 'ipapermright': {'read', 'search', 'compare'},
+ 'ipapermdefaultattr': {
+ 'objectclass',
+ 'ipauniqueid', 'managedby', 'memberof', 'usercertificate',
+ 'krbprincipalname', 'krbcanonicalname', 'krbprincipalaliases',
+ 'krbprincipalexpiration', 'krbpasswordexpiration',
+ 'krblastpwdchange', 'ipakrbauthzdata', 'ipakrbprincipalalias',
+ 'krbobjectreferences',
+ },
+ },
+ 'System: Add Services': {
+ 'ipapermright': {'add'},
+ 'replaces': [
+ '(target = "ldap:///krbprincipalname=*,cn=services,cn=accounts,$SUFFIX")(version 3.0;acl "permission:Add Services";allow (add) groupdn = "ldap:///cn=Add Services,cn=permissions,cn=pbac,$SUFFIX";)',
+ ],
+ 'default_privileges': {'Service Administrators'},
+ },
+ 'System: Manage Service Keytab': {
+ 'ipapermright': {'write'},
+ 'ipapermdefaultattr': {'krblastpwdchange', 'krbprincipalkey'},
+ 'replaces': [
+ '(targetattr = "krbprincipalkey || krblastpwdchange")(target = "ldap:///krbprincipalname=*,cn=services,cn=accounts,$SUFFIX")(version 3.0;acl "permission:Manage service keytab";allow (write) groupdn = "ldap:///cn=Manage service keytab,cn=permissions,cn=pbac,$SUFFIX";)',
+ ],
+ 'default_privileges': {'Service Administrators', 'Host Administrators'},
+ },
+ 'System: Manage Service Keytab Permissions': {
+ 'ipapermright': {'read', 'search', 'compare', 'write'},
+ 'ipapermdefaultattr': {
+ 'ipaallowedtoperform;write_keys',
+ 'ipaallowedtoperform;read_keys', 'objectclass'
+ },
+ 'default_privileges': {'Service Administrators', 'Host Administrators'},
+ },
+ 'System: Modify Services': {
+ 'ipapermright': {'write'},
+ 'ipapermdefaultattr': {'usercertificate'},
+ 'replaces': [
+ '(targetattr = "usercertificate")(target = "ldap:///krbprincipalname=*,cn=services,cn=accounts,$SUFFIX")(version 3.0;acl "permission:Modify Services";allow (write) groupdn = "ldap:///cn=Modify Services,cn=permissions,cn=pbac,$SUFFIX";)',
+ ],
+ 'default_privileges': {'Service Administrators'},
+ },
+ 'System: Remove Services': {
+ 'ipapermright': {'delete'},
+ 'replaces': [
+ '(target = "ldap:///krbprincipalname=*,cn=services,cn=accounts,$SUFFIX")(version 3.0;acl "permission:Remove Services";allow (delete) groupdn = "ldap:///cn=Remove Services,cn=permissions,cn=pbac,$SUFFIX";)',
+ ],
+ 'default_privileges': {'Service Administrators'},
+ },
+ }
+
+ label = _('Services')
+ label_singular = _('Service')
+
+ takes_params = (
+ Str('krbprincipalname', validate_principal,
+ cli_name='principal',
+ label=_('Principal'),
+ doc=_('Service principal'),
+ primary_key=True,
+ normalizer=lambda value: normalize_principal(value),
+ ),
+ Bytes('usercertificate*', validate_certificate,
+ cli_name='certificate',
+ label=_('Certificate'),
+ doc=_('Base-64 encoded service certificate'),
+ flags=['no_search',],
+ ),
+ StrEnum('ipakrbauthzdata*',
+ cli_name='pac_type',
+ label=_('PAC type'),
+ doc=_("Override default list of supported PAC types."
+ " Use 'NONE' to disable PAC support for this service,"
+ " e.g. this might be necessary for NFS services."),
+ values=(u'MS-PAC', u'PAD', u'NONE'),
+ ),
+ Str('krbprincipalauthind*',
+ cli_name='auth_ind',
+ label=_('Authentication Indicators'),
+ doc=_("Defines a whitelist for Authentication Indicators."
+ " Use 'otp' to allow OTP-based 2FA authentications."
+ " Use 'radius' to allow RADIUS-based 2FA authentications."
+ " Other values may be used for custom configurations."),
+ ),
+ ) + ticket_flags_params
+
+ def validate_ipakrbauthzdata(self, entry):
+ new_value = entry.get('ipakrbauthzdata', [])
+
+ if not new_value:
+ return
+
+ if not isinstance(new_value, (list, tuple)):
+ new_value = set([new_value])
+ else:
+ new_value = set(new_value)
+
+ if u'NONE' in new_value and len(new_value) > 1:
+ raise errors.ValidationError(name='ipakrbauthzdata',
+ error=_('NONE value cannot be combined with other PAC types'))
+
+ def get_dn(self, *keys, **kwargs):
+ keys = (normalize_principal(k) for k in keys)
+ return super(service, self).get_dn(*keys, **kwargs)
+
+
+@register()
+class service_add(LDAPCreate):
+ __doc__ = _('Add a new IPA service.')
+
+ msg_summary = _('Added service "%(value)s"')
+ member_attributes = ['managedby']
+ has_output_params = LDAPCreate.has_output_params + output_params
+ takes_options = LDAPCreate.takes_options + (
+ Flag('force',
+ label=_('Force'),
+ doc=_('force principal name even if not in DNS'),
+ ),
+ )
+
+ def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options):
+ assert isinstance(dn, DN)
+ (service, hostname, realm) = split_principal(keys[-1])
+ if service.lower() == 'host' and not options['force']:
+ raise errors.HostService()
+
+ try:
+ hostresult = api.Command['host_show'](hostname)['result']
+ except errors.NotFound:
+ raise errors.NotFound(
+ reason=_("The host '%s' does not exist to add a service to.") %
+ hostname)
+
+ self.obj.validate_ipakrbauthzdata(entry_attrs)
+
+ certs = options.get('usercertificate', [])
+ certs_der = [x509.normalize_certificate(c) for c in certs]
+ for dercert in certs_der:
+ x509.verify_cert_subject(ldap, hostname, dercert)
+ entry_attrs['usercertificate'] = certs_der
+
+ if not options.get('force', False):
+ # We know the host exists if we've gotten this far but we
+ # really want to discourage creating services for hosts that
+ # don't exist in DNS.
+ util.verify_host_resolvable(hostname)
+ if not 'managedby' in entry_attrs:
+ entry_attrs['managedby'] = hostresult['dn']
+
+ # Enforce ipaKrbPrincipalAlias to aid case-insensitive searches
+ # as krbPrincipalName/krbCanonicalName are case-sensitive in Kerberos
+ # schema
+ entry_attrs['ipakrbprincipalalias'] = keys[-1]
+
+ # Objectclass ipakrbprincipal providing ipakrbprincipalalias is not in
+ # in a list of default objectclasses, add it manually
+ entry_attrs['objectclass'].append('ipakrbprincipal')
+
+ update_krbticketflags(ldap, entry_attrs, attrs_list, options, False)
+
+ return dn
+
+ def post_callback(self, ldap, dn, entry_attrs, *keys, **options):
+ set_kerberos_attrs(entry_attrs, options)
+ rename_ipaallowedtoperform_from_ldap(entry_attrs, options)
+ return dn
+
+
+
+@register()
+class service_del(LDAPDelete):
+ __doc__ = _('Delete an IPA service.')
+
+ msg_summary = _('Deleted service "%(value)s"')
+ member_attributes = ['managedby']
+ def pre_callback(self, ldap, dn, *keys, **options):
+ assert isinstance(dn, DN)
+ # In the case of services we don't want IPA master services to be
+ # deleted. This is a limited few though. If the user has their own
+ # custom services allow them to manage them.
+ (service, hostname, realm) = split_principal(keys[-1])
+ check_required_principal(ldap, hostname, service)
+ if self.api.Command.ca_is_enabled()['result']:
+ try:
+ entry_attrs = ldap.get_entry(dn, ['usercertificate'])
+ except errors.NotFound:
+ self.obj.handle_not_found(*keys)
+ revoke_certs(entry_attrs.get('usercertificate', []), self.log)
+
+ return dn
+
+
+
+@register()
+class service_mod(LDAPUpdate):
+ __doc__ = _('Modify an existing IPA service.')
+
+ msg_summary = _('Modified service "%(value)s"')
+ takes_options = LDAPUpdate.takes_options
+ has_output_params = LDAPUpdate.has_output_params + output_params
+
+ member_attributes = ['managedby']
+
+ def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options):
+ assert isinstance(dn, DN)
+
+ self.obj.validate_ipakrbauthzdata(entry_attrs)
+
+ (service, hostname, realm) = split_principal(keys[-1])
+
+ # verify certificates
+ certs = entry_attrs.get('usercertificate') or []
+ certs_der = [x509.normalize_certificate(c) for c in certs]
+ for dercert in certs_der:
+ x509.verify_cert_subject(ldap, hostname, dercert)
+ # revoke removed certificates
+ if certs and self.api.Command.ca_is_enabled()['result']:
+ try:
+ entry_attrs_old = ldap.get_entry(dn, ['usercertificate'])
+ except errors.NotFound:
+ self.obj.handle_not_found(*keys)
+ old_certs = entry_attrs_old.get('usercertificate', [])
+ old_certs_der = [x509.normalize_certificate(c) for c in old_certs]
+ removed_certs_der = set(old_certs_der) - set(certs_der)
+ revoke_certs(removed_certs_der, self.log)
+
+ if certs:
+ entry_attrs['usercertificate'] = certs_der
+
+ update_krbticketflags(ldap, entry_attrs, attrs_list, options, True)
+
+ return dn
+
+ def post_callback(self, ldap, dn, entry_attrs, *keys, **options):
+ assert isinstance(dn, DN)
+ set_certificate_attrs(entry_attrs)
+ set_kerberos_attrs(entry_attrs, options)
+ rename_ipaallowedtoperform_from_ldap(entry_attrs, options)
+ return dn
+
+
+
+@register()
+class service_find(LDAPSearch):
+ __doc__ = _('Search for IPA services.')
+
+ msg_summary = ngettext(
+ '%(count)d service matched', '%(count)d services matched', 0
+ )
+ member_attributes = ['managedby']
+ takes_options = LDAPSearch.takes_options
+ has_output_params = LDAPSearch.has_output_params + output_params
+
+ def pre_callback(self, ldap, filter, attrs_list, base_dn, scope, *args, **options):
+ assert isinstance(base_dn, DN)
+ # lisp style!
+ custom_filter = '(&(objectclass=ipaService)' \
+ '(!(objectClass=posixAccount))' \
+ '(!(|(krbprincipalname=kadmin/*)' \
+ '(krbprincipalname=K/M@*)' \
+ '(krbprincipalname=krbtgt/*))' \
+ ')' \
+ ')'
+ return (
+ ldap.combine_filters((custom_filter, filter), rules=ldap.MATCH_ALL),
+ base_dn, scope
+ )
+
+ def post_callback(self, ldap, entries, truncated, *args, **options):
+ if options.get('pkey_only', False):
+ return truncated
+ for entry_attrs in entries:
+ self.obj.get_password_attributes(ldap, entry_attrs.dn, entry_attrs)
+ set_certificate_attrs(entry_attrs)
+ set_kerberos_attrs(entry_attrs, options)
+ rename_ipaallowedtoperform_from_ldap(entry_attrs, options)
+ return truncated
+
+
+
+@register()
+class service_show(LDAPRetrieve):
+ __doc__ = _('Display information about an IPA service.')
+
+ member_attributes = ['managedby']
+ takes_options = LDAPRetrieve.takes_options + (
+ Str('out?',
+ doc=_('file to store certificate in'),
+ ),
+ )
+ has_output_params = LDAPRetrieve.has_output_params + output_params
+
+ def post_callback(self, ldap, dn, entry_attrs, *keys, **options):
+ assert isinstance(dn, DN)
+ self.obj.get_password_attributes(ldap, dn, entry_attrs)
+
+ set_certificate_attrs(entry_attrs)
+ set_kerberos_attrs(entry_attrs, options)
+ rename_ipaallowedtoperform_from_ldap(entry_attrs, options)
+
+ return dn
+
+
+@register()
+class service_add_host(LDAPAddMember):
+ __doc__ = _('Add hosts that can manage this service.')
+
+ member_attributes = ['managedby']
+ has_output_params = LDAPAddMember.has_output_params + output_params
+
+
+
+@register()
+class service_remove_host(LDAPRemoveMember):
+ __doc__ = _('Remove hosts that can manage this service.')
+
+ member_attributes = ['managedby']
+ has_output_params = LDAPRemoveMember.has_output_params + output_params
+
+
+@register()
+class service_allow_retrieve_keytab(LDAPAddMember):
+ __doc__ = _('Allow users, groups, hosts or host groups to retrieve a keytab'
+ ' of this service.')
+ member_attributes = ['ipaallowedtoperform_read_keys']
+ has_output_params = LDAPAddMember.has_output_params + output_params
+
+ def pre_callback(self, ldap, dn, found, not_found, *keys, **options):
+ rename_ipaallowedtoperform_to_ldap(found)
+ rename_ipaallowedtoperform_to_ldap(not_found)
+ add_missing_object_class(ldap, u'ipaallowedoperations', dn)
+ return dn
+
+ def post_callback(self, ldap, completed, failed, dn, entry_attrs, *keys, **options):
+ rename_ipaallowedtoperform_from_ldap(entry_attrs, options)
+ rename_ipaallowedtoperform_from_ldap(failed, options)
+ return (completed, dn)
+
+
+@register()
+class service_disallow_retrieve_keytab(LDAPRemoveMember):
+ __doc__ = _('Disallow users, groups, hosts or host groups to retrieve a '
+ 'keytab of this service.')
+ member_attributes = ['ipaallowedtoperform_read_keys']
+ has_output_params = LDAPRemoveMember.has_output_params + output_params
+
+ def pre_callback(self, ldap, dn, found, not_found, *keys, **options):
+ rename_ipaallowedtoperform_to_ldap(found)
+ rename_ipaallowedtoperform_to_ldap(not_found)
+ return dn
+
+ def post_callback(self, ldap, completed, failed, dn, entry_attrs, *keys, **options):
+ rename_ipaallowedtoperform_from_ldap(entry_attrs, options)
+ rename_ipaallowedtoperform_from_ldap(failed, options)
+ return (completed, dn)
+
+
+@register()
+class service_allow_create_keytab(LDAPAddMember):
+ __doc__ = _('Allow users, groups, hosts or host groups to create a keytab '
+ 'of this service.')
+ member_attributes = ['ipaallowedtoperform_write_keys']
+ has_output_params = LDAPAddMember.has_output_params + output_params
+
+ def pre_callback(self, ldap, dn, found, not_found, *keys, **options):
+ rename_ipaallowedtoperform_to_ldap(found)
+ rename_ipaallowedtoperform_to_ldap(not_found)
+ add_missing_object_class(ldap, u'ipaallowedoperations', dn)
+ return dn
+
+ def post_callback(self, ldap, completed, failed, dn, entry_attrs, *keys, **options):
+ rename_ipaallowedtoperform_from_ldap(entry_attrs, options)
+ rename_ipaallowedtoperform_from_ldap(failed, options)
+ return (completed, dn)
+
+
+@register()
+class service_disallow_create_keytab(LDAPRemoveMember):
+ __doc__ = _('Disallow users, groups, hosts or host groups to create a '
+ 'keytab of this service.')
+ member_attributes = ['ipaallowedtoperform_write_keys']
+ has_output_params = LDAPRemoveMember.has_output_params + output_params
+
+ def pre_callback(self, ldap, dn, found, not_found, *keys, **options):
+ rename_ipaallowedtoperform_to_ldap(found)
+ rename_ipaallowedtoperform_to_ldap(not_found)
+ return dn
+
+ def post_callback(self, ldap, completed, failed, dn, entry_attrs, *keys, **options):
+ rename_ipaallowedtoperform_from_ldap(entry_attrs, options)
+ rename_ipaallowedtoperform_from_ldap(failed, options)
+ return (completed, dn)
+
+
+@register()
+class service_disable(LDAPQuery):
+ __doc__ = _('Disable the Kerberos key and SSL certificate of a service.')
+
+ has_output = output.standard_value
+ msg_summary = _('Disabled service "%(value)s"')
+ has_output_params = LDAPQuery.has_output_params + output_params
+
+ def execute(self, *keys, **options):
+ ldap = self.obj.backend
+
+ dn = self.obj.get_dn(*keys, **options)
+ entry_attrs = ldap.get_entry(dn, ['usercertificate'])
+
+ (service, hostname, realm) = split_principal(keys[-1])
+ check_required_principal(ldap, hostname, service)
+
+ # See if we do any work at all here and if not raise an exception
+ done_work = False
+
+ if self.api.Command.ca_is_enabled()['result']:
+ certs = entry_attrs.get('usercertificate', [])
+
+ if len(certs) > 0:
+ revoke_certs(certs, self.log)
+ # Remove the usercertificate altogether
+ entry_attrs['usercertificate'] = None
+ ldap.update_entry(entry_attrs)
+ done_work = True
+
+ self.obj.get_password_attributes(ldap, dn, entry_attrs)
+ if entry_attrs['has_keytab']:
+ ldap.remove_principal_key(dn)
+ done_work = True
+
+ if not done_work:
+ raise errors.AlreadyInactive()
+
+ return dict(
+ result=True,
+ value=pkey_to_value(keys[0], options),
+ )
+
+
+@register()
+class service_add_cert(LDAPAddAttribute):
+ __doc__ = _('Add new certificates to a service')
+ msg_summary = _('Added certificates to service principal "%(value)s"')
+ attribute = 'usercertificate'
+
+
+@register()
+class service_remove_cert(LDAPRemoveAttribute):
+ __doc__ = _('Remove certificates from a service')
+ msg_summary = _('Removed certificates from service principal "%(value)s"')
+ attribute = 'usercertificate'
+
+ def post_callback(self, ldap, dn, entry_attrs, *keys, **options):
+ assert isinstance(dn, DN)
+
+ if 'usercertificate' in options:
+ revoke_certs(options['usercertificate'], self.log)
+
+ return dn
diff --git a/ipaserver/plugins/servicedelegation.py b/ipaserver/plugins/servicedelegation.py
new file mode 100644
index 000000000..958c3b739
--- /dev/null
+++ b/ipaserver/plugins/servicedelegation.py
@@ -0,0 +1,550 @@
+#
+# Copyright (C) 2015 FreeIPA Contributors see COPYING for license
+#
+
+import six
+
+from ipalib import api
+from ipalib import Str
+from ipalib.plugable import Registry
+from .baseldap import (
+ LDAPObject,
+ LDAPAddMember,
+ LDAPRemoveMember,
+ LDAPCreate,
+ LDAPDelete,
+ LDAPSearch,
+ LDAPRetrieve)
+from .service import normalize_principal
+from ipalib import _, ngettext
+from ipalib import errors
+from ipapython.dn import DN
+
+if six.PY3:
+ unicode = str
+
+__doc__ = _("""
+Service Constrained Delegation
+
+Manage rules to allow constrained delegation of credentials so
+that a service can impersonate a user when communicating with another
+service without requiring the user to actually forward their TGT.
+This makes for a much better method of delegating credentials as it
+prevents exposure of the short term secret of the user.
+
+The naming convention is to append the word "target" or "targets" to
+a matching rule name. This is not mandatory but helps conceptually
+to associate rules and targets.
+
+A rule consists of two things:
+ - A list of targets the rule applies to
+ - A list of memberPrincipals that are allowed to delegate for
+ those targets
+
+A target consists of a list of principals that can be delegated.
+
+In English, a rule says that this principal can delegate as this
+list of principals, as defined by these targets.
+
+EXAMPLES:
+
+ Add a new constrained delegation rule:
+ ipa servicedelegationrule-add ftp-delegation
+
+ Add a new constrained delegation target:
+ ipa servicedelegationtarget-add ftp-delegation-target
+
+ Add a principal to the rule:
+ ipa servicedelegationrule-add-member --principals=ftp/ipa.example.com \
+ ftp-delegation
+
+ Add our target to the rule:
+ ipa servicedelegationrule-add-target \
+ --servicedelegationtargets=ftp-delegation-target ftp-delegation
+
+ Add a principal to the target:
+ ipa servicedelegationtarget-add-member --principals=ldap/ipa.example.com \
+ ftp-delegation-target
+
+ Display information about a named delegation rule and target:
+ ipa servicedelegationrule_show ftp-delegation
+ ipa servicedelegationtarget_show ftp-delegation-target
+
+ Remove a constrained delegation:
+ ipa servicedelegationrule-del ftp-delegation-target
+ ipa servicedelegationtarget-del ftp-delegation
+
+In this example the ftp service can get a TGT for the ldap service on
+the bound user's behalf.
+
+It is strongly discouraged to modify the delegations that ship with
+IPA, ipa-http-delegation and its targets ipa-cifs-delegation-targets and
+ipa-ldap-delegation-targets. Incorrect changes can remove the ability
+to delegate, causing the framework to stop functioning.
+""")
+
+register = Registry()
+
+PROTECTED_CONSTRAINT_RULES = (
+ u'ipa-http-delegation',
+)
+
+PROTECTED_CONSTRAINT_TARGETS = (
+ u'ipa-cifs-delegation-targets',
+ u'ipa-ldap-delegation-targets',
+
+)
+
+
+output_params = (
+ Str(
+ 'ipaallowedtarget_servicedelegationtarget',
+ label=_('Allowed Target'),
+ ),
+ Str(
+ 'ipaallowedtoimpersonate',
+ label=_('Allowed to Impersonate'),
+ ),
+ Str(
+ 'memberprincipal',
+ label=_('Member principals'),
+ ),
+ Str(
+ 'failed_memberprincipal',
+ label=_('Failed members'),
+ ),
+ Str(
+ 'ipaallowedtarget',
+ label=_('Failed targets'),
+ ),
+)
+
+
+class servicedelegation(LDAPObject):
+ """
+ Service Constrained Delegation base object.
+
+ This jams a couple of concepts into a single plugin because the
+ data is all stored in one place. There is a "rule" which has the
+ objectclass ipakrb5delegationacl. This is the entry that controls
+ the delegation. Other entries that lack this objectclass are
+ targets and define what services can be impersonated.
+ """
+ container_dn = api.env.container_s4u2proxy
+ object_class = ['groupofprincipals', 'top']
+
+ managed_permissions = {
+ 'System: Read Service Delegations': {
+ 'ipapermbindruletype': 'permission',
+ 'ipapermright': {'read', 'search', 'compare'},
+ 'ipapermtargetfilter': {'(objectclass=groupofprincipals)'},
+ 'ipapermdefaultattr': {
+ 'cn', 'objectclass', 'memberprincipal',
+ 'ipaallowedtarget',
+ },
+ 'default_privileges': {'Service Administrators'},
+ },
+ 'System: Add Service Delegations': {
+ 'ipapermright': {'add'},
+ 'ipapermtargetfilter': {'(objectclass=groupofprincipals)'},
+ 'default_privileges': {'Service Administrators'},
+ },
+ 'System: Remove Service Delegations': {
+ 'ipapermright': {'delete'},
+ 'ipapermtargetfilter': {'(objectclass=groupofprincipals)'},
+ 'default_privileges': {'Service Administrators'},
+ },
+ 'System: Modify Service Delegation Membership': {
+ 'ipapermright': {'write'},
+ 'ipapermtargetfilter': {'(objectclass=groupofprincipals)'},
+ 'ipapermdefaultattr': {'memberprincipal', 'ipaallowedtarget'},
+ 'default_privileges': {'Service Administrators'},
+ },
+ }
+
+ rdn_is_primary_key = True
+
+ takes_params = (
+ Str(
+ 'cn',
+ pattern='^[a-zA-Z0-9_.][a-zA-Z0-9_ .-]{0,253}[a-zA-Z0-9_.-]?$',
+ pattern_errmsg='may only include letters, numbers, _, -, ., '
+ 'and a space inside',
+ maxlength=255,
+ cli_name='delegation_name',
+ label=_('Delegation name'),
+ primary_key=True,
+ ),
+ )
+
+
+class servicedelegation_add_member(LDAPAddMember):
+ __doc__ = _('Add target to a named service delegation.')
+ member_attrs = ['memberprincipal']
+ member_attributes = []
+ member_names = {}
+ principal_attr = 'memberprincipal'
+ principal_failedattr = 'failed_memberprincipal'
+
+ has_output_params = LDAPAddMember.has_output_params + output_params
+
+ def get_options(self):
+ for option in super(servicedelegation_add_member, self).get_options():
+ yield option
+ for attr in self.member_attrs:
+ name = self.member_names[attr]
+ doc = self.member_param_doc % name
+ yield Str('%s*' % name, cli_name='%ss' % name, doc=doc,
+ label=_('member %s') % name, alwaysask=True)
+
+ def get_member_dns(self, **options):
+ """
+ There are no member_dns to return. memberPrincipal needs
+ special handling since it is just a principal, not a
+ full dn.
+ """
+ return dict(), dict()
+
+ def post_callback(self, ldap, completed, failed, dn, entry_attrs,
+ *keys, **options):
+ """
+ Add memberPrincipal values. This is done afterward because it isn't
+ a DN and the LDAPAddMember method explicitly only handles DNs.
+
+ A separate fake attribute name is used for failed members. This is
+ a reverse of the way this is typically handled in the *Member
+ routines, where a successful addition will be represented as
+ member/memberof_<attribute>. In this case, because memberPrincipal
+ isn't a DN, I'm doing the reverse, and creating a fake failed
+ attribute instead.
+ """
+ ldap = self.obj.backend
+ members = []
+ failed[self.principal_failedattr] = {}
+ failed[self.principal_failedattr][self.principal_attr] = []
+ names = options.get(self.member_names[self.principal_attr], [])
+ ldap_obj = self.api.Object['service']
+ if names:
+ for name in names:
+ if not name:
+ continue
+ name = normalize_principal(name)
+ obj_dn = ldap_obj.get_dn(name)
+ try:
+ ldap.get_entry(obj_dn, ['krbprincipalname'])
+ except errors.NotFound as e:
+ failed[self.principal_failedattr][
+ self.principal_attr].append((name, unicode(e)))
+ continue
+ try:
+ if name not in entry_attrs.get(self.principal_attr, []):
+ members.append(name)
+ else:
+ raise errors.AlreadyGroupMember()
+ except errors.PublicError as e:
+ failed[self.principal_failedattr][
+ self.principal_attr].append((name, unicode(e)))
+ else:
+ completed += 1
+
+ if members:
+ value = entry_attrs.setdefault(self.principal_attr, [])
+ value.extend(members)
+
+ try:
+ ldap.update_entry(entry_attrs)
+ except errors.EmptyModlist:
+ pass
+
+ return completed, dn
+
+
+class servicedelegation_remove_member(LDAPRemoveMember):
+ __doc__ = _('Remove member from a named service delegation.')
+
+ member_attrs = ['memberprincipal']
+ member_attributes = []
+ member_names = {}
+ principal_attr = 'memberprincipal'
+ principal_failedattr = 'failed_memberprincipal'
+
+ has_output_params = LDAPRemoveMember.has_output_params + output_params
+
+ def get_options(self):
+ for option in super(
+ servicedelegation_remove_member, self).get_options():
+ yield option
+ for attr in self.member_attrs:
+ name = self.member_names[attr]
+ doc = self.member_param_doc % name
+ yield Str('%s*' % name, cli_name='%ss' % name, doc=doc,
+ label=_('member %s') % name, alwaysask=True)
+
+ def get_member_dns(self, **options):
+ """
+ Need to ignore memberPrincipal for now and handle the difference
+ in objectclass between a rule and a target.
+ """
+ dns = {}
+ failed = {}
+ for attr in self.member_attrs:
+ dns[attr] = {}
+ if attr.lower() == 'memberprincipal':
+ # This will be handled later. memberprincipal isn't a
+ # DN so will blow up in assertions in baseldap.
+ continue
+ failed[attr] = {}
+ for ldap_obj_name in self.obj.attribute_members[attr]:
+ dns[attr][ldap_obj_name] = []
+ failed[attr][ldap_obj_name] = []
+ names = options.get(self.member_names[attr], [])
+ if not names:
+ continue
+ for name in names:
+ if not name:
+ continue
+ ldap_obj = self.api.Object[ldap_obj_name]
+ try:
+ dns[attr][ldap_obj_name].append(ldap_obj.get_dn(name))
+ except errors.PublicError as e:
+ failed[attr][ldap_obj_name].append((name, unicode(e)))
+ return dns, failed
+
+ def post_callback(self, ldap, completed, failed, dn, entry_attrs,
+ *keys, **options):
+ """
+ Remove memberPrincipal values. This is done afterward because it
+ isn't a DN and the LDAPAddMember method explicitly only handles DNs.
+
+ See servicedelegation_add_member() for an explanation of what
+ failedattr is.
+ """
+ ldap = self.obj.backend
+ failed[self.principal_failedattr] = {}
+ failed[self.principal_failedattr][self.principal_attr] = []
+ names = options.get(self.member_names[self.principal_attr], [])
+ if names:
+ for name in names:
+ if not name:
+ continue
+ name = normalize_principal(name)
+ try:
+ if name in entry_attrs.get(self.principal_attr, []):
+ entry_attrs[self.principal_attr].remove(name)
+ else:
+ raise errors.NotGroupMember()
+ except errors.PublicError as e:
+ failed[self.principal_failedattr][
+ self.principal_attr].append((name, unicode(e)))
+ else:
+ completed += 1
+
+ try:
+ ldap.update_entry(entry_attrs)
+ except errors.EmptyModlist:
+ pass
+
+ return completed, dn
+
+
+@register()
+class servicedelegationrule(servicedelegation):
+ """
+ A service delegation rule. This is the ACL that controls
+ what can be delegated to whom.
+ """
+ object_name = _('service delegation rule')
+ object_name_plural = _('service delegation rules')
+ object_class = ['ipakrb5delegationacl', 'groupofprincipals', 'top']
+ default_attributes = [
+ 'cn', 'memberprincipal', 'ipaallowedtarget',
+ 'ipaallowedtoimpersonate',
+ ]
+ attribute_members = {
+ # memberprincipal is not listed because it isn't a DN
+ 'ipaallowedtarget': ['servicedelegationtarget'],
+ }
+
+ label = _('Service delegation rules')
+ label_singular = _('Service delegation rule')
+
+
+@register()
+class servicedelegationrule_add(LDAPCreate):
+ __doc__ = _('Create a new service delegation rule.')
+
+ msg_summary = _('Added service delegation rule "%(value)s"')
+
+
+@register()
+class servicedelegationrule_del(LDAPDelete):
+ __doc__ = _('Delete service delegation.')
+
+ msg_summary = _('Deleted service delegation "%(value)s"')
+
+ def pre_callback(self, ldap, dn, *keys, **options):
+ assert isinstance(dn, DN)
+ if keys[0] in PROTECTED_CONSTRAINT_RULES:
+ raise errors.ProtectedEntryError(
+ label=_(u'service delegation rule'),
+ key=keys[0],
+ reason=_(u'privileged service delegation rule')
+ )
+ return dn
+
+
+@register()
+class servicedelegationrule_find(LDAPSearch):
+ __doc__ = _('Search for service delegations rule.')
+
+ has_output_params = LDAPSearch.has_output_params + output_params
+
+ msg_summary = ngettext(
+ '%(count)d service delegation rule matched',
+ '%(count)d service delegation rules matched', 0
+ )
+
+
+@register()
+class servicedelegationrule_show(LDAPRetrieve):
+ __doc__ = _('Display information about a named service delegation rule.')
+
+ has_output_params = LDAPRetrieve.has_output_params + output_params
+
+
+@register()
+class servicedelegationrule_add_member(servicedelegation_add_member):
+ __doc__ = _('Add member to a named service delegation rule.')
+
+ member_names = {
+ 'memberprincipal': 'principal',
+ }
+
+
+@register()
+class servicedelegationrule_remove_member(servicedelegation_remove_member):
+ __doc__ = _('Remove member from a named service delegation rule.')
+ member_names = {
+ 'memberprincipal': 'principal',
+ }
+
+
+@register()
+class servicedelegationrule_add_target(LDAPAddMember):
+ __doc__ = _('Add target to a named service delegation rule.')
+
+ member_attributes = ['ipaallowedtarget']
+ attribute_members = {
+ 'ipaallowedtarget': ['servicedelegationtarget'],
+ }
+ has_output_params = LDAPAddMember.has_output_params + output_params
+
+
+@register()
+class servicedelegationrule_remove_target(LDAPRemoveMember):
+ __doc__ = _('Remove target from a named service delegation rule.')
+ member_attributes = ['ipaallowedtarget']
+ attribute_members = {
+ 'ipaallowedtarget': ['servicedelegationtarget'],
+ }
+ has_output_params = LDAPRemoveMember.has_output_params + output_params
+
+
+@register()
+class servicedelegationtarget(servicedelegation):
+ object_name = _('service delegation target')
+ object_name_plural = _('service delegation targets')
+ object_class = ['groupofprincipals', 'top']
+ default_attributes = [
+ 'cn', 'memberprincipal',
+ ]
+ attribute_members = {}
+
+ label = _('Service delegation targets')
+ label_singular = _('Service delegation target')
+
+
+@register()
+class servicedelegationtarget_add(LDAPCreate):
+ __doc__ = _('Create a new service delegation target.')
+
+ msg_summary = _('Added service delegation target "%(value)s"')
+
+
+@register()
+class servicedelegationtarget_del(LDAPDelete):
+ __doc__ = _('Delete service delegation target.')
+
+ msg_summary = _('Deleted service delegation target "%(value)s"')
+
+ def pre_callback(self, ldap, dn, *keys, **options):
+ assert isinstance(dn, DN)
+ if keys[0] in PROTECTED_CONSTRAINT_TARGETS:
+ raise errors.ProtectedEntryError(
+ label=_(u'service delegation target'),
+ key=keys[0],
+ reason=_(u'privileged service delegation target')
+ )
+ return dn
+
+
+@register()
+class servicedelegationtarget_find(LDAPSearch):
+ __doc__ = _('Search for service delegation target.')
+
+ has_output_params = LDAPSearch.has_output_params + output_params
+
+ msg_summary = ngettext(
+ '%(count)d service delegation target matched',
+ '%(count)d service delegation targets matched', 0
+ )
+
+ def pre_callback(self, ldap, filters, attrs_list, base_dn, scope,
+ term=None, **options):
+ """
+ Exclude rules from the search output. A target contains a subset
+ of a rule objectclass.
+ """
+ search_kw = self.args_options_2_entry(**options)
+ search_kw['objectclass'] = self.obj.object_class
+ attr_filter = ldap.make_filter(search_kw, rules=ldap.MATCH_ALL)
+ rule_kw = {'objectclass': 'ipakrb5delegationacl'}
+ target_filter = ldap.make_filter(rule_kw, rules=ldap.MATCH_NONE)
+ attr_filter = ldap.combine_filters(
+ (target_filter, attr_filter), rules=ldap.MATCH_ALL
+ )
+
+ search_kw = {}
+ for a in self.obj.default_attributes:
+ search_kw[a] = term
+
+ term_filter = ldap.make_filter(search_kw, exact=False)
+
+ sfilter = ldap.combine_filters(
+ (term_filter, attr_filter), rules=ldap.MATCH_ALL
+ )
+ return sfilter, base_dn, ldap.SCOPE_ONELEVEL
+
+
+@register()
+class servicedelegationtarget_show(LDAPRetrieve):
+ __doc__ = _('Display information about a named service delegation target.')
+
+ has_output_params = LDAPRetrieve.has_output_params + output_params
+
+
+@register()
+class servicedelegationtarget_add_member(servicedelegation_add_member):
+ __doc__ = _('Add member to a named service delegation target.')
+
+ member_names = {
+ 'memberprincipal': 'principal',
+ }
+
+
+@register()
+class servicedelegationtarget_remove_member(servicedelegation_remove_member):
+ __doc__ = _('Remove member from a named service delegation target.')
+ member_names = {
+ 'memberprincipal': 'principal',
+ }
diff --git a/ipaserver/plugins/session.py b/ipaserver/plugins/session.py
new file mode 100644
index 000000000..b03b6b410
--- /dev/null
+++ b/ipaserver/plugins/session.py
@@ -0,0 +1,33 @@
+#
+# Copyright (C) 2015 FreeIPA Contributors see COPYING for license
+#
+
+from ipalib import api, Command
+from ipalib.request import context
+from ipalib.plugable import Registry
+
+if api.env.in_server:
+ from ipalib.session import session_mgr
+
+register = Registry()
+
+
+@register()
+class session_logout(Command):
+ '''
+ RPC command used to log the current user out of their session.
+ '''
+ NO_CLI = True
+
+ def execute(self, *args, **options):
+ session_data = getattr(context, 'session_data', None)
+ if session_data is None:
+ self.debug('session logout command: no session_data found')
+ else:
+ session_id = session_data.get('session_id')
+ self.debug('session logout command: session_id=%s', session_id)
+
+ # Notifiy registered listeners
+ session_mgr.auth_mgr.logout(session_data)
+
+ return dict(result=None)
diff --git a/ipaserver/plugins/stageuser.py b/ipaserver/plugins/stageuser.py
new file mode 100644
index 000000000..86b1935f3
--- /dev/null
+++ b/ipaserver/plugins/stageuser.py
@@ -0,0 +1,745 @@
+# Authors:
+# Thierry Bordaz <tbordaz@redhat.com>
+#
+# Copyright (C) 2014 Red Hat
+# see file 'COPYING' for use and warranty information
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+import posixpath
+from copy import deepcopy
+
+import six
+
+from ipalib import api, errors
+from ipalib import Bool
+from ipalib.plugable import Registry
+from .baseldap import (
+ LDAPCreate,
+ LDAPQuery,
+ DN)
+from . import baseldap
+from .baseuser import (
+ baseuser,
+ baseuser_add,
+ baseuser_del,
+ baseuser_mod,
+ baseuser_find,
+ baseuser_show,
+ NO_UPG_MAGIC,
+ baseuser_pwdchars,
+ baseuser_output_params,
+ status_baseuser_output_params,
+ baseuser_add_manager,
+ baseuser_remove_manager)
+from ipalib.request import context
+from ipalib import _, ngettext
+from ipalib import output
+from ipaplatform.paths import paths
+from ipapython.ipautil import ipa_generate_password
+from ipalib.capabilities import client_has_capability
+
+if six.PY3:
+ unicode = str
+
+__doc__ = _("""
+Stageusers
+
+Manage stage user entries.
+
+Stage user entries are directly under the container: "cn=stage users,
+cn=accounts, cn=provisioning, SUFFIX".
+Users can not authenticate with those entries (even if the entries
+contain credentials). Those entries are only candidate to become Active entries.
+
+Active user entries are Posix users directly under the container: "cn=accounts, SUFFIX".
+Users can authenticate with Active entries, at the condition they have
+credentials.
+
+Deleted user entries are Posix users directly under the container: "cn=deleted users,
+cn=accounts, cn=provisioning, SUFFIX".
+Users can not authenticate with those entries, even if the entries contain credentials.
+
+The stage user container contains entries:
+ - created by 'stageuser-add' commands that are Posix users,
+ - created by external provisioning system.
+
+A valid stage user entry MUST have:
+ - entry RDN is 'uid',
+ - ipaUniqueID is 'autogenerate'.
+
+IPA supports a wide range of username formats, but you need to be aware of any
+restrictions that may apply to your particular environment. For example,
+usernames that start with a digit or usernames that exceed a certain length
+may cause problems for some UNIX systems.
+Use 'ipa config-mod' to change the username format allowed by IPA tools.
+
+
+EXAMPLES:
+
+ Add a new stageuser:
+ ipa stageuser-add --first=Tim --last=User --password tuser1
+
+ Add a stageuser from the deleted users container:
+ ipa stageuser-add --first=Tim --last=User --from-delete tuser1
+
+""")
+
+register = Registry()
+
+
+stageuser_output_params = baseuser_output_params
+
+status_output_params = status_baseuser_output_params
+
+@register()
+class stageuser(baseuser):
+ """
+ Stage User object
+ A Stage user is not an Active user and can not be used to bind with.
+ Stage container is: cn=staged users,cn=accounts,cn=provisioning,SUFFIX
+ Stage entry conforms the schema
+ Stage entry RDN attribute is 'uid'
+ Stage entry are disabled (nsAccountLock: True) through cos
+ """
+
+ container_dn = baseuser.stage_container_dn
+ label = _('Stage Users')
+ label_singular = _('Stage User')
+ object_name = _('stage user')
+ object_name_plural = _('stage users')
+ managed_permissions = {
+ #
+ # Stage container
+ #
+ # Allowed to create stage user
+ 'System: Add Stage User': {
+ 'ipapermlocation': DN(baseuser.stage_container_dn, api.env.basedn),
+ 'ipapermbindruletype': 'permission',
+ 'ipapermtarget': DN('uid=*', baseuser.stage_container_dn, api.env.basedn),
+ 'ipapermtargetfilter': {'(objectclass=*)'},
+ 'ipapermright': {'add'},
+ 'ipapermdefaultattr': {'*'},
+ 'default_privileges': {'Stage User Administrators', 'Stage User Provisioning'},
+ },
+ # Allow to read kerberos/password
+ 'System: Read Stage User password': {
+ 'ipapermlocation': DN(baseuser.stage_container_dn, api.env.basedn),
+ 'ipapermbindruletype': 'permission',
+ 'ipapermtarget': DN('uid=*', baseuser.stage_container_dn, api.env.basedn),
+ 'ipapermtargetfilter': {'(objectclass=*)'},
+ 'ipapermright': {'read', 'search', 'compare'},
+ 'ipapermdefaultattr': {
+ 'userPassword', 'krbPrincipalKey',
+ },
+ 'default_privileges': {'Stage User Administrators'},
+ },
+ # Allow to update stage user
+ 'System: Modify Stage User': {
+ 'ipapermlocation': DN(baseuser.stage_container_dn, api.env.basedn),
+ 'ipapermbindruletype': 'permission',
+ 'ipapermtarget': DN('uid=*', baseuser.stage_container_dn, api.env.basedn),
+ 'ipapermtargetfilter': {'(objectclass=*)'},
+ 'ipapermright': {'write'},
+ 'ipapermdefaultattr': {'*'},
+ 'default_privileges': {'Stage User Administrators'},
+ },
+ # Allow to delete stage user
+ 'System: Remove Stage User': {
+ 'ipapermlocation': DN(baseuser.stage_container_dn, api.env.basedn),
+ 'ipapermbindruletype': 'permission',
+ 'ipapermtarget': DN('uid=*', baseuser.stage_container_dn, api.env.basedn),
+ 'ipapermtargetfilter': {'(objectclass=*)'},
+ 'ipapermright': {'delete'},
+ 'ipapermdefaultattr': {'*'},
+ 'default_privileges': {'Stage User Administrators'},
+ },
+ # Allow to read any attributes of stage users
+ 'System: Read Stage Users': {
+ 'ipapermlocation': DN(baseuser.stage_container_dn, api.env.basedn),
+ 'ipapermbindruletype': 'permission',
+ 'ipapermtarget': DN('uid=*', baseuser.stage_container_dn, api.env.basedn),
+ 'ipapermtargetfilter': {'(objectclass=*)'},
+ 'ipapermright': {'read', 'search', 'compare'},
+ 'ipapermdefaultattr': {'*'},
+ 'default_privileges': {'Stage User Administrators'},
+ },
+ #
+ # Preserve container
+ #
+ # Allow to read Preserved User
+ 'System: Read Preserved Users': {
+ 'ipapermlocation': DN(baseuser.delete_container_dn, api.env.basedn),
+ 'ipapermbindruletype': 'permission',
+ 'ipapermtarget': DN('uid=*', baseuser.delete_container_dn, api.env.basedn),
+ 'ipapermtargetfilter': {'(objectclass=posixaccount)'},
+ 'ipapermright': {'read', 'search', 'compare'},
+ 'ipapermdefaultattr': {'*'},
+ 'default_privileges': {'Stage User Administrators'},
+ },
+ # Allow to update Preserved User
+ 'System: Modify Preserved Users': {
+ 'ipapermlocation': DN(baseuser.delete_container_dn, api.env.basedn),
+ 'ipapermbindruletype': 'permission',
+ 'ipapermtarget': DN('uid=*', baseuser.delete_container_dn, api.env.basedn),
+ 'ipapermtargetfilter': {'(objectclass=posixaccount)'},
+ 'ipapermright': {'write'},
+ 'ipapermdefaultattr': {'*'},
+ 'default_privileges': {'Stage User Administrators'},
+ },
+ # Allow to reset Preserved User password
+ 'System: Reset Preserved User password': {
+ 'ipapermlocation': DN(baseuser.delete_container_dn, api.env.basedn),
+ 'ipapermbindruletype': 'permission',
+ 'ipapermtarget': DN('uid=*', baseuser.delete_container_dn, api.env.basedn),
+ 'ipapermtargetfilter': {'(objectclass=posixaccount)'},
+ 'ipapermright': {'read', 'search', 'write'},
+ 'ipapermdefaultattr': {
+ 'userPassword', 'krbPrincipalKey','krbPasswordExpiration','krbLastPwdChange'
+ },
+ 'default_privileges': {'Stage User Administrators'},
+ },
+ # Allow to delete preserved user
+ 'System: Remove preserved User': {
+ 'ipapermlocation': DN(baseuser.delete_container_dn, api.env.basedn),
+ 'ipapermbindruletype': 'permission',
+ 'ipapermtarget': DN('uid=*', baseuser.delete_container_dn, api.env.basedn),
+ 'ipapermtargetfilter': {'(objectclass=*)'},
+ 'ipapermright': {'delete'},
+ 'ipapermdefaultattr': {'*'},
+ 'default_privileges': {'Stage User Administrators'},
+ },
+ #
+ # Active container
+ #
+ # Stage user administrators need write right on RDN when
+ # the active user is deleted (preserved)
+ 'System: Modify User RDN': {
+ 'ipapermlocation': DN(baseuser.active_container_dn, api.env.basedn),
+ 'ipapermbindruletype': 'permission',
+ 'ipapermtarget': DN('uid=*', baseuser.active_container_dn, api.env.basedn),
+ 'ipapermtargetfilter': {'(objectclass=posixaccount)'},
+ 'ipapermright': {'write'},
+ 'ipapermdefaultattr': {'uid'},
+ 'default_privileges': {'Stage User Administrators'},
+ },
+ #
+ # Cross containers autorization
+ #
+ # Allow to move active user to preserve container (user-del --preserve)
+ # Note: targetfilter is the target parent container
+ 'System: Preserve User': {
+ 'ipapermlocation': DN(api.env.basedn),
+ 'ipapermbindruletype': 'permission',
+ 'ipapermtargetfrom': DN(baseuser.active_container_dn, api.env.basedn),
+ 'ipapermtargetto': DN(baseuser.delete_container_dn, api.env.basedn),
+ 'ipapermtargetfilter': {'(objectclass=nsContainer)'},
+ 'ipapermright': {'moddn'},
+ 'default_privileges': {'Stage User Administrators'},
+ },
+ # Allow to move preserved user to active container (user-undel)
+ # Note: targetfilter is the target parent container
+ 'System: Undelete User': {
+ 'ipapermlocation': DN(api.env.basedn),
+ 'ipapermbindruletype': 'permission',
+ 'ipapermtargetfrom': DN(baseuser.delete_container_dn, api.env.basedn),
+ 'ipapermtargetto': DN(baseuser.active_container_dn, api.env.basedn),
+ 'ipapermtargetfilter': {'(objectclass=nsContainer)'},
+ 'ipapermright': {'moddn'},
+ 'default_privileges': {'Stage User Administrators'},
+ },
+ }
+
+@register()
+class stageuser_add(baseuser_add):
+ __doc__ = _('Add a new stage user.')
+
+ msg_summary = _('Added stage user "%(value)s"')
+
+ has_output_params = baseuser_add.has_output_params + stageuser_output_params
+
+ takes_options = LDAPCreate.takes_options + (
+ Bool(
+ 'from_delete?',
+ deprecated=True,
+ doc=_('Create Stage user in from a delete user'),
+ cli_name='from_delete',
+ flags={'no_option'},
+ ),
+ )
+
+ def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options):
+ assert isinstance(dn, DN)
+
+ # then givenname and sn are required attributes
+ if 'givenname' not in entry_attrs:
+ raise errors.RequirementError(name='givenname', error=_('givenname is required'))
+
+ if 'sn' not in entry_attrs:
+ raise errors.RequirementError(name='sn', error=_('sn is required'))
+
+ # we don't want an user private group to be created for this user
+ # add NO_UPG_MAGIC description attribute to let the DS plugin know
+ entry_attrs.setdefault('description', [])
+ entry_attrs['description'].append(NO_UPG_MAGIC)
+
+ # uidNumber/gidNumber
+ entry_attrs.setdefault('uidnumber', baseldap.DNA_MAGIC)
+ entry_attrs.setdefault('gidnumber', baseldap.DNA_MAGIC)
+
+ if not client_has_capability(
+ options['version'], 'optional_uid_params'):
+ # https://fedorahosted.org/freeipa/ticket/2886
+ # Old clients say 999 (OLD_DNA_MAGIC) when they really mean
+ # "assign a value dynamically".
+ OLD_DNA_MAGIC = 999
+ if entry_attrs.get('uidnumber') == OLD_DNA_MAGIC:
+ entry_attrs['uidnumber'] = baseldap.DNA_MAGIC
+ if entry_attrs.get('gidnumber') == OLD_DNA_MAGIC:
+ entry_attrs['gidnumber'] = baseldap.DNA_MAGIC
+
+
+ # Check the lenght of the RDN (uid) value
+ config = ldap.get_ipa_config()
+ if 'ipamaxusernamelength' in config:
+ if len(keys[-1]) > int(config.get('ipamaxusernamelength')[0]):
+ raise errors.ValidationError(
+ name=self.obj.primary_key.cli_name,
+ error=_('can be at most %(len)d characters') % dict(
+ len = int(config.get('ipamaxusernamelength')[0])
+ )
+ )
+ default_shell = config.get('ipadefaultloginshell', [paths.SH])[0]
+ entry_attrs.setdefault('loginshell', default_shell)
+ # hack so we can request separate first and last name in CLI
+ full_name = '%s %s' % (entry_attrs['givenname'], entry_attrs['sn'])
+ entry_attrs.setdefault('cn', full_name)
+
+ # Homedirectory
+ # (order is : option, placeholder (TBD), CLI default value (here in config))
+ if 'homedirectory' not in entry_attrs:
+ # get home's root directory from config
+ homes_root = config.get('ipahomesrootdir', [paths.HOME_DIR])[0]
+ # build user's home directory based on his uid
+ entry_attrs['homedirectory'] = posixpath.join(homes_root, keys[-1])
+
+ # Kerberos principal
+ entry_attrs.setdefault('krbprincipalname', '%s@%s' % (entry_attrs['uid'], api.env.realm))
+
+
+ # If requested, generate a userpassword
+ if 'userpassword' not in entry_attrs and options.get('random'):
+ entry_attrs['userpassword'] = ipa_generate_password(baseuser_pwdchars)
+ # save the password so it can be displayed in post_callback
+ setattr(context, 'randompassword', entry_attrs['userpassword'])
+
+ # Check the email or create it
+ if 'mail' in entry_attrs:
+ entry_attrs['mail'] = self.obj.normalize_and_validate_email(entry_attrs['mail'], config)
+ else:
+ # No e-mail passed in. If we have a default e-mail domain set
+ # then we'll add it automatically.
+ defaultdomain = config.get('ipadefaultemaildomain', [None])[0]
+ if defaultdomain:
+ entry_attrs['mail'] = self.obj.normalize_and_validate_email(keys[-1], config)
+
+ # If the manager is defined, check it is a ACTIVE user to validate it
+ if 'manager' in entry_attrs:
+ entry_attrs['manager'] = self.obj.normalize_manager(entry_attrs['manager'], self.obj.active_container_dn)
+
+ if ('objectclass' in entry_attrs
+ and 'userclass' in entry_attrs
+ and 'ipauser' not in entry_attrs['objectclass']):
+ entry_attrs['objectclass'].append('ipauser')
+
+ if 'ipatokenradiusconfiglink' in entry_attrs:
+ cl = entry_attrs['ipatokenradiusconfiglink']
+ if cl:
+ if 'objectclass' not in entry_attrs:
+ _entry = ldap.get_entry(dn, ['objectclass'])
+ entry_attrs['objectclass'] = _entry['objectclass']
+
+ if 'ipatokenradiusproxyuser' not in entry_attrs['objectclass']:
+ entry_attrs['objectclass'].append('ipatokenradiusproxyuser')
+
+ answer = self.api.Object['radiusproxy'].get_dn_if_exists(cl)
+ entry_attrs['ipatokenradiusconfiglink'] = answer
+
+ self.pre_common_callback(ldap, dn, entry_attrs, attrs_list, *keys,
+ **options)
+
+ return dn
+
+ def post_callback(self, ldap, dn, entry_attrs, *keys, **options):
+ assert isinstance(dn, DN)
+ config = ldap.get_ipa_config()
+
+ # Fetch the entry again to update memberof, mep data, etc updated
+ # at the end of the transaction.
+ newentry = ldap.get_entry(dn, ['*'])
+ entry_attrs.update(newentry)
+
+ if options.get('random', False):
+ try:
+ entry_attrs['randompassword'] = unicode(getattr(context, 'randompassword'))
+ except AttributeError:
+ # if both randompassword and userpassword options were used
+ pass
+
+ self.post_common_callback(ldap, dn, entry_attrs, *keys, **options)
+ return dn
+
+@register()
+class stageuser_del(baseuser_del):
+ __doc__ = _('Delete a stage user.')
+
+ msg_summary = _('Deleted stage user "%(value)s"')
+
+@register()
+class stageuser_mod(baseuser_mod):
+ __doc__ = _('Modify a stage user.')
+
+ msg_summary = _('Modified stage user "%(value)s"')
+
+ has_output_params = baseuser_mod.has_output_params + stageuser_output_params
+
+ def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options):
+ self.pre_common_callback(ldap, dn, entry_attrs, attrs_list, *keys,
+ **options)
+ # Make sure it is not possible to authenticate with a Stage user account
+ if 'nsaccountlock' in entry_attrs:
+ del entry_attrs['nsaccountlock']
+ return dn
+
+ def post_callback(self, ldap, dn, entry_attrs, *keys, **options):
+ self.post_common_callback(ldap, dn, entry_attrs, **options)
+ if 'nsaccountlock' in entry_attrs:
+ del entry_attrs['nsaccountlock']
+ return dn
+
+@register()
+class stageuser_find(baseuser_find):
+ __doc__ = _('Search for stage users.')
+
+ member_attributes = ['memberof']
+ has_output_params = baseuser_find.has_output_params + stageuser_output_params
+
+ def pre_callback(self, ldap, filter, attrs_list, base_dn, scope, *keys, **options):
+ assert isinstance(base_dn, DN)
+ self.pre_common_callback(ldap, filter, attrs_list, base_dn, scope,
+ *keys, **options)
+
+ container_filter = "(objectclass=posixaccount)"
+ # provisioning system can create non posixaccount stage user
+ # but then they have to create inetOrgPerson stage user
+ stagefilter = filter.replace(container_filter,
+ "(|%s(objectclass=inetOrgPerson))" % container_filter)
+ self.log.debug("stageuser_find: pre_callback new filter=%s " % (stagefilter))
+ return (stagefilter, base_dn, scope)
+
+ def post_callback(self, ldap, entries, truncated, *args, **options):
+ if options.get('pkey_only', False):
+ return truncated
+ self.post_common_callback(ldap, entries, lockout=True, **options)
+ return truncated
+
+ msg_summary = ngettext(
+ '%(count)d user matched', '%(count)d users matched', 0
+ )
+
+@register()
+class stageuser_show(baseuser_show):
+ __doc__ = _('Display information about a stage user.')
+
+ has_output_params = baseuser_show.has_output_params + stageuser_output_params
+
+ def pre_callback(self, ldap, dn, attrs_list, *keys, **options):
+ assert isinstance(dn, DN)
+ self.pre_common_callback(ldap, dn, attrs_list, *keys, **options)
+ return dn
+
+ def post_callback(self, ldap, dn, entry_attrs, *keys, **options):
+ entry_attrs['nsaccountlock'] = True
+ self.post_common_callback(ldap, dn, entry_attrs, *keys, **options)
+ return dn
+
+
+@register()
+class stageuser_activate(LDAPQuery):
+ __doc__ = _('Activate a stage user.')
+
+ msg_summary = _('Activate a stage user "%(value)s"')
+
+ preserved_DN_syntax_attrs = ('manager', 'managedby', 'secretary')
+
+ searched_operational_attributes = ['uidNumber', 'gidNumber', 'nsAccountLock', 'ipauniqueid']
+
+ has_output = output.standard_entry
+ has_output_params = LDAPQuery.has_output_params + stageuser_output_params
+
+ def _check_validy(self, dn, entry):
+ if dn[0].attr != 'uid':
+ raise errors.ValidationError(
+ name=self.obj.primary_key.cli_name,
+ error=_('Entry RDN is not \'uid\''),
+ )
+ for attr in ('cn', 'sn', 'uid'):
+ if attr not in entry:
+ raise errors.ValidationError(
+ name=self.obj.primary_key.cli_name,
+ error=_('Entry has no \'%(attribute)s\'') % dict(attribute=attr),
+ )
+
+ def _build_new_entry(self, ldap, dn, entry_from, entry_to):
+ config = ldap.get_ipa_config()
+
+ if 'uidnumber' not in entry_from:
+ entry_to['uidnumber'] = baseldap.DNA_MAGIC
+ if 'gidnumber' not in entry_from:
+ entry_to['gidnumber'] = baseldap.DNA_MAGIC
+ if 'homedirectory' not in entry_from:
+ # get home's root directory from config
+ homes_root = config.get('ipahomesrootdir', [paths.HOME_DIR])[0]
+ # build user's home directory based on his uid
+ entry_to['homedirectory'] = posixpath.join(homes_root, dn[0].value)
+ if 'ipamaxusernamelength' in config:
+ if len(dn[0].value) > int(config.get('ipamaxusernamelength')[0]):
+ raise errors.ValidationError(
+ name=self.obj.primary_key.cli_name,
+ error=_('can be at most %(len)d characters') % dict(
+ len = int(config.get('ipamaxusernamelength')[0])
+ )
+ )
+ if 'loginshell' not in entry_from:
+ default_shell = config.get('ipadefaultloginshell', [paths.SH])[0]
+ if default_shell:
+ entry_to.setdefault('loginshell', default_shell)
+
+ if 'givenname' not in entry_from:
+ entry_to['givenname'] = entry_from['cn'][0].split()[0]
+
+ if 'krbprincipalname' not in entry_from:
+ entry_to['krbprincipalname'] = '%s@%s' % (entry_from['uid'][0], api.env.realm)
+
+ def __dict_new_entry(self, *args, **options):
+ ldap = self.obj.backend
+
+ entry_attrs = self.args_options_2_entry(*args, **options)
+ entry_attrs = ldap.make_entry(DN(), entry_attrs)
+
+ self.process_attr_options(entry_attrs, None, args, options)
+
+ entry_attrs['objectclass'] = deepcopy(self.obj.object_class)
+
+ if self.obj.object_class_config:
+ config = ldap.get_ipa_config()
+ entry_attrs['objectclass'] = config.get(
+ self.obj.object_class_config, entry_attrs['objectclass']
+ )
+
+ return(entry_attrs)
+
+ def __merge_values(self, args, options, entry_from, entry_to, attr):
+ '''
+ This routine merges the values of attr taken from entry_from, into entry_to.
+ If attr is a syntax DN attribute, it is replaced by an empty value. It is a preferable solution
+ compare to skiping it because the final entry may no longer conform the schema.
+ An exception of this is for a limited set of syntax DN attribute that we want to
+ preserved (defined in preserved_DN_syntax_attrs)
+ see http://www.freeipa.org/page/V3/User_Life-Cycle_Management#Adjustment_of_DN_syntax_attributes
+ '''
+ if not attr in entry_to:
+ if isinstance(entry_from[attr], (list, tuple)):
+ # attr is multi value attribute
+ entry_to[attr] = []
+ else:
+ # attr single valued attribute
+ entry_to[attr] = None
+
+ # At this point entry_to contains for all resulting attributes
+ # either a list (possibly empty) or a value (possibly None)
+
+ for value in entry_from[attr]:
+ # merge all the values from->to
+ v = self.__value_2_add(args, options, attr, value)
+ if (isinstance(v, str) and v in ('', None)) or \
+ (isinstance(v, unicode) and v in (u'', None)):
+ try:
+ v.decode('utf-8')
+ self.log.debug("merge: %s:%r wiped" % (attr, v))
+ except Exception:
+ self.log.debug("merge %s: [no_print %s]" % (attr, v.__class__.__name__))
+ if isinstance(entry_to[attr], (list, tuple)):
+ # multi value attribute
+ if v not in entry_to[attr]:
+ # it may has been added before in the loop
+ # so add it only if it not present
+ entry_to[attr].append(v)
+ else:
+ # single value attribute
+ # keep the value defined in staging
+ entry_to[attr] = v
+ else:
+ try:
+ v.decode('utf-8')
+ self.log.debug("Add: %s:%r" % (attr, v))
+ except Exception:
+ self.log.debug("Add %s: [no_print %s]" % (attr, v.__class__.__name__))
+
+ if isinstance(entry_to[attr], (list, tuple)):
+ # multi value attribute
+ if attr.lower() == 'objectclass':
+ entry_to[attr] = [oc.lower() for oc in entry_to[attr]]
+ value = value.lower()
+ if value not in entry_to[attr]:
+ entry_to[attr].append(value)
+ else:
+ if value not in entry_to[attr]:
+ entry_to[attr].append(value)
+ else:
+ # single value attribute
+ if value:
+ entry_to[attr] = value
+
+ def __value_2_add(self, args, options, attr, value):
+ '''
+ If the attribute is NOT syntax DN it returns its value.
+ Else it checks if the value can be preserved.
+ To be preserved:
+ - attribute must be in preserved_DN_syntax_attrs
+ - value must be an active user DN (in Active container)
+ - the active user entry exists
+ '''
+ ldap = self.obj.backend
+
+ if ldap.has_dn_syntax(attr):
+ if attr.lower() in self.preserved_DN_syntax_attrs:
+ # we are about to add a DN syntax value
+ # Check this is a valid DN
+ if not isinstance(value, DN):
+ return u''
+
+ if not self.obj.active_user(value):
+ return u''
+
+ # Check that this value is a Active user
+ try:
+ entry_attrs = self._exc_wrapper(args, options, ldap.get_entry)(value, ['dn'])
+ return value
+ except errors.NotFound:
+ return u''
+ else:
+ return u''
+ else:
+ return value
+
+ def execute(self, *args, **options):
+
+ ldap = self.obj.backend
+
+ staging_dn = self.obj.get_dn(*args, **options)
+ assert isinstance(staging_dn, DN)
+
+ # retrieve the current entry
+ try:
+ entry_attrs = self._exc_wrapper(args, options, ldap.get_entry)(
+ staging_dn, ['*']
+ )
+ except errors.NotFound:
+ self.obj.handle_not_found(*args)
+ entry_attrs = dict((k.lower(), v) for (k, v) in entry_attrs.items())
+
+ # Check it does not exist an active entry with the same RDN
+ active_dn = DN(staging_dn[0], api.env.container_user, api.env.basedn)
+ try:
+ test_entry_attrs = self._exc_wrapper(args, options, ldap.get_entry)(
+ active_dn, ['dn']
+ )
+ assert isinstance(staging_dn, DN)
+ raise errors.DuplicateEntry(
+ message=_('active user with name "%(user)s" already exists') %
+ dict(user=args[-1]))
+ except errors.NotFound:
+ pass
+
+ # Check the original entry is valid
+ self._check_validy(staging_dn, entry_attrs)
+
+ # Time to build the new entry
+ result_entry = {'dn' : active_dn}
+ new_entry_attrs = self.__dict_new_entry()
+ for (attr, values) in entry_attrs.items():
+ self.__merge_values(args, options, entry_attrs, new_entry_attrs, attr)
+ result_entry[attr] = values
+
+ # Allow Managed entry plugin to do its work
+ if 'description' in new_entry_attrs and NO_UPG_MAGIC in new_entry_attrs['description']:
+ new_entry_attrs['description'].remove(NO_UPG_MAGIC)
+ if result_entry['description'] == NO_UPG_MAGIC:
+ del result_entry['description']
+
+ for (k, v) in new_entry_attrs.items():
+ self.log.debug("new entry: k=%r and v=%r)" % (k, v))
+
+ self._build_new_entry(ldap, staging_dn, entry_attrs, new_entry_attrs)
+
+ # Add the Active entry
+ entry = ldap.make_entry(active_dn, new_entry_attrs)
+ self._exc_wrapper(args, options, ldap.add_entry)(entry)
+
+ # Now delete the Staging entry
+ try:
+ self._exc_wrapper(args, options, ldap.delete_entry)(staging_dn)
+ except:
+ try:
+ self.log.error("Fail to delete the Staging user after activating it %s " % (staging_dn))
+ self._exc_wrapper(args, options, ldap.delete_entry)(active_dn)
+ except Exception:
+ self.log.error("Fail to cleanup activation. The user remains active %s" % (active_dn))
+ raise
+
+ # add the user we just created into the default primary group
+ config = ldap.get_ipa_config()
+ def_primary_group = config.get('ipadefaultprimarygroup')
+ group_dn = self.api.Object['group'].get_dn(def_primary_group)
+
+ # if the user is already a member of default primary group,
+ # do not raise error
+ # this can happen if automember rule or default group is set
+ try:
+ ldap.add_entry_to_group(active_dn, group_dn)
+ except errors.AlreadyGroupMember:
+ pass
+
+ # Now retrieve the activated entry
+ result = self.api.Command.user_show(
+ args[-1],
+ all=options.get('all', False),
+ raw=options.get('raw', False),
+ version=options.get('version'),
+ )
+ result['summary'] = unicode(
+ _('Stage user %s activated' % staging_dn[0].value))
+
+ return result
+
+
+@register()
+class stageuser_add_manager(baseuser_add_manager):
+ __doc__ = _("Add a manager to the stage user entry")
+
+
+@register()
+class stageuser_remove_manager(baseuser_remove_manager):
+ __doc__ = _("Remove a manager to the stage user entry")
diff --git a/ipaserver/plugins/sudo.py b/ipaserver/plugins/sudo.py
new file mode 100644
index 000000000..eb1f49ff9
--- /dev/null
+++ b/ipaserver/plugins/sudo.py
@@ -0,0 +1,7 @@
+#
+# Copyright (C) 2016 FreeIPA Contributors see COPYING for license
+#
+
+from ipalib.text import _
+
+__doc__ = _('commands for controlling sudo configuration')
diff --git a/ipaserver/plugins/sudocmd.py b/ipaserver/plugins/sudocmd.py
new file mode 100644
index 000000000..e3ae33a84
--- /dev/null
+++ b/ipaserver/plugins/sudocmd.py
@@ -0,0 +1,203 @@
+# Authors:
+# Jr Aquino <jr.aquino@citrixonline.com>
+#
+# Copyright (C) 2010 Red Hat
+# see file 'COPYING' for use and warranty information
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+from ipalib import api, errors
+from ipalib import Str
+from ipalib.plugable import Registry
+from .baseldap import (
+ LDAPObject,
+ LDAPCreate,
+ LDAPDelete,
+ LDAPUpdate,
+ LDAPSearch,
+ LDAPRetrieve)
+from ipalib import _, ngettext
+from ipapython.dn import DN
+
+__doc__ = _("""
+Sudo Commands
+
+Commands used as building blocks for sudo
+
+EXAMPLES:
+
+ Create a new command
+ ipa sudocmd-add --desc='For reading log files' /usr/bin/less
+
+ Remove a command
+ ipa sudocmd-del /usr/bin/less
+
+""")
+
+register = Registry()
+
+topic = 'sudo'
+
+@register()
+class sudocmd(LDAPObject):
+ """
+ Sudo Command object.
+ """
+ container_dn = api.env.container_sudocmd
+ object_name = _('sudo command')
+ object_name_plural = _('sudo commands')
+ object_class = ['ipaobject', 'ipasudocmd']
+ permission_filter_objectclasses = ['ipasudocmd']
+ # object_class_config = 'ipahostobjectclasses'
+ search_attributes = [
+ 'sudocmd', 'description',
+ ]
+ default_attributes = [
+ 'sudocmd', 'description', 'memberof',
+ ]
+ attribute_members = {
+ 'memberof': ['sudocmdgroup'],
+ }
+ uuid_attribute = 'ipauniqueid'
+ rdn_attribute = 'ipauniqueid'
+ managed_permissions = {
+ 'System: Read Sudo Commands': {
+ 'replaces_global_anonymous_aci': True,
+ 'ipapermbindruletype': 'all',
+ 'ipapermright': {'read', 'search', 'compare'},
+ 'ipapermdefaultattr': {
+ 'description', 'ipauniqueid', 'memberof', 'objectclass',
+ 'sudocmd',
+ },
+ },
+ 'System: Add Sudo Command': {
+ 'ipapermright': {'add'},
+ 'replaces': [
+ '(target = "ldap:///sudocmd=*,cn=sudocmds,cn=sudo,$SUFFIX")(version 3.0;acl "permission:Add Sudo command";allow (add) groupdn = "ldap:///cn=Add Sudo command,cn=permissions,cn=pbac,$SUFFIX";)',
+ '(targetfilter = "(objectclass=ipasudocmd)")(target = "ldap:///cn=sudocmds,cn=sudo,$SUFFIX")(version 3.0;acl "permission:Add Sudo command";allow (add) groupdn = "ldap:///cn=Add Sudo command,cn=permissions,cn=pbac,$SUFFIX";)',
+ ],
+ 'default_privileges': {'Sudo Administrator'},
+ },
+ 'System: Delete Sudo Command': {
+ 'ipapermright': {'delete'},
+ 'replaces': [
+ '(target = "ldap:///sudocmd=*,cn=sudocmds,cn=sudo,$SUFFIX")(version 3.0;acl "permission:Delete Sudo command";allow (delete) groupdn = "ldap:///cn=Delete Sudo command,cn=permissions,cn=pbac,$SUFFIX";)',
+ '(targetfilter = "(objectclass=ipasudocmd)")(target = "ldap:///cn=sudocmds,cn=sudo,$SUFFIX")(version 3.0;acl "permission:Delete Sudo command";allow (delete) groupdn = "ldap:///cn=Delete Sudo command,cn=permissions,cn=pbac,$SUFFIX";)',
+ ],
+ 'default_privileges': {'Sudo Administrator'},
+ },
+ 'System: Modify Sudo Command': {
+ 'ipapermright': {'write'},
+ 'ipapermdefaultattr': {'description'},
+ 'replaces': [
+ '(targetattr = "description")(target = "ldap:///sudocmd=*,cn=sudocmds,cn=sudo,$SUFFIX")(version 3.0;acl "permission:Modify Sudo command";allow (write) groupdn = "ldap:///cn=Modify Sudo command,cn=permissions,cn=pbac,$SUFFIX";)',
+ '(targetfilter = "(objectclass=ipasudocmd)")(targetattr = "description")(target = "ldap:///cn=sudocmds,cn=sudo,$SUFFIX")(version 3.0;acl "permission:Modify Sudo command";allow (write) groupdn = "ldap:///cn=Modify Sudo command,cn=permissions,cn=pbac,$SUFFIX";)',
+ ],
+ 'default_privileges': {'Sudo Administrator'},
+ },
+ }
+
+ label = _('Sudo Commands')
+ label_singular = _('Sudo Command')
+
+ takes_params = (
+ Str('sudocmd',
+ cli_name='command',
+ label=_('Sudo Command'),
+ primary_key=True,
+ ),
+ Str('description?',
+ cli_name='desc',
+ label=_('Description'),
+ doc=_('A description of this command'),
+ ),
+ )
+
+ def get_dn(self, *keys, **options):
+ if keys[-1].endswith('.'):
+ keys[-1] = keys[-1][:-1]
+ dn = super(sudocmd, self).get_dn(*keys, **options)
+ try:
+ self.backend.get_entry(dn, [''])
+ except errors.NotFound:
+ try:
+ entry_attrs = self.backend.find_entry_by_attr(
+ 'sudocmd', keys[-1], self.object_class, [''],
+ DN(self.container_dn, api.env.basedn))
+ dn = entry_attrs.dn
+ except errors.NotFound:
+ pass
+ return dn
+
+
+@register()
+class sudocmd_add(LDAPCreate):
+ __doc__ = _('Create new Sudo Command.')
+
+ msg_summary = _('Added Sudo Command "%(value)s"')
+
+
+@register()
+class sudocmd_del(LDAPDelete):
+ __doc__ = _('Delete Sudo Command.')
+
+ msg_summary = _('Deleted Sudo Command "%(value)s"')
+
+ def pre_callback(self, ldap, dn, *keys, **options):
+ filters = [
+ ldap.make_filter_from_attr(attr, dn)
+ for attr in ('memberallowcmd', 'memberdenycmd')]
+ filter = ldap.combine_filters(filters, ldap.MATCH_ANY)
+ filter = ldap.combine_filters(
+ (filter, ldap.make_filter_from_attr('objectClass', 'ipasudorule')),
+ ldap.MATCH_ALL)
+ dependent_sudorules = []
+ try:
+ entries, truncated = ldap.find_entries(
+ filter, ['cn'],
+ base_dn=DN(api.env.container_sudorule, api.env.basedn))
+ except errors.NotFound:
+ pass
+ else:
+ for entry_attrs in entries:
+ [cn] = entry_attrs['cn']
+ dependent_sudorules.append(cn)
+
+ if dependent_sudorules:
+ raise errors.DependentEntry(
+ key=keys[0], label='sudorule',
+ dependent=', '.join(dependent_sudorules))
+ return dn
+
+
+@register()
+class sudocmd_mod(LDAPUpdate):
+ __doc__ = _('Modify Sudo Command.')
+
+ msg_summary = _('Modified Sudo Command "%(value)s"')
+
+
+@register()
+class sudocmd_find(LDAPSearch):
+ __doc__ = _('Search for Sudo Commands.')
+
+ msg_summary = ngettext(
+ '%(count)d Sudo Command matched', '%(count)d Sudo Commands matched', 0
+ )
+
+
+@register()
+class sudocmd_show(LDAPRetrieve):
+ __doc__ = _('Display Sudo Command.')
+
diff --git a/ipaserver/plugins/sudocmdgroup.py b/ipaserver/plugins/sudocmdgroup.py
new file mode 100644
index 000000000..9e8c016fd
--- /dev/null
+++ b/ipaserver/plugins/sudocmdgroup.py
@@ -0,0 +1,195 @@
+# Authors:
+# Jr Aquino <jr.aquino@citrixonline.com>
+#
+# Copyright (C) 2010 Red Hat
+# see file 'COPYING' for use and warranty information
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+from ipalib import api
+from ipalib import Str
+from ipalib.plugable import Registry
+from .baseldap import (
+ LDAPObject,
+ LDAPCreate,
+ LDAPDelete,
+ LDAPUpdate,
+ LDAPSearch,
+ LDAPRetrieve,
+ LDAPAddMember,
+ LDAPRemoveMember)
+from ipalib import _, ngettext
+
+__doc__ = _("""
+Groups of Sudo Commands
+
+Manage groups of Sudo Commands.
+
+EXAMPLES:
+
+ Add a new Sudo Command Group:
+ ipa sudocmdgroup-add --desc='administrators commands' admincmds
+
+ Remove a Sudo Command Group:
+ ipa sudocmdgroup-del admincmds
+
+ Manage Sudo Command Group membership, commands:
+ ipa sudocmdgroup-add-member --sudocmds=/usr/bin/less --sudocmds=/usr/bin/vim admincmds
+
+ Manage Sudo Command Group membership, commands:
+ ipa sudocmdgroup-remove-member --sudocmds=/usr/bin/less admincmds
+
+ Show a Sudo Command Group:
+ ipa sudocmdgroup-show admincmds
+""")
+
+register = Registry()
+
+topic = 'sudo'
+
+@register()
+class sudocmdgroup(LDAPObject):
+ """
+ Sudo Command Group object.
+ """
+ container_dn = api.env.container_sudocmdgroup
+ object_name = _('sudo command group')
+ object_name_plural = _('sudo command groups')
+ object_class = ['ipaobject', 'ipasudocmdgrp']
+ permission_filter_objectclasses = ['ipasudocmdgrp']
+ default_attributes = [
+ 'cn', 'description', 'member',
+ ]
+ uuid_attribute = 'ipauniqueid'
+ attribute_members = {
+ 'member': ['sudocmd'],
+ }
+ managed_permissions = {
+ 'System: Read Sudo Command Groups': {
+ 'replaces_global_anonymous_aci': True,
+ 'ipapermbindruletype': 'all',
+ 'ipapermright': {'read', 'search', 'compare'},
+ 'ipapermdefaultattr': {
+ 'businesscategory', 'cn', 'description', 'ipauniqueid',
+ 'member', 'o', 'objectclass', 'ou', 'owner', 'seealso',
+ 'memberuser', 'memberhost',
+ },
+ },
+ 'System: Add Sudo Command Group': {
+ 'ipapermright': {'add'},
+ 'replaces': [
+ '(target = "ldap:///cn=*,cn=sudocmdgroups,cn=sudo,$SUFFIX")(version 3.0;acl "permission:Add Sudo command group";allow (add) groupdn = "ldap:///cn=Add Sudo command group,cn=permissions,cn=pbac,$SUFFIX";)',
+ ],
+ 'default_privileges': {'Sudo Administrator'},
+ },
+ 'System: Delete Sudo Command Group': {
+ 'ipapermright': {'delete'},
+ 'replaces': [
+ '(target = "ldap:///cn=*,cn=sudocmdgroups,cn=sudo,$SUFFIX")(version 3.0;acl "permission:Delete Sudo command group";allow (delete) groupdn = "ldap:///cn=Delete Sudo command group,cn=permissions,cn=pbac,$SUFFIX";)',
+ ],
+ 'default_privileges': {'Sudo Administrator'},
+ },
+ 'System: Modify Sudo Command Group': {
+ 'ipapermright': {'write'},
+ 'ipapermdefaultattr': {'description'},
+ 'default_privileges': {'Sudo Administrator'},
+ },
+ 'System: Manage Sudo Command Group Membership': {
+ 'ipapermright': {'write'},
+ 'ipapermdefaultattr': {'member'},
+ 'replaces': [
+ '(targetattr = "member")(target = "ldap:///cn=*,cn=sudocmdgroups,cn=sudo,$SUFFIX")(version 3.0;acl "permission:Manage Sudo command group membership";allow (write) groupdn = "ldap:///cn=Manage Sudo command group membership,cn=permissions,cn=pbac,$SUFFIX";)',
+ ],
+ 'default_privileges': {'Sudo Administrator'},
+ },
+ }
+
+ label = _('Sudo Command Groups')
+ label_singular = _('Sudo Command Group')
+
+ takes_params = (
+ Str('cn',
+ cli_name='sudocmdgroup_name',
+ label=_('Sudo Command Group'),
+ primary_key=True,
+ normalizer=lambda value: value.lower(),
+ ),
+ Str('description?',
+ cli_name='desc',
+ label=_('Description'),
+ doc=_('Group description'),
+ ),
+ Str('membercmd_sudocmd?',
+ label=_('Commands'),
+ flags=['no_create', 'no_update', 'no_search'],
+ ),
+ Str('membercmd_sudocmdgroup?',
+ label=_('Sudo Command Groups'),
+ flags=['no_create', 'no_update', 'no_search'],
+ ),
+ )
+
+
+
+@register()
+class sudocmdgroup_add(LDAPCreate):
+ __doc__ = _('Create new Sudo Command Group.')
+
+ msg_summary = _('Added Sudo Command Group "%(value)s"')
+
+
+
+@register()
+class sudocmdgroup_del(LDAPDelete):
+ __doc__ = _('Delete Sudo Command Group.')
+
+ msg_summary = _('Deleted Sudo Command Group "%(value)s"')
+
+
+
+@register()
+class sudocmdgroup_mod(LDAPUpdate):
+ __doc__ = _('Modify Sudo Command Group.')
+
+ msg_summary = _('Modified Sudo Command Group "%(value)s"')
+
+
+
+@register()
+class sudocmdgroup_find(LDAPSearch):
+ __doc__ = _('Search for Sudo Command Groups.')
+
+ msg_summary = ngettext(
+ '%(count)d Sudo Command Group matched',
+ '%(count)d Sudo Command Groups matched', 0
+ )
+
+
+
+@register()
+class sudocmdgroup_show(LDAPRetrieve):
+ __doc__ = _('Display Sudo Command Group.')
+
+
+
+@register()
+class sudocmdgroup_add_member(LDAPAddMember):
+ __doc__ = _('Add members to Sudo Command Group.')
+
+
+
+@register()
+class sudocmdgroup_remove_member(LDAPRemoveMember):
+ __doc__ = _('Remove members from Sudo Command Group.')
+
diff --git a/ipaserver/plugins/sudorule.py b/ipaserver/plugins/sudorule.py
new file mode 100644
index 000000000..15d03c659
--- /dev/null
+++ b/ipaserver/plugins/sudorule.py
@@ -0,0 +1,998 @@
+# Authors:
+# Jr Aquino <jr.aquino@citrixonline.com>
+#
+# Copyright (C) 2010-2014 Red Hat
+# see file 'COPYING' for use and warranty information
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+import netaddr
+import six
+
+from ipalib import api, errors
+from ipalib import Str, StrEnum, Bool, Int
+from ipalib.plugable import Registry
+from .baseldap import (LDAPObject, LDAPCreate, LDAPDelete,
+ LDAPUpdate, LDAPSearch, LDAPRetrieve,
+ LDAPQuery, LDAPAddMember, LDAPRemoveMember,
+ add_external_pre_callback,
+ add_external_post_callback,
+ remove_external_post_callback,
+ output, entry_to_dict, pkey_to_value,
+ external_host_param)
+from .hbacrule import is_all
+from ipalib import _, ngettext
+from ipalib.util import validate_hostmask
+from ipapython.dn import DN
+
+if six.PY3:
+ unicode = str
+
+__doc__ = _("""
+Sudo Rules
+""") + _("""
+Sudo (su "do") allows a system administrator to delegate authority to
+give certain users (or groups of users) the ability to run some (or all)
+commands as root or another user while providing an audit trail of the
+commands and their arguments.
+""") + _("""
+FreeIPA provides a means to configure the various aspects of Sudo:
+ Users: The user(s)/group(s) allowed to invoke Sudo.
+ Hosts: The host(s)/hostgroup(s) which the user is allowed to to invoke Sudo.
+ Allow Command: The specific command(s) permitted to be run via Sudo.
+ Deny Command: The specific command(s) prohibited to be run via Sudo.
+ RunAsUser: The user(s) or group(s) of users whose rights Sudo will be invoked with.
+ RunAsGroup: The group(s) whose gid rights Sudo will be invoked with.
+ Options: The various Sudoers Options that can modify Sudo's behavior.
+""") + _("""
+An order can be added to a sudorule to control the order in which they
+are evaluated (if the client supports it). This order is an integer and
+must be unique.
+""") + _("""
+FreeIPA provides a designated binddn to use with Sudo located at:
+uid=sudo,cn=sysaccounts,cn=etc,dc=example,dc=com
+""") + _("""
+To enable the binddn run the following command to set the password:
+LDAPTLS_CACERT=/etc/ipa/ca.crt /usr/bin/ldappasswd -S -W \
+-h ipa.example.com -ZZ -D "cn=Directory Manager" \
+uid=sudo,cn=sysaccounts,cn=etc,dc=example,dc=com
+""") + _("""
+EXAMPLES:
+""") + _("""
+ Create a new rule:
+ ipa sudorule-add readfiles
+""") + _("""
+ Add sudo command object and add it as allowed command in the rule:
+ ipa sudocmd-add /usr/bin/less
+ ipa sudorule-add-allow-command readfiles --sudocmds /usr/bin/less
+""") + _("""
+ Add a host to the rule:
+ ipa sudorule-add-host readfiles --hosts server.example.com
+""") + _("""
+ Add a user to the rule:
+ ipa sudorule-add-user readfiles --users jsmith
+""") + _("""
+ Add a special Sudo rule for default Sudo server configuration:
+ ipa sudorule-add defaults
+""") + _("""
+ Set a default Sudo option:
+ ipa sudorule-add-option defaults --sudooption '!authenticate'
+""")
+
+register = Registry()
+
+topic = 'sudo'
+
+
+def deprecated(attribute):
+ raise errors.ValidationError(
+ name=attribute,
+ error=_('this option has been deprecated.'))
+
+
+hostmask_membership_param = Str('hostmask?', validate_hostmask,
+ label=_('host masks of allowed hosts'),
+ flags=['no_create', 'no_update', 'no_search'],
+ multivalue=True,
+ )
+
+def validate_externaluser(ugettext, value):
+ deprecated('externaluser')
+
+
+def validate_runasextuser(ugettext, value):
+ deprecated('runasexternaluser')
+
+
+def validate_runasextgroup(ugettext, value):
+ deprecated('runasexternalgroup')
+
+
+@register()
+class sudorule(LDAPObject):
+ """
+ Sudo Rule object.
+ """
+ container_dn = api.env.container_sudorule
+ object_name = _('sudo rule')
+ object_name_plural = _('sudo rules')
+ object_class = ['ipaassociation', 'ipasudorule']
+ permission_filter_objectclasses = ['ipasudorule']
+ default_attributes = [
+ 'cn', 'ipaenabledflag', 'externaluser',
+ 'description', 'usercategory', 'hostcategory',
+ 'cmdcategory', 'memberuser', 'memberhost',
+ 'memberallowcmd', 'memberdenycmd', 'ipasudoopt',
+ 'ipasudorunas', 'ipasudorunasgroup',
+ 'ipasudorunasusercategory', 'ipasudorunasgroupcategory',
+ 'sudoorder', 'hostmask', 'externalhost', 'ipasudorunasextusergroup',
+ 'ipasudorunasextgroup', 'ipasudorunasextuser'
+ ]
+ uuid_attribute = 'ipauniqueid'
+ rdn_attribute = 'ipauniqueid'
+ attribute_members = {
+ 'memberuser': ['user', 'group'],
+ 'memberhost': ['host', 'hostgroup'],
+ 'memberallowcmd': ['sudocmd', 'sudocmdgroup'],
+ 'memberdenycmd': ['sudocmd', 'sudocmdgroup'],
+ 'ipasudorunas': ['user', 'group'],
+ 'ipasudorunasgroup': ['group'],
+ }
+ managed_permissions = {
+ 'System: Read Sudo Rules': {
+ 'replaces_global_anonymous_aci': True,
+ 'ipapermbindruletype': 'all',
+ 'ipapermright': {'read', 'search', 'compare'},
+ 'ipapermdefaultattr': {
+ 'cmdcategory', 'cn', 'description', 'externalhost',
+ 'externaluser', 'hostcategory', 'hostmask', 'ipaenabledflag',
+ 'ipasudoopt', 'ipasudorunas', 'ipasudorunasextgroup',
+ 'ipasudorunasextuser', 'ipasudorunasextusergroup',
+ 'ipasudorunasgroup',
+ 'ipasudorunasgroupcategory', 'ipasudorunasusercategory',
+ 'ipauniqueid', 'memberallowcmd', 'memberdenycmd',
+ 'memberhost', 'memberuser', 'sudonotafter', 'sudonotbefore',
+ 'sudoorder', 'usercategory', 'objectclass', 'member',
+ },
+ },
+ 'System: Read Sudoers compat tree': {
+ 'non_object': True,
+ 'ipapermlocation': api.env.basedn,
+ 'ipapermtarget': DN('ou=sudoers', api.env.basedn),
+ 'ipapermbindruletype': 'anonymous',
+ 'ipapermright': {'read', 'search', 'compare'},
+ 'ipapermdefaultattr': {
+ 'objectclass', 'cn', 'ou',
+ 'sudouser', 'sudohost', 'sudocommand', 'sudorunas',
+ 'sudorunasuser', 'sudorunasgroup', 'sudooption',
+ 'sudonotbefore', 'sudonotafter', 'sudoorder', 'description',
+ },
+ },
+ 'System: Add Sudo rule': {
+ 'ipapermright': {'add'},
+ 'replaces': [
+ '(target = "ldap:///ipauniqueid=*,cn=sudorules,cn=sudo,$SUFFIX")(version 3.0;acl "permission:Add Sudo rule";allow (add) groupdn = "ldap:///cn=Add Sudo rule,cn=permissions,cn=pbac,$SUFFIX";)',
+ ],
+ 'default_privileges': {'Sudo Administrator'},
+ },
+ 'System: Delete Sudo rule': {
+ 'ipapermright': {'delete'},
+ 'replaces': [
+ '(target = "ldap:///ipauniqueid=*,cn=sudorules,cn=sudo,$SUFFIX")(version 3.0;acl "permission:Delete Sudo rule";allow (delete) groupdn = "ldap:///cn=Delete Sudo rule,cn=permissions,cn=pbac,$SUFFIX";)',
+ ],
+ 'default_privileges': {'Sudo Administrator'},
+ },
+ 'System: Modify Sudo rule': {
+ 'ipapermright': {'write'},
+ 'ipapermdefaultattr': {
+ 'description', 'ipaenabledflag', 'usercategory',
+ 'hostcategory', 'cmdcategory', 'ipasudorunasusercategory',
+ 'ipasudorunasgroupcategory', 'externaluser',
+ 'ipasudorunasextusergroup',
+ 'ipasudorunasextuser', 'ipasudorunasextgroup', 'memberdenycmd',
+ 'memberallowcmd', 'memberuser', 'memberhost', 'externalhost',
+ 'sudonotafter', 'hostmask', 'sudoorder', 'sudonotbefore',
+ 'ipasudorunas', 'externalhost', 'ipasudorunasgroup',
+ 'ipasudoopt', 'memberhost',
+ },
+ 'replaces': [
+ '(targetattr = "description || ipaenabledflag || usercategory || hostcategory || cmdcategory || ipasudorunasusercategory || ipasudorunasgroupcategory || externaluser || ipasudorunasextuser || ipasudorunasextgroup || memberdenycmd || memberallowcmd || memberuser")(target = "ldap:///ipauniqueid=*,cn=sudorules,cn=sudo,$SUFFIX")(version 3.0;acl "permission:Modify Sudo rule";allow (write) groupdn = "ldap:///cn=Modify Sudo rule,cn=permissions,cn=pbac,$SUFFIX";)',
+ ],
+ 'default_privileges': {'Sudo Administrator'},
+ },
+ }
+
+ label = _('Sudo Rules')
+ label_singular = _('Sudo Rule')
+
+ takes_params = (
+ Str('cn',
+ cli_name='sudorule_name',
+ label=_('Rule name'),
+ primary_key=True,
+ ),
+ Str('description?',
+ cli_name='desc',
+ label=_('Description'),
+ ),
+ Bool('ipaenabledflag?',
+ label=_('Enabled'),
+ flags=['no_option'],
+ ),
+ StrEnum('usercategory?',
+ cli_name='usercat',
+ label=_('User category'),
+ doc=_('User category the rule applies to'),
+ values=(u'all', ),
+ ),
+ StrEnum('hostcategory?',
+ cli_name='hostcat',
+ label=_('Host category'),
+ doc=_('Host category the rule applies to'),
+ values=(u'all', ),
+ ),
+ StrEnum('cmdcategory?',
+ cli_name='cmdcat',
+ label=_('Command category'),
+ doc=_('Command category the rule applies to'),
+ values=(u'all', ),
+ ),
+ StrEnum('ipasudorunasusercategory?',
+ cli_name='runasusercat',
+ label=_('RunAs User category'),
+ doc=_('RunAs User category the rule applies to'),
+ values=(u'all', ),
+ ),
+ StrEnum('ipasudorunasgroupcategory?',
+ cli_name='runasgroupcat',
+ label=_('RunAs Group category'),
+ doc=_('RunAs Group category the rule applies to'),
+ values=(u'all', ),
+ ),
+ Int('sudoorder?',
+ cli_name='order',
+ label=_('Sudo order'),
+ doc=_('integer to order the Sudo rules'),
+ default=0,
+ minvalue=0,
+ ),
+ Str('memberuser_user?',
+ label=_('Users'),
+ flags=['no_create', 'no_update', 'no_search'],
+ ),
+ Str('memberuser_group?',
+ label=_('User Groups'),
+ flags=['no_create', 'no_update', 'no_search'],
+ ),
+ Str('externaluser?', validate_externaluser,
+ cli_name='externaluser',
+ label=_('External User'),
+ doc=_('External User the rule applies to (sudorule-find only)'),
+ ),
+ Str('memberhost_host?',
+ label=_('Hosts'),
+ flags=['no_create', 'no_update', 'no_search'],
+ ),
+ Str('memberhost_hostgroup?',
+ label=_('Host Groups'),
+ flags=['no_create', 'no_update', 'no_search'],
+ ),
+ Str('hostmask', validate_hostmask,
+ normalizer=lambda x: unicode(netaddr.IPNetwork(x).cidr),
+ label=_('Host Masks'),
+ flags=['no_create', 'no_update', 'no_search'],
+ multivalue=True,
+ ),
+ external_host_param,
+ Str('memberallowcmd_sudocmd?',
+ label=_('Sudo Allow Commands'),
+ flags=['no_create', 'no_update', 'no_search'],
+ ),
+ Str('memberdenycmd_sudocmd?',
+ label=_('Sudo Deny Commands'),
+ flags=['no_create', 'no_update', 'no_search'],
+ ),
+ Str('memberallowcmd_sudocmdgroup?',
+ label=_('Sudo Allow Command Groups'),
+ flags=['no_create', 'no_update', 'no_search'],
+ ),
+ Str('memberdenycmd_sudocmdgroup?',
+ label=_('Sudo Deny Command Groups'),
+ flags=['no_create', 'no_update', 'no_search'],
+ ),
+ Str('ipasudorunas_user?',
+ label=_('RunAs Users'),
+ doc=_('Run as a user'),
+ flags=['no_create', 'no_update', 'no_search'],
+ ),
+ Str('ipasudorunas_group?',
+ label=_('Groups of RunAs Users'),
+ doc=_('Run as any user within a specified group'),
+ flags=['no_create', 'no_update', 'no_search'],
+ ),
+ Str('ipasudorunasextuser?', validate_runasextuser,
+ cli_name='runasexternaluser',
+ label=_('RunAs External User'),
+ doc=_('External User the commands can run as (sudorule-find only)'),
+ ),
+ Str('ipasudorunasextusergroup?',
+ cli_name='runasexternalusergroup',
+ label=_('External Groups of RunAs Users'),
+ doc=_('External Groups of users that the command can run as'),
+ flags=['no_create', 'no_update', 'no_search'],
+ ),
+ Str('ipasudorunasgroup_group?',
+ label=_('RunAs Groups'),
+ doc=_('Run with the gid of a specified POSIX group'),
+ flags=['no_create', 'no_update', 'no_search'],
+ ),
+ Str('ipasudorunasextgroup?', validate_runasextgroup,
+ cli_name='runasexternalgroup',
+ label=_('RunAs External Group'),
+ doc=_('External Group the commands can run as (sudorule-find only)'),
+ ),
+ Str('ipasudoopt?',
+ label=_('Sudo Option'),
+ flags=['no_create', 'no_update', 'no_search'],
+ ),
+ )
+
+ order_not_unique_msg = _(
+ 'order must be a unique value (%(order)d already used by %(rule)s)'
+ )
+
+ def check_order_uniqueness(self, *keys, **options):
+ if options.get('sudoorder') is not None:
+ entries = self.methods.find(
+ sudoorder=options['sudoorder']
+ )['result']
+
+ if len(entries) > 0:
+ rule_name = entries[0]['cn'][0]
+ raise errors.ValidationError(
+ name='order',
+ error=self.order_not_unique_msg % {
+ 'order': options['sudoorder'],
+ 'rule': rule_name,
+ }
+ )
+
+
+@register()
+class sudorule_add(LDAPCreate):
+ __doc__ = _('Create new Sudo Rule.')
+
+ def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options):
+ assert isinstance(dn, DN)
+ self.obj.check_order_uniqueness(*keys, **options)
+ # Sudo Rules are enabled by default
+ entry_attrs['ipaenabledflag'] = 'TRUE'
+ return dn
+
+ msg_summary = _('Added Sudo Rule "%(value)s"')
+
+
+@register()
+class sudorule_del(LDAPDelete):
+ __doc__ = _('Delete Sudo Rule.')
+
+ msg_summary = _('Deleted Sudo Rule "%(value)s"')
+
+
+@register()
+class sudorule_mod(LDAPUpdate):
+ __doc__ = _('Modify Sudo Rule.')
+
+ msg_summary = _('Modified Sudo Rule "%(value)s"')
+
+ def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options):
+ assert isinstance(dn, DN)
+
+ if 'sudoorder' in options:
+ new_order = options.get('sudoorder')
+ old_entry = self.api.Command.sudorule_show(keys[-1])['result']
+ if 'sudoorder' in old_entry:
+ old_order = int(old_entry['sudoorder'][0])
+ if old_order != new_order:
+ self.obj.check_order_uniqueness(*keys, **options)
+ else:
+ self.obj.check_order_uniqueness(*keys, **options)
+
+ try:
+ _entry_attrs = ldap.get_entry(dn, self.obj.default_attributes)
+ except errors.NotFound:
+ self.obj.handle_not_found(*keys)
+
+ error = _("%(type)s category cannot be set to 'all' "
+ "while there are allowed %(objects)s")
+
+ category_info = [(
+ 'usercategory',
+ ['memberuser', 'externaluser'],
+ error % {'type': _('user'), 'objects': _('users')}
+ ),
+ (
+ 'hostcategory',
+ ['memberhost', 'externalhost', 'hostmask'],
+ error % {'type': _('host'), 'objects': _('hosts')}
+ ),
+ (
+ 'cmdcategory',
+ ['memberallowcmd'],
+ error % {'type': _('command'), 'objects': _('commands')}
+ ),
+ (
+ 'ipasudorunasusercategory',
+ ['ipasudorunas', 'ipasudorunasextuser',
+ 'ipasudorunasextusergroup'],
+ error % {'type': _('runAs user'), 'objects': _('runAs users')}
+ ),
+ (
+ 'ipasudorunasgroupcategory',
+ ['ipasudorunasgroup', 'ipasudorunasextgroup'],
+ error % {'type': _('group runAs'), 'objects': _('runAs groups')}
+ ),
+ ]
+
+
+ # Enforce the checks for all the categories
+ for category, member_attrs, error in category_info:
+ any_member_attrs_set = any(attr in _entry_attrs
+ for attr in member_attrs)
+
+ if is_all(options, category) and any_member_attrs_set:
+ raise errors.MutuallyExclusiveError(reason=error)
+
+ return dn
+
+
+@register()
+class sudorule_find(LDAPSearch):
+ __doc__ = _('Search for Sudo Rule.')
+
+ msg_summary = ngettext(
+ '%(count)d Sudo Rule matched', '%(count)d Sudo Rules matched', 0
+ )
+
+
+@register()
+class sudorule_show(LDAPRetrieve):
+ __doc__ = _('Display Sudo Rule.')
+
+
+@register()
+class sudorule_enable(LDAPQuery):
+ __doc__ = _('Enable a Sudo Rule.')
+
+ def execute(self, cn, **options):
+ ldap = self.obj.backend
+
+ dn = self.obj.get_dn(cn)
+ try:
+ entry_attrs = ldap.get_entry(dn, ['ipaenabledflag'])
+ except errors.NotFound:
+ self.obj.handle_not_found(cn)
+
+ entry_attrs['ipaenabledflag'] = ['TRUE']
+
+ try:
+ ldap.update_entry(entry_attrs)
+ except errors.EmptyModlist:
+ pass
+
+ return dict(result=True)
+
+
+@register()
+class sudorule_disable(LDAPQuery):
+ __doc__ = _('Disable a Sudo Rule.')
+
+ def execute(self, cn, **options):
+ ldap = self.obj.backend
+
+ dn = self.obj.get_dn(cn)
+ try:
+ entry_attrs = ldap.get_entry(dn, ['ipaenabledflag'])
+ except errors.NotFound:
+ self.obj.handle_not_found(cn)
+
+ entry_attrs['ipaenabledflag'] = ['FALSE']
+
+ try:
+ ldap.update_entry(entry_attrs)
+ except errors.EmptyModlist:
+ pass
+
+ return dict(result=True)
+
+
+@register()
+class sudorule_add_allow_command(LDAPAddMember):
+ __doc__ = _('Add commands and sudo command groups affected by Sudo Rule.')
+
+ member_attributes = ['memberallowcmd']
+ member_count_out = ('%i object added.', '%i objects added.')
+
+ def pre_callback(self, ldap, dn, found, not_found, *keys, **options):
+ assert isinstance(dn, DN)
+
+ try:
+ _entry_attrs = ldap.get_entry(dn, self.obj.default_attributes)
+ except errors.NotFound:
+ self.obj.handle_not_found(*keys)
+
+ if is_all(_entry_attrs, 'cmdcategory'):
+ raise errors.MutuallyExclusiveError(
+ reason=_("commands cannot be added when command "
+ "category='all'"))
+
+ return dn
+
+
+@register()
+class sudorule_remove_allow_command(LDAPRemoveMember):
+ __doc__ = _('Remove commands and sudo command groups affected by Sudo Rule.')
+
+ member_attributes = ['memberallowcmd']
+ member_count_out = ('%i object removed.', '%i objects removed.')
+
+
+@register()
+class sudorule_add_deny_command(LDAPAddMember):
+ __doc__ = _('Add commands and sudo command groups affected by Sudo Rule.')
+
+ member_attributes = ['memberdenycmd']
+ member_count_out = ('%i object added.', '%i objects added.')
+
+ def pre_callback(self, ldap, dn, found, not_found, *keys, **options):
+ assert isinstance(dn, DN)
+ return dn
+
+
+@register()
+class sudorule_remove_deny_command(LDAPRemoveMember):
+ __doc__ = _('Remove commands and sudo command groups affected by Sudo Rule.')
+
+ member_attributes = ['memberdenycmd']
+ member_count_out = ('%i object removed.', '%i objects removed.')
+
+
+@register()
+class sudorule_add_user(LDAPAddMember):
+ __doc__ = _('Add users and groups affected by Sudo Rule.')
+
+ member_attributes = ['memberuser']
+ member_count_out = ('%i object added.', '%i objects added.')
+
+ def pre_callback(self, ldap, dn, found, not_found, *keys, **options):
+ assert isinstance(dn, DN)
+
+ try:
+ _entry_attrs = ldap.get_entry(dn, self.obj.default_attributes)
+ except errors.NotFound:
+ self.obj.handle_not_found(*keys)
+
+ if is_all(_entry_attrs, 'usercategory'):
+ raise errors.MutuallyExclusiveError(
+ reason=_("users cannot be added when user category='all'"))
+
+ return add_external_pre_callback('user', ldap, dn, keys, options)
+
+ def post_callback(self, ldap, completed, failed, dn, entry_attrs,
+ *keys, **options):
+ assert isinstance(dn, DN)
+ return add_external_post_callback(ldap, dn, entry_attrs,
+ failed=failed,
+ completed=completed,
+ memberattr='memberuser',
+ membertype='user',
+ externalattr='externaluser')
+
+
+@register()
+class sudorule_remove_user(LDAPRemoveMember):
+ __doc__ = _('Remove users and groups affected by Sudo Rule.')
+
+ member_attributes = ['memberuser']
+ member_count_out = ('%i object removed.', '%i objects removed.')
+
+ def post_callback(self, ldap, completed, failed, dn, entry_attrs,
+ *keys, **options):
+ assert isinstance(dn, DN)
+ return remove_external_post_callback(ldap, dn, entry_attrs,
+ failed=failed,
+ completed=completed,
+ memberattr='memberuser',
+ membertype='user',
+ externalattr='externaluser')
+
+
+@register()
+class sudorule_add_host(LDAPAddMember):
+ __doc__ = _('Add hosts and hostgroups affected by Sudo Rule.')
+
+ member_attributes = ['memberhost']
+ member_count_out = ('%i object added.', '%i objects added.')
+
+ def get_options(self):
+ for option in super(sudorule_add_host, self).get_options():
+ yield option
+ yield hostmask_membership_param
+
+ def pre_callback(self, ldap, dn, found, not_found, *keys, **options):
+ assert isinstance(dn, DN)
+ try:
+ _entry_attrs = ldap.get_entry(dn, self.obj.default_attributes)
+ except errors.NotFound:
+ self.obj.handle_not_found(*keys)
+
+ if is_all(_entry_attrs, 'hostcategory'):
+ raise errors.MutuallyExclusiveError(
+ reason=_("hosts cannot be added when host category='all'"))
+
+ return add_external_pre_callback('host', ldap, dn, keys, options)
+
+ def post_callback(self, ldap, completed, failed, dn, entry_attrs,
+ *keys, **options):
+ assert isinstance(dn, DN)
+ try:
+ _entry_attrs = ldap.get_entry(dn, self.obj.default_attributes)
+ except errors.NotFound:
+ self.obj.handle_not_found(*keys)
+
+ if 'hostmask' in options:
+ norm = lambda x: unicode(netaddr.IPNetwork(x).cidr)
+
+ old_masks = set(norm(m) for m in _entry_attrs.get('hostmask', []))
+ new_masks = set(norm(m) for m in options['hostmask'])
+
+ num_added = len(new_masks - old_masks)
+
+ if num_added:
+ entry_attrs['hostmask'] = list(old_masks | new_masks)
+ try:
+ ldap.update_entry(entry_attrs)
+ except errors.EmptyModlist:
+ pass
+ completed = completed + num_added
+
+ return add_external_post_callback(ldap, dn, entry_attrs,
+ failed=failed,
+ completed=completed,
+ memberattr='memberhost',
+ membertype='host',
+ externalattr='externalhost')
+
+
+@register()
+class sudorule_remove_host(LDAPRemoveMember):
+ __doc__ = _('Remove hosts and hostgroups affected by Sudo Rule.')
+
+ member_attributes = ['memberhost']
+ member_count_out = ('%i object removed.', '%i objects removed.')
+
+ def get_options(self):
+ for option in super(sudorule_remove_host, self).get_options():
+ yield option
+ yield hostmask_membership_param
+
+ def post_callback(self, ldap, completed, failed, dn, entry_attrs,
+ *keys, **options):
+ assert isinstance(dn, DN)
+
+ try:
+ _entry_attrs = ldap.get_entry(dn, self.obj.default_attributes)
+ except errors.NotFound:
+ self.obj.handle_not_found(*keys)
+
+ if 'hostmask' in options:
+ def norm(x):
+ return unicode(netaddr.IPNetwork(x).cidr)
+
+ old_masks = set(norm(m) for m in _entry_attrs.get('hostmask', []))
+ removed_masks = set(norm(m) for m in options['hostmask'])
+
+ num_added = len(removed_masks & old_masks)
+
+ if num_added:
+ entry_attrs['hostmask'] = list(old_masks - removed_masks)
+ try:
+ ldap.update_entry(entry_attrs)
+ except errors.EmptyModlist:
+ pass
+ completed = completed + num_added
+
+ return remove_external_post_callback(ldap, dn, entry_attrs,
+ failed=failed,
+ completed=completed,
+ memberattr='memberhost',
+ membertype='host',
+ externalattr='externalhost')
+
+
+@register()
+class sudorule_add_runasuser(LDAPAddMember):
+ __doc__ = _('Add users and groups for Sudo to execute as.')
+
+ member_attributes = ['ipasudorunas']
+ member_count_out = ('%i object added.', '%i objects added.')
+
+ def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options):
+ assert isinstance(dn, DN)
+
+ def check_validity(runas):
+ v = unicode(runas)
+ if v.upper() == u'ALL':
+ return False
+ return True
+
+ try:
+ _entry_attrs = ldap.get_entry(dn, self.obj.default_attributes)
+ except errors.NotFound:
+ self.obj.handle_not_found(*keys)
+
+ if any((is_all(_entry_attrs, 'ipasudorunasusercategory'),
+ is_all(_entry_attrs, 'ipasudorunasgroupcategory'))):
+
+ raise errors.MutuallyExclusiveError(
+ reason=_("users cannot be added when runAs user or runAs "
+ "group category='all'"))
+
+ if 'user' in options:
+ for name in options['user']:
+ if not check_validity(name):
+ raise errors.ValidationError(name='runas-user',
+ error=unicode(_("RunAsUser does not accept "
+ "'%(name)s' as a user name")) %
+ dict(name=name))
+
+ if 'group' in options:
+ for name in options['group']:
+ if not check_validity(name):
+ raise errors.ValidationError(name='runas-user',
+ error=unicode(_("RunAsUser does not accept "
+ "'%(name)s' as a group name")) %
+ dict(name=name))
+
+ return add_external_pre_callback('user', ldap, dn, keys, options)
+
+ def post_callback(self, ldap, completed, failed, dn, entry_attrs,
+ *keys, **options):
+ assert isinstance(dn, DN)
+
+ # Since external_post_callback returns the total number of completed
+ # entries yet (that is, any external users it added plus the value of
+ # passed variable 'completed', we need to pass 0 as completed,
+ # so that the entries added by the framework are not counted twice
+ # (once in each call of add_external_post_callback)
+
+ (completed_ex_users, dn) = add_external_post_callback(ldap, dn,
+ entry_attrs,
+ failed=failed,
+ completed=0,
+ memberattr='ipasudorunas',
+ membertype='user',
+ externalattr='ipasudorunasextuser',
+ )
+
+ (completed_ex_groups, dn) = add_external_post_callback(ldap, dn,
+ entry_attrs=entry_attrs,
+ failed=failed,
+ completed=0,
+ memberattr='ipasudorunas',
+ membertype='group',
+ externalattr='ipasudorunasextusergroup',
+ )
+
+ return (completed + completed_ex_users + completed_ex_groups, dn)
+
+
+@register()
+class sudorule_remove_runasuser(LDAPRemoveMember):
+ __doc__ = _('Remove users and groups for Sudo to execute as.')
+
+ member_attributes = ['ipasudorunas']
+ member_count_out = ('%i object removed.', '%i objects removed.')
+
+ def post_callback(self, ldap, completed, failed, dn, entry_attrs,
+ *keys, **options):
+ assert isinstance(dn, DN)
+
+ # Since external_post_callback returns the total number of completed
+ # entries yet (that is, any external users it added plus the value of
+ # passed variable 'completed', we need to pass 0 as completed,
+ # so that the entries added by the framework are not counted twice
+ # (once in each call of remove_external_post_callback)
+
+ (completed_ex_users, dn) = remove_external_post_callback(ldap, dn,
+ entry_attrs=entry_attrs,
+ failed=failed,
+ completed=0,
+ memberattr='ipasudorunas',
+ membertype='user',
+ externalattr='ipasudorunasextuser',
+ )
+
+ (completed_ex_groups, dn) = remove_external_post_callback(ldap, dn,
+ entry_attrs=entry_attrs,
+ failed=failed,
+ completed=0,
+ memberattr='ipasudorunas',
+ membertype='group',
+ externalattr='ipasudorunasextusergroup',
+ )
+
+ return (completed + completed_ex_users + completed_ex_groups, dn)
+
+
+@register()
+class sudorule_add_runasgroup(LDAPAddMember):
+ __doc__ = _('Add group for Sudo to execute as.')
+
+ member_attributes = ['ipasudorunasgroup']
+ member_count_out = ('%i object added.', '%i objects added.')
+
+ def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options):
+ assert isinstance(dn, DN)
+
+ def check_validity(runas):
+ v = unicode(runas)
+ if v.upper() == u'ALL':
+ return False
+ return True
+
+ try:
+ _entry_attrs = ldap.get_entry(dn, self.obj.default_attributes)
+ except errors.NotFound:
+ self.obj.handle_not_found(*keys)
+ if is_all(_entry_attrs, 'ipasudorunasusercategory') or \
+ is_all(_entry_attrs, 'ipasudorunasgroupcategory'):
+ raise errors.MutuallyExclusiveError(
+ reason=_("users cannot be added when runAs user or runAs "
+ "group category='all'"))
+
+ if 'group' in options:
+ for name in options['group']:
+ if not check_validity(name):
+ raise errors.ValidationError(name='runas-group',
+ error=unicode(_("RunAsGroup does not accept "
+ "'%(name)s' as a group name")) %
+ dict(name=name))
+
+ return add_external_pre_callback('group', ldap, dn, keys, options)
+
+ def post_callback(self, ldap, completed, failed, dn, entry_attrs,
+ *keys, **options):
+ assert isinstance(dn, DN)
+ return add_external_post_callback(ldap, dn, entry_attrs,
+ failed=failed,
+ completed=completed,
+ memberattr='ipasudorunasgroup',
+ membertype='group',
+ externalattr='ipasudorunasextgroup',
+ )
+
+
+@register()
+class sudorule_remove_runasgroup(LDAPRemoveMember):
+ __doc__ = _('Remove group for Sudo to execute as.')
+
+ member_attributes = ['ipasudorunasgroup']
+ member_count_out = ('%i object removed.', '%i objects removed.')
+
+ def post_callback(self, ldap, completed, failed, dn, entry_attrs,
+ *keys, **options):
+ assert isinstance(dn, DN)
+ return remove_external_post_callback(ldap, dn, entry_attrs,
+ failed=failed,
+ completed=completed,
+ memberattr='ipasudorunasgroup',
+ membertype='group',
+ externalattr='ipasudorunasextgroup',
+ )
+
+
+@register()
+class sudorule_add_option(LDAPQuery):
+ __doc__ = _('Add an option to the Sudo Rule.')
+
+ has_output = output.standard_entry
+ takes_options = (
+ Str('ipasudoopt',
+ cli_name='sudooption',
+ label=_('Sudo Option'),
+ ),
+ )
+
+ def execute(self, cn, **options):
+ ldap = self.obj.backend
+
+ dn = self.obj.get_dn(cn)
+
+ if not options['ipasudoopt'].strip():
+ raise errors.EmptyModlist()
+ entry_attrs = ldap.get_entry(dn, ['ipasudoopt'])
+
+ try:
+ if options['ipasudoopt'] not in entry_attrs['ipasudoopt']:
+ entry_attrs.setdefault('ipasudoopt', []).append(
+ options['ipasudoopt'])
+ else:
+ raise errors.DuplicateEntry
+ except KeyError:
+ entry_attrs.setdefault('ipasudoopt', []).append(
+ options['ipasudoopt'])
+ try:
+ ldap.update_entry(entry_attrs)
+ except errors.EmptyModlist:
+ pass
+ except errors.NotFound:
+ self.obj.handle_not_found(cn)
+
+ attrs_list = self.obj.default_attributes
+ entry_attrs = ldap.get_entry(dn, attrs_list)
+
+ entry_attrs = entry_to_dict(entry_attrs, **options)
+
+ return dict(result=entry_attrs, value=pkey_to_value(cn, options))
+
+
+@register()
+class sudorule_remove_option(LDAPQuery):
+ __doc__ = _('Remove an option from Sudo Rule.')
+
+ has_output = output.standard_entry
+ takes_options = (
+ Str('ipasudoopt',
+ cli_name='sudooption',
+ label=_('Sudo Option'),
+ ),
+ )
+
+ def execute(self, cn, **options):
+ ldap = self.obj.backend
+
+ dn = self.obj.get_dn(cn)
+
+ if not options['ipasudoopt'].strip():
+ raise errors.EmptyModlist()
+
+ entry_attrs = ldap.get_entry(dn, ['ipasudoopt'])
+
+ try:
+ if options['ipasudoopt'] in entry_attrs['ipasudoopt']:
+ entry_attrs.setdefault('ipasudoopt', []).remove(
+ options['ipasudoopt'])
+ ldap.update_entry(entry_attrs)
+ else:
+ raise errors.AttrValueNotFound(
+ attr='ipasudoopt',
+ value=options['ipasudoopt']
+ )
+ except ValueError:
+ pass
+ except KeyError:
+ raise errors.AttrValueNotFound(
+ attr='ipasudoopt',
+ value=options['ipasudoopt']
+ )
+ except errors.NotFound:
+ self.obj.handle_not_found(cn)
+
+ attrs_list = self.obj.default_attributes
+ entry_attrs = ldap.get_entry(dn, attrs_list)
+
+ entry_attrs = entry_to_dict(entry_attrs, **options)
+
+ return dict(result=entry_attrs, value=pkey_to_value(cn, options))
diff --git a/ipaserver/plugins/topology.py b/ipaserver/plugins/topology.py
new file mode 100644
index 000000000..a6e638479
--- /dev/null
+++ b/ipaserver/plugins/topology.py
@@ -0,0 +1,503 @@
+#
+# Copyright (C) 2015 FreeIPA Contributors see COPYING for license
+#
+
+import six
+
+from ipalib import api, errors
+from ipalib import Int, Str, StrEnum, Flag, DNParam
+from ipalib.plugable import Registry
+from .baseldap import (
+ LDAPObject, LDAPSearch, LDAPCreate, LDAPDelete, LDAPUpdate, LDAPQuery,
+ LDAPRetrieve)
+from ipalib import _, ngettext
+from ipalib import output
+from ipalib.constants import DOMAIN_LEVEL_1
+from ipalib.util import create_topology_graph, get_topology_connection_errors
+from ipapython.dn import DN
+
+if six.PY3:
+ unicode = str
+
+__doc__ = _("""
+Topology
+
+Management of a replication topology at domain level 1.
+""") + _("""
+IPA server's data is stored in LDAP server in two suffixes:
+* domain suffix, e.g., 'dc=example,dc=com', contains all domain related data
+* ca suffix, 'o=ipaca', is present only on server with CA installed. It
+ contains data for Certificate Server component
+""") + _("""
+Data stored on IPA servers is replicated to other IPA servers. The way it is
+replicated is defined by replication agreements. Replication agreements needs
+to be set for both suffixes separately. On domain level 0 they are managed
+using ipa-replica-manage and ipa-csreplica-manage tools. With domain level 1
+they are managed centrally using `ipa topology*` commands.
+""") + _("""
+Agreements are represented by topology segments. By default topology segment
+represents 2 replication agreements - one for each direction, e.g., A to B and
+B to A. Creation of unidirectional segments is not allowed.
+""") + _("""
+To verify that no server is disconnected in the topology of the given suffix,
+use:
+ ipa topologysuffix-verify $suffix
+""") + _("""
+
+Examples:
+ Find all IPA servers:
+ ipa server-find
+""") + _("""
+ Find all suffixes:
+ ipa topologysuffix-find
+""") + _("""
+ Add topology segment to 'domain' suffix:
+ ipa topologysegment-add domain --left IPA_SERVER_A --right IPA_SERVER_B
+""") + _("""
+ Add topology segment to 'ca' suffix:
+ ipa topologysegment-add ca --left IPA_SERVER_A --right IPA_SERVER_B
+""") + _("""
+ List all topology segments in 'domain' suffix:
+ ipa topologysegment-find domain
+""") + _("""
+ List all topology segments in 'ca' suffix:
+ ipa topologysegment-find ca
+""") + _("""
+ Delete topology segment in 'domain' suffix:
+ ipa topologysegment-del domain segment_name
+""") + _("""
+ Delete topology segment in 'ca' suffix:
+ ipa topologysegment-del ca segment_name
+""") + _("""
+ Verify topology of 'domain' suffix:
+ ipa topologysuffix-verify domain
+""") + _("""
+ Verify topology of 'ca' suffix:
+ ipa topologysuffix-verify ca
+""")
+
+register = Registry()
+
+
+def validate_domain_level(api):
+ current = int(api.Command.domainlevel_get()['result'])
+ if current < DOMAIN_LEVEL_1:
+ raise errors.InvalidDomainLevelError(
+ reason=_('Topology management requires minimum domain level {0} '
+ .format(DOMAIN_LEVEL_1))
+ )
+
+
+@register()
+class topologysegment(LDAPObject):
+ """
+ Topology segment.
+ """
+ parent_object = 'topologysuffix'
+ container_dn = api.env.container_topology
+ object_name = _('segment')
+ object_name_plural = _('segments')
+ object_class = ['iparepltoposegment']
+ default_attributes = [
+ 'cn',
+ 'ipaReplTopoSegmentdirection', 'ipaReplTopoSegmentrightNode',
+ 'ipaReplTopoSegmentLeftNode', 'nsds5replicastripattrs',
+ 'nsds5replicatedattributelist', 'nsds5replicatedattributelisttotal',
+ 'nsds5replicatimeout', 'nsds5replicaenabled'
+ ]
+ search_display_attributes = [
+ 'cn', 'ipaReplTopoSegmentdirection', 'ipaReplTopoSegmentrightNode',
+ 'ipaReplTopoSegmentLeftNode'
+ ]
+
+ label = _('Topology Segments')
+ label_singular = _('Topology Segment')
+
+ takes_params = (
+ Str(
+ 'cn',
+ maxlength=255,
+ cli_name='name',
+ primary_key=True,
+ label=_('Segment name'),
+ default_from=lambda iparepltoposegmentleftnode, iparepltoposegmentrightnode:
+ '%s-to-%s' % (iparepltoposegmentleftnode, iparepltoposegmentrightnode),
+ normalizer=lambda value: value.lower(),
+ doc=_('Arbitrary string identifying the segment'),
+ ),
+ Str(
+ 'iparepltoposegmentleftnode',
+ pattern='^[a-zA-Z0-9.][a-zA-Z0-9.-]{0,252}[a-zA-Z0-9.$-]?$',
+ pattern_errmsg='may only include letters, numbers, -, . and $',
+ maxlength=255,
+ cli_name='leftnode',
+ label=_('Left node'),
+ normalizer=lambda value: value.lower(),
+ doc=_('Left replication node - an IPA server'),
+ flags={'no_update'},
+ ),
+ Str(
+ 'iparepltoposegmentrightnode',
+ pattern='^[a-zA-Z0-9.][a-zA-Z0-9.-]{0,252}[a-zA-Z0-9.$-]?$',
+ pattern_errmsg='may only include letters, numbers, -, . and $',
+ maxlength=255,
+ cli_name='rightnode',
+ label=_('Right node'),
+ normalizer=lambda value: value.lower(),
+ doc=_('Right replication node - an IPA server'),
+ flags={'no_update'},
+ ),
+ StrEnum(
+ 'iparepltoposegmentdirection',
+ cli_name='direction',
+ label=_('Connectivity'),
+ values=(u'both', u'left-right', u'right-left'),
+ default=u'both',
+ autofill=True,
+ doc=_('Direction of replication between left and right replication '
+ 'node'),
+ flags={'no_option', 'no_update'},
+ ),
+ Str(
+ 'nsds5replicastripattrs?',
+ cli_name='stripattrs',
+ label=_('Attributes to strip'),
+ normalizer=lambda value: value.lower(),
+ doc=_('A space separated list of attributes which are removed from '
+ 'replication updates.')
+ ),
+ Str(
+ 'nsds5replicatedattributelist?',
+ cli_name='replattrs',
+ label='Attributes to replicate',
+ doc=_('Attributes that are not replicated to a consumer server '
+ 'during a fractional update. E.g., `(objectclass=*) '
+ '$ EXCLUDE accountlockout memberof'),
+ ),
+ Str(
+ 'nsds5replicatedattributelisttotal?',
+ cli_name='replattrstotal',
+ label=_('Attributes for total update'),
+ doc=_('Attributes that are not replicated to a consumer server '
+ 'during a total update. E.g. (objectclass=*) $ EXCLUDE '
+ 'accountlockout'),
+ ),
+ Int(
+ 'nsds5replicatimeout?',
+ cli_name='timeout',
+ label=_('Session timeout'),
+ minvalue=0,
+ doc=_('Number of seconds outbound LDAP operations waits for a '
+ 'response from the remote replica before timing out and '
+ 'failing'),
+ ),
+ StrEnum(
+ 'nsds5replicaenabled?',
+ cli_name='enabled',
+ label=_('Replication agreement enabled'),
+ doc=_('Whether a replication agreement is active, meaning whether '
+ 'replication is occurring per that agreement'),
+ values=(u'on', u'off'),
+ flags={'no_option'},
+ ),
+ )
+
+ def validate_nodes(self, ldap, dn, entry_attrs):
+ leftnode = entry_attrs.get('iparepltoposegmentleftnode')
+ rightnode = entry_attrs.get('iparepltoposegmentrightnode')
+
+ if not leftnode and not rightnode:
+ return # nothing to check
+
+ # check if nodes are IPA servers
+ masters = self.api.Command.server_find(
+ '', sizelimit=0, no_members=False)['result']
+ m_hostnames = [master['cn'][0].lower() for master in masters]
+
+ if leftnode and leftnode not in m_hostnames:
+ raise errors.ValidationError(
+ name='leftnode',
+ error=_('left node is not a topology node: %(leftnode)s') %
+ dict(leftnode=leftnode)
+ )
+
+ if rightnode and rightnode not in m_hostnames:
+ raise errors.ValidationError(
+ name='rightnode',
+ error=_('right node is not a topology node: %(rightnode)s') %
+ dict(rightnode=rightnode)
+ )
+
+ # prevent creation of reflexive relation
+ key = 'leftnode'
+ if not leftnode or not rightnode: # get missing end
+ _entry_attrs = ldap.get_entry(dn, ['*'])
+ if not leftnode:
+ key = 'rightnode'
+ leftnode = _entry_attrs['iparepltoposegmentleftnode'][0]
+ else:
+ rightnode = _entry_attrs['iparepltoposegmentrightnode'][0]
+
+ if leftnode == rightnode:
+ raise errors.ValidationError(
+ name=key,
+ error=_('left node and right node must not be the same')
+ )
+
+
+@register()
+class topologysegment_find(LDAPSearch):
+ __doc__ = _('Search for topology segments.')
+
+ msg_summary = ngettext(
+ '%(count)d segment matched',
+ '%(count)d segments matched', 0
+ )
+
+
+@register()
+class topologysegment_add(LDAPCreate):
+ __doc__ = _('Add a new segment.')
+
+ msg_summary = _('Added segment "%(value)s"')
+
+ def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options):
+ assert isinstance(dn, DN)
+ validate_domain_level(self.api)
+ self.obj.validate_nodes(ldap, dn, entry_attrs)
+ return dn
+
+
+@register()
+class topologysegment_del(LDAPDelete):
+ __doc__ = _('Delete a segment.')
+
+ msg_summary = _('Deleted segment "%(value)s"')
+
+ def pre_callback(self, ldap, dn, *keys, **options):
+ assert isinstance(dn, DN)
+ validate_domain_level(self.api)
+ return dn
+
+
+@register()
+class topologysegment_mod(LDAPUpdate):
+ __doc__ = _('Modify a segment.')
+
+ msg_summary = _('Modified segment "%(value)s"')
+
+ def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options):
+ assert isinstance(dn, DN)
+ validate_domain_level(self.api)
+ self.obj.validate_nodes(ldap, dn, entry_attrs)
+ return dn
+
+
+@register()
+class topologysegment_reinitialize(LDAPQuery):
+ __doc__ = _('Request a full re-initialization of the node '
+ 'retrieving data from the other node.')
+
+ has_output = output.standard_value
+ msg_summary = _('%(value)s')
+
+ takes_options = (
+ Flag(
+ 'left?',
+ doc=_('Initialize left node'),
+ default=False,
+ ),
+ Flag(
+ 'right?',
+ doc=_('Initialize right node'),
+ default=False,
+ ),
+ Flag(
+ 'stop?',
+ doc=_('Stop already started refresh of chosen node(s)'),
+ default=False,
+ ),
+ )
+
+ def execute(self, *keys, **options):
+ dn = self.obj.get_dn(*keys, **options)
+ validate_domain_level(self.api)
+
+ entry = self.obj.backend.get_entry(
+ dn, [
+ 'nsds5beginreplicarefresh;left',
+ 'nsds5beginreplicarefresh;right'
+ ])
+
+ left = options.get('left')
+ right = options.get('right')
+ stop = options.get('stop')
+
+ if not left and not right:
+ raise errors.OptionError(
+ _('left or right node has to be specified')
+ )
+
+ if left and right:
+ raise errors.OptionError(
+ _('only one node can be specified')
+ )
+
+ action = u'start'
+ msg = _('Replication refresh for segment: "%(pkey)s" requested.')
+ if stop:
+ action = u'stop'
+ msg = _('Stopping of replication refresh for segment: "'
+ '%(pkey)s" requested.')
+
+ # left and right are swapped because internally it's a push not
+ # pull operation
+ if right:
+ entry['nsds5beginreplicarefresh;left'] = [action]
+ if left:
+ entry['nsds5beginreplicarefresh;right'] = [action]
+
+ self.obj.backend.update_entry(entry)
+
+ msg = msg % {'pkey': keys[-1]}
+ return dict(
+ result=True,
+ value=msg,
+ )
+
+
+@register()
+class topologysegment_show(LDAPRetrieve):
+ __doc__ = _('Display a segment.')
+
+
+@register()
+class topologysuffix(LDAPObject):
+ """
+ Suffix managed by the topology plugin.
+ """
+ container_dn = api.env.container_topology
+ object_name = _('suffix')
+ object_name_plural = _('suffixes')
+ object_class = ['iparepltopoconf']
+ default_attributes = ['cn', 'ipaReplTopoConfRoot']
+ search_display_attributes = ['cn', 'ipaReplTopoConfRoot']
+ label = _('Topology suffixes')
+ label_singular = _('Topology suffix')
+
+ takes_params = (
+ Str(
+ 'cn',
+ cli_name='name',
+ primary_key=True,
+ label=_('Suffix name'),
+ ),
+ DNParam(
+ 'iparepltopoconfroot',
+ cli_name='suffix_dn',
+ label=_('Managed LDAP suffix DN'),
+ ),
+ )
+
+
+@register()
+class topologysuffix_find(LDAPSearch):
+ __doc__ = _('Search for topology suffixes.')
+
+ msg_summary = ngettext(
+ '%(count)d topology suffix matched',
+ '%(count)d topology suffixes matched', 0
+ )
+
+
+@register()
+class topologysuffix_del(LDAPDelete):
+ __doc__ = _('Delete a topology suffix.')
+
+ NO_CLI = True
+
+ msg_summary = _('Deleted topology suffix "%(value)s"')
+
+ def pre_callback(self, ldap, dn, *keys, **options):
+ assert isinstance(dn, DN)
+ validate_domain_level(self.api)
+ return dn
+
+
+@register()
+class topologysuffix_add(LDAPCreate):
+ __doc__ = _('Add a new topology suffix to be managed.')
+
+ NO_CLI = True
+
+ msg_summary = _('Added topology suffix "%(value)s"')
+
+ def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options):
+ assert isinstance(dn, DN)
+ validate_domain_level(self.api)
+ return dn
+
+
+@register()
+class topologysuffix_mod(LDAPUpdate):
+ __doc__ = _('Modify a topology suffix.')
+
+ NO_CLI = True
+
+ msg_summary = _('Modified topology suffix "%(value)s"')
+
+ def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options):
+ assert isinstance(dn, DN)
+ validate_domain_level(self.api)
+ return dn
+
+
+@register()
+class topologysuffix_show(LDAPRetrieve):
+ __doc__ = _('Show managed suffix.')
+
+
+@register()
+class topologysuffix_verify(LDAPQuery):
+ __doc__ = _('''
+Verify replication topology for suffix.
+
+Checks done:
+ 1. check if a topology is not disconnected. In other words if there are
+ replication paths between all servers.
+ 2. check if servers don't have more than the recommended number of
+ replication agreements
+''')
+
+ def execute(self, *keys, **options):
+
+ validate_domain_level(self.api)
+
+ masters = self.api.Command.server_find(
+ '', sizelimit=0, no_members=False)['result']
+ segments = self.api.Command.topologysegment_find(
+ keys[0], sizelimit=0)['result']
+ graph = create_topology_graph(masters, segments)
+ master_cns = [m['cn'][0] for m in masters]
+ master_cns.sort()
+
+ # check if each master can contact others
+ connect_errors = get_topology_connection_errors(graph)
+
+ # check if suggested maximum number of agreements per replica
+ max_agmts_errors = []
+ for m in master_cns:
+ # chosen direction doesn't matter much given that 'both' is the
+ # only allowed direction
+ suppliers = graph.get_tails(m)
+ if len(suppliers) > self.api.env.recommended_max_agmts:
+ max_agmts_errors.append((m, suppliers))
+
+ return dict(
+ result={
+ 'in_order': not connect_errors and not max_agmts_errors,
+ 'connect_errors': connect_errors,
+ 'max_agmts_errors': max_agmts_errors,
+ 'max_agmts': self.api.env.recommended_max_agmts
+ },
+ )
diff --git a/ipaserver/plugins/trust.py b/ipaserver/plugins/trust.py
new file mode 100644
index 000000000..ee0ab5d10
--- /dev/null
+++ b/ipaserver/plugins/trust.py
@@ -0,0 +1,1725 @@
+# Authors:
+# Alexander Bokovoy <abokovoy@redhat.com>
+# Martin Kosek <mkosek@redhat.com>
+#
+# Copyright (C) 2011 Red Hat
+# see file 'COPYING' for use and warranty information
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+import six
+
+from ipalib.messages import (
+ add_message,
+ BrokenTrust)
+from ipalib.plugable import Registry
+from .baseldap import (
+ pkey_to_value,
+ entry_to_dict,
+ LDAPCreate,
+ LDAPDelete,
+ LDAPUpdate,
+ LDAPSearch,
+ LDAPRetrieve,
+ LDAPObject,
+ LDAPQuery)
+from .dns import dns_container_exists
+from ipapython.dn import DN
+from ipapython.ipautil import realm_to_suffix
+from ipapython.ipa_log_manager import root_logger
+from ipalib import api, Str, StrEnum, Password, Bool, _, ngettext, Int, Flag
+from ipalib import Command
+from ipalib import errors
+from ipalib import output
+from ldap import SCOPE_SUBTREE
+from time import sleep
+
+if six.PY3:
+ unicode = str
+
+try:
+ import pysss_murmur #pylint: disable=F0401
+ _murmur_installed = True
+except Exception as e:
+ _murmur_installed = False
+
+try:
+ import pysss_nss_idmap #pylint: disable=F0401
+ _nss_idmap_installed = True
+except Exception as e:
+ _nss_idmap_installed = False
+
+if api.env.in_server and api.env.context in ['lite', 'server']:
+ try:
+ import ipaserver.dcerpc #pylint: disable=F0401
+ from ipaserver.dcerpc import TRUST_ONEWAY, TRUST_BIDIRECTIONAL
+ import dbus
+ import dbus.mainloop.glib
+ _bindings_installed = True
+ except ImportError:
+ _bindings_installed = False
+
+__doc__ = _("""
+Cross-realm trusts
+
+Manage trust relationship between IPA and Active Directory domains.
+
+In order to allow users from a remote domain to access resources in IPA
+domain, trust relationship needs to be established. Currently IPA supports
+only trusts between IPA and Active Directory domains under control of Windows
+Server 2008 or later, with functional level 2008 or later.
+
+Please note that DNS on both IPA and Active Directory domain sides should be
+configured properly to discover each other. Trust relationship relies on
+ability to discover special resources in the other domain via DNS records.
+
+Examples:
+
+1. Establish cross-realm trust with Active Directory using AD administrator
+ credentials:
+
+ ipa trust-add --type=ad <ad.domain> --admin <AD domain administrator> --password
+
+2. List all existing trust relationships:
+
+ ipa trust-find
+
+3. Show details of the specific trust relationship:
+
+ ipa trust-show <ad.domain>
+
+4. Delete existing trust relationship:
+
+ ipa trust-del <ad.domain>
+
+Once trust relationship is established, remote users will need to be mapped
+to local POSIX groups in order to actually use IPA resources. The mapping should
+be done via use of external membership of non-POSIX group and then this group
+should be included into one of local POSIX groups.
+
+Example:
+
+1. Create group for the trusted domain admins' mapping and their local POSIX group:
+
+ ipa group-add --desc='<ad.domain> admins external map' ad_admins_external --external
+ ipa group-add --desc='<ad.domain> admins' ad_admins
+
+2. Add security identifier of Domain Admins of the <ad.domain> to the ad_admins_external
+ group:
+
+ ipa group-add-member ad_admins_external --external 'AD\\Domain Admins'
+
+3. Allow members of ad_admins_external group to be associated with ad_admins POSIX group:
+
+ ipa group-add-member ad_admins --groups ad_admins_external
+
+4. List members of external members of ad_admins_external group to see their SIDs:
+
+ ipa group-show ad_admins_external
+
+
+GLOBAL TRUST CONFIGURATION
+
+When IPA AD trust subpackage is installed and ipa-adtrust-install is run,
+a local domain configuration (SID, GUID, NetBIOS name) is generated. These
+identifiers are then used when communicating with a trusted domain of the
+particular type.
+
+1. Show global trust configuration for Active Directory type of trusts:
+
+ ipa trustconfig-show --type ad
+
+2. Modify global configuration for all trusts of Active Directory type and set
+ a different fallback primary group (fallback primary group GID is used as
+ a primary user GID if user authenticating to IPA domain does not have any other
+ primary GID already set):
+
+ ipa trustconfig-mod --type ad --fallback-primary-group "alternative AD group"
+
+3. Change primary fallback group back to default hidden group (any group with
+ posixGroup object class is allowed):
+
+ ipa trustconfig-mod --type ad --fallback-primary-group "Default SMB Group"
+""")
+
+register = Registry()
+
+trust_output_params = (
+ Str('trustdirection',
+ label=_('Trust direction')),
+ Str('trusttype',
+ label=_('Trust type')),
+ Str('truststatus',
+ label=_('Trust status')),
+)
+
+_trust_type_dict = {1 : _('Non-Active Directory domain'),
+ 2 : _('Active Directory domain'),
+ 3 : _('RFC4120-compliant Kerberos realm')}
+_trust_direction_dict = {1 : _('Trusting forest'),
+ 2 : _('Trusted forest'),
+ 3 : _('Two-way trust')}
+_trust_status_dict = {True : _('Established and verified'),
+ False : _('Waiting for confirmation by remote side')}
+_trust_type_dict_unknown = _('Unknown')
+
+_trust_type_option = StrEnum('trust_type',
+ cli_name='type',
+ label=_('Trust type (ad for Active Directory, default)'),
+ values=(u'ad',),
+ default=u'ad',
+ autofill=True,
+ )
+
+DEFAULT_RANGE_SIZE = 200000
+
+DBUS_IFACE_TRUST = 'com.redhat.idm.trust'
+
+CRED_STYLE_SAMBA = 1
+CRED_STYLE_KERBEROS = 2
+
+def trust_type_string(level):
+ """
+ Returns a string representing a type of the trust. The original field is an enum:
+ LSA_TRUST_TYPE_DOWNLEVEL = 0x00000001,
+ LSA_TRUST_TYPE_UPLEVEL = 0x00000002,
+ LSA_TRUST_TYPE_MIT = 0x00000003
+ """
+ string = _trust_type_dict.get(int(level), _trust_type_dict_unknown)
+ return unicode(string)
+
+def trust_direction_string(level):
+ """
+ Returns a string representing a direction of the trust. The original field is a bitmask taking two bits in use
+ LSA_TRUST_DIRECTION_INBOUND = 0x00000001,
+ LSA_TRUST_DIRECTION_OUTBOUND = 0x00000002
+ """
+ string = _trust_direction_dict.get(int(level), _trust_type_dict_unknown)
+ return unicode(string)
+
+def trust_status_string(level):
+ string = _trust_status_dict.get(level, _trust_type_dict_unknown)
+ return unicode(string)
+
+def make_trust_dn(env, trust_type, dn):
+ assert isinstance(dn, DN)
+ if trust_type:
+ container_dn = DN(('cn', trust_type), env.container_trusts, env.basedn)
+ return DN(dn, container_dn)
+ return dn
+
+def find_adtrust_masters(ldap, api):
+ """
+ Returns a list of names of IPA servers with ADTRUST component configured.
+ """
+
+ try:
+ entries, truncated = ldap.find_entries(
+ "cn=ADTRUST",
+ base_dn=api.env.container_masters + api.env.basedn
+ )
+ except errors.NotFound:
+ entries = []
+
+ return [entry.dn[1].value for entry in entries]
+
+def verify_samba_component_presence(ldap, api):
+ """
+ Verifies that Samba is installed and configured on this particular master.
+ If Samba is not available, provide a heplful hint with the list of masters
+ capable of running the commands.
+ """
+
+ adtrust_present = api.Command['adtrust_is_enabled']()['result']
+
+ hint = _(
+ ' Alternatively, following servers are capable of running this '
+ 'command: %(masters)s'
+ )
+
+ def raise_missing_component_error(message):
+ masters_with_adtrust = find_adtrust_masters(ldap, api)
+
+ # If there are any masters capable of running Samba requiring commands
+ # let's advertise them directly
+ if masters_with_adtrust:
+ message += hint % dict(masters=', '.join(masters_with_adtrust))
+
+ raise errors.NotFound(
+ name=_('AD Trust setup'),
+ reason=message,
+ )
+
+ # We're ok in this case, bail out
+ if adtrust_present and _bindings_installed:
+ return
+
+ # First check for packages missing
+ elif not _bindings_installed:
+ error_message=_(
+ 'Cannot perform the selected command without Samba 4 support '
+ 'installed. Make sure you have installed server-trust-ad '
+ 'sub-package of IPA.'
+ )
+
+ raise_missing_component_error(error_message)
+
+ # Packages present, but ADTRUST instance is not configured
+ elif not adtrust_present:
+ error_message=_(
+ 'Cannot perform the selected command without Samba 4 instance '
+ 'configured on this machine. Make sure you have run '
+ 'ipa-adtrust-install on this server.'
+ )
+
+ raise_missing_component_error(error_message)
+
+
+def generate_creds(trustinstance, style, **options):
+ """
+ Generate string representing credentials using trust instance
+ Input:
+ trustinstance -- ipaserver.dcerpc.TrustInstance object
+ style -- style of credentials
+ CRED_STYLE_SAMBA -- for using with Samba bindings
+ CRED_STYLE_KERBEROS -- for obtaining Kerberos ticket
+ **options -- options with realm_admin and realm_passwd keys
+
+ Result:
+ a string representing credentials with first % separating username and password
+ None is returned if realm_passwd key returns nothing from options
+ """
+ creds = None
+ password = options.get('realm_passwd', None)
+ if password:
+ admin_name = options.get('realm_admin')
+ sp = []
+ sep = '@'
+ if style == CRED_STYLE_SAMBA:
+ sep = "\\"
+ sp = admin_name.split(sep)
+ if len(sp) == 1:
+ sp.insert(0, trustinstance.remote_domain.info['name'])
+ elif style == CRED_STYLE_KERBEROS:
+ sp = admin_name.split('\\')
+ if len(sp) > 1:
+ sp = [sp[1]]
+ else:
+ sp = admin_name.split(sep)
+ if len(sp) == 1:
+ sp.append(trustinstance.remote_domain.info['dns_forest'].upper())
+ creds = u"{name}%{password}".format(name=sep.join(sp),
+ password=password)
+ return creds
+
+def add_range(myapi, trustinstance, range_name, dom_sid, *keys, **options):
+ """
+ First, we try to derive the parameters of the ID range based on the
+ information contained in the Active Directory.
+
+ If that was not successful, we go for our usual defaults (random base,
+ range size 200 000, ipa-ad-trust range type).
+
+ Any of these can be overridden by passing appropriate CLI options
+ to the trust-add command.
+ """
+
+ range_size = None
+ range_type = None
+ base_id = None
+
+ # First, get information about ID space from AD
+ # However, we skip this step if other than ipa-ad-trust-posix
+ # range type is enforced
+
+ if options.get('range_type', None) in (None, u'ipa-ad-trust-posix'):
+
+ # Get the base dn
+ domain = keys[-1]
+ basedn = realm_to_suffix(domain)
+
+ # Search for information contained in
+ # CN=ypservers,CN=ypServ30,CN=RpcServices,CN=System
+ info_filter = '(objectClass=msSFU30DomainInfo)'
+ info_dn = DN('CN=ypservers,CN=ypServ30,CN=RpcServices,CN=System')\
+ + basedn
+
+ # Get the domain validator
+ domain_validator = ipaserver.dcerpc.DomainValidator(myapi)
+ if not domain_validator.is_configured():
+ raise errors.NotFound(
+ reason=_('Cannot search in trusted domains without own '
+ 'domain configured. Make sure you have run '
+ 'ipa-adtrust-install on the IPA server first'))
+
+ creds = None
+ if trustinstance:
+ # Re-use AD administrator credentials if they were provided
+ creds = generate_creds(trustinstance, style=CRED_STYLE_KERBEROS, **options)
+ if creds:
+ domain_validator._admin_creds = creds
+ # KDC might not get refreshed data at the first time,
+ # retry several times
+ for retry in range(10):
+ info_list = domain_validator.search_in_dc(domain,
+ info_filter,
+ None,
+ SCOPE_SUBTREE,
+ basedn=info_dn,
+ quiet=True)
+
+ if info_list:
+ info = info_list[0]
+ break
+ else:
+ sleep(2)
+
+ required_msSFU_attrs = ['msSFU30MaxUidNumber', 'msSFU30OrderNumber']
+
+ if not info_list:
+ # We were unable to gain UNIX specific info from the AD
+ root_logger.debug("Unable to gain POSIX info from the AD")
+ else:
+ if all(attr in info for attr in required_msSFU_attrs):
+ root_logger.debug("Able to gain POSIX info from the AD")
+ range_type = u'ipa-ad-trust-posix'
+
+ max_uid = info.get('msSFU30MaxUidNumber')
+ max_gid = info.get('msSFU30MaxGidNumber', None)
+ max_id = int(max(max_uid, max_gid)[0])
+
+ base_id = int(info.get('msSFU30OrderNumber')[0])
+ range_size = (1 + (max_id - base_id) // DEFAULT_RANGE_SIZE)\
+ * DEFAULT_RANGE_SIZE
+
+ # Second, options given via the CLI options take precedence to discovery
+ if options.get('range_type', None):
+ range_type = options.get('range_type', None)
+ elif not range_type:
+ range_type = u'ipa-ad-trust'
+
+ if options.get('range_size', None):
+ range_size = options.get('range_size', None)
+ elif not range_size:
+ range_size = DEFAULT_RANGE_SIZE
+
+ if options.get('base_id', None):
+ base_id = options.get('base_id', None)
+ elif not base_id:
+ # Generate random base_id if not discovered nor given via CLI
+ base_id = DEFAULT_RANGE_SIZE + (
+ pysss_murmur.murmurhash3(
+ dom_sid,
+ len(dom_sid), 0xdeadbeef
+ ) % 10000
+ ) * DEFAULT_RANGE_SIZE
+
+ # Finally, add new ID range
+ myapi.Command['idrange_add'](range_name,
+ ipabaseid=base_id,
+ ipaidrangesize=range_size,
+ ipabaserid=0,
+ iparangetype=range_type,
+ ipanttrusteddomainsid=dom_sid)
+
+ # Return the values that were generated inside this function
+ return range_type, range_size, base_id
+
+def fetch_trusted_domains_over_dbus(myapi, log, forest_name):
+ if not _bindings_installed:
+ return
+ # Calling oddjobd-activated service via DBus has some quirks:
+ # - Oddjobd registers multiple canonical names on the same address
+ # - python-dbus only follows name owner changes when mainloop is in use
+ # See https://fedorahosted.org/oddjob/ticket/2 for details
+ dbus.mainloop.glib.DBusGMainLoop(set_as_default=True)
+ try:
+ _ret = 0
+ _stdout = ''
+ _stderr = ''
+ bus = dbus.SystemBus()
+ intf = bus.get_object(DBUS_IFACE_TRUST,"/", follow_name_owner_changes=True)
+ fetch_domains_method = intf.get_dbus_method('fetch_domains', dbus_interface=DBUS_IFACE_TRUST)
+ (_ret, _stdout, _stderr) = fetch_domains_method(forest_name)
+ except dbus.DBusException as e:
+ log.error('Failed to call %(iface)s.fetch_domains helper.'
+ 'DBus exception is %(exc)s.' % dict(iface=DBUS_IFACE_TRUST, exc=str(e)))
+ if _ret != 0:
+ log.error('Helper was called for forest %(forest)s, return code is %(ret)d' % dict(forest=forest_name, ret=_ret))
+ log.error('Standard output from the helper:\n%s---\n' % (_stdout))
+ log.error('Error output from the helper:\n%s--\n' % (_stderr))
+ raise errors.ServerCommandError(server=myapi.env.host,
+ error=_('Fetching domains from trusted forest failed. '
+ 'See details in the error_log'))
+ return
+
+@register()
+class trust(LDAPObject):
+ """
+ Trust object.
+ """
+ trust_types = ('ad', 'ipa')
+ container_dn = api.env.container_trusts
+ object_name = _('trust')
+ object_name_plural = _('trusts')
+ object_class = ['ipaNTTrustedDomain']
+ default_attributes = ['cn', 'ipantflatname', 'ipanttrusteddomainsid',
+ 'ipanttrusttype', 'ipanttrustattributes', 'ipanttrustdirection',
+ 'ipanttrustpartner', 'ipanttrustforesttrustinfo',
+ 'ipanttrustposixoffset', 'ipantsupportedencryptiontypes' ]
+ search_display_attributes = ['cn', 'ipantflatname',
+ 'ipanttrusteddomainsid', 'ipanttrusttype']
+ managed_permissions = {
+ 'System: Read Trust Information': {
+ # Allow reading of attributes needed for SSSD subdomains support
+ 'non_object': True,
+ 'ipapermlocation': DN(container_dn, api.env.basedn),
+ 'replaces_global_anonymous_aci': True,
+ 'ipapermbindruletype': 'all',
+ 'ipapermright': {'read', 'search', 'compare'},
+ 'ipapermdefaultattr': {
+ 'cn', 'objectclass',
+ 'ipantflatname', 'ipantsecurityidentifier',
+ 'ipanttrusteddomainsid', 'ipanttrustpartner',
+ 'ipantsidblacklistincoming', 'ipantsidblacklistoutgoing',
+ 'ipanttrustdirection'
+ },
+ },
+
+ 'System: Read system trust accounts': {
+ 'non_object': True,
+ 'ipapermlocation': DN(container_dn, api.env.basedn),
+ 'replaces_global_anonymous_aci': True,
+ 'ipapermright': {'read', 'search', 'compare'},
+ 'ipapermdefaultattr': {
+ 'uidnumber', 'gidnumber', 'krbprincipalname'
+ },
+ 'default_privileges': {'ADTrust Agents'},
+ },
+ }
+
+ label = _('Trusts')
+ label_singular = _('Trust')
+
+ takes_params = (
+ Str('cn',
+ cli_name='realm',
+ label=_('Realm name'),
+ primary_key=True,
+ ),
+ Str('ipantflatname',
+ cli_name='flat_name',
+ label=_('Domain NetBIOS name'),
+ flags=['no_create', 'no_update']),
+ Str('ipanttrusteddomainsid',
+ cli_name='sid',
+ label=_('Domain Security Identifier'),
+ flags=['no_create', 'no_update']),
+ Str('ipantsidblacklistincoming*',
+ cli_name='sid_blacklist_incoming',
+ label=_('SID blacklist incoming'),
+ flags=['no_create']),
+ Str('ipantsidblacklistoutgoing*',
+ cli_name='sid_blacklist_outgoing',
+ label=_('SID blacklist outgoing'),
+ flags=['no_create']),
+ )
+
+ def validate_sid_blacklists(self, entry_attrs):
+ if not _bindings_installed:
+ # SID validator is not available, return
+ # Even if invalid SID gets in the trust entry, it won't crash
+ # the validation process as it is translated to SID S-0-0
+ return
+ for attr in ('ipantsidblacklistincoming', 'ipantsidblacklistoutgoing'):
+ values = entry_attrs.get(attr)
+ if not values:
+ continue
+ for value in values:
+ if not ipaserver.dcerpc.is_sid_valid(value):
+ raise errors.ValidationError(name=attr,
+ error=_("invalid SID: %(value)s") % dict(value=value))
+
+ def get_dn(self, *keys, **kwargs):
+ trust_type = kwargs.get('trust_type')
+
+ sdn = [('cn', x) for x in keys]
+ sdn.reverse()
+
+ if trust_type is None:
+ ldap = self.backend
+ trustfilter = ldap.make_filter({
+ 'objectclass': ['ipaNTTrustedDomain'],
+ 'cn': [keys[-1]]},
+ rules=ldap.MATCH_ALL
+ )
+
+ # more type of objects can be located in subtree (for example
+ # cross-realm principals). we need this attr do detect trust
+ # entries
+ trustfilter = ldap.combine_filters(
+ (trustfilter, "ipaNTTrustPartner=*"),
+ rules=ldap.MATCH_ALL
+ )
+
+ try:
+ result = ldap.get_entries(
+ DN(self.container_dn, self.env.basedn),
+ ldap.SCOPE_SUBTREE, trustfilter, ['']
+ )
+ except errors.NotFound:
+ self.handle_not_found(keys[-1])
+
+ if len(result) > 1:
+ raise errors.OnlyOneValueAllowed(attr='trust domain')
+
+ return result[0].dn
+
+ return make_trust_dn(self.env, trust_type, DN(*sdn))
+
+ def warning_if_ad_trust_dom_have_missing_SID(self, result, **options):
+ """Due bug https://fedorahosted.org/freeipa/ticket/5665 there might be
+ AD trust domain without generated SID, warn user about it.
+ """
+ ldap = self.api.Backend.ldap2
+
+ try:
+ entries, truncated = ldap.find_entries(
+ base_dn=DN(self.api.env.container_adtrusts,
+ self.api.env.basedn),
+ scope=ldap.SCOPE_ONELEVEL,
+ attrs_list=['cn'],
+ filter='(&(ipaNTTrustPartner=*)'
+ '(!(ipaNTSecurityIdentifier=*)))',
+ )
+ except errors.NotFound:
+ pass
+ else:
+ for entry in entries:
+ add_message(
+ options['version'],
+ result,
+ BrokenTrust(domain=entry.single_value['cn'])
+ )
+
+
+@register()
+class trust_add(LDAPCreate):
+ __doc__ = _('''
+Add new trust to use.
+
+This command establishes trust relationship to another domain
+which becomes 'trusted'. As result, users of the trusted domain
+may access resources of this domain.
+
+Only trusts to Active Directory domains are supported right now.
+
+The command can be safely run multiple times against the same domain,
+this will cause change to trust relationship credentials on both
+sides.
+ ''')
+
+ range_types = {
+ u'ipa-ad-trust': unicode(_('Active Directory domain range')),
+ u'ipa-ad-trust-posix': unicode(_('Active Directory trust range with '
+ 'POSIX attributes')),
+ }
+
+ takes_options = LDAPCreate.takes_options + (
+ _trust_type_option,
+ Str('realm_admin?',
+ cli_name='admin',
+ label=_("Active Directory domain administrator"),
+ ),
+ Password('realm_passwd?',
+ cli_name='password',
+ label=_("Active Directory domain administrator's password"),
+ confirm=False,
+ ),
+ Str('realm_server?',
+ cli_name='server',
+ label=_('Domain controller for the Active Directory domain (optional)'),
+ ),
+ Password('trust_secret?',
+ cli_name='trust_secret',
+ label=_('Shared secret for the trust'),
+ confirm=False,
+ ),
+ Int('base_id?',
+ cli_name='base_id',
+ label=_('First Posix ID of the range reserved for the trusted domain'),
+ ),
+ Int('range_size?',
+ cli_name='range_size',
+ label=_('Size of the ID range reserved for the trusted domain'),
+ ),
+ StrEnum('range_type?',
+ label=_('Range type'),
+ cli_name='range_type',
+ doc=(_('Type of trusted domain ID range, one of {vals}'
+ .format(vals=', '.join(range_types.keys())))),
+ values=tuple(range_types.keys()),
+ ),
+ Bool('bidirectional?',
+ label=_('Two-way trust'),
+ cli_name='two_way',
+ doc=(_('Establish bi-directional trust. By default trust is inbound one-way only.')),
+ default=False,
+ ),
+ )
+
+ msg_summary = _('Added Active Directory trust for realm "%(value)s"')
+ msg_summary_existing = _('Re-established trust to domain "%(value)s"')
+ has_output_params = LDAPCreate.has_output_params + trust_output_params
+
+ def execute(self, *keys, **options):
+ ldap = self.obj.backend
+
+ verify_samba_component_presence(ldap, self.api)
+
+ full_join = self.validate_options(*keys, **options)
+ old_range, range_name, dom_sid = self.validate_range(*keys, **options)
+ result = self.execute_ad(full_join, *keys, **options)
+
+ if not old_range:
+ # Store the created range type, since for POSIX trusts no
+ # ranges for the subdomains should be added, POSIX attributes
+ # provide a global mapping across all subdomains
+ (created_range_type, _, _) = add_range(self.api, self.trustinstance,
+ range_name, dom_sid,
+ *keys, **options)
+ else:
+ created_range_type = old_range['result']['iparangetype'][0]
+
+ trust_filter = "cn=%s" % result['value']
+ (trusts, truncated) = ldap.find_entries(
+ base_dn=DN(self.api.env.container_trusts, self.api.env.basedn),
+ filter=trust_filter)
+
+ result['result'] = entry_to_dict(trusts[0], **options)
+
+ # Fetch topology of the trust forest -- we need always to do it
+ # for AD trusts, regardless of the type of idranges associated with it
+ # Note that add_new_domains_from_trust will add needed ranges for
+ # the algorithmic ID mapping case.
+ if (options.get('trust_type') == u'ad' and
+ options.get('trust_secret') is None):
+ if options.get('bidirectional') == True:
+ # Bidirectional trust allows us to use cross-realm TGT, so we can
+ # run the call under original user's credentials
+ res = fetch_domains_from_trust(self.api, self.trustinstance,
+ result['result'], **options)
+ domains = add_new_domains_from_trust(self.api, self.trustinstance,
+ result['result'], res, **options)
+ else:
+ # One-way trust is more complex. We don't have cross-realm TGT
+ # and cannot use IPA principals to authenticate against AD.
+ # Instead, we have to use our trusted domain object's (TDO)
+ # account in AD. Access to the credentials is limited and IPA
+ # framework cannot access it directly. Instead, we call out to
+ # oddjobd-activated higher privilege process that will use TDO
+ # object credentials to authenticate to AD with Kerberos,
+ # run DCE RPC calls to do discovery and will call
+ # add_new_domains_from_trust() on its own.
+ fetch_trusted_domains_over_dbus(self.api, self.log, result['value'])
+
+ # Format the output into human-readable values
+ result['result']['trusttype'] = [trust_type_string(
+ result['result']['ipanttrusttype'][0])]
+ result['result']['trustdirection'] = [trust_direction_string(
+ result['result']['ipanttrustdirection'][0])]
+ result['result']['truststatus'] = [trust_status_string(
+ result['verified'])]
+
+ del result['verified']
+ result['result'].pop('ipanttrustauthoutgoing', None)
+ result['result'].pop('ipanttrustauthincoming', None)
+
+ return result
+
+ def validate_options(self, *keys, **options):
+ trusted_realm_domain = keys[-1]
+
+ if not _murmur_installed and 'base_id' not in options:
+ raise errors.ValidationError(
+ name=_('missing base_id'),
+ error=_(
+ 'pysss_murmur is not available on the server '
+ 'and no base-id is given.'
+ )
+ )
+
+ if 'trust_type' not in options:
+ raise errors.RequirementError(name='trust_type')
+
+ if options['trust_type'] != u'ad':
+ raise errors.ValidationError(
+ name=_('trust type'),
+ error=_('only "ad" is supported')
+ )
+
+ # Detect IPA-AD domain clash
+ if self.api.env.domain.lower() == trusted_realm_domain.lower():
+ raise errors.ValidationError(
+ name=_('domain'),
+ error=_('Cannot establish a trust to AD deployed in the same '
+ 'domain as IPA. Such setup is not supported.')
+ )
+
+ # If domain name and realm does not match, IPA server is not be able
+ # to establish trust with Active Directory.
+
+ realm_not_matching_domain = (self.api.env.domain.upper() != self.api.env.realm)
+
+ if options['trust_type'] == u'ad' and realm_not_matching_domain:
+ raise errors.ValidationError(
+ name=_('Realm-domain mismatch'),
+ error=_('To establish trust with Active Directory, the '
+ 'domain name and the realm name of the IPA server '
+ 'must match')
+ )
+
+ self.trustinstance = ipaserver.dcerpc.TrustDomainJoins(self.api)
+ if not self.trustinstance.configured:
+ raise errors.NotFound(
+ name=_('AD Trust setup'),
+ reason=_(
+ 'Cannot perform join operation without own domain '
+ 'configured. Make sure you have run ipa-adtrust-install '
+ 'on the IPA server first'
+ )
+ )
+
+ # Obtain a list of IPA realm domains
+ result = self.api.Command.realmdomains_show()['result']
+ realm_domains = result['associateddomain']
+
+ # Do not allow the AD's trusted realm domain in the list
+ # of our realm domains
+ if trusted_realm_domain.lower() in realm_domains:
+ raise errors.ValidationError(
+ name=_('AD Trust setup'),
+ error=_(
+ 'Trusted domain %(domain)s is included among '
+ 'IPA realm domains. It needs to be removed '
+ 'prior to establishing the trust. See the '
+ '"ipa realmdomains-mod --del-domain" command.'
+ ) % dict(domain=trusted_realm_domain)
+ )
+
+ self.realm_server = options.get('realm_server')
+ self.realm_admin = options.get('realm_admin')
+ self.realm_passwd = options.get('realm_passwd')
+
+ if self.realm_admin:
+ names = self.realm_admin.split('@')
+
+ if len(names) > 1:
+ # realm admin name is in UPN format, user@realm, check that
+ # realm is the same as the one that we are attempting to trust
+ if trusted_realm_domain.lower() != names[-1].lower():
+ raise errors.ValidationError(
+ name=_('AD Trust setup'),
+ error=_(
+ 'Trusted domain and administrator account use '
+ 'different realms'
+ )
+ )
+ self.realm_admin = names[0]
+
+ if not self.realm_passwd:
+ raise errors.ValidationError(
+ name=_('AD Trust setup'),
+ error=_('Realm administrator password should be specified')
+ )
+ return True
+
+ return False
+
+ def validate_range(self, *keys, **options):
+ # If a range for this trusted domain already exists,
+ # '--base-id' or '--range-size' options should not be specified
+ range_name = keys[-1].upper() + '_id_range'
+ range_type = options.get('range_type')
+
+ try:
+ old_range = self.api.Command['idrange_show'](range_name, raw=True)
+ except errors.NotFound:
+ old_range = None
+
+ if options.get('trust_type') == u'ad':
+ if range_type and range_type not in (u'ipa-ad-trust',
+ u'ipa-ad-trust-posix'):
+ raise errors.ValidationError(
+ name=_('id range type'),
+ error=_(
+ 'Only the ipa-ad-trust and ipa-ad-trust-posix are '
+ 'allowed values for --range-type when adding an AD '
+ 'trust.'
+ ))
+
+ base_id = options.get('base_id')
+ range_size = options.get('range_size')
+
+ if old_range and (base_id or range_size):
+ raise errors.ValidationError(
+ name=_('id range'),
+ error=_(
+ 'An id range already exists for this trust. '
+ 'You should either delete the old range, or '
+ 'exclude --base-id/--range-size options from the command.'
+ )
+ )
+
+ # If a range for this trusted domain already exists,
+ # domain SID must also match
+ self.trustinstance.populate_remote_domain(
+ keys[-1],
+ self.realm_server,
+ self.realm_admin,
+ self.realm_passwd
+ )
+ dom_sid = self.trustinstance.remote_domain.info['sid']
+
+ if old_range:
+ old_dom_sid = old_range['result']['ipanttrusteddomainsid'][0]
+ old_range_type = old_range['result']['iparangetype'][0]
+
+ if old_dom_sid != dom_sid:
+ raise errors.ValidationError(
+ name=_('range exists'),
+ error=_(
+ 'ID range with the same name but different domain SID '
+ 'already exists. The ID range for the new trusted '
+ 'domain must be created manually.'
+ )
+ )
+
+ if range_type and range_type != old_range_type:
+ raise errors.ValidationError(name=_('range type change'),
+ error=_('ID range for the trusted domain already exists, '
+ 'but it has a different type. Please remove the '
+ 'old range manually, or do not enforce type '
+ 'via --range-type option.'))
+
+ return old_range, range_name, dom_sid
+
+ def execute_ad(self, full_join, *keys, **options):
+ # Join domain using full credentials and with random trustdom
+ # secret (will be generated by the join method)
+
+ # First see if the trust is already in place
+ # Force retrieval of the trust object by not passing trust_type
+ try:
+ dn = self.obj.get_dn(keys[-1])
+ except errors.NotFound:
+ dn = None
+
+ trust_type = TRUST_ONEWAY
+ if options.get('bidirectional', False):
+ trust_type = TRUST_BIDIRECTIONAL
+ # 1. Full access to the remote domain. Use admin credentials and
+ # generate random trustdom password to do work on both sides
+ if full_join:
+ try:
+ result = self.trustinstance.join_ad_full_credentials(
+ keys[-1],
+ self.realm_server,
+ self.realm_admin,
+ self.realm_passwd,
+ trust_type
+ )
+ except errors.NotFound:
+ error_message=_("Unable to resolve domain controller for '%s' domain. ") % (keys[-1])
+ instructions=[]
+ if dns_container_exists(self.obj.backend):
+ try:
+ dns_zone = self.api.Command.dnszone_show(keys[-1])['result']
+ if ('idnsforwardpolicy' in dns_zone) and dns_zone['idnsforwardpolicy'][0] == u'only':
+ instructions.append(_("Forward policy is defined for it in IPA DNS, "
+ "perhaps forwarder points to incorrect host?"))
+ except (errors.NotFound, KeyError) as e:
+ instructions.append(_("IPA manages DNS, please verify "
+ "your DNS configuration and "
+ "make sure that service records "
+ "of the '%(domain)s' domain can "
+ "be resolved. Examples how to "
+ "configure DNS with CLI commands "
+ "or the Web UI can be found in "
+ "the documentation. " ) %
+ dict(domain=keys[-1]))
+ else:
+ instructions.append(_("Since IPA does not manage DNS records, ensure DNS "
+ "is configured to resolve '%(domain)s' domain from "
+ "IPA hosts and back.") % dict(domain=keys[-1]))
+ raise errors.NotFound(reason=error_message, instructions=instructions)
+
+ if result is None:
+ raise errors.ValidationError(name=_('AD Trust setup'),
+ error=_('Unable to verify write permissions to the AD'))
+
+ ret = dict(
+ value=pkey_to_value(
+ self.trustinstance.remote_domain.info['dns_domain'],
+ options),
+ verified=result['verified']
+ )
+ if dn:
+ ret['summary'] = self.msg_summary_existing % ret
+ return ret
+
+ # 2. We don't have access to the remote domain and trustdom password
+ # is provided. Do the work on our side and inform what to do on remote
+ # side.
+ if options.get('trust_secret'):
+ result = self.trustinstance.join_ad_ipa_half(
+ keys[-1],
+ self.realm_server,
+ options['trust_secret'],
+ trust_type
+ )
+ ret = dict(
+ value=pkey_to_value(
+ self.trustinstance.remote_domain.info['dns_domain'],
+ options),
+ verified=result['verified']
+ )
+ if dn:
+ ret['summary'] = self.msg_summary_existing % ret
+ return ret
+ else:
+ raise errors.ValidationError(
+ name=_('AD Trust setup'),
+ error=_('Not enough arguments specified to perform trust '
+ 'setup'))
+
+@register()
+class trust_del(LDAPDelete):
+ __doc__ = _('Delete a trust.')
+
+ msg_summary = _('Deleted trust "%(value)s"')
+
+@register()
+class trust_mod(LDAPUpdate):
+ __doc__ = _("""
+ Modify a trust (for future use).
+
+ Currently only the default option to modify the LDAP attributes is
+ available. More specific options will be added in coming releases.
+ """)
+
+ msg_summary = _('Modified trust "%(value)s" '
+ '(change will be effective in 60 seconds)')
+
+ def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options):
+ assert isinstance(dn, DN)
+
+ self.obj.validate_sid_blacklists(entry_attrs)
+
+ return dn
+
+@register()
+class trust_find(LDAPSearch):
+ __doc__ = _('Search for trusts.')
+ has_output_params = LDAPSearch.has_output_params + trust_output_params +\
+ (Str('ipanttrusttype'),)
+
+ msg_summary = ngettext(
+ '%(count)d trust matched', '%(count)d trusts matched', 0
+ )
+
+ # Since all trusts types are stored within separate containers under 'cn=trusts',
+ # search needs to be done on a sub-tree scope
+ def pre_callback(self, ldap, filters, attrs_list, base_dn, scope, *args, **options):
+ # list only trust, not trust domains
+ trust_filter = '(ipaNTTrustPartner=*)'
+ filter = ldap.combine_filters((filters, trust_filter), rules=ldap.MATCH_ALL)
+ return (filter, base_dn, ldap.SCOPE_SUBTREE)
+
+ def execute(self, *args, **options):
+ result = super(trust_find, self).execute(*args, **options)
+
+ self.obj.warning_if_ad_trust_dom_have_missing_SID(result, **options)
+
+ return result
+
+ def post_callback(self, ldap, entries, truncated, *args, **options):
+ if options.get('pkey_only', False):
+ return truncated
+
+ for attrs in entries:
+ # Translate ipanttrusttype to trusttype if --raw not used
+ trust_type = attrs.get('ipanttrusttype', [None])[0]
+ if not options.get('raw', False) and trust_type is not None:
+ attrs['trusttype'] = trust_type_string(attrs['ipanttrusttype'][0])
+ del attrs['ipanttrusttype']
+
+ return truncated
+
+@register()
+class trust_show(LDAPRetrieve):
+ __doc__ = _('Display information about a trust.')
+ has_output_params = LDAPRetrieve.has_output_params + trust_output_params +\
+ (Str('ipanttrusttype'), Str('ipanttrustdirection'))
+
+ def execute(self, *keys, **options):
+ result = super(trust_show, self).execute(*keys, **options)
+
+ self.obj.warning_if_ad_trust_dom_have_missing_SID(result, **options)
+
+ return result
+
+ def post_callback(self, ldap, dn, entry_attrs, *keys, **options):
+
+ assert isinstance(dn, DN)
+ # Translate ipanttrusttype to trusttype
+ # and ipanttrustdirection to trustdirection
+ # if --raw not used
+
+ if not options.get('raw', False):
+ trust_type = entry_attrs.get('ipanttrusttype', [None])[0]
+ if trust_type is not None:
+ entry_attrs['trusttype'] = trust_type_string(trust_type)
+ del entry_attrs['ipanttrusttype']
+
+ dir_str = entry_attrs.get('ipanttrustdirection', [None])[0]
+ if dir_str is not None:
+ entry_attrs['trustdirection'] = [trust_direction_string(dir_str)]
+ del entry_attrs['ipanttrustdirection']
+
+ return dn
+
+
+_trustconfig_dn = {
+ u'ad': DN(('cn', api.env.domain), api.env.container_cifsdomains, api.env.basedn),
+}
+
+
+@register()
+class trustconfig(LDAPObject):
+ """
+ Trusts global configuration object
+ """
+ object_name = _('trust configuration')
+ default_attributes = [
+ 'cn', 'ipantsecurityidentifier', 'ipantflatname', 'ipantdomainguid',
+ 'ipantfallbackprimarygroup',
+ ]
+
+ label = _('Global Trust Configuration')
+ label_singular = _('Global Trust Configuration')
+
+ takes_params = (
+ Str('cn',
+ label=_('Domain'),
+ flags=['no_update'],
+ ),
+ Str('ipantsecurityidentifier',
+ label=_('Security Identifier'),
+ flags=['no_update'],
+ ),
+ Str('ipantflatname',
+ label=_('NetBIOS name'),
+ flags=['no_update'],
+ ),
+ Str('ipantdomainguid',
+ label=_('Domain GUID'),
+ flags=['no_update'],
+ ),
+ Str('ipantfallbackprimarygroup',
+ cli_name='fallback_primary_group',
+ label=_('Fallback primary group'),
+ ),
+ )
+
+ def get_dn(self, *keys, **kwargs):
+ trust_type = kwargs.get('trust_type')
+ if trust_type is None:
+ raise errors.RequirementError(name='trust_type')
+ try:
+ return _trustconfig_dn[kwargs['trust_type']]
+ except KeyError:
+ raise errors.ValidationError(name='trust_type',
+ error=_("unsupported trust type"))
+
+ def _normalize_groupdn(self, entry_attrs):
+ """
+ Checks that group with given name/DN exists and updates the entry_attrs
+ """
+ if 'ipantfallbackprimarygroup' not in entry_attrs:
+ return
+
+ group = entry_attrs['ipantfallbackprimarygroup']
+ if isinstance(group, (list, tuple)):
+ group = group[0]
+
+ if group is None:
+ return
+
+ try:
+ dn = DN(group)
+ # group is in a form of a DN
+ try:
+ self.backend.get_entry(dn)
+ except errors.NotFound:
+ self.api.Object['group'].handle_not_found(group)
+ # DN is valid, we can just return
+ return
+ except ValueError:
+ # The search is performed for groups with "posixgroup" objectclass
+ # and not "ipausergroup" so that it can also match groups like
+ # "Default SMB Group" which does not have this objectclass.
+ try:
+ group_entry = self.backend.find_entry_by_attr(
+ self.api.Object['group'].primary_key.name,
+ group,
+ ['posixgroup'],
+ [''],
+ DN(self.api.env.container_group, self.api.env.basedn))
+ except errors.NotFound:
+ self.api.Object['group'].handle_not_found(group)
+ else:
+ entry_attrs['ipantfallbackprimarygroup'] = [group_entry.dn]
+
+ def _convert_groupdn(self, entry_attrs, options):
+ """
+ Convert an group dn into a name. As we use CN as user RDN, its value
+ can be extracted from the DN without further LDAP queries.
+ """
+ if options.get('raw', False):
+ return
+
+ try:
+ groupdn = entry_attrs['ipantfallbackprimarygroup'][0]
+ except (IndexError, KeyError):
+ groupdn = None
+
+ if groupdn is None:
+ return
+ assert isinstance(groupdn, DN)
+
+ entry_attrs['ipantfallbackprimarygroup'] = [groupdn[0][0].value]
+
+
+@register()
+class trustconfig_mod(LDAPUpdate):
+ __doc__ = _('Modify global trust configuration.')
+
+ takes_options = LDAPUpdate.takes_options + (_trust_type_option,)
+ msg_summary = _('Modified "%(value)s" trust configuration')
+
+ def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options):
+ self.obj._normalize_groupdn(entry_attrs)
+ return dn
+
+ def execute(self, *keys, **options):
+ result = super(trustconfig_mod, self).execute(*keys, **options)
+ result['value'] = pkey_to_value(options['trust_type'], options)
+ return result
+
+ def post_callback(self, ldap, dn, entry_attrs, *keys, **options):
+ self.obj._convert_groupdn(entry_attrs, options)
+ return dn
+
+
+
+@register()
+class trustconfig_show(LDAPRetrieve):
+ __doc__ = _('Show global trust configuration.')
+
+ takes_options = LDAPRetrieve.takes_options + (_trust_type_option,)
+
+ def execute(self, *keys, **options):
+ result = super(trustconfig_show, self).execute(*keys, **options)
+ result['value'] = pkey_to_value(options['trust_type'], options)
+ return result
+
+ def post_callback(self, ldap, dn, entry_attrs, *keys, **options):
+ self.obj._convert_groupdn(entry_attrs, options)
+ return dn
+
+
+if _nss_idmap_installed:
+ _idmap_type_dict = {
+ pysss_nss_idmap.ID_USER : 'user',
+ pysss_nss_idmap.ID_GROUP : 'group',
+ pysss_nss_idmap.ID_BOTH : 'both',
+ }
+ def idmap_type_string(level):
+ string = _idmap_type_dict.get(int(level), 'unknown')
+ return unicode(string)
+
+@register()
+class trust_resolve(Command):
+ NO_CLI = True
+ __doc__ = _('Resolve security identifiers of users and groups in trusted domains')
+
+ takes_options = (
+ Str('sids+',
+ label = _('Security Identifiers (SIDs)'),
+ ),
+ )
+
+ has_output_params = (
+ Str('name', label= _('Name')),
+ Str('sid', label= _('SID')),
+ )
+
+ has_output = (
+ output.ListOfEntries('result'),
+ )
+
+ def execute(self, *keys, **options):
+ result = list()
+ if not _nss_idmap_installed:
+ return dict(result=result)
+ try:
+ sids = [str(x) for x in options['sids']]
+ xlate = pysss_nss_idmap.getnamebysid(sids)
+ for sid in xlate:
+ entry = dict()
+ entry['sid'] = [unicode(sid)]
+ entry['name'] = [unicode(xlate[sid][pysss_nss_idmap.NAME_KEY])]
+ entry['type'] = [idmap_type_string(xlate[sid][pysss_nss_idmap.TYPE_KEY])]
+ result.append(entry)
+ except ValueError as e:
+ pass
+
+ return dict(result=result)
+
+
+
+@register()
+class adtrust_is_enabled(Command):
+ NO_CLI = True
+
+ __doc__ = _('Determine whether ipa-adtrust-install has been run on this '
+ 'system')
+
+ def execute(self, *keys, **options):
+ ldap = self.api.Backend.ldap2
+ adtrust_dn = DN(
+ ('cn', 'ADTRUST'),
+ ('cn', self.api.env.host),
+ ('cn', 'masters'),
+ ('cn', 'ipa'),
+ ('cn', 'etc'),
+ self.api.env.basedn
+ )
+
+ try:
+ ldap.get_entry(adtrust_dn)
+ except errors.NotFound:
+ return dict(result=False)
+
+ return dict(result=True)
+
+
+
+@register()
+class compat_is_enabled(Command):
+ NO_CLI = True
+
+ __doc__ = _('Determine whether Schema Compatibility plugin is configured '
+ 'to serve trusted domain users and groups')
+
+ def execute(self, *keys, **options):
+ ldap = self.api.Backend.ldap2
+ users_dn = DN(
+ ('cn', 'users'),
+ ('cn', 'Schema Compatibility'),
+ ('cn', 'plugins'),
+ ('cn', 'config')
+ )
+ groups_dn = DN(
+ ('cn', 'groups'),
+ ('cn', 'Schema Compatibility'),
+ ('cn', 'plugins'),
+ ('cn', 'config')
+ )
+
+ try:
+ users_entry = ldap.get_entry(users_dn)
+ except errors.NotFound:
+ return dict(result=False)
+
+ attr = users_entry.get('schema-compat-lookup-nsswitch')
+ if not attr or 'user' not in attr:
+ return dict(result=False)
+
+ try:
+ groups_entry = ldap.get_entry(groups_dn)
+ except errors.NotFound:
+ return dict(result=False)
+
+ attr = groups_entry.get('schema-compat-lookup-nsswitch')
+ if not attr or 'group' not in attr:
+ return dict(result=False)
+
+ return dict(result=True)
+
+
+
+@register()
+class sidgen_was_run(Command):
+ """
+ This command tries to determine whether the sidgen task was run during
+ ipa-adtrust-install. It does that by simply checking the "editors" group
+ for the presence of the ipaNTSecurityIdentifier attribute - if the
+ attribute is present, the sidgen task was run.
+
+ Since this command relies on the existence of the "editors" group, it will
+ fail loudly in case this group does not exist.
+ """
+ NO_CLI = True
+
+ __doc__ = _('Determine whether ipa-adtrust-install has been run with '
+ 'sidgen task')
+
+ def execute(self, *keys, **options):
+ ldap = self.api.Backend.ldap2
+ editors_dn = DN(
+ ('cn', 'editors'),
+ ('cn', 'groups'),
+ ('cn', 'accounts'),
+ api.env.basedn
+ )
+
+ try:
+ editors_entry = ldap.get_entry(editors_dn)
+ except errors.NotFound:
+ raise errors.NotFound(
+ name=_('sidgen_was_run'),
+ reason=_(
+ 'This command relies on the existence of the "editors" '
+ 'group, but this group was not found.'
+ )
+ )
+
+ attr = editors_entry.get('ipaNTSecurityIdentifier')
+ if not attr:
+ return dict(result=False)
+
+ return dict(result=True)
+
+
+@register()
+class trustdomain(LDAPObject):
+ """
+ Object representing a domain of the AD trust.
+ """
+ parent_object = 'trust'
+ trust_type_idx = {'2':u'ad'}
+ object_name = _('trust domain')
+ object_name_plural = _('trust domains')
+ object_class = ['ipaNTTrustedDomain']
+ default_attributes = ['cn', 'ipantflatname', 'ipanttrusteddomainsid', 'ipanttrustpartner']
+ search_display_attributes = ['cn', 'ipantflatname', 'ipanttrusteddomainsid', ]
+
+ label = _('Trusted domains')
+ label_singular = _('Trusted domain')
+
+ takes_params = (
+ Str('cn',
+ label=_('Domain name'),
+ cli_name='domain',
+ primary_key=True
+ ),
+ Str('ipantflatname?',
+ cli_name='flat_name',
+ label=_('Domain NetBIOS name'),
+ ),
+ Str('ipanttrusteddomainsid?',
+ cli_name='sid',
+ label=_('Domain Security Identifier'),
+ ),
+ Str('ipanttrustpartner?',
+ label=_('Trusted domain partner'),
+ flags=['no_display', 'no_option'],
+ ),
+ )
+
+ # LDAPObject.get_dn() only passes all but last element of keys and no kwargs
+ # to the parent object's get_dn() no matter what you pass to it. Make own get_dn()
+ # as we really need all elements to construct proper dn.
+ def get_dn(self, *keys, **kwargs):
+ sdn = [('cn', x) for x in keys]
+ sdn.reverse()
+ trust_type = kwargs.get('trust_type')
+ if not trust_type:
+ trust_type=u'ad'
+
+ dn=make_trust_dn(self.env, trust_type, DN(*sdn))
+ return dn
+
+@register()
+class trustdomain_find(LDAPSearch):
+ __doc__ = _('Search domains of the trust')
+
+ has_output_params = LDAPSearch.has_output_params + (
+ Flag('domain_enabled', label= _('Domain enabled')),
+ )
+ def pre_callback(self, ldap, filters, attrs_list, base_dn, scope, *args, **options):
+ return (filters, base_dn, ldap.SCOPE_SUBTREE)
+
+ def post_callback(self, ldap, entries, truncated, *args, **options):
+ if options.get('pkey_only', False):
+ return truncated
+ trust_dn = self.obj.get_dn(args[0], trust_type=u'ad')
+ trust_entry = ldap.get_entry(trust_dn)
+ for entry in entries:
+ sid = entry['ipanttrusteddomainsid'][0]
+
+ blacklist = trust_entry.get('ipantsidblacklistincoming')
+ if blacklist is None:
+ continue
+
+ if sid in blacklist:
+ entry['domain_enabled'] = [False]
+ else:
+ entry['domain_enabled'] = [True]
+ return truncated
+
+
+
+@register()
+class trustdomain_mod(LDAPUpdate):
+ __doc__ = _('Modify trustdomain of the trust')
+
+ NO_CLI = True
+ takes_options = LDAPUpdate.takes_options + (_trust_type_option,)
+
+@register()
+class trustdomain_add(LDAPCreate):
+ __doc__ = _('Allow access from the trusted domain')
+ NO_CLI = True
+
+ takes_options = LDAPCreate.takes_options + (_trust_type_option,)
+ def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options):
+ if 'ipanttrustpartner' in options:
+ entry_attrs['ipanttrustpartner'] = [options['ipanttrustpartner']]
+ return dn
+
+@register()
+class trustdomain_del(LDAPDelete):
+ __doc__ = _('Remove infromation about the domain associated with the trust.')
+
+ msg_summary = _('Removed information about the trusted domain "%(value)s"')
+
+ def execute(self, *keys, **options):
+ ldap = self.api.Backend.ldap2
+ verify_samba_component_presence(ldap, self.api)
+
+ # Note that pre-/post- callback handling for LDAPDelete is causing pre_callback
+ # to always receive empty keys. We need to catch the case when root domain is being deleted
+
+ for domain in keys[1]:
+ # Fetch the trust to verify that the entered domain is trusted
+ self.api.Command.trust_show(domain)
+
+ if keys[0].lower() == domain:
+ raise errors.ValidationError(name='domain',
+ error=_("cannot delete root domain of the trust, "
+ "use trust-del to delete the trust itself"))
+ try:
+ res = self.api.Command.trustdomain_enable(keys[0], domain)
+ except errors.AlreadyActive:
+ pass
+
+ result = super(trustdomain_del, self).execute(*keys, **options)
+ result['value'] = pkey_to_value(keys[1], options)
+ return result
+
+
+def fetch_domains_from_trust(myapi, trustinstance, trust_entry, **options):
+ trust_name = trust_entry['cn'][0]
+ # We want to use Kerberos if we have admin credentials even with SMB calls
+ # as eventually use of NTLMSSP will be deprecated for trusted domain operations
+ # If admin credentials are missing, 'creds' will be None and fetch_domains
+ # will use HTTP/ipa.master@IPA.REALM principal, e.g. Kerberos authentication
+ # as well.
+ creds = generate_creds(trustinstance, style=CRED_STYLE_KERBEROS, **options)
+ server = options.get('realm_server', None)
+ domains = ipaserver.dcerpc.fetch_domains(myapi,
+ trustinstance.local_flatname,
+ trust_name, creds=creds, server=server)
+ return domains
+
+def add_new_domains_from_trust(myapi, trustinstance, trust_entry, domains, **options):
+ result = []
+ if not domains:
+ return result
+
+ trust_name = trust_entry['cn'][0]
+ # trust range must exist by the time add_new_domains_from_trust is called
+ range_name = trust_name.upper() + '_id_range'
+ old_range = myapi.Command.idrange_show(range_name, raw=True)['result']
+ idrange_type = old_range['iparangetype'][0]
+
+ for dom in domains:
+ dom['trust_type'] = u'ad'
+ try:
+ name = dom['cn']
+ del dom['cn']
+ if 'all' in options:
+ dom['all'] = options['all']
+ if 'raw' in options:
+ dom['raw'] = options['raw']
+
+ res = myapi.Command.trustdomain_add(trust_name, name, **dom)
+ result.append(res['result'])
+
+ if idrange_type != u'ipa-ad-trust-posix':
+ range_name = name.upper() + '_id_range'
+ dom['range_type'] = u'ipa-ad-trust'
+ add_range(myapi, trustinstance, range_name, dom['ipanttrusteddomainsid'],
+ trust_name, name, **dom)
+ except errors.DuplicateEntry:
+ # Ignore updating duplicate entries
+ pass
+ return result
+
+@register()
+class trust_fetch_domains(LDAPRetrieve):
+ __doc__ = _('Refresh list of the domains associated with the trust')
+
+ has_output = output.standard_list_of_entries
+ takes_options = LDAPRetrieve.takes_options + (
+ Str('realm_server?',
+ cli_name='server',
+ label=_('Domain controller for the Active Directory domain (optional)'),
+ ),
+ )
+
+ def execute(self, *keys, **options):
+ ldap = self.api.Backend.ldap2
+ verify_samba_component_presence(ldap, self.api)
+
+ trust = self.api.Command.trust_show(keys[0], raw=True)['result']
+
+ result = dict()
+ result['result'] = []
+ result['count'] = 0
+ result['truncated'] = False
+
+ # For one-way trust fetch over DBus. we don't get the list in this case.
+ if int(trust['ipanttrustdirection'][0]) != TRUST_BIDIRECTIONAL:
+ fetch_trusted_domains_over_dbus(self.api, self.log, keys[0])
+ result['summary'] = unicode(_('List of trust domains successfully refreshed. Use trustdomain-find command to list them.'))
+ return result
+
+ trustinstance = ipaserver.dcerpc.TrustDomainJoins(self.api)
+ if not trustinstance.configured:
+ raise errors.NotFound(
+ name=_('AD Trust setup'),
+ reason=_(
+ 'Cannot perform join operation without own domain '
+ 'configured. Make sure you have run ipa-adtrust-install '
+ 'on the IPA server first'
+ )
+ )
+ res = fetch_domains_from_trust(self.api, trustinstance, trust, **options)
+ domains = add_new_domains_from_trust(self.api, trustinstance, trust, res, **options)
+
+ if len(domains) > 0:
+ result['summary'] = unicode(_('List of trust domains successfully refreshed'))
+ else:
+ result['summary'] = unicode(_('No new trust domains were found'))
+
+ result['result'] = domains
+ result['count'] = len(domains)
+ return result
+
+
+@register()
+class trustdomain_enable(LDAPQuery):
+ __doc__ = _('Allow use of IPA resources by the domain of the trust')
+
+ has_output = output.standard_value
+ msg_summary = _('Enabled trust domain "%(value)s"')
+
+ def execute(self, *keys, **options):
+ ldap = self.api.Backend.ldap2
+ verify_samba_component_presence(ldap, self.api)
+
+ if keys[0].lower() == keys[1].lower():
+ raise errors.ValidationError(name='domain',
+ error=_("Root domain of the trust is always enabled for the existing trust"))
+ try:
+ trust_dn = self.obj.get_dn(keys[0], trust_type=u'ad')
+ trust_entry = ldap.get_entry(trust_dn)
+ except errors.NotFound:
+ self.api.Object[self.obj.parent_object].handle_not_found(keys[0])
+
+ dn = self.obj.get_dn(keys[0], keys[1], trust_type=u'ad')
+ try:
+ entry = ldap.get_entry(dn)
+ sid = entry['ipanttrusteddomainsid'][0]
+ if sid in trust_entry['ipantsidblacklistincoming']:
+ trust_entry['ipantsidblacklistincoming'].remove(sid)
+ ldap.update_entry(trust_entry)
+ # Force MS-PAC cache re-initialization on KDC side
+ domval = ipaserver.dcerpc.DomainValidator(self.api)
+ (ccache_name, principal) = domval.kinit_as_http(keys[0])
+ else:
+ raise errors.AlreadyActive()
+ except errors.NotFound:
+ self.obj.handle_not_found(*keys)
+
+ return dict(
+ result=True,
+ value=pkey_to_value(keys[1], options),
+ )
+
+
+@register()
+class trustdomain_disable(LDAPQuery):
+ __doc__ = _('Disable use of IPA resources by the domain of the trust')
+
+ has_output = output.standard_value
+ msg_summary = _('Disabled trust domain "%(value)s"')
+
+ def execute(self, *keys, **options):
+ ldap = self.api.Backend.ldap2
+ verify_samba_component_presence(ldap, self.api)
+
+ if keys[0].lower() == keys[1].lower():
+ raise errors.ValidationError(name='domain',
+ error=_("cannot disable root domain of the trust, use trust-del to delete the trust itself"))
+ try:
+ trust_dn = self.obj.get_dn(keys[0], trust_type=u'ad')
+ trust_entry = ldap.get_entry(trust_dn)
+ except errors.NotFound:
+ self.api.Object[self.obj.parent_object].handle_not_found(keys[0])
+
+ dn = self.obj.get_dn(keys[0], keys[1], trust_type=u'ad')
+ try:
+ entry = ldap.get_entry(dn)
+ sid = entry['ipanttrusteddomainsid'][0]
+ if not (sid in trust_entry['ipantsidblacklistincoming']):
+ trust_entry['ipantsidblacklistincoming'].append(sid)
+ ldap.update_entry(trust_entry)
+ # Force MS-PAC cache re-initialization on KDC side
+ domval = ipaserver.dcerpc.DomainValidator(self.api)
+ (ccache_name, principal) = domval.kinit_as_http(keys[0])
+ else:
+ raise errors.AlreadyInactive()
+ except errors.NotFound:
+ self.obj.handle_not_found(*keys)
+
+ return dict(
+ result=True,
+ value=pkey_to_value(keys[1], options),
+ )
+
diff --git a/ipaserver/plugins/user.py b/ipaserver/plugins/user.py
new file mode 100644
index 000000000..adc59fcba
--- /dev/null
+++ b/ipaserver/plugins/user.py
@@ -0,0 +1,1151 @@
+# Authors:
+# Jason Gerard DeRose <jderose@redhat.com>
+# Pavel Zuna <pzuna@redhat.com>
+#
+# Copyright (C) 2008 Red Hat
+# see file 'COPYING' for use and warranty information
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+import time
+from time import gmtime, strftime
+import posixpath
+import os
+
+import six
+
+from ipalib import api
+from ipalib import errors
+from ipalib import Bool, Flag, Str
+from .baseuser import (
+ baseuser,
+ baseuser_add,
+ baseuser_del,
+ baseuser_mod,
+ baseuser_find,
+ baseuser_show,
+ NO_UPG_MAGIC,
+ UPG_DEFINITION_DN,
+ baseuser_output_params,
+ status_baseuser_output_params,
+ baseuser_pwdchars,
+ validate_nsaccountlock,
+ convert_nsaccountlock,
+ fix_addressbook_permission_bindrule,
+ baseuser_add_manager,
+ baseuser_remove_manager)
+from .idviews import remove_ipaobject_overrides
+from ipalib.plugable import Registry
+from .baseldap import (
+ pkey_to_value,
+ LDAPCreate,
+ LDAPSearch,
+ LDAPQuery,
+ LDAPMultiQuery,
+ LDAPAddAttribute,
+ LDAPRemoveAttribute)
+from . import baseldap
+from ipalib.request import context
+from ipalib import _, ngettext
+from ipalib import output
+from ipaplatform.paths import paths
+from ipapython.dn import DN
+from ipapython.ipautil import ipa_generate_password
+from ipalib.capabilities import client_has_capability
+
+if api.env.in_server:
+ from ipaserver.plugins.ldap2 import ldap2
+
+if six.PY3:
+ unicode = str
+
+__doc__ = _("""
+Users
+
+Manage user entries. All users are POSIX users.
+
+IPA supports a wide range of username formats, but you need to be aware of any
+restrictions that may apply to your particular environment. For example,
+usernames that start with a digit or usernames that exceed a certain length
+may cause problems for some UNIX systems.
+Use 'ipa config-mod' to change the username format allowed by IPA tools.
+
+Disabling a user account prevents that user from obtaining new Kerberos
+credentials. It does not invalidate any credentials that have already
+been issued.
+
+Password management is not a part of this module. For more information
+about this topic please see: ipa help passwd
+
+Account lockout on password failure happens per IPA master. The user-status
+command can be used to identify which master the user is locked out on.
+It is on that master the administrator must unlock the user.
+
+EXAMPLES:
+
+ Add a new user:
+ ipa user-add --first=Tim --last=User --password tuser1
+
+ Find all users whose entries include the string "Tim":
+ ipa user-find Tim
+
+ Find all users with "Tim" as the first name:
+ ipa user-find --first=Tim
+
+ Disable a user account:
+ ipa user-disable tuser1
+
+ Enable a user account:
+ ipa user-enable tuser1
+
+ Delete a user:
+ ipa user-del tuser1
+""")
+
+register = Registry()
+
+
+user_output_params = baseuser_output_params
+
+status_output_params = status_baseuser_output_params
+
+
+def check_protected_member(user, protected_group_name=u'admins'):
+ '''
+ Ensure the last enabled member of a protected group cannot be deleted or
+ disabled by raising LastMemberError.
+ '''
+
+ # Get all users in the protected group
+ result = api.Command.user_find(in_group=protected_group_name)
+
+ # Build list of users in the protected group who are enabled
+ result = result['result']
+ enabled_users = [entry['uid'][0] for entry in result if not entry['nsaccountlock']]
+
+ # If the user is the last enabled user raise LastMemberError exception
+ if enabled_users == [user]:
+ raise errors.LastMemberError(key=user, label=_(u'group'),
+ container=protected_group_name)
+
+@register()
+class user(baseuser):
+ """
+ User object.
+ """
+
+ container_dn = baseuser.active_container_dn
+ label = _('Users')
+ label_singular = _('User')
+ object_name = _('user')
+ object_name_plural = _('users')
+ managed_permissions = {
+ 'System: Read User Standard Attributes': {
+ 'replaces_global_anonymous_aci': True,
+ 'ipapermbindruletype': 'anonymous',
+ 'ipapermright': {'read', 'search', 'compare'},
+ 'ipapermdefaultattr': {
+ 'objectclass', 'cn', 'sn', 'description', 'title', 'uid',
+ 'displayname', 'givenname', 'initials', 'manager', 'gecos',
+ 'gidnumber', 'homedirectory', 'loginshell', 'uidnumber',
+ 'ipantsecurityidentifier'
+ },
+ },
+ 'System: Read User Addressbook Attributes': {
+ 'replaces_global_anonymous_aci': True,
+ 'ipapermbindruletype': 'all',
+ 'ipapermright': {'read', 'search', 'compare'},
+ 'ipapermdefaultattr': {
+ 'seealso', 'telephonenumber',
+ 'facsimiletelephonenumber', 'l', 'ou', 'st', 'postalcode', 'street',
+ 'destinationindicator', 'internationalisdnnumber',
+ 'physicaldeliveryofficename', 'postaladdress', 'postofficebox',
+ 'preferreddeliverymethod', 'registeredaddress',
+ 'teletexterminalidentifier', 'telexnumber', 'x121address',
+ 'carlicense', 'departmentnumber', 'employeenumber',
+ 'employeetype', 'preferredlanguage', 'mail', 'mobile', 'pager',
+ 'audio', 'businesscategory', 'homephone', 'homepostaladdress',
+ 'jpegphoto', 'labeleduri', 'o', 'photo', 'roomnumber',
+ 'secretary', 'usercertificate',
+ 'usersmimecertificate', 'x500uniqueidentifier',
+ 'inetuserhttpurl', 'inetuserstatus',
+ },
+ 'fixup_function': fix_addressbook_permission_bindrule,
+ },
+ 'System: Read User IPA Attributes': {
+ 'replaces_global_anonymous_aci': True,
+ 'ipapermbindruletype': 'all',
+ 'ipapermright': {'read', 'search', 'compare'},
+ 'ipapermdefaultattr': {
+ 'ipauniqueid', 'ipasshpubkey', 'ipauserauthtype', 'userclass',
+ },
+ 'fixup_function': fix_addressbook_permission_bindrule,
+ },
+ 'System: Read User Kerberos Attributes': {
+ 'replaces_global_anonymous_aci': True,
+ 'ipapermbindruletype': 'all',
+ 'ipapermright': {'read', 'search', 'compare'},
+ 'ipapermdefaultattr': {
+ 'krbprincipalname', 'krbcanonicalname', 'krbprincipalaliases',
+ 'krbprincipalexpiration', 'krbpasswordexpiration',
+ 'krblastpwdchange', 'nsaccountlock', 'krbprincipaltype',
+ },
+ },
+ 'System: Read User Kerberos Login Attributes': {
+ 'replaces_global_anonymous_aci': True,
+ 'ipapermright': {'read', 'search', 'compare'},
+ 'ipapermdefaultattr': {
+ 'krblastsuccessfulauth', 'krblastfailedauth',
+ 'krblastpwdchange', 'krblastadminunlock',
+ 'krbloginfailedcount', 'krbpwdpolicyreference',
+ 'krbticketpolicyreference', 'krbupenabled',
+ },
+ 'default_privileges': {'User Administrators'},
+ },
+ 'System: Read User Membership': {
+ 'replaces_global_anonymous_aci': True,
+ 'ipapermbindruletype': 'all',
+ 'ipapermright': {'read', 'search', 'compare'},
+ 'ipapermdefaultattr': {
+ 'memberof',
+ },
+ },
+ 'System: Read UPG Definition': {
+ # Required for adding users
+ 'replaces_global_anonymous_aci': True,
+ 'non_object': True,
+ 'ipapermlocation': UPG_DEFINITION_DN,
+ 'ipapermtarget': UPG_DEFINITION_DN,
+ 'ipapermright': {'read', 'search', 'compare'},
+ 'ipapermdefaultattr': {'*'},
+ 'default_privileges': {'User Administrators'},
+ },
+ 'System: Add Users': {
+ 'ipapermright': {'add'},
+ 'replaces': [
+ '(target = "ldap:///uid=*,cn=users,cn=accounts,$SUFFIX")(version 3.0;acl "permission:Add Users";allow (add) groupdn = "ldap:///cn=Add Users,cn=permissions,cn=pbac,$SUFFIX";)',
+ ],
+ 'default_privileges': {'User Administrators'},
+ },
+ 'System: Add User to default group': {
+ 'non_object': True,
+ 'ipapermright': {'write'},
+ 'ipapermlocation': DN(api.env.container_group, api.env.basedn),
+ 'ipapermtarget': DN('cn=ipausers', api.env.container_group,
+ api.env.basedn),
+ 'ipapermdefaultattr': {'member'},
+ 'replaces': [
+ '(targetattr = "member")(target = "ldap:///cn=ipausers,cn=groups,cn=accounts,$SUFFIX")(version 3.0;acl "permission:Add user to default group";allow (write) groupdn = "ldap:///cn=Add user to default group,cn=permissions,cn=pbac,$SUFFIX";)',
+ ],
+ 'default_privileges': {'User Administrators'},
+ },
+ 'System: Change User password': {
+ 'ipapermright': {'write'},
+ 'ipapermtargetfilter': [
+ '(objectclass=posixaccount)',
+ '(!(memberOf=%s))' % DN('cn=admins',
+ api.env.container_group,
+ api.env.basedn),
+ ],
+ 'ipapermdefaultattr': {
+ 'krbprincipalkey', 'passwordhistory', 'sambalmpassword',
+ 'sambantpassword', 'userpassword'
+ },
+ 'replaces': [
+ '(target = "ldap:///uid=*,cn=users,cn=accounts,$SUFFIX")(targetattr = "userpassword || krbprincipalkey || sambalmpassword || sambantpassword || passwordhistory")(version 3.0;acl "permission:Change a user password";allow (write) groupdn = "ldap:///cn=Change a user password,cn=permissions,cn=pbac,$SUFFIX";)',
+ '(targetfilter = "(!(memberOf=cn=admins,cn=groups,cn=accounts,$SUFFIX))")(target = "ldap:///uid=*,cn=users,cn=accounts,$SUFFIX")(targetattr = "userpassword || krbprincipalkey || sambalmpassword || sambantpassword || passwordhistory")(version 3.0;acl "permission:Change a user password";allow (write) groupdn = "ldap:///cn=Change a user password,cn=permissions,cn=pbac,$SUFFIX";)',
+ '(targetattr = "userPassword || krbPrincipalKey || sambaLMPassword || sambaNTPassword || passwordHistory")(version 3.0; acl "Windows PassSync service can write passwords"; allow (write) userdn="ldap:///uid=passsync,cn=sysaccounts,cn=etc,$SUFFIX";)',
+ ],
+ 'default_privileges': {
+ 'User Administrators',
+ 'Modify Users and Reset passwords',
+ 'PassSync Service',
+ },
+ },
+ 'System: Manage User SSH Public Keys': {
+ 'ipapermright': {'write'},
+ 'ipapermdefaultattr': {'ipasshpubkey'},
+ 'replaces': [
+ '(targetattr = "ipasshpubkey")(target = "ldap:///uid=*,cn=users,cn=accounts,$SUFFIX")(version 3.0;acl "permission:Manage User SSH Public Keys";allow (write) groupdn = "ldap:///cn=Manage User SSH Public Keys,cn=permissions,cn=pbac,$SUFFIX";)',
+ ],
+ 'default_privileges': {'User Administrators'},
+ },
+ 'System: Manage User Certificates': {
+ 'ipapermright': {'write'},
+ 'ipapermdefaultattr': {'usercertificate'},
+ 'default_privileges': {
+ 'User Administrators',
+ 'Modify Users and Reset passwords',
+ },
+ },
+ 'System: Modify Users': {
+ 'ipapermright': {'write'},
+ 'ipapermdefaultattr': {
+ 'businesscategory', 'carlicense', 'cn', 'departmentnumber',
+ 'description', 'displayname', 'employeetype',
+ 'employeenumber', 'facsimiletelephonenumber',
+ 'gecos', 'givenname', 'homephone', 'inetuserhttpurl',
+ 'initials', 'l', 'labeleduri', 'loginshell', 'manager', 'mail',
+ 'mepmanagedentry', 'mobile', 'objectclass', 'ou', 'pager',
+ 'postalcode', 'roomnumber', 'secretary', 'seealso', 'sn', 'st',
+ 'street', 'telephonenumber', 'title', 'userclass',
+ 'preferredlanguage',
+ },
+ 'replaces': [
+ '(targetattr = "givenname || sn || cn || displayname || title || initials || loginshell || gecos || homephone || mobile || pager || facsimiletelephonenumber || telephonenumber || street || roomnumber || l || st || postalcode || manager || secretary || description || carlicense || labeleduri || inetuserhttpurl || seealso || employeetype || businesscategory || ou || mepmanagedentry || objectclass")(target = "ldap:///uid=*,cn=users,cn=accounts,$SUFFIX")(version 3.0;acl "permission:Modify Users";allow (write) groupdn = "ldap:///cn=Modify Users,cn=permissions,cn=pbac,$SUFFIX";)',
+ ],
+ 'default_privileges': {
+ 'User Administrators',
+ 'Modify Users and Reset passwords',
+ },
+ },
+ 'System: Remove Users': {
+ 'ipapermright': {'delete'},
+ 'replaces': [
+ '(target = "ldap:///uid=*,cn=users,cn=accounts,$SUFFIX")(version 3.0;acl "permission:Remove Users";allow (delete) groupdn = "ldap:///cn=Remove Users,cn=permissions,cn=pbac,$SUFFIX";)',
+ ],
+ 'default_privileges': {'User Administrators'},
+ },
+ 'System: Unlock User': {
+ 'ipapermright': {'write'},
+ 'ipapermdefaultattr': {
+ 'krblastadminunlock', 'krbloginfailedcount', 'nsaccountlock',
+ },
+ 'replaces': [
+ '(targetattr = "krbLastAdminUnlock || krbLoginFailedCount")(target = "ldap:///uid=*,cn=users,cn=accounts,$SUFFIX")(version 3.0;acl "permission:Unlock user accounts";allow (write) groupdn = "ldap:///cn=Unlock user accounts,cn=permissions,cn=pbac,$SUFFIX";)',
+ ],
+ 'default_privileges': {'User Administrators'},
+ },
+ 'System: Read User Compat Tree': {
+ 'non_object': True,
+ 'ipapermbindruletype': 'anonymous',
+ 'ipapermlocation': api.env.basedn,
+ 'ipapermtarget': DN('cn=users', 'cn=compat', api.env.basedn),
+ 'ipapermright': {'read', 'search', 'compare'},
+ 'ipapermdefaultattr': {
+ 'objectclass', 'uid', 'cn', 'gecos', 'gidnumber', 'uidnumber',
+ 'homedirectory', 'loginshell',
+ },
+ },
+ 'System: Read User Views Compat Tree': {
+ 'non_object': True,
+ 'ipapermbindruletype': 'anonymous',
+ 'ipapermlocation': api.env.basedn,
+ 'ipapermtarget': DN('cn=users', 'cn=*', 'cn=views', 'cn=compat', api.env.basedn),
+ 'ipapermright': {'read', 'search', 'compare'},
+ 'ipapermdefaultattr': {
+ 'objectclass', 'uid', 'cn', 'gecos', 'gidnumber', 'uidnumber',
+ 'homedirectory', 'loginshell',
+ },
+ },
+ 'System: Read User NT Attributes': {
+ 'ipapermbindruletype': 'permission',
+ 'ipapermright': {'read', 'search', 'compare'},
+ 'ipapermdefaultattr': {
+ 'ntuserdomainid', 'ntuniqueid', 'ntuseracctexpires',
+ 'ntusercodepage', 'ntuserdeleteaccount', 'ntuserlastlogoff',
+ 'ntuserlastlogon',
+ },
+ 'default_privileges': {'PassSync Service'},
+ },
+ }
+
+ takes_params = baseuser.takes_params + (
+ Bool('nsaccountlock?',
+ label=_('Account disabled'),
+ flags=['no_option'],
+ ),
+ Bool('preserved?',
+ label=_('Preserved user'),
+ default=False,
+ flags=['virtual_attribute', 'no_create', 'no_update'],
+ ),
+ )
+
+ def get_either_dn(self, *keys, **options):
+ '''
+ Returns the DN of a user
+ The user can be active (active container) or delete (delete container)
+ If the user does not exist, returns the Active user DN
+ '''
+ ldap = self.backend
+ # Check that this value is a Active user
+ try:
+ active_dn = self.get_dn(*keys, **options)
+ ldap.get_entry(active_dn, ['dn'])
+
+ # The Active user exists
+ dn = active_dn
+ except errors.NotFound:
+ # Check that this value is a Delete user
+ delete_dn = DN(active_dn[0], self.delete_container_dn, api.env.basedn)
+ try:
+ ldap.get_entry(delete_dn, ['dn'])
+
+ # The Delete user exists
+ dn = delete_dn
+ except errors.NotFound:
+ # The user is neither Active/Delete -> returns that Active DN
+ dn = active_dn
+
+ return dn
+
+ def _normalize_manager(self, manager):
+ """
+ Given a userid verify the user's existence and return the dn.
+ """
+ return super(user, self).normalize_manager(manager, self.active_container_dn)
+
+ def get_preserved_attribute(self, entry, options):
+ if options.get('raw', False):
+ return
+ delete_container_dn = DN(self.delete_container_dn, api.env.basedn)
+ if entry.dn.endswith(delete_container_dn):
+ entry['preserved'] = True
+ elif options.get('all', False):
+ entry['preserved'] = False
+
+
+@register()
+class user_add(baseuser_add):
+ __doc__ = _('Add a new user.')
+
+ msg_summary = _('Added user "%(value)s"')
+
+ has_output_params = baseuser_add.has_output_params + user_output_params
+
+ takes_options = LDAPCreate.takes_options + (
+ Flag('noprivate',
+ cli_name='noprivate',
+ doc=_('Don\'t create user private group'),
+ ),
+ )
+
+ def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options):
+ dn = self.obj.get_either_dn(*keys, **options)
+ if not options.get('noprivate', False):
+ try:
+ # The Managed Entries plugin will allow a user to be created
+ # even if a group has a duplicate name. This would leave a user
+ # without a private group. Check for both the group and the user.
+ self.api.Object['group'].get_dn_if_exists(keys[-1])
+ try:
+ self.api.Command['user_show'](keys[-1])
+ self.obj.handle_duplicate_entry(*keys)
+ except errors.NotFound:
+ raise errors.ManagedGroupExistsError(group=keys[-1])
+ except errors.NotFound:
+ pass
+ else:
+ # we don't want an user private group to be created for this user
+ # add NO_UPG_MAGIC description attribute to let the DS plugin know
+ entry_attrs.setdefault('description', [])
+ entry_attrs['description'].append(NO_UPG_MAGIC)
+
+ entry_attrs.setdefault('uidnumber', baseldap.DNA_MAGIC)
+
+ if not client_has_capability(
+ options['version'], 'optional_uid_params'):
+ # https://fedorahosted.org/freeipa/ticket/2886
+ # Old clients say 999 (OLD_DNA_MAGIC) when they really mean
+ # "assign a value dynamically".
+ OLD_DNA_MAGIC = 999
+ if entry_attrs.get('uidnumber') == OLD_DNA_MAGIC:
+ entry_attrs['uidnumber'] = baseldap.DNA_MAGIC
+ if entry_attrs.get('gidnumber') == OLD_DNA_MAGIC:
+ entry_attrs['gidnumber'] = baseldap.DNA_MAGIC
+
+ validate_nsaccountlock(entry_attrs)
+ config = ldap.get_ipa_config()
+ if 'ipamaxusernamelength' in config:
+ if len(keys[-1]) > int(config.get('ipamaxusernamelength')[0]):
+ raise errors.ValidationError(
+ name=self.obj.primary_key.cli_name,
+ error=_('can be at most %(len)d characters') % dict(
+ len = int(config.get('ipamaxusernamelength')[0])
+ )
+ )
+ default_shell = config.get('ipadefaultloginshell', [paths.SH])[0]
+ entry_attrs.setdefault('loginshell', default_shell)
+ # hack so we can request separate first and last name in CLI
+ full_name = '%s %s' % (entry_attrs['givenname'], entry_attrs['sn'])
+ entry_attrs.setdefault('cn', full_name)
+ if 'homedirectory' not in entry_attrs:
+ # get home's root directory from config
+ homes_root = config.get('ipahomesrootdir', [paths.HOME_DIR])[0]
+ # build user's home directory based on his uid
+ entry_attrs['homedirectory'] = posixpath.join(homes_root, keys[-1])
+ entry_attrs.setdefault('krbprincipalname', '%s@%s' % (entry_attrs['uid'], api.env.realm))
+
+ if entry_attrs.get('gidnumber') is None:
+ # gidNumber wasn't specified explicity, find out what it should be
+ if not options.get('noprivate', False) and ldap.has_upg():
+ # User Private Groups - uidNumber == gidNumber
+ entry_attrs['gidnumber'] = entry_attrs['uidnumber']
+ else:
+ # we're adding new users to a default group, get its gidNumber
+ # get default group name from config
+ def_primary_group = config.get('ipadefaultprimarygroup')
+ group_dn = self.api.Object['group'].get_dn(def_primary_group)
+ try:
+ group_attrs = ldap.get_entry(group_dn, ['gidnumber'])
+ except errors.NotFound:
+ error_msg = _('Default group for new users not found')
+ raise errors.NotFound(reason=error_msg)
+ if 'gidnumber' not in group_attrs:
+ error_msg = _('Default group for new users is not POSIX')
+ raise errors.NotFound(reason=error_msg)
+ entry_attrs['gidnumber'] = group_attrs['gidnumber']
+
+ if 'userpassword' not in entry_attrs and options.get('random'):
+ entry_attrs['userpassword'] = ipa_generate_password(baseuser_pwdchars)
+ # save the password so it can be displayed in post_callback
+ setattr(context, 'randompassword', entry_attrs['userpassword'])
+
+ if 'mail' in entry_attrs:
+ entry_attrs['mail'] = self.obj.normalize_and_validate_email(entry_attrs['mail'], config)
+ else:
+ # No e-mail passed in. If we have a default e-mail domain set
+ # then we'll add it automatically.
+ defaultdomain = config.get('ipadefaultemaildomain', [None])[0]
+ if defaultdomain:
+ entry_attrs['mail'] = self.obj.normalize_and_validate_email(keys[-1], config)
+
+ if 'manager' in entry_attrs:
+ entry_attrs['manager'] = self.obj.normalize_manager(entry_attrs['manager'], self.obj.active_container_dn)
+
+ if 'userclass' in entry_attrs and \
+ 'ipauser' not in entry_attrs['objectclass']:
+ entry_attrs['objectclass'].append('ipauser')
+
+ if 'ipauserauthtype' in entry_attrs and \
+ 'ipauserauthtypeclass' not in entry_attrs['objectclass']:
+ entry_attrs['objectclass'].append('ipauserauthtypeclass')
+
+ rcl = entry_attrs.get('ipatokenradiusconfiglink', None)
+ if rcl:
+ if 'ipatokenradiusproxyuser' not in entry_attrs['objectclass']:
+ entry_attrs['objectclass'].append('ipatokenradiusproxyuser')
+
+ answer = self.api.Object['radiusproxy'].get_dn_if_exists(rcl)
+ entry_attrs['ipatokenradiusconfiglink'] = answer
+
+ self.pre_common_callback(ldap, dn, entry_attrs, attrs_list, *keys,
+ **options)
+
+ return dn
+
+ def post_callback(self, ldap, dn, entry_attrs, *keys, **options):
+ assert isinstance(dn, DN)
+ config = ldap.get_ipa_config()
+ # add the user we just created into the default primary group
+ def_primary_group = config.get('ipadefaultprimarygroup')
+ group_dn = self.api.Object['group'].get_dn(def_primary_group)
+
+ # if the user is already a member of default primary group,
+ # do not raise error
+ # this can happen if automember rule or default group is set
+ try:
+ ldap.add_entry_to_group(dn, group_dn)
+ except errors.AlreadyGroupMember:
+ pass
+
+ # delete description attribute NO_UPG_MAGIC if present
+ if options.get('noprivate', False):
+ if not options.get('all', False):
+ desc_attr = ldap.get_entry(dn, ['description'])
+ entry_attrs.update(desc_attr)
+ if 'description' in entry_attrs and NO_UPG_MAGIC in entry_attrs['description']:
+ entry_attrs['description'].remove(NO_UPG_MAGIC)
+ kw = {'setattr': unicode('description=%s' % ','.join(entry_attrs['description']))}
+ try:
+ self.api.Command['user_mod'](keys[-1], **kw)
+ except (errors.EmptyModlist, errors.NotFound):
+ pass
+
+ # Fetch the entry again to update memberof, mep data, etc updated
+ # at the end of the transaction.
+ newentry = ldap.get_entry(dn, ['*'])
+ entry_attrs.update(newentry)
+
+ if options.get('random', False):
+ try:
+ entry_attrs['randompassword'] = unicode(getattr(context, 'randompassword'))
+ except AttributeError:
+ # if both randompassword and userpassword options were used
+ pass
+
+ self.obj.get_preserved_attribute(entry_attrs, options)
+
+ self.post_common_callback(ldap, dn, entry_attrs, *keys, **options)
+
+ return dn
+
+
+@register()
+class user_del(baseuser_del):
+ __doc__ = _('Delete a user.')
+
+ msg_summary = _('Deleted user "%(value)s"')
+
+ takes_options = baseuser_del.takes_options + (
+ Bool('preserve?',
+ exclude='cli',
+ ),
+ )
+
+ def _preserve_user(self, pkey, delete_container, **options):
+ assert isinstance(delete_container, DN)
+
+ dn = self.obj.get_either_dn(pkey, **options)
+ delete_dn = DN(dn[0], delete_container)
+ ldap = self.obj.backend
+ self.log.debug("preserve move %s -> %s" % (dn, delete_dn))
+
+ if dn.endswith(delete_container):
+ raise errors.ExecutionError(
+ _('%s: user is already preserved' % pkey)
+ )
+ # Check that this value is a Active user
+ try:
+ original_entry_attrs = self._exc_wrapper(
+ pkey, options, ldap.get_entry)(dn, ['dn'])
+ except errors.NotFound:
+ self.obj.handle_not_found(pkey)
+
+ for callback in self.get_callbacks('pre'):
+ dn = callback(self, ldap, dn, pkey, **options)
+ assert isinstance(dn, DN)
+
+ # start to move the entry to Delete container
+ self._exc_wrapper(pkey, options, ldap.move_entry)(dn, delete_dn,
+ del_old=True)
+
+ # Then clear the credential attributes
+ attrs_to_clear = ['krbPrincipalKey', 'krbLastPwdChange',
+ 'krbPasswordExpiration', 'userPassword']
+
+ entry_attrs = self._exc_wrapper(pkey, options, ldap.get_entry)(
+ delete_dn, attrs_to_clear)
+
+ clearedCredential = False
+ for attr in attrs_to_clear:
+ if attr.lower() in entry_attrs:
+ del entry_attrs[attr]
+ clearedCredential = True
+ if clearedCredential:
+ self._exc_wrapper(pkey, options, ldap.update_entry)(entry_attrs)
+
+ # Then restore some original entry attributes
+ attrs_to_restore = ['secretary', 'managedby', 'manager', 'ipauniqueid',
+ 'uidnumber', 'gidnumber', 'passwordHistory']
+
+ entry_attrs = self._exc_wrapper(
+ pkey, options, ldap.get_entry)(delete_dn, attrs_to_restore)
+
+ restoreAttr = False
+ for attr in attrs_to_restore:
+ if ((attr.lower() in original_entry_attrs) and
+ not (attr.lower() in entry_attrs)):
+ restoreAttr = True
+ entry_attrs[attr.lower()] = original_entry_attrs[attr.lower()]
+ if restoreAttr:
+ self._exc_wrapper(pkey, options, ldap.update_entry)(entry_attrs)
+
+ def pre_callback(self, ldap, dn, *keys, **options):
+ dn = self.obj.get_either_dn(*keys, **options)
+
+ # For User life Cycle: user-del is a common plugin
+ # command to delete active user (active container) and
+ # delete user (delete container).
+ # If the target entry is a Delete entry, skip the orphaning/removal
+ # of OTP tokens.
+ check_protected_member(keys[-1])
+
+ if not options.get('preserve', False):
+ # Remove any ID overrides tied with this user
+ try:
+ remove_ipaobject_overrides(self.obj.backend, self.obj.api, dn)
+ except errors.NotFound:
+ self.obj.handle_not_found(*keys)
+
+ if dn.endswith(DN(self.obj.delete_container_dn, api.env.basedn)):
+ return dn
+
+ # Delete all tokens owned and managed by this user.
+ # Orphan all tokens owned but not managed by this user.
+ owner = self.api.Object.user.get_primary_key_from_dn(dn)
+ results = self.api.Command.otptoken_find(
+ ipatokenowner=owner, no_members=False)['result']
+ for token in results:
+ orphan = not [x for x in token.get('managedby_user', []) if x == owner]
+ token = self.api.Object.otptoken.get_primary_key_from_dn(token['dn'])
+ if orphan:
+ self.api.Command.otptoken_mod(token, ipatokenowner=None)
+ else:
+ self.api.Command.otptoken_del(token)
+
+ return dn
+
+ def execute(self, *keys, **options):
+
+ # We are going to permanent delete or the user is already in the delete container.
+ delete_container = DN(self.obj.delete_container_dn, self.api.env.basedn)
+
+ # The user to delete is active and there is no 'no_preserve' option
+ if options.get('preserve', False):
+ failed = []
+ preserved = []
+ for pkey in keys[-1]:
+ try:
+ self._preserve_user(pkey, delete_container, **options)
+ preserved.append(pkey_to_value(pkey, options))
+ except Exception:
+ if not options.get('continue', False):
+ raise
+ failed.append(pkey_to_value(pkey, options))
+
+ val = dict(result=dict(failed=failed), value=preserved)
+ return val
+ else:
+ return super(user_del, self).execute(*keys, **options)
+
+
+@register()
+class user_mod(baseuser_mod):
+ __doc__ = _('Modify a user.')
+
+ msg_summary = _('Modified user "%(value)s"')
+
+ has_output_params = baseuser_mod.has_output_params + user_output_params
+
+ def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options):
+ dn = self.obj.get_either_dn(*keys, **options)
+ self.pre_common_callback(ldap, dn, entry_attrs, attrs_list, *keys,
+ **options)
+ validate_nsaccountlock(entry_attrs)
+ return dn
+
+ def post_callback(self, ldap, dn, entry_attrs, *keys, **options):
+ self.post_common_callback(ldap, dn, entry_attrs, *keys, **options)
+ self.obj.get_preserved_attribute(entry_attrs, options)
+ return dn
+
+
+@register()
+class user_find(baseuser_find):
+ __doc__ = _('Search for users.')
+
+ member_attributes = ['memberof']
+ has_output_params = baseuser_find.has_output_params + user_output_params
+
+ msg_summary = ngettext(
+ '%(count)d user matched', '%(count)d users matched', 0
+ )
+
+ takes_options = LDAPSearch.takes_options + (
+ Flag('whoami',
+ label=_('Self'),
+ doc=_('Display user record for current Kerberos principal'),
+ ),
+ )
+
+ def pre_callback(self, ldap, filter, attrs_list, base_dn, scope, *keys, **options):
+ assert isinstance(base_dn, DN)
+ self.pre_common_callback(ldap, filter, attrs_list, base_dn, scope,
+ *keys, **options)
+
+ if options.get('whoami'):
+ return ("(&(objectclass=posixaccount)(krbprincipalname=%s))"%\
+ getattr(context, 'principal'), base_dn, scope)
+
+ preserved = options.get('preserved', False)
+ if preserved is None:
+ base_dn = self.api.env.basedn
+ scope = ldap.SCOPE_SUBTREE
+ elif preserved:
+ base_dn = DN(self.obj.delete_container_dn, self.api.env.basedn)
+ else:
+ base_dn = DN(self.obj.active_container_dn, self.api.env.basedn)
+
+ return (filter, base_dn, scope)
+
+ def post_callback(self, ldap, entries, truncated, *args, **options):
+ if options.get('pkey_only', False):
+ return truncated
+
+ if options.get('preserved', False) is None:
+ base_dns = (
+ DN(self.obj.active_container_dn, self.api.env.basedn),
+ DN(self.obj.delete_container_dn, self.api.env.basedn),
+ )
+ entries[:] = [e for e in entries
+ if any(e.dn.endswith(bd) for bd in base_dns)]
+
+ self.post_common_callback(ldap, entries, lockout=False, **options)
+ for entry in entries:
+ self.obj.get_preserved_attribute(entry, options)
+
+ return truncated
+
+
+@register()
+class user_show(baseuser_show):
+ __doc__ = _('Display information about a user.')
+
+ has_output_params = baseuser_show.has_output_params + user_output_params
+ takes_options = baseuser_show.takes_options + (
+ Str('out?',
+ doc=_('file to store certificate in'),
+ ),
+ )
+
+ def pre_callback(self, ldap, dn, attrs_list, *keys, **options):
+ dn = self.obj.get_either_dn(*keys, **options)
+ self.pre_common_callback(ldap, dn, attrs_list, *keys, **options)
+ return dn
+
+ def post_callback(self, ldap, dn, entry_attrs, *keys, **options):
+ convert_nsaccountlock(entry_attrs)
+ self.post_common_callback(ldap, dn, entry_attrs, *keys, **options)
+ self.obj.get_preserved_attribute(entry_attrs, options)
+ return dn
+
+
+@register()
+class user_undel(LDAPQuery):
+ __doc__ = _('Undelete a delete user account.')
+
+ has_output = output.standard_value
+ msg_summary = _('Undeleted user account "%(value)s"')
+
+ def execute(self, *keys, **options):
+ ldap = self.obj.backend
+
+ # First check that the user exists and is a delete one
+ delete_dn = self.obj.get_either_dn(*keys, **options)
+ try:
+ entry_attrs = self._exc_wrapper(keys, options, ldap.get_entry)(delete_dn)
+ except errors.NotFound:
+ self.obj.handle_not_found(*keys)
+ if delete_dn.endswith(DN(self.obj.active_container_dn,
+ api.env.basedn)):
+ raise errors.InvocationError(
+ message=_('user "%s" is already active') % keys[-1])
+
+ active_dn = DN(delete_dn[0], self.obj.active_container_dn, api.env.basedn)
+
+ # start to move the entry to the Active container
+ self._exc_wrapper(keys, options, ldap.move_entry)(delete_dn, active_dn, del_old=True)
+
+ # add the user we just undelete into the default primary group
+ config = ldap.get_ipa_config()
+ def_primary_group = config.get('ipadefaultprimarygroup')
+ group_dn = self.api.Object['group'].get_dn(def_primary_group)
+
+ # if the user is already a member of default primary group,
+ # do not raise error
+ # this can happen if automember rule or default group is set
+ try:
+ ldap.add_entry_to_group(active_dn, group_dn)
+ except errors.AlreadyGroupMember:
+ pass
+
+ return dict(
+ result=True,
+ value=pkey_to_value(keys[0], options),
+ )
+
+
+@register()
+class user_stage(LDAPMultiQuery):
+ __doc__ = _('Move deleted user into staged area')
+
+ has_output = output.standard_multi_delete
+ msg_summary = _('Staged user account "%(value)s"')
+
+ def execute(self, *keys, **options):
+ staged = []
+ failed = []
+
+ for key in keys[-1]:
+ single_keys = keys[:-1] + (key,)
+ multi_keys = keys[:-1] + ((key,),)
+
+ user = self.api.Command.user_show(*single_keys, all=True)['result']
+ new_options = {}
+ for param in self.api.Command.stageuser_add.options():
+ try:
+ value = user[param.name]
+ except KeyError:
+ continue
+ if param.multivalue and not isinstance(value, (list, tuple)):
+ value = [value]
+ elif not param.multivalue and isinstance(value, (list, tuple)):
+ value = value[0]
+ new_options[param.name] = value
+
+ try:
+ self.api.Command.stageuser_add(*single_keys, **new_options)
+ try:
+ self.api.Command.user_del(*multi_keys, preserve=False)
+ except errors.ExecutionError:
+ self.api.Command.stageuser_del(*multi_keys)
+ raise
+ except errors.ExecutionError:
+ if not options['continue']:
+ raise
+ failed.append(key)
+ else:
+ staged.append(key)
+
+ return dict(
+ result=dict(
+ failed=pkey_to_value(failed, options),
+ ),
+ value=pkey_to_value(staged, options),
+ )
+
+
+@register()
+class user_disable(LDAPQuery):
+ __doc__ = _('Disable a user account.')
+
+ has_output = output.standard_value
+ msg_summary = _('Disabled user account "%(value)s"')
+
+ def execute(self, *keys, **options):
+ ldap = self.obj.backend
+
+ check_protected_member(keys[-1])
+
+ dn = self.obj.get_either_dn(*keys, **options)
+ ldap.deactivate_entry(dn)
+
+ return dict(
+ result=True,
+ value=pkey_to_value(keys[0], options),
+ )
+
+
+@register()
+class user_enable(LDAPQuery):
+ __doc__ = _('Enable a user account.')
+
+ has_output = output.standard_value
+ has_output_params = LDAPQuery.has_output_params + user_output_params
+ msg_summary = _('Enabled user account "%(value)s"')
+
+ def execute(self, *keys, **options):
+ ldap = self.obj.backend
+
+ dn = self.obj.get_either_dn(*keys, **options)
+
+ ldap.activate_entry(dn)
+
+ return dict(
+ result=True,
+ value=pkey_to_value(keys[0], options),
+ )
+
+
+@register()
+class user_unlock(LDAPQuery):
+ __doc__ = _("""
+ Unlock a user account
+
+ An account may become locked if the password is entered incorrectly too
+ many times within a specific time period as controlled by password
+ policy. A locked account is a temporary condition and may be unlocked by
+ an administrator.""")
+
+ has_output = output.standard_value
+ msg_summary = _('Unlocked account "%(value)s"')
+
+ def execute(self, *keys, **options):
+ dn = self.obj.get_either_dn(*keys, **options)
+ entry = self.obj.backend.get_entry(
+ dn, ['krbLastAdminUnlock', 'krbLoginFailedCount'])
+
+ entry['krbLastAdminUnlock'] = [strftime("%Y%m%d%H%M%SZ", gmtime())]
+ entry['krbLoginFailedCount'] = ['0']
+
+ self.obj.backend.update_entry(entry)
+
+ return dict(
+ result=True,
+ value=pkey_to_value(keys[0], options),
+ )
+
+
+@register()
+class user_status(LDAPQuery):
+ __doc__ = _("""
+ Lockout status of a user account
+
+ An account may become locked if the password is entered incorrectly too
+ many times within a specific time period as controlled by password
+ policy. A locked account is a temporary condition and may be unlocked by
+ an administrator.
+
+ This connects to each IPA master and displays the lockout status on
+ each one.
+
+ To determine whether an account is locked on a given server you need
+ to compare the number of failed logins and the time of the last failure.
+ For an account to be locked it must exceed the maxfail failures within
+ the failinterval duration as specified in the password policy associated
+ with the user.
+
+ The failed login counter is modified only when a user attempts a log in
+ so it is possible that an account may appear locked but the last failed
+ login attempt is older than the lockouttime of the password policy. This
+ means that the user may attempt a login again. """)
+
+ has_output = output.standard_list_of_entries
+ has_output_params = LDAPSearch.has_output_params + status_output_params
+
+ def execute(self, *keys, **options):
+ ldap = self.obj.backend
+ dn = self.obj.get_either_dn(*keys, **options)
+ attr_list = ['krbloginfailedcount', 'krblastsuccessfulauth', 'krblastfailedauth', 'nsaccountlock']
+
+ disabled = False
+ masters = []
+ # Get list of masters
+ try:
+ (masters, truncated) = ldap.find_entries(
+ None, ['*'], DN(('cn', 'masters'), ('cn', 'ipa'), ('cn', 'etc'), api.env.basedn),
+ ldap.SCOPE_ONELEVEL
+ )
+ except errors.NotFound:
+ # If this happens we have some pretty serious problems
+ self.error('No IPA masters found!')
+
+ entries = []
+ count = 0
+ for master in masters:
+ host = master['cn'][0]
+ if host == api.env.host:
+ other_ldap = self.obj.backend
+ else:
+ other_ldap = ldap2(self.api, ldap_uri='ldap://%s' % host)
+ try:
+ other_ldap.connect(ccache=os.environ['KRB5CCNAME'])
+ except Exception as e:
+ self.error("user_status: Connecting to %s failed with %s" % (host, str(e)))
+ newresult = {'dn': dn}
+ newresult['server'] = _("%(host)s failed: %(error)s") % dict(host=host, error=str(e))
+ entries.append(newresult)
+ count += 1
+ continue
+ try:
+ entry = other_ldap.get_entry(dn, attr_list)
+ newresult = {'dn': dn}
+ for attr in ['krblastsuccessfulauth', 'krblastfailedauth']:
+ newresult[attr] = entry.get(attr, [u'N/A'])
+ newresult['krbloginfailedcount'] = entry.get('krbloginfailedcount', u'0')
+ if not options.get('raw', False):
+ for attr in ['krblastsuccessfulauth', 'krblastfailedauth']:
+ try:
+ if newresult[attr][0] == u'N/A':
+ continue
+ newtime = time.strptime(newresult[attr][0], '%Y%m%d%H%M%SZ')
+ newresult[attr][0] = unicode(time.strftime('%Y-%m-%dT%H:%M:%SZ', newtime))
+ except Exception as e:
+ self.debug("time conversion failed with %s" % str(e))
+ newresult['server'] = host
+ if options.get('raw', False):
+ time_format = '%Y%m%d%H%M%SZ'
+ else:
+ time_format = '%Y-%m-%dT%H:%M:%SZ'
+ newresult['now'] = unicode(strftime(time_format, gmtime()))
+ convert_nsaccountlock(entry)
+ if 'nsaccountlock' in entry:
+ disabled = entry['nsaccountlock']
+ self.obj.get_preserved_attribute(entry, options)
+ entries.append(newresult)
+ count += 1
+ except errors.NotFound:
+ self.obj.handle_not_found(*keys)
+ except Exception as e:
+ self.error("user_status: Retrieving status for %s failed with %s" % (dn, str(e)))
+ newresult = {'dn': dn}
+ newresult['server'] = _("%(host)s failed") % dict(host=host)
+ entries.append(newresult)
+ count += 1
+
+ if host != api.env.host:
+ other_ldap.disconnect()
+
+ return dict(result=entries,
+ count=count,
+ truncated=False,
+ summary=unicode(_('Account disabled: %(disabled)s' %
+ dict(disabled=disabled))),
+ )
+
+
+@register()
+class user_add_cert(LDAPAddAttribute):
+ __doc__ = _('Add one or more certificates to the user entry')
+ msg_summary = _('Added certificates to user "%(value)s"')
+ attribute = 'usercertificate'
+
+ def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys,
+ **options):
+ dn = self.obj.get_either_dn(*keys, **options)
+
+ self.obj.convert_usercertificate_pre(entry_attrs)
+
+ return dn
+
+ def post_callback(self, ldap, dn, entry_attrs, *keys, **options):
+ assert isinstance(dn, DN)
+
+ self.obj.convert_usercertificate_post(entry_attrs, **options)
+
+ return dn
+
+
+@register()
+class user_remove_cert(LDAPRemoveAttribute):
+ __doc__ = _('Remove one or more certificates to the user entry')
+ msg_summary = _('Removed certificates from user "%(value)s"')
+ attribute = 'usercertificate'
+
+ def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys,
+ **options):
+ dn = self.obj.get_either_dn(*keys, **options)
+
+ self.obj.convert_usercertificate_pre(entry_attrs)
+
+ return dn
+
+ def post_callback(self, ldap, dn, entry_attrs, *keys, **options):
+ assert isinstance(dn, DN)
+
+ self.obj.convert_usercertificate_post(entry_attrs, **options)
+
+ return dn
+
+
+@register()
+class user_add_manager(baseuser_add_manager):
+ __doc__ = _("Add a manager to the user entry")
+
+
+@register()
+class user_remove_manager(baseuser_remove_manager):
+ __doc__ = _("Remove a manager to the user entry")
diff --git a/ipaserver/plugins/vault.py b/ipaserver/plugins/vault.py
new file mode 100644
index 000000000..05db63cdc
--- /dev/null
+++ b/ipaserver/plugins/vault.py
@@ -0,0 +1,1215 @@
+# Authors:
+# Endi S. Dewata <edewata@redhat.com>
+#
+# Copyright (C) 2015 Red Hat
+# see file 'COPYING' for use and warranty information
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+from ipalib.frontend import Command, Object
+from ipalib import api, errors
+from ipalib import Bytes, Flag, Str, StrEnum
+from ipalib import output
+from ipalib.crud import PKQuery, Retrieve
+from ipalib.plugable import Registry
+from .baseldap import LDAPObject, LDAPCreate, LDAPDelete,\
+ LDAPSearch, LDAPUpdate, LDAPRetrieve, LDAPAddMember, LDAPRemoveMember,\
+ LDAPModMember, pkey_to_value
+from ipalib.request import context
+from .baseuser import split_principal
+from .service import normalize_principal
+from ipalib import _, ngettext
+from ipapython.dn import DN
+
+if api.env.in_server:
+ import pki.account
+ import pki.key
+
+__doc__ = _("""
+Vaults
+""") + _("""
+Manage vaults.
+""") + _("""
+Vault is a secure place to store a secret.
+""") + _("""
+Based on the ownership there are three vault categories:
+* user/private vault
+* service vault
+* shared vault
+""") + _("""
+User vaults are vaults owned used by a particular user. Private
+vaults are vaults owned the current user. Service vaults are
+vaults owned by a service. Shared vaults are owned by the admin
+but they can be used by other users or services.
+""") + _("""
+Based on the security mechanism there are three types of
+vaults:
+* standard vault
+* symmetric vault
+* asymmetric vault
+""") + _("""
+Standard vault uses a secure mechanism to transport and
+store the secret. The secret can only be retrieved by users
+that have access to the vault.
+""") + _("""
+Symmetric vault is similar to the standard vault, but it
+pre-encrypts the secret using a password before transport.
+The secret can only be retrieved using the same password.
+""") + _("""
+Asymmetric vault is similar to the standard vault, but it
+pre-encrypts the secret using a public key before transport.
+The secret can only be retrieved using the private key.
+""") + _("""
+EXAMPLES:
+""") + _("""
+ List vaults:
+ ipa vault-find
+ [--user <user>|--service <service>|--shared]
+""") + _("""
+ Add a standard vault:
+ ipa vault-add <name>
+ [--user <user>|--service <service>|--shared]
+ --type standard
+""") + _("""
+ Add a symmetric vault:
+ ipa vault-add <name>
+ [--user <user>|--service <service>|--shared]
+ --type symmetric --password-file password.txt
+""") + _("""
+ Add an asymmetric vault:
+ ipa vault-add <name>
+ [--user <user>|--service <service>|--shared]
+ --type asymmetric --public-key-file public.pem
+""") + _("""
+ Show a vault:
+ ipa vault-show <name>
+ [--user <user>|--service <service>|--shared]
+""") + _("""
+ Modify vault description:
+ ipa vault-mod <name>
+ [--user <user>|--service <service>|--shared]
+ --desc <description>
+""") + _("""
+ Modify vault type:
+ ipa vault-mod <name>
+ [--user <user>|--service <service>|--shared]
+ --type <type>
+ [old password/private key]
+ [new password/public key]
+""") + _("""
+ Modify symmetric vault password:
+ ipa vault-mod <name>
+ [--user <user>|--service <service>|--shared]
+ --change-password
+ ipa vault-mod <name>
+ [--user <user>|--service <service>|--shared]
+ --old-password <old password>
+ --new-password <new password>
+ ipa vault-mod <name>
+ [--user <user>|--service <service>|--shared]
+ --old-password-file <old password file>
+ --new-password-file <new password file>
+""") + _("""
+ Modify asymmetric vault keys:
+ ipa vault-mod <name>
+ [--user <user>|--service <service>|--shared]
+ --private-key-file <old private key file>
+ --public-key-file <new public key file>
+""") + _("""
+ Delete a vault:
+ ipa vault-del <name>
+ [--user <user>|--service <service>|--shared]
+""") + _("""
+ Display vault configuration:
+ ipa vaultconfig-show
+""") + _("""
+ Archive data into standard vault:
+ ipa vault-archive <name>
+ [--user <user>|--service <service>|--shared]
+ --in <input file>
+""") + _("""
+ Archive data into symmetric vault:
+ ipa vault-archive <name>
+ [--user <user>|--service <service>|--shared]
+ --in <input file>
+ --password-file password.txt
+""") + _("""
+ Archive data into asymmetric vault:
+ ipa vault-archive <name>
+ [--user <user>|--service <service>|--shared]
+ --in <input file>
+""") + _("""
+ Retrieve data from standard vault:
+ ipa vault-retrieve <name>
+ [--user <user>|--service <service>|--shared]
+ --out <output file>
+""") + _("""
+ Retrieve data from symmetric vault:
+ ipa vault-retrieve <name>
+ [--user <user>|--service <service>|--shared]
+ --out <output file>
+ --password-file password.txt
+""") + _("""
+ Retrieve data from asymmetric vault:
+ ipa vault-retrieve <name>
+ [--user <user>|--service <service>|--shared]
+ --out <output file> --private-key-file private.pem
+""") + _("""
+ Add vault owners:
+ ipa vault-add-owner <name>
+ [--user <user>|--service <service>|--shared]
+ [--users <users>] [--groups <groups>] [--services <services>]
+""") + _("""
+ Delete vault owners:
+ ipa vault-remove-owner <name>
+ [--user <user>|--service <service>|--shared]
+ [--users <users>] [--groups <groups>] [--services <services>]
+""") + _("""
+ Add vault members:
+ ipa vault-add-member <name>
+ [--user <user>|--service <service>|--shared]
+ [--users <users>] [--groups <groups>] [--services <services>]
+""") + _("""
+ Delete vault members:
+ ipa vault-remove-member <name>
+ [--user <user>|--service <service>|--shared]
+ [--users <users>] [--groups <groups>] [--services <services>]
+""")
+
+
+register = Registry()
+
+vault_options = (
+ Str(
+ 'service?',
+ doc=_('Service name of the service vault'),
+ normalizer=normalize_principal,
+ ),
+ Flag(
+ 'shared?',
+ doc=_('Shared vault'),
+ ),
+ Str(
+ 'username?',
+ cli_name='user',
+ doc=_('Username of the user vault'),
+ ),
+)
+
+
+class VaultModMember(LDAPModMember):
+ def get_options(self):
+ for param in super(VaultModMember, self).get_options():
+ if param.name == 'service' and param not in vault_options:
+ param = param.clone_rename('services')
+ yield param
+
+ def get_member_dns(self, **options):
+ if 'services' in options:
+ options['service'] = options.pop('services')
+ else:
+ options.pop('service', None)
+ return super(VaultModMember, self).get_member_dns(**options)
+
+ def post_callback(self, ldap, completed, failed, dn, entry_attrs, *keys, **options):
+ for fail in failed.itervalues():
+ fail['services'] = fail.pop('service', [])
+ self.obj.get_container_attribute(entry_attrs, options)
+ return completed, dn
+
+
+@register()
+class vaultcontainer(LDAPObject):
+ __doc__ = _("""
+ Vault Container object.
+ """)
+
+ container_dn = api.env.container_vault
+
+ object_name = _('vaultcontainer')
+ object_name_plural = _('vaultcontainers')
+ object_class = ['ipaVaultContainer']
+ permission_filter_objectclasses = ['ipaVaultContainer']
+
+ attribute_members = {
+ 'owner': ['user', 'group', 'service'],
+ }
+
+ label = _('Vault Containers')
+ label_singular = _('Vault Container')
+
+ managed_permissions = {
+ 'System: Read Vault Containers': {
+ 'ipapermlocation': api.env.basedn,
+ 'ipapermtarget': DN(api.env.container_vault, api.env.basedn),
+ 'ipapermright': {'read', 'search', 'compare'},
+ 'ipapermdefaultattr': {
+ 'objectclass', 'cn', 'description', 'owner',
+ },
+ 'default_privileges': {'Vault Administrators'},
+ },
+ 'System: Add Vault Containers': {
+ 'ipapermlocation': api.env.basedn,
+ 'ipapermtarget': DN(api.env.container_vault, api.env.basedn),
+ 'ipapermright': {'add'},
+ 'default_privileges': {'Vault Administrators'},
+ },
+ 'System: Delete Vault Containers': {
+ 'ipapermlocation': api.env.basedn,
+ 'ipapermtarget': DN(api.env.container_vault, api.env.basedn),
+ 'ipapermright': {'delete'},
+ 'default_privileges': {'Vault Administrators'},
+ },
+ 'System: Modify Vault Containers': {
+ 'ipapermlocation': api.env.basedn,
+ 'ipapermtarget': DN(api.env.container_vault, api.env.basedn),
+ 'ipapermright': {'write'},
+ 'ipapermdefaultattr': {
+ 'objectclass', 'cn', 'description',
+ },
+ 'default_privileges': {'Vault Administrators'},
+ },
+ 'System: Manage Vault Container Ownership': {
+ 'ipapermlocation': api.env.basedn,
+ 'ipapermtarget': DN(api.env.container_vault, api.env.basedn),
+ 'ipapermright': {'write'},
+ 'ipapermdefaultattr': {
+ 'owner',
+ },
+ 'default_privileges': {'Vault Administrators'},
+ },
+ }
+
+ takes_params = (
+ Str(
+ 'owner_user?',
+ label=_('Owner users'),
+ ),
+ Str(
+ 'owner_group?',
+ label=_('Owner groups'),
+ ),
+ Str(
+ 'owner_service?',
+ label=_('Owner services'),
+ ),
+ Str(
+ 'owner?',
+ label=_('Failed owners'),
+ ),
+ Str(
+ 'service?',
+ label=_('Vault service'),
+ flags={'virtual_attribute'},
+ ),
+ Flag(
+ 'shared?',
+ label=_('Shared vault'),
+ flags={'virtual_attribute'},
+ ),
+ Str(
+ 'username?',
+ label=_('Vault user'),
+ flags={'virtual_attribute'},
+ ),
+ )
+
+ def get_dn(self, *keys, **options):
+ """
+ Generates vault DN from parameters.
+ """
+ service = options.get('service')
+ shared = options.get('shared')
+ user = options.get('username')
+
+ count = (bool(service) + bool(shared) + bool(user))
+ if count > 1:
+ raise errors.MutuallyExclusiveError(
+ reason=_('Service, shared and user options ' +
+ 'cannot be specified simultaneously'))
+
+ parent_dn = super(vaultcontainer, self).get_dn(*keys, **options)
+
+ if not count:
+ principal = getattr(context, 'principal')
+
+ if principal.startswith('host/'):
+ raise errors.NotImplementedError(
+ reason=_('Host is not supported'))
+
+ (name, realm) = split_principal(principal)
+ if '/' in name:
+ service = principal
+ else:
+ user = name
+
+ if service:
+ dn = DN(('cn', service), ('cn', 'services'), parent_dn)
+ elif shared:
+ dn = DN(('cn', 'shared'), parent_dn)
+ elif user:
+ dn = DN(('cn', user), ('cn', 'users'), parent_dn)
+ else:
+ raise RuntimeError
+
+ return dn
+
+ def get_container_attribute(self, entry, options):
+ if options.get('raw', False):
+ return
+ container_dn = DN(self.container_dn, self.api.env.basedn)
+ if entry.dn.endswith(DN(('cn', 'services'), container_dn)):
+ entry['service'] = entry.dn[0]['cn']
+ elif entry.dn.endswith(DN(('cn', 'shared'), container_dn)):
+ entry['shared'] = True
+ elif entry.dn.endswith(DN(('cn', 'users'), container_dn)):
+ entry['username'] = entry.dn[0]['cn']
+
+
+@register()
+class vaultcontainer_show(LDAPRetrieve):
+ __doc__ = _('Display information about a vault container.')
+
+ takes_options = LDAPRetrieve.takes_options + vault_options
+
+ has_output_params = LDAPRetrieve.has_output_params
+
+ def pre_callback(self, ldap, dn, attrs_list, *keys, **options):
+ assert isinstance(dn, DN)
+
+ if not self.api.Command.kra_is_enabled()['result']:
+ raise errors.InvocationError(
+ format=_('KRA service is not enabled'))
+
+ return dn
+
+ def post_callback(self, ldap, dn, entry_attrs, *keys, **options):
+ self.obj.get_container_attribute(entry_attrs, options)
+ return dn
+
+
+@register()
+class vaultcontainer_del(LDAPDelete):
+ __doc__ = _('Delete a vault container.')
+
+ takes_options = LDAPDelete.takes_options + vault_options
+
+ msg_summary = _('Deleted vault container')
+
+ subtree_delete = False
+
+ def pre_callback(self, ldap, dn, *keys, **options):
+ assert isinstance(dn, DN)
+
+ if not self.api.Command.kra_is_enabled()['result']:
+ raise errors.InvocationError(
+ format=_('KRA service is not enabled'))
+
+ return dn
+
+ def execute(self, *keys, **options):
+ keys = keys + (u'',)
+ return super(vaultcontainer_del, self).execute(*keys, **options)
+
+
+@register()
+class vaultcontainer_add_owner(VaultModMember, LDAPAddMember):
+ __doc__ = _('Add owners to a vault container.')
+
+ takes_options = LDAPAddMember.takes_options + vault_options
+
+ member_attributes = ['owner']
+ member_param_label = _('owner %s')
+ member_count_out = ('%i owner added.', '%i owners added.')
+
+ has_output = (
+ output.Entry('result'),
+ output.Output(
+ 'failed',
+ type=dict,
+ doc=_('Owners that could not be added'),
+ ),
+ output.Output(
+ 'completed',
+ type=int,
+ doc=_('Number of owners added'),
+ ),
+ )
+
+
+@register()
+class vaultcontainer_remove_owner(VaultModMember, LDAPRemoveMember):
+ __doc__ = _('Remove owners from a vault container.')
+
+ takes_options = LDAPRemoveMember.takes_options + vault_options
+
+ member_attributes = ['owner']
+ member_param_label = _('owner %s')
+ member_count_out = ('%i owner removed.', '%i owners removed.')
+
+ has_output = (
+ output.Entry('result'),
+ output.Output(
+ 'failed',
+ type=dict,
+ doc=_('Owners that could not be removed'),
+ ),
+ output.Output(
+ 'completed',
+ type=int,
+ doc=_('Number of owners removed'),
+ ),
+ )
+
+
+@register()
+class vault(LDAPObject):
+ __doc__ = _("""
+ Vault object.
+ """)
+
+ container_dn = api.env.container_vault
+
+ object_name = _('vault')
+ object_name_plural = _('vaults')
+
+ object_class = ['ipaVault']
+ permission_filter_objectclasses = ['ipaVault']
+ default_attributes = [
+ 'cn',
+ 'description',
+ 'ipavaulttype',
+ 'ipavaultsalt',
+ 'ipavaultpublickey',
+ 'owner',
+ 'member',
+ ]
+ search_display_attributes = [
+ 'cn',
+ 'description',
+ 'ipavaulttype',
+ ]
+ attribute_members = {
+ 'owner': ['user', 'group', 'service'],
+ 'member': ['user', 'group', 'service'],
+ }
+
+ label = _('Vaults')
+ label_singular = _('Vault')
+
+ managed_permissions = {
+ 'System: Read Vaults': {
+ 'ipapermlocation': api.env.basedn,
+ 'ipapermtarget': DN(api.env.container_vault, api.env.basedn),
+ 'ipapermright': {'read', 'search', 'compare'},
+ 'ipapermdefaultattr': {
+ 'objectclass', 'cn', 'description', 'ipavaulttype',
+ 'ipavaultsalt', 'ipavaultpublickey', 'owner', 'member',
+ 'memberuser', 'memberhost',
+ },
+ 'default_privileges': {'Vault Administrators'},
+ },
+ 'System: Add Vaults': {
+ 'ipapermlocation': api.env.basedn,
+ 'ipapermtarget': DN(api.env.container_vault, api.env.basedn),
+ 'ipapermright': {'add'},
+ 'default_privileges': {'Vault Administrators'},
+ },
+ 'System: Delete Vaults': {
+ 'ipapermlocation': api.env.basedn,
+ 'ipapermtarget': DN(api.env.container_vault, api.env.basedn),
+ 'ipapermright': {'delete'},
+ 'default_privileges': {'Vault Administrators'},
+ },
+ 'System: Modify Vaults': {
+ 'ipapermlocation': api.env.basedn,
+ 'ipapermtarget': DN(api.env.container_vault, api.env.basedn),
+ 'ipapermright': {'write'},
+ 'ipapermdefaultattr': {
+ 'objectclass', 'cn', 'description', 'ipavaulttype',
+ 'ipavaultsalt', 'ipavaultpublickey',
+ },
+ 'default_privileges': {'Vault Administrators'},
+ },
+ 'System: Manage Vault Ownership': {
+ 'ipapermlocation': api.env.basedn,
+ 'ipapermtarget': DN(api.env.container_vault, api.env.basedn),
+ 'ipapermright': {'write'},
+ 'ipapermdefaultattr': {
+ 'owner',
+ },
+ 'default_privileges': {'Vault Administrators'},
+ },
+ 'System: Manage Vault Membership': {
+ 'ipapermlocation': api.env.basedn,
+ 'ipapermtarget': DN(api.env.container_vault, api.env.basedn),
+ 'ipapermright': {'write'},
+ 'ipapermdefaultattr': {
+ 'member',
+ },
+ 'default_privileges': {'Vault Administrators'},
+ },
+ }
+
+ takes_params = (
+ Str(
+ 'cn',
+ cli_name='name',
+ label=_('Vault name'),
+ primary_key=True,
+ pattern='^[a-zA-Z0-9_.-]+$',
+ pattern_errmsg='may only include letters, numbers, _, ., and -',
+ maxlength=255,
+ ),
+ Str(
+ 'description?',
+ cli_name='desc',
+ label=_('Description'),
+ doc=_('Vault description'),
+ ),
+ StrEnum(
+ 'ipavaulttype?',
+ cli_name='type',
+ label=_('Type'),
+ doc=_('Vault type'),
+ values=(u'standard', u'symmetric', u'asymmetric', ),
+ default=u'symmetric',
+ autofill=True,
+ ),
+ Bytes(
+ 'ipavaultsalt?',
+ cli_name='salt',
+ label=_('Salt'),
+ doc=_('Vault salt'),
+ flags=['no_search'],
+ ),
+ Bytes(
+ 'ipavaultpublickey?',
+ cli_name='public_key',
+ label=_('Public key'),
+ doc=_('Vault public key'),
+ flags=['no_search'],
+ ),
+ Str(
+ 'owner_user?',
+ label=_('Owner users'),
+ flags=['no_create', 'no_update', 'no_search'],
+ ),
+ Str(
+ 'owner_group?',
+ label=_('Owner groups'),
+ flags=['no_create', 'no_update', 'no_search'],
+ ),
+ Str(
+ 'owner_service?',
+ label=_('Owner services'),
+ flags=['no_create', 'no_update', 'no_search'],
+ ),
+ Str(
+ 'owner?',
+ label=_('Failed owners'),
+ flags=['no_create', 'no_update', 'no_search'],
+ ),
+ Str(
+ 'service?',
+ label=_('Vault service'),
+ flags={'virtual_attribute', 'no_create', 'no_update', 'no_search'},
+ ),
+ Flag(
+ 'shared?',
+ label=_('Shared vault'),
+ flags={'virtual_attribute', 'no_create', 'no_update', 'no_search'},
+ ),
+ Str(
+ 'username?',
+ label=_('Vault user'),
+ flags={'virtual_attribute', 'no_create', 'no_update', 'no_search'},
+ ),
+ )
+
+ def get_dn(self, *keys, **options):
+ """
+ Generates vault DN from parameters.
+ """
+ service = options.get('service')
+ shared = options.get('shared')
+ user = options.get('username')
+
+ count = (bool(service) + bool(shared) + bool(user))
+ if count > 1:
+ raise errors.MutuallyExclusiveError(
+ reason=_('Service, shared, and user options ' +
+ 'cannot be specified simultaneously'))
+
+ # TODO: create container_dn after object initialization then reuse it
+ container_dn = DN(self.container_dn, self.api.env.basedn)
+
+ dn = super(vault, self).get_dn(*keys, **options)
+ assert dn.endswith(container_dn)
+ rdns = DN(*dn[:-len(container_dn)])
+
+ if not count:
+ principal = getattr(context, 'principal')
+
+ if principal.startswith('host/'):
+ raise errors.NotImplementedError(
+ reason=_('Host is not supported'))
+
+ (name, realm) = split_principal(principal)
+ if '/' in name:
+ service = principal
+ else:
+ user = name
+
+ if service:
+ parent_dn = DN(('cn', service), ('cn', 'services'), container_dn)
+ elif shared:
+ parent_dn = DN(('cn', 'shared'), container_dn)
+ elif user:
+ parent_dn = DN(('cn', user), ('cn', 'users'), container_dn)
+ else:
+ raise RuntimeError
+
+ return DN(rdns, parent_dn)
+
+ def create_container(self, dn, owner_dn):
+ """
+ Creates vault container and its parents.
+ """
+
+ # TODO: create container_dn after object initialization then reuse it
+ container_dn = DN(self.container_dn, self.api.env.basedn)
+
+ entries = []
+
+ while dn:
+ assert dn.endswith(container_dn)
+
+ rdn = dn[0]
+ entry = self.backend.make_entry(
+ dn,
+ {
+ 'objectclass': ['ipaVaultContainer'],
+ 'cn': rdn['cn'],
+ 'owner': [owner_dn],
+ })
+
+ # if entry can be added, return
+ try:
+ self.backend.add_entry(entry)
+ break
+
+ except errors.NotFound:
+ pass
+
+ # otherwise, create parent entry first
+ dn = DN(*dn[1:])
+ entries.insert(0, entry)
+
+ # then create the entries again
+ for entry in entries:
+ self.backend.add_entry(entry)
+
+ def get_key_id(self, dn):
+ """
+ Generates a client key ID to archive/retrieve data in KRA.
+ """
+
+ # TODO: create container_dn after object initialization then reuse it
+ container_dn = DN(self.container_dn, self.api.env.basedn)
+
+ # make sure the DN is a vault DN
+ if not dn.endswith(container_dn, 1):
+ raise ValueError('Invalid vault DN: %s' % dn)
+
+ # construct the vault ID from the bottom up
+ id = u''
+ for rdn in dn[:-len(container_dn)]:
+ name = rdn['cn']
+ id = u'/' + name + id
+
+ return 'ipa:' + id
+
+ def get_container_attribute(self, entry, options):
+ if options.get('raw', False):
+ return
+ container_dn = DN(self.container_dn, self.api.env.basedn)
+ if entry.dn.endswith(DN(('cn', 'services'), container_dn)):
+ entry['service'] = entry.dn[1]['cn']
+ elif entry.dn.endswith(DN(('cn', 'shared'), container_dn)):
+ entry['shared'] = True
+ elif entry.dn.endswith(DN(('cn', 'users'), container_dn)):
+ entry['username'] = entry.dn[1]['cn']
+
+
+@register()
+class vault_add_internal(LDAPCreate):
+
+ NO_CLI = True
+
+ takes_options = LDAPCreate.takes_options + vault_options
+
+ msg_summary = _('Added vault "%(value)s"')
+
+ def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys,
+ **options):
+ assert isinstance(dn, DN)
+
+ if not self.api.Command.kra_is_enabled()['result']:
+ raise errors.InvocationError(
+ format=_('KRA service is not enabled'))
+
+ principal = getattr(context, 'principal')
+ (name, realm) = split_principal(principal)
+ if '/' in name:
+ owner_dn = self.api.Object.service.get_dn(name)
+ else:
+ owner_dn = self.api.Object.user.get_dn(name)
+
+ parent_dn = DN(*dn[1:])
+
+ try:
+ self.obj.create_container(parent_dn, owner_dn)
+ except errors.DuplicateEntry as e:
+ pass
+
+ # vault should be owned by the creator
+ entry_attrs['owner'] = owner_dn
+
+ return dn
+
+ def post_callback(self, ldap, dn, entry, *keys, **options):
+ self.obj.get_container_attribute(entry, options)
+ return dn
+
+
+@register()
+class vault_del(LDAPDelete):
+ __doc__ = _('Delete a vault.')
+
+ takes_options = LDAPDelete.takes_options + vault_options
+
+ msg_summary = _('Deleted vault "%(value)s"')
+
+ def pre_callback(self, ldap, dn, *keys, **options):
+ assert isinstance(dn, DN)
+
+ if not self.api.Command.kra_is_enabled()['result']:
+ raise errors.InvocationError(
+ format=_('KRA service is not enabled'))
+
+ return dn
+
+ def post_callback(self, ldap, dn, *args, **options):
+ assert isinstance(dn, DN)
+
+ kra_client = self.api.Backend.kra.get_client()
+
+ kra_account = pki.account.AccountClient(kra_client.connection)
+ kra_account.login()
+
+ client_key_id = self.obj.get_key_id(dn)
+
+ # deactivate vault record in KRA
+ response = kra_client.keys.list_keys(
+ client_key_id, pki.key.KeyClient.KEY_STATUS_ACTIVE)
+
+ for key_info in response.key_infos:
+ kra_client.keys.modify_key_status(
+ key_info.get_key_id(),
+ pki.key.KeyClient.KEY_STATUS_INACTIVE)
+
+ kra_account.logout()
+
+ return True
+
+
+@register()
+class vault_find(LDAPSearch):
+ __doc__ = _('Search for vaults.')
+
+ takes_options = LDAPSearch.takes_options + vault_options + (
+ Flag(
+ 'services?',
+ doc=_('List all service vaults'),
+ ),
+ Flag(
+ 'users?',
+ doc=_('List all user vaults'),
+ ),
+ )
+
+ has_output_params = LDAPSearch.has_output_params
+
+ msg_summary = ngettext(
+ '%(count)d vault matched',
+ '%(count)d vaults matched',
+ 0,
+ )
+
+ def pre_callback(self, ldap, filter, attrs_list, base_dn, scope, *args,
+ **options):
+ assert isinstance(base_dn, DN)
+
+ if not self.api.Command.kra_is_enabled()['result']:
+ raise errors.InvocationError(
+ format=_('KRA service is not enabled'))
+
+ if options.get('users') or options.get('services'):
+ mutex = ['service', 'services', 'shared', 'username', 'users']
+ count = sum(bool(options.get(option)) for option in mutex)
+ if count > 1:
+ raise errors.MutuallyExclusiveError(
+ reason=_('Service(s), shared, and user(s) options ' +
+ 'cannot be specified simultaneously'))
+
+ scope = ldap.SCOPE_SUBTREE
+ container_dn = DN(self.obj.container_dn,
+ self.api.env.basedn)
+
+ if options.get('services'):
+ base_dn = DN(('cn', 'services'), container_dn)
+ else:
+ base_dn = DN(('cn', 'users'), container_dn)
+ else:
+ base_dn = self.obj.get_dn(None, **options)
+
+ return filter, base_dn, scope
+
+ def post_callback(self, ldap, entries, truncated, *args, **options):
+ for entry in entries:
+ self.obj.get_container_attribute(entry, options)
+ return truncated
+
+ def exc_callback(self, args, options, exc, call_func, *call_args,
+ **call_kwargs):
+ if call_func.__name__ == 'find_entries':
+ if isinstance(exc, errors.NotFound):
+ # ignore missing containers since they will be created
+ # automatically on vault creation.
+ raise errors.EmptyResult(reason=str(exc))
+
+ raise exc
+
+
+@register()
+class vault_mod_internal(LDAPUpdate):
+
+ NO_CLI = True
+
+ takes_options = LDAPUpdate.takes_options + vault_options
+
+ msg_summary = _('Modified vault "%(value)s"')
+
+ def pre_callback(self, ldap, dn, entry_attrs, attrs_list,
+ *keys, **options):
+
+ assert isinstance(dn, DN)
+
+ if not self.api.Command.kra_is_enabled()['result']:
+ raise errors.InvocationError(
+ format=_('KRA service is not enabled'))
+
+ return dn
+
+ def post_callback(self, ldap, dn, entry_attrs, *keys, **options):
+ self.obj.get_container_attribute(entry_attrs, options)
+ return dn
+
+
+@register()
+class vault_show(LDAPRetrieve):
+ __doc__ = _('Display information about a vault.')
+
+ takes_options = LDAPRetrieve.takes_options + vault_options
+
+ has_output_params = LDAPRetrieve.has_output_params
+
+ def pre_callback(self, ldap, dn, attrs_list, *keys, **options):
+ assert isinstance(dn, DN)
+
+ if not self.api.Command.kra_is_enabled()['result']:
+ raise errors.InvocationError(
+ format=_('KRA service is not enabled'))
+
+ return dn
+
+ def post_callback(self, ldap, dn, entry_attrs, *keys, **options):
+ self.obj.get_container_attribute(entry_attrs, options)
+ return dn
+
+
+@register()
+class vaultconfig(Object):
+ __doc__ = _('Vault configuration')
+
+ takes_params = (
+ Bytes(
+ 'transport_cert',
+ label=_('Transport Certificate'),
+ ),
+ )
+
+
+@register()
+class vaultconfig_show(Retrieve):
+ __doc__ = _('Show vault configuration.')
+
+ takes_options = (
+ Str(
+ 'transport_out?',
+ doc=_('Output file to store the transport certificate'),
+ ),
+ )
+
+ def execute(self, *args, **options):
+
+ if not self.api.Command.kra_is_enabled()['result']:
+ raise errors.InvocationError(
+ format=_('KRA service is not enabled'))
+
+ kra_client = self.api.Backend.kra.get_client()
+ transport_cert = kra_client.system_certs.get_transport_cert()
+ return {
+ 'result': {
+ 'transport_cert': transport_cert.binary
+ },
+ 'value': None,
+ }
+
+
+@register()
+class vault_archive_internal(PKQuery):
+
+ NO_CLI = True
+
+ takes_options = vault_options + (
+ Bytes(
+ 'session_key',
+ doc=_('Session key wrapped with transport certificate'),
+ ),
+ Bytes(
+ 'vault_data',
+ doc=_('Vault data encrypted with session key'),
+ ),
+ Bytes(
+ 'nonce',
+ doc=_('Nonce'),
+ ),
+ )
+
+ has_output = output.standard_entry
+
+ msg_summary = _('Archived data into vault "%(value)s"')
+
+ def execute(self, *args, **options):
+
+ if not self.api.Command.kra_is_enabled()['result']:
+ raise errors.InvocationError(
+ format=_('KRA service is not enabled'))
+
+ wrapped_vault_data = options.pop('vault_data')
+ nonce = options.pop('nonce')
+ wrapped_session_key = options.pop('session_key')
+
+ # retrieve vault info
+ vault = self.api.Command.vault_show(*args, **options)['result']
+
+ # connect to KRA
+ kra_client = self.api.Backend.kra.get_client()
+
+ kra_account = pki.account.AccountClient(kra_client.connection)
+ kra_account.login()
+
+ client_key_id = self.obj.get_key_id(vault['dn'])
+
+ # deactivate existing vault record in KRA
+ response = kra_client.keys.list_keys(
+ client_key_id,
+ pki.key.KeyClient.KEY_STATUS_ACTIVE)
+
+ for key_info in response.key_infos:
+ kra_client.keys.modify_key_status(
+ key_info.get_key_id(),
+ pki.key.KeyClient.KEY_STATUS_INACTIVE)
+
+ # forward wrapped data to KRA
+ kra_client.keys.archive_encrypted_data(
+ client_key_id,
+ pki.key.KeyClient.PASS_PHRASE_TYPE,
+ wrapped_vault_data,
+ wrapped_session_key,
+ None,
+ nonce,
+ )
+
+ kra_account.logout()
+
+ response = {
+ 'value': args[-1],
+ 'result': {},
+ }
+
+ response['summary'] = self.msg_summary % response
+
+ return response
+
+
+@register()
+class vault_retrieve_internal(PKQuery):
+
+ NO_CLI = True
+
+ takes_options = vault_options + (
+ Bytes(
+ 'session_key',
+ doc=_('Session key wrapped with transport certificate'),
+ ),
+ )
+
+ has_output = output.standard_entry
+
+ msg_summary = _('Retrieved data from vault "%(value)s"')
+
+ def execute(self, *args, **options):
+
+ if not self.api.Command.kra_is_enabled()['result']:
+ raise errors.InvocationError(
+ format=_('KRA service is not enabled'))
+
+ wrapped_session_key = options.pop('session_key')
+
+ # retrieve vault info
+ vault = self.api.Command.vault_show(*args, **options)['result']
+
+ # connect to KRA
+ kra_client = self.api.Backend.kra.get_client()
+
+ kra_account = pki.account.AccountClient(kra_client.connection)
+ kra_account.login()
+
+ client_key_id = self.obj.get_key_id(vault['dn'])
+
+ # find vault record in KRA
+ response = kra_client.keys.list_keys(
+ client_key_id,
+ pki.key.KeyClient.KEY_STATUS_ACTIVE)
+
+ if not len(response.key_infos):
+ raise errors.NotFound(reason=_('No archived data.'))
+
+ key_info = response.key_infos[0]
+
+ # retrieve encrypted data from KRA
+ key = kra_client.keys.retrieve_key(
+ key_info.get_key_id(),
+ wrapped_session_key)
+
+ kra_account.logout()
+
+ response = {
+ 'value': args[-1],
+ 'result': {
+ 'vault_data': key.encrypted_data,
+ 'nonce': key.nonce_data,
+ },
+ }
+
+ response['summary'] = self.msg_summary % response
+
+ return response
+
+
+@register()
+class vault_add_owner(VaultModMember, LDAPAddMember):
+ __doc__ = _('Add owners to a vault.')
+
+ takes_options = LDAPAddMember.takes_options + vault_options
+
+ member_attributes = ['owner']
+ member_param_label = _('owner %s')
+ member_count_out = ('%i owner added.', '%i owners added.')
+
+ has_output = (
+ output.Entry('result'),
+ output.Output(
+ 'failed',
+ type=dict,
+ doc=_('Owners that could not be added'),
+ ),
+ output.Output(
+ 'completed',
+ type=int,
+ doc=_('Number of owners added'),
+ ),
+ )
+
+
+@register()
+class vault_remove_owner(VaultModMember, LDAPRemoveMember):
+ __doc__ = _('Remove owners from a vault.')
+
+ takes_options = LDAPRemoveMember.takes_options + vault_options
+
+ member_attributes = ['owner']
+ member_param_label = _('owner %s')
+ member_count_out = ('%i owner removed.', '%i owners removed.')
+
+ has_output = (
+ output.Entry('result'),
+ output.Output(
+ 'failed',
+ type=dict,
+ doc=_('Owners that could not be removed'),
+ ),
+ output.Output(
+ 'completed',
+ type=int,
+ doc=_('Number of owners removed'),
+ ),
+ )
+
+
+@register()
+class vault_add_member(VaultModMember, LDAPAddMember):
+ __doc__ = _('Add members to a vault.')
+
+ takes_options = LDAPAddMember.takes_options + vault_options
+
+
+@register()
+class vault_remove_member(VaultModMember, LDAPRemoveMember):
+ __doc__ = _('Remove members from a vault.')
+
+ takes_options = LDAPRemoveMember.takes_options + vault_options
+
+
+@register()
+class kra_is_enabled(Command):
+ NO_CLI = True
+
+ has_output = output.standard_value
+
+ def execute(self, *args, **options):
+ base_dn = DN(('cn', 'masters'), ('cn', 'ipa'), ('cn', 'etc'),
+ self.api.env.basedn)
+ filter = '(&(objectClass=ipaConfigObject)(cn=KRA))'
+ try:
+ self.api.Backend.ldap2.find_entries(
+ base_dn=base_dn, filter=filter, attrs_list=[])
+ except errors.NotFound:
+ result = False
+ else:
+ result = True
+ return dict(result=result, value=pkey_to_value(None, options))
diff --git a/ipaserver/plugins/virtual.py b/ipaserver/plugins/virtual.py
new file mode 100644
index 000000000..2ba69f651
--- /dev/null
+++ b/ipaserver/plugins/virtual.py
@@ -0,0 +1,68 @@
+# Authors:
+# Rob Crittenden <rcritten@redhat.com>
+#
+# Copyright (C) 2009 Red Hat
+# see file 'COPYING' for use and warranty information
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+"""
+Base classes for non-LDAP backend plugins.
+"""
+from ipalib import Command
+from ipalib import errors
+from ipapython.dn import DN
+from ipalib.text import _
+
+class VirtualCommand(Command):
+ """
+ A command that doesn't use the LDAP backend but wants to use the
+ LDAP access control system to make authorization decisions.
+
+ The class variable operation is the commonName attribute of the
+ entry to be tested against.
+
+ In advance, you need to create an entry of the form:
+ cn=<operation>, api.env.container_virtual, api.env.basedn
+
+ Ex.
+ cn=request certificate, cn=virtual operations,cn=etc, dc=example, dc=com
+ """
+ operation = None
+
+ def check_access(self, operation=None):
+ """
+ Perform an LDAP query to determine authorization.
+
+ This should be executed before any actual work is done.
+ """
+ if self.operation is None and operation is None:
+ raise errors.ACIError(info=_('operation not defined'))
+
+ if operation is None:
+ operation = self.operation
+
+ ldap = self.api.Backend.ldap2
+ self.log.debug("IPA: virtual verify %s" % operation)
+
+ operationdn = DN(('cn', operation), self.api.env.container_virtual, self.api.env.basedn)
+
+ try:
+ if not ldap.can_write(operationdn, "objectclass"):
+ raise errors.ACIError(
+ info=_('not allowed to perform operation: %s') % operation)
+ except errors.NotFound:
+ raise errors.ACIError(info=_('No such virtual command'))
+
+ return True