def _assert_is_admin(self, org_id): om = current_app.org_manager if current_user().has_cap('admin'): # Always allow admins to edit every organization. return 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().user_id, org_id) raise wz_exceptions.Forbidden()
def patch_assign_user(self, org_id: bson.ObjectId, patch: dict): """Assigns a single user by User ID to an organization. The calling user must be admin of the organization. """ from . import NotEnoughSeats self._assert_is_admin(org_id) # Do some basic validation. try: user_id = patch['user_id'] except KeyError: raise wz_exceptions.BadRequest('No key "user_id" in patch.') user_oid = str2id(user_id) log.info('User %s uses PATCH to add user %s to organization %s', current_user().user_id, user_oid, org_id) try: org_doc = current_app.org_manager.assign_single_user( org_id, user_id=user_oid) except NotEnoughSeats: resp = jsonify( {'_message': f'Not enough seats to assign this user'}) resp.status_code = 422 return resp return jsonify(org_doc)
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. """ from . import NotEnoughSeats 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.') # Skip empty emails. emails = [ stripped for stripped in (email.strip() for email in emails) if stripped ] log.info('User %s uses PATCH to add users to organization %s', current_user().user_id, org_id) try: org_doc = current_app.org_manager.assign_users(org_id, emails) except NotEnoughSeats: resp = jsonify({ '_message': f'Not enough seats to assign {len(emails)} users' }) resp.status_code = 422 return resp return jsonify(org_doc)
def pre_get_organizations(request, lookup): user = current_user() if user.is_anonymous: raise wz_exceptions.Forbidden() if user.has_cap('admin'): # Allow all lookups to admins. return # Only allow users to see their own organizations. lookup['$or'] = [{'admin_uid': user.user_id}, {'members': user.user_id}]
def test_current_user_logged_in(self): self.enter_app_context() from flask import g from pillar.auth import UserClass from pillar.api.utils.authentication import current_user with self.app.test_request_context(): g.current_user = UserClass.construct('the token', ctd.EXAMPLE_USER) user = current_user() self.assertIs(g.current_user, user)
def generate_token(manager_id: str): manager_oid = str2id(manager_id) manager = mongo.find_one_or_404('flamenco_managers', manager_oid) # There are three ways in which a user can get here. One is authenticated via # Authorization header (either Bearer token or Basic token:subtoken), and the # other is via an already-existing browser session. # In the latter case it's a redirect from a Flamenco Manager and we need to # check the timeout and HMAC. if not request.headers.get('Authorization'): hasher = current_flamenco.manager_manager.hasher(manager_oid) if hasher is None: raise wz_exceptions.InternalServerError( 'Flamenco Manager not linked to this server') expires = request.args.get('expires', '') string_to_hash = f'{expires}-{manager_id}' hasher.update(string_to_hash.encode('utf8')) actual_hmac = hasher.hexdigest() query_hmac = request.args.get('hmac', '') if not hmac.compare_digest(query_hmac, actual_hmac): raise wz_exceptions.Unauthorized('Bad HMAC') # Only parse the timestamp after we learned we can trust it. expire_timestamp = dateutil.parser.parse(expires) validity_seconds_left = (expire_timestamp - utcnow()).total_seconds() if validity_seconds_left < 0: raise wz_exceptions.Unauthorized('Link expired') if validity_seconds_left > 900: # Flamenco Manager generates links that are valid for less than a minute, so # if it's more than 15 minutes in the future, it's bad. raise wz_exceptions.Unauthorized('Link too far in the future') user = authentication.current_user() if not current_flamenco.manager_manager.user_may_use(mngr_doc=manager): log.warning( 'Account %s called %s for manager %s without access to that manager', user.user_id, request.url, manager_oid) raise wz_exceptions.Unauthorized() jwt = current_flamenco.jwt if not jwt.usable: raise wz_exceptions.NotImplemented( 'JWT keystore is not usable at the moment') log.info('Generating JWT key for user_id=%s manager_id=%s remote_addr=%s', user.user_id, manager_id, request.remote_addr) key_for_manager = jwt.generate_key_for_manager(manager_oid, user.user_id) return Response(key_for_manager, content_type='text/plain')
def generate_token(manager_id: str): manager_oid = str2id(manager_id) manager = mongo.find_one_or_404('flamenco_managers', manager_oid) # There are three ways in which a user can get here. One is authenticated via # Authorization header (either Bearer token or Basic token:subtoken), and the # other is via an already-existing browser session. # In the latter case it's a redirect from a Flamenco Manager and we need to # check the timeout and HMAC. if not request.headers.get('Authorization'): hasher = current_flamenco.manager_manager.hasher(manager_oid) if hasher is None: raise wz_exceptions.InternalServerError('Flamenco Manager not linked to this server') expires = request.args.get('expires', '') string_to_hash = f'{expires}-{manager_id}' hasher.update(string_to_hash.encode('utf8')) actual_hmac = hasher.hexdigest() query_hmac = request.args.get('hmac', '') if not hmac.compare_digest(query_hmac, actual_hmac): raise wz_exceptions.Unauthorized('Bad HMAC') # Only parse the timestamp after we learned we can trust it. expire_timestamp = dateutil.parser.parse(expires) validity_seconds_left = (expire_timestamp - utcnow()).total_seconds() if validity_seconds_left < 0: raise wz_exceptions.Unauthorized('Link expired') if validity_seconds_left > 900: # Flamenco Manager generates links that are valid for less than a minute, so # if it's more than 15 minutes in the future, it's bad. raise wz_exceptions.Unauthorized('Link too far in the future') user = authentication.current_user() if not current_flamenco.manager_manager.user_may_use(mngr_doc=manager): log.warning( 'Account %s called %s for manager %s without access to that manager', user.user_id, request.url, manager_oid) raise wz_exceptions.Unauthorized() jwt = current_flamenco.jwt if not jwt.usable: raise wz_exceptions.NotImplemented('JWT keystore is not usable at the moment') log.info('Generating JWT key for user_id=%s manager_id=%s remote_addr=%s', user.user_id, manager_id, request.remote_addr) key_for_manager = jwt.generate_key_for_manager(manager_oid, user.user_id) return Response(key_for_manager, content_type='text/plain')
def test_current_user_anonymous(self): self.enter_app_context() from flask import g from pillar.auth import AnonymousUser from pillar.api.utils.authentication import current_user with self.app.test_request_context(): g.current_user = None user = current_user() self.assertIsInstance(user, AnonymousUser) self.assertIsNone(user.user_id) self.assertTrue(user.is_anonymous) self.assertFalse(user.is_authenticated)
def patch_set_job_status(self, job_id: bson.ObjectId, patch: dict): """Updates a job's status in the database.""" self.assert_job_access(job_id) new_status = patch['status'] user = current_user() log.info('User %s uses PATCH to set job %s status to "%s"', user.user_id, job_id, new_status) try: current_flamenco.job_manager.api_set_job_status( job_id, new_status, reason=f'Set to {new_status} by {user.full_name} (@{user.username})') except ValueError as ex: log.debug('api_set_job_status(%s, %r) raised %s', job_id, new_status, ex) raise wz_exceptions.UnprocessableEntity(f'Status {new_status} is invalid')
def patch_assign_admin(self, org_id: bson.ObjectId, patch: dict): """Assigns a single user by User ID as admin of the organization. The calling user must be admin of the organization. """ self._assert_is_admin(org_id) # Do some basic validation. try: user_id = patch['user_id'] except KeyError: raise wz_exceptions.BadRequest('No key "user_id" in patch.') user_oid = str2id(user_id) log.info('User %s uses PATCH to set user %s as admin for organization %s', current_user().user_id, user_oid, org_id) current_app.org_manager.assign_admin(org_id, user_id=user_oid)
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. """ # 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 # Users require admin rights on the org, except when removing themselves. current_user_id = current_user().user_id if user_oid is None or user_oid != current_user_id: self._assert_is_admin(org_id) log.info('User %s uses PATCH to remove user %s from organization %s', current_user_id, user_oid, org_id) org_doc = current_app.org_manager.remove_user(org_id, user_id=user_oid, email=email) return jsonify(org_doc)
def patch_construct(self, job_id: bson.ObjectId, patch: dict): """Send job to 'under-construction' status and compile its tasks. The patch can contain extra settings for the job, such as 'filepath' for Blender render jobs. """ job = self.assert_job_access(job_id) job_status = job.get('status', '-unset-') if job_status != 'waiting-for-files': log.info('User %s wants to PATCH to construct job %s; rejecting because it is in ' 'status %r', current_user_id(), job_id, job_status) raise wz_exceptions.UnprocessableEntity( f'Current job status {job_status} does not allow job construction') # TODO(Sybren): add job settings handling. user = current_user() new_job_settings = patch.get('settings') log.info('User %s uses PATCH to construct job %s with settings %s', user.user_id, job_id, new_job_settings) current_flamenco.job_manager.api_construct_job( job_id, new_job_settings, reason=f'Construction initiated by {user.full_name} (@{user.username})')
def patch_edit_from_web(self, org_id: bson.ObjectId, patch: dict): """Updates Organization fields from the web. The PATCH command supports the following payload. The 'name' field must be set, all other fields are optional. When an optional field is ommitted it will be handled as an instruction to clear that field. {'name': str, 'description': str, 'website': str, 'location': str, 'ip_ranges': list of human-readable IP ranges} """ from pymongo.results import UpdateResult from . import ip_ranges self._assert_is_admin(org_id) user = current_user() current_user_id = user.user_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(), 'location': patch.get('location', '').strip(), } unset = {} # Special transformation for IP ranges iprs = patch.get('ip_ranges') if iprs: ipr_docs = [] for r in iprs: try: doc = ip_ranges.doc(r, min_prefixlen6=48, min_prefixlen4=8) except ValueError as ex: raise wz_exceptions.UnprocessableEntity( f'Invalid IP range {r!r}: {ex}') ipr_docs.append(doc) update['ip_ranges'] = ipr_docs else: unset['ip_ranges'] = True refresh_user_roles = False if user.has_cap('admin'): if 'seat_count' in patch: update['seat_count'] = int(patch['seat_count']) if 'org_roles' in patch: org_roles = [ stripped for stripped in (role.strip() for role in patch['org_roles']) if stripped ] if not all(role.startswith('org-') for role in org_roles): raise wz_exceptions.UnprocessableEntity( 'Invalid role given, all roles must start with "org-"') update['org_roles'] = org_roles refresh_user_roles = True 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, persisted_document={}): resp = jsonify({ '_errors': validator.errors, '_message': ', '.join(f'{field}: {error}' for field, error in validator.errors.items()), }) resp.status_code = 422 return resp # Figure out what to set and what to unset for_mongo = {'$set': update} if unset: for_mongo['$unset'] = unset organizations_coll = current_app.db('organizations') result: UpdateResult = organizations_coll.update_one({'_id': org_id}, for_mongo) 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() if refresh_user_roles: self.log.info( 'Organization roles set for org %s, refreshing users', org_id) current_app.org_manager.refresh_all_user_roles(org_id) return '', 204
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) user = current_user() current_user_id = user.user_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(), 'location': patch.get('location', '').strip(), } refresh_user_roles = False if user.has_cap('admin'): if 'seat_count' in patch: update['seat_count'] = int(patch['seat_count']) if 'org_roles' in patch: org_roles = [ stripped for stripped in (role.strip() for role in patch['org_roles']) if stripped ] if not all(role.startswith('org-') for role in org_roles): raise wz_exceptions.UnprocessableEntity( 'Invalid role given, all roles must start with "org-"') update['org_roles'] = org_roles refresh_user_roles = True 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() if refresh_user_roles: self.log.info( 'Organization roles set for org %s, refreshing users', org_id) current_app.org_manager.refresh_all_user_roles(org_id) return '', 204
def pre_post_organizations(request): user = current_user() if not user.has_cap('create-organization'): raise wz_exceptions.Forbidden()