Adding backend code for sources models

This commit is contained in:
kevgliss 2015-08-01 15:29:34 -07:00
parent 46c6b8f8a4
commit e247d635fc
26 changed files with 1096 additions and 588 deletions

View File

@ -22,6 +22,8 @@ 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
from lemur.sources.views import mod as sources_bp
LEMUR_BLUEPRINTS = (
users_bp,
@ -36,6 +38,7 @@ LEMUR_BLUEPRINTS = (
status_bp,
plugins_bp,
notifications_bp,
sources_bp
)

View File

@ -23,7 +23,9 @@ 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, certificate_notification_associations
from lemur.models import certificate_associations, certificate_source_associations, \
certificate_destination_associations, certificate_notification_associations
def create_name(issuer, not_before, not_after, subject, san):
@ -222,6 +224,7 @@ class Certificate(db.Model):
authority_id = Column(Integer, ForeignKey('authorities.id'))
notifications = relationship("Notification", secondary=certificate_notification_associations, backref='certificate')
destinations = relationship("Destination", secondary=certificate_destination_associations, backref='certificate')
sources = relationship("Source", secondary=certificate_source_associations, backref='certificate')
domains = relationship("Domain", secondary=certificate_associations, backref="certificate")
elb_listeners = relationship("Listener", lazy='dynamic', backref='certificate')

View File

@ -1,44 +0,0 @@
"""
.. module: lemur.elbs.models
:platform: Unix
:copyright: (c) 2015 by Netflix Inc., see AUTHORS for more
:license: Apache, see LICENSE for more details.
.. moduleauthor:: Kevin Glisson <kglisson@netflix.com>
"""
from sqlalchemy import Column, BigInteger, String, DateTime, PassiveDefault, func
from sqlalchemy.orm import relationship
from lemur.database import db
from lemur.listeners.models import Listener
class ELB(db.Model):
__tablename__ = 'elbs'
id = Column(BigInteger, primary_key=True)
# account_id = Column(BigInteger, ForeignKey("accounts.id"), index=True)
region = Column(String(32))
name = Column(String(128))
vpc_id = Column(String(128))
scheme = Column(String(128))
dns_name = Column(String(128))
listeners = relationship("Listener", backref='elb', cascade="all, delete, delete-orphan")
date_created = Column(DateTime, PassiveDefault(func.now()), nullable=False)
def __init__(self, elb_obj=None):
if elb_obj:
self.region = elb_obj.connection.region.name
self.name = elb_obj.name
self.vpc_id = elb_obj.vpc_id
self.scheme = elb_obj.scheme
self.dns_name = elb_obj.dns_name
for listener in elb_obj.listeners:
self.listeners.append(Listener(listener))
def as_dict(self):
return {c.name: getattr(self, c.name) for c in self.__table__.columns}
def serialize(self):
blob = self.as_dict()
del blob['date_created']
return blob

View File

@ -1,124 +0,0 @@
"""
.. module: lemur.elbs.service
:platform: Unix
:copyright: (c) 2015 by Netflix Inc., see AUTHORS for more
:license: Apache, see LICENSE for more details.
.. moduleauthor:: Kevin Glisson <kglisson@netflix.com>
"""
from sqlalchemy import func
from sqlalchemy.sql import and_
from lemur import database
from lemur.elbs.models import ELB
from lemur.listeners.models import Listener
def get_all(account_id, elb_name):
"""
Retrieves all ELBs in a given account
:param account_id:
:param elb_name:
:rtype : Elb
:return:
"""
query = database.session_query(ELB)
return query.filter(and_(ELB.name == elb_name, ELB.account_id == account_id)).all()
def get_by_region_and_account(region, account_id):
query = database.session_query(ELB)
return query.filter(and_(ELB.region == region, ELB.account_id == account_id)).all()
def get_all_elbs():
"""
Get all ELBs that Lemur knows about
:rtype : list
:return:
"""
return ELB.query.all()
def get(elb_id):
"""
Retrieve an ELB with a give ID
:rtype : Elb
:param elb_id:
:return:
"""
return database.get(ELB, elb_id)
def create(account, elb):
"""
Create a new ELB
:param account:
:param elb:
"""
elb = ELB(elb)
account.elbs.append(elb)
database.create(elb)
def delete(elb_id):
"""
Delete an ELB
:param elb_id:
"""
database.delete(get(elb_id))
def render(args):
query = database.session_query(ELB)
sort_by = args.pop('sort_by')
sort_dir = args.pop('sort_dir')
page = args.pop('page')
count = args.pop('count')
filt = args.pop('filter')
active = args.pop('active')
certificate_id = args.pop('certificate_id')
if certificate_id:
query.filter(ELB.listeners.any(Listener.certificate_id == certificate_id))
if active == 'true':
query = query.filter(ELB.listeners.any())
if filt:
terms = filt.split(';')
query = database.filter(query, ELB, terms)
query = database.find_all(query, ELB, args)
if sort_by and sort_dir:
query = database.sort(query, ELB, sort_by, sort_dir)
return database.paginate(query, page, count)
def stats(**kwargs):
attr = getattr(ELB, kwargs.get('metric'))
query = database.db.session.query(attr, func.count(attr))
if kwargs.get('account_id'):
query = query.filter(ELB.account_id == kwargs.get('account_id'))
if kwargs.get('active') == 'true':
query = query.join(ELB.listeners)
query = query.filter(Listener.certificate_id != None) # noqa
items = query.group_by(attr).all()
results = []
for key, count in items:
if key:
results.append({"key": key, "y": count})
return results

View File

@ -1,78 +0,0 @@
"""
.. module: lemur.elbs.service
:platform: Unix
:copyright: (c) 2015 by Netflix Inc., see AUTHORS for more
:license: Apache, see LICENSE for more details.
.. moduleauthor:: Kevin Glisson <kglisson@netflix.com>
"""
from flask import Blueprint
from flask.ext.restful import reqparse, Api, fields
from lemur.elbs import service
from lemur.auth.service import AuthenticatedResource
from lemur.common.utils import marshal_items, paginated_parser
mod = Blueprint('elbs', __name__)
api = Api(mod)
FIELDS = {
'name': fields.String,
'id': fields.Integer,
'region': fields.String,
'scheme': fields.String,
'accountId': fields.Integer(attribute='account_id'),
'vpcId': fields.String(attribute='vpc_id')
}
class ELBsList(AuthenticatedResource):
""" Defines the 'elbs' endpoint """
def __init__(self):
super(ELBsList, self).__init__()
@marshal_items(FIELDS)
def get(self):
parser = paginated_parser.copy()
parser.add_argument('owner', type=str, location='args')
parser.add_argument('id', type=str, location='args')
parser.add_argument('accountId', type=str, dest='account_id', location='args')
parser.add_argument('certificateId', type=str, dest='certificate_id', location='args')
parser.add_argument('active', type=str, default='true', location='args')
args = parser.parse_args()
return service.render(args)
class ELBsStats(AuthenticatedResource):
def __init__(self):
self.reqparse = reqparse.RequestParser()
super(ELBsStats, self).__init__()
def get(self):
self.reqparse.add_argument('metric', type=str, location='args')
self.reqparse.add_argument('accountId', dest='account_id', location='args')
self.reqparse.add_argument('active', type=str, default='true', location='args')
args = self.reqparse.parse_args()
items = service.stats(**args)
return {"items": items, "total": len(items)}
class ELBs(AuthenticatedResource):
def __init__(self):
self.reqparse = reqparse.RequestParser()
super(ELBs, self).__init__()
@marshal_items(FIELDS)
def get(self, elb_id):
return service.get(elb_id)
api.add_resource(ELBsList, '/elbs', endpoint='elbs')
api.add_resource(ELBs, '/elbs/<int:elb_id>', endpoint='elb')
api.add_resource(ELBsStats, '/elbs/stats', endpoint='elbsStats')

View File

@ -1,42 +0,0 @@
"""
.. module: lemur.elbs.models
:platform: Unix
:copyright: (c) 2015 by Netflix Inc., see AUTHORS for more
:license: Apache, see LICENSE for more details.
.. moduleauthor:: Kevin Glisson <kglisson@netflix.com>
"""
from sqlalchemy import Column, Integer, BigInteger, String, ForeignKey, DateTime, PassiveDefault, func
from lemur.database import db
from lemur.certificates import service as cert_service
from lemur.certificates.models import Certificate, get_name_from_arn
class Listener(db.Model):
__tablename__ = 'listeners'
id = Column(BigInteger, primary_key=True)
certificate_id = Column(Integer, ForeignKey(Certificate.id), index=True)
elb_id = Column(BigInteger, ForeignKey("elbs.id"), index=True)
instance_port = Column(Integer)
instance_protocol = Column(String(16))
load_balancer_port = Column(Integer)
load_balancer_protocol = Column(String(16))
date_created = Column(DateTime, PassiveDefault(func.now()), nullable=False)
def __init__(self, listener):
self.load_balancer_port = listener.load_balancer_port
self.load_balancer_protocol = listener.protocol
self.instance_port = listener.instance_port
self.instance_protocol = listener.instance_protocol
if listener.ssl_certificate_id not in ["Invalid-Certificate", None]:
self.certificate_id = cert_service.get_by_name(get_name_from_arn(listener.ssl_certificate_id)).id
def as_dict(self):
return {c.name: getattr(self, c.name) for c in self.__table__.columns}
def serialize(self):
blob = self.as_dict()
del blob['date_created']
return blob

View File

@ -1,159 +0,0 @@
"""
.. module: lemur.listeners.service
:platform: Unix
:copyright: (c) 2015 by Netflix Inc., see AUTHORS for more
:license: Apache, see LICENSE for more details.
.. moduleauthor:: Kevin Glisson <kglisson@netflix.com>
"""
from sqlalchemy import func
from lemur import database
from lemur.exceptions import CertificateUnavailable
from lemur.elbs.models import ELB
from lemur.listeners.models import Listener
from lemur.elbs import service as elb_service
from lemur.certificates import service as certificate_service
# from lemur.common.services.aws.elb import update_listeners, create_new_listeners, delete_listeners
def verify_attachment(certificate_id, elb_account_number):
"""
Ensures that the certificate we want ot attach to our listener is
in the same account as our listener.
:rtype : Certificate
:param certificate_id:
:param elb_account_number:
:return: :raise CertificateUnavailable:
"""
cert = certificate_service.get(certificate_id)
# we need to ensure that the specified cert is in our account
for account in cert.accounts:
if account.account_number == elb_account_number:
break
else:
raise CertificateUnavailable
return cert
def get(listener_id):
return database.get(Listener, listener_id)
def create(elb_id, instance_protocol, instance_port, load_balancer_port, load_balancer_protocol, certificate_id=None):
listener = Listener(elb_id,
instance_port,
instance_protocol,
load_balancer_port,
load_balancer_protocol
)
elb = elb_service.get(elb_id)
elb.listeners.append(listener)
account_number = elb.account.account_number
cert = verify_attachment(certificate_id, account_number)
listener_tuple = (load_balancer_port, instance_port, load_balancer_protocol, cert.get_art(account_number),)
# create_new_listeners(account_number, elb.region, elb.name, [listener_tuple])
return {'message': 'Listener has been created'}
def update(listener_id, **kwargs):
listener = get(listener_id)
# if the lb_port has changed we need to make sure we are deleting
# the listener on the old port to avoid listener duplication
ports = []
if listener.load_balancer_port != kwargs.get('load_balancer_port'):
ports.append(listener.load_balancer_port)
else:
ports.append(kwargs.get('load_balancer_port'))
certificate_id = kwargs.get('certificate_id')
listener.instance_port = kwargs.get('instance_port')
listener.instance_protocol = kwargs.get('instance_protocol')
listener.load_balancer_port = kwargs.get('load_balancer_port')
listener.load_balancer_protocol = kwargs.get('load_balancer_protocol')
elb = listener.elb
account_number = listener.elb.account.account_number
arn = None
if certificate_id:
cert = verify_attachment(certificate_id, account_number)
cert.elb_listeners.append(listener)
arn = cert.get_arn(account_number)
# remove certificate that is no longer wanted
if listener.certificate and not certificate_id:
listener.certificate.remove()
database.update(listener)
listener_tuple = (listener.load_balancer_port, listener.instance_port, listener.load_balancer_protocol, arn,)
# update_listeners(account_number, elb.region, elb.name, [listener_tuple], ports)
return {'message': 'Listener has been updated'}
def delete(listener_id):
# first try to delete the listener in aws
listener = get(listener_id)
# delete_listeners(listener.elb.account.account_number, listener.elb.region, listener.elb.name, [listener.load_balancer_port])
# cleanup operation in lemur
database.delete(listener)
def render(args):
query = database.session_query(Listener)
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)
elb_id = args.pop('elb_id', None)
if certificate_id:
query = database.get_all(Listener, certificate_id, field='certificate_id')
if elb_id:
query = query.filter(Listener.elb_id == elb_id)
if filt:
terms = filt.split(';')
query = database.filter(query, Listener, terms)
query = database.find_all(query, Listener, args)
if sort_by and sort_dir:
query = database.sort(query, Listener, sort_by, sort_dir)
return database.paginate(query, page, count)
def stats(**kwargs):
attr = getattr(Listener, kwargs.get('metric'))
query = database.db.session.query(attr, func.count(attr))
query = query.join(Listener.elb)
if kwargs.get('account_id'):
query = query.filter(ELB.account_id == kwargs.get('account_id'))
if kwargs.get('active') == 'true':
query = query.filter(Listener.certificate_id != None) # noqa
items = query.group_by(attr).all()
results = []
for key, count in items:
if key:
results.append({"key": key, "y": count})
return results

View File

@ -1,128 +0,0 @@
"""
.. module: lemur.listeners.service
:platform: Unix
:copyright: (c) 2015 by Netflix Inc., see AUTHORS for more
:license: Apache, see LICENSE for more details.
.. moduleauthor:: Kevin Glisson <kglisson@netflix.com>
"""
from flask import Blueprint
from flask.ext.restful import reqparse, Api, fields
from lemur.listeners import service
from lemur.auth.service import AuthenticatedResource
from lemur.auth.permissions import admin_permission
from lemur.common.utils import marshal_items, paginated_parser
mod = Blueprint('listeners', __name__)
api = Api(mod)
FIELDS = {
'id': fields.Integer,
'elbId': fields.Integer(attribute="elb_id"),
'certificateId': fields.Integer(attribute="certificate_id"),
'instancePort': fields.Integer(attribute="instance_port"),
'instanceProtocol': fields.String(attribute="instance_protocol"),
'loadBalancerPort': fields.Integer(attribute="load_balancer_port"),
'loadBalancerProtocol': fields.String(attribute="load_balancer_protocol")
}
class ListenersList(AuthenticatedResource):
def __init__(self):
super(ListenersList, self).__init__()
@marshal_items(FIELDS)
def get(self):
parser = paginated_parser.copy()
parser.add_argument('certificateId', type=int, dest='certificate_id', location='args')
args = parser.parse_args()
return service.render(args)
class ListenersCertificateList(AuthenticatedResource):
def __init__(self):
super(ListenersCertificateList, self).__init__()
@marshal_items(FIELDS)
def get(self, certificate_id):
parser = paginated_parser.copy()
args = parser.parse_args()
args['certificate_id'] = certificate_id
return service.render(args)
class ListenersELBList(AuthenticatedResource):
def __init__(self):
super(ListenersELBList, self).__init__()
@marshal_items(FIELDS)
def get(self, elb_id):
parser = paginated_parser.copy()
args = parser.parse_args()
args['elb_id'] = elb_id
return service.render(args)
class ListenersStats(AuthenticatedResource):
def __init__(self):
self.reqparse = reqparse.RequestParser()
super(ListenersStats, self).__init__()
def get(self):
self.reqparse.add_argument('metric', type=str, location='args')
self.reqparse.add_argument('accountId', dest='account_id', location='args')
self.reqparse.add_argument('active', type=str, default='true', location='args')
args = self.reqparse.parse_args()
items = service.stats(**args)
return {"items": items, "total": len(items)}
class Listeners(AuthenticatedResource):
def __init__(self):
super(Listeners, self).__init__()
@marshal_items(FIELDS)
def get(self, listener_id):
return service.get(listener_id)
@admin_permission.require(http_exception=403)
@marshal_items(FIELDS)
def post(self):
self.reqparse.add_argument('elbId', type=str, dest='elb_id', required=True, location='json')
self.reqparse.add_argument('instanceProtocol', type=str, dest='instance_protocol', required=True, location='json')
self.reqparse.add_argument('instancePort', type=int, dest='instance_port', required=True, location='json')
self.reqparse.add_argument('loadBalancerProtocol', type=str, dest='load_balancer_protocol', required=True, location='json')
self.reqparse.add_argument('loadBalancerPort', type=int, dest='load_balancer_port', required=True, location='json')
self.reqparse.add_argument('certificateId', type=int, dest='certificate_id', location='json')
args = self.reqparse.parse_args()
return service.create(**args)
@admin_permission.require(http_exception=403)
@marshal_items(FIELDS)
def put(self, listener_id):
self.reqparse.add_argument('instanceProtocol', type=str, dest='instance_protocol', required=True, location='json')
self.reqparse.add_argument('instancePort', type=int, dest='instance_port', required=True, location='json')
self.reqparse.add_argument('loadBalancerProtocol', type=str, dest='load_balancer_protocol', required=True, location='json')
self.reqparse.add_argument('loadBalancerPort', type=int, dest='load_balancer_port', required=True, location='json')
self.reqparse.add_argument('certificateId', type=int, dest='certificate_id', location='json')
args = self.reqparse.parse_args()
return service.update(listener_id, **args)
@admin_permission.require(http_exception=403)
def delete(self, listener_id):
return service.delete(listener_id)
api.add_resource(ListenersList, '/listeners', endpoint='listeners')
api.add_resource(Listeners, '/listeners/<int:listener_id>', endpoint='listener')
api.add_resource(ListenersStats, '/listeners/stats', endpoint='listenersStats')
api.add_resource(ListenersCertificateList, '/certificates/<int:certificate_id>/listeners', endpoint='listenersCertificates')
api.add_resource(ListenersELBList, '/elbs/<int:elb_id>/listeners', endpoint='elbListeners')

View File

@ -19,7 +19,7 @@ from lemur.certificates import service as cert_service
from lemur.plugins.base import plugins
from lemur.certificates.verify import verify_string
from lemur.certificates import sync
from lemur.sources import sync
from lemur import create_app
@ -33,6 +33,7 @@ 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
from lemur.sources.models import Source # noqa
manager = Manager(create_app)
@ -183,12 +184,11 @@ class Sync(Command):
run on a periodic basis and updates the Lemur datastore with the
information it discovers.
"""
# TODO create these commands dynamically
option_list = [
Group(
Option('-a', '--all', action="store_true"),
Option('-b', '--aws', action="store_true"),
Option('-d', '--cloudca', action="store_true"),
Option('-s', '--source', action="store_true"),
exclusive=True, required=True
)
]

View File

@ -0,0 +1,42 @@
"""Adding in models for certificate sources
Revision ID: 1ff763f5b80b
Revises: 4dc5ddd111b8
Create Date: 2015-08-01 15:24:20.412725
"""
# revision identifiers, used by Alembic.
revision = '1ff763f5b80b'
down_revision = '4dc5ddd111b8'
from alembic import op
import sqlalchemy as sa
import sqlalchemy_utils
def upgrade():
### commands auto generated by Alembic - please adjust! ###
op.create_table('sources',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('label', sa.String(length=32), nullable=True),
sa.Column('options', sqlalchemy_utils.types.json.JSONType(), nullable=True),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('plugin_name', sa.String(length=32), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_table('certificate_source_associations',
sa.Column('source_id', sa.Integer(), nullable=True),
sa.Column('certificate_id', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['certificate_id'], ['certificates.id'], ondelete='cascade'),
sa.ForeignKeyConstraint(['source_id'], ['destinations.id'], ondelete='cascade')
)
### end Alembic commands ###
def downgrade():
### commands auto generated by Alembic - please adjust! ###
op.drop_table('certificate_source_associations')
op.drop_table('sources')
### end Alembic commands ###

View File

@ -0,0 +1,41 @@
"""Adding notifications
Revision ID: 4c8915e461b3
Revises: 3b718f59b8ce
Create Date: 2015-07-24 14:34:57.316273
"""
# revision identifiers, used by Alembic.
revision = '4c8915e461b3'
down_revision = '3b718f59b8ce'
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
import sqlalchemy_utils
def upgrade():
### commands auto generated by Alembic - please adjust! ###
op.create_table('notifications',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('label', sa.String(length=128), nullable=True),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('options', sqlalchemy_utils.types.json.JSONType(), nullable=True),
sa.Column('active', sa.Boolean(), nullable=True),
sa.Column('plugin_name', sa.String(length=32), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.drop_column(u'certificates', 'challenge')
op.drop_column(u'certificates', 'csr_config')
### end Alembic commands ###
def downgrade():
### commands auto generated by Alembic - please adjust! ###
op.add_column(u'certificates', sa.Column('csr_config', sa.TEXT(), autoincrement=False, nullable=True))
op.add_column(u'certificates', sa.Column('challenge', postgresql.BYTEA(), autoincrement=False, nullable=True))
op.drop_table('notifications')
### end Alembic commands ###

View File

@ -0,0 +1,31 @@
"""Creating a one-to-many relationship for notifications
Revision ID: 4dc5ddd111b8
Revises: 4c8915e461b3
Create Date: 2015-07-24 15:02:04.398262
"""
# revision identifiers, used by Alembic.
revision = '4dc5ddd111b8'
down_revision = '4c8915e461b3'
from alembic import op
import sqlalchemy as sa
def upgrade():
### commands auto generated by Alembic - please adjust! ###
op.create_table('certificate_notification_associations',
sa.Column('notification_id', sa.Integer(), nullable=True),
sa.Column('certificate_id', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['certificate_id'], ['certificates.id'], ondelete='cascade'),
sa.ForeignKeyConstraint(['notification_id'], ['notifications.id'], ondelete='cascade')
)
### end Alembic commands ###
def downgrade():
### commands auto generated by Alembic - please adjust! ###
op.drop_table('certificate_notification_associations')
### end Alembic commands ###

View File

@ -8,9 +8,7 @@
:license: Apache, see LICENSE for more details.
.. moduleauthor:: Kevin Glisson <kglisson@netflix.com>
"""
from sqlalchemy import Column, Integer, ForeignKey
from lemur.database import db
certificate_associations = db.Table('certificate_associations',
@ -25,6 +23,13 @@ certificate_destination_associations = db.Table('certificate_destination_associa
ForeignKey('certificates.id', ondelete='cascade'))
)
certificate_source_associations = db.Table('certificate_source_associations',
Column('source_id', Integer,
ForeignKey('destinations.id', ondelete='cascade')),
Column('certificate_id', Integer,
ForeignKey('certificates.id', ondelete='cascade'))
)
certificate_notification_associations = db.Table('certificate_notification_associations',
Column('notification_id', Integer,
ForeignKey('notifications.id', ondelete='cascade')),

29
lemur/sources/models.py Normal file
View File

@ -0,0 +1,29 @@
"""
.. module: lemur.sources.models
:platform: unix
:copyright: (c) 2015 by Netflix Inc., see AUTHORS for more
:license: Apache, see LICENSE for more details.
.. moduleauthor:: Kevin Glisson <kglisson@netflix.com>
"""
import copy
from sqlalchemy import Column, Integer, String, Text
from sqlalchemy_utils import JSONType
from lemur.database import db
from lemur.plugins.base import plugins
class Source(db.Model):
__tablename__ = 'sources'
id = Column(Integer, primary_key=True)
label = Column(String(32))
options = Column(JSONType)
description = Column(Text())
plugin_name = Column(String(32))
@property
def plugin(self):
p = plugins.get(self.plugin_name)
c = copy.deepcopy(p)
c.options = self.options
return c

107
lemur/sources/service.py Normal file
View File

@ -0,0 +1,107 @@
"""
.. module: lemur.sources.service
:platform: Unix
:copyright: (c) 2015 by Netflix Inc., see AUTHORS for more
:license: Apache, see LICENSE for more details.
.. moduleauthor:: Kevin Glisson <kglisson@netflix.com>
"""
from lemur import database
from lemur.sources.models import Source
from lemur.certificates.models import Certificate
def create(label, plugin_name, options, description=None):
"""
Creates a new source, that can then be used as a source for certificates.
:param label: Source common name
:param description:
:rtype : Source
:return: New source
"""
source = Source(label=label, options=options, plugin_name=plugin_name, description=description)
return database.create(source)
def update(source_id, label, options, description):
"""
Updates an existing source.
:param source_id: Lemur assigned ID
:param label: Source common name
:rtype : Source
:return:
"""
source = get(source_id)
source.label = label
source.options = options
source.description = description
return database.update(source)
def delete(source_id):
"""
Deletes an source.
:param source_id: Lemur assigned ID
"""
database.delete(get(source_id))
def get(source_id):
"""
Retrieves an source by it's lemur assigned ID.
:param source_id: Lemur assigned ID
:rtype : Source
:return:
"""
return database.get(Source, source_id)
def get_by_label(label):
"""
Retrieves a source by it's label
:param label:
:return:
"""
return database.get(Source, label, field='label')
def get_all():
"""
Retrieves all source currently known by Lemur.
:return:
"""
query = database.session_query(Source)
return database.find_all(query, Source, {}).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(Source).join(Certificate, Source.certificate)
query = query.filter(Certificate.id == certificate_id)
else:
query = database.session_query(Source)
if filt:
terms = filt.split(';')
query = database.filter(query, Source, terms)
query = database.find_all(query, Source, args)
if sort_by and sort_dir:
query = database.sort(query, Source, sort_by, sort_dir)
return database.paginate(query, page, count)

View File

@ -1,5 +1,5 @@
"""
.. module: sync
.. module: lemur.sources.sync
:platform: Unix
:synopsis: This module contains various certificate syncing operations.
Because of the nature of the SSL environment there are multiple ways

359
lemur/sources/views.py Normal file
View File

@ -0,0 +1,359 @@
"""
.. module: lemur.sources.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 <kglisson@netflix.com>
"""
from flask import Blueprint
from flask.ext.restful import Api, reqparse, fields
from lemur.sources import service
from lemur.auth.service import AuthenticatedResource
from lemur.auth.permissions import admin_permission
from lemur.common.utils import paginated_parser, marshal_items
mod = Blueprint('sources', __name__)
api = Api(mod)
FIELDS = {
'description': fields.String,
'sourceOptions': fields.Raw(attribute='options'),
'pluginName': fields.String(attribute='plugin_name'),
'label': fields.String,
'id': fields.Integer,
}
class SourcesList(AuthenticatedResource):
""" Defines the 'sources' endpoint """
def __init__(self):
self.reqparse = reqparse.RequestParser()
super(SourcesList, self).__init__()
@marshal_items(FIELDS)
def get(self):
"""
.. http:get:: /sources
The current account list
**Example request**:
.. sourcecode:: http
GET /sources 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": [
{
"sourceOptions": [
{
"name": "accountNumber",
"required": true,
"value": 111111111112,
"helpMessage": "Must be a valid AWS account number!",
"validation": "/^[0-9]{12,12}$/",
"type": "int"
}
],
"pluginName": "aws-source",
"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
: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)
@admin_permission.require(http_exception=403)
@marshal_items(FIELDS)
def post(self):
"""
.. http:post:: /sources
Creates a new account
**Example request**:
.. sourcecode:: http
POST /sources HTTP/1.1
Host: example.com
Accept: application/json, text/javascript
{
"sourceOptions": [
{
"name": "accountNumber",
"required": true,
"value": 111111111112,
"helpMessage": "Must be a valid AWS account number!",
"validation": "/^[0-9]{12,12}$/",
"type": "int"
}
],
"pluginName": "aws-source",
"id": 3,
"description": "test",
"label": "test"
}
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: text/javascript
{
"sourceOptions": [
{
"name": "accountNumber",
"required": true,
"value": 111111111112,
"helpMessage": "Must be a valid AWS account number!",
"validation": "/^[0-9]{12,12}$/",
"type": "int"
}
],
"pluginName": "aws-source",
"id": 3,
"description": "test",
"label": "test"
}
:arg label: human readable account label
: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('plugin', type=dict, location='json', required=True)
self.reqparse.add_argument('description', type=str, location='json')
args = self.reqparse.parse_args()
return service.create(args['label'], args['plugin']['slug'], args['plugin']['pluginOptions'], args['description'])
class Sources(AuthenticatedResource):
def __init__(self):
self.reqparse = reqparse.RequestParser()
super(Sources, self).__init__()
@marshal_items(FIELDS)
def get(self, source_id):
"""
.. http:get:: /sources/1
Get a specific account
**Example request**:
.. sourcecode:: http
GET /sources/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
{
"sourceOptions": [
{
"name": "accountNumber",
"required": true,
"value": 111111111112,
"helpMessage": "Must be a valid AWS account number!",
"validation": "/^[0-9]{12,12}$/",
"type": "int"
}
],
"pluginName": "aws-source",
"id": 3,
"description": "test",
"label": "test"
}
:reqheader Authorization: OAuth token to authenticate
:statuscode 200: no error
"""
return service.get(source_id)
@admin_permission.require(http_exception=403)
@marshal_items(FIELDS)
def put(self, source_id):
"""
.. http:put:: /sources/1
Updates an account
**Example request**:
.. sourcecode:: http
POST /sources/1 HTTP/1.1
Host: example.com
Accept: application/json, text/javascript
{
"sourceOptions": [
{
"name": "accountNumber",
"required": true,
"value": 111111111112,
"helpMessage": "Must be a valid AWS account number!",
"validation": "/^[0-9]{12,12}$/",
"type": "int"
}
],
"pluginName": "aws-source",
"id": 3,
"description": "test",
"label": "test"
}
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: text/javascript
{
"sourceOptions": [
{
"name": "accountNumber",
"required": true,
"value": 111111111112,
"helpMessage": "Must be a valid AWS account number!",
"validation": "/^[0-9]{12,12}$/",
"type": "int"
}
],
"pluginName": "aws-source",
"id": 3,
"description": "test",
"label": "test"
}
:arg accountNumber: aws account number
:arg label: human readable account label
: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('plugin', type=dict, location='json', required=True)
self.reqparse.add_argument('description', type=str, location='json')
args = self.reqparse.parse_args()
return service.update(source_id, args['label'], args['plugin']['pluginOptions'], args['description'])
@admin_permission.require(http_exception=403)
def delete(self, source_id):
service.delete(source_id)
return {'result': True}
class CertificateSources(AuthenticatedResource):
""" Defines the 'certificate/<int:certificate_id/sources'' endpoint """
def __init__(self):
super(CertificateSources, self).__init__()
@marshal_items(FIELDS)
def get(self, certificate_id):
"""
.. http:get:: /certificates/1/sources
The current account list for a given certificates
**Example request**:
.. sourcecode:: http
GET /certificates/1/sources 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": [
{
"sourceOptions": [
{
"name": "accountNumber",
"required": true,
"value": 111111111112,
"helpMessage": "Must be a valid AWS account number!",
"validation": "/^[0-9]{12,12}$/",
"type": "int"
}
],
"pluginName": "aws-source",
"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
: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()
args['certificate_id'] = certificate_id
return service.render(args)
api.add_resource(SourcesList, '/sources', endpoint='sources')
api.add_resource(Sources, '/sources/<int:source_id>', endpoint='account')
api.add_resource(CertificateSources, '/certificates/<int:certificate_id>/sources',
endpoint='certificateSources')

View File

@ -0,0 +1,38 @@
<div class="modal-header">
<div class="modal-title">
<h3 class="modal-header">Edit <span class="text-muted"><small>{{ certificate.name }}</small></span></h3>
</div>
<div class="modal-body">
<form name="editForm" class="form-horizontal" role="form" novalidate>
<div class="form-group"
ng-class="{'has-error': editForm.owner.$invalid, 'has-success': !editForm.owner.$invalid&&editForm.owner.$dirty}">
<label class="control-label col-sm-2">
Owner
</label>
<div class="col-sm-10">
<input type="email" name="owner" ng-model="certificate.owner" placeholder="owner@netflix.com"
class="form-control" required/>
<p ng-show="editForm.owner.$invalid && !editForm.owner.$pristine" class="help-block">Enter a valid
email.</p>
</div>
</div>
<div class="form-group"
ng-class="{'has-error': editForm.description.$invalid, 'has-success': !editForm.$invalid&&editForm.description.$dirty}">
<label class="control-label col-sm-2">
Description
</label>
<div class="col-sm-10">
<textarea name="description" ng-model="certificate.description" placeholder="Something elegant" class="form-control" ng-pattern="/^[\w\-\s]+$/" required></textarea>
<p ng-show="editForm.description.$invalid && !editForm.description.$pristine" class="help-block">You must give a short description about this authority will be used for, this description should only include alphanumeric characters</p>
</div>
</div>
<div ng-include="'angular/certificates/certificate/notifications.tpl.html'"></div>
<div ng-include="'angular/certificates/certificate/destinations.tpl.html'"></div>
</form>
</div>
<div class="modal-footer">
<button type="submit" ng-click="save(certificate)" ng-disabled="editForm.$invalid" class="btn btn-success">Save</button>
<button ng-click="cancel()" class="btn btn-danger">Cancel</button>
</div>
</div>

View File

@ -0,0 +1,28 @@
<div class="form-group">
<label class="control-label col-sm-2">
Notifications
</label>
<div class="col-sm-10">
<div class="input-group">
<input type="text" ng-model="certificate.selectedNotification" placeholder="Email"
typeahead="notification.label for notification in notificationService.findNotificationsByName($viewValue)" typeahead-loading="loadingDestinations"
class="form-control input-md" typeahead-on-select="certificate.attachNotification($item)" typeahead-min-wait="50"
tooltip="By default Lemur will always notify you about this certificate through Email notifications."
tooltip-trigger="focus" tooltip-placement="top">
<span class="input-group-btn">
<button ng-model="notifications.show" class="btn btn-md btn-default" btn-checkbox btn-checkbox-true="1" btn-checkbox-false="0">
<span class="badge">{{ certificate.notifications.length || 0 }}</span>
</button>
</span>
</div>
<table class="table">
<tr ng-repeat="notification in certificate.notifications track by $index">
<td><a class="btn btn-sm btn-info" href="#/notifications/{{ notification.id }}/certificates">{{ notification.label }}</a></td>
<td><span class="text-muted">{{ notification.description }}</span></td>
<td>
<button type="button" ng-click="certificate.removeNotification($index)" class="btn btn-danger btn-sm pull-right">Remove</button>
</td>
</tr>
</table>
</div>
</div>

View File

@ -0,0 +1,56 @@
'use strict';
angular.module('lemur')
.controller('NotificationsCreateController', function ($scope, $modalInstance, PluginService, NotificationService, CertificateService, LemurRestangular){
$scope.notification = LemurRestangular.restangularizeElement(null, {}, 'notifications');
PluginService.getByType('notification').then(function (plugins) {
$scope.plugins = plugins;
});
$scope.save = function (notification) {
NotificationService.create(notification).then(
function () {
$modalInstance.close();
},
function () {
}
);
};
$scope.cancel = function () {
$modalInstance.dismiss('cancel');
};
$scope.certificateService = CertificateService;
})
.controller('NotificationsEditController', function ($scope, $modalInstance, NotificationService, NotificationApi, PluginService, CertificateService, editId) {
NotificationApi.get(editId).then(function (notification) {
$scope.notification = notification;
NotificationService.getCertificates(notification);
});
PluginService.getByType('notification').then(function (plugins) {
$scope.plugins = plugins;
_.each($scope.plugins, function (plugin) {
if (plugin.slug == $scope.notification.pluginName) {
plugin.pluginOptions = $scope.notification.notificationOptions;
$scope.notification.plugin = plugin;
};
});
});
$scope.save = function (notification) {
NotificationService.update(notification).then(function () {
$modalInstance.close();
});
};
$scope.cancel = function () {
$modalInstance.dismiss('cancel');
};
$scope.certificateService = CertificateService;
});

View File

@ -0,0 +1,87 @@
<div class="modal-header">
<div class="modal-title">
<h3 class="modal-header"><span ng-show="!notification.fromServer">Create</span><span ng-show="notification.fromServer">Edit</span> Notification <span class="text-muted"><small>you gotta speak louder son!</small></span></h3>
</div>
<div class="modal-body">
<form name="createForm" class="form-horizontal" role="form" novalidate>
<div class="form-group"
ng-class="{'has-error': createForm.label.$invalid, 'has-success': !createForm.label.$invalid&&createForm.label.$dirty}">
<label class="control-label col-sm-2">
Label
</label>
<div class="col-sm-10">
<input name="label" ng-model="notification.label" placeholder="Label" class="form-control" required/>
<p ng-show="createForm.label.$invalid && !createForm.label.$pristine" class="help-block">You must enter an notification label</p>
</div>
</div>
<div class="form-group">
<label class="control-label col-sm-2">
Description
</label>
<div class="col-sm-10">
<textarea name="comments" ng-model="notification.description" placeholder="Something elegant" class="form-control" ></textarea>
</div>
</div>
<div class="form-group">
<label class="control-label col-sm-2">
Plugin
</label>
<div class="col-sm-10">
<select class="form-control" ng-model="notification.plugin" ng-options="plugin.title for plugin in plugins" required></select>
</div>
</div>
<div class="form-group" ng-repeat="item in notification.plugin.pluginOptions">
<ng-form name="subForm" class="form-horizontal" role="form" novalidate>
<div ng-class="{'has-error': subForm.sub.$invalid, 'has-success': !subForm.sub.$invalid&&subForm.sub.$dirty}">
<label class="control-label col-sm-2">
{{ item.name | titleCase }}
</label>
<div class="col-sm-10">
<input name="sub" ng-if="item.type == 'int'" type="number" ng-pattern="item.validation" class="form-control" ng-model="item.value"/>
<select name="sub" ng-if="item.type == 'select'" class="form-control" ng-options="i as (i | titleCase) for i in item.available" ng-model="item.value"></select>
<input name="sub" ng-if="item.type == 'bool'" class="form-control" type="checkbox" ng-model="item.value">
<input name="sub" ng-if="item.type == 'str'" ng-pattern="item.validation" type="text" class="form-control" ng-model="item.value"/>
<p ng-show="subForm.sub.$invalid && !subForm.sub.$pristine" class="help-block">{{ item.helpMessage }}</p>
</div>
</div>
</ng-form>
</div>
<div class="form-group">
<label class="control-label col-sm-2">
Certificates
</label>
<div class="col-sm-10">
<div class="input-group">
<input type="text" ng-model="notification.selectedCertificate" placeholder="Certificate Name"
typeahead="certificate.name for certificate in certificateService.findCertificatesByName($viewValue)" typeahead-loading="loadingCertificates"
class="form-control input-md" typeahead-on-select="notification.attachCertificate($item)" typeahead-min-wait="50">
<span class="input-group-btn">
<button ng-model="certificates.show" class="btn btn-md btn-default" btn-checkbox btn-checkbox-true="1" btn-checkbox-false="0">
<span class="badge">{{ notification.certificates.total || 0 }}</span>
</button>
</span>
</div>
<table ng-show="notification.certificates" class="table">
<tr ng-repeat="certificate in notification.certificates track by $index">
<td><a class="btn btn-sm btn-info" href="#">{{ certificate.name }}</a></td>
<td><span class="text-muted">{{ certificate.description }}</span></td>
<td>
<button type="button" ng-click="notification.removeCertificate($index)" class="btn btn-danger btn-sm pull-right">Remove</button>
</td>
</tr>
<tr>
<td></td>
<td></td>
<td><a class="pull-right" ng-click="loadMoreCertificates()"><strong>More</strong></a></td>
</tr>
</table>
</div>
</div>
</form>
</div>
<div class="modal-footer">
<button ng-click="save(notification)" type="submit" ng-disabled="createForm.$invalid" class="btn btn-primary">Save</button>
<button ng-click="cancel()" class="btn btn-danger">Cancel</button>
</div>
</div>

View File

@ -0,0 +1,106 @@
'use strict';
angular.module('lemur')
.service('NotificationApi', function (LemurRestangular) {
LemurRestangular.extendModel('notifications', function (obj) {
return angular.extend(obj, {
attachCertificate: function (certificate) {
this.selectedCertificate = null;
if (this.certificates === undefined) {
this.certificates = [];
}
this.certificates.push(certificate);
},
removeCertificate: function (index) {
this.certificate.splice(index, 1);
}
});
});
return LemurRestangular.all('notifications');
})
.service('NotificationService', function ($location, NotificationApi, PluginService, toaster) {
var NotificationService = this;
NotificationService.findNotificationsByName = function (filterValue) {
return NotificationApi.getList({'filter[label]': filterValue})
.then(function (notifications) {
return notifications;
});
};
NotificationService.getCertificates = function (notification) {
notification.getList('certificates').then(function (certificates) {
notification.certificates = certificates;
});
};
NotificationService.getPlugin = function (notification) {
return PluginService.getByName(notification.pluginName).then(function (plugin) {
notification.plugin = plugin;
});
};
NotificationService.loadMoreCertificates = function (notification, page) {
notification.getList('certificates', {page: page}).then(function (certificates) {
_.each(certificates, function (certificate) {
notification.roles.push(certificate);
});
});
};
NotificationService.create = function (notification) {
return NotificationApi.post(notification).then(
function () {
toaster.pop({
type: 'success',
title: notification.label,
body: 'Successfully created!'
});
$location.path('notifications');
},
function (response) {
toaster.pop({
type: 'error',
title: notification.label,
body: 'Was not created! ' + response.data.message
});
});
};
NotificationService.update = function (notification) {
return notification.put().then(
function () {
toaster.pop({
type: 'success',
title: notification.label,
body: 'Successfully updated!'
});
$location.path('notifications');
},
function (response) {
toaster.pop({
type: 'error',
title: notification.label,
body: 'Was not updated! ' + response.data.message
});
});
};
NotificationService.updateActive = function (notification) {
notification.put().then(
function () {
toaster.pop({
type: 'success',
title: notification.name,
body: 'Successfully updated!'
});
},
function (response) {
toaster.pop({
type: 'error',
title: notification.name,
body: 'Was not updated! ' + response.data.message
});
});
};
return NotificationService;
});

View File

@ -0,0 +1,96 @@
'use strict';
angular.module('lemur')
.config(function config($routeProvider) {
$routeProvider.when('/notifications', {
templateUrl: '/angular/notifications/view/view.tpl.html',
controller: 'NotificationsViewController'
});
})
.controller('NotificationsViewController', function ($q, $scope, $modal, NotificationApi, NotificationService, ngTableParams, toaster) {
$scope.filter = {};
$scope.notificationsTable = new ngTableParams({
page: 1, // show first page
count: 10, // count per page
sorting: {
id: 'desc' // initial sorting
},
filter: $scope.filter
}, {
total: 0, // length of data
getData: function ($defer, params) {
NotificationApi.getList(params.url()).then(
function (data) {
_.each(data, function (notification) {
NotificationService.getPlugin(notification);
});
params.total(data.total);
$defer.resolve(data);
}
);
}
});
$scope.getNotificationStatus = function () {
var def = $q.defer();
def.resolve([{'title': 'Active', 'id': true}, {'title': 'Inactive', 'id': false}]);
return def;
};
$scope.remove = function (notification) {
notification.remove().then(
function () {
$scope.notificationsTable.reload();
},
function (response) {
toaster.pop({
type: 'error',
title: 'Opps',
body: 'I see what you did there' + response.data.message
});
}
);
};
$scope.edit = function (notificationId) {
var modalInstance = $modal.open({
animation: true,
templateUrl: '/angular/notifications/notification/notification.tpl.html',
controller: 'NotificationsEditController',
size: 'lg',
resolve: {
editId: function () {
return notificationId;
}
}
});
modalInstance.result.then(function () {
$scope.notificationsTable.reload();
});
};
$scope.create = function () {
var modalInstance = $modal.open({
animation: true,
controller: 'NotificationsCreateController',
templateUrl: '/angular/notifications/notification/notification.tpl.html',
size: 'lg'
});
modalInstance.result.then(function () {
$scope.notificationsTable.reload();
});
};
$scope.toggleFilter = function (params) {
params.settings().$scope.show_filter = !params.settings().$scope.show_filter;
};
$scope.notificationService = NotificationService;
});

View File

@ -0,0 +1,52 @@
<div class="row">
<div class="col-md-12">
<h2 class="featurette-heading">Notifications
<span class="text-muted"><small>you have to speak up son!</small></span></h2>
<div class="panel panel-default">
<div class="panel-heading">
<div class="btn-group pull-right">
<button ng-click="create()" class="btn btn-primary">Create</button>
</div>
<div class="btn-group">
<button ng-click="toggleFilter(notificationsTable)" class="btn btn-default">Filter</button>
</div>
<div class="clearfix"></div>
</div>
<div class="table-responsive">
<table ng-table="notificationsTable" class="table table-striped" show-filter="false" template-pagination="angular/pager.html" >
<tbody>
<tr ng-repeat="notification in $data track by $index">
<td data-title="'Label'" sortable="'label'" filter="{ 'label': 'text' }">
<ul class="list-unstyled">
<li>{{ notification.label }}</li>
<li><span class="text-muted">{{ notification.description }}</span></li>
</ul>
</td>
<td data-title="'Plugin'">
<ul class="list-unstyled">
<li>{{ notification.plugin.title }}</li>
<li><span class="text-muted">{{ notification.plugin.description }}</span></li>
</ul>
</td>
<td data-title="'Active'" filter="{ 'active': 'select' }" filter-data="getNotificationStatus()">
<form>
<switch ng-change="notificationService.updateActive(notification)" id="status" name="status" ng-model="notification.active" class="green small"></switch>
</form>
</td>
<td data-title="''">
<div class="btn-group-vertical pull-right">
<button tooltip="Edit Notification" ng-click="edit(notification.id)" class="btn btn-sm btn-info">
Edit
</button>
<button tooltip="Delete Notification" ng-click="remove(notification)" type="button" class="btn btn-sm btn-danger pull-left">
Remove
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>