Remove Entrust modules and certificate providers (#900)

* Remove Entrust modules and certificate providers.

* Add more information on Entrust removal.

* Remove Entrust content from ignore.txt files.

* Work around bug in ansible-test.
This commit is contained in:
Felix Fontein
2025-05-22 21:08:48 +02:00
committed by GitHub
parent 41b71bb60c
commit 43ea6148df
25 changed files with 25 additions and 3119 deletions

View File

@@ -1,297 +0,0 @@
# Copyright (c) 2016-2017, Yanis Guenane <yanis+ansible@guenane.org>
# Copyright (c) 2017, Markus Teufelberger <mteufelberger+ansible@mgit.at>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later
# Note that this module util is **PRIVATE** to the collection. It can have breaking changes at any time.
# Do not use this from other collections or standalone plugins/modules!
from __future__ import annotations
import datetime
import os
import typing as t
from ansible.module_utils.common.text.converters import to_bytes, to_text
from ansible_collections.community.crypto.plugins.module_utils._crypto.cryptography_support import (
CRYPTOGRAPHY_TIMEZONE,
get_not_valid_after,
)
from ansible_collections.community.crypto.plugins.module_utils._crypto.module_backends.certificate import (
CertificateBackend,
CertificateError,
CertificateProvider,
)
from ansible_collections.community.crypto.plugins.module_utils._crypto.support import (
load_certificate,
)
from ansible_collections.community.crypto.plugins.module_utils._ecs.api import (
ECSClient,
RestOperationException,
SessionConfigurationException,
)
from ansible_collections.community.crypto.plugins.module_utils._time import (
get_now_datetime,
get_relative_time_option,
)
if t.TYPE_CHECKING:
from ansible.module_utils.basic import AnsibleModule
from ansible_collections.community.crypto.plugins.module_utils._argspec import (
ArgumentSpec,
)
try:
from cryptography.x509.oid import NameOID
except ImportError:
pass
class EntrustCertificateBackend(CertificateBackend):
def __init__(self, *, module: AnsibleModule) -> None:
super().__init__(module=module)
self.trackingId = None
self.notAfter = get_relative_time_option(
module.params["entrust_not_after"],
input_name="entrust_not_after",
with_timezone=CRYPTOGRAPHY_TIMEZONE,
)
self.cert_bytes: bytes | None = None
if self.csr_content is None:
if self.csr_path is None:
raise CertificateError(
"csr_path or csr_content is required for entrust provider"
)
if not os.path.exists(self.csr_path):
raise CertificateError(
f"The certificate signing request file {self.csr_path} does not exist"
)
self._ensure_csr_loaded()
if self.csr is None:
raise CertificateError("CSR not provided")
# ECS API defaults to using the validated organization tied to the account.
# We want to always force behavior of trying to use the organization provided in the CSR.
# To that end we need to parse out the organization from the CSR.
self.csr_org = None
csr_subject_orgs = self.csr.subject.get_attributes_for_oid(
NameOID.ORGANIZATION_NAME
)
if len(csr_subject_orgs) == 1:
self.csr_org = csr_subject_orgs[0].value
elif len(csr_subject_orgs) > 1:
self.module.fail_json(
msg=(
"Entrust provider does not currently support multiple validated organizations. Multiple organizations found in "
f"Subject DN: '{self.csr.subject}'. "
)
)
# If no organization in the CSR, explicitly tell ECS that it should be blank in issued cert, not defaulted to
# organization tied to the account.
if self.csr_org is None:
self.csr_org = ""
try:
self.ecs_client = ECSClient(
entrust_api_user=self.module.params["entrust_api_user"],
entrust_api_key=self.module.params["entrust_api_key"],
entrust_api_cert=self.module.params["entrust_api_client_cert_path"],
entrust_api_cert_key=self.module.params[
"entrust_api_client_cert_key_path"
],
entrust_api_specification_path=self.module.params[
"entrust_api_specification_path"
],
)
except SessionConfigurationException as e:
module.fail_json(msg=f"Failed to initialize Entrust Provider: {e}")
def generate_certificate(self) -> None:
"""(Re-)Generate certificate."""
body = {}
# Read the CSR that was generated for us
if self.csr_content is not None:
# csr_content contains bytes
body["csr"] = to_text(self.csr_content)
else:
assert self.csr_path is not None
with open(self.csr_path, "r", encoding="utf-8") as csr_file:
body["csr"] = csr_file.read()
body["certType"] = self.module.params["entrust_cert_type"]
# Handle expiration (30 days if not specified)
expiry = self.notAfter
if not expiry:
gmt_now = get_now_datetime(with_timezone=CRYPTOGRAPHY_TIMEZONE)
expiry = gmt_now + datetime.timedelta(days=365)
expiry_iso3339 = expiry.strftime("%Y-%m-%dT%H:%M:%S.00Z")
body["certExpiryDate"] = expiry_iso3339
body["org"] = self.csr_org
body["tracking"] = {
"requesterName": self.module.params["entrust_requester_name"],
"requesterEmail": self.module.params["entrust_requester_email"],
"requesterPhone": self.module.params["entrust_requester_phone"],
}
try:
result = self.ecs_client.NewCertRequest( # type: ignore[attr-defined] # pylint: disable=no-member
Body=body
)
self.trackingId = result.get("trackingId")
except RestOperationException as e:
self.module.fail_json(
msg=f"Failed to request new certificate from Entrust Certificate Services (ECS): {e.message}"
)
self.cert_bytes = to_bytes(result.get("endEntityCert"))
self.cert = load_certificate(
path=None,
content=self.cert_bytes,
)
def get_certificate_data(self) -> bytes:
"""Return bytes for self.cert."""
if self.cert_bytes is None:
raise AssertionError("Contract violation: cert_bytes not set")
return self.cert_bytes
def needs_regeneration(
self,
*,
not_before: datetime.datetime | None = None,
not_after: datetime.datetime | None = None,
) -> bool:
parent_check = super().needs_regeneration()
try:
cert_details = self._get_cert_details()
except RestOperationException as e:
self.module.fail_json(
msg=f"Failed to get status of existing certificate from Entrust Certificate Services (ECS): {e.message}."
)
# Always issue a new certificate if the certificate is expired, suspended or revoked
status = cert_details.get("status", False)
if status in ("EXPIRED", "SUSPENDED", "REVOKED"):
return True
# If the requested cert type was specified and it is for a different certificate type than the initial certificate, a new one is needed
if (
self.module.params["entrust_cert_type"]
and cert_details.get("certType")
and self.module.params["entrust_cert_type"] != cert_details.get("certType")
):
return True
return parent_check
def _get_cert_details(self) -> dict[str, t.Any]:
cert_details: dict[str, t.Any] = {}
try:
self._ensure_existing_certificate_loaded()
except Exception:
return cert_details
if self.existing_certificate:
serial_number = f"{self.existing_certificate.serial_number:X}"
expiry = get_not_valid_after(self.existing_certificate)
# get some information about the expiry of this certificate
expiry_iso3339 = expiry.strftime("%Y-%m-%dT%H:%M:%S.00Z")
cert_details["expiresAfter"] = expiry_iso3339
# If a trackingId is not already defined (from the result of a generate)
# use the serial number to identify the tracking Id
if self.trackingId is None and serial_number is not None:
cert_results = self.ecs_client.GetCertificates( # type: ignore[attr-defined] # pylint: disable=no-member
serialNumber=serial_number
).get(
"certificates", {}
)
# Finding 0 or more than 1 result is a very unlikely use case, it simply means we cannot perform additional checks
# on the 'state' as returned by Entrust Certificate Services (ECS). The general certificate validity is
# still checked as it is in the rest of the module.
if len(cert_results) == 1:
self.trackingId = cert_results[0].get("trackingId")
if self.trackingId is not None:
cert_details.update(
self.ecs_client.GetCertificate( # pylint: disable=no-member
trackingId=self.trackingId
)
)
return cert_details
class EntrustCertificateProvider(CertificateProvider):
def validate_module_args(self, module: AnsibleModule) -> None:
pass
def create_backend(self, module: AnsibleModule) -> EntrustCertificateBackend:
return EntrustCertificateBackend(module=module)
def add_entrust_provider_to_argument_spec(argument_spec: ArgumentSpec) -> None:
argument_spec.argument_spec["provider"]["choices"].append("entrust")
argument_spec.argument_spec.update(
{
"entrust_cert_type": {
"type": "str",
"default": "STANDARD_SSL",
"choices": [
"STANDARD_SSL",
"ADVANTAGE_SSL",
"UC_SSL",
"EV_SSL",
"WILDCARD_SSL",
"PRIVATE_SSL",
"PD_SSL",
"CDS_ENT_LITE",
"CDS_ENT_PRO",
"SMIME_ENT",
],
},
"entrust_requester_email": {"type": "str"},
"entrust_requester_name": {"type": "str"},
"entrust_requester_phone": {"type": "str"},
"entrust_api_user": {"type": "str"},
"entrust_api_key": {"type": "str", "no_log": True},
"entrust_api_client_cert_path": {"type": "path"},
"entrust_api_client_cert_key_path": {"type": "path", "no_log": True},
"entrust_api_specification_path": {
"type": "path",
"default": "https://cloud.entrust.net/EntrustCloud/documentation/cms-api-2.1.0.yaml",
},
"entrust_not_after": {"type": "str", "default": "+365d"},
}
)
argument_spec.required_if.append(
(
"provider",
"entrust",
[
"entrust_requester_email",
"entrust_requester_name",
"entrust_requester_phone",
"entrust_api_user",
"entrust_api_key",
"entrust_api_client_cert_path",
"entrust_api_client_cert_key_path",
],
)
)
__all__ = (
"EntrustCertificateBackend",
"EntrustCertificateProvider",
"add_entrust_provider_to_argument_spec",
)

View File

@@ -1,421 +0,0 @@
# This code is part of Ansible, but is an independent component.
# This particular file snippet, and this file snippet only, is licensed under the
# Modified BSD License. Modules you write using this snippet, which is embedded
# dynamically by Ansible, still belong to the author of the module, and may assign
# their own license to the complete work.
#
# Copyright (c), Entrust Datacard Corporation, 2019
# Simplified BSD License (see LICENSES/BSD-2-Clause.txt or https://opensource.org/licenses/BSD-2-Clause)
# SPDX-License-Identifier: BSD-2-Clause
# Note that this module util is **PRIVATE** to the collection. It can have breaking changes at any time.
# Do not use this from other collections or standalone plugins/modules!
from __future__ import annotations
import json
import os
import re
import traceback
import typing as t
from urllib.error import HTTPError
from urllib.parse import urlencode
from ansible.module_utils.basic import missing_required_lib
from ansible.module_utils.common.text.converters import to_text
from ansible.module_utils.urls import Request
if t.TYPE_CHECKING:
_P = t.ParamSpec("_P")
YAML_IMP_ERR = None
try:
import yaml
except ImportError:
YAML_FOUND = False
YAML_IMP_ERR = traceback.format_exc()
else:
YAML_FOUND = True
valid_file_format = re.compile(r".*(\.)(yml|yaml|json)$")
def ecs_client_argument_spec() -> dict[str, t.Any]:
return {
"entrust_api_user": {"type": "str", "required": True},
"entrust_api_key": {"type": "str", "required": True, "no_log": True},
"entrust_api_client_cert_path": {"type": "path", "required": True},
"entrust_api_client_cert_key_path": {
"type": "path",
"required": True,
"no_log": True,
},
"entrust_api_specification_path": {
"type": "path",
"default": "https://cloud.entrust.net/EntrustCloud/documentation/cms-api-2.1.0.yaml",
},
}
class SessionConfigurationException(Exception):
"""Raised if we cannot configure a session with the API"""
class RestOperationException(Exception):
"""Encapsulate a REST API error"""
def __init__(self, error: dict[str, t.Any]) -> None:
self.status = to_text(error.get("status", None))
self.errors = [to_text(err.get("message")) for err in error.get("errors", {})]
self.message = " ".join(self.errors)
def generate_docstring(operation_spec: dict[str, t.Any]) -> str:
"""Generate a docstring for an operation defined in operation_spec (swagger)"""
# Description of the operation
docs = operation_spec.get("description", "No Description")
docs += "\n\n"
# Parameters of the operation
parameters = operation_spec.get("parameters", [])
if len(parameters) != 0:
docs += "\tArguments:\n\n"
for parameter in parameters:
req = "Required" if parameter.get("required", False) else "Not Required"
docs += f"{parameter.get('name')} ({parameter.get('type', 'No Type')}:{req}): {parameter.get('description')}\n"
return docs
_T = t.TypeVar("_T")
_R = t.TypeVar("_R")
def bind(
instance: _T,
method: t.Callable[t.Concatenate[_T, _P], _R],
operation_spec: dict[str, str],
) -> t.Callable[_P, _R]:
def binding_scope_fn(*args, **kwargs) -> _R:
return method(instance, *args, **kwargs)
# Make sure we do not confuse users; add the proper name and documentation to the function.
# Users can use !help(<function>) to get help on the function from interactive python or pdb
operation_name = operation_spec["operationId"].split("Using")[0]
binding_scope_fn.__name__ = str(operation_name)
binding_scope_fn.__doc__ = generate_docstring(operation_spec)
return binding_scope_fn
class RestOperation:
def __init__(
self,
session: "ECSSession",
uri: str,
method: str,
parameters: dict | None = None,
) -> None:
self.session = session
self.method = method
if parameters is None:
self.parameters = {}
else:
self.parameters = parameters
self.url = (
f"https://{session._spec.get('host')}{session._spec.get('basePath')}{uri}"
)
def restmethod(self, *args, **kwargs) -> t.Any:
"""Do the hard work of making the request here"""
# gather named path parameters and do substitution on the URL
body_parameters: dict[str, t.Any] | None
if self.parameters:
path_parameters = {}
body_parameters = {}
query_parameters = {}
for x in self.parameters:
expected_location = x.get("in")
key_name = x.get("name", None)
key_value = kwargs.get(key_name, None)
if expected_location == "path" and key_name and key_value:
path_parameters.update({key_name: key_value})
elif expected_location == "body" and key_name and key_value:
body_parameters.update({key_name: key_value})
elif expected_location == "query" and key_name and key_value:
query_parameters.update({key_name: key_value})
if len(body_parameters.keys()) >= 1:
body_parameters = body_parameters.get(list(body_parameters.keys())[0])
else:
body_parameters = None
else:
path_parameters = {}
query_parameters = {}
body_parameters = None
# This will fail if we have not set path parameters with a KeyError
url = self.url.format(**path_parameters)
if query_parameters:
# modify the URL to add path parameters
url = url + "?" + urlencode(query_parameters)
try:
if body_parameters:
body_parameters_json = json.dumps(body_parameters)
response = self.session.request.open(
method=self.method, url=url, data=body_parameters_json
)
else:
response = self.session.request.open(method=self.method, url=url)
except HTTPError as e:
# An HTTPError has the same methods available as a valid response from request.open
response = e
# Return the result if JSON and success ({} for empty responses)
# Raise an exception if there was a failure.
try:
result_code = response.getcode()
result = json.loads(response.read())
except ValueError:
result = {}
if result or result == {}:
if result_code and result_code < 400:
return result
raise RestOperationException(result)
# Raise a generic RestOperationException if this fails
raise RestOperationException(
{"status": result_code, "errors": [{"message": "REST Operation Failed"}]}
)
class Resource:
"""Implement basic CRUD operations against a path."""
def __init__(self, session: "ECSSession") -> None:
self.session = session
self.parameters: dict[str, t.Any] = {}
for url in session._spec.get("paths").keys():
methods = session._spec.get("paths").get(url)
for method in methods.keys():
operation_spec = methods.get(method)
operation_name = operation_spec.get("operationId", None)
parameters = operation_spec.get("parameters")
if not operation_name:
if method.lower() == "post":
operation_name = "Create"
elif method.lower() == "get":
operation_name = "Get"
elif method.lower() == "put":
operation_name = "Update"
elif method.lower() == "delete":
operation_name = "Delete"
elif method.lower() == "patch":
operation_name = "Patch"
else:
raise SessionConfigurationException(
f"Invalid REST method type {method}"
)
# Get the non-parameter parts of the URL and append to the operation name
# e.g /application/version -> GetApplicationVersion
# e.g. /application/{id} -> GetApplication
# This may lead to duplicates, which we must prevent.
operation_name += (
re.sub(r"{(.*)}", "", url)
.replace("/", " ")
.title()
.replace(" ", "")
)
operation_spec["operationId"] = operation_name
op = RestOperation(session, url, method, parameters)
setattr(self, operation_name, bind(self, op.restmethod, operation_spec))
# Session to encapsulate the connection parameters of the module_utils Request object, the api spec, etc
class ECSSession:
def __init__(self, name: str, **kwargs) -> None:
"""
Initialize our session
"""
self._set_config(name, **kwargs)
def client(self) -> Resource:
resource = Resource(self)
return resource
def _set_config(self, name: str, **kwargs) -> None:
headers = {
"Content-Type": "application/json",
"Connection": "keep-alive",
}
self.request = Request(headers=headers, timeout=60)
configurators = [self._read_config_vars]
for configurator in configurators:
self._config = configurator(name, **kwargs)
if self._config:
break
if self._config is None:
raise SessionConfigurationException("No Configuration Found.")
# set up auth if passed
entrust_api_user: str | None = self.get_config("entrust_api_user")
entrust_api_key: str | None = self.get_config("entrust_api_key")
if entrust_api_user and entrust_api_key:
self.request.url_username = entrust_api_user
self.request.url_password = entrust_api_key
else:
raise SessionConfigurationException("User and key must be provided.")
# set up client certificate if passed (support all-in one or cert + key)
entrust_api_cert: str | None = self.get_config("entrust_api_cert")
entrust_api_cert_key: str | None = self.get_config("entrust_api_cert_key")
if entrust_api_cert:
self.request.client_cert = entrust_api_cert
if entrust_api_cert_key:
self.request.client_key = entrust_api_cert_key
else:
raise SessionConfigurationException(
"Client certificate for authentication to the API must be provided."
)
# set up the spec
entrust_api_specification_path = self.get_config(
"entrust_api_specification_path"
)
if not isinstance(entrust_api_specification_path, str):
raise SessionConfigurationException(
"entrust_api_specification_path must be a string."
)
if not entrust_api_specification_path.startswith("http") and not os.path.isfile(
entrust_api_specification_path
):
raise SessionConfigurationException(
f"OpenAPI specification was not found at location {entrust_api_specification_path}."
)
if not valid_file_format.match(entrust_api_specification_path):
raise SessionConfigurationException(
"OpenAPI specification filename must end in .json, .yml or .yaml"
)
self.verify = True
if entrust_api_specification_path.startswith("http"):
try:
http_response = Request().open(
method="GET", url=entrust_api_specification_path
)
http_response_contents = http_response.read()
if entrust_api_specification_path.endswith(".json"):
self._spec = json.load(http_response_contents)
elif entrust_api_specification_path.endswith(
".yml"
) or entrust_api_specification_path.endswith(".yaml"):
self._spec = yaml.safe_load(http_response_contents)
except HTTPError as e:
raise SessionConfigurationException(
f"Error downloading specification from address '{entrust_api_specification_path}', received error code '{e.getcode()}'"
) from e
else:
with open(entrust_api_specification_path, "rb") as f:
if ".json" in entrust_api_specification_path:
self._spec = json.load(f)
elif (
".yml" in entrust_api_specification_path
or ".yaml" in entrust_api_specification_path
):
self._spec = yaml.safe_load(f)
def get_config(self, item: str) -> t.Any | None:
return self._config.get(item, None)
def _read_config_vars(self, name: str, **kwargs) -> dict[str, t.Any]:
"""Read configuration from variables passed to the module."""
config = {}
entrust_api_specification_path = kwargs.get("entrust_api_specification_path")
if not entrust_api_specification_path or (
not entrust_api_specification_path.startswith("http")
and not os.path.isfile(entrust_api_specification_path)
):
raise SessionConfigurationException(
f"Parameter provided for entrust_api_specification_path of value '{entrust_api_specification_path}'"
" was not a valid file path or HTTPS address."
)
for required_file in ["entrust_api_cert", "entrust_api_cert_key"]:
file_path = kwargs.get(required_file)
if not file_path or not os.path.isfile(file_path):
raise SessionConfigurationException(
f"Parameter provided for {required_file} of value '{file_path}' was not a valid file path."
)
for required_var in ["entrust_api_user", "entrust_api_key"]:
if not kwargs.get(required_var):
raise SessionConfigurationException(
f"Parameter provided for {required_var} was missing."
)
config["entrust_api_cert"] = kwargs.get("entrust_api_cert")
config["entrust_api_cert_key"] = kwargs.get("entrust_api_cert_key")
config["entrust_api_specification_path"] = kwargs.get(
"entrust_api_specification_path"
)
config["entrust_api_user"] = kwargs.get("entrust_api_user")
config["entrust_api_key"] = kwargs.get("entrust_api_key")
return config
def ECSClient(
entrust_api_user: str | None = None,
entrust_api_key: str | None = None,
entrust_api_cert: str | None = None,
entrust_api_cert_key: str | None = None,
entrust_api_specification_path: str | None = None,
) -> Resource:
"""Create an ECS client"""
if not YAML_FOUND:
raise SessionConfigurationException(
missing_required_lib("PyYAML") # TODO: pass `exception=YAML_IMP_ERR`
)
if entrust_api_specification_path is None:
entrust_api_specification_path = (
"https://cloud.entrust.net/EntrustCloud/documentation/cms-api-2.1.0.yaml"
)
# Not functionally necessary with current uses of this module_util, but better to be explicit for future use cases
entrust_api_user = to_text(entrust_api_user)
entrust_api_key = to_text(entrust_api_key)
entrust_api_cert_key = to_text(entrust_api_cert_key)
entrust_api_specification_path = to_text(entrust_api_specification_path)
return ECSSession(
"ecs",
entrust_api_user=entrust_api_user,
entrust_api_key=entrust_api_key,
entrust_api_cert=entrust_api_cert,
entrust_api_cert_key=entrust_api_cert_key,
entrust_api_specification_path=entrust_api_specification_path,
).client()
__all__ = (
"ecs_client_argument_spec",
"SessionConfigurationException",
"RestOperationException",
"ECSClient",
)