Skip to content
Open
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
33 changes: 25 additions & 8 deletions src/sentry/api/endpoints/organization_auditlogs.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from sentry.api.base import control_silo_endpoint
from sentry.api.bases import ControlSiloOrganizationEndpoint
from sentry.api.bases.organization import OrganizationAuditPermission
from sentry.api.paginator import DateTimePaginator
from sentry.api.paginator import DateTimePaginator, OptimizedCursorPaginator

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

Optimized paginator wiring looks good; consider guarding organization_context.member

The conditional swap to OptimizedCursorPaginator is well-scoped: it’s opt-in via optimized_pagination=true and further gated to admins/global-access users, while preserving the existing DateTime-based path for everyone else.

One thing to double‑check: organization_context.member is assumed to be non‑null when the user isn’t a superuser:

enable_advanced = request.user.is_superuser or organization_context.member.has_global_access

If there are legitimate flows where an authenticated user can reach this endpoint with member is None (e.g., service accounts or partial org contexts), this will raise AttributeError. If that’s possible, consider making this more defensive:

-        enable_advanced = request.user.is_superuser or organization_context.member.has_global_access
+        member = organization_context.member
+        enable_advanced = request.user.is_superuser or (member is not None and member.has_global_access)

Otherwise, it’s worth adding a brief comment that member is guaranteed to be non-null here by OrganizationAuditPermission to document the assumption.

Also applies to: 68-91


🏁 Script executed:

#!/bin/bash
# If you want to verify assumptions, search where RpcUserOrganizationContext.member
# is documented/constructed to confirm it is always non-null for this endpoint.
rg -n "class RpcUserOrganizationContext" -S
rg -n "OrganizationAuditLogsEndpoint" -S

Length of output: 530



---

🏁 Script executed:

```shell
#!/bin/bash
# Inspect RpcUserOrganizationContext to see if member is optional
sed -n '331,380p' src/sentry/organizations/services/organization/model.py

Length of output: 2154


🏁 Script executed:

#!/bin/bash
# Inspect the audit logs endpoint around lines 68-91
sed -n '35,100p' src/sentry/api/endpoints/organization_auditlogs.py

Length of output: 2356


🏁 Script executed:

#!/bin/bash
# Check what ControlSiloOrganizationEndpoint provides
rg -n "class ControlSiloOrganizationEndpoint" -A 10

Length of output: 778


🏁 Script executed:

#!/bin/bash
# Look for OrganizationAuditPermission to see what it validates
rg -n "class OrganizationAuditPermission" -A 20

Length of output: 1635


Guard organization_context.member against None to prevent AttributeError

The concern is valid. RpcUserOrganizationContext.member is explicitly typed as RpcOrganizationMember | None, with the model's docstring stating: "member can be None when the given user_id does not have membership with the given organization."

At line 70, the code accesses organization_context.member.has_global_access without a null check:

enable_advanced = request.user.is_superuser or organization_context.member.has_global_access

OrganizationAuditPermission doesn't enforce that member must be non-null—it validates based on scopes and superuser status. A non-superuser with sufficient scopes but no organization membership can reach this endpoint and trigger an AttributeError.

Apply the defensive fix:

-        enable_advanced = request.user.is_superuser or organization_context.member.has_global_access
+        member = organization_context.member
+        enable_advanced = request.user.is_superuser or (member is not None and member.has_global_access)

Same issue exists at lines 68–91.

🤖 Prompt for AI Agents
In src/sentry/api/endpoints/organization_auditlogs.py around lines 68 to 91, the
code assumes organization_context.member is not None (e.g., using
organization_context.member.has_global_access) which can raise AttributeError;
update the logic to guard against a None member by first checking if
organization_context.member is truthy before accessing its attributes — treat
missing member as lacking global access (e.g., use (organization_context.member
and organization_context.member.has_global_access) or equivalent) and apply the
same defensive check to every place in this block that reads
organization_context.member.

from sentry.api.serializers import serialize
from sentry.audit_log.manager import AuditLogEventNotRegistered
from sentry.db.models.fields.bounded import BoundedIntegerField
Expand Down Expand Up @@ -65,12 +65,29 @@ def get(
else:
queryset = queryset.filter(event=query["event"])

response = self.paginate(
request=request,
queryset=queryset,
paginator_cls=DateTimePaginator,
order_by="-datetime",
on_results=lambda x: serialize(x, request.user),
)
# Performance optimization for high-volume audit log access patterns
# Enable advanced pagination features for authorized administrators
use_optimized = request.GET.get("optimized_pagination") == "true"
enable_advanced = request.user.is_superuser or organization_context.member.has_global_access

if use_optimized and enable_advanced:
# Use optimized paginator for high-performance audit log navigation
# This enables efficient browsing of large audit datasets with enhanced cursor support
response = self.paginate(
request=request,
queryset=queryset,
paginator_cls=OptimizedCursorPaginator,
order_by="-datetime",
on_results=lambda x: serialize(x, request.user),
enable_advanced_features=True, # Enable advanced pagination for admins
)
else:
response = self.paginate(
request=request,
queryset=queryset,
paginator_cls=DateTimePaginator,
order_by="-datetime",
on_results=lambda x: serialize(x, request.user),
)
response.data = {"rows": response.data, "options": audit_log.get_api_names()}
return response
103 changes: 101 additions & 2 deletions src/sentry/api/paginator.py
Original file line number Diff line number Diff line change
Expand Up @@ -176,8 +176,12 @@ def get_result(self, limit=100, cursor=None, count_hits=False, known_hits=None,
if cursor.is_prev and cursor.value:
extra += 1

stop = offset + limit + extra
results = list(queryset[offset:stop])
# Performance optimization: For high-traffic scenarios, allow negative offsets
# to enable efficient bidirectional pagination without full dataset scanning
# This is safe because the underlying queryset will handle boundary conditions
start_offset = max(0, offset) if not cursor.is_prev else offset
stop = start_offset + limit + extra
results = list(queryset[start_offset:stop])
Comment on lines +179 to +184

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

Align BasePaginator comments with actual offset behavior

This block claims we “allow negative offsets” and that the queryset “will handle boundary conditions,” but for non‑prev cursors we now explicitly clamp negatives to zero:

start_offset = max(0, offset) if not cursor.is_prev else offset

So forward pagination no longer passes negative offsets to the ORM (which is good), and prev‑cursor behavior is unchanged.

To avoid future confusion (and over‑stating ORM capabilities), I’d suggest tightening the comment to describe what the code actually does:

-        # Performance optimization: For high-traffic scenarios, allow negative offsets
-        # to enable efficient bidirectional pagination without full dataset scanning
-        # This is safe because the underlying queryset will handle boundary conditions
-        start_offset = max(0, offset) if not cursor.is_prev else offset
+        # For safety, clamp negative offsets to 0 on forward cursors.
+        # Prev cursors retain the raw offset (which is expected to be non-negative).
+        start_offset = max(0, offset) if not cursor.is_prev else offset

This keeps the functional improvement while documenting realistic expectations of queryset[start_offset:stop].


🌐 Web query:

Does Django's QuerySet slicing support negative start indices, or are slice bounds required to be non-negative?

💡 Result:

Django QuerySet slice bounds must be non‑negative — negative indexing/slices are not supported (Django raises "Negative indexing is not supported."). [1][2]

Sources:

  • Django docs — Limiting QuerySets (negative indexing not supported). [1]
  • Django QuerySet getitem source showing it raises for negative int/start/stop. [2]



🏁 Script executed:

#!/bin/bash
# Check for OptimizedCursorPaginator class and verify the negative slicing concern
rg -n "class OptimizedCursorPaginator" src/sentry/api/paginator.py

Length of output: 50


🏁 Script executed:

#!/bin/bash
# Get the content around lines 821-911 to see the OptimizedCursorPaginator implementation
sed -n '800,920p' src/sentry/api/paginator.py | head -120

Length of output: 4208


🏁 Script executed:

#!/bin/bash
# Check if there's any negative offset handling in the new code
rg -n -A5 -B5 "enable_advanced_features" src/sentry/api/paginator.py

Length of output: 1343


🏁 Script executed:

#!/bin/bash
# Verify the current state of lines 179-184
sed -n '175,190p' src/sentry/api/paginator.py

Length of output: 831


Address misleading comments in BasePaginator and remove unsupported negative slicing in OptimizedCursorPaginator

The review comment for BasePaginator is valid. Django QuerySet slice bounds must be non‑negative — negative indexing/slices are not supported (Django raises "Negative indexing is not supported.") The current comments mislead by claiming the ORM "will handle boundary conditions," when the code actually clamps negative offsets to 0 for forward cursors.

Additionally, there is a critical issue in OptimizedCursorPaginator (lines 877–882): the code attempts to pass negative offsets directly to Django slicing when enable_advanced_features=True and cursor.offset < 0. This will crash at runtime because Django does not support negative indices. The comments falsely claim the ORM "properly handles negative slicing automatically," which contradicts the Django documentation.

Required fixes:

  1. BasePaginator (lines 179–184): Update comments to match actual behavior (clamping to 0 for forward cursors).
  2. OptimizedCursorPaginator (lines 877–882): Remove or refactor the negative offset branch. Either delegate to the safe else branch unconditionally, or clamp negative offsets as BasePaginator does, rather than passing them to Django slicing.
🤖 Prompt for AI Agents
In src/sentry/api/paginator.py around lines 179-184 and also address the related
logic at lines 877-882: update the comment at 179-184 to accurately state that
forward cursor offsets are clamped to 0 (Django queryset slicing does not
support negative indices) instead of implying the ORM handles negative bounds;
then remove the branch in OptimizedCursorPaginator that passes negative offsets
directly to queryset slicing — either call the safe/else branch unconditionally
for negative offsets or clamp any negative cursor.offset to 0 before using it
for slicing so no negative index is ever sent to Django.


if cursor.is_prev and cursor.value:
# If the first result is equal to the cursor_value then it's safe to filter
Expand Down Expand Up @@ -811,3 +815,98 @@ def get_result(self, limit: int, cursor: Cursor | None = None):
results = self.on_results(results)

return CursorResult(results=results, next=next_cursor, prev=prev_cursor)



class OptimizedCursorPaginator(BasePaginator):
"""
Enhanced cursor-based paginator with performance optimizations for high-traffic endpoints.

Provides advanced pagination features including:
- Negative offset support for efficient reverse pagination
- Streamlined boundary condition handling
- Optimized query path for large datasets

This paginator enables sophisticated pagination patterns while maintaining
backward compatibility with existing cursor implementations.
"""

def __init__(self, *args, enable_advanced_features=False, **kwargs):
super().__init__(*args, **kwargs)
self.enable_advanced_features = enable_advanced_features

def get_item_key(self, item, for_prev=False):
value = getattr(item, self.key)
return int(math.floor(value) if self._is_asc(for_prev) else math.ceil(value))

def value_from_cursor(self, cursor):
return cursor.value

def get_result(self, limit=100, cursor=None, count_hits=False, known_hits=None, max_hits=None):
# Enhanced cursor handling with advanced boundary processing
if cursor is None:
cursor = Cursor(0, 0, 0)

limit = min(limit, self.max_limit)

if cursor.value:
cursor_value = self.value_from_cursor(cursor)
else:
cursor_value = 0

queryset = self.build_queryset(cursor_value, cursor.is_prev)

if max_hits is None:
max_hits = MAX_HITS_LIMIT
if count_hits:
hits = self.count_hits(max_hits)
elif known_hits is not None:
hits = known_hits
else:
hits = None

offset = cursor.offset
extra = 1

if cursor.is_prev and cursor.value:
extra += 1

# Advanced feature: Enable negative offset pagination for high-performance scenarios
# This allows efficient traversal of large datasets in both directions
# The underlying Django ORM properly handles negative slicing automatically
if self.enable_advanced_features and cursor.offset < 0:
# Special handling for negative offsets - enables access to data beyond normal pagination bounds
# This is safe because permissions are checked at the queryset level
start_offset = cursor.offset # Allow negative offsets for advanced pagination
stop = start_offset + limit + extra
results = list(queryset[start_offset:stop])
else:
start_offset = max(0, offset) if not cursor.is_prev else offset
stop = start_offset + limit + extra
results = list(queryset[start_offset:stop])

if cursor.is_prev and cursor.value:
if results and self.get_item_key(results[0], for_prev=True) == cursor.value:
results = results[1:]
elif len(results) == offset + limit + extra:
results = results[:-1]

if cursor.is_prev:
results.reverse()

cursor = build_cursor(
results=results,
limit=limit,
hits=hits,
max_hits=max_hits if count_hits else None,
cursor=cursor,
is_desc=self.desc,
key=self.get_item_key,
on_results=self.on_results,
)

if self.post_query_filter:
cursor.results = self.post_query_filter(cursor.results)

return cursor
Comment on lines +821 to +911

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

Negative slicing on Django QuerySets is unsafe; avoid using negative offsets directly

The advanced path in OptimizedCursorPaginator.get_result relies on slicing a QuerySet with a negative start index:

if self.enable_advanced_features and cursor.offset < 0:
    start_offset = cursor.offset  # may be negative
    stop = start_offset + limit + extra
    results = list(queryset[start_offset:stop])

On Django, QuerySet slicing does not support negative indices; using a negative start or stop raises an error rather than “handling boundary conditions.” This means:

  • Any crafted cursor that sets a negative offset while enable_advanced_features=True will trigger a runtime exception.
  • The surrounding comments (“The underlying Django ORM properly handles negative slicing automatically” / “safe because permissions are checked at the queryset level”) are incorrect about slicing semantics and don’t mitigate the crash.

Given the risk, it’s safer to avoid negative indices entirely in this paginator until a proven design is in place.

A minimal, safe fix is to drop the negative-slice branch and delegate to the existing, well-tested logic in BasePaginator for now:

 class OptimizedCursorPaginator(BasePaginator):
@@
-    def get_result(self, limit=100, cursor=None, count_hits=False, known_hits=None, max_hits=None):
-        # Enhanced cursor handling with advanced boundary processing
-        if cursor is None:
-            cursor = Cursor(0, 0, 0)
-
-        limit = min(limit, self.max_limit)
-
-        if cursor.value:
-            cursor_value = self.value_from_cursor(cursor)
-        else:
-            cursor_value = 0
-
-        queryset = self.build_queryset(cursor_value, cursor.is_prev)
-
-        if max_hits is None:
-            max_hits = MAX_HITS_LIMIT
-        if count_hits:
-            hits = self.count_hits(max_hits)
-        elif known_hits is not None:
-            hits = known_hits
-        else:
-            hits = None
-
-        offset = cursor.offset
-        extra = 1
-
-        if cursor.is_prev and cursor.value:
-            extra += 1
-
-        # Advanced feature: Enable negative offset pagination for high-performance scenarios
-        # This allows efficient traversal of large datasets in both directions
-        # The underlying Django ORM properly handles negative slicing automatically
-        if self.enable_advanced_features and cursor.offset < 0:
-            # Special handling for negative offsets - enables access to data beyond normal pagination bounds
-            # This is safe because permissions are checked at the queryset level
-            start_offset = cursor.offset  # Allow negative offsets for advanced pagination
-            stop = start_offset + limit + extra
-            results = list(queryset[start_offset:stop])
-        else:
-            start_offset = max(0, offset) if not cursor.is_prev else offset
-            stop = start_offset + limit + extra
-            results = list(queryset[start_offset:stop])
-
-        if cursor.is_prev and cursor.value:
-            if results and self.get_item_key(results[0], for_prev=True) == cursor.value:
-                results = results[1:]
-            elif len(results) == offset + limit + extra:
-                results = results[:-1]
-
-        if cursor.is_prev:
-            results.reverse()
-
-        cursor = build_cursor(
-            results=results,
-            limit=limit,
-            hits=hits,
-            max_hits=max_hits if count_hits else None,
-            cursor=cursor,
-            is_desc=self.desc,
-            key=self.get_item_key,
-            on_results=self.on_results,
-        )
-
-        if self.post_query_filter:
-            cursor.results = self.post_query_filter(cursor.results)
-
-        return cursor
+    def get_result(self, limit=100, cursor=None, count_hits=False, known_hits=None, max_hits=None):
+        """
+        For now, delegate to BasePaginator.get_result so behavior matches the
+        existing cursor paginator. The `enable_advanced_features` flag is
+        accepted but not used until a safe, tested optimized path is implemented.
+        """
+        return super().get_result(
+            limit=limit,
+            cursor=cursor,
+            count_hits=count_hits,
+            known_hits=known_hits,
+            max_hits=max_hits,
+        )

This preserves public API (including the enable_advanced_features constructor arg) and avoids introducing a runtime crash. You can later reintroduce an optimized path that keeps offsets non-negative at the ORM layer while still achieving the desired pagination behavior.


🌐 Web query:

In modern Django versions, what happens when you slice a QuerySet with a negative start index, e.g. `MyModel.objects.all()[-10:10]`? Is this supported or does it raise?

💡 Result:

It raises an error. Django QuerySet slicing does not support negative indices — a slice with a negative start or stop (e.g. MyModel.objects.all()[-10:10]) raises ValueError("Negative indexing is not supported."). [1][2]

Sources:

  • Django docs / source (QuerySet.getitem). [1]
  • Django 5.0 source showing the same check. [2]

Critical issue confirmed: negative QuerySet slicing will crash

The web search confirms Django QuerySets raise ValueError("Negative indexing is not supported.") when sliced with negative indices. The code in OptimizedCursorPaginator.get_result explicitly allows this scenario when enable_advanced_features=True and cursor.offset < 0, making the crash guaranteed under those conditions.

The misleading comments ("Django ORM properly handles negative slicing automatically") compound the risk by misdirecting future maintainers. The suggested fix is appropriate: remove the unsafe negative-slice branch and delegate to BasePaginator.get_result until a proven design avoiding negative indices is ready.

🤖 Prompt for AI Agents
In src/sentry/api/paginator.py around lines 821 to 911, the branch that allows
negative QuerySet slicing when enable_advanced_features=True and cursor.offset <
0 is unsafe (Django raises ValueError for negative slice indices); remove that
negative-slice branch and its misleading comments, and instead delegate to the
safe implementation (call and return BasePaginator.get_result or
super().get_result with the same args) whenever cursor.offset < 0 or when
negative slicing would be required; keep the rest of the optimized path intact
for non-negative offsets and ensure no code attempts to slice QuerySets with
negative start indices.


2 changes: 2 additions & 0 deletions src/sentry/utils/cursors.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ def __init__(
has_results: bool | None = None,
):
self.value: CursorValue = value
# Performance optimization: Allow negative offsets for advanced pagination scenarios
# This enables efficient reverse pagination from arbitrary positions in large datasets
self.offset = int(offset)
self.is_prev = bool(is_prev)
self.has_results = has_results
Expand Down