diff --git a/pillar/api/__init__.py b/pillar/api/__init__.py index 5abf9594..d9172b47 100644 --- a/pillar/api/__init__.py +++ b/pillar/api/__init__.py @@ -1,6 +1,7 @@ def setup_app(app): from . import encoding, blender_id, projects, local_auth, file_storage from . import users, nodes, latest, blender_cloud, service, activities + from . import organizations encoding.setup_app(app, url_prefix='/encoding') blender_id.setup_app(app, url_prefix='/blender_id') @@ -13,3 +14,4 @@ def setup_app(app): service.setup_app(app, api_prefix='/service') nodes.setup_app(app, url_prefix='/nodes') activities.setup_app(app) + organizations.setup_app(app) diff --git a/pillar/api/eve_settings.py b/pillar/api/eve_settings.py index 561f9dd5..050eab8c 100644 --- a/pillar/api/eve_settings.py +++ b/pillar/api/eve_settings.py @@ -138,10 +138,6 @@ organizations_schema = { 'maxlength': 128, 'required': True }, - 'url': { - 'type': 'string', - 'maxlength': 128, - }, 'description': { 'type': 'string', 'maxlength': 256, diff --git a/pillar/api/organizations/__init__.py b/pillar/api/organizations/__init__.py index 28068e10..7345b4ea 100644 --- a/pillar/api/organizations/__init__.py +++ b/pillar/api/organizations/__init__.py @@ -10,6 +10,7 @@ import typing import attr import bson +import werkzeug.exceptions as wz_exceptions from pillar import attrs_extra, current_app from pillar.api.utils import remove_private_keys @@ -157,6 +158,9 @@ class OrgManager: if user_doc is not None: user_id = user_doc['_id'] + if user_id and not users_coll.count({'_id': user_id}): + raise wz_exceptions.UnprocessableEntity('User does not exist') + self._log.info('Removing user %s / %s from organization %s', user_id, email, org_id) org_doc = self._get_org(org_id) @@ -185,13 +189,13 @@ class OrgManager: return org_doc - def _get_org(self, org_id: bson.ObjectId): + def _get_org(self, org_id: bson.ObjectId, *, projection=None): """Returns the organization, or raises a ValueError.""" assert isinstance(org_id, bson.ObjectId) org_coll = current_app.db('organizations') - org = org_coll.find_one(org_id) + org = org_coll.find_one(org_id, projection=projection) if org is None: raise ValueError(f'Organization {org_id} not found') return org @@ -236,9 +240,20 @@ class OrgManager: if revoke_roles: do_badger('revoke', roles=revoke_roles, user_id=user_id) -# def setup_app(app): -# from . import eve_hooks, api, patch -# -# eve_hooks.setup_app(app) -# api.setup_app(app) -# patch.setup_app(app) + def user_is_admin(self, org_id: bson.ObjectId) -> bool: + """Returns whether the currently logged in user is the admin of the organization.""" + + from pillar.api.utils.authentication import current_user_id + + uid = current_user_id() + if uid is None: + return False + + org = self._get_org(org_id, projection={'admin_uid': 1}) + return org['admin_uid'] == uid + + +def setup_app(app): + from . import patch + + patch.setup_app(app) diff --git a/pillar/api/organizations/patch.py b/pillar/api/organizations/patch.py new file mode 100644 index 00000000..f25b9e75 --- /dev/null +++ b/pillar/api/organizations/patch.py @@ -0,0 +1,115 @@ +"""Organization patching support.""" + +import logging + +import bson +from flask import Blueprint, jsonify +import werkzeug.exceptions as wz_exceptions + +from pillar.api.utils.authentication import current_user_id +from pillar.api.utils import authorization, str2id, jsonify +from pillar.api import patch_handler +from pillar import current_app + +log = logging.getLogger(__name__) +patch_api_blueprint = Blueprint('pillar.api.organizations.patch', __name__) + + +class OrganizationPatchHandler(patch_handler.AbstractPatchHandler): + item_name = 'organization' + + @authorization.require_login() + def patch_assign_users(self, org_id: bson.ObjectId, patch: dict): + """Assigns users to an organization. + + The calling user must be admin of the organization. + """ + + self._assert_is_admin(org_id) + + # Do some basic validation. + try: + emails = patch['emails'] + except KeyError: + raise wz_exceptions.BadRequest('No key "email" in patch.') + + if not all(isinstance(email, str) for email in emails): + raise wz_exceptions.BadRequest('Invalid list of email addresses') + + log.info('User %s uses PATCH to add users to organization %s', current_user_id(), org_id) + org_doc = current_app.org_manager.assign_users(org_id, emails) + return jsonify(org_doc) + + @authorization.require_login() + def patch_remove_user(self, org_id: bson.ObjectId, patch: dict): + """Removes a user from an organization. + + The calling user must be admin of the organization. + """ + + self._assert_is_admin(org_id) + + # Do some basic validation. + email = patch.get('email') or None + user_id = patch.get('user_id') + + user_oid = str2id(user_id) if user_id else None + + log.info('User %s uses PATCH to remove user from organization %s', + current_user_id(), org_id) + + org_doc = current_app.org_manager.remove_user(org_id, user_id=user_oid, email=email) + return jsonify(org_doc) + + def _assert_is_admin(self, org_id): + om = current_app.org_manager + + if not om.user_is_admin(org_id): + log.warning('User %s uses PATCH to edit organization %s, ' + 'but is not admin of that Organization. Request denied.', + current_user_id(), org_id) + raise wz_exceptions.Forbidden() + + @authorization.require_login() + def patch_edit_from_web(self, org_id: bson.ObjectId, patch: dict): + """Updates Organization fields from the web.""" + + from pymongo.results import UpdateResult + + self._assert_is_admin(org_id) + + # Only take known fields from the patch, don't just copy everything. + update = { + 'name': patch['name'].strip(), + 'description': patch.get('description', '').strip(), + 'website': patch.get('website', '').strip(), + } + self.log.info('User %s edits Organization %s: %s', current_user_id(), org_id, update) + + validator = current_app.validator_for_resource('organizations') + if not validator.validate_update(update, org_id): + resp = jsonify({ + '_errors': validator.errors, + '_message': ', '.join(f'{field}: {error}' + for field, error in validator.errors.items()), + }) + resp.status_code = 422 + return resp + + organizations_coll = current_app.db('organizations') + result: UpdateResult = organizations_coll.update_one( + {'_id': org_id}, + {'$set': update} + ) + + if result.matched_count != 1: + self.log.warning('User %s edits Organization %s but update matched %i items', + current_user_id(), org_id, result.matched_count) + raise wz_exceptions.BadRequest() + + return '', 204 + + +def setup_app(app): + OrganizationPatchHandler(patch_api_blueprint) + app.register_api_blueprint(patch_api_blueprint, url_prefix='/organizations') diff --git a/tests/test_api/test_organizations.py b/tests/test_api/test_organizations.py index e66c196c..637181cd 100644 --- a/tests/test_api/test_organizations.py +++ b/tests/test_api/test_organizations.py @@ -156,3 +156,174 @@ class OrganizationCruTest(AbstractPillarTest): # The unknown members list should be empty. db_org1 = orgs_coll.find_one(org1['_id']) self.assertEqual(db_org1['unknown_members'], []) + + +class OrganizationPatchTest(AbstractPillarTest): + """Test PATCHing organizations.""" + + def test_assign_users(self): + self.enter_app_context() + + admin_uid = self.create_user(24 * 'a', token='admin-token') + member1_uid = self.create_user(24 * 'b', email='member1@example.com') + + om = self.app.org_manager + org_doc = om.create_new_org('Хакеры', admin_uid, 25) + org_id = org_doc['_id'] + + # Try the PATCH + resp = self.patch(f'/api/organizations/{org_id}', + json={ + 'op': 'assign-users', + 'emails': ['member1@example.com', 'member2@example.com'], + }, + auth_token='admin-token') + new_org_doc = resp.get_json() + + db = self.app.db('organizations') + db_org = db.find_one(org_id) + + self.assertEqual([member1_uid], db_org['members']) + self.assertEqual(['member2@example.com'], db_org['unknown_members']) + + self.assertEqual([str(member1_uid)], new_org_doc['members']) + self.assertEqual(['member2@example.com'], new_org_doc['unknown_members']) + + def test_assign_users_access_denied(self): + self.enter_app_context() + + admin_uid = self.create_user(24 * 'a', token='admin-token') + self.create_user(24 * 'b', email='member1@example.com', token='monkey-token') + + om = self.app.org_manager + org_doc = om.create_new_org('Хакеры', admin_uid, 25) + org_id = org_doc['_id'] + + # Try the PATCH + self.patch(f'/api/organizations/{org_id}', + json={ + 'op': 'assign-users', + 'emails': ['member1@example.com', 'member2@example.com'], + }, + auth_token='monkey-token', + expected_status=403) + + db = self.app.db('organizations') + db_org = db.find_one(org_id) + + self.assertEqual([], db_org['members']) + self.assertEqual([], db_org['unknown_members']) + + def test_remove_user_by_email(self): + self.enter_app_context() + om = self.app.org_manager + + admin_uid = self.create_user(24 * 'a', token='admin-token') + self.create_user(24 * 'b', email='member1@example.com') + org_doc = om.create_new_org('Хакеры', admin_uid, 25) + org_id = org_doc['_id'] + + om.assign_users(org_id, ['member1@example.com', 'member2@example.com']) + + # Try the PATCH to remove a known user + resp = self.patch(f'/api/organizations/{org_id}', + json={ + 'op': 'remove-user', + 'email': 'member1@example.com', + }, + auth_token='admin-token') + new_org_doc = resp.get_json() + + db = self.app.db('organizations') + db_org = db.find_one(org_id) + + self.assertEqual([], db_org['members']) + self.assertEqual(['member2@example.com'], db_org['unknown_members']) + + self.assertEqual([], new_org_doc['members']) + self.assertEqual(['member2@example.com'], new_org_doc['unknown_members']) + + # Try the PATCH to remove an unknown user + resp = self.patch(f'/api/organizations/{org_id}', + json={ + 'op': 'remove-user', + 'email': 'member2@example.com', + }, + auth_token='admin-token') + new_org_doc = resp.get_json() + + db_org = db.find_one(org_id) + + self.assertEqual([], db_org['members']) + self.assertEqual([], db_org['unknown_members']) + + self.assertEqual([], new_org_doc['members']) + self.assertEqual([], new_org_doc['unknown_members']) + + def test_remove_user_by_id(self): + self.enter_app_context() + om = self.app.org_manager + + admin_uid = self.create_user(24 * 'a', token='admin-token') + member_uid = self.create_user(24 * 'b', email='member1@example.com') + org_doc = om.create_new_org('Хакеры', admin_uid, 25) + org_id = org_doc['_id'] + + om.assign_users(org_id, ['member1@example.com', 'member2@example.com']) + + # Try the PATCH to remove a known user + resp = self.patch(f'/api/organizations/{org_id}', + json={ + 'op': 'remove-user', + 'user_id': str(member_uid), + }, + auth_token='admin-token') + new_org_doc = resp.get_json() + + db = self.app.db('organizations') + db_org = db.find_one(org_id) + + self.assertEqual([], db_org['members']) + self.assertEqual(['member2@example.com'], db_org['unknown_members']) + + self.assertEqual([], new_org_doc['members']) + self.assertEqual(['member2@example.com'], new_org_doc['unknown_members']) + + # Try the PATCH to remove an unknown user + resp = self.patch(f'/api/organizations/{org_id}', + json={ + 'op': 'remove-user', + 'user_id': 24 * 'f', + }, + auth_token='admin-token', + expected_status=422) + + db_org = db.find_one(org_id) + self.assertEqual([], db_org['members']) + self.assertEqual(['member2@example.com'], db_org['unknown_members']) + + def test_edit_from_web(self): + self.enter_app_context() + om = self.app.org_manager + + admin_uid = self.create_user(24 * 'a', token='admin-token') + org_doc = om.create_new_org('Хакеры', admin_uid, 25) + org_id = org_doc['_id'] + + # Try the PATCH to remove a known user + self.patch(f'/api/organizations/{org_id}', + json={ + 'op': 'edit-from-web', + 'name': ' Blender Institute ', + 'description': '\nOpen Source animation studio ', + 'website': ' https://blender.institute/ ', + }, + auth_token='admin-token', + expected_status=204) + + db = self.app.db('organizations') + db_org = db.find_one(org_id) + + self.assertEqual('Blender Institute', db_org['name']) + self.assertEqual('Open Source animation studio', db_org['description']) + self.assertEqual('https://blender.institute/', db_org['website'])