[PATCH] Password Settings Object (PSO) support for Samba

Tim Beale timbeale at catalyst.net.nz
Thu May 10 05:30:47 UTC 2018


Hi,

I'm currently working on adding support to Samba for PSOs (Password
Settings Objects). This AD feature is also known as Fine-Grained
Password Policies (FGPP).

PSOs allow AD administrators to override the domain password policy
settings for specific users or groups of users. For example, forcing
certain users to have longer password lengths, or relaxing the
complexity constraints for other users, etc. PSOs can be applied to
groups or to individual users. When multiple PSOs apply to the same
user, essentially the PSO with the best precedence takes effect.

The gist of the changes to Samba are:
- users have a new msDS-ResultantPSO constructed attribute used to
determine what PSO applies to them (if any).
- where the code enforces password policy, we now need to check if a PSO
applies to the given user. If so, the PSO's password policy settings are
used. If not, the domain's password policy settings are used (i.e. the
current behaviour).
- new test cases been added to check both the msDS-ResultantPSO is
calculated correctly, and that the msDS-ResultantPSO is enforced
correctly (these pass against both Windows and Samba).
- the existing password_lockout tests have been extended to also cover PSOs
- samba-tool support has been added for managing PSOs.

Attached is a rough set of patches. I still need to tidy a few things
up, get some clean autobuilds, and investigate if there's any
performance impact. Patches also available via git here:
http://git.catalyst.net.nz/gw?p=samba.git;a=shortlog;h=refs/heads/tim-pso

Let me know if anyone has any feedback on the changes at this point.

Thanks,
Tim

-------------- next part --------------
From 3398bab967178693f0e920c9bdf60743020f4c98 Mon Sep 17 00:00:00 2001
From: Tim Beale <timbeale at catalyst.net.nz>
Date: Thu, 15 Mar 2018 12:44:30 +1300
Subject: [PATCH 01/23] tests: Move repeated code into a helper function

Several tests hang all the objects they create off a unique OU.
Having a common OU makes cleanup easier, and having a unique OU (i.e.
adding some randomness) helps protect against one-off test failures
(Replication between testenvs is happening in the background.
Occasionally, when a test finishes on one testenv and moves onto the
next testenv, that testenv may have received the replicated test
objects from the first testenv, but has not received their deletion
yet).

Rather than copy-n-pasting this code yet again, split it out into a
helper function.

Signed-off-by: Tim Beale <timbeale at catalyst.net.nz>
---
 python/samba/tests/__init__.py               | 13 +++++++++++++
 source4/torture/drs/python/getncchanges.py   |  9 +--------
 source4/torture/drs/python/link_conflicts.py |  9 +--------
 source4/torture/drs/python/repl_rodc.py      | 10 ++--------
 4 files changed, 17 insertions(+), 24 deletions(-)

diff --git a/python/samba/tests/__init__.py b/python/samba/tests/__init__.py
index bc8c185..61036b5 100644
--- a/python/samba/tests/__init__.py
+++ b/python/samba/tests/__init__.py
@@ -36,6 +36,7 @@ import re
 import samba.auth
 import samba.dcerpc.base
 from samba.compat import PY3, text_type
+from random import randint
 if not PY3:
     # Py2 only
     from samba.samdb import SamDB
@@ -475,3 +476,15 @@ def delete_force(samdb, dn, **kwargs):
     except ldb.LdbError as error:
         (num, errstr) = error.args
         assert num == ldb.ERR_NO_SUCH_OBJECT, "ldb.delete() failed: %s" % errstr
+
+def create_test_ou(samdb, name):
+    """Creates a unique OU for the test"""
+
+    # Add some randomness to the test OU. Replication between the testenvs is
+    # constantly happening in the background. Deletion of the last test's
+    # objects can be slow to replicate out. So the OU created by a previous
+    # testenv may still exist at the point that tests start on another testenv.
+    rand = randint(1, 10000000)
+    dn = "OU=%s%d,%s" %(name, rand, samdb.get_default_basedn())
+    samdb.add({ "dn": dn, "objectclass": "organizationalUnit"})
+    return dn
diff --git a/source4/torture/drs/python/getncchanges.py b/source4/torture/drs/python/getncchanges.py
index 1b619f1..d545fe0 100644
--- a/source4/torture/drs/python/getncchanges.py
+++ b/source4/torture/drs/python/getncchanges.py
@@ -46,15 +46,8 @@ class DrsReplicaSyncIntegrityTestCase(drs_base.DrsBaseTestCase):
         # the vampire_dc), so we point this test directly at that DC
         self.set_test_ldb_dc(self.ldb_dc2)
 
-        # add some randomness to the test OU. (Deletion of the last test's
-        # objects can be slow to replicate out. So the OU created by a previous
-        # testenv may still exist at this point).
-        rand = random.randint(1, 10000000)
+        self.ou = samba.tests.create_test_ou(self.test_ldb_dc, "getncchanges")
         self.base_dn = self.test_ldb_dc.get_default_basedn()
-        self.ou = "OU=getncchanges%d_test,%s" %(rand, self.base_dn)
-        self.test_ldb_dc.add({
-            "dn": self.ou,
-            "objectclass": "organizationalUnit"})
 
         self.default_conn = DcConnection(self, self.ldb_dc2, self.dnsname_dc2)
         self.set_dc_connection(self.default_conn)
diff --git a/source4/torture/drs/python/link_conflicts.py b/source4/torture/drs/python/link_conflicts.py
index 6522fb6..31ebc30 100644
--- a/source4/torture/drs/python/link_conflicts.py
+++ b/source4/torture/drs/python/link_conflicts.py
@@ -46,15 +46,8 @@ class DrsReplicaLinkConflictTestCase(drs_base.DrsBaseTestCase):
     def setUp(self):
         super(DrsReplicaLinkConflictTestCase, self).setUp()
 
-        # add some randomness to the test OU. (Deletion of the last test's
-        # objects can be slow to replicate out. So the OU created by a previous
-        # testenv may still exist at this point).
-        rand = random.randint(1, 10000000)
+        self.ou = samba.tests.create_test_ou(self.ldb_dc1, "test_link_conflict")
         self.base_dn = self.ldb_dc1.get_default_basedn()
-        self.ou = "OU=test_link_conflict%d,%s" %(rand, self.base_dn)
-        self.ldb_dc1.add({
-            "dn": self.ou,
-            "objectclass": "organizationalUnit"})
 
         (self.drs, self.drs_handle) = self._ds_bind(self.dnsname_dc1)
         (self.drs2, self.drs2_handle) = self._ds_bind(self.dnsname_dc2)
diff --git a/source4/torture/drs/python/repl_rodc.py b/source4/torture/drs/python/repl_rodc.py
index 89c42f0..1d84c99 100644
--- a/source4/torture/drs/python/repl_rodc.py
+++ b/source4/torture/drs/python/repl_rodc.py
@@ -92,17 +92,11 @@ class DrsRodcTestCase(drs_base.DrsBaseTestCase):
         super(DrsRodcTestCase, self).setUp()
         self.base_dn = self.ldb_dc1.get_default_basedn()
 
-        rand = random.randint(1, 10000000)
-
-        self.ou = "OU=test_drs_rodc%s,%s" % (rand, self.base_dn)
-        self.ldb_dc1.add({
-            "dn": self.ou,
-            "objectclass": "organizationalUnit"
-        })
+        self.ou = samba.tests.create_test_ou(self.ldb_dc1, "test_drs_rodc")
         self.allowed_group = "CN=Allowed RODC Password Replication Group,CN=Users,%s" % self.base_dn
 
         self.site = self.ldb_dc1.server_site_name()
-        self.rodc_name = "TESTRODCDRS%s" % rand
+        self.rodc_name = "TESTRODCDRS%s" % random.randint(1, 10000000)
         self.rodc_pass = "password12#"
         self.computer_dn = "CN=%s,OU=Domain Controllers,%s" % (self.rodc_name, self.base_dn)
 
-- 
2.7.4


From 48e058c418e75d8617199525f529f27fac7b2dc6 Mon Sep 17 00:00:00 2001
From: Tim Beale <timbeale at catalyst.net.nz>
Date: Fri, 16 Mar 2018 11:18:07 +1300
Subject: [PATCH 02/23] tests: Split out setUp code into separate function for
 reuse

Any test that wants to change a password has to set the dSHeuristics
and minPwdAge first in order for the password change to work. The code
that does this is duplicated in several tests. This patch splits it out
into a static method so that the code can be reused rather than
duplicated.

Signed-off-by: Tim Beale <timbeale at catalyst.net.nz>
---
 python/samba/tests/auth_log_pass_change.py         | 20 ++------
 python/samba/tests/password_hash.py                | 20 ++------
 python/samba/tests/password_test.py                | 60 ++++++++++++++++++++++
 source4/dsdb/tests/python/acl.py                   | 16 ++----
 source4/dsdb/tests/python/password_lockout_base.py | 23 ++-------
 source4/dsdb/tests/python/passwords.py             | 22 ++------
 source4/dsdb/tests/python/tombstone_reanimation.py | 16 ++----
 7 files changed, 83 insertions(+), 94 deletions(-)
 create mode 100644 python/samba/tests/password_test.py

diff --git a/python/samba/tests/auth_log_pass_change.py b/python/samba/tests/auth_log_pass_change.py
index 9782389..e326ee6 100644
--- a/python/samba/tests/auth_log_pass_change.py
+++ b/python/samba/tests/auth_log_pass_change.py
@@ -33,6 +33,7 @@ from samba import ntstatus
 import samba
 from subprocess import call
 from ldb import LdbError
+from samba.tests.password_test import PasswordCommon
 
 USER_NAME = "authlogtestuser"
 USER_PASS = samba.generate_random_password(32,32)
@@ -59,26 +60,11 @@ class AuthLogPassChangeTests(samba.tests.auth_log_base.AuthLogTestBase):
         # Gets back the configuration basedn
         configuration_dn = self.ldb.get_config_basedn().get_linearized()
 
-        # Get the old "dSHeuristics" if it was set
-        dsheuristics = self.ldb.get_dsheuristics()
+        # permit password changes during this test
+        PasswordCommon.allow_password_changes(self, self.ldb)
 
-        # Set the "dSHeuristics" to activate the correct "userPassword"
-        # behaviour
-        self.ldb.set_dsheuristics("000000001")
-
-        # Reset the "dSHeuristics" as they were before
-        self.addCleanup(self.ldb.set_dsheuristics, dsheuristics)
-
-        # Get the old "minPwdAge"
-        minPwdAge = self.ldb.get_minPwdAge()
-
-        # Set it temporarily to "0"
-        self.ldb.set_minPwdAge("0")
         self.base_dn = self.ldb.domain_dn()
 
-        # Reset the "minPwdAge" as it was before
-        self.addCleanup(self.ldb.set_minPwdAge, minPwdAge)
-
         # (Re)adds the test user USER_NAME with password USER_PASS
         delete_force(self.ldb, "cn=" + USER_NAME + ",cn=users," + self.base_dn)
         self.ldb.add({
diff --git a/python/samba/tests/password_hash.py b/python/samba/tests/password_hash.py
index a3a74aa..0a295d5 100644
--- a/python/samba/tests/password_hash.py
+++ b/python/samba/tests/password_hash.py
@@ -29,6 +29,7 @@ from samba.dcerpc import drsblobs
 from samba.dcerpc.samr import DOMAIN_PASSWORD_STORE_CLEARTEXT
 from samba.dsdb import UF_ENCRYPTED_TEXT_PASSWORD_ALLOWED
 from samba.tests import delete_force
+from samba.tests.password_test import PasswordCommon
 import ldb
 import samba
 import binascii
@@ -101,26 +102,11 @@ class PassWordHashTests(TestCase):
         # Gets back the configuration basedn
         configuration_dn = self.ldb.get_config_basedn().get_linearized()
 
-        # Get the old "dSHeuristics" if it was set
-        dsheuristics = self.ldb.get_dsheuristics()
+        # permit password changes during this test
+        PasswordCommon.allow_password_changes(self, self.ldb)
 
-        # Set the "dSHeuristics" to activate the correct "userPassword"
-        # behaviour
-        self.ldb.set_dsheuristics("000000001")
-
-        # Reset the "dSHeuristics" as they were before
-        self.addCleanup(self.ldb.set_dsheuristics, dsheuristics)
-
-        # Get the old "minPwdAge"
-        minPwdAge = self.ldb.get_minPwdAge()
-
-        # Set it temporarily to "0"
-        self.ldb.set_minPwdAge("0")
         self.base_dn = self.ldb.domain_dn()
 
-        # Reset the "minPwdAge" as it was before
-        self.addCleanup(self.ldb.set_minPwdAge, minPwdAge)
-
         account_control = 0
         if clear_text:
             # get the current pwdProperties
diff --git a/python/samba/tests/password_test.py b/python/samba/tests/password_test.py
new file mode 100644
index 0000000..2cfd8da
--- /dev/null
+++ b/python/samba/tests/password_test.py
@@ -0,0 +1,60 @@
+# -*- coding: utf-8 -*-
+#
+# Common functionality for all password change tests
+#
+# Copyright (C) Catalyst.Net Ltd. 2018
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+#
+
+import samba.tests
+from samba.samdb import SamDB
+
+class PasswordCommon:
+
+    @staticmethod
+    def allow_password_changes(testcase, samdb):
+        """Updates the DC to allow password changes during the current test"""
+
+        # Get the old "dSHeuristics" if it was set
+        dsheuristics = samdb.get_dsheuristics()
+
+        # Reset the "dSHeuristics" as they were before
+        testcase.addCleanup(samdb.set_dsheuristics, dsheuristics)
+
+        # Set the "dSHeuristics" to activate the correct "userPassword" behaviour
+        samdb.set_dsheuristics("000000001")
+
+        # Get the old "minPwdAge"
+        minPwdAge = samdb.get_minPwdAge()
+
+        # Reset the "minPwdAge" as it was before
+        testcase.addCleanup(samdb.set_minPwdAge, minPwdAge)
+
+        # Set it temporarily to "0"
+        samdb.set_minPwdAge("0")
+
+
+class PasswordTestCase(samba.tests.TestCase):
+
+    # this requires that an LDB connection has already been setup (so is not
+    # part of the inherited setUp())
+    def allow_password_changes(self, samdb=None):
+        """Updates the DC to allow password changes during the current test"""
+
+        if samdb is None:
+            samdb = self.ldb
+
+        PasswordCommon.allow_password_changes(self, samdb)
+
diff --git a/source4/dsdb/tests/python/acl.py b/source4/dsdb/tests/python/acl.py
index 357e19b..25588f6 100755
--- a/source4/dsdb/tests/python/acl.py
+++ b/source4/dsdb/tests/python/acl.py
@@ -31,6 +31,7 @@ from samba.credentials import Credentials, DONT_USE_KERBEROS
 import samba.tests
 from samba.tests import delete_force
 import samba.dsdb
+from samba.tests.password_test import PasswordCommon
 
 parser = optparse.OptionParser("acl.py [options] <host>")
 sambaopts = options.SambaOptions(parser)
@@ -650,18 +651,9 @@ class AclSearchTests(AclTests):
 
     def setUp(self):
         super(AclSearchTests, self).setUp()
-        # Get the old "dSHeuristics" if it was set
-        dsheuristics = self.ldb_admin.get_dsheuristics()
-        # Reset the "dSHeuristics" as they were before
-        self.addCleanup(self.ldb_admin.set_dsheuristics, dsheuristics)
-        # Set the "dSHeuristics" to activate the correct "userPassword" behaviour
-        self.ldb_admin.set_dsheuristics("000000001")
-        # Get the old "minPwdAge"
-        minPwdAge = self.ldb_admin.get_minPwdAge()
-        # Reset the "minPwdAge" as it was before
-        self.addCleanup(self.ldb_admin.set_minPwdAge, minPwdAge)
-        # Set it temporarely to "0"
-        self.ldb_admin.set_minPwdAge("0")
+
+        # permit password changes during this test
+        PasswordCommon.allow_password_changes(self, self.ldb_admin)
 
         self.u1 = "search_u1"
         self.u2 = "search_u2"
diff --git a/source4/dsdb/tests/python/password_lockout_base.py b/source4/dsdb/tests/python/password_lockout_base.py
index 164b86f..721948c 100644
--- a/source4/dsdb/tests/python/password_lockout_base.py
+++ b/source4/dsdb/tests/python/password_lockout_base.py
@@ -14,10 +14,11 @@ import samba.tests
 from samba.tests import delete_force
 from samba.dcerpc import security, samr
 from samba.ndr import ndr_unpack
+from samba.tests.password_test import PasswordTestCase
 
 import time
 
-class BasePasswordTestCase(samba.tests.TestCase):
+class BasePasswordTestCase(PasswordTestCase):
     def _open_samr_user(self, res):
         self.assertTrue("objectSid" in res[0])
 
@@ -272,19 +273,12 @@ userPassword: """ + userpass + """
         self.template_creds.set_gensec_features(self.global_creds.get_gensec_features())
         self.template_creds.set_kerberos_state(self.global_creds.get_kerberos_state())
 
-
         # Gets back the basedn
         base_dn = self.ldb.domain_dn()
 
         # Gets back the configuration basedn
         configuration_dn = self.ldb.get_config_basedn().get_linearized()
 
-        # Get the old "dSHeuristics" if it was set
-        dsheuristics = self.ldb.get_dsheuristics()
-
-        # Reset the "dSHeuristics" as they were before
-        self.addCleanup(self.ldb.set_dsheuristics, dsheuristics)
-
         res = self.ldb.search(base_dn,
                          scope=SCOPE_BASE, attrs=["lockoutDuration", "lockOutObservationWindow", "lockoutThreshold"])
 
@@ -335,17 +329,8 @@ lockoutThreshold: """ + str(lockoutThreshold) + """
 
         self.ldb.modify(m)
 
-        # Set the "dSHeuristics" to activate the correct "userPassword" behaviour
-        self.ldb.set_dsheuristics("000000001")
-
-        # Get the old "minPwdAge"
-        minPwdAge = self.ldb.get_minPwdAge()
-
-        # Reset the "minPwdAge" as it was before
-        self.addCleanup(self.ldb.set_minPwdAge, minPwdAge)
-
-        # Set it temporarely to "0"
-        self.ldb.set_minPwdAge("0")
+        # update DC to allow password changes for the duration of this test
+        self.allow_password_changes()
 
         self.base_dn = self.ldb.domain_dn()
 
diff --git a/source4/dsdb/tests/python/passwords.py b/source4/dsdb/tests/python/passwords.py
index bbb8be1..5e174de 100755
--- a/source4/dsdb/tests/python/passwords.py
+++ b/source4/dsdb/tests/python/passwords.py
@@ -19,6 +19,7 @@ sys.path.insert(0, "bin/python")
 import samba
 
 from samba.tests.subunitrun import SubunitOptions, TestProgram
+from samba.tests.password_test import PasswordTestCase
 
 import samba.getopt as options
 
@@ -35,6 +36,7 @@ from samba import gensec
 from samba.samdb import SamDB
 import samba.tests
 from samba.tests import delete_force
+from password_lockout_base import BasePasswordTestCase
 
 parser = optparse.OptionParser("passwords.py [options] <host>")
 sambaopts = options.SambaOptions(parser)
@@ -64,7 +66,7 @@ creds.set_gensec_features(creds.get_gensec_features() | gensec.FEATURE_SEAL)
 # Tests start here
 #
 
-class PasswordTests(samba.tests.TestCase):
+class PasswordTests(PasswordTestCase):
 
     def setUp(self):
         super(PasswordTests, self).setUp()
@@ -76,25 +78,11 @@ class PasswordTests(samba.tests.TestCase):
         # Gets back the configuration basedn
         configuration_dn = self.ldb.get_config_basedn().get_linearized()
 
-        # Get the old "dSHeuristics" if it was set
-        dsheuristics = self.ldb.get_dsheuristics()
+        # permit password changes during this test
+        self.allow_password_changes()
 
-        # Set the "dSHeuristics" to activate the correct "userPassword" behaviour
-        self.ldb.set_dsheuristics("000000001")
-
-        # Reset the "dSHeuristics" as they were before
-        self.addCleanup(self.ldb.set_dsheuristics, dsheuristics)
-
-        # Get the old "minPwdAge"
-        minPwdAge = self.ldb.get_minPwdAge()
-
-        # Set it temporarily to "0"
-        self.ldb.set_minPwdAge("0")
         self.base_dn = self.ldb.domain_dn()
 
-        # Reset the "minPwdAge" as it was before
-        self.addCleanup(self.ldb.set_minPwdAge, minPwdAge)
-
         # (Re)adds the test user "testuser" with no password atm
         delete_force(self.ldb, "cn=testuser,cn=users," + self.base_dn)
         self.ldb.add({
diff --git a/source4/dsdb/tests/python/tombstone_reanimation.py b/source4/dsdb/tests/python/tombstone_reanimation.py
index 829998d..6185922 100755
--- a/source4/dsdb/tests/python/tombstone_reanimation.py
+++ b/source4/dsdb/tests/python/tombstone_reanimation.py
@@ -30,6 +30,7 @@ from samba.dcerpc import misc
 from samba.dcerpc import security
 from samba.dcerpc import drsblobs
 from samba.dcerpc.drsuapi import *
+from samba.tests.password_test import PasswordCommon
 
 import samba.tests
 from ldb import (SCOPE_BASE, FLAG_MOD_ADD, FLAG_MOD_DELETE, FLAG_MOD_REPLACE, Dn, Message,
@@ -49,21 +50,12 @@ class RestoredObjectAttributesBaseTestCase(samba.tests.TestCase):
         self.base_dn = self.samdb.domain_dn()
         self.schema_dn = self.samdb.get_schema_basedn().get_linearized()
         self.configuration_dn = self.samdb.get_config_basedn().get_linearized()
-        # Get the old "dSHeuristics" if it was set
-        self.dsheuristics = self.samdb.get_dsheuristics()
-        # Set the "dSHeuristics" to activate the correct "userPassword" behaviour
-        self.samdb.set_dsheuristics("000000001")
-        # Get the old "minPwdAge"
-        self.minPwdAge = self.samdb.get_minPwdAge()
-        # Set it temporary to "0"
-        self.samdb.set_minPwdAge("0")
+
+        # permit password changes during this test
+        PasswordCommon.allow_password_changes(self, self.samdb)
 
     def tearDown(self):
         super(RestoredObjectAttributesBaseTestCase, self).tearDown()
-        # Reset the "dSHeuristics" as they were before
-        self.samdb.set_dsheuristics(self.dsheuristics)
-        # Reset the "minPwdAge" as it was before
-        self.samdb.set_minPwdAge(self.minPwdAge)
 
     def GUID_string(self, guid):
         return self.samdb.schema_format_value("objectGUID", guid)
-- 
2.7.4


From a2582f42095047d1a9824496a3f865a58330d843 Mon Sep 17 00:00:00 2001
From: Tim Beale <timbeale at catalyst.net.nz>
Date: Mon, 12 Mar 2018 15:22:24 +1300
Subject: [PATCH 03/23] tests: Add tests for Password Settings Objects

a.k.a Fine-Grained Password Policies

These tests currently all run and pass gainst Windows, but fail against
Samba. (Actually, the permissions test case passes against Samba,
presumably because it's enforced by the Schema permissions).

Two helper classes have been added:
- PasswordSettings: creates a PSO object and tracks its values.
- TestUser: creates a user and tracks its password history
This allows other existing tests (e.g. password_lockout, password_hash)
to easily be extended to also cover PSOs.

Most test cases use assert_PSO_applied(), which asserts:
- the correct msDS-ResultantPSO attribute is returned
- the PSO's min-password-length, complexity, and password-history
settings are correctly enforced (this has been temporarily been hobbled
until the basic constructed-attribute support is working).

Signed-off-by: Tim Beale <timbeale at catalyst.net.nz>
---
 python/samba/tests/pso.py                      | 272 +++++++++
 selftest/knownfail.d/password_settings         |   9 +
 source4/dsdb/tests/python/password_settings.py | 802 +++++++++++++++++++++++++
 source4/selftest/tests.py                      |   5 +
 4 files changed, 1088 insertions(+)
 create mode 100644 python/samba/tests/pso.py
 create mode 100644 selftest/knownfail.d/password_settings
 create mode 100644 source4/dsdb/tests/python/password_settings.py

diff --git a/python/samba/tests/pso.py b/python/samba/tests/pso.py
new file mode 100644
index 0000000..21ed6bc
--- /dev/null
+++ b/python/samba/tests/pso.py
@@ -0,0 +1,272 @@
+#
+# Helper classes for testing Password Settings Objects.
+#
+# This also tests the default password complexity (i.e. pwdProperties),
+# minPwdLength, pwdHistoryLength settings as a side-effect.
+#
+# Copyright (C) Catalyst.Net Ltd. 2018
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+#
+
+import ldb
+from ldb import SCOPE_BASE, FLAG_MOD_DELETE, FLAG_MOD_ADD, FLAG_MOD_REPLACE
+from samba.dcerpc.samr import (DOMAIN_PASSWORD_COMPLEX,
+                               DOMAIN_PASSWORD_STORE_CLEARTEXT)
+import os
+from samba.credentials import Credentials
+from samba.samdb import SamDB
+from samba import gensec
+
+class TestUser:
+    def __init__(self, username, samdb, userou=None):
+        initial_password = "Initial12#"
+        self.name = username
+        self.ldb = samdb
+        self.dn = "CN=%s,%s,%s" %(username, (userou or "CN=Users"),
+                                  self.ldb.domain_dn())
+
+        # store all passwords that have ever been used for this user, as well
+        # as a pwd_history that more closely resembles the history on the DC
+        self.all_old_passwords = [initial_password]
+        self.pwd_history = [initial_password]
+        self.ldb.newuser(username, initial_password, userou=userou)
+        self.ldb.enable_account("(sAMAccountName=%s)" % username)
+        self.last_pso = None
+
+    def old_invalid_passwords(self, hist_len):
+        """Returns the expected password history for the DC"""
+        if hist_len == 0:
+            return []
+
+        # return the last n items in the list
+        return self.pwd_history[-hist_len:]
+
+    def old_valid_passwords(self, hist_len):
+        """Returns old passwords that fall outside the DC's expected history"""
+        # if PasswordHistoryLength is zero, any previous password can be valid
+        if hist_len == 0:
+            return self.all_old_passwords
+
+        # just exclude our pwd_history if there's not much in it. This can happen
+        # if we've been using a lower PasswordHistoryLength setting previously
+        hist_len = min(len(self.pwd_history), hist_len)
+
+        # return any passwords up to the nth-from-last item
+        return self.all_old_passwords[:-hist_len]
+
+    def update_pwd_history(self, new_password):
+        """Updates the user's password history to reflect a password change"""
+        # we maintain 2 lists: all passwords the user has ever had, and an
+        # effective password-history that should roughly mirror the DC.
+        # pwd_history_change() handles the corner-case where we need to truncate
+        # password-history due to PasswordHistoryLength settings changes
+        if new_password in self.all_old_passwords:
+            self.all_old_passwords.remove(new_password)
+        self.all_old_passwords.append(new_password)
+
+        if new_password in self.pwd_history:
+            self.pwd_history.remove(new_password)
+        self.pwd_history.append(new_password)
+
+    def get_resultant_PSO(self):
+        """Returns the DN of the applicable PSO, or None if none applies"""
+        res = self.ldb.search(self.dn, attrs=['msDS-ResultantPSO'])
+
+        if 'msDS-ResultantPSO' in res[0]:
+            return res[0]['msDS-ResultantPSO'][0]
+        else:
+            return None
+
+    def get_password(self):
+        """Returns the user's current password"""
+        # current password in the last item in the list
+        return self.all_old_passwords[-1]
+
+    def set_password(self, new_password):
+        """Attempts to change a user's password"""
+        ldif = """
+dn: %s
+changetype: modify
+delete: userPassword
+userPassword: %s
+add: userPassword
+userPassword: %s
+""" % (self.dn, self.get_password(), new_password)
+        # this modify will throw an exception if new_password doesn't meet the
+        # PSO constraints (which the test code catches if it's expected to fail)
+        self.ldb.modify_ldif(ldif)
+        self.update_pwd_history(new_password)
+
+    def pwd_history_change(self, old_hist_len, new_hist_len):
+        """
+        Updates what in the password history will take effect, to reflect changes
+        on the DC. When the PasswordHistoryLength applied to a user changes from
+        a low setting (e.g. 2) to a higher setting (e.g. 4), passwords #3 and #4
+        won't actually have been stored on the DC, so we need to make sure they
+        are removed them from our mirror pwd_history list.
+        """
+
+        # our list may have been tracking more passwords than the DC actually
+        # stores. Truncate the list now to match what the DC currently has
+        hist_len = min(new_hist_len, old_hist_len)
+        if hist_len == 0:
+            self.pwd_history = []
+        elif hist_len < len(self.pwd_history):
+            self.pwd_history = self.pwd_history[-hist_len:]
+
+        # this is a difference in behaviour between Samba and Windows. If the
+        # history-length goes from zero to non-zero, Samba starts tracking
+        # password *changes* from that point onwards. However, Windows starts
+        # tracking the current password even before it gets changed
+        if "WINDOWS" in os.environ.keys():
+            if old_hist_len == 0 and new_hist_len > 0:
+                self.pwd_history = [self.get_password()]
+
+    def set_primary_group(self, group_dn):
+        """Sets a user's primaryGroupID to be that of the specified group"""
+
+        # get the primaryGroupToken of the group
+        res = self.ldb.search(base=group_dn, attrs=["primaryGroupToken"],
+                              scope=ldb.SCOPE_BASE)
+        group_id = res[0]["primaryGroupToken"]
+
+        # set primaryGroupID attribute of the user to that group
+        m = ldb.Message()
+        m.dn = ldb.Dn(self.ldb, self.dn)
+        m["primaryGroupID"] = ldb.MessageElement(group_id, FLAG_MOD_REPLACE,
+                                                 "primaryGroupID")
+        self.ldb.modify(m)
+
+class PasswordSettings:
+    def default_settings(self, samdb):
+        """
+        Returns a object representing the default password settings that will
+        take effect (i.e. when no other Fine-Grained Password Policy applies)
+        """
+        pw_attrs=["minPwdAge", "lockoutDuration", "lockOutObservationWindow",
+                  "lockoutThreshold", "maxPwdAge", "minPwdAge", "minPwdLength",
+                  "pwdHistoryLength", "pwdProperties"]
+        res = samdb.search(samdb.domain_dn(), scope=ldb.SCOPE_BASE,
+                           attrs=pw_attrs)
+
+        self.name = "Defaults"
+        self.dn = None
+        self.ldb = samdb
+        self.precedence = 0
+        self.complexity = \
+            int(res[0]["pwdProperties"][0]) & DOMAIN_PASSWORD_COMPLEX
+        self.store_plaintext = \
+            int(res[0]["pwdProperties"][0]) & DOMAIN_PASSWORD_STORE_CLEARTEXT
+        self.password_len = int(res[0]["minPwdLength"][0])
+        self.lockout_attempts = int(res[0]["lockoutThreshold"][0])
+        self.history_len = int(res[0]["pwdHistoryLength"][0])
+        # convert to time in secs
+        self.lockout_duration = int(res[0]["lockoutDuration"][0]) / -int(1e7)
+        self.lockout_window =\
+            int(res[0]["lockOutObservationWindow"][0]) / -int(1e7)
+        self.password_age_min = int(res[0]["minPwdAge"][0]) / -int(1e7)
+        self.password_age_max = int(res[0]["maxPwdAge"][0]) / -int(1e7)
+
+    def __init__(self, name, samdb, precedence=10, complexity=True,
+                 password_len=10, lockout_attempts=0, lockout_duration=5,
+                 password_age_min=0, password_age_max=60 * 60 * 24 * 30,
+                 history_len=2, store_plaintext=False, container=None):
+
+        # if no PSO was specified, return an object representing the global
+        # password settings (i.e. the default settings, if no PSO trumps them)
+        if name is None:
+            return self.default_settings(samdb)
+
+        # only PSOs in the Password Settings Container are considered. You can
+        # create PSOs outside of this container, but it's not recommended
+        if container is None:
+            base_dn = samdb.domain_dn()
+            container = "CN=Password Settings Container,CN=System,%s" % base_dn
+
+        self.name = name
+        self.dn = "CN=%s,%s" %(name, container)
+        self.ldb = samdb
+        self.precedence = precedence
+        self.complexity = complexity
+        self.store_plaintext = store_plaintext
+        self.password_len = password_len
+        self.lockout_attempts = lockout_attempts
+        self.history_len = history_len
+        # times in secs
+        self.lockout_duration = lockout_duration
+        # lockout observation-window must be <= lockout-duration (the existing
+        # lockout tests just use the same value for both settings)
+        self.lockout_window = lockout_duration
+        self.password_age_min = password_age_min
+        self.password_age_max = password_age_max
+
+        # add the PSO to the DB
+        self.ldb.add_ldif(self.get_ldif())
+
+    def get_ldif(self):
+        complexity_str = "TRUE" if self.complexity else "FALSE"
+        plaintext_str = "TRUE" if self.store_plaintext else "FALSE"
+
+        # timestamps here are in units of -100 nano-seconds
+        lockout_duration = -int(self.lockout_duration * (1e7))
+        lockout_window = -int(self.lockout_window * (1e7))
+        min_age = -int(self.password_age_min * (1e7))
+        max_age = -int(self.password_age_max * (1e7))
+
+        # all the following fields are mandatory for the PSO object
+        ldif = """
+dn: %s
+objectClass: msDS-PasswordSettings
+msDS-PasswordSettingsPrecedence: %u
+msDS-PasswordReversibleEncryptionEnabled: %s
+msDS-PasswordHistoryLength: %u
+msDS-PasswordComplexityEnabled: %s
+msDS-MinimumPasswordLength: %u
+msDS-MinimumPasswordAge: %d
+msDS-MaximumPasswordAge: %d
+msDS-LockoutThreshold: %u
+msDS-LockoutObservationWindow: %d
+msDS-LockoutDuration: %d
+""" % (self.dn, self.precedence, plaintext_str, self.history_len,
+       complexity_str, self.password_len, min_age, max_age,
+       self.lockout_attempts, lockout_window, lockout_duration)
+
+        return ldif
+
+    def apply_to(self, user_group, operation=FLAG_MOD_ADD):
+        """Updates this Password Settings Object to apply to a user or group"""
+        m = ldb.Message()
+        m.dn = ldb.Dn(self.ldb, self.dn)
+        m["msDS-PSOAppliesTo"] = ldb.MessageElement(user_group, operation,
+                                                    "msDS-PSOAppliesTo")
+        self.ldb.modify(m)
+
+    def unapply(self, user_group):
+        """Updates this PSO to no longer apply to a user or group"""
+        # just delete the msDS-PSOAppliesTo attribute (instead of adding it)
+        self.apply_to(user_group, operation=FLAG_MOD_DELETE)
+
+    def set_precedence(self, new_precedence, samdb=None):
+        if samdb is None:
+            samdb = self.ldb
+        ldif = """
+dn: %s
+changetype: modify
+replace: msDS-PasswordSettingsPrecedence
+msDS-PasswordSettingsPrecedence: %u
+""" % (self.dn, new_precedence)
+        samdb.modify_ldif(ldif)
+        self.precedence = new_precedence
+
diff --git a/selftest/knownfail.d/password_settings b/selftest/knownfail.d/password_settings
new file mode 100644
index 0000000..3bb6d2c
--- /dev/null
+++ b/selftest/knownfail.d/password_settings
@@ -0,0 +1,9 @@
+samba4.ldap.password_settings.python.password_settings.PasswordSettingsTestCase.test_pso_basics\(ad_dc_ntvfs\)
+samba4.ldap.password_settings.python.password_settings.PasswordSettingsTestCase.test_pso_equal_precedence\(ad_dc_ntvfs\)
+samba4.ldap.password_settings.python.password_settings.PasswordSettingsTestCase.test_pso_min_age\(ad_dc_ntvfs\)
+samba4.ldap.password_settings.python.password_settings.PasswordSettingsTestCase.test_pso_max_age\(ad_dc_ntvfs\)
+samba4.ldap.password_settings.python.password_settings.PasswordSettingsTestCase.test_pso_nested_groups\(ad_dc_ntvfs\)
+samba4.ldap.password_settings.python.password_settings.PasswordSettingsTestCase.test_pso_none_applied\(ad_dc_ntvfs\)
+samba4.ldap.password_settings.python.password_settings.PasswordSettingsTestCase.test_pso_special_groups\(ad_dc_ntvfs\)
+samba4.ldap.password_settings.python.password_settings.PasswordSettingsTestCase.test_pso_add_user\(ad_dc_ntvfs\)
+
diff --git a/source4/dsdb/tests/python/password_settings.py b/source4/dsdb/tests/python/password_settings.py
new file mode 100644
index 0000000..ddc927a
--- /dev/null
+++ b/source4/dsdb/tests/python/password_settings.py
@@ -0,0 +1,802 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+#
+# Tests for Password Settings Objects.
+#
+# This also tests the default password complexity (i.e. pwdProperties),
+# minPwdLength, pwdHistoryLength settings as a side-effect.
+#
+# Copyright (C) Catalyst.Net Ltd. 2018
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+#
+
+#
+# Usage:
+#  export SERVER_IP=target_dc
+#  export SUBUNITRUN=$samba4srcdir/scripting/bin/subunitrun
+#  PYTHONPATH="$PYTHONPATH:$samba4srcdir/dsdb/tests/python" $SUBUNITRUN password_settings -U"$DOMAIN/$DC_USERNAME"%"$DC_PASSWORD"
+#
+
+import samba.tests
+import ldb
+from ldb import SCOPE_BASE, FLAG_MOD_DELETE, FLAG_MOD_ADD, FLAG_MOD_REPLACE
+from samba import dsdb
+import time
+from samba.tests.password_test import PasswordTestCase
+from samba.tests.pso import TestUser
+from samba.tests.pso import PasswordSettings
+from samba.credentials import Credentials
+from samba.samdb import SamDB
+from samba import gensec
+from samba.dcerpc.samr import DOMAIN_PASSWORD_STORE_CLEARTEXT
+from samba.auth import system_session
+import base64
+
+class PasswordSettingsTestCase(PasswordTestCase):
+    def setUp(self):
+        super(PasswordSettingsTestCase, self).setUp()
+
+        self.host_url = "ldap://%s" % samba.tests.env_get_var_value("SERVER_IP")
+        self.ldb = samba.tests.connect_samdb(self.host_url)
+
+        # create a temp OU to put this test's users into
+        self.ou = samba.tests.create_test_ou(self.ldb, "password_settings")
+
+        # update DC to allow password changes for the duration of this test
+        self.allow_password_changes()
+
+        # store the current password-settings for the domain
+        self.pwd_defaults = PasswordSettings(None, self.ldb)
+        self.test_objs = []
+
+    def tearDown(self):
+        super(PasswordSettingsTestCase, self).tearDown()
+
+        # remove all objects under the top-level OU
+        self.ldb.delete(self.ou, ["tree_delete:1"])
+
+        # PSOs can't reside within an OU so they need to be cleaned up separately
+        for obj in self.test_objs:
+            self.ldb.delete(obj)
+
+    def add_obj_cleanup(self, dn_list):
+        """Handles cleanup of objects outside of the test OU in the tearDown"""
+        self.test_objs.extend(dn_list)
+
+    def add_group(self, group_name):
+        """Creates a new group"""
+        dn = "CN=%s,%s" %(group_name, self.ou)
+        self.ldb.add({"dn": dn, "objectclass": "group"})
+        return dn
+
+    def set_attribute(self, dn, attr, value, operation=FLAG_MOD_ADD, samdb=None):
+        """Modifies an attribute for an object"""
+        if samdb is None:
+            samdb = self.ldb
+        m = ldb.Message()
+        m.dn = ldb.Dn(samdb, dn)
+        m[attr] = ldb.MessageElement(value, operation, attr)
+        samdb.modify(m)
+
+    def add_user(self, username):
+        # add a new user to the DB under our top-level OU
+        userou = self.ou.split(',')[0]
+        return TestUser(username, self.ldb, userou=userou)
+
+    def assert_password_invalid(self, user, password):
+        """
+        Check we can't set a password that violates complexity or length
+        constraints
+        """
+        try:
+            user.set_password(password)
+            # fail the test if no exception was encountered
+            self.fail("Password '%s' should have been rejected" % password)
+        except ldb.LdbError as e:
+            (num, msg) = e.args
+            self.assertEquals(num, ldb.ERR_CONSTRAINT_VIOLATION, msg)
+            self.assertTrue('0000052D' in msg, msg)
+
+    def assert_password_valid(self, user, password):
+        """Checks that we can set a password successfully"""
+        try:
+            user.set_password(password)
+        except ldb.LdbError as e:
+            (num, msg) = e.args
+            # fail the test (rather than throw an error)
+            self.fail("Password '%s' unexpectedly rejected: %s" %(password, msg))
+
+    def assert_PSO_applied(self, user, pso):
+        """
+        Asserts the expected PSO is applied by checking the msDS-ResultantPSO
+        attribute, as well as checking the corresponding password-length,
+        complexity, and history are enforced correctly
+        """
+        resultant_pso = user.get_resultant_PSO()
+        self.assertTrue(resultant_pso == pso.dn,
+                        "Expected PSO %s, not %s" %(pso.name,
+                                                    str(resultant_pso)))
+
+        # temporarily returning early here will just test the resultant-PSO
+        # constructed attribute. Remove this return to also test min password
+        # length, complexity, and password-history
+        return
+
+        # we're mirroring the pwd_history for the user, so make sure this is
+        # up-to-date, before we start making password changes
+        if user.last_pso:
+            user.pwd_history_change(user.last_pso.history_len, pso.history_len)
+        user.last_pso = pso
+
+        # check if we can set a sufficiently long, but non-complex, password.
+        # (We use the history-size to generate a unique password for each
+        # assertion - otherwise, if the password is already in the history,
+        # then it'll be rejected)
+        unique_char = chr(ord('a') + len(user.all_old_passwords))
+        noncomplex_pwd = "%cabcdefghijklmnopqrst" % unique_char
+
+        if pso.complexity:
+            self.assert_password_invalid(user, noncomplex_pwd)
+        else:
+            self.assert_password_valid(user, noncomplex_pwd)
+
+        # use a unique and sufficiently complex base-string to check pwd-length
+        pass_phrase = "%d#AaBbCcDdEeFfGgHhIi" % len(user.all_old_passwords)
+
+        # check that passwords less than the specified length are rejected
+        for i in range(3, pso.password_len):
+            self.assert_password_invalid(user, pass_phrase[:i])
+
+        # check we can set a password that's exactly the minimum length
+        self.assert_password_valid(user, pass_phrase[:pso.password_len])
+
+        # check the password history is enforced correctly.
+        # first, check the last n items in the password history are invalid
+        invalid_passwords = user.old_invalid_passwords(pso.history_len)
+        for pwd in invalid_passwords:
+            self.assert_password_invalid(user, pwd)
+
+        # next, check any passwords older than the history-len can be re-used
+        valid_passwords = user.old_valid_passwords(pso.history_len)
+        for pwd in valid_passwords:
+            self.assert_set_old_password(user, pwd, pso)
+
+    def password_is_complex(self, password):
+        # non-complex passwords used in the tests are all lower-case letters
+        # If it's got a number in the password, assume it's complex
+        return any(c.isdigit() for c in password)
+
+    def assert_set_old_password(self, user, password, pso):
+        """
+        Checks a user password can be set (if the password conforms to the PSO
+        settings). Used to check an old password that falls outside the history
+        length, but might still be invalid for other reasons.
+        """
+        if self.password_is_complex(password):
+            # check password conforms to length requirements
+            if len(password) < pso.password_len:
+                self.assert_password_invalid(user, password)
+            else:
+                self.assert_password_valid(user, password)
+        else:
+            # password is not complex, check PSO handles it appropriately
+            if pso.complexity:
+                self.assert_password_invalid(user, password)
+            else:
+                self.assert_password_valid(user, password)
+
+    def test_pso_basics(self):
+        """Simple tests that a PSO takes effect when applied to a group or user"""
+
+        # create some PSOs that vary in priority and basic password-len
+        best_pso = PasswordSettings("highest-priority-PSO", self.ldb,
+                                    precedence=5, password_len=16,
+                                    history_len=6)
+        medium_pso = PasswordSettings("med-priority-PSO", self.ldb,
+                                      precedence=15, password_len=10,
+                                      history_len=4)
+        worst_pso = PasswordSettings("lowest-priority-PSO", self.ldb,
+                                     precedence=100, complexity=False,
+                                     password_len=4, history_len=0)
+
+        # handle PSO clean-up (as they're outside the top-level test OU)
+        self.add_obj_cleanup([worst_pso.dn, medium_pso.dn, best_pso.dn])
+
+        # create some groups and apply the PSOs to the groups
+        group1 = self.add_group("Group-1")
+        group2 = self.add_group("Group-2")
+        group3 = self.add_group("Group-3")
+        group4 = self.add_group("Group-4")
+        worst_pso.apply_to(group1)
+        medium_pso.apply_to(group2)
+        best_pso.apply_to(group3)
+        worst_pso.apply_to(group4)
+
+        # create a user and check the default settings apply to it
+        user = self.add_user("testuser")
+        self.assert_PSO_applied(user, self.pwd_defaults)
+
+        # add user to a group. Check that the group's PSO applies to the user
+        self.set_attribute(group1, "member", user.dn)
+        self.assert_PSO_applied(user, worst_pso)
+
+        # add the user to a group with a higher precedence PSO and and check
+        # that now trumps the previous PSO
+        self.set_attribute(group2, "member", user.dn)
+        self.assert_PSO_applied(user, medium_pso)
+
+        # add the user to the remaining groups. The highest precedence PSO
+        # should now take effect
+        self.set_attribute(group3, "member", user.dn)
+        self.set_attribute(group4, "member", user.dn)
+        self.assert_PSO_applied(user, best_pso)
+
+        # delete a group membership and check the PSO changes
+        self.set_attribute(group3, "member", user.dn, operation=FLAG_MOD_DELETE)
+        self.assert_PSO_applied(user, medium_pso)
+
+        # apply the low-precedence PSO directly to the user
+        # (directly applied PSOs should trump higher precedence group PSOs)
+        worst_pso.apply_to(user.dn)
+        self.assert_PSO_applied(user, worst_pso)
+
+        # remove applying the PSO directly to the user and check PSO changes
+        worst_pso.unapply(user.dn)
+        self.assert_PSO_applied(user, medium_pso)
+
+        # remove all appliesTo and check we have the default settings again
+        worst_pso.unapply(group1)
+        medium_pso.unapply(group2)
+        worst_pso.unapply(group4)
+        self.assert_PSO_applied(user, self.pwd_defaults)
+
+    def test_pso_nested_groups(self):
+        """PSOs operate correctly when applied to nested groups"""
+
+        # create some PSOs that vary in priority and basic password-len
+        group1_pso = PasswordSettings("group1-PSO", self.ldb, precedence=50,
+                                      password_len=12, history_len=3)
+        group2_pso = PasswordSettings("group2-PSO", self.ldb, precedence=25,
+                                      password_len=10, history_len=5,
+                                      complexity=False)
+        group3_pso = PasswordSettings("group3-PSO", self.ldb, precedence=10,
+                                      password_len=6, history_len=2)
+
+        # create some groups and apply the PSOs to the groups
+        group1 = self.add_group("Group-1")
+        group2 = self.add_group("Group-2")
+        group3 = self.add_group("Group-3")
+        group4 = self.add_group("Group-4")
+        group1_pso.apply_to(group1)
+        group2_pso.apply_to(group2)
+        group3_pso.apply_to(group3)
+
+        # create a PSO and apply it to a group that the user is not a member
+        # of - it should not have any effect on the user
+        unused_pso = PasswordSettings("unused-PSO", self.ldb, precedence=1,
+                                      password_len=20)
+        unused_pso.apply_to(group4)
+
+        # handle PSO clean-up (as they're outside the top-level test OU)
+        self.add_obj_cleanup([group1_pso.dn, group2_pso.dn, group3_pso.dn,
+                              unused_pso.dn])
+
+        # create a user and check the default settings apply to it
+        user = self.add_user("testuser")
+        self.assert_PSO_applied(user, self.pwd_defaults)
+
+        # add user to a group. Check that the group's PSO applies to the user
+        self.set_attribute(group1, "member", user.dn)
+        self.set_attribute(group2, "member", group1)
+        self.assert_PSO_applied(user, group2_pso)
+
+        # add another level to the group heirachy & check this PSO takes effect
+        self.set_attribute(group3, "member", group2)
+        self.assert_PSO_applied(user, group3_pso)
+
+        # invert the PSO precedence and check the new lowest value takes effect
+        group1_pso.set_precedence(3)
+        group2_pso.set_precedence(13)
+        group3_pso.set_precedence(33)
+        self.assert_PSO_applied(user, group1_pso)
+
+        # delete a PSO and check it no longer applies
+        self.ldb.delete(group1_pso.dn)
+        self.test_objs.remove(group1_pso.dn)
+        self.assert_PSO_applied(user, group2_pso)
+
+    def get_guid(self, dn):
+        res = self.ldb.search(base=dn, attrs=["objectGUID"], scope=ldb.SCOPE_BASE)
+        return res[0]['objectGUID'][0]
+
+    def guid_string(self, guid):
+        return self.ldb.schema_format_value("objectGUID", guid)
+
+    def PSO_with_lowest_GUID(self, pso_list):
+        """Returns the PSO object in the list with the lowest GUID"""
+        # go through each PSO and fetch its GUID
+        guid_list = []
+        mapping = {}
+        for pso in pso_list:
+            guid = self.get_guid(pso.dn)
+            guid_list.append(guid)
+            # remember which GUID maps to what PSO
+            mapping[guid] = pso
+
+        # sort the GUID list to work out the lowest/best GUID
+        guid_list.sort()
+        best_guid = guid_list[0]
+
+        # sanity-check the mapping between GUID and DN is correct
+        self.assertEqual(self.guid_string(self.get_guid(mapping[best_guid].dn)),
+                         self.guid_string(best_guid))
+
+        # return the PSO that this GUID corresponds to
+        return mapping[best_guid]
+
+    def test_pso_equal_precedence(self):
+        """Tests expected PSO wins when several have the same precedence"""
+
+        # create some PSOs that vary in priority and basic password-len
+        pso1 = PasswordSettings("PSO-1", self.ldb, precedence=5, history_len=1,
+                                password_len=11)
+        pso2 = PasswordSettings("PSO-2", self.ldb, precedence=5, history_len=2,
+                                password_len=8)
+        pso3 = PasswordSettings("PSO-3", self.ldb, precedence=5, history_len=3,
+                                password_len=5, complexity=False)
+        pso4 = PasswordSettings("PSO-4", self.ldb, precedence=5, history_len=4,
+                                password_len=13, complexity=False)
+
+        # handle PSO clean-up (as they're outside the top-level test OU)
+        self.add_obj_cleanup([pso1.dn, pso2.dn, pso3.dn, pso4.dn])
+
+        # create some groups and apply the PSOs to the groups
+        group1 = self.add_group("Group-1")
+        group2 = self.add_group("Group-2")
+        group3 = self.add_group("Group-3")
+        group4 = self.add_group("Group-4")
+        pso1.apply_to(group1)
+        pso2.apply_to(group2)
+        pso3.apply_to(group3)
+        pso4.apply_to(group4)
+
+        # create a user and check the default settings apply to it
+        user = self.add_user("testuser")
+        self.assert_PSO_applied(user, self.pwd_defaults)
+
+        # add the user to all the groups
+        self.set_attribute(group1, "member", user.dn)
+        self.set_attribute(group2, "member", user.dn)
+        self.set_attribute(group3, "member", user.dn)
+        self.set_attribute(group4, "member", user.dn)
+
+        # precedence is equal, so the PSO with lowest GUID gets applied
+        pso_list = [pso1, pso2, pso3, pso4]
+        best_pso = self.PSO_with_lowest_GUID(pso_list)
+        self.assert_PSO_applied(user, best_pso)
+
+        # excluding the winning PSO, apply the other PSOs directly to the user
+        pso_list.remove(best_pso)
+        for pso in pso_list:
+            pso.apply_to(user.dn)
+
+        # we should now have a different PSO applied (the 2nd lowest GUID)
+        next_best_pso = self.PSO_with_lowest_GUID(pso_list)
+        self.assertTrue(next_best_pso is not best_pso)
+        self.assert_PSO_applied(user, next_best_pso)
+
+        # bump the precedence of another PSO and it should now win
+        pso_list.remove(next_best_pso)
+        best_pso = pso_list[0]
+        best_pso.set_precedence(4)
+        self.assert_PSO_applied(user, best_pso)
+
+    def test_pso_invalid_location(self):
+        """Tests that PSOs in an invalid location have no effect"""
+
+        # PSOs should only be able to be created within a Password Settings
+        # Container object. Trying to create one under an OU should fail
+        try:
+            rogue_pso = PasswordSettings("rogue-PSO", self.ldb, precedence=1,
+                                         complexity=False, password_len=20,
+                                         container=self.ou)
+            self.fail()
+        except ldb.LdbError as e:
+            (num, msg) = e.args
+            self.assertEquals(num, ldb.ERR_NAMING_VIOLATION, msg)
+            # Windows returns 2099 (Illegal superior), Samba returns 2037
+            # (Naming violation - "not a valid child class")
+            self.assertTrue('00002099' in msg or '00002037' in msg, msg)
+
+        # we can't create Password Settings Containers under an OU either
+        try:
+            rogue_psc = "CN=Rogue-PSO-container,%s" % self.ou
+            self.ldb.add({"dn": rogue_psc,
+                          "objectclass": "msDS-PasswordSettingsContainer"})
+            self.fail()
+        except ldb.LdbError as e:
+            (num, msg) = e.args
+            self.assertEquals(num, ldb.ERR_NAMING_VIOLATION, msg)
+            self.assertTrue('00002099' in msg or '00002037' in msg, msg)
+
+        base_dn = self.ldb.get_default_basedn()
+        rogue_psc = "CN=Rogue-PSO-container,CN=Computers,%s" % base_dn
+        self.ldb.add({"dn": rogue_psc,
+                      "objectclass": "msDS-PasswordSettingsContainer"})
+
+        rogue_pso = PasswordSettings("rogue-PSO", self.ldb, precedence=1,
+                                     container=rogue_psc, password_len=20)
+        self.add_obj_cleanup([rogue_pso.dn, rogue_psc])
+
+        # apply the PSO to a group and check it has no effect on the user
+        user = self.add_user("testuser")
+        group = self.add_group("Group-1")
+        rogue_pso.apply_to(group)
+        self.set_attribute(group, "member", user.dn)
+        self.assert_PSO_applied(user, self.pwd_defaults)
+
+        # apply the PSO directly to the user and check it has no effect
+        rogue_pso.apply_to(user.dn)
+        self.assert_PSO_applied(user, self.pwd_defaults)
+
+    # the PSOs created in these test-cases all use a default min-age of zero.
+    # This is the only test case that checks the PSO's min-age is enforced
+    def test_pso_min_age(self):
+        """Tests that a PSO's min-age is enforced"""
+        pso = PasswordSettings("min-age-PSO", self.ldb, password_len=10,
+                               password_age_min=1, complexity=False)
+        self.add_obj_cleanup([pso.dn])
+
+        # create a user and apply the PSO
+        user = self.add_user("testuser")
+        pso.apply_to(user.dn)
+        self.assertTrue(user.get_resultant_PSO() == pso.dn)
+
+        # changing the password immediately should fail, even if password is valid
+        valid_password = "min-age-passwd"
+        self.assert_password_invalid(user, valid_password)
+        # then trying the same password later (min-age=1sec) should succeed
+        time.sleep(1.5)
+        self.assert_password_valid(user, valid_password)
+
+    def test_pso_max_age(self):
+        """Tests that a PSO's max-age is used"""
+
+        # create PSOs that use the domain's max-age +/- 1 day
+        domain_max_age = self.pwd_defaults.password_age_max
+        day_in_secs = 60 * 60 * 24
+        higher_max_age = domain_max_age + day_in_secs
+        lower_max_age = domain_max_age - day_in_secs
+        longer_pso = PasswordSettings("longer-age-PSO", self.ldb, precedence=5,
+                                      password_age_max=higher_max_age)
+        shorter_pso = PasswordSettings("shorter-age-PSO", self.ldb,
+                                       precedence=1,
+                                       password_age_max=lower_max_age)
+        self.add_obj_cleanup([longer_pso.dn, shorter_pso.dn])
+
+        user = self.add_user("testuser")
+
+        # we can't wait around long enough for the max-age to expire, so instead
+        # just check the msDS-UserPasswordExpiryTimeComputed for the user
+        attrs=['msDS-UserPasswordExpiryTimeComputed']
+        res = self.ldb.search(user.dn, attrs=attrs)
+        domain_expiry = int(res[0]['msDS-UserPasswordExpiryTimeComputed'][0])
+
+        # apply the longer PSO and check the expiry-time becomes longer
+        longer_pso.apply_to(user.dn)
+        self.assertTrue(user.get_resultant_PSO() == longer_pso.dn)
+        res = self.ldb.search(user.dn, attrs=attrs)
+        new_expiry = int(res[0]['msDS-UserPasswordExpiryTimeComputed'][0])
+
+        # use timestamp diff of 1 day - 1 minute. The new expiry should still
+        # be greater than this, without getting into nano-second granularity
+        approx_timestamp_diff = (day_in_secs - 60) * (1e7)
+        self.assertTrue(new_expiry > domain_expiry + approx_timestamp_diff)
+
+        # apply the shorter PSO and check the expiry-time is shorter
+        shorter_pso.apply_to(user.dn)
+        self.assertTrue(user.get_resultant_PSO() == shorter_pso.dn)
+        res = self.ldb.search(user.dn, attrs=attrs)
+        new_expiry = int(res[0]['msDS-UserPasswordExpiryTimeComputed'][0])
+        self.assertTrue(new_expiry < domain_expiry - approx_timestamp_diff)
+
+    def test_pso_special_groups(self):
+        """Checks applying a PSO to built-in AD groups takes effect"""
+
+        # create some PSOs that will apply to special groups
+        default_pso = PasswordSettings("default-PSO", self.ldb, precedence=20,
+                                       password_len=10, complexity=False)
+        guest_pso = PasswordSettings("guest-PSO", self.ldb, history_len=4,
+                                     precedence=5, password_len=12)
+        builtin_pso = PasswordSettings("builtin-PSO", self.ldb, history_len=9,
+                                       precedence=1, password_len=9)
+        admin_pso = PasswordSettings("admin-PSO", self.ldb, history_len=10,
+                                     precedence=2, password_len=16)
+        self.add_obj_cleanup([default_pso.dn, guest_pso.dn, admin_pso.dn,
+                              builtin_pso.dn])
+        domain_users = "CN=Domain Users,CN=Users,%s" % self.ldb.domain_dn()
+        domain_guests = "CN=Domain Guests,CN=Users,%s" % self.ldb.domain_dn()
+        admin_users = "CN=Domain Admins,CN=Users,%s" % self.ldb.domain_dn()
+
+        # if we apply a PSO to Domain Users (which all users are a member of)
+        # then that PSO should take effect on a new user
+        default_pso.apply_to(domain_users)
+        user = self.add_user("testuser")
+        self.assert_PSO_applied(user, default_pso)
+
+        # Apply a PSO to a builtin group. 'Domain Users' should be a member of
+        # Builtin/Users, but builtin groups should be excluded from the PSO
+        # calculation, so this should have no effect
+        builtin_pso.apply_to("CN=Users,CN=Builtin,%s" % self.ldb.domain_dn())
+        builtin_pso.apply_to("CN=Administrators,CN=Builtin,%s" % self.ldb.domain_dn())
+        self.assert_PSO_applied(user, default_pso)
+
+        # change the user's primary group to another group (the primaryGroupID
+        # is a little odd in that there's no memberOf backlink for it)
+        self.set_attribute(domain_guests, "member", user.dn)
+        user.set_primary_group(domain_guests)
+        # No PSO is applied to the Domain Guests yet, so the default PSO should
+        # still apply
+        self.assert_PSO_applied(user, default_pso)
+
+        # now apply a PSO to the guests group, which should trump the default
+        # PSO (because the guest PSO has a better precedence)
+        guest_pso.apply_to(domain_guests)
+        self.assert_PSO_applied(user, guest_pso)
+
+        # create a new group that's a member of Admin Users
+        nested_group = self.add_group("nested-group")
+        self.set_attribute(admin_users, "member", nested_group)
+        # set the user's primary-group to be the new group
+        self.set_attribute(nested_group, "member", user.dn)
+        user.set_primary_group(nested_group)
+        # we've only changed group membership so far, not the PSO
+        self.assert_PSO_applied(user, guest_pso)
+
+        # now apply the best-precedence PSO to Admin Users and check it applies
+        # to the user (via the nested-group's membership)
+        admin_pso.apply_to(admin_users)
+        self.assert_PSO_applied(user, admin_pso)
+
+    def test_pso_none_applied(self):
+        """Tests cases where no Resultant PSO should be returned"""
+
+        # create a PSO that we will check *doesn't* get returned
+        dummy_pso = PasswordSettings("dummy-PSO", self.ldb, password_len=20)
+        self.add_obj_cleanup([dummy_pso.dn])
+
+        # you can apply a PSO to other objects (like OUs), but the resultantPSO
+        # attribute should only be returned for users
+        dummy_pso.apply_to(self.ou)
+        res = self.ldb.search(self.ou, attrs=['msDS-ResultantPSO'])
+        self.assertFalse('msDS-ResultantPSO' in res[0])
+
+        # create a dummy user and apply the PSO
+        user = self.add_user("testuser")
+        dummy_pso.apply_to(user.dn)
+        self.assertTrue(user.get_resultant_PSO() == dummy_pso.dn)
+
+        # now clear the ADS_UF_NORMAL_ACCOUNT flag for the user, which should
+        # mean a resultant PSO is no longer returned (we're essentially turning
+        # the user into a DC here, which is a little overkill but tests
+        # behaviour as per the Windows specification)
+        self.set_attribute(user.dn, "userAccountControl",
+                           str(dsdb.UF_WORKSTATION_TRUST_ACCOUNT),
+                           operation=FLAG_MOD_REPLACE)
+        self.assertTrue(user.get_resultant_PSO() == None)
+
+        # reset it back to a normal user account
+        self.set_attribute(user.dn, "userAccountControl",
+                           str(dsdb.UF_NORMAL_ACCOUNT),
+                           operation=FLAG_MOD_REPLACE)
+        self.assertTrue(user.get_resultant_PSO() == dummy_pso.dn)
+
+        # no PSO should be returned if RID is equal to DOMAIN_USER_RID_KRBTGT
+        # (note this currently fails against Windows due to a Windows bug)
+        krbtgt_user = "CN=krbtgt,CN=Users,%s" % self.ldb.domain_dn()
+        dummy_pso.apply_to(krbtgt_user)
+        res = self.ldb.search(krbtgt_user, attrs=['msDS-ResultantPSO'])
+        self.assertFalse('msDS-ResultantPSO' in res[0])
+
+    def get_ldb_connection(self, username, password, ldaphost):
+        """Returns an LDB connection using the specified user's credentials"""
+        creds = self.get_credentials()
+        creds_tmp = Credentials()
+        creds_tmp.set_username(username)
+        creds_tmp.set_password(password)
+        creds_tmp.set_domain(creds.get_domain())
+        creds_tmp.set_realm(creds.get_realm())
+        creds_tmp.set_workstation(creds.get_workstation())
+        creds_tmp.set_gensec_features(creds_tmp.get_gensec_features()
+                                      | gensec.FEATURE_SEAL)
+        return samba.tests.connect_samdb(ldaphost, credentials=creds_tmp)
+
+    def test_pso_permissions(self):
+        """Checks that regular users can't modify/view PSO objects"""
+
+        user = self.add_user("testuser")
+
+        # get an ldb connection with the new user's privileges
+        user_ldb = self.get_ldb_connection("testuser", user.get_password(),
+                                           self.host_url)
+
+        # regular users should not be able to create a PSO (at least, not in
+        # the default Password Settings container)
+        try:
+            priv_pso = PasswordSettings("priv-PSO", user_ldb, password_len=20)
+            self.fail()
+        except ldb.LdbError as e:
+            (num, msg) = e.args
+            self.assertEquals(num, ldb.ERR_INSUFFICIENT_ACCESS_RIGHTS, msg)
+
+        # create a PSO as the admin user
+        priv_pso = PasswordSettings("priv-PSO", self.ldb, password_len=20)
+        self.add_obj_cleanup([priv_pso.dn])
+
+        # regular users should not be able to apply a PSO to a user
+        try:
+            self.set_attribute(priv_pso.dn, "msDS-PSOAppliesTo", user.dn,
+                               samdb=user_ldb)
+            self.fail()
+        except ldb.LdbError as e:
+            (num, msg) = e.args
+            self.assertEquals(num, ldb.ERR_INSUFFICIENT_ACCESS_RIGHTS, msg)
+            self.assertTrue('00002098' in msg, msg)
+
+        self.set_attribute(priv_pso.dn, "msDS-PSOAppliesTo", user.dn,
+                           samdb=self.ldb)
+
+        # regular users should not be able to change a PSO's precedence
+        try:
+            priv_pso.set_precedence(100, samdb=user_ldb)
+            self.fail()
+        except ldb.LdbError as e:
+            (num, msg) = e.args
+            self.assertEquals(num, ldb.ERR_INSUFFICIENT_ACCESS_RIGHTS, msg)
+            self.assertTrue('00002098' in msg, msg)
+
+        priv_pso.set_precedence(100, samdb=self.ldb)
+
+        # regular users should not be able to view a PSO's settings
+        pso_attrs = ["msDS-PSOAppliesTo", "msDS-PasswordSettingsPrecedence",
+                     "msDS-PasswordHistoryLength", "msDS-LockoutThreshold",
+                     "msDS-PasswordComplexityEnabled"]
+
+        # users can see the PSO object's DN, but not its attributes
+        res = user_ldb.search(priv_pso.dn, scope=ldb.SCOPE_BASE,
+                              attrs=pso_attrs)
+        self.assertTrue(str(priv_pso.dn) == str(res[0].dn))
+        for attr in pso_attrs:
+            self.assertFalse(attr in res[0])
+
+        # whereas admin users can see everything
+        res = self.ldb.search(priv_pso.dn, scope=ldb.SCOPE_BASE,
+                              attrs=pso_attrs)
+        for attr in pso_attrs:
+            self.assertTrue(attr in res[0])
+
+        # check replace/delete operations can't be performed by regular users
+        operations = [ FLAG_MOD_REPLACE, FLAG_MOD_DELETE ]
+
+        for oper in operations:
+            try:
+                self.set_attribute(priv_pso.dn, "msDS-PSOAppliesTo", user.dn,
+                                   samdb=user_ldb, operation=oper)
+                self.fail()
+            except ldb.LdbError as e:
+                (num, msg) = e.args
+                self.assertEquals(num, ldb.ERR_INSUFFICIENT_ACCESS_RIGHTS, msg)
+                self.assertTrue('00002098' in msg, msg)
+
+            # ...but can be performed by the admin user
+            self.set_attribute(priv_pso.dn, "msDS-PSOAppliesTo", user.dn,
+                               samdb=self.ldb, operation=oper)
+
+    # The 'user add' case is a bit more complicated as you can't really query
+    # the msDS-ResultantPSO attribute on a user that doesn't exist yet (it
+    # won't have any group membership or PSOs applied directly against it yet).
+    # In theory it's possible to still get an applicable PSO via the user's
+    # primaryGroupID (i.e. 'Domain Users' by default). However, testing aginst
+    # Windows shows that the PSO doesn't take effect during the user add
+    # operation. (However, the Windows GUI tools presumably adds the user in 2
+    # steps, because it does enforce the PSO for users added via the GUI).
+    def test_pso_add_user(self):
+        """Tests against a 'Domain Users' PSO taking effect on a new user"""
+
+        # create a PSO that will apply to users by default
+        default_pso = PasswordSettings("default-PSO", self.ldb, precedence=20,
+                                       password_len=12, complexity=False)
+        self.add_obj_cleanup([default_pso.dn])
+
+        # apply the PSO to Domain Users (which all users are a member of). In
+        # theory, this PSO *could* take effect on a new user (but it doesn't)
+        domain_users = "CN=Domain Users,CN=Users,%s" % self.ldb.domain_dn()
+        default_pso.apply_to(domain_users)
+
+        # first try to add a user with a password that doesn't meet the domain
+        # defaults, to prove that the DC will reject bad passwords during a
+        # user add
+        userdn = "CN=testuser,%s" % self.ou
+        password = base64.b64encode("\"abcdef\"".encode('utf-16-le'))
+
+        # Note we use an LDIF operation to ensure that the password gets set
+        # as part of the 'add' operation (whereas self.add_user() adds the user
+        # first, then sets the password later in a 2nd step)
+        try:
+            ldif = """
+dn: %s
+objectClass: user
+sAMAccountName: testuser
+unicodePwd:: %s
+""" % (userdn, password)
+            self.ldb.add_ldif(ldif)
+            self.fail()
+        except ldb.LdbError as e:
+                (num, msg) = e.args
+                # error codes differ between Samba and Windows
+                self.assertTrue(num == ldb.ERR_UNWILLING_TO_PERFORM or
+                                num == ldb.ERR_CONSTRAINT_VIOLATION, msg)
+                self.assertTrue('0000052D' in msg, msg)
+
+        # now use a password that meets the domain defaults, but doesn't meet
+        # the PSO requirements. Note that Windows allows this, i.e. it doesn't
+        # honour the PSO during the add operation
+        password = base64.b64encode("\"abcde12#\"".encode('utf-16-le'))
+        ldif = """
+dn: %s
+objectClass: user
+sAMAccountName: testuser
+unicodePwd:: %s
+""" % (userdn, password)
+        self.ldb.add_ldif(ldif)
+
+        # Now do essentially the same thing, but set the password in a 2nd step
+        # which proves that the same password doesn't meet the PSO requirements
+        userdn = "CN=testuser2,%s" % self.ou
+        ldif = """
+dn: %s
+objectClass: user
+sAMAccountName: testuser2
+""" % userdn
+        self.ldb.add_ldif(ldif)
+
+        # now that the user exists, assert that the PSO is honoured
+        try:
+            ldif = """
+dn: %s
+changetype: modify
+delete: unicodePwd
+add: unicodePwd
+unicodePwd:: %s
+""" % (userdn, password)
+            self.ldb.modify_ldif(ldif)
+            self.fail()
+        except ldb.LdbError as e:
+                (num, msg) = e.args
+                self.assertEquals(num, ldb.ERR_CONSTRAINT_VIOLATION, msg)
+                self.assertTrue('0000052D' in msg, msg)
+
+        # check setting a password that meets the PSO settings works
+        password = base64.b64encode("\"abcdefghijkl\"".encode('utf-16-le'))
+        ldif = """
+dn: %s
+changetype: modify
+delete: unicodePwd
+add: unicodePwd
+unicodePwd:: %s
+""" % (userdn, password)
+        self.ldb.modify_ldif(ldif)
+
+
diff --git a/source4/selftest/tests.py b/source4/selftest/tests.py
index b1483aa..405f2b5 100755
--- a/source4/selftest/tests.py
+++ b/source4/selftest/tests.py
@@ -799,6 +799,11 @@ planoldpythontestsuite("rodc:local", "replica_sync_rodc",
 		       environ={'DC1': '$DC_SERVER', 'DC2': '$RODC_DC_SERVER'},
 		       extra_args=['-U$DOMAIN/$DC_USERNAME%$DC_PASSWORD'])
 
+planoldpythontestsuite("ad_dc_ntvfs", "password_settings",
+                       extra_path=[os.path.join(samba4srcdir, 'dsdb/tests/python')],
+                       name="samba4.ldap.password_settings.python",
+                       extra_args=['-U$DOMAIN/$DC_USERNAME%$DC_PASSWORD'])
+
 for env in ["ad_dc_ntvfs", "fl2000dc", "fl2003dc", "fl2008r2dc"]:
     plantestsuite_loadlist("samba4.ldap_schema.python(%s)" % env, env, [python, os.path.join(samba4srcdir, "dsdb/tests/python/ldap_schema.py"), '$SERVER', '-U"$USERNAME%$PASSWORD"', '--workgroup=$DOMAIN', '$LOADLIST', '$LISTOPT'])
     plantestsuite("samba4.ldap.possibleInferiors.python(%s)" % env, env, [python, os.path.join(samba4srcdir, "dsdb/samdb/ldb_modules/tests/possibleinferiors.py"), "ldap://$SERVER", '-U"$USERNAME%$PASSWORD"', "-W$DOMAIN"])
-- 
2.7.4


From 14d3ae5373dc43f75d5d246c86d8de85c9c12db9 Mon Sep 17 00:00:00 2001
From: Tim Beale <timbeale at catalyst.net.nz>
Date: Wed, 11 Apr 2018 12:40:59 +1200
Subject: [PATCH 04/23] tests: Add comments to help explain password_lockout
 tests

Signed-off-by: Tim Beale <timbeale at catalyst.net.nz>
---
 source4/dsdb/tests/python/password_lockout.py | 11 +++++++++++
 1 file changed, 11 insertions(+)

diff --git a/source4/dsdb/tests/python/password_lockout.py b/source4/dsdb/tests/python/password_lockout.py
index 1425b87..763d762 100755
--- a/source4/dsdb/tests/python/password_lockout.py
+++ b/source4/dsdb/tests/python/password_lockout.py
@@ -121,6 +121,11 @@ userAccountControl: %d
 
     def _test_userPassword_lockout_with_clear_change(self, creds, other_ldb, method,
                                                      initial_lastlogon_relation=None):
+        """
+        Tests user lockout behaviour when we try to change the user's password
+        but specify an incorrect old-password. The method parameter specifies
+        how to reset the locked out account (e.g. by resetting lockoutTime)
+        """
         # Notice: This works only against Windows if "dSHeuristics" has been set
         # properly
         username = creds.get_username()
@@ -546,6 +551,12 @@ userPassword: thatsAcomplPASS2XYZ
                                     dsdb.UF_NORMAL_ACCOUNT,
                                   msDSUserAccountControlComputed=0)
 
+    # The following test lockout behaviour when modifying a user's password
+    # and specifying an invalid old password. There are variants for both
+    # NTLM and kerberos user authentication. As well as that, there are 3 ways
+    # to reset the locked out account: by clearing the lockout bit for
+    # userAccountControl (via LDAP), resetting it via SAMR, and by resetting
+    # the lockoutTime.
     def test_userPassword_lockout_with_clear_change_krb5_ldap_userAccountControl(self):
         self._test_userPassword_lockout_with_clear_change(self.lockout1krb5_creds,
                                                           self.lockout2krb5_ldb,
-- 
2.7.4


From aff13fe9a5b35715a75111211d31b05a8fd307c9 Mon Sep 17 00:00:00 2001
From: Tim Beale <timbeale at catalyst.net.nz>
Date: Mon, 19 Mar 2018 12:56:14 +1300
Subject: [PATCH 05/23] tests: Add PSO test case to existing password_lockout
 tests

This checks that the lockout settings of the PSO take effect when one is
applied to a user. Import the password_settings code to create/apply a
PSO with the same lockout settings that the test cases normally use.
Then update the global settings so that the default lockout settings are
wildly different (i.e. so the test fails if the default lockout settings
get used instead of the PSO's).

As the password-lockout tests are quite slow, I've selected test cases
that should provide sufficient PSO coverage (rather than repeat every
single password-lockout test case in its entirety).

Signed-off-by: Tim Beale <timbeale at catalyst.net.nz>
---
 selftest/knownfail.d/password_lockout              |  5 +++
 source4/dsdb/tests/python/password_lockout.py      | 47 ++++++++++++++++++++++
 source4/dsdb/tests/python/password_lockout_base.py | 37 ++++++++---------
 3 files changed, 69 insertions(+), 20 deletions(-)
 create mode 100644 selftest/knownfail.d/password_lockout

diff --git a/selftest/knownfail.d/password_lockout b/selftest/knownfail.d/password_lockout
new file mode 100644
index 0000000..a62c7d1
--- /dev/null
+++ b/selftest/knownfail.d/password_lockout
@@ -0,0 +1,5 @@
+samba4.ldap.password_lockout.python\(ad_dc_ntvfs\).__main__.PasswordTests.test_pso_login_lockout_ntlm\(ad_dc_ntvfs\)
+samba4.ldap.password_lockout.python\(ad_dc_ntvfs\).__main__.PasswordTests.test_pso_login_lockout_krb5\(ad_dc_ntvfs\)
+samba4.ldap.password_lockout.python\(ad_dc_ntvfs\).__main__.PasswordTests.test_pso_userPassword_lockout_with_clear_change_krb5_ldap_userAccountControl\(ad_dc_ntvfs\)
+samba4.ldap.password_lockout.python\(ad_dc_ntvfs\).__main__.PasswordTests.test_pso_userPassword_lockout_with_clear_change_ntlm_ldap_lockoutTime\(ad_dc_ntvfs\)
+samba4.ldap.password_lockout.python\(ad_dc_ntvfs\).__main__.PasswordTests.test_pso_userPassword_lockout_with_clear_change_ntlm_samr\(ad_dc_ntvfs\)
diff --git a/source4/dsdb/tests/python/password_lockout.py b/source4/dsdb/tests/python/password_lockout.py
index 763d762..99e9ef6 100755
--- a/source4/dsdb/tests/python/password_lockout.py
+++ b/source4/dsdb/tests/python/password_lockout.py
@@ -33,6 +33,7 @@ import samba.tests
 from samba.tests import delete_force
 from samba.dcerpc import security, samr
 from samba.ndr import ndr_unpack
+from samba.tests.pso import PasswordSettings
 
 parser = optparse.OptionParser("password_lockout.py [options] <host>")
 sambaopts = options.SambaOptions(parser)
@@ -590,6 +591,41 @@ userPassword: thatsAcomplPASS2XYZ
                                                           "samr",
                                                           initial_lastlogon_relation='greater')
 
+    # For PSOs, just test a selection of the above combinations
+    def test_pso_userPassword_lockout_with_clear_change_krb5_ldap_userAccountControl(self):
+        self.use_pso_lockout_settings(self.lockout1krb5_creds)
+        self._test_userPassword_lockout_with_clear_change(self.lockout1krb5_creds,
+                                                          self.lockout2krb5_ldb,
+                                                          "ldap_userAccountControl")
+
+    def test_pso_userPassword_lockout_with_clear_change_ntlm_ldap_lockoutTime(self):
+        self.use_pso_lockout_settings(self.lockout1ntlm_creds)
+        self._test_userPassword_lockout_with_clear_change(self.lockout1ntlm_creds,
+                                                          self.lockout2ntlm_ldb,
+                                                          "ldap_lockoutTime",
+                                                          initial_lastlogon_relation='greater')
+
+    def test_pso_userPassword_lockout_with_clear_change_ntlm_samr(self):
+        self.use_pso_lockout_settings(self.lockout1ntlm_creds)
+        self._test_userPassword_lockout_with_clear_change(self.lockout1ntlm_creds,
+                                                          self.lockout2ntlm_ldb,
+                                                          "samr",
+                                                          initial_lastlogon_relation='greater')
+
+    def use_pso_lockout_settings(self, creds):
+        # create a PSO with the lockout settings the test cases normally expect
+        pso = PasswordSettings("lockout-PSO", self.ldb, lockout_attempts=3,
+                               lockout_duration=2)
+        self.addCleanup(self.ldb.delete, pso.dn)
+
+        userdn = "cn=%s,cn=users,%s" % (creds.get_username(), self.base_dn)
+        pso.apply_to(userdn)
+
+        # update the global lockout settings to be wildly different to what
+        # the test cases normally expect
+        self.update_lockout_settings(threshold=10, duration=600,
+                                     observation_window=600)
+
     def _test_unicodePwd_lockout_with_clear_change(self, creds, other_ldb,
                                                    initial_logoncount_relation=None):
         print("Performs a password cleartext change operation on 'unicodePwd'")
@@ -1012,6 +1048,17 @@ unicodePwd:: """ + base64.b64encode(new_utf16) + """
     def test_login_lockout_ntlm(self):
         self._test_login_lockout(self.lockout1ntlm_creds)
 
+    # Repeat the login lockout tests using PSOs
+    def test_pso_login_lockout_krb5(self):
+        """Check the PSO lockout settings get applied to the user correctly"""
+        self.use_pso_lockout_settings(self.lockout1krb5_creds)
+        self._test_login_lockout(self.lockout1krb5_creds)
+
+    def test_pso_login_lockout_ntlm(self):
+        """Check the PSO lockout settings get applied to the user correctly"""
+        self.use_pso_lockout_settings(self.lockout1ntlm_creds)
+        self._test_login_lockout(self.lockout1ntlm_creds)
+
     def test_multiple_logon_krb5(self):
         self._test_multiple_logon(self.lockout1krb5_creds)
 
diff --git a/source4/dsdb/tests/python/password_lockout_base.py b/source4/dsdb/tests/python/password_lockout_base.py
index 721948c..843eaff 100644
--- a/source4/dsdb/tests/python/password_lockout_base.py
+++ b/source4/dsdb/tests/python/password_lockout_base.py
@@ -200,6 +200,20 @@ class BasePasswordTestCase(PasswordTestCase):
         time.sleep(0.01)
         return res
 
+    def update_lockout_settings(self, threshold, duration, observation_window):
+        """Updates the global user lockout settings"""
+        m = Message()
+        m.dn = Dn(self.ldb, self.base_dn)
+        account_lockout_duration_ticks = -int(duration * (1e7))
+        m["lockoutDuration"] = MessageElement(str(account_lockout_duration_ticks),
+                                              FLAG_MOD_REPLACE, "lockoutDuration")
+        m["lockoutThreshold"] = MessageElement(str(threshold),
+                                               FLAG_MOD_REPLACE, "lockoutThreshold")
+        lockout_observation_window_ticks = -int(observation_window * (1e7))
+        m["lockOutObservationWindow"] = MessageElement(str(lockout_observation_window_ticks),
+                                                       FLAG_MOD_REPLACE, "lockOutObservationWindow")
+        self.ldb.modify(m)
+
     def _readd_user(self, creds, lockOutObservationWindow=0):
         username = creds.get_username()
         userpass = creds.get_password()
@@ -308,32 +322,15 @@ replace: lockoutThreshold
 lockoutThreshold: """ + str(lockoutThreshold) + """
 """)
 
-        m = Message()
-        m.dn = Dn(self.ldb, base_dn)
-
+        self.base_dn = self.ldb.domain_dn()
         self.account_lockout_duration = 2
-        account_lockout_duration_ticks = -int(self.account_lockout_duration * (1e7))
-
-        m["lockoutDuration"] = MessageElement(str(account_lockout_duration_ticks),
-                                              FLAG_MOD_REPLACE, "lockoutDuration")
-
-        account_lockout_threshold = 3
-        m["lockoutThreshold"] = MessageElement(str(account_lockout_threshold),
-                                               FLAG_MOD_REPLACE, "lockoutThreshold")
-
         self.lockout_observation_window = 2
-        lockout_observation_window_ticks = -int(self.lockout_observation_window * (1e7))
-
-        m["lockOutObservationWindow"] = MessageElement(str(lockout_observation_window_ticks),
-                                                       FLAG_MOD_REPLACE, "lockOutObservationWindow")
-
-        self.ldb.modify(m)
+        self.update_lockout_settings(threshold=3, duration=2,
+                                     observation_window=2)
 
         # update DC to allow password changes for the duration of this test
         self.allow_password_changes()
 
-        self.base_dn = self.ldb.domain_dn()
-
         self.domain_sid = security.dom_sid(self.ldb.get_domain_sid())
         self.samr = samr.samr("ncacn_ip_tcp:%s[seal]" % self.host, self.lp, self.global_creds)
         self.samr_handle = self.samr.Connect2(None, security.SEC_FLAG_MAXIMUM_ALLOWED)
-- 
2.7.4


From 620bfa325889bab6087ed4968c610bdd94e74a58 Mon Sep 17 00:00:00 2001
From: Tim Beale <timbeale at catalyst.net.nz>
Date: Fri, 20 Apr 2018 12:50:00 +1200
Subject: [PATCH 06/23] tests: Add test for password-lockout via SAMR RPC

The existing password_lockout tests didn't check for changing the
password via the SAMR password_change RPC. This patch adds a test-case
for this, using the default domain lockout settings (which passes), and
then repeats the same test using a PSO (which fails).

Signed-off-by: Tim Beale <timbeale at catalyst.net.nz>
---
 selftest/knownfail.d/password_lockout         |   1 +
 source4/dsdb/tests/python/password_lockout.py | 117 ++++++++++++++++++++++++++
 2 files changed, 118 insertions(+)

diff --git a/selftest/knownfail.d/password_lockout b/selftest/knownfail.d/password_lockout
index a62c7d1..115dc53 100644
--- a/selftest/knownfail.d/password_lockout
+++ b/selftest/knownfail.d/password_lockout
@@ -3,3 +3,4 @@ samba4.ldap.password_lockout.python\(ad_dc_ntvfs\).__main__.PasswordTests.test_p
 samba4.ldap.password_lockout.python\(ad_dc_ntvfs\).__main__.PasswordTests.test_pso_userPassword_lockout_with_clear_change_krb5_ldap_userAccountControl\(ad_dc_ntvfs\)
 samba4.ldap.password_lockout.python\(ad_dc_ntvfs\).__main__.PasswordTests.test_pso_userPassword_lockout_with_clear_change_ntlm_ldap_lockoutTime\(ad_dc_ntvfs\)
 samba4.ldap.password_lockout.python\(ad_dc_ntvfs\).__main__.PasswordTests.test_pso_userPassword_lockout_with_clear_change_ntlm_samr\(ad_dc_ntvfs\)
+samba4.ldap.password_lockout.python\(ad_dc_ntvfs\).__main__.PasswordTests.test_pso_samr_change_password\(ad_dc_ntvfs\)
diff --git a/source4/dsdb/tests/python/password_lockout.py b/source4/dsdb/tests/python/password_lockout.py
index 99e9ef6..6c76118 100755
--- a/source4/dsdb/tests/python/password_lockout.py
+++ b/source4/dsdb/tests/python/password_lockout.py
@@ -34,6 +34,9 @@ from samba.tests import delete_force
 from samba.dcerpc import security, samr
 from samba.ndr import ndr_unpack
 from samba.tests.pso import PasswordSettings
+from samba.net import Net
+from samba import NTSTATUSError, ntstatus
+import ctypes
 
 parser = optparse.OptionParser("password_lockout.py [options] <host>")
 sambaopts = options.SambaOptions(parser)
@@ -1280,6 +1283,120 @@ userPassword: """ + userpass + """
         self._testing_add_user(lockout4ntlm_creds,
                                lockOutObservationWindow=self.lockout_observation_window)
 
+    def _test_samr_password_change(self, creds, other_creds, lockout_threshold=3):
+        """Tests user lockout by using bad password in SAMR password_change"""
+
+        # create a connection for SAMR using another user's credentials
+        lp = self.get_loadparm()
+        net = Net(other_creds, lp, server=self.host)
+
+        # work out the initial account values for this user
+        username = creds.get_username()
+        userdn = "cn=%s,cn=users,%s" % (username, self.base_dn)
+        res = self._check_account(userdn,
+                                  badPwdCount=0,
+                                  badPasswordTime=("greater", 0),
+                                  badPwdCountOnly=True)
+        badPasswordTime = int(res[0]["badPasswordTime"][0])
+        logonCount = int(res[0]["logonCount"][0])
+        lastLogon = int(res[0]["lastLogon"][0])
+        lastLogonTimestamp = int(res[0]["lastLogonTimestamp"][0])
+
+        # prove we can change the user password (using the correct password)
+        new_password = "thatsAcomplPASS2"
+        net.change_password(newpassword=new_password.encode('utf-8'),
+                            username=username,
+                            oldpassword=creds.get_password())
+        creds.set_password(new_password)
+
+        # try entering 'x' many bad passwords in a row to lock the user out
+        new_password = "thatsAcomplPASS3"
+        for i in range(lockout_threshold):
+            badPwdCount = i + 1
+            try:
+                print("Trying bad password, attempt #%u" % badPwdCount)
+                net.change_password(newpassword=new_password.encode('utf-8'),
+                                    username=creds.get_username(),
+                                    oldpassword="bad-password")
+                self.fail("Invalid SAMR change_password accepted")
+            except NTSTATUSError as e:
+                enum = ctypes.c_uint32(e[0]).value
+                self.assertEquals(enum, ntstatus.NT_STATUS_WRONG_PASSWORD)
+
+            # check the status of the account is updated after each bad attempt
+            account_flags = 0
+            lockoutTime = None
+            if badPwdCount >= lockout_threshold:
+                account_flags = dsdb.UF_LOCKOUT
+                lockoutTime = ("greater", badPasswordTime)
+
+            res = self._check_account(userdn,
+                                      badPwdCount=badPwdCount,
+                                      badPasswordTime=("greater", badPasswordTime),
+                                      logonCount=logonCount,
+                                      lastLogon=lastLogon,
+                                      lastLogonTimestamp=lastLogonTimestamp,
+                                      lockoutTime=lockoutTime,
+                                      userAccountControl=dsdb.UF_NORMAL_ACCOUNT,
+                                      msDSUserAccountControlComputed=account_flags)
+            badPasswordTime = int(res[0]["badPasswordTime"][0])
+
+        # the user is now locked out
+        lockoutTime = int(res[0]["lockoutTime"][0])
+
+        # check the user remains locked out regardless of whether they use a
+        # good or a bad password now
+        for password in (creds.get_password(), "bad-password"):
+            try:
+                print("Trying password %s" % password)
+                net.change_password(newpassword=new_password.encode('utf-8'),
+                                    username=creds.get_username(),
+                                    oldpassword=password)
+                self.fail("Invalid SAMR change_password accepted")
+            except NTSTATUSError as e:
+                enum = ctypes.c_uint32(e[0]).value
+                self.assertEquals(enum, ntstatus.NT_STATUS_ACCOUNT_LOCKED_OUT)
+
+            res = self._check_account(userdn,
+                                      badPwdCount=lockout_threshold,
+                                      badPasswordTime=badPasswordTime,
+                                      logonCount=logonCount,
+                                      lastLogon=lastLogon,
+                                      lastLogonTimestamp=lastLogonTimestamp,
+                                      lockoutTime=lockoutTime,
+                                      userAccountControl=dsdb.UF_NORMAL_ACCOUNT,
+                                      msDSUserAccountControlComputed=dsdb.UF_LOCKOUT)
+
+        # reset the user account lockout
+        self._reset_samr(res)
+
+        # check bad password counts are reset
+        res = self._check_account(userdn,
+                                  badPwdCount=0,
+                                  badPasswordTime=badPasswordTime,
+                                  logonCount=logonCount,
+                                  lockoutTime=0,
+                                  lastLogon=lastLogon,
+                                  lastLogonTimestamp=lastLogonTimestamp,
+                                  userAccountControl=dsdb.UF_NORMAL_ACCOUNT,
+                                  msDSUserAccountControlComputed=0)
+
+        # check we can change the user password successfully now
+        net.change_password(newpassword=new_password.encode('utf-8'),
+                            username=username,
+                            oldpassword=creds.get_password())
+        creds.set_password(new_password)
+
+    def test_samr_change_password(self):
+        self._test_samr_password_change(self.lockout1ntlm_creds,
+                                        other_creds=self.lockout2ntlm_creds)
+
+    # same as above, but use a PSO to enforce the lockout
+    def test_pso_samr_change_password(self):
+        self.use_pso_lockout_settings(self.lockout1ntlm_creds)
+        self._test_samr_password_change(self.lockout1ntlm_creds,
+                                        other_creds=self.lockout2ntlm_creds)
+
 host_url = "ldap://%s" % host
 
 TestProgram(module=__name__, opts=subunitopts)
-- 
2.7.4


From caccca00c9e6b67894547918ac83aaf5b18d0027 Mon Sep 17 00:00:00 2001
From: Tim Beale <timbeale at catalyst.net.nz>
Date: Mon, 7 May 2018 17:33:51 +1200
Subject: [PATCH 07/23] tests: Add a test case for
 msDS-PasswordReversibleEncryptionEnabled

Add a test for the 'msDS-PasswordReversibleEncryptionEnabled' attribute
on the PSO. The Effective-PasswordReversibleEncryptionEnabled is
based on the PSO setting (if one applies) or else the
DOMAIN_PASSWORD_STORE_CLEARTEXT bit for the domain's pwdProperties.
This indicates whether the user's cleartext password is to be stored
in the supplementalCredentials attribute (as 'Primary:CLEARTEXT').

The password_hash tests already text the cleartext behaviour, so I've
added an additional test case for PSOs. Note that supplementary-
credential information is not returned over LDAP (the password_hash
test uses a local LDB connection), so it made more sense to extend
the password_hash tests than to check this behaviour as part of the
PSO tests (i.e. rather than in password_settings.py).

Signed-off-by: Tim Beale <timbeale at catalyst.net.nz>
---
 python/samba/tests/password_hash.py       | 20 ++++++---
 python/samba/tests/password_hash_gpgme.py | 67 ++++++++++++++++++++++++++++++-
 selftest/knownfail.d/password_hash_gpgme  |  2 +
 3 files changed, 81 insertions(+), 8 deletions(-)
 create mode 100644 selftest/knownfail.d/password_hash_gpgme

diff --git a/python/samba/tests/password_hash.py b/python/samba/tests/password_hash.py
index 0a295d5..4c4dbcf 100644
--- a/python/samba/tests/password_hash.py
+++ b/python/samba/tests/password_hash.py
@@ -70,6 +70,17 @@ class PassWordHashTests(TestCase):
         self.lp = samba.tests.env_loadparm()
         super(PassWordHashTests, self).setUp()
 
+    def set_store_cleartext(self, cleartext):
+        # get the current pwdProperties
+        pwdProperties = self.ldb.get_pwdProperties()
+        # update the clear-text properties flag
+        props = int(pwdProperties)
+        if cleartext:
+            props |= DOMAIN_PASSWORD_STORE_CLEARTEXT
+        else:
+            props &= ~DOMAIN_PASSWORD_STORE_CLEARTEXT
+        self.ldb.set_pwdProperties(str(props))
+
     # Add a user to ldb, this will exercise the password_hash code
     # and calculate the appropriate supplemental credentials
     def add_user(self, options=None, clear_text=False, ldb=None):
@@ -109,14 +120,11 @@ class PassWordHashTests(TestCase):
 
         account_control = 0
         if clear_text:
-            # get the current pwdProperties
+            # Restore the current domain setting on exit.
             pwdProperties = self.ldb.get_pwdProperties()
-            # enable clear text properties
-            props = int(pwdProperties)
-            props |= DOMAIN_PASSWORD_STORE_CLEARTEXT
-            self.ldb.set_pwdProperties(str(props))
-            # Restore the value on exit.
             self.addCleanup(self.ldb.set_pwdProperties, pwdProperties)
+            # Update the domain setting
+            self.set_store_cleartext(clear_text)
             account_control |= UF_ENCRYPTED_TEXT_PASSWORD_ALLOWED
 
         # (Re)adds the test user USER_NAME with password USER_PASS
diff --git a/python/samba/tests/password_hash_gpgme.py b/python/samba/tests/password_hash_gpgme.py
index 25a8a0c..6dafaa8 100644
--- a/python/samba/tests/password_hash_gpgme.py
+++ b/python/samba/tests/password_hash_gpgme.py
@@ -28,12 +28,14 @@ kerberos newer keys are generated.
 from samba.tests.password_hash import (
     PassWordHashTests,
     get_package,
-    USER_PASS
+    USER_PASS,
+    USER_NAME
 )
 from samba.ndr import ndr_unpack
 from samba.dcerpc import drsblobs
 import binascii
-
+from samba.tests.pso import PasswordSettings
+import samba
 
 class PassWordHashGpgmeTests(PassWordHashTests):
 
@@ -125,6 +127,67 @@ class PassWordHashGpgmeTests(PassWordHashTests):
                         binascii.a2b_hex(ct_package.data))
         self.assertEquals(USER_PASS.encode('utf-16-le'), ct.cleartext)
 
+    def assert_cleartext(self, expect_cleartext, password=None):
+        """Checks cleartext is (or isn't) returned as expected"""
+        sc = self.get_supplemental_creds()
+        if expect_cleartext:
+            (pos, ct_package) = get_package(sc, "Primary:CLEARTEXT")
+            self.assertTrue(ct_package != None, "Failed to retrieve cleartext")
+
+            # Check the clear-text value is correct.
+            ct = ndr_unpack(drsblobs.package_PrimaryCLEARTEXTBlob,
+                            binascii.a2b_hex(ct_package.data))
+            self.assertEquals(password.encode('utf-16-le'), ct.cleartext)
+        else:
+            ct_package = get_package(sc, "Primary:CLEARTEXT")
+            self.assertTrue(ct_package == None,
+                            "Got cleartext when we shouldn't have")
+
+    def test_supplementalCredentials_cleartext_pso(self):
+        """Checks that a PSO's cleartext setting can override the domain's"""
+
+        # create a user that stores plain-text passwords
+        self.add_user(clear_text=True)
+
+        # check that clear-text is present in the supplementary-credentials
+        self.assert_cleartext(expect_cleartext=True, password=USER_PASS)
+
+        # create a PSO overriding the plain-text setting & apply it to the user
+        no_plaintext_pso = PasswordSettings("no-plaintext-PSO", self.ldb,
+                                            precedence=200,
+                                            store_plaintext=False)
+        self.addCleanup(self.ldb.delete, no_plaintext_pso.dn)
+        userdn = "cn=" + USER_NAME + ",cn=users," + self.base_dn
+        no_plaintext_pso.apply_to(userdn)
+
+        # set the password to update the cleartext password stored
+        new_password = samba.generate_random_password(32, 32)
+        self.ldb.setpassword("(sAMAccountName=%s)" % USER_NAME, new_password)
+
+        # this time cleartext shouldn't be in the supplementary creds
+        self.assert_cleartext(expect_cleartext=False)
+
+        # unapply PSO, update password, and check we get the cleartext again
+        no_plaintext_pso.unapply(userdn)
+        new_password = samba.generate_random_password(32, 32)
+        self.ldb.setpassword("(sAMAccountName=%s)" % USER_NAME, new_password)
+        self.assert_cleartext(expect_cleartext=True, password=new_password)
+
+        # Now update the domain setting and check we no longer get cleartext
+        self.set_store_cleartext(False)
+        new_password = samba.generate_random_password(32, 32)
+        self.ldb.setpassword("(sAMAccountName=%s)" % USER_NAME, new_password)
+        self.assert_cleartext(expect_cleartext=False)
+
+        # create a PSO overriding the domain setting & apply it to the user
+        plaintext_pso = PasswordSettings("plaintext-PSO", self.ldb,
+                                         precedence=100, store_plaintext=True)
+        self.addCleanup(self.ldb.delete, plaintext_pso.dn)
+        plaintext_pso.apply_to(userdn)
+        new_password = samba.generate_random_password(32, 32)
+        self.ldb.setpassword("(sAMAccountName=%s)" % USER_NAME, new_password)
+        self.assert_cleartext(expect_cleartext=True, password=new_password)
+
     def test_userPassword_multiple_hashes(self):
         self.add_user(options=[(
             "password hash userPassword schemes",
diff --git a/selftest/knownfail.d/password_hash_gpgme b/selftest/knownfail.d/password_hash_gpgme
new file mode 100644
index 0000000..a382714
--- /dev/null
+++ b/selftest/knownfail.d/password_hash_gpgme
@@ -0,0 +1,2 @@
+samba.tests.password_hash_gpgme.samba.tests.password_hash_gpgme.PassWordHashGpgmeTests.test_supplementalCredentials_cleartext_pso\(ad_dc:local\)
+
-- 
2.7.4


From 2b9bf66bb9c459c4320ac1fd6f34b6fcf01e8c29 Mon Sep 17 00:00:00 2001
From: Tim Beale <timbeale at catalyst.net.nz>
Date: Thu, 5 Apr 2018 10:40:03 +1200
Subject: [PATCH 08/23] dsdb: Use attribute-name parameter for error message

We'll reuse this code for working out the msDS-ResultantPSO, so
references to 'tokenGroups' in error messages would be misleading.

Signed-off-by: Tim Beale <timbeale at catalyst.net.nz>
---
 source4/dsdb/samdb/ldb_modules/operational.c | 10 ++++++----
 1 file changed, 6 insertions(+), 4 deletions(-)

diff --git a/source4/dsdb/samdb/ldb_modules/operational.c b/source4/dsdb/samdb/ldb_modules/operational.c
index 576cf86..3fe61ed 100644
--- a/source4/dsdb/samdb/ldb_modules/operational.c
+++ b/source4/dsdb/samdb/ldb_modules/operational.c
@@ -249,8 +249,9 @@ static int construct_generic_token_groups(struct ldb_module *module,
 					   tmp_ctx, &groupSIDs, &num_groupSIDs);
 
 	if (!NT_STATUS_IS_OK(status)) {
-		ldb_asprintf_errstring(ldb, "Failed to construct tokenGroups: expanding groups of SID %s failed: %s",
-				       account_sid_string, nt_errstr(status));
+		ldb_asprintf_errstring(ldb, "Failed to construct %s: expanding groups of SID %s failed: %s",
+				       attribute_string, account_sid_string,
+				       nt_errstr(status));
 		talloc_free(tmp_ctx);
 		return LDB_ERR_OPERATIONS_ERROR;
 	}
@@ -264,8 +265,9 @@ static int construct_generic_token_groups(struct ldb_module *module,
 	status = dsdb_expand_nested_groups(ldb, &primary_group_blob, false, filter,
 					   tmp_ctx, &groupSIDs, &num_groupSIDs);
 	if (!NT_STATUS_IS_OK(status)) {
-		ldb_asprintf_errstring(ldb, "Failed to construct tokenGroups: expanding groups of SID %s failed: %s",
-				       account_sid_string, nt_errstr(status));
+		ldb_asprintf_errstring(ldb, "Failed to construct %s: expanding groups of SID %s failed: %s",
+				       attribute_string, account_sid_string,
+				       nt_errstr(status));
 		talloc_free(tmp_ctx);
 		return LDB_ERR_OPERATIONS_ERROR;
 	}
-- 
2.7.4


From a55d840bf97bc9569303712f79935bee25c284ca Mon Sep 17 00:00:00 2001
From: Tim Beale <timbeale at catalyst.net.nz>
Date: Thu, 5 Apr 2018 10:51:42 +1200
Subject: [PATCH 09/23] dsdb: Split out construct_generic_token_groups() so we
 can reuse it

construct_generic_token_groups() currently works out the entire group
membership for a user, including the primaryGroupID. We want to do the
exact same thing for the msDS-ResultantPSO constructed attribute.
However, construct_generic_token_groups() currently adds the resulting
SIDs to the LDB search result, which we don't want to do for
msDS-ResultantPSO.

This patch splits the bulk of the group SID calculation work out into
a separate function that we can reuse for msDS-ResultantPSO. basically
this is just a straight move of the existing code. The only real change
is the TALLOC_CTX is renamed (tmp_ctx --> mem_ctx) and now passed into
the new function (so freeing it if an error conditions is hit is now
done in the caller).

Signed-off-by: Tim Beale <timbeale at catalyst.net.nz>
---
 source4/dsdb/samdb/ldb_modules/operational.c | 94 +++++++++++++++-------------
 1 file changed, 50 insertions(+), 44 deletions(-)

diff --git a/source4/dsdb/samdb/ldb_modules/operational.c b/source4/dsdb/samdb/ldb_modules/operational.c
index 3fe61ed..2efade7 100644
--- a/source4/dsdb/samdb/ldb_modules/operational.c
+++ b/source4/dsdb/samdb/ldb_modules/operational.c
@@ -131,112 +131,86 @@ static int construct_primary_group_token(struct ldb_module *module,
 }
 
 /*
-  construct the token groups for SAM objects from a message
-*/
-static int construct_generic_token_groups(struct ldb_module *module,
-					  struct ldb_message *msg, enum ldb_scope scope,
-					  struct ldb_request *parent,
-					  const char *attribute_string,
-					  enum search_type type)
+ * Returns the group SIDs for the user in the given LDB message
+ */
+static int get_group_sids(struct ldb_context *ldb, TALLOC_CTX *mem_ctx,
+			  struct ldb_message *msg, const char *attribute_string,
+			  enum search_type type, struct dom_sid **groupSIDs,
+			  unsigned int *num_groupSIDs)
 {
-	struct ldb_context *ldb = ldb_module_get_ctx(module);
-	TALLOC_CTX *tmp_ctx = talloc_new(msg);
-	unsigned int i;
-	int ret;
 	const char *filter = NULL;
-
 	NTSTATUS status;
-
 	struct dom_sid *primary_group_sid;
 	const char *primary_group_string;
 	const char *primary_group_dn;
 	DATA_BLOB primary_group_blob;
-
 	struct dom_sid *account_sid;
 	const char *account_sid_string;
 	const char *account_sid_dn;
 	DATA_BLOB account_sid_blob;
-	struct dom_sid *groupSIDs = NULL;
-	unsigned int num_groupSIDs = 0;
-
 	struct dom_sid *domain_sid;
 
-	if (scope != LDB_SCOPE_BASE) {
-		ldb_set_errstring(ldb, "Cannot provide tokenGroups attribute, this is not a BASE search");
-		return LDB_ERR_OPERATIONS_ERROR;
-	}
-
 	/* If it's not a user, it won't have a primaryGroupID */
 	if (ldb_msg_find_element(msg, "primaryGroupID") == NULL) {
-		talloc_free(tmp_ctx);
 		return LDB_SUCCESS;
 	}
 
 	/* Ensure it has an objectSID too */
-	account_sid = samdb_result_dom_sid(tmp_ctx, msg, "objectSid");
+	account_sid = samdb_result_dom_sid(mem_ctx, msg, "objectSid");
 	if (account_sid == NULL) {
-		talloc_free(tmp_ctx);
 		return LDB_SUCCESS;
 	}
 
-	status = dom_sid_split_rid(tmp_ctx, account_sid, &domain_sid, NULL);
+	status = dom_sid_split_rid(mem_ctx, account_sid, &domain_sid, NULL);
 	if (NT_STATUS_EQUAL(status, NT_STATUS_INVALID_PARAMETER)) {
-		talloc_free(tmp_ctx);
 		return LDB_ERR_INVALID_ATTRIBUTE_SYNTAX;
 	} else if (!NT_STATUS_IS_OK(status)) {
-		talloc_free(tmp_ctx);
 		return LDB_ERR_OPERATIONS_ERROR;
 	}
 
-	primary_group_sid = dom_sid_add_rid(tmp_ctx,
+	primary_group_sid = dom_sid_add_rid(mem_ctx,
 					    domain_sid,
 					    ldb_msg_find_attr_as_uint(msg, "primaryGroupID", ~0));
 	if (!primary_group_sid) {
-		talloc_free(tmp_ctx);
 		return ldb_oom(ldb);
 	}
 
 	/* only return security groups */
 	switch(type) {
 	case TOKEN_GROUPS_GLOBAL_AND_UNIVERSAL:
-		filter = talloc_asprintf(tmp_ctx, "(&(objectClass=group)(groupType:1.2.840.113556.1.4.803:=%u)(|(groupType:1.2.840.113556.1.4.803:=%u)(groupType:1.2.840.113556.1.4.803:=%u)))",
+		filter = talloc_asprintf(mem_ctx, "(&(objectClass=group)(groupType:1.2.840.113556.1.4.803:=%u)(|(groupType:1.2.840.113556.1.4.803:=%u)(groupType:1.2.840.113556.1.4.803:=%u)))",
 					 GROUP_TYPE_SECURITY_ENABLED, GROUP_TYPE_ACCOUNT_GROUP, GROUP_TYPE_UNIVERSAL_GROUP);
 		break;
 	case TOKEN_GROUPS_NO_GC_ACCEPTABLE:
 	case TOKEN_GROUPS:
-		filter = talloc_asprintf(tmp_ctx, "(&(objectClass=group)(groupType:1.2.840.113556.1.4.803:=%u))",
+		filter = talloc_asprintf(mem_ctx, "(&(objectClass=group)(groupType:1.2.840.113556.1.4.803:=%u))",
 					 GROUP_TYPE_SECURITY_ENABLED);
 		break;
 	}
 
 	if (!filter) {
-		talloc_free(tmp_ctx);
 		return ldb_oom(ldb);
 	}
 
-	primary_group_string = dom_sid_string(tmp_ctx, primary_group_sid);
+	primary_group_string = dom_sid_string(mem_ctx, primary_group_sid);
 	if (!primary_group_string) {
-		talloc_free(tmp_ctx);
 		return ldb_oom(ldb);
 	}
 
-	primary_group_dn = talloc_asprintf(tmp_ctx, "<SID=%s>", primary_group_string);
+	primary_group_dn = talloc_asprintf(mem_ctx, "<SID=%s>", primary_group_string);
 	if (!primary_group_dn) {
-		talloc_free(tmp_ctx);
 		return ldb_oom(ldb);
 	}
 
 	primary_group_blob = data_blob_string_const(primary_group_dn);
 
-	account_sid_string = dom_sid_string(tmp_ctx, account_sid);
+	account_sid_string = dom_sid_string(mem_ctx, account_sid);
 	if (!account_sid_string) {
-		talloc_free(tmp_ctx);
 		return ldb_oom(ldb);
 	}
 
-	account_sid_dn = talloc_asprintf(tmp_ctx, "<SID=%s>", account_sid_string);
+	account_sid_dn = talloc_asprintf(mem_ctx, "<SID=%s>", account_sid_string);
 	if (!account_sid_dn) {
-		talloc_free(tmp_ctx);
 		return ldb_oom(ldb);
 	}
 
@@ -246,13 +220,12 @@ static int construct_generic_token_groups(struct ldb_module *module,
 					   true, /* We don't want to add the object's SID itself,
 						    it's not returend in this attribute */
 					   filter,
-					   tmp_ctx, &groupSIDs, &num_groupSIDs);
+					   mem_ctx, groupSIDs, num_groupSIDs);
 
 	if (!NT_STATUS_IS_OK(status)) {
 		ldb_asprintf_errstring(ldb, "Failed to construct %s: expanding groups of SID %s failed: %s",
 				       attribute_string, account_sid_string,
 				       nt_errstr(status));
-		talloc_free(tmp_ctx);
 		return LDB_ERR_OPERATIONS_ERROR;
 	}
 
@@ -263,15 +236,48 @@ static int construct_generic_token_groups(struct ldb_module *module,
 	 * domain groups, not builtin groups
 	 */
 	status = dsdb_expand_nested_groups(ldb, &primary_group_blob, false, filter,
-					   tmp_ctx, &groupSIDs, &num_groupSIDs);
+					   mem_ctx, groupSIDs, num_groupSIDs);
 	if (!NT_STATUS_IS_OK(status)) {
 		ldb_asprintf_errstring(ldb, "Failed to construct %s: expanding groups of SID %s failed: %s",
 				       attribute_string, account_sid_string,
 				       nt_errstr(status));
+		return LDB_ERR_OPERATIONS_ERROR;
+	}
+
+	return LDB_SUCCESS;
+}
+
+/*
+  construct the token groups for SAM objects from a message
+*/
+static int construct_generic_token_groups(struct ldb_module *module,
+					  struct ldb_message *msg, enum ldb_scope scope,
+					  struct ldb_request *parent,
+					  const char *attribute_string,
+					  enum search_type type)
+{
+	struct ldb_context *ldb = ldb_module_get_ctx(module);
+	TALLOC_CTX *tmp_ctx = talloc_new(msg);
+	unsigned int i;
+	int ret;
+	struct dom_sid *groupSIDs = NULL;
+	unsigned int num_groupSIDs = 0;
+
+	if (scope != LDB_SCOPE_BASE) {
+		ldb_set_errstring(ldb, "Cannot provide tokenGroups attribute, this is not a BASE search");
+		return LDB_ERR_OPERATIONS_ERROR;
+	}
+
+	/* calculate the group SIDs for this object */
+	ret = get_group_sids(ldb, tmp_ctx, msg, attribute_string, type,
+			     &groupSIDs, &num_groupSIDs);
+
+	if (ret != LDB_SUCCESS) {
 		talloc_free(tmp_ctx);
 		return LDB_ERR_OPERATIONS_ERROR;
 	}
 
+	/* add these SIDs to the search result */
 	for (i=0; i < num_groupSIDs; i++) {
 		ret = samdb_msg_add_dom_sid(ldb, msg, msg, attribute_string, &groupSIDs[i]);
 		if (ret) {
-- 
2.7.4


From 5633c6f34608e9e2ca4192d9b41a92142ae91254 Mon Sep 17 00:00:00 2001
From: Tim Beale <timbeale at catalyst.net.nz>
Date: Wed, 21 Mar 2018 10:45:38 +1300
Subject: [PATCH 10/23] dsdb: Add msDS-ResultantPSO constructed attribute
 support

Add support for the msDS-ResultantPSO constructed attribute, which
indicates the PSO (if any) that should apply to a given user. First we
consider any PSOs that apply directly to a user. If none apply directly,
we consider PSOs that apply to any groups the user is a member of. (PSO
lookups are done by finding any 'msDS-PSOAppliesTo' links that apply to
the user or group SIDs we're interested in.

Note: the PSO should be selected based on the RevMembGetAccountGroups
membership, which doesn't include builtin groups. Looking at the spec,
it appears that perhaps our tokenGroups implementation should also
exclude builtin groups. However, in the short-term, I've added a new
ACCOUNT_GROUPS option to the enum, which is only used internally for
PSOs.

The PSO test cases (which are currently only checking the constructed
attribute) now pass, showing that the correct msDS-ResultantPSO value is
being returned, even if the corresponding password-policy settings are
not yet being applied.

Signed-off-by: Tim Beale <timbeale at catalyst.net.nz>
---
 selftest/knownfail.d/password_settings       |   5 -
 source4/dsdb/samdb/ldb_modules/operational.c | 273 ++++++++++++++++++++++++++-
 2 files changed, 271 insertions(+), 7 deletions(-)

diff --git a/selftest/knownfail.d/password_settings b/selftest/knownfail.d/password_settings
index 3bb6d2c..a3c6a72 100644
--- a/selftest/knownfail.d/password_settings
+++ b/selftest/knownfail.d/password_settings
@@ -1,9 +1,4 @@
-samba4.ldap.password_settings.python.password_settings.PasswordSettingsTestCase.test_pso_basics\(ad_dc_ntvfs\)
-samba4.ldap.password_settings.python.password_settings.PasswordSettingsTestCase.test_pso_equal_precedence\(ad_dc_ntvfs\)
 samba4.ldap.password_settings.python.password_settings.PasswordSettingsTestCase.test_pso_min_age\(ad_dc_ntvfs\)
 samba4.ldap.password_settings.python.password_settings.PasswordSettingsTestCase.test_pso_max_age\(ad_dc_ntvfs\)
-samba4.ldap.password_settings.python.password_settings.PasswordSettingsTestCase.test_pso_nested_groups\(ad_dc_ntvfs\)
-samba4.ldap.password_settings.python.password_settings.PasswordSettingsTestCase.test_pso_none_applied\(ad_dc_ntvfs\)
-samba4.ldap.password_settings.python.password_settings.PasswordSettingsTestCase.test_pso_special_groups\(ad_dc_ntvfs\)
 samba4.ldap.password_settings.python.password_settings.PasswordSettingsTestCase.test_pso_add_user\(ad_dc_ntvfs\)
 
diff --git a/source4/dsdb/samdb/ldb_modules/operational.c b/source4/dsdb/samdb/ldb_modules/operational.c
index 2efade7..bc268ea 100644
--- a/source4/dsdb/samdb/ldb_modules/operational.c
+++ b/source4/dsdb/samdb/ldb_modules/operational.c
@@ -87,7 +87,14 @@ struct operational_data {
 enum search_type {
 	TOKEN_GROUPS,
 	TOKEN_GROUPS_GLOBAL_AND_UNIVERSAL,
-	TOKEN_GROUPS_NO_GC_ACCEPTABLE
+	TOKEN_GROUPS_NO_GC_ACCEPTABLE,
+
+	/*
+	 * MS-DRSR 4.1.8.1.3 RevMembGetAccountGroups: Transitive membership in
+	 * all account groups in a given domain, excluding built-in groups.
+	 * (Used internally for msDS-ResultantPSO support)
+	 */
+	ACCOUNT_GROUPS
 };
 
 /*
@@ -186,6 +193,12 @@ static int get_group_sids(struct ldb_context *ldb, TALLOC_CTX *mem_ctx,
 		filter = talloc_asprintf(mem_ctx, "(&(objectClass=group)(groupType:1.2.840.113556.1.4.803:=%u))",
 					 GROUP_TYPE_SECURITY_ENABLED);
 		break;
+
+	/* for RevMembGetAccountGroups, exclude built-in groups */
+	case ACCOUNT_GROUPS:
+		filter = talloc_asprintf(mem_ctx, "(&(objectClass=group)(!(groupType:1.2.840.113556.1.4.803:=%u))(groupType:1.2.840.113556.1.4.803:=%u))",
+				GROUP_TYPE_BUILTIN_LOCAL_GROUP, GROUP_TYPE_SECURITY_ENABLED);
+		break;
 	}
 
 	if (!filter) {
@@ -856,6 +869,251 @@ static int construct_msds_user_password_expiry_time_computed(struct ldb_module *
 				   password_expiry_time);
 }
 
+/*
+ * Checks whether the msDS-ResultantPSO attribute is supported for a given
+ * user object. As per MS-ADTS, section 3.1.1.4.5.36 msDS-ResultantPSO.
+ */
+static bool pso_is_supported(struct ldb_context *ldb, struct ldb_message *msg)
+{
+	int functional_level;
+	uint32_t uac;
+	uint32_t user_rid;
+
+	functional_level = dsdb_functional_level(ldb);
+	if (functional_level < DS_DOMAIN_FUNCTION_2008) {
+		return false;
+	}
+
+	/* msDS-ResultantPSO is only supported for user objects */
+	if (!ldb_match_msg_objectclass(msg, "user")) {
+		return false;
+	}
+
+	/* ...and only if the ADS_UF_NORMAL_ACCOUNT bit is set */
+	uac = ldb_msg_find_attr_as_uint(msg, "userAccountControl", 0);
+	if (!(uac & UF_NORMAL_ACCOUNT)) {
+		return false;
+	}
+
+	/* skip it if it's the special KRBTGT default account */
+	user_rid = samdb_result_rid_from_sid(msg, msg, "objectSid", 0);
+	if (user_rid == DOMAIN_RID_KRBTGT) {
+		return false;
+	}
+
+	/* ...or if it's a special KRBTGT account for an RODC KDC */
+	if (ldb_msg_find_ldb_val(msg, "msDS-SecondaryKrbTgtNumber") != NULL) {
+		return false;
+	}
+
+	return true;
+}
+
+/*
+ * Compares two PSO objects returned by a search, to work out the better PSO.
+ * The PSO with the lowest precedence is better, otherwise (if the precedence
+ * is equal) the PSO with the lower GUID wins.
+ */
+static int pso_compare(struct ldb_message **m1, struct ldb_message **m2,
+		       TALLOC_CTX *mem_ctx)
+{
+	uint32_t prec1;
+	uint32_t prec2;
+
+	prec1 = ldb_msg_find_attr_as_uint(*m1, "msDS-PasswordSettingsPrecedence",
+					  0xffffffff);
+	prec2 = ldb_msg_find_attr_as_uint(*m2, "msDS-PasswordSettingsPrecedence",
+					  0xffffffff);
+
+	/* if precedence is equal, use the lowest GUID */
+	if (prec1 == prec2) {
+		struct GUID guid1 = samdb_result_guid(*m1, "objectGUID");
+		struct GUID guid2 = samdb_result_guid(*m2, "objectGUID");
+
+		return ndr_guid_compare(&guid1, &guid2);
+	} else {
+		return prec1 - prec2;
+	}
+}
+
+/*
+ * Search for PSO objects that apply to the object SIDs specified
+ */
+static int pso_search_by_sids(struct ldb_context *ldb, TALLOC_CTX *mem_ctx,
+			      struct dom_sid *sid_array, unsigned int num_sids,
+			      struct ldb_result **result)
+{
+	int ret;
+	int i;
+	char *sid_filter = NULL;
+	struct ldb_dn *domain_dn = NULL;
+	struct ldb_dn *psc_dn = NULL;
+	const char *attrs[] = {
+		"msDS-PasswordSettingsPrecedence",
+		"objectGUID",
+		NULL
+	};
+
+	/* build a query for PSO objects that apply to any of the SIDs given */
+	sid_filter = talloc_strdup(mem_ctx, "");
+
+	for (i = 0; sid_filter && i < num_sids; i++) {
+		char sid_buf[DOM_SID_STR_BUFLEN] = {0,};
+
+		dom_sid_string_buf(&sid_array[i], sid_buf, sizeof(sid_buf));
+
+		sid_filter = talloc_asprintf_append(sid_filter,
+						    "(msDS-PSOAppliesTo=<SID=%s>)",
+						    sid_buf);
+	}
+
+	if (sid_filter == NULL) {
+		return ldb_oom(ldb);
+	}
+
+	/* only PSOs located in the Password Settings Container are valid */
+	domain_dn = ldb_get_default_basedn(ldb);
+	psc_dn = ldb_dn_new_fmt(mem_ctx, ldb,
+			        "CN=Password Settings Container,CN=System,%s",
+				ldb_dn_get_linearized(domain_dn));
+	if (psc_dn == NULL) {
+		return ldb_oom(ldb);
+	}
+
+	ret = ldb_search(ldb, mem_ctx, result, psc_dn, LDB_SCOPE_SUBTREE,
+			 attrs, "(&(objectClass=msDS-PasswordSettings)(|%s))",
+			 sid_filter);
+	talloc_free(sid_filter);
+	return ret;
+}
+
+/*
+ * Returns the best PSO object that applies to the object SID(s) specified
+ */
+static int pso_find_best(struct ldb_context *ldb, TALLOC_CTX *mem_ctx,
+			 struct dom_sid *sid_array, unsigned int num_sids,
+			 struct ldb_message **best_pso)
+{
+	struct ldb_result *res = NULL;
+	int ret;
+
+	*best_pso = NULL;
+
+	/* find any PSOs that apply to the SIDs specified */
+	ret = pso_search_by_sids(ldb, mem_ctx, sid_array, num_sids, &res);
+	if (ret != LDB_SUCCESS) {
+		return ret;
+	}
+
+	/* sort the list so that the best PSO is first */
+	LDB_TYPESAFE_QSORT(res->msgs, res->count, mem_ctx, pso_compare);
+
+	if (res->count > 0) {
+		*best_pso = res->msgs[0];
+	}
+
+	return LDB_SUCCESS;
+}
+
+/*
+ * Determines the Password Settings Object (PSO) that applies to the given user
+ */
+static int get_pso_for_user(struct ldb_context *ldb,
+			    struct ldb_message *user_msg,
+                            struct ldb_message **pso_msg)
+{
+	bool pso_supported;
+	struct ldb_message *best_pso = NULL;
+	int ret;
+	struct ldb_message_element *el = NULL;
+	TALLOC_CTX *tmp_ctx = NULL;
+
+	*pso_msg = NULL;
+
+	/* first, check msDS-ResultantPSO is supported for this object */
+	pso_supported = pso_is_supported(ldb, user_msg);
+
+	if (!pso_supported) {
+		return LDB_SUCCESS;
+	}
+
+	tmp_ctx = talloc_new(user_msg);
+
+	/*
+	 * if any PSOs apply directly to the user, they are considered first
+	 * before we check group membership PSOs
+	 */
+	el = ldb_msg_find_element(user_msg, "msDS-PSOApplied");
+
+	if (el != NULL && el->num_values > 0) {
+		struct dom_sid *user_sid = NULL;
+
+		/* lookup the best PSO object, based on the user's SID */
+		user_sid = samdb_result_dom_sid(tmp_ctx, user_msg, "objectSid");
+		ret = pso_find_best(ldb, tmp_ctx, user_sid, 1, &best_pso);
+
+		if (ret != LDB_SUCCESS) {
+			talloc_free(tmp_ctx);
+			return ret;
+		}
+	}
+
+	/* If no valid PSO applies directly to the user, then try its groups */
+	if (best_pso == NULL) {
+		struct dom_sid *groupSIDs = NULL;
+		unsigned int num_groupSIDs = 0;
+
+		/* get the SIDs of any account groups the user is a member of */
+		ret = get_group_sids(ldb, tmp_ctx, user_msg,
+				     "msDS-ResultantPSO", ACCOUNT_GROUPS,
+				     &groupSIDs, &num_groupSIDs);
+
+		if (ret != LDB_SUCCESS) {
+			talloc_free(tmp_ctx);
+			return ret;
+		}
+
+		/* lookup the best PSO that applies to any of these groups */
+		ret = pso_find_best(ldb, tmp_ctx, groupSIDs, num_groupSIDs,
+				    &best_pso);
+
+		if (ret != LDB_SUCCESS) {
+			talloc_free(tmp_ctx);
+			return ret;
+		}
+	}
+
+	*pso_msg = best_pso;
+	return LDB_SUCCESS;
+}
+
+/*
+ * Constructs the msDS-ResultantPSO attribute, which is the DN of the Password
+ * Settings Object (PSO) that applies to that user.
+ */
+static int construct_resultant_pso(struct ldb_module *module,
+                                   struct ldb_message *msg,
+				   enum ldb_scope scope,
+                                   struct ldb_request *parent)
+{
+	struct ldb_message *pso = NULL;
+	int ret;
+	struct ldb_context *ldb = ldb_module_get_ctx(module);
+
+	/* work out the PSO (if any) that applies to this user */
+	ret = get_pso_for_user(ldb, msg, &pso);
+	if (ret != LDB_SUCCESS) {
+		return ret;
+	}
+
+	if (pso != NULL) {
+		return ldb_msg_add_string(msg, "msDS-ResultantPSO",
+					  ldb_dn_get_linearized(pso->dn));
+	}
+
+	/* no PSO applies to this user */
+	return LDB_SUCCESS;
+}
 
 struct op_controls_flags {
 	bool sd;
@@ -918,6 +1176,15 @@ static const char *user_password_expiry_time_computed_attrs[] =
 	NULL
 };
 
+static const char *resultant_pso_computed_attrs[] =
+{
+	"msDS-PSOApplied",
+	"userAccountControl",
+	"objectSid",
+	"msDS-SecondaryKrbTgtNumber",
+	"primaryGroupID",
+	NULL
+};
 
 /*
   a list of attribute names that are hidden, but can be searched for
@@ -939,7 +1206,9 @@ static const struct op_attributes_replace search_sub[] = {
 	{ "msDS-User-Account-Control-Computed", "userAccountControl", user_account_control_computed_attrs,
 	  construct_msds_user_account_control_computed },
 	{ "msDS-UserPasswordExpiryTimeComputed", "userAccountControl", user_password_expiry_time_computed_attrs,
-	  construct_msds_user_password_expiry_time_computed }
+	  construct_msds_user_password_expiry_time_computed },
+	{ "msDS-ResultantPSO", "objectClass", resultant_pso_computed_attrs,
+	  construct_resultant_pso }
 };
 
 
-- 
2.7.4


From 37a3fd7992e3aeda6d85bf08148f7ac04cf0e007 Mon Sep 17 00:00:00 2001
From: Tim Beale <timbeale at catalyst.net.nz>
Date: Tue, 8 May 2018 14:45:17 +1200
Subject: [PATCH 11/23] tests: Extend PSO tests to cover
 password-history/length/complexity

Unhobble the PSO test cases so that they not only check the
msDS-ResultantPSO constructed attribute, but also that the corresponding
PSO's password-history, minimum password length, and complexity settings
are actually used.

The tests now fail once more, as actually using the PSO's settings isn't
implemented yet.

Signed-off-by: Tim Beale <timbeale at catalyst.net.nz>
---
 selftest/knownfail.d/password_settings         | 4 ++++
 source4/dsdb/tests/python/password_settings.py | 5 -----
 2 files changed, 4 insertions(+), 5 deletions(-)

diff --git a/selftest/knownfail.d/password_settings b/selftest/knownfail.d/password_settings
index a3c6a72..a0cf46f 100644
--- a/selftest/knownfail.d/password_settings
+++ b/selftest/knownfail.d/password_settings
@@ -1,3 +1,7 @@
+samba4.ldap.password_settings.python.password_settings.PasswordSettingsTestCase.test_pso_basics\(ad_dc_ntvfs\)
+samba4.ldap.password_settings.python.password_settings.PasswordSettingsTestCase.test_pso_equal_precedence\(ad_dc_ntvfs\)
+samba4.ldap.password_settings.python.password_settings.PasswordSettingsTestCase.test_pso_nested_groups\(ad_dc_ntvfs\)
+samba4.ldap.password_settings.python.password_settings.PasswordSettingsTestCase.test_pso_special_groups\(ad_dc_ntvfs\)
 samba4.ldap.password_settings.python.password_settings.PasswordSettingsTestCase.test_pso_min_age\(ad_dc_ntvfs\)
 samba4.ldap.password_settings.python.password_settings.PasswordSettingsTestCase.test_pso_max_age\(ad_dc_ntvfs\)
 samba4.ldap.password_settings.python.password_settings.PasswordSettingsTestCase.test_pso_add_user\(ad_dc_ntvfs\)
diff --git a/source4/dsdb/tests/python/password_settings.py b/source4/dsdb/tests/python/password_settings.py
index ddc927a..e1c2b77 100644
--- a/source4/dsdb/tests/python/password_settings.py
+++ b/source4/dsdb/tests/python/password_settings.py
@@ -129,11 +129,6 @@ class PasswordSettingsTestCase(PasswordTestCase):
                         "Expected PSO %s, not %s" %(pso.name,
                                                     str(resultant_pso)))
 
-        # temporarily returning early here will just test the resultant-PSO
-        # constructed attribute. Remove this return to also test min password
-        # length, complexity, and password-history
-        return
-
         # we're mirroring the pwd_history for the user, so make sure this is
         # up-to-date, before we start making password changes
         if user.last_pso:
-- 
2.7.4


From 259e02cba952efc3df2fb9f0054081526cefa212 Mon Sep 17 00:00:00 2001
From: Tim Beale <timbeale at catalyst.net.nz>
Date: Fri, 6 Apr 2018 16:42:50 +1200
Subject: [PATCH 12/23] dsdb/auth: Use PSO settings for
 lockOutThreshold/Duration

If a PSO applies to a user, use its lockOutThreshold/Duration settings
instead of the domain setting. When we lookup a user, we now include the
msDS-ResultantPSO attribute. If the attribute is present for a user,
then we lookup the corresponding PSO object to get the lockOutThreshold/
Duration settings.

Note: This is not quite enough to make the PSO lockout tests pass, as
msDS-User-Account-Control-Computed is still constructed based on the
domain lockoutDuration setting rather than the PSO.

Updating the password_hash.c code properly will be done in a subsequent
commit.

Signed-off-by: Tim Beale <timbeale at catalyst.net.nz>
---
 source4/auth/sam.c                             | 48 +++++++++++++++++++++++++-
 source4/dsdb/common/util.c                     | 45 +++++++++++++++++++++---
 source4/dsdb/samdb/ldb_modules/password_hash.c |  1 +
 3 files changed, 89 insertions(+), 5 deletions(-)

diff --git a/source4/auth/sam.c b/source4/auth/sam.c
index fb309f5..eacd651 100644
--- a/source4/auth/sam.c
+++ b/source4/auth/sam.c
@@ -100,6 +100,7 @@ const char *user_attrs[] = {
 	"badPasswordTime",
 	"lmPwdHistory",
 	"ntPwdHistory",
+	"msDS-ResultantPSO",
 	NULL,
 };
 
@@ -774,6 +775,38 @@ NTSTATUS authsam_get_user_info_dc_principal(TALLOC_CTX *mem_ctx,
 	return NT_STATUS_OK;
 }
 
+/*
+ * Returns the details for the Password Settings Object (PSO), if one applies
+ * the user.
+ */
+static int authsam_get_user_pso(struct ldb_context *sam_ctx,
+				TALLOC_CTX *mem_ctx,
+				struct ldb_message *user_msg,
+				struct ldb_message **pso_msg)
+{
+	const char *attrs[] = { "msDS-LockoutThreshold",
+				"msDS-LockoutObservationWindow",
+				NULL };
+	struct ldb_dn *pso_dn = NULL;
+	struct ldb_result *res = NULL;
+	int ret;
+
+	/* check if the user has a PSO that applies to it */
+	pso_dn = ldb_msg_find_attr_as_dn(sam_ctx, mem_ctx, user_msg,
+					 "msDS-ResultantPSO");
+
+	if (pso_dn != NULL) {
+		ret = dsdb_search_dn(sam_ctx, mem_ctx, &res, pso_dn, attrs, 0);
+		if (ret != LDB_SUCCESS) {
+			return ret;
+		}
+
+		*pso_msg = res->msgs[0];
+	}
+
+	return LDB_SUCCESS;
+}
+
 NTSTATUS authsam_update_bad_pwd_count(struct ldb_context *sam_ctx,
 				      struct ldb_message *msg,
 				      struct ldb_dn *domain_dn)
@@ -787,6 +820,7 @@ NTSTATUS authsam_update_bad_pwd_count(struct ldb_context *sam_ctx,
 	NTSTATUS status;
 	struct ldb_result *domain_res;
 	struct ldb_message *msg_mod = NULL;
+	struct ldb_message *pso_msg = NULL;
 	TALLOC_CTX *mem_ctx;
 
 	mem_ctx = talloc_new(msg);
@@ -800,8 +834,20 @@ NTSTATUS authsam_update_bad_pwd_count(struct ldb_context *sam_ctx,
 		return NT_STATUS_INTERNAL_DB_CORRUPTION;
 	}
 
+	ret = authsam_get_user_pso(sam_ctx, mem_ctx, msg, &pso_msg);
+	if (ret != LDB_SUCCESS) {
+
+		/*
+		 * fallback to using the domain defaults so that we still
+		 * record the bad password attempt
+		 */
+		DBG_ERR("Error (%d) checking PSO for %s",
+			ret, ldb_dn_get_linearized(msg->dn));
+	}
+
 	status = dsdb_update_bad_pwd_count(mem_ctx, sam_ctx,
-					   msg, domain_res->msgs[0], &msg_mod);
+					   msg, domain_res->msgs[0], pso_msg,
+					   &msg_mod);
 	if (!NT_STATUS_IS_OK(status)) {
 		TALLOC_FREE(mem_ctx);
 		return status;
diff --git a/source4/dsdb/common/util.c b/source4/dsdb/common/util.c
index ed91bc7..cc58909 100644
--- a/source4/dsdb/common/util.c
+++ b/source4/dsdb/common/util.c
@@ -5267,6 +5267,39 @@ int samdb_result_effective_badPwdCount(struct ldb_context *sam_ldb,
 }
 
 /*
+ * Returns the lockoutThreshold that applies. If a PSO is specified, then that
+ * setting is used over the domain defaults
+ */
+static int64_t get_lockout_threshold(struct ldb_message *domain_msg,
+				     struct ldb_message *pso_msg)
+{
+	if (pso_msg != NULL) {
+		return ldb_msg_find_attr_as_int(pso_msg,
+						"msDS-LockoutThreshold", 0);
+	} else {
+		return ldb_msg_find_attr_as_int(domain_msg,
+						"lockoutThreshold", 0);
+	}
+}
+
+/*
+ * Returns the lockOutObservationWindow that applies. If a PSO is specified,
+ * then that setting is used over the domain defaults
+ */
+static int64_t get_lockout_observation_window(struct ldb_message *domain_msg,
+					      struct ldb_message *pso_msg)
+{
+	if (pso_msg != NULL) {
+		return ldb_msg_find_attr_as_int(pso_msg,
+						"msDS-LockoutObservationWindow",
+						 0);
+	} else {
+		return ldb_msg_find_attr_as_int(domain_msg,
+						"lockOutObservationWindow", 0);
+	}
+}
+
+/*
  * Prepare an update to the badPwdCount and associated attributes.
  *
  * This requires that the user_msg have (if present):
@@ -5278,11 +5311,16 @@ int samdb_result_effective_badPwdCount(struct ldb_context *sam_ldb,
  *  - pwdProperties
  *  - lockoutThreshold
  *  - lockOutObservationWindow
+ *
+ * This also requires that the pso_msg have (if present):
+ *  - msDS-LockoutThreshold
+ *  - msDS-LockoutObservationWindow
  */
 NTSTATUS dsdb_update_bad_pwd_count(TALLOC_CTX *mem_ctx,
 				   struct ldb_context *sam_ctx,
 				   struct ldb_message *user_msg,
 				   struct ldb_message *domain_msg,
+				   struct ldb_message *pso_msg,
 				   struct ldb_message **_mod_msg)
 {
 	int i, ret, badPwdCount;
@@ -5315,8 +5353,7 @@ NTSTATUS dsdb_update_bad_pwd_count(TALLOC_CTX *mem_ctx,
 	 * Also, the built in administrator account is exempt:
 	 * http://msdn.microsoft.com/en-us/library/windows/desktop/aa375371%28v=vs.85%29.aspx
 	 */
-	lockoutThreshold = ldb_msg_find_attr_as_int(domain_msg,
-						    "lockoutThreshold", 0);
+	lockoutThreshold = get_lockout_threshold(domain_msg, pso_msg);
 	if (lockoutThreshold == 0 || (rid == DOMAIN_RID_ADMINISTRATOR)) {
 		DEBUG(5, ("Not updating badPwdCount on %s after wrong password\n",
 			  ldb_dn_get_linearized(user_msg->dn)));
@@ -5333,8 +5370,8 @@ NTSTATUS dsdb_update_bad_pwd_count(TALLOC_CTX *mem_ctx,
 		return NT_STATUS_NO_MEMORY;
 	}
 
-	lockOutObservationWindow = ldb_msg_find_attr_as_int64(domain_msg,
-							      "lockOutObservationWindow", 0);
+	lockOutObservationWindow = get_lockout_observation_window(domain_msg,
+								  pso_msg);
 
 	badPwdCount = dsdb_effective_badPwdCount(user_msg, lockOutObservationWindow, now);
 
diff --git a/source4/dsdb/samdb/ldb_modules/password_hash.c b/source4/dsdb/samdb/ldb_modules/password_hash.c
index 146fb6f..16683ee 100644
--- a/source4/dsdb/samdb/ldb_modules/password_hash.c
+++ b/source4/dsdb/samdb/ldb_modules/password_hash.c
@@ -2533,6 +2533,7 @@ static int make_error_and_update_badPwdCount(struct setup_password_fields_io *io
 	status = dsdb_update_bad_pwd_count(io->ac, ldb,
 					   io->ac->search_res->message,
 					   io->ac->dom_res->message,
+					   NULL, /* TODO: support PSO */
 					   &mod_msg);
 	if (!NT_STATUS_IS_OK(status)) {
 		goto done;
-- 
2.7.4


From 91f7f342c5b13b876476bd349c73e40ead74f29a Mon Sep 17 00:00:00 2001
From: Tim Beale <timbeale at catalyst.net.nz>
Date: Tue, 8 May 2018 15:11:30 +1200
Subject: [PATCH 13/23] dsdb: PSO support for
 msDS-User-Account-Control-Computed

msDS-User-Account-Control-Computed uses the effective-lockoutDuration to
determine if a user is locked out or not. If a PSO applies to the user,
then the effective-lockoutDuration is the PSO's msDS-LockoutDuration
setting. Otherwise it is the domain default lockoutDuration value.

Signed-off-by: Tim Beale <timbeale at catalyst.net.nz>
---
 source4/dsdb/samdb/ldb_modules/operational.c | 64 ++++++++++++++++++++++++----
 1 file changed, 56 insertions(+), 8 deletions(-)

diff --git a/source4/dsdb/samdb/ldb_modules/operational.c b/source4/dsdb/samdb/ldb_modules/operational.c
index bc268ea..145c4be 100644
--- a/source4/dsdb/samdb/ldb_modules/operational.c
+++ b/source4/dsdb/samdb/ldb_modules/operational.c
@@ -97,6 +97,10 @@ enum search_type {
 	ACCOUNT_GROUPS
 };
 
+static int get_pso_for_user(struct ldb_context *ldb,
+			    struct ldb_message *user_msg,
+                            struct ldb_message **pso_msg);
+
 /*
   construct a canonical name from a message
 */
@@ -769,6 +773,34 @@ static NTTIME get_msds_user_password_expiry_time_computed(struct ldb_module *mod
 	return ret;
 }
 
+/*
+ * Returns the Effective-LockoutDuration for a user
+ */
+static int64_t get_user_lockout_duration(struct ldb_context *ldb,
+				         struct ldb_message *user_msg,
+					 struct ldb_dn *nc_root)
+{
+	int ret;
+	struct ldb_message *pso = NULL;
+
+	/* if a PSO applies to the user, use its lockoutDuration */
+	ret = get_pso_for_user(ldb, user_msg, &pso);
+	if (ret != LDB_SUCCESS) {
+
+		/* log the error, but fallback to the domain default */
+		DBG_ERR("Error retrieving PSO for %s",
+			ldb_dn_get_linearized(user_msg->dn));
+	}
+
+	if (pso != NULL) {
+		return ldb_msg_find_attr_as_int64(pso,
+					          "msDS-LockoutDuration", 0);
+	}
+
+	/* otherwise return the default domain value */
+	return samdb_search_int64(ldb, user_msg, 0, nc_root, "lockoutDuration",
+				  NULL);
+}
 
 /*
   construct msDS-User-Account-Control-Computed attr
@@ -806,9 +838,12 @@ static int construct_msds_user_account_control_computed(struct ldb_module *modul
 
 		int64_t lockoutTime = ldb_msg_find_attr_as_int64(msg, "lockoutTime", 0);
 		if (lockoutTime != 0) {
-			int64_t lockoutDuration = samdb_search_int64(ldb,
-								     msg, 0, nc_root,
-								     "lockoutDuration", NULL);
+			int64_t lockoutDuration;
+
+			lockoutDuration = get_user_lockout_duration(ldb, msg,
+								    nc_root);
+
+			/* zero locks out until the administrator intervenes */
 			if (lockoutDuration >= 0) {
 				msDS_User_Account_Control_Computed |= UF_LOCKOUT;
 			} else if (lockoutTime - lockoutDuration >= now) {
@@ -951,6 +986,7 @@ static int pso_search_by_sids(struct ldb_context *ldb, TALLOC_CTX *mem_ctx,
 	const char *attrs[] = {
 		"msDS-PasswordSettingsPrecedence",
 		"objectGUID",
+		"msDS-LockoutDuration",
 		NULL
 	};
 
@@ -1147,6 +1183,21 @@ struct op_attributes_replace {
 	int (*constructor)(struct ldb_module *, struct ldb_message *, enum ldb_scope, struct ldb_request *);
 };
 
+/* the 'extra_attrs' required for msDS-ResultantPSO */
+#define RESULTANT_PSO_COMPUTED_ATTRS \
+	"msDS-PSOApplied", \
+	"userAccountControl", \
+	"objectSid", \
+	"msDS-SecondaryKrbTgtNumber", \
+	"primaryGroupID"
+
+/*
+ * any other constructed attributes that want to work out the PSO also need to
+ * include objectClass (this gets included via 'replace' for msDS-ResultantPSO)
+ */
+#define PSO_ATTR_DEPENDENCIES \
+	RESULTANT_PSO_COMPUTED_ATTRS, \
+	"objectClass"
 
 static const char *objectSid_attr[] =
 {
@@ -1166,6 +1217,7 @@ static const char *user_account_control_computed_attrs[] =
 {
 	"lockoutTime",
 	"pwdLastSet",
+	PSO_ATTR_DEPENDENCIES,
 	NULL
 };
 
@@ -1178,11 +1230,7 @@ static const char *user_password_expiry_time_computed_attrs[] =
 
 static const char *resultant_pso_computed_attrs[] =
 {
-	"msDS-PSOApplied",
-	"userAccountControl",
-	"objectSid",
-	"msDS-SecondaryKrbTgtNumber",
-	"primaryGroupID",
+	RESULTANT_PSO_COMPUTED_ATTRS,
 	NULL
 };
 
-- 
2.7.4


From 5b283aba294502523856861dae8fe8d40deee2ba Mon Sep 17 00:00:00 2001
From: Tim Beale <timbeale at catalyst.net.nz>
Date: Wed, 11 Apr 2018 10:33:21 +1200
Subject: [PATCH 14/23] dsdb/rpc: Update effective badPwdCount to use PSO
 settings

The lockOutObservationWindow is used to calculate the badPwdCount. When
a PSO applies to a user, we want to use the PSO's lockout-observation
window rather the the default domain setting.

This is finally enough to get some of the PSO password_lockout tests
to pass.

Signed-off-by: Tim Beale <timbeale at catalyst.net.nz>
---
 selftest/knownfail.d/password_lockout |  2 --
 source4/dsdb/common/util.c            | 59 +++++++++++++++++++++++++++++++++--
 source4/rpc_server/samr/dcesrv_samr.c |  2 ++
 3 files changed, 58 insertions(+), 5 deletions(-)

diff --git a/selftest/knownfail.d/password_lockout b/selftest/knownfail.d/password_lockout
index 115dc53..eebab10 100644
--- a/selftest/knownfail.d/password_lockout
+++ b/selftest/knownfail.d/password_lockout
@@ -1,5 +1,3 @@
-samba4.ldap.password_lockout.python\(ad_dc_ntvfs\).__main__.PasswordTests.test_pso_login_lockout_ntlm\(ad_dc_ntvfs\)
-samba4.ldap.password_lockout.python\(ad_dc_ntvfs\).__main__.PasswordTests.test_pso_login_lockout_krb5\(ad_dc_ntvfs\)
 samba4.ldap.password_lockout.python\(ad_dc_ntvfs\).__main__.PasswordTests.test_pso_userPassword_lockout_with_clear_change_krb5_ldap_userAccountControl\(ad_dc_ntvfs\)
 samba4.ldap.password_lockout.python\(ad_dc_ntvfs\).__main__.PasswordTests.test_pso_userPassword_lockout_with_clear_change_ntlm_ldap_lockoutTime\(ad_dc_ntvfs\)
 samba4.ldap.password_lockout.python\(ad_dc_ntvfs\).__main__.PasswordTests.test_pso_userPassword_lockout_with_clear_change_ntlm_samr\(ad_dc_ntvfs\)
diff --git a/source4/dsdb/common/util.c b/source4/dsdb/common/util.c
index cc58909..c227c83 100644
--- a/source4/dsdb/common/util.c
+++ b/source4/dsdb/common/util.c
@@ -5247,12 +5247,46 @@ static int dsdb_effective_badPwdCount(const struct ldb_message *user_msg,
 }
 
 /*
+ * Returns a user's PSO, or NULL if none was found
+ */
+static struct ldb_result *lookup_user_pso(struct ldb_context *sam_ldb,
+					  TALLOC_CTX *mem_ctx,
+					  const struct ldb_message *user_msg,
+					  const char * const *attrs)
+{
+	struct ldb_result *res = NULL;
+	struct ldb_dn *pso_dn = NULL;
+	int ret;
+
+	/* if the user has a PSO that applies, then use the PSO's setting */
+	pso_dn = ldb_msg_find_attr_as_dn(sam_ldb, mem_ctx, user_msg,
+					 "msDS-ResultantPSO");
+
+	if (pso_dn != NULL) {
+
+		ret = dsdb_search_dn(sam_ldb, mem_ctx, &res, pso_dn, attrs, 0);
+		if (ret != LDB_SUCCESS) {
+
+			/*
+			 * log the error. The caller should fallback to using
+			 * the default domain password settings
+			 */
+			DBG_ERR("Error retrieving msDS-ResultantPSO %s for %s",
+				ldb_dn_get_linearized(pso_dn),
+				ldb_dn_get_linearized(user_msg->dn));
+		}
+		talloc_free(pso_dn);
+	}
+	return res;
+}
+
+/*
  * Return the effective badPwdCount
  *
  * This requires that the user_msg have (if present):
  *  - badPasswordTime
  *  - badPwdCount
- *
+ *  - msDS-ResultantPSO
  */
 int samdb_result_effective_badPwdCount(struct ldb_context *sam_ldb,
 				       TALLOC_CTX *mem_ctx,
@@ -5261,8 +5295,27 @@ int samdb_result_effective_badPwdCount(struct ldb_context *sam_ldb,
 {
 	struct timeval tv_now = timeval_current();
 	NTTIME now = timeval_to_nttime(&tv_now);
-	int64_t lockOutObservationWindow = samdb_search_int64(sam_ldb, mem_ctx, 0, domain_dn,
-							      "lockOutObservationWindow", NULL);
+	int64_t lockOutObservationWindow;
+	struct ldb_result *res = NULL;
+	const char *attrs[] = { "msDS-LockoutObservationWindow",
+				NULL };
+
+	res = lookup_user_pso(sam_ldb, mem_ctx, user_msg, attrs);
+
+	if (res != NULL) {
+		lockOutObservationWindow =
+			ldb_msg_find_attr_as_int(res->msgs[0],
+						 "msDS-LockoutObservationWindow",
+						  0);
+		talloc_free(res);
+	} else {
+
+		/* no PSO was found, lookup the default domain setting */
+		lockOutObservationWindow =
+			 samdb_search_int64(sam_ldb, mem_ctx, 0, domain_dn,
+					    "lockOutObservationWindow", NULL);
+	}
+
 	return dsdb_effective_badPwdCount(user_msg, lockOutObservationWindow, now);
 }
 
diff --git a/source4/rpc_server/samr/dcesrv_samr.c b/source4/rpc_server/samr/dcesrv_samr.c
index c7d692a..cb5d8d4 100644
--- a/source4/rpc_server/samr/dcesrv_samr.c
+++ b/source4/rpc_server/samr/dcesrv_samr.c
@@ -2776,6 +2776,7 @@ static NTSTATUS dcesrv_samr_QueryUserInfo(struct dcesrv_call_state *dce_call, TA
 						      "badPasswordTime",
 						      "logonCount",
 						      "pwdLastSet",
+						      "msDS-ResultantPSO",
 						      "msDS-UserPasswordExpiryTimeComputed",
 						      "accountExpires",
 						      "userAccountControl",
@@ -2882,6 +2883,7 @@ static NTSTATUS dcesrv_samr_QueryUserInfo(struct dcesrv_call_state *dce_call, TA
 		static const char * const attrs2[] = {"lastLogon",
 						      "lastLogoff",
 						      "pwdLastSet",
+						      "msDS-ResultantPSO",
 						      "msDS-UserPasswordExpiryTimeComputed",
 						      "accountExpires",
 						      "sAMAccountName",
-- 
2.7.4


From 4c579e86f218d29e5da0479f3a5657b129b964f2 Mon Sep 17 00:00:00 2001
From: Tim Beale <timbeale at catalyst.net.nz>
Date: Tue, 8 May 2018 16:07:54 +1200
Subject: [PATCH 15/23] rpc/samr: Fix PSO support in SAMR password_change RPC

To get the SAMR password_lockout test passing, we now just need to query
the msDS-ResultantPSO attribute for the user in the SAMR code. The
common code will then determine that a PSO applies to the user, and use
the PSO's lockout settings.

Signed-off-by: Tim Beale <timbeale at catalyst.net.nz>
---
 selftest/knownfail.d/password_lockout   | 1 -
 source4/rpc_server/samr/samr_password.c | 2 ++
 2 files changed, 2 insertions(+), 1 deletion(-)

diff --git a/selftest/knownfail.d/password_lockout b/selftest/knownfail.d/password_lockout
index eebab10..58f047f 100644
--- a/selftest/knownfail.d/password_lockout
+++ b/selftest/knownfail.d/password_lockout
@@ -1,4 +1,3 @@
 samba4.ldap.password_lockout.python\(ad_dc_ntvfs\).__main__.PasswordTests.test_pso_userPassword_lockout_with_clear_change_krb5_ldap_userAccountControl\(ad_dc_ntvfs\)
 samba4.ldap.password_lockout.python\(ad_dc_ntvfs\).__main__.PasswordTests.test_pso_userPassword_lockout_with_clear_change_ntlm_ldap_lockoutTime\(ad_dc_ntvfs\)
 samba4.ldap.password_lockout.python\(ad_dc_ntvfs\).__main__.PasswordTests.test_pso_userPassword_lockout_with_clear_change_ntlm_samr\(ad_dc_ntvfs\)
-samba4.ldap.password_lockout.python\(ad_dc_ntvfs\).__main__.PasswordTests.test_pso_samr_change_password\(ad_dc_ntvfs\)
diff --git a/source4/rpc_server/samr/samr_password.c b/source4/rpc_server/samr/samr_password.c
index 22f456f..ed290f0 100644
--- a/source4/rpc_server/samr/samr_password.c
+++ b/source4/rpc_server/samr/samr_password.c
@@ -107,6 +107,7 @@ NTSTATUS dcesrv_samr_OemChangePasswordUser2(struct dcesrv_call_state *dce_call,
 	struct ldb_message **res;
 	const char * const attrs[] = { "objectSid", "dBCSPwd",
 				       "userAccountControl",
+				       "msDS-ResultantPSO",
 				       "msDS-User-Account-Control-Computed",
 				       "badPwdCount", "badPasswordTime",
 				       "samAccountName",
@@ -295,6 +296,7 @@ NTSTATUS dcesrv_samr_ChangePasswordUser3(struct dcesrv_call_state *dce_call,
 	struct ldb_message **res;
 	const char * const attrs[] = { "unicodePwd", "dBCSPwd",
 				       "userAccountControl",
+				       "msDS-ResultantPSO",
 				       "msDS-User-Account-Control-Computed",
 				       "badPwdCount", "badPasswordTime",
 				       "objectSid", NULL };
-- 
2.7.4


From b34be5ab54d7305d645db141e213c40902cff590 Mon Sep 17 00:00:00 2001
From: Tim Beale <timbeale at catalyst.net.nz>
Date: Wed, 18 Apr 2018 14:21:46 +1200
Subject: [PATCH 16/23] dsdb: Lookup PSO's lockout settings for password_hash
 modifies

When a user's password-hash is modified, we need the PSO settings for
that user, so that any lockout settings get applied correctly.

To do this, we query the msDS-ResultantPSO in the user search. Then, if
a PSO applies to the user, we add in a extra search to retrieve the
PSO's settings. Once the PSO search completes, we continue with the
modify operation.

In the event of error cases, I've tried to fallback to logging the
problem and continuing with the default domain settings. However,
unusual internal errors will still fail the operation.

We can pass the PSO result into dsdb_update_bad_pwd_count(), which means
the PSO's lockout-threshold and observation-window are now used. This is
enough to get the remaining lockout tests passing.

Signed-off-by: Tim Beale <timbeale at catalyst.net.nz>
---
 selftest/knownfail.d/password_lockout          |   3 -
 source4/dsdb/samdb/ldb_modules/password_hash.c | 164 ++++++++++++++++++++++++-
 2 files changed, 162 insertions(+), 5 deletions(-)
 delete mode 100644 selftest/knownfail.d/password_lockout

diff --git a/selftest/knownfail.d/password_lockout b/selftest/knownfail.d/password_lockout
deleted file mode 100644
index 58f047f..0000000
--- a/selftest/knownfail.d/password_lockout
+++ /dev/null
@@ -1,3 +0,0 @@
-samba4.ldap.password_lockout.python\(ad_dc_ntvfs\).__main__.PasswordTests.test_pso_userPassword_lockout_with_clear_change_krb5_ldap_userAccountControl\(ad_dc_ntvfs\)
-samba4.ldap.password_lockout.python\(ad_dc_ntvfs\).__main__.PasswordTests.test_pso_userPassword_lockout_with_clear_change_ntlm_ldap_lockoutTime\(ad_dc_ntvfs\)
-samba4.ldap.password_lockout.python\(ad_dc_ntvfs\).__main__.PasswordTests.test_pso_userPassword_lockout_with_clear_change_ntlm_samr\(ad_dc_ntvfs\)
diff --git a/source4/dsdb/samdb/ldb_modules/password_hash.c b/source4/dsdb/samdb/ldb_modules/password_hash.c
index 16683ee..8f33795 100644
--- a/source4/dsdb/samdb/ldb_modules/password_hash.c
+++ b/source4/dsdb/samdb/ldb_modules/password_hash.c
@@ -95,6 +95,8 @@ struct ph_context {
 	struct ldb_request *dom_req;
 	struct ldb_reply *dom_res;
 
+	struct ldb_reply *pso_res;
+
 	struct ldb_reply *search_res;
 
 	struct ldb_message *update_msg;
@@ -2527,13 +2529,19 @@ static int make_error_and_update_badPwdCount(struct setup_password_fields_io *io
 {
 	struct ldb_context *ldb = ldb_module_get_ctx(io->ac->module);
 	struct ldb_message *mod_msg = NULL;
+	struct ldb_message *pso_msg = NULL;
 	NTSTATUS status;
 	int ret;
 
+	/* PSO search result is optional (NULL if no PSO applies) */
+	if (io->ac->pso_res != NULL) {
+		pso_msg = io->ac->pso_res->message;
+	}
+
 	status = dsdb_update_bad_pwd_count(io->ac, ldb,
 					   io->ac->search_res->message,
 					   io->ac->dom_res->message,
-					   NULL, /* TODO: support PSO */
+					   pso_msg,
 					   &mod_msg);
 	if (!NT_STATUS_IS_OK(status)) {
 		goto done;
@@ -3837,12 +3845,150 @@ static int password_hash_mod_search_self(struct ph_context *ac);
 static int ph_mod_search_callback(struct ldb_request *req, struct ldb_reply *ares);
 static int password_hash_mod_do_mod(struct ph_context *ac);
 
+/*
+ * LDB callback handler for searching for a user's PSO. Once we have all the
+ * Password Settings that apply to the user, we can continue with the modify
+ * operation
+ */
+static int get_pso_data_callback(struct ldb_request *req,
+				 struct ldb_reply *ares)
+{
+	struct ldb_context *ldb = NULL;
+	struct ph_context *ac = NULL;
+	int ret = LDB_SUCCESS;
+
+	ac = talloc_get_type(req->context, struct ph_context);
+	ldb = ldb_module_get_ctx(ac->module);
+
+	if (!ares) {
+		ret = LDB_ERR_OPERATIONS_ERROR;
+		goto done;
+	}
+	if (ares->error != LDB_SUCCESS) {
+		return ldb_module_done(ac->req, ares->controls,
+				       ares->response, ares->error);
+	}
+
+	switch (ares->type) {
+	case LDB_REPLY_ENTRY:
+
+		/* check status was initialized by the domain query */
+		if (ac->status == NULL) {
+			talloc_free(ares);
+			ldb_set_errstring(ldb, "Uninitialized status");
+			ret = LDB_ERR_OPERATIONS_ERROR;
+			goto done;
+		}
+
+		if (ac->pso_res != NULL) {
+			DBG_ERR("Too many PSO results for %s",
+				ldb_dn_get_linearized(ac->search_res->message->dn));
+			talloc_free(ac->pso_res);
+		}
+
+		/* store the PSO result (we may need its lockout settings) */
+		ac->pso_res = talloc_steal(ac, ares);
+		ret = LDB_SUCCESS;
+		break;
+
+	case LDB_REPLY_REFERRAL:
+		/* ignore */
+		talloc_free(ares);
+		ret = LDB_SUCCESS;
+		break;
+
+	case LDB_REPLY_DONE:
+		talloc_free(ares);
+
+		/*
+		 * perform the next step of the modify operation (this code
+		 * shouldn't get called in the 'user add' case)
+		 */
+		if (ac->req->operation == LDB_MODIFY) {
+			ret = password_hash_mod_do_mod(ac);
+		} else {
+			ret = LDB_ERR_OPERATIONS_ERROR;
+		}
+		break;
+	}
+
+done:
+	if (ret != LDB_SUCCESS) {
+		struct ldb_reply *new_ares;
+
+		new_ares = talloc_zero(ac->req, struct ldb_reply);
+		if (new_ares == NULL) {
+			ldb_oom(ldb);
+			return ldb_module_done(ac->req, NULL, NULL,
+					       LDB_ERR_OPERATIONS_ERROR);
+		}
+
+		new_ares->error = ret;
+		if ((ret != LDB_ERR_OPERATIONS_ERROR) && (ac->change_status)) {
+			/* On success and trivial errors a status control is being
+			 * added (used for example by the "samdb_set_password" call) */
+			ldb_reply_add_control(new_ares,
+					      DSDB_CONTROL_PASSWORD_CHANGE_STATUS_OID,
+					      false,
+					      ac->status);
+		}
+
+		return ldb_module_done(ac->req, new_ares->controls,
+				       new_ares->response, new_ares->error);
+	}
+
+	return LDB_SUCCESS;
+}
+
+/*
+ * Builds and returns a search request to lookup up the PSO that applies to
+ * the user in question. Returns NULL if no PSO applies, or could not be found
+ */
+static struct ldb_request * build_pso_data_request(struct ph_context *ac)
+{
+	/* attrs[] is returned from this function in
+	   pso_req->op.search.attrs, so it must be static, as
+	   otherwise the compiler can put it on the stack */
+	static const char * const attrs[] = { "msDS-LockoutThreshold",
+					      "msDS-LockoutObservationWindow",
+					      NULL };
+	struct ldb_context *ldb = NULL;
+	struct ldb_request *pso_req = NULL;
+	struct ldb_dn *pso_dn = NULL;
+	TALLOC_CTX *mem_ctx = ac;
+	int ret;
+
+	ldb = ldb_module_get_ctx(ac->module);
+
+	/* if a PSO applies to the user, we need to lookup the PSO as well */
+	pso_dn = ldb_msg_find_attr_as_dn(ldb, mem_ctx, ac->search_res->message,
+					 "msDS-ResultantPSO");
+	if (pso_dn == NULL) {
+		return NULL;
+	}
+
+	ret = ldb_build_search_req(&pso_req, ldb, mem_ctx, pso_dn,
+				   LDB_SCOPE_BASE, NULL, attrs, NULL,
+				   ac, get_pso_data_callback,
+				   ac->dom_req);
+
+	/* log errors, but continue with the default domain settings */
+	if (ret != LDB_SUCCESS) {
+		DBG_ERR("Error %d constructing PSO query for user %s", ret,
+			ldb_dn_get_linearized(ac->search_res->message->dn));
+	}
+	LDB_REQ_SET_LOCATION(pso_req);
+	return pso_req;
+}
+
+
 static int get_domain_data_callback(struct ldb_request *req,
 				    struct ldb_reply *ares)
 {
 	struct ldb_context *ldb;
 	struct ph_context *ac;
 	struct loadparm_context *lp_ctx;
+	struct ldb_request *pso_req = NULL;
 	int ret = LDB_SUCCESS;
 
 	ac = talloc_get_type(req->context, struct ph_context);
@@ -3933,7 +4079,20 @@ static int get_domain_data_callback(struct ldb_request *req,
 			break;
 
 		case LDB_MODIFY:
-			ret = password_hash_mod_do_mod(ac);
+
+			/*
+			 * The user may have an optional PSO applied. If so,
+			 * query the PSO to get the Fine-Grained Password Policy
+			 * for the user, before we perform the modify
+			 */
+			pso_req = build_pso_data_request(ac);
+			if (pso_req != NULL) {
+				ret = ldb_next_request(ac->module, pso_req);
+			} else {
+
+				/* no PSO, so we can perform the modify now */
+				ret = password_hash_mod_do_mod(ac);
+			}
 			break;
 
 		default:
@@ -4495,6 +4654,7 @@ static int password_hash_mod_search_self(struct ph_context *ac)
 	struct ldb_context *ldb;
 	static const char * const attrs[] = { "objectClass",
 					      "userAccountControl",
+					      "msDS-ResultantPSO",
 					      "msDS-User-Account-Control-Computed",
 					      "pwdLastSet",
 					      "sAMAccountName",
-- 
2.7.4


From e2a76237c081ccaf55f68a58935770b88d6e9421 Mon Sep 17 00:00:00 2001
From: Tim Beale <timbeale at catalyst.net.nz>
Date: Thu, 19 Apr 2018 09:47:42 +1200
Subject: [PATCH 17/23] dsdb: Move anonymous domain_data struct

Anonymous structs and 80 character line-lengths don't mix well. Allow
the struct to be referenced directly.

With the introduction of PSOs, the password-settings are now calculated
per-user rather than per-domain. I've tried to reflect this in the
struct name.

Signed-off-by: Tim Beale <timbeale at catalyst.net.nz>
---
 source4/dsdb/samdb/samdb.h | 24 +++++++++++++-----------
 1 file changed, 13 insertions(+), 11 deletions(-)

diff --git a/source4/dsdb/samdb/samdb.h b/source4/dsdb/samdb/samdb.h
index a095858..326b96c 100644
--- a/source4/dsdb/samdb/samdb.h
+++ b/source4/dsdb/samdb/samdb.h
@@ -76,18 +76,20 @@ struct dsdb_control_current_partition {
 
 #define DSDB_CONTROL_PASSWORD_CHANGE_STATUS_OID "1.3.6.1.4.1.7165.4.3.8"
 
+struct dsdb_user_pwd_settings {
+	uint32_t pwdProperties;
+	uint32_t pwdHistoryLength;
+	int64_t maxPwdAge;
+	int64_t minPwdAge;
+	uint32_t minPwdLength;
+	bool store_cleartext;
+	const char *netbios_domain;
+	const char *dns_domain;
+	const char *realm;
+};
+
 struct dsdb_control_password_change_status {
-	struct {
-		uint32_t pwdProperties;
-		uint32_t pwdHistoryLength;
-		int64_t maxPwdAge;
-		int64_t minPwdAge;
-		uint32_t minPwdLength;
-		bool store_cleartext;
-		const char *netbios_domain;
-		const char *dns_domain;
-		const char *realm;
-	} domain_data;
+	struct dsdb_user_pwd_settings domain_data;
 	enum samPwdChangeReason reject_reason;
 };
 
-- 
2.7.4


From b90af503c7a49707ead4ef7a788e598b362f7649 Mon Sep 17 00:00:00 2001
From: Tim Beale <timbeale at catalyst.net.nz>
Date: Thu, 19 Apr 2018 10:46:48 +1200
Subject: [PATCH 18/23] dsdb: Update password_hash to use PSO settings for
 password changes

Honour the settings in the PSO when changing the password, i.e.
msDS-PasswordComplexityEnabled, msDS-PasswordHistoryLength, etc.

The password_hash code populates dsdb_control_password_change_status's
domain_data with the password settings to use - these are currently
based on the settings for the domain.

Now, if the password_hash code has worked out that a PSO applies to the
user, we override the domain settings with the PSO's values.

This change means the password_settings tests now pass.

Signed-off-by: Tim Beale <timbeale at catalyst.net.nz>
---
 selftest/knownfail.d/password_hash_gpgme       |  2 -
 selftest/knownfail.d/password_settings         |  6 ---
 source4/dsdb/samdb/ldb_modules/password_hash.c | 52 +++++++++++++++++++++++++-
 3 files changed, 51 insertions(+), 9 deletions(-)
 delete mode 100644 selftest/knownfail.d/password_hash_gpgme

diff --git a/selftest/knownfail.d/password_hash_gpgme b/selftest/knownfail.d/password_hash_gpgme
deleted file mode 100644
index a382714..0000000
--- a/selftest/knownfail.d/password_hash_gpgme
+++ /dev/null
@@ -1,2 +0,0 @@
-samba.tests.password_hash_gpgme.samba.tests.password_hash_gpgme.PassWordHashGpgmeTests.test_supplementalCredentials_cleartext_pso\(ad_dc:local\)
-
diff --git a/selftest/knownfail.d/password_settings b/selftest/knownfail.d/password_settings
index a0cf46f..b23f3f9 100644
--- a/selftest/knownfail.d/password_settings
+++ b/selftest/knownfail.d/password_settings
@@ -1,8 +1,2 @@
-samba4.ldap.password_settings.python.password_settings.PasswordSettingsTestCase.test_pso_basics\(ad_dc_ntvfs\)
-samba4.ldap.password_settings.python.password_settings.PasswordSettingsTestCase.test_pso_equal_precedence\(ad_dc_ntvfs\)
-samba4.ldap.password_settings.python.password_settings.PasswordSettingsTestCase.test_pso_nested_groups\(ad_dc_ntvfs\)
-samba4.ldap.password_settings.python.password_settings.PasswordSettingsTestCase.test_pso_special_groups\(ad_dc_ntvfs\)
-samba4.ldap.password_settings.python.password_settings.PasswordSettingsTestCase.test_pso_min_age\(ad_dc_ntvfs\)
 samba4.ldap.password_settings.python.password_settings.PasswordSettingsTestCase.test_pso_max_age\(ad_dc_ntvfs\)
-samba4.ldap.password_settings.python.password_settings.PasswordSettingsTestCase.test_pso_add_user\(ad_dc_ntvfs\)
 
diff --git a/source4/dsdb/samdb/ldb_modules/password_hash.c b/source4/dsdb/samdb/ldb_modules/password_hash.c
index 8f33795..f82c981 100644
--- a/source4/dsdb/samdb/ldb_modules/password_hash.c
+++ b/source4/dsdb/samdb/ldb_modules/password_hash.c
@@ -3855,6 +3855,9 @@ static int get_pso_data_callback(struct ldb_request *req,
 {
 	struct ldb_context *ldb = NULL;
 	struct ph_context *ac = NULL;
+	bool domain_complexity = true;
+	bool pso_complexity = true;
+	struct dsdb_user_pwd_settings *settings = NULL;
 	int ret = LDB_SUCCESS;
 
 	ac = talloc_get_type(req->context, struct ph_context);
@@ -3880,6 +3883,47 @@ static int get_pso_data_callback(struct ldb_request *req,
 			goto done;
 		}
 
+		/*
+		 * use the PSO's values instead of the domain defaults (the PSO
+		 * attributes should always exist, but use the domain default
+		 * values as a fallback).
+		 */
+		settings = &ac->status->domain_data;
+		settings->store_cleartext =
+			ldb_msg_find_attr_as_bool(ares->message,
+						  "msDS-PasswordReversibleEncryptionEnabled",
+						  settings->store_cleartext);
+
+		settings->pwdHistoryLength =
+			ldb_msg_find_attr_as_uint(ares->message,
+						  "msDS-PasswordHistoryLength",
+						  settings->pwdHistoryLength);
+		settings->maxPwdAge =
+			ldb_msg_find_attr_as_int64(ares->message,
+						   "msDS-MaximumPasswordAge",
+						   settings->maxPwdAge);
+		settings->minPwdAge =
+			ldb_msg_find_attr_as_int64(ares->message,
+						   "msDS-MinimumPasswordAge",
+						   settings->minPwdAge);
+		settings->minPwdLength =
+			ldb_msg_find_attr_as_uint(ares->message,
+						  "msDS-MinimumPasswordLength",
+						  settings->minPwdLength);
+		domain_complexity =
+			(settings->pwdProperties & DOMAIN_PASSWORD_COMPLEX);
+		pso_complexity =
+			ldb_msg_find_attr_as_bool(ares->message,
+						  "msDS-PasswordComplexityEnabled",
+						   domain_complexity);
+
+		/* set or clear the complexity bit if required */
+		if (pso_complexity && !domain_complexity) {
+			settings->pwdProperties |= DOMAIN_PASSWORD_COMPLEX;
+		} else if (domain_complexity && !pso_complexity) {
+			settings->pwdProperties &= ~DOMAIN_PASSWORD_COMPLEX;
+		}
+
 		if (ac->pso_res != NULL) {
 			DBG_ERR("Too many PSO results for %s",
 				ldb_dn_get_linearized(ac->search_res->message->dn));
@@ -3949,7 +3993,13 @@ static struct ldb_request * build_pso_data_request(struct ph_context *ac)
 	/* attrs[] is returned from this function in
 	   pso_req->op.search.attrs, so it must be static, as
 	   otherwise the compiler can put it on the stack */
-	static const char * const attrs[] = { "msDS-LockoutThreshold",
+	static const char * const attrs[] = { "msDS-PasswordComplexityEnabled",
+					      "msDS-PasswordReversibleEncryptionEnabled",
+					      "msDS-PasswordHistoryLength",
+					      "msDS-MaximumPasswordAge",
+					      "msDS-MinimumPasswordAge",
+					      "msDS-MinimumPasswordLength",
+					      "msDS-LockoutThreshold",
 					      "msDS-LockoutObservationWindow",
 					      NULL };
 	struct ldb_context *ldb = NULL;
-- 
2.7.4


From 643029f8cb4fe59dc787e79b991ea21314cac4cf Mon Sep 17 00:00:00 2001
From: Tim Beale <timbeale at catalyst.net.nz>
Date: Thu, 19 Apr 2018 13:51:36 +1200
Subject: [PATCH 19/23] dsdb: Use PSO maxPwdAge for operational
 msDS-PasswordExpiryTimeComputed

When calculating the Password-Expiry-Time, we should use the PSO's
max-password-age setting, if one applies to the user.

This is code may be inefficient, as it may repeat the PSO-lookup work
several times (once for each constructed attribute that tries to use
it). For now, I've gone for the simplest code change, and efficiency can
be addressed in a subsequent patch (once we have a good test to measure
it).

Signed-off-by: Tim Beale <timbeale at catalyst.net.nz>
---
 selftest/knownfail.d/password_settings       |  2 --
 source4/dsdb/samdb/ldb_modules/operational.c | 35 ++++++++++++++++++++++++++--
 2 files changed, 33 insertions(+), 4 deletions(-)
 delete mode 100644 selftest/knownfail.d/password_settings

diff --git a/selftest/knownfail.d/password_settings b/selftest/knownfail.d/password_settings
deleted file mode 100644
index b23f3f9..0000000
--- a/selftest/knownfail.d/password_settings
+++ /dev/null
@@ -1,2 +0,0 @@
-samba4.ldap.password_settings.python.password_settings.PasswordSettingsTestCase.test_pso_max_age\(ad_dc_ntvfs\)
-
diff --git a/source4/dsdb/samdb/ldb_modules/operational.c b/source4/dsdb/samdb/ldb_modules/operational.c
index 145c4be..d71ba2c 100644
--- a/source4/dsdb/samdb/ldb_modules/operational.c
+++ b/source4/dsdb/samdb/ldb_modules/operational.c
@@ -694,6 +694,35 @@ static int construct_msds_keyversionnumber(struct ldb_module *module,
 	_UF_TRUST_ACCOUNTS \
 )
 
+
+/*
+ * Returns the Effective-MaximumPasswordAge for a user
+ */
+static int64_t get_user_max_pwd_age(struct ldb_context *ldb,
+				    struct ldb_message *user_msg,
+				    struct ldb_dn *nc_root)
+{
+	int ret;
+	struct ldb_message *pso = NULL;
+
+	/* if a PSO applies to the user, use its maxPwdAge */
+	ret = get_pso_for_user(ldb, user_msg, &pso);
+	if (ret != LDB_SUCCESS) {
+
+		/* log the error, but fallback to the domain default */
+		DBG_ERR("Error retrieving PSO for %s",
+			ldb_dn_get_linearized(user_msg->dn));
+	}
+
+	if (pso != NULL) {
+		return ldb_msg_find_attr_as_int64(pso,
+					          "msDS-MaximumPasswordAge", 0);
+	}
+
+	/* otherwise return the default domain value */
+	return samdb_search_int64(ldb, user_msg, 0, nc_root, "maxPwdAge", NULL);
+}
+
 /*
   calculate msDS-UserPasswordExpiryTimeComputed
 */
@@ -741,8 +770,8 @@ static NTTIME get_msds_user_password_expiry_time_computed(struct ldb_module *mod
 	 * maxPwdAge: -9223372036854775808 (-0x8000000000000000ULL)
 	 *
 	 */
-	maxPwdAge = samdb_search_int64(ldb_module_get_ctx(module), msg, 0,
-				       domain_dn, "maxPwdAge", NULL);
+	maxPwdAge = get_user_max_pwd_age(ldb_module_get_ctx(module), msg,
+					 domain_dn);
 	if (maxPwdAge >= -864000000000) {
 		/*
 		 * This is not really possible...
@@ -987,6 +1016,7 @@ static int pso_search_by_sids(struct ldb_context *ldb, TALLOC_CTX *mem_ctx,
 		"msDS-PasswordSettingsPrecedence",
 		"objectGUID",
 		"msDS-LockoutDuration",
+		"msDS-MaximumPasswordAge",
 		NULL
 	};
 
@@ -1225,6 +1255,7 @@ static const char *user_account_control_computed_attrs[] =
 static const char *user_password_expiry_time_computed_attrs[] =
 {
 	"pwdLastSet",
+	PSO_ATTR_DEPENDENCIES,
 	NULL
 };
 
-- 
2.7.4


From 0b315c9aec8f97e104a085a83c7874288a2c8b3b Mon Sep 17 00:00:00 2001
From: Tim Beale <timbeale at catalyst.net.nz>
Date: Thu, 3 May 2018 11:48:21 +1200
Subject: [PATCH 20/23] netcmd: Small tweak to retrieving pwdProperties

Currently the 'samba-tool domain passwordsettings' command shares a
'set' and 'show' option, but there is very little common code between
the two. The only variable that's shared is pwd_props, but there's a
separate API we can use to get this. This allows us to split the command
into a super-command in a subsequent patch.

Fixed up erroneous comments while I'm at it.

Signed-off-by: Tim Beale <timbeale at catalyst.net.nz>
---
 python/samba/netcmd/domain.py | 5 +++--
 1 file changed, 3 insertions(+), 2 deletions(-)

diff --git a/python/samba/netcmd/domain.py b/python/samba/netcmd/domain.py
index da61711..8b34bfd 100644
--- a/python/samba/netcmd/domain.py
+++ b/python/samba/netcmd/domain.py
@@ -1367,6 +1367,7 @@ class cmd_domain_passwordsettings(Command):
             msgs = []
             m = ldb.Message()
             m.dn = ldb.Dn(samdb, domain_dn)
+            pwd_props = int(samdb.get_pwdProperties())
 
             if complexity is not None:
                 if complexity == "on" or complexity == "default":
@@ -1458,7 +1459,7 @@ class cmd_domain_passwordsettings(Command):
                 if account_lockout_duration < 0 or account_lockout_duration > 99999:
                     raise CommandError("Maximum password age must be in the range of 0 to 99999!")
 
-                # days -> ticks
+                # minutes -> ticks
                 if account_lockout_duration == 0:
                     account_lockout_duration_ticks = -0x8000000000000000
                 else:
@@ -1487,7 +1488,7 @@ class cmd_domain_passwordsettings(Command):
                 if reset_account_lockout_after < 0 or reset_account_lockout_after > 99999:
                     raise CommandError("Maximum password age must be in the range of 0 to 99999!")
 
-                # days -> ticks
+                # minutes -> ticks
                 if reset_account_lockout_after == 0:
                     reset_account_lockout_after_ticks = -0x8000000000000000
                 else:
-- 
2.7.4


From df7c42d10b81b962605f01398737bbe59b87f686 Mon Sep 17 00:00:00 2001
From: Tim Beale <timbeale at catalyst.net.nz>
Date: Thu, 3 May 2018 12:12:04 +1200
Subject: [PATCH 21/23] netcmd: Split 'domain passwordsettings' into a
 super-command

The show and set options are not really related to each other at all, so
it makes sense to split the code into 2 separate commands.

We also want to add separate sub-commands for PSOs in a subsequent
patch.

Because of the way the sub-command was implemented previously, it meant
that you could specify other command-line options before the 'set' or
'show' keyword, and the command would still be accepted. However, now
that it's a super-command 'set'/'show' needs to be specified before any
additional arguments, so we need to update the test code to reflect
this.

Signed-off-by: Tim Beale <timbeale at catalyst.net.nz>
---
 python/samba/netcmd/domain.py                | 356 ++++++++++++++-------------
 source4/setup/tests/blackbox_setpassword.sh  |   2 +-
 testprogs/blackbox/test_kinit_heimdal.sh     |   4 +-
 testprogs/blackbox/test_kinit_mit.sh         |   4 +-
 testprogs/blackbox/test_kpasswd_heimdal.sh   |   4 +-
 testprogs/blackbox/test_kpasswd_mit.sh       |   4 +-
 testprogs/blackbox/test_password_settings.sh |  10 +-
 7 files changed, 204 insertions(+), 180 deletions(-)

diff --git a/python/samba/netcmd/domain.py b/python/samba/netcmd/domain.py
index 8b34bfd..cb2b1cc 100644
--- a/python/samba/netcmd/domain.py
+++ b/python/samba/netcmd/domain.py
@@ -1263,8 +1263,74 @@ class cmd_domain_level(Command):
         else:
             raise CommandError("invalid argument: '%s' (choose from 'show', 'raise')" % subcommand)
 
+class cmd_domain_passwordsettings_show(Command):
+    """Display current password settings for the domain."""
 
-class cmd_domain_passwordsettings(Command):
+    synopsis = "%prog [options]"
+
+    takes_optiongroups = {
+        "sambaopts": options.SambaOptions,
+        "versionopts": options.VersionOptions,
+        "credopts": options.CredentialsOptions,
+        }
+
+    takes_options = [
+        Option("-H", "--URL", help="LDB URL for database or target server", type=str,
+               metavar="URL", dest="H"),
+          ]
+
+    def run(self, H=None, credopts=None, sambaopts=None, versionopts=None):
+        lp = sambaopts.get_loadparm()
+        creds = credopts.get_credentials(lp)
+
+        samdb = SamDB(url=H, session_info=system_session(),
+            credentials=creds, lp=lp)
+
+        domain_dn = samdb.domain_dn()
+        res = samdb.search(domain_dn, scope=ldb.SCOPE_BASE,
+          attrs=["pwdProperties", "pwdHistoryLength", "minPwdLength",
+                 "minPwdAge", "maxPwdAge", "lockoutDuration", "lockoutThreshold",
+                 "lockOutObservationWindow"])
+        assert(len(res) == 1)
+        try:
+            pwd_props = int(res[0]["pwdProperties"][0])
+            pwd_hist_len = int(res[0]["pwdHistoryLength"][0])
+            cur_min_pwd_len = int(res[0]["minPwdLength"][0])
+            # ticks -> days
+            cur_min_pwd_age = int(abs(int(res[0]["minPwdAge"][0])) / (1e7 * 60 * 60 * 24))
+            if int(res[0]["maxPwdAge"][0]) == -0x8000000000000000:
+                cur_max_pwd_age = 0
+            else:
+                cur_max_pwd_age = int(abs(int(res[0]["maxPwdAge"][0])) / (1e7 * 60 * 60 * 24))
+            cur_account_lockout_threshold = int(res[0]["lockoutThreshold"][0])
+            # ticks -> mins
+            if int(res[0]["lockoutDuration"][0]) == -0x8000000000000000:
+                cur_account_lockout_duration = 0
+            else:
+                cur_account_lockout_duration = abs(int(res[0]["lockoutDuration"][0])) / (1e7 * 60)
+            cur_reset_account_lockout_after = abs(int(res[0]["lockOutObservationWindow"][0])) / (1e7 * 60)
+        except Exception as e:
+            raise CommandError("Could not retrieve password properties!", e)
+
+        self.message("Password informations for domain '%s'" % domain_dn)
+        self.message("")
+        if pwd_props & DOMAIN_PASSWORD_COMPLEX != 0:
+            self.message("Password complexity: on")
+        else:
+            self.message("Password complexity: off")
+        if pwd_props & DOMAIN_PASSWORD_STORE_CLEARTEXT != 0:
+            self.message("Store plaintext passwords: on")
+        else:
+            self.message("Store plaintext passwords: off")
+        self.message("Password history length: %d" % pwd_hist_len)
+        self.message("Minimum password length: %d" % cur_min_pwd_len)
+        self.message("Minimum password age (days): %d" % cur_min_pwd_age)
+        self.message("Maximum password age (days): %d" % cur_max_pwd_age)
+        self.message("Account lockout duration (mins): %d" % cur_account_lockout_duration)
+        self.message("Account lockout threshold (attempts): %d" % cur_account_lockout_threshold)
+        self.message("Reset account lockout after (mins): %d" % cur_reset_account_lockout_after)
+
+class cmd_domain_passwordsettings_set(Command):
     """Set password settings.
 
     Password complexity, password lockout policy, history length,
@@ -1274,7 +1340,7 @@ class cmd_domain_passwordsettings(Command):
     Use against a Windows DC is possible, but group policy will override it.
     """
 
-    synopsis = "%prog (show|set <options>) [options]"
+    synopsis = "%prog <options> [options]"
 
     takes_optiongroups = {
         "sambaopts": options.SambaOptions,
@@ -1306,9 +1372,7 @@ class cmd_domain_passwordsettings(Command):
           help="After this time is elapsed, the recorded number of attempts restarts from zero (<integer> | default).  Default is 30.", type=str),
           ]
 
-    takes_args = ["subcommand"]
-
-    def run(self, subcommand, H=None, min_pwd_age=None, max_pwd_age=None,
+    def run(self, H=None, min_pwd_age=None, max_pwd_age=None,
             quiet=False, complexity=None, store_plaintext=None, history_length=None,
             min_pwd_length=None, account_lockout_duration=None, account_lockout_threshold=None,
             reset_account_lockout_after=None, credopts=None, sambaopts=None,
@@ -1320,195 +1384,155 @@ class cmd_domain_passwordsettings(Command):
             credentials=creds, lp=lp)
 
         domain_dn = samdb.domain_dn()
-        res = samdb.search(domain_dn, scope=ldb.SCOPE_BASE,
-          attrs=["pwdProperties", "pwdHistoryLength", "minPwdLength",
-                 "minPwdAge", "maxPwdAge", "lockoutDuration", "lockoutThreshold",
-                 "lockOutObservationWindow"])
-        assert(len(res) == 1)
-        try:
-            pwd_props = int(res[0]["pwdProperties"][0])
-            pwd_hist_len = int(res[0]["pwdHistoryLength"][0])
-            cur_min_pwd_len = int(res[0]["minPwdLength"][0])
-            # ticks -> days
-            cur_min_pwd_age = int(abs(int(res[0]["minPwdAge"][0])) / (1e7 * 60 * 60 * 24))
-            if int(res[0]["maxPwdAge"][0]) == -0x8000000000000000:
-                cur_max_pwd_age = 0
+        msgs = []
+        m = ldb.Message()
+        m.dn = ldb.Dn(samdb, domain_dn)
+        pwd_props = int(samdb.get_pwdProperties())
+
+        if complexity is not None:
+            if complexity == "on" or complexity == "default":
+                pwd_props = pwd_props | DOMAIN_PASSWORD_COMPLEX
+                msgs.append("Password complexity activated!")
+            elif complexity == "off":
+                pwd_props = pwd_props & (~DOMAIN_PASSWORD_COMPLEX)
+                msgs.append("Password complexity deactivated!")
+
+        if store_plaintext is not None:
+            if store_plaintext == "on" or store_plaintext == "default":
+                pwd_props = pwd_props | DOMAIN_PASSWORD_STORE_CLEARTEXT
+                msgs.append("Plaintext password storage for changed passwords activated!")
+            elif store_plaintext == "off":
+                pwd_props = pwd_props & (~DOMAIN_PASSWORD_STORE_CLEARTEXT)
+                msgs.append("Plaintext password storage for changed passwords deactivated!")
+
+        if complexity is not None or store_plaintext is not None:
+            m["pwdProperties"] = ldb.MessageElement(str(pwd_props),
+              ldb.FLAG_MOD_REPLACE, "pwdProperties")
+
+        if history_length is not None:
+            if history_length == "default":
+                pwd_hist_len = 24
             else:
-                cur_max_pwd_age = int(abs(int(res[0]["maxPwdAge"][0])) / (1e7 * 60 * 60 * 24))
-            cur_account_lockout_threshold = int(res[0]["lockoutThreshold"][0])
-            # ticks -> mins
-            if int(res[0]["lockoutDuration"][0]) == -0x8000000000000000:
-                cur_account_lockout_duration = 0
-            else:
-                cur_account_lockout_duration = abs(int(res[0]["lockoutDuration"][0])) / (1e7 * 60)
-            cur_reset_account_lockout_after = abs(int(res[0]["lockOutObservationWindow"][0])) / (1e7 * 60)
-        except Exception as e:
-            raise CommandError("Could not retrieve password properties!", e)
+                pwd_hist_len = int(history_length)
 
-        if subcommand == "show":
-            self.message("Password informations for domain '%s'" % domain_dn)
-            self.message("")
-            if pwd_props & DOMAIN_PASSWORD_COMPLEX != 0:
-                self.message("Password complexity: on")
-            else:
-                self.message("Password complexity: off")
-            if pwd_props & DOMAIN_PASSWORD_STORE_CLEARTEXT != 0:
-                self.message("Store plaintext passwords: on")
-            else:
-                self.message("Store plaintext passwords: off")
-            self.message("Password history length: %d" % pwd_hist_len)
-            self.message("Minimum password length: %d" % cur_min_pwd_len)
-            self.message("Minimum password age (days): %d" % cur_min_pwd_age)
-            self.message("Maximum password age (days): %d" % cur_max_pwd_age)
-            self.message("Account lockout duration (mins): %d" % cur_account_lockout_duration)
-            self.message("Account lockout threshold (attempts): %d" % cur_account_lockout_threshold)
-            self.message("Reset account lockout after (mins): %d" % cur_reset_account_lockout_after)
-        elif subcommand == "set":
-            msgs = []
-            m = ldb.Message()
-            m.dn = ldb.Dn(samdb, domain_dn)
-            pwd_props = int(samdb.get_pwdProperties())
-
-            if complexity is not None:
-                if complexity == "on" or complexity == "default":
-                    pwd_props = pwd_props | DOMAIN_PASSWORD_COMPLEX
-                    msgs.append("Password complexity activated!")
-                elif complexity == "off":
-                    pwd_props = pwd_props & (~DOMAIN_PASSWORD_COMPLEX)
-                    msgs.append("Password complexity deactivated!")
-
-            if store_plaintext is not None:
-                if store_plaintext == "on" or store_plaintext == "default":
-                    pwd_props = pwd_props | DOMAIN_PASSWORD_STORE_CLEARTEXT
-                    msgs.append("Plaintext password storage for changed passwords activated!")
-                elif store_plaintext == "off":
-                    pwd_props = pwd_props & (~DOMAIN_PASSWORD_STORE_CLEARTEXT)
-                    msgs.append("Plaintext password storage for changed passwords deactivated!")
-
-            if complexity is not None or store_plaintext is not None:
-                m["pwdProperties"] = ldb.MessageElement(str(pwd_props),
-                  ldb.FLAG_MOD_REPLACE, "pwdProperties")
-
-            if history_length is not None:
-                if history_length == "default":
-                    pwd_hist_len = 24
-                else:
-                    pwd_hist_len = int(history_length)
+            if pwd_hist_len < 0 or pwd_hist_len > 24:
+                raise CommandError("Password history length must be in the range of 0 to 24!")
 
-                if pwd_hist_len < 0 or pwd_hist_len > 24:
-                    raise CommandError("Password history length must be in the range of 0 to 24!")
+            m["pwdHistoryLength"] = ldb.MessageElement(str(pwd_hist_len),
+              ldb.FLAG_MOD_REPLACE, "pwdHistoryLength")
+            msgs.append("Password history length changed!")
 
-                m["pwdHistoryLength"] = ldb.MessageElement(str(pwd_hist_len),
-                  ldb.FLAG_MOD_REPLACE, "pwdHistoryLength")
-                msgs.append("Password history length changed!")
+        if min_pwd_length is not None:
+            if min_pwd_length == "default":
+                min_pwd_len = 7
+            else:
+                min_pwd_len = int(min_pwd_length)
 
-            if min_pwd_length is not None:
-                if min_pwd_length == "default":
-                    min_pwd_len = 7
-                else:
-                    min_pwd_len = int(min_pwd_length)
+            if min_pwd_len < 0 or min_pwd_len > 14:
+                raise CommandError("Minimum password length must be in the range of 0 to 14!")
 
-                if min_pwd_len < 0 or min_pwd_len > 14:
-                    raise CommandError("Minimum password length must be in the range of 0 to 14!")
+            m["minPwdLength"] = ldb.MessageElement(str(min_pwd_len),
+              ldb.FLAG_MOD_REPLACE, "minPwdLength")
+            msgs.append("Minimum password length changed!")
 
-                m["minPwdLength"] = ldb.MessageElement(str(min_pwd_len),
-                  ldb.FLAG_MOD_REPLACE, "minPwdLength")
-                msgs.append("Minimum password length changed!")
+        if min_pwd_age is not None:
+            if min_pwd_age == "default":
+                min_pwd_age = 1
+            else:
+                min_pwd_age = int(min_pwd_age)
 
-            if min_pwd_age is not None:
-                if min_pwd_age == "default":
-                    min_pwd_age = 1
-                else:
-                    min_pwd_age = int(min_pwd_age)
+            if min_pwd_age < 0 or min_pwd_age > 998:
+                raise CommandError("Minimum password age must be in the range of 0 to 998!")
 
-                if min_pwd_age < 0 or min_pwd_age > 998:
-                    raise CommandError("Minimum password age must be in the range of 0 to 998!")
+            # days -> ticks
+            min_pwd_age_ticks = -int(min_pwd_age * (24 * 60 * 60 * 1e7))
 
-                # days -> ticks
-                min_pwd_age_ticks = -int(min_pwd_age * (24 * 60 * 60 * 1e7))
+            m["minPwdAge"] = ldb.MessageElement(str(min_pwd_age_ticks),
+              ldb.FLAG_MOD_REPLACE, "minPwdAge")
+            msgs.append("Minimum password age changed!")
 
-                m["minPwdAge"] = ldb.MessageElement(str(min_pwd_age_ticks),
-                  ldb.FLAG_MOD_REPLACE, "minPwdAge")
-                msgs.append("Minimum password age changed!")
+        if max_pwd_age is not None:
+            if max_pwd_age == "default":
+                max_pwd_age = 43
+            else:
+                max_pwd_age = int(max_pwd_age)
 
-            if max_pwd_age is not None:
-                if max_pwd_age == "default":
-                    max_pwd_age = 43
-                else:
-                    max_pwd_age = int(max_pwd_age)
+            if max_pwd_age < 0 or max_pwd_age > 999:
+                raise CommandError("Maximum password age must be in the range of 0 to 999!")
 
-                if max_pwd_age < 0 or max_pwd_age > 999:
-                    raise CommandError("Maximum password age must be in the range of 0 to 999!")
+            # days -> ticks
+            if max_pwd_age == 0:
+                max_pwd_age_ticks = -0x8000000000000000
+            else:
+                max_pwd_age_ticks = -int(max_pwd_age * (24 * 60 * 60 * 1e7))
 
-                # days -> ticks
-                if max_pwd_age == 0:
-                    max_pwd_age_ticks = -0x8000000000000000
-                else:
-                    max_pwd_age_ticks = -int(max_pwd_age * (24 * 60 * 60 * 1e7))
+            m["maxPwdAge"] = ldb.MessageElement(str(max_pwd_age_ticks),
+              ldb.FLAG_MOD_REPLACE, "maxPwdAge")
+            msgs.append("Maximum password age changed!")
 
-                m["maxPwdAge"] = ldb.MessageElement(str(max_pwd_age_ticks),
-                  ldb.FLAG_MOD_REPLACE, "maxPwdAge")
-                msgs.append("Maximum password age changed!")
+        if account_lockout_duration is not None:
+            if account_lockout_duration == "default":
+                account_lockout_duration = 30
+            else:
+                account_lockout_duration = int(account_lockout_duration)
 
-            if account_lockout_duration is not None:
-                if account_lockout_duration == "default":
-                    account_lockout_duration = 30
-                else:
-                    account_lockout_duration = int(account_lockout_duration)
+            if account_lockout_duration < 0 or account_lockout_duration > 99999:
+                raise CommandError("Maximum password age must be in the range of 0 to 99999!")
 
-                if account_lockout_duration < 0 or account_lockout_duration > 99999:
-                    raise CommandError("Maximum password age must be in the range of 0 to 99999!")
+            # minutes -> ticks
+            if account_lockout_duration == 0:
+                account_lockout_duration_ticks = -0x8000000000000000
+            else:
+                account_lockout_duration_ticks = -int(account_lockout_duration * (60 * 1e7))
 
-                # minutes -> ticks
-                if account_lockout_duration == 0:
-                    account_lockout_duration_ticks = -0x8000000000000000
-                else:
-                    account_lockout_duration_ticks = -int(account_lockout_duration * (60 * 1e7))
+            m["lockoutDuration"] = ldb.MessageElement(str(account_lockout_duration_ticks),
+              ldb.FLAG_MOD_REPLACE, "lockoutDuration")
+            msgs.append("Account lockout duration changed!")
 
-                m["lockoutDuration"] = ldb.MessageElement(str(account_lockout_duration_ticks),
-                  ldb.FLAG_MOD_REPLACE, "lockoutDuration")
-                msgs.append("Account lockout duration changed!")
+        if account_lockout_threshold is not None:
+            if account_lockout_threshold == "default":
+                account_lockout_threshold = 0
+            else:
+                account_lockout_threshold = int(account_lockout_threshold)
 
-            if account_lockout_threshold is not None:
-                if account_lockout_threshold == "default":
-                    account_lockout_threshold = 0
-                else:
-                    account_lockout_threshold = int(account_lockout_threshold)
+            m["lockoutThreshold"] = ldb.MessageElement(str(account_lockout_threshold),
+              ldb.FLAG_MOD_REPLACE, "lockoutThreshold")
+            msgs.append("Account lockout threshold changed!")
 
-                m["lockoutThreshold"] = ldb.MessageElement(str(account_lockout_threshold),
-                  ldb.FLAG_MOD_REPLACE, "lockoutThreshold")
-                msgs.append("Account lockout threshold changed!")
+        if reset_account_lockout_after is not None:
+            if reset_account_lockout_after == "default":
+                reset_account_lockout_after = 30
+            else:
+                reset_account_lockout_after = int(reset_account_lockout_after)
 
-            if reset_account_lockout_after is not None:
-                if reset_account_lockout_after == "default":
-                    reset_account_lockout_after = 30
-                else:
-                    reset_account_lockout_after = int(reset_account_lockout_after)
+            if reset_account_lockout_after < 0 or reset_account_lockout_after > 99999:
+                raise CommandError("Maximum password age must be in the range of 0 to 99999!")
 
-                if reset_account_lockout_after < 0 or reset_account_lockout_after > 99999:
-                    raise CommandError("Maximum password age must be in the range of 0 to 99999!")
+            # minutes -> ticks
+            if reset_account_lockout_after == 0:
+                reset_account_lockout_after_ticks = -0x8000000000000000
+            else:
+                reset_account_lockout_after_ticks = -int(reset_account_lockout_after * (60 * 1e7))
 
-                # minutes -> ticks
-                if reset_account_lockout_after == 0:
-                    reset_account_lockout_after_ticks = -0x8000000000000000
-                else:
-                    reset_account_lockout_after_ticks = -int(reset_account_lockout_after * (60 * 1e7))
+            m["lockOutObservationWindow"] = ldb.MessageElement(str(reset_account_lockout_after_ticks),
+              ldb.FLAG_MOD_REPLACE, "lockOutObservationWindow")
+            msgs.append("Duration to reset account lockout after changed!")
 
-                m["lockOutObservationWindow"] = ldb.MessageElement(str(reset_account_lockout_after_ticks),
-                  ldb.FLAG_MOD_REPLACE, "lockOutObservationWindow")
-                msgs.append("Duration to reset account lockout after changed!")
+        if max_pwd_age > 0 and min_pwd_age >= max_pwd_age:
+            raise CommandError("Maximum password age (%d) must be greater than minimum password age (%d)!" % (max_pwd_age, min_pwd_age))
 
-            if max_pwd_age > 0 and min_pwd_age >= max_pwd_age:
-                raise CommandError("Maximum password age (%d) must be greater than minimum password age (%d)!" % (max_pwd_age, min_pwd_age))
+        if len(m) == 0:
+            raise CommandError("You must specify at least one option to set. Try --help")
+        samdb.modify(m)
+        msgs.append("All changes applied successfully!")
+        self.message("\n".join(msgs))
 
-            if len(m) == 0:
-                raise CommandError("You must specify at least one option to set. Try --help")
-            samdb.modify(m)
-            msgs.append("All changes applied successfully!")
-            self.message("\n".join(msgs))
-        else:
-            raise CommandError("Wrong argument '%s'!" % subcommand)
+class cmd_domain_passwordsettings(SuperCommand):
+    """Manage password policy settings."""
 
+    subcommands = {}
+    subcommands["show"] = cmd_domain_passwordsettings_show()
+    subcommands["set"] = cmd_domain_passwordsettings_set()
 
 class cmd_domain_classicupgrade(Command):
     """Upgrade from Samba classic (NT4-like) database to Samba AD DC database.
diff --git a/source4/setup/tests/blackbox_setpassword.sh b/source4/setup/tests/blackbox_setpassword.sh
index 33fbe8b..8055740 100755
--- a/source4/setup/tests/blackbox_setpassword.sh
+++ b/source4/setup/tests/blackbox_setpassword.sh
@@ -25,6 +25,6 @@ testit "setpassword" $samba_tool user setpassword --configfile=$PREFIX/simple-dc
 
 testit "setpassword" $samba_tool user setpassword --configfile=$PREFIX/simple-dc/etc/smb.conf testuser --newpassword=testp at ssw0Rd --must-change-at-next-login
 
-testit "passwordsettings" $samba_tool domain passwordsettings --quiet set --configfile=$PREFIX/simple-dc/etc/smb.conf --complexity=default --history-length=default --min-pwd-length=default --min-pwd-age=default --max-pwd-age=default --store-plaintext=on
+testit "passwordsettings" $samba_tool domain passwordsettings set --quiet --configfile=$PREFIX/simple-dc/etc/smb.conf --complexity=default --history-length=default --min-pwd-length=default --min-pwd-age=default --max-pwd-age=default --store-plaintext=on
 
 exit $failed
diff --git a/testprogs/blackbox/test_kinit_heimdal.sh b/testprogs/blackbox/test_kinit_heimdal.sh
index 7babb25..0d8a62f 100755
--- a/testprogs/blackbox/test_kinit_heimdal.sh
+++ b/testprogs/blackbox/test_kinit_heimdal.sh
@@ -63,7 +63,7 @@ ADMIN_KRB5CCNAME="FILE:$KRB5CCNAME_PATH"
 export KRB5CCNAME
 rm -rf $KRB5CCNAME_PATH
 
-testit "reset password policies beside of minimum password age of 0 days" $VALGRIND $samba_tool domain passwordsettings $ADMIN_LDBMODIFY_CONFIG set --complexity=default --history-length=default --min-pwd-length=default --min-pwd-age=0 --max-pwd-age=default || failed=`expr $failed + 1`
+testit "reset password policies beside of minimum password age of 0 days" $VALGRIND $samba_tool domain passwordsettings set $ADMIN_LDBMODIFY_CONFIG --complexity=default --history-length=default --min-pwd-length=default --min-pwd-age=0 --max-pwd-age=default || failed=`expr $failed + 1`
 
 echo $PASSWORD > $PREFIX/tmppassfile
 testit "kinit with password" $samba4kinit $enctype --password-file=$PREFIX/tmppassfile --request-pac $USERNAME@$REALM   || failed=`expr $failed + 1`
@@ -253,7 +253,7 @@ rm -f $KRB5CCNAME_PATH
 testit "kinit with machineaccountccache script" $machineaccountccache $CONFIGURATION $KRB5CCNAME || failed=`expr $failed + 1`
 test_smbclient "Test machine account login with kerberos ccache" 'ls' "$unc" -k yes || failed=`expr $failed + 1`
 
-testit "reset password policies" $VALGRIND $samba_tool domain passwordsettings $ADMIN_LDBMODIFY_CONFIG set --complexity=default --history-length=default --min-pwd-length=default --min-pwd-age=default --max-pwd-age=default || failed=`expr $failed + 1`
+testit "reset password policies" $VALGRIND $samba_tool domain passwordsettings set $ADMIN_LDBMODIFY_CONFIG --complexity=default --history-length=default --min-pwd-length=default --min-pwd-age=default --max-pwd-age=default || failed=`expr $failed + 1`
 
 rm -f $PREFIX/tmpccache tmpccfile tmppassfile tmpuserpassfile tmpuserccache tmpkpasswdscript
 exit $failed
diff --git a/testprogs/blackbox/test_kinit_mit.sh b/testprogs/blackbox/test_kinit_mit.sh
index d178ba4..dabf991 100755
--- a/testprogs/blackbox/test_kinit_mit.sh
+++ b/testprogs/blackbox/test_kinit_mit.sh
@@ -68,7 +68,7 @@ ADMIN_KRB5CCNAME="FILE:$KRB5CCNAME_PATH"
 export KRB5CCNAME
 rm -rf $KRB5CCNAME_PATH
 
-testit "reset password policies beside of minimum password age of 0 days" $VALGRIND $samba_tool domain passwordsettings $ADMIN_LDBMODIFY_CONFIG set --complexity=default --history-length=default --min-pwd-length=default --min-pwd-age=0 --max-pwd-age=default || failed=`expr $failed + 1`
+testit "reset password policies beside of minimum password age of 0 days" $VALGRIND $samba_tool domain passwordsettings set $ADMIN_LDBMODIFY_CONFIG --complexity=default --history-length=default --min-pwd-length=default --min-pwd-age=0 --max-pwd-age=default || failed=`expr $failed + 1`
 
 cat > $PREFIX/tmpkinitscript <<EOF
 expect Password for
@@ -297,7 +297,7 @@ rm -f $KRB5CCNAME_PATH
 testit "kinit with machineaccountccache script" $machineaccountccache $CONFIGURATION $KRB5CCNAME || failed=`expr $failed + 1`
 test_smbclient "Test machine account login with kerberos ccache" 'ls' -k yes || failed=`expr $failed + 1`
 
-testit "reset password policies" $VALGRIND $samba_tool domain passwordsettings $ADMIN_LDBMODIFY_CONFIG set --complexity=default --history-length=default --min-pwd-length=default --min-pwd-age=default --max-pwd-age=default || failed=`expr $failed + 1`
+testit "reset password policies" $VALGRIND $samba_tool domain passwordsettings set $ADMIN_LDBMODIFY_CONFIG --complexity=default --history-length=default --min-pwd-length=default --min-pwd-age=default --max-pwd-age=default || failed=`expr $failed + 1`
 
 ### Cleanup
 
diff --git a/testprogs/blackbox/test_kpasswd_heimdal.sh b/testprogs/blackbox/test_kpasswd_heimdal.sh
index 5cb6dab..2e0b311 100755
--- a/testprogs/blackbox/test_kpasswd_heimdal.sh
+++ b/testprogs/blackbox/test_kpasswd_heimdal.sh
@@ -53,7 +53,7 @@ CONFIG="--configfile=$PREFIX/etc/smb.conf"
 export CONFIG
 
 testit "reset password policies beside of minimum password age of 0 days" \
-	$VALGRIND $samba_tool domain passwordsettings $CONFIG set --complexity=default --history-length=default --min-pwd-length=default --min-pwd-age=0 --max-pwd-age=default || failed=`expr $failed + 1`
+	$VALGRIND $samba_tool domain passwordsettings set $CONFIG --complexity=default --history-length=default --min-pwd-length=default --min-pwd-age=0 --max-pwd-age=default || failed=`expr $failed + 1`
 
 TEST_USERNAME="$(mktemp -u alice-XXXXXX)"
 TEST_PRINCIPAL="$TEST_USERNAME@$REALM"
@@ -208,7 +208,7 @@ test_smbclient "Test login with smbclient (ntlm)" \
 ###########################################################
 
 testit "reset password policies" \
-	$VALGRIND $samba_tool domain passwordsettings $CONFIG set --complexity=default --history-length=default --min-pwd-length=default --min-pwd-age=default --max-pwd-age=default || failed=`expr $failed + 1`
+	$VALGRIND $samba_tool domain passwordsettings set $CONFIG --complexity=default --history-length=default --min-pwd-length=default --min-pwd-age=default --max-pwd-age=default || failed=`expr $failed + 1`
 
 testit "delete user" \
 	$VALGRIND $samba_tool user delete $TEST_USERNAME -U"$USERNAME%$PASSWORD" $CONFIG -k no  || failed=`expr $failed + 1`
diff --git a/testprogs/blackbox/test_kpasswd_mit.sh b/testprogs/blackbox/test_kpasswd_mit.sh
index d08367f..97f0eaa 100755
--- a/testprogs/blackbox/test_kpasswd_mit.sh
+++ b/testprogs/blackbox/test_kpasswd_mit.sh
@@ -52,7 +52,7 @@ CONFIG="--configfile=$PREFIX/etc/smb.conf"
 export CONFIG
 
 testit "reset password policies beside of minimum password age of 0 days" \
-	$VALGRIND $samba_tool domain passwordsettings $CONFIG set --complexity=default --history-length=default --min-pwd-length=default --min-pwd-age=0 --max-pwd-age=default || failed=`expr $failed + 1`
+	$VALGRIND $samba_tool domain passwordsettings set $CONFIG --complexity=default --history-length=default --min-pwd-length=default --min-pwd-age=0 --max-pwd-age=default || failed=`expr $failed + 1`
 
 TEST_USERNAME="samson"
 TEST_PASSWORD="testPaSS at 00%"
@@ -219,7 +219,7 @@ test_smbclient "Test login with smbclient (ntlm)" \
 ###########################################################
 
 testit "reset password policies" \
-	$VALGRIND $samba_tool domain passwordsettings $CONFIG set --complexity=default --history-length=default --min-pwd-length=default --min-pwd-age=default --max-pwd-age=default || failed=`expr $failed + 1`
+	$VALGRIND $samba_tool domain passwordsettings set $CONFIG --complexity=default --history-length=default --min-pwd-length=default --min-pwd-age=default --max-pwd-age=default || failed=`expr $failed + 1`
 
 testit "delete user" \
 	$VALGRIND $samba_tool user delete $TEST_USERNAME -U"$USERNAME%$PASSWORD" $CONFIG -k no  || failed=`expr $failed + 1`
diff --git a/testprogs/blackbox/test_password_settings.sh b/testprogs/blackbox/test_password_settings.sh
index 97adb3c..49d5126 100755
--- a/testprogs/blackbox/test_password_settings.sh
+++ b/testprogs/blackbox/test_password_settings.sh
@@ -59,7 +59,7 @@ CONFIG="--configfile=$PREFIX/etc/smb.conf"
 export CONFIG
 
 testit "reset password policies beside of minimum password age of 0 days" \
-	$VALGRIND $samba_tool domain passwordsettings $CONFIG set --complexity=default --history-length=default --min-pwd-length=default --min-pwd-age=0 --max-pwd-age=default || failed=`expr $failed + 1`
+	$VALGRIND $samba_tool domain passwordsettings set $CONFIG --complexity=default --history-length=default --min-pwd-length=default --min-pwd-age=0 --max-pwd-age=default || failed=`expr $failed + 1`
 
 TEST_USERNAME="$(mktemp -u alice-XXXXXX)"
 TEST_PASSWORD="testPaSS at 00%"
@@ -194,7 +194,7 @@ testit_expect_failure "try to set a short password (command should not succeed)"
 	$VALGRIND $samba_tool user password -W$DOMAIN "-U$DOMAIN/$TEST_USERNAME%$TEST_PASSWORD" -k no --newpassword="$TEST_PASSWORD_SHORT" && failed=`expr $failed + 1`
 
 testit "allow short passwords (length 1)" \
-	$VALGRIND $samba_tool domain passwordsettings $CONFIG set --min-pwd-length=1 || failed=`expr $failed + 1`
+	$VALGRIND $samba_tool domain passwordsettings set $CONFIG --min-pwd-length=1 || failed=`expr $failed + 1`
 
 testit "try to set a short password (command should succeed)" \
 	$VALGRIND $samba_tool user password -W$DOMAIN "-U$DOMAIN/$TEST_USERNAME%$TEST_PASSWORD" -k no --newpassword="$TEST_PASSWORD_SHORT" || failed=`expr $failed + 1`
@@ -203,16 +203,16 @@ TEST_PASSWORD=$TEST_PASSWORD_SHORT
 TEST_PASSWORD_NEW="testPaSS at 07%"
 
 testit "require minimum password age of 1 day" \
-	$VALGRIND $samba_tool domain passwordsettings $CONFIG set --min-pwd-age=1 || failed=`expr $failed + 1`
+	$VALGRIND $samba_tool domain passwordsettings set $CONFIG --min-pwd-age=1 || failed=`expr $failed + 1`
 
 testit "show password settings" \
-	$VALGRIND $samba_tool domain passwordsettings $CONFIG show || failed=`expr $failed + 1`
+	$VALGRIND $samba_tool domain passwordsettings show $CONFIG || failed=`expr $failed + 1`
 
 testit_expect_failure "try to change password too quickly (command should not succeed)" \
 	$VALGRIND $samba_tool user password -W$DOMAIN "-U$DOMAIN/$TEST_USERNAME%$TEST_PASSWORD" -k no --newpassword="$TEST_PASSWORD_NEW"  && failed=`expr $failed + 1`
 
 testit "reset password policies" \
-	$VALGRIND $samba_tool domain passwordsettings $CONFIG set --complexity=default --history-length=default --min-pwd-length=default --min-pwd-age=default --max-pwd-age=default || failed=`expr $failed + 1`
+	$VALGRIND $samba_tool domain passwordsettings set $CONFIG --complexity=default --history-length=default --min-pwd-length=default --min-pwd-age=default --max-pwd-age=default || failed=`expr $failed + 1`
 
 testit "delete user $TEST_USERNAME" \
 	$VALGRIND $samba_tool user delete $TEST_USERNAME -U"$USERNAME%$PASSWORD" $CONFIG -k no  || failed=`expr $failed + 1`
-- 
2.7.4


From 91a2bb100d79e51bd61d83ba7063242ffe75f8cf Mon Sep 17 00:00:00 2001
From: Tim Beale <timbeale at catalyst.net.nz>
Date: Mon, 23 Apr 2018 10:47:21 +1200
Subject: [PATCH 22/23] netcmd: Add samba-tool support for managing PSOs

Add a new command 'samba-tool domain passwordsettings pso', with the
sub-command options: create, delete, set, list, show, show-user, apply,
unapply. The apply and unapply options apply the PSO to a user or group.
The show-user option shows the actual PSO (and its settings) that will
take effect for a given user.

The new commands are pretty self-contained in a new pso.py file. We
decided to add these new commands under the existing 'samba-tool domain
passwordsettings' command, as that's what users would be already
familiar with.

Signed-off-by: Tim Beale <timbeale at catalyst.net.nz>
---
 python/samba/netcmd/domain.py |   3 +
 python/samba/netcmd/pso.py    | 766 ++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 769 insertions(+)
 create mode 100644 python/samba/netcmd/pso.py

diff --git a/python/samba/netcmd/domain.py b/python/samba/netcmd/domain.py
index cb2b1cc..7365025 100644
--- a/python/samba/netcmd/domain.py
+++ b/python/samba/netcmd/domain.py
@@ -99,6 +99,8 @@ from samba.provision.common import (
     FILL_DRS
 )
 
+from samba.netcmd.pso import cmd_domain_passwordsettings_pso
+
 string_version_to_constant = {
     "2008_R2" : DS_DOMAIN_FUNCTION_2008_R2,
     "2012": DS_DOMAIN_FUNCTION_2012,
@@ -1531,6 +1533,7 @@ class cmd_domain_passwordsettings(SuperCommand):
     """Manage password policy settings."""
 
     subcommands = {}
+    subcommands["pso"] = cmd_domain_passwordsettings_pso()
     subcommands["show"] = cmd_domain_passwordsettings_show()
     subcommands["set"] = cmd_domain_passwordsettings_set()
 
diff --git a/python/samba/netcmd/pso.py b/python/samba/netcmd/pso.py
new file mode 100644
index 0000000..cfae29e
--- /dev/null
+++ b/python/samba/netcmd/pso.py
@@ -0,0 +1,766 @@
+# Manages Password Settings Objects
+#
+# Copyright (C) Catalyst.Net Ltd. 2018
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+#
+import samba
+import samba.getopt as options
+import ldb
+from samba.samdb import SamDB
+from samba.netcmd import (Command, CommandError, Option, SuperCommand)
+from samba.dcerpc.samr import DOMAIN_PASSWORD_COMPLEX, DOMAIN_PASSWORD_STORE_CLEARTEXT
+from samba.auth import system_session
+
+import pdb
+
+NEVER_TIMESTAMP = int(-0x8000000000000000)
+
+def pso_container(samdb):
+    return "CN=Password Settings Container,CN=System,%s" % samdb.domain_dn()
+
+def timestamp_to_mins(timestamp_str):
+    """Converts a timestamp in -100 nanosecond units to minutes"""
+    # treat a timestamp of 'never' the same as zero (this should work OK for
+    # most settings, and it displays better than trying to convert
+    # -0x8000000000000000 to minutes)
+    if int(timestamp_str) == NEVER_TIMESTAMP:
+        return 0
+    else:
+        return abs(int(timestamp_str)) / (1e7 * 60)
+
+def timestamp_to_days(timestamp_str):
+    """Converts a timestamp in -100 nanosecond units to days"""
+    return timestamp_to_mins(timestamp_str) / (60 * 24)
+
+def mins_to_timestamp(mins):
+    """Converts a value in minutes to -100 nanosecond units"""
+    timestamp = -int((1e7) * 60 * mins)
+    return str(timestamp)
+
+def days_to_timestamp(days):
+    """Converts a value in days to -100 nanosecond units"""
+    timestamp = mins_to_timestamp(days * 60 * 24)
+    return str(timestamp)
+
+def show_pso_by_dn(outf, samdb, dn, show_applies_to=True):
+    """Displays the password settings for a PSO specified by DN"""
+
+    # map from the boolean LDB value to the CLI string the user sees
+    on_off_str = { "TRUE" : "on", "FALSE" : "off" }
+
+    pso_attrs = ['name', 'msDS-PasswordSettingsPrecedence',
+                 'msDS-PasswordReversibleEncryptionEnabled',
+                 'msDS-PasswordHistoryLength', 'msDS-MinimumPasswordLength',
+                 'msDS-PasswordComplexityEnabled', 'msDS-MinimumPasswordAge',
+                 'msDS-MaximumPasswordAge', 'msDS-LockoutObservationWindow',
+                 'msDS-LockoutThreshold', 'msDS-LockoutDuration',
+                 'msDS-PSOAppliesTo']
+
+    res = samdb.search(dn, scope=ldb.SCOPE_BASE, attrs=pso_attrs)
+    pso_res = res[0]
+    outf.write("Password information for PSO '%s'\n" % pso_res['name'])
+    outf.write("\n")
+
+    outf.write("Precedence (lowest is best): %s\n" %
+               pso_res['msDS-PasswordSettingsPrecedence'])
+    bool_str = str(pso_res['msDS-PasswordComplexityEnabled'])
+    outf.write("Password complexity: %s\n" % on_off_str[bool_str])
+    bool_str = str(pso_res['msDS-PasswordReversibleEncryptionEnabled'])
+    outf.write("Store plaintext passwords: %s\n" % on_off_str[bool_str])
+    outf.write("Password history length: %s\n" %
+               pso_res['msDS-PasswordHistoryLength'])
+    outf.write("Minimum password length: %s\n" %
+               pso_res['msDS-MinimumPasswordLength'])
+    outf.write("Minimum password age (days): %d\n" %
+               timestamp_to_days(pso_res['msDS-MinimumPasswordAge'][0]))
+    outf.write("Maximum password age (days): %d\n" %
+               timestamp_to_days(pso_res['msDS-MaximumPasswordAge'][0]))
+    outf.write("Account lockout duration (mins): %d\n" %
+               timestamp_to_mins(pso_res['msDS-LockoutDuration'][0]))
+    outf.write("Account lockout threshold (attempts): %s\n" %
+               pso_res['msDS-LockoutThreshold'])
+    outf.write("Reset account lockout after (mins): %d\n" %
+               timestamp_to_mins(pso_res['msDS-LockoutObservationWindow'][0]))
+
+    if show_applies_to:
+        if 'msDS-PSOAppliesTo' in pso_res:
+            outf.write("\nPSO applies directly to %d groups/users:\n" %
+                       len(pso_res['msDS-PSOAppliesTo']))
+            for dn in pso_res['msDS-PSOAppliesTo']:
+                outf.write("  %s\n" % dn)
+        else:
+            outf.write("\nNote: PSO does not apply to any users or groups.\n")
+
+def check_pso_valid(samdb, pso_dn, name):
+    """Gracefully bail out if we can't view/modify the PSO specified"""
+    # the base scope search for the PSO throws an error if it doesn't exist
+    try:
+        res = samdb.search(pso_dn, scope=ldb.SCOPE_BASE,
+                           attrs=['msDS-PasswordSettingsPrecedence'])
+    except Exception as e:
+        raise CommandError("Unable to find PSO '%s'" % name)
+
+    # users need admin permission to modify/view a PSO. In this case, the
+    # search succeeds, but it doesn't return any attributes
+    if 'msDS-PasswordSettingsPrecedence' not in res[0]:
+        raise CommandError("You may not have permission to view/modify PSOs")
+
+def show_pso_for_user(outf, samdb, username):
+    """Displays the password settings for a specific user"""
+
+    search_filter = "(&(sAMAccountName=%s)(objectClass=user))" % username
+
+    res = samdb.search(samdb.domain_dn(), scope=ldb.SCOPE_SUBTREE,
+                       expression=search_filter,
+                       attrs=['msDS-ResultantPSO', 'msDS-PSOApplied'])
+
+    if len(res) == 0:
+        outf.write("User '%s' not found.\n" % username)
+    elif 'msDS-ResultantPSO' not in res[0]:
+        outf.write("No PSO applies to user '%s'. The default domain settings apply.\n"
+                   % username)
+        outf.write("Refer to 'samba-tool domain passwordsettings show'.\n")
+    else:
+        # sanity-check user has permissions to view PSO details (non-admin
+        # users can view msDS-ResultantPSO, but not the actual PSO details)
+        check_pso_valid(samdb, res[0]['msDS-ResultantPSO'][0], "???")
+        outf.write("The following PSO settings apply to user '%s'.\n\n" %
+                   username)
+        show_pso_by_dn(outf, samdb, res[0]['msDS-ResultantPSO'][0],
+                       show_applies_to=False)
+        # PSOs that apply directly to a user don't necessarily have the best
+        # precedence, which could be a little confusing for PSO management
+        if 'msDS-PSOApplied' in res[0]:
+            outf.write("\nNote: PSO applies directly to user (any group PSOs are overridden)\n")
+        else:
+            outf.write("\nPSO applies to user via group membership.\n")
+
+def make_pso_ldb_msg(outf, samdb, pso_dn, create, lockout_threshold=None,
+                     complexity=None, precedence=None, store_plaintext=None,
+                     history_length=None, min_pwd_length=None,
+                     min_pwd_age=None, max_pwd_age=None, lockout_duration=None,
+                     reset_account_lockout_after=None):
+    """Packs the given PSO settings into an LDB message"""
+
+    m = ldb.Message()
+    m.dn = ldb.Dn(samdb, pso_dn)
+
+    if create:
+        ldb_oper = ldb.FLAG_MOD_ADD
+        m["msDS-objectClass"] = ldb.MessageElement("msDS-PasswordSettings",
+              ldb_oper, "objectClass")
+    else:
+        ldb_oper = ldb.FLAG_MOD_REPLACE
+
+    if precedence is not None:
+        m["msDS-PasswordSettingsPrecedence"] = ldb.MessageElement(str(precedence),
+              ldb_oper, "msDS-PasswordSettingsPrecedence")
+
+    if complexity is not None:
+        bool_str = "TRUE" if complexity == "on" else "FALSE"
+        m["msDS-PasswordComplexityEnabled"] = ldb.MessageElement(bool_str,
+              ldb_oper, "msDS-PasswordComplexityEnabled")
+
+    if store_plaintext is not None:
+        bool_str = "TRUE" if store_plaintext == "on" else "FALSE"
+        m["msDS-msDS-PasswordReversibleEncryptionEnabled"] = \
+            ldb.MessageElement(bool_str, ldb_oper,
+                               "msDS-PasswordReversibleEncryptionEnabled")
+
+    if history_length is not None:
+        m["msDS-PasswordHistoryLength"] = ldb.MessageElement(str(history_length),
+            ldb_oper, "msDS-PasswordHistoryLength")
+
+    if min_pwd_length is not None:
+        m["msDS-MinimumPasswordLength"] = ldb.MessageElement(str(min_pwd_length),
+            ldb_oper, "msDS-MinimumPasswordLength")
+
+    if min_pwd_age is not None:
+        min_pwd_age_ticks = days_to_timestamp(min_pwd_age)
+        m["msDS-MinimumPasswordAge"] = ldb.MessageElement(min_pwd_age_ticks,
+            ldb_oper, "msDS-MinimumPasswordAge")
+
+    if max_pwd_age is not None:
+        # Windows won't let you set max-pwd-age to zero. Here we take zero to
+        # mean 'never expire' and use the timestamp corresponding to 'never'
+        if max_pwd_age == 0:
+            max_pwd_age_ticks = str(NEVER_TIMESTAMP)
+        else:
+            max_pwd_age_ticks = days_to_timestamp(max_pwd_age)
+        m["msDS-MaximumPasswordAge"] = ldb.MessageElement(max_pwd_age_ticks,
+            ldb_oper, "msDS-MaximumPasswordAge")
+
+    if lockout_duration is not None:
+        lockout_duration_ticks = mins_to_timestamp(lockout_duration)
+        m["msDS-LockoutDuration"] = ldb.MessageElement(lockout_duration_ticks,
+            ldb_oper, "msDS-LockoutDuration")
+
+    if lockout_threshold is not None:
+        m["msDS-LockoutThreshold"] = ldb.MessageElement(str(lockout_threshold),
+            ldb_oper, "msDS-LockoutThreshold")
+
+    if reset_account_lockout_after is not None:
+        observation_window_ticks = mins_to_timestamp(reset_account_lockout_after)
+        m["msDS-LockoutObservationWindow"] = ldb.MessageElement(observation_window_ticks,
+            ldb_oper, "msDS-LockoutObservationWindow")
+
+    return m
+
+def check_pso_constraints(min_pwd_length=None, history_length=None,
+                          min_pwd_age=None, max_pwd_age=None):
+    """Checks PSO settings fall within valid ranges"""
+
+    # check values as per section 3.1.1.5.2.2 Constraints in MS-ADTS spec
+    if history_length is not None and history_length > 1024:
+        raise CommandError("Bad password history length: valid range is 0 to 1024")
+
+    if min_pwd_length is not None and min_pwd_length > 255:
+        raise CommandError("Bad minimum password length: valid range is 0 to 255")
+
+    if min_pwd_age is not None and max_pwd_age is not None:
+        # note max-age=zero is a special case meaning 'never expire'
+        if min_pwd_age >= max_pwd_age and max_pwd_age != 0:
+            raise CommandError("Minimum password age must be less than the maximum age")
+
+
+# the same args are used for both create and set commands
+pwd_settings_options = [
+    Option("--complexity", type="choice", choices=["on","off"],
+      help="The password complexity (on | off)."),
+    Option("--store-plaintext", type="choice", choices=["on","off"],
+      help="Store plaintext passwords where account have 'store passwords with reversible encryption' set (on | off)."),
+    Option("--history-length",
+      help="The password history length (<integer>).", type=int),
+    Option("--min-pwd-length",
+      help="The minimum password length (<integer>).", type=int),
+    Option("--min-pwd-age",
+      help="The minimum password age (<integer in days>). Default is domain setting.", type=int),
+    Option("--max-pwd-age",
+      help="The maximum password age (<integer in days>). Default is domain setting.", type=int),
+    Option("--account-lockout-duration",
+      help="The the length of time an account is locked out after exeeding the limit on bad password attempts (<integer in mins>). Default is domain setting", type=int),
+    Option("--account-lockout-threshold",
+      help="The number of bad password attempts allowed before locking out the account (<integer>). Default is domain setting.", type=int),
+    Option("--reset-account-lockout-after",
+      help="After this time is elapsed, the recorded number of attempts restarts from zero (<integer in mins>). Default is domain setting.", type=int),
+      ]
+
+def num_options_in_args(options, args):
+    """
+    Returns the number of options specified that are present in the args.
+    (There can be other args besides just the ones we're interested in, which
+    is why argc on its own is not enough)
+    """
+    num_opts = 0
+    for opt in options:
+        for arg in args:
+            # The option should be a sub-string of the CLI argument for a match
+            if str(opt) in arg:
+                num_opts += 1
+    return num_opts
+
+class cmd_domain_pwdsettings_pso_create(Command):
+    """Creates a new Password Settings Object (PSO).
+
+    PSOs are a way to tailor different password settings (lockout policy,
+    minimum password length, etc) for specific users or groups.
+
+    The psoname is a unique name for the new Password Settings Object.
+    When multiple PSOs apply to a user, the precedence determines which PSO
+    will take effect. The PSO with the lowest precedence will take effect.
+
+    For most arguments, the default value (if unspecified) is the current
+    domain passwordsettings value. To see these values, enter the command
+    'samba-tool domain passwordsettings show'.
+
+    To apply the new PSO to user(s) or group(s), enter the command
+    'samba-tool domain passwordsettings pso apply'.
+    """
+
+    synopsis = "%prog <psoname> <precedence> [options]"
+
+    takes_optiongroups = {
+        "sambaopts": options.SambaOptions,
+        "versionopts": options.VersionOptions,
+        "credopts": options.CredentialsOptions,
+        }
+
+    takes_options = pwd_settings_options + [
+    Option("-H", "--URL", help="LDB URL for database or target server", type=str,
+           metavar="URL", dest="H"),
+        ]
+    takes_args = ["psoname", "precedence"]
+
+    def run(self, psoname, precedence, H=None, min_pwd_age=None,
+            max_pwd_age=None, complexity=None, store_plaintext=None,
+            history_length=None, min_pwd_length=None,
+            account_lockout_duration=None, account_lockout_threshold=None,
+            reset_account_lockout_after=None, credopts=None, sambaopts=None,
+            versionopts=None):
+        lp = sambaopts.get_loadparm()
+        creds = credopts.get_credentials(lp)
+
+        samdb = SamDB(url=H, session_info=system_session(),
+            credentials=creds, lp=lp)
+
+        try:
+            precedence = int(precedence)
+        except ValueError:
+            raise CommandError("The PSO's precedence should be a numerical value. Try --help")
+
+        # sanity-check that the PSO doesn't already exist
+        pso_dn = "CN=%s,%s" % (psoname, pso_container(samdb))
+        try:
+            res = samdb.search(pso_dn, scope=ldb.SCOPE_BASE)
+        except Exception as e:
+            pass
+        else:
+            raise CommandError("PSO '%s' already exists" % psoname)
+
+        # we expect the user to specify at least one password-policy setting,
+        # otherwise there's no point in creating a PSO
+        num_pwd_args = num_options_in_args(pwd_settings_options, self.raw_argv)
+        if num_pwd_args == 0:
+            raise CommandError("Please specify at least one password policy setting. Try --help")
+
+        # it's unlikely that the user will specify all 9 password policy
+        # settings on the CLI - current domain password-settings as the default
+        # values for unspecified arguments
+        if num_pwd_args < len(pwd_settings_options):
+            self.message("Not all password policy options have been specified.")
+            self.message("For unspecified options, the current domain password settings will be used as the default values.")
+
+        # lookup the current domain password-settings
+        res = samdb.search(samdb.domain_dn(), scope=ldb.SCOPE_BASE,
+            attrs=["pwdProperties", "pwdHistoryLength", "minPwdLength",
+                "minPwdAge", "maxPwdAge", "lockoutDuration",
+                "lockoutThreshold", "lockOutObservationWindow"])
+        assert(len(res) == 1)
+
+        # use the domain settings for any missing arguments
+        pwd_props = int(res[0]["pwdProperties"][0])
+        if complexity is None:
+            prop_flag = DOMAIN_PASSWORD_COMPLEX
+            complexity = "on" if pwd_props & prop_flag else "off"
+
+        if store_plaintext is None:
+            prop_flag = DOMAIN_PASSWORD_STORE_CLEARTEXT
+            store_plaintext = "on" if pwd_props & prop_flag else "off"
+
+        if history_length is None:
+            history_length = int(res[0]["pwdHistoryLength"][0])
+
+        if min_pwd_length is None:
+            min_pwd_length = int(res[0]["minPwdLength"][0])
+
+        if min_pwd_age is None:
+            min_pwd_age = timestamp_to_days(res[0]["minPwdAge"][0])
+
+        if max_pwd_age is None:
+            max_pwd_age = timestamp_to_days(res[0]["maxPwdAge"][0])
+
+        if account_lockout_duration is None:
+            account_lockout_duration = \
+                timestamp_to_mins(res[0]["lockoutDuration"][0])
+
+        if account_lockout_threshold is None:
+            account_lockout_threshold = int(res[0]["lockoutThreshold"][0])
+
+        if reset_account_lockout_after is None:
+            reset_account_lockout_after = \
+                timestamp_to_mins(res[0]["lockOutObservationWindow"][0])
+
+        check_pso_constraints(max_pwd_age=max_pwd_age, min_pwd_age=min_pwd_age,
+                              history_length=history_length,
+                              min_pwd_length=min_pwd_length)
+
+        # pack the settings into an LDB message
+        m = make_pso_ldb_msg(self.outf, samdb, pso_dn, create=True,
+                             complexity=complexity, precedence=precedence,
+                             store_plaintext=store_plaintext,
+                             history_length=history_length,
+                             min_pwd_length=min_pwd_length,
+                             min_pwd_age=min_pwd_age, max_pwd_age=max_pwd_age,
+                             lockout_duration=account_lockout_duration,
+                             lockout_threshold=account_lockout_threshold,
+                             reset_account_lockout_after=reset_account_lockout_after)
+
+        # create the new PSO
+        try:
+            samdb.add(m)
+            self.message("PSO successfully created: %s" % pso_dn)
+            # display the new PSO's settings
+            show_pso_by_dn(self.outf, samdb, pso_dn, show_applies_to=False)
+        except ldb.LdbError as e:
+            (num, msg) = e.args
+            if num == ldb.ERR_INSUFFICIENT_ACCESS_RIGHTS:
+                raise CommandError("Administrator permissions are needed to create a PSO.")
+            else:
+                raise CommandError("Failed to create PSO '%s': %s" %(pso_dn, msg))
+
+class cmd_domain_pwdsettings_pso_set(Command):
+    """Modifies a Password Settings Object (PSO)."""
+
+    synopsis = "%prog <psoname> [options]"
+
+    takes_optiongroups = {
+        "sambaopts": options.SambaOptions,
+        "versionopts": options.VersionOptions,
+        "credopts": options.CredentialsOptions,
+        }
+
+    takes_options = pwd_settings_options + [
+        Option("--precedence", type=int,
+               help="This PSO's precedence relative to other PSOs. Lower precedence is better (<integer>)."),
+        Option("-H", "--URL", help="LDB URL for database or target server",
+               type=str, metavar="URL", dest="H"),
+        ]
+    takes_args = ["psoname"]
+
+    def run(self, psoname, H=None, precedence=None, min_pwd_age=None,
+            max_pwd_age=None, complexity=None, store_plaintext=None,
+            history_length=None, min_pwd_length=None,
+            account_lockout_duration=None, account_lockout_threshold=None,
+            reset_account_lockout_after=None, credopts=None, sambaopts=None,
+            versionopts=None):
+        lp = sambaopts.get_loadparm()
+        creds = credopts.get_credentials(lp)
+
+        samdb = SamDB(url=H, session_info=system_session(),
+            credentials=creds, lp=lp)
+
+        # sanity-check the PSO exists
+        pso_dn = "CN=%s,%s" % (psoname, pso_container(samdb))
+        check_pso_valid(samdb, pso_dn, psoname)
+
+        # we expect the user to specify at least one password-policy setting
+        num_pwd_args = num_options_in_args(pwd_settings_options, self.raw_argv)
+        if num_pwd_args == 0 and precedence is None:
+            raise CommandError("Please specify at least one password policy setting. Try --help")
+
+        if min_pwd_age is not None or max_pwd_age is not None:
+            # if we're modifying either the max or min pwd-age, check the max is
+            # always larger. We may have to fetch the PSO's setting to verify this
+            res = samdb.search(pso_dn, scope=ldb.SCOPE_BASE,
+                               attrs=['msDS-MinimumPasswordAge',
+                                      'msDS-MaximumPasswordAge'])
+            if min_pwd_age is None:
+                min_pwd_age = timestamp_to_days(res[0]['msDS-MinimumPasswordAge'][0])
+
+            if max_pwd_age is None:
+                max_pwd_age = timestamp_to_days(res[0]['msDS-MaximumPasswordAge'][0])
+
+        check_pso_constraints(max_pwd_age=max_pwd_age, min_pwd_age=min_pwd_age,
+                              history_length=history_length,
+                              min_pwd_length=min_pwd_length)
+
+        # pack the settings into an LDB message
+        m = make_pso_ldb_msg(self.outf, samdb, pso_dn, create=False,
+                             complexity=complexity, precedence=precedence,
+                             store_plaintext=store_plaintext,
+                             history_length=history_length,
+                             min_pwd_length=min_pwd_length,
+                             min_pwd_age=min_pwd_age, max_pwd_age=max_pwd_age,
+                             lockout_duration=account_lockout_duration,
+                             lockout_threshold=account_lockout_threshold,
+                             reset_account_lockout_after=reset_account_lockout_after)
+
+        # update the PSO
+        try:
+            samdb.modify(m)
+            self.message("Successfully updated PSO: %s" % pso_dn)
+            # display the new PSO's settings
+            show_pso_by_dn(self.outf, samdb, pso_dn, show_applies_to=False)
+        except ldb.LdbError as e:
+            (num, msg) = e.args
+            raise CommandError("Failed to update PSO '%s': %s" %(pso_dn, msg))
+
+
+class cmd_domain_pwdsettings_pso_delete(Command):
+    """Deletes a Password Settings Object (PSO)."""
+
+    synopsis = "%prog <psoname> [options]"
+
+    takes_optiongroups = {
+        "sambaopts": options.SambaOptions,
+        "versionopts": options.VersionOptions,
+        "credopts": options.CredentialsOptions,
+        }
+
+    takes_options = [
+    Option("-H", "--URL", help="LDB URL for database or target server", type=str,
+           metavar="URL", dest="H"),
+        ]
+    takes_args = ["psoname"]
+
+    def run(self, psoname, H=None, credopts=None, sambaopts=None,
+            versionopts=None):
+        lp = sambaopts.get_loadparm()
+        creds = credopts.get_credentials(lp)
+
+        samdb = SamDB(url=H, session_info=system_session(),
+            credentials=creds, lp=lp)
+
+        pso_dn = "CN=%s,%s" % (psoname, pso_container(samdb))
+        # sanity-check the PSO exists
+        check_pso_valid(samdb, pso_dn, psoname)
+
+        samdb.delete(pso_dn)
+        self.message("Deleted PSO %s" % psoname)
+
+
+def pso_cmp(a, b):
+    """Compares two PSO LDB search results"""
+    a_precedence = int(a['msDS-PasswordSettingsPrecedence'][0])
+    b_precedence = int(b['msDS-PasswordSettingsPrecedence'][0])
+    return a_precedence - b_precedence
+
+class cmd_domain_pwdsettings_pso_list(Command):
+    """Lists all Password Settings Objects (PSOs)."""
+
+    synopsis = "%prog [options]"
+
+    takes_optiongroups = {
+        "sambaopts": options.SambaOptions,
+        "versionopts": options.VersionOptions,
+        "credopts": options.CredentialsOptions,
+        }
+
+    takes_options = [
+    Option("-H", "--URL", help="LDB URL for database or target server", type=str,
+           metavar="URL", dest="H"),
+        ]
+
+    def run(self, H=None, credopts=None, sambaopts=None, versionopts=None):
+        lp = sambaopts.get_loadparm()
+        creds = credopts.get_credentials(lp)
+
+        samdb = SamDB(url=H, session_info=system_session(),
+            credentials=creds, lp=lp)
+
+        res = samdb.search(pso_container(samdb), scope=ldb.SCOPE_SUBTREE,
+                           attrs=['name', 'msDS-PasswordSettingsPrecedence'],
+                           expression="(objectClass=msDS-PasswordSettings)")
+
+        # an unprivileged search against Windows returns nothing here. On Samba
+        # we get the PSO names, but not their attributes
+        if len(res) == 0 or 'msDS-PasswordSettingsPrecedence' not in res[0]:
+            self.outf.write("No PSOs are present, or you don't have permission to view them.\n")
+            return
+
+        # sort the PSOs so they're displayed in order of precedence
+        pso_list = sorted(res, cmp=pso_cmp)
+
+        self.outf.write("Precedence | PSO name\n")
+        self.outf.write("--------------------------------------------------\n")
+
+        for pso in pso_list:
+            precedence = pso['msDS-PasswordSettingsPrecedence']
+            self.outf.write("%-10s | %s\n" %(precedence, pso['name']))
+
+class cmd_domain_pwdsettings_pso_show(Command):
+    """Display a Password Settings Object's details."""
+
+    synopsis = "%prog <psoname> [options]"
+
+    takes_optiongroups = {
+        "sambaopts": options.SambaOptions,
+        "versionopts": options.VersionOptions,
+        "credopts": options.CredentialsOptions,
+        }
+
+    takes_options = [
+    Option("-H", "--URL", help="LDB URL for database or target server", type=str,
+           metavar="URL", dest="H"),
+        ]
+    takes_args = ["psoname"]
+
+    def run(self, psoname, H=None, credopts=None, sambaopts=None,
+            versionopts=None):
+        lp = sambaopts.get_loadparm()
+        creds = credopts.get_credentials(lp)
+
+        samdb = SamDB(url=H, session_info=system_session(),
+            credentials=creds, lp=lp)
+
+        pso_dn = "CN=%s,%s" % (psoname, pso_container(samdb))
+        check_pso_valid(samdb, pso_dn, psoname)
+        show_pso_by_dn(self.outf, samdb, pso_dn)
+
+
+class cmd_domain_pwdsettings_pso_show_user(Command):
+    """Displays the Password Settings that apply to a user."""
+
+    synopsis = "%prog <username> [options]"
+
+    takes_optiongroups = {
+        "sambaopts": options.SambaOptions,
+        "versionopts": options.VersionOptions,
+        "credopts": options.CredentialsOptions,
+        }
+
+    takes_options = [
+    Option("-H", "--URL", help="LDB URL for database or target server", type=str,
+           metavar="URL", dest="H"),
+        ]
+    takes_args = ["username"]
+
+    def run(self, username, H=None, credopts=None, sambaopts=None,
+            versionopts=None):
+        lp = sambaopts.get_loadparm()
+        creds = credopts.get_credentials(lp)
+
+        samdb = SamDB(url=H, session_info=system_session(),
+            credentials=creds, lp=lp)
+
+        show_pso_for_user(self.outf, samdb, username)
+
+
+class cmd_domain_pwdsettings_pso_apply(Command):
+    """Applies a PSO's password policy to a user or group.
+
+    When a PSO is applied to a group, it will apply to all users (and groups)
+    that are members of that group. If a PSO applies directly to a user, it
+    will override any group membership PSOs for that user.
+
+    When multiple PSOs apply to a user, either directly or through group
+    membership, the PSO with the lowest precedence will take effect.
+    """
+
+    synopsis = "%prog <psoname> <user-or-group-name> [options]"
+
+    takes_optiongroups = {
+        "sambaopts": options.SambaOptions,
+        "versionopts": options.VersionOptions,
+        "credopts": options.CredentialsOptions,
+        }
+
+    takes_options = [
+    Option("-H", "--URL", help="LDB URL for database or target server", type=str,
+           metavar="URL", dest="H"),
+        ]
+    takes_args = ["psoname", "user_or_group"]
+
+    def run(self, psoname, user_or_group, H=None, credopts=None,
+            sambaopts=None, versionopts=None):
+        lp = sambaopts.get_loadparm()
+        creds = credopts.get_credentials(lp)
+
+        samdb = SamDB(url=H, session_info=system_session(),
+            credentials=creds, lp=lp)
+
+        pso_dn = "CN=%s,%s" % (psoname, pso_container(samdb))
+        # sanity-check the PSO exists
+        check_pso_valid(samdb, pso_dn, psoname)
+
+        # lookup the user/group by account-name to gets its DN
+        search_filter = "(sAMAccountName=%s)" % user_or_group
+        res = samdb.search(samdb.domain_dn(), scope=ldb.SCOPE_SUBTREE,
+                           expression=search_filter)
+
+        if len(res) == 0:
+            raise CommandError("The specified user or group '%s' was not found"
+                               % user_or_group)
+
+        # modify the PSO to apply to the user/group specified
+        target_dn = str(res[0].dn)
+        m = ldb.Message()
+        m.dn = ldb.Dn(samdb, pso_dn)
+        m["msDS-PSOAppliesTo"] = ldb.MessageElement(target_dn, ldb.FLAG_MOD_ADD,
+                                                    "msDS-PSOAppliesTo")
+        try:
+            samdb.modify(m)
+        except ldb.LdbError as e:
+            (num, msg) = e.args
+            # most likely error - PSO already applies to that user/group
+            if num == ldb.ERR_ATTRIBUTE_OR_VALUE_EXISTS:
+                raise CommandError("PSO '%s' already applies to '%s'"
+                                   % (psoname, user_or_group))
+            else:
+                raise CommandError("Failed to update PSO '%s': %s" %(psoname,
+                                                                     msg))
+
+        self.message("PSO '%s' applied to '%s'" %(psoname, user_or_group))
+
+
+class cmd_domain_pwdsettings_pso_unapply(Command):
+    """Updates a PSO to no longer apply to a user or group."""
+
+    synopsis = "%prog <psoname> <user-or-group-name> [options]"
+
+    takes_optiongroups = {
+        "sambaopts": options.SambaOptions,
+        "versionopts": options.VersionOptions,
+        "credopts": options.CredentialsOptions,
+        }
+
+    takes_options = [
+    Option("-H", "--URL", help="LDB URL for database or target server", type=str,
+           metavar="URL", dest="H"),
+        ]
+    takes_args = ["psoname", "user_or_group"]
+
+    def run(self, psoname, user_or_group, H=None, credopts=None,
+            sambaopts=None, versionopts=None):
+        lp = sambaopts.get_loadparm()
+        creds = credopts.get_credentials(lp)
+
+        samdb = SamDB(url=H, session_info=system_session(),
+            credentials=creds, lp=lp)
+
+        pso_dn = "CN=%s,%s" % (psoname, pso_container(samdb))
+        # sanity-check the PSO exists
+        check_pso_valid(samdb, pso_dn, psoname)
+
+        # lookup the user/group by account-name to gets its DN
+        search_filter = "(sAMAccountName=%s)" % user_or_group
+        res = samdb.search(samdb.domain_dn(), scope=ldb.SCOPE_SUBTREE,
+                           expression=search_filter)
+
+        if len(res) == 0:
+            raise CommandError("The specified user or group '%s' was not found"
+                               % user_or_group)
+
+        # modify the PSO to apply to the user/group specified
+        target_dn = str(res[0].dn)
+        m = ldb.Message()
+        m.dn = ldb.Dn(samdb, pso_dn)
+        m["msDS-PSOAppliesTo"] = ldb.MessageElement(target_dn, ldb.FLAG_MOD_DELETE,
+                                                    "msDS-PSOAppliesTo")
+        try:
+            samdb.modify(m)
+        except ldb.LdbError as e:
+            (num, msg) = e.args
+            # most likely error - PSO doesn't apply to that user/group
+            if num == ldb.ERR_NO_SUCH_ATTRIBUTE:
+                raise CommandError("PSO '%s' doesn't apply to '%s'"
+                                   % (psoname, user_or_group))
+            else:
+                raise CommandError("Failed to update PSO '%s': %s" %(psoname,
+                                                                     msg))
+        self.message("PSO '%s' no longer applies to '%s'" %(psoname, user_or_group))
+
+class cmd_domain_passwordsettings_pso(SuperCommand):
+    """Manage fine-grained Password Settings Objects (PSOs)."""
+
+    subcommands = {}
+    subcommands["apply"] = cmd_domain_pwdsettings_pso_apply()
+    subcommands["create"] = cmd_domain_pwdsettings_pso_create()
+    subcommands["delete"] = cmd_domain_pwdsettings_pso_delete()
+    subcommands["list"] = cmd_domain_pwdsettings_pso_list()
+    subcommands["set"] = cmd_domain_pwdsettings_pso_set()
+    subcommands["show"] = cmd_domain_pwdsettings_pso_show()
+    subcommands["show-user"] = cmd_domain_pwdsettings_pso_show_user()
+    subcommands["unapply"] = cmd_domain_pwdsettings_pso_unapply()
-- 
2.7.4


From 0c8b0cb97c8a796a0c1e422f63b8085f2b8212d3 Mon Sep 17 00:00:00 2001
From: Tim Beale <timbeale at catalyst.net.nz>
Date: Thu, 10 May 2018 16:22:06 +1200
Subject: [PATCH 23/23] tests: Add tests for samba-tool passwordsettings
 commands

The base samba_tool test's runsubcmd() only handled commands with
exactly one sub-command, i.e. it would handle the command 'samba-tool
domain passwordsettings' OK, but not 'samba-tool domain passwordsettings
set' or 'samba-tool domain passwordsettings pso create'. (The command
still seemed to run OK, but you wouldn't get the output/err back
correctly). A new runsublevelcmd() function now handles a varying number
of sub-commands.

I've also added a test case for 'samba-tool domain passwordsettings set/
show' - it's behaviour shouldn't have changed, but there was no test for
this currently.

Signed-off-by: Tim Beale <timbeale at catalyst.net.nz>
---
 python/samba/tests/samba_tool/base.py             |  17 +
 python/samba/tests/samba_tool/passwordsettings.py | 438 ++++++++++++++++++++++
 source4/selftest/tests.py                         |   1 +
 3 files changed, 456 insertions(+)
 create mode 100644 python/samba/tests/samba_tool/passwordsettings.py

diff --git a/python/samba/tests/samba_tool/base.py b/python/samba/tests/samba_tool/base.py
index 89a0922..a877ed3 100644
--- a/python/samba/tests/samba_tool/base.py
+++ b/python/samba/tests/samba_tool/base.py
@@ -88,6 +88,23 @@ class SambaToolCmdTest(samba.tests.BlackboxTestCase):
         result = cmd._run("samba-tool %s %s" % (name, sub), *args)
         return (result, cmd.outf.getvalue(), cmd.errf.getvalue())
 
+    def runsublevelcmd(self, name, sublevels, *args):
+        """run a command with any number of sub command levels"""
+        # Same as runsubcmd, except this handles a varying number of sub-command
+        # levels, e.g. 'samba-tool domain passwordsettings pso set', whereas
+        # runsubcmd() only handles exactly one level of sub-commands.
+        # First, traverse the levels of sub-commands to get the actual cmd
+        # object we'll run, and construct the cmd string along the way
+        cmd = cmd_sambatool.subcommands[name]
+        cmd_str = "samba-tool %s" % name
+        for sub in sublevels:
+	    cmd = cmd.subcommands[sub]
+	    cmd_str += " %s" % sub
+        cmd.outf = StringIO()
+        cmd.errf = StringIO()
+        result = cmd._run(cmd_str, *args)
+        return (result, cmd.outf.getvalue(), cmd.errf.getvalue())
+
     def assertCmdSuccess(self, exit, out, err, msg=""):
         self.assertIsNone(exit, msg="exit[%s] stdout[%s] stderr[%s]: %s" % (
                           exit, out, err, msg))
diff --git a/python/samba/tests/samba_tool/passwordsettings.py b/python/samba/tests/samba_tool/passwordsettings.py
new file mode 100644
index 0000000..3876df5
--- /dev/null
+++ b/python/samba/tests/samba_tool/passwordsettings.py
@@ -0,0 +1,438 @@
+# Test 'samba-tool domain passwordsettings' sub-commands
+#
+# Copyright (C) Catalyst.Net Ltd. 2018
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+#
+
+import os
+import ldb
+from samba.tests.samba_tool.base import SambaToolCmdTest
+from samba.tests.pso import PasswordSettings, TestUser
+
+class PwdSettingsCmdTestCase(SambaToolCmdTest):
+    """Tests for 'samba-tool domain passwordsettings' subcommands"""
+
+    def setUp(self):
+        super(PwdSettingsCmdTestCase, self).setUp()
+        self.server = "ldap://%s" % os.environ["DC_SERVER"]
+        self.user_auth = "-U%s%%%s" % (os.environ["DC_USERNAME"],
+                                       os.environ["DC_PASSWORD"])
+        self.ldb = self.getSamDB("-H", self.server, self.user_auth)
+        self.pso_container = \
+             "CN=Password Settings Container,CN=System,%s" % self.ldb.domain_dn()
+        self.obj_cleanup = []
+
+    def tearDown(self):
+        super(PwdSettingsCmdTestCase, self).tearDown()
+        # clean-up any objects the test has created
+        for dn in self.obj_cleanup:
+            self.ldb.delete(dn)
+
+    def check_pso(self, pso_name, pso):
+        """Checks the PSO info in the DB matches what's expected"""
+
+        # lookup the PSO in the DB
+        dn = "CN=%s,%s" %(pso_name, self.pso_container)
+        pso_attrs = ['name', 'msDS-PasswordSettingsPrecedence',
+                     'msDS-PasswordReversibleEncryptionEnabled',
+                     'msDS-PasswordHistoryLength', 'msDS-MinimumPasswordLength',
+                     'msDS-PasswordComplexityEnabled', 'msDS-MinimumPasswordAge',
+                     'msDS-MaximumPasswordAge', 'msDS-LockoutObservationWindow',
+                     'msDS-LockoutThreshold', 'msDS-LockoutDuration']
+        res = self.ldb.search(dn, scope=ldb.SCOPE_BASE, attrs=pso_attrs)
+        self.assertEquals(len(res), 1, "PSO lookup failed")
+
+        # convert types in the PSO-settings to what the search returns, i.e.
+        # boolean --> string, seconds --> timestamps in -100 nanosecond units
+        complexity_str = "TRUE" if pso.complexity else "FALSE"
+        plaintext_str = "TRUE" if pso.store_plaintext else "FALSE"
+        lockout_duration = -int(pso.lockout_duration * (1e7))
+        lockout_window = -int(pso.lockout_window * (1e7))
+        min_age = -int(pso.password_age_min * (1e7))
+        max_age = -int(pso.password_age_max * (1e7))
+
+        # check the PSO's settings match the search results
+        self.assertEquals(str(res[0]['msDS-PasswordComplexityEnabled'][0]),
+                          complexity_str)
+        self.assertEquals(str(res[0]['msDS-PasswordReversibleEncryptionEnabled'][0]),
+                          plaintext_str)
+        self.assertEquals(int(res[0]['msDS-PasswordHistoryLength'][0]),
+                          pso.history_len)
+        self.assertEquals(int(res[0]['msDS-MinimumPasswordLength'][0]),
+                          pso.password_len)
+        self.assertEquals(int(res[0]['msDS-MinimumPasswordAge'][0]), min_age)
+        self.assertEquals(int(res[0]['msDS-MaximumPasswordAge'][0]), max_age)
+        self.assertEquals(int(res[0]['msDS-LockoutObservationWindow'][0]),
+                          lockout_window)
+        self.assertEquals(int(res[0]['msDS-LockoutDuration'][0]),
+                          lockout_duration)
+        self.assertEquals(int(res[0]['msDS-LockoutThreshold'][0]),
+                          pso.lockout_attempts)
+        self.assertEquals(int(res[0]['msDS-PasswordSettingsPrecedence'][0]),
+                          pso.precedence)
+
+        # check we can also display the PSO via the show command
+        (result, out, err) = self.runsublevelcmd("domain", ("passwordsettings",
+                                                 "pso", "show"), pso_name,
+                                                 "-H", self.server,
+                                                 self.user_auth)
+        self.assertTrue(len(out.split(":")) >= 10, "Expect 10 fields displayed")
+
+        # for a few settings, sanity-check the display is what we expect
+        self.assertIn("Minimum password length: %u" % pso.password_len, out)
+        self.assertIn("Password history length: %u" % pso.history_len, out)
+        self.assertIn("lockout threshold (attempts): %u" % pso.lockout_attempts,
+                      out)
+
+    def test_pso_create(self):
+        """Tests basic PSO creation using the samba-tool"""
+
+        # we expect the PSO to take the current domain settings by default
+        # (we'll set precedence/complexity, the rest should be the defaults)
+        expected_pso = PasswordSettings(None, self.ldb)
+        expected_pso.complexity = False
+        expected_pso.precedence = 100
+
+        # check basic PSO creation works
+        pso_name = "test-create-PSO"
+        (result, out, err) = self.runsublevelcmd("domain", ("passwordsettings",
+                                                 "pso", "create"), pso_name,
+                                                 "100", "--complexity=off",
+                                                 "-H", self.server,
+                                                 self.user_auth)
+        # make sure we clean-up after the test completes
+        self.obj_cleanup.append("CN=%s,%s" %(pso_name, self.pso_container))
+
+        self.assertCmdSuccess(result, out, err)
+        self.assertEquals(err,"","Shouldn't be any error messages")
+        self.assertIn("successfully created", out)
+        self.check_pso(pso_name, expected_pso)
+
+        # check creating a PSO with the same name fails
+        (result, out, err) = self.runsublevelcmd("domain", ("passwordsettings",
+                                                 "pso", "create"), pso_name,
+                                                 "100", "--complexity=off",
+                                                 "-H", self.server,
+                                                 self.user_auth)
+        self.assertCmdFail(result, "Ensure that create for existing PSO fails")
+        self.assertIn("already exists", err)
+
+        # check we need to specify at least one password policy argument
+        pso_name = "test-create-PSO2"
+        (result, out, err) = self.runsublevelcmd("domain", ("passwordsettings",
+                                                 "pso", "create"), pso_name,
+                                                 "100", "-H", self.server,
+                                                 self.user_auth)
+        self.assertCmdFail(result, "Ensure that create for existing PSO fails")
+        self.assertIn("specify at least one password policy setting", err)
+
+        # create a PSO with different settings and check they match
+        expected_pso.complexity = True
+        expected_pso.store_plaintext = True
+        expected_pso.precedence = 50
+        expected_pso.password_len = 12
+        day_in_secs = 60 * 60 * 24
+        expected_pso.password_age_min = 11 * day_in_secs
+        expected_pso.password_age_max = 50 * day_in_secs
+
+        (result, out, err) = self.runsublevelcmd("domain", ("passwordsettings",
+                                                 "pso", "create"), pso_name,
+                                                 "50", "--complexity=on",
+                                                 "--store-plaintext=on",
+                                                 "--min-pwd-length=12",
+                                                 "--min-pwd-age=11",
+                                                 "--max-pwd-age=50",
+                                                 "-H", self.server,
+                                                 self.user_auth)
+        self.obj_cleanup.append("CN=%s,%s" %(pso_name, self.pso_container))
+        self.assertCmdSuccess(result, out, err)
+        self.assertEquals(err,"","Shouldn't be any error messages")
+        self.assertIn("successfully created", out)
+        self.check_pso(pso_name, expected_pso)
+
+        # check the PSOs we created are present in the 'list' command
+        (result, out, err) = self.runsublevelcmd("domain", ("passwordsettings",
+                                                 "pso", "list"),
+                                                 "-H", self.server,
+                                                 self.user_auth)
+        self.assertCmdSuccess(result, out, err)
+        self.assertIn("test-create-PSO", out)
+        self.assertIn("test-create-PSO2", out)
+
+    def _create_pso(self, pso_name):
+        """Creates a PSO for use in other tests"""
+        # the new PSO will take the current domain settings by default
+        pso_settings = PasswordSettings(None, self.ldb)
+        pso_settings.name = pso_name
+        pso_settings.password_len = 10
+        pso_settings.precedence = 200
+
+        (result, out, err) = self.runsublevelcmd("domain", ("passwordsettings",
+                                                 "pso", "create"), pso_name,
+                                                 "200", "--min-pwd-length=10",
+                                                 "-H", self.server,
+                                                 self.user_auth)
+        # make sure we clean-up after the test completes
+        pso_settings.dn = "CN=%s,%s" %(pso_name, self.pso_container)
+        self.obj_cleanup.append(pso_settings.dn)
+
+        # sanity-check the cmd was successful
+        self.assertCmdSuccess(result, out, err)
+        self.assertEquals(err,"","Shouldn't be any error messages")
+        self.assertIn("successfully created", out)
+        self.check_pso(pso_name, pso_settings)
+
+        return pso_settings
+
+    def test_pso_set(self):
+        """Tests we can modify a PSO using the samba-tool"""
+
+        pso_name = "test-set-PSO"
+        pso_settings = self._create_pso(pso_name)
+
+        # check we can update a PSO's settings
+        pso_settings.precedence = 99
+        pso_settings.lockout_attempts = 10
+        pso_settings.lockout_duration = 60 * 17
+        (result, out, err) = self.runsublevelcmd("domain", ("passwordsettings",
+                                                 "pso", "set"), pso_name,
+                                                 "--precedence=99",
+                                                 "--account-lockout-threshold=10",
+                                                 "--account-lockout-duration=17",
+                                                 "-H", self.server,
+                                                 self.user_auth)
+        self.assertCmdSuccess(result, out, err)
+        self.assertEquals(err,"","Shouldn't be any error messages")
+        self.assertIn("Successfully updated", out)
+
+        # check the PSO's settings now reflect the new values
+        self.check_pso(pso_name, pso_settings)
+
+    def test_pso_delete(self):
+        """Tests we can delete a PSO using the samba-tool"""
+
+        pso_name = "test-delete-PSO"
+        pso_settings = self._create_pso(pso_name)
+
+        # check we can successfully delete the PSO
+        (result, out, err) = self.runsublevelcmd("domain", ("passwordsettings",
+                                                 "pso", "delete"), pso_name,
+                                                 "-H", self.server,
+                                                 self.user_auth)
+        self.assertCmdSuccess(result, out, err)
+        self.assertEquals(err,"","Shouldn't be any error messages")
+        self.assertIn("Deleted PSO", out)
+        dn = "CN=%s,%s" %(pso_name, self.pso_container)
+        self.obj_cleanup.remove(dn)
+
+        # check the object no longer exists in the DB
+        try:
+            res = self.ldb.search(dn, scope=ldb.SCOPE_BASE, attrs=['name'])
+            self.fail("PSO shouldn't exist")
+        except ldb.LdbError as e:
+            (enum, estr) = e.args
+            self.assertEquals(enum, ldb.ERR_NO_SUCH_OBJECT)
+
+        # run the same cmd again - it should fail because PSO no longer exists
+        (result, out, err) = self.runsublevelcmd("domain", ("passwordsettings",
+                                                 "pso", "delete"), pso_name,
+                                                 "-H", self.server,
+                                                 self.user_auth)
+        self.assertCmdFail(result, "Deleteing a non-existent PSO should fail")
+        self.assertIn("Unable to find PSO", err)
+
+    def check_pso_applied(self, user, pso):
+        """Checks that the correct PSO is applied to a given user"""
+
+        # first check the samba-tool output tells us the correct PSO is applied
+        (result, out, err) = self.runsublevelcmd("domain", ("passwordsettings",
+                                                 "pso", "show-user"), user.name,
+                                                 "-H", self.server,
+                                                 self.user_auth)
+        self.assertCmdSuccess(result, out, err)
+        self.assertEquals(err,"","Shouldn't be any error messages")
+        if pso is None:
+            self.assertIn("No PSO applies to user", out)
+        else:
+            self.assertIn(pso.name, out)
+
+        # then check the DB tells us the same thing
+        if pso is None:
+            self.assertEquals(user.get_resultant_PSO(), None)
+        else:
+            self.assertEquals(user.get_resultant_PSO(), pso.dn)
+
+    def test_pso_apply_to_user(self):
+        """Checks we can apply/unapply a PSO to a user"""
+
+        pso_name = "test-apply-PSO"
+        test_pso = self._create_pso(pso_name)
+
+        # check that a new user has no PSO applied by default
+        user = TestUser("test-PSO-user", self.ldb)
+        self.obj_cleanup.append(user.dn)
+        self.check_pso_applied(user, pso=None)
+
+        # add the user to a new group
+        group_name = "test-PSO-group"
+        dn = "CN=%s,%s" %(group_name, self.ldb.domain_dn())
+        self.ldb.add({"dn": dn, "objectclass": "group",
+                      "sAMAccountName": group_name})
+        self.obj_cleanup.append(dn)
+        m = ldb.Message()
+        m.dn = ldb.Dn(self.ldb, dn)
+        m["member"] = ldb.MessageElement(user.dn, ldb.FLAG_MOD_ADD, "member")
+        self.ldb.modify(m)
+
+        # check samba-tool can successfully link a PSO to a group
+        (result, out, err) = self.runsublevelcmd("domain", ("passwordsettings",
+                                                 "pso", "apply"), pso_name,
+                                                 group_name, "-H", self.server,
+                                                 self.user_auth)
+        self.assertCmdSuccess(result, out, err)
+        self.assertEquals(err,"","Shouldn't be any error messages")
+        self.check_pso_applied(user, pso=test_pso)
+
+        # we should fail if we try to apply the same PSO/group twice though
+        (result, out, err) = self.runsublevelcmd("domain", ("passwordsettings",
+                                                 "pso", "apply"), pso_name,
+                                                 group_name, "-H", self.server,
+                                                 self.user_auth)
+        self.assertCmdFail(result, "Shouldn't be able to apply PSO twice")
+        self.assertIn("already applies", err)
+
+        # check samba-tool can successfully link a PSO to a user
+        (result, out, err) = self.runsublevelcmd("domain", ("passwordsettings",
+                                                 "pso", "apply"), pso_name,
+                                                 user.name, "-H", self.server,
+                                                 self.user_auth)
+        self.assertCmdSuccess(result, out, err)
+        self.assertEquals(err,"","Shouldn't be any error messages")
+        self.check_pso_applied(user, pso=test_pso)
+
+        # check samba-tool can successfully unlink a group from a PSO
+        (result, out, err) = self.runsublevelcmd("domain", ("passwordsettings",
+                                                 "pso", "unapply"), pso_name,
+                                                 group_name, "-H", self.server,
+                                                 self.user_auth)
+        self.assertCmdSuccess(result, out, err)
+        self.assertEquals(err,"","Shouldn't be any error messages")
+        # PSO still applies directly to the user, even though group was removed
+        self.check_pso_applied(user, pso=test_pso)
+
+        # check samba-tool can successfully unlink a user from a PSO
+        (result, out, err) = self.runsublevelcmd("domain", ("passwordsettings",
+                                                 "pso", "unapply"), pso_name,
+                                                 user.name, "-H", self.server,
+                                                 self.user_auth)
+        self.assertCmdSuccess(result, out, err)
+        self.assertEquals(err,"","Shouldn't be any error messages")
+        self.check_pso_applied(user, pso=None)
+
+    def test_pso_unpriv(self):
+        """Checks unprivileged users can't modify PSOs via samba-tool"""
+
+        # create a dummy PSO and a non-admin user
+        pso_name = "test-unpriv-PSO"
+        test_pso = self._create_pso(pso_name)
+        user = TestUser("test-unpriv-user", self.ldb)
+        self.obj_cleanup.append(user.dn)
+        unpriv_auth = "-U%s%%%s" %(user.name, user.get_password())
+
+        # check we need admin privileges to be able to do anything to PSOs
+        (result, out, err) = self.runsublevelcmd("domain", ("passwordsettings",
+                                                 "pso", "set"), pso_name,
+                                                 "--complexity=off", "-H",
+                                                 self.server, unpriv_auth)
+        self.assertCmdFail(result, "Need admin privileges to modify PSO")
+        self.assertIn("You may not have permission", err)
+
+        (result, out, err) = self.runsublevelcmd("domain", ("passwordsettings",
+                                                 "pso", "create"), "bad-perm",
+                                                 "250", "--complexity=off",
+                                                 "-H", self.server, unpriv_auth)
+        self.assertCmdFail(result, "Need admin privileges to modify PSO")
+        self.assertIn("Administrator permissions are needed", err)
+
+        (result, out, err) = self.runsublevelcmd("domain", ("passwordsettings",
+                                                 "pso", "delete"), pso_name,
+                                                 "-H", self.server, unpriv_auth)
+        self.assertCmdFail(result, "Need admin privileges to delete PSO")
+        self.assertIn("You may not have permission", err)
+
+        (result, out, err) = self.runsublevelcmd("domain", ("passwordsettings",
+                                                 "pso", "show"), pso_name,
+                                                 "-H", self.server, unpriv_auth)
+        self.assertCmdFail(result, "Need admin privileges to view PSO")
+        self.assertIn("You may not have permission", err)
+
+        (result, out, err) = self.runsublevelcmd("domain", ("passwordsettings",
+                                                 "pso", "apply"), pso_name,
+                                                 user.name, "-H", self.server,
+                                                 unpriv_auth)
+        self.assertCmdFail(result, "Need admin privileges to modify PSO")
+        self.assertIn("You may not have permission", err)
+
+        (result, out, err) = self.runsublevelcmd("domain", ("passwordsettings",
+                                                 "pso", "unapply"), pso_name,
+                                                 user.name, "-H", self.server,
+                                                 unpriv_auth)
+        self.assertCmdFail(result, "Need admin privileges to modify PSO")
+        self.assertIn("You may not have permission", err)
+
+        # The 'list' command actually succeeds because it's not easy to tell
+        # whether we got no results due to lack of permissions, or because
+        # there were no PSOs to display
+        (result, out, err) = self.runsublevelcmd("domain", ("passwordsettings",
+                                                 "pso", "list"), "-H",
+                                                 self.server, unpriv_auth)
+        self.assertCmdSuccess(result, out, err)
+        self.assertIn("No PSOs", out)
+        self.assertIn("permission", out)
+
+    def test_domain_passwordsettings(self):
+        """Checks the 'set/show' commands for the domain settings (non-PSO)"""
+
+        # check the 'show' cmd for the domain settings
+        (result, out, err) = self.runsublevelcmd("domain", ("passwordsettings",
+                                                 "show"), "-H", self.server,
+                                                 self.user_auth)
+        self.assertCmdSuccess(result, out, err)
+        self.assertEquals(err,"","Shouldn't be any error messages")
+
+        # check an arbitrary setting is displayed correctly
+        min_pwd_len = self.ldb.get_minPwdLength()
+        self.assertIn("Minimum password length: %s" % min_pwd_len, out)
+
+        # check we can change the domain setting
+        self.addCleanup(self.ldb.set_minPwdLength, min_pwd_len)
+        new_len = int(min_pwd_len) + 3
+        (result, out, err) = self.runsublevelcmd("domain", ("passwordsettings",
+                                                 "set"),
+                                                 "--min-pwd-length=%u" % new_len,
+                                                 "-H", self.server,
+                                                 self.user_auth)
+        self.assertCmdSuccess(result, out, err)
+        self.assertEquals(err,"","Shouldn't be any error messages")
+        self.assertIn("successful", out)
+
+        # check the updated value is now displayed
+        (result, out, err) = self.runsublevelcmd("domain", ("passwordsettings",
+                                                 "show"), "-H", self.server,
+                                                 self.user_auth)
+        self.assertCmdSuccess(result, out, err)
+        self.assertEquals(err,"","Shouldn't be any error messages")
+        self.assertIn("Minimum password length: %u" % new_len, out)
+
diff --git a/source4/selftest/tests.py b/source4/selftest/tests.py
index 405f2b5..88af607 100755
--- a/source4/selftest/tests.py
+++ b/source4/selftest/tests.py
@@ -615,6 +615,7 @@ planpythontestsuite("ad_dc_ntvfs:local", "samba.tests.samba_tool.computer")
 planpythontestsuite("ad_dc:local", "samba.tests.samba_tool.ntacl")
 planpythontestsuite("none", "samba.tests.samba_tool.provision_password_check")
 planpythontestsuite("none", "samba.tests.samba_tool.help")
+planpythontestsuite("ad_dc_ntvfs:local", "samba.tests.samba_tool.passwordsettings")
 
 # Run these against chgdcpass to share the runtime load
 planpythontestsuite("chgdcpass:local", "samba.tests.samba_tool.sites")
-- 
2.7.4



More information about the samba-technical mailing list