Add backend ability to generate sec keys in config app
This commit is contained in:
parent
0bc22d810a
commit
9faba7f5c3
3 changed files with 243 additions and 165 deletions
|
@ -2,6 +2,7 @@ import logging
|
||||||
import pathvalidate
|
import pathvalidate
|
||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
from flask import request, jsonify, make_response
|
from flask import request, jsonify, make_response
|
||||||
|
|
||||||
|
@ -22,173 +23,231 @@ logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@resource('/v1/superuser/customcerts/<certpath>')
|
@resource('/v1/superuser/customcerts/<certpath>')
|
||||||
class SuperUserCustomCertificate(ApiResource):
|
class SuperUserCustomCertificate(ApiResource):
|
||||||
""" Resource for managing a custom certificate. """
|
""" Resource for managing a custom certificate. """
|
||||||
|
|
||||||
@nickname('uploadCustomCertificate')
|
@nickname('uploadCustomCertificate')
|
||||||
def post(self, certpath):
|
def post(self, certpath):
|
||||||
uploaded_file = request.files['file']
|
uploaded_file = request.files['file']
|
||||||
if not uploaded_file:
|
if not uploaded_file:
|
||||||
raise InvalidRequest('Missing certificate file')
|
raise InvalidRequest('Missing certificate file')
|
||||||
|
|
||||||
# Save the certificate.
|
# Save the certificate.
|
||||||
certpath = pathvalidate.sanitize_filename(certpath)
|
certpath = pathvalidate.sanitize_filename(certpath)
|
||||||
if not certpath.endswith('.crt'):
|
if not certpath.endswith('.crt'):
|
||||||
raise InvalidRequest('Invalid certificate file: must have suffix `.crt`')
|
raise InvalidRequest('Invalid certificate file: must have suffix `.crt`')
|
||||||
|
|
||||||
logger.debug('Saving custom certificate %s', certpath)
|
logger.debug('Saving custom certificate %s', certpath)
|
||||||
cert_full_path = config_provider.get_volume_path(EXTRA_CA_DIRECTORY, certpath)
|
cert_full_path = config_provider.get_volume_path(EXTRA_CA_DIRECTORY, certpath)
|
||||||
config_provider.save_volume_file(cert_full_path, uploaded_file)
|
config_provider.save_volume_file(cert_full_path, uploaded_file)
|
||||||
logger.debug('Saved custom certificate %s', certpath)
|
logger.debug('Saved custom certificate %s', certpath)
|
||||||
|
|
||||||
# Validate the certificate.
|
# Validate the certificate.
|
||||||
try:
|
try:
|
||||||
logger.debug('Loading custom certificate %s', certpath)
|
logger.debug('Loading custom certificate %s', certpath)
|
||||||
with config_provider.get_volume_file(cert_full_path) as f:
|
with config_provider.get_volume_file(cert_full_path) as f:
|
||||||
load_certificate(f.read())
|
load_certificate(f.read())
|
||||||
except CertInvalidException:
|
except CertInvalidException:
|
||||||
logger.exception('Got certificate invalid error for cert %s', certpath)
|
logger.exception('Got certificate invalid error for cert %s', certpath)
|
||||||
return '', 204
|
return '', 204
|
||||||
except IOError:
|
except IOError:
|
||||||
logger.exception('Got IO error for cert %s', certpath)
|
logger.exception('Got IO error for cert %s', certpath)
|
||||||
return '', 204
|
return '', 204
|
||||||
|
|
||||||
# Call the update script with config dir location to install the certificate immediately.
|
# 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')],
|
if subprocess.call([os.path.join(INIT_SCRIPTS_LOCATION, 'certs_install.sh')],
|
||||||
env={ 'QUAYCONFIG': config_provider.get_config_dir_path() }) != 0:
|
env={ 'QUAYCONFIG': config_provider.get_config_dir_path() }) != 0:
|
||||||
raise Exception('Could not install certificates')
|
raise Exception('Could not install certificates')
|
||||||
|
|
||||||
return '', 204
|
return '', 204
|
||||||
|
|
||||||
@nickname('deleteCustomCertificate')
|
@nickname('deleteCustomCertificate')
|
||||||
def delete(self, certpath):
|
def delete(self, certpath):
|
||||||
cert_full_path = config_provider.get_volume_path(EXTRA_CA_DIRECTORY, certpath)
|
cert_full_path = config_provider.get_volume_path(EXTRA_CA_DIRECTORY, certpath)
|
||||||
config_provider.remove_volume_file(cert_full_path)
|
config_provider.remove_volume_file(cert_full_path)
|
||||||
return '', 204
|
return '', 204
|
||||||
|
|
||||||
|
|
||||||
@resource('/v1/superuser/customcerts')
|
@resource('/v1/superuser/customcerts')
|
||||||
class SuperUserCustomCertificates(ApiResource):
|
class SuperUserCustomCertificates(ApiResource):
|
||||||
""" Resource for managing custom certificates. """
|
""" Resource for managing custom certificates. """
|
||||||
|
|
||||||
@nickname('getCustomCertificates')
|
@nickname('getCustomCertificates')
|
||||||
def get(self):
|
def get(self):
|
||||||
has_extra_certs_path = config_provider.volume_file_exists(EXTRA_CA_DIRECTORY)
|
has_extra_certs_path = config_provider.volume_file_exists(EXTRA_CA_DIRECTORY)
|
||||||
extra_certs_found = config_provider.list_volume_directory(EXTRA_CA_DIRECTORY)
|
extra_certs_found = config_provider.list_volume_directory(EXTRA_CA_DIRECTORY)
|
||||||
if extra_certs_found is None:
|
if extra_certs_found is None:
|
||||||
return {
|
return {
|
||||||
'status': 'file' if has_extra_certs_path else 'none',
|
'status': 'file' if has_extra_certs_path else 'none',
|
||||||
}
|
}
|
||||||
|
|
||||||
cert_views = []
|
cert_views = []
|
||||||
for extra_cert_path in extra_certs_found:
|
for extra_cert_path in extra_certs_found:
|
||||||
try:
|
try:
|
||||||
cert_full_path = config_provider.get_volume_path(EXTRA_CA_DIRECTORY, extra_cert_path)
|
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:
|
with config_provider.get_volume_file(cert_full_path) as f:
|
||||||
certificate = load_certificate(f.read())
|
certificate = load_certificate(f.read())
|
||||||
cert_views.append({
|
cert_views.append({
|
||||||
'path': extra_cert_path,
|
'path': extra_cert_path,
|
||||||
'names': list(certificate.names),
|
'names': list(certificate.names),
|
||||||
'expired': certificate.expired,
|
'expired': certificate.expired,
|
||||||
})
|
})
|
||||||
except CertInvalidException as cie:
|
except CertInvalidException as cie:
|
||||||
cert_views.append({
|
cert_views.append({
|
||||||
'path': extra_cert_path,
|
'path': extra_cert_path,
|
||||||
'error': cie.message,
|
'error': cie.message,
|
||||||
})
|
})
|
||||||
except IOError as ioe:
|
except IOError as ioe:
|
||||||
cert_views.append({
|
cert_views.append({
|
||||||
'path': extra_cert_path,
|
'path': extra_cert_path,
|
||||||
'error': ioe.message,
|
'error': ioe.message,
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'status': 'directory',
|
'status': 'directory',
|
||||||
'certs': cert_views,
|
'certs': cert_views,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@resource('/v1/superuser/keys')
|
@resource('/v1/superuser/keys')
|
||||||
class SuperUserServiceKeyManagement(ApiResource):
|
class SuperUserServiceKeyManagement(ApiResource):
|
||||||
""" Resource for managing service keys."""
|
""" Resource for managing service keys."""
|
||||||
schemas = {
|
schemas = {
|
||||||
'CreateServiceKey': {
|
'CreateServiceKey': {
|
||||||
'id': 'CreateServiceKey',
|
'id': 'CreateServiceKey',
|
||||||
'type': 'object',
|
'type': 'object',
|
||||||
'description': 'Description of creation of a service key',
|
'description': 'Description of creation of a service key',
|
||||||
'required': ['service', 'expiration'],
|
'required': ['service', 'expiration'],
|
||||||
'properties': {
|
'properties': {
|
||||||
'service': {
|
'service': {
|
||||||
'type': 'string',
|
'type': 'string',
|
||||||
'description': 'The service authenticating with this key',
|
'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'}],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
'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')
|
log_action('service_key_create', None, key_log_metadata)
|
||||||
def get(self):
|
log_action('service_key_approve', None, key_log_metadata)
|
||||||
keys = pre_oci_model.list_all_service_keys()
|
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'keys': [key.to_dict() for key in keys],
|
'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/<kid>')
|
@resource('/v1/superuser/approvedkeys/<kid>')
|
||||||
class SuperUserServiceKeyApproval(ApiResource):
|
class SuperUserServiceKeyApproval(ApiResource):
|
||||||
""" Resource for approving service keys. """
|
""" Resource for approving service keys. """
|
||||||
|
|
||||||
schemas = {
|
schemas = {
|
||||||
'ApproveServiceKey': {
|
'ApproveServiceKey': {
|
||||||
'id': 'ApproveServiceKey',
|
'id': 'ApproveServiceKey',
|
||||||
'type': 'object',
|
'type': 'object',
|
||||||
'description': 'Information for approving service keys',
|
'description': 'Information for approving service keys',
|
||||||
'properties': {
|
'properties': {
|
||||||
'notes': {
|
'notes': {
|
||||||
'type': 'string',
|
'type': 'string',
|
||||||
'description': 'Optional approval notes',
|
'description': 'Optional approval notes',
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
@nickname('approveServiceKey')
|
@nickname('approveServiceKey')
|
||||||
@validate_json_request('ApproveServiceKey')
|
@validate_json_request('ApproveServiceKey')
|
||||||
def post(self, kid):
|
def post(self, kid):
|
||||||
notes = request.get_json().get('notes', '')
|
notes = request.get_json().get('notes', '')
|
||||||
try:
|
try:
|
||||||
key = pre_oci_model.approve_service_key(kid, ServiceKeyApprovalType.SUPERUSER, notes=notes)
|
key = pre_oci_model.approve_service_key(kid, ServiceKeyApprovalType.SUPERUSER, notes=notes)
|
||||||
|
|
||||||
# Log the approval of the service key.
|
# Log the approval of the service key.
|
||||||
key_log_metadata = {
|
key_log_metadata = {
|
||||||
'kid': kid,
|
'kid': kid,
|
||||||
'service': key.service,
|
'service': key.service,
|
||||||
'name': key.name,
|
'name': key.name,
|
||||||
'expiration_date': key.expiration_date,
|
'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,
|
# 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
|
# 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]
|
super_user = app.config.get('SUPER_USERS', [None])[0]
|
||||||
log_action('service_key_approve', super_user, key_log_metadata)
|
log_action('service_key_approve', super_user, key_log_metadata)
|
||||||
except ServiceKeyDoesNotExist:
|
except ServiceKeyDoesNotExist:
|
||||||
raise NotFound()
|
raise NotFound()
|
||||||
except ServiceKeyAlreadyApproved:
|
except ServiceKeyAlreadyApproved:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
return make_response('', 201)
|
return make_response('', 201)
|
||||||
|
|
|
@ -3,45 +3,50 @@ from data import model
|
||||||
from config_app.config_endpoints.api.superuser_models_interface import SuperuserDataInterface, User, ServiceKey, Approval
|
from config_app.config_endpoints.api.superuser_models_interface import SuperuserDataInterface, User, ServiceKey, Approval
|
||||||
|
|
||||||
def _create_user(user):
|
def _create_user(user):
|
||||||
if user is None:
|
if user is None:
|
||||||
return None
|
return None
|
||||||
return User(user.username, user.email, user.verified, user.enabled, user.robot)
|
return User(user.username, user.email, user.verified, user.enabled, user.robot)
|
||||||
|
|
||||||
|
|
||||||
def _create_key(key):
|
def _create_key(key):
|
||||||
approval = None
|
approval = None
|
||||||
if key.approval is not None:
|
if key.approval is not None:
|
||||||
approval = Approval(_create_user(key.approval.approver), key.approval.approval_type, key.approval.approved_date,
|
approval = Approval(_create_user(key.approval.approver), key.approval.approval_type, key.approval.approved_date,
|
||||||
key.approval.notes)
|
key.approval.notes)
|
||||||
|
|
||||||
return ServiceKey(key.name, key.kid, key.service, key.jwk, key.metadata, key.created_date, key.expiration_date,
|
return ServiceKey(key.name, key.kid, key.service, key.jwk, key.metadata, key.created_date, key.expiration_date,
|
||||||
key.rotation_duration, approval)
|
key.rotation_duration, approval)
|
||||||
|
|
||||||
class ServiceKeyDoesNotExist(Exception):
|
class ServiceKeyDoesNotExist(Exception):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class ServiceKeyAlreadyApproved(Exception):
|
class ServiceKeyAlreadyApproved(Exception):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class PreOCIModel(SuperuserDataInterface):
|
class PreOCIModel(SuperuserDataInterface):
|
||||||
"""
|
"""
|
||||||
PreOCIModel implements the data model for the SuperUser using a database schema
|
PreOCIModel implements the data model for the SuperUser using a database schema
|
||||||
before it was changed to support the OCI specification.
|
before it was changed to support the OCI specification.
|
||||||
"""
|
"""
|
||||||
def list_all_service_keys(self):
|
def list_all_service_keys(self):
|
||||||
keys = model.service_keys.list_all_keys()
|
keys = model.service_keys.list_all_keys()
|
||||||
return [_create_key(key) for key in keys]
|
return [_create_key(key) for key in keys]
|
||||||
|
|
||||||
def approve_service_key(self, kid, approval_type, notes=''):
|
def approve_service_key(self, kid, approval_type, notes=''):
|
||||||
try:
|
try:
|
||||||
key = model.service_keys.approve_service_key(kid, approval_type, notes=notes)
|
key = model.service_keys.approve_service_key(kid, approval_type, notes=notes)
|
||||||
return _create_key(key)
|
return _create_key(key)
|
||||||
except model.ServiceKeyDoesNotExist:
|
except model.ServiceKeyDoesNotExist:
|
||||||
raise ServiceKeyDoesNotExist
|
raise ServiceKeyDoesNotExist
|
||||||
except model.ServiceKeyAlreadyApproved:
|
except model.ServiceKeyAlreadyApproved:
|
||||||
raise 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()
|
pre_oci_model = PreOCIModel()
|
||||||
|
|
|
@ -27,6 +27,19 @@ export type MarkdownSymbol = 'heading1'
|
||||||
export type BrowserPlatform = "firefox"
|
export type BrowserPlatform = "firefox"
|
||||||
| "chrome";
|
| "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
|
* Dynamically fetch and register a new language with Highlight.js
|
||||||
*/
|
*/
|
||||||
|
@ -90,6 +103,7 @@ highlightedLanguages.forEach((langName) => addHighlightedLanguage(langName));
|
||||||
declarations: [],
|
declarations: [],
|
||||||
providers: [
|
providers: [
|
||||||
{provide: 'markdownConverter', useValue: new Converter({extensions: [<any>showdownHighlight]})},
|
{provide: 'markdownConverter', useValue: new Converter({extensions: [<any>showdownHighlight]})},
|
||||||
|
{provide: 'BrowserPlatform', useValue: browserPlatform},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class MarkdownModule {
|
export class MarkdownModule {
|
||||||
|
|
Reference in a new issue