Add scope ordinality and translations. Process oauth tokens and limit scopes accordingly.

This commit is contained in:
jakedt 2014-03-12 16:31:37 -04:00
parent 25ceb90fc6
commit e74eb3ee87
8 changed files with 137 additions and 31 deletions

View file

@ -1,13 +1,16 @@
import logging import logging
from functools import wraps from functools import wraps
from datetime import datetime
from flask import request, _request_ctx_stack, session from flask import request, _request_ctx_stack, session
from flask.ext.principal import identity_changed, Identity from flask.ext.principal import identity_changed, Identity
from base64 import b64decode from base64 import b64decode
from data import model from data import model
from data.model import oauth
from app import app from app import app
from permissions import QuayDeferredPermissionUser from permissions import QuayDeferredPermissionUser
import scopes
from util.http import abort from util.http import abort
@ -49,7 +52,8 @@ def process_basic_auth(auth):
ctx = _request_ctx_stack.top ctx = _request_ctx_stack.top
ctx.authenticated_user = robot ctx.authenticated_user = robot
identity_changed.send(app, identity=Identity(robot.username, 'username')) deferred_robot = QuayDeferredPermissionUser(robot.username, 'username')
identity_changed.send(app, identity=deferred_robot)
return return
except model.InvalidRobotException: except model.InvalidRobotException:
logger.debug('Invalid robot or password for robot: %s' % credentials[0]) logger.debug('Invalid robot or password for robot: %s' % credentials[0])
@ -62,8 +66,7 @@ def process_basic_auth(auth):
ctx = _request_ctx_stack.top ctx = _request_ctx_stack.top
ctx.authenticated_user = authenticated ctx.authenticated_user = authenticated
new_identity = QuayDeferredPermissionUser(authenticated.username, new_identity = QuayDeferredPermissionUser(authenticated.username, 'username')
'username')
identity_changed.send(app, identity=new_identity) identity_changed.send(app, identity=new_identity)
return return
@ -94,18 +97,55 @@ def process_token(auth):
token_data = model.load_token_data(token_vals['signature']) token_data = model.load_token_data(token_vals['signature'])
except model.InvalidTokenException: except model.InvalidTokenException:
logger.warning('Token could not be validated: %s' % logger.warning('Token could not be validated: %s', token_vals['signature'])
token_vals['signature'])
abort(401, message="Token could not be validated: %(auth)", issue='invalid-auth-token', abort(401, message="Token could not be validated: %(auth)", issue='invalid-auth-token',
auth=auth) auth=auth)
logger.debug('Successfully validated token: %s' % token_data.code) logger.debug('Successfully validated token: %s', token_data.code)
ctx = _request_ctx_stack.top ctx = _request_ctx_stack.top
ctx.validated_token = token_data ctx.validated_token = token_data
identity_changed.send(app, identity=Identity(token_data.code, 'token')) 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
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)
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)
def process_auth(f): def process_auth(f):
@wraps(f) @wraps(f)
def wrapper(*args, **kwargs): def wrapper(*args, **kwargs):
@ -115,6 +155,7 @@ def process_auth(f):
logger.debug('Validating auth header: %s' % auth) logger.debug('Validating auth header: %s' % auth)
process_token(auth) process_token(auth)
process_basic_auth(auth) process_basic_auth(auth)
process_oauth(auth)
else: else:
logger.debug('No auth header.') logger.debug('No auth header.')
@ -126,8 +167,7 @@ def extract_namespace_repo_from_session(f):
@wraps(f) @wraps(f)
def wrapper(*args, **kwargs): def wrapper(*args, **kwargs):
if 'namespace' not in session or 'repository' not in session: if 'namespace' not in session or 'repository' not in session:
logger.error('Unable to load namespace or repository from session: %s' % logger.error('Unable to load namespace or repository from session: %s' % session)
session)
abort(400, message="Missing namespace in request") abort(400, message="Missing namespace in request")
return f(session['namespace'], session['repository'], *args, **kwargs) return f(session['namespace'], session['repository'], *args, **kwargs)

View file

@ -18,40 +18,78 @@ _OrganizationNeed = namedtuple('organization', ['orgname', 'role'])
_TeamNeed = namedtuple('orgteam', ['orgname', 'teamname', 'role']) _TeamNeed = namedtuple('orgteam', ['orgname', 'teamname', 'role'])
REPO_ROLES = [None, 'read', 'write', 'admin']
TEAM_ROLES = [None, 'member', 'creator', 'admin']
SCOPE_MAX_REPO_ROLES = {
'repo:read': 'read',
'repo:write': 'write',
'repo:admin': 'admin',
'repo:create': None,
}
SCOPE_MAX_TEAM_ROLES = {
'repo:read': None,
'repo:write': None,
'repo:admin': None,
'repo:create': 'creator',
}
class QuayDeferredPermissionUser(Identity): class QuayDeferredPermissionUser(Identity):
def __init__(self, id, auth_type=None): def __init__(self, id, auth_type=None, scopes=None):
super(QuayDeferredPermissionUser, self).__init__(id, auth_type) super(QuayDeferredPermissionUser, self).__init__(id, auth_type)
self._permissions_loaded = False self._permissions_loaded = False
self._scope_set = scopes
def _translate_role_for_scopes(self, cardinality, max_roles, role):
if self._scope_set is None:
return role
max_for_scopes = max({cardinality.index(max_roles[scope]) for scope in self._scope_set})
if max_for_scopes < cardinality.index(role):
logger.debug('Translated permission %s -> %s', role, cardinality[max_for_scopes])
return cardinality[max_for_scopes]
else:
return role
def _team_role_for_scopes(self, role):
return self._translate_role_for_scopes(TEAM_ROLES, SCOPE_MAX_TEAM_ROLES, role)
def _repo_role_for_scopes(self, role):
return self._translate_role_for_scopes(REPO_ROLES, SCOPE_MAX_REPO_ROLES, role)
def can(self, permission): def can(self, permission):
if not self._permissions_loaded: if not self._permissions_loaded:
logger.debug('Loading user permissions after deferring.') logger.debug('Loading user permissions after deferring.')
user_object = model.get_user(self.id) user_object = model.get_user(self.id)
# Add the user specific permissions # Add the user specific permissions, only for non-oauth permission
user_grant = UserNeed(user_object.username) if self._scope_set is None:
self.provides.add(user_grant) user_grant = UserNeed(user_object.username)
self.provides.add(user_grant)
# Every user is the admin of their own 'org' # Every user is the admin of their own 'org'
user_namespace = _OrganizationNeed(user_object.username, 'admin') user_namespace = _OrganizationNeed(user_object.username, self._team_role_for_scopes('admin'))
self.provides.add(user_namespace) self.provides.add(user_namespace)
# Add repository permissions # Add repository permissions
for perm in model.get_all_user_permissions(user_object): for perm in model.get_all_user_permissions(user_object):
grant = _RepositoryNeed(perm.repository.namespace, grant = _RepositoryNeed(perm.repository.namespace, perm.repository.name,
perm.repository.name, perm.role.name) self._repo_role_for_scopes(perm.role.name))
logger.debug('User added permission: {0}'.format(grant)) logger.debug('User added permission: {0}'.format(grant))
self.provides.add(grant) self.provides.add(grant)
# Add namespace permissions derived # Add namespace permissions derived
for team in model.get_org_wide_permissions(user_object): for team in model.get_org_wide_permissions(user_object):
grant = _OrganizationNeed(team.organization.username, team.role.name) grant = _OrganizationNeed(team.organization.username,
self._team_role_for_scopes(team.role.name))
logger.debug('Organization team added permission: {0}'.format(grant)) logger.debug('Organization team added permission: {0}'.format(grant))
self.provides.add(grant) self.provides.add(grant)
team_grant = _TeamNeed(team.organization.username, team.name, team_grant = _TeamNeed(team.organization.username, team.name,
team.role.name) self._team_role_for_scopes(team.role.name))
logger.debug('Team added permission: {0}'.format(team_grant)) logger.debug('Team added permission: {0}'.format(team_grant))
self.provides.add(team_grant) self.provides.add(team_grant)

View file

@ -23,3 +23,12 @@ CREATE_REPO = {
} }
ALL_SCOPES = {scope['scope']:scope for scope in (READ_REPO, WRITE_REPO, ADMIN_REPO, CREATE_REPO)} ALL_SCOPES = {scope['scope']:scope for scope in (READ_REPO, WRITE_REPO, ADMIN_REPO, CREATE_REPO)}
def scopes_from_scope_string(scopes):
return {ALL_SCOPES.get(scope, {}).get('scope', None) for scope in scopes.split(',')}
def validate_scope_string(scopes):
decoded = scopes_from_scope_string(scopes)
return None not in decoded and len(decoded) > 0

View file

@ -2,7 +2,7 @@ from datetime import datetime, timedelta
from oauth2lib.provider import AuthorizationProvider from oauth2lib.provider import AuthorizationProvider
from oauth2lib import utils from oauth2lib import utils
from data.database import OAuthApplication, OAuthAuthorizationCode, OAuthAccessToken from data.database import OAuthApplication, OAuthAuthorizationCode, OAuthAccessToken, User
from auth import scopes from auth import scopes
@ -34,7 +34,7 @@ class DatabaseAuthorizationProvider(AuthorizationProvider):
return False return False
def validate_scope(self, client_id, scope): def validate_scope(self, client_id, scope):
return scope in scopes.ALL_SCOPES.keys() return scopes.validate_scope_string(scope)
def validate_access(self): def validate_access(self):
return self.get_authorized_user() is not None return self.get_authorized_user() is not None
@ -140,3 +140,14 @@ class DatabaseAuthorizationProvider(AuthorizationProvider):
def create_application(org, redirect_uri, **kwargs): def create_application(org, redirect_uri, **kwargs):
return OAuthApplication.create(organization=org, redirect_uri=redirect_uri, **kwargs) return OAuthApplication.create(organization=org, redirect_uri=redirect_uri, **kwargs)
def validate_access_token(access_token):
try:
found = (OAuthAccessToken
.select(OAuthAccessToken, User)
.join(User)
.where(OAuthAccessToken.access_token == access_token)
.get())
return found
except OAuthAccessToken.DoesNotExist:
return None

View file

@ -20,7 +20,7 @@ from auth import scopes
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
api_bp = Blueprint('api', __name__) api_bp = Blueprint('api', __name__)
api = Api(api_bp) api = Api(api_bp)
api.decorators = [crossdomain(origin='*')] api.decorators = [crossdomain(origin='*', headers=['Authorization'])]
def resource(*urls, **kwargs): def resource(*urls, **kwargs):
@ -97,7 +97,12 @@ def parse_repository_name(func):
return wrapper return wrapper
class RepositoryParamResource(Resource): class ApiResource(Resource):
def options(self):
return None, 200
class RepositoryParamResource(ApiResource):
method_decorators = [parse_repository_name] method_decorators = [parse_repository_name]

View file

@ -1,9 +1,9 @@
import re import re
import logging import logging
from flask.ext.restful import Resource, reqparse from flask.ext.restful import reqparse
from endpoints.api import resource, method_metadata, nickname, truthy_bool from endpoints.api import ApiResource, resource, method_metadata, nickname, truthy_bool
from app import app from app import app
from auth import scopes from auth import scopes
@ -131,7 +131,7 @@ def swagger_route_data():
return swagger_data return swagger_data
@resource('/v1/discovery') @resource('/v1/discovery')
class DiscoveryResource(Resource): class DiscoveryResource(ApiResource):
"""Ability to inspect the API for usage information and documentation.""" """Ability to inspect the API for usage information and documentation."""
@nickname('discovery') @nickname('discovery')
def get(self): def get(self):

View file

@ -1,22 +1,24 @@
import logging import logging
import json import json
from flask.ext.restful import Resource, reqparse, abort from flask import current_app
from flask.ext.restful import reqparse, abort
from flask.ext.login import current_user from flask.ext.login import current_user
from data import model from data import model
from endpoints.api import (truthy_bool, format_date, nickname, log_action, validate_json_request, from endpoints.api import (truthy_bool, format_date, nickname, log_action, validate_json_request,
require_repo_read, RepositoryParamResource, resource, query_param, require_repo_read, RepositoryParamResource, resource, query_param,
parse_args) parse_args, ApiResource)
from auth.permissions import (ReadRepositoryPermission, ModifyRepositoryPermission, from auth.permissions import (ReadRepositoryPermission, ModifyRepositoryPermission,
AdministerRepositoryPermission) AdministerRepositoryPermission)
from auth.auth import process_auth
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@resource('/v1/repository') @resource('/v1/repository')
class RepositoryList(Resource): class RepositoryList(ApiResource):
"""Operations for creating and listing repositories.""" """Operations for creating and listing repositories."""
schemas = { schemas = {
'NewRepo': { 'NewRepo': {
@ -146,6 +148,7 @@ def image_view(image):
@resource('/v1/repository/<path:repository>') @resource('/v1/repository/<path:repository>')
class Repository(RepositoryParamResource): class Repository(RepositoryParamResource):
"""Operations for managing a specific repository.""" """Operations for managing a specific repository."""
@process_auth
@require_repo_read @require_repo_read
@nickname('getRepo') @nickname('getRepo')
def get(self, namespace, repository): def get(self, namespace, repository):

View file

@ -1,7 +1,8 @@
import logging import logging
import json
from app import mixpanel from app import mixpanel
from flask import request, abort as flask_abort, jsonify from flask import request, abort as flask_abort, make_response
from auth.auth_context import get_authenticated_user, get_validated_token from auth.auth_context import get_authenticated_user, get_validated_token
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -15,7 +16,7 @@ DEFAULT_MESSAGE[404] = 'Not Found'
DEFAULT_MESSAGE[409] = 'Conflict' DEFAULT_MESSAGE[409] = 'Conflict'
DEFAULT_MESSAGE[501] = 'Not Implemented' DEFAULT_MESSAGE[501] = 'Not Implemented'
def abort(status_code, message=None, issue=None, **kwargs): def abort(status_code, message=None, issue=None, headers=None, **kwargs):
message = (str(message) % kwargs if message else message = (str(message) % kwargs if message else
DEFAULT_MESSAGE.get(status_code, '')) DEFAULT_MESSAGE.get(status_code, ''))
@ -53,8 +54,7 @@ def abort(status_code, message=None, issue=None, **kwargs):
if issue_url: if issue_url:
data['info_url'] = issue_url data['info_url'] = issue_url
resp = jsonify(data) resp = make_response(json.dumps(data), status_code, headers)
resp.status_code = status_code
# Report the abort to the user. # Report the abort to the user.
flask_abort(resp) flask_abort(resp)