From 0de7e502b0cf19911dcc1bb1f84bcb7dc2738037 Mon Sep 17 00:00:00 2001
From: Christian Heimes <cheimes@redhat.com>
Date: Fri, 19 Mar 2021 11:48:38 +0100
Subject: [PATCH] Add basic support for subordinate user/group ids

New LDAP object class "ipaUserSubordinate" with four new fields:
- ipasubuidnumber / ipasubuidcount
- ipasubgidnumber / ipasgbuidcount

New self-service permission to add subids.

New command user-auto-subid to auto-assign subid

The code hard-codes counts to 65536, sets subgid equal to subuid, and
does not allow removal of subids. There is also a hack that emulates a
DNA plugin with step interval 65536 for testing.

See: https://pagure.io/freeipa/issue/8361
Signed-off-by: Christian Heimes <cheimes@redhat.com>
---
 ACI.txt                           |   4 +-
 API.txt                           |  31 ++++--
 doc/designs/subordinate-ids.md    |  16 +++
 install/share/60basev3.ldif       |   5 +
 install/ui/src/freeipa/user.js    |  53 +++++++++-
 install/updates/20-indices.update |  16 +++
 install/updates/73-subid.update   |   7 ++
 install/updates/Makefile.am       |   1 +
 ipalib/constants.py               |   5 +
 ipaserver/plugins/baseuser.py     | 165 +++++++++++++++++++++++++++++-
 ipaserver/plugins/internal.py     |  12 +++
 ipaserver/plugins/user.py         |  19 +++-
 12 files changed, 320 insertions(+), 14 deletions(-)
 create mode 100644 doc/designs/subordinate-ids.md
 create mode 100644 install/updates/73-subid.update

diff --git a/ACI.txt b/ACI.txt
index bb725637442..2b616cd0a78 100644
--- a/ACI.txt
+++ b/ACI.txt
@@ -367,6 +367,8 @@ aci: (targetattr = "krbcanonicalname || krbprincipalname")(targetfilter = "(&(!(
 dn: cn=users,cn=accounts,dc=ipa,dc=example
 aci: (targetattr = "ipasshpubkey")(targetfilter = "(&(!(memberOf=cn=admins,cn=groups,cn=accounts,dc=ipa,dc=example))(objectclass=posixaccount))")(version 3.0;acl "permission:System: Manage User SSH Public Keys";allow (write) groupdn = "ldap:///cn=System: Manage User SSH Public Keys,cn=permissions,cn=pbac,dc=ipa,dc=example";)
 dn: cn=users,cn=accounts,dc=ipa,dc=example
+aci: (targetattr = "ipasubgidcount || ipasubgidnumber || ipasubuidcount || ipasubuidnumber")(targetfilter = "(objectclass=posixaccount)")(version 3.0;acl "permission:System: Manage user subordinate ids";allow (write) groupdn = "ldap:///cn=System: Manage user subordinate ids,cn=permissions,cn=pbac,dc=ipa,dc=example";)
+dn: cn=users,cn=accounts,dc=ipa,dc=example
 aci: (targetattr = "businesscategory || carlicense || cn || departmentnumber || description || displayname || employeenumber || employeetype || facsimiletelephonenumber || gecos || givenname || homedirectory || homephone || inetuserhttpurl || initials || l || labeleduri || loginshell || mail || manager || mepmanagedentry || mobile || objectclass || ou || pager || postalcode || preferredlanguage || roomnumber || secretary || seealso || sn || st || street || telephonenumber || title || userclass")(targetfilter = "(&(!(memberOf=cn=admins,cn=groups,cn=accounts,dc=ipa,dc=example))(objectclass=posixaccount))")(version 3.0;acl "permission:System: Modify Users";allow (write) groupdn = "ldap:///cn=System: Modify Users,cn=permissions,cn=pbac,dc=ipa,dc=example";)
 dn: cn=UPG Definition,cn=Definitions,cn=Managed Entries,cn=etc,dc=ipa,dc=example
 aci: (targetattr = "*")(target = "ldap:///cn=UPG Definition,cn=Definitions,cn=Managed Entries,cn=etc,dc=ipa,dc=example")(version 3.0;acl "permission:System: Read UPG Definition";allow (compare,read,search) groupdn = "ldap:///cn=System: Read UPG Definition,cn=permissions,cn=pbac,dc=ipa,dc=example";)
@@ -375,7 +377,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 = "ipasshpubkey || ipasubgidcount || ipasubgidnumber || ipasubuidcount || ipasubuidnumber || 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
diff --git a/API.txt b/API.txt
index a71a9306ff0..0dc239ca891 100644
--- a/API.txt
+++ b/API.txt
@@ -4970,7 +4970,7 @@ output: Entry('result')
 output: Output('summary', type=[<type 'unicode'>, <type 'NoneType'>])
 output: PrimaryKey('value')
 command: stageuser_add/1
-args: 1,45,3
+args: 1,46,3
 arg: Str('uid', cli_name='login')
 option: Str('addattr*', cli_name='addattr')
 option: Flag('all', autofill=True, cli_name='all', default=False)
@@ -4988,6 +4988,7 @@ option: Str('givenname', cli_name='first')
 option: Str('homedirectory?', cli_name='homedir')
 option: Str('initials?', autofill=True)
 option: Str('ipasshpubkey*', cli_name='sshpubkey')
+option: Int('ipasubuidnumber?', cli_name='subuid')
 option: Str('ipatokenradiusconfiglink?', cli_name='radius')
 option: Str('ipatokenradiususername?', cli_name='radius_username')
 option: StrEnum('ipauserauthtype*', cli_name='user_auth_type', values=[u'password', u'radius', u'otp', u'pkinit', u'hardened'])
@@ -5076,7 +5077,7 @@ output: Output('result', type=[<type 'dict'>])
 output: Output('summary', type=[<type 'unicode'>, <type 'NoneType'>])
 output: ListOfPrimaryKeys('value')
 command: stageuser_find/1
-args: 1,58,4
+args: 1,60,4
 arg: Str('criteria?')
 option: Flag('all', autofill=True, cli_name='all', default=False)
 option: Str('carlicense*', autofill=False)
@@ -5100,6 +5101,8 @@ option: Str('ipanthomedirectory?', autofill=False, cli_name='smb_home_dir')
 option: StrEnum('ipanthomedirectorydrive?', autofill=False, cli_name='smb_home_drive', values=[u'A:', u'B:', u'C:', u'D:', u'E:', u'F:', u'G:', u'H:', u'I:', u'J:', u'K:', u'L:', u'M:', u'N:', u'O:', u'P:', u'Q:', u'R:', u'S:', u'T:', u'U:', u'V:', u'W:', u'X:', u'Y:', u'Z:'])
 option: Str('ipantlogonscript?', autofill=False, cli_name='smb_logon_script')
 option: Str('ipantprofilepath?', autofill=False, cli_name='smb_profile_path')
+option: Int('ipasubgidnumber?', autofill=False, cli_name='subgid')
+option: Int('ipasubuidnumber?', autofill=False, cli_name='subuid')
 option: Str('ipatokenradiusconfiglink?', autofill=False, cli_name='radius')
 option: Str('ipatokenradiususername?', autofill=False, cli_name='radius_username')
 option: StrEnum('ipauserauthtype*', autofill=False, cli_name='user_auth_type', values=[u'password', u'radius', u'otp', u'pkinit', u'hardened'])
@@ -5141,7 +5144,7 @@ output: ListOfEntries('result')
 output: Output('summary', type=[<type 'unicode'>, <type 'NoneType'>])
 output: Output('truncated', type=[<type 'bool'>])
 command: stageuser_mod/1
-args: 1,51,3
+args: 1,52,3
 arg: Str('uid', cli_name='login')
 option: Str('addattr*', cli_name='addattr')
 option: Flag('all', autofill=True, cli_name='all', default=False)
@@ -5163,6 +5166,7 @@ option: StrEnum('ipanthomedirectorydrive?', autofill=False, cli_name='smb_home_d
 option: Str('ipantlogonscript?', autofill=False, cli_name='smb_logon_script')
 option: Str('ipantprofilepath?', autofill=False, cli_name='smb_profile_path')
 option: Str('ipasshpubkey*', autofill=False, cli_name='sshpubkey')
+option: Int('ipasubuidnumber?', autofill=False, cli_name='subuid')
 option: Str('ipatokenradiusconfiglink?', autofill=False, cli_name='radius')
 option: Str('ipatokenradiususername?', autofill=False, cli_name='radius_username')
 option: StrEnum('ipauserauthtype*', autofill=False, cli_name='user_auth_type', values=[u'password', u'radius', u'otp', u'pkinit', u'hardened'])
@@ -6054,7 +6058,7 @@ output: Entry('result')
 output: Output('summary', type=[<type 'unicode'>, <type 'NoneType'>])
 output: PrimaryKey('value')
 command: user_add/1
-args: 1,46,3
+args: 1,47,3
 arg: Str('uid', cli_name='login')
 option: Str('addattr*', cli_name='addattr')
 option: Flag('all', autofill=True, cli_name='all', default=False)
@@ -6071,6 +6075,7 @@ option: Str('givenname', cli_name='first')
 option: Str('homedirectory?', cli_name='homedir')
 option: Str('initials?', autofill=True)
 option: Str('ipasshpubkey*', cli_name='sshpubkey')
+option: Int('ipasubuidnumber?', cli_name='subuid')
 option: Str('ipatokenradiusconfiglink?', cli_name='radius')
 option: Str('ipatokenradiususername?', cli_name='radius_username')
 option: StrEnum('ipauserauthtype*', cli_name='user_auth_type', values=[u'password', u'radius', u'otp', u'pkinit', u'hardened'])
@@ -6152,6 +6157,16 @@ option: Str('version?')
 output: Entry('result')
 output: Output('summary', type=[<type 'unicode'>, <type 'NoneType'>])
 output: PrimaryKey('value')
+command: user_auto_subid/1
+args: 1,4,3
+arg: Str('uid', cli_name='login')
+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_del/1
 args: 1,3,3
 arg: Str('uid+', cli_name='login')
@@ -6176,7 +6191,7 @@ output: Output('result', type=[<type 'bool'>])
 output: Output('summary', type=[<type 'unicode'>, <type 'NoneType'>])
 output: PrimaryKey('value')
 command: user_find/1
-args: 1,61,4
+args: 1,63,4
 arg: Str('criteria?')
 option: Flag('all', autofill=True, cli_name='all', default=False)
 option: Str('carlicense*', autofill=False)
@@ -6200,6 +6215,8 @@ option: Str('ipanthomedirectory?', autofill=False, cli_name='smb_home_dir')
 option: StrEnum('ipanthomedirectorydrive?', autofill=False, cli_name='smb_home_drive', values=[u'A:', u'B:', u'C:', u'D:', u'E:', u'F:', u'G:', u'H:', u'I:', u'J:', u'K:', u'L:', u'M:', u'N:', u'O:', u'P:', u'Q:', u'R:', u'S:', u'T:', u'U:', u'V:', u'W:', u'X:', u'Y:', u'Z:'])
 option: Str('ipantlogonscript?', autofill=False, cli_name='smb_logon_script')
 option: Str('ipantprofilepath?', autofill=False, cli_name='smb_profile_path')
+option: Int('ipasubgidnumber?', autofill=False, cli_name='subgid')
+option: Int('ipasubuidnumber?', autofill=False, cli_name='subuid')
 option: Str('ipatokenradiusconfiglink?', autofill=False, cli_name='radius')
 option: Str('ipatokenradiususername?', autofill=False, cli_name='radius_username')
 option: StrEnum('ipauserauthtype*', autofill=False, cli_name='user_auth_type', values=[u'password', u'radius', u'otp', u'pkinit', u'hardened'])
@@ -6244,7 +6261,7 @@ output: ListOfEntries('result')
 output: Output('summary', type=[<type 'unicode'>, <type 'NoneType'>])
 output: Output('truncated', type=[<type 'bool'>])
 command: user_mod/1
-args: 1,52,3
+args: 1,53,3
 arg: Str('uid', cli_name='login')
 option: Str('addattr*', cli_name='addattr')
 option: Flag('all', autofill=True, cli_name='all', default=False)
@@ -6266,6 +6283,7 @@ option: StrEnum('ipanthomedirectorydrive?', autofill=False, cli_name='smb_home_d
 option: Str('ipantlogonscript?', autofill=False, cli_name='smb_logon_script')
 option: Str('ipantprofilepath?', autofill=False, cli_name='smb_profile_path')
 option: Str('ipasshpubkey*', autofill=False, cli_name='sshpubkey')
+option: Int('ipasubuidnumber?', autofill=False, cli_name='subuid')
 option: Str('ipatokenradiusconfiglink?', autofill=False, cli_name='radius')
 option: Str('ipatokenradiususername?', autofill=False, cli_name='radius_username')
 option: StrEnum('ipauserauthtype*', autofill=False, cli_name='user_auth_type', values=[u'password', u'radius', u'otp', u'pkinit', u'hardened'])
@@ -7179,6 +7197,7 @@ default: user_add_cert/1
 default: user_add_certmapdata/1
 default: user_add_manager/1
 default: user_add_principal/1
+default: user_auto_subid/1
 default: user_del/1
 default: user_disable/1
 default: user_enable/1
diff --git a/doc/designs/subordinate-ids.md b/doc/designs/subordinate-ids.md
new file mode 100644
index 00000000000..593b24faffb
--- /dev/null
+++ b/doc/designs/subordinate-ids.md
@@ -0,0 +1,16 @@
+# Subordinate user and group ids
+
+## Overview
+
+https://pagure.io/freeipa/issue/8361
+
+## Implementation
+
+## Revision 1
+
+* subuid and subgids cannot be set independently. They are always set
+  to the same value.
+* counts are hard-coded to value 65536
+* subids cannot be removed
+* subids are auto-assigned. Auto-assignment is currently emulated
+  until 389-DS has been extended to support DNA with step interval.
diff --git a/install/share/60basev3.ldif b/install/share/60basev3.ldif
index efc6c8afb60..c5b64195744 100644
--- a/install/share/60basev3.ldif
+++ b/install/share/60basev3.ldif
@@ -58,6 +58,10 @@ attributeTypes: (2.16.840.1.113730.3.8.11.70 NAME 'ipaPermTargetTo' DESC 'Destin
 attributeTypes: (2.16.840.1.113730.3.8.11.71 NAME 'ipaPermTargetFrom' DESC 'Source location from where moving an entry IPA permission ACI' EQUALITY distinguishedNameMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.12 SINGLE-VALUE X-ORIGIN 'IPA v4.0' )
 attributeTypes: ( 2.16.840.1.113730.3.8.11.75 NAME 'ipaNTAdditionalSuffixes' DESC 'Suffix for the user principal name associated with the domain' EQUALITY caseIgnoreMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15)
 attributeTypes: (2.16.840.1.113730.3.8.11.77 NAME 'ipaDomainResolutionOrder' DESC 'List of domains used to resolve a short name' EQUALITY caseIgnoreIA5Match SYNTAX 1.3.6.1.4.1.1466.115.121.1.26 SINGLE-VALUE X-ORIGIN 'IPA v4.5')
+attributeTypes: ( 2.16.840.1.113730.3.8.11.78 NAME 'ipaSubUidNumber' DESC 'numerical subordinate user ID' EQUALITY integerMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.27 SINGLE-VALUE  X-ORIGIN 'IPA v4.9')
+attributeTypes: ( 2.16.840.1.113730.3.8.11.79 NAME 'ipaSubUidCount' DESC 'numerical subordinate user ID count' EQUALITY integerMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.27 SINGLE-VALUE  X-ORIGIN 'IPA v4.9')
+attributeTypes: ( 2.16.840.1.113730.3.8.11.80 NAME 'ipaSubGidNumber' DESC 'numerical subordinate user ID' EQUALITY integerMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.27 SINGLE-VALUE  X-ORIGIN 'IPA v4.9')
+attributeTypes: ( 2.16.840.1.113730.3.8.11.81 NAME 'ipaSubGidCount' DESC 'numerical subordinate user ID count' EQUALITY integerMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.27 SINGLE-VALUE  X-ORIGIN 'IPA v4.9')
 attributeTypes: (2.16.840.1.113730.3.8.18.2.1 NAME 'ipaVaultType' DESC 'IPA vault type' EQUALITY caseExactMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 X-ORIGIN 'IPA v4.2')
 attributeTypes: (2.16.840.1.113730.3.8.18.2.2 NAME 'ipaVaultSalt' DESC 'IPA vault salt' EQUALITY octetStringMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.40 X-ORIGIN 'IPA v4.2' )
 # FIXME: https://bugzilla.redhat.com/show_bug.cgi?id=1267782
@@ -86,5 +90,6 @@ objectClasses: (2.16.840.1.113730.3.8.12.25 NAME 'ipaPrivateKeyObject' DESC 'Wra
 objectClasses: (2.16.840.1.113730.3.8.12.26 NAME 'ipaSecretKeyObject' DESC 'Wrapped secret keys' SUP top AUXILIARY MUST ( ipaSecretKey $ ipaWrappingKey $ ipaWrappingMech ) X-ORIGIN 'IPA v4.1' )
 objectClasses: (2.16.840.1.113730.3.8.12.34 NAME 'ipaSecretKeyRefObject' DESC 'Indirect storage for encoded key material' SUP top AUXILIARY MUST ( ipaSecretKeyRef ) X-ORIGIN 'IPA v4.1' )
 objectClasses: (2.16.840.1.113730.3.8.12.39 NAME 'ipaNameResolutionData' DESC 'Data used to resolve short names to fully-qualified form' SUP top AUXILIARY MAY ( ipaDomainResolutionOrder ) X-ORIGIN 'IPA v4.5')
+objectClasses: (2.16.840.1.113730.3.8.12.40 NAME 'ipaUserSubordinate' DESC 'Subordinate uid and gid for users' SUP posixAccount AUXILIARY MUST ( ipaSubUidNumber $ ipaSubUidCount $ ipaSubGidNumber $ ipaSubGidCount ) X-ORIGIN 'IPA v4.9')
 objectClasses: (2.16.840.1.113730.3.8.18.1.1 NAME 'ipaVault' DESC 'IPA vault' SUP top STRUCTURAL MUST ( cn ) MAY ( description $ ipaVaultType $ ipaVaultSalt $ ipaVaultPublicKey $ owner $ member ) X-ORIGIN 'IPA v4.2' )
 objectClasses: (2.16.840.1.113730.3.8.18.1.2 NAME 'ipaVaultContainer' DESC 'IPA vault container' SUP top STRUCTURAL MUST ( cn ) MAY ( description $ owner ) X-ORIGIN 'IPA v4.2' )
diff --git a/install/ui/src/freeipa/user.js b/install/ui/src/freeipa/user.js
index a4eb390b7d9..5b49b0f6edb 100644
--- a/install/ui/src/freeipa/user.js
+++ b/install/ui/src/freeipa/user.js
@@ -259,6 +259,33 @@ return {
                         }
                     ]
                 },
+                {
+                    name: 'subordinate',
+                    label: '@i18n:objects.subordinate.identity',
+                    fields: [
+                        {
+                            name: 'ipasubuidnumber',
+                            label: '@i18n:objects.subordinate.subuidnumber',
+                            read_only: true
+                        },
+                        {
+                            name: 'ipasubuidcount',
+                            label: '@i18n:objects.subordinate.subuidcount',
+                            read_only: true
+
+                        },
+                        {
+                            name: 'ipasubgidnumber',
+                            label: '@i18n:objects.subordinate.subgidnumber',
+                            read_only: true
+                        },
+                        {
+                            name: 'ipasubgidcount',
+                            label: '@i18n:objects.subordinate.subgidcount',
+                            read_only: true
+                        }
+                    ]
+                },
                 {
                     name: 'pwpolicy',
                     label: '@i18n:objects.pwpolicy.identity',
@@ -451,6 +478,16 @@ return {
                     enable_cond: ['is-locked'],
                     confirm_msg: '@i18n:objects.user.unlock_confirm'
                 },
+                {
+                    $factory: IPA.object_action,
+                    name: 'auto_subid',
+                    method: 'auto_subid',
+                    label: '@i18n:objects.user.auto_subid',
+                    needs_confirm: true,
+                    hide_cond: ['preserved-user'],
+                    enable_cond: ['no-subid'],
+                    confirm_msg: '@i18n:objects.user.auto_subid_confirm'
+                },
                 {
                     $type: 'automember_rebuild',
                     name: 'automember_rebuild',
@@ -461,12 +498,22 @@ return {
                     $type: 'cert_request',
                     hide_cond: ['preserved-user'],
                     title: '@i18n:objects.cert.issue_for_user'
+                },
+                {
+                    $factory: IPA.object_action,
+                    name: 'auto_subid',
+                    method: 'auto_subid',
+                    label: '@i18n:objects.user.auto_subid',
+                    needs_confirm: true,
+                    hide_cond: ['preserved-user'],
+                    enable_cond: ['no-subid'],
+                    confirm_msg: '@i18n:objects.user.auto_subid_confirm'
                 }
             ],
             header_actions: [
                 'reset_password', 'enable', 'disable', 'stage', 'undel',
                 'delete_active_user', 'delete', 'unlock', 'add_otptoken',
-                'automember_rebuild', 'request_cert'
+                'automember_rebuild', 'request_cert', 'auto_subid'
             ],
             state: {
                 evaluators: [
@@ -1159,6 +1206,10 @@ IPA.user.is_locked_evaluator = function(spec) {
             }
         }
 
+        if (!user.ipasubuidnumber) {
+            that.state.push('no-subid');
+        }
+
         that.notify_on_change(old_state);
     };
 
diff --git a/install/updates/20-indices.update b/install/updates/20-indices.update
index 6632f105a98..b47b3851c55 100644
--- a/install/updates/20-indices.update
+++ b/install/updates/20-indices.update
@@ -272,6 +272,22 @@ add:nsIndexType: eq
 add:nsIndexType: pres
 add:nsIndexType: sub
 
+dn: cn=ipaSubGidNumber,cn=index,cn=userRoot,cn=ldbm database,cn=plugins,cn=config
+only:cn: ipaSubGidNumber
+default:objectClass: nsIndex
+default:objectClass: top
+default:nsSystemIndex: false
+add:nsIndexType: eq
+add:nsMatchingRule: integerOrderingMatch
+
+dn: cn=ipaSubUidNumber,cn=index,cn=userRoot,cn=ldbm database,cn=plugins,cn=config
+only:cn: ipaSubUidNumber
+default:objectClass: nsIndex
+default:objectClass: top
+default:nsSystemIndex: false
+add:nsIndexType: eq
+add:nsMatchingRule: integerOrderingMatch
+
 dn: cn=ipasudorunasgroup,cn=index,cn=userRoot,cn=ldbm database,cn=plugins,cn=config
 only:cn: ipasudorunasgroup
 default:objectClass: nsIndex
diff --git a/install/updates/73-subid.update b/install/updates/73-subid.update
new file mode 100644
index 00000000000..215da020c17
--- /dev/null
+++ b/install/updates/73-subid.update
@@ -0,0 +1,7 @@
+# subordinate ids
+dn: cn=users,cn=accounts,$SUFFIX
+# allow users to create new subid with DNA MAGIC value and subid count with 65536
+# the delete-when-empty check is required because IPA uses MOD_REPLACE
+# see https://github.com/389ds/389-ds-base/issues/4597
+# TODO: replace ">=2147483648" with "=-1"
+add: aci: (targattrfilters = "add=objectClass:(objectClass=ipausersubordinate) && ipasubuidnumber:(ipasubuidnumber>=2147483648) && ipasubuidcount:(ipasubuidcount=65536) && ipasubgidnumber:(ipasubgidnumber>=2147483648) && ipasubgidcount:(ipasubgidcount=65536), del=ipasubuidnumber:(!(ipasubuidnumber=*)) && ipasubuidcount:(!(ipasubuidcount=*)) && ipasubgidnumber:(!(ipasubgidnumber=*)) && ipasubgidcount:(!(ipasubgidcount=*))")(targetfilter = "(objectclass=posixaccount)")(version 3.0;acl "selfservice: Add subordinate ids";allow (write) userdn = "ldap:///self";)
diff --git a/install/updates/Makefile.am b/install/updates/Makefile.am
index 5741805a65a..d4f6acba0dc 100644
--- a/install/updates/Makefile.am
+++ b/install/updates/Makefile.am
@@ -61,6 +61,7 @@ app_DATA =				\
 	71-idviews-sasl-mapping.update  \
 	72-domainlevels.update		\
 	73-custodia.update		\
+	73-subid.update		\
 	73-winsync.update		\
 	73-certmap.update		\
 	75-user-trust-attributes.update	\
diff --git a/ipalib/constants.py b/ipalib/constants.py
index a622f640b24..78f3a0288bc 100644
--- a/ipalib/constants.py
+++ b/ipalib/constants.py
@@ -340,3 +340,8 @@
 # Apache's mod_ssl SSLVerifyDepth value (Maximum depth of CA
 # Certificates in Client Certificate verification)
 MOD_SSL_VERIFY_DEPTH = '5'
+
+# subuid / subgid counts are hard-coded
+# An interval of 65536 uids/gids is required to map nobody (65534).
+SUBUID_COUNT = 65536
+SUBGID_COUNT = 65536
diff --git a/ipaserver/plugins/baseuser.py b/ipaserver/plugins/baseuser.py
index e1b7763f0fd..afe8e1f67a2 100644
--- a/ipaserver/plugins/baseuser.py
+++ b/ipaserver/plugins/baseuser.py
@@ -19,7 +19,7 @@
 
 import six
 
-from ipalib import api, errors
+from ipalib import api, errors, output
 from ipalib import (
     Flag, Int, Password, Str, Bool, StrEnum, DateTime, DNParam)
 from ipalib.parameters import Principal, Certificate
@@ -27,13 +27,16 @@
 from .baseldap import (
     DN, LDAPObject, LDAPCreate, LDAPUpdate, LDAPSearch, LDAPDelete,
     LDAPRetrieve, LDAPAddAttribute, LDAPModAttribute, LDAPRemoveAttribute,
-    LDAPAddMember, LDAPRemoveMember,
+    LDAPQuery, LDAPAddMember, LDAPRemoveMember,
     LDAPAddAttributeViaOption, LDAPRemoveAttributeViaOption,
-    add_missing_object_class)
+    add_missing_object_class, DNA_MAGIC, pkey_to_value, entry_to_dict
+)
 from ipaserver.plugins.service import (validate_realm, normalize_principal)
 from ipalib.request import context
 from ipalib import _
-from ipalib.constants import PATTERN_GROUPUSER_NAME
+from ipalib.constants import (
+    PATTERN_GROUPUSER_NAME, SUBUID_COUNT, SUBGID_COUNT
+)
 from ipapython import kerberos
 from ipapython.ipautil import ipa_generate_password, TMP_PWD_ENTROPY_BITS
 from ipapython.ipavalidate import Email
@@ -175,13 +178,17 @@ class baseuser(LDAPObject):
         'krbprincipalexpiration', 'usercertificate;binary',
         'krbprincipalname', 'krbcanonicalname',
         'ipacertmapdata', 'ipantlogonscript', 'ipantprofilepath',
-        'ipanthomedirectory', 'ipanthomedirectorydrive'
+        'ipanthomedirectory', 'ipanthomedirectorydrive',
+        'ipasubuidnumber', 'ipasubuidcount', 'ipasubgidnumber',
+        'ipasubgidcount',
     ]
     search_display_attributes = [
         'uid', 'givenname', 'sn', 'homedirectory', 'krbcanonicalname',
         'krbprincipalname', 'loginshell',
         'mail', 'telephonenumber', 'title', 'nsaccountlock',
         'uidnumber', 'gidnumber', 'sshpubkeyfp',
+        'ipasubuidnumber', 'ipasubuidcount', 'ipasubgidnumber',
+        'ipasubgidcount',
     ]
     uuid_attribute = 'ipauniqueid'
     attribute_members = {
@@ -429,6 +436,38 @@ class baseuser(LDAPObject):
                     'J:', 'K:', 'L:', 'M:', 'N:', 'O:', 'P:', 'Q:', 'R:',
                     'S:', 'T:', 'U:', 'V:', 'W:', 'X:', 'Y:', 'Z:'),
                 ),
+        Int(
+            'ipasubuidnumber?',
+            label=_('SubUID range start'),
+            cli_name='subuid',
+            doc=_('Start value for subordinate user ID (subuid) range'),
+            default_from=lambda: DNA_MAGIC,
+        ),
+        Int(
+            'ipasubuidcount?',
+            label=_('SubUID range size'),
+            cli_name='subuidcount',
+            doc=_('Subordinate user ID count'),
+            flags={'no_create', 'no_update', 'no_search'},
+            minvalue=SUBUID_COUNT,
+            maxvalue=SUBUID_COUNT,
+        ),
+        Int(
+            'ipasubgidnumber?',
+            label=_('SubGID range start'),
+            cli_name='subgid',
+            doc=_('Start value for subordinate group ID (subgid) range'),
+            flags={'no_create', 'no_update'},
+        ),
+        Int(
+            'ipasubgidcount?',
+            label=_('SubGID range size'),
+            cli_name='subgidcount',
+            doc=_('Subordinate group ID count'),
+            flags={'no_create', 'no_update', 'no_search'},
+            minvalue=SUBGID_COUNT,
+            maxvalue=SUBGID_COUNT,
+        ),
     )
 
     def normalize_and_validate_email(self, email, config=None):
@@ -526,6 +565,79 @@ def convert_attribute_members(self, entry_attrs, *keys, **options):
         except KeyError:
             pass
 
+    def handle_subordinate_ids(self, ldap, dn, entry_attrs):
+        """Handle ipaUserSubordinate object class
+        """
+        obj_classes = entry_attrs.get("objectclass")
+        new_subuid = entry_attrs.single_value.get("ipasubuidnumber")
+        new_subgid = entry_attrs.single_value.get("ipasubgidnumber")
+
+        # entry has object class ipaUserSubordinate
+        # default to auto-assigment of subuids
+        if obj_classes is not None and "ipausersubordinate" in obj_classes:
+            if new_subuid is None:
+                new_subuid = DNA_MAGIC
+
+        # neither auto-assignment nor explicit assignment
+        if new_subuid is None:
+            # nothing to do
+            return False
+
+        # enforce subuid == subgid for now
+        if new_subgid is not None and new_subgid != new_subuid:
+            raise errors.ValidationError(
+                name="ipasubgidnumber",
+                error=_("subgidnumber must be equal to subuidnumber")
+            )
+
+        self.set_subordinate_ids(ldap, dn, entry_attrs, new_subuid)
+        return True
+
+    def set_subordinate_ids(self, ldap, dn, entry_attrs, subuid):
+        """Set subuid value of an entry
+
+        Takes care of objectclass and sibbling attributes
+        """
+        if "objectclass" in entry_attrs:
+            obj_classes = entry_attrs["objectclass"]
+        else:
+            _entry_attrs = ldap.get_entry(dn, ["objectclass"])
+            entry_attrs["objectclass"] = _entry_attrs["objectclass"]
+            obj_classes = entry_attrs["objectclass"]
+
+        if "ipausersubordinate" not in obj_classes:
+            obj_classes.append("ipausersubordinate")
+
+        if subuid == DNA_MAGIC:
+            subuid = self._fake_dna_plugin(ldap, dn, entry_attrs)
+
+        entry_attrs["ipasubuidnumber"] = subuid
+        # enforice subuid == subgid for now
+        entry_attrs["ipasubgidnumber"] = subuid
+        # hard-coded constants
+        entry_attrs["ipasubuidcount"] = SUBUID_COUNT
+        entry_attrs["ipasubgidcount"] = SUBGID_COUNT
+
+    def _fake_dna_plugin(self, ldap, dn, entry_attrs):
+        # XXX HACK, please remove later
+        if not hasattr(context, "idrange_ipabaseid"):
+            range_name = f"{self.api.env.realm}_id_range"
+            range = self.api.Command.idrange_show(range_name)["result"]
+            context.idrange_ipabaseid = int(range["ipabaseid"][0])
+
+        range_start = context.idrange_ipabaseid
+
+        uidnumber = entry_attrs.single_value.get("uidnumber")
+        if uidnumber is None:
+            entry = ldap.get_entry(dn, ["uidnumber"])
+            uidnumber = entry.single_value["uidnumber"]
+        uidnumber = int(uidnumber)
+
+        assert uidnumber >= range_start
+        assert uidnumber < range_start + 2**14
+
+        return (uidnumber - range_start) * SUBGID_COUNT + 2**31
+
 
 class baseuser_add(LDAPCreate):
     """
@@ -536,6 +648,7 @@ def pre_common_callback(self, ldap, dn, entry_attrs, attrs_list, *keys,
         assert isinstance(dn, DN)
         set_krbcanonicalname(entry_attrs)
         self.obj.convert_usercertificate_pre(entry_attrs)
+        self.obj.handle_subordinate_ids(ldap, dn, entry_attrs)
         if entry_attrs.get('ipatokenradiususername', None):
             add_missing_object_class(ldap, u'ipatokenradiusproxyuser', dn,
                                      entry_attrs, update=False)
@@ -683,6 +796,7 @@ def pre_common_callback(self, ldap, dn, entry_attrs, attrs_list, *keys,
 
         self.check_objectclass(ldap, dn, entry_attrs)
         self.obj.convert_usercertificate_pre(entry_attrs)
+        self.obj.handle_subordinate_ids(ldap, dn, entry_attrs)
         self.preserve_krbprincipalname_pre(ldap, entry_attrs, *keys, **options)
         update_samba_attrs(ldap, dn, entry_attrs, **options)
 
@@ -963,3 +1077,44 @@ 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 baseuser_auto_subid(LDAPQuery):
+    __doc__ = _("Auto-assign subuid and subgid range to user entry")
+
+    has_output = output.standard_entry
+
+    def execute(self, cn, **options):
+        ldap = self.obj.backend
+        dn = self.obj.get_dn(cn)
+
+        try:
+            entry_attrs = ldap.get_entry(
+                dn, ["objectclass", "ipasubuidnumber"]
+            )
+        except errors.NotFound:
+            raise self.obj.handle_not_found(cn)
+
+        if "ipasubuidnumber" in entry_attrs:
+            raise errors.AlreadyContainsValueError(attr="ipasubuidnumber")
+
+        self.obj.set_subordinate_ids(ldap, dn, entry_attrs, subuid=DNA_MAGIC)
+        ldap.update_entry(entry_attrs)
+
+        # fetch updated entry
+        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)
+
+        entry = self._exc_wrapper((cn,), options, ldap.get_entry)(
+            dn, attrs_list
+        )
+        entry_attrs = entry_to_dict(entry, **options)
+        entry_attrs['dn'] = dn
+
+        return dict(result=entry_attrs, value=pkey_to_value(cn, options))
diff --git a/ipaserver/plugins/internal.py b/ipaserver/plugins/internal.py
index 7b61e527eb7..4e4d1256f3d 100644
--- a/ipaserver/plugins/internal.py
+++ b/ipaserver/plugins/internal.py
@@ -1546,6 +1546,13 @@ class i18n_messages(Command):
                     "Drive to mount a home directory"
                 ),
             },
+            "subordinate": {
+                "identity": _("Subordinate user and group id"),
+                "subuidnumber": _("Subordinate user id"),
+                "subuidcount": _("Subordinate user id count"),
+                "subgidnumber": _("Subordinate group id"),
+                "subgidcount": _("Subordinate group id count"),
+            },
             "trustconfig": {
                 "options": _("Options"),
             },
@@ -1569,6 +1576,11 @@ class i18n_messages(Command):
                 "add_into_sudo": _(
                     "Add user '${primary_key}' into sudo rules"
                 ),
+                "auto_subid": _("Auto assign subordinate ids"),
+                "auto_subid_confirm": _(
+                    "Are you sure you want to auto-assign a subordinate id "
+                    "to user ${object}?"
+                ),
                 "contact": _("Contact Settings"),
                 "delete_mode": _("Delete mode"),
                 "employee": _("Employee Information"),
diff --git a/ipaserver/plugins/user.py b/ipaserver/plugins/user.py
index 775eb6346eb..d9f3efcb0a1 100644
--- a/ipaserver/plugins/user.py
+++ b/ipaserver/plugins/user.py
@@ -50,7 +50,9 @@
     baseuser_add_principal,
     baseuser_remove_principal,
     baseuser_add_certmapdata,
-    baseuser_remove_certmapdata)
+    baseuser_remove_certmapdata,
+    baseuser_auto_subid,
+)
 from .idviews import remove_ipaobject_overrides
 from ipalib.plugable import Registry
 from .baseldap import (
@@ -202,6 +204,8 @@ class user(baseuser):
             'ipapermright': {'read', 'search', 'compare'},
             'ipapermdefaultattr': {
                 'ipauniqueid', 'ipasshpubkey', 'ipauserauthtype', 'userclass',
+                'ipasubuidnumber', 'ipasubuidcount', 'ipasubgidnumber',
+                'ipasubgidcount',
             },
             'fixup_function': fix_addressbook_permission_bindrule,
         },
@@ -422,6 +426,14 @@ class user(baseuser):
                 'Certificate Identity Mapping Administrators'
             },
         },
+        'System: Manage user subordinate ids': {
+            'ipapermright': {'write'},
+            'ipapermdefaultattr': {
+                'ipasubuidnumber', 'ipasubuidcount', 'ipasubgidnumber',
+                'ipasubgidcount',
+            },
+            'default_privileges': {'User Subordinate ID Administrators'},
+        },
     }
 
     takes_params = baseuser.takes_params + (
@@ -1305,3 +1317,8 @@ 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_auto_subid(baseuser_auto_subid):
+    __doc__ = baseuser_auto_subid.__doc__
