From 64019cbc558f84ee34f4f0139c1bf2d467b76ec2 Mon Sep 17 00:00:00 2001 From: Nigel Gott <nigel@baserow.io> Date: Fri, 25 Aug 2023 11:41:54 +0000 Subject: [PATCH] Resolve "Fix allowing builders to write lookup formulas onto tables into which they don't have viewer or higher rights" --- .../dependencies/dependency_rebuilder.py | 8 +- .../database/fields/dependencies/handler.py | 73 +++++++++- .../contrib/database/fields/field_types.py | 14 ++ .../contrib/database/fields/handler.py | 9 +- .../contrib/database/fields/registries.py | 16 +++ .../contrib/database/formula/__init__.py | 6 + .../database/formula/types/exceptions.py | 9 ++ .../database/formula/types/visitors.py | 9 +- ...o_write_lookup_formulas_onto_tables_i.json | 7 + .../fields/test_link_row_field_rbac.py | 126 ++++++++++++++++++ .../rbac/SelectSubjectsListFooter.vue | 14 -- .../baserow_enterprise/locales/en.json | 3 +- .../field/FieldSelectTargetFieldSubForm.vue | 9 +- .../field/FieldSelectThroughFieldSubForm.vue | 11 +- 14 files changed, 286 insertions(+), 28 deletions(-) create mode 100644 changelog/entries/unreleased/bug/1883_fix_allowing_builders_to_write_lookup_formulas_onto_tables_i.json diff --git a/backend/src/baserow/contrib/database/fields/dependencies/dependency_rebuilder.py b/backend/src/baserow/contrib/database/fields/dependencies/dependency_rebuilder.py index 8944e86ec..3b97cf87e 100644 --- a/backend/src/baserow/contrib/database/fields/dependencies/dependency_rebuilder.py +++ b/backend/src/baserow/contrib/database/fields/dependencies/dependency_rebuilder.py @@ -1,4 +1,4 @@ -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, List from django.db.models import Q @@ -66,7 +66,7 @@ def update_fields_with_broken_references(field: "field_models.Field"): def rebuild_field_dependencies( field_instance, field_cache: FieldCache, -): +) -> List[FieldDependency]: """ Deletes all existing dependencies a field has and resets them to the ones defined by the field_instances FieldType.get_field_dependencies. Does not @@ -75,6 +75,7 @@ def rebuild_field_dependencies( :param field_instance: The field whose dependencies to change. :param field_cache: A cache which will be used to lookup the actual fields referenced by any provided field dependencies. + :return: Any new dependencies created by the rebuild. """ from baserow.contrib.database.fields.registries import field_type_registry @@ -106,8 +107,9 @@ def rebuild_field_dependencies( ): raise CircularFieldDependencyError() - FieldDependency.objects.bulk_create(new_dependencies_to_create) + new_dependencies = FieldDependency.objects.bulk_create(new_dependencies_to_create) # All new dependencies will have been removed from current_deps_by_str and so any # remaining ones are old dependencies which should no longer exist. Delete them. delete_ids = [dep.id for dep in current_deps_by_str.values()] FieldDependency.objects.filter(pk__in=delete_ids).delete() + return new_dependencies diff --git a/backend/src/baserow/contrib/database/fields/dependencies/handler.py b/backend/src/baserow/contrib/database/fields/dependencies/handler.py index 8808ae5b2..d28f84257 100644 --- a/backend/src/baserow/contrib/database/fields/dependencies/handler.py +++ b/backend/src/baserow/contrib/database/fields/dependencies/handler.py @@ -1,5 +1,6 @@ from typing import Iterable, List, Optional, Tuple +from django.contrib.auth.models import AbstractUser from django.db.models import Q from baserow.contrib.database.fields.dependencies.dependency_rebuilder import ( @@ -10,6 +11,9 @@ from baserow.contrib.database.fields.dependencies.dependency_rebuilder import ( from baserow.contrib.database.fields.field_cache import FieldCache from baserow.contrib.database.fields.models import Field, LinkRowField from baserow.contrib.database.fields.registries import FieldType, field_type_registry +from baserow.core.handler import CoreHandler +from baserow.core.models import Workspace +from baserow.core.types import PermissionCheck from .models import FieldDependency @@ -35,16 +39,19 @@ class FieldDependencyHandler: ] @classmethod - def rebuild_dependencies(cls, field, field_cache: FieldCache): + def rebuild_dependencies( + cls, field, field_cache: FieldCache + ) -> List[FieldDependency]: """ Rebuilds this fields dependencies based off field_type.get_field_dependencies. :param field: The field to rebuild its field dependencies for. :param field_cache: A field cache which will be used to lookup fields. + :return: Any new dependencies created by the rebuild. """ update_fields_with_broken_references(field) - rebuild_field_dependencies(field, field_cache) + return rebuild_field_dependencies(field, field_cache) @classmethod def break_dependencies_delete_dependants(cls, field): @@ -160,3 +167,65 @@ class FieldDependencyHandler: ) dependants.append(field_dep_tuple) return dependants + + @classmethod + def rebuild_or_raise_if_user_doesnt_have_permissions_after( + cls, + workspace: Workspace, + user: AbstractUser, + field: Field, + field_cache: FieldCache, + field_operation_name: str, + ): + new_dependencies = FieldDependencyHandler.rebuild_dependencies( + field, field_cache + ) + FieldDependencyHandler.raise_if_user_doesnt_have_operation_on_dependencies_in_other_tables( + workspace, user, field, new_dependencies, field_operation_name + ) + + @classmethod + def raise_if_user_doesnt_have_operation_on_dependencies_in_other_tables( + cls, + workspace: Workspace, + user: AbstractUser, + field: Field, + dependencies: List[FieldDependency], + field_operation_name: str, + ): + """ + For a list of dependencies checks the provided user has the provided operation + on each of the dependency fields raising if they do not. It skips any + dependencies that point at the same table assuming the user already has rights + on fields in the table `field` is in. + + :param workspace: The workspace. + :param user: The user. + :param field: The field. + :param dependencies: The dependencies. + :param field_operation_name: The field operation name. + """ + + perm_checks = [] + for dep in dependencies: + dependency_field = dep.dependency + if dependency_field.table_id != field.table_id: + perm_checks.append( + PermissionCheck( + actor=user, + operation_name=field_operation_name, + context=dependency_field, + ) + ) + + changed_field_type = field_type_registry.get_by_model(field) + for ( + check, + result, + ) in ( + CoreHandler().check_multiple_permissions(perm_checks, workspace).items() + ): + if not result: + raise changed_field_type.get_permission_error_when_user_changes_field_to_depend_on_forbidden_field( + check.actor, field, check.context + ) diff --git a/backend/src/baserow/contrib/database/fields/field_types.py b/backend/src/baserow/contrib/database/fields/field_types.py index 9b88f33ce..ea5c46b39 100755 --- a/backend/src/baserow/contrib/database/fields/field_types.py +++ b/backend/src/baserow/contrib/database/fields/field_types.py @@ -3739,6 +3739,20 @@ class FormulaFieldType(ReadOnlyFieldType): def can_represent_date(self, field: "Field") -> bool: return self.to_baserow_formula_type(field.specific).can_represent_date + def get_permission_error_when_user_changes_field_to_depend_on_forbidden_field( + self, user: AbstractUser, changed_field: Field, forbidden_field: Field + ) -> Exception: + from baserow.contrib.database.formula import ( + InvalidFormulaType, + get_invalid_field_and_table_formula_error, + ) + + return InvalidFormulaType( + get_invalid_field_and_table_formula_error( + forbidden_field.name, forbidden_field.table.name + ) + ) + class CountFieldType(FormulaFieldType): type = "count" diff --git a/backend/src/baserow/contrib/database/fields/handler.py b/backend/src/baserow/contrib/database/fields/handler.py index e62df1b29..02ea76a5a 100644 --- a/backend/src/baserow/contrib/database/fields/handler.py +++ b/backend/src/baserow/contrib/database/fields/handler.py @@ -43,6 +43,7 @@ from baserow.contrib.database.fields.operations import ( CreateFieldOperationType, DeleteFieldOperationType, DuplicateFieldOperationType, + ReadFieldOperationType, UpdateFieldOperationType, ) from baserow.contrib.database.table.models import Table @@ -308,7 +309,9 @@ class FieldHandler(metaclass=baserow_trace_methods(tracer)): field_cache = FieldCache() instance.save(field_cache=field_cache, raise_if_invalid=True) - FieldDependencyHandler.rebuild_dependencies(instance, field_cache) + FieldDependencyHandler.rebuild_or_raise_if_user_doesnt_have_permissions_after( + workspace, user, instance, field_cache, ReadFieldOperationType.type + ) # Add the field to the table schema. with safe_django_schema_editor(atomic=False) as schema_editor: @@ -458,7 +461,9 @@ class FieldHandler(metaclass=baserow_trace_methods(tracer)): field = set_allowed_attrs(field_values, allowed_fields, field) field.save(field_cache=field_cache, raise_if_invalid=True) - FieldDependencyHandler.rebuild_dependencies(field, field_cache) + FieldDependencyHandler.rebuild_or_raise_if_user_doesnt_have_permissions_after( + workspace, user, field, field_cache, ReadFieldOperationType.type + ) # If no converter is found we are going to convert to field using the # lenient schema editor which will alter the field's type and set the data # value to null if it can't be converted. diff --git a/backend/src/baserow/contrib/database/fields/registries.py b/backend/src/baserow/contrib/database/fields/registries.py index e45596ff1..8f2965386 100644 --- a/backend/src/baserow/contrib/database/fields/registries.py +++ b/backend/src/baserow/contrib/database/fields/registries.py @@ -1,6 +1,7 @@ from typing import TYPE_CHECKING, Any, Dict, List, NoReturn, Optional, Union from zipfile import ZipFile +from django.contrib.auth.models import AbstractUser from django.contrib.postgres.fields import ArrayField, JSONField from django.core.exceptions import ValidationError from django.core.files.storage import Storage @@ -1517,6 +1518,21 @@ class FieldType( return False + def get_permission_error_when_user_changes_field_to_depend_on_forbidden_field( + self, user: AbstractUser, changed_field: Field, forbidden_field: Field + ) -> Exception: + """ + Called when the field has been created or changed in a way that resulted in + it depending on another field in a way that was forbidden for the user + who triggered the change. + + :param user: The user. + :param changed_field: The changed field. + :param forbidden_field: The forbidden field. + """ + + return PermissionError(user) + class ReadOnlyFieldHasNoInternalDbValueError(Exception): """ diff --git a/backend/src/baserow/contrib/database/formula/__init__.py b/backend/src/baserow/contrib/database/formula/__init__.py index f344d1cfe..66d24f821 100644 --- a/backend/src/baserow/contrib/database/formula/__init__.py +++ b/backend/src/baserow/contrib/database/formula/__init__.py @@ -34,6 +34,10 @@ allowing use of that language in Baserow easily. from baserow.contrib.database.formula.ast.tree import BaserowExpression from baserow.contrib.database.formula.handler import FormulaHandler from baserow.contrib.database.formula.operations import TypeFormulaOperationType +from baserow.contrib.database.formula.types.exceptions import ( + InvalidFormulaType, + get_invalid_field_and_table_formula_error, +) from baserow.contrib.database.formula.types.formula_type import ( BaserowFormulaInvalidType, BaserowFormulaType, @@ -71,4 +75,6 @@ __all__ = [ BASEROW_FORMULA_ARRAY_TYPE_CHOICES, literal, TypeFormulaOperationType, + InvalidFormulaType, + get_invalid_field_and_table_formula_error, ] diff --git a/backend/src/baserow/contrib/database/formula/types/exceptions.py b/backend/src/baserow/contrib/database/formula/types/exceptions.py index 1bb83cf2e..d1f50e3a0 100644 --- a/backend/src/baserow/contrib/database/formula/types/exceptions.py +++ b/backend/src/baserow/contrib/database/formula/types/exceptions.py @@ -10,3 +10,12 @@ class UnknownFormulaType(BaserowFormulaException): super().__init__( f"unknown formula type found on formula field of {unknown_formula_type}" ) + + +def get_invalid_field_and_table_formula_error( + unknown_field_name: str, table_name: str +) -> str: + return ( + f"references the deleted or unknown field {unknown_field_name} in table " + f"{table_name}" + ) diff --git a/backend/src/baserow/contrib/database/formula/types/visitors.py b/backend/src/baserow/contrib/database/formula/types/visitors.py index aba472ec2..d7a92a9f9 100644 --- a/backend/src/baserow/contrib/database/formula/types/visitors.py +++ b/backend/src/baserow/contrib/database/formula/types/visitors.py @@ -16,6 +16,9 @@ from baserow.contrib.database.formula.ast.tree import ( BaserowStringLiteral, ) from baserow.contrib.database.formula.ast.visitors import BaserowFormulaASTVisitor +from baserow.contrib.database.formula.types.exceptions import ( + get_invalid_field_and_table_formula_error, +) from baserow.contrib.database.formula.types.formula_type import ( BaserowFormulaValidType, UnTyped, @@ -254,9 +257,9 @@ class FormulaTypingVisitor( ) if target_field is None: return field_reference.with_invalid_type( - f"references the deleted or unknown field" - f" {field_reference.target_field} in table " - f"{target_table.name}" + get_invalid_field_and_table_formula_error( + field_reference.target_field, target_table.name + ) ) else: return self._create_lookup_reference( diff --git a/changelog/entries/unreleased/bug/1883_fix_allowing_builders_to_write_lookup_formulas_onto_tables_i.json b/changelog/entries/unreleased/bug/1883_fix_allowing_builders_to_write_lookup_formulas_onto_tables_i.json new file mode 100644 index 000000000..643022802 --- /dev/null +++ b/changelog/entries/unreleased/bug/1883_fix_allowing_builders_to_write_lookup_formulas_onto_tables_i.json @@ -0,0 +1,7 @@ +{ + "type": "bug", + "message": "Fix allowing builders to create or edit lookup formulas to target fields in tables where they dont have access", + "issue_number": 1883, + "bullet_points": [], + "created_at": "2023-08-03" +} \ No newline at end of file diff --git a/enterprise/backend/tests/baserow_enterprise_tests/fields/test_link_row_field_rbac.py b/enterprise/backend/tests/baserow_enterprise_tests/fields/test_link_row_field_rbac.py index 9b18205c6..dab60d491 100644 --- a/enterprise/backend/tests/baserow_enterprise_tests/fields/test_link_row_field_rbac.py +++ b/enterprise/backend/tests/baserow_enterprise_tests/fields/test_link_row_field_rbac.py @@ -4,6 +4,7 @@ This file tests the link row field in combination with RBAC enabled import pytest from baserow.contrib.database.fields.handler import FieldHandler +from baserow.contrib.database.formula import InvalidFormulaType from baserow.core.exceptions import PermissionDenied from baserow_enterprise.role.handler import RoleAssignmentHandler from baserow_enterprise.role.models import Role @@ -137,3 +138,128 @@ def test_link_row_field_linked_to_table_with_no_access_from_inaccessible_to_acce field=link_row_field.specific, link_row_table=table_unrelated, ) + + +@pytest.mark.django_db +def test_cant_create_lookup_at_table_where_not_viewer_or_higher( + data_fixture, +): + user_without_access = data_fixture.create_user() + user_with_access = data_fixture.create_user() + workspace = data_fixture.create_workspace( + user=user_with_access, members=[user_without_access] + ) + database = data_fixture.create_database_application(workspace=workspace) + table_with_access = data_fixture.create_database_table( + user_without_access, database=database + ) + table_with_no_access = data_fixture.create_database_table( + user_without_access, database=database + ) + private_field_in_no_access_table = data_fixture.create_text_field( + user_with_access, table=table_with_no_access, name="private" + ) + no_access = Role.objects.get(uid="NO_ACCESS") + viewer_role = Role.objects.get(uid="VIEWER") + + RoleAssignmentHandler().assign_role( + user_without_access, workspace, role=no_access, scope=table_with_no_access + ) + + link_row_field = FieldHandler().create_field( + user=user_with_access, + table=table_with_access, + type_name="link_row", + name="link row", + link_row_table=table_with_no_access, + ) + + with pytest.raises(InvalidFormulaType): + FieldHandler().create_field( + user=user_without_access, + table=table_with_access, + type_name="lookup", + name="shouldnt be able to create", + target_field_name=private_field_in_no_access_table.name, + through_field_name=link_row_field.name, + ) + + # Now make them a viewer and it should work + RoleAssignmentHandler().assign_role( + user_without_access, workspace, role=viewer_role, scope=table_with_no_access + ) + + FieldHandler().create_field( + user=user_without_access, + table=table_with_access, + type_name="lookup", + name="now it will work", + target_field_name=private_field_in_no_access_table.name, + through_field_name=link_row_field.name, + ) + + +@pytest.mark.django_db +def test_cant_update_target_lookup_point_at_table_where_not_viewer_or_higher( + data_fixture, +): + user_without_access = data_fixture.create_user() + user_with_access = data_fixture.create_user() + workspace = data_fixture.create_workspace( + user=user_with_access, members=[user_without_access] + ) + database = data_fixture.create_database_application(workspace=workspace) + table_with_access = data_fixture.create_database_table( + user_without_access, database=database + ) + table_with_no_access = data_fixture.create_database_table( + user_without_access, database=database + ) + private_field_in_no_access_table = data_fixture.create_text_field( + user_with_access, table=table_with_no_access, name="private" + ) + other_private_field_in_no_access_table = data_fixture.create_text_field( + user_with_access, table=table_with_no_access, name="other private" + ) + no_access = Role.objects.get(uid="NO_ACCESS") + viewer_role = Role.objects.get(uid="VIEWER") + + RoleAssignmentHandler().assign_role( + user_without_access, workspace, role=no_access, scope=table_with_no_access + ) + + link_row_field = FieldHandler().create_field( + user=user_with_access, + table=table_with_access, + type_name="link_row", + name="link row", + link_row_table=table_with_no_access, + ) + lookup_field = FieldHandler().create_field( + user=user_with_access, + table=table_with_access, + type_name="lookup", + name="lookup", + target_field_id=private_field_in_no_access_table.id, + through_field_id=link_row_field.id, + ) + + with pytest.raises(InvalidFormulaType): + # The user without the access tries to point the lookup at a different field + FieldHandler().update_field( + user=user_without_access, + field=lookup_field, + target_field_id=other_private_field_in_no_access_table.id, + through_field_id=link_row_field.id, + ) + + # Now make them a viewer and it should work + RoleAssignmentHandler().assign_role( + user_without_access, workspace, role=viewer_role, scope=table_with_no_access + ) + FieldHandler().update_field( + user=user_without_access, + field=lookup_field, + target_field_name=other_private_field_in_no_access_table.name, + through_field_name=link_row_field.name, + ) diff --git a/enterprise/web-frontend/modules/baserow_enterprise/components/rbac/SelectSubjectsListFooter.vue b/enterprise/web-frontend/modules/baserow_enterprise/components/rbac/SelectSubjectsListFooter.vue index 1a4e6ec37..7f84e44ab 100644 --- a/enterprise/web-frontend/modules/baserow_enterprise/components/rbac/SelectSubjectsListFooter.vue +++ b/enterprise/web-frontend/modules/baserow_enterprise/components/rbac/SelectSubjectsListFooter.vue @@ -5,14 +5,6 @@ > <div v-if="showRoleSelector" class="select-list-footer__left-side"> <RoleSelector v-model="roleSelected" :roles="roles" /> - <HelpIcon - v-if="roleSelectedExposesData" - class="margin-left-1" - :tooltip=" - $t('selectSubjectsListFooter.roleSelectorBuilderDataWarningTooltip') - " - is-warning - /> </div> <div> <HelpIcon @@ -88,12 +80,6 @@ export default { inviteEnabled() { return this.count !== 0 }, - roleSelectedExposesData() { - return ( - ['ADMIN', 'BUILDER'].includes(this.roleSelected.uid) && - this.scopeType === 'database_table' - ) - }, }, mounted() { // Set a default selected role, the last role is usually the one with the least diff --git a/enterprise/web-frontend/modules/baserow_enterprise/locales/en.json b/enterprise/web-frontend/modules/baserow_enterprise/locales/en.json index 25b8ca29d..d7ee8628d 100644 --- a/enterprise/web-frontend/modules/baserow_enterprise/locales/en.json +++ b/enterprise/web-frontend/modules/baserow_enterprise/locales/en.json @@ -238,8 +238,7 @@ "members": "members", "teams": "teams" }, - "helpTooltip": "Member role assignments override team role assignments.", - "roleSelectorBuilderDataWarningTooltip": "They will be able to create formula and lookup fields exposing any fields in any linked tables." + "helpTooltip": "Member role assignments override team role assignments." }, "membersRoleField": { "noAccessHelpText": "This user requires explicit access to resources.", diff --git a/web-frontend/modules/database/components/field/FieldSelectTargetFieldSubForm.vue b/web-frontend/modules/database/components/field/FieldSelectTargetFieldSubForm.vue index d597cdc55..1ec1c745c 100644 --- a/web-frontend/modules/database/components/field/FieldSelectTargetFieldSubForm.vue +++ b/web-frontend/modules/database/components/field/FieldSelectTargetFieldSubForm.vue @@ -84,7 +84,7 @@ export default { return ( this.linkedToTable && this.$hasPermission( - 'database.table.list_fields', + 'database.table.create_field', this.linkedToTable, this.database.workspace.id ) @@ -115,6 +115,13 @@ export default { .get('field', f.type) .canBeReferencedByFormulaField() }) + .filter((f) => { + return this.$hasPermission( + 'database.table.field.update', + f, + this.database.workspace.id + ) + }) .map((f) => { const fieldType = this.$registry.get('field', f.type) f.icon = fieldType.getIconClass() diff --git a/web-frontend/modules/database/components/field/FieldSelectThroughFieldSubForm.vue b/web-frontend/modules/database/components/field/FieldSelectThroughFieldSubForm.vue index 5605f576f..920b247f5 100644 --- a/web-frontend/modules/database/components/field/FieldSelectThroughFieldSubForm.vue +++ b/web-frontend/modules/database/components/field/FieldSelectThroughFieldSubForm.vue @@ -77,7 +77,16 @@ export default { return this.$store.getters['application/getAll'].reduce( (tables, application) => { if (application.type === databaseType) { - return tables.concat(application.tables || []) + const tablesWithCreateFieldAccess = ( + application.tables || [] + ).filter((table) => + this.$hasPermission( + 'database.table.create_field', + table, + this.database.workspace.id + ) + ) + return tables.concat(tablesWithCreateFieldAccess) } return tables },