""" Superuser Config API. """ import logging import os import signal from flask import abort from endpoints.api import (ApiResource, nickname, resource, internal_only, show_if, require_fresh_login, request, validate_json_request, verify_not_prod, InvalidRequest) from endpoints.common import common_login from app import app, config_provider, superusers, OVERRIDE_CONFIG_DIRECTORY from data import model from data.database import configure from auth.permissions import SuperUserPermission from auth.auth_context import get_authenticated_user from data.database import User from util.config.configutil import add_enterprise_config_defaults from util.config.database import sync_database_with_config from util.config.validator import validate_service_for_config, CONFIG_FILENAMES from util.config.provider.license import decode_license, LicenseError from data.runmigration import run_alembic_migration from data.users import get_federated_service_name, get_users_handler import features logger = logging.getLogger(__name__) def database_is_valid(): """ Returns whether the database, as configured, is valid. """ if app.config['TESTING']: return False try: list(User.select().limit(1)) return True except: return False def database_has_users(): """ Returns whether the database has any users defined. """ return bool(list(User.select().limit(1))) @resource('/v1/superuser/registrystatus') @internal_only @show_if(features.SUPER_USERS) class SuperUserRegistryStatus(ApiResource): """ Resource for determining the status of the registry, such as if config exists, if a database is configured, and if it has any defined users. """ @nickname('scRegistryStatus') @verify_not_prod def get(self): """ Returns the status of the registry. """ # If there is no conf/stack volume, then report that status. if not config_provider.volume_exists(): return { 'status': 'missing-config-dir' } # If there is no license file, we need to ask the user to upload it. if not config_provider.has_license_file(): return { 'status': 'upload-license' } # If there is no config file, we need to setup the database. if not config_provider.config_exists(): return { 'status': 'config-db' } # If the database isn't yet valid, then we need to set it up. if not database_is_valid(): return { 'status': 'setup-db' } # If we have SETUP_COMPLETE, then we're ready to go! if app.config.get('SETUP_COMPLETE', False): return { 'requires_restart': config_provider.requires_restart(app.config), 'status': 'ready' } return { 'status': 'create-superuser' if not database_has_users() else 'config' } class _AlembicLogHandler(logging.Handler): def __init__(self): super(_AlembicLogHandler, self).__init__() self.records = [] def emit(self, record): self.records.append({ 'level': record.levelname, 'message': record.getMessage() }) @resource('/v1/superuser/setupdb') @internal_only @show_if(features.SUPER_USERS) class SuperUserSetupDatabase(ApiResource): """ Resource for invoking alembic to setup the database. """ @verify_not_prod @nickname('scSetupDatabase') def get(self): """ Invokes the alembic upgrade process. """ # Note: This method is called after the database configured is saved, but before the # database has any tables. Therefore, we only allow it to be run in that unique case. if config_provider.config_exists() and not database_is_valid(): # Note: We need to reconfigure the database here as the config has changed. combined = dict(**app.config) combined.update(config_provider.get_config()) configure(combined) app.config['DB_URI'] = combined['DB_URI'] log_handler = _AlembicLogHandler() try: run_alembic_migration(log_handler) except Exception as ex: return { 'error': str(ex) } return { 'logs': log_handler.records } abort(403) @resource('/v1/superuser/shutdown') @internal_only @show_if(features.SUPER_USERS) class SuperUserShutdown(ApiResource): """ Resource for sending a shutdown signal to the container. """ @verify_not_prod @nickname('scShutdownContainer') def post(self): """ Sends a signal to the phusion init system to shut down the container. """ # Note: This method is called to set the database configuration before super users exists, # so we also allow it to be called if there is no valid registry configuration setup. if app.config['TESTING'] or not database_has_users() or SuperUserPermission().can(): # Note: We skip if debugging locally. if app.config.get('DEBUGGING') == True: return {} os.kill(1, signal.SIGINT) return {} abort(403) @resource('/v1/superuser/config') @internal_only @show_if(features.SUPER_USERS) class SuperUserConfig(ApiResource): """ Resource for fetching and updating the current configuration, if any. """ schemas = { 'UpdateConfig': { 'type': 'object', 'description': 'Updates the YAML config file', 'required': [ 'config', 'hostname' ], 'properties': { 'config': { 'type': 'object' }, 'hostname': { 'type': 'string' }, 'password': { 'type': 'string' }, }, }, } @require_fresh_login @verify_not_prod @nickname('scGetConfig') def get(self): """ Returns the currently defined configuration, if any. """ if SuperUserPermission().can(): config_object = config_provider.get_config() return { 'config': config_object } abort(403) @nickname('scUpdateConfig') @verify_not_prod @validate_json_request('UpdateConfig') def put(self): """ Updates the config override file. """ # Note: This method is called to set the database configuration before super users exists, # so we also allow it to be called if there is no valid registry configuration setup. if not config_provider.config_exists() or SuperUserPermission().can(): config_object = request.get_json()['config'] hostname = request.get_json()['hostname'] # Add any enterprise defaults missing from the config. add_enterprise_config_defaults(config_object, app.config['SECRET_KEY'], hostname) # Write the configuration changes to the config override file. config_provider.save_config(config_object) # If the authentication system is not the database, link the superuser account to the # the authentication system chosen. if config_object.get('AUTHENTICATION_TYPE', 'Database') != 'Database': current_user = get_authenticated_user() if current_user is None: abort(401) service_name = get_federated_service_name(config_object['AUTHENTICATION_TYPE']) if not model.user.lookup_federated_login(current_user, service_name): # Verify the user's credentials and retrieve the user's external username+email. handler = get_users_handler(config_object, config_provider, OVERRIDE_CONFIG_DIRECTORY) (result, err_msg) = handler.verify_credentials(current_user.username, request.get_json().get('password', '')) if not result: logger.error('Could not save configuration due to external auth failure: %s', err_msg) abort(400) # Link the existing user to the external user. model.user.attach_federated_login(current_user, service_name, result.username) # Ensure database is up-to-date with config sync_database_with_config(config_object) return { 'exists': True, 'config': config_object } abort(403) @resource('/v1/superuser/config/license') @internal_only @show_if(features.SUPER_USERS) class SuperUserSetAndValidateLicense(ApiResource): """ Resource for setting and validating a license. """ schemas = { 'ValidateLicense': { 'type': 'object', 'description': 'Validates and sets a license', 'required': [ 'license', ], 'properties': { 'license': { 'type': 'string' }, }, }, } @nickname('suSetAndValidateLicense') @verify_not_prod @validate_json_request('ValidateLicense') def post(self): """ Validates the given license contents and then saves it to the config volume. """ if config_provider.has_license_file(): abort(403) license_contents = request.get_json()['license'] try: decoded_license = decode_license(license_contents) except LicenseError as le: raise InvalidRequest(le.message) if decoded_license.is_expired: raise InvalidRequest('License has expired') config_provider.save_license(license_contents) return { 'decoded': decoded_license.subscription, 'success': True } @resource('/v1/superuser/config/file/') @internal_only @show_if(features.SUPER_USERS) class SuperUserConfigFile(ApiResource): """ Resource for fetching the status of config files and overriding them. """ @nickname('scConfigFileExists') @verify_not_prod def get(self, filename): """ Returns whether the configuration file with the given name exists. """ if not filename in CONFIG_FILENAMES: abort(404) if SuperUserPermission().can(): return { 'exists': config_provider.volume_file_exists(filename) } abort(403) @nickname('scUpdateConfigFile') @verify_not_prod def post(self, filename): """ Updates the configuration file with the given name. """ if not filename in CONFIG_FILENAMES: abort(404) # Note: This method can be called before the configuration exists # to upload the database SSL cert. if not config_provider.config_exists() or SuperUserPermission().can(): uploaded_file = request.files['file'] if not uploaded_file: abort(400) config_provider.save_volume_file(filename, uploaded_file) return { 'status': True } abort(403) @resource('/v1/superuser/config/createsuperuser') @internal_only @show_if(features.SUPER_USERS) class SuperUserCreateInitialSuperUser(ApiResource): """ Resource for creating the initial super user. """ schemas = { 'CreateSuperUser': { 'type': 'object', 'description': 'Information for creating the initial super user', 'required': [ 'username', 'password', 'email' ], 'properties': { 'username': { 'type': 'string', 'description': 'The username for the superuser' }, 'password': { 'type': 'string', 'description': 'The password for the superuser' }, 'email': { 'type': 'string', 'description': 'The e-mail address for the superuser' }, }, }, } @nickname('scCreateInitialSuperuser') @verify_not_prod @validate_json_request('CreateSuperUser') def post(self): """ Creates the initial super user, updates the underlying configuration and sets the current session to have that super user. """ # Special security check: This method is only accessible when: # - There is a valid config YAML file. # - There are currently no users in the database (clean install) # # We do this special security check because at the point this method is called, the database # is clean but does not (yet) have any super users for our permissions code to check against. if config_provider.config_exists() and not database_has_users(): data = request.get_json() username = data['username'] password = data['password'] email = data['email'] # Create the user in the database. superuser = model.user.create_user(username, password, email, auto_verify=True) # Add the user to the config. config_object = config_provider.get_config() config_object['SUPER_USERS'] = [username] config_provider.save_config(config_object) # Update the in-memory config for the new superuser. superusers.register_superuser(username) # Conduct login with that user. common_login(superuser) return { 'status': True } abort(403) @resource('/v1/superuser/config/validate/') @internal_only @show_if(features.SUPER_USERS) class SuperUserConfigValidate(ApiResource): """ Resource for validating a block of configuration against an external service. """ schemas = { 'ValidateConfig': { 'type': 'object', 'description': 'Validates configuration', 'required': [ 'config' ], 'properties': { 'config': { 'type': 'object' }, 'password': { 'type': 'string', 'description': 'The users password, used for auth validation' } }, }, } @nickname('scValidateConfig') @verify_not_prod @validate_json_request('ValidateConfig') def post(self, service): """ Validates the given config for the given service. """ # Note: This method is called to validate the database configuration before super users exists, # so we also allow it to be called if there is no valid registry configuration setup. Note that # this is also safe since this method does not access any information not given in the request. if not config_provider.config_exists() or SuperUserPermission().can(): config = request.get_json()['config'] return validate_service_for_config(service, config, request.get_json().get('password', '')) abort(403)