From 75a787d2177f671409f22ea7f9bcd0dfc5742d80 Mon Sep 17 00:00:00 2001
From: Alexander Bokovoy <abokovoy@redhat.com>
Date: Thu, 26 Apr 2018 11:08:33 +0300
Subject: [PATCH 1/2] service: allow creating services without a host to manage
 them

Add --avoid-host-check option to ipa service-add command to allow
creating services without corresponding host object. This is needed to
cover use cases where Kerberos services created to handle client
authentication in a dynamically generated environment like Kubernetes.

Fixes: https://pagure.io/freeipa/issue/7514
---
 API.txt                                     |  3 ++-
 install/ui/src/freeipa/service.js           |  6 ++++++
 ipaserver/plugins/service.py                | 22 ++++++++++++++--------
 ipatests/test_xmlrpc/test_service_plugin.py | 27 +++++++++++++++++++++++++++
 4 files changed, 49 insertions(+), 9 deletions(-)

diff --git a/API.txt b/API.txt
index 05dec4475c..488452d62f 100644
--- a/API.txt
+++ b/API.txt
@@ -4457,7 +4457,7 @@ output: Entry('result')
 output: Output('summary', type=[<type 'unicode'>, <type 'NoneType'>])
 output: PrimaryKey('value')
 command: service_add/1
-args: 1,13,3
+args: 1,14,3
 arg: Principal('krbcanonicalname', cli_name='canonical_principal')
 option: Str('addattr*', cli_name='addattr')
 option: Flag('all', autofill=True, cli_name='all', default=False)
@@ -4470,6 +4470,7 @@ option: Str('krbprincipalauthind*', cli_name='auth_ind')
 option: Flag('no_members', autofill=True, default=False)
 option: Flag('raw', autofill=True, cli_name='raw', default=False)
 option: Str('setattr*', cli_name='setattr')
+option: Flag('skip_host_check', autofill=True, default=False)
 option: Certificate('usercertificate*', cli_name='certificate')
 option: Str('version?')
 output: Entry('result')
diff --git a/install/ui/src/freeipa/service.js b/install/ui/src/freeipa/service.js
index 93808b0122..bd62728399 100644
--- a/install/ui/src/freeipa/service.js
+++ b/install/ui/src/freeipa/service.js
@@ -407,6 +407,7 @@ return {
                         other_entity: 'host',
                         other_field: 'fqdn',
                         label: '@i18n:objects.service.host',
+                        editable: true,
                         required: true,
                         z_index: 1
                     },
@@ -415,6 +416,11 @@ return {
                         name: 'force',
                         metadata: '@mc-opt:service_add:force'
                     }
+                    {
+                        $type: 'checkbox',
+                        name: 'skip_host_check',
+                        metadata: '@mc-opt:service_add:skip_host_check'
+                    }
                 ]
             }
         ]
diff --git a/ipaserver/plugins/service.py b/ipaserver/plugins/service.py
index be31f81027..ebe7bc53d5 100644
--- a/ipaserver/plugins/service.py
+++ b/ipaserver/plugins/service.py
@@ -602,8 +602,13 @@ class service_add(LDAPCreate):
     takes_options = LDAPCreate.takes_options + (
         Flag('force',
             label=_('Force'),
-            doc=_('force principal name even if not in DNS'),
+            doc=_('force principal name even if host not in DNS'),
         ),
+        Flag('skip_host_check',
+             label=_('Force'),
+             doc=_('force service to be created even when host '
+                   'object does not exist to manage it'),
+             ),
     )
 
     def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options):
@@ -614,12 +619,13 @@ def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options):
         if principal.is_host and not options['force']:
             raise errors.HostService()
 
-        try:
-            hostresult = self.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)
+        if not options['avoid_host_check']:
+            try:
+                hostresult = self.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)
 
@@ -628,7 +634,7 @@ def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options):
             # 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:
+        if not (options['avoid_host_check'] or 'managedby' in entry_attrs):
             entry_attrs['managedby'] = hostresult['dn']
 
         # Enforce ipaKrbPrincipalAlias to aid case-insensitive searches
diff --git a/ipatests/test_xmlrpc/test_service_plugin.py b/ipatests/test_xmlrpc/test_service_plugin.py
index c910269df3..aa329a999c 100644
--- a/ipatests/test_xmlrpc/test_service_plugin.py
+++ b/ipatests/test_xmlrpc/test_service_plugin.py
@@ -47,6 +47,10 @@
 host1dn = DN(('fqdn',fqdn1),('cn','computers'),('cn','accounts'),api.env.basedn)
 host2dn = DN(('fqdn',fqdn2),('cn','computers'),('cn','accounts'),api.env.basedn)
 host3dn = DN(('fqdn',fqdn3),('cn','computers'),('cn','accounts'),api.env.basedn)
+disconnected_service = u'some/at.some.arbitrary.name@%s' % api.env.realm
+disconnected_servicedn = DN(('krbprincipalname', service1),
+                            ('cn', 'services'), ('cn', 'accounts'),
+                            api.env.basedn)
 
 role1 = u'Test Role'
 role1_dn = DN(('cn', role1), api.env.container_rolegroup, api.env.basedn)
@@ -87,6 +91,7 @@ class test_service(Declarative):
         ('host_del', [fqdn2], {}),
         ('host_del', [fqdn3], {}),
         ('service_del', [service1], {}),
+        ('service_del', [disconnected_service], {}),
     ]
 
     tests = [
@@ -732,6 +737,28 @@ class test_service(Declarative):
         ),
 
 
+        # Create a service disconnected from any host
+        dict(
+            desc='Try to create service %r without any host' %
+                 disconnected_service,
+            command=('service_add', [disconnected_service],
+                     dict(
+                          force=True,
+                          avoid_host_check=True,
+                         ),
+                     ),
+            expected=dict(
+                value=disconnected_service,
+                summary=u'Added service "%s"' % disconnected_service,
+                result=dict(
+                    dn=disconnected_servicedn,
+                    krbprincipalname=[disconnected_service],
+                    krbcanonicalname=[disconnected_service],
+                    objectclass=objectclasses.service,
+                    ipauniqueid=[fuzzy_uuid],
+                ),
+            ),
+        ),
     ]
 
 

From fe4a4dd2ffecfa296d320602724f30714a8fcb68 Mon Sep 17 00:00:00 2001
From: Alexander Bokovoy <abokovoy@redhat.com>
Date: Thu, 26 Apr 2018 11:32:19 +0300
Subject: [PATCH 2/2] group: allow services as members of groups

Allow services to be members of the groups, like users and other groups
can already be.

This is required for use cases where such services aren't associated
with a particular host (and thus, the host object cannot be used to
retrieve the keytabs) but represent purely client Kerberos principals to
use in a dynamically generated environment such as Kubernetes.

Fixes: https://pagure.io/freeipa/issue/7513
---
 API.txt                                     | 10 +++++--
 install/ui/src/freeipa/group.js             |  4 +++
 ipaserver/plugins/group.py                  | 17 ++++++++----
 ipatests/test_xmlrpc/test_service_plugin.py | 43 +++++++++++++++++++++++++++++
 4 files changed, 66 insertions(+), 8 deletions(-)

diff --git a/API.txt b/API.txt
index 488452d62f..70c831157f 100644
--- a/API.txt
+++ b/API.txt
@@ -1944,13 +1944,14 @@ output: Entry('result')
 output: Output('summary', type=[<type 'unicode'>, <type 'NoneType'>])
 output: PrimaryKey('value')
 command: group_add_member/1
-args: 1,7,3
+args: 1,8,3
 arg: Str('cn', cli_name='group_name')
 option: Flag('all', autofill=True, cli_name='all', default=False)
 option: Str('group*', alwaysask=True, cli_name='groups')
 option: Str('ipaexternalmember*', cli_name='external')
 option: Flag('no_members', autofill=True, default=False)
 option: Flag('raw', autofill=True, cli_name='raw', default=False)
+option: Str('service*', alwaysask=True, cli_name='services')
 option: Str('user*', alwaysask=True, cli_name='users')
 option: Str('version?')
 output: Output('completed', type=[<type 'int'>])
@@ -1972,7 +1973,7 @@ output: Output('result', type=[<type 'bool'>])
 output: Output('summary', type=[<type 'unicode'>, <type 'NoneType'>])
 output: PrimaryKey('value')
 command: group_find/1
-args: 1,28,4
+args: 1,30,4
 arg: Str('criteria?')
 option: Flag('all', autofill=True, cli_name='all', default=False)
 option: Str('cn?', autofill=False, cli_name='group_name')
@@ -1987,6 +1988,7 @@ option: Str('in_role*', cli_name='in_roles')
 option: Str('in_sudorule*', cli_name='in_sudorules')
 option: Str('no_group*', cli_name='no_groups')
 option: Flag('no_members', autofill=True, default=True)
+option: Principal('no_service*', cli_name='no_services')
 option: Str('no_user*', cli_name='no_users')
 option: Flag('nonposix', autofill=True, cli_name='nonposix', default=False)
 option: Str('not_in_group*', cli_name='not_in_groups')
@@ -1998,6 +2000,7 @@ option: Flag('pkey_only?', autofill=True, default=False)
 option: Flag('posix', autofill=True, cli_name='posix', default=False)
 option: Flag('private', autofill=True, cli_name='private', default=False)
 option: Flag('raw', autofill=True, cli_name='raw', default=False)
+option: Principal('service*', cli_name='services')
 option: Int('sizelimit?', autofill=False)
 option: Int('timelimit?', autofill=False)
 option: Str('user*', cli_name='users')
@@ -2026,13 +2029,14 @@ output: Entry('result')
 output: Output('summary', type=[<type 'unicode'>, <type 'NoneType'>])
 output: PrimaryKey('value')
 command: group_remove_member/1
-args: 1,7,3
+args: 1,8,3
 arg: Str('cn', cli_name='group_name')
 option: Flag('all', autofill=True, cli_name='all', default=False)
 option: Str('group*', alwaysask=True, cli_name='groups')
 option: Str('ipaexternalmember*', cli_name='external')
 option: Flag('no_members', autofill=True, default=False)
 option: Flag('raw', autofill=True, cli_name='raw', default=False)
+option: Str('service*', alwaysask=True, cli_name='services')
 option: Str('user*', alwaysask=True, cli_name='users')
 option: Str('version?')
 output: Output('completed', type=[<type 'int'>])
diff --git a/install/ui/src/freeipa/group.js b/install/ui/src/freeipa/group.js
index bf718ad9ea..3030374dde 100644
--- a/install/ui/src/freeipa/group.js
+++ b/install/ui/src/freeipa/group.js
@@ -128,6 +128,10 @@ return {
             $type: 'association',
             name: 'member_group'
         },
+        {
+            $type: 'association',
+            name: 'member_service'
+        },
         {
             $type: 'attribute',
             name: 'member_external',
diff --git a/ipaserver/plugins/group.py b/ipaserver/plugins/group.py
index 2d6aba1495..7fb2d79d09 100644
--- a/ipaserver/plugins/group.py
+++ b/ipaserver/plugins/group.py
@@ -58,10 +58,10 @@
 __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
+Manage groups of users, groups, or services. 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.
@@ -71,6 +71,10 @@
 to supply a GID when creating a group. IPA will generate one automatically
 if it is not provided.
 
+Groups members can be users, other groups, and Kerberos services. In POSIX
+environments only users will be visible as group members, but nested groups and
+groups of services can be used for IPA management purposes.
+
 EXAMPLES:
 
  Add a new group:
@@ -97,6 +101,9 @@
  Add multiple users to the "localadmins" group:
    ipa group-add-member --users=test1 --users=test2 localadmins
 
+ To add Kerberos services to the "printer admins" group:
+   ipa group-add-member --services=CUPS/some.host printeradmins
+
  Remove a user from the "localadmins" group:
    ipa group-remove-member --users=test2 localadmins
 
@@ -167,7 +174,7 @@ class group(LDAPObject):
     ]
     uuid_attribute = 'ipauniqueid'
     attribute_members = {
-        'member': ['user', 'group'],
+        'member': ['user', 'group', 'service'],
         'memberof': ['group', 'netgroup', 'role', 'hbacrule', 'sudorule'],
         'memberindirect': ['user', 'group'],
         'memberofindirect': ['group', 'netgroup', 'role', 'hbacrule',
diff --git a/ipatests/test_xmlrpc/test_service_plugin.py b/ipatests/test_xmlrpc/test_service_plugin.py
index aa329a999c..1a1ac67208 100644
--- a/ipatests/test_xmlrpc/test_service_plugin.py
+++ b/ipatests/test_xmlrpc/test_service_plugin.py
@@ -894,6 +894,7 @@ class test_service_allowed_to(Declarative):
     cleanup_commands = [
         ('user_del', [user1], {}),
         ('user_del', [user2], {}),
+        ('service_del', [disconnected_service], {}),
         ('group_del', [group1], {}),
         ('group_del', [group2], {}),
         ('host_del', [fqdn1], {}),
@@ -959,6 +960,48 @@ class test_service_allowed_to(Declarative):
                 ),
             ),
         ),
+        # Create a service disconnected from any host
+        dict(
+            desc='Try to create service %r without any host' %
+                 disconnected_service,
+            command=('service_add', [disconnected_service],
+                     dict(
+                          force=True,
+                          avoid_host_check=True,
+                         ),
+                     ),
+            expected=dict(
+                value=disconnected_service,
+                summary=u'Added service "%s"' % disconnected_service,
+                result=dict(
+                    dn=disconnected_servicedn,
+                    krbprincipalname=[disconnected_service],
+                    krbcanonicalname=[disconnected_service],
+                    objectclass=objectclasses.service,
+                    ipauniqueid=[fuzzy_uuid],
+                ),
+            ),
+        ),
+        dict(
+            desc='Add service %r to a group: %r' %
+                 (disconnected_service, group1),
+            command=(
+                'group_add_member', [group1],
+                dict(services=disconnected_service),
+                ),
+            expected=dict(
+                value=group1,
+                summary=u'Number of members added 1',
+                result=dict(
+                    cn=[group1],
+                    objectclass=objectclasses.group + [u'posixgroup'],
+                    ipauniqueid=[fuzzy_uuid],
+                    gidnumber=[fuzzy_digits],
+                    dn=group1_dn,
+                    member=[disconnected_servicedn],
+                ),
+                ),
+        ),
         dict(
             desc='Create %r' % fqdn1,
             command=(
