samba-tool domain passwordsettings: Avoid except Exception
[sfrench/samba-autobuild/.git] / python / samba / netcmd / pso.py
1 # Manages Password Settings Objects
2 #
3 # Copyright (C) Andrew Bartlett <abartlet@samba.org> 2018
4 #
5 # This program is free software; you can redistribute it and/or modify
6 # it under the terms of the GNU General Public License as published by
7 # the Free Software Foundation; either version 3 of the License, or
8 # (at your option) any later version.
9 #
10 # This program is distributed in the hope that it will be useful,
11 # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13 # GNU General Public License for more details.
14 #
15 # You should have received a copy of the GNU General Public License
16 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
17 #
18 import samba
19 import samba.getopt as options
20 import ldb
21 from samba.samdb import SamDB
22 from samba.netcmd import (Command, CommandError, Option, SuperCommand)
23 from samba.dcerpc.samr import DOMAIN_PASSWORD_COMPLEX, DOMAIN_PASSWORD_STORE_CLEARTEXT
24 from samba.auth import system_session
25
26 import pdb
27
28 NEVER_TIMESTAMP = int(-0x8000000000000000)
29
30 def pso_container(samdb):
31     return "CN=Password Settings Container,CN=System,%s" % samdb.domain_dn()
32
33 def timestamp_to_mins(timestamp_str):
34     """Converts a timestamp in -100 nanosecond units to minutes"""
35     # treat a timestamp of 'never' the same as zero (this should work OK for
36     # most settings, and it displays better than trying to convert
37     # -0x8000000000000000 to minutes)
38     if int(timestamp_str) == NEVER_TIMESTAMP:
39         return 0
40     else:
41         return abs(int(timestamp_str)) / (1e7 * 60)
42
43 def timestamp_to_days(timestamp_str):
44     """Converts a timestamp in -100 nanosecond units to days"""
45     return timestamp_to_mins(timestamp_str) / (60 * 24)
46
47 def mins_to_timestamp(mins):
48     """Converts a value in minutes to -100 nanosecond units"""
49     timestamp = -int((1e7) * 60 * mins)
50     return str(timestamp)
51
52 def days_to_timestamp(days):
53     """Converts a value in days to -100 nanosecond units"""
54     timestamp = mins_to_timestamp(days * 60 * 24)
55     return str(timestamp)
56
57 def show_pso_by_dn(outf, samdb, dn, show_applies_to=True):
58     """Displays the password settings for a PSO specified by DN"""
59
60     # map from the boolean LDB value to the CLI string the user sees
61     on_off_str = {"TRUE": "on", "FALSE": "off"}
62
63     pso_attrs = ['name', 'msDS-PasswordSettingsPrecedence',
64                  'msDS-PasswordReversibleEncryptionEnabled',
65                  'msDS-PasswordHistoryLength', 'msDS-MinimumPasswordLength',
66                  'msDS-PasswordComplexityEnabled', 'msDS-MinimumPasswordAge',
67                  'msDS-MaximumPasswordAge', 'msDS-LockoutObservationWindow',
68                  'msDS-LockoutThreshold', 'msDS-LockoutDuration',
69                  'msDS-PSOAppliesTo']
70
71     res = samdb.search(dn, scope=ldb.SCOPE_BASE, attrs=pso_attrs)
72     pso_res = res[0]
73     outf.write("Password information for PSO '%s'\n" % pso_res['name'])
74     outf.write("\n")
75
76     outf.write("Precedence (lowest is best): %s\n" %
77                pso_res['msDS-PasswordSettingsPrecedence'])
78     bool_str = str(pso_res['msDS-PasswordComplexityEnabled'])
79     outf.write("Password complexity: %s\n" % on_off_str[bool_str])
80     bool_str = str(pso_res['msDS-PasswordReversibleEncryptionEnabled'])
81     outf.write("Store plaintext passwords: %s\n" % on_off_str[bool_str])
82     outf.write("Password history length: %s\n" %
83                pso_res['msDS-PasswordHistoryLength'])
84     outf.write("Minimum password length: %s\n" %
85                pso_res['msDS-MinimumPasswordLength'])
86     outf.write("Minimum password age (days): %d\n" %
87                timestamp_to_days(pso_res['msDS-MinimumPasswordAge'][0]))
88     outf.write("Maximum password age (days): %d\n" %
89                timestamp_to_days(pso_res['msDS-MaximumPasswordAge'][0]))
90     outf.write("Account lockout duration (mins): %d\n" %
91                timestamp_to_mins(pso_res['msDS-LockoutDuration'][0]))
92     outf.write("Account lockout threshold (attempts): %s\n" %
93                pso_res['msDS-LockoutThreshold'])
94     outf.write("Reset account lockout after (mins): %d\n" %
95                timestamp_to_mins(pso_res['msDS-LockoutObservationWindow'][0]))
96
97     if show_applies_to:
98         if 'msDS-PSOAppliesTo' in pso_res:
99             outf.write("\nPSO applies directly to %d groups/users:\n" %
100                        len(pso_res['msDS-PSOAppliesTo']))
101             for dn in pso_res['msDS-PSOAppliesTo']:
102                 outf.write("  %s\n" % dn)
103         else:
104             outf.write("\nNote: PSO does not apply to any users or groups.\n")
105
106 def check_pso_valid(samdb, pso_dn, name):
107     """Gracefully bail out if we can't view/modify the PSO specified"""
108     # the base scope search for the PSO throws an error if it doesn't exist
109     try:
110         res = samdb.search(pso_dn, scope=ldb.SCOPE_BASE,
111                            attrs=['msDS-PasswordSettingsPrecedence'])
112     except ldb.LdbError as e:
113         if e.args[0] == ldb.ERR_NO_SUCH_OBJECT:
114             raise CommandError("Unable to find PSO '%s'" % name)
115         raise
116
117     # users need admin permission to modify/view a PSO. In this case, the
118     # search succeeds, but it doesn't return any attributes
119     if 'msDS-PasswordSettingsPrecedence' not in res[0]:
120         raise CommandError("You may not have permission to view/modify PSOs")
121
122 def show_pso_for_user(outf, samdb, username):
123     """Displays the password settings for a specific user"""
124
125     search_filter = "(&(sAMAccountName=%s)(objectClass=user))" % username
126
127     res = samdb.search(samdb.domain_dn(), scope=ldb.SCOPE_SUBTREE,
128                        expression=search_filter,
129                        attrs=['msDS-ResultantPSO', 'msDS-PSOApplied'])
130
131     if len(res) == 0:
132         outf.write("User '%s' not found.\n" % username)
133     elif 'msDS-ResultantPSO' not in res[0]:
134         outf.write("No PSO applies to user '%s'. The default domain settings apply.\n"
135                    % username)
136         outf.write("Refer to 'samba-tool domain passwordsettings show'.\n")
137     else:
138         # sanity-check user has permissions to view PSO details (non-admin
139         # users can view msDS-ResultantPSO, but not the actual PSO details)
140         check_pso_valid(samdb, res[0]['msDS-ResultantPSO'][0], "???")
141         outf.write("The following PSO settings apply to user '%s'.\n\n" %
142                    username)
143         show_pso_by_dn(outf, samdb, res[0]['msDS-ResultantPSO'][0],
144                        show_applies_to=False)
145         # PSOs that apply directly to a user don't necessarily have the best
146         # precedence, which could be a little confusing for PSO management
147         if 'msDS-PSOApplied' in res[0]:
148             outf.write("\nNote: PSO applies directly to user (any group PSOs are overridden)\n")
149         else:
150             outf.write("\nPSO applies to user via group membership.\n")
151
152 def make_pso_ldb_msg(outf, samdb, pso_dn, create, lockout_threshold=None,
153                      complexity=None, precedence=None, store_plaintext=None,
154                      history_length=None, min_pwd_length=None,
155                      min_pwd_age=None, max_pwd_age=None, lockout_duration=None,
156                      reset_account_lockout_after=None):
157     """Packs the given PSO settings into an LDB message"""
158
159     m = ldb.Message()
160     m.dn = ldb.Dn(samdb, pso_dn)
161
162     if create:
163         ldb_oper = ldb.FLAG_MOD_ADD
164         m["msDS-objectClass"] = ldb.MessageElement("msDS-PasswordSettings",
165               ldb_oper, "objectClass")
166     else:
167         ldb_oper = ldb.FLAG_MOD_REPLACE
168
169     if precedence is not None:
170         m["msDS-PasswordSettingsPrecedence"] = ldb.MessageElement(str(precedence),
171               ldb_oper, "msDS-PasswordSettingsPrecedence")
172
173     if complexity is not None:
174         bool_str = "TRUE" if complexity == "on" else "FALSE"
175         m["msDS-PasswordComplexityEnabled"] = ldb.MessageElement(bool_str,
176               ldb_oper, "msDS-PasswordComplexityEnabled")
177
178     if store_plaintext is not None:
179         bool_str = "TRUE" if store_plaintext == "on" else "FALSE"
180         m["msDS-msDS-PasswordReversibleEncryptionEnabled"] = \
181             ldb.MessageElement(bool_str, ldb_oper,
182                                "msDS-PasswordReversibleEncryptionEnabled")
183
184     if history_length is not None:
185         m["msDS-PasswordHistoryLength"] = ldb.MessageElement(str(history_length),
186             ldb_oper, "msDS-PasswordHistoryLength")
187
188     if min_pwd_length is not None:
189         m["msDS-MinimumPasswordLength"] = ldb.MessageElement(str(min_pwd_length),
190             ldb_oper, "msDS-MinimumPasswordLength")
191
192     if min_pwd_age is not None:
193         min_pwd_age_ticks = days_to_timestamp(min_pwd_age)
194         m["msDS-MinimumPasswordAge"] = ldb.MessageElement(min_pwd_age_ticks,
195             ldb_oper, "msDS-MinimumPasswordAge")
196
197     if max_pwd_age is not None:
198         # Windows won't let you set max-pwd-age to zero. Here we take zero to
199         # mean 'never expire' and use the timestamp corresponding to 'never'
200         if max_pwd_age == 0:
201             max_pwd_age_ticks = str(NEVER_TIMESTAMP)
202         else:
203             max_pwd_age_ticks = days_to_timestamp(max_pwd_age)
204         m["msDS-MaximumPasswordAge"] = ldb.MessageElement(max_pwd_age_ticks,
205             ldb_oper, "msDS-MaximumPasswordAge")
206
207     if lockout_duration is not None:
208         lockout_duration_ticks = mins_to_timestamp(lockout_duration)
209         m["msDS-LockoutDuration"] = ldb.MessageElement(lockout_duration_ticks,
210             ldb_oper, "msDS-LockoutDuration")
211
212     if lockout_threshold is not None:
213         m["msDS-LockoutThreshold"] = ldb.MessageElement(str(lockout_threshold),
214             ldb_oper, "msDS-LockoutThreshold")
215
216     if reset_account_lockout_after is not None:
217         observation_window_ticks = mins_to_timestamp(reset_account_lockout_after)
218         m["msDS-LockoutObservationWindow"] = ldb.MessageElement(observation_window_ticks,
219             ldb_oper, "msDS-LockoutObservationWindow")
220
221     return m
222
223 def check_pso_constraints(min_pwd_length=None, history_length=None,
224                           min_pwd_age=None, max_pwd_age=None):
225     """Checks PSO settings fall within valid ranges"""
226
227     # check values as per section 3.1.1.5.2.2 Constraints in MS-ADTS spec
228     if history_length is not None and history_length > 1024:
229         raise CommandError("Bad password history length: valid range is 0 to 1024")
230
231     if min_pwd_length is not None and min_pwd_length > 255:
232         raise CommandError("Bad minimum password length: valid range is 0 to 255")
233
234     if min_pwd_age is not None and max_pwd_age is not None:
235         # note max-age=zero is a special case meaning 'never expire'
236         if min_pwd_age >= max_pwd_age and max_pwd_age != 0:
237             raise CommandError("Minimum password age must be less than the maximum age")
238
239
240 # the same args are used for both create and set commands
241 pwd_settings_options = [
242     Option("--complexity", type="choice", choices=["on","off"],
243       help="The password complexity (on | off)."),
244     Option("--store-plaintext", type="choice", choices=["on","off"],
245       help="Store plaintext passwords where account have 'store passwords with reversible encryption' set (on | off)."),
246     Option("--history-length",
247       help="The password history length (<integer>).", type=int),
248     Option("--min-pwd-length",
249       help="The minimum password length (<integer>).", type=int),
250     Option("--min-pwd-age",
251       help="The minimum password age (<integer in days>). Default is domain setting.", type=int),
252     Option("--max-pwd-age",
253       help="The maximum password age (<integer in days>). Default is domain setting.", type=int),
254     Option("--account-lockout-duration",
255       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),
256     Option("--account-lockout-threshold",
257       help="The number of bad password attempts allowed before locking out the account (<integer>). Default is domain setting.", type=int),
258     Option("--reset-account-lockout-after",
259       help="After this time is elapsed, the recorded number of attempts restarts from zero (<integer in mins>). Default is domain setting.", type=int)]
260
261 def num_options_in_args(options, args):
262     """
263     Returns the number of options specified that are present in the args.
264     (There can be other args besides just the ones we're interested in, which
265     is why argc on its own is not enough)
266     """
267     num_opts = 0
268     for opt in options:
269         for arg in args:
270             # The option should be a sub-string of the CLI argument for a match
271             if str(opt) in arg:
272                 num_opts += 1
273     return num_opts
274
275 class cmd_domain_pwdsettings_pso_create(Command):
276     """Creates a new Password Settings Object (PSO).
277
278     PSOs are a way to tailor different password settings (lockout policy,
279     minimum password length, etc) for specific users or groups.
280
281     The psoname is a unique name for the new Password Settings Object.
282     When multiple PSOs apply to a user, the precedence determines which PSO
283     will take effect. The PSO with the lowest precedence will take effect.
284
285     For most arguments, the default value (if unspecified) is the current
286     domain passwordsettings value. To see these values, enter the command
287     'samba-tool domain passwordsettings show'.
288
289     To apply the new PSO to user(s) or group(s), enter the command
290     'samba-tool domain passwordsettings pso apply'.
291     """
292
293     synopsis = "%prog <psoname> <precedence> [options]"
294
295     takes_optiongroups = {
296         "sambaopts": options.SambaOptions,
297         "versionopts": options.VersionOptions,
298         "credopts": options.CredentialsOptions,
299         }
300
301     takes_options = pwd_settings_options + [
302         Option("-H", "--URL", help="LDB URL for database or target server", type=str,
303                metavar="URL", dest="H")
304         ]
305     takes_args = ["psoname", "precedence"]
306
307     def run(self, psoname, precedence, H=None, min_pwd_age=None,
308             max_pwd_age=None, complexity=None, store_plaintext=None,
309             history_length=None, min_pwd_length=None,
310             account_lockout_duration=None, account_lockout_threshold=None,
311             reset_account_lockout_after=None, credopts=None, sambaopts=None,
312             versionopts=None):
313         lp = sambaopts.get_loadparm()
314         creds = credopts.get_credentials(lp)
315
316         samdb = SamDB(url=H, session_info=system_session(),
317             credentials=creds, lp=lp)
318
319         try:
320             precedence = int(precedence)
321         except ValueError:
322             raise CommandError("The PSO's precedence should be a numerical value. Try --help")
323
324         # sanity-check that the PSO doesn't already exist
325         pso_dn = "CN=%s,%s" % (psoname, pso_container(samdb))
326         try:
327             res = samdb.search(pso_dn, scope=ldb.SCOPE_BASE)
328         except ldb.LdbError as e:
329             if e.args[0] == ldb.ERR_NO_SUCH_OBJECT:
330                 pass
331             else:
332                 raise
333         else:
334             raise CommandError("PSO '%s' already exists" % psoname)
335
336         # we expect the user to specify at least one password-policy setting,
337         # otherwise there's no point in creating a PSO
338         num_pwd_args = num_options_in_args(pwd_settings_options, self.raw_argv)
339         if num_pwd_args == 0:
340             raise CommandError("Please specify at least one password policy setting. Try --help")
341
342         # it's unlikely that the user will specify all 9 password policy
343         # settings on the CLI - current domain password-settings as the default
344         # values for unspecified arguments
345         if num_pwd_args < len(pwd_settings_options):
346             self.message("Not all password policy options have been specified.")
347             self.message("For unspecified options, the current domain password settings will be used as the default values.")
348
349         # lookup the current domain password-settings
350         res = samdb.search(samdb.domain_dn(), scope=ldb.SCOPE_BASE,
351             attrs=["pwdProperties", "pwdHistoryLength", "minPwdLength",
352                 "minPwdAge", "maxPwdAge", "lockoutDuration",
353                 "lockoutThreshold", "lockOutObservationWindow"])
354         assert(len(res) == 1)
355
356         # use the domain settings for any missing arguments
357         pwd_props = int(res[0]["pwdProperties"][0])
358         if complexity is None:
359             prop_flag = DOMAIN_PASSWORD_COMPLEX
360             complexity = "on" if pwd_props & prop_flag else "off"
361
362         if store_plaintext is None:
363             prop_flag = DOMAIN_PASSWORD_STORE_CLEARTEXT
364             store_plaintext = "on" if pwd_props & prop_flag else "off"
365
366         if history_length is None:
367             history_length = int(res[0]["pwdHistoryLength"][0])
368
369         if min_pwd_length is None:
370             min_pwd_length = int(res[0]["minPwdLength"][0])
371
372         if min_pwd_age is None:
373             min_pwd_age = timestamp_to_days(res[0]["minPwdAge"][0])
374
375         if max_pwd_age is None:
376             max_pwd_age = timestamp_to_days(res[0]["maxPwdAge"][0])
377
378         if account_lockout_duration is None:
379             account_lockout_duration = \
380                 timestamp_to_mins(res[0]["lockoutDuration"][0])
381
382         if account_lockout_threshold is None:
383             account_lockout_threshold = int(res[0]["lockoutThreshold"][0])
384
385         if reset_account_lockout_after is None:
386             reset_account_lockout_after = \
387                 timestamp_to_mins(res[0]["lockOutObservationWindow"][0])
388
389         check_pso_constraints(max_pwd_age=max_pwd_age, min_pwd_age=min_pwd_age,
390                               history_length=history_length,
391                               min_pwd_length=min_pwd_length)
392
393         # pack the settings into an LDB message
394         m = make_pso_ldb_msg(self.outf, samdb, pso_dn, create=True,
395                              complexity=complexity, precedence=precedence,
396                              store_plaintext=store_plaintext,
397                              history_length=history_length,
398                              min_pwd_length=min_pwd_length,
399                              min_pwd_age=min_pwd_age, max_pwd_age=max_pwd_age,
400                              lockout_duration=account_lockout_duration,
401                              lockout_threshold=account_lockout_threshold,
402                              reset_account_lockout_after=reset_account_lockout_after)
403
404         # create the new PSO
405         try:
406             samdb.add(m)
407             self.message("PSO successfully created: %s" % pso_dn)
408             # display the new PSO's settings
409             show_pso_by_dn(self.outf, samdb, pso_dn, show_applies_to=False)
410         except ldb.LdbError as e:
411             (num, msg) = e.args
412             if num == ldb.ERR_INSUFFICIENT_ACCESS_RIGHTS:
413                 raise CommandError("Administrator permissions are needed to create a PSO.")
414             else:
415                 raise CommandError("Failed to create PSO '%s': %s" %(pso_dn, msg))
416
417 class cmd_domain_pwdsettings_pso_set(Command):
418     """Modifies a Password Settings Object (PSO)."""
419
420     synopsis = "%prog <psoname> [options]"
421
422     takes_optiongroups = {
423         "sambaopts": options.SambaOptions,
424         "versionopts": options.VersionOptions,
425         "credopts": options.CredentialsOptions,
426         }
427
428     takes_options = pwd_settings_options + [
429         Option("--precedence", type=int,
430                help="This PSO's precedence relative to other PSOs. Lower precedence is better (<integer>)."),
431         Option("-H", "--URL", help="LDB URL for database or target server",
432                type=str, metavar="URL", dest="H"),
433         ]
434     takes_args = ["psoname"]
435
436     def run(self, psoname, H=None, precedence=None, min_pwd_age=None,
437             max_pwd_age=None, complexity=None, store_plaintext=None,
438             history_length=None, min_pwd_length=None,
439             account_lockout_duration=None, account_lockout_threshold=None,
440             reset_account_lockout_after=None, credopts=None, sambaopts=None,
441             versionopts=None):
442         lp = sambaopts.get_loadparm()
443         creds = credopts.get_credentials(lp)
444
445         samdb = SamDB(url=H, session_info=system_session(),
446             credentials=creds, lp=lp)
447
448         # sanity-check the PSO exists
449         pso_dn = "CN=%s,%s" % (psoname, pso_container(samdb))
450         check_pso_valid(samdb, pso_dn, psoname)
451
452         # we expect the user to specify at least one password-policy setting
453         num_pwd_args = num_options_in_args(pwd_settings_options, self.raw_argv)
454         if num_pwd_args == 0 and precedence is None:
455             raise CommandError("Please specify at least one password policy setting. Try --help")
456
457         if min_pwd_age is not None or max_pwd_age is not None:
458             # if we're modifying either the max or min pwd-age, check the max is
459             # always larger. We may have to fetch the PSO's setting to verify this
460             res = samdb.search(pso_dn, scope=ldb.SCOPE_BASE,
461                                attrs=['msDS-MinimumPasswordAge',
462                                       'msDS-MaximumPasswordAge'])
463             if min_pwd_age is None:
464                 min_pwd_age = timestamp_to_days(res[0]['msDS-MinimumPasswordAge'][0])
465
466             if max_pwd_age is None:
467                 max_pwd_age = timestamp_to_days(res[0]['msDS-MaximumPasswordAge'][0])
468
469         check_pso_constraints(max_pwd_age=max_pwd_age, min_pwd_age=min_pwd_age,
470                               history_length=history_length,
471                               min_pwd_length=min_pwd_length)
472
473         # pack the settings into an LDB message
474         m = make_pso_ldb_msg(self.outf, samdb, pso_dn, create=False,
475                              complexity=complexity, precedence=precedence,
476                              store_plaintext=store_plaintext,
477                              history_length=history_length,
478                              min_pwd_length=min_pwd_length,
479                              min_pwd_age=min_pwd_age, max_pwd_age=max_pwd_age,
480                              lockout_duration=account_lockout_duration,
481                              lockout_threshold=account_lockout_threshold,
482                              reset_account_lockout_after=reset_account_lockout_after)
483
484         # update the PSO
485         try:
486             samdb.modify(m)
487             self.message("Successfully updated PSO: %s" % pso_dn)
488             # display the new PSO's settings
489             show_pso_by_dn(self.outf, samdb, pso_dn, show_applies_to=False)
490         except ldb.LdbError as e:
491             (num, msg) = e.args
492             raise CommandError("Failed to update PSO '%s': %s" %(pso_dn, msg))
493
494
495 class cmd_domain_pwdsettings_pso_delete(Command):
496     """Deletes a Password Settings Object (PSO)."""
497
498     synopsis = "%prog <psoname> [options]"
499
500     takes_optiongroups = {
501         "sambaopts": options.SambaOptions,
502         "versionopts": options.VersionOptions,
503         "credopts": options.CredentialsOptions,
504         }
505
506     takes_options = [
507         Option("-H", "--URL", help="LDB URL for database or target server", type=str,
508                metavar="URL", dest="H")
509         ]
510     takes_args = ["psoname"]
511
512     def run(self, psoname, H=None, credopts=None, sambaopts=None,
513             versionopts=None):
514         lp = sambaopts.get_loadparm()
515         creds = credopts.get_credentials(lp)
516
517         samdb = SamDB(url=H, session_info=system_session(),
518             credentials=creds, lp=lp)
519
520         pso_dn = "CN=%s,%s" % (psoname, pso_container(samdb))
521         # sanity-check the PSO exists
522         check_pso_valid(samdb, pso_dn, psoname)
523
524         samdb.delete(pso_dn)
525         self.message("Deleted PSO %s" % psoname)
526
527
528 def pso_cmp(a, b):
529     """Compares two PSO LDB search results"""
530     a_precedence = int(a['msDS-PasswordSettingsPrecedence'][0])
531     b_precedence = int(b['msDS-PasswordSettingsPrecedence'][0])
532     return a_precedence - b_precedence
533
534 class cmd_domain_pwdsettings_pso_list(Command):
535     """Lists all Password Settings Objects (PSOs)."""
536
537     synopsis = "%prog [options]"
538
539     takes_optiongroups = {
540         "sambaopts": options.SambaOptions,
541         "versionopts": options.VersionOptions,
542         "credopts": options.CredentialsOptions,
543         }
544
545     takes_options = [
546         Option("-H", "--URL", help="LDB URL for database or target server", type=str,
547                metavar="URL", dest="H")
548         ]
549
550     def run(self, H=None, credopts=None, sambaopts=None, versionopts=None):
551         lp = sambaopts.get_loadparm()
552         creds = credopts.get_credentials(lp)
553
554         samdb = SamDB(url=H, session_info=system_session(),
555             credentials=creds, lp=lp)
556
557         res = samdb.search(pso_container(samdb), scope=ldb.SCOPE_SUBTREE,
558                            attrs=['name', 'msDS-PasswordSettingsPrecedence'],
559                            expression="(objectClass=msDS-PasswordSettings)")
560
561         # an unprivileged search against Windows returns nothing here. On Samba
562         # we get the PSO names, but not their attributes
563         if len(res) == 0 or 'msDS-PasswordSettingsPrecedence' not in res[0]:
564             self.outf.write("No PSOs are present, or you don't have permission to view them.\n")
565             return
566
567         # sort the PSOs so they're displayed in order of precedence
568         pso_list = sorted(res, cmp=pso_cmp)
569
570         self.outf.write("Precedence | PSO name\n")
571         self.outf.write("--------------------------------------------------\n")
572
573         for pso in pso_list:
574             precedence = pso['msDS-PasswordSettingsPrecedence']
575             self.outf.write("%-10s | %s\n" %(precedence, pso['name']))
576
577 class cmd_domain_pwdsettings_pso_show(Command):
578     """Display a Password Settings Object's details."""
579
580     synopsis = "%prog <psoname> [options]"
581
582     takes_optiongroups = {
583         "sambaopts": options.SambaOptions,
584         "versionopts": options.VersionOptions,
585         "credopts": options.CredentialsOptions,
586         }
587
588     takes_options = [
589         Option("-H", "--URL", help="LDB URL for database or target server", type=str,
590                metavar="URL", dest="H")
591         ]
592     takes_args = ["psoname"]
593
594     def run(self, psoname, H=None, credopts=None, sambaopts=None,
595             versionopts=None):
596         lp = sambaopts.get_loadparm()
597         creds = credopts.get_credentials(lp)
598
599         samdb = SamDB(url=H, session_info=system_session(),
600             credentials=creds, lp=lp)
601
602         pso_dn = "CN=%s,%s" % (psoname, pso_container(samdb))
603         check_pso_valid(samdb, pso_dn, psoname)
604         show_pso_by_dn(self.outf, samdb, pso_dn)
605
606
607 class cmd_domain_pwdsettings_pso_show_user(Command):
608     """Displays the Password Settings that apply to a user."""
609
610     synopsis = "%prog <username> [options]"
611
612     takes_optiongroups = {
613         "sambaopts": options.SambaOptions,
614         "versionopts": options.VersionOptions,
615         "credopts": options.CredentialsOptions,
616         }
617
618     takes_options = [
619         Option("-H", "--URL", help="LDB URL for database or target server", type=str,
620                metavar="URL", dest="H")
621         ]
622     takes_args = ["username"]
623
624     def run(self, username, H=None, credopts=None, sambaopts=None,
625             versionopts=None):
626         lp = sambaopts.get_loadparm()
627         creds = credopts.get_credentials(lp)
628
629         samdb = SamDB(url=H, session_info=system_session(),
630             credentials=creds, lp=lp)
631
632         show_pso_for_user(self.outf, samdb, username)
633
634
635 class cmd_domain_pwdsettings_pso_apply(Command):
636     """Applies a PSO's password policy to a user or group.
637
638     When a PSO is applied to a group, it will apply to all users (and groups)
639     that are members of that group. If a PSO applies directly to a user, it
640     will override any group membership PSOs for that user.
641
642     When multiple PSOs apply to a user, either directly or through group
643     membership, the PSO with the lowest precedence will take effect.
644     """
645
646     synopsis = "%prog <psoname> <user-or-group-name> [options]"
647
648     takes_optiongroups = {
649         "sambaopts": options.SambaOptions,
650         "versionopts": options.VersionOptions,
651         "credopts": options.CredentialsOptions,
652         }
653
654     takes_options = [
655         Option("-H", "--URL", help="LDB URL for database or target server", type=str,
656                metavar="URL", dest="H")
657         ]
658     takes_args = ["psoname", "user_or_group"]
659
660     def run(self, psoname, user_or_group, H=None, credopts=None,
661             sambaopts=None, versionopts=None):
662         lp = sambaopts.get_loadparm()
663         creds = credopts.get_credentials(lp)
664
665         samdb = SamDB(url=H, session_info=system_session(),
666             credentials=creds, lp=lp)
667
668         pso_dn = "CN=%s,%s" % (psoname, pso_container(samdb))
669         # sanity-check the PSO exists
670         check_pso_valid(samdb, pso_dn, psoname)
671
672         # lookup the user/group by account-name to gets its DN
673         search_filter = "(sAMAccountName=%s)" % user_or_group
674         res = samdb.search(samdb.domain_dn(), scope=ldb.SCOPE_SUBTREE,
675                            expression=search_filter)
676
677         if len(res) == 0:
678             raise CommandError("The specified user or group '%s' was not found"
679                                % user_or_group)
680
681         # modify the PSO to apply to the user/group specified
682         target_dn = str(res[0].dn)
683         m = ldb.Message()
684         m.dn = ldb.Dn(samdb, pso_dn)
685         m["msDS-PSOAppliesTo"] = ldb.MessageElement(target_dn, ldb.FLAG_MOD_ADD,
686                                                     "msDS-PSOAppliesTo")
687         try:
688             samdb.modify(m)
689         except ldb.LdbError as e:
690             (num, msg) = e.args
691             # most likely error - PSO already applies to that user/group
692             if num == ldb.ERR_ATTRIBUTE_OR_VALUE_EXISTS:
693                 raise CommandError("PSO '%s' already applies to '%s'"
694                                    % (psoname, user_or_group))
695             else:
696                 raise CommandError("Failed to update PSO '%s': %s" %(psoname,
697                                                                      msg))
698
699         self.message("PSO '%s' applied to '%s'" %(psoname, user_or_group))
700
701
702 class cmd_domain_pwdsettings_pso_unapply(Command):
703     """Updates a PSO to no longer apply to a user or group."""
704
705     synopsis = "%prog <psoname> <user-or-group-name> [options]"
706
707     takes_optiongroups = {
708         "sambaopts": options.SambaOptions,
709         "versionopts": options.VersionOptions,
710         "credopts": options.CredentialsOptions,
711         }
712
713     takes_options = [
714         Option("-H", "--URL", help="LDB URL for database or target server", type=str,
715                metavar="URL", dest="H"),
716         ]
717     takes_args = ["psoname", "user_or_group"]
718
719     def run(self, psoname, user_or_group, H=None, credopts=None,
720             sambaopts=None, versionopts=None):
721         lp = sambaopts.get_loadparm()
722         creds = credopts.get_credentials(lp)
723
724         samdb = SamDB(url=H, session_info=system_session(),
725             credentials=creds, lp=lp)
726
727         pso_dn = "CN=%s,%s" % (psoname, pso_container(samdb))
728         # sanity-check the PSO exists
729         check_pso_valid(samdb, pso_dn, psoname)
730
731         # lookup the user/group by account-name to gets its DN
732         search_filter = "(sAMAccountName=%s)" % user_or_group
733         res = samdb.search(samdb.domain_dn(), scope=ldb.SCOPE_SUBTREE,
734                            expression=search_filter)
735
736         if len(res) == 0:
737             raise CommandError("The specified user or group '%s' was not found"
738                                % user_or_group)
739
740         # modify the PSO to apply to the user/group specified
741         target_dn = str(res[0].dn)
742         m = ldb.Message()
743         m.dn = ldb.Dn(samdb, pso_dn)
744         m["msDS-PSOAppliesTo"] = ldb.MessageElement(target_dn, ldb.FLAG_MOD_DELETE,
745                                                     "msDS-PSOAppliesTo")
746         try:
747             samdb.modify(m)
748         except ldb.LdbError as e:
749             (num, msg) = e.args
750             # most likely error - PSO doesn't apply to that user/group
751             if num == ldb.ERR_NO_SUCH_ATTRIBUTE:
752                 raise CommandError("PSO '%s' doesn't apply to '%s'"
753                                    % (psoname, user_or_group))
754             else:
755                 raise CommandError("Failed to update PSO '%s': %s" %(psoname,
756                                                                      msg))
757         self.message("PSO '%s' no longer applies to '%s'" %(psoname, user_or_group))
758
759 class cmd_domain_passwordsettings_pso(SuperCommand):
760     """Manage fine-grained Password Settings Objects (PSOs)."""
761
762     subcommands = {}
763     subcommands["apply"] = cmd_domain_pwdsettings_pso_apply()
764     subcommands["create"] = cmd_domain_pwdsettings_pso_create()
765     subcommands["delete"] = cmd_domain_pwdsettings_pso_delete()
766     subcommands["list"] = cmd_domain_pwdsettings_pso_list()
767     subcommands["set"] = cmd_domain_pwdsettings_pso_set()
768     subcommands["show"] = cmd_domain_pwdsettings_pso_show()
769     subcommands["show-user"] = cmd_domain_pwdsettings_pso_show_user()
770     subcommands["unapply"] = cmd_domain_pwdsettings_pso_unapply()