Port some suconfig/superuser endpoints, with data.model references
This commit is contained in:
		
							parent
							
								
									841053f878
								
							
						
					
					
						commit
						acf242f241
					
				
					 5 changed files with 379 additions and 13 deletions
				
			
		|  | @ -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) | ||||
|  |  | |||
|  | @ -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') | ||||
| 
 | ||||
| 
 | ||||
|  |  | |||
|  | @ -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/<service>') | ||||
| 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) | ||||
|  |  | |||
							
								
								
									
										39
									
								
								config_app/config_endpoints/api/suconfig_models_interface.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										39
									
								
								config_app/config_endpoints/api/suconfig_models_interface.py
									
										
									
									
									
										Normal file
									
								
							|  | @ -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. | ||||
|         """ | ||||
							
								
								
									
										35
									
								
								config_app/config_endpoints/api/suconfig_models_pre_oci.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								config_app/config_endpoints/api/suconfig_models_pre_oci.py
									
										
									
									
									
										Normal file
									
								
							|  | @ -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() | ||||
		Reference in a new issue