import json import logging import urlparse from functools import wraps from flask import request, make_response, jsonify, session from app import userevents, metric_queue from auth.auth_context import get_authenticated_context, get_authenticated_user from auth.credentials import validate_credentials, CredentialKind from auth.decorators import process_auth from auth.permissions import ( ModifyRepositoryPermission, UserAdminPermission, ReadRepositoryPermission, CreateRepositoryPermission, repository_read_grant, repository_write_grant) from auth.signedgrant import generate_signed_token from endpoints.decorators import anon_protect, anon_allowed, parse_repository_name from endpoints.v1 import v1_bp from endpoints.v1.models_pre_oci import pre_oci_model as model from notifications import spawn_notification from util.audit import track_and_log from util.http import abort from util.names import REPOSITORY_NAME_REGEX logger = logging.getLogger(__name__) class GrantType(object): READ_REPOSITORY = 'read' WRITE_REPOSITORY = 'write' def ensure_namespace_enabled(f): @wraps(f) def wrapper(namespace_name, repo_name, *args, **kwargs): if not model.is_namespace_enabled(namespace_name): abort(400, message='Namespace is disabled. Please contact your system administrator.') return f(namespace_name, repo_name, *args, **kwargs) return wrapper def generate_headers(scope=GrantType.READ_REPOSITORY, add_grant_for_status=None): def decorator_method(f): @wraps(f) def wrapper(namespace_name, repo_name, *args, **kwargs): response = f(namespace_name, repo_name, *args, **kwargs) # Setting session namespace and repository session['namespace'] = namespace_name session['repository'] = repo_name # We run our index and registry on the same hosts for now registry_server = urlparse.urlparse(request.url).netloc response.headers['X-Docker-Endpoints'] = registry_server has_token_request = request.headers.get('X-Docker-Token', '') force_grant = (add_grant_for_status == response.status_code) if has_token_request or force_grant: grants = [] if scope == GrantType.READ_REPOSITORY: if force_grant or ReadRepositoryPermission(namespace_name, repo_name).can(): grants.append(repository_read_grant(namespace_name, repo_name)) elif scope == GrantType.WRITE_REPOSITORY: if force_grant or ModifyRepositoryPermission(namespace_name, repo_name).can(): grants.append(repository_write_grant(namespace_name, repo_name)) # Generate a signed token for the user (if any) and the grants (if any) if grants or get_authenticated_user(): user_context = get_authenticated_user() and get_authenticated_user().username signature = generate_signed_token(grants, user_context) response.headers['WWW-Authenticate'] = signature response.headers['X-Docker-Token'] = signature return response return wrapper return decorator_method @v1_bp.route('/users', methods=['POST']) @v1_bp.route('/users/', methods=['POST']) @anon_allowed def create_user(): user_data = request.get_json() if not user_data or not 'username' in user_data: abort(400, 'Missing username') username = user_data['username'] password = user_data.get('password', '') # UGH! we have to use this response when the login actually worked, in order # to get the CLI to try again with a get, and then tell us login succeeded. success = make_response('"Username or email already exists"', 400) result, kind = validate_credentials(username, password) if not result.auth_valid: if kind == CredentialKind.token: abort(400, 'Invalid access token.', issue='invalid-access-token') if kind == CredentialKind.robot: abort(400, 'Invalid robot account or password.', issue='robot-login-failure') if kind == CredentialKind.oauth_token: abort(400, 'Invalid oauth access token.', issue='invalid-oauth-access-token') if kind == CredentialKind.user: # Mark that the login failed. event = userevents.get_event(username) event.publish_event_data('docker-cli', {'action': 'loginfailure'}) abort(400, result.error_message, issue='login-failure') # Default case: Just fail. abort(400, result.error_message, issue='login-failure') if result.has_nonrobot_user: # Mark that the user was logged in. event = userevents.get_event(username) event.publish_event_data('docker-cli', {'action': 'login'}) return success @v1_bp.route('/users', methods=['GET']) @v1_bp.route('/users/', methods=['GET']) @process_auth @anon_allowed def get_user(): context = get_authenticated_context() if not context or context.is_anonymous: abort(404) return jsonify({ 'username': context.credential_username, 'email': None, }) @v1_bp.route('/users//', methods=['PUT']) @process_auth @anon_allowed def update_user(username): permission = UserAdminPermission(username) if permission.can(): update_request = request.get_json() if 'password' in update_request: logger.debug('Updating user password') model.change_user_password(get_authenticated_user(), update_request['password']) return jsonify({ 'username': get_authenticated_user().username, 'email': get_authenticated_user().email}) abort(403) @v1_bp.route('/repositories//', methods=['PUT']) @process_auth @parse_repository_name() @ensure_namespace_enabled @generate_headers(scope=GrantType.WRITE_REPOSITORY, add_grant_for_status=201) @anon_allowed def create_repository(namespace_name, repo_name): # Verify that the repository name is valid. if not REPOSITORY_NAME_REGEX.match(repo_name): abort(400, message='Invalid repository name. Repository names cannot contain slashes.') logger.debug('Looking up repository %s/%s', namespace_name, repo_name) repo = model.get_repository(namespace_name, repo_name) logger.debug('Found repository %s/%s', namespace_name, repo_name) if not repo and get_authenticated_user() is None: logger.debug('Attempt to create repository %s/%s without user auth', namespace_name, repo_name) abort(401, message='Cannot create a repository as a guest. Please login via "docker login" first.', issue='no-login') elif repo: modify_perm = ModifyRepositoryPermission(namespace_name, repo_name) if not modify_perm.can(): abort(403, message='You do not have permission to modify repository %(namespace)s/%(repository)s', issue='no-repo-write-permission', namespace=namespace_name, repository=repo_name) elif repo.kind != 'image': msg = 'This repository is for managing %s resources and not container images.' % repo.kind abort(405, message=msg, namespace=namespace_name) else: create_perm = CreateRepositoryPermission(namespace_name) if not create_perm.can(): logger.warning('Attempt to create a new repo %s/%s with insufficient perms', namespace_name, repo_name) msg = 'You do not have permission to create repositories in namespace "%(namespace)s"' abort(403, message=msg, issue='no-create-permission', namespace=namespace_name) # Attempt to create the new repository. logger.debug('Creating repository %s/%s with owner: %s', namespace_name, repo_name, get_authenticated_user().username) model.create_repository(namespace_name, repo_name, get_authenticated_user()) if get_authenticated_user(): user_event_data = { 'action': 'push_start', 'repository': repo_name, 'namespace': namespace_name,} event = userevents.get_event(get_authenticated_user().username) event.publish_event_data('docker-cli', user_event_data) return make_response('Created', 201) @v1_bp.route('/repositories//images', methods=['PUT']) @process_auth @parse_repository_name() @ensure_namespace_enabled @generate_headers(scope=GrantType.WRITE_REPOSITORY) @anon_allowed def update_images(namespace_name, repo_name): permission = ModifyRepositoryPermission(namespace_name, repo_name) if permission.can(): logger.debug('Looking up repository') repo = model.get_repository(namespace_name, repo_name) if not repo: # Make sure the repo actually exists. abort(404, message='Unknown repository', issue='unknown-repo') elif repo.kind != 'image': msg = 'This repository is for managing %s resources and not container images.' % repo.kind abort(405, message=msg, namespace=namespace_name) # Generate a job for each notification that has been added to this repo logger.debug('Adding notifications for repository') updated_tags = session.get('pushed_tags', {}) event_data = { 'updated_tags': updated_tags,} track_and_log('push_repo', repo) spawn_notification(repo, 'repo_push', event_data) metric_queue.repository_push.Inc(labelvalues=[namespace_name, repo_name, 'v1', True]) return make_response('Updated', 204) abort(403) @v1_bp.route('/repositories//images', methods=['GET']) @process_auth @parse_repository_name() @ensure_namespace_enabled @generate_headers(scope=GrantType.READ_REPOSITORY) @anon_protect def get_repository_images(namespace_name, repo_name): permission = ReadRepositoryPermission(namespace_name, repo_name) # TODO invalidate token? if permission.can() or model.repository_is_public(namespace_name, repo_name): # We can't rely on permissions to tell us if a repo exists anymore logger.debug('Looking up repository') repo = model.get_repository(namespace_name, repo_name) if not repo: abort(404, message='Unknown repository', issue='unknown-repo') elif repo.kind != 'image': msg = 'This repository is for managing %s resources and not container images.' % repo.kind abort(405, message=msg, namespace=namespace_name) logger.debug('Building repository image response') resp = make_response(json.dumps([]), 200) resp.mimetype = 'application/json' track_and_log('pull_repo', repo, analytics_name='pull_repo_100x', analytics_sample=0.01) metric_queue.repository_pull.Inc(labelvalues=[namespace_name, repo_name, 'v1', True]) return resp abort(403) @v1_bp.route('/repositories//images', methods=['DELETE']) @process_auth @parse_repository_name() @ensure_namespace_enabled @generate_headers(scope=GrantType.WRITE_REPOSITORY) @anon_allowed def delete_repository_images(namespace_name, repo_name): abort(501, 'Not Implemented', issue='not-implemented') @v1_bp.route('/repositories//auth', methods=['PUT']) @parse_repository_name() @ensure_namespace_enabled @anon_allowed def put_repository_auth(namespace_name, repo_name): abort(501, 'Not Implemented', issue='not-implemented') @v1_bp.route('/search', methods=['GET']) @process_auth @anon_protect def get_search(): query = request.args.get('q') or '' try: limit = min(100, max(1, int(request.args.get('n', 25)))) except ValueError: limit = 25 try: page = max(0, int(request.args.get('page', 1))) except ValueError: page = 1 username = None user = get_authenticated_user() if user is not None: username = user.username data = _conduct_repo_search(username, query, limit, page) resp = make_response(json.dumps(data), 200) resp.mimetype = 'application/json' return resp def _conduct_repo_search(username, query, limit=25, page=1): """ Finds matching repositories. """ # Note that we put a maximum limit of five pages here, because this API should only really ever # be used by the Docker CLI, and it doesn't even paginate. page = min(page, 5) offset = (page - 1) * limit if query: matching_repos = model.get_sorted_matching_repositories(query, username, limit=limit + 1, offset=offset) else: matching_repos = [] results = [] for repo in matching_repos[0:limit]: results.append({ 'name': repo.namespace_name + '/' + repo.name, 'description': repo.description, 'is_public': repo.is_public, 'href': '/repository/' + repo.namespace_name + '/' + repo.name}) # Defined: https://docs.docker.com/v1.6/reference/api/registry_api/ return { 'query': query, 'num_results': len(results), 'num_pages': page + 1 if len(matching_repos) > limit else page, 'page': page, 'page_size': limit, 'results': results,}