diff --git a/daemons/dnssec/ipa-ods-exporter.in b/daemons/dnssec/ipa-ods-exporter.in index bd170440211..2e998798534 100644 --- a/daemons/dnssec/ipa-ods-exporter.in +++ b/daemons/dnssec/ipa-ods-exporter.in @@ -16,7 +16,7 @@ Purpose of this replacement is to upload keys generated by OpenDNSSEC to LDAP. """ from __future__ import print_function -from datetime import datetime +from datetime import datetime, timezone import logging import os import socket @@ -24,7 +24,6 @@ import select import sys import traceback -import dateutil.tz import dns.dnssec from gssapi.exceptions import GSSError import six @@ -93,11 +92,8 @@ def datetime2ldap(dt): def sql2datetime(sql_time): """Convert SQL date format from local time zone into UTC.""" - localtz = dateutil.tz.tzlocal() - localtime = datetime.strptime(sql_time, "%Y-%m-%d %H:%M:%S").replace( - tzinfo=localtz) - utctz = dateutil.tz.gettz('UTC') - return localtime.astimezone(utctz) + localtime = datetime.strptime(sql_time, "%Y-%m-%d %H:%M:%S") + return localtime.astimezone(timezone.utc) def sql2datetimes(row): row2key_map = {'generate': 'idnsSecKeyCreated', diff --git a/freeipa.spec.in b/freeipa.spec.in index 4fa981d7509..98e95ab9017 100644 --- a/freeipa.spec.in +++ b/freeipa.spec.in @@ -416,7 +416,6 @@ BuildRequires: keyutils BuildRequires: python3-augeas BuildRequires: python3-cffi BuildRequires: python3-cryptography >= 1.6 -BuildRequires: python3-dateutil BuildRequires: python3-dbus BuildRequires: python3-dns >= 1.15 BuildRequires: python3-docker @@ -975,7 +974,6 @@ Requires: keyutils Requires: python3-argcomplete Requires: python3-cffi Requires: python3-cryptography >= 1.6 -Requires: python3-dateutil Requires: python3-dbus Requires: python3-dns >= 1.15 Requires: python3-gssapi >= 1.2.0 @@ -1394,6 +1392,8 @@ fi %systemd_post ipa-epn.timer %post client +%tmpfiles_create ipaclient.conf + if [ $1 -gt 1 ] ; then # Has the client been configured? restore=0 @@ -1926,13 +1926,12 @@ fi %ghost %attr(644,root,root) %config(noreplace) %{_sysconfdir}/ipa/nssdb/pkcs11.txt %ghost %attr(600,root,root) %config(noreplace) %{_sysconfdir}/ipa/nssdb/pwdfile.txt %ghost %attr(644,root,root) %config(noreplace) %{_sysconfdir}/pki/ca-trust/source/ipa.p11-kit -%dir %{_localstatedir}/lib/ipa-client -%dir %{_localstatedir}/lib/ipa-client/pki -%dir %{_localstatedir}/lib/ipa-client/sysrestore %{_mandir}/man5/default.conf.5* %dir %{_usr}/share/ipa/client %{_usr}/share/ipa/client/*.template - +# NOTE: systemd specific section +%{_tmpfilesdir}/ipaclient.conf +# END %files python-compat %doc README.md Contributors.txt diff --git a/init/tmpfilesd/Makefile.am b/init/tmpfilesd/Makefile.am index 8d264aaab06..b2bb38fe15d 100644 --- a/init/tmpfilesd/Makefile.am +++ b/init/tmpfilesd/Makefile.am @@ -1,9 +1,11 @@ dist_noinst_DATA = \ ipa.conf.in \ + ipaclient.conf.in \ ipa-dnssec.conf.in systemdtmpfiles_DATA = \ - ipa.conf + ipa.conf \ + ipaclient.conf appdir = $(IPA_DATA_DIR) dist_app_DATA = \ @@ -13,5 +15,8 @@ CLEANFILES = $(systemdtmpfiles_DATA) $(app_DATA) %: %.in Makefile sed \ - -e 's|@HTTPD_GROUP[@]|$(HTTPD_GROUP)|g;s|@ODS_USER[@]|$(ODS_USER)|g;s|@NAMED_GROUP[@]|$(NAMED_GROUP)|g' \ + -e 's|@HTTPD_GROUP[@]|$(HTTPD_GROUP)|g' \ + -e 's|@ODS_USER[@]|$(ODS_USER)|g' \ + -e 's|@NAMED_GROUP[@]|$(NAMED_GROUP)|g' \ + -e 's|@localstatedir[@]|$(localstatedir)|g' \ '$(srcdir)/$@.in' >$@ diff --git a/init/tmpfilesd/ipaclient.conf.in b/init/tmpfilesd/ipaclient.conf.in new file mode 100644 index 00000000000..b1611cd3210 --- /dev/null +++ b/init/tmpfilesd/ipaclient.conf.in @@ -0,0 +1,3 @@ +d @localstatedir@/lib/ipa-client 0755 root root +d @localstatedir@/lib/ipa-client/pki 0755 root root +d @localstatedir@/lib/ipa-client/sysrestore 0755 root root diff --git a/ipaplatform/fedora/paths.py b/ipaplatform/fedora/paths.py index 4e993c063e2..2ea43f7324d 100644 --- a/ipaplatform/fedora/paths.py +++ b/ipaplatform/fedora/paths.py @@ -27,6 +27,7 @@ from ipaplatform.redhat.paths import RedHatPathNamespace from ipaplatform.fedora.constants import HAS_NFS_CONF +from ipaplatform.osinfo import osinfo class FedoraPathNamespace(RedHatPathNamespace): @@ -36,6 +37,8 @@ class FedoraPathNamespace(RedHatPathNamespace): NAMED_CRYPTO_POLICY_FILE = "/etc/crypto-policies/back-ends/bind.config" if HAS_NFS_CONF: SYSCONFIG_NFS = '/etc/nfs.conf' + if osinfo.version_number >= (45,): + BIN_TOMCAT = "/usr/share/tomcat/bin/version.sh" paths = FedoraPathNamespace() diff --git a/ipaplatform/rhel/paths.py b/ipaplatform/rhel/paths.py index 3631550eba5..f348509e1b7 100644 --- a/ipaplatform/rhel/paths.py +++ b/ipaplatform/rhel/paths.py @@ -27,12 +27,15 @@ from ipaplatform.redhat.paths import RedHatPathNamespace from ipaplatform.rhel.constants import HAS_NFS_CONF +from ipaplatform.osinfo import osinfo class RHELPathNamespace(RedHatPathNamespace): NAMED_CRYPTO_POLICY_FILE = "/etc/crypto-policies/back-ends/bind.config" if HAS_NFS_CONF: SYSCONFIG_NFS = '/etc/nfs.conf' + if osinfo.version_number >= (11,0): + BIN_TOMCAT = "/usr/share/tomcat/bin/version.sh" paths = RHELPathNamespace() diff --git a/ipaserver/dnssec/_ods21.py b/ipaserver/dnssec/_ods21.py index 4f93b59ce89..1b70ab5122f 100644 --- a/ipaserver/dnssec/_ods21.py +++ b/ipaserver/dnssec/_ods21.py @@ -3,8 +3,6 @@ # import os -import dateutil.tz - from ipaserver.dnssec._odsbase import AbstractODSDBConnection from ipaserver.dnssec._odsbase import AbstractODSSignerConn from ipaserver.dnssec._odsbase import ODS_SE_MAXLINE @@ -41,10 +39,9 @@ def get_keys_for_zone(self, zone_id): key['HSMkey_id'] = row['locator'] # The date is stored in UTC format but OpenDNSSEC 1.4 was # returning a local tz format - tz = dateutil.tz.tzlocal() key['generate'] = ipautil.datetime_from_utctimestamp( row['inception'], - units=1).astimezone(tz).replace(tzinfo=None).isoformat( + units=1).astimezone().replace(tzinfo=None).isoformat( sep=' ', timespec='seconds') key['algorithm'] = row['algorithm'] key['publish'] = key['generate'] diff --git a/ipaserver/install/cainstance.py b/ipaserver/install/cainstance.py index 4933ad23d73..617a33a55f4 100644 --- a/ipaserver/install/cainstance.py +++ b/ipaserver/install/cainstance.py @@ -952,20 +952,27 @@ def __request_ra_certificate(self): tmpdb.import_pkcs12( paths.DOGTAG_ADMIN_P12, pkcs12_passwd=self.dm_password) - (_keytype, keysize) = api.env.key_type_size.split(':', 1) + (keytype, keysize) = api.env.key_type_size.split(':', 1) + + if keytype == "mldsa": + # convert to the format NSS expects + keysize = f"ML-DSA-{keysize}" csrfile = os.path.join(tmpdb.secdir, "csr") - ipautil.run( - [paths.CERTUTIL, - "-d", tmpdb.secdir, - "-R", "-s", str(DN(('CN', 'IPA RA'), self.subject_base)), - # eventually use -q curve-name for ECC - "-g", keysize, - "-z", os.path.join(tmpdb.secdir, tmpdb.noise_fname), - "-f", tmpdb.passwd_fname, - "-o", csrfile, - "-a",] - ) + cmd = [ + paths.CERTUTIL, + "-d", tmpdb.secdir, + "-R", "-s", str(DN(('CN', 'IPA RA'), self.subject_base)), + "-k", keytype, + "-z", os.path.join(tmpdb.secdir, tmpdb.noise_fname), + "-f", tmpdb.passwd_fname, + "-o", csrfile, + "-a",] + if keytype.lower() == 'rsa': + cmd.extend(["-g", keysize]) + else: + cmd.extend(["-q", keysize]) + ipautil.run(cmd) tmpdb.pki_issue_ra_certificate( csrfile=csrfile, diff --git a/ipaserver/install/certs.py b/ipaserver/install/certs.py index 7f984934323..0605bbfce27 100644 --- a/ipaserver/install/certs.py +++ b/ipaserver/install/certs.py @@ -45,7 +45,7 @@ from ipapython.certdb import get_ca_nickname, find_cert_from_txt, NSSDatabase from ipapython.dn import DN from ipapython.ipautil import log_level_override -from ipalib import x509, api, constants +from ipalib import x509, api from ipalib.errors import CertificateOperationError from ipalib.install import certstore from ipalib.util import strip_csr_header @@ -147,6 +147,42 @@ def is_ipa_issued_cert(api, cert): return DN(cert.issuer) == cacert_subject +def get_ra_agent_profile(api): + """This is suitable during installation. I doubt at runtime. + + FIXME: This will need a conditional on whether + api.env.key_type_size available or not. If not then + retrieve the value from LDAP. + + The caller is expected to handle None. + """ + (keytype, _keysize) = api.env.key_type_size.split(':', 1) + if keytype == "rsa": + return "caSubsystemCert" + elif keytype == "mldsa": + return "caMLDSASubsystemCert" + else: + return None + + +def get_default_profile(api): + """This is suitable during installation. I doubt at runtime. + + FIXME: This will need a conditional on whether + api.env.key_type_size available or not. If not then + retrieve the value from LDAP. + + The caller is expected to handle None. + """ + (keytype, _keysize) = api.env.key_type_size.split(':', 1) + if keytype == "rsa": + return "caIPAserviceCert" + elif keytype == "mldsa": + return "caMLDSAServerCert" + else: + return None + + class CertDB: """An IPA-server-specific wrapper around NSS @@ -779,13 +815,13 @@ def pki_issue_ra_certificate(self, csrfile, certfile, dm_password): ca_client = pki.ca.CAClient(pki_client) cert_client = pki.cert.CertClient(ca_client) + profile = get_ra_agent_profile(api) inputs = dict() inputs['cert_request_type'] = 'pkcs10' with open(csrfile, 'r') as f: inputs['cert_request'] = f.read() with log_level_override(): - result = cert_client.enroll_cert( - constants.RA_AGENT_PROFILE, inputs)[0] + result = cert_client.enroll_cert(profile, inputs)[0] request_data = result.request if request_data.request_status != pki.cert.CertRequestStatus.COMPLETE: @@ -806,8 +842,14 @@ def pki_issue_certificate(self, service, profile, keyfile, certfile, """Use openssl to generate a CSR and submit it using the pki Python API, using the IPA RA certificate. """ + (keytype, keysize) = api.env.key_type_size.split(':', 1) + + # Generate a private key if service == 'krbtgt': principal = api.env.realm + # PKINIT doesn't support ML-DSA yet, force RSA. + keytype = "rsa" + keysize = 2048 else: principal = api.env.host template = os.path.join( @@ -829,21 +871,23 @@ def pki_issue_certificate(self, service, profile, keyfile, certfile, os.fchmod(f.fileno(), 0o600) f.write(conf) - (keytype, keysize) = api.env.key_type_size.split(':', 1) - - # Generate a private key - if (keytype.lower() == 'rsa'): - args = ["openssl", "genrsa", - "-out", keyfile] - if key_passwd_file: - args.extend( - ["-aes256", - "-passout", "file:{}".format(key_passwd_file)] - ) - args.extend([keysize]) # must be the last argument - result = ipautil.run(args, capture_output=True) + opts = [] + if keytype.lower() == 'rsa': + opts = ["-pkeyopt", "rsa_keygen_bits:{}".format(keysize)] + elif keytype.lower() == 'mldsa': + keytype = "ML-DSA-{}".format(keysize) else: raise RuntimeError(f"Key type not supported: {keytype}") + args = ["openssl", "genpkey", "-algorithm", keytype, + "-out", keyfile] + args.extend(opts) + + if key_passwd_file: + args.extend( + ["-aes256", + "-pass", "file:{}".format(key_passwd_file)] + ) + result = ipautil.run(args, capture_output=True) # Generate a CSR using the private key args = ["openssl", "req", "-new", diff --git a/ipaserver/install/dsinstance.py b/ipaserver/install/dsinstance.py index c840c41929f..250e61aca89 100644 --- a/ipaserver/install/dsinstance.py +++ b/ipaserver/install/dsinstance.py @@ -39,7 +39,6 @@ EXTERNAL_CA_TRUST_FLAGS, TrustFlags) from ipapython import ipautil, ipaldap -from ipapython import dogtag from ipaserver.install import service from ipaserver.install import installutils from ipaserver.install import certs @@ -861,7 +860,7 @@ def __enable_ssl(self): keyfile = os.path.join(tmpdb.secdir, "key.pem") certfile = os.path.join(tmpdb.secdir, "cert.pem") tmpdb.pki_issue_certificate( - "ldap", dogtag.DEFAULT_PROFILE, + "ldap", certs.get_default_profile(api), keyfile, certfile ) @@ -892,7 +891,7 @@ def __enable_ssl(self): passwd_fname=dsdb.passwd_fname, subject=str(DN(('CN', self.fqdn), self.subject_base)), ca='IPA', - profile=dogtag.DEFAULT_PROFILE, + profile=certs.get_default_profile(api), dns=[self.fqdn], post_command=cmd, resubmit_timeout=api.env.certmonger_wait_timeout, @@ -1206,7 +1205,7 @@ def start_tracking_certificates(self, serverid): self.principal, password_file=dsdb.passwd_fname, command='restart_dirsrv %s' % serverid, - profile=dogtag.DEFAULT_PROFILE) + profile=certs.get_default_profile(api)) else: logger.debug("Will not track DS server certificate %s as it is " "not issued by IPA", nickname) diff --git a/ipaserver/install/httpinstance.py b/ipaserver/install/httpinstance.py index 23a64535284..01f74b9bf85 100644 --- a/ipaserver/install/httpinstance.py +++ b/ipaserver/install/httpinstance.py @@ -37,7 +37,6 @@ from ipaserver.install import certs from ipaserver.install import installutils from ipapython import directivesetter -from ipapython import dogtag from ipapython import ipautil from ipapython.dn import DN import ipapython.errors @@ -345,7 +344,7 @@ def __setup_ssl(self): tmpdb.create_from_cacert() dns_2 = f"DNS.2={IPA_CA_RECORD}.{api.env.domain}" tmpdb.pki_issue_certificate( - "HTTP", dogtag.DEFAULT_PROFILE, + "HTTP", certs.get_default_profile(api), paths.HTTPD_KEY_FILE, paths.HTTPD_CERT_FILE, key_passwd_file, dns_2_san=dns_2 ) @@ -362,7 +361,7 @@ def __setup_ssl(self): principal=self.principal, subject=str(DN(('CN', self.fqdn), self.subject_base)), ca='IPA', - profile=dogtag.DEFAULT_PROFILE, + profile=certs.get_default_profile(api), dns=[self.fqdn, f'{IPA_CA_RECORD}.{api.env.domain}'], post_command='restart_httpd', storage='FILE', @@ -567,7 +566,7 @@ def start_tracking_certificates(self): request_id = certmonger.start_tracking( certpath=(paths.HTTPD_CERT_FILE, paths.HTTPD_KEY_FILE), post_command='restart_httpd', storage='FILE', - profile=dogtag.DEFAULT_PROFILE, + profile=certs.get_default_profile(api), pinfile=key_passwd_file, dns=[self.fqdn, f'{IPA_CA_RECORD}.{api.env.domain}'], ) diff --git a/ipaserver/install/installutils.py b/ipaserver/install/installutils.py index 87a690e9be8..cf856c179ed 100644 --- a/ipaserver/install/installutils.py +++ b/ipaserver/install/installutils.py @@ -1601,7 +1601,7 @@ def validate_key_type_size(value): types = { 'rsa': (2048, 3072, 4096, 7168, 8192), 'ec': tuple(), - 'ml-dsa': tuple(), + 'mldsa': (44, 65, 87), } if len(value.split(':', 1)) != 2: return _('Must be of the form type:size') @@ -1619,7 +1619,7 @@ def validate_key_type_size(value): "types": ", ".join(types) } - if type == 'rsa': + if type in ('rsa', 'mldsa'): try: size = int(size) except ValueError: diff --git a/ipaserver/install/ipa_migrate.py b/ipaserver/install/ipa_migrate.py index 47dc4902275..6903a7400d4 100644 --- a/ipaserver/install/ipa_migrate.py +++ b/ipaserver/install/ipa_migrate.py @@ -1523,10 +1523,19 @@ def process_db_entry(self, entry_dn, entry_attrs): if DN(exclude_dn) in DN(entry_dn): return + # Build objectclass list + oc_list = [oc.lower() for oc in entry_attrs['objectClass']] + # Skip tombstones # The attributes haven't been normalized yet normalize_attr(entry_attrs, 'objectClass') - if 'nstombstone' in [oc.lower() for oc in entry_attrs['objectClass']]: + if 'nstombstone' in oc_list: + return + + # Skip replication conflicts + if 'nsds5replconflict' in oc_list: + self.log_debug(f"Skipping replication conflict entry: " + f"'{entry_dn}'") return # Determine entry type: user, group, hbac, etc diff --git a/ipaserver/install/ipa_migrate_constants.py b/ipaserver/install/ipa_migrate_constants.py index fd5d18c48a0..60793f75df8 100644 --- a/ipaserver/install/ipa_migrate_constants.py +++ b/ipaserver/install/ipa_migrate_constants.py @@ -1013,6 +1013,13 @@ 'mode': 'all', 'count': 0, }, + 'dns_locations': { + 'oc': ['ipalocationobject'], + 'subtree': ',cn=locations,cn=etc,$SUFFIX', + 'label': 'DNS Locations', + 'mode': 'all', + 'count': 0, + }, # Kerberos 'krb_realm': { 'oc': ['krbrealmcontainer'], diff --git a/ipaserver/install/ipa_otptoken_import.py b/ipaserver/install/ipa_otptoken_import.py index 17457f6c5b8..3aad9c176c6 100644 --- a/ipaserver/install/ipa_otptoken_import.py +++ b/ipaserver/install/ipa_otptoken_import.py @@ -27,8 +27,6 @@ import uuid from lxml import etree -import dateutil.parser -import dateutil.tz import gssapi import six @@ -76,13 +74,9 @@ def fetch(element, xpath, conv=lambda x: x, default=None): def convertDate(value): "Converts an ISO 8601 string into a UTC datetime object." - dt = dateutil.parser.parse(value) + dt = datetime.datetime.fromisoformat(value.replace('Z', '+00:00')) - if dt.tzinfo is None: - dt = datetime.datetime(*dt.timetuple()[0:6], - tzinfo=dateutil.tz.tzlocal()) - - return dt.astimezone(dateutil.tz.tzutc()) + return dt.astimezone(datetime.timezone.utc) def convertTokenType(value): diff --git a/ipaserver/install/server/install.py b/ipaserver/install/server/install.py index 0fc432a6a75..1a7816f2fbe 100644 --- a/ipaserver/install/server/install.py +++ b/ipaserver/install/server/install.py @@ -770,12 +770,14 @@ def install_check(installer): options.ntp_servers or options.ntp_pool): options.ntp_servers, options.ntp_pool = timeconf.get_time_source() + (keytype, keysize) = api.env.key_type_size.split(':', 1) print() print("The IPA Master Server will be configured with:") print("Hostname: %s" % host_name) print("IP address(es): %s" % ", ".join(str(ip) for ip in ip_addresses)) print("Domain name: %s" % domain_name) print("Realm name: %s" % realm_name) + print("Key type/size %s:%s" % (keytype.upper(), keysize)) print() if setup_ca: diff --git a/ipaserver/plugins/group.py b/ipaserver/plugins/group.py index f05a39f69ec..308e5458c1d 100644 --- a/ipaserver/plugins/group.py +++ b/ipaserver/plugins/group.py @@ -26,6 +26,7 @@ from ipalib import api from ipalib import Int, Str, Flag from ipalib.constants import PATTERN_GROUPUSER_NAME, ERRMSG_GROUPUSER_NAME +from ipalib.parameters import MAX_UINT32 from ipalib.plugable import Registry from .baseldap import ( add_external_post_callback, @@ -354,6 +355,7 @@ class group(LDAPObject): label=_('GID'), doc=_('GID (use this option to set it manually)'), minvalue=1, + maxvalue=MAX_UINT32, ), ipaexternalmember_param, ) diff --git a/ipatests/test_integration/test_dns.py b/ipatests/test_integration/test_dns.py index 4b9ab1fe8d7..0a16e5989e7 100644 --- a/ipatests/test_integration/test_dns.py +++ b/ipatests/test_integration/test_dns.py @@ -5,6 +5,7 @@ from __future__ import absolute_import +import pytest import time import dns.exception import dns.resolver @@ -72,7 +73,7 @@ # TSIG key configuration for dynamic DNS updates (hmac-md5) KEY_NAME = "selfupdate" -KEY_SECRET = "05Fu1ACKv1/1Ag==" +KEY_SECRET = "05Fu1ACKv1/1Ag==" # notsecret KEY_CONFIG = f'''key {KEY_NAME} {{ algorithm hmac-md5; secret "{KEY_SECRET}"; @@ -1714,13 +1715,12 @@ def test_soa_serial_increments(self): tasks.del_dns_zone(self.master, zone, raiseonerr=False) def test_allow_query_transfer_ipv6(self): - """Test allow-query and allow-transfer with IPv4 and IPv6. + """Test allow-query and allow-transfer with IPv6. Bugzilla: https://bugzilla.redhat.com/show_bug.cgi?id=701677 """ tasks.kinit_admin(self.master) - zone = "example.com" - ipv4 = self.master.ip + zone = "example6.com" ipv6_added = False temp_ipv6 = '2001:0db8:0:f101::1/64' @@ -1732,6 +1732,13 @@ def test_allow_query_transfer_ipv6(self): ]) eth = result.stdout_text.strip() + # Check if IPv6 is disabled on the interface + result = self.master.run_command([ + 'sysctl', '-n', f'net.ipv6.conf.{eth}.disable_ipv6' + ]) + if result.stdout_text.strip() == '1': + pytest.skip(f"IPv6 is disabled on interface {eth}") + # Add temporary IPv6 if none exists result = self.master.run_command( ['ip', 'addr', 'show', 'scope', 'global'], raiseonerr=False @@ -1754,62 +1761,21 @@ def test_allow_query_transfer_ipv6(self): tasks.add_dns_zone(self.master, zone, skip_overlap_check=True, admin_email=self.EMAIL) - # Test allow-query: IPv4 allowed, IPv6 denied - tasks.mod_dns_zone( - self.master, zone, - f"--allow-query={ipv4};!{ipv6};" - ) - result = self.master.run_command( - ['dig', f'@{ipv4}', '-t', 'soa', zone], raiseonerr=False - ) - assert 'ANSWER SECTION' in result.stdout_text - result = self.master.run_command( - ['dig', f'@{ipv6}', '-t', 'soa', zone], raiseonerr=False - ) - assert 'ANSWER SECTION' not in result.stdout_text - - # Test allow-query: IPv6 allowed, IPv4 denied + # Test allow-query: IPv6 allowed tasks.mod_dns_zone( self.master, zone, - f"--allow-query={ipv6};!{ipv4};" - ) - result = self.master.run_command( - ['dig', f'@{ipv4}', '-t', 'soa', zone], raiseonerr=False + f"--allow-query={ipv6};" ) - assert 'ANSWER SECTION' not in result.stdout_text result = self.master.run_command( ['dig', f'@{ipv6}', '-t', 'soa', zone], raiseonerr=False ) assert 'ANSWER SECTION' in result.stdout_text - # Reset allow-query to any - tasks.mod_dns_zone( - self.master, zone, "--allow-query=any;" - ) - - # Test allow-transfer: IPv4 allowed, IPv6 denied - tasks.mod_dns_zone( - self.master, zone, - f"--allow-transfer={ipv4};!{ipv6};" - ) - result = self.master.run_command( - ['dig', f'@{ipv4}', zone, 'axfr'], raiseonerr=False - ) - assert 'Transfer failed' not in result.stdout_text - result = self.master.run_command( - ['dig', f'@{ipv6}', zone, 'axfr'], raiseonerr=False - ) - assert 'Transfer failed' in result.stdout_text - - # Test allow-transfer: IPv6 allowed, IPv4 denied + # Test allow-transfer: IPv6 allowed tasks.mod_dns_zone( self.master, zone, - f"--allow-transfer={ipv6};!{ipv4};" - ) - result = self.master.run_command( - ['dig', f'@{ipv4}', zone, 'axfr'], raiseonerr=False + f"--allow-transfer={ipv6};" ) - assert 'Transfer failed' in result.stdout_text result = self.master.run_command( ['dig', f'@{ipv6}', zone, 'axfr'], raiseonerr=False ) diff --git a/ipatests/test_integration/test_ipa_ipa_migration.py b/ipatests/test_integration/test_ipa_ipa_migration.py index 03276b0f311..a550e8f208d 100644 --- a/ipatests/test_integration/test_ipa_ipa_migration.py +++ b/ipatests/test_integration/test_ipa_ipa_migration.py @@ -9,6 +9,7 @@ from ipatests.test_integration.base import IntegrationTest from ipatests.pytest_ipa.integration import tasks from ipaplatform.paths import paths +from ipapython.ipaldap import realm_to_serverid from collections import Counter import pytest @@ -199,9 +200,12 @@ def prepare_ipa_server(master): ) master.run_command(["ipa", "krbtpolicy-mod", "admin", "--maxlife=9600"]) - # Add IPA location + # Add IPA locations master.run_command( - ["ipa", "location-add", "location", "--description", "My location"] + ["ipa", "location-add", "brno", "--description", "Brno office"] + ) + master.run_command( + ["ipa", "location-add", "raleigh", "--description", "Raleigh office"] ) # Add idviews and overrides @@ -1024,7 +1028,7 @@ def test_ipa_migrate_prod_mode(self, empty_log_file): self.master.hostname, "cn=Directory Manager", self.master.config.admin_password, - extra_args=['-n'], + extra_args=['-B', '-n'], ) install_msg = self.replicas[0].get_file_contents( paths.IPA_MIGRATE_LOG, encoding="utf-8" @@ -1232,6 +1236,23 @@ def test_automountlocation_is_migrated(self): assert CMD3_OUTPUT in cmd3.stdout_text assert DEBUG_LOG in install_msg + def test_ipa_locations_are_migrated(self): + """ + This testcase checks that IPA locations are migrated + from remote server to local server in prod mode. + """ + location1 = 'brno' + location2 = 'raleigh' + tasks.kinit_admin(self.replicas[0]) + cmd1 = self.replicas[0].run_command( + ["ipa", "location-find", location1]) + cmd2 = self.replicas[0].run_command( + ["ipa", "location-find", location2]) + assert 'Location name: brno\n' in cmd1.stdout_text + assert 'Description: Brno office\n' in cmd1.stdout_text + assert 'Location name: raleigh\n' in cmd2.stdout_text + assert 'Description: Raleigh office\n' in cmd2.stdout_text + def test_sshpubkey_migration_for_user(self): """ This testcase checks that SSH public key is migrated @@ -1300,6 +1321,74 @@ def test_sshpubkey_migration_for_idoverride(self): assert "test@example.com" in result.stdout_text assert "ssh-ed25519" in result.stdout_text + def test_ipa_migrate_skip_replication_conflicts(self): + """ + Test that ipa-migrate skips replication conflict entries + """ + tasks.kinit_admin(self.master) + tasks.kinit_admin(self.replicas[0]) + + # Get DS instance name + instance_name = realm_to_serverid(self.master.domain.realm) + + # Stop IPA on master to export database + self.master.run_command(['ipactl', 'stop']) + + # Export database using dsctl + ldif_file = "/tmp/userroot.ldif" + self.master.run_command([ + 'dsctl', instance_name, 'db2ldif', 'userroot', ldif_file + ]) + + # Start IPA back on master + self.master.run_command(['ipactl', 'start']) + + # Copy LDIF to replica + ldif_content = self.master.get_file_contents( + ldif_file, encoding="utf-8" + ) + replica_ldif = "/tmp/userroot_with_conflict.ldif" + + # Add mocked replication conflict entry to the LDIF + users_dn = "cn=users,cn=accounts," + str(self.master.domain.basedn) + conflict_entry = textwrap.dedent( + """ + # entry-id: 12345678 + dn: nsuniqueid=12345678-1234+uid=conflictuser,{users_dn} + uid: conflictuser + cn: Conflict User + uidNumber: 1234 + gidNumber: 1234 + sn: User + objectClass: top + objectClass: person + objectClass: inetorgperson + objectClass: nsds5replconflict + nsds5ReplConflict: namingConflict uid=conflictuser,{users_dn} + + """ + ).format(users_dn=users_dn) + + # Append conflict entry to LDIF + modified_ldif = ldif_content + conflict_entry + self.replicas[0].put_file_contents(replica_ldif, modified_ldif) + + result = run_migrate( + self.replicas[0], + "prod-mode", + self.master.hostname, + "cn=Directory Manager", + self.master.config.admin_password, + extra_args=["-f", replica_ldif, "-n", "-x"], + ) + + install_msg = self.replicas[0].get_file_contents( + paths.IPA_MIGRATE_LOG, encoding="utf-8" + ) + + assert result.returncode == 0 + assert "Skipping replication conflict entry" in install_msg + class TestIPAMigrationWithADtrust(IntegrationTest): """ diff --git a/ipatests/test_integration/test_replica_promotion.py b/ipatests/test_integration/test_replica_promotion.py index f8c8414eefb..4f686ac6f7d 100644 --- a/ipatests/test_integration/test_replica_promotion.py +++ b/ipatests/test_integration/test_replica_promotion.py @@ -180,7 +180,7 @@ def install(cls, mh): cls.username = 'testuser' tasks.install_master(cls.master, domain_level=cls.domain_level) password = cls.master.config.dirman_password - cls.new_password = '$ome0therPaaS' + cls.new_password = '$ome0therPaaS' # notsecret adduser_stdin_text = "%s\n%s\n" % (cls.master.config.admin_password, cls.master.config.admin_password) user_kinit_stdin_text = "%s\n%s\n%s\n" % (password, cls.new_password, diff --git a/ipatests/test_integration/test_trust.py b/ipatests/test_integration/test_trust.py index 0cab277c910..c69c89f2fe4 100644 --- a/ipatests/test_integration/test_trust.py +++ b/ipatests/test_integration/test_trust.py @@ -48,7 +48,7 @@ class BaseTestTrust(IntegrationTest): upn_principal = '{}@{}'.format(upn_username, upn_suffix) upn_password = 'Secret123456' - shared_secret = 'qwertyuiopQq!1' + shared_secret = 'qwertyuiopQq!1' # notsecret default_shell = platformconstants.DEFAULT_SHELL @classmethod diff --git a/ipatests/test_integration/test_user_permissions.py b/ipatests/test_integration/test_user_permissions.py index cd1096ff358..cbbcd45c654 100644 --- a/ipatests/test_integration/test_user_permissions.py +++ b/ipatests/test_integration/test_user_permissions.py @@ -166,7 +166,7 @@ def test_user_add_withradius(self): # Create a user with 'User Administrator' role altuser = 'specialuser' - password = 'SpecialUser123' + password = 'SpecialUser123' # notsecret password_confirmation = "%s\n%s\n" % (password, password) self.master.run_command( ['ipa', 'user-add', altuser, '--first', altuser, '--last', altuser, diff --git a/ipatests/test_xmlrpc/test_selfservice_plugin.py b/ipatests/test_xmlrpc/test_selfservice_plugin.py index 02aea35e140..e55502a2d94 100644 --- a/ipatests/test_xmlrpc/test_selfservice_plugin.py +++ b/ipatests/test_xmlrpc/test_selfservice_plugin.py @@ -21,14 +21,17 @@ Test the `ipaserver/plugins/selfservice.py` module. """ -from ipalib import errors -from ipatests.test_xmlrpc.xmlrpc_test import Declarative +from ipalib import api, errors +from ipatests.test_xmlrpc.xmlrpc_test import ( + Declarative, XMLRPC_test, assert_attr_equal, +) +from ipatests.test_xmlrpc.tracker.user_plugin import UserTracker +from ipatests.util import change_principal, unlock_principal_password import pytest selfservice1 = u'testself' invalid_selfservice1 = u'bad+name' - @pytest.mark.tier1 class test_selfservice(Declarative): @@ -290,3 +293,460 @@ class test_selfservice(Declarative): ), ] + + +@pytest.mark.tier1 +class test_selfservice_misc(Declarative): + """Bugzilla regression tests for selfservice plugin.""" + + cleanup_commands = [ + ("selfservice_del", [selfservice1], {}), + ] + + tests = [ + # BZ 772106: selfservice-add with --raw must not return internal error + dict( + desc="Create %r with --raw for BZ 772106" % selfservice1, + command=( + "selfservice_add", + [selfservice1], + dict(attrs=["l"], raw=True), + ), + expected=dict( + value=selfservice1, + summary='Added selfservice "%s"' % selfservice1, + result={ + "aci": '(targetattr = "l")(version 3.0;acl ' + '"selfservice:%s";allow (write) ' + 'userdn = "ldap:///self";)' % selfservice1, + }, + ), + ), + # BZ 772675: selfservice-mod with --raw must not return internal error + dict( + desc="Modify %r with --raw for BZ 772675" % selfservice1, + command=( + "selfservice_mod", + [selfservice1], + dict(attrs=["mobile"], raw=True), + ), + expected=dict( + value=selfservice1, + summary='Modified selfservice "%s"' % selfservice1, + result={ + "aci": '(targetattr = "mobile")(version 3.0;acl ' + '"selfservice:%s";allow (write) ' + 'userdn = "ldap:///self";)' % selfservice1, + }, + ), + ), + # BZ 747730: selfservice-mod --permissions="" must not delete the entry + dict( + desc=( + "Modify %r with empty permissions for BZ 747730" + % selfservice1 + ), + command=( + "selfservice_mod", + [selfservice1], + dict(permissions=""), + ), + expected=lambda got, output: True, + ), + dict( + desc="Verify %r still exists after BZ 747730" % selfservice1, + command=("selfservice_show", [selfservice1], {}), + expected=lambda got, output: ( + got is None + and output["result"]["aciname"] == selfservice1 + ), + ), + # BZ 747741: selfservice-mod --attrs=badattrs must not delete the entry + dict( + desc="Modify %r with bad attrs for BZ 747741" % selfservice1, + command=( + "selfservice_mod", + [selfservice1], + dict(attrs=["badattrs"]), + ), + expected=lambda got, output: True, + ), + dict( + desc="Verify %r still exists after BZ 747741" % selfservice1, + command=("selfservice_show", [selfservice1], {}), + expected=lambda got, output: ( + got is None + and output["result"]["aciname"] == selfservice1 + ), + ), + # BZ 747720: selfservice-find --permissions="" must not return + # internal error + dict( + desc="BZ 747720: selfservice-find with empty permissions", + command=("selfservice_find", [], dict(permissions="")), + expected=lambda got, output: ( + got is None and isinstance(output["result"], (list, tuple)) + ), + ), + # BZ 747722: selfservice-find --attrs="" must not return + # internal error + dict( + desc="BZ 747722: selfservice-find with empty attrs", + command=("selfservice_find", [], dict(attrs="")), + expected=lambda got, output: ( + got is None and isinstance(output["result"], (list, tuple)) + ), + ), + ] + + +SS_USER1 = 'ssuser0001' +SS_USER1_PASSWORD = 'Passw0rd1' +SS_USER2 = 'ssuser0002' +SS_USER2_PASSWORD = 'Passw0rd2' +SS_GOOD_MANAGER = 'ss_good_manager' +SS_GOOD_MANAGER_PASSWORD = 'Passw0rd3' + +SS_DEFAULT_SELFSERVICE = 'User Self service' +SS_CUSTOM_RULE = 'ss_test_rule0001' + +SS_DEFAULT_SELFSERVICE_ATTRS = [ + 'givenname', 'sn', 'cn', 'displayname', 'title', 'initials', + 'loginshell', 'gecos', 'homephone', 'mobile', 'pager', + 'facsimiletelephonenumber', 'telephonenumber', 'street', + 'roomnumber', 'l', 'st', 'postalcode', 'manager', 'secretary', + 'description', 'carlicense', 'labeleduri', 'inetuserhttpurl', + 'seealso', 'employeetype', 'businesscategory', 'ou', +] + +SS_CUSTOM_RULE_ATTRS = [ + 'mobile', 'pager', + 'facsimiletelephonenumber', 'telephonenumber', +] + + +def _safe_del_selfservice(name): + """Delete a selfservice rule, ignoring NotFound.""" + try: + api.Command['selfservice_del'](name) + except errors.NotFound: + pass + + +@pytest.fixture +def custom_selfservice_rule(xmlrpc_setup): + """Replace the default selfservice rule with the narrow custom rule.""" + api.Command['selfservice_del'](SS_DEFAULT_SELFSERVICE) + api.Command['selfservice_add']( + SS_CUSTOM_RULE, attrs=SS_CUSTOM_RULE_ATTRS, + ) + yield + _safe_del_selfservice(SS_CUSTOM_RULE) + api.Command['selfservice_add']( + SS_DEFAULT_SELFSERVICE, attrs=SS_DEFAULT_SELFSERVICE_ATTRS, + ) + + +@pytest.fixture(scope='class') +def ss_user1(request, xmlrpc_setup): + tracker = UserTracker( + name=SS_USER1, givenname='Test', sn='User0001', + userpassword=SS_USER1_PASSWORD, + ) + tracker.make_fixture(request) + tracker.make_create_command()() + tracker.exists = True + unlock_principal_password( + SS_USER1, SS_USER1_PASSWORD, SS_USER1_PASSWORD, + ) + return tracker + + +@pytest.fixture(scope='class') +def ss_user2(request, xmlrpc_setup): + tracker = UserTracker( + name=SS_USER2, givenname='Test', sn='User0002', + userpassword=SS_USER2_PASSWORD, + ) + tracker.make_fixture(request) + tracker.make_create_command()() + tracker.exists = True + unlock_principal_password( + SS_USER2, SS_USER2_PASSWORD, SS_USER2_PASSWORD, + ) + return tracker + + +@pytest.fixture(scope='class') +def ss_good_manager(request, xmlrpc_setup): + tracker = UserTracker( + name=SS_GOOD_MANAGER, givenname='Good', sn='Manager', + userpassword=SS_GOOD_MANAGER_PASSWORD, + ) + tracker.make_fixture(request) + tracker.make_create_command()() + tracker.exists = True + unlock_principal_password( + SS_GOOD_MANAGER, SS_GOOD_MANAGER_PASSWORD, SS_GOOD_MANAGER_PASSWORD, + ) + return tracker + + +@pytest.mark.tier1 +@pytest.mark.usefixtures('ss_user1', 'ss_user2', 'ss_good_manager') +class test_selfservice_users(XMLRPC_test): + """Test self-service user attribute modification permissions.""" + + # usertest_1001: Set all attrs allowed by default self-service rule. + def test_set_all_default_selfservice_attrs(self): + """Set all attrs allowed by the default self-service rule.""" + attrs = { + 'givenname': 'Good', + 'sn': 'User', + 'cn': 'gooduser', + 'displayname': 'gooduser', + 'initials': 'GU', + 'gecos': 'gooduser@good.example.com', + 'loginshell': '/bin/bash', + 'street': 'Good_Street_Rd', + 'l': 'Good_City', + 'st': 'Goodstate', + 'postalcode': '33333', + 'telephonenumber': '333-333-3333', + 'mobile': '333-333-3333', + 'pager': '333-333-3333', + 'facsimiletelephonenumber': '333-333-3333', + 'ou': 'good-org', + 'title': 'good_admin', + 'manager': SS_GOOD_MANAGER, + 'carlicense': 'good-3333', + } + + with change_principal(SS_USER1, SS_USER1_PASSWORD): + for attr, value in attrs.items(): + api.Command['user_mod'](SS_USER1, **{attr: value}) + + entry = api.Command['user_show'](SS_USER1, all=True)['result'] + for attr, value in attrs.items(): + assert_attr_equal(entry, attr, value) + + # usertest_1002: Test that default disallowed attributes are rejected. + def test_reject_uidnumber_by_default(self): + """uidnumber change is rejected by default.""" + with change_principal(SS_USER1, SS_USER1_PASSWORD): + with pytest.raises(errors.ACIError): + api.Command['user_mod'](SS_USER1, uidnumber=9999) + + def test_reject_gidnumber_by_default(self): + """gidnumber change is rejected by default.""" + with change_principal(SS_USER1, SS_USER1_PASSWORD): + with pytest.raises(errors.ACIError): + api.Command['user_mod'](SS_USER1, gidnumber=9999) + + def test_reject_homedirectory_by_default(self): + """homedirectory change is rejected by default.""" + with change_principal(SS_USER1, SS_USER1_PASSWORD): + with pytest.raises(errors.ACIError): + api.Command['user_mod']( + SS_USER1, homedirectory='/home/gooduser') + + def test_reject_email_by_default(self): + """email change is rejected by default.""" + with change_principal(SS_USER1, SS_USER1_PASSWORD): + with pytest.raises(errors.ACIError): + api.Command['user_mod']( + SS_USER1, mail='gooduser@good.example.com') + + # usertest_1003: All attrs rejected when the default rule is deleted. + def test_all_attrs_rejected_without_default_rule(self): + """All attrs are rejected when the default rule is deleted.""" + attrs = { + 'givenname': 'Bad', + 'sn': 'LUser', + 'cn': 'badluser', + 'displayname': 'badluser', + 'initials': 'BL', + 'gecos': 'badluser@bad.example.com', + 'loginshell': '/bin/tcsh', + 'street': 'Bad_Street_Av', + 'l': 'Bad_City', + 'st': 'Badstate', + 'postalcode': '99999', + 'telephonenumber': '999-999-9999', + 'mobile': '999-999-9999', + 'pager': '999-999-9999', + 'facsimiletelephonenumber': '999-999-9999', + 'ou': 'bad-org', + 'title': 'bad_admin', + 'manager': 'admin', + 'carlicense': 'bad-9999', + } + + api.Command['selfservice_del'](SS_DEFAULT_SELFSERVICE) + try: + with change_principal(SS_USER1, SS_USER1_PASSWORD): + for attr, value in attrs.items(): + with pytest.raises(errors.ACIError): + api.Command['user_mod'](SS_USER1, **{attr: value}) + finally: + api.Command['selfservice_add']( + SS_DEFAULT_SELFSERVICE, + attrs=SS_DEFAULT_SELFSERVICE_ATTRS, + ) + + # usertest_1004: Custom rule grants write access to its specified attrs. + def test_custom_rule_grants_write_access( + self, custom_selfservice_rule): + """Custom rule grants write access to its specified attrs.""" + with change_principal(SS_USER1, SS_USER1_PASSWORD): + api.Command['user_mod']( + SS_USER1, telephonenumber='777-777-7777') + api.Command['user_mod'](SS_USER1, mobile='777-777-7777') + api.Command['user_mod'](SS_USER1, pager='777-777-7777') + api.Command['user_mod']( + SS_USER1, + facsimiletelephonenumber='777-777-7777') + + # usertest_1005: Persisted attrs and user-find by phone, fax, manager. + def test_verify_persisted_attrs(self): + """Verify attrs set by previous tests are persisted.""" + expected = { + 'givenname': 'Good', + 'sn': 'User', + 'cn': 'gooduser', + 'displayname': 'gooduser', + 'initials': 'GU', + 'gecos': 'gooduser@good.example.com', + 'loginshell': '/bin/bash', + 'street': 'Good_Street_Rd', + 'l': 'Good_City', + 'st': 'Goodstate', + 'postalcode': '33333', + 'telephonenumber': '777-777-7777', + 'mobile': '777-777-7777', + 'pager': '777-777-7777', + 'facsimiletelephonenumber': '777-777-7777', + 'ou': 'good-org', + 'title': 'good_admin', + 'carlicense': 'good-3333', + } + + entry = api.Command['user_show'](SS_USER1, all=True)['result'] + for attr, value in expected.items(): + assert_attr_equal(entry, attr, value) + assert_attr_equal(entry, 'manager', SS_GOOD_MANAGER) + + def test_user_find_by_phone(self): + """BZ 1188195: user-find by phone number returns results.""" + result = api.Command['user_find']( + telephonenumber='777-777-7777') + assert result['count'] >= 1 + uids = [e['uid'][0] for e in result['result']] + assert SS_USER1 in uids + + def test_user_find_by_fax(self): + """BZ 1188195: user-find by fax number returns results.""" + result = api.Command['user_find']( + facsimiletelephonenumber='777-777-7777') + assert result['count'] >= 1 + uids = [e['uid'][0] for e in result['result']] + assert SS_USER1 in uids + + def test_user_find_by_manager(self): + """BZ 781208: user-find by manager returns matches.""" + result = api.Command['user_find']( + SS_USER1, manager=SS_GOOD_MANAGER) + assert result['count'] >= 1, ( + 'BZ 781208: user-find --manager did not find matches' + ) + uids = [e['uid'][0] for e in result['result']] + assert SS_USER1 in uids + + # usertest_1006: BZ 985016, 967509: user can modify an allowed attr. + def test_user_can_modify_allowed_attr(self): + """BZ 985016, 967509: user can modify an allowed attr.""" + with change_principal(SS_USER1, SS_USER1_PASSWORD): + api.Command['user_mod'](SS_USER1, mobile='888-888-8888') + entry = api.Command['user_show'](SS_USER1, all=True)['result'] + assert_attr_equal(entry, 'mobile', '888-888-8888') + + # usertest_1007: BZ 985016, 967509: disallowed attribute is rejected. + def test_disallowed_attr_rejected_with_custom_rule( + self, custom_selfservice_rule): + """BZ 985016, 967509: disallowed attribute is rejected.""" + with change_principal(SS_USER1, SS_USER1_PASSWORD): + with pytest.raises(errors.ACIError): + api.Command['user_mod'](SS_USER1, title='Dr') + + # usertest_1008: user-mod fails atomically on mixed attr permissions. + def test_user_mod_atomic_failure_mixed_perms( + self, custom_selfservice_rule): + """user-mod fails atomically when one attr is disallowed.""" + original_title = api.Command['user_show']( + SS_USER1)['result'].get('title') + with change_principal(SS_USER1, SS_USER1_PASSWORD): + with pytest.raises(errors.ACIError): + api.Command['user_mod']( + SS_USER1, + title='notgonnawork', + telephonenumber='999-999-9990', + ) + result = api.Command['user_find']( + SS_USER1, telephonenumber='999-999-9990') + assert result['count'] == 0, ( + 'Phone was changed despite disallowed title in same call' + ) + after = api.Command['user_show'](SS_USER1)['result'] + assert after.get('title') == original_title, ( + 'Title was modified despite being disallowed' + ) + + # usertest_1009: BZ 985013: user can change their own password. + def test_self_password_change_via_passwd(self): + """BZ 985013: user can change their own password via passwd.""" + policy = api.Command['pwpolicy_show']()['result'] + orig_minlife = policy.get('krbminpwdlife', ('1',))[0] + + api.Command['pwpolicy_mod'](krbminpwdlife=0) + try: + with change_principal(SS_USER1, SS_USER1_PASSWORD): + api.Command['passwd']( + SS_USER1, + password='MyN3wP@55', + current_password=SS_USER1_PASSWORD, + ) + # Reset password so the next test can authenticate + unlock_principal_password( + SS_USER1, 'MyN3wP@55', SS_USER1_PASSWORD, + ) + finally: + api.Command['pwpolicy_mod'](krbminpwdlife=int(orig_minlife)) + + def test_self_password_change_via_user_mod(self): + """BZ 985013: user can change their own password via user_mod.""" + policy = api.Command['pwpolicy_show']()['result'] + orig_minlife = policy.get('krbminpwdlife', ('1',))[0] + + api.Command['pwpolicy_mod'](krbminpwdlife=0) + try: + with change_principal(SS_USER1, SS_USER1_PASSWORD): + api.Command['user_mod']( + SS_USER1, + userpassword='MyN3wP@55', + ) + finally: + api.Command['pwpolicy_mod'](krbminpwdlife=int(orig_minlife)) + + # usertest_1010: User cannot modify another user's attributes. + def test_cross_user_modification_rejected(self): + """User cannot modify another user's attributes.""" + with change_principal(SS_USER2, SS_USER2_PASSWORD): + with pytest.raises(errors.ACIError): + api.Command['user_mod'](SS_USER1, mobile='867-5309') + + def test_verify_cross_user_modification_rejected(self): + """Verify attrs did not change after cross-user modification.""" + result = api.Command['user_find'](SS_USER1, mobile='867-5309') + assert result['count'] == 0, ( + 'Mobile was changed by a different user' + )