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

188 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_policy 

20version_added: '1.28.0' 

21short_description: Manage FlashBlade Management Access Policies and their rules 

22description: 

23- Create, update, or delete a FlashBlade Management Access Policy. 

24- Reconcile the declarative list of rules (role + scope) attached to the policy. 

25- Attachment of policies to admins or directory-service roles is handled by 

26 M(everpure.flashblade.purefb_mgmt_role). 

27author: 

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

29options: 

30 name: 

31 description: 

32 - Name of the management access policy. 

33 - Case-insensitive, case-preserving. This is the handle used for 

34 attachment and for naming child rules. 

35 type: str 

36 required: true 

37 state: 

38 description: 

39 - Whether the policy should exist. 

40 default: present 

41 choices: [ absent, present ] 

42 type: str 

43 enabled: 

44 description: 

45 - Whether the policy is active. Disabled policies are dropped during 

46 aggregation on the member. 

47 - Omit to leave unchanged on an existing policy; defaults to C(true) 

48 when creating a new policy. 

49 type: bool 

50 aggregation_strategy: 

51 description: 

52 - How permissions combine across multiple policies attached to the same 

53 member. 

54 - C(all-permissions) unions the permissions from every policy (default 

55 on the array). 

56 - C(least-common-permission) intersects them. 

57 - Never affects rule combination within a single policy. 

58 - Omit to leave unchanged on an existing policy. 

59 type: str 

60 choices: [ all-permissions, least-common-permission ] 

61 rules: 

62 description: 

63 - Declarative list of rules (role + scope) that make up the policy. 

64 - Setting the list will add missing rules and remove any rule not 

65 listed here. Rule identity is (I(role), I(scope.resource_type), 

66 I(scope.name)), matched case-insensitively — names are 

67 case-preserving on the array. 

68 - Duplicate rules (same role and scope, any case) are rejected. 

69 - Omit (or set to C(null)) to leave the current rule list untouched. 

70 - Provide an empty list to remove every rule from the policy. 

71 - On REST API 2.26 and newer rules are added and removed one at a time. 

72 Older arrays (REST 2.19 to 2.25) have no per-rule endpoints, so the 

73 rule list is sent with the policy at creation and replaced as a whole 

74 when it differs on update. 

75 - Purity//FB 4.6 (REST 2.21) requires exactly one rule when a policy 

76 is created. 

77 type: list 

78 elements: dict 

79 suboptions: 

80 role: 

81 description: 

82 - Name of the role granted by this rule (built-in, e.g. C(viewer), 

83 C(admin), C(storage), C(support); or a custom role created by 

84 M(everpure.flashblade.purefb_mgmt_role)). 

85 type: str 

86 required: true 

87 scope: 

88 description: 

89 - Where the role applies. 

90 type: dict 

91 required: true 

92 suboptions: 

93 name: 

94 description: 

95 - Name of the target (the FlashBlade array name for 

96 I(resource_type=arrays), or the realm name for 

97 I(resource_type=realms)). 

98 type: str 

99 required: true 

100 resource_type: 

101 description: 

102 - Scope type. 

103 type: str 

104 required: true 

105 choices: [ arrays, realms ] 

106 context: 

107 description: 

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

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

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

111 type: str 

112 default: "" 

113extends_documentation_fragment: 

114- everpure.flashblade.everpure.fb 

115""" 

116 

117EXAMPLES = r""" 

118- name: Create an empty policy 

119 everpure.flashblade.purefb_mgmt_policy: 

120 name: tenant-a-ops 

121 enabled: true 

122 aggregation_strategy: all-permissions 

123 fb_url: 10.10.10.2 

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

125 

126- name: Create a policy with two rules 

127 everpure.flashblade.purefb_mgmt_policy: 

128 name: tenant-a-ops 

129 rules: 

130 - role: viewer 

131 scope: 

132 name: my-flashblade 

133 resource_type: arrays 

134 - role: backup-operator 

135 scope: 

136 name: realm-a 

137 resource_type: realms 

138 fb_url: 10.10.10.2 

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

140 

141- name: Disable a policy 

142 everpure.flashblade.purefb_mgmt_policy: 

143 name: tenant-a-ops 

144 enabled: false 

145 fb_url: 10.10.10.2 

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

147 

148- name: Remove every rule from a policy 

149 everpure.flashblade.purefb_mgmt_policy: 

150 name: tenant-a-ops 

151 rules: [] 

152 fb_url: 10.10.10.2 

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

154 

155- name: Delete a policy 

156 everpure.flashblade.purefb_mgmt_policy: 

157 name: tenant-a-ops 

158 state: absent 

159 fb_url: 10.10.10.2 

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

161""" 

162 

163RETURN = r""" 

164""" 

165 

166HAS_PYPURECLIENT = True 

167try: 

168 from pypureclient.flashblade import ( 

169 ManagementAccessPolicy, 

170 ManagementAccessPolicyPost, 

171 ManagementAccessPolicyRule, 

172 ReferenceWritable, 

173 ) 

174except ImportError: 

175 HAS_PYPURECLIENT = False 

176 

177from ansible.module_utils.basic import AnsibleModule 

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

179 get_system, 

180 purefb_argument_spec, 

181) 

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

183 get_error_message, 

184 get_rest_api_version, 

185) 

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

187 LooseVersion, 

188) 

189 

190MIN_POLICY_API_VERSION = "2.19" 

191# REST version that added the management-access-policies/rules endpoints. 

192# Older arrays carry the rule list inline on the policy object instead. 

193MIN_RULE_API_VERSION = "2.26" 

194CONTEXT_API_VERSION = "2.17" 

195BUILTIN_POLICIES = frozenset( 

196 ["array_admin", "management", "ops_admin", "readonly", "storage_admin"] 

197) 

198 

199 

200def _ctx(module, api_version): 

201 """Return the context_names kwargs dict when the array supports it.""" 

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

203 return {} 

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

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

206 return {} 

207 

208 

209def _rule_key(role_name, scope_type, scope_name): 

210 """Case-insensitive rule identity — names are case-preserving on the 

211 array, so a case variant of an existing rule is the same rule.""" 

212 return (role_name.lower(), scope_type.lower(), scope_name.lower()) 

213 

214 

215def _desired_rules_by_key(rules): 

216 """Map identity key -> user-supplied rule dict.""" 

217 by_key = {} 

218 for rule in rules: 

219 key = _rule_key( 

220 rule["role"], 

221 rule["scope"]["resource_type"], 

222 rule["scope"]["name"], 

223 ) 

224 by_key.setdefault(key, rule) 

225 return by_key 

226 

227 

228def _validate_rules(module): 

229 """Reject duplicate rules client-side. 

230 

231 Rule identity is (role, scope.resource_type, scope.name). Without this 

232 check the create path would forward duplicates to the array while the 

233 update path silently collapses them. 

234 """ 

235 rules = module.params["rules"] 

236 if not rules: 

237 return 

238 seen = set() 

239 for rule in rules: 

240 key = _rule_key( 

241 rule["role"], 

242 rule["scope"]["resource_type"], 

243 rule["scope"]["name"], 

244 ) 

245 if key in seen: 

246 module.fail_json( 

247 msg=("Duplicate rule (role={0}, scope={1}/{2}) in rules").format( 

248 rule["role"], 

249 rule["scope"]["resource_type"], 

250 rule["scope"]["name"], 

251 ) 

252 ) 

253 seen.add(key) 

254 

255 

256def _get_policy(module, blade, ctx_kwargs): 

257 res = blade.get_management_access_policies( 

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

259 ) 

260 if res.status_code != 200: 

261 return None 

262 items = list(res.items) 

263 return items[0] if items else None 

264 

265 

266def _get_current_rules(module, blade, ctx_kwargs): 

267 res = blade.get_management_access_policies_rules( 

268 policy_names=[module.params["name"]], **ctx_kwargs 

269 ) 

270 if res.status_code != 200: 

271 module.fail_json( 

272 msg="Failed to list rules for policy {0}. Error: {1}".format( 

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

274 ) 

275 ) 

276 return list(res.items) 

277 

278 

279def _members_attached(module, blade, ctx_kwargs): 

280 res = blade.get_management_access_policies_members( 

281 policy_names=[module.params["name"]], **ctx_kwargs 

282 ) 

283 if res.status_code != 200: 

284 return False 

285 return bool(list(res.items)) 

286 

287 

288def delete_policy(module, blade, ctx_kwargs): 

289 if _members_attached(module, blade, ctx_kwargs): 

290 module.fail_json( 

291 msg=( 

292 "Policy {0} is attached to one or more members. " 

293 "Detach it first (see purefb_mgmt_role)." 

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

295 ) 

296 if not module.check_mode: 

297 res = blade.delete_management_access_policies( 

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

299 ) 

300 if res.status_code != 200: 

301 module.fail_json( 

302 msg="Failed to delete policy {0}. Error: {1}".format( 

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

304 ) 

305 ) 

306 module.exit_json(changed=True) 

307 

308 

309def _reconcile_rules(module, blade, ctx_kwargs): 

310 """Diff desired rule list against actual; add/remove to converge. 

311 

312 Returns True if any change was made (or would be made in check-mode). 

313 """ 

314 desired = module.params["rules"] 

315 if desired is None: 

316 return False 

317 

318 current = _get_current_rules(module, blade, ctx_kwargs) 

319 current_by_key = {} 

320 for rule in current: 

321 key = _rule_key(rule.role.name, rule.scope.resource_type, rule.scope.name) 

322 current_by_key[key] = rule 

323 

324 desired_by_key = _desired_rules_by_key(desired) 

325 

326 to_remove = set(current_by_key) - set(desired_by_key) 

327 to_add = set(desired_by_key) - set(current_by_key) 

328 

329 if not to_remove and not to_add: 

330 return False 

331 

332 if module.check_mode: 

333 return True 

334 

335 for key in to_remove: 

336 # Rule names (<policy>.<index>) renumber whenever a lower-indexed 

337 # rule is deleted, so a name captured before this loop can point at 

338 # a different rule (or none) by the time it is used. Rule IDs are 

339 # immutable — delete by id only. 

340 rule = current_by_key[key] 

341 res = blade.delete_management_access_policies_rules( 

342 ids=[rule.id], 

343 **ctx_kwargs, 

344 ) 

345 if res.status_code != 200: 

346 module.fail_json( 

347 msg=( 

348 "Failed to remove rule (role={0}, scope={1}/{2}) from " 

349 "policy {3}. Error: {4}" 

350 ).format( 

351 rule.role.name, 

352 rule.scope.resource_type, 

353 rule.scope.name, 

354 module.params["name"], 

355 get_error_message(res), 

356 ) 

357 ) 

358 

359 for key in to_add: 

360 spec = desired_by_key[key] 

361 role_name = spec["role"] 

362 scope_type = spec["scope"]["resource_type"] 

363 scope_name = spec["scope"]["name"] 

364 rule = ManagementAccessPolicyRule( 

365 role=ReferenceWritable(name=role_name), 

366 scope=ReferenceWritable(name=scope_name, resource_type=scope_type), 

367 ) 

368 res = blade.post_management_access_policies_rules( 

369 rule=rule, 

370 policy_names=[module.params["name"]], 

371 **ctx_kwargs, 

372 ) 

373 if res.status_code != 200: 

374 module.fail_json( 

375 msg=( 

376 "Failed to add rule (role={0}, scope={1}/{2}) to " 

377 "policy {3}. Error: {4}" 

378 ).format( 

379 role_name, 

380 scope_type, 

381 scope_name, 

382 module.params["name"], 

383 get_error_message(res), 

384 ) 

385 ) 

386 

387 return True 

388 

389 

390def _inline_rule_key(rule): 

391 """Identity key of a rule as the array returns it inside the policy. 

392 

393 Tolerates missing role/scope references (a rule whose role was deleted 

394 comes back with ``role: null``) instead of raising ``AttributeError``. 

395 """ 

396 role = getattr(getattr(rule, "role", None), "name", None) or "" 

397 scope = getattr(rule, "scope", None) 

398 return _rule_key( 

399 role, 

400 getattr(scope, "resource_type", None) or "", 

401 getattr(scope, "name", None) or "", 

402 ) 

403 

404 

405def _inline_rules_differ(module, existing): 

406 """Compare the desired rules with the rule list carried on the policy 

407 object (REST < 2.26, where there are no per-rule endpoints). 

408 

409 Identity is (role, scope.resource_type, scope.name), case-insensitive, 

410 so declaring the same rule the policy was created with is not a change. 

411 """ 

412 current = set() 

413 for rule in getattr(existing, "rules", None) or []: 

414 current.add(_inline_rule_key(rule)) 

415 return current != set(_desired_rules_by_key(module.params["rules"])) 

416 

417 

418def _replace_inline_rules(module, blade, ctx_kwargs, existing): 

419 """Replace the whole rule list of an existing policy (REST < 2.26). 

420 

421 Returns True if a change was made (or would be made in check-mode). 

422 """ 

423 if module.params["rules"] is None or not _inline_rules_differ(module, existing): 

424 return False 

425 if module.check_mode: 

426 return True 

427 res = blade.patch_management_access_policies( 

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

429 policy=ManagementAccessPolicy(rules=_build_post_rules(module.params["rules"])), 

430 **ctx_kwargs, 

431 ) 

432 if res.status_code != 200: 

433 module.fail_json( 

434 msg="Failed to replace rules on policy {0}. Error: {1}".format( 

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

436 ) 

437 ) 

438 return True 

439 

440 

441def _build_post_rules(rules): 

442 if rules is None: 

443 return None 

444 result = [] 

445 for rule in rules: 

446 result.append( 

447 ManagementAccessPolicyRule( 

448 role=ReferenceWritable(name=rule["role"]), 

449 scope=ReferenceWritable( 

450 name=rule["scope"]["name"], 

451 resource_type=rule["scope"]["resource_type"], 

452 ), 

453 ) 

454 ) 

455 return result 

456 

457 

458def create_policy(module, blade, ctx_kwargs): 

459 enabled = module.params["enabled"] 

460 if enabled is None: 

461 enabled = True 

462 

463 if module.check_mode: 

464 module.exit_json(changed=True) 

465 

466 body_kwargs = {"enabled": enabled} 

467 if module.params["aggregation_strategy"] is not None: 

468 body_kwargs["aggregation_strategy"] = module.params["aggregation_strategy"] 

469 post_rules = _build_post_rules(module.params["rules"]) 

470 if post_rules is not None: 

471 body_kwargs["rules"] = post_rules 

472 

473 policy = ManagementAccessPolicyPost(**body_kwargs) 

474 res = blade.post_management_access_policies( 

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

476 ) 

477 if res.status_code != 200: 

478 module.fail_json( 

479 msg="Failed to create policy {0}. Error: {1}".format( 

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

481 ) 

482 ) 

483 module.exit_json(changed=True) 

484 

485 

486def update_policy(module, blade, ctx_kwargs, existing, api_version=None): 

487 changed = False 

488 

489 patch_kwargs = {} 

490 if ( 

491 module.params["enabled"] is not None 

492 and module.params["enabled"] != existing.enabled 

493 ): 

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

495 if ( 

496 module.params["aggregation_strategy"] is not None 

497 and module.params["aggregation_strategy"] != existing.aggregation_strategy 

498 ): 

499 patch_kwargs["aggregation_strategy"] = module.params["aggregation_strategy"] 

500 

501 if patch_kwargs: 

502 changed = True 

503 if not module.check_mode: 

504 res = blade.patch_management_access_policies( 

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

506 policy=ManagementAccessPolicy(**patch_kwargs), 

507 **ctx_kwargs, 

508 ) 

509 if res.status_code != 200: 

510 module.fail_json( 

511 msg="Failed to update policy {0}. Error: {1}".format( 

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

513 ) 

514 ) 

515 

516 if module.params["rules"] is not None: 

517 if api_version is not None and LooseVersion( 

518 MIN_RULE_API_VERSION 

519 ) > LooseVersion(api_version): 

520 rules_changed = _replace_inline_rules(module, blade, ctx_kwargs, existing) 

521 else: 

522 rules_changed = _reconcile_rules(module, blade, ctx_kwargs) 

523 if rules_changed: 

524 changed = True 

525 

526 module.exit_json(changed=changed) 

527 

528 

529def main(): 

530 argument_spec = purefb_argument_spec() 

531 argument_spec.update( 

532 dict( 

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

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

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

536 aggregation_strategy=dict( 

537 type="str", 

538 choices=["all-permissions", "least-common-permission"], 

539 ), 

540 rules=dict( 

541 type="list", 

542 elements="dict", 

543 options=dict( 

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

545 scope=dict( 

546 type="dict", 

547 required=True, 

548 options=dict( 

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

550 resource_type=dict( 

551 type="str", 

552 required=True, 

553 choices=["arrays", "realms"], 

554 ), 

555 ), 

556 ), 

557 ), 

558 ), 

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

560 ) 

561 ) 

562 

563 module = AnsibleModule(argument_spec, supports_check_mode=True) 

564 

565 if not HAS_PYPURECLIENT: 

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

567 

568 state = module.params["state"] 

569 name = module.params["name"] 

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

571 # the guard must match case variants (e.g. "Readonly") too. 

572 is_builtin = name.lower() in BUILTIN_POLICIES 

573 mutating = ( 

574 module.params["enabled"] is not None 

575 or module.params["aggregation_strategy"] is not None 

576 or module.params["rules"] is not None 

577 or state == "absent" 

578 ) 

579 if is_builtin and mutating: 

580 module.fail_json( 

581 msg=("Built-in policy {0} cannot be modified or deleted.").format(name) 

582 ) 

583 _validate_rules(module) 

584 

585 blade = get_system(module) 

586 api_version = get_rest_api_version(blade) 

587 if LooseVersion(MIN_POLICY_API_VERSION) > LooseVersion(api_version): 

588 module.fail_json( 

589 msg=( 

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

591 "access policies (requires {1}+)." 

592 ).format(api_version, MIN_POLICY_API_VERSION) 

593 ) 

594 ctx_kwargs = _ctx(module, api_version) 

595 

596 existing = _get_policy(module, blade, ctx_kwargs) 

597 

598 if state == "absent": 

599 if existing is None: 

600 module.exit_json(changed=False) 

601 delete_policy(module, blade, ctx_kwargs) 

602 else: 

603 if existing is None: 

604 create_policy(module, blade, ctx_kwargs) 

605 else: 

606 update_policy(module, blade, ctx_kwargs, existing, api_version) 

607 

608 

609if __name__ == "__main__": 

610 main()