From 4b9dd29ad5169499a23af86a2abd215f26d9983a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sybren=20A=2E=20St=C3=BCvel?= Date: Tue, 19 Apr 2016 16:00:32 +0200 Subject: [PATCH] Added /p/create entry point to create new projects. This requires the user to be logged in. The project will be owned by that user. --- pillar/application/__init__.py | 5 +- pillar/application/modules/projects.py | 188 +++++++++++++++------ pillar/application/utils/__init__.py | 2 - pillar/application/utils/authentication.py | 3 +- pillar/application/utils/authorization.py | 26 +++ pillar/settings.py | 22 --- tests/common_test_class.py | 16 +- tests/common_test_data.py | 2 +- tests/test_auth.py | 65 +++++++ tests/test_project_management.py | 65 +++++++ 10 files changed, 311 insertions(+), 83 deletions(-) create mode 100644 tests/test_project_management.py diff --git a/pillar/application/__init__.py b/pillar/application/__init__.py index 061c96db..e3086f7e 100644 --- a/pillar/application/__init__.py +++ b/pillar/application/__init__.py @@ -354,9 +354,6 @@ app.on_inserted_nodes += after_inserting_nodes app.on_fetched_item_projects += before_returning_item_permissions app.on_fetched_item_projects += project_node_type_has_method app.on_fetched_resource_projects += before_returning_resource_permissions -# Projects hooks -app.on_insert_projects += before_inserting_projects -app.on_inserted_projects += after_inserting_projects def post_GET_user(request, payload): @@ -389,6 +386,8 @@ file_storage.setup_app(app, url_prefix='/storage') # The encoding module (receive notification and report progress) from modules.encoding import encoding from modules.blender_id import blender_id +from modules import projects app.register_blueprint(encoding, url_prefix='/encoding') app.register_blueprint(blender_id, url_prefix='/blender_id') +projects.setup_app(app, url_prefix='/p') diff --git a/pillar/application/modules/projects.py b/pillar/application/modules/projects.py index 3c74be21..4561c44d 100644 --- a/pillar/application/modules/projects.py +++ b/pillar/application/modules/projects.py @@ -1,17 +1,22 @@ import logging -from flask import g -from flask import abort -from eve.methods.put import put_internal +import json + from eve.methods.post import post_internal -from application import app -from application.utils import remove_private_keys +from eve.methods.put import put_internal +from eve.methods.patch import patch_internal +from flask import g, Blueprint, request, abort, current_app + +from application.utils import remove_private_keys, authorization, PillarJSONEncoder from application.utils.gcs import GoogleCloudStorageBucket from manage_extra.node_types.asset import node_type_asset +from manage_extra.node_types.blog import node_type_blog +from manage_extra.node_types.comment import node_type_comment from manage_extra.node_types.group import node_type_group from manage_extra.node_types.page import node_type_page -from manage_extra.node_types.comment import node_type_comment +from manage_extra.node_types.post import node_type_post log = logging.getLogger(__name__) +blueprint = Blueprint('projects', __name__) def before_inserting_projects(items): @@ -34,52 +39,131 @@ def after_inserting_projects(items): :param items: List of project docs that have been inserted (normally one) """ current_user = g.current_user - users_collection = app.data.driver.db['users'] - user = users_collection.find_one({'_id': current_user['user_id']}) + users_collection = current_app.data.driver.db['users'] + user = users_collection.find_one(current_user['user_id']) for item in items: - # Create a project specific group (with name matching the project id) - project_group = dict(name=str(item['_id'])) - group = post_internal('groups', project_group) - # If Group creation failed, stop - # TODO: undo project creation - if group[3] != 201: - abort(group[3]) - else: - group = group[0] - # Assign the current user to the group - if 'groups' in user: - user['groups'].append(group['_id']) - else: - user['groups'] = [group['_id']] - put_internal('users', remove_private_keys(user), _id=user['_id']) - # Assign the group to the project with admin rights - permissions = dict( - world=['GET'], - users=[], - groups=[ - dict(group=group['_id'], - methods=['GET', 'PUT', 'POST']) - ] - ) - # Assign permissions to the project itself, as well as to the node_types - item['permissions'] = permissions - node_type_asset['permissions'] = permissions - node_type_group['permissions'] = permissions - node_type_page['permissions'] = permissions - node_type_comment['permissions'] = permissions - # Assign the basic 'group', 'asset' and 'page' node_types - item['node_types'] = [ - node_type_group, - node_type_asset, - node_type_page, - node_type_comment] - # TODO: Depending on user role or status, assign the url attribute - # Initialize storage page (defaults to GCS) - gcs_storage = GoogleCloudStorageBucket(str(item['_id'])) + after_inserting_project(item, user) + + +def after_inserting_project(project, db_user): + project_id = project['_id'] + user_id = db_user['_id'] + + # Create a project-specific admin group (with name matching the project id) + result, _, _, status = post_internal('groups', {'name': str(project_id)}) + if status != 201: + log.error('Unable to create admin group for new project %s: %s', + project_id, result) + return abort_with_error(status) + + admin_group_id = result['_id'] + log.info('Created admin group %s for project %s', admin_group_id, project_id) + + # Assign the current user to the group + db_user.setdefault('groups', []).append(admin_group_id) + + result, _, _, status = patch_internal('users', {'groups': db_user['groups']}, _id=user_id) + if status != 200: + log.error('Unable to add user %s as member of admin group %s for new project %s: %s', + user_id, admin_group_id, project_id, result) + return abort_with_error(status) + log.debug('Made user %s member of group %s', user_id, admin_group_id) + + # Assign the group to the project with admin rights + permissions = { + 'world': ['GET'], + 'users': [], + 'groups': [ + {'group': admin_group_id, + 'methods': ['GET', 'PUT', 'POST']}, + ] + } + + # Assign permissions to the project itself, as well as to the node_types + project['permissions'] = permissions + node_type_asset['permissions'] = permissions + node_type_group['permissions'] = permissions + node_type_page['permissions'] = permissions + node_type_comment['permissions'] = permissions + + # Assign the basic 'group', 'asset' and 'page' node_types + project['node_types'] = [ + node_type_group, + node_type_asset, + node_type_page, + node_type_comment] + project['url'] = "p-{!s}".format(project_id) + + # Initialize storage page (defaults to GCS) + if current_app.config.get('TESTING'): + log.warning('Not creating Google Cloud Storage bucket while running unit tests!') + else: + gcs_storage = GoogleCloudStorageBucket(str(project_id)) if gcs_storage.bucket.exists(): - log.debug("Created CGS bucket {0}".format(item['_id'])) - # Assign a url based on the project id - item['url'] = "p-{}".format(item['_id']) - # Commit the changes - put_internal('projects', remove_private_keys(item), _id=item['_id']) + log.info('Created CGS instance for project %s', project_id) + else: + log.warning('Unable to create CGS instance for project %s', project_id) + + # Commit the changes + result, _, _, status = put_internal('projects', remove_private_keys(project), _id=project_id) + if status != 200: + log.warning('Unable to update project %s: %s', project_id, result) + abort_with_error(status) + + +@blueprint.route('/create', methods=['POST']) +@authorization.require_login(require_roles={'admin', 'subscriber'}) +def create_project(): + """Creates a new project.""" + + project_name = request.form['project_name'] + user_id = g.current_user['user_id'] + + log.info('Creating new project "%s" for user %s', project_name, user_id) + + # Create the project itself, the rest will be done by the after-insert hook. + project = {'description': '', + 'name': project_name, + 'node_types': [node_type_blog, + node_type_post, + node_type_comment], + 'status': 'published', + 'user': user_id, + 'is_private': True, + 'permissions': {}, + 'url': '', + 'summary': '', + 'category': 'assets', # TODO: allow the user to choose this. + } + + result, _, _, status = post_internal('projects', project) + if status != 201: + log.error('Unable to create project "%s": %s', project_name, result) + return abort_with_error(status) + project.update(result) + + project_id = result['_id'] + log.info('Created project %s for user %s', project_id, user_id) + + # Return the project in the response. + resp = current_app.response_class(json.dumps(project, cls=PillarJSONEncoder), + mimetype='application/json', + status=201, + headers={'Location': '/projects/%s' % project_id}) + return resp + + +def abort_with_error(status): + """Aborts with the given status, or 500 if the status doesn't indicate an error. + + If the status is < 400, status 500 is used instead. + """ + + abort(status if status // 100 >= 4 else 500) + + +def setup_app(app, url_prefix): + app.on_insert_projects += before_inserting_projects + app.on_inserted_projects += after_inserting_projects + app.register_blueprint(blueprint, url_prefix=url_prefix) diff --git a/pillar/application/utils/__init__.py b/pillar/application/utils/__init__.py index 02cdccc1..416a8701 100644 --- a/pillar/application/utils/__init__.py +++ b/pillar/application/utils/__init__.py @@ -25,8 +25,6 @@ class PillarJSONEncoder(json.JSONEncoder): def default(self, obj): if isinstance(obj, datetime.datetime): - if obj.tzinfo is None: - raise ValueError('All datetime.datetime objects should be timezone-aware.') return obj.strftime(RFC1123_DATE_FORMAT) if isinstance(obj, bson.ObjectId): diff --git a/pillar/application/utils/authentication.py b/pillar/application/utils/authentication.py index a3b979d3..a3629ee8 100644 --- a/pillar/application/utils/authentication.py +++ b/pillar/application/utils/authentication.py @@ -63,7 +63,8 @@ def validate_token(): return False g.current_user = {'user_id': db_user['_id'], - 'groups': db_user['groups']} + 'groups': db_user['groups'], + 'roles': set(db_user.get('roles', []))} return True diff --git a/pillar/application/utils/authorization.py b/pillar/application/utils/authorization.py index f160374c..a3b0b07a 100644 --- a/pillar/application/utils/authorization.py +++ b/pillar/application/utils/authorization.py @@ -1,4 +1,5 @@ import logging +import functools from flask import g from flask import request @@ -83,3 +84,28 @@ def check_permissions(resource, method, append_allowed_methods=False): return abort(403) + + +def require_login(require_roles=set()): + """Decorator that enforces users to authenticate. + + Optionally only allows access to users with a certain role./ + """ + + def decorator(func): + @functools.wraps(func) + def wrapper(*args, **kwargs): + current_user = g.get('current_user') + + if current_user is None: + log.warning('Unauthenticated acces to %s attempted.', func) + abort(403) + + if require_roles and not require_roles.intersection(set(current_user['roles'])): + 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) + + return func(*args, **kwargs) + return wrapper + return decorator diff --git a/pillar/settings.py b/pillar/settings.py index 83cc315d..dd4024c7 100644 --- a/pillar/settings.py +++ b/pillar/settings.py @@ -560,28 +560,6 @@ projects_schema = { 'embeddable': True }, }, - 'owners': { - 'type': 'dict', - 'schema': { - 'users': { - 'type': 'list', - 'schema': { - 'type': 'objectid', - } - }, - 'groups': { - 'type': 'list', - 'schema': { - 'type': 'objectid', - 'data_relation': { - 'resource': 'groups', - 'field': '_id', - 'embeddable': True - } - } - } - } - }, 'status': { 'type': 'string', 'allowed': [ diff --git a/tests/common_test_class.py b/tests/common_test_class.py index f53f8ae8..a51e1e32 100644 --- a/tests/common_test_class.py +++ b/tests/common_test_class.py @@ -46,6 +46,7 @@ class AbstractPillarTest(TestMinimal): from application import app + logging.getLogger('').setLevel(logging.DEBUG) logging.getLogger('application').setLevel(logging.DEBUG) logging.getLogger('werkzeug').setLevel(logging.DEBUG) logging.getLogger('eve').setLevel(logging.DEBUG) @@ -95,7 +96,7 @@ class AbstractPillarTest(TestMinimal): return found['_id'], found - def create_user(self): + def create_user(self, roles=('subscriber', )): with self.app.test_request_context(): users = self.app.data.driver.db['users'] assert isinstance(users, pymongo.collection.Collection) @@ -106,7 +107,7 @@ class AbstractPillarTest(TestMinimal): '_created': datetime.datetime(2016, 4, 15, 13, 15, 11, tzinfo=tz_util.utc), 'username': 'tester', 'groups': [], - 'roles': ['subscriber'], + 'roles': list(roles), 'settings': {'email_communications': 1}, 'auth': [{'token': '', 'user_id': unicode(BLENDER_ID_TEST_USERID), @@ -117,6 +118,17 @@ class AbstractPillarTest(TestMinimal): return result.inserted_id + def create_valid_auth_token(self, user_id, token='token'): + now = datetime.datetime.now(tz_util.utc) + future = now + datetime.timedelta(days=1) + + with self.app.test_request_context(): + from application.utils import authentication as auth + + token_data = auth.store_token(user_id, token, future, None) + + return token_data + def mock_blenderid_validate_unhappy(self): """Sets up Responses to mock unhappy validation flow.""" diff --git a/tests/common_test_data.py b/tests/common_test_data.py index 1c693d3e..aedf0d8a 100644 --- a/tests/common_test_data.py +++ b/tests/common_test_data.py @@ -280,4 +280,4 @@ EXAMPLE_PROJECT = { u'status': u'published', u'summary': u'Texture collection from all Blender Institute open projects.', u'url': u'textures', - u'user': ObjectId('552b066b41acdf5dec4436f2')} \ No newline at end of file + u'user': ObjectId('552b066b41acdf5dec4436f2')} diff --git a/tests/test_auth.py b/tests/test_auth.py index 32691b10..eef250a8 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -1,5 +1,6 @@ import datetime import responses +import json from bson import tz_util from common_test_class import AbstractPillarTest, TEST_EMAIL_USER, TEST_EMAIL_ADDRESS @@ -93,3 +94,67 @@ class AuthenticationTests(AbstractPillarTest): found_token = auth.find_token('expired-sub', subclient) self.assertIsNotNone(found_token) self.assertNotEqual(token3['_id'], found_token['_id']) + + @responses.activate + def test_save_own_user(self): + """Tests that a user can't change their own fields.""" + + from application.utils import authentication as auth + from application.utils import PillarJSONEncoder, remove_private_keys + + user_id = self.create_user(roles=[u'subscriber']) + + now = datetime.datetime.now(tz_util.utc) + future = now + datetime.timedelta(days=1) + + with self.app.test_request_context(): + auth.store_token(user_id, 'nonexpired-main', future, None) + + with self.app.test_request_context( + headers={'Authorization': self.make_header('nonexpired-main')}): + self.assertTrue(auth.validate_token()) + + users = self.app.data.driver.db['users'] + db_user = users.find_one(user_id) + + updated_fields = remove_private_keys(db_user) + updated_fields['roles'] = ['admin', 'subscriber', 'demo'] # Try to elevate our roles. + + # POSTing updated info to a specific user URL is not allowed by Eve. + resp = self.client.post('/users/%s' % user_id, + data=json.dumps(updated_fields, cls=PillarJSONEncoder), + headers={'Authorization': self.make_header('nonexpired-main'), + 'Content-Type': 'application/json'}) + self.assertEqual(405, resp.status_code) + + # POSTing with our _id to update shouldn't work either, as POST always creates new users. + updated_fields_with_id = dict(_id=user_id, **updated_fields) + resp = self.client.post('/users', + data=json.dumps(updated_fields_with_id, cls=PillarJSONEncoder), + headers={'Authorization': self.make_header('nonexpired-main'), + 'Content-Type': 'application/json'}) + self.assertEqual(422, resp.status_code) + + # PUT and PATCH should not be allowed. + resp = self.client.put('/users/%s' % user_id, + data=json.dumps(updated_fields, cls=PillarJSONEncoder), + headers={'Authorization': self.make_header('nonexpired-main'), + 'Content-Type': 'application/json'}) + self.assertEqual(403, resp.status_code) + + updated_fields = {'roles': ['admin', 'subscriber', 'demo']} + resp = self.client.patch('/users/%s' % user_id, + data=json.dumps(updated_fields, cls=PillarJSONEncoder), + headers={'Authorization': self.make_header('nonexpired-main'), + 'Content-Type': 'application/json'}) + self.assertEqual(403, resp.status_code) + + # After all of this, the roles should be the same. + with self.app.test_request_context( + headers={'Authorization': self.make_header('nonexpired-main')}): + self.assertTrue(auth.validate_token()) + + users = self.app.data.driver.db['users'] + db_user = users.find_one(user_id) + + self.assertEqual([u'subscriber'], db_user['roles']) diff --git a/tests/test_project_management.py b/tests/test_project_management.py new file mode 100644 index 00000000..af06de36 --- /dev/null +++ b/tests/test_project_management.py @@ -0,0 +1,65 @@ +# -*- encoding: utf-8 -*- + +"""Unit tests for creating and editing projects_blueprint.""" + +import json + +import responses +from bson import ObjectId + +from common_test_class import AbstractPillarTest + + +class ProjectCreationTest(AbstractPillarTest): + @responses.activate + def test_project_creation_wrong_role(self): + user_id = self.create_user(roles=[u'whatever']) + self.create_valid_auth_token(user_id, 'token') + + resp = self.client.post('/p/create', + headers={'Authorization': self.make_header('token')}, + data={'project_name': u'Prøject El Niño'}) + + self.assertEqual(403, resp.status_code) + + # Test that the project wasn't created. + with self.app.test_request_context(): + projects = self.app.data.driver.db['projects'] + self.assertEqual(0, len(list(projects.find()))) + + @responses.activate + def test_project_creation_good_role(self): + user_id = self.create_user(roles=[u'subscriber']) + self.create_valid_auth_token(user_id, 'token') + + resp = self.client.post('/p/create', + headers={'Authorization': self.make_header('token')}, + data={'project_name': u'Prøject El Niñö'}) + + self.assertEqual(201, resp.status_code) + project = json.loads(resp.data.decode('utf-8')) + project_id = project['_id'] + + # Test that the Location header contains the location of the project document. + self.assertEqual('http://localhost/projects/%s' % project_id, + resp.headers['Location']) + + # Check some of the more complex/interesting fields. + self.assertEqual(u'Prøject El Niñö', project['name']) + self.assertEqual(str(user_id), project['user']) + self.assertEqual('p-%s' % project_id, project['url']) + self.assertEqual(1, len(project['permissions']['groups'])) + + group_id = ObjectId(project['permissions']['groups'][0]['group']) + + # Check that there is a group for the project, and that the user is member of it. + with self.app.test_request_context(): + groups = self.app.data.driver.db['groups'] + users = self.app.data.driver.db['users'] + + group = groups.find_one(group_id) + db_user = users.find_one(user_id) + + self.assertEqual(str(project_id), group['name']) + self.assertIn(group_id, db_user['groups']) +