Coverage for plugins/modules/purefb_password_policy.py: 97%
88 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-10-30 17:06 -0400
« prev ^ index » next coverage.py v7.14.1, created at 2026-10-30 17:06 -0400
1#!/usr/bin/python
2# -*- coding: utf-8 -*-
4# (c) 2026, Simon Dodsley (simon@everpuredata.com)
5# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
7from __future__ import absolute_import, division, print_function
9__metaclass__ = type
11ANSIBLE_METADATA = {
12 "metadata_version": "1.1",
13 "status": ["preview"],
14 "supported_by": "community",
15}
17DOCUMENTATION = r"""
18---
19module: purefb_password_policy
20version_added: '1.28.0'
21short_description: Manage the FlashBlade management password policy
22description:
23- Configure the array-wide password policy that applies to local FlashBlade
24 management users authenticating with a password (Web UI, CLI/SSH).
25- The policy is a singleton configuration resource, normally named
26 C(management), that always exists on the array. It cannot be created or
27 deleted, only modified, so this module has no I(state) option.
28- Passwords of AD/LDAP users are governed by the external directory
29 service and are not affected by this policy.
30author:
31- Pure Storage Ansible Team (@avk) <pure-ansible-team@everpuredata.com>
32notes:
33- Options that are omitted leave the corresponding policy setting unchanged.
34- Durations are given in seconds; the array stores them in milliseconds.
35- Rules disabled by setting 0 are reported by the array as absent (null)
36 values; the GUI displays them as 0.
37- The array only allows I(max_login) and I(lockout) to transition between
38 enabled (non-zero) and disabled (0) together, so supply both in the same
39 task when enabling or disabling the lockout rules. Once both are enabled,
40 each can be changed independently.
41- Password policies require FlashBlade REST API 2.16 (Purity//FB 4.5.2) or
42 higher; I(max_password_age) additionally requires REST API 2.18
43 (Purity//FB 4.6.0) or higher.
44- The I(min_password), I(max_login) and I(lockout) options use the same
45 names and units as the equivalent options of
46 M(everpure.flashblade.purefb_admin).
47options:
48 name:
49 description:
50 - Name of the password policy.
51 - The array provides a single built-in policy named C(management).
52 type: str
53 default: management
54 enabled:
55 description:
56 - Whether policy enforcement is enabled.
57 type: bool
58 min_password:
59 description:
60 - Minimum number of characters required in a password.
61 - Range between 1 and 100.
62 type: int
63 max_login:
64 description:
65 - Maximum number of failed login attempts allowed before the user is
66 locked out.
67 - Range between 1 and 100.
68 - Set to 0 to disable the maximum login attempts rule.
69 type: int
70 lockout:
71 description:
72 - Duration, in seconds, of the account lockout after I(max_login)
73 is exceeded.
74 - Range between 1 second and 90 days (7776000 seconds).
75 - Set to 0 to disable the lockout duration rule.
76 type: int
77 password_history:
78 description:
79 - Number of previous passwords tracked to prevent reuse.
80 - Range between 0 and 64.
81 type: int
82 min_password_age:
83 description:
84 - Minimum time, in seconds, that must pass before a password can be
85 changed again.
86 - The array stores this with one-hour precision, so the value must be a
87 whole number of hours (a multiple of 3600).
88 - Range between 0 and 7 days (604800 seconds).
89 type: int
90 max_password_age:
91 description:
92 - Maximum time, in seconds, a password remains valid before the user is
93 required to change it.
94 - Set to 0 to disable password expiration.
95 - The array stores this with one-hour precision, so the value must be a
96 whole number of hours (a multiple of 3600).
97 - Range between 1 day (86400 seconds) and 99999 days (8639913600
98 seconds), and must be greater than I(min_password_age).
99 - Requires FlashBlade REST API 2.18 (Purity//FB 4.6.0) or higher.
100 type: int
101 min_character_groups:
102 description:
103 - Number of character groups (lowercase letters, uppercase letters,
104 numbers, special characters) required to be present in a password.
105 - Range between 1 and 4.
106 - Set to 0 to disable the character group rule.
107 type: int
108 min_characters_per_group:
109 description:
110 - Minimum number of characters per group to count the group as present.
111 - Must be 1 or greater.
112 - Set to 0 to disable the characters per group rule.
113 type: int
114 enforce_username_check:
115 description:
116 - Whether the username is prevented from being a substring of the
117 password.
118 - Only applies to usernames of 4 characters and longer.
119 type: bool
120 enforce_dictionary_check:
121 description:
122 - Whether passwords are tested against a dictionary of known leaked
123 passwords.
124 - Requires passwords longer than 6 characters.
125 type: bool
126extends_documentation_fragment:
127- everpure.flashblade.everpure.fb
128"""
130EXAMPLES = r"""
131- name: Enforce password complexity and length
132 everpure.flashblade.purefb_password_policy:
133 min_password: 12
134 min_character_groups: 3
135 min_characters_per_group: 1
136 enforce_username_check: true
137 enforce_dictionary_check: true
138 fb_url: 10.10.10.2
139 api_token: T-55a68eb5-c785-4720-a2ca-8b03903bf641
141- name: Lock accounts for 30 minutes after 5 failed logins
142 everpure.flashblade.purefb_password_policy:
143 max_login: 5
144 lockout: 1800
145 fb_url: 10.10.10.2
146 api_token: T-55a68eb5-c785-4720-a2ca-8b03903bf641
148- name: Expire passwords after 90 days, at most one change per day
149 everpure.flashblade.purefb_password_policy:
150 max_password_age: 7776000
151 min_password_age: 86400
152 password_history: 5
153 fb_url: 10.10.10.2
154 api_token: T-55a68eb5-c785-4720-a2ca-8b03903bf641
156- name: Disable password expiration
157 everpure.flashblade.purefb_password_policy:
158 max_password_age: 0
159 fb_url: 10.10.10.2
160 api_token: T-55a68eb5-c785-4720-a2ca-8b03903bf641
162- name: Disable password policy enforcement
163 everpure.flashblade.purefb_password_policy:
164 enabled: false
165 fb_url: 10.10.10.2
166 api_token: T-55a68eb5-c785-4720-a2ca-8b03903bf641
167"""
169RETURN = r"""
170"""
172HAS_PYPURECLIENT = True
173try:
174 from pypureclient.flashblade import PasswordPolicy
175except ImportError:
176 HAS_PYPURECLIENT = False
178from ansible.module_utils.basic import AnsibleModule
179from ansible_collections.everpure.flashblade.plugins.module_utils.purefb import (
180 get_system,
181 purefb_argument_spec,
182)
183from ansible_collections.everpure.flashblade.plugins.module_utils.common import (
184 get_error_message,
185 get_rest_api_version,
186)
187from ansible_collections.everpure.flashblade.plugins.module_utils.version import (
188 LooseVersion,
189)
191MIN_PASSWORD_POLICY_API_VERSION = "2.16"
192MAX_PASSWORD_AGE_API_VERSION = "2.18"
194# Module option -> API field, value passed unchanged. The settings shared
195# with purefb_admin (max_login, min_password) reuse its option names.
196PLAIN_PARAMS = {
197 "enabled": "enabled",
198 "enforce_dictionary_check": "enforce_dictionary_check",
199 "enforce_username_check": "enforce_username_check",
200 "max_login": "max_login_attempts",
201 "min_character_groups": "min_character_groups",
202 "min_characters_per_group": "min_characters_per_group",
203 "min_password": "min_password_length",
204 "password_history": "password_history",
205}
206# Module option in seconds -> API field in milliseconds.
207SECONDS_PARAMS = {
208 "lockout": "lockout_duration",
209 "min_password_age": "min_password_age",
210 "max_password_age": "max_password_age",
211}
212# Inclusive (min, max) bounds in module units; None means unbounded.
213PARAM_RANGES = {
214 "max_login": (0, 100),
215 "min_password": (1, 100),
216 "password_history": (0, 64),
217 "min_character_groups": (0, 4),
218 "min_characters_per_group": (0, None),
219 "lockout": (0, 7776000),
220 "min_password_age": (0, 604800),
221 "max_password_age": (0, 8639913600),
222}
223# The array stores password ages with one-hour precision; finer values
224# would either be rejected or silently rounded, breaking idempotency.
225HOUR_PRECISION_PARAMS = ("min_password_age", "max_password_age")
226# A rule set to 0 (disabled) is reported by the array as an absent field,
227# so a missing attribute on the current policy compares equal to 0.
228NULL_WHEN_DISABLED_FIELDS = (
229 "lockout_duration",
230 "max_login_attempts",
231 "max_password_age",
232 "min_character_groups",
233 "min_characters_per_group",
234 "min_password_age",
235 "password_history",
236)
239def _validate_params(module):
240 for param in sorted(PARAM_RANGES):
241 value = module.params[param]
242 if value is None:
243 continue
244 low, high = PARAM_RANGES[param]
245 if value < low or (high is not None and value > high):
246 if high is None:
247 module.fail_json(msg="{0} must be {1} or greater".format(param, low))
248 module.fail_json(
249 msg="{0} must be between {1} and {2}".format(param, low, high)
250 )
251 for param in HOUR_PRECISION_PARAMS:
252 value = module.params[param]
253 if value is not None and value % 3600:
254 module.fail_json(
255 msg=(
256 "{0} must be a whole number of hours (a multiple of "
257 "3600 seconds)"
258 ).format(param)
259 )
260 max_age = module.params["max_password_age"]
261 if max_age is not None and max_age != 0 and max_age < 86400:
262 module.fail_json(
263 msg=(
264 "max_password_age must be 0 (expiration disabled) or at "
265 "least 86400 seconds (1 day)"
266 )
267 )
268 min_age = module.params["min_password_age"]
269 if max_age and min_age is not None and max_age <= min_age:
270 module.fail_json(msg="max_password_age must be greater than min_password_age")
273def get_policy(module, blade):
274 """The singleton policy object.
276 The policy always exists on a supported array, so any failure to read
277 it fails the task.
278 """
279 res = blade.get_password_policies(names=[module.params["name"]])
280 if res.status_code != 200:
281 module.fail_json(
282 msg="Failed to read password policy {0}. Error: {1}".format(
283 module.params["name"], get_error_message(res)
284 )
285 )
286 items = list(res.items)
287 if not items:
288 module.fail_json(
289 msg="Password policy {0} was not found.".format(module.params["name"])
290 )
291 return items[0]
294def _current_value(current, field):
295 value = getattr(current, field, None)
296 if value is None and field in NULL_WHEN_DISABLED_FIELDS:
297 return 0
298 return value
301def update_policy(module, blade, current):
302 """PATCH only the settings that differ from the current policy."""
303 patch_kwargs = {}
304 for param, field in PLAIN_PARAMS.items():
305 value = module.params[param]
306 if value is not None and value != _current_value(current, field):
307 patch_kwargs[field] = value
308 for param, field in SECONDS_PARAMS.items():
309 value = module.params[param]
310 if value is not None and value * 1000 != _current_value(current, field):
311 patch_kwargs[field] = value * 1000
313 changed = bool(patch_kwargs)
314 if changed and not module.check_mode:
315 res = blade.patch_password_policies(
316 names=[module.params["name"]],
317 policy=PasswordPolicy(**patch_kwargs),
318 )
319 if res.status_code != 200:
320 module.fail_json(
321 msg="Failed to update password policy {0}. Error: {1}".format(
322 module.params["name"], get_error_message(res)
323 )
324 )
325 module.exit_json(changed=changed)
328def main():
329 argument_spec = purefb_argument_spec()
330 argument_spec.update(
331 dict(
332 name=dict(type="str", default="management"),
333 enabled=dict(type="bool"),
334 min_password=dict(type="int", no_log=False),
335 max_login=dict(type="int"),
336 lockout=dict(type="int"),
337 password_history=dict(type="int", no_log=False),
338 min_password_age=dict(type="int", no_log=False),
339 max_password_age=dict(type="int", no_log=False),
340 min_character_groups=dict(type="int"),
341 min_characters_per_group=dict(type="int"),
342 enforce_username_check=dict(type="bool"),
343 enforce_dictionary_check=dict(type="bool"),
344 )
345 )
347 module = AnsibleModule(argument_spec, supports_check_mode=True)
349 if not HAS_PYPURECLIENT:
350 module.fail_json(msg="py-pure-client sdk is required for this module")
352 _validate_params(module)
354 blade = get_system(module)
355 api_version = get_rest_api_version(blade)
356 if LooseVersion(MIN_PASSWORD_POLICY_API_VERSION) > LooseVersion(api_version):
357 module.fail_json(
358 msg=(
359 "FlashBlade REST version {0} does not support password "
360 "policies (requires {1}+)."
361 ).format(api_version, MIN_PASSWORD_POLICY_API_VERSION)
362 )
363 if module.params["max_password_age"] is not None and LooseVersion(
364 MAX_PASSWORD_AGE_API_VERSION
365 ) > LooseVersion(api_version):
366 module.fail_json(
367 msg=(
368 "FlashBlade REST version {0} does not support "
369 "max_password_age (requires {1}+)."
370 ).format(api_version, MAX_PASSWORD_AGE_API_VERSION)
371 )
373 current = get_policy(module, blade)
374 update_policy(module, blade, current)
377if __name__ == "__main__":
378 main()