diff --git a/pillar/application/modules/blender_cloud/home_project.py b/pillar/application/modules/blender_cloud/home_project.py index 40579763..aa1319ea 100644 --- a/pillar/application/modules/blender_cloud/home_project.py +++ b/pillar/application/modules/blender_cloud/home_project.py @@ -1,6 +1,8 @@ +import copy import logging from bson import ObjectId +from eve.methods.post import post_internal from eve.methods.put import put_internal from eve.methods.get import get from flask import Blueprint, g, current_app, request @@ -14,11 +16,68 @@ blueprint = Blueprint('blender_cloud.home_project', __name__) log = logging.getLogger(__name__) # Users with any of these roles will get a home project. -HOME_PROJECT_USERS = {u'subscriber', u'demo'} +HOME_PROJECT_USERS = set() + +# Users with any of these roles will get full write access to their home project. +HOME_PROJECT_WRITABLE_USERS = {u'subscriber', u'demo'} +SYNC_GROUP_NODE_NAME = u'Blender Sync' +SYNC_GROUP_NODE_DESC = 'The [Blender Cloud Addon](https://cloud.blender.org/services' \ + '#blender-addon) will synchronize your Blender settings here.' -def create_home_project(user_id): - """Creates a home project for the given user.""" +def create_blender_sync_node(project_id, admin_group_id, user_id): + """Creates a node for Blender Sync, with explicit write access for the admin group. + + Writes the node to the database. + + :param project_id: ID of the home project + :type project_id: ObjectId + :param admin_group_id: ID of the admin group of the project. This group will + receive write access to the node. + :type admin_group_id: ObjectId + :param user_id: ID of the owner of the node. + :type user_id: ObjectId + + :returns: The created node. + :rtype: dict + """ + + log.debug('Creating sync node for project %s, user %s', project_id, user_id) + + node = { + 'project': ObjectId(project_id), + 'node_type': 'group', + 'name': SYNC_GROUP_NODE_NAME, + 'user': ObjectId(user_id), + 'description': SYNC_GROUP_NODE_DESC, + 'properties': {'status': 'published'}, + 'permissions': { + 'users': [], + 'groups': [ + {'group': ObjectId(admin_group_id), + 'methods': ['GET', 'PUT', 'POST', 'DELETE']} + ], + 'world': [], + } + } + + r, _, _, status = post_internal('nodes', node) + if status != 201: + log.warning('Unable to create Blender Sync node for home project %s: %s', + project_id, r) + raise wz_exceptions.InternalServerError('Unable to create Blender Sync node') + + node.update(r) + return node + + +def create_home_project(user_id, write_access): + """Creates a home project for the given user. + + :param user_id: the user ID of the owner + :param write_access: whether the user has full write access to the home project. + :type write_access: bool + """ log.info('Creating home project for user %s', user_id) overrides = { @@ -55,13 +114,18 @@ def create_home_project(user_id): # as the inherited project permissions are fine. from manage_extra.node_types.group import node_type_group from manage_extra.node_types.asset import node_type_asset - from manage_extra.node_types.text import node_type_text + # from manage_extra.node_types.text import node_type_text from manage_extra.node_types.comment import node_type_comment + if not write_access: + # Take away write access from the admin group, and grant it to + # certain node types. + project['permissions']['groups'][0]['methods'] = ['GET'] + project['node_types'] = [ node_type_group, node_type_asset, - node_type_text, + # node_type_text, node_type_comment, ] @@ -73,12 +137,17 @@ def create_home_project(user_id): raise wz_exceptions.InternalServerError('Unable to update home project') project.update(result) + # Create the Blender Sync node, with explicit write permissions on the node itself. + create_blender_sync_node(project['_id'], + project['permissions']['groups'][0]['group'], + user_id) + return project @blueprint.route('/home-project') @authorization.ab_testing(require_roles={u'homeproject'}) -@authorization.require_login(require_roles={u'subscriber', u'demo'}) +@authorization.require_login() def home_project(): """Fetches the home project, creating it if necessary. @@ -89,14 +158,16 @@ def home_project(): roles = g.current_user.get('roles', ()) log.debug('Possibly creating home project for user %s with roles %s', user_id, roles) - if not HOME_PROJECT_USERS.intersection(roles): + if HOME_PROJECT_USERS and not HOME_PROJECT_USERS.intersection(roles): log.debug('User %s is not a subscriber, not creating home project.', user_id) return 'No home project', 404 # Create the home project before we do the Eve query. This costs an extra round-trip # to the database, but makes it easier to do projections correctly. if not has_home_project(user_id): - create_home_project(user_id) + write_access = bool(not HOME_PROJECT_WRITABLE_USERS or + HOME_PROJECT_WRITABLE_USERS.intersection(roles)) + create_home_project(user_id, write_access) resp, _, _, status, _ = get('projects', category=u'home', user=user_id) if status != 200: @@ -120,5 +191,72 @@ def has_home_project(user_id): return proj_coll.count({'user': user_id, 'category': 'home', '_deleted': False}) > 0 +def is_home_project(project_id, user_id): + """Returns True iff the given project exists and is the user's home project.""" + + proj_coll = current_app.data.driver.db['projects'] + return proj_coll.count({'_id': project_id, + 'user': user_id, + 'category': 'home', + '_deleted': False}) > 0 + + +def check_home_project_nodes_permissions(nodes): + for node in nodes: + check_home_project_node_permissions(node) + + +def check_home_project_node_permissions(node): + """Grants POST access to the node when the user has POST access on its parent.""" + + user_id = authentication.current_user_id() + if not user_id: + log.debug('check_home_project_node_permissions: user not logged in.') + return + + parent_id = node.get('parent') + if not parent_id: + log.debug('check_home_project_node_permissions: not checking for top-level node.') + return + + project_id = node.get('project') + if not project_id: + log.debug('check_home_project_node_permissions: ignoring node without project ID') + return + + project_id = ObjectId(project_id) + if not is_home_project(project_id, user_id): + log.debug('check_home_project_node_permissions: node not part of home project.') + return + + # Get the parent node for permission checking. + parent_id = ObjectId(parent_id) + nodes_coll = current_app.data.driver.db['nodes'] + parent_node = nodes_coll.find_one(parent_id, + projection={'permissions': 1, + 'project': 1, + 'node_type': 1}) + if parent_node['project'] != project_id: + log.warning('check_home_project_node_permissions: User %s is trying to reference ' + 'parent node %s from different project %s, expected project %s.', + user_id, parent_id, parent_node['project'], project_id) + raise wz_exceptions.BadRequest('Trying to create cross-project links.') + + has_access = authorization.has_permissions('nodes', parent_node, 'POST') + if not has_access: + log.debug('check_home_project_node_permissions: No POST access to parent node %s, ' + 'ignoring.', parent_id) + return + + # Grant access! + log.debug('check_home_project_node_permissions: POST access at parent node %s, ' + 'so granting POST access to new child node.', parent_id) + + # Make sure the permissions of the parent node are copied to this node. + node['permissions'] = copy.deepcopy(parent_node['permissions']) + + def setup_app(app, url_prefix): app.register_blueprint(blueprint, url_prefix=url_prefix) + + app.on_insert_nodes += check_home_project_nodes_permissions diff --git a/tests/test_bcloud_home_project.py b/tests/test_bcloud_home_project.py index aae5c0d3..7bc4d9ee 100644 --- a/tests/test_bcloud_home_project.py +++ b/tests/test_bcloud_home_project.py @@ -2,14 +2,12 @@ """Unit tests for the Blender Cloud home project module.""" -import functools import json import logging -import urllib import responses from bson import ObjectId -from flask import g, url_for +from flask import url_for from common_test_class import AbstractPillarTest, TEST_EMAIL_ADDRESS @@ -22,7 +20,10 @@ class HomeProjectTest(AbstractPillarTest): self.create_standard_groups() def _create_user_with_token(self, roles, token, user_id='cafef00df00df00df00df00d'): - """Creates a user directly in MongoDB, so that it doesn't trigger any Eve hooks.""" + """Creates a user directly in MongoDB, so that it doesn't trigger any Eve hooks. + + Adds the 'homeproject' role too, which we need to get past the AB-testing. + """ user_id = self.create_user(roles=roles.union({u'homeproject'}), user_id=user_id) self.create_valid_auth_token(user_id, token) return user_id @@ -37,9 +38,9 @@ class HomeProjectTest(AbstractPillarTest): with self.app.test_request_context(headers={'Authorization': self.make_header('token')}): validate_token() - proj = home_project.create_home_project(user_id) + proj = home_project.create_home_project(user_id, write_access=True) self.assertEqual('home', proj['category']) - self.assertEqual({u'text', u'group', u'asset', u'comment'}, + self.assertEqual({u'group', u'asset', u'comment'}, set(nt['name'] for nt in proj['node_types'])) endpoint = url_for('blender_cloud.home_project.home_project') @@ -75,6 +76,16 @@ class HomeProjectTest(AbstractPillarTest): json_proj = json.loads(resp.data) self.assertEqual('home', json_proj['category']) + # Check that a Blender Sync node was created automatically. + with self.app.test_request_context(headers={'Authorization': self.make_header('token')}): + nodes_coll = self.app.data.driver.db['nodes'] + node = nodes_coll.find_one({ + 'project': ObjectId(json_proj['_id']), + 'node_type': 'group', + 'name': 'Blender Sync', + }) + self.assertIsNotNone(node) + @responses.activate def test_home_project_ab_testing(self): self.mock_blenderid_validate_happy() @@ -114,19 +125,84 @@ class HomeProjectTest(AbstractPillarTest): json_proj = json.loads(resp.data) self.assertEqual('home', json_proj['category']) + # Check that a Blender Sync node was created automatically. + with self.app.test_request_context(headers={'Authorization': self.make_header('token')}): + nodes_coll = self.app.data.driver.db['nodes'] + node = nodes_coll.find_one({ + 'project': ObjectId(json_proj['_id']), + 'node_type': 'group', + 'name': 'Blender Sync', + }) + self.assertIsNotNone(node) + @responses.activate def test_autocreate_home_project_with_succubus_role(self): + from application.utils import dumps + # Implicitly create user by token validation. self.mock_blenderid_validate_happy() resp = self.client.get('/users/me', headers={'Authorization': self.make_header('token')}) - self.assertEqual(200, resp.status_code, resp) + self.assertEqual(200, resp.status_code, resp.data) + user_id = ObjectId(json.loads(resp.data)['_id']) - # Grant succubus role, which should NOT allow creation fo the home project. + # Grant succubus role, which should allow creation of a read-only home project. self.badger(TEST_EMAIL_ADDRESS, {'succubus', 'homeproject'}, 'grant') resp = self.client.get('/bcloud/home-project', headers={'Authorization': self.make_header('token')}) - self.assertEqual(403, resp.status_code) + self.assertEqual(200, resp.status_code) + json_proj = json.loads(resp.data) + self.assertEqual('home', json_proj['category']) + + # Check that the admin group of the project only has GET permissions. + self.assertEqual({'GET'}, set(json_proj['permissions']['groups'][0]['methods'])) + project_id = ObjectId(json_proj['_id']) + admin_group_id = json_proj['permissions']['groups'][0]['group'] + + # Check that a Blender Sync node was created automatically. + expected_node_permissions = {u'users': [], + u'groups': [ + {u'group': ObjectId(admin_group_id), + u'methods': [u'GET', u'PUT', u'POST', u'DELETE']}, ], + u'world': []} + with self.app.test_request_context(headers={'Authorization': self.make_header('token')}): + nodes_coll = self.app.data.driver.db['nodes'] + node = nodes_coll.find_one({ + 'project': project_id, + 'node_type': 'group', + 'name': 'Blender Sync', + }) + self.assertIsNotNone(node) + + # Check that the node itself has write permissions for the admin group. + node_perms = node['permissions'] + self.assertEqual(node_perms, expected_node_permissions) + sync_node_id = node['_id'] + + # Check that we can create a group node inside the sync node. + sub_sync_node = {'project': project_id, + 'node_type': 'group', + 'parent': sync_node_id, + 'name': '2.77', + 'user': user_id, + 'description': 'Sync folder for Blender 2.77', + 'properties': {'status': 'published'}, + } + resp = self.client.post('/nodes', data=dumps(sub_sync_node), + headers={'Authorization': self.make_header('token'), + 'Content-Type': 'application/json'} + ) + self.assertEqual(201, resp.status_code, resp.data) + sub_node_info = json.loads(resp.data) + + # Check the explicit node-level permissions are copied. + # These aren't returned by the POST to Eve, so we have to check them in the DB manually. + with self.app.test_request_context(headers={'Authorization': self.make_header('token')}): + nodes_coll = self.app.data.driver.db['nodes'] + sub_node = nodes_coll.find_one(ObjectId(sub_node_info['_id'])) + + node_perms = sub_node['permissions'] + self.assertEqual(node_perms, expected_node_permissions) def test_has_home_project(self): from application.modules.blender_cloud import home_project @@ -139,7 +215,7 @@ class HomeProjectTest(AbstractPillarTest): validate_token() self.assertFalse(home_project.has_home_project(user_id)) - proj = home_project.create_home_project(user_id) + proj = home_project.create_home_project(user_id, write_access=True) self.assertTrue(home_project.has_home_project(user_id)) # Delete the project. @@ -204,17 +280,19 @@ class HomeProjectTest(AbstractPillarTest): # Create home projects with self.app.test_request_context(headers={'Authorization': self.make_header('token1')}): validate_token() - proj1 = home_project.create_home_project(uid1) + proj1 = home_project.create_home_project(uid1, write_access=True) db_proj1 = self.app.data.driver.db['projects'].find_one(proj1['_id']) with self.app.test_request_context(headers={'Authorization': self.make_header('token2')}): validate_token() - proj2 = home_project.create_home_project(uid2) + proj2 = home_project.create_home_project(uid2, write_access=True) db_proj2 = self.app.data.driver.db['projects'].find_one(proj2['_id']) # Test availability at end-point - resp1 = self.client.get('/bcloud/home-project', headers={'Authorization': self.make_header('token1')}) - resp2 = self.client.get('/bcloud/home-project', headers={'Authorization': self.make_header('token2')}) + resp1 = self.client.get('/bcloud/home-project', + headers={'Authorization': self.make_header('token1')}) + resp2 = self.client.get('/bcloud/home-project', + headers={'Authorization': self.make_header('token2')}) self.assertEqual(200, resp1.status_code) self.assertEqual(200, resp2.status_code)