From 1e748a64d796d1ee5e041a3de9f2b1f5b7c0d271 Mon Sep 17 00:00:00 2001 From: kevgliss Date: Wed, 29 Jul 2015 17:13:06 -0700 Subject: [PATCH 1/6] Initial support for notification plugins closes #8, closes #9, closes #7, closes #4, closes #16 --- lemur/__init__.py | 2 + lemur/authorities/service.py | 10 +- lemur/certificates/models.py | 19 +- lemur/certificates/service.py | 67 +-- lemur/certificates/views.py | 98 +++- lemur/destinations/views.py | 152 ++++-- lemur/manage.py | 2 + lemur/models.py | 6 + lemur/notifications.py | 218 --------- .../{templates => notifications}/__init__.py | 0 lemur/notifications/models.py | 29 ++ lemur/notifications/service.py | 254 ++++++++++ lemur/notifications/views.py | 455 ++++++++++++++++++ lemur/plugins/bases/__init__.py | 1 + lemur/plugins/bases/notification.py | 52 ++ lemur/plugins/lemur_aws/plugin.py | 2 +- lemur/plugins/lemur_email/__init__.py | 5 + lemur/plugins/lemur_email/plugin.py | 76 +++ .../plugins/lemur_email/templates/__init__.py | 0 .../lemur_email}/templates/config.py | 0 .../lemur_email/templates/expiration.html} | 6 + lemur/plugins/views.py | 46 +- .../authorities/authority/authority.js | 22 +- .../authority/authorityEdit.tpl.html | 83 ++-- .../app/angular/authorities/services.js | 19 +- .../app/angular/authorities/view/view.js | 2 +- .../certificates/certificate/certificate.js | 24 +- .../certificate/certificateWizard.tpl.html | 7 +- .../certificate/destinations.tpl.html | 34 +- .../certificate/tracking.tpl.html | 143 +++--- .../certificates/certificate/upload.js | 5 +- .../certificates/certificate/upload.tpl.html | 1 + .../app/angular/certificates/services.js | 38 +- .../app/angular/certificates/view/view.js | 20 +- .../angular/certificates/view/view.tpl.html | 135 +++--- .../destinations/destination/destination.js | 12 +- .../app/angular/destinations/services.js | 8 +- .../app/angular/destinations/view/view.js | 3 + lemur/static/app/angular/plugins/services.js | 36 +- .../app/angular/users/user/user.tpl.html | 10 +- lemur/static/app/index.html | 21 +- lemur/tests/test_notifications.py | 117 +++++ setup.py | 1 + 43 files changed, 1659 insertions(+), 582 deletions(-) delete mode 100644 lemur/notifications.py rename lemur/{templates => notifications}/__init__.py (100%) create mode 100644 lemur/notifications/models.py create mode 100644 lemur/notifications/service.py create mode 100644 lemur/notifications/views.py create mode 100644 lemur/plugins/bases/notification.py create mode 100644 lemur/plugins/lemur_email/__init__.py create mode 100644 lemur/plugins/lemur_email/plugin.py create mode 100644 lemur/plugins/lemur_email/templates/__init__.py rename lemur/{ => plugins/lemur_email}/templates/config.py (100%) rename lemur/{templates/event.html => plugins/lemur_email/templates/expiration.html} (96%) create mode 100644 lemur/tests/test_notifications.py diff --git a/lemur/__init__.py b/lemur/__init__.py index 85bb846f..43c7e0df 100644 --- a/lemur/__init__.py +++ b/lemur/__init__.py @@ -21,6 +21,7 @@ from lemur.listeners.views import mod as listeners_bp from lemur.certificates.views import mod as certificates_bp from lemur.status.views import mod as status_bp from lemur.plugins.views import mod as plugins_bp +from lemur.notifications.views import mod as notifications_bp LEMUR_BLUEPRINTS = ( users_bp, @@ -34,6 +35,7 @@ LEMUR_BLUEPRINTS = ( certificates_bp, status_bp, plugins_bp, + notifications_bp, ) diff --git a/lemur/authorities/service.py b/lemur/authorities/service.py index 9c0abb95..0c831be8 100644 --- a/lemur/authorities/service.py +++ b/lemur/authorities/service.py @@ -15,7 +15,7 @@ from lemur.authorities.models import Authority from lemur.roles import service as role_service from lemur.roles.models import Role -import lemur.certificates.service as cert_service +from lemur.certificates.models import Certificate from lemur.plugins.base import plugins @@ -42,10 +42,6 @@ def create(kwargs): """ Create a new authority. - :param name: name of the authority - :param roles: roles that are allowed to use this authority - :param options: available options for authority - :param description: :rtype : Authority :return: """ @@ -55,7 +51,9 @@ def create(kwargs): kwargs['creator'] = g.current_user.email cert_body, intermediate, issuer_roles = issuer.create_authority(kwargs) - cert = cert_service.save_cert(cert_body, None, intermediate, []) + cert = Certificate(cert_body, chain=intermediate) + cert.owner = kwargs['ownerEmail'] + cert.description = "This is the ROOT certificate for the {0} certificate authority".format(kwargs.get('caName')) cert.user = g.current_user # we create and attach any roles that the issuer gives us diff --git a/lemur/certificates/models.py b/lemur/certificates/models.py index f807e1b6..d876a3da 100644 --- a/lemur/certificates/models.py +++ b/lemur/certificates/models.py @@ -13,16 +13,17 @@ from cryptography.hazmat.backends import default_backend from flask import current_app from sqlalchemy.orm import relationship -from sqlalchemy import Integer, ForeignKey, String, DateTime, PassiveDefault, func, Column, Text, Boolean +from sqlalchemy import event, Integer, ForeignKey, String, DateTime, PassiveDefault, func, Column, Text, Boolean from sqlalchemy_utils import EncryptedType from lemur.database import db +from lemur.plugins.base import plugins from lemur.domains.models import Domain from lemur.constants import SAN_NAMING_TEMPLATE, DEFAULT_NAMING_TEMPLATE -from lemur.models import certificate_associations, certificate_destination_associations +from lemur.models import certificate_associations, certificate_destination_associations, certificate_notification_associations def create_name(issuer, not_before, not_after, subject, san): @@ -147,7 +148,7 @@ def cert_get_issuer(cert): """ delchars = ''.join(c for c in map(chr, range(256)) if not c.isalnum()) try: - issuer = str(cert.subject.get_attributes_for_oid(x509.OID_ORGANIZATION_NAME)[0].value) + issuer = str(cert.issuer.get_attributes_for_oid(x509.OID_ORGANIZATION_NAME)[0].value) return issuer.translate(None, delchars) except Exception as e: current_app.logger.error("Unable to get issuer! {0}".format(e)) @@ -203,8 +204,6 @@ class Certificate(db.Model): owner = Column(String(128)) body = Column(Text()) private_key = Column(EncryptedType(String, os.environ.get('LEMUR_ENCRYPTION_KEY'))) - challenge = Column(EncryptedType(String, os.environ.get('LEMUR_ENCRYPTION_KEY'))) # TODO deprecate - csr_config = Column(Text()) # TODO deprecate status = Column(String(128)) deleted = Column(Boolean, index=True) name = Column(String(128)) @@ -221,7 +220,8 @@ class Certificate(db.Model): date_created = Column(DateTime, PassiveDefault(func.now()), nullable=False) user_id = Column(Integer, ForeignKey('users.id')) authority_id = Column(Integer, ForeignKey('authorities.id')) - accounts = relationship("Destination", secondary=certificate_destination_associations, backref='certificate') + notifications = relationship("Notification", secondary=certificate_notification_associations, backref='certificate') + destinations = relationship("Destination", secondary=certificate_destination_associations, backref='certificate') domains = relationship("Domain", secondary=certificate_associations, backref="certificate") elb_listeners = relationship("Listener", lazy='dynamic', backref='certificate') @@ -272,3 +272,10 @@ class Certificate(db.Model): def as_dict(self): return {c.name: getattr(self, c.name) for c in self.__table__.columns} + + +@event.listens_for(Certificate.destinations, 'append') +def update_destinations(target, value, initiator): + destination_plugin = plugins.get(value.plugin_name) + + destination_plugin.upload(target.body, target.private_key, target.chain, value.options) diff --git a/lemur/certificates/service.py b/lemur/certificates/service.py index f1997344..77f2f5b3 100644 --- a/lemur/certificates/service.py +++ b/lemur/certificates/service.py @@ -15,6 +15,7 @@ from lemur.plugins.base import plugins from lemur.certificates.models import Certificate from lemur.destinations.models import Destination +from lemur.notifications.models import Notification from lemur.authorities.models import Authority from lemur.roles.models import Role @@ -75,7 +76,7 @@ def find_duplicates(cert_body): return Certificate.query.filter_by(body=cert_body).all() -def update(cert_id, owner, active): +def update(cert_id, owner, description, active, destinations, notifications): """ Updates a certificate. @@ -87,6 +88,11 @@ def update(cert_id, owner, active): cert = get(cert_id) cert.owner = owner cert.active = active + cert.description = description + + database.update_list(cert, 'notifications', Notification, notifications) + database.update_list(cert, 'destinations', Destination, destinations) + return database.update(cert) @@ -106,7 +112,8 @@ def mint(issuer_options): issuer_options['creator'] = g.user.email cert_body, cert_chain = issuer.create_certificate(csr, issuer_options) - cert = save_cert(cert_body, private_key, cert_chain, issuer_options.get('destinations')) + cert = Certificate(cert_body, private_key, cert_chain) + cert.user = g.user cert.authority = authority database.update(cert) @@ -139,43 +146,25 @@ def import_certificate(**kwargs): if kwargs.get('user'): cert.user = kwargs.get('user') - if kwargs.get('destination'): - cert.destinations.append(kwargs.get('destination')) + database.update_list(cert, 'notifications', Notification, kwargs.get('notifications')) cert = database.create(cert) return cert -def save_cert(cert_body, private_key, cert_chain, destinations): - """ - Determines if the certificate needs to be uploaded to AWS or other services. - - :param cert_body: - :param private_key: - :param cert_chain: - :param destinations: - """ - cert = Certificate(cert_body, private_key, cert_chain) - - # we should save them to any destination that is requested - for destination in destinations: - destination_plugin = plugins.get(destination['plugin']['slug']) - destination_plugin.upload(cert, private_key, cert_chain, destination['plugin']['pluginOptions']) - - return cert - - def upload(**kwargs): """ Allows for pre-made certificates to be imported into Lemur. """ - cert = save_cert( + cert = Certificate( kwargs.get('public_cert'), kwargs.get('private_key'), kwargs.get('intermediate_cert'), - kwargs.get('destinations') ) + database.update_list(cert, 'destinations', Destination, kwargs.get('destinations')) + database.update_list(cert, 'notifications', Notification, kwargs.get('notifications')) + cert.owner = kwargs['owner'] cert = database.create(cert) g.user.certificates.append(cert) @@ -189,10 +178,18 @@ def create(**kwargs): cert, private_key, cert_chain = mint(kwargs) cert.owner = kwargs['owner'] + + database.update_list(cert, 'destinations', Destination, kwargs.get('destinations')) + database.create(cert) cert.description = kwargs['description'] g.user.certificates.append(cert) database.update(g.user) + + # do this after the certificate has already been created because if it fails to upload to the third party + # we do not want to lose the certificate information. + database.update_list(cert, 'notifications', Notification, kwargs.get('notifications')) + database.update(cert) return cert @@ -207,6 +204,7 @@ def render(args): time_range = args.pop('time_range') destination_id = args.pop('destination_id') + notification_id = args.pop('notification_id', None) show = args.pop('show') # owner = args.pop('owner') # creator = args.pop('creator') # TODO we should enabling filtering by owner @@ -248,6 +246,9 @@ def render(args): if destination_id: query = query.filter(Certificate.destinations.any(Destination.id == destination_id)) + if notification_id: + query = query.filter(Certificate.notifications.any(Notification.id == notification_id)) + if time_range: to = arrow.now().replace(weeks=+time_range).format('YYYY-MM-DD') now = arrow.now().format('YYYY-MM-DD') @@ -284,11 +285,17 @@ def create_csr(csr_config): x509.BasicConstraints(ca=False, path_length=None), critical=True, ) - # for k, v in csr_config.get('extensions', {}).items(): - # if k == 'subAltNames': - # builder = builder.add_extension( - # x509.SubjectAlternativeName([x509.DNSName(n) for n in v]), critical=True, - # ) + for k, v in csr_config.get('extensions', {}).items(): + if k == 'subAltNames': + # map types to their x509 objects + general_names = [] + for name in v['names']: + if name['nameType'] == 'DNSName': + general_names.append(x509.DNSName(name['value'])) + + builder = builder.add_extension( + x509.SubjectAlternativeName(general_names), critical=True + ) # TODO support more CSR options, none of the authorities support these atm # builder.add_extension( diff --git a/lemur/certificates/views.py b/lemur/certificates/views.py index df5f3737..eac23891 100644 --- a/lemur/certificates/views.py +++ b/lemur/certificates/views.py @@ -271,7 +271,7 @@ class CertificatesList(AuthenticatedResource): """ self.reqparse.add_argument('extensions', type=dict, location='json') self.reqparse.add_argument('destinations', type=list, default=[], location='json') - self.reqparse.add_argument('elbs', type=list, location='json') + self.reqparse.add_argument('notifications', type=list, default=[], location='json') self.reqparse.add_argument('owner', type=str, location='json') self.reqparse.add_argument('validityStart', type=str, location='json') # TODO validate self.reqparse.add_argument('validityEnd', type=str, location='json') # TODO validate @@ -329,7 +329,8 @@ class CertificatesUpload(AuthenticatedResource): "publicCert": "---Begin Public...", "intermediateCert": "---Begin Public...", "privateKey": "---Begin Private..." - "destinations": [] + "destinations": [], + "notifications": [] } **Example response**: @@ -371,6 +372,7 @@ class CertificatesUpload(AuthenticatedResource): self.reqparse.add_argument('owner', type=str, required=True, location='json') self.reqparse.add_argument('publicCert', type=pem_str, required=True, dest='public_cert', location='json') self.reqparse.add_argument('destinations', type=list, default=[], dest='destinations', location='json') + self.reqparse.add_argument('notifications', type=list, default=[], dest='notifications', location='json') self.reqparse.add_argument('intermediateCert', type=pem_str, dest='intermediate_cert', location='json') self.reqparse.add_argument('privateKey', type=private_key_str, dest='private_key', location='json') @@ -523,6 +525,8 @@ class Certificates(AuthenticatedResource): { "owner": "jimbob@example.com", "active": false + "notifications": [], + "destinations": [] } **Example response**: @@ -549,7 +553,7 @@ class Certificates(AuthenticatedResource): "notBefore": "2015-06-05T17:09:39", "notAfter": "2015-06-10T17:09:39", "cn": "example.com", - "status": "unknown" + "status": "unknown", } :reqheader Authorization: OAuth token to authenticate @@ -558,6 +562,9 @@ class Certificates(AuthenticatedResource): """ self.reqparse.add_argument('active', type=bool, location='json') self.reqparse.add_argument('owner', type=str, location='json') + self.reqparse.add_argument('description', type=str, location='json') + self.reqparse.add_argument('destinations', type=list, default=[], location='json') + self.reqparse.add_argument('notifications', type=list, default=[], location='json') args = self.reqparse.parse_args() cert = service.get(certificate_id) @@ -565,13 +572,96 @@ class Certificates(AuthenticatedResource): permission = UpdateCertificatePermission(certificate_id, hasattr(role, 'id')) if permission.can(): - return service.update(certificate_id, args['owner'], args['active']) + return service.update( + certificate_id, + args['owner'], + args['description'], + args['active'], + args['destinations'], + args['notifications'] + ) return dict(message='You are not authorized to update this certificate'), 403 +class NotificationCertificatesList(AuthenticatedResource): + """ Defines the 'certificates' endpoint """ + def __init__(self): + self.reqparse = reqparse.RequestParser() + super(NotificationCertificatesList, self).__init__() + + @marshal_items(FIELDS) + def get(self, notification_id): + """ + .. http:get:: /notifications/1/certificates + + The current list of certificates for a given notification + + **Example request**: + + .. sourcecode:: http + + GET /notifications/1/certificates HTTP/1.1 + Host: example.com + Accept: application/json, text/javascript + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: text/javascript + + { + "items": [ + { + "id": 1, + "name": "cert1", + "description": "this is cert1", + "bits": 2048, + "deleted": false, + "issuer": "ExampeInc.", + "serial": "123450", + "chain": "-----Begin ...", + "body": "-----Begin ...", + "san": true, + "owner": 'bob@example.com", + "active": true, + "notBefore": "2015-06-05T17:09:39", + "notAfter": "2015-06-10T17:09:39", + "cn": "example.com", + "status": "unknown" + } + ] + "total": 1 + } + + :query sortBy: field to sort on + :query sortDir: acs or desc + :query page: int. default is 1 + :query filter: key value pair. format is k=v; + :query limit: limit number. default is 10 + :reqheader Authorization: OAuth token to authenticate + :statuscode 200: no error + :statuscode 403: unauthenticated + """ + parser = paginated_parser.copy() + parser.add_argument('timeRange', type=int, dest='time_range', location='args') + parser.add_argument('owner', type=bool, location='args') + parser.add_argument('id', type=str, location='args') + parser.add_argument('active', type=bool, location='args') + parser.add_argument('destinationId', type=int, dest="destination_id", location='args') + parser.add_argument('creator', type=str, location='args') + parser.add_argument('show', type=str, location='args') + + args = parser.parse_args() + args['notification_id'] = notification_id + return service.render(args) + api.add_resource(CertificatesList, '/certificates', endpoint='certificates') api.add_resource(Certificates, '/certificates/', endpoint='certificate') api.add_resource(CertificatesStats, '/certificates/stats', endpoint='certificateStats') api.add_resource(CertificatesUpload, '/certificates/upload', endpoint='certificateUpload') api.add_resource(CertificatePrivateKey, '/certificates//key', endpoint='privateKeyCertificates') +api.add_resource(NotificationCertificatesList, '/notifications//certificates', endpoint='notificationCertificates') diff --git a/lemur/destinations/views.py b/lemur/destinations/views.py index 5d336e4a..55ff7071 100644 --- a/lemur/destinations/views.py +++ b/lemur/destinations/views.py @@ -14,7 +14,6 @@ from lemur.auth.service import AuthenticatedResource from lemur.auth.permissions import admin_permission from lemur.common.utils import paginated_parser, marshal_items -from lemur.plugins.views import FIELDS as PLUGIN_FIELDS mod = Blueprint('destinations', __name__) api = Api(mod) @@ -22,7 +21,8 @@ api = Api(mod) FIELDS = { 'description': fields.String, - 'plugin': fields.Nested(PLUGIN_FIELDS, attribute='plugin'), + 'destinationOptions': fields.Raw(attribute='options'), + 'pluginName': fields.String(attribute='plugin_name'), 'label': fields.String, 'id': fields.Integer, } @@ -60,19 +60,23 @@ class DestinationsList(AuthenticatedResource): { "items": [ { - "id": 2, - "accountNumber": 222222222, - "label": "account2", - "comments": "this is a thing" - }, - { - "id": 1, - "accountNumber": 11111111111, - "label": "account1", - "comments": "this is a thing" - }, - ] - "total": 2 + "destinationOptions": [ + { + "name": "accountNumber", + "required": true, + "value": 111111111112, + "helpMessage": "Must be a valid AWS account number!", + "validation": "/^[0-9]{12,12}$/", + "type": "int" + } + ], + "pluginName": "aws-destination", + "id": 3, + "description": "test", + "label": "test" + } + ], + "total": 1 } :query sortBy: field to sort on @@ -104,9 +108,20 @@ class DestinationsList(AuthenticatedResource): Accept: application/json, text/javascript { - "accountNumber": 11111111111, - "label": "account1, - "comments": "this is a thing" + "destinationOptions": [ + { + "name": "accountNumber", + "required": true, + "value": 111111111112, + "helpMessage": "Must be a valid AWS account number!", + "validation": "/^[0-9]{12,12}$/", + "type": "int" + } + ], + "pluginName": "aws-destination", + "id": 3, + "description": "test", + "label": "test" } **Example response**: @@ -118,15 +133,24 @@ class DestinationsList(AuthenticatedResource): Content-Type: text/javascript { - "id": 1, - "accountNumber": 11111111111, - "label": "account1", - "comments": "this is a thing" + "destinationOptions": [ + { + "name": "accountNumber", + "required": true, + "value": 111111111112, + "helpMessage": "Must be a valid AWS account number!", + "validation": "/^[0-9]{12,12}$/", + "type": "int" + } + ], + "pluginName": "aws-destination", + "id": 3, + "description": "test", + "label": "test" } - :arg accountNumber: aws account number :arg label: human readable account label - :arg comments: some description about the account + :arg description: some description about the account :reqheader Authorization: OAuth token to authenticate :statuscode 200: no error """ @@ -167,10 +191,20 @@ class Destinations(AuthenticatedResource): Content-Type: text/javascript { - "id": 1, - "accountNumber": 11111111111, - "label": "account1", - "comments": "this is a thing" + "destinationOptions": [ + { + "name": "accountNumber", + "required": true, + "value": 111111111112, + "helpMessage": "Must be a valid AWS account number!", + "validation": "/^[0-9]{12,12}$/", + "type": "int" + } + ], + "pluginName": "aws-destination", + "id": 3, + "description": "test", + "label": "test" } :reqheader Authorization: OAuth token to authenticate @@ -194,6 +228,22 @@ class Destinations(AuthenticatedResource): Host: example.com Accept: application/json, text/javascript + { + "destinationOptions": [ + { + "name": "accountNumber", + "required": true, + "value": 111111111112, + "helpMessage": "Must be a valid AWS account number!", + "validation": "/^[0-9]{12,12}$/", + "type": "int" + } + ], + "pluginName": "aws-destination", + "id": 3, + "description": "test", + "label": "test" + } **Example response**: @@ -204,24 +254,34 @@ class Destinations(AuthenticatedResource): Content-Type: text/javascript { - "id": 1, - "accountNumber": 11111111111, - "label": "labelChanged", - "comments": "this is a thing" + "destinationOptions": [ + { + "name": "accountNumber", + "required": true, + "value": 111111111112, + "helpMessage": "Must be a valid AWS account number!", + "validation": "/^[0-9]{12,12}$/", + "type": "int" + } + ], + "pluginName": "aws-destination", + "id": 3, + "description": "test", + "label": "test" } :arg accountNumber: aws account number :arg label: human readable account label - :arg comments: some description about the account + :arg description: some description about the account :reqheader Authorization: OAuth token to authenticate :statuscode 200: no error """ self.reqparse.add_argument('label', type=str, location='json', required=True) - self.reqparse.add_argument('pluginOptions', type=dict, location='json', required=True) + self.reqparse.add_argument('plugin', type=dict, location='json', required=True) self.reqparse.add_argument('description', type=str, location='json') args = self.reqparse.parse_args() - return service.update(destination_id, args['label'], args['options'], args['description']) + return service.update(destination_id, args['label'], args['plugin']['pluginOptions'], args['description']) @admin_permission.require(http_exception=403) def delete(self, destination_id): @@ -257,6 +317,28 @@ class CertificateDestinations(AuthenticatedResource): Vary: Accept Content-Type: text/javascript + { + "items": [ + { + "destinationOptions": [ + { + "name": "accountNumber", + "required": true, + "value": 111111111112, + "helpMessage": "Must be a valid AWS account number!", + "validation": "/^[0-9]{12,12}$/", + "type": "int" + } + ], + "pluginName": "aws-destination", + "id": 3, + "description": "test", + "label": "test" + } + ], + "total": 1 + } + :query sortBy: field to sort on :query sortDir: acs or desc :query page: int. default is 1 diff --git a/lemur/manage.py b/lemur/manage.py index 3db88f17..21929980 100755 --- a/lemur/manage.py +++ b/lemur/manage.py @@ -32,6 +32,8 @@ from lemur.destinations.models import Destination # noqa from lemur.domains.models import Domain # noqa from lemur.elbs.models import ELB # noqa from lemur.listeners.models import Listener # noqa +from lemur.notifications.models import Notification # noqa + manager = Manager(create_app) manager.add_option('-c', '--config', dest='config') diff --git a/lemur/models.py b/lemur/models.py index f12e49b6..10ee07be 100644 --- a/lemur/models.py +++ b/lemur/models.py @@ -25,6 +25,12 @@ certificate_destination_associations = db.Table('certificate_destination_associa ForeignKey('certificates.id', ondelete='cascade')) ) +certificate_notification_associations = db.Table('certificate_notification_associations', + Column('notification_id', Integer, + ForeignKey('notifications.id', ondelete='cascade')), + Column('certificate_id', Integer, + ForeignKey('certificates.id', ondelete='cascade')) + ) roles_users = db.Table('roles_users', Column('user_id', Integer, ForeignKey('users.id')), Column('role_id', Integer, ForeignKey('roles.id')) diff --git a/lemur/notifications.py b/lemur/notifications.py deleted file mode 100644 index a6a47a32..00000000 --- a/lemur/notifications.py +++ /dev/null @@ -1,218 +0,0 @@ -""" -.. module: lemur.notifications - :platform: Unix - - :copyright: (c) 2015 by Netflix Inc., see AUTHORS for more - :license: Apache, see LICENSE for more details. - -.. moduleauthor:: Kevin Glisson - -""" -import ssl -import socket - -import arrow -import boto.ses - -from flask import current_app -from flask_mail import Message - -from lemur import database -from lemur.certificates.models import Certificate -from lemur.domains.models import Domain - -from lemur.templates.config import env -from lemur.extensions import smtp_mail - - -NOTIFICATION_INTERVALS = [30, 15, 5, 2] - - -def _get_domain_certificate(name): - """ - Fetch the SSL certificate currently hosted at a given domain (if any) and - compare it against our all of our know certificates to determine if a new - SSL certificate has already been deployed - - :param name: - :return: - """ - query = database.session_query(Certificate) - try: - pub_key = ssl.get_server_certificate((name, 443)) - return query.filter(Certificate.body == pub_key.strip()).first() - - except socket.gaierror as e: - current_app.logger.info(str(e)) - - -def _find_superseded(domains): - """ - Here we try to fetch any domain in the certificate to see if we can resolve it - and to try and see if it is currently serving the certificate we are - alerting on - - :param domains: - :return: - """ - query = database.session_query(Certificate) - ss_list = [] - for domain in domains: - dc = _get_domain_certificate(domain.name) - if dc: - ss_list.append(dc) - current_app.logger.info("Trying to resolve {0}".format(domain.name)) - - query = query.filter(Certificate.domains.any(Domain.name.in_([x.name for x in domains]))) - query = query.filter(Certificate.active == True) # noqa - query = query.filter(Certificate.not_after >= arrow.utcnow().format('YYYY-MM-DD')) - ss_list.extend(query.all()) - - return ss_list - - -def send_expiration_notifications(): - """ - This function will check for upcoming certificate expiration, - and send out notification emails at given intervals. - """ - notifications = 0 - certs = _get_expiring_certs() - - alerts = [] - for cert in certs: - if _is_eligible_for_notifications(cert): - data = _get_message_data(cert) - recipients = _get_message_recipients(cert) - alerts.append((data, recipients)) - - roll_ups = _create_roll_ups(alerts) - - for messages, recipients in roll_ups: - notifications += 1 - send("Certificate Expiration", dict(messages=messages), 'event', recipients) - - print notifications - current_app.logger.info("Lemur has sent {0} certification notifications".format(notifications)) - - -def _get_message_recipients(cert): - """ - Determine who the recipients of the certificate expiration should be - - :param cert: - :return: - """ - recipients = [] - if current_app.config.get('SECURITY_TEAM_EMAIL'): - recipients.extend(current_app.config.get('SECURITY_TEAM_EMAIL')) - - recipients.append(cert.owner) - - if cert.user: - recipients.append(cert.user.email) - return list(set(recipients)) - - -def _get_message_data(cert): - """ - Parse our the certification information needed for our notification - - :param cert: - :return: - """ - cert_dict = cert.as_dict() - cert_dict['domains'] = [x .name for x in cert.domains] - cert_dict['superseded'] = list(set([x.name for x in _find_superseded(cert.domains) if cert.name != x])) - return cert_dict - - -def _get_expiring_certs(outlook=30): - """ - Find all the certificates expiring within a given outlook - - :param outlook: int days to look forward - :return: - """ - now = arrow.utcnow() - - query = database.session_query(Certificate) - attr = Certificate.not_after - - # get all certs expiring in the next 30 days - to = now.replace(days=+outlook).format('YYYY-MM-DD') - - certs = [] - for cert in query.filter(attr <= to).filter(attr >= now.format('YYYY-MM-DD')).all(): - if _is_eligible_for_notifications(cert): - certs.append(cert) - return certs - - -def _is_eligible_for_notifications(cert, intervals=None): - """ - Determine if notifications for a given certificate should - currently be sent - - :param cert: - :param intervals: list of days to alert on - :return: - """ - now = arrow.utcnow() - if cert.active: - days = (cert.not_after - now.naive).days - - if not intervals: - intervals = NOTIFICATION_INTERVALS - - if days in intervals: - return cert - - -def _create_roll_ups(messages): - """ - Take all of the messages that should be sent and provide - a roll up to the same set if the recipients are the same - - :param messages: - """ - roll_ups = [] - for message_data, recipients in messages: - for m, r in roll_ups: - if r == recipients: - m.append(message_data) - current_app.logger.info( - "Sending email expiration alert about {0} to {1}".format( - message_data['name'], ",".join(recipients))) - break - else: - roll_ups.append(([message_data], recipients)) - return roll_ups - - -def send(subject, data, email_type, recipients): - """ - Configures all Lemur email messaging - - :param subject: - :param data: - :param email_type: - :param recipients: - """ - # jinja template depending on type - template = env.get_template('{}.html'.format(email_type)) - body = template.render(**data) - - s_type = current_app.config.get("LEMUR_EMAIL_SENDER").lower() - if s_type == 'ses': - conn = boto.connect_ses() - conn.send_email(current_app.config.get("LEMUR_EMAIL"), subject, body, recipients, format='html') - - elif s_type == 'smtp': - msg = Message(subject, recipients=recipients) - msg.body = "" # kinda a weird api for sending html emails - msg.html = body - smtp_mail.send(msg) - - else: - current_app.logger.error("No mail carrier specified, notification emails were not able to be sent!") diff --git a/lemur/templates/__init__.py b/lemur/notifications/__init__.py similarity index 100% rename from lemur/templates/__init__.py rename to lemur/notifications/__init__.py diff --git a/lemur/notifications/models.py b/lemur/notifications/models.py new file mode 100644 index 00000000..7318e5f3 --- /dev/null +++ b/lemur/notifications/models.py @@ -0,0 +1,29 @@ +""" +.. module: lemur.notifications.models + :platform: Unix + :copyright: (c) 2015 by Netflix Inc., see AUTHORS for more + :license: Apache, see LICENSE for more details. +.. moduleauthor:: Kevin Glisson +""" +from sqlalchemy.orm import relationship +from sqlalchemy import Integer, String, Column, Boolean, Text +from sqlalchemy_utils import JSONType + +from lemur.database import db +from lemur.plugins.base import plugins +from lemur.models import certificate_notification_associations + + +class Notification(db.Model): + __tablename__ = 'notifications' + id = Column(Integer, primary_key=True) + label = Column(String(128)) + description = Column(Text()) + options = Column(JSONType) + active = Column(Boolean, default=True) + plugin_name = Column(String(32)) + certificates = relationship("Certificate", secondary=certificate_notification_associations, passive_deletes=True, backref="notification", cascade='all,delete') + + @property + def plugin(self): + return plugins.get(self.plugin_name) diff --git a/lemur/notifications/service.py b/lemur/notifications/service.py new file mode 100644 index 00000000..517bc8ab --- /dev/null +++ b/lemur/notifications/service.py @@ -0,0 +1,254 @@ +""" +.. module: lemur.notifications + :platform: Unix + + :copyright: (c) 2015 by Netflix Inc., see AUTHORS for more + :license: Apache, see LICENSE for more details. + +.. moduleauthor:: Kevin Glisson + +""" +import ssl +import socket + +import arrow + +from flask import current_app +from lemur import database +from lemur.domains.models import Domain +from lemur.notifications.models import Notification +from lemur.certificates.models import Certificate + +from lemur.certificates import service as cert_service + +from lemur.plugins.base import plugins + + +def _get_message_data(cert): + """ + Parse our the certification information needed for our notification + + :param cert: + :return: + """ + cert_dict = cert.as_dict() + cert_dict['creator'] = cert.user.email + cert_dict['domains'] = [x .name for x in cert.domains] + cert_dict['superseded'] = list(set([x.name for x in find_superseded(cert.domains) if cert.name != x])) + return cert_dict + + +def _deduplicate(messages): + """ + Take all of the messages that should be sent and provide + a roll up to the same set if the recipients are the same + """ + roll_ups = [] + for targets, data in messages: + for m, r in roll_ups: + if r == targets: + m.append(data) + current_app.logger.info( + "Sending expiration alert about {0} to {1}".format( + data['name'], ",".join(targets))) + break + else: + roll_ups.append(([data], targets, data.plugin_options)) + return roll_ups + + +def send_expiration_notifications(): + """ + This function will check for upcoming certificate expiration, + and send out notification emails at given intervals. + """ + notifications = 0 + + for plugin_name, notifications in database.get_all(Notification, 'active', field='status').group_by(Notification.plugin_name): + notifications += 1 + + messages = _deduplicate(notifications) + plugin = plugins.get(plugin_name) + + for data, targets, options in messages: + plugin.send('expiration', data, targets, options) + + current_app.logger.info("Lemur has sent {0} certification notifications".format(notifications)) + + +def get_domain_certificate(name): + """ + Fetch the SSL certificate currently hosted at a given domain (if any) and + compare it against our all of our know certificates to determine if a new + SSL certificate has already been deployed + + :param name: + :return: + """ + try: + pub_key = ssl.get_server_certificate((name, 443)) + return cert_service.find_duplicates(pub_key.strip()) + except socket.gaierror as e: + current_app.logger.info(str(e)) + + +def find_superseded(domains): + """ + Here we try to fetch any domain in the certificate to see if we can resolve it + and to try and see if it is currently serving the certificate we are + alerting on. + + :param domains: + :return: + """ + query = database.session_query(Certificate) + ss_list = [] + for domain in domains: + dc = get_domain_certificate(domain.name) + if dc: + ss_list.append(dc) + current_app.logger.info("Trying to resolve {0}".format(domain.name)) + + query = query.filter(Certificate.domains.any(Domain.name.in_([x.name for x in domains]))) + query = query.filter(Certificate.active == True) # noqa + query = query.filter(Certificate.not_after >= arrow.utcnow().format('YYYY-MM-DD')) + ss_list.extend(query.all()) + + return ss_list + + +def _is_eligible_for_notifications(cert): + """ + Determine if notifications for a given certificate should + currently be sent + + :param cert: + :return: + """ + now = arrow.utcnow() + days = (cert.not_after - now.naive).days + + for notification in cert.notifications: + interval = notification.options['interval'] + unit = notification.options['unit'] + if unit == 'weeks': + interval *= 7 + + elif unit == 'months': + interval *= 30 + + elif unit == 'days': # it's nice to be explicit about the base unit + pass + + else: + raise Exception("Invalid base unit for expiration interval: {0}".format(unit)) + + if days == interval: + return cert + + +def create(label, plugin_name, options, description, certificates): + """ + Creates a new destination, that can then be used as a destination for certificates. + + :param label: Notification common name + :param plugin_name: + :param options: + :param description: + :rtype : Notification + :return: + """ + notification = Notification(label=label, options=options, plugin_name=plugin_name, description=description) + notification = database.update_list(notification, 'certificates', Certificate, certificates) + return database.create(notification) + + +def update(notification_id, label, options, description, certificates): + """ + Updates an existing destination. + + :param label: Notification common name + :param options: + :param description: + :rtype : Notification + :return: + """ + notification = get(notification_id) + + notification.label = label + notification.options = options + notification.description = description + notification = database.update_list(notification, 'certificates', Certificate, certificates) + + return database.update(notification) + + +def delete(notification_id): + """ + Deletes an notification. + + :param notification_id: Lemur assigned ID + """ + database.delete(get(notification_id)) + + +def get(notification_id): + """ + Retrieves an notification by it's lemur assigned ID. + + :param notification_id: Lemur assigned ID + :rtype : Notification + :return: + """ + return database.get(Notification, notification_id) + + +def get_by_label(label): + """ + Retrieves a notification by it's label + + :param label: + :return: + """ + return database.get(Notification, label, field='label') + + +def get_all(): + """ + Retrieves all notification currently known by Lemur. + + :return: + """ + query = database.session_query(Notification) + return database.find_all(query, Notification, {}).all() + + +def render(args): + sort_by = args.pop('sort_by') + sort_dir = args.pop('sort_dir') + page = args.pop('page') + count = args.pop('count') + filt = args.pop('filter') + certificate_id = args.pop('certificate_id', None) + + if certificate_id: + query = database.session_query(Notification).join(Certificate, Notification.certificate) + query = query.filter(Certificate.id == certificate_id) + else: + query = database.session_query(Notification) + + if filt: + terms = filt.split(';') + if terms[0] == 'active' and terms[1] == 'false': + query = query.filter(Notification.active == False) # noqa + elif terms[0] == 'active' and terms[1] == 'true': + query = query.filter(Notification.active == True) # noqa + else: + query = database.filter(query, Notification, terms) + + query = database.find_all(query, Notification, args) + + if sort_by and sort_dir: + query = database.sort(query, Notification, sort_by, sort_dir) + + return database.paginate(query, page, count) diff --git a/lemur/notifications/views.py b/lemur/notifications/views.py new file mode 100644 index 00000000..884cea90 --- /dev/null +++ b/lemur/notifications/views.py @@ -0,0 +1,455 @@ +""" +.. module: lemur.notifications.views + :platform: Unix + :synopsis: This module contains all of the accounts view code. + :copyright: (c) 2015 by Netflix Inc., see AUTHORS for more + :license: Apache, see LICENSE for more details. +.. moduleauthor:: Kevin Glisson +""" +from flask import Blueprint +from flask.ext.restful import Api, reqparse, fields +from lemur.notifications import service + +from lemur.auth.service import AuthenticatedResource +from lemur.common.utils import paginated_parser, marshal_items + + +mod = Blueprint('notifications', __name__) +api = Api(mod) + + +FIELDS = { + 'description': fields.String, + 'notificationOptions': fields.Raw(attribute='options'), + 'pluginName': fields.String(attribute='plugin_name'), + 'label': fields.String, + 'active': fields.Boolean, + 'id': fields.Integer, +} + + +class NotificationsList(AuthenticatedResource): + """ Defines the 'notifications' endpoint """ + def __init__(self): + self.reqparse = reqparse.RequestParser() + super(NotificationsList, self).__init__() + + @marshal_items(FIELDS) + def get(self): + """ + .. http:get:: /notifications + + The current account list + + **Example request**: + + .. sourcecode:: http + + GET /notifications HTTP/1.1 + Host: example.com + Accept: application/json, text/javascript + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: text/javascript + + { + "items": [ + { + "description": "An example", + "notificationOptions": [ + { + "name": "interval", + "required": true, + "value": 5, + "helpMessage": "Number of days to be alert before expiration.", + "validation": "^\\d+$", + "type": "int" + }, + { + "available": [ + "days", + "weeks", + "months" + ], + "name": "unit", + "required": true, + "value": "weeks", + "helpMessage": "Interval unit", + "validation": "", + "type": "select" + }, + { + "name": "recipients", + "required": true, + "value": "kglisson@netflix.com,example@netflix.com", + "helpMessage": "Comma delimited list of email addresses", + "validation": "^([\\w+-.%]+@[\\w-.]+\\.[A-Za-z]{2,4},?)+$", + "type": "str" + } + ], + "label": "example", + "pluginName": "email-notification", + "active": true, + "id": 2 + } + ], + "total": 1 + } + + :query sortBy: field to sort on + :query sortDir: acs or desc + :query page: int. default is 1 + :query filter: key value pair. format is k=v; + :query limit: limit number. default is 10 + :reqheader Authorization: OAuth token to authenticate + :statuscode 200: no error + """ + parser = paginated_parser.copy() + args = parser.parse_args() + return service.render(args) + + @marshal_items(FIELDS) + def post(self): + """ + .. http:post:: /notifications + + Creates a new account + + **Example request**: + + .. sourcecode:: http + + POST /notifications HTTP/1.1 + Host: example.com + Accept: application/json, text/javascript + + { + "description": "a test", + "notificationOptions": [ + { + "name": "interval", + "required": true, + "value": 5, + "helpMessage": "Number of days to be alert before expiration.", + "validation": "^\\d+$", + "type": "int" + }, + { + "available": [ + "days", + "weeks", + "months" + ], + "name": "unit", + "required": true, + "value": "weeks", + "helpMessage": "Interval unit", + "validation": "", + "type": "select" + }, + { + "name": "recipients", + "required": true, + "value": "kglisson@netflix.com,example@netflix.com", + "helpMessage": "Comma delimited list of email addresses", + "validation": "^([\\w+-.%]+@[\\w-.]+\\.[A-Za-z]{2,4},?)+$", + "type": "str" + } + ], + "label": "test", + "pluginName": "email-notification", + "active": true, + "id": 2 + } + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: text/javascript + + { + "description": "a test", + "notificationOptions": [ + { + "name": "interval", + "required": true, + "value": 5, + "helpMessage": "Number of days to be alert before expiration.", + "validation": "^\\d+$", + "type": "int" + }, + { + "available": [ + "days", + "weeks", + "months" + ], + "name": "unit", + "required": true, + "value": "weeks", + "helpMessage": "Interval unit", + "validation": "", + "type": "select" + }, + { + "name": "recipients", + "required": true, + "value": "kglisson@netflix.com,example@netflix.com", + "helpMessage": "Comma delimited list of email addresses", + "validation": "^([\\w+-.%]+@[\\w-.]+\\.[A-Za-z]{2,4},?)+$", + "type": "str" + } + ], + "label": "test", + "pluginName": "email-notification", + "active": true, + "id": 2 + } + + :arg accountNumber: aws account number + :arg label: human readable account label + :arg comments: some description about the account + :reqheader Authorization: OAuth token to authenticate + :statuscode 200: no error + """ + self.reqparse.add_argument('label', type=str, location='json', required=True) + self.reqparse.add_argument('plugin', type=dict, location='json', required=True) + self.reqparse.add_argument('description', type=str, location='json') + self.reqparse.add_argument('certificates', type=list, default=[], location='json') + + args = self.reqparse.parse_args() + return service.create( + args['label'], + args['plugin']['slug'], + args['plugin']['pluginOptions'], + args['description'], + args['certificates'] + ) + + +class Notifications(AuthenticatedResource): + def __init__(self): + self.reqparse = reqparse.RequestParser() + super(Notifications, self).__init__() + + @marshal_items(FIELDS) + def get(self, notification_id): + """ + .. http:get:: /notifications/1 + + Get a specific account + + **Example request**: + + .. sourcecode:: http + + GET /notifications/1 HTTP/1.1 + Host: example.com + Accept: application/json, text/javascript + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: text/javascript + + { + "description": "a test", + "notificationOptions": [ + { + "name": "interval", + "required": true, + "value": 5, + "helpMessage": "Number of days to be alert before expiration.", + "validation": "^\\d+$", + "type": "int" + }, + { + "available": [ + "days", + "weeks", + "months" + ], + "name": "unit", + "required": true, + "value": "weeks", + "helpMessage": "Interval unit", + "validation": "", + "type": "select" + }, + { + "name": "recipients", + "required": true, + "value": "kglisson@netflix.com,example@netflix.com", + "helpMessage": "Comma delimited list of email addresses", + "validation": "^([\\w+-.%]+@[\\w-.]+\\.[A-Za-z]{2,4},?)+$", + "type": "str" + } + ], + "label": "test", + "pluginName": "email-notification", + "active": true, + "id": 2 + } + + :reqheader Authorization: OAuth token to authenticate + :statuscode 200: no error + """ + return service.get(notification_id) + + @marshal_items(FIELDS) + def put(self, notification_id): + """ + .. http:put:: /notifications/1 + + Updates an account + + **Example request**: + + .. sourcecode:: http + + POST /notifications/1 HTTP/1.1 + Host: example.com + Accept: application/json, text/javascript + + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: text/javascript + + { + "id": 1, + "accountNumber": 11111111111, + "label": "labelChanged", + "comments": "this is a thing" + } + + :arg accountNumber: aws account number + :arg label: human readable account label + :arg comments: some description about the account + :reqheader Authorization: OAuth token to authenticate + :statuscode 200: no error + """ + self.reqparse.add_argument('label', type=str, location='json', required=True) + self.reqparse.add_argument('plugin', type=dict, location='json', required=True) + self.reqparse.add_argument('certificates', type=list, default=[], location='json') + self.reqparse.add_argument('description', type=str, location='json') + + args = self.reqparse.parse_args() + return service.update( + notification_id, + args['label'], + args['plugin']['pluginOptions'], + args['description'], + args['certificates'] + ) + + def delete(self, notification_id): + service.delete(notification_id) + return {'result': True} + + +class CertificateNotifications(AuthenticatedResource): + """ Defines the 'certificate/', endpoint='notification') +api.add_resource(CertificateNotifications, '/certificates//notifications', + endpoint='certificateNotifications') diff --git a/lemur/plugins/bases/__init__.py b/lemur/plugins/bases/__init__.py index 044bb213..a47b5c54 100644 --- a/lemur/plugins/bases/__init__.py +++ b/lemur/plugins/bases/__init__.py @@ -1,3 +1,4 @@ from .destination import DestinationPlugin # noqa from .issuer import IssuerPlugin # noqa from .source import SourcePlugin # noqa +from .notification import NotificationPlugin, ExpirationNotificationPlugin # noqa diff --git a/lemur/plugins/bases/notification.py b/lemur/plugins/bases/notification.py new file mode 100644 index 00000000..36e94ef3 --- /dev/null +++ b/lemur/plugins/bases/notification.py @@ -0,0 +1,52 @@ +""" +.. module: lemur.bases.notification + :platform: Unix + :copyright: (c) 2015 by Netflix Inc., see AUTHORS for more + :license: Apache, see LICENSE for more details. + +.. moduleauthor:: Kevin Glisson +""" +from lemur.plugins.base import Plugin + + +class NotificationPlugin(Plugin): + """ + This is the base class from which all of the supported + issuers will inherit from. + """ + type = 'notification' + + def send(self): + raise NotImplementedError + + +class ExpirationNotificationPlugin(NotificationPlugin): + """ + This is the base class for all expiration notification plugins. + It contains some default options that are needed for all expiration + notification plugins. + """ + default_options = [ + { + 'name': 'interval', + 'type': 'int', + 'required': True, + 'validation': '^\d+$', + 'helpMessage': 'Number of days to be alert before expiration.', + }, + { + 'name': 'unit', + 'type': 'select', + 'required': True, + 'validation': '', + 'available': ['days', 'weeks', 'months'], + 'helpMessage': 'Interval unit', + } + ] + + @property + def options(self): + return list(self.default_options) + self.additional_options + + def send(self): + raise NotImplementedError diff --git a/lemur/plugins/lemur_aws/plugin.py b/lemur/plugins/lemur_aws/plugin.py index 72304965..668e8975 100644 --- a/lemur/plugins/lemur_aws/plugin.py +++ b/lemur/plugins/lemur_aws/plugin.py @@ -61,7 +61,7 @@ class AWSSourcePlugin(SourcePlugin): options = [ { 'name': 'accountNumber', - 'type': 'int', + 'type': 'str', 'required': True, 'validation': '/^[0-9]{12,12}$/', 'helpMessage': 'Must be a valid AWS account number!', diff --git a/lemur/plugins/lemur_email/__init__.py b/lemur/plugins/lemur_email/__init__.py new file mode 100644 index 00000000..e572596e --- /dev/null +++ b/lemur/plugins/lemur_email/__init__.py @@ -0,0 +1,5 @@ +try: + VERSION = __import__('pkg_resources') \ + .get_distribution(__name__).version +except Exception, e: + VERSION = 'unknown' diff --git a/lemur/plugins/lemur_email/plugin.py b/lemur/plugins/lemur_email/plugin.py new file mode 100644 index 00000000..cecc6e37 --- /dev/null +++ b/lemur/plugins/lemur_email/plugin.py @@ -0,0 +1,76 @@ +""" +.. module: lemur.plugins.lemur_aws.aws + :platform: Unix + :copyright: (c) 2015 by Netflix Inc., see AUTHORS for more + :license: Apache, see LICENSE for more details. + +.. moduleauthor:: Kevin Glisson +""" +import boto.ses +from flask import current_app +from flask_mail import Message + +from lemur.extensions import smtp_mail + +from lemur.plugins.bases import ExpirationNotificationPlugin +from lemur.plugins import lemur_email as email + + +from lemur.plugins.lemur_email.templates.config import env + + +def find_value(name, options): + for o in options: + if o.get(name): + return o['value'] + + +class EmailNotificationPlugin(ExpirationNotificationPlugin): + title = 'Email' + slug = 'email-notification' + description = 'Sends expiration email notifications' + version = email.VERSION + + author = 'Kevin Glisson' + author_url = 'https://github.com/netflix/lemur' + + additional_options = [ + { + 'name': 'recipients', + 'type': 'str', + 'required': True, + 'validation': '^([\w+-.%]+@[\w-.]+\.[A-Za-z]{2,4},?)+$', + 'helpMessage': 'Comma delimited list of email addresses', + }, + ] + + @staticmethod + def send(event_type, message, targets, options, **kwargs): + """ + Configures all Lemur email messaging + + :param event_type: + :param options: + """ + subject = 'Notification: Lemur' + + if event_type == 'expiration': + subject = 'Notification: SSL Certificate Expiration ' + + # jinja template depending on type + template = env.get_template('{}.html'.format(event_type)) + body = template.render(**kwargs) + + s_type = current_app.config.get("LEMUR_EMAIL_SENDER").lower() + if s_type == 'ses': + conn = boto.connect_ses() + conn.send_email(current_app.config.get("LEMUR_EMAIL"), subject, body, targets, format='html') + + elif s_type == 'smtp': + msg = Message(subject, recipients=targets) + msg.body = "" # kinda a weird api for sending html emails + msg.html = body + smtp_mail.send(msg) + + else: + current_app.logger.error("No mail carrier specified, notification emails were not able to be sent!") diff --git a/lemur/plugins/lemur_email/templates/__init__.py b/lemur/plugins/lemur_email/templates/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/lemur/templates/config.py b/lemur/plugins/lemur_email/templates/config.py similarity index 100% rename from lemur/templates/config.py rename to lemur/plugins/lemur_email/templates/config.py diff --git a/lemur/templates/event.html b/lemur/plugins/lemur_email/templates/expiration.html similarity index 96% rename from lemur/templates/event.html rename to lemur/plugins/lemur_email/templates/expiration.html index 0c12b619..f5459496 100644 --- a/lemur/templates/event.html +++ b/lemur/plugins/lemur_email/templates/expiration.html @@ -72,6 +72,12 @@ {{ message.owner }} + + Creator + + + {{ message.creator }} + Not Before diff --git a/lemur/plugins/views.py b/lemur/plugins/views.py index eb774415..ddbe780e 100644 --- a/lemur/plugins/views.py +++ b/lemur/plugins/views.py @@ -65,13 +65,13 @@ class PluginsList(AuthenticatedResource): "id": 2, "accountNumber": 222222222, "label": "account2", - "comments": "this is a thing" + "description": "this is a thing" }, { "id": 1, "accountNumber": 11111111111, "label": "account1", - "comments": "this is a thing" + "description": "this is a thing" }, ] "total": 2 @@ -80,19 +80,24 @@ class PluginsList(AuthenticatedResource): :reqheader Authorization: OAuth token to authenticate :statuscode 200: no error """ + self.reqparse.add_argument('type', type=str, location='args') + args = self.reqparse.parse_args() + + if args['type']: + return list(plugins.all(plugin_type=args['type'])) + return plugins.all() -class PluginsTypeList(AuthenticatedResource): - """ Defines the 'plugins' endpoint """ +class Plugins(AuthenticatedResource): + """ Defines the the 'plugins' endpoint """ def __init__(self): - self.reqparse = reqparse.RequestParser() - super(PluginsTypeList, self).__init__() + super(Plugins, self).__init__() @marshal_items(FIELDS) - def get(self, plugin_type): + def get(self, name): """ - .. http:get:: /plugins/issuer + .. http:get:: /plugins/ The current plugin list @@ -100,7 +105,7 @@ class PluginsTypeList(AuthenticatedResource): .. sourcecode:: http - GET /plugins/issuer HTTP/1.1 + GET /plugins HTTP/1.1 Host: example.com Accept: application/json, text/javascript @@ -113,27 +118,16 @@ class PluginsTypeList(AuthenticatedResource): Content-Type: text/javascript { - "items": [ - { - "id": 2, - "accountNumber": 222222222, - "label": "account2", - "comments": "this is a thing" - }, - { - "id": 1, - "accountNumber": 11111111111, - "label": "account1", - "comments": "this is a thing" - }, - ] - "total": 2 + "accountNumber": 222222222, + "label": "account2", + "description": "this is a thing" } :reqheader Authorization: OAuth token to authenticate :statuscode 200: no error """ - return list(plugins.all(plugin_type=plugin_type)) + return plugins.get(name) + api.add_resource(PluginsList, '/plugins', endpoint='plugins') -api.add_resource(PluginsTypeList, '/plugins/', endpoint='pluginType') +api.add_resource(Plugins, '/plugins/', endpoint='pluginName') diff --git a/lemur/static/app/angular/authorities/authority/authority.js b/lemur/static/app/angular/authorities/authority/authority.js index 419e49fd..0fcd54b3 100644 --- a/lemur/static/app/angular/authorities/authority/authority.js +++ b/lemur/static/app/angular/authorities/authority/authority.js @@ -2,15 +2,29 @@ angular.module('lemur') - .controller('AuthorityEditController', function ($scope, $routeParams, AuthorityApi, AuthorityService, RoleService){ - AuthorityApi.get($routeParams.id).then(function (authority) { + .controller('AuthorityEditController', function ($scope, $modalInstance, AuthorityApi, AuthorityService, RoleService, editId){ + AuthorityApi.get(editId).then(function (authority) { AuthorityService.getRoles(authority); $scope.authority = authority; }); $scope.authorityService = AuthorityService; - $scope.save = AuthorityService.update; $scope.roleService = RoleService; + + $scope.save = function (authority) { + AuthorityService.update(authority).then( + function () { + $modalInstance.close(); + }, + function () { + + } + ); + }; + + $scope.cancel = function () { + $modalInstance.dismiss('cancel'); + }; }) .controller('AuthorityCreateController', function ($scope, $modalInstance, AuthorityService, LemurRestangular, RoleService, PluginService, WizardHandler) { @@ -25,7 +39,7 @@ angular.module('lemur') }); }; - PluginService.get('issuer').then(function (plugins) { + PluginService.getByType('issuer').then(function (plugins) { $scope.plugins = plugins; }); diff --git a/lemur/static/app/angular/authorities/authority/authorityEdit.tpl.html b/lemur/static/app/angular/authorities/authority/authorityEdit.tpl.html index f40502f1..f100ba1b 100644 --- a/lemur/static/app/angular/authorities/authority/authorityEdit.tpl.html +++ b/lemur/static/app/angular/authorities/authority/authorityEdit.tpl.html @@ -1,44 +1,41 @@ -

Edit Authority Chain of command -

-
-
- Cancel -
-
-
-
-
- -
-
- - - - -
- - - - - - -
{{ role.name }}{{ role.description }} - -
-
-
-
-
- + diff --git a/lemur/static/app/angular/authorities/services.js b/lemur/static/app/angular/authorities/services.js index 49a50ca4..eefdbc8c 100644 --- a/lemur/static/app/angular/authorities/services.js +++ b/lemur/static/app/angular/authorities/services.js @@ -65,6 +65,19 @@ angular.module('lemur') }); }; + AuthorityService.findActiveAuthorityByName = function (filterValue) { + return AuthorityApi.getList({'filter[name]': filterValue}) + .then(function (authorities) { + var activeAuthorities = []; + _.each(authorities, function (authority) { + if (authority.active) { + activeAuthorities.push(authority); + } + }); + return activeAuthorities; + }); + }; + AuthorityService.create = function (authority) { authority.attachSubAltName(); return AuthorityApi.post(authority).then( @@ -86,7 +99,7 @@ angular.module('lemur') }; AuthorityService.update = function (authority) { - authority.put().then( + return authority.put().then( function () { toaster.pop({ type: 'success', @@ -105,13 +118,13 @@ angular.module('lemur') }; AuthorityService.getRoles = function (authority) { - authority.getList('roles').then(function (roles) { + return authority.getList('roles').then(function (roles) { authority.roles = roles; }); }; AuthorityService.updateActive = function (authority) { - authority.put().then( + return authority.put().then( function () { toaster.pop({ type: 'success', diff --git a/lemur/static/app/angular/authorities/view/view.js b/lemur/static/app/angular/authorities/view/view.js index 0ee16082..970b7a7e 100644 --- a/lemur/static/app/angular/authorities/view/view.js +++ b/lemur/static/app/angular/authorities/view/view.js @@ -46,7 +46,7 @@ angular.module('lemur') $scope.edit = function (authorityId) { var modalInstance = $modal.open({ animation: true, - templateUrl: '/angular/authorities/authority/authorityWizard.tpl.html', + templateUrl: '/angular/authorities/authority/authorityEdit.tpl.html', controller: 'AuthorityEditController', size: 'lg', resolve: { diff --git a/lemur/static/app/angular/certificates/certificate/certificate.js b/lemur/static/app/angular/certificates/certificate/certificate.js index 9a166050..834c8206 100644 --- a/lemur/static/app/angular/certificates/certificate/certificate.js +++ b/lemur/static/app/angular/certificates/certificate/certificate.js @@ -1,17 +1,28 @@ 'use strict'; angular.module('lemur') - .controller('CertificateEditController', function ($scope, $routeParams, CertificateApi, CertificateService, MomentService) { - CertificateApi.get($routeParams.id).then(function (certificate) { + .controller('CertificateEditController', function ($scope, $modalInstance, CertificateApi, CertificateService, DestinationService, NotificationService, editId) { + CertificateApi.get(editId).then(function (certificate) { + CertificateService.getNotifications(certificate); + CertificateService.getDestinations(certificate); $scope.certificate = certificate; }); - $scope.momentService = MomentService; - $scope.save = CertificateService.update; + $scope.cancel = function () { + $modalInstance.dismiss('cancel'); + }; + $scope.save = function (certificate) { + CertificateService.update(certificate).then(function () { + $modalInstance.close(); + }); + }; + + $scope.destinationService = DestinationService; + $scope.notificationService = NotificationService; }) - .controller('CertificateCreateController', function ($scope, $modalInstance, CertificateApi, CertificateService, DestinationService, ELBService, AuthorityService, PluginService, MomentService, WizardHandler, LemurRestangular) { + .controller('CertificateCreateController', function ($scope, $modalInstance, CertificateApi, CertificateService, DestinationService, ELBService, AuthorityService, PluginService, MomentService, WizardHandler, LemurRestangular, NotificationService) { $scope.certificate = LemurRestangular.restangularizeElement(null, {}, 'certificates'); $scope.create = function (certificate) { @@ -77,11 +88,12 @@ angular.module('lemur') }; - PluginService.get('destination').then(function (plugins) { + PluginService.getByType('destination').then(function (plugins) { $scope.plugins = plugins; }); $scope.elbService = ELBService; $scope.authorityService = AuthorityService; $scope.destinationService = DestinationService; + $scope.notificationService = NotificationService; }); diff --git a/lemur/static/app/angular/certificates/certificate/certificateWizard.tpl.html b/lemur/static/app/angular/certificates/certificate/certificateWizard.tpl.html index a7e36c1f..726a3ec9 100644 --- a/lemur/static/app/angular/certificates/certificate/certificateWizard.tpl.html +++ b/lemur/static/app/angular/certificates/certificate/certificateWizard.tpl.html @@ -7,14 +7,11 @@ - - - - - + +
diff --git a/lemur/static/app/angular/certificates/certificate/destinations.tpl.html b/lemur/static/app/angular/certificates/certificate/destinations.tpl.html index 58d88dc7..9961a6f2 100644 --- a/lemur/static/app/angular/certificates/certificate/destinations.tpl.html +++ b/lemur/static/app/angular/certificates/certificate/destinations.tpl.html @@ -3,26 +3,26 @@ Destinations
-
- +
+ -
- - - - - - -
{{ destination.label }}{{ destination.description }} - -
+
+ + + + + + +
{{ destination.label }}{{ destination.description }} + +
diff --git a/lemur/static/app/angular/certificates/certificate/tracking.tpl.html b/lemur/static/app/angular/certificates/certificate/tracking.tpl.html index 8ceb50c0..8fa42680 100644 --- a/lemur/static/app/angular/certificates/certificate/tracking.tpl.html +++ b/lemur/static/app/angular/certificates/certificate/tracking.tpl.html @@ -1,83 +1,84 @@
-
-
- -
- -

You must enter an Certificate owner

-
-
-
- -
- -

You must give a short description about this authority will be used for, this description should only include alphanumeric characters

-
-
-
- -
-
- +
+
+ +
+ +

You must enter an Certificate owner

+
-
-
-
- -
- -
-
-
- -
- -

You must enter a common name

-
-
-
- -
-
-
- +
+ +
+ +

You must give a short description about this authority will be used for, this description should only include alphanumeric characters

+
+
+
+ +
+
+ +
+
+
+
+ +
+ +
+
+
+ +
+ +

You must enter a common name

+
+
+
+ +
+
+
+ -
-
-
- -
-
-
- +
+
+
+ +
+
+
+ -
+
+
+
-
+
+
-
-
diff --git a/lemur/static/app/angular/certificates/certificate/upload.js b/lemur/static/app/angular/certificates/certificate/upload.js index 0bf57b4f..685da6f0 100644 --- a/lemur/static/app/angular/certificates/certificate/upload.js +++ b/lemur/static/app/angular/certificates/certificate/upload.js @@ -2,14 +2,15 @@ angular.module('lemur') - .controller('CertificateUploadController', function ($scope, $modalInstance, CertificateService, LemurRestangular, DestinationService, ELBService, PluginService) { + .controller('CertificateUploadController', function ($scope, $modalInstance, CertificateService, LemurRestangular, DestinationService, NotificationService, ELBService, PluginService) { $scope.certificate = LemurRestangular.restangularizeElement(null, {}, 'certificates'); $scope.upload = CertificateService.upload; $scope.destinationService = DestinationService; + $scope.notificationService = NotificationService; $scope.elbService = ELBService; - PluginService.get('destination').then(function (plugins) { + PluginService.getByType('destination').then(function (plugins) { $scope.plugins = plugins; }); diff --git a/lemur/static/app/angular/certificates/certificate/upload.tpl.html b/lemur/static/app/angular/certificates/certificate/upload.tpl.html index afefa5e3..42723bb6 100644 --- a/lemur/static/app/angular/certificates/certificate/upload.tpl.html +++ b/lemur/static/app/angular/certificates/certificate/upload.tpl.html @@ -61,6 +61,7 @@ class="help-block">Enter a valid certificate.

+
diff --git a/lemur/static/app/angular/certificates/services.js b/lemur/static/app/angular/certificates/services.js index 504c2bcd..db9c1223 100644 --- a/lemur/static/app/angular/certificates/services.js +++ b/lemur/static/app/angular/certificates/services.js @@ -67,6 +67,16 @@ angular.module('lemur') removeDestination: function (index) { this.destinations.splice(index, 1); }, + attachNotification: function (notification) { + this.selectedNotification = null; + if (this.notifications === undefined) { + this.notifications = []; + } + this.notifications.push(notification); + }, + removeNotification: function (index) { + this.notifications.splice(index, 1); + }, attachELB: function (elb) { this.selectedELB = null; if (this.elbs === undefined) { @@ -89,7 +99,7 @@ angular.module('lemur') }); return LemurRestangular.all('certificates'); }) - .service('CertificateService', function ($location, CertificateApi, toaster) { + .service('CertificateService', function ($location, CertificateApi, LemurRestangular, toaster) { var CertificateService = this; CertificateService.findCertificatesByName = function (filterValue) { return CertificateApi.getList({'filter[name]': filterValue}) @@ -120,7 +130,7 @@ angular.module('lemur') }; CertificateService.update = function (certificate) { - certificate.put().then(function () { + return LemurRestangular.copy(certificate).put().then(function () { toaster.pop({ type: 'success', title: certificate.name, @@ -131,7 +141,7 @@ angular.module('lemur') }; CertificateService.upload = function (certificate) { - CertificateApi.customPOST(certificate, 'upload').then( + return CertificateApi.customPOST(certificate, 'upload').then( function () { toaster.pop({ type: 'success', @@ -150,7 +160,7 @@ angular.module('lemur') }; CertificateService.loadPrivateKey = function (certificate) { - certificate.customGET('key').then( + return certificate.customGET('key').then( function (response) { if (response.key === null) { toaster.pop({ @@ -172,43 +182,49 @@ angular.module('lemur') }; CertificateService.getAuthority = function (certificate) { - certificate.customGET('authority').then(function (authority) { + return certificate.customGET('authority').then(function (authority) { certificate.authority = authority; }); }; CertificateService.getCreator = function (certificate) { - certificate.customGET('creator').then(function (creator) { + return certificate.customGET('creator').then(function (creator) { certificate.creator = creator; }); }; CertificateService.getDestinations = function (certificate) { - certificate.getList('destinations').then(function (destinations) { + return certificate.getList('destinations').then(function (destinations) { certificate.destinations = destinations; }); }; + CertificateService.getNotifications = function (certificate) { + return certificate.getList('notifications').then(function (notifications) { + certificate.notifications = notifications; + }); + }; + CertificateService.getListeners = function (certificate) { - certificate.getList('listeners').then(function (listeners) { + return certificate.getList('listeners').then(function (listeners) { certificate.listeners = listeners; }); }; CertificateService.getELBs = function (certificate) { - certificate.getList('listeners').then(function (elbs) { + return certificate.getList('listeners').then(function (elbs) { certificate.elbs = elbs; }); }; CertificateService.getDomains = function (certificate) { - certificate.getList('domains').then(function (domains) { + return certificate.getList('domains').then(function (domains) { certificate.domains = domains; }); }; CertificateService.updateActive = function (certificate) { - certificate.put().then( + return certificate.put().then( function () { toaster.pop({ type: 'success', diff --git a/lemur/static/app/angular/certificates/view/view.js b/lemur/static/app/angular/certificates/view/view.js index 3c295f84..caed61e8 100644 --- a/lemur/static/app/angular/certificates/view/view.js +++ b/lemur/static/app/angular/certificates/view/view.js @@ -27,7 +27,7 @@ angular.module('lemur') _.each(data, function (certificate) { CertificateService.getDomains(certificate); CertificateService.getDestinations(certificate); - CertificateService.getListeners(certificate); + CertificateService.getNotifications(certificate); CertificateService.getAuthority(certificate); CertificateService.getCreator(certificate); }); @@ -74,6 +74,24 @@ angular.module('lemur') }); }; + $scope.edit = function (certificateId) { + var modalInstance = $modal.open({ + animation: true, + controller: 'CertificateEditController', + templateUrl: '/angular/certificates/certificate/edit.tpl.html', + size: 'lg', + resolve: { + editId: function () { + return certificateId; + } + } + }); + + modalInstance.result.then(function () { + $scope.certificateTable.reload(); + }); + }; + $scope.import = function () { var modalInstance = $modal.open({ animation: true, diff --git a/lemur/static/app/angular/certificates/view/view.tpl.html b/lemur/static/app/angular/certificates/view/view.tpl.html index abe3c5f3..563b4263 100644 --- a/lemur/static/app/angular/certificates/view/view.tpl.html +++ b/lemur/static/app/angular/certificates/view/view.tpl.html @@ -30,11 +30,6 @@
  • {{ certificate.owner }}
  • - - -
    @@ -47,70 +42,80 @@ {{ certificate.cn }} -
    - +
    + +
    - -
    -
      -
    • - Creator - - {{ certificate.creator.email }} - -
    • -
    • - Not Before - - {{ momentService.createMoment(certificate.notBefore) }} - -
    • -
    • - Not After - - {{ momentService.createMoment(certificate.notAfter) }} - -
    • -
    • - San - - - - -
    • -
    • - Bits - {{ certificate.bits }} -
    • -
    • - Serial - {{ certificate.serial }} -
    • -
    • - Validity - - Unknown - Revoked - Valid - -
    • -
    • - Description - {{ certificate.description }} -
    • -
    -

    Domains

    - -

    ARNs

    -
      -
    • {{ arn }}
    • -
    -
    + + + +
      +
    • + Creator + + {{ certificate.creator.email }} + +
    • +
    • + Not Before + + {{ momentService.createMoment(certificate.notBefore) }} + +
    • +
    • + Not After + + {{ momentService.createMoment(certificate.notAfter) }} + +
    • +
    • + San + + + + +
    • +
    • + Bits + {{ certificate.bits }} +
    • +
    • + Serial + {{ certificate.serial }} +
    • +
    • + Validity + + Unknown + Revoked + Valid + +
    • +
    • + Description + {{ certificate.description }} +
    • +
    +
    + + + + + + + + + +

    diff --git a/lemur/static/app/angular/destinations/destination/destination.js b/lemur/static/app/angular/destinations/destination/destination.js index 7ef85dd1..8d947a7a 100644 --- a/lemur/static/app/angular/destinations/destination/destination.js +++ b/lemur/static/app/angular/destinations/destination/destination.js @@ -5,7 +5,7 @@ angular.module('lemur') .controller('DestinationsCreateController', function ($scope, $modalInstance, PluginService, DestinationService, LemurRestangular){ $scope.destination = LemurRestangular.restangularizeElement(null, {}, 'destinations'); - PluginService.get('destination').then(function (plugins) { + PluginService.getByType('destination').then(function (plugins) { $scope.plugins = plugins; }); $scope.save = function (destination) { @@ -24,8 +24,14 @@ angular.module('lemur') $scope.destination = destination; }); - PluginService.get('destination').then(function (plugins) { - $scope.plugins = plugins; + PluginService.getByType('destination').then(function (plugins) { + $scope.plugins = plugins; + _.each($scope.plugins, function (plugin) { + if (plugin.slug == $scope.destination.pluginName) { + plugin.pluginOptions = $scope.destination.destinationOptions; + $scope.destination.plugin = plugin; + }; + }); }); $scope.save = function (destination) { diff --git a/lemur/static/app/angular/destinations/services.js b/lemur/static/app/angular/destinations/services.js index 08d304d8..e778a1f3 100644 --- a/lemur/static/app/angular/destinations/services.js +++ b/lemur/static/app/angular/destinations/services.js @@ -3,7 +3,7 @@ angular.module('lemur') .service('DestinationApi', function (LemurRestangular) { return LemurRestangular.all('destinations'); }) - .service('DestinationService', function ($location, DestinationApi, toaster) { + .service('DestinationService', function ($location, DestinationApi, PluginService, toaster) { var DestinationService = this; DestinationService.findDestinationsByName = function (filterValue) { return DestinationApi.getList({'filter[label]': filterValue}) @@ -49,5 +49,11 @@ angular.module('lemur') }); }); }; + + DestinationService.getPlugin = function (destination) { + return PluginService.getByName(destination.pluginName).then(function (plugin) { + destination.plugin = plugin; + }); + }; return DestinationService; }); diff --git a/lemur/static/app/angular/destinations/view/view.js b/lemur/static/app/angular/destinations/view/view.js index bd4974dd..cee6d691 100644 --- a/lemur/static/app/angular/destinations/view/view.js +++ b/lemur/static/app/angular/destinations/view/view.js @@ -23,6 +23,9 @@ angular.module('lemur') getData: function ($defer, params) { DestinationApi.getList(params.url()).then( function (data) { + _.each(data, function (destination) { + DestinationService.getPlugin(destination); + }); params.total(data.total); $defer.resolve(data); } diff --git a/lemur/static/app/angular/plugins/services.js b/lemur/static/app/angular/plugins/services.js index ed54bff6..4d5ff9a9 100644 --- a/lemur/static/app/angular/plugins/services.js +++ b/lemur/static/app/angular/plugins/services.js @@ -1,14 +1,28 @@ 'use strict'; angular.module('lemur') - .service('PluginApi', function (LemurRestangular) { - return LemurRestangular.all('plugins'); - }) - .service('PluginService', function (PluginApi) { - var PluginService = this; - PluginService.get = function (type) { - return PluginApi.customGETLIST(type).then(function (plugins) { - return plugins; - }); - }; - }); + .service('PluginApi', function (LemurRestangular) { + return LemurRestangular.all('plugins'); + }) + .service('PluginService', function (PluginApi) { + var PluginService = this; + PluginService.get = function () { + return PluginApi.getList().then(function (plugins) { + return plugins; + }); + }; + + PluginService.getByType = function (type) { + return PluginApi.getList({'type': type}).then(function (plugins) { + return plugins; + }); + }; + + PluginService.getByName = function (pluginName) { + return PluginApi.customGET(pluginName).then(function (plugin) { + return plugin; + }); + }; + + return PluginService; + }); diff --git a/lemur/static/app/angular/users/user/user.tpl.html b/lemur/static/app/angular/users/user/user.tpl.html index 9b1aca52..c2e9687b 100644 --- a/lemur/static/app/angular/users/user/user.tpl.html +++ b/lemur/static/app/angular/users/user/user.tpl.html @@ -55,11 +55,11 @@ class="form-control input-md" typeahead-on-select="user.attachRole($item)" typeahead-min-wait="50" tooltip="Roles control which authorities a user can issue certificates from" tooltip-trigger="focus" tooltip-placement="top"> - - - + + +

    diff --git a/lemur/static/app/index.html b/lemur/static/app/index.html index f8bd8abb..3ef93dd7 100644 --- a/lemur/static/app/index.html +++ b/lemur/static/app/index.html @@ -49,13 +49,20 @@