diff --git a/config_app/c_app.py b/config_app/c_app.py index 4fbf1d1a2..449563181 100644 --- a/config_app/c_app.py +++ b/config_app/c_app.py @@ -4,6 +4,9 @@ from flask import Flask from _init_config import CONF_DIR from config_app.config_util.config import get_config_provider + +from util.config.superusermanager import SuperUserManager + app = Flask(__name__) logger = logging.getLogger(__name__) @@ -29,3 +32,4 @@ else: # Load the override config via the provider. config_provider.update_app_config(app.config) +superusers = SuperUserManager(app) diff --git a/config_app/config_endpoints/api/__init__.py b/config_app/config_endpoints/api/__init__.py index b5df7b405..fab34ffdd 100644 --- a/config_app/config_endpoints/api/__init__.py +++ b/config_app/config_endpoints/api/__init__.py @@ -1,6 +1,6 @@ import logging -from flask import Blueprint +from flask import Blueprint, request from flask_restful import Resource, Api from flask_restful.utils.cors import crossdomain from email.utils import formatdate @@ -9,7 +9,7 @@ from functools import partial, wraps from jsonschema import validate, ValidationError from config_app.c_app import app -from config_app.config_endpoints.exception import InvalidResponse +from config_app.config_endpoints.exception import InvalidResponse, InvalidRequest logger = logging.getLogger(__name__) api_bp = Blueprint('api', __name__) @@ -128,6 +128,26 @@ def define_json_response(schema_name): return wrapper +def validate_json_request(schema_name, optional=False): + def wrapper(func): + @add_method_metadata('request_schema', schema_name) + @wraps(func) + def wrapped(self, *args, **kwargs): + schema = self.schemas[schema_name] + try: + json_data = request.get_json() + if json_data is None: + if not optional: + raise InvalidRequest('Missing JSON body') + else: + validate(json_data, schema) + return func(self, *args, **kwargs) + except ValidationError as ex: + raise InvalidRequest(ex.message) + return wrapped + return wrapper + + nickname = partial(add_method_metadata, 'nickname') diff --git a/config_app/config_endpoints/api/suconfig.py b/config_app/config_endpoints/api/suconfig.py index bc17ce3af..79e759ec2 100644 --- a/config_app/config_endpoints/api/suconfig.py +++ b/config_app/config_endpoints/api/suconfig.py @@ -1,11 +1,39 @@ import logging +import os +import subprocess +import signal -from config_app.config_endpoints.api import resource, ApiResource, verify_not_prod, nickname -from config_app.c_app import app, config_provider +from flask import abort, request + +from config_app.config_endpoints.api.suconfig_models_pre_oci import pre_oci_model as model +from config_app.config_endpoints.api import resource, ApiResource, verify_not_prod, nickname, validate_json_request +from config_app.c_app import app, config_provider, superusers, OVERRIDE_CONFIG_DIRECTORY + +from auth.auth_context import get_authenticated_user +from data.users import get_federated_service_name, get_users_handler +from data.database import configure +from data.runmigration import run_alembic_migration +from util.config.configutil import add_enterprise_config_defaults +from util.config.database import sync_database_with_config +# TODO(config) re-add this import when we get the app extracted from validators +# from util.config.validator import validate_service_for_config logger = logging.getLogger(__name__) +def database_is_valid(): + """ Returns whether the database, as configured, is valid. """ + if app.config['TESTING']: + return False + + return model.is_valid() + + +def database_has_users(): + """ Returns whether the database has any users defined. """ + return model.has_users() + + @resource('/v1/superuser/config') class SuperUserConfig(ApiResource): """ Resource for fetching and updating the current configuration, if any. """ @@ -43,6 +71,56 @@ class SuperUserConfig(ApiResource): 'config': config_object } + @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(): + if not config_provider.config_exists(): + 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 federated, link the superuser account to the + # the authentication system chosen. + service_name = get_federated_service_name(config_object['AUTHENTICATION_TYPE']) + if service_name is not None: + 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.has_federated_login(current_user.username, 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.attach_federated_login(current_user.username, 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/registrystatus') class SuperUserRegistryStatus(ApiResource): @@ -75,13 +153,203 @@ class SuperUserRegistryStatus(ApiResource): } # If the database isn't yet valid, then we need to set it up. - # if not database_is_valid(): - # return { - # 'status': 'setup-db' - # } - # - # return { - # 'status': 'create-superuser' if not database_has_users() else 'config' - # } + if not database_is_valid(): + return { + 'status': 'setup-db' + } - return {} + 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') +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) + + +# From: https://stackoverflow.com/a/44712205 +def get_process_id(name): + """Return process ids found by (partial) name or regex. + + >>> get_process_id('kthreadd') + [2] + >>> get_process_id('watchdog') + [10, 11, 16, 21, 26, 31, 36, 41, 46, 51, 56, 61] # ymmv + >>> get_process_id('non-existent process') + [] + """ + child = subprocess.Popen(['pgrep', name], stdout=subprocess.PIPE, shell=False) + response = child.communicate()[0] + return [int(pid) for pid in response.split()] + + +@resource('/v1/superuser/shutdown') +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(): + if app.config['TESTING'] or not database_has_users(): + # Note: We skip if debugging locally. + if app.config.get('DEBUGGING') == True: + return {} + + os.kill(get_process_id('my_init')[0], signal.SIGINT) + return {} + + abort(403) + + +@resource('/v1/superuser/config/createsuperuser') +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') + @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_uuid = model.create_superuser(username, password, email) + + # 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. + # TODO(config): do we need to register a list of the superusers? If so, we can take out the entire superuser in c_app + superusers.register_superuser(username) + + # Conduct login with that user. + # TODO(config): assuming we don't need to login the user + # common_login(superuser_uuid) + + return { + 'status': True + } + + abort(403) + + +@resource('/v1/superuser/config/validate/') +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(): + if not config_provider.config_exists(): + config = request.get_json()['config'] + return validate_service_for_config(service, config, request.get_json().get('password', '')) + + abort(403) diff --git a/config_app/config_endpoints/api/suconfig_models_interface.py b/config_app/config_endpoints/api/suconfig_models_interface.py new file mode 100644 index 000000000..4b99170c5 --- /dev/null +++ b/config_app/config_endpoints/api/suconfig_models_interface.py @@ -0,0 +1,39 @@ +from abc import ABCMeta, abstractmethod +from six import add_metaclass + + +@add_metaclass(ABCMeta) +class SuperuserConfigDataInterface(object): + """ + Interface that represents all data store interactions required by the superuser config API. + """ + + @abstractmethod + def is_valid(self): + """ + Returns true if the configured database is valid. + """ + + @abstractmethod + def has_users(self): + """ + Returns true if there are any users defined. + """ + + @abstractmethod + def create_superuser(self, username, password, email): + """ + Creates a new superuser with the given username, password and email. Returns the user's UUID. + """ + + @abstractmethod + def has_federated_login(self, username, service_name): + """ + Returns true if the matching user has a federated login under the matching service. + """ + + @abstractmethod + def attach_federated_login(self, username, service_name, federated_username): + """ + Attaches a federatated login to the matching user, under the given service. + """ diff --git a/config_app/config_endpoints/api/suconfig_models_pre_oci.py b/config_app/config_endpoints/api/suconfig_models_pre_oci.py new file mode 100644 index 000000000..df83b8e9f --- /dev/null +++ b/config_app/config_endpoints/api/suconfig_models_pre_oci.py @@ -0,0 +1,35 @@ +from data import model +from data.database import User +from config_app.config_endpoints.api.suconfig_models_interface import SuperuserConfigDataInterface + + +class PreOCIModel(SuperuserConfigDataInterface): + def is_valid(self): + try: + list(User.select().limit(1)) + return True + except: + return False + + def has_users(self): + return bool(list(User.select().limit(1))) + + def create_superuser(self, username, password, email): + return model.user.create_user(username, password, email, auto_verify=True).uuid + + def has_federated_login(self, username, service_name): + user = model.user.get_user(username) + if user is None: + return False + + return bool(model.user.lookup_federated_login(user, service_name)) + + def attach_federated_login(self, username, service_name, federated_username): + user = model.user.get_user(username) + if user is None: + return False + + model.user.attach_federated_login(user, service_name, federated_username) + + +pre_oci_model = PreOCIModel()