diff --git a/pillar/application/__init__.py b/pillar/application/__init__.py index a23b446d..edc68dc2 100644 --- a/pillar/application/__init__.py +++ b/pillar/application/__init__.py @@ -196,6 +196,7 @@ from modules import users from modules import nodes from modules import latest from modules import blender_cloud +from modules import service app.register_blueprint(encoding, url_prefix='/encoding') app.register_blueprint(blender_id, url_prefix='/blender_id') @@ -205,4 +206,5 @@ file_storage.setup_app(app, url_prefix='/storage') latest.setup_app(app, url_prefix='/latest') blender_cloud.setup_app(app, url_prefix='/bcloud') users.setup_app(app, url_prefix='/users') +service.setup_app(app, url_prefix='/service') nodes.setup_app(app) diff --git a/pillar/application/modules/service.py b/pillar/application/modules/service.py new file mode 100644 index 00000000..01f14dbc --- /dev/null +++ b/pillar/application/modules/service.py @@ -0,0 +1,102 @@ +"""Service accounts.""" + +import logging + +from bson import ObjectId +from flask import Blueprint, current_app, g, request +from werkzeug import exceptions as wz_exceptions + +from application.utils import authorization +from application.modules import local_auth + +blueprint = Blueprint('service', __name__) +log = logging.getLogger(__name__) + + +@blueprint.route('/badger', methods=['POST']) +@authorization.require_login(require_roles={u'service', u'badger'}, require_all=True) +def badger(): + if request.mimetype != 'application/json': + raise wz_exceptions.BadRequest() + + # Parse the request + args = request.json + action = args['action'] + user_email = args['user_email'] + role = args['role'] + + if action not in {'grant', 'revoke'}: + raise wz_exceptions.BadRequest('Action %r not supported' % action) + + log.info('Service account %s %ss role %r to/from user %s', + g.current_user['user_id'], action, role, user_email) + + users_coll = current_app.data.driver.db['users'] + + # Check that the user is allowed to grant this role. + srv_user = users_coll.find_one(g.current_user['user_id'], + projection={'service.badger': 1}) + if srv_user is None: + log.error('badger(%s, %s, %s): current user %s not found -- how did they log in?', + action, user_email, role, g.current_user['user_id']) + return 'User not found', 403 + + allowed_roles = set(srv_user.get('service', {}).get('badger', [])) + if role not in allowed_roles: + log.warning('badger(%s, %s, %s): service account not authorized to %s role %s', + action, user_email, role, action, role) + return 'Role not allowed', 403 + + # Fetch the user + db_user = users_coll.find_one({'email': user_email}, projection={'roles': 1}) + if db_user is None: + log.warning('badger(%s, %s, %s): user not found', action, user_email, role) + return 'User not found', 404 + + # Apply the action + roles = set(db_user['roles'] or []) + if action == 'grant': + roles.add(role) + else: + roles.discard(role) + users_coll.update_one({'_id': db_user['_id']}, + {'$set': {'roles': list(roles)}}) + + return '', 204 + + +def create_service_account(email, roles, service): + """Creates a service account with the given roles + the role 'service'. + + :param email: email address associated with the account + :type email: str + :param roles: iterable of role names + :param service: dict of the 'service' key in the user. + :type service: dict + :return: tuple (user doc, token doc) + """ + from eve.methods.post import post_internal + + # Create a user with the correct roles. + roles = list(set(roles).union({u'service'})) + user = {'username': email, + 'groups': [], + 'roles': roles, + 'settings': {'email_communications': 0}, + 'auth': [], + 'full_name': email, + 'email': email, + 'service': service} + result, _, _, status = post_internal('users', user) + if status != 201: + raise SystemExit('Error creating user {}: {}'.format(email, result)) + user.update(result) + + # Create an authentication token that won't expire for a long time. + token = local_auth.generate_and_store_token(user['_id'], days=36500, prefix='SRV') + + return user, token + + +def setup_app(app, url_prefix): + app.register_blueprint(blueprint, url_prefix=url_prefix) diff --git a/pillar/application/utils/authorization.py b/pillar/application/utils/authorization.py index f3697fbf..7bd6242b 100644 --- a/pillar/application/utils/authorization.py +++ b/pillar/application/utils/authorization.py @@ -232,15 +232,26 @@ def merge_permissions(*args): return effective -def require_login(require_roles=set()): +def require_login(require_roles=set(), + require_all=False): """Decorator that enforces users to authenticate. - Optionally only allows access to users with a certain role./ + Optionally only allows access to users with a certain role. + + :param require_roles: set of roles. + :param require_all: + When False (the default): if the user's roles have a + non-empty intersection with the given roles, access is granted. + When True: require the user to have all given roles before access is + granted. """ if not isinstance(require_roles, set): raise TypeError('require_roles param should be a set, but is a %r' % type(require_roles)) + if require_all and not require_roles: + raise ValueError('require_login(require_all=True) cannot be used with empty require_roles.') + def decorator(func): @functools.wraps(func) def wrapper(*args, **kwargs): @@ -253,7 +264,13 @@ def require_login(require_roles=set()): log.debug('Unauthenticated acces to %s attempted.', func) abort(403) - if require_roles and not require_roles.intersection(set(current_user['roles'])): + intersection = require_roles.intersection(set(current_user['roles'])) + if require_all: + if intersection != require_roles: + log.warning('User %s does not have ALL required roles %s to access %s', + current_user['user_id'], require_roles, func) + abort(403) + elif require_roles and not intersection: log.warning('User %s is authenticated, but does not have any required role %s to ' 'access %s', current_user['user_id'], require_roles, func) abort(403) diff --git a/pillar/manage.py b/pillar/manage.py index 45727c61..4409458e 100755 --- a/pillar/manage.py +++ b/pillar/manage.py @@ -781,5 +781,31 @@ def update_texture_nodes_maps(): nodes_collection.update({'_id': node['_id']}, node) +@manager.command +def create_badger_account(email, badges): + """ + Creates a new service account that can give badges (i.e. roles). + + :param email: email address associated with the account + :param badges: single space-separated argument containing the roles + this account can assign and revoke. + """ + + from application.modules import service + from application.utils import dumps + + account, token = service.create_service_account( + email, + [u'badger'], + {'badger': badges.strip().split()} + ) + + print('Account created:') + print(dumps(account, indent=4, sort_keys=True)) + print() + print('Access token: %s' % token['token']) + print(' expires on: %s' % token['expire_time']) + + if __name__ == '__main__': manager.run() diff --git a/pillar/settings.py b/pillar/settings.py index 7c3c639c..a27b3c4e 100644 --- a/pillar/settings.py +++ b/pillar/settings.py @@ -62,7 +62,7 @@ users_schema = { }, 'roles': { 'type': 'list', - 'allowed': ["admin", "subscriber", "demo"], + 'schema': {'type': 'string'} }, 'groups': { 'type': 'list', @@ -107,6 +107,15 @@ users_schema = { 'allowed': [0, 1] } } + }, + 'service': { + 'type': 'dict', + 'schema': { + 'badger': { + 'type': 'list', + 'schema': {'type': 'string'} + } + } } } diff --git a/tests/test_auth.py b/tests/test_auth.py index 164b4fe7..76d396f0 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -497,3 +497,79 @@ class PermissionComputationTest(AbstractPillarTest): u'methods': [u'GET']}], u'world': [u'GET']}, self.sort(compute_aggr_permissions('nodes', node, None))) + + +class RequireRolesTest(AbstractPillarTest): + def test_no_roles_required(self): + from flask import g + from application.utils.authorization import require_login + + called = [False] + + @require_login() + def call_me(): + called[0] = True + + with self.app.test_request_context(): + g.current_user = {'user_id': ObjectId(24*'a'), + 'roles': [u'succubus']} + call_me() + + self.assertTrue(called[0]) + + def test_some_roles_required(self): + from flask import g + from application.utils.authorization import require_login + + called = [False] + + @require_login(require_roles={u'admin'}) + def call_me(): + called[0] = True + + with self.app.test_request_context(): + g.current_user = {'user_id': ObjectId(24*'a'), + 'roles': [u'succubus']} + self.assertRaises(Forbidden, call_me) + self.assertFalse(called[0]) + + with self.app.test_request_context(): + g.current_user = {'user_id': ObjectId(24*'a'), + 'roles': [u'admin']} + call_me() + self.assertTrue(called[0]) + + def test_all_roles_required(self): + from flask import g + from application.utils.authorization import require_login + + called = [False] + + @require_login(require_roles={u'service', u'badger'}, + require_all=True) + def call_me(): + called[0] = True + + with self.app.test_request_context(): + g.current_user = {'user_id': ObjectId(24*'a'), + 'roles': [u'admin']} + self.assertRaises(Forbidden, call_me) + self.assertFalse(called[0]) + + with self.app.test_request_context(): + g.current_user = {'user_id': ObjectId(24*'a'), + 'roles': [u'service']} + self.assertRaises(Forbidden, call_me) + self.assertFalse(called[0]) + + with self.app.test_request_context(): + g.current_user = {'user_id': ObjectId(24*'a'), + 'roles': [u'badger']} + self.assertRaises(Forbidden, call_me) + self.assertFalse(called[0]) + + with self.app.test_request_context(): + g.current_user = {'user_id': ObjectId(24*'a'), + 'roles': [u'service', u'badger']} + call_me() + self.assertTrue(called[0]) diff --git a/tests/test_service_badger.py b/tests/test_service_badger.py new file mode 100644 index 00000000..379fd3ed --- /dev/null +++ b/tests/test_service_badger.py @@ -0,0 +1,51 @@ +"""Test badger service.""" + +from common_test_class import AbstractPillarTest, TEST_EMAIL_ADDRESS + + +class BadgerServiceTest(AbstractPillarTest): + def setUp(self, **kwargs): + AbstractPillarTest.setUp(self, **kwargs) + + from application.modules import service + + with self.app.test_request_context(): + self.badger, token_doc = service.create_service_account( + 'serviceaccount@example.com', [u'badger'], {u'badger': [u'succubus']} + ) + self.badger_token = token_doc['token'] + + self.user_id = self.create_user() + self.user_email = TEST_EMAIL_ADDRESS + + def _post(self, data): + from application.utils import dumps + return self.client.post('/service/badger', + data=dumps(data), + headers={'Authorization': self.make_header(self.badger_token), + 'Content-Type': 'application/json'}) + + def test_grant_revoke_badge(self): + # Grant the badge + resp = self._post({'action': 'grant', 'user_email': self.user_email, 'role': 'succubus'}) + self.assertEqual(204, resp.status_code) + + with self.app.test_request_context(): + user = self.app.data.driver.db['users'].find_one(self.user_id) + self.assertIn(u'succubus', user['roles']) + + # Aaaahhhw it's gone again + resp = self._post({'action': 'revoke', 'user_email': self.user_email, 'role': 'succubus'}) + self.assertEqual(204, resp.status_code) + + with self.app.test_request_context(): + user = self.app.data.driver.db['users'].find_one(self.user_id) + self.assertNotIn(u'succubus', user['roles']) + + def test_grant_not_allowed_badge(self): + resp = self._post({'action': 'grant', 'user_email': self.user_email, 'role': 'admin'}) + self.assertEqual(403, resp.status_code) + + with self.app.test_request_context(): + user = self.app.data.driver.db['users'].find_one(self.user_id) + self.assertNotIn(u'admin', user['roles'])