Coverage for plugins/modules/purefb_tls_policy.py: 87%
203 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_tls_policy
20version_added: '1.28.0'
21short_description: Manage FlashBlade TLS policies
22description:
23- Create, update or delete FlashBlade TLS policies and manage the
24 network interfaces they are applied to.
25- A TLS policy controls the server certificate presented on a network
26 interface, the TLS versions and ciphers permitted, and optional
27 mutual-TLS settings for verifying client certificates.
28author:
29- Pure Storage Ansible Team (@avk) <pure-ansible-team@everpuredata.com>
30options:
31 name:
32 description:
33 - Name of the TLS policy.
34 type: str
35 required: true
36 state:
37 description:
38 - Define whether the TLS policy should exist or not.
39 default: present
40 choices: [ absent, present ]
41 type: str
42 enabled:
43 description:
44 - If C(true) the policy is enabled.
45 - Defaults to C(true) on creation if not specified.
46 type: bool
47 appliance_certificate:
48 description:
49 - Name of the certificate to present as the server certificate during
50 TLS negotiation with clients connecting to network interfaces
51 covered by this policy.
52 - Required when creating a policy; the array rejects a policy without
53 an appliance certificate. Use C(global) for the built-in certificate.
54 - Set to C(global) to reset the policy to the built-in appliance
55 certificate, effectively removing a previously assigned custom
56 certificate.
57 type: str
58 client_certificates_required:
59 description:
60 - If C(true), all clients must present a client certificate during
61 TLS negotiation; failure to do so will be rejected.
62 - If C(false), client certificates are optional.
63 - The array only accepts C(true) when
64 I(verify_client_certificate_trust) is also C(true), which in turn
65 needs I(trusted_client_certificate_authority).
66 - Requires Purity//FB REST API 2.18 or later.
67 type: bool
68 trusted_client_certificate_authority:
69 description:
70 - Name of a certificate, or certificate group, used to verify
71 certificates presented by clients when
72 I(verify_client_certificate_trust) is C(true).
73 - Set to an empty string (C("")) to clear a previously assigned
74 trusted CA reference. This only applies on update; on create an
75 empty string is treated the same as omitting the parameter.
76 - Requires Purity//FB REST API 2.18 or later.
77 type: str
78 verify_client_certificate_trust:
79 description:
80 - If C(true), certificates presented by clients in TLS negotiation
81 will undergo strict trust verification using the certificate(s)
82 referenced by I(trusted_client_certificate_authority).
83 - Requires Purity//FB REST API 2.18 or later.
84 type: bool
85 min_tls_version:
86 description:
87 - Minimum TLS version permitted for inbound connections.
88 - C(default) lets the array pick a recommended minimum that may shift
89 across software upgrades.
90 type: str
91 choices: [ default, '1.0', '1.1', '1.2', '1.3' ]
92 enabled_tls_ciphers:
93 description:
94 - List of TLS ciphers to enable.
95 - When supplied, only these ciphers will be enabled.
96 type: list
97 elements: str
98 disabled_tls_ciphers:
99 description:
100 - List of TLS ciphers to disable.
101 type: list
102 elements: str
103 network_interfaces:
104 description:
105 - Desired set of network interfaces this policy should be applied to.
106 - On update, the set of attached interfaces is reconciled against
107 this list - missing interfaces are attached, extras are detached.
108 - Omit entirely to leave existing attachments untouched.
109 - Use an empty list to detach the policy from all interfaces.
110 - On create, an empty list creates the policy with no interfaces
111 attached; this is equivalent to omitting the parameter.
112 type: list
113 elements: str
114extends_documentation_fragment:
115- everpure.flashblade.everpure.fb
116"""
118EXAMPLES = r"""
119- name: Create TLS policy with a server cert and minimum TLS 1.2
120 everpure.flashblade.purefb_tls_policy:
121 name: secure_data_plane
122 appliance_certificate: my_cert
123 min_tls_version: '1.2'
124 fb_url: 10.10.10.2
125 api_token: T-68618f31-0c9e-4e57-aa44-5306a2cf10e3
127- name: Require mutual TLS using a trusted CA group
128 everpure.flashblade.purefb_tls_policy:
129 name: secure_data_plane
130 client_certificates_required: true
131 verify_client_certificate_trust: true
132 trusted_client_certificate_authority: corp_ca_group
133 fb_url: 10.10.10.2
134 api_token: T-68618f31-0c9e-4e57-aa44-5306a2cf10e3
136- name: Apply the policy to a specific set of data network interfaces
137 everpure.flashblade.purefb_tls_policy:
138 name: secure_data_plane
139 network_interfaces:
140 - data1.eth0
141 - data2.eth0
142 fb_url: 10.10.10.2
143 api_token: T-68618f31-0c9e-4e57-aa44-5306a2cf10e3
145- name: Detach the TLS policy from all network interfaces
146 everpure.flashblade.purefb_tls_policy:
147 name: secure_data_plane
148 network_interfaces: []
149 fb_url: 10.10.10.2
150 api_token: T-68618f31-0c9e-4e57-aa44-5306a2cf10e3
152- name: Disable a TLS policy without deleting it
153 everpure.flashblade.purefb_tls_policy:
154 name: secure_data_plane
155 enabled: false
156 fb_url: 10.10.10.2
157 api_token: T-68618f31-0c9e-4e57-aa44-5306a2cf10e3
159- name: Reset appliance cert to the built-in global cert and clear the trusted CA
160 everpure.flashblade.purefb_tls_policy:
161 name: secure_data_plane
162 appliance_certificate: global
163 trusted_client_certificate_authority: ""
164 fb_url: 10.10.10.2
165 api_token: T-68618f31-0c9e-4e57-aa44-5306a2cf10e3
167- name: Delete a TLS policy
168 everpure.flashblade.purefb_tls_policy:
169 name: secure_data_plane
170 state: absent
171 fb_url: 10.10.10.2
172 api_token: T-68618f31-0c9e-4e57-aa44-5306a2cf10e3
173"""
175RETURN = r"""
176"""
178HAS_PYPURECLIENT = True
179try:
180 from pypureclient.flashblade import (
181 TlsPolicy,
182 TlsPolicyPost,
183 ReferenceWritable,
184 )
185except ImportError:
186 HAS_PYPURECLIENT = False
188from ansible.module_utils.basic import AnsibleModule
189from ansible_collections.everpure.flashblade.plugins.module_utils.purefb import (
190 get_system,
191 purefb_argument_spec,
192)
193from ansible_collections.everpure.flashblade.plugins.module_utils.common import (
194 get_error_message,
195 get_rest_api_version,
196)
197from ansible_collections.everpure.flashblade.plugins.module_utils.version import (
198 LooseVersion,
199)
201MIN_REQUIRED_API_VERSION = "2.17"
202MIN_MUTUAL_TLS_API_VERSION = "2.18"
203MUTUAL_TLS_PARAMS = (
204 "client_certificates_required",
205 "trusted_client_certificate_authority",
206 "verify_client_certificate_trust",
207)
210def _check_mutual_tls_support(module, api_version):
211 """Fail early if mutual-TLS options were set but the array does not advertise REST 2.18.
213 pypureclient silently drops unknown fields when serialising TlsPolicyPost, so
214 without this guard a user on 2.17 would get a successful create with the
215 mutual-TLS settings missing from the policy.
216 """
217 if LooseVersion(MIN_MUTUAL_TLS_API_VERSION) > LooseVersion(api_version):
218 used = [p for p in MUTUAL_TLS_PARAMS if module.params.get(p) is not None]
219 if used:
220 module.fail_json(
221 msg=(
222 "Mutual TLS options ({0}) require Purity//FB REST API {1} or later."
223 ).format(", ".join(used), MIN_MUTUAL_TLS_API_VERSION)
224 )
227def _validate_attachable(module, blade, interfaces):
228 """Validate every interface in C(interfaces) exists and isn't bound to another TLS policy.
230 Called for the to-attach subset only: interfaces already bound to *this* policy
231 do not conflict and are left alone.
232 """
233 if not interfaces:
234 return
235 res = blade.get_network_interfaces(
236 names=interfaces,
237 )
238 if res.status_code != 200:
239 module.fail_json(
240 msg="Network interface(s) not found: {0}. Error: {1}".format(
241 interfaces, get_error_message(res)
242 )
243 )
244 found = {getattr(i, "name", None) for i in (res.items or [])}
245 missing = [i for i in interfaces if i not in found]
246 if missing:
247 module.fail_json(msg="Network interface(s) not found: {0}".format(missing))
249 res = blade.get_tls_policies_network_interfaces(
250 member_names=interfaces,
251 )
252 if res.status_code != 200:
253 return
254 our_name = module.params["name"]
255 conflicts = []
256 for item in res.items or []:
257 policy_name = getattr(getattr(item, "policy", None), "name", None)
258 member_name = getattr(getattr(item, "member", None), "name", None)
259 if policy_name and policy_name != our_name:
260 conflicts.append((member_name, policy_name))
261 if conflicts:
262 formatted = ", ".join("{0}=>{1}".format(m, p) for m, p in conflicts)
263 module.fail_json(
264 msg=(
265 "Cannot attach TLS policy {0} - the following interface(s) already "
266 "have a different TLS policy applied: {1}. Detach the existing "
267 "policy before re-attaching."
268 ).format(our_name, formatted)
269 )
272def _get_policy(module, blade):
273 """Return the existing TLS policy object or None."""
274 res = blade.get_tls_policies(
275 names=[module.params["name"]],
276 )
277 if res.status_code != 200:
278 return None
279 items = list(res.items)
280 return items[0] if items else None
283def _get_attached_interfaces(module, blade):
284 """Return a sorted list of network interface names this policy is applied to."""
285 res = blade.get_tls_policies_network_interfaces(
286 policy_names=[module.params["name"]],
287 )
288 if res.status_code != 200:
289 return []
290 names = []
291 for item in res.items or []:
292 member = getattr(item, "member", None)
293 member_name = getattr(member, "name", None)
294 if member_name:
295 names.append(member_name)
296 return sorted(names)
299def _build_post_kwargs(module):
300 """Build kwargs for TlsPolicyPost from module params."""
301 kwargs = {}
302 if module.params["enabled"] is not None:
303 kwargs["enabled"] = module.params["enabled"]
304 if module.params["appliance_certificate"]:
305 kwargs["appliance_certificate"] = ReferenceWritable(
306 name=module.params["appliance_certificate"]
307 )
308 if module.params["client_certificates_required"] is not None:
309 kwargs["client_certificates_required"] = module.params[
310 "client_certificates_required"
311 ]
312 if module.params["trusted_client_certificate_authority"]:
313 kwargs["trusted_client_certificate_authority"] = ReferenceWritable(
314 name=module.params["trusted_client_certificate_authority"]
315 )
316 if module.params["verify_client_certificate_trust"] is not None:
317 kwargs["verify_client_certificate_trust"] = module.params[
318 "verify_client_certificate_trust"
319 ]
320 if module.params["min_tls_version"]:
321 kwargs["min_tls_version"] = module.params["min_tls_version"]
322 if module.params["enabled_tls_ciphers"] is not None:
323 kwargs["enabled_tls_ciphers"] = module.params["enabled_tls_ciphers"]
324 if module.params["disabled_tls_ciphers"] is not None:
325 kwargs["disabled_tls_ciphers"] = module.params["disabled_tls_ciphers"]
326 return kwargs
329def _reconcile_interfaces(module, blade):
330 """Reconcile policy network-interface attachments to the desired set.
332 Returns True if any attach/detach was needed.
333 """
334 desired = sorted(module.params["network_interfaces"])
335 current = _get_attached_interfaces(module, blade)
336 to_attach = [i for i in desired if i not in current]
337 to_detach = [i for i in current if i not in desired]
339 if not (to_attach or to_detach):
340 return False
342 if to_attach:
343 _validate_attachable(module, blade, to_attach)
345 if module.check_mode:
346 return True
348 attached = []
349 for iface in to_attach:
350 res = blade.post_tls_policies_network_interfaces(
351 policy_names=[module.params["name"]],
352 member_names=[iface],
353 )
354 if res.status_code != 200:
355 note = " Already attached: {0}.".format(attached) if attached else ""
356 module.fail_json(
357 msg="Failed to attach TLS policy {0} to interface {1}. Error: {2}.{3}".format(
358 module.params["name"], iface, get_error_message(res), note
359 )
360 )
361 attached.append(iface)
363 detached = []
364 for iface in to_detach:
365 res = blade.delete_tls_policies_network_interfaces(
366 policy_names=[module.params["name"]],
367 member_names=[iface],
368 )
369 if res.status_code != 200:
370 note = " Already detached: {0}.".format(detached) if detached else ""
371 module.fail_json(
372 msg="Failed to detach TLS policy {0} from interface {1}. Error: {2}.{3}".format(
373 module.params["name"], iface, get_error_message(res), note
374 )
375 )
376 detached.append(iface)
378 return True
381def delete_policy(module, blade):
382 """Delete a TLS policy, detaching it from any network interfaces first."""
383 changed = True
384 if not module.check_mode:
385 attached = _get_attached_interfaces(module, blade)
386 detached = []
387 for iface in attached:
388 res = blade.delete_tls_policies_network_interfaces(
389 policy_names=[module.params["name"]],
390 member_names=[iface],
391 )
392 if res.status_code != 200:
393 note = " Already detached: {0}.".format(detached) if detached else ""
394 module.fail_json(
395 msg="Failed to detach TLS policy {0} from interface {1}. Error: {2}.{3}".format(
396 module.params["name"], iface, get_error_message(res), note
397 )
398 )
399 detached.append(iface)
400 res = blade.delete_tls_policies(
401 names=[module.params["name"]],
402 )
403 if res.status_code != 200:
404 detached_note = ""
405 if attached:
406 detached_note = (
407 " The policy has already been detached from interfaces {0};"
408 " re-run to retry deletion or reattach manually if needed."
409 ).format(attached)
410 module.fail_json(
411 msg="Failed to delete TLS policy {0}. Error: {1}.{2}".format(
412 module.params["name"], get_error_message(res), detached_note
413 )
414 )
415 module.exit_json(changed=changed)
418def create_policy(module, blade):
419 """Create a new TLS policy and optionally attach it to interfaces."""
420 changed = True
421 if not module.params["appliance_certificate"]:
422 module.fail_json(
423 msg=(
424 "appliance_certificate is required to create TLS policy {0} "
425 "(use 'global' for the built-in appliance certificate)."
426 ).format(module.params["name"])
427 )
428 if module.params["network_interfaces"]:
429 _validate_attachable(module, blade, module.params["network_interfaces"])
430 if not module.check_mode:
431 res = blade.post_tls_policies(
432 names=[module.params["name"]],
433 policy=TlsPolicyPost(**_build_post_kwargs(module)),
434 )
435 if res.status_code != 200:
436 module.fail_json(
437 msg="Failed to create TLS policy {0}. Error: {1}".format(
438 module.params["name"], get_error_message(res)
439 )
440 )
441 attached = []
442 for iface in module.params["network_interfaces"] or []:
443 res = blade.post_tls_policies_network_interfaces(
444 policy_names=[module.params["name"]],
445 member_names=[iface],
446 )
447 if res.status_code != 200:
448 note = " Already attached: {0}.".format(attached) if attached else ""
449 module.fail_json(
450 msg="Failed to attach TLS policy {0} to interface {1}. Error: {2}.{3}".format(
451 module.params["name"], iface, get_error_message(res), note
452 )
453 )
454 attached.append(iface)
455 module.exit_json(changed=changed)
458def update_policy(module, blade, policy):
459 """Update a TLS policy and reconcile its network-interface attachments."""
460 changed = False
461 patch_kwargs = {}
463 if module.params["enabled"] is not None and module.params["enabled"] != getattr(
464 policy, "enabled", None
465 ):
466 patch_kwargs["enabled"] = module.params["enabled"]
468 if module.params["min_tls_version"] and module.params["min_tls_version"] != getattr(
469 policy, "min_tls_version", None
470 ):
471 patch_kwargs["min_tls_version"] = module.params["min_tls_version"]
473 if module.params["client_certificates_required"] is not None and module.params[
474 "client_certificates_required"
475 ] != getattr(policy, "client_certificates_required", None):
476 patch_kwargs["client_certificates_required"] = module.params[
477 "client_certificates_required"
478 ]
480 if module.params["verify_client_certificate_trust"] is not None and module.params[
481 "verify_client_certificate_trust"
482 ] != getattr(policy, "verify_client_certificate_trust", None):
483 patch_kwargs["verify_client_certificate_trust"] = module.params[
484 "verify_client_certificate_trust"
485 ]
487 if module.params["enabled_tls_ciphers"] is not None:
488 current = list(getattr(policy, "enabled_tls_ciphers", None) or [])
489 if sorted(module.params["enabled_tls_ciphers"]) != sorted(current):
490 patch_kwargs["enabled_tls_ciphers"] = module.params["enabled_tls_ciphers"]
492 if module.params["disabled_tls_ciphers"] is not None:
493 current = list(getattr(policy, "disabled_tls_ciphers", None) or [])
494 if sorted(module.params["disabled_tls_ciphers"]) != sorted(current):
495 patch_kwargs["disabled_tls_ciphers"] = module.params["disabled_tls_ciphers"]
497 if module.params["appliance_certificate"]:
498 current = getattr(getattr(policy, "appliance_certificate", None), "name", None)
499 if module.params["appliance_certificate"] != current:
500 patch_kwargs["appliance_certificate"] = ReferenceWritable(
501 name=module.params["appliance_certificate"]
502 )
504 if module.params["trusted_client_certificate_authority"] is not None:
505 current = (
506 getattr(
507 getattr(policy, "trusted_client_certificate_authority", None),
508 "name",
509 None,
510 )
511 or ""
512 )
513 if module.params["trusted_client_certificate_authority"] != current:
514 patch_kwargs["trusted_client_certificate_authority"] = ReferenceWritable(
515 name=module.params["trusted_client_certificate_authority"]
516 )
518 if module.params["network_interfaces"] is not None:
519 if _reconcile_interfaces(module, blade):
520 changed = True
522 if patch_kwargs:
523 changed = True
524 if not module.check_mode:
525 res = blade.patch_tls_policies(
526 names=[module.params["name"]],
527 policy=TlsPolicy(**patch_kwargs),
528 )
529 if res.status_code != 200:
530 module.fail_json(
531 msg="Failed to update TLS policy {0}. Error: {1}".format(
532 module.params["name"], get_error_message(res)
533 )
534 )
536 module.exit_json(changed=changed)
539def main():
540 argument_spec = purefb_argument_spec()
541 argument_spec.update(
542 dict(
543 state=dict(type="str", default="present", choices=["absent", "present"]),
544 name=dict(type="str", required=True),
545 enabled=dict(type="bool"),
546 appliance_certificate=dict(type="str"),
547 client_certificates_required=dict(type="bool"),
548 trusted_client_certificate_authority=dict(type="str"),
549 verify_client_certificate_trust=dict(type="bool"),
550 min_tls_version=dict(
551 type="str",
552 choices=[
553 "default",
554 "1.0",
555 "1.1",
556 "1.2",
557 "1.3",
558 ],
559 ),
560 enabled_tls_ciphers=dict(type="list", elements="str"),
561 disabled_tls_ciphers=dict(type="list", elements="str"),
562 network_interfaces=dict(type="list", elements="str"),
563 )
564 )
566 module = AnsibleModule(
567 argument_spec,
568 required_if=[
569 (
570 "verify_client_certificate_trust",
571 True,
572 ["trusted_client_certificate_authority"],
573 ),
574 ],
575 supports_check_mode=True,
576 )
578 if not HAS_PYPURECLIENT:
579 module.fail_json(msg="py-pure-client sdk is required for this module")
581 blade = get_system(module)
582 api_version = get_rest_api_version(blade)
583 if LooseVersion(MIN_REQUIRED_API_VERSION) > LooseVersion(api_version):
584 module.fail_json(
585 msg="FlashBlade REST version not supported. "
586 "Minimum version required: {0}".format(MIN_REQUIRED_API_VERSION)
587 )
588 _check_mutual_tls_support(module, api_version)
590 state = module.params["state"]
591 policy = _get_policy(module, blade)
593 if state == "present" and policy is None:
594 create_policy(module, blade)
595 elif state == "present" and policy is not None:
596 update_policy(module, blade, policy)
597 elif state == "absent" and policy is not None:
598 delete_policy(module, blade)
600 module.exit_json(changed=False)
603if __name__ == "__main__":
604 main()