diff --git a/auth/auth.py b/auth/auth.py index 2637cdeda..6c50685a3 100644 --- a/auth/auth.py +++ b/auth/auth.py @@ -108,42 +108,50 @@ def process_token(auth): identity_changed.send(app, identity=Identity(token_data.code, 'token')) -def process_oauth(auth): - normalized = [part.strip() for part in auth.split(' ') if part] - if normalized[0].lower() != 'bearer' or len(normalized) != 2: - logger.debug('Invalid oauth bearer token format.') - return +def process_oauth(f): + @wraps(f) + def wrapper(*args, **kwargs): + auth = request.headers.get('authorization', '') + if auth: + normalized = [part.strip() for part in auth.split(' ') if part] + if normalized[0].lower() != 'bearer' or len(normalized) != 2: + logger.debug('Invalid oauth bearer token format.') + return - token = normalized[1] - validated = oauth.validate_access_token(token) - if not validated: - logger.warning('OAuth access token could not be validated: %s', token) - authenticate_header = { - 'WWW-Authenticate': ('Bearer error="invalid_token", ' - 'error_description="The access token is invalid"'), - } - abort(401, message="OAuth access token could not be validated: %(token)", - issue='invalid-oauth-token', token=token, header=authenticate_header) - elif validated.expires_at <= datetime.now(): - logger.info('OAuth access with an expired token: %s', token) - authenticate_header = { - 'WWW-Authenticate': ('Bearer error="invalid_token", ' - 'error_description="The access token expired"'), - } - abort(401, message="OAuth access token has expired: %(token)", issue='invalid-oauth-token', - token=token, headers=authenticate_header) + token = normalized[1] + validated = oauth.validate_access_token(token) + if not validated: + logger.warning('OAuth access token could not be validated: %s', token) + authenticate_header = { + 'WWW-Authenticate': ('Bearer error="invalid_token", ' + 'error_description="The access token is invalid"'), + } + abort(401, message="OAuth access token could not be validated: %(token)", + issue='invalid-oauth-token', token=token, header=authenticate_header) + elif validated.expires_at <= datetime.now(): + logger.info('OAuth access with an expired token: %s', token) + authenticate_header = { + 'WWW-Authenticate': ('Bearer error="invalid_token", ' + 'error_description="The access token expired"'), + } + abort(401, message="OAuth access token has expired: %(token)", issue='invalid-oauth-token', + token=token, headers=authenticate_header) - # We have a valid token - scope_set = scopes.scopes_from_scope_string(validated.scope) - logger.debug('Successfully validated oauth access token: %s with scope: %s', token, - scope_set) + # We have a valid token + scope_set = scopes.scopes_from_scope_string(validated.scope) + logger.debug('Successfully validated oauth access token: %s with scope: %s', token, + scope_set) - ctx = _request_ctx_stack.top - ctx.authenticated_user = validated.authorized_user + ctx = _request_ctx_stack.top + ctx.authenticated_user = validated.authorized_user - new_identity = QuayDeferredPermissionUser(validated.authorized_user.username, 'username', - scope_set) - identity_changed.send(app, identity=new_identity) + new_identity = QuayDeferredPermissionUser(validated.authorized_user.username, 'username', + scope_set) + identity_changed.send(app, identity=new_identity) + else: + logger.debug('No auth header.') + return f(*args, **kwargs) + return wrapper def process_auth(f): @@ -155,7 +163,6 @@ def process_auth(f): logger.debug('Validating auth header: %s' % auth) process_token(auth) process_basic_auth(auth) - process_oauth(auth) else: logger.debug('No auth header.') diff --git a/auth/permissions.py b/auth/permissions.py index b92a5116a..f3695bf81 100644 --- a/auth/permissions.py +++ b/auth/permissions.py @@ -69,6 +69,7 @@ class QuayDeferredPermissionUser(Identity): if self._scope_set is None: user_grant = UserNeed(user_object.username) self.provides.add(user_grant) + logger.debug('Add admin to user namespace: %s', user_object.username) # Every user is the admin of their own 'org' user_namespace = _OrganizationNeed(user_object.username, self._team_role_for_scopes('admin')) diff --git a/endpoints/api/__init__.py b/endpoints/api/__init__.py index 22c202e4e..5fd3865b3 100644 --- a/endpoints/api/__init__.py +++ b/endpoints/api/__init__.py @@ -1,9 +1,9 @@ import logging +import json -from flask import Blueprint, request +from flask import Blueprint, request, make_response from flask.ext.restful import Resource, abort, Api, reqparse from flask.ext.restful.utils.cors import crossdomain -from flask.ext.login import current_user from calendar import timegm from email.utils import formatdate from functools import partial, wraps @@ -15,12 +15,15 @@ from auth.permissions import (ReadRepositoryPermission, ModifyRepositoryPermission, AdministerRepositoryPermission) from auth import scopes +from auth.auth_context import get_authenticated_user +from auth.auth import process_oauth logger = logging.getLogger(__name__) api_bp = Blueprint('api', __name__) api = Api(api_bp) -api.decorators = [crossdomain(origin='*', headers=['Authorization'])] +api.decorators = [process_oauth, + crossdomain(origin='*', headers=['Authorization', 'Content-Type'])] def resource(*urls, **kwargs): @@ -111,6 +114,8 @@ def require_repo_permission(permission_class, scope, allow_public=False): @add_method_metadata('oauth2_scope', scope) @wraps(func) def wrapped(self, namespace, repository, *args, **kwargs): + logger.debug('Checking permission %s for repo: %s/%s', permission_class, namespace, + repository) permission = permission_class(namespace, repository) if (permission.can() or (allow_public and @@ -126,23 +131,41 @@ require_repo_write = require_repo_permission(ModifyRepositoryPermission, scopes. require_repo_admin = require_repo_permission(AdministerRepositoryPermission, scopes.ADMIN_REPO) +def require_scope(scope_object): + def wrapper(func): + @add_method_metadata('oauth2_scope', scope_object['scope']) + @wraps(func) + def wrapped(*args, **kwargs): + return func(*args, **kwargs) + return wrapped + return wrapper + + def validate_json_request(schema_name): def wrapper(func): @add_method_metadata('request_schema', schema_name) @wraps(func) - def wrapped(self, namespace, repository, *args, **kwargs): + def wrapped(self, *args, **kwargs): schema = self.schemas[schema_name] try: validate(request.get_json(), schema) - return func(self, namespace, repository, *args, **kwargs) + return func(self, *args, **kwargs) except ValidationError as ex: abort(400, message=ex.message) return wrapped return wrapper +def request_error(exception=None, **kwargs): + data = kwargs.copy() + if exception: + data['message'] = exception.message + + return json.dumps(data), 400 + + def log_action(kind, user_or_orgname, metadata={}, repo=None): - performer = current_user.db_user() + performer = get_authenticated_user() model.log_action(kind, user_or_orgname, performer=performer, ip=request.remote_addr, metadata=metadata, repository=repo) diff --git a/endpoints/api/legacy.py b/endpoints/api/legacy.py index b03de7006..dcdc0e30f 100644 --- a/endpoints/api/legacy.py +++ b/endpoints/api/legacy.py @@ -11,7 +11,7 @@ from functools import wraps from collections import defaultdict from urllib import quote -from endpoints.api import api_bp +from endpoints.api import api_bp, log_action from data import model from data.plans import PLANS, get_plan from app import app @@ -66,12 +66,6 @@ def request_error(exception=None, **kwargs): return make_response(jsonify(data), 400) -def log_action(kind, user_or_orgname, metadata={}, repo=None): - performer = current_user.db_user() - model.log_action(kind, user_or_orgname, performer=performer, - ip=request.remote_addr, metadata=metadata, repository=repo) - - def api_login_required(f): @wraps(f) def decorated_view(*args, **kwargs): @@ -111,6 +105,7 @@ def org_api_call(user_call_name): return internal_decorator +# Ported @api_bp.route('/discovery') def discovery(): return jsonify(get_route_data()) @@ -891,6 +886,7 @@ def delete_organization_team_member(orgname, teamname, membername): abort(403) +# Ported @api_bp.route('/repository', methods=['POST']) @api_login_required def create_repo(): @@ -948,6 +944,7 @@ def find_repos(): return jsonify(response) +# Ported @api_bp.route('/repository/', methods=['GET']) def list_repos(): def repo_view(repo_obj): @@ -1003,6 +1000,7 @@ def list_repos(): return jsonify(response) +# Ported @api_bp.route('/repository/', methods=['PUT']) @api_login_required @parse_repository_name @@ -1025,6 +1023,7 @@ def update_repo(namespace, repository): abort(403) +# Ported @api_bp.route('/repository//changevisibility', methods=['POST']) @api_login_required @@ -1046,6 +1045,7 @@ def change_repo_visibility(namespace, repository): abort(403) +# Ported @api_bp.route('/repository/', methods=['DELETE']) @api_login_required @parse_repository_name @@ -1077,6 +1077,7 @@ def image_view(image): } +# Ported @api_bp.route('/repository/', methods=['GET']) @parse_repository_name def get_repo(namespace, repository): diff --git a/endpoints/api/repository.py b/endpoints/api/repository.py index 10347ffe0..b7546159d 100644 --- a/endpoints/api/repository.py +++ b/endpoints/api/repository.py @@ -1,17 +1,20 @@ import logging import json -from flask import current_app +from flask import current_app, request from flask.ext.restful import reqparse, abort from flask.ext.login import current_user from data import model from endpoints.api import (truthy_bool, format_date, nickname, log_action, validate_json_request, - require_repo_read, RepositoryParamResource, resource, query_param, - parse_args, ApiResource) + require_repo_read, require_repo_write, require_repo_admin, + RepositoryParamResource, resource, query_param, parse_args, ApiResource, + request_error, require_scope) from auth.permissions import (ReadRepositoryPermission, ModifyRepositoryPermission, - AdministerRepositoryPermission) + AdministerRepositoryPermission, CreateRepositoryPermission) from auth.auth import process_auth +from auth import scopes +from auth.auth_context import get_authenticated_user logger = logging.getLogger(__name__) @@ -25,14 +28,12 @@ class RepositoryList(ApiResource): 'id': 'NewRepo', 'type': 'object', 'description': 'Description of a new repository', - 'required': [ - 'repository', - 'visibility', - ], + 'required': True, 'properties': { 'repository': { 'type': 'string', 'description': 'Repository name', + 'required': True, }, 'visibility': { 'type': 'string', @@ -40,7 +41,8 @@ class RepositoryList(ApiResource): 'enum': [ 'public', 'private', - ] + ], + 'required': True, }, 'namespace': { 'type': 'string', @@ -55,11 +57,12 @@ class RepositoryList(ApiResource): } } + @require_scope(scopes.CREATE_REPO) @nickname('createRepo') @validate_json_request('NewRepo') def post(self): """Create a new repository.""" - owner = current_user.db_user() + owner = get_authenticated_user() req = request.get_json() namespace_name = req['namespace'] if 'namespace' in req else owner.username @@ -81,10 +84,10 @@ class RepositoryList(ApiResource): log_action('create_repo', namespace_name, {'repo': repository_name, 'namespace': namespace_name}, repo=repo) - return jsonify({ + return { 'namespace': namespace_name, 'name': repository_name - }) + }, 201 abort(403) @@ -148,7 +151,22 @@ def image_view(image): @resource('/v1/repository/') class Repository(RepositoryParamResource): """Operations for managing a specific repository.""" - @process_auth + schemas = { + 'RepoUpdate': { + 'id': 'RepoUpdate', + 'type': 'object', + 'description': 'Fields which can be updated in a repository.', + 'required': True, + 'properties': { + 'description': { + 'type': 'string', + 'description': 'Markdown encoded description for the repository', + 'required': True, + }, + } + } + } + @require_repo_read @nickname('getRepo') def get(self, namespace, repository): @@ -195,3 +213,70 @@ class Repository(RepositoryParamResource): } abort(404) # Not found + + @require_repo_write + @nickname('updateRepo') + @validate_json_request('RepoUpdate') + def put(self, namespace, repository): + """ Update the description in the specified repository. """ + repo = model.get_repository(namespace, repository) + if repo: + values = request.get_json() + repo.description = values['description'] + repo.save() + + log_action('set_repo_description', namespace, + {'repo': repository, 'description': values['description']}, + repo=repo) + return { + 'success': True + } + abort(404) # Not found + + @require_repo_admin + @nickname('deleteRepository') + def delete(self, namespace, repository): + model.purge_repository(namespace, repository) + log_action('delete_repo', namespace, + {'repo': repository, 'namespace': namespace}) + return 'Deleted', 204 + + +@resource('/v1/repository//changevisibility') +class RepositoryVisibility(RepositoryParamResource): + """ Custom verb for changing the visibility of the repository. """ + schemas = { + 'ChangeVisibility': { + 'id': 'ChangeVisibility', + 'type': 'object', + 'description': 'Change the visibility for the repository.', + 'required': True, + 'properties': { + 'visibility': { + 'type': 'string', + 'description': 'Visibility which the repository will start with', + 'enum': [ + 'public', + 'private', + ], + 'required': True, + }, + } + } + } + + @require_repo_admin + @nickname('changeRepoVisibility') + @validate_json_request('ChangeVisibility') + def post(self, namespace, repository): + """ Change the visibility of a repository. """ + repo = model.get_repository(namespace, repository) + if repo: + values = request.get_json() + model.set_repository_visibility(repo, values['visibility']) + log_action('change_repo_visibility', namespace, + {'repo': repository, 'visibility': values['visibility']}, + repo=repo) + return { + 'success': True + }