diff --git a/docs/developer/plugins/index.rst b/docs/developer/plugins/index.rst index e9fc7936..8af5e1c8 100644 --- a/docs/developer/plugins/index.rst +++ b/docs/developer/plugins/index.rst @@ -100,10 +100,16 @@ If you have a third party or internal service that creates authorities (EJBCA, e it can treat any issuer plugin as both a source of creating new certificates as well as new authorities. -The `IssuerPlugin` exposes two functions:: +The `IssuerPlugin` exposes four functions functions:: def create_certificate(self, csr, issuer_options): # requests.get('a third party') + def revoke_certificate(self, certificate, comments): + # requests.put('a third party') + def get_ordered_certificate(self, order_id): + # requests.get('already existing certificate') + def canceled_ordered_certificate(self, pending_cert, **kwargs): + # requests.put('cancel an order that has yet to be issued') Lemur will pass a dictionary of all possible options for certificate creation. Including a valid CSR, and the raw options associated with the request. @@ -139,6 +145,19 @@ The `IssuerPlugin` doesn't have any options like Destination, Source, and Notifi any fields you might need to submit a request to a third party. If there are additional options you need in your plugin feel free to open an issue, or look into adding additional options to issuers yourself. +Asynchronous Certificates +^^^^^^^^^^^^^^^^^^^^^^^^^ +An issuer may take some time to actually issue a certificate for an order. In this case, a `PendingCertificate` is returned, which holds information to recreate a `Certificate` object at a later time. Then, `get_ordered_certificate()` should be run periodically via `python manage.py pending_certs fetch -i all` to attempt to retrieve an ordered certificate:: + + def get_ordered_ceriticate(self, order_id): + # order_id is the external id of the order, not the external_id of the certificate + # retrieve an order, and check if there is an issued certificate attached to it + +`cancel_ordered_certificate()` should be implemented to allow an ordered certificate to be canceled before it is issued:: + def cancel_ordered_certificate(self, pending_cert, **kwargs): + # pending_cert should contain the necessary information to match an order + # kwargs can be given to provide information to the issuer for canceling + Destination ----------- diff --git a/docs/requirements.txt b/docs/requirements.txt index 0362eadf..e89bedf4 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,3 +1,4 @@ +CloudFlare==1.7.5 Flask==0.12 Flask-RESTful==0.3.6 Flask-SQLAlchemy==2.1 diff --git a/lemur/__init__.py b/lemur/__init__.py index 444ed56f..6f29733c 100644 --- a/lemur/__init__.py +++ b/lemur/__init__.py @@ -27,6 +27,7 @@ from lemur.sources.views import mod as sources_bp from lemur.endpoints.views import mod as endpoints_bp from lemur.logs.views import mod as logs_bp from lemur.api_keys.views import mod as api_key_bp +from lemur.pending_certificates.views import mod as pending_certificates_bp from lemur.__about__ import ( __author__, __copyright__, __email__, __license__, __summary__, __title__, @@ -53,7 +54,8 @@ LEMUR_BLUEPRINTS = ( sources_bp, endpoints_bp, logs_bp, - api_key_bp + api_key_bp, + pending_certificates_bp, ) diff --git a/lemur/authorities/models.py b/lemur/authorities/models.py index fc1302ad..45744144 100644 --- a/lemur/authorities/models.py +++ b/lemur/authorities/models.py @@ -32,6 +32,9 @@ class Authority(db.Model): authority_certificate = relationship("Certificate", backref='root_authority', uselist=False, foreign_keys='Certificate.root_authority_id') certificates = relationship("Certificate", backref='authority', foreign_keys='Certificate.authority_id') + authority_pending_certificate = relationship("PendingCertificate", backref='root_authority', uselist=False, foreign_keys='PendingCertificate.root_authority_id') + pending_certificates = relationship('PendingCertificate', backref='authority', foreign_keys='PendingCertificate.authority_id') + def __init__(self, **kwargs): self.owner = kwargs['owner'] self.roles = kwargs.get('roles', []) diff --git a/lemur/certificates/models.py b/lemur/certificates/models.py index 433cb05a..81dfa8d3 100644 --- a/lemur/certificates/models.py +++ b/lemur/certificates/models.py @@ -37,7 +37,7 @@ from lemur.constants import SUCCESS_METRIC_STATUS, FAILURE_METRIC_STATUS from lemur.models import certificate_associations, certificate_source_associations, \ certificate_destination_associations, certificate_notification_associations, \ - certificate_replacement_associations, roles_certificates + certificate_replacement_associations, roles_certificates, pending_cert_replacement_associations from lemur.domains.models import Domain from lemur.policies.models import RotationPolicy @@ -129,6 +129,11 @@ class Certificate(db.Model): secondaryjoin=id == certificate_replacement_associations.c.replaced_certificate_id, # noqa backref='replaced') + replaced_by_pending = relationship('PendingCertificate', + secondary=pending_cert_replacement_associations, + backref='pending_replace', + viewonly=True) + logs = relationship('Log', backref='certificate') endpoints = relationship('Endpoint', backref='certificate') rotation_policy = relationship("RotationPolicy") diff --git a/lemur/certificates/schemas.py b/lemur/certificates/schemas.py index 9547a373..651aa647 100644 --- a/lemur/certificates/schemas.py +++ b/lemur/certificates/schemas.py @@ -45,11 +45,14 @@ class CertificateCreationSchema(CertificateSchema): @post_load def default_notification(self, data): if not data['notifications']: - notification_name = "DEFAULT_{0}".format(data['owner'].split('@')[0].upper()) - data['notifications'] += notification_service.create_default_expiration_notifications(notification_name, [data['owner']]) - - notification_name = 'DEFAULT_SECURITY' - data['notifications'] += notification_service.create_default_expiration_notifications(notification_name, current_app.config.get('LEMUR_SECURITY_TEAM_EMAIL')) + data['notifications'] += notification_service.create_default_expiration_notifications( + "DEFAULT_{0}".format(data['owner'].split('@')[0].upper()), + [data['owner']], + ) + data['notifications'] += notification_service.create_default_expiration_notifications( + 'DEFAULT_SECURITY', + current_app.config.get('LEMUR_SECURITY_TEAM_EMAIL') + ) return data diff --git a/lemur/certificates/service.py b/lemur/certificates/service.py index 48364652..64630d52 100644 --- a/lemur/certificates/service.py +++ b/lemur/certificates/service.py @@ -25,6 +25,7 @@ from lemur.authorities.models import Authority from lemur.destinations.models import Destination from lemur.certificates.models import Certificate from lemur.notifications.models import Notification +from lemur.pending_certificates.models import PendingCertificate from lemur.certificates.schemas import CertificateOutputSchema, CertificateInputSchema @@ -63,6 +64,9 @@ def get_by_serial(serial): :param serial: :return: """ + if isinstance(serial, int): + # although serial is a number, the DB column is String(128) + serial = str(serial) return Certificate.query.filter(Certificate.serial == serial).all() @@ -190,7 +194,7 @@ def mint(**kwargs): csr_imported.send(authority=authority, csr=csr) cert_body, cert_chain, external_id = issuer.create_certificate(csr, kwargs) - return cert_body, private_key, cert_chain, external_id + return cert_body, private_key, cert_chain, external_id, csr def import_certificate(**kwargs): @@ -243,11 +247,12 @@ def create(**kwargs): """ Creates a new certificate. """ - cert_body, private_key, cert_chain, external_id = mint(**kwargs) + cert_body, private_key, cert_chain, external_id, csr = mint(**kwargs) kwargs['body'] = cert_body kwargs['private_key'] = private_key kwargs['chain'] = cert_chain kwargs['external_id'] = external_id + kwargs['csr'] = csr roles = create_certificate_roles(**kwargs) @@ -256,15 +261,20 @@ def create(**kwargs): else: kwargs['roles'] = roles - cert = Certificate(**kwargs) + if cert_body: + cert = Certificate(**kwargs) + kwargs['creator'].certificates.append(cert) + else: + cert = PendingCertificate(**kwargs) + kwargs['creator'].pending_certificates.append(cert) - kwargs['creator'].certificates.append(cert) cert.authority = kwargs['authority'] - certificate_issued.send(certificate=cert, authority=cert.authority) database.commit() - metrics.send('certificate_issued', 'counter', 1, metric_tags=dict(owner=cert.owner, issuer=cert.issuer)) + if isinstance(cert, Certificate): + certificate_issued.send(certificate=cert, authority=cert.authority) + metrics.send('certificate_issued', 'counter', 1, metric_tags=dict(owner=cert.owner, issuer=cert.issuer)) return cert diff --git a/lemur/certificates/views.py b/lemur/certificates/views.py index 79edcd69..80355950 100644 --- a/lemur/certificates/views.py +++ b/lemur/certificates/views.py @@ -18,6 +18,7 @@ from lemur.auth.service import AuthenticatedResource from lemur.auth.permissions import AuthorityPermission, CertificatePermission from lemur.certificates import service +from lemur.certificates.models import Certificate from lemur.plugins.base import plugins from lemur.certificates.schemas import ( certificate_input_schema, @@ -267,7 +268,9 @@ class CertificatesList(AuthenticatedResource): if authority_permission.can(): data['creator'] = g.user cert = service.create(**data) - log_service.create(g.user, 'create_cert', certificate=cert) + if isinstance(cert, Certificate): + # only log if created, not pending + log_service.create(g.user, 'create_cert', certificate=cert) return cert return dict(message="You are not authorized to use the authority: {0}".format(data['authority'].name)), 403 diff --git a/lemur/common/defaults.py b/lemur/common/defaults.py index ea8541d9..bb49c5b7 100644 --- a/lemur/common/defaults.py +++ b/lemur/common/defaults.py @@ -162,6 +162,9 @@ def domains(cert): entries = ext.value.get_values_for_type(x509.DNSName) for entry in entries: domains.append(entry) + except x509.ExtensionNotFound: + if current_app.config.get("LOG_SSL_SUBJ_ALT_NAME_ERRORS", True): + sentry.captureException() except Exception as e: sentry.captureException() diff --git a/lemur/common/utils.py b/lemur/common/utils.py index 04bb0d0c..621a2c39 100644 --- a/lemur/common/utils.py +++ b/lemur/common/utils.py @@ -53,6 +53,19 @@ def parse_certificate(body): return x509.load_pem_x509_certificate(body, default_backend()) +def parse_csr(csr): + """ + Helper function that parses a CSR. + + :param csr: + :return: + """ + if isinstance(csr, str): + csr = csr.encode('utf-8') + + return x509.load_pem_x509_csr(csr, default_backend()) + + def get_authority_key(body): """Returns the authority key for a given certificate in hex format""" parsed_cert = parse_certificate(body) diff --git a/lemur/manage.py b/lemur/manage.py index 57cd7b2c..308e6c30 100755 --- a/lemur/manage.py +++ b/lemur/manage.py @@ -21,6 +21,7 @@ from lemur.reporting.cli import manager as report_manager from lemur.endpoints.cli import manager as endpoint_manager from lemur.certificates.cli import manager as certificate_manager from lemur.notifications.cli import manager as notification_manager +from lemur.pending_certificates.cli import manager as pending_certificate_manager from lemur import database from lemur.users import service as user_service @@ -44,6 +45,7 @@ from lemur.sources.models import Source # noqa from lemur.logs.models import Log # noqa from lemur.endpoints.models import Endpoint # noqa from lemur.policies.models import RotationPolicy # noqa +from lemur.pending_certificates.models import PendingCertificate # noqa manager = Manager(create_app) @@ -542,6 +544,7 @@ def main(): manager.add_command("endpoint", endpoint_manager) manager.add_command("report", report_manager) manager.add_command("policy", policy_manager) + manager.add_command("pending_certs", pending_certificate_manager) manager.run() diff --git a/lemur/migrations/versions/449c3d5c7299_.py b/lemur/migrations/versions/449c3d5c7299_.py new file mode 100644 index 00000000..1dcb7ab5 --- /dev/null +++ b/lemur/migrations/versions/449c3d5c7299_.py @@ -0,0 +1,28 @@ +"""Add unique constraint to certificate_notification_associations table. + +Revision ID: 449c3d5c7299 +Revises: 5770674184de +Create Date: 2018-02-24 22:51:35.369229 + +""" + +# revision identifiers, used by Alembic. +revision = '449c3d5c7299' +down_revision = '5770674184de' + +from alembic import op +from flask_sqlalchemy import SQLAlchemy + +db = SQLAlchemy() + +CONSTRAINT_NAME = "uq_dest_not_ids" +TABLE = "certificate_notification_associations" +COLUMNS = ["notification_id", "certificate_id"] + + +def upgrade(): + op.create_unique_constraint(CONSTRAINT_NAME, TABLE, COLUMNS) + + +def downgrade(): + op.drop_constraint(CONSTRAINT_NAME, TABLE) diff --git a/lemur/migrations/versions/556ceb3e3c3e_.py b/lemur/migrations/versions/556ceb3e3c3e_.py new file mode 100644 index 00000000..2916c0eb --- /dev/null +++ b/lemur/migrations/versions/556ceb3e3c3e_.py @@ -0,0 +1,99 @@ +"""Add Pending Certificates models and relations + +Revision ID: 556ceb3e3c3e +Revises: 47baffaae1a7 +Create Date: 2018-01-05 01:18:45.571595 + +""" + +# revision identifiers, used by Alembic. +revision = '556ceb3e3c3e' +down_revision = '449c3d5c7299' + +from alembic import op +import sqlalchemy as sa +from lemur.utils import Vault +from sqlalchemy.dialects import postgresql +from sqlalchemy_utils import ArrowType + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('pending_certs', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('external_id', sa.String(length=128), nullable=True), + sa.Column('owner', sa.String(length=128), nullable=False), + sa.Column('name', sa.String(length=256), nullable=True), + sa.Column('description', sa.String(length=1024), nullable=True), + sa.Column('notify', sa.Boolean(), nullable=True), + sa.Column('number_attempts', sa.Integer(), nullable=True), + sa.Column('rename', sa.Boolean(), nullable=True), + sa.Column('cn', sa.String(length=128), nullable=True), + sa.Column('csr', sa.Text(), nullable=False), + sa.Column('chain', sa.Text(), nullable=True), + sa.Column('private_key', Vault(), nullable=True), + sa.Column('date_created', ArrowType(), server_default=sa.text('now()'), nullable=False), + sa.Column('status', sa.String(length=128), nullable=True), + sa.Column('rotation', sa.Boolean(), nullable=True), + sa.Column('user_id', sa.Integer(), nullable=True), + sa.Column('authority_id', sa.Integer(), nullable=True), + sa.Column('root_authority_id', sa.Integer(), nullable=True), + sa.Column('rotation_policy_id', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(['authority_id'], ['authorities.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['root_authority_id'], ['authorities.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['rotation_policy_id'], ['rotation_policies.id'], ), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('name') + ) + op.create_table('pending_cert_destination_associations', + sa.Column('destination_id', sa.Integer(), nullable=True), + sa.Column('pending_cert_id', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(['destination_id'], ['destinations.id'], ondelete='cascade'), + sa.ForeignKeyConstraint(['pending_cert_id'], ['pending_certs.id'], ondelete='cascade') + ) + op.create_index('pending_cert_destination_associations_ix', 'pending_cert_destination_associations', ['destination_id', 'pending_cert_id'], unique=False) + op.create_table('pending_cert_notification_associations', + sa.Column('notification_id', sa.Integer(), nullable=True), + sa.Column('pending_cert_id', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(['notification_id'], ['notifications.id'], ondelete='cascade'), + sa.ForeignKeyConstraint(['pending_cert_id'], ['pending_certs.id'], ondelete='cascade') + ) + op.create_index('pending_cert_notification_associations_ix', 'pending_cert_notification_associations', ['notification_id', 'pending_cert_id'], unique=False) + op.create_table('pending_cert_replacement_associations', + sa.Column('replaced_certificate_id', sa.Integer(), nullable=True), + sa.Column('pending_cert_id', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(['pending_cert_id'], ['pending_certs.id'], ondelete='cascade'), + sa.ForeignKeyConstraint(['replaced_certificate_id'], ['certificates.id'], ondelete='cascade') + ) + op.create_index('pending_cert_replacement_associations_ix', 'pending_cert_replacement_associations', ['replaced_certificate_id', 'pending_cert_id'], unique=False) + op.create_table('pending_cert_role_associations', + sa.Column('pending_cert_id', sa.Integer(), nullable=True), + sa.Column('role_id', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(['pending_cert_id'], ['pending_certs.id'], ), + sa.ForeignKeyConstraint(['role_id'], ['roles.id'], ) + ) + op.create_index('pending_cert_role_associations_ix', 'pending_cert_role_associations', ['pending_cert_id', 'role_id'], unique=False) + op.create_table('pending_cert_source_associations', + sa.Column('source_id', sa.Integer(), nullable=True), + sa.Column('pending_cert_id', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(['pending_cert_id'], ['pending_certs.id'], ondelete='cascade'), + sa.ForeignKeyConstraint(['source_id'], ['sources.id'], ondelete='cascade') + ) + op.create_index('pending_cert_source_associations_ix', 'pending_cert_source_associations', ['source_id', 'pending_cert_id'], unique=False) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index('pending_cert_source_associations_ix', table_name='pending_cert_source_associations') + op.drop_table('pending_cert_source_associations') + op.drop_index('pending_cert_role_associations_ix', table_name='pending_cert_role_associations') + op.drop_table('pending_cert_role_associations') + op.drop_index('pending_cert_replacement_associations_ix', table_name='pending_cert_replacement_associations') + op.drop_table('pending_cert_replacement_associations') + op.drop_index('pending_cert_notification_associations_ix', table_name='pending_cert_notification_associations') + op.drop_table('pending_cert_notification_associations') + op.drop_index('pending_cert_destination_associations_ix', table_name='pending_cert_destination_associations') + op.drop_table('pending_cert_destination_associations') + op.drop_table('pending_certs') + # ### end Alembic commands ### diff --git a/lemur/migrations/versions/5770674184de_.py b/lemur/migrations/versions/5770674184de_.py new file mode 100644 index 00000000..88262a84 --- /dev/null +++ b/lemur/migrations/versions/5770674184de_.py @@ -0,0 +1,44 @@ +"""Remove duplicates from certificate_notification_associations. + +Revision ID: 5770674184de +Revises: ce547319f7be +Create Date: 2018-02-23 15:27:30.335435 + +""" + +# revision identifiers, used by Alembic. +revision = '5770674184de' +down_revision = 'ce547319f7be' + +from flask_sqlalchemy import SQLAlchemy +from lemur.models import certificate_notification_associations + +db = SQLAlchemy() +session = db.session() + + +def upgrade(): + print("Querying for all entries in certificate_notification_associations.") + # Query for all entries in table + results = session.query(certificate_notification_associations).with_entities( + certificate_notification_associations.c.certificate_id, + certificate_notification_associations.c.notification_id, + certificate_notification_associations.c.id, + ) + + seen = {} + # Iterate through all entries and mark as seen for each certificate_id and notification_id pair + for x in results: + # If we've seen a pair already, delete the duplicates + if seen.get("{}-{}".format(x.certificate_id, x.notification_id)): + print("Deleting duplicate: {}".format(x)) + d = session.query(certificate_notification_associations).filter(certificate_notification_associations.c.id==x.id) + d.delete(synchronize_session=False) + seen["{}-{}".format(x.certificate_id, x.notification_id)] = True + db.session.commit() + db.session.flush() + + +def downgrade(): + # No way to downgrade this + pass diff --git a/lemur/migrations/versions/ce547319f7be_.py b/lemur/migrations/versions/ce547319f7be_.py new file mode 100644 index 00000000..41ef1fa8 --- /dev/null +++ b/lemur/migrations/versions/ce547319f7be_.py @@ -0,0 +1,36 @@ +"""Add id column to certificate_notification_associations. + +Revision ID: ce547319f7be +Revises: 5bc47fa7cac4 +Create Date: 2018-02-23 11:00:02.150561 + +""" + +# revision identifiers, used by Alembic. +revision = 'ce547319f7be' +down_revision = '5bc47fa7cac4' + +import sqlalchemy as sa + +from alembic import op +from flask_sqlalchemy import SQLAlchemy + + +db = SQLAlchemy() + +TABLE = "certificate_notification_associations" + + +def upgrade(): + print("Adding id column") + op.add_column( + TABLE, + sa.Column('id', sa.Integer, primary_key=True, autoincrement=True) + ) + db.session.commit() + db.session.flush() + +def downgrade(): + op.drop_column(TABLE, "id") + db.session.commit() + db.session.flush() diff --git a/lemur/models.py b/lemur/models.py index f189d76c..02c64dbe 100644 --- a/lemur/models.py +++ b/lemur/models.py @@ -8,7 +8,7 @@ :license: Apache, see LICENSE for more details. .. moduleauthor:: Kevin Glisson """ -from sqlalchemy import Column, Integer, ForeignKey, Index +from sqlalchemy import Column, Integer, ForeignKey, Index, UniqueConstraint from lemur.database import db @@ -41,7 +41,9 @@ certificate_notification_associations = db.Table('certificate_notification_assoc Column('notification_id', Integer, ForeignKey('notifications.id', ondelete='cascade')), Column('certificate_id', Integer, - ForeignKey('certificates.id', ondelete='cascade')) + ForeignKey('certificates.id', ondelete='cascade')), + Column('id', Integer, primary_key=True, autoincrement=True), + UniqueConstraint('notification_id', 'certificate_id', name='uq_dest_not_ids') ) Index('certificate_notification_associations_ix', certificate_notification_associations.c.notification_id, certificate_notification_associations.c.certificate_id) @@ -83,3 +85,48 @@ policies_ciphers = db.Table('policies_ciphers', Column('policy_id', Integer, ForeignKey('policy.id'))) Index('policies_ciphers_ix', policies_ciphers.c.cipher_id, policies_ciphers.c.policy_id) + + +pending_cert_destination_associations = db.Table('pending_cert_destination_associations', + Column('destination_id', Integer, + ForeignKey('destinations.id', ondelete='cascade')), + Column('pending_cert_id', Integer, + ForeignKey('pending_certs.id', ondelete='cascade')) + ) + +Index('pending_cert_destination_associations_ix', pending_cert_destination_associations.c.destination_id, pending_cert_destination_associations.c.pending_cert_id) + + +pending_cert_notification_associations = db.Table('pending_cert_notification_associations', + Column('notification_id', Integer, + ForeignKey('notifications.id', ondelete='cascade')), + Column('pending_cert_id', Integer, + ForeignKey('pending_certs.id', ondelete='cascade')) + ) + +Index('pending_cert_notification_associations_ix', pending_cert_notification_associations.c.notification_id, pending_cert_notification_associations.c.pending_cert_id) + +pending_cert_source_associations = db.Table('pending_cert_source_associations', + Column('source_id', Integer, + ForeignKey('sources.id', ondelete='cascade')), + Column('pending_cert_id', Integer, + ForeignKey('pending_certs.id', ondelete='cascade')) + ) + +Index('pending_cert_source_associations_ix', pending_cert_source_associations.c.source_id, pending_cert_source_associations.c.pending_cert_id) + +pending_cert_replacement_associations = db.Table('pending_cert_replacement_associations', + Column('replaced_certificate_id', Integer, + ForeignKey('certificates.id', ondelete='cascade')), + Column('pending_cert_id', Integer, + ForeignKey('pending_certs.id', ondelete='cascade')) + ) + +Index('pending_cert_replacement_associations_ix', pending_cert_replacement_associations.c.replaced_certificate_id, pending_cert_replacement_associations.c.pending_cert_id) + +pending_cert_role_associations = db.Table('pending_cert_role_associations', + Column('pending_cert_id', Integer, ForeignKey('pending_certs.id')), + Column('role_id', Integer, ForeignKey('roles.id')) + ) + +Index('pending_cert_role_associations_ix', pending_cert_role_associations.c.pending_cert_id, pending_cert_role_associations.c.role_id) diff --git a/lemur/notifications/models.py b/lemur/notifications/models.py index c2cb4ffc..79a3df1c 100644 --- a/lemur/notifications/models.py +++ b/lemur/notifications/models.py @@ -11,7 +11,8 @@ from sqlalchemy_utils import JSONType from lemur.database import db from lemur.plugins.base import plugins -from lemur.models import certificate_notification_associations +from lemur.models import certificate_notification_associations, \ + pending_cert_notification_associations class Notification(db.Model): @@ -29,6 +30,13 @@ class Notification(db.Model): backref="notification", cascade='all,delete' ) + pending_certificates = relationship( + "PendingCertificate", + secondary=pending_cert_notification_associations, + passive_deletes=True, + backref="notification", + cascade='all,delete' + ) @property def plugin(self): diff --git a/lemur/pending_certificates/__init__.py b/lemur/pending_certificates/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/lemur/pending_certificates/cli.py b/lemur/pending_certificates/cli.py new file mode 100644 index 00000000..ff846fe1 --- /dev/null +++ b/lemur/pending_certificates/cli.py @@ -0,0 +1,48 @@ +""" +.. module: lemur.pending_certificates.cli + +.. moduleauthor:: James Chuong +""" + +from flask_script import Manager + +from lemur.pending_certificates import service as pending_certificate_service +from lemur.plugins.base import plugins +from lemur.users import service as user_service + +manager = Manager(usage="Handles pending certificate related tasks.") + + +@manager.option('-i', dest='ids', action='append', help='IDs of pending certificates to fetch') +def fetch(ids): + """ + Attempt to get full certificates for each pending certificate listed. + + Args: + ids: a list of ids of PendingCertificates (passed in by manager options when run as CLI) + `python manager.py pending_certs fetch -i 123 321 all` + """ + new = 0 + failed = 0 + pending_certs = pending_certificate_service.get_pending_certs(ids) + user = user_service.get_by_username('lemur') + + for cert in pending_certs: + authority = plugins.get(cert.authority.plugin_name) + real_cert = authority.get_ordered_certificate(cert.external_id) + if real_cert: + # If a real certificate was returned from issuer, then create it in Lemur and delete + # the pending certificate + pending_certificate_service.create_certificate(cert, real_cert, user) + pending_certificate_service.delete(cert) + # add metrics to metrics extension + new += 1 + else: + pending_certificate_service.increment_attempt(cert) + failed += 1 + print( + "[+] Certificates: New: {new} Failed: {failed}".format( + new=new, + failed=failed + ) + ) diff --git a/lemur/pending_certificates/models.py b/lemur/pending_certificates/models.py new file mode 100644 index 00000000..0e841968 --- /dev/null +++ b/lemur/pending_certificates/models.py @@ -0,0 +1,95 @@ +""" +.. module: lemur.pending_certificates.models + Copyright (c) 2017 and onwards Instart Logic, Inc. All rights reserved. +.. moduleauthor:: James Chuong +""" +from datetime import datetime as dt + +from sqlalchemy.orm import relationship +from sqlalchemy import Integer, ForeignKey, String, PassiveDefault, func, Column, Text, Boolean +from sqlalchemy_utils.types.arrow import ArrowType + +import lemur.common.utils +from lemur.certificates.models import get_or_increase_name +from lemur.common import defaults +from lemur.database import db +from lemur.utils import Vault + +from lemur.models import pending_cert_source_associations, \ + pending_cert_destination_associations, pending_cert_notification_associations, \ + pending_cert_replacement_associations, pending_cert_role_associations + + +class PendingCertificate(db.Model): + __tablename__ = 'pending_certs' + id = Column(Integer, primary_key=True) + external_id = Column(String(128)) + owner = Column(String(128), nullable=False) + name = Column(String(256), unique=True) + description = Column(String(1024)) + notify = Column(Boolean, default=True) + number_attempts = Column(Integer) + rename = Column(Boolean, default=True) + + cn = Column(String(128)) + csr = Column(Text(), nullable=False) + chain = Column(Text()) + private_key = Column(Vault, nullable=True) + + date_created = Column(ArrowType, PassiveDefault(func.now()), nullable=False) + + status = Column(String(128)) + + rotation = Column(Boolean, default=False) + user_id = Column(Integer, ForeignKey('users.id')) + authority_id = Column(Integer, ForeignKey('authorities.id', ondelete="CASCADE")) + root_authority_id = Column(Integer, ForeignKey('authorities.id', ondelete="CASCADE")) + rotation_policy_id = Column(Integer, ForeignKey('rotation_policies.id')) + + notifications = relationship('Notification', secondary=pending_cert_notification_associations, backref='pending_cert', passive_deletes=True) + destinations = relationship('Destination', secondary=pending_cert_destination_associations, backref='pending_cert', passive_deletes=True) + sources = relationship('Source', secondary=pending_cert_source_associations, backref='pending_cert', passive_deletes=True) + roles = relationship('Role', secondary=pending_cert_role_associations, backref='pending_cert', passive_deletes=True) + replaces = relationship('Certificate', + secondary=pending_cert_replacement_associations, + backref='pending_cert', + passive_deletes=True) + + rotation_policy = relationship("RotationPolicy") + + sensitive_fields = ('private_key',) + + def __init__(self, **kwargs): + self.csr = kwargs.get('csr') + self.private_key = kwargs.get('private_key', "") + if self.private_key: + # If the request does not send private key, the key exists but the value is None + self.private_key = self.private_key.strip() + self.external_id = kwargs.get('external_id') + + # when destinations are appended they require a valid name. + if kwargs.get('name'): + self.name = get_or_increase_name(defaults.text_to_slug(kwargs['name']), 0) + self.rename = False + else: + # TODO: Fix auto-generated name, it should be renamed on creation + self.name = get_or_increase_name( + defaults.certificate_name(kwargs['common_name'], kwargs['authority'].name, + dt.now(), dt.now(), False), self.external_id) + self.rename = True + + self.cn = defaults.common_name(lemur.common.utils.parse_csr(self.csr)) + self.owner = kwargs['owner'] + self.number_attempts = 0 + + if kwargs.get('chain'): + self.chain = kwargs['chain'].strip() + + self.notify = kwargs.get('notify', True) + self.destinations = kwargs.get('destinations', []) + self.notifications = kwargs.get('notifications', []) + self.description = kwargs.get('description') + self.roles = list(set(kwargs.get('roles', []))) + self.replaces = kwargs.get('replaces', []) + self.rotation = kwargs.get('rotation') + self.rotation_policy = kwargs.get('rotation_policy') diff --git a/lemur/pending_certificates/schemas.py b/lemur/pending_certificates/schemas.py new file mode 100644 index 00000000..35a4c18a --- /dev/null +++ b/lemur/pending_certificates/schemas.py @@ -0,0 +1,101 @@ +from marshmallow import fields, post_load + +from lemur.schemas import ( + AssociatedCertificateSchema, + AssociatedDestinationSchema, + AssociatedNotificationSchema, + AssociatedRoleSchema, + EndpointNestedOutputSchema, + ExtensionSchema +) + +from lemur.common.schema import LemurInputSchema, LemurOutputSchema +from lemur.users.schemas import UserNestedOutputSchema +from lemur.authorities.schemas import AuthorityNestedOutputSchema +from lemur.certificates.schemas import CertificateNestedOutputSchema +from lemur.destinations.schemas import DestinationNestedOutputSchema +from lemur.domains.schemas import DomainNestedOutputSchema +from lemur.notifications.schemas import NotificationNestedOutputSchema +from lemur.roles.schemas import RoleNestedOutputSchema +from lemur.policies.schemas import RotationPolicyNestedOutputSchema + +from lemur.notifications import service as notification_service + + +class PendingCertificateSchema(LemurInputSchema): + owner = fields.Email(required=True) + description = fields.String(missing='', allow_none=True) + + +class PendingCertificateOutputSchema(LemurOutputSchema): + id = fields.Integer() + external_id = fields.String() + csr = fields.String() + chain = fields.String() + deleted = fields.Boolean(default=False) + description = fields.String() + issuer = fields.String() + name = fields.String() + number_attempts = fields.Integer() + date_created = fields.Date() + + rotation = fields.Boolean() + + # Note aliasing is the first step in deprecating these fields. + notify = fields.Boolean() + active = fields.Boolean(attribute='notify') + + cn = fields.String() + common_name = fields.String(attribute='cn') + + owner = fields.Email() + + status = fields.String() + user = fields.Nested(UserNestedOutputSchema) + + extensions = fields.Nested(ExtensionSchema) + + # associated objects + domains = fields.Nested(DomainNestedOutputSchema, many=True) + destinations = fields.Nested(DestinationNestedOutputSchema, many=True) + notifications = fields.Nested(NotificationNestedOutputSchema, many=True) + replaces = fields.Nested(CertificateNestedOutputSchema, many=True) + authority = fields.Nested(AuthorityNestedOutputSchema) + roles = fields.Nested(RoleNestedOutputSchema, many=True) + endpoints = fields.Nested(EndpointNestedOutputSchema, many=True, missing=[]) + replaced_by = fields.Nested(CertificateNestedOutputSchema, many=True, attribute='replaced') + rotation_policy = fields.Nested(RotationPolicyNestedOutputSchema) + + +class PendingCertificateEditInputSchema(PendingCertificateSchema): + owner = fields.String() + + notify = fields.Boolean() + rotation = fields.Boolean() + + destinations = fields.Nested(AssociatedDestinationSchema, missing=[], many=True) + notifications = fields.Nested(AssociatedNotificationSchema, missing=[], many=True) + replaces = fields.Nested(AssociatedCertificateSchema, missing=[], many=True) + roles = fields.Nested(AssociatedRoleSchema, missing=[], many=True) + + @post_load + def enforce_notifications(self, data): + """ + Ensures that when an owner changes, default notifications are added for the new owner. + Old owner notifications are retained unless explicitly removed. + :param data: + :return: + """ + if data['owner']: + notification_name = "DEFAULT_{0}".format(data['owner'].split('@')[0].upper()) + data['notifications'] += notification_service.create_default_expiration_notifications(notification_name, [data['owner']]) + return data + + +class PendingCertificateCancelSchema(LemurInputSchema): + note = fields.String() + + +pending_certificate_output_schema = PendingCertificateOutputSchema() +pending_certificate_edit_input_schema = PendingCertificateEditInputSchema() +pending_certificate_cancel_schema = PendingCertificateCancelSchema() diff --git a/lemur/pending_certificates/service.py b/lemur/pending_certificates/service.py new file mode 100644 index 00000000..fed2ddd4 --- /dev/null +++ b/lemur/pending_certificates/service.py @@ -0,0 +1,219 @@ +""" +.. module: lemur.pending_certificates.service + Copyright (c) 2017 and onwards Instart Logic, Inc. All rights reserved. +.. moduleauthor:: James Chuong +""" +import arrow + +from sqlalchemy import or_, cast, Boolean, Integer + +from lemur import database +from lemur.plugins.base import plugins + +from lemur.roles.models import Role +from lemur.domains.models import Domain +from lemur.authorities.models import Authority +from lemur.destinations.models import Destination +from lemur.notifications.models import Notification +from lemur.pending_certificates.models import PendingCertificate + +from lemur.certificates import service as certificate_service +from lemur.users import service as user_service + +from lemur.certificates.schemas import CertificateUploadInputSchema + + +def get(pending_cert_id): + """ + Retrieve pending certificate by ID + """ + return database.get(PendingCertificate, pending_cert_id) + + +def get_by_external_id(issuer, external_id): + """ + Retrieves a pending certificate by its issuer and external_id + Since external_id is not necessarily unique between CAs + + :param issuer: + :param external_id: + :return: PendingCertificate or None + """ + if isinstance(external_id, int): + external_id = str(external_id) + return PendingCertificate.query \ + .filter(PendingCertificate.authority_id == issuer.id) \ + .filter(PendingCertificate.external_id == external_id) \ + .one_or_none() + + +def get_by_name(pending_cert_name): + """ + Retrieve pending certificate by name + """ + return database.get(PendingCertificate, pending_cert_name, field='name') + + +def delete(pending_certificate): + database.delete(pending_certificate) + + +def get_pending_certs(pending_ids): + """ + Retrieve a list of pending certs given a list of ids + Filters out non-existing pending certs + """ + pending_certs = [] + if 'all' in pending_ids: + query = database.session_query(PendingCertificate) + return database.find_all(query, PendingCertificate, {}).all() + else: + for pending_id in pending_ids: + pending_cert = get(pending_id) + if pending_cert: + pending_certs.append(pending_cert) + return pending_certs + + +def create_certificate(pending_certificate, certificate, user): + """ + Create and store a certificate with pending certificate's info + Args: + pending_certificate: PendingCertificate which will populate the certificate + certificate: dict from Authority, which contains the body, chain and external id + user: User that called this function, used as 'creator' of the certificate if it does + not have an owner + """ + certificate['owner'] = pending_certificate.owner + data, errors = CertificateUploadInputSchema().load(certificate) + if errors: + raise Exception("Unable to create certificate: {reasons}".format(reasons=errors)) + + data.update(vars(pending_certificate)) + # Copy relationships, vars doesn't copy this without explicit fields + data['notifications'] = list(pending_certificate.notifications) + data['destinations'] = list(pending_certificate.destinations) + data['sources'] = list(pending_certificate.sources) + data['roles'] = list(pending_certificate.roles) + data['replaces'] = list(pending_certificate.replaces) + data['rotation_policy'] = pending_certificate.rotation_policy + + # Replace external id and chain with the one fetched from source + data['external_id'] = certificate['external_id'] + data['chain'] = certificate['chain'] + creator = user_service.get_by_email(pending_certificate.owner) + if not creator: + # Owner of the pending certificate is not the creator, so use the current user who called + # this as the creator (usually lemur) + creator = user + + if pending_certificate.rename: + # If generating name from certificate, remove the one from pending certificate + del data['name'] + data['creator'] = creator + cert = certificate_service.import_certificate(**data) + database.update(cert) + return cert + + +def increment_attempt(pending_certificate): + """ + Increments pending certificate attempt counter and updates it in the database. + """ + pending_certificate.number_attempts += 1 + database.update(pending_certificate) + return pending_certificate.number_attempts + + +def update(pending_cert_id, **kwargs): + """ + Updates a pending certificate. The allowed fields are validated by + PendingCertificateEditInputSchema. + """ + pending_cert = get(pending_cert_id) + for key, value in kwargs.items(): + setattr(pending_cert, key, value) + return database.update(pending_cert) + + +def cancel(pending_certificate, **kwargs): + """ + Cancel a pending certificate. A check should be done prior to this function to decide to + revoke the certificate or just abort cancelling. + Args: + pending_certificate: PendingCertificate to be cancelled + Returns: the pending certificate if successful, raises Exception if there was an issue + """ + plugin = plugins.get(pending_certificate.authority.plugin_name) + plugin.cancel_ordered_certificate(pending_certificate, **kwargs) + pending_certificate.status = 'Cancelled' + database.update(pending_certificate) + return pending_certificate + + +def render(args): + query = database.session_query(PendingCertificate) + 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 + + filt = args.pop('filter') + + if filt: + terms = filt.split(';') + + if 'issuer' in terms: + # we can't rely on issuer being correct in the cert directly so we combine queries + sub_query = database.session_query(Authority.id)\ + .filter(Authority.name.ilike('%{0}%'.format(terms[1])))\ + .subquery() + + query = query.filter( + or_( + PendingCertificate.issuer.ilike('%{0}%'.format(terms[1])), + PendingCertificate.authority_id.in_(sub_query) + ) + ) + + elif 'destination' in terms: + query = query.filter(PendingCertificate.destinations.any(Destination.id == terms[1])) + elif 'notify' in filt: + query = query.filter(PendingCertificate.notify == cast(terms[1], Boolean)) + elif 'active' in filt: + query = query.filter(PendingCertificate.active == terms[1]) + elif 'cn' in terms: + query = query.filter( + or_( + PendingCertificate.cn.ilike('%{0}%'.format(terms[1])), + PendingCertificate.domains.any(Domain.name.ilike('%{0}%'.format(terms[1]))) + ) + ) + elif 'id' in terms: + query = query.filter(PendingCertificate.id == cast(terms[1], Integer)) + else: + query = database.filter(query, PendingCertificate, terms) + + if show: + sub_query = database.session_query(Role.name).filter(Role.user_id == args['user'].id).subquery() + query = query.filter( + or_( + PendingCertificate.user_id == args['user'].id, + PendingCertificate.owner.in_(sub_query) + ) + ) + + if destination_id: + query = query.filter(PendingCertificate.destinations.any(Destination.id == destination_id)) + + if notification_id: + query = query.filter(PendingCertificate.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') + query = query.filter(PendingCertificate.not_after <= to).filter(PendingCertificate.not_after >= now) + + return database.sort_and_page(query, PendingCertificate, args) diff --git a/lemur/pending_certificates/views.py b/lemur/pending_certificates/views.py new file mode 100644 index 00000000..81b0671e --- /dev/null +++ b/lemur/pending_certificates/views.py @@ -0,0 +1,424 @@ +""" +.. module: lemur.pending_certificates.views + :platform: Unix + :license: Apache, see LICENSE for more details. +.. moduleauthor:: James Chuong +""" +from flask import Blueprint, g, make_response, jsonify +from flask_restful import Api, reqparse + +from lemur.auth.service import AuthenticatedResource +from lemur.auth.permissions import CertificatePermission + +from lemur.common.schema import validate_schema +from lemur.common.utils import paginated_parser + +from lemur.pending_certificates import service +from lemur.roles import service as role_service + +from lemur.pending_certificates.schemas import ( + pending_certificate_output_schema, + pending_certificate_edit_input_schema, + pending_certificate_cancel_schema, +) + +mod = Blueprint('pending_certificates', __name__) +api = Api(mod) + + +class PendingCertificatesList(AuthenticatedResource): + def __init__(self): + self.reqparse = reqparse.RequestParser() + super(PendingCertificatesList, self).__init__() + + @validate_schema(None, pending_certificate_output_schema) + def get(self): + """ + .. http:get:: /pending_certificates + + List of pending certificates + + **Example request**: + + .. sourcecode:: http + + GET /pending_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 + + { + "status": null, + "cn": "*.test.example.net", + "chain": "", + "authority": { + "active": true, + "owner": "secure@example.com", + "id": 1, + "description": "verisign test authority", + "name": "verisign" + }, + "owner": "joe@example.com", + "serial": "82311058732025924142789179368889309156", + "id": 2288, + "issuer": "SymantecCorporation", + "notBefore": "2016-06-03T00:00:00+00:00", + "notAfter": "2018-01-12T23:59:59+00:00", + "destinations": [], + "description": null, + "deleted": null, + "notifications": [{ + "id": 1 + }], + "signingAlgorithm": "sha256", + "user": { + "username": "jane", + "active": true, + "email": "jane@example.com", + "id": 2 + }, + "active": true, + "domains": [{ + "sensitive": false, + "id": 1090, + "name": "*.test.example.net" + }], + "rotation": true, + "rotationPolicy": {"name": "default"}, + "replaces": [], + "replaced": [], + "name": "WILDCARD.test.example.net-SymantecCorporation-20160603-20180112", + "roles": [{ + "id": 464, + "description": "This is a google group based role created by Lemur", + "name": "joe@example.com" + }], + "san": null + } + + :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['user'] = g.user + return service.render(args) + + +class PendingCertificates(AuthenticatedResource): + def __init__(self): + self.reqparse = reqparse.RequestParser() + super(PendingCertificates, self).__init__() + + @validate_schema(None, pending_certificate_output_schema) + def get(self, pending_certificate_id): + """ + .. http:get:: /pending_certificates/1 + + One pending certificate + + **Example request**: + + .. sourcecode:: http + + GET /pending_certificates/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 + + { + "status": null, + "cn": "*.test.example.net", + "chain": "", + "authority": { + "active": true, + "owner": "secure@example.com", + "id": 1, + "description": "verisign test authority", + "name": "verisign" + }, + "owner": "joe@example.com", + "serial": "82311058732025924142789179368889309156", + "id": 1, + "issuer": "SymantecCorporation", + "notBefore": "2016-06-03T00:00:00+00:00", + "notAfter": "2018-01-12T23:59:59+00:00", + "destinations": [], + "description": null, + "deleted": null, + "notifications": [{ + "id": 1 + }], + "signingAlgorithm": "sha256", + "user": { + "username": "jane", + "active": true, + "email": "jane@example.com", + "id": 2 + }, + "active": true, + "domains": [{ + "sensitive": false, + "id": 1090, + "name": "*.test.example.net" + }], + "rotation": true, + "rotationPolicy": {"name": "default"}, + "replaces": [], + "replaced": [], + "name": "WILDCARD.test.example.net-SymantecCorporation-20160603-20180112", + "roles": [{ + "id": 464, + "description": "This is a google group based role created by Lemur", + "name": "joe@example.com" + }], + "san": null + } + + :reqheader Authorization: OAuth token to authenticate + :statuscode 200: no error + :statuscode 403: unauthenticated + + """ + return service.get(pending_certificate_id) + + @validate_schema(pending_certificate_edit_input_schema, pending_certificate_output_schema) + def put(self, pending_certificate_id, data=None): + """ + .. http:put:: /pending_certificates/1 + + Update a pending certificate + + **Example request**: + + .. sourcecode:: http + + PUT /pending certificates/1 HTTP/1.1 + Host: example.com + Accept: application/json, text/javascript + + { + "owner": "jimbob@example.com", + "active": false + "notifications": [], + "destinations": [], + "replacements": [] + } + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: text/javascript + + { + "status": null, + "cn": "*.test.example.net", + "chain": "", + "authority": { + "active": true, + "owner": "secure@example.com", + "id": 1, + "description": "verisign test authority", + "name": "verisign" + }, + "owner": "joe@example.com", + "serial": "82311058732025924142789179368889309156", + "id": 2288, + "issuer": "SymantecCorporation", + "destinations": [], + "description": null, + "deleted": null, + "notifications": [{ + "id": 1 + }] + "user": { + "username": "jane", + "active": true, + "email": "jane@example.com", + "id": 2 + }, + "active": true, + "number_attempts": 1, + "csr": "-----BEGIN CERTIFICATE REQUEST-----...", + "external_id": 12345, + "domains": [{ + "sensitive": false, + "id": 1090, + "name": "*.test.example.net" + }], + "replaces": [], + "name": "WILDCARD.test.example.net-SymantecCorporation-20160603-20180112", + "roles": [{ + "id": 464, + "description": "This is a google group based role created by Lemur", + "name": "joe@example.com" + }], + "rotation": true, + "rotationPolicy": {"name": "default"}, + } + + :reqheader Authorization: OAuth token to authenticate + :statuscode 200: no error + :statuscode 403: unauthenticated + + """ + pending_cert = service.get(pending_certificate_id) + + if not pending_cert: + return dict(message="Cannot find specified pending certificate"), 404 + + # allow creators + if g.current_user != pending_cert.user: + owner_role = role_service.get_by_name(pending_cert.owner) + permission = CertificatePermission(owner_role, [x.name for x in pending_cert.roles]) + + if not permission.can(): + return dict(message='You are not authorized to update this certificate'), 403 + + for destination in data['destinations']: + if destination.plugin.requires_key: + if not pending_cert.private_key: + return dict( + message='Unable to add destination: {0}. Certificate does not have required private key.'.format( + destination.label + ) + ), 400 + + pending_cert = service.update(pending_certificate_id, **data) + return pending_cert + + @validate_schema(pending_certificate_cancel_schema, None) + def delete(self, pending_certificate_id, data=None): + """ + .. http:delete:: /pending_certificates/1 + + Cancel and delete a pending certificate + + **Example request**: + + .. sourcecode:: http + + DELETE /pending certificates/1 HTTP/1.1 + Host: example.com + Accept: application/json, text/javascript + + { + "note": "Why I am cancelling this order" + } + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 204 No Content + + :reqheader Authorization: OAuth token to authenticate + :statuscode 204: no error + :statuscode 401: unauthenticated + :statuscode 403: unauthorized + :statuscode 404: pending certificate id not found + :statuscode 500: internal error + """ + pending_cert = service.get(pending_certificate_id) + + if not pending_cert: + return dict(message="Cannot find specified pending certificate"), 404 + + # allow creators + if g.current_user != pending_cert.user: + owner_role = role_service.get_by_name(pending_cert.owner) + permission = CertificatePermission(owner_role, [x.name for x in pending_cert.roles]) + + if not permission.can(): + return dict(message='You are not authorized to update this certificate'), 403 + + if service.cancel(pending_cert, **data): + service.delete(pending_cert) + return('', 204) + else: + # service.cancel raises exception if there was an issue, but this will ensure something + # is relayed to user in case of something unexpected (unsuccessful update somehow). + return dict(message="Unexpected error occurred while trying to cancel this certificate"), 500 + + +class PendingCertificatePrivateKey(AuthenticatedResource): + def __init__(self): + super(PendingCertificatePrivateKey, self).__init__() + + def get(self, pending_certificate_id): + """ + .. http:get:: /pending_certificates/1/key + + Retrieves the private key for a given pneding certificate + + **Example request**: + + .. sourcecode:: http + + GET /pending_certificates/1/key 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 + + { + "key": "-----BEGIN ..." + } + + :reqheader Authorization: OAuth token to authenticate + :statuscode 200: no error + :statuscode 403: unauthenticated + """ + cert = service.get(pending_certificate_id) + if not cert: + return dict(message="Cannot find specified pending 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 view this key'), 403 + + 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 + + +api.add_resource(PendingCertificatesList, '/pending_certificates', endpoint='pending_certificates') +api.add_resource(PendingCertificates, '/pending_certificates/', endpoint='pending_certificate') +api.add_resource(PendingCertificatePrivateKey, '/pending_certificates//key', endpoint='privateKeyPendingCertificates') diff --git a/lemur/plugins/bases/issuer.py b/lemur/plugins/bases/issuer.py index add16a16..1cca60d7 100644 --- a/lemur/plugins/bases/issuer.py +++ b/lemur/plugins/bases/issuer.py @@ -24,3 +24,9 @@ class IssuerPlugin(Plugin): def revoke_certificate(self, certificate, comments): raise NotImplementedError + + def get_ordered_certificate(self, order_id): + raise NotImplementedError + + def cancel_ordered_certificate(self, pending_cert, **kwargs): + raise NotImplementedError diff --git a/lemur/plugins/lemur_acme/cloudflare.py b/lemur/plugins/lemur_acme/cloudflare.py new file mode 100644 index 00000000..2a665b38 --- /dev/null +++ b/lemur/plugins/lemur_acme/cloudflare.py @@ -0,0 +1,76 @@ +import time +import CloudFlare + +from flask import current_app + + +def cf_api_call(): + cf_key = current_app.config.get('ACME_CLOUDFLARE_KEY', '') + cf_email = current_app.config.get('ACME_CLOUDFLARE_EMAIL', '') + return CloudFlare.CloudFlare(email=cf_email, token=cf_key) + + +def find_zone_id(host): + elements = host.split('.') + cf = cf_api_call() + + n = 1 + + while n < 5: + n = n + 1 + domain = '.'.join(elements[-n:]) + current_app.logger.debug("Trying to get ID for zone {0}".format(domain)) + + try: + zone = cf.zones.get(params={'name': domain, 'per_page': 1}) + except Exception as e: + current_app.logger.error("Cloudflare API error: %s" % e) + pass + + if len(zone) == 1: + break + + if len(zone) == 0: + current_app.logger.error('No zone found') + return + else: + return zone[0]['id'] + + +def wait_for_dns_change(change_id, account_number=None): + cf = cf_api_call() + zone_id, record_id = change_id + while True: + r = cf.zones.get(zone_id, record_id) + current_app.logger.debug("Record status: %s" % r['status']) + if r['status'] == 'active': + break + time.sleep(1) + return + + +def create_txt_record(host, value, account_number): + cf = cf_api_call() + zone_id = find_zone_id(host) + if not zone_id: + return + + txt_record = {'name': host, 'type': 'TXT', 'content': value} + + current_app.logger.debug("Creating TXT record {0} with value {1}".format(host, value)) + + try: + r = cf.zones.dns_records.post(zone_id, data=txt_record) + except Exception as e: + current_app.logger.error('/zones.dns_records.post %s: %s' % (txt_record['name'], e)) + return zone_id, r['id'] + + +def delete_txt_record(change_id, account_number, host, value): + cf = cf_api_call() + zone_id, record_id = change_id + current_app.logger.debug("Removing record with id {0}".format(record_id)) + try: + cf.zones.dns_records.delete(zone_id, record_id) + except Exception as e: + current_app.logger.error('/zones.dns_records.post: %s' % e) diff --git a/lemur/plugins/lemur_acme/plugin.py b/lemur/plugins/lemur_acme/plugin.py index bcd9371a..3dabc575 100644 --- a/lemur/plugins/lemur_acme/plugin.py +++ b/lemur/plugins/lemur_acme/plugin.py @@ -25,8 +25,6 @@ from lemur.common.utils import validate_conf from lemur.plugins.bases import IssuerPlugin from lemur.plugins import lemur_acme as acme -from .route53 import delete_txt_record, create_txt_record, wait_for_r53_change - def find_dns_challenge(authz): for combo in authz.body.resolved_combinations: @@ -45,12 +43,13 @@ class AuthorizationRecord(object): self.change_id = change_id -def start_dns_challenge(acme_client, account_number, host): +def start_dns_challenge(acme_client, account_number, host, dns_provider): + current_app.logger.debug("Starting DNS challenge for {0}".format(host)) authz = acme_client.request_domain_challenges(host) [dns_challenge] = find_dns_challenge(authz) - change_id = create_txt_record( + change_id = dns_provider.create_txt_record( dns_challenge.validation_domain_name(host), dns_challenge.validation(acme_client.key), account_number @@ -64,8 +63,8 @@ def start_dns_challenge(acme_client, account_number, host): ) -def complete_dns_challenge(acme_client, account_number, authz_record): - wait_for_r53_change(authz_record.change_id, account_number=account_number) +def complete_dns_challenge(acme_client, account_number, authz_record, dns_provider): + dns_provider.wait_for_dns_change(authz_record.change_id, account_number=account_number) response = authz_record.dns_challenge.response(acme_client.key) @@ -96,12 +95,12 @@ def request_certificate(acme_client, authorizations, csr): OpenSSL.crypto.FILETYPE_PEM, cert_response.body ).decode('utf-8') - # https://github.com/alex/letsencrypt-aws/commit/853ea7f93f141fe18d9ef12aee6b3388f98b4830 - pem_certificate_chain = b"\n".join( - OpenSSL.crypto.dump_certificate(OpenSSL.crypto.FILETYPE_PEM, cert) + pem_certificate_chain = "\n".join( + OpenSSL.crypto.dump_certificate(OpenSSL.crypto.FILETYPE_PEM, cert.decode("utf-8")) for cert in acme_client.fetch_chain(cert_response) ).decode('utf-8') + current_app.logger.debug("{0} {1}".format(type(pem_certificate). type(pem_certificate_chain))) return pem_certificate, pem_certificate_chain @@ -113,12 +112,15 @@ def setup_acme_client(): key = jose.JWKRSA(key=generate_private_key('RSA2048')) + current_app.logger.debug("Connecting with directory at {0}".format(directory_url)) client = Client(directory_url, key) registration = client.register( messages.NewRegistration.from_data(email=email) ) + current_app.logger.debug("Connected: {0}".format(registration.uri)) + client.agree_to_tos(registration) return client, registration @@ -129,26 +131,30 @@ def get_domains(options): :param options: :return: """ + current_app.logger.debug("Fetching domains") + domains = [options['common_name']] if options.get('extensions'): for name in options['extensions']['sub_alt_names']['names']: - domains.append(name.value) + domains.append(name) + + current_app.logger.debug("Got these domains: {0}".format(domains)) return domains -def get_authorizations(acme_client, account_number, domains): +def get_authorizations(acme_client, account_number, domains, dns_provider): authorizations = [] try: for domain in domains: - authz_record = start_dns_challenge(acme_client, account_number, domain) + authz_record = start_dns_challenge(acme_client, account_number, domain, dns_provider) authorizations.append(authz_record) for authz_record in authorizations: - complete_dns_challenge(acme_client, account_number, authz_record) + complete_dns_challenge(acme_client, account_number, authz_record, dns_provider) finally: for authz_record in authorizations: dns_challenge = authz_record.dns_challenge - delete_txt_record( + dns_provider.delete_txt_record( authz_record.change_id, account_number, dns_challenge.validation_domain_name(authz_record.host), @@ -177,6 +183,9 @@ class ACMEIssuerPlugin(IssuerPlugin): ] validate_conf(current_app, required_vars) + self.dns_provider_name = current_app.config.get('ACME_DNS_PROVIDER', 'route53') + current_app.logger.debug("Using DNS provider: {0}".format(self.dns_provider_name)) + self.dns_provider = __import__(self.dns_provider_name, globals(), locals(), [], 1) super(ACMEIssuerPlugin, self).__init__(*args, **kwargs) def create_certificate(self, csr, issuer_options): @@ -191,7 +200,7 @@ class ACMEIssuerPlugin(IssuerPlugin): acme_client, registration = setup_acme_client() account_number = current_app.config.get('ACME_AWS_ACCOUNT_NUMBER') domains = get_domains(issuer_options) - authorizations = get_authorizations(acme_client, account_number, domains) + authorizations = get_authorizations(acme_client, account_number, domains, self.dns_provider) pem_certificate, pem_certificate_chain = request_certificate(acme_client, authorizations, csr) # TODO add external ID (if possible) return pem_certificate, pem_certificate_chain, None diff --git a/lemur/plugins/lemur_acme/route53.py b/lemur/plugins/lemur_acme/route53.py index f7a9c594..9e5b9688 100644 --- a/lemur/plugins/lemur_acme/route53.py +++ b/lemur/plugins/lemur_acme/route53.py @@ -3,7 +3,7 @@ from lemur.plugins.lemur_aws.sts import sts_client @sts_client('route53') -def wait_for_r53_change(change_id, client=None): +def wait_for_dns_change(change_id, client=None): _, change_id = change_id while True: diff --git a/lemur/plugins/lemur_digicert/plugin.py b/lemur/plugins/lemur_digicert/plugin.py index 16d0b34e..00fe9519 100644 --- a/lemur/plugins/lemur_digicert/plugin.py +++ b/lemur/plugins/lemur_digicert/plugin.py @@ -258,8 +258,7 @@ class DigiCertIssuerPlugin(IssuerPlugin): """Wrap the Digicert Issuer API.""" title = 'DigiCert' slug = 'digicert-issuer' - description = "Enables the creation of certificates by" - "the DigiCert REST API." + description = "Enables the creation of certificates by the DigiCert REST API." version = digicert.VERSION author = 'Kevin Glisson' @@ -326,6 +325,39 @@ class DigiCertIssuerPlugin(IssuerPlugin): response = self.session.put(create_url, data=json.dumps({'comments': comments})) return handle_response(response) + def get_ordered_certificate(self, order_id): + """ Retrieve a certificate via order id """ + base_url = current_app.config.get('DIGICERT_URL') + try: + certificate_id = get_certificate_id(self.session, base_url, order_id) + except Exception as ex: + return None + 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) + cert = {'body': "\n".join(str(end_entity).splitlines()), + 'chain': "\n".join(str(intermediate).splitlines()), + 'external_id': str(certificate_id)} + return cert + + def cancel_ordered_certificate(self, pending_cert, **kwargs): + """ Set the certificate order to canceled """ + base_url = current_app.config.get('DIGICERT_URL') + api_url = "{0}/services/v2/order/certificate/{1}/status".format(base_url, pending_cert.external_id) + payload = { + 'status': 'CANCELED', + 'note': kwargs.get('note') + } + response = self.session.put(api_url, data=json.dumps(payload)) + if response.status_code == 404: + # not well documented by Digicert, but either the certificate does not exist or we + # don't own that order (someone else's order id!). Either way, we can just ignore it + # and have it removed from Lemur + current_app.logger.warning( + "Digicert Plugin tried to cancel pending certificate {0} but it does not exist!".format(pending_cert.name)) + elif response.status_code != 204: + current_app.logger.debug("{0} code {1}".format(response.status_code, response.content)) + raise Exception("Failed to cancel pending certificate {0}".format(pending_cert.name)) + @staticmethod def create_authority(options): """Create an authority. diff --git a/lemur/plugins/lemur_digicert/tests/test_digicert.py b/lemur/plugins/lemur_digicert/tests/test_digicert.py index 5448db9c..dca24892 100644 --- a/lemur/plugins/lemur_digicert/tests/test_digicert.py +++ b/lemur/plugins/lemur_digicert/tests/test_digicert.py @@ -1,6 +1,8 @@ import pytest import arrow import json +from unittest.mock import patch + from freezegun import freeze_time from lemur.tests.vectors import CSR_STR @@ -175,3 +177,22 @@ ghi assert cert == "-----BEGIN CERTIFICATE-----\nabc\n-----END CERTIFICATE-----" assert intermediate == "-----BEGIN CERTIFICATE-----\ndef\n-----END CERTIFICATE-----" + + +@patch("lemur.pending_certificates.models.PendingCertificate") +def test_cancel_ordered_certificate(mock_pending_cert): + import requests_mock + from lemur.plugins.lemur_digicert.plugin import DigiCertIssuerPlugin + + mock_pending_cert.external_id = 1234 + subject = DigiCertIssuerPlugin() + adapter = requests_mock.Adapter() + adapter.register_uri('PUT', 'mock://www.digicert.com/services/v2/order/certificate/1234/status', status_code=204) + adapter.register_uri('PUT', 'mock://www.digicert.com/services/v2/order/certificate/111/status', status_code=404) + subject.session.mount('mock', adapter) + data = {'note': 'Test'} + subject.cancel_ordered_certificate(mock_pending_cert, **data) + + # A non-existing order id, does not raise exception because if it doesn't exist, then it doesn't matter + mock_pending_cert.external_id = 111 + subject.cancel_ordered_certificate(mock_pending_cert, **data) diff --git a/lemur/roles/models.py b/lemur/roles/models.py index 3ecd9188..21a40be2 100644 --- a/lemur/roles/models.py +++ b/lemur/roles/models.py @@ -14,7 +14,8 @@ from sqlalchemy import Boolean, Column, Integer, String, Text, ForeignKey from lemur.database import db from lemur.utils import Vault -from lemur.models import roles_users, roles_authorities, roles_certificates +from lemur.models import roles_users, roles_authorities, roles_certificates, \ + pending_cert_role_associations class Role(db.Model): @@ -30,6 +31,7 @@ class Role(db.Model): third_party = Column(Boolean) users = relationship("User", secondary=roles_users, passive_deletes=True, backref="role") certificates = relationship("Certificate", secondary=roles_certificates, backref="role") + pending_certificates = relationship("PendingCertificate", secondary=pending_cert_role_associations, backref="role") sensitive_fields = ('password',) diff --git a/lemur/sources/service.py b/lemur/sources/service.py index a1c19e4b..9e6ce289 100644 --- a/lemur/sources/service.py +++ b/lemur/sources/service.py @@ -140,6 +140,8 @@ def sync_certificates(source, user): for e in exists: if certificate.get('external_id'): e.external_id = certificate['external_id'] + if certificate.get('authority_id'): + e.authority_id = certificate['authority_id'] certificate_update(e, source) updated += 1 diff --git a/lemur/static/app/angular/pending_certificates/pending_certificate/cancel.tpl.html b/lemur/static/app/angular/pending_certificates/pending_certificate/cancel.tpl.html new file mode 100644 index 00000000..4b6b12fc --- /dev/null +++ b/lemur/static/app/angular/pending_certificates/pending_certificate/cancel.tpl.html @@ -0,0 +1,25 @@ + +