API: add new commands for passkey mappings

- ipa user-add-passkey
- ipa user-remove-passkey
- ipa stageuser-add-passkey
- ipa stageuser-remove-passkey

Fixes: https://pagure.io/freeipa/issue/9261
Signed-off-by: Florence Blanc-Renaud <flo@redhat.com>
Reviewed-By: Alexander Bokovoy <abokovoy@redhat.com>
This commit is contained in:
Florence Blanc-Renaud 2022-09-05 15:39:58 +02:00
parent 4bd1be9e90
commit a21214cb9e
15 changed files with 413 additions and 4 deletions

View File

@ -387,6 +387,8 @@ aci: (targetattr = "krbpasswordexpiration || krbprincipalkey || passwordhistory
dn: cn=users,cn=accounts,dc=ipa,dc=example
aci: (targetattr = "krbpasswordexpiration || krbprincipalkey || passwordhistory || sambalmpassword || sambantpassword || userpassword")(targetfilter = "(&(!(memberOf=cn=admins,cn=groups,cn=accounts,dc=ipa,dc=example))(objectclass=posixaccount))")(version 3.0;acl "permission:System: Change User password";allow (write) groupdn = "ldap:///cn=System: Change User password,cn=permissions,cn=pbac,dc=ipa,dc=example";)
dn: cn=users,cn=accounts,dc=ipa,dc=example
aci: (targetattr = "ipapasskey || objectclass")(targetfilter = "(objectclass=posixaccount)")(version 3.0;acl "permission:System: Manage Passkey Mappings";allow (write) groupdn = "ldap:///cn=System: Manage Passkey Mappings,cn=permissions,cn=pbac,dc=ipa,dc=example";)
dn: cn=users,cn=accounts,dc=ipa,dc=example
aci: (targetattr = "ipacertmapdata || objectclass")(targetfilter = "(objectclass=posixaccount)")(version 3.0;acl "permission:System: Manage User Certificate Mappings";allow (write) groupdn = "ldap:///cn=System: Manage User Certificate Mappings,cn=permissions,cn=pbac,dc=ipa,dc=example";)
dn: cn=users,cn=accounts,dc=ipa,dc=example
aci: (targetattr = "usercertificate")(targetfilter = "(&(!(memberOf=cn=admins,cn=groups,cn=accounts,dc=ipa,dc=example))(objectclass=posixaccount))")(version 3.0;acl "permission:System: Manage User Certificates";allow (write) groupdn = "ldap:///cn=System: Manage User Certificates,cn=permissions,cn=pbac,dc=ipa,dc=example";)
@ -403,7 +405,7 @@ aci: (targetattr = "audio || businesscategory || carlicense || departmentnumber
dn: dc=ipa,dc=example
aci: (targetattr = "cn || createtimestamp || entryusn || gecos || gidnumber || homedirectory || loginshell || modifytimestamp || objectclass || uid || uidnumber")(target = "ldap:///cn=users,cn=compat,dc=ipa,dc=example")(version 3.0;acl "permission:System: Read User Compat Tree";allow (compare,read,search) userdn = "ldap:///anyone";)
dn: cn=users,cn=accounts,dc=ipa,dc=example
aci: (targetattr = "ipasshpubkey || ipauniqueid || ipauserauthtype || userclass")(targetfilter = "(objectclass=posixaccount)")(version 3.0;acl "permission:System: Read User IPA Attributes";allow (compare,read,search) userdn = "ldap:///all";)
aci: (targetattr = "ipapasskey || ipasshpubkey || ipauniqueid || ipauserauthtype || userclass")(targetfilter = "(objectclass=posixaccount)")(version 3.0;acl "permission:System: Read User IPA Attributes";allow (compare,read,search) userdn = "ldap:///all";)
dn: cn=users,cn=accounts,dc=ipa,dc=example
aci: (targetattr = "krbcanonicalname || krblastpwdchange || krbpasswordexpiration || krbprincipalaliases || krbprincipalexpiration || krbprincipalname || krbprincipaltype || nsaccountlock")(targetfilter = "(objectclass=posixaccount)")(version 3.0;acl "permission:System: Read User Kerberos Attributes";allow (compare,read,search) userdn = "ldap:///all";)
dn: cn=users,cn=accounts,dc=ipa,dc=example

48
API.txt
View File

@ -5282,6 +5282,17 @@ option: Str('version?')
output: Output('completed', type=[<type 'int'>])
output: Output('failed', type=[<type 'dict'>])
output: Entry('result')
command: stageuser_add_passkey/1
args: 2,4,3
arg: Str('uid', cli_name='login')
arg: Str('ipapasskey+', alwaysask=True, cli_name='passkey')
option: Flag('all', autofill=True, cli_name='all', default=False)
option: Flag('no_members', autofill=True, default=False)
option: Flag('raw', autofill=True, cli_name='raw', default=False)
option: Str('version?')
output: Entry('result')
output: Output('summary', type=[<type 'unicode'>, <type 'NoneType'>])
output: PrimaryKey('value')
command: stageuser_add_principal/1
args: 2,4,3
arg: Str('uid', cli_name='login')
@ -5465,6 +5476,17 @@ option: Str('version?')
output: Output('completed', type=[<type 'int'>])
output: Output('failed', type=[<type 'dict'>])
output: Entry('result')
command: stageuser_remove_passkey/1
args: 2,4,3
arg: Str('uid', cli_name='login')
arg: Str('ipapasskey+', alwaysask=True, cli_name='passkey')
option: Flag('all', autofill=True, cli_name='all', default=False)
option: Flag('no_members', autofill=True, default=False)
option: Flag('raw', autofill=True, cli_name='raw', default=False)
option: Str('version?')
output: Entry('result')
output: Output('summary', type=[<type 'unicode'>, <type 'NoneType'>])
output: PrimaryKey('value')
command: stageuser_remove_principal/1
args: 2,4,3
arg: Str('uid', cli_name='login')
@ -6469,6 +6491,17 @@ option: Str('version?')
output: Output('completed', type=[<type 'int'>])
output: Output('failed', type=[<type 'dict'>])
output: Entry('result')
command: user_add_passkey/1
args: 2,4,3
arg: Str('uid', cli_name='login')
arg: Str('ipapasskey+', alwaysask=True, cli_name='passkey')
option: Flag('all', autofill=True, cli_name='all', default=False)
option: Flag('no_members', autofill=True, default=False)
option: Flag('raw', autofill=True, cli_name='raw', default=False)
option: Str('version?')
output: Entry('result')
output: Output('summary', type=[<type 'unicode'>, <type 'NoneType'>])
output: PrimaryKey('value')
command: user_add_principal/1
args: 2,4,3
arg: Str('uid', cli_name='login')
@ -6671,6 +6704,17 @@ option: Str('version?')
output: Output('completed', type=[<type 'int'>])
output: Output('failed', type=[<type 'dict'>])
output: Entry('result')
command: user_remove_passkey/1
args: 2,4,3
arg: Str('uid', cli_name='login')
arg: Str('ipapasskey+', alwaysask=True, cli_name='passkey')
option: Flag('all', autofill=True, cli_name='all', default=False)
option: Flag('no_members', autofill=True, default=False)
option: Flag('raw', autofill=True, cli_name='raw', default=False)
option: Str('version?')
output: Entry('result')
output: Output('summary', type=[<type 'unicode'>, <type 'NoneType'>])
output: PrimaryKey('value')
command: user_remove_principal/1
args: 2,4,3
arg: Str('uid', cli_name='login')
@ -7445,6 +7489,7 @@ default: stageuser_add/1
default: stageuser_add_cert/1
default: stageuser_add_certmapdata/1
default: stageuser_add_manager/1
default: stageuser_add_passkey/1
default: stageuser_add_principal/1
default: stageuser_del/1
default: stageuser_find/1
@ -7452,6 +7497,7 @@ default: stageuser_mod/1
default: stageuser_remove_cert/1
default: stageuser_remove_certmapdata/1
default: stageuser_remove_manager/1
default: stageuser_remove_passkey/1
default: stageuser_remove_principal/1
default: stageuser_show/1
default: subid/1
@ -7540,6 +7586,7 @@ default: user_add/1
default: user_add_cert/1
default: user_add_certmapdata/1
default: user_add_manager/1
default: user_add_passkey/1
default: user_add_principal/1
default: user_del/1
default: user_disable/1
@ -7549,6 +7596,7 @@ default: user_mod/1
default: user_remove_cert/1
default: user_remove_certmapdata/1
default: user_remove_manager/1
default: user_remove_passkey/1
default: user_remove_principal/1
default: user_show/1
default: user_stage/1

View File

@ -382,6 +382,7 @@ IPA API Commands
stageuser_add_cert.md
stageuser_add_certmapdata.md
stageuser_add_manager.md
stageuser_add_passkey.md
stageuser_add_principal.md
stageuser_del.md
stageuser_find.md
@ -389,6 +390,7 @@ IPA API Commands
stageuser_remove_cert.md
stageuser_remove_certmapdata.md
stageuser_remove_manager.md
stageuser_remove_passkey.md
stageuser_remove_principal.md
stageuser_show.md
subid_add.md
@ -466,6 +468,7 @@ IPA API Commands
user_add_cert.md
user_add_certmapdata.md
user_add_manager.md
user_add_passkey.md
user_add_principal.md
user_del.md
user_disable.md
@ -475,6 +478,7 @@ IPA API Commands
user_remove_cert.md
user_remove_certmapdata.md
user_remove_manager.md
user_remove_passkey.md
user_remove_principal.md
user_show.md
user_stage.md

View File

@ -0,0 +1,32 @@
[//]: # (THE CONTENT BELOW IS GENERATED. DO NOT EDIT.)
# stageuser_add_passkey
Add one or more passkey mappings to the stage user entry.
### Arguments
|Name|Type|Required
|-|-|-
|uid|:ref:`Str<Str>`|True
|ipapasskey|:ref:`Str<Str>`|True
### Options
* all : :ref:`Flag<Flag>` **(Required)**
* Default: False
* raw : :ref:`Flag<Flag>` **(Required)**
* Default: False
* no_members : :ref:`Flag<Flag>` **(Required)**
* Default: False
* version : :ref:`Str<Str>`
### Output
|Name|Type
|-|-
|result|Entry
|summary|Output
|value|PrimaryKey
[//]: # (ADD YOUR NOTES BELOW. THESE WILL BE PICKED EVERY TIME THE DOCS ARE REGENERATED. //end)
### Semantics
### Notes
### Version differences

View File

@ -0,0 +1,32 @@
[//]: # (THE CONTENT BELOW IS GENERATED. DO NOT EDIT.)
# stageuser_remove_passkey
Remove one or more passkey mappings from the stage user entry.
### Arguments
|Name|Type|Required
|-|-|-
|uid|:ref:`Str<Str>`|True
|ipapasskey|:ref:`Str<Str>`|True
### Options
* all : :ref:`Flag<Flag>` **(Required)**
* Default: False
* raw : :ref:`Flag<Flag>` **(Required)**
* Default: False
* no_members : :ref:`Flag<Flag>` **(Required)**
* Default: False
* version : :ref:`Str<Str>`
### Output
|Name|Type
|-|-
|result|Entry
|summary|Output
|value|PrimaryKey
[//]: # (ADD YOUR NOTES BELOW. THESE WILL BE PICKED EVERY TIME THE DOCS ARE REGENERATED. //end)
### Semantics
### Notes
### Version differences

View File

@ -0,0 +1,32 @@
[//]: # (THE CONTENT BELOW IS GENERATED. DO NOT EDIT.)
# user_add_passkey
Add one or more passkey mappings to the user entry.
### Arguments
|Name|Type|Required
|-|-|-
|uid|:ref:`Str<Str>`|True
|ipapasskey|:ref:`Str<Str>`|True
### Options
* all : :ref:`Flag<Flag>` **(Required)**
* Default: False
* raw : :ref:`Flag<Flag>` **(Required)**
* Default: False
* no_members : :ref:`Flag<Flag>` **(Required)**
* Default: False
* version : :ref:`Str<Str>`
### Output
|Name|Type
|-|-
|result|Entry
|summary|Output
|value|PrimaryKey
[//]: # (ADD YOUR NOTES BELOW. THESE WILL BE PICKED EVERY TIME THE DOCS ARE REGENERATED. //end)
### Semantics
### Notes
### Version differences

View File

@ -0,0 +1,32 @@
[//]: # (THE CONTENT BELOW IS GENERATED. DO NOT EDIT.)
# user_remove_passkey
Remove one or more passkey mappings from the user entry.
### Arguments
|Name|Type|Required
|-|-|-
|uid|:ref:`Str<Str>`|True
|ipapasskey|:ref:`Str<Str>`|True
### Options
* all : :ref:`Flag<Flag>` **(Required)**
* Default: False
* raw : :ref:`Flag<Flag>` **(Required)**
* Default: False
* no_members : :ref:`Flag<Flag>` **(Required)**
* Default: False
* version : :ref:`Str<Str>`
### Output
|Name|Type
|-|-
|result|Entry
|summary|Output
|value|PrimaryKey
[//]: # (ADD YOUR NOTES BELOW. THESE WILL BE PICKED EVERY TIME THE DOCS ARE REGENERATED. //end)
### Semantics
### Notes
### Version differences

View File

@ -12,3 +12,6 @@ default:objectClass: groupofnames
default:objectClass: nestedgroup
default:cn: Passkey Administrators
default:description: Passkey Administrators
dn: $SUFFIX
add:aci: (targetattr = "ipapasskey")(targattrfilters="add=objectclass:(objectclass=ipapasskeyuser)")(version 3.0;acl "selfservice:Users can manage their own passkey mappings";allow (write) userdn = "ldap:///self";)

View File

@ -0,0 +1,93 @@
#
# Copyright (C) 2022 FreeIPA Contributors see COPYING for license
#
import os
import locale
import logging
import subprocess
from ipaclient.frontend import MethodOverride
from ipalib import errors
from ipalib import Bool, Flag, StrEnum
from ipalib.text import _
from ipaplatform.paths import paths
logger = logging.getLogger(__name__)
class baseuser_add_passkey(MethodOverride):
takes_options = (
Flag(
'register',
cli_name='register',
doc=_('Register the passkey'),
),
Bool(
'require_user_verification?',
cli_name='require_user_verification',
doc=_('Require user verification during authentication with '
'the passkey')
),
StrEnum(
'cosetype?',
cli_name='cose_type',
doc=_('COSE type to use for registration'),
values=('es256', 'rs256', 'eddsa'),
),
)
def get_args(self):
# ipapasskey is not mandatory as it can be built
# from the registration step
for arg in super(baseuser_add_passkey, self).get_args():
if arg.name == 'ipapasskey':
yield arg.clone(required=False, alwaysask=False)
else:
yield arg.clone()
def forward(self, *args, **options):
if self.api.env.context == 'cli':
# 2 formats are possible for ipa user-add-passkey:
# --register [--require-user-verification] [--cose-type ...]
# or
# passkey:<key id>,<pub key>
for option in super(baseuser_add_passkey, self).get_options():
if args and option in options:
raise errors.MutuallyExclusiveError(
reason=_("cannot specify both %s and "
"passkey mapping").format(option))
# if the first format is used, need to register the key first
# and obtained the data
if 'register' in options:
# Ensure the executable exists
if not os.path.exists(paths.PASSKEY_CHILD):
raise errors.ValidationError(name="register", error=_(
"Missing executable %s, use the command with "
"LOGIN PASSKEY instead of LOGIN --register")
% paths.PASSKEY_CHILD)
options.pop('register')
cosetype = options.pop('cosetype', None)
require_verif = options.pop('require_user_verification', None)
cmd = [paths.PASSKEY_CHILD, "--register",
"--domain", self.api.env.domain,
"--username", args[0]]
if cosetype:
cmd.append("--type")
cmd.append(cosetype)
if require_verif is not None:
cmd.append("--user-verification")
cmd.append(str(require_verif).lower())
logger.debug("Executing command: %s", cmd)
subp = subprocess.Popen(cmd, stdout=subprocess.PIPE)
stdout, _stderr = subp.communicate(None)
if subp.returncode != 0:
raise errors.NotFound(reason="Failed to generate passkey")
passkey = stdout.decode(locale.getpreferredencoding(),
errors='replace').strip()
args = (args[0], [passkey])
return super(baseuser_add_passkey, self).forward(*args, **options)

View File

@ -0,0 +1,14 @@
#
# Copyright (C) 2022 FreeIPA Contributors see COPYING for license
#
from ipaclient.plugins.baseuser import baseuser_add_passkey
from ipalib.plugable import Registry
from ipalib import _
register = Registry()
@register(override=True, no_fail=True)
class stageuser_add_passkey(baseuser_add_passkey):
__doc__ = _("Add one or more passkey mappings to the user entry.")

View File

@ -19,6 +19,7 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from ipaclient.frontend import MethodOverride
from ipaclient.plugins.baseuser import baseuser_add_passkey
from ipalib import errors
from ipalib import Flag
from ipalib import util
@ -79,3 +80,8 @@ class user_show(MethodOverride):
raise errors.NoCertificateError(entry=keys[-1])
else:
return super(user_show, self).forward(*keys, **options)
@register(override=True, no_fail=True)
class user_add_passkey(baseuser_add_passkey):
__doc__ = _("Add one or more passkey mappings to the user entry.")

View File

@ -463,6 +463,7 @@ class BasePathNamespace:
"/var/lib/gssproxy/ipa_ccache_sweeper.sock"
)
PAM_CONFIG = None
PASSKEY_CHILD = '/usr/libexec/sssd/passkey_child'
def check_paths(self):
"""Check paths for missing files

View File

@ -17,6 +17,10 @@
# 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 base64
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives.serialization import load_pem_public_key
import re
import six
from ipalib import api, errors, constants
@ -157,6 +161,35 @@ def update_samba_attrs(ldap, dn, entry_attrs, **options):
)
def validate_passkey(ugettext, key):
"""
Validate the format for passkey mappings.
The expected format is passkey:<key id>,<pubkey>
"""
pattern = re.compile(r'passkey:(?P<id>.*),(?P<pkey>.*)')
result = re.match(pattern, key)
if result is None:
return '"%s" is not a valid passkey mapping' % key
# Validate the id part
try:
base64.b64decode(result.group('id'))
except Exception:
return '"%s" is not a valid passkey mapping, invalid id' % key
# Validate the pkey part
try:
pem = "-----BEGIN PUBLIC KEY-----\n" + \
result.group('pkey') + \
"\n-----END PUBLIC KEY-----"
load_pem_public_key(data=pem.encode('utf-8'),
backend=default_backend())
except ValueError:
return '"%s" is not a valid passkey mapping, invalid key' % key
return None
class baseuser(LDAPObject):
"""
baseuser object.
@ -170,7 +203,7 @@ class baseuser(LDAPObject):
possible_objectclasses = [
'meporiginentry', 'ipauserauthtypeclass', 'ipauser',
'ipatokenradiusproxyuser', 'ipacertmapobject',
'ipantuserattrs', 'ipaidpuser',
'ipantuserattrs', 'ipaidpuser', 'ipapasskeyuser',
]
disallow_object_classes = ['krbticketpolicyaux']
permission_filter_objectclasses = ['posixaccount']
@ -186,6 +219,7 @@ class baseuser(LDAPObject):
'krbprincipalname', 'krbcanonicalname',
'ipacertmapdata', 'ipantlogonscript', 'ipantprofilepath',
'ipanthomedirectory', 'ipanthomedirectorydrive',
'ipapasskey',
]
search_display_attributes = [
'uid', 'givenname', 'sn', 'homedirectory', 'krbcanonicalname',
@ -451,6 +485,12 @@ class baseuser(LDAPObject):
'J:', 'K:', 'L:', 'M:', 'N:', 'O:', 'P:', 'Q:', 'R:',
'S:', 'T:', 'U:', 'V:', 'W:', 'X:', 'Y:', 'Z:'),
),
Str('ipapasskey*', validate_passkey,
cli_name='passkey',
label=_('Passkey mapping'),
doc=_('Passkey mapping'),
flags=['no_create', 'no_update', 'no_search'],
),
)
def normalize_and_validate_email(self, email, config=None):
@ -1011,3 +1051,30 @@ class baseuser_remove_certmapdata(ModCertMapData,
LDAPRemoveAttribute):
__doc__ = _("Remove one or more certificate mappings from the user entry.")
msg_summary = _('Removed certificate mappings from user "%(value)s"')
class ModPassKey(LDAPModAttribute):
attribute = 'ipapasskey'
class baseuser_add_passkey(ModPassKey, LDAPAddAttribute):
__doc__ = _("Add one or more passkey mappings to the user entry.")
msg_summary = _('Added passkey mappings to user "%(value)s"')
def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys,
**options):
dn = super(baseuser_add_passkey, self).pre_callback(
ldap, dn, entry_attrs, attrs_list, *keys, **options)
# The objectclass ipafpasskeyuser may not be present on
# existing user entries. We need to add it if we define a new
# value for ipapasskey
add_missing_object_class(ldap, u'ipapasskeyuser', dn)
return dn
class baseuser_remove_passkey(ModPassKey, LDAPRemoveAttribute):
__doc__ = _("Remove one or more passkey mappings from the user entry.")
msg_summary = _('Removed passkey mappings from user "%(value)s"')

View File

@ -49,7 +49,9 @@ from .baseuser import (
baseuser_add_manager,
baseuser_remove_manager,
baseuser_add_certmapdata,
baseuser_remove_certmapdata)
baseuser_remove_certmapdata,
baseuser_add_passkey,
baseuser_remove_passkey)
from ipalib.request import context
from ipalib.util import set_krbcanonicalname
from ipalib import _, ngettext
@ -819,3 +821,15 @@ class stageuser_add_certmapdata(baseuser_add_certmapdata):
class stageuser_remove_certmapdata(baseuser_remove_certmapdata):
__doc__ = _("Remove one or more certificate mappings from the stage user"
" entry.")
@register()
class stageuser_add_passkey(baseuser_add_passkey):
__doc__ = _("Add one or more passkey mappings to the stage user"
" entry.")
@register()
class stageuser_remove_passkey(baseuser_remove_passkey):
__doc__ = _("Remove one or more passkey mappings from the stage user"
" entry.")

View File

@ -51,6 +51,8 @@ from .baseuser import (
baseuser_remove_principal,
baseuser_add_certmapdata,
baseuser_remove_certmapdata,
baseuser_add_passkey,
baseuser_remove_passkey,
)
from .idviews import remove_ipaobject_overrides
from ipalib.plugable import Registry
@ -210,6 +212,7 @@ class user(baseuser):
'ipapermright': {'read', 'search', 'compare'},
'ipapermdefaultattr': {
'ipauniqueid', 'ipasshpubkey', 'ipauserauthtype', 'userclass',
'ipapasskey',
},
'fixup_function': fix_addressbook_permission_bindrule,
},
@ -430,6 +433,14 @@ class user(baseuser):
'Certificate Identity Mapping Administrators'
},
},
'System: Manage Passkey Mappings': {
'ipapermright': {'write'},
'ipapermdefaultattr': {'ipapasskey', 'objectclass'},
'default_privileges': {
'Passkey Administrators'
},
},
}
takes_params = baseuser.takes_params + (
@ -1019,13 +1030,15 @@ class user_stage(LDAPMultiQuery):
# ipauniqueid, krbcanonicalname, sshpubkeyfp, krbextradata
# are automatically generated
# ipacertmapdata can only be provided with user_add_certmapdata
# ipapasskey can only be provided with user_add_passkey
ignore_attrs = [u'dn', u'uid',
u'has_keytab', u'has_password', u'preserved',
u'ipauniqueid', u'krbcanonicalname',
u'sshpubkeyfp', u'krbextradata',
u'ipacertmapdata',
'ipantsecurityidentifier',
u'nsaccountlock']
u'nsaccountlock',
u'ipapasskey']
def execute(self, *keys, **options):
@ -1079,6 +1092,12 @@ class user_stage(LDAPMultiQuery):
self.api.Command.stageuser_add_certmapdata(
*single_keys,
ipacertmapdata=certmapdata)
# special handling for passkey
passkey = user.get(u'ipapasskey')
if passkey:
self.api.Command.stageuser_add_passkey(
*single_keys,
ipapasskey=passkey)
try:
self.api.Command.user_del(*multi_keys, preserve=False)
except errors.ExecutionError:
@ -1360,3 +1379,13 @@ class user_add_principal(baseuser_add_principal):
class user_remove_principal(baseuser_remove_principal):
__doc__ = _('Remove principal alias from the user entry')
msg_summary = _('Removed aliases from user "%(value)s"')
@register()
class user_add_passkey(baseuser_add_passkey):
__doc__ = _("Add one or more passkey mappings to the user entry.")
@register()
class user_remove_passkey(baseuser_remove_passkey):
__doc__ = _("Remove one or more passkey mappings from the user entry.")