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

1#!/usr/bin/python 

2# -*- coding: utf-8 -*- 

3 

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) 

6 

7from __future__ import absolute_import, division, print_function 

8 

9__metaclass__ = type 

10 

11ANSIBLE_METADATA = { 

12 "metadata_version": "1.1", 

13 "status": ["preview"], 

14 "supported_by": "community", 

15} 

16 

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""" 

129 

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 

140 

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 

147 

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 

155 

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 

161 

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""" 

168 

169RETURN = r""" 

170""" 

171 

172HAS_PYPURECLIENT = True 

173try: 

174 from pypureclient.flashblade import PasswordPolicy 

175except ImportError: 

176 HAS_PYPURECLIENT = False 

177 

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) 

190 

191MIN_PASSWORD_POLICY_API_VERSION = "2.16" 

192MAX_PASSWORD_AGE_API_VERSION = "2.18" 

193 

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) 

237 

238 

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") 

271 

272 

273def get_policy(module, blade): 

274 """The singleton policy object. 

275 

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] 

292 

293 

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 

299 

300 

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 

312 

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) 

326 

327 

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 ) 

346 

347 module = AnsibleModule(argument_spec, supports_check_mode=True) 

348 

349 if not HAS_PYPURECLIENT: 

350 module.fail_json(msg="py-pure-client sdk is required for this module") 

351 

352 _validate_params(module) 

353 

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 ) 

372 

373 current = get_policy(module, blade) 

374 update_policy(module, blade, current) 

375 

376 

377if __name__ == "__main__": 

378 main()