From 60f4f488139ab6366dd5af2eb6a504b5e3bd4262 Mon Sep 17 00:00:00 2001 From: Oleg Dopertchouk Date: Mon, 1 Apr 2024 15:18:32 -0400 Subject: [PATCH 01/11] initial checkin --- lemur/plugins/lemur_google_ca/__init__.py | 5 +++ lemur/plugins/lemur_google_ca/plugin.py | 40 +++++++++++++++++++++++ requirements.in | 2 ++ 3 files changed, 47 insertions(+) create mode 100644 lemur/plugins/lemur_google_ca/__init__.py create mode 100644 lemur/plugins/lemur_google_ca/plugin.py diff --git a/lemur/plugins/lemur_google_ca/__init__.py b/lemur/plugins/lemur_google_ca/__init__.py new file mode 100644 index 0000000000..8f49727da2 --- /dev/null +++ b/lemur/plugins/lemur_google_ca/__init__.py @@ -0,0 +1,5 @@ +try: + VERSION = __import__("pkg_resources").get_distribution(__name__).version +except Exception as e: + VERSION = "unknown" + diff --git a/lemur/plugins/lemur_google_ca/plugin.py b/lemur/plugins/lemur_google_ca/plugin.py new file mode 100644 index 0000000000..718ddafc0a --- /dev/null +++ b/lemur/plugins/lemur_google_ca/plugin.py @@ -0,0 +1,40 @@ +import json +import os +import uuid + +import google.cloud.security.privateca_v1 as private_ca +from flask import current_app +from google.protobuf import duration_pb2 + +from lemur.exceptions import InvalidConfiguration +from lemur.plugins import lemur_google_ca as gca, VERSION +from lemur.plugins.bases import IssuerPlugin + + +class GoogleCaIssuerPlugin(IssuerPlugin): + title = "Google CA" + slug = "googleca-issuer" + description = "Enables the creation of certificates by Google CA" + version = gca.VERSION + + author = "Oleg Dopertchouk" + author_url = "https://github.com/sqsp" + + options = [ + { + "name": "CAPool", + "type": "str", + "required": True, + "value": "ca-pool1", + "validation": "(?i)^[a-zA-Z_0-9.-]+$", + "helpMessage": "Must be a valid GCP name!", + }, + { + "name": "Duration", + "type": "int", + "required": False, + "value": 365, + "validation": "(?i)[0-9]+$", + "helpMessage": "Duration in days", + }, + ] \ No newline at end of file diff --git a/requirements.in b/requirements.in index 34bd71ae0f..2ac0e25403 100644 --- a/requirements.in +++ b/requirements.in @@ -25,6 +25,8 @@ Flask<3 # until https://github.com/pytest-dev/pytest-flask/pull/168 is released Flask-Cors flask_replicated future +google-cloud-private-ca +protobuf gunicorn hvac # required for the vault destination plugin inflection From 8185af800f6a5bb722a060fac6aacffc7251d8c9 Mon Sep 17 00:00:00 2001 From: Oleg Dopertchouk Date: Tue, 9 Apr 2024 12:32:19 -0400 Subject: [PATCH 02/11] Google CA plugin + its dependencies --- lemur/plugins/lemur_google_ca/__init__.py | 1 - lemur/plugins/lemur_google_ca/plugin.py | 183 ++++++++++++++++++++-- requirements-dev.txt | 95 ++++++++--- requirements-docs.txt | 87 ++++++++-- requirements-tests.txt | 91 +++++++++-- requirements.in | 1 + requirements.txt | 65 ++++++-- setup.py | 4 +- 8 files changed, 451 insertions(+), 76 deletions(-) diff --git a/lemur/plugins/lemur_google_ca/__init__.py b/lemur/plugins/lemur_google_ca/__init__.py index 8f49727da2..f8afd7e35f 100644 --- a/lemur/plugins/lemur_google_ca/__init__.py +++ b/lemur/plugins/lemur_google_ca/__init__.py @@ -2,4 +2,3 @@ VERSION = __import__("pkg_resources").get_distribution(__name__).version except Exception as e: VERSION = "unknown" - diff --git a/lemur/plugins/lemur_google_ca/plugin.py b/lemur/plugins/lemur_google_ca/plugin.py index 718ddafc0a..2333dc7164 100644 --- a/lemur/plugins/lemur_google_ca/plugin.py +++ b/lemur/plugins/lemur_google_ca/plugin.py @@ -1,26 +1,100 @@ +""" +.. module: lemur.plugins.lemur_google_ca.plugin + :platform: Unix + :synopsis: This module is responsible for creating certificates with the Google CA API ' + :license: Apache, see LICENSE for more details. + + Google CA (v2 API) Documentation + https://cloud.google.com/certificate-authority-service/docs/reference/rest + + This plugin requires following packages: + - google-cloud-private-ca + - protobuf + - types-protobuf (for mypy) + Make sure to add these to `requirements.in` + +.. moduleauthor:: Oleg Dopertchouk +""" import json -import os +import re import uuid +from typing import Optional import google.cloud.security.privateca_v1 as private_ca + +import arrow from flask import current_app +from google.oauth2 import service_account from google.protobuf import duration_pb2 -from lemur.exceptions import InvalidConfiguration -from lemur.plugins import lemur_google_ca as gca, VERSION +from lemur.constants import CRLReason +from lemur.common.utils import validate_conf +import lemur.plugins.lemur_google_ca from lemur.plugins.bases import IssuerPlugin +import google.cloud.security.privateca_v1 as privateca_v1 + +SECONDS_PER_YEAR = 365 * 24 * 60 * 60 + + +def get_duration(options): + """ + Deduce certificate duration from options + """ + validity_end = options.get("validity_end") + if validity_end: + return int((validity_end - arrow.utcnow()).total_seconds()) + else: + return options.get("validity_years", 1) * SECONDS_PER_YEAR + + +def generate_certificate_id(common_name) -> str: + """ + Generates a readable unique id for a cert based on cert's CN + """ + name = common_name.lower().strip() + name = re.sub(r'[^a-z0-9-]', '_', name) + name = name[:50] # leave space for random id + return f"{name}-{uuid.uuid4().hex}"[:63] # Truncate to 63 characters, to fit the api constraints + + +def fetch_authority(ca_path: str) -> tuple[str, str]: + client = privateca_v1.CertificateAuthorityServiceClient() + resp = client.get_certificate_authority(name=ca_path) + if resp.state != privateca_v1.CertificateAuthority.State.ENABLED: + raise Exception("The CA is not enabled") + certs = list(resp.pem_ca_certificates) + ca_pem = certs[0] + ca_chain = '\n'.join(certs[1:]) + return ca_pem, ca_chain + class GoogleCaIssuerPlugin(IssuerPlugin): title = "Google CA" slug = "googleca-issuer" description = "Enables the creation of certificates by Google CA" - version = gca.VERSION + version = lemur.plugins.lemur_google_ca.VERSION author = "Oleg Dopertchouk" - author_url = "https://github.com/sqsp" + author_url = "https://github.com/odopertchouk" options = [ + { + "name": "Project", + "type": "str", + "required": True, + "value": "lemur", + "validation": "(?i)^[a-zA-Z_0-9.-]+$", + "helpMessage": "Must be a valid GCP project name!", + }, + { + "name": "Location", + "type": "str", + "required": True, + "value": "us-central1", + "validation": "(?i)^[a-z0-9-]+$", + "helpMessage": "Must be a valid GCP location name!", + }, { "name": "CAPool", "type": "str", @@ -30,11 +104,96 @@ class GoogleCaIssuerPlugin(IssuerPlugin): "helpMessage": "Must be a valid GCP name!", }, { - "name": "Duration", - "type": "int", - "required": False, - "value": 365, - "validation": "(?i)[0-9]+$", - "helpMessage": "Duration in days", + "name": "CAName", + "type": "str", + "required": True, + "validation": "(?i)^[a-zA-Z_0-9.-]+$", + "helpMessage": "Must be a valid GCP name!", }, - ] \ No newline at end of file + ] + + def __init__(self, *args, **kwargs): + """Initialize source with appropriate details.""" + required_vars = [ + "GOOGLE_APPLICATION_CREDENTIALS", + ] + validate_conf(current_app, required_vars) + + def create_certificate(self, csr, options) -> tuple[str, str, str]: + """ + :param csr: Certificate Signing Request to turn into a certificate + :param options: Options passed from the UI (validated by CertificateInputSchema) + """ + authority = options['authority'] + if not authority: + raise ValueError("Certificate requires a signer CA to be specified") + if authority.plugin_name != GoogleCaIssuerPlugin.slug: + raise ValueError("Certificate must be created by Google CA") + ca_options = {opt['name']: opt['value'] for opt in json.loads(authority.options)} + ca_path = f"projects/{ca_options['Project']}" \ + f"/locations/{ca_options['Location']}" \ + f"/caPools/{ca_options['CAPool']}" + + lifetime = get_duration(options) + + client = private_ca.CertificateAuthorityServiceClient( + credentials=service_account.Credentials.from_service_account_file( + current_app.config['GOOGLE_APPLICATION_CREDENTIALS'] + ) + ) + request = private_ca.CreateCertificateRequest( + parent=ca_path, + certificate=private_ca.Certificate( + pem_csr=csr, + lifetime=duration_pb2.Duration(seconds=lifetime) + ), + certificate_id=generate_certificate_id(options['common_name']), + issuing_certificate_authority_id=ca_options['CAName'] + ) + resp = client.create_certificate(request) + cert_pem = resp.pem_certificate + chain_pem = '\n'.join(resp.pem_certificate_chain) + ext_id = request.certificate_id + return cert_pem, chain_pem, ext_id + + def create_authority(self, options: dict) -> tuple[str, Optional[str], str, list[dict]]: + """ + :param options: Plugin options as specified in AuthorityInputSchema + :return body, private_key, chain, roles + """ + plugin_options = {opt['name']: opt.get('value') for opt in options.get('plugin', {}).get('plugin_options', [])} + + ca_name = options["name"] + ca_path = f"projects/{plugin_options['Project']}" \ + f"/locations/{plugin_options['Location']}" \ + f"/caPools/{plugin_options['CAPool']}" \ + f"/certificateAuthorities/{plugin_options['CAName']}" + ca_pem, chain_pem = fetch_authority(ca_path) + + name = f"googleca_ca_name_{ca_name}_admin" + role = {"username": "", "password": "", "name": name} + return ca_pem, "", chain_pem, [role] + + def revoke_certificate(self, certificate, reason): + authority = certificate.authority + if not authority: + raise ValueError("Certificate requires a signer CA to be specified") + if authority.plugin_name != GoogleCaIssuerPlugin.slug: + raise ValueError("Certificate must be created by Google CA") + + ca_options = {opt['name']: opt['value'] for opt in json.loads(authority.options)} + ca_path = f"projects/{ca_options['Project']}" \ + f"/locations/{ca_options['Location']}" \ + f"/caPools/{ca_options['CAPool']}" \ + f"/certificates/{certificate.external_id}" + crl_reason = CRLReason.unspecified + if "crl_reason" in reason: + crl_reason = CRLReason[reason["crl_reason"]] + + client = private_ca.CertificateAuthorityServiceClient() + request = privateca_v1.RevokeCertificateRequest( + name=ca_path, + reason=crl_reason, + ) + response = client.revoke_certificate(request=request) + return response diff --git a/requirements-dev.txt b/requirements-dev.txt index d56a05dc77..45d45e52b7 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -4,7 +4,7 @@ # # pip-compile --no-emit-index-url --output-file=requirements-dev.txt requirements-dev.in # -acme==2.9.0 +acme==2.10.0 # via # -r requirements-tests.txt # certbot @@ -42,7 +42,7 @@ attrs==23.2.0 # jsonschema # referencing # sarif-om -aws-sam-translator==1.86.0 +aws-sam-translator==1.87.0 # via # -r requirements-tests.txt # cfn-lint @@ -50,6 +50,8 @@ aws-xray-sdk==2.13.0 # via # -r requirements-tests.txt # moto +backports-tarfile==1.0.0 + # via jaraco-context bandit==1.7.8 # via -r requirements-tests.txt bcrypt==4.1.2 @@ -69,21 +71,25 @@ blinker==1.7.0 # flask # flask-mail # flask-principal -boto3==1.34.72 +boto3==1.34.80 # via # -r requirements-tests.txt # aws-sam-translator # moto -botocore==1.34.72 +botocore==1.34.80 # via # -r requirements-tests.txt # aws-xray-sdk # boto3 # moto # s3transfer +cachetools==5.3.3 + # via + # -r requirements-tests.txt + # google-auth celery[redis]==5.3.6 # via -r requirements-tests.txt -certbot==2.9.0 +certbot==2.10.0 # via -r requirements-tests.txt certifi==2024.2.2 # via @@ -99,7 +105,7 @@ cffi==1.16.0 # pynacl cfgv==3.4.0 # via pre-commit -cfn-lint==0.86.1 +cfn-lint==0.86.2 # via # -r requirements-tests.txt # moto @@ -180,7 +186,7 @@ docutils==0.20.1 # via readme-renderer dyn==1.8.6 # via -r requirements-tests.txt -ecdsa==0.18.0 +ecdsa==0.19.0 # via # -r requirements-tests.txt # moto @@ -192,7 +198,7 @@ exceptiongroup==1.2.0 # pytest factory-boy==3.3.0 # via -r requirements-tests.txt -faker==24.4.0 +faker==24.8.0 # via # -r requirements-tests.txt # factory-boy @@ -238,10 +244,42 @@ freezegun==1.4.0 # via -r requirements-tests.txt future==1.0.0 # via -r requirements-tests.txt +google-api-core[grpc]==2.18.0 + # via + # -r requirements-tests.txt + # google-cloud-private-ca +google-auth==2.29.0 + # via + # -r requirements-tests.txt + # google-api-core + # google-cloud-private-ca +google-cloud-private-ca==1.12.0 + # via -r requirements-tests.txt +googleapis-common-protos[grpc]==1.63.0 + # via + # -r requirements-tests.txt + # google-api-core + # grpc-google-iam-v1 + # grpcio-status graphql-core==3.2.3 # via # -r requirements-tests.txt # moto +grpc-google-iam-v1==0.13.0 + # via + # -r requirements-tests.txt + # google-cloud-private-ca +grpcio==1.62.1 + # via + # -r requirements-tests.txt + # google-api-core + # googleapis-common-protos + # grpc-google-iam-v1 + # grpcio-status +grpcio-status==1.62.1 + # via + # -r requirements-tests.txt + # google-api-core gunicorn==21.2.0 # via -r requirements-tests.txt hvac==2.1.0 @@ -275,13 +313,13 @@ itsdangerous==2.1.2 # via # -r requirements-tests.txt # flask -jaraco-classes==3.3.1 +jaraco-classes==3.4.0 # via keyring -jaraco-context==4.3.0 +jaraco-context==5.3.0 # via keyring jaraco-functools==4.0.0 # via keyring -javaobj-py3==0.4.3 +javaobj-py3==0.4.4 # via # -r requirements-tests.txt # pyjks @@ -344,7 +382,7 @@ junit-xml==1.9 # via # -r requirements-tests.txt # cfn-lint -keyring==25.0.0 +keyring==25.1.0 # via twine kombu==5.3.6 # via @@ -482,6 +520,20 @@ prompt-toolkit==3.0.43 # via # -r requirements-tests.txt # click-repl +proto-plus==1.23.0 + # via + # -r requirements-tests.txt + # google-api-core + # google-cloud-private-ca +protobuf==4.25.3 + # via + # -r requirements-tests.txt + # google-api-core + # google-cloud-private-ca + # googleapis-common-protos + # grpc-google-iam-v1 + # grpcio-status + # proto-plus psycopg2==2.9.9 # via -r requirements-tests.txt py-partiql-parser==0.5.0 @@ -500,11 +552,12 @@ pyasn1==0.6.0 pyasn1-modules==0.4.0 # via # -r requirements-tests.txt + # google-auth # pyjks # python-ldap pycodestyle==2.11.1 # via flake8 -pycparser==2.21 +pycparser==2.22 # via # -r requirements-tests.txt # cffi @@ -625,6 +678,7 @@ requests==2.31.0 # certsrv # cloudflare # docker + # google-api-core # hvac # jsonschema-path # moto @@ -633,7 +687,7 @@ requests==2.31.0 # requests-toolbelt # responses # twine -requests-mock==1.12.0 +requests-mock==1.12.1 # via -r requirements-tests.txt requests-ntlm==1.2.0 # via @@ -667,6 +721,7 @@ rpds-py==0.18.0 rsa==4.9 # via # -r requirements-tests.txt + # google-auth # python-jose s3transfer==0.10.1 # via @@ -676,7 +731,7 @@ sarif-om==1.0.4 # via # -r requirements-tests.txt # cfn-lint -sentry-sdk==1.44.0 +sentry-sdk==1.44.1 # via -r requirements-tests.txt six==1.16.0 # via @@ -731,6 +786,8 @@ types-deprecated==1.2.9.20240311 # via -r requirements-tests.txt types-paramiko==3.4.0.20240311 # via -r requirements-tests.txt +types-protobuf==4.24.0.20240408 + # via -r requirements-tests.txt types-pyopenssl==24.0.0.20240311 # via # -r requirements-tests.txt @@ -743,7 +800,7 @@ types-python-dateutil==2.9.0.20240316 # arrow types-pytz==2024.1.0.20240203 # via -r requirements-tests.txt -types-redis==4.6.0.20240311 +types-redis==4.6.0.20240409 # via -r requirements-tests.txt types-requests==2.31.0.6 # via -r requirements-tests.txt @@ -757,7 +814,7 @@ types-urllib3==1.26.25.14 # via # -r requirements-tests.txt # types-requests -typing-extensions==4.10.0 +typing-extensions==4.11.0 # via # -r requirements-tests.txt # alembic @@ -782,7 +839,7 @@ urllib3==1.26.18 # responses # sentry-sdk # twine -validators==0.24.0 +validators==0.28.0 # via -r requirements-tests.txt vine==5.1.0 # via @@ -796,7 +853,7 @@ wcwidth==0.2.13 # via # -r requirements-tests.txt # prompt-toolkit -werkzeug==3.0.1 +werkzeug==3.0.2 # via # -r requirements-tests.txt # flask diff --git a/requirements-docs.txt b/requirements-docs.txt index 220259679c..0f1703f265 100644 --- a/requirements-docs.txt +++ b/requirements-docs.txt @@ -4,7 +4,7 @@ # # pip-compile --no-emit-index-url --output-file=requirements-docs.txt requirements-docs.in # -acme==2.9.0 +acme==2.10.0 # via # -r requirements-docs.in # -r requirements-tests.txt @@ -47,7 +47,7 @@ attrs==23.2.0 # jsonschema # referencing # sarif-om -aws-sam-translator==1.86.0 +aws-sam-translator==1.87.0 # via # -r requirements-tests.txt # cfn-lint @@ -76,13 +76,13 @@ blinker==1.7.0 # flask # flask-mail # flask-principal -boto3==1.34.72 +boto3==1.34.80 # via # -r requirements-docs.in # -r requirements-tests.txt # aws-sam-translator # moto -botocore==1.34.72 +botocore==1.34.80 # via # -r requirements-docs.in # -r requirements-tests.txt @@ -90,11 +90,15 @@ botocore==1.34.72 # boto3 # moto # s3transfer +cachetools==5.3.3 + # via + # -r requirements-tests.txt + # google-auth celery[redis]==5.3.6 # via # -r requirements-docs.in # -r requirements-tests.txt -certbot==2.9.0 +certbot==2.10.0 # via # -r requirements-docs.in # -r requirements-tests.txt @@ -112,7 +116,7 @@ cffi==1.16.0 # -r requirements-tests.txt # cryptography # pynacl -cfn-lint==0.86.1 +cfn-lint==0.86.2 # via # -r requirements-tests.txt # moto @@ -201,7 +205,7 @@ dyn==1.8.6 # via # -r requirements-docs.in # -r requirements-tests.txt -ecdsa==0.18.0 +ecdsa==0.19.0 # via # -r requirements-tests.txt # moto @@ -213,7 +217,7 @@ exceptiongroup==1.2.0 # pytest factory-boy==3.3.0 # via -r requirements-tests.txt -faker==24.4.0 +faker==24.8.0 # via # -r requirements-tests.txt # factory-boy @@ -273,10 +277,42 @@ freezegun==1.4.0 # via -r requirements-tests.txt future==1.0.0 # via -r requirements-tests.txt +google-api-core[grpc]==2.18.0 + # via + # -r requirements-tests.txt + # google-cloud-private-ca +google-auth==2.29.0 + # via + # -r requirements-tests.txt + # google-api-core + # google-cloud-private-ca +google-cloud-private-ca==1.12.0 + # via -r requirements-tests.txt +googleapis-common-protos[grpc]==1.63.0 + # via + # -r requirements-tests.txt + # google-api-core + # grpc-google-iam-v1 + # grpcio-status graphql-core==3.2.3 # via # -r requirements-tests.txt # moto +grpc-google-iam-v1==0.13.0 + # via + # -r requirements-tests.txt + # google-cloud-private-ca +grpcio==1.62.1 + # via + # -r requirements-tests.txt + # google-api-core + # googleapis-common-protos + # grpc-google-iam-v1 + # grpcio-status +grpcio-status==1.62.1 + # via + # -r requirements-tests.txt + # google-api-core gunicorn==21.2.0 # via # -r requirements-docs.in @@ -314,7 +350,7 @@ itsdangerous==2.1.2 # -r requirements-docs.in # -r requirements-tests.txt # flask -javaobj-py3==0.4.3 +javaobj-py3==0.4.4 # via # -r requirements-tests.txt # pyjks @@ -508,6 +544,20 @@ prompt-toolkit==3.0.43 # via # -r requirements-tests.txt # click-repl +proto-plus==1.23.0 + # via + # -r requirements-tests.txt + # google-api-core + # google-cloud-private-ca +protobuf==4.25.3 + # via + # -r requirements-tests.txt + # google-api-core + # google-cloud-private-ca + # googleapis-common-protos + # grpc-google-iam-v1 + # grpcio-status + # proto-plus psycopg2==2.9.9 # via -r requirements-tests.txt py-partiql-parser==0.5.0 @@ -526,9 +576,10 @@ pyasn1==0.6.0 pyasn1-modules==0.4.0 # via # -r requirements-tests.txt + # google-auth # pyjks # python-ldap -pycparser==2.21 +pycparser==2.22 # via # -r requirements-tests.txt # cffi @@ -650,6 +701,7 @@ requests==2.31.0 # certsrv # cloudflare # docker + # google-api-core # hvac # jsonschema-path # moto @@ -657,7 +709,7 @@ requests==2.31.0 # requests-ntlm # responses # sphinx -requests-mock==1.12.0 +requests-mock==1.12.1 # via -r requirements-tests.txt requests-ntlm==1.2.0 # via @@ -688,6 +740,7 @@ rpds-py==0.18.0 rsa==4.9 # via # -r requirements-tests.txt + # google-auth # python-jose s3transfer==0.10.1 # via @@ -697,7 +750,7 @@ sarif-om==1.0.4 # via # -r requirements-tests.txt # cfn-lint -sentry-sdk==1.44.0 +sentry-sdk==1.44.1 # via # -r requirements-docs.in # -r requirements-tests.txt @@ -784,6 +837,8 @@ types-deprecated==1.2.9.20240311 # via -r requirements-tests.txt types-paramiko==3.4.0.20240311 # via -r requirements-tests.txt +types-protobuf==4.24.0.20240408 + # via -r requirements-tests.txt types-pyopenssl==24.0.0.20240311 # via # -r requirements-tests.txt @@ -796,7 +851,7 @@ types-python-dateutil==2.9.0.20240316 # arrow types-pytz==2024.1.0.20240203 # via -r requirements-tests.txt -types-redis==4.6.0.20240311 +types-redis==4.6.0.20240409 # via -r requirements-tests.txt types-requests==2.31.0.6 # via -r requirements-tests.txt @@ -810,7 +865,7 @@ types-urllib3==1.26.25.14 # via # -r requirements-tests.txt # types-requests -typing-extensions==4.10.0 +typing-extensions==4.11.0 # via # -r requirements-tests.txt # alembic @@ -834,7 +889,7 @@ urllib3==1.26.18 # requests # responses # sentry-sdk -validators==0.24.0 +validators==0.28.0 # via -r requirements-tests.txt vine==5.1.0 # via @@ -847,7 +902,7 @@ wcwidth==0.2.13 # via # -r requirements-tests.txt # prompt-toolkit -werkzeug==3.0.1 +werkzeug==3.0.2 # via # -r requirements-docs.in # -r requirements-tests.txt diff --git a/requirements-tests.txt b/requirements-tests.txt index b77235ad5d..65e78b9409 100644 --- a/requirements-tests.txt +++ b/requirements-tests.txt @@ -4,7 +4,7 @@ # # pip-compile --no-emit-index-url --output-file=requirements-tests.txt requirements-tests.in # -acme==2.9.0 +acme==2.10.0 # via # -r requirements.txt # certbot @@ -40,7 +40,7 @@ attrs==23.2.0 # jsonschema # referencing # sarif-om -aws-sam-translator==1.86.0 +aws-sam-translator==1.87.0 # via cfn-lint aws-xray-sdk==2.13.0 # via moto @@ -63,23 +63,27 @@ blinker==1.7.0 # flask # flask-mail # flask-principal -boto3==1.34.72 +boto3==1.34.80 # via # -r requirements.txt # aws-sam-translator # moto -botocore==1.34.72 +botocore==1.34.80 # via # -r requirements.txt # aws-xray-sdk # boto3 # moto # s3transfer +cachetools==5.3.3 + # via + # -r requirements.txt + # google-auth celery[redis]==5.3.6 # via # -r requirements-tests.in # -r requirements.txt -certbot==2.9.0 +certbot==2.10.0 # via -r requirements.txt certifi==2024.2.2 # via @@ -93,7 +97,7 @@ cffi==1.16.0 # -r requirements.txt # cryptography # pynacl -cfn-lint==0.86.1 +cfn-lint==0.86.2 # via moto charset-normalizer==3.3.2 # via @@ -166,7 +170,7 @@ docker==7.0.0 # via moto dyn==1.8.6 # via -r requirements.txt -ecdsa==0.18.0 +ecdsa==0.19.0 # via # moto # python-jose @@ -175,7 +179,7 @@ exceptiongroup==1.2.0 # via pytest factory-boy==3.3.0 # via -r requirements-tests.in -faker==24.4.0 +faker==24.8.0 # via # -r requirements-tests.in # factory-boy @@ -217,8 +221,40 @@ freezegun==1.4.0 # via -r requirements-tests.in future==1.0.0 # via -r requirements.txt +google-api-core[grpc]==2.18.0 + # via + # -r requirements.txt + # google-cloud-private-ca +google-auth==2.29.0 + # via + # -r requirements.txt + # google-api-core + # google-cloud-private-ca +google-cloud-private-ca==1.12.0 + # via -r requirements.txt +googleapis-common-protos[grpc]==1.63.0 + # via + # -r requirements.txt + # google-api-core + # grpc-google-iam-v1 + # grpcio-status graphql-core==3.2.3 # via moto +grpc-google-iam-v1==0.13.0 + # via + # -r requirements.txt + # google-cloud-private-ca +grpcio==1.62.1 + # via + # -r requirements.txt + # google-api-core + # googleapis-common-protos + # grpc-google-iam-v1 + # grpcio-status +grpcio-status==1.62.1 + # via + # -r requirements.txt + # google-api-core gunicorn==21.2.0 # via -r requirements.txt hvac==2.1.0 @@ -244,7 +280,7 @@ itsdangerous==2.1.2 # via # -r requirements.txt # flask -javaobj-py3==0.4.3 +javaobj-py3==0.4.4 # via # -r requirements.txt # pyjks @@ -390,6 +426,20 @@ prompt-toolkit==3.0.43 # via # -r requirements.txt # click-repl +proto-plus==1.23.0 + # via + # -r requirements.txt + # google-api-core + # google-cloud-private-ca +protobuf==4.25.3 + # via + # -r requirements.txt + # google-api-core + # google-cloud-private-ca + # googleapis-common-protos + # grpc-google-iam-v1 + # grpcio-status + # proto-plus psycopg2==2.9.9 # via -r requirements.txt py-partiql-parser==0.5.0 @@ -406,9 +456,10 @@ pyasn1==0.6.0 pyasn1-modules==0.4.0 # via # -r requirements.txt + # google-auth # pyjks # python-ldap -pycparser==2.21 +pycparser==2.22 # via # -r requirements.txt # cffi @@ -512,13 +563,14 @@ requests==2.31.0 # certsrv # cloudflare # docker + # google-api-core # hvac # jsonschema-path # moto # requests-mock # requests-ntlm # responses -requests-mock==1.12.0 +requests-mock==1.12.1 # via -r requirements-tests.in requests-ntlm==1.2.0 # via @@ -540,14 +592,17 @@ rpds-py==0.18.0 # jsonschema # referencing rsa==4.9 - # via python-jose + # via + # -r requirements.txt + # google-auth + # python-jose s3transfer==0.10.1 # via # -r requirements.txt # boto3 sarif-om==1.0.4 # via cfn-lint -sentry-sdk==1.44.0 +sentry-sdk==1.44.1 # via -r requirements.txt six==1.16.0 # via @@ -591,6 +646,8 @@ types-deprecated==1.2.9.20240311 # via -r requirements-tests.in types-paramiko==3.4.0.20240311 # via -r requirements-tests.in +types-protobuf==4.24.0.20240408 + # via -r requirements.txt types-pyopenssl==24.0.0.20240311 # via # -r requirements-tests.in @@ -603,7 +660,7 @@ types-python-dateutil==2.9.0.20240316 # arrow types-pytz==2024.1.0.20240203 # via -r requirements-tests.in -types-redis==4.6.0.20240311 +types-redis==4.6.0.20240409 # via -r requirements-tests.in types-requests==2.31.0.6 # via -r requirements-tests.in @@ -615,7 +672,7 @@ types-tabulate==0.9.0.20240106 # via -r requirements-tests.in types-urllib3==1.26.25.14 # via types-requests -typing-extensions==4.10.0 +typing-extensions==4.11.0 # via # -r requirements.txt # alembic @@ -639,7 +696,7 @@ urllib3==1.26.18 # requests # responses # sentry-sdk -validators==0.24.0 +validators==0.28.0 # via -r requirements.txt vine==5.1.0 # via @@ -651,7 +708,7 @@ wcwidth==0.2.13 # via # -r requirements.txt # prompt-toolkit -werkzeug==3.0.1 +werkzeug==3.0.2 # via # -r requirements.txt # flask diff --git a/requirements.in b/requirements.in index 2ac0e25403..a1184c6a29 100644 --- a/requirements.in +++ b/requirements.in @@ -27,6 +27,7 @@ flask_replicated future google-cloud-private-ca protobuf +types-protobuf gunicorn hvac # required for the vault destination plugin inflection diff --git a/requirements.txt b/requirements.txt index e52577bb20..9b956a4bd8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,7 +4,7 @@ # # pip-compile --no-emit-index-url --output-file=requirements.txt requirements.in # -acme==2.9.0 +acme==2.10.0 # via # -r requirements.in # certbot @@ -35,16 +35,18 @@ blinker==1.7.0 # flask # flask-mail # flask-principal -boto3==1.34.72 +boto3==1.34.80 # via -r requirements.in -botocore==1.34.72 +botocore==1.34.80 # via # -r requirements.in # boto3 # s3transfer +cachetools==5.3.3 + # via google-auth celery[redis]==5.3.6 # via -r requirements.in -certbot==2.9.0 +certbot==2.10.0 # via -r requirements.in certifi==2024.2.2 # via @@ -131,6 +133,29 @@ flask-sqlalchemy==2.5.1 # flask-migrate future==1.0.0 # via -r requirements.in +google-api-core[grpc]==2.18.0 + # via google-cloud-private-ca +google-auth==2.29.0 + # via + # google-api-core + # google-cloud-private-ca +google-cloud-private-ca==1.12.0 + # via -r requirements.in +googleapis-common-protos[grpc]==1.63.0 + # via + # google-api-core + # grpc-google-iam-v1 + # grpcio-status +grpc-google-iam-v1==0.13.0 + # via google-cloud-private-ca +grpcio==1.62.1 + # via + # google-api-core + # googleapis-common-protos + # grpc-google-iam-v1 + # grpcio-status +grpcio-status==1.62.1 + # via google-api-core gunicorn==21.2.0 # via -r requirements.in hvac==2.1.0 @@ -149,7 +174,7 @@ itsdangerous==2.1.2 # via # -r requirements.in # flask -javaobj-py3==0.4.3 +javaobj-py3==0.4.4 # via pyjks jinja2==3.1.3 # via @@ -206,6 +231,19 @@ pem==23.1.0 # via -r requirements.in prompt-toolkit==3.0.43 # via click-repl +proto-plus==1.23.0 + # via + # google-api-core + # google-cloud-private-ca +protobuf==4.25.3 + # via + # -r requirements.in + # google-api-core + # google-cloud-private-ca + # googleapis-common-protos + # grpc-google-iam-v1 + # grpcio-status + # proto-plus psycopg2==2.9.9 # via -r requirements.in pyasn1==0.6.0 @@ -214,11 +252,13 @@ pyasn1==0.6.0 # pyasn1-modules # pyjks # python-ldap + # rsa pyasn1-modules==0.4.0 # via + # google-auth # pyjks # python-ldap -pycparser==2.21 +pycparser==2.22 # via cffi pycryptodomex==3.20.0 # via pyjks @@ -271,6 +311,7 @@ requests==2.31.0 # acme # certsrv # cloudflare + # google-api-core # hvac # requests-ntlm requests-ntlm==1.2.0 @@ -279,9 +320,11 @@ retrying==1.3.4 # via -r requirements.in rich==13.7.1 # via flask-limiter +rsa==4.9 + # via google-auth s3transfer==0.10.1 # via boto3 -sentry-sdk==1.44.0 +sentry-sdk==1.44.1 # via -r requirements.in six==1.16.0 # via @@ -303,9 +346,11 @@ tabulate==0.9.0 # via -r requirements.in twofish==0.3.0 # via pyjks +types-protobuf==4.24.0.20240408 + # via -r requirements.in types-python-dateutil==2.9.0.20240316 # via arrow -typing-extensions==4.10.0 +typing-extensions==4.11.0 # via # alembic # flask-limiter @@ -318,7 +363,7 @@ urllib3==1.26.18 # botocore # requests # sentry-sdk -validators==0.24.0 +validators==0.28.0 # via -r requirements.in vine==5.1.0 # via @@ -327,7 +372,7 @@ vine==5.1.0 # kombu wcwidth==0.2.13 # via prompt-toolkit -werkzeug==3.0.1 +werkzeug==3.0.2 # via # -r requirements.in # flask diff --git a/setup.py b/setup.py index b01f758083..cf65e901da 100644 --- a/setup.py +++ b/setup.py @@ -162,7 +162,9 @@ def run(self): 'adcs_source = lemur.plugins.lemur_adcs.plugin:ADCSSourcePlugin', 'entrust_issuer = lemur.plugins.lemur_entrust.plugin:EntrustIssuerPlugin', 'entrust_source = lemur.plugins.lemur_entrust.plugin:EntrustSourcePlugin', - 'azure_destination = lemur.plugins.lemur_azure_dest.plugin:AzureDestinationPlugin' + 'azure_destination = lemur.plugins.lemur_azure_dest.plugin:AzureDestinationPlugin', + + 'google_ca_issuer = lemur.plugins.lemur_google_ca.plugin:GoogleCaIssuerPlugin' ], }, classifiers=[ From 5ea7c4be02da82754bbbbb5c6ef7a9c620243943 Mon Sep 17 00:00:00 2001 From: Oleg Dopertchouk Date: Tue, 9 Apr 2024 13:03:29 -0400 Subject: [PATCH 03/11] removed default values, because they are non generic --- lemur/plugins/lemur_google_ca/plugin.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/lemur/plugins/lemur_google_ca/plugin.py b/lemur/plugins/lemur_google_ca/plugin.py index 2333dc7164..f9334b1d54 100644 --- a/lemur/plugins/lemur_google_ca/plugin.py +++ b/lemur/plugins/lemur_google_ca/plugin.py @@ -83,7 +83,6 @@ class GoogleCaIssuerPlugin(IssuerPlugin): "name": "Project", "type": "str", "required": True, - "value": "lemur", "validation": "(?i)^[a-zA-Z_0-9.-]+$", "helpMessage": "Must be a valid GCP project name!", }, @@ -91,7 +90,6 @@ class GoogleCaIssuerPlugin(IssuerPlugin): "name": "Location", "type": "str", "required": True, - "value": "us-central1", "validation": "(?i)^[a-z0-9-]+$", "helpMessage": "Must be a valid GCP location name!", }, @@ -99,7 +97,6 @@ class GoogleCaIssuerPlugin(IssuerPlugin): "name": "CAPool", "type": "str", "required": True, - "value": "ca-pool1", "validation": "(?i)^[a-zA-Z_0-9.-]+$", "helpMessage": "Must be a valid GCP name!", }, @@ -133,7 +130,6 @@ def create_certificate(self, csr, options) -> tuple[str, str, str]: ca_path = f"projects/{ca_options['Project']}" \ f"/locations/{ca_options['Location']}" \ f"/caPools/{ca_options['CAPool']}" - lifetime = get_duration(options) client = private_ca.CertificateAuthorityServiceClient( @@ -190,7 +186,11 @@ def revoke_certificate(self, certificate, reason): if "crl_reason" in reason: crl_reason = CRLReason[reason["crl_reason"]] - client = private_ca.CertificateAuthorityServiceClient() + client = private_ca.CertificateAuthorityServiceClient( + credentials=service_account.Credentials.from_service_account_file( + current_app.config['GOOGLE_APPLICATION_CREDENTIALS'] + ) + ) request = privateca_v1.RevokeCertificateRequest( name=ca_path, reason=crl_reason, From 4cb618903eeeb97569ec42c04ea3138749666df5 Mon Sep 17 00:00:00 2001 From: Oleg Dopertchouk Date: Tue, 9 Apr 2024 13:18:57 -0400 Subject: [PATCH 04/11] updated comments for plugin --- lemur/plugins/lemur_google_ca/plugin.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/lemur/plugins/lemur_google_ca/plugin.py b/lemur/plugins/lemur_google_ca/plugin.py index f9334b1d54..243a743f68 100644 --- a/lemur/plugins/lemur_google_ca/plugin.py +++ b/lemur/plugins/lemur_google_ca/plugin.py @@ -13,6 +13,13 @@ - types-protobuf (for mypy) Make sure to add these to `requirements.in` + The plugin requires `GOOGLE_ACCOUNT_CREDENTIALS` config variable, which should point at the file containing + credentials that Lemur is using to connect to Google Cloud Platform. + These credentials normally would be for a service account that has permissions + - `privateca.certificates.update` + - `privateca.certificates.create` + for a specified Certifiate authority or have a role `roles/privateca.certificateManager` + .. moduleauthor:: Oleg Dopertchouk """ import json From 945974d67eb51170e828b821a6dc48c0f39a1252 Mon Sep 17 00:00:00 2001 From: Oleg Dopertchouk Date: Tue, 9 Apr 2024 13:39:10 -0400 Subject: [PATCH 05/11] clarified comment about required permissions for the plugin --- lemur/plugins/lemur_google_ca/plugin.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/lemur/plugins/lemur_google_ca/plugin.py b/lemur/plugins/lemur_google_ca/plugin.py index 243a743f68..cd5c1f610e 100644 --- a/lemur/plugins/lemur_google_ca/plugin.py +++ b/lemur/plugins/lemur_google_ca/plugin.py @@ -15,10 +15,18 @@ The plugin requires `GOOGLE_ACCOUNT_CREDENTIALS` config variable, which should point at the file containing credentials that Lemur is using to connect to Google Cloud Platform. - These credentials normally would be for a service account that has permissions - - `privateca.certificates.update` - - `privateca.certificates.create` - for a specified Certifiate authority or have a role `roles/privateca.certificateManager` + + IAM permissions: + To issue a certificate, Lemur would need permission `privateca.certificates.create` + for the specified Certifiate authority + + To revoke a certificate, Lemur would need permission `privateca.certificates.update` + for the specified Certifiate authority + + To add a Google-based CA, Lemur would need permission `privateca.certificateAuthorities.get` + + This can be achieved by assigning `roles/privateca.certificateAuthorityViewer` and ` + roles/privateca.certificateManager` to Lemur's service account, or by using a custom role. .. moduleauthor:: Oleg Dopertchouk """ From c48098cba644457911963c078d3020fb3b7d842f Mon Sep 17 00:00:00 2001 From: odopertchouk <55001692+odopertchouk@users.noreply.github.com> Date: Wed, 10 Apr 2024 12:32:51 -0400 Subject: [PATCH 06/11] Update lemur/plugins/lemur_google_ca/plugin.py Remove duplicate import Co-authored-by: Jared Crawford --- lemur/plugins/lemur_google_ca/plugin.py | 1 - 1 file changed, 1 deletion(-) diff --git a/lemur/plugins/lemur_google_ca/plugin.py b/lemur/plugins/lemur_google_ca/plugin.py index cd5c1f610e..868d7e1501 100644 --- a/lemur/plugins/lemur_google_ca/plugin.py +++ b/lemur/plugins/lemur_google_ca/plugin.py @@ -47,7 +47,6 @@ import lemur.plugins.lemur_google_ca from lemur.plugins.bases import IssuerPlugin -import google.cloud.security.privateca_v1 as privateca_v1 SECONDS_PER_YEAR = 365 * 24 * 60 * 60 From 375d1751683c5fbb01e6f3a623ccc4e494c8bce5 Mon Sep 17 00:00:00 2001 From: odopertchouk <55001692+odopertchouk@users.noreply.github.com> Date: Wed, 10 Apr 2024 12:33:14 -0400 Subject: [PATCH 07/11] Update lemur/plugins/lemur_google_ca/plugin.py Fixed a typo in a role name Co-authored-by: Jared Crawford --- lemur/plugins/lemur_google_ca/plugin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lemur/plugins/lemur_google_ca/plugin.py b/lemur/plugins/lemur_google_ca/plugin.py index 868d7e1501..bcdd81e82c 100644 --- a/lemur/plugins/lemur_google_ca/plugin.py +++ b/lemur/plugins/lemur_google_ca/plugin.py @@ -180,7 +180,7 @@ def create_authority(self, options: dict) -> tuple[str, Optional[str], str, list f"/certificateAuthorities/{plugin_options['CAName']}" ca_pem, chain_pem = fetch_authority(ca_path) - name = f"googleca_ca_name_{ca_name}_admin" + name = f"googleca_{ca_name}_admin" role = {"username": "", "password": "", "name": name} return ca_pem, "", chain_pem, [role] From b165931e86791482110f519287ec999ceb8611ad Mon Sep 17 00:00:00 2001 From: Oleg Dopertchouk Date: Wed, 10 Apr 2024 12:35:08 -0400 Subject: [PATCH 08/11] Included ca path in an error message --- lemur/plugins/lemur_google_ca/plugin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lemur/plugins/lemur_google_ca/plugin.py b/lemur/plugins/lemur_google_ca/plugin.py index bcdd81e82c..18aa2c48eb 100644 --- a/lemur/plugins/lemur_google_ca/plugin.py +++ b/lemur/plugins/lemur_google_ca/plugin.py @@ -76,7 +76,7 @@ def fetch_authority(ca_path: str) -> tuple[str, str]: client = privateca_v1.CertificateAuthorityServiceClient() resp = client.get_certificate_authority(name=ca_path) if resp.state != privateca_v1.CertificateAuthority.State.ENABLED: - raise Exception("The CA is not enabled") + raise Exception(f"The CA {ca_path} is not enabled") certs = list(resp.pem_ca_certificates) ca_pem = certs[0] ca_chain = '\n'.join(certs[1:]) From 7d608dada5efc15388e3cdf6b8c2c8708ee402fc Mon Sep 17 00:00:00 2001 From: Oleg Dopertchouk Date: Thu, 11 Apr 2024 17:37:44 -0400 Subject: [PATCH 09/11] added tests to google ca plugin --- lemur/plugins/lemur_google_ca/plugin.py | 36 ++-- .../lemur_google_ca/tests/test_google_ca.py | 177 ++++++++++++++++++ 2 files changed, 196 insertions(+), 17 deletions(-) create mode 100644 lemur/plugins/lemur_google_ca/tests/test_google_ca.py diff --git a/lemur/plugins/lemur_google_ca/plugin.py b/lemur/plugins/lemur_google_ca/plugin.py index 18aa2c48eb..bc0d082f8c 100644 --- a/lemur/plugins/lemur_google_ca/plugin.py +++ b/lemur/plugins/lemur_google_ca/plugin.py @@ -35,7 +35,7 @@ import uuid from typing import Optional -import google.cloud.security.privateca_v1 as private_ca +import google.cloud.security.privateca_v1 as privateca import arrow from flask import current_app @@ -47,7 +47,6 @@ import lemur.plugins.lemur_google_ca from lemur.plugins.bases import IssuerPlugin - SECONDS_PER_YEAR = 365 * 24 * 60 * 60 @@ -73,9 +72,9 @@ def generate_certificate_id(common_name) -> str: def fetch_authority(ca_path: str) -> tuple[str, str]: - client = privateca_v1.CertificateAuthorityServiceClient() + client = create_ca_client() resp = client.get_certificate_authority(name=ca_path) - if resp.state != privateca_v1.CertificateAuthority.State.ENABLED: + if resp.state != privateca.CertificateAuthority.State.ENABLED: raise Exception(f"The CA {ca_path} is not enabled") certs = list(resp.pem_ca_certificates) ca_pem = certs[0] @@ -83,6 +82,17 @@ def fetch_authority(ca_path: str) -> tuple[str, str]: return ca_pem, ca_chain +def create_ca_client(): + """ + Creates a client for accessing GCP API based on credentials supplied in application config. + """ + return privateca.CertificateAuthorityServiceClient( + credentials=service_account.Credentials.from_service_account_file( + current_app.config['GOOGLE_APPLICATION_CREDENTIALS'] + ) + ) + + class GoogleCaIssuerPlugin(IssuerPlugin): title = "Google CA" slug = "googleca-issuer" @@ -146,14 +156,10 @@ def create_certificate(self, csr, options) -> tuple[str, str, str]: f"/caPools/{ca_options['CAPool']}" lifetime = get_duration(options) - client = private_ca.CertificateAuthorityServiceClient( - credentials=service_account.Credentials.from_service_account_file( - current_app.config['GOOGLE_APPLICATION_CREDENTIALS'] - ) - ) - request = private_ca.CreateCertificateRequest( + client = create_ca_client() + request = privateca.CreateCertificateRequest( parent=ca_path, - certificate=private_ca.Certificate( + certificate=privateca.Certificate( pem_csr=csr, lifetime=duration_pb2.Duration(seconds=lifetime) ), @@ -200,12 +206,8 @@ def revoke_certificate(self, certificate, reason): if "crl_reason" in reason: crl_reason = CRLReason[reason["crl_reason"]] - client = private_ca.CertificateAuthorityServiceClient( - credentials=service_account.Credentials.from_service_account_file( - current_app.config['GOOGLE_APPLICATION_CREDENTIALS'] - ) - ) - request = privateca_v1.RevokeCertificateRequest( + client = create_ca_client() + request = privateca.RevokeCertificateRequest( name=ca_path, reason=crl_reason, ) diff --git a/lemur/plugins/lemur_google_ca/tests/test_google_ca.py b/lemur/plugins/lemur_google_ca/tests/test_google_ca.py new file mode 100644 index 0000000000..f5767cae31 --- /dev/null +++ b/lemur/plugins/lemur_google_ca/tests/test_google_ca.py @@ -0,0 +1,177 @@ +import json +import pytest +import unittest +from unittest.mock import MagicMock +from unittest.mock import patch + +import arrow +from flask import Flask +import google.cloud.security.privateca_v1 as privateca +from google.protobuf import duration_pb2 + +from lemur.constants import CRLReason +from lemur.plugins.lemur_google_ca import plugin + +_test_config = { + 'GOOGLE_APPLICATION_CREDENTIALS': '123' +} + + +def config_mock(key): + return _test_config[key] + + +class TestGoogleCa(unittest.TestCase): + def setUp(self): + _app = Flask('lemur_test_google_ca') + self.ctx = _app.app_context() + assert self.ctx + self.ctx.push() + + def tearDown(self): + self.ctx.pop() + + def test_create_certificate(self): + assert 1 + + def test_create_authority(self): + assert 1 + + @patch('lemur.plugins.lemur_google_ca.plugin.create_ca_client') + def test_fetch_authority_enabled(self, mock_ca_client): + # Set up mock response + mock_resp = MagicMock() + mock_resp.state = privateca.CertificateAuthority.State.ENABLED + mock_resp.pem_ca_certificates = ['ca_pem_certificate', 'ca_chain_certificate1', 'ca_chain_certificate2'] + mock_ca_client.return_value.get_certificate_authority.return_value = mock_resp + + ca_path = "projects/my-project/locations/my-location/certificateAuthorities/my-ca" + ca_pem, ca_chain = plugin.fetch_authority(ca_path) + self.assertEqual(ca_pem, 'ca_pem_certificate') + self.assertEqual(ca_chain, 'ca_chain_certificate1\nca_chain_certificate2') + + @patch('lemur.plugins.lemur_google_ca.plugin.create_ca_client') + def test_fetch_authority_not_enabled(self, mock_ca_client): + # Set up mock response + mock_resp = MagicMock() + mock_resp.state = privateca.CertificateAuthority.State.DISABLED + mock_ca_client.return_value.get_certificate_authority.return_value = mock_resp + + ca_path = "projects/my-project/locations/my-location/certificateAuthorities/my-ca" + + with pytest.raises(Exception) as exc_info: + plugin.fetch_authority(ca_path) + + self.assertEqual(str(exc_info.value), f"The CA {ca_path} is not enabled") + + @patch('lemur.plugins.lemur_google_ca.plugin.generate_certificate_id') + @patch('lemur.plugins.lemur_google_ca.plugin.current_app') + @patch('lemur.plugins.lemur_google_ca.plugin.create_ca_client') + def test_create_certificate(self, mock_ca_client, mock_current_app, mock_gen_cert_id): + mock_gen_cert_id.return_value = "dummy_cert_id" + mock_current_app.config = _test_config + + # Set up mock response from the CA client + mock_resp = MagicMock() + mock_resp.pem_certificate = "cert_pem" + mock_resp.pem_certificate_chain = ["chain_pem1", "chain_pem2"] + mock_create_certificate = mock_ca_client.return_value.create_certificate + mock_create_certificate.return_value = mock_resp + + pg = plugin.GoogleCaIssuerPlugin() + csr = "dummy_csr" + options = { + "authority": MagicMock( + plugin_name="googleca-issuer", + options=json.dumps([ + {"name": "Project", "value": "dummy_project"}, + {"name": "Location", "value": "dummy_location"}, + {"name": "CAPool", "value": "dummy_capool"}, + {"name": "CAName", "value": "dummy_caname"}, + ]) + ), + "common_name": "example.com" + } + cert_pem, chain_pem, ext_id = pg.create_certificate(csr, options) + self.assertEqual(cert_pem, "cert_pem") + self.assertEqual(chain_pem, "chain_pem1\nchain_pem2") + + expected_ca_path = f"projects/dummy_project/locations/dummy_location/caPools/dummy_capool" + expected_lifetime_seconds = 365 * 24 * 60 * 60 # Assuming 1 year in your get_duration function + + # test that we call client.create_certificate the right way + mock_create_certificate.assert_called_once_with( + privateca.CreateCertificateRequest( + parent=expected_ca_path, + certificate=privateca.Certificate( + pem_csr=csr, + lifetime=duration_pb2.Duration(seconds=expected_lifetime_seconds) + ), + certificate_id="dummy_cert_id", + # Assuming generate_certificate_id() generates a unique ID each call + issuing_certificate_authority_id="dummy_caname" + ) + ) + + @patch('lemur.plugins.lemur_google_ca.plugin.current_app') + @patch('lemur.plugins.lemur_google_ca.plugin.create_ca_client') + def test_revoke_certificate(self, mock_ca_client, mock_current_app): + mock_current_app.config = _test_config + + mock_revoke_certificate = mock_ca_client.return_value.revoke_certificate + mock_revoke_certificate.return_value = "mock_resp" + + certificate = MagicMock( + authority=MagicMock( + plugin_name="googleca-issuer", + options=json.dumps([ + {"name": "Project", "value": "dummy_project"}, + {"name": "Location", "value": "dummy_location"}, + {"name": "CAPool", "value": "dummy_capool"}, + {"name": "CAName", "value": "dummy_caname"}, + ]) + ), + external_id="dummy_external_id", + ) + + pg = plugin.GoogleCaIssuerPlugin() + resp = pg.revoke_certificate( + certificate=certificate, + reason={ + "crl_reason": "unspecified" + } + ) + + self.assertEqual(resp, "mock_resp") + mock_revoke_certificate.assert_called_once_with( + request=privateca.RevokeCertificateRequest( + name="projects/dummy_project/locations/dummy_location/caPools/dummy_capool/certificates/dummy_external_id", + reason=CRLReason.unspecified.value, + ) + ) + + @patch("lemur.plugins.lemur_google_ca.plugin.arrow.utcnow") + def test_get_duration_with_validity_end(self, mock_now): + mock_now.return_value = arrow.get(2023, 4, 10) + # Simulate options where validity_end is 10 days from now + expected_duration = 10 * 24 * 60 * 60 # 10 days in seconds + ret_duration = plugin.get_duration({ + "validity_end": arrow.get(2023, 4, 20) + }) + self.assertEqual(ret_duration, expected_duration) + + @patch("lemur.plugins.lemur_google_ca.plugin.arrow.utcnow") + def test_get_duration_with_validity_years(self, mock_now): + mock_now.return_value = arrow.get(2023, 4, 10) + # Simulate options with validity_years set to 2 + expected_duration = 2 * plugin.SECONDS_PER_YEAR + ret_duration = plugin.get_duration({"validity_years": 2}) + self.assertEqual(ret_duration, expected_duration) + + @patch("lemur.plugins.lemur_google_ca.plugin.arrow.utcnow") + def test_get_duration_defaults_to_one_year(self, mock_now): + mock_now.return_value = arrow.get(2023, 4, 10) + # Simulate options without validity_end or validity_years + expected_duration = plugin.SECONDS_PER_YEAR # Default to 1 year + ret_duration = plugin.get_duration({}) + self.assertEqual(ret_duration, expected_duration) From 7d94cbeb0ccf725038d027d64ef1a5e84a0de948 Mon Sep 17 00:00:00 2001 From: Oleg Dopertchouk Date: Thu, 11 Apr 2024 17:46:57 -0400 Subject: [PATCH 10/11] added changelog entry, fixed lint issues --- CHANGELOG.rst | 2 +- lemur/plugins/lemur_google_ca/tests/test_google_ca.py | 8 +------- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index cc532bc386..3af6187f1c 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -16,7 +16,7 @@ Added AWS ACM source plugin. This plugin retreives all certificates for an accou Added AWS ACM destination plugin. This plugin uploads a certificate to AWS ACM. Allow updating options field via authority update API. Fixed a DoS security issue affecting Windows env via the name parameter of the certificate post endpoint. - +Added Google CA issuer plugin. This plugin creates certificates viaa Google CA Manager API. 1.6.0 - `2023-10-23` ~~~~~~~~~~~~~~~~~~~~ diff --git a/lemur/plugins/lemur_google_ca/tests/test_google_ca.py b/lemur/plugins/lemur_google_ca/tests/test_google_ca.py index f5767cae31..d29b6c5301 100644 --- a/lemur/plugins/lemur_google_ca/tests/test_google_ca.py +++ b/lemur/plugins/lemur_google_ca/tests/test_google_ca.py @@ -31,12 +31,6 @@ def setUp(self): def tearDown(self): self.ctx.pop() - def test_create_certificate(self): - assert 1 - - def test_create_authority(self): - assert 1 - @patch('lemur.plugins.lemur_google_ca.plugin.create_ca_client') def test_fetch_authority_enabled(self, mock_ca_client): # Set up mock response @@ -96,7 +90,7 @@ def test_create_certificate(self, mock_ca_client, mock_current_app, mock_gen_cer self.assertEqual(cert_pem, "cert_pem") self.assertEqual(chain_pem, "chain_pem1\nchain_pem2") - expected_ca_path = f"projects/dummy_project/locations/dummy_location/caPools/dummy_capool" + expected_ca_path = "projects/dummy_project/locations/dummy_location/caPools/dummy_capool" expected_lifetime_seconds = 365 * 24 * 60 * 60 # Assuming 1 year in your get_duration function # test that we call client.create_certificate the right way From fd046fb27b011a8198245f76890517d03e86a4d7 Mon Sep 17 00:00:00 2001 From: Jared Crawford Date: Mon, 15 Apr 2024 11:32:56 -0400 Subject: [PATCH 11/11] Update CHANGELOG.rst --- CHANGELOG.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 3af6187f1c..df91238b63 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -3,6 +3,7 @@ Changelog Unreleased ~~~~~~~~~~~~~~~~~~~~ +Added Google CA issuer plugin. This plugin creates certificates via Google CA Manager API. 1.7.0 - `2024-01-17` ~~~~~~~~~~~~~~~~~~~~ @@ -16,7 +17,6 @@ Added AWS ACM source plugin. This plugin retreives all certificates for an accou Added AWS ACM destination plugin. This plugin uploads a certificate to AWS ACM. Allow updating options field via authority update API. Fixed a DoS security issue affecting Windows env via the name parameter of the certificate post endpoint. -Added Google CA issuer plugin. This plugin creates certificates viaa Google CA Manager API. 1.6.0 - `2023-10-23` ~~~~~~~~~~~~~~~~~~~~