Initial work allowing certificates to be revoked. (#941)

* Initial work allowing for certificates to be revoked.
This commit is contained in:
kevgliss 2017-09-28 18:27:56 -07:00 committed by GitHub
parent ea6f5c920b
commit bb08b1e637
23 changed files with 286 additions and 47 deletions

View File

@ -80,6 +80,7 @@ def get_or_increase_name(name):
class Certificate(db.Model): class Certificate(db.Model):
__tablename__ = 'certificates' __tablename__ = 'certificates'
id = Column(Integer, primary_key=True) id = Column(Integer, primary_key=True)
external_id = Column(String(128))
owner = Column(String(128), nullable=False) owner = Column(String(128), nullable=False)
name = Column(String(128), unique=True) name = Column(String(128), unique=True)
description = Column(String(1024)) description = Column(String(1024))
@ -162,6 +163,7 @@ class Certificate(db.Model):
self.signing_algorithm = defaults.signing_algorithm(cert) self.signing_algorithm = defaults.signing_algorithm(cert)
self.bits = defaults.bitstrength(cert) self.bits = defaults.bitstrength(cert)
self.serial = defaults.serial(cert) self.serial = defaults.serial(cert)
self.external_id = kwargs.get('external_id')
for domain in defaults.domains(cert): for domain in defaults.domains(cert):
self.domains.append(Domain(name=domain)) self.domains.append(Domain(name=domain))

View File

@ -172,6 +172,7 @@ class CertificateCloneSchema(LemurOutputSchema):
class CertificateOutputSchema(LemurOutputSchema): class CertificateOutputSchema(LemurOutputSchema):
id = fields.Integer() id = fields.Integer()
external_id = fields.String()
bits = fields.Integer() bits = fields.Integer()
body = fields.String() body = fields.String()
chain = fields.String() chain = fields.String()
@ -253,6 +254,10 @@ class CertificateNotificationOutputSchema(LemurOutputSchema):
endpoints = fields.Nested(EndpointNestedOutputSchema, many=True, missing=[]) endpoints = fields.Nested(EndpointNestedOutputSchema, many=True, missing=[])
class CertificateRevokeSchema(LemurInputSchema):
comments = fields.String()
certificate_input_schema = CertificateInputSchema() certificate_input_schema = CertificateInputSchema()
certificate_output_schema = CertificateOutputSchema() certificate_output_schema = CertificateOutputSchema()
certificates_output_schema = CertificateOutputSchema(many=True) certificates_output_schema = CertificateOutputSchema(many=True)
@ -260,3 +265,4 @@ certificate_upload_input_schema = CertificateUploadInputSchema()
certificate_export_input_schema = CertificateExportInputSchema() certificate_export_input_schema = CertificateExportInputSchema()
certificate_edit_input_schema = CertificateEditInputSchema() certificate_edit_input_schema = CertificateEditInputSchema()
certificate_notification_output_schema = CertificateNotificationOutputSchema() certificate_notification_output_schema = CertificateNotificationOutputSchema()
certificate_revoke_schema = CertificateRevokeSchema()

View File

@ -180,8 +180,8 @@ def mint(**kwargs):
private_key = None private_key = None
csr_imported.send(authority=authority, csr=csr) csr_imported.send(authority=authority, csr=csr)
cert_body, cert_chain = issuer.create_certificate(csr, kwargs) cert_body, cert_chain, external_id = issuer.create_certificate(csr, kwargs)
return cert_body, private_key, cert_chain, return cert_body, private_key, cert_chain, external_id
def import_certificate(**kwargs): def import_certificate(**kwargs):
@ -234,10 +234,11 @@ def create(**kwargs):
""" """
Creates a new certificate. Creates a new certificate.
""" """
cert_body, private_key, cert_chain = mint(**kwargs) cert_body, private_key, cert_chain, external_id = mint(**kwargs)
kwargs['body'] = cert_body kwargs['body'] = cert_body
kwargs['private_key'] = private_key kwargs['private_key'] = private_key
kwargs['chain'] = cert_chain kwargs['chain'] = cert_chain
kwargs['external_id'] = external_id
roles = create_certificate_roles(**kwargs) roles = create_certificate_roles(**kwargs)

View File

@ -18,8 +18,16 @@ from lemur.auth.service import AuthenticatedResource
from lemur.auth.permissions import AuthorityPermission, CertificatePermission from lemur.auth.permissions import AuthorityPermission, CertificatePermission
from lemur.certificates import service from lemur.certificates import service
from lemur.certificates.schemas import certificate_input_schema, certificate_output_schema, \ from lemur.plugins.base import plugins
certificate_upload_input_schema, certificates_output_schema, certificate_export_input_schema, certificate_edit_input_schema from lemur.certificates.schemas import (
certificate_input_schema,
certificate_output_schema,
certificate_upload_input_schema,
certificates_output_schema,
certificate_export_input_schema,
certificate_edit_input_schema,
certificate_revoke_schema
)
from lemur.roles import service as role_service from lemur.roles import service as role_service
from lemur.logs import service as log_service from lemur.logs import service as log_service
@ -944,6 +952,73 @@ class CertificateExport(AuthenticatedResource):
return dict(extension=extension, passphrase=passphrase, data=base64.b64encode(data).decode('utf-8')) return dict(extension=extension, passphrase=passphrase, data=base64.b64encode(data).decode('utf-8'))
class CertificateRevoke(AuthenticatedResource):
def __init__(self):
self.reqparse = reqparse.RequestParser()
super(CertificateRevoke, self).__init__()
@validate_schema(certificate_revoke_schema, None)
def put(self, certificate_id, data=None):
"""
.. http:put:: /certificates/1/revoke
Revoke a certificate
**Example request**:
.. sourcecode:: http
POST /certificates/1/revoke HTTP/1.1
Host: example.com
Accept: application/json, text/javascript
{
"comments": "Certificate no longer needed"
}
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: text/javascript
{
'id': 1
}
:reqheader Authorization: OAuth token to authenticate
:statuscode 200: no error
:statuscode 403: unauthenticated
"""
cert = service.get(certificate_id)
if not cert:
return dict(message="Cannot find specified certificate"), 404
# allow creators
if g.current_user != cert.user:
owner_role = role_service.get_by_name(cert.owner)
permission = CertificatePermission(owner_role, [x.name for x in cert.roles])
if not permission.can():
return dict(message='You are not authorized to revoke this certificate.'), 403
if not cert.external_id:
return dict(message='Cannot revoke certificate. No external id found.'), 400
if cert.endpoints:
return dict(message='Cannot revoke certificate. Endpoints are deployed with the given certificate.'), 403
plugin = plugins.get(cert.authority.plugin_name)
plugin.revoke_certificate(cert, data)
log_service.create(g.current_user, 'revoke_cert', certificate=cert)
return dict(id=cert.id)
api.add_resource(CertificateRevoke, '/certificates/<int:certificate_id>/revoke', endpoint='revokeCertificate')
api.add_resource(CertificatesList, '/certificates', endpoint='certificates') api.add_resource(CertificatesList, '/certificates', endpoint='certificates')
api.add_resource(Certificates, '/certificates/<int:certificate_id>', endpoint='certificate') api.add_resource(Certificates, '/certificates/<int:certificate_id>', endpoint='certificate')
api.add_resource(CertificatesStats, '/certificates/stats', endpoint='certificateStats') api.add_resource(CertificatesStats, '/certificates/stats', endpoint='certificateStats')

View File

@ -18,6 +18,6 @@ class Log(db.Model):
__tablename__ = 'logs' __tablename__ = 'logs'
id = Column(Integer, primary_key=True) id = Column(Integer, primary_key=True)
certificate_id = Column(Integer, ForeignKey('certificates.id')) certificate_id = Column(Integer, ForeignKey('certificates.id'))
log_type = Column(Enum('key_view', 'create_cert', 'update_cert', name='log_type'), nullable=False) log_type = Column(Enum('key_view', 'create_cert', 'update_cert', 'revoke_cert', name='log_type'), nullable=False)
logged_at = Column(ArrowType(), PassiveDefault(func.now()), nullable=False) logged_at = Column(ArrowType(), PassiveDefault(func.now()), nullable=False)
user_id = Column(Integer, ForeignKey('users.id'), nullable=False) user_id = Column(Integer, ForeignKey('users.id'), nullable=False)

View File

@ -3,6 +3,9 @@ from alembic import context
from sqlalchemy import engine_from_config, pool from sqlalchemy import engine_from_config, pool
from logging.config import fileConfig from logging.config import fileConfig
import alembic_autogenerate_enums
# this is the Alembic Config object, which provides # this is the Alembic Config object, which provides
# access to the values within the .ini file in use. # access to the values within the .ini file in use.
config = context.config config = context.config

View File

@ -14,28 +14,8 @@ from alembic import op
def upgrade(): def upgrade():
connection = None op.sync_enum_values('public', 'log_type', ['key_view'], ['create_cert', 'key_view', 'update_cert'])
if not op.get_context().as_sql:
connection = op.get_bind()
connection.execution_options(isolation_level='AUTOCOMMIT')
op.execute("ALTER TYPE log_type ADD VALUE 'create_cert'")
op.execute("ALTER TYPE log_type ADD VALUE 'update_cert'")
if connection is not None:
connection.execution_options(isolation_level='READ_COMMITTED')
def downgrade(): def downgrade():
connection = None op.sync_enum_values('public', 'log_type', ['create_cert', 'key_view', 'update_cert'], ['key_view'])
if not op.get_context().as_sql:
connection = op.get_bind()
connection.execution_options(isolation_level='AUTOCOMMIT')
op.execute("ALTER TYPE log_type DROP VALUE 'create_cert'")
op.execute("ALTER TYPE log_type DROP VALUE 'update_cert'")
if connection is not None:
connection.execution_options(isolation_level='READ_COMMITTED')

View File

@ -0,0 +1,28 @@
"""Adds external ID checking and modifying enum
Revision ID: b29e2c4bf8c9
Revises: 1ae8e3104db8
Create Date: 2017-09-26 10:50:35.740367
"""
# revision identifiers, used by Alembic.
revision = 'b29e2c4bf8c9'
down_revision = '1ae8e3104db8'
from alembic import op
import sqlalchemy as sa
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('certificates', sa.Column('external_id', sa.String(128), nullable=True))
op.sync_enum_values('public', 'log_type', ['create_cert', 'key_view', 'update_cert'], ['create_cert', 'key_view', 'revoke_cert', 'update_cert'])
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.sync_enum_values('public', 'log_type', ['create_cert', 'key_view', 'revoke_cert', 'update_cert'], ['create_cert', 'key_view', 'update_cert'])
op.drop_column('certificates', 'external_id')
# ### end Alembic commands ###

View File

@ -21,3 +21,6 @@ class IssuerPlugin(Plugin):
def create_authority(self, options): def create_authority(self, options):
raise NotImplementedError raise NotImplementedError
def revoke_certificate(self, certificate, comments):
raise NotImplementedError

View File

@ -49,7 +49,8 @@ class CfsslIssuerPlugin(IssuerPlugin):
response_json = json.loads(response.content.decode('utf_8')) response_json = json.loads(response.content.decode('utf_8'))
cert = response_json['result']['certificate'] cert = response_json['result']['certificate']
return cert, current_app.config.get('CFSSL_INTERMEDIATE'), # TODO add external ID
return cert, current_app.config.get('CFSSL_INTERMEDIATE'), None
@staticmethod @staticmethod
def create_authority(options): def create_authority(options):

View File

@ -187,7 +187,7 @@ class CryptographyIssuerPlugin(IssuerPlugin):
""" """
current_app.logger.debug("Issuing new cryptography certificate with options: {0}".format(options)) current_app.logger.debug("Issuing new cryptography certificate with options: {0}".format(options))
cert_pem, chain_cert_pem = issue_certificate(csr, options) cert_pem, chain_cert_pem = issue_certificate(csr, options)
return cert_pem, chain_cert_pem return cert_pem, chain_cert_pem, None
@staticmethod @staticmethod
def create_authority(options): def create_authority(options):

View File

@ -312,7 +312,17 @@ class DigiCertIssuerPlugin(IssuerPlugin):
# retrieve certificate # retrieve certificate
certificate_url = "{0}/services/v2/certificate/{1}/download/format/pem_all".format(base_url, certificate_id) certificate_url = "{0}/services/v2/certificate/{1}/download/format/pem_all".format(base_url, certificate_id)
end_entity, intermediate, root = pem.parse(self.session.get(certificate_url).content) end_entity, intermediate, root = pem.parse(self.session.get(certificate_url).content)
return "\n".join(str(end_entity).splitlines()), "\n".join(str(intermediate).splitlines()) return "\n".join(str(end_entity).splitlines()), "\n".join(str(intermediate).splitlines()), certificate_id
def revoke_certificate(self, certificate, comments):
"""Revoke a Digicert certificate."""
base_url = current_app.config.get('DIGICERT_URL')
# make certificate revoke request
create_url = '{0}/certificate/{1}/revoke'.format(base_url, certificate.external_id)
metrics.send('digicert_revoke_certificate', 'counter', 1)
response = self.session.put(create_url, data=json.dumps({'comments': comments}))
return handle_response(response)
@staticmethod @staticmethod
def create_authority(options): def create_authority(options):
@ -379,7 +389,22 @@ class DigiCertCISIssuerPlugin(IssuerPlugin):
self.session.headers.pop('Accept') self.session.headers.pop('Accept')
end_entity = pem.parse(certificate_pem)[0] end_entity = pem.parse(certificate_pem)[0]
return "\n".join(str(end_entity).splitlines()), current_app.config.get('DIGICERT_CIS_INTERMEDIATE') return "\n".join(str(end_entity).splitlines()), current_app.config.get('DIGICERT_CIS_INTERMEDIATE'), data['id']
def revoke_certificate(self, certificate, comments):
"""Revoke a Digicert certificate."""
base_url = current_app.config.get('DIGICERT_CIS_URL')
# make certificate revoke request
revoke_url = '{0}/platform/cis/certificate/{1}/revoke'.format(base_url, certificate.external_id)
metrics.send('digicert_revoke_certificate_success', 'counter', 1)
response = self.session.put(revoke_url, data=json.dumps({'comments': comments}))
if response.status_code != 204:
metrics.send('digicert_revoke_certificate_failure', 'counter', 1)
raise Exception('Failed to revoke certificate.')
metrics.send('digicert_revoke_certificate_success', 'counter', 1)
@staticmethod @staticmethod
def create_authority(options): def create_authority(options):

View File

@ -171,7 +171,7 @@ ghi
adapter.register_uri('GET', 'mock://www.digicert.com/services/v2/certificate/cert123/download/format/pem_all', text=pem_fixture) adapter.register_uri('GET', 'mock://www.digicert.com/services/v2/certificate/cert123/download/format/pem_all', text=pem_fixture)
subject.session.mount('mock', adapter) subject.session.mount('mock', adapter)
cert, intermediate = subject.create_certificate("", {'common_name': 'test.com'}) cert, intermediate, external_id = subject.create_certificate("", {'common_name': 'test.com'})
assert cert == "-----BEGIN CERTIFICATE-----\nabc\n-----END CERTIFICATE-----" assert cert == "-----BEGIN CERTIFICATE-----\nabc\n-----END CERTIFICATE-----"
assert intermediate == "-----BEGIN CERTIFICATE-----\ndef\n-----END CERTIFICATE-----" assert intermediate == "-----BEGIN CERTIFICATE-----\ndef\n-----END CERTIFICATE-----"

View File

@ -195,7 +195,8 @@ class VerisignIssuerPlugin(IssuerPlugin):
response = self.session.post(url, data=data) response = self.session.post(url, data=data)
cert = handle_response(response.content)['Response']['Certificate'] cert = handle_response(response.content)['Response']['Certificate']
return cert, current_app.config.get('VERISIGN_INTERMEDIATE'), # TODO add external id
return cert, current_app.config.get('VERISIGN_INTERMEDIATE'), None
@staticmethod @staticmethod
def create_authority(options): def create_authority(options):

View File

@ -58,6 +58,25 @@
}); });
}); });
lemur.directive('compareTo', function() {
return {
require: 'ngModel',
scope: {
otherModelValue: '=compareTo'
},
link: function(scope, element, attributes, ngModel) {
ngModel.$validators.compareTo = function(modelValue) {
return modelValue === scope.otherModelValue;
};
scope.$watch('otherModelValue', function() {
ngModel.$validate();
});
}
};
});
lemur.service('MomentService', function () { lemur.service('MomentService', function () {
this.diffMoment = function (start, end) { this.diffMoment = function (start, end) {
if (end !== 'None') { if (end !== 'None') {

View File

@ -328,4 +328,36 @@ angular.module('lemur')
$scope.authorityService = AuthorityService; $scope.authorityService = AuthorityService;
$scope.destinationService = DestinationService; $scope.destinationService = DestinationService;
$scope.notificationService = NotificationService; $scope.notificationService = NotificationService;
})
.controller('CertificateRevokeController', function ($scope, $uibModalInstance, CertificateApi, CertificateService, LemurRestangular, NotificationService, toaster, revokeId) {
CertificateApi.get(revokeId).then(function (certificate) {
$scope.certificate = certificate;
});
$scope.cancel = function () {
$uibModalInstance.dismiss('cancel');
};
$scope.revoke = function (certificate) {
CertificateService.revoke(certificate).then(
function () {
toaster.pop({
type: 'success',
title: certificate.name,
body: 'Successfully revoked!'
});
$uibModalInstance.close();
},
function (response) {
toaster.pop({
type: 'error',
title: certificate.name,
body: 'lemur-bad-request',
bodyOutputType: 'directive',
directiveData: response.data,
timeout: 100000
});
});
};
}); });

View File

@ -0,0 +1,48 @@
<div class="modal-header">
<button type="button" class="close" ng-click="cancel()" aria-label="Close"><span aria-hidden="true">&times;</span>
</button>
<h3 class="modal-title">Revoke <span class="text-muted"><small>{{ certificate.name }}</small></span></h3>
</div>
<div class="modal-body">
<form name="revokeForm" ng-if="!certificate.endpoints.length" novalidate>
<p><strong>Certificate revocation is final. Once revoked the certificate is no longer valid.</strong></p>
<div class="form-horizontal">
<div class="form-group"
ng-class="{'has-error': revokeForm.confirm.$invalid, 'has-success': !revokeForm.$invalid&&revokeForm.confirm.$dirty}">
<label class="control-label col-sm-2">
Confirm Revocation
</label>
<div class="col-sm-10">
<input name="confirm" ng-model="confirm" placeholder='{{ certificate.name }}'
uib-tooltip="Confirm revocation by entering '{{ certificate.name }}'"
class="form-control"
compare-to="certificate.name"
required/>
<p ng-show="revokeForm.confirm.$invalid && !revokeForm.confirm.$pristine" class="help-block">
You must confirm certificate revocation.</p>
</div>
</div>
</div>
</form>
<div ng-if="certificate.endpoints.length">
<p><strong>Certificate cannot be revoked, it is associated with the following endpoints. Disassociate this
certificate
before revoking.</strong></p>
<ul class="list-group">
<li class="list-group-item" ng-repeat="endpoint in certificate.endpoints">
<span class="pull-right"><label class="label label-default">{{ endpoint.type }}</label></span>
<ul class="list-unstyled">
<li>{{ endpoint.name }}</li>
<li><span class="text-muted">{{ endpoint.dnsname }}</span></li>
</ul>
</li>
</ul>
</div>
</div>
<div class="modal-footer">
<button type="submit" ng-click="revoke(certificate)" ng-disabled="revokeForm.confirm.$invalid"
class="btn btn-danger">Revoke
</button>
<button ng-click="cancel()" class="btn">Cancel</button>
</div>

View File

@ -258,5 +258,9 @@ angular.module('lemur')
return certificate.customPOST(certificate.exportOptions, 'export'); return certificate.customPOST(certificate.exportOptions, 'export');
}; };
CertificateService.revoke = function (certificate) {
return certificate.customPUT(certificate.externalId, 'revoke');
};
return CertificateService; return CertificateService;
}); });

View File

@ -190,4 +190,19 @@ angular.module('lemur')
} }
}); });
}; };
$scope.revoke = function (certificateId) {
$uibModal.open({
animation: true,
controller: 'CertificateRevokeController',
templateUrl: '/angular/certificates/certificate/revoke.tpl.html',
size: 'lg',
backdrop: 'static',
resolve: {
revokeId: function () {
return certificateId;
}
}
});
};
}); });

View File

@ -60,6 +60,7 @@
<li><a href ng-click="edit(certificate.id)">Edit</a></li> <li><a href ng-click="edit(certificate.id)">Edit</a></li>
<li><a href ng-click="clone(certificate.id)">Clone</a></li> <li><a href ng-click="clone(certificate.id)">Clone</a></li>
<li><a href ng-click="export(certificate.id)">Export</a></li> <li><a href ng-click="export(certificate.id)">Export</a></li>
<li><a href ng-click="revoke(certificate.id)">Revoke</a></li>
</ul> </ul>
</div> </div>
</div> </div>
@ -188,27 +189,21 @@
<uib-tab> <uib-tab>
<uib-tab-heading> <uib-tab-heading>
Chain Chain
<button class="btn btn-xs btn-default clipboard-btn glyphicon glyphicon-copy" <i class="glyphicon glyphicon-copy" style="cursor: pointer" clipboard text="certificate.chain"></i>
uib-tooltip="Copy chain to clipboard" tooltip-trigger="mouseenter" clipboard
text="certificate.chain"></button>
</uib-tab-heading> </uib-tab-heading>
<pre style="width: 100%">{{ certificate.chain }}</pre> <pre style="width: 100%">{{ certificate.chain }}</pre>
</uib-tab> </uib-tab>
<uib-tab> <uib-tab>
<uib-tab-heading> <uib-tab-heading>
Public Certificate Public Certificate
<button class="btn btn-xs btn-default clipboard-btn glyphicon glyphicon-copy" <i class="glyphicon glyphicon-copy" style="cursor: pointer" clipboard text="certificate.body"></i>
uib-tooltip="Copy certificate to clipboard" tooltip-trigger="mouseenter" clipboard
text="certificate.body"></button>
</uib-tab-heading> </uib-tab-heading>
<pre style="width: 100%">{{ certificate.body }}</pre> <pre style="width: 100%">{{ certificate.body }}</pre>
</uib-tab> </uib-tab>
<uib-tab ng-click="loadPrivateKey(certificate)"> <uib-tab ng-click="loadPrivateKey(certificate)">
<uib-tab-heading> <uib-tab-heading>
Private Key Private Key
<button class="btn btn-xs btn-default clipboard-btn glyphicon glyphicon-copy" <i class="glyphicon glyphicon-copy" style="cursor: pointer" clipboard text="certificate.privateKey"></i>
uib-tooltip="Copy key to clipboard" tooltip-trigger="mouseenter" clipboard
text="certificate.privateKey"></button>
</uib-tab-heading> </uib-tab-heading>
<pre style="width: 100%">{{ certificate.privateKey }}</pre> <pre style="width: 100%">{{ certificate.privateKey }}</pre>
</uib-tab> </uib-tab>

View File

@ -15,7 +15,7 @@ class TestIssuerPlugin(IssuerPlugin):
super(TestIssuerPlugin, self).__init__(*args, **kwargs) super(TestIssuerPlugin, self).__init__(*args, **kwargs)
def create_certificate(self, csr, issuer_options): def create_certificate(self, csr, issuer_options):
return INTERNAL_VALID_LONG_STR, INTERNAL_VALID_SAN_STR return INTERNAL_VALID_LONG_STR, INTERNAL_VALID_SAN_STR, None
@staticmethod @staticmethod
def create_authority(options): def create_authority(options):

View File

@ -430,7 +430,7 @@ def test_get_account_number(client):
def test_mint_certificate(issuer_plugin, authority): def test_mint_certificate(issuer_plugin, authority):
from lemur.certificates.service import mint from lemur.certificates.service import mint
cert_body, private_key, chain = mint(authority=authority, csr=CSR_STR) cert_body, private_key, chain, external_id = mint(authority=authority, csr=CSR_STR)
assert cert_body == INTERNAL_VALID_LONG_STR, INTERNAL_VALID_SAN_STR assert cert_body == INTERNAL_VALID_LONG_STR, INTERNAL_VALID_SAN_STR

View File

@ -66,7 +66,8 @@ install_requires = [
'raven[flask]==6.2.1', 'raven[flask]==6.2.1',
'jinja2==2.9.6', 'jinja2==2.9.6',
'pyldap==2.4.37', # required by ldap auth provider 'pyldap==2.4.37', # required by ldap auth provider
'paramiko==2.3.1' # required for lemur_linuxdst plugin 'paramiko==2.3.1', # required for lemur_linuxdst plugin
'alembic-autogenerate-enums==0.0.2'
] ]
tests_require = [ tests_require = [