2009-05-11 08:49:07 -05:00
|
|
|
# Authors:
|
|
|
|
# Rob Crittenden <rcritten@redhat.com>
|
|
|
|
# Pavel Zuna <pzuna@redhat.com>
|
|
|
|
#
|
|
|
|
# Copyright (C) 2009 Red Hat
|
|
|
|
# see file 'COPYING' for use and warranty information
|
|
|
|
#
|
2010-12-09 06:59:11 -06:00
|
|
|
# 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.
|
2009-05-11 08:49:07 -05:00
|
|
|
#
|
|
|
|
# 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
|
2010-12-09 06:59:11 -06:00
|
|
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
2011-08-24 21:48:30 -05:00
|
|
|
|
|
|
|
from ipalib import api
|
|
|
|
from ipalib import Int, Str
|
|
|
|
from ipalib.plugins.baseldap import *
|
|
|
|
from ipalib import _, ngettext
|
|
|
|
|
|
|
|
__doc__ = _("""
|
2009-06-16 09:51:44 -05:00
|
|
|
Groups of users
|
2010-06-02 13:08:50 -05:00
|
|
|
|
2010-10-01 12:33:33 -05:00
|
|
|
Manage groups of users. By default, new groups are POSIX groups. You
|
2011-03-04 10:08:54 -06:00
|
|
|
can add the --nonposix option to the group-add command to mark a new group
|
2011-11-16 17:42:40 -06:00
|
|
|
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
|
2010-08-24 22:40:32 -05:00
|
|
|
converted to non-POSIX groups.
|
2010-06-02 13:08:50 -05:00
|
|
|
|
|
|
|
Every group must have a description.
|
|
|
|
|
2011-03-04 10:08:54 -06:00
|
|
|
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
|
2010-08-24 22:40:32 -05:00
|
|
|
to supply a GID when creating a group. IPA will generate one automatically
|
|
|
|
if it is not provided.
|
2010-06-02 13:08:50 -05:00
|
|
|
|
|
|
|
EXAMPLES:
|
|
|
|
|
|
|
|
Add a new group:
|
|
|
|
ipa group-add --desc='local administrators' localadmins
|
|
|
|
|
2010-10-01 12:33:33 -05:00
|
|
|
Add a new non-POSIX group:
|
|
|
|
ipa group-add --nonposix --desc='remote administrators' remoteadmins
|
2010-06-02 13:08:50 -05:00
|
|
|
|
2010-08-24 22:40:32 -05:00
|
|
|
Convert a non-POSIX group to posix:
|
2010-10-01 12:33:33 -05:00
|
|
|
ipa group-mod --posix remoteadmins
|
2010-06-02 13:08:50 -05:00
|
|
|
|
2010-08-24 22:40:32 -05:00
|
|
|
Add a new POSIX group with a specific Group ID number:
|
2010-10-01 12:33:33 -05:00
|
|
|
ipa group-add --gid=500 --desc='unix admins' unixadmins
|
2010-06-02 13:08:50 -05:00
|
|
|
|
2010-08-24 22:40:32 -05:00
|
|
|
Add a new POSIX group and let IPA assign a Group ID number:
|
2010-10-01 12:33:33 -05:00
|
|
|
ipa group-add --desc='printer admins' printeradmins
|
2010-08-24 22:40:32 -05:00
|
|
|
|
2010-06-02 13:08:50 -05:00
|
|
|
Remove a group:
|
|
|
|
ipa group-del unixadmins
|
|
|
|
|
2010-08-24 22:40:32 -05:00
|
|
|
To add the "remoteadmins" group to the "localadmins" group:
|
2010-06-02 13:08:50 -05:00
|
|
|
ipa group-add-member --groups=remoteadmins localadmins
|
|
|
|
|
2010-08-24 22:40:32 -05:00
|
|
|
Add a list of users to the "localadmins" group:
|
2010-06-02 13:08:50 -05:00
|
|
|
ipa group-add-member --users=test1,test2 localadmins
|
|
|
|
|
2010-08-24 22:40:32 -05:00
|
|
|
Remove a user from the "localadmins" group:
|
2010-06-02 13:08:50 -05:00
|
|
|
ipa group-remove-member --users=test2 localadmins
|
|
|
|
|
2010-08-24 22:40:32 -05:00
|
|
|
Display information about a named group.
|
2010-06-02 13:08:50 -05:00
|
|
|
ipa group-show localadmins
|
2011-08-24 21:48:30 -05:00
|
|
|
""")
|
2009-05-12 11:40:14 -05:00
|
|
|
|
2009-08-27 08:51:48 -05:00
|
|
|
class group(LDAPObject):
|
2009-05-11 08:49:07 -05:00
|
|
|
"""
|
|
|
|
Group object.
|
|
|
|
"""
|
2009-08-27 08:51:48 -05:00
|
|
|
container_dn = api.env.container_group
|
2011-07-12 11:01:25 -05:00
|
|
|
object_name = _('group')
|
|
|
|
object_name_plural = _('groups')
|
2009-08-27 08:51:48 -05:00
|
|
|
object_class = ['ipausergroup']
|
|
|
|
object_class_config = 'ipagroupobjectclasses'
|
2010-12-20 22:28:33 -06:00
|
|
|
possible_objectclasses = ['posixGroup', 'mepManagedEntry']
|
2010-07-12 13:17:33 -05:00
|
|
|
search_attributes_config = 'ipagroupsearchfields'
|
2009-10-21 09:12:11 -05:00
|
|
|
default_attributes = [
|
2010-10-04 16:45:40 -05:00
|
|
|
'cn', 'description', 'gidnumber', 'member', 'memberof',
|
2011-02-19 22:09:03 -06:00
|
|
|
'memberindirect', 'memberofindirect',
|
2009-10-21 09:12:11 -05:00
|
|
|
]
|
2009-08-27 08:51:48 -05:00
|
|
|
uuid_attribute = 'ipauniqueid'
|
|
|
|
attribute_members = {
|
|
|
|
'member': ['user', 'group'],
|
2011-05-31 16:52:35 -05:00
|
|
|
'memberof': ['group', 'netgroup', 'role', 'hbacrule', 'sudorule'],
|
2011-06-24 14:18:35 -05:00
|
|
|
'memberindirect': ['user', 'group'],
|
2011-05-31 16:52:35 -05:00
|
|
|
'memberofindirect': ['group', 'netgroup', 'role', 'hbacrule',
|
|
|
|
'sudorule'],
|
2009-08-27 08:51:48 -05:00
|
|
|
}
|
2012-02-29 12:31:20 -06:00
|
|
|
rdn_is_primary_key = True
|
2009-08-27 08:51:48 -05:00
|
|
|
|
2010-02-08 06:03:28 -06:00
|
|
|
label = _('User Groups')
|
2011-07-13 21:10:47 -05:00
|
|
|
label_singular = _('User Group')
|
2010-02-08 06:03:28 -06:00
|
|
|
|
2009-08-27 08:51:48 -05:00
|
|
|
takes_params = (
|
|
|
|
Str('cn',
|
2010-11-10 16:30:01 -06:00
|
|
|
pattern='^[a-zA-Z0-9_.][a-zA-Z0-9_.-]{0,252}[a-zA-Z0-9_.$-]?$',
|
2010-07-27 15:35:23 -05:00
|
|
|
pattern_errmsg='may only include letters, numbers, _, -, . and $',
|
2010-11-10 16:30:01 -06:00
|
|
|
maxlength=255,
|
2010-12-02 15:29:26 -06:00
|
|
|
cli_name='group_name',
|
2010-02-19 10:08:16 -06:00
|
|
|
label=_('Group name'),
|
2009-08-27 08:51:48 -05:00
|
|
|
primary_key=True,
|
|
|
|
normalizer=lambda value: value.lower(),
|
|
|
|
),
|
|
|
|
Str('description',
|
|
|
|
cli_name='desc',
|
2010-02-19 10:08:16 -06:00
|
|
|
label=_('Description'),
|
|
|
|
doc=_('Group description'),
|
2009-08-27 08:51:48 -05:00
|
|
|
),
|
2009-05-11 08:49:07 -05:00
|
|
|
Int('gidnumber?',
|
|
|
|
cli_name='gid',
|
2010-02-19 10:08:16 -06:00
|
|
|
label=_('GID'),
|
|
|
|
doc=_('GID (use this option to set it manually)'),
|
2012-02-23 03:25:22 -06:00
|
|
|
minvalue=1,
|
2009-05-11 08:49:07 -05:00
|
|
|
),
|
|
|
|
)
|
|
|
|
|
2009-06-16 07:38:27 -05:00
|
|
|
api.register(group)
|
2009-05-11 08:49:07 -05:00
|
|
|
|
|
|
|
|
2009-08-27 08:51:48 -05:00
|
|
|
class group_add(LDAPCreate):
|
2011-08-24 21:48:30 -05:00
|
|
|
__doc__ = _('Create a new group.')
|
2009-12-09 10:09:53 -06:00
|
|
|
|
|
|
|
msg_summary = _('Added group "%(value)s"')
|
|
|
|
|
2009-08-27 08:51:48 -05:00
|
|
|
takes_options = LDAPCreate.takes_options + (
|
2010-10-01 12:33:33 -05:00
|
|
|
Flag('nonposix',
|
|
|
|
cli_name='nonposix',
|
2011-03-04 10:08:54 -06:00
|
|
|
doc=_('Create as a non-POSIX group'),
|
2010-10-01 12:33:33 -05:00
|
|
|
default=False,
|
2009-05-11 08:49:07 -05:00
|
|
|
),
|
|
|
|
)
|
|
|
|
|
2009-12-10 09:39:24 -06:00
|
|
|
def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options):
|
2010-10-01 12:33:33 -05:00
|
|
|
if not options['nonposix']:
|
2009-10-08 05:55:14 -05:00
|
|
|
entry_attrs['objectclass'].append('posixgroup')
|
2010-06-25 15:14:46 -05:00
|
|
|
if not 'gidnumber' in options:
|
|
|
|
entry_attrs['gidnumber'] = 999
|
2009-08-27 08:51:48 -05:00
|
|
|
return dn
|
2009-05-11 08:49:07 -05:00
|
|
|
|
2009-12-09 10:09:53 -06:00
|
|
|
|
2009-06-16 09:51:44 -05:00
|
|
|
api.register(group_add)
|
2009-05-11 08:49:07 -05:00
|
|
|
|
|
|
|
|
2009-08-27 08:51:48 -05:00
|
|
|
class group_del(LDAPDelete):
|
2011-08-24 21:48:30 -05:00
|
|
|
__doc__ = _('Delete group.')
|
2009-12-09 10:09:53 -06:00
|
|
|
|
|
|
|
msg_summary = _('Deleted group "%(value)s"')
|
|
|
|
|
2009-08-27 08:51:48 -05:00
|
|
|
def pre_callback(self, ldap, dn, *keys, **options):
|
|
|
|
config = ldap.get_ipa_config()[1]
|
|
|
|
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:
|
2011-01-25 11:46:26 -06:00
|
|
|
raise errors.DefaultGroupError()
|
2010-10-08 21:44:48 -05:00
|
|
|
group_attrs = self.obj.methods.show(
|
|
|
|
self.obj.get_primary_key_from_dn(dn), all=True
|
|
|
|
)['result']
|
|
|
|
|
2010-08-09 15:40:51 -05:00
|
|
|
if 'mepmanagedby' in group_attrs:
|
|
|
|
raise errors.ManagedGroupError()
|
2009-08-27 08:51:48 -05:00
|
|
|
return dn
|
2009-05-11 08:49:07 -05:00
|
|
|
|
2010-01-28 16:19:30 -06:00
|
|
|
def post_callback(self, ldap, dn, *keys, **options):
|
|
|
|
try:
|
2010-05-14 14:58:34 -05:00
|
|
|
api.Command['pwpolicy_del'](keys[-1])
|
2010-01-28 16:19:30 -06:00
|
|
|
except errors.NotFound:
|
|
|
|
pass
|
|
|
|
|
|
|
|
return True
|
|
|
|
|
2009-06-16 09:51:44 -05:00
|
|
|
api.register(group_del)
|
2009-05-11 08:49:07 -05:00
|
|
|
|
|
|
|
|
2009-08-27 08:51:48 -05:00
|
|
|
class group_mod(LDAPUpdate):
|
2011-08-24 21:48:30 -05:00
|
|
|
__doc__ = _('Modify a group.')
|
|
|
|
|
2009-12-09 10:09:53 -06:00
|
|
|
msg_summary = _('Modified group "%(value)s"')
|
|
|
|
|
2009-08-27 08:51:48 -05:00
|
|
|
takes_options = LDAPUpdate.takes_options + (
|
2009-05-11 08:49:07 -05:00
|
|
|
Flag('posix',
|
|
|
|
cli_name='posix',
|
2010-10-01 12:33:33 -05:00
|
|
|
doc=_('change to a POSIX group'),
|
2009-05-11 08:49:07 -05:00
|
|
|
),
|
|
|
|
)
|
2009-08-27 08:51:48 -05:00
|
|
|
|
|
|
|
def pre_callback(self, ldap, dn, entry_attrs, *keys, **options):
|
|
|
|
if options['posix'] or 'gidnumber' in options:
|
|
|
|
(dn, old_entry_attrs) = ldap.get_entry(dn, ['objectclass'])
|
|
|
|
if 'posixgroup' in old_entry_attrs['objectclass']:
|
|
|
|
if options['posix']:
|
2009-05-21 07:37:04 -05:00
|
|
|
raise errors.AlreadyPosixGroup()
|
2009-05-11 08:49:07 -05:00
|
|
|
else:
|
2009-08-27 08:51:48 -05:00
|
|
|
old_entry_attrs['objectclass'].append('posixgroup')
|
|
|
|
entry_attrs['objectclass'] = old_entry_attrs['objectclass']
|
2010-06-25 15:14:46 -05:00
|
|
|
if not 'gidnumber' in options:
|
|
|
|
entry_attrs['gidnumber'] = 999
|
2011-11-28 11:31:45 -06:00
|
|
|
# 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='gid')
|
2009-08-27 08:51:48 -05:00
|
|
|
return dn
|
2009-05-11 08:49:07 -05:00
|
|
|
|
2011-11-28 11:31:45 -06:00
|
|
|
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.
|
2012-04-19 07:06:32 -05:00
|
|
|
if call_func.func_name == 'update_entry':
|
|
|
|
if isinstance(exc, errors.ObjectclassViolation):
|
|
|
|
if 'gidNumber' in exc.message and 'posixGroup' in exc.message:
|
|
|
|
raise errors.RequirementError(name='gid')
|
2011-11-28 11:31:45 -06:00
|
|
|
raise exc
|
|
|
|
|
2009-06-16 07:38:27 -05:00
|
|
|
api.register(group_mod)
|
2009-05-11 08:49:07 -05:00
|
|
|
|
|
|
|
|
2009-08-27 08:51:48 -05:00
|
|
|
class group_find(LDAPSearch):
|
2011-08-24 21:48:30 -05:00
|
|
|
__doc__ = _('Search for groups.')
|
|
|
|
|
2011-01-04 14:15:54 -06:00
|
|
|
member_attributes = ['member', 'memberof']
|
2010-12-02 18:24:11 -06:00
|
|
|
|
2009-12-09 10:09:53 -06:00
|
|
|
msg_summary = ngettext(
|
|
|
|
'%(count)d group matched', '%(count)d groups matched', 0
|
|
|
|
)
|
|
|
|
|
2010-09-21 12:03:40 -05:00
|
|
|
takes_options = LDAPSearch.takes_options + (
|
|
|
|
Flag('private',
|
|
|
|
cli_name='private',
|
|
|
|
doc=_('search for private groups'),
|
|
|
|
),
|
|
|
|
)
|
|
|
|
|
2010-11-23 08:02:54 -06:00
|
|
|
def pre_callback(self, ldap, filter, attrs_list, base_dn, scope, *args, **options):
|
2010-09-21 12:03:40 -05:00
|
|
|
# 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()[1]
|
|
|
|
attrs = config.get(self.obj.search_attributes_config, [])
|
|
|
|
if len(attrs) == 1 and isinstance(attrs[0], basestring):
|
|
|
|
search_attrs = attrs[0].split(',')
|
|
|
|
for a in search_attrs:
|
|
|
|
search_kw[a] = args[-1]
|
|
|
|
cflt = ldap.make_filter(search_kw, exact=False)
|
|
|
|
|
|
|
|
filter = ldap.combine_filters((oflt, cflt), rules=ldap.MATCH_ALL)
|
2010-11-23 08:02:54 -06:00
|
|
|
return (filter, base_dn, scope)
|
2010-09-21 12:03:40 -05:00
|
|
|
|
2009-06-16 07:38:27 -05:00
|
|
|
api.register(group_find)
|
2009-05-11 08:49:07 -05:00
|
|
|
|
|
|
|
|
2009-08-27 08:51:48 -05:00
|
|
|
class group_show(LDAPRetrieve):
|
2011-08-24 21:48:30 -05:00
|
|
|
__doc__ = _('Display information about a named group.')
|
2009-05-12 11:40:14 -05:00
|
|
|
|
2009-06-16 07:38:27 -05:00
|
|
|
api.register(group_show)
|
2009-05-11 08:49:07 -05:00
|
|
|
|
|
|
|
|
2009-08-27 08:51:48 -05:00
|
|
|
class group_add_member(LDAPAddMember):
|
2011-08-24 21:48:30 -05:00
|
|
|
__doc__ = _('Add members to a group.')
|
2009-05-12 11:40:14 -05:00
|
|
|
|
2009-06-16 07:38:27 -05:00
|
|
|
api.register(group_add_member)
|
2009-05-11 08:49:07 -05:00
|
|
|
|
|
|
|
|
2009-08-27 08:51:48 -05:00
|
|
|
class group_remove_member(LDAPRemoveMember):
|
2011-08-24 21:48:30 -05:00
|
|
|
__doc__ = _('Remove members from a group.')
|
2009-05-12 11:40:14 -05:00
|
|
|
|
2009-07-09 12:02:44 -05:00
|
|
|
api.register(group_remove_member)
|
2010-08-09 15:40:51 -05:00
|
|
|
|
|
|
|
|
2010-11-29 16:09:35 -06:00
|
|
|
class group_detach(LDAPQuery):
|
2011-08-24 21:48:30 -05:00
|
|
|
__doc__ = _('Detach a managed group from a user.')
|
|
|
|
|
2010-08-09 15:40:51 -05:00
|
|
|
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)
|
|
|
|
|
2011-02-02 08:29:38 -06:00
|
|
|
try:
|
|
|
|
(user_dn, user_attrs) = ldap.get_entry(user_dn)
|
|
|
|
except errors.NotFound:
|
|
|
|
self.obj.handle_not_found(*keys)
|
2010-11-01 11:05:53 -05:00
|
|
|
is_managed = self.obj.has_objectclass(user_attrs['objectclass'], 'mepmanagedentry')
|
2010-08-09 15:40:51 -05:00
|
|
|
if (not ldap.can_write(user_dn, "objectclass") or
|
2010-11-01 11:05:53 -05:00
|
|
|
not (ldap.can_write(user_dn, "mepManagedEntry")) and is_managed):
|
2010-08-09 15:40:51 -05:00
|
|
|
raise errors.ACIError(info=_('not allowed to modify user entries'))
|
|
|
|
|
2010-11-01 11:05:53 -05:00
|
|
|
(group_dn, group_attrs) = ldap.get_entry(group_dn)
|
|
|
|
is_managed = self.obj.has_objectclass(group_attrs['objectclass'], 'mepmanagedby')
|
2010-08-09 15:40:51 -05:00
|
|
|
if (not ldap.can_write(group_dn, "objectclass") or
|
2010-11-01 11:05:53 -05:00
|
|
|
not (ldap.can_write(group_dn, "mepManagedBy")) and is_managed):
|
2010-08-09 15:40:51 -05:00
|
|
|
raise errors.ACIError(info=_('not allowed to modify group entries'))
|
|
|
|
|
|
|
|
objectclasses = user_attrs['objectclass']
|
|
|
|
try:
|
|
|
|
i = objectclasses.index('mepOriginEntry')
|
2010-11-01 11:05:53 -05:00
|
|
|
del objectclasses[i]
|
|
|
|
update_attrs = {'objectclass': objectclasses, 'mepManagedEntry': None}
|
|
|
|
ldap.update_entry(user_dn, update_attrs)
|
2010-08-09 15:40:51 -05:00
|
|
|
except ValueError:
|
2010-11-01 11:05:53 -05:00
|
|
|
# Somehow the user isn't managed, let it pass for now. We'll
|
|
|
|
# let the group throw "Not managed".
|
|
|
|
pass
|
2010-08-09 15:40:51 -05:00
|
|
|
|
|
|
|
(group_dn, group_attrs) = ldap.get_entry(group_dn)
|
|
|
|
objectclasses = group_attrs['objectclass']
|
|
|
|
try:
|
|
|
|
i = objectclasses.index('mepManagedEntry')
|
|
|
|
except ValueError:
|
|
|
|
# this should never happen
|
2010-11-01 11:05:53 -05:00
|
|
|
raise errors.NotFound(reason=_('Not a managed group'))
|
2010-08-09 15:40:51 -05:00
|
|
|
del objectclasses[i]
|
2010-11-01 11:05:53 -05:00
|
|
|
|
|
|
|
# Make sure the resulting group has the default group objectclasses
|
|
|
|
config = ldap.get_ipa_config()[1]
|
|
|
|
def_objectclass = config.get(
|
|
|
|
self.obj.object_class_config, objectclasses
|
|
|
|
)
|
|
|
|
objectclasses = list(set(def_objectclass + objectclasses))
|
|
|
|
|
2010-08-09 15:40:51 -05:00
|
|
|
update_attrs = {'objectclass': objectclasses, 'mepManagedBy': None}
|
|
|
|
ldap.update_entry(group_dn, update_attrs)
|
|
|
|
|
|
|
|
return dict(
|
|
|
|
result=True,
|
|
|
|
value=keys[0],
|
|
|
|
)
|
|
|
|
|
|
|
|
api.register(group_detach)
|