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
« prev ^ index » next coverage.py v7.14.1, created at 2026-10-30 17:06 -0400
1#!/usr/bin/python
2# -*- coding: utf-8 -*-
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)
7from __future__ import absolute_import, division, print_function
9__metaclass__ = type
11ANSIBLE_METADATA = {
12 "metadata_version": "1.1",
13 "status": ["preview"],
14 "supported_by": "community",
15}
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"""
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
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
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
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
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"""
163RETURN = r"""
164"""
166HAS_PYPURECLIENT = True
167try:
168 from pypureclient.flashblade import (
169 ManagementAccessPolicy,
170 ManagementAccessPolicyPost,
171 ManagementAccessPolicyRule,
172 ReferenceWritable,
173 )
174except ImportError:
175 HAS_PYPURECLIENT = False
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)
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)
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 {}
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())
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
228def _validate_rules(module):
229 """Reject duplicate rules client-side.
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)
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
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)
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))
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)
309def _reconcile_rules(module, blade, ctx_kwargs):
310 """Diff desired rule list against actual; add/remove to converge.
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
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
324 desired_by_key = _desired_rules_by_key(desired)
326 to_remove = set(current_by_key) - set(desired_by_key)
327 to_add = set(desired_by_key) - set(current_by_key)
329 if not to_remove and not to_add:
330 return False
332 if module.check_mode:
333 return True
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 )
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 )
387 return True
390def _inline_rule_key(rule):
391 """Identity key of a rule as the array returns it inside the policy.
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 )
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).
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"]))
418def _replace_inline_rules(module, blade, ctx_kwargs, existing):
419 """Replace the whole rule list of an existing policy (REST < 2.26).
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
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
458def create_policy(module, blade, ctx_kwargs):
459 enabled = module.params["enabled"]
460 if enabled is None:
461 enabled = True
463 if module.check_mode:
464 module.exit_json(changed=True)
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
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)
486def update_policy(module, blade, ctx_kwargs, existing, api_version=None):
487 changed = False
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"]
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 )
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
526 module.exit_json(changed=changed)
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 )
563 module = AnsibleModule(argument_spec, supports_check_mode=True)
565 if not HAS_PYPURECLIENT:
566 module.fail_json(msg="py-pure-client sdk is required for this module")
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)
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)
596 existing = _get_policy(module, blade, ctx_kwargs)
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)
609if __name__ == "__main__":
610 main()