From 018ddfa20bed2db3556e2b1b5891de0496d310a3 Mon Sep 17 00:00:00 2001 From: Francesco Siddi Date: Sun, 11 Oct 2015 22:20:18 +0200 Subject: [PATCH] New authentication logic We are replacing the existing mixed BaseAuth TokenAuth authentication logic and permissions system with a more streamlined solution, based on user id and groups checking against node_type stored permissions. Such permissions can be overridden on the node level (and complement the public GET operations on the node entry point). --- pillar/application/__init__.py | 192 +++++++++++++++++++++++++-------- pillar/manage.py | 149 +++++++++++++------------ pillar/settings.py | 85 ++++++++++++--- 3 files changed, 292 insertions(+), 134 deletions(-) diff --git a/pillar/application/__init__.py b/pillar/application/__init__.py index 0e84dbe7..5fc36fae 100644 --- a/pillar/application/__init__.py +++ b/pillar/application/__init__.py @@ -17,14 +17,15 @@ from bson import ObjectId from flask import g from flask import request from flask import url_for +from flask import abort from pre_hooks import pre_GET from pre_hooks import pre_PUT from pre_hooks import pre_PATCH from pre_hooks import pre_POST from pre_hooks import pre_DELETE -from pre_hooks import check_permissions -from pre_hooks import compute_permissions +# from pre_hooks import check_permissions +# from pre_hooks import compute_permissions from datetime import datetime from datetime import timedelta @@ -47,9 +48,13 @@ class SystemUtility(): def validate(token): - """Validate a Token against Blender ID server - """ + """Validate a token against the Blender ID server. This simple lookup + returns a dictionary with the following keys: + - message: a success message + - valid: a boolean, stating if the token is valid + - user: a dictionary with information regarding the user + """ payload = dict( token=token) try: @@ -59,41 +64,55 @@ def validate(token): raise e if r.status_code == 200: - message = r.json()['message'] - valid = r.json()['valid'] - user = r.json()['user'] + response = r.json() + validation_result = dict( + message=response['message'], + valid=response['valid'], + user=response['user']) else: - message = "" - valid = False - user = None - return dict(valid=valid, message=message, user=user) + validation_result = dict(valid=False) + return validation_result def validate_token(): + """Validate the token provided in the request and populate the current_user + flask.g object, so that permissions and access to a resource can be defined + from it. + """ if not request.authorization: + # If no authorization headers are provided, we are getting a request + # from a non logged in user. Proceed accordingly. return None + + current_user = {} + token = request.authorization.username tokens = app.data.driver.db['tokens'] - users = app.data.driver.db['users'] + lookup = {'token': token, 'expire_time': {"$gt": datetime.now()}} - dbtoken = tokens.find_one(lookup) - if not dbtoken: + db_token = tokens.find_one(lookup) + if not db_token: + # If no valid token is found, we issue a new request to the Blender ID + # to verify the validity of the token. We will get basic user info if + # the user is authorized and we will make a new token. validation = validate(token) if validation['valid']: + users = app.data.driver.db['users'] email = validation['user']['email'] - dbuser = users.find_one({'email': email}) + db_user = users.find_one({'email': email}) tmpname = email.split('@')[0] - if not dbuser: + if not db_user: user_data = { 'first_name': tmpname, 'last_name': tmpname, 'email': email, - 'role': ['admin'], } r = post_internal('users', user_data) - user_id = r[0]["_id"] + user_id = r[0]['_id'] + groups = None else: - user_id = dbuser['_id'] + user_id = db_user['_id'] + groups = db_user['groups'] token_data = { 'user': user_id, @@ -101,20 +120,27 @@ def validate_token(): 'expire_time': datetime.now() + timedelta(hours=1) } post_internal('tokens', token_data) - return token_data + current_user = dict( + user_id=user_id, + token=token, + groups=groups, + token_expire_time=datetime.now() + timedelta(hours=1)) + #return token_data else: return None else: - token_data = { - 'user': dbtoken['user'], - 'token': dbtoken['token'], - 'expire_time': dbtoken['expire_time'] - } - return token_data + users = app.data.driver.db['users'] + db_user = users.find_one(db_token['user']) + current_user = dict( + user_id=db_token['user'], + token=db_token['token'], + groups=db_user['groups'], + token_expire_time=db_token['expire_time']) + + setattr(g, 'current_user', current_user) class TokensAuth(TokenAuth): - def check_auth(self, token, allowed_roles, resource, method): if not token: return False @@ -127,21 +153,13 @@ class TokensAuth(TokenAuth): # return validation['valid'] return True - """ - users = app.data.driver.db['users'] - lookup = {'first_name': token['username']} - if allowed_roles: - lookup['role'] = {'$in': allowed_roles} - user = users.find_one(lookup) - if not user: - return False - return token - """ class BasicsAuth(BasicAuth): def check_auth(self, username, password, allowed_roles, resource, method): # return username == 'admin' and password == 'secret' + print username + print password return True @@ -157,12 +175,29 @@ class CustomTokenAuth(BasicsAuth): return self.authorized_protected( self, allowed_roles, resource, method) else: + print 'is auth' return self.token_auth.authorized(allowed_roles, resource, method) def authorized_protected(self): pass +class NewAuth(TokenAuth): + def check_auth(self, token, allowed_roles, resource, method): + if not token: + return False + else: + print '---' + print 'validating' + print token + print resource + print method + print '---' + validate_token() + + return True + + class ValidateCustomFields(Validator): def convert_properties(self, properties, node_schema): for prop in node_schema: @@ -229,11 +264,10 @@ def post_item(entry, data): # automatically. The default path (which work in Docker) can be overriden with # an env variable. settings_path = os.environ.get('EVE_SETTINGS', '/data/dev/pillar/pillar/settings.py') -app = Eve(settings=settings_path, validator=ValidateCustomFields, auth=CustomTokenAuth) +app = Eve(settings=settings_path, validator=ValidateCustomFields, auth=NewAuth) import config app.config.from_object(config.Deployment) -app.config['MONGO_HOST'] = os.environ.get('MONGO_HOST', 'localhost') client = MongoClient(app.config['MONGO_HOST'], 27017) db = client.eve @@ -243,8 +277,10 @@ def global_validation(): token_data = validate_token() if token_data: setattr(g, 'token_data', token_data) - setattr(g, 'validate', validate(token_data['token'])) + #setattr(g, 'validate', validate(token_data['token'])) check_permissions(token_data['user'], app.data.driver) + else: + print 'NO TOKEN' def pre_GET_nodes(request, lookup): @@ -270,11 +306,13 @@ def pre_PATCH_nodes(request): def pre_POST_nodes(request): - global_validation() + # global_validation() # print ("Post") # print ("World: {0}".format(g.get('world_permissions'))) # print ("Group: {0}".format(g.get('groups_permissions'))) - return pre_POST(request, app.data.driver) + # return pre_POST(request, app.data.driver) + print 'pre posting' + print request def pre_DELETE_nodes(request, lookup): @@ -287,20 +325,80 @@ def pre_DELETE_nodes(request, lookup): return pre_DELETE(request, lookup, app.data.driver) -app.on_pre_GET_nodes += pre_GET_nodes +#app.on_pre_GET_nodes += pre_GET_nodes app.on_pre_POST_nodes += pre_POST_nodes -app.on_pre_PATCH_nodes += pre_PATCH_nodes -app.on_pre_PUT_nodes += pre_PUT_nodes +# app.on_pre_PATCH_nodes += pre_PATCH_nodes +#app.on_pre_PUT_nodes += pre_PUT_nodes app.on_pre_DELETE_nodes += pre_DELETE_nodes +def check_permissions(resource, method): + """Check user permissions to access a node. We look up node permissions from + world to groups to users and match them with the computed user permissions. + If there is not match, we return 403. + """ + current_user = g.get('current_user', None) + + if 'permissions' in resource: + # If permissions are embedde in the node (this overrides any other + # permission previously set) + resource_permissions = resource['permissions'] + elif type(resource['node_type']) is dict: + # If the node_type is embedded in the document, extract permissions + # from there + resource_permissions = resource['node_type']['permissions'] + else: + # If the node_type is referenced with an ObjectID (was not embedded on + # request) query for if from the database and get the permissions + node_types_collection = app.data.driver.db['node_types'] + node_type = node_types_collection.find_one(resource['node_type']) + resource_permissions = node_type['permissions'] + + if current_user: + # If the user is authenticated, proceed to compare the group permissions + for permission in resource_permissions['groups']: + if permission['group'] in current_user['groups']: + if method in permission['methods']: + return + + for permission in resource_permissions['users']: + if current_user['user_id'] == permission['user']: + if method in permission['methods']: + return + + # Check if the node is public or private. This must be set for non logged + # in users to see the content. For most BI projects this is on by default, + # while for private project this will not be set at all. + if 'world' in permissions and method in permissions['world']: + return + + abort(403) + +def before_returning_node(response): + # Run validation process, since GET on nodes entry point is public + validate_token() + check_permissions(response, 'GET') + +def before_replacing_node(item, original): + check_permissions(original, 'PUT') + +def before_inserting_nodes(items): + for item in items: + check_permissions(item, 'POST') + +app.on_fetched_item_nodes += before_returning_node +app.on_replace_nodes += before_replacing_node +app.on_insert_nodes += before_inserting_nodes + + def post_GET_user(request, payload): + print 'computing permissions' json_data = json.loads(payload.data) # Check if we are querying the users endpoint (instead of the single user) if json_data.get('_id') is None: return - json_data['computed_permissions'] = \ - compute_permissions(json_data['_id'], app.data.driver) + # json_data['computed_permissions'] = \ + # compute_permissions(json_data['_id'], app.data.driver) payload.data = json.dumps(json_data) app.on_post_GET_users += post_GET_user diff --git a/pillar/manage.py b/pillar/manage.py index dec35e4f..cef0268a 100644 --- a/pillar/manage.py +++ b/pillar/manage.py @@ -1,4 +1,5 @@ import os +from eve.methods.put import put_internal from application import app from application import db from application import post_item @@ -48,25 +49,6 @@ def clear_db(): db.drop_collection('users') -@manager.command -def remove_properties_order(): - """Removes properties.order - """ - from pymongo import MongoClient - client = MongoClient(MONGO_HOST, 27017) - db = client.eve - nodes = db.nodes.find() - for node in nodes: - new_prop = {} - for prop in node['properties']: - if prop == 'order': - continue - else: - new_prop[prop] = node['properties'][prop] - db.nodes.update({"_id": node['_id']}, - {"$set": {"properties": new_prop}}) - - @manager.command def upgrade_node_types(): """Wipes node_types collection @@ -410,14 +392,6 @@ def populate_node_types(old_ids={}): scene_node_type = { 'name': 'scene', 'description': 'Scene node type', - 'dyn_schema': { - 'order': { - 'type': 'integer', - } - }, - 'form_schema': { - 'order': {}, - }, 'parent': { "node_types": ["act"] } @@ -426,49 +400,11 @@ def populate_node_types(old_ids={}): act_node_type = { 'name': 'act', 'description': 'Act node type', - 'dyn_schema': { - 'order': { - 'type': 'integer', - } - }, - 'form_schema': { - 'order': {}, - }, 'parent': {} } - comment_node_type = { - 'name': 'comment', - 'description': 'Comment node type', - 'dyn_schema': { - 'text': { - 'type': 'string', - 'maxlength': 256 - }, - 'attachments': { - 'type': 'list', - 'schema': { - 'type': 'objectid', - 'data_relation': { - 'resource': 'files', - 'field': '_id', - 'embeddable': True - } - } - } - }, - 'form_schema': { - 'text': {}, - 'attachments': { - 'items': [("File", "name")] - } - }, - 'parent': { - "node_types": ["shot", "task"] - } - } - project_node_type = { + node_type_project = { 'name': 'project', 'parent': {}, 'description': 'The official project type', @@ -567,9 +503,17 @@ def populate_node_types(old_ids={}): } }, }, + 'permissions': { + 'groups': [{ + 'group': '5596e975ea893b269af85c0e', + 'methods': ['GET', 'PUT', 'POST'] + }], + 'users': [], + 'world': ['GET'] + } } - group_node_type = { + node_type_group = { 'name': 'group', 'description': 'Generic group node type', 'parent': {}, @@ -594,9 +538,17 @@ def populate_node_types(old_ids={}): 'status': {}, 'notes': {}, }, + 'permissions': { + 'groups': [{ + 'group': '5596e975ea893b269af85c0e', + 'methods': ['GET', 'PUT', 'POST'] + }], + 'users': [], + 'world': ['GET'] + } } - asset_node_type = { + node_type_asset = { 'name': 'asset', 'description': 'Assets for Elephants Dream', # This data type does not have parent limitations (can be child @@ -633,6 +585,14 @@ def populate_node_types(old_ids={}): 'status': {}, 'content_type': {}, 'file': {}, + }, + 'permissions': { + 'groups': [{ + 'group': '5596e975ea893b269af85c0e', + 'methods': ['GET', 'PUT', 'POST'] + }], + 'users': [], + 'world': ['GET'] } } @@ -666,6 +626,14 @@ def populate_node_types(old_ids={}): }, 'parent': { "node_types": ["group", "project"] + }, + 'permissions': { + 'groups': [{ + 'group': '5596e975ea893b269af85c0e', + 'methods': ['GET', 'PUT', 'POST'] + }], + 'users': [], + 'world': ['GET'] } } @@ -734,6 +702,14 @@ def populate_node_types(old_ids={}): }, 'parent': { 'node_types': ['asset',] + }, + 'permissions': { + 'groups': [{ + 'group': '5596e975ea893b269af85c0e', + 'methods': ['GET', 'PUT', 'POST'] + }], + 'users': [], + 'world': ['GET'] } } @@ -757,10 +733,16 @@ def populate_node_types(old_ids={}): node_name = node_type['name'] if node_name in old_ids: node_type = mix_node_type(old_ids[node_name], node_type) - # Remove old node_type - db.node_types.remove({'_id': old_ids[node_name]}) - # Insert new node_type - db.node_types.insert(node_type) + node_id = node_type['_id'] + + # Removed internal fields that would cause validation error + internal_fields = ['_id', '_etag', '_updated', '_created'] + for field in internal_fields: + node_type.pop(field, None) + + p = put_internal('node_types', node_type, **{'_id': node_id}) + print p + else: print("Making the node") print(node_type) @@ -770,9 +752,9 @@ def populate_node_types(old_ids={}): # upgrade(task_node_type, old_ids) # upgrade(scene_node_type, old_ids) # upgrade(act_node_type, old_ids) - # upgrade(comment_node_type, old_ids) - upgrade(project_node_type, old_ids) - upgrade(asset_node_type, old_ids) + upgrade(node_type_project, old_ids) + upgrade(node_type_group, old_ids) + upgrade(node_type_asset, old_ids) upgrade(node_type_storage, old_ids) upgrade(node_type_comment, old_ids) @@ -1037,6 +1019,23 @@ def make_thumbnails(): t = build_thumbnails(file_path=f['path']) print t +@manager.command +def add_node_permissions(): + import codecs + import sys + UTF8Writer = codecs.getwriter('utf8') + sys.stdout = UTF8Writer(sys.stdout) + nodes_collection = app.data.driver.db['nodes'] + node_types_collection = app.data.driver.db['node_types'] + nodes = nodes_collection.find() + for node in nodes: + print u"{0}".format(node['name']) + if 'permissions' not in node: + node_type = node_types_collection.find_one(node['node_type']) + # nodes_collection.update({'_id': node['_id']}, + # {"$set": {'permissions': node_type['permissions']}}) + print node['_id'] + break if __name__ == '__main__': manager.run() diff --git a/pillar/settings.py b/pillar/settings.py index 79d00198..6bec95f9 100644 --- a/pillar/settings.py +++ b/pillar/settings.py @@ -11,8 +11,6 @@ ITEM_METHODS = ['GET', 'PUT', 'DELETE', 'PATCH'] PAGINATION_LIMIT = 25 -# To be implemented on Eve 0.6 -# RETURN_MEDIA_AS_URL = True users_schema = { 'first_name': { @@ -147,6 +145,56 @@ organizations_schema = { } } +permissions_embedded_schema = { + 'groups': { + 'type': 'list', + 'schema': { + 'type': 'dict', + 'schema': { + 'group': { + 'type': 'objectid', + 'required': True, + 'data_relation': { + 'resource': 'groups', + 'field': '_id', + 'embeddable': True + } + }, + 'methods': { + 'type': 'list', + 'required': True, + 'allowed': ['GET', 'PUT', 'POST', 'DELETE'] + } + } + }, + }, + 'users': { + 'type': 'list', + 'schema': { + 'type': 'dict', + 'schema': { + 'user' : { + 'type': 'objectid', + 'required': True, + }, + 'methods': { + 'type': 'list', + 'required': True, + 'allowed': ['GET', 'PUT', 'POST', 'DELETE'] + } + } + } + }, + 'world': { + 'type': 'list', + #'required': True, + 'allowed': ['GET',] + }, + 'is_free': { + 'type': 'boolean', + } +} + nodes_schema = { 'name': { 'type': 'string', @@ -200,11 +248,15 @@ nodes_schema = { 'embeddable': True }, }, - 'properties': { - 'type' : 'dict', - 'valid_properties' : True, - 'required': True, + 'properties': { + 'type' : 'dict', + 'valid_properties' : True, + 'required': True, }, + 'permissions': { + 'type': 'dict', + 'schema': permissions_embedded_schema + } } node_types_schema = { @@ -229,6 +281,11 @@ node_types_schema = { 'parent': { 'type': 'dict', 'required': True, + }, + 'permissions': { + 'type': 'dict', + 'required': True, + 'schema': permissions_embedded_schema } } @@ -332,7 +389,6 @@ files_schema = { } } - groups_schema = { 'name': { 'type': 'string', @@ -364,11 +420,15 @@ groups_schema = { } nodes = { - 'schema': nodes_schema + 'schema': nodes_schema, + 'public_methods': ['GET'], + 'public_item_methods': ['GET'] } node_types = { 'resource_methods': ['GET', 'POST'], + 'public_methods': ['GET'], + 'public_item_methods': ['GET'], 'schema': node_types_schema, } @@ -379,7 +439,6 @@ users = { 'cache_control': 'max-age=10,must-revalidate', 'cache_expires': 10, - # most global settings can be overridden at resource level 'resource_methods': ['GET', 'POST'], 'public_methods': ['GET', 'POST'], @@ -399,7 +458,9 @@ tokens = { files = { 'resource_methods': ['GET', 'POST'], - 'schema': files_schema, + 'public_methods': ['GET'], + 'public_item_methods': ['GET'], + 'schema': files_schema } @@ -423,5 +484,5 @@ DOMAIN = { } -if os.environ.get('MONGO_HOST'): - MONGO_HOST = os.environ.get('MONGO_HOST') +MONGO_HOST = os.environ.get('MONGO_HOST', 'localhost') +MONGO_PORT = os.environ.get('MONGO_PORT', 27017)