Added PATCH support for organizations
With a PATCH request you can now: - assign users, - remove a user, - edit the name, description, and website fields. Only the organization admin user can do this.
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
def setup_app(app):
|
def setup_app(app):
|
||||||
from . import encoding, blender_id, projects, local_auth, file_storage
|
from . import encoding, blender_id, projects, local_auth, file_storage
|
||||||
from . import users, nodes, latest, blender_cloud, service, activities
|
from . import users, nodes, latest, blender_cloud, service, activities
|
||||||
|
from . import organizations
|
||||||
|
|
||||||
encoding.setup_app(app, url_prefix='/encoding')
|
encoding.setup_app(app, url_prefix='/encoding')
|
||||||
blender_id.setup_app(app, url_prefix='/blender_id')
|
blender_id.setup_app(app, url_prefix='/blender_id')
|
||||||
@@ -13,3 +14,4 @@ def setup_app(app):
|
|||||||
service.setup_app(app, api_prefix='/service')
|
service.setup_app(app, api_prefix='/service')
|
||||||
nodes.setup_app(app, url_prefix='/nodes')
|
nodes.setup_app(app, url_prefix='/nodes')
|
||||||
activities.setup_app(app)
|
activities.setup_app(app)
|
||||||
|
organizations.setup_app(app)
|
||||||
|
@@ -138,10 +138,6 @@ organizations_schema = {
|
|||||||
'maxlength': 128,
|
'maxlength': 128,
|
||||||
'required': True
|
'required': True
|
||||||
},
|
},
|
||||||
'url': {
|
|
||||||
'type': 'string',
|
|
||||||
'maxlength': 128,
|
|
||||||
},
|
|
||||||
'description': {
|
'description': {
|
||||||
'type': 'string',
|
'type': 'string',
|
||||||
'maxlength': 256,
|
'maxlength': 256,
|
||||||
|
@@ -10,6 +10,7 @@ import typing
|
|||||||
|
|
||||||
import attr
|
import attr
|
||||||
import bson
|
import bson
|
||||||
|
import werkzeug.exceptions as wz_exceptions
|
||||||
|
|
||||||
from pillar import attrs_extra, current_app
|
from pillar import attrs_extra, current_app
|
||||||
from pillar.api.utils import remove_private_keys
|
from pillar.api.utils import remove_private_keys
|
||||||
@@ -157,6 +158,9 @@ class OrgManager:
|
|||||||
if user_doc is not None:
|
if user_doc is not None:
|
||||||
user_id = user_doc['_id']
|
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)
|
self._log.info('Removing user %s / %s from organization %s', user_id, email, org_id)
|
||||||
|
|
||||||
org_doc = self._get_org(org_id)
|
org_doc = self._get_org(org_id)
|
||||||
@@ -185,13 +189,13 @@ class OrgManager:
|
|||||||
|
|
||||||
return org_doc
|
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."""
|
"""Returns the organization, or raises a ValueError."""
|
||||||
|
|
||||||
assert isinstance(org_id, bson.ObjectId)
|
assert isinstance(org_id, bson.ObjectId)
|
||||||
|
|
||||||
org_coll = current_app.db('organizations')
|
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:
|
if org is None:
|
||||||
raise ValueError(f'Organization {org_id} not found')
|
raise ValueError(f'Organization {org_id} not found')
|
||||||
return org
|
return org
|
||||||
@@ -236,9 +240,20 @@ class OrgManager:
|
|||||||
if revoke_roles:
|
if revoke_roles:
|
||||||
do_badger('revoke', roles=revoke_roles, user_id=user_id)
|
do_badger('revoke', roles=revoke_roles, user_id=user_id)
|
||||||
|
|
||||||
# def setup_app(app):
|
def user_is_admin(self, org_id: bson.ObjectId) -> bool:
|
||||||
# from . import eve_hooks, api, patch
|
"""Returns whether the currently logged in user is the admin of the organization."""
|
||||||
#
|
|
||||||
# eve_hooks.setup_app(app)
|
from pillar.api.utils.authentication import current_user_id
|
||||||
# api.setup_app(app)
|
|
||||||
# patch.setup_app(app)
|
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)
|
||||||
|
115
pillar/api/organizations/patch.py
Normal file
115
pillar/api/organizations/patch.py
Normal file
@@ -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')
|
@@ -156,3 +156,174 @@ class OrganizationCruTest(AbstractPillarTest):
|
|||||||
# The unknown members list should be empty.
|
# The unknown members list should be empty.
|
||||||
db_org1 = orgs_coll.find_one(org1['_id'])
|
db_org1 = orgs_coll.find_one(org1['_id'])
|
||||||
self.assertEqual(db_org1['unknown_members'], [])
|
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'])
|
||||||
|
Reference in New Issue
Block a user