Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions backend/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@ dev = [
"ipdb",
"build",
"rust-just>=1.46.0",
"syrupy==5.1.0",
]
changelog = [
"typer==0.24.1",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,13 @@
from rest_framework import serializers
from rest_framework.exceptions import ValidationError

from baserow.contrib.builder.data_sources.exceptions import (
DataSourceRefinementForbidden,
)
from baserow.contrib.builder.elements.exceptions import ElementDoesNotExist
from baserow.contrib.builder.elements.models import Element
from baserow.contrib.builder.elements.service import ElementService
from baserow.core.exceptions import PermissionException

IANA_TIMEZONES = [(tz, tz) for tz in pytz.all_timezones]

Expand All @@ -27,6 +33,15 @@ def validate(self, data):
page = self.context.get("page")
element = data.get("element")
if element:
user = self.context.get("user")
if user is not None:
try:
data["element"] = ElementService().get_element(user, element.id)
except (ElementDoesNotExist, PermissionException):
raise DataSourceRefinementForbidden(
"The data source is not available for the dispatched element."
) from None

if (
element.page_id != page.id
and element.page.builder.shared_page.id != page.id
Expand Down
30 changes: 30 additions & 0 deletions backend/src/baserow/contrib/builder/application_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -579,3 +579,33 @@ def fetch_pages_to_serialize(
else:
instance = self.enhance_queryset(base_queryset).first()
return instance and list(instance.page_set.all()) or []

def serialize_for_regression_testing(self, builder: Builder) -> dict:
"""
Serializes each page's element tree as a list of ``{type, children}`` nodes,
keyed by page name. Used by snapshot tests to detect element-hierarchy
regressions across template changes.

:param builder: The builder application instance to serialize.
:return: A dict mapping page names to their element-tree representation.
"""
from baserow.contrib.builder.elements.handler import ElementHandler
from baserow.contrib.builder.pages.handler import PageHandler

result = {}
for page in PageHandler().get_pages(builder):
elements = list(ElementHandler().get_elements(page))
# Elements are already ordered by (order, id) via Element.Meta.ordering.
by_parent: dict[int | None, list] = {}
for el in elements:
by_parent.setdefault(el.parent_element_id, []).append(el)

def build_tree(parent_id):
return [
{"type": el.get_type().type, "children": build_tree(el.id)}
for el in by_parent.get(parent_id, [])
]

result[page.name] = build_tree(None)

return result
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,14 @@ def request_data(self) -> Dict:

serializer = DynamicMetadataSerializer(
data=getattr(self.request, "data", {}).get("metadata", {}),
context={"page": self.page},
context={
"page": self.page,
"user": getattr(
self.request,
"user_source_user",
getattr(self.request, "user", None),
),
},
)
serializer.is_valid(raise_exception=True)

Expand Down
72 changes: 55 additions & 17 deletions backend/src/baserow/contrib/builder/elements/permission_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@
from django.contrib.auth.models import AbstractUser
from django.db.models import Q, QuerySet

from baserow.contrib.builder.elements.operations import ListElementsPageOperationType
from baserow.contrib.builder.elements.operations import (
ListElementsPageOperationType,
ReadElementOperationType,
)
from baserow.contrib.builder.pages.models import Page
from baserow.contrib.builder.workflow_actions.operations import (
DispatchBuilderWorkflowActionOperationType,
Expand Down Expand Up @@ -70,6 +73,53 @@ def auth_user_can_view_element(self, user, element):
# Return False by default for safety
return False

def actor_can_view_page(self, actor, page):
"""
Return True if the actor is allowed to view the page.
"""

if isinstance(actor, User):
return True

if page.visibility != Page.VISIBILITY_TYPES.LOGGED_IN:
return True

if not getattr(actor, "is_authenticated", False):
return False

if page.role_type == Page.ROLE_TYPES.ALLOW_ALL:
return True
elif page.role_type == Page.ROLE_TYPES.ALLOW_ALL_EXCEPT:
return actor.role not in page.roles
elif page.role_type == Page.ROLE_TYPES.DISALLOW_ALL_EXCEPT:
return actor.role in page.roles

return False

def actor_can_view_element(self, actor, element):
"""
Return True if the actor is allowed to view the element, taking element
and page visibility and role settings into account.
"""

if not self.actor_can_view_page(actor, element.page):
return False

current_element = element
while current_element is not None:
if getattr(actor, "is_authenticated", False):
if current_element.visibility == Element.VISIBILITY_TYPES.NOT_LOGGED:
return False

if not self.auth_user_can_view_element(actor, current_element):
return False
elif current_element.visibility == Element.VISIBILITY_TYPES.LOGGED_IN:
return False

current_element = current_element.parent_element

return True

def check_multiple_permissions(
self,
checks,
Expand All @@ -85,22 +135,10 @@ def check_multiple_permissions(

for check in checks:
if check.operation_name == DispatchBuilderWorkflowActionOperationType.type:
if getattr(check.actor, "is_authenticated", False):
if (
check.context.element.visibility
== Element.VISIBILITY_TYPES.NOT_LOGGED
):
result[check] = False
elif not self.auth_user_can_view_element(
check.actor, check.context.element
):
result[check] = False
else:
if (
check.context.element.visibility
== Element.VISIBILITY_TYPES.LOGGED_IN
):
result[check] = False
if not self.actor_can_view_element(check.actor, check.context.element):
result[check] = False
elif check.operation_name == ReadElementOperationType.type:
result[check] = self.actor_can_view_element(check.actor, check.context)

return result

Expand Down
15 changes: 15 additions & 0 deletions backend/src/baserow/core/registries.py
Original file line number Diff line number Diff line change
Expand Up @@ -627,6 +627,21 @@ def get_application_id_for_url(cls, url: str) -> int | None:

return None

def serialize_for_regression_testing(
self, application: "Application"
) -> dict | None:
"""
Optionally serialize the application state for regression snapshot testing.

Override in subclasses to capture a human-readable, ID-free representation of
the application structure. Return None to opt this application type out.

:param application: The specific application instance.
:return: A serializable dict, or None to skip this application type.
"""

return None


ApplicationSubClassInstance = TypeVar(
"ApplicationSubClassInstance", bound="Application"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import json
from unittest.mock import ANY, MagicMock, patch

from django.test.utils import override_settings
Expand Down Expand Up @@ -1066,6 +1067,96 @@ def test_public_dispatch_data_source_view_returns_some_fields(
}


@pytest.mark.django_db
def test_public_dispatch_data_source_cannot_use_hidden_element_refinements(
api_client, data_fixture
):
user = data_fixture.create_user()
builder_from = data_fixture.create_builder_application(user=user)
builder = data_fixture.create_builder_application(workspace=None)
data_fixture.create_builder_custom_domain(
builder=builder_from, published_to=builder
)
public_page = data_fixture.create_builder_page(builder=builder)
hidden_page = data_fixture.create_builder_page(
builder=builder, visibility=Page.VISIBILITY_TYPES.LOGGED_IN
)
integration = data_fixture.create_local_baserow_integration(
application=builder, authorized_user=user, name="test"
)
table, _, _ = data_fixture.build_table(
user=user,
columns=[
("Name", "text"),
("SSN", "text"),
],
rows=[
["Peter", "111"],
["Afonso", "222"],
],
)
public_field = table.field_set.get(name="Name")
private_field = table.field_set.get(name="SSN")
data_source = data_fixture.create_builder_local_baserow_list_rows_data_source(
user=user,
page=builder.shared_page,
integration=integration,
table=table,
)

visible_element = data_fixture.create_builder_table_element(
page=public_page,
data_source=data_source,
fields=[
{
"name": "Name",
"type": "text",
"config": {"value": f"get('current_record.{public_field.db_column}')"},
},
],
)
visible_element.property_options.create(
schema_property=private_field.db_column, filterable=False
)

hidden_element = data_fixture.create_builder_table_element(
page=hidden_page,
data_source=data_source,
visibility=Element.VISIBILITY_TYPES.LOGGED_IN,
)
hidden_element.property_options.create(
schema_property=private_field.db_column, filterable=True
)

advanced_filters = {
"filter_type": "AND",
"filters": [
{
"field": private_field.id,
"type": "equal",
"value": "222",
}
],
}
url = reverse(
"api:builder:domains:public_dispatch",
kwargs={"data_source_id": data_source.id},
)

response = api_client.post(
f"{url}?filters={json.dumps(advanced_filters)}",
{"metadata": {"data_source": {"element": hidden_element.id}}},
format="json",
)

assert response.status_code == HTTP_400_BAD_REQUEST
assert response.json() == {
"error": "ERROR_DATA_SOURCE_REFINEMENT_FORBIDDEN",
"detail": "Data source filter, search and/or sort fields error: "
"The data source is not available for the dispatched element.",
}


@pytest.mark.django_db
def test_public_dispatch_data_sources_get_row_no_elements(
api_client, data_fixture, user_source_user_fixture
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@
import pytest

from baserow.contrib.builder.elements.models import Element
from baserow.contrib.builder.elements.operations import ListElementsPageOperationType
from baserow.contrib.builder.elements.operations import (
ListElementsPageOperationType,
ReadElementOperationType,
)
from baserow.contrib.builder.elements.permission_manager import (
ElementVisibilityPermissionManager,
)
Expand Down Expand Up @@ -255,6 +258,82 @@ def test_element_visibility_permission_manager_filter_queryset(
)


@pytest.mark.django_db
def test_element_visibility_permission_manager_read_element_permission(
data_fixture,
stub_user_source_registry,
):
user = data_fixture.create_user(username="Auth user")
builder = data_fixture.create_builder_application(user=user)
builder_to = data_fixture.create_builder_application(workspace=None)
data_fixture.create_builder_custom_domain(builder=builder, published_to=builder_to)
public_page = data_fixture.create_builder_page(builder=builder_to)
logged_in_page = data_fixture.create_builder_page(
builder=builder_to, visibility=Page.VISIBILITY_TYPES.LOGGED_IN
)
public_user_source = data_fixture.create_user_source_with_first_type(
application=builder_to
)
public_user_source_user = UserSourceUser(
public_user_source, None, 1, "US public", "e@ma.il"
)

element_all = data_fixture.create_builder_button_element(
page=public_page, visibility=Element.VISIBILITY_TYPES.ALL
)
element_logged_in = data_fixture.create_builder_button_element(
page=public_page, visibility=Element.VISIBILITY_TYPES.LOGGED_IN
)
element_not_logged = data_fixture.create_builder_button_element(
page=public_page, visibility=Element.VISIBILITY_TYPES.NOT_LOGGED
)
element_on_logged_in_page = data_fixture.create_builder_button_element(
page=logged_in_page, visibility=Element.VISIBILITY_TYPES.ALL
)

checks = [
PermissionCheck(
public_user_source_user, ReadElementOperationType.type, element_all
),
PermissionCheck(
public_user_source_user, ReadElementOperationType.type, element_logged_in
),
PermissionCheck(
public_user_source_user, ReadElementOperationType.type, element_not_logged
),
PermissionCheck(
public_user_source_user,
ReadElementOperationType.type,
element_on_logged_in_page,
),
PermissionCheck(AnonymousUser(), ReadElementOperationType.type, element_all),
PermissionCheck(
AnonymousUser(), ReadElementOperationType.type, element_logged_in
),
PermissionCheck(
AnonymousUser(), ReadElementOperationType.type, element_not_logged
),
PermissionCheck(
AnonymousUser(), ReadElementOperationType.type, element_on_logged_in_page
),
]

result = ElementVisibilityPermissionManager().check_multiple_permissions(
checks, builder.workspace
)

assert [result.get(check, None) for check in checks] == [
True,
True,
False,
True,
True,
False,
True,
False,
]


@pytest.fixture(autouse=True)
def ab_builder_user_page(data_fixture):
"""A fixture to help test Element permissions."""
Expand Down
Empty file.
Loading
Loading