Merge branch 'master' into add-ca-cert-notifications

This commit is contained in:
charhate
2020-12-02 11:44:36 -08:00
committed by GitHub
20 changed files with 511 additions and 41 deletions

View File

@ -211,7 +211,7 @@ class LdapPrincipal:
for group in lgroups:
(dn, values) = group
if type(values) == dict:
self.ldap_groups.append(values["cn"][0].decode("ascii"))
self.ldap_groups.append(values["cn"][0].decode("utf-8"))
else:
lgroups = self.ldap_client.search_s(
self.ldap_base_dn, ldap.SCOPE_SUBTREE, ldap_filter, self.ldap_attrs

View File

@ -21,6 +21,7 @@ from lemur.certificates.schemas import CertificateOutputSchema, CertificateInput
from lemur.common.utils import generate_private_key, truthiness
from lemur.destinations.models import Destination
from lemur.domains.models import Domain
from lemur.endpoints import service as endpoint_service
from lemur.extensions import metrics, sentry, signals
from lemur.models import certificate_associations
from lemur.notifications.models import Notification
@ -797,3 +798,61 @@ def reissue_certificate(certificate, replace=None, user=None):
new_cert = create(**primitives)
return new_cert
def is_attached_to_endpoint(certificate_name, endpoint_name):
"""
Find if given certificate is attached to the endpoint. Both, certificate and endpoint, are identified by name.
This method talks to elb and finds the real time information.
:param certificate_name:
:param endpoint_name:
:return: True if certificate is attached to the given endpoint, False otherwise
"""
endpoint = endpoint_service.get_by_name(endpoint_name)
attached_certificates = endpoint.source.plugin.get_endpoint_certificate_names(endpoint)
return certificate_name in attached_certificates
def remove_from_destination(certificate, destination):
"""
Remove the certificate from given destination if clean() is implemented
:param certificate:
:param destination:
:return:
"""
plugin = plugins.get(destination.plugin_name)
if not hasattr(plugin, "clean"):
info_text = f"Cannot clean certificate {certificate.name}, {destination.plugin_name} plugin does not implement 'clean()'"
current_app.logger.warning(info_text)
else:
plugin.clean(certificate=certificate, options=destination.options)
def cleanup_after_revoke(certificate):
"""
Perform the needed cleanup for a revoked certificate. This includes -
1. Disabling notification
2. Disabling auto-rotation
3. Update certificate status to 'revoked'
4. Remove from AWS
:param certificate: Certificate object to modify and update in DB
:return: None
"""
certificate.notify = False
certificate.rotation = False
certificate.status = 'revoked'
error_message = ""
for destination in list(certificate.destinations):
try:
remove_from_destination(certificate, destination)
certificate.destinations.remove(destination)
except Exception as e:
# This cleanup is the best-effort since certificate is already revoked at this point.
# We will capture the exception and move on to the next destination
sentry.captureException()
error_message = error_message + f"Failed to remove destination: {destination.label}. {str(e)}. "
database.update(certificate)
return error_message

View File

@ -19,6 +19,7 @@ from lemur.auth.permissions import AuthorityPermission, CertificatePermission
from lemur.certificates import service
from lemur.certificates.models import Certificate
from lemur.extensions import sentry
from lemur.plugins.base import plugins
from lemur.certificates.schemas import (
certificate_input_schema,
@ -888,8 +889,24 @@ class Certificates(AuthenticatedResource):
if cert.owner != data["owner"]:
service.cleanup_owner_roles_notification(cert.owner, data)
error_message = ""
# if destination is removed, cleanup the certificate from AWS
for destination in cert.destinations:
if destination not in data["destinations"]:
try:
service.remove_from_destination(cert, destination)
except Exception as e:
sentry.captureException()
# Add the removed destination back
data["destinations"].append(destination)
error_message = error_message + f"Failed to remove destination: {destination.label}. {str(e)}. "
# go ahead with DB update
cert = service.update(certificate_id, **data)
log_service.create(g.current_user, "update_cert", certificate=cert)
if error_message:
return dict(message=f"Edit Successful except -\n\n {error_message}"), 400
return cert
@validate_schema(certificate_edit_input_schema, certificate_output_schema)
@ -1433,16 +1450,24 @@ class CertificateRevoke(AuthenticatedResource):
return dict(message="Cannot revoke certificate. No external id found."), 400
if cert.endpoints:
return (
dict(
message="Cannot revoke certificate. Endpoints are deployed with the given certificate."
),
403,
)
for endpoint in cert.endpoints:
if service.is_attached_to_endpoint(cert.name, endpoint.name):
return (
dict(
message="Cannot revoke certificate. Endpoints are deployed with the given certificate."
),
403,
)
plugin = plugins.get(cert.authority.plugin_name)
plugin.revoke_certificate(cert, data)
log_service.create(g.current_user, "revoke_cert", certificate=cert)
# Perform cleanup after revoke
error_message = service.cleanup_after_revoke(cert)
if error_message:
return dict(message=f"Certificate (id:{cert.id}) is revoked - {error_message}"), 400
return dict(id=cert.id)

View File

@ -10,6 +10,7 @@ import random
import re
import string
import pem
import base64
import sqlalchemy
from cryptography import x509
@ -34,6 +35,12 @@ paginated_parser.add_argument("filter", type=str, location="args")
paginated_parser.add_argument("owner", type=str, location="args")
def base64encode(string):
# Performs Base64 encoding of string to string using the base64.b64encode() function
# which encodes bytes to bytes.
return base64.b64encode(string.encode()).decode()
def get_psuedo_random_string():
"""
Create a random and strongish challenge.

View File

@ -224,7 +224,7 @@ class AcmeHandler(object):
def revoke_certificate(self, certificate):
if not self.reuse_account(certificate.authority):
raise InvalidConfiguration("There is no ACME account saved, unable to revoke the certificate.")
acme_client, _ = self.acme.setup_acme_client(certificate.authority)
acme_client, _ = self.setup_acme_client(certificate.authority)
fullchain_com = jose.ComparableX509(
OpenSSL.crypto.load_certificate(

View File

@ -149,6 +149,38 @@ def get_listener_arn_from_endpoint(endpoint_name, endpoint_port, **kwargs):
raise
@sts_client("elbv2")
@retry(retry_on_exception=retry_throttled, wait_fixed=2000, stop_max_attempt_number=5)
def get_load_balancer_arn_from_endpoint(endpoint_name, **kwargs):
"""
Get a load balancer ARN from an endpoint.
:param endpoint_name:
:return:
"""
try:
client = kwargs.pop("client")
elbs = client.describe_load_balancers(Names=[endpoint_name])
if "LoadBalancers" in elbs and elbs["LoadBalancers"]:
return elbs["LoadBalancers"][0]["LoadBalancerArn"]
except Exception as e: # noqa
metrics.send(
"get_load_balancer_arn_from_endpoint",
"counter",
1,
metric_tags={
"error": str(e),
"endpoint_name": endpoint_name,
},
)
sentry.captureException(
extra={
"endpoint_name": str(endpoint_name),
}
)
raise
@sts_client("elb")
@retry(retry_on_exception=retry_throttled, wait_fixed=2000, stop_max_attempt_number=20)
def get_elbs(**kwargs):

View File

@ -300,6 +300,41 @@ class AWSSourcePlugin(SourcePlugin):
)
return None
def get_endpoint_certificate_names(self, endpoint):
options = endpoint.source.options
account_number = self.get_option("accountNumber", options)
region = get_region_from_dns(endpoint.dnsname)
certificate_names = []
if endpoint.type == "elb":
elb_details = elb.get_elbs(account_number=account_number,
region=region,
LoadBalancerNames=[endpoint.name],)
for lb_description in elb_details["LoadBalancerDescriptions"]:
for listener_description in lb_description["ListenerDescriptions"]:
listener = listener_description.get("Listener")
if not listener.get("SSLCertificateId"):
continue
certificate_names.append(iam.get_name_from_arn(listener.get("SSLCertificateId")))
elif endpoint.type == "elbv2":
listeners = elb.describe_listeners_v2(
account_number=account_number,
region=region,
LoadBalancerArn=elb.get_load_balancer_arn_from_endpoint(endpoint.name,
account_number=account_number,
region=region),
)
for listener in listeners["Listeners"]:
if not listener.get("Certificates"):
continue
for certificate in listener["Certificates"]:
certificate_names.append(iam.get_name_from_arn(certificate["CertificateArn"]))
return certificate_names
class AWSDestinationPlugin(DestinationPlugin):
title = "AWS"
@ -344,6 +379,10 @@ class AWSDestinationPlugin(DestinationPlugin):
def deploy(self, elb_name, account, region, certificate):
pass
def clean(self, certificate, options, **kwargs):
account_number = self.get_option("accountNumber", options)
iam.delete_cert(certificate.name, account_number=account_number)
class S3DestinationPlugin(ExportDestinationPlugin):
title = "AWS-S3"

View File

@ -1,5 +1,5 @@
import boto3
from moto import mock_sts, mock_elb
from moto import mock_sts, mock_ec2, mock_elb, mock_elbv2, mock_iam
@mock_sts()
@ -27,3 +27,107 @@ def test_get_all_elbs(app, aws_credentials):
elbs = get_all_elbs(account_number="123456789012", region="us-east-1")
assert elbs
@mock_sts()
@mock_ec2
@mock_elbv2()
@mock_iam
def test_create_elb_with_https_listener_miscellaneous(app, aws_credentials):
from lemur.plugins.lemur_aws import iam, elb
endpoint_name = "example-lbv2"
account_number = "123456789012"
region_ue1 = "us-east-1"
client = boto3.client("elbv2", region_name="us-east-1")
ec2 = boto3.resource("ec2", region_name="us-east-1")
# Create VPC
vpc = ec2.create_vpc(CidrBlock="172.28.7.0/24")
# Create LB (elbv2) in above VPC
assert create_load_balancer(client, ec2, vpc.id, endpoint_name)
# Create target group
target_group_arn = create_target_group(client, vpc.id)
assert target_group_arn
# Test get_load_balancer_arn_from_endpoint
lb_arn = elb.get_load_balancer_arn_from_endpoint(endpoint_name,
account_number=account_number,
region=region_ue1)
assert lb_arn
# Test describe_listeners_v2
listeners = elb.describe_listeners_v2(account_number=account_number,
region=region_ue1,
LoadBalancerArn=lb_arn)
assert listeners
assert not listeners["Listeners"]
# Upload cert
response = iam.upload_cert("LemurTestCert", "testCert", "cert1", "cert2",
account_number=account_number)
assert response
cert_arn = response["ServerCertificateMetadata"]["Arn"]
assert cert_arn
# Create https listener using above cert
listeners = client.create_listener(
LoadBalancerArn=lb_arn,
Protocol="HTTPS",
Port=443,
Certificates=[{"CertificateArn": cert_arn}],
DefaultActions=[{"Type": "forward", "TargetGroupArn": target_group_arn}],
)
assert listeners
listener_arn = listeners["Listeners"][0]["ListenerArn"]
assert listener_arn
assert listeners["Listeners"]
for listener in listeners["Listeners"]:
if listener["Port"] == 443:
assert listener["Certificates"]
assert cert_arn == listener["Certificates"][0]["CertificateArn"]
# Test get_listener_arn_from_endpoint
assert listener_arn == elb.get_listener_arn_from_endpoint(
endpoint_name,
443,
account_number=account_number,
region=region_ue1,
)
@mock_sts()
@mock_elb()
def test_get_all_elbs_v2():
from lemur.plugins.lemur_aws.elb import get_all_elbs_v2
elbs = get_all_elbs_v2(account_number="123456789012",
region="us-east-1")
assert elbs
def create_load_balancer(client, ec2, vpc_id, endpoint_name):
subnet1 = ec2.create_subnet(
VpcId=vpc_id,
CidrBlock="172.28.7.192/26",
AvailabilityZone="us-east-1a"
)
return client.create_load_balancer(
Name=endpoint_name,
Subnets=[
subnet1.id,
],
)
def create_target_group(client, vpc_id):
response = client.create_target_group(
Name="a-target",
Protocol="HTTPS",
Port=443,
VpcId=vpc_id,
)
return response.get("TargetGroups")[0]["TargetGroupArn"]

View File

@ -0,0 +1,4 @@
try:
VERSION = __import__("pkg_resources").get_distribution(__name__).version
except Exception as e:
VERSION = "unknown"

View File

@ -0,0 +1,184 @@
"""
.. module: lemur.plugins.lemur_azure_dest.plugin
:platform: Unix
:copyright: (c) 2019
:license: Apache, see LICENCE for more details.
Plugin for uploading certificates and private key as secret to azure key-vault
that can be pulled down by end point nodes.
.. moduleauthor:: sirferl
"""
from flask import current_app
from lemur.common.defaults import common_name, bitstrength
from lemur.common.utils import parse_certificate, parse_private_key
from lemur.plugins.bases import DestinationPlugin
from cryptography.hazmat.primitives import serialization
import requests
import json
import sys
def handle_response(my_response):
"""
Helper function for parsing responses from the Entrust API.
:param my_response:
:return: :raise Exception:
"""
msg = {
200: "The request was successful.",
400: "Keyvault Error"
}
try:
data = json.loads(my_response.content)
except ValueError:
# catch an empty jason object here
data = {'response': 'No detailed message'}
status_code = my_response.status_code
if status_code > 399:
raise Exception(f"AZURE error: {msg.get(status_code, status_code)}\n{data}")
log_data = {
"function": f"{__name__}.{sys._getframe().f_code.co_name}",
"message": "Response",
"status": status_code,
"response": data
}
current_app.logger.info(log_data)
if data == {'response': 'No detailed message'}:
# status if no data
return status_code
else:
# return data from the response
return data
def get_access_token(tenant, appID, password, self):
"""
Gets the access token with the appid and the password and returns it
Improvment option: we can try to save it and renew it only when necessary
:param tenant: Tenant used
:param appID: Application ID from Azure
:param password: password for Application ID
:return: Access token to post to the keyvault
"""
# prepare the call for the access_token
auth_url = f"https://login.microsoftonline.com/{tenant}/oauth2/token"
post_data = {
'grant_type': 'client_credentials',
'client_id': appID,
'client_secret': password,
'resource': 'https://vault.azure.net'
}
try:
response = self.session.post(auth_url, data=post_data)
except requests.exceptions.RequestException as e:
current_app.logger.exception(f"AZURE: Error for POST {e}")
access_token = json.loads(response.content)["access_token"]
return access_token
class AzureDestinationPlugin(DestinationPlugin):
"""Azure Keyvault Destination plugin for Lemur"""
title = "Azure"
slug = "azure-keyvault-destination"
description = "Allow the uploading of certificates to Azure key vault"
author = "Sirferl"
author_url = "https://github.com/sirferl/lemur"
options = [
{
"name": "vaultUrl",
"type": "str",
"required": True,
"validation": "^https?://[a-zA-Z0-9.:-]+$",
"helpMessage": "Valid URL to Azure key vault instance",
},
{
"name": "azureTenant",
"type": "str",
"required": True,
"validation": "^([a-zA-Z0-9/-/?)+$",
"helpMessage": "Tenant for the Azure Key Vault",
},
{
"name": "appID",
"type": "str",
"required": True,
"validation": "^([a-zA-Z0-9/-/?)+$",
"helpMessage": "AppID for the Azure Key Vault",
},
{
"name": "azurePassword",
"type": "str",
"required": True,
"validation": "[0-9a-zA-Z.:_-~]+",
"helpMessage": "Tenant password for the Azure Key Vault",
}
]
def __init__(self, *args, **kwargs):
self.session = requests.Session()
super(AzureDestinationPlugin, self).__init__(*args, **kwargs)
def upload(self, name, body, private_key, cert_chain, options, **kwargs):
"""
Upload certificate and private key
:param private_key:
:param cert_chain:
:return:
"""
# we use the common name to identify the certificate
# Azure does not allow "." in the certificate name we replace them with "-"
cert = parse_certificate(body)
certificate_name = common_name(cert).replace(".", "-")
vault_URI = self.get_option("vaultUrl", options)
tenant = self.get_option("azureTenant", options)
app_id = self.get_option("appID", options)
password = self.get_option("azurePassword", options)
access_token = get_access_token(tenant, app_id, password, self)
cert_url = f"{vault_URI}/certificates/{certificate_name}/import?api-version=7.1"
post_header = {
"Authorization": f"Bearer {access_token}"
}
key_pkcs8 = parse_private_key(private_key).private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption(),
)
key_pkcs8 = key_pkcs8.decode("utf-8").replace('\\n', '\n')
cert_package = f"{body}\n{key_pkcs8}"
post_body = {
"value": cert_package,
"policy": {
"key_props": {
"exportable": True,
"kty": "RSA",
"key_size": bitstrength(cert),
"reuse_key": True
},
"secret_props": {
"contentType": "application/x-pem-file"
}
}
}
try:
response = self.session.post(cert_url, headers=post_header, json=post_body)
except requests.exceptions.RequestException as e:
current_app.logger.exception(f"AZURE: Error for POST {e}")
return_value = handle_response(response)

View File

@ -0,0 +1 @@
from lemur.tests.conftest import * # noqa

View File

@ -10,7 +10,6 @@
.. moduleauthor:: Mikhail Khodorovskiy <mikhail.khodorovskiy@jivesoftware.com>
"""
import base64
import itertools
import os
@ -18,7 +17,7 @@ import requests
from flask import current_app
from lemur.common.defaults import common_name
from lemur.common.utils import parse_certificate
from lemur.common.utils import parse_certificate, base64encode
from lemur.plugins.bases import DestinationPlugin
DEFAULT_API_VERSION = "v1"
@ -73,12 +72,6 @@ def _resolve_uri(k8s_base_uri, namespace, kind, name=None, api_ver=DEFAULT_API_V
)
# Performs Base64 encoding of string to string using the base64.b64encode() function
# which encodes bytes to bytes.
def base64encode(string):
return base64.b64encode(string.encode()).decode()
def build_secret(secret_format, secret_name, body, private_key, cert_chain):
secret = {
"apiVersion": "v1",

View File

@ -33,7 +33,7 @@
<div class="col-sm-10">
<select class="form-control" ng-model="certificate.keyType"
ng-options="option for option in ['RSA2048', 'RSA4096', 'ECCPRIME256V1', 'ECCSECP384R1']"
ng-init="certificate.keyType = 'RSA2048'"></select>
ng-init="certificate.keyType = 'ECCPRIME256V1'"></select>
</div>
</div>
<div class="form-group">

View File

@ -26,9 +26,8 @@
</div>
</form>
<div ng-if="certificate.endpoints.length">
<p><strong>Certificate cannot be revoked, it is associated with the following endpoints. Disassociate this
certificate
before revoking.</strong></p>
<p><strong>Certificate might be associated with the following endpoints. Disassociate this
certificate before revoking or continue if you've already done so.</strong></p>
<ul class="list-group">
<li class="list-group-item" ng-repeat="endpoint in certificate.endpoints">
<span class="pull-right"><label class="label label-default">{{ endpoint.type }}</label></span>