diff --git a/tests/base.py b/tests/base.py index 4bee6867d2..5aaa951f5e 100644 --- a/tests/base.py +++ b/tests/base.py @@ -3,6 +3,7 @@ import orjson as json import os import ntpath +import fakeredis from mixer.backend.flask import mixer @@ -42,9 +43,14 @@ from zou.app.models.task_type import TaskType from zou.app.models.software import Software from zou.app.models.working_file import WorkingFile +from zou.app.stores import auth_tokens_store TEST_FOLDER = os.path.join("tests", "tmp") +auth_tokens_store.revoked_tokens_store = fakeredis.FakeStrictRedis( + decode_responses=True +) + class ApiTestCase(unittest.TestCase): """ diff --git a/tests/misc/test_commands.py b/tests/misc/test_commands.py index 1970d49c8a..996b95d543 100644 --- a/tests/misc/test_commands.py +++ b/tests/misc/test_commands.py @@ -1,4 +1,3 @@ -import orjson as json import datetime from tests.base import ApiDBTestCase @@ -20,68 +19,17 @@ class CommandsTestCase(ApiDBTestCase): def setUp(self): super(CommandsTestCase, self).setUp() self.store = auth_tokens_store - for key in self.store.keys(): - self.store.delete(key) + self.store.clear() def test_clean_auth_tokens_revoked(self): - now = datetime.datetime.now() - self.store.add( - "testkey", - json.dumps( - { - "token": { - "exp": totimestamp(now + datetime.timedelta(days=8)) - }, - "revoked": False, - } - ), - ) - self.store.add( - "testkey2", - json.dumps( - { - "token": { - "exp": totimestamp(now + datetime.timedelta(days=8)) - }, - "revoked": True, - } - ), - ) + self.store.add("testkey", "false") + self.store.add("testkey2", "false") self.assertEqual(len(self.store.keys()), 2) + self.store.add("testkey2", "true") commands.clean_auth_tokens() self.assertEqual(len(self.store.keys()), 1) self.assertEqual(self.store.keys()[0], "testkey") - def test_clean_auth_tokens_expired(self): - now = datetime.datetime.now() - self.store.add( - "testkey", - json.dumps( - { - "token": { - "exp": totimestamp(now - datetime.timedelta(days=8)) - }, - "revoked": False, - } - ), - ) - self.store.add( - "testkey2", - json.dumps( - { - "token": { - "exp": totimestamp(now + datetime.timedelta(days=8)) - }, - "revoked": False, - } - ), - ) - - self.assertEqual(len(self.store.keys()), 2) - commands.clean_auth_tokens() - self.assertEqual(len(self.store.keys()), 1) - self.assertEqual(self.store.keys()[0], "testkey2") - def test_init_data(self): commands.init_data() task_types = TaskType.get_all() diff --git a/tests/services/test_tasks_service.py b/tests/services/test_tasks_service.py index c92830c0c1..c3bc4b43f7 100644 --- a/tests/services/test_tasks_service.py +++ b/tests/services/test_tasks_service.py @@ -13,6 +13,7 @@ preview_files_service, tasks_service, persons_service, + identities_service, ) from zou.app.utils import events, fields @@ -454,11 +455,11 @@ def test_get_comments(self): comments = tasks_service.get_comments(self.task_id, is_manager=False) self.assertEqual(len(comments), 3) - old_get_current_user = persons_service.get_current_user - persons_service.get_current_user = self.get_current_user + old_get_current_user = identities_service.get_current_identity + identities_service.get_current_identity = self.get_current_user comments = tasks_service.get_comments(self.task_id, is_client=True) self.assertEqual(len(comments), 1) - persons_service.get_current_user = old_get_current_user + identities_service.get_current_identity = old_get_current_user def test_new_comment(self): comment = comments_service.new_comment( diff --git a/tests/services/test_user_service.py b/tests/services/test_user_service.py index 9aaed73de4..e34b050137 100644 --- a/tests/services/test_user_service.py +++ b/tests/services/test_user_service.py @@ -4,10 +4,10 @@ from zou.app.models.person import Person from zou.app.services import ( comments_service, - persons_service, projects_service, tasks_service, user_service, + identities_service, ) from zou.app.utils import permissions @@ -45,15 +45,19 @@ def setUp(self): self.wip_status_id = self.task_status_wip.id self.to_review_status_id = self.task_status_to_review.id - self.old_get_current_user = persons_service.get_current_user - persons_service.get_current_user = self.get_current_user - self.old_get_current_user_raw = persons_service.get_current_user_raw - persons_service.get_current_user_raw = self.get_current_user_raw + self.old_get_current_user = identities_service.get_current_identity + identities_service.get_current_identity = self.get_current_user + self.old_get_current_user_raw = ( + identities_service.get_current_identity_raw + ) + identities_service.get_current_identity_raw = self.get_current_user_raw def tearDown(self): super(UserServiceTestCase, self).tearDown() - persons_service.get_current_user = self.old_get_current_user - persons_service.get_current_user_raw = self.old_get_current_user_raw + identities_service.get_current_identity = self.old_get_current_user + identities_service.get_current_identity_raw = ( + self.old_get_current_user_raw + ) def get_current_user(self): return self.user @@ -101,7 +105,7 @@ def test_check_entity_access(self): self.assertTrue(user_service.check_entity_access(str(self.asset_id))) def test_get_last_notifications(self): - persons_service.get_current_user = self.get_current_user_artist + identities_service.get_current_identity = self.get_current_user_artist self.generate_fixture_user_cg_artist() self.log_in_cg_artist() person_id = self.user_cg_artist["id"] diff --git a/tests/stores/test_auth_tokens_store.py b/tests/stores/test_auth_tokens_store.py index b4e9fcea25..6866e21362 100644 --- a/tests/stores/test_auth_tokens_store.py +++ b/tests/stores/test_auth_tokens_store.py @@ -1,5 +1,7 @@ +import time from tests.base import ApiTestCase + from zou.app.stores import auth_tokens_store @@ -23,14 +25,23 @@ def test_delete(self): self.assertIsNone(self.store.get("key-1")) def test_is_revoked(self): - self.assertTrue(self.store.is_revoked({"jti": "key-1"})) + self.assertTrue(self.store.is_revoked("key-1")) self.store.add("key-1", "true") - self.assertTrue(self.store.is_revoked({"jti": "key-1"})) + self.assertTrue(self.store.is_revoked("key-1")) self.store.add("key-1", "false") - self.assertFalse(self.store.is_revoked({"jti": "key-1"})) + self.assertFalse(self.store.is_revoked("key-1")) def test_keys(self): self.store.add("key-1", "true") self.store.add("key-2", "true") self.assertTrue("key-1" in self.store.keys()) self.assertTrue("key-2" in self.store.keys()) + + def test_ttl(self): + self.store.add("key-1", "true", ttl=10) + self.assertEqual(self.store.get("key-1"), "true") + self.store.add("key-2", "true", ttl=1) + self.assertEqual(self.store.get("key-2"), "true") + self.store.add("key-3", "true", ttl=1) + time.sleep(1) + self.assertIsNone(self.store.get("key-3")) diff --git a/zou/app/__init__.py b/zou/app/__init__.py index 2dd2852471..854912f873 100644 --- a/zou/app/__init__.py +++ b/zou/app/__init__.py @@ -5,7 +5,7 @@ from flask import Flask, jsonify, current_app from flasgger import Swagger from flask_jwt_extended import JWTManager -from flask_principal import Principal, identity_changed, Identity +from flask_principal import Principal from flask_sqlalchemy import SQLAlchemy from flask_migrate import Migrate from flask_mail import Mail @@ -21,7 +21,7 @@ from zou.app.stores import auth_tokens_store from zou.app.services.exception import ( ModelWithRelationsDeletionException, - PersonNotFoundException, + IdentityNotFoundException, WrongIdFormatException, WrongParameterException, WrongTaskTypeForEntityException, @@ -140,24 +140,21 @@ def server_error(error): def configure_auth(): - from zou.app.services import persons_service + from zou.app.services import identities_service @jwt.token_in_blocklist_loader def check_if_token_is_revoked(_, payload): - return auth_tokens_store.is_revoked(payload) + return auth_tokens_store.is_revoked( + payload["jti"] + ) # and ApiToken.get_by(jti=payload["jti"]) is None @jwt.user_lookup_loader - def add_permissions(_, payload): + def user_lookup_callback(_, payload): try: - user = persons_service.get_person(payload["user_id"]) - if user is not None: - identity_changed.send( - current_app._get_current_object(), - identity=Identity(user["id"]), - ) - return user - except PersonNotFoundException: + identity = identities_service.get_identity_raw(payload["sub"]) + except IdentityNotFoundException: return None + return identity def load_api(): diff --git a/zou/app/blueprints/assets/resources.py b/zou/app/blueprints/assets/resources.py index 37eebd4869..e991eecf4b 100644 --- a/zou/app/blueprints/assets/resources.py +++ b/zou/app/blueprints/assets/resources.py @@ -11,6 +11,7 @@ shots_service, tasks_service, user_service, + identities_service, ) @@ -100,9 +101,9 @@ def get(self): criterions = query.get_query_criterions_from_request(request) check_criterion_access(criterions) if permissions.has_vendor_permissions(): - criterions["assigned_to"] = persons_service.get_current_user()[ - "id" - ] + criterions[ + "assigned_to" + ] = identities_service.get_current_identity()["id"] return assets_service.get_assets(criterions) @@ -133,12 +134,12 @@ def get(self): page = self.get_page() check_criterion_access(criterions) if permissions.has_vendor_permissions(): - criterions["assigned_to"] = persons_service.get_current_user()[ - "id" - ] + criterions[ + "assigned_to" + ] = identities_service.get_current_identity()["id"] criterions["vendor_departments"] = [ str(department.id) - for department in persons_service.get_current_user_raw().departments + for department in identities_service.get_current_identity_raw().departments ] return assets_service.get_assets_and_tasks(criterions, page) @@ -271,9 +272,9 @@ def get(self, project_id): criterions = query.get_query_criterions_from_request(request) criterions["project_id"] = project_id if permissions.has_vendor_permissions(): - criterions["assigned_to"] = persons_service.get_current_user()[ - "id" - ] + criterions[ + "assigned_to" + ] = identities_service.get_current_identity()["id"] return assets_service.get_assets(criterions) @@ -311,9 +312,9 @@ def get(self, project_id, asset_type_id): criterions["project_id"] = project_id criterions["entity_type_id"] = asset_type_id if permissions.has_vendor_permissions(): - criterions["assigned_to"] = persons_service.get_current_user()[ - "id" - ] + criterions[ + "assigned_to" + ] = identities_service.get_current_identity()["id"] return assets_service.get_assets(criterions) diff --git a/zou/app/blueprints/auth/resources.py b/zou/app/blueprints/auth/resources.py index cad0fe6028..8bddec7475 100644 --- a/zou/app/blueprints/auth/resources.py +++ b/zou/app/blueprints/auth/resources.py @@ -11,6 +11,15 @@ identity_changed, identity_loaded, ) +from flask_jwt_extended import ( + jwt_required, + create_access_token, + create_refresh_token, + get_jwt, + set_access_cookies, + set_refresh_cookies, + unset_jwt_cookies, +) from sqlalchemy.exc import OperationalError, TimeoutError from babel.dates import format_datetime @@ -18,7 +27,12 @@ from zou.app import app, config from zou.app.mixin import ArgsMixin from zou.app.utils import auth, emails -from zou.app.services import persons_service, auth_service, events_service +from zou.app.services import ( + persons_service, + auth_service, + events_service, + identities_service, +) from zou.app.stores import auth_tokens_store from zou.app.services.exception import ( EmailOTPAlreadyEnabledException, @@ -30,6 +44,7 @@ NoAuthStrategyConfigured, NoTwoFactorAuthenticationEnabled, PersonNotFoundException, + IdentityNotFoundException, TooMuchLoginFailedAttemps, TOTPAlreadyEnabledException, TOTPNotEnabledException, @@ -41,18 +56,6 @@ ) -from flask_jwt_extended import ( - jwt_required, - create_access_token, - create_refresh_token, - get_jwt_identity, - get_jwt, - set_access_cookies, - set_refresh_cookies, - unset_jwt_cookies, -) - - def is_from_browser(user_agent): return user_agent.browser in [ "Brave", @@ -67,8 +70,7 @@ def is_from_browser(user_agent): def logout(): try: - current_token = get_jwt() - jti = current_token["jti"] + jti = get_jwt()["jti"] auth_service.revoke_tokens(app, jti) except Exception: pass @@ -82,49 +84,49 @@ def wrong_auth_handler(identity_user=None): @identity_loaded.connect_via(app) -def on_identity_loaded(sender, identity): - if isinstance(identity.id, str): - try: - identity.user = persons_service.get_person(identity.id) - - if hasattr(identity.user, "id"): - identity.provides.add(UserNeed(identity.user["id"])) +def on_identity_loaded(_, identity): + try: + if isinstance(identity.id, str): + identity.user = identities_service.get_identity_raw(identity.id) if identity.user is None: - raise PersonNotFoundException + raise IdentityNotFoundException + + if hasattr(identity.user, "id"): + identity.provides.add(UserNeed(identity.user.id)) - if identity.user["role"] == "admin": + if identity.user.role == "admin": identity.provides.add(RoleNeed("admin")) identity.provides.add(RoleNeed("manager")) - if identity.user["role"] == "manager": + if identity.user.role == "manager": identity.provides.add(RoleNeed("manager")) - if identity.user["role"] == "supervisor": + if identity.user.role == "supervisor": identity.provides.add(RoleNeed("supervisor")) - if identity.user["role"] == "client": + if identity.user.role == "client": identity.provides.add(RoleNeed("client")) - if identity.user["role"] == "vendor": + if identity.user.role == "vendor": identity.provides.add(RoleNeed("vendor")) - if not identity.user["active"]: + if not identity.user.active: current_app.logger.error("Current user is not active anymore") logout() return wrong_auth_handler(identity.user) - return identity - except PersonNotFoundException: - return wrong_auth_handler() - except TimeoutError: - current_app.logger.error("Identity loading timed out") - return wrong_auth_handler() - except Exception as exception: - current_app.logger.error(exception, exc_info=1) - if hasattr(exception, "message"): - current_app.logger.error(exception.message) - return wrong_auth_handler() + return identity + except IdentityNotFoundException: + return wrong_auth_handler() + except TimeoutError: + current_app.logger.error("Identity loading timed out") + return wrong_auth_handler() + except Exception as exception: + current_app.logger.error(exception, exc_info=1) + if hasattr(exception, "message"): + current_app.logger.error(exception.message) + return wrong_auth_handler() class AuthenticatedResource(Resource): @@ -151,9 +153,7 @@ def get(self): description: Person not found """ try: - person = persons_service.get_person_by_email( - get_jwt_identity(), relations=True - ) + person = identities_service.get_current_identity(relations=True) organisation = persons_service.get_organisation() return { "authenticated": True, @@ -284,12 +284,18 @@ def post(self): ) access_token = create_access_token( - identity=user["email"], - additional_claims={"user_id": user["id"]}, + identity=user["id"], + additional_claims={ + "email": user["email"], + "identity_type": "person", + }, ) refresh_token = create_refresh_token( - identity=user["email"], - additional_claims={"user_id": user["id"]}, + identity=user["id"], + additional_claims={ + "email": user["email"], + "identity_type": "person", + }, ) auth_service.register_tokens(app, access_token, refresh_token) identity_changed.send( @@ -449,10 +455,13 @@ def get(self): 200: description: Access Token """ - user = persons_service.get_current_user() + user = identities_service.get_current_identity() access_token = create_access_token( - identity=user["email"], - additional_claims={"user_id": user["id"]}, + identity=user["id"], + additional_claims={ + "email": user["email"], + "identity_type": "person", + }, ) auth_service.register_tokens(app, access_token) if is_from_browser(request.user_agent): @@ -618,7 +627,7 @@ def post(self): (old_password, password, password_2) = self.get_arguments() try: - user = persons_service.get_current_user() + user = identities_service.get_current_identity() auth_service.check_auth( app, user["email"], old_password, no_otp=True ) @@ -874,7 +883,7 @@ def put(self): """ try: totp_provisionning_uri, totp_secret = auth_service.pre_enable_totp( - persons_service.get_current_user()["id"] + identities_service.get_current_identity()["id"] ) return { "totp_provisionning_uri": totp_provisionning_uri, @@ -906,7 +915,7 @@ def post(self): try: otp_recovery_codes = auth_service.enable_totp( - persons_service.get_current_user()["id"], args["totp"] + identities_service.get_current_identity()["id"], args["totp"] ) return {"otp_recovery_codes": otp_recovery_codes} except TOTPAlreadyEnabledException: @@ -948,7 +957,7 @@ def delete(self): ) try: - person = persons_service.get_current_user(unsafe=True) + person = identities_service.get_current_identity(unsafe=True) if not auth_service.person_two_factor_authentication_enabled( person ): @@ -1009,7 +1018,7 @@ def get(self): try: try: - person = persons_service.get_person_by_email_dekstop_login( + person = persons_service.get_person_by_email_desktop_login( args["email"] ) except PersonNotFoundException: @@ -1047,7 +1056,7 @@ def put(self): """ try: auth_service.pre_enable_email_otp( - persons_service.get_current_user()["id"] + identities_service.get_current_identity()["id"] ) return {"success": True} except EmailOTPAlreadyEnabledException: @@ -1076,7 +1085,8 @@ def post(self): try: otp_recovery_codes = auth_service.enable_email_otp( - persons_service.get_current_user()["id"], args["email_otp"] + identities_service.get_current_identity()["id"], + args["email_otp"], ) return {"otp_recovery_codes": otp_recovery_codes} except EmailOTPAlreadyEnabledException: @@ -1121,7 +1131,7 @@ def delete(self): ) try: - person = persons_service.get_current_user(unsafe=True) + person = identities_service.get_current_identity(unsafe=True) if not auth_service.person_two_factor_authentication_enabled( person ): @@ -1180,7 +1190,7 @@ def get(self): try: try: - person = persons_service.get_person_by_email_dekstop_login( + person = persons_service.get_person_by_email_desktop_login( args["email"] ) except PersonNotFoundException: @@ -1216,7 +1226,7 @@ def put(self): Inactive user """ return auth_service.pre_register_fido( - persons_service.get_current_user()["id"] + identities_service.get_current_identity()["id"] ) @jwt_required() @@ -1244,7 +1254,7 @@ def post(self): ) otp_recovery_codes = auth_service.register_fido( - persons_service.get_current_user()["id"], + identities_service.get_current_identity()["id"], args["registration_response"], args["device_name"], ) @@ -1291,7 +1301,7 @@ def delete(self): ) try: - person = persons_service.get_current_user(unsafe=True) + person = identities_service.get_current_identity(unsafe=True) if not auth_service.person_two_factor_authentication_enabled( person ): @@ -1353,7 +1363,7 @@ def put(self): ) try: - person = persons_service.get_current_user(unsafe=True) + person = identities_service.get_current_identity(unsafe=True) if not auth_service.person_two_factor_authentication_enabled( person ): diff --git a/zou/app/blueprints/comments/resources.py b/zou/app/blueprints/comments/resources.py index 9559464ca8..a928eebd05 100644 --- a/zou/app/blueprints/comments/resources.py +++ b/zou/app/blueprints/comments/resources.py @@ -13,6 +13,7 @@ persons_service, tasks_service, user_service, + identities_service, ) from zou.app import config @@ -257,7 +258,7 @@ def delete(self, task_id, comment_id, attachment_id): 204: description: Empty response """ - user = persons_service.get_current_user() + user = identities_service.get_current_identity() comment = tasks_service.get_comment(comment_id) if comment["person_id"] != user["id"]: permissions.check_admin_permissions() @@ -300,7 +301,7 @@ def post(self, task_id, comment_id): 201: description: Given files added to the comment entry as attachments """ - user = persons_service.get_current_user() + user = identities_service.get_current_identity() comment = tasks_service.get_comment(comment_id) if comment["person_id"] != user["id"]: permissions.check_admin_permissions() @@ -371,7 +372,7 @@ def post(self, project_id): description: Given files added to the comment entry as attachments """ comments = request.json - person = persons_service.get_current_user(relations=True) + person = identities_service.get_current_identity(relations=True) try: user_service.check_manager_project_access(project_id) except permissions.PermissionDenied: @@ -503,7 +504,7 @@ def delete(self, task_id, comment_id, reply_id): user_service.check_project_access(task["project_id"]) user_service.check_entity_access(task["entity_id"]) reply = comments_service.get_reply(comment_id, reply_id) - current_user = persons_service.get_current_user() + current_user = identities_service.get_current_identity() if reply["person_id"] != current_user["id"]: permissions.check_admin_permissions() return comments_service.delete_reply(comment_id, reply_id) diff --git a/zou/app/blueprints/crud/__init__.py b/zou/app/blueprints/crud/__init__.py index 70c4ef07b6..cb5f77de65 100644 --- a/zou/app/blueprints/crud/__init__.py +++ b/zou/app/blueprints/crud/__init__.py @@ -64,6 +64,10 @@ ) from zou.app.blueprints.crud.news import NewssResource, NewsResource from zou.app.blueprints.crud.person import PersonResource, PersonsResource +from zou.app.blueprints.crud.api_token import ( + ApiTokenResource, + ApiTokensResource, +) from zou.app.blueprints.crud.preview_file import ( PreviewFilesResource, PreviewFileResource, @@ -123,6 +127,8 @@ routes = [ ("/data/persons", PersonsResource), ("/data/persons/", PersonResource), + ("/data/api-tokens", ApiTokensResource), + ("/data/api-tokens/", ApiTokenResource), ("/data/projects", ProjectsResource), ("/data/projects/", ProjectResource), ("/data/project-status", ProjectStatussResource), diff --git a/zou/app/blueprints/crud/api_token.py b/zou/app/blueprints/crud/api_token.py new file mode 100644 index 0000000000..4b11c681c9 --- /dev/null +++ b/zou/app/blueprints/crud/api_token.py @@ -0,0 +1,167 @@ +from flask import abort +from flask_jwt_extended import jwt_required, create_access_token, get_jti +from flask_restful import current_app +from sqlalchemy.exc import StatementError + +from zou.app.models.api_token import ApiToken +from zou.app.services import ( + api_tokens_service, + deletion_service, + index_service, +) +from zou.app.utils import permissions + +from zou.app.blueprints.crud.base import BaseModelsResource, BaseModelResource + +from zou.app.mixin import ArgsMixin + +from zou.app.services.exception import ( + DepartmentNotFoundException, + ApiTokenInProtectedAccounts, +) +from zou.app.models.department import Department + +from zou.app import config + + +class ApiTokensResource(BaseModelsResource): + def __init__(self): + BaseModelsResource.__init__(self, ApiToken) + + def all_entries(self, query=None, relations=False): + if query is None: + query = self.model.query + + if permissions.has_admin_permissions(): + return [ + api_token.serialize_safe(relations=relations) + for api_token in query.all() + ] + else: + return [ + api_token.present_minimal(relations=relations) + for api_token in query.all() + ] + + def update_data(self, data): + if "departments" in data: + try: + departments = [] + for department_id in data["departments"]: + department = Department.get(department_id) + if department is not None: + departments.append(department) + except StatementError: + raise DepartmentNotFoundException() + data["departments"] = departments + return data + + def post_creation(self, instance): + api_tokens_service.clear_api_token_cache() + access_token = create_access_token( + identity=instance.id, + additional_claims={ + "email": instance.email, + "identity_type": "api_token", + }, + ) + instance.jti = get_jti(access_token) + instance.save() + instance_dict = instance.serialize() + instance_dict["access_token"] = access_token + return instance_dict + + def check_read_permissions(self): + return True + + +class ApiTokenResource(BaseModelResource, ArgsMixin): + def __init__(self): + BaseModelResource.__init__(self, ApiToken) + self.protected_fields += ["jti"] + + def check_read_permissions(self, instance): + return True + + @jwt_required() + def get(self, instance_id): + """ + Retrieves the given API token. + """ + relations = self.get_bool_parameter("relations") + + try: + instance = self.get_model_or_404(instance_id) + result = self.serialize_instance(instance, relations=relations) + self.check_read_permissions(result) + result = self.clean_get_result(result) + + except StatementError as exception: + current_app.logger.error(str(exception), exc_info=1) + return {"message": str(exception)}, 400 + + except ValueError: + abort(404) + + return result, 200 + + def serialize_instance(self, instance, relations=False): + if permissions.has_admin_permissions(): + return instance.serialize_safe(relations=relations) + else: + return instance.present_minimal(relations=relations) + + def pre_update(self, instance_dict, data): + if ( + data.get("active") is False + and instance_dict["email"] in config.PROTECTED_ACCOUNTS + ): + raise ApiTokenInProtectedAccounts( + "Can't set this API token as inactive it's a protected account." + ) + return data + + def post_update(self, instance_dict): + api_tokens_service.clear_api_token_cache() + api_token = api_tokens_service.get_api_token_raw(instance_dict["id"]) + if api_token.active: + index_service.index_api_token(api_token) + instance_dict["departments"] = [ + str(department.id) for department in self.instance.departments + ] + return instance_dict + + def post_delete(self, instance_dict): + api_tokens_service.clear_api_token_cache() + return instance_dict + + def update_data(self, data, instance_id): + if "jti" in data: + del data["jti"] + if "departments" in data: + try: + departments = [] + for department_id in data["departments"]: + department = Department.get(department_id) + if department is not None: + departments.append(department) + except StatementError: + raise DepartmentNotFoundException() + data["departments"] = departments + return data + + @jwt_required() + def delete(self, instance_id): + """ + Delete an API token corresponding at given ID and return it as a JSON + object. + """ + force = self.get_force() + api_token = self.get_model_or_404(instance_id) + api_token_dict = api_token.serialize() + self.check_delete_permissions(api_token_dict) + self.pre_delete(api_token_dict) + deletion_service.remove_api_token(instance_id, force=force) + self.emit_delete_event(api_token_dict) + self.post_delete(api_token_dict) + return "", 204 diff --git a/zou/app/blueprints/crud/comments.py b/zou/app/blueprints/crud/comments.py index 08afe80fed..af80542337 100644 --- a/zou/app/blueprints/crud/comments.py +++ b/zou/app/blueprints/crud/comments.py @@ -13,6 +13,7 @@ persons_service, tasks_service, user_service, + identities_service, ) from zou.app.utils import events, permissions @@ -103,7 +104,7 @@ def check_update_permissions(self, instance, data): return True else: comment = self.get_model_or_404(instance["id"]) - current_user = persons_service.get_current_user() + current_user = identities_service.get_current_identity() return current_user["id"] == str(comment.person_id) def pre_delete(self, comment): diff --git a/zou/app/blueprints/crud/entity.py b/zou/app/blueprints/crud/entity.py index 8a1807c66a..84d5e7412a 100644 --- a/zou/app/blueprints/crud/entity.py +++ b/zou/app/blueprints/crud/entity.py @@ -16,6 +16,7 @@ persons_service, shots_service, user_service, + identities_service, ) from zou.app.utils import events, fields, date_helpers @@ -171,7 +172,7 @@ def save_version_if_needed(self, shot, previous_shot): pname = previous_shot["name"] version = None if frame_in != pframe_in or frame_out != pframe_out or name != pname: - current_user_id = persons_service.get_current_user()["id"] + current_user_id = identities_service.get_current_identity()["id"] previous_updated_at = fields.get_date_object( previous_shot["updated_at"], date_format="%Y-%m-%dT%H:%M:%S" ) diff --git a/zou/app/blueprints/crud/person.py b/zou/app/blueprints/crud/person.py index 1022f089fd..7f1e29e53c 100644 --- a/zou/app/blueprints/crud/person.py +++ b/zou/app/blueprints/crud/person.py @@ -4,7 +4,12 @@ from sqlalchemy.exc import StatementError from zou.app.models.person import Person -from zou.app.services import deletion_service, index_service, persons_service +from zou.app.services import ( + deletion_service, + index_service, + persons_service, + identities_service, +) from zou.app.utils import permissions from zou.app.blueprints.crud.base import BaseModelsResource, BaseModelResource @@ -21,7 +26,7 @@ from zou.app import config -class PersonsResource(BaseModelsResource, ArgsMixin): +class PersonsResource(BaseModelsResource): def __init__(self): BaseModelsResource.__init__(self, Person) @@ -62,14 +67,20 @@ def check_read_permissions(self, instance): return True def check_update_permissions(self, instance_dict, data): - if instance_dict["id"] != persons_service.get_current_user()["id"]: + if ( + instance_dict["id"] + != identities_service.get_current_identity()["id"] + ): self.check_escalation_permissions(instance_dict, data) else: data.pop("role", None) return instance_dict def check_delete_permissions(self, instance_dict): - if instance_dict["id"] == persons_service.get_current_user()["id"]: + if ( + instance_dict["id"] + == identities_service.get_current_identity()["id"] + ): raise permissions.PermissionDenied self.check_escalation_permissions(instance_dict) return instance_dict diff --git a/zou/app/blueprints/edits/resources.py b/zou/app/blueprints/edits/resources.py index c740aa6980..76d963df2e 100644 --- a/zou/app/blueprints/edits/resources.py +++ b/zou/app/blueprints/edits/resources.py @@ -9,6 +9,7 @@ edits_service, tasks_service, user_service, + identities_service, ) from zou.app.mixin import ArgsMixin @@ -100,9 +101,9 @@ def get(self): criterions = query.get_query_criterions_from_request(request) user_service.check_project_access(criterions.get("project_id", None)) if permissions.has_vendor_permissions(): - criterions["assigned_to"] = persons_service.get_current_user()[ - "id" - ] + criterions[ + "assigned_to" + ] = identities_service.get_current_identity()["id"] return edits_service.get_edits(criterions) @@ -138,9 +139,9 @@ def get(self): """ criterions = query.get_query_criterions_from_request(request) if permissions.has_vendor_permissions(): - criterions["assigned_to"] = persons_service.get_current_user()[ - "id" - ] + criterions[ + "assigned_to" + ] = identities_service.get_current_identity()["id"] user_service.check_project_access(criterions.get("project_id", None)) return edits_service.get_edits(criterions) @@ -312,12 +313,12 @@ def get(self): criterions = query.get_query_criterions_from_request(request) user_service.check_project_access(criterions.get("project_id", None)) if permissions.has_vendor_permissions(): - criterions["assigned_to"] = persons_service.get_current_user()[ - "id" - ] + criterions[ + "assigned_to" + ] = identities_service.get_current_identity()["id"] criterions["vendor_departments"] = [ str(department.id) - for department in persons_service.get_current_user_raw().departments + for department in identities_service.get_current_identity_raw().departments ] return edits_service.get_edits_and_tasks(criterions) diff --git a/zou/app/blueprints/export/csv/playlists.py b/zou/app/blueprints/export/csv/playlists.py index 44d6c27965..dff306181d 100644 --- a/zou/app/blueprints/export/csv/playlists.py +++ b/zou/app/blueprints/export/csv/playlists.py @@ -14,6 +14,7 @@ shots_service, user_service, tasks_service, + identities_service, ) from zou.app.utils import csv_utils @@ -69,7 +70,7 @@ def build_headers(self, playlist, project, episode=None): if episode: context_name += " - %s" % episode["name"] context_name += " | %s" % entity_type - timezone = persons_service.get_current_user()["timezone"] + timezone = identities_service.get_current_identity()["timezone"] created_at = date_helpers.get_date_string_with_timezone( playlist["created_at"], timezone ) @@ -137,7 +138,7 @@ def get_author(self, comment): def get_date(self, comment): comment_date = comment.get("date", None) if comment_date is not None: - timezone = persons_service.get_current_user()["timezone"] + timezone = identities_service.get_current_identity()["timezone"] return date_helpers.get_date_string_with_timezone( comment_date, timezone ) diff --git a/zou/app/blueprints/export/csv/time_spents.py b/zou/app/blueprints/export/csv/time_spents.py index f724f902c5..345c5789c7 100644 --- a/zou/app/blueprints/export/csv/time_spents.py +++ b/zou/app/blueprints/export/csv/time_spents.py @@ -9,7 +9,7 @@ from zou.app.models.task import Task from zou.app.models.task_type import TaskType -from zou.app.services import names_service, persons_service +from zou.app.services import names_service, persons_service, identities_service from zou.app.utils import date_helpers @@ -18,7 +18,7 @@ def __init__(self): BaseCsvExport.__init__(self) def prepare_import(self): - user = persons_service.get_current_user() + user = identities_service.get_current_identity() date = date_helpers.get_today_string_with_timezone(user["timezone"]) self.file_name = "%s_open_projects_time_spents_export" % date diff --git a/zou/app/blueprints/files/resources.py b/zou/app/blueprints/files/resources.py index ad057827e1..0e76e4f4c8 100644 --- a/zou/app/blueprints/files/resources.py +++ b/zou/app/blueprints/files/resources.py @@ -20,6 +20,7 @@ tasks_service, entities_service, user_service, + identities_service, ) from zou.app.services.exception import ( @@ -674,7 +675,7 @@ def post(self, task_id): user_service.check_entity_access(task["entity_id"]) software = files_service.get_software(software_id) tasks_service.assign_task( - task_id, persons_service.get_current_user()["id"] + task_id, identities_service.get_current_identity()["id"] ) if revision == 0: @@ -708,7 +709,7 @@ def build_path(self, task, name, revision, software, sep, mode): return "%s%s%s" % (folder_path, sep, file_name) def get_arguments(self): - person = persons_service.get_current_user() + person = identities_service.get_current_identity() maxsoft = files_service.get_or_create_software( "3ds Max", "max", ".max" ) @@ -947,7 +948,7 @@ def post(self, entity_id): task_type = tasks_service.get_task_type(args["task_type_id"]) if args["person_id"] is None: - person = persons_service.get_current_user() + person = identities_service.get_current_identity() else: person = persons_service.get_person(args["person_id"]) @@ -1182,7 +1183,7 @@ def post(self, asset_instance_id, temporal_entity_id): output_type = files_service.get_output_type(args["output_type_id"]) task_type = tasks_service.get_task_type(args["task_type_id"]) if args["person_id"] is None: - person = persons_service.get_current_user() + person = identities_service.get_current_identity() else: person = persons_service.get_person(args["person_id"]) diff --git a/zou/app/blueprints/persons/resources.py b/zou/app/blueprints/persons/resources.py index 33addebab9..a892693929 100644 --- a/zou/app/blueprints/persons/resources.py +++ b/zou/app/blueprints/persons/resources.py @@ -14,6 +14,7 @@ time_spents_service, shots_service, user_service, + identities_service, ) from zou.app.utils import permissions, csv_utils, auth, emails, fields from zou.app.services.exception import ( @@ -145,7 +146,7 @@ def get(self, person_id): 200: description: Desktop login logs """ - current_user = persons_service.get_current_user() + current_user = identities_service.get_current_identity() if ( current_user["id"] != person_id and not permissions.has_manager_permissions() @@ -182,7 +183,7 @@ def post(self, person_id): """ args = self.get_args([("date", datetime.datetime.utcnow())]) - current_user = persons_service.get_current_user() + current_user = identities_service.get_current_identity() if ( current_user["id"] != person_id and not permissions.has_admin_permissions() @@ -295,7 +296,7 @@ def get(self, person_id, date): department_ids = None project_ids = None if not permissions.has_admin_permissions(): - if persons_service.get_current_user()["id"] != person_id: + if identities_service.get_current_identity()["id"] != person_id: if ( permissions.has_manager_permissions() or permissions.has_supervisor_permissions() @@ -305,9 +306,11 @@ def get(self, person_id, date): for project in user_service.get_projects() ] if permissions.has_supervisor_permissions(): - department_ids = persons_service.get_current_user( - True - ).get("departments", []) + department_ids = ( + identities_service.get_current_identity(True).get( + "departments", [] + ) + ) else: raise permissions.PermissionDenied try: @@ -352,7 +355,7 @@ def get(self, person_id, date): 404: description: Wrong date format """ - current_user = persons_service.get_current_user() + current_user = identities_service.get_current_identity() if current_user["id"] != person_id: try: permissions.check_at_least_supervisor_permissions() @@ -373,7 +376,7 @@ def get_project_department_arguments(self, person_id): project_id = self.get_project_id() department_ids = None if not permissions.has_admin_permissions(): - if persons_service.get_current_user()["id"] != person_id: + if identities_service.get_current_identity()["id"] != person_id: if ( permissions.has_manager_permissions() or permissions.has_supervisor_permissions() @@ -387,9 +390,11 @@ def get_project_department_arguments(self, person_id): elif project_id not in project_ids: raise permissions.PermissionDenied if permissions.has_supervisor_permissions(): - department_ids = persons_service.get_current_user( - True - )["departments"] + department_ids = ( + identities_service.get_current_identity(True)[ + "departments" + ] + ) else: raise permissions.PermissionDenied @@ -800,11 +805,11 @@ def get_person_project_department_arguments(self): elif project_id not in project_ids: raise permissions.PermissionDenied if permissions.has_supervisor_permissions(): - department_ids = persons_service.get_current_user( + department_ids = identities_service.get_current_identity( relations=True )["departments"] else: - person_id = persons_service.get_current_user()["id"] + person_id = identities_service.get_current_identity()["id"] return { "person_id": person_id, @@ -984,7 +989,7 @@ def get(self, year, month): if permissions.has_admin_permissions(): return time_spents_service.get_day_offs_for_month(year, month) else: - person_id = persons_service.get_current_user()["id"] + person_id = identities_service.get_current_identity()["id"] return time_spents_service.get_person_day_offs_for_month( person_id, year, month ) @@ -1025,7 +1030,7 @@ def get(self, person_id, year, week): 200: description: All day off recorded for given week and person """ - user_id = persons_service.get_current_user()["id"] + user_id = identities_service.get_current_identity()["id"] if person_id != user_id: permissions.check_admin_permissions() return time_spents_service.get_person_day_offs_for_week( @@ -1068,7 +1073,7 @@ def get(self, person_id, year, month): 200: description: All day off recorded for given month and person """ - user_id = persons_service.get_current_user()["id"] + user_id = identities_service.get_current_identity()["id"] if person_id != user_id: permissions.check_admin_permissions() return time_spents_service.get_person_day_offs_for_month( @@ -1104,7 +1109,7 @@ def get(self, person_id, year): 200: description: All day off recorded for given year and person """ - user_id = persons_service.get_current_user()["id"] + user_id = identities_service.get_current_identity()["id"] if person_id != user_id: permissions.check_admin_permissions() return time_spents_service.get_person_day_offs_for_year( @@ -1237,7 +1242,7 @@ def post(self, person_id): try: permissions.check_admin_permissions() person = persons_service.get_person(person_id) - current_user = persons_service.get_current_user() + current_user = identities_service.get_current_identity() auth.validate_password(password, password_2) password = auth.encrypt_password(password) persons_service.update_password(person["email"], password) @@ -1332,7 +1337,7 @@ def delete(self, person_id): try: permissions.check_admin_permissions() person = persons_service.get_person(person_id) - current_user = persons_service.get_current_user() + current_user = identities_service.get_current_identity() disable_two_factor_authentication_for_person(person["id"]) current_app.logger.warning( "User %s has disabled the two factor authentication of %s" diff --git a/zou/app/blueprints/playlists/resources.py b/zou/app/blueprints/playlists/resources.py index 0b3cd1b899..fc0b92c7cc 100644 --- a/zou/app/blueprints/playlists/resources.py +++ b/zou/app/blueprints/playlists/resources.py @@ -15,6 +15,7 @@ projects_service, shots_service, user_service, + identities_service, ) from zou.app.stores import file_store, queue_store from zou.app.utils import fs, permissions @@ -300,7 +301,7 @@ def get(self, playlist_id): # remote worker can not access files local to the web app assert not remote or config.FS_BACKEND in ["s3", "swift"] - current_user = persons_service.get_current_user() + current_user = identities_service.get_current_identity() queue_store.job_queue.enqueue( playlists_service.build_playlist_job, args=( diff --git a/zou/app/blueprints/previews/resources.py b/zou/app/blueprints/previews/resources.py index 5aef00aecd..5a5b6e1691 100644 --- a/zou/app/blueprints/previews/resources.py +++ b/zou/app/blueprints/previews/resources.py @@ -21,6 +21,7 @@ shots_service, tasks_service, user_service, + identities_service, ) from zou.app.stores import queue_store from zou.utils import movie @@ -883,7 +884,7 @@ def is_exist(self, person_id): def check_permissions(self, instance_id): is_current_user = ( - persons_service.get_current_user()["id"] != instance_id + identities_service.get_current_identity()["id"] != instance_id ) if is_current_user and not permissions.has_manager_permissions(): raise permissions.PermissionDenied @@ -1113,7 +1114,7 @@ def put(self, preview_file_id): is_client = permissions.has_client_permissions() is_supervisor_allowed = False if permissions.has_supervisor_permissions(): - user_departments = persons_service.get_current_user( + user_departments = identities_service.get_current_identity( relations=True )["departments"] if ( @@ -1131,7 +1132,7 @@ def put(self, preview_file_id): additions = request.json.get("additions", []) updates = request.json.get("updates", []) deletions = request.json.get("deletions", []) - user = persons_service.get_current_user() + user = identities_service.get_current_identity() return preview_files_service.update_preview_file_annotations( user["id"], task["project_id"], diff --git a/zou/app/blueprints/shots/resources.py b/zou/app/blueprints/shots/resources.py index faa0fa7815..0f8a11b636 100644 --- a/zou/app/blueprints/shots/resources.py +++ b/zou/app/blueprints/shots/resources.py @@ -14,6 +14,7 @@ stats_service, tasks_service, user_service, + identities_service, ) from zou.app.mixin import ArgsMixin @@ -160,9 +161,9 @@ def get(self): del criterions["sequence_id"] user_service.check_project_access(criterions.get("project_id", None)) if permissions.has_vendor_permissions(): - criterions["assigned_to"] = persons_service.get_current_user()[ - "id" - ] + criterions[ + "assigned_to" + ] = identities_service.get_current_identity()["id"] return shots_service.get_shots(criterions) @@ -205,9 +206,9 @@ def get(self): criterions["parent_id"] = sequence["id"] del criterions["sequence_id"] if permissions.has_vendor_permissions(): - criterions["assigned_to"] = persons_service.get_current_user()[ - "id" - ] + criterions[ + "assigned_to" + ] = identities_service.get_current_identity()["id"] user_service.check_project_access(criterions.get("project_id", None)) return shots_service.get_shots(criterions) @@ -531,12 +532,12 @@ def get(self): criterions = query.get_query_criterions_from_request(request) user_service.check_project_access(criterions.get("project_id", None)) if permissions.has_vendor_permissions(): - criterions["assigned_to"] = persons_service.get_current_user()[ - "id" - ] + criterions[ + "assigned_to" + ] = identities_service.get_current_identity()["id"] criterions["vendor_departments"] = [ str(department.id) - for department in persons_service.get_current_user_raw().departments + for department in identities_service.get_current_identity_raw().departments ] return shots_service.get_shots_and_tasks(criterions) @@ -1197,9 +1198,9 @@ def get(self, sequence_id): criterions = query.get_query_criterions_from_request(request) criterions["parent_id"] = sequence_id if permissions.has_vendor_permissions(): - criterions["assigned_to"] = persons_service.get_current_user()[ - "id" - ] + criterions[ + "assigned_to" + ] = identities_service.get_current_identity()["id"] return shots_service.get_shots(criterions) diff --git a/zou/app/blueprints/source/csv/assets.py b/zou/app/blueprints/source/csv/assets.py index 539f1f9292..f52d3f5625 100644 --- a/zou/app/blueprints/source/csv/assets.py +++ b/zou/app/blueprints/source/csv/assets.py @@ -7,8 +7,12 @@ from zou.app.services import assets_service, projects_service, shots_service from zou.app.models.entity import Entity -from zou.app.services import comments_service, index_service, tasks_service -from zou.app.services.persons_service import get_current_user +from zou.app.services import ( + comments_service, + index_service, + tasks_service, + identities_service, +) from zou.app.services.exception import WrongParameterException from zou.app.utils import events, cache @@ -64,7 +68,7 @@ def prepare_import(self, project_id): status["id"]: [status[n].lower() for n in ("name", "short_name")] for status in tasks_service.get_task_statuses() } - self.current_user_id = get_current_user()["id"] + self.current_user_id = identities_service.get_current_identity()["id"] self.task_types_for_ready_for_map = { task_type.name: str(task_type.id) for task_type in TaskType.query.join(ProjectTaskTypeLink) diff --git a/zou/app/blueprints/source/csv/casting.py b/zou/app/blueprints/source/csv/casting.py index 98af52a1b0..a86a700f04 100644 --- a/zou/app/blueprints/source/csv/casting.py +++ b/zou/app/blueprints/source/csv/casting.py @@ -1,3 +1,4 @@ +from flask_jwt_extended import jwt_required from slugify import slugify from zou.app.blueprints.source.csv.base import BaseCsvProjectImportResource diff --git a/zou/app/blueprints/source/csv/edits.py b/zou/app/blueprints/source/csv/edits.py index 7a5ac875d0..8c666164fa 100644 --- a/zou/app/blueprints/source/csv/edits.py +++ b/zou/app/blueprints/source/csv/edits.py @@ -5,7 +5,12 @@ from zou.app.models.project import ProjectTaskTypeLink from zou.app.models.task_type import TaskType -from zou.app.services import edits_service, projects_service, shots_service +from zou.app.services import ( + edits_service, + projects_service, + shots_service, + identities_service, +) from zou.app.models.entity import Entity from zou.app.services.tasks_service import ( create_task, @@ -15,7 +20,6 @@ get_task_type, ) from zou.app.services.comments_service import create_comment -from zou.app.services.persons_service import get_current_user from zou.app.services.exception import WrongParameterException from zou.app.utils import events @@ -71,7 +75,7 @@ def prepare_import(self, project_id): status["id"]: [status[n].lower() for n in ("name", "short_name")] for status in get_task_statuses() } - self.current_user_id = get_current_user()["id"] + self.current_user_id = identities_service.get_current_identity()["id"] def get_tasks_update(self, row): tasks_update = [] diff --git a/zou/app/blueprints/source/csv/shots.py b/zou/app/blueprints/source/csv/shots.py index 19a02bc0de..ac61f7bb99 100644 --- a/zou/app/blueprints/source/csv/shots.py +++ b/zou/app/blueprints/source/csv/shots.py @@ -6,7 +6,12 @@ from zou.app.models.entity import Entity from zou.app.models.project import ProjectTaskTypeLink from zou.app.models.task_type import TaskType -from zou.app.services import shots_service, projects_service, index_service +from zou.app.services import ( + shots_service, + projects_service, + index_service, + identities_service, +) from zou.app.services.tasks_service import ( create_task, create_tasks, @@ -15,7 +20,6 @@ get_task_type, ) from zou.app.services.comments_service import create_comment -from zou.app.services.persons_service import get_current_user from zou.app.services.exception import WrongParameterException from zou.app.utils import events @@ -66,7 +70,7 @@ def prepare_import(self, project_id): status["id"]: [status[n].lower() for n in ("name", "short_name")] for status in get_task_statuses() } - self.current_user_id = get_current_user()["id"] + self.current_user_id = identities_service.get_current_identity()["id"] def get_tasks_update(self, row): tasks_update = [] diff --git a/zou/app/blueprints/source/shotgun/person.py b/zou/app/blueprints/source/shotgun/person.py index c8c6f6decb..f438c83e7b 100644 --- a/zou/app/blueprints/source/shotgun/person.py +++ b/zou/app/blueprints/source/shotgun/person.py @@ -2,7 +2,8 @@ from zou.app import db from zou.app.models.department import Department -from zou.app.models.person import Person, department_link as DepartmentLink +from zou.app.models.person import Person +from zou.app.models.identity import department_link as DepartmentLink from zou.app.blueprints.source.shotgun.exception import ( ShotgunEntryImportFailed, ) diff --git a/zou/app/blueprints/tasks/resources.py b/zou/app/blueprints/tasks/resources.py index 6a210b646d..e8b93a7d25 100644 --- a/zou/app/blueprints/tasks/resources.py +++ b/zou/app/blueprints/tasks/resources.py @@ -24,6 +24,7 @@ shots_service, tasks_service, user_service, + identities_service, ) from zou.app.utils import events, query, permissions from zou.app.mixin import ArgsMixin @@ -80,7 +81,7 @@ def post(self, task_id, comment_id): comment = tasks_service.get_comment(comment_id) tasks_service.get_task_status(comment["task_status_id"]) - person = persons_service.get_current_user() + person = identities_service.get_current_identity() preview_file = tasks_service.add_preview_file_to_comment( comment_id, person["id"], task_id, args["revision"] ) @@ -131,7 +132,7 @@ def post(self, task_id, comment_id, preview_file_id): user_service.check_entity_access(task["entity_id"]) tasks_service.get_comment(comment_id) - person = persons_service.get_current_user() + person = identities_service.get_current_identity() related_preview_file = files_service.get_preview_file(preview_file_id) preview_file = tasks_service.add_preview_file_to_comment( @@ -397,7 +398,7 @@ def get(self, person_id, task_type_id): 200: description: All Tasks for given task type """ - user = persons_service.get_current_user() + user = identities_service.get_current_identity() if person_id != user["id"]: permissions.check_admin_permissions() return tasks_service.get_person_related_tasks(person_id, task_type_id) @@ -705,7 +706,7 @@ def put(self, task_id): if person_id is not None: person = persons_service.get_person(person_id) else: - person = persons_service.get_current_user() + person = identities_service.get_current_identity() preview_path = self.get_preview_path(task, name, revision) @@ -865,7 +866,7 @@ def put(self, person_id): ) tasks = [] - current_user = persons_service.get_current_user() + current_user = identities_service.get_current_identity() for task_id in args["task_ids"]: try: user_service.check_task_departement_access(task_id, person_id) @@ -981,7 +982,7 @@ def get(self, task_id): description: Task with many information """ task = tasks_service.get_full_task( - task_id, persons_service.get_current_user()["id"] + task_id, identities_service.get_current_identity()["id"] ) user_service.check_project_access(task["project_id"]) user_service.check_entity_access(task["entity_id"]) @@ -1303,7 +1304,7 @@ class ProjectSubscriptionsResource(Resource): """ @jwt_required() - @permissions.require_admin + @permissions.admin_permission.require() def get(self, project_id): """ Retrieve all subcriptions to tasks related to given project. @@ -1333,7 +1334,7 @@ class ProjectNotificationsResource(Resource, ArgsMixin): """ @jwt_required() - @permissions.require_admin + @permissions.admin_permission.require() def get(self, project_id): """ Retrieve all notifications to tasks related to given project. @@ -1366,7 +1367,7 @@ class ProjectTasksResource(Resource, ArgsMixin): """ @jwt_required() - @permissions.require_admin + @permissions.admin_permission.require() def get(self, project_id): """ Retrieve all tasks related to given project. @@ -1397,7 +1398,7 @@ class ProjectCommentsResource(Resource, ArgsMixin): """ @jwt_required() - @permissions.require_admin + @permissions.admin_permission.require() def get(self, project_id): """ Retrieve all comments to tasks related to given project. @@ -1427,7 +1428,7 @@ class ProjectPreviewFilesResource(Resource, ArgsMixin): """ @jwt_required() - @permissions.require_admin + @permissions.admin_permission.require() def get(self, project_id): """ Preview files related to a given project. @@ -1492,7 +1493,7 @@ def put(self, task_id): class PersonsTasksDatesResource(Resource): @jwt_required() - @permissions.require_admin + @permissions.admin_permission.require() def get(self): """ For schedule usages, for each active person, it returns the first start diff --git a/zou/app/blueprints/user/resources.py b/zou/app/blueprints/user/resources.py index 5d051a6e09..096677f5ad 100644 --- a/zou/app/blueprints/user/resources.py +++ b/zou/app/blueprints/user/resources.py @@ -13,6 +13,7 @@ shots_service, time_spents_service, user_service, + identities_service, ) from zou.app.utils import permissions @@ -809,7 +810,7 @@ def get(self): 200: description: Desktop login logs """ - current_user = persons_service.get_current_user() + current_user = identities_service.get_current_identity() return persons_service.get_desktop_login_logs(current_user["id"]) @jwt_required() @@ -832,7 +833,7 @@ def post(self): description: Desktop login log created """ arguments = self.get_args(["date", datetime.datetime.utcnow()]) - current_user = persons_service.get_current_user() + current_user = identities_service.get_current_identity() desktop_login_log = persons_service.create_desktop_login_logs( current_user["id"], arguments["date"] ) @@ -1161,7 +1162,7 @@ class TimeSpentsResource(Resource): def get(self): arguments = self.get_args(["start_date", "end_date"]) start_date, end_date = arguments["start_date"], arguments["end_date"] - current_user = persons_service.get_current_user() + current_user = identities_service.get_current_identity() if not start_date and not end_date: return time_spents_service.get_time_spents(current_user["id"]) @@ -1193,7 +1194,7 @@ class DateTimeSpentsResource(Resource): @jwt_required() def get(self, date): try: - current_user = persons_service.get_current_user() + current_user = identities_service.get_current_identity() return time_spents_service.get_time_spents( current_user["id"], date ) @@ -1233,7 +1234,7 @@ def get(self, task_id, date): description: Wrong date format """ try: - current_user = persons_service.get_current_user() + current_user = identities_service.get_current_identity() return time_spents_service.get_time_spent( current_user["id"], task_id, date ) @@ -1267,7 +1268,7 @@ def get(self, date): description: Wrong date format """ try: - current_user = persons_service.get_current_user() + current_user = identities_service.get_current_identity() return time_spents_service.get_day_off(current_user["id"], date) except WrongDateFormatException: abort(404) @@ -1303,6 +1304,6 @@ def delete(self): 204: description: Avatar file deleted """ - user = persons_service.get_current_user() + user = identities_service.get_current_identity() persons_service.clear_avatar(user["id"]) return "", 204 diff --git a/zou/app/models/api_token.py b/zou/app/models/api_token.py new file mode 100644 index 0000000000..42ea7bb2b4 --- /dev/null +++ b/zou/app/models/api_token.py @@ -0,0 +1,42 @@ +from sqlalchemy_utils import EmailType + +from zou.app import db +from zou.app.models.identity import Identity + + +class ApiToken(db.Model, Identity): + """ + Describe an API Token. + """ + + name = db.Column(db.String(80), nullable=False, unique=True) + email = db.Column(EmailType) + jti = db.Column(db.String(60), nullable=True, unique=True) + expiration_date = db.Column(db.Date()) + + def __repr__(self): + return f"" + + def full_name(self): + return self.name + + def serialize(self, obj_type="ApiToken", relations=False): + data = super().serialize(obj_type, relations=relations) + return data + + def serialize_safe(self, relations=False): + data = super().serialize_safe(relations=relations) + del data["jti"] + return data + + def present_minimal(self, relations=False): + data = self.serialize(relations=relations) + return { + "id": data["id"], + "name": data["name"], + "full_name": self.full_name(), + "has_avatar": data["has_avatar"], + "active": data["active"], + "departments": data.get("departments", []), + "role": data["role"], + } diff --git a/zou/app/models/identity.py b/zou/app/models/identity.py new file mode 100644 index 0000000000..4962c75bcf --- /dev/null +++ b/zou/app/models/identity.py @@ -0,0 +1,82 @@ +from sqlalchemy_utils import LocaleType, TimezoneType, UUIDType +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.ext.declarative import declared_attr + +from pytz import timezone as pytz_timezone +from babel import Locale + +from zou.app import db, config +from zou.app.models.serializer import SerializerMixin +from zou.app.models.base import BaseMixin + +department_link = db.Table( + "department_link", + db.Column( + "person_id", + UUIDType(binary=False), + db.ForeignKey("person.id"), + nullable=True, + ), + db.Column( + "api_token_id", + UUIDType(binary=False), + db.ForeignKey("api_token.id"), + nullable=True, + ), + db.Column( + "department_id", + UUIDType(binary=False), + db.ForeignKey("department.id"), + primary_key=True, + ), +) + + +class Identity(BaseMixin, SerializerMixin): + """ + Base class for all identity models. + """ + + active = db.Column(db.Boolean(), default=True) + timezone = db.Column( + TimezoneType(backend="pytz"), + default=pytz_timezone(config.DEFAULT_TIMEZONE), + ) + locale = db.Column(LocaleType, default=Locale("en", "US")) + data = db.Column(JSONB) + role = db.Column(db.String(30), default="user") + has_avatar = db.Column(db.Boolean(), default=False) + + @declared_attr + def departments(cls): + return db.relationship( + "Department", + secondary=department_link, + lazy="joined", + overlaps="departments", + ) + + def __repr__(self): + return f"" + + def full_name(self): + return f"" + + def serialize(self, obj_type="Identity", relations=False): + data = super().serialize(obj_type, relations=relations) + data["full_name"] = self.full_name() + return data + + def serialize_safe(self, relations=False): + return self.serialize(relations=relations) + + def present_minimal(self, relations=False): + data = self.serialize(relations=relations) + return { + "id": data["id"], + "full_name": self.full_name(), + "has_avatar": data["has_avatar"], + "active": data["active"], + "departments": data.get("departments", []), + "role": data["role"], + } diff --git a/zou/app/models/person.py b/zou/app/models/person.py index 1245354224..01723a16a7 100644 --- a/zou/app/models/person.py +++ b/zou/app/models/person.py @@ -1,36 +1,12 @@ from sqlalchemy_utils import ( - UUIDType, EmailType, - LocaleType, - TimezoneType, ChoiceType, ) from sqlalchemy.dialects.postgresql import JSONB -from pytz import timezone as pytz_timezone -from babel import Locale - from zou.app import db -from zou.app.models.serializer import SerializerMixin -from zou.app.models.base import BaseMixin -from zou.app import config - - -department_link = db.Table( - "department_link", - db.Column( - "person_id", - UUIDType(binary=False), - db.ForeignKey("person.id"), - primary_key=True, - ), - db.Column( - "department_id", - UUIDType(binary=False), - db.ForeignKey("department.id"), - primary_key=True, - ), -) +from zou.app.models.identity import Identity +from zou.app.models.department import Department TWO_FACTOR_AUTHENTICATION_TYPES = [ ("totp", "TOTP"), @@ -39,7 +15,7 @@ ] -class Person(db.Model, BaseMixin, SerializerMixin): +class Person(db.Model, Identity): """ Describe a member of the studio (and an API user). """ @@ -49,12 +25,10 @@ class Person(db.Model, BaseMixin, SerializerMixin): email = db.Column(EmailType, unique=True) phone = db.Column(db.String(30)) - active = db.Column(db.Boolean(), default=True) archived = db.Column(db.Boolean(), default=False) last_presence = db.Column(db.Date()) password = db.Column(db.LargeBinary(60)) - desktop_login = db.Column(db.String(80)) login_failed_attemps = db.Column(db.Integer, default=0) last_login_failed = db.Column(db.DateTime()) totp_enabled = db.Column(db.Boolean(), default=False) @@ -67,16 +41,11 @@ class Person(db.Model, BaseMixin, SerializerMixin): preferred_two_factor_authentication = db.Column( ChoiceType(TWO_FACTOR_AUTHENTICATION_TYPES) ) + desktop_login = db.Column(db.String(80)) + is_generated_from_ldap = db.Column(db.Boolean(), default=False) + ldap_uid = db.Column(db.String(60), unique=True, default=None) shotgun_id = db.Column(db.Integer, unique=True) - timezone = db.Column( - TimezoneType(backend="pytz"), - default=pytz_timezone(config.DEFAULT_TIMEZONE), - ) - locale = db.Column(LocaleType, default=Locale("en", "US")) - data = db.Column(JSONB) - role = db.Column(db.String(30), default="user") - has_avatar = db.Column(db.Boolean(), default=False) notifications_enabled = db.Column(db.Boolean(), default=False) notifications_slack_enabled = db.Column(db.Boolean(), default=False) @@ -86,13 +55,6 @@ class Person(db.Model, BaseMixin, SerializerMixin): notifications_discord_enabled = db.Column(db.Boolean(), default=False) notifications_discord_userid = db.Column(db.String(60), default="") - departments = db.relationship( - "Department", secondary=department_link, lazy="joined" - ) - - is_generated_from_ldap = db.Column(db.Boolean(), default=False) - ldap_uid = db.Column(db.String(60), unique=True, default=None) - def __repr__(self): return f"" @@ -109,15 +71,12 @@ def fido_devices(self): ] def serialize(self, obj_type="Person", relations=False): - data = SerializerMixin.serialize(self, "Person", relations=relations) - data["full_name"] = self.full_name() + data = super().serialize(obj_type, relations=relations) data["fido_devices"] = self.fido_devices() return data def serialize_safe(self, relations=False): - data = SerializerMixin.serialize(self, "Person", relations=relations) - data["full_name"] = self.full_name() - data["fido_devices"] = self.fido_devices() + data = super().serialize_safe(relations=relations) del data["password"] del data["totp_secret"] del data["email_otp_secret"] @@ -126,7 +85,7 @@ def serialize_safe(self, relations=False): return data def present_minimal(self, relations=False): - data = SerializerMixin.serialize(self, "Person", relations=relations) + data = self.serialize(relations=relations) return { "id": data["id"], "first_name": data["first_name"], @@ -140,8 +99,6 @@ def present_minimal(self, relations=False): } def set_departments(self, department_ids): - from zou.app.models.department import Department - self.departments = [] for department_id in department_ids: department = Department.get(department_id) diff --git a/zou/app/services/api_tokens_service.py b/zou/app/services/api_tokens_service.py new file mode 100644 index 0000000000..0dc101d042 --- /dev/null +++ b/zou/app/services/api_tokens_service.py @@ -0,0 +1,109 @@ +from sqlalchemy.exc import StatementError + +from zou.app.models.api_token import ApiToken +from zou.app.models.department import Department + +from zou.app.utils import cache, fields, events + +from zou.app.services import identities_service + +from zou.app.services.exception import ( + DepartmentNotFoundException, + ApiTokenNotFoundException, +) + + +def clear_api_token_cache(): + cache.cache.delete_memoized(get_api_token) + cache.cache.delete_memoized(get_active_api_tokens) + cache.cache.delete_memoized(get_api_tokens) + identities_service.clear_identities_cache() + + +@cache.memoize_function(120) +def get_api_tokens(minimal=False): + """ + Return all API tokens stored in database. + """ + api_tokens = [] + for api_token in ApiToken.query.all(): + if not minimal: + api_token.append(api_token.serialize_safe(relations=True)) + else: + api_token.append(api_token.present_minimal(relations=True)) + return api_tokens + + +@cache.memoize_function(120) +def get_api_token(api_token_id, unsafe=False, relations=True): + """ + Return given API token as a dictionary. + """ + api_token = get_api_token_raw(api_token_id) + if unsafe: + return api_token.serialize(relations=relations) + else: + return api_token.serialize_safe(relations=relations) + + +@cache.memoize_function(120) +def get_active_api_tokens(): + """ + Return all API tokens with flag active set to True. + """ + api_tokens = ( + ApiToken.query.filter_by(active=True).order_by(ApiToken.name).all() + ) + return fields.serialize_models(api_tokens) + + +def get_api_token_raw(api_token_id): + """ + Return given API token as an active record. + """ + if api_token_id is None: + raise ApiTokenNotFoundException() + + try: + api_token = ApiToken.get(api_token_id) + except StatementError: + raise ApiTokenNotFoundException() + + if api_token is None: + raise ApiTokenNotFoundException() + return api_token + + +def create_api_token( + email, + name, + role="user", + departments=[], + serialize=True, +): + """ + Create a new API token entry in the database. + The token is not created at this moment, it needs to be created after.""" + if email is not None: + email = email.strip() + if not departments: + departments = [] + + try: + departments_objects = [ + Department.get(department_id) + for department_id in departments + if department_id is not None + ] + except StatementError: + raise DepartmentNotFoundException() + + api_token = ApiToken.create( + name=name, + email=email, + role=role, + departments=departments_objects, + ) + events.emit("api-token:new", {"api_token_id": api_token.id}) + clear_api_token_cache() + return api_token.serialize(relations=True) if serialize else api_token diff --git a/zou/app/services/auth_service.py b/zou/app/services/auth_service.py index aff3ac569c..565199d42f 100644 --- a/zou/app/services/auth_service.py +++ b/zou/app/services/auth_service.py @@ -83,7 +83,7 @@ def check_auth( if not email: raise WrongUserException() try: - person = persons_service.get_person_by_email_dekstop_login(email) + person = persons_service.get_person_by_email_desktop_login(email) except PersonNotFoundException: raise WrongUserException() @@ -697,6 +697,7 @@ def register_tokens(app, access_token, refresh_token=None): can be used like a session. """ access_jti = get_jti(encoded_token=access_token) + auth_tokens_store.add( access_jti, "false", app.config["JWT_ACCESS_TOKEN_EXPIRES"] ) diff --git a/zou/app/services/comments_service.py b/zou/app/services/comments_service.py index 4f5f9056cb..67afdd6f39 100644 --- a/zou/app/services/comments_service.py +++ b/zou/app/services/comments_service.py @@ -25,6 +25,7 @@ persons_service, projects_service, tasks_service, + identities_service, ) from zou.app.services.exception import ( AttachmentFileNotFoundException, @@ -121,7 +122,7 @@ def _get_comment_author(person_id): if person_id is not None and person_id != "": person = persons_service.get_person(person_id) else: - person = persons_service.get_current_user() + person = identities_service.get_current_identity() return person @@ -378,7 +379,7 @@ def acknowledge_comment(comment_id): comment = tasks_service.get_comment_raw(comment_id) task = tasks_service.get_task(str(comment.object_id)) project_id = task["project_id"] - current_user = persons_service.get_current_user_raw() + current_user = identities_service.get_current_identity_raw() current_user_id = str(current_user.id) acknowledgements = fields.serialize_orm_arrays(comment.acknowledgements) @@ -424,7 +425,7 @@ def reply_comment(comment_id, text, person_id=None): """ person = None if person_id is None: - person = persons_service.get_current_user() + person = identities_service.get_current_identity() else: person = persons_service.get_person(person_id) comment = tasks_service.get_comment_raw(comment_id) diff --git a/zou/app/services/deletion_service.py b/zou/app/services/deletion_service.py index 726918e5ee..9481f83ebd 100644 --- a/zou/app/services/deletion_service.py +++ b/zou/app/services/deletion_service.py @@ -24,6 +24,7 @@ from zou.app.models.task import Task from zou.app.models.time_spent import TimeSpent from zou.app.models.working_file import WorkingFile +from zou.app.models.api_token import ApiToken from zou.app.utils import events, fields from zou.app.stores import file_store @@ -34,6 +35,7 @@ CommentNotFoundException, ModelWithRelationsDeletionException, PersonInProtectedAccounts, + ApiTokenInProtectedAccounts, ) @@ -314,6 +316,10 @@ def remove_project(project_id): def remove_person(person_id, force=True): person = Person.get(person_id) + if person.email in config.PROTECTED_ACCOUNTS: + raise PersonInProtectedAccounts( + "Can't delete this person it's a protected account." + ) if force: for comment in Comment.get_all_by(person_id=person_id): remove_comment(comment.id) @@ -355,14 +361,9 @@ def remove_person(person_id, force=True): for output_file in OutputFile.get_all_by(person_id=person_id): output_file.update({"person_id": None}) for working_file in WorkingFile.get_all_by(person_id=person_id): - output_file.update({"person_id": None}) - for task in WorkingFile.get_all_by(person_id=person_id): - output_file.update({"person_id": None}) - elif person.email in config.PROTECTED_ACCOUNTS: - raise PersonInProtectedAccounts( - "Can't delete this person it's a protected account." - ) - + working_file.update({"person_id": None}) + for preview_file in PreviewFile.get_all_by(person_id=person_id): + preview_file.update({"person_id": None}) try: person.delete() events.emit("person:delete", {"person_id": person.id}) @@ -374,6 +375,67 @@ def remove_person(person_id, force=True): return person.serialize_safe() +def remove_api_token(api_token_id, force=True): + api_token = ApiToken.get(api_token_id) + if api_token.email in config.PROTECTED_ACCOUNTS: + raise ApiTokenInProtectedAccounts( + "Can't delete this API Token it's a protected account." + ) + if force: + for comment in Comment.get_all_by(api_token_id=api_token_id): + remove_comment(comment.id) + comments = Comment.query.filter( + Comment.acknowledgements.contains(api_token) + ) + for comment in comments: + comment.acknowledgements = [ + member + for member in comment.acknowledgements + if str(member.id) != api_token_id + ] + comment.save() + ApiEvent.delete_all_by(user_id=api_token_id) + Notification.delete_all_by(person_id=api_token_id) + Notification.delete_all_by(author_id=api_token_id) + SearchFilterGroup.delete_all_by(person_id=api_token_id) + SearchFilter.delete_all_by(person_id=api_token_id) + DesktopLoginLog.delete_all_by(person_id=api_token_id) + LoginLog.delete_all_by(person_id=api_token_id) + Subscription.delete_all_by(person_id=api_token_id) + TimeSpent.delete_all_by(person_id=api_token_id) + for project in Project.query.filter(Project.team.contains(api_token)): + project.team = [ + member + for member in project.team + if str(member.id) != api_token_id + ] + project.save() + for task in Task.query.filter(Task.assignees.contains(api_token)): + task.assignees = [ + assignee + for assignee in task.assignees + if str(assignee.id) != api_token_id + ] + task.save() + for task in Task.get_all_by(assigner_id=api_token_id): + task.update({"assigner_id": None}) + for output_file in OutputFile.get_all_by(person_id=api_token_id): + output_file.update({"person_id": None}) + for working_file in WorkingFile.get_all_by(person_id=api_token_id): + working_file.update({"person_id": None}) + for preview_file in PreviewFile.get_all_by(person_id=api_token_id): + preview_file.update({"person_id": None}) + try: + api_token.delete() + events.emit("api_token:delete", {"api_token_id": api_token.id}) + except IntegrityError: + raise ModelWithRelationsDeletionException( + "Some data are still linked to given person." + ) + + return api_token.serialize_safe() + + def remove_old_events(days_old=90): """ Remove events older than *days_old*. diff --git a/zou/app/services/exception.py b/zou/app/services/exception.py index e75e451e50..645dd052e1 100644 --- a/zou/app/services/exception.py +++ b/zou/app/services/exception.py @@ -49,11 +49,23 @@ class TaskTypeNotFoundException(NotFound): pass -class PersonNotFoundException(NotFound): +class PersonInProtectedAccounts(Forbidden): pass -class PersonInProtectedAccounts(Forbidden): +class IdentityNotFoundException(NotFound): + pass + + +class PersonNotFoundException(IdentityNotFoundException): + pass + + +class ApiTokenNotFoundException(IdentityNotFoundException): + pass + + +class ApiTokenInProtectedAccounts(Forbidden): pass diff --git a/zou/app/services/identities_service.py b/zou/app/services/identities_service.py new file mode 100644 index 0000000000..338ae39a84 --- /dev/null +++ b/zou/app/services/identities_service.py @@ -0,0 +1,47 @@ +from flask_jwt_extended import current_user + +from zou.app.utils import cache +from zou.app.services import persons_service, api_tokens_service + +from zou.app.services.exception import ( + PersonNotFoundException, + ApiTokenNotFoundException, + IdentityNotFoundException, +) + + +def clear_identities_cache(): + cache.cache.delete_memoized(get_identity_raw) + + +def get_current_identity(unsafe=False, relations=False): + """ + Return identity from its auth token (the one that does the request) as a + dictionary. + """ + if unsafe: + return current_user.serialize(relations=relations) + else: + return current_user.serialize_safe(relations=relations) + + +def get_current_identity_raw(): + """ + Return identity from its auth token (the one that does the request) as an + active record. + """ + return current_user + + +@cache.memoize_function(120) +def get_identity_raw(id): + """ + Return given identity as a dictionary. + """ + try: + return persons_service.get_person_raw(id) + except PersonNotFoundException: + try: + return api_tokens_service.get_api_token_raw(id) + except ApiTokenNotFoundException: + raise IdentityNotFoundException() diff --git a/zou/app/services/notifications_service.py b/zou/app/services/notifications_service.py index c8dd082913..c14406c7b8 100644 --- a/zou/app/services/notifications_service.py +++ b/zou/app/services/notifications_service.py @@ -11,9 +11,9 @@ from zou.app.services import ( assets_service, emails_service, - persons_service, projects_service, tasks_service, + identities_service, ) from zou.app.services.exception import PersonNotFoundException from zou.app.utils import events, fields, query as query_utils @@ -474,7 +474,7 @@ def get_notifications_for_project(project_id, page=0): def get_subscriptions_for_user(project_id, entity_type_id): subscription_map = {} if project_id is not None: - user_id = persons_service.get_current_user()["id"] + user_id = identities_service.get_current_identity()["id"] if entity_type_id is not None: subscriptions = ( Subscription.query.join(Task) diff --git a/zou/app/services/persons_service.py b/zou/app/services/persons_service.py index 2e428e123a..b8a72331e0 100644 --- a/zou/app/services/persons_service.py +++ b/zou/app/services/persons_service.py @@ -8,8 +8,6 @@ from babel.dates import format_datetime -from flask_jwt_extended import get_jwt_identity - from zou.app.models.department import Department from zou.app.models.desktop_login_log import DesktopLoginLog from zou.app.models.organisation import Organisation @@ -18,7 +16,7 @@ from zou.app import config from zou.app.utils import fields, events, cache, emails -from zou.app.services import index_service, auth_service +from zou.app.services import index_service, auth_service, identities_service from zou.app.stores import file_store, auth_tokens_store from zou.app.services.exception import ( @@ -32,9 +30,10 @@ def clear_person_cache(): cache.cache.delete_memoized(get_person) cache.cache.delete_memoized(get_person_by_email) cache.cache.delete_memoized(get_person_by_desktop_login) - cache.cache.delete_memoized(get_person_by_email_dekstop_login) + cache.cache.delete_memoized(get_person_by_email_desktop_login) cache.cache.delete_memoized(get_active_persons) cache.cache.delete_memoized(get_persons) + identities_service.clear_identities_cache() @cache.memoize_function(120) @@ -44,10 +43,10 @@ def get_persons(minimal=False): """ persons = [] for person in Person.query.all(): - if not minimal: - persons.append(person.serialize_safe(relations=True)) - else: + if minimal: persons.append(person.present_minimal(relations=True)) + else: + persons.append(person.serialize_safe(relations=True)) return persons @@ -61,7 +60,7 @@ def get_all_raw_active_persons(): @cache.memoize_function(120) def get_active_persons(): """ - Return all person with flag active set to True. + Return all persons with flag active set to True. """ persons = ( Person.query.filter_by(active=True) @@ -90,12 +89,15 @@ def get_person_raw(person_id): @cache.memoize_function(120) -def get_person(person_id): +def get_person(person_id, unsafe=False, relations=True): """ Return given person as a dictionary. """ person = get_person_raw(person_id) - return person.serialize_safe(relations=True) + if unsafe: + return person.serialize(relations=relations) + else: + return person.serialize_safe(relations=relations) def get_person_by_email_raw(email): @@ -154,7 +156,7 @@ def get_person_by_ldap_uid(ldap_uid): @cache.memoize_function(120) -def get_person_by_email_dekstop_login(email_or_desktop_login): +def get_person_by_email_desktop_login(email_or_desktop_login): """ Return person that matches given email or desktop login as a dictionary. """ @@ -164,24 +166,6 @@ def get_person_by_email_dekstop_login(email_or_desktop_login): return get_person_by_desktop_login(email_or_desktop_login) -def get_current_user(unsafe=False, relations=False): - """ - Return person from its auth token (the one that does the request) as a - dictionary. - """ - return get_person_by_email( - get_jwt_identity(), unsafe=unsafe, relations=relations - ) - - -def get_current_user_raw(): - """ - Return person from its auth token (the one that does the request) as an - active record. - """ - return get_person_by_email_raw(get_jwt_identity()) - - def get_persons_map(): """ Return a dict of which keys are person_id and values are person. @@ -454,8 +438,9 @@ def add_to_department(department_id, person_id): """ person = get_person_raw(person_id) department = Department.get(department_id) - person.departments = person.departments + [department] + person.departments.append(department) person.save() + clear_person_cache() return person.serialize(relations=True) @@ -470,6 +455,7 @@ def remove_from_department(department_id, person_id): if str(department.id) != department_id ] person.save() + clear_person_cache() return person.serialize(relations=True) diff --git a/zou/app/services/projects_service.py b/zou/app/services/projects_service.py index d3b0f18266..989f360d8c 100644 --- a/zou/app/services/projects_service.py +++ b/zou/app/services/projects_service.py @@ -3,7 +3,8 @@ from zou.app.models.entity import Entity from zou.app.models.entity_type import EntityType from zou.app.models.metadata_descriptor import MetadataDescriptor -from zou.app.models.person import Person, department_link +from zou.app.models.person import Person +from zou.app.models.identity import department_link as DepartmentLink from zou.app.models.project import ( Project, ProjectPersonLink, @@ -638,8 +639,8 @@ def get_department_team(project_id, department_id): Person.query.join( ProjectPersonLink, ProjectPersonLink.person_id == Person.id ) - .join(department_link, department_link.columns.person_id == Person.id) + .join(DepartmentLink, DepartmentLink.columns.person_id == Person.id) .filter(ProjectPersonLink.project_id == project_id) - .filter(department_link.columns.department_id == department_id) + .filter(DepartmentLink.columns.department_id == department_id) ).all() return persons diff --git a/zou/app/services/tasks_service.py b/zou/app/services/tasks_service.py index 8d8cfd970c..9f6d00590a 100644 --- a/zou/app/services/tasks_service.py +++ b/zou/app/services/tasks_service.py @@ -51,6 +51,7 @@ shots_service, entities_service, edits_service, + identities_service, ) @@ -521,7 +522,7 @@ def get_comments(task_id, is_client=False, is_manager=False): project = projects_service.get_project(task["project_id"]) for comment in comments: person = persons_service.get_person(comment["person_id"]) - current_user = persons_service.get_current_user() + current_user = identities_service.get_current_identity() is_author = comment["person_id"] == current_user["id"] is_author_client = person["role"] == "client" is_clients_isolated = project.get("is_clients_isolated", False) @@ -1082,7 +1083,7 @@ def create_tasks(task_type, entities): task_status = get_default_status() current_user_id = None try: - current_user_id = persons_service.get_current_user()["id"] + current_user_id = identities_service.get_current_identity()["id"] except RuntimeError: pass @@ -1126,7 +1127,7 @@ def create_task(task_type, entity, name="main"): task_status = get_default_status() try: try: - current_user_id = persons_service.get_current_user()["id"] + current_user_id = identities_service.get_current_identity()["id"] except RuntimeError: current_user_id = None task = Task.create( diff --git a/zou/app/services/user_service.py b/zou/app/services/user_service.py index 7435111d41..e72f5805a0 100644 --- a/zou/app/services/user_service.py +++ b/zou/app/services/user_service.py @@ -26,6 +26,7 @@ shots_service, status_automations_service, tasks_service, + identities_service, ) from zou.app.services.exception import ( SearchFilterNotFoundException, @@ -51,7 +52,7 @@ def build_assignee_filter(): """ Query filter for task to retrieve only tasks assigned to current user. """ - current_user = persons_service.get_current_user_raw() + current_user = identities_service.get_current_identity_raw() return Task.assignees.contains(current_user) @@ -60,7 +61,7 @@ def build_team_filter(): Query filter for task to retrieve only models from project for which the user is part of the team. """ - current_user = persons_service.get_current_user_raw() + current_user = identities_service.get_current_identity_raw() return Project.team.contains(current_user) @@ -108,7 +109,7 @@ def get_todos(): """ Get all unfinished tasks assigned to current user. """ - current_user = persons_service.get_current_user() + current_user = identities_service.get_current_identity() projects = related_projects() return tasks_service.get_person_tasks(current_user["id"], projects) @@ -117,7 +118,7 @@ def get_done_tasks(): """ Get all finished tasks assigned to current user for open projects. """ - current_user = persons_service.get_current_user() + current_user = identities_service.get_current_identity() projects = related_projects() return tasks_service.get_person_done_tasks(current_user["id"], projects) @@ -126,7 +127,7 @@ def get_tasks_to_check(): """ Get all tasks waiting for feedback in the user department. """ - current_user = persons_service.get_current_user(relations=True) + current_user = identities_service.get_current_identity(relations=True) projects = related_projects() project_ids = [project["id"] for project in projects] return tasks_service.get_person_tasks_to_check( @@ -315,9 +316,9 @@ def get_open_projects(name=None): if permissions.has_client_permissions(): for_client = True elif permissions.has_vendor_permissions(): - vendor_departments = persons_service.get_current_user(relations=True)[ - "departments" - ] + vendor_departments = identities_service.get_current_identity( + relations=True + )["departments"] return projects_service.get_projects_with_extra_data( query, for_client, vendor_departments @@ -347,7 +348,7 @@ def check_working_on_entity(entity_id): """ Return True if user has task assigned which is related to given entity. """ - current_user = persons_service.get_current_user_raw() + current_user = identities_service.get_current_identity_raw() query = Task.query.filter(Task.assignees.contains(current_user)).filter( Task.entity_id == entity_id ) @@ -362,7 +363,7 @@ def check_working_on_task(task_id): """ Return True if user has task assigned. """ - current_user = persons_service.get_current_user_raw() + current_user = identities_service.get_current_identity_raw() query = Task.query.filter(Task.assignees.contains(current_user)).filter( Task.id == task_id ) @@ -377,7 +378,7 @@ def check_person_access(person_id): """ Return True if user is an admin or is matching given person id. """ - current_user = persons_service.get_current_user() + current_user = identities_service.get_current_identity() if permissions.has_admin_permissions() or current_user["id"] == person_id: return True else: @@ -393,7 +394,7 @@ def check_belong_to_project(project_id): return False project = projects_service.get_project_with_relations(str(project_id)) - current_user = persons_service.get_current_user() + current_user = identities_service.get_current_identity() if current_user["id"] in project["team"]: return True else: @@ -464,7 +465,7 @@ def check_comment_access(comment_id): ): return True elif permissions.has_client_permissions(): - current_user = persons_service.get_current_user() + current_user = identities_service.get_current_identity() project = projects_service.get_project(task["project_id"]) if project.get("is_clients_isolated", False): if not comment["person_id"] == current_user["id"]: @@ -531,7 +532,7 @@ def check_supervisor_task_access(task, new_data={}): ["priority", "start_date", "due_date", "estimation"] ) if len(set(new_data.keys()) - allowed_columns) == 0: - user_departments = persons_service.get_current_user( + user_departments = identities_service.get_current_identity( relations=True )["departments"] if ( @@ -563,9 +564,9 @@ def check_supervisor_schedule_item_access(schedule_item, new_data={}): elif permissions.has_supervisor_permissions() and check_belong_to_project( schedule_item["project_id"] ): - user_departments = persons_service.get_current_user(relations=True)[ - "departments" - ] + user_departments = identities_service.get_current_identity( + relations=True + )["departments"] if ( user_departments == [] or tasks_service.get_task_type(schedule_item["task_type_id"])[ @@ -599,7 +600,7 @@ def check_metadata_department_access(entity, new_data={}): # for which he is authorized allowed_columns = set(["data"]) if len(set(new_data.keys()) - allowed_columns) == 0: - user_departments = persons_service.get_current_user( + user_departments = identities_service.get_current_identity( relations=True )["departments"] if user_departments == []: @@ -651,7 +652,7 @@ def check_task_departement_access(task_id, person_id): or is a supervisor in the department of the task or is an artist assigning himself in the department of the task. """ - user = persons_service.get_current_user(relations=True) + user = identities_service.get_current_identity(relations=True) task = tasks_service.get_task(task_id) task_type = tasks_service.get_task_type(task["task_type_id"]) is_allowed = permissions.has_admin_permissions() or ( @@ -693,7 +694,7 @@ def check_task_departement_access_for_unassign(task_id, person_id=None): or is a supervisor in the department of the task or is an artist assigning himself in the department of the task. """ - user = persons_service.get_current_user(relations=True) + user = identities_service.get_current_identity(relations=True) task = tasks_service.get_task(task_id) task_type = tasks_service.get_task_type(task["task_type_id"]) is_allowed = permissions.has_admin_permissions() or ( @@ -734,9 +735,9 @@ def check_all_departments_access(project_id, departments=[]): elif permissions.has_supervisor_permissions() and check_belong_to_project( project_id ): - user_departments = persons_service.get_current_user(relations=True)[ - "departments" - ] + user_departments = identities_service.get_current_identity( + relations=True + )["departments"] is_allowed = departments and ( user_departments == [] or all( @@ -766,7 +767,7 @@ def check_day_off_access(day_off): """ Return true if current user is admin or day_off is for itself """ - user = persons_service.get_current_user() + user = identities_service.get_current_identity() is_admin = permissions.has_admin_permissions() is_same_person = user["id"] == day_off["person_id"] if not (is_admin or is_same_person): @@ -780,7 +781,7 @@ def get_filters(): list type and project_id. If the filter is not related to a project, the project_id is all. """ - current_user = persons_service.get_current_user() + current_user = identities_service.get_current_identity() return get_user_filters(current_user["id"]) @@ -831,7 +832,7 @@ def create_filter(list_type, name, query, project_id=None, entity_type=None): """ Add a new search filter to the database. """ - current_user = persons_service.get_current_user() + current_user = identities_service.get_current_identity() search_filter = SearchFilter.create( list_type=list_type, name=name, @@ -849,7 +850,7 @@ def update_filter(search_filter_id, data): """ Update given filter from database. """ - current_user = persons_service.get_current_user() + current_user = identities_service.get_current_identity() search_filter = SearchFilter.get_by( id=search_filter_id, person_id=current_user["id"] ) @@ -864,7 +865,7 @@ def remove_filter(search_filter_id): """ Remove given filter from database. """ - current_user = persons_service.get_current_user() + current_user = identities_service.get_current_identity() search_filter = SearchFilter.get_by( id=search_filter_id, person_id=current_user["id"] ) @@ -881,7 +882,7 @@ def get_filter_groups(): list type and project_id. If the filter group is not related to a project, the project_id is all. """ - current_user = persons_service.get_current_user() + current_user = identities_service.get_current_identity() return get_user_filter_groups(current_user["id"]) @@ -938,7 +939,7 @@ def create_filter_group( """ Add a new search filter group to the database. """ - current_user = persons_service.get_current_user() + current_user = identities_service.get_current_identity() search_filter_group = SearchFilterGroup.create( list_type=list_type, name=name, @@ -956,7 +957,7 @@ def get_filter_group(search_filter_group_id): """ Get given filter group from the database. """ - current_user = persons_service.get_current_user() + current_user = identities_service.get_current_identity() search_filter_group = SearchFilterGroup.get_by( id=search_filter_group_id, person_id=current_user["id"] ) @@ -969,7 +970,7 @@ def update_filter_group(search_filter_group_id, data): """ Update given filter group from database. """ - current_user = persons_service.get_current_user() + current_user = identities_service.get_current_identity() search_filter_group = SearchFilterGroup.get_by( id=search_filter_group_id, person_id=current_user["id"] ) @@ -984,7 +985,7 @@ def remove_filter_group(search_filter_group_id): """ Remove given filter group from database. """ - current_user = persons_service.get_current_user() + current_user = identities_service.get_current_identity() search_filter_group = SearchFilterGroup.get_by( id=search_filter_group_id, person_id=current_user["id"] ) @@ -1011,7 +1012,7 @@ def get_unread_notifications_count(notification_id=None): """ Return the number of unread notifications. """ - current_user = persons_service.get_current_user() + current_user = identities_service.get_current_identity() return Notification.query.filter_by( person_id=current_user["id"], read=False ).count() @@ -1028,7 +1029,7 @@ def get_last_notifications( """ Return last 100 user notifications. """ - current_user = persons_service.get_current_user() + current_user = identities_service.get_current_identity() Author = aliased(Person, name="author") is_current_user_artist = current_user["role"] == "user" result = [] @@ -1163,7 +1164,7 @@ def mark_notifications_as_read(): Mark all recent notifications for current_user as read. It is useful to mark a list of notifications as read after an user retrieved them. """ - current_user = persons_service.get_current_user() + current_user = identities_service.get_current_identity() notifications = ( Notification.query.filter_by(person_id=current_user["id"], read=False) .order_by(Notification.created_at) @@ -1181,7 +1182,7 @@ def has_task_subscription(task_id): Returns true if a subscription entry exists for current user and given task. """ - current_user = persons_service.get_current_user() + current_user = identities_service.get_current_identity() return notifications_service.has_task_subscription( current_user["id"], task_id ) @@ -1191,7 +1192,7 @@ def subscribe_to_task(task_id): """ Create a subscription entry for current user and given task """ - current_user = persons_service.get_current_user() + current_user = identities_service.get_current_identity() return notifications_service.subscribe_to_task(current_user["id"], task_id) @@ -1199,7 +1200,7 @@ def unsubscribe_from_task(task_id): """ Remove subscription entry for current user and given task """ - current_user = persons_service.get_current_user() + current_user = identities_service.get_current_identity() return notifications_service.unsubscribe_from_task( current_user["id"], task_id ) @@ -1210,7 +1211,7 @@ def has_sequence_subscription(sequence_id, task_type_id): Returns true if a subscription entry exists for current user and given sequence. """ - current_user = persons_service.get_current_user() + current_user = identities_service.get_current_identity() return notifications_service.has_sequence_subscription( current_user["id"], sequence_id, task_type_id ) @@ -1220,7 +1221,7 @@ def subscribe_to_sequence(sequence_id, task_type_id): """ Create a subscription entry for current user and given sequence """ - current_user = persons_service.get_current_user() + current_user = identities_service.get_current_identity() return notifications_service.subscribe_to_sequence( current_user["id"], sequence_id, task_type_id ) @@ -1230,7 +1231,7 @@ def unsubscribe_from_sequence(sequence_id, task_type_id): """ Remove subscription entry for current user and given sequence """ - current_user = persons_service.get_current_user() + current_user = identities_service.get_current_identity() return notifications_service.unsubscribe_from_sequence( current_user["id"], sequence_id, task_type_id ) @@ -1241,7 +1242,7 @@ def get_sequence_subscriptions(project_id, task_type_id): Return list of sequence ids for which the current user has subscriptions for given project and task type. """ - current_user = persons_service.get_current_user() + current_user = identities_service.get_current_identity() return notifications_service.get_all_sequence_subscriptions( current_user["id"], project_id, task_type_id ) @@ -1249,7 +1250,7 @@ def get_sequence_subscriptions(project_id, task_type_id): def get_timezone(): try: - timezone = persons_service.get_current_user()["timezone"] + timezone = identities_service.get_current_identity()["timezone"] except Exception: timezone = config.DEFAULT_TIMEZONE return timezone or config.DEFAULT_TIMEZONE diff --git a/zou/app/stores/auth_tokens_store.py b/zou/app/stores/auth_tokens_store.py index 3d762b0177..d2d1735d4c 100644 --- a/zou/app/stores/auth_tokens_store.py +++ b/zou/app/stores/auth_tokens_store.py @@ -11,50 +11,39 @@ db=config.AUTH_TOKEN_BLACKLIST_KV_INDEX, decode_responses=True, ) - revoked_tokens_store.get("test") + revoked_tokens_store.ping() except redis.ConnectionError: - try: - import fakeredis - - revoked_tokens_store = fakeredis.FakeStrictRedis() - except BaseException: + revoked_tokens_store = None + if "pytest" not in sys.modules: print("Cannot access to the required Redis instance") - sys.exit(1) def add(key, token, ttl=None): """ Store a token with key as access key. """ - return revoked_tokens_store.set(key.encode("utf-8"), token, ex=ttl) + return revoked_tokens_store.set(key, token, ex=ttl) def get(key): """ Retrieve auth token corresponding at given key. """ - value = revoked_tokens_store.get(key) - if value is not None and hasattr(value, "decode"): - value = value.decode("utf-8") - return value + return revoked_tokens_store.get(key) def delete(key): """ Remove auth token corresponding at given key. """ - return revoked_tokens_store.delete(key.encode("utf-8")) + return revoked_tokens_store.delete(key) def keys(): """ Get all keys available in the store. """ - keys = revoked_tokens_store.keys() - if len(keys) > 0 and hasattr(keys[0], "decode"): - return [x.decode("utf-8") for x in revoked_tokens_store.keys()] - else: - return [x for x in revoked_tokens_store.keys()] + return [x for x in revoked_tokens_store.keys()] def clear(): @@ -65,10 +54,8 @@ def clear(): delete(key) -def is_revoked(decrypted_token): +def is_revoked(jti): """ Tell if a stored auth token is revoked or not. """ - jti = decrypted_token["jti"] - is_revoked = get(jti) - return (is_revoked is None) or (is_revoked == "true") + return get(jti) in [None, "true"] diff --git a/zou/app/utils/commands.py b/zou/app/utils/commands.py index 6003abee8f..eafc1bf9ff 100644 --- a/zou/app/utils/commands.py +++ b/zou/app/utils/commands.py @@ -37,21 +37,12 @@ def clean_auth_tokens(): """ - Remove all revoked tokens (most of the time outdated) from the key value + Remove all revoked tokens from the key value store. """ for key in auth_tokens_store.keys(): - value = json.loads(auth_tokens_store.get(key)) - - if isinstance(value, bool): + if auth_tokens_store.is_revoked(key): auth_tokens_store.delete(key) - else: - is_revoked = value["revoked"] == True - expiration = datetime.datetime.fromtimestamp(value["token"]["exp"]) - is_expired = expiration < datetime.datetime.utcnow() - - if is_revoked or is_expired: - auth_tokens_store.delete(key) def clear_all_auth_tokens(): diff --git a/zou/app/utils/events.py b/zou/app/utils/events.py index e591eb8779..3a934725d1 100644 --- a/zou/app/utils/events.py +++ b/zou/app/utils/events.py @@ -91,9 +91,11 @@ def save_event(event, data, project_id=None): Store event information in the database. """ try: - from zou.app.services.persons_service import get_current_user_raw + from zou.app.services.identities_service import ( + get_current_identity_raw, + ) - person = get_current_user_raw() + person = get_current_identity_raw() person_id = person.id except BaseException: person_id = None diff --git a/zou/app/utils/permissions.py b/zou/app/utils/permissions.py index 6e01f559dc..abcdea8cdb 100644 --- a/zou/app/utils/permissions.py +++ b/zou/app/utils/permissions.py @@ -90,21 +90,3 @@ def check_admin_permissions(): return True else: raise PermissionDenied - - -def require_admin(function): - @wraps(function) - def decorated_function(*args, **kwargs): - check_admin_permissions() - return function(*args, **kwargs) - - return decorated_function - - -def require_manager(function): - @wraps(function) - def decorated_function(*args, **kwargs): - check_manager_permissions() - return function(*args, **kwargs) - - return decorated_function diff --git a/zou/cli.py b/zou/cli.py index 8888029169..2a3cd77d79 100755 --- a/zou/cli.py +++ b/zou/cli.py @@ -181,7 +181,7 @@ def disable_two_factor_authentication(email_or_desktop_login): """ with app.app_context(): try: - person_id = persons_service.get_person_by_email_dekstop_login( + person_id = persons_service.get_person_by_email_desktop_login( email_or_desktop_login ) auth_service.disable_two_factor_authentication_for_person( @@ -494,4 +494,4 @@ def generate_tiles_and_reset_preview_files_metadata(): if __name__ == "__main__": - cli() + clean_auth_tokens() diff --git a/zou/event_stream.py b/zou/event_stream.py index b0d7cc3edf..956733f2c4 100644 --- a/zou/event_stream.py +++ b/zou/event_stream.py @@ -4,7 +4,7 @@ from flask import Flask, jsonify from flask_jwt_extended import ( - get_jwt, + get_jwt_identity, jwt_required, verify_jwt_in_request, JWTManager, @@ -115,7 +115,7 @@ def connected(): def disconnected(): try: verify_jwt_in_request() - user_id = get_jwt()["user_id"] + user_id = get_jwt_identity() # needed to be able to clear empty rooms tmp_rooms_data = dict(rooms_data) for room_id in tmp_rooms_data: @@ -160,7 +160,7 @@ def on_join(data): When a person joins the review room, we notify all its members that a new person is added to the room. """ - user_id = get_jwt()["user_id"] + user_id = get_jwt_identity() room, room_id = _get_room_from_data(data) if len(room["people"]) == 0: _update_room_playing_status(data, room) @@ -171,7 +171,7 @@ def on_join(data): @socketio.on("preview-room:leave", namespace="/events") @jwt_required() def on_leave(data): - user_id = get_jwt()["user_id"] + user_id = get_jwt_identity() room_id = data["playlist_id"] _leave_room(room_id, user_id) @@ -231,7 +231,7 @@ def set_auth(app): @jwt.token_in_blocklist_loader def check_if_token_is_revoked(_, payload): - return auth_tokens_store.is_revoked(payload) + return auth_tokens_store.is_revoked(payload["jti"]) (app, socketio) = create_app() diff --git a/zou/migrations/versions/104f22654ae9_create_new_table_apitoken.py b/zou/migrations/versions/104f22654ae9_create_new_table_apitoken.py new file mode 100644 index 0000000000..290855a4fe --- /dev/null +++ b/zou/migrations/versions/104f22654ae9_create_new_table_apitoken.py @@ -0,0 +1,92 @@ +"""Create new table ApiToken + +Revision ID: 104f22654ae9 +Revises: 7748d3d22925 +Create Date: 2023-09-29 03:58:11.087453 + +""" +from alembic import op +import sqlalchemy as sa +import sqlalchemy_utils +from sqlalchemy.dialects import postgresql +from pytz import timezone as pytz_timezone +from babel import Locale +import uuid + +# revision identifiers, used by Alembic. +revision = "104f22654ae9" +down_revision = "7748d3d22925" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "api_token", + sa.Column("name", sa.String(length=80), nullable=False, unique=True), + sa.Column( + "email", + sqlalchemy_utils.types.email.EmailType(length=255), + nullable=True, + ), + sa.Column("jti", sa.String(length=60), nullable=True), + sa.Column("expiration_date", sa.Date(), nullable=True), + sa.Column("active", sa.Boolean(), nullable=True), + sa.Column( + "timezone", + sqlalchemy_utils.types.timezone.TimezoneType(backend="pytz"), + default=pytz_timezone("Europe/Paris"), + nullable=True, + ), + sa.Column( + "locale", + sqlalchemy_utils.types.locale.LocaleType(), + default=Locale("en", "US"), + nullable=True, + ), + sa.Column( + "data", postgresql.JSONB(astext_type=sa.Text()), nullable=True + ), + sa.Column("role", sa.String(length=30), nullable=True), + sa.Column("has_avatar", sa.Boolean(), nullable=True), + sa.Column( + "id", + sqlalchemy_utils.types.uuid.UUIDType(binary=False), + default=uuid.uuid4, + nullable=False, + ), + sa.Column("created_at", sa.DateTime(), nullable=True), + sa.Column("updated_at", sa.DateTime(), nullable=True), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("jti"), + ) + with op.batch_alter_table("department_link", schema=None) as batch_op: + batch_op.add_column( + sa.Column( + "api_token_id", + sqlalchemy_utils.types.uuid.UUIDType(binary=False), + default=None, + nullable=True, + ) + ) + batch_op.alter_column( + "person_id", existing_type=sa.UUID(), nullable=True, default=None + ) + batch_op.create_foreign_key( + None, "api_token", ["api_token_id"], ["id"] + ) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("department_link", schema=None) as batch_op: + batch_op.alter_column( + "person_id", existing_type=sa.UUID(), nullable=False + ) + batch_op.drop_column("api_token_id") + + op.drop_table("api_token") + # ### end Alembic commands ###