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
« 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_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"""
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
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
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
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
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"""
187RETURN = r"""
188"""
190HAS_PYPURECLIENT = True
191try:
192 from pypureclient.flashblade import (
193 ManagementAccessPolicyRolePost,
194 ManagementAccessPolicyRolePermissionPatch,
195 ManagementAccessPolicyRolePermissionPost,
196 )
197except ImportError:
198 HAS_PYPURECLIENT = False
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)
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"])
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 {}
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
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)
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")
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
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)
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
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)
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))
337 if module.check_mode:
338 module.exit_json(changed=True)
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 )
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 )
369 module.exit_json(changed=True)
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
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
388 current = _get_permissions(module, blade, ctx_kwargs)
389 current_by_resource = {perm.resource: perm for perm in current}
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)
402 if not to_remove and not to_add and not to_patch:
403 return False
405 if module.check_mode:
406 return True
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 )
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 )
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 )
452 return True
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"])
465 existing = _get_role(module, blade, ctx_kwargs)
466 state = module.params["state"]
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)
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
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))
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 )
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)
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 ]
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)
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
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 )
603 module.exit_json(changed=True)
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 )
633 required_if = [
634 ("object", "role", ["name"]),
635 ("object", "admin_attach", ["admin", "policies"]),
636 ("object", "ds_attach", ["ds_role", "policies"]),
637 ]
639 module = AnsibleModule(
640 argument_spec, required_if=required_if, supports_check_mode=True
641 )
643 if not HAS_PYPURECLIENT:
644 module.fail_json(msg="py-pure-client sdk is required for this module")
646 blade = get_system(module)
647 api_version = get_rest_api_version(blade)
648 ctx_kwargs = _ctx(module, api_version)
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")
659if __name__ == "__main__":
660 main()