Use new error format for auth errors (factor exceptions into module)

This commit is contained in:
Evan Cordell 2016-04-11 16:20:11 -04:00
parent 9c08717173
commit eba75494d9
25 changed files with 214 additions and 177 deletions

View file

@ -13,6 +13,7 @@ import scopes
from data import model
from app import app, authentication
from endpoints.exception import InvalidToken, ExpiredToken
from permissions import QuayDeferredPermissionUser
from auth_context import (set_authenticated_user, set_validated_token, set_grant_context,
set_validated_oauth_token)
@ -50,20 +51,10 @@ def _validate_and_apply_oauth_token(token):
validated = model.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)s',
issue='invalid-oauth-token', token=token, headers=authenticate_header)
raise InvalidToken('OAuth access token could not be validated: {token}'.format(token=token))
elif validated.expires_at <= datetime.utcnow():
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)s',
issue='invalid-oauth-token', token=token, headers=authenticate_header)
raise ExpiredToken('OAuth access token has expired: {token}'.format(token=token))
# Don't allow disabled users to login.
if not validated.authorized_user.enabled:

View file

@ -16,7 +16,7 @@ from buildtrigger.basehandler import BuildTriggerHandler
from util.security.ssh import generate_ssh_keypair
from util.dict_wrappers import JSONPathDict, SafeDictSetter
from endpoints.api import ExternalServiceTimeout
from endpoints.exception import ExternalServiceTimeout
import gitlab
import requests

View file

@ -21,6 +21,7 @@ from auth import scopes
from auth.auth_context import get_authenticated_user, get_validated_oauth_token
from auth.auth import process_oauth
from endpoints.csrf import csrf_protect
from endpoints.exception import ApiException, Unauthorized, InvalidRequest, InvalidResponse
from endpoints.decorators import check_anon_protection
from util.saas.metricqueue import time_decorator
from util.pagination import encrypt_page_token, decrypt_page_token
@ -34,125 +35,9 @@ api.decorators = [csrf_protect,
process_oauth, time_decorator(api_bp.name, metric_queue)]
class ApiErrorType(Enum):
external_service_timeout = 'external_service_timeout'
invalid_request = 'invalid_request'
invalid_response = 'invalid_response'
invalid_token = 'invalid_token'
insufficient_scope = 'insufficient_scope'
fresh_login_required = 'fresh_login_required'
exceeds_license = 'exceeds_license'
not_found = 'not_found'
downstream_issue = 'downstream_issue'
ERROR_DESCRIPTION = {
ApiErrorType.external_service_timeout.value: "An external service timed out. Retrying the request may resolve the issue.",
ApiErrorType.invalid_request.value: "The request was invalid. It may have contained invalid values or was improperly formatted.",
ApiErrorType.invalid_response.value: "The response was invalid.",
ApiErrorType.invalid_token.value: "The access token provided was invalid. It may have expired.",
ApiErrorType.insufficient_scope.value: "The access token did not have sufficient scope to access the requested resource.",
ApiErrorType.fresh_login_required.value: "The action requires a fresh login to succeed.",
ApiErrorType.exceeds_license.value: "The action was refused because the current license does not allow it.",
ApiErrorType.not_found.value: "The resource was not found.",
ApiErrorType.downstream_issue.value: "An error occurred in a downstream service.",
}
class ApiException(Exception):
"""
o "type" (string) - A URI reference that identifies the
problem type.
o "title" (string) - A short, human-readable summary of the problem
type. It SHOULD NOT change from occurrence to occurrence of the
problem, except for purposes of localization
o "status" (number) - The HTTP status code
o "detail" (string) - A human-readable explanation specific to this
occurrence of the problem.
o "instance" (string) - A URI reference that identifies the specific
occurrence of the problem. It may or may not yield further
information if dereferenced.
"""
def __init__(self, error_type, status_code, error_description, payload=None):
Exception.__init__(self)
self.error_description = error_description
self.status_code = status_code
self.payload = payload
self.error_type = error_type
print(self)
def to_dict(self):
rv = dict(self.payload or ())
if self.error_description is not None:
rv['detail'] = self.error_description
rv['title'] = self.error_type.value
rv['type'] = url_for('error', error_type=self.error_type.value, _external=True)
rv['status'] = self.status_code
return rv
class ExternalServiceTimeout(ApiException):
def __init__(self, error_description, payload=None):
ApiException.__init__(self, ApiErrorType.external_service_timeout, 520, error_description, payload)
class InvalidRequest(ApiException):
def __init__(self, error_description, payload=None):
ApiException.__init__(self, ApiErrorType.invalid_request, 400, error_description, payload)
class InvalidResponse(ApiException):
def __init__(self, error_description, payload=None):
ApiException.__init__(self, ApiErrorType.invalid_response, 400, error_description, payload)
class InvalidToken(ApiException):
def __init__(self, error_description, payload=None):
ApiException.__init__(self, ApiErrorType.invalid_token, 401, error_description, payload)
class Unauthorized(ApiException):
def __init__(self, payload=None):
user = get_authenticated_user()
if user is None or user.organization:
ApiException.__init__(self, ApiErrorType.invalid_token, 401, "Requires authentication", payload)
else:
ApiException.__init__(self, ApiErrorType.insufficient_scope, 403, 'Unauthorized', payload)
class FreshLoginRequired(ApiException):
def __init__(self, payload=None):
ApiException.__init__(self, ApiErrorType.fresh_login_required, 401, "Requires fresh login", payload)
class ExceedsLicenseException(ApiException):
def __init__(self, payload=None):
ApiException.__init__(self, ApiErrorType.exceeds_license, 402, 'Payment Required', payload)
class NotFound(ApiException):
def __init__(self, payload=None):
ApiException.__init__(self, ApiErrorType.not_found, 404, 'Not Found', payload)
class DownstreamIssue(ApiException):
def __init__(self, payload=None):
ApiException.__init__(self, ApiErrorType.downstream_issue, 520, 'Downstream Issue', payload)
@api_bp.app_errorhandler(ApiException)
@crossdomain(origin='*', headers=['Authorization', 'Content-Type'])
def handle_api_error(error):
print(error)
response = Response(json.dumps(error.to_dict()), error.status_code, mimetype='application/problem+json')
if error.status_code is 401:
response.headers['WWW-Authenticate'] = ('Bearer error="%s" error_description="%s"' %

View file

@ -5,8 +5,9 @@ import stripe
from flask import request
from app import billing
from endpoints.api import (resource, nickname, ApiResource, validate_json_request, log_action,
related_user_resource, internal_only, Unauthorized, NotFound,
require_user_admin, show_if, path_param, require_scope, abort)
related_user_resource, internal_only, require_user_admin, show_if,
path_param, require_scope, abort)
from endpoints.exception import Unauthorized, NotFound
from endpoints.api.subscribe import subscribe, subscription_view
from auth.permissions import AdministerOrganizationPermission
from auth.auth_context import get_authenticated_user

View file

@ -12,8 +12,9 @@ from app import app, userfiles as user_files, build_logs, log_archive, dockerfil
from buildtrigger.basehandler import BuildTriggerHandler
from endpoints.api import (RepositoryParamResource, parse_args, query_param, nickname, resource,
require_repo_read, require_repo_write, validate_json_request,
ApiResource, internal_only, format_date, api, Unauthorized, NotFound,
path_param, InvalidRequest, require_repo_admin)
ApiResource, internal_only, format_date, api, path_param,
require_repo_admin)
from endpoints.exception import Unauthorized, NotFound, InvalidRequest
from endpoints.building import start_build, PreparedBuild
from data import database
from data import model

View file

@ -3,7 +3,6 @@
import re
import logging
import sys
import copy
from flask.ext.restful import reqparse
@ -180,24 +179,50 @@ def swagger_route_data(include_internal=False, compact=False):
if response_schema_name:
models[response_schema_name] = view_class.schemas[response_schema_name]
models['ApiError'] = {
'type': 'object',
'properties': {
'status': {
'type': 'integer'
},
'type': {
'type': 'string'
},
'detail': {
'type': 'string'
},
'title': {
'type': 'string'
}
},
'required': [
'status',
'type',
'title',
]
}
responses = {
'400': {
'description': 'Bad Request'
'description': 'Bad Request',
},
'401': {
'description': 'Session required'
'description': 'Session required',
},
'403': {
'description': 'Unauthorized access'
'description': 'Unauthorized access',
},
'404': {
'description': 'Not found'
'description': 'Not found',
},
}
for status, body in responses.items():
body['schema'] = {'$ref': '#/definitions/ApiError'}
if method_name == 'DELETE':
responses['204'] = {
'description': 'Deleted'

View file

@ -2,9 +2,9 @@
from flask import url_for
from enum import Enum
from endpoints.api import (resource, nickname, ApiResource, NotFound, path_param,
define_json_response, ApiErrorType, ERROR_DESCRIPTION)
from endpoints.api import (resource, nickname, ApiResource, path_param,
define_json_response)
from endpoints.exception import NotFound, ApiErrorType, ERROR_DESCRIPTION
def error_view(error_type):
return {
@ -19,12 +19,12 @@ def error_view(error_type):
class Error(ApiResource):
""" Resource for manging an organization's teams. """
schemas = {
'ApiError': {
'ApiErrorDescription': {
'type': 'object',
'description': 'Description of an error',
'required': [
'type',
'properties',
'description',
'title',
],
'properties': {
@ -45,8 +45,8 @@ class Error(ApiResource):
},
}
@define_json_response('ApiError')
@nickname('getError')
@define_json_response('ApiErrorDescription')
@nickname('getErrorDescription')
def get(self, error_type):
""" Get a detailed description of the error """
if error_type in ERROR_DESCRIPTION.keys():

View file

@ -4,7 +4,8 @@ import json
from collections import defaultdict
from endpoints.api import (resource, nickname, require_repo_read, RepositoryParamResource,
format_date, NotFound, path_param)
format_date, path_param)
from endpoints.exception import NotFound
from data import model
from util.cache import cache_control_flask_restful

View file

@ -7,8 +7,8 @@ from dateutil.relativedelta import relativedelta
from endpoints.api import (resource, nickname, ApiResource, query_param, parse_args,
RepositoryParamResource, require_repo_admin, related_user_resource,
format_date, Unauthorized, NotFound, require_user_admin,
path_param, require_scope, page_support)
format_date, require_user_admin, path_param, require_scope, page_support)
from endpoints.exception import Unauthorized, NotFound
from auth.permissions import AdministerOrganizationPermission, AdministerOrganizationPermission
from auth.auth_context import get_authenticated_user
from data import model, database

View file

@ -8,9 +8,9 @@ import features
from app import billing as stripe, avatar
from endpoints.api import (resource, nickname, ApiResource, validate_json_request, request_error,
related_user_resource, internal_only, Unauthorized, NotFound,
require_user_admin, log_action, show_if, path_param,
require_scope)
related_user_resource, internal_only, require_user_admin, log_action,
show_if, path_param, require_scope)
from endpoints.exception import Unauthorized, NotFound
from endpoints.api.team import team_view
from endpoints.api.user import User, PrivateRepositories
from auth.permissions import (AdministerOrganizationPermission, OrganizationMemberPermission,

View file

@ -6,8 +6,8 @@ from flask import request
from app import avatar
from endpoints.api import (resource, nickname, require_repo_admin, RepositoryParamResource,
log_action, request_error, validate_json_request, path_param,
NotFound)
log_action, request_error, validate_json_request, path_param)
from endpoints.exception import NotFound
from data import model

View file

@ -3,7 +3,8 @@
from flask import request
from endpoints.api import (resource, nickname, ApiResource, validate_json_request, request_error,
log_action, Unauthorized, NotFound, path_param, require_scope)
log_action, path_param, require_scope)
from endpoints.exception import Unauthorized, NotFound
from auth.permissions import AdministerOrganizationPermission
from auth.auth_context import get_authenticated_user
from auth import scopes

View file

@ -5,9 +5,9 @@ import logging
from flask import request, abort
from endpoints.api import (resource, nickname, require_repo_admin, RepositoryParamResource,
log_action, validate_json_request, NotFound, internal_only,
log_action, validate_json_request, internal_only,
path_param, show_if)
from endpoints.exception import NotFound
from app import tf
from data import model
from data.database import db

View file

@ -13,8 +13,8 @@ from data.database import Repository as RepositoryTable
from endpoints.api import (truthy_bool, format_date, nickname, log_action, validate_json_request,
require_repo_read, require_repo_write, require_repo_admin,
RepositoryParamResource, resource, query_param, parse_args, ApiResource,
request_error, require_scope, Unauthorized, NotFound, InvalidRequest,
path_param, ExceedsLicenseException, page_support)
request_error, require_scope, path_param, page_support)
from endpoints.exception import Unauthorized, NotFound, InvalidRequest, ExceedsLicenseException
from endpoints.api.billing import lookup_allowed_private_repos, get_namespace_plan
from endpoints.api.subscribe import check_repository_usage

View file

@ -6,8 +6,9 @@ from flask import request
from app import notification_queue
from endpoints.api import (RepositoryParamResource, nickname, resource, require_repo_admin,
log_action, validate_json_request, NotFound, request_error,
log_action, validate_json_request, request_error,
path_param)
from endpoints.exception import NotFound
from endpoints.notificationevent import NotificationEvent
from endpoints.notificationmethod import (NotificationMethod,
CannotValidateNotificationMethodException)

View file

@ -5,7 +5,8 @@ import logging
from flask import request
from endpoints.api import (resource, nickname, require_repo_admin, RepositoryParamResource,
log_action, validate_json_request, NotFound, path_param)
log_action, validate_json_request, path_param)
from endpoints.exception import NotFound
from data import model

View file

@ -1,8 +1,9 @@
""" Manage user and organization robot accounts. """
from endpoints.api import (resource, nickname, ApiResource, log_action, related_user_resource,
Unauthorized, require_user_admin, require_scope, path_param, parse_args,
require_user_admin, require_scope, path_param, parse_args,
truthy_bool, query_param)
from endpoints.exception import Unauthorized
from auth.permissions import AdministerOrganizationPermission, OrganizationMemberPermission
from auth.auth_context import get_authenticated_user
from auth import scopes

View file

@ -5,9 +5,10 @@ import features
from app import secscan_api
from data import model
from endpoints.api import (require_repo_read, NotFound, DownstreamIssue, path_param,
from endpoints.api import (require_repo_read, path_param,
RepositoryParamResource, resource, nickname, show_if, parse_args,
query_param, truthy_bool)
from endpoints.exception import NotFound, DownstreamIssue
from util.secscan.api import APIRequestFailure

View file

@ -4,7 +4,8 @@ import logging
import stripe
from app import billing
from endpoints.api import request_error, log_action, NotFound
from endpoints.api import request_error, log_action
from endpoints.exception import NotFound
from data import model
from data.billing import PLANS

View file

@ -3,8 +3,9 @@
from flask import request, abort
from endpoints.api import (resource, nickname, require_repo_read, require_repo_write,
RepositoryParamResource, log_action, NotFound, validate_json_request,
RepositoryParamResource, log_action, validate_json_request,
path_param, parse_args, query_param, truthy_bool)
from endpoints.exception import NotFound
from endpoints.api.image import image_view
from data import model
from auth.auth_context import get_authenticated_user

View file

@ -5,9 +5,9 @@ from flask import request
import features
from endpoints.api import (resource, nickname, ApiResource, validate_json_request, request_error,
log_action, Unauthorized, NotFound, internal_only, require_scope,
path_param, query_param, truthy_bool, parse_args, require_user_admin,
show_if)
log_action, internal_only, require_scope, path_param, query_param,
truthy_bool, parse_args, require_user_admin, show_if)
from endpoints.exception import Unauthorized, NotFound
from auth.permissions import AdministerOrganizationPermission, ViewTeamPermission
from auth.auth_context import get_authenticated_user
from auth import scopes

View file

@ -15,8 +15,8 @@ from buildtrigger.triggerutil import (TriggerDeactivationException,
RepositoryReadException, TriggerStartException)
from endpoints.api import (RepositoryParamResource, nickname, resource, require_repo_admin,
log_action, request_error, query_param, parse_args, internal_only,
validate_json_request, api, Unauthorized, NotFound, InvalidRequest,
path_param)
validate_json_request, api, path_param)
from endpoints.exception import NotFound, Unauthorized, InvalidRequest
from endpoints.api.build import build_status_view, trigger_view, RepositoryBuildStatus
from endpoints.building import start_build
from data import model

View file

@ -13,10 +13,11 @@ import features
from app import app, billing as stripe, authentication, avatar
from data.database import Repository as RepositoryTable
from endpoints.api import (ApiResource, nickname, resource, validate_json_request, request_error,
log_action, internal_only, NotFound, require_user_admin, parse_args,
query_param, InvalidToken, require_scope, format_date, show_if,
log_action, internal_only, require_user_admin, parse_args,
query_param, require_scope, format_date, show_if,
license_error, require_fresh_login, path_param, define_json_response,
RepositoryParamResource, page_support)
from endpoints.exception import NotFound, InvalidToken
from endpoints.api.subscribe import subscribe
from endpoints.common import common_login
from endpoints.decorators import anon_allowed

125
endpoints/exception.py Normal file
View file

@ -0,0 +1,125 @@
from enum import Enum
from flask import url_for
from auth.auth_context import get_authenticated_user
class ApiErrorType(Enum):
external_service_timeout = 'external_service_timeout'
invalid_request = 'invalid_request'
invalid_response = 'invalid_response'
invalid_token = 'invalid_token'
expired_token = 'expired_token'
insufficient_scope = 'insufficient_scope'
fresh_login_required = 'fresh_login_required'
exceeds_license = 'exceeds_license'
not_found = 'not_found'
downstream_issue = 'downstream_issue'
ERROR_DESCRIPTION = {
ApiErrorType.external_service_timeout.value: "An external service timed out. Retrying the request may resolve the issue.",
ApiErrorType.invalid_request.value: "The request was invalid. It may have contained invalid values or was improperly formatted.",
ApiErrorType.invalid_response.value: "The response was invalid.",
ApiErrorType.invalid_token.value: "The access token provided was invalid.",
ApiErrorType.expired_token.value: "The access token provided has expired.",
ApiErrorType.insufficient_scope.value: "The access token did not have sufficient scope to access the requested resource.",
ApiErrorType.fresh_login_required.value: "The action requires a fresh login to succeed.",
ApiErrorType.exceeds_license.value: "The action was refused because the current license does not allow it.",
ApiErrorType.not_found.value: "The resource was not found.",
ApiErrorType.downstream_issue.value: "An error occurred in a downstream service.",
}
class ApiException(Exception):
"""
o "type" (string) - A URI reference that identifies the
problem type.
o "title" (string) - A short, human-readable summary of the problem
type. It SHOULD NOT change from occurrence to occurrence of the
problem, except for purposes of localization
o "status" (number) - The HTTP status code
o "detail" (string) - A human-readable explanation specific to this
occurrence of the problem.
o "instance" (string) - A URI reference that identifies the specific
occurrence of the problem. It may or may not yield further
information if dereferenced.
"""
def __init__(self, error_type, status_code, error_description, payload=None):
Exception.__init__(self)
self.error_description = error_description
self.status_code = status_code
self.payload = payload
self.error_type = error_type
print(self)
def to_dict(self):
rv = dict(self.payload or ())
if self.error_description is not None:
rv['detail'] = self.error_description
rv['title'] = self.error_type.value
rv['type'] = url_for('error', error_type=self.error_type.value, _external=True)
rv['status'] = self.status_code
return rv
class ExternalServiceTimeout(ApiException):
def __init__(self, error_description, payload=None):
ApiException.__init__(self, ApiErrorType.external_service_timeout, 520, error_description, payload)
class InvalidRequest(ApiException):
def __init__(self, error_description, payload=None):
ApiException.__init__(self, ApiErrorType.invalid_request, 400, error_description, payload)
class InvalidResponse(ApiException):
def __init__(self, error_description, payload=None):
ApiException.__init__(self, ApiErrorType.invalid_response, 400, error_description, payload)
class InvalidToken(ApiException):
def __init__(self, error_description, payload=None):
ApiException.__init__(self, ApiErrorType.invalid_token, 401, error_description, payload)
class ExpiredToken(ApiException):
def __init__(self, error_description, payload=None):
ApiException.__init__(self, ApiErrorType.expired_token, 401, error_description, payload)
class Unauthorized(ApiException):
def __init__(self, payload=None):
user = get_authenticated_user()
if user is None or user.organization:
ApiException.__init__(self, ApiErrorType.invalid_token, 401, "Requires authentication", payload)
else:
ApiException.__init__(self, ApiErrorType.insufficient_scope, 403, 'Unauthorized', payload)
class FreshLoginRequired(ApiException):
def __init__(self, payload=None):
ApiException.__init__(self, ApiErrorType.fresh_login_required, 401, "Requires fresh login", payload)
class ExceedsLicenseException(ApiException):
def __init__(self, payload=None):
ApiException.__init__(self, ApiErrorType.exceeds_license, 402, 'Payment Required', payload)
class NotFound(ApiException):
def __init__(self, payload=None):
ApiException.__init__(self, ApiErrorType.not_found, 404, 'Not Found', payload)
class DownstreamIssue(ApiException):
def __init__(self, payload=None):
ApiException.__init__(self, ApiErrorType.downstream_issue, 520, 'Downstream Issue', payload)

View file

@ -44,7 +44,7 @@ from endpoints.api.logs import UserLogs, OrgLogs, OrgAggregateLogs, UserAggregat
from endpoints.api.billing import (UserCard, UserPlan, ListPlans, OrganizationCard,
OrganizationPlan)
from endpoints.api.discovery import DiscoveryResource
from endpoints.api.error import ApiError
from endpoints.api.error import Error
from endpoints.api.organization import (OrganizationList, OrganizationMember,
OrgPrivateRepositories, OrganizationMemberList,
Organization, ApplicationInformation,
@ -237,12 +237,12 @@ class TestDiscovery(ApiTestCase):
assert 'paths' in json
class TestError(ApiTestCase):
class TestErrorDescription(ApiTestCase):
def test_get_error(self):
json = self.getJsonResponse(APIError, data=dict(error='not_found'))
json = self.getJsonResponse(Error, params=dict(error_type='not_found'))
assert json['title'] == 'not_found'
assert type in json
assert description in json
assert 'type' in json
assert 'description' in json
class TestPlans(ApiTestCase):
@ -355,7 +355,7 @@ class TestGetUserPrivateAllowed(ApiTestCase):
assert json['privateCount'] == 0
assert not json['privateAllowed']
def test_allowedz(self):
def test_allowed(self):
self.login(ADMIN_ACCESS_USER)
# Change the subscription of the namespace.