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

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_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""" 

117 

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 

126 

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 

135 

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 

144 

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 

151 

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 

158 

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 

166 

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""" 

174 

175RETURN = r""" 

176""" 

177 

178HAS_PYPURECLIENT = True 

179try: 

180 from pypureclient.flashblade import ( 

181 TlsPolicy, 

182 TlsPolicyPost, 

183 ReferenceWritable, 

184 ) 

185except ImportError: 

186 HAS_PYPURECLIENT = False 

187 

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) 

200 

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) 

208 

209 

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. 

212 

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 ) 

225 

226 

227def _validate_attachable(module, blade, interfaces): 

228 """Validate every interface in C(interfaces) exists and isn't bound to another TLS policy. 

229 

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)) 

248 

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 ) 

270 

271 

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 

281 

282 

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) 

297 

298 

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 

327 

328 

329def _reconcile_interfaces(module, blade): 

330 """Reconcile policy network-interface attachments to the desired set. 

331 

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] 

338 

339 if not (to_attach or to_detach): 

340 return False 

341 

342 if to_attach: 

343 _validate_attachable(module, blade, to_attach) 

344 

345 if module.check_mode: 

346 return True 

347 

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) 

362 

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) 

377 

378 return True 

379 

380 

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) 

416 

417 

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) 

456 

457 

458def update_policy(module, blade, policy): 

459 """Update a TLS policy and reconcile its network-interface attachments.""" 

460 changed = False 

461 patch_kwargs = {} 

462 

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"] 

467 

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"] 

472 

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 ] 

479 

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 ] 

486 

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"] 

491 

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"] 

496 

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 ) 

503 

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 ) 

517 

518 if module.params["network_interfaces"] is not None: 

519 if _reconcile_interfaces(module, blade): 

520 changed = True 

521 

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 ) 

535 

536 module.exit_json(changed=changed) 

537 

538 

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 ) 

565 

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 ) 

577 

578 if not HAS_PYPURECLIENT: 

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

580 

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) 

589 

590 state = module.params["state"] 

591 policy = _get_policy(module, blade) 

592 

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) 

599 

600 module.exit_json(changed=False) 

601 

602 

603if __name__ == "__main__": 

604 main()