Initial implementation
This commit is contained in:
parent
ea915282b2
commit
a04cce6044
|
@ -1441,7 +1441,7 @@ Slack
|
||||||
Adds support for slack notifications.
|
Adds support for slack notifications.
|
||||||
|
|
||||||
|
|
||||||
AWS
|
AWS (Source)
|
||||||
----
|
----
|
||||||
|
|
||||||
:Authors:
|
:Authors:
|
||||||
|
@ -1454,7 +1454,7 @@ AWS
|
||||||
Uses AWS IAM as a source of certificates to manage. Supports a multi-account deployment.
|
Uses AWS IAM as a source of certificates to manage. Supports a multi-account deployment.
|
||||||
|
|
||||||
|
|
||||||
AWS
|
AWS (Destination)
|
||||||
----
|
----
|
||||||
|
|
||||||
:Authors:
|
:Authors:
|
||||||
|
@ -1467,6 +1467,19 @@ AWS
|
||||||
Uses AWS IAM as a destination for Lemur generated certificates. Support a multi-account deployment.
|
Uses AWS IAM as a destination for Lemur generated certificates. Support a multi-account deployment.
|
||||||
|
|
||||||
|
|
||||||
|
AWS (Notification)
|
||||||
|
-----
|
||||||
|
|
||||||
|
:Authors:
|
||||||
|
Jasmine Schladen <jschladen@netflix.com>
|
||||||
|
:Type:
|
||||||
|
Notification
|
||||||
|
:Description:
|
||||||
|
Adds support for SNS notifications. SNS notifications (like other notification plugins) are currently only supported
|
||||||
|
for certificate expiration. Configuration requires a region, account number, and SNS topic name; these elements
|
||||||
|
are then combined to build the topic ARN. Lemur must have access to publish messages to the specified SNS topic.
|
||||||
|
|
||||||
|
|
||||||
Kubernetes
|
Kubernetes
|
||||||
----------
|
----------
|
||||||
|
|
||||||
|
|
|
@ -215,18 +215,21 @@ Notification
|
||||||
------------
|
------------
|
||||||
|
|
||||||
Lemur includes the ability to create Email notifications by **default**. These notifications
|
Lemur includes the ability to create Email notifications by **default**. These notifications
|
||||||
currently come in the form of expiration notices. Lemur periodically checks certifications expiration dates and
|
currently come in the form of expiration and rotation notices. Lemur periodically checks certifications expiration dates and
|
||||||
determines if a given certificate is eligible for notification. There are currently only two parameters used to
|
determines if a given certificate is eligible for notification. There are currently only two parameters used to
|
||||||
determine if a certificate is eligible; validity expiration (date the certificate is no longer valid) and the number
|
determine if a certificate is eligible; validity expiration (date the certificate is no longer valid) and the number
|
||||||
of days the current date (UTC) is from that expiration date.
|
of days the current date (UTC) is from that expiration date.
|
||||||
|
|
||||||
There are currently two objects that available for notification plugins the first is `NotficationPlugin`. This is the base object for
|
Expiration notifications can also be configured for Slack or AWS SNS. Rotation notifications are not configurable.
|
||||||
any notification within Lemur. Currently the only support notification type is an certificate expiration notification. If you
|
Notifications sent to a certificate owner and security team (`LEMUR_SECURITY_TEAM_EMAIL`) can currently only be sent via email.
|
||||||
|
|
||||||
|
There are currently two objects that available for notification plugins. The first is `NotificationPlugin`, which is the base object for
|
||||||
|
any notification within Lemur. Currently the only supported notification type is an certificate expiration notification. If you
|
||||||
are trying to create a new notification type (audit, failed logins, etc.) this would be the object to base your plugin on.
|
are trying to create a new notification type (audit, failed logins, etc.) this would be the object to base your plugin on.
|
||||||
You would also then need to build additional code to trigger the new notification type.
|
You would also then need to build additional code to trigger the new notification type.
|
||||||
|
|
||||||
The second is `ExpirationNotificationPlugin`, this object inherits from `NotificationPlugin` object.
|
The second is `ExpirationNotificationPlugin`, which inherits from `NotificationPlugin` object.
|
||||||
You will most likely want to base your plugin on, if you want to add new channels for expiration notices (Slack, HipChat, Jira, etc.). It adds default options that are required by
|
You will most likely want to base your plugin on this object if you want to add new channels for expiration notices (HipChat, Jira, etc.). It adds default options that are required by
|
||||||
all expiration notifications (interval, unit). This interface expects for the child to define the following function::
|
all expiration notifications (interval, unit). This interface expects for the child to define the following function::
|
||||||
|
|
||||||
def send(self, notification_type, message, targets, options, **kwargs):
|
def send(self, notification_type, message, targets, options, **kwargs):
|
||||||
|
|
|
@ -29,13 +29,15 @@ from lemur.plugins.utils import get_plugin_option
|
||||||
|
|
||||||
def get_certificates(exclude=None):
|
def get_certificates(exclude=None):
|
||||||
"""
|
"""
|
||||||
Finds all certificates that are eligible for notifications.
|
Finds all certificates that are eligible for expiration notifications.
|
||||||
:param exclude:
|
:param exclude:
|
||||||
:return:
|
:return:
|
||||||
"""
|
"""
|
||||||
now = arrow.utcnow()
|
now = arrow.utcnow()
|
||||||
max = now + timedelta(days=90)
|
max = now + timedelta(days=90)
|
||||||
|
|
||||||
|
print("ALPACA: Checking for certs not after {0} with notify enabled and not expired".format(max))
|
||||||
|
|
||||||
q = (
|
q = (
|
||||||
database.db.session.query(Certificate)
|
database.db.session.query(Certificate)
|
||||||
.filter(Certificate.not_after <= max)
|
.filter(Certificate.not_after <= max)
|
||||||
|
@ -43,6 +45,8 @@ def get_certificates(exclude=None):
|
||||||
.filter(Certificate.expired == False)
|
.filter(Certificate.expired == False)
|
||||||
) # noqa
|
) # noqa
|
||||||
|
|
||||||
|
print("ALPACA: Excluding {0}".format(exclude))
|
||||||
|
|
||||||
exclude_conditions = []
|
exclude_conditions = []
|
||||||
if exclude:
|
if exclude:
|
||||||
for e in exclude:
|
for e in exclude:
|
||||||
|
@ -56,51 +60,64 @@ def get_certificates(exclude=None):
|
||||||
if needs_notification(c):
|
if needs_notification(c):
|
||||||
certs.append(c)
|
certs.append(c)
|
||||||
|
|
||||||
|
print("ALPACA: Found {0} eligible certs".format(len(certs)))
|
||||||
|
|
||||||
return certs
|
return certs
|
||||||
|
|
||||||
|
|
||||||
def get_eligible_certificates(exclude=None):
|
def get_eligible_certificates(exclude=None):
|
||||||
"""
|
"""
|
||||||
Finds all certificates that are eligible for certificate expiration.
|
Finds all certificates that are eligible for certificate expiration notification.
|
||||||
|
Returns the set of all eligible certificates, grouped by owner, with a list of applicable notifications.
|
||||||
:param exclude:
|
:param exclude:
|
||||||
:return:
|
:return:
|
||||||
"""
|
"""
|
||||||
certificates = defaultdict(dict)
|
certificates = defaultdict(dict)
|
||||||
certs = get_certificates(exclude=exclude)
|
certs = get_certificates(exclude=exclude)
|
||||||
|
|
||||||
|
print("ALPACA: Found {0} certificates to check for notifications".format(len(certs)))
|
||||||
|
|
||||||
# group by owner
|
# group by owner
|
||||||
for owner, items in groupby(certs, lambda x: x.owner):
|
for owner, items in groupby(certs, lambda x: x.owner):
|
||||||
notification_groups = []
|
notification_groups = []
|
||||||
|
|
||||||
for certificate in items:
|
for certificate in items:
|
||||||
notifications = needs_notification(certificate)
|
notifications = needs_notification(certificate)
|
||||||
|
print("ALPACA: Considering sending {0} notifications for cert {1}".format(len(notifications), certificate))
|
||||||
|
|
||||||
if notifications:
|
if notifications:
|
||||||
for notification in notifications:
|
for notification in notifications:
|
||||||
|
print("ALPACA: Will send notification {0} for certificate {1}".format(notification, certificate))
|
||||||
notification_groups.append((notification, certificate))
|
notification_groups.append((notification, certificate))
|
||||||
|
|
||||||
# group by notification
|
# group by notification
|
||||||
for notification, items in groupby(notification_groups, lambda x: x[0].label):
|
for notification, items in groupby(notification_groups, lambda x: x[0].label):
|
||||||
certificates[owner][notification] = list(items)
|
certificates[owner][notification] = list(items)
|
||||||
|
|
||||||
|
print("ALPACA: Certificates that need notifications: {0}".format(certificates))
|
||||||
|
|
||||||
return certificates
|
return certificates
|
||||||
|
|
||||||
|
|
||||||
def send_notification(event_type, data, targets, notification):
|
def send_plugin_notification(event_type, data, recipients, notification):
|
||||||
"""
|
"""
|
||||||
Executes the plugin and handles failure.
|
Executes the plugin and handles failure.
|
||||||
|
|
||||||
:param event_type:
|
:param event_type:
|
||||||
:param data:
|
:param data:
|
||||||
:param targets:
|
:param recipients:
|
||||||
:param notification:
|
:param notification:
|
||||||
:return:
|
:return:
|
||||||
"""
|
"""
|
||||||
status = FAILURE_METRIC_STATUS
|
status = FAILURE_METRIC_STATUS
|
||||||
try:
|
try:
|
||||||
notification.plugin.send(event_type, data, targets, notification.options)
|
print("ALPACA: Trying to send notification {0} (plugin: {1})".format(notification, notification.plugin))
|
||||||
|
notification.plugin.send(event_type, data, recipients, notification.options)
|
||||||
status = SUCCESS_METRIC_STATUS
|
status = SUCCESS_METRIC_STATUS
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
current_app.logger.error(
|
||||||
|
"Unable to send notification {}.".format(notification), exc_info=True
|
||||||
|
)
|
||||||
sentry.captureException()
|
sentry.captureException()
|
||||||
|
|
||||||
metrics.send(
|
metrics.send(
|
||||||
|
@ -140,36 +157,31 @@ def send_expiration_notifications(exclude):
|
||||||
notification_data.append(cert_data)
|
notification_data.append(cert_data)
|
||||||
security_data.append(cert_data)
|
security_data.append(cert_data)
|
||||||
|
|
||||||
if send_notification(
|
print("ALPACA: Sending owner notification to {0} for certificate {1}. Data: {2}".format(owner, certificates, notification_data))
|
||||||
"expiration", notification_data, [owner], notification
|
|
||||||
|
if send_default_notification(
|
||||||
|
"expiration", notification_data, [owner], notification.options
|
||||||
):
|
):
|
||||||
success += 1
|
success += 1
|
||||||
else:
|
else:
|
||||||
failure += 1
|
failure += 1
|
||||||
|
|
||||||
notification_recipient = get_plugin_option(
|
recipients = notification.plugin.filter_recipients(security_email + [owner], notification.options)
|
||||||
"recipients", notification.options
|
|
||||||
)
|
|
||||||
if notification_recipient:
|
|
||||||
notification_recipient = notification_recipient.split(",")
|
|
||||||
# removing owner and security_email from notification_recipient
|
|
||||||
notification_recipient = [i for i in notification_recipient if i not in security_email and i != owner]
|
|
||||||
|
|
||||||
if (
|
print("ALPACA: Sending plugin notification {0} for certificate {1} to recipients {2}".format(notification, certificates, recipients))
|
||||||
notification_recipient
|
if send_plugin_notification(
|
||||||
):
|
|
||||||
if send_notification(
|
|
||||||
"expiration",
|
"expiration",
|
||||||
notification_data,
|
notification_data,
|
||||||
notification_recipient,
|
recipients,
|
||||||
notification,
|
notification,
|
||||||
):
|
):
|
||||||
success += 1
|
success += 1
|
||||||
else:
|
else:
|
||||||
failure += 1
|
failure += 1
|
||||||
|
|
||||||
if send_notification(
|
print("ALPACA: Sending security notification to {0}".format(security_email))
|
||||||
"expiration", security_data, security_email, notification
|
if send_default_notification(
|
||||||
|
"expiration", security_data, security_email, notification.options
|
||||||
):
|
):
|
||||||
success += 1
|
success += 1
|
||||||
else:
|
else:
|
||||||
|
@ -178,29 +190,29 @@ def send_expiration_notifications(exclude):
|
||||||
return success, failure
|
return success, failure
|
||||||
|
|
||||||
|
|
||||||
def send_rotation_notification(certificate, notification_plugin=None):
|
def send_default_notification(notification_type, data, targets, notification_options=None):
|
||||||
"""
|
"""
|
||||||
Sends a report to certificate owners when their certificate has been
|
Sends a report to the specified target via the default notification plugin. Applicable for any notification_type.
|
||||||
rotated.
|
At present, "default" means email, as the other notification plugins do not support dynamically configured targets.
|
||||||
|
|
||||||
:param certificate:
|
:param notification_type:
|
||||||
:param notification_plugin:
|
:param data:
|
||||||
|
:param targets:
|
||||||
|
:param notification_options:
|
||||||
:return:
|
:return:
|
||||||
"""
|
"""
|
||||||
status = FAILURE_METRIC_STATUS
|
status = FAILURE_METRIC_STATUS
|
||||||
if not notification_plugin:
|
|
||||||
notification_plugin = plugins.get(
|
notification_plugin = plugins.get(
|
||||||
current_app.config.get("LEMUR_DEFAULT_NOTIFICATION_PLUGIN")
|
current_app.config.get("LEMUR_DEFAULT_NOTIFICATION_PLUGIN", "email-notification")
|
||||||
)
|
)
|
||||||
|
|
||||||
data = certificate_notification_output_schema.dump(certificate).data
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
notification_plugin.send("rotation", data, [data["owner"]])
|
# we need the notification.options here because the email templates utilize the interval/unit info
|
||||||
|
notification_plugin.send(notification_type, data, targets, notification_options)
|
||||||
status = SUCCESS_METRIC_STATUS
|
status = SUCCESS_METRIC_STATUS
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
current_app.logger.error(
|
current_app.logger.error(
|
||||||
"Unable to send notification to {}.".format(data["owner"]), exc_info=True
|
"Unable to send notification to {}.".format(targets), exc_info=True
|
||||||
)
|
)
|
||||||
sentry.captureException()
|
sentry.captureException()
|
||||||
|
|
||||||
|
@ -208,77 +220,49 @@ def send_rotation_notification(certificate, notification_plugin=None):
|
||||||
"notification",
|
"notification",
|
||||||
"counter",
|
"counter",
|
||||||
1,
|
1,
|
||||||
metric_tags={"status": status, "event_type": "rotation"},
|
metric_tags={"status": status, "event_type": notification_type},
|
||||||
)
|
)
|
||||||
|
|
||||||
if status == SUCCESS_METRIC_STATUS:
|
if status == SUCCESS_METRIC_STATUS:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def send_rotation_notification(certificate):
|
||||||
|
data = certificate_notification_output_schema.dump(certificate).data
|
||||||
|
return send_default_notification("rotation", data, [data["owner"]])
|
||||||
|
|
||||||
|
|
||||||
def send_pending_failure_notification(
|
def send_pending_failure_notification(
|
||||||
pending_cert, notify_owner=True, notify_security=True, notification_plugin=None
|
pending_cert, notify_owner=True, notify_security=True
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Sends a report to certificate owners when their pending certificate failed to be created.
|
Sends a report to certificate owners when their pending certificate failed to be created.
|
||||||
|
|
||||||
:param pending_cert:
|
:param pending_cert:
|
||||||
:param notification_plugin:
|
:param notify_owner:
|
||||||
|
:param notify_security:
|
||||||
:return:
|
:return:
|
||||||
"""
|
"""
|
||||||
status = FAILURE_METRIC_STATUS
|
|
||||||
|
|
||||||
if not notification_plugin:
|
|
||||||
notification_plugin = plugins.get(
|
|
||||||
current_app.config.get(
|
|
||||||
"LEMUR_DEFAULT_NOTIFICATION_PLUGIN", "email-notification"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
data = pending_certificate_output_schema.dump(pending_cert).data
|
data = pending_certificate_output_schema.dump(pending_cert).data
|
||||||
data["security_email"] = current_app.config.get("LEMUR_SECURITY_TEAM_EMAIL")
|
data["security_email"] = current_app.config.get("LEMUR_SECURITY_TEAM_EMAIL")
|
||||||
|
|
||||||
|
notify_owner_success = False
|
||||||
if notify_owner:
|
if notify_owner:
|
||||||
try:
|
notify_owner_success = send_default_notification("failed", data, [data["owner"]], pending_cert)
|
||||||
notification_plugin.send("failed", data, [data["owner"]], pending_cert)
|
|
||||||
status = SUCCESS_METRIC_STATUS
|
|
||||||
except Exception as e:
|
|
||||||
current_app.logger.error(
|
|
||||||
"Unable to send pending failure notification to {}.".format(
|
|
||||||
data["owner"]
|
|
||||||
),
|
|
||||||
exc_info=True,
|
|
||||||
)
|
|
||||||
sentry.captureException()
|
|
||||||
|
|
||||||
|
notify_security_success = False
|
||||||
if notify_security:
|
if notify_security:
|
||||||
try:
|
notify_security_success = send_default_notification("failed", data, data["security_email"], pending_cert)
|
||||||
notification_plugin.send(
|
|
||||||
"failed", data, data["security_email"], pending_cert
|
|
||||||
)
|
|
||||||
status = SUCCESS_METRIC_STATUS
|
|
||||||
except Exception as e:
|
|
||||||
current_app.logger.error(
|
|
||||||
"Unable to send pending failure notification to "
|
|
||||||
"{}.".format(data["security_email"]),
|
|
||||||
exc_info=True,
|
|
||||||
)
|
|
||||||
sentry.captureException()
|
|
||||||
|
|
||||||
metrics.send(
|
return notify_owner_success or notify_security_success
|
||||||
"notification",
|
|
||||||
"counter",
|
|
||||||
1,
|
|
||||||
metric_tags={"status": status, "event_type": "rotation"},
|
|
||||||
)
|
|
||||||
|
|
||||||
if status == SUCCESS_METRIC_STATUS:
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def needs_notification(certificate):
|
def needs_notification(certificate):
|
||||||
"""
|
"""
|
||||||
Determine if notifications for a given certificate should
|
Determine if notifications for a given certificate should currently be sent.
|
||||||
currently be sent
|
For each notification configured for the cert, verifies it is active, properly configured,
|
||||||
|
and that the configured expiration period is currently met.
|
||||||
|
|
||||||
:param certificate:
|
:param certificate:
|
||||||
:return:
|
:return:
|
||||||
|
@ -288,9 +272,13 @@ def needs_notification(certificate):
|
||||||
|
|
||||||
notifications = []
|
notifications = []
|
||||||
|
|
||||||
|
print("ALPACA: Considering if cert {0} needs notifications".format(certificate))
|
||||||
|
print("ALPACA: Notifications for {0}: {1}".format(certificate, certificate.notifications))
|
||||||
|
|
||||||
for notification in certificate.notifications:
|
for notification in certificate.notifications:
|
||||||
|
print("ALPACA: Considering if cert {0} needs notification {1}".format(certificate, notification))
|
||||||
if not notification.active or not notification.options:
|
if not notification.active or not notification.options:
|
||||||
return
|
continue
|
||||||
|
|
||||||
interval = get_plugin_option("interval", notification.options)
|
interval = get_plugin_option("interval", notification.options)
|
||||||
unit = get_plugin_option("unit", notification.options)
|
unit = get_plugin_option("unit", notification.options)
|
||||||
|
@ -309,6 +297,8 @@ def needs_notification(certificate):
|
||||||
"Invalid base unit for expiration interval: {0}".format(unit)
|
"Invalid base unit for expiration interval: {0}".format(unit)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
print("ALPACA: Considering if cert {0} is applicable for notification {1}: {2} days remaining, configured as "
|
||||||
|
"{3} days".format(certificate, notification, days, interval))
|
||||||
if days == interval:
|
if days == interval:
|
||||||
notifications.append(notification)
|
notifications.append(notification)
|
||||||
return notifications
|
return notifications
|
||||||
|
|
|
@ -20,6 +20,15 @@ class NotificationPlugin(Plugin):
|
||||||
def send(self, notification_type, message, targets, options, **kwargs):
|
def send(self, notification_type, message, targets, options, **kwargs):
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def filter_recipients(self, options, excluded_recipients):
|
||||||
|
"""
|
||||||
|
Given a set of options (which should include configured recipient info), filters out recipients that
|
||||||
|
we do NOT want to notify.
|
||||||
|
|
||||||
|
For any notification types where recipients can't be dynamically modified, this returns an empty list.
|
||||||
|
"""
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
class ExpirationNotificationPlugin(NotificationPlugin):
|
class ExpirationNotificationPlugin(NotificationPlugin):
|
||||||
"""
|
"""
|
||||||
|
@ -50,5 +59,5 @@ class ExpirationNotificationPlugin(NotificationPlugin):
|
||||||
def options(self):
|
def options(self):
|
||||||
return self.default_options + self.additional_options
|
return self.default_options + self.additional_options
|
||||||
|
|
||||||
def send(self, notification_type, message, targets, options, **kwargs):
|
def send(self, notification_type, message, excluded_targets, options, **kwargs):
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
|
@ -32,13 +32,14 @@
|
||||||
.. moduleauthor:: Mikhail Khodorovskiy <mikhail.khodorovskiy@jivesoftware.com>
|
.. moduleauthor:: Mikhail Khodorovskiy <mikhail.khodorovskiy@jivesoftware.com>
|
||||||
.. moduleauthor:: Harm Weites <harm@weites.com>
|
.. moduleauthor:: Harm Weites <harm@weites.com>
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from acme.errors import ClientError
|
from acme.errors import ClientError
|
||||||
from flask import current_app
|
from flask import current_app
|
||||||
from lemur.extensions import sentry, metrics
|
|
||||||
|
|
||||||
from lemur.plugins import lemur_aws as aws
|
from lemur.extensions import sentry, metrics
|
||||||
|
from lemur.plugins import lemur_aws as aws, ExpirationNotificationPlugin
|
||||||
from lemur.plugins.bases import DestinationPlugin, ExportDestinationPlugin, SourcePlugin
|
from lemur.plugins.bases import DestinationPlugin, ExportDestinationPlugin, SourcePlugin
|
||||||
from lemur.plugins.lemur_aws import iam, s3, elb, ec2
|
from lemur.plugins.lemur_aws import iam, s3, elb, ec2, sns
|
||||||
|
|
||||||
|
|
||||||
def get_region_from_dns(dns):
|
def get_region_from_dns(dns):
|
||||||
|
@ -406,3 +407,55 @@ class S3DestinationPlugin(ExportDestinationPlugin):
|
||||||
self.get_option("encrypt", options),
|
self.get_option("encrypt", options),
|
||||||
account_number=self.get_option("accountNumber", options),
|
account_number=self.get_option("accountNumber", options),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SNSNotificationPlugin(ExpirationNotificationPlugin):
|
||||||
|
title = "AWS SNS"
|
||||||
|
slug = "aws-sns"
|
||||||
|
description = "Sends notifications to AWS SNS"
|
||||||
|
version = aws.VERSION
|
||||||
|
|
||||||
|
author = "Jasmine Schladen <jschladen@netflix.com>"
|
||||||
|
author_url = "https://github.com/Netflix/lemur"
|
||||||
|
|
||||||
|
additional_options = [
|
||||||
|
{
|
||||||
|
"name": "accountNumber",
|
||||||
|
"type": "str",
|
||||||
|
"required": True,
|
||||||
|
"validation": "[0-9]{12}",
|
||||||
|
"helpMessage": "A valid AWS account number with permission to access the SNS topic",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "region",
|
||||||
|
"type": "str",
|
||||||
|
"required": True,
|
||||||
|
"validation": "[0-9a-z\\-]{1,25}",
|
||||||
|
"helpMessage": "Region in which the SNS topic is located, e.g. \"us-east-1\"",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Topic Name",
|
||||||
|
"type": "str",
|
||||||
|
"required": True,
|
||||||
|
# base topic name is 1-256 characters (alphanumeric plus underscore and hyphen)
|
||||||
|
"validation": "^[a-zA-Z0-9_\\-]{1,256}$",
|
||||||
|
"helpMessage": "The name of the topic to use for expiration notifications",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
def send(self, notification_type, message, excluded_targets, options, **kwargs):
|
||||||
|
"""
|
||||||
|
While we receive a `targets` parameter here, it is unused, as the SNS topic is pre-configured in the
|
||||||
|
plugin configuration, and can't reasonably be changed dynamically.
|
||||||
|
"""
|
||||||
|
|
||||||
|
topic_arn = "arn:aws:sns:{0}:{1}:{2}".format(self.get_option("region", options),
|
||||||
|
self.get_option("accountNumber", options),
|
||||||
|
self.get_option("Topic Name", options))
|
||||||
|
|
||||||
|
current_app.logger.debug("Publishing {0} notification to topic {1}".format(notification_type, topic_arn))
|
||||||
|
print("ALPACA: Trying to send {0} SNS notification to topic {1}".format(notification_type, topic_arn))
|
||||||
|
try:
|
||||||
|
sns.publish(topic_arn, message, notification_type, region_name=self.get_option("region", options))
|
||||||
|
except Exception:
|
||||||
|
current_app.logger.exception("Error publishing {0} notification to topic {1}".format(notification_type, topic_arn))
|
|
@ -0,0 +1,56 @@
|
||||||
|
"""
|
||||||
|
.. module: lemur.plugins.lemur_aws.sts
|
||||||
|
:platform: Unix
|
||||||
|
:copyright: (c) 2020 by Netflix Inc., see AUTHORS for more
|
||||||
|
:license: Apache, see LICENSE for more details.
|
||||||
|
.. moduleauthor:: Kevin Glisson <kglisson@netflix.com>
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
|
||||||
|
import arrow
|
||||||
|
import boto3
|
||||||
|
from flask import current_app
|
||||||
|
|
||||||
|
|
||||||
|
def publish(topic_arn, certificates, notification_type, **kwargs):
|
||||||
|
sns_client = boto3.client("sns", **kwargs)
|
||||||
|
print("ALPACA: SNS client: {0}, certificates: {1}".format(sns_client, certificates))
|
||||||
|
message_ids = {}
|
||||||
|
for certificate in certificates:
|
||||||
|
message_ids[certificate["name"]] = publish_single(sns_client, topic_arn, certificate, notification_type)
|
||||||
|
|
||||||
|
return message_ids
|
||||||
|
|
||||||
|
|
||||||
|
def publish_single(sns_client, topic_arn, certificate, notification_type):
|
||||||
|
response = sns_client.publish(
|
||||||
|
TopicArn=topic_arn,
|
||||||
|
Message=format_message(certificate, notification_type),
|
||||||
|
)
|
||||||
|
|
||||||
|
response_code = response["ResponseMetadata"]["HTTPStatusCode"]
|
||||||
|
if response_code != 200:
|
||||||
|
raise Exception("Failed to publish notification to SNS, response code was {}".format(response_code))
|
||||||
|
|
||||||
|
current_app.logger.debug(
|
||||||
|
"AWS SNS message published to topic [{0}]: [{1}]".format(topic_arn, response)
|
||||||
|
)
|
||||||
|
|
||||||
|
return response["MessageId"]
|
||||||
|
|
||||||
|
|
||||||
|
def create_certificate_url(name):
|
||||||
|
return "https://{hostname}/#/certificates/{name}".format(
|
||||||
|
hostname=current_app.config.get("LEMUR_HOSTNAME"), name=name
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def format_message(certificate, notification_type):
|
||||||
|
json_message = {
|
||||||
|
"notification_type": notification_type,
|
||||||
|
"certificate_name": certificate["name"],
|
||||||
|
"expires": arrow.get(certificate["validityEnd"]).format("dddd, MMMM D, YYYY"),
|
||||||
|
"endpoints_detected": len(certificate["endpoints"]),
|
||||||
|
"details": create_certificate_url(certificate["name"])
|
||||||
|
}
|
||||||
|
return json.dumps(json_message)
|
|
@ -0,0 +1,50 @@
|
||||||
|
from moto import mock_sts, mock_sns, mock_sqs
|
||||||
|
import boto3
|
||||||
|
import json
|
||||||
|
|
||||||
|
import arrow
|
||||||
|
from lemur.plugins.lemur_aws.sns import format_message
|
||||||
|
from lemur.plugins.lemur_aws.sns import publish
|
||||||
|
from lemur.certificates.schemas import certificate_notification_output_schema
|
||||||
|
|
||||||
|
@mock_sns()
|
||||||
|
def test_format(certificate, endpoint):
|
||||||
|
|
||||||
|
data = [certificate_notification_output_schema.dump(certificate).data]
|
||||||
|
|
||||||
|
for certificate in data:
|
||||||
|
expected_message = {
|
||||||
|
"notification_type": "expiration",
|
||||||
|
"certificate_name": certificate["name"],
|
||||||
|
"expires": arrow.get(certificate["validityEnd"]).format("dddd, MMMM D, YYYY"),
|
||||||
|
"endpoints_detected": 0,
|
||||||
|
"details": "https://lemur.example.com/#/certificates/{name}".format(name=certificate["name"])
|
||||||
|
}
|
||||||
|
assert expected_message == json.loads(format_message(certificate, "expiration"))
|
||||||
|
|
||||||
|
|
||||||
|
@mock_sns()
|
||||||
|
@mock_sqs()
|
||||||
|
def test_publish(certificate, endpoint):
|
||||||
|
|
||||||
|
data = [certificate_notification_output_schema.dump(certificate).data]
|
||||||
|
|
||||||
|
sns_client = boto3.client("sns", region_name="us-east-1")
|
||||||
|
topic_arn = sns_client.create_topic(Name='lemursnstest')["TopicArn"]
|
||||||
|
|
||||||
|
sqs_client = boto3.client("sqs", region_name="us-east-1")
|
||||||
|
queue = sqs_client.create_queue(QueueName="lemursnstestqueue")
|
||||||
|
queue_url = queue["QueueUrl"]
|
||||||
|
queue_arn = sqs_client.get_queue_attributes(QueueUrl=queue_url)["Attributes"]["QueueArn"]
|
||||||
|
sns_client.subscribe(TopicArn=topic_arn, Protocol="sqs", Endpoint=queue_arn)
|
||||||
|
|
||||||
|
message_ids = publish(topic_arn, data, "expiration", region_name="us-east-1")
|
||||||
|
assert len(message_ids) == len(data)
|
||||||
|
received_messages = sqs_client.receive_message(QueueUrl=queue_url)["Messages"]
|
||||||
|
|
||||||
|
print("ALPACA: Received messages = {}".format(received_messages))
|
||||||
|
|
||||||
|
for certificate in data:
|
||||||
|
expected_message_id = message_ids[certificate["name"]]
|
||||||
|
actual_message = next((m for m in received_messages if json.loads(m["Body"])["MessageId"] == expected_message_id), None)
|
||||||
|
assert json.loads(actual_message["Body"])["Message"] == format_message(certificate, "expiration")
|
|
@ -17,6 +17,7 @@ from lemur.plugins.bases import ExpirationNotificationPlugin
|
||||||
from lemur.plugins import lemur_email as email
|
from lemur.plugins import lemur_email as email
|
||||||
|
|
||||||
from lemur.plugins.lemur_email.templates.config import env
|
from lemur.plugins.lemur_email.templates.config import env
|
||||||
|
from lemur.plugins.utils import get_plugin_option
|
||||||
|
|
||||||
|
|
||||||
def render_html(template_name, message):
|
def render_html(template_name, message):
|
||||||
|
@ -105,8 +106,23 @@ class EmailNotificationPlugin(ExpirationNotificationPlugin):
|
||||||
|
|
||||||
s_type = current_app.config.get("LEMUR_EMAIL_SENDER", "ses").lower()
|
s_type = current_app.config.get("LEMUR_EMAIL_SENDER", "ses").lower()
|
||||||
|
|
||||||
if s_type == "ses":
|
current_app.logger.info("ALPACA: Would send an email to {0} with subject \"{1}\" here".format(targets, subject))
|
||||||
send_via_ses(subject, body, targets)
|
|
||||||
|
|
||||||
elif s_type == "smtp":
|
# if s_type == "ses":
|
||||||
send_via_smtp(subject, body, targets)
|
# send_via_ses(subject, body, targets)
|
||||||
|
#
|
||||||
|
# elif s_type == "smtp":
|
||||||
|
# send_via_smtp(subject, body, targets)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def filter_recipients(options, excluded_recipients):
|
||||||
|
print("ALPACA: Getting recipients for notification {0}".format(options))
|
||||||
|
notification_recipients = get_plugin_option("recipients", options)
|
||||||
|
print(
|
||||||
|
"ALPACA: Sending certificate notifications to recipients {0}".format(notification_recipients.split(",")))
|
||||||
|
if notification_recipients:
|
||||||
|
notification_recipients = notification_recipients.split(",")
|
||||||
|
# removing owner and security_email from notification_recipient
|
||||||
|
notification_recipients = [i for i in notification_recipients if i not in excluded_recipients]
|
||||||
|
|
||||||
|
return notification_recipients
|
||||||
|
|
|
@ -34,3 +34,15 @@ def test_render(certificate, endpoint):
|
||||||
hostname="lemur.test.example.com",
|
hostname="lemur.test.example.com",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_filter_recipients(certificate, endpoint):
|
||||||
|
from lemur.plugins.lemur_email.plugin import EmailNotificationPlugin
|
||||||
|
|
||||||
|
options = [{"name": "recipients", "value": "security@netflix.com,bob@netflix.com,joe@netflix.com"}]
|
||||||
|
assert EmailNotificationPlugin.filter_recipients(options, []) == ["security@netflix.com", "bob@netflix.com",
|
||||||
|
"joe@netflix.com"]
|
||||||
|
assert EmailNotificationPlugin.filter_recipients(options, ["security@netflix.com"]) == ["bob@netflix.com",
|
||||||
|
"joe@netflix.com"]
|
||||||
|
assert EmailNotificationPlugin.filter_recipients(options, ["security@netflix.com", "bob@netflix.com",
|
||||||
|
"joe@netflix.com"]) == []
|
||||||
|
|
|
@ -119,6 +119,9 @@ class SlackNotificationPlugin(ExpirationNotificationPlugin):
|
||||||
"""
|
"""
|
||||||
A typical check can be performed using the notify command:
|
A typical check can be performed using the notify command:
|
||||||
`lemur notify`
|
`lemur notify`
|
||||||
|
|
||||||
|
While we receive a `targets` parameter here, it is unused, as Slack webhooks do not allow
|
||||||
|
dynamic re-targeting of messages. The webhook itself specifies a channel.
|
||||||
"""
|
"""
|
||||||
attachments = None
|
attachments = None
|
||||||
if notification_type == "expiration":
|
if notification_type == "expiration":
|
||||||
|
@ -142,6 +145,6 @@ class SlackNotificationPlugin(ExpirationNotificationPlugin):
|
||||||
if r.status_code not in [200]:
|
if r.status_code not in [200]:
|
||||||
raise Exception("Failed to send message")
|
raise Exception("Failed to send message")
|
||||||
|
|
||||||
current_app.logger.error(
|
current_app.logger.info(
|
||||||
"Slack response: {0} Message Body: {1}".format(r.status_code, body)
|
"Slack response: {0} Message Body: {1}".format(r.status_code, body)
|
||||||
)
|
)
|
||||||
|
|
|
@ -14,4 +14,5 @@ class TestNotificationPlugin(NotificationPlugin):
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def send(notification_type, message, targets, options, **kwargs):
|
def send(notification_type, message, targets, options, **kwargs):
|
||||||
|
print("TODO REMOVE: sending email to {}".format(targets))
|
||||||
return
|
return
|
||||||
|
|
|
@ -87,7 +87,9 @@ def test_send_expiration_notification(certificate, notification, notification_pl
|
||||||
|
|
||||||
delta = certificate.not_after - timedelta(days=10)
|
delta = certificate.not_after - timedelta(days=10)
|
||||||
with freeze_time(delta.datetime):
|
with freeze_time(delta.datetime):
|
||||||
assert send_expiration_notifications([]) == (2, 0)
|
# this will only send owner and security emails (no additional recipients),
|
||||||
|
# but it executes 3 successful send attempts
|
||||||
|
assert send_expiration_notifications([]) == (3, 0)
|
||||||
|
|
||||||
|
|
||||||
@mock_ses
|
@mock_ses
|
||||||
|
@ -103,6 +105,23 @@ def test_send_expiration_notification_with_no_notifications(
|
||||||
|
|
||||||
@mock_ses
|
@mock_ses
|
||||||
def test_send_rotation_notification(notification_plugin, certificate):
|
def test_send_rotation_notification(notification_plugin, certificate):
|
||||||
|
from lemur.tests.factories import UserFactory
|
||||||
|
from lemur.tests.factories import CertificateFactory
|
||||||
from lemur.notifications.messaging import send_rotation_notification
|
from lemur.notifications.messaging import send_rotation_notification
|
||||||
|
|
||||||
send_rotation_notification(certificate, notification_plugin=notification_plugin)
|
user = UserFactory(email="jschladen@netflix.com")
|
||||||
|
|
||||||
|
new_cert = CertificateFactory(user=user)
|
||||||
|
assert send_rotation_notification(new_cert)
|
||||||
|
|
||||||
|
|
||||||
|
@mock_ses
|
||||||
|
def test_send_pending_failure_notification(certificate, endpoint):
|
||||||
|
from lemur.tests.factories import UserFactory
|
||||||
|
from lemur.tests.factories import PendingCertificateFactory
|
||||||
|
from lemur.notifications.messaging import send_pending_failure_notification
|
||||||
|
|
||||||
|
user = UserFactory(email="jschladen@netflix.com")
|
||||||
|
|
||||||
|
pending_cert = PendingCertificateFactory(user=user)
|
||||||
|
assert send_pending_failure_notification(pending_cert)
|
||||||
|
|
1
setup.py
1
setup.py
|
@ -135,6 +135,7 @@ setup(
|
||||||
'aws_destination = lemur.plugins.lemur_aws.plugin:AWSDestinationPlugin',
|
'aws_destination = lemur.plugins.lemur_aws.plugin:AWSDestinationPlugin',
|
||||||
'aws_source = lemur.plugins.lemur_aws.plugin:AWSSourcePlugin',
|
'aws_source = lemur.plugins.lemur_aws.plugin:AWSSourcePlugin',
|
||||||
'aws_s3 = lemur.plugins.lemur_aws.plugin:S3DestinationPlugin',
|
'aws_s3 = lemur.plugins.lemur_aws.plugin:S3DestinationPlugin',
|
||||||
|
'aws_sns = lemur.plugins.lemur_aws.plugin:SNSNotificationPlugin',
|
||||||
'email_notification = lemur.plugins.lemur_email.plugin:EmailNotificationPlugin',
|
'email_notification = lemur.plugins.lemur_email.plugin:EmailNotificationPlugin',
|
||||||
'slack_notification = lemur.plugins.lemur_slack.plugin:SlackNotificationPlugin',
|
'slack_notification = lemur.plugins.lemur_slack.plugin:SlackNotificationPlugin',
|
||||||
'java_truststore_export = lemur.plugins.lemur_jks.plugin:JavaTruststoreExportPlugin',
|
'java_truststore_export = lemur.plugins.lemur_jks.plugin:JavaTruststoreExportPlugin',
|
||||||
|
|
Loading…
Reference in New Issue