From 9faba7f5c32ae430b74a51c0e7f24f8cd8390b10 Mon Sep 17 00:00:00 2001 From: Sam Chow Date: Tue, 14 Aug 2018 11:42:11 -0400 Subject: [PATCH] Add backend ability to generate sec keys in config app --- config_app/config_endpoints/api/superuser.py | 337 ++++++++++-------- .../api/superuser_models_pre_oci.py | 57 +-- .../js/components/markdown/markdown.module.ts | 14 + 3 files changed, 243 insertions(+), 165 deletions(-) diff --git a/config_app/config_endpoints/api/superuser.py b/config_app/config_endpoints/api/superuser.py index fd82ec6ee..7cca94012 100644 --- a/config_app/config_endpoints/api/superuser.py +++ b/config_app/config_endpoints/api/superuser.py @@ -2,6 +2,7 @@ import logging import pathvalidate import os import subprocess +from datetime import datetime from flask import request, jsonify, make_response @@ -22,173 +23,231 @@ logger = logging.getLogger(__name__) @resource('/v1/superuser/customcerts/') class SuperUserCustomCertificate(ApiResource): - """ Resource for managing a custom certificate. """ + """ Resource for managing a custom certificate. """ - @nickname('uploadCustomCertificate') - def post(self, certpath): - uploaded_file = request.files['file'] - if not uploaded_file: - raise InvalidRequest('Missing certificate file') + @nickname('uploadCustomCertificate') + def post(self, certpath): + uploaded_file = request.files['file'] + if not uploaded_file: + raise InvalidRequest('Missing certificate file') - # Save the certificate. - certpath = pathvalidate.sanitize_filename(certpath) - if not certpath.endswith('.crt'): - raise InvalidRequest('Invalid certificate file: must have suffix `.crt`') + # Save the certificate. + certpath = pathvalidate.sanitize_filename(certpath) + if not certpath.endswith('.crt'): + raise InvalidRequest('Invalid certificate file: must have suffix `.crt`') - logger.debug('Saving custom certificate %s', certpath) - cert_full_path = config_provider.get_volume_path(EXTRA_CA_DIRECTORY, certpath) - config_provider.save_volume_file(cert_full_path, uploaded_file) - logger.debug('Saved custom certificate %s', certpath) + logger.debug('Saving custom certificate %s', certpath) + cert_full_path = config_provider.get_volume_path(EXTRA_CA_DIRECTORY, certpath) + config_provider.save_volume_file(cert_full_path, uploaded_file) + logger.debug('Saved custom certificate %s', certpath) - # Validate the certificate. - try: - logger.debug('Loading custom certificate %s', certpath) - with config_provider.get_volume_file(cert_full_path) as f: - load_certificate(f.read()) - except CertInvalidException: - logger.exception('Got certificate invalid error for cert %s', certpath) - return '', 204 - except IOError: - logger.exception('Got IO error for cert %s', certpath) - return '', 204 + # Validate the certificate. + try: + logger.debug('Loading custom certificate %s', certpath) + with config_provider.get_volume_file(cert_full_path) as f: + load_certificate(f.read()) + except CertInvalidException: + logger.exception('Got certificate invalid error for cert %s', certpath) + return '', 204 + except IOError: + logger.exception('Got IO error for cert %s', certpath) + return '', 204 - # Call the update script with config dir location to install the certificate immediately. - if subprocess.call([os.path.join(INIT_SCRIPTS_LOCATION, 'certs_install.sh')], - env={ 'QUAYCONFIG': config_provider.get_config_dir_path() }) != 0: - raise Exception('Could not install certificates') + # Call the update script with config dir location to install the certificate immediately. + if subprocess.call([os.path.join(INIT_SCRIPTS_LOCATION, 'certs_install.sh')], + env={ 'QUAYCONFIG': config_provider.get_config_dir_path() }) != 0: + raise Exception('Could not install certificates') - return '', 204 + return '', 204 - @nickname('deleteCustomCertificate') - def delete(self, certpath): - cert_full_path = config_provider.get_volume_path(EXTRA_CA_DIRECTORY, certpath) - config_provider.remove_volume_file(cert_full_path) - return '', 204 + @nickname('deleteCustomCertificate') + def delete(self, certpath): + cert_full_path = config_provider.get_volume_path(EXTRA_CA_DIRECTORY, certpath) + config_provider.remove_volume_file(cert_full_path) + return '', 204 @resource('/v1/superuser/customcerts') class SuperUserCustomCertificates(ApiResource): - """ Resource for managing custom certificates. """ + """ Resource for managing custom certificates. """ - @nickname('getCustomCertificates') - def get(self): - has_extra_certs_path = config_provider.volume_file_exists(EXTRA_CA_DIRECTORY) - extra_certs_found = config_provider.list_volume_directory(EXTRA_CA_DIRECTORY) - if extra_certs_found is None: - return { - 'status': 'file' if has_extra_certs_path else 'none', - } + @nickname('getCustomCertificates') + def get(self): + has_extra_certs_path = config_provider.volume_file_exists(EXTRA_CA_DIRECTORY) + extra_certs_found = config_provider.list_volume_directory(EXTRA_CA_DIRECTORY) + if extra_certs_found is None: + return { + 'status': 'file' if has_extra_certs_path else 'none', + } - cert_views = [] - for extra_cert_path in extra_certs_found: - try: - cert_full_path = config_provider.get_volume_path(EXTRA_CA_DIRECTORY, extra_cert_path) - with config_provider.get_volume_file(cert_full_path) as f: - certificate = load_certificate(f.read()) - cert_views.append({ - 'path': extra_cert_path, - 'names': list(certificate.names), - 'expired': certificate.expired, - }) - except CertInvalidException as cie: - cert_views.append({ - 'path': extra_cert_path, - 'error': cie.message, - }) - except IOError as ioe: - cert_views.append({ - 'path': extra_cert_path, - 'error': ioe.message, - }) + cert_views = [] + for extra_cert_path in extra_certs_found: + try: + cert_full_path = config_provider.get_volume_path(EXTRA_CA_DIRECTORY, extra_cert_path) + with config_provider.get_volume_file(cert_full_path) as f: + certificate = load_certificate(f.read()) + cert_views.append({ + 'path': extra_cert_path, + 'names': list(certificate.names), + 'expired': certificate.expired, + }) + except CertInvalidException as cie: + cert_views.append({ + 'path': extra_cert_path, + 'error': cie.message, + }) + except IOError as ioe: + cert_views.append({ + 'path': extra_cert_path, + 'error': ioe.message, + }) - return { - 'status': 'directory', - 'certs': cert_views, - } + return { + 'status': 'directory', + 'certs': cert_views, + } @resource('/v1/superuser/keys') class SuperUserServiceKeyManagement(ApiResource): - """ Resource for managing service keys.""" - schemas = { - 'CreateServiceKey': { - 'id': 'CreateServiceKey', - 'type': 'object', - 'description': 'Description of creation of a service key', - 'required': ['service', 'expiration'], - 'properties': { - 'service': { - 'type': 'string', - 'description': 'The service authenticating with this key', - }, - 'name': { - 'type': 'string', - 'description': 'The friendly name of a service key', - }, - 'metadata': { - 'type': 'object', - 'description': 'The key/value pairs of this key\'s metadata', - }, - 'notes': { - 'type': 'string', - 'description': 'If specified, the extra notes for the key', - }, - 'expiration': { - 'description': 'The expiration date as a unix timestamp', - 'anyOf': [{'type': 'number'}, {'type': 'null'}], - }, - }, + """ Resource for managing service keys.""" + schemas = { + 'CreateServiceKey': { + 'id': 'CreateServiceKey', + 'type': 'object', + 'description': 'Description of creation of a service key', + 'required': ['service', 'expiration'], + 'properties': { + 'service': { + 'type': 'string', + 'description': 'The service authenticating with this key', }, + 'name': { + 'type': 'string', + 'description': 'The friendly name of a service key', + }, + 'metadata': { + 'type': 'object', + 'description': 'The key/value pairs of this key\'s metadata', + }, + 'notes': { + 'type': 'string', + 'description': 'If specified, the extra notes for the key', + }, + 'expiration': { + 'description': 'The expiration date as a unix timestamp', + 'anyOf': [{'type': 'number'}, {'type': 'null'}], + }, + }, + }, + } + + @nickname('listServiceKeys') + def get(self): + keys = pre_oci_model.list_all_service_keys() + + return jsonify({ + 'keys': [key.to_dict() for key in keys], + }) + + @nickname('createServiceKey') + @validate_json_request('CreateServiceKey') + def post(self): + body = request.get_json() + + # Ensure we have a valid expiration date if specified. + expiration_date = body.get('expiration', None) + if expiration_date is not None: + try: + expiration_date = datetime.utcfromtimestamp(float(expiration_date)) + except ValueError as ve: + raise InvalidRequest('Invalid expiration date: %s' % ve) + + if expiration_date <= datetime.now(): + raise InvalidRequest('Expiration date cannot be in the past') + + # Create the metadata for the key. + # Since we don't have logins in the config app, we'll just get any of the superusers + user = config_provider.get_config().get('SUPER_USERS', [None])[0] + if user is None: + raise InvalidRequest('No super users exist, cannot create service key without approver') + + metadata = body.get('metadata', {}) + metadata.update({ + 'created_by': 'Quay Superuser Panel', + 'creator': user, + 'ip': request.remote_addr, + }) + + # Generate a key with a private key that we *never save*. + (private_key, key_id) = pre_oci_model.generate_service_key(body['service'], expiration_date, + metadata=metadata, + name=body.get('name', '')) + # Auto-approve the service key. + pre_oci_model.approve_service_key(key_id, ServiceKeyApprovalType.SUPERUSER, + notes=body.get('notes', '')) + + # Log the creation and auto-approval of the service key. + key_log_metadata = { + 'kid': key_id, + 'preshared': True, + 'service': body['service'], + 'name': body.get('name', ''), + 'expiration_date': expiration_date, + 'auto_approved': True, } - @nickname('listServiceKeys') - def get(self): - keys = pre_oci_model.list_all_service_keys() + log_action('service_key_create', None, key_log_metadata) + log_action('service_key_approve', None, key_log_metadata) - return jsonify({ - 'keys': [key.to_dict() for key in keys], - }) + return jsonify({ + 'kid': key_id, + 'name': body.get('name', ''), + 'service': body['service'], + 'public_key': private_key.publickey().exportKey('PEM'), + 'private_key': private_key.exportKey('PEM'), + }) @resource('/v1/superuser/approvedkeys/') class SuperUserServiceKeyApproval(ApiResource): - """ Resource for approving service keys. """ + """ Resource for approving service keys. """ - schemas = { - 'ApproveServiceKey': { - 'id': 'ApproveServiceKey', - 'type': 'object', - 'description': 'Information for approving service keys', - 'properties': { - 'notes': { - 'type': 'string', - 'description': 'Optional approval notes', - }, - }, + schemas = { + 'ApproveServiceKey': { + 'id': 'ApproveServiceKey', + 'type': 'object', + 'description': 'Information for approving service keys', + 'properties': { + 'notes': { + 'type': 'string', + 'description': 'Optional approval notes', }, - } + }, + }, + } - @nickname('approveServiceKey') - @validate_json_request('ApproveServiceKey') - def post(self, kid): - notes = request.get_json().get('notes', '') - try: - key = pre_oci_model.approve_service_key(kid, ServiceKeyApprovalType.SUPERUSER, notes=notes) + @nickname('approveServiceKey') + @validate_json_request('ApproveServiceKey') + def post(self, kid): + notes = request.get_json().get('notes', '') + try: + key = pre_oci_model.approve_service_key(kid, ServiceKeyApprovalType.SUPERUSER, notes=notes) - # Log the approval of the service key. - key_log_metadata = { - 'kid': kid, - 'service': key.service, - 'name': key.name, - 'expiration_date': key.expiration_date, - } + # Log the approval of the service key. + key_log_metadata = { + 'kid': kid, + 'service': key.service, + 'name': key.name, + 'expiration_date': key.expiration_date, + } - # Note: this may not actually be the current person modifying the config, but if they're in the config tool, - # they have full access to the DB and could pretend to be any user, so pulling any superuser is likely fine - super_user = app.config.get('SUPER_USERS', [None])[0] - log_action('service_key_approve', super_user, key_log_metadata) - except ServiceKeyDoesNotExist: - raise NotFound() - except ServiceKeyAlreadyApproved: - pass + # Note: this may not actually be the current person modifying the config, but if they're in the config tool, + # they have full access to the DB and could pretend to be any user, so pulling any superuser is likely fine + super_user = app.config.get('SUPER_USERS', [None])[0] + log_action('service_key_approve', super_user, key_log_metadata) + except ServiceKeyDoesNotExist: + raise NotFound() + except ServiceKeyAlreadyApproved: + pass - return make_response('', 201) + return make_response('', 201) diff --git a/config_app/config_endpoints/api/superuser_models_pre_oci.py b/config_app/config_endpoints/api/superuser_models_pre_oci.py index 22cdbf821..85cb7dadc 100644 --- a/config_app/config_endpoints/api/superuser_models_pre_oci.py +++ b/config_app/config_endpoints/api/superuser_models_pre_oci.py @@ -3,45 +3,50 @@ from data import model from config_app.config_endpoints.api.superuser_models_interface import SuperuserDataInterface, User, ServiceKey, Approval def _create_user(user): - if user is None: - return None - return User(user.username, user.email, user.verified, user.enabled, user.robot) + if user is None: + return None + return User(user.username, user.email, user.verified, user.enabled, user.robot) def _create_key(key): - approval = None - if key.approval is not None: - approval = Approval(_create_user(key.approval.approver), key.approval.approval_type, key.approval.approved_date, - key.approval.notes) + approval = None + if key.approval is not None: + approval = Approval(_create_user(key.approval.approver), key.approval.approval_type, key.approval.approved_date, + key.approval.notes) - return ServiceKey(key.name, key.kid, key.service, key.jwk, key.metadata, key.created_date, key.expiration_date, - key.rotation_duration, approval) + return ServiceKey(key.name, key.kid, key.service, key.jwk, key.metadata, key.created_date, key.expiration_date, + key.rotation_duration, approval) class ServiceKeyDoesNotExist(Exception): - pass + pass class ServiceKeyAlreadyApproved(Exception): - pass + pass class PreOCIModel(SuperuserDataInterface): - """ - PreOCIModel implements the data model for the SuperUser using a database schema - before it was changed to support the OCI specification. - """ - def list_all_service_keys(self): - keys = model.service_keys.list_all_keys() - return [_create_key(key) for key in keys] + """ + PreOCIModel implements the data model for the SuperUser using a database schema + before it was changed to support the OCI specification. + """ + def list_all_service_keys(self): + keys = model.service_keys.list_all_keys() + return [_create_key(key) for key in keys] - def approve_service_key(self, kid, approval_type, notes=''): - try: - key = model.service_keys.approve_service_key(kid, approval_type, notes=notes) - return _create_key(key) - except model.ServiceKeyDoesNotExist: - raise ServiceKeyDoesNotExist - except model.ServiceKeyAlreadyApproved: - raise ServiceKeyAlreadyApproved + def approve_service_key(self, kid, approval_type, notes=''): + try: + key = model.service_keys.approve_service_key(kid, approval_type, notes=notes) + return _create_key(key) + except model.ServiceKeyDoesNotExist: + raise ServiceKeyDoesNotExist + except model.ServiceKeyAlreadyApproved: + raise ServiceKeyAlreadyApproved + + def generate_service_key(self, service, expiration_date, kid=None, name='', metadata=None, rotation_duration=None): + (private_key, key) = model.service_keys.generate_service_key(service, expiration_date, metadata=metadata, name=name) + + return private_key, key.kid pre_oci_model = PreOCIModel() diff --git a/config_app/js/components/markdown/markdown.module.ts b/config_app/js/components/markdown/markdown.module.ts index 18dfd0e75..a457ea0e0 100644 --- a/config_app/js/components/markdown/markdown.module.ts +++ b/config_app/js/components/markdown/markdown.module.ts @@ -27,6 +27,19 @@ export type MarkdownSymbol = 'heading1' export type BrowserPlatform = "firefox" | "chrome"; +/** + * Constant representing current browser platform. Used for determining available features. + * TODO Only rudimentary implementation, should prefer specific feature detection strategies instead. + */ +export const browserPlatform: BrowserPlatform = (() => { + if (navigator.userAgent.toLowerCase().indexOf('firefox') != -1) { + return 'firefox'; + } + else { + return 'chrome'; + } +})(); + /** * Dynamically fetch and register a new language with Highlight.js */ @@ -90,6 +103,7 @@ highlightedLanguages.forEach((langName) => addHighlightedLanguage(langName)); declarations: [], providers: [ {provide: 'markdownConverter', useValue: new Converter({extensions: [showdownHighlight]})}, + {provide: 'BrowserPlatform', useValue: browserPlatform}, ], }) export class MarkdownModule {