This patchset implements new API commands for manipulating user/host/service userCertificate attribute alongside some underlying plumbing.

PATCH 0045 is a small test suite that I slapped together since manual testing of this stuff is very cumbersome. It requires my PATCH 0040 to apply and work which was pushed to master recently
(commit 74883bbc959058c8bfafd9f63e8fad0e3792ac28).

The work is related to http://www.freeipa.org/page/V4/User_Certificates and https://fedorahosted.org/freeipa/ticket/4238

--
Martin^3 Babinsky
From 54836ffd7b1fb69fbe687aba16418ee5f27fe8ac Mon Sep 17 00:00:00 2001
From: Martin Babinsky <mbabi...@redhat.com>
Date: Tue, 23 Jun 2015 13:42:45 +0200
Subject: [PATCH 4/4] test suite for user/host/service certificate management
 API commands

These tests excercise various scenarios when using new class of API commands
to add or remove certificates to user/service/host entries.

Part of http://www.freeipa.org/page/V4/User_Certificates
---
 ipatests/test_xmlrpc/test_add_remove_cert_cmd.py | 352 +++++++++++++++++++++++
 1 file changed, 352 insertions(+)
 create mode 100644 ipatests/test_xmlrpc/test_add_remove_cert_cmd.py

diff --git a/ipatests/test_xmlrpc/test_add_remove_cert_cmd.py b/ipatests/test_xmlrpc/test_add_remove_cert_cmd.py
new file mode 100644
index 0000000000000000000000000000000000000000..48863468e3344084ad7211ec296bb7d113a49f44
--- /dev/null
+++ b/ipatests/test_xmlrpc/test_add_remove_cert_cmd.py
@@ -0,0 +1,352 @@
+#
+# Copyright (C) 2015  FreeIPA Contributors see COPYING for license
+#
+
+import base64
+
+from ipalib import api, errors
+
+from ipatests.util import assert_deepequal, raises
+from xmlrpc_test import XMLRPC_test
+from ipapython.dn import DN
+from testcert import get_testcert
+
+
+class CertManipCmdTestBase(XMLRPC_test):
+    entity_class = ''
+    entity_pkey = None
+    entity_subject = None
+    entity_principal = None
+    non_existent_entity = None
+
+    profile_store_orig = True
+    default_profile_id = u'caIPAserviceCert'
+    default_caacl = u'hosts_services_%s' % default_profile_id
+    cmd_options = dict(
+        entity_add=None,
+        caacl=None,
+    )
+    cert_add_cmd = None
+    cert_del_cmd = None
+
+    cert_add_summary = u''
+    cert_del_summary = u''
+
+    entity_attrs = None
+
+    @classmethod
+    def disable_profile_store(cls):
+        try:
+            api.Command.certprofile_mod(cls.default_profile_id,
+                                        ipacertprofilestoreissued=False)
+        except errors.EmptyModlist:
+            cls.profile_store_orig = False
+        else:
+            cls.profile_store_orig = True
+
+    @classmethod
+    def restore_profile_store(cls):
+        if cls.profile_store_orig:
+            api.Command.certprofile_mod(
+                cls.default_profile_id,
+                ipacertprofilestoreissued=cls.profile_store_orig)
+
+    @classmethod
+    def add_entity(cls):
+        api.Command['%s_add' % cls.entity_class](
+            cls.entity_pkey,
+            **cls.cmd_options['entity_add'])
+
+    @classmethod
+    def delete_entity(cls):
+        try:
+            api.Command['%s_del' % cls.entity_class](cls.entity_pkey)
+        except errors.NotFound:
+            pass
+
+    # optional methods which implement adding CA ACL rule so that we can
+    # request cert for the entity. Currently used only for users.
+    @classmethod
+    def add_caacl(cls):
+        pass
+
+    @classmethod
+    def remove_caacl(cls):
+        pass
+
+    @classmethod
+    def setup_class(cls):
+        super(CertManipCmdTestBase, cls).setup_class()
+
+        cls.delete_entity()
+
+        cls.add_entity()
+        cls.add_caacl()
+
+        cls.disable_profile_store()
+
+        # list of certificates to add to entry
+        cls.certs = [
+            get_testcert(DN(('CN', cls.entity_subject)), cls.entity_principal)
+            for i in xrange(3)
+        ]
+
+        # list of certificates for testing of removal of non-existent certs
+        cls.nonexistent_certs = [
+            get_testcert(DN(('CN', cls.entity_subject)), cls.entity_principal)
+            for j in xrange(2)
+            ]
+
+        # cert subset to remove from entry
+        cls.certs_subset = cls.certs[:2]
+
+        # remaining subset
+        cls.certs_remainder = cls.certs[2:]
+
+        # mixture of certs which exist and do not exists in the entry
+        cls.mixed_certs = cls.certs[:2] + cls.nonexistent_certs[:1]
+
+        # store entity info for the final test
+        cls.entity_attrs = api.Command['%s_show' % cls.entity_class](
+            cls.entity_pkey)
+
+    @classmethod
+    def teardown_class(cls):
+        cls.delete_entity()
+        cls.remove_caacl()
+
+        cls.restore_profile_store()
+        super(CertManipCmdTestBase, cls).teardown_class()
+
+    def test_01_add_cert_to_nonexistent_entity(self):
+        """
+        Tests whether trying to add certificates to a non-existent entry
+        raises NotFound error.
+        """
+        raises(errors.NotFound, self.cert_add_cmd,
+               self.non_existent_entity, usercertificate=self.certs)
+
+    def test_02_remove_cert_from_nonexistent_entity(self):
+        """
+        Tests whether trying to remove certificates from a non-existent entry
+        raises NotFound error.
+        """
+        raises(errors.NotFound, self.cert_add_cmd,
+               self.non_existent_entity, usercertificate=self.certs)
+
+    def test_03_remove_cert_from_entity_with_no_certs(self):
+        """
+        Attempt to remove certificates from an entity that has none raises
+        InvocationError
+        """
+        raises(errors.InvocationError, self.cert_del_cmd,
+               self.entity_pkey, usercertificate=self.certs)
+
+    def test_04_add_single_cert_to_entity(self):
+        """
+        Add single certificate to entry
+        """
+        assert_deepequal(
+            dict(
+                result=dict(
+                    usercertificate=[base64.b64decode(self.certs[0])]
+                ),
+                summary=self.cert_add_summary % (1, self.entity_pkey),
+                value=self.entity_pkey,
+                attrs=('usercertificate',),
+                n_values=1
+            ),
+            self.cert_add_cmd(  # pylint: disable=E1102
+                self.entity_pkey,
+                usercertificate=self.certs[0])
+        )
+
+    def test_05_add_more_certs_to_entity(self):
+        """
+        Add the rest of the certificate set to the entry.
+        """
+        n_values = len(self.certs[1:])
+        assert_deepequal(
+            dict(
+                result=dict(
+                    usercertificate=map(base64.b64decode, self.certs)
+                ),
+                summary=self.cert_add_summary % (n_values, self.entity_pkey),
+                value=self.entity_pkey,
+                n_values=n_values,
+                attrs=('usercertificate',)
+            ),
+            self.cert_add_cmd(  # pylint: disable=E1102
+                self.entity_pkey,
+                usercertificate=self.certs[1:]
+            )
+        )
+
+    def test_06_add_already_present_cert_to_entity(self):
+        """
+        Tests that InvocationError is raised when attempting to add certificates
+        to the entry that already contains them.
+        """
+        raises(
+            errors.InvocationError,
+            self.cert_add_cmd,
+            self.entity_pkey,
+            usercertificate=self.certs_subset
+        )
+
+    def test_07_remove_nonexistent_certs_from_entity(self):
+        """
+        Tests that an attempt to remove certificates that are not present in the
+        entry raises InvocationError
+        """
+        raises(
+            errors.InvocationError,
+            self.cert_del_cmd,
+            self.entity_pkey,
+            usercertificate=self.nonexistent_certs
+        )
+
+    def test_08_remove_valid_and_nonexistent_certs_from_entity(self):
+        """
+        Try to remove multiple certificates. Some of them are not present in
+        the entry. This scenario should raise InvocationError.
+        """
+        raises(
+            errors.InvocationError,
+            self.cert_del_cmd,
+            self.entity_pkey,
+            usercertificate=self.mixed_certs
+        )
+
+    def test_09_remove_cert_subset_from_entity(self):
+        """
+        Test correct removal a part of entry's certificates.
+        """
+        n_values = len(self.certs_subset)
+        assert_deepequal(
+            dict(
+                result=dict(
+                    usercertificate=map(base64.b64decode,
+                                        self.certs_remainder)
+                ),
+                summary=self.cert_del_summary % (n_values, self.entity_pkey),
+                value=self.entity_pkey,
+                n_values=n_values,
+                attrs=('usercertificate',)
+            ),
+            self.cert_del_cmd(  # pylint: disable=E1102
+                self.entity_pkey,
+                usercertificate=self.certs_subset
+            )
+        )
+
+    def test_10_remove_remaining_certs_from_entity(self):
+        """
+        Test correct removal of all the remaining certificates from the entry.
+        """
+        n_values = len(self.certs_remainder)
+        assert_deepequal(
+            dict(
+                result=dict(
+                    usercertificate=[]
+                ),
+                summary=self.cert_del_summary % (n_values, self.entity_pkey),
+                value=self.entity_pkey,
+                n_values=n_values,
+                attrs=('usercertificate',)
+            ),
+            self.cert_del_cmd(  # pylint: disable=E1102
+                self.entity_pkey,
+                usercertificate=self.certs_remainder
+            )
+        )
+
+    def test_99_check_final_entity_consistency(self):
+        """
+        Tests that all the previous operations do not modify other attributes
+        of the entry. Make sure that the show command returns the same
+        information as in the beginning of the test suite.
+        """
+        assert_deepequal(
+            self.entity_attrs,
+            api.Command['%s_show' % self.entity_class](self.entity_pkey)
+        )
+
+
+class TestCertManipCmdUser(CertManipCmdTestBase):
+    entity_class = 'user'
+    entity_pkey = u'tuser'
+    entity_subject = entity_pkey
+    entity_principal = u'tuser'
+    non_existent_entity = u'nonexistentuser'
+
+    cmd_options = dict(
+        entity_add=dict(givenname=u'Test', sn=u'User'),
+        caacl=dict(user=[u'tuser']),
+    )
+
+    cert_add_cmd = api.Command.user_add_cert
+    cert_del_cmd = api.Command.user_remove_cert
+
+    cert_add_summary = u'Added %d certificates to user "%s"'
+    cert_del_summary = u'Removed %d certificates from user "%s"'
+
+    @classmethod
+    def add_caacl(cls):
+        api.Command['caacl_add_%s' % cls.entity_class](
+            cls.default_caacl, **cls.cmd_options['caacl'])
+
+    @classmethod
+    def remove_caacl(cls):
+        api.Command['caacl_remove_%s' % cls.entity_class](
+            cls.default_caacl, **cls.cmd_options['caacl'])
+
+
+class TestCertManipCmdHost(CertManipCmdTestBase):
+    entity_class = 'host'
+    entity_pkey = u'host.example.com'
+    entity_subject = entity_pkey
+    entity_principal = u'host/%s' % entity_pkey
+    non_existent_entity = u'non.existent.host.com'
+
+    cmd_options = dict(
+        entity_add=dict(force=True),
+    )
+
+    cert_add_cmd = api.Command.host_add_cert
+    cert_del_cmd = api.Command.host_remove_cert
+
+    cert_add_summary = u'Added %d certificates to host "%s"'
+    cert_del_summary = u'Removed %d certificates from host "%s"'
+
+
+class TestCertManipCmdService(CertManipCmdTestBase):
+    entity_class = 'service'
+    entity_pkey = u'testservice/%s@%s' % (TestCertManipCmdHost.entity_pkey,
+                                          api.env.realm)
+    entity_subject = TestCertManipCmdHost.entity_pkey
+    entity_principal = entity_pkey
+    non_existent_entity = u'testservice/non.existent.host.com'
+
+    cmd_options = dict(
+        entity_add=dict(force=True),
+    )
+
+    cert_add_cmd = api.Command.service_add_cert
+    cert_del_cmd = api.Command.service_remove_cert
+
+    cert_add_summary = u'Added %d certificates to service principal "%s"'
+    cert_del_summary = u'Removed %d certificates from service principal "%s"'
+
+    @classmethod
+    def add_entity(cls):
+        api.Command.host_add(TestCertManipCmdHost.entity_pkey, force=True)
+        super(TestCertManipCmdService, cls).add_entity()
+
+    @classmethod
+    def delete_entity(cls):
+        super(TestCertManipCmdService, cls).delete_entity()
+        try:
+            api.Command.host_del(TestCertManipCmdHost.entity_pkey)
+        except errors.NotFound:
+            pass
-- 
2.1.0

From cfc0db04571d5d49746090556f040e7bd32f2dfa Mon Sep 17 00:00:00 2001
From: Martin Babinsky <mbabi...@redhat.com>
Date: Tue, 23 Jun 2015 13:42:01 +0200
Subject: [PATCH 3/4] new commands to manage user/host/service certificates

A new group of commands is introduced that simplifies adding and removing
binary certificates to entries. A general form of the command is

ipa [user/host/service]-[add/remove]-cert [pkey] --certificate=[BASE64 BLOB]

Part of http://www.freeipa.org/page/V4/User_Certificates and
https://fedorahosted.org/freeipa/ticket/4238
---
 API.txt                   | 60 +++++++++++++++++++++++++++++++++++++++++++++++
 VERSION                   |  4 ++--
 ipalib/plugins/host.py    | 32 +++++++++++++++++++++++--
 ipalib/plugins/service.py | 27 +++++++++++++++++++++
 ipalib/plugins/user.py    | 23 ++++++++++++++++++
 5 files changed, 142 insertions(+), 4 deletions(-)

diff --git a/API.txt b/API.txt
index 3bcb3bdd24ada4e513f6263fc32a2953c18fc142..af09674f84d77f5a3d627c286a7ed25d064cf921 100644
--- a/API.txt
+++ b/API.txt
@@ -2066,6 +2066,16 @@ option: Str('version?', exclude='webui')
 output: Entry('result', <type 'dict'>, Gettext('A dictionary representing an LDAP entry', domain='ipa', localedir=None))
 output: Output('summary', (<type 'unicode'>, <type 'NoneType'>), None)
 output: PrimaryKey('value', None, None)
+command: host_add_cert
+args: 1,2,5
+arg: Str('fqdn', attribute=True, cli_name='hostname', multivalue=False, primary_key=True, query=True, required=True)
+option: Bytes('usercertificate*', cli_name='certificate')
+option: Str('version?', exclude='webui')
+output: Output('attrs', <type 'tuple'>, None)
+output: Output('n_values', <type 'int'>, None)
+output: Output('result', None, None)
+output: Output('summary', (<type 'unicode'>, <type 'NoneType'>), None)
+output: PrimaryKey('value', None, None)
 command: host_add_managedby
 args: 1,5,3
 arg: Str('fqdn', attribute=True, cli_name='hostname', multivalue=False, primary_key=True, query=True, required=True)
@@ -2220,6 +2230,16 @@ option: Str('version?', exclude='webui')
 output: Entry('result', <type 'dict'>, Gettext('A dictionary representing an LDAP entry', domain='ipa', localedir=None))
 output: Output('summary', (<type 'unicode'>, <type 'NoneType'>), None)
 output: PrimaryKey('value', None, None)
+command: host_remove_cert
+args: 1,2,5
+arg: Str('fqdn', attribute=True, cli_name='hostname', multivalue=False, primary_key=True, query=True, required=True)
+option: Bytes('usercertificate*', cli_name='certificate')
+option: Str('version?', exclude='webui')
+output: Output('attrs', <type 'tuple'>, None)
+output: Output('n_values', <type 'int'>, None)
+output: Output('result', None, None)
+output: Output('summary', (<type 'unicode'>, <type 'NoneType'>), None)
+output: PrimaryKey('value', None, None)
 command: host_remove_managedby
 args: 1,5,3
 arg: Str('fqdn', attribute=True, cli_name='hostname', multivalue=False, primary_key=True, query=True, required=True)
@@ -3851,6 +3871,16 @@ option: Str('version?', exclude='webui')
 output: Entry('result', <type 'dict'>, Gettext('A dictionary representing an LDAP entry', domain='ipa', localedir=None))
 output: Output('summary', (<type 'unicode'>, <type 'NoneType'>), None)
 output: PrimaryKey('value', None, None)
+command: service_add_cert
+args: 1,2,5
+arg: Str('krbprincipalname', attribute=True, cli_name='principal', multivalue=False, primary_key=True, query=True, required=True)
+option: Bytes('usercertificate*', cli_name='certificate')
+option: Str('version?', exclude='webui')
+output: Output('attrs', <type 'tuple'>, None)
+output: Output('n_values', <type 'int'>, None)
+output: Output('result', None, None)
+output: Output('summary', (<type 'unicode'>, <type 'NoneType'>), None)
+output: PrimaryKey('value', None, None)
 command: service_add_host
 args: 1,5,3
 arg: Str('krbprincipalname', attribute=True, cli_name='principal', multivalue=False, primary_key=True, query=True, required=True)
@@ -3969,6 +3999,16 @@ option: Str('version?', exclude='webui')
 output: Entry('result', <type 'dict'>, Gettext('A dictionary representing an LDAP entry', domain='ipa', localedir=None))
 output: Output('summary', (<type 'unicode'>, <type 'NoneType'>), None)
 output: PrimaryKey('value', None, None)
+command: service_remove_cert
+args: 1,2,5
+arg: Str('krbprincipalname', attribute=True, cli_name='principal', multivalue=False, primary_key=True, query=True, required=True)
+option: Bytes('usercertificate*', cli_name='certificate')
+option: Str('version?', exclude='webui')
+output: Output('attrs', <type 'tuple'>, None)
+output: Output('n_values', <type 'int'>, None)
+output: Output('result', None, None)
+output: Output('summary', (<type 'unicode'>, <type 'NoneType'>), None)
+output: PrimaryKey('value', None, None)
 command: service_remove_host
 args: 1,5,3
 arg: Str('krbprincipalname', attribute=True, cli_name='principal', multivalue=False, primary_key=True, query=True, required=True)
@@ -5151,6 +5191,16 @@ option: Str('version?', exclude='webui')
 output: Entry('result', <type 'dict'>, Gettext('A dictionary representing an LDAP entry', domain='ipa', localedir=None))
 output: Output('summary', (<type 'unicode'>, <type 'NoneType'>), None)
 output: PrimaryKey('value', None, None)
+command: user_add_cert
+args: 1,2,5
+arg: Str('uid', attribute=True, cli_name='login', maxlength=255, multivalue=False, pattern='^[a-zA-Z0-9_.][a-zA-Z0-9_.-]{0,252}[a-zA-Z0-9_.$-]?$', primary_key=True, query=True, required=True)
+option: Bytes('usercertificate*', cli_name='certificate')
+option: Str('version?', exclude='webui')
+output: Output('attrs', <type 'tuple'>, None)
+output: Output('n_values', <type 'int'>, None)
+output: Output('result', None, None)
+output: Output('summary', (<type 'unicode'>, <type 'NoneType'>), None)
+output: PrimaryKey('value', None, None)
 command: user_del
 args: 1,4,3
 arg: Str('uid', attribute=True, cli_name='login', maxlength=255, multivalue=True, pattern='^[a-zA-Z0-9_.][a-zA-Z0-9_.-]{0,252}[a-zA-Z0-9_.$-]?$', primary_key=True, query=True, required=True)
@@ -5290,6 +5340,16 @@ option: Str('version?', exclude='webui')
 output: Entry('result', <type 'dict'>, Gettext('A dictionary representing an LDAP entry', domain='ipa', localedir=None))
 output: Output('summary', (<type 'unicode'>, <type 'NoneType'>), None)
 output: PrimaryKey('value', None, None)
+command: user_remove_cert
+args: 1,2,5
+arg: Str('uid', attribute=True, cli_name='login', maxlength=255, multivalue=False, pattern='^[a-zA-Z0-9_.][a-zA-Z0-9_.-]{0,252}[a-zA-Z0-9_.$-]?$', primary_key=True, query=True, required=True)
+option: Bytes('usercertificate*', cli_name='certificate')
+option: Str('version?', exclude='webui')
+output: Output('attrs', <type 'tuple'>, None)
+output: Output('n_values', <type 'int'>, None)
+output: Output('result', None, None)
+output: Output('summary', (<type 'unicode'>, <type 'NoneType'>), None)
+output: PrimaryKey('value', None, None)
 command: user_show
 args: 1,5,3
 arg: Str('uid', attribute=True, cli_name='login', maxlength=255, multivalue=False, pattern='^[a-zA-Z0-9_.][a-zA-Z0-9_.-]{0,252}[a-zA-Z0-9_.$-]?$', primary_key=True, query=True, required=True)
diff --git a/VERSION b/VERSION
index 224d34925685c8ecb6f2db3672d34c40621dc9dc..fa90e18c248cf6d27a0f98083e0193a2776b3341 100644
--- a/VERSION
+++ b/VERSION
@@ -90,5 +90,5 @@ IPA_DATA_VERSION=20100614120000
 #                                                      #
 ########################################################
 IPA_API_VERSION_MAJOR=2
-IPA_API_VERSION_MINOR=135
-# Last change: jcholast - User life cycle: Make user-del flags CLI-specific
+IPA_API_VERSION_MINOR=136
+# Last change: mbabinsk: Commands to manage user/service/host certificates
diff --git a/ipalib/plugins/host.py b/ipalib/plugins/host.py
index e81dca94e124b080a3d68a3b1cfd079710e30336..bd1aa79ce9082de330e923561cf5a46278d35521 100644
--- a/ipalib/plugins/host.py
+++ b/ipalib/plugins/host.py
@@ -28,11 +28,13 @@ from ipalib.plugins.baseldap import (LDAPQuery, LDAPObject, LDAPCreate,
                                      LDAPDelete, LDAPUpdate, LDAPSearch,
                                      LDAPRetrieve, LDAPAddMember,
                                      LDAPRemoveMember, host_is_master,
-                                     pkey_to_value, add_missing_object_class)
+                                     pkey_to_value, add_missing_object_class,
+                                     LDAPAddAttrValue, LDAPRemoveAttrValue)
 from ipalib.plugins.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)
+    rename_ipaallowedtoperform_to_ldap, normalize_and_validate_certs,
+    revoke_certs)
 from ipalib.plugins.dns import (dns_container_exists, _record_types,
         add_records_for_host_validation, add_records_for_host,
         get_reverse_zone)
@@ -1311,3 +1313,29 @@ class host_disallow_create_keytab(LDAPRemoveMember):
         rename_ipaallowedtoperform_from_ldap(entry_attrs, options)
         rename_ipaallowedtoperform_from_ldap(failed, options)
         return (completed, dn)
+
+
+@register()
+class host_add_cert(LDAPAddAttrValue):
+    __doc__ = _('Add certificates to host entry')
+    msg_summary = _('Added %(n_values)d certificates to host "%(value)s"')
+    attrs_to_mod = ('usercertificate',)
+
+    def pre_callback(self, ldap, dn, *keys, **options):
+        normalize_and_validate_certs(*keys, **options)
+        return dn
+
+
+@register()
+class host_remove_cert(LDAPRemoveAttrValue):
+    __doc__ = _('Remove certificates from host entry')
+    msg_summary = _('Removed %(n_values)d certificates from host "%(value)s"')
+    attrs_to_mod = ('usercertificate',)
+
+    def pre_callback(self, ldap, dn, *keys, **options):
+        normalize_and_validate_certs(*keys, **options)
+        return dn
+
+    def post_callback(self, ldap, dn, entry_attrs, *keys, **options):
+        revoke_certs(self.log, **options)
+        return dn
diff --git a/ipalib/plugins/service.py b/ipalib/plugins/service.py
index 80abac9b251f961fd81b67bca9128c66c3057c61..0335ae2df87ac9d5b216c993d36b55e6e7ce3a86 100644
--- a/ipalib/plugins/service.py
+++ b/ipalib/plugins/service.py
@@ -936,3 +936,30 @@ class service_disable(LDAPQuery):
             value=pkey_to_value(keys[0], options),
         )
 
+
+@register()
+class service_add_cert(LDAPAddAttrValue):
+    __doc__ = _('Add new certificates to a service')
+    msg_summary = _('Added %(n_values)d certificates to service principal '
+                    '"%(value)s"')
+    attrs_to_mod = ('usercertificate',)
+
+    def pre_callback(self, ldap, dn, *keys, **options):
+        normalize_and_validate_certs(*keys, **options)
+        return dn
+
+
+@register()
+class service_remove_cert(LDAPRemoveAttrValue):
+    __doc__ = _('Remove certificates from a service')
+    msg_summary = _('Removed %(n_values)d certificates from service principal '
+                    '"%(value)s"')
+    attrs_to_mod = ('usercertificate',)
+
+    def pre_callback(self, ldap, dn, *keys, **options):
+        normalize_and_validate_certs(*keys, **options)
+        return dn
+
+    def post_callback(self, ldap, dn, entry_attrs, *keys, **options):
+        revoke_certs(self.log, **options)
+        return dn
diff --git a/ipalib/plugins/user.py b/ipalib/plugins/user.py
index d2404e2ede1183c89a339c0ecb86b80a21fff02d..ec05baef1a698e4a7dcdef69c5bfeb2d965a405e 100644
--- a/ipalib/plugins/user.py
+++ b/ipalib/plugins/user.py
@@ -45,6 +45,7 @@ from ipalib.util import (normalize_sshpubkey, validate_sshpubkey,
     convert_sshpubkey_post)
 if api.env.in_server and api.env.context in ['lite', 'server']:
     from ipaserver.plugins.ldap2 import ldap2
+from ipalib.plugins.service import normalize_and_validate_certs
 
 __doc__ = _("""
 Users
@@ -998,3 +999,25 @@ class user_status(LDAPQuery):
                     summary=unicode(_('Account disabled: %(disabled)s' %
                         dict(disabled=disabled))),
         )
+
+
+@register()
+class user_add_cert(LDAPAddAttrValue):
+    __doc__ = _('Add one or more certificates to the user entry')
+    msg_summary = _('Added %(n_values)d certificates to user "%(value)s"')
+    attrs_to_mod = ('usercertificate',)
+
+    def pre_callback(self, ldap, dn, *keys, **options):
+        normalize_and_validate_certs(*keys, **options)
+        return dn
+
+
+@register()
+class user_remove_cert(LDAPRemoveAttrValue):
+    __doc__ = _('Remove one or more certificates to the user entry')
+    msg_summary = _('Removed %(n_values)d certificates from user "%(value)s"')
+    attrs_to_mod = ('usercertificate',)
+
+    def pre_callback(self, ldap, dn, *keys, **options):
+        normalize_and_validate_certs(*keys, **options)
+        return dn
-- 
2.1.0

From deb35e2cc1c3af1a347ef94261c025431c4b4817 Mon Sep 17 00:00:00 2001
From: Martin Babinsky <mbabi...@redhat.com>
Date: Tue, 23 Jun 2015 13:40:30 +0200
Subject: [PATCH 2/4] service plugin: new functions for certificate
 normalization and revocation

These functions will be used by new API commands that will manage certificate
attributes for users, hosts, and services.

Part of http://www.freeipa.org/page/V4/User_Certificates
---
 ipalib/plugins/service.py | 41 +++++++++++++++++++++++++++++++++++++++++
 1 file changed, 41 insertions(+)

diff --git a/ipalib/plugins/service.py b/ipalib/plugins/service.py
index 166d978a248e7c5da6f8df4b534edad0a0799b7e..80abac9b251f961fd81b67bca9128c66c3057c61 100644
--- a/ipalib/plugins/service.py
+++ b/ipalib/plugins/service.py
@@ -249,6 +249,47 @@ def validate_certificate(ugettext, cert):
         # We'll assume this is DER data
         pass
 
+
+def normalize_and_validate_certs(self, *keys, **options):
+    """
+    Encodes the incoming certificates to DER and checks the issuer
+    """
+    rawcerts = options.get('usercertificate', None)
+    if not rawcerts:
+        return
+
+    dercerts = []
+    for rawcert in rawcerts:
+        dercert = x509.normalize_certificate(rawcert)
+        x509.verify_cert_subject(None, keys[0], dercert)
+        dercerts.append(dercert)
+
+    options['usercertificate'] = dercerts
+
+
+def revoke_certs(logger, **options):
+    if api.Command.ca_is_enabled()['result']:
+            certs = options.get('usercertificate', [])
+            for cert in map(x509.normalize_certificate, certs):
+                try:
+                    serial = unicode(x509.get_serial_number(cert, x509.DER))
+
+                    result = api.Command['cert_show'](unicode(serial))['result']
+                    if 'revocation_reason' not in result:
+                        api.Command['cert_revoke'](unicode(serial),
+                                                   revocation_reason=4)
+
+                except errors.NotImplementedError:
+                    # some CA's might not implement revoke
+                    pass
+                except NSPRError, nsprerr:
+                    if nsprerr.errno == -8183:
+                        logger.info("Problem decoding certificate %s"
+                                    % nsprerr.args[1])
+                    else:
+                        raise nsprerr
+
+
 def set_certificate_attrs(entry_attrs):
     """
     Set individual attributes from some values from a certificate.
-- 
2.1.0

From 860d02b4e036599b3b3dd71b24b0b6276414d91a Mon Sep 17 00:00:00 2001
From: Martin Babinsky <mbabi...@redhat.com>
Date: Tue, 23 Jun 2015 13:39:35 +0200
Subject: [PATCH 1/4]  baseldap: add support for API commands managing only a
 subset of attributes

 This patch extends the API framework with a set of classes which add/remove
 values to one or more LDAPObject attributes.
---
 ipalib/plugins/baseldap.py | 137 +++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 137 insertions(+)

diff --git a/ipalib/plugins/baseldap.py b/ipalib/plugins/baseldap.py
index 2eab69f3decb3359e82d30a0a3a595e81a6d9bc3..d2423321c89bb6a8a9b5e6b4ba059a909f87e9ba 100644
--- a/ipalib/plugins/baseldap.py
+++ b/ipalib/plugins/baseldap.py
@@ -2367,3 +2367,140 @@ class LDAPRemoveReverseMember(LDAPModReverseMember):
 
     def interactive_prompt_callback(self, kw):
         return
+
+
+class LDAPModAttrValue(LDAPQuery):
+
+    attrs_to_mod = None
+    msg_summary = _('modified %(n_values)d values of the following '
+                    'attributes %(attrs)s')
+    has_output = output.standard + (output.value,
+                                    output.Output('n_values',
+                                                  type=int,
+                                                  doc=_('total number of '
+                                                        'modified values')),
+                                    output.Output('attrs',
+                                                  type=tuple,
+                                                  doc=_('List of attributes'))
+                                    )
+
+    def takes_params(self):
+        if hasattr(self.obj, 'primary_key'):
+            yield self.obj.primary_key
+
+    def takes_options(self):
+        for attr_name in self.attrs_to_mod:
+            # make our own copy of Param and set it
+            # unconditionally to required.
+            option = deepcopy(getattr(self.obj.params, attr_name))
+            object.__setattr__(option, 'required', True)
+            yield option
+
+
+    def pre_callback(self, ldap, dn, *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
+
+    def interactive_prompt_callback(self, kw):
+        return
+
+    def update_attrs(self, entry_attrs, **options):
+        raise NotImplementedError("%s.update_attrs()", self.__class__.__name__)
+
+    def execute(self, *keys, **options):
+        ldap = self.obj.backend
+
+        dn = self.obj.get_dn(*keys, **options)
+        assert isinstance(dn, DN)
+
+        entry_attrs = self._exc_wrapper(keys, options, ldap.get_entry)(dn)
+        attrs_list = set(self.obj.default_attributes)
+        attrs_list.update(options.keys())
+
+        for callback in self.get_callbacks('pre'):
+            dn = callback(self, ldap, dn, entry_attrs, *keys, **options)
+
+        n_values = self.update_attrs(entry_attrs, **options)
+
+        self._exc_wrapper(keys, options, ldap.update_entry)(entry_attrs)
+
+        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)
+
+        entry_attrs = entry_to_dict(entry_attrs, **options)
+        entry_attrs['dn'] = dn
+
+        # fill the result dict only with modified attributes
+        result = {attr: entry_attrs[attr] for attr in self.attrs_to_mod}
+
+        return dict(result=result,
+                    value=pkey_to_value(keys[0], options),
+                    n_values=n_values,
+                    attrs=self.attrs_to_mod)
+
+
+class LDAPAddAttrValue(LDAPModAttrValue):
+    msg_summary = _('added %(n_values)d values to following '
+                    'attributes: %(attrs)s')
+
+    def update_attrs(self, entry_attrs, **options):
+        n_values = 0
+
+        for attr in self.attrs_to_mod:
+            orig_values = set(entry_attrs.get(attr, []))
+            values_to_add = set(options.get(attr, []))
+
+            if not values_to_add:
+                continue
+
+            if not orig_values.isdisjoint(values_to_add):
+                raise errors.InvocationError(
+                    message=_('Attribute "%s" already contains one or more '
+                              'values' % attr))
+
+            n_values += len(values_to_add)
+            entry_attrs[attr] = list(orig_values.union(values_to_add))
+
+        return n_values
+
+
+class LDAPRemoveAttrValue(LDAPModAttrValue):
+    msg_summary = _('removed %(n_values)d values from following '
+                    'attributes: %(attrs)s')
+
+    def update_attrs(self, entry_attrs, **options):
+        n_values = 0
+
+        for attr in self.attrs_to_mod:
+            orig_values = set(entry_attrs.get(attr, []))
+            if not orig_values:
+                raise errors.InvocationError(
+                    message=_('Can not remove values from empty attribute'))
+
+            values_to_remove = set(options.get(attr, []))
+
+            if not values_to_remove:
+                continue
+
+            if not values_to_remove.issubset(orig_values):
+                raise errors.InvocationError(
+                    message=_('Can not remove non-existent values from '
+                              'attribute "%s"' % attr))
+
+            n_values += len(values_to_remove)
+            entry_attrs[attr] = list(
+                orig_values.difference(values_to_remove))
+
+        return n_values
-- 
2.1.0

-- 
Manage your subscription for the Freeipa-devel mailing list:
https://www.redhat.com/mailman/listinfo/freeipa-devel
Contribute to FreeIPA: http://www.freeipa.org/page/Contribute/Code

Reply via email to