diff --git a/.coveragerc b/.coveragerc index 2b0b20dc..9f3142e1 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1,2 +1,4 @@ [report] include = lemur/*.py +omit = lemur/migrations/* + diff --git a/lemur/certificates/models.py b/lemur/certificates/models.py index 71e23b31..c5d740f5 100644 --- a/lemur/certificates/models.py +++ b/lemur/certificates/models.py @@ -79,6 +79,7 @@ class Certificate(db.Model): secondaryjoin=id == certificate_replacement_associations.c.replaced_certificate_id, # noqa backref='replaced') + views = relationship("View", backref="certificate") endpoints = relationship("Endpoint", backref='certificate') def __init__(self, **kwargs): diff --git a/lemur/certificates/service.py b/lemur/certificates/service.py index 6643969c..cae7d9ed 100644 --- a/lemur/certificates/service.py +++ b/lemur/certificates/service.py @@ -19,6 +19,7 @@ from lemur.destinations.models import Destination from lemur.notifications.models import Notification from lemur.authorities.models import Authority from lemur.domains.models import Domain +from lemur.users.models import View from lemur.roles.models import Role from lemur.roles import service as role_service @@ -129,6 +130,19 @@ def update(cert_id, owner, description, notify, destinations, notifications, rep return database.update(cert) +def log_private_key_view(certificate, user): + """ + Creates a record each time a certificates private key is viewed. + + :param certificate: + :param user: + :return: + """ + view = View(user_id=user.id, certificate_id=certificate.id) + database.add(view) + database.commit() + + def create_certificate_roles(**kwargs): # create an role for the owner and assign it owner_role = role_service.get_by_name(kwargs['owner']) diff --git a/lemur/certificates/views.py b/lemur/certificates/views.py index 8a1a2ee2..cdf78178 100644 --- a/lemur/certificates/views.py +++ b/lemur/certificates/views.py @@ -437,16 +437,18 @@ class CertificatePrivateKey(AuthenticatedResource): if not cert: return dict(message="Cannot find specified certificate"), 404 - owner_role = role_service.get_by_name(cert.owner) - permission = CertificatePermission(cert.id, owner_role, [x.name for x in cert.roles]) + if not g.current_user.is_admin: + owner_role = role_service.get_by_name(cert.owner) + permission = CertificatePermission(cert.id, owner_role, [x.name for x in cert.roles]) - if permission.can(): - response = make_response(jsonify(key=cert.private_key), 200) - response.headers['cache-control'] = 'private, max-age=0, no-cache, no-store' - response.headers['pragma'] = 'no-cache' - return response + if not permission.can(): + return dict(message='You are not authorized to view this key'), 403 - return dict(message='You are not authorized to view this key'), 403 + service.log_private_key_view(cert, g.current_user) + response = make_response(jsonify(key=cert.private_key), 200) + response.headers['cache-control'] = 'private, max-age=0, no-cache, no-store' + response.headers['pragma'] = 'no-cache' + return response class Certificates(AuthenticatedResource): @@ -908,45 +910,40 @@ class CertificateExport(AuthenticatedResource): """ cert = service.get(certificate_id) - owner_role = role_service.get_by_name(cert.owner) - permission = CertificatePermission(cert.id, owner_role, [x.name for x in cert.roles]) + if not cert: + return dict(message="Cannot find specified certificate"), 404 - options = data['plugin']['plugin_options'] plugin = data['plugin']['plugin_object'] if plugin.requires_key: - if cert.private_key: - if permission.can(): - extension, passphrase, data = plugin.export(cert.body, cert.chain, cert.private_key, options) - else: - return dict(message='You are not authorized to export this certificate.'), 403 + if not cert.private_key: + return dict( + message='Unable to export certificate, plugin: {0} requires a private key but no key was found.'.format( + plugin.slug)) + else: - return dict(message='Unable to export certificate, plugin: {0} requires a private key but no key was found.'.format(plugin.slug)) - else: - extension, passphrase, data = plugin.export(cert.body, cert.chain, cert.private_key, options) + if not g.current_user.is_admin: + owner_role = role_service.get_by_name(cert.owner) + permission = CertificatePermission(cert.id, owner_role, [x.name for x in cert.roles]) + + if not permission.can(): + return dict(message='You are not authorized to export this certificate.'), 403 + + options = data['plugin']['plugin_options'] + + service.log_private_key_view(cert, g.current_user) + extension, passphrase, data = plugin.export(cert.body, cert.chain, cert.private_key, options) # we take a hit in message size when b64 encoding return dict(extension=extension, passphrase=passphrase, data=base64.b64encode(data).decode('utf-8')) -class CertificateClone(AuthenticatedResource): - def __init__(self): - self.reqparse = reqparse.RequestParser() - super(CertificateExport, self).__init__() - - @validate_schema(None, certificate_output_schema) - def get(self, certificate_id): - - pass - - 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(CertificateExport, '/certificates//export', endpoint='exportCertificate') -api.add_resource(CertificateClone, '/certificates//clone', endpoint='cloneCertificate') api.add_resource(NotificationCertificatesList, '/notifications//certificates', endpoint='notificationCertificates') api.add_resource(CertificatesReplacementsList, '/certificates//replacements', diff --git a/lemur/factory.py b/lemur/factory.py index 28f48a98..d121b3c1 100644 --- a/lemur/factory.py +++ b/lemur/factory.py @@ -94,14 +94,39 @@ def configure_app(app, config=None): if config and config != 'None': app.config.from_object(from_file(config)) - try: - app.config.from_envvar("LEMUR_CONF") - except RuntimeError: - # look in default paths - if os.path.isfile(os.path.expanduser("~/.lemur/lemur.conf.py")): - app.config.from_object(from_file(os.path.expanduser("~/.lemur/lemur.conf.py"))) - else: - app.config.from_object(from_file(os.path.join(os.path.dirname(os.path.realpath(__file__)), 'default.conf.py'))) + else: + try: + app.config.from_envvar("LEMUR_CONF") + except RuntimeError: + # look in default paths + if os.path.isfile(os.path.expanduser("~/.lemur/lemur.conf.py")): + app.config.from_object(from_file(os.path.expanduser("~/.lemur/lemur.conf.py"))) + else: + app.config.from_object(from_file(os.path.join(os.path.dirname(os.path.realpath(__file__)), 'default.conf.py'))) + + validate_conf(app) + + +def validate_conf(app): + """ + There are a few configuration variables that are 'required' by Lemur. Here + we validate those required variables are set. + """ + required_vars = [ + 'LEMUR_SECURITY_TEAM_EMAIL', + 'LEMUR_DEFAULT_ORGANIZATIONAL_UNIT', + 'LEMUR_DEFAULT_ORGANIZATION', + 'LEMUR_DEFAULT_LOCATION', + 'LEMUR_DEFAULT_COUNTRY', + 'LEMUR_DEFAULT_STATE', + 'SQLALCHEMY_DATABASE_URI' + ] + + for var in required_vars: + if not app.config.get(var): + raise Exception("Required variable {var} is not set, ensure that it is set in Lemur's configuration file".format( + var=var + )) def configure_extensions(app): diff --git a/lemur/migrations/versions/6d6151f5f307_.py b/lemur/migrations/versions/6d6151f5f307_.py new file mode 100644 index 00000000..554b347c --- /dev/null +++ b/lemur/migrations/versions/6d6151f5f307_.py @@ -0,0 +1,36 @@ +"""Adding private key auditing. + +Revision ID: 6d6151f5f307 +Revises: 932525b82f1a +Create Date: 2016-11-18 16:08:12.191959 + +""" + +# revision identifiers, used by Alembic. +revision = '6d6151f5f307' +down_revision = '932525b82f1a' + +from alembic import op +import sqlalchemy as sa + +from sqlalchemy_utils.types import ArrowType + + +def upgrade(): + ### commands auto generated by Alembic - please adjust! ### + op.create_table('views', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('certificate_id', sa.Integer(), nullable=True), + sa.Column('viewed_at', ArrowType(), server_default=sa.text('now()'), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(['certificate_id'], ['certificates.id'], ), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id') + ) + ### end Alembic commands ### + + +def downgrade(): + ### commands auto generated by Alembic - please adjust! ### + op.drop_table('views') + ### end Alembic commands ### diff --git a/lemur/tests/test_certificates.py b/lemur/tests/test_certificates.py index 5fb8d048..73c28ce9 100644 --- a/lemur/tests/test_certificates.py +++ b/lemur/tests/test_certificates.py @@ -423,6 +423,21 @@ def test_upload_private_key_str(user): assert cert +def test_private_key_audit(client, certificate): + assert len(certificate.views) == 0 + client.get(api.url_for(CertificatePrivateKey, certificate_id=certificate.id), headers=VALID_ADMIN_HEADER_TOKEN) + assert len(certificate.views) == 1 + + +@pytest.mark.parametrize("token,status", [ + (VALID_USER_HEADER_TOKEN, 200), + (VALID_ADMIN_HEADER_TOKEN, 200), + ('', 401) +]) +def test_certificate_get_private_key(client, token, status): + assert client.get(api.url_for(Certificates, certificate_id=1), headers=token).status_code == status + + @pytest.mark.parametrize("token,status", [ (VALID_USER_HEADER_TOKEN, 200), (VALID_ADMIN_HEADER_TOKEN, 200), @@ -513,15 +528,6 @@ def test_certificates_patch(client, token, status): assert client.patch(api.url_for(CertificatesList), data={}, headers=token).status_code == status -@pytest.mark.parametrize("token,status", [ - (VALID_USER_HEADER_TOKEN, 403), - (VALID_ADMIN_HEADER_TOKEN, 200), - ('', 401) -]) -def test_certificate_credentials_get(client, token, status): - assert client.get(api.url_for(CertificatePrivateKey, certificate_id=1), headers=token).status_code == status - - @pytest.mark.parametrize("token,status", [ (VALID_USER_HEADER_TOKEN, 405), (VALID_ADMIN_HEADER_TOKEN, 405), diff --git a/lemur/users/models.py b/lemur/users/models.py index 62165cb5..2a31caa5 100644 --- a/lemur/users/models.py +++ b/lemur/users/models.py @@ -8,11 +8,11 @@ .. moduleauthor:: Kevin Glisson """ -import sys from sqlalchemy.orm import relationship -from sqlalchemy import Column, Integer, String, Boolean, DateTime +from sqlalchemy import Integer, ForeignKey, String, PassiveDefault, func, Column, Boolean from sqlalchemy.event import listen +from sqlalchemy_utils.types.arrow import ArrowType from lemur.database import db from lemur.models import roles_users @@ -37,7 +37,7 @@ class User(db.Model): id = Column(Integer, primary_key=True) password = Column(String(128)) active = Column(Boolean()) - confirmed_at = Column(DateTime()) + confirmed_at = Column(ArrowType()) username = Column(String(255), nullable=False, unique=True) email = Column(String(128), unique=True) profile_picture = Column(String(255)) @@ -63,11 +63,7 @@ class User(db.Model): :return: """ if self.password: - if sys.version_info[0] >= 3: - self.password = bcrypt.generate_password_hash(self.password).decode('utf-8') - else: - self.password = bcrypt.generate_password_hash(self.password) - return self.password + self.password = bcrypt.generate_password_hash(self.password).decode('utf-8') @property def is_admin(self): @@ -85,4 +81,12 @@ class User(db.Model): return "User(username={username})".format(username=self.username) +class View(db.Model): + __tablename__ = 'views' + id = Column(Integer, primary_key=True) + certificate_id = Column(Integer, ForeignKey('certificates.id')) + viewed_at = Column(ArrowType(), PassiveDefault(func.now()), nullable=False) + user_id = Column(Integer, ForeignKey('users.id')) + + listen(User, 'before_insert', hash_password) diff --git a/tox.ini b/tox.ini index eaa43109..fdd2585b 100644 --- a/tox.ini +++ b/tox.ini @@ -1,2 +1,2 @@ [tox] -envlist = py27,py35 +envlist = py35