This repository has been archived on 2020-03-24. You can view files and clone it, but cannot push or open issues or pull requests.
quay/endpoints/v1/index.py
Joseph Schorr 651666b60b Refactor our auth handling code to be cleaner
Breaks out the validation code from the auth context modification calls, makes decorators easier to define and adds testing for each individual piece. Will be the basis of better error messaging in the following change.
2017-03-23 15:42:45 -04:00

354 lines
12 KiB
Python

import json
import logging
import urlparse
from functools import wraps
from flask import request, make_response, jsonify, session
from data.interfaces.v1 import pre_oci_model as model
from app import authentication, userevents, metric_queue
from auth.auth_context import get_authenticated_user, get_validated_token, get_validated_oauth_token
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 util.http import abort
from util.names import REPOSITORY_NAME_REGEX
from endpoints.common import parse_repository_name
from endpoints.v1 import v1_bp
from endpoints.trackhelper import track_and_log
from endpoints.notificationhelper import spawn_notification
from endpoints.decorators import anon_protect, anon_allowed
logger = logging.getLogger(__name__)
class GrantType(object):
READ_REPOSITORY = 'read'
WRITE_REPOSITORY = 'write'
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)
if username == '$token':
if model.load_token(password):
return success
abort(400, 'Invalid access token.', issue='invalid-access-token')
elif username == '$oauthtoken':
if model.validate_oauth_token(password):
return success
abort(400, 'Invalid oauth access token.', issue='invalid-oauth-access-token')
elif '+' in username:
if model.verify_robot(username, password):
return success
abort(400, 'Invalid robot account or password.', issue='robot-login-failure')
(verified, error_message) = authentication.verify_and_link_user(username, password,
basic_auth=True)
if verified:
# Mark that the user was logged in.
event = userevents.get_event(username)
event.publish_event_data('docker-cli', {'action': 'login'})
return success
else:
# Mark that the login failed.
event = userevents.get_event(username)
event.publish_event_data('docker-cli', {'action': 'loginfailure'})
abort(400, error_message, issue='login-failure')
@v1_bp.route('/users', methods=['GET'])
@v1_bp.route('/users/', methods=['GET'])
@process_auth
@anon_allowed
def get_user():
if get_validated_oauth_token():
return jsonify({
'username': '$oauthtoken',
'email': None,
})
elif get_authenticated_user():
return jsonify({
'username': get_authenticated_user().username,
'email': get_authenticated_user().email,
})
elif get_validated_token():
return jsonify({
'username': '$token',
'email': None,
})
abort(404)
@v1_bp.route('/users/<username>/', 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/<repopath:repository>/', methods=['PUT'])
@process_auth
@parse_repository_name()
@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.info('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/<repopath:repository>/images', methods=['PUT'])
@process_auth
@parse_repository_name()
@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/<repopath:repository>/images', methods=['GET'])
@process_auth
@parse_repository_name()
@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/<repopath:repository>/images', methods=['DELETE'])
@process_auth
@parse_repository_name()
@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/<repopath:repository>/auth', methods=['PUT'])
@parse_repository_name()
@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,
}