Coverage for plugins/modules/purefb_user.py: 40%

201 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) 2019, 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_user 

20version_added: '1.0.0' 

21short_description: Create, modify or delete FlashBlade user accounts 

22description: 

23- Modify user on a Pure Stoage FlashBlade. 

24author: 

25- Everpure Ansible Team (@sdodsley) <pure-ansible-team@everpuredata.com> 

26options: 

27 state: 

28 description: 

29 - Create, delete or update local user account 

30 default: present 

31 type: str 

32 choices: [ absent, present ] 

33 version_added: "1.21.0" 

34 name: 

35 description: 

36 - The name of the user account 

37 type: str 

38 required: true 

39 role: 

40 description: 

41 - Sets the local user's access level to the system 

42 - A new local user is created with I(readonly) when no role is given. 

43 - When omitted for an existing user the current role is left unchanged. 

44 type: str 

45 choices: [ readonly, ops_admin, storage_admin, array_admin ] 

46 version_added: "1.21.0" 

47 password: 

48 description: 

49 - Password for the local user. 

50 type: str 

51 old_password: 

52 description: 

53 - If changing an existing password, you must provide the old password for security 

54 type: str 

55 api: 

56 description: 

57 - Define whether to create an API token for this user 

58 - An existing token is replaced, so this is not idempotent. 

59 - Token can be exposed using the I(debug) module 

60 type: bool 

61 default: false 

62 version_added: "1.21.0" 

63 timeout: 

64 description: 

65 - The duration of API token validity. 

66 - Valid values are weeks (w), days(d), hours(h), minutes(m) and seconds(s). 

67 - The default of C("0") creates a token that does not expire. 

68 type: str 

69 default: "0" 

70 version_added: "1.21.0" 

71 public_key: 

72 description: 

73 - SSH public key for the user in OpenSSH C(authorized_keys) format, for 

74 example C(ssh-rsa AAAA... comment) or C(ssh-ed25519 AAAA... comment). 

75 - The array accepts at most two keys, one per line. 

76 - PEM blocks (I(-----BEGIN PUBLIC KEY-----)) are rejected. That is the 

77 key format of M(everpure.flashblade.purefb_apiclient); the array treats 

78 every line of a PEM block as a separate SSH key. 

79 - Keys are compared by type and key material, so a different comment or 

80 whitespace is not a change. When the array masks the stored key 

81 (reported as C(****)) only its presence can be checked; clear it with 

82 C("") before setting a different key. 

83 - An empty string removes the user's public key. 

84 type: str 

85 version_added: "1.8.0" 

86 clear_lock: 

87 description: 

88 - Clear user lockout flag 

89 type: bool 

90 default: false 

91 version_added: "1.8.0" 

92 ad_user: 

93 description: 

94 - Whether the user is in the AD system 

95 - Not required for local users 

96 type: bool 

97 default: false 

98 version_added: "1.21.0" 

99extends_documentation_fragment: 

100- everpure.flashblade.everpure.fb 

101""" 

102 

103EXAMPLES = r""" 

104- name: Change password for local user (NOT IDEMPOTENT) 

105 everpure.flashblade.purefb_user: 

106 name: pureuser 

107 password: anewpassword 

108 old_password: apassword 

109 fb_url: 10.10.10.2 

110 api_token: T-9f276a18-50ab-446e-8a0c-666a3529a1b6 

111 

112- name: Set public key for user 

113 everpure.flashblade.purefb_user: 

114 name: fred 

115 public_key: "{{lookup('file', 'public_pem_file') }}" 

116 fb_url: 10.10.10.2 

117 api_token: T-9f276a18-50ab-446e-8a0c-666a3529a1b6 

118 

119- name: Clear user lockout 

120 everpure.flashblade.purefb_user: 

121 name: fred 

122 clear_lock: true 

123 fb_url: 10.10.10.2 

124 api_token: T-9f276a18-50ab-446e-8a0c-666a3529a1b6 

125 

126- name: Create an API token (TTL of 2 days) and assign a public key to an AD user 

127 everpure.flashblade.purefb_user: 

128 name: ansible-ad 

129 ad_user: true 

130 public_key: "{{lookup('file', 'id_rsa.pub') }}" 

131 api: true 

132 timeout: 2d 

133 fb_url: 10.10.10.2 

134 api_token: T-9f276a18-50ab-446e-8a0c-666a3529a1b6 

135""" 

136 

137RETURN = r""" 

138""" 

139 

140HAS_PYPURECLIENT = True 

141try: 

142 from pypureclient.flashblade import ( 

143 AdminPatch, 

144 AdminPost, 

145 ReferenceWritable, 

146 ) 

147except ImportError: 

148 HAS_PYPURECLIENT = False 

149 

150from ansible.module_utils.basic import AnsibleModule 

151from ansible_collections.everpure.flashblade.plugins.module_utils.time_utils import ( 

152 time_to_milliseconds, 

153 TimeConversionError, 

154) 

155from ansible_collections.everpure.flashblade.plugins.module_utils.purefb import ( 

156 get_system, 

157 purefb_argument_spec, 

158) 

159from ansible_collections.everpure.flashblade.plugins.module_utils.version import ( 

160 LooseVersion, 

161) 

162from ansible_collections.everpure.flashblade.plugins.module_utils.common import ( 

163 get_error_message, 

164 get_rest_api_version, 

165) 

166import re 

167 

168MIN_LOCAL_USER = "2.15" 

169MIN_EXPOSE_API = "2.18" 

170DEFAULT_ROLE = "readonly" 

171# Value the array reports for a stored public key when it masks it. 

172PUBLIC_KEY_MASK = "****" 

173MAX_PUBLIC_KEYS = 2 

174# One OpenSSH authorized_keys entry: "<type> <base64 key> [comment]" 

175OPENSSH_KEY_LINE = re.compile(r"^(\S+)\s+([A-Za-z0-9+/=]+)(?:\s+.*)?$") 

176 

177 

178def parse_public_keys(text): 

179 """Return the (type, key) pairs found in an OpenSSH authorized_keys string. 

180 

181 Blank lines are ignored. Returns None when any non-empty line is not an 

182 OpenSSH public key entry (for example a PEM block). 

183 """ 

184 keys = [] 

185 for line in (text or "").splitlines(): 

186 line = line.strip() 

187 if not line: 

188 continue 

189 match = OPENSSH_KEY_LINE.match(line) 

190 if not match: 

191 return None 

192 keys.append((match.group(1), match.group(2))) 

193 return keys 

194 

195 

196def validate_public_key(module): 

197 """Fail early when public_key is not in the format the array accepts. 

198 

199 The array wants OpenSSH format (``ssh-rsa AAAA...``) and reads one key 

200 per line, so a PEM block is seen as many keys and rejected with the 

201 unhelpful "At most 2 public keys can be uploaded for a user". 

202 """ 

203 text = module.params["public_key"] 

204 if not text or not text.strip(): 

205 return 

206 keys = parse_public_keys(text) 

207 if keys is None: 

208 module.fail_json( 

209 msg=( 

210 "public_key must be in OpenSSH authorized_keys format " 

211 "('ssh-rsa AAAA...' or 'ssh-ed25519 AAAA...', one key per " 

212 "line). PEM blocks are the purefb_apiclient key format and " 

213 "are not accepted for SSH access." 

214 ) 

215 ) 

216 if len(keys) > MAX_PUBLIC_KEYS: 

217 module.fail_json( 

218 msg="At most {0} public keys can be set for a user".format(MAX_PUBLIC_KEYS) 

219 ) 

220 

221 

222def public_key_changed(module, user): 

223 """Return True when the requested public key differs from the user's. 

224 

225 Keys are compared by type and key material only, so comments and 

226 whitespace do not count. An empty request clears the key and is a 

227 change only while a key is set. A masked value (``****``) means the 

228 array does not return stored keys; only presence can be compared then. 

229 """ 

230 desired = module.params["public_key"] 

231 if desired is None: 

232 return False 

233 current = getattr(user, "public_key", None) or "" 

234 if not desired.strip(): 

235 return bool(current.strip()) 

236 if current.strip() == PUBLIC_KEY_MASK: 

237 return False 

238 return sorted(parse_public_keys(desired)) != sorted( 

239 parse_public_keys(current) or [] 

240 ) 

241 

242 

243def set_public_key(module, blade, action="change"): 

244 """PATCH the user's public key (an empty string clears it)""" 

245 res = blade.patch_admins( 

246 names=[module.params["name"]], 

247 admin=AdminPatch(public_key=module.params["public_key"].strip()), 

248 ) 

249 if res.status_code != 200: 

250 module.fail_json( 

251 msg="Failed to {0} SSH key. Error: {1}".format( 

252 action, get_error_message(res) 

253 ) 

254 ) 

255 

256 

257def get_user(module, blade): 

258 """Return Local User Account or None""" 

259 user = None 

260 res = blade.get_admins(names=[module.params["name"]]) 

261 if res.status_code != 200: 

262 return None 

263 else: 

264 return list(res.items)[0] 

265 

266 

267def has_api_token(user): 

268 """Return True if the admin currently holds an API token. 

269 

270 The token value itself is never returned by GET admins, only its 

271 ``created_at``/``expires_at`` timestamps, so test those. 

272 """ 

273 token = getattr(user, "api_token", None) 

274 return bool( 

275 getattr(token, "created_at", None) or getattr(token, "expires_at", None) 

276 ) 

277 

278 

279def get_token_ttl(module): 

280 """Return the API token validity in milliseconds, or None for no expiry""" 

281 timeout = module.params["timeout"] 

282 if timeout in (None, "", "0", 0): 

283 return None 

284 try: 

285 return time_to_milliseconds(timeout) 

286 except TimeConversionError as err: 

287 module.fail_json(msg="Invalid timeout {0}: {1}".format(timeout, err)) 

288 

289 

290def delete_api_token(module, blade): 

291 """Delete the admin's API token""" 

292 res = blade.delete_admins_api_tokens(admin_names=[module.params["name"]]) 

293 if res.status_code != 200: 

294 module.fail_json( 

295 msg="Failed to delete original API token. Error: {0}".format( 

296 get_error_message(res) 

297 ) 

298 ) 

299 

300 

301def create_api_token(module, blade): 

302 """Create an API token for the admin and return it""" 

303 res = blade.post_admins_api_tokens( 

304 admin_names=[module.params["name"]], timeout=get_token_ttl(module) 

305 ) 

306 if res.status_code != 200: 

307 module.fail_json( 

308 msg="Failed to create API token. Error: {0}".format(get_error_message(res)) 

309 ) 

310 return list(res.items)[0].api_token.token 

311 

312 

313def create_local_user(module, blade, user): 

314 """Create or Update Local User Account""" 

315 changed = key_changed = api_changed = role_changed = passwd_changed = False 

316 lock_changed = False 

317 role = module.params["role"] or DEFAULT_ROLE 

318 api_token = "No API token created" 

319 if not user: 

320 changed = True 

321 if not module.check_mode: 

322 res = blade.post_admins( 

323 names=[module.params["name"]], 

324 admin=AdminPost( 

325 role=ReferenceWritable(name=role), 

326 password=module.params["password"], 

327 ), 

328 ) 

329 if res.status_code != 200: 

330 module.fail_json( 

331 msg="Failed to create user {0}. Error: {1}".format( 

332 module.params["name"], get_error_message(res) 

333 ) 

334 ) 

335 if module.params["api"]: 

336 api_token = create_api_token(module, blade) 

337 if module.params["public_key"] and module.params["public_key"].strip(): 

338 set_public_key(module, blade, action="add") 

339 else: 

340 if module.params["password"] and not module.params["old_password"]: 

341 module.exit_json(changed=changed) 

342 if ( 

343 module.params["password"] 

344 and module.params["old_password"] 

345 and module.params["password"] != module.params["old_password"] 

346 ): 

347 passwd_changed = True 

348 if not module.check_mode: 

349 res = blade.patch_admins( 

350 names=[module.params["name"]], 

351 admin=AdminPatch( 

352 password=module.params["password"], 

353 old_password=module.params["old_password"], 

354 ), 

355 ) 

356 if res.status_code != 200: 

357 module.fail_json( 

358 msg="Local User {0} password reset failed. Error: {1}" 

359 "Check old password.".format( 

360 module.params["name"], get_error_message(res) 

361 ) 

362 ) 

363 if module.params["api"]: 

364 api_changed = True 

365 if not module.check_mode: 

366 if has_api_token(user): 

367 delete_api_token(module, blade) 

368 api_token = create_api_token(module, blade) 

369 if ( 

370 module.params["role"] 

371 and module.params["role"] != getattr(user.role, "name", None) 

372 and user.is_local 

373 ): 

374 if module.params["name"] != "pureuser": 

375 role_changed = True 

376 if not module.check_mode: 

377 res = blade.patch_admins( 

378 names=[module.params["name"]], 

379 admin=AdminPatch( 

380 role=ReferenceWritable(name=module.params["role"]) 

381 ), 

382 ) 

383 if res.status_code != 200: 

384 module.fail_json( 

385 msg="Local User {0} role changed failed. Error: {1}".format( 

386 module.params["name"], get_error_message(res) 

387 ) 

388 ) 

389 else: 

390 module.warn("Role for 'pureuser' cannot be modified.") 

391 if public_key_changed(module, user): 

392 key_changed = True 

393 if not module.check_mode: 

394 set_public_key(module, blade) 

395 if module.params["clear_lock"] and getattr(user, "locked", False): 

396 lock_changed = True 

397 if not module.check_mode: 

398 res = blade.patch_admins( 

399 names=[module.params["name"]], 

400 admin=AdminPatch(locked=False), 

401 ) 

402 if res.status_code != 200: 

403 module.fail_json( 

404 msg="Failed to unlock user {0}. Error: {1}".format( 

405 module.params["name"], get_error_message(res) 

406 ) 

407 ) 

408 changed = bool( 

409 passwd_changed or role_changed or api_changed or key_changed or lock_changed 

410 ) 

411 module.exit_json(changed=changed, user_info=api_token) 

412 

413 

414def update_ad_user(module, blade, user): 

415 """Update AD user API token and/or SSH key""" 

416 api_token = "No API token created" 

417 api_changed = ssh_changed = False 

418 if module.params["api"]: 

419 api_changed = True 

420 if not module.check_mode: 

421 if user and has_api_token(user): 

422 delete_api_token(module, blade) 

423 api_token = create_api_token(module, blade) 

424 if module.params["public_key"] is not None: 

425 if user is None: 

426 # An AD user with no array-side state yet: nothing to compare 

427 # against, and nothing to clear. 

428 ssh_changed = bool(module.params["public_key"].strip()) 

429 else: 

430 ssh_changed = public_key_changed(module, user) 

431 if ssh_changed and not module.check_mode: 

432 set_public_key(module, blade, action="add") 

433 changed = bool(api_changed or ssh_changed) 

434 module.exit_json(changed=changed, user_info=api_token) 

435 

436 

437def delete_ad_user(module, blade, user): 

438 """Delete AD User Account references""" 

439 changed = False 

440 if not module.check_mode: 

441 if user: 

442 changed = True 

443 if has_api_token(user): 

444 res = blade.delete_admins_api_tokens( 

445 admin_names=[module.params["name"]] 

446 ) 

447 if res.status_code != 200: 

448 module.fail_json( 

449 msg="AD Account {0} API token deletion failed. Error: {1}".format( 

450 module.params["name"], get_error_message(res) 

451 ) 

452 ) 

453 if getattr(user, "public_key", None): 

454 res = blade.patch_admins( 

455 names=[module.params["name"]], 

456 admin=AdminPatch(public_key=""), 

457 ) 

458 if res.status_code != 200: 

459 module.fail_json( 

460 msg="AD Account {0} public key deletion failed. Error: {1}".format( 

461 module.params["name"], get_error_message(res) 

462 ) 

463 ) 

464 res = blade.delete_admins_cache(names=[module.params["name"]]) 

465 if res.status_code != 200: 

466 module.fail_json( 

467 msg="Admin Cache deleteion failed for AD user {0}. Error: {1}".format( 

468 module.params["name"], get_error_message(res) 

469 ) 

470 ) 

471 module.exit_json(changed=changed) 

472 

473 

474def delete_local_user(module, blade): 

475 """Delete Local User Account""" 

476 changed = True 

477 if not module.check_mode: 

478 res = blade.delete_admins(names=[module.params["name"]]) 

479 if res.status_code != 200: 

480 module.fail_json( 

481 msg="User Account {0} deletion failed. Error: {1}".format( 

482 module.params["name"], get_error_message(res) 

483 ) 

484 ) 

485 module.exit_json(changed=changed) 

486 

487 

488def main(): 

489 argument_spec = purefb_argument_spec() 

490 argument_spec.update( 

491 dict( 

492 name=dict(type="str", required=True), 

493 public_key=dict(type="str", no_log=True), 

494 password=dict(type="str", no_log=True), 

495 old_password=dict(type="str", no_log=True), 

496 clear_lock=dict(type="bool", default=False), 

497 role=dict( 

498 type="str", 

499 choices=["readonly", "ops_admin", "storage_admin", "array_admin"], 

500 ), 

501 state=dict(type="str", default="present", choices=["absent", "present"]), 

502 api=dict(type="bool", default=False), 

503 ad_user=dict(type="bool", default=False), 

504 timeout=dict(type="str", default="0"), 

505 ) 

506 ) 

507 

508 module = AnsibleModule( 

509 argument_spec, 

510 supports_check_mode=True, 

511 ) 

512 

513 validate_public_key(module) 

514 blade = get_system(module) 

515 api_version = get_rest_api_version(blade) 

516 if LooseVersion(MIN_LOCAL_USER) > LooseVersion(api_version): 

517 module.fail_json( 

518 msg="Purity//FB must be upgraded to support managing local users." 

519 ) 

520 

521 state = module.params["state"] 

522 pattern = re.compile("^[a-z0-9]([a-z0-9-]{0,30}[a-z0-9])?$") 

523 user = get_user(module, blade) 

524 local_user = getattr(user, "is_local", False) 

525 if not pattern.match(module.params["name"]): 

526 module.fail_json( 

527 msg="name must contain a minimum of 1 and a maximum of 32 characters " 

528 "(alphanumeric or `-`). All letters must be lowercase." 

529 ) 

530 

531 if state == "present" and not local_user and module.params["ad_user"]: 

532 update_ad_user(module, blade, user) 

533 if state == "absent" and local_user: 

534 delete_local_user(module, blade) 

535 if state == "absent" and not local_user: 

536 if user is None and not module.params["ad_user"]: 

537 # Nothing to delete: the local user does not exist 

538 module.exit_json(changed=False) 

539 delete_ad_user(module, blade, user) 

540 elif state == "present" and not module.params["ad_user"]: 

541 create_local_user(module, blade, user) 

542 else: 

543 module.exit_json(changed=False) 

544 

545 

546if __name__ == "__main__": 

547 main()