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
5 changes: 5 additions & 0 deletions backend/src/baserow/config/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -842,6 +842,11 @@ def __setitem__(self, key, value):
EXTRA_PUBLIC_WEB_FRONTEND_HOSTNAMES.append(hostname)

FROM_EMAIL = os.getenv("FROM_EMAIL", "no-reply@localhost")
# Align Django's built-in senders with the Baserow configured sender so that any
# code relying on Django defaults (e.g. the `Send email` action using instance
# SMTP) uses the configured FROM_EMAIL instead of `webmaster@localhost`.
DEFAULT_FROM_EMAIL = FROM_EMAIL
SERVER_EMAIL = FROM_EMAIL
RESET_PASSWORD_TOKEN_MAX_AGE = 60 * 60 * 2 # 2 hours
CHANGE_EMAIL_TOKEN_MAX_AGE = 60 * 60 * 12 # 12 hours

Expand Down
12 changes: 12 additions & 0 deletions backend/src/baserow/contrib/builder/api/data_sources/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,12 +61,16 @@
DataSourceRefinementForbidden,
)
from baserow.contrib.builder.data_sources.handler import DataSourceHandler
from baserow.contrib.builder.data_sources.operations import (
DispatchDataSourceOperationType,
)
from baserow.contrib.builder.data_sources.service import DataSourceService
from baserow.contrib.builder.elements.exceptions import ElementDoesNotExist
from baserow.contrib.builder.pages.exceptions import PageDoesNotExist
from baserow.contrib.builder.pages.handler import PageHandler
from baserow.contrib.builder.pages.models import Page
from baserow.core.exceptions import PermissionException
from baserow.core.handler import CoreHandler
from baserow.core.services.exceptions import (
DoesNotExist,
InvalidContextContentDispatchException,
Expand Down Expand Up @@ -650,6 +654,14 @@ class GetRecordNamesView(APIView):
def get(self, request, data_source_id: int):
# Find the data source corresponding to the given id
data_source = DataSourceHandler().get_data_source(data_source_id)

CoreHandler().check_permissions(
request.user,
DispatchDataSourceOperationType.type,
workspace=data_source.page.builder.workspace,
context=data_source,
)

service = data_source.service.specific
service_type = service.get_type()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,7 @@ def dispatch_data_sources(
CoreHandler().check_multiple_permissions(
checks,
workspace=data_sources[0].page.builder.workspace,
raise_exception=True,
)

results = self.handler.dispatch_data_sources(data_sources, dispatch_context)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -870,7 +870,7 @@ def dispatch_data(
using_instance_smtp = self._should_use_instance_smtp(service)

if using_instance_smtp:
from_email = settings.DEFAULT_FROM_EMAIL
from_email = settings.FROM_EMAIL
connection = get_connection(
backend=settings.CELERY_EMAIL_BACKEND,
timeout=SMTP_EMAIL_TIMEOUT,
Expand Down
32 changes: 23 additions & 9 deletions backend/src/baserow/core/handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,7 @@ def check_multiple_permissions(
workspace: Optional[Workspace] = None,
include_trash: bool = False,
return_permissions_exceptions: bool = False,
raise_exception: bool = False,
) -> Dict[PermissionCheck, Union[bool, PermissionException]]:
"""
Given a list of permission to check, returns True for each check for which the
Expand All @@ -236,18 +237,27 @@ def check_multiple_permissions(
a definitive answer.

If None of the permission managers replied with a final answer for a check,
the operation is denied by default for this check.
the check is recorded as denied in the returned mapping.

Use check_permissions(), or set raise_exception=True, if you want a denied
permission to raise a PermissionException.

:param checks: The list of checks to do. Each check is a triplet of
(actor, permission_name, scope).
:param workspace: The optional workspace in which the operations take place.
:param include_trash: If true, then also checks if the given workspace has been
trashed instead of raising a DoesNotExist exception.
:param return_permissions_exceptions: Raise an exception when the permission is
disallowed when `True`. Return `False` instead when `False`.
`False` by default.
:param return_permissions_exceptions: When True, a denied check is recorded
in the returned mapping as the corresponding PermissionException instance.
Otherwise, a denied check is recorded as False.
:param raise_exception: When True, the first PermissionException encountered
among the denied checks is raised instead of being returned in the mapping.
:raises PermissionException: If `raise_exception` is True and at least one
check is denied.
:return: A dictionary with one entry for each check of the parameter as key and
whether the operation is allowed or not as value.
whether the operation is allowed (True) or denied (False, or the
PermissionException instance when return_permissions_exceptions=True)
as value.
"""

result = {}
Expand All @@ -274,9 +284,8 @@ def check_multiple_permissions(

for check, check_result in manager_result.items():
if check_result is not None:
if (
isinstance(check_result, PermissionException)
and not return_permissions_exceptions
if isinstance(check_result, PermissionException) and not (
return_permissions_exceptions or raise_exception
):
result[check] = False
else:
Expand All @@ -288,10 +297,15 @@ def check_multiple_permissions(
for undetermined_check in undetermined_checks:
result[undetermined_check] = (
PermissionDenied(undetermined_check.actor)
if return_permissions_exceptions
if (return_permissions_exceptions or raise_exception)
else False
)

if raise_exception:
for check_result in result.values():
if isinstance(check_result, PermissionException):
raise check_result

return result

def check_permission_for_multiple_actors(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2054,6 +2054,64 @@ def test_get_record_names(api_client, data_fixture):
)


@pytest.mark.django_db
def test_dispatch_data_source_anonymous_unpublished_builder_is_denied(
api_client, data_fixture
):
user = data_fixture.create_user()
table, fields, rows = data_fixture.build_table(
user=user,
columns=[("Name", "text")],
rows=[["Ada"], ["Alan"]],
)
builder = data_fixture.create_builder_application(user=user)
integration = data_fixture.create_local_baserow_integration(
user=user, application=builder
)
page = data_fixture.create_builder_page(user=user, builder=builder)
data_source = data_fixture.create_builder_local_baserow_list_rows_data_source(
user=user, page=page, integration=integration, table=table
)

url = reverse(
"api:builder:data_source:dispatch", kwargs={"data_source_id": data_source.id}
)
# No authentication header: anonymous request.
response = api_client.post(url, {}, format="json")

assert response.status_code == HTTP_401_UNAUTHORIZED
assert response.json()["error"] == "PERMISSION_DENIED"


@pytest.mark.django_db
def test_get_record_names_anonymous_unpublished_builder_is_denied(
api_client, data_fixture
):
user = data_fixture.create_user()
builder = data_fixture.create_builder_application(user=user)
integration = data_fixture.create_local_baserow_integration(
user=user, application=builder
)
table = data_fixture.create_database_table(user=user)
data_fixture.create_text_field(name="Name", table=table, primary=True)
model = table.get_model(attribute_names=True)
row = model.objects.create(name="Ada")

data_source = data_fixture.create_builder_local_baserow_list_rows_data_source(
user=user, table=table, integration=integration
)

base_url = reverse(
"api:builder:data_source:record-names",
kwargs={"data_source_id": data_source.id},
)
# No authentication header: anonymous request.
response = api_client.get(f"{base_url}?record_ids={row.id}", format="json")

assert response.status_code == HTTP_401_UNAUTHORIZED
assert response.json()["error"] == "PERMISSION_DENIED"


@pytest.mark.django_db
def test_dispatch_data_sources_list_rows_with_elements(
api_client, data_fixture, data_source_fixture
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -525,7 +525,7 @@ def test_dispatch_data_sources_page_visibility_all_returns_elements(
endpoint should return elements.
"""

page = data_source_fixture["page"]
page = data_source_fixture["public_page"]
page.visibility = Page.VISIBILITY_TYPES.ALL
page.save()

Expand Down Expand Up @@ -654,7 +654,7 @@ def test_dispatch_data_sources_page_visibility_logged_in_returns_no_elements_for
endpoint should return zero elements.
"""

page = data_source_fixture["page"]
page = data_source_fixture["public_page"]
page.visibility = Page.VISIBILITY_TYPES.LOGGED_IN
page.save()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ def test_send_smtp_email_with_integration_ignores_global_celery_email_backend(
CELERY_EMAIL_BACKEND="django.core.mail.backends.smtp.EmailBackend",
EMAIL_HOST="instance.smtp.example.com",
EMAIL_PORT=2525,
DEFAULT_FROM_EMAIL="instance@example.com",
FROM_EMAIL="My Database <no-reply@example.com>",
)
def test_send_smtp_email_uses_instance_smtp_settings(data_fixture):
service = data_fixture.create_core_smtp_email_service(
Expand All @@ -191,7 +191,7 @@ def test_send_smtp_email_uses_instance_smtp_settings(data_fixture):
mock_email.assert_called_once_with(
"Test Subject",
"Hello, this is a test email!",
"instance@example.com",
"My Database <no-reply@example.com>",
["recipient@example.com"],
bcc=[],
cc=[],
Expand All @@ -200,6 +200,46 @@ def test_send_smtp_email_uses_instance_smtp_settings(data_fixture):
assert result.data == {"success": True}


@pytest.mark.django_db
@override_settings(
INTEGRATION_ALLOW_SMTP_SERVICE_TO_USE_INSTANCE_SETTINGS=True,
CELERY_EMAIL_BACKEND="django.core.mail.backends.smtp.EmailBackend",
EMAIL_HOST="instance.smtp.example.com",
EMAIL_PORT=2525,
FROM_EMAIL="My Database <no-reply@example.com>",
DEFAULT_FROM_EMAIL="webmaster@localhost",
)
def test_send_smtp_email_instance_smtp_uses_from_email_not_default(data_fixture):
# Regression test: the instance SMTP path must use the configured
# FROM_EMAIL and not fall back to Django's DEFAULT_FROM_EMAIL, which
# defaults to `webmaster@localhost`.
service = data_fixture.create_core_smtp_email_service(
integration=None,
use_instance_smtp_settings=True,
from_email="''",
from_name="''",
to_emails="'recipient@example.com'",
subject="'Test Subject'",
body="'Hello, this is a test email!'",
body_type="plain",
)

service_type = service.get_type()
dispatch_context = FakeDispatchContext()

with mock_django_email() as (mock_email, mock_connection):
service_type.dispatch(service, dispatch_context)
mock_email.assert_called_once_with(
"Test Subject",
"Hello, this is a test email!",
"My Database <no-reply@example.com>",
["recipient@example.com"],
bcc=[],
cc=[],
connection=mock_connection.return_value,
)


@pytest.mark.django_db
def test_send_smtp_email_multiple_to_cc_and_bcc(data_fixture):
smtp_integration = data_fixture.create_smtp_integration(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"type": "bug",
"message": "Fix the Send email action using instance SMTP sending from webmaster@localhost instead of the configured FROM_EMAIL.",
"issue_origin": "github",
"issue_number": 5640,
"domain": "integration",
"bullet_points": [],
"created_at": "2026-07-08"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"type": "bug",
"message": "Improved anonymous builder dispatch.",
"issue_origin": "github",
"issue_number": null,
"domain": "builder",
"bullet_points": [],
"created_at": "2026-07-07"
}
Loading