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

Tim Beale timbeale at catalyst.net.nz
Tue May 22 05:50:34 UTC 2018


Hi,

Attached is the latest patch-set that contains the functional changes
required to support PSOs. Changes can also be viewed here:
https://gitlab.com/catalyst-samba/samba/commits/tim-pso

And proof they run through the CI tests successfully here:
https://gitlab.com/catalyst-samba/samba/pipelines/22403197

Note: the domain.py file move work is still waiting for the backup
changes to go in first (I haven't forgotten about it).

Thanks,
Tim

On 16/05/18 19:58, William Brown via samba-technical wrote:
> On Wed, 2018-05-16 at 19:50 +1200, Andrew Bartlett wrote:
>> On Wed, 2018-05-16 at 11:28 +1200, Tim Beale via samba-technical
>> wrote:
>>> Hi William,
>>>
>>> Thanks for the feedback. That's a good point about the netcmd code
>>> layout.
>>>
>>> Given that the new PSO samba-tool code is pretty self-contained,
>>> and
>>> domain.py is already 4000+ lines long, I think it'd make sense to
>>> keep
>>> it as a separate file.
>>>
>>> However, one problem is the python import won't like having both a
>>> domain.py file and a domain/ sub-directory. So are you suggesting
>>> that I
>>> move netcmd/domain.py --> netcmd/domain/__init__.py? I just thought
>>> I'd
>>> double-check, as that might have ramifications for other people
>>> with
>>> back-porting patches, or for work in progress changes, etc.
>> I think this (the rename of domain.py -> domain/__init__.py) is the
>> most resaonble, but once we land the backup changes so as not to make
>> that any harder than is already is.  
> This is what I meant :) 
>
> I have some changes to domain.py too I think in my recent patchsets ...
>
>> Thanks,
>>
>> Andrew Bartlett

-------------- next part --------------
From 6652fa607845e8eceb133be88203141b7529f657 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 01/15] 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 | 290 ++++++++++++++++++++++++++-
 2 files changed, 288 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..a19b24d 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,268 @@ 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_module *module, TALLOC_CTX *mem_ctx,
+			      struct ldb_request *parent,
+			      struct dom_sid *sid_array, unsigned int num_sids,
+			      struct ldb_result **result)
+{
+	int ret;
+	int i;
+	struct ldb_context *ldb = ldb_module_get_ctx(module);
+	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 = dsdb_module_search(module, mem_ctx, result, psc_dn,
+				 LDB_SCOPE_SUBTREE, attrs,
+				 DSDB_FLAG_NEXT_MODULE, parent,
+				 "(&(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_module *module, TALLOC_CTX *mem_ctx,
+			 struct ldb_request *parent, 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(module, mem_ctx, parent, sid_array, num_sids,
+				 &res);
+	if (ret != LDB_SUCCESS) {
+		DBG_ERR("Error %d retrieving PSO for SID(s)\n", ret);
+		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_module *module,
+			    struct ldb_message *user_msg,
+			    struct ldb_request *parent,
+                            struct ldb_message **pso_msg)
+{
+	bool pso_supported;
+	struct dom_sid *groupSIDs = NULL;
+	unsigned int num_groupSIDs = 0;
+	struct ldb_context *ldb = ldb_module_get_ctx(module);
+	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(module, tmp_ctx, parent, user_sid, 1,
+				    &best_pso);
+		if (ret != LDB_SUCCESS) {
+			talloc_free(tmp_ctx);
+			return ret;
+		}
+
+		if (best_pso != NULL) {
+			*pso_msg = best_pso;
+			return LDB_SUCCESS;
+		}
+	}
+
+	/*
+	 * If no valid PSO applies directly to the user, then try its groups.
+	 * Work out 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) {
+		DBG_ERR("Error %d determining group SIDs for %s\n", ret,
+			ldb_dn_get_linearized(user_msg->dn));
+		talloc_free(tmp_ctx);
+		return ret;
+	}
+
+	/* lookup the best PSO that applies to any of these groups */
+	ret = pso_find_best(module, tmp_ctx, parent, 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;
+
+	/* work out the PSO (if any) that applies to this user */
+	ret = get_pso_for_user(module, msg, parent, &pso);
+	if (ret != LDB_SUCCESS) {
+		DBG_ERR("Couldn't determine PSO for %s\n",
+			ldb_dn_get_linearized(msg->dn));
+		return ret;
+	}
+
+	if (pso != NULL) {
+		DBG_INFO("%s is resultant PSO for user %s\n",
+			 ldb_dn_get_linearized(pso->dn),
+			 ldb_dn_get_linearized(msg->dn));
+		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 +1193,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 +1223,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 3e2880dcd8150541a201ca95781ab3d175045b24 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 02/15] 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 4ba534f..7712d24 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 5723d976882e80f7deae1accfdf987ef2e9fc19e 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 03/15] 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 2641cb9..80e6348 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 012e2e8132e464fd5e9f62a0e5a5f10a3a6e61be 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 04/15] 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 | 68 ++++++++++++++++++++++++----
 1 file changed, 60 insertions(+), 8 deletions(-)

diff --git a/source4/dsdb/samdb/ldb_modules/operational.c b/source4/dsdb/samdb/ldb_modules/operational.c
index a19b24d..3ff6e34 100644
--- a/source4/dsdb/samdb/ldb_modules/operational.c
+++ b/source4/dsdb/samdb/ldb_modules/operational.c
@@ -97,6 +97,11 @@ enum search_type {
 	ACCOUNT_GROUPS
 };
 
+static int get_pso_for_user(struct ldb_module *module,
+			    struct ldb_message *user_msg,
+			    struct ldb_request *parent,
+			    struct ldb_message **pso_msg);
+
 /*
   construct a canonical name from a message
 */
@@ -769,6 +774,36 @@ 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_module *module,
+				         struct ldb_message *user_msg,
+				         struct ldb_request *parent,
+					 struct ldb_dn *nc_root)
+{
+	int ret;
+	struct ldb_message *pso = NULL;
+	struct ldb_context *ldb = ldb_module_get_ctx(module);
+
+	/* if a PSO applies to the user, use its lockoutDuration */
+	ret = get_pso_for_user(module, user_msg, parent, &pso);
+	if (ret != LDB_SUCCESS) {
+
+		/* log the error, but fallback to the domain default */
+		DBG_ERR("Error retrieving PSO for %s\n",
+			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 +841,13 @@ 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(module, msg,
+								    parent,
+								    nc_root);
+
+			/* zero locks out until the administrator intervenes */
 			if (lockoutDuration >= 0) {
 				msDS_User_Account_Control_Computed |= UF_LOCKOUT;
 			} else if (lockoutTime - lockoutDuration >= now) {
@@ -953,6 +992,7 @@ static int pso_search_by_sids(struct ldb_module *module, TALLOC_CTX *mem_ctx,
 	const char *attrs[] = {
 		"msDS-PasswordSettingsPrecedence",
 		"objectGUID",
+		"msDS-LockoutDuration",
 		NULL
 	};
 
@@ -1164,6 +1204,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[] =
 {
@@ -1183,6 +1238,7 @@ static const char *user_account_control_computed_attrs[] =
 {
 	"lockoutTime",
 	"pwdLastSet",
+	PSO_ATTR_DEPENDENCIES,
 	NULL
 };
 
@@ -1195,11 +1251,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 dfece5a23750ce8981739006a6cab61c60eb3797 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 05/15] 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 bf086f7..eccf9d2 100644
--- a/source4/rpc_server/samr/dcesrv_samr.c
+++ b/source4/rpc_server/samr/dcesrv_samr.c
@@ -2781,6 +2781,7 @@ static NTSTATUS dcesrv_samr_QueryUserInfo(struct dcesrv_call_state *dce_call, TA
 						      "badPasswordTime",
 						      "logonCount",
 						      "pwdLastSet",
+						      "msDS-ResultantPSO",
 						      "msDS-UserPasswordExpiryTimeComputed",
 						      "accountExpires",
 						      "userAccountControl",
@@ -2887,6 +2888,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 22f38cbf0d71c033059e03f7cd26f72bf6d82cc5 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 06/15] 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 d7b5e16..db202cc 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",
@@ -301,6 +302,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 5f1923957a58b5d6a0804de86e45ee25cbee6278 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 07/15] 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 80e6348..ac97415 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:
@@ -4491,6 +4650,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 a0eb9dcf23b63b4e22bcd30dd9504f7a31fcac40 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 08/15] 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 65d22ea..28d1b5c 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 060ce4b546efeffa8197c699da4bfd8897e61ace 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 09/15] 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 ac97415..56ecdaf 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 58bc5ef9f2ac744ee3aa4f769fb235c0f55b0230 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 10/15] 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 | 43 +++++++++++++++++++++++++---
 2 files changed, 39 insertions(+), 6 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 3ff6e34..e0959f9 100644
--- a/source4/dsdb/samdb/ldb_modules/operational.c
+++ b/source4/dsdb/samdb/ldb_modules/operational.c
@@ -695,11 +695,43 @@ 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_module *module,
+				    struct ldb_message *user_msg,
+				    struct ldb_request *parent,
+				    struct ldb_dn *nc_root)
+{
+	int ret;
+	struct ldb_message *pso = NULL;
+	struct ldb_context *ldb = ldb_module_get_ctx(module);
+
+	/* if a PSO applies to the user, use its maxPwdAge */
+	ret = get_pso_for_user(module, user_msg, parent, &pso);
+	if (ret != LDB_SUCCESS) {
+
+		/* log the error, but fallback to the domain default */
+		DBG_ERR("Error retrieving PSO for %s\n",
+			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
 */
 static NTTIME get_msds_user_password_expiry_time_computed(struct ldb_module *module,
 						struct ldb_message *msg,
+						struct ldb_request *parent,
 						struct ldb_dn *domain_dn)
 {
 	int64_t pwdLastSet, maxPwdAge;
@@ -742,8 +774,7 @@ 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(module, msg, parent, domain_dn);
 	if (maxPwdAge >= -864000000000) {
 		/*
 		 * This is not really possible...
@@ -859,7 +890,9 @@ static int construct_msds_user_account_control_computed(struct ldb_module *modul
 	if (!(userAccountControl & _UF_NO_EXPIRY_ACCOUNTS)) {
 		NTTIME must_change_time
 			= get_msds_user_password_expiry_time_computed(module,
-								      msg, nc_root);
+								      msg,
+								      parent,
+								      nc_root);
 		/* check for expired password */
 		if (must_change_time < now) {
 			msDS_User_Account_Control_Computed |= UF_PASSWORD_EXPIRED;
@@ -900,7 +933,7 @@ static int construct_msds_user_password_expiry_time_computed(struct ldb_module *
 
 	password_expiry_time
 		= get_msds_user_password_expiry_time_computed(module, msg,
-							      nc_root);
+							      parent, nc_root);
 
 	return samdb_msg_add_int64(ldb,
 				   msg->elements, msg,
@@ -993,6 +1026,7 @@ static int pso_search_by_sids(struct ldb_module *module, TALLOC_CTX *mem_ctx,
 		"msDS-PasswordSettingsPrecedence",
 		"objectGUID",
 		"msDS-LockoutDuration",
+		"msDS-MaximumPasswordAge",
 		NULL
 	};
 
@@ -1246,6 +1280,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 51d7235a1612550f10ab28a6fdff00dbb1233ca6 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 11/15] 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>
---
 docs-xml/manpages/samba-tool.8.xml |  45 +++
 python/samba/netcmd/domain.py      |   3 +
 python/samba/netcmd/pso.py         | 766 +++++++++++++++++++++++++++++++++++++
 3 files changed, 814 insertions(+)
 create mode 100644 python/samba/netcmd/pso.py

diff --git a/docs-xml/manpages/samba-tool.8.xml b/docs-xml/manpages/samba-tool.8.xml
index 3173083..f2154b9 100644
--- a/docs-xml/manpages/samba-tool.8.xml
+++ b/docs-xml/manpages/samba-tool.8.xml
@@ -334,6 +334,51 @@
 </refsect3>
 
 <refsect3>
+	<title>domain passwordsettings pso</title>
+	<para>Manage fine-grained Password Settings Objects (PSOs).</para>
+</refsect3>
+
+<refsect3>
+	<title>domain passwordsettings pso apply <replaceable>pso-name</replaceable> <replaceable>user-or-group-name</replaceable> [options]</title>
+	<para>Applies a PSO's password policy to a user or group.</para>
+</refsect3>
+
+<refsect3>
+	<title>domain passwordsettings pso create <replaceable>pso-name</replaceable> <replaceable>precedence</replaceable> [options]</title>
+	<para>Creates a new Password Settings Object (PSO).</para>
+</refsect3>
+
+<refsect3>
+	<title>domain passwordsettings pso delete <replaceable>pso-name</replaceable> [options]</title>
+	<para>Deletes a Password Settings Object (PSO).</para>
+</refsect3>
+
+<refsect3>
+	<title>domain passwordsettings pso list [options]</title>
+	<para>Lists all Password Settings Objects (PSOs).</para>
+</refsect3>
+
+<refsect3>
+	<title>domain passwordsettings pso set <replaceable>pso-name</replaceable> [options]</title>
+	<para>Modifies a Password Settings Object (PSO).</para>
+</refsect3>
+
+<refsect3>
+	<title>domain passwordsettings pso show <replaceable>user-name</replaceable> [options]</title>
+	<para>Displays a Password Settings Object (PSO).</para>
+</refsect3>
+
+<refsect3>
+	<title>domain passwordsettings pso show-user <replaceable>pso-name</replaceable> [options]</title>
+	<para>Displays the Password Settings that apply to a user.</para>
+</refsect3>
+
+<refsect3>
+	<title>domain passwordsettings pso unapply <replaceable>pso-name</replaceable> <replaceable>user-or-group-name</replaceable> [options]</title>
+	<para>Updates a PSO to no longer apply to a user or group.</para>
+</refsect3>
+
+<refsect3>
 	<title>domain provision</title>
 	<para>Promote an existing domain member or NT4 PDC to an AD DC.</para>
 </refsect3>
diff --git a/python/samba/netcmd/domain.py b/python/samba/netcmd/domain.py
index f4a689b..6698fc9 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,
@@ -1530,6 +1532,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..b12f00d
--- /dev/null
+++ b/python/samba/netcmd/pso.py
@@ -0,0 +1,766 @@
+# Manages Password Settings Objects
+#
+# Copyright (C) Andrew Bartlett <abartlet at samba.org> 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 57d2ad296e03a578aaf4bd3826e416baa9042794 Mon Sep 17 00:00:00 2001
From: Tim Beale <timbeale at catalyst.net.nz>
Date: Fri, 11 May 2018 11:49:23 +1200
Subject: [PATCH 12/15] tests: Extend passwordsettings tests to cover PSO
 command options

Add test cases for the new PSO samba-tool command options.

Signed-off-by: Tim Beale <timbeale at catalyst.net.nz>
---
 python/samba/tests/samba_tool/passwordsettings.py | 369 ++++++++++++++++++++++
 1 file changed, 369 insertions(+)

diff --git a/python/samba/tests/samba_tool/passwordsettings.py b/python/samba/tests/samba_tool/passwordsettings.py
index 5766352..7c1afc8 100644
--- a/python/samba/tests/samba_tool/passwordsettings.py
+++ b/python/samba/tests/samba_tool/passwordsettings.py
@@ -19,6 +19,7 @@
 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"""
@@ -29,9 +30,377 @@ class PwdSettingsCmdTestCase(SambaToolCmdTest):
         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)"""
-- 
2.7.4


From 114055bd8a648000fe96b19bc3132875652faeb0 Mon Sep 17 00:00:00 2001
From: Tim Beale <timbeale at catalyst.net.nz>
Date: Fri, 11 May 2018 09:29:01 +1200
Subject: [PATCH 13/15] tests: Add tests for domain pwdHistoryLength

This is not related to PSOs at all, but there's a minor discrepancy
between Windows and Samba password-history-length behaviour that I
noticed during PSO testing.

When the pwdHistoryLength changes from zero to non-zero, Windows
includes the user's current password as invalid immediately, whereas
Samba only includes it as invalid *after* it next changes. It's a
fairly obscure corner-case, and we might not care enough about it to
fix it. However, I've added a test case to highlight the difference and
marked it as a known-fail for now.

I also added a general pwdHistoryLength test case to show that the
basics work (this didn't seem to be tested anywhere else).

Signed-off-by: Tim Beale <timbeale at catalyst.net.nz>
---
 selftest/knownfail.d/password_settings         |  3 ++
 source4/dsdb/tests/python/password_settings.py | 62 ++++++++++++++++++++++++++
 2 files changed, 65 insertions(+)
 create mode 100644 selftest/knownfail.d/password_settings

diff --git a/selftest/knownfail.d/password_settings b/selftest/knownfail.d/password_settings
new file mode 100644
index 0000000..bbca928
--- /dev/null
+++ b/selftest/knownfail.d/password_settings
@@ -0,0 +1,3 @@
+# highlights a minor corner-case discrepancy between Windows and Samba
+samba4.ldap.password_settings.python.password_settings.PasswordSettingsTestCase.test_domain_pwd_history_zero\(ad_dc_ntvfs\)
+
diff --git a/source4/dsdb/tests/python/password_settings.py b/source4/dsdb/tests/python/password_settings.py
index 7712d24..a5de7ff 100644
--- a/source4/dsdb/tests/python/password_settings.py
+++ b/source4/dsdb/tests/python/password_settings.py
@@ -794,4 +794,66 @@ unicodePwd:: %s
 """ % (userdn, password)
         self.ldb.modify_ldif(ldif)
 
+    def set_domain_pwdHistoryLength(self, value):
+        m = ldb.Message()
+        m.dn = ldb.Dn(self.ldb, self.ldb.domain_dn())
+        m["pwdHistoryLength"] = ldb.MessageElement(value, ldb.FLAG_MOD_REPLACE, "pwdHistoryLength")
+        self.ldb.modify(m)
+
+    def test_domain_pwd_history(self):
+        """Non-PSO test for domain's pwdHistoryLength setting"""
+
+        # restore the current pwdHistoryLength setting after the test completes
+        curr_hist_len = str(self.pwd_defaults.history_len)
+        self.addCleanup(self.set_domain_pwdHistoryLength, curr_hist_len)
+
+        self.set_domain_pwdHistoryLength("4")
+        user = self.add_user("testuser")
+
+        initial_pwd = user.get_password()
+        passwords = ["First12#", "Second12#", "Third12#", "Fourth12#"]
+
+        # we should be able to set the password to new values OK
+        for pwd in passwords:
+            self.assert_password_valid(user, pwd)
+
+        # the 2nd time round it should fail because they're in the history now
+        for pwd in passwords:
+            self.assert_password_invalid(user, pwd)
+
+        # but the initial password is now outside the history, so should be OK
+        self.assert_password_valid(user, initial_pwd)
+
+        # if we set the history to zero, all the old passwords should now be OK
+        self.set_domain_pwdHistoryLength("0")
+        for pwd in passwords:
+            self.assert_password_valid(user, pwd)
+
+    def test_domain_pwd_history_zero(self):
+        """Non-PSO test for pwdHistoryLength going from zero to non-zero"""
+
+        # restore the current pwdHistoryLength setting after the test completes
+        curr_hist_len = str(self.pwd_defaults.history_len)
+        self.addCleanup(self.set_domain_pwdHistoryLength, curr_hist_len)
+
+        self.set_domain_pwdHistoryLength("0")
+        user = self.add_user("testuser")
+
+        initial_pwd = user.get_password()
+        self.assert_password_valid(user, "NewPwd12#")
+        # we can set the exact same password again because there's no history
+        self.assert_password_valid(user, "NewPwd12#")
+
+        # There is a difference in behaviour here between Windows and Samba.
+        # When going from zero to non-zero password-history, Windows treats
+        # the current user's password as invalid (even though the password has
+        # not been altered since the setting changed). Whereas Samba accepts
+        # the current password (because it's not in the history until the
+        # *next* time the user's password changes.
+        self.set_domain_pwdHistoryLength("1")
+        self.assert_password_invalid(user, "NewPwd12#")
+
+
+
+
 
-- 
2.7.4


From 01f0ea86d65685f17b98af7f41cb855f8ea034be Mon Sep 17 00:00:00 2001
From: Tim Beale <timbeale at catalyst.net.nz>
Date: Tue, 15 May 2018 14:02:32 +1200
Subject: [PATCH 14/15] dsdb: Avoid performance hit if PSOs aren't actually
 used

The new PSO code adds some additional overhead in extra lookups. To
avoid penalizing existing setups, we can short-circuit the PSO
processing and return early if there are no actual PSO objects in the
DB. The one-level search should be very quick, and it avoids the need to
do more complicated PSO processing (i.e. expanding the nested groups).

The longer-term plan is to rework the tokenGroups lookup so that it only
gets done once, and the result can then be reused by the resultant-PSO
code (rather than computing the nested-groups again). However, in the
short-term, a slight decrease in performance is the price for any users
that want to deploy PSOs.

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

diff --git a/source4/dsdb/samdb/ldb_modules/operational.c b/source4/dsdb/samdb/ldb_modules/operational.c
index e0959f9..993a311 100644
--- a/source4/dsdb/samdb/ldb_modules/operational.c
+++ b/source4/dsdb/samdb/ldb_modules/operational.c
@@ -982,6 +982,43 @@ static bool pso_is_supported(struct ldb_context *ldb, struct ldb_message *msg)
 }
 
 /*
+ * Returns the number of PSO objects that exist in the DB
+ */
+static int get_pso_count(struct ldb_module *module, TALLOC_CTX *mem_ctx,
+			 struct ldb_request *parent, int *pso_count)
+{
+	static const char * const attrs[] = { NULL };
+	int ret;
+	struct ldb_dn *domain_dn = NULL;
+	struct ldb_dn *psc_dn = NULL;
+	struct ldb_result *res = NULL;
+	struct ldb_context *ldb = ldb_module_get_ctx(module);
+
+	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);
+	}
+
+	/* get the number of PSO children */
+	ret = dsdb_module_search(module, mem_ctx, &res, psc_dn,
+				 LDB_SCOPE_ONELEVEL, attrs,
+				 DSDB_FLAG_NEXT_MODULE, parent,
+				 "(objectClass=msDS-PasswordSettings)");
+	if (ret != LDB_SUCCESS) {
+		return ret;
+	}
+
+	*pso_count = res->count;
+	talloc_free(res);
+	talloc_free(psc_dn);
+
+	return LDB_SUCCESS;
+}
+
+/*
  * 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.
@@ -1111,6 +1148,7 @@ static int get_pso_for_user(struct ldb_module *module,
 	int ret;
 	struct ldb_message_element *el = NULL;
 	TALLOC_CTX *tmp_ctx = NULL;
+	int pso_count = 0;
 
 	*pso_msg = NULL;
 
@@ -1150,8 +1188,24 @@ static int get_pso_for_user(struct ldb_module *module,
 
 	/*
 	 * If no valid PSO applies directly to the user, then try its groups.
-	 * Work out the SIDs of any account groups the user is a member of
+	 * The group expansion is expensive, so check there are actually
+	 * PSOs in the DB first (which is a quick search). Note in the above
+	 * case we could tell that a PSO applied to the user, based on info
+	 * already retrieved by the user search.
 	 */
+	ret = get_pso_count(module, tmp_ctx, parent, &pso_count);
+	if (ret != LDB_SUCCESS) {
+		DBG_ERR("Error %d determining PSOs in system\n", ret);
+		talloc_free(tmp_ctx);
+		return ret;
+	}
+
+	if (pso_count == 0) {
+		talloc_free(tmp_ctx);
+		return LDB_SUCCESS;
+	}
+
+	/* Work out 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);
-- 
2.7.4


From 492d5c1e6e6c6a0fbab9c93f8399242b064e1a7a Mon Sep 17 00:00:00 2001
From: Tim Beale <timbeale at catalyst.net.nz>
Date: Wed, 16 May 2018 09:45:32 +1200
Subject: [PATCH 15/15] dsdb: Avoid calculating the PSO multiple times

In a typical user login query, the code tries to work out the PSO 2-3
times - once for the msDS-ResultantPSO attribute, and then again for the
msDS-User-Account-Control-Computed & msDS-UserPasswordExpiryTimeComputed
constructed attributes.

The PSO calculation is reasonably expensive, mostly due to the nested
groups calculation. If we've already constructed the msDS-ResultantPSO
attribute, then we can save ourselves extra work by just re-fetching the
result directly, rather than expanding the nested groups again from
scratch.

The previous patch improves efficiency when there are no PSOs in the
system. This should improve the case where there are PSOs that apply to
the users. (Unfortunately, it won't help where there are some PSOs in
the system, but no PSO applies to the user being queried).

Also updated sam.c so the msDS-ResultantPSO gets calculated first,
before the other constructed attributes.

Signed-off-by: Tim Beale <timbeale at catalyst.net.nz>
---
 source4/auth/sam.c                           |  3 ++-
 source4/dsdb/samdb/ldb_modules/operational.c | 34 +++++++++++++++++++++++++++-
 2 files changed, 35 insertions(+), 2 deletions(-)

diff --git a/source4/auth/sam.c b/source4/auth/sam.c
index eacd651..e55d28e 100644
--- a/source4/auth/sam.c
+++ b/source4/auth/sam.c
@@ -65,6 +65,8 @@ const char *server_attrs[] = {
 };
 
 const char *user_attrs[] = {
+	"msDS-ResultantPSO",
+
 	KRBTGT_ATTRS,
 
 	"logonHours",
@@ -100,7 +102,6 @@ const char *user_attrs[] = {
 	"badPasswordTime",
 	"lmPwdHistory",
 	"ntPwdHistory",
-	"msDS-ResultantPSO",
 	NULL,
 };
 
diff --git a/source4/dsdb/samdb/ldb_modules/operational.c b/source4/dsdb/samdb/ldb_modules/operational.c
index 993a311..a8f46e5 100644
--- a/source4/dsdb/samdb/ldb_modules/operational.c
+++ b/source4/dsdb/samdb/ldb_modules/operational.c
@@ -1145,10 +1145,17 @@ static int get_pso_for_user(struct ldb_module *module,
 	unsigned int num_groupSIDs = 0;
 	struct ldb_context *ldb = ldb_module_get_ctx(module);
 	struct ldb_message *best_pso = NULL;
+	struct ldb_dn *pso_dn = NULL;
 	int ret;
 	struct ldb_message_element *el = NULL;
 	TALLOC_CTX *tmp_ctx = NULL;
 	int pso_count = 0;
+	struct ldb_result *res = NULL;
+	static const char *attrs[] = {
+		"msDS-LockoutDuration",
+		"msDS-MaximumPasswordAge",
+		NULL
+	};
 
 	*pso_msg = NULL;
 
@@ -1162,6 +1169,31 @@ static int get_pso_for_user(struct ldb_module *module,
 	tmp_ctx = talloc_new(user_msg);
 
 	/*
+	 * Several different constructed attributes try to use the PSO info. If
+	 * we've already constructed the msDS-ResultantPSO for this user, we can
+	 * just re-use the result, rather than calculating it from scratch again
+	 */
+	pso_dn = ldb_msg_find_attr_as_dn(ldb, tmp_ctx, user_msg,
+					 "msDS-ResultantPSO");
+
+	if (pso_dn != NULL) {
+		ret = dsdb_module_search_dn(module, tmp_ctx, &res, pso_dn,
+					    attrs, DSDB_FLAG_NEXT_MODULE,
+					    parent);
+		if (ret != LDB_SUCCESS) {
+			DBG_ERR("Error %d retrieving PSO %s\n", ret,
+				ldb_dn_get_linearized(pso_dn));
+			talloc_free(tmp_ctx);
+			return ret;
+		}
+
+		if (res->count == 1) {
+			*pso_msg = res->msgs[0];
+			return LDB_SUCCESS;
+		}
+	}
+
+	/*
 	 * if any PSOs apply directly to the user, they are considered first
 	 * before we check group membership PSOs
 	 */
@@ -1190,7 +1222,7 @@ static int get_pso_for_user(struct ldb_module *module,
 	 * If no valid PSO applies directly to the user, then try its groups.
 	 * The group expansion is expensive, so check there are actually
 	 * PSOs in the DB first (which is a quick search). Note in the above
-	 * case we could tell that a PSO applied to the user, based on info
+	 * cases we could tell that a PSO applied to the user, based on info
 	 * already retrieved by the user search.
 	 */
 	ret = get_pso_count(module, tmp_ctx, parent, &pso_count);
-- 
2.7.4



More information about the samba-technical mailing list