diff --git a/0118-Add-domain-option-to-ipa-client-automount-for-DNS-di.patch b/0118-Add-domain-option-to-ipa-client-automount-for-DNS-di.patch new file mode 100644 index 0000000..36fb214 --- /dev/null +++ b/0118-Add-domain-option-to-ipa-client-automount-for-DNS-di.patch @@ -0,0 +1,97 @@ +From b394ac6f7ad62d021412d34364a22ea0dc5a6362 Mon Sep 17 00:00:00 2001 +From: Rob Crittenden +Date: Tue, 6 May 2025 09:52:35 -0400 +Subject: [PATCH] Add --domain option to ipa-client-automount for DNS discovery + +If the client machine is not in the IPA DNS domain then discovery +will not find a server. Add a --domain option so that the set of +servers can be discovered. + +Note that --domain is initialized to "" rather than None to match +the behavior in ipa-client-install. + +Fixes: https://pagure.io/freeipa/issue/9780 + +Signed-off-by: Rob Crittenden +Reviewed-By: David Hanina +--- + client/man/ipa-client-automount.1 | 3 +++ + ipaclient/install/ipa_client_automount.py | 8 +++++++- + ipatests/test_integration/test_nfs.py | 24 +++++++++++++++++++++++ + 3 files changed, 34 insertions(+), 1 deletion(-) + +diff --git a/client/man/ipa-client-automount.1 b/client/man/ipa-client-automount.1 +index d55ca994d1a8c413134a3031918bc88ce431adb1..1a061e84b7e499c573d2efc9377a5edf0b7b5f0e 100644 +--- a/client/man/ipa-client-automount.1 ++++ b/client/man/ipa-client-automount.1 +@@ -49,6 +49,9 @@ NFSv4 is also configured. The rpc.gssd and rpc.idmapd are started on clients to + \fB\-\-server\fR=\fISERVER\fR + Set the FQDN of the IPA server to connect to. + .TP ++\fB\-\-domain\fR=\fIDOMAIN\fR ++Primary DNS domain of the IPA deployment to be used for server discovery. ++.TP + \fB\-\-location\fR=\fILOCATION\fR + Automount location. + .TP +diff --git a/ipaclient/install/ipa_client_automount.py b/ipaclient/install/ipa_client_automount.py +index 9f49ff9edeee2648d2be1dea6b09806ba0b5e041..035eefb1c85a33534fdd2640c8f329aa6fb212bf 100644 +--- a/ipaclient/install/ipa_client_automount.py ++++ b/ipaclient/install/ipa_client_automount.py +@@ -61,6 +61,12 @@ def parse_options(): + usage = "%prog [options]\n" + parser = IPAOptionParser(usage=usage) + parser.add_option("--server", dest="server", help="FQDN of IPA server") ++ parser.add_option( ++ "--domain", ++ dest="domain", ++ default="", ++ help="Primary DNS domain of the IPA deployment" ++ ) + parser.add_option( + "--location", + dest="location", +@@ -387,7 +393,7 @@ def configure_automount(): + ds = discovery.IPADiscovery() + if not options.server: + print("Searching for IPA server...") +- ret = ds.search(ca_cert_path=ca_cert_path) ++ ret = ds.search(domain=options.domain, ca_cert_path=ca_cert_path) + logger.debug('Executing DNS discovery') + if ret == discovery.NO_LDAP_SERVER: + logger.debug('Autodiscovery did not find LDAP server') +diff --git a/ipatests/test_integration/test_nfs.py b/ipatests/test_integration/test_nfs.py +index 68cba2988d2c2bd95a60846b0ae999b76fc4c289..32d107b718aecd0944eaeb790af201c17b0ab56a 100644 +--- a/ipatests/test_integration/test_nfs.py ++++ b/ipatests/test_integration/test_nfs.py +@@ -355,3 +355,27 @@ class TestIpaClientAutomountFileRestore(IntegrationTest): + + def test_nsswitch_backup_restore_sssd(self): + self.nsswitch_backup_restore() ++ ++ ++class TestIpaClientAutomountDiscovery(IntegrationTest): ++ ++ num_clients = 1 ++ topology = 'line' ++ ++ def test_automount_invalid_domain(self): ++ """Validate that the --domain option is passed into ++ Discovery. This is expected to fail discovery. ++ """ ++ testdomain = "client.test" ++ msg1 = f"Search for LDAP SRV record in {testdomain}" ++ msg2 = f"Search DNS for SRV record of _ldap._tcp.{testdomain}" ++ msg3 = "Autodiscovery did not find LDAP server" ++ ++ client = self.clients[0] ++ result = client.run_command([ ++ 'ipa-client-automount', '--domain', 'client.test', ++ '--debug' ++ ], stdin_text="n", raiseonerr=False) ++ assert msg1 in result.stderr_text ++ assert msg2 in result.stderr_text ++ assert msg3 in result.stderr_text +-- +2.51.1 + diff --git a/0119-ipatests-Update-ipatests-to-test-topology-with-multi.patch b/0119-ipatests-Update-ipatests-to-test-topology-with-multi.patch new file mode 100644 index 0000000..42aff88 --- /dev/null +++ b/0119-ipatests-Update-ipatests-to-test-topology-with-multi.patch @@ -0,0 +1,378 @@ +From 1f915d4b2a1793a0a3e536604643f80aa76b6b0c Mon Sep 17 00:00:00 2001 +From: Anuja More +Date: Fri, 23 Aug 2024 15:08:57 +0530 +Subject: [PATCH] ipatests: Update ipatests to test topology with multiple + domain. + +Added changes in ipatests so that ipa server-replica-client +can be installed with two domain - ipa.test and trustedipa.test + +Related: https://pagure.io/freeipa/issue/9657 + +Signed-off-by: Anuja More +Reviewed-By: Florence Blanc-Renaud +Reviewed-By: Rob Crittenden +--- + ipatests/pytest_ipa/integration/__init__.py | 34 ++++++++- + ipatests/pytest_ipa/integration/config.py | 30 +++++++- + ipatests/test_integration/base.py | 71 +++++++++++++++++++ + .../test_integration/test_multidomain_ipa.py | 54 ++++++++++++++ + pylint_plugins.py | 12 ++++ + 5 files changed, 198 insertions(+), 3 deletions(-) + create mode 100644 ipatests/test_integration/test_multidomain_ipa.py + +diff --git a/ipatests/pytest_ipa/integration/__init__.py b/ipatests/pytest_ipa/integration/__init__.py +index eb032cd72d2aa2a5ed4c476e3cb04dc77f607eaa..31e909160a0b34b5bb2dd418a20677e4883d318d 100644 +--- a/ipatests/pytest_ipa/integration/__init__.py ++++ b/ipatests/pytest_ipa/integration/__init__.py +@@ -415,6 +415,16 @@ def mh(request, class_integration_logs): + 'type': 'AD_TREEDOMAIN', + 'hosts': {'ad_treedomain': 1} + }) ++ for _i in range(cls.num_trusted_domains): ++ domain_descriptions.append({ ++ 'type': 'TRUSTED_IPA', ++ 'hosts': ++ { ++ 'master': 1, ++ 'replica': cls.num_trusted_replicas, ++ 'client': cls.num_trusted_clients, ++ } ++ }) + + mh = make_multihost_fixture( + request, +@@ -423,10 +433,20 @@ def mh(request, class_integration_logs): + _config=get_global_config(), + ) + +- mh.domain = mh.config.domains[0] ++ for domain in mh.config.domains: ++ if domain.type == 'IPA': ++ mh.domain = domain ++ elif domain.type == 'TRUSTED_IPA': ++ mh.trusted_domain = domain ++ + [mh.master] = mh.domain.hosts_by_role('master') + mh.replicas = mh.domain.hosts_by_role('replica') + mh.clients = mh.domain.hosts_by_role('client') ++ if mh.config.trusted_domains: ++ [mh.trusted_master] = mh.trusted_domain.hosts_by_role('master') ++ mh.trusted_replicas = mh.trusted_domain.hosts_by_role('replica') ++ mh.trusted_clients = mh.trusted_domain.hosts_by_role('client') ++ + ad_domains = mh.config.ad_domains + if ad_domains: + mh.ads = [] +@@ -489,6 +509,12 @@ def add_compat_attrs(cls, mh): + cls.ad_subdomains = mh.ad_subdomains + cls.ad_treedomains = mh.ad_treedomains + ++ cls.trusted_domains = mh.config.trusted_domains ++ if cls.trusted_domains: ++ cls.trusted_master = mh.trusted_master ++ cls.trusted_replicas = mh.trusted_replicas ++ cls.trusted_clients = mh.trusted_clients ++ + + def del_compat_attrs(cls): + """Remove convenience attributes from the test class +@@ -506,6 +532,12 @@ def del_compat_attrs(cls): + del cls.ad_treedomains + del cls.ad_domains + ++ if cls.trusted_domains: ++ del cls.trusted_master ++ del cls.trusted_replicas ++ del cls.trusted_clients ++ del cls.trusted_domains ++ + + def skip_if_fips(reason='Not supported in FIPS mode', host='master'): + if callable(reason): +diff --git a/ipatests/pytest_ipa/integration/config.py b/ipatests/pytest_ipa/integration/config.py +index 1f4dff7f6526953ea8a98604aabd90d80a3c3403..0fb2313db0aa739a1a57f00bd6a0c6675f1616a6 100644 +--- a/ipatests/pytest_ipa/integration/config.py ++++ b/ipatests/pytest_ipa/integration/config.py +@@ -88,13 +88,19 @@ class Config(pytest_multihost.config.Config): + def ad_domains(self): + return [d for d in self.domains if d.is_ad_type] + ++ @property ++ def trusted_domains(self): ++ return [d for d in self.domains if d.is_trusted_ipa_type] ++ + def get_all_hosts(self): + for domain in self.domains: + for host in domain.hosts: + yield host + + def get_all_ipa_hosts(self): +- for ipa_domain in (d for d in self.domains if d.is_ipa_type): ++ for ipa_domain in (d for d in self.domains ++ if d.is_ipa_type or d.is_trusted_ipa_type ++ ): + for ipa_host in ipa_domain.hosts: + yield ipa_host + +@@ -135,7 +141,7 @@ class Domain(pytest_multihost.config.Domain): + self.name = str(name) + self.hosts = [] + +- assert self.is_ipa_type or self.is_ad_type ++ assert self.is_ipa_type or self.is_ad_type or self.is_trusted_ipa_type + self.realm = self.name.upper() + self.basedn = DN(*(('dc', p) for p in name.split('.'))) + +@@ -143,6 +149,10 @@ class Domain(pytest_multihost.config.Domain): + def is_ipa_type(self): + return self.type == 'IPA' + ++ @property ++ def is_trusted_ipa_type(self): ++ return self.type == 'TRUSTED_IPA' ++ + @property + def is_ad_type(self): + return self.type == 'AD' or self.type.startswith('AD_') +@@ -158,6 +168,8 @@ class Domain(pytest_multihost.config.Domain): + return ('ad_subdomain',) + elif self.type == 'AD_TREEDOMAIN': + return ('ad_treedomain',) ++ elif self.type == 'TRUSTED_IPA': ++ return ('trusted_master', 'trusted_replica', 'trusted_client') + else: + raise LookupError(self.type) + +@@ -168,6 +180,8 @@ class Domain(pytest_multihost.config.Domain): + return Host + elif self.is_ad_type: + return WinHost ++ elif self.is_trusted_ipa_type: ++ return Host + else: + raise LookupError(self.type) + +@@ -175,6 +189,10 @@ class Domain(pytest_multihost.config.Domain): + def master(self): + return self.host_by_role('master') + ++ @property ++ def trusted_master(self): ++ return self.host_by_role('trusted_master') ++ + @property + def masters(self): + return self.hosts_by_role('master') +@@ -183,10 +201,18 @@ class Domain(pytest_multihost.config.Domain): + def replicas(self): + return self.hosts_by_role('replica') + ++ @property ++ def trusted_replicas(self): ++ return self.hosts_by_role('replica') ++ + @property + def clients(self): + return self.hosts_by_role('client') + ++ @property ++ def trusted_clients(self): ++ return self.hosts_by_role('client') ++ + @property + def ads(self): + return self.hosts_by_role('ad') +diff --git a/ipatests/test_integration/base.py b/ipatests/test_integration/base.py +index 4717667cb0c48790f77064e6dc300e673d2bb58b..cb34067d4d35cd22a83b34aa265d2df7fb47d371 100644 +--- a/ipatests/test_integration/base.py ++++ b/ipatests/test_integration/base.py +@@ -33,6 +33,7 @@ class IntegrationTest: + num_replicas = 0 + num_clients = 0 + num_ad_domains = 0 ++ num_trusted_domains = 0 + num_ad_subdomains = 0 + num_ad_treedomains = 0 + required_extra_roles = [] +@@ -95,6 +96,7 @@ class IntegrationTest: + cls.clients, domain_level, + random_serial=cls.random_serial, + extra_args=extra_args,) ++ + @classmethod + def uninstall(cls, mh): + for replica in cls.replicas: +@@ -112,3 +114,72 @@ class IntegrationTest: + tasks.uninstall_client(client) + if cls.fips_mode: + cls.disable_fips_mode() ++ ++ ++@ordered ++@pytest.mark.usefixtures('mh') ++@pytest.mark.usefixtures('integration_logs') ++class MultiDomainIntegrationTest(IntegrationTest): ++ num_trusted_domains = 1 ++ num_trusted_replicas = 0 ++ num_trusted_clients = 0 ++ ++ @classmethod ++ def get_domains(cls): ++ return super(MultiDomainIntegrationTest, cls ++ ).get_domains() + cls.trusted_domains ++ ++ @classmethod ++ def install(cls, mh): ++ super(MultiDomainIntegrationTest, cls).install(mh) ++ extra_args = [] ++ if cls.topology is None: ++ return ++ else: ++ if cls.token_password: ++ extra_args.extend(('--token-password', cls.token_password,)) ++ tasks.install_topo(cls.topology, ++ cls.trusted_master, cls.trusted_replicas, ++ cls.trusted_clients, 1, ++ random_serial=cls.random_serial, ++ extra_args=extra_args,) ++ tasks.kinit_admin(cls.master) ++ tasks.kinit_admin(cls.trusted_master) ++ # Now enable dnssec on the zones ++ cls.master.run_command([ ++ "ipa-dns-install", ++ "--dnssec-master", ++ "--forwarder", cls.master.config.dns_forwarder, ++ "-U", ++ ]) ++ cls.master.run_command([ ++ "ipa", "dnszone-mod", cls.master.domain.name, ++ "--dnssec=True" ++ ]) ++ cls.trusted_master.run_command([ ++ "ipa-dns-install", ++ "--dnssec-master", ++ "--forwarder", cls.trusted_master.config.dns_forwarder, ++ "-U", ++ ]) ++ cls.trusted_master.run_command([ ++ "ipa", "dnszone-mod", cls.trusted_master.domain.name, ++ "--dnssec=True" ++ ]) ++ ++ @classmethod ++ def uninstall(cls, mh): ++ super(MultiDomainIntegrationTest, cls).uninstall(mh) ++ for trustedreplica in cls.trusted_replicas: ++ try: ++ tasks.run_server_del( ++ cls.trusted_master, trustedreplica.hostname, force=True, ++ ignore_topology_disconnect=True, ignore_last_of_role=True) ++ except subprocess.CalledProcessError: ++ # If the master has already been uninstalled, ++ # this call may fail ++ pass ++ tasks.uninstall_master(trustedreplica) ++ tasks.uninstall_master(cls.trusted_master) ++ for client in cls.trusted_clients: ++ tasks.uninstall_client(client) +diff --git a/ipatests/test_integration/test_multidomain_ipa.py b/ipatests/test_integration/test_multidomain_ipa.py +new file mode 100644 +index 0000000000000000000000000000000000000000..b1a39a072f0e936d0a7f0f100f9f8ebba729d57c +--- /dev/null ++++ b/ipatests/test_integration/test_multidomain_ipa.py +@@ -0,0 +1,54 @@ ++from ipatests.pytest_ipa.integration import tasks ++from ipatests.test_integration.base import MultiDomainIntegrationTest ++ ++ ++class TestMultidomain(MultiDomainIntegrationTest): ++ num_clients = 1 ++ num_replicas = 1 ++ num_trusted_clients = 1 ++ num_trusted_replicas = 1 ++ topology = 'line' ++ ++ def test_multidomain_trust(self): ++ """ ++ Test services on multidomain topology. ++ """ ++ ++ for host in (self.master, self.replicas[0], ++ self.trusted_master, self.trusted_replicas[0] ++ ): ++ tasks.start_ipa_server(host) ++ ++ for host in (self.master, self.trusted_master): ++ tasks.disable_dnssec_validation(host) ++ tasks.restart_named(host) ++ ++ for host in (self.master, self.replicas[0], ++ self.trusted_master, self.trusted_replicas[0], ++ self.clients[0], self.trusted_clients[0] ++ ): ++ tasks.kinit_admin(host) ++ ++ # Add DNS forwarder to trusted domain on ipa domain ++ self.master.run_command([ ++ "ipa", "dnsforwardzone-add", self.trusted_master.domain.name, ++ "--forwarder", self.trusted_master.ip, ++ "--forward-policy=only" ++ ]) ++ self.trusted_master.run_command([ ++ "ipa", "dnsforwardzone-add", self.master.domain.name, ++ "--forwarder", self.master.ip, ++ "--forward-policy=only" ++ ]) ++ ++ tasks.install_adtrust(self.master) ++ tasks.install_adtrust(self.trusted_master) ++ ++ # Establish trust ++ # self.master.run_command([ ++ # "ipa", "trust-add", "--type=ipa", ++ # "--admin", "admin@{}".format(self.trusted_master.domain.realm), ++ # "--range-type=ipa-ad-trust-posix", ++ # "--password", "--two-way=true", ++ # self.trusted_master.domain.name ++ # ], stdin_text=self.trusted_master.config.admin_password) +diff --git a/pylint_plugins.py b/pylint_plugins.py +index d75da3f4aae9e7bb5083a0618c8d242ce8117f64..75d65f016fde70a783b24689c9bb2c88a7e193de 100644 +--- a/pylint_plugins.py ++++ b/pylint_plugins.py +@@ -566,6 +566,7 @@ AstroidBuilder(MANAGER).string_build( + textwrap.dedent( + """\ + from ipatests.test_integration.base import IntegrationTest ++ from ipatests.test_integration.base import MultiDomainIntegrationTest + from ipatests.pytest_ipa.integration.host import Host, WinHost + from ipatests.pytest_ipa.integration.config import Config, Domain + +@@ -584,6 +585,9 @@ AstroidBuilder(MANAGER).string_build( + def __getitem__(self, key): + return Domain() + ++ class PylintTrustedDomains: ++ def __getitem__(self, key): ++ return Domain() + + Host.config = Config() + Host.domain = Domain() +@@ -596,6 +600,14 @@ AstroidBuilder(MANAGER).string_build( + IntegrationTest.ad_treedomains = PylintWinHosts() + IntegrationTest.ad_subdomains = PylintWinHosts() + IntegrationTest.ad_domains = PylintADDomains() ++ MultiDomainIntegrationTest.domain = Domain() ++ MultiDomainIntegrationTest.master = Host() ++ MultiDomainIntegrationTest.replicas = PylintIPAHosts() ++ MultiDomainIntegrationTest.clients = PylintIPAHosts() ++ MultiDomainIntegrationTest.trusted_master = Host() ++ MultiDomainIntegrationTest.trusted_replicas = PylintIPAHosts() ++ MultiDomainIntegrationTest.trusted_clients = PylintIPAHosts() ++ MultiDomainIntegrationTest.trusted_domains = PylintTrustedDomains() + """ + ) + ) +-- +2.51.1 + diff --git a/0120-ipatests-Add-comprehensive-tests-for-ipa-client-auto.patch b/0120-ipatests-Add-comprehensive-tests-for-ipa-client-auto.patch new file mode 100644 index 0000000..655a296 --- /dev/null +++ b/0120-ipatests-Add-comprehensive-tests-for-ipa-client-auto.patch @@ -0,0 +1,404 @@ +From 902dbeb67e0574dca4c761d058b43af3ac2cef6a Mon Sep 17 00:00:00 2001 +From: Anuja More +Date: Mon, 26 May 2025 20:27:02 +0530 +Subject: [PATCH] ipatests: Add comprehensive tests for ipa-client-automount + --domain option + +- Add parametrized test for domain validation covering valid/invalid formats +- Add cross-domain discovery test showing --domain enables discovery when + client is in different domain than IPA domain +- Validate configuration in sssd.conf after successful automount setup + +The new tests ensure --domain option works correctly and provides proper +hints for DNS discovery in cross-domain scenarios, reducing user friction +compared to requiring --server specification. + +Related: https://pagure.io/freeipa/issue/9780 + +Signed-off-by: Anuja More +Reviewed-By: Rob Crittenden +Reviewed-By: Rob Crittenden +--- + .../nightly_ipa-4-12_latest.yaml | 17 ++ + .../nightly_ipa-4-12_latest_selinux.yaml | 18 ++ + ipatests/prci_definitions/temp_commit.yaml | 4 + + ipatests/test_integration/test_nfs.py | 215 ++++++++++++++++-- + ipatests/test_ipalib/test_util.py | 21 +- + 5 files changed, 255 insertions(+), 20 deletions(-) + +diff --git a/ipatests/prci_definitions/nightly_ipa-4-12_latest.yaml b/ipatests/prci_definitions/nightly_ipa-4-12_latest.yaml +index bc2a10de47bb136e13bf99869fc4f41101e863cb..198c6acec368f2dc11197b55066a042473b27201 100644 +--- a/ipatests/prci_definitions/nightly_ipa-4-12_latest.yaml ++++ b/ipatests/prci_definitions/nightly_ipa-4-12_latest.yaml +@@ -43,6 +43,10 @@ topologies: + name: ad_master_1repl_1client + cpu: 6 + memory: 12096 ++ ipa_ipa_trust: &ipa_ipa_trust ++ name: ipa_ipa_trust ++ cpu: 7 ++ memory: 14750 + + jobs: + fedora-latest-ipa-4-12/build: +@@ -59,6 +63,19 @@ jobs: + timeout: 1800 + topology: *build + ++ fedora-latest-ipa-4-12/nfs_automountdiscovery: ++ requires: [fedora-latest-ipa-4-12/build] ++ priority: 50 ++ job: ++ class: RunPytest ++ args: ++ build_url: '{fedora-latest-ipa-4-12/build_url}' ++ test_suite: test_integration/test_nfs.py::TestIpaClientAutomountDiscovery ++ trusted_domain: True ++ template: *ci-ipa-4-12-latest ++ timeout: 3600 ++ topology: *ipa_ipa_trust ++ + fedora-latest-ipa-4-12/simple_replication: + requires: [fedora-latest-ipa-4-12/build] + priority: 50 +diff --git a/ipatests/prci_definitions/nightly_ipa-4-12_latest_selinux.yaml b/ipatests/prci_definitions/nightly_ipa-4-12_latest_selinux.yaml +index fc31186dfa4dcf863220044a2a5881304b39e76d..8f01bb84126e7f6b2b8e79e8e45475f85a0d8469 100644 +--- a/ipatests/prci_definitions/nightly_ipa-4-12_latest_selinux.yaml ++++ b/ipatests/prci_definitions/nightly_ipa-4-12_latest_selinux.yaml +@@ -43,6 +43,10 @@ topologies: + name: ad_master_1repl_1client + cpu: 6 + memory: 12096 ++ ipa_ipa_trust: &ipa_ipa_trust ++ name: ipa_ipa_trust ++ cpu: 7 ++ memory: 14750 + + jobs: + fedora-latest-ipa-4-12/build: +@@ -59,6 +63,20 @@ jobs: + timeout: 1800 + topology: *build + ++ fedora-latest-ipa-4-12/nfs_automountdiscovery: ++ requires: [fedora-latest-ipa-4-12/build] ++ priority: 50 ++ job: ++ class: RunPytest ++ args: ++ build_url: '{fedora-latest-ipa-4-12/build_url}' ++ selinux_enforcing: True ++ test_suite: test_integration/test_nfs.py::TestIpaClientAutomountDiscovery ++ trusted_domain: True ++ template: *ci-ipa-4-12-latest ++ timeout: 3600 ++ topology: *ipa_ipa_trust ++ + fedora-latest-ipa-4-12/simple_replication: + requires: [fedora-latest-ipa-4-12/build] + priority: 50 +diff --git a/ipatests/prci_definitions/temp_commit.yaml b/ipatests/prci_definitions/temp_commit.yaml +index 24b7b4c48f31522421a7d7a099702a7e92cfbd27..036a1f495f85d6dd9a414bd7ea94446f780711e7 100644 +--- a/ipatests/prci_definitions/temp_commit.yaml ++++ b/ipatests/prci_definitions/temp_commit.yaml +@@ -49,6 +49,10 @@ topologies: + name: ad_master_1repl_1client + cpu: 6 + memory: 12096 ++ ipa_ipa_trust: &ipa_ipa_trust ++ name: ipa_ipa_trust ++ cpu: 7 ++ memory: 14750 + + jobs: + fedora-latest-ipa-4-12/build: +diff --git a/ipatests/test_integration/test_nfs.py b/ipatests/test_integration/test_nfs.py +index 32d107b718aecd0944eaeb790af201c17b0ab56a..49a86fc8ef7441d26a1307e52cac0f6aa8962fcf 100644 +--- a/ipatests/test_integration/test_nfs.py ++++ b/ipatests/test_integration/test_nfs.py +@@ -21,15 +21,35 @@ import time + + import pytest + +-from ipatests.test_integration.base import IntegrationTest ++from ipaplatform.paths import paths ++from ipatests.test_integration.base import ( ++ IntegrationTest, MultiDomainIntegrationTest) + from ipatests.pytest_ipa.integration import tasks +- + # give some time for units to stabilize + # otherwise we get transient errors + WAIT_AFTER_INSTALL = 5 + WAIT_AFTER_UNINSTALL = WAIT_AFTER_INSTALL + + ++def remove_automount(host): ++ time.sleep(WAIT_AFTER_INSTALL) ++ host.run_command([ ++ 'ipa-client-automount', '--uninstall', '-U' ++ ], raiseonerr=False) ++ time.sleep(WAIT_AFTER_UNINSTALL) ++ ++ ++def add_automount(host, extra_args, raiseonerr=False): ++ time.sleep(WAIT_AFTER_UNINSTALL) ++ args = [ ++ "ipa-client-automount", ++ ] ++ args.extend(extra_args) ++ ret = host.run_command(args, raiseonerr=raiseonerr) ++ time.sleep(WAIT_AFTER_INSTALL) ++ return ret ++ ++ + class TestNFS(IntegrationTest): + + num_clients = 3 +@@ -357,25 +377,184 @@ class TestIpaClientAutomountFileRestore(IntegrationTest): + self.nsswitch_backup_restore() + + +-class TestIpaClientAutomountDiscovery(IntegrationTest): ++class TestIpaClientAutomountDiscovery(MultiDomainIntegrationTest): + ++ num_replicas = 0 ++ num_trusted_replicas = 0 + num_clients = 1 +- topology = 'line' ++ num_trusted_clients = 1 ++ topology = "line" ++ ++ def test_automount_valid_domain(self): ++ """Test that --domain option controls which domain is used ++ for DNS SRV record lookup. + +- def test_automount_invalid_domain(self): +- """Validate that the --domain option is passed into +- Discovery. This is expected to fail discovery. ++ Without --domain: client searches in its local domain (fails if no ++ IPA records) With --domain: client searches in the specified ++ domain (succeeds for IPA domain) + """ +- testdomain = "client.test" +- msg1 = f"Search for LDAP SRV record in {testdomain}" +- msg2 = f"Search DNS for SRV record of _ldap._tcp.{testdomain}" +- msg3 = "Autodiscovery did not find LDAP server" ++ testdomain1 = self.master.domain.name ++ client2 = self.trusted_clients[0] ++ tasks.uninstall_client(client2) ++ client2.run_command(["ipa-client-install", "--domain", testdomain1, ++ "--realm", self.master.domain.realm, ++ "--server", self.master.hostname, ++ "-p", client2.config.admin_name, "-w", ++ client2.config.admin_password, "-U"] ++ ) ++ result = add_automount( ++ client2, extra_args=['--debug', '-U'] ++ ) ++ msg = "Search DNS for SRV record of _ldap._tcp.{0}" ++ assert msg.format(client2.domain.name) in result.stderr_text ++ remove_automount(client2) ++ result2 = add_automount( ++ client2, extra_args=['--debug', '--domain', testdomain1, '-U'] ++ ) ++ assert msg.format(testdomain1) in result2.stderr_text + ++ @pytest.mark.parametrize( ++ "domain_input,expected_success,test_description", [ ++ ("{ipa_domain}", True, "valid IPA domain should succeed"), ++ ("client.test", False, "non-IPA domain should fail discovery"), ++ (" example.com ", True, "whitespace should be trimmed"), ++ ("EXAMPLE.COM", True, "uppercase should work"), ++ ++ ]) ++ def test_automount_domain_option_integration( ++ self, domain_input, expected_success, test_description): ++ """Test for --domain affects DNS discovery and system integration. ++ ++ This test verifies that the --domain option actually changes the DNS ++ discovery behavior and system configuration, not just input validation. ++ ++ Test cases: ++ - Valid IPA domain: Should successfully discover IPA services and ++ configure automount correctly ++ - Non-IPA domain: Should fail discovery since no IPA DNS records ++ exist in that domain ++ """ ++ client = self.clients[0] ++ ipa_domain = self.master.domain.name ++ ++ # Replace placeholders in domain_input ++ domain_to_test = domain_input.format(ipa_domain=ipa_domain) ++ ++ if expected_success: ++ # Should succeed ++ add_automount( ++ client, extra_args=['--domain', domain_to_test, ++ '--debug', '-U'] ++ ) ++ # Verify configuration if successful ++ sssd_conf = client.get_file_contents( ++ paths.SSSD_CONF).decode() ++ assert "autofs_provider = ipa" in sssd_conf, \ ++ "Autofs provider should be set to ipa" ++ # Clean up ++ remove_automount(client) ++ else: ++ # Should fail ++ result = client.run_command([ ++ 'ipa-client-automount', '--domain', domain_to_test, ++ '--debug' ++ ], stdin_text="n", raiseonerr=False) ++ assert ( ++ result.returncode != 0 ++ or "Autodiscovery did not find LDAP server" in ++ result.stderr_text ++ or "Invalid domain" in result.stderr_text ++ ), f"Should have failed: {test_description}" ++ ++ def test_automount_domain_option_overrides_discovery(self): ++ """Test that explicit --domain option overrides automatic discovery.""" + client = self.clients[0] +- result = client.run_command([ +- 'ipa-client-automount', '--domain', 'client.test', +- '--debug' +- ], stdin_text="n", raiseonerr=False) +- assert msg1 in result.stderr_text +- assert msg2 in result.stderr_text +- assert msg3 in result.stderr_text ++ ipa_domain = self.master.domain.name ++ ++ # First install without domain to establish baseline ++ result_auto = add_automount(client, extra_args=['--debug', '-U']) ++ assert "Search DNS for SRV record" in result_auto.stderr_text ++ remove_automount(client) ++ ++ # Now with explicit domain ++ result_explicit = add_automount( ++ client, extra_args=['--domain', ipa_domain, '--debug', '-U'] ++ ) ++ explicit_domain_msg = ( ++ f"Using domain '{ipa_domain}'" in result_explicit.stderr_text ++ or f"Search DNS for SRV record of _ldap._tcp.{ipa_domain}" ++ in result_explicit.stderr_text ++ ) ++ ++ assert explicit_domain_msg, \ ++ "Explicit domain should override automatic discovery" ++ ++ # Final cleanup ++ remove_automount(client) ++ ++ def test_automount_domain_hint_for_cross_domain_discovery(self): ++ """Test that --domain option enables discovery when client is in ++ a different domain than the IPA domain. ++ """ ++ client = self.clients[0] ++ other_domain = self.trusted_master.domain.name ++ tasks.uninstall_client(client) ++ # Add DNS forwarder ++ self.master.run_command([ ++ "ipa", "dnsforwardzone-add", self.trusted_master.domain.name, ++ "--forwarder", self.trusted_master.ip, ++ "--forward-policy=only" ++ ]) ++ self.trusted_master.run_command([ ++ "ipa", "dnsforwardzone-add", self.master.domain.name, ++ "--forwarder", self.master.ip, ++ "--forward-policy=only" ++ ]) ++ # Backup original resolv.conf ++ tasks.backup_file(client, paths.RESOLV_CONF) ++ ++ try: ++ # Install client in a domain other than the IPA domain ++ non_ipa_resolv_conf = f"""search {other_domain} ++nameserver {self.trusted_master.ip} ++""" ++ client.put_file_contents(paths.RESOLV_CONF, non_ipa_resolv_conf) ++ ++ # Ensure client is installed for the test ++ tasks.uninstall_client(client) ++ client.run_command(["ipa-client-install", "--domain", other_domain, ++ "--realm", self.trusted_master.domain.realm, ++ "--server", self.trusted_master.hostname, ++ "-p", client.config.admin_name, "-w", ++ client.config.admin_password, "-U"] ++ ) ++ # Verify DNS discovery will fail when client is in Non-IPA domain ++ # Attempt automount with --domain hint (should succeed) ++ nodomain = add_automount( ++ client, extra_args=['--debug', '-U'] ++ ) ++ # Verify discovery fails. ++ assert "DNS record not found" in nodomain.stderr_text ++ remove_automount(client) ++ ++ # Attempt automount with --domain hint (should succeed) ++ withdomain = add_automount( ++ client, extra_args=['--domain', other_domain, ++ '--debug', '-U'] ++ ) ++ # Verify discovery finds a IPA server. ++ assert "DNS record found" in withdomain.stderr_text ++ ipa_discovery = ( ++ f"Validated servers: {self.trusted_master.hostname}" ++ in withdomain.stderr_text ++ ) ++ assert ipa_discovery, \ ++ "Autodiscovery success" ++ # Verify configuration was applied correctly ++ sssd_conf = client.get_file_contents(paths.SSSD_CONF).decode() ++ assert "autofs_provider = ipa" in sssd_conf, \ ++ "Autofs provider should be configured" ++ finally: ++ # Cleanup: restore original resolv.conf and uninstall ++ tasks.restore_files(client) ++ remove_automount(client) +diff --git a/ipatests/test_ipalib/test_util.py b/ipatests/test_ipalib/test_util.py +index 74a32b72c08aef6e01396b26efe6d71f570754cc..ba1b9f120d96b998c6effd55f12c7c9ba80a2565 100644 +--- a/ipatests/test_ipalib/test_util.py ++++ b/ipatests/test_ipalib/test_util.py +@@ -11,8 +11,10 @@ from unittest import mock + import pytest + + from ipalib.util import ( +- get_pager, create_https_connection, get_proper_tls_version_span ++ get_pager, create_https_connection, get_proper_tls_version_span, ++ validate_domain_name + ) ++ + from ipaplatform.constants import constants + + +@@ -27,7 +29,7 @@ from ipaplatform.constants import constants + def test_get_pager(pager, expected_result): + with mock.patch.dict(os.environ, {'PAGER': pager}): + pager = get_pager() +- assert(pager == expected_result or pager.endswith(expected_result)) ++ assert (pager == expected_result or pager.endswith(expected_result)) + + + BASE_CTX = ssl.SSLContext(ssl.PROTOCOL_TLS) +@@ -75,3 +77,18 @@ def test_tls_version_span(minver, maxver, opt, expected): + ctx = getattr(conn, "_context") + assert ctx.options == BASE_OPT | opt + assert ctx.get_ciphers() == BASE_CTX.get_ciphers() ++ ++ ++@pytest.mark.parametrize("domain_input,expected_valid,description", [ ++ ("invalid..domain", False, "double dots should be rejected"), ++ (".invalid.domain", False, "leading dot should be rejected"), ++ ("invalid domain with spaces", False, "spaces should be rejected"), ++ ("toolong" + "x" * 250 + ".domain", False, "overly long domain rejected"), ++ ("", False, "empty string should be rejected"), ++ ("single", False, "single label should be rejected"), ++]) ++def test_validate_domain_name(domain_input, expected_valid, description): ++ """Test domain name validation logic in ipalib.util.validate_domain_name""" ++ ++ with pytest.raises((ValueError, TypeError)): ++ validate_domain_name(domain_input) +-- +2.51.1 + diff --git a/0121-ipatests-remove-xfail-for-PKI-11.7.patch b/0121-ipatests-remove-xfail-for-PKI-11.7.patch new file mode 100644 index 0000000..dc2ef64 --- /dev/null +++ b/0121-ipatests-remove-xfail-for-PKI-11.7.patch @@ -0,0 +1,37 @@ +From b923355ff04dd88b1530d0bb2e032280afc5d315 Mon Sep 17 00:00:00 2001 +From: Florence Blanc-Renaud +Date: Tue, 26 Aug 2025 09:00:48 +0200 +Subject: [PATCH] ipatests: remove xfail for PKI 11.7 + +The test test_ca_show_error_handling is green with PKI 11.7 +because the PKI regression has been fixed. +Update the xfail condition to 11.5 <= version < 11.7. + +Fixes: https://pagure.io/freeipa/issue/9606 +Signed-off-by: Florence Blanc-Renaud +Reviewed-By: Alexander Bokovoy +Reviewed-By: Rob Crittenden +--- + ipatests/test_integration/test_cert.py | 6 ++++-- + 1 file changed, 4 insertions(+), 2 deletions(-) + +diff --git a/ipatests/test_integration/test_cert.py b/ipatests/test_integration/test_cert.py +index 05b20b910b249af24039a497538f96dad07162aa..84adf2ceafe013e6cfc973fb2cb650c40f36971d 100644 +--- a/ipatests/test_integration/test_cert.py ++++ b/ipatests/test_integration/test_cert.py +@@ -558,8 +558,10 @@ class TestCAShowErrorHandling(IntegrationTest): + ) + error_msg = 'ipa: ERROR: The certificate for ' \ + '{} is not available on this server.'.format(lwca) +- bad_version = (tasks.get_pki_version(self.master) +- >= tasks.parse_version('11.5.0')) ++ pki_version = tasks.get_pki_version(self.master) ++ # The regression was introduced in 11.5 and fixed in 11.7 ++ bad_version = (tasks.parse_version('11.5.0') <= pki_version ++ < tasks.parse_version('11.7.0')) + with xfail_context(bad_version, + reason="https://pagure.io/freeipa/issue/9606"): + assert error_msg in result.stderr_text +-- +2.51.1 + diff --git a/0122-ipatests-fix-test_otp.patch b/0122-ipatests-fix-test_otp.patch new file mode 100644 index 0000000..1516b19 --- /dev/null +++ b/0122-ipatests-fix-test_otp.patch @@ -0,0 +1,42 @@ +From 9b631f80720fe1f2492d1a30bb1c2410af5eb587 Mon Sep 17 00:00:00 2001 +From: Florence Blanc-Renaud +Date: Wed, 3 Sep 2025 14:57:52 +0200 +Subject: [PATCH] ipatests: fix test_otp + +The test is performing ssh from the runner to the master +but is using the external_hostname and randomly fails. + +Make sure to use the configured hostname instead. + +Fixes: https://pagure.io/freeipa/issue/9849 +Signed-off-by: Florence Blanc-Renaud +Reviewed-By: Rob Crittenden +--- + ipatests/test_integration/test_otp.py | 4 ++-- + 1 file changed, 2 insertions(+), 2 deletions(-) + +diff --git a/ipatests/test_integration/test_otp.py b/ipatests/test_integration/test_otp.py +index 0babb45897c6107bf354477dbb0d3a805a3116f5..a4adeee69b068b0f889165882ab4ab6c5e2a97f8 100644 +--- a/ipatests/test_integration/test_otp.py ++++ b/ipatests/test_integration/test_otp.py +@@ -504,7 +504,7 @@ class TestOTPToken(IntegrationTest): + ) + with xfail_context(rhel_fail or fedora_fail, reason=github_ticket): + result = ssh_2fa_with_cmd(master, +- self.master.external_hostname, ++ self.master.hostname, + USER3, PASSWORD, otpvalue=otpvalue, + command="klist") + print(result.stdout_text) +@@ -552,7 +552,7 @@ class TestOTPToken(IntegrationTest): + otpvalue = totp.generate(int(time.time())).decode('ascii') + tasks.clear_sssd_cache(self.master) + result = ssh_2fa_with_cmd(master, +- self.master.external_hostname, ++ self.master.hostname, + USER4, PASSWORD, otpvalue=otpvalue, + command="klist") + print(result.stdout_text) +-- +2.51.1 + diff --git a/0123-ipatests-update-the-Let-s-Encrypt-cert-chain.patch b/0123-ipatests-update-the-Let-s-Encrypt-cert-chain.patch new file mode 100644 index 0000000..8bfd4d5 --- /dev/null +++ b/0123-ipatests-update-the-Let-s-Encrypt-cert-chain.patch @@ -0,0 +1,115 @@ +From 94493640e10547cd4aff82b017391916149822e5 Mon Sep 17 00:00:00 2001 +From: Florence Blanc-Renaud +Date: Wed, 17 Sep 2025 10:13:44 +0200 +Subject: [PATCH] ipatests: update the Let's Encrypt cert chain + +The test TestIPACommand::test_cacert_manage is using +Let's Encrypt chain to check the ipa-cacert-manage install +command. +The chain isrgrootx1 > r3 must be replaced with +isrgrootx1 > r12 because r3 expired Sep 15. + +Fixes: https://pagure.io/freeipa/issue/9857 +Signed-off-by: Florence Blanc-Renaud +Reviewed-By: Alexander Bokovoy +Reviewed-By: Rob Crittenden +--- + ipatests/test_integration/test_commands.py | 63 +++++++++++----------- + 1 file changed, 31 insertions(+), 32 deletions(-) + +diff --git a/ipatests/test_integration/test_commands.py b/ipatests/test_integration/test_commands.py +index ad97affe62e15c68442239d669032f0c84e7f5c9..fcf347ee068729d1b28d215b242569f02e9a549c 100644 +--- a/ipatests/test_integration/test_commands.py ++++ b/ipatests/test_integration/test_commands.py +@@ -88,41 +88,40 @@ isrgrootx1 = ( + ) + isrgrootx1_nick = 'CN=ISRG Root X1,O=Internet Security Research Group,C=US' + +-# This sub-CA expires on Sep 15, 2025 and will need to be replaced ++# This sub-CA expires on March 12, 2027 and will need to be replaced + # after this date. Otherwise TestIPACommand::test_cacert_manage fails. +-letsencryptauthorityr3 = ( ++letsencryptauthorityr12 = ( + b'-----BEGIN CERTIFICATE-----\n' +- b'MIIFFjCCAv6gAwIBAgIRAJErCErPDBinU/bWLiWnX1owDQYJKoZIhvcNAQELBQAw\n' ++ b'MIIFBjCCAu6gAwIBAgIRAMISMktwqbSRcdxA9+KFJjwwDQYJKoZIhvcNAQELBQAw\n' + b'TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh\n' +- b'cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMjAwOTA0MDAwMDAw\n' +- b'WhcNMjUwOTE1MTYwMDAwWjAyMQswCQYDVQQGEwJVUzEWMBQGA1UEChMNTGV0J3Mg\n' +- b'RW5jcnlwdDELMAkGA1UEAxMCUjMwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEK\n' +- b'AoIBAQC7AhUozPaglNMPEuyNVZLD+ILxmaZ6QoinXSaqtSu5xUyxr45r+XXIo9cP\n' +- b'R5QUVTVXjJ6oojkZ9YI8QqlObvU7wy7bjcCwXPNZOOftz2nwWgsbvsCUJCWH+jdx\n' +- b'sxPnHKzhm+/b5DtFUkWWqcFTzjTIUu61ru2P3mBw4qVUq7ZtDpelQDRrK9O8Zutm\n' +- b'NHz6a4uPVymZ+DAXXbpyb/uBxa3Shlg9F8fnCbvxK/eG3MHacV3URuPMrSXBiLxg\n' +- b'Z3Vms/EY96Jc5lP/Ooi2R6X/ExjqmAl3P51T+c8B5fWmcBcUr2Ok/5mzk53cU6cG\n' +- b'/kiFHaFpriV1uxPMUgP17VGhi9sVAgMBAAGjggEIMIIBBDAOBgNVHQ8BAf8EBAMC\n' +- b'AYYwHQYDVR0lBBYwFAYIKwYBBQUHAwIGCCsGAQUFBwMBMBIGA1UdEwEB/wQIMAYB\n' +- b'Af8CAQAwHQYDVR0OBBYEFBQusxe3WFbLrlAJQOYfr52LFMLGMB8GA1UdIwQYMBaA\n' +- b'FHm0WeZ7tuXkAXOACIjIGlj26ZtuMDIGCCsGAQUFBwEBBCYwJDAiBggrBgEFBQcw\n' +- b'AoYWaHR0cDovL3gxLmkubGVuY3Iub3JnLzAnBgNVHR8EIDAeMBygGqAYhhZodHRw\n' +- b'Oi8veDEuYy5sZW5jci5vcmcvMCIGA1UdIAQbMBkwCAYGZ4EMAQIBMA0GCysGAQQB\n' +- b'gt8TAQEBMA0GCSqGSIb3DQEBCwUAA4ICAQCFyk5HPqP3hUSFvNVneLKYY611TR6W\n' +- b'PTNlclQtgaDqw+34IL9fzLdwALduO/ZelN7kIJ+m74uyA+eitRY8kc607TkC53wl\n' +- b'ikfmZW4/RvTZ8M6UK+5UzhK8jCdLuMGYL6KvzXGRSgi3yLgjewQtCPkIVz6D2QQz\n' +- b'CkcheAmCJ8MqyJu5zlzyZMjAvnnAT45tRAxekrsu94sQ4egdRCnbWSDtY7kh+BIm\n' +- b'lJNXoB1lBMEKIq4QDUOXoRgffuDghje1WrG9ML+Hbisq/yFOGwXD9RiX8F6sw6W4\n' +- b'avAuvDszue5L3sz85K+EC4Y/wFVDNvZo4TYXao6Z0f+lQKc0t8DQYzk1OXVu8rp2\n' +- b'yJMC6alLbBfODALZvYH7n7do1AZls4I9d1P4jnkDrQoxB3UqQ9hVl3LEKQ73xF1O\n' +- b'yK5GhDDX8oVfGKF5u+decIsH4YaTw7mP3GFxJSqv3+0lUFJoi5Lc5da149p90Ids\n' +- b'hCExroL1+7mryIkXPeFM5TgO9r0rvZaBFOvV2z0gp35Z0+L4WPlbuEjN/lxPFin+\n' +- b'HlUjr8gRsI3qfJOQFy/9rKIJR0Y/8Omwt/8oTWgy1mdeHmmjk7j1nYsvC9JSQ6Zv\n' +- b'MldlTTKB3zhThV1+XWYp6rjd5JW1zbVWEkLNxE7GJThEUG3szgBVGP7pSWTUTsqX\n' +- b'nLRbwHOoq7hHwg==\n' ++ b'cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMjQwMzEzMDAwMDAw\n' ++ b'WhcNMjcwMzEyMjM1OTU5WjAzMQswCQYDVQQGEwJVUzEWMBQGA1UEChMNTGV0J3Mg\n' ++ b'RW5jcnlwdDEMMAoGA1UEAxMDUjEyMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB\n' ++ b'CgKCAQEA2pgodK2+lP474B7i5Ut1qywSf+2nAzJ+Npfs6DGPpRONC5kuHs0BUT1M\n' ++ b'5ShuCVUxqqUiXXL0LQfCTUA83wEjuXg39RplMjTmhnGdBO+ECFu9AhqZ66YBAJpz\n' ++ b'kG2Pogeg0JfT2kVhgTU9FPnEwF9q3AuWGrCf4yrqvSrWmMebcas7dA8827JgvlpL\n' ++ b'Thjp2ypzXIlhZZ7+7Tymy05v5J75AEaz/xlNKmOzjmbGGIVwx1Blbzt05UiDDwhY\n' ++ b'XS0jnV6j/ujbAKHS9OMZTfLuevYnnuXNnC2i8n+cF63vEzc50bTILEHWhsDp7CH4\n' ++ b'WRt/uTp8n1wBnWIEwii9Cq08yhDsGwIDAQABo4H4MIH1MA4GA1UdDwEB/wQEAwIB\n' ++ b'hjAdBgNVHSUEFjAUBggrBgEFBQcDAgYIKwYBBQUHAwEwEgYDVR0TAQH/BAgwBgEB\n' ++ b'/wIBADAdBgNVHQ4EFgQUALUp8i2ObzHom0yteD763OkM0dIwHwYDVR0jBBgwFoAU\n' ++ b'ebRZ5nu25eQBc4AIiMgaWPbpm24wMgYIKwYBBQUHAQEEJjAkMCIGCCsGAQUFBzAC\n' ++ b'hhZodHRwOi8veDEuaS5sZW5jci5vcmcvMBMGA1UdIAQMMAowCAYGZ4EMAQIBMCcG\n' ++ b'A1UdHwQgMB4wHKAaoBiGFmh0dHA6Ly94MS5jLmxlbmNyLm9yZy8wDQYJKoZIhvcN\n' ++ b'AQELBQADggIBAI910AnPanZIZTKS3rVEyIV29BWEjAK/duuz8eL5boSoVpHhkkv3\n' ++ b'4eoAeEiPdZLj5EZ7G2ArIK+gzhTlRQ1q4FKGpPPaFBSpqV/xbUb5UlAXQOnkHn3m\n' ++ b'FVj+qYv87/WeY+Bm4sN3Ox8BhyaU7UAQ3LeZ7N1X01xxQe4wIAAE3JVLUCiHmZL+\n' ++ b'qoCUtgYIFPgcg350QMUIWgxPXNGEncT921ne7nluI02V8pLUmClqXOsCwULw+PVO\n' ++ b'ZCB7qOMxxMBoCUeL2Ll4oMpOSr5pJCpLN3tRA2s6P1KLs9TSrVhOk+7LX28NMUlI\n' ++ b'usQ/nxLJID0RhAeFtPjyOCOscQBA53+NRjSCak7P4A5jX7ppmkcJECL+S0i3kXVU\n' ++ b'y5Me5BbrU8973jZNv/ax6+ZK6TM8jWmimL6of6OrX7ZU6E2WqazzsFrLG3o2kySb\n' ++ b'zlhSgJ81Cl4tv3SbYiYXnJExKQvzf83DYotox3f0fwv7xln1A2ZLplCb0O+l/AK0\n' ++ b'YE0DS2FPxSAHi0iwMfW2nNHJrXcY3LLHD77gRgje4Eveubi2xxa+Nmk/hmhLdIET\n' ++ b'iVDFanoCrMVIpQ59XWHkzdFmoHXHBV7oibVjGSO7ULSQ7MJ1Nz51phuDJSgAIU7A\n' ++ b'0zrLnOrAj/dfrlEWRhCvAgbuwLZX1A2sjNjXoPOHbsPiy+lO1KF8/XY7\n' + b'-----END CERTIFICATE-----\n' + ) +-le_r3_nick = "CN=R3,O=Let's Encrypt,C=US" ++le_r12_nick = "CN=R12,O=Let's Encrypt,C=US" + + # Certificates for reproducing duplicate ipaCertSubject values. + # The trick to creating the second intermediate is for the validity +@@ -1230,7 +1229,7 @@ class TestIPACommand(IntegrationTest): + result.stderr_text + + # Install 3rd party CA's, Let's Encrypt in this case +- for cert in (isrgrootx1, letsencryptauthorityr3): ++ for cert in (isrgrootx1, letsencryptauthorityr12): + certfile = os.path.join(self.master.config.test_dir, 'cert.pem') + self.master.put_file_contents(certfile, cert) + result = self.master.run_command( +@@ -1257,7 +1256,7 @@ class TestIPACommand(IntegrationTest): + + # deletion of a subca + result = self.master.run_command( +- ['ipa-cacert-manage', 'delete', le_r3_nick], ++ ['ipa-cacert-manage', 'delete', le_r12_nick], + raiseonerr=False + ) + assert result.returncode == 0 +-- +2.51.1 + diff --git a/0124-ipatests-fix-TestIPAMigratewithBackupRestore-setup.patch b/0124-ipatests-fix-TestIPAMigratewithBackupRestore-setup.patch new file mode 100644 index 0000000..dc6b842 --- /dev/null +++ b/0124-ipatests-fix-TestIPAMigratewithBackupRestore-setup.patch @@ -0,0 +1,35 @@ +From 325108c7134db2a4ea631ee4b31fc2e1b70580ff Mon Sep 17 00:00:00 2001 +From: Florence Blanc-Renaud +Date: Fri, 19 Sep 2025 16:31:36 +0200 +Subject: [PATCH] ipatests: fix TestIPAMigratewithBackupRestore setup + +The test is installing a first master with DNS enabled, then a +second master (same domain name, with DNS enabled) in order to +perform migration. +Add --allow-zone-overlap to the 2nd master installation. + +Fixes: https://pagure.io/freeipa/issue/9858 +Signed-off-by: Florence Blanc-Renaud +Reviewed-By: Rob Crittenden +Reviewed-By: Sudhir Menon +--- + ipatests/test_integration/test_ipa_ipa_migration.py | 3 ++- + 1 file changed, 2 insertions(+), 1 deletion(-) + +diff --git a/ipatests/test_integration/test_ipa_ipa_migration.py b/ipatests/test_integration/test_ipa_ipa_migration.py +index c6247e772b257748aa0c0f58bd04b53d3756125c..d076510f90aa65d37cfe72b52e915504155aa2e4 100644 +--- a/ipatests/test_integration/test_ipa_ipa_migration.py ++++ b/ipatests/test_integration/test_ipa_ipa_migration.py +@@ -1283,7 +1283,8 @@ class TestIPAMigratewithBackupRestore(IntegrationTest): + def install(cls, mh): + tasks.install_master(cls.master, setup_dns=True, setup_kra=True) + prepare_ipa_server(cls.master) +- tasks.install_master(cls.replicas[0], setup_dns=True, setup_kra=True) ++ tasks.install_master(cls.replicas[0], setup_dns=True, setup_kra=True, ++ extra_args=['--allow-zone-overlap']) + tasks.install_replica(cls.master, cls.replicas[1], + setup_dns=True, setup_kra=True) + +-- +2.51.1 + diff --git a/0125-Add-support-for-libpwpolicy-credit-to-password-polic.patch b/0125-Add-support-for-libpwpolicy-credit-to-password-polic.patch new file mode 100644 index 0000000..e3f7f8a --- /dev/null +++ b/0125-Add-support-for-libpwpolicy-credit-to-password-polic.patch @@ -0,0 +1,1115 @@ +From 18c7bcc302d5cfbf9f1d592e50a908c8d5bd85fc Mon Sep 17 00:00:00 2001 +From: Rob Crittenden +Date: Tue, 24 Jun 2025 16:47:51 -0400 +Subject: [PATCH] Add support for libpwpolicy credit to password policy + +We already have libpwquality plumbed in. The ask here was +to support the quality credits as well. + +There are four credits: digits, uppers, lowers and others + +These either count complexity towards satisfying the minimum +length or set the minimum number of a type allowed. + +If any of the four credits is configured then it will cause +the password policy plugin to go through the libpwquality. + +Reminder that libpwquality sets its own min password length +floor that should be overridden already. + +If min_classes is set as well then that is still enforced. + +Fixes: https://pagure.io/freeipa/issue/9835 + +Signed-off-by: Rob Crittenden +Reviewed-By: David Hanina +--- + ACI.txt | 4 +- + API.txt | 18 +- + VERSION.m4 | 4 +- + daemons/ipa-kdb/ipa_kdb_passwords.c | 4 + + daemons/ipa-kdb/ipa_kdb_pwdpolicy.c | 27 ++- + .../ipa-slapi-plugins/ipa-pwd-extop/common.c | 8 +- + doc/api/pwpolicy_add.md | 10 +- + doc/api/pwpolicy_find.md | 10 +- + doc/api/pwpolicy_mod.md | 10 +- + install/share/60basev2.ldif | 6 +- + ipaserver/plugins/pwpolicy.py | 82 ++++++- + ipatests/test_integration/test_pwpolicy.py | 109 ++++++++- + util/ipa_pwd.c | 212 +++++++++++------- + util/ipa_pwd.h | 12 +- + util/t_policy.c | 113 +++++++++- + 15 files changed, 523 insertions(+), 106 deletions(-) + +diff --git a/ACI.txt b/ACI.txt +index 50c8824d43cd6d3ca9a381b5d34425cb0197508c..0b2cd0cfdbf933b1354f01ce78d10522c19de265 100644 +--- a/ACI.txt ++++ b/ACI.txt +@@ -247,9 +247,9 @@ aci: (targetfilter = "(|(objectclass=ipapwdpolicy)(objectclass=krbpwdpolicy))")( + dn: cn=IPA.EXAMPLE,cn=kerberos,dc=ipa,dc=example + aci: (targetfilter = "(|(objectclass=ipapwdpolicy)(objectclass=krbpwdpolicy))")(version 3.0;acl "permission:System: Delete Group Password Policy";allow (delete) groupdn = "ldap:///cn=System: Delete Group Password Policy,cn=permissions,cn=pbac,dc=ipa,dc=example";) + dn: cn=IPA.EXAMPLE,cn=kerberos,dc=ipa,dc=example +-aci: (targetattr = "ipapwddictcheck || ipapwdmaxrepeat || ipapwdmaxsequence || ipapwdusercheck || krbmaxpwdlife || krbminpwdlife || krbpwdfailurecountinterval || krbpwdhistorylength || krbpwdlockoutduration || krbpwdmaxfailure || krbpwdmindiffchars || krbpwdminlength || passwordgracelimit")(targetfilter = "(|(objectclass=ipapwdpolicy)(objectclass=krbpwdpolicy))")(version 3.0;acl "permission:System: Modify Group Password Policy";allow (write) groupdn = "ldap:///cn=System: Modify Group Password Policy,cn=permissions,cn=pbac,dc=ipa,dc=example";) ++aci: (targetattr = "ipapwddcredit || ipapwddictcheck || ipapwdlcredit || ipapwdmaxrepeat || ipapwdmaxsequence || ipapwdocredit || ipapwducredit || ipapwdusercheck || krbmaxpwdlife || krbminpwdlife || krbpwdfailurecountinterval || krbpwdhistorylength || krbpwdlockoutduration || krbpwdmaxfailure || krbpwdmindiffchars || krbpwdminlength || passwordgracelimit")(targetfilter = "(|(objectclass=ipapwdpolicy)(objectclass=krbpwdpolicy))")(version 3.0;acl "permission:System: Modify Group Password Policy";allow (write) groupdn = "ldap:///cn=System: Modify Group Password Policy,cn=permissions,cn=pbac,dc=ipa,dc=example";) + dn: cn=IPA.EXAMPLE,cn=kerberos,dc=ipa,dc=example +-aci: (targetattr = "cn || cospriority || createtimestamp || entryusn || ipapwddictcheck || ipapwdmaxrepeat || ipapwdmaxsequence || ipapwdusercheck || krbmaxpwdlife || krbminpwdlife || krbpwdfailurecountinterval || krbpwdhistorylength || krbpwdlockoutduration || krbpwdmaxfailure || krbpwdmindiffchars || krbpwdminlength || modifytimestamp || objectclass || passwordgracelimit")(targetfilter = "(|(objectclass=ipapwdpolicy)(objectclass=krbpwdpolicy))")(version 3.0;acl "permission:System: Read Group Password Policy";allow (compare,read,search) groupdn = "ldap:///cn=System: Read Group Password Policy,cn=permissions,cn=pbac,dc=ipa,dc=example";) ++aci: (targetattr = "cn || cospriority || createtimestamp || entryusn || ipapwddcredit || ipapwddictcheck || ipapwdlcredit || ipapwdmaxrepeat || ipapwdmaxsequence || ipapwdocredit || ipapwducredit || ipapwdusercheck || krbmaxpwdlife || krbminpwdlife || krbpwdfailurecountinterval || krbpwdhistorylength || krbpwdlockoutduration || krbpwdmaxfailure || krbpwdmindiffchars || krbpwdminlength || modifytimestamp || objectclass || passwordgracelimit")(targetfilter = "(|(objectclass=ipapwdpolicy)(objectclass=krbpwdpolicy))")(version 3.0;acl "permission:System: Read Group Password Policy";allow (compare,read,search) groupdn = "ldap:///cn=System: Read Group Password Policy,cn=permissions,cn=pbac,dc=ipa,dc=example";) + dn: cn=radiusproxy,dc=ipa,dc=example + aci: (targetattr = "cn || createtimestamp || description || entryusn || ipatokenradiusretries || ipatokenradiusserver || ipatokenradiustimeout || ipatokenusermapattribute || modifytimestamp || objectclass")(targetfilter = "(objectclass=ipatokenradiusconfiguration)")(version 3.0;acl "permission:System: Read Radius Servers";allow (compare,read,search) groupdn = "ldap:///cn=System: Read Radius Servers,cn=permissions,cn=pbac,dc=ipa,dc=example";) + dn: cn=Realm Domains,cn=ipa,cn=etc,dc=ipa,dc=example +diff --git a/API.txt b/API.txt +index f19e3bf344cf6f23680c268c5081570ac629f851..9dee923d703d85c277168d2d0d106e33504fe1ae 100644 +--- a/API.txt ++++ b/API.txt +@@ -4134,14 +4134,18 @@ output: Entry('result') + output: Output('summary', type=[, ]) + output: PrimaryKey('value') + command: pwpolicy_add/1 +-args: 1,19,3 ++args: 1,23,3 + arg: Str('cn', cli_name='group') + option: Str('addattr*', cli_name='addattr') + option: Flag('all', autofill=True, cli_name='all', default=False) + option: Int('cospriority', cli_name='priority') ++option: Int('ipapwddcredit?', cli_name='dcredit', default=0) + option: Bool('ipapwddictcheck?', cli_name='dictcheck', default=False) ++option: Int('ipapwducredit?', cli_name='ucredit', default=0) ++option: Int('ipapwdlcredit?', cli_name='lcredit', default=0) + option: Int('ipapwdmaxrepeat?', cli_name='maxrepeat', default=0) + option: Int('ipapwdmaxsequence?', cli_name='maxsequence', default=0) ++option: Int('ipapwdocredit?', cli_name='ocredit', default=0) + option: Bool('ipapwdusercheck?', cli_name='usercheck', default=False) + option: Int('krbmaxpwdlife?', cli_name='maxlife') + option: Int('krbminpwdlife?', cli_name='minlife') +@@ -4167,14 +4171,18 @@ output: Output('result', type=[]) + output: Output('summary', type=[, ]) + output: ListOfPrimaryKeys('value') + command: pwpolicy_find/1 +-args: 1,21,4 ++args: 1,25,4 + arg: Str('criteria?') + option: Flag('all', autofill=True, cli_name='all', default=False) + option: Str('cn?', autofill=False, cli_name='group') + option: Int('cospriority?', autofill=False, cli_name='priority') ++option: Int('ipapwddcredit?', autofill=False, cli_name='dcredit', default=0) + option: Bool('ipapwddictcheck?', autofill=False, cli_name='dictcheck', default=False) ++option: Int('ipapwducredit?', autofill=False, cli_name='ucredit', default=0) ++option: Int('ipapwdlcredit?', autofill=False, cli_name='lcredit', default=0) + option: Int('ipapwdmaxrepeat?', autofill=False, cli_name='maxrepeat', default=0) + option: Int('ipapwdmaxsequence?', autofill=False, cli_name='maxsequence', default=0) ++option: Int('ipapwdocredit?', autofill=False, cli_name='ocredit', default=0) + option: Bool('ipapwdusercheck?', autofill=False, cli_name='usercheck', default=False) + option: Int('krbmaxpwdlife?', autofill=False, cli_name='maxlife') + option: Int('krbminpwdlife?', autofill=False, cli_name='minlife') +@@ -4195,15 +4203,19 @@ output: ListOfEntries('result') + output: Output('summary', type=[, ]) + output: Output('truncated', type=[]) + command: pwpolicy_mod/1 +-args: 1,21,3 ++args: 1,25,3 + arg: Str('cn?', cli_name='group') + option: Str('addattr*', cli_name='addattr') + option: Flag('all', autofill=True, cli_name='all', default=False) + option: Int('cospriority?', autofill=False, cli_name='priority') + option: Str('delattr*', cli_name='delattr') ++option: Int('ipapwddcredit?', autofill=False, cli_name='dcredit', default=0) + option: Bool('ipapwddictcheck?', autofill=False, cli_name='dictcheck', default=False) ++option: Int('ipapwducredit?', autofill=False, cli_name='ucredit', default=0) ++option: Int('ipapwdlcredit?', autofill=False, cli_name='lcredit', default=0) + option: Int('ipapwdmaxrepeat?', autofill=False, cli_name='maxrepeat', default=0) + option: Int('ipapwdmaxsequence?', autofill=False, cli_name='maxsequence', default=0) ++option: Int('ipapwdocredit?', autofill=False, cli_name='ocredit', default=0) + option: Bool('ipapwdusercheck?', autofill=False, cli_name='usercheck', default=False) + option: Int('krbmaxpwdlife?', autofill=False, cli_name='maxlife') + option: Int('krbminpwdlife?', autofill=False, cli_name='minlife') +diff --git a/VERSION.m4 b/VERSION.m4 +index f91509aa69e3b492dddc8be62ce08bc5c6457615..57a65a5a7d8fdbb78ce190a84cc1b8975b29269f 100644 +--- a/VERSION.m4 ++++ b/VERSION.m4 +@@ -86,8 +86,8 @@ define(IPA_DATA_VERSION, 20100614120000) + # # + ######################################################## + define(IPA_API_VERSION_MAJOR, 2) +-# Last change: add keeponly option to batch command +-define(IPA_API_VERSION_MINOR, 254) ++# Last change: add pwpolicy credit options ++define(IPA_API_VERSION_MINOR, 255) + + ######################################################## + # Following values are auto-generated from values above +diff --git a/daemons/ipa-kdb/ipa_kdb_passwords.c b/daemons/ipa-kdb/ipa_kdb_passwords.c +index 3fc75bf1e7d244ca30effb4c60e52ef76d7f2a01..4ee9dfe6b3580edfe2e54a160cae39a9932f21e6 100644 +--- a/daemons/ipa-kdb/ipa_kdb_passwords.c ++++ b/daemons/ipa-kdb/ipa_kdb_passwords.c +@@ -50,6 +50,10 @@ static krb5_error_code ipapwd_error_to_kerr(krb5_context context, + krb5_set_error_message(context, kerr, "Password reuse not permitted"); + break; + case IPAPWD_POLICY_PWD_COMPLEXITY: ++ case IPAPWD_POLICY_PWD_MIN_DIGITS: ++ case IPAPWD_POLICY_PWD_MIN_UPPERS: ++ case IPAPWD_POLICY_PWD_MIN_LOWERS: ++ case IPAPWD_POLICY_PWD_MIN_OTHERS: + kerr = KADM5_PASS_Q_CLASS; + krb5_set_error_message(context, kerr, "Password is too simple"); + break; +diff --git a/daemons/ipa-kdb/ipa_kdb_pwdpolicy.c b/daemons/ipa-kdb/ipa_kdb_pwdpolicy.c +index 6f21ef86734d0c386780dec678c04226356aba53..4df80f457bac2bfa3755bfc91c200f858ea7f6c0 100644 +--- a/daemons/ipa-kdb/ipa_kdb_pwdpolicy.c ++++ b/daemons/ipa-kdb/ipa_kdb_pwdpolicy.c +@@ -38,6 +38,10 @@ char *std_pwdpolicy_attrs[] = { + "ipapwdmaxsequence", + "ipapwddictcheck", + "ipapwdusercheck", ++ "ipapwddcredit", ++ "ipapwducredit", ++ "ipapwdlcredit", ++ "ipapwdocredit", + + NULL + }; +@@ -51,6 +55,7 @@ krb5_error_code ipadb_get_ipapwd_policy(struct ipadb_context *ipactx, + LDAPMessage *res = NULL; + LDAPMessage *lentry; + uint32_t result; ++ int iresult; + bool resbool; + int ret; + +@@ -146,8 +151,28 @@ krb5_error_code ipadb_get_ipapwd_policy(struct ipadb_context *ipactx, + pol->usercheck = 1; + } + ++ ret = ipadb_ldap_attr_to_int(ipactx->lcontext, lentry, ++ "ipaPwdDcredit", &iresult); + if (ret == 0) { +- pol->max_sequence = result; ++ pol->dcredit = iresult; ++ } ++ ++ ret = ipadb_ldap_attr_to_int(ipactx->lcontext, lentry, ++ "ipaPwdUcredit", &iresult); ++ if (ret == 0) { ++ pol->ucredit = iresult; ++ } ++ ++ ret = ipadb_ldap_attr_to_int(ipactx->lcontext, lentry, ++ "ipaPwdLcredit", &iresult); ++ if (ret == 0) { ++ pol->lcredit = iresult; ++ } ++ ++ ret = ipadb_ldap_attr_to_int(ipactx->lcontext, lentry, ++ "ipaPwdOcredit", &iresult); ++ if (ret == 0) { ++ pol->ocredit = iresult; + } + + *_pol = pol; +diff --git a/daemons/ipa-slapi-plugins/ipa-pwd-extop/common.c b/daemons/ipa-slapi-plugins/ipa-pwd-extop/common.c +index 1355f20d3ab990c81b5b41875d659a9bc9f97085..c85795c1e1c4fa42bde80829861333168ea193e6 100644 +--- a/daemons/ipa-slapi-plugins/ipa-pwd-extop/common.c ++++ b/daemons/ipa-slapi-plugins/ipa-pwd-extop/common.c +@@ -333,7 +333,9 @@ int ipapwd_getPolicy(const char *dn, + "krbPwdMinDiffChars", "krbPwdMinLength", + "krbPwdHistoryLength", "ipaPwdMaxRepeat", + "ipaPwdMaxSequence", "ipaPwdDictCheck", +- "ipaPwdUserCheck", NULL}; ++ "ipaPwdUserCheck", "ipapwdDcredit", ++ "ipaPwdUcredit", "ipaPwdLcredit", ++ "ipaPwdOcredit", NULL}; + Slapi_Entry **es = NULL; + Slapi_Entry *pe = NULL; + int ret, res, scope; +@@ -418,6 +420,10 @@ int ipapwd_getPolicy(const char *dn, + policy->max_sequence = slapi_entry_attr_get_int(pe, "ipaPwdMaxSequence"); + policy->dictcheck = slapi_entry_attr_get_bool(pe, "ipaPwdDictCheck"); + policy->usercheck = slapi_entry_attr_get_bool(pe, "ipaPwdUserCheck"); ++ policy->dcredit = slapi_entry_attr_get_int(pe, "ipaPwdDcredit"); ++ policy->ucredit = slapi_entry_attr_get_int(pe, "ipaPwdUcredit"); ++ policy->lcredit = slapi_entry_attr_get_int(pe, "ipaPwdLcredit"); ++ policy->ocredit = slapi_entry_attr_get_int(pe, "ipaPwdOcredit"); + + ret = 0; + +diff --git a/doc/api/pwpolicy_add.md b/doc/api/pwpolicy_add.md +index 77319754a7002f561f3d9fb9fc2fd5d04d5eb686..2bf660b655db7782203090b0bdbd21e83940f4af 100644 +--- a/doc/api/pwpolicy_add.md ++++ b/doc/api/pwpolicy_add.md +@@ -29,6 +29,14 @@ Add a new group password policy. + * Default: False + * ipapwdusercheck : :ref:`Bool` + * Default: False ++* ipapwddcredit : :ref:`Int` ++ * Default: 0 ++* ipapwducredit : :ref:`Int` ++ * Default: 0 ++* ipapwdlcredit : :ref:`Int` ++ * Default: 0 ++* ipapwdocredit : :ref:`Int` ++ * Default: 0 + * passwordgracelimit : :ref:`Int` + * Default: -1 + * setattr : :ref:`Str` +@@ -47,4 +55,4 @@ Add a new group password policy. + + ### Notes + +-### Version differences +\ No newline at end of file ++### Version differences +diff --git a/doc/api/pwpolicy_find.md b/doc/api/pwpolicy_find.md +index 0c7ecb5d1d4e7d9a55593b99bcdb5eff6c6a4572..7ca80ac72420dcf7e50e20cd4c9380d9a37f11d5 100644 +--- a/doc/api/pwpolicy_find.md ++++ b/doc/api/pwpolicy_find.md +@@ -30,6 +30,14 @@ Search for group password policies. + * Default: False + * ipapwdusercheck : :ref:`Bool` + * Default: False ++* ipapwddcredit : :ref:`Int` ++ * Default: 0 ++* ipapwducredit : :ref:`Int` ++ * Default: 0 ++* ipapwdlcredit : :ref:`Int` ++ * Default: 0 ++* ipapwdocredit : :ref:`Int` ++ * Default: 0 + * passwordgracelimit : :ref:`Int` + * Default: -1 + * timelimit : :ref:`Int` +@@ -51,4 +59,4 @@ Search for group password policies. + + ### Notes + +-### Version differences +\ No newline at end of file ++### Version differences +diff --git a/doc/api/pwpolicy_mod.md b/doc/api/pwpolicy_mod.md +index 7232122787baff75a4a796e341a5428bffb7bcd1..2bc165045f8207ab3e3018fdaf78c9eb865cb6fc 100644 +--- a/doc/api/pwpolicy_mod.md ++++ b/doc/api/pwpolicy_mod.md +@@ -31,6 +31,14 @@ Modify a group password policy. + * Default: False + * ipapwdusercheck : :ref:`Bool` + * Default: False ++* ipapwddcredit : :ref:`Int` ++ * Default: 0 ++* ipapwducredit : :ref:`Int` ++ * Default: 0 ++* ipapwdlcredit : :ref:`Int` ++ * Default: 0 ++* ipapwdocredit : :ref:`Int` ++ * Default: 0 + * passwordgracelimit : :ref:`Int` + * Default: -1 + * setattr : :ref:`Str` +@@ -50,4 +58,4 @@ Modify a group password policy. + + ### Notes + +-### Version differences +\ No newline at end of file ++### Version differences +diff --git a/install/share/60basev2.ldif b/install/share/60basev2.ldif +index 53c857d1b64ae1707c82b653b74d0be13bab36e1..dbaa7cac83b0fa628fcb87cc1b082345a109f1c5 100644 +--- a/install/share/60basev2.ldif ++++ b/install/share/60basev2.ldif +@@ -58,4 +58,8 @@ attributeTypes: (2.16.840.1.113730.3.8.23.2 NAME 'ipaPwdMaxRepeat' EQUALITY inte + attributeTypes: (2.16.840.1.113730.3.8.23.3 NAME 'ipaPwdMaxSequence' EQUALITY integerMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.27 SINGLE-VALUE X-ORIGIN 'IPA v4') + attributeTypes: (2.16.840.1.113730.3.8.23.4 NAME 'ipaPwdDictCheck' EQUALITY booleanMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.7 SINGLE-VALUE X-ORIGIN 'IPA v4') + attributeTypes: (2.16.840.1.113730.3.8.23.5 NAME 'ipaPwdUserCheck' EQUALITY booleanMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.7 SINGLE-VALUE X-ORIGIN 'IPA v4') +-objectClasses: (2.16.840.1.113730.3.8.24.1 NAME 'ipaPwdPolicy' DESC 'IPA Password policy object class' SUP top MAY (ipaPwdMaxRepeat $ ipaPwdMaxSequence $ ipaPwdDictCheck $ ipaPwdUserCheck $ passwordGraceLimit) X-ORIGIN 'IPA v4') ++attributeTypes: (2.16.840.1.113730.3.8.23.28 NAME 'ipaPwdDcredit' EQUALITY integerMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.27 SINGLE-VALUE X-ORIGIN 'IPA v4') ++attributeTypes: (2.16.840.1.113730.3.8.23.29 NAME 'ipaPwdUcredit' EQUALITY integerMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.27 SINGLE-VALUE X-ORIGIN 'IPA v4') ++attributeTypes: (2.16.840.1.113730.3.8.23.30 NAME 'ipaPwdLcredit' EQUALITY integerMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.27 SINGLE-VALUE X-ORIGIN 'IPA v4') ++attributeTypes: (2.16.840.1.113730.3.8.23.31 NAME 'ipaPwdOcredit' EQUALITY integerMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.27 SINGLE-VALUE X-ORIGIN 'IPA v4') ++objectClasses: (2.16.840.1.113730.3.8.24.1 NAME 'ipaPwdPolicy' DESC 'IPA Password policy object class' SUP top MAY (ipaPwdMaxRepeat $ ipaPwdMaxSequence $ ipaPwdDictCheck $ ipaPwdUserCheck $ ipaPwdDcredit $ ipaPwdUcredit $ ipaPwdLcredit $ ipaPwdOcredit $ passwordGraceLimit) X-ORIGIN 'IPA v4') +diff --git a/ipaserver/plugins/pwpolicy.py b/ipaserver/plugins/pwpolicy.py +index 15cfef45b69743c852e43d58b7428976b9e55681..e49a2e1cd35cfd4e5798c58329a7212c4a5b7598 100644 +--- a/ipaserver/plugins/pwpolicy.py ++++ b/ipaserver/plugins/pwpolicy.py +@@ -66,6 +66,20 @@ Grace period defines the number of LDAP logins allowed after expiration. + -1 means do not enforce expiration to match previous behavior. 0 allows + no additional logins after expiration. + ++The pwquality options are either mutually exclusive, or take ++precedence, over the standard password policy values. In the case ++of minimum password length if any pwquality-based options are used then ++the minimum length must be >= 6. ++ ++The "credit" settings are used to adjust password complexity requirements. ++- With a value of 0, the default, the option is ignored. ++- With a positive value each character of that type in the password ++ contributes towards meeting the mininum length requirement. ++ For example, with a password policy of `minlength=6`, `dcredit=1`, ++ these passwords are valid: abcdef or abcd1 ++- With a negative value signifies a minimum number of that character type ++ that must be present. ++ + EXAMPLES: + + Modify the global policy: +@@ -249,6 +263,7 @@ class pwpolicy(LDAPObject): + 'krbpwdlockoutduration', 'ipapwdmaxrepeat', + 'ipapwdmaxsequence', 'ipapwddictcheck', + 'ipapwdusercheck', 'passwordgracelimit', ++ 'ipapwddcredit', 'ipapwducredit', 'ipapwdlcredit', 'ipapwdocredit', + ] + managed_permissions = { + 'System: Read Group Password Policy': { +@@ -261,7 +276,8 @@ class pwpolicy(LDAPObject): + 'krbpwdlockoutduration', 'krbpwdmaxfailure', + 'krbpwdmindiffchars', 'krbpwdminlength', 'objectclass', + 'ipapwdmaxrepeat', 'ipapwdmaxsequence', 'ipapwddictcheck', +- 'ipapwdusercheck', 'passwordgracelimit', ++ 'ipapwdusercheck', 'passwordgracelimit', 'ipapwddcredit', ++ 'ipapwducredit', 'ipapwdlcredit', 'ipapwdocredit', + }, + 'default_privileges': { + 'Password Policy Readers', +@@ -289,7 +305,9 @@ class pwpolicy(LDAPObject): + 'krbpwdhistorylength', 'krbpwdlockoutduration', + 'krbpwdmaxfailure', 'krbpwdmindiffchars', 'krbpwdminlength', + 'ipapwdmaxrepeat', 'ipapwdmaxsequence', 'ipapwddictcheck', +- 'ipapwdusercheck', 'passwordgracelimit', ++ 'ipapwdusercheck', 'passwordgracelimit', 'ipapwddcredit', ++ 'ipapwducredit', 'ipapwdlcredit', 'ipapwdocredit', ++ + }, + 'replaces': [ + '(targetattr = "krbmaxpwdlife || krbminpwdlife || krbpwdhistorylength || krbpwdmindiffchars || krbpwdminlength || krbpwdmaxfailure || krbpwdfailurecountinterval || krbpwdlockoutduration")(target = "ldap:///cn=*,cn=$REALM,cn=kerberos,$SUFFIX")(version 3.0;acl "permission:Modify Group Password Policy";allow (write) groupdn = "ldap:///cn=Modify Group Password Policy,cn=permissions,cn=pbac,$SUFFIX";)', +@@ -400,6 +418,42 @@ class pwpolicy(LDAPObject): + doc=_('Check if the password contains the username'), + default=False, + ), ++ Int( ++ 'ipapwddcredit?', ++ cli_name='dcredit', ++ label=_('Digit Credit'), ++ doc=_('The max credit for digits in the password.'), ++ minvalue=-256, ++ maxvalue=256, ++ default=0, ++ ), ++ Int( ++ 'ipapwducredit?', ++ cli_name='ucredit', ++ label=_('Uppercase Credit'), ++ doc=_('The max credit for uppercase characters in the password.'), ++ minvalue=-256, ++ maxvalue=256, ++ default=0, ++ ), ++ Int( ++ 'ipapwdlcredit?', ++ cli_name='lcredit', ++ label=_('Lowercase Credit'), ++ doc=_('The max credit for lowercase characters in the password.'), ++ minvalue=-256, ++ maxvalue=256, ++ default=0, ++ ), ++ Int( ++ 'ipapwdocredit?', ++ cli_name='ocredit', ++ label=_('Other Credit'), ++ doc=_('The max credit for other characters in the password.'), ++ minvalue=-256, ++ maxvalue=256, ++ default=0, ++ ), + Int( + 'passwordgracelimit?', + cli_name='gracelimit', +@@ -455,14 +509,26 @@ class pwpolicy(LDAPObject): + + def has_pwquality_set(entry): + for attr in ['ipapwdmaxrepeat', 'ipapwdmaxsequence', +- 'ipapwddictcheck', 'ipapwdusercheck']: ++ 'ipapwddictcheck', 'ipapwdusercheck', ++ 'ipapwddcredit', 'ipapwducredit', ++ 'ipapwdlcredit', 'ipapwdocredit',]: ++ val = get_val(entry, attr) ++ if val not in (False, 'FALSE', '0', 0, None): ++ return True ++ return False ++ ++ def has_credit_set(entry): ++ for attr in ['ipapwddcredit', 'ipapwducredit', ++ 'ipapwdlcredit', 'ipapwdocredit',]: + val = get_val(entry, attr) + if val not in (False, 'FALSE', '0', 0, None): + return True + return False + + has_pwquality_value = False ++ has_credit_value = False + min_length = 0 ++ minclasses = False + if not add: + if len(keys) > 0: + existing_entry = self.api.Command.pwpolicy_show( +@@ -473,11 +539,15 @@ class pwpolicy(LDAPObject): + existing_entry.update(entry_attrs) + if existing_entry.get('krbpwdminlength'): + min_length = int(get_val(existing_entry, 'krbpwdminlength')) ++ if existing_entry.get('krbpwdmindiffchars'): ++ minclasses = int(get_val(existing_entry, 'krbpwdmindiffchars')) + has_pwquality_value = has_pwquality_set(existing_entry) ++ has_credit_value = has_credit_set(existing_entry) + else: + if entry_attrs.get('krbpwdminlength'): + min_length = int(get_val(entry_attrs, 'krbpwdminlength')) + has_pwquality_value = has_pwquality_set(entry_attrs) ++ has_credit_value = has_credit_set(entry_attrs) + + if min_length < 6 and has_pwquality_value: + raise errors.ValidationError( +@@ -485,6 +555,12 @@ class pwpolicy(LDAPObject): + error=_('Minimum length must be >= 6 if maxrepeat, ' + 'maxsequence, dictcheck or usercheck are defined') + ) ++ if minclasses > 0 and has_credit_value: ++ raise errors.ValidationError( ++ name='minclasses', ++ error=_('Minimum number of classes cannot be used when ' ++ 'character credit are defined') ++ ) + + def validate_lifetime(self, entry_attrs, add=False, *keys): + """ +diff --git a/ipatests/test_integration/test_pwpolicy.py b/ipatests/test_integration/test_pwpolicy.py +index 652c95e47bdab8bbe137f660d0b2ea2c0496c53e..a627b66ce69e2d554df0bdceef1d24394082a7bd 100644 +--- a/ipatests/test_integration/test_pwpolicy.py ++++ b/ipatests/test_integration/test_pwpolicy.py +@@ -55,15 +55,20 @@ class TestPWPolicy(IntegrationTest): + ), + ) + +- def reset_password(self, host, user=USER, password=PASSWORD): ++ def reset_password(self, host, user=USER, password=PASSWORD, ++ unlock=False): + tasks.kinit_admin(host) + host.run_command( + ['ipa', 'passwd', user], + stdin_text='{password}\n{password}\n'.format(password=password), + ) ++ if unlock: ++ host.run_command(['ipa', 'user-unlock', user]) ++ tasks.kdestroy_all(host) + + def set_pwpolicy(self, minlength=None, maxrepeat=None, maxsequence=None, +- dictcheck=None, usercheck=None, minclasses=None): ++ dictcheck=None, usercheck=None, minclasses=None, ++ dcredit=None, ucredit=None, lcredit=None, ocredit=None): + tasks.kinit_admin(self.master) + args = ["ipa", "pwpolicy-mod", POLICY] + if minlength is not None: +@@ -78,6 +83,14 @@ class TestPWPolicy(IntegrationTest): + args.append("--usercheck={}".format(usercheck)) + if minclasses is not None: + args.append("--minclasses={}".format(minclasses)) ++ if dcredit is not None: ++ args.append("--dcredit={}".format(dcredit)) ++ if ucredit is not None: ++ args.append("--ucredit={}".format(ucredit)) ++ if lcredit is not None: ++ args.append("--lcredit={}".format(lcredit)) ++ if ocredit is not None: ++ args.append("--ocredit={}".format(ocredit)) + self.master.run_command(args) + + self.reset_password(self.master) +@@ -92,7 +105,11 @@ class TestPWPolicy(IntegrationTest): + "--dictcheck" ,"false", + "--minlife", "0", + "--minlength", "0", +- "--minclasses", "0",], ++ "--minclasses", "0", ++ "--dcredit", "0", ++ "--ucredit", "0", ++ "--lcredit", "0", ++ "--ocredit", "0",], + ) + # minlength => 6 is required for any of the libpwquality settings + self.master.run_command( +@@ -226,6 +243,89 @@ class TestPWPolicy(IntegrationTest): + self.kinit_as_user(self.master, PASSWORD, valid) + self.reset_password(self.master) + ++ def test_credits(self, reset_pwpolicy): ++ """Test each credit individually. This would be all copy/paste ++ if done individually so provide a config and set of good ++ and bad passwords to test. ++ ++ Reminder: a positive credit reduces minlength ++ a zero credit is a no-op ++ a negative credit is the minimum required ++ """ ++ def test_policy(good, bad, msg, **options): ++ """Validate the configuration. ++ ++ Given the options in **options, the other arguments are: ++ good: tuple of valid passwords ++ bad: tuple of invalid passwords ++ msg: tuple of expected messages. LDAP may return a different ++ one than kinit. ++ """ ++ ++ tasks.kinit_admin(self.master) ++ self.clean_pwpolicy() ++ self.set_pwpolicy(**options) ++ ++ for password in good: ++ self.kinit_as_user(self.master, PASSWORD, password) ++ self.reset_password(self.master) ++ ++ for password in bad: ++ result = self.kinit_as_user(self.master, PASSWORD, password, ++ raiseonerr=False) ++ assert result.returncode == 1 ++ for message in msg: ++ if message in result.stdout_text: ++ break ++ else: ++ assert False, "%s not in %s" % ( ++ result.stdout_text, ', '.join(msg) ++ ) ++ result = tasks.ldappasswd_user_change(USER, PASSWORD, password, ++ self.master, ++ raiseonerr=False) ++ assert result.returncode == 1 ++ for message in msg: ++ if message in result.stdout_text: ++ break ++ else: ++ assert False, "%s not in %s" % ( ++ result.stdout_text, ', '.join(msg) ++ ) ++ ++ # hint: minlength == 6 ++ # dcredit ++ test_policy(('Pass12', 'password1'), ('123', 'passw'), ++ ('Password is too short',), **{'dcredit': 1}) ++ test_policy(('Passw0rd1', 'password12'), ('Passwd1', 'password1'), ++ ('Password does not contain enough character', ++ 'Password contains too few digits',), ++ **{'dcredit': -2}) ++ ++ # ucredit ++ test_policy(('Passw', 'PASSWORD'), ('PAS', 'passw'), ++ ('Password is too short',), **{'ucredit': 1}) ++ test_policy(('Passw0rD1', 'PasswordS'), ('Passwd1', 'password1'), ++ ('Password does not contain enough character', ++ 'Password contains too few upper characters',), ++ **{'ucredit': -2}) ++ ++ # lcredit ++ test_policy(('PASSw', 'password'), ('pas', 'PASSW'), ++ ('Password is too short',), **{'lcredit': 1}) ++ test_policy(('Passw0rD1', 'PasswordS'), ('PASSWd1', 'PASSWoRD1'), ++ ('Password does not contain enough character', ++ 'Password contains too few lower characters',), ++ **{'lcredit': -2}) ++ ++ # ocredit ++ test_policy(('passw!', 'password#'), ('pas#', 'PAS$'), ++ ('Password is too short',), **{'ocredit': 1}) ++ test_policy(('Passw@rD1$', 'P&sswordS%'), ('passwd*', 'password^'), ++ ('Password does not contain enough character', ++ 'Password contains too few special characters',), ++ **{'ocredit': -2}) ++ + def test_minlength_mod(self, reset_pwpolicy): + """Test that the pwpolicy minlength overrides our policy + """ +@@ -245,7 +345,7 @@ class TestPWPolicy(IntegrationTest): + assert result.returncode != 0 + assert 'minlength' in result.stderr_text + +- # With any pwq value set, setting minlife < 6 should fail ++ # With any pwq value set, setting minlength < 6 should fail + for values in (('--maxrepeat', '4'), + ('--maxsequence', '4'), + ('--dictcheck', 'true'), +@@ -388,6 +488,7 @@ class TestPWPolicy(IntegrationTest): + dn = "uid={user},cn=users,cn=accounts,{base_dn}".format( + user=USER, base_dn=str(self.master.domain.basedn)) + ++ tasks.kinit_admin(self.master) + self.master.run_command( + ["ipa", "pwpolicy-mod", POLICY, "--gracelimit", "0", ], + ) +diff --git a/util/ipa_pwd.c b/util/ipa_pwd.c +index 2abc9d20cefb81ba094a4756b58045216224e399..ba68601066bc42d89ba00037ae3ec8b787a4e858 100644 +--- a/util/ipa_pwd.c ++++ b/util/ipa_pwd.c +@@ -393,6 +393,128 @@ cleanup: + return history; + } + ++/** ++* @brief Returns True/False if any of the pwquality config is set ++* ++* A history element is a base64 string of a hash+salt buffer, prepended ++* by the hash type enclosed within curly braces. ++* ++* @param policy The policy to check against ++* ++* @return a boolean ++*/ ++static bool has_pwquality_rules(struct ipapwd_policy *p) ++{ ++ return (p->max_repeat || p->max_sequence || ++ p->dictcheck || p->usercheck || ++ p->dcredit || p->ucredit || ++ p->lcredit || p->ocredit ); ++} ++ ++/** ++* @brief Runs the libpwquality checks as configured in the policy ++* ++* If any libpwquality policies are defined then the password is ++* passed into libpwquality for validation. The result is converted ++* into a IPA password policy response and returned. ++* ++* @param policy The policy to check against ++* ++* @return int of the result ++*/ ++ ++#if defined(USE_PWQUALITY) ++static int call_pwquality(const struct ipapwd_policy *policy, ++ const char *password, ++ const char *user) ++{ ++ int entropy = 0; ++ char buf[PWQ_MAX_ERROR_MESSAGE_LEN]; ++ void *auxerror; ++ struct pwquality_settings *pwq = pwquality_default_settings(); ++ ++ if (!pwq) { ++ syslog(LOG_ERR, "pwquality defaults failed"); ++ return IPAPWD_POLICY_ERROR; ++ } ++ ++ /* Call libpwquality */ ++ openlog(NULL, LOG_CONS | LOG_NDELAY, LOG_DAEMON); ++ if (pwq == NULL) { ++ syslog(LOG_ERR, "Not able to set pwquality defaults\n"); ++ return IPAPWD_POLICY_ERROR; ++ } ++ if (policy->min_pwd_length < 6) ++ syslog(LOG_WARNING, "password policy min length is < 6. Will be enforced as 6\n"); ++ pwquality_set_int_value(pwq, PWQ_SETTING_MIN_LENGTH, policy->min_pwd_length); ++ pwquality_set_int_value(pwq, PWQ_SETTING_MAX_REPEAT, policy->max_repeat); ++ pwquality_set_int_value(pwq, PWQ_SETTING_MAX_SEQUENCE, policy->max_sequence); ++ pwquality_set_int_value(pwq, PWQ_SETTING_DICT_CHECK, policy->dictcheck); ++ pwquality_set_int_value(pwq, PWQ_SETTING_USER_CHECK, policy->usercheck); ++ pwquality_set_int_value(pwq, PWQ_SETTING_DIG_CREDIT, policy->dcredit); ++ pwquality_set_int_value(pwq, PWQ_SETTING_UP_CREDIT, policy->ucredit); ++ pwquality_set_int_value(pwq, PWQ_SETTING_LOW_CREDIT, policy->lcredit); ++ pwquality_set_int_value(pwq, PWQ_SETTING_OTH_CREDIT, policy->ocredit); ++ ++ entropy = pwquality_check(pwq, password, NULL, user, &auxerror); ++ pwquality_free_settings(pwq); ++ ++#ifdef TEST ++ if (user != NULL) { ++ fprintf(stderr, "Checking password for %s\n", user); ++ } else { ++ fprintf(stderr, "No user provided\n"); ++ } ++ ++ fprintf(stderr, "min length %d\n", policy->min_pwd_length); ++ fprintf(stderr, "max repeat %d\n", policy->max_repeat); ++ fprintf(stderr, "max sequence %d\n", policy->max_sequence); ++ fprintf(stderr, "dict check %d\n", policy->dictcheck); ++ fprintf(stderr, "user check %d\n", policy->usercheck); ++ fprintf(stderr, "digit credit %d\n", policy->dcredit); ++ fprintf(stderr, "upper credit %d\n", policy->ucredit); ++ fprintf(stderr, "lower credit %d\n", policy->lcredit); ++ fprintf(stderr, "other credit %d\n", policy->ocredit); ++#endif ++ ++ if (entropy < 0) { ++#ifdef TEST ++ fprintf(stderr, "Bad password '%s': %s\n", password, pwquality_strerror(buf, sizeof(buf), entropy, auxerror)); ++#endif ++ syslog(LOG_ERR, "Password is rejected with error %d: %s\n", entropy, pwquality_strerror(buf, sizeof(buf), entropy, auxerror)); ++ switch (entropy) { ++ case PWQ_ERROR_MIN_LENGTH: ++ return IPAPWD_POLICY_PWD_TOO_SHORT; ++ case PWQ_ERROR_PALINDROME: ++ return IPAPWD_POLICY_PWD_PALINDROME; ++ case PWQ_ERROR_MAX_CONSECUTIVE: ++ return IPAPWD_POLICY_PWD_CONSECUTIVE; ++ case PWQ_ERROR_MAX_SEQUENCE: ++ return IPAPWD_POLICY_PWD_SEQUENCE; ++ case PWQ_ERROR_CRACKLIB_CHECK: ++ return IPAPWD_POLICY_PWD_DICT_WORD; ++ case PWQ_ERROR_USER_CHECK: ++ return IPAPWD_POLICY_PWD_USER; ++ case PWQ_ERROR_MIN_DIGITS: ++ return IPAPWD_POLICY_PWD_MIN_DIGITS; ++ case PWQ_ERROR_MIN_UPPERS: ++ return IPAPWD_POLICY_PWD_MIN_UPPERS; ++ case PWQ_ERROR_MIN_LOWERS: ++ return IPAPWD_POLICY_PWD_MIN_LOWERS; ++ case PWQ_ERROR_MIN_OTHERS: ++ return IPAPWD_POLICY_PWD_MIN_OTHERS; ++ default: ++ return IPAPWD_POLICY_PWD_COMPLEXITY; ++ } ++#ifdef TEST ++ } else { ++ fprintf(stderr, "Password '%s' is ok, entropy is %d\n", password, entropy); ++#endif ++ } ++ return IPAPWD_POLICY_OK; ++} ++#endif /* USE_PWQUALITY */ ++ + /** + * @brief Funtion used to check password policies on a password change. + * +@@ -417,13 +539,6 @@ int ipapwd_check_policy(struct ipapwd_policy *policy, + { + int pwdlen, blen; + int ret; +-#if defined(USE_PWQUALITY) +- pwquality_settings_t *pwq; +- int check_pwquality = 0; +- int entropy = 0; +- char buf[PWQ_MAX_ERROR_MESSAGE_LEN]; +- void *auxerror; +-#endif + + if (!policy || !password) { + return IPAPWD_POLICY_ERROR; +@@ -453,12 +568,6 @@ int ipapwd_check_policy(struct ipapwd_policy *policy, + + pwdlen = strlen_utf8(password, &blen); + +- if (policy->min_pwd_length) { +- if (pwdlen < policy->min_pwd_length) { +- return IPAPWD_POLICY_PWD_TOO_SHORT; +- } +- } +- + if (policy->min_complexity) { + int num_digits = 0; + int num_alphas = 0; +@@ -541,70 +650,19 @@ int ipapwd_check_policy(struct ipapwd_policy *policy, + * because there are a number of checks that don't have knobs + * so preserve the previous behavior. + */ +- check_pwquality = policy->max_repeat + policy->max_sequence + policy->dictcheck + policy->usercheck; +- +- if (check_pwquality > 0) { +- /* Call libpwquality */ +- openlog(NULL, LOG_CONS | LOG_NDELAY, LOG_DAEMON); +- pwq = pwquality_default_settings(); +- if (pwq == NULL) { +- syslog(LOG_ERR, "Not able to set pwquality defaults\n"); +- return IPAPWD_POLICY_ERROR; +- } +- if (policy->min_pwd_length < 6) +- syslog(LOG_WARNING, "password policy min length is < 6. Will be enforced as 6\n"); +- pwquality_set_int_value(pwq, PWQ_SETTING_MIN_LENGTH, policy->min_pwd_length); +- pwquality_set_int_value(pwq, PWQ_SETTING_MAX_REPEAT, policy->max_repeat); +- pwquality_set_int_value(pwq, PWQ_SETTING_MAX_SEQUENCE, policy->max_sequence); +- pwquality_set_int_value(pwq, PWQ_SETTING_DICT_CHECK, policy->dictcheck); +- pwquality_set_int_value(pwq, PWQ_SETTING_USER_CHECK, policy->usercheck); +- +- entropy = pwquality_check(pwq, password, NULL, user, &auxerror); +- pwquality_free_settings(pwq); +- +-#ifdef TEST +- if (user != NULL) { +- fprintf(stderr, "Checking password for %s\n", user); +- } else { +- fprintf(stderr, "No user provided\n"); +- } +- +- fprintf(stderr, "min length %d\n", policy->min_pwd_length); +- fprintf(stderr, "max repeat %d\n", policy->max_repeat); +- fprintf(stderr, "max sequence %d\n", policy->max_sequence); +- fprintf(stderr, "dict check %d\n", policy->dictcheck); +- fprintf(stderr, "user check %d\n", policy->usercheck); +-#endif +- +- if (entropy < 0) { +-#ifdef TEST +- fprintf(stderr, "Bad password '%s': %s\n", password, pwquality_strerror(buf, sizeof(buf), entropy, auxerror)); +-#endif +- syslog(LOG_ERR, "Password is rejected with error %d: %s\n", entropy, pwquality_strerror(buf, sizeof(buf), entropy, auxerror)); +- switch (entropy) { +- case PWQ_ERROR_MIN_LENGTH: ++ if (has_pwquality_rules(policy)) { ++ ret = call_pwquality(policy, password, user); ++ if (ret != IPAPWD_POLICY_OK) ++ return ret; ++ } else ++#endif /* USE_PWQUALITY */ ++ { ++ if (policy->min_pwd_length) { ++ if (pwdlen < policy->min_pwd_length) { + return IPAPWD_POLICY_PWD_TOO_SHORT; +- case PWQ_ERROR_PALINDROME: +- return IPAPWD_POLICY_PWD_PALINDROME; +- case PWQ_ERROR_MAX_CONSECUTIVE: +- return IPAPWD_POLICY_PWD_CONSECUTIVE; +- case PWQ_ERROR_MAX_SEQUENCE: +- return IPAPWD_POLICY_PWD_SEQUENCE; +- case PWQ_ERROR_CRACKLIB_CHECK: +- return IPAPWD_POLICY_PWD_DICT_WORD; +- case PWQ_ERROR_USER_CHECK: +- return IPAPWD_POLICY_PWD_USER; +- default: +- return IPAPWD_POLICY_PWD_COMPLEXITY; + } +- +-#ifdef TEST +- } else { +- fprintf(stderr, "Password '%s' is ok, entropy is %d\n", password, entropy); +-#endif + } + } +-#endif /* USE_PWQUALITY */ + + if (pwd_history) { + char *hash; +@@ -634,13 +692,17 @@ char * IPAPWD_ERROR_STRINGS[] = { + "Password contains a monotonic sequence", + "Password is based on a dictionary word", + "Password is a palindrone", +- "Password contains username" ++ "Password contains username", ++ "Password contains too few digits", ++ "Password contains too few upper characters", ++ "Password contains too few lower characters", ++ "Password contains too few special characters" + }; + + char * IPAPWD_ERROR_STRING_GENERAL = "Password does not meet the policy requirements"; + + char * ipapwd_error2string(enum ipapwd_error err) { +- if (err < 0 || err > IPAPWD_POLICY_PWD_USER) { ++ if (err < 0 || err > IPAPWD_POLICY_PWD_MIN_OTHERS) { + /* IPAPWD_POLICY_ERROR or out of boundary, return general error */ + return IPAPWD_ERROR_STRING_GENERAL; + } +diff --git a/util/ipa_pwd.h b/util/ipa_pwd.h +index 4bd3a70d4c462df63eb92fcdf1db1f7b0d95ca2f..aa2c6e978d4498ac42b6ef194f0256786cf93892 100644 +--- a/util/ipa_pwd.h ++++ b/util/ipa_pwd.h +@@ -46,7 +46,11 @@ enum ipapwd_error { + IPAPWD_POLICY_PWD_SEQUENCE = 7, + IPAPWD_POLICY_PWD_DICT_WORD = 8, + IPAPWD_POLICY_PWD_PALINDROME = 9, +- IPAPWD_POLICY_PWD_USER = 10 ++ IPAPWD_POLICY_PWD_USER = 10, ++ IPAPWD_POLICY_PWD_MIN_DIGITS = 11, ++ IPAPWD_POLICY_PWD_MIN_UPPERS = 12, ++ IPAPWD_POLICY_PWD_MIN_LOWERS = 13, ++ IPAPWD_POLICY_PWD_MIN_OTHERS = 14 + }; + + struct ipapwd_policy { +@@ -58,11 +62,17 @@ struct ipapwd_policy { + int max_fail; + int failcnt_interval; + int lockout_duration; ++ /* begin libpwquality options */ + int max_repeat; + int max_sequence; + int max_classrepeat; + int dictcheck; + int usercheck; ++ int dcredit; ++ int ucredit; ++ int lcredit; ++ int ocredit; ++ /* end libpwquality options */ + }; + + time_t ipapwd_gentime_to_time_t(char *timestr); +diff --git a/util/t_policy.c b/util/t_policy.c +index 88ac1b53b62609d4f51202e9882da4a72aa10818..3a06498f6089654de29dca254fd7341a562d9cf9 100644 +--- a/util/t_policy.c ++++ b/util/t_policy.c +@@ -16,7 +16,8 @@ static void + set_policy(struct ipapwd_policy *policy, + int min_pwd_length, int min_diff_chars, int max_repeat, + int max_sequence, int max_class_repeat, int dict_check, +- int user_check) ++ int user_check, int dcredit, int ucredit, int lcredit, ++ int ocredit) + + { + /* defaults for things we aren't testing */ +@@ -32,6 +33,10 @@ set_policy(struct ipapwd_policy *policy, + policy->max_classrepeat = max_class_repeat; + policy->dictcheck = dict_check; + policy->usercheck = user_check; ++ policy->dcredit = dcredit; ++ policy->ucredit = ucredit; ++ policy->lcredit = lcredit; ++ policy->ocredit = ocredit; + } + + int main(int argc, const char *argv[]) { +@@ -41,7 +46,7 @@ int main(int argc, const char *argv[]) { + struct ipapwd_policy policy = {0}; + + /* No policy applied */ +- set_policy(&policy, 0, 0, 0, 0, 0, 0, 0); ++ set_policy(&policy, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0); + assert(ipapwd_check_policy(&policy, "Secret123", NULL, 0, 0, 0, 0, NULL) == IPAPWD_POLICY_OK); + assert(ipapwd_check_policy(&policy, "password", NULL, 0, 0, 0, 0, NULL) == IPAPWD_POLICY_OK); + assert(ipapwd_check_policy(&policy, "abcddcba", NULL, 0, 0, 0, 0, NULL) == IPAPWD_POLICY_OK); +@@ -50,7 +55,7 @@ int main(int argc, const char *argv[]) { + assert(ipapwd_check_policy(&policy, "abc", NULL, 3, 0, 0, 0, NULL) == IPAPWD_POLICY_OK); + + /* Max repeats of 1 */ +- set_policy(&policy, 0, 0, 1, 0, 0, 0, 0); ++ set_policy(&policy, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0); + assert(ipapwd_check_policy(&policy, "password", NULL, 0, 0, 0, 0, NULL) == IPAPWD_POLICY_PWD_CONSECUTIVE); + assert(ipapwd_check_policy(&policy, "Assembly", NULL, 0, 0, 0, 0, NULL) == IPAPWD_POLICY_PWD_CONSECUTIVE); + +@@ -58,37 +63,125 @@ int main(int argc, const char *argv[]) { + assert(ipapwd_check_policy(&policy, "abc", NULL, 3, 0, 0, 0, NULL) == IPAPWD_POLICY_PWD_TOO_SHORT); + + /* Max repeats of 2 */ +- set_policy(&policy, 0, 0, 2, 0, 0, 0, 0); ++ set_policy(&policy, 0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0); + assert(ipapwd_check_policy(&policy, "password", NULL, 0, 0, 0, 0, NULL) == IPAPWD_POLICY_OK); + assert(ipapwd_check_policy(&policy, "Assembly", NULL, 0, 0, 0, 0, NULL) == IPAPWD_POLICY_OK); + assert(ipapwd_check_policy(&policy, "permisssive", NULL, 0, 0, 0, 0, NULL) == IPAPWD_POLICY_PWD_CONSECUTIVE); + + /* Max sequence of 1 */ +- set_policy(&policy, 0, 0, 0, 1, 0, 0, 0); ++ set_policy(&policy, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0); + assert(ipapwd_check_policy(&policy, "abacab", NULL, 0, 0, 0, 0, NULL) == IPAPWD_POLICY_PWD_SEQUENCE); + assert(ipapwd_check_policy(&policy, "AbacAb", NULL, 0, 0, 0, 0, NULL) == IPAPWD_POLICY_PWD_SEQUENCE); + + /* Max sequence of 2 */ +- set_policy(&policy, 0, 0, 0, 2, 0, 0, 0); ++ set_policy(&policy, 0, 0, 0, 2, 0, 0, 0, 0, 0, 0, 0); + assert(ipapwd_check_policy(&policy, "AbacAb", NULL, 0, 0, 0, 0, NULL) == IPAPWD_POLICY_OK); + assert(ipapwd_check_policy(&policy, "abacabc", NULL, 0, 0, 0, 0, NULL) == IPAPWD_POLICY_PWD_SEQUENCE); + + /* Palindrone */ +- set_policy(&policy, 0, 0, 0, 0, 0, 0, 0); /* Note there is no policy */ ++ set_policy(&policy, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0); /* Note there is no policy */ + assert(ipapwd_check_policy(&policy, "password", NULL, 0, 0, 0, 0, NULL) == IPAPWD_POLICY_OK); + assert(ipapwd_check_policy(&policy, "abccba", NULL, 0, 0, 0, 0, NULL) == IPAPWD_POLICY_OK); +- set_policy(&policy, 0, 0, 3, 0, 0, 0, 0); /* Set anything */ ++ set_policy(&policy, 0, 0, 3, 0, 0, 0, 0, 0, 0, 0, 0); /* Set anything */ + assert(ipapwd_check_policy(&policy, "abccba", NULL, 0, 0, 0, 0, NULL) == IPAPWD_POLICY_PWD_PALINDROME); + + /* Dictionary check */ +- set_policy(&policy, 0, 0, 0, 0, 0, 1, 0); ++ set_policy(&policy, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0); + assert(ipapwd_check_policy(&policy, "password", NULL, 0, 0, 0, 0, NULL) == IPAPWD_POLICY_PWD_DICT_WORD); + assert(ipapwd_check_policy(&policy, "Secret123", NULL, 0, 0, 0, 0, NULL) == IPAPWD_POLICY_PWD_DICT_WORD); + + /* User check */ + assert(ipapwd_check_policy(&policy, "userPDQ123", "user", 0, 0, 0, 0, NULL) == IPAPWD_POLICY_OK); +- set_policy(&policy, 0, 0, 0, 0, 0, 0, 1); ++ set_policy(&policy, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0); + assert(ipapwd_check_policy(&policy, "userPDQ123", "user", 0, 0, 0, 0, NULL) == IPAPWD_POLICY_PWD_USER); + ++ /* Digit check. ++ * Negative == minimum # of digits required ++ * Zero == skip check ++ * Positive == amount to add towards min length ++ */ ++ set_policy(&policy, 0, 0, 0, 0, 0, 0, 0, -1, 0, 0, 0); ++ assert(ipapwd_check_policy(&policy, "Secret123", NULL, 0, 0, 0, 0, NULL) == IPAPWD_POLICY_OK); ++ set_policy(&policy, 0, 0, 0, 0, 0, 0, 0, -19, 0, 0, 0); ++ assert(ipapwd_check_policy(&policy, "Secret123", NULL, 0, 0, 0, 0, NULL) == IPAPWD_POLICY_PWD_MIN_DIGITS); ++ ++ /* dcredit > 0 gives a "credit" to a shorter password for having ++ * complexity so allows a shorter than minimum length password. ++ * Reminder that with libpwquality the minimum password len is ++ * hardcoded at 6. ++ */ ++ set_policy(&policy, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0); ++ assert(ipapwd_check_policy(&policy, "abcd1", NULL, 0, 0, 0, 0, NULL) == IPAPWD_POLICY_OK); ++ set_policy(&policy, 0, 0, 0, 0, 0, 0, 0, 2, 0, 0, 0); ++ assert(ipapwd_check_policy(&policy, "ab21", NULL, 0, 0, 0, 0, NULL) == IPAPWD_POLICY_OK); ++ ++ /* Verify that no credits are added automatically. We need to set some ++ * pwquality option in order to validate it, so set length. ++ */ ++ set_policy(&policy, 6, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0); ++ assert(ipapwd_check_policy(&policy, "abcd1", NULL, 0, 0, 0, 0, NULL) == IPAPWD_POLICY_PWD_TOO_SHORT); ++ ++ /* Upper check. ++ * Negative == minimum # of uppers required ++ * Zero == skip check ++ * Positive == amount to add towards min length ++ */ ++ set_policy(&policy, 0, 0, 0, 0, 0, 0, 0, 0, -1, 0, 0); ++ assert(ipapwd_check_policy(&policy, "Secret123", NULL, 0, 0, 0, 0, NULL) == IPAPWD_POLICY_OK); ++ set_policy(&policy, 0, 0, 0, 0, 0, 0, 0, 0, -19, 0, 0); ++ assert(ipapwd_check_policy(&policy, "Secret123", NULL, 0, 0, 0, 0, NULL) == IPAPWD_POLICY_PWD_MIN_UPPERS); ++ ++ /* ucredit > 0 gives a "credit" to a shorter password for having ++ * complexity so allows a shorter than minimum length password. ++ * Reminder that with libpwquality the minimum password len is ++ * hardcoded at 6. ++ */ ++ set_policy(&policy, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0); ++ assert(ipapwd_check_policy(&policy, "abcdE", NULL, 0, 0, 0, 0, NULL) == IPAPWD_POLICY_OK); ++ set_policy(&policy, 0, 0, 0, 0, 0, 0, 0, 0, 2, 0, 0); ++ assert(ipapwd_check_policy(&policy, "abDE", NULL, 0, 0, 0, 0, NULL) == IPAPWD_POLICY_OK); ++ ++ /* Verify that no credits are added automatically. We need to set some ++ * pwquality option in order to validate it, so set length. ++ */ ++ set_policy(&policy, 6, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0); ++ assert(ipapwd_check_policy(&policy, "abcdE", NULL, 0, 0, 0, 0, NULL) == IPAPWD_POLICY_PWD_TOO_SHORT); ++ ++ /* Lower check. ++ * Negative == minimum # of uppers required ++ * Zero == skip check ++ * Positive == amount to add towards min length ++ */ ++ set_policy(&policy, 0, 0, 0, 0, 0, 0, 0, 0, 0, -1, 0); ++ assert(ipapwd_check_policy(&policy, "SECREt123", NULL, 0, 0, 0, 0, NULL) == IPAPWD_POLICY_OK); ++ set_policy(&policy, 0, 0, 0, 0, 0, 0, 0, 0, 0, -19, 0); ++ assert(ipapwd_check_policy(&policy, "SECREt123", NULL, 0, 0, 0, 0, NULL) == IPAPWD_POLICY_PWD_MIN_LOWERS); ++ ++ /* lcredit > 0 gives a "credit" to a shorter password for having ++ * complexity so allows a shorter than minimum length password. ++ * Reminder that with libpwquality the minimum password len is ++ * hardcoded at 6. ++ */ ++ set_policy(&policy, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0); ++ assert(ipapwd_check_policy(&policy, "ABCEe", NULL, 0, 0, 0, 0, NULL) == IPAPWD_POLICY_OK); ++ set_policy(&policy, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2, 0); ++ assert(ipapwd_check_policy(&policy, "ABcd", NULL, 0, 0, 0, 0, NULL) == IPAPWD_POLICY_OK); ++ ++ /* Verify that no credits are added automatically. We need to set some ++ * pwquality option in order to validate it, so set length. ++ */ ++ set_policy(&policy, 6, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0); ++ assert(ipapwd_check_policy(&policy, "ABCDE", NULL, 0, 0, 0, 0, NULL) == IPAPWD_POLICY_PWD_TOO_SHORT); ++ ++ /* Mixed credit checks */ ++ set_policy(&policy, 0, 0, 0, 0, 0, 0, 0, -2, -2, -2, 0); ++ assert(ipapwd_check_policy(&policy, "SecreT123", NULL, 0, 0, 0, 0, NULL) == IPAPWD_POLICY_OK); ++ set_policy(&policy, 0, 0, 0, 0, 0, 0, 0, -2, -2, -2, 0); ++ assert(ipapwd_check_policy(&policy, "SECREt123", NULL, 0, 0, 0, 0, NULL) == IPAPWD_POLICY_PWD_MIN_LOWERS); ++ set_policy(&policy, 0, 0, 0, 0, 0, 0, 0, -2, -2, -2, 0); ++ assert(ipapwd_check_policy(&policy, "Secret123", NULL, 0, 0, 0, 0, NULL) == IPAPWD_POLICY_PWD_MIN_UPPERS); ++ set_policy(&policy, 0, 0, 0, 0, 0, 0, 0, -1, -1, -1, -1); ++ assert(ipapwd_check_policy(&policy, "SecreT123", NULL, 0, 0, 0, 0, NULL) == IPAPWD_POLICY_PWD_MIN_OTHERS); ++ + return 0; + } +-- +2.51.1 + diff --git a/0126-ipatests-Refactor-and-port-trust-functional-HBAC-tes.patch b/0126-ipatests-Refactor-and-port-trust-functional-HBAC-tes.patch new file mode 100644 index 0000000..0aec117 --- /dev/null +++ b/0126-ipatests-Refactor-and-port-trust-functional-HBAC-tes.patch @@ -0,0 +1,376 @@ +From 13ab328e519ba3e8e22bcade7680bc060a11d4a1 Mon Sep 17 00:00:00 2001 +From: Anuja More +Date: Tue, 29 Jul 2025 20:46:37 +0530 +Subject: [PATCH] ipatests: Refactor and port trust functional HBAC tests. + +- Tests to cover both root domain and subdomain users: + - Test that adding AD users/groups without the external group to + HBAC rules fails. + - Test HBAC rule denies SSH access for AD users. + - Test HBAC rule allows SSH access for AD users in external group. + - Test HBAC rule denies sudo access for AD users when rule doesn't + include them. + - Test HBAC rule allows sudo access for AD users in external group. + +Related : https://pagure.io/freeipa/issue/9845 + +Signed-off-by: Anuja More +Reviewed-By: Florence Blanc-Renaud +Reviewed-By: Florence Blanc-Renaud +--- + ipatests/test_integration/test_trust.py | 11 +- + .../test_integration/test_trust_functional.py | 306 ++++++++++++++++++ + 2 files changed, 315 insertions(+), 2 deletions(-) + create mode 100644 ipatests/test_integration/test_trust_functional.py + +diff --git a/ipatests/test_integration/test_trust.py b/ipatests/test_integration/test_trust.py +index 4086cb30ac5d52ee595c1ecdbe86a8d511cbb704..7bb74e2f5821719ffe2ceaf2bdcd8e7d46a6cd1f 100644 +--- a/ipatests/test_integration/test_trust.py ++++ b/ipatests/test_integration/test_trust.py +@@ -66,6 +66,10 @@ class BaseTestTrust(IntegrationTest): + if cls.num_ad_subdomains > 0: + cls.child_ad = cls.ad_subdomains[0] + cls.ad_subdomain = cls.child_ad.domain.name ++ cls.subaduser = f"subdomaintestuser@{cls.ad_subdomain}" ++ cls.subaduser2 = f"subdomaindisabledadu@{cls.ad_subdomain}" ++ cls.ad_sub_group = f"subdomaintestgroup@{cls.ad_subdomain}" ++ + if cls.num_ad_treedomains > 0: + cls.tree_ad = cls.ad_treedomains[0] + cls.ad_treedomain = cls.tree_ad.domain.name +@@ -74,6 +78,9 @@ class BaseTestTrust(IntegrationTest): + cls.srv_gc_record_name = \ + '_ldap._tcp.Default-First-Site-Name._sites.gc._msdcs' + cls.srv_gc_record_value = '0 100 389 {}.'.format(cls.master.hostname) ++ cls.aduser = f"nonposixuser@{cls.ad_domain}" ++ cls.aduser2 = f"nonposixuser1@{cls.ad_domain}" ++ cls.ad_group = f"testgroup@{cls.ad_domain}" + + @classmethod + def check_sid_generation(cls): +@@ -1303,8 +1310,8 @@ class TestPosixAutoPrivateGroup(BaseTestTrust): + self.gid_override): + self.mod_idrange_auto_private_group(type) + (uid, gid) = self.get_user_id(self.clients[0], posixuser) +- assert(uid == self.uid_override +- and gid == self.gid_override) ++ assert (uid == self.uid_override ++ and gid == self.gid_override) + result = self.clients[0].run_command(['id', posixuser]) + sssd_version = tasks.get_sssd_version(self.clients[0]) + bad_version = sssd_version >= tasks.parse_version("2.9.4") +diff --git a/ipatests/test_integration/test_trust_functional.py b/ipatests/test_integration/test_trust_functional.py +new file mode 100644 +index 0000000000000000000000000000000000000000..b40bf9675ed5cddaf51624417356ddab28e870a5 +--- /dev/null ++++ b/ipatests/test_integration/test_trust_functional.py +@@ -0,0 +1,306 @@ ++# Copyright (C) 2019 FreeIPA Contributors see COPYING for license ++ ++from __future__ import absolute_import ++ ++from ipaplatform.paths import paths ++from ipatests.pytest_ipa.integration import tasks ++from ipatests.test_integration.test_trust import BaseTestTrust ++ ++ ++class TestTrustFunctionalHbac(BaseTestTrust): ++ topology = 'line' ++ num_ad_treedomains = 0 ++ ++ def _add_hbacrule_with_service(self, rule_name, service_name): ++ self.master.run_command( ++ ["ipa", "hbacrule-add", rule_name, "--hostcat=all"] ++ ) ++ self.master.run_command( ++ [ ++ "ipa", ++ "hbacrule-add-service", ++ rule_name, ++ f"--hbacsvcs={service_name}", ++ ] ++ ) ++ ++ def _disable_allow_all_and_wait(self): ++ tasks.kinit_admin(self.master) ++ self.master.run_command(["ipa", "hbacrule-disable", "allow_all"]) ++ tasks.wait_for_sssd_domain_status_online(self.master) ++ tasks.wait_for_sssd_domain_status_online(self.clients[0]) ++ ++ def _cleanup_hrule_allow_all_and_wait(self, hrule): ++ tasks.kinit_admin(self.master) ++ self.master.run_command(["ipa", "hbacrule-del", hrule]) ++ self.master.run_command(["ipa", "hbacrule-enable", "allow_all"]) ++ tasks.wait_for_sssd_domain_status_online(self.master) ++ tasks.wait_for_sssd_domain_status_online(self.clients[0]) ++ ++ def _ssh_with_password( ++ self, ++ login, ++ host, ++ password, ++ success_expected=False ++ ): ++ result = self.clients[0].run_command( ++ ['sshpass', '-p', password, ++ 'ssh', '-v', '-o', 'StrictHostKeyChecking=no', ++ '-l', login, host, "id"], ++ raiseonerr=success_expected ++ ) ++ output = f"{result.stdout_text}{result.stderr_text}" ++ return output ++ ++ def _get_log_tail(self, host, log_path, start_offset): ++ return host.get_file_contents(log_path)[start_offset:] ++ ++ def test_setup(self): ++ tasks.configure_dns_for_trust(self.master, self.ad) ++ tasks.establish_trust_with_ad( ++ self.master, self.ad_domain, ++ extra_args=['--range-type', 'ipa-ad-trust']) ++ tasks.kdestroy_all(self.master) ++ tasks.kinit_admin(self.master) ++ tasks.group_add( ++ self.master, ++ groupname="hbacgroup_external", ++ extra_args=["--external"], ++ ) ++ tasks.group_add(self.master, groupname="hbacgroup") ++ tasks.group_add_member( ++ self.master, ++ groupname="hbacgroup", ++ extra_args=['--groups=hbacgroup_external'], ++ ) ++ self.master.run_command([ ++ 'ipa', '-n', 'group-add-member', '--external', ++ self.aduser, 'hbacgroup_external', ++ ]) ++ self.master.run_command([ ++ 'ipa', '-n', 'group-add-member', '--external', ++ self.subaduser, 'hbacgroup_external', ++ ]) ++ ++ def test_ipa_trust_func_hbac_0001(self): ++ """ ++ Test that adding AD users/groups without the external group to ++ HBAC rules fails. ++ ++ This test verifies that when attempting to add AD users or ++ groups directly to HABC rules, the operation fails with ++ a "no such entry" error. ++ """ ++ hrule = "hbacrule_hbac_0001" ++ tasks.kinit_admin(self.master) ++ try: ++ self._add_hbacrule_with_service(hrule, 'sudo') ++ for arg in [ ++ f"--users={self.aduser}", f"--users={self.subaduser}", ++ f"--groups={self.ad_group}", f"--groups={self.ad_sub_group}" ++ ]: ++ result = self.master.run_command( ++ ["ipa", "hbacrule-add-user", hrule, arg], ++ raiseonerr=False ++ ) ++ output = f"{result.stdout_text}{result.stderr_text}" ++ assert result.returncode != 0 ++ assert "no such entry" in output ++ finally: ++ self._cleanup_hrule_allow_all_and_wait(hrule) ++ ++ def test_ipa_trust_func_hbac_0002(self): ++ """ ++ Test HBAC rule denies SSH access for AD users. ++ ++ This test creates an HBAC rule that allows SSH access only for admin ++ users/groups, then verifies that AD users from the trusted domain are ++ denied access. The test confirms that the denial is logged with ++ "Access denied by HBAC rules" message. ++ """ ++ hrule = "hbacrule_hbac_0002" ++ tasks.kinit_admin(self.master) ++ log_file = '{0}/sssd_{1}.log'.format( ++ paths.VAR_LOG_SSSD_DIR, self.master.domain.name) ++ try: ++ self._add_hbacrule_with_service(hrule, 'sshd') ++ self.master.run_command( ++ ['ipa', 'hbacrule-add-user', hrule, ++ '--users=admin', '--groups=admins' ++ ] ++ ) ++ self._disable_allow_all_and_wait() ++ for user in [self.aduser, self.subaduser]: ++ logsize = tasks.get_logsize( ++ self.clients[0], log_file ++ ) ++ self._ssh_with_password( ++ user, ++ self.clients[0].hostname, ++ 'Secret123', ++ success_expected=False, ++ ) ++ sssd_logs = self._get_log_tail( ++ self.clients[0], log_file, logsize ++ ) ++ assert b"Access denied by HBAC rules" in sssd_logs ++ finally: ++ self._cleanup_hrule_allow_all_and_wait(hrule) ++ ++ def test_ipa_trust_func_hbac_0005(self): ++ """ ++ Test HBAC rule allows SSH access for AD users in external group. ++ ++ This test creates an HBAC rule that allows SSH access for members of ++ the hbacgroup (which includes AD users via external group membership). ++ It verifies that AD users who are members can successfully SSH, while ++ AD users who are not members are denied access. ++ """ ++ hrule = "hbacrule_hbac_0005" ++ tasks.kinit_admin(self.master) ++ try: ++ self._add_hbacrule_with_service(hrule, 'sshd') ++ self.master.run_command( ++ [ ++ "ipa", ++ "hbacrule-add-user", ++ hrule, ++ "--groups=hbacgroup", ++ ] ++ ) ++ self._disable_allow_all_and_wait() ++ tasks.kinit_admin(self.clients[0]) ++ for user in [self.aduser, self.subaduser]: ++ tasks.kinit_admin(self.clients[0]) ++ self.clients[0].run_command( ++ ["ipa", "hbactest", f"--user={user}", "--service=sshd", ++ f"--host={self.clients[0].hostname}", ++ ] ++ ) ++ tasks.kdestroy_all(self.clients[0]) ++ output = self._ssh_with_password( ++ user, ++ self.clients[0].hostname, ++ 'Secret123', ++ success_expected=True, ++ ) ++ assert "domain users" in output ++ ++ for user2 in [self.aduser2, self.subaduser2]: ++ self._ssh_with_password( ++ user2, ++ self.clients[0].hostname, ++ 'Secret123', ++ success_expected=False, ++ ) ++ finally: ++ self._cleanup_hrule_allow_all_and_wait(hrule) ++ ++ def test_ipa_trust_func_hbac_0008(self): ++ """ ++ Test HBAC rule denies sudo access for AD users when rule doesn't ++ include them. ++ ++ This test creates an HBAC rule for sudo service that only allows ++ admin users, and a sudo rule that allows admin users to run all ++ commands. It then verifies that AD users are denied sudo access ++ due to HBAC restrictions, with the denial being logged as ++ "user NOT authorized on host". ++ """ ++ hrule = "hbacrule_hbac_0008" ++ srule = "sudorule_hbac_0008" ++ tasks.kinit_admin(self.master) ++ try: ++ self._add_hbacrule_with_service(hrule, 'sudo') ++ self.master.run_command( ++ ["ipa", "hbacrule-add-user", hrule, "--users=admin", ++ "--groups=admins"] ++ ) ++ self.master.run_command( ++ [ ++ "ipa", ++ "sudorule-add", ++ srule, ++ "--hostcat=all", ++ "--cmdcat=all", ++ ] ++ ) ++ self.master.run_command( ++ [ ++ "ipa", ++ "sudorule-add-user", ++ srule, ++ "--users=admin", ++ "--groups=admins" ++ ] ++ ) ++ tasks.clear_sssd_cache(self.clients[0]) ++ self._disable_allow_all_and_wait() ++ tasks.kdestroy_all(self.clients[0]) ++ ++ for user in [self.aduser, self.subaduser]: ++ test_sudo = "su {0} -c 'sudo -S id'".format(user) ++ result = self.clients[0].run_command( ++ test_sudo, ++ stdin_text='Secret123', ++ raiseonerr=False ++ ) ++ output = f"{result.stdout_text}{result.stderr_text}" ++ assert ( ++ "sudo: PAM account management error: Permission denied" ++ in output ++ ) ++ finally: ++ self._cleanup_hrule_allow_all_and_wait(hrule) ++ self.master.run_command(["ipa", "sudorule-del", srule]) ++ ++ def test_ipa_trust_func_hbac_0011(self): ++ """ ++ Test HBAC rule allows sudo access for AD users in external group. ++ ++ This test creates an HBAC rule for sudo service that allows members of ++ the hbacgroup (which includes AD users via external group membership), ++ and a sudo rule that allows hbacgroup members to run all commands. ++ It verifies that AD users who are members of the external group can ++ successfully use sudo and gain root privileges. ++ """ ++ hrule = "ipa_trust_func_hbac_0011" ++ srule = "ipa_trust_func_hbac_0011" ++ tasks.clear_sssd_cache(self.master) ++ tasks.kinit_admin(self.master) ++ try: ++ self._add_hbacrule_with_service(hrule, 'sudo') ++ ++ self.master.run_command( ++ ["ipa", "hbacrule-add-user", hrule, "--groups=hbacgroup"] ++ ) ++ self.master.run_command(["ipa", "hbacrule-disable", "allow_all"]) ++ self.master.run_command( ++ ["ipa", "sudorule-add", srule, "--hostcat=all", "--cmdcat=all"] ++ ) ++ self.master.run_command( ++ ["ipa", "sudorule-add-user", srule, "--groups=hbacgroup"] ++ ) ++ tasks.clear_sssd_cache(self.master) ++ tasks.clear_sssd_cache(self.clients[0]) ++ tasks.wait_for_sssd_domain_status_online(self.master) ++ test_sudo = "su {user} -c 'sudo -S id'" ++ for user in [self.aduser, self.subaduser]: ++ with self.clients[0].spawn_expect( ++ test_sudo.format(user=user)) as e: ++ e.sendline('Secret123') ++ e.expect_exit(ignore_remaining_output=True, timeout=60) ++ output = e.get_last_output() ++ assert 'uid=0(root)' in output ++ for user in [self.aduser2, self.subaduser2]: ++ test_sudo = "su {0} -c 'sudo -S id'".format(user) ++ result = self.clients[0].run_command( ++ test_sudo, ++ stdin_text='Secret123', ++ raiseonerr=False ++ ) ++ assert result.returncode != 0 ++ finally: ++ self._cleanup_hrule_allow_all_and_wait(hrule) ++ self.master.run_command(["ipa", "sudorule-del", srule]) +-- +2.51.1 + diff --git a/0127-ipatests-skip-encrypted-dns-tests-on-fedora-41.patch b/0127-ipatests-skip-encrypted-dns-tests-on-fedora-41.patch new file mode 100644 index 0000000..8d73c06 --- /dev/null +++ b/0127-ipatests-skip-encrypted-dns-tests-on-fedora-41.patch @@ -0,0 +1,55 @@ +From 5b10d0eebffe0aaec7e7cb7974b8299905d289e9 Mon Sep 17 00:00:00 2001 +From: Florence Blanc-Renaud +Date: Mon, 2 Jun 2025 15:03:40 +0200 +Subject: [PATCH] ipatests: skip encrypted dns tests on fedora 41 + +The package ipa-server-encrypted-dns is not available on fedora 41 +as it requires a more recent bind version. +Skip the tests that require this package in f41. + +Fixes: https://pagure.io/freeipa/issue/9799 +Signed-off-by: Florence Blanc-Renaud +Reviewed-By: Rob Crittenden +Reviewed-By: David Hanina +--- + ipatests/test_integration/test_edns.py | 8 ++++++++ + 1 file changed, 8 insertions(+) + +diff --git a/ipatests/test_integration/test_edns.py b/ipatests/test_integration/test_edns.py +index dd046f226926d09074d8d6ce536999c5d452fcc4..1f843c7bcc5f8420740175ca03bcdc1ddf59ce09 100644 +--- a/ipatests/test_integration/test_edns.py ++++ b/ipatests/test_integration/test_edns.py +@@ -4,15 +4,20 @@ + """This covers tests for DNS over TLS related feature""" + + from __future__ import absolute_import ++import pytest + import textwrap + + from ipatests.pytest_ipa.integration import tasks + from ipatests.test_integration.base import IntegrationTest + from ipatests.test_integration.test_dns import TestDNS + from ipatests.pytest_ipa.integration.firewall import Firewall ++from ipaplatform.osinfo import osinfo + from ipaplatform.paths import paths + + ++@pytest.mark.skipif( ++ osinfo.id == 'fedora' and osinfo.version_number == (41,), ++ reason='Encrypted DNS not supported in fedora 41') + class TestDNSOverTLS(IntegrationTest): + """Tests for DNS over TLS feature.""" + +@@ -246,6 +251,9 @@ class TestDNSOverTLS(IntegrationTest): + assert '''--dns-over-tls Configure DNS over TLS''' in cmdout.stdout_text # noqa: E501 + + ++@pytest.mark.skipif( ++ osinfo.id == 'fedora' and osinfo.version_number == (41,), ++ reason='Encrypted DNS not supported in fedora 41') + class TestDNS_DoT(TestDNS): + + @classmethod +-- +2.51.1 + diff --git a/0128-Extended-eDNS-testsuite-with-Relaxed-policy-testcase.patch b/0128-Extended-eDNS-testsuite-with-Relaxed-policy-testcase.patch new file mode 100644 index 0000000..71f10f1 --- /dev/null +++ b/0128-Extended-eDNS-testsuite-with-Relaxed-policy-testcase.patch @@ -0,0 +1,367 @@ +From 79eaa672eeed6f6eb2f92ec97150fb154e963eb4 Mon Sep 17 00:00:00 2001 +From: PRANAV THUBE +Date: Wed, 20 Aug 2025 15:55:55 +0530 +Subject: [PATCH] Extended eDNS testsuite with Relaxed policy testcases. 1. + Relaxed policy without certs and including --no-dnssec-validation 2. Relaxed + policy with external CA and including --no-dnssec-validation + +Automated with Cursor+Claude +Related: https://issues.redhat.com/browse/IDM-2894 + +Signed-off-by: PRANAV THUBE +Reviewed-By: Alexander Bokovoy +Reviewed-By: Antonio Torres +--- + ipatests/test_integration/test_edns.py | 315 ++++++++++++++++--------- + 1 file changed, 202 insertions(+), 113 deletions(-) + +diff --git a/ipatests/test_integration/test_edns.py b/ipatests/test_integration/test_edns.py +index 1f843c7bcc5f8420740175ca03bcdc1ddf59ce09..6556c46ce2040ea5ce94bebe69cff06e727af64a 100644 +--- a/ipatests/test_integration/test_edns.py ++++ b/ipatests/test_integration/test_edns.py +@@ -6,7 +6,9 @@ + from __future__ import absolute_import + import pytest + import textwrap +- ++import os ++from ipatests.test_integration.test_caless import ExternalCA ++from cryptography.hazmat.primitives import serialization + from ipatests.pytest_ipa.integration import tasks + from ipatests.test_integration.base import IntegrationTest + from ipatests.test_integration.test_dns import TestDNS +@@ -15,6 +17,54 @@ from ipaplatform.osinfo import osinfo + from ipaplatform.paths import paths + + ++def verify_queries_encrypted(master, replicas, clients, ++ forwarder="1.1.1.1#853", ++ dns_hostname="freeipa.org"): ++ """ ++ Helper function to verify that queries are encrypted and ++ routed to the specified forwarder. ++ """ ++ unbound_log_cfg = textwrap.dedent(""" ++ server: ++ verbosity: 3 ++ log-queries: yes ++ cache-min-ttl: 0 ++ cache-max-ttl: 0 ++ """) ++ ++ for server in [master] + replicas: ++ server.put_file_contents( ++ os.path.join(paths.UNBOUND_CONFIG_DIR, "log.conf"), ++ unbound_log_cfg, ++ ) ++ server.run_command(["systemctl", "restart", "unbound"]) ++ server.run_command( ++ ["journalctl", "--flush", "--rotate", "--vacuum-time=1s"] ++ ) ++ server.run_command(["dig", dns_hostname]) ++ log_output = server.run_command( ++ ["journalctl", "-u", "unbound", "--grep", forwarder] ++ ) ++ assert forwarder in log_output.stdout_text, ( ++ f"Forwarder {forwarder} not found in logs on " ++ f"{server.hostname}" ++ ) ++ server.run_command( ++ ["journalctl", "--flush", "--rotate", "--vacuum-time=1s"] ++ ) ++ ++ for client in clients: ++ client.run_command(["dig", dns_hostname]) ++ log_output = master.run_command( ++ ["journalctl", "-u", "unbound", "--grep", forwarder] ++ ) ++ ++ assert forwarder in log_output.stdout_text, ( ++ f"Forwarder {forwarder} not found in logs on master for " ++ f"client {client.hostname}" ++ ) ++ ++ + @pytest.mark.skipif( + osinfo.id == 'fedora' and osinfo.version_number == (41,), + reason='Encrypted DNS not supported in fedora 41') +@@ -83,118 +133,6 @@ class TestDNSOverTLS(IntegrationTest): + "--setup-dns, ignoring") in res.stdout_text + tasks.uninstall_master(self.master) + +- def test_install_dnsovertls_master(self): +- """ +- This tests installs IPA server with --dns-over-tls option. +- """ +- args = [ +- "--dns-over-tls", +- "--dot-forwarder", "1.1.1.1#cloudflare-dns.com", +- ] +- return tasks.install_master(self.master, extra_args=args) +- +- def test_install_dnsovertls_client(self): +- """ +- This tests installs IPA client with --dns-over-tls option. +- """ +- self.clients[0].put_file_contents( +- paths.RESOLV_CONF, +- "nameserver %s" % self.master.ip +- ) +- args = [ +- "--dns-over-tls" +- ] +- return tasks.install_client(self.master, +- self.clients[0], +- nameservers=None, +- extra_args=args) +- +- def test_install_dnsovertls_replica(self): +- """ +- This tests installs IPA replica with --dns-over-tls option. +- """ +- args = [ +- "--dns-over-tls", +- "--dot-forwarder", "1.1.1.1#cloudflare-dns.com", +- ] +- return tasks.install_replica(self.master, self.replicas[0], +- setup_dns=True, extra_args=args) +- +- def test_queries_encrypted(self): +- """ +- This test performs queries from each of the hosts +- and ensures they were routed to 1.1.1.1#853 (eDNS). +- """ +- unbound_log_cfg = textwrap.dedent(""" +- server: +- verbosity: 3 +- log-queries: yes +- """) +- # Test servers first (querying to local Unbound) +- for server in [self.master, self.replicas[0]]: +- server.put_file_contents("/etc/unbound/conf.d/log.conf", +- unbound_log_cfg) +- server.run_command(["systemctl", "restart", "unbound"]) +- server.run_command(["journalctl", "--flush", "--rotate", +- "--vacuum-time=1s"]) +- server.run_command(["dig", "freeipa.org"]) +- server.run_command(["journalctl", "-u", "unbound", +- "--grep=1.1.1.1#853"]) +- server.run_command(["journalctl", "--flush", "--rotate", +- "--vacuum-time=1s"]) +- # Now, test the client (redirects query to master) +- self.clients[0].run_command(["dig", "redhat.com"]) +- self.master.run_command(["journalctl", "-u", "unbound", +- "--grep=1.1.1.1#853"]) +- +- def test_uninstall_all(self): +- """ +- This test ensures that all hosts can be uninstalled correctly. +- """ +- tasks.uninstall_client(self.clients[0]) +- tasks.uninstall_replica(self.master, self.replicas[0]) +- tasks.uninstall_master(self.master) +- +- def test_install_dnsovertls_master_external_ca(self): +- """ +- This test ensures that IPA server can be installed +- with DoT using an external CA. +- """ +- self.master.run_command(["openssl", "req", "-newkey", "rsa:2048", +- "-nodes", "-keyout", +- "/etc/pki/tls/certs/privkey.pem", "-x509", +- "-days", "36500", "-out", +- "/etc/pki/tls/certs/certificate.pem", "-subj", +- ("/C=ES/ST=Andalucia/L=Sevilla/O=CompanyName/" +- "OU=IT/CN={}/" +- "emailAddress=email@example.com") +- .format(self.master.hostname)]) +- self.master.run_command(["chown", "named:named", +- "/etc/pki/tls/certs/privkey.pem", +- "/etc/pki/tls/certs/certificate.pem"]) +- args = [ +- "--dns-over-tls", +- "--dot-forwarder", "1.1.1.1#cloudflare-dns.com", +- "--dns-over-tls-cert", "/etc/pki/tls/certs/certificate.pem", +- "--dns-over-tls-key", "/etc/pki/tls/certs/privkey.pem" +- ] +- return tasks.install_master(self.master, extra_args=args) +- +- def test_enrollments_external_ca(self): +- """ +- Test that replicas and clients can be deployed when the master +- uses an external CA. +- """ +- tasks.copy_files(self.master, self.clients[0], +- ["/etc/pki/tls/certs/certificate.pem"]) +- self.clients[0].run_command(["mv", +- "/etc/pki/tls/certs/certificate.pem", +- "/etc/pki/ca-trust/source/anchors/"]) +- self.clients[0].run_command(["update-ca-trust", "extract"]) +- self.test_install_dnsovertls_client() +- self.test_install_dnsovertls_replica() +- self.test_queries_encrypted() +- + def test_install_dnsovertls_with_invalid_ipaddress_master(self): + """ + This test installs an IPA server using the --dns-over-tls +@@ -251,6 +189,157 @@ class TestDNSOverTLS(IntegrationTest): + assert '''--dns-over-tls Configure DNS over TLS''' in cmdout.stdout_text # noqa: E501 + + ++@pytest.mark.skipif( ++ osinfo.id == 'fedora' and osinfo.version_number < (42,), ++ reason='Encrypted DNS not supported in Fedora < 42') ++class TestDNSOverTLS_RelaxedPolicy(IntegrationTest): ++ """Tests for DNS over TLS feature.""" ++ ++ topology = 'line' ++ num_replicas = 1 ++ num_clients = 1 ++ ++ @classmethod ++ def install(cls, mh): ++ Firewall(cls.master).enable_service("dns-over-tls") ++ Firewall(cls.replicas[0]).enable_service("dns-over-tls") ++ tasks.install_packages(cls.master, ['*ipa-server-encrypted-dns']) ++ tasks.install_packages(cls.replicas[0], ['*ipa-server-encrypted-dns']) ++ tasks.install_packages(cls.clients[0], ['*ipa-client-encrypted-dns']) ++ ++ def test_dot_relaxed_dns_policy_with_IPA_CA(self): ++ """ ++ This test installs IPA server, replica, and client with ++ --no-dnssec-validation option, relaxed DNS policy, and ++ with IPA CA, ensuring all queries are encrypted. ++ """ ++ args = [ ++ "--dns-over-tls", ++ "--dot-forwarder", "1.1.1.1#cloudflare-dns.com", ++ "--no-dnssec-validation", ++ "--dns-policy", "relaxed" ++ ] ++ tasks.install_master(self.master, extra_args=args) ++ ++ self.clients[0].put_file_contents( ++ paths.RESOLV_CONF, ++ "nameserver %s" % self.master.ip ++ ) ++ args = [ ++ "--dns-over-tls", ++ "--no-dnssec-validation" ++ ] ++ tasks.install_client( ++ self.master, ++ self.clients[0], ++ nameservers=None, ++ extra_args=args ++ ) ++ ++ args = [ ++ "--dns-over-tls", ++ "--dot-forwarder", "1.1.1.1#cloudflare-dns.com", ++ "--no-dnssec-validation", ++ "--dns-policy", "relaxed" ++ ] ++ tasks.install_replica( ++ self.master, ++ self.replicas[0], ++ setup_dns=True, ++ extra_args=args ++ ) ++ verify_queries_encrypted( ++ self.master, ++ [self.replicas[0]], ++ [self.clients[0]] ++ ) ++ ++ def test_uninstall_all(self): ++ """ ++ This test ensures that all hosts can be uninstalled correctly. ++ """ ++ tasks.uninstall_client(self.clients[0]) ++ tasks.uninstall_replica(self.master, self.replicas[0]) ++ tasks.uninstall_master(self.master) ++ ++ def test_dot_relaxed_dns_policy_with_external_ca(self): ++ """ ++ This test installs IPA server, replica, and client with ++ --no-dnssec-validation option, relaxed DNS policy, and ++ with external CA, ensuring all queries are encrypted. ++ """ ++ # Install Master with external CA ++ # Create external CA cert + key. ++ external_ca = ExternalCA(days=36500) ++ cert_pem = external_ca.create_ca() ++ key_pem = external_ca.ca_key.private_bytes( ++ encoding=serialization.Encoding.PEM, ++ format=serialization.PrivateFormat.TraditionalOpenSSL, ++ encryption_algorithm=serialization.NoEncryption(), ++ ) ++ cert_dest = "/etc/pki/tls/certs/certificate.pem" ++ key_dest = "/etc/pki/tls/certs/privkey.pem" ++ ++ self.master.put_file_contents(cert_dest, cert_pem) ++ self.master.put_file_contents(key_dest, key_pem) ++ ++ args = [ ++ "--dns-over-tls", ++ "--dot-forwarder", "1.1.1.1#cloudflare-dns.com", ++ "--dns-over-tls-cert", cert_dest, ++ "--dns-over-tls-key", key_dest, ++ "--no-dnssec-validation", ++ "--dns-policy", "relaxed" ++ ] ++ tasks.install_master(self.master, extra_args=args) ++ ++ # Install Client with external CA ++ self.clients[0].put_file_contents( ++ paths.RESOLV_CONF, ++ "nameserver %s" % self.master.ip ++ ) ++ dest_file = "/etc/pki/ca-trust/source/anchors/certificate.pem" ++ data = self.master.get_file_contents(cert_dest) ++ self.clients[0].transport.put_file_contents(dest_file, data) ++ self.clients[0].run_command(["update-ca-trust", "extract"]) ++ ++ args = [ ++ "--dns-over-tls", ++ "--no-dnssec-validation" ++ ] ++ tasks.install_client( ++ self.master, ++ self.clients[0], ++ nameservers=None, ++ extra_args=args ++ ) ++ ++ # Install Replica with external CA ++ dest_file = "/etc/pki/ca-trust/source/anchors/certificate.pem" ++ data = self.master.get_file_contents(cert_dest) ++ self.clients[0].transport.put_file_contents(dest_file, data) ++ ++ self.replicas[0].run_command(["update-ca-trust", "extract"]) ++ args = [ ++ "--dns-over-tls", ++ "--dot-forwarder", "1.1.1.1#cloudflare-dns.com", ++ "--no-dnssec-validation", ++ "--dns-policy", "relaxed" ++ ] ++ tasks.install_replica( ++ self.master, ++ self.replicas[0], ++ setup_dns=True, ++ extra_args=args ++ ) ++ ++ verify_queries_encrypted( ++ self.master, ++ [self.replicas[0]], ++ [self.clients[0]] ++ ) ++ ++ + @pytest.mark.skipif( + osinfo.id == 'fedora' and osinfo.version_number == (41,), + reason='Encrypted DNS not supported in fedora 41') +-- +2.51.1 + diff --git a/0129-ipatest-make-test_cert-more-robust-to-replication-de.patch b/0129-ipatest-make-test_cert-more-robust-to-replication-de.patch new file mode 100644 index 0000000..703c051 --- /dev/null +++ b/0129-ipatest-make-test_cert-more-robust-to-replication-de.patch @@ -0,0 +1,36 @@ +From 883f69db280071cf8003eff977f6f061651c7a7d Mon Sep 17 00:00:00 2001 +From: Florence Blanc-Renaud +Date: Tue, 11 Mar 2025 18:32:43 +0100 +Subject: [PATCH] ipatest: make test_cert more robust to replication delays + +The test TestCAShowErrorHandling::test_ca_show_error_handling is +adding a subca on the replica, then checks the entry is present on the +master. +If the replication is a bit slow, the call on the master may fail to +return the newly created subca. +The test should wait for replication to complete before calling +ipa ca-find. + +Fixes: https://pagure.io/freeipa/issue/9762 +Signed-off-by: Florence Blanc-Renaud +Reviewed-By: Rob Crittenden +--- + ipatests/test_integration/test_cert.py | 2 ++ + 1 file changed, 2 insertions(+) + +diff --git a/ipatests/test_integration/test_cert.py b/ipatests/test_integration/test_cert.py +index 91598b655a8cd6ff92c1a0cf2166c6548a7af758..c642caaf03dfb980e956cf8105911440a5ec8539 100644 +--- a/ipatests/test_integration/test_cert.py ++++ b/ipatests/test_integration/test_cert.py +@@ -548,6 +548,8 @@ class TestCAShowErrorHandling(IntegrationTest): + 'ipa', 'ca-add', lwca, '--subject', 'CN=LWCA 1' + ]) + assert 'Created CA "{}"'.format(lwca) in result.stdout_text ++ # wait for replication to propagate the change ++ tasks.wait_for_replication(self.replicas[0].ldap_connect()) + result = self.master.run_command(['ipa', 'ca-find']) + assert 'Name: {}'.format(lwca) in result.stdout_text + result = self.master.run_command( +-- +2.51.1 + diff --git a/0129-test_cert-adapt-the-expect-error-message-to-PKI-11.7.patch b/0129-test_cert-adapt-the-expect-error-message-to-PKI-11.7.patch new file mode 100644 index 0000000..fc8de54 --- /dev/null +++ b/0129-test_cert-adapt-the-expect-error-message-to-PKI-11.7.patch @@ -0,0 +1,49 @@ +From 9c416a61b72b288212e03724cff9bd169390cbfe Mon Sep 17 00:00:00 2001 +From: Florence Blanc-Renaud +Date: Wed, 8 Oct 2025 14:26:12 +0200 +Subject: [PATCH] test_cert: adapt the expect error message to PKI 11.7.0-5 + +The error message returned by ipa ca-show has changed with PKI 11.7. +Adapt the test to succeed with old and new versions. + +Signed-off-by: Florence Blanc-Renaud +Reviewed-By: Rob Crittenden +--- + ipatests/test_integration/test_cert.py | 8 +++++++- + 1 file changed, 7 insertions(+), 1 deletion(-) + +diff --git a/ipatests/test_integration/test_cert.py b/ipatests/test_integration/test_cert.py +index 3e1e8fd1fa14278f3868e6e206a979e11b70a848..21568c2421c21855df06bcf5fbb4d52b3651a523 100644 +--- a/ipatests/test_integration/test_cert.py ++++ b/ipatests/test_integration/test_cert.py +@@ -575,6 +575,9 @@ class TestCAShowErrorHandling(IntegrationTest): + 'ipa', 'ca-add', lwca, '--subject', 'CN=LWCA 1' + ]) + assert 'Created CA "{}"'.format(lwca) in result.stdout_text ++ match = re.search(r'Authority ID: (?P.*)', result.stdout_text) ++ id = match.group('id') ++ + # wait for replication to propagate the change + tasks.wait_for_replication(self.replicas[0].ldap_connect()) + result = self.master.run_command(['ipa', 'ca-find']) +@@ -585,13 +588,16 @@ class TestCAShowErrorHandling(IntegrationTest): + ) + error_msg = 'ipa: ERROR: The certificate for ' \ + '{} is not available on this server.'.format(lwca) ++ new_error_msg = 'ipa: ERROR: Certificate for CA ' \ ++ '"{}" not available'.format(id) + pki_version = tasks.get_pki_version(self.master) + # The regression was introduced in 11.5 and fixed in 11.7 + bad_version = (tasks.parse_version('11.5.0') <= pki_version + < tasks.parse_version('11.7.0')) + with xfail_context(bad_version, + reason="https://pagure.io/freeipa/issue/9606"): +- assert error_msg in result.stderr_text ++ assert (error_msg in result.stderr_text ++ or new_error_msg in result.stderr_text) + + def test_certmonger_empty_cert_not_segfault(self): + """Test empty cert request doesn't force certmonger to segfault +-- +2.51.1 + diff --git a/0130-ipatests-fix-test_certmonger_ipa_responder_jsonrpc.patch b/0130-ipatests-fix-test_certmonger_ipa_responder_jsonrpc.patch new file mode 100644 index 0000000..470a0ec --- /dev/null +++ b/0130-ipatests-fix-test_certmonger_ipa_responder_jsonrpc.patch @@ -0,0 +1,51 @@ +From 4a2e912d2386dfeb9765e32dd244b32b03cbf9a5 Mon Sep 17 00:00:00 2001 +From: Florence Blanc-Renaud +Date: Tue, 2 Sep 2025 10:05:31 +0200 +Subject: [PATCH] ipatests: fix test_certmonger_ipa_responder_jsonrpc + +Test scenario: +- install IPA server and client +- store the start date +- request a certificate on the client using ipa-getcert +- check in the journal after start date that the request was done using the +https://.../ipa/json URI + +The test obtains the start date on the runner. As a consequence, if the runner +is late compared to the client, it may miss the message in the journal. +The date should rather be obtained on the client. + +Fixes: https://pagure.io/freeipa/issue/9848 +Signed-off-by: Florence Blanc-Renaud +Reviewed-By: Rob Crittenden +--- + ipatests/test_integration/test_cert.py | 6 +++--- + 1 file changed, 3 insertions(+), 3 deletions(-) + +diff --git a/ipatests/test_integration/test_cert.py b/ipatests/test_integration/test_cert.py +index 84adf2ceafe013e6cfc973fb2cb650c40f36971d..ddc4e089a365b89a3dd26881845228ca60558bf7 100644 +--- a/ipatests/test_integration/test_cert.py ++++ b/ipatests/test_integration/test_cert.py +@@ -13,7 +13,6 @@ import pytest + import random + import re + import string +-import time + import textwrap + + from ipaplatform.paths import paths +@@ -70,9 +69,10 @@ class TestInstallMasterClient(IntegrationTest): + def install(cls, mh): + super().install(mh) + +- # time to look into journal logs in ++ # store the start time to look into journal logs in + # test_certmonger_ipa_responder_jsonrpc +- cls.since = time.strftime('%Y-%m-%d %H:%M:%S') ++ result = cls.clients[0].run_command(['date', '+%Y-%m-%d %H:%M:%S']) ++ cls.since = result.stdout_text.strip() + + def test_cacert_file_appear_with_option_F(self): + """Test if getcert creates cacert file with -F option +-- +2.51.1 + diff --git a/0131-test_cert-adapt-the-expect-error-message-to-PKI-11.7.patch b/0131-test_cert-adapt-the-expect-error-message-to-PKI-11.7.patch new file mode 100644 index 0000000..fc8de54 --- /dev/null +++ b/0131-test_cert-adapt-the-expect-error-message-to-PKI-11.7.patch @@ -0,0 +1,49 @@ +From 9c416a61b72b288212e03724cff9bd169390cbfe Mon Sep 17 00:00:00 2001 +From: Florence Blanc-Renaud +Date: Wed, 8 Oct 2025 14:26:12 +0200 +Subject: [PATCH] test_cert: adapt the expect error message to PKI 11.7.0-5 + +The error message returned by ipa ca-show has changed with PKI 11.7. +Adapt the test to succeed with old and new versions. + +Signed-off-by: Florence Blanc-Renaud +Reviewed-By: Rob Crittenden +--- + ipatests/test_integration/test_cert.py | 8 +++++++- + 1 file changed, 7 insertions(+), 1 deletion(-) + +diff --git a/ipatests/test_integration/test_cert.py b/ipatests/test_integration/test_cert.py +index 3e1e8fd1fa14278f3868e6e206a979e11b70a848..21568c2421c21855df06bcf5fbb4d52b3651a523 100644 +--- a/ipatests/test_integration/test_cert.py ++++ b/ipatests/test_integration/test_cert.py +@@ -575,6 +575,9 @@ class TestCAShowErrorHandling(IntegrationTest): + 'ipa', 'ca-add', lwca, '--subject', 'CN=LWCA 1' + ]) + assert 'Created CA "{}"'.format(lwca) in result.stdout_text ++ match = re.search(r'Authority ID: (?P.*)', result.stdout_text) ++ id = match.group('id') ++ + # wait for replication to propagate the change + tasks.wait_for_replication(self.replicas[0].ldap_connect()) + result = self.master.run_command(['ipa', 'ca-find']) +@@ -585,13 +588,16 @@ class TestCAShowErrorHandling(IntegrationTest): + ) + error_msg = 'ipa: ERROR: The certificate for ' \ + '{} is not available on this server.'.format(lwca) ++ new_error_msg = 'ipa: ERROR: Certificate for CA ' \ ++ '"{}" not available'.format(id) + pki_version = tasks.get_pki_version(self.master) + # The regression was introduced in 11.5 and fixed in 11.7 + bad_version = (tasks.parse_version('11.5.0') <= pki_version + < tasks.parse_version('11.7.0')) + with xfail_context(bad_version, + reason="https://pagure.io/freeipa/issue/9606"): +- assert error_msg in result.stderr_text ++ assert (error_msg in result.stderr_text ++ or new_error_msg in result.stderr_text) + + def test_certmonger_empty_cert_not_segfault(self): + """Test empty cert request doesn't force certmonger to segfault +-- +2.51.1 + diff --git a/0132-Include-the-HSM-token-name-when-creating-LWCAs.patch b/0132-Include-the-HSM-token-name-when-creating-LWCAs.patch new file mode 100644 index 0000000..929a459 --- /dev/null +++ b/0132-Include-the-HSM-token-name-when-creating-LWCAs.patch @@ -0,0 +1,286 @@ +From 12dd94e61a245ac8645789423aa9fc47b3cc14d0 Mon Sep 17 00:00:00 2001 +From: Rob Crittenden +Date: Fri, 10 Oct 2025 19:41:15 +0000 +Subject: [PATCH] Include the HSM token name when creating LWCAs + +In order to generate the private key for a a LWCA (subca) +on an HSM the name of the subca needs to be the HSM token +name : name_of_subca, e.g. ipa_token:test. + +This works fine now without any code changes but it requires +that admins always remember to include the prefix which is +unlikely (everyone makes mistakes). So do it for them if +an HSM is present and a token is not provided. We only support +one token at a time in IPA so for now this is sufficient. + +One can also mix-and-match including the token and not. +For example you can run: + +$ ipa ca-add test --subject cn=test +$ ipa ca-show ipa_token:test + +It shouldn't be an issue if a lwca name contains a colon. + +Fixes: https://pagure.io/freeipa/issue/9865 + +Signed-off-by: Rob Crittenden +Reviewed-By: Rafael Guterres Jeffman +Reviewed-By: Rafael Guterres Jeffman +--- + ipaserver/plugins/ca.py | 42 +++++++- + ipatests/test_integration/test_hsm.py | 140 ++++++++++++++++++++++++++ + 2 files changed, 181 insertions(+), 1 deletion(-) + +diff --git a/ipaserver/plugins/ca.py b/ipaserver/plugins/ca.py +index d35275cc6f625c4834f43a537b29da32a909326b..1eeab4048091bd990c4b27e0f7976940da734acc 100644 +--- a/ipaserver/plugins/ca.py ++++ b/ipaserver/plugins/ca.py +@@ -10,7 +10,7 @@ from ipalib import api, errors, messages, output + from ipalib import Bytes, DNParam, Flag, Str, Int + from ipalib.constants import IPA_CA_CN + from ipalib.plugable import Registry +-from ipapython.dn import ATTR_NAME_BY_OID ++from ipapython.dn import ATTR_NAME_BY_OID, DN + from ipaserver.plugins.baseldap import ( + LDAPObject, LDAPSearch, LDAPCreate, LDAPDelete, + LDAPUpdate, LDAPRetrieve, LDAPQuery, pkey_to_value) +@@ -174,6 +174,26 @@ class ca(LDAPObject): + }, + } + ++ # LWCA are supported on HSMs but the key will only be generated ++ # there if the LWCA name is prefixed by the token name. So do ++ # that automatically for users to hide that complexity. ++ ++ def add_token_key(self, *keys): ++ if len(keys) == 0 or keys[0] == IPA_CA_CN: ++ return keys ++ config = api.Command['config_show']()['result'] ++ if 'hsm_token_name' in config and not keys[-1].startswith( ++ config['hsm_token_name'] ++ ): ++ keys = (f"{config['hsm_token_name']}:{keys[-1]}",) ++ return keys ++ ++ def remove_token_key(self, *keys): ++ config = api.Command['config_show']()['result'] ++ if 'hsm_token_name' in config and ':' in keys[-1]: ++ keys = (keys[-1].split(':', 1)[1],) ++ return keys ++ + + def set_certificate_attrs(entry, options, want_cert=True): + """ +@@ -233,6 +253,10 @@ class ca_find(LDAPSearch): + + def execute(self, *keys, **options): + ca_enabled_check(self.api) ++ keys = self.obj.add_token_key(*keys) ++ if 'cn' in options: ++ new = self.obj.add_token_key(options['cn']) ++ options['cn'] = new[0] + result = super(ca_find, self).execute(*keys, **options) + if not options.get('pkey_only', False): + for entry in result['result']: +@@ -259,6 +283,7 @@ class ca_show(LDAPRetrieve): + + def execute(self, *keys, **options): + ca_enabled_check(self.api) ++ keys = self.obj.add_token_key(*keys) + result = super(ca_show, self).execute(*keys, **options) + msg = set_certificate_attrs(result['result'], options) + if msg: +@@ -301,6 +326,7 @@ class ca_add(LDAPCreate): + + # check for name collision before creating CA in Dogtag + try: ++ keys = self.obj.remove_token_key(*keys) + api.Object.ca.get_dn_if_exists(keys[-1]) + self.obj.handle_duplicate_entry(*keys) + except errors.NotFound: +@@ -325,6 +351,10 @@ class ca_add(LDAPCreate): + entry['ipacasubjectdn'] = [resp['dn']] + return dn + ++ def execute(self, *keys, **options): ++ keys = self.obj.add_token_key(*keys) ++ return super(ca_add, self).execute(*keys, **options) ++ + def post_callback(self, ldap, dn, entry_attrs, *keys, **options): + msg = set_certificate_attrs(entry_attrs, options) + if msg: +@@ -341,6 +371,12 @@ class ca_del(LDAPDelete): + def pre_callback(self, ldap, dn, *keys, **options): + ca_enabled_check(self.api) + ++ # Handle an HSM-stored LWCA that is referenced without the ++ # token name. On a non-HSM install real_* will be unchanged ++ # from keys and dn. ++ real_keys = self.obj.add_token_key(*keys) ++ dn = DN(('cn', real_keys[0]), self.obj.container_dn, ++ api.env.basedn) + # ensure operator has permission to delete CA + # before contacting Dogtag + if not ldap.can_delete(dn): +@@ -386,6 +422,10 @@ class ca_mod(LDAPUpdate): + + return dn + ++ def execute(self, *keys, **options): ++ keys = self.obj.add_token_key(*keys) ++ return super(ca_mod, self).execute(*keys, **options) ++ + + class CAQuery(LDAPQuery): + has_output = output.standard_value +diff --git a/ipatests/test_integration/test_hsm.py b/ipatests/test_integration/test_hsm.py +index 42895fcd60a7c02d3b6103c2f6751a367da30b2f..159ead2fcc79982c7289f316c57aaeb2b812004e 100644 +--- a/ipatests/test_integration/test_hsm.py ++++ b/ipatests/test_integration/test_hsm.py +@@ -1316,3 +1316,143 @@ class TestHSMVault(BaseHSMTest): + vault_name, + "--password", vault_password, + ]) ++ ++ ++class TestHSMLWCA(BaseHSMTest): ++ """Test that managing a LWCA on an HSM-installed system installs ++ the keys onto the HSM and not the local NSS database. ++ ++ Also verify that the ca-* operations can handle hiding the ++ complexity of the token name prefix without specifying it ++ (but also allowing it). ++ """ ++ ++ num_replicas = 0 ++ ++ def remove_lwca(self, lwca, othername=None): ++ """Save a lot of duplicate code disabling and removing a lwca""" ++ self.master.run_command([ ++ 'ipa', 'ca-disable', lwca, ++ ]) ++ ++ name = othername or lwca ++ self.master.run_command([ ++ 'ipa', 'ca-del', name, ++ ]) ++ ++ def test_hsm_add_lwca(self): ++ lwca = "lwca" ++ lwca_fullname = "{}:{}".format(self.token_name, lwca) ++ ++ check_version(self.master) ++ ++ """First test add/show/disable/delete without specifying ++ the token. So hiding the complexity of including the ++ token name except the stored Name (cn) value. ++ """ ++ result = self.master.run_command([ ++ 'ipa', 'ca-add', lwca, '--subject', 'CN=LWCA', ++ ]) ++ assert f'Name: {lwca_fullname}' in result.stdout_text ++ ++ self.remove_lwca(lwca) ++ ++ def test_hsm_token_add_lwca(self): ++ """Now test add/show/disable/delete with specifying ++ the token with the name. So not hiding the complexity of ++ including the token name. ++ """ ++ lwca = "lwca" ++ lwca_fullname = "{}:{}".format(self.token_name, lwca) ++ ++ check_version(self.master) ++ ++ result = self.master.run_command([ ++ 'ipa', 'ca-add', lwca_fullname, '--subject', 'CN=LWCA', ++ ]) ++ assert f'Name: {lwca_fullname}' in result.stdout_text ++ ++ self.remove_lwca(lwca_fullname) ++ ++ def test_duplicate_token_add_lwca(self): ++ """Test that adding a duplicate name by adding with and ++ without the token included""" ++ lwca = "lwca" ++ lwca_fullname = "{}:{}".format(self.token_name, lwca) ++ ++ check_version(self.master) ++ ++ self.master.run_command([ ++ 'ipa', 'ca-add', lwca_fullname, '--subject', 'CN=LWCA', ++ ]) ++ result = self.master.run_command([ ++ 'ipa', 'ca-add', lwca, '--subject', 'CN=LWCA', ++ ], raiseonerr=False) ++ assert 'Subject DN is already used' in result.stderr_text ++ ++ # we can also mix and match the name in the cleanup ++ self.remove_lwca(lwca, lwca_fullname) ++ ++ def test_colon_in_lwca(self): ++ """ ++ Trying to add a CA using an unknown or mis-typed token ++ name will result in the real token + whatever the name. ++ """ ++ lwca = "lwca" ++ colonname = "undefined:{}".format(lwca) ++ colonfullname = "{}:undefined:{}".format(self.token_name, lwca) ++ ++ check_version(self.master) ++ ++ result = self.master.run_command([ ++ 'ipa', 'ca-add', colonname, '--subject', 'CN=LWCA', ++ ]) ++ assert 'Name: {}'.format(colonfullname) in result.stdout_text ++ ++ self.remove_lwca(colonfullname, colonname) ++ ++ def test_show_lwca(self): ++ """Show a lwca using with and without the token name""" ++ lwca = "lwca" ++ lwca_fullname = "{}:{}".format(self.token_name, lwca) ++ ++ check_version(self.master) ++ ++ for name in (lwca, lwca_fullname,): ++ result = self.master.run_command([ ++ 'ipa', 'ca-add', name, '--subject', f'CN={name}', ++ ]) ++ assert f'Name: {lwca_fullname}' in result.stdout_text ++ self.master.run_command(['ipa', 'ca-show', name]) ++ self.remove_lwca(name) ++ ++ def test_find_lwca(self): ++ """Find a lwca using with and without the token name""" ++ lwca = "lwca" ++ lwca_fullname = "{}:{}".format(self.token_name, lwca) ++ ++ check_version(self.master) ++ ++ for name in (lwca, lwca_fullname,): ++ result = self.master.run_command([ ++ 'ipa', 'ca-add', name, '--subject', f'CN={name}', ++ ]) ++ assert f'Name: {lwca_fullname}' in result.stdout_text ++ self.master.run_command(['ipa', 'ca-find', '--name', name]) ++ self.remove_lwca(name) ++ ++ def test_mod_lwca(self): ++ """Modify a lwca using with and without the token name""" ++ lwca = "lwca" ++ lwca_fullname = "{}:{}".format(self.token_name, lwca) ++ ++ check_version(self.master) ++ ++ for name in (lwca, lwca_fullname,): ++ result = self.master.run_command([ ++ 'ipa', 'ca-add', name, '--subject', f'CN={name}', ++ ]) ++ assert f'Name: {lwca_fullname}' in result.stdout_text ++ self.master.run_command( ++ ['ipa', 'ca-mod', name, '--desc', name]) ++ self.remove_lwca(name) +-- +2.51.1 + diff --git a/0133-ipatests-Refactor-and-port-trust-functional-SUDO-tes.patch b/0133-ipatests-Refactor-and-port-trust-functional-SUDO-tes.patch new file mode 100644 index 0000000..8690bc6 --- /dev/null +++ b/0133-ipatests-Refactor-and-port-trust-functional-SUDO-tes.patch @@ -0,0 +1,403 @@ +From 9d144b89ce743805e6e2d19791436ca5ecee172f Mon Sep 17 00:00:00 2001 +From: Anuja More +Date: Wed, 8 Oct 2025 13:18:56 +0530 +Subject: [PATCH] ipatests: Refactor and port trust functional SUDO tests. + +- Test scenarios : + - AD users running commands as root via external groups + - AD users switching to other AD user accounts + - IPA users running commands as AD users + - Sudo rule disable/enable functionality + - Command allow/deny restrictions + - Access denial for users not in sudo rules +- Automated with Cursor+Claude + +Related : https://pagure.io/freeipa/issue/9845 + +Signed-off-by: Anuja More +Reviewed-By: David Hanina +--- + .../test_integration/test_trust_functional.py | 356 +++++++++++++++++- + 1 file changed, 355 insertions(+), 1 deletion(-) + +diff --git a/ipatests/test_integration/test_trust_functional.py b/ipatests/test_integration/test_trust_functional.py +index b40bf9675ed5cddaf51624417356ddab28e870a5..a85f21e96463757b9a446df666d5361e65ba686c 100644 +--- a/ipatests/test_integration/test_trust_functional.py ++++ b/ipatests/test_integration/test_trust_functional.py +@@ -2,6 +2,8 @@ + + from __future__ import absolute_import + ++import time ++ + from ipaplatform.paths import paths + from ipatests.pytest_ipa.integration import tasks + from ipatests.test_integration.test_trust import BaseTestTrust +@@ -290,7 +292,9 @@ class TestTrustFunctionalHbac(BaseTestTrust): + with self.clients[0].spawn_expect( + test_sudo.format(user=user)) as e: + e.sendline('Secret123') +- e.expect_exit(ignore_remaining_output=True, timeout=60) ++ e.sendline('exit') ++ e.expect_exit( ++ ignore_remaining_output=True, raiseonerr=False) + output = e.get_last_output() + assert 'uid=0(root)' in output + for user in [self.aduser2, self.subaduser2]: +@@ -304,3 +308,353 @@ class TestTrustFunctionalHbac(BaseTestTrust): + finally: + self._cleanup_hrule_allow_all_and_wait(hrule) + self.master.run_command(["ipa", "sudorule-del", srule]) ++ ++ ++class TestTrustFunctionalSudo(BaseTestTrust): ++ topology = 'line' ++ num_ad_treedomains = 0 ++ ++ def cache_reset(self): ++ tasks.clear_sssd_cache(self.master) ++ tasks.clear_sssd_cache(self.clients[0]) ++ tasks.wait_for_sssd_domain_status_online(self.master) ++ tasks.wait_for_sssd_domain_status_online(self.clients[0]) ++ # give time to SSSD to retrieve new records ++ time.sleep(30) ++ ++ def _cleanup_srule(self, srule): ++ tasks.kinit_admin(self.master) ++ self.master.run_command(["ipa", "sudorule-del", srule]) ++ self.cache_reset() ++ ++ def _run_sudo_command(self, host, command, username, password='Secret123', ++ expected_output=None, raiseonerr=True, timeout=60): ++ """ ++ Run a sudo command using spawn_expect with proper error handling. ++ ++ Args: ++ host: Host to run the command on ++ command: Command to execute ++ username: Username for password prompt ++ password: Password to send ++ expected_output: Expected string in output (for assertion) ++ raiseonerr: Whether to raise on error ++ timeout: Timeout for expect_exit ++ ++ Returns: ++ Output from the command ++ """ ++ with host.spawn_expect(command) as e: ++ e.expect(r'(?i).*Password for {}.*:'.format(username)) ++ e.sendline(password) ++ e.expect_exit(ignore_remaining_output=True, ++ raiseonerr=raiseonerr, timeout=timeout) ++ output = e.get_last_output() ++ ++ if expected_output: ++ assert expected_output in output, ( ++ f"Expected '{expected_output}' in output, got: {output}" ++ ) ++ ++ return output ++ ++ def test_ipa_trust_func_sudo_setup(self): ++ tasks.configure_dns_for_trust(self.master, self.ad) ++ tasks.establish_trust_with_ad( ++ self.master, self.ad_domain, ++ extra_args=['--range-type', 'ipa-ad-trust']) ++ tasks.kinit_admin(self.master) ++ ++ for i in range(1, 3): ++ external_group = f"sudogroup_external{i}" ++ internal_group = f"sudogroup{i}" ++ tasks.group_add(self.master, ++ groupname=external_group, ++ extra_args=["--external"] ++ ) ++ tasks.group_add(self.master, ++ groupname=internal_group ++ ) ++ tasks.group_add_member(self.master, ++ groupname=internal_group, ++ extra_args=[f"--groups={external_group}"] ++ ) ++ ++ group_members = { ++ "sudogroup_external1": [self.aduser, self.subaduser], ++ "sudogroup_external2": [self.aduser2, self.subaduser2], ++ } ++ ++ for group, members in group_members.items(): ++ for member in members: ++ self.master.run_command([ ++ 'ipa', '-n', 'group-add-member', '--external', ++ member, group, ++ ]) ++ for user in ['sudouser1', 'sudouser2', 'ipauser1']: ++ tasks.create_active_user( ++ self.master, user, password='Secret123' ++ ) ++ ++ def test_ipa_trust_func_sudo_0001(self): ++ """ ++ Test sudo rule allow AD user in external group to run commands as root. ++ ++ This test creates a sudo rule that allows members of sudogroup1 (which ++ includes AD users via external group membership) to run all commands as ++ root. It verifies that AD users who are members of the external group ++ can successfully use sudo to gain root privileges. ++ """ ++ srule = "sudorule_01" ++ cmd = ["ipa", "sudorule-add", srule, "--hostcat=all", "--cmdcat=all"] ++ try: ++ tasks.kinit_admin(self.master) ++ self.master.run_command(cmd) ++ self.master.run_command( ++ ["ipa", "sudorule-add-user", srule, "--groups=sudogroup1"] ++ ) ++ self.cache_reset() ++ for user in [self.aduser, self.subaduser]: ++ test_sudo = f"su {user} -c 'sudo -S id'" ++ self._run_sudo_command( ++ self.clients[0], test_sudo, user, ++ expected_output='uid=0(root)' ++ ) ++ finally: ++ self._cleanup_srule(srule) ++ ++ def test_ipa_trust_func_sudo_0002(self): ++ """ ++ Test sudo rule allows AD users to run commands as other AD users. ++ ++ This test creates a sudo rule that allows members of sudogroup1 to run ++ commands as members of sudogroup2. It verifies that AD users can ++ successfully use sudo to switch to other AD user accounts when they ++ have the appropriate sudo permissions. ++ """ ++ srule = "sudorule_02" ++ cmd = ["ipa", "sudorule-add", srule, "--hostcat=all", "--cmdcat=all"] ++ try: ++ tasks.kinit_admin(self.master) ++ self.master.run_command(cmd) ++ self.master.run_command( ++ ["ipa", "sudorule-add-user", srule, "--groups=sudogroup1"] ++ ) ++ self.master.run_command( ++ ["ipa", "sudorule-add-runasuser", srule, "--groups=sudogroup2"] ++ ) ++ self.cache_reset() ++ test_sudo = "su {0} -c 'sudo -S -u {1} id'".format( ++ self.aduser, self.aduser2 ++ ) ++ self._run_sudo_command(self.clients[0], test_sudo, self.aduser, ++ expected_output=self.aduser2 ++ ) ++ ++ test_sudo = "su {0} -c 'sudo -S -u {1} id'".format( ++ self.subaduser, self.subaduser2 ++ ) ++ self._run_sudo_command(self.clients[0], test_sudo, self.subaduser, ++ expected_output=self.subaduser2 ++ ) ++ finally: ++ self._cleanup_srule(srule) ++ ++ def test_ipa_trust_func_sudo_0004(self): ++ """ ++ Test sudo rule allows IPA users to run commands as AD users. ++ ++ This test creates a sudo rule that allows IPA users to run commands ++ as AD users who are members of external groups. It verifies that ++ IPA users can successfully use sudo to switch to AD user accounts ++ when they have the appropriate sudo permissions. ++ """ ++ srule = "sudorule_04" ++ cmd = ["ipa", "sudorule-add", srule, "--hostcat=all", "--cmdcat=all"] ++ try: ++ tasks.kinit_admin(self.master) ++ self.master.run_command(cmd) ++ self.master.run_command( ++ ["ipa", "sudorule-add-user", srule, "--users=ipauser1"] ++ ) ++ self.master.run_command( ++ ["ipa", "sudorule-add-runasuser", srule, "--groups=sudogroup1"] ++ ) ++ self.cache_reset() ++ self.master.run_command( ++ ["ipa", "sudorule-show", srule, "--all"] ++ ) ++ self.master.run_command(['ipa', 'group-show', 'sudogroup1']) ++ self.master.run_command( ++ ['ipa', 'group-show', 'sudogroup_external1'] ++ ) ++ for user in [self.aduser, self.subaduser]: ++ tasks.clear_sssd_cache(self.master) ++ test_sudo = f"su ipauser1 -c 'sudo -S -u {user} id'" ++ self._run_sudo_command(self.master, test_sudo, 'ipauser1', ++ expected_output=user) ++ ++ for user in [self.aduser2, self.subaduser2]: ++ tasks.clear_sssd_cache(self.master) ++ test_sudo = f"su ipauser1 -c 'sudo -S -u {user} id'" ++ self._run_sudo_command(self.master, test_sudo, 'ipauser1', ++ expected_output="not allowed to", ++ raiseonerr=False) ++ finally: ++ self._cleanup_srule(srule) ++ ++ def test_ipa_trust_func_sudo_0005(self): ++ """ ++ Test sudo rule disable/enable functionality for AD users. ++ ++ Test creates a sudo rule that allows AD users to run commands as root, ++ then tests the disable/enable functionality. It verifies that: ++ 1. AD users can sudo as root when the rule is enabled ++ 2. AD users are denied sudo access when the rule is disabled ++ 3. AD users can sudo as root again when the rule is re-enabled ++ """ ++ srule = "sudorule_05" ++ cmd = ["ipa", "sudorule-add", srule, "--hostcat=all", "--cmdcat=all"] ++ try: ++ tasks.kinit_admin(self.master) ++ self.master.run_command(cmd) ++ self.master.run_command( ++ ["ipa", "sudorule-add-user", srule, "--groups=sudogroup1"] ++ ) ++ self.cache_reset() ++ for aduser in [self.aduser, self.subaduser]: ++ # First check that user can sudo as root ++ sudo_cmd = f"su - {aduser} -c 'sudo -S id'" ++ self._run_sudo_command(self.clients[0], sudo_cmd, aduser, ++ expected_output='uid=0(root)') ++ ++ # disable sudorule ++ self.master.run_command(["ipa", "sudorule-disable", srule]) ++ self.cache_reset() ++ ++ # now make sure user cannot sudo as root ++ sudo_cmd = f"su - {aduser} -c 'sudo -S id'" ++ self._run_sudo_command(self.clients[0], sudo_cmd, aduser, ++ expected_output="is not allowed to", ++ raiseonerr=False) ++ ++ # now reenable rule ++ self.master.run_command(["ipa", "sudorule-enable", srule]) ++ self.cache_reset() ++ sudo_cmd = f"su - {aduser} -c 'sudo -S id'" ++ self._run_sudo_command(self.clients[0], sudo_cmd, aduser, ++ expected_output='uid=0(root)') ++ finally: ++ self._cleanup_srule(srule) ++ ++ def test_ipa_trust_func_sudo_0007(self): ++ """ ++ Test sudo rule with allow/deny command restrictions for AD users. ++ ++ Test creates a sudo rule that allows AD users to run commands as root, ++ but with specific command restrictions. It verifies that: ++ 1. AD users are denied access to commands in the deny list ++ 2. AD users are allowed access to commands in the allow list ++ ++ The test uses /usr/bin/id as a denied command and /usr/bin/whoami as an ++ allowed command to demonstrate the allow/deny functionality. ++ """ ++ srule = "sudorule_07" ++ try: ++ tasks.kinit_admin(self.master) ++ cmd = ["ipa", "sudorule-add", srule, "--hostcat=all"] ++ self.master.run_command(cmd) ++ self.master.run_command( ++ ["ipa", "sudorule-add-user", srule, "--groups=sudogroup1"] ++ ) ++ self.master.run_command(['ipa', 'sudocmd-add', '/usr/bin/id']) ++ self.master.run_command(['ipa', 'sudocmd-add', '/usr/bin/whoami']) ++ self.master.run_command(['ipa', 'sudorule-add-deny-command', srule, ++ '--sudocmds', '/usr/bin/id'] ++ ) ++ self.master.run_command( ++ ['ipa', 'sudorule-add-allow-command', srule, ++ '--sudocmds', '/usr/bin/whoami'] ++ ) ++ self.cache_reset() ++ for aduser in [self.aduser, self.subaduser]: ++ sudo_cmd = f"su - {aduser} -c 'sudo -S id'" ++ self._run_sudo_command(self.clients[0], sudo_cmd, aduser, ++ expected_output="is not allowed to", ++ raiseonerr=False) ++ for aduser in [self.aduser, self.subaduser]: ++ sudo_cmd = f"su - {aduser} -c 'sudo -S whoami'" ++ self._run_sudo_command(self.clients[0], sudo_cmd, aduser, ++ expected_output='root') ++ finally: ++ self._cleanup_srule(srule) ++ self.master.run_command(['ipa', 'sudocmd-del', '/usr/bin/id']) ++ self.master.run_command(['ipa', 'sudocmd-del', '/usr/bin/whoami']) ++ ++ def test_ipa_trust_func_sudo_0009(self): ++ """ ++ Test sudo rule denies AD users access when they are not in the rule. ++ ++ This test creates a sudo rule that only allows members of sudogroup2 ++ to run commands as other members of sudogroup2. It verifies that ++ AD users who are not members of the allowed group are denied access ++ when attempting to use sudo to switch to other user accounts. ++ """ ++ srule = "sudorule_09" ++ cmd = ["ipa", "sudorule-add", srule, "--hostcat=all", "--cmdcat=all"] ++ try: ++ tasks.kinit_admin(self.master) ++ self.master.run_command(cmd) ++ self.master.run_command( ++ ["ipa", "sudorule-add-user", srule, "--groups=sudogroup2"] ++ ) ++ self.master.run_command( ++ ["ipa", "sudorule-add-runasuser", srule, "--groups=sudogroup2"] ++ ) ++ self.cache_reset() ++ test_sudo = "su {0} -c 'sudo -S -u {1} id'".format( ++ self.aduser, self.aduser2 ++ ) ++ self._run_sudo_command(self.clients[0], test_sudo, self.aduser, ++ expected_output='not allowed to run sudo', ++ raiseonerr=False) ++ ++ test_sudo = "su {0} -c 'sudo -S -u {1} id'".format( ++ self.subaduser, self.subaduser2 ++ ) ++ self._run_sudo_command(self.clients[0], test_sudo, self.subaduser, ++ expected_output='not allowed to run sudo', ++ raiseonerr=False) ++ finally: ++ self._cleanup_srule(srule) ++ ++ def test_ipa_trust_func_sudo_0010(self): ++ """ ++ Test sudo rule denies IPA users access to AD users not in the rule. ++ ++ This test creates a sudo rule that allows IPA users to run commands as ++ members of sudogroup2 (which includes aduser2/subaduser2), but not as ++ members of sudogroup1 (which includes aduser1/subaduser1). It verifies ++ that IPA users are denied access when attempting to use sudo to switch ++ to AD user accounts that are not in the allowed runasuser group. ++ """ ++ srule = "sudorule_10" ++ cmd = ["ipa", "sudorule-add", srule, "--hostcat=all", "--cmdcat=all"] ++ try: ++ tasks.kinit_admin(self.master) ++ self.master.run_command(cmd) ++ self.master.run_command( ++ ["ipa", "sudorule-add-user", srule, "--users=ipauser1"] ++ ) ++ self.master.run_command( ++ ["ipa", "sudorule-add-runasuser", srule, "--groups=sudogroup2"] ++ ) ++ self.cache_reset() ++ ++ for aduser in [self.aduser, self.subaduser]: ++ sudo_cmd = f"su - {aduser} -c 'sudo -S id'" ++ self._run_sudo_command(self.clients[0], sudo_cmd, aduser, ++ expected_output="is not allowed to", ++ raiseonerr=False) ++ finally: ++ self._cleanup_srule(srule) +-- +2.51.1 + diff --git a/0134-ipa-pwd-extop-add-SysAcctManagersDNs-support.patch b/0134-ipa-pwd-extop-add-SysAcctManagersDNs-support.patch new file mode 100644 index 0000000..91196bf --- /dev/null +++ b/0134-ipa-pwd-extop-add-SysAcctManagersDNs-support.patch @@ -0,0 +1,158 @@ +From 5550efd2c4fe9e71544747ca23a99544b0f43274 Mon Sep 17 00:00:00 2001 +From: Alexander Bokovoy +Date: Tue, 16 Sep 2025 14:48:59 +0300 +Subject: [PATCH] ipa-pwd-extop: add SysAcctManagersDNs support + +Add new attribute, SysAcctManagersDNs, to store list of DNs allowed to +reset user passwords without forcing the users to change them +afterwards. + +This list will differ from the use of PassSyncManagersDNs by the fact +that password policy checks will still apply to those password changes. + +Fixes: https://pagure.io/freeipa/issue/9842 + +Signed-off-by: Alexander Bokovoy +Reviewed-By: Rafael Guterres Jeffman +Reviewed-By: Thomas Woerner +Reviewed-By: Florence Blanc-Renaud +--- + .../ipa-slapi-plugins/ipa-pwd-extop/common.c | 15 ++++++++++---- + .../ipa-pwd-extop/ipa_pwd_extop.c | 9 +++++---- + .../ipa-slapi-plugins/ipa-pwd-extop/ipapwd.h | 3 ++- + .../ipa-slapi-plugins/ipa-pwd-extop/prepost.c | 20 +++++++++++-------- + 4 files changed, 30 insertions(+), 17 deletions(-) + +diff --git a/daemons/ipa-slapi-plugins/ipa-pwd-extop/common.c b/daemons/ipa-slapi-plugins/ipa-pwd-extop/common.c +index c85795c1e1c4fa42bde80829861333168ea193e6..114d20417d053ad7e822bd474eedf794b2c316d6 100644 +--- a/daemons/ipa-slapi-plugins/ipa-pwd-extop/common.c ++++ b/daemons/ipa-slapi-plugins/ipa-pwd-extop/common.c +@@ -225,7 +225,10 @@ static struct ipapwd_krbcfg *ipapwd_getConfig(void) + goto free_and_error; + } + config->passsync_mgrs = +- slapi_entry_attr_get_charray(config_entry, "passSyncManagersDNs"); ++ slapi_entry_attr_get_charray(config_entry, "passSyncManagersDNs"); ++ config->sysacct_mgrs = ++ slapi_entry_attr_get_charray(config_entry, "SysAcctManagersDNs"); ++ + /* now add Directory Manager, it is always added by default */ + tmpstr = slapi_ch_strdup("cn=Directory Manager"); + slapi_ch_array_add(&config->passsync_mgrs, tmpstr); +@@ -233,8 +236,6 @@ static struct ipapwd_krbcfg *ipapwd_getConfig(void) + LOG_OOM(); + goto free_and_error; + } +- for (i = 0; config->passsync_mgrs[i]; i++) /* count */ ; +- config->num_passsync_mgrs = i; + + slapi_entry_free(config_entry); + +@@ -289,6 +290,7 @@ free_and_error: + free(config->pref_encsalts); + free(config->supp_encsalts); + slapi_ch_array_free(config->passsync_mgrs); ++ slapi_ch_array_free(config->sysacct_mgrs); + free(config); + } + slapi_entry_free(config_entry); +@@ -614,6 +616,12 @@ int ipapwd_CheckPolicy(struct ipapwd_data *data) + + switch(data->changetype) { + case IPA_CHANGETYPE_NORMAL: ++ case IPA_CHANGETYPE_SYSACCT: ++ /* ++ * Treat a system account-initiated password change as the user's ++ * initiated one as well, to force password quality checks on them. ++ */ ++ + /* Find the entry with the password policy */ + ret = ipapwd_getPolicy(data->dn, data->target, &pol); + if (ret) { +@@ -1143,4 +1151,3 @@ int ipapwd_check_max_pwd_len(size_t len, char **errMesg) { + } + return 0; + } +- +diff --git a/daemons/ipa-slapi-plugins/ipa-pwd-extop/ipa_pwd_extop.c b/daemons/ipa-slapi-plugins/ipa-pwd-extop/ipa_pwd_extop.c +index 43c31becae45c1c91c7c2adf498aedbd05af9a69..ca48a12a68ffeca8dcb3f0ed46d789973aab2192 100644 +--- a/daemons/ipa-slapi-plugins/ipa-pwd-extop/ipa_pwd_extop.c ++++ b/daemons/ipa-slapi-plugins/ipa-pwd-extop/ipa_pwd_extop.c +@@ -583,10 +583,11 @@ parse_req_done: + (strcasecmp(ipa_changepw_principal_dn, bindDN) != 0)) { + pwdata.changetype = IPA_CHANGETYPE_ADMIN; + +- for (size_t i = 0; i < krbcfg->num_passsync_mgrs; i++) { +- if (strcasecmp(krbcfg->passsync_mgrs[i], bindDN) == 0) { +- pwdata.changetype = IPA_CHANGETYPE_DSMGR; +- break; ++ if (slapi_ch_array_utf8_inlist(krbcfg->passsync_mgrs, bindDN) == 1) { ++ pwdata.changetype = IPA_CHANGETYPE_DSMGR; ++ } else { ++ if (slapi_ch_array_utf8_inlist(krbcfg->sysacct_mgrs, bindDN) == 1) { ++ pwdata.changetype = IPA_CHANGETYPE_SYSACCT; + } + } + } +diff --git a/daemons/ipa-slapi-plugins/ipa-pwd-extop/ipapwd.h b/daemons/ipa-slapi-plugins/ipa-pwd-extop/ipapwd.h +index 97697000674d8fbbe3a924af63261482db173852..c2682a7ba06962147414636484cc425850398cc4 100644 +--- a/daemons/ipa-slapi-plugins/ipa-pwd-extop/ipapwd.h ++++ b/daemons/ipa-slapi-plugins/ipa-pwd-extop/ipapwd.h +@@ -75,6 +75,7 @@ + #define IPA_CHANGETYPE_NORMAL 0 + #define IPA_CHANGETYPE_ADMIN 1 + #define IPA_CHANGETYPE_DSMGR 2 ++#define IPA_CHANGETYPE_SYSACCT 3 + + struct ipapwd_data { + Slapi_Entry *target; +@@ -108,7 +109,7 @@ struct ipapwd_krbcfg { + int num_pref_encsalts; + krb5_key_salt_tuple *pref_encsalts; + char **passsync_mgrs; +- int num_passsync_mgrs; ++ char **sysacct_mgrs; + bool allow_nt_hash; + bool enforce_ldap_otp; + }; +diff --git a/daemons/ipa-slapi-plugins/ipa-pwd-extop/prepost.c b/daemons/ipa-slapi-plugins/ipa-pwd-extop/prepost.c +index 42e880fd0a5c8b4708b145b340209eb218f60c4e..0fdb7840bbe3d800270f60c58c1438a2d8267ba2 100644 +--- a/daemons/ipa-slapi-plugins/ipa-pwd-extop/prepost.c ++++ b/daemons/ipa-slapi-plugins/ipa-pwd-extop/prepost.c +@@ -368,10 +368,12 @@ static int ipapwd_pre_add(Slapi_PBlock *pb) + slapi_pblock_get(pb, SLAPI_CONN_DN, &binddn); + + /* if it is a passsync manager we also need to skip resets */ +- for (size_t i = 0; i < krbcfg->num_passsync_mgrs; i++) { +- if (strcasecmp(krbcfg->passsync_mgrs[i], binddn) == 0) { +- pwdop->pwdata.changetype = IPA_CHANGETYPE_DSMGR; +- break; ++ if (slapi_ch_array_utf8_inlist(krbcfg->passsync_mgrs, binddn) == 1) { ++ pwdop->pwdata.changetype = IPA_CHANGETYPE_DSMGR; ++ } else { ++ /* if it is a system account allowed to skip resets, mark it so */ ++ if (slapi_ch_array_utf8_inlist(krbcfg->sysacct_mgrs, binddn) == 1) { ++ pwdop->pwdata.changetype = IPA_CHANGETYPE_SYSACCT; + } + } + } +@@ -861,10 +863,12 @@ static int ipapwd_pre_mod(Slapi_PBlock *pb) + pwdop->pwdata.changetype = IPA_CHANGETYPE_ADMIN; + + /* if it is a passsync manager we also need to skip resets */ +- for (size_t i = 0; i < krbcfg->num_passsync_mgrs; i++) { +- if (strcasecmp(krbcfg->passsync_mgrs[i], binddn) == 0) { +- pwdop->pwdata.changetype = IPA_CHANGETYPE_DSMGR; +- break; ++ if (slapi_ch_array_utf8_inlist(krbcfg->passsync_mgrs, binddn) == 1) { ++ pwdop->pwdata.changetype = IPA_CHANGETYPE_DSMGR; ++ } else { ++ /* if it is a system account allowed to skip resets, mark it so */ ++ if (slapi_ch_array_utf8_inlist(krbcfg->sysacct_mgrs, binddn) == 1) { ++ pwdop->pwdata.changetype = IPA_CHANGETYPE_SYSACCT; + } + } + } +-- +2.51.1 + diff --git a/0135-Add-system-accounts-sysaccounts.patch b/0135-Add-system-accounts-sysaccounts.patch new file mode 100644 index 0000000..9274ce5 --- /dev/null +++ b/0135-Add-system-accounts-sysaccounts.patch @@ -0,0 +1,1890 @@ +From a3e044b7bff6e47d848c2b840d68737c1cfa735c Mon Sep 17 00:00:00 2001 +From: Alexander Bokovoy +Date: Mon, 3 Nov 2025 10:32:16 +0200 +Subject: [PATCH] Add system accounts (sysaccounts) + +Introduce support for LDAP-based system accounts by adding a dedicated +sysaccount plugin with full CLI commands, extending role membership to +include system accounts, and providing warnings for passsync +configuration updates across servers, along with corresponding +documentation and tests. + +New Features: + + Implement system accounts LDAP object with CLI commands for add, +delete, modify, find, show, enable, and disable. + +Enhancements: + + Extend role and baseldap plugins to support sysaccount membership +and manage passsync managers with a ServerSysacctMgrUpdateRequired +warning. + +Documentation: + + Add a design document for system accounts and regenerate API docs +for sysaccount commands and role membership. + +Tests: + + Update existing role and service plugin tests to cover sysaccount +membership. + +Fixes: https://pagure.io/freeipa/issue/9842 + +Signed-off-by: Alexander Bokovoy +Reviewed-By: Rafael Guterres Jeffman +Reviewed-By: Thomas Woerner +Reviewed-By: Florence Blanc-Renaud +--- + ACI.txt | 10 + + API.txt | 113 ++++- + VERSION.m4 | 4 +- + doc/api/commands.rst | 8 + + doc/api/role_add_member.md | 1 + + doc/api/role_remove_member.md | 1 + + doc/api/sysaccount_add.md | 40 ++ + doc/api/sysaccount_del.md | 27 ++ + doc/api/sysaccount_disable.md | 25 + + doc/api/sysaccount_enable.md | 25 + + doc/api/sysaccount_find.md | 40 ++ + doc/api/sysaccount_mod.md | 43 ++ + doc/api/sysaccount_policy.md | 34 ++ + doc/api/sysaccount_show.md | 33 ++ + doc/designs/index.rst | 1 + + doc/designs/sysaccounts.md | 269 +++++++++++ + install/ui/src/freeipa/app.js | 1 + + .../ui/src/freeipa/navigation/menu_spec.js | 3 +- + install/ui/src/freeipa/sysaccount.js | 255 ++++++++++ + install/updates/40-delegation.update | 20 + + install/updates/45-roles.update | 9 + + ipaclient/plugins/sysaccounts.py | 16 + + ipalib/messages.py | 26 + + ipaserver/plugins/baseldap.py | 8 +- + ipaserver/plugins/internal.py | 18 + + ipaserver/plugins/role.py | 2 +- + ipaserver/plugins/sysaccounts.py | 456 ++++++++++++++++++ + ipatests/test_xmlrpc/test_role_plugin.py | 2 + + ipatests/test_xmlrpc/test_service_plugin.py | 1 + + 29 files changed, 1483 insertions(+), 8 deletions(-) + create mode 100644 doc/api/sysaccount_add.md + create mode 100644 doc/api/sysaccount_del.md + create mode 100644 doc/api/sysaccount_disable.md + create mode 100644 doc/api/sysaccount_enable.md + create mode 100644 doc/api/sysaccount_find.md + create mode 100644 doc/api/sysaccount_mod.md + create mode 100644 doc/api/sysaccount_policy.md + create mode 100644 doc/api/sysaccount_show.md + create mode 100644 doc/designs/sysaccounts.md + create mode 100644 install/ui/src/freeipa/sysaccount.js + create mode 100644 ipaclient/plugins/sysaccounts.py + create mode 100644 ipaserver/plugins/sysaccounts.py + +diff --git a/ACI.txt b/ACI.txt +index 0b2cd0cfdbf933b1354f01ce78d10522c19de265..8db1634bc25ff616f67cc4bc7ccfa385c2d77c53 100644 +--- a/ACI.txt ++++ b/ACI.txt +@@ -374,6 +374,16 @@ dn: cn=sudorules,cn=sudo,dc=ipa,dc=example + aci: (targetattr = "cmdcategory || cn || createtimestamp || description || entryusn || externalhost || externaluser || hostcategory || hostmask || ipaenabledflag || ipasudoopt || ipasudorunas || ipasudorunasextgroup || ipasudorunasextuser || ipasudorunasextusergroup || ipasudorunasgroup || ipasudorunasgroupcategory || ipasudorunasusercategory || ipauniqueid || member || memberallowcmd || memberdenycmd || memberhost || memberuser || modifytimestamp || objectclass || sudonotafter || sudonotbefore || sudoorder || usercategory")(targetfilter = "(objectclass=ipasudorule)")(version 3.0;acl "permission:System: Read Sudo Rules";allow (compare,read,search) userdn = "ldap:///all";) + dn: dc=ipa,dc=example + aci: (targetattr = "cn || createtimestamp || description || entryusn || modifytimestamp || objectclass || ou || sudocommand || sudohost || sudonotafter || sudonotbefore || sudooption || sudoorder || sudorunas || sudorunasgroup || sudorunasuser || sudouser")(target = "ldap:///ou=sudoers,dc=ipa,dc=example")(version 3.0;acl "permission:System: Read Sudoers compat tree";allow (compare,read,search) userdn = "ldap:///anyone";) ++dn: cn=sysaccounts,cn=etc,dc=ipa,dc=example ++aci: (targetfilter = "(objectclass=simplesecurityobject)")(version 3.0;acl "permission:System: Add System Accounts";allow (add) groupdn = "ldap:///cn=System: Add System Accounts,cn=permissions,cn=pbac,dc=ipa,dc=example";) ++dn: cn=sysaccounts,cn=etc,dc=ipa,dc=example ++aci: (targetattr = "userpassword")(targetfilter = "(objectclass=simplesecurityobject)")(version 3.0;acl "permission:System: Check System Accounts passwords";allow (search) groupdn = "ldap:///cn=System: Check System Accounts passwords,cn=permissions,cn=pbac,dc=ipa,dc=example";) ++dn: cn=sysaccounts,cn=etc,dc=ipa,dc=example ++aci: (targetattr = "userpassword")(targetfilter = "(objectclass=simplesecurityobject)")(version 3.0;acl "permission:System: Modify System Accounts";allow (write) groupdn = "ldap:///cn=System: Modify System Accounts,cn=permissions,cn=pbac,dc=ipa,dc=example";) ++dn: cn=sysaccounts,cn=etc,dc=ipa,dc=example ++aci: (targetattr = "createtimestamp || entryusn || memberof || modifytimestamp || objectclass || uid")(targetfilter = "(objectclass=simplesecurityobject)")(version 3.0;acl "permission:System: Read System Accounts";allow (compare,read,search) userdn = "ldap:///all";) ++dn: cn=sysaccounts,cn=etc,dc=ipa,dc=example ++aci: (targetfilter = "(objectclass=simplesecurityobject)")(version 3.0;acl "permission:System: Remove System Accounts";allow (delete) groupdn = "ldap:///cn=System: Remove System Accounts,cn=permissions,cn=pbac,dc=ipa,dc=example";) + dn: cn=topology,cn=ipa,cn=etc,dc=ipa,dc=example + aci: (targetfilter = "(|(objectclass=iparepltopoconf)(objectclass=iparepltoposegment))")(version 3.0;acl "permission:System: Add Topology Segments";allow (add) groupdn = "ldap:///cn=System: Add Topology Segments,cn=permissions,cn=pbac,dc=ipa,dc=example";) + dn: cn=topology,cn=ipa,cn=etc,dc=ipa,dc=example +diff --git a/API.txt b/API.txt +index 7c8daadc1e5cb431d22e9f76ecb0f766795ef61d..6822812a7fcc2d6d5562ae69c310d3cb3f4788e5 100644 +--- a/API.txt ++++ b/API.txt +@@ -4358,7 +4358,7 @@ output: Entry('result') + output: Output('summary', type=[, ]) + output: PrimaryKey('value') + command: role_add_member/1 +-args: 1,10,3 ++args: 1,11,3 + arg: Str('cn', cli_name='name') + option: Flag('all', autofill=True, cli_name='all', default=False) + option: Str('group*', alwaysask=True, cli_name='groups') +@@ -4368,6 +4368,7 @@ option: Str('idoverrideuser*', alwaysask=True, cli_name='idoverrideusers') + option: Flag('no_members', autofill=True, default=False) + option: Flag('raw', autofill=True, cli_name='raw', default=False) + option: Str('service*', alwaysask=True, cli_name='services') ++option: Str('sysaccount*', alwaysask=True, cli_name='sysaccounts') + option: Str('user*', alwaysask=True, cli_name='users') + option: Str('version?') + output: Output('completed', type=[]) +@@ -4425,7 +4426,7 @@ output: Entry('result') + output: Output('summary', type=[, ]) + output: PrimaryKey('value') + command: role_remove_member/1 +-args: 1,10,3 ++args: 1,11,3 + arg: Str('cn', cli_name='name') + option: Flag('all', autofill=True, cli_name='all', default=False) + option: Str('group*', alwaysask=True, cli_name='groups') +@@ -4435,6 +4436,7 @@ option: Str('idoverrideuser*', alwaysask=True, cli_name='idoverrideusers') + option: Flag('no_members', autofill=True, default=False) + option: Flag('raw', autofill=True, cli_name='raw', default=False) + option: Str('service*', alwaysask=True, cli_name='services') ++option: Str('sysaccount*', alwaysask=True, cli_name='sysaccounts') + option: Str('user*', alwaysask=True, cli_name='users') + option: Str('version?') + output: Output('completed', type=[]) +@@ -6039,6 +6041,104 @@ option: Str('version?') + output: Entry('result') + output: Output('summary', type=[, ]) + output: PrimaryKey('value') ++command: sysaccount_add/1 ++args: 1,11,3 ++arg: Str('uid', cli_name='login') ++option: Str('addattr*', cli_name='addattr') ++option: Flag('all', autofill=True, cli_name='all', default=False) ++option: Str('description?', cli_name='desc') ++option: Flag('no_members', autofill=True, default=False) ++option: Bool('nsaccountlock?', cli_name='disabled', default=False) ++option: Bool('privileged?') ++option: Flag('random?', autofill=True, default=False) ++option: Flag('raw', autofill=True, cli_name='raw', default=False) ++option: Str('setattr*', cli_name='setattr') ++option: Password('userpassword?', cli_name='password') ++option: Str('version?') ++output: Entry('result') ++output: Output('summary', type=[, ]) ++output: PrimaryKey('value') ++command: sysaccount_del/1 ++args: 1,2,3 ++arg: Str('uid+', cli_name='login') ++option: Flag('continue', autofill=True, cli_name='continue', default=False) ++option: Str('version?') ++output: Output('result', type=[]) ++output: Output('summary', type=[, ]) ++output: ListOfPrimaryKeys('value') ++command: sysaccount_disable/1 ++args: 1,1,3 ++arg: Str('uid', cli_name='login') ++option: Str('version?') ++output: Output('result', type=[]) ++output: Output('summary', type=[, ]) ++output: PrimaryKey('value') ++command: sysaccount_enable/1 ++args: 1,1,3 ++arg: Str('uid', cli_name='login') ++option: Str('version?') ++output: Output('result', type=[]) ++output: Output('summary', type=[, ]) ++output: PrimaryKey('value') ++command: sysaccount_find/1 ++args: 1,10,4 ++arg: Str('criteria?') ++option: Flag('all', autofill=True, cli_name='all', default=False) ++option: Str('description?', autofill=False, cli_name='desc') ++option: Flag('no_members', autofill=True, default=True) ++option: Bool('nsaccountlock?', autofill=False, cli_name='disabled', default=False) ++option: Flag('pkey_only?', autofill=True, default=False) ++option: Flag('raw', autofill=True, cli_name='raw', default=False) ++option: Int('sizelimit?', autofill=False) ++option: Int('timelimit?', autofill=False) ++option: Str('uid?', autofill=False, cli_name='login') ++option: Str('version?') ++output: Output('count', type=[]) ++output: ListOfEntries('result') ++output: Output('summary', type=[, ]) ++output: Output('truncated', type=[]) ++command: sysaccount_mod/1 ++args: 1,13,3 ++arg: Str('uid', cli_name='login') ++option: Str('addattr*', cli_name='addattr') ++option: Flag('all', autofill=True, cli_name='all', default=False) ++option: Str('delattr*', cli_name='delattr') ++option: Str('description?', autofill=False, cli_name='desc') ++option: Flag('no_members', autofill=True, default=False) ++option: Bool('nsaccountlock?', autofill=False, cli_name='disabled', default=False) ++option: Bool('privileged?') ++option: Flag('random?', autofill=True, default=False) ++option: Flag('raw', autofill=True, cli_name='raw', default=False) ++option: Flag('rights', autofill=True, default=False) ++option: Str('setattr*', cli_name='setattr') ++option: Password('userpassword?', autofill=False, cli_name='password') ++option: Str('version?') ++output: Entry('result') ++output: Output('summary', type=[, ]) ++output: PrimaryKey('value') ++command: sysaccount_policy/1 ++args: 1,6,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: Bool('privileged?') ++option: Flag('raw', autofill=True, cli_name='raw', default=False) ++option: Flag('rights', autofill=True, default=False) ++option: Str('version?') ++output: Entry('result') ++output: Output('summary', type=[, ]) ++output: PrimaryKey('value') ++command: sysaccount_show/1 ++args: 1,5,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: Flag('rights', autofill=True, default=False) ++option: Str('version?') ++output: Entry('result') ++output: Output('summary', type=[, ]) ++output: PrimaryKey('value') + command: topic_find/1 + args: 1,4,4 + arg: Str('criteria?') +@@ -7560,6 +7660,15 @@ default: sudorule_remove_runasgroup/1 + default: sudorule_remove_runasuser/1 + default: sudorule_remove_user/1 + default: sudorule_show/1 ++default: sysaccount/1 ++default: sysaccount_add/1 ++default: sysaccount_del/1 ++default: sysaccount_disable/1 ++default: sysaccount_enable/1 ++default: sysaccount_find/1 ++default: sysaccount_mod/1 ++default: sysaccount_policy/1 ++default: sysaccount_show/1 + default: topic/1 + default: topic_find/1 + default: topic_show/1 +diff --git a/VERSION.m4 b/VERSION.m4 +index 57a65a5a7d8fdbb78ce190a84cc1b8975b29269f..9b7230c8ba65601869825ecb28eb60384ea11fca 100644 +--- a/VERSION.m4 ++++ b/VERSION.m4 +@@ -86,8 +86,8 @@ define(IPA_DATA_VERSION, 20100614120000) + # # + ######################################################## + define(IPA_API_VERSION_MAJOR, 2) +-# Last change: add pwpolicy credit options +-define(IPA_API_VERSION_MINOR, 255) ++# Last change: add sysaccounts ++define(IPA_API_VERSION_MINOR, 256) + + ######################################################## + # Following values are auto-generated from values above +diff --git a/doc/api/commands.rst b/doc/api/commands.rst +index 560f4d901777bb54e2d06ebf044dd474d2d182fe..cbc2eee6f99d5e727ac99eeed22715519e60858b 100644 +--- a/doc/api/commands.rst ++++ b/doc/api/commands.rst +@@ -434,6 +434,14 @@ IPA API Commands + sudorule_remove_runasuser.md + sudorule_remove_user.md + sudorule_show.md ++ sysaccount_add.md ++ sysaccount_del.md ++ sysaccount_disable.md ++ sysaccount_enable.md ++ sysaccount_find.md ++ sysaccount_mod.md ++ sysaccount_policy.md ++ sysaccount_show.md + topic_find.md + topic_show.md + topologysegment_add.md +diff --git a/doc/api/role_add_member.md b/doc/api/role_add_member.md +index 0993852de71516c50ba8611207879ef738596539..40acead1c6c4d80a59358ea1a01587a5a7bc5553 100644 +--- a/doc/api/role_add_member.md ++++ b/doc/api/role_add_member.md +@@ -21,6 +21,7 @@ Add members to a role. + * hostgroup : :ref:`Str` + * service : :ref:`Str` + * idoverrideuser : :ref:`Str` ++* sysaccount : :ref:`Str` + + ### Output + |Name|Type +diff --git a/doc/api/role_remove_member.md b/doc/api/role_remove_member.md +index 9a9ae8f376d5fcead4d658e709fde13bc8b0cfa6..ba309cf8c9c31086ab6050dee99ce333b6d24b1d 100644 +--- a/doc/api/role_remove_member.md ++++ b/doc/api/role_remove_member.md +@@ -21,6 +21,7 @@ Remove members from a role. + * hostgroup : :ref:`Str` + * service : :ref:`Str` + * idoverrideuser : :ref:`Str` ++* sysaccount : :ref:`Str` + + ### Output + |Name|Type +diff --git a/doc/api/sysaccount_add.md b/doc/api/sysaccount_add.md +new file mode 100644 +index 0000000000000000000000000000000000000000..304fc0212760d69227d9b2180886204567337254 +--- /dev/null ++++ b/doc/api/sysaccount_add.md +@@ -0,0 +1,40 @@ ++[//]: # (THE CONTENT BELOW IS GENERATED. DO NOT EDIT.) ++# sysaccount_add ++Add a new IPA system account. ++ ++### Arguments ++|Name|Type|Required ++|-|-|- ++|uid|:ref:`Str`|True ++ ++### Options ++* all : :ref:`Flag` **(Required)** ++ * Default: False ++* raw : :ref:`Flag` **(Required)** ++ * Default: False ++* no_members : :ref:`Flag` **(Required)** ++ * Default: False ++* description : :ref:`Str` ++* userpassword : :ref:`Password` ++* random : :ref:`Flag` ++ * Default: False ++* nsaccountlock : :ref:`Bool` ++ * Default: False ++* setattr : :ref:`Str` ++* addattr : :ref:`Str` ++* privileged : :ref:`Bool` ++* version : :ref:`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 +\ No newline at end of file +diff --git a/doc/api/sysaccount_del.md b/doc/api/sysaccount_del.md +new file mode 100644 +index 0000000000000000000000000000000000000000..a330d648631101b245c079155a8d678aabb7d592 +--- /dev/null ++++ b/doc/api/sysaccount_del.md +@@ -0,0 +1,27 @@ ++[//]: # (THE CONTENT BELOW IS GENERATED. DO NOT EDIT.) ++# sysaccount_del ++Delete an IPA system account. ++ ++### Arguments ++|Name|Type|Required ++|-|-|- ++|uid|:ref:`Str`|True ++ ++### Options ++* continue : :ref:`Flag` **(Required)** ++ * Default: False ++* version : :ref:`Str` ++ ++### Output ++|Name|Type ++|-|- ++|result|Output ++|summary|Output ++|value|ListOfPrimaryKeys ++ ++[//]: # (ADD YOUR NOTES BELOW. THESE WILL BE PICKED EVERY TIME THE DOCS ARE REGENERATED. //end) ++### Semantics ++ ++### Notes ++ ++### Version differences +\ No newline at end of file +diff --git a/doc/api/sysaccount_disable.md b/doc/api/sysaccount_disable.md +new file mode 100644 +index 0000000000000000000000000000000000000000..e45299385c58fc229840558b0fbf9a6c8aee9cdd +--- /dev/null ++++ b/doc/api/sysaccount_disable.md +@@ -0,0 +1,25 @@ ++[//]: # (THE CONTENT BELOW IS GENERATED. DO NOT EDIT.) ++# sysaccount_disable ++Disable a system account. ++ ++### Arguments ++|Name|Type|Required ++|-|-|- ++|uid|:ref:`Str`|True ++ ++### Options ++* version : :ref:`Str` ++ ++### Output ++|Name|Type ++|-|- ++|result|Output ++|summary|Output ++|value|PrimaryKey ++ ++[//]: # (ADD YOUR NOTES BELOW. THESE WILL BE PICKED EVERY TIME THE DOCS ARE REGENERATED. //end) ++### Semantics ++ ++### Notes ++ ++### Version differences +\ No newline at end of file +diff --git a/doc/api/sysaccount_enable.md b/doc/api/sysaccount_enable.md +new file mode 100644 +index 0000000000000000000000000000000000000000..6e0422fe26e2085f87f942c375dbdc9dd7aac561 +--- /dev/null ++++ b/doc/api/sysaccount_enable.md +@@ -0,0 +1,25 @@ ++[//]: # (THE CONTENT BELOW IS GENERATED. DO NOT EDIT.) ++# sysaccount_enable ++Enable a system account. ++ ++### Arguments ++|Name|Type|Required ++|-|-|- ++|uid|:ref:`Str`|True ++ ++### Options ++* version : :ref:`Str` ++ ++### Output ++|Name|Type ++|-|- ++|result|Output ++|summary|Output ++|value|PrimaryKey ++ ++[//]: # (ADD YOUR NOTES BELOW. THESE WILL BE PICKED EVERY TIME THE DOCS ARE REGENERATED. //end) ++### Semantics ++ ++### Notes ++ ++### Version differences +\ No newline at end of file +diff --git a/doc/api/sysaccount_find.md b/doc/api/sysaccount_find.md +new file mode 100644 +index 0000000000000000000000000000000000000000..aba0890922da189e6c6d76199e2bbf918a852e61 +--- /dev/null ++++ b/doc/api/sysaccount_find.md +@@ -0,0 +1,40 @@ ++[//]: # (THE CONTENT BELOW IS GENERATED. DO NOT EDIT.) ++# sysaccount_find ++Search for IPA system accounts. ++ ++### Arguments ++|Name|Type|Required ++|-|-|- ++|criteria|:ref:`Str`|False ++ ++### Options ++* all : :ref:`Flag` **(Required)** ++ * Default: False ++* raw : :ref:`Flag` **(Required)** ++ * Default: False ++* no_members : :ref:`Flag` **(Required)** ++ * Default: True ++* uid : :ref:`Str` ++* description : :ref:`Str` ++* nsaccountlock : :ref:`Bool` ++ * Default: False ++* timelimit : :ref:`Int` ++* sizelimit : :ref:`Int` ++* version : :ref:`Str` ++* pkey_only : :ref:`Flag` ++ * Default: False ++ ++### Output ++|Name|Type ++|-|- ++|count|Output ++|result|ListOfEntries ++|summary|Output ++|truncated|Output ++ ++[//]: # (ADD YOUR NOTES BELOW. THESE WILL BE PICKED EVERY TIME THE DOCS ARE REGENERATED. //end) ++### Semantics ++ ++### Notes ++ ++### Version differences +\ No newline at end of file +diff --git a/doc/api/sysaccount_mod.md b/doc/api/sysaccount_mod.md +new file mode 100644 +index 0000000000000000000000000000000000000000..9f8b08765d852bc317a7bb0067a2b52d27549a84 +--- /dev/null ++++ b/doc/api/sysaccount_mod.md +@@ -0,0 +1,43 @@ ++[//]: # (THE CONTENT BELOW IS GENERATED. DO NOT EDIT.) ++# sysaccount_mod ++Modify an existing IPA system account. ++ ++### Arguments ++|Name|Type|Required ++|-|-|- ++|uid|:ref:`Str`|True ++ ++### Options ++* rights : :ref:`Flag` **(Required)** ++ * Default: False ++* all : :ref:`Flag` **(Required)** ++ * Default: False ++* raw : :ref:`Flag` **(Required)** ++ * Default: False ++* no_members : :ref:`Flag` **(Required)** ++ * Default: False ++* description : :ref:`Str` ++* userpassword : :ref:`Password` ++* random : :ref:`Flag` ++ * Default: False ++* nsaccountlock : :ref:`Bool` ++ * Default: False ++* setattr : :ref:`Str` ++* addattr : :ref:`Str` ++* delattr : :ref:`Str` ++* privileged : :ref:`Bool` ++* version : :ref:`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 +\ No newline at end of file +diff --git a/doc/api/sysaccount_policy.md b/doc/api/sysaccount_policy.md +new file mode 100644 +index 0000000000000000000000000000000000000000..b95a8b9d5a2aa3a37cd681b391cac13dbff2dd4a +--- /dev/null ++++ b/doc/api/sysaccount_policy.md +@@ -0,0 +1,34 @@ ++[//]: # (THE CONTENT BELOW IS GENERATED. DO NOT EDIT.) ++# sysaccount_policy ++Manage the system account policy. ++ ++### Arguments ++|Name|Type|Required ++|-|-|- ++|uid|:ref:`Str`|True ++ ++### Options ++* rights : :ref:`Flag` **(Required)** ++ * Default: False ++* all : :ref:`Flag` **(Required)** ++ * Default: False ++* raw : :ref:`Flag` **(Required)** ++ * Default: False ++* no_members : :ref:`Flag` **(Required)** ++ * Default: False ++* privileged : :ref:`Bool` ++* version : :ref:`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 +\ No newline at end of file +diff --git a/doc/api/sysaccount_show.md b/doc/api/sysaccount_show.md +new file mode 100644 +index 0000000000000000000000000000000000000000..2b78764e4d79a76d4e477ad63832d2758fa5dade +--- /dev/null ++++ b/doc/api/sysaccount_show.md +@@ -0,0 +1,33 @@ ++[//]: # (THE CONTENT BELOW IS GENERATED. DO NOT EDIT.) ++# sysaccount_show ++Display information about an IPA system account. ++ ++### Arguments ++|Name|Type|Required ++|-|-|- ++|uid|:ref:`Str`|True ++ ++### Options ++* rights : :ref:`Flag` **(Required)** ++ * Default: False ++* all : :ref:`Flag` **(Required)** ++ * Default: False ++* raw : :ref:`Flag` **(Required)** ++ * Default: False ++* no_members : :ref:`Flag` **(Required)** ++ * Default: False ++* version : :ref:`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 +\ No newline at end of file +diff --git a/doc/designs/index.rst b/doc/designs/index.rst +index 8804a33a16e9da5c1a3d52c62e04572e04fcabca..274120711daabb657130012f6897084554354123 100644 +--- a/doc/designs/index.rst ++++ b/doc/designs/index.rst +@@ -35,3 +35,4 @@ FreeIPA design documentation + rbcd.md + id-mapping.md + audit-ipa-api.md ++ sysaccounts.md +diff --git a/doc/designs/sysaccounts.md b/doc/designs/sysaccounts.md +new file mode 100644 +index 0000000000000000000000000000000000000000..c968bc4e0b1477a8108c5fac5e03d7760047e916 +--- /dev/null ++++ b/doc/designs/sysaccounts.md +@@ -0,0 +1,269 @@ ++# LDAP system accounts ++ ++## Overview ++ ++Two important FreeIPA components are LDAP server and Kerberos infrastructure. When Kerberos protocol ++is used for authentication in FreeIPA deployments, context of the original authentication is ++preserved. This allows applications to inspect it and make decisions based not only on user identity ++but also how the original authentication has happened. For applications that do not support ++integration with Kerberos, a traditional LDAP bind is used. In this case, the original ++authentication context is lost and the application can only see the identity of the user that has ++authenticated. ++ ++Applications which do not support Kerberos authentication also need to authenticate to the LDAP ++server. This is typically done using an LDAP system account. They are not used in the POSIX ++environment and are not associated with any user. System accounts are typically used by applications ++to authenticate to the LDAP server and perform operations on behalf of the application. ++ ++This document describes the design of the system accounts in FreeIPA. ++ ++## Use Cases ++ ++### Use Case 1: Legacy Application authentication ++ ++A legacy application that does not support Kerberos authentication needs to authenticate to the LDAP ++server. The application uses an LDAP system account to authenticate to the LDAP server. After ++successful authentication, the application can perform operations on behalf of the system account. ++ ++### Use Case 2: External account password rotation ++ ++An external system controls the passwords of user accounts in FreeIPA. The external system uses an ++LDAP system account to authenticate to the LDAP server and change the password of a user account. ++The change of the user account's password should not trigger the password policy reset for the user ++account. ++ ++## How to Use ++ ++LDAP system account is addressed by its LDAP DN. An application can bind to LDAP by presenting both ++LDAP DN of the object to bind as and its password. The password is stored in the `userPassword` ++attribute of the system account object. ++ ++A typical LDAP authentication operation with the system account would look like this: ++ ++``` ++$ ldapsearch -D "uid=system-account,cn=sysaccounts,cn=etc,dc=example,dc=com" -W -b "dc=example,dc=com" -s sub "(objectclass=*)" ++``` ++ ++In the `ldapsearch` command above, the system account ++`uid=systemaccount,cn=sysaccounts,cn=etc,dc=example,dc=com` is used to authenticate to the LDAP ++server. Its password is not provided directly but `ldapsearch` command will prompt for it due to ++`-W` option. ++ ++If the system account is used to perform LDAP operations, the system account should have the ++necessary permissions to perform the operation. The permissions are granted using the LDAP access ++controls (ACIs). ++ ++## Design ++ ++### System Account LDAP Object ++ ++System account object is a regular LDAP entry with at least two object classes defined: `account` ++and `simpleSecurityObject`. The object is stored in the `cn=sysaccounts,cn=etc` container. In order ++to allow membership in groups and roles, object class `nsMemberOf` can be used. The object has the ++following attributes: ++ ++- `uid`: The unique identifier of the system account. ++ ++- `userPassword`: The password of the system account. ++ ++- `description`: A human-readable description of the system account. ++ ++- `memberOf`: The groups and roles the system account is a member of, in case the `nsMemberOf` ++ object class is used. ++ ++Other attributes can be added to the system account object as needed but they aren't used by the ++system account itself. ++ ++## Implementation ++ ++### LDAP BIND Operation ++ ++FreeIPA provides a number of plugins that alter the behavior of the LDAP server. One of these ++plugins is the `ipa_pwd_extop` plugin. This plugin is used to intercept the LDAP BIND operation and ++perform additional checks and operations. In particular, this plugin enforces two-factor ++authentication for the user accounts if `EnforceLDAPOTP` global option is set or LDAP client ++enforced the check through an LDAP control. ++ ++When `EnforceLDAPOTP` mode is enabled, any LDAP bind must be performed with a user account that has ++two-factor authentication enabled. This would break LDAP binds with system accounts as they do not ++have two-factor authentication enabled. `ipa_pwd_extop` plugin accounts for this by checking that ++the LDAP object pointed by the LDAP bind has `simpleSecurityObject` object class. If the object does ++have this object class, the plugin allows the bind to proceed. ++ ++### Password modifications using system accounts ++ ++FreeIPA implements a password change policy that ensures only users can keep the passwords they ++changed. If a password change came from any other source, it will be marked for a change next time ++it is used. For example, an administrator may reset a user's password but this password will have to ++be changed next time user authenticates to the system. This is enforced through both LDAP and ++Kerberos authentication flows. ++ ++In order to allow external systems to synchronize passwords without triggering the password reset, ++FreeIPA implements two exceptions: ++ ++- `cn=Directory Manager` can change passwords without marking them for a change. ++ ++- LDAP objects whose DNs are stored in the `passSyncManagersDNs` attribute of the ++ `cn=ipa_pwd_extop,cn=plugins,cn=config` LDAP entry can change passwords without marking them for a ++ change. ++ ++The latter exception is used internally by the FreeIPA replication system to synchronize data from ++Windows domain controllers with the help of PassSync plugin. However, both of these exceptions also ++avoid password policy checks for the new passwords. ++ ++In order to differentiate the `passSyncManagersDNs` and the system accounts, we introduce a ++separate attribute `SysAcctManagersDNs`. The system accounts DNs can be added to the ++`SysAcctManagersDNs` attribute to allow them to update user passwords without marking them for a ++change. ++ ++Members whose DNs are stored in `SysAcctManagersDNs` attribute will have the following semantics in ++`ipa_pwd_extop` processing of the password changes: ++ ++- the password change will be subject to the password policy associated with the user account, like ++ for the normal user password changes; ++ ++- the password change initiated by the system account will not force expiration of the user's ++ password unlike the administrator-initiated password change. ++ ++The LDAP entry `cn=ipa_pwd_extop,cn=plugins,cn=config` is not replicated. Every IPA server has its ++own configuration. If more than one server needs to allow a way to modify passwords without reset, ++each server's configuration must be updated. ++ ++### Access controls ++ ++To simplify administration of the system accounts, a new privilege `System Accounts Administrators` ++is added. This privilege is granted by default to the `Security Architect` role. ++ ++The privilege gives access to the following separate permissions: ++ ++- `System: Add System Accounts`: allows to create system account objects ++- `System: Check System Accounts passwords`: allows to check whether system accounts have passwords ++ defined ++- `System: Modify System Accounts`: allows to update system account information, including a ++ password ++- `System: Remove System Accounts`: allows to remove the system account ++ ++The system account itself needs to be permitted to modify user passwords. To help with that, a role ++management is extended to allow system accounts membership. Thus, a corresponding permission ++(`System: Change User password`) can be associated through the privilege system and granted via the ++role. ++ ++Even when a system account is granted permission to modify user accounts, treating the change to not ++cause a password reset needs an explicit buy-in, as described in the previous session. ++ ++In order to provide the management of the `SysAcctManagersDNs` attribute, FreeIPA defines two ++additional permissions: ++ ++- `Modify System Account Managers Configuration` permission allows adding and removing DNs to and ++ from the `SysAcctManagersDNs` attribute. ++ ++- `Read System Account Managers Configuration` permission allows reading the `SysAcctManagersDNs` ++ attribute. ++ ++Both these permissions are granted to the `System Accounts Administrators` privilege and, through ++that privilege, to the `Security Architect` role. Members of `admins` group are not members of the ++`Security Architect` role by default. Instead, they are added to the `System Accounts ++Administrators` privilege through the 'Replication Administrators' privilege. ++ ++The following sequence will allow a system account `my-app` to change user passwords: ++ ++```bash ++$ kinit admin ++$ ipa sysaccount-add 'my-app' --random ++$ ipa privilege-add 'my-app password change privilege' ++$ ipa privilege-add-permission 'my-app password change privilege' \ ++ --permission 'System: Change User password' ++$ ipa role-add 'my-app role' ++$ ipa role-add-privilege 'my-app role' \ ++ --privilege 'my-app password change privilege' ++$ ipa role-add-member 'my-app role' --sysaccounts 'my-app' ++# In order to allow password changes without reset: ++$ ipa sysaccount-policy 'my-app' --privileged=true ++``` ++ ++## Feature Management ++ ++### CLI ++ ++| Command | Options | Description | ++| ------------------ | ------- | ---------------------------------------------- | ++| sysaccount-add | name | Create LDAP object for a new system account | ++| sysaccount-del | name | Remove LDAP object used for the system account | ++| sysaccount-find | [name] | Return list of existing system accounts | ++| sysaccount-mod | name | Modify settings for the system account | ++| sysaccount-policy | name | Update `SysAcctManagersDNs` configuration | ++| sysaccount-enable | name | Allow use of system account for LDAP BIND | ++| sysaccount-disable | name | Disable use of system account for LDAP BIND | ++ ++The following commands provide an additional `--privileged=TRUE|FALSE` option: ++`sysaccount-add`, `sysaccount-mod`, and `sysaccount-policy`. This option is used to allow a ++system account to update user passwords without forcing them being requested to change on the next ++login by the user. This is done by adding the DN of this system account to the list of DNs allowed ++to manage password synchronization (`SysAcctManagersDNs` LDAP attribute) of the `ipa_pwd_extop` ++plugin. ++ ++In order to allow adding system accounts to roles, `role` object has been extended to allow ++membership of system accounts. The command `ipa role-add-member 'my-role' --sysaccounts ++'my-account'` will make sure to grant `my-account` system account all privileges associated with the ++`my-role` role. ++ ++An example of running `ipa sysaccount-add -v my-app --random --privileged=true`, where `-v` option ++allows to see the `INFO` level message that shows bind DN of the created system account: ++ ++```bash ++$ ipa sysaccount-add -v my-app --random --privileged=true ++ipa: WARNING: Password reset permission is local to server server.ipa.test. ++Restart the Directory Server services on it. Run the 'sysaccount-policy' ++command against each server you want to allow or disable to reset passwords on. ++ipa: INFO: To bind to LDAP with system account 'my-app', use the bind DN 'uid=my-app,cn=sysaccounts,cn=etc,dc=ipa,dc=test'. ++------------------------------ ++Added system account "my-app" ++------------------------------ ++ System account ID: my-app ++ Random password: ++``` ++ ++Due to the fact that `SysAcctManagersDNs` configuration setting is not replicated across IPA ++servers, the update of the configuration will issue a warning to make sure to update other IPA ++servers as well. It will also tell that an LDAP server on the specific server where command was ++executed must be restarted to apply changes to the `ipa_pwd_extop` plugin configuration. ++ ++Typically, external consumers of the system accounts operate against a single IPA server, thus ++changing the configuration at a single system is enough. As mentioned earlier, `ipa_pwd_extop` ++plugin configuration is not replicated and must be modified on each IPA replica. IPA command line ++tool, `ipa`, can be run on the individual IPA server to force connection to that specific server. ++ ++Command `sysaccount-del` will automatically remove the deleted system account reference from the ++`ipa_pwd_extop` configuration, if any. However, it will only be done on the single master. Thus, it ++is recommended to remove the entry from all affected IPA servers using `ipa sysaccount-policy` ++before removing the system account with `sysaccount-del` command. ++ ++Three examples below demonstrate different ways to perform the policy operation on a system account ++in order to affect a specific IPA server's `ipa_pwd_extop` plugin configuration: ++ ++1. Use `ipa` tool as unprivileged user with administrative Kerberos ticket: ++ ++```bash ++... ssh to ipa-server ++[ipa-server] $ kinit admin ++[ipa-server] $ ipa sysaccount-policy my-app --privileged=true ++``` ++ ++2. Newer `ipa` tool implementations might provide `--force-server ` option that allows ++ to choose the server to communicate with: ++ ++```bash ++$ kinit admin ++$ ipa --force-server ipa-server sysaccount-policy my-app --privileged=true ++``` ++ ++3. Run `ipa` tool as the privileged root user on the IPA server: ++ ++```bash ++... login as root on the ipa-server ++[ipa-server] # ipa -e in_server=true sysaccount-policy my-app --privileged=true ++``` ++ ++In the latter example `ipa` tool will directly connect to LDAPI endpoint and POSIX root user will be ++mapped to `cn=Directory Manager` LDAP administrator account. This approach also works for ++deployments where Kerberos infrastructure is temporarily unavailable. +diff --git a/install/ui/src/freeipa/app.js b/install/ui/src/freeipa/app.js +index d78cebb3ac64671ebee97ae4cfc05450a0872d3a..bf4818736cfc8a78089e09096797fa5281e85703 100644 +--- a/install/ui/src/freeipa/app.js ++++ b/install/ui/src/freeipa/app.js +@@ -55,6 +55,7 @@ define([ + './stageuser', + './subid', + './sudo', ++ './sysaccount', + './trust', + './topology', + './user', +diff --git a/install/ui/src/freeipa/navigation/menu_spec.js b/install/ui/src/freeipa/navigation/menu_spec.js +index 0540220bb0c1f34f2b82dcbab4320cdf6c6e6275..bef3c980a2a9e740eeb2d3d315bfd516bb4fee73 100644 +--- a/install/ui/src/freeipa/navigation/menu_spec.js ++++ b/install/ui/src/freeipa/navigation/menu_spec.js +@@ -121,7 +121,8 @@ var nav = {}; + label: '@i18n:objects.subid.stats' + } + ] +- } ++ }, ++ { entity: 'sysaccount' } + ] + }, + { +diff --git a/install/ui/src/freeipa/sysaccount.js b/install/ui/src/freeipa/sysaccount.js +new file mode 100644 +index 0000000000000000000000000000000000000000..269d1904d6df0911d306aaca4a7fa69e59beefe6 +--- /dev/null ++++ b/install/ui/src/freeipa/sysaccount.js +@@ -0,0 +1,255 @@ ++/* ++ * System accounts support ++ */ ++ ++define([ ++ "dojo/on", ++ "./ipa", ++ "./builder", ++ "./jquery", ++ "./phases", ++ "./reg", ++ "./text", ++], function (on, IPA, builder, $, phases, reg, text) { ++ /** ++ * System accounts module ++ */ ++ var exp = (IPA.sysaccount = {}); ++ ++ var make_spec = function () { ++ return { ++ name: "sysaccount", ++ policies: [ ++ IPA.search_facet_update_policy, ++ IPA.details_facet_update_policy, ++ ], ++ facets: [ ++ { ++ $type: "search", ++ row_disabled_attribute: "nsaccountlock", ++ columns: [ ++ "uid", ++ "description", ++ { ++ $type: "checkbox", ++ name: "privileged", ++ label: "@i18n:objects.sysaccount.privileged", ++ formatter: { ++ $type: "boolean_status", ++ invert_value: false, ++ }, ++ }, ++ { ++ name: "nsaccountlock", ++ label: "@i18n:status.label", ++ formatter: { ++ $type: "boolean_status", ++ invert_value: true, ++ }, ++ }, ++ ], ++ }, ++ { ++ $type: "details", ++ actions: [ ++ "add", ++ "delete", ++ "enable", ++ "disable", ++ { $type: "reset_sysaccount_password" }, ++ ], ++ header_actions: [ ++ "reset_sysaccount_password", ++ "delete", ++ "enable", ++ "disable", ++ ], ++ state: { ++ evaluators: [ ++ { ++ $factory: IPA.enable_state_evaluator, ++ field: "nsaccountlock", ++ adapter: { $type: "batch", result_index: 0 }, ++ invert_value: true, ++ }, ++ { ++ $factory: IPA.acl_state_evaluator, ++ name: "reset_password_acl_evaluator", ++ adapter: { $type: "batch", result_index: 0 }, ++ attribute: "userpassword", ++ }, ++ ], ++ }, ++ sections: [ ++ { ++ name: "details", ++ fields: [ ++ "uid", ++ "description", ++ ++ { ++ $type: "checkbox", ++ name: "privileged", ++ label: "@i18n:objects.sysaccount.privileged", ++ metadata: ++ "@mc-opt:sysaccount_policy:privileged", ++ needs_confirm: true, ++ confirm_msg: ++ "@i18n.objects.sysaccount.privileged_confirm", ++ acl_param: "uid", ++ }, ++ ], ++ }, ++ ], ++ }, ++ { ++ $type: "association", ++ name: "memberof_role", ++ associator: IPA.serial_associator, ++ add_title: "@i18n:objects.sysaccount.add_into_roles", ++ remove_title: "@i18n:objects.sysaccount.remove_from_roles", ++ }, ++ ], ++ standard_association_facets: true, ++ adder_dialog: { ++ title: "@i18n:objects.sysaccount.add", ++ $factory: IPA.sysaccount.adder_dialog, ++ sections: [ ++ { ++ fields: [ ++ { ++ name: "uid", ++ required: true, ++ }, ++ { ++ name: "description", ++ required: false, ++ }, ++ { ++ $type: "checkbox", ++ name: "privileged", ++ label: "@i18n:objects.sysaccount.privileged", ++ metadata: "@mc-opt:sysaccount_add:privileged", ++ }, ++ ], ++ }, ++ { ++ fields: [ ++ { ++ name: "userpassword", ++ label: "@i18n:password.new_password", ++ $type: "password", ++ required: true, ++ }, ++ { ++ name: "userpassword2", ++ label: "@i18n:password.verify_password", ++ $type: "password", ++ required: true, ++ flags: ["no_command"], ++ }, ++ ], ++ }, ++ ], ++ }, ++ deleter_dialog: { ++ title: "@i18n:objects.sysaccount.remove", ++ }, ++ }; ++ }; ++ ++ IPA.sysaccount.adder_dialog = function (spec) { ++ var that = IPA.entity_adder_dialog(spec); ++ ++ that.validate = function () { ++ var valid = that.dialog_validate(); ++ ++ var field1 = that.fields.get_field("userpassword"); ++ var field2 = that.fields.get_field("userpassword2"); ++ ++ var password1 = field1.save()[0]; ++ var password2 = field2.save()[0]; ++ ++ if (password1 !== password2) { ++ field2.set_valid({ ++ valid: false, ++ message: text.get("@i18n:password.password_must_match"), ++ }); ++ valid = false; ++ } ++ ++ return valid; ++ }; ++ ++ return that; ++ }; ++ ++ IPA.sysaccount.password_dialog_pre_op0 = function (spec) { ++ spec.password_name = spec.password_name || "userpassword"; ++ return spec; ++ }; ++ ++ IPA.sysaccount.password_dialog_pre_op = function (spec) { ++ spec.method = spec.method || "sysaccount_mod"; ++ return spec; ++ }; ++ ++ IPA.sysaccount.reset_password_action = function (spec) { ++ spec = spec || {}; ++ spec.name = spec.name || "reset_sysaccount_password"; ++ spec.label = spec.label || "@i18n:password.reset_password"; ++ spec.enable_cond = spec.enable_cond || ["userpassword_w"]; ++ ++ var that = IPA.action(spec); ++ ++ that.execute_action = function (facet) { ++ var dialog = builder.build("dialog", { ++ $type: "sysaccount_password", ++ args: [facet.get_pkey()], ++ }); ++ ++ dialog.open(); ++ }; ++ ++ that.save = function (record) { ++ that.dialog_save(record); ++ delete record.userpassword2; ++ }; ++ ++ return that; ++ }; ++ ++ /** ++ * System accounts entity specification object ++ * @member sysaccount ++ */ ++ exp.entity_spec = make_spec(); ++ ++ /** ++ * Register entity ++ * @member sysaccount ++ */ ++ exp.register = function () { ++ var e = reg.entity; ++ var a = reg.action; ++ var d = reg.dialog; ++ e.register({ type: "sysaccount", spec: exp.entity_spec }); ++ a.register( ++ "reset_sysaccount_password", ++ IPA.sysaccount.reset_password_action, ++ ); ++ d.copy("password", "sysaccount_password", { ++ factory: IPA.sysaccount.password_dialog, ++ pre_ops: [IPA.sysaccount.password_dialog_pre_op], ++ }); ++ d.register_pre_op( ++ "sysaccount_password", ++ IPA.sysaccount.password_dialog_pre_op0, ++ true, ++ ); ++ }; ++ ++ phases.on("registration", exp.register); ++ ++ return exp; ++}); +diff --git a/install/updates/40-delegation.update b/install/updates/40-delegation.update +index 2de5f10fe87cec4bef59b92f9743fd41eac0dae4..2704b6776181ae004a8cca8c2bdda3459d932e0f 100644 +--- a/install/updates/40-delegation.update ++++ b/install/updates/40-delegation.update +@@ -306,3 +306,23 @@ default:objectClass: groupofnames + default:objectClass: nestedgroup + default:cn: External IdP server Administrators + default:description: External IdP server Administrators ++ ++dn: cn=Read System Accounts Managers Configuration,cn=permissions,cn=pbac,$SUFFIX ++default:objectClass: groupofnames ++default:objectClass: ipapermission ++default:objectClass: top ++default:cn: Read System Accounts Managers Configuration ++default:member: cn=Replication Administrators,cn=privileges,cn=pbac,$SUFFIX ++default:ipapermissiontype: SYSTEM ++ ++dn: cn=Modify System Accounts Managers Configuration,cn=permissions,cn=pbac,$SUFFIX ++default:objectClass: groupofnames ++default:objectClass: ipapermission ++default:objectClass: top ++default:cn: Modify System Accounts Managers Configuration ++default:member: cn=Replication Administrators,cn=privileges,cn=pbac,$SUFFIX ++default:ipapermissiontype: SYSTEM ++ ++dn: cn=config ++add:aci: (targetattr = "sysacctmanagersdns*")(target = "ldap:///cn=ipa_pwd_extop,cn=plugins,cn=config")(version 3.0;acl "permission:Modify System Accounts Managers Configuration";allow (read,search,write) groupdn = "ldap:///cn=Modify System Accounts Managers Configuration,cn=permissions,cn=pbac,$SUFFIX";) ++ +diff --git a/install/updates/45-roles.update b/install/updates/45-roles.update +index e1681bf670fa8b76584aa3f743afa9b6083c5317..bafdce3c4b10b6a1d7d90d080b626565de79c67e 100644 +--- a/install/updates/45-roles.update ++++ b/install/updates/45-roles.update +@@ -100,3 +100,12 @@ default:description: Enrollment Administrator responsible for client(host) enrol + + dn: cn=Host Enrollment,cn=privileges,cn=pbac,$SUFFIX + add:member: cn=Enrollment Administrator,cn=roles,cn=accounts,$SUFFIX ++ ++dn: cn=System Accounts Administrators,cn=privileges,cn=pbac,$SUFFIX ++default:objectClass: groupofnames ++default:objectClass: nestedgroup ++default:objectClass: top ++default:cn: System Accounts Administrators ++default:description: System Accounts Administrators ++add: member: cn=Security Architect,cn=roles,cn=accounts,$SUFFIX ++add: member: cn=Replication Administrators,cn=privileges,cn=pbac,$SUFFIX +diff --git a/ipaclient/plugins/sysaccounts.py b/ipaclient/plugins/sysaccounts.py +new file mode 100644 +index 0000000000000000000000000000000000000000..26a305715d8efe4444ce4da70ca274ec0413146a +--- /dev/null ++++ b/ipaclient/plugins/sysaccounts.py +@@ -0,0 +1,16 @@ ++# Copyright (C) 2025 Red Hat ++# see file 'COPYING' for use and warranty information ++ ++from ipaclient.frontend import MethodOverride ++from ipalib.plugable import Registry ++ ++register = Registry() ++ ++ ++@register(override=True, no_fail=True) ++class sysaccount_add(MethodOverride): ++ def interactive_prompt_callback(self, kw): ++ if not (kw.get('random', False) or kw.get('userpassword', False)): ++ kw['userpassword'] = self.Backend.textui.prompt_password( ++ self.params['userpassword'].label ++ ) +diff --git a/ipalib/messages.py b/ipalib/messages.py +index a440ca6221d00e6d753c94f87396fc5d7ae177b5..00849a9cc5ccecf2aef76db32713e3fd037aaaa4 100644 +--- a/ipalib/messages.py ++++ b/ipalib/messages.py +@@ -531,6 +531,32 @@ class UidNumberOutOfLocalIDRange(PublicMessage): + ) + + ++class ServerSysacctMgrUpdateRequired(PublicMessage): ++ """ ++ **13035** An update of sysaccount manager entry is required on other servers ++ """ ++ errno = 13035 ++ type = "warning" ++ format = _( ++ "Password reset permission is local to server %(server)s.\n" ++ "Restart the Directory Server services on it. Run the command " ++ "'ipa %(command)s' against each server you want to allow or " ++ "disable to reset passwords on." ++ ) ++ ++ ++class SystemAccountUsage(PublicMessage): ++ """ ++ **13036** General sysaccount usage note ++ """ ++ errno = 13036 ++ type = "info" ++ format = _( ++ "To bind to LDAP with system account '%(uid)s', use the bind DN " ++ "'%(dn)s'." ++ ) ++ ++ + def iter_messages(variables, base): + """Return a tuple with all subclasses + """ +diff --git a/ipaserver/plugins/baseldap.py b/ipaserver/plugins/baseldap.py +index c991d84ea527fcabdc456b8888f665461841f238..adcbfa1bfa956b3778a561a1997d052892f846af 100644 +--- a/ipaserver/plugins/baseldap.py ++++ b/ipaserver/plugins/baseldap.py +@@ -125,6 +125,8 @@ global_output_params = ( + label='Subordinate ids',), + Str('member_idoverrideuser?', + label=_('Member ID user overrides'),), ++ Str('member_sysaccount?', ++ label=_('Member system accounts'),), + Str('memberindirect_idoverrideuser?', + label=_('Indirect Member ID user overrides'),), + Str('memberindirect_user?', +@@ -1480,6 +1482,7 @@ class LDAPUpdate(LDAPQuery, crud.Update): + ) + + has_output_params = global_output_params ++ allow_empty_update = False + + def _get_rename_option(self): + rdnparam = getattr(self.obj.params, self.obj.primary_key.name) +@@ -1573,8 +1576,9 @@ class LDAPUpdate(LDAPQuery, crud.Update): + + self._exc_wrapper(keys, options, ldap.update_entry)(update) + except errors.EmptyModlist as e: +- if not rdnupdate: +- raise e ++ if not self.allow_empty_update: ++ if not rdnupdate: ++ raise e + except errors.NotFound: + raise self.obj.handle_not_found(*keys) + +diff --git a/ipaserver/plugins/internal.py b/ipaserver/plugins/internal.py +index 283b430778c37861c417c7829ac04bd2fb966be0..f14b2ac522adf5180331b9d78881f12500755748 100644 +--- a/ipaserver/plugins/internal.py ++++ b/ipaserver/plugins/internal.py +@@ -1533,6 +1533,24 @@ class i18n_messages(Command): + "sudooptions": { + "remove": _("Remove sudo options"), + }, ++ "sysaccount": { ++ "add": _("Add system account"), ++ "remove": _("Remove system account"), ++ "add_into_roles": _( ++ "Add system account '${primary_key}' into roles" ++ ), ++ "remove_from_roles": _( ++ "Remove system account '${primary_key}' from roles" ++ ), ++ "privileged": _( ++ "Change passwords without reset" ++ ), ++ "privileged_confirm": _( ++ "Change privileged status of '${primary_key}' " ++ "system account" ++ ) ++ ++ }, + "topology": { + "autogenerated": _("Autogenerated"), + "segment_details": _("Segment details"), +diff --git a/ipaserver/plugins/role.py b/ipaserver/plugins/role.py +index c63432087dafbf5f74316e5c2a2d45374959bf37..c3b322e1d3c8a3e16a837bc6e73665b008098c36 100644 +--- a/ipaserver/plugins/role.py ++++ b/ipaserver/plugins/role.py +@@ -89,7 +89,7 @@ class role(LDAPObject): + + attribute_members = { + 'member': ['user', 'group', 'host', 'hostgroup', 'service', +- 'idoverrideuser'], ++ 'idoverrideuser', 'sysaccount'], + 'memberof': ['privilege'], + } + reverse_members = { +diff --git a/ipaserver/plugins/sysaccounts.py b/ipaserver/plugins/sysaccounts.py +new file mode 100644 +index 0000000000000000000000000000000000000000..e2dd820efc2e83ca79f8a2badbca2013b1d40c5d +--- /dev/null ++++ b/ipaserver/plugins/sysaccounts.py +@@ -0,0 +1,456 @@ ++ ++# Copyright (C) 2025 Red Hat ++# see file 'COPYING' for use and warranty information ++ ++from ipalib import api, errors ++from ipalib import Str, Bool, Password, Flag ++from ipalib.plugable import Registry ++from ipalib.request import context ++from .baseldap import ( ++ pkey_to_value, ++ LDAPObject, ++ LDAPCreate, ++ LDAPDelete, ++ LDAPUpdate, ++ LDAPSearch, ++ LDAPRetrieve, ++ LDAPQuery) ++from ipalib import _, ngettext ++from ipalib import constants ++from ipalib import output ++from ipalib.messages import ServerSysacctMgrUpdateRequired, SystemAccountUsage ++from ipapython.ipautil import ipa_generate_password, TMP_PWD_ENTROPY_BITS ++from ipapython.dn import DN ++ ++__doc__ = _(""" ++System accounts ++ ++System accounts designed to allow applications to query LDAP database. ++Unlike IPA users, system accounts have no POSIX properties and cannot be ++resolved as 'users' in a POSIX environment. ++ ++System accounts are stored in cn=sysaccounts,cn=etc LDAP subtree. Some of ++system accounts are special to IPA's own operations and cannot be removed. ++ ++EXAMPLES: ++ ++ Add a new system account, set random password: ++ ipa sysaccount-add my-app --random ++ ++ Allow the system account to change user passwords without triggering a reset: ++ ipa sysaccount-mod my-app --privileged=True ++ ++The system account still needs to be permitted to modify user passwords through ++a role that includes a corresponding permission ('System: Change User ++password'), through the privilege system: ++ ipa privilege-add 'my-app password change privilege' ++ ipa privilege-add-permission 'my-app password change privilege' \ ++ --permission 'System: Change User password' ++ ipa role-add 'my-app role' ++ ipa role-add-privilege 'my-app role' \ ++ --privilege 'my-app password change privilege' ++ ipa role-add-member 'my-app role' --sysaccounts my-app ++ ++ Delete a system account: ++ ipa sysaccount-del my-app ++ ++ Find all system accounts: ++ ipa sysaccount-find ++ ++ Disable the system account: ++ ipa sysaccount-disable my-app ++ ++ Re-enable the system account: ++ ipa sysaccount-enable my-app ++ ++ Allow the system account to change user passwords without a reset: ++ ipa sysaccount-policy my-app --privileged=true ++ ++""") ++ ++register = Registry() ++ ++required_system_accounts = [ ++ 'passsync', ++ 'sudo', ++] ++ ++sysaccount_mgrs_dn = DN('cn=ipa_pwd_extop,cn=plugins,cn=config') ++attr_sysacctmgrdns = 'sysacctmanagersdns' ++ ++update_without_reset = ( ++ Bool( ++ 'privileged?', ++ label=_('Privileged'), ++ doc=_('Allow password updates without reset'), ++ ), ++) ++ ++ ++def check_userpassword(entry_attrs, **options): ++ if 'userpassword' not in entry_attrs and options.get('random'): ++ entry_attrs['userpassword'] = ipa_generate_password( ++ entropy_bits=TMP_PWD_ENTROPY_BITS) ++ # save the password so it can be displayed in post_callback ++ setattr(context, 'randompassword', entry_attrs['userpassword']) ++ ++ ++def fill_randompassword(entry_attrs, **options): ++ if options.get('random', False): ++ try: ++ entry_attrs['randompassword'] = getattr(context, ++ 'randompassword') ++ except AttributeError: ++ # if both randompassword and userpassword options were used ++ pass ++ ++ ++@register() ++class sysaccount(LDAPObject): ++ """ ++ System account object. ++ """ ++ container_dn = api.env.container_sysaccounts ++ object_name = _('system account') ++ object_name_plural = _('system accounts') ++ object_class = [ ++ 'account', 'simplesecurityobject' ++ ] ++ possible_objectclasses = ['nsmemberof'] ++ permission_filter_objectclasses = ['simplesecurityobject'] ++ search_attributes = ['uid', 'description'] ++ default_attributes = [ ++ 'uid', 'description', 'memberof', 'nsaccountlock'] ++ uuid_attribute = '' ++ attribute_members = { ++ 'memberof': ['role'], ++ } ++ password_attributes = [('userpassword', 'has_password')] ++ bindable = True ++ relationships = { ++ 'managedby': ('Managed by', 'man_by_', 'not_man_by_'), ++ } ++ password_attributes = [('userpassword', 'has_password')] ++ managed_permissions = { ++ 'System: Read System Accounts': { ++ 'ipapermbindruletype': 'all', ++ 'ipapermright': {'read', 'search', 'compare'}, ++ 'ipapermdefaultattr': { ++ 'objectclass', ++ 'uid', 'memberof' ++ }, ++ }, ++ 'System: Check System Accounts passwords': { ++ 'ipapermright': {'search'}, ++ 'ipapermdefaultattr': {'userpassword'}, ++ 'default_privileges': {'System Accounts Administrators'}, ++ }, ++ 'System: Add System Accounts': { ++ 'ipapermright': {'add'}, ++ 'default_privileges': {'System Accounts Administrators'}, ++ }, ++ 'System: Modify System Accounts': { ++ 'ipapermright': {'write'}, ++ 'ipapermdefaultattr': {'userpassword'}, ++ 'default_privileges': {'System Accounts Administrators'}, ++ }, ++ 'System: Remove System Accounts': { ++ 'ipapermright': {'delete'}, ++ 'default_privileges': {'System Accounts Administrators'}, ++ }, ++ } ++ ++ label = _('System Accounts') ++ label_singular = _('System Account') ++ ++ takes_params = ( ++ Str('uid', ++ pattern=constants.PATTERN_GROUPUSER_NAME, ++ pattern_errmsg=constants.ERRMSG_GROUPUSER_NAME.format('user'), ++ maxlength=255, ++ cli_name='login', ++ label=_('System account ID'), ++ primary_key=True, ++ normalizer=lambda value: value.lower()), ++ Str('description?', ++ cli_name='desc', ++ doc=_('A description of system account'), ++ label=_('Description')), ++ Password('userpassword?', ++ cli_name='password', ++ label=_('Password'), ++ doc=_('Prompt to set the user password'), ++ exclude='webui', ++ flags=('no_search',)), ++ Flag('random?', ++ doc=_('Generate a random user password'), ++ flags=('no_search', 'virtual_attribute'), ++ default=False), ++ Str('randompassword?', ++ label=_('Random password'), ++ flags=('no_create', 'no_update', 'no_search', 'virtual_attribute')), ++ Bool('nsaccountlock?', ++ cli_name=('disabled'), ++ default=False, ++ label=_('Account disabled')), ++ ) ++ ++ def get_dn(self, *keys, **kwargs): ++ key = keys[0] ++ ++ parent_dn = DN(self.container_dn, self.api.env.basedn) ++ true_rdn = 'uid' ++ ++ return self.backend.make_dn_from_attr( ++ true_rdn, key, parent_dn ++ ) ++ ++ def get_password_attributes(self, ldap, dn, entry_attrs): ++ """ ++ Search on the entry to determine if it has a password or ++ keytab set. ++ """ ++ # Limit objectclass to simpleSecurityObject ++ obj_filter = self.api.Object.permission.make_type_filter(self) ++ for (pwattr, attr) in self.password_attributes: ++ search_filter = '(&(%s=*)%s)' % (pwattr, obj_filter) ++ try: ++ ldap.find_entries( ++ search_filter, [pwattr], dn, ldap.SCOPE_BASE ++ ) ++ entry_attrs[attr] = True ++ except errors.NotFound: ++ entry_attrs[attr] = False ++ ++ def handle_reset(self, cmd, next_cmd, ldap, dn, entry_attrs, **options): ++ privileged = None ++ exc = None ++ if 'privileged' in options: ++ # TODO: change the code to perform DBUS oddjob operation instead ++ # because cn=config changes require cn=Directory Manager permissions ++ # and then 389-ds needs a restart ++ add_to_passsync_mgrs = options.get('privileged', False) ++ try: ++ if add_to_passsync_mgrs: ++ ldap.add_entry_to_group( ++ dn, sysaccount_mgrs_dn, ++ attr_sysacctmgrdns) ++ privileged = True ++ else: ++ ldap.remove_entry_from_group( ++ dn, sysaccount_mgrs_dn, ++ attr_sysacctmgrdns) ++ privileged = False ++ if next_cmd: ++ command_name = next_cmd.name.replace('_','-') ++ cmd.add_message(ServerSysacctMgrUpdateRequired( ++ server=cmd.api.env.server, ++ command=command_name)) ++ except (errors.EmptyModlist, ++ errors.NotGroupMember, ++ errors.AlreadyGroupMember) as e: ++ exc = e ++ if entry_attrs is not None: ++ if privileged is None: ++ privileged = False ++ # Retrieve the sysacctmanagersdns and see if the DN is there ++ try: ++ entry = ldap.get_entry( ++ sysaccount_mgrs_dn, ++ [attr_sysacctmgrdns] ++ ) ++ managers = entry.get(attr_sysacctmgrdns, []) ++ if str(dn) in managers: ++ privileged = True ++ except errors.NotFound: ++ pass ++ entry_attrs['privileged'] = privileged ++ if exc is not None: ++ raise exc ++ ++ ++@register() ++class sysaccount_add(LDAPCreate): ++ __doc__ = _('Add a new IPA system account.') ++ msg_summary = _('Added system account "%(value)s"') ++ ++ takes_options = LDAPCreate.takes_options + update_without_reset ++ has_output_params = LDAPCreate.has_output_params + update_without_reset ++ ++ def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options): ++ assert isinstance(dn, DN) ++ if 'userpassword' not in entry_attrs and 'random' not in options: ++ raise errors.ValidationError( ++ name='password', ++ error=_('Either --password or --random is required') ++ ) ++ check_userpassword(entry_attrs, **options) ++ return dn ++ ++ def post_callback(self, ldap, dn, entry_attrs, *keys, **options): ++ assert isinstance(dn, DN) ++ fill_randompassword(entry_attrs, **options) ++ try: ++ self.obj.handle_reset(self, self.api.Command.sysaccount_policy, ++ ldap, dn, entry_attrs, **options) ++ except errors.NotGroupMember: ++ pass ++ self.add_message(SystemAccountUsage(uid=keys[0], dn=dn)) ++ return dn ++ ++ ++@register() ++class sysaccount_del(LDAPDelete): ++ __doc__ = _('Delete an IPA system account.') ++ msg_summary = _('Deleted system account "%(value)s"') ++ ++ def pre_callback(self, ldap, dn, *keys, **options): ++ assert isinstance(dn, DN) ++ ++ sysaccount = keys[-1] ++ if sysaccount.lower() in required_system_accounts: ++ raise errors.ValidationError( ++ name='system account', ++ error=_('{} is required by the IPA master').format(sysaccount) ++ ) ++ ++ # Make sure to remove the sysaccount entry from passsync_mgrs_dn ++ # don't error out if access is denied ++ try: ++ options['privileged'] = False ++ self.obj.handle_reset(self, None, ++ ldap, dn, None, **options) ++ except (errors.ACIError, errors.NotGroupMember): ++ pass ++ ++ return dn ++ ++ ++@register() ++class sysaccount_mod(LDAPUpdate): ++ __doc__ = _('Modify an existing IPA system account.') ++ ++ takes_options = LDAPUpdate.takes_options + update_without_reset ++ has_output_params = LDAPUpdate.has_output_params + update_without_reset ++ allow_empty_update = True ++ ++ msg_summary = _('Modified service "%(value)s"') ++ ++ def pre_callback(self, ldap, dn, entry_attrs, attrs_list, *keys, **options): ++ assert isinstance(dn, DN) ++ check_userpassword(entry_attrs, **options) ++ try: ++ self.obj.handle_reset(self, self.api.Command.sysaccount_policy, ++ ldap, dn, entry_attrs, **options) ++ except (errors.EmptyModlist, ++ errors.NotGroupMember, ++ errors.AlreadyGroupMember): ++ self.allow_empty_update = False ++ ++ setattr(context, 'privileged', entry_attrs['privileged']) ++ del entry_attrs['privileged'] ++ ++ if 'privileged' not in options: ++ self.allow_empty_update = False ++ ++ return dn ++ ++ def post_callback(self, ldap, dn, entry_attrs, *keys, **options): ++ assert isinstance(dn, DN) ++ fill_randompassword(entry_attrs, **options) ++ entry_attrs['privileged'] = getattr(context, 'privileged') ++ return dn ++ ++ ++@register() ++class sysaccount_find(LDAPSearch): ++ __doc__ = _('Search for IPA system accounts.') ++ ++ msg_summary = ngettext( ++ '%(count)d system account matched', ++ '%(count)d system accounts matched', 0 ++ ) ++ sort_result_entries = False ++ ++ takes_options = LDAPSearch.takes_options ++ has_output_params = LDAPSearch.has_output_params + update_without_reset ++ ++ def post_callback(self, ldap, entries, truncated, *args, **options): ++ if options.get('pkey_only', False): ++ return truncated ++ for entry_attrs in entries: ++ self.obj.get_password_attributes(ldap, entry_attrs.dn, entry_attrs) ++ self.obj.handle_reset(self, self, ++ ldap, entry_attrs.dn, entry_attrs, **options) ++ ++ return truncated ++ ++ ++@register() ++class sysaccount_show(LDAPRetrieve): ++ __doc__ = _('Display information about an IPA system account.') ++ ++ member_attributes = ['memberof'] ++ has_output_params = LDAPRetrieve.has_output_params + update_without_reset ++ ++ def post_callback(self, ldap, dn, entry_attrs, *keys, **options): ++ assert isinstance(dn, DN) ++ self.obj.get_password_attributes(ldap, dn, entry_attrs) ++ self.obj.handle_reset(self, self, ++ ldap, dn, entry_attrs, **options) ++ ++ return dn ++ ++ ++@register() ++class sysaccount_policy(LDAPRetrieve): ++ __doc__ = _( ++ 'Manage the system account policy.' ++ ) ++ ++ takes_options = LDAPRetrieve.takes_options + update_without_reset ++ has_output_params = LDAPRetrieve.has_output_params + update_without_reset ++ ++ def post_callback(self, ldap, dn, entry_attrs, *keys, **options): ++ self.obj.handle_reset(self, self, ldap, dn, entry_attrs, **options) ++ return dn ++ ++ ++@register() ++class sysaccount_disable(LDAPQuery): ++ __doc__ = _('Disable a system account.') ++ ++ has_output = output.standard_value ++ msg_summary = _('Disabled system account "%(value)s"') ++ ++ def execute(self, *keys, **options): ++ ldap = self.obj.backend ++ ++ dn = self.obj.get_dn(*keys, **options) ++ ldap.deactivate_entry(dn) ++ ++ return dict( ++ result=True, ++ value=pkey_to_value(keys[0], options), ++ ) ++ ++ ++@register() ++class sysaccount_enable(LDAPQuery): ++ __doc__ = _('Enable a system account.') ++ ++ has_output = output.standard_value ++ has_output_params = LDAPQuery.has_output_params ++ msg_summary = _('Enabled system account "%(value)s"') ++ ++ def execute(self, *keys, **options): ++ ldap = self.obj.backend ++ ++ dn = self.obj.get_dn(*keys, **options) ++ ++ ldap.activate_entry(dn) ++ ++ return dict( ++ result=True, ++ value=pkey_to_value(keys[0], options), ++ ) +diff --git a/ipatests/test_xmlrpc/test_role_plugin.py b/ipatests/test_xmlrpc/test_role_plugin.py +index dd862c53fa4dee7b40a205d67ff9dc5b1a052b03..4e7386a3e13919069c8dfda27205e736510c31d5 100644 +--- a/ipatests/test_xmlrpc/test_role_plugin.py ++++ b/ipatests/test_xmlrpc/test_role_plugin.py +@@ -259,6 +259,7 @@ class test_role(Declarative): + hostgroup=[], + service=[], + idoverrideuser=[], ++ sysaccount=[], + ), + ), + result={ +@@ -517,6 +518,7 @@ class test_role(Declarative): + hostgroup=[], + service=[], + idoverrideuser=[], ++ sysaccount=[], + ), + ), + result={ +diff --git a/ipatests/test_xmlrpc/test_service_plugin.py b/ipatests/test_xmlrpc/test_service_plugin.py +index 4aeeb9d89971a56a2ccfccd616b15392f5f0e0ee..3606736832afefb5eb4916ca85a44408a44775b6 100644 +--- a/ipatests/test_xmlrpc/test_service_plugin.py ++++ b/ipatests/test_xmlrpc/test_service_plugin.py +@@ -1047,6 +1047,7 @@ class test_service_in_role(Declarative): + service=[], + user=[], + idoverrideuser=[], ++ sysaccount=[], + ), + ), + completed=1, +-- +2.51.1 + diff --git a/0136-sysaccounts-add-integration-test.patch b/0136-sysaccounts-add-integration-test.patch new file mode 100644 index 0000000..5df6038 --- /dev/null +++ b/0136-sysaccounts-add-integration-test.patch @@ -0,0 +1,462 @@ +From ce907c2d805632e7d1aeb46363e37efd81b6ad04 Mon Sep 17 00:00:00 2001 +From: Alexander Bokovoy +Date: Thu, 18 Sep 2025 19:31:17 +0300 +Subject: [PATCH] sysaccounts: add integration test + +The integration test was created with the help of claude.ai by +providing the following prompt: + + >> given the design document in doc/designs/sysaccounts.md, write an + >> integration test for sysaccounts module + +Fixes: https://pagure.io/freeipa/issue/9842 + +Assisted-by: Claude +Signed-off-by: Alexander Bokovoy +Signed-off-by: Florence Blanc-Renaud +Reviewed-By: Rafael Guterres Jeffman +Reviewed-By: Thomas Woerner +Reviewed-By: Florence Blanc-Renaud +--- + ipatests/test_integration/test_sysaccounts.py | 429 ++++++++++++++++++ + 1 files changed, 429 insertions(+) + create mode 100644 ipatests/test_integration/test_sysaccounts.py + +diff --git a/ipatests/test_integration/test_sysaccounts.py b/ipatests/test_integration/test_sysaccounts.py +new file mode 100644 +index 0000000000000000000000000000000000000000..e19b1ae18633b95b14ccd6f94f863f4224ebde64 +--- /dev/null ++++ b/ipatests/test_integration/test_sysaccounts.py +@@ -0,0 +1,429 @@ ++# Copyright (C) 2025 Red Hat ++# see file 'COPYING' for use and warranty information ++"""Tests for FreeIPA system accounts functionality""" ++ ++import time ++from ipatests.test_integration.base import IntegrationTest ++from ipatests.pytest_ipa.integration import tasks ++from ipapython.ipautil import ipa_generate_password ++ ++ ++def extract_password_from_result(result): ++ # Extract password from creation output ++ password_line = [ ++ line ++ for line in result.stdout_text.split("\n") ++ if "Random password:" in line ++ ][0] ++ return password_line.split("Random password:")[1].strip() ++ ++ ++class TestSystemAccounts(IntegrationTest): ++ """Integration tests for system accounts functionality""" ++ ++ topology = "line" ++ num_clients = 0 ++ num_replicas = 0 ++ ++ def test_system_account_lifecycle(self): ++ """Test basic system account lifecycle operations""" ++ sysaccount_name = "test-app" ++ tasks.kinit_admin(self.master) ++ ++ # Test creating a system account with random password ++ result = self.master.run_command( ++ ["ipa", "sysaccount-add", sysaccount_name, "--random"] ++ ) ++ assert ( ++ f'Added system account "{sysaccount_name}"' in result.stdout_text ++ ) ++ assert "Random password:" in result.stdout_text ++ ++ # Test finding system accounts ++ result = self.master.run_command(["ipa", "sysaccount-find"]) ++ assert sysaccount_name in result.stdout_text ++ ++ # Test showing system account details ++ result = self.master.run_command( ++ ["ipa", "sysaccount-show", sysaccount_name] ++ ) ++ assert f"System account ID: {sysaccount_name}" in result.stdout_text ++ ++ # Test disabling system account ++ result = self.master.run_command( ++ ["ipa", "sysaccount-disable", sysaccount_name] ++ ) ++ assert ( ++ f'Disabled system account "{sysaccount_name}"' ++ in result.stdout_text ++ ) ++ ++ # Test enabling system account ++ result = self.master.run_command( ++ ["ipa", "sysaccount-enable", sysaccount_name] ++ ) ++ assert ( ++ f'Enabled system account "{sysaccount_name}"' in result.stdout_text ++ ) ++ ++ # Clean up ++ self.master.run_command(["ipa", "sysaccount-del", sysaccount_name]) ++ ++ # Verify deletion ++ result = self.master.run_command( ++ ["ipa", "sysaccount-show", sysaccount_name], raiseonerr=False ++ ) ++ assert result.returncode != 0 ++ ++ def test_system_account_ldap_bind(self): ++ """Test LDAP bind functionality with system accounts""" ++ sysaccount_name = "ldap-test-app" ++ basedn = str(self.master.domain.basedn) ++ tasks.kinit_admin(self.master) ++ ++ # Create system account ++ result = self.master.run_command( ++ ["ipa", "sysaccount-add", sysaccount_name, "--random"] ++ ) ++ ++ # Extract password from creation output ++ password = extract_password_from_result(result) ++ ++ bind_dn = f"uid={sysaccount_name},cn=sysaccounts,cn=etc,{basedn}" ++ ++ # Test LDAP bind with the system account ++ ldap_uri = f"ldap://{self.master.hostname}" ++ result = self.master.run_command( ++ [ ++ "ldapsearch", ++ "-D", bind_dn, ++ "-w", password, ++ "-H", ldap_uri, ++ "-b", basedn, ++ "-s", "base", ++ "(objectclass=*)", ++ "dn", ++ ] ++ ) ++ assert result.returncode == 0 ++ assert basedn in result.stdout_text ++ ++ # Clean up ++ self.master.run_command(["ipa", "sysaccount-del", sysaccount_name]) ++ ++ def test_system_account_password_management_without_reset(self): ++ """Test system account password management without triggering reset""" ++ sysaccount_name = "password-mgmt-app" ++ test_user = "sysaccount-test-user" ++ dashed_domain = self.master.domain.realm.replace(".", "-") ++ basedn = str(self.master.domain.basedn) ++ tasks.kinit_admin(self.master) ++ ++ try: ++ # Create test user ++ self.master.run_command( ++ [ ++ "ipa", ++ "user-add", test_user, ++ "--first", "Test", ++ "--last", "User", ++ "--random", ++ ] ++ ) ++ ++ # Create system account ++ result = self.master.run_command( ++ ["ipa", "sysaccount-add", sysaccount_name, "--random"] ++ ) ++ ++ # Extract password from creation output ++ sysaccount_password = extract_password_from_result(result) ++ ++ # Create privilege for password changes ++ privilege_name = f"{sysaccount_name}-password-privilege" ++ self.master.run_command(["ipa", "privilege-add", privilege_name]) ++ ++ # Add permission to privilege ++ self.master.run_command( ++ [ ++ "ipa", ++ "privilege-add-permission", privilege_name, ++ "--permission", "System: Change User password", ++ ] ++ ) ++ ++ # Create role ++ role_name = f"{sysaccount_name}-role" ++ self.master.run_command(["ipa", "role-add", role_name]) ++ ++ # Add privilege to role ++ self.master.run_command( ++ [ ++ "ipa", ++ "role-add-privilege", role_name, ++ "--privilege", privilege_name, ++ ] ++ ) ++ ++ # Add system account to role ++ self.master.run_command( ++ [ ++ "ipa", ++ "role-add-member", role_name, ++ "--sysaccounts", sysaccount_name, ++ ] ++ ) ++ ++ # Enable privileged password changes for the system account ++ result = self.master.run_command( ++ [ ++ "ipa", ++ "sysaccount-policy", sysaccount_name, ++ "--privileged=true", ++ ] ++ ) ++ assert ( ++ "Restart the Directory Server services" in result.stderr_text ++ ) ++ ++ # Restart directory server to apply changes ++ self.master.run_command( ++ ["systemctl", "restart", f"dirsrv@{dashed_domain}"] ++ ) ++ ++ # Wait for the service to restart ++ time.sleep(15) ++ ++ # Set user password to a known value first ++ initial_password = ipa_generate_password() ++ self.master.run_command( ++ ["ipa", "user-mod", test_user, "--password"], ++ stdin_text=f"{initial_password}\n{initial_password}\n", ++ ) ++ ++ # Change user password using system account via ldappasswd ++ new_password = ipa_generate_password() ++ sysaccount_dn = ( ++ f"uid={sysaccount_name},cn=sysaccounts,cn=etc,{basedn}" ++ ) ++ user_dn = ( ++ f"uid={test_user},cn=users,cn=accounts,{basedn}" ++ ) ++ ++ result = self.master.run_command( ++ [ ++ "ldappasswd", ++ "-D", sysaccount_dn, ++ "-w", sysaccount_password, ++ "-a", initial_password, ++ "-s", new_password, ++ "-x", ++ "-ZZ", ++ "-H", ++ f"ldap://{self.master.hostname}", ++ user_dn, ++ ] ++ ) ++ assert result.returncode == 0 ++ ++ # Verify password was changed and no reset is required ++ user_details = self.master.run_command( ++ ["ipa", "user-show", test_user, "--all", "--raw"] ++ ) ++ ++ # The key test: ++ # verify that krbLastPwdChange != krbPasswordExpiration ++ # If they are equal, it means password reset is required ++ krb_last_pwd_change = None ++ krb_pwd_expiration = None ++ ++ for line in user_details.stdout_text.split("\n"): ++ if "krbLastPwdChange:" in line: ++ krb_last_pwd_change = line.split(":", 1)[1].strip() ++ elif "krbPasswordExpiration:" in line: ++ krb_pwd_expiration = line.split(":", 1)[1].strip() ++ ++ # If system account privileged password change worked, ++ # these values should be different ++ if krb_last_pwd_change and krb_pwd_expiration: ++ assert krb_last_pwd_change != krb_pwd_expiration, ( ++ "Password reset was not bypassed - " ++ "krbLastPwdChange equals krbPasswordExpiration" ++ ) ++ ++ # Test user can authenticate with new password ++ self.master.run_command(["kdestroy", "-A"]) ++ result = self.master.run_command( ++ ["kinit", test_user], stdin_text=f"{new_password}\n" ++ ) ++ assert result.returncode == 0 ++ ++ finally: ++ # Clean up ++ self.master.run_command(["kdestroy", "-A"]) ++ tasks.kinit_admin(self.master) ++ ++ # Remove system account policy ++ self.master.run_command( ++ [ ++ "ipa", ++ "sysaccount-policy", sysaccount_name, ++ "--privileged=false", ++ ], ++ raiseonerr=False, ++ ) ++ ++ # Clean up entities in reverse order ++ self.master.run_command( ++ ["ipa", "role-del", role_name], raiseonerr=False ++ ) ++ self.master.run_command( ++ ["ipa", "privilege-del", privilege_name], raiseonerr=False ++ ) ++ self.master.run_command( ++ ["ipa", "sysaccount-del", sysaccount_name], raiseonerr=False ++ ) ++ self.master.run_command( ++ ["ipa", "user-del", test_user], raiseonerr=False ++ ) ++ ++ # Restart directory server to apply changes ++ self.master.run_command( ++ ["systemctl", "restart", f"dirsrv@{dashed_domain}"] ++ ) ++ ++ # Wait for the service to restart ++ time.sleep(15) ++ ++ def test_system_account_role_membership(self): ++ """Test system account membership in roles""" ++ sysaccount_name = "role-test-app" ++ role_name = "test-sysaccount-role" ++ tasks.kinit_admin(self.master) ++ ++ try: ++ # Create system account ++ self.master.run_command( ++ ["ipa", "sysaccount-add", sysaccount_name, "--random"] ++ ) ++ ++ # Create a test role ++ self.master.run_command(["ipa", "role-add", role_name]) ++ ++ # Add system account to role ++ result = self.master.run_command( ++ [ ++ "ipa", ++ "role-add-member", role_name, ++ "--sysaccounts", sysaccount_name, ++ ] ++ ) ++ assert "Number of members added 1" in result.stdout_text ++ ++ # Verify membership ++ result = self.master.run_command(["ipa", "role-show", role_name]) ++ assert sysaccount_name in result.stdout_text ++ ++ # Verify from system account perspective ++ result = self.master.run_command( ++ ["ipa", "sysaccount-show", sysaccount_name] ++ ) ++ assert role_name in result.stdout_text ++ ++ # Remove system account from role ++ result = self.master.run_command( ++ [ ++ "ipa", ++ "role-remove-member", role_name, ++ "--sysaccounts", sysaccount_name, ++ ] ++ ) ++ assert "Number of members removed 1" in result.stdout_text ++ ++ finally: ++ # Clean up ++ self.master.run_command( ++ ["ipa", "role-del", role_name], raiseonerr=False ++ ) ++ self.master.run_command( ++ ["ipa", "sysaccount-del", sysaccount_name], raiseonerr=False ++ ) ++ ++ def test_required_system_accounts_protection(self): ++ """Test that required system accounts cannot be deleted""" ++ required_accounts = ["passsync", "sudo"] ++ tasks.kinit_admin(self.master) ++ ++ for account in required_accounts: ++ # Try to delete a required system account ++ result = self.master.run_command( ++ ["ipa", "sysaccount-del", account], raiseonerr=False ++ ) ++ assert result.returncode != 0 ++ assert "is required by the IPA master" in result.stderr_text ++ ++ def test_system_account_password_validation(self): ++ """Test system account password validation""" ++ sysaccount_name = "password-validation-test" ++ tasks.kinit_admin(self.master) ++ ++ # Test creation with explicit password ++ test_password = ipa_generate_password() ++ result = self.master.run_command( ++ ["ipa", "sysaccount-add", sysaccount_name, "--password"], ++ stdin_text=f"{test_password}\n{test_password}\n", ++ ) ++ assert ( ++ f'Added system account "{sysaccount_name}"' in result.stdout_text ++ ) ++ ++ # Clean up ++ self.master.run_command(["ipa", "sysaccount-del", sysaccount_name]) ++ ++ def test_system_account_implicit_password(self): ++ """Test system account password validation""" ++ sysaccount_name = "password-implicit-validation-test" ++ tasks.kinit_admin(self.master) ++ ++ # Test creation with implicitly asked password ++ test_password = ipa_generate_password() ++ result = self.master.run_command( ++ ["ipa", "sysaccount-add", sysaccount_name], ++ stdin_text=f"{test_password}\n{test_password}\n", ++ ) ++ assert ( ++ f'Added system account "{sysaccount_name}"' in result.stdout_text ++ ) ++ ++ # Clean up ++ self.master.run_command(["ipa", "sysaccount-del", sysaccount_name]) ++ ++ def test_system_account_modify_password(self): ++ """Test modifying system account password""" ++ sysaccount_name = "modify-password-test" ++ tasks.kinit_admin(self.master) ++ ++ try: ++ # Create system account ++ self.master.run_command( ++ ["ipa", "sysaccount-add", sysaccount_name, "--random"] ++ ) ++ ++ # Modify with new random password ++ result = self.master.run_command( ++ ["ipa", "sysaccount-mod", sysaccount_name, "--random"] ++ ) ++ assert "Random password:" in result.stdout_text ++ ++ # Modify with explicit password ++ new_password = ipa_generate_password() ++ self.master.run_command( ++ ["ipa", "sysaccount-mod", sysaccount_name, "--password"], ++ stdin_text=f"{new_password}\n{new_password}\n", ++ ) ++ ++ finally: ++ # Clean up ++ self.master.run_command( ++ ["ipa", "sysaccount-del", sysaccount_name], raiseonerr=False ++ ) +-- +2.51.1 + diff --git a/0137-Port-bash-sudo-tests.patch b/0137-Port-bash-sudo-tests.patch new file mode 100644 index 0000000..233bb35 --- /dev/null +++ b/0137-Port-bash-sudo-tests.patch @@ -0,0 +1,1331 @@ +From 2794351a8b6a69e81f685ae47ba86506c288af67 Mon Sep 17 00:00:00 2001 +From: PRANAV THUBE +Date: Fri, 5 Sep 2025 12:56:26 +0530 +Subject: [PATCH] Port bash sudo tests. + +Description: Porting the bash sudo testsuite to upstream (Including all +Negative & Positive test cases. Automated with Cursor+Claude + +Related: https://issues.redhat.com/browse/IDM-2875 +Signed-off-by: PRANAV THUBE +Reviewed-By: Florence Blanc-Renaud +Reviewed-By: Rob Crittenden +--- + ipatests/test_integration/test_sudo.py | 1293 +++++++++++++++++++++++- + 1 file changed, 1290 insertions(+), 3 deletions(-) + +diff --git a/ipatests/test_integration/test_sudo.py b/ipatests/test_integration/test_sudo.py +index c873950fa28a8c15f95c334ee185e3fa20b5d166..ce4aafd12961c8e66373e0d6c6200799c0642302 100644 +--- a/ipatests/test_integration/test_sudo.py ++++ b/ipatests/test_integration/test_sudo.py +@@ -18,13 +18,14 @@ + # along with this program. If not, see . + + import pytest +- ++import re + from ipaplatform.paths import paths +- + from ipatests.test_integration.base import IntegrationTest + from ipatests.pytest_ipa.integration.tasks import ( + clear_sssd_cache, get_host_ip_with_hostmask, remote_sssd_config, +- FileBackup) ++ FileBackup, install_master, install_client, kinit_admin, ++ create_active_user, ldapsearch_dm, stop_ipa_server, ++ start_ipa_server, kinit_as_user) + + class TestSudo(IntegrationTest): + """ +@@ -756,3 +757,1289 @@ class TestSudo(IntegrationTest): + finally: + self.master.run_command( + ['ipa', 'config-mod', '--domain-resolution-order=']) ++ ++ ++class TestSudo_Functional(IntegrationTest): ++ """ ++ Test Sudo Functional ++ """ ++ num_clients = 1 ++ ++ # Define constants for reuse ++ SUDO_RULE = "sudorule1" ++ USER_1 = "testuser1" ++ USER_2 = "testuser2" ++ USER_3 = "testuser3" ++ SUDO_GROUP = "sudogrp1" ++ HOST_GROUP = "hostgrp1" ++ GROUP = "testgroup" ++ ++ @classmethod ++ def install(cls, mh): ++ super(TestSudo_Functional, cls).install(mh) ++ ++ extra_args = ["--idstart=60001", "--idmax=65000"] ++ install_master(cls.master, setup_dns=True, extra_args=extra_args) ++ install_client(cls.master, cls.clients[0]) ++ ++ cls.client = cls.clients[0] ++ cls.user_password = "Secret123!" ++ ++ for username in [cls.USER_1, cls.USER_2, cls.USER_3]: ++ create_active_user( ++ cls.master, ++ username, ++ password=cls.user_password ++ ) ++ ++ def list_sudo_commands( ++ self, user, raiseonerr=False, verbose=False, ++ skip_kinit=False, skip_sssd_cache_clear=False, ++ skip_password=False): ++ """ ++ List sudo commands for a given user. ++ ++ - If skip_password=False (default), the function uses the command ++ `echo | sudo -S -l`. ++ - If skip_password=True, it uses `sudo -l -n` (non-interactive, no ++ password). ++ - The verbose flag (-ll) can be used to get more detailed sudo output. ++ """ ++ list_flag = '-ll' if verbose else '-l' ++ ++ if not skip_sssd_cache_clear: ++ clear_sssd_cache(self.client) ++ if not skip_kinit: ++ kinit_as_user(self.client, user, self.user_password) ++ ++ if skip_password: ++ cmd = f'su -c "sudo {list_flag} -n" {user}' ++ else: ++ cmd = ( ++ f'su -c "echo {self.user_password} | ' ++ f'sudo {list_flag} -S" {user}' ++ ) ++ ++ return self.client.run_command(cmd, raiseonerr=raiseonerr) ++ ++ def run_as_sudo_user( ++ self, command, sudo_user, su_user, raiseonerr=False, ++ skip_kinit=False, skip_sssd_cache_clear=False, ++ skip_password=False): ++ """ ++ Run a command as 'sudo_user' through 'su' to 'su_user'. ++ ++ Parameters: ++ - command: str, the command to execute ++ - sudo_user: str, the user to run the command as via sudo (-u) ++ - su_user: str, the user to switch to via su ++ - skip_password: bool, if True, run sudo non-interactively (-n) ++ without providing password. If False, provide password via -S. ++ - raiseonerr: bool, whether to raise an exception on non-zero exit. ++ """ ++ if not skip_sssd_cache_clear: ++ clear_sssd_cache(self.client) ++ if not skip_kinit: ++ kinit_as_user(self.client, su_user, self.user_password) ++ ++ if skip_password: ++ cmd = f'su -c "sudo -u {sudo_user} -n {command}" {su_user}' ++ else: ++ cmd = ( ++ f'su -c "echo {self.user_password} | ' ++ f'sudo -u {sudo_user} -S {command}" {su_user}' ++ ) ++ ++ return self.client.run_command(cmd, raiseonerr=raiseonerr) ++ ++ def run_as_sudo_group( ++ self, command, sudo_group, su_user, raiseonerr=False, ++ skip_kinit=False, skip_sssd_cache_clear=False, ++ skip_password=False): ++ """ ++ Run a command as 'sudo_group' through 'su' to 'su_user'. ++ ++ Parameters: ++ - command: str, the command to execute ++ - sudo_group: str, the group to run the command as via sudo (-g) ++ - su_user: str, the user to switch to via su ++ - skip_password: bool, if True, run sudo non-interactively (-n) ++ without providing password. If False, provide password via -S. ++ - raiseonerr: bool, whether to raise an exception on non-zero exit. ++ ++ Returns: ++ - Result from self.client.run_command() ++ """ ++ if not skip_sssd_cache_clear: ++ clear_sssd_cache(self.client) ++ if not skip_kinit: ++ kinit_as_user(self.client, su_user, self.user_password) ++ ++ if skip_password: ++ cmd = f'su -c "sudo -g {sudo_group} -n {command}" {su_user}' ++ else: ++ cmd = ( ++ f'su -c "echo {self.user_password} | ' ++ f'sudo -g {sudo_group} -S {command}" {su_user}' ++ ) ++ ++ return self.client.run_command(cmd, raiseonerr=raiseonerr) ++ ++ def setup_sudo_rule( ++ self, master, client, user, rule_name, ++ group_name, allowed_command): ++ """ ++ Common helper to set up a sudo rule with the same flow as the test: ++ 1. Add sudo commands ++ 2. Create sudo command group and add members ++ 3. Create sudo rule ++ 4. Add !authenticate option ++ 5. Add host, user, and allowed command (variable) ++ """ ++ kinit_admin(master) ++ ++ cmds = [ ++ "/bin/mkdir", "/bin/date", "/bin/df", "/bin/touch", "/bin/rm", ++ "/bin/uname", "/bin/hostname", "/bin/rmdir" ++ ] ++ for cmd in cmds: ++ master.run_command(["ipa", "sudocmd-add", cmd]) ++ master.run_command([ ++ "ipa", "sudocmdgroup-add", group_name, "--desc=sudogrp1" ++ ]) ++ master.run_command([ ++ "ipa", "sudocmdgroup-add-member", group_name, ++ "--sudocmds=/bin/date", "--sudocmds=/bin/touch", ++ "--sudocmds=/bin/uname" ++ ]) ++ master.run_command(["ipa", "sudorule-add", rule_name]) ++ master.run_command([ ++ "ipa", "sudorule-add-host", rule_name, "--hosts", client ++ ]) ++ master.run_command([ ++ "ipa", "sudorule-add-user", rule_name, "--users", user ++ ]) ++ master.run_command([ ++ "ipa", "sudorule-add-allow-command", ++ f"--sudocmds={allowed_command}", rule_name ++ ]) ++ ++ def test_sudorule_add_allow_command_func001(self): ++ master = self.master ++ client = self.clients[0].hostname ++ kinit_admin(master) ++ ++ self.setup_sudo_rule( ++ master=master, ++ client=client, ++ user=self.USER_1, ++ rule_name=self.SUDO_RULE, ++ group_name=self.SUDO_GROUP, ++ allowed_command="/bin/mkdir" ++ ) ++ clear_sssd_cache(self.client) ++ result = self.list_sudo_commands(self.USER_1, verbose=True) ++ assert re.search(r'(? +Date: Wed, 5 Nov 2025 16:16:21 +0200 +Subject: [PATCH] sysaccount: make sure nsaccountlock is always present + +Commands sysaccount-enable/sysaccount-disable allow to lock/unlock the +account. This is exposed via operational attribute `nsAccountLock` +which has to be explicitly requested to query the state. However, if +account wasn't explicitly disabled before, `nsAccountLock` will not be +returned by the LDAP server. + +Ensure the nsaccountlock attribute is always retrieved, validated, and +normalized across sysaccount operations. + +- Invoke validate_nsaccountlock in create and modify pre-callbacks to + enforce valid values + +- Invoke convert_nsaccountlock in create, modify, list, reset, and + policy post-callbacks to normalize the nsaccountlock attribute and + default to False in case it is absent. + +Fixes: https://pagure.io/freeipa/issue/9842 + +Signed-off-by: Alexander Bokovoy +Reviewed-By: Rob Crittenden +Reviewed-By: Thomas Woerner +--- + ipaserver/plugins/sysaccounts.py | 9 +++++++++ + 1 file changed, 9 insertions(+) + +diff --git a/ipaserver/plugins/sysaccounts.py b/ipaserver/plugins/sysaccounts.py +index e2dd820efc2e83ca79f8a2badbca2013b1d40c5d..a67d8b2fb17a9bd1590f95bf661d8f1b0453c807 100644 +--- a/ipaserver/plugins/sysaccounts.py ++++ b/ipaserver/plugins/sysaccounts.py +@@ -15,6 +15,7 @@ from .baseldap import ( + LDAPSearch, + LDAPRetrieve, + LDAPQuery) ++from .baseuser import validate_nsaccountlock, convert_nsaccountlock + from ipalib import _, ngettext + from ipalib import constants + from ipalib import output +@@ -285,6 +286,7 @@ class sysaccount_add(LDAPCreate): + error=_('Either --password or --random is required') + ) + check_userpassword(entry_attrs, **options) ++ validate_nsaccountlock(entry_attrs) + return dn + + def post_callback(self, ldap, dn, entry_attrs, *keys, **options): +@@ -296,6 +298,7 @@ class sysaccount_add(LDAPCreate): + except errors.NotGroupMember: + pass + self.add_message(SystemAccountUsage(uid=keys[0], dn=dn)) ++ convert_nsaccountlock(entry_attrs) + return dn + + +@@ -353,12 +356,15 @@ class sysaccount_mod(LDAPUpdate): + if 'privileged' not in options: + self.allow_empty_update = False + ++ validate_nsaccountlock(entry_attrs) ++ + return dn + + def post_callback(self, ldap, dn, entry_attrs, *keys, **options): + assert isinstance(dn, DN) + fill_randompassword(entry_attrs, **options) + entry_attrs['privileged'] = getattr(context, 'privileged') ++ convert_nsaccountlock(entry_attrs) + return dn + + +@@ -382,6 +388,7 @@ class sysaccount_find(LDAPSearch): + self.obj.get_password_attributes(ldap, entry_attrs.dn, entry_attrs) + self.obj.handle_reset(self, self, + ldap, entry_attrs.dn, entry_attrs, **options) ++ convert_nsaccountlock(entry_attrs) + + return truncated + +@@ -398,6 +405,7 @@ class sysaccount_show(LDAPRetrieve): + self.obj.get_password_attributes(ldap, dn, entry_attrs) + self.obj.handle_reset(self, self, + ldap, dn, entry_attrs, **options) ++ convert_nsaccountlock(entry_attrs) + + return dn + +@@ -413,6 +421,7 @@ class sysaccount_policy(LDAPRetrieve): + + def post_callback(self, ldap, dn, entry_attrs, *keys, **options): + self.obj.handle_reset(self, self, ldap, dn, entry_attrs, **options) ++ convert_nsaccountlock(entry_attrs) + return dn + + +-- +2.51.1 + diff --git a/0139-test_sudo-do-not-clean-the-cache-for-offline-cache-t.patch b/0139-test_sudo-do-not-clean-the-cache-for-offline-cache-t.patch new file mode 100644 index 0000000..e9767f9 --- /dev/null +++ b/0139-test_sudo-do-not-clean-the-cache-for-offline-cache-t.patch @@ -0,0 +1,146 @@ +From 8e9e516299883e5f4f820c3a3c444513b896a36a Mon Sep 17 00:00:00 2001 +From: Florence Blanc-Renaud +Date: Mon, 10 Nov 2025 17:59:22 +0100 +Subject: [PATCH] test_sudo: do not clean the cache for offline cache tests + +The tests for offline caching should not clear SSSD cache +before shutting down the IPA server. Currently most of them +follow the same steps: +- create the rule +- clear the cache +- call run_as_sudo_user (which clears the cache) +- call list_sudo_commands (which clears the cache) +- stop the master +- call run_as_sudo_user with skip_sssd_cache_clear +- call list_sudo_commands with skip_sssd_cache_clear + +The scenario is wrong as skip_sssd_cache_clear should also be +added on the calls before the master is stopped. + +Fixes: https://pagure.io/freeipa/issue/9874 +Signed-off-by: Florence Blanc-Renaud +Reviewed-By: PRANAV THUBE +--- + ipatests/test_integration/test_sudo.py | 38 +++++++++++++++++--------- + 1 file changed, 25 insertions(+), 13 deletions(-) + +diff --git a/ipatests/test_integration/test_sudo.py b/ipatests/test_integration/test_sudo.py +index ce4aafd12961c8e66373e0d6c6200799c0642302..639f3e6b6fef0842eefb9fc5c5fda445682d5baa 100644 +--- a/ipatests/test_integration/test_sudo.py ++++ b/ipatests/test_integration/test_sudo.py +@@ -1343,13 +1343,15 @@ class TestSudo_Functional(IntegrationTest): + clear_sssd_cache(master) + clear_sssd_cache(self.client) + result = self.run_as_sudo_user( +- "date", sudo_user="root", su_user=self.USER_1) ++ "date", sudo_user="root", su_user=self.USER_1, ++ skip_sssd_cache_clear=True) + + sys_day = self.client.run_command( + ["date", "+%a"]).stdout_text.strip() + assert sys_day in result.stdout_text + +- result = self.list_sudo_commands(self.USER_1) ++ result = self.list_sudo_commands(self.USER_1, ++ skip_sssd_cache_clear=True) + assert "(root) /bin/date" in result.stdout_text + + stop_ipa_server(master) +@@ -1383,9 +1385,11 @@ class TestSudo_Functional(IntegrationTest): + clear_sssd_cache(self.client) + clear_sssd_cache(master) + result = self.run_as_sudo_user( +- "uname", sudo_user="root", su_user=self.USER_1) ++ "uname", sudo_user="root", su_user=self.USER_1, ++ skip_sssd_cache_clear=True) + +- result = self.list_sudo_commands(self.USER_1) ++ result = self.list_sudo_commands(self.USER_1, ++ skip_sssd_cache_clear=True) + assert "(root) !/bin/uname" in result.stdout_text + + stop_ipa_server(master) +@@ -1420,7 +1424,8 @@ class TestSudo_Functional(IntegrationTest): + clear_sssd_cache(master) + clear_sssd_cache(self.client) + result = self.run_as_sudo_user( +- "date", sudo_user=self.USER_2, su_user=self.USER_1) ++ "date", sudo_user=self.USER_2, su_user=self.USER_1, ++ skip_sssd_cache_clear=True) + + sys_day = self.client.run_command( + ["date", "+%a"]).stdout_text.strip() +@@ -1485,13 +1490,15 @@ class TestSudo_Functional(IntegrationTest): + ) + clear_sssd_cache(self.client) + result = self.run_as_sudo_user( +- "date", sudo_user="root", su_user=self.USER_1) ++ "date", sudo_user="root", su_user=self.USER_1, ++ skip_sssd_cache_clear=True) + + sys_day = self.client.run_command( + ["date", "+%a"]).stdout_text.strip() + assert sys_day in result.stdout_text + +- result = self.list_sudo_commands(self.USER_1) ++ result = self.list_sudo_commands(self.USER_1, ++ skip_sssd_cache_clear=True) + assert "(root) /bin/date" in result.stdout_text + + stop_ipa_server(master) +@@ -1554,13 +1561,15 @@ class TestSudo_Functional(IntegrationTest): + clear_sssd_cache(self.client) + + result = self.run_as_sudo_user( +- "date", sudo_user="root", su_user=self.USER_1) ++ "date", sudo_user="root", su_user=self.USER_1, ++ skip_sssd_cache_clear=True) + + sys_day = self.client.run_command( + ["date", "+%a"]).stdout_text.strip() + assert sys_day in result.stdout_text + +- result = self.list_sudo_commands(self.USER_1) ++ result = self.list_sudo_commands(self.USER_1, ++ skip_sssd_cache_clear=True) + assert "(root) /bin/date" in result.stdout_text + + stop_ipa_server(master) +@@ -1613,13 +1622,14 @@ class TestSudo_Functional(IntegrationTest): + clear_sssd_cache(self.client) + result = self.run_as_sudo_user( + "date", sudo_user="root", su_user=self.USER_1, +- skip_password=True) ++ skip_password=True, skip_sssd_cache_clear=True) + + sys_day = self.client.run_command( + ["date", "+%a"]).stdout_text.strip() + assert sys_day in result.stdout_text + +- result = self.list_sudo_commands(self.USER_1) ++ result = self.list_sudo_commands(self.USER_1, ++ skip_sssd_cache_clear=True) + assert "(root) NOPASSWD: /bin/date" in result.stdout_text + + stop_ipa_server(master) +@@ -1660,13 +1670,15 @@ class TestSudo_Functional(IntegrationTest): + master.run_command(["ipa", "sudorule-disable", self.SUDO_RULE]) + clear_sssd_cache(self.client) + result = self.run_as_sudo_user( +- "date", sudo_user="root", su_user=self.USER_1) ++ "date", sudo_user="root", su_user=self.USER_1, ++ skip_sssd_cache_clear=True) + + sys_day = self.client.run_command( + ["date", "+%a"]).stdout_text.strip() + assert sys_day not in result.stdout_text + +- result = self.list_sudo_commands(self.USER_1) ++ result = self.list_sudo_commands(self.USER_1, ++ skip_sssd_cache_clear=True) + assert "(root) /bin/date" not in result.stdout_text + + stop_ipa_server(master) +-- +2.51.1 + diff --git a/0140-sysaccounts-extend-permissions-to-include-descriptio.patch b/0140-sysaccounts-extend-permissions-to-include-descriptio.patch new file mode 100644 index 0000000..4592566 --- /dev/null +++ b/0140-sysaccounts-extend-permissions-to-include-descriptio.patch @@ -0,0 +1,62 @@ +From 5e87614ef408cfa89093bc2186242f1b99c0b251 Mon Sep 17 00:00:00 2001 +From: Alexander Bokovoy +Date: Wed, 12 Nov 2025 13:38:14 +0200 +Subject: [PATCH] sysaccounts: extend permissions to include description and + account lock + +Security Architect role was supposed to manage sysaccount objects. But +since description attribute is missing from the list of the managed +permissions, the role is unable to modify 'description' field. Same for +nsAccountLock which needs an explicit ACI. + +Fixes: https://pagure.io/freeipa/issue/9875 + +Signed-off-by: Alexander Bokovoy +Reviewed-By: Florence Blanc-Renaud +--- + ACI.txt | 4 ++-- + ipaserver/plugins/sysaccounts.py | 5 +++-- + 2 files changed, 5 insertions(+), 4 deletions(-) + +diff --git a/ACI.txt b/ACI.txt +index 8db1634bc25ff616f67cc4bc7ccfa385c2d77c53..b3c87bac9fa50ae153eee8d2a0271c2585a8bf75 100644 +--- a/ACI.txt ++++ b/ACI.txt +@@ -379,9 +379,9 @@ aci: (targetfilter = "(objectclass=simplesecurityobject)")(version 3.0;acl "perm + dn: cn=sysaccounts,cn=etc,dc=ipa,dc=example + aci: (targetattr = "userpassword")(targetfilter = "(objectclass=simplesecurityobject)")(version 3.0;acl "permission:System: Check System Accounts passwords";allow (search) groupdn = "ldap:///cn=System: Check System Accounts passwords,cn=permissions,cn=pbac,dc=ipa,dc=example";) + dn: cn=sysaccounts,cn=etc,dc=ipa,dc=example +-aci: (targetattr = "userpassword")(targetfilter = "(objectclass=simplesecurityobject)")(version 3.0;acl "permission:System: Modify System Accounts";allow (write) groupdn = "ldap:///cn=System: Modify System Accounts,cn=permissions,cn=pbac,dc=ipa,dc=example";) ++aci: (targetattr = "description || nsaccountlock || userpassword")(targetfilter = "(objectclass=simplesecurityobject)")(version 3.0;acl "permission:System: Modify System Accounts";allow (write) groupdn = "ldap:///cn=System: Modify System Accounts,cn=permissions,cn=pbac,dc=ipa,dc=example";) + dn: cn=sysaccounts,cn=etc,dc=ipa,dc=example +-aci: (targetattr = "createtimestamp || entryusn || memberof || modifytimestamp || objectclass || uid")(targetfilter = "(objectclass=simplesecurityobject)")(version 3.0;acl "permission:System: Read System Accounts";allow (compare,read,search) userdn = "ldap:///all";) ++aci: (targetattr = "createtimestamp || description || entryusn || memberof || modifytimestamp || nsaccountlock || objectclass || uid")(targetfilter = "(objectclass=simplesecurityobject)")(version 3.0;acl "permission:System: Read System Accounts";allow (compare,read,search) userdn = "ldap:///all";) + dn: cn=sysaccounts,cn=etc,dc=ipa,dc=example + aci: (targetfilter = "(objectclass=simplesecurityobject)")(version 3.0;acl "permission:System: Remove System Accounts";allow (delete) groupdn = "ldap:///cn=System: Remove System Accounts,cn=permissions,cn=pbac,dc=ipa,dc=example";) + dn: cn=topology,cn=ipa,cn=etc,dc=ipa,dc=example +diff --git a/ipaserver/plugins/sysaccounts.py b/ipaserver/plugins/sysaccounts.py +index a67d8b2fb17a9bd1590f95bf661d8f1b0453c807..ad579eeed4521d75a274a07ea70dcd6bf96d589b 100644 +--- a/ipaserver/plugins/sysaccounts.py ++++ b/ipaserver/plugins/sysaccounts.py +@@ -138,7 +138,7 @@ class sysaccount(LDAPObject): + 'ipapermright': {'read', 'search', 'compare'}, + 'ipapermdefaultattr': { + 'objectclass', +- 'uid', 'memberof' ++ 'uid', 'memberof', 'nsaccountlock', 'description' + }, + }, + 'System: Check System Accounts passwords': { +@@ -152,7 +152,8 @@ class sysaccount(LDAPObject): + }, + 'System: Modify System Accounts': { + 'ipapermright': {'write'}, +- 'ipapermdefaultattr': {'userpassword'}, ++ 'ipapermdefaultattr': {'userpassword', 'description', ++ 'nsaccountlock'}, + 'default_privileges': {'System Accounts Administrators'}, + }, + 'System: Remove System Accounts': { +-- +2.51.1 + diff --git a/0141-Correctly-recognize-OID-2.5.4.97-organizationIdentif.patch b/0141-Correctly-recognize-OID-2.5.4.97-organizationIdentif.patch new file mode 100644 index 0000000..e61f720 --- /dev/null +++ b/0141-Correctly-recognize-OID-2.5.4.97-organizationIdentif.patch @@ -0,0 +1,464 @@ +From 8905bbf7a9ad7aefc9ddad9ccdbfb050c8429863 Mon Sep 17 00:00:00 2001 +From: Aleksandr Sharov +Date: Mon, 20 Oct 2025 21:41:19 +0200 +Subject: [PATCH] Correctly recognize OID 2.5.4.97, organizationIdentifier as a + subject/issuer DN of the CA certificate + +OID 2.5.4.97 added to the ATTR_NAME_BY_OID list, and cainstance during +2-step installation is re-fetching CA certificate and re-parsing OIDs +based on the list. Example: + +cert issuer: +DN(cert issuer): CN=ROOT,O=Corp,organizationIdentifier=LLCCZ-123456789,C=CZ + +Fixes: https://pagure.io/freeipa/issue/9866 + +Signed-off-by: Aleksandr Sharov +Reviewed-By: Rob Crittenden +Reviewed-By: David Hanina +--- + ipapython/dn.py | 1 + + ipaserver/install/cainstance.py | 10 +- + ipatests/test_integration/test_external_ca.py | 372 ++++++++++++++++++ + 3 files changed, 381 insertions(+), 2 deletions(-) + +diff --git a/ipapython/dn.py b/ipapython/dn.py +index 8974b420cb2c2d823f8ea45ee05cfd1f0c1a3954..12870d59143e89ecb729aa059218a40c368efd64 100644 +--- a/ipapython/dn.py ++++ b/ipapython/dn.py +@@ -1462,4 +1462,5 @@ ATTR_NAME_BY_OID = { + cryptography.x509.ObjectIdentifier('2.5.4.9'): 'STREET', + cryptography.x509.ObjectIdentifier('2.5.4.17'): 'postalCode', + cryptography.x509.ObjectIdentifier('0.9.2342.19200300.100.1.1'): 'UID', ++ cryptography.x509.ObjectIdentifier('2.5.4.97'): 'organizationIdentifier', + } +diff --git a/ipaserver/install/cainstance.py b/ipaserver/install/cainstance.py +index b3cc0b262e8c7c770b521b25ffed2b1fe97b81b5..b2a0a76a402ca88d873d2e1dd423f637686b0fba 100644 +--- a/ipaserver/install/cainstance.py ++++ b/ipaserver/install/cainstance.py +@@ -2352,10 +2352,16 @@ def ensure_ipa_authority_entry(): + api.Backend.ra_lightweight_ca.override_port = 8443 + with api.Backend.ra_lightweight_ca as lwca: + data = lwca.read_ca('host-authority') ++ # Loading certificate to properly re-parse issuer and subject DNs in ++ # case CA doesn't recognize some of the OIDs. DN class will re-access ++ # the OIDs based on ATTR_NAME_BY_OID list. ++ cert_data = lwca.read_ca_cert('host-authority') ++ cert = x509.load_der_x509_certificate(cert_data) ++ + attrs = dict( + ipacaid=data['id'], +- ipacaissuerdn=data['issuerDN'], +- ipacasubjectdn=data['dn'], ++ ipacaissuerdn=DN(cert.issuer), ++ ipacasubjectdn=DN(cert.subject), + ) + api.Backend.ra_lightweight_ca.override_port = None + +diff --git a/ipatests/test_integration/test_external_ca.py b/ipatests/test_integration/test_external_ca.py +index aeb08aae725af080dcd7807857b94c23094c3155..a6a088537dbfbb389658f89f9b07fa5323a05ed8 100644 +--- a/ipatests/test_integration/test_external_ca.py ++++ b/ipatests/test_integration/test_external_ca.py +@@ -23,6 +23,8 @@ import time + + from cryptography import x509 + from cryptography.hazmat.backends import default_backend ++from cryptography.x509.oid import ObjectIdentifier, NameOID ++from cryptography.hazmat.primitives import hashes, serialization + + from ipatests.pytest_ipa.integration import tasks + from ipatests.test_integration.base import IntegrationTest +@@ -108,6 +110,142 @@ def check_ipaca_issuerDN(host, expected_dn): + assert "Issuer DN: {}".format(expected_dn) in result.stdout_text + + ++def create_external_ca_with_subject(subject_attrs): ++ """ ++ Create an external CA with custom subject attributes including non-standard ++ OIDs. ++ ++ :param subject_attrs: List of x509.NameAttribute objects to include in ++ subject ++ :return: Tuple of (ExternalCA object, root CA certificate as PEM bytes) ++ ++ Example: ++ subj_attrs = [ ++ x509.NameAttribute(NameOID.COMMON_NAME, 'My CA'), ++ x509.NameAttribute(NameOID.COUNTRY_NAME, 'US'), ++ x509.NameAttribute(ObjectIdentifier('2.5.4.97'), 'VATEU-123456789') ++ ] ++ external_ca, root_ca_pem = create_external_ca_with_subject(subj_attrs) ++ signed_cert = external_ca.sign_csr(csr_data) ++ """ ++ external_ca = ExternalCA() ++ external_ca.create_ca_key() ++ ++ # Create the custom subject ++ subject = x509.Name(subject_attrs) ++ external_ca.issuer = subject ++ ++ # Build the root CA certificate ++ builder = x509.CertificateBuilder() ++ builder = builder.subject_name(subject) ++ builder = builder.issuer_name(subject) # self-signed ++ builder = builder.public_key(external_ca.ca_public_key) ++ builder = builder.serial_number(x509.random_serial_number()) ++ builder = builder.not_valid_before(external_ca.now) ++ builder = builder.not_valid_after(external_ca.now + external_ca.delta) ++ ++ # Add required extensions for a CA certificate ++ builder = builder.add_extension( ++ x509.KeyUsage( ++ digital_signature=False, ++ content_commitment=False, ++ key_encipherment=False, ++ data_encipherment=False, ++ key_agreement=False, ++ key_cert_sign=True, ++ crl_sign=True, ++ encipher_only=False, ++ decipher_only=False, ++ ), ++ critical=True, ++ ) ++ ++ builder = builder.add_extension( ++ x509.BasicConstraints(ca=True, path_length=None), ++ critical=True, ++ ) ++ ++ builder = builder.add_extension( ++ x509.SubjectKeyIdentifier.from_public_key( ++ external_ca.ca_public_key ++ ), ++ critical=False, ++ ) ++ ++ builder = builder.add_extension( ++ x509.AuthorityKeyIdentifier.from_issuer_public_key( ++ external_ca.ca_public_key ++ ), ++ critical=False, ++ ) ++ ++ # Sign the certificate ++ root_ca_cert = builder.sign( ++ external_ca.ca_key, hashes.SHA256(), default_backend() ++ ) ++ root_ca_pem = root_ca_cert.public_bytes(serialization.Encoding.PEM) ++ ++ return external_ca, root_ca_pem ++ ++ ++def find_cert_in_chain(cert_chain, subject_attrs=None, issuer_attrs=None): ++ """ ++ Retrieves a certificate from a provided chain that matches specified ++ criteria. The search can be filtered using dictionaries of subject ++ attributes, issuer attributes, or a combination of both. ++ ++ :param cert_chain: List of certificates to search through ++ :param subject_attrs: Dict of OID -> expected value for subject attributes ++ :param issuer_attrs: Dict of OID -> expected value for issuer attributes ++ :return: The matching certificate or None if not found ++ ++ Example: ++ from cryptography.x509.oid import NameOID, ObjectIdentifier ++ org_id_oid = ObjectIdentifier("2.5.4.97") ++ ++ # Find IPA CA cert with specific subject and issuer ++ cert = find_cert_in_chain( ++ ca_chain, ++ subject_attrs={ ++ NameOID.COMMON_NAME: "Certificate Authority", ++ NameOID.ORGANIZATION_NAME: "EXAMPLE.TEST" ++ }, ++ issuer_attrs={ ++ org_id_oid: "VATEU-123456789" ++ } ++ ) ++ """ ++ for cert in cert_chain: ++ # Check subject attributes if provided ++ if subject_attrs: ++ subject_match = True ++ for oid, expected_value in subject_attrs.items(): ++ attrs = [attr for attr in cert.subject if attr.oid == oid] ++ if not any(attr.value == expected_value for attr in attrs): ++ # This cert doesn't match, move to next cert ++ subject_match = False ++ break ++ if not subject_match: ++ continue ++ ++ # Check issuer attributes if provided ++ if issuer_attrs: ++ issuer_match = True ++ for oid, expected_value in issuer_attrs.items(): ++ attrs = [attr for attr in cert.issuer if attr.oid == oid] ++ if not any(attr.value == expected_value for attr in attrs): ++ # This cert doesn't match, move to next cert ++ issuer_match = False ++ break ++ if not issuer_match: ++ continue ++ ++ # All specified attributes match, return this cert ++ return cert ++ ++ return None ++ ++ + def check_mscs_extension(ipa_csr, template): + csr = x509.load_pem_x509_csr(ipa_csr, default_backend()) + extensions = [ +@@ -243,6 +381,133 @@ class TestExternalCAConstraints(IntegrationTest): + self.master.run_command(['ipa', 'ping']) + + ++class TestExternalCAInstallWithOrgId(IntegrationTest): ++ """Test 2-step installation with external CA containing ++ organizationIdentifier. ++ ++ This test verifies that FreeIPA can successfully install with a 2-step ++ external CA process when the external CA certificate contains the ++ organizationIdentifier attribute (OID 2.5.4.97) in its issuer DN. ++ ++ This tests the fix for DN parsing in ensure_ipa_authority_entry in ++ cainstance.py where the issuer DN must be properly parsed against ++ ATTR_NAME_BY_OID to recognize all OIDs including organizationIdentifier. ++ """ ++ num_replicas = 0 ++ num_clients = 0 ++ ++ def test_external_ca_install_with_organization_identifier(self): ++ """Test 2-step installation with organizationIdentifier (OID 2.5.4.97) ++ ++ Verify that FreeIPA can successfully complete a 2-step installation ++ with an external CA that contains organizationIdentifier (OID 2.5.4.97) ++ in the issuer DN. The issuer DN should be properly parsed and stored ++ in LDAP during the ensure_ipa_authority_entry process. ++ """ ++ ++ # Test parameters ++ org_id_value = "VATEU-123456789" ++ org_id_oid = ObjectIdentifier("2.5.4.97") # organizationIdentifier OID ++ external_ca_cn = "External CA with OrgID" ++ ++ # Step 1 of ipa-server-install ++ result = install_server_external_ca_step1(self.master) ++ assert result.returncode == 0 ++ ++ # Get the CSR generated by step 1 ++ ipa_csr = self.master.get_file_contents(paths.ROOT_IPA_CSR) ++ ++ # Create an external CA with organizationIdentifier in the subject ++ subject_attrs = [ ++ x509.NameAttribute(NameOID.COMMON_NAME, external_ca_cn), ++ x509.NameAttribute(NameOID.COUNTRY_NAME, 'US'), ++ x509.NameAttribute(NameOID.ORGANIZATION_NAME, 'Test Organization'), ++ x509.NameAttribute(org_id_oid, org_id_value), ++ ] ++ external_ca, root_ca = create_external_ca_with_subject(subject_attrs) ++ ++ # Sign the IPA CSR with the external CA that has organizationIdentifier ++ ipa_ca = external_ca.sign_csr(ipa_csr) ++ ++ # Write certificates to files ++ root_ca_fname = os.path.join( ++ self.master.config.test_dir, ++ 'root_ca_with_orgid.crt' ++ ) ++ ipa_ca_fname = os.path.join( ++ self.master.config.test_dir, ++ 'ipa_ca_signed_with_orgid.crt' ++ ) ++ ++ # Transport certificates to master ++ self.master.put_file_contents(root_ca_fname, root_ca) ++ self.master.put_file_contents(ipa_ca_fname, ipa_ca) ++ ++ # Step 2 of ipa-server-install ++ # This should succeed despite organizationIdentifier in issuer DN ++ result = install_server_external_ca_step2( ++ self.master, ipa_ca_fname, root_ca_fname ++ ) ++ assert result.returncode == 0 ++ ++ # Make sure IPA server is working properly ++ tasks.kinit_admin(self.master) ++ result = self.master.run_command(['ipa', 'user-show', 'admin']) ++ assert 'User login: admin' in result.stdout_text ++ ++ # Verify IPA is functional ++ result = self.master.run_command(['ipa', 'ping']) ++ assert result.returncode == 0 ++ ++ # Verify the certificate chain contains the expected certificates ++ # Load all certificates from /etc/ipa/ca.crt (the CA chain) ++ ca_chain_content = self.master.get_file_contents(paths.IPA_CA_CRT) ++ ca_chain = ipa_x509.load_certificate_list(ca_chain_content) ++ ++ # 1. Find and verify the IPA CA certificate ++ # It should have subject O=REALM, CN=Certificate Authority ++ # and issuer with organizationIdentifier ++ ipa_ca_cert = find_cert_in_chain( ++ ca_chain, ++ subject_attrs={ ++ NameOID.COMMON_NAME: "Certificate Authority", ++ NameOID.ORGANIZATION_NAME: self.master.domain.realm ++ }, ++ issuer_attrs={ ++ org_id_oid: org_id_value, ++ NameOID.COMMON_NAME: external_ca_cn ++ } ++ ) ++ assert ipa_ca_cert is not None, \ ++ f"Did not find IPA CA certificate with subject " \ ++ f"O={self.master.domain.realm}, CN=Certificate Authority " \ ++ f"and issuer with organizationIdentifier={org_id_value}" ++ ++ # 2. Find and verify the external root CA certificate ++ # It should be self-signed with organizationIdentifier in subject ++ external_ca_cert = find_cert_in_chain( ++ ca_chain, ++ subject_attrs={ ++ NameOID.COMMON_NAME: external_ca_cn, ++ org_id_oid: org_id_value ++ }, ++ issuer_attrs={ ++ NameOID.COMMON_NAME: external_ca_cn, ++ org_id_oid: org_id_value ++ } ++ ) ++ assert external_ca_cert is not None, \ ++ f"Did not find external root CA certificate (CN={external_ca_cn})" \ ++ f" with organizationIdentifier={org_id_value} in subject" ++ ++ # 3. Verify the issuer DN is correctly stored in LDAP ++ # The issuer DN should contain organizationIdentifier ++ # Note: The order in the DN string representation matters ++ result = self.master.run_command(['ipa', 'ca-show', 'ipa']) ++ assert f"organizationIdentifier={org_id_value}" in result.stdout_text, \ ++ "organizationIdentifier not found in IPA CA issuer DN" ++ ++ + def verify_caentry(host, cert): + """ + Verify the content of cn=DOMAIN IPA CA,cn=certificates,cn=ipa,cn=etc,basedn +@@ -475,6 +740,113 @@ class TestExternalCAInstall(IntegrationTest): + self.master.run_command([paths.IPA_CACERT_MANAGE, 'install', + root_ca_fname]) + ++ def test_renew_external_ca_with_organization_identifier(self): ++ """Test CA renewal with organizationIdentifier (OID 2.5.4.97) ++ ++ Verify that FreeIPA can successfully renew CA with an external CA ++ that contains organizationIdentifier (OID 2.5.4.97) in the issuer DN. ++ The IPA CA will be signed by this external CA, and the issuer DN ++ will contain the organizationIdentifier attribute. ++ """ ++ ++ # Test parameters ++ org_id_value = "VATEU-123456789" ++ org_id_oid = ObjectIdentifier("2.5.4.97") # organizationIdentifier OID ++ external_ca_cn = "External CA with OrgID" ++ ++ # Initiate CA renewal with external CA ++ result = self.master.run_command([paths.IPA_CACERT_MANAGE, 'renew', ++ '--external-ca']) ++ assert result.returncode == 0 ++ ++ # Get the CSR generated by the renewal process ++ ipa_csr = self.master.get_file_contents(paths.IPA_CA_CSR) ++ ++ # Create an external CA with organizationIdentifier in the subject ++ subject_attrs = [ ++ x509.NameAttribute(NameOID.COMMON_NAME, external_ca_cn), ++ x509.NameAttribute(NameOID.COUNTRY_NAME, 'US'), ++ x509.NameAttribute(NameOID.ORGANIZATION_NAME, 'Test Organization'), ++ x509.NameAttribute(org_id_oid, org_id_value), ++ ] ++ external_ca, root_ca = create_external_ca_with_subject(subject_attrs) ++ ++ # Sign the IPA CSR with the external CA that has organizationIdentifier ++ ipa_ca = external_ca.sign_csr(ipa_csr) ++ ++ # Write certificates to files ++ root_ca_fname = os.path.join( ++ self.master.config.test_dir, ++ 'root_ca_with_orgid.crt' ++ ) ++ ipa_ca_fname = os.path.join( ++ self.master.config.test_dir, ++ 'ipa_ca_signed_with_orgid.crt' ++ ) ++ ++ # Transport certificates to master ++ self.master.put_file_contents(root_ca_fname, root_ca) ++ self.master.put_file_contents(ipa_ca_fname, ipa_ca) ++ ++ # Complete the renewal with the signed certificates ++ # This should succeed despite organizationIdentifier in issuer DN ++ result = self.master.run_command([ ++ paths.IPA_CACERT_MANAGE, 'renew', ++ '--external-cert-file', ipa_ca_fname, ++ '--external-cert-file', root_ca_fname ++ ]) ++ assert result.returncode == 0 ++ ++ # Verify the CA was properly installed ++ result = self.master.run_command([paths.IPA_CERTUPDATE]) ++ assert result.returncode == 0 ++ ++ # Verify IPA is still functional ++ tasks.kinit_admin(self.master) ++ result = self.master.run_command(['ipa', 'ping']) ++ assert result.returncode == 0 ++ ++ # Verify the certificate chain contains the expected certificates ++ # Load all certificates from /etc/ipa/ca.crt (the CA chain) ++ ca_chain_content = self.master.get_file_contents(paths.IPA_CA_CRT) ++ ca_chain = ipa_x509.load_certificate_list(ca_chain_content) ++ ++ # 1. Find and verify the IPA CA certificate ++ # It should have subject O=REALM, CN=Certificate Authority ++ # and issuer with organizationIdentifier ++ ipa_ca_cert = find_cert_in_chain( ++ ca_chain, ++ subject_attrs={ ++ NameOID.COMMON_NAME: "Certificate Authority", ++ NameOID.ORGANIZATION_NAME: self.master.domain.realm ++ }, ++ issuer_attrs={ ++ org_id_oid: org_id_value, ++ NameOID.COMMON_NAME: external_ca_cn ++ } ++ ) ++ assert ipa_ca_cert is not None, \ ++ f"Did not find IPA CA certificate with subject " \ ++ f"O={self.master.domain.realm}, CN=Certificate Authority " \ ++ f"and issuer with organizationIdentifier={org_id_value}" ++ ++ # 2. Find and verify the external root CA certificate ++ # It should be self-signed with organizationIdentifier in subject ++ external_ca_cert = find_cert_in_chain( ++ ca_chain, ++ subject_attrs={ ++ NameOID.COMMON_NAME: external_ca_cn, ++ org_id_oid: org_id_value ++ }, ++ issuer_attrs={ ++ NameOID.COMMON_NAME: external_ca_cn, ++ org_id_oid: org_id_value ++ } ++ ) ++ assert external_ca_cert is not None, \ ++ f"Did not find external root CA certificate (CN={external_ca_cn})"\ ++ f" with organizationIdentifier={org_id_value} in subject" ++ + + class TestMultipleExternalCA(IntegrationTest): + """Setup externally signed ca1 +-- +2.51.1 + diff --git a/freeipa.spec b/freeipa.spec index fea287f..3b88598 100644 --- a/freeipa.spec +++ b/freeipa.spec @@ -232,7 +232,7 @@ Name: %{package_name} Version: %{IPA_VERSION} -Release: 24%{?rc_version:.%rc_version}%{?dist} +Release: 25%{?rc_version:.%rc_version}%{?dist} Summary: The Identity, Policy and Audit system License: GPL-3.0-or-later @@ -373,6 +373,30 @@ Patch0114: 0114-ipasam-remove-definitions-which-included-from-ndr_dr.patch Patch0115: 0115-Enforce-uniqueness-across-krbprincipalname-and-krbca.patch Patch0116: 0116-ipa-kdb-enforce-PAC-presence-on-TGT-for-TGS-REQ.patch Patch0117: 0117-ipatests-extend-test-for-unique-krbcanonicalname.patch +Patch0118: 0118-Add-domain-option-to-ipa-client-automount-for-DNS-di.patch +Patch0119: 0119-ipatests-Update-ipatests-to-test-topology-with-multi.patch +Patch0120: 0120-ipatests-Add-comprehensive-tests-for-ipa-client-auto.patch +Patch0121: 0121-ipatests-remove-xfail-for-PKI-11.7.patch +Patch0122: 0122-ipatests-fix-test_otp.patch +Patch0123: 0123-ipatests-update-the-Let-s-Encrypt-cert-chain.patch +Patch0124: 0124-ipatests-fix-TestIPAMigratewithBackupRestore-setup.patch +Patch0125: 0125-Add-support-for-libpwpolicy-credit-to-password-polic.patch +Patch0126: 0126-ipatests-Refactor-and-port-trust-functional-HBAC-tes.patch +Patch0127: 0127-ipatests-skip-encrypted-dns-tests-on-fedora-41.patch +Patch0128: 0128-Extended-eDNS-testsuite-with-Relaxed-policy-testcase.patch +Patch0129: 0129-ipatest-make-test_cert-more-robust-to-replication-de.patch +Patch0130: 0130-ipatests-fix-test_certmonger_ipa_responder_jsonrpc.patch +Patch0131: 0131-test_cert-adapt-the-expect-error-message-to-PKI-11.7.patch +Patch0132: 0132-Include-the-HSM-token-name-when-creating-LWCAs.patch +Patch0133: 0133-ipatests-Refactor-and-port-trust-functional-SUDO-tes.patch +Patch0134: 0134-ipa-pwd-extop-add-SysAcctManagersDNs-support.patch +Patch0135: 0135-Add-system-accounts-sysaccounts.patch +Patch0136: 0136-sysaccounts-add-integration-test.patch +Patch0137: 0137-Port-bash-sudo-tests.patch +Patch0138: 0138-sysaccount-make-sure-nsaccountlock-is-always-present.patch +Patch0139: 0139-test_sudo-do-not-clean-the-cache-for-offline-cache-t.patch +Patch0140: 0140-sysaccounts-extend-permissions-to-include-descriptio.patch +Patch0141: 0141-Correctly-recognize-OID-2.5.4.97-organizationIdentif.patch Patch1001: 1001-Change-branding-to-IPA-and-Identity-Management.patch %endif %endif @@ -2026,6 +2050,16 @@ fi %endif %changelog +* Wed Nov 19 2025 Florence Blanc-Renaud - 4.12.2-25 +- Resolves: RHEL-128238 [RFE] Support storing LWCA private keys on an HSM [rhel-9] +- Resolves: RHEL-126515 RFE: Enable external password reset agents to use ipa_pwd_extop in RHEL IdM [rhel-9] +- Resolves: RHEL-73399 RFE: Update IdM password policy configurations to meet M-22-09 by restricting spaces and require number character class +- Resolves: RHEL-128241 ATTR_NAME_BY_OID is missing OID 2.5.4.97, organizationIdentifier [rhel-9] +- Resolves: RHEL-126514 [RFE] ipa-client-automount should have an option to include domain of the machine. [rhel-9] +- Resolves: RHEL-124171 Include latest fixes in python3-ipatests package +- Resolves: RHEL-120514 Include fixes in python3-ipatests [rhel-9.8] +- Resolves: RHEL-118609 test_cacert_manage fails due to expired Let's Encrypt R3 certificate + * Tue Sep 30 2025 Florence Blanc-Renaud - 4.12.2-24 - Resolves: RHEL-118448 CVE-2025-7493 ipa: Privilege escalation from host to domain admin in FreeIPA