Allow Blender Sync access to non-subscribers.

This commit is contained in:
2016-06-28 14:25:13 +02:00
parent 5e506abac9
commit 18c7ca17e9
2 changed files with 238 additions and 22 deletions

View File

@@ -1,6 +1,8 @@
import copy
import logging import logging
from bson import ObjectId from bson import ObjectId
from eve.methods.post import post_internal
from eve.methods.put import put_internal from eve.methods.put import put_internal
from eve.methods.get import get from eve.methods.get import get
from flask import Blueprint, g, current_app, request from flask import Blueprint, g, current_app, request
@@ -14,11 +16,68 @@ blueprint = Blueprint('blender_cloud.home_project', __name__)
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
# Users with any of these roles will get a home project. # 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): def create_blender_sync_node(project_id, admin_group_id, user_id):
"""Creates a home project for the given user.""" """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) log.info('Creating home project for user %s', user_id)
overrides = { overrides = {
@@ -55,13 +114,18 @@ def create_home_project(user_id):
# as the inherited project permissions are fine. # as the inherited project permissions are fine.
from manage_extra.node_types.group import node_type_group 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.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 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'] = [ project['node_types'] = [
node_type_group, node_type_group,
node_type_asset, node_type_asset,
node_type_text, # node_type_text,
node_type_comment, node_type_comment,
] ]
@@ -73,12 +137,17 @@ def create_home_project(user_id):
raise wz_exceptions.InternalServerError('Unable to update home project') raise wz_exceptions.InternalServerError('Unable to update home project')
project.update(result) 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 return project
@blueprint.route('/home-project') @blueprint.route('/home-project')
@authorization.ab_testing(require_roles={u'homeproject'}) @authorization.ab_testing(require_roles={u'homeproject'})
@authorization.require_login(require_roles={u'subscriber', u'demo'}) @authorization.require_login()
def home_project(): def home_project():
"""Fetches the home project, creating it if necessary. """Fetches the home project, creating it if necessary.
@@ -89,14 +158,16 @@ def home_project():
roles = g.current_user.get('roles', ()) roles = g.current_user.get('roles', ())
log.debug('Possibly creating home project for user %s with roles %s', user_id, 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) log.debug('User %s is not a subscriber, not creating home project.', user_id)
return 'No home project', 404 return 'No home project', 404
# Create the home project before we do the Eve query. This costs an extra round-trip # 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. # to the database, but makes it easier to do projections correctly.
if not has_home_project(user_id): 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) resp, _, _, status, _ = get('projects', category=u'home', user=user_id)
if status != 200: 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 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): def setup_app(app, url_prefix):
app.register_blueprint(blueprint, url_prefix=url_prefix) app.register_blueprint(blueprint, url_prefix=url_prefix)
app.on_insert_nodes += check_home_project_nodes_permissions

View File

@@ -2,14 +2,12 @@
"""Unit tests for the Blender Cloud home project module.""" """Unit tests for the Blender Cloud home project module."""
import functools
import json import json
import logging import logging
import urllib
import responses import responses
from bson import ObjectId from bson import ObjectId
from flask import g, url_for from flask import url_for
from common_test_class import AbstractPillarTest, TEST_EMAIL_ADDRESS from common_test_class import AbstractPillarTest, TEST_EMAIL_ADDRESS
@@ -22,7 +20,10 @@ class HomeProjectTest(AbstractPillarTest):
self.create_standard_groups() self.create_standard_groups()
def _create_user_with_token(self, roles, token, user_id='cafef00df00df00df00df00d'): 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) user_id = self.create_user(roles=roles.union({u'homeproject'}), user_id=user_id)
self.create_valid_auth_token(user_id, token) self.create_valid_auth_token(user_id, token)
return user_id return user_id
@@ -37,9 +38,9 @@ class HomeProjectTest(AbstractPillarTest):
with self.app.test_request_context(headers={'Authorization': self.make_header('token')}): with self.app.test_request_context(headers={'Authorization': self.make_header('token')}):
validate_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('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'])) set(nt['name'] for nt in proj['node_types']))
endpoint = url_for('blender_cloud.home_project.home_project') endpoint = url_for('blender_cloud.home_project.home_project')
@@ -75,6 +76,16 @@ class HomeProjectTest(AbstractPillarTest):
json_proj = json.loads(resp.data) json_proj = json.loads(resp.data)
self.assertEqual('home', json_proj['category']) 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 @responses.activate
def test_home_project_ab_testing(self): def test_home_project_ab_testing(self):
self.mock_blenderid_validate_happy() self.mock_blenderid_validate_happy()
@@ -114,19 +125,84 @@ class HomeProjectTest(AbstractPillarTest):
json_proj = json.loads(resp.data) json_proj = json.loads(resp.data)
self.assertEqual('home', json_proj['category']) 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 @responses.activate
def test_autocreate_home_project_with_succubus_role(self): def test_autocreate_home_project_with_succubus_role(self):
from application.utils import dumps
# Implicitly create user by token validation. # Implicitly create user by token validation.
self.mock_blenderid_validate_happy() self.mock_blenderid_validate_happy()
resp = self.client.get('/users/me', headers={'Authorization': self.make_header('token')}) 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') self.badger(TEST_EMAIL_ADDRESS, {'succubus', 'homeproject'}, 'grant')
resp = self.client.get('/bcloud/home-project', resp = self.client.get('/bcloud/home-project',
headers={'Authorization': self.make_header('token')}) 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): def test_has_home_project(self):
from application.modules.blender_cloud import home_project from application.modules.blender_cloud import home_project
@@ -139,7 +215,7 @@ class HomeProjectTest(AbstractPillarTest):
validate_token() validate_token()
self.assertFalse(home_project.has_home_project(user_id)) 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)) self.assertTrue(home_project.has_home_project(user_id))
# Delete the project. # Delete the project.
@@ -204,17 +280,19 @@ class HomeProjectTest(AbstractPillarTest):
# Create home projects # Create home projects
with self.app.test_request_context(headers={'Authorization': self.make_header('token1')}): with self.app.test_request_context(headers={'Authorization': self.make_header('token1')}):
validate_token() 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']) db_proj1 = self.app.data.driver.db['projects'].find_one(proj1['_id'])
with self.app.test_request_context(headers={'Authorization': self.make_header('token2')}): with self.app.test_request_context(headers={'Authorization': self.make_header('token2')}):
validate_token() 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']) db_proj2 = self.app.data.driver.db['projects'].find_one(proj2['_id'])
# Test availability at end-point # Test availability at end-point
resp1 = self.client.get('/bcloud/home-project', headers={'Authorization': self.make_header('token1')}) resp1 = self.client.get('/bcloud/home-project',
resp2 = self.client.get('/bcloud/home-project', headers={'Authorization': self.make_header('token2')}) 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, resp1.status_code)
self.assertEqual(200, resp2.status_code) self.assertEqual(200, resp2.status_code)