Coverage for plugins/modules/purefb_mgmt_role.py: 96%

234 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_mgmt_role 

20version_added: '1.28.0' 

21short_description: Manage FlashBlade custom management roles, permissions, and policy attachment 

22description: 

23- Manage FlashBlade Management Access Roles and the permissions they grant 

24 (I(object=role)). 

25- Attach or detach management access policies to a local admin 

26 (I(object=admin_attach)) or to a directory-service role mapping 

27 (I(object=ds_attach)). 

28- Policies and their rules are managed by 

29 M(everpure.flashblade.purefb_mgmt_policy). 

30- API-client policy attachment is not covered here; it is managed via the 

31 I(access_policies) field on M(everpure.flashblade.purefb_apiclient). 

32author: 

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

34options: 

35 object: 

36 description: 

37 - What the module operates on. 

38 - C(role) manages a custom role and its permissions. 

39 - C(admin_attach) attaches or detaches policies on a local admin. 

40 - C(ds_attach) attaches or detaches policies on a directory-service role 

41 mapping. 

42 type: str 

43 required: true 

44 choices: [ role, admin_attach, ds_attach ] 

45 state: 

46 description: 

47 - For I(object=role), whether the role should exist. 

48 - For attachment modes, whether the listed policies should be attached 

49 (C(present)) or detached (C(absent)). Other policies on the subject 

50 are untouched. 

51 - Detaching from a subject that does not exist is a no-op; attaching 

52 to one fails. 

53 default: present 

54 choices: [ absent, present ] 

55 type: str 

56 name: 

57 description: 

58 - Name of the custom role. Required for I(object=role). 

59 - Maximum 63 characters. Cannot begin with an underscore (reserved). 

60 type: str 

61 description: 

62 description: 

63 - Free-form description of the role. Applied at creation time only; 

64 the FlashBlade REST API does not support editing a role's description. 

65 type: str 

66 permissions: 

67 description: 

68 - Declarative list of permissions on the role. Setting this list adds 

69 missing entries and removes any not listed. Identity key is 

70 I(resource); changing I(actions) on an existing resource is a PATCH. 

71 - Omit (or set to C(null)) to leave the current permission list 

72 untouched. 

73 - Provide an empty list to remove every permission from the role. 

74 - Built-in (Pure-defined) roles cannot be modified; supplying this 

75 option for one fails. Omit it to use the task as a bare existence 

76 check on a built-in role. 

77 - Only meaningful with I(object=role). 

78 type: list 

79 elements: dict 

80 suboptions: 

81 resource: 

82 description: 

83 - Target API resource. Accepted as either the short name (e.g. 

84 C(file-system-snapshots)) or the fully-qualified form 

85 (e.g. C(purestorage.com/v2/file-system-snapshots)). 

86 type: str 

87 required: true 

88 actions: 

89 description: 

90 - Allowed verbs on the resource. Any subset of C(get), C(post), 

91 C(patch), C(delete), or the special value C(all). 

92 - C(all) cannot be combined with specific verbs. 

93 - Duplicates are rejected. 

94 - The array stores C(all) as a grant distinct from the four 

95 explicit verbs, even though the effective access is identical 

96 today. Re-declaring an existing permission in the other spelling 

97 is a real state change and reports C(changed=true). 

98 - C(all) is forward-compatible - verbs added by future Purity 

99 releases are included automatically, while an enumerated list 

100 stays frozen at the verbs listed. 

101 type: list 

102 elements: str 

103 required: true 

104 admin: 

105 description: 

106 - Name of the local admin whose policy list should be reconciled. 

107 - Required for I(object=admin_attach). 

108 - The array refuses policy changes on built-in users (such as 

109 C(pureuser)); use a local admin created by 

110 M(everpure.flashblade.purefb_user). 

111 type: str 

112 ds_role: 

113 description: 

114 - Name of the directory-service role mapping whose policy list should 

115 be reconciled. 

116 - Required for I(object=ds_attach). 

117 type: str 

118 policies: 

119 description: 

120 - List of management access policy names to attach (with I(state=present)) 

121 or detach (with I(state=absent)) from the subject. 

122 - Names are matched against the attached policies case-insensitively. 

123 - Required for I(object=admin_attach) and I(object=ds_attach). 

124 type: list 

125 elements: str 

126 context: 

127 description: 

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

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

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

131 type: str 

132 default: "" 

133extends_documentation_fragment: 

134- everpure.flashblade.everpure.fb 

135""" 

136 

137EXAMPLES = r""" 

138- name: Create a custom role with one permission 

139 everpure.flashblade.purefb_mgmt_role: 

140 object: role 

141 name: backup-operator 

142 description: Manages file system snapshots and restores in a realm 

143 permissions: 

144 - resource: file-system-snapshots 

145 actions: [get, post, patch, delete] 

146 fb_url: 10.10.10.2 

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

148 

149- name: Grant a role read-only on a second resource, remove any other permissions 

150 everpure.flashblade.purefb_mgmt_role: 

151 object: role 

152 name: backup-operator 

153 permissions: 

154 - resource: file-system-snapshots 

155 actions: [get] 

156 - resource: file-systems 

157 actions: [get] 

158 fb_url: 10.10.10.2 

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

160 

161- name: Delete a custom role 

162 everpure.flashblade.purefb_mgmt_role: 

163 object: role 

164 name: backup-operator 

165 state: absent 

166 fb_url: 10.10.10.2 

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

168 

169- name: Attach a policy to a local admin 

170 everpure.flashblade.purefb_mgmt_role: 

171 object: admin_attach 

172 admin: svc-automation 

173 policies: [tenant-a-ops] 

174 fb_url: 10.10.10.2 

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

176 

177- name: Detach policies from a DS role mapping 

178 everpure.flashblade.purefb_mgmt_role: 

179 object: ds_attach 

180 ds_role: tenant-a-admins 

181 policies: [tenant-a-ops, tenant-a-readonly] 

182 state: absent 

183 fb_url: 10.10.10.2 

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

185""" 

186 

187RETURN = r""" 

188""" 

189 

190HAS_PYPURECLIENT = True 

191try: 

192 from pypureclient.flashblade import ( 

193 ManagementAccessPolicyRolePost, 

194 ManagementAccessPolicyRolePermissionPatch, 

195 ManagementAccessPolicyRolePermissionPost, 

196 ) 

197except ImportError: 

198 HAS_PYPURECLIENT = False 

199 

200from ansible.module_utils.basic import AnsibleModule 

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

202 get_system, 

203 purefb_argument_spec, 

204) 

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

206 get_error_message, 

207 get_rest_api_version, 

208) 

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

210 LooseVersion, 

211) 

212 

213MIN_ATTACH_API_VERSION = "2.19" 

214MIN_ROLE_API_VERSION = "2.26" 

215CONTEXT_API_VERSION = "2.17" 

216RESOURCE_PREFIX = "purestorage.com/v2/" 

217VALID_VERB_SET = frozenset(["get", "post", "patch", "delete"]) 

218 

219 

220def _ctx(module, api_version): 

221 if not module.params.get("context"): 

222 return {} 

223 if LooseVersion(CONTEXT_API_VERSION) <= LooseVersion(api_version): 

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

225 return {} 

226 

227 

228def _normalize_resource(resource): 

229 """Accept both short and fully-qualified resource names.""" 

230 if resource.startswith(RESOURCE_PREFIX): 

231 return resource 

232 return RESOURCE_PREFIX + resource 

233 

234 

235def _normalize_actions(module, actions): 

236 """Lowercase, dedupe, validate. Returns a sorted list for comparison.""" 

237 seen = [] 

238 for verb in actions: 

239 v = verb.lower() 

240 if v in seen: 

241 module.fail_json(msg="Duplicate action {0} in permission".format(verb)) 

242 seen.append(v) 

243 if "all" in seen and len(seen) > 1: 

244 module.fail_json(msg="Action 'all' cannot be combined with specific verbs") 

245 if "all" not in seen: 

246 invalid = [v for v in seen if v not in VALID_VERB_SET] 

247 if invalid: 

248 module.fail_json( 

249 msg="Invalid action(s) {0}; allowed: get, post, patch, delete, all".format( 

250 invalid 

251 ) 

252 ) 

253 return sorted(seen) 

254 

255 

256def _validate_role_name(module, name): 

257 if len(name) > 63: 

258 module.fail_json(msg="Role name must be 63 characters or fewer") 

259 if name.startswith("_"): 

260 module.fail_json(msg="Role names beginning with '_' are reserved") 

261 

262 

263def _get_role(module, blade, ctx_kwargs): 

264 res = blade.get_management_access_policies_roles( 

265 names=[module.params["name"]], **ctx_kwargs 

266 ) 

267 if res.status_code != 200: 

268 return None 

269 items = list(res.items) 

270 return items[0] if items else None 

271 

272 

273def _get_permissions(module, blade, ctx_kwargs): 

274 res = blade.get_management_access_policies_roles_permissions( 

275 role_names=[module.params["name"]], **ctx_kwargs 

276 ) 

277 if res.status_code != 200: 

278 module.fail_json( 

279 msg="Failed to list permissions for role {0}. Error: {1}".format( 

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

281 ) 

282 ) 

283 return list(res.items) 

284 

285 

286def _role_referenced_by_rule(blade, role_name, ctx_kwargs): 

287 """Return True if any policy has a rule referencing this role.""" 

288 res = blade.get_management_access_policies_rules(**ctx_kwargs) 

289 if res.status_code != 200: 

290 return False 

291 for rule in list(res.items): 

292 if getattr(rule.role, "name", None) == role_name: 

293 return True 

294 return False 

295 

296 

297def _delete_role(module, blade, ctx_kwargs, existing): 

298 if getattr(existing, "pure_defined", False): 

299 module.fail_json( 

300 msg="Built-in role {0} cannot be deleted".format(module.params["name"]) 

301 ) 

302 if _role_referenced_by_rule(blade, module.params["name"], ctx_kwargs): 

303 module.fail_json( 

304 msg=( 

305 "Role {0} is referenced by one or more policy rules. Remove " 

306 "the referencing rules first (see purefb_mgmt_policy)." 

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

308 ) 

309 if not module.check_mode: 

310 res = blade.delete_management_access_policies_roles( 

311 names=[module.params["name"]], **ctx_kwargs 

312 ) 

313 if res.status_code != 200: 

314 module.fail_json( 

315 msg="Failed to delete role {0}. Error: {1}".format( 

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

317 ) 

318 ) 

319 module.exit_json(changed=True) 

320 

321 

322def _create_role(module, blade, ctx_kwargs): 

323 # Validate every permission before any mutating call so a rejected 

324 # create leaves nothing behind on the array. 

325 validated = [] 

326 seen_resources = set() 

327 for perm in module.params["permissions"] or []: 

328 actions = _normalize_actions(module, perm["actions"]) 

329 resource = _normalize_resource(perm["resource"]) 

330 if resource in seen_resources: 

331 module.fail_json( 

332 msg="Duplicate resource {0} in permissions".format(resource) 

333 ) 

334 seen_resources.add(resource) 

335 validated.append((resource, actions)) 

336 

337 if module.check_mode: 

338 module.exit_json(changed=True) 

339 

340 body_kwargs = {} 

341 if module.params["description"] is not None: 

342 body_kwargs["description"] = module.params["description"] 

343 role_post = ManagementAccessPolicyRolePost(**body_kwargs) 

344 res = blade.post_management_access_policies_roles( 

345 names=[module.params["name"]], role=role_post, **ctx_kwargs 

346 ) 

347 if res.status_code != 200: 

348 module.fail_json( 

349 msg="Failed to create role {0}. Error: {1}".format( 

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

351 ) 

352 ) 

353 

354 for resource, actions in validated: 

355 res = blade.post_management_access_policies_roles_permissions( 

356 permission=ManagementAccessPolicyRolePermissionPost( 

357 actions=actions, resource=resource 

358 ), 

359 role_names=[module.params["name"]], 

360 **ctx_kwargs, 

361 ) 

362 if res.status_code != 200: 

363 module.fail_json( 

364 msg=( 

365 "Failed to add permission on {0} to role {1}. " "Error: {2}" 

366 ).format(resource, module.params["name"], get_error_message(res)) 

367 ) 

368 

369 module.exit_json(changed=True) 

370 

371 

372def _reconcile_permissions(module, blade, ctx_kwargs): 

373 """Diff desired permissions against actual. Returns True if changed.""" 

374 desired = module.params["permissions"] 

375 if desired is None: 

376 return False 

377 

378 desired_by_resource = {} 

379 for perm in desired: 

380 resource = _normalize_resource(perm["resource"]) 

381 actions = _normalize_actions(module, perm["actions"]) 

382 if resource in desired_by_resource: 

383 module.fail_json( 

384 msg="Duplicate resource {0} in permissions".format(resource) 

385 ) 

386 desired_by_resource[resource] = actions 

387 

388 current = _get_permissions(module, blade, ctx_kwargs) 

389 current_by_resource = {perm.resource: perm for perm in current} 

390 

391 to_remove = set(current_by_resource) - set(desired_by_resource) 

392 to_add = set(desired_by_resource) - set(current_by_resource) 

393 to_check = set(desired_by_resource) & set(current_by_resource) 

394 to_patch = [] 

395 for resource in to_check: 

396 current_actions = sorted( 

397 [a.lower() for a in list(current_by_resource[resource].actions or [])] 

398 ) 

399 if current_actions != desired_by_resource[resource]: 

400 to_patch.append(resource) 

401 

402 if not to_remove and not to_add and not to_patch: 

403 return False 

404 

405 if module.check_mode: 

406 return True 

407 

408 for resource in to_remove: 

409 perm_name = current_by_resource[resource].name 

410 res = blade.delete_management_access_policies_roles_permissions( 

411 names=[perm_name], 

412 **ctx_kwargs, 

413 ) 

414 if res.status_code != 200: 

415 module.fail_json( 

416 msg=( 

417 "Failed to remove permission {0} from role {1}. Error: {2}" 

418 ).format(perm_name, module.params["name"], get_error_message(res)) 

419 ) 

420 

421 for resource in to_add: 

422 res = blade.post_management_access_policies_roles_permissions( 

423 permission=ManagementAccessPolicyRolePermissionPost( 

424 actions=desired_by_resource[resource], resource=resource 

425 ), 

426 role_names=[module.params["name"]], 

427 **ctx_kwargs, 

428 ) 

429 if res.status_code != 200: 

430 module.fail_json( 

431 msg=("Failed to add permission on {0} to role {1}. Error: {2}").format( 

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

433 ) 

434 ) 

435 

436 for resource in to_patch: 

437 perm_name = current_by_resource[resource].name 

438 res = blade.patch_management_access_policies_roles_permissions( 

439 permission=ManagementAccessPolicyRolePermissionPatch( 

440 actions=desired_by_resource[resource] 

441 ), 

442 names=[perm_name], 

443 **ctx_kwargs, 

444 ) 

445 if res.status_code != 200: 

446 module.fail_json( 

447 msg=("Failed to update permission {0} on role {1}. Error: {2}").format( 

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

449 ) 

450 ) 

451 

452 return True 

453 

454 

455def _handle_role(module, blade, api_version, ctx_kwargs): 

456 if LooseVersion(MIN_ROLE_API_VERSION) > LooseVersion(api_version): 

457 module.fail_json( 

458 msg=( 

459 "FlashBlade REST version {0} does not support custom roles " 

460 "and permissions (requires {1}+)." 

461 ).format(api_version, MIN_ROLE_API_VERSION) 

462 ) 

463 _validate_role_name(module, module.params["name"]) 

464 

465 existing = _get_role(module, blade, ctx_kwargs) 

466 state = module.params["state"] 

467 

468 if state == "absent": 

469 if existing is None: 

470 module.exit_json(changed=False) 

471 _delete_role(module, blade, ctx_kwargs, existing) 

472 else: 

473 if existing is None: 

474 _create_role(module, blade, ctx_kwargs) 

475 else: 

476 # Guard the mutation only: a bare existence check (no 

477 # permissions supplied) on a built-in role stays a no-op. 

478 if module.params["permissions"] is not None and getattr( 

479 existing, "pure_defined", False 

480 ): 

481 module.fail_json( 

482 msg="Built-in role {0} cannot be modified".format( 

483 module.params["name"] 

484 ) 

485 ) 

486 changed = _reconcile_permissions(module, blade, ctx_kwargs) 

487 module.exit_json(changed=changed) 

488 

489 

490def _current_attached_policy_names(module, blade, kind, ctx_kwargs): 

491 if kind == "admin": 

492 subject = module.params["admin"] 

493 res = blade.get_management_access_policies_admins( 

494 member_names=[subject], **ctx_kwargs 

495 ) 

496 else: 

497 subject = module.params["ds_role"] 

498 res = blade.get_management_access_policies_directory_services_roles( 

499 member_names=[subject], **ctx_kwargs 

500 ) 

501 if res.status_code != 200: 

502 module.fail_json( 

503 msg="Failed to list policies attached to {0} {1}. Error: {2}".format( 

504 kind, subject, get_error_message(res) 

505 ) 

506 ) 

507 names = set() 

508 for mapping in list(res.items): 

509 pol = getattr(mapping, "policy", None) 

510 if pol is not None and getattr(pol, "name", None): 

511 names.add(pol.name) 

512 return names 

513 

514 

515def _subject_exists(module, blade, kind, ctx_kwargs): 

516 """Return True if the attach subject (admin or ds_role) exists.""" 

517 if kind == "admin": 

518 res = blade.get_admins(names=[module.params["admin"]], **ctx_kwargs) 

519 else: 

520 res = blade.get_directory_services_roles( 

521 names=[module.params["ds_role"]], **ctx_kwargs 

522 ) 

523 return res.status_code == 200 and bool(list(res.items)) 

524 

525 

526def _handle_attach(module, blade, api_version, ctx_kwargs, kind): 

527 if LooseVersion(MIN_ATTACH_API_VERSION) > LooseVersion(api_version): 

528 module.fail_json( 

529 msg=( 

530 "FlashBlade REST version {0} does not support management " 

531 "access policy attachment (requires {1}+)." 

532 ).format(api_version, MIN_ATTACH_API_VERSION) 

533 ) 

534 

535 state = module.params["state"] 

536 if not _subject_exists(module, blade, kind, ctx_kwargs): 

537 # A missing subject trivially satisfies state=absent: nothing can 

538 # be attached to it, so detaching is an idempotent no-op. 

539 if state == "absent": 

540 module.exit_json(changed=False) 

541 subject = ( 

542 module.params["admin"] if kind == "admin" else module.params["ds_role"] 

543 ) 

544 module.fail_json(msg="{0} {1} does not exist".format(kind, subject)) 

545 current = _current_attached_policy_names(module, blade, kind, ctx_kwargs) 

546 # Policy names are case-insensitive, case-preserving on the array, so 

547 # diff on a lowercased key. Detach uses the array's canonical spelling. 

548 current_by_key = {name.lower(): name for name in current} 

549 requested_by_key = {} 

550 for name in module.params["policies"]: 

551 requested_by_key.setdefault(name.lower(), name) 

552 

553 if state == "present": 

554 to_add = [ 

555 name for key, name in requested_by_key.items() if key not in current_by_key 

556 ] 

557 to_remove = [] 

558 else: 

559 to_add = [] 

560 to_remove = [ 

561 current_by_key[key] for key in requested_by_key if key in current_by_key 

562 ] 

563 

564 if not to_add and not to_remove: 

565 module.exit_json(changed=False) 

566 if module.check_mode: 

567 module.exit_json(changed=True) 

568 

569 if kind == "admin": 

570 subject = module.params["admin"] 

571 add_fn = blade.post_management_access_policies_admins 

572 del_fn = blade.delete_management_access_policies_admins 

573 else: 

574 subject = module.params["ds_role"] 

575 add_fn = blade.post_management_access_policies_directory_services_roles 

576 del_fn = blade.delete_management_access_policies_directory_services_roles 

577 

578 if to_add: 

579 res = add_fn( 

580 member_names=[subject], 

581 policy_names=sorted(to_add), 

582 **ctx_kwargs, 

583 ) 

584 if res.status_code != 200: 

585 module.fail_json( 

586 msg="Failed to attach policies {0} to {1} {2}. Error: {3}".format( 

587 sorted(to_add), kind, subject, get_error_message(res) 

588 ) 

589 ) 

590 if to_remove: 

591 res = del_fn( 

592 member_names=[subject], 

593 policy_names=sorted(to_remove), 

594 **ctx_kwargs, 

595 ) 

596 if res.status_code != 200: 

597 module.fail_json( 

598 msg="Failed to detach policies {0} from {1} {2}. Error: {3}".format( 

599 sorted(to_remove), kind, subject, get_error_message(res) 

600 ) 

601 ) 

602 

603 module.exit_json(changed=True) 

604 

605 

606def main(): 

607 argument_spec = purefb_argument_spec() 

608 argument_spec.update( 

609 dict( 

610 object=dict( 

611 type="str", 

612 required=True, 

613 choices=["role", "admin_attach", "ds_attach"], 

614 ), 

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

616 name=dict(type="str"), 

617 description=dict(type="str"), 

618 permissions=dict( 

619 type="list", 

620 elements="dict", 

621 options=dict( 

622 resource=dict(type="str", required=True), 

623 actions=dict(type="list", elements="str", required=True), 

624 ), 

625 ), 

626 admin=dict(type="str"), 

627 ds_role=dict(type="str"), 

628 policies=dict(type="list", elements="str"), 

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

630 ) 

631 ) 

632 

633 required_if = [ 

634 ("object", "role", ["name"]), 

635 ("object", "admin_attach", ["admin", "policies"]), 

636 ("object", "ds_attach", ["ds_role", "policies"]), 

637 ] 

638 

639 module = AnsibleModule( 

640 argument_spec, required_if=required_if, supports_check_mode=True 

641 ) 

642 

643 if not HAS_PYPURECLIENT: 

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

645 

646 blade = get_system(module) 

647 api_version = get_rest_api_version(blade) 

648 ctx_kwargs = _ctx(module, api_version) 

649 

650 obj = module.params["object"] 

651 if obj == "role": 

652 _handle_role(module, blade, api_version, ctx_kwargs) 

653 elif obj == "admin_attach": 

654 _handle_attach(module, blade, api_version, ctx_kwargs, "admin") 

655 else: 

656 _handle_attach(module, blade, api_version, ctx_kwargs, "ds_role") 

657 

658 

659if __name__ == "__main__": 

660 main()