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
14 changes: 12 additions & 2 deletions backend/src/baserow/api/authentication.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from baserow.api.user.errors import ERROR_INVALID_ACCESS_TOKEN
from baserow.core.sentry import setup_user_in_sentry
from baserow.core.telemetry.utils import setup_user_in_baggage_and_spans
from baserow.core.user.cache import get_cached_user, set_cached_user
from baserow.core.user.exceptions import DeactivatedUserException

from .sessions import set_user_session_data_from_request
Expand All @@ -25,14 +26,23 @@ def get_user(self, validated_token):
except KeyError:
raise InvalidToken(_("Token contained no recognizable user identification"))

cached = get_cached_user(user_id)
if cached is not None:
return cached

try:
user = self.user_model.objects.select_related("profile").get(
**{jwt_settings.USER_ID_FIELD: user_id}
# defer the password so we don't cache it
user = (
self.user_model.objects.select_related("profile")
.defer("password")
.get(**{jwt_settings.USER_ID_FIELD: user_id})
)
except self.user_model.DoesNotExist:
raise exceptions.AuthenticationFailed(
_("User not found"), code="user_not_found"
)

set_cached_user(user)
return user

def authenticate(self, request):
Expand Down
24 changes: 23 additions & 1 deletion backend/src/baserow/api/exceptions.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,25 @@
from django.conf import settings
from django.http import JsonResponse

from rest_framework import status
from rest_framework.exceptions import APIException, ValidationError
from rest_framework.exceptions import APIException, Throttled, ValidationError


def api_exception_to_json_response(exc: APIException) -> JsonResponse:
"""
Serialize a DRF ``APIException`` the same way DRF's default
``exception_handler`` does, for use in Django middleware that runs before
the DRF view and therefore outside the handler's reach.
"""

detail = exc.detail
data = detail if isinstance(detail, (list, dict)) else {"detail": str(detail)}
response = JsonResponse(data, status=exc.status_code, safe=False)
if getattr(exc, "auth_header", None):
response["WWW-Authenticate"] = exc.auth_header
if getattr(exc, "wait", None):
response["Retry-After"] = "%d" % exc.wait
return response


class RequestBodyValidationException(APIException):
Expand All @@ -26,6 +44,10 @@ def __init__(self, detail=None, code=None):
self.status_code = 400


class ThrottledAPIException(Throttled):
pass


class InvalidClientSessionIdAPIException(APIException):
status_code = status.HTTP_400_BAD_REQUEST
default_code = "ERROR_INVALID_CLIENT_SESSION_ID"
Expand Down
5 changes: 3 additions & 2 deletions backend/src/baserow/api/two_factor_auth/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,9 @@
TOTPAuthProviderType,
two_factor_auth_type_registry,
)
from baserow.throttling import RateLimitExceededException, rate_limit
from baserow.throttling_types import RateLimit
from baserow.throttling.exceptions import RateLimitExceededException
from baserow.throttling.handler import rate_limit
from baserow.throttling.types import RateLimit


class ConfigureTwoFactorAuthView(APIView):
Expand Down
6 changes: 0 additions & 6 deletions backend/src/baserow/api/workspaces/invitations/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,3 @@
HTTP_400_BAD_REQUEST,
"Your email address does not match with the invitation.",
)
ERROR_MAX_NUMBER_OF_PENDING_WORKSPACE_INVITES_REACHED = (
"ERROR_MAX_NUMBER_OF_PENDING_WORKSPACE_INVITES_REACHED",
HTTP_400_BAD_REQUEST,
"The maximum number of pending invites for this workspace has been reached. "
"Please wait for some invitees to accept the invite or cancel the existing ones.",
)
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ class Meta:
"workspace",
"email",
"permissions",
"message",
"created_on",
)
extra_kwargs = {"id": {"read_only": True}}
Expand All @@ -28,7 +27,7 @@ class CreateWorkspaceInvitationSerializer(serializers.ModelSerializer):

class Meta:
model = WorkspaceInvitation
fields = ("email", "permissions", "message", "base_url")
fields = ("email", "permissions", "base_url")


class UpdateWorkspaceInvitationSerializer(serializers.ModelSerializer):
Expand All @@ -54,13 +53,11 @@ class Meta:
"invited_by",
"workspace",
"email",
"message",
"created_on",
"email_exists",
)
extra_kwargs = {
"id": {"read_only": True},
"message": {"read_only": True},
"created_on": {"read_only": True},
}

Expand Down
8 changes: 1 addition & 7 deletions backend/src/baserow/api/workspaces/invitations/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@
from baserow.api.workspaces.invitations.errors import (
ERROR_GROUP_INVITATION_DOES_NOT_EXIST,
ERROR_GROUP_INVITATION_EMAIL_MISMATCH,
ERROR_MAX_NUMBER_OF_PENDING_WORKSPACE_INVITES_REACHED,
)
from baserow.api.workspaces.serializers import WorkspaceUserWorkspaceSerializer
from baserow.api.workspaces.users.errors import ERROR_GROUP_USER_ALREADY_EXISTS
Expand All @@ -40,7 +39,6 @@
)
from baserow.core.exceptions import (
BaseURLHostnameNotAllowed,
MaxNumberOfPendingWorkspaceInvitesReached,
UserInvalidWorkspacePermissionsError,
UserNotInWorkspace,
WorkspaceDoesNotExist,
Expand Down Expand Up @@ -68,10 +66,9 @@

class WorkspaceInvitationsView(APIView, SortableViewMixin, SearchableViewMixin):
permission_classes = (IsAuthenticated,)
search_fields = ["email", "message"]
search_fields = ["email"]
sort_field_mapping = {
"email": "email",
"message": "message",
}

@extend_schema(
Expand Down Expand Up @@ -157,7 +154,6 @@ def get(self, request, workspace_id, query_params):
"ERROR_USER_NOT_IN_GROUP",
"ERROR_USER_INVALID_GROUP_PERMISSIONS",
"ERROR_REQUEST_BODY_VALIDATION",
"ERROR_MAX_NUMBER_OF_PENDING_WORKSPACE_INVITES_REACHED",
]
),
404: get_error_schema(["ERROR_GROUP_DOES_NOT_EXIST"]),
Expand All @@ -172,7 +168,6 @@ def get(self, request, workspace_id, query_params):
UserInvalidWorkspacePermissionsError: ERROR_USER_INVALID_GROUP_PERMISSIONS,
WorkspaceUserAlreadyExists: ERROR_GROUP_USER_ALREADY_EXISTS,
BaseURLHostnameNotAllowed: ERROR_HOSTNAME_IS_NOT_ALLOWED,
MaxNumberOfPendingWorkspaceInvitesReached: ERROR_MAX_NUMBER_OF_PENDING_WORKSPACE_INVITES_REACHED,
}
)
def post(self, request, data, workspace_id):
Expand All @@ -187,7 +182,6 @@ def post(self, request, data, workspace_id):
data["email"],
data["permissions"],
data["base_url"],
data.get("message", ""),
)

return Response(WorkspaceInvitationSerializer(workspace_invitation).data)
Expand Down
59 changes: 39 additions & 20 deletions backend/src/baserow/config/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
try_int,
)
from baserow.core.telemetry.utils import otel_is_enabled
from baserow.throttling_types import RateLimit
from baserow.throttling.types import RateLimit
from baserow.version import VERSION

# A comma separated list of feature flags used to enable in-progress or not ready
Expand Down Expand Up @@ -283,11 +283,18 @@

DATABASE_READ_REPLICAS.append(db_key)

# Enable connection health checks for all database connections. This makes Django
# verify that a database connection is still usable before each request/task, which
# prevents "connection already closed" errors when connections are dropped by the
# server, a load balancer, or a connection pooler.
# Default 0 = new connection per request; each runs a locale-setting query.
# Increase in WSGI to save those round-trips. In ASGI be careful: async tasks
# open their own connections and persistent ones can exhaust the pool.
BASEROW_CONN_MAX_AGE = int(os.getenv("BASEROW_CONN_MAX_AGE", 0))

# Apply the configured connection reuse timeout consistently to every database.
# Also enable connection health checks by default so Django verifies that a
# connection is still usable before each request/task, which prevents
# "connection already closed" errors when connections are dropped by the server,
# a load balancer, or a connection pooler.
for _db_key in DATABASES:
DATABASES[_db_key]["CONN_MAX_AGE"] = BASEROW_CONN_MAX_AGE
DATABASES[_db_key].setdefault("CONN_HEALTH_CHECKS", True)

DATABASE_ROUTERS = ["baserow.config.db_routers.ReadReplicaRouter"]
Expand Down Expand Up @@ -401,30 +408,42 @@
"DEFAULT_SCHEMA_CLASS": "baserow.api.openapi.AutoSchema",
}

# Limits the number of concurrent requests per user.
# If BASEROW_MAX_CONCURRENT_USER_REQUESTS is not set, then the default value of -1
# will be used which means the throttling is disabled.
# Throttling / rate-limiting — see docs/installation/configuration.md
BASEROW_MAX_CONCURRENT_USER_REQUESTS = int(
os.getenv("BASEROW_MAX_CONCURRENT_USER_REQUESTS", "") or -1
)
BASEROW_CONCURRENT_USER_REQUESTS_THROTTLE_TIMEOUT = int(
os.getenv("BASEROW_CONCURRENT_USER_REQUESTS_THROTTLE_TIMEOUT", 180)
)
BASEROW_THROTTLE_BLACKLIST_TTL_SECONDS = int(
os.getenv("BASEROW_THROTTLE_BLACKLIST_TTL_SECONDS", "") or -1
)
BASEROW_THROTTLE_IP_ENABLED = str_to_bool(os.getenv("BASEROW_THROTTLE_IP_ENABLED", ""))

if BASEROW_MAX_CONCURRENT_USER_REQUESTS > 0:
REST_FRAMEWORK["DEFAULT_THROTTLE_CLASSES"] = [
"baserow.throttling.ConcurrentUserRequestsThrottle",
"baserow.throttling.handler.ConcurrentUserRequestsThrottle",
]

REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"] = {
"concurrent_user_requests": BASEROW_MAX_CONCURRENT_USER_REQUESTS
}

if BASEROW_THROTTLE_BLACKLIST_TTL_SECONDS > 0:
# Insert after SecurityMiddleware so 429s still get security/CORS headers.
_security_idx = MIDDLEWARE.index(
"django.middleware.security.SecurityMiddleware"
)
MIDDLEWARE.insert(
_security_idx + 1,
"baserow.throttling.middleware.ThrottleBlacklistMiddleware",
)

MIDDLEWARE += [
"baserow.middleware.ConcurrentUserRequestsMiddleware",
"baserow.throttling.middleware.ConcurrentUserRequestsMiddleware",
]

# The maximum number of seconds that a request can be throttled for.
BASEROW_CONCURRENT_USER_REQUESTS_THROTTLE_TIMEOUT = int(
os.getenv("BASEROW_CONCURRENT_USER_REQUESTS_THROTTLE_TIMEOUT", 30)
)
BASEROW_CACHE_TTL_SECONDS = int(os.getenv("BASEROW_CACHE_TTL_SECONDS", 0))

PUBLIC_VIEW_AUTHORIZATION_HEADER = "Baserow-View-Authorization"

Expand Down Expand Up @@ -1186,12 +1205,6 @@ def __setitem__(self, key, value):
BASEROW_USER_LOG_ENTRY_RETENTION_DAYS = int(
os.getenv("BASEROW_USER_LOG_ENTRY_RETENTION_DAYS", 61)
)
# The maximum number of pending invites that a workspace can have. If `0` then
# unlimited invites are allowed, which is the default value.
BASEROW_MAX_PENDING_WORKSPACE_INVITES = int(
os.getenv("BASEROW_MAX_PENDING_WORKSPACE_INVITES", 0)
)

BASEROW_IMPORT_EXPORT_RESOURCE_CLEANUP_INTERVAL_MINUTES = int(
os.getenv("BASEROW_IMPORT_EXPORT_RESOURCE_CLEANUP_INTERVAL_MINUTES", 5)
)
Expand Down Expand Up @@ -1255,6 +1268,12 @@ def __setitem__(self, key, value):
"level": BASEROW_BACKEND_DATABASE_LOG_LEVEL,
"propagate": True,
},
# Default to ERROR to suppress 429 spam under heavy throttling.
"django.request": {
"handlers": ["console"],
"level": os.getenv("BASEROW_DJANGO_REQUEST_LOG_LEVEL", "ERROR"),
"propagate": False,
},
},
"root": {
"handlers": ["console"],
Expand Down
11 changes: 11 additions & 0 deletions backend/src/baserow/config/settings/test.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,17 @@ def getenv_for_tests(key: str, default: str = "") -> str:
# Look into tests.baserow.api.test_api_utils.py if you need to test the throttle
REST_FRAMEWORK["DEFAULT_THROTTLE_CLASSES"] = []

# Disable object caches (users, tokens, settings, licenses) so every request
# hits the DB. This ensures query-count assertions remain stable and predictable.
BASEROW_CACHE_TTL_SECONDS = 0

# Tests should not inherit the anonymous IP throttle from any local env.
BASEROW_THROTTLE_IP_ENABLED = False

# Default TTL used by tests that call blacklist_token / blacklist_ip without
# specifying one explicitly. Individual tests can still override this.
BASEROW_THROTTLE_BLACKLIST_TTL_SECONDS = 10


BUILDER_PUBLICLY_USED_PROPERTIES_CACHE_TTL_SECONDS = 10
BUILDER_DISPATCH_ACTION_CACHE_TTL_SECONDS = 300
Expand Down
1 change: 1 addition & 0 deletions backend/src/baserow/contrib/database/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -1181,6 +1181,7 @@ def ready(self):
import baserow.contrib.database.search.receivers # noqa: F403, F401
import baserow.contrib.database.search.tasks # noqa: F401
import baserow.contrib.database.table.receivers # noqa: F401
import baserow.contrib.database.tokens.receivers # noqa: F401
import baserow.contrib.database.views.receivers # noqa: F401
import baserow.contrib.database.views.tasks # noqa: F401
from baserow.contrib.database.fields.models import SelectOption
Expand Down
36 changes: 26 additions & 10 deletions backend/src/baserow/contrib/database/fields/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

from django.contrib.contenttypes.models import ContentType
from django.contrib.postgres.fields import ArrayField
from django.core.exceptions import ObjectDoesNotExist
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
from django.utils.functional import cached_property
Expand Down Expand Up @@ -809,10 +810,15 @@ def save(self, *args, **kwargs):
from baserow.contrib.database.formula.ast.function_defs import BaserowCount
from baserow.contrib.database.formula.ast.tree import BaserowFieldReference

field_reference = BaserowFieldReference(
getattr(self.through_field, "name", ""), None, None
)
self.formula = f"{BaserowCount.type}({field_reference})"
try:
through_field = getattr(self, "through_field")
field_name = getattr(through_field, "name", "")
except ObjectDoesNotExist:
field_name = "'invalid through'"

field_ref = BaserowFieldReference(field_name, None, None)
self.formula = f"{BaserowCount.type}({field_ref})"

super().save(*args, **kwargs)

def __str__(self):
Expand Down Expand Up @@ -852,13 +858,23 @@ def save(self, *args, **kwargs):
formula_function_registry,
)

try:
through_field = getattr(self, "through_field")
through_name = getattr(through_field, "name", "")
except ObjectDoesNotExist:
self.through_field = None
through_name = "'invalid through'"

try:
target_field = getattr(self, "target_field")
target_name = getattr(target_field, "name", "")
except ObjectDoesNotExist:
self.target_field = None
target_name = "'invalid target'"

formula_function = formula_function_registry.get(self.rollup_function)
field_reference = BaserowFieldReference(
getattr(self.through_field, "name", ""),
getattr(self.target_field, "name", ""),
None,
)
self.formula = f"{formula_function.type}({field_reference})"
field_ref = BaserowFieldReference(through_name, target_name, None)
self.formula = f"{formula_function.type}({field_ref})"
super().save(*args, **kwargs)

def __str__(self):
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
# Generated by Django 5.2.12 on 2026-03-17 09:16

from django.db import migrations, models
from django.contrib.postgres.operations import AddIndexConcurrently


class Migration(migrations.Migration):
Expand All @@ -12,8 +11,14 @@ class Migration(migrations.Migration):
]

operations = [
AddIndexConcurrently(
model_name='rowhistory',
index=models.Index(fields=['action_timestamp'], name='database_ro_action__6ea699_idx'),
migrations.RunSQL(
sql='CREATE INDEX CONCURRENTLY IF NOT EXISTS "database_ro_action__6ea699_idx" ON "database_rowhistory" ("action_timestamp")',
reverse_sql='DROP INDEX IF EXISTS "database_ro_action__6ea699_idx"',
state_operations=[
migrations.AddIndex(
model_name='rowhistory',
index=models.Index(fields=['action_timestamp'], name='database_ro_action__6ea699_idx'),
),
],
),
]
Loading
Loading