Coverage for plugins/modules/purefb_mgmt_auth_policy.py: 95%
214 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_auth_policy
20version_added: '1.28.0'
21short_description: Manage FlashBlade Management Authentication Policies and their members
22description:
23- Create, update, or delete a FlashBlade Management Authentication Policy.
24- Management authentication policies define which authentication factors
25 (password, SSH key/certificate/FIDO2 key) admins must or may present.
26 Currently the SSH interface is supported.
27- Reconcile the declarative list of members (admins and/or the array) the
28 policy is attached to.
29- A member can be attached to at most one management authentication policy
30 at a time; the array does not move members between policies implicitly.
31author:
32- Pure Storage Ansible Team (@avk) <pure-ansible-team@everpuredata.com>
33notes:
34- Changing an attached policy affects subsequent SSH logins for its members
35 (existing sessions are unaffected). Misconfiguration can lock
36 administrators out of SSH access - review changes with check mode first.
37- The built-in C(default-authentication-policy) is not protected by the
38 array; it can be modified or deleted like any other policy and is the
39 fallback for members without an explicit policy. Change it with care.
40- Moving a member between policies (I(replace_existing=true)) is not
41 atomic on the array; the member is briefly unattached during the move,
42 and if the move fails midway it is left without a policy until the
43 task is re-run.
44- Deletion safety is enforced by this module, not by the array - the
45 array will delete a policy that still has members attached, silently
46 detaching them.
47- When I(state=absent), configuration and membership options are ignored.
48options:
49 name:
50 description:
51 - Name of the management authentication policy.
52 type: str
53 required: true
54 state:
55 description:
56 - Whether the policy should exist.
57 default: present
58 choices: [ absent, present ]
59 type: str
60 enabled:
61 description:
62 - Whether the policy is active. Members of a disabled policy fall back
63 to the platform default authentication behavior.
64 - Omit to leave unchanged on an existing policy; defaults to C(true)
65 when creating a new policy.
66 type: bool
67 ssh_allowed_methods:
68 description:
69 - Authentication methods where any one method is sufficient for SSH
70 login.
71 - Mutually exclusive with I(ssh_required_methods) - a policy uses one
72 mode or the other. Supplying this list switches the policy to allowed
73 mode and clears any required methods.
74 - C(default) permits whatever methods the FlashBlade supports for the
75 interface and version.
76 - Omit to leave the current SSH configuration unchanged.
77 type: list
78 elements: str
79 choices: [ password, key, default ]
80 ssh_required_methods:
81 description:
82 - Authentication methods that must all be presented for SSH login.
83 - Mutually exclusive with I(ssh_allowed_methods) - a policy uses one
84 mode or the other. Supplying this list switches the policy to required
85 mode and clears any allowed methods.
86 - C(default) is not valid as a required method.
87 - Omit to leave the current SSH configuration unchanged.
88 type: list
89 elements: str
90 choices: [ password, key ]
91 members:
92 description:
93 - Declarative list of members the policy is attached to.
94 - Setting the list will attach missing members and detach any member
95 not listed here.
96 - Omit (or set to C(null)) to leave current membership untouched.
97 - Provide an empty list to detach every member from the policy.
98 - Attaching a member that is attached to a different policy fails
99 unless I(replace_existing=true).
100 type: list
101 elements: dict
102 suboptions:
103 name:
104 description:
105 - Name of the member (a local admin name, or the FlashBlade array
106 name for I(type=array)).
107 type: str
108 required: true
109 type:
110 description:
111 - Type of the member.
112 type: str
113 required: true
114 choices: [ admin, array ]
115 replace_existing:
116 description:
117 - Whether a member currently attached to a different management
118 authentication policy may be detached from it and attached to this
119 policy (detach-then-attach).
120 - When C(false) a conflicting assignment fails the task, naming the
121 conflicting policy.
122 type: bool
123 default: false
124 context:
125 description:
126 - Name of fleet member on which to perform the operation.
127 - This requires the array receiving the request is a member of a fleet
128 and the context name to be a member of the same fleet.
129 type: str
130 default: ""
131extends_documentation_fragment:
132- everpure.flashblade.everpure.fb
133"""
135EXAMPLES = r"""
136- name: Create a policy allowing password or key
137 everpure.flashblade.purefb_mgmt_auth_policy:
138 name: ssh-flexible
139 ssh_allowed_methods:
140 - password
141 - key
142 fb_url: 10.10.10.2
143 api_token: T-55a68eb5-c785-4720-a2ca-8b03903bf641
145- name: Create an MFA policy (password AND key) attached to two admins
146 everpure.flashblade.purefb_mgmt_auth_policy:
147 name: ssh-mfa
148 ssh_required_methods:
149 - password
150 - key
151 members:
152 - name: alice
153 type: admin
154 - name: bob
155 type: admin
156 fb_url: 10.10.10.2
157 api_token: T-55a68eb5-c785-4720-a2ca-8b03903bf641
159- name: Switch an existing policy from required to allowed mode
160 everpure.flashblade.purefb_mgmt_auth_policy:
161 name: ssh-mfa
162 ssh_allowed_methods:
163 - key
164 fb_url: 10.10.10.2
165 api_token: T-55a68eb5-c785-4720-a2ca-8b03903bf641
167- name: Attach the array baseline, moving it from its current policy
168 everpure.flashblade.purefb_mgmt_auth_policy:
169 name: ssh-mfa
170 members:
171 - name: my-flashblade
172 type: array
173 replace_existing: true
174 fb_url: 10.10.10.2
175 api_token: T-55a68eb5-c785-4720-a2ca-8b03903bf641
177- name: Detach every member from a policy
178 everpure.flashblade.purefb_mgmt_auth_policy:
179 name: ssh-mfa
180 members: []
181 fb_url: 10.10.10.2
182 api_token: T-55a68eb5-c785-4720-a2ca-8b03903bf641
184- name: Delete a policy (fails while members are attached)
185 everpure.flashblade.purefb_mgmt_auth_policy:
186 name: ssh-mfa
187 state: absent
188 fb_url: 10.10.10.2
189 api_token: T-55a68eb5-c785-4720-a2ca-8b03903bf641
190"""
192RETURN = r"""
193"""
195HAS_PYPURECLIENT = True
196try:
197 from pypureclient.flashblade import (
198 ManagementAuthenticationPolicy,
199 ManagementAuthenticationPolicyConfig,
200 ManagementAuthenticationPolicyPost,
201 )
202except ImportError:
203 HAS_PYPURECLIENT = False
205from ansible.module_utils.basic import AnsibleModule
206from ansible_collections.everpure.flashblade.plugins.module_utils.purefb import (
207 get_system,
208 purefb_argument_spec,
209)
210from ansible_collections.everpure.flashblade.plugins.module_utils.common import (
211 get_error_message,
212 get_rest_api_version,
213)
214from ansible_collections.everpure.flashblade.plugins.module_utils.version import (
215 LooseVersion,
216)
218MIN_MGMT_AUTH_API_VERSION = "2.22"
219CONTEXT_API_VERSION = "2.17"
220# Product design exempts these users from authentication-policy membership;
221# the module must never attach or detach them.
222PROTECTED_ADMINS = frozenset(["ir", "pureeng"])
223# Module member type -> REST member_types value.
224MEMBER_TYPE_API = {"admin": "admins", "array": "arrays"}
225MEMBER_TYPE_MODULE = {"admins": "admin", "arrays": "array"}
228def _ctx(module, api_version):
229 """Return the context_names kwargs dict when the array supports it."""
230 if not module.params.get("context"):
231 return {}
232 if LooseVersion(CONTEXT_API_VERSION) <= LooseVersion(api_version):
233 return {"context_names": [module.params["context"]]}
234 return {}
237def _validate_methods(module):
238 """Reject invalid SSH method lists before any API call.
240 The argument spec enforces per-list choices ('default' is only a choice
241 of ssh_allowed_methods) and mutual exclusion; this adds the rules the
242 spec cannot express.
243 """
244 for param in ("ssh_allowed_methods", "ssh_required_methods"):
245 methods = module.params[param]
246 if methods is None:
247 continue
248 if not methods:
249 module.fail_json(
250 msg=(
251 "{0} must contain at least one method. To switch modes "
252 "supply the new mode's non-empty list; the other mode is "
253 "cleared automatically."
254 ).format(param)
255 )
256 if len(set(methods)) != len(methods):
257 module.fail_json(msg="Duplicate method in {0}".format(param))
260def _validate_members(module):
261 members = module.params["members"]
262 if not members:
263 return
264 seen = set()
265 arrays = 0
266 for member in members:
267 key = (member["type"], member["name"])
268 if key in seen:
269 module.fail_json(
270 msg="Duplicate member (name={0}, type={1}) in members".format(
271 member["name"], member["type"]
272 )
273 )
274 seen.add(key)
275 if member["type"] == "array":
276 arrays += 1
277 elif member["name"].lower() in PROTECTED_ADMINS:
278 module.fail_json(
279 msg=(
280 "Admin {0} is exempt from management authentication "
281 "policies and cannot be attached."
282 ).format(member["name"])
283 )
284 if arrays > 1:
285 module.fail_json(msg="members may contain at most one array member")
288def _get_policy(module, blade, ctx_kwargs):
289 """The policy object, or None when it does not exist.
291 A missing policy is itself a 400 here ("Management authentication
292 policy does not exist."), so only that error reads as None - any
293 other failure fails the task rather than masquerading as "absent"
294 (which would let state=absent report ok on a transient error).
295 """
296 res = blade.get_management_authentication_policies(
297 names=[module.params["name"]], **ctx_kwargs
298 )
299 if res.status_code == 200:
300 items = list(res.items)
301 return items[0] if items else None
302 error = get_error_message(res)
303 if error and "policy does not exist" in str(error).lower():
304 return None
305 module.fail_json(
306 msg="Failed to read policy {0}. Error: {1}".format(module.params["name"], error)
307 )
310def _get_members(module, blade, ctx_kwargs):
311 """Current membership as a set of (module_type, member_name)."""
312 res = blade.get_management_authentication_policies_members(
313 policy_names=[module.params["name"]], **ctx_kwargs
314 )
315 if res.status_code != 200:
316 module.fail_json(
317 msg="Failed to list members of policy {0}. Error: {1}".format(
318 module.params["name"], get_error_message(res)
319 )
320 )
321 return set(
322 (MEMBER_TYPE_MODULE[item.member.resource_type], item.member.name)
323 for item in res.items
324 )
327def _get_member_current_policy(module, blade, member_type, member_name, ctx_kwargs):
328 """Name of the policy the member is attached to, or None.
330 A failed lookup fails the task - treating it as "no conflict" would
331 silently skip the preflight this call exists for.
332 """
333 res = blade.get_management_authentication_policies_members(
334 member_names=[member_name],
335 member_types=[MEMBER_TYPE_API[member_type]],
336 **ctx_kwargs,
337 )
338 if res.status_code != 200:
339 module.fail_json(
340 msg=("Failed to look up current policy for {0} {1}. Error: {2}").format(
341 member_type, member_name, get_error_message(res)
342 )
343 )
344 items = list(res.items)
345 return items[0].policy.name if items else None
348def _desired_ssh_config(module):
349 """Desired (mode, methods-set) from the params, or None when omitted."""
350 if module.params["ssh_allowed_methods"] is not None:
351 return ("allowed", set(module.params["ssh_allowed_methods"]))
352 if module.params["ssh_required_methods"] is not None:
353 return ("required", set(module.params["ssh_required_methods"]))
354 return None
357def _current_ssh_config(existing):
358 """Current (mode, methods-set) of a policy.
360 The array reports the inactive mode as an empty list, so exactly one
361 mode is non-empty on a configured policy.
362 """
363 allowed = set(getattr(existing.ssh, "allowed_methods", None) or [])
364 required = set(getattr(existing.ssh, "required_methods", None) or [])
365 if required:
366 return ("required", required)
367 return ("allowed", allowed)
370def _build_ssh_config(mode, methods):
371 """SSH config body with the opposite mode explicitly cleared.
373 PATCH replaces the ssh object wholesale (verified on REST 2.28: an
374 omitted list is cleared too), but the explicit empty list keeps the
375 request self-describing and version-safe.
376 """
377 if mode == "allowed":
378 return ManagementAuthenticationPolicyConfig(
379 allowed_methods=sorted(methods), required_methods=[]
380 )
381 return ManagementAuthenticationPolicyConfig(
382 allowed_methods=[], required_methods=sorted(methods)
383 )
386def _warn_if_live(module, blade, ctx_kwargs):
387 """Warn when changing authentication requirements on an attached policy."""
388 if _get_members(module, blade, ctx_kwargs):
389 module.warn(
390 "Policy {0} is attached to one or more members. The new "
391 "authentication requirements apply to their subsequent SSH "
392 "logins.".format(module.params["name"])
393 )
396def _validate_members_exist(module, blade, members, ctx_kwargs):
397 """Fail when a desired member does not exist.
399 The members relationship GET returns 200 with no items for a
400 nonexistent member - indistinguishable from "no policy attached" - so
401 existence has to come from the admins/arrays endpoints. Runs in check
402 mode too, so check mode catches a mistyped member name.
403 """
404 for member_type, member_name in sorted(members):
405 if member_type == "admin":
406 res = blade.get_admins(names=[member_name], **ctx_kwargs)
407 if res.status_code != 200:
408 # A missing admin is a 400 "Admin does not exist." here;
409 # passing the array's error through keeps a transient
410 # failure from masquerading as a missing member.
411 module.fail_json(
412 msg=(
413 "Member admin {0} does not exist or could not be "
414 "verified. Error: {1}"
415 ).format(member_name, get_error_message(res))
416 )
417 else:
418 res = blade.get_arrays(**ctx_kwargs)
419 if res.status_code != 200:
420 module.fail_json(
421 msg=(
422 "Member array {0} could not be verified. " "Error: {1}"
423 ).format(member_name, get_error_message(res))
424 )
425 if not any(array.name == member_name for array in res.items):
426 module.fail_json(
427 msg="Member array {0} does not exist.".format(member_name)
428 )
431def _find_member_conflicts(module, blade, members, ctx_kwargs):
432 """Read-only lookup of members attached to a different policy.
434 Returns a list of (member_type, member_name, other_policy_name).
435 """
436 conflicts = []
437 for member_type, member_name in sorted(members):
438 other = _get_member_current_policy(
439 module, blade, member_type, member_name, ctx_kwargs
440 )
441 if other and other != module.params["name"]:
442 conflicts.append((member_type, member_name, other))
443 return conflicts
446def _fail_on_member_conflicts(module, conflicts):
447 if conflicts and not module.params["replace_existing"]:
448 module.fail_json(
449 msg=(
450 "Cannot attach members already attached to another "
451 "management authentication policy: {0}. Set "
452 "replace_existing=true to move them."
453 ).format(
454 ", ".join(
455 "{0} {1} (attached to {2})".format(mtype, mname, other)
456 for mtype, mname, other in conflicts
457 )
458 )
459 )
462def reconcile_members(module, blade, ctx_kwargs):
463 """Diff desired members against actual; attach/detach to converge.
465 Returns True if any change was made (or would be made in check-mode).
466 """
467 desired_list = module.params["members"]
468 if desired_list is None:
469 return False
471 desired = set((member["type"], member["name"]) for member in desired_list)
472 current = _get_members(module, blade, ctx_kwargs)
474 to_add = desired - current
475 to_remove = set(
476 member
477 for member in current - desired
478 # Never detach exempt users, even if the array reports them.
479 if not (member[0] == "admin" and member[1].lower() in PROTECTED_ADMINS)
480 )
482 # Existence and conflict checks run in check mode too so that check
483 # mode fails exactly where a real run would.
484 _validate_members_exist(module, blade, to_add, ctx_kwargs)
485 conflicts = _find_member_conflicts(module, blade, to_add, ctx_kwargs)
486 _fail_on_member_conflicts(module, conflicts)
488 if not to_add and not to_remove:
489 return False
491 if module.check_mode:
492 return True
494 for member_type, member_name in sorted(to_remove):
495 res = blade.delete_management_authentication_policies_members(
496 policy_names=[module.params["name"]],
497 member_names=[member_name],
498 member_types=[MEMBER_TYPE_API[member_type]],
499 **ctx_kwargs,
500 )
501 if res.status_code != 200:
502 module.fail_json(
503 msg=("Failed to detach {0} {1} from policy {2}. Error: {3}").format(
504 member_type,
505 member_name,
506 module.params["name"],
507 get_error_message(res),
508 )
509 )
511 # Attach each member as a tight detach->attach pair. The array offers
512 # no atomic move (member-side POSTs refuse an attached member just
513 # like the policy-side one), so a moved member is unavoidably
514 # unattached between its own two calls - but only between those two,
515 # and a failure orphans at most that one member.
516 conflict_by_member = {(mtype, mname): other for mtype, mname, other in conflicts}
517 for member_type, member_name in sorted(to_add):
518 other = conflict_by_member.get((member_type, member_name))
519 if other:
520 res = blade.delete_management_authentication_policies_members(
521 policy_names=[other],
522 member_names=[member_name],
523 member_types=[MEMBER_TYPE_API[member_type]],
524 **ctx_kwargs,
525 )
526 if res.status_code != 200:
527 module.fail_json(
528 msg=("Failed to detach {0} {1} from policy {2}. Error: {3}").format(
529 member_type, member_name, other, get_error_message(res)
530 )
531 )
532 res = blade.post_management_authentication_policies_members(
533 policy_names=[module.params["name"]],
534 member_names=[member_name],
535 member_types=[MEMBER_TYPE_API[member_type]],
536 **ctx_kwargs,
537 )
538 if res.status_code != 200:
539 orphan_note = ""
540 if other:
541 orphan_note = (
542 " {0} {1} is currently attached to no policy; re-run "
543 "the task to attach it."
544 ).format(member_type, member_name)
545 module.fail_json(
546 msg=("Failed to attach {0} {1} to policy {2}. Error: {3}{4}").format(
547 member_type,
548 member_name,
549 module.params["name"],
550 get_error_message(res),
551 orphan_note,
552 )
553 )
555 return True
558def delete_policy(module, blade, ctx_kwargs):
559 """Guarded delete.
561 The array allows deleting an attached policy, so the members check
562 must live client-side.
563 """
564 if _get_members(module, blade, ctx_kwargs):
565 module.fail_json(
566 msg=(
567 "Authentication policy {0} has attached members. Detach "
568 "members first (members: [] with state: present)."
569 ).format(module.params["name"])
570 )
571 if not module.check_mode:
572 res = blade.delete_management_authentication_policies(
573 names=[module.params["name"]], **ctx_kwargs
574 )
575 if res.status_code != 200:
576 module.fail_json(
577 msg="Failed to delete policy {0}. Error: {1}".format(
578 module.params["name"], get_error_message(res)
579 )
580 )
581 module.exit_json(changed=True)
584def create_policy(module, blade, ctx_kwargs):
585 desired_ssh = _desired_ssh_config(module)
586 if desired_ssh is None:
587 module.fail_json(
588 msg=(
589 "Creating a policy requires ssh_allowed_methods or "
590 "ssh_required_methods."
591 )
592 )
593 enabled = module.params["enabled"]
594 if enabled is None:
595 enabled = True
597 # Preflight member existence and conflicts before the policy POST so
598 # a refused attach cannot leave a half-created policy behind, and so
599 # check mode fails exactly where a real run would.
600 if module.params["members"]:
601 desired_members = set(
602 (member["type"], member["name"]) for member in module.params["members"]
603 )
604 _validate_members_exist(module, blade, desired_members, ctx_kwargs)
605 _fail_on_member_conflicts(
606 module,
607 _find_member_conflicts(module, blade, desired_members, ctx_kwargs),
608 )
610 if module.check_mode:
611 module.exit_json(changed=True)
613 res = blade.post_management_authentication_policies(
614 names=[module.params["name"]],
615 policy=ManagementAuthenticationPolicyPost(
616 enabled=enabled,
617 ssh=_build_ssh_config(*desired_ssh),
618 ),
619 **ctx_kwargs,
620 )
621 if res.status_code != 200:
622 module.fail_json(
623 msg="Failed to create policy {0}. Error: {1}".format(
624 module.params["name"], get_error_message(res)
625 )
626 )
627 reconcile_members(module, blade, ctx_kwargs)
628 module.exit_json(changed=True)
631def update_policy(module, blade, ctx_kwargs, existing):
632 changed = False
634 patch_kwargs = {}
635 if (
636 module.params["enabled"] is not None
637 and module.params["enabled"] != existing.enabled
638 ):
639 patch_kwargs["enabled"] = module.params["enabled"]
641 desired_ssh = _desired_ssh_config(module)
642 if desired_ssh is not None and desired_ssh != _current_ssh_config(existing):
643 patch_kwargs["ssh"] = _build_ssh_config(*desired_ssh)
645 if patch_kwargs:
646 changed = True
647 _warn_if_live(module, blade, ctx_kwargs)
648 if not module.check_mode:
649 res = blade.patch_management_authentication_policies(
650 names=[module.params["name"]],
651 policy=ManagementAuthenticationPolicy(**patch_kwargs),
652 **ctx_kwargs,
653 )
654 if res.status_code != 200:
655 module.fail_json(
656 msg="Failed to update policy {0}. Error: {1}".format(
657 module.params["name"], get_error_message(res)
658 )
659 )
661 if reconcile_members(module, blade, ctx_kwargs):
662 changed = True
664 module.exit_json(changed=changed)
667def main():
668 argument_spec = purefb_argument_spec()
669 argument_spec.update(
670 dict(
671 name=dict(type="str", required=True),
672 state=dict(type="str", default="present", choices=["absent", "present"]),
673 enabled=dict(type="bool"),
674 ssh_allowed_methods=dict(
675 type="list",
676 elements="str",
677 choices=["password", "key", "default"],
678 ),
679 ssh_required_methods=dict(
680 type="list",
681 elements="str",
682 choices=["password", "key"],
683 ),
684 members=dict(
685 type="list",
686 elements="dict",
687 options=dict(
688 name=dict(type="str", required=True),
689 type=dict(
690 type="str",
691 required=True,
692 choices=["admin", "array"],
693 ),
694 ),
695 ),
696 replace_existing=dict(type="bool", default=False),
697 context=dict(type="str", default=""),
698 )
699 )
701 module = AnsibleModule(
702 argument_spec,
703 mutually_exclusive=[["ssh_allowed_methods", "ssh_required_methods"]],
704 supports_check_mode=True,
705 )
707 if not HAS_PYPURECLIENT:
708 module.fail_json(msg="py-pure-client sdk is required for this module")
710 state = module.params["state"]
711 if state == "present":
712 _validate_methods(module)
713 _validate_members(module)
715 blade = get_system(module)
716 api_version = get_rest_api_version(blade)
717 if LooseVersion(MIN_MGMT_AUTH_API_VERSION) > LooseVersion(api_version):
718 module.fail_json(
719 msg=(
720 "FlashBlade REST version {0} does not support management "
721 "authentication policies (requires {1}+)."
722 ).format(api_version, MIN_MGMT_AUTH_API_VERSION)
723 )
724 ctx_kwargs = _ctx(module, api_version)
726 existing = _get_policy(module, blade, ctx_kwargs)
728 if state == "absent":
729 if existing is None:
730 module.exit_json(changed=False)
731 delete_policy(module, blade, ctx_kwargs)
732 else:
733 if existing is None:
734 create_policy(module, blade, ctx_kwargs)
735 else:
736 update_policy(module, blade, ctx_kwargs, existing)
739if __name__ == "__main__":
740 main()