From 86366530e8e96312ed8cfd33d86a3c55a7ac9667 Mon Sep 17 00:00:00 2001 From: Chris Trufan <31186388+ctrufan@users.noreply.github.com> Date: Sat, 17 Aug 2019 15:32:02 -0400 Subject: [PATCH] Entrust Datacard - Support for "entrust" provider in openssl_certificate module (#59272) * Addition of entrust provider to openssl_certificate module * Fix native return values of error messages and JSON response. * Documentation and syntax fixes per ansibot. * Refactored structure of for loop due to ansible test failures in python 2.6 * Remove OCSP functionality for inclusion in possible seperate future pull request. * Remove reissue support. * Indicate the entrust parameters are specific to entrust. * Comment fixes to make it clear module_utils request is used. * Fixes to not_after documentation * Response to pull request comments and cleanup of error handling for bad connections to properly use the 'six' HttpError for compatibility with both Python 2/3 underlying url libraries. * pep8/pycodestyle fixes. * Added code fragment and response to comments. * Update license to simplified BSD * Fixed botmeta typo * Include license text in api.yml * Remove unsupported certificate types, and always submit an explicit organization to match organization in CSR * Fix documentation misquote, add expired to a comment, and fix path check timing. * Update changelogs/fragments/59272-support-for-entrust-provider-in-openssl_certificate_module.yaml Co-Authored-By: Felix Fontein --- .github/BOTMETA.yml | 3 + ...rovider-in-openssl_certificate_module.yaml | 2 + lib/ansible/module_utils/ecs/__init__.py | 0 lib/ansible/module_utils/ecs/api.py | 351 ++++++++++++++++++ .../modules/crypto/openssl_certificate.py | 303 ++++++++++++++- 5 files changed, 655 insertions(+), 4 deletions(-) create mode 100644 changelogs/fragments/59272-support-for-entrust-provider-in-openssl_certificate_module.yaml create mode 100644 lib/ansible/module_utils/ecs/__init__.py create mode 100644 lib/ansible/module_utils/ecs/api.py diff --git a/.github/BOTMETA.yml b/.github/BOTMETA.yml index c44a8970ea4..7a3da168e07 100644 --- a/.github/BOTMETA.yml +++ b/.github/BOTMETA.yml @@ -216,6 +216,8 @@ files: labels: crypto maintainers: $team_crypto supershipit: felixfontein + $modules/crypto/openssl_certificate.py: + maintainers: ctrufan $modules/database/influxdb/: kamsz $modules/database/mssql/mssql_db.py: Jmainguy kenichi-ogawa-1988 $modules/database/mysql/: &mysql @@ -668,6 +670,7 @@ files: labels: - aws - cloud + maintainers: ctrufan $team_crypto $module_utils/facts: support: core $module_utils/facts/hardware/aix.py: *aix diff --git a/changelogs/fragments/59272-support-for-entrust-provider-in-openssl_certificate_module.yaml b/changelogs/fragments/59272-support-for-entrust-provider-in-openssl_certificate_module.yaml new file mode 100644 index 00000000000..98e25b88d0c --- /dev/null +++ b/changelogs/fragments/59272-support-for-entrust-provider-in-openssl_certificate_module.yaml @@ -0,0 +1,2 @@ +minor_changes: + - openssl_certificate - Add support for a new provider ``entrust`` (https://github.com/ansible/ansible/pull/59272). diff --git a/lib/ansible/module_utils/ecs/__init__.py b/lib/ansible/module_utils/ecs/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/lib/ansible/module_utils/ecs/api.py b/lib/ansible/module_utils/ecs/api.py new file mode 100644 index 00000000000..22290469894 --- /dev/null +++ b/lib/ansible/module_utils/ecs/api.py @@ -0,0 +1,351 @@ +# -*- coding: utf-8 -*- + +# 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/simplified_bsd.txt or https://opensource.org/licenses/BSD-2-Clause) + +# Redistribution and use in source and binary forms, with or without modification, +# are permitted provided that the following conditions are met: +# 1. Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# 2. Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. +# IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE +# USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +import json +import os +import re +import time +import traceback + +from ansible.module_utils._text import to_text, to_native +from ansible.module_utils.basic import missing_required_lib +from ansible.module_utils.six.moves.urllib.parse import urlencode +from ansible.module_utils.six.moves.urllib.error import HTTPError +from ansible.module_utils.urls import Request + +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)$") + + +class SessionConfigurationException(Exception): + """ Raised if we cannot configure a session with the API """ + + pass + + +class RestOperationException(Exception): + """ Encapsulate a REST API error """ + + def __init__(self, error): + self.status = to_native(error.get("status", None)) + self.errors = [to_native(err.get("message")) for err in error.get("errors", {})] + self.message = to_native(" ".join(self.errors)) + + +def generate_docstring(operation_spec): + """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: + docs += "{0} ({1}:{2}): {3}\n".format( + parameter.get("name"), + parameter.get("type", "No Type"), + "Required" if parameter.get("required", False) else "Not Required", + parameter.get("description"), + ) + + return docs + + +def bind(instance, method, operation_spec): + def binding_scope_fn(*args, **kwargs): + return method(instance, *args, **kwargs) + + # Make sure we don't confuse users; add the proper name and documentation to the function. + # Users can use !help() to get help on the function from interactive python or pdb + operation_name = operation_spec.get("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(object): + def __init__(self, session, uri, method, parameters=None): + self.session = session + self.method = method + if parameters is None: + self.parameters = {} + else: + self.parameters = parameters + self.url = "{scheme}://{host}{base_path}{uri}".format(scheme="https", host=session._spec.get("host"), base_path=session._spec.get("basePath"), uri=uri) + + def restmethod(self, *args, **kwargs): + """Do the hard work of making the request here""" + + # gather named path parameters and do substitution on the URL + 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) + request_error = False + except HTTPError as e: + # An HTTPError has the same methods available as a valid response from request.open + response = e + request_error = True + + # 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 + else: + raise RestOperationException(result) + + # Raise a generic RestOperationException if this fails + raise RestOperationException({"status": result_code, "errors": [{"message": "REST Operation Failed"}]}) + + +class Resource(object): + """ Implement basic CRUD operations against a path. """ + + def __init__(self, session): + self.session = session + self.parameters = {} + + 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(to_native("Invalid REST method type {0}".format(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(object): + def __init__(self, name, **kwargs): + """ + Initialize our session + """ + + self._set_config(name, **kwargs) + + def client(self): + resource = Resource(self) + return resource + + def _set_config(self, name, **kwargs): + headers = {"Content-Type": "application/json"} + 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(to_native("No Configuration Found.")) + + # set up auth if passed + entrust_api_user = self.get_config("entrust_api_user") + entrust_api_key = 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(to_native("User and key must be provided.")) + + # set up client certificate if passed (support all-in one or cert + key) + entrust_api_cert = self.get_config("entrust_api_cert") + entrust_api_cert_key = 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(to_native("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 entrust_api_specification_path.startswith("http") and not os.path.isfile(entrust_api_specification_path): + raise SessionConfigurationException(to_native("OpenAPI specification was not found at location {0}.".format(entrust_api_specification_path))) + if not valid_file_format.match(entrust_api_specification_path): + raise SessionConfigurationException(to_native("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(to_native("Error downloading specification from address '{0}', received error code '{1}'".format( + entrust_api_specification_path, e.getcode()))) + else: + with open(entrust_api_specification_path) 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): + return self._config.get(item, None) + + def _read_config_vars(self, name, **kwargs): + """ 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( + to_native( + "Parameter provided for entrust_api_specification_path of value '{0}' was not a valid file path or HTTPS address.".format( + entrust_api_specification_path + ) + ) + ) + + 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( + to_native("Parameter provided for {0} of value '{1}' was not a valid file path.".format(required_file, file_path)) + ) + + for required_var in ["entrust_api_user", "entrust_api_key"]: + if not kwargs.get(required_var): + raise SessionConfigurationException(to_native("Parameter provided for {0} was missing.".format(required_var))) + + 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=None, entrust_api_key=None, entrust_api_cert=None, entrust_api_cert_key=None, entrust_api_specification_path=None): + """Create an ECS client""" + + if not YAML_FOUND: + raise SessionConfigurationException(missing_required_lib("PyYAML"), 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() diff --git a/lib/ansible/modules/crypto/openssl_certificate.py b/lib/ansible/modules/crypto/openssl_certificate.py index bfa01694e9d..827842502e3 100644 --- a/lib/ansible/modules/crypto/openssl_certificate.py +++ b/lib/ansible/modules/crypto/openssl_certificate.py @@ -19,7 +19,7 @@ version_added: "2.4" short_description: Generate and/or check OpenSSL certificates description: - This module allows one to (re)generate OpenSSL certificates. - - It implements a notion of provider (ie. C(selfsigned), C(ownca), C(acme), C(assertonly)) + - It implements a notion of provider (ie. C(selfsigned), C(ownca), C(acme), C(assertonly), C(entrust)) for your certificate. - The C(assertonly) provider is intended for use cases where one is only interested in checking properties of a supplied certificate. @@ -58,9 +58,11 @@ options: description: - Name of the provider to use to generate/retrieve the OpenSSL certificate. - The C(assertonly) provider will not generate files and fail if the certificate file is missing. + - "The C(entrust) provider was added for Ansible 2.9 and requires credentials for the + L(https://www.entrustdatacard.com/products/categories/ssl-certificates,Entrust Certificate Services) (ECS) API." type: str required: true - choices: [ acme, assertonly, ownca, selfsigned ] + choices: [ acme, assertonly, entrust, ownca, selfsigned ] force: description: @@ -298,7 +300,6 @@ options: type: str aliases: [ notAfter ] - valid_in: description: - The certificate must still be valid at this relative time offset from now. @@ -373,6 +374,97 @@ options: default: no version_added: "2.8" + entrust_cert_type: + description: + - The type of certificate product to request. + - This is only used by the C(entrust) provider. + 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' ] + version_added: "2.9" + + entrust_requester_email: + description: + - The email of the requester of the certificate (for tracking purposes). + - This is only used by the C(entrust) provider. + - This is required if the provider is C(entrust). + type: str + version_added: "2.9" + + entrust_requester_name: + description: + - The name of the requester of the certificate (for tracking purposes). + - This is only used by the C(entrust) provider. + - This is required if the provider is C(entrust). + type: str + version_added: "2.9" + + entrust_requester_phone: + description: + - The phone number of the requester of the certificate (for tracking purposes). + - This is only used by the C(entrust) provider. + - This is required if the provider is C(entrust). + type: str + version_added: "2.9" + + entrust_api_user: + description: + - The username for authentication to the Entrust Certificate Services (ECS) API. + - This is only used by the C(entrust) provider. + - This is required if the provider is C(entrust). + type: str + version_added: "2.9" + + entrust_api_key: + description: + - The key (password) for authentication to the Entrust Certificate Services (ECS) API. + - This is only used by the C(entrust) provider. + - This is required if the provider is C(entrust). + type: str + version_added: "2.9" + + entrust_api_client_cert_path: + description: + - The path of the client certificate used to authenticate to the Entrust Certificate Services (ECS) API. + - This is only used by the C(entrust) provider. + - This is required if the provider is C(entrust). + type: path + version_added: "2.9" + + entrust_api_client_cert_key_path: + description: + - The path of the key for the client certificate used to authenticate to the Entrust Certificate Services (ECS) API. + - This is only used by the C(entrust) provider. + - This is required if the provider is C(entrust). + type: path + version_added: "2.9" + + entrust_not_after: + description: + - The point in time at which the certificate stops being valid. + - Time can be specified either as relative time or as absolute timestamp. + - Time will always be interpreted as UTC. + - Note that only the date (day, month, year) is supported for specifying expiry date of the issued certificate. + - The full date-time is adjusted to EST (GMT -5:00) before issuance, which may result in a certificate with an expiration date one day + earlier than expected if a relative time is used. + - The minimum certificate lifetime is 90 days, and maximum is three years. + - Valid format is C([+-]timespec | ASN.1 TIME) where timespec can be an integer + + C([w | d | h | m | s]) (e.g. C(+32w1d2h). + - If this value is not specified, the certificate will stop being valid 365 days from now. + - This is only used by the C(entrust) provider. + type: str + default: +365d + version_added: "2.9" + + entrust_api_specification_path: + description: + - Path to the specification file defining the Entrust Certificate Services (ECS) API. + - Can be used to keep a local copy of the specification to avoid downloading it every time the module is used. + - This is only used by the C(entrust) provider. + type: path + default: https://cloud.entrust.net/EntrustCloud/documentation/cms-api-2.1.0.yaml + version_added: "2.9" + extends_documentation_fragment: files notes: - All ASN.1 TIME values should be specified following the YYYYMMDDHHMMSSZ pattern. @@ -421,6 +513,21 @@ EXAMPLES = r''' acme_challenge_path: /etc/ssl/challenges/ansible.com/ force: yes +- name: Generate an Entrust certificate via the Entrust Certificate Services (ECS) API + openssl_certificate: + path: /etc/ssl/crt/ansible.com.crt + csr_path: /etc/ssl/csr/ansible.com.csr + provider: entrust + entrust_requester_name: Jo Doe + entrust_requester_email: jdoe@ansible.com + entrust_requester_phone: 555-555-5555 + entrust_cert_type: STANDARD_SSL + entrust_api_user: apiusername + entrust_api_key: a^lv*32!cd9LnT + entrust_api_client_cert_path: /etc/ssl/entrust/ecs-client.crt + entrust_api_client_cert_key_path: /etc/ssl/entrust/ecs-key.crt + entrust_api_specification_path: /etc/ssl/entrust/api-docs/cms-api-2.1.0.yaml + # Examples for some checks one could use the assertonly provider for: # How to use the assertonly provider to implement and trigger your own custom certificate generation workflow: @@ -535,6 +642,7 @@ backup_file: from random import randint import abc import datetime +import time import os import traceback from distutils.version import LooseVersion @@ -543,6 +651,7 @@ from ansible.module_utils import crypto as crypto_utils from ansible.module_utils.basic import AnsibleModule, missing_required_lib from ansible.module_utils._text import to_native, to_bytes, to_text from ansible.module_utils.compat import ipaddress as compat_ipaddress +from ansible.module_utils.ecs.api import ECSClient, RestOperationException, SessionConfigurationException MINIMAL_CRYPTOGRAPHY_VERSION = '1.6' MINIMAL_PYOPENSSL_VERSION = '0.15' @@ -564,7 +673,9 @@ try: from cryptography import x509 from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives.serialization import Encoding + from cryptography.hazmat.primitives.hashes import SHA1 from cryptography.x509 import NameAttribute, Name + from cryptography.x509.oid import NameOID CRYPTOGRAPHY_VERSION = LooseVersion(cryptography.__version__) except ImportError: CRYPTOGRAPHY_IMP_ERR = traceback.format_exc() @@ -1701,6 +1812,167 @@ class AssertOnlyCertificate(AssertOnlyCertificateBase): return self.cert.get_notBefore(), valid_in_date, self.cert.get_notAfter() +class EntrustCertificate(Certificate): + """Retrieve a certificate using Entrust (ECS).""" + + def __init__(self, module, backend): + super(EntrustCertificate, self).__init__(module, backend) + self.trackingId = None + self.notAfter = self.get_relative_time_option(module.params['entrust_not_after'], 'entrust_not_after') + + if not os.path.exists(self.csr_path): + raise CertificateError( + 'The certificate signing request file {0} does not exist'.format(self.csr_path) + ) + + self.csr = crypto_utils.load_certificate_request(self.csr_path, backend=self.backend) + + # 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 + if self.backend == 'pyopenssl': + csr_subject = self.csr.get_subject() + csr_subject_components = csr_subject.get_components() + for k, v in csr_subject_components: + if k.upper() == 'O': + # Entrust does not support multiple validated organizations in a single certificate + if self.csr_org is not None: + module.fail_json(msg=("Entrust provider does not currently support multiple validated organizations. Multiple organizations found in " + "Subject DN: '{0}'. ".format(csr_subject))) + else: + self.csr_org = v + elif self.backend == 'cryptography': + 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: + module.fail_json(msg=("Entrust provider does not currently support multiple validated organizations. Multiple organizations found in " + "Subject DN: '{0}'. ".format(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=module.params.get('entrust_api_user'), + entrust_api_key=module.params.get('entrust_api_key'), + entrust_api_cert=module.params.get('entrust_api_client_cert_path'), + entrust_api_cert_key=module.params.get('entrust_api_client_cert_key_path'), + entrust_api_specification_path=module.params.get('entrust_api_specification_path') + ) + except SessionConfigurationException as e: + module.fail_json(msg='Failed to initialize Entrust Provider: {0}'.format(to_native(e.message))) + + def generate(self, module): + + if not self.check(module, perms_required=False) or self.force: + # Read the CSR that was generated for us + body = {} + with open(self.csr_path, 'r') as csr_file: + body['csr'] = csr_file.read() + + body['certType'] = module.params['entrust_cert_type'] + + # Handle expiration (30 days if not specified) + expiry = self.notAfter + if not expiry: + gmt_now = datetime.datetime.fromtimestamp(time.mktime(time.gmtime())) + 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': module.params['entrust_requester_name'], + 'requesterEmail': module.params['entrust_requester_email'], + 'requesterPhone': module.params['entrust_requester_phone'], + } + + try: + result = self.ecs_client.NewCertRequest(Body=body) + self.trackingId = result.get('trackingId') + except RestOperationException as e: + module.fail_json(msg='Failed to request new certificate from Entrust Certificate Services (ECS): {0}'.format(to_native(e.message))) + + if self.backup: + self.backup_file = module.backup_local(self.path) + crypto_utils.write_file(module, to_bytes(result.get('endEntityCert'))) + self.cert = crypto_utils.load_certificate(self.path, backend=self.backend) + self.changed = True + + def check(self, module, perms_required=True): + """Ensure the resource is in its desired state.""" + + parent_check = super(EntrustCertificate, self).check(module, perms_required) + + try: + cert_details = self._get_cert_details() + except RestOperationException as e: + module.fail_json(msg='Failed to get status of existing certificate from Entrust Certificate Services (ECS): {0}.'.format(to_native(e.message))) + + # Always issue a new certificate if the certificate is expired, suspended or revoked + status = cert_details.get('status', False) + if status == 'EXPIRED' or status == 'SUSPENDED' or status == 'REVOKED': + return False + + # 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 module.params['entrust_cert_type'] and cert_details.get('certType') and module.params['entrust_cert_type'] != cert_details.get('certType'): + return False + + return parent_check + + def _get_cert_details(self): + cert_details = {} + if self.cert: + serial_number = None + expiry = None + if self.backend == 'pyopenssl': + serial_number = "{0:X}".format(self.cert.get_serial_number()) + time_string = to_native(self.cert.get_notAfter()) + expiry = datetime.datetime.strptime(time_string, "%Y%m%d%H%M%SZ") + elif self.backend == 'cryptography': + serial_number = "{0:X}".format(self.cert.serial_number) + expiry = self.cert.not_valid_after + + # 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(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(trackingId=self.trackingId)) + + return cert_details + + def dump(self, check_mode=False): + + result = { + 'changed': self.changed, + 'filename': self.path, + 'privatekey': self.privatekey_path, + 'csr': self.csr_path, + } + + if self.backup_file: + result['backup_file'] = self.backup_file + + result.update(self._get_cert_details()) + + return result + + class AcmeCertificate(Certificate): """Retrieve a certificate using the ACME protocol.""" @@ -1777,7 +2049,7 @@ def main(): argument_spec=dict( state=dict(type='str', default='present', choices=['present', 'absent']), path=dict(type='path', required=True), - provider=dict(type='str', choices=['acme', 'assertonly', 'ownca', 'selfsigned']), + provider=dict(type='str', choices=['acme', 'assertonly', 'entrust', 'ownca', 'selfsigned']), force=dict(type='bool', default=False,), csr_path=dict(type='path'), backup=dict(type='bool', default=False), @@ -1826,9 +2098,28 @@ def main(): acme_accountkey_path=dict(type='path'), acme_challenge_path=dict(type='path'), acme_chain=dict(type='bool', default=False), + + # provider: entrust + entrust_cert_type=dict(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=dict(type='str'), + entrust_requester_name=dict(type='str'), + entrust_requester_phone=dict(type='str'), + entrust_api_user=dict(type='str'), + entrust_api_key=dict(type='str', no_log=True), + entrust_api_client_cert_path=dict(type='path'), + entrust_api_client_cert_key_path=dict(type='path', no_log=True), + entrust_api_specification_path=dict(type='path', default='https://cloud.entrust.net/EntrustCloud/documentation/cms-api-2.1.0.yaml'), + entrust_not_after=dict(type='str', default='+365d'), ), supports_check_mode=True, add_file_common_args=True, + required_if=[ + ['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']] + ] ) try: @@ -1887,6 +2178,8 @@ def main(): certificate = AcmeCertificate(module, 'pyopenssl') elif provider == 'ownca': certificate = OwnCACertificate(module) + elif provider == 'entrust': + certificate = EntrustCertificate(module, 'pyopenssl') else: certificate = AssertOnlyCertificate(module) elif backend == 'cryptography': @@ -1902,6 +2195,8 @@ def main(): certificate = AcmeCertificate(module, 'cryptography') elif provider == 'ownca': certificate = OwnCACertificateCryptography(module) + elif provider == 'entrust': + certificate = EntrustCertificate(module, 'cryptography') else: certificate = AssertOnlyCertificateCryptography(module)