Coverage for plugins/modules/purefb_local_user.py: 88%

191 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_local_user 

20version_added: '1.28.0' 

21short_description: Manage FlashBlade Local Directory Service users 

22description: 

23- Create, update, rename or delete a local user in a FlashBlade Local 

24 Directory Service (LDS). These are the SMB/NFS local identities that live 

25 inside an LDS attached to a FlashBlade server. They are not the array 

26 management admins - use M(everpure.flashblade.purefb_user) or 

27 M(everpure.flashblade.purefb_admin) for those. 

28- Requires FlashBlade REST API version 2.24 or later (Purity//FB 4.6.8+). 

29- Group memberships can be managed inline via the I(groups) option. If you 

30 also drive membership from the group side (via 

31 M(everpure.flashblade.purefb_local_group)), only manage a given 

32 (user,group) edge from one side in a single playbook run. 

33author: 

34- Pure Storage Ansible Team (@avk) <pure-ansible-team@everpuredata.com> 

35options: 

36 name: 

37 description: 

38 - Name of the local user inside the LDS. 

39 type: str 

40 required: true 

41 state: 

42 description: 

43 - Whether the local user should exist or not. 

44 type: str 

45 default: present 

46 choices: [ absent, present ] 

47 local_directory_service: 

48 description: 

49 - Name of the Local Directory Service that owns this user. 

50 - Attach LDSes to servers via M(everpure.flashblade.purefb_server). 

51 type: str 

52 required: true 

53 new_name: 

54 description: 

55 - New name for the user. Uses the C(LocalUserPatch.name) field. 

56 - Rejected for built-in users. 

57 type: str 

58 uid: 

59 description: 

60 - Numeric UID for the user. Auto-assigned by the array if omitted at 

61 create time. Patchable. 

62 type: int 

63 email: 

64 description: 

65 - Email address for the user. 

66 type: str 

67 enabled: 

68 description: 

69 - Whether the user account is enabled. 

70 - At create time, if unset the user is created enabled. 

71 - On update, this field is only applied when explicitly set. This lets 

72 you idempotently manage other attributes of a disabled user without 

73 accidentally re-enabling the account. 

74 type: bool 

75 password: 

76 description: 

77 - Password for the user. 

78 - Required at create time when I(enabled=true). 

79 - Required on the transition of an existing user from 

80 I(enabled=false) to I(enabled=true). 

81 - Otherwise ignored unless I(force_password_reset=true). 

82 type: str 

83 force_password_reset: 

84 description: 

85 - Force the password to be reset on an existing user, even if unchanged. 

86 - There is no way to compare the current password on the array, so 

87 password change is inherently non-idempotent. Set this true to 

88 explicitly ask for a reset. 

89 type: bool 

90 default: false 

91 primary_group: 

92 description: 

93 - Name of the local group that is the user's primary group. 

94 - Required when I(state=present) - the array will not create a user 

95 without one. 

96 - The primary group must already exist in the same LDS. 

97 type: str 

98 groups: 

99 description: 

100 - List of non-primary local group names the user should belong to. 

101 - When set, the module reconciles the user's group memberships against 

102 this list via C(/directory-services/local/users/members). 

103 - The primary group (either the current one or a newly requested one) is 

104 never removed via this list - the API disallows removing the primary 

105 group edge. 

106 - After a primary-group change the former primary remains a regular 

107 member; if it is not listed here, a second run removes it. 

108 type: list 

109 elements: str 

110 context: 

111 description: 

112 - Name of fleet member on which to perform the operation. 

113 - This requires the array receiving the request is a member of a fleet 

114 and the context name to be a member of the same fleet. 

115 type: str 

116 default: "" 

117extends_documentation_fragment: 

118- everpure.flashblade.everpure.fb 

119""" 

120 

121EXAMPLES = r""" 

122- name: Create local user 'alice' 

123 everpure.flashblade.purefb_local_user: 

124 name: alice 

125 local_directory_service: myserver_local 

126 uid: 5001 

127 password: "s3cret!" 

128 primary_group: alice 

129 email: alice@example.com 

130 fb_url: 10.10.10.2 

131 api_token: T-55a68eb5-c785-4720-a2ca-8b03903bf641 

132 

133- name: Ensure 'alice' also belongs to secondary groups 

134 everpure.flashblade.purefb_local_user: 

135 name: alice 

136 local_directory_service: myserver_local 

137 primary_group: alice 

138 groups: 

139 - developers 

140 - infra 

141 fb_url: 10.10.10.2 

142 api_token: T-55a68eb5-c785-4720-a2ca-8b03903bf641 

143 

144- name: Disable local user 

145 everpure.flashblade.purefb_local_user: 

146 name: alice 

147 local_directory_service: myserver_local 

148 primary_group: alice 

149 enabled: false 

150 fb_url: 10.10.10.2 

151 api_token: T-55a68eb5-c785-4720-a2ca-8b03903bf641 

152 

153- name: Rename local user 

154 everpure.flashblade.purefb_local_user: 

155 name: alice 

156 new_name: alice2 

157 local_directory_service: myserver_local 

158 primary_group: alice 

159 fb_url: 10.10.10.2 

160 api_token: T-55a68eb5-c785-4720-a2ca-8b03903bf641 

161 

162- name: Delete local user 

163 everpure.flashblade.purefb_local_user: 

164 name: alice 

165 local_directory_service: myserver_local 

166 state: absent 

167 fb_url: 10.10.10.2 

168 api_token: T-55a68eb5-c785-4720-a2ca-8b03903bf641 

169""" 

170 

171RETURN = r""" 

172""" 

173 

174 

175HAS_PYPURECLIENT = True 

176try: 

177 from pypureclient.flashblade import ( 

178 LocalUserPost, 

179 LocalUserPatch, 

180 LocalUserMembershipPost, 

181 LocalUserMembershipPostGroups, 

182 ReferenceWritable, 

183 ) 

184except ImportError: 

185 HAS_PYPURECLIENT = False 

186 

187 

188from ansible.module_utils.basic import AnsibleModule 

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

190 get_system, 

191 purefb_argument_spec, 

192) 

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

194 get_error_message, 

195 get_rest_api_version, 

196) 

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

198 LooseVersion, 

199) 

200 

201MIN_REQUIRED_API_VERSION = "2.24" 

202 

203 

204def _context_kwargs(module): 

205 """Return context_names kwargs when a context is set. 

206 

207 Local users require REST 2.24, which is well above the 2.17 fleet-context 

208 floor, so context_names is always accepted here. 

209 """ 

210 if module.params["context"]: 

211 return {"context_names": [module.params["context"]]} 

212 return {} 

213 

214 

215def _lds_filter(local_ds_name, extra=None): 

216 """Build a filter expression that scopes to the given LDS. 

217 

218 The FB GET endpoints for local users / members do not accept 

219 ``local_directory_service_names``; scoping must go through ``filter``. 

220 """ 

221 # Per Pure's REST filter grammar, string literals are single-quoted and 

222 # the backslash is the escape character: escape backslashes first, then 

223 # single quotes. 

224 escaped = local_ds_name.replace("\\", "\\\\").replace("'", "\\'") 

225 

226 expr = "local_directory_service.name='{0}'".format(escaped) 

227 if extra: 

228 expr = "({0}) and ({1})".format(expr, extra) 

229 return expr 

230 

231 

232def _get_user(module, blade, local_ds_name): 

233 ctx = _context_kwargs(module) 

234 res = blade.get_directory_services_local_users( 

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

236 filter=_lds_filter(local_ds_name), 

237 **ctx, 

238 ) 

239 if res.status_code == 200: 

240 items = list(res.items) 

241 if items: 

242 return items[0] 

243 return None 

244 

245 

246def _get_current_groups(module, blade, local_ds_name): 

247 """Return the set of current non-primary group names for this user.""" 

248 ctx = _context_kwargs(module) 

249 res = blade.get_directory_services_local_users_members( 

250 member_names=[module.params["name"]], 

251 filter=_lds_filter(local_ds_name), 

252 **ctx, 

253 ) 

254 if res.status_code != 200: 

255 module.fail_json( 

256 msg="Failed to fetch memberships for user {0}. Error: {1}".format( 

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

258 ) 

259 ) 

260 result = set() 

261 for edge in list(res.items): 

262 group = getattr(edge, "group", None) 

263 if group is not None: 

264 gname = getattr(group, "name", None) 

265 if gname: 

266 result.add(gname) 

267 return result 

268 

269 

270def _add_memberships(module, blade, local_ds_name, groups): 

271 ctx = _context_kwargs(module) 

272 body = LocalUserMembershipPost( 

273 groups=[ 

274 LocalUserMembershipPostGroups(group=ReferenceWritable(name=g)) 

275 for g in groups 

276 ] 

277 ) 

278 res = blade.post_directory_services_local_users_members( 

279 local_membership=body, 

280 member_names=[module.params["name"]], 

281 local_directory_service_names=[local_ds_name], 

282 **ctx, 

283 ) 

284 if res.status_code != 200: 

285 module.fail_json( 

286 msg="Failed to add group memberships {0} for user {1}. Error: {2}".format( 

287 sorted(groups), 

288 module.params["name"], 

289 get_error_message(res), 

290 ) 

291 ) 

292 

293 

294def _remove_memberships(module, blade, local_ds_name, groups): 

295 ctx = _context_kwargs(module) 

296 for g in groups: 

297 res = blade.delete_directory_services_local_users_members( 

298 member_names=[module.params["name"]], 

299 group_names=[g], 

300 local_directory_service_names=[local_ds_name], 

301 **ctx, 

302 ) 

303 if res.status_code != 200: 

304 module.fail_json( 

305 msg=("Failed to remove user {0} from group {1}. Error: {2}").format( 

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

307 ) 

308 ) 

309 

310 

311def _reconcile_memberships( 

312 module, blade, local_ds_name, desired_primary_group, current_primary_group=None 

313): 

314 """Reconcile the user's non-primary group set against module.params['groups']. 

315 

316 Both the desired and the current primary group are excluded from removal: 

317 the API disallows deleting the primary-group edge, and until the PATCH has 

318 run the current primary is still in force. 

319 """ 

320 desired = set(module.params["groups"] or []) 

321 current = _get_current_groups(module, blade, local_ds_name) 

322 

323 exclude = set() 

324 if desired_primary_group: 

325 exclude.add(desired_primary_group) 

326 if current_primary_group: 

327 exclude.add(current_primary_group) 

328 desired = desired - exclude 

329 current_diff = current - exclude 

330 

331 add = desired - current_diff 

332 remove = current_diff - desired 

333 changed = bool(add or remove) 

334 if changed and not module.check_mode: 

335 if add: 

336 _add_memberships(module, blade, local_ds_name, add) 

337 if remove: 

338 _remove_memberships(module, blade, local_ds_name, remove) 

339 return changed 

340 

341 

342def delete_user(module, blade, local_ds_name, user): 

343 if getattr(user, "built_in", False): 

344 module.fail_json( 

345 msg="Refusing to delete built-in local user {0}".format( 

346 module.params["name"] 

347 ) 

348 ) 

349 changed = True 

350 if not module.check_mode: 

351 ctx = _context_kwargs(module) 

352 res = blade.delete_directory_services_local_users( 

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

354 local_directory_service_names=[local_ds_name], 

355 **ctx, 

356 ) 

357 if res.status_code != 200: 

358 module.fail_json( 

359 msg="Failed to delete local user {0}. Error: {1}".format( 

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

361 ) 

362 ) 

363 module.exit_json(changed=changed) 

364 

365 

366def create_user(module, blade, local_ds_name): 

367 # enabled is not defaulted in the argument spec so update paths stay 

368 # idempotent; at create time an unset value means "enabled". 

369 enabled = module.params["enabled"] if module.params["enabled"] is not None else True 

370 if enabled and not module.params["password"]: 

371 module.fail_json( 

372 msg="'password' is required when creating an enabled local user" 

373 ) 

374 changed = True 

375 if not module.check_mode: 

376 body_kwargs = {} 

377 if module.params["email"] is not None: 

378 body_kwargs["email"] = module.params["email"] 

379 body_kwargs["enabled"] = enabled 

380 if module.params["password"]: 

381 body_kwargs["password"] = module.params["password"] 

382 body_kwargs["primary_group"] = ReferenceWritable( 

383 name=module.params["primary_group"] 

384 ) 

385 if module.params["uid"] is not None: 

386 body_kwargs["uid"] = module.params["uid"] 

387 

388 ctx = _context_kwargs(module) 

389 res = blade.post_directory_services_local_users( 

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

391 local_directory_service_names=[local_ds_name], 

392 local_user=LocalUserPost(**body_kwargs), 

393 **ctx, 

394 ) 

395 if res.status_code != 200: 

396 module.fail_json( 

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

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

399 ) 

400 ) 

401 

402 if module.params["groups"]: 

403 _reconcile_memberships( 

404 module, 

405 blade, 

406 local_ds_name, 

407 module.params["primary_group"], 

408 ) 

409 module.exit_json(changed=changed) 

410 

411 

412def update_user(module, blade, local_ds_name, user): 

413 ctx = _context_kwargs(module) 

414 built_in = bool(getattr(user, "built_in", False)) 

415 current_pg = getattr(user, "primary_group", None) 

416 current_pg_name = getattr(current_pg, "name", None) if current_pg else None 

417 

418 patch_kwargs = {} 

419 identity_changes = [] 

420 

421 if module.params["new_name"] and module.params["new_name"] != module.params["name"]: 

422 if built_in: 

423 module.fail_json( 

424 msg="Refusing to rename built-in local user {0}".format( 

425 module.params["name"] 

426 ) 

427 ) 

428 # Refuse if target name already exists in this LDS. 

429 name_conflict_check = blade.get_directory_services_local_users( 

430 names=[module.params["new_name"]], 

431 filter=_lds_filter(local_ds_name), 

432 **ctx, 

433 ) 

434 if name_conflict_check.status_code == 200 and list(name_conflict_check.items): 

435 module.fail_json( 

436 msg=( 

437 "Cannot rename local user {0} to {1}: target name already exists" 

438 ).format(module.params["name"], module.params["new_name"]) 

439 ) 

440 patch_kwargs["name"] = module.params["new_name"] 

441 identity_changes.append("name") 

442 

443 if module.params["email"] is not None and module.params["email"] != getattr( 

444 user, "email", None 

445 ): 

446 patch_kwargs["email"] = module.params["email"] 

447 identity_changes.append("email") 

448 

449 if module.params["enabled"] is not None and module.params["enabled"] != getattr( 

450 user, "enabled", None 

451 ): 

452 # false -> true transition needs a password. 

453 if module.params["enabled"] and not getattr(user, "enabled", False): 

454 if not module.params["password"]: 

455 module.fail_json( 

456 msg=( 

457 "'password' is required to enable local user {0} " 

458 "(re-enabling a disabled account)" 

459 ).format(module.params["name"]) 

460 ) 

461 patch_kwargs["enabled"] = module.params["enabled"] 

462 identity_changes.append("enabled") 

463 

464 if module.params["uid"] is not None and module.params["uid"] != getattr( 

465 user, "uid", None 

466 ): 

467 patch_kwargs["uid"] = module.params["uid"] 

468 identity_changes.append("uid") 

469 

470 desired_pg_name = module.params["primary_group"] 

471 pg_changed = False 

472 if desired_pg_name != current_pg_name: 

473 patch_kwargs["primary_group"] = ReferenceWritable(name=desired_pg_name) 

474 identity_changes.append("primary_group") 

475 pg_changed = True 

476 

477 if module.params["force_password_reset"]: 

478 if not module.params["password"]: 

479 module.fail_json( 

480 msg=("'password' is required when 'force_password_reset' is true") 

481 ) 

482 patch_kwargs["password"] = module.params["password"] 

483 identity_changes.append("password") 

484 elif ( 

485 "password" not in patch_kwargs 

486 and patch_kwargs.get("enabled") is True 

487 and not getattr(user, "enabled", False) 

488 ): 

489 # Re-enable transition: include the password once we know it exists. 

490 patch_kwargs["password"] = module.params["password"] 

491 

492 if identity_changes and built_in: 

493 # Rename is already blocked above. Block other identity mutations too; 

494 # let the array reject anything we didn't foresee, but stop obvious 

495 # mutations up-front with a clear message. 

496 module.fail_json( 

497 msg="Refusing to modify built-in local user {0}".format( 

498 module.params["name"] 

499 ) 

500 ) 

501 

502 membership_changed = False 

503 # Order-of-ops guard: reconcile memberships BEFORE PATCHing primary_group 

504 # to dodge the known array-side regression where PATCHing primary_group 

505 # with stale membership edges present has returned 500 on some releases. 

506 if module.params["groups"] is not None and pg_changed: 

507 membership_changed = _reconcile_memberships( 

508 module, blade, local_ds_name, desired_pg_name, current_pg_name 

509 ) 

510 

511 patch_changed = bool(patch_kwargs) 

512 if patch_changed and not module.check_mode: 

513 res = blade.patch_directory_services_local_users( 

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

515 local_directory_service_names=[local_ds_name], 

516 local_user=LocalUserPatch(**patch_kwargs), 

517 **ctx, 

518 ) 

519 if res.status_code != 200: 

520 module.fail_json( 

521 msg="Failed to update local user {0}. Error: {1}".format( 

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

523 ) 

524 ) 

525 

526 # If we didn't reconcile earlier (no primary-group change), reconcile now. 

527 if module.params["groups"] is not None and not pg_changed: 

528 # After a rename the user is known by new_name, and reconciliation 

529 # uses module.params['name'] - but in check mode the rename was 

530 # skipped, so the user still answers to the current name. 

531 if patch_kwargs.get("name") and not module.check_mode: 

532 module.params["name"] = patch_kwargs["name"] 

533 membership_changed = _reconcile_memberships( 

534 module, blade, local_ds_name, desired_pg_name, current_pg_name 

535 ) 

536 

537 module.exit_json(changed=patch_changed or membership_changed) 

538 

539 

540def main(): 

541 argument_spec = purefb_argument_spec() 

542 argument_spec.update( 

543 dict( 

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

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

546 local_directory_service=dict(type="str", required=True), 

547 new_name=dict(type="str"), 

548 uid=dict(type="int"), 

549 email=dict(type="str"), 

550 enabled=dict(type="bool"), 

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

552 force_password_reset=dict(type="bool", default=False, no_log=False), 

553 primary_group=dict(type="str"), 

554 groups=dict(type="list", elements="str"), 

555 context=dict(type="str", default=""), 

556 ) 

557 ) 

558 

559 module = AnsibleModule( 

560 argument_spec, 

561 required_if=[("state", "present", ["primary_group"])], 

562 supports_check_mode=True, 

563 ) 

564 

565 if not HAS_PYPURECLIENT: 

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

567 

568 blade = get_system(module) 

569 api_version = get_rest_api_version(blade) 

570 if LooseVersion(MIN_REQUIRED_API_VERSION) > LooseVersion(api_version): 

571 module.fail_json( 

572 msg=( 

573 "FlashBlade REST version not supported. " 

574 "Minimum version required: {0} (Purity//FB 4.6.8+)" 

575 ).format(MIN_REQUIRED_API_VERSION) 

576 ) 

577 

578 if not module.params["context"]: 

579 # If no context is provided set the context to the local array name 

580 fleet_res = blade.get_fleets() 

581 if fleet_res.status_code == 200 and list(fleet_res.items): 

582 module.params["context"] = list(blade.get_arrays().items)[0].name 

583 

584 local_ds_name = module.params["local_directory_service"] 

585 user = _get_user(module, blade, local_ds_name) 

586 state = module.params["state"] 

587 

588 if state == "absent" and user: 

589 delete_user(module, blade, local_ds_name, user) 

590 elif state == "absent": 

591 module.exit_json(changed=False) 

592 elif state == "present" and not user: 

593 create_user(module, blade, local_ds_name) 

594 else: 

595 update_user(module, blade, local_ds_name, user) 

596 

597 

598if __name__ == "__main__": 

599 main()