From 9d6d679eb3b2dd173fcfa84f787a2d7bb73568b4 Mon Sep 17 00:00:00 2001 From: davide <silvestri.eng@gmail.com> Date: Tue, 22 Mar 2022 10:18:18 +0000 Subject: [PATCH] Resolve #244: Updating the token permissions fails hard when a table has been deleted in the meantime --- .../database/api/tokens/serializers.py | 6 ++ .../baserow/contrib/database/tokens/models.py | 17 ++++ .../database/api/tokens/test_token_views.py | 96 ++++++++++++++++++- .../database/tokens/test_token_handler.py | 29 ++---- changelog.md | 4 +- .../database/components/settings/APIToken.vue | 46 +++++++++ 6 files changed, 172 insertions(+), 26 deletions(-) diff --git a/backend/src/baserow/contrib/database/api/tokens/serializers.py b/backend/src/baserow/contrib/database/api/tokens/serializers.py index d58c0c7cf..2fd2c841f 100644 --- a/backend/src/baserow/contrib/database/api/tokens/serializers.py +++ b/backend/src/baserow/contrib/database/api/tokens/serializers.py @@ -17,6 +17,8 @@ class TokenPermissionsField(serializers.Field): 'ids like [["database", 1], ["table", 1]].' ), "invalid_instance_type": "The instance type can only be a database or table.", + "invalid_table_id": "The table id {instance_id} is not valid.", + "invalid_database_id": "The database id {instance_id} is not valid.", } valid_types = ["create", "read", "update", "delete"] @@ -102,8 +104,12 @@ class TokenPermissionsField(serializers.Field): if isinstance(value, list): for index, (instance_type, instance_id) in enumerate(value): if instance_type == "database": + if instance_id not in databases: + self.fail("invalid_database_id", instance_id=instance_id) data[key][index] = databases[instance_id] elif instance_type == "table": + if instance_id not in tables: + self.fail("invalid_table_id", instance_id=instance_id) data[key][index] = tables[instance_id] return { diff --git a/backend/src/baserow/contrib/database/tokens/models.py b/backend/src/baserow/contrib/database/tokens/models.py index 7ca0d2752..1cbdc0d91 100644 --- a/backend/src/baserow/contrib/database/tokens/models.py +++ b/backend/src/baserow/contrib/database/tokens/models.py @@ -1,5 +1,6 @@ from django.db import models from django.contrib.auth import get_user_model +from django.db.models import Q from baserow.core.mixins import ParentGroupTrashableModelMixin from baserow.core.models import Group @@ -47,6 +48,20 @@ class Token(ParentGroupTrashableModelMixin, models.Model): ordering = ("id",) +class TokenPermissionManager(models.Manager): + """ + This manager is needed to avoid problems with tokens of trashed + but not already deleted databases and tables. + After 3 days (default) trashed databases and tables are deleted permanently, + and so are relative token permissions (because of the CASCADE option). + In the meanwhile, we need to filter out the trashed databases and tables. + """ + + def get_queryset(self): + trashed_Q = Q(database__trashed=True) | Q(table__trashed=True) + return super().get_queryset().filter(~trashed_Q) + + class TokenPermission(models.Model): """ The existence of a permission indicates that the token has access to a table. If @@ -56,6 +71,8 @@ class TokenPermission(models.Model): means that the token has access to that table. """ + objects = TokenPermissionManager() + token = models.ForeignKey("database.Token", on_delete=models.CASCADE) type = models.CharField( max_length=6, diff --git a/backend/tests/baserow/contrib/database/api/tokens/test_token_views.py b/backend/tests/baserow/contrib/database/api/tokens/test_token_views.py index 907b93208..c0c0e5cef 100644 --- a/backend/tests/baserow/contrib/database/api/tokens/test_token_views.py +++ b/backend/tests/baserow/contrib/database/api/tokens/test_token_views.py @@ -12,6 +12,7 @@ from django.shortcuts import reverse from baserow.contrib.database.tokens.handler import TokenHandler from baserow.contrib.database.tokens.models import Token, TokenPermission +from baserow.core.trash.handler import TrashHandler @pytest.mark.django_db @@ -209,8 +210,8 @@ def test_get_token(api_client, data_fixture): assert len(response_json["permissions"]["read"]) == 1 assert response_json["permissions"]["read"][0] == ["database", database_2.id] assert len(response_json["permissions"]["update"]) == 2 - assert response_json["permissions"]["update"][0] == ["database", database_1.id] - assert response_json["permissions"]["update"][1] == ["table", table_3.id] + assert response_json["permissions"]["update"][0] == ["table", table_3.id] + assert response_json["permissions"]["update"][1] == ["database", database_1.id] assert response_json["permissions"]["delete"] is False TokenHandler().update_token_permissions( @@ -227,8 +228,8 @@ def test_get_token(api_client, data_fixture): response_json = response.json() assert response.status_code == HTTP_200_OK assert len(response_json["permissions"]["create"]) == 2 - assert response_json["permissions"]["create"][0] == ["database", database_1.id] - assert response_json["permissions"]["create"][1] == ["database", database_2.id] + assert response_json["permissions"]["create"][0] == ["database", database_2.id] + assert response_json["permissions"]["create"][1] == ["database", database_1.id] assert response_json["permissions"]["read"] is False assert response_json["permissions"]["update"] is True assert len(response_json["permissions"]["delete"]) == 1 @@ -525,3 +526,90 @@ def test_delete_token(api_client, data_fixture): assert Token.objects.all().count() == 2 assert TokenPermission.objects.all().count() == 0 + + +@pytest.mark.django_db +def test_trashing_table_hides_restores_tokens(api_client, data_fixture): + user, token = data_fixture.create_user_and_token() + group_1 = data_fixture.create_group(user=user) + token_1 = data_fixture.create_token(user=user, group=group_1) + + database_1 = data_fixture.create_database_application(group=group_1) + database_2 = data_fixture.create_database_application(group=group_1) + table_1 = data_fixture.create_database_table( + database=database_1, create_table=False + ) + table_2 = data_fixture.create_database_table( + database=database_1, create_table=False + ) + + TokenHandler().update_token_permissions( + user, + token_1, + create=[database_1, table_1, table_2, database_2], + read=[database_1, table_1, table_2, database_2], + update=[database_1, table_1, table_2, database_2], + delete=[database_1, table_1, table_2, database_2], + ) + + def assert_all_permission_types_for_token_are(value): + url = reverse("api:database:tokens:item", kwargs={"token_id": token_1.id}) + response = api_client.get(url, format="json", HTTP_AUTHORIZATION=f"JWT {token}") + response_json = response.json() + assert response.status_code == HTTP_200_OK + # permissions must be the same, ignoring order + permissions = response_json["permissions"] + sorted_value = sorted(value) + assert sorted(permissions["update"]) == sorted_value + assert sorted(permissions["create"]) == sorted_value + assert sorted(permissions["delete"]) == sorted_value + assert sorted(permissions["read"]) == sorted_value + + assert_all_permission_types_for_token_are( + [ + ["table", table_1.id], + ["table", table_2.id], + ["database", database_2.id], + ["database", database_1.id], + ] + ) + + TrashHandler.trash(user, group_1, database_1, table_1) + + assert_all_permission_types_for_token_are( + [ + ["table", table_2.id], + ["database", database_2.id], + ["database", database_1.id], + ] + ) + + TrashHandler.trash(user, group_1, database_1, database_1) + + assert_all_permission_types_for_token_are( + [ + ["table", table_2.id], + ["database", database_2.id], + ] + ) + + TrashHandler.restore_item(user, "application", database_1.id) + + assert_all_permission_types_for_token_are( + [ + ["table", table_2.id], + ["database", database_2.id], + ["database", database_1.id], + ] + ) + + TrashHandler.restore_item(user, "table", table_1.id) + + assert_all_permission_types_for_token_are( + [ + ["table", table_2.id], + ["table", table_1.id], + ["database", database_2.id], + ["database", database_1.id], + ] + ) diff --git a/backend/tests/baserow/contrib/database/tokens/test_token_handler.py b/backend/tests/baserow/contrib/database/tokens/test_token_handler.py index 67cbc955a..3ed951d1c 100644 --- a/backend/tests/baserow/contrib/database/tokens/test_token_handler.py +++ b/backend/tests/baserow/contrib/database/tokens/test_token_handler.py @@ -97,7 +97,6 @@ def test_generate_token(data_fixture): @pytest.mark.django_db def test_create_token(data_fixture): user = data_fixture.create_user() - data_fixture.create_user() group_1 = data_fixture.create_group(user=user) group_2 = data_fixture.create_group() @@ -117,25 +116,15 @@ def test_create_token(data_fixture): permissions = TokenPermission.objects.all() assert permissions.count() == 4 - assert permissions[0].token_id == token.id - assert permissions[0].type == "create" - assert permissions[0].database_id is None - assert permissions[0].table_id is None - - assert permissions[1].token_id == token.id - assert permissions[1].type == "read" - assert permissions[1].database_id is None - assert permissions[1].table_id is None - - assert permissions[2].token_id == token.id - assert permissions[2].type == "update" - assert permissions[2].database_id is None - assert permissions[2].table_id is None - - assert permissions[3].token_id == token.id - assert permissions[3].type == "delete" - assert permissions[3].database_id is None - assert permissions[3].table_id is None + permissions_types = {"create", "read", "update", "delete"} + for i in range(4): + assert permissions[i].token_id == token.id + try: + permissions_types.remove(permissions[i].type) + except KeyError: + assert False, f"Permission type '{permissions[i].type}' not found" + assert permissions[i].database_id is None + assert permissions[i].table_id is None @pytest.mark.django_db diff --git a/changelog.md b/changelog.md index b96bd47fa..b50141e93 100644 --- a/changelog.md +++ b/changelog.md @@ -12,7 +12,8 @@ * Fixed the reactivity of the row values of newly created fields in some cases. * Fixed a bug that made it possible to delete created on/modified by fields on the web frontend. * Allow the setting of max request page size via environment variable. -* Boolean field converts the word `checked` to `True` value +* Boolean field converts the word `checked` to `True` value. +* Fixed a bug where the backend would fail hard updating token permissions for deleted tables. ## Released (2022-03-03 1.9.1) @@ -81,7 +82,6 @@ * Add health checks for all services. * Ensure error logging is enabled in the Backend even when DEBUG is off. * Removed upload file size limit. -* Boolean field converts the word `checked` to `True` value ## Released (2022-01-13 1.8.2) diff --git a/web-frontend/modules/database/components/settings/APIToken.vue b/web-frontend/modules/database/components/settings/APIToken.vue index 4221e6110..1ddf2b759 100644 --- a/web-frontend/modules/database/components/settings/APIToken.vue +++ b/web-frontend/modules/database/components/settings/APIToken.vue @@ -210,6 +210,16 @@ export default { ) }, }, + watch: { + databases: { + handler() { + // if databases or tables change, we need to ensure that token permissions + // are still valid + this.removeInvalidPermissions() + }, + deep: true, + }, + }, methods: { copyTokenToClipboard() { copyToClipboard(this.token.key) @@ -355,6 +365,42 @@ export default { this.exists(operation, 'table', table.id) ) }, + /** + * Indicates if the permission refer to a database or table still existent. + * This fixes the problem that arises when user deletes a database or table from + * another browser tab while this form is opened. + * We need to delete the permissions that are pointing to the deleted database + * before sending updates to the backend if we want to avoid errors. + */ + removeInvalidPermissions() { + const tokenPermissions = JSON.parse( + JSON.stringify(this.token.permissions) + ) + for (const [operation, permissions] of Object.entries(tokenPermissions)) { + if (!Array.isArray(permissions)) { + continue + } + permissions.forEach((permission) => { + if (!this.isPermissionValid(permission)) { + const [permType, permId] = permission + this.remove(operation, permType, permId) + } + }) + } + }, + isPermissionValid(permission) { + const databases = this.databases + const [permType, permId] = permission + if (permType === 'database') { + const database = databases.find((database) => database.id === permId) + return database !== undefined + } else if (permType === 'table') { + return databases.find((database) => { + const table = database.tables.find((table) => table.id === permId) + return table !== undefined + }) + } + }, /** * Changes the token permission state of all databases and tables of the given * operation. Also updates the permissions with the backend.