Skip to content

Allow export retrieve/content across teammates in project#57541

Open
Twixes wants to merge 1 commit intomasterfrom
posthog-code/exports-shared-project-access
Open

Allow export retrieve/content across teammates in project#57541
Twixes wants to merge 1 commit intomasterfrom
posthog-code/exports-shared-project-access

Conversation

@Twixes
Copy link
Copy Markdown
Member

@Twixes Twixes commented May 4, 2026

Problem

Session-problem rasterized replay and sharing expected exported assets referenced by UUID to load for anyone with project (team) access, but /api/projects/{}/exports/ detail/queryset filtering prevented teammates from retrieving another user’s export even when RBAC permitted the underlying dashboard/insight/session.

The [PostHog Code companion PR](PostHog/code#2011) now loads inbox replay video only via exported_asset_id instead of discovering exports via list + session_recording_id. This backend change is expected to merge first.

Changes

  • List: unchanged intent— created_by=request.user exports only (plus existing list filters moved under the list branch): session_recording_id, context_path, export_format.
  • Retrieve / /content/: queryset is scoped to team/project, not creator; safely_get_object still applies dashboard, insight, and session-recording viewer RBAC.
  • Removed the special-case list exception for shared “system session” exports (teammates no longer enumerate others’ system exports via list).
  • Tests updated for teammate access to another user’s dashboard PNG export via retrieve + content, session-video content sharing, RBAC blocking when recording access is denied; list assertions updated for own-export-only semantics.
  • Dropped noisy Temporal logger.info block before emitting session-problem signals (debug-style payload dump).

How did you test this code?

Ran local pre-commit hooks on commit (hogli Python lint/format + uv run ty check). No ClickHouse Postgres test suite run in this agent session (pytest …/test_exports)—reviewer should rely on posthog/api/test/test_exports.py in CI once available.

👉 Stay up-to-date with PostHog coding conventions for smoother review.

Publish to changelog?

no

Docs update

N/A unless we document export sharing semantics externally.

🤖 Agent context

Co-authored tooling: branch cut from origin/master; companion linked to PostHog/code#2011.

- Detail actions scoped to team; list stays created_by-only
- Session listing filter narrowed to user's exports only
- Tests for teammate png/video access and RBAC
- Drop verbose session-problem pre-emission workflow log
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented May 4, 2026

Comments Outside Diff (1)

  1. posthog/api/exports.py, line 419-435 (link)

    P2 RBAC bypass when referenced SessionRecording has been deleted

    In safely_get_object, if export_context contains a session_recording_id but the corresponding SessionRecording row no longer exists (e.g. the recording was deleted after the export was created), resource stays None and the check_access_level_for_object guard is skipped entirely. Before this PR, only the original creator could reach that path; now any project member can retrieve or stream the export content for a deleted recording without any RBAC check. The export bytes themselves may still contain sensitive replay data.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: posthog/api/exports.py
    Line: 419-435
    
    Comment:
    **RBAC bypass when referenced `SessionRecording` has been deleted**
    
    In `safely_get_object`, if `export_context` contains a `session_recording_id` but the corresponding `SessionRecording` row no longer exists (e.g. the recording was deleted after the export was created), `resource` stays `None` and the `check_access_level_for_object` guard is skipped entirely. Before this PR, only the original creator could reach that path; now any project member can retrieve or stream the export content for a deleted recording without any RBAC check. The export bytes themselves may still contain sensitive replay data.
    
    How can I resolve this? If you propose a fix, please make it concise.
Prompt To Fix All With AI
Fix the following 3 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 3
posthog/api/test/test_exports.py:710-729
**Tests only cover `/content/`, missing parameterised `retrieve` case**

Both `test_teammate_can_access_system_session_video_export_content` and `test_teammate_blocked_from_system_video_export_when_no_session_access` test only the `/content/` endpoint. The existing pattern (used in `test_teammate_can_access_other_users_export_by_id`, `test_cannot_access_export_after_losing_resource_access`, and `test_cannot_access_session_recording_export_after_losing_access`) parameterises over both `retrieve` and `content`. Because `safely_get_object` is shared by both actions, the same RBAC path is exercised either way — but an omitted `retrieve` case means the test suite doesn't catch a regression where, say, the retrieve serializer bypasses the queryset lookup differently.

Consider parameterising these two new tests the same way the neighbouring tests are written.

### Issue 2 of 3
posthog/api/exports.py:419-435
**RBAC bypass when referenced `SessionRecording` has been deleted**

In `safely_get_object`, if `export_context` contains a `session_recording_id` but the corresponding `SessionRecording` row no longer exists (e.g. the recording was deleted after the export was created), `resource` stays `None` and the `check_access_level_for_object` guard is skipped entirely. Before this PR, only the original creator could reach that path; now any project member can retrieve or stream the export content for a deleted recording without any RBAC check. The export bytes themselves may still contain sensitive replay data.

### Issue 3 of 3
posthog/api/exports.py:390-417
**Superfluous final `return queryset`**

The early `return queryset` inside the `if self.action == "list":` block already returns for list actions. The trailing bare `return queryset` is only reached for non-list actions where the queryset is never mutated. Per the project's simplicity rules this is a superfluous part — collapsing to a single trailing return removes the duplication:

```suggestion
    def safely_get_queryset(self, queryset):
        """
        List shows only exports you created (quota + history are per user).

        Retrieve / content fetch any export in this project by id; safely_get_object still
        enforces dashboard, insight, or session recording viewer access when applicable.
        """
        if self.action == "list":
            queryset = queryset.filter(created_by=self.request.user)

            session_recording_filter = self.request.query_params.get("session_recording_id")
            if session_recording_filter:
                queryset = queryset.filter(
                    export_context__session_recording_id=session_recording_filter,
                )

            context_path_filter = self.request.query_params.get("context_path")
            if context_path_filter:
                queryset = queryset.filter(export_context__path__icontains=context_path_filter)

            # Add export format filter
            export_format_filter = self.request.query_params.get("export_format")
            if export_format_filter and export_format_filter in ExportedAsset.get_supported_format_values():
                queryset = queryset.filter(export_format=export_format_filter)

        return queryset
```

Reviews (1): Last reviewed commit: "fix(exports): Retrieve and content URLs ..." | Re-trigger Greptile

Comment on lines +710 to +729
def test_teammate_can_access_system_session_video_export_content(self) -> None:
from posthog.session_recordings.models.session_recording import SessionRecording

owner = self.user
teammate = User.objects.create_and_join(self.organization, "signal-session-viewer@posthog.com", "password")
SessionRecording.objects.create(team=self.team, session_id="signal-sess-shared")

export = ExportedAsset.objects.create(
team=self.team,
export_format="video/mp4",
export_context={"session_recording_id": "signal-sess-shared"},
created_by=owner,
is_system=True,
content=b"videobytes",
)

self.client.force_login(teammate)
response = self.client.get(f"/api/projects/{self.team.id}/exports/{export.id}/content/")
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.content, b"videobytes")
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Tests only cover /content/, missing parameterised retrieve case

Both test_teammate_can_access_system_session_video_export_content and test_teammate_blocked_from_system_video_export_when_no_session_access test only the /content/ endpoint. The existing pattern (used in test_teammate_can_access_other_users_export_by_id, test_cannot_access_export_after_losing_resource_access, and test_cannot_access_session_recording_export_after_losing_access) parameterises over both retrieve and content. Because safely_get_object is shared by both actions, the same RBAC path is exercised either way — but an omitted retrieve case means the test suite doesn't catch a regression where, say, the retrieve serializer bypasses the queryset lookup differently.

Consider parameterising these two new tests the same way the neighbouring tests are written.

Prompt To Fix With AI
This is a comment left during a code review.
Path: posthog/api/test/test_exports.py
Line: 710-729

Comment:
**Tests only cover `/content/`, missing parameterised `retrieve` case**

Both `test_teammate_can_access_system_session_video_export_content` and `test_teammate_blocked_from_system_video_export_when_no_session_access` test only the `/content/` endpoint. The existing pattern (used in `test_teammate_can_access_other_users_export_by_id`, `test_cannot_access_export_after_losing_resource_access`, and `test_cannot_access_session_recording_export_after_losing_access`) parameterises over both `retrieve` and `content`. Because `safely_get_object` is shared by both actions, the same RBAC path is exercised either way — but an omitted `retrieve` case means the test suite doesn't catch a regression where, say, the retrieve serializer bypasses the queryset lookup differently.

Consider parameterising these two new tests the same way the neighbouring tests are written.

How can I resolve this? If you propose a fix, please make it concise.

Comment thread posthog/api/exports.py
Comment on lines 390 to 417
@@ -400,6 +412,8 @@ def safely_get_queryset(self, queryset):
if export_format_filter and export_format_filter in ExportedAsset.get_supported_format_values():
queryset = queryset.filter(export_format=export_format_filter)

return queryset

return queryset
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Superfluous final return queryset

The early return queryset inside the if self.action == "list": block already returns for list actions. The trailing bare return queryset is only reached for non-list actions where the queryset is never mutated. Per the project's simplicity rules this is a superfluous part — collapsing to a single trailing return removes the duplication:

Suggested change
def safely_get_queryset(self, queryset):
"""
List shows only exports you created (quota + history are per user).
Retrieve / content fetch any export in this project by id; safely_get_object still
enforces dashboard, insight, or session recording viewer access when applicable.
"""
if self.action == "list":
queryset = queryset.filter(created_by=self.request.user)
session_recording_filter = self.request.query_params.get("session_recording_id")
if session_recording_filter:
queryset = queryset.filter(
export_context__session_recording_id=session_recording_filter,
)
context_path_filter = self.request.query_params.get("context_path")
if context_path_filter:
queryset = queryset.filter(export_context__path__icontains=context_path_filter)
# Add export format filter
export_format_filter = self.request.query_params.get("export_format")
if export_format_filter and export_format_filter in ExportedAsset.get_supported_format_values():
queryset = queryset.filter(export_format=export_format_filter)
return queryset
Prompt To Fix With AI
This is a comment left during a code review.
Path: posthog/api/exports.py
Line: 390-417

Comment:
**Superfluous final `return queryset`**

The early `return queryset` inside the `if self.action == "list":` block already returns for list actions. The trailing bare `return queryset` is only reached for non-list actions where the queryset is never mutated. Per the project's simplicity rules this is a superfluous part — collapsing to a single trailing return removes the duplication:

```suggestion
    def safely_get_queryset(self, queryset):
        """
        List shows only exports you created (quota + history are per user).

        Retrieve / content fetch any export in this project by id; safely_get_object still
        enforces dashboard, insight, or session recording viewer access when applicable.
        """
        if self.action == "list":
            queryset = queryset.filter(created_by=self.request.user)

            session_recording_filter = self.request.query_params.get("session_recording_id")
            if session_recording_filter:
                queryset = queryset.filter(
                    export_context__session_recording_id=session_recording_filter,
                )

            context_path_filter = self.request.query_params.get("context_path")
            if context_path_filter:
                queryset = queryset.filter(export_context__path__icontains=context_path_filter)

            # Add export format filter
            export_format_filter = self.request.query_params.get("export_format")
            if export_format_filter and export_format_filter in ExportedAsset.get_supported_format_values():
                queryset = queryset.filter(export_format=export_format_filter)

        return queryset
```

How can I resolve this? If you propose a fix, please make it concise.

Comment thread posthog/api/exports.py

return queryset

return queryset
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 RBAC bypass for session-recording exports when SessionRecording DB row is absent

The new queryset scoping lets any authenticated project member reach the safely_get_object RBAC gate for every export in the team. That gate has a fail-open path: when export_context.session_recording_id is set but no SessionRecording row exists in Postgres, resource is None and check_access_level_for_object is never called.

ExportedAsset.id is an auto-increment integer (confirmed in migration 0001), so a project member can enumerate all team exports with sequential GETs against /api/projects/{team}/exports/{id}/content/.

For an EE org with a team-wide session_recording resource-level default of "none" (blocking all recordings by default), any project member can retrieve the raw video bytes of a session-recording export whenever the SessionRecording DB row does not yet exist (e.g. the signals pipeline created the export before the recording was materialised in Postgres, or the recording row was deleted after the export was created—there is no FK cascade since the link is a plain JSON string in export_context).

safely_get_object is the only RBAC gate for non-list actions; _filter_queryset_by_access_level explicitly skips non-list actions (routing.py line 185-188) and check_object_permissions passes through because model_to_resource(ExportedAsset) returns None.

Prompt To Fix With AI
In `ExportedAssetViewSet.safely_get_object`, when `session_recording_id` is set in `export_context` but no `SessionRecording` row is found, fall back to the resource-level RBAC default instead of skipping the check:

```python
if session_recording_id:
    resource = SessionRecording.objects.filter(
        team_id=instance.team_id, session_id=session_recording_id
    ).first()
    if resource is None:
        # No DB row — cannot do an object-level check, but still enforce the
        # team-wide session_recording resource default (which may be "none").
        if not self.user_access_control.check_access_level_for_resource(
            "session_recording", required_level="viewer"
        ):
            raise NotFound()
```

Alternatively, fail closed by raising NotFound() when the row is absent, forcing the calling code to ensure the SessionRecording row is created before the export becomes accessible.

Also consider migrating ExportedAsset.id to a UUID primary key to eliminate sequential enumeration of export IDs across project members.

Severity: medium | Confidence: 70%

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant