""" Superuser API. """ import logging import os import string import socket from datetime import datetime, timedelta from random import SystemRandom from flask import request, make_response, jsonify import features from app import app, avatar, superusers, authentication, config_provider from auth import scopes from auth.auth_context import get_authenticated_user from auth.permissions import SuperUserPermission from data.database import ServiceKeyApprovalType from endpoints.api import (ApiResource, nickname, resource, validate_json_request, internal_only, require_scope, show_if, parse_args, query_param, require_fresh_login, path_param, verify_not_prod, page_support, log_action, format_date, truthy_bool, InvalidRequest, NotFound, Unauthorized, InvalidResponse) from endpoints.api.build import get_logs_or_log_url from endpoints.api.superuser_models_pre_oci import (pre_oci_model, ServiceKeyDoesNotExist, ServiceKeyAlreadyApproved, InvalidRepositoryBuildException) from endpoints.api.logs_models_pre_oci import pre_oci_model as log_model from util.useremails import send_confirmation_email, send_recovery_email from _init import ROOT_DIR logger = logging.getLogger(__name__) def _validate_logs_arguments(start_time, end_time): if start_time: try: start_time = datetime.strptime(start_time + ' UTC', '%m/%d/%Y %Z') except ValueError: start_time = None if not start_time: start_time = datetime.today() - timedelta(7) # One week if end_time: try: end_time = datetime.strptime(end_time + ' UTC', '%m/%d/%Y %Z') end_time = end_time + timedelta(days=1) except ValueError: end_time = None if not end_time: end_time = datetime.today() return start_time, end_time def get_immediate_subdirectories(directory): return [name for name in os.listdir(directory) if os.path.isdir(os.path.join(directory, name))] def get_services(): services = set(get_immediate_subdirectories(app.config['SYSTEM_SERVICES_PATH'])) services = services - set(app.config['SYSTEM_SERVICE_BLACKLIST']) return services @resource('/v1/superuser/systemlogs/') @internal_only @show_if(features.SUPER_USERS) class SuperUserGetLogsForService(ApiResource): """ Resource for fetching the kinds of system logs in the system. """ @require_fresh_login @verify_not_prod @nickname('getSystemLogs') @require_scope(scopes.SUPERUSER) def get(self, service): """ Returns the logs for the specific service. """ if SuperUserPermission().can(): if service not in get_services(): raise NotFound() logs = [] try: with open(app.config['SYSTEM_LOGS_FILE'], 'r') as f: logs = [line for line in f if line.find(service + '[') >= 0] except Exception: logger.exception('Cannot read logs') raise InvalidRequest('Cannot read logs') return { 'instance': socket.gethostname(), 'logs': '\n'.join(logs) } raise Unauthorized() @resource('/v1/superuser/systemlogs/') @internal_only @show_if(features.SUPER_USERS) class SuperUserSystemLogServices(ApiResource): """ Resource for fetching the kinds of system logs in the system. """ @require_fresh_login @verify_not_prod @nickname('listSystemLogServices') @require_scope(scopes.SUPERUSER) def get(self): """ List the system logs for the current system. """ if SuperUserPermission().can(): return { 'instance': socket.gethostname(), 'services': list(get_services()) } raise Unauthorized() @resource('/v1/superuser/aggregatelogs') @internal_only class SuperUserAggregateLogs(ApiResource): """ Resource for fetching aggregated logs for the current user. """ @require_fresh_login @verify_not_prod @nickname('listAllAggregateLogs') @parse_args() @query_param('starttime', 'Earliest time from which to get logs. (%m/%d/%Y %Z)', type=str) @query_param('endtime', 'Latest time to which to get logs. (%m/%d/%Y %Z)', type=str) def get(self, parsed_args): """ Returns the aggregated logs for the current system. """ if SuperUserPermission().can(): (start_time, end_time) = _validate_logs_arguments(parsed_args['starttime'], parsed_args['endtime']) # TODO(LogMigrate): Change to a unified log lookup util lib once we're back on LogEntry only. aggregated_logs = log_model.get_aggregated_logs(start_time, end_time) kinds = log_model.get_log_entry_kinds() return { 'aggregated': [log.to_dict(kinds, start_time) for log in aggregated_logs] } raise Unauthorized() LOGS_PER_PAGE = 20 @resource('/v1/superuser/logs') @internal_only @show_if(features.SUPER_USERS) class SuperUserLogs(ApiResource): """ Resource for fetching all logs in the system. """ @require_fresh_login @verify_not_prod @nickname('listAllLogs') @parse_args() @query_param('starttime', 'Earliest time from which to get logs (%m/%d/%Y %Z)', type=str) @query_param('endtime', 'Latest time to which to get logs (%m/%d/%Y %Z)', type=str) @query_param('page', 'The page number for the logs', type=int, default=1) @page_support() @require_scope(scopes.SUPERUSER) def get(self, parsed_args, page_token): """ List the usage logs for the current system. """ if SuperUserPermission().can(): start_time = parsed_args['starttime'] end_time = parsed_args['endtime'] (start_time, end_time) = _validate_logs_arguments(start_time, end_time) # TODO(LogMigrate): Change to a unified log lookup util lib once we're back on LogEntry only. log_page = log_model.get_logs_query(start_time, end_time, page_token=page_token) kinds = log_model.get_log_entry_kinds() return { 'start_time': format_date(start_time), 'end_time': format_date(end_time), 'logs': [log.to_dict(kinds, include_namespace=True) for log in log_page.logs], }, log_page.next_page_token raise Unauthorized() def org_view(org): return { 'name': org.username, 'email': org.email, 'avatar': avatar.get_data_for_org(org), } def user_view(user, password=None): user_data = { 'kind': 'user', 'name': user.username, 'username': user.username, 'email': user.email, 'verified': user.verified, 'avatar': avatar.get_data_for_user(user), 'super_user': superusers.is_superuser(user.username), 'enabled': user.enabled, } if password is not None: user_data['encrypted_password'] = authentication.encrypt_user_password(password) return user_data @resource('/v1/superuser/changelog/') @internal_only @show_if(features.SUPER_USERS) class ChangeLog(ApiResource): """ Resource for returning the change log for enterprise customers. """ @require_fresh_login @verify_not_prod @nickname('getChangeLog') @require_scope(scopes.SUPERUSER) def get(self): """ Returns the change log for this installation. """ if SuperUserPermission().can(): with open(os.path.join(ROOT_DIR, 'CHANGELOG.md'), 'r') as f: return { 'log': f.read() } raise Unauthorized() @resource('/v1/superuser/organizations/') @internal_only @show_if(features.SUPER_USERS) class SuperUserOrganizationList(ApiResource): """ Resource for listing organizations in the system. """ @require_fresh_login @verify_not_prod @nickname('listAllOrganizations') @require_scope(scopes.SUPERUSER) def get(self): """ Returns a list of all organizations in the system. """ if SuperUserPermission().can(): return { 'organizations': [org.to_dict() for org in pre_oci_model.get_organizations()] } raise Unauthorized() @resource('/v1/superuser/users/') @show_if(features.SUPER_USERS) class SuperUserList(ApiResource): """ Resource for listing users in the system. """ schemas = { 'CreateInstallUser': { 'id': 'CreateInstallUser', 'description': 'Data for creating a user', 'required': ['username'], 'properties': { 'username': { 'type': 'string', 'description': 'The username of the user being created' }, 'email': { 'type': 'string', 'description': 'The email address of the user being created' } } } } @require_fresh_login @verify_not_prod @nickname('listAllUsers') @parse_args() @query_param('disabled', 'If false, only enabled users will be returned.', type=truthy_bool, default=True) @require_scope(scopes.SUPERUSER) def get(self, parsed_args): """ Returns a list of all users in the system. """ if SuperUserPermission().can(): users = pre_oci_model.get_active_users(disabled=parsed_args['disabled']) return { 'users': [user.to_dict() for user in users] } raise Unauthorized() @require_fresh_login @verify_not_prod @nickname('createInstallUser') @validate_json_request('CreateInstallUser') @require_scope(scopes.SUPERUSER) def post(self): """ Creates a new user. """ # Ensure that we are using database auth. if app.config['AUTHENTICATION_TYPE'] != 'Database': raise InvalidRequest('Cannot create a user in a non-database auth system') user_information = request.get_json() if SuperUserPermission().can(): # Generate a temporary password for the user. random = SystemRandom() password = ''.join([random.choice(string.ascii_uppercase + string.digits) for _ in range(32)]) # Create the user. username = user_information['username'] email = user_information.get('email') install_user, confirmation_code = pre_oci_model.create_install_user(username, password, email) if features.MAILING: send_confirmation_email(install_user.username, install_user.email, confirmation_code) return { 'username': username, 'email': email, 'password': password, 'encrypted_password': authentication.encrypt_user_password(password), } raise Unauthorized() @resource('/v1/superusers/users//sendrecovery') @internal_only @show_if(features.SUPER_USERS) @show_if(features.MAILING) class SuperUserSendRecoveryEmail(ApiResource): """ Resource for sending a recovery user on behalf of a user. """ @require_fresh_login @verify_not_prod @nickname('sendInstallUserRecoveryEmail') @require_scope(scopes.SUPERUSER) def post(self, username): # Ensure that we are using database auth. if app.config['AUTHENTICATION_TYPE'] != 'Database': raise InvalidRequest('Cannot send a recovery e-mail for non-database auth') if SuperUserPermission().can(): user = pre_oci_model.get_nonrobot_user(username) if user is None: raise NotFound() if superusers.is_superuser(username): raise InvalidRequest('Cannot send a recovery email for a superuser') code = pre_oci_model.create_reset_password_email_code(user.email) send_recovery_email(user.email, code) return { 'email': user.email } raise Unauthorized() @resource('/v1/superuser/users/') @path_param('username', 'The username of the user being managed') @internal_only @show_if(features.SUPER_USERS) class SuperUserManagement(ApiResource): """ Resource for managing users in the system. """ schemas = { 'UpdateUser': { 'id': 'UpdateUser', 'type': 'object', 'description': 'Description of updates for a user', 'properties': { 'password': { 'type': 'string', 'description': 'The new password for the user', }, 'email': { 'type': 'string', 'description': 'The new e-mail address for the user', }, 'enabled': { 'type': 'boolean', 'description': 'Whether the user is enabled' } }, }, } @require_fresh_login @verify_not_prod @nickname('getInstallUser') @require_scope(scopes.SUPERUSER) def get(self, username): """ Returns information about the specified user. """ if SuperUserPermission().can(): user = pre_oci_model.get_nonrobot_user(username) if user is None: raise NotFound() return user.to_dict() raise Unauthorized() @require_fresh_login @verify_not_prod @nickname('deleteInstallUser') @require_scope(scopes.SUPERUSER) def delete(self, username): """ Deletes the specified user. """ if SuperUserPermission().can(): user = pre_oci_model.get_nonrobot_user(username) if user is None: raise NotFound() if superusers.is_superuser(username): raise InvalidRequest('Cannot delete a superuser') pre_oci_model.mark_user_for_deletion(username) return '', 204 raise Unauthorized() @require_fresh_login @verify_not_prod @nickname('changeInstallUser') @validate_json_request('UpdateUser') @require_scope(scopes.SUPERUSER) def put(self, username): """ Updates information about the specified user. """ if SuperUserPermission().can(): user = pre_oci_model.get_nonrobot_user(username) if user is None: raise NotFound() if superusers.is_superuser(username): raise InvalidRequest('Cannot update a superuser') user_data = request.get_json() if 'password' in user_data: # Ensure that we are using database auth. if app.config['AUTHENTICATION_TYPE'] != 'Database': raise InvalidRequest('Cannot change password in non-database auth') pre_oci_model.change_password(username, user_data['password']) if 'email' in user_data: # Ensure that we are using database auth. if app.config['AUTHENTICATION_TYPE'] != 'Database': raise InvalidRequest('Cannot change e-mail in non-database auth') pre_oci_model.update_email(username, user_data['email'], auto_verify=True) if 'enabled' in user_data: # Disable/enable the user. pre_oci_model.update_enabled(username, bool(user_data['enabled'])) if 'superuser' in user_data: config_object = config_provider.get_config() superusers_set = set(config_object['SUPER_USERS']) if user_data['superuser']: superusers_set.add(username) elif username in superusers_set: superusers_set.remove(username) config_object['SUPER_USERS'] = list(superusers_set) config_provider.save_config(config_object) return_value = user.to_dict() if user_data.get('password') is not None: password = user_data.get('password') return_value['encrypted_password'] = authentication.encrypt_user_password(password) return return_value raise Unauthorized() @resource('/v1/superuser/takeownership/') @path_param('namespace', 'The namespace of the user or organization being managed') @internal_only @show_if(features.SUPER_USERS) class SuperUserTakeOwnership(ApiResource): """ Resource for a superuser to take ownership of a namespace. """ @require_fresh_login @verify_not_prod @nickname('takeOwnership') @require_scope(scopes.SUPERUSER) def post(self, namespace): """ Takes ownership of the specified organization or user. """ if SuperUserPermission().can(): # Disallow for superusers. if superusers.is_superuser(namespace): raise InvalidRequest('Cannot take ownership of a superuser') authed_user = get_authenticated_user() entity_id, was_user = pre_oci_model.take_ownership(namespace, authed_user) if entity_id is None: raise NotFound() # Log the change. log_metadata = { 'entity_id': entity_id, 'namespace': namespace, 'was_user': was_user, 'superuser': authed_user.username, } log_action('take_ownership', authed_user.username, log_metadata) return jsonify({ 'namespace': namespace }) raise Unauthorized() @resource('/v1/superuser/organizations/') @path_param('name', 'The name of the organizaton being managed') @show_if(features.SUPER_USERS) class SuperUserOrganizationManagement(ApiResource): """ Resource for managing organizations in the system. """ schemas = { 'UpdateOrg': { 'id': 'UpdateOrg', 'type': 'object', 'description': 'Description of updates for an organization', 'properties': { 'name': { 'type': 'string', 'description': 'The new name for the organization', } }, }, } @require_fresh_login @verify_not_prod @nickname('deleteOrganization') @require_scope(scopes.SUPERUSER) def delete(self, name): """ Deletes the specified organization. """ if SuperUserPermission().can(): pre_oci_model.mark_organization_for_deletion(name) return '', 204 raise Unauthorized() @require_fresh_login @verify_not_prod @nickname('changeOrganization') @validate_json_request('UpdateOrg') @require_scope(scopes.SUPERUSER) def put(self, name): """ Updates information about the specified user. """ if SuperUserPermission().can(): org_data = request.get_json() old_name = org_data['name'] if 'name' in org_data else None org = pre_oci_model.change_organization_name(name, old_name) return org.to_dict() raise Unauthorized() def key_view(key): return { 'name': key.name, 'kid': key.kid, 'service': key.service, 'jwk': key.jwk, 'metadata': key.metadata, 'created_date': key.created_date, 'expiration_date': key.expiration_date, 'rotation_duration': key.rotation_duration, 'approval': approval_view(key.approval) if key.approval is not None else None, } def approval_view(approval): return { 'approver': user_view(approval.approver) if approval.approver else None, 'approval_type': approval.approval_type, 'approved_date': approval.approved_date, 'notes': approval.notes, } @resource('/v1/superuser/keys') @show_if(features.SUPER_USERS) 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'}], }, }, }, } @verify_not_prod @nickname('listServiceKeys') @require_scope(scopes.SUPERUSER) def get(self): if SuperUserPermission().can(): keys = pre_oci_model.list_all_service_keys() return jsonify({ 'keys': [key.to_dict() for key in keys], }) raise Unauthorized() @require_fresh_login @verify_not_prod @nickname('createServiceKey') @require_scope(scopes.SUPERUSER) @validate_json_request('CreateServiceKey') def post(self): if SuperUserPermission().can(): 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. user = get_authenticated_user() metadata = body.get('metadata', {}) metadata.update({ 'created_by': 'Quay Superuser Panel', 'creator': user.username, '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, user, 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, } log_action('service_key_create', None, key_log_metadata) log_action('service_key_approve', None, key_log_metadata) 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'), }) raise Unauthorized() @resource('/v1/superuser/keys/') @path_param('kid', 'The unique identifier for a service key') @show_if(features.SUPER_USERS) class SuperUserServiceKey(ApiResource): """ Resource for managing service keys. """ schemas = { 'PutServiceKey': { 'id': 'PutServiceKey', 'type': 'object', 'description': 'Description of updates for a service key', 'properties': { 'name': { 'type': 'string', 'description': 'The friendly name of a service key', }, 'metadata': { 'type': 'object', 'description': 'The key/value pairs of this key\'s metadata', }, 'expiration': { 'description': 'The expiration date as a unix timestamp', 'anyOf': [{'type': 'number'}, {'type': 'null'}], }, }, }, } @verify_not_prod @nickname('getServiceKey') @require_scope(scopes.SUPERUSER) def get(self, kid): if SuperUserPermission().can(): try: key = pre_oci_model.get_service_key(kid, approved_only=False, alive_only=False) return jsonify(key.to_dict()) except ServiceKeyDoesNotExist: raise NotFound() raise Unauthorized() @require_fresh_login @verify_not_prod @nickname('updateServiceKey') @require_scope(scopes.SUPERUSER) @validate_json_request('PutServiceKey') def put(self, kid): if SuperUserPermission().can(): body = request.get_json() try: key = pre_oci_model.get_service_key(kid, approved_only=False, alive_only=False) except ServiceKeyDoesNotExist: raise NotFound() key_log_metadata = { 'kid': key.kid, 'service': key.service, 'name': body.get('name', key.name), 'expiration_date': key.expiration_date, } if 'expiration' in body: expiration_date = body['expiration'] if expiration_date is not None and expiration_date != '': 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('Cannot have an expiration date in the past') key_log_metadata.update({ 'old_expiration_date': key.expiration_date, 'expiration_date': expiration_date, }) log_action('service_key_extend', None, key_log_metadata) pre_oci_model.set_key_expiration(kid, expiration_date) if 'name' in body or 'metadata' in body: pre_oci_model.update_service_key(kid, body.get('name'), body.get('metadata')) log_action('service_key_modify', None, key_log_metadata) updated_key = pre_oci_model.get_service_key(kid, approved_only=False, alive_only=False) return jsonify(updated_key.to_dict()) raise Unauthorized() @require_fresh_login @verify_not_prod @nickname('deleteServiceKey') @require_scope(scopes.SUPERUSER) def delete(self, kid): if SuperUserPermission().can(): try: key = pre_oci_model.delete_service_key(kid) except ServiceKeyDoesNotExist: raise NotFound() key_log_metadata = { 'kid': kid, 'service': key.service, 'name': key.name, 'created_date': key.created_date, 'expiration_date': key.expiration_date, } log_action('service_key_delete', None, key_log_metadata) return make_response('', 204) raise Unauthorized() @resource('/v1/superuser/approvedkeys/') @path_param('kid', 'The unique identifier for a service key') @show_if(features.SUPER_USERS) class SuperUserServiceKeyApproval(ApiResource): """ 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', }, }, }, } @require_fresh_login @verify_not_prod @nickname('approveServiceKey') @require_scope(scopes.SUPERUSER) @validate_json_request('ApproveServiceKey') def post(self, kid): if SuperUserPermission().can(): notes = request.get_json().get('notes', '') approver = get_authenticated_user() try: key = pre_oci_model.approve_service_key(kid, approver, 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_action('service_key_approve', None, key_log_metadata) except ServiceKeyDoesNotExist: raise NotFound() except ServiceKeyAlreadyApproved: pass return make_response('', 201) raise Unauthorized() @resource('/v1/superuser//logs') @path_param('build_uuid', 'The UUID of the build') @show_if(features.SUPER_USERS) class SuperUserRepositoryBuildLogs(ApiResource): """ Resource for loading repository build logs for the superuser. """ @require_fresh_login @verify_not_prod @nickname('getRepoBuildLogsSuperUser') @require_scope(scopes.SUPERUSER) def get(self, build_uuid): """ Return the build logs for the build specified by the build uuid. """ if SuperUserPermission().can(): try: repo_build = pre_oci_model.get_repository_build(build_uuid) return get_logs_or_log_url(repo_build) except InvalidRepositoryBuildException as e: raise InvalidResponse(e.message) raise Unauthorized() @resource('/v1/superuser//status') @path_param('repository', 'The full path of the repository. e.g. namespace/name') @path_param('build_uuid', 'The UUID of the build') @show_if(features.SUPER_USERS) class SuperUserRepositoryBuildStatus(ApiResource): """ Resource for dealing with repository build status. """ @require_fresh_login @verify_not_prod @nickname('getRepoBuildStatusSuperUser') @require_scope(scopes.SUPERUSER) def get(self, build_uuid): """ Return the status for the builds specified by the build uuids. """ if SuperUserPermission().can(): try: build = pre_oci_model.get_repository_build(build_uuid) except InvalidRepositoryBuildException as e: raise InvalidResponse(e.message) return build.to_dict() raise Unauthorized() @resource('/v1/superuser//build') @path_param('repository', 'The full path of the repository. e.g. namespace/name') @path_param('build_uuid', 'The UUID of the build') @show_if(features.SUPER_USERS) class SuperUserRepositoryBuildResource(ApiResource): """ Resource for dealing with repository builds as a super user. """ @require_fresh_login @verify_not_prod @nickname('getRepoBuildSuperUser') @require_scope(scopes.SUPERUSER) def get(self, build_uuid): """ Returns information about a build. """ if SuperUserPermission().can(): try: build = pre_oci_model.get_repository_build(build_uuid) except InvalidRepositoryBuildException: raise NotFound() return build.to_dict() raise Unauthorized()