Allow Blender Sync access to non-subscribers.
This commit is contained in:
@@ -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
|
||||||
|
@@ -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)
|
||||||
|
|
||||||
|
Reference in New Issue
Block a user